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

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

自定义注解

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

相关文章

Security OAuth2 单点登录流程

单点登录(英语:Single sign-on,缩写为 SSO),又译为单一签入,一种对于许多相互关连,但是又是各自独立的软件系统,提供访问控制的属性。当拥有这项属性时,当用户登录时,就可以获取所有系统的访问权限,不用对每个单一系统都逐一登录。这项功能通常是以轻型目录访问协议(LDAP)来实现,在服务器上会将用户信息存储到LDAP数据库中。相同的,单一注销(single sign-off)就是指

Spring Security基于数据库验证流程详解

Spring Security 校验流程图 相关解释说明(认真看哦) AbstractAuthenticationProcessingFilter 抽象类 /*** 调用 #requiresAuthentication(HttpServletRequest, HttpServletResponse) 决定是否需要进行验证操作。* 如果需要验证,则会调用 #attemptAuthentica

服务器集群同步时间手记

1.时间服务器配置(必须root用户) (1)检查ntp是否安装 [root@node1 桌面]# rpm -qa|grep ntpntp-4.2.6p5-10.el6.centos.x86_64fontpackages-filesystem-1.41-1.1.el6.noarchntpdate-4.2.6p5-10.el6.centos.x86_64 (2)修改ntp配置文件 [r

这15个Vue指令,让你的项目开发爽到爆

1. V-Hotkey 仓库地址: github.com/Dafrok/v-ho… Demo: 戳这里 https://dafrok.github.io/v-hotkey 安装: npm install --save v-hotkey 这个指令可以给组件绑定一个或多个快捷键。你想要通过按下 Escape 键后隐藏某个组件,按住 Control 和回车键再显示它吗?小菜一碟: <template

中文分词jieba库的使用与实景应用(一)

知识星球:https://articles.zsxq.com/id_fxvgc803qmr2.html 目录 一.定义: 精确模式(默认模式): 全模式: 搜索引擎模式: paddle 模式(基于深度学习的分词模式): 二 自定义词典 三.文本解析   调整词出现的频率 四. 关键词提取 A. 基于TF-IDF算法的关键词提取 B. 基于TextRank算法的关键词提取

Hadoop企业开发案例调优场景

需求 (1)需求:从1G数据中,统计每个单词出现次数。服务器3台,每台配置4G内存,4核CPU,4线程。 (2)需求分析: 1G / 128m = 8个MapTask;1个ReduceTask;1个mrAppMaster 平均每个节点运行10个 / 3台 ≈ 3个任务(4    3    3) HDFS参数调优 (1)修改:hadoop-env.sh export HDFS_NAMENOD

使用SecondaryNameNode恢复NameNode的数据

1)需求: NameNode进程挂了并且存储的数据也丢失了,如何恢复NameNode 此种方式恢复的数据可能存在小部分数据的丢失。 2)故障模拟 (1)kill -9 NameNode进程 [lytfly@hadoop102 current]$ kill -9 19886 (2)删除NameNode存储的数据(/opt/module/hadoop-3.1.4/data/tmp/dfs/na

Hadoop数据压缩使用介绍

一、压缩原则 (1)运算密集型的Job,少用压缩 (2)IO密集型的Job,多用压缩 二、压缩算法比较 三、压缩位置选择 四、压缩参数配置 1)为了支持多种压缩/解压缩算法,Hadoop引入了编码/解码器 2)要在Hadoop中启用压缩,可以配置如下参数

【前端学习】AntV G6-08 深入图形与图形分组、自定义节点、节点动画(下)

【课程链接】 AntV G6:深入图形与图形分组、自定义节点、节点动画(下)_哔哩哔哩_bilibili 本章十吾老师讲解了一个复杂的自定义节点中,应该怎样去计算和绘制图形,如何给一个图形制作不间断的动画,以及在鼠标事件之后产生动画。(有点难,需要好好理解) <!DOCTYPE html><html><head><meta charset="UTF-8"><title>06

Makefile简明使用教程

文章目录 规则makefile文件的基本语法:加在命令前的特殊符号:.PHONY伪目标: Makefilev1 直观写法v2 加上中间过程v3 伪目标v4 变量 make 选项-f-n-C Make 是一种流行的构建工具,常用于将源代码转换成可执行文件或者其他形式的输出文件(如库文件、文档等)。Make 可以自动化地执行编译、链接等一系列操作。 规则 makefile文件