手游服务端框架之网关

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/littleschemer/article/details/73555975

网关介绍

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

在单一进程服务端架构里,网关跟游戏业务处理是在同一个进程里。为了提高通信吞吐量,一些服务端架构将网关作为一个独立进程。这种模式下,客户端请求全部由网关接收,再通过网关转发给服务端;另一方面,服务端下发的消息,也只能通过网关推送到客户端。由于只有客户端跟网关是一对一的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();
		}

	}

}
编码器的完整代码如下:
public class ProtobufEncoder implements ProtocolEncoder {

	private static Logger logger = LoggerFactory.getLogger(ProtobufEncoder.class);

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

	}

	@Override
	public void encode(IoSession session, Object message, ProtocolEncoderOutput out) throws Exception {
		CodecContext context = (CodecContext) session.getAttribute(SessionProperties.CODEC_CONTEXT);
		if (context == null) {
			context = new CodecContext();
			session.setAttribute(SessionProperties.CODEC_CONTEXT, 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 = wrapMessageBody(message);
		buffer.put(body);
		//回到buff字节数组头部
		buffer.flip();
		//消息元信息,两个short,共4个字节
		final int METE_SIZE = 4;
		//重新写入包体长度
		buffer.putInt(buffer.limit() - METE_SIZE);
		buffer.rewind();

		return buffer;
	}

	private byte[] wrapMessageBody(Message message) {
		//写入具体消息的内容
		byte[] body = null;
		Class<Message> msgClazz = (Class<Message>) message.getClass();
		try {
			Codec<Message> codec = ProtobufProxy.create(msgClazz);
			body = codec.encode(message);
		} catch (IOException e) {
			logger.error("", e);
		}
		return body;
	}

}

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

解码器设计

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

	private static Logger logger = LoggerFactory.getLogger(ProtobufDecoder.class);

	public void decode(IoSession session, IoBuffer in, ProtocolDecoderOutput out) throws Exception {
		//必须保证每一个数据包的字节缓存都和session绑定在一起,不然就读取不了上一次剩余的数据了
		CodecContext context = SessionManager.INSTANCE.getSessionAttr(session, SessionProperties.CODEC_CONTEXT, CodecContext.class);
		if (context == null) {
			context = new CodecContext();
			session.setAttribute(SessionProperties.CODEC_CONTEXT, context);
		}

		IoBuffer ioBuffer = context.getBuffer();
		ioBuffer.put(in);

		//在循环里迭代,以处理数据粘包
		for (; ;) {
			ioBuffer.flip();
			//消息元信息常量4表示消息body前面的两个short字段,一个表示moduel,一个表示cmd,
			final int METE_SIZE = 4;
			if (ioBuffer.remaining() < METE_SIZE) {
				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-METE_SIZE];
				ioBuffer.get(body);
				Message msg = readMessage(moduleId, cmd, body);

				if (moduleId > 0) {
					out.write(msg);
				} else { //属于组合包
					CombineMessage combineMessage = (CombineMessage)msg;
					List<Packet> packets = combineMessage.getPackets();
					for (Packet packet :packets) {
						//依次拆包反序列化为具体的Message
						out.write(Packet.asMessage(packet));
					}
				}
				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) {
			logger.error("读取消息出错,模块号{},类型{},异常{}", new Object[]{module, cmd ,e});
		}
		return null;
	}

	public void dispose(IoSession arg0) throws Exception {

	}

	public void finishDecode(IoSession arg0, ProtocolDecoderOutput arg1) throws Exception {

	}

}



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


猜你喜欢

转载自blog.csdn.net/littleschemer/article/details/73555975