网络通信

每天学习新知识,每天进步一点点。

在程序设计中,多线程就是指一个应用程序中有多条并发执行的线索,每条线索都被称作一个线程。他们会交替执行,彼此间可以进行通信。本文将对Java中的多线程知识进行介绍。

1. 网络编程基础

1.1 网络通信协议

通过计算机网络可以使多台计算机实现连接,但是位于同一个网络中的计算机在进行连接和通信时必须要遵守一定的规则,这就好比在道路中行驶的汽车一样要遵守交通规则。在计算机网络中,这些连接和通信的规则被称为网络通信协议,它对数据的传输格式、传输速率、传输步骤等做了统一规定,通信双方必须同时遵守才能完成数据交互。

网络通信协议有很多种,目前应用最广泛的有TCP/IP协议(Transmission Control Protocol/Internet Protocol,传输控制协议/英特网互联协议)UDP协议(User Datagram Protocol,用户数据报协议)和其他一些协议的协议组。

为了减少网络编程设计的复杂性,绝大多数网络采用分层设计方法。所谓分层设计,就是按照信息的流动过程将网络的整体功能分解为一个个的功能层,不同机器上的同等功能层之间采用相同的协议,同一机器上的相邻功能层之间通过接口进行信息传递。本文中所学的网络编程知识,主要就是基于TCP/IP协议中的内容,在学习具体的内容之前,首先来了解一下TCP/IP协议。TCP/IP协议(又称为TCP/IP协议簇)是一组用于实现网络互联的通信协议,其名称来源于该协议簇中的两个重要协议TCP协议和IP协议,基于TCP/IP协议参考模型的网络层次结构比较简单,共分为四层,如图1所示。

TCP/IP网络模型

图1 TCP/IP网络模型

图1中,TCP/IP协议中的四层结构分别是链路层(也叫网络接口层)、网络层、传输层和应用层,每层分别负责不同的通信功能,接下来针对这四层结构进行详细地讲解。

  • 链路层:链路层是用于定义物理传输通道,通常是对某些网络连接设备的驱动协议,例如针对光纤、双绞线提供的驱动。

  • 网络层:网络层是整个TCP/IP协议的核心,它主要用于将传输的数据进行分组,将分组数据发送到目标计算机或者网络。

  • 运输层:主要使网络程序进行通信,在进行网络通信时,可以采用TCP协议,也可以采用UDP协议。

  • 应用层:主要为互联网中的各种网络应用提供服务。

1.2 IP地址和端口号

要想使网络中的计算机能够进行通信,还必须为每台计算机指定一个标识号,通过这个标识号来指定接收数据的计算机或者发送数据的计算机。在TCP/IP协议中,这个标识号就是IP地址,它可以唯一标识一台计算机,目前,IP地址广泛使用的版本是IPv4,它是由4个字节大小的二进制数来表示,如:00001010000000000000000000000001。由于二进制形式表示的IP地址非常不便记忆和处理,因此通常会将IP地址写成十进制的形式,每个字节用一个十进制数字(0-255)表示,数字间用符号“.”分开表示4段数字,如“10.0.0.1”。

随着计算机网络规模的不断扩大,对IP地址的需求也越来越多,IPv4这种用4个字节表示的IP地址面临枯竭(实际情况是在2011年2月3日IPv4位地址已经分配完毕),因此IPv6 版本的IP地址便应运而生了,IPv6使用16个字节表示IP地址,它所拥有的地址容量约是IPv4的8×1028倍,达到2128个(算上全零的),这样就解决了网络地址资源数量不足的问题。

最初设计互联网时,为了便于寻址以及层次化构造网络,每个IP地址由两部分组成,即“网络.主机”的形式,其中网络部分表示其属于互联网的哪一个网络,是网络的地址编码主机部分表示其属于该网络中的哪一台主机,是网络中一个主机的地址编码,二者是主从关系。IP地址总共分为5类,常用的有A、B、C三类(另外的D和E类为特殊地址),介绍如下:

  • A类地址:由第一段的网络地址和其余三段的主机地址组成,范围是1.0.0.0到127.255.255.255。

  • B类地址:由前两段的网络地址和其余两段的主机地址组成,范围是128.0.0.0到191.255.255.255。

  • C类地址:由前三段的网络地址和最后一段的主机地址组成,范围是192.0.0.0到223.255.255.255。

另外,还有一个本地回环地址127.0.0.1,指本机地址,该地址一般用来测试使用,例如:ping 127.0.0.1 来测试本机TCP/IP协议是否正常。

通过IP地址可以连接到指定计算机,但如果想访问目标计算机中的某个应用程序,还需要指定端口号。在计算机中,不同的应用程序是通过端口号区分的。端口号是用两个字节(16位的二进制数)表示的,它的取值范围是065535,其中,01023之间的端口号用于一些知名的网络服务和应用,用户的普通应用程序需要使用1024以上的端口号,从而避免端口号被另外一些应用或服务所占用。

接下来通过一个图例来描述IP地址和端口号的作用,如图1-2所示。

图1-2 IP地址和端口号

从图1-2可以看出,位于网络中一台计算机可以通过IP地址去访问另一台计算机,并通过端口号访问目标计算机中的某个应用程序。

1.3 InetAddress

在JDK中提供了一个与IP地址相关的InetAddress类,该类用于封装一个IP地址,并提供了一系列与IP地址相关的方法,接下来列举InetAddress类中的一些常用方法,如表1所示。

表1 InetAddress类的常用方法

方法声明功能描述
InetAddress getByName(String host)获取给定主机名的的IP地址,host参数表示指定主机
InetAddress getLocalHost()获取本地主机地址
String getHostName()获取本地IP地址的主机名
boolean isReachable(int timeout)判断在限定时间内指定的IP地址是否可以访问
String getHostAddress()获取字符串格式的原始IP地址

表1中,列举了InetAddress的五个常用方法。其中,前两个方法用于获得该类的实例对象,第一个方法用于获得表示指定主机的InetAddress对象,第二个方法用于获得表示本地的InetAddress对象。通过InetAddress对象便可获取指定主机名、IP地址等,接下来通过一个案例来演示InetAddress类常用方法的基本使用,如下所示。
例1-3 Demo1.java

import java.net.InetAddress; // 导入InetAddress类,用于获取IP地址和主机名

public class Demo1 {
    public static void main(String[] args) throws Exception {
        // 获取本地计算机的InetAddress对象
        InetAddress add = InetAddress.getLocalHost();
        
        // 根据指定主机名获取InetAddress对象(此处为百度)
        InetAddress bd = InetAddress.getByName("www.baidu.com");
        
        // 输出本地计算机的主机名
        System.out.println("本地的主机名:" + add.getHostName());
        
        // 输出本地计算机的IP地址
        System.out.println("原始的IP地址为:" + add.getHostAddress());
        
        // 输出百度的主机名
        System.out.println("百度的主机名:" + bd.getHostName());
        
        // 输出百度的IP地址
        System.out.println("百度的IP地址为:" + bd.getHostAddress());
        
        // 判断是否可以在3秒内访问百度,并输出结果
        System.out.println("是否可以访问百度:" + bd.isReachable(3000));
    }
}

输出

本地的主机名:little-thing
原始的IP地址为:192.168.56.1
百度的主机名:www.baidu.com
百度的IP地址为:180.101.50.188
是否可以访问百度:true

从结果可以看出,InetAddress类中常用方法的具体使用效果。需要注意的是,getHostName()方法用于得到某个主机的域名,如果InetAddress对象是通过主机名创建的,则将返回该主机名,否则,将根据IP地址反向查找对应的主机名,如果找到将其返回,否则将返回IP地址。

1.4 UDP与TCP协议

在介绍TCP/IP结构时,提到传输层两个重要的高级协议,分别是UDPTCP,其中UDP是User Datagram Protocol的简称,称为用户数据报协议TCP是Transmission Control Protocol的简称,称为传输控制协议

UDP是无连接通信协议,即在数据传输时,数据的发送端和接收端不建立逻辑连接。简单来说,当一台计算机向另外一台计算机发送数据时,发送端不会确认接收端是否存在,就会发出数据,同样接收端在收到数据时,也不会向发送端反馈是否收到数据。由于使用UDP协议消耗资源小、通信效率高、延迟小,所以通常都会用于音频、视频和普通数据的传输,例如视频会议都使用UDP协议,因为这种情况即使偶尔丢失一两个数据包,也不会对接收结果产生太大影响。但是在使用UDP协议传送数据时,由于UDP的面向无连接性,不能保证数据的完整性,因此在传输重要数据时不建议使用UDP协议。UDP的交互过程如图所示。

UDP协议通信

图1-4 UDP协议通信

TCP协议是面向连接的通信协议,即在传输数据前先在发送端和接收端建立逻辑连接,然后再传输数据,它保证了两台计算机之间可靠无差错的数据传输。在TCP连接中必须要明确客户端与服务器端,先由客户端向服务端发出连接请求,每次连接的创建都需要经过“三次握手”:第一次握手,客户端向服务器端发出连接请求,等待服务器确认;第二次握手,服务器端向客户端返回一个响应,通知客户端收到了连接请求;第三次握手,客户端再次向服务器端发送确认信息,确认连接。所以,TCP协议传送速度较慢,但传送的数据比较可靠。TCP的整个交互过程如图所示。

TCP协议通信
图1-5 TCP协议通信

由于TCP协议的面向连接特性,它可以保证传输数据的安全性和完整性,所以是一个被广泛采用的协议,例如在下载文件时,如果数据接收不完整,将会导致文件数据丢失而不能被打开,因此,下载文件时必须采用TCP协议。

2. UDP通信

2.1 UDP通信简介

UDP是一种面向无连接的协议,因此,在通信时发送端和接收端不用建立连接。UDP通信的过程就像是货运公司在两个码头间发送货物一样,在码头发送和接收货物时都需要使用集装箱来装载货物。UDP通信也是一样,发送和接收的数据也需要使用“集装箱”进行打包,为此JDK中提供了一个DatagramPacket类,该类的实例对象就相当于一个集装箱,用于封装UDP通信中发送或者接收的数据。然而运输货物只有“集装箱”是不够的,还需要有码头。为此,JDK提供了与之对应的DatagramSocket类,该类的作用就类似于码头,使用这个类的实例对象就可以发送和接收DatagramPacket数据报,发送和接收数据的过程如图所示。

UDP通信

图2-1 UDP通信

了解了DatagramPacket、DatagramSocket在数据发送与接收端通信过程中的作用后,接下来针对DatagramPacket和DatagramSocket进行详细地讲解。

2.2 DatagramPacket

DatagramPacket用于封装UDP通信中的数据,在创建发送端和接收端的DatagramPacket对象时,使用的构造方法有所不同,接收端的构造方法只需要接收一个字节数组来存放接收到的数据,而发送端的构造方法不但要接收存放了发送数据的字节数组还需要指定发送端IP地址和端口号。接下来根据API文档,对DatagramPacket类常用的构造方法进行详细地讲解。

  • DatagramPacket(byte[] buf,int length)

使用该构造方法在创建DatagramPacket对象时,指定了封装数据的字节数组和数据的大小,没有指定IP地址和端口号。很明显,这样的对象只能用于接收端,不能用于发送端。因为发送端一定要明确指出数据的目的地(IP地址和端口号),而接收端不需要明确知道数据的来源,只需要接收到数据即可。

  • DatagramPacket(byte[] buf,int offset,int length)

该构造方法与第1个构造方法类似同样用于接收端,只不过在第1个构造方法的基础上,增加了一个offset参数,该参数用于指定一个数组中发送数据的偏移量为offset,即从offset位置开始发送数据

  • DatagramPacket(byte[] buf,int length,InetAddress addr,int port)

使用该构造方法在创建DatagramPacket对象时,不仅指定了封装数据的字节数组和数据的大小,还指定了数据包的目标IP地址(addr)和端口号(port)。该对象通常用于发送端,因为在发送数据时必须指定接收端的IP地址和端口号,就好像发送货物的集装箱上面必须标明接收人的地址一样。

  • DatagramPacket(byte[] buf,int offset,int length,InetAddress addr,int port)

该构造方法与第3个构造方法类似同样用于发送端,只不过在第3个构造方法的基础上,增加了一个offset参数,该参数用于指定一个数组中发送数据的偏移量为offset,即从offset位置开始发送数据。

上面我们讲解了DatagramPacket的常用构造方法,接下来对DatagramPacket类中的常用方法进行详细地讲解,如表所示。

表2-1 DatagramPacket类中的常用方法

方法声明功能描述
InetAddress getAddress()该方法用于返回发送端或者接收端的IP地址,如果是发送端的DatagramPacket对象,就返回接收端的IP地址;反之,就返回发送端的IP地址
int getPort()该方法用于返回发送端或者接收端的端口号,如果是发送端的DatagramPacket对象,就返回接收端的端口号;反之,就返回发送端的端口号
byte[] getData()该方法用于返回将要接收或者将要发送的数据,如果是发送端的DatagramPacket对象,就返回将要发送的数据;反之,就返回接收到的数据
int getLength()该方法用于返回接收或者将要发送数据的长度,如果是发送端的DatagramPacket对象,就返回将要发送的数据长度;反之,就返回接收到数据的长度

表中,列举了DatagramPacket类的四个常用方法及其功能,通过这四个方法,可以得到发送或者接收到的DatagramPacket数据包中的信息。

2.3 DatagramSocket

DatagramSocket用于创建发送端和接收端对象,然而在创建发送端和接收端的DatagramSocket对象时,使用的构造方法有所不同,下面对DatagramSocket类中常用的构造方法进行讲解。

  • DatagramSocket()

该构造方法用于创建发送端的DatagramSocket对象,在创建DatagramSocket对象时,并没有指定端口号,此时,系统会分配一个没有被其它网络程序所使用的端口号

  • DatagramSocket(int port)

该构造方法既可用于创建接收端的DatagramSocket对象,也可以创建发送端的DatagramSocket对象,在创建接收端的DatagramSocket对象时,必须要指定一个端口号,这样就可以监听指定的端口。

  • DatagramSocket(int port,InetAddress addr)

使用该构造方法在创建DatagramSocket时,不仅指定了端口号还指定了相关的IP地址,这种情况适用于计算机上有多块网卡的情况,可以明确规定数据通过哪块网卡向外发送和接收哪块网卡的数据。由于计算机中针对不同的网卡会分配不同的IP,因此在创建DatagramSocket对象时需要通过指定IP地址来确定使用哪块网卡进行通信。

上面我们讲解了DatagramSocket的常用构造方法,接下来对DatagramSocket类中的常用方法进行详细地讲解,如表所示。

表2-2 DatagramSocket类中的常用方法

方法声明功能描述
void receive(DatagramPacket p)该方法用于接收DatagramPacket数据报,在接收到数据之前会一直处于阻塞状态,如果发送消息的长度比数据报长,则消息将会被截取
void send(DatagramPacket p)该方法用于发送DatagramPacket数据报,发送的数据报中包含将要发送的数据、数据的长度、远程主机的IP地址和端口号
void close()关闭当前的Socket,通知驱动程序释放为这个Socket保留的资源

表中,针对DatagramSocket类中的常用方法及其功能进行了介绍。其中前两个方法可以完成数据的发送或者接收的功能。

2.4 UDP网络程序

前面两个小节讲解了DatagramPacket和DatagramSocket的相关知识,接下来通过一个案例来学习一下它们的具体用法。要实现UDP通信需要创建一个发送端程序和一个接收端程序,很明显,在通信时只有接收端程序先运行,才能避免因发送端发送的数据无法接收,而造成数据丢失。因此,首先需要来完成接收端程序的编写,如下所示。
例2-1 Demo2.java

import java.net.*; // 导入java.net包中的所有类

public class Demo2 {
    public static void main(String[] args) throws Exception {
        // 创建一个DatagramSocket对象,绑定到8998端口
        DatagramSocket server = new DatagramSocket(8998);
        
        // 创建一个字节数组,用于存储接收到的数据
        byte[] bytes = new byte[1024];
        
        // 创建一个DatagramPacket对象,用于接收数据包
        DatagramPacket dp = new DatagramPacket(bytes, bytes.length);
        
        System.out.println("等待接收数据");
        
        // 无限循环,以持续接收数据
        while (true) {
            // 接收数据,阻塞直到接收到数据
            server.receive(dp);
            
            // 将接收到的数据转化为字符串
            String s = new String(dp.getData(), 0, dp.getLength());
            
            // 输出接收到的数据的发送方IP地址、端口号和消息内容
            System.out.println("接收到了IP地址为:" + dp.getAddress() + "的" + dp.getPort() + "端口的消息:" + s);
        }
    }
}

接下来编写发送端程序,如下所示。
例2-2 Demo3.java

import java.net.*; // 导入java.net包中的所有类

public class Demo3 {
    public static void main(String[] args) throws Exception {
        // 获取本地计算机的InetAddress对象
        InetAddress add = InetAddress.getLocalHost();
        
        // 创建一个DatagramSocket对象,选择端口6326
        DatagramSocket server = new DatagramSocket(6326);
        
        // 要发送的字符串消息
        String str = "十一月的早晨,吹进卧室的风";
        
        // 创建一个DatagramPacket对象,用于发送数据包
        // 参数分别为:消息内容的字节数组、消息长度、本地IP地址、目的端口8998
        DatagramPacket dp = new DatagramPacket(str.getBytes(), str.getBytes().length, add, 8998);
        
        // 发送数据包
        server.send(dp);
        
        // 关闭DatagramSocket
        server.close();
    }
}

案例中创建了一个UDP发送端程序,用来发送数据。在创建DatagramPacket对象时需要指定目标IP地址和端口号,而且端口号必须要和接收端指定的端口号一致,这样调用DatagramSocket的send()方法才能将数据发送到对应的接收端。

在接收端程序阻塞的状态下,运行发送端程序,接收端程序就会收到发送端发送的数据而结束阻塞状态,打印接收的数据如图所示。

UDP案例.jpg

需要注意的是,在创建发送端的DatagramSocket对象时,可以不指定端口号,而指定端口号目的就是,为了每次运行时接收端的getPort()方法返回值都是一致的,否则发送端的端口号由系统自动分配,接收端的getPort()方法的返回值每次都不同。

tip:UDP程序在发送数据时,是一次性全部封装到DatagramPacket数据报中进行统一发送的,然而DatagramPacket数据报一次性允许封装的数据量是有限度的,理论最大数量是65507字节(即IP数据报的最大限制65535字节减去IP首部的20字节和UDP首部的8字节),但实际上总是比这要少的多,在许多平台下,实际的最大限制是8192字节(8K),甚至低于8K。因此,UDP协议中如果发送端一次性发送超过8K的数据报,就需要特别注意,大多时候,这种更大的包会被简单的截取到8K数据。同时,从案例可以看出,接收DatagramPacket数据报的缓存区大小设置为了1024字节大小,如果发送端的数据过大,显然也会出现数据丢失的情况,所以这种情况下一般会将接收端DatagramPacket数据报的缓存区大小设置为UDP数据理论最大限制65507字节,但本程序数据量较小所以将缓冲区设置为1024字节大小。

3. TCP通信

3.1 TCP通信简介

TCP通信同UDP通信一样,都能实现两台计算机之间的通信,通信的两端则都需要创建Socket对象。TCP通信与UDP通信的其中一个主要区别在于,UDP中只有发送端和接收端,不区分客户端与服务器端,计算机之间可以任意地发送数据;而TCP通信是严格区分客户端与服务器端的,在通信时,必须先由客户端去连接服务器端才能实现通信,服务器端不可以主动连接客户端。

在JDK中提供了两个用于实现TCP程序的类,一个是ServerSocket类,用于表示服务器端;另一个是Socket类,用于表示客户端。通信时,首先要创建代表服务器端的ServerSocket对象,该对象相当于开启一个服务,并等待客户端的连接;然后创建代表客户端的Socket对象,并向服务器端发出连接请求,服务器端响应请求,两者建立连接后可以正式进行通信。整个通信过程如图1所示。

Socket和ServerSocket通信

图3-1 Socket和ServerSocket通信

了解了ServerSocket、Socket在服务器端与客户端通信过程中的作用后,接下来针对ServerSocket和Socket进行详细地讲解。

3.2 ServerSocket

在开发TCP程序时,首先需要创建服务器端程序。JDK的java.net包中提供了一个ServerSocket类,该类的实例对象可以实现一个服务器端的程序。通过查阅API文档可知,ServerSocket类提供了多个构造方法,接下来就对ServerSocket的构造方法进行详细地讲解。

  • ServerSocket()

使用该构造方法在创建ServerSocket对象时并没有指定端口号,因此该对象不监听任何端口,不能直接使用,使用时还需要调用bind(SocketAddress endpoint)方法将其绑定到指定的端口号上

  • ServerSocket(int port)

使用该构造方法在创建ServerSocket对象时,可以将其绑定到指定的端口号上。如果port参数值为0,此时系统就会分配一个未被其他程序占用的端口号。由于客户端需要根据指定的端口号来访问服务器端程序,因此端口号随机分配的情况并不常用,通常都会给服务器端指定一个端口号。

  • ServerSocket(int port, int backlog)

该构造方法就是在第2个构造方法的基础上,增加了一个backlog参数。该参数用于指定在服务器忙时,可以与之保持连接请求的等待客户端数量,如果没有指定这个参数,默认为50

  • ServerSocket(int port, int backlog, InetAddress bindAddr)

该构造方法就是在第3个构造方法的基础上,指定了相关的IP地址,这种情况适用于计算机上有多块网卡和多个IP的情况,使用时可以明确规定ServerSocket在哪块网卡或IP地址上等待客户端的连接请求。显然,对于一般只有一块网卡的情况,就不用专门指定该参数。

在以上介绍的构造方法中,第2个构造方法是最常使用的。了解了如何通过ServerSocket的构造方法创建对象,接下来学习一下ServerSocket的常用方法,如表所示。

表3-1 ServerSocket的常用方法

方法声明功能描述
Socket accept()该方法用于等待客户端的连接,在客户端连接之前一直处于阻塞状态,如果有客户端连接就会返回一个与之对应的Socket对象
InetAddress getInetAddress()该方法用于返回一个InetAddress对象,该对象中封装了ServerSocket绑定的IP地址
boolean isClosed()该方法用于判断ServerSocket对象是否为关闭状态,如果是关闭状态则返回true,反之则返回false
void bind(SocketAddress endpoint)该方法用于将ServerSocket对象绑定到指定的IP地址和端口号,其中参数endpoint 封装了IP 地址和端口号

ServerSocket对象负责监听某台客户端计算机的端口号,在创建ServerSocket对象后,需要继续调用该对象的accept()方法,接收来自客户端的请求。当执行了accept()方法之后,服务器端程序会发生阻塞,直到客户端发出连接请求,accept()方法才会返回一个Scoket对象用于和客户端实现通信,程序才能继续向下执行。

3.3 Socket

ServerSocket对象可以实现服务端程序,但只实现服务器端程序还不能完成通信,此时还需要一个客户端程序与之交互,为此JDK提供了一个Socket类,用于实现TCP客户端程序。通过查阅API文档可知Socket类同样提供了多个构造方法,接下来就对Socket的常用构造方法进行详细讲解。

  • Socket()

使用该构造方法在创建Socket对象时,并没有指定IP地址和端口号,也就意味着只创建了客户端对象,并没有去连接任何服务器。通过该构造方法创建对象后还需调用connect(SocketAddress endpoint)方法,才能完成与指定服务器端的连接,其中参数endpoint用于封装IP地址和端口号

  • Socket(String host, int port)

使用该构造方法在创建Socket对象时,会根据参数去连接在指定地址和端口上运行的服务器程序,其中参数host接收的是一个字符串类型的IP地址

  • Socket(InetAddress address, int port)

该方法在使用上与第2个构造方法类似,参数address用于接收一个InetAddress类型的对象,该对象用于封装一个IP地址

在以上Socket的构造方法中,最常用的是第1个构造方法。了解了Socket构造方法的用法,接下来学习一下Socket的常用方法,如表所示。

表3-3 Socket的常用方法

方法声明功能描述
int getPort()该方法用于返回此Socket连接的远程服务端的端口号
InetAddress getLocalAddress()该方法用于获取Socket对象绑定的本地IP地址,并将IP地址封装成InetAddress类型的对象返回
void close()该方法用于关闭Socket连接,结束本次通信。在关闭Socket之前,应将与Socket相关的所有的输入/输出流全部关闭,这是因为一个良好的程序应该在执行完毕时释放所有的资源
InputStream getInputStream()该方法返回一个InputStream类型的输入流对象。如果该对象是由服务器端的Socket返回,就用于读取客户端发送的数据;反之,用于读取服务器端发送的数据
OutputStream getOutputStream()该方法返回一个OutputStream类型的输出流对象。如果该对象是由服务器端的Socket返回,就用于向客户端发送数据;反之,用于向服务器端发送数据

表1中列举了Socket类的常用方法,其中getInputStream()和getOutStream()方法分别用于获取输入流和输出流。当客户端和服务端建立连接后,数据是以IO流的形式进行交互,从而实现通信的。接下来通过一张图来描述服务器端和客户端的数据传输,如图1所示。

服务端和客户端通信图
图3-3 服务端和客户端通信图

3.4 简单的TCP网络程序

通过前面两个小节的讲解,了解了ServerSocket、Socket类的基本用法,为了让初学者更好地掌握这两个类,接下来通过一个TCP通信的案例来进一步学习这两个类的基本使用。

要实现TCP通信需要创建一个服务器端程序和一个客户端程序,为了保证数据传输的安全性,首先需要实现服务器端程序,如下所示。
例3-4 Demo4.java

import java.net.*; // 导入网络相关的类
import java.io.*;  // 导入输入输出相关的类

public class Demo4 {
    public static void main(String[] args) throws InterruptedException, IOException {
        // 创建一个绑定到端口9544的ServerSocket对象
        ServerSocket client = new ServerSocket(9544);
        
        // 无限循环,持续接受客户端的连接
        while (true) {
            // 接受客户端的连接请求,并返回一个Socket对象
            Socket sc = client.accept();
            // 获取与客户端通信的输出流
            OutputStream os = sc.getOutputStream();
			System.out.println("与客户端连接成功,开始进行数据交互");
            
            // 向客户端发送一条消息
            os.write(("服务端发送数据:十一月的早晨,吹进卧室的风!").getBytes());
            
            // 关闭输出流
            os.close();
            // 关闭与客户端的Socket连接
            sc.close();
        }
    }
}

接下来创建客户端程序,如下所示。
例3-5 Demo5.java

import java.net.*; // 导入网络相关类
import java.io.*;  // 导入输入输出相关类

public class Demo5 {
    public static void main(String[] args) throws InterruptedException, Exception {
        // 创建一个Socket对象,连接到本地地址的9544端口
        Socket sk = new Socket(InetAddress.getLocalHost(), 9544);
        
        // 获取与服务器通信的输入流
        InputStream is = sk.getInputStream();
        
        // 创建一个字节数组,大小为1024,用于存储读取到的数据
        byte[] buff = new byte[1024];
        int len = 0; // 定义读取到的字节数

        // 循环读取输入流中的数据,直到没有数据可读取
        while ((len = is.read(buff)) != -1) {
            // 将读取到的字节数组中的内容转换为字符串,并输出
            System.out.println(new String(buff, 0, len));
        }
        
        // 关闭输入流
        is.close();
        
        // 关闭Socket连接
        sk.close();
    }
}

TCP简单程序.jpg

案例3-4中,创建了一个服务器端程序,用于接收客户端发送的数据。在创建ServerSocket对象时指定了服务器端的端口号为9544,并在while循环中调用该对象的accept()方法持续监听客户端连接。其中,在执行accept()方法时,程序会发生阻塞,直到有客户端来访问时才会结束这种阻塞状态同时会返回一个Socket类型的对象用于表示客户端,通过该对象可以获取与客户端关联的输出流并向客户端发送信息。最后,调用Socket对象的close()方法将通信结束。

案例3-5中,创建了一个客户端程序,用于向指定服务端发送连接并进行数据交互。在客户端创建Socket对象与服务器端建立连接后,会打印出“服务器端向客户端做出响应”,服务端程序结束了阻塞状态,会打印出“与客户端连接成功,开始进行数据交互!”。如图所示。

3.5 多线程TCP网络程序

在上一小节的两个案例中,分别实现了服务器端程序和客户端程序,当一个客户端程序请求服务器端时,服务器端就会结束阻塞状态,完成程序的运行。实际上,很多服务器端程序都是允许被多个应用程序访问的,例如门户网站可以被多个用户同时访问,因此服务器端都是多线程的。下面就通过一个图例来表示多个用户访问同一个服务器,如图所示。

多线程TCP网络程序

图3-5 多个客户端访问服务器端

在图中,服务器端为每个客户端创建一个对应的Socket对象,并且开启一个新的线程使两个Socket建立专线进行通信。

接下来根据图所示的多线程通信方式对服务端程序进行改进,如下所示。
例3-6 Demo6.java

import java.net.*; // 导入网络相关的类
import java.io.*;  // 导入输入输出相关的类

public class Demo6 {
    public static void main(String[] args) throws Exception {
        // 创建一个绑定到端口9544的ServerSocket对象
        ServerSocket client = new ServerSocket(9544);
        
        // 无限循环,持续接受客户端的连接
        while (true) {
            // 接受客户端的连接请求,并返回一个Socket对象
            Socket sc = client.accept();

            // 创建一个新的线程用于处理与客户端的交互
            Thread t = new Thread(() -> {
                try {
                    // 获取客户端的端口号
                    int port = sc.getPort();

                    // 获取与客户端通信的输出流
                    OutputStream os = sc.getOutputStream();
                    System.out.println("与端口号为:《《" + port + "》》的客户端连接成功,开始进行数据交互");
                    
                    // 向客户端发送一条消息
                    os.write(("服务端发送数据:十一月的早晨,吹进卧室的风!").getBytes());
                    
                    // 打印交互结束的信息
                    System.out.println("结束与端口号为:《《" + port + "》》的客户端的交互");
                    
                    // 关闭输出流
                    os.close();
                    // 关闭与客户端的Socket连接
                    sc.close();
                
                } catch (Exception e) {
                    e.printStackTrace(); // 打印异常堆栈跟踪信息
                }
            });

            // 启动处理客户端交互的线程
            t.start();
        }
    }
}

TCP多线程交互.jpg

案例中,使用多线程的方式创建了一个服务器端程序。通过在while循环中调用accept()方法,持续接收客户端发送的请求,当与客户端建立连接后,就会为每个客户端开启一个新的线程,每个线程都会与一个客户端建立一对一连接,从而进行数据交互。

为了验证服务器端程序是否实现了多线程,首先运行服务端程序,之后连续运行三个客户端程序来与启动的服务端建立连接进行数据交互。具体如图所示。