Reading 25: 网络编程(Networking)

目录 · ← l24 · l26 →

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.eduhostnslookup 亲自验证名字到地址的翻译。
    • 127.0.0.1loopback / 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 端口。

套接字:监听套接字与连接套接字(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()
    • 一个端口只有一个监听者,但一个监听者可以派生任意多个连接套接字(每个连接一个),这就是并发服务器的基础。

缓冲区、分包与阵发性传输(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-8new InputStreamReader(in, StandardCharsets.UTF_8)
    • 不要依赖默认编码:Java 可能从系统设置取默认编码(如 Windows 的 CP-1252),于是”文件兼容性”问题会污染网络代码。
    • 编码 bug 难以发现:UTF-8、CP-1252 都是 ASCII 的超集,纯英文文本一切正常;只有重音拉丁字母、非拉丁文字、emoji、弯引号 “ ” 才会变成乱码。
    • 构造任何 Reader/Writer 时都显式写出编码,这是本讲所有示例的一致做法。

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) 在没有服务器监听该端口时抛 IOExceptionnew 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.1Host: 头,最后以空行结束请求)。
    • 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、你写的客户端,关键在于协议而非实现。

用文法与规格说明描述协议(Grammar + Specs)

  • 定义与目的:要精确说明”允许哪些消息”,应当写出文法(grammar);但文法只相当于 ADT 的方法签名,还必须补充前置条件(precondition)后置条件(postcondition)。它关系到 Safe from bugs(拒绝非法消息)与 Easy to understand(协议文档可读)。
  • 直观解释(”它是什么?”):文法规定”句子的形状”,规格说明规定”这句话在什么情况下才能说、说了以后会发生什么”。
  • 关键规则与最佳实践
    • 文法回答:哪些字节序列是合法消息(例如 ON ::= "on " IDID ::= [1-9][0-9]*,因此 on 0 非法)。
    • 规格说明回答:字段取值范围的前置条件(是任意数字,还是服务器已知记录的 ID?)、消息的发送时序约束(某些消息只有在特定序列中才合法)、以及后置条件(服务器会改哪些数据、回什么消息)。
    • 用现成工具解析协议(由文法自动生成的解析器、或正则表达式库)比手写字符串切分更不易出错。
    • 限制单条消息的规模并校验字段,防止恶意客户端撑爆服务器缓冲区。

网络分层的抽象(Layers of Abstraction)

  • 定义与目的:网络是分层的:物理链路 → IP(把包送达主机)→ TCP(提供可靠、有序的字节流)→ 应用协议(HTTP、SMTP)。套接字正是 TCP 提供给应用层的抽象:它把”重传、排序、拥塞控制”全部隐藏,只暴露”可靠字节流”这一简洁契约。
  • 直观解释(”它是什么?”):像寄信的多级体系:你只管把信投进邮筒(写套接字),邮政系统负责分拣、转运、丢件重发,收件人收到的是完整有序的信。
  • 关键规则与最佳实践
    • TCP 保证字节流有序可靠,但不保证消息边界——分帧(framing)必须由应用协议自己解决(这也是行式协议流行的原因)。
    • 套接字之上可以再套抽象:HttpURLConnectionServerSocket + 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();

【错误代码的问题】

  1. 在 Windows(默认 CP-1252)上运行的服务端与在 Linux(默认 UTF-8)上运行的客户端互相发送含重音字符、破折号或 emoji 的消息时,接收方会把它们解成乱码(mojibake)。
  2. 纯英文测试全部通过,bug 只在非 ASCII 文本上出现,属于典型的”潜伏型”缺陷,极难定位。
  3. 编码行为随运行环境变化,违反可移植性:同一份代码在不同机器上语义不同,违反”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 不生效,仍留在缓冲区

【错误代码的问题】

  1. 死锁式的互相等待:客户端等应答、服务器等请求,程序永久挂起;在单机测试里往往”偶尔正常”,因为缓冲区填满会触发实际发送,从而掩盖了缺陷。
  2. 交互式协议(一问一答)几乎必然卡死,用户以为程序崩溃。
  3. 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();
        }
    }
}

【错误代码的问题】

  1. 一个慢客户端阻塞所有人:任何客户端的空闲连接都会让服务器无法接待新客户,可用性随客户端数量线性恶化甚至完全瘫痪(简单的拒绝服务)。
  2. 交互式协议下用户体验崩坏:第二个客户端”连上了却没有任何反应”,看起来像网络故障。
  3. 若把 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(消息传递) 的直接应用:并发模块必须给出线程安全论证;本方案用的是”线程限制 + 每连接独立状态”,共享状态为零,因此无需锁。ServerSocketSocket 之间的分工也体现了抽象设计:把”接待”与”服务”两种职责分开,才能各自独立地阻塞而不互相妨碍。


场景 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();
}
// 测试时必须先启动一个真实服务器、建立真实连接、控制时序 —— 慢、脆、且难覆盖边界情况

【错误代码的问题】

  1. 不可单元测试:要验证方法本身,必须搭起 ServerSocket、连上网络、处理端口占用与超时,测试变成集成测试,脆弱且慢。
  2. 每次测试都用掉一个端口,测试之间互相干扰,并行运行会随机失败。
  3. 逻辑与传输耦合,将来换成管道、文件或内存队列就要重写。

✅ 正确代码

// 正确:只依赖字符流,因此可以用内存流作为测试桩
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 的前置条件(inputoutput 处于打开状态)与后置条件(读一行、写其大写)写清,测试才能只针对这些条件构造用例。


场景 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();
    }
    // 其它命令被静默忽略,客户端永远得不到应答
}

【错误代码的问题】

  1. 违反协议文法的输入(on 0onOFF 1、超长 ID)会导致 ArrayIndexOutOfBoundsExceptionNumberFormatException服务器被一个畸形消息打挂(安全漏洞:拒绝服务)。
  2. 非法输入被静默忽略,客户端无从知道出了什么问题——违反 Easy to understand,也让调试变成猜谜。
  3. 没有对消息长度设上限,恶意客户端可以持续发送超长行耗尽服务器内存或缓冲区。

✅ 正确代码

// 正确:按文法解析并严格校验;非法输入返回明确的错误应答
// 文法(来自课程原文的例子):
//   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):套接字读写与 BlockingQueueput()/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(接口、泛型、枚举、函数对象):用接口类型(BufferedReaderPrintWriter,乃至自定义的 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,用内存流做测试桩,让网络代码薄得只剩”连接、包装、关闭”。

常见陷阱与注意事项

  1. 忘记 flush() → 消息留在客户端缓冲区未发送,双方互等造成死锁;用 print(msg + "\n") 搭配 autoflush 更是”看起来等价”的陷阱。
  2. 不指定字符编码 → 在非 ASCII 文本上出现乱码,且在本机英文测试中完全看不出来;Windows 上的 CP-1252 默认值是典型祸首。
  3. 单线程服务器,或工作线程内异常未捕获 → 前者让一个慢/空闲客户端把所有其他客户端排队(表现为”连得上但没反应”);后者让工作线程静默死亡,连接挂起、套接字泄漏,服务器表面正常但已有连接”黑洞”。
  4. readLine() 返回的 null 当作空字符串处理 → 对端已关闭连接时陷入死循环或写出错误应答;null 是流结束信号,必须 break
  5. 在业务逻辑里直接用 Socket → 无法单元测试、端口冲突、逻辑与传输耦合;测试要么很慢要么很脆。
  6. 协议解析不校验、不限长 → 畸形消息触发异常打挂服务器(可用性/安全漏洞),或超长消息耗尽内存;同时非法输入被静默忽略会让客户端无从调试。

思考题(带答案)

问题 1:为什么 ServerSocketSocket 被设计成两个不同的类,而不是让 ServerSocket.accept() 返回它自己(像物理 USB 口那样”插上线就变成连接口”)?请从”一个端口只有一个监听者但可以有多个并发连接”的角度解释,并说明这对并发服务器的意义。

答案:物理插口的类比会误导人。真实 USB 口插上线后就不再空着,而网络监听套接字在接完一个客户端后仍然存在、仍然绑定同一端口,并在下次 accept() 时交出下一个客户端。因此必须区分两种角色:监听套接字负责”以端口为标识接待新连接”,连接套接字负责”与某一个具体对端收发字节”。如果 accept() 返回监听套接字自身,服务器就无法同时保留”端口的所有权”与”多个已建立连接”,也就无法在服务客户端 A 的同时继续接待客户端 B。正因为 Java 为每个新连接新建一个 Socket 对象,服务器才能把每个连接交给独立线程,形成”主线程 accept + 每连接一个工作线程”的并发结构(连接数大时改用线程池)。这也解释了为什么端口冲突的错误发生在 new ServerSocket(port)BindException),而不是发生在 accept()

问题 2EchoServer 有两种结束与某个客户端对话的方式:客户端发送 "quit",或客户端关闭自己这一端的连接。这两种方式在服务器端代码里分别表现为哪一行?为什么协议设计者需要同时支持二者?

答案"quit"应用层协议里的毒丸消息(poison pill),由服务器循环中的 if (message.equals("quit")) break; 处理;客户端关闭连接则表现为 readLine() 返回 null,由 if (message == null) break; 处理。二者都需要支持,因为:毒丸消息是”礼貌的、有语义的告别”,客户端可以在结束前确保自己已收到所有应答;而连接关闭是传输层事件,客户端崩溃、网络断开、进程被杀死时都可能发生,服务器无法阻止,只能把它识别为流结束并释放资源。如果只处理毒丸,一旦客户端异常退出,服务器线程就会读到 null 却继续把它当字符串处理(例如 null.equals("quit")NullPointerException,或陷入死循环),造成线程与套接字泄漏。这与 Reading 24 中”BlockingQueuetake() 需要毒丸来终止消费者”的思想一致,只不过套接字额外提供了”对端消失”这一层信号。

问题 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——可以用 telnetnc 手工敲一行 JSON 与服务对话,调试时能直接看懂报文;(3)Ready for change——行内 JSON 可以轻松增删字段而不破坏分帧,新老字段可共存,双方可以各自演进。JSON 本身是成熟的序列化格式,不要自己发明一套转义规则。代价与取舍:文本协议体积较大、解析有开销,且含换行的字符串必须转义(JSON 规范已处理);若改用”长度前缀 + 二进制体”,则分帧更紧凑、可承载任意二进制数据、解析更快,但报文不再可读、不便手工调试,且必须严格约定字节序与长度字段宽度,对实现错误的容忍度更低。选择取决于”可读/可调试”与”紧凑/高效”哪个更重要——这正是 Reading 7 中设计规格时的权衡思路。