使用NIO构建服务器端和客户端(上)

构建一个最基本的服务器端和客户端连接,因为网络的读写操作都是使用标准IO,所以通常使用的是Socket处理机制来实现。假如要实现一个可以为多个客户端响应的服务器,服务器端为每一个建立了连接的客户端分配一个线程,每一个线程只为一个客户端连接服务,这种多线程的服务器开发是十分常见的,它的简单实现如下。

多线程服务器简单实现

要建立能处理多客户端连接处理的服务器,需要一个线程池,为每一个提交建立连接请求的客户端都创建一个线程来为它服务即可:

package com.justin.multithreadserver;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class MultiThreadServerDemo {
	// 服务器端中实现多线程的线程池
	private static ExecutorService threadPool = Executors.newCachedThreadPool();
	
	//监听读取客户端数据类
	static class ReadClientData implements Runnable {
		Socket clientSocket; // 获取该客户端连接
		
		//构造方法初始化
		public ReadClientData(Socket clientSocket) {
			this.clientSocket = clientSocket;
		}
		
		@Override
		public void run() {
			BufferedReader bufferedRead = null;
			PrintWriter printWrite = null;
			InputStreamReader streamRead = null;
			String readData = null; //保存每读到一行的数据
			
			try {
				streamRead = new InputStreamReader(clientSocket.getInputStream());
				bufferedRead = new BufferedReader(streamRead);
				printWrite = new PrintWriter(clientSocket.getOutputStream());
				
				//开始读客户端发来的数据
				while ((readData = bufferedRead.readLine()) != null) {
					printWrite.println(readData); //输出读到的数据
				}
				//回发客户端确认连接信息
				printWrite.write("建立连接成功.\r\n");
				printWrite.flush(); // 将缓冲区的数据输出,刷新缓冲区
			} catch (IOException e) {
				e.printStackTrace();
			} finally {
				try {
					if(bufferedRead != null) {
						bufferedRead.close();
					}
					if(printWrite != null) {
						printWrite.close();
					}
					clientSocket.close(); //关闭客户端连接
				} catch (IOException e) {
					e.printStackTrace();
				}
			}
		}
	}
	
	@SuppressWarnings("resource")
	//服务器中的主线程
	public static void main(String[] args) { 
		ServerSocket server = null;
		Socket clientSocket = null;
		try {
			server = new ServerSocket(5000); //端口号5000
		} catch (IOException e) {
			e.printStackTrace();
		}

		//开始死循环监听客户端发来的请求
		while (true) {
			try {
				clientSocket = server.accept(); //accept()方法会阻塞当前线程,等待客户端发来的请求
				System.out.println(clientSocket.getRemoteSocketAddress() + " succeed connect.");
				ReadClientData readThread = new ReadClientData(clientSocket); // 为这个客户端连接开启读数据线程
				threadPool.execute(readThread); // 为该客户端服务的线程提交到线程池执行
			} catch (IOException e) {
				e.printStackTrace();
			}
		}
	}

}

第18到23行,服务器端每一个线程接收一个客户端Socket,即一个线程对应为一个客户端连接服务。第33行,用InputStreamReader来接收来自客户端发来的数据,然后用BufferedReader来读出数据并显示。在主线程中,第68置服务器端在端口号为5000的端口上等待,第76行服务器端线程阻塞,等待接收客户端的请求,一旦受到客户端发来的连接请求,服务器线程就会收到这个连接并保存在clientSocket中,然后通过这个客户端连接建立线程,并交由线程池执行,这样这个线程就单独的,完全为这个客户端服务。

来看看客户端的实现:

package com.justin.multithreadserver;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.InetSocketAddress;
import java.net.Socket;

public class ClientDemo implements Runnable {
	Socket clientSocket = null;
	BufferedReader bufferedRead = null;
	PrintWriter printWrite = null;
	InputStreamReader streamRead = null;
	InetSocketAddress serverAddress = new InetSocketAddress("localhost", 5000); // 封装端口

	@Override
	public void run() {
		try {
			clientSocket = new Socket();
			clientSocket.connect(serverAddress);
			PrintWriter printWrite = new PrintWriter(clientSocket.getOutputStream());
			printWrite.println(clientSocket.getRemoteSocketAddress() + "send message.");
			//printWrite.flush(); // 将缓冲区的数据输出,刷新缓冲区
			
			streamRead = new InputStreamReader(clientSocket.getInputStream());
			bufferedRead = new BufferedReader(streamRead);
			System.out.println("Form server: " + bufferedRead.readLine());
		} catch (IOException e) {
			e.printStackTrace();
		} finally {
			try {
				if(bufferedRead != null) {
					bufferedRead.close();
				}
				if(printWrite != null) {
					printWrite.close();
				}
				if(clientSocket != null) {
					clientSocket.close(); //关闭客户端连接
				}
			} catch (IOException e) {
				e.printStackTrace();
			}
		}
	}
	
	public static void main(String[] args) {
		ClientDemo client = new ClientDemo();
		Thread clientThread = new Thread(client); 
		clientThread.start(); //启动客户端线程

	}

}

客户端实现比较简单,第15行配置好端口号后,第21行调用clientSocket.connect()方法来连接服务器的5000端口,连接成功,服务器端会启动线程,并输出提示:

这样做似乎没什么问题,服务器端多线程处理多个客户端的请求,可以更好地利用多核CPU。

 

阻塞IO和非阻塞IO

但是,我们留意到,上面的代码中,无论是读取数据的InputStreamReader流,BufferedReader流,还是发送数据的PrintWriter类,它们都是来自java.io这个包中,IO是面向流的,即每次从流中读取字节,可能一个或多个字节一次,读取的中途,这些字节数据没有被缓存,它们只能一直被读取,直到读取结束。还有,IO流都是阻塞型的,也就是说,结合上面的例子,无论是服务器线程,还是客户端线程,当它们进行数据读取和写入发送,即调用read()或者write方法时,当前线程就会被阻塞,服务器线程读取客户端发来的数据时,期间就不能做其他事情了,它必须等待,直到数据被读取。反过来也是,写入数据时,除非数据完全写入,否则当前线程也无法转去处理其他的事情,这对服务器端和客户端都一样,因为它们使用的是阻塞式的IO流。这样看来,如果客户端向服务器发送大量数据,服务器便会产生大量的IO等待,让服务器CPU浪费时间在IO等待上,即使使用多线程处理多个客户端,也无法提升太大的效率。有没有什么办法解决呢?

还记得上一篇日志讲Future模式时,使用FutureTesk类配合实现Callable接口的线程,可以让线程执行后,立刻返回,由FutureTesk类的实例接收返回结果。即使是计算量极大的任务,主线程也无需等待子线程的执行完成,可以转去干别的事情,在需要子线程执行结果时,再通过FutureTesk类的实例调用get()方法即可,这是Runnable接口无法做到的。

同样,普通IO做不到的,NIO可以做到。NIO是面向缓冲的(不同于IO是面向流的),于IO不同的是,使用NIO读取数据,数据会被放到一个缓冲区中,如果当前缓冲区中接受的数据还不完全,即还不可读取,那么线程就不会等待返回,而是转去处理其他的事情,也就不会导致线程阻塞。这是怎么做到的?慢慢来看。

 

核心API:Channel,Buffer和Selector

首先是组件Channel通道,类似于流,可以通过Channel通道将数据写入到Buffer中,或者读出数据到Channel,Channel的具体实现有:

FileChannle对文件进行数据的读写。

SocketChannel通过TCP协议对网络中的数据进行读写。

DatagramChannel通过UDP协议对网络中的数据进行读写。

ServerSocketChannel服务器,对每一个新连接建立,都会创建一个SocketChannel。

Channel通道是双向的,你既可以往通道中写入数据,也可以从通道中读出数据,这点与流不同,流是单向的,例如读数据用InputStreamReader流,写入数据用OutputStreamWriter流。

有了数据交换的通道还不够,还需要数据交换的“包装”,或者说数据在这个Channel通道中是如何传输的。这就是另一个组件Buffer的任务,Buffer是一块内存,作为缓冲区,数据需要包装成一个Buffer才能Channel通道中传输,所以传输的数据时,要么是写入一个Buffer,要么是读出一个Buffer。既然Buffer是一块内存块,那么它的使用方法,即实例化对象时要调用方法allocate()来分配内存:

CharBuffer charBuf = CharBuffer.allocate(1024);

至于向Buffer中写入数据,可以调用Buffer.put()方法,如果要把Buffer中的数据读出到Channel通道中,例如SocketChannel通道,则可以调用SocketChannel.read(Buffer)方法。

      第三个重要组件就是Selector选择器,它负责管理多个Channle通道,通过将Channle通道调用register()方法来绑定到Selector上,方法返回一个SelectionKey类的对象,这个对象十分重要,因为里面包含了很多东西!其中有以下的属性:

ready集合

ready集合里面有返回布尔类型的方法,用来检测Channel通道的就绪状态,例如:

selectionKey.isWritable(); 标识当前通道是否可写。

selectionKey.isReadable(); 标识当前通道是否可读。

selectionKey.isAcceptable(); 标识当前通道是否可接收。

selectionKey.isConnectable(); 标识当前通道是否已连接。

interest集合

Channel通道绑定到选择器Selector上:

SelectionKey channleKey = channel.register(selector, Selectionkey.OP_Write);

先来看register()方法的两个参数,第一个是选择器selector,第二个是监听的事件,例如上面的参数Selectionkey.OP_Write表示写事件,即向通道写入数据操作。同样的还有其他注册事件,它们都在SelectionKey的事件集合中,如:

SelectionKey.OP_READ读取操作事件,可以向通道中读取数据。

SelectionKey.OP_CONNECT套接字连接操作事件,服务器与客户端已建立连接。

SelectionKey.OP_ACCEPT套接字接收事件。

 

除此之外,SelectionKey中还包括了实例对象注册时使用的Channle和绑定的Selector。

选择通道

选择使用哪个通道前,首先是查找,查找Selector选择器管理的通道中,有没有符合你需要的事件的通道。当Channle通道调用register()绑定到一个Selector选择器后,就可以通过这个选择器来选择使用不同的通道,通过调用Selector选择器的select()方法,该方法会返回符合你想要执行的事件的已就绪的通道。例如你想要执行写操作,select()方法会去轮询查找,返回那些由它管理的,注册了写操作且已就绪的通道。来看看select()方法的几个重载:

int select(): 该方法会去查找符合你需要的事件的通道,如果没有符合的通道,就会阻塞,继续轮询查找直到找到一个符合要求的通道。

int selectNow():该方法如果没有找到符合的通道,不会阻塞,而是会立刻返回0。

int select(long timeout):和第一个select()一样,不过最多阻塞timeout毫秒。

      例如你想要一个准备就绪好进行写入事件的通道:

int writeChannelNum = selector.select();

int返回值表示由该selector管理的符合事件的通道的数量。

 

获得符合的通道数量后,就可以调用选择器Selector的selectedKeys()方法,来操作这些符合条件的通道:

Set selectedKeys = selector.selectedKeys();

selectedKeys中存放了selector选择器中符合条件的通道,它们是SelectionKey对象(也就是Channle绑定Selector时返回出去的对象)。之后,我们就可以遍历这个selectedKeys中的通道,根据调用不同的方法选择就绪的通道,有以下方法:

SelectionKey.isWritable():表示这个通道写操作是否已就绪。

SelectionKey.isReadable():表示这个通道读操作是否已就绪。

SelectionKey.isConnectable():表示这个连接是否已建立。

SelectionKey.isAcceptable():表示这个连接是否可接收。

感觉自己写的有点乱,我整理一下,来看看代码步骤,首先是Channel通道和Buffer的配合使用:

package com.justin.channelselector;

import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

public class ChannelSelectorDemo {

	public static void main(String[] args) throws IOException {
		// 随机流关联文件,可读可写
		RandomAccessFile file = new RandomAccessFile("src/Harry Potter.txt", "rw");
		FileChannel fileChannel = file.getChannel(); //文件使用该通道
		ByteBuffer buffer = ByteBuffer.allocate(1024); //Channel中的数据包装
		
		int byteRead = fileChannel.read(buffer);
		while (byteRead != -1) {
			System.out.print(byteRead);
			buffer.flip(); // 方法改变读写指针的位置,从0开始
			
			// hasRemaining()方法返回Buffer中剩余的可用长度
			while (buffer.hasRemaining()) {
				System.out.print((char)buffer.get());
			}
			
			buffer.clear(); //清空缓冲区
			byteRead = fileChannel.read(buffer);
		}
		
		file.close();
	}

}

第12行使用随机文件流,然后实例化一个FileChannel通道,上面有列出说明,这是一个对文件进行读写的通道,通过FileChannel通道读出数据,数据以Buffer形式包装,第14行,实例化Buffer对象时调用方法allocate()来为这个Buffer分配内存,分配出一个可存储1024字节的ByteBuffer,然后就可以通过通道读出数据到Buffer中了:

至于加入选择器Selector后,如何组合起来使用?如何利用它们构建NIO服务器和客户端?我们下一篇日志继续。:-)

本篇日志使用的完整代码已上传GitHub:

https://github.com/justinzengtm/Java-Multithreading/tree/master/Java-NIO/NIO-NETWORK

发布了97 篇原创文章 · 获赞 71 · 访问量 7万+

猜你喜欢

转载自blog.csdn.net/justinzengTM/article/details/92650235