从零开始搭建游戏服务器 第六节 合理使用自定义注解+反射 简化开发流程

本文主要是介绍从零开始搭建游戏服务器 第六节 合理使用自定义注解+反射 简化开发流程,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

自定义注解

  • 前言
  • 正文
    • 创建注解
    • 创建类扫描工具
    • 创建ProtoDispatcher类
    • 初始化Dispatcher
    • 协议的逻辑分发dispatcher
    • 使用注解标记方法
    • 测试
  • 结语

前言

在前面几节我们将Login服的大体架构搭建了起来,
具体流程是这样的:

  1. 客户端上传protobuf协议到LoginServer
  2. LoginServer的NettyServer接收数据将数据发送到ConnectActor
  3. ConnectActor根据协议号,对不同的协议使用不同的Protobuf类解包,然后调用不同的方法。

当我们收到不同的协议号,我们添加了不同的if判断条件来反序列化协议,再根据不同的协议号调用不同的方法。
当我们的业务逻辑越发复杂,协议越来越多,就会导致if分支变多,不用很多时间,这个类就会变得又臭又长,且多人开发时会有代码提交冲突的问题。
为了解决这个问题,我们需要有分而治之的思想。使用自定义注解+反射,可以将这部分工作变得简单且无脑。

正文

本节,我们的目标是创建一个协议分发类,里面存放一张映射表,将协议号与对应的方法记录在里面。
当收到一条协议,便根据协议号找到对应的Method。
再根据Method,获取第二个参数的类型(我们默认第一个参数为玩家数据,第二个参数为客户端上行的protobuf数据)。获得参数类型就可以使用protobuf进行反序列化。
最后通过反射的方式进行方法调用。

接下来看笔者一步步实现。

创建注解

在common下添加dispatch包,创建CMD注解

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface CMD {// 协议号 ProtoEnumMsg.CMD.IDint value();
}

RetentionPolicy.RUNTIME 表示运行时也需要用到该注解,我们会在代码中扫描使用了该注解描述的方法。
ElementType.METHOD 表示它用于描述方法。
int value(); 用于存放协议号,起名叫value方便我们后面写注解时可以不用写属性名。

创建类扫描工具

为了扫描出使用该注解描述的方法,我们需要扫描所有的类。
在utils目录下创建ClassScannerUtil

/*** 类扫描工具*/
public class ClassScannerUtils {public static Set<Class<?>> getClasses(String packageName) throws IOException, URISyntaxException, ClassNotFoundException {ClassLoader classLoader = Thread.currentThread().getContextClassLoader();assert classLoader != null;String path = packageName.replace('.', '/');Enumeration<URL> resources = classLoader.getResources(path);List<File> directories = new ArrayList<>();while (resources.hasMoreElements()) {URL resource = resources.nextElement();directories.add(new File(resource.toURI()));}Set<Class<?>> classes = new HashSet<>();for (File directory : directories) {classes.addAll(findClasses(directory, packageName));}return classes;}private static List<Class<?>> findClasses(File directory, String packageName) throws ClassNotFoundException {List<Class<?>> classes = new ArrayList<>();if (!directory.exists()) {return classes;}File[] files = directory.listFiles();if (files == null) {return classes;}for (File file : files) {if (file.isDirectory()) {assert !file.getName().contains(".");classes.addAll(findClasses(file, packageName + "." + file.getName()));} else if (file.getName().endsWith(".class")) {classes.add(Class.forName(packageName + '.' + file.getName().substring(0, file.getName().length() - 6)));}}return classes;}}

逻辑比较简单,传入一个包名,遍历获取该目录下的所有.class结尾的文件。

创建ProtoDispatcher类

@Slf4j
@Component
public class ProtoDispatcher {private final Map<Integer, ProtoWorker> workerMap = new HashMap<>();/*** 载入分发数据*/public void load(Set<Class<?>> classes) throws NoSuchMethodException {for (Class<?> clz : classes) {if (clz.getSuperclass() != BaseProtoHandler.class) {continue;}Object protoHandler = SpringUtils.getBean(clz);Method[] methods = clz.getDeclaredMethods();for (Method method : methods) {CMD annotation = method.getAnnotation(CMD.class);if (annotation == null) {continue;}int cmdId = annotation.value();if (workerMap.containsKey(cmdId)) {// 出现重复cmdIdString err = "cmdId " + cmdId + " is duplicate.";throw new RuntimeException(err);}workerMap.put(cmdId, new ProtoWorker(cmdId, protoHandler, method));}}}/*** 分发协议* @param cmdId 协议号* @param data  协议内容* @param obj   玩家数据* @return 要返回给客户端的Pack*/public Pack dispatch(int cmdId, byte[] data, Object obj) throws InvocationTargetException, IllegalAccessException {ProtoWorker protoWorker = workerMap.get(cmdId);if (protoWorker == null) {log.warn("not find proto worker. cmdId={}", cmdId);return null;}long startTime = System.currentTimeMillis();GeneratedMessageV3 protoMsg = (GeneratedMessageV3) protoWorker.getProtobufDecode().invoke(null, data);Pack pack = (Pack) protoWorker.getMethod().invoke(protoWorker.getHandler(), obj, protoMsg);long usedTime = System.currentTimeMillis() - startTime;if (usedTime > 1000L) { // 协议处理太久log.warn("proto worker slowly. cmdId = {}, used = {}", cmdId,usedTime);}return pack;}}

load方法传入我们扫描出来的类,筛选出继承于BaseProtoHandler的类,它会将每个类中使用@CMD注解描述的方法提取出来存入workerMap中。

BaseProtoHandler是个abstract类,他里面没有任何逻辑,用于管理所有协议接受处理类。

package org.common.handler;
/*** 协议处理基类*/
public abstract class BaseProtoHandler {
}

当有协议进入,调用dispatch,会自动将byte[] data按照对应处理方法的第二个参数类型进行反序列化。具体看worker代码:


/*** 协议处理方法*/
public class ProtoWorker {// 协议idprivate final int cmdId;// 协议处理类的对象private final Object handler;// 协议处理的方法private final Method method;// protobuf解析方法private final Method protobufDecode;public ProtoWorker(int cmdId, Object handler, Method method) throws NoSuchMethodException {this.cmdId = cmdId;this.handler = handler;this.method = method;Class<?> parameterType = method.getParameterTypes()[1];this.protobufDecode = parameterType.getMethod("parseFrom", byte[].class);}public int getCmdId() {return cmdId;}public Object getHandler() {return handler;}public Method getMethod() {return method;}public Method getProtobufDecode() {return protobufDecode;}
}

由于我们确定方法的第二个参数一定是Protobuf协议数据,而Protobuf生成的类中自带有parseFrom的方法,可以将byte数组反序列化成Protobuf数据对象,我们就可以使用反射的方式自动反序列化。

这一波是结合了项目开发规范的代码优化。

初始化Dispatcher

修改LoginMain的initServer,启动服务时搜索项目目录下的所有类,并传入ProtoDispatcher进行初始化。

@Overrideprotected void initServer() {...// 协议转发器初始化Set<Class<?>> classes;try {classes = ClassScannerUtils.getClasses("org.login");ProtoDispatcher protoDispatcher = SpringUtils.getBean(ProtoDispatcher.class);protoDispatcher.load(classes);} catch (IOException | URISyntaxException | ClassNotFoundException | NoSuchMethodException e) {throw new RuntimeException(e);}log.info("LoginServer start!");}

协议的逻辑分发dispatcher

修改ConnectActor,移除注册登陆的ifelse分支,改为使用ProtoDispatcher进行协议分发。

    /*** 客户端上行数据*/private Behavior<BaseMsg> onClientUpMsg(ClientUpMsg msg) throws InvocationTargetException, IllegalAccessException {Pack decode = PackCodec.decode(msg.getData());log.info("receive client up msg. cmdId = {}", decode.getCmdId());byte[] data = decode.getData();ProtoDispatcher dispatcher = SpringUtils.getBean(ProtoDispatcher.class);Pack pack = dispatcher.dispatch(decode.getCmdId(), data, this);if (pack != null) {this.ctx.writeAndFlush(PackCodec.encode(pack));}return this;}

使用注解标记方法

我们修改LoginProtoHandler类,使其继承于BaseProtoHandler。
并且将注册登录两个方法使用@CMD注解标记。

/**1. Player相关协议处理*/
@Slf4j
@Component
public class LoginProtoHandler extends BaseProtoHandler {@CMD(ProtoEnumMsg.CMD.ID.PLAYER_REGISTER_VALUE)public Pack onPlayerRegisterMsg(ConnectActor actor, PlayerMsg.C2SPlayerRegister up) {log.info("player register, accountName = {}, password = {}", up.getAccountName(), up.getPassword());...PlayerMsg.S2CPlayerRegister.Builder builder = PlayerMsg.S2CPlayerRegister.newBuilder();...return new Pack(ProtoEnumMsg.CMD.ID.PLAYER_REGISTER_VALUE, builder.build().toByteArray());}@CMD(ProtoEnumMsg.CMD.ID.PLAYER_LOGIN_VALUE)public Pack onPlayerLoginMsg(ConnectActor actor, PlayerMsg.C2SPlayerLogin up) {...PlayerMsg.S2CPlayerLogin.Builder builder = PlayerMsg.S2CPlayerLogin.newBuilder();...return new Pack(ProtoEnumMsg.CMD.ID.PLAYER_LOGIN_VALUE, builder.build().toByteArray());}
}

两个细节:

  1. 使用@Component注解标记类:因为我们的dispatcher通过Spring获取handler的单例对象,并通过该对象进行方法调用,因此使用@Component将其生命周期托管给Spring。
  2. @CMD(ProtoEnumMsg.CMD.ID.xx):因为我们对CMD的参数命名为value,因此使用注解不需要带入参数名,如@CMD(value = ProtoEnumMsg.CMD.ID.xx).
  3. 回参改为回Pack,由ConnectActor进行消息回传。

基于这几点,我们将所有的业务逻辑独立在了ProtoHandler中,后续业务开发不再需要考虑如何反序列化,如何回传消息,如何将协议号与方法映射。

测试

启动LoginServer,启动Client,Client控制台输入login_test1_123456
可以看到登录服输出了登录协议相关日志。

结语

本节笔者使用自定义注解+反射,解决了开发新协议时需要添加if…else…分支的问题,同时也使得业务开发人员可以更加专注于业务逻辑开发,减少其开发新协议需要修改的文件数量,在多人协同时是非常有益且高效的。
但是这也带来了问题,使用@CMD注解的方法,其传参的规则就定下来,参数0为玩家数据,参数1为protobuf数据,而这个规则需要由开发人员口口相传或者整理一份新员工开发文档中作为项目开发规范。若是不熟悉代码且经验不足的开发人员,可能会在传参上犯下错误。

但是总的来说,这么做还是利大于弊的,未来我们进行游戏逻辑服的开发,会涉及大量的协议交互,使用dispatcher可以很大程度上节约我们的时间,提高我们的效率。

这篇关于从零开始搭建游戏服务器 第六节 合理使用自定义注解+反射 简化开发流程的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/840053

相关文章

解决Maven项目idea找不到本地仓库jar包问题以及使用mvn install:install-file

《解决Maven项目idea找不到本地仓库jar包问题以及使用mvninstall:install-file》:本文主要介绍解决Maven项目idea找不到本地仓库jar包问题以及使用mvnin... 目录Maven项目idea找不到本地仓库jar包以及使用mvn install:install-file基

Java中的@SneakyThrows注解用法详解

《Java中的@SneakyThrows注解用法详解》:本文主要介绍Java中的@SneakyThrows注解用法的相关资料,Lombok的@SneakyThrows注解简化了Java方法中的异常... 目录前言一、@SneakyThrows 简介1.1 什么是 Lombok?二、@SneakyThrows

Python使用getopt处理命令行参数示例解析(最佳实践)

《Python使用getopt处理命令行参数示例解析(最佳实践)》getopt模块是Python标准库中一个简单但强大的命令行参数处理工具,它特别适合那些需要快速实现基本命令行参数解析的场景,或者需要... 目录为什么需要处理命令行参数?getopt模块基础实际应用示例与其他参数处理方式的比较常见问http

C 语言中enum枚举的定义和使用小结

《C语言中enum枚举的定义和使用小结》在C语言里,enum(枚举)是一种用户自定义的数据类型,它能够让你创建一组具名的整数常量,下面我会从定义、使用、特性等方面详细介绍enum,感兴趣的朋友一起看... 目录1、引言2、基本定义3、定义枚举变量4、自定义枚举常量的值5、枚举与switch语句结合使用6、枚

使用Python从PPT文档中提取图片和图片信息(如坐标、宽度和高度等)

《使用Python从PPT文档中提取图片和图片信息(如坐标、宽度和高度等)》PPT是一种高效的信息展示工具,广泛应用于教育、商务和设计等多个领域,PPT文档中常常包含丰富的图片内容,这些图片不仅提升了... 目录一、引言二、环境与工具三、python 提取PPT背景图片3.1 提取幻灯片背景图片3.2 提取

C++如何通过Qt反射机制实现数据类序列化

《C++如何通过Qt反射机制实现数据类序列化》在C++工程中经常需要使用数据类,并对数据类进行存储、打印、调试等操作,所以本文就来聊聊C++如何通过Qt反射机制实现数据类序列化吧... 目录设计预期设计思路代码实现使用方法在 C++ 工程中经常需要使用数据类,并对数据类进行存储、打印、调试等操作。由于数据类

使用Python实现图像LBP特征提取的操作方法

《使用Python实现图像LBP特征提取的操作方法》LBP特征叫做局部二值模式,常用于纹理特征提取,并在纹理分类中具有较强的区分能力,本文给大家介绍了如何使用Python实现图像LBP特征提取的操作方... 目录一、LBP特征介绍二、LBP特征描述三、一些改进版本的LBP1.圆形LBP算子2.旋转不变的LB

Maven的使用和配置国内源的保姆级教程

《Maven的使用和配置国内源的保姆级教程》Maven是⼀个项目管理工具,基于POM(ProjectObjectModel,项目对象模型)的概念,Maven可以通过一小段描述信息来管理项目的构建,报告... 目录1. 什么是Maven?2.创建⼀个Maven项目3.Maven 核心功能4.使用Maven H

Python中__init__方法使用的深度解析

《Python中__init__方法使用的深度解析》在Python的面向对象编程(OOP)体系中,__init__方法如同建造房屋时的奠基仪式——它定义了对象诞生时的初始状态,下面我们就来深入了解下_... 目录一、__init__的基因图谱二、初始化过程的魔法时刻继承链中的初始化顺序self参数的奥秘默认

SpringBoot使用GZIP压缩反回数据问题

《SpringBoot使用GZIP压缩反回数据问题》:本文主要介绍SpringBoot使用GZIP压缩反回数据问题,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教... 目录SpringBoot使用GZIP压缩反回数据1、初识gzip2、gzip是什么,可以干什么?3、Spr