【Socket】Java 中的 Socket
1 前言
本节介绍Java 中 Socket 的用法,Java 中的 Socket 可以分为普通 Socket 和 NioSocket 两种,来感受下两者的使用。
2 普通 Socket 的用法
Java 中的网络通信是通过 Socket实现的,Socket分为 ServerSocket和 Socket 两大类ServerSocket 用于服务端,可以通过 accept 方法监听请求,监听到请求后返回 Socket,Socket用于具体完成数据传输,客户端直接使用 Socket 发起请求并传输数据。
ServerSocket 的使用可以分为三步:
- 创建 ServerSocket。ServerSocket 的构造方法一共有 5个,用起来最方便的是 Server-Socket (intport),只需要一个 port(端口号)就可以了。
- 调用创建出来的ServerSocket 的 accept 方法进行监听。accept 方法是阻塞方法,也就是说调用 accept 方法后程序会停下来等待连接请求,在接收到请求之前程序将不会往下走当接收到请求后 accept 方法会返回一个 Socket。
- 使用accept方法返回的Socket 与客户端进行通信。
下面写一个ServerSocket 简单的使用示例。
import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.io.PrintWriter; import java.net.ServerSocket; import java.net.Socket; /** * @author xjx * @description */ public class TestSocketServer { /** * Server 端 * @param args */ public static void main(String[] args) { try { // 创建 ServerSocket 监听 8080 端口 ServerSocket serverSocket = new ServerSocket(8080); // 等待请求 Socket socket = serverSocket.accept(); // 接收到请求后使用 Socket 进行通信,创建 BufferReader 用于读取数据 BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(socket.getInputStream())); String line = bufferedReader.readLine(); System.out.println("接受到信息:" + line); // 创建 PrintWriter 用于返回信息 PrintWriter printWriter = new PrintWriter(socket.getOutputStream()); printWriter.println("收到你的消息:" + line); printWriter.flush(); // 关闭资源 printWriter.close(); bufferedReader.close(); socket.close(); serverSocket.close(); } catch (IOException e) { e.printStackTrace(); } } }
在上面的 Server 里面,首先创建了 ServerSocket,然后调用 accept 等待请求,当接收到请求后,用返回的 Socket创建 Reader 和 Writer 来接收和发送数据,Reader 接收到数据后保存到line,然后打印到控制台,再将数据发送到 client,告诉 client 接收到的是什么数据,功能非常简单。
然后再来看客户端 Socket 的用法。Socket 的使用也一样,首先创建一个 Socket,Socket的构造方法非常多,这里用的是 Socket(String host,int port),把目标主机的地址和端口号传人即可,Socket创建的过程就会跟服务端建立连接,创建完 Socket 后,再用其创建 Writer 和Reader 来传输数据,数据传输完成后释放资源关闭连接就可以了。
我们再来个客户端:
import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.io.PrintWriter; import java.net.ServerSocket; import java.net.Socket; /** * @author xjx * @description */ public class TestSocketClient { /** * Client 端 * @param args */ public static void main(String[] args) { try { // 创建 Socket 连接本地的 8080 端口 Socket socket = new Socket("127.0.0.1", 8080); // 使用 Socket 进行读写数据 BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(socket.getInputStream())); PrintWriter printWriter = new PrintWriter(socket.getOutputStream()); // 发送数据 printWriter.println("你好"); printWriter.flush(); // 读取响应数据 String line = bufferedReader.readLine(); System.out.println("响应信息:" + line); // 关闭资源 printWriter.close(); bufferedReader.close(); socket.close(); } catch (IOException e) { e.printStackTrace(); } } }
功能也非常简单,启动后自动将 msg 发送给服务端,然后再接收服务端返回的数据并打印到控制台,最后释放资源关闭连接。我们来看看效果:
3 NioSocket 的用法
从JDK1.4开始,Java增加了新的io模式-nio(new 1O)nio在底层采用了新的处理方式,极大地提高了10 的效率。我们使用的 Socket 也属于10 的一种io提供了相应的工具ServerSocketChannel和 SocketChannel,它们分别对应原来的 ServerSocket 和 Socket。
要想理解 NioSocket 的使用必须先理解三个概念:Buffer、Channel和 Selector。为了方便大家理解,我们来看个例子。记得我上学的时候有个同学批发了很多方便面、电话卡和别的日用品在宿舍卖,而且提供送货上门的服务,只要公寓里有打电话买东西,他就送过去、收钱、返回来,然后再等下一个电话,这种模式就相当于普通 Socket处理请求的模式。如果请求不是很多,这是没有问题的,当请求多起来的时候这种模式就应付不过来了,如果现在的电商网站也用这种配送方式,效果大家可想而知,所以电商网站必须采用新的配送模式,这就是现在快递的模式(也许以后还会有更合理的模式)。快递并不会一件一件地送,而是将很多件货一起拿去送,而且在中转站都有专门的分拣员负责按配送范围把货物分给不同的送货员,这样效率就提高了很多。这种模式就相当于 NioSocket 的处理模式,Buffer 就是所要送的货物,Channel 就是送货员 (或者开往某个区域的配货车),Selector 就是中转站的分栋员。
NioSocket使用中首先要创建 ServerSocketChannel,然后注册 Selector,接下来就可以用Selector 接收请求并处理了。
ServerSocketChannel可以使用自己的静态工厂方法open创建。每个ServerSocketChannel对应一个ServerSocket,可以调用其socket方法来获取,不过如果直接使用获取到ServerSocket 来监听请求,那还是原来的处理模式,一般使用获取到的 ServerSocket 来绑定端口。ServerSocketChannel可以通过 configureBlocking 方法来设置是否采用阻塞模式,如果要采用非阻塞模式可以用congureBlocking(false)来设置,设置了非阻塞模式之后就可以调用register 方法注册 Selector 来使用了(阻塞模式不可以使用 Selector)。
Selector可以通过其静态工厂方法 open 创建,创建后通过Channel的register 方法注册到ServerSocketChannel或者 SocketChannel上,注册完之后 Selector 就可以通过 select 方法来等待请求,select 方法有一个 long 类型的参数,代表最长等待时间,如果在这段时间里接收到了相应操作的请求则返回可以处理的请求的数量,否则在超时后返回 0,程序继续往下走,如果传入的参数为0或者调用无参数的重载方法,select 方法会采用阻塞模式直到有相应操作的请求出现。当接收到请求后 Selector 调用 selectedKeys 方法返回 SelectionKey 的集合。
SelectionKey 保存了处理当前请求的 Channel 和 Selector,并且提供了不同的操作类型Channel 在注册 Selector 的时候可以通过 register 的第二个参数选择特定的操作,这里的操作就是在SelectionKey 中定义的,一共有4种:
- SelectionKey.OP ACCEPT
- SelectionKey.OP CONNECT
- SelectionKey.OP READ
- SelectionKey.OP WRITE
它们分别表示接受请求操作、连接操作、读操作和写操作,只有在 register 方法中注册了相应的操作 Selector 才会关心相应类型操作的请求。
Channel和 Selector 并没有谁属于谁的关系,就好像一个分拣员可以为多个地区分栋货物而每个地区也可以有多个分拣员来分拣一样,它们就好像数据库里的多对多的关系,不过Selector 这个分拣员分拣得更细,它可以按不同的类型来分拣,分拣后的结果保存在 Selec.tionKey中,可以分别通过 SelectionKey 的channel方法和selector 方法来获取对应的 Channel和Selector,而且还可以通过 isAcceptable、isConnectable、isReadable 和 isWritable 方法来判断是什么类型的操作。
NioSocket中服务端的处理过程可以分为5步:
- 创建 ServerSocketChannel并设置相应参数。
- 创建 Selector 并注册到 ServerSocketChannel 上。
- 调用 Selector的 select 方法等待请求。
- Selector 接收到请求后使用 selectedKeys 返回 SelectionKey 集合。
- 使用SelectionKey 获取到 Channel、Selector 和操作类型并进行具体操作。
我们来写个例子将前面的Server改成使用nio方式进行处理的NIOServer:
import java.io.IOException; import java.net.InetSocketAddress; import java.nio.ByteBuffer; import java.nio.channels.*; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.Iterator; /** * @author xjx * @description * @date 2023/3/21 6:58 */ public class TestNioSocketServer { static class Handler { private int bufferSize = 1024; private String localCharset = "UTF-8"; public Handler() { } public Handler(int bufferSize) { this.bufferSize = bufferSize; } public void handleAccept(SelectionKey selectionKey) throws IOException { SocketChannel socketChannel = ((ServerSocketChannel) selectionKey.channel()).accept(); socketChannel.configureBlocking(false); socketChannel.register(selectionKey.selector(), SelectionKey.OP_READ, ByteBuffer.allocate(bufferSize)); } public void handleRead(SelectionKey selectionKey) throws IOException { // 获取 Channel SocketChannel socketChannel = ((SocketChannel) selectionKey.channel()); ByteBuffer buffer = (ByteBuffer) selectionKey.attachment(); buffer.clear(); // 没有读取到内容就关闭 if (socketChannel.read(buffer) == -1) { socketChannel.close(); } else { // 将 buffer 转为读状态 buffer.flip(); // 将 buffer 中接收到的内容按编码格式保存 String line = Charset.forName(localCharset).newDecoder().decode(buffer).toString(); System.out.println("服务端收到信息:" + line); // 返回数据给客户端 String res = "收到你的消息:" + line; buffer = ByteBuffer.wrap(res.getBytes(localCharset)); socketChannel.write(buffer); // 关闭 Socket socketChannel.close(); } } } public static void main(String[] args) { try { // 创建 ServerSocketChannel 并监听 8080 端口 ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); serverSocketChannel.bind(new InetSocketAddress(8080)); // 设置为非阻塞模式 serverSocketChannel.configureBlocking(false); // 注册选择器 Selector selector = Selector.open(); serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); // 创建处理器 Handler handler = new Handler(1024); while (true) { // 等待请求,每次阻塞 3秒钟,超过3秒后线程继续向下运行,如果传入0或者不传参将一直阻塞 if (selector.select(3000) == 0) { System.out.println("等待请求超时..."); continue; } System.out.println("处理请求"); // 获取待处理的 SelectionKey Iterator<SelectionKey> iterator = selector.selectedKeys().iterator(); while (iterator.hasNext()) { SelectionKey next = iterator.next(); try { // 接收到连接请求时 if (next.isAcceptable()) { handler.handleAccept(next); } // 读数据 if (next.isReadable()) { handler.handleRead(next); } } catch (IOException e) { iterator.remove(); continue; } // 处理完成后,从待处理的 SelectionKey 迭代器中移除当前所使用的 key iterator.remove(); } } } catch (IOException e) { e.printStackTrace(); } } }
我们看下效果:
上面的处理过程都做了注释,main 方法启动监听,当监听到请求时根据SelectionKey的状态交给内部类 Handler 进行处理,Handler 可以通过重载的构造方法设置编码格式和每次读取数据的最大值。Handler 处理过程中用到了 Buffer,Buffer 是 javanio 包中的一个类,专门用于存储数据,Buffer 里有4个属性非常重要,它们分别是:
- capacity:容量,也就是 Buffer 最多可以保存元素的数量,在创建时设置,使用过程中不可以改变;
- limit:可以使用的上限,开始创建时 limit 和capacity 的值相同,如果给limit 设置一个值之后,limit就成了最大可以访问的值,其值不可以超过 capacity。比如,一个Buffer 的容量capacity为 100,表示最多可以保存100个数据,但是现在只往里面写了20个数据然后要读取,在读取的时候 limit 就会设置为 20;
- position:当前所操作元素所在的索引位置,position 从0开始,随着 get 和 put 方法自动更新;
- mark:用来暂时保存 position 的值,position 保存到 mark后就可以修改并进行相关的操作,操作完后可以通过reset 方法将 mark 的值恢复到 position。比如,Buffer 中一共保存了20个数据,position 的位置是 10现在想读取15到20之间的数据,这时就可以调用 Buffer#mark0)将当前的 position 保存到mark 中,然后调用 Buffer#position(15)将position 指向第15个元素,这时就可以读取了,读取完之后调用 Buffer#reset0 就可以将position 恢复到10。mark 默认值为-1,而且其值必须小于 position 的值,如果调用Buffer#position(int newPosition)时传入的newPosition 比mark 小则会将mark 设为-1。
这4个属性的大小关系是:mark <= position <=limit<=capacity。
理解了这4个属性,Buffer 就容易理解了。我们这里的 NioServer 用到 clear 和 fip 方法clear的作用是重新初始化limit、position 和 mark 三个属性,让 limit=capacity、position=0mark=-1。fip 方法的作用是这样的:在保存数据时保存一个数据 position 加1,保存完了之后如果想读出来就需要将最好 position 的位置设置给 limit,然后将 position 设置为0这样就可以读取所保存的数据了,fip 方法就是做这个用的,这两个方法的代码如下:
// java.nio.Buffer public final Buffer clear() { position = 0; limit = capacity; mark = -1; return this; } public final Buffer flip() { limit = position; position = 0; mark = -1; return this; }
NioSocket 就介绍到这里,当然我们所举的例子只是为了让大家理解 NioSocket 使用的方法,实际使用中一般都会采用多线程的方式来处理,不过使用单线程更容易理解,下节我会把这里的例子改成多线程,在后面分析 Tomcat 的时候大家可以看到实际的用法。
4 小结
这节我们看了下 Socket 不同的方式使用,NIO是主流性能提升了很多,有理解不对的地方欢迎指正哈。