今天我们来学习一下Java Socket(套接字)。Java Socket主要用于网络数据传输。首先,我们先要了解一下计算机网络。所谓计算机网络,就是把分布在不同地理区域的计算机使用通信设备和线路连接在一起,从而使这些分散的计算机可以方便地互相传输信息。比如说,因特网(Internet)就是可以连接全球计算机的网络,同时它也通过了全球信息数据的共享。但是,让这些计算机能够互联互通,不是一件容易的事情。在计算机网络中实现通信必须有一些约定,这些约定被称为通信协议。通信协议负责对传输速率、传输代码、代码结构、传输控制步骤、出错控制等制定处理标准。于是,国际标准化组织(ISO)就制定了“开放系统互连参考模型”,即著名的 OSI(Open System Interconnection)参考模型,该模型模式已成为各种计算机网络结构的参考标准。OSI 参考模型把计算机网络分成物理层、数据链路层、网络层、传输层、会话层、表示层、应用层七层。在这些通信协议中,最著名就是TCP/IP 协议。现在几乎所有的操作系统都支持 TCP/IP 协议,它是因特网(Internet)中最常用的基础协议。TCP/IP 不仅仅是指 TCP 和 IP 这两种协议,而是通信过程中,使用到的协议族的统称。TCP/IP 协议族按OSI层次分别分为以下四层:应用层、传输层、网络层和数据链路层。
应用层决定了向用户提供应用服务时通信的活动。TCP/IP 协议族内预存了各类通用的应用服务。比如,FTP(文件传输协议),DNS(域名系统)和HTTP(超文本传输协议)就属于该层。
传输层提供处于网络连接中的两台计算机之间的数据传输。在传输层有两个性质不同的协议:TCP(传输控制协议)和UDP(用户数据报协议),稍后我们重点介绍这两种协议。
网络层用来处理在网络上流动的数据包。数据包是网络传输的最小数据单位。该层规定了通过怎样的路径到达对方计算机,并把数据包传送给对方。IP协议就属于该层。
数据链路层用来处理连接网络的硬件部分。包括控制操作系统、硬件的设备驱动、网卡及光纤等物理可见部分。硬件上的范畴均在链路层的作用范围之内。
接下来我们再介绍一下传输层上面的TCP和UDP两种通信协议。
TCP协议是一种面向连接的保证可靠传输的协议。建立起一个TCP连接需要经过“三次握手”。握手的过程我们不再详细说明,握手过程中传送的包里不包含数据,三次握手完毕后,通信双方才正式开始传输数据。TCP连接建立后,如果通信双方中的任何一方不主动关闭的话,该TCP 连接都将被一直保持下去。只要连接存在,通信双方就能向彼此收发数据信息。
UDP协议是一种无连接的协议,每个数据报都是一个独立的信息,包括完整的源地址或目的地址,它在网络上以任何可能的路径传往目的地,因此能否到达目的地,到达目的地的时间以及内容的正确性都是不能被保证的。相比较而言,UDP更加高效,但准确性就低。
IP 协议的作用是把各种数据包传送给对方,其中两个重要的是IP地址和MAC地址。IP地址就是计算机被分配到的地址,MAC 地址是指网卡所属的固定地址。IP地址是可变的,但 MAC地址是不变的。我们这里重点说一下IP地址。IP 地址用于唯一标识网络中的一个通信实体,这个通信实体既可以是一个主机,也可以是一台打印机,或者是路由器。而在基于 IP 协议的网络中传输的数据包,都必须使用 IP 地址来进行标识。IP 地址是数字型的,它是一个 32 位(4字节)整数。但为了便于记忆,通常把它分成 4 个 8 位(1个字节)的二进制数,每 8 位之间用圆点隔开,每个 8 位整数都可以转换成一个 0~255 的十进制整数,因此日常看到的 IP 地址常常是这种形式:60.10.125.124。因特网(Internet)中的计算机IP地址谁来决定呢?NIC(Internet Network Information Center)统一负责全球IP地址的规划和管理,而 InterNIC、APNIC、RIPE 三大网络信息中心则具体负责美国及其他地区的 IP 地址分配。其中APNIC负责亚太地区的IP地址管理,我国申请IP地址也要通过APNIC,APNIC的总部设在日本东京大学。请注意,这里所说的IP地址是针对全球因特网(Internet)而言的,每一个接入因特网(Internet)的计算机都会分配到一个全球独一无二的公网IP地址。有了IP地址才可以进行通信,比如浏览网页,网上购物,微信聊天等等。
IP地址(四个字节)所能代表的范围是有限的。现在计算机已经普及到每一个家庭,并且随着移动互联网和工业互联网的发展,我们的手机以及其他设备都可以接入因特网,这些如果都分配到IP地址的话,显然是不够的。因此,IPV6就诞生了。它的出现就是为了解决IP地址枯竭的问题。IPv6的地址长度为128位(16字节),是IPv4地址长度的4倍。于是IPv4点分十进制格式不再适用,采用十六进制表示:A3C6:AF51:B3B5:C7A9:AE0F:0F0E:A54D:A919
如果我们是windows操作系统的话,可以同时按下“Win”+“R”键,然后输入“CMD”进入Dos黑窗口下,在黑窗口下输入:ipconfig命令,就能看到本机的网络地址信息,里面就有IPV4和IPV6两种形式的地址。由于我们大部分计算机都是通过路由器上网的,因此你的计算机IP地址实际是由路由器分配的局域网IP地址,局域网IP地址一般是192.168.0.xxx或者是192.168.1.xxx的形式,里面的xxx就范围是0-255之间,并且整个局域网IP地址并不是固定不变的,今天你的计算机被分配到了192.168.0.18,后天可能就会变成192.168.0.20。当然,如果你的局域网内还有其他计算机上网的话,他们也会被分配到一个局域网IP,但是所有被分配的局域网IP是不可以重复的。也就是说,你被分配了192.168.0.18,那么别人就不会被分配到这个IP地址。另外192.168.0.1或者192.168.1.1的IP地址,一般都是网关,也就是路由器的IP地址。我们设置路由器的时候,都是通过这个IP去访问它。当然,我们可以在局域网内部访问任何一个被分配到局域网IP地址的计算机。
接入因特网(Internet)的计算机获取公网IP地址有两种,第一种是固定式的。我们的Web服务器都属于这种情况。例如,我们购买云服务器的话,都会分配一个固定的公网IP地址,使用这个IP地址就可以直接访问该云服务器了。第二种获取IP的方式是随机分配,我们大部分人上网都是这种方式。这种情况下,计算机被ISP(电信,移动,联通等)分配给上网用户一个临时的 IP地址,这样这台计算机才能上网。那为什么大家还存在局域网IP地址呢?很简单,处在同一个局域网内的计算机上网的话,他们都是共用是同一个公网IP地址,这个IP地址就是被ISP分配的那个临时IP地址。局域网IP就是为了区分局域网内每一上网的计算机,并且是由路由器分配的。我们日常说的Java Web开发,其实就是访问固定IP地址的计算机(服务器),当然访问者的上网方式没有限制,可以是第一种(服务器访问服务器),也可以是第二种(客户端访问服务器)。客户端与客户端相互直接访问的内容我们不讨论。
通过上面的介绍,我们已经对计算机网络和TCP/IP协议有了大致的了解了。他们都是实现网络数据传输的基础,我们日常的应用开发使用的是一些高级协议,也就是面向应用层的协议。比如说,对于Web开发的话,我们使用的是HTTP协议。当我们在浏览器上要访问一个网站(例如:腾讯啊,网易啊)的时候,他们的网址都是以“http”开头的。HTTP协议是Hyper Text Transfer Protocol(超文本传输协议)的缩写,是用于从Web服务器传输超文本到本地浏览器的传送协议。HTTP是一个基于TCP/IP通信协议来传递数据。HTTP协议工作于客户端/服务端(B/S)架构为上。浏览器作为HTTP客户端通过URL(网址/域名)向HTTP服务端(WEB服务器)发送所有请求。Web服务器根据接收到的请求后,向客户端发送响应信息。这是我们Web开发的基础,大家一定要搞清楚了!!!
讲到通过网址访问Web服务器,就不得不提到另一个重要的协议,DNS服务协议。DNS(Domain Name System)服务协议是和 HTTP 协议一样位于应用层的协议。它提供域名到 IP 地址之间的解析服务。计算机既可以被赋予 IP 地址,也可以被赋予域名。比如http://www.badidu.com。用户通常使用域名来访问对方的计算机,而不是直接通过 IP地址访问。因为与 IP 地址的一组纯数字相比,用字母配合数字的表示形式来指定计算机名更符合人类的记忆习惯。DNS服务协议提供通过域名查找 IP 地址,或逆向从 IP 地址反查域名的服务。这种服务的提供非常简单,就是把域名和服务器IP做一个绑定的存储,访问域名的时候,就去这个DNS服务器上面查找对应的服务器IP地址。有了IP地址,就可以访问服务器了。因为互联网起源于美国,域名体系也是诞生于美国,在互联网不断扩张和发展的过程中,逐渐形成了13台服务器为全球根域名服务器。其中1个为主根服务器在美国,其余12个均为辅根服务器,其中9个在美国,2个在欧洲(位于英国和瑞典),1个在亚洲(位于日本)。2016年在全球16个国家完成25台IPv6根服务器架设,事实上形成了13台原有根加25台IPv6根的新格局。中国部署了其中的4台,由1台主根服务器和3台辅根服务器组成,打破了中国过去没有根服务器的困境。根服务器的存在其实就是保证DNS服务数据的统一性和完整性,我们日常上网基本上都是电信或者网通的线路,DSN服务也是由他们提供的。
如果我们访问其他计算机(服务器)的话,IP地址只是解决了位置问题。我们访问计算机,实际上访问的是计算机上面提供的软件服务。比如Web开发中,我们访问的Web服务器实际就是由Apache Http,Nginx,Tomcat等软件提供的服务(浏览网页)。不同的服务之间是通过“端口”来区分的。端口是一个 16 位的整数,用于表示将数据(访问)交给计算机哪个软件程序(服务)来处理。因此,端口就是应用程序与外界交流的出入口。不同的应用程序处理不同端口上的数据。端口号可以为 0~65535,通常将端口分为如下三类:
公认端口:0~1023,它们一般绑定在特定的服务上。
注册端口:1024~49151,应用程序使用这个范围内的端口。
动态和/或私有端口:49152~65535,应用程序使用的动态端口,一般不会主动使用。
我们访问其他计算机,本质就是访问其他计算机上面软件程序提供的服务,这些服务都是在自己绑定的端口上提供,也就是说不同的软件程序都由自己唯一的端口,通过端口就能识别计算机上面的软件程序。当然,也会存在绑定端口冲突的情况。因此,当一个程序需要发送数据时,需要指定目的地的 IP 地址和端口号,计算机网络才可以将数据发送给该IP地址和端口号所对应的软件程序。举个简单的例子,我们使用Java做了一个服务功能,它绑定在自己的8888端口上面,那么其他计算机就可以通过访问我们计算机IP+8888端口的形式,直接访问我们的Java服务功能。IP地址是找到计算机,端口是找到计算机上面的软件服务。这里大家一定要记住的是,HTTP的端口是80,HTTPS的端口号是443,MySql数据库是3306。
接下来,我们正式开始学习Java Socket(套接字),它就是对TCP/IP协议的封装,不是我们web开发中的HTTP协议的封装哦。在Java Socket程序开发中,包含两个角色,一个是服务器端,一个是客户端。其中服务器端拥有固定IP,且在某个端口开启监听服务,等待客户端的连接。连接成功后,双方就可以收发数据信息了。服务器端可以同时处理多个客户端的连接,因此需要使用多线程。服务器端和客户端的通信,也就是输入和输出,需要使用IO流的内容。
Java Socket的API主要在java.net包中,对于服务器端提供了SocketServer类,对于客户端提供了Socket类。SocketServer类只用于来做客户端连接的监听,并不参与数据通信,SocketServer类监听到客户端端连接后,也会创建一个客户端Socket类。也就是说,服务器端和客户端的通信就是服务器端的Socket类与客户端的Socket类进行数据IO传输。服务器端可以监听多个客户端,并创建多个客户端Socket类,这样就形成了一个客户端一对Socket类的通信机制,由于通信是实时进行的,因此服务端的Socket类是运行在自己的线程中的。
接下来,我们使用代码示例说明SocketServer类和Socket类的使用,由于通信是两个端,因此我们需要写两套Java程序,一个用于服务器端,一个用于客户端。我们之前在多线程的章节中讲过,一个Java程序就是一个JVM进程,进程和进程之间是相互隔离的。在实际的程序运行中,服务器端的代码应该运行在服务器端,且由固定IP地址,而客户端端的代码应该运行在普通的计算机(客户端)上就可以。但是,我们没有这样的运行环境,因此我们使用本机同时充当服务器和客户端即可,那么此时服务器端IP地址怎么写呢?这里,我们介绍一个特殊的IP地址:127.0.0.1 这个IP地址就是用来做本机测试的,如果你访问这个IP地址,实际上就是访问的自己。等部署正式服务器后,我们将这个IP改成服务器IP即可。
为了能够编写两套Java程序,我们可以启动两个Eclipse,一个是之前的Hello工程,用于客户端代码编写,另一个是新建的HelloServer工程,用于服务器端代码编写。首先,我们先看看服务器端的代码编写,工程名为HelloServer,程序类文件也是HelloServer.java,如下:
// 创建服务端socket,在端口8888开启监听
ServerSocket serverSocket = new ServerSocket(8888);
// 监听客户端
Socket clientSocket = serverSocket.accept();
// 输出客户端IP地址
InetAddress address = clientSocket.getInetAddress();
System.out.println("客户端的IP:"+address.getHostAddress());
// 接收客户端发来的消息
byte[] msg = new byte[1024];
InputStream input = clientSocket.getInputStream();
int len = input.read(msg);
System.out.println(new String(msg,0,len));
// 向客户端发送消息
OutputStream output = clientSocket.getOutputStream();
output.write("我是服务器端的消息".getBytes());
output.flush();
请注意上面的代码,我们实例化ServerSocket类的时候,参数就是端口号,这里不需要IP地址,默认就是运行本套程序所在的计算机。紧接着,使用ServerSocket类的accept方法开始监听客户端的连接,此方法是阻塞式的,也就是说,如果没有客户端连接过来,程序就会一直停留在本方法的位置,不会向下执行,直到由客户端连接,实例化客户端Socket类成功,代码才向下执行。紧接着,我们打印了连接客户端的IP地址。然后获取客户端Socket类的输入流,也就是读取客户端发送过来的消息,请注意这个read方法也是阻塞式的,如果没有读取到信息,程序会一直停留在本方法的位置,直到读取并打印消息。最后就是通过输出流向客户端发送消息。另外还需要提醒大家,上述方法都会抛出异常,我们偷懒,直接在main入口方法上使用throws IOException交给JVM虚拟机处理异常。
接下来,我们看看客户端的代码,如下:
// 连接服务器8888端口
Socket socket = new Socket("127.0.0.1", 8888);
// 向服务器发送消息
OutputStream output = socket.getOutputStream();
output.write("我是客户端的消息".getBytes());
output.flush();
// 读取服务器发来的消息
byte[] msg = new byte[1024];
InputStream input = socket.getInputStream();
int len = input.read(msg);
System.out.println(new String(msg,0,len));
上面的代码非常的简单,实例化Socket类就会直接连接服务器端,参数就是服务器端的IP地址和端口号。前面我们已经说明过了,我们服务器端程序也是运行在本地计算机上面的,因此我们客户端连接服务器端的IP地址,就是本机IP地址:127.0.0.1 剩下的就是根据输入和输出流来读取消息了。在上述的服务端代码和客户端代码中,必须先运行服务端代码,然后再运行客户端代码,这样彼此双方就能获取到对方的数据啦。再运行过程中,不管任意一方断开连接,另一方都会抛出异常而终止程序的运行,这显然是有问题,我们暂不考虑。本案例中,我们只是简单的介绍Java Socket的通信,现实开发中我们一般不会这么做。其中最重要的原因就是,服务器端和客户端都通信都是并发执行的,所以,我们需要借助多线程来实现。我们将上述代码调整到多线程下运行,首先是客户端的代码调整,如下所示:
// 输入(读取)线程
class ReadThread extends Thread {
// 构造方法,实例化输入读取流
private InputStream input;
public ReadThread(Socket socket) {
try {
input = socket.getInputStream();
} catch (IOException e) {}
}
@Override
public void run() {
super.run();
// 无限循环读取消息
while(true) {
byte[] data = new byte[1024];
int len = 0;
try {len = input.read(data);} catch (IOException e) {}
System.out.println(new String(data,0,len));
}
}
}
在上述代码中,我们创建了一个客户端线程,专门用来处理读取服务端发来的消息。同理的话,我们还需要创建另一个客户端线程,用来向服务端发送消息,代码如下:
// 输出(写入)线程
class WriterThread extends Thread {
// 构造方法,实例化输出写入流
private OutputStream output;
public WriterThread(Socket socket) {
try {
output = socket.getOutputStream();
} catch (IOException e) {}
}
@Override
public void run() {
super.run();
// 无限循环获取键盘输入并发送消息
while(true) {
// 从键盘获取消息
String data = null;
BufferedReader in = new BufferedReader(new InputStreamReader(System.in));
try {
// 读取一行数据
data = in.readLine();
} catch (IOException e) {}
// 把消息输出到Socket中
if(data != null) {
try {
output.write(data.getBytes());
} catch (IOException e) {}
}
}
}
}
在上述代码中,我们使用了缓冲字符输入类来包装System.in来获取键盘的输入,获取的方式是一行一行的获取键盘输入的字符信息。最后在入口main方法中来连接服务器,
// 连接服务器8888端口
Socket socket = new Socket("127.0.0.1", 8888);
// 使用线程实时执行读取和写入两种操作
new ReadThread(socket).start();
new WriterThread(socket).start();
这样,完整的客户端程序就完成了,我们使用多线程可以同时处理读写两种操作。接下来就是服务器端的代码,我们为每一个连接服务端的客户端都创建一个线程,在这个线程中先处理读操作,再处理写操作。当然,我们也可以效仿上面客户端的代码,使用两个线程分别处理读操作和写操作。代码如下:
// 客户端线程,支持先读后写的操作
class ClientThread extends Thread {
// 构造方法,实例化输入输出流
private InputStream input;
private OutputStream output;
public ClientThread(Socket socket) {
try {
input = socket.getInputStream();
output = socket.getOutputStream();
} catch (IOException e) {}
}
@Override
public void run() {
super.run();
// 无限循环处理输入和输出
while(true) {
// 先读取内容
byte[] data = new byte[1024];
int len = 0;
try {len = input.read(data);} catch (IOException e) {}
System.out.println(new String(data,0,len));
// 从键盘获取消息
String str = null;
BufferedReader in = new BufferedReader(new InputStreamReader(System.in));
try {
// 读取一行数据
str = in.readLine();
} catch (IOException e) {}
// 把消息输出到Socket中
if(str != null) {
try {
output.write(str.getBytes());
} catch (IOException e) {}
}
}
}
}
代码非常的相似,这里就不再介绍了。再入口main方法代码如下:
// 创建服务端socket,在端口8888开启监听
ServerSocket serverSocket = new ServerSocket(8888);
// 无限循环监听不同的客户端
while(true) {
// 实例化客户端 Socket
Socket clientSocket = serverSocket.accept();
new ClientThread(clientSocket).start();
// 输出客户端IP地址
InetAddress address = clientSocket.getInetAddress();
System.out.println("客户端的IP:"+address.getHostAddress());
}
再上面的代码中,我们使用无限循环来介绍不同的客户端来连接服务器端。只要有客户端连接进来,我们就创建一个线程来处理这个客户端,这样我们的服务器端就能支持同时处理多个客户端啦。同样的,先运行服务器端代码,然后再运行客户端代码,双方就可以根据键盘输入来进行数据交互啦,类似于服务端和客户端进行聊天。
在实际应用中,网络的数据在TCP/IP协议下的socket都是采用数据流的方式进行发送,而且,大多数发送的数据都是不定长的,所有接受方也不知道此次数据发送有多长,因此无法精确地创建一个缓冲区(字节数组)用来接收,在不定长通讯中,通常使用的方式时每次默认读取 8*1024长度的字节,若输入流中仍有数据,则再次读取,一直到输入流没有数据为止。但是如果发送数据过大时,发送方会对数据进行分包发送,这种情况下或导致接收方判断错误,误以为数据传输完成,因而接收不全。解决这个问题也比较简单,我们可以把我们要发送的一个完整数据按照“数据包=包长+包体”的方式进行包装,每次发送数据的时候,我们都会固定前4个字节为数据包长,拿到数据包长后,我们就可以非常精确的创建一个数据缓存区用来接收包体数据。
接下来,我们来简单说明基于UDP的socket实现,它同样也分为服务端和客户端两套程序,首先是服务端代码如下:
// 创建DatagramSocket对象,监听8888端口
DatagramSocket socket = new DatagramSocket(8888);
// 要接收的数据包
byte[] data = new byte[1024];
DatagramPacket packet = new DatagramPacket(data, data.length);
// 阻塞式接收消息
socket.receive(packet);
// 输出消息
String msg = new String(packet.getData(),0,packet.getLength());
System.out.println(msg);
// 关闭socket
socket.close();
Java使用UDP通信就是发送和接收数据包,这里是服务器端程序,因此它需要等待客户端的连接,然后接收客户端发来的消息,最后输出消息即可。它跟TCP的区别在于,不建立持久性的连接,而只是以数据包为单位进行发送和接收,而且数据包是否收到不做校验。接下来是客户端程序,如下所示:
// 客户端发送的消息
String msg = "客户端发送的消息";
// 服务端地址
InetAddress server = InetAddress.getByName("127.0.0.1");
// 创建UDP数据包,包含目标计算机地址和端口
DatagramPacket packet = new DatagramPacket(msg.getBytes(),msg.getBytes().length, server, 8888);
// 创建DatagramSocket对象
DatagramSocket socket = new DatagramSocket();
// 发送消息
socket.send(packet);
// 关闭socket
socket.close();
从上述代码中,我们可以看到,我们构建UDP数据包是重点,该数据包中除了数据本身之外,包含了目标服务端的IP地址和端口,紧接着将此数据包发送出去即可。至于服务端是否真正的收到,UDP形式的通信是不做保证的。如何构建上面TCP服务器端和客户端的双方通信呢,其实就是再两端无限循环的创建DatagramSocket 和 DatagramPacket,然后利用DatagramSocket的receive方法和send方法来接收和发送两种DatagramPacket数据包。关于UDP我们不在深入介绍,我们继续返回到上文中使用多线程进行服务端和客户端进行双方通信的代码案例上来。之所以使用多线程,主要原因在于accept()、read()、write()三个方法都是同步阻塞的,如果是单线程的话,IO将霸占CPU资源,导致无法处理其他业务逻辑。开启多线程,就可以让CPU去处理更多IO连接,但是多线程同时处理IO也是非常消耗性能的。现在的多线程一般都使用线程池,可以让线程的创建和回收成本相对较低。在活动连接数不是特别高(小于单机1000)的情况下,这种模型是比较不错的。但是,当面对上万级连接的时候,传统的IO模型是无能为力的。随着移动端应用的兴起和各种网络游戏的盛行,百万级长连接日趋普遍,此时,必然需要一种更高效的I/O处理模型。
就是接下来要讲的NIO。
NIO主要有三大核心部分:Channel(通道),Buffer(缓冲区), Selector(选择区)。传统IO基于字节流和字符流进行操作,而NIO基于Channel(通道)和Buffer(缓冲区)进行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。Selector(选择区)用于监听多个通道的事件(比如:连接打开,数据到达)。因此,单个线程可以监听多个数据通道。传统IO的各种流是阻塞的,当一个线程调用read或write时,该线程被阻塞,直到有一些数据被读取或写入。该线程在此期间不能再干任何事情了。NIO是非阻塞模式,使一个线程从某通道发送请求读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取。而不是保持线程阻塞,所以直至数据变得可以读取之前,该线程可以继续做其他的事情。 非阻塞写也是如此。一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。线程通常将非阻塞IO的空闲时间用于在其它通道上执行IO操作,所以一个单独的线程现在可以管理多个输入和输出通道(channel)。
接下来,我们详细介绍NIO的工作流程。所有的IO连接在NIO 中都是一个Channel(通道)。服务端和客户端的交换数据可以从Channel读到Buffer中,也可以从Buffer 写到Channel中。服务端使用ServerSocketChannel类,客户端使用SocketChannel类,他们的工作原理类似于ServerSocket和Socket。服务端ServerSocketChannel类用来监听端口,如果有客户端连接,就创建SocketChannel类。当通信双方建立连接后,就可以通过SocketChannel类的read方法和write方法进行数据的读写操作了。这里需要注意的是,从Channel中读取的数据,首先要读取到缓冲区对象Buffer中,Java NIO中的Buffer实现有:ByteBuffer,CharBuffer,DoubleBuffer,FloatBuffer,IntBuffer,LongBuffer,ShortBuffer等等,其中ByteBuffer常用。我们上面也讲到,Java NIO使用单线程就可以处理多个IO连接的读写操作,它的具体实现需要借助Selector(选择区)。具体的对象关系如下图所示:
由上图所示,Selector才是重点。Selector对象通过调用Selector.open()方法来创建,如下:
Selector selector = Selector.open();
当通信双方建立Channel通道后,就需要将此Channel通道注册到Selector对象上,通过SelectableChannel.register()方法来实现,如下:
SelectionKey key = channel.register(selector, Selectionkey.OP_READ);
注意register()方法的第二个参数,它的意思是让Selector监听Channel上的事件。这里可以监听四种不同类型的事件:Connect,Accept,Read,Write。这四种事件用SelectionKey的四个常量来表示:
SelectionKey.OP_CONNECT (连接事件,用于客户端)
SelectionKey.OP_ACCEPT (接收事件,用于服务端)
SelectionKey.OP_READ (读取事件,用于客户端和服务端)
SelectionKey.OP_WRITE (写入事件,用于客户端和服务端)
如果你对不止一种事件感兴趣,那么可以用“位或”操作符将常量连接起来,如下:
int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE;
当Channel通道触发了一个事件意思是该事件已经就绪。例如,某个客户端channel成功连接到服务器端称为“连接就绪”。一个服务器端channel准备好接收新进入的客户端连接称为“接收就绪”。一个有数据可读的通道可以说是“读就绪”,等待写数据的通道可以说是“写就绪”。Selector在处理多个“就绪”channel的时候,就是按照上述事件进行批量处理的。当向Selector注册Channel时,register()方法会返回一个SelectionKey对象,该对象持有Channel对象,同时我们还可以使用selectionKey.attach(obj)方法将额外的对象obj附着到SelectionKey上,也就是将额外对象与Channel对象进行一一绑定。我们上文中也说过,数据的读写操作都是在Channel对象上进行的,而Channel对象又被SelectionKey对象持有。可见,SelectionKey对象是非常重要的,当然我们没有必要单独的去存储SelectionKey对象。因为Selector(选择区)会帮我们获取SelectionKey对象的。一旦向Selector注册了一或多个通道,就可以调用Selector的select方法返回你所感兴趣的事件(如连接、接受、读或写)已经准备就绪的那些通道。这些通道就绪后,就可以通过调用selector的selectedKeys()方法,就可以获取到SelectionKey对象的集合。如下所示:
Set selectedKeys = selector.selectedKeys();
注意,这是一个集合,所以我们需要遍历获取每一个SelectionKey对象,有了SelectionKey对象就拥有了Channel对象,最后再根据事件做相应的数据读写处理。这个循环遍历已选择键集中的每个键,并检测各个键所对应的通道的就绪事件。注意每次迭代末尾的keyIterator.remove()调用。Selector不会自己从已选择键集中移除SelectionKey实例。必须在处理完通道时自己移除。下次该通道变成就绪时,Selector会再次将其放入已选择键集中。
整体的工作原理:两个终端进行通信的话,优先必须存在通道channel,主要分为两种:服务端通道ServerSocketChannel和客户端通道SocketChannel。每一个客户端都代表一个通道channel,数据通信就是通过这个通道channel来实现传输的。Java NIO的核心是选择器Selector,它负责管理所有的通道channel,因此,我们需要将通道channel注册到选择器Selector上。注册的时候,有两个重要的点,一个是参数事件,一个是返回SelectionKey对象。选择器Selector对通道channel的管理主要体现在建立连接,读取数据和写入数据三种,也就对应了三种参数事件(SelectionKey.OP_CONNECT和SelectionKey.OP_ACCEPT,SelectionKey.OP_READ和SelectionKey.OP_WRITE)。选择器Selector会按照不同的事件,对所有的通道channel进行分组管理。也就是说,如果有两个客户端向服务器端发来了数据,那么这个两个客户端的通道channel会放置到一个组中,我们通过读取事件就能获取到这个两个客户端通道channel。建立连接和写入数据也是同理。SelectionKey对象是什么?它代表的就是客户端,它持有通道channel对象。我们上面刚刚讲过,选择器Selector会根据不同的事件来将处理好的通道channel放置到一个组里面,然后我们就可以通过选择器Selector的selectedKeys方法来获取这些处理好的通道channel,其实就是许多SelectionKey对象。然后,我们对这个组做一个循环,获取到每一个具体的SelectionKey对象。SelectionKey对象的channel方法就可以获取到通道channel,有了通道channel对象,就能够读写数据了。当然,这个读写数据,还得根据SelectionKey对象本身的事件来判定。对于连接事件来讲,ServerSocketChannel的accept方法来获取客户端SocketChannel ,然后将其绑定到选择器Selector上面,通常我们将参数事件设定为SelectionKey.OP_READ。当我们将要向客户端写入数据的时候,我们需要将参数事件设定为SelectionKey.OP_ WRITE,当然我们同时监听读写事件写成:SelectionKey.OP_READ|SelectionKey.OP_WRITE。这里需要注意的是,SelectionKey.OP_CONNECT是客户端通道SocketChannel的绑定的事件,SelectionKey.OP_ACCEPT是服务端通道ServerSocketChannel的绑定事件。最后我们在说具体的读写操作,都是通过通道channel的read和write方法来实现的,两个方法的主要参数是ByteBuffer对象(常用)。这里大家要注意的是,Java NIO是通过事件来处理IO的,我们基本上实时监听读事件,然后想要写入数据的时候,才会监听写事件。一定是先监听事件,然后才对数据进行读取操作。
Java NIO中的Buffer用于和NIO通道进行交互。数据是从通道读入缓冲区,从缓冲区写入到通道中的。缓冲区本质上是一块可以写入数据,然后可以从中读取数据的内存。这块内存被包装成Java NIO Buffer对象,并提供了一组方法,用来方便的访问该块内存。为了理解Buffer的工作原理,需要熟悉它的三个属性:capacity,position,limit,mark
capacity:缓冲区的容量大小
position:初始的position值为0。当写数据到Buffer中时,position表示当前的位置,也就是说从当前位置开始写入数据。当数据写到Buffer后, position会向前移动到下一个可写位置,移动的距离就是写入数据的大小。也就是说,写时候,position的值会向前移动,方便下次写。position最大可为capacity – 1。当读取数据时,也是从当前位置读。同理,读的时候,postion的值也会向前移动,方便下次读。当将Buffer从写模式切换到读模式,position会被重置为0。
limit:在写模式下,Buffer的limit表示你最多能往Buffer里写多少数据。写模式下,limit等于Buffer的capacity。当切换到读模式时,limit表示你最多能读到多少数据。当切换Buffer到读模式时,limit会被设置成写模式下的position值。
mark:一个备忘位置,调用mark()方法的话,mark值将存储当前position的值,等下次调用reset()方法时,会设定position的值为之前的标记值。
四个属性值之间的大小关系:0 <= mark <= position <= limit <= capacity
接下来介绍Buffer的一些常用方法:
allocate(capacity):可以创建一个指定容量的缓冲区
put():用于向缓冲区中存储数据
get():用于从缓冲区中读取数据
compact():压缩数据。当缓冲区中还有未读完的数据,可以调用compact()进行压缩,将所有未读取的数据复制到Buffer的起始位置,把position设置到最后一个未读元素的后面。limit属性设置为capacity。这样做的目的是方便从0开始继续读取未处理的数据。
capacity():返回缓冲区的大小
hasRemaing():用于判断当前的position后面是否还有可处理的数据,即判断position与limit是否还有数据
limit():返回limit上限的位置
mark():设置缓冲区的标志位置,这个值只能在0~position之间,可以通过reset()返回到这个位置
position():可以返回position当前位置
remaining():返回当前position位置与limit之间的数据个数
reset():将position设置到mark标记的位置
rewind():将position设置为0,然后取消mark标记
clear():清空缓冲区,仅仅是修改position标志为0,设置limit为capacity,缓冲区的数据还是存在的
flip():可以把缓冲区由写模式切换到读模式,写模式切换到读模式后,先把limit设置为position位置,再把position设置为0。
remaining():返回当前position到limit的数量
1、创建一个容量大小为10的字符缓冲区
ByteBuffer bf = ByteBuffer.allocate(10);
此时:mark = -1; position = 0; limit = 10; capacity = 10;
2、往缓冲区中put()五个字节
bf.put((byte)'H').put((byte)'e').put((byte)'l').put((byte)'l').put((byte)'0');
此时:mark = -1; position = 5; limit = 10; capacity = 10;
3、调用flip()方法,切换为读就绪状态
bf.flip();
此时:mark = -1; position = 0; limit = 5; capacity = 10;
4、读取两个元素
System.out.println("" + (char) bf.get() + (char) bf.get());
此时:mark = -1; position = 2; limit = 5; capacity = 10;
5、标记此时的position位置
bf.mark();
此时:mark = 2; position = 2; limit = 5; capacity = 10;
6、读取两个元素后,恢复到之前mark的位置处
System.out.println("" + (char) bf.get() + (char) bf.get());
此时:
bf.reset();
此时:mark = 2; position = 4; limit = 5; capacity = 10;
7、调用compact()方法,释放已读数据的空间,准备重新填充缓存区
bf.compact();
此时:mark = 2; position = 3; limit = 10; capacity = 10;
接下来我们使用代码来演示NIO的使用,首先是服务器端代码,如下所示:
// 定义Selector(选择区)对象
private static Selector selector = null;
// 定义读写Buffer(缓冲区)对象
private static ByteBuffer readBuffer = ByteBuffer.allocate(1024);
private static ByteBuffer sendBuffer = ByteBuffer.allocate(1024);
// 键盘输入数据
private static BufferedReader input = null;
上面的代码中,我们定义了Selector(选择区)对象,它是Java NIO的核心。我们定义它为一个类的成员变量,就是想再各个类方法中直接共享使用。接下来就是定义分别用来读写的两个缓冲区对象,他们的大小都是1024字节,缓冲区的大小根据自身业务情况而定。最后我们定义了一个键盘输入对象,主要用来手动输入消息,并发送给客户端。接下来就是main入口方法的内容,由于涉及到IO异常,因此我们直接在main方法后面抛出,代码如下:
// 创建服务端通道对象
ServerSocketChannel serverChannel = ServerSocketChannel.open();
// 非阻塞IO
serverChannel.configureBlocking(false);
// 绑定本地8888端口
serverChannel.bind(new InetSocketAddress(8888));
// 创建Selector(选择区)对象
selector = Selector.open();
// 服务端通道注册到Selector上,同时监听连接事件(等待客户端连接)
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
// 从键盘获取消息
input = new BufferedReader(new InputStreamReader(System.in));
上面的代码中,我们创建了ServerSocketChannel对象用来监听客户端的连接,紧接着又创建了Selector对象用来处理这些客户端连接,处理的方式就是以事件为触发条件分别进行连接,读取和写入的操作处理。最后我们创建一个键盘的BufferReader字符输入流对象,用来从键盘输入消息数据,并将该消息发送给客户端,实现双方的交互。接下来就是使用一个无限循环来不停的处理来自客户端的IO,大致的逻辑就是,通过Selector对象来获取“事件就绪”的通道,然后进行读取操作。本案例的代码比较简单,主要实现服务端和客户端双方的交替式消息传输,客户端先向服务端发送消息,然后服务端接收后,再向客户端发送消息,两者交替执行读写操作。因此,我们设置“事件”的大致逻辑就是读完之后注册写事件,写完之后注册读事件。但是,再实际的项目开发中,我们应该实时的监听读时间,然后根据键盘的数据输入来监听写事件,两者应该并发执行,所以仍然需要借助多线程来实现
// 无限循环处理IO事件,循环条件为当前线程不中断
while(!Thread.currentThread().isInterrupted()) {
// 返回事件就绪的通道
selector.select();
// 返回事件就绪通道的SelectionKey集合对象
Set<SelectionKey> keys = selector.selectedKeys();
// 迭代器循环SelectionKey集合对象
Iterator<SelectionKey> iterator = keys.iterator();
// 循环获取SelectionKey对象
while (iterator.hasNext()) {
// 获取当前SelectionKey对象
SelectionKey key = iterator.next();
// 无效的通道(可能被关闭)
if (!key.isValid()){ continue; }
// 处理接收事件
if (key.isAcceptable()) { accept(key); }
// 处理读事件
if(key.isReadable()) { read(key); }
// 处理写事件
if (key.isWritable()) { write(key); }
// 移除当前SelectionKey对象
iterator.remove();
}
}
上面的代码中,我们通过Selector对象来获取SelectionKey对象,通过该对象可以获取通道对象,有了通道对象,我们就可以进行读写操作啦。再具体的事件处理中,我们封装成了不同的方法来进一步处理,下面逐一介绍每一个方法,代码如下:
// 处理接收事件
private static void accept(SelectionKey key) throws IOException {
// 获取服务端通道对象
ServerSocketChannel serverChannel = (ServerSocketChannel) key.channel();
// 获取连接到服务端的客户端通道对象
SocketChannel clientChannel = serverChannel.accept();
// 非阻塞IO
clientChannel.configureBlocking(false);
// 客户端通道注册到Selector(选择区)对象,同时监听读事件(等待客户端先发来消息)
clientChannel.register(selector, SelectionKey.OP_READ);
// 打印客户端IP地址
System.out.println("客户端IP: "+clientChannel.getRemoteAddress());
}
上述代码就是接收客户端的连接,并创建客户端通道对象,然后注册到Selector对象上,这样Selector对象就能够处理该客户端通道IO啦。既然已经创建了连接,自然是要监听读事件了,也就是等待客户端发送过来的消息。记下来就介绍读操作方法,如下所示:
// 处理读事件
private static void read(SelectionKey key) throws IOException{
// 客户端Channel对象
SocketChannel clientChannel = (SocketChannel) key.channel();
// 清除缓冲区,准备接受新数据
readBuffer.clear();
// 读取字节数据
int size = clientChannel.read(readBuffer);
// 字节数组转字符串
String str = new String(readBuffer.array(), 0, size);
System.out.println(str);
// 监听写事件,读写事件交替监听
clientChannel.register(selector, SelectionKey.OP_WRITE);
}
上述代码我们通过通道的read方法将消息读取到缓冲对象中,然后根据读取的长度转化成字符串,读取完毕后,就监听写事件,也就是向客户端发送消息。处理写代码如下:
// 处理写事件
private static void write(SelectionKey key) throws IOException{
// 获取客户端通道
SocketChannel clientChannel = (SocketChannel) key.channel();
// 从键盘读取一行数据,这个位置会阻塞
String msg = input.readLine();
// 写入缓冲区
sendBuffer.clear();
sendBuffer.put(msg.getBytes());
// 向通道中写入消息
sendBuffer.flip();
clientChannel.write(sendBuffer);
// 监听读事件,读写事件交替监听
clientChannel.register(selector,SelectionKey.OP_READ);
}
上述代码我们从键盘输出字符串,然后转化到缓冲区,再借助通道的write方法发送给客户端。消息发送完毕后,我们继续监听读事件。这里大家需要明白一点,新注册的事件会覆盖旧注册的事件,因为为了能够既处理读事件,又处理写事件,因此我们反反复复的交替注册读事件和写事件。其实,我们完全可以再连接创建完毕后,同时注册读写事件。这样再循环处理客户端通道的时候,就能同时处理读和写操作了,也就是下面的代码:
clientChannel.register(selector, SelectionKey.OP_READ | SelectionKey.OP_WRITE);
但是,这样会给我们当前的程序造成要给问题,就是客户端发送过来的消息,不能实时再服务器端显示,为什么呢?因为我们服务端是再一起客户端通道处理循环中,同时处理读操作和写操作。服务端程序启动后,由于没有信息的读取,因此就会执行写操作,而写操作中有键盘输入的操作,该操作属于阻塞式的,这将导致程序将停留再写操作位置不动。这样,即便式客户端发送过来的数据,服务器端也无法执行下次循环的读操作。我们只能键盘输入后,才能进入到下一次循环的客户端通道处理中,才能执行读操作,才能输出客户端发来的消息。这就是为什么,我们写数据的时候,尤其从键盘获取输入的时候,需要重新启动一个线程来实现的原因啦。关于这一点,大家理解就可以了,我们继续讲解客户端的程序,如下:
// 定义Selector(选择区)对象
private static Selector selector = null;
// 定义读写Buffer(缓冲区)对象
private static ByteBuffer writeBuffer = ByteBuffer.allocate(1024);
private static ByteBuffer readBuffer = ByteBuffer.allocate(1024);
// 键盘输入数据
private static BufferedReader input = null;
上述代码不再讲解,我们来看main方法中的代码,如下:
// 创建socket通道
SocketChannel socketChannel = SocketChannel.open();
// IO非阻塞
socketChannel.configureBlocking(false);
// 连接服务器端,参数为服务器端IP和端口号
socketChannel.connect(new InetSocketAddress("127.0.0.1", 8888));
// 创建Selector(选择区)对象
selector = Selector.open();
// 将socket通道注册到Selector上,同时监听连接事件
socketChannel.register(selector, SelectionKey.OP_CONNECT);
// 从键盘获取消息
input = new BufferedReader(new InputStreamReader(System.in));
上述代码也不再过多的解释,继续我们的代码,
// 无限循环处理IO事件,循环条件为当前线程不中断
while(!Thread.currentThread().isInterrupted()) {
// 获取准备就绪的通道
selector.select();
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> iterator = keys.iterator();
while (iterator.hasNext()){
// 获取当前SelectionKey对象
SelectionKey key = iterator.next();
// 无效的通道(可能被关闭)
if (!key.isValid()){ continue; }
// 处理连接事件
if (key.isConnectable()) connect(key);
// 处理读事件
if (key.isWritable()) write(key);
// 处理写事件
if(key.isReadable()) read(key);
// 移除当前SelectionKey对象
iterator.remove();
}
}
上面的代码和服务端的处理是差不多的。虽然客户端通道只有一个,但是,我们仍然按照服务器端的逻辑来处理。接下来就是每个具体方法,如下:
// 处理连接事件
private static void connect(SelectionKey key) throws IOException {
// 获取socket通道
SocketChannel channel = (SocketChannel) key.channel();
channel.finishConnect();
// 监听写事件,客户端优先发送数据给服务端
channel.register(selector,SelectionKey.OP_WRITE);
}
上述代码就是通道创建完成的代码,创建完毕后,我们就监听读事件,说白了就是主动先服务器发送消息。上文中,我们也说这个注册事件的问题,我们可以再这个位置一次性的同时注册读和写事件,这样后续就不用再重复注册事件了。这一点大家一定要清楚明白。
// 处理读事件
private static void read(SelectionKey key) throws IOException {
// 获取socket通道
SocketChannel channel = (SocketChannel) key.channel();
// 读取消息
readBuffer.clear();
int size = channel.read(readBuffer);
// 输出消息字符串
String msg = new String(readBuffer.array(),0, size);
System.out.println(msg);
// 监听写事件,读写事件交替监听
channel.register(selector,SelectionKey.OP_WRITE);
}
以上是处理读事件的代码,如果再创建连接成功后,同时注册读和写事件的话,再本方法就不需要再继续注册事件了,因为只要建立连接成功,我们就只处理读写两种事件。
// 处理写事件
private static void write(SelectionKey key) throws IOException {
// 获取socket通道
SocketChannel channel = (SocketChannel) key.channel();
// 从键盘读取一行数据,这个位置会阻塞
String msg = input.readLine();
// 写入缓冲区
writeBuffer.clear();
writeBuffer.put(msg.getBytes());
// 写入socket通道
writeBuffer.flip();
channel.write(writeBuffer);
// 监听读事件,读写事件交替监听
channel.register(selector,SelectionKey.OP_READ);
}
以上是写入事件的处理。最后我们就可以运行程序了。先允许服务端程序,然后允许客户端程序,然后再客户端输入消息先发送给服务端,服务端接收并输出消息后,再键盘输入消息发送给客户端,这样双方就能够相互通信了。最后补充一点,我们再上述案例中没有对异常进行处理。因为不够是客户端还是服务端由于任何原因导致程序停止的话,对方都会收到IO异常,当收到异常后,我们关闭这连接就可以了。
本课程涉及的代码可以免费下载:
https://download.csdn.net/download/richieandndsc/85645935
今天的内容就讲的这里,我们来总结一下。今天我们主要讲了Java Socket。这部分章节主要以理解为主,不要求大家能够使用Socket进行功能开发。因为在Web开发中,很少使用Socket编程,即便是使用Socket,也是建立在某些框架基础上,这些基础的操作是不需要做的。但是,对于计算机网络的一些基础知识,大家必须掌握,这些是Java Web开发的基础,尤其是基于Tcp/IP协议实现的高级Http协议的理解,这是我们开发B/S架构程序的基础。关于Java NIO的应用也是比较多的,比较著名的框架就是Netty,适用于高并发量要求的场景。当下流行的的面向服务编程框架设计中,RPC(远程过程调用)是其最核心的技术实现,而使用Netty来构建高性能的异步通信应用也越来越收到欢迎。总之,Java NIO就是为解决大并发而诞生的技术。好的,谢谢大家的收看,欢迎大家在下方留言,我也会及时回复大家的留言的。