# JDK HTTP Client 完全指南:从入门到实战 ## 前言 Java 11 之前,JDK 自带的 HTTP 客户端只有 HttpURLConnection,又笨又难用,很多人干脆用 Apache HttpClient。Java 11 终于把 HttpClient 内置进了 `java.net.http`:支持 HTTP/2,同步异步都有,日常够用了。 本文基于 Java 17,围绕一个本地运行的 User Management API(`http://localhost:8080`)做实战演示。每个知识点配三样东西:文字说明、示例代码、JUnit 测试。 > 运行示例/测试前,请确保 API 服务已在本机 8080 端口启动。 --- ## 一、快速入门:请求的构造与响应处理 HttpClient 的用法其实就一句话:构造请求,发出去,处理响应。核心 API 记三个就够了: | 类 | 职责 | |----|------| | `java.net.http.HttpClient` | HTTP 客户端,负责发送请求、管理连接 | | `java.net.http.HttpRequest` | 请求对象,描述 URI、方法、请求头、请求体 | | `java.net.http.HttpResponse` | 响应对象,携带状态码、响应头和响应体 | 最小化流程分四步: 1. 创建客户端:`HttpClient.newHttpClient()` 用 JDK 默认配置创建实例; 2. 构造请求:`HttpRequest.newBuilder().uri(...).GET().build()` 链式构建; 3. 发送请求:`client.send(request, BodyHandlers.ofString())` 同步阻塞发送; 4. 处理响应:通过 `HttpResponse` 获取 `statusCode()`、`body()` 等。 > 注意:`send()` 会抛出 `IOException`(IO 失败)和 `InterruptedException`(线程被中断),需要显式处理或向上抛出。 ### 示例代码 完整的例子在 `QuickStart.java`: ```java // 1. 创建 HttpClient:newHttpClient() 使用 JDK 默认的配置创建一个客户端 HttpClient httpClient = HttpClient.newHttpClient(); // 2. 构造请求:HttpRequest.newBuilder() 返回一个 Builder,链式配置请求 HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(BASE_URL + "/api/users")) // 设置请求的目标地址 .GET() // 指定请求方法为 GET .build(); // 结束构建,返回不可变对象 // 3. 发送请求:send() 同步阻塞,BodyHandlers.ofString() 将响应体转为字符串 HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); // 4. 处理响应 System.out.println("HTTP 状态码: " + response.statusCode()); System.out.println("响应体: " + response.body()); ``` ### 测试验证 见 `QuickStartTest.java`。测试会真实调用本地 API,运行后控制台打印响应结果。 (测试目标:GET `http://localhost:8080/api/users`,正常情况下返回 `{"code":200,"message":"OK","data":[...]}`) --- ## 二、GET 请求 GET 是最常用的 HTTP 方法,用来从服务器拿数据,不带请求体。用 JDK HTTP Client 发 GET 请求,常见写法有三种: 1. 不带参数的 GET:`.uri(url)` + `.GET()`,比如查用户列表; 2. 带路径参数的 GET:把 id 拼进 URL 路径,比如查单个用户 `/api/users/1`; 3. 带查询字符串的 GET:`?key=value` 跟在 URI 后面,服务端按条件过滤。 | 要点 | 说明 | |------|------| | `.GET()` | 显式声明请求方法;省略时默认也是 GET,但显式写出更清晰 | | 路径参数 | 直接拼在 URL 中,如 `/api/users/{id}` | | 查询参数 | 拼在 `?` 之后,多个用 `&` 连接 | | 无请求体 | GET 请求使用 `BodyPublishers.noBody()`(默认),无需设置请求体 | | 响应码 | 200 找到资源;404 资源不存在 | ### 示例代码 代码都在 `GetExample.java` 里: ```java // 不带参数的 GET:查询所有用户 HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(BASE_URL + "/api/users")) .GET() .build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); // 带路径参数的 GET:根据 id 查询单个用户 HttpRequest request2 = HttpRequest.newBuilder() .uri(URI.create(BASE_URL + "/api/users/" + id)) .GET() .build(); HttpResponse response2 = httpClient.send(request2, HttpResponse.BodyHandlers.ofString()); // 带查询字符串的 GET(标准写法演示) URI uri = URI.create(BASE_URL + "/api/users?account=alice01"); ``` ### 测试验证 见 `GetExampleTest.java`,测试点包括: - `getUsers`:GET `/api/users` 返回 200,且响应体含 `code` 包装字段; - `getUserById_notExists`:GET `/api/users/999999999` 返回 404; - `buildRequestWithQuery`:验证带 `?account=alice01` 查询参数的请求 URI 拼接正确,且 GET 请求体为空。 --- ## 三、POST / PUT / DELETE 请求 ### 3.1 POST 请求 POST 用来向服务器提交数据、创建资源,和 GET 最大的区别是带请求体。提交 JSON 的标准流程: 1. 序列化:把 Java 对象转成 JSON 字符串(教程里用 Jackson 的 `ObjectMapper`); 2. 设置请求头:`Content-Type: application/json`,告诉服务器请求体格式; 3. 构造请求体:`BodyPublishers.ofString(json)` 把字符串包装成请求体; 4. 声明方法:`.POST(publisher)` 指定请求方法和请求体; 5. 处理响应:服务端返回统一包装 `Result`,用 `TypeReference` 反序列化拿到具体数据对象。 | 要点 | 说明 | |------|------| | `.POST(BodyPublishers.ofString(json))` | POST 方法必须携带请求体发布器 | | `BodyPublishers.ofString` | 将字符串作为请求体;另有 ofInputStream/ofByteArray 等 | | 状态码 200 | 创建成功(该服务返回 200) | | 状态码 400 | 请求体格式不正确 / 缺少必填字段 | | 状态码 409 | 账号已存在,创建冲突 | > 注意:为便于演示,本项目引入了 Jackson(`jackson-databind`)。JDK HttpClient 本身不关心请求体是 JSON 还是其他格式,序列化的职责由调用方承担。 #### 示例代码 例子在 `PostExample.java`: ```java // 1. 使用 Jackson 把对象序列化为 JSON 字符串 String json = objectMapper.writeValueAsString(user); // 2. 构造 POST 请求 HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(BASE_URL + "/api/users")) .header("Content-Type", "application/json") // 声明请求体是 JSON .POST(BodyPublishers.ofString(json)) // 指定请求体 .build(); // 3. 发送请求 HttpResponse response = HttpClient.newHttpClient().send(request, BodyHandlers.ofString()); // 4. 反序列化统一包装结构 Result Result result = objectMapper.readValue(response.body(), new TypeReference>() { }); ``` 配套模型类位于 `model/` 包下:`UserRequest`(请求体)、`UserVO`(用户响应)、`Result`(统一包装)。 #### 测试验证 见 `PostExampleTest.java`,测试点包括: - `createUser_success`:POST 唯一账号返回 200,响应体含 code 与账号; - `createUserAndGetUser_success`:解析出创建后的用户对象,账号与请求一致; - `createUser_duplicateAccount`:相同账号重复创建返回 409。 > 测试使用 `System.nanoTime()` 生成带时间戳的唯一账号,避免与历史数据冲突。 ### 3.2 PUT 请求 PUT 用来整体更新已有资源,写法跟 POST 几乎一样:同样带 JSON 请求体,同样用 `BodyPublishers.ofString`。唯一的区别是把 `.POST(...)` 换成 `.PUT(...)`,目标地址改成带 id 的 `/api/users/{id}`。 | 要点 | 说明 | |------|------| | `.PUT(BodyPublishers.ofString(json))` | PUT 快捷方法,必须携带请求体发布器 | | 路径参数 | id 拼在 URL 中,如 `/api/users/1` | | 状态码 200 | 更新成功 | | 状态码 404 | 用户不存在 | | 状态码 409 | 账号与其他用户冲突 | > PUT 与 POST 在 HTTP 语义上的区别:POST 是「创建新资源」,PUT 是「整体替换已有资源」。发送代码唯一的差别就是 `.POST(...)` / `.PUT(...)` 方法名不同。 #### 示例代码 见 `PutDeleteExample.java`,核心代码如下: ```java // PUT 请求:更新 id=1 的用户,请求体为更新后的完整用户信息 String json = objectMapper.writeValueAsString(user); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(BASE_URL + "/api/users/" + id)) .header("Content-Type", "application/json") .PUT(BodyPublishers.ofString(json)) // 与 POST 唯一的区别 .build(); ``` #### 测试验证 见 `PutDeleteExampleTest.java`,测试点包括: - `updateUser_success` / `updateUserAndGetUser_success`:更新成功返回 200,解析出的账号与请求一致; - `updateUser_notExists`:更新不存在的用户返回 404; - `buildPutRequest_hasBody`:构造的 PUT 请求 method 为 "PUT",且携带请求体。 ### 3.3 DELETE 请求 DELETE 用来删除资源,和 GET 一样不带请求体,把 id 拼进路径即可。Builder 提供了 `.DELETE()` 快捷方法,内部用的就是 `BodyPublishers.noBody()`。 | 要点 | 说明 | |------|------| | `.DELETE()` | DELETE 快捷方法,不传请求体 | | 路径参数 | id 拼在 URL 中,如 `/api/users/1` | | 状态码 200 | 删除成功(服务端返回 Result\) | | 状态码 404 | 用户不存在 | #### 示例代码 看 `PutDeleteExample.java` 里的 DELETE 部分: ```java HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(BASE_URL + "/api/users/" + id)) .DELETE() // 不携带请求体 .build(); HttpResponse response = HttpClient.newHttpClient().send(request, BodyHandlers.ofString()); ``` #### 测试验证 见 `PutDeleteExampleTest.java`,测试点包括: - `deleteUser_success`:先创建再删除,返回 200; - `deleteUser_notExists`:删除不存在的用户返回 404; - `buildDeleteRequest_noBody`:构造的 DELETE 请求 method 为 "DELETE",且无请求体(bodyPublisher 为空)。 --- ## 四、请求头 先澄清一个误区:JDK 的 `HttpClient.Builder` 没有「设置默认请求头」的方法。(可用 `javap --module java.net.http java.net.http.HttpClient$Builder` 验证,Builder 只提供 `cookieHandler / connectTimeout / executor / followRedirects / priority / proxy / authenticator / sslContext / version` 等配置。) 所以请求头只能在 HttpRequest 构造阶段配置。JDK 给了三个 API: | 方法 | 作用 | |------|------| | `.header(key, value)` | 追加一个请求头(同名头可存在多个) | | `.headers(k1, v1, k2, v2, ...)` | 一次追加多组请求头,参数个数必须为偶数 | | `.setHeader(key, value)` | 与 header 不同,会**覆盖**已存在的同名头 | 「Client 级默认头」的变通方案:既然 HttpClient 不能配置默认头,一份通用的做法是提供一个工厂方法,统一返回「已预置公共请求头」的 `HttpRequest.Builder`,同一个客户端发出的请求都能带上公共头(比如 `User-Agent`、`Accept`),集中管理。 响应头读取:`response.headers()` 返回 `HttpHeaders` 对象,常用方法:`firstValue(name)`(返回 `Optional`)、`allValues(name)`(返回 `List`)。 ### 示例代码 见 `HeaderExample.java`: ```java // 单个请求头 HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(BASE_URL + "/api/users")) .header("Accept", "application/json") .GET() .build(); // 多个请求头:key/value 成对出现,个数为偶数 HttpRequest request2 = HttpRequest.newBuilder() .uri(URI.create(BASE_URL + "/api/users")) .headers( "Content-Type", "application/json", "Accept", "application/json", "X-Request-Id", "tutorial-001" ) .GET() .build(); // setHeader 覆盖同名头:最终 X-Version 只有一个值 2 HttpRequest request3 = HttpRequest.newBuilder() .uri(URI.create(BASE_URL + "/api/users")) .header("X-Version", "1") .setHeader("X-Version", "2") .GET() .build(); // 读取响应头 String contentType = response.headers() .firstValue("Content-Type") .orElse("unknown"); ``` 工厂方法预置默认请求头的变通方案: ```java public HttpRequest.Builder newBuilderWithDefaultHeaders() { return HttpRequest.newBuilder() .header("User-Agent", "JDK-HttpClient-Tutorial/1.0") .header("Accept", "application/json"); } ``` ### 测试验证 见 `HeaderExampleTest.java`,测试点包括: - `singleHeader`:`.header()` 设置单个头,可正常读出; - `multipleHeaders`:`.headers()` 一次设置三组头; - `setHeaderOverrides`:`.setHeader()` 覆盖后同名字只有一个值; - `defaultHeaderBuilder`:工厂方法预置的公共头可正常读出; - `sendRequestWithHeaders`:带请求头发送 GET 返回 200。 --- ## 五、请求体 请求体由 `BodyPublisher` 描述,通过 `POST(BodyPublisher)`(PUT/PATCH 也一样)传给请求构造器。JDK 内置了多种实现: | 发布器 | 说明 | 典型场景 | |--------|------|----------| | `BodyPublishers.ofString(String)` | 字符串请求体 | JSON 字符串提交(最常用) | | `BodyPublishers.ofByteArray(byte[])` | 字节数组请求体 | 手工构建的二进制/复杂格式体 | | `BodyPublishers.ofInputStream(Supplier)` | 输入流请求体 | 大文件流式上传,避免整块载入内存 | | `BodyPublishers.ofFile(Path)` | 文件请求体 | 直接以文件为请求体 | | `BodyPublishers.noBody()` | 无请求体 | GET/DELETE 等无体请求 | multipart/form-data 文件上传:JDK HttpClient 没有内置 multipart 支持,按 RFC 2046 规范手工拼请求体即可,格式是这样: ```text --boundary\r\n Content-Disposition: form-data; name="file"; filename="report.txt"\r\n Content-Type: text/plain\r\n \r\n (文件内容)\r\n --boundary--\r\n ``` 注意:`Content-Type` 请求头里的 boundary 和请求体里用的是同一个,两边不一致的话服务器就没法正确切分字段。 ### 示例代码 代码见 `BodyExample.java`: ```java // ofString:字符串 JSON 请求体 HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(BASE_URL + "/api/users")) .header("Content-Type", "application/json") .POST(BodyPublishers.ofString(json)) // <- 字符串请求体 .build(); // ofInputStream:输入流请求体(懒加载) ByteArrayInputStream stream = new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)); HttpRequest request2 = HttpRequest.newBuilder() .uri(URI.create(BASE_URL + "/api/users")) .header("Content-Type", "application/json") .POST(BodyPublishers.ofInputStream(() -> stream)) .build(); // 手工构建 multipart/form-data 请求体(文件上传) String boundary = "----WebKitFormBoundary" + UUID.randomUUID(); byte[] body = buildMultipartBody(fileName, fileBytes, boundary); HttpRequest request3 = HttpRequest.newBuilder() .uri(URI.create(BASE_URL + "/api/files/upload")) .header("Content-Type", "multipart/form-data; boundary=" + boundary) .POST(BodyPublishers.ofByteArray(body)) .build(); ``` ### 测试验证 见 `BodyExampleTest.java`,测试点包括: - `createUser_withJsonString`:ofString 提交 JSON 创建用户,返回 200; - `createUser_viaInputStream`:ofInputStream 提交 JSON 创建用户,返回 200; - `uploadFile`:上传文本文件,`FileVO` 返回的原始文件名与大小一致; - `uploadFile_empty`:上传空文件返回 400; - `noBodyRequest`:GET 请求对象无 bodyPublisher。 --- ## 六、文件下载 下载和上传相反:服务器把文件内容按二进制流返回。HttpClient 本身不关心响应体怎么消费,区别全在传入的响应处理器上。常用的有三种: | 处理器 | 得到的内容 | 适用场景 | |--------|-----------|----------| | `BodyHandlers.ofFile(Path)` | 直接把响应体写入本地文件 | 大文件下载,不占堆内存 | | `BodyHandlers.ofByteArray()` | `byte[]` | 文件较小,想整体读入内存处理 | | `BodyHandlers.ofInputStream()` | `InputStream` | 流式读取,边读边处理/转发 | 下载要分两步: 1. 先上传拿文件名:调用 `POST /api/files/upload` 得到 `FileVO`,其中的 `storedFileName` 就是服务器磁盘上的文件名; 2. 再按文件名下载:GET `/api/files/download/{storedFileName}`,服务器把文件内容作为二进制流返回。 > 响应头里的 `Content-Disposition` 携带 `attachment` 标记与原文件名,可通过 `response.headers().firstValue("Content-Disposition")` 读取;注意本服务返回的文件名是 storedFileName 去掉扩展名后的部分。 ### 示例代码 见 `FileDownloadExample.java`,核心代码如下: ```java // 方式一:ofFile — 直接落盘到本地路径 HttpResponse resp = httpClient.send(request, HttpResponse.BodyHandlers.ofFile(target)); // 方式二:ofByteArray — 读入内存得到 byte[] HttpResponse resp2 = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()); // 方式三:ofInputStream — 以输入流形式消费 HttpResponse resp3 = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); ``` 下载 URL 的构造同 GET:`BASE_URL + "/api/files/download/" + storedFileName`。 ### 测试验证 见 `FileDownloadExampleTest.java`,测试点包括: - `downloadToFile_success`:下载内容写入指定本地文件,内容与服务端一致; - `downloadAsBytes_success`:下载得到 byte[],内容一致; - `downloadAsStream_success`:从 InputStream 读出的内容一致; - `readContentDisposition_present`:响应头含 `Content-Disposition` 且带 `attachment` 与原文件名; - `uploadThenDownload_roundTrip`:上传后再下载,字节完全一致(round-trip); - `download_notExists`:下载不存在的文件返回 404。 > 每个测试都先调用上传接口生成临时文件、拿到 `storedFileName` 再下载,结束后清理临时文件。 --- ## 七、同步与异步 HttpClient 有同步、异步两种发送方式: 1. 同步 `send()`:阻塞当前线程直到收到完整响应。 ```java HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); ``` - 直接返回 `HttpResponse`; - 需处理 `IOException`(网络/IO 失败)与 `InterruptedException`(线程中断); - 适用于请求少的场景,简单直观。 2. 异步 `sendAsync()`:立即返回 `CompletableFuture`,请求在内部线程池里执行,不阻塞调用线程。 ```java CompletableFuture> future = httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()); HttpResponse response = future.join(); // 阻塞式获取,或: future.thenApply(resp -> ...); // 回调式处理,不阻塞 ``` - 获取结果有两种方式: - `join()`:阻塞直到完成(异常以 `CompletionException` 抛出); - 回调链:`thenApply` / `whenComplete` / `thenCompose` 等 `CompletableFuture` API; - `CompletableFuture.allOf(...)` 可等待多个并发请求全部完成,非常适合批量并发。 | 对比项 | send() | sendAsync() | |--------|--------|-------------| | 阻塞 | 阻塞当前线程 | 不阻塞 | | 返回 | HttpResponse | CompletableFuture\ | | 异常 | IOException / InterruptedException | CompletionException | | 适用 | 少量串行请求 | 批量、并发、回调链 | ### 示例代码 以 `SyncAsyncExample.java` 为例: ```java // 同步发送 HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); // 异步发送 + join 阻塞取结果 CompletableFuture> future = httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()); HttpResponse result = future.join(); // 异步发送 + 回调链(不阻塞主线程) httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()) .thenApply(resp -> "状态码=" + resp.statusCode() + ", body=" + abbreviate(resp.body())); // 批量并发:并发发起 N 个请求,全部完成后汇总 List>> futures = IntStream.range(0, count) .mapToObj(i -> httpClient.sendAsync(buildGetRequest(), HttpResponse.BodyHandlers.ofString())) .collect(Collectors.toList()); CompletableFuture all = CompletableFuture.allOf( futures.toArray(new CompletableFuture[0])); all.join(); return futures.stream() .map(f -> f.join().statusCode()) .collect(Collectors.toList()); ``` ### 测试验证 见 `SyncAsyncExampleTest.java`,测试点包括: - `sendSync`:同步发送返回 200; - `sendAsync`:异步 + join 返回 200; - `sendAsyncWithCallback`:回调链得到处理结果(以 `状态码=200,` 开头); - `sendAsyncInParallel`:并发 10 个请求全部成功(状态码均为 200)。 --- ## 八、响应处理器 `BodyHandler` 决定响应体怎么消费:拿到响应头(状态码等)之后,它返回一个 `BodySubscriber`,后者把响应体的字节流转成目标类型 T。发送时作为第二个参数传入:`client.send(request, bodyHandler)`。 JDK 内置的响应处理器(BodyHandlers): | 处理器 | 响应体类型 | 适用场景 | |--------|-----------|----------| | `BodyHandlers.ofString()` | String | 文本/JSON 响应(最常用) | | `BodyHandlers.ofByteArray()` | byte[] | 二进制内容 | | `BodyHandlers.ofFile(Path)` | Path | 大文件下载,直接落盘 | | `BodyHandlers.ofInputStream()` | InputStream | 流式读取 | | `BodyHandlers.discarding()` | Void | 只关心状态码,body() 为 null | | `BodyHandlers.ofLines()` | Stream\ | 逐行处理 | 自定义 BodyHandler 只需实现 `apply(ResponseInfo)`,返回一个 `BodySubscriber` 就行: - 按状态码分流:2xx 正常读取,非 2xx 用 `BodySubscribers.replacing(null)` 丢弃响应体,body() 返回 null; - 响应后处理:用 `BodySubscribers.mapping(upstream, fn)` 把上游订阅器的结果再转一次(比如加个前缀、组装业务对象)。 > 提示:自定义 `BodyHandler` 通常与 `BodySubscribers.ofString` / `ofByteArray` 组合使用,实现「先读字节流、再按业务逻辑解析」的能力,是接入统一响应包装结构的标准方式。 ### 示例代码 见 `ResponseHandlerExample.java`,核心代码如下: ```java // 内置:ofString HttpResponse resp = client.send(req, BodyHandlers.ofString()); // 内置:ofFile(下载直接落盘) HttpResponse resp2 = client.send(req, BodyHandlers.ofFile(target)); // 自定义:按状态码分流(2xx 正常读取,非 2xx 丢弃响应体) BodyHandler handler = responseInfo -> { if (responseInfo.statusCode() >= 200 && responseInfo.statusCode() < 300) { return BodySubscribers.ofString(StandardCharsets.UTF_8); } return BodySubscribers.replacing(null); }; // 自定义:响应后处理(加前缀标记) BodySubscriber upstream = BodySubscribers.ofString(StandardCharsets.UTF_8); BodyHandler handler2 = responseInfo -> BodySubscribers.mapping(upstream, body -> "[TAG] " + body); ``` ### 测试验证 见 `ResponseHandlerExampleTest.java`,测试点包括: - `getAsString` / `getAsByteArray` / `getAsFile` / `getAsInputStream`:验证各内置处理器行为; - `getDiscarded`:discarding 处理器 body() 为 null; - `customHandler_success`:自定义 mapping 处理器为响应体加前缀 `[MY-TAG]`; - `customHandler_404DiscardsBody`:自定义处理器在 404 时 body() 为 null; - `statusBasedHandler_successString`:按状态码分流的处理器 2xx 正常返回。 --- ## 九、HTTP Client 配置项 `HttpClient.newBuilder()` 返回的 Builder 用来配置客户端的通用行为。配置在 `build()` 之后就不能改了,所以要一次设好。用到的配置项: | 配置项 | 作用 | 备注 | |--------|------|------| | `.version(HTTP_1_1 / HTTP_2)` | 协议版本 | | | `.connectTimeout(Duration)` | 连接建立超时 | 超时抛 ConnectTimeoutException | | `.executor(Executor)` | 异步请求线程池 | 默认内置线程池 | | `.followRedirects(NEVER/ALWAYS/NORMAL)` | 重定向策略 | NORMAL 只跟随 GET 等安全方法 | | `.proxy(ProxySelector)` | 代理选择器 | `ProxySelector.NO_PROXY` 直连 | | `.cookieHandler(CookieHandler)` | Cookie 管理器 | 通常搭配 `CookieManager` | | `.authenticator(Authenticator)` | 认证器 | Basic Auth 等 | | `.sslContext / .sslParameters` | TLS 配置 | 仅 HTTPS 生效 | | `.priority(int)` | HTTP/2 流优先级 | 范围 1~256,仅 HTTP_2 生效 | ### 示例代码 看 `ClientConfigExample.java`: ```java CookieManager cookieManager = new CookieManager(); HttpClient client = HttpClient.newBuilder() .version(HttpClient.Version.HTTP_1_1) // 协议版本 .connectTimeout(Duration.ofSeconds(5)) // 连接超时 .executor(Executors.newFixedThreadPool(4)) // 异步线程池 .followRedirects(HttpClient.Redirect.NORMAL) // 重定向策略 .proxy(ProxySelector.getDefault()) // 代理 .cookieHandler(cookieManager) // Cookie 管理 .build(); ``` 配置生效后能用 getter 读出来校验,比如 `client.version()`、`client.connectTimeout()`、`client.followRedirects()`。 ### 测试验证 见 `ClientConfigExampleTest.java`,测试点包括: - `buildConfiguredClient_hasExpectedValues`:校验 version / connectTimeout / followRedirects / executor / cookieHandler 配置均生效; - `buildHttp2Client_defaultsToHttp2`:HTTP_2 客户端版本正确; - `sendGet_withConfiguredClient`:配置后的客户端请求返回 200; - `connectTimeout_returns200OrThrows`:连接不可达主机时抛出预期异常。 --- ## 十、HTTP Request 配置项 `HttpRequest.newBuilder()` 用来构造单个请求。可配项如下,注意它们和 HttpClient 级配置互相独立,请求级的优先级更高: | 配置项 | 作用 | 备注 | |--------|------|------| | `.uri(URI)` | 请求地址 | 必填 | | `.timeout(Duration)` | **整个请求**的超时 | 与 client 的 connectTimeout(仅连接阶段)不同 | | `.version(HTTP_1_1/HTTP_2)` | 请求级协议版本 | 覆盖 client 的 version | | `.expectContinue(true)` | 期望服务器先返回 100-continue | 标志位存储,发送时才生成 Expect 头 | | `.GET()/.POST(pub)/.PUT(pub)/.DELETE()` | 标准请求方法 | | | `.method(name, publisher)` | 自定义请求方法 | PATCH、HEAD 等 | | `.header / .headers / .setHeader` | 请求头 | | | `.copy()` | 复制 Builder | 写时复制,修改副本不影响原 Builder | `.timeout` 与 `.connectTimeout` 的区别 | | connectTimeout(Client 级) | timeout(Request 级) | |---|---------------------------|-----------------------| | 作用阶段 | 建立 TCP 连接 | 从发送到拿到完整响应体 | | 超时抛错 | `ConnectTimeoutException` | `HttpTimeoutException` | ### 示例代码 看 `RequestConfigExample.java`: ```java // 请求级超时(整个请求 3 秒) HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(BASE_URL + "/api/users")) .timeout(Duration.ofSeconds(3)) .GET() .build(); // 期望继续:提交大请求体前先「询价」 HttpRequest request2 = HttpRequest.newBuilder() .uri(URI.create(BASE_URL + "/api/users")) .header("Content-Type", "application/json") .expectContinue(true) .POST(BodyPublishers.ofString(json)) .build(); // 自定义请求方法 HttpRequest request3 = HttpRequest.newBuilder() .uri(URI.create(BASE_URL + "/api/users")) .method("PATCH", BodyPublishers.noBody()) .build(); // 复制 Builder(写时复制,修改副本不影响原 Builder) HttpRequest.Builder original = HttpRequest.newBuilder() .uri(uri).header("X-Original", "yes"); HttpRequest copied = original.copy() .header("X-Copy", "yes").GET().build(); ``` ### 测试验证 见 `RequestConfigExampleTest.java`,测试点包括: - `timeout_configPresent`:请求级 timeout 可读,且为 3 秒; - `version_override`:请求级版本配置生效; - `expectContinue_flagSet`:expectContinue 标志位置为 true(Expect 头在发送阶段生成,headers() 中读取不到); - `customMethod`:method("PATCH", noBody) 生效; - `copyBuilder_isIndependent`:修改副本不影响原 Builder; - `sendWithTimeout`:带请求级超时的请求正常返回 200; - `timeoutExpired_throwsExpected`:不可达地址 + 短超时抛出预期异常。 --- ## 十一、HTTP Client 核心对象及 API 速览 把前面的示例都过一遍,你会发现核心对象其实只有四个,搞清它们就够用了: | 核心对象 | 职责 | 获取方式 | |----------|------|----------| | `HttpClient` | 发送请求、管理连接与配置 | `HttpClient.newHttpClient()` / `HttpClient.newBuilder()` | | `HttpRequest` | 描述一次请求(URI、方法、头、体) | `HttpRequest.newBuilder().build()` | | `HttpResponse` | 一次请求的结果(状态码、头、体) | `client.send(...)` 的返回值 | | `HttpHeaders` | 请求/响应的头部集合 | `request.headers()` / `response.headers()` | ### HttpClient - `newHttpClient()` / `newBuilder()`:创建客户端; - `send(request, bodyHandler)` / `sendAsync(...)`:同步/异步发送; - `version()` / `connectTimeout()` / `followRedirects()` / `executor()` / `proxy()` / `cookieHandler()`:读取创建时配置的各项值。 ### HttpRequest 与 Builder - Builder:`.uri()`、`.header()/headers()/setHeader()`、`.timeout()`、`.version()`、`.expectContinue()`、`.GET()/.POST()/.PUT()/.DELETE()`、`.method()`、`.copy()`,最后 `.build()` 产出不可变请求; - Request:`.method()`、`.uri()`、`.timeout()`、`.headers()`(返回 `HttpHeaders`)、`.bodyPublisher()`(`Optional`)。 ### HttpResponse\ - `.statusCode()`、`.uri()`、`.request()`、`.version()`; - `.headers()`(返回 `HttpHeaders`)、`.body()`(目标类型 T); - `.previousResponse()`:重定向场景下返回上一次响应。 ### HttpHeaders - `.firstValue(name)`:返回 `Optional`,取第一个同名值; - `.allValues(name)`:返回 `List`,取全部同名值; - `.map()`:返回底层 `Map>`; - `equals/hashCode/toString` 等常规方法。 ### 示例代码 参考 `CoreApiExample.java`: ```java // 1. 创建客户端 HttpClient client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(5)) .build(); System.out.println("version=" + client.version()); System.out.println("connectTimeout=" + client.connectTimeout()); // 2. 构造请求 HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(BASE_URL + "/api/users")) .header("Accept", "application/json") .timeout(Duration.ofSeconds(3)) .GET() .build(); // 3. 查看请求对象 System.out.println("method=" + request.method()); HttpHeaders reqHeaders = request.headers(); reqHeaders.firstValue("Accept"); // Optional request.bodyPublisher().isEmpty(); // true(GET 无体) // 4. 发送并查看响应对象 HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println("statusCode=" + response.statusCode()); System.out.println("responseUri=" + response.uri()); // 响应头常用 API HttpHeaders respHeaders = response.headers(); respHeaders.firstValue("Content-Type").orElse("unknown"); respHeaders.map(); // Map> ``` ### 测试验证 见 `CoreApiExampleTest.java`,测试点包括: - `showCoreApis_runsAgainstServer`:真实发请求验证所有核心对象 API 可访问; - `httpRequest_hasExpectedFields`:method/uri/headers 读取正确,同名头用 `allValues` 返回全部值; - `httpHeaders_firstAndAllValues`:`firstValue` 与 `allValues` 行为验证。 --- ## 总结 JDK HTTP Client 的 API 就那么几件套:`HttpClient`、`HttpRequest`、`HttpResponse`、`HttpHeaders`,加上 `BodyHandlers` 和 `BodyPublishers` 的几个实现。流程上永远是「构建请求 → 发送 → 处理响应」,没有更多花样。 日常开发里我的建议是: - 简单请求用同步 `send()`,直观,好调试; - 批量、高并发的场景换 `sendAsync()` + `CompletableFuture`,把线程池用起来; - 想统一处理响应(比如都得反序列化 `Result`),写个自定义 `BodyHandler`,别每次请求都手写一遍; - 所有请求都要带公共头时,用工厂方法返回一个预置好请求头的 Builder。 示例代码在 `src/main/java/space/anyi/httpClient/`,测试在 `src/test/java/space/anyi/httpClient/`。跑之前记得先启动 API 服务。