qinghuan
← Java SE / 全部笔记

单元 59 / Java SE

P1 · 重点提升L2 · 进阶

网络编程:TCP 字节流与 HTTP 客户端

分清连接成功、收到完整消息和业务成功,再用带超时与状态检查的 HTTP 客户端发送请求。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能发送有超时与状态检查的 HTTP 请求,并按失败层次决定后续动作。

学习理由、准备要求与知识点

为什么学它

调用外部服务需要分清连接、响应与重试后果,不能只判断请求是否抛异常。

先读这些内容

知识点清单

  • Socket 字节流
  • 应用消息边界
  • 连接与请求超时
  • HttpClient 复用
  • HTTP 状态检查
  • DNS/TLS/网络异常
  • 同步与异步发送
  • 有副作用操作重试

运行前先确认Java 17;源码在本机 Microsoft OpenJDK 17.0.20.1 src.zip 核验,在线链接为上游 jdk-17.0.20-ga。深入模块可在掌握基础后回读。

01先弄懂它是什么

用 Socket 读网络数据时,拿到的是连续字节;ServerSocket 负责监听并接收连接。发送方写了两次,接收方不一定正好读两次,所以协议要规定长度、分隔符或其他消息边界。即使已经连上,也不保证下次读取就有完整消息;连接提前关闭时可能只收到一半,程序需要发现并报告。

直接调用 HTTP 接口时,Java17 的 HttpClient 提供同步、异步请求和连接复用。HttpRequest 写明地址、方法、请求头和正文,HttpResponse 返回状态码、头和内容。收到 400 或 500 等状态码,不一定会抛 Java 异常;请求发出以后仍要检查状态和正文,再决定业务上算成功还是失败。

构造简单公开请求时可使用 HttpRequest.newBuilder(URI.create("https://example.com/")).timeout(Duration.ofSeconds(5)).GET().build(),再由 HttpClient 发送。

这里是构造片段,需要导入对应类型,不包含凭证。Java17 HttpClient 不是 AutoCloseable,通常复用客户端;若正文采用输入流处理器,则应按响应体的资源契约及时关闭。

文中这几个词,先认一认

消息分帧
规定连续字节中一条消息从哪里开始、到哪里结束,例如先写长度再写正文。
连接超时
尝试建立网络连接最多允许等多久,超出后按失败处理。
请求超时
HTTP 请求等待响应等阶段的时间限制;正文若以流读取,还要单独核对处理器的行为。
响应体处理器
决定响应正文按字符串、字节、文件或输入流等方式交给调用者的处理规则。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

连不上服务器,与连上后一直等不到数据,是两种等待。Socket.connect 的超时限制前一种,setSoTimeout 限制读取时阻塞多久;HTTP 客户端的连接超时,也不能包办整次请求和正文处理。

改用异步 API,只是让调用方先拿到一个“结果以后完成”的对象,并没有免去线程、连接等资源的占用,取消操作也有成本。仍应给整个调用设定期限,并分别观察查地址、连接、TLS、等待响应和读正文用了多久。

收到一串字节,怎样知道一条消息到哪里结束?下面的教学协议先放四个字节说明正文长度,再放正文;长度按“大端”顺序存放,也就是较高位的字节在前,并按无符号数解释,不把最高位当作负号。这个小模型帮助你分清只收到半条消息、连续收到多条消息和长度越界。HTTP 已有自己的消息含义和各版本格式,应交给成熟客户端处理,不能把这个自定格式当作 HTTP。

对不可信长度先验证再分配,避免把四字节字段直接转成负数组长度或巨额内存申请。帧长度零可以合法表示空消息,但仍消耗头部字节,因此解析继续前进。图把头部完整性、长度上限和正文完整性分开,任何一项不满足都应返回明确失败。

正在加载流程图…

自定义教学帧只解释 TCP 字节流边界;HTTP 使用其自身规范和客户端实现。
查看流程图源码(Mermaid)
flowchart TD
 A["接收连续字节"] --> B{"是否至少有四字节头部"}
 B -->|"否且非空"| C["截断错误"]
 B -->|"是"| D["按大端读取无符号长度"]
 D --> E{"长度在协议上限内"}
 E -->|"否"| F["拒绝长度"]
 E -->|"是"| G{"剩余正文是否完整"}
 G -->|"否"| C
 G -->|"是"| H["提取一帧并继续解析后续字节"]

按顺序看,每一步做了什么

  1. 选择 Socket 协议还是成熟 HTTP 客户端。
  2. 明确消息边界和最大长度。
  3. 分别设置连接、请求与正文处理预算。
  4. 处理状态码、异常和提前结束。
  5. 复用适当客户端并关闭正文资源。

03对照实现代码

请求构建器如何限制 HTTP URI 形状 (新标签页)↗

Microsoft OpenJDK 17.0.20.1;java.net.http/jdk/internal/net/http/HttpRequestBuilderImpl.java · jdk.internal.net.http.HttpRequestBuilderImpl#checkURI(URI)

以下为原创 Java 17 教学实现,不是原样 JDK 源码;只保留文中明确讨论的机制,省略优化、兼容及内部接口。

核心机制代码 · java
import java.net.URI;
public final class SourceModel {
    public static URI checked(URI uri) {
        if (uri == null) throw new NullPointerException("uri");
        String scheme = uri.getScheme();
        if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme))
            throw new IllegalArgumentException("scheme");
        if (uri.getHost() == null) throw new IllegalArgumentException("host");
        // 额外教学策略:不接受在 URI 用户信息部分嵌入身份数据。
        if (uri.getRawUserInfo() != null) throw new IllegalArgumentException("userinfo");
        return uri;
    }
}

真实 HttpRequestBuilderImpl.checkURI 检查 URI 是否有合适主机,以及方案是否为 http 或 https。模型使用公开 URI 方法表达这些主要检查,并额外拒绝 userinfo;这一额外规则是教学输入约束,不声称真实 JDK 方法完全同样处理。它不执行 DNS 或连接测试。

URI 语法正确不代表目标可达,主机字段存在也不表示证书有效或请求已授权。构建阶段主要拒绝不适用输入,后续网络与应用验证仍要分别进行。把输入校验成功当作网络调用成功,会混淆多个独立失败阶段。

请求对象将方法、头和正文描述交给客户端执行,构建器不是通用浏览器导航器。重定向、代理和认证等行为需要按客户端配置和协议核对,不能让任意外部 URL 自动继承应用凭证。示例只构造公开无凭证目标,重点是明确 URI 与请求生命周期。

该源码位于 JDK 内部 HTTP 实现包,只用于阅读定位。应用应使用公开 HttpRequest 与 HttpClient API,不导入内部构建器或绕过模块导出限制。实现类名和内部路径可能随版本变化,版本化引用帮助学习机制,不应成为业务编译依赖。

代码先调用谁,再调用谁

  1. HttpRequest.newBuilder 接收 URI
  2. 内部构建器调用 checkURI
  3. 检查方案与主机等结构
  4. 构建不可变请求描述
  5. HttpClient 后续负责发送并交付响应

04动手写一个例子

解析四字节长度前缀的完整字节快照

输入一段已收集字节,解析零个或多个教学帧:四字节大端无符号长度加正文。长度不得超过 maxFrame,头部或正文不完整则抛 IOException,允许零长度正文,不修改输入。

输入 1
长度1+A,长度2+BC
输出 1
两帧A与BC
输入 2
空字节数组
输出 2
零帧
输入 3
长度3但只有两字节正文
输出 3
截断错误

用 ByteBuffer 显式设置大端顺序,读取头部 int 后转换为 unsigned long,避免高位为一的长度变成负数。先与 maxFrame 比较,确认可接受后再转成数组长度。读取正文前还要比较剩余字节数,防止把截断输入当成完整数据。

循环持续到没有剩余字节。若还有一到三字节,说明存在不完整头部;若恰好一帧结束则正常完成。每轮至少消费四字节头部,即使正文长度为零也会推进位置,因此不会出现空帧导致的无限循环。

本题输入是完整快照,截断立即失败。真实网络逐块到达时,未完整未必等于永久错误,需要保留部分头部和正文状态,等更多数据或确认连接结束后再判断。把快照解析与增量解析分开,能够让每个算法的输入前提清楚可测。

为什么这样写是对的每轮开始时,缓冲位置之前的字节已被完整且唯一地分成合法帧,当前位置是下一帧头部或输入末尾。

时间 O(n),n 为输入总字节数。额外空间 O(n) 输出正文副本,加 O(f) 帧列表,f 为帧数。

每个头部读取一次,每个正文复制一次;长度检查发生在分配前。整个快照已经在输入内存中,因此这不是固定内存的实时网络解码器。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 49 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.io.ByteArrayOutputStream;
import java.io.EOFException;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
public class Main {
    static List<byte[]> decode(byte[] wire, int maxFrame) throws IOException {
        if (wire == null || maxFrame < 0) throw new IllegalArgumentException("input");
        ByteBuffer input = ByteBuffer.wrap(wire).order(ByteOrder.BIG_ENDIAN);
        List<byte[]> frames = new ArrayList<>();
        while (input.hasRemaining()) {
            if (input.remaining() < 4) throw new EOFException("header");
            long length = Integer.toUnsignedLong(input.getInt());
            // 无符号长度先验上限,不能直接作为有符号数组长度。
            if (length > maxFrame) throw new IOException("frame limit");
            if (length > input.remaining()) throw new EOFException("body");
            byte[] body = new byte[(int)length];
            input.get(body);
            frames.add(body); // 每个正文独立复制;字节数组本身仍可修改。
        }
        return List.copyOf(frames);
    }
    static byte[] frame(String text) {
        byte[] body = text.getBytes(StandardCharsets.UTF_8);
        return ByteBuffer.allocate(4+body.length).putInt(body.length).put(body).array();
    }
    static void reject(byte[] wire, int max) {
        try { decode(wire,max); } catch (IOException e) { return; }
        throw new AssertionError("must reject");
    }
    public static void main(String[] args) throws Exception {
        ByteArrayOutputStream joined = new ByteArrayOutputStream();
        joined.writeBytes(frame("A")); joined.writeBytes(frame("BC"));
        List<String> texts = decode(joined.toByteArray(),10).stream()
                .map(b -> new String(b,StandardCharsets.UTF_8)).toList();
        if (!texts.equals(List.of("A","BC"))) throw new AssertionError(texts);
        // 零帧与一条空帧是不同结果,分别验证。
        if (!decode(new byte[0],10).isEmpty()) throw new AssertionError();
        if (decode(frame(""),0).size() != 1) throw new AssertionError();
        reject(new byte[]{0,0},10);
        reject(new byte[]{0,0,0,3,1,2},10);
        reject(new byte[]{-1,-1,-1,-1},100);
        reject(frame("ABC"),2);
        System.out.println("PASS 7");
    }
}

这些特殊情况也要试一试

  • 空输入代表零帧。
  • 零长度正文仍是一条帧。
  • 高位长度按无符号值处理并受上限约束。
  • 残缺头部和正文分别报告截断。
进阶选读 · 可选深入:增量解码与背压

场景连接持续到达碎片字节,不能先保存完整流再解析。

做法维护读头部与读正文两个状态,跨读取保留位置,限制待处理总字节和帧队列容量;连接结束时再判断残缺状态。

复杂度累计处理 O(n),内存由最大帧与队列上限决定。

好处与代价状态更多且需要取消、超时和背压策略,不能只把快照解析循环套在每次 read 外面。

05项目里怎么用

连续调用同一批接口时,通常复用 HTTP 客户端,让它管理和复用连接,不必每次重新创建。接收正文也要选合适方式:小文本可用 ofString,它会把正文收进内存再组成字符串;大内容可考虑边收边处理,但仍要限制大小,并按所用接口的约定及时消费和关闭响应流。收到文本不等于调用成功,还要分别检查 HTTP 状态、Java 异常和正文中的业务错误。

重试前先判断请求可能做到哪一步。连不上,和已经发送却等不到响应,含义不同;后者可能已在服务端完成修改。写操作应有固定操作编号、避免重复效果的处理或结果查询,并限制整组尝试的总时间,不能每次重试都重新获得完整等待额度。

TLS 验证与目标地址管理应保持客户端公开安全默认能力。遇到证书或主机名错误应核对目标、信任配置和证书状态,而不是直接关闭校验。日志记录可包含阶段、状态和请求标识,避免输出认证头、完整敏感正文或嵌入 URI 的身份数据。

容易踩的坑

  • 把一次 read 当成一条完整消息。
  • 未校验长度就按网络输入分配数组。
  • 连接超时被误当作整个请求和正文超时。
  • HTTP错误状态码未检查,直接当业务成功。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
Socket 自定义协议完全控制消息格式与交互需自己管理分帧、超时与兼容确有专用传输协议需求
同步 HttpClient调用链清楚且易于处理结果等待期间占用调用线程普通服务调用与可控并发
异步 HttpClient可组合并发请求完成阶段取消、异常和资源管理更复杂有明确并发与延迟需求的调用

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01TCP 为什么会出现半包或多条连读?

展开参考答案与推导

它提供连续字节流,读取批次由缓冲、网络与调度决定,不保留应用每次写入的边界。应用需要长度、分隔符或成熟协议来重建消息,而不是根据 read 次数判断。

02HTTP四百状态是否一定抛 Java异常?

展开参考答案与推导

通常仍是一个有效 HTTP 响应,客户端交付状态和正文供应用处理。网络或协议失败与应用返回错误状态是不同情况,调用者必须检查状态及业务约定。

03为什么先把长度转换成无符号 long?

展开参考答案与推导

四字节协议字段可能把最高位作为数值位,直接 int 会得到负数。转换后再做上限检查,才能明确拒绝过大长度而不是触发意外负数组长度或错误分支。

换个情况,还会处理吗

01客户端超时后服务端却创建了订单,为什么?

展开参考答案与推导

超时表示客户端在预算内没有获得所需结果,不证明服务端未执行。重试需要幂等标识或查询处理结果,不能把传输失败简单解释为业务完全没发生。

02小响应正常,大响应内存明显增加,先看哪里?

展开参考答案与推导

检查正文处理器是否一次性缓冲为字符串或字节数组,以及是否有内容上限。需要时改成受控流式消费并关闭资源,不能仅调大堆内存而忽略不受限输入。

03自定义解析器每块都报截断,问题可能是什么?

展开参考答案与推导

把每次网络读取块当成完整快照处理了。增量接收应保留未完成头部和正文,等待后续字节;只有确认输入结束或超过期限后,残缺状态才成为最终失败。

写代码试一试

01验证多个空帧不会卡住

输入 / 条件
连续两个零长度头部
输出 / 预期
两条空帧

提示 每轮至少消耗四字节。

展开参考答案与推导

拼接两个 frame("") 并断言结果大小为二,每个正文长度为零。它检验循环进展由头部消费保证,而不是错误依赖正文长度必须为正。

02为公开 GET 构造请求

输入 / 条件
https://example.com/
输出 / 预期
GET请求描述

提示 构造不等于发送。

展开参考答案与推导

使用 HttpRequest.newBuilder(URI.create(...)).timeout(Duration.ofSeconds(5)).GET().build(),导入 URI、Duration 与 HttpRequest。这个片段只创建描述,发送还需客户端及响应处理,不应把构造成功称为联网成功。

03增加输入隔离测试

输入 / 条件
解析一帧A后修改原wire正文
输出 / 预期
结果仍为A

提示 正文由新数组保存。

展开参考答案与推导

解析后把原字节数组对应正文改成其它字节,再断言返回正文未变。结果列表不可修改不等于里面 byte[] 不可修改,测试与文档都要分别说明这两个层次。

读完之后,独立完成

课后作业:自己做一遍

本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能发送有超时与状态检查的 HTTP 请求,并按失败层次决定后续动作。

本篇重点加练

重点练消息完整性与超时范围,避免接收一段字节就提前认定一次调用完成。

  • 应用消息边界
  • 连接与请求超时

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01原理问答

    发送端依次写入 [A,B] 与 [C,D,E] 两组字节。为接收端设计三种可能的读取分块,说明消息协议还需要提供哪些信息才能判定完整请求。

    提交内容
    分块图与协议需求清单。
    怎样算完成
    • 应用消息与读取调用分开
    • 包含未完整到达的情况

    练到的知识点Socket 字节流 · 应用消息边界

  2. 作业 02设计评审

    个人学习助手每次请求都创建 HttpClient,连接预算 1 秒、整体调用预算 3 秒。设计复用方式和超时观测点,列出执行过程中需要额外限定的资源。

    提交内容
    客户端生命周期图与超时合同。
    怎样算完成
    • 连接阶段与请求阶段分开
    • 复用边界与配置相符

    练到的知识点连接与请求超时 · HttpClient 复用

综合应用

  1. 作业 03排错实验

    用本地可控 HTTP 服务返回 200、404、500,并模拟延迟响应;记录 HttpClient 的状态结果与异常路径。DNS/TLS 故障仅设计测试方案,不必访问外部网站。

    提交内容
    本地实验、自写状态与超时测试、实际记录。
    怎样算完成
    • HTTP 失败和传输失败分别分类
    • 实验有退出与清理机制

    练到的知识点HTTP 状态检查 · DNS/TLS/网络异常

  2. 作业 04面试场景

    异步提交订单后调用方取消等待,随后再次提交。根据“未收到结果”这一事实列出服务器可能处于的状态,规定客户端随后可以怎样查询结果。

    提交内容
    状态时间线和重试/查询政策。
    怎样算完成
    • 取消等待与撤销副作用分开
    • 不同失败阶段有不同证据需求

    练到的知识点同步与异步发送 · 有副作用操作重试

  3. 作业 05编程实践

    实现增量长度前缀帧解析器,输入以任意小块提供:头部可被拆成四次,两个帧可同时到达,载荷长度最多 1024;明确结束时残缺帧的处理。

    提交内容
    Java 实现、自写所有切分位置及边界测试、运行证据。
    怎样算完成
    • 解析不依赖输入分块
    • 长度与缓冲资源有界
    • 未完成和非法输入区分

    练到的知识点Socket 字节流 · 应用消息边界

  4. 作业 06编程实践

    实现内存分块接收模型:消息以换行结束,单条最长 64 字节,测试输入可中途停顿;注入虚拟时间并定义整体接收预算,不建立公网连接。

    提交内容
    Java 模型、自写分块/超长/超时/多消息测试与运行证据。
    怎样算完成
    • 消息完成条件可核验
    • 超时起算与重置规则明确

    练到的知识点应用消息边界 · 连接与请求超时

  5. 作业 07设计评审

    协议允许消息正文含换行,同时还要支持空正文。比较可选消息边界格式,给出双方误解同一字节序列的歧义检查。

    提交内容
    协议候选和代表性字节样本。
    怎样算完成
    • 所有合法正文都能表示
    • 长度限制与非法输入合同明确

    练到的知识点应用消息边界

  6. 作业 08面试场景

    连接很快建立,服务却每隔 800ms 返回一个字节。产品要求调用最多等待 3 秒,列出需验证的超时类型和客户端测量点。

    提交内容
    阶段时间线与预算验收。
    怎样算完成
    • 局部活动与整体完成分开
    • 测试有确定终止条件

    练到的知识点连接与请求超时

进阶挑战 · 掌握前置后选做

  1. 作业 09设计评审

    学完并发队列后:批量抓取一百个本地模拟端点,最多四个同时执行。设计超时、取消、部分失败和结果顺序合同,并说明如何检验实际并发上限。

    提交内容
    调度设计、资源预算与验收矩阵。
    怎样算完成
    • 并发数量有测量证据
    • 失败不造成无界任务或资源泄露

    练到的知识点连接与请求超时 · HttpClient 复用 · 同步与异步发送 · 有副作用操作重试

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 条件变化

    短请求协议变成长时间流式生成,用户希望随时取消但保留已收到的完整片段。重新设计完成、超时、取消和片段边界之间的关系。

    提交内容
    流式状态图与事件序列测试规格。
    怎样算完成
    • 部分结果合同明确
    • 超时政策对应新的业务时长

    练到的知识点应用消息边界 · 连接与请求超时

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗