网络编程与 HTTP
学习目标
- 区分 URI、HTTP、TCP、UDP、Socket 和应用层消息。
- 能根据请求协议选择
HttpClient、Socket或DatagramSocket。 - 能识别连接超时、读取超时、HTTP 状态、业务失败和半包问题。
核心知识点
专业术语
- URI(Uniform Resource Identifier):描述资源身份和定位信息的统一语法。
- TCP:面向连接的可靠字节流,不保留应用消息边界。
- UDP:面向数据报的无连接协议,保留数据报边界但不保证送达。
- HTTP:运行在 TCP/TLS 之上的请求-响应协议,包含方法、头、状态码和主体。
白话解释与边界
浏览器式接口优先使用 HttpClient,它已经处理请求构造、TLS、连接复用、同步/异步发送等 HTTP 语义;只有自定义文本协议、长连接或教学 TCP 场景才直接使用 Socket。TCP 的一次 read 可能只得到半条消息,必须由应用层设计长度、分隔符或帧格式。HTTP 状态码不是业务结果,2xx 也可能包含业务失败字段。
常用用法
URI:表达 HTTP 资源地址
URI 是结构化地址值,适合传给 HttpRequest;它本身不负责建立连接。
// 说明:URI:表达 HTTP 资源地址。
import java.net.URI;
public class UriDemo {
public static void main(String[] args) {
URI uri = URI.create("https://example.com/api?q=java");
// 关键变化:URI uri = URI.create("https://example.com/api?q=java");;按 "https://example.com/api?q=java" 调用 create 创建值,结果写入 uri。
System.out.println(uri.getScheme() + ":" + uri.getPath());
// 输出:https:/api
}
}不要把用户输入字符串直接拼进 URL,至少先解析并校验 scheme、host 和允许的端口。URI、URL 和 URLConnection 的差别见 Socket 与网络边界。
HttpClient:构建可复用的 HTTP 客户端
客户端可复用以共享连接池;connectTimeout 只限制建立连接,不等于完整请求的读取超时。
// 说明:HttpClient:构建可复用的 HTTP 客户端。
import java.net.http.HttpClient;
import java.time.Duration;
public class HttpClientBuilderDemo {
public static void main(String[] args) {
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
// 关键变化:.connectTimeout(Duration.ofSeconds(3)) .followRedirects(HttpClient.Redirect.NORMAL) .build();;调用 build,实参为 无显式参数;按已配置的超时和重定向策略构建 HttpClient。
// 关键变化:.followRedirects(HttpClient.Redirect.NORMAL) .build();;调用 build,实参为 无显式参数;按已配置的超时和重定向策略构建 HttpClient。
// 关键变化:.build();;调用 build,实参为 无显式参数;完成前面的 builder 配置并返回构造对象。
System.out.println(client.version());
// 输出:HTTP_2
}
}完整请求和响应处理见 HTTP Client 常用 API。
Socket:自定义 TCP 字节流
Socket 只提供连接和字节流,不替你定义消息边界、编码、认证和重试;要看完整回显服务和半关闭行为,阅读 Socket 与网络边界。
// 说明:Socket:自定义 TCP 字节流。
import java.net.InetSocketAddress;
public class SocketAddressDemo {
public static void main(String[] args) {
var address = new InetSocketAddress("127.0.0.1", 8080);
System.out.println(address.getHostString() + ":" + address.getPort());
// 输出:127.0.0.1:8080
}
}HTTP 请求的同步与异步选择
少量调用用同步 send 最直观;需要并发请求且不想阻塞当前线程时用 sendAsync,但仍要处理完成、异常、取消和资源大小边界。
// 说明:HTTP 请求的同步与异步选择。
import java.net.URI;
import java.net.http.HttpRequest;
public class HttpRequestChoiceDemo {
public static void main(String[] args) {
var request = HttpRequest.newBuilder(URI.create("https://example.com"))
.header("Accept", "text/plain")
.GET()
.build();
// 关键变化:var request = HttpRequest.newBuilder(URI.create("https://example.com")) .header("Accept", "text/plain") .GET() .build();;按 "https://example.com" 调用 create 创建值,结果写入 request。
// 关键变化:.header("Accept", "text/plain") .GET() .build();;调用 build,实参为 无显式参数;按已配置的方法、请求头和请求体构建 HttpRequest。
// 关键变化:.GET() .build();;调用 build,实参为 无显式参数;按已配置的方法、请求头和请求体构建 HttpRequest。
// 关键变化:.build();;调用 build,实参为 无显式参数;完成前面的 builder 配置并返回构造对象。
System.out.println(request.method() + " " + request.uri());
// 输出:GET https://example.com
}
}不常用但需要知道
URLConnection:旧式 URL 连接 API
旧代码中仍会看到 URLConnection/HttpURLConnection,但新 HTTP 客户端优先使用 JDK 11+ 的 HttpClient,避免在连接、异步和 HTTP/2 处理上重复造轮子。
import java.net.URI;
public class UrlConnectionBoundaryDemo {
public static void main(String[] args) throws Exception {
var connection = URI.create("https://example.com").toURL().openConnection();
// 关键变化:var connection = URI.create("https://example.com").toURL().openConnection();;按 "https://example.com" 调用 create 创建值,结果写入 connection。
System.out.println(connection.getClass().getSimpleName());
// 输出:HttpURLConnection
}
}DatagramSocket:UDP 数据报
UDP 适合能容忍丢包、重复或乱序的场景;需要可靠顺序、拥塞控制或复杂重试时不要把 UDP 当成“更快的 TCP”。
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.nio.charset.StandardCharsets;
public class UdpPacketDemo {
public static void main(String[] args) throws Exception {
byte[] bytes = "ping".getBytes(StandardCharsets.UTF_8);
var packet = new DatagramPacket(bytes, bytes.length);
System.out.println(packet.getLength());
// 输出:4
try (var socket = new DatagramSocket()) {
// 输入:try (var socket = new DatagramSocket()) {;资源变量 socket 接收 new DatagramSocket(),try 结束时自动关闭。
// 关键变化:try (var socket = new DatagramSocket()) {;创建资源 socket,构造表达式为 new DatagramSocket();try 结束时关闭该资源。
socket.connect("127.0.0.1", 9999);
// 关键变化:socket.connect("127.0.0.1", 9999);;让 socket 连接到 127.0.0.1:9999;连接状态更新。
}
}
}shutdownInput/shutdownOutput:TCP 半关闭
半关闭表示“我不再发送”或“我不再接收”,不等于立即关闭整个连接;只有协议明确支持结束方向时才使用,否则对端可能把 EOF 误判为异常。
// 说明:这里只创建未连接的 socket;连接后 shutdownOutput() 会向对端发送 EOF,而输入方向仍可继续接收字节。
import java.net.Socket;
public class HalfCloseDemo {
public static void main(String[] args) throws Exception {
try (Socket socket = new Socket()) {
// 输入:try (Socket socket = new Socket()) {;资源变量 socket 接收 new Socket(),try 结束时自动关闭。
// 关键变化:try (Socket socket = new Socket()) {;创建资源 socket,构造表达式为 new Socket();try 结束时关闭该资源。
System.out.println(socket.isClosed());
// 输出:false
// 连接建立后可分别调用 socket.shutdownOutput() 或 shutdownInput()
}
}
}Proxy 与代理边界
代理是连接路径的配置,不是业务重试或认证策略;生产环境还要明确代理凭证、TLS 终止位置和内网地址校验。
// 说明:proxy 把 HTTP 连接路径指向 127.0.0.1:8080;URI 仍表示目标 https 资源,不携带代理凭证。
import java.net.Proxy;
import java.net.URI;
public class ProxyBoundaryDemo {
public static void main(String[] args) {
var proxy = new Proxy(Proxy.Type.HTTP, new java.net.InetSocketAddress("127.0.0.1", 8080));
System.out.println(proxy.type() + " " + URI.create("https://example.com").getScheme());
// 输出:HTTP https
}
}简单案例
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public class NetworkDemo {
public static void main(String[] args) throws Exception {
var client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3))
.build();
// 关键变化:.connectTimeout(Duration.ofSeconds(3)) .build();;调用 build,实参为 无显式参数;按已配置的超时和重定向策略构建 HttpClient。
// 关键变化:.build();;调用 build,实参为 无显式参数;完成前面的 builder 配置并返回构造对象。
var request = HttpRequest.newBuilder(URI.create("https://example.com"))
.timeout(Duration.ofSeconds(5))
.header("Accept", "text/html")
.GET()
.build();
// 关键变化:var request = HttpRequest.newBuilder(URI.create("https://example.com")) .timeout(Duration.ofSeconds(5)) .header("Accept", "text/html") .GET() .build();;按 "https://example.com" 调用 create 创建值,结果写入 request。
// 关键变化:.timeout(Duration.ofSeconds(5)) .header("Accept", "text/html") .GET() .build();;调用 build,实参为 无显式参数;按已配置的方法、请求头和请求体构建 HttpRequest。
// 关键变化:.header("Accept", "text/html") .GET() .build();;调用 build,实参为 无显式参数;按已配置的方法、请求头和请求体构建 HttpRequest。
// 关键变化:.GET() .build();;调用 build,实参为 无显式参数;按已配置的方法、请求头和请求体构建 HttpRequest。
// 关键变化:.build();;调用 build,实参为 无显式参数;完成前面的 builder 配置并返回构造对象。
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode());
// 输出:200
}
}该案例需要网络可达;生产请求还应显式处理非 2xx、响应大小、字符集、TLS 证书、取消和幂等重试。
易混点
- URI 是地址值,URL/URLConnection 是旧式访问 API,HttpClient 才是现代 HTTP 请求客户端。
- TCP 提供字节流,不提供消息边界;UDP 提供数据报边界,但不保证送达。
- 连接超时、请求超时、读取超时和业务超时不是同一个概念,应分别设定。
- HTTP
2xx只表示协议层成功,响应体仍可能表示业务失败;网络异常也不等于 HTTP 状态码。
课后小问
为什么不能把一次
Socket.read()当成一条完整消息? 答案:TCP 只保证字节顺序,不保留发送方的消息边界。 解析:应用协议必须定义长度、分隔符或帧头,并循环读取到完整消息。HttpClient的连接超时和HttpRequest的 timeout 分别解决什么问题? 答案:前者限制建立连接,后者限制一次请求操作的总等待范围。 解析:DNS、TLS、服务器处理和响应读取还可能各自耗时,不能只配置一个数字就覆盖所有故障。
本节小结
- HTTP 请求优先使用可复用的
HttpClient,自定义协议才直接操作 Socket。 - URI、连接、协议状态和业务结果应分层处理。
- TCP 需要应用层消息边界,UDP 需要接受丢包和乱序风险。
- 超时、取消、响应体关闭和幂等性决定网络代码是否可靠。
快速回顾
HttpRequest描述请求,HttpClient发送请求,HttpResponse携带状态和主体。Socket是字节流连接,不是完整消息队列。- HTTP 重试必须考虑方法幂等性和退避。
- 网络代码要区分连接失败、协议失败和业务失败。
阿源的知识库