java游戏服务端框架之网关

目录

网关介绍

mina服务端代码示例

消息编解码

私有协议栈定义

消息体序列化与反序列化

编码器设计

解码器设计


网关介绍

游戏服务器的网关,主要是用于管理手机客户端会话(包括建立与移除),负责接收来自手机客户端的请求协议,以及主动推送服务端的响应包。

在单一进程服务端架构里,网关跟游戏业务处理是在同一个进程里。为了提高通信吞吐量,一些服务端架构将网关作为一个独立进程。这种模式下,客户端请求全部由网关接收,再通过网关转发给服务端;另一方面,服务端下发的消息,也只能通过网关推送到客户端。由于只有客户端跟网关是一对一的socket连接,网关到服务端只需创建若干socket就可以完成全部通信任务,大大提高了服务端的负载能力。

本文讨论的为集成网关。

采用Java编写的服务器在选择通信框架技术上,要么选择Netty,要么选择Mina,很少有公司会去研发自己的通信框架。原因很简单,重新造轮子实现NIO服务器,开发成本非常高,需要自己去处理各种复杂的网络情况,诸如客户端重复接入,消息编解码,半包读写等情况。即使花费长时间编写出来的NIO框架投入到生产环境使用,等待框架稳定也要非常长的时间,而且一旦在生产环境出现问题,后果是非常严重的。

当然,Netty和Mina不仅仅用于游戏服务器开发。多个开源的跨进程通信(例如rpc)就使用它们作为网络通信基础。例如阿里鼎鼎大名的Dubbo框架。

个人感觉Mina更容易上手。这可能跟我先学Netty,对NIO框架有了一点皮毛认知有关(^_^)

本文选择介绍的通信框架为Mina。(github上的项目本身支持Mina/Netty自由切换)

mina服务端代码示例

一个简单的Mina服务端通信demo是非常简单的,主要代码无非就是以下几行:

1. 创建NioSocketAcceptor,用于监听客户端连接;

2. 指定通信编解码处理器;

3. 指定处理业务逻辑器,主要是接受消息之后的业务逻辑;

4. 指定监听端口,启动NioSocket服务;

主要代码如下:

	public void start() throws Exception {
		int serverPort = ServerConfig.getInstance().getServerPort();
		IoBuffer.setUseDirectBuffer(false);
		IoBuffer.setAllocator(new SimpleBufferAllocator());

		acceptor = new NioSocketAcceptor(pool);
		acceptor.setReuseAddress(true);
		acceptor.getSessionConfig().setAll(getSessionConfig());

		logger.info("mina socket server start at port:{},正在监听客户端的连接...", serverPort);
		DefaultIoFilterChainBuilder filterChain = acceptor.getFilterChain();
		filterChain.addLast("codec",
				new ProtocolCodecFilter(SerializerHelper.getInstance().getCodecFactory()));
		filterChain.addLast("moduleEntrance", new ModuleEntranceFilter());
		filterChain.addLast("msgTrace", new MessageTraceFilter());
		filterChain.addLast("flood", new FloodFilter());
		//指定业务逻辑处理器
		acceptor.setHandler(new ServerSocketIoHandler(new MessageDispatcher()));
		//设置端口号
		acceptor.setDefaultLocalAddress(new InetSocketAddress(serverPort));
		//启动监听
		acceptor.bind();
	}

其中ServerSocketIoHandler继承IoHandlerAdapter,负责处理链路的建立,摧毁,以及消息的接收。当收到消息之后,先不进行业务处理,暂时打印消息的内容。

public class ServerSocketIoHandler extends IoHandlerAdapter {
	
	@Override 
	public void sessionCreated(IoSession session) { 
		//显示客户端的ip和端口 
		System.out.println(session.getRemoteAddress().toString()); 
	} 
	
	@Override 
	public void messageReceived(IoSession session, Object data ) throws Exception 
	{ 
		Message message = (Message)data;
		System.out.println("收到消息-->" + message); 
		
	} 
} 

网关主要处理客户端的链接建立,以及消息的接受与响应。而具体通信协议栈的设计,则涉及到数据编解码问题了。

下面主要介绍消息序列化与反序列化库的选择,以及介绍Mina处理粘包拆包的解决方案。

消息编解码

私有协议栈定义

私有协议主要用于游戏项目内部客户端与服务端通信消息的格式定义。不同于http/tcp协议,私有协议只用于内部通信,所以不需要遵循公有协议标准。每个项目都使用自定义的通信协议,协议标准主要是开发方便,编解码速度快,通信字节量少等。

本文使用的消息定义如下:

  • 消息头
  • 消息体
消息头,包括一个int类型表示消息长度(4个字节),一个short类型表示消息所属的模块号(2个字节),一个short类型表示消息所属的子类型(2个字节)
消息体,主要是具体业务所包含的数据,不定长度,由Message类表示。
消息的元信息分为模块和子类型组合在一起的原因在于。
1. 游戏业务本来就是以模块化的结构进行构建,消息绑定在指定的模块,更符合模块化的思想。
2. 作者本人经历的很多游戏项目,只使用消息id进行类型申明。导致开发人员在开发新模块注册id的时候,往往是见缝插针,哪里找到空闲id就往哪里插入,管理起来非常乱。
Message类为所有消息的抽象父类,消息所属模块所属子类型等元信息由类注解提供,代码如下:
/**
 * 通信消息体定义
 */
public abstract class Message {

	/**
	 * messageMeta, module of message
	 * @return
	 */
	public short getModule() {
		MessageMeta annotation = getClass().getAnnotation(MessageMeta.class);
		if (annotation != null) {
			return annotation.module();
		}
		return 0;
	}

	/**
	 * messageMeta, subType of module
	 * @return
	 */
	public byte getCmd() {
		MessageMeta annotation = getClass().getAnnotation(MessageMeta.class);
		if (annotation != null) {
			return annotation.cmd();
		}
		return 0;
	}

	public String key() {
		return this.getModule() + "_" + this.getCmd();
	}

}
其中,MessageMeta类是一个注解,主要包含消息的元信息申明
@Documented
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface MessageMeta {
	
	/** 消息所属模块号 */
	short module();
	/** 消息所属子类型 */
	short cmd();

}

消息体序列化与反序列化

消息体序列化方案的选择,也是一个值得花大篇幅介绍的话题。
序列化方式对比
优点 缺点
jdk 官方血统

通信双方都是Java平台;

需要实现Serializable接口;

速度慢,体积大;

json 自说明,可读性高 数据量大,不利于传输
protobuf 压缩率高

手动编写.proto文件;

数据肉眼无法识别

protostuff 基于protobuff,不用写proto文件

JDK自带的序列化方式虽然简单,但速度慢,序列化后内容大,首先被排除。Goole的Protobuf自带光环,序列化速度快,数据小,理应被重用。但Protobuf有一个致命的缺点,就是需要手动编写.proto文件,这是一个扣分项。幸运的是,JProtobuf的出现,挽救了这种局面。通过JProtobuf注解,我们再也不用编写讨厌的.proto文件。

项目地址--> jprotobuf官网。(github项目提供了基于java反射的消息编解码,可支持使用者自由切换)

JProtobuf的编解码非常简单,对于一个我们定义的请求消息,ReqAccoutLogin类。为playerId,password两个字段引入@Protobuf注解。
@MessageMeta(module=Modules.LOGIN, cmd=LoginDataPool.REQ_LOGIN)
public class ReqAccountLogin extends Message {
	
	/** 账号流水号 */
	@Protobuf(order = 1)
	private long accountId;
	
	@Protobuf(order = 2)
	private String password;

	public long getAccountId() {
		return accountId;
	}

	public void setAccountId(long playerId) {
		this.accountId = playerId;
	}

	public String getPassword() {
		return password;
	}

	public void setPassword(String password) {
		this.password = password;
	}

	@Override
	public String toString() {
		return "ReqLoginMessage [accountId=" + accountId + ", password="
				+ password + "]";
	}
	
}
jprotobuf序列化与反序列化例子:
public class JProtobufTest {


	@Test
	public void testRequest() {
		ReqAccountLogin request = new ReqAccountLogin();
		request.setAccountId(123456L);
		request.setPassword("kingston");
		Codec simpleTypeCodec = ProtobufProxy
				.create(ReqAccountLogin.class);
		try {
			// 序列化
			byte[] bb = simpleTypeCodec.encode(request);
			// 反序列化
			ReqAccountLogin request2 = simpleTypeCodec.decode(bb);
			Assert.assertTrue(request2.getAccountId() == request.getAccountId());
			Assert.assertTrue(request2.getPassword().equals(request.getPassword()));
		} catch (IOException e) {
			e.printStackTrace();
		}
	}

}

编码器设计

编解器的设计需要对Nio常用的API有一定的了解,熟悉ByteBuffer的flip(),rewind(),clear()等方法。
从前面的消息协议格式可知,对于一个具体的消息对象,消息头需要一个int长度表示消息长度,由于具体消息的内容还没有拿到,可以先写入一个int长度的数据作为占位符,再依次写入消息short长度的moduleId,short长度的cmd等元信息。

编码器的完整代码如下:

public class MinaProtocolEncoder implements ProtocolEncoder {

	@Override
	public void dispose(IoSession arg0) throws Exception {

	}

	@Override
	public void encode(IoSession session, Object message, ProtocolEncoderOutput out) throws Exception {
		IoBuffer buffer = writeMessage((Message) message);
		out.write(buffer);
	}

	private IoBuffer writeMessage(Message message) {
		// ----------------消息协议格式-------------------------
		// packetLength | moduleId | cmd  | body
		// int             short     byte  byte[]

		IoBuffer buffer = IoBuffer.allocate(CodecContext.WRITE_CAPACITY);
		buffer.setAutoExpand(true);

		// 写入具体消息的内容
		IMessageEncoder msgEncoder = SerializerHelper.getInstance().getEncoder();
		byte[] body = msgEncoder.writeMessageBody(message);
		// 消息元信息常量3表示消息body前面的两个字段,一个short表示module,一个byte表示cmd,
		final int metaSize = 3;
		// 消息内容长度
		buffer.putInt(body.length + metaSize);
		short moduleId = message.getModule();
		byte cmd = message.getCmd();
		// 写入module类型
		buffer.putShort(moduleId);
		// 写入cmd类型
		buffer.put(cmd);
	
		buffer.put(body);
//		// 回到buff字节数组头部
		buffer.flip();

		return buffer;
	}

}
编码器代码比较简单,只要注意消息协议的格式,结合JProtobuf的编码即可。

解码器设计

理论上来说,解码器就是按编码器的协议格式定义,重新把消息读出来而已。但实际上解码器的设计比编码器来得复杂一些。我们知道,TCP是一个“流”协议,也就是说,消息与消息之间是没有分界线的。在业务上,一个完整的消息包可能被底层拆分成多个包进行发送;另一方面,多个小包也可能被封装成一个大的数据包进行发送。所以,我们需要解决TCP的粘包和拆包问题。
Mina解决粘包拆包的技巧
回顾我们的消息协议格式,消息头有一个int长度的数组表示消息的长度(不包括本身4个字节)。有了这个长度,无论粘包,还是拆包,我们都可以先从流中读到一个int字节表示消息长度(packetLength),再从剩下的流中取出长度为packetLength的字节数据。如果当前数据包的字节长度不够packetLength的长度,说明这个包还没有包含完整的消息,直接中断等待新的数据到来。如此,就读到一个完整的消息了。
完整的解码器代码如下
public class MinaProtocolDecoder extends CumulativeProtocolDecoder {

    private Logger logger = LoggerFactory.getLogger(MinaProtocolDecoder.class);

    @Override
    protected boolean doDecode(IoSession session, IoBuffer in, ProtocolDecoderOutput out) throws Exception {
        if (in.remaining() < 4) {
            return false;
        }
        IMessageDecoder msgDecoder = SerializerHelper.getInstance().getDecoder();
        in.mark();

        int length = in.getInt();
        int maxReceiveBytes = 4096;
        if (length > maxReceiveBytes) {
            logger.error("单包长度[{}]过大,断开链接", length);
            session.close(true);
            return true;
        }

        if (in.remaining() < length) {
            in.reset();
            return false;
        }

        // 消息元信息常量3表示消息body前面的两个字段,一个short表示module,一个byte表示cmd,
        final int metaSize = 3;
        short moduleId = in.getShort();
        byte cmd = in.get();
        byte[] body = new byte[length - metaSize];
        in.get(body);
        Message msg = msgDecoder.readMessage(moduleId, cmd, body);

        out.write(msg);
        return true;
    }

}
本文主要讲述Mina socket服务端的搭建以及消息数据的发送与接收,至于消息在业务上的流向如何,将在下一篇文章进行讲解。
文章预告:下一篇主要介绍消息的业务处理以及玩家数据推送。
java服务端开源框架系列完整的代码请移步github ->> jforgame

你可能感兴趣的:(手游服务端,从零开始搭建游戏服务器框架,手游,mina,游戏服务端,java,netty)