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

自定义注解

  • 前言
  • 正文
    • 创建注解
    • 创建类扫描工具
    • 创建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.mzph.cn/news/764913.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

基于飞凌嵌入式i.MX6ULL核心板的电梯智能物联网关方案

电梯是现代社会中不可或缺的基础性设施&#xff0c;为人们的生产生活提供了很大的便捷。我国目前正处于城镇化的快速发展阶段&#xff0c;由此带动的城市基础设施建设、楼宇建设、老破小改造等需求也让我国的电梯行业处在了一个高速增长期。截至2023年年底&#xff0c;中国电梯…

「Linux系列」Shell echo命令/printf命令/test命令

文章目录 一、Shell echo命令二、Shell printf命令三、Shell test命令四、相关链接 一、Shell echo命令 echo 是 Unix 和 Linux 系统中常用的一个命令&#xff0c;用于在终端输出字符串或变量的值。这个命令非常基础且常用&#xff0c;通常用于脚本编写、命令行操作等场合。 …

UE5 GameMode C++函数 学习

已经尝试&#xff0c;确实能重启游戏 类描述符加了noplaceable过后即使是Actor也不能放到场景中了&#xff0c;关卡蓝图&#xff0c;GameMode&#xff0c;GameState这些就不能放场景中了 UFUNCTION(exec)

ruoyi-nbcio-plus基于vue3的flowable增加开始节点的表单绑定修改

更多ruoyi-nbcio功能请看演示系统 gitee源代码地址 前后端代码&#xff1a; https://gitee.com/nbacheng/ruoyi-nbcio 演示地址&#xff1a;RuoYi-Nbcio后台管理系统 http://122.227.135.243:9666/ 更多nbcio-boot功能请看演示系统 gitee源代码地址 后端代码&#xff1a…

Android Studio Gradle设置查看全部task

如果你在 Android Studio 的 Gradle 窗口中看不到所有的任务&#xff0c;你可以尝试以下步骤来解决这个问题 android studio 版本&#xff1a; Android Studio Iguana | 2023.2.1 Build #AI-232.10227.8.2321.11479570, built on February 22, 2024 打开 Android Studio 的设置…

行业官网:律师行业官网解决方案和案例

hello&#xff0c;我是大千UI工厂&#xff0c;从此篇开始介绍各行业官网建设的解决方案 和经典案例&#xff0c;本期介绍律师行业&#xff0c;欢迎老铁们关注、评论、如有设计需求可以私信我们。 一、高大上律师官网有什么作用 高大上官网对律师行业的作用主要体现在以下几个…

eNSP学习——GVRP基础配置

目录 一、什么是GVRP 二、实验内容 三、实验目的 四、实验步骤 五、实验拓扑 六、实验编址 七、实验步骤 7.1、基本配置 7.2、配置GVRP单向注册 7.3、配置GVRP双向注册 7.4、配置GVRP的Fixed模式 7.5、配置GVRP的Forbidden模式 需要完整的配置命令大全的可以点击链…

传输线和串扰(一):串扰的叠加以及耦合的起源

串扰是六大信号完整性问题之一。它是将不需要的信号从一个网络传输到相邻网络&#xff0c;并且发生在每对网络之间。网络包括信号路径和返回路径&#xff0c;它连接系统中的一个或多个节点。我们通常将具有噪声源的网络称为主动网络或攻击网络。产生噪声的网络称为安静网络或受…

Linux mkswap命令教程:如何设置Linux交换区(附实例详解和注意事项)

Linux mkswap命令介绍 mkswap命令用于在设备或文件上设置Linux交换区。设备参数通常是磁盘分区&#xff08;例如/dev/sdb7&#xff09;&#xff0c;但也可以是文件。Linux内核不查看分区ID&#xff0c;但许多安装脚本假定十六进制类型82&#xff08;LINUX_SWAP&#xff09;的分…

机器学习 - 训练模型

接着这一篇博客做进一步说明&#xff1a; 机器学习 - 选择模型 为了解决测试和预测之间的差距&#xff0c;可以通过更新 internal parameters, the weights set randomly use nn.Parameter() and bias set randomly use torch.randn(). Much of the time you won’t know what…

STM32之HAL开发——手动移植HAL库

HAL库移植步骤 创建目录 配置启动文件 在\Drivers\CMSIS\Device\ST\stm32f1xx\Source\Templates\ARM目录下&#xff0c;根据你的芯片型号选择对应的启动文件&#xff0c;不同容量大小的芯片&#xff0c;对应的启动文件也不一样。 注意&#xff1a;在HAL库中&#xff0c;不同容…

HTML网页文档和DOM结构介绍

HTML网页文档和DOM结构介绍 HTML网页文档 HTML&#xff0c;全称为超文本标记语言&#xff08;Hypertext Markup Language&#xff09;&#xff0c;是用来描述并定义内容结构的标记语言&#xff0c;它是构建任何网页和网络应用的最基础的组成部分。HTML文档由一系列的元素构成…

[SAP ABAP] SE11查询数据库表中的数据

我们可以通过事务码SE11查询对应数据库表中的详细数据 本次查询使用的数据库表名为MARA&#xff0c;具体操作如下所示: ① 输入事务码SE11进入ABAP字典操作界面&#xff0c;在数据库表搜索框中输入目标表名MARA&#xff0c;并点击【显示】按钮 ② 进入到显示表界面&#xff0…

c++翁恺

1、面向对象 Data&#xff1a;杯子的属性 Opera&#xff1a;杯子提供的服务 老师上课&#xff1a; C&#xff1a;按流程执行 C&#xff1a;定一个教室&#xff0c;有很多学生&#xff0c;投影仪&#xff0c;灯&#xff0c;每个学生反映不一样。 这个场景有什么东西&#xff0c…

关于Rust的项目结构的笔记

层级 PackageCrateModulePath Package cargo的特性, 构建、测试、共享Crate 组成: 一个 Cargo.toml 文件, 描述了如何构建这些 Crates至少包含一个 crate最多只能包含一个 library crate可以包含任意个 binary crate cargo new demo-pro 会产生一个名为 demo-pro 的 Packa…

【自记录】VS2022编译OpenSSL1.0.2u

因为突然要编译一个老工程&#xff0c;老工程里面用到了OpenSSL 1.0.x。 于是官网下载了最后一个1.0.x版本1.0.2u。 1 下载安装Perl 去Perl官网下载即可。 使用vcpkg直接安装也可以&#xff0c;比前者更方便 vcpkg install perl #根据实际路径调整 set PATHD:\vcpkg\downloa…

【C语言】linux内核pci_set_master

一、__pci_set_master static void __pci_set_master(struct pci_dev *dev, bool enable) {u16 old_cmd, cmd;pci_read_config_word(dev, PCI_COMMAND, &old_cmd); // 读取设备的PCI命令寄存器的当前值if (enable)cmd old_cmd | PCI_COMMAND_MASTER; // 如果要启用总线…

力扣● 503.下一个更大元素II ● 42. 接雨水

503.下一个更大元素II 与496.下一个更大元素 I的不同是要循环地搜索元素的下一个更大的数。那么主要是对于遍历结束后&#xff0c;单调栈里面剩下的那些元素。 如果直接把两个数组拼接在一起&#xff0c;然后使用单调栈求下一个最大值就可以。 代码实现的话&#xff0c;不用直…

蓝桥杯练习——神秘咒语——axios

目标 完善 index.js 中的 TODO 部分&#xff0c;通过新增或者修改代码&#xff0c;完成以下目标&#xff1a; 点击钥匙 1 和钥匙 2 按钮时会通过 axios 发送请求&#xff0c;在发送请求时需要在请求头中添加 Authorization 字段携带 token&#xff0c;token 的值为 2b58f9a8-…

瑞_23种设计模式_状态模式

文章目录 1 状态模式&#xff08;State Pattern&#xff09;1.1 介绍1.2 概述1.3 状态模式的结构1.4 状态模式的优缺点1.5 状态模式的使用场景 2 案例一2.1 需求2.2 代码实现&#xff08;未使用状态模式&#xff09;2.3 代码实现&#xff08;状态模式&#xff09; 3 案例二3.1 …