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

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

自定义注解

  • 前言
  • 正文
    • 创建注解
    • 创建类扫描工具
    • 创建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

相关文章

C#中lock关键字的使用小结

《C#中lock关键字的使用小结》在C#中,lock关键字用于确保当一个线程位于给定实例的代码块中时,其他线程无法访问同一实例的该代码块,下面就来介绍一下lock关键字的使用... 目录使用方式工作原理注意事项示例代码为什么不能lock值类型在C#中,lock关键字用于确保当一个线程位于给定实例的代码块中时

PyQt5 GUI 开发的基础知识

《PyQt5GUI开发的基础知识》Qt是一个跨平台的C++图形用户界面开发框架,支持GUI和非GUI程序开发,本文介绍了使用PyQt5进行界面开发的基础知识,包括创建简单窗口、常用控件、窗口属性设... 目录简介第一个PyQt程序最常用的三个功能模块控件QPushButton(按钮)控件QLable(纯文本

MySQL 强制使用特定索引的操作

《MySQL强制使用特定索引的操作》MySQL可通过FORCEINDEX、USEINDEX等语法强制查询使用特定索引,但优化器可能不采纳,需结合EXPLAIN分析执行计划,避免性能下降,注意版本差异... 目录1. 使用FORCE INDEX语法2. 使用USE INDEX语法3. 使用IGNORE IND

C# $字符串插值的使用

《C#$字符串插值的使用》本文介绍了C#中的字符串插值功能,详细介绍了使用$符号的实现方式,文中通过示例代码介绍的非常详细,需要的朋友们下面随着小编来一起学习学习吧... 目录$ 字符使用方式创建内插字符串包含不同的数据类型控制内插表达式的格式控制内插表达式的对齐方式内插表达式中使用转义序列内插表达式中使用

flask库中sessions.py的使用小结

《flask库中sessions.py的使用小结》在Flask中Session是一种用于在不同请求之间存储用户数据的机制,Session默认是基于客户端Cookie的,但数据会经过加密签名,防止篡改,... 目录1. Flask Session 的基本使用(1) 启用 Session(2) 存储和读取 Se

springboot自定义注解RateLimiter限流注解技术文档详解

《springboot自定义注解RateLimiter限流注解技术文档详解》文章介绍了限流技术的概念、作用及实现方式,通过SpringAOP拦截方法、缓存存储计数器,结合注解、枚举、异常类等核心组件,... 目录什么是限流系统架构核心组件详解1. 限流注解 (@RateLimiter)2. 限流类型枚举 (

Java Thread中join方法使用举例详解

《JavaThread中join方法使用举例详解》JavaThread中join()方法主要是让调用改方法的thread完成run方法里面的东西后,在执行join()方法后面的代码,这篇文章主要介绍... 目录前言1.join()方法的定义和作用2.join()方法的三个重载版本3.join()方法的工作原

Spring AI使用tool Calling和MCP的示例详解

《SpringAI使用toolCalling和MCP的示例详解》SpringAI1.0.0.M6引入ToolCalling与MCP协议,提升AI与工具交互的扩展性与标准化,支持信息检索、行动执行等... 目录深入探索 Spring AI聊天接口示例Function CallingMCPSTDIOSSE结束语

Linux系统之lvcreate命令使用解读

《Linux系统之lvcreate命令使用解读》lvcreate是LVM中创建逻辑卷的核心命令,支持线性、条带化、RAID、镜像、快照、瘦池和缓存池等多种类型,实现灵活存储资源管理,需注意空间分配、R... 目录lvcreate命令详解一、命令概述二、语法格式三、核心功能四、选项详解五、使用示例1. 创建逻

在Java中使用OpenCV实践

《在Java中使用OpenCV实践》用户分享了在Java项目中集成OpenCV4.10.0的实践经验,涵盖库简介、Windows安装、依赖配置及灰度图测试,强调其在图像处理领域的多功能性,并计划后续探... 目录前言一 、OpenCV1.简介2.下载与安装3.目录说明二、在Java项目中使用三 、测试1.测