第 13 章 · 网络编程

第一次让两个进程通过网络说话,是学编程以来最接近「魔法」的时刻:两个完全独立的 JVM,隔着 localhost,互相收到了对方的消息。本章把这份魔法拆开:网络课(第 9 章)讲过的 Socket 在 Java 里的样子、UDP 和 TCP 两套 API、以及「一线程一连接」这个经典服务端模型的写法与局限。计网课第 3、4 章的协议知识在这里全部落地成代码。

13.1 定位:IP、端口与两种协议

  • IP 地址定位主机,端口号(0~65535)定位主机上的进程;「IP:端口」四元组(计网课第 4 章)唯一定位一条连接。1024 以下是特权端口(HTTP 80、HTTPS 443),自己实验用 10000 以上。
  • UDP:无连接、不保证到达、不保证顺序,快而糙(视频通话、游戏);TCP:三次握手建连接、可靠有序、字节流(网页、文件),绝大多数业务的首选。
  • InetAddress 封装 IP:InetAddress.getByName("localhost")(域名解析,计网课第 2 章 DNS 在这里被调用)。

13.2 UDP:一封信的旅行

UDP 没有连接概念,发的是「数据报」(一封写好地址的信),收发双方对等:

// 发送端
try (DatagramSocket s = new DatagramSocket()) {
    byte[] data = "你好,UDP".getBytes();
    DatagramPacket p = new DatagramPacket(data, data.length,
            InetAddress.getByName("127.0.0.1"), 9999);
    s.send(p);                        // 扔出去就完事,对方在不在不关我事
}
// 接收端(先启动)
try (DatagramSocket s = new DatagramSocket(9999)) {
    byte[] buf = new byte[1024];
    DatagramPacket p = new DatagramPacket(buf, buf.length);
    s.receive(p);                     // 阻塞等信
    System.out.println(new String(p.getData(), 0, p.getLength()));
}

先跑接收端再跑发送端,消息直达。UDP 的特性全在 API 的「无状态」里:没有 accept、没有流、一个包一个包地寄。它的「不可靠」用个实验感受:内网 localhost 基本不丢,丢包要在真实网络或限速工具下才看得见,这也是「UDP 快」的真相(省了握手和确认,不是传输更快)。

13.3 TCP:一个最小的回声服务器

TCP 是面向连接的字节流(计网课第 3 章),Java 把它包成两个类:ServerSocket(守门)和 Socket(通道)。经典入门项目 Echo 服务器:收到什么回什么。

// 服务端
import java.io.*;
import java.net.*;

public class EchoServer {
    public static void main(String[] args) throws IOException {
        try (ServerSocket server = new ServerSocket(9999)) {
            System.out.println("服务端就绪,9999 端口守候中...");
            while (true) {                        // 服务端永不退休
                Socket client = server.accept();  // 阻塞:没有连接就停在这
                System.out.println("客户端上线:" + client.getRemoteSocketAddress());
                try (BufferedReader in = new BufferedReader(
                         new InputStreamReader(client.getInputStream(), "UTF-8"));
                     PrintWriter out = new PrintWriter(
                         new OutputStreamWriter(client.getOutputStream(), "UTF-8"), true)) {
                    String line;
                    while ((line = in.readLine()) != null) {   // null = 对方关连接
                        out.println("[echo] " + line);          // 自动 flush
                    }
                }
            }
        }
    }
}

// 客户端
try (Socket s = new Socket("127.0.0.1", 9999);
     BufferedReader fromServer = new BufferedReader(
             new InputStreamReader(s.getInputStream(), "UTF-8"));
     BufferedReader keyboard = new BufferedReader(new InputStreamReader(System.in));
     PrintWriter toServer = new PrintWriter(s.getOutputStream(), true)) {
    String msg;
    while (!(msg = keyboard.readLine()).equals("bye")) {
        toServer.println(msg);
        System.out.println(fromServer.readLine());
    }
}

两个观察。其一,第 12 章的 IO 流体系在网络上是原样复用的:getInputStream 拿到的流和文件流同一个接口,读文件和读网络是同一套代码,这是流抽象的胜利。其二,new Socket(host, port) 内部就是 connect(三次握手发生在这一行的阻塞里),readLine() 阻塞在等数据(计网课第 4 章的「对方还没发」),阻塞是默认行为,理解它才能理解 13.4 的问题。

测试工具:服务端跑起来后,终端 nc localhost 9999 也能直接连(和你的 Java 客户端等价),先 nc 后写客户端是排错利器。

13.4 并发服务端:一线程一连接

上面的服务器同一时刻只能陪一个客户端聊天(accept 之后的 while 在伺候它,别人连不进来)。经典解法:每个连接开一个线程(操作系统课第 2 章的线程终于派上用场):

while (true) {
    Socket client = server.accept();          // 主线程只管接客
    new Thread(() -> handle(client)).start(); // 服务交给新线程
}

static void handle(Socket client) {
    try (client; ...) { /* 和上面的 echo 逻辑相同 */ }
}

这个模型直观、够用到几十上百连接;上限在千级连接(每线程 1MB 栈 + 上下文切换开销,操作系统课第 3 章的账),万级并发要换 IO 多路复用(Java NIO 的 Selector,计网课第 9 章 IO 多路复用的 Java 版),那是进阶课程的内容,知道方向即可。先跑通 thread-per-connection,再理解 NIO 解决什么问题,是学网络编程的正确顺序。

常见错误

  • 报错 Connection refused。服务端没起/端口不对/防火墙;先 nc 测一下再怀疑代码。
  • 消息粘在一起读不出来(TCP 粘包)。TCP 是字节流没有「消息边界」,println 的换行就是自造的分隔符(所以模板用 readLine);传二进制要自己设计长度头(前 4 字节写长度),这是网络编程第一设计课。
  • 流忘了 flush。PrintWriter 构造第二个参数 true = 自动 flush,自己 new 的 BufferedOutputStream 不 flush,数据闷在缓冲区里对方永远收不到。
  • 客户端异常退出服务端还在读。readLine 返回 null 或抛 IOException,catch 掉、关 socket、结束该线程,别让一个客户端的死亡拖垮服务端。

练习

  1. 动手:跑通 Echo 服务器 + nc 测试;再用 Java 客户端连,并用两个客户端同时连(观察第二个被卡住),验证 13.4 的单线程瓶颈。
  2. 实现:thread-per-connection 版并发 Echo;开 5 个 nc 同时聊天互不干扰;再用 jstack(JDK 自带)看线程数,数一数你的连接都对应哪些线程。
  3. 实现:UDP 双人聊天室(发送端 + 接收端合一,一个线程收一个线程发),体会 UDP 的「无连接」在代码里的样子(没有 accept、没有 while 读流)。
  4. 思考:TCP 粘包问题的本质是「字节流没有消息边界」。对比 HTTP 怎么解决(Content-Length 头/分块编码)和你 readLine 的方案,再想想「设计一个应用层协议」最少需要哪几个要素。