从零开始搭建游戏服务器 第二节 登录注册功能

上节代码优化

在上一节代码中,还有几个点可以进行优化。

  1. ip地址和端口硬编码

    我们把ip地址和端口从代码中剥离出来,单独记录在配置文件中,方便进行管理和修改。

    在resources目录下新建一个game.conf文件

ip = "127.0.0.1"
port = 2333
在build.gradle中添加依赖
implementation group: 'com.typesafe', name: 'config', version: '1.4.2'
在GameBeanConfiguration类添加代码
    @Bean(name = "gameConfig")
    Config getGameConfig() {
        Config config = ConfigFactory.parseFile(new File("src/main/resources/game.conf"));
        return config;
    }
修改GameMain类,修改startNetty(),传入参数int port, 获取gameConfig后拿到端口,传入startNetty方法中。
    Config gameConfig = (Config) springContext.getBean("gameConfig");
    // 启动Netty服务器
    try {
        startNetty(gameConfig.getInt("port"));
        log.info("Netty server start!");
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
运行一下,启动成功,优化完成。

接入mongo

导入依赖包

在build.gradle中添加代码,导入mongodb相关依赖包

    implementation group: 'org.springframework.data', name: 'spring-data-mongodb', version: '2.2.12.RELEASE'
    implementation group: 'org.mongodb', name: 'mongo-java-driver', version: '3.12.2'

安装mongoDB

我这里有一台linux服务器,采用docker安装mongoDB。

docker pull mongo
docker run -itd --name mongo -p 27017:27017 mongo --auth
// 进入mongo修改用户名和密码
docker exec -it mongo mongosh admin
db.createUser({user:'admin',pwd:'admin',roles:[{role:'userAdminAnyDatabase',db:'admin'},"readWriteAnyDatabase"]});
// 测试用户添加成功
db.auth('admin', 'admin')
// 创建数据库game
use game

添加mongo相关配置

往game.conf中增加mongo相关配置

mongo.ip = "127.0.0.1"
mongo.port = 27017
mongo.user = "admin"
mongo.psw = "admin"
mongo.database = "game"

增加MongoDbContext类,以后的Mongo相关操作都由他来完成

@Slf4j
public class MongoDbContext {
    // mongo连接客户端
    private MongoClient mongoClient;
    // template
    private MongoTemplate template;

    public MongoDbContext(MongoClient mongoClient, String databaseGame) {
        this.mongoClient = mongoClient;
        this.template = new MongoTemplate(mongoClient, databaseGame);
        log.info("MongoDb finish start!");
    }

    public MongoClient getMongoClient() {
        return mongoClient;
    }

    public MongoTemplate getTemplate() {
        return template;
    }
}

在GameBeanConfiguration中注册MongoDbContext

    @Bean(name = "mongoDbContext")
    MongoDbContext getMongoDbContext() {
        Config gameConfig = getGameConfig();
        String mongoDbIp = gameConfig.getString("mongo.ip");
        int mongoDbPort = gameConfig.getInt("mongo.port");
        String mongoDbUser = gameConfig.getString("mongo.user");
        String mongoDbPsw = gameConfig.getString("mongo.psw");
        String mongoDbAdmin = gameConfig.getString("mongo.database.admin");
        String mongoDbGame = gameConfig.getString("mongo.database.game");
        String url = "mongodb://" + mongoDbUser + ":" + mongoDbPsw + "@" + mongoDbIp + ":" + mongoDbPort + "/" + mongoDbAdmin;
        MongoClientSettings.Builder mongoDbSettings = MongoClientSettings.builder();
        mongoDbSettings.writeConcern(WriteConcern.MAJORITY);
        mongoDbSettings.applyConnectionString(new ConnectionString(url));
        MongoClient mongoClient = MongoClients.create(mongoDbSettings.build());
        return new MongoDbContext(mongoClient, mongoDbGame);
    }

启动服务器,会发现控制台不停打印Mongo连接检测的信息,对我们进行debug查看控制台信息很不友好,因此我们再resource目录下添加logback.xml对日志打印逻辑进行修改。

<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="60 seconds" debug="false">
    <!-- 日志格式 -->
    <property name="CONSOLE_STR"
              value="%date{ISO8601}[%highlight(%level)] Class:%class  Line:%L - %m %n"/>
    <!--输出到控制台-->
    <appender name="console" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>${CONSOLE_STR}</pattern>
            <charset>utf8</charset>
        </encoder>
    </appender>
    <root level="INFO">
        <appender-ref ref="console"/>
    </root>

    <logger name="org.mongodb.driver.cluster" level="INFO"/>
    <logger name="org.mongodb.driver.protocol.command" level="INFO"/>

</configuration>

MongoDb配置完成,接下来我们开发登录注册功能。

为了方便管理,我们修改一下项目结构,如下:

在这里插入图片描述

项目基本框架类放入frame包中,再新建func用于存放游戏功能逻辑

登录注册功能

在func下添加新包,player用于管理玩家登录注册相关类,新建PlayerEntity作为存放玩家数据的Bean

public class PlayerEntity {
    // 用户名
    private String userName;
    // 密码
    private String password;
    public String getUserName() {
        return userName;
    }
    public void setUserName(String userName) {
        this.userName = userName;
    }
    public String getPassword() {
        return password;
    }
    public void setPassword(String password) {
        this.password = password;
    }
}

新建PlayerProtoHandler用于处理客户端发上来的数据。我们先简单处理,暂时不用客户端上行用户名密码之类的数据。

@Slf4j
@Component
public class PlayerProtoHandler {
    @Autowired
    private MongoDbContext mongoDbContext;

    public String login() {
        List<PlayerEntity> all = mongoDbContext.getTemplate().findAll(PlayerEntity.class);
        log.info(String.valueOf(all));
        log.info("player login.");
        return "*success*";
    }

    public String register() {
        PlayerEntity entity = new PlayerEntity();
        entity.setUserName("111111");
        entity.setPassword("111111");
        mongoDbContext.getTemplate().insert(entity);
        log.info("player register finish.");
        return "success";
    }
}

在修改NettyMessageHandler,对客户端上行的数据进行分发

@Sharable
@Component
@Slf4j
public class NettyMessageHandler extends SimpleChannelInboundHandler<Object> {

    @Autowired
    PlayerProtoHandler playerProtoHandler;

    /**
     * 读取数据
     */
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
        this.doRead(ctx, msg);
    }

    private void doRead(ChannelHandlerContext ctx, Object msg) {
        log.info("received msg = : " + msg);
        String response = "";
        if (msg.equals("login\n")) {
            response = playerProtoHandler.login();
        } else if (msg.equals("register\n")) {
            response = playerProtoHandler.register();
        }
        ctx.writeAndFlush(response + "\n");
    }

启动我们的客户端,分别输入register和login,可以在服务端控制台上得到结果,成功注册了名为111111的玩家。

在这里插入图片描述

思考

上面的开发过程其实很难受,因为我们双端都使用了String类型的交互协议,不仅要考虑双端如何约定我们的消息结构,还要对每一个消息进行手动添加逻辑分发。这大大增加了我们的代码开发量,使我们无法专注在具体功能逻辑的开发。

因此我们将会进行下面几个优化方案:

  1. 修改交互协议,由String修改为protobuf。
  2. 通过Spring注解,开发协议派发机制。

优化

protobuf引入

protobuf是Google公司开发的一种数据描述语言,用于描述一种轻便高效的结构化数据存储格式,可以用于结构化数据序列化和反序列化。

对我们来说,引入protobuf不仅规范了双端交互协议的制定,也减轻了我们编写协议序列化反序列化的代码量。

我们先下载protobuf编译器,我这里下载了22.1版本

https://github.com/protocolbuffers/protobuf/releases

我们在resources下添加目录proto用于存放我们的协议文件,将刚下载的protoc.exe移入其中,并创建一个player.proto协议文件

syntax = "proto3";
// 生成包名
option java_package = "com.wfgame.protobuf";
// 生成类型
option java_outer_classname = "PlayerProto";

message Player {
  string playerName = 1;
  string password = 2;
}

// 玩家登录
message C2SPlayerLogin {
  Player player = 1;  // 登录玩家信息
}
message S2CPlayerLogin {
  bool success = 1; // 是否登录成功
}

// 玩家注册
message C2SPlayerRegister {
  Player player = 1;  // 登录玩家信息
}
message S2CPlayerRegister {
  bool success = 1; // 是否注册成功
}

创建一个协议号相关的proto,起名为ProtoEnum.proto

syntax = "proto3";

option java_package = "com.wfgame.protobuf";
option java_outer_classname = "ProtoEnum";

enum CmdIdEnum {
  NONE = 0;
  // 玩家相关协议 前缀 110
  PLAYER_LOGIN = 11001; // 玩家登录
  PLAYER_REGISTER = 11002;  // 玩家注册
}

再创建一个protoc.bat批处理指令

@echo off
for %%f in (*.proto) do (
    protoc --proto_path=. --java_out="../../java" %%~nf%%~xf
    echo %%~nf%%~xf
)
pause

运行批处理命令,会发现在对应的包下生成了Player协议类

在这里插入图片描述

build.gradle引入依赖

    implementation 'com.google.protobuf:protobuf-java:3.22.2'
    implementation 'com.google.protobuf:protobuf-java-util:3.22.2'

为了方便进行协议消息的分发处理,我们需要给每一条协议增加一条协议码,简称cmdId,为此我们不能直接使用Netty的protobuf编解码器,我需要要在每一条proto上再包装一层。

在frame包下创建ProtoPack类

public class ProtoPack {
    /**
     * 头长度  cmdId 4位
     */
    private static final int LEN = 4;

    /**
     * 把byte[]转化成Pack
     */
    public static Pack decode(byte[] data) {
        int it = 0;
        int cmd = readInt(data, 0);
        byte[] newData = Arrays.copyOfRange(data, it + LEN, data.length);
        return new Pack(cmd, newData);
    }

    /**
     * 把Pack转化成byte[]
     */
    public static byte[] encode(Pack pack) {
        return encode(pack.getCmdId(), pack.getData());
    }
    public static byte[] encode(int cmdId, byte[] data) {
        byte[] res = new byte[data.length + LEN];
        int it = 0;
        writeInt(res, 0, cmdId);
        it += LEN;
        for (int i = 0; i < data.length; ++i) {
            res[it + i] = data[i];
        }
        return res;
    }
    
    /**
     * 从byte[]中获取cmdId
     */
    public static int getCmdId(byte[] data) {
        return readInt(data, 0);
    }

    /**
     * byte[]从offset位置开始往后读一个int
     */
    private static int readInt(byte[] bytes, int offset){
        int it = offset;
        return (bytes[it++] & 0xFF)
                + ((bytes[it++] & 0xFF) << 8)
                + ((bytes[it++] & 0xFF) << 16)
                + ((bytes[it++] & 0xFF) << 24);
    }

    /**
     * byte[]从offset位置开始往后写一个int value
     */
    private static void writeInt(byte[] bytes, int offset, int value) {
        int it = offset;
        bytes[it++] = (byte) (value & 0xFF);
        bytes[it++] = (byte) ((value >>> 8) & 0xFF);
        bytes[it++] = (byte) ((value >>> 16) & 0xFF);
        bytes[it++] = (byte) ((value >>> 24) & 0xFF);
    }

    public static final class Pack {
        // 协议号
        private int cmdId;
        // 协议内容
        private byte[] data;

        public Pack() {
        }

        public Pack(int cmdId, byte[] data) {
            this.cmdId = cmdId;
            this.data = data;
        }
        
        public int getCmdId() {
            return cmdId;
        }

        public void setCmdId(int cmdId) {
            this.cmdId = cmdId;
        }

        public byte[] getData() {
            return data;
        }

        public void setData(byte[] data) {
            this.data = data;
        }
    }
    
}

由于我们不会永远是java → java的信息交互,我们应该能够支持与其他语言的客户端进行交互,因此我们将会使用byte[]进行数据发送与接受,并且在消息的开头加上我们消息的长度,方便进行编解码。

我们先修改客户端的代码

    public static void login() throws IOException {
        PlayerProto.C2SPlayerLogin.Builder builder = PlayerProto.C2SPlayerLogin.newBuilder();
        PlayerProto.Player player = PlayerProto.Player.newBuilder().setPlayerName("111111").setPassword("111111").build();
        builder.setPlayer(player);
        ProtoPack.Pack pack = new ProtoPack.Pack(ProtoEnum.CmdIdEnum.PLAYER_LOGIN_VALUE, builder.build().toByteArray());
        byte[] encode = ProtoPack.encode(pack);
        byte[] bytes = addLength(encode);
        System.out.println(Arrays.toString(bytes));
        writer.write(bytes);
        writer.flush();
    }

    public static void register() throws IOException {
        PlayerProto.C2SPlayerRegister.Builder builder = PlayerProto.C2SPlayerRegister.newBuilder();
        PlayerProto.Player player = PlayerProto.Player.newBuilder().setPlayerName("111111").setPassword("111111").build();
        builder.setPlayer(player);
        ProtoPack.Pack pack = new ProtoPack.Pack(ProtoEnum.CmdIdEnum.PLAYER_REGISTER_VALUE, builder.build().toByteArray());
        byte[] encode = ProtoPack.encode(pack);
        byte[] bytes = addLength(encode);
        ProtoPack.writeInt(bytes, 0, bytes.length);
        writer.write(bytes);
        writer.flush();
    }

    /**
     * 添加长度到消息头
     */
    public static byte[] addLength(byte[] bytes) {
        byte[] newBytes = new byte[bytes.length + 4];
        ProtoPack.writeInt(newBytes, 0, bytes.length);
        byte[] lengthBytes = toUnsignedByteArray(bytes.length);
        for (int i = 0; i < lengthBytes.length; i++) {
            newBytes[i] = lengthBytes[i];
        }
        for (int i = 0; i < bytes.length; i++) {
            newBytes[i + 4] = bytes[i];
        }
        return newBytes;
    }

    /**
     * 获取无符号整型对应的byte[]
     */
    public static byte[] toUnsignedByteArray(int value) {
        byte[] result = new byte[4];
        result[0] = (byte) (value >>> 24);
        result[1] = (byte) (value >>> 16);
        result[2] = (byte) (value >>> 8);
        result[3] = (byte) value;
        return result;
    }

然后修改服务器的代码。

我们需要自己创建一个编码解码器,用于替换之前的StringDecoder和StringEncoder。

解码器MyDecoder,仅仅是封装了一下Netty自带的长度解码器

public class MyDecoder extends LengthFieldBasedFrameDecoder {
    public MyDecoder(int maxFrameLength, int lengthFieldOffset, int lengthFieldLength, int lengthAdjustment, int initialBytesToStrip) {
        super(maxFrameLength, lengthFieldOffset, lengthFieldLength, lengthAdjustment, initialBytesToStrip);
    }

    @Override
    protected Object decode(ChannelHandlerContext ctx, ByteBuf in) throws Exception {
        return super.decode(ctx, in);
    }
}

编码器MyEncoder,用于把ByteBuf写入byte[]

public class MyEncoder extends MessageToByteEncoder<byte[]> {
    @Override
    protected void encode(ChannelHandlerContext ctx, byte[] sendBytes, ByteBuf out) throws Exception {
        out.writeBytes(sendBytes);
    }
}

修改GameMain,替换原本的编解码器

        bootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
            @Override
            protected void initChannel(SocketChannel ch) throws Exception {
                ChannelPipeline cp = ch.pipeline();
                cp.addLast(new MyDecoder(1024 * 1024, 0, 4, 0, 4));//基于长度的解码器:1024*1024, 0, 4, 0, 4
                cp.addLast(new LengthFieldPrepender(4));//MyEncoder编码后再往开头插入4个字节的消息长度
                cp.addLast(new MyEncoder());//ByteBuf-->byte[]
                cp.addLast("handler", handler);
            }
        });

修改NettyMessageHandler,我们将从ByteBuf中读取数据,然后根据协议号进行逻辑分发。

@Sharable
@Component
@Slf4j
public class NettyMessageHandler extends SimpleChannelInboundHandler<Object> {

    @Autowired
    PlayerProtoHandler playerProtoHandler;

    /**
     * 读取数据
     */
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
        this.doRead(ctx, msg);
    }

    private void doRead(ChannelHandlerContext ctx, Object msg) throws InvalidProtocolBufferException {
        ByteBuf in = (ByteBuf) msg;
        byte[] bytes = new byte[in.readableBytes()];
        in.readBytes(bytes);
        // 读取协议号
        int cmdId = ProtoPack.getCmdId(bytes);
        ProtoPack.Pack request = ProtoPack.decode(bytes);
        ProtoPack.Pack response = null;
        if (cmdId == ProtoEnum.CmdIdEnum.PLAYER_LOGIN_VALUE) {// 登录
            response = playerProtoHandler.login(PlayerProto.C2SPlayerLogin.parseFrom(request.getData()));
        } else if (cmdId == ProtoEnum.CmdIdEnum.PLAYER_REGISTER_VALUE) {// 注册
            response = playerProtoHandler.register(PlayerProto.C2SPlayerRegister.parseFrom(request.getData()));
        }
        ctx.writeAndFlush(response);
    }
}

修改PlayerProtoHandler,使其通过传入的Protobuf协议来进行登录与注册

@Slf4j
@Component
public class PlayerProtoHandler {
    @Autowired
    private MongoDbContext mongoDbContext;

    public ProtoPack.Pack login(PlayerProto.C2SPlayerLogin request) {
        PlayerProto.Player player = request.getPlayer();
        log.info("player login userName={}, password={}", player.getPlayerName(), player.getPassword());
        Query query = new Query();
        query.addCriteria(Criteria.where("userName").is(player.getPlayerName()));
        PlayerEntity one = mongoDbContext.getTemplate().findOne(query, PlayerEntity.class);
        boolean res = true;
        if (one == null || !one.getPassword().equals(player.getPassword())) {
            res = false;    // 账号密码错误
        }
        return new ProtoPack.Pack(ProtoEnum.CmdIdEnum.PLAYER_LOGIN_VALUE, PlayerProto.S2CPlayerLogin.newBuilder().setSuccess(res).build().toByteArray());
    }

    public ProtoPack.Pack register(PlayerProto.C2SPlayerRegister request) {
        PlayerProto.Player player = request.getPlayer();
        Query query = new Query();
        query.addCriteria(Criteria.where("userName").is(player.getPlayerName()));
        PlayerEntity one = mongoDbContext.getTemplate().findOne(query, PlayerEntity.class);
        boolean res = false;
        if (one == null) {
            PlayerEntity entity = new PlayerEntity();
            entity.setUserName(player.getPlayerName());
            entity.setPassword(player.getPassword());
            mongoDbContext.getTemplate().insert(entity);
            log.info("player register finish.");
            res = true;
        }
        return new ProtoPack.Pack(ProtoEnum.CmdIdEnum.PLAYER_REGISTER_VALUE, PlayerProto.S2CPlayerRegister.newBuilder().setSuccess(res).build().toByteArray());
    }
}

修改完毕,测试一下。启动客户端输入login得到如下信息

在这里插入图片描述

优化完成。

总结一下这里我们做了什么:

  1. 接入了Protobuf做协议序列化和反序列化。
  2. 引入了协议号的概念,方便后面做协议逻辑分发。
  3. 包装了协议,在协议头增加了消息的长度以及协议号。

协议分发注解

为了方便后续开发,我们可以通过注解+协议号进行功能的分发。有点类似@Controller注解和@request注解。

我们新建注解类@CMD

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

修改PlayerProtoHandler.java,在login和register方法上添加CMD注解

    /**
     * 登录
     */
    @CMD(ProtoEnum.CmdIdEnum.PLAYER_LOGIN_VALUE)
    public ProtoPack.Pack login(PlayerProto.C2SPlayerLogin request) {
      ...
    }

    /**
     * 注册
     */
    @CMD(ProtoEnum.CmdIdEnum.PLAYER_REGISTER_VALUE)
    public ProtoPack.Pack register(PlayerProto.C2SPlayerRegister request) {
        ...
    }

先写一个扫描包下所有Class的工具类ClassPathScanner.java

@Slf4j
public class ClassPathScanner {
    /**
     * 扫描包
     *
     * @param basePackage     基础包
     * @param recursive       是否递归搜索子包
     * @param excludeInner    是否排除内部类 true->是 false->否
     * @param checkInOrEx     过滤规则适用情况 true—>搜索符合规则的 false->排除符合规则的
     * @param classFilterStrs List<java.lang.String> 自定义过滤规则,如果是null或者空,即全部符合不过滤
     * @return Set
     */
    public static Set<Class> scan(String basePackage, boolean recursive, boolean excludeInner, boolean checkInOrEx,
                                  List<String> classFilterStrs) {
        Set<Class> classes = new LinkedHashSet<>();
        String packageName = basePackage;
        List<Pattern> classFilters = toClassFilters(classFilterStrs);

        if (packageName.endsWith(".")) {
            packageName = packageName
                    .substring(0, packageName.lastIndexOf('.'));
        }
        String package2Path = packageName.replace('.', '/');

        Enumeration<URL> dirs;
        try {
            dirs = Thread.currentThread().getContextClassLoader()
                    .getResources(package2Path);
            while (dirs.hasMoreElements()) {
                URL url = dirs.nextElement();
                String protocol = url.getProtocol();
                if ("file".equals(protocol)) {
                    log.debug("扫描file类型的class文件....");
                    String filePath = URLDecoder.decode(url.getFile(), "UTF-8");
                    doScanPackageClassesByFile(classes, packageName, filePath,
                            recursive, excludeInner, checkInOrEx, classFilters);
                } else if ("jar".equals(protocol)) {
                    log.debug("扫描jar文件中的类....");
                    doScanPackageClassesByJar(packageName, url, recursive,
                            classes, excludeInner, checkInOrEx, classFilters);
                }
            }
        } catch (IOException e) {
            log.error("IOException error:", e);
        }

        return classes;
    }

    /**
     * 以jar的方式扫描包下的所有Class文件
     */
    private static void doScanPackageClassesByJar(String basePackage, URL url,
                                                  final boolean recursive, Set<Class> classes, boolean excludeInner, boolean checkInOrEx, List<Pattern> classFilters) {
        String packageName = basePackage;
        String package2Path = packageName.replace('.', '/');
        JarFile jar;
        try {
            jar = ((JarURLConnection) url.openConnection()).getJarFile();
            Enumeration<JarEntry> entries = jar.entries();
            while (entries.hasMoreElements()) {
                JarEntry entry = entries.nextElement();
                String name = entry.getName();
                if (!name.startsWith(package2Path) || entry.isDirectory()) {
                    continue;
                }

                // 判断是否递归搜索子包
                if (!recursive
                        && name.lastIndexOf('/') != package2Path.length()) {
                    continue;
                }
                // 判断是否过滤 inner class
                if (excludeInner && name.indexOf('$') != -1) {
                    log.debug("exclude inner class with name:" + name);
                    continue;
                }
                String classSimpleName = name
                        .substring(name.lastIndexOf('/') + 1);
                // 判定是否符合过滤条件
                if (filterClassName(classSimpleName, checkInOrEx, classFilters)) {
                    String className = name.replace('/', '.');
                    className = className.substring(0, className.length() - 6);
                    try {
                        classes.add(Thread.currentThread()
                                .getContextClassLoader().loadClass(className));
                    } catch (ClassNotFoundException e) {
                        log.error("Class.forName error:", e);
                    }
                }
            }
        } catch (IOException e) {
            log.error("IOException error:", e);
        }
    }

    /**
     * 以文件的方式扫描包下的所有Class文件
     */
    private static void doScanPackageClassesByFile(Set<Class> classes,
        String packageName, String packagePath, boolean recursive, final boolean excludeInner, final boolean checkInOrEx, final List<Pattern> classFilters) {
        File dir = new File(packagePath);
        if (!dir.exists() || !dir.isDirectory()) {
            return;
        }
        final boolean fileRecursive = recursive;
        File[] dirfiles = dir.listFiles(new FileFilter() {
            // 自定义文件过滤规则
            @Override
            public boolean accept(File file) {
                if (file.isDirectory()) {
                    return fileRecursive;
                }
                String filename = file.getName();
                if (excludeInner && filename.indexOf('$') != -1) {
                    log.debug("exclude inner class with name:" + filename);
                    return false;
                }
                return filterClassName(filename, checkInOrEx, classFilters);
            }
        });
        for (File file : dirfiles) {
            if (file.isDirectory()) {
                doScanPackageClassesByFile(classes,
                        packageName + "." + file.getName(),
                        file.getAbsolutePath(), recursive, excludeInner, checkInOrEx, classFilters);
            } else {
                String className = file.getName().substring(0,
                        file.getName().length() - 6);
                try {
                    classes.add(Thread.currentThread().getContextClassLoader()
                            .loadClass(packageName + '.' + className));

                } catch (ClassNotFoundException e) {
                    log.error("IOException error:", e);
                }
            }
        }
    }

    /**
     * 根据过滤规则判断类名
     */
    private static boolean filterClassName(String className, boolean checkInOrEx, List<Pattern> classFilters) {
        if (!className.endsWith(".class")) {
            return false;
        }
        if (null == classFilters || classFilters.isEmpty()) {
            return true;
        }
        String tmpName = className.substring(0, className.length() - 6);
        boolean flag = false;
        for (Pattern p : classFilters) {
            if (p.matcher(tmpName).find()) {
                flag = true;
                break;
            }
        }
        return (checkInOrEx && flag) || (!checkInOrEx && !flag);
    }

    /**
     * @param pClassFilters the classFilters to set
     */
    private static List<Pattern> toClassFilters(List<String> pClassFilters) {
        List<Pattern> classFilters = new ArrayList<Pattern>();
        if (pClassFilters != null) {

            for (String s : pClassFilters) {
                String reg = "^" + s.replace("*", ".*") + "$";
                Pattern p = Pattern.compile(reg);
                classFilters.add(p);
            }
        }
        return classFilters;
    }

}

添加ProtoDispatch类,用于做消息的分发

@Slf4j
@Component
public class ProtoDispatcher {
    /**指令缓存*/
    private Map<Integer, Commander> commanders = new HashMap<>();

    private static class Commander {
        private final Object o;
        private final Method method;
        private final Method protobufParser;

        public Commander(Object o, Method method, int cmdId) throws NoSuchMethodException {
            this.o = o;
            this.method = method;
            // 协议数据应该转成什么类型
            Class paramType = method.getParameterTypes()[0];
            this.protobufParser = paramType.getMethod("parseFrom", byte[].class);
        }
    }

    /**
     * 加载注解
     */
    public void load(ApplicationContext springContext, Collection<Class> classes) {
        Map<Integer, Commander> newCommanders = new HashMap<>();
        String err = null;
        for (Class cls : classes) {
            try {
                Object o = springContext.getBean(cls);
                Method[] methods = cls.getDeclaredMethods();
                for (Method method : methods) {
                    CMD cmd = method.getAnnotation(CMD.class);
                    if(cmd != null) {
                        if (newCommanders.get(cmd.value()) != null) {
                            err = "协议加载异常:协议号重复 cmd.id = " + cmd.value();
                            log.error(err);
                        }
                        try {
                            newCommanders.put(cmd.value(), new Commander(o, method, cmd.value()));
                        } catch (Exception e) {
                            log.error("协议[" + cls + "]加载出错!!!,cmd=" + cmd.value() + ", method=" + method.getName(), e);
                            throw new RuntimeException(e);
                        }
                    }
                }
            } catch (Exception e) {
                log.error("协议[" + cls + "]加载出错!!!", e);
                throw new RuntimeException(e);
            }
        }

        //协议号重复直接报错
        if(err != null){
            throw new RuntimeException(err);
        }

        commanders = newCommanders;
    }

    /**
     * 通过反射调用对应的协议处理器
     */
    public ProtoPack.Pack invoke(int cmd, byte[] bytes) throws InvocationTargetException, IllegalAccessException {
        Commander commander = commanders.get(cmd);
        if (commander != null) {
            long begin = System.currentTimeMillis();
            GeneratedMessageV3 params = (GeneratedMessageV3) commander.protobufParser.invoke(null, bytes);
            ProtoPack.Pack res = (ProtoPack.Pack) commander.method.invoke(commander.o, params);
            long used = System.currentTimeMillis() - begin;
            log.debug("协议[{}]处理完成,耗时{}ms", cmd, used);
            return res;
        } else {
            throw new RuntimeException("协议号不存在 cmd=" + cmd);
        }
    }
}

在GameMain中添加代码,在服务器启动的时候对分发器进行初始化

    public static void main(String[] args) {
        // 初始化Spring
        ...

        // 启动Netty服务器
        ...

        // 初始化协议分发器
        initProtoDispatcher(springContext);

        log.info("server start!");
        ...

        //停掉虚拟机
        System.exit(0);
    }
    /**
     * 初始化协议分发器
     */
    private static void initProtoDispatcher(ApplicationContext springContext) {
        ProtoDispatcher dispatcher = springContext.getBean(ProtoDispatcher.class);
        Set<Class> protoHandlerClasses = new HashSet<>();
        Set<Class> classes = ClassPathScanner.scan("com.wfgame.func", true, true, false, null);
        for (Class aClass : classes) {
            if (aClass.getSuperclass() == BaseProtoHandler.class) {
                protoHandlerClasses.add(aClass);
            }
        }
        dispatcher.load(springContext, protoHandlerClasses);
    }

修改NettyMessageHandler.java,将其中我们手动进行消息分发的部分代码进行修改

    @Autowired
    ProtoDispatcher dispatcher;
    
    private void doRead(ChannelHandlerContext ctx, Object msg) throws InvalidProtocolBufferException, InvocationTargetException, IllegalAccessException {
        ByteBuf in = (ByteBuf) msg;
        byte[] bytes = new byte[in.readableBytes()];
        in.readBytes(bytes);
        int cmdId = ProtoPack.getCmdId(bytes);
        // 协议分发
        ProtoPack.Pack response = dispatcher.invoke(cmdId, ProtoPack.decode(bytes).getData());
        ctx.writeAndFlush(response);
    }

进行测试,能正常进行初始化和消息分发。

总结

本节一共做了这么几件事:

  1. 引入了game.conf,对游戏的配置做了统一管理。
  2. 在项目中接入了mongoDb数据库,持久化这一块做了初步的接入。
  3. 引入了logback.xml,后续可以对项目的日志这一块做更多的优化。
  4. 接入了Google的ProtoBuffer,方便后续我们进行协议的开发。
  5. 引入了协议号概念,并且针对协议号做了注解,进行客户端上行协议到具体模块的分发。

猜你喜欢

转载自blog.csdn.net/sinat_18538231/article/details/129741242
今日推荐