最近在读《Netty实战》,便使用netty搭建了一套rts游戏的服务器框架!
服务器分为三个部分:
(1)gateServer网关服务器:顾名思义网关服务器负责协议的接收与分发
(2)lobbyServer大厅服务器:大厅服务器处理用户的登录,注册,创建房间,查看战绩等逻辑
(3)battleServer战斗服务器:负责处理房间内对战的游戏逻辑,使用单例模式,帧同步的方法实现同一房间内不同玩家之间的一致性
在这里简单介绍一下帧同步:
(1)客户端每隔一段时间采集用户的所有指令发送给服务端
(2)服务器每隔一段时间将当前所有的游戏数据推送给客户端
(3)客户端每次用户做出指令的时候先做出预处理,当收到服务端的指令后进行同步
(4)无论客户端还是服务端发送协议的时间间隔都是毫秒级,每秒至少发送20-30次,这样就能基本保证不同客户端之间的同步,就想帧动画一样,我们服务端发送的同步协议也可以理解为一次为一帧
服务器使用到的技术与框架:
使用maven 模块式管理(使项目目录看起来一目了然,利用好maven的继承关系,可以避免重复写很多pom文件中的标签),netty框架(应该是最好用的java通信框架吧),redis
下面上干货:
public class LobbySever { //基础配置信息 //log日志 private static final Logger logger = LoggerFactory.getLogger(LobbySever.class); //服务器IP(可配置到配置文件) private static final String IP = "127.0.0.1"; //端口号(可配置到配置文件中) private static final int port = 8088; //分配用于处理业务的线程组数量 Runtime.getRuntime().availableProcessors()获取jvm可用的线程数 protected static final int BisGroupSize = Runtime.getRuntime().availableProcessors() * 2; //每个线程组中线程的数量 protected static final int worGroupSize = 4; //NioEventLoopGroup进行事件处理,如接收新连接以及数据处理 private static final EventLoopGroup bossGruop = new NioEventLoopGroup(BisGroupSize); private static final EventLoopGroup workerGroup = new NioEventLoopGroup(worGroupSize); protected static void run() throws Exception{ //serverBootstrap 服务端引导 ServerBootstrap bootStrap = new ServerBootstrap(); bootStrap.group(bossGruop, workerGroup); //指定所使用的 channel 有nio oio linux有epoll(性能比nio强大的异步非阻塞) bootStrap.channel(NioServerSocketChannel.class); bootStrap.childHandler(new ChannelInitializer<SocketChannel>(){ @Override protected void initChannel(SocketChannel ch) throws Exception { //ChannelPipeline链 将所有的业务逻辑层连接到一起 ChannelPipeline pipeline = ch.pipeline(); pipeline.addLast(new RtsEncoder()); pipeline.addLast(new RtsDecoder()); //pipeline.addLast("logging",new LoggingHandler(LogLevel.WARN)); //注册HeartBeatReqHandler pipeline.addLast(new HeartBeatReqHandler()); //注册LoginHandler 多个channelHandler执行顺序为注册顺序 pipeline.addLast(new LoginHandler()); } //ChannelOption设置tcp缓冲区的大小 }).option(ChannelOption.SO_BACKLOG, 1024) //通过NODELY禁用Nagle,使消息立即发出,不用等到一点的数量才发出 .option(ChannelOption.TCP_NODELAY, true) //保持长连接 .childOption(ChannelOption.SO_KEEPALIVE,true); logger.info("LobbySever 启动TCP长连接完成!"); //sync导致当前Thread 阻塞,一直到绑定操作完成为止 bind方法绑定服务器 ChannelFuture f = bootStrap.bind(IP,port).sync(); f.channel().closeFuture().sync(); logger.info("LobbySever Socket服务器已启动完成!"); //closeFuture会一直阻塞到channel关闭 然后调用shutdown shutdown(); } protected static void shutdown(){ //关闭eventLoopGroup释放所有资源 bossGruop.shutdownGracefully(); workerGroup.shutdownGracefully(); } public static void main(String[] args) throws Exception{ logger.info("开始启动LobbySever服务器..."); ApplicationContext context = new ClassPathXmlApplicationContext("applicationContext.xml"); logger.info("装载spring容器成功"); LobbyManager lobbyManager = LobbyManager.getInstance(); lobbyManager.init(); logger.info("初始化管理器成功"); RedisUtil.getInstance(); run(); } }
上面的每一行注释都很详细,lobbyManager使用单例模式管理玩家和连接,RtsEncode和RtsDecode重新定义了编码,redisUtil是对redis的简单管理
public class LobbyManager { /** * 协议管理器 */ public static Map<Integer,Processors> protocolManager; /** * 客户端连接管理器 */ public static Map<String, SocketChannel> gateManager; /** * GameSession管理器 */ public static Map<String,LobbyGameSession> sessionManager; /** * 玩家管理器 */ public static Map<String,Player> playerManager; public void init(){ protocolManager = LobbyProtocol.toMap(); gateManager = new HashMap<String,SocketChannel>(); sessionManager = new HashMap<String,LobbyGameSession>(); playerManager = new HashMap<String,Player>(); System.out.println("初始化管理器成功!"); } private LobbyManager(){ } private static LobbyManager lobbyManager = null; /** * 单例模式 * @return */ public static synchronized LobbyManager getInstance(){ if(lobbyManager == null){ lobbyManager = new LobbyManager(); } return lobbyManager; } /** * 将新获取的连接增加到gateManager中 * @param sessionId * @param socketChannel */ public synchronized void addChannel(String sessionId,SocketChannel socketChannel){ //直接put 如果已经存在相当于其他地方登录,直接覆盖 if(socketChannel != null){ gateManager.put(sessionId, socketChannel); } } /** * 根据sessionId获取SocketChannel * @param sessionId */ public SocketChannel getChannel(String sessionId){ if(StringUtils.isEmpty(sessionId)){ return null; } return gateManager.get(sessionId); } /** * 新增gameSession * 如果已经存在了 很可能存在同一个ip用户又进行了其他账号的登录,这时直接覆盖 * @param sessionId * @param gameSession */ public synchronized void addSession(String sessionId,LobbyGameSession gameSession){ if(gameSession != null){ sessionManager.put(sessionId,gameSession); } } /** * 根据sessionId获取session * @param sessionId * @return */ public LobbyGameSession getSession(String sessionId){ if(StringUtils.isEmpty(sessionId)){ return null; } return sessionManager.get(sessionId); } /** * 玩家离线,将玩家从gateManager中删掉 * @param uuid */ public synchronized void removeChannel(String sessionId){ gateManager.remove(sessionId); //同时 删除掉玩家的session信息 sessionManager.remove(sessionId); } /** * 新增用户到玩家管理器 * @param uuid * @param player */ public synchronized void addPlayer(String uuid,Player player){ if(player != null){ playerManager.put(uuid,player); } } /** * 根据用户Id从用户管理器中获取用户 * @param uuid * @return */ public Player getPlayer(String uuid){ if(StringUtils.isEmpty(uuid)){ return null; } return playerManager.get(uuid); } /** * 从玩家管理器中删除用户 * @param uuid */ public synchronized void removePlayer(String uuid){ playerManager.remove(uuid); } }
protocolManager是协议管理器,在lobbyserver启动的时候将所有的协议装载到协议管理器中,一会儿在下文中我们将详细介绍协议的分发
gateManager是连接管理器,在channelHandler的active方法中,每次客户端连接成功后都会回调到active 方法中,即使有多个channelHandler但是同一个tcp长连接,只要连接不断开就只会调用一次active方法,上文中提到过执行channelhandler的顺序为handler的注册顺序
重写encode和decode:
Encode
public class RtsEncoder extends MessageToByteEncoder<RtsProtocal>{ @Override protected void encode(ChannelHandlerContext ctx, RtsProtocal msg, ByteBuf out) throws Exception { //写入消息SmartCar的具体内容 //1.写入消息的开头的信息标志 out.writeInt(msg.getHead_data()); //2.写入协议类型 out.writeByte(msg.getType()); //3.写入协议号 out.writeInt(msg.getProtocloNumber()); //4.写入消息的长度(此长度不包含,head_data 和 contentLength所占的字节) out.writeInt(msg.getContentLength()); //5.写入消息的内容 out.writeBytes(msg.getContent()); } }Decode:
/** * 自定义解码器 * @author miracle * */ public class RtsDecoder extends ByteToMessageDecoder{ /** * 消息头,协议开始的标志 head_data ,int类型,占4个字节 * 数据的长度contentLength,int类型,占4个字节 * 数据的类型type,byte类型,占1个字节 */ public final int BASE_LENGTH = 4+4+1; @Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception { //可读长度必须大于基本长度 if(in.readableBytes() >= BASE_LENGTH){ //防止socket字节流攻击 //防止客户端传过来的数据过大,因为太大的数据,是不合理的 if(in.readableBytes() > 2048){ in.skipBytes(in.readableBytes()); } //记录包头开始的index int beginReader; while(true){ //获取包头开始的index beginReader = in.readerIndex(); //标记包头开始的index in.markReaderIndex(); //读到了协议开始的标志,结束while循环 if(in.readInt() == ConstantValue.HEAD_DATA){ break; } //如果未读到包头略过一个字节去读包头,去读取是否是消息的开头 in.resetReaderIndex(); in.readByte(); //如果这时候包的长度不足 最低要求,return if(in.readableBytes() < BASE_LENGTH){ return; } } //获取协议类型 byte type = in.readByte(); //如果type == 2表示是心跳协议,心跳协议没有协议号和协议体 if(type == 2){ SmartCarProtocal protocal = new SmartCarProtocal(type,0,0,new byte[]{}); out.add(protocal); return; } //协议号 int protocloNumber = in.readInt(); //消息的长度 int length = in.readInt(); //判断请求数据包数据是否到齐 if(in.readableBytes() < length){ //消息未到齐,还远读指针 in.readerIndex(beginReader); return; } //读取data数据 byte[] data = new byte[length]; in.readBytes(data); SmartCarProtocal protocal = new SmartCarProtocal(type,protocloNumber,data.length,data); out.add(protocal); } } }RtsProtocol:
/** * 数据包格式 * @author miracle * */ public class RtsProtocal { /** * 消息头,消息开始的标志 */ private int head_data = ConstantValue.HEAD_DATA; /** * 协议类型 * 1 登录相关协议(连接服务器检测,登录,注册...) * 2 心跳协议 * 3 大厅内协议(查看战绩,排行,查找,添加好友等...) * 4 游戏(创建房间,解散房间等) */ private byte type; /** * 协议号 */ private int protocloNumber; /** * 消息的长度 */ private int contentLength; /** * 消息的内容 */ private byte[] content; public int getHead_data() { return head_data; } public void setHead_data(int head_data) { this.head_data = head_data; } public byte getType() { return type; } public void setType(byte type) { this.type = type; } public int getProtocloNumber() { return protocloNumber; } public void setProtocloNumber(int protocloNumber) { this.protocloNumber = protocloNumber; } public int getContentLength() { return contentLength; } public void setContentLength(int contentLength) { this.contentLength = contentLength; } public byte[] getContent() { return content; } public void setContent(byte[] content) { this.content = content; } /** * 用于初始化,SmartCarProtocal * @param contentLength * 消息长度 * @param content * 消息内容 */ public RtsProtocal(byte type,int protocloNumber,int contentLength,byte[] content){ this.type = type; this.protocloNumber = protocloNumber; this.contentLength = contentLength; this.content = content; } public RtsProtocal(byte type,MsgRsponse rsp){ this.type = type; this.protocloNumber = rsp.toClassName(); this.content = JSONObject.toJSONString(rsp).getBytes(); this.contentLength = content.length; } @Override public String toString() { return "SmartCarProtocol [head_data=" + head_data + ", type=" + type + ",protocloNumber=" + protocloNumber +",contentLength=" + contentLength + ", content=" + Arrays.toString(content) + "]"; } }编码和解码方式比较简单,值得注意的是netty中使用的是byteBuf想比较jdk中的byteBuffer性能要好很多
netty中的核心类是重写的handler,重点看一下loginHandler
public class LoginHandler extends ChannelHandlerAdapter{ public static final Logger logger = LoggerFactory.getLogger(LoginHandler.class); @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { logger.error(ctx.channel().remoteAddress()+" 错误关闭"); cause.printStackTrace(); ctx.close(); } /** * 用于获取客户端发送的消息 */ @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { //用于获取客户端发送的消息 RtsProtocal body = (RtsProtocal)msg; logger.info("LobbyServer接受的客户端的信息:"+body.toString()); //如果是登录相关协议 if(body.getType() == 1){ logger.info("是登录相关的协议!"); LobbyManager lobbyManager = LobbyManager.getInstance(); //根据sessionId 获取gameSession LobbyGameSession gameSession = lobbyManager.sessionManager.get(ctx.channel().id().asLongText()); if(gameSession == null){ gameSession = new LobbyGameSession(); //设置ip gameSession.setIP(((InetSocketAddress)ctx.channel().remoteAddress()).getAddress() .getHostAddress()); //设置sessionId gameSession.setSessionId(ctx.channel().id().asLongText()); lobbyManager.addSession(ctx.channel().id().asLongText(), gameSession); } //根据协议号调用业务 Processors processors = lobbyManager.protocolManager.get(body.getProtocloNumber()); processors.setGameSession(gameSession); processors.setJson(new String(body.getContent())); //执行具体的业务逻辑 processors.process(); }else{ //通知下一个channelHandler执行 ctx.fireChannelRead(msg); } } }
每次收到客户端的协议后会先通过预设好的编码器将byteBuf解析为我们要的类RtsProtocol,然后调用channelRead方法,ctx.fireChannelRead(msg)这个方法是通知下一个channelRead来执行read方法,当收到客户端的协议后,根据协议号找到protocolManager中对应的协议,protocolManager中的key是协议号,value是processors对应的子类,将客户端传过来的参数json赋值给processors后,每个子类重写了父类的process()方法,在这里调用这个方法就可以进入对应协议的业务逻辑
在heartBeatHandler中调用的active方法如下:
/** * 多个active同时存在的时候 * 根据handler注册的先后顺序active只在第一次 */ @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { String sessionId = ctx.channel().id().asLongText(); LobbyManager lobbyManger = LobbyManager.getInstance(); lobbyManger.addChannel(sessionId, (SocketChannel)ctx.channel()); logger.info("HeartBeatReq active...1"); }
项目还在开发中,后期会不定时更新,笔者水平有限,如有错误,请及时指出!