手游服务端框架之网关

原创 2017年06月24日 14:17:39

网关介绍

游戏服务器的网关,主要是用于手机客户端与游戏业务服务端通信的中转器,负责接收来自手机客户端的请求协议,以及推送服务端的响应包。

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

本文讨论的为集成网关。

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

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

本文选择的通信框架为Mina。

mina服务端代码示例

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

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

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

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

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

主要代码如下:

	public void start() throws Exception {
		
		IoBuffer.setUseDirectBuffer(false);
		IoBuffer.setAllocator(new SimpleBufferAllocator());
		
		acceptor = new NioSocketAcceptor(pool);
		acceptor.setReuseAddress(true);
		acceptor.getSessionConfig().setAll(getSessionConfig());
		
		//暂时写死在代码里,后期使用独立配置文件
		int port = 9527;
		logger.info("socket启动端口为{},正在监听客户端的连接", port);
		DefaultIoFilterChainBuilder filterChain = acceptor.getFilterChain();
		filterChain.addLast("codec", new ProtocolCodecFilter(MessageCodecFactory.getInstance())); 
		acceptor.setHandler( new IOHandler() );//指定业务逻辑处理器 
		acceptor.setDefaultLocalAddress(new InetSocketAddress(port) );//设置端口号 
		acceptor.bind();//启动监听 
		
	}

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

package com.kingston.net;

import org.apache.mina.core.service.IoHandlerAdapter;
import org.apache.mina.core.session.IoSession;

public class IoHandler 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类表示
Message类为所有消息的抽象父类,消息所属模块所属子类型等元信息由类注解提供,代码如下:
package com.kingston.net;

import com.kingston.net.annotation.Protocol;

/**
 * 通信消息体定义
 */
public abstract class Message {
	
	public short getModule() {
		Protocol annotation = getClass().getAnnotation(Protocol.class);
		if (annotation != null) {
			return annotation.module();
		}
		return 0;
	}
	
	public short getCmd() {
		Protocol annotation = getClass().getAnnotation(Protocol.class);
		if (annotation != null) {
			return annotation.cmd();
		}
		return 0;
	}
	
	public String key() {
		return this.getModule() + "_" + this.getCmd();
	}

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

}


编码器设计

编解器的设计需要对Nio常用的API有一定的了解,熟悉ByteBuffer的flip(),rewind(),clear()等方法。
从前面的消息协议格式可知,对于一个具体的消息对象,消息头需要一个int长度表示消息长度,由于具体消息的内容还没有拿到,可以先写入一个int长度的数据作为占位符,再依次写入消息short长度的moduleId,short长度的cmd等元信息。
消息体序列化方案的选择,也是一个值得花大篇幅介绍的话题。JDK自带的序列化方式虽然简单,但速度慢,序列化后内容大,首先被排除。Goole的Protobuf自带光环,序列化速度快,数据小,理应被重用。但Protobuf有一个致命的缺点,就是需要手动编写.proto文件,这是一个扣分项。幸运的是,JProtobuf的出现,挽救了这种局面。通过JProtobuf注解,我们再也不用编写讨厌的.proto文件。项目地址-->jprotobuf官网
JProtobuf的编解码非常简单,对于一个我们定义的请求消息,ReqLoginMessage类的playerId,password两个字段带有MessageMeta注解。
/**
 * 请求-账号登录
 * @author kingston
 */
@MessageMeta(module=Modules.LOGIN, cmd=LoginDataPool.REQ_LOGIN)
public class ReqLoginMessage 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序列化与反序列化例子:
package com.kingston.test.net;

import java.io.IOException;

import junit.framework.Assert;

import org.junit.Test;

import com.baidu.bjf.remoting.protobuf.Codec;
import com.baidu.bjf.remoting.protobuf.ProtobufProxy;
import com.kingston.game.login.message.ReqLoginMessage;

public class TestJProtobuf {


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

	}

}
编码器的完整代码如下:
package com.kingston.net.codec;

import java.io.IOException;

import org.apache.mina.core.buffer.IoBuffer;
import org.apache.mina.core.session.IoSession;
import org.apache.mina.filter.codec.ProtocolEncoder;
import org.apache.mina.filter.codec.ProtocolEncoderOutput;

import com.baidu.bjf.remoting.protobuf.Codec;
import com.baidu.bjf.remoting.protobuf.ProtobufProxy;
import com.kingston.net.Message;
import com.kingston.net.MessageFactory;
import com.kingston.net.SessionProperties;

public class MessageEncoder implements ProtocolEncoder{

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

	@Override
	public void encode(IoSession session, Object message, ProtocolEncoderOutput out) throws Exception {
		_encode(session, message, out);
	}
	
	public void _encode(IoSession session, Object message, ProtocolEncoderOutput out) throws Exception {
		CodecContext context = (CodecContext) session.getAttribute(SessionProperties.CONTEXT_KEY);
		if (context == null) {
			context = new CodecContext();
			session.setAttribute(SessionProperties.CONTEXT_KEY, context);
		}
		IoBuffer buffer = writeMessage((Message) message);
		out.write(buffer);
	}
	
	private IoBuffer writeMessage(Message message) {
		//----------------消息协议格式-------------------------
		// packetLength | moduleId | cmd   |  body
		// int            short      short   byte[]
		
		IoBuffer buffer = IoBuffer.allocate(CodecContext.WRITE_CAPACITY);
		buffer.setAutoExpand(true);
		
		//消息内容长度,先占个坑
		buffer.putInt(0);
		short moduleId = message.getModule();
		short cmd = message.getCmd();
		//写入module类型
		buffer.putShort(moduleId);
		//写入cmd类型
		buffer.putShort(cmd);
		
		//写入具体消息的内容
		byte[] body = null;
		Class<Message> msgClazz = (Class<Message>) MessageFactory.INSTANCE.getMessage(moduleId, cmd);
		try {
			Codec<Message> codec = ProtobufProxy.create(msgClazz);
			body = codec.encode(message);
		} catch (IOException e) {
			e.printStackTrace();
			//logger
		}
		buffer.put(body);
		//回到buff字节数组头部
		buffer.flip();
		//重新写入包体长度
		buffer.putInt(buffer.limit()-4);
		buffer.rewind();
		
		return buffer;
	}

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

解码器设计

理论上来说,解码器就是按编码器的协议格式定义,重新把消息读出来而已。但实际上解码器的设计比编码器来得复杂一些。我们知道,TCP是一个“流”协议,也就是说,消息与消息之间是没有分界线的。在业务上,一个完整的消息包可能被底层拆分成多个包进行发送;另一方面,多个小包也可能被封装成一个大的数据包进行发送。所以,我们需要解决TCP的粘包和拆包问题。
Mina解决粘包拆包的技巧
回顾我们的消息协议格式,消息头有一个int长度的数组表示消息的长度(不包括本身4个字节)。有了这个长度,我们就可以先从流中读到一个int字节表示消息长度(packetLength),再从剩下的流中取出长度为packetLength的字节数据。如此,就读到一个完整的消息了。那这个数据包剩余字节怎么处理??剩余的字节里面可能包括多个小的消息,怎么把它们全部取完。把读取消息的逻辑放在一个循环里就可以搞定!而拆包就比较简单了,如果当前数据包的字节长度不够packetLength的长度,那么这个包就没有包含完整的消息。直接中断等待新的数据到来。
完整的解码器代码如下
package com.kingston.net.codec;

import java.io.IOException;

import org.apache.mina.core.buffer.IoBuffer;
import org.apache.mina.core.session.IoSession;
import org.apache.mina.filter.codec.ProtocolDecoder;
import org.apache.mina.filter.codec.ProtocolDecoderOutput;

import com.baidu.bjf.remoting.protobuf.Codec;
import com.baidu.bjf.remoting.protobuf.ProtobufProxy;
import com.kingston.game.login.message.ReqLoginMessage;
import com.kingston.net.Message;
import com.kingston.net.MessageFactory;
import com.kingston.net.SessionProperties;

public class MessageDecoder implements ProtocolDecoder{

	public void decode(IoSession session, IoBuffer in, ProtocolDecoderOutput out) throws Exception {
		_decode(session, in, out);

	}

	private void _decode(IoSession session, IoBuffer in, ProtocolDecoderOutput out) {
		//必须保证每一个数据包的字节缓存都和session绑定在一起,不然就读取不了上一次剩余的数据了
		CodecContext context = (CodecContext) session.getAttribute(SessionProperties.CONTEXT_KEY);
		if (context == null) {
			context = new CodecContext();
			session.setAttribute(SessionProperties.CONTEXT_KEY, context);
		}
		IoBuffer ioBuffer = context.getBuffer();
		ioBuffer.put(in);

		//在循环里迭代,以处理数据粘包
		for (; ;) {
			ioBuffer.flip();
			//常量4表示消息body前面的两个short字段,一个表示moduel,一个表示cmd,
			//一个short字段有两个字节,总共4个字节
			if (ioBuffer.remaining() < 4) {
				ioBuffer.compact();
				return;
			}
			//----------------消息协议格式-------------------------
			// packetLength | moduleId | cmd   |  body
			// int            short      short   byte[]
			int length = ioBuffer.getInt();
			int packLen = length + 4;
			//大于消息body长度,说明至少有一条完整的message消息
			if (ioBuffer.remaining() >= length) {
				short moduleId =  ioBuffer.getShort();
				short cmd = ioBuffer.getShort();
				byte[] body = new byte[length-4];
				ioBuffer.get(body);

				Message msg = readMessage(moduleId, cmd, body);
				out.write(msg);

				if (ioBuffer.remaining() == 0) {
					ioBuffer.clear();
					break;
				}
				ioBuffer.compact();
			} else{
				//数据包不完整,继续等待数据到达
				ioBuffer.rewind();
				ioBuffer.compact();
				break;
			}
		}
	}

	private Message readMessage(short module, short cmd, byte[] body) {
		Class<?> msgClazz = MessageFactory.INSTANCE.getMessage(module, cmd);
		try {
			Codec<?> codec = ProtobufProxy.create(msgClazz);
			Message message = (Message) codec.decode(body);

			return message;
		} catch (IOException e) {
			e.printStackTrace();
		}
		return null;
	}

	public void dispose(IoSession arg0) throws Exception {
		// TODO Auto-generated method stub

	}

	public void finishDecode(IoSession arg0, ProtocolDecoderOutput arg1) throws Exception {
		// TODO Auto-generated method stub

	}

}

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


版权声明:本文为博主原创文章,未经博主允许不得转载。

手游服务端框架之使用事件驱动模型解决业务高耦合

游戏里经常有这样的业务,当玩家触发某个动作时,有若干与之关联的业务也要一起执行。诸如这样场景,我们选择引入事件驱动模型来帮助我们解决业务代码耦合的问题。本文实现一个工具库,来解决监听器与事件多对多的关...

手游服务端框架之消息线程模型

玩家的消息请求如果放在mina的io线程池进行处理,当业务处理非常耗时,会严重影响io的吞吐量。所以,我们应该另起用于处理业务逻辑的线程池,采用生产者消费者模型,异步处理玩家请求。...

手游服务端框架之模仿SpringMvc处理玩家请求

经典web开发项目通常采用三层架构来组织代码。典型的,第一层为表现层,通常使用MVC模式;第二层为业务逻辑层,该层主要是各种service业务操作类;第三层则为数据访问层。类似的,我们的游戏项目也可以...

手游服务端框架之配置与玩家数据库设计

一款网络游戏的设计,至少需要策划数据库和用户数据库两种数据库。本文主要介绍这两种数据库的设计及使用,同时,介绍如何通过ORM框架来完成玩家数据的持久化。...

手游服务端框架之使用Guava构建缓存系统

缓存,在项目中的应用非常之广泛。诸如这样的场景,某些对象计算或者获取的代码比较昂贵,并且在程序里你不止一次要用到这些对象,那么,你就应该使用缓存。缓存,在项目中的应用非常之广泛。诸如这样的场景,某些对...

python手游服务端搭建

最近在研究网游了,由于无意中在群里看到了9秒社区发布的源代码链接,

高效率完成一次接入80个手游渠道SDK——游戏接入SDK服务端篇

.1 概要     通常,游戏开发商并不会只在一个渠道上线他们的游戏,接入越多的渠道,代表着可能获取越多的用户,但同时也代表着越多的接入SDK工作量、工期和费用。一款游戏要有足够的用户,甚至需要接入3...

服务端开发笔记二:基于pomelo的分布式手游架构

经过了一个周的时间,新项目的基本结构已经大体确定,拿来给大家分享。 一 基础概念 在说明手游服务器架构之前,一下几点须知。 1 我们的服务器集群分为前端服务器和后端服务器。 前端服务器:负责接收前...

手游服务端开发基础概念扫盲篇

从事手游服务端开发也快3年了,整理了一份资料,介绍在开发过程中碰到的概念以及自己的理解,希望能够帮到即将从事该职业的朋友。...
内容举报
返回顶部
收藏助手
不良信息举报
您举报文章:手游服务端框架之网关
举报原因:
原因补充:

(最多只允许输入30个字)