H5游戏服务端(四)
之前已经演示出账号挂载体系, 但是没有涉及数据库落地情况, 所以这里单独篇章介绍应该怎么处理数据实体落地.
这里采用 Spring 集成的 ORM(Object Relational Mapping) 处理, 方便将定义的实体写入数据库, 比较常用的 ORM 有:
MyBatis: 这个相对国内用的比较多, 比较灵活可以手写 SQL, 更加接近于原生底层处理.JPA: 外国相对用的比较多, 映射类对象更加彻底, 性能方便可能有所损耗但学习方便.
这里采用 JPA 方式, 方便引入数据库驱动相关:
<!-- 集成库对象 -->
<dependencies>
<!-- 其他略 -->
<!-- JPA ORM -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- MariaDB 驱动 -->
<dependency>
<groupId>org.mariadb.jdbc</groupId>
<artifactId>mariadb-java-client</artifactId>
<version>3.1.4</version>
</dependency>
<!-- Lombok, 节约编写 getter/setter 时间, 但是有性能损耗 -->
<!-- 这里实际上来说看你侧重于开发效率还是性能效率, 如果性能要求高不推荐引入 Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.28</version>
</dependency>
</dependencies>
这里 MariaDB 驱动可以更换成其他比如 mysql|postgresql, Lombok 有性能损耗引入方面需要看自己需求处理
另外我习惯性将玩家实体叫做 Model(模型), 所以比较习惯用此命名方式, 而 JPA 则是称为 Entity.
最后编写下数据库配置处理:
# 本地数据库默认配置
spring.datasource.url=jdbc:mariadb://127.0.0.1:3306/game_1
spring.datasource.username=game_user
spring.datasource.password=game_user
spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
spring.datasource.hikari.maximum-pool-size=2
spring.datasource.tomcat.init-s-q-l=SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci
# JPA配置 - 测试开发配置
# ddl-auto 配置比较关键
# create-drop: 启动服务时候在数据库构建表, 退出直接删除清空所有表和数据, 用于开发时候测试
# update: 自动对表结构字段比对更新, 如果表内部字段已经存在可能不灰直接更新, 需要自己修改
# validate: 正式环节的配置, 在运行时验证表和外键但不会进行修改, 正式数据库一般运维处理
# 其他则是数据库日志展示相关内容
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
spring.jpa.open-in-view=false
需要在数据库生成
game_1为名的数据库代表开了个游戏默认1服, 还有这里账号密码采用game_user来做本地测试开发.
确认配置完成之后, 接下来就是具体的数据实体落地逻辑.
JPA编写
首先重构之前 PlayerModel 为 PlayerInfoModel, 用来处理玩家信息数据.
我习惯采用IDE命名法, 驼峰和下划线都是采用 分类A + 分类B 合要素合并; 如玩家信息PlayerInfo|玩家分数PlayerScore; 这种命名设计可以有效加快 IDE 全局检索时候自带的分类, 查询 Player 在IDE结果当中就可以分出 PlayerInfo|PlayerScore 排序分类.
初定的玩家实体主体如下, 这个比较标准的玩家实体模板:
/**
* 玩家实体数据
*/
@Entity
@Table(name = "player_info")
public class PlayerInfoModel implements Serializable {
/**
* 玩家第三方登录uid, 这里采用主键自递增记录
*/
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(nullable = false, columnDefinition = "BIGINT COMMENT '主键ID,同时也是标识玩家UID'")
private Long uid;
/**
* 玩家昵称
*/
@Column(nullable = false, columnDefinition = "VARCHAR(64) COMMENT '玩家昵称'")
private String nickname;
/**
* 玩家游戏内金币
*/
@Column(nullable = false, columnDefinition = "BIGINT COMMENT '玩家游戏内金币数量'")
private Integer gold;
/**
* 玩家目前等级, 如果游戏是轻度没有等级体系可以删除
*/
@Column(nullable = false, columnDefinition = "INT COMMENT '玩家目前等级'")
private Integer level = 1;
/**
* 玩家目前经验, 如果游戏是轻度没有等级体系可以删除
*/
private Integer exp = 0;
/**
* 玩家疲劳点数, 比较常见副本疲劳值设定
* 二次元游戏比较多关卡消耗疲劳点数, 充值提升疲劳上限进入副本
*/
@Column(nullable = false, columnDefinition = "INT COMMENT '玩家疲劳点数'")
private Integer fatigue;
/**
* 玩家道具信息: { "1": 10, "2": 20 }, 后续这里会映射成MAP格式, 目前先采取String
*/
@Column(nullable = false, columnDefinition = "JSON COMMENT 'JSON标识保存玩家道具'")
private String item = "{}";
/**
* 场景ID,
*/
@Column(nullable = false, columnDefinition = "INT COMMENT '某些战斗没结束需要重新进入需要读取该值继续战斗'")
private Integer scene = 0;
/**
* 账号的创建时间
*/
@Column(nullable = false, columnDefinition = "BIGINT COMMENT '账号创建时间'")
private Long createTime;
/**
* 账号登录时间
*/
@Column(nullable = false, columnDefinition = "BIGINT COMMENT '账号登录时间'")
private Long updateTime;
/* 以下是手动编写的 setter/getter/toString, 如果引入 lombok 就不用编写以下样板代码 ------------- */
public void setNickname(String nickname) {
this.nickname = nickname;
}
public String getNickname() {
return nickname;
}
public void setScene(Integer scene) {
this.scene = scene;
}
public Integer getScene() {
return scene;
}
public void setUid(Long uid) {
this.uid = uid;
}
public Long getUid() {
return uid;
}
public void setGold(Integer gold) {
this.gold = gold;
}
public Integer getGold() {
return gold;
}
public void setCreateTime(Long createTime) {
this.createTime = createTime;
}
public Long getCreateTime() {
return createTime;
}
public void setExp(Integer exp) {
this.exp = exp;
}
public Integer getExp() {
return exp;
}
public void setItem(String item) {
this.item = item;
}
public String getItem() {
return item;
}
public void setLevel(Integer level) {
this.level = level;
}
public Integer getLevel() {
return level;
}
public void setUpdateTime(Long updateTime) {
this.updateTime = updateTime;
}
public Long getUpdateTime() {
return updateTime;
}
public void setFatigue(Integer fatigue) {
this.fatigue = fatigue;
}
public Integer getFatigue() {
return fatigue;
}
@Override
public String toString() {
return "PlayerInfoModel{" +
"uid=" + uid +
", nickname='" + nickname + '\'' +
", gold=" + gold +
", level=" + level +
", exp=" + exp +
", fatigue=" + fatigue +
", item='" + item + '\'' +
", scene=" + scene +
", createTime=" + createTime +
", updateTime=" + updateTime +
'}';
}
}
按照游戏品类的不同, 玩家实体对象字段要素也有所不同, 这里节选些比较常规的要素
- 金币|游戏货币: 这类是游戏内部的货币, 游戏内部交易直接对象, 属于游戏资源
- 钻石|充值点数: 这里是直接现金购买货币, 常见的Q币来转化购买非游戏资源, 如不提供金币购买时装等
- 等级/经验值: 没什么好说, 主要是等级换算机制; 有的是记录总经验值后不断扣除策划配表最后等级, 有的则是直接升级时候等级加1多余经验值扣除换算下一级
- 疲劳值|体力: 常见游戏副本进去之后会扣除玩家生成点数进场, 该值随着现实时间流逝递增补充, 也可以直接通过现金购买
这里就是比较基础的玩家模板, 起步开发的时候可以按照这套玩家实体模板起步.
注: 如果游戏业务复杂的时候不止玩家信息表, 有的数据会分表落地到其他比如 bag(背包)/box(仓库) 表等
对于 JPA 除了数据实体, 还需要生成个继承数据工厂接口:
/**
* 玩家信息工厂, 用来给做数据 CRUD 操作
*/
public interface PlayerInfoRepository extends CrudRepository<PlayerInfoModel, Long> {
}
这里先测试 Websocket 之后落地保存到数据库, 这里更改之前 PlayerLogic 业务逻辑:
/**
* 线上内存挂载服务
* 注解 @Service 会把对象挂载在进程内存, 这也是我们将玩家实体挂载内存的关键服务
* 注意这里先简单编写功能, 后续扩展|封装功能按照自己需求处理
*/
@Service
public class PlayerInfoServer {
/**
* 数据工厂
*/
final PlayerInfoRepository repository;
/**
* 进程内存数据
*/
final HashMap<Long, PlayerInfoModel> players = new HashMap<>();
/**
* 需要更新的玩家标示, 这里做好线程安全, 标识可能会多个线程共享
*/
final List<Long> marks = new CopyOnWriteArrayList<>();
/**
* 构造方法
*
* @param repository 数据工厂
*/
public PlayerInfoServer(PlayerInfoRepository repository) {
this.repository = repository;
}
/**
* 获取玩家实体
*
* @param uid 玩家ID
* @return PlayerInfoModel|null
*/
public PlayerInfoModel getByUid(@NonNull Long uid) {
// 检索内存实体
PlayerInfoModel model = players.get(uid);
if (model != null) {
return model;
}
// 检索数据库实体
model = repository.findById(uid).orElse(null);
if (model != null) {
players.put(uid, model);
}
return model;
}
/**
* 保存数据
*
* @param model 玩家模型
* @return PlayerInfoModel
*/
public PlayerInfoModel save(@NonNull PlayerInfoModel model) {
return repository.save(model);
}
/**
* 对指定玩家标识为需要数据落地
*
* @param uid 玩家ID
*/
public void mark(@NonNull Long uid) {
if (!marks.contains(uid)) {
marks.add(uid);
}
}
/**
* 写入数据要求异步保存
*
* @param uid 玩家ID
* @param model 新的数据模型
*/
public void mark(@NonNull Long uid, @NonNull PlayerInfoModel model) {
players.put(uid, model);
mark(uid);
}
/**
* 获取异步任务
*
* @return List
*/
public List<Long> getMarks() {
return marks;
}
/**
* 清理标识
*
* @param uid 玩家标识
*/
public void clearMark(Long uid) {
marks.remove(uid);
}
}
最后更新玩家模块, 测试数据异步落地的功能 PlayerLogic :
/**
* 玩家信息 Actor
*/
@EnableActor(owner = PlayerLogic.class)
public class PlayerLogic extends ActorConfigurer {
/**
* 日志句柄
*/
final Logger logger = LoggerFactory.getLogger(PlayerLogic.class);
/**
* 玩家数据实体服务
*/
final PlayerInfoServer playerInfoServer;
/**
* 落地任务, 用来取消任务
*/
ScheduledFuture<?> event = null;
/**
* 初始化
*
* @param playerInfoServer 玩家数据实体服务
*/
public PlayerLogic(PlayerInfoServer playerInfoServer) {
this.playerInfoServer = playerInfoServer;
}
/**
* 初始化方法
* 这里其实应该加载测试配表, 提供给默认创建玩家信息数据
*
* @throws Exception Error
*/
@Override
public void init() throws Exception {
super.init();
// todo: 启动时加载策划配表
// 启动的时候定时运行异步数据库写入任务
// 这里3秒检索下需要异步落地的任务, 具体可以自己调整
ActorEventContainer container = getContainer();
if (container != null) {
event = container.scheduleAtFixedRate(this::flushPlayer, 3L, 3L, TimeUnit.SECONDS);
}
}
/**
* 退出方法
* 将挂载更新过的实体写入到数据库内部完成落地
*
* @throws Exception Error
*/
@Override
public void destroy() throws Exception {
super.destroy();
// 确认任务之后取消掉默认任务
if (event != null) {
event.cancel(false);
}
// 退出游戏进程的时候数据最后落地
flushPlayer();
}
/**
* 写入数据落地
*/
public void flushPlayer() {
for (Long mark : playerInfoServer.getMarks()) {
// 获取目前内存挂载的数据
PlayerInfoModel model = playerInfoServer.getByUid(mark);
if (model != null) {
// 落地保存进去
logger.debug("写入数据落地数据 = {}", model);
playerInfoServer.save(model);
playerInfoServer.clearMark(mark);
}
}
}
/**
* 服务端内部调用的指令, 确认玩家是否存在, 不存在就读表配置生成实体
* 注意: 这里的 state 可以自己定义, 这里设为 3 用来提供给进程互相调用
*/
@ActorMapping(value = 300, state = {3})
public void check(Long uid) {
// 测试同步写入数据库, 查找玩家数据, 如果查询到会被挂载在内存中等待以后调用
PlayerInfoModel model = playerInfoServer.getByUid(uid);
if (model == null) {
// 不存在玩家就数据库同步生成玩家对象
model = new PlayerInfoModel();
model.setUid(uid);
model.setLevel(1);
model.setExp(0);
model.setGold(1000);// 注册赠送基础资源
model.setNickname(String.format("注册玩家%d", uid));
model.setFatigue(100); // 默认体力值
model.setScene(0);
model.setCreateTime(System.currentTimeMillis());
model.setUpdateTime(0L);
// 数据先同步落地到数据库
playerInfoServer.save(model);
}
}
/**
* 玩家追加游戏货币 - 用于测试
* Example: { "value":302,"args":{ }}
*
* @param runtime 运行时
* @param session 会话
* @param args 参数
*/
@ActorMapping(value = 302, state = {1})
public void addGold(WebsocketApplication runtime, WebSocketSession session, JsonNode args) {
// 测试追加100金币
// 获取玩家实体
Long uid = runtime.getUid(session);
PlayerInfoModel model = playerInfoServer.getByUid(uid);
// 直接玩家添加 100 金币等待延迟写入
model.setGold(model.getGold() + 100);
playerInfoServer.mark(uid, model);
}
}
现在登录玩家可以测试推送 302 接口, 看看是否能够正确数据落地, 至于数据落地间隔可以按自己自身需求来异步写入.