前言
在前面几节我们将Login服的大体架构搭建了起来,
具体流程是这样的:
- 客户端上传protobuf协议到LoginServer
- LoginServer的NettyServer接收数据将数据发送到ConnectActor
- ConnectActor根据协议号,对不同的协议使用不同的Protobuf类解包,然后调用不同的方法。
当我们收到不同的协议号,我们添加了不同的if判断条件来反序列化协议,再根据不同的协议号调用不同的方法。
当我们的业务逻辑越发复杂,协议越来越多,就会导致if分支变多,不用很多时间,这个类就会变得又臭又长,且多人开发时会有代码提交冲突的问题。
为了解决这个问题,我们需要有分而治之的思想。使用自定义注解+反射,可以将这部分工作变得简单且无脑。
正文
本节,我们的目标是创建一个协议分发类,里面存放一张映射表,将协议号与对应的方法记录在里面。
当收到一条协议,便根据协议号找到对应的Method。
再根据Method,获取第二个参数的类型(我们默认第一个参数为玩家数据,第二个参数为客户端上行的protobuf数据)。获得参数类型就可以使用protobuf进行反序列化。
最后通过反射的方式进行方法调用。
接下来看笔者一步步实现。
创建注解
在common下添加dispatch包,创建CMD注解
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface CMD {
// 协议号 ProtoEnumMsg.CMD.ID
int 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)) {
// 出现重复cmdId
String 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 {
// 协议id
private 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进行初始化。
@Override
protected 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());
}
}
两个细节:
- 使用@Component注解标记类:因为我们的dispatcher通过Spring获取handler的单例对象,并通过该对象进行方法调用,因此使用@Component将其生命周期托管给Spring。
- @CMD(ProtoEnumMsg.CMD.ID.xx):因为我们对CMD的参数命名为value,因此使用注解不需要带入参数名,如@CMD(value = ProtoEnumMsg.CMD.ID.xx).
- 回参改为回Pack,由ConnectActor进行消息回传。
基于这几点,我们将所有的业务逻辑独立在了ProtoHandler中,后续业务开发不再需要考虑如何反序列化,如何回传消息,如何将协议号与方法映射。
测试
启动LoginServer,启动Client,Client控制台输入login_test1_123456
可以看到登录服输出了登录协议相关日志。
结语
本节笔者使用自定义注解+反射,解决了开发新协议时需要添加if…else…分支的问题,同时也使得业务开发人员可以更加专注于业务逻辑开发,减少其开发新协议需要修改的文件数量,在多人协同时是非常有益且高效的。
但是这也带来了问题,使用@CMD注解的方法,其传参的规则就定下来,参数0为玩家数据,参数1为protobuf数据,而这个规则需要由开发人员口口相传或者整理一份新员工开发文档中作为项目开发规范。若是不熟悉代码且经验不足的开发人员,可能会在传参上犯下错误。
但是总的来说,这么做还是利大于弊的,未来我们进行游戏逻辑服的开发,会涉及大量的协议交互,使用dispatcher可以很大程度上节约我们的时间,提高我们的效率。