Skip to content

douglarek/bgnet-in-java

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 

Repository files navigation

用 Java 学 Beej's Guide to Network Programming

本仓库是对 Beej's Guide to Network Programming 的学习实践。 Beej 原文用 C 讲解 socket 编程,这里把每个核心概念都用 Java(java.net / java.nio)重新实现, 方便你用更简洁的代码理解同一套底层概念。

原文是 Unix/C 视角。Java 把很多琐碎的 struct/getaddrinfo() 封装掉了, 所以 Java 代码更短,但概念完全对应——每个 Java 调用背后是哪个 C 系统调用, 都在源码注释里标了出来。


目录

  1. Socket 是什么
  2. 两种 Internet Socket:TCP vs UDP
  3. IP 地址、字节序、struct
  4. 系统调用流程总览
  5. 示例 1:TCP 流式服务器 / 客户端
  6. 示例 2:UDP 数据报 listener / talker
  7. 示例 3:广播 broadcaster
  8. 示例 4:Selector 多路复用(poll/select 的 Java 版)
  9. 示例 5:数据封装与分帧
  10. 编译运行
  11. C ↔ Java 速查表

1. Socket 是什么

Unix 里有句话:「一切皆文件」。程序做 I/O 靠的是文件描述符(file descriptor,一个整数), 而网络连接也是一种"文件"。socket 就是一个用于网络通信的文件描述符。

  • C 里:调用 socket() 系统调用拿到一个 int 类型的 socket 描述符,再用 send()/recv() 收发。
  • Java 里:java.net.Socket / ServerSocket / DatagramSocket 对象内部就持有一个 socket 描述符, 你调用它的方法相当于在底层调用 send()/recv()

为什么不直接用 read()/write()?因为 send()/recv() 提供更多控制(带外数据、对端关闭感知等)。 不过 Beej 指出:在 socket 上用 read()/write() 其实也行——Java 的 Socket.getInputStream() 返回的 InputStream,底层就是在这条 socket 上做 read。


2. 两种 Internet Socket:TCP vs UDP

SOCK_STREAM(流式) SOCK_DGRAM(数据报)
协议 TCP UDP
连接 面向连接(要先 connect/accept 无连接
可靠性 可靠、有序、不丢 不保证送达、可能乱序
边界 字节流,没有消息边界 每个包独立,保留消息边界
Java 类 Socket / ServerSocket DatagramSocket / DatagramPacket
本仓库示例 StreamServer / StreamClient Listener / Talker

Beej 的比喻:TCP 像打电话(先拨号建立连接,然后双向通话,挂断要挥手); UDP 像寄明信片(写好地址扔邮筒,到不到不管)。

关键推论:因为 TCP 是字节流没有边界,发 3 条消息到对端可能粘成 1 块,也可能被拆成 5 块—— 这就引出了第 5 节"数据封装"的必要性。


3. IP 地址、字节序、struct

3.1 字节序(Byte Order)

这是 Beej 用大量篇幅强调的点:不同的 CPU 把多字节数存进内存的顺序不一样。

  • 大端(Big-Endian):高位字节在前。0xb34f 存成 b3 4f
  • 小端(Little-Endian):低位字节在前。Intel x86 就是小端,存成 4f b3

网络上传输规定用大端,称为 Network Byte Order;机器本地的称为 Host Byte Order。 所以发数据前要转成网络序,收到后转回主机序。C 提供四个函数:

C 函数 含义
htons() host to network short(2 字节)
htonl() host to network long(4 字节)
ntohs() network to host short
ntohl() network to host long

Java 里你基本不用操心这个java.io.DataInput/DataOutputByteBuffer 默认就是大端序 (= Network Byte Order)。只有当你用 ByteBuffer.order(ByteOrder.LITTLE_ENDIAN) 显式改过时才需要注意。 这是因为 Java 从一开始就规定 int 在内存里是大端,跨平台一致。

3.2 那一堆 struct

C 的 socket API 需要你手工填充一堆地址结构体:

struct sockaddr        // 通用地址,sa_family + 14 字节裸数据
struct sockaddr_in     // IPv4 地址:sin_family + sin_port + sin_addr
struct sockaddr_in6    // IPv6 地址:sin6_family + sin6_port + sin6_addr + ...
struct sockaddr_storage // 足够大,能装下 IPv4 或 IPv6,事后按 ss_family 转型
struct addrinfo        // getaddrinfo() 返回的链表节点,封装了上面的信息

历史包袱:sockaddr 太难填,于是发明了 sockaddr_in,两者可以互相转型。 getaddrinfo() 是现代做法,能自动处理 DNS 解析和 IPv4/IPv6,返回一个填好地址的链表。

Java 把这些全封装了

  • InetAddress / Inet4Address / Inet6Address 对应 struct in_addr / in6_addr
  • InetSocketAddress(host, port) = 地址 + 端口,对应 sockaddr_in
  • InetAddress.getByName("www.example.com") 做的就是 getaddrinfo() 的 DNS 解析工作。
  • 你永远不需要手工填 struct,也不需要 memsethtons(sin_port) 这些。

3.3 IP 地址字符串 ↔ 二进制

C 的 inet_pton()(presentation to network,字符串→二进制)和 inet_ntop()(反向)。 Java 里 InetAddress.getByName("10.12.110.57")inetAddress.getHostAddress() 完全对应。


4. 系统调用流程总览

Beej 最有价值的地方之一:明确告诉你这些调用该按什么顺序来。man page 不会告诉你这个。

TCP 服务器流程

getaddrinfo() → socket() → bind() → listen() → accept() → recv()/send() → close()

对应 Java:

new ServerSocket(port)        // 内含 bind + listen
  ↓
.accept()                     // 阻塞等连接,返回 Socket
  ↓
socket.getInputStream()       // recv
socket.getOutputStream()      // send
  ↓
socket.close()                // close

TCP 客户端流程

getaddrinfo() → socket() → connect() → send()/recv() → close()

对应 Java:

new Socket(); socket.connect(new InetSocketAddress(host, port))   // 内含 getaddrinfo + connect
  ↓
getInputStream / getOutputStream
  ↓
close

UDP(无连接,双方都简单)

服务器:getaddrinfo() → socket() → bind() → recvfrom() → close()
客户端:getaddrinfo() → socket() → sendto() → close()

对应 Java:new DatagramSocket(port) 就完成 socket+bind;receive(packet) / send(packet) 收发。


示例 1:TCP 流式服务器 / 客户端

对应 Beej 6.1 / 6.2 节。源码:beej/tcp/StreamServer.javabeej/tcp/StreamClient.java

服务器:监听 3490 端口,每来一个连接就 fork 一个线程回送 "Hello, world!"

核心对照:

Beej 的 C 代码 Java 等价
getaddrinfo(NULL, PORT, {AI_PASSIVE}) + bind() new ServerSocket(PORT, BACKLOG)
listen(sockfd, BACKLOG) ServerSocket 构造时已隐含
accept(sockfd, &their_addr, &sin_size) listener.accept()
if (!fork()) { send(new_fd, ...); } new Thread(() -> handleClient(...)).start()
sigaction(SIGCHLD, reap_handler) 回收僵尸进程 Java 线程结束自动回收,无需
close() try-with-resources 自动 close()

注意 Beej 在服务器里处理了 SIGCHLD(回收 fork() 产生的僵尸子进程)。Java 用线程而非进程, 线程结束由 GC 处理,所以这整段信号处理代码在 Java 里不存在——这是用 Java 学的好处之一: 概念更少,但能聚焦在网络逻辑本身

客户端:连接指定主机:3490,读一行打印。先不启动服务器直接连,会得到 Connection refused——和 C 版完全一样的体验。


示例 2:UDP 数据报 listener / talker

对应 Beej 6.3 节。源码:beej/udp/Listener.javabeej/udp/Talker.java

UDP 的最大特点:不需要 listen() / accept()。绑定端口后直接 recvfrom() 等包。 talker 连 connect() 都不用,sendto() 把目的地址写在参数里直接扔出去。

DatagramPacket 这个类同时承载"数据 + 来源/目的地址 + 长度", 正好对应 C 里 recvfrombuf + struct sockaddr *src_addr + socklen_t *addrlen 三件套。

测试时你会发现:先启动 Listener,再用 Talker 发包,Listener 收到;但如果先发包再启动 Listener, 包就丢了——这就是 UDP 的"不保证送达"。


示例 3:广播 broadcaster

对应 Beej 7.7 节。源码:beej/udp/Broadcaster.java

广播(broadcast)是 UDP 独有的能力(TCP 没有),可以让同一局域网内所有机器都收到一个包。

C 里的关键步骤是必须先 setsockopt(sockfd, SOL_SOCKET, SO_BROADCAST, &yes, sizeof yes), 否则 sendto 到广播地址会返回 Permission denied

Java 里这一行变成 sock.setBroadcast(true)——其余和普通 UDP 发送一模一样。

两种广播地址:

  • 255.255.255.255:受限广播,只在本地网段,路由器不转发。
  • 192.168.1.255:子网定向广播(网络号 + 主机位全 1)。

Beej 警告:广播会让局域网内每台机器都被迫处理这个包(哪怕它不监听该端口), 所以要慎用,别搞成 "smurf 攻击"。IPv6 已废弃广播,改用更优雅的 multicast。


示例 4:Selector 多路复用(poll/select 的 Java 版)

对应 Beej 7.2 / 7.3 节。源码:beej/advanced/SelectorServer.java

为什么要多路复用?

朴素的服务器是"一个连接一个线程/进程"(像 StreamServer 那样 fork)。 但连接数一多(几千上万),线程开销就扛不住。而且很多连接大部分时间在空闲等待数据, 占着线程却不干活。

select() / poll() 的思路:一个线程同时盯着很多 socket,谁就绪了就处理谁。 你把所有关心的 fd 塞进一个集合,调用 poll(),内核会阻塞直到至少一个 fd 有事件, 然后告诉你"这几个好了",你只处理这几个。

Java NIO 的 Selector

Java 把 select/poll 统一抽象成 java.nio.channels.Selector,概念完全对应:

Beej 的 poll() Java NIO
struct pollfd fds[N]; poll(fds, N, timeout) selector.select(timeout)
POLLIN / POLLOUT SelectionKey.OP_READ / OP_WRITE
POLLERR / POLLHUP 就绪后 read 返回 -1 表示对端关闭
遍历 revents 找就绪 fd 遍历 selector.selectedKeys()
监听 socket 用 OP_ACCEPT SelectionKey.OP_ACCEPT
fcntl(O_NONBLOCK) 设非阻塞 channel.configureBlocking(false)

本示例 SelectorServer 是个 echo 服务器:单线程,却能同时服务多个客户端。 用 telnet localhost 3490 连多个终端,输入字符会被原样回显,互不阻塞。

为什么示例里要 it.remove() selectedKeys() 返回的是就绪键的集合, 处理完一个必须手动移除,否则下一轮循环还会重复处理——这和 C 里 poll 返回后 你要自己检查 revents 并清空是同一个道理。


示例 5:数据封装与分帧

对应 Beej 7.6 节 "Son of Data Encapsulation"。源码:beej/serialization/

这是 Beej 全书最实用也最容易踩坑的一节,专门解决 TCP 的"粘包/半包"问题。

问题

TCP 是字节流,没有消息边界。客户端连发两条消息:

"tom: Hi"          (很短)
"Benjamin: Hey guys what is up?"  (很长)

接收方 recv() 时可能遇到:

  1. 两条粘在一起一次返回:tom: HiBenjamin: Hey guys...
  2. 第二条被拆成两次:先收到 Benjamin: Hey gu,再收到 ys what is up?

光看字节流,你根本不知道一条消息从哪开始、到哪结束。

解决:自定义协议(加头部)

Beej 的方案:给每条消息前面加一个小头部,定义包结构:

┌─────────┬──────────────────┬───────────────────┐
│ len (1) │ name (8, NUL 填充)│ chatdata (≤128)   │
└─────────┴──────────────────┴───────────────────┘
len = 整个包的字节数 = 1 + 8 + chatdata.length

有了 len,接收方就知道"还要再读多少字节才算一个完整包"。

Beej 强调:包里的多字节整数要用 Network Byte Order(大端)。 ChatPacket.toBytes()ByteBuffer,Java 默认就是大端,天然满足。

接收端的核心逻辑(FramedServer

维护一个工作缓冲区 work[],记录已用字节 used
循环:
  1. recv() 读更多字节追加到 work 末尾,used += n
  2. 只要 work 里够一个完整包:
       a. 读第一个字节 = packetLen
       b. 若 used >= packetLen,说明包齐了,解析它
       c. 把 work 里剩余字节(可能是下个包的开头)前移到首部
       d. used -= packetLen,回到步骤 2 继续看下一个包
  3. 包没齐就回到步骤 1 继续读

Beej 特意提醒:工作缓冲区要能装下至少两个包,因为一次 recv() 可能读进来 "一个完整包 + 下一个包的开头"。FramedServerwork = new byte[MAX_PACKET * 2] 就是这个原因。

发送端:sendall

Beej 7.4 节指出 send() 可能只发出请求字节的一部分(比如你要发 512 字节,它只发了 412), 剩下的要你自己循环补发。这就是 sendall() 函数。FramedClient.sendAll() 是它的 Java 版。


编译运行

需要 JDK 8+(已用 JDK 25 测试通过)。

cd /Users/admin/work/learning-bgnet

# 一次性编译所有源码到 out/
javac -d out $(find src -name "*.java")

跑 TCP 示例

# 终端 1
java -cp out beej.tcp.StreamServer
# 终端 2
java -cp out beej.tcp.StreamClient localhost
# 或直接用 telnet
telnet localhost 3490

跑 UDP 示例

# 终端 1
java -cp out beej.udp.Listener
# 终端 2
java -cp out beej.udp.Talker localhost "hello-udp"

跑 Selector echo 服务器

# 终端 1
java -cp out beej.advanced.SelectorServer
# 终端 2(可同时开多个,验证单线程服务多客户端)
telnet localhost 3490

跑数据封装示例

# 终端 1
java -cp out beej.serialization.FramedServer
# 终端 2,交互式输入 "名字 消息"
java -cp out beej.serialization.FramedClient localhost
# 输入:
#   tom Hi
#   Benjamin Hey guys what is up?
#   tom bye

跑广播(需在同一局域网,且把 Listener 的端口对上)

# 终端 1
java -cp out beej.udp.Listener
# 终端 2(注意:要把 Listener 也设成能收广播才有效,本示例演示发送权限即可)
java -cp out beej.udp.Broadcaster 255.255.255.255 "hello all"

C ↔ Java 速查表

概念 / C 调用 Java 等价
socket() new Socket() / new ServerSocket() / new DatagramSocket()
bind() 构造函数传端口即绑定
listen() ServerSocket 构造时隐含
accept() ServerSocket.accept()
connect() Socket.connect(InetSocketAddress) 或构造时传 host/port
send() / write() OutputStream.write()
recv() / read() InputStream.read()
sendto() DatagramSocket.send(DatagramPacket)
recvfrom() DatagramSocket.receive(DatagramPacket)
close() Socket.close()(推荐 try-with-resources)
shutdown() 半关闭 socket.shutdownOutput() / shutdownInput()
getaddrinfo() DNS 解析 InetAddress.getByName()
inet_pton() 字符串→地址 InetAddress.getByName("10.0.0.1")
inet_ntop() 地址→字符串 InetAddress.getHostAddress()
htons/htonl 字节序转换 不需要,Java 默认大端
struct sockaddr_in InetSocketAddress
struct sockaddr_storage InetSocketAddress(自动适配 v4/v6)
fcntl(O_NONBLOCK) 非阻塞 channel.configureBlocking(false)
poll() / select() Selector.select() + SelectionKey
setsockopt(SO_REUSEADDR) socket.setReuseAddress(true)
setsockopt(SO_BROADCAST) socket.setBroadcast(true)
fork() 多进程 new Thread() 多线程,或用线程池
SIGCHLD 回收僵尸 无需,线程由 GC 回收
perror() e.getMessage() / printStackTrace()
errno 异常对象

学习建议

  1. 先读 Beej 原文第 2、3、5 章建立概念(socket 是什么、字节序、系统调用顺序), 原文的幽默风格能帮你记住这些枯燥的 API。
  2. 对照本仓库的 Java 代码看每个 C 调用如何映射,注释里都标了对应关系。
  3. 动手跑:编译后按上面的命令实际运行,改改端口/消息观察行为。
  4. 重点啃第 5 节(数据封装):这是从"会调 API"到"能写真实网络协议"的关键一步, 理解了粘包/半包和分帧,你就真正入门网络编程了。
  5. 想深入非阻塞 I/O,再回到 Beej 7.2/7.3 节读 poll/select,配合本仓库的 SelectorServer

About

用 Java 重新实现 Beej's Guide to Network Programming 的核心示例,对照学习网络编程

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages