Java游戏编程之NIO API

358 阅读17分钟

作者:老九—技术大黍

原文:Developing Games in Java

社交:知乎

公众号:老九学堂(新人有惊喜)

特别声明:原创不易,未经授权不得转载或抄袭,如需转载可联系笔者授权

前言

世界最流行的Web服务器除了Apache之外,那就是Tomcat服务器了。而Tomcat是使用纯Java编写的Web服务器,它的底层的网络服务是使用Java的NIO和AIO技术实现的。

今天我们来讨论Java中书写游戏服务器必须使用的NIO技术。

ServerSocketChannel类

java.nio.channels包中的ServerSocketChannel抽象类是一个面向流监听的Socket的可选择通道。 该类的数字签名是:

public abstract class ServerSocketChannel extends AbstractSelectChannel

服务端的socket通道是不完整的监听网络socket的抽象。所以绑定和操作socket选择必须通过ServerSocket辅助对象完成—呼叫socket()方法即可。

它不可能给任意的、预存的服务端socket创建一个通道,也不可能使用一个服务端socket指定一个SocketImpl对象。 服务端Socket通道只能通过该类的open方法创建,但是它不能绑定主机和端口

image-20210415143745036.png

如果不绑定该通道就呼叫socket的accept()方法,那么会抛出NotYetBoundException异常;

服务端socket通道呼叫服务端socket的bind方法来绑定主机和端口。

该类的父类AbstractSelectableChannel是一个抽象类,它实现Channel接口,是一个SelectableChannel实现。

AbstractSelectableChannel类处理机器的通道注册、释放和关闭。它维护该通道的当前锁定模式也就是当前选择Key的组。它执行所有需要的同步行为,以实现SelectableChannel的规范要求。

该类中定义的受保护方法没必要同步,因为其它线程可能会做相同的操作。

服务端演示代码

IServer接口

package com.funfree.game.chat;
import java.nio.*;
import java.nio.channels.*;
/**
	功能:定义一个接口用来抽象网络服务器的操作
	作者:技术大黍
	备注:需要使用方来捕获异常。该接口的使用方式:
		IServer server = NetManager.buildServer("实现类名");
	*/
public interface IServer{
	public void accept()throws Exception;   //接收客户端的连接
	public void read()throws Exception;		//从客户端读取消息
	public void write(SocketChannel channel, ByteBuffer writeBuffer)throws Exception;	//向客户端写入消息
}

ChatterServer类

package com.funfree.game.chat;

import java.nio.*;
import java.nio.channels.*;
import java.nio.charset.*;
import java.util.*;
import java.net.*;
import java.io.*;

import org.apache.log4j.*;

/**
  	功能:翻写一个聊天服务器,使用使用NIO实现的多人聊天应用
 	作者:技术大黍 
	备注:socket接受、从socket读和向socket写的方法必须写在接口定义的accept、read和write方法中
 */
public class ChatterServer extends Thread implements IServer{
    private static final int BUFFER_SIZE = 255; //缓冲容量的大小
    private static final long CHANNEL_WRITE_SLEEP = 10L; //通道写操作时的间隔时间
    private static final int PORT = 10997; //端口号

    private Logger log = Logger.getLogger(ChatterServer.class); //对该类进行日志操作
    private ServerSocketChannel serverSocketChannel; //有服务端通道
    private Selector acceptSelector; //接受连接选择器
    private Selector readSelector; //读取选择器
    private SelectionKey selectKey; //被选择的键
    private boolean running;	//标识是否在运行
    private LinkedList clients; //使用链表保存
    private ByteBuffer readBuffer;	//使用字节缓冲读
    private ByteBuffer writeBuffer;	//使用字节缓冲写
    private CharsetDecoder asciiDecoder; //字符解码
  
    public static void main(String args[]) {
		// 配置log4j
		BasicConfigurator.configure();
		
		// 初始化聊天服务器,然后启动定
		ChatterServer cs = new ChatterServer();
		cs.start();
    }
  	
    public ChatterServer() {
		clients = new LinkedList(); //使用链表保存客户端
		readBuffer = ByteBuffer.allocateDirect(BUFFER_SIZE);
		writeBuffer = ByteBuffer.allocateDirect(BUFFER_SIZE);
		asciiDecoder = Charset.forName( "gb2312").newDecoder();
    }
	
	/**
		在这个方法中,服务端完成socket的创建、绑定(bind)本地端口,以及实现监听(Listen)的动作。
		*/
    private void initServerSocket() {
		try {
		    // 指定打开非锁定的服务端socket通道
		    serverSocketChannel = ServerSocketChannel.open();
		    serverSocketChannel.configureBlocking(false);

		    // 绑定本监听的端口
		    InetAddress addr = InetAddress.getLocalHost();
		    serverSocketChannel.socket().bind(new InetSocketAddress(addr, PORT));

		    // 获取选择器,用来处理多个客户端通道
		    readSelector = Selector.open();
		}
		catch (Exception e) {
		    log.error("初始化服务器出错", e);
		}
    }

    public void run() {
		initServerSocket();
		
		log.info("聊天服务器正在运行。。。");
		running = true;
		int numReady = 0;
		
		// 在连接客户端时锁定,直到有客户端连接
		while (running) {
			try{
			    // 检查新的客户端连接
			    accept();
			    // 检查接收的消息
			    read();
		    }
		    catch(Exception e){
		    	log.error("出现错误:",e);
		    }
		    // 第间隔100毫秒接受连接和消息
		    try {
				Thread.sleep(100);
		    }catch (InterruptedException ie) {
		    	ie.printStackTrace();
		    }
		}
    }
    
    /**
    	在这个方法,服务端是在完成了与客户端三次握手成功之后,第一次向客户端发送问候消息!
    	*/
    public void accept() throws Exception{
		//定义一个socket通道
	    SocketChannel clientChannel;
	    //因为serverSocketChannel是非锁定的,所以它会立即返回,而不管该连接是否要用
	    while ((clientChannel = serverSocketChannel.accept()) != null) {
			addNewClient(clientChannel);
			log.info("从: " + clientChannel.socket().getInetAddress() + "获取连接。"); 
			sendBroadcastMessage("从:" + clientChannel.socket().getInetAddress() + "登录", clientChannel);
			sendMessage(clientChannel, "\n\n欢迎来到聊天室,当前有" + 
				    clients.size() + "用户在线。");
			sendMessage(clientChannel, "输入 'quit'退出系统。");
	    }
    }
	
	/**
		该方法是在三次握手,并且第一次回复客户端之后的双向socket交互循环行为!
		*/
    public void read() throws Exception{
		
	    // 使用非锁定选择,所以会立即返回,而不管有多少个key在准备
	    readSelector.selectNow();
	    
	    // 获取key值
	    Set readyKeys = readSelector.selectedKeys();
	    
	    // 通过key和进程来运行
	    Iterator i = readyKeys.iterator();
	    while (i.hasNext()) {
			SelectionKey key = (SelectionKey) i.next();
			i.remove();
			SocketChannel channel = (SocketChannel) key.channel();
			readBuffer.clear();
			
			// 把通道中内容读到缓冲
			long nbytes = channel.read(readBuffer);
			
			// 检查流结束符
			if (nbytes == -1) { 
			    log.info("断开连接: " + channel.socket().getInetAddress() + ",因为遇到流结束符。");
			    channel.close();
			    clients.remove(channel);
			    sendBroadcastMessage("用户:" + channel.socket().getInetAddress() + "登出", channel);
			}
			else {
			    // 把附加放到字符中缓冲
			    StringBuffer sb = (StringBuffer)key.attachment();
				
			    //使用字符集解码器把这些字节解析成字符串,然后附加到字符串缓冲
			    readBuffer.flip( );
			    String str = asciiDecoder.decode( readBuffer).toString( );
			    readBuffer.clear( );
			    sb.append( str);
			    
			    // 检查所有的内容行
			    String line = sb.toString();
			    if ((line.indexOf("\n") != -1) || (line.indexOf("\r") != -1)) {
					line = line.trim();
					if (line.startsWith("quit")) {
					    // 如果是quit消息,那么判决该通道,并把它从在线用户中删除
					    log.info("得到退出(quit)消息,现在关闭:" + channel.socket().getInetAddress());
					    channel.close();
					    clients.remove(channel);
					    sendBroadcastMessage("用户: " + channel.socket().getInetAddress() + "退出", channel);
					}
					else {
					    // 否则向所有客户广播消息
					    log.info("广播: " + line);
					    sendBroadcastMessage(channel.socket().getInetAddress() + ": " + line, channel);
					    sb.delete(0,sb.length());
					}
			    }
			}
	    }
    }
    
    private void addNewClient(SocketChannel chan) throws Exception{
		// 添加socket通道到集合中
		clients.add(chan);
		
		//使用选择器注册该通道,保存了新的字符串缓冲作为Key的附件,该附件用来保存需要读取的消息
	    chan.configureBlocking( false);
	    SelectionKey readKey = chan.register(readSelector, 
	    		SelectionKey.OP_READ, new StringBuffer());
    }
    
    private void sendMessage(SocketChannel channel, String mesg)throws Exception {
		prepWriteBuffer(mesg);
		write(channel, writeBuffer);
    }
    
    private void sendBroadcastMessage(String mesg, SocketChannel from)throws Exception {
		prepWriteBuffer(mesg);
		Iterator i = clients.iterator();
		while (i.hasNext()) {
		    SocketChannel channel = (SocketChannel)i.next();
		    if (channel != from) 
			write(channel, writeBuffer);
		}
    }
    
    private void prepWriteBuffer(String mesg) {
		//从给定的字符串填充缓冲,以便一个通道执行写操作
		writeBuffer.clear();
		writeBuffer.put(mesg.getBytes());
		writeBuffer.putChar('\n');
		writeBuffer.flip();
    }
    
    public void write(SocketChannel channel, ByteBuffer writeBuffer)throws Exception {
		long nbytes = 0;
		long toWrite = writeBuffer.remaining();
		
		//循环执行通道的写操作,但是没有必要,因所有字节一下就可以写完
	    while (nbytes != toWrite) {
			nbytes += channel.write(writeBuffer);
			try {
			    Thread.sleep(CHANNEL_WRITE_SLEEP);
			}catch (InterruptedException e) {
			}
	    }
		
		// 准备另外一个写操作
		writeBuffer.rewind();
    }
}

SocketChannel类

SocketChannel类是一个面向流的连接socket的选择通道。 它是数字签名如下:

public abstract class SocketChannel extends AbstractSelectableChannel implements ByteChannel, ScatteringByteChannel, GatheringByteChannel

该类是一个不完整的连接网络socket的抽象,因此需要Socket对象的协助完成绑定主机,关闭连接,以及操作Socket选项等。

Socket通道对象只能通过该类的open方法创建。一个新建的Socket通道是打开的,但是没有连接到服务器。如果现有呼叫通道的I/O操作会抛出NotYetConnectedException异常。

当呼叫SocketChannel.connect()方法时,通道会连接到服务端,并且它的生命周期直到呼叫SocketChannel.close()方法时停止。判断通道是否连接到服务器时,呼叫isConnected()方法。

image-20210415144443614.png

Socket通道支持非锁定连接:一个socket通道可以先创建,然后初始使用connect连接到远程socket对象,最后finishConnect方法完成最后的连接。

确定当前连接操作是否正在进行,呼叫isConnectionPending方法即可。向一个socket通道执行读写操作,可独立的执行“shut down”(关闭)动作,但是不实际关闭通道本身。关闭输出通道呼叫shutdownInput方法,返之呼叫shutdownOutput方法;如果再次执行写通道操作,会抛出CloseChannelException异常。

Socket通道支持异步关闭,类似于Channel类的异常关闭操作。如果一个读socket线程关闭读动作,那么另外一个锁定读操作的线程,会马上结束读操作,然后返回-1值。写socket线程关闭操作,那么另外一个锁定写操作的线程会接收到AsynchrousCloseException异常。 Socket通道的多线程并发操作是安全的。

image-20210415144627662.png

客户端演示代码

ChatterClient类

package com.funfree.game.chat;

import java.nio.*;
import java.nio.charset.*;
import java.nio.channels.*;
import java.util.*;
import java.net.*;
import java.io.*;
import javax.swing.*;
import java.awt.*;
import java.awt.event.*;

/**
 	功能:翻写一个聊天客户端,使用使用NIO实现的多人聊天应用
 	作者:技术大黍
 */
public class ChatterClient extends Thread implements IServer{
    private static final int BUFFER_SIZE = 255;
    private static final long CHANNEL_WRITE_SLEEP = 10L;
    private static final int PORT = 10997;
  
    private ByteBuffer writeBuffer;
    private ByteBuffer readBuffer;
    private boolean running;
    private SocketChannel channel;
    private String host;
    private Selector readSelector;
    private CharsetDecoder asciiDecoder;
    private InputThread it;
    
    public static void main(String args[]) {
		String host = args[0];
		ChatterClient cc = new ChatterClient(host);
		cc.start();
    }

    public ChatterClient(String host) {
		this.host = host;
		writeBuffer = ByteBuffer.allocateDirect(BUFFER_SIZE);
		readBuffer = ByteBuffer.allocateDirect(BUFFER_SIZE);
		asciiDecoder = Charset.forName( "gb2312").newDecoder();;
    }

    public void run() {
    	try{
			accept(); //连接服务器
			it = new InputThread(this);//开启一个输入线程,读取键盘输入
			it.start();
			
			running = true;
			//判断当前客户端是否正常运行
			while (running) {
			    read(); //从读取服务端的消息
				
			    // 每50毫秒从服务器读一次消息
			    try {
					Thread.sleep(50);
			    }
			    catch (InterruptedException ie) {
			    }
			}
		}catch(Exception e){
			e.printStackTrace();
		}
    }
	
	/**
		在这个方法中完成创建socket对象、绑定(bind)本地端口,以及连接(connect)到服务端
		*/
    public void accept() throws Exception{
		//打开读取选择器
	    readSelector = Selector.open();
	    InetAddress addr = InetAddress.getByName(host);
	    channel = SocketChannel.open(new InetSocketAddress(addr, PORT));
	    channel.configureBlocking(false);
	    //注册通道,把内容放到字符串缓冲
	    channel.register(readSelector, SelectionKey.OP_READ, new StringBuffer());
    }
    
    /**
    	在这个方法中,客户端完成向服务端发送一个回复accept()动作的消息。
    	在selectedKey集合中,就是与服务端握手成功之后的交互!
    	*/
    public  void read() throws Exception{
		// 检查是否有服务端的消息
	    // 因为是非锁定选择器,所以会立即返回,而不有多少key准备读
	    readSelector.selectNow();
		
	    // 获取key
	    Set readyKeys = readSelector.selectedKeys();
		
	    // 通过key和进程进行运行
	    Iterator i = readyKeys.iterator();
	    while (i.hasNext()) {
			SelectionKey key = (SelectionKey) i.next();
			i.remove();
			SocketChannel channel = (SocketChannel) key.channel();
			readBuffer.clear();
			
			// 把通道中的内容读到缓冲
			long nbytes = channel.read(readBuffer);

			// 检查是否流结束
			if (nbytes == -1) { 
			    System.out.println("断开与服务器的连接,因为流结束了");
			    channel.close();
			    shutdown();
			    it.shutdown();
			}
			else {
			    // 否则获取附件信息,然后放到字符串缓冲
			    StringBuffer sb = (StringBuffer)key.attachment();
				
			    //使用CharsetDecoder把获取的字节转换成字符串,然后附加到字符串缓冲
			    readBuffer.flip( );
			    String str = asciiDecoder.decode( readBuffer).toString( );
			    sb.append( str );
			    readBuffer.clear( );

			    // 检查整行,然后输出控制台
			    String line = sb.toString();
			    if ((line.indexOf("\n") != -1) || (line.indexOf("\r") != -1)) {
					sb.delete(0,sb.length());
					System.out.print("" + line);
					System.out.print("> ");
			    }
			}
	    }		
    }
    
    private void sendMessage(String mesg) throws Exception{
		prepWriteBuffer(mesg);
		write(channel, writeBuffer);
    }

    private void prepWriteBuffer(String mesg) throws Exception{
		//使用给定的字符串填充到缓冲,以便通道执行写操作
		writeBuffer.clear();
		writeBuffer.put(mesg.getBytes());
		//writeBuffer.putChar('\n');
		writeBuffer.flip();
    }
    
    public void write(SocketChannel channel, ByteBuffer writeBuffer) throws Exception{
		long nbytes = 0;
		long toWrite = writeBuffer.remaining();
		// 进行写操作
	    while (nbytes != toWrite) {
			nbytes += channel.write(writeBuffer);
	    }
		
		// 准备下一次写操作
		writeBuffer.rewind();
    }

    public void shutdown() {
		running = false;
		interrupt();
    }

    /** 
     * 功能:该线程完成键盘的输入功能
     */
    class InputThread extends Thread {
		private ChatterClient cc;
		private boolean running;
		public InputThread(ChatterClient cc) {
		    this.cc = cc;
		}

		public void run() {
			/*JFrame chatFrame = new JFrame("聊天客户端");
			chatFrame.setSize(300,250);
			chatFrame.setLocationRelativeTo(null);
			chatFrame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
			chatFrame.setVisible(true);
			final JTextArea text = new JTextArea(15,15);
			JButton sendButton = new JButton("发送");
			chatFrame.add(text,BorderLayout.CENTER);
			chatFrame.add(sendButton,BorderLayout.SOUTH);
			sendButton.addActionListener(new ActionListener(){
				public void actionPerformed(ActionEvent e){
					String content = text.getText().trim();
					try{
						if (content.length() > 0){						
							cc.sendMessage(content + "\n");
						}
						if(content.equals("quit")){
							cc.shutdown();
						}
					}catch(Exception ee){
						ee.printStackTrace();
					}
				}
			});*/
		    BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
		    running = true;
		    while (running) {
				try {
				    String s;
				    System.out.print("> ");
				    System.out.flush();
				    s = br.readLine();
				    if (s.length() > 0)
						cc.sendMessage(s);
				    if (s.equals("quit")) 
						running = false;
				}catch (Exception ioe) {
				    running = false;
				}
		    }
		    cc.shutdown();
		}
		public void shutdown() {
		    running = false;
		    interrupt();
		}
    }
}

Selector类

Selector类是一个SelectableChannel对象的多路复用器。 它的数字签名是:

public abstract class Selector extends Object

一个选择器对象可以由该类的open方法创建,该类用来使用操作系统提供的默认选择器提供者,以便创建一个新的选择器。一个选择器对象也可以呼用户自定义的selector提供者的openSelector方法来创建。

选择器会一直打开,直到呼叫close方法时关闭。当一个可选择的通道使用选择器进行注册时,那么会产生SelectionKey对象。选择器会维护三组选择key:

  • key set (键集合)—这些key表示当前注册了该选择器的通道。呼叫Selector.keys()方法返回
  • selected-key(选择了的键集合)—表示被系统侦查到的,已经准备好的通道。呼叫Selector.selectedKeys()方法返回,它是keys的子集
  • cancelled-key(取消的键集合)—表示已经注销的通道对象,这个集合不能直接访问。

在创建一个新的选择器时,三个集合都是空的。

image-20210415145112736.png

放到选择器的key集合中的key就是通过通道注册了的有效通道对象。取消的key会在key操作期间被删除,因为key集合本身不能直接被修改。取消key时会导致它所属通道被注销。被添加到选择了key集合中的key对象,可以呼叫Set.remove()方法移动掉。Key不可以直接被添加到选择了的集合(Set)中去,Selector类自我实现多个线程的并发操作,包括它的key集合。选择器本身的选择操作也是同步的,不管是针对选择了key集合,还是取消了key集合。线程锁定select()和select(long)操作可以三种方式终止:

  • 呼叫Selector.wakeup方法
  • 呼叫Selector.close方法
  • 呼叫锁定线程的interrupt方法

SelectionKey类

SelectionKey是一个抽象类,它表示一个使用Selector注册成功的SelectableChannel通道。它的数字签名如下

public abstract class SelectionKey extends Object

一个选择key在一个通道使用一个选择器注册成功之后被创建。一个的key的生命周期存活到呼叫SelectionKey.cancel()方法时为止,或者当Selector.close()方法时为止。呼叫SelectionKey.cancel()方法是,不是立即把该通道删除,而是放到Selector的取消key集合中保存,让Selector去销毁。该类的选择并发操作是安全的,对于读写interest集合是同步的。在实际开发中,使用应用级别的数据结构,比如使用一个对象表示高阶的协议,这样可实现协议顺序读取性。因此SelectionKey支持使用任意对象作为附件扔给这个key使用。作为附件的对象,在呼叫SelectionKey.attach()方法添加到通道,获取附件时呼叫attachment方法即可。

image-20210415150002055.png

ByteBuffer类

该类是一个字节缓冲的抽象,它的数字签名如下:

public abstract class ByteBuffer extends Buffer implements Comparable

该类定义了六种操作字节的操作:

  • 绝对和相对的get和put操作,这些操作是单个字节的读和写操作
  • 相对的块get方法,把连接了字节传送到一个数组中去
  • 相对的块put方法,是get相反操作

image-20210415150209557.png

绝对和相对的get和put方法,用来读写其它原始数据类型。

image-20210415150323167.png

字符集

java.nio.charsets包定义了新的字符集和新的字符转码的机制。新的字符集类以更标准的方式来表示以上抽象的概念。字符集(Character set)—字符的集合,比如字母”A”是字符,同样”%”也是字符;不管直接数字值,或者ASCII值、Unicode值都是字符。它们在计算机被发明之前就已经存在了。编码字符集(Coded character set)—一组表示字符集的指定数值。这些指定的数值可以数字化的表示特定字符编码集。其它的编码字符集合,可能会用不同的数值表示相同的字符。比如US-ASCII码,ISO 8859-1编码, Unicode (ISO 10646-1)编码等。 以上三种编码就是把编码字符集的成员影射成字节(8位)序列。而编码策略定义了怎样编码字符的序列,该序列是一个字节的序列。它有点像序列化和反序列的动作。

字符数据通常是被编码过的,用来通过网络传输,或者保存存文件形式。而编码策略不是字符集,它只是一种影射机制!(An encoding scheme is not a character set, it’s a mapping)比如UTF-8是一个专用的于Unicode字符集的编码策略,但是,它同样可以用来处理多种字符集。UTF-8编码字符代码值都小于0x80值,这个值被看成一个字节的值(标准ASCII码值)。其它的Unicode字符使用两个字节(16位)序列来编码。字符(Charset)—该术语是编码字符集和字节编码策略的综合体。java.nio.charset包就是它的抽象。

下面图示说明java.nio.charsets包是怎样封装字符的:

image-20210415150521027.png

大多数操作系统还是使用面向字节的I/O操作与文件存贮方式,所以不管使用什么编码方式,比如Unicode编码或者其它的编码,它们都必须在字节序列与字符集编码之间转换! java.nio.charset包就是为了解决这个问题而产生的。它不是Java平台描述的字符集编码,它是最系统的、复杂的和灵活的解决方案。 java.nio.charset.spi包提供了服务提供者接口(SPI),该接口允许加入新的编码器和解码器。

Charset类

Charset 类封装了固定的信息,对于字符编码,我们使用Charset类来进行编码(encode)与解码(decode) 对于非阻塞方式的socket编程,必须使用Charset类、CharsetDecoder类、CharsetEncoder类来对二进制位信息进行编码与解码,才能正确的发送与显示。

Charset演示代码

package com.funfree.game.chat;

import java.io.*;
import java.nio.*;
import java.nio.channels.*;
import java.util.*;
import java.net.*;
import java.nio.channels.spi.*;
import java.nio.charset.*;

/**
	功能:书写一个类最简化版本的NIO服务端
	作者:技术大黍
	备注:使用单线程完成Socket的客户端的并发处理
	*/

public class NIOChatServer{
	private ServerSocketChannel serverChannel;
	private Selector connectSelector;
	private PrintWriter out = new PrintWriter(System.out,true);
	private static int PORT = 1818;
	
	
	/**
		在构造方法中初始化成员变量,以及完成监听客户端的接入动作
		*/
	public NIOChatServer()throws Exception{
		//创建一个非阻塞服务端通道
		serverChannel = ServerSocketChannel.open();
		serverChannel.configureBlocking(false);
		
		//创建一个选择器,用来注册服务端通道
		connectSelector = SelectorProvider.provider().openSelector();
		
		//绑定服务端通道到指定的地址和端口
		/*InetSocketAddress address = new InetSocketAddress(
			InetAddress.getByName("az-1"),PORT);*/
		serverChannel.socket().bind(new InetSocketAddress(PORT));
		
		//注册服务端通道,表示等待新的连接
		serverChannel.register(connectSelector, SelectionKey.OP_ACCEPT);
		
		//启动一个子线程来处理所有的并发的客户端请求
		new ClientHandler(connectSelector).start();
		
		out.println("服务器已启动。。。");
		
	}
	
	public static void main(String[] args){
		try{
			new NIOChatServer();
		}catch(Exception e){
			e.printStackTrace();
		}
	}
}

/**
	功能:使用一个子线程专门处理与客户端的通讯交流
	作者:技术大黍
	备注:处理并发是通过key来判断操作,所有的客户端使用一个SocketChannel来表示。
		  非阻塞技术与普通I/O流技术的区别是:使用一个线程处理多个客户端的socket
		  对象;而不是给每个客户端的socket分配一个线程来处理!
	*/
class ClientHandler extends Thread{
	static  LinkedList clients = new LinkedList(); //用来保存客户端socket对象
	private Selector selector;
	static int count = 0;
	private PrintWriter out = new PrintWriter(System.out, true);
	static boolean flag = false; //false表示没有初始化过,true表示已经初始化过了
	
	public ClientHandler(Selector selector)throws Exception{
		this.selector = selector;
	}
	
	/**
		在run方法中来判断是否有客户端的连接和消息的读入。
		*/
	public void run(){
		
		try{
			while(true){
				//查看注册了的通道
				selector.select();
				
				Iterator selectedKeys = selector.selectedKeys().iterator();
				while(selectedKeys.hasNext()){
					SelectionKey key = (SelectionKey)selectedKeys.next();
					selectedKeys.remove();
					
					if(! key.isValid()){//如果没有找到适合的socket通道
						continue; //不作为
					}
					
					//否则根据isXXXX来判断是客户端接入,还是客户端消息读入
					if(key.isAcceptable()){//如果有客户端接入
						//那么处理多个客户端接入的情况
						accept(key);
					}else if(key.isReadable()){ //如果有消息读入
						sendAll(key);
					}
				}
				sleep(100); //间隔100毫秒接收客户端的接入
				
				if(!flag){
					//定时向各个客户端发送消息
					//new MyTimer().start();
				}
			}
		}catch(Exception e){
			e.printStackTrace();
		}
	}
	
	private void accept(SelectionKey key)throws Exception{
		ServerSocketChannel serverChannel = (ServerSocketChannel)key.channel();
		//获取客户端连接
		SocketChannel socketChannel = serverChannel.accept();
		Socket socket = socketChannel.socket();//获取一个socket用来向客户端说Hello
		PrintWriter output = new PrintWriter(socket.getOutputStream(),true);
		output.println("欢迎进入聊天室!");
		socketChannel.configureBlocking(false);
		socketChannel.register(selector, SelectionKey.OP_READ,new StringBuffer());
		
		count++;
		out.println("当前有" + count + "客户端连接到服务器。");
		clients.addLast(socketChannel); //保存到集合中
	}
	
	private void sendAll(SelectionKey key)throws Exception{
		SocketChannel client = (SocketChannel)key.channel();
		client.configureBlocking(false); //如果这样设置为非阻塞,并且立即注册通道如下
		client.register(selector, SelectionKey.OP_READ,new StringBuffer());//那么就是告诉操作系统立即读取当前所信息
		
		ByteBuffer buffer = ByteBuffer.allocate(255);
		
		int flag = client.read(buffer); //关键代码是SocketChannel.read方法
		
		//因为系统感知的,所以需要通过客户端的输入来判断服务端的通讯内容。
		if(flag == -1){
			client.close();
			clients.remove(client);
		}else{
			StringBuffer sb = (StringBuffer)key.attachment();
			buffer.flip();
			
			//在循环读入的过程,必须使用CharsetDecoder来处理接收的字符,否则不会出现
			//我们想要的字符结果!!!
			CharsetDecoder decoder = Charset.forName("gb2312").newDecoder();
			
			//String str = new String(buffer.array());//不解码会出现问题
			String str = decoder.decode(buffer).toString();
			buffer.clear();
			
			sb.append(str);
			
			String line = sb.toString();
			if((line.indexOf("\n") != -1) || (line.indexOf("\r") != -1)){
				line = line.trim();
				out.println("客户端输入的内容是:" + line);
				if(line.startsWith("quit")){
					client.close();
					clients.remove(client);
				}else{
					for(int i = 0; i < clients.size(); i++){
						SocketChannel x = (SocketChannel)clients.get(i);
						if(x != client){
							ByteBuffer tempBuffer = ByteBuffer.wrap((client.socket().getInetAddress() +
								"说:" + line + "\n").getBytes());
							//然后向客户端写内容
							x.write(tempBuffer);
							
							tempBuffer.flip();
						}
					}
					sb.delete(0,sb.length());
				}
			}
		}
	}
	
	class MyTimer extends Thread{
		
		public MyTimer(){
			ClientHandler.flag = true; //表示已经初始化过了
		}
		
		public void run(){
			try{
				while(true){
					sleep(500);
					for(int i = 0; i < clients.size(); i++){
						SocketChannel x = (SocketChannel)clients.get(i);
						ByteBuffer tempBuffer = ByteBuffer.wrap("测试我的计时器".getBytes());
						//然后向客户端写内容
						x.write(tempBuffer);
						
						tempBuffer.flip();
					}
				}
			}catch(Exception e){
				e.printStackTrace();
			}
		}
		
		
	}
}

游戏数据设计说明

在游戏的服务端,除了上述的非阻塞方式实现的高并发服务性能之外,还有一个非常重要的方面。那就是游戏数据的存贮问题,一般使用关系型数据库。主要用来存贮玩家数据、得分中高分值、游戏统计数据、同伴列表,以及其它游戏特定的数据。因为我们使用标准的Java应用服务器:GameServer类,所以不会使用J2EE应用服务器。

那么数据库器怎样选择?大多数程序都选择使用直接使用JDBC API来完成。它的好处是速度,并且如果我们使用的数据小的话,那么这是非常好的策略。如果不强调速度,并且数据量非常大的时候,最好使用ORM技术来实现,比如JDO、Hibernate以及iBats等框架技术。 MMORPG游戏服务器还有提供玩家可以查找好友列表的功能,以及与这些好友聊天功能。

查找好友功能包括:好友列表(与即时通讯的功能一样),比如当你的好友登入时,显示你在哪个游戏大厅,以及哪个服务器(区)有哪些人在。玩家匹配特性,根据玩家所在大厅中相关玩家相同技能、游戏性,或者其它关键的会议、聊天,以及游戏任务挑战等。聊天功能所有游戏必须的功能。基于聊天功能,我们可以让管理员把有关的用户踢出。以保证游戏世界中的玩家是正常的玩家。

服务器管理

每个应用至少需要一个错误日志和调试日志。另外,我们可能会需要一个聊天日志,游戏历史日志,连接/访问日志,或者游戏特定的日志。JDK 1.4在java.util.logging包中提供了新的日志API。不过实际上的标准是apache的log4j框架。Log4j是高配置,易使用和高效率的框架。在游戏的客户端生成日志是非常重要的,我们需要看到数据,但是在测试阶段是非常重要的。即使在游戏布置之后,如果用户有麻烦,我们可以让客户端发送他/她的日志来帮助我们分析问题。

服务器管理员控制台:管理控制台可以远程的,也可以本地的应用。本地应用可以RPSConsoleEventReader类表示,它从键盘读入命令,然后把这些命令输入到事件中去。远程控制可以通过GameClient和RPSClient修改而成。当然可以控制或者GUI界面的。不管哪种方式,都要把GameEvent发送到GameController中去,类似于另外一个客户端。

不同的是,我们需要用户经过管理员的列表和远程IP地址过滤之后。管理员客户端可以与普通的GameController或者特定的AdminController讲话。一个AdminController类提供管理功能,比如服务器性能监视、游戏监控、日志查询,以及远程关闭/启动。对于安全要求,我们可以使用另外一个端口来管理接入,或者设置相应的防火强规则来限制端口访问。

游戏监控—是一种游戏在运行中的间谍行为,对于问题分析和玩家的行为分析是非常重要的。如果要实现该功能,那么修改GameClient类,对于用户的输入不需要响应。另外在GameController类需要修改,以便允许管理员客户端附加到游戏中,然后开始接收所有的事件。比如,sendEvent和sendBroadcastEvent方法需要先检查是否有间谍附件存在,然后把所有事件发送给游戏监控客户端。

断开连接和重新连接—在游戏服务器开发中最头痛是处理客户端断开连接。玩家可能因为多种原因掉线而退出游戏。比如网络繁忙,物理断开等情况。不管怎样,我们需要让玩家重新连接,然后继续游戏。对于已经发送了断开事件,并且已经关闭的连接,我们需要一个Reaper(收割者)来处理。这个收割者一个线程,它使用java.util.Timer或者用户自定义线程来监视已经发送了关闭事件,并且已经关闭的socket客户端,但是它们还没有被激活的socket客户端。 对于游戏服务器测试的指标有

  • 服务器支持的最大并发用户数
  • 并发连接之后的事件通过率(events/sec)
  • 并发连接的延迟时间
  • 并发连接之后的CPU使用情况
  • 事件延迟情况(每次事件从接受到开始处理的时间)
  • EventQueue积压率(用来调整Wrap池的大小)
  • 内存使用时间,注意是否有连续增长情况,它表示有内存泄漏(使用Java会出现这种情况)
  • 出现非正常异常和错误
  • 线程死锁

测试时需要把客户端机器与服务端机器分开,因为客户端与服务端同在一台机器中是不可能出现并发的效果。该GameServer服务器有下面几个关键点是影响性能的:

  • Event processing time(事件处理时间)
  • Game logic times for each GameEvent type (每个游戏事件的游戏逻辑时间)
  • Event latency (事件在队列中等待处理的时间)
  • Database query times (数据查询次数)
  • Time to add a new connection (每次连接时间)

在RPSController.processEvent方法可以添加统计代码优化性能时需要关注两点:

  • Make events smaller—如果事件小,那么分配内存时间少,同时序列化时间少,最后它们的传输时间也少
  • Send fewer events—尽可能少的发送事件。如果客户端不确实不需要数据,那么不需要发送给它们。比如GameController.sendBroadcastEvent()方法,我们不需要给事件源广播该消息。

另外我们需要优化GC—使用-Xincgc命令,以及JDK1.4以后使用-XX:+UseConcMarkSweepGC和 -XX+UseParallelGC命令 参见:www.oracle.com/technetwork…

还有就是让对象重用—尽可能让少的对象来完成相同的事情。RPSController就是为达到这个目的设置的。总体上讲,使用对象池是最佳解决方案。比如GameServer能非常快速创建GameEvent实例。但是,这些事件的生命周期非常短,它们被创建,接着被发送到客户端,然后被销毁。这是使用c/c++的编程策略。 GameEvent池对游戏服务扩大的装载性能有着重大的影响,最难的部分是确定在这个池到底有多少个对象才合适,但是取决于自动测试结果,或者对象池可以被低级别是权限的线程来所捕获,然后对它的大小进行调整。 大多数Web和应用服务器都使用这样的技术来管理它们的请求处理线程池。 最后的优化策略—把线程保持到最小数目。使用Wrap模式可以保证使用较少的线程来检查平均队列大小。因为每个线程都需要额外的资源,所以我们应该把线程数量保存最小化。同步技术也是需要仔细考虑的,保持同步最小化这是不用多说的技术问题要避免这样的循环写法:

while(true){
	checkForSomeCondition();
	try{
		Thread.sleep(SLEEP_TIME);
	}catch(Exception e){}
}

最后,在循环中把所有不需要日志的代码统统删除掉。太多的日志会杀掉服务器的性能。

总结

说明到游戏服务器运行效率,那么一定与硬件相关,因此,Java必须给出运行效率较高API给第三方程序员使用。想写出高效的服务端代码,那么从Java的角度讲,掌握NIO编程技巧几乎是必须的。

如果大家感兴趣,想要完整的代码,那么使用下面的联系方式

QQ咨询:胖达QQ:3038443845

微信加:laojiujun 老九君

最后

记得给大黍❤️关注+点赞+收藏+评论+转发❤️

作者:老九学堂—技术大黍

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。