这里写目录标题
- 前言
- 正文
- 添加依赖
- 安装MongoDB
- 添加MongoDB相关配置
- 创建MongoContext类
- 尝试初始化DB连接
- 实现注册功能
- 测试注册功能
- 实现登录逻辑
- 测试登录流程
- 结语
- 下节预告
前言
游戏服务器中, 很重要的一点就是如何保存玩家的游戏数据.
当一个服务端架构趋于稳定且功能全面, 开发者会发现服务端的业务开发基本就围绕着CRUD来展开,
即业务数据的创建 \ 查找 \ 更新 \ 删除.
本节内容我们就将MongoDB作为持久化数据库引入项目中.
正文
添加依赖
在build.gradle中添加依赖
implementation 'org.springframework.data:spring-data-mongodb:4.2.4'
implementation 'org.mongodb:mongodb-driver-sync:5.0.0'
安装MongoDB
MongoDB的安装步骤我就不一一解释了,
读者可以选择使用docker快速创建一个MongoDB容器,
也可以使用MongoDB官方提供的免费云数据库进行练习 https://www.mongodb.com/cloud/atlas/register
也可以在本地运行一个MongoDB进程
添加MongoDB相关配置
在common模块下添加common.conf配置文件以及CommonConfig类.
mongodb.host=mongodb://%s:%s@localhost:27017/%s?retryWrites=true&w=majority
mongodb.user=root
mongodb.password=123456
# 登录服数据库名
mongodb.login.db=login
@Getter
@Component
@PropertySource("classpath:common.conf")
public class CommonConfig {
@Value("${mongodb.host}")
String mongoHost;
@Value("${mongodb.user}")
String mongoUser;
@Value("${mongodb.password}")
String mongoPassword;
@Value("${mongodb.login.db}")
String loginDbName;
}
创建MongoContext类
为了管理MongoDB连接, 创建一个MongoContext类用于初始化mongodb连接与管理.
package org.common.mongo;
import ...
/**
* Mongo上下文
*/
@Slf4j
@Component
public class MongoContext {
private MongoClient mongoClient;
private MongoTemplate mongoTemplate;
public void initMongoContext(String mongoUrl, String user, String password, String dbName) {
String url = String.format(mongoUrl, user, password, dbName);
log.info(url);
MongoClientSettings.Builder settings = MongoClientSettings.builder();
settings.applyConnectionString(new ConnectionString(url));
MongoClient mongoClient = MongoClients.create(settings.build());
mongoTemplate = new MongoTemplate(mongoClient, dbName);
log.info("mongo server ok!");
}
}
其中MongoClient 是 mongodb-driver-sync库的核心, 用于直接连接和操作 MongoDB 数据库。
而MongoTemplate是spring-data-mongodb库的核心, 它基于MongoClient进行了接口封装, 提供了比 MongoClient 更丰富的功能,包括更简洁的查询构建、更强大的映射支持。
在MongoContext外层封装一层MongoService用来实现Mongo增删改查相关接口。
@Slf4j
@Component
public class MongoService {
private MongoContext mongoContext;
public void initMongoService(String mongoUrl, String user, String password, String dbName) {
MongoContext mongoContext = new MongoContext();
mongoContext.initMongoContext(mongoUrl, user, password, dbName);
this.mongoContext = mongoContext;
}
public MongoContext getMongoContext() {
return mongoContext;
}
/**
* 插入数据
*/
public <T extends BaseCollection> boolean insert(T obj) {
mongoContext.getMongoTemplate().insert(obj);
return true;
}
/**
* 查询数据
*/
public <T extends BaseCollection> BaseCollection findById(Object id, Class<T> clz) {
T object = mongoContext.getMongoTemplate().findById(id, clz);
return object;
}
public <T extends BaseCollection> T findOneByQuery(Criteria criteria, Class<T> clz) {
Query query = Query.query(criteria);
T object = mongoContext.getMongoTemplate().findOne(query, clz);
return object;
}
//TODO 删
//TODO 改
先实现了增查,以便我们后面实现账号注册登录功能来举例。
尝试初始化DB连接
在LoginServer的initServer下面增加MongoContext的初始化代码. 然后运行.
@Override
protected void initServer() {
LoginConfig config = SpringUtils.getBean(LoginConfig.class);
// actor初始化
AkkaContext.initActorSystem();
// netty启动
NettyServer nettyServer = SpringUtils.getBean(NettyServer.class);
nettyServer.start(config.getPort());
// mongo服务启动
CommonConfig commonConfig = SpringUtils.getBean(CommonConfig.class);
MongoService mongoService = SpringUtils.getBean(MongoService.class);
mongoService.initMongoService(commonConfig.getMongoHost(), commonConfig.getMongoUser(), commonConfig.getMongoPassword(), commonConfig.getLoginDbName());
log.info("LoginServer start!");
}
启动LoginServer得到结果:
实现注册功能
上一节我们使用Protobuf创建了注册协议, 从客户端发送到了登录服进行解析.
接下来我们将注册的账号密码进行入库以便后续取出使用.
我们先构思一下一个账号应该有的数据, 创建一个AccountCollection类, 用于映射Mongo数据库中的AccountCollection表.
@Document
public class AccountCollection extends BaseCollection {
@Id
private long accountId;
private String accountName;
private String password;
// getter & setter
}
很好理解, @Document注解表示该类是一个mongo的文档映射类, @Id表示这个字段作为该文档的主键.
BaseCollection目前就是一个空的Abstract类, 实现了Serializable接口.
public abstract class BaseCollection implements Serializable {
}
接下来修改ConnectActor中的onClientUpMsg方法, 该方法负责接收客户端上行协议并进行解包.
private Behavior<BaseMsg> onClientUpMsg(ClientUpMsg msg) throws InvalidProtocolBufferException {
Pack decode = PackCodec.decode(msg.getData());
log.info("receive client up msg. cmdId = {}", decode.getCmdId());
byte[] data = decode.getData();
if (decode.getCmdId() == ProtoEnumMsg.CMD.ID.PLAYER_REGISTER_VALUE) {
// 注册协议
LoginProtoHandler.onPlayerRegisterMsg(this, PlayerMsg.C2SPlayerRegister.parseFrom(data));
}
return this;
}
我增加了一个LoginProtoHandler类用于处理登录相关的业务逻辑.
若是将所有的代码都写在ConnectActor, 将来这里的代码会越来越长最终变得不可控.
使用单一职责的思想, 使ConnectActor只负责进行协议的解包, 具体业务逻辑由各个功能模块自己实现, 将来游戏服我们也会这么处理, 这里只简单提一嘴.
@Slf4j
public class LoginProtoHandler {
public static void onPlayerRegisterMsg(ConnectActor actor, PlayerMsg.C2SPlayerRegister up) {
log.info("player register, accountName = {}, password = {}", up.getAccountName(), up.getPassword());
long accountId = 1L;
String accountName = up.getAccountName();
String password = up.getPassword();
AccountCollection accountCollection = new AccountCollection();
accountCollection.setAccountId(accountId);
accountCollection.setAccountName(accountName);
accountCollection.setPassword(password);
MongoService mongoService = SpringUtils.getBean(MongoService.class);
boolean res = mongoService.insert(accountCollection);
log.info("create account collection. accountId = {}, accountName = {}, res = {}", accountId, accountName, res);
// 回包
PlayerMsg.S2CPlayerRegister.Builder builder = PlayerMsg.S2CPlayerRegister.newBuilder();
builder.setSuccess(res);
byte[] down = PackCodec.encode(new Pack(ProtoEnumMsg.CMD.ID.PLAYER_REGISTER_VALUE, builder.build().toByteArray()));
actor.getCtx().writeAndFlush(down);
}
}
LoginProtoHandler负责处理客户端上行的关于账号登录相关的协议.
onPlayerRegisterMsg负责账号注册相关逻辑.
由于账号Id的生成规则我还没想好, 先用一个1L来进行测试, 然后我们调用MongoService的insert方法, 将accountCollection写入mongo. 并进行回包.
修改ClientMain, 使我们在输入"register"时, 发送注册协议进行账号的注册.
@Override
protected void handleBackGroundCmd(String cmd) {
if (cmd.equals("test")) {
channel.writeAndFlush("test".getBytes());
} else if (cmd.equals("register")) {
PlayerMsg.C2SPlayerRegister.Builder builder = PlayerMsg.C2SPlayerRegister.newBuilder();
builder.setAccountName("clintAccount");
builder.setPassword("123456");
Pack pack = new Pack(ProtoEnumMsg.CMD.ID.PLAYER_REGISTER_VALUE, builder.build().toByteArray());
byte[] data = PackCodec.encode(pack);
channel.writeAndFlush(data);
} else if (cmd.equals("login")) {
PlayerMsg.C2SPlayerLogin.Builder builder = PlayerMsg.C2SPlayerLogin.newBuilder();
builder.setAccountName("clintAccount");
builder.setPassword("123456");
Pack pack = new Pack(ProtoEnumMsg.CMD.ID.PLAYER_LOGIN_VALUE, builder.build().toByteArray());
byte[] data = PackCodec.encode(pack);
channel.writeAndFlush(data);
}
}
这里顺便把登录的协议一并附上, 没啥技术含量就不多讲.
测试注册功能
启动登录服, 启动客户端, 客户端控制台输入register.
看看登录服的打印:
再看看mongo中数据是否写入成功:
可以看到确实写入了一条AccountCollection文档, 字段_id为每个collection的主键, 他的数值也就是我们使用@Id注解标识的字段, 另外_class是spring-data-mongo库为我们添加的类名, 用于读取数据时反序列化用. 需要注意的是如果你的AccountCollection类修改了包名或类名, 这里反序列化就会失败, 需要额外添加处理.
实现登录逻辑
登录与注册相差不大, 只是把添加数据修改为查找数据.
修改LoginProtoHandler, 添加onPlayerLoginMsg
public static void onPlayerLoginMsg(ConnectActor actor, PlayerMsg.C2SPlayerLogin up) {
String accountName = up.getAccountName();
String password = up.getPassword();
MongoService mongoService = SpringUtils.getBean(MongoService.class);
Criteria criteria = Criteria.where("accountName").is(accountName);
AccountCollection accountCollection = mongoService.findOneByQuery(criteria, AccountCollection.class);
PlayerMsg.S2CPlayerLogin.Builder builder = PlayerMsg.S2CPlayerLogin.newBuilder();
if (accountCollection == null) {
log.warn("login without account. accountName = {}", accountName);
builder.setSuccess(false);
} else if( !accountCollection.getPassword().equals(password) ) {
log.warn("login password error. accountName = {}", accountName);
builder.setSuccess(false);
} else {
log.info("login success. accountName = {}, accountId = {}", accountName, accountCollection.getAccountId());
builder.setSuccess(true);
builder.setAccountId(accountCollection.getAccountId());
}
byte[] down = PackCodec.encode(new Pack(ProtoEnumMsg.CMD.ID.PLAYER_LOGIN_VALUE, builder.build().toByteArray()));
actor.getCtx().writeAndFlush(down);
}
这里我们使用Criteria创建一个条件, 根据accountName来查找一条mongo中的文档, 然后对比密码是否一致, 来实现登录流程.
修改ConnectActor使其对Login协议进行解包并分发到LoginProtoHandler中.
private Behavior<BaseMsg> onClientUpMsg(ClientUpMsg msg) throws InvalidProtocolBufferException {
Pack decode = PackCodec.decode(msg.getData());
log.info("receive client up msg. cmdId = {}", decode.getCmdId());
byte[] data = decode.getData();
if (decode.getCmdId() == ProtoEnumMsg.CMD.ID.PLAYER_REGISTER_VALUE) {
// 注册协议
LoginProtoHandler.onPlayerRegisterMsg(this, PlayerMsg.C2SPlayerRegister.parseFrom(data));
} else if (decode.getCmdId() == ProtoEnumMsg.CMD.ID.PLAYER_LOGIN_VALUE) {
// 登录协议
LoginProtoHandler.onPlayerLoginMsg(this, PlayerMsg.C2SPlayerLogin.parseFrom(data));
}
return this;
}
测试登录流程
启动登录服, 启动客户端, 客户端控制台输入login.
看看登录服的打印:
结语
本节我们将MongoDB引入到项目中作为我们的持久化数据库来使用, 并通过注册登录的两个小例子, 来展示spring-data-mongo这个库的用法.
我们只需要定好映射类, 便算是搭建好了一张表结构, 使用起来还是很简单的, 当然我们后面还会继续对其封装, 减少业务开发人员对MongoService的直接调用.
另外, 我们实现的注册登录例子十分粗糙, 其实只是做了一次mongo的读写, 对于游戏服务器来说, 注册登录功能是重中之重, 它维护着玩家的账号安全, 同时也是我们整个游戏的入口.
对于账号注册, 我们还需要做 账号id生成, 账号名重复性检测, accountId与accountName的缓存映射, 后期还有sdk接入等工作.
对于账号登录, 我们还需要做 登录状态修改, 多点登录顶号, 分配游戏服 等工作.
这些我们后面会继续优化.
下节预告
下一节笔者将会引入redis作为游戏的缓存数据库. 当游戏玩家变多, 使用缓存数据库可以大幅减小数据库读写压力. 同时redis的特性可以做很多事情, 比如我们可以用redis的incrby来做账号的递增而不怕多进程中为玩家分配到同一个id; 还可以用作为分布式锁来实现一些需要多进程同时处理的业务功能.