MeteorCat / H5游戏服务端(四)

Created Wed, 24 Jan 2024 15:19:34 +0800 Modified Wed, 29 Oct 2025 23:24:59 +0800

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编写

首先重构之前 PlayerModelPlayerInfoModel, 用来处理玩家信息数据.

我习惯采用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 接口, 看看是否能够正确数据落地, 至于数据落地间隔可以按自己自身需求来异步写入.