我竟然到现在才发现《Fundamental Networking in Java》这本神作,真有点无地自容的感觉。最近几年做的都是所谓的企业级开发,免不了和网络打交道,但在实际工作中,往往会采用框架将底层细节和上层应用隔离开,感觉就像是在一个 Word 模板表单里面填写内容,做出来也没什么成就感。虽然没有不使用框架的理由,但我还真是有点怀念当初直接用套接字做网络编程的日子,既能掌控更多东西,还可以学到更多知识,为研究框架的实现原理打基础。闲话完毕,转入今天的正题:IP(Internet Protocol,互联网协议)。
IP 基础知识
说到 IP,大多数人的第一反应估计都是 IP 地址。其实 IP 是一种协议,IP 地址只是协议的一部分。《RFC 791 - INTERNET PROTOCOL》说:“互联网协议是为在包交换计算机通信网络的互联系统中使用而设计的。”IP 包含三方面的功能:
- 用于查找主机的寻址系统
- 包格式的定义
- 传输和接收包的规则
IP 的相关 Java 类
从 Java 的角度来看上面说到的三个功能,只有第一个是开发人员需要关心的。另外两个都依赖底层系统的实现,JDK 也没有提供相关的类去操作。下面一一介绍 JDK 提供的用于处理 IP 地址的类。
InetAddress
此类用来表示 IP 地址,它有两个子类:Inet4Address
和 Inet6Address
,分别用于处理 IPv4 和 IPv6 两个版本。在实际应用中,InetAddress
足以应付绝大多数情况。它提供了一些静态方法来构造实例,能根据参数格式自动识别 IP 版本:
public static InetAddress[] getAllByName(String host) throws UnknownHostException
- 解析指定的主机地址,并返回其所有的 IP 地址;如果传入 IP 地址字符串,则只会校验格式,返回的数组也只包含一个代表该 IP 地址的实例。例如,想看看谷歌有多少马甲的话,
InetAddress.getAllByName("www.google.com")
就可以了。 public static InetAddress getByAddress(byte[] addr) throws UnknownHostException
- 用表示 IP 地址的字节数组(专业术语称为“原始 IP 地址”)构造一个实例。IPv4 地址必须是 4 个字节,IPv6 必须 16 个。不常用。
public static InetAddress getByAddress(String host, byte[] addr) throws UnknownHostException
- 用主机地址和原始 IP 地址构造一个实例。此方法应该慎用,因为它不会对主机名进行解析。即使主机名为 IP 地址字符串,也不会检查是否与字节数组一致。
public static InetAddress getByName(String host) throws UnknownHostException
- 用主机地址构造一个实例,也可以直接传入 IP 地址字符串,等同于
getAllByName(host)[0]
。 public static InetAddress getLocalHost() throws UnknownHostException
- 返回本机在网络中的地址。
public static InetAddress getLoopbackAddress()
- 返回环回地址
127.0.0.1
,不抛出异常,等同于 getByName("localhost")
(不要和 getLocalHost()
搞混)。环回地址使主机能够自己连接自己,常被用来对在同一台机器上测试网络应用程序。在 IPv4 中,环回地址的网段为 127.0.0.0/8
,通常用 127.0.0.1
;IPv6 中只有一个 ::1
。
接下来看看 InetAddress
中定义的部分实例方法:
public byte[] getAddress()
- 返回原始 IP 地址。
public String getCanonicalHostName()
- 返回全限定域名。这个方法可以用来探查实际的主机名,例如
InetAddress.getByName("www.google.com").getCanonicalHostName()
返回 we-in-f99.1e100.net。 public String getHostAddress()
- 返回构造时传入的主机地址。
public String getHostName()
- 返回主机名。如果构造时传入的主机地址为 IP 地址字符串,则调用
getCanonicalHostName()
,否则直接返回构造时传入的主机地址。 public boolean isAnyLocalAddress()
- 检查是否为通配符地址。通配符地址为
0.0.0.0
(IPv4)或 ::0
(IPv6),代表所有的本地 IP 地址。例如,假设电脑有两块网卡,各有一个地址,如果想让一个程序同时监听这两个地址,就需要用通配符地址。 public boolean isLinkLocalAddress()
- 检查是否为链路本地地址。IPv4 里定义为地址段
169.254.0.0/16
,Ipv6 里是以 fe80::/64
为前缀的地址。在电脑没联网的时候查看本机 IP,就能看到这种地址。 public boolean isLoopbackAddress()
- 检查是否为环回地址。
public boolean isSiteLocalAddress()
- 检查是否为站点本地地址。站点本地地址这个名词实际上已经过时了,现在叫唯一本地地址。IPv4 中未定义;IPv6 中定义为地址段
fc00::/7
。这些地址用于私有网络,例如企业内部的局域网。
此外还有一些有关多播地址的方法,暂时略过。
JDK 默认同时支持 IPv4 和 IPv6。如果只想使用一种,可以根据情况将 java.net.preferIPv4Stack
或 java.net.preferIPv6Addresses
这两个系统属性之一设为 true
。两个属性的默认值都为false
。一般来说不需要去惊动它们。
SocketAddress
该类是一个空壳,事实上应用程序使用的是它的唯一子类 InetSocketAddress
,目前还看不出这样设计有什么意义。该类只不过在 InetAddress
的基础上增加了一个端口属性。
NetworkInterface
该类代表网络接口,例如一块网卡。一个网络接口可以绑定一些 IP 地址。具有多个网络接口的主机被称为多宿主主机。下面的代码可打印出所有本机网络接口的信息:
1 2 3 4 5 6 7 | for (NetworkInterface ni : Collections.list(NetworkInterface.getNetworkInterfaces())) {
System.out.println(ni);
for (InterfaceAddress ia : ni.getInterfaceAddresses()) {
System.out.println( "\t" + ia);
}
System.out.println();
}
|
在我的笔记本上运行结果为:
name:lo (Software Loopback Interface 1) /127.0.0.1/8 [/127.255.255.255] /0:0:0:0:0:0:0:1/128 [null] name:net0 (WAN Miniport (SSTP)) name:net1 (WAN Miniport (L2TP)) name:net2 (WAN Miniport (PPTP)) name:ppp0 (WAN Miniport (PPPOE)) name:eth0 (WAN Miniport (IPv6)) name:eth1 (WAN Miniport (Network Monitor)) name:eth2 (WAN Miniport (IP)) name:ppp1 (RAS Async Adapter) name:net3 (WAN Miniport (IKEv2)) name:net4 (Intel(R) Wireless WiFi Link 4965AGN) /fe80:0:0:0:288a:2daf:3549:1811%11/64 [null] name:eth3 (Broadcom NetXtreme 57xx Gigabit Controller) /10.140.1.133/24 [/10.140.1.255] /fe80:0:0:0:78c7:e420:1739:f947%12/64 [null] name:net5 (Teredo Tunneling Pseudo-Interface) /fe80:0:0:0:e0:0:0:0%13/64 [null] name:net6 (Bluetooth Device (RFCOMM Protocol TDI)) name:eth4 (Bluetooth Device (Personal Area Network)) name:eth5 (Cisco AnyConnect VPN Virtual Miniport Adapter for Windows x64) name:net7 (Microsoft ISATAP Adapter) /fe80:0:0:0:0:5efe:a8c:185%17/128 [null] name:net8 (Microsoft ISATAP Adapter #2) name:net9 (Intel(R) Wireless WiFi Link 4965AGN-QoS Packet Scheduler-0000) name:eth6 (Broadcom NetXtreme 57xx Gigabit Controller-TM NDIS Sample LightWeight Filter-0000) name:eth7 (Broadcom NetXtreme 57xx Gigabit Controller-QoS Packet Scheduler-0000) name:eth8 (Broadcom NetXtreme 57xx Gigabit Controller-WFP LightWeight Filter-0000) name:eth9 (WAN Miniport (Network Monitor)-QoS Packet Scheduler-0000) name:eth10 (WAN Miniport (IP)-QoS Packet Scheduler-0000) name:eth11 (WAN Miniport (IPv6)-QoS Packet Scheduler-0000) name:net10 (Intel(R) Wireless WiFi Link 4965AGN-Native WiFi Filter Driver-0000) name:net11 (Intel(R) Wireless WiFi Link 4965AGN-TM NDIS Sample LightWeight Filter-0000) name:net12 (Intel(R) Wireless WiFi Link 4965AGN-WFP LightWeight Filter-0000)
JDK 提供了对 TCP(Transmission Control Protocol,传输控制协议)和 UDP(User Datagram Protocol,用户数据报协议)这两个数据传输协议的支持。本文开始探讨 TCP。
TCP 基础知识
在“服务器-客户端”这种架构中,服务器和客户端各自维护一个端点,两个端点需要通过网络进行数据交换。TCP 为这种需求提供了一种可靠的流式连接,流式的意思是传出和收到的数据都是连续的字节,没有对数据量进行大小限制。一个端点由 IP 地址和端口构成(专业术语为“元组 {IP 地址, 端口}”)。这样,一个连接就可以由元组 {本地地址, 本地端口, 远程地址, 远程端口} 来表示。
连接过程
在 TCP 编程接口中,端点体现为 TCP 套接字。共有两种 TCP 套接字:主动和被动,“被动”状态也常被称为“侦听”状态。服务器和客户端利用套接字进行连接的过程如下:
1、服务器创建一个被动套接字,开始循环侦听客户端的连接。
2、客户端创建一个主动套接字,连接服务器。
3、服务器接受客户端的连接,并创建一个代表该连接的主动套接字。
4、服务器和客户端通过步骤 2 和 3 中创建的两个主动套接字进行数据传输。
下面是连接过程的图解:
一个简单的 TCP 服务器
JDK 提供了 ServerSocket 类来代表 TCP 服务器的被动套接字。下面的代码演示了一个简单的 TCP 服务器(多线程阻塞模式),它不断侦听并接受客户端的连接,然后将客户端发送过来的文本按行读取,全文转换为大写后返回给客户端,直到客户端发送文本行 bye:
- public class TcpServer implements Runnable {
- private ServerSocket serverSocket;
-
- public TcpServer(int port) throws IOException {
- // 创建绑定到某个端口的 TCP 服务器被动套接字。
- serverSocket = new ServerSocket(port);
- }
-
- @Override
- public void run() {
- while (true) {
- try {
- // 以阻塞的方式接受一个客户端连接,返回代表该连接的主动套接字。
- Socket socket = serverSocket.accept();
- // 在新线程中处理客户端连接。
- new Thread(new ClientHandler(socket)).start();
- } catch (IOException ex) {
- ex.printStackTrace();
- }
- }
- }
- }
-
- public class ClientHandler implements Runnable {
- private Socket socket;
-
- public ClientHandler(Socket socket) {
- this.socket = Objects.requireNonNull(socket);
- }
-
- @Override
- public void run() {
- try (Socket s = socket) { // 减少代码量的花招……
- // 包装套接字的输入流以读取客户端发送的文本行。
- BufferedReader in = new BufferedReader(new InputStreamReader(
- s.getInputStream(), StandardCharsets.UTF_8));
- // 包装套接字的输出流以向客户端发送转换结果。
- PrintWriter out = new PrintWriter(new OutputStreamWriter(
- s.getOutputStream(), StandardCharsets.UTF_8), true);
-
- String line = null;
- while ((line = in.readLine()) != null) {
- if (line.equals("bye")) {
- break;
- }
-
- // 将转换结果输出给客户端。
- out.println(line.toUpperCase(Locale.ENGLISH));
- }
- } catch (IOException ex) {
- ex.printStackTrace();
- }
- }
- }
|
阻塞模式的编程方式简单,但存在性能问题,因为服务器线程会卡死在接受客户端的 accept() 方法上,不能有效利用资源。套接字支持非阻塞模式,现在暂时略过。
一个简单的 TCP 客户端
JDK 提供了 Socket 类来代表 TCP 客户端的主动套接字。下面的代码演示了上述服务器的客户端:
- public class TcpClient implements Runnable {
- private Socket socket;
-
- public TcpClient(String host, int port) throws IOException {
- // 创建连接到服务器的套接字。
- socket = new Socket(host, port);
- }
-
- @Override
- public void run() {
- try (Socket s = socket) { // 再次减少代码量……
- // 包装套接字的输出流以向服务器发送文本行。
- PrintWriter out = new PrintWriter(new OutputStreamWriter(
- s.getOutputStream(), StandardCharsets.UTF_8), true);
- // 包装套接字的输入流以读取服务器返回的文本行。
- BufferedReader in = new BufferedReader(new InputStreamReader(
- s.getInputStream(), StandardCharsets.UTF_8));
-
- Console console = System.console();
- String line = null;
- while ((line = console.readLine()) != null) {
- if (line.equals("bye")) {
- break;
- }
-
- // 将文本行发送给服务器。
- out.println(line);
- // 打印服务器返回的文本行。
- console.writer().println(in.readLine());
- }
-
- // 通知服务器关闭连接。
- out.println("bye");
- } catch (IOException ex) {
- ex.printStackTrace();
- }
- }
- }
|
从 JDK 文档可以看到,ServerSocket 和 Socket 在初始化的时候,可以设定一些参数,还支持延迟绑定。这些东西对性能和行为都有所影响。后续两篇文章将分别详解这两个类的初始化。