Reading 25: 网络编程(Networking)
Reading 25: 网络编程(Networking)
说明:本讲 sp22 原版使用 TypeScript(用 Express 写 Web 服务器),本笔记按用户要求提供 Java 代码示例;类型/API 与 sp21(6.031 Java 版,Reading 24: Sockets & Networking)原文保持一致。凡属 Java 生态的额外补充(如
HttpServer、线程池、Socket.setSoTimeout),均明确标注为「补充说明」。
概述
本讲讨论通过网络进行的客户端/服务器通信(client/server communication over the network):客户端主动连接服务器、发送请求、接收应答、断开连接;服务器可以同时服务许多客户端。核心设计原则有两条:其一,网络通信本质上就是并发的,因此客户端与服务器都必须推理其并发行为并做到线程安全;其二,客户端与服务器之间交换的字节序列必须被设计,正如我们为 ADT 设计操作一样——这个设计产物叫做线路协议(wire protocol)或 Web API。它与三大目标的关系是:Safe from bugs(协议与并发设计正确、异常与超时被处理、编码与 flush 不出错)、Easy to understand(文本协议可以人工阅读调试,套接字代码与业务代码分离)、Ready for change(协议带版本号、用接口隔离 MIDI/网络实现、把流抽象成可替换的参数)。
核心概念与设计原则详解
客户端/服务器设计模式(Client/Server Design Pattern)
- 定义与目的:一种用消息传递(message passing)进行通信的设计模式。其中有两类进程:客户端(client)主动发起通信,连接服务器、发送请求(request)、接收应答(reply)、最后断开;服务器(server)等待连接并应答。它解决的是”两个独立进程如何在不共享内存的前提下协作”的问题,直接关系到 Safe from bugs(跨进程边界必须有清晰的协议与错误处理)与 Ready for change(服务器可以换实现而不影响客户端)。
- 直观解释(”它是什么?”):像餐厅点菜。顾客(客户端)主动走进餐厅、看菜单点菜(请求)、等菜上桌(应答)、吃完走人(断开)。厨房(服务器)不主动找顾客,但可以同时服务很多桌。浏览器是 Web 服务器的客户端,Outlook 是邮件服务器的客户端。
- 关键规则与最佳实践:
- 发起方永远是客户端:连接由客户端建立,服务器只负责
accept;这决定了谁负责处理”连不上”的错误。 - 客户端与服务器不必在不同机器上:服务器完全可以与客户端在同一台机器上(用
localhost连接),这对测试极其重要。 - 一个服务器同时服务多个客户端,一个客户端也可以连多个服务器,因此两端都需要处理并发。
- 客户端与服务器之间不共享内存,只能交换字节序列,所以必须约定消息格式(协议),而不能像同进程线程那样共享对象。
- 发起方永远是客户端:连接由客户端建立,服务器只负责
IP 地址、主机名与 DNS(IP Addresses, Hostnames, and DNS)
- 定义与目的:网络接口(network interface)由 IP 地址标识;IPv4 地址是 32 位数,写成四个 8 位部分,如
18.9.22.69。主机名(hostname)是可以被翻译成 IP 地址的名字,如web.mit.edu;翻译工作由 DNS(Domain Name System)完成。它关系到 Easy to understand(人记名字,机器用数字)与 Ready for change(同一个主机名可以在不同时间映射到不同 IP,客户端代码无需修改)。 - 直观解释(”它是什么?”):IP 地址像门牌号,主机名像”某某大厦”这种好记的名字,DNS 就是电话簿/导航 App:你报名字,它给你门牌号。
- 关键规则与最佳实践:
- 用
dig +short web.mit.edu、host、nslookup亲自验证名字到地址的翻译。 127.0.0.1是 loopback / localhost 地址,永远指本机;严格说首字节为 127 的地址都是 loopback,但127.0.0.1是标准写法。- 同一个主机名可能映射到不同 IP,多个主机名也可能映射到同一个 IP,因此不要在程序里硬编码 IP 地址,要写主机名。
- 笔记本换网络环境(换 WiFi)时 IP 地址会变,这说明 IP 地址是”位置”而不是”身份”。
- 用
端口号(Port Numbers)
- 定义与目的:一台机器上可能同时跑多个服务器程序,因此需要把同一网络接口上的流量分派给不同进程。网络接口有多个端口(port),由 16 位数标识;端口 0 被保留,因此端口号实际范围是 1–65535。它关系到 Safe from bugs(避免连错进程)与 Easy to understand(标准端口是常识)。
- 直观解释(”它是什么?”):IP 地址是大厦地址,端口号是大厦里的房间号。大厦(主机)只有一个地址,但里面有很多房间(服务)。
- 关键规则与最佳实践:
- 服务器进程 bind(绑定)到某个端口后即在该端口 listening(监听);同一时刻一个端口只能有一个监听者,第二个进程去监听同一端口会失败(Java 中抛
BindException)。 - 客户端必须知道服务器监听哪一个端口号,这是连接的必要信息之一。
- 记住常用端口:22 = SSH,25 = 邮件(SMTP),80 = HTTP。
http://web.mit.edu实际上是在18.9.22.69的 80 端口上交谈。 - 非标准端口写在 URL 里:
http://128.2.39.10:9000表示该机器的 9000 端口。
- 服务器进程 bind(绑定)到某个端口后即在该端口 listening(监听);同一时刻一个端口只能有一个监听者,第二个进程去监听同一端口会失败(Java 中抛
套接字:监听套接字与连接套接字(Sockets: Listening vs Connected)
- 定义与目的:套接字(socket)表示客户端与服务器之间连接的一端。监听套接字(listening socket)由服务器用来等待远端客户端连接;连接套接字(connected socket)用来与连接另一端的进程收发消息。它给出了一条清晰的抽象边界:网络层的复杂性被封装在套接字之后,客户端与服务器只需读写字节流。
- 直观解释(”它是什么?”):像 USB 插口。监听套接字是空着的 USB 口,连接套接字是插着线的 USB 口;一条线有两个头,所以连接涉及两个套接字——客户端一个、服务器一个。
- 关键规则与最佳实践:
- 注意物理类比不准确的地方:真实 USB 口插上线就不再空着,但监听套接字在接完一个客户端后依然存在,仍然绑定同一端口,随时可以
accept下一个客户端。这是”服务器能同时服务多个客户端”的机制根源。 - 在 Java 中:服务器用
new ServerSocket(port)建监听套接字,用accept()拿到连接套接字;客户端用new Socket(hostname, port)直接建立连接套接字。 - 连接由”一对”套接字构成,一方的输出流就是另一方的输入流:客户端写
socket.getOutputStream(),数据流到服务器的socket.getInputStream()。 - 一个端口只有一个监听者,但一个监听者可以派生任意多个连接套接字(每个连接一个),这就是并发服务器的基础。
- 注意物理类比不准确的地方:真实 USB 口插上线就不再空着,但监听套接字在接完一个客户端后依然存在,仍然绑定同一端口,随时可以
缓冲区、分包与阵发性传输(Buffers and Bursty Transmission)
- 定义与目的:收发数据是按块(chunks)进行的,网络把大块切成数据包(packets)分别路由,接收端再把它们重新拼成字节流;数据到达后进入缓冲区(buffer),即内存中保存待读数据的数组。它关系到 Safe from bugs:你必须假设数据”可能已经到了、也可能还没到”,不能假设一次
read就能拿到一整条消息。 - 直观解释(”它是什么?”):像快递。你寄一箱书,物流公司拆成多个包裹走不同路线,收件人陆续收到再拼回一箱。数据到达是阵发(bursty)的:要么已经在缓冲区里,要么你得等。
- 关键规则与最佳实践:
- 网络传输有分片:不能假设一次
read()恰好读到一条完整消息,也不能假设多条消息不会粘在一次读取中——这正是”行式协议 +readLine()“存在的理由。 - 缓冲区是字节数组:输出缓冲区满了
write会阻塞,输入缓冲区空了read会阻塞。 - 设计协议时用明确的定界符或长度前缀(例如每行以换行结束),让接收方能可靠地切分消息。
- 不要依赖”小消息不会被拆分”这种巧合,这在本地测试通过、在真实网络就会出 bug。
- 网络传输有分片:不能假设一次
阻塞式 I/O(Blocking I/O)
- 定义与目的:输入/输出流表现出阻塞行为:当输入套接字的缓冲区为空,调用
read会一直阻塞直到有数据;当输出套接字的缓冲区已满,调用write会一直阻塞直到有空位。它对程序员非常方便:可以像”读一定成功”那样写代码,由操作系统负责把该线程挂起与唤醒。 - 直观解释(”它是什么?”):像在食堂排队打饭。轮到之前你站着不动(线程被阻塞),但你不必自己安排”什么时候再来看看”——有人(操作系统)会在轮到你时叫你。
- 关键规则与最佳实践:
- 记住哪些调用可能阻塞:
Socket构造、ServerSocket.accept()、BufferedReader.readLine()、PrintWriter.println()(缓冲区满时)。 - 阻塞的是调用它的那个线程,不是整个进程:其他线程照常运行,这正是”每连接一个线程”能工作的原因。
- 阻塞与
BlockingQueue的消息传递范式同源:向套接字输出流写 ≈put(),从输入流读 ≈take()。 - 服务器绝不能在有其他客户端等待时被一个慢客户端长期阻塞,否则其它客户端会被饿死(starvation)。
- 记住哪些调用可能阻塞:
字节流与字符流:编码问题(Byte Streams vs Character Streams, UTF-8)
- 定义与目的:套接字上的数据是字节流;Java 中
InputStream表示数据流入你的程序,OutputStream表示数据汇(sink)。但程序内部通常要处理 Unicode 字符(String),因此需要Reader/Writer做字节与字符的转换,InputStreamReader/OutputStreamWriter就是把字节流适配成字符流的包装器。它关系到 Safe from bugs:字符编码(character encoding)用错会静默产生乱码。 - 直观解释(”它是什么?”):字节流像传真机收到的点阵,字符流像有人把它翻译成一段文字;编码就是”点阵 ↔ 文字”的对照表,双方必须用同一张表。
- 关键规则与最佳实践:
- 网络通信一律显式使用 UTF-8:
new InputStreamReader(in, StandardCharsets.UTF_8)。 - 不要依赖默认编码:Java 可能从系统设置取默认编码(如 Windows 的 CP-1252),于是”文件兼容性”问题会污染网络代码。
- 编码 bug 难以发现:UTF-8、CP-1252 都是 ASCII 的超集,纯英文文本一切正常;只有重音拉丁字母、非拉丁文字、emoji、弯引号
“ ”才会变成乱码。 - 构造任何
Reader/Writer时都显式写出编码,这是本讲所有示例的一致做法。
- 网络通信一律显式使用 UTF-8:
Java 套接字编程模型(ServerSocket / Socket / BufferedReader / PrintWriter)
- 定义与目的:这是把上面的概念落到 Java 的一套”最小可用 API 组合”。客户端:
new Socket(host, port)→getOutputStream()/getInputStream()→ 包装成PrintWriter/BufferedReader;服务器:new ServerSocket(port)→accept()→ 同样的流包装。它关系到 Easy to understand(模型只有四五个类)与 Ready for change(用接口类型BufferedReader/PrintWriter而不是具体实现,方便替换与测试)。 - 直观解释(”它是什么?”):套接字是”一根管道”,
BufferedReader/PrintWriter是装在管道两端的”行式收发器”:你按行说话,它负责缓冲与切行。 - 关键规则与最佳实践:
new Socket(host, port)在没有服务器监听该端口时抛IOException;new ServerSocket(port)在端口已被占用时抛BindException。- 服务器套接字不提供字节流,它只生产”新的客户端连接”,这就是
accept()的语义。 readLine()在对端关闭连接时返回null(不是抛异常);用if (line == null) break;处理流结束。- 写完必须
flush():println只是写进PrintWriter的内部缓冲区,只有缓冲区满或关闭连接时才真正发送。PrintWriter的 autoflush 只对println等少数操作生效,print(msg + "\n")可能仍留在缓冲区里。 - 用 try-with-resources 保证
close()一定被调用:try (Socket socket = new Socket(host, port)) { ... },适用于InputStream/OutputStream/Reader/Writer/Socket/ServerSocket。 - 「补充说明」:真实程序还应设置超时,避免永久阻塞——
socket.setSoTimeout(ms)(读超时,抛SocketTimeoutException)与new Socket()之后socket.connect(addr, timeoutMs)(连接超时)。
并发服务器:每个连接一个线程(Concurrent Server, Thread-per-Connection)
- 定义与目的:单线程服务器一次只能服务一个客户端:服务器循环被钉死在某个客户端的
readLine()上,直到该客户端断开才回去accept下一个。要同时服务多个客户端,就需要为每个新客户端开一个新线程处理 I/O,而主线程继续待在accept()上。它直接关系到 Safe from bugs(线程安全论证)与 Easy to understand(把”接受连接”和”处理一个客户端”拆成两段清晰的代码)。 - 直观解释(”它是什么?”):像银行大堂。大堂经理(主线程)只负责接引顾客到窗口;每个窗口的柜员(工作线程)各自服务一位顾客。经理不会陪某一位顾客办完全部业务。
- 关键规则与最佳实践:
- 主线程循环:
Socket socket = serverSocket.accept(); new Thread(() -> handleClient(socket)).start(); - 每个连接的数据被限制(confinement)在它自己的线程里,这是最省事的线程安全策略;只有真正共享的状态才需要同步。
- 为每个连接新建线程有资源上限:连接数很多时应改用线程池(「补充说明」:
Executors.newFixedThreadPool(n)/newCachedThreadPool())。 - 共享的可变状态(如全局计数器、共享缓存)必须用消息传递(线程安全队列)或
synchronized保护,并写下线程安全论证。 handleClient里的异常必须捕获:线程中抛出的异常不会传播给主线程,会导致连接悄悄挂掉(并泄漏套接字)。
- 主线程循环:
线路协议与 HTTP(Wire Protocol and HTTP)
- 定义与目的:协议(protocol)是两个通信方可以交换的一组消息;线路协议(wire protocol)特指以字节序列表示的消息集合,例如 “hello world” 与 “bye”(前提是双方已经约定字符如何编码成字节)。它取代了同进程消息传递中的”选择或设计一个 ADT”,成为跨网络的通信契约。
- 直观解释(”它是什么?”):像两个国家的外交辞令:说什么、按什么顺序说、说错了怎么办,都有约定;遵守约定,双方才听得懂。
- 关键规则与最佳实践:
- 许多互联网协议是基于 ASCII 的文本协议,可以用
telnet直接手工对话(例如telnet www.eecs.mit.edu 80然后输入GET / HTTP/1.1与Host:头,最后以空行结束请求)。 - HTTP 是 Web 的语言:80 端口是它的标准端口。请求由方法(method)、请求 URI、版本号与头部构成;响应有三位状态码(
200 OK成功、404 Not Found不存在、400 Bad Request参数有问题、500 Internal Server Error其它失败)与应答体(reply body)。 - HTTP 方法对应 ADT 操作分类:GET 是 observer(只读、可安全重复),POST 是 mutator / producer / creator(会改变或创建数据,浏览器会谨慎地弹窗确认重复提交)。PUT、DELETE 在 Web API 中较少用。
- 参数有三种传法:路径分量(
/points/42.3541,-71.1104)、查询参数(?area=MA&severity=Minor)、请求体(body,只有 POST 之类才有)。GET 无 body,因此只能用前两种。 - 返回结果常用 JSON(JavaScript Object Notation),它是交换结构化数据最常见的方式;序列化(serialization)指把内存中的数据结构转换成便于存储或传输的格式,不要自己发明格式。
- 协议要版本化(Ready for change):HTTP 让客户端与服务器协商版本,新老实现可以共存。
telnet/curl都能与服务器说 HTTP:能说 HTTP 的工具可以是浏览器、telnet、curl、你写的客户端,关键在于协议而非实现。
- 许多互联网协议是基于 ASCII 的文本协议,可以用
用文法与规格说明描述协议(Grammar + Specs)
- 定义与目的:要精确说明”允许哪些消息”,应当写出文法(grammar);但文法只相当于 ADT 的方法签名,还必须补充前置条件(precondition)与后置条件(postcondition)。它关系到 Safe from bugs(拒绝非法消息)与 Easy to understand(协议文档可读)。
- 直观解释(”它是什么?”):文法规定”句子的形状”,规格说明规定”这句话在什么情况下才能说、说了以后会发生什么”。
- 关键规则与最佳实践:
- 文法回答:哪些字节序列是合法消息(例如
ON ::= "on " ID、ID ::= [1-9][0-9]*,因此on 0非法)。 - 规格说明回答:字段取值范围的前置条件(是任意数字,还是服务器已知记录的 ID?)、消息的发送时序约束(某些消息只有在特定序列中才合法)、以及后置条件(服务器会改哪些数据、回什么消息)。
- 用现成工具解析协议(由文法自动生成的解析器、或正则表达式库)比手写字符串切分更不易出错。
- 限制单条消息的规模并校验字段,防止恶意客户端撑爆服务器缓冲区。
- 文法回答:哪些字节序列是合法消息(例如
网络分层的抽象(Layers of Abstraction)
- 定义与目的:网络是分层的:物理链路 → IP(把包送达主机)→ TCP(提供可靠、有序的字节流)→ 应用协议(HTTP、SMTP)。套接字正是 TCP 提供给应用层的抽象:它把”重传、排序、拥塞控制”全部隐藏,只暴露”可靠字节流”这一简洁契约。
- 直观解释(”它是什么?”):像寄信的多级体系:你只管把信投进邮筒(写套接字),邮政系统负责分拣、转运、丢件重发,收件人收到的是完整有序的信。
- 关键规则与最佳实践:
- TCP 保证字节流有序可靠,但不保证消息边界——分帧(framing)必须由应用协议自己解决(这也是行式协议流行的原因)。
- 套接字之上可以再套抽象:
HttpURLConnection、ServerSocket+BufferedReader、或整个 Web 框架;抽象层次越高,代码越少但控制力越弱。 - 抽象边界两侧的契约就是协议规格:一边的实现变了(换数据库、换语言),另一边不受影响,这就是”平台无关性(platform independence)“,与 ADT 的表示独立性(representation independence)是同一思想。
- 设计协议时不要泄露实现细节:HTTP 不规定网页如何存储、如何生成、客户端如何渲染。
把读写流抽象为 ADT:分离套接字代码与流代码(Separating Socket Code from Stream Code)
- 定义与目的:需要读写套接字的函数/模块,往往只需要输入输出流,而不需要套接字本身;把参数类型定为
BufferedReader/PrintWriter(而不是Socket),就能用不来自套接字的流来测试它。它极大提升 Safe from bugs(可单元测试)与 Ready for change(换传输方式不改逻辑)。 - 直观解释(”它是什么?”):像家电的插头标准:电器(业务逻辑)只依赖”电”(流),不关心电来自火电还是水电(套接字还是内存数组)。
- 关键规则与最佳实践:
- 函数签名写
void upperCaseLine(BufferedReader input, PrintWriter output) throws IOException,而不是void upperCaseLine(Socket sock)。 - 用
ByteArrayInputStream提供固定输入、ByteArrayOutputStream收集输出,二者就是测试桩(test stub)。 - 更完整的模块可以用 mock object 模拟真实客户端/服务器的整段交互序列,逐条断言消息。
- 把不涉及网络的 ADT(数据结构、算法)单独规格化、测试、实现,让它们本身与网络无关;它们若会被多线程使用,就用消息传递/同步/不可变等策略保证线程安全。
- 并发与网络难以测试和调试:race condition 不可复现,网络延迟不可控,因此必须”为并发而设计”并给出正确性论证。
- 函数签名写
代码示例与对比分析
场景 1:为套接字流构造字符流时是否显式指定编码
❌ 错误代码
// 错误:依赖平台默认字符编码
Socket socket = new Socket(hostname, port);
PrintWriter writeToServer = new PrintWriter(socket.getOutputStream());
BufferedReader readFromServer = new BufferedReader(
new InputStreamReader(socket.getInputStream()));
writeToServer.println("café ☕ — naïve");
writeToServer.flush();
【错误代码的问题】
- 在 Windows(默认 CP-1252)上运行的服务端与在 Linux(默认 UTF-8)上运行的客户端互相发送含重音字符、破折号或 emoji 的消息时,接收方会把它们解成乱码(mojibake)。
- 纯英文测试全部通过,bug 只在非 ASCII 文本上出现,属于典型的”潜伏型”缺陷,极难定位。
- 编码行为随运行环境变化,违反可移植性:同一份代码在不同机器上语义不同,违反”correct in the unknown future”。
✅ 正确代码
// 正确:显式指定 UTF-8,网络通信的通用选择
Socket socket = new Socket(hostname, port);
PrintWriter writeToServer = new PrintWriter(
new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8));
BufferedReader readFromServer = new BufferedReader(
new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
writeToServer.println("café ☕ — naïve");
writeToServer.flush();
【为什么这样更好】 两端都按 UTF-8 解释字节序列,编码成为协议的一部分而不是环境的偶然属性;任何符合 UTF-8 的客户端/服务器都能正确互通,字符编码 bug 被从根上消除。 【代码对比解说】 两种写法的逻辑完全相同,差异只在包装层的第三个参数。InputStreamReader/OutputStreamWriter 是”字节流 → 字符流”的适配器(adapter):它必须知道用哪张对照表。不指定时它就问系统要一张表,而系统给出的答案可能与对端不同。注意 OutputStreamWriter(OutputStream) 与 OutputStreamWriter(OutputStream, Charset) 的差异是”静默依赖环境”与”显式声明契约”的差异,正是规格说明精神的体现:把隐含假设变成显式约定。 【设计原则透视】 这是规格说明(Reading 6/7)在 I/O 上的体现:编码是接口契约的一部分,不是实现细节。它也是抽象边界问题:Reader/Writer 的抽象把”字节 ↔ 字符”的转换集中到一处,前提是这个抽象的配置必须与对端一致。与 Reading 11 的表示独立性类比:只要双方约定的”表示”(编码)一致,各自的内部实现如何都无所谓。
场景 2:发送消息后忘记 flush
❌ 错误代码
// 错误:println 只写进了客户端侧缓冲区,消息可能根本没发出去
PrintWriter writeToServer = new PrintWriter(
new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8));
String message = "hello";
writeToServer.println(message); // 看似已发送
// 没有 flush,客户端随后阻塞在 readLine() 上等应答 —— 服务器根本没收到请求
// 同样错误的变体:构造时开启 autoflush,却用 print 而非 println
PrintWriter auto =
new PrintWriter(new OutputStreamWriter(socket.getOutputStream(),
StandardCharsets.UTF_8), true /* autoflush */);
auto.print(message + "\n"); // autoflush 对 print 不生效,仍留在缓冲区
【错误代码的问题】
- 死锁式的互相等待:客户端等应答、服务器等请求,程序永久挂起;在单机测试里往往”偶尔正常”,因为缓冲区填满会触发实际发送,从而掩盖了缺陷。
- 交互式协议(一问一答)几乎必然卡死,用户以为程序崩溃。
- 用
print(message + "\n")替代println(message)会让 autoflush 悄然失效——这是一个”看起来等价”的陷阱,破坏 Easy to understand。
✅ 正确代码
// 正确:写完后显式 flush
PrintWriter writeToServer = new PrintWriter(
new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8));
String message = "hello";
writeToServer.println(message);
writeToServer.flush(); // 重要!否则这一行可能只是躺在缓冲区里
// 或者在构造时开启 autoflush,并且只用 println 这类会触发自动 flush 的操作
PrintWriter auto = new PrintWriter(
new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8),
true /* autoflush */);
auto.println(message); // println 会触发自动 flush
【为什么这样更好】 flush() 把”逻辑上已发送”变成”物理上已发送”,消除了双方互等的死锁风险;显式 flush 让”何时真正上线”在代码中一目了然,读者无需推理缓冲区大小。 【代码对比解说】 PrintWriter 是带缓冲的装饰器(decorator):println 的规格只是”把行写入缓冲”,而不是”发到网络”。缓冲区存在的理由是性能(合并小写操作);代价是时序被推迟。因此凡是”请求—应答”式的交互协议,都必须在一轮写完之后 flush。课程原文的提醒值得抄在笔记本上:always remember to flush. 同理,关闭流(close())也会触发 flush,因此 try-with-resources 能在退出时兜底,但不要依赖它来保证交互时序——应答可能在关闭之前根本不会到来。 【设计原则透视】 缓冲是表示(rep)的一部分,它引入了”逻辑状态 vs 物理状态”的差异;不 flush 的代码在方法签名与规格层面看不出任何问题,属于”规格没写清、实现有隐藏前置条件”的经典案例:真正的后置条件应是”消息已交给操作系统发出”。这也是 Easy to understand 的反面教材——代码的可见行为与读者预期的行为不一致。
场景 3:单线程服务器 vs 每连接一个线程的并发服务器
❌ 错误代码
// 错误:整个服务器一次只能服务一个客户端
public static void main(String[] args) throws IOException {
int port = 4589;
try (ServerSocket serverSocket = new ServerSocket(port)) {
while (true) {
Socket socket = serverSocket.accept(); // 阻塞直到有新连接
handleClient(socket); // 一直服务到该客户端断开
// 在此之前,其他客户端只能排队等待,accept() 根本不会被再次调用
}
}
}
private static void handleClient(Socket socket) throws IOException {
try (BufferedReader readFromClient = new BufferedReader(
new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
PrintWriter writeToClient = new PrintWriter(
new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8))) {
while (true) {
String message = readFromClient.readLine(); // 慢客户端会把服务器钉在这里
if (message == null) break; // 客户端关闭连接
if (message.equals("quit")) break; // 毒丸消息
writeToClient.println("echo: " + message);
writeToClient.flush();
}
}
}
【错误代码的问题】
- 一个慢客户端阻塞所有人:任何客户端的空闲连接都会让服务器无法接待新客户,可用性随客户端数量线性恶化甚至完全瘫痪(简单的拒绝服务)。
- 交互式协议下用户体验崩坏:第二个客户端”连上了却没有任何反应”,看起来像网络故障。
- 若把
handleClient内部逻辑改成”等待某个外部事件”,服务器会永久失去服务能力,且没有明显报错,调试困难。
✅ 正确代码
// 正确:主线程只负责接受连接,每个连接交给一个新线程处理
public static void main(String[] args) throws IOException {
int port = 4589;
try (ServerSocket serverSocket = new ServerSocket(port)) {
while (true) {
final Socket socket = serverSocket.accept(); // 阻塞直到有新连接
// 新线程处理该客户端,主线程立刻回到 accept() 等下一个
new Thread(new Runnable() {
public void run() {
try {
handleClient(socket);
} catch (IOException ioe) {
ioe.printStackTrace(); // 线程内异常必须自己处理,不能上抛给主线程
}
}
}).start();
}
}
}
private static void handleClient(Socket socket) throws IOException {
try (BufferedReader readFromClient = new BufferedReader(
new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
PrintWriter writeToClient = new PrintWriter(
new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8))) {
while (true) {
String message = readFromClient.readLine();
if (message == null) break;
if (message.equals("quit")) break;
writeToClient.println("echo: " + message);
writeToClient.flush();
}
} // 离开 try 时自动 close 流与套接字
}
【为什么这样更好】 阻塞式 I/O 下,”阻塞”只影响调用它的线程:一个线程被慢客户端阻塞时,主线程仍能接待新客户端,其他工作线程仍在服务自己的客户端。每个连接的读写状态被限制(confinement)在该连接自己的线程里,天然避免了共享可变状态,是最简单的线程安全策略。 【代码对比解说】 两种写法的差别只有”accept() 之后立刻开线程”这一步,但它把服务器的并发度从 1 提升到 O(客户端数)。代价是:线程是有限资源,连接数很大时应改用线程池;同时必须处理线程内异常(否则静默失败并泄漏 Socket);若有跨连接的共享状态,就要引入消息传递或同步。此外,quit 毒丸与 readLine() 返回 null 代表两种不同的停止方式——后者意味着客户端直接关闭了自己这一端。 【设计原则透视】 这是 Reading 21(并发)、Reading 23(互斥)、Reading 24(消息传递) 的直接应用:并发模块必须给出线程安全论证;本方案用的是”线程限制 + 每连接独立状态”,共享状态为零,因此无需锁。ServerSocket 与 Socket 之间的分工也体现了抽象设计:把”接待”与”服务”两种职责分开,才能各自独立地阻塞而不互相妨碍。
场景 4:业务逻辑直接依赖 Socket vs 依赖读写流(可测试性)
❌ 错误代码
// 错误:把网络细节写死在业务逻辑里,无法脱离真实网络测试
public static void upperCaseLine(Socket sock) throws IOException {
BufferedReader in = new BufferedReader(
new InputStreamReader(sock.getInputStream(), StandardCharsets.UTF_8));
PrintWriter out = new PrintWriter(
new OutputStreamWriter(sock.getOutputStream(), StandardCharsets.UTF_8), true);
String line = in.readLine();
if (line == null) return;
out.println(line.toUpperCase());
out.flush();
}
// 测试时必须先启动一个真实服务器、建立真实连接、控制时序 —— 慢、脆、且难覆盖边界情况
【错误代码的问题】
- 不可单元测试:要验证方法本身,必须搭起
ServerSocket、连上网络、处理端口占用与超时,测试变成集成测试,脆弱且慢。 - 每次测试都用掉一个端口,测试之间互相干扰,并行运行会随机失败。
- 逻辑与传输耦合,将来换成管道、文件或内存队列就要重写。
✅ 正确代码
// 正确:只依赖字符流,因此可以用内存流作为测试桩
public static void upperCaseLine(BufferedReader input, PrintWriter output)
throws IOException {
String line = input.readLine();
if (line == null) return; // 输入已结束
output.println(line.toUpperCase());
output.flush();
}
// 生产环境:接到套接字上
Socket sock = new Socket(hostname, port);
BufferedReader in = new BufferedReader(
new InputStreamReader(sock.getInputStream(), StandardCharsets.UTF_8));
PrintWriter out = new PrintWriter(
new OutputStreamWriter(sock.getOutputStream(), StandardCharsets.UTF_8),
true /* autoflush */);
upperCaseLine(in, out);
// 单元测试:用内存字节流替换网络(测试桩)
String inString = "dog\ncat\n";
ByteArrayInputStream inBytes = new ByteArrayInputStream(inString.getBytes(StandardCharsets.UTF_8));
ByteArrayOutputStream outBytes = new ByteArrayOutputStream();
BufferedReader testIn = new BufferedReader(
new InputStreamReader(inBytes, StandardCharsets.UTF_8));
PrintWriter testOut = new PrintWriter(
new OutputStreamWriter(outBytes, StandardCharsets.UTF_8), true);
upperCaseLine(testIn, testOut);
assertEquals("cat", testIn.readLine(), "expected input line 2 remaining");
assertEquals("DOG\n", outBytes.toString(StandardCharsets.UTF_8), "expected upper case");
【为什么这样更好】 方法的依赖被抽象成接口参数(BufferedReader/PrintWriter),于是可以在测试里用内存流替换真实网络:ByteArrayInputStream 提供确定输入,ByteArrayOutputStream 捕获输出。测试快、可重复、无端口冲突,且能顺便断言”恰好消费了第一行”,覆盖了读位置的边界。 【代码对比解说】 关键改动是把 Socket 从参数中拿掉:Socket 只是”如何获得流”的一种方式,方法真正需要的是”一条能读行、能写行的字符流”。这种”依赖倒置”让同一个方法可以在三种环境里复用:真实套接字、内存流(单元测试)、mock 对象(模拟完整交互序列)。注意两处断言分别检查输入被消费到什么位置与输出内容,这是把”读写行为”也纳入规格的例子。 【设计原则透视】 这是 Reading 12(用接口定义 ADT) 与 Reading 3(测试) 的结合:面向接口编程让模块可替换;测试桩/mock 是”满足同一规格但行为可预测的替代实现”。它也体现 Reading 7(设计规格) 的思想:先把 upperCaseLine 的前置条件(input、output 处于打开状态)与后置条件(读一行、写其大写)写清,测试才能只针对这些条件构造用例。
场景 5:手工切割协议文本 vs 用文法/校验设计协议
❌ 错误代码
// 错误:靠 split 和字符串拼接"差不多"地解析协议,既不校验也不限长
public static void handleMessage(String message, List<Light> lights) {
String[] parts = message.split(" ");
String command = parts[0];
int id = Integer.parseInt(parts[1]); // "help" 会数组越界;"on 0" 会被当成合法 ID
if (command.equals("on")) {
lights.get(id).turnOn(); // 未校验 id 范围,可能 IndexOutOfBounds
} else if (command.equals("off")) {
lights.get(id).turnOff();
}
// 其它命令被静默忽略,客户端永远得不到应答
}
【错误代码的问题】
- 违反协议文法的输入(
on 0、on、OFF 1、超长 ID)会导致ArrayIndexOutOfBoundsException或NumberFormatException,服务器被一个畸形消息打挂(安全漏洞:拒绝服务)。 - 非法输入被静默忽略,客户端无从知道出了什么问题——违反 Easy to understand,也让调试变成猜谜。
- 没有对消息长度设上限,恶意客户端可以持续发送超长行耗尽服务器内存或缓冲区。
✅ 正确代码
// 正确:按文法解析并严格校验;非法输入返回明确的错误应答
// 文法(来自课程原文的例子):
// MESSAGE ::= ( ON | OFF | HELP_REQ ) NEWLINE
// ON ::= "on " ID
// OFF ::= "off " ID
// HELP_REQ::= "help"
// ID ::= [1-9][0-9]*
private static final Pattern ON_OR_OFF =
Pattern.compile("(on|off) ([1-9][0-9]*)");
private static final Pattern HELP = Pattern.compile("help");
/** @param message 已由 readLine() 去掉行结束符的一行输入
* @return 给客户端的应答;非法输入返回 "error: bad request" */
public static String handleMessage(String message, int lightCount) {
if (message.length() > 100) { // 限制规模,防止缓冲区耗尽
return "error: message too long";
}
Matcher help = HELP.matcher(message);
if (help.matches()) {
return "turn lights on and off with: on <id> / off <id>";
}
Matcher m = ON_OR_OFF.matcher(message);
if (!m.matches()) {
return "error: bad request"; // 明确拒绝,而不是猜
}
int id = Integer.parseInt(m.group(2));
if (id > lightCount) { // 前置条件:ID 必须是已知的灯
return "error: unknown light " + id;
}
return m.group(1) + " " + id + " ok";
}
【为什么这样更好】 解析规则与文法一一对应((on\|off) <id>、help),任何不符合文法的输入都被显式拒绝并得到可读的错误应答;规模上限把”恶意或损坏的对端”变成可处理的错误而不是崩溃。代码与协议文档保持同构,改协议时改文法即可。 【代码对比解说】 前者的 split(" ")[1] 隐含假设”消息一定是两个词、第二个词一定是数字、数字一定是合法 ID”——这些假设都不在协议规格里,属于典型的隐藏前置条件。后者用一份正则把文法写死,再单独检查”ID 是否已知”这一真正的语义前置条件,层次清晰:语法(形状对不对)与语义(内容合不合法)分开处理。返回字符串而非抛异常,也让服务器可以继续服务同一个客户端——这正是线路协议该有的宽容度:坏消息不等于坏连接。 【设计原则透视】 呼应 Reading 18(正则表达式与文法) 与 Reading 19(解析器):协议本身就是一种语言,先用文法定义它,再让解析器实现它,能显著减少 bug。同时体现 Reading 6/7 的规格思想——文法相当于方法签名,前置/后置条件才是完整的规格。安全视角上,这正是课程反复强调的:”考虑损坏或恶意的客户端/服务器如何往协议里塞垃圾数据来破坏对端”,SMTP 允许谎报 From: 就是历史教训。
与其他设计原则的关联
- Reading 24(消息传递 Message-Passing):套接字读写与
BlockingQueue的put()/take()是同一种”阻塞式消息传递”范式;毒丸消息(quit)与”对端关闭连接导致readLine()返回null“对应两种终止协议的方式。区别在于套接字传递的是字节流,因此必须额外设计协议(分帧、编码、序列化)。 - Reading 21(并发)与 Reading 23(互斥):网络通信天然并发,服务器为每个连接开线程,因此必须写出线程安全论证。本讲推荐的策略是线程限制(每个连接的流只被自己的线程访问)与不可变/消息传递;一旦引入跨连接共享状态,就需要 Reading 23 的锁或 Reading 24 的线程安全队列。
- Reading 6(规格说明)与 Reading 7(设计规格):线路协议、Web API 都需要前置条件与后置条件;文法只相当于方法签名。
upperCaseLine的”输入输出必须打开”就是前置条件。 - Reading 10(抽象数据类型)与 Reading 11(抽象函数与表示不变量):协议是客户端与服务器之间的抽象边界,平台无关性与 ADT 的表示独立性是同一思想;把
Socket换成流参数,正是”缩小依赖接口”的 ADT 设计手法。 - Reading 12(接口、泛型、枚举、函数对象):用接口类型(
BufferedReader、PrintWriter,乃至自定义的SequencePlayer式接口)编程,才能替换实现、注入测试桩。 - Reading 3(测试)与 Reading 4(代码评审):
ByteArrayInputStream/ByteArrayOutputStream测试桩与 mock object 属于 Reading 3 的技术;把套接字代码与流代码分离正是代码评审中最常见的”可测试性”评点。 - Reading 18(正则与文法)、Reading 19(解析器):协议用文法描述、由解析器实现;HTTP 请求行/头部的文法来自 RFC,正是”文法即规格”的现实案例。
- Reading 26/27(小语言 I/II):协议是一种”小语言”,消息集合是它的句子;当消息结构复杂到需要树形表示时,就需要 Reading 26 的递归数据类型与 Reading 27 的访问者模式来处理消息。
关键要点
- 网络编程 = 并发编程 + 协议设计:先把”谁先说话、说什么、出错怎么办”写成文法与规格,再写代码;不要一边写
readLine一边想协议。 - 套接字之上永远自己分帧:TCP 只保证有序可靠的字节流,不保证消息边界;行式协议 +
readLine()是最省心的分帧方式,但要显式处理null(对端关闭)。 - 三件套不能忘:显式
StandardCharsets.UTF_8、写完flush()、用 try-with-resources 关闭;再加上「补充说明」中的超时设置,才算健壮。 - 服务器不要被任何单个客户端阻塞:主线程只
accept,每连接一个线程(连接数大时用线程池),并保证线程内异常被捕获。 - 把
Socket挡在业务逻辑之外:让核心函数只依赖BufferedReader/PrintWriter,用内存流做测试桩,让网络代码薄得只剩”连接、包装、关闭”。
常见陷阱与注意事项
- 忘记
flush()→ 消息留在客户端缓冲区未发送,双方互等造成死锁;用print(msg + "\n")搭配 autoflush 更是”看起来等价”的陷阱。 - 不指定字符编码 → 在非 ASCII 文本上出现乱码,且在本机英文测试中完全看不出来;Windows 上的 CP-1252 默认值是典型祸首。
- 单线程服务器,或工作线程内异常未捕获 → 前者让一个慢/空闲客户端把所有其他客户端排队(表现为”连得上但没反应”);后者让工作线程静默死亡,连接挂起、套接字泄漏,服务器表面正常但已有连接”黑洞”。
- 把
readLine()返回的null当作空字符串处理 → 对端已关闭连接时陷入死循环或写出错误应答;null是流结束信号,必须break。 - 在业务逻辑里直接用
Socket→ 无法单元测试、端口冲突、逻辑与传输耦合;测试要么很慢要么很脆。 - 协议解析不校验、不限长 → 畸形消息触发异常打挂服务器(可用性/安全漏洞),或超长消息耗尽内存;同时非法输入被静默忽略会让客户端无从调试。
思考题(带答案)
问题 1:为什么 ServerSocket 与 Socket 被设计成两个不同的类,而不是让 ServerSocket.accept() 返回它自己(像物理 USB 口那样”插上线就变成连接口”)?请从”一个端口只有一个监听者但可以有多个并发连接”的角度解释,并说明这对并发服务器的意义。
答案:物理插口的类比会误导人。真实 USB 口插上线后就不再空着,而网络监听套接字在接完一个客户端后仍然存在、仍然绑定同一端口,并在下次 accept() 时交出下一个客户端。因此必须区分两种角色:监听套接字负责”以端口为标识接待新连接”,连接套接字负责”与某一个具体对端收发字节”。如果 accept() 返回监听套接字自身,服务器就无法同时保留”端口的所有权”与”多个已建立连接”,也就无法在服务客户端 A 的同时继续接待客户端 B。正因为 Java 为每个新连接新建一个 Socket 对象,服务器才能把每个连接交给独立线程,形成”主线程 accept + 每连接一个工作线程”的并发结构(连接数大时改用线程池)。这也解释了为什么端口冲突的错误发生在 new ServerSocket(port)(BindException),而不是发生在 accept()。
问题 2:EchoServer 有两种结束与某个客户端对话的方式:客户端发送 "quit",或客户端关闭自己这一端的连接。这两种方式在服务器端代码里分别表现为哪一行?为什么协议设计者需要同时支持二者?
答案:"quit" 是应用层协议里的毒丸消息(poison pill),由服务器循环中的 if (message.equals("quit")) break; 处理;客户端关闭连接则表现为 readLine() 返回 null,由 if (message == null) break; 处理。二者都需要支持,因为:毒丸消息是”礼貌的、有语义的告别”,客户端可以在结束前确保自己已收到所有应答;而连接关闭是传输层事件,客户端崩溃、网络断开、进程被杀死时都可能发生,服务器无法阻止,只能把它识别为流结束并释放资源。如果只处理毒丸,一旦客户端异常退出,服务器线程就会读到 null 却继续把它当字符串处理(例如 null.equals("quit") 抛 NullPointerException,或陷入死循环),造成线程与套接字泄漏。这与 Reading 24 中”BlockingQueue 的 take() 需要毒丸来终止消费者”的思想一致,只不过套接字额外提供了”对端消失”这一层信号。
问题 3:你要写一个”每行是一条 JSON 请求、每行是一条 JSON 应答”的网络服务。请说明为什么”每行一个 JSON 对象”这种设计同时改善了 Safe from bugs、Easy to understand 与 Ready for change,并指出它相对”直接发送裸 JSON 流”的关键优势。若换成”发送长度前缀 + 二进制体”会带来什么取舍?
答案:分帧(framing)是这里的核心。裸 JSON 流的问题是接收方无法判断”当前这个对象到哪里结束”,必须自己做括号匹配式的增量解析,且消息间无法复用同一套简单读取逻辑。改为”每行一个 JSON”后:(1)Safe from bugs——用 readLine() 就能可靠地切出一条完整消息,解析器只需处理一整行,边界情况大幅减少;(2)Easy to understand——可以用 telnet 或 nc 手工敲一行 JSON 与服务对话,调试时能直接看懂报文;(3)Ready for change——行内 JSON 可以轻松增删字段而不破坏分帧,新老字段可共存,双方可以各自演进。JSON 本身是成熟的序列化格式,不要自己发明一套转义规则。代价与取舍:文本协议体积较大、解析有开销,且含换行的字符串必须转义(JSON 规范已处理);若改用”长度前缀 + 二进制体”,则分帧更紧凑、可承载任意二进制数据、解析更快,但报文不再可读、不便手工调试,且必须严格约定字节序与长度字段宽度,对实现错误的容忍度更低。选择取决于”可读/可调试”与”紧凑/高效”哪个更重要——这正是 Reading 7 中设计规格时的权衡思路。
