Trước Java 11, gọi một API HTTP bằng thư viện chuẩn nghĩa là HttpURLConnection — một API từ năm 1997, không hỗ trợ HTTP/2, không bất đồng bộ, và khó dùng tới mức gần như mọi dự án đều kéo thêm Apache HttpClient hoặc OkHttp vào chỉ để gọi vài endpoint.

java.net.http.HttpClient thay hẳn chuyện đó. Bài này là những gì nó làm được, cộng ba chỗ tôi thấy người dùng hay vấp.

Toàn bộ số liệu dưới đây đo trên một máy chủ HTTP chạy cục bộ trong cùng tiến trình, để kết quả tái lập được và không phụ thuộc mạng.

Gọi một cái GET

HttpClient client = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(5))
        .followRedirects(HttpClient.Redirect.NORMAL)
        .build();

HttpResponse<String> r = client.send(
        HttpRequest.newBuilder(URI.create(goc + "/xin-chao")).build(),
        HttpResponse.BodyHandlers.ofString());
  mã trạng thái : 200
  content-type  : text/plain; charset=utf-8
  thân          : chào từ máy chủ
  phiên bản     : HTTP_1_1

Ba đối tượng, mỗi cái một việc: HttpClient giữ cấu hình và tài nguyên dùng chung, HttpRequest là một yêu cầu bất biến, HttpResponse là kết quả.

Chú ý followRedirects. Mặc định là NEVER — nghĩa là gặp 301 hay 302 thì bạn nhận về đúng mã đó với thân rỗng chứ không phải nội dung trang đích. Đây là chỗ vấp đầu tiên, vì hầu hết thư viện khác theo chuyển hướng mặc định. NORMAL theo chuyển hướng nhưng không hạ cấp từ HTTPS xuống HTTP — đó là lựa chọn đúng gần như mọi lúc.

POST và các kiểu thân yêu cầu

HttpRequest.newBuilder(URI.create(goc + "/vong-lai"))
        .header("Content-Type", "application/json")
        .POST(HttpRequest.BodyPublishers.ofString("{\"ten\":\"Minh\"}"))
        .build();
  mã : 200
  máy chủ nhận được : {"ten":"Minh"}

BodyPublishers có sẵn ofString, ofFile, ofByteArray, ofInputStreamnoBody. Chiều ngược lại, BodyHandlersofString, ofByteArray, ofLines (trả Stream<String>), discarding, và một cái rất tiện:

client.send(req, HttpResponse.BodyHandlers.ofFile(Path.of("tai-ve.txt")));
  ghi vào tai-ve.txt (21 byte)

Tải thẳng ra tệp, không qua bộ nhớ. Với tệp lớn thì đây là khác biệt giữa chạy được và OutOfMemoryError — cùng nguyên tắc "đừng nạp cả tệp vào RAM" ở bài về Files.lines.

Thứ không có sẵn là JSON. HttpClient không biết gì về JSON, nên bạn tự ghép với Jackson. Với tôi đó là thiết kế đúng: một lớp làm HTTP, một lớp làm JSON.

Hai loại timeout, và cái bạn hay quên

Đây là chỗ vấp thứ hai, và là chỗ nguy hiểm nhất trong bài.

HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5))   // thời gian BẮT TAY
HttpRequest.newBuilder(uri).timeout(Duration.ofMillis(300))     // thời gian CẢ LỜI GỌI

connectTimeout chỉ tính lúc mở kết nối TCP. Kết nối xong rồi mà máy chủ ngồi im mãi thì nó không có tác dụng gì cả.

Đặt timeout trên request:

  HttpTimeoutException: request timed out

Không đặt, với một endpoint ngủ 3 giây rồi mới trả lời:

  chờ 3.0 giây rồi mới xong

Ba giây ở đây là vì máy chủ thử nghiệm chỉ ngủ ba giây. Với một máy chủ thật bị treo, con số đó là vô hạn — luồng của bạn nằm đó cho tới khi hệ điều hành bỏ cuộc.

Luôn đặt timeout trên request, không chỉ connectTimeout trên client. Một dịch vụ bên thứ ba chậm mà không có phép chờ sẽ ăn dần hết luồng xử lý của bạn, và triệu chứng người dùng thấy không phải là "API kia hỏng" mà là "cả trang web của bạn không vào được".

Đây đúng là bài học đã ghi trong CLAUDE.md của chính blog này về SMTP_TIMEOUT_MS, gặp lại ở một giao thức khác.

Mã lỗi HTTP không phải ngoại lệ

Chỗ vấp thứ ba:

  gọi tới endpoint trả 404 -> không ném gì, statusCode = 404

send() chỉ ném ngoại lệ khi không hoàn thành được lời gọi: không phân giải được tên miền, không mở được kết nối, hết giờ chờ. Máy chủ trả lời 404, 500 hay 503 đều là lời gọi thành công — bạn đã hỏi, và bạn đã nhận được câu trả lời.

Nên một đoạn try/catch quanh send() không hề bảo vệ bạn khỏi lỗi phía máy chủ. Phải kiểm mã trạng thái tường minh:

if (r.statusCode() / 100 != 2) {
    throw new IllegalStateException("API trả " + r.statusCode() + ": " + r.body());
}

Tôi khuyên gói lời gọi vào một phương thức nhỏ làm sẵn việc này, vì quên kiểm mã trạng thái là loại lỗi im lặng đúng kiểu khó chịu nhất: bạn đưa thân của một trang lỗi HTML vào bộ phân tích JSON, và thông báo lỗi cuối cùng chẳng liên quan gì tới nguyên nhân.

sendAsync: chỗ nó đáng giá nhất

Sáu lời gọi độc lập, mỗi lời gọi mất khoảng 200 ms:

  tuần tự (send)          :   1475 ms
  song song (sendAsync)   :    249 ms

Gần sáu lần. Và đây không phải chuyện tối ưu vi mô — nó là chuyện một trang hiển thị trong một phần tư giây thay vì một giây rưỡi.

Cách viết:

List<CompletableFuture<HttpResponse<String>>> fs = ds.stream()
        .map(u -> client.sendAsync(HttpRequest.newBuilder(u).build(),
                                   HttpResponse.BodyHandlers.ofString()))
        .toList();
CompletableFuture.allOf(fs.toArray(new CompletableFuture[0])).join();

Điểm cần để ý: .toList() phải nằm ở đó, và đây là cái bẫy tôi muốn nhấn mạnh nhất của mục này.

Stream xử lý từng phần tử đi hết chuỗi thao tác rồi mới sang phần tử sau. Nên nếu bạn viết liền một mạch:

ds.stream()
  .map(u -> client.sendAsync(...))
  .map(CompletableFuture::join)     // <- chờ ngay tại đây
  .toList();

thì phần tử thứ nhất được gửi đi rồi chờ xong, sau đó mới tới phần tử thứ hai. Cùng sáu lời gọi ấy:

  map(sendAsync).map(join)          : 1272 ms
  toList() rồi mới join             :  247 ms

Hơn năm lần, và mã chỉ khác nhau ở chỗ đặt một dấu chấm. Nó vẫn chạy đúng, vẫn trả về sáu kết quả, nên không có gì báo cho bạn biết phần bất đồng bộ đã bị vô hiệu hoàn toàn.

Quy tắc để nhớ: gửi hết trước, chờ sau. toList() ép gửi hết; chỉ khi đó join mới là chờ song song.

Xâu chuỗi và bắt lỗi:

client.sendAsync(req, HttpResponse.BodyHandlers.ofString())
        .thenApply(HttpResponse::body)
        .thenApply(String::toUpperCase)
        .exceptionally(ex -> "hỏng: " + ex.getMessage())
        .join();
  CHÀO TỪ MÁY CHỦ

Nhưng phải biết mã của bạn đang chạy ở đâu:

  thenApply chạy trên luồng : ForkJoinPool.commonPool-worker-1

Mặc định các bước thenApply chạy trên pool chung của ForkJoin — cùng cái pool mà parallelStream dùng, và nó có số luồng bằng số nhân trừ một. Đặt một lời gọi chặn hay một truy vấn cơ sở dữ liệu vào đó là bạn đang lấy tài nguyên của toàn bộ ứng dụng. Cần việc nặng thì truyền executor riêng bằng thenApplyAsync(fn, executor).

Hoặc bỏ hẳn bất đồng bộ, dùng luồng ảo

Java 21 có luồng ảo, và nó làm cho phần lớn lý do dùng sendAsync biến mất:

try (var es = Executors.newVirtualThreadPerTaskExecutor()) {
    for (URI u : ds) es.submit(() -> client.send(HttpRequest.newBuilder(u).build(), bh).body());
}
  6 lời gọi song song bằng luồng ảo : 47 ms

Cùng mức song song, nhưng mã là send() đồng bộ thẳng tuột — đọc từ trên xuống, try/catch hoạt động bình thường, dấu vết ngăn xếp khi lỗi chỉ về đúng dòng gây lỗi thay vì một mớ khung CompletableFuture.

Lời khuyên của tôi cho mã mới trên Java 21: viết send() đồng bộ, chạy trên luồng ảo. Để dành sendAsync cho lúc bạn thật sự cần ghép nối các bước bằng CompletableFuture, hoặc khi mã phải chạy trên bản Java cũ hơn.

Một client dùng chung cho cả ứng dụng

  luồng lúc đầu           : 6
  sau khi tạo 50 client   : 56

Đúng một luồng thêm cho mỗi HttpClient, cộng một pool kết nối riêng. Tạo client trong mỗi lần gọi là rò rỉ tài nguyên chậm rãi — và trên máy chủ có tải, đây là loại lỗi chỉ lộ ra sau vài ngày chạy.

Nên khai một lần và dùng lại. HttpClient an toàn luồng:

private static final HttpClient CLIENT = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(5))
        .followRedirects(HttpClient.Redirect.NORMAL)
        .build();

Java 21 bổ sung một thứ mà bốn phiên bản trước không có:

  AutoCloseable : true
  có phương thức close() : có

Từ Java 21, HttpClient cài AutoCloseable, nên dùng được trong try-với-tài-nguyên và giải phóng được luồng của nó. Trước đó, một client đã tạo sẽ giữ luồng cho tới khi bộ thu gom rác dọn, và không có cách nào yêu cầu nó dừng — một chi tiết gây phiền cho các ứng dụng tạo client theo từng phiên làm việc.

Với client dùng chung suốt vòng đời ứng dụng thì không cần đóng. Với client tạo tạm thì hãy đóng.

Khi nào vẫn nên dùng thư viện ngoài

HttpClient đủ cho việc gọi API, tải tệp, và phần lớn tích hợp. Nó hỗ trợ HTTP/2 với ghép kênh, WebSocket, xác thực, cookie.

Ba chỗ nó chưa bằng:

Thử lại tự động. Không có sẵn. Phải tự viết vòng lặp có lùi theo cấp số nhân, hoặc dùng Resilience4j.

Chặn giữa (interceptor). Không có khái niệm này, nên ghi log, thêm header xác thực hay đo thời gian đều phải tự gói lại trong phương thức của mình.

Đa phần biểu mẫu (multipart). Không có BodyPublisher sẵn — phải tự dựng phần thân với chuỗi ngăn cách. Làm được nhưng lắt nhắt; tải tệp lên thường xuyên thì OkHttp gọn hơn nhiều.

Còn ở tầng framework, Spring có RestClientWebClient bọc bên ngoài, thêm ánh xạ JSON, chặn giữa và xử lý lỗi. Trong ứng dụng Spring thì dùng chúng; trong thư viện hay công cụ dòng lệnh, HttpClient trần là đủ và không kéo theo phụ thuộc nào.

Thử ba mươi giây

Tìm trong dự án của bạn mọi chỗ gọi HTTP ra ngoài, rồi kiểm hai thứ:

.timeout(...) trên request không — connectTimeout trên client không thay được nó.

Có kiểm statusCode() không, hay đang cho rằng send() trả về là mọi thứ ổn.

Hai câu hỏi đó bắt được gần hết các sự cố "hệ thống chậm không rõ lý do" mà tôi từng phải đi tìm.

Ngày mai xuống một tầng nữa: JDBC — Connection, PreparedStatement, ResultSet, và vì sao nối chuỗi để dựng câu SQL là mời sẵn SQL injection vào nhà.