从 Pomelo 到 TSRPC:打造下一代有状态单实例游戏服务器

从 Pomelo 到 TSRPC:打造下一代有状态单实例游戏服务器

引言

在游戏服务器开发领域,Pomelo 框架曾经是许多开发者的首选。它的集群架构和路由机制为早期大规模多人在线游戏提供了坚实的基础。然而,随着游戏技术的演进和开发需求的变化,我们开始思考:是否存在一种更简洁、更高效的方式来构建游戏服务器?

今天,我想向大家介绍我们团队正在开发的一款全新游戏服务器框架——DonkJS。这是一个基于 TSRPC 的有状态单实例服务器,它抛弃了传统的集群加路由模式,采用了一种更直接、更灵活的服务提供方式。

技术选型:为什么选择 TSRPC?

在开始项目之前,我们评估了多种通信框架,最终选择了 TSRPC(TypeScript Remote Procedure Call)。做出这个选择主要基于以下几个原因:

1. 类型安全

作为 TypeScript 原生的 RPC 框架,TSRPC 提供了端到端的类型检查。这意味着客户端和服务器之间的通信协议会在编译时进行验证,大大减少了运行时错误。

2. 高性能

TSRPC 采用了二进制序列化(TSBuffer),比 JSON 序列化更高效。对于游戏服务器来说,这意味着更低的延迟和更高的吞吐量。

3. 简单易用

与复杂的微服务框架不同,TSRPC 提供了简洁的 API,使开发者能够快速上手并专注于业务逻辑的实现。

4. 灵活性

TSRPC 支持多种传输协议(HTTP、WebSocket),并提供了丰富的扩展接口,使我们能够根据游戏需求进行定制开发。

架构设计:有状态单实例服务器

DonkJS 采用了有状态单实例服务器架构,这与传统的无状态微服务架构有很大不同。

核心设计理念

  1. 单实例,全状态
  • 服务器保持游戏的完整状态,包括玩家数据、游戏世界状态等
  • 避免了分布式状态同步的复杂性和性能开销
  1. 路径化服务提供
  • 开发者通过定义清晰的路径地址来提供服务
  • 客户端直接通过路径调用相应的服务,无需经过复杂的路由层
  1. 组件化架构
  • 服务器功能被划分为多个独立组件
  • 组件之间通过明确的接口通信,便于维护和扩展

与 Pomelo 架构的对比

特性 Pomelo DonkJS
架构模式 无状态集群 + 路由 有状态单实例 + 路径服务
通信协议 自定义二进制协议 TSRPC + TSBuffer
服务发现 基于 ZooKeeper 直接路径访问
状态管理 分布式状态同步 单实例全状态
开发复杂度
性能开销 中(路由和同步开销) 高(避免了网络开销)

核心特性介绍

1. 实时 WebSocket 服务

DonkJS 内置了高性能的 WebSocket 游戏服务器,支持:

  • 低延迟的双向通信
  • 消息广播和定向发送
  • 连接管理和心跳检测

2. 基于组件的模块化设计

服务器功能通过组件化方式实现:

typescript

Apply

// 组件注册示例
const globalVarComp: GlobalVarComponent = new GlobalVarComponent();
ComponentManager.instance.register(EComName.GlobalVarComponent, globalVarComp);

// 组件启动
await ComponentManager.instance.startAll();

3. 灵活的日志系统

基于 log4js 实现,支持:

  • 多级别日志输出
  • 按日期和大小滚动的日志文件
  • 自定义 CSV 格式日志(用于数据分析)

4. 优雅的关闭机制

支持在各种环境下(包括 PM2)的优雅关闭:

typescript

Apply

process.on("SIGINT", () => {
  gameLogger.log("SIGINT received, shutting down gracefully...");
  stopFrontServer();
});

process.on("SIGBREAK", () => {
  gameLogger.log("SIGBREAK received, shutting down gracefully...");
  stopFrontServer();
});

开发体验:通过路径提供服务

在 DonkJS 中,开发者通过定义路径来提供服务,客户端直接通过这些路径调用服务。这种方式大大简化了开发流程:

服务定义示例

typescript

Apply

// 定义服务接口
export interface ReqJoinRoom {
  roomId: string;
  playerName: string;
}

export interface ResJoinRoom {
  success: boolean;
  playerId: string;
  roomInfo: RoomInfo;
}

// 实现服务
export async function joinRoom(req: ReqJoinRoom): Promise<ResJoinRoom> {
  // 业务逻辑实现
  return {
    success: true,
    playerId: generatePlayerId(),
    roomInfo: getRoomInfo(req.roomId)
  };
}

客户端调用示例

typescript

Apply

// 客户端直接通过路径调用服务
const result = await client.callApi('/room/join', {
  roomId: 'room123',
  playerName: 'Player1'
});

未来规划:向分布式演进

虽然 DonkJS 当前是一个单实例服务器,但我们已经为未来的分布式扩展做好了准备:

  1. 水平扩展支持
  • 计划通过区域分片(Sharding)实现水平扩展
  • 不同区域的服务器保持独立状态,通过网关进行通信
  1. 微服务集成
  • 将非核心功能(如统计、分析)拆分为独立微服务
  • 使用 TSRPC 实现服务之间的高效通信
  1. 容器化部署
  • 支持 Docker 和 Kubernetes 部署
  • 提供自动化的部署和管理工具

为什么选择有状态单实例?

您可能会问:在分布式架构盛行的今天,为什么我们还要开发一个单实例服务器?

适合的场景

  1. 中小型游戏
  • 对于玩家规模在数千人以下的游戏,单实例服务器已经足够
  • 避免了分布式架构带来的复杂性
  1. 快速迭代开发
  • 单实例架构便于快速开发和测试
  • 适合游戏原型开发和早期版本迭代
  1. 状态密集型游戏
  • 对于需要频繁访问和修改状态的游戏(如实时策略游戏、沙盒游戏)
  • 单实例架构可以避免分布式状态同步的开销

结语

DonkJS 代表了我们对游戏服务器架构的一种新思考。它不是要取代所有传统框架,而是为游戏开发者提供了一种新的选择——一种更简洁、更高效、更适合现代游戏开发需求的选择。

我们相信,在许多场景下,有状态单实例服务器架构能够提供比传统集群架构更好的性能和开发体验。而通过 TSRPC 实现的路径化服务提供方式,则为游戏开发者打开了一扇新的大门。

如果您对 DonkJS 感兴趣,欢迎访问我们的 GitHub 仓库,了解更多技术细节并参与到项目的开发中来!

[GitHub 仓库链接:https://github.com/lyh1091106900/donkjs.git)

关于作者 :本文由 DonkJS 开发团队撰写,我们致力于打造下一代游戏服务器框架,为游戏开发者提供更好的开发体验和性能表现。

4赞

可以,不然就几个用户天天分布式,,

n个项目都用了快四年了 tsrpc真是棒。

哈哈哈,太多产品就没有同时在线100人的。pomelo写起来还挺累的。太复杂了。很多人对于路由理解都不一定了解到位

是的。donkjs规范了目录结构。如果有计算密集型的请求可以用bulljs扩展。服务一个1000+的小游戏项目完全没有问题的

新增 装饰器 ,这个装饰器的作用是玩家的请求可以在装饰器的修饰下成为一个队列。主要是为了满足满足一些条件下一些异步请求需要顺序执行时使用。最常见的是在一些有检测的情况。比如只有5枚金币,
每次需要消耗5枚。不使用队列会导致消耗的异常
import { queueByUser } from ‘…/common/SysDecorate’;
import { UserInfo } from ‘…/shared/type/Type’;
import { db } from ‘…/util/db’;

/**

  • 用户服务类
    /
    export class UserService {
    /
    *

    • 更新用户信息
    • @param userInfo 用户信息(作为第一个参数)
    • @param nickname 新昵称
    • @param avatar 新头像
    • @returns 更新结果
      */
      @queueByUser()
      async updateUserInfo(userInfo: UserInfo, nickname: string, avatar: string): Promise {
      // 业务逻辑:更新用户信息到数据库
      await db.collection(‘users’).updateOne(
      { uid: userInfo.uid, zone: userInfo.zone },
      { $set: { nickname, avatar, updateTime: new Date() } }
      );
      return true;
      }

    /**

    • 增加用户金币

    • @param userInfo 用户信息(作为第一个参数)

    • @param amount 增加数量

    • @returns 更新后的金币数量
      */
      @queueByUser()
      async addUserGold(userInfo: UserInfo, amount: number): Promise {
      // 业务逻辑:增加用户金币
      const result = await db.collection(‘userAssets’).findOneAndUpdate(
      { uid: userInfo.uid, zone: userInfo.zone },
      { $inc: { gold: amount } },
      { returnDocument: ‘after’ }
      );

      return result.value?.gold || 0;
      }

    /**

    • 用户背包物品操作

    • @param userInfo 用户信息(作为第一个参数)

    • @param itemId 物品ID

    • @param action 操作类型(add/remove/use)

    • @param count 数量

    • @returns 操作结果
      */
      @queueByUser()
      async processUserItem(
      userInfo: UserInfo,
      itemId: string,
      action: ‘add’ | ‘remove’ | ‘use’,
      count: number
      ): Promise<{ success: boolean; message: string }> {
      // 业务逻辑:处理用户背包物品
      if (action === ‘add’) {
      await db.collection(‘userInventory’).updateOne(
      { uid: userInfo.uid, zone: userInfo.zone, itemId },
      { $inc: { count } },
      { upsert: true }
      );
      } else if (action === ‘remove’) {
      // 移除物品逻辑
      } else if (action === ‘use’) {
      // 使用物品逻辑
      }

      return { success: true, message: 操作成功:${action} ${count}个物品${itemId} };
      }
      }

// 使用示例
const userService = new UserService();

// 用户信息
const userInfo: UserInfo = {
uid: 123456,
zone: ‘zone1’,
name: ‘玩家1’,
visualId: 1001
};

// 调用服务方法
await userService.updateUserInfo(userInfo, ‘新昵称’, ‘new_avatar_url’);
await userService.addUserGold(userInfo, 100);
await userService.processUserItem(userInfo, ‘item_001’, ‘add’, 5);

1赞

下一步引入bulljs 完成计算密集型的扩展。小游戏场景下,整个游戏服务器承载能力不在水平扩展情况下可以支持1000人。这就是nodejs最舒服的场景。水平扩展属于外部又包了一层框架

我早几年就自行实现了基于ts websocket的rpc调用

这里的RPC 具体是指什么,如何理解?优点?

rpc框架说明具有服务器的互相调用的能力。我还没写rpc组件。不过我觉得这个框架如果就是支持1000人的服务器规模不需要rpc组件。可以用bulljs进行计算密集型的工作。将游戏的分区集合进来多建立几个独立的实例增加玩家数量

bulljs 是个很棒的队列方案

支持,已star

谢谢,未来的功能主要通过组件的形式扩展。需要提供数据库组件吗?这个组件需要配合目录。不同得数据库会有不同的目录结构,我不是很清楚需要完成到怎样的步骤比较合适

需要数据库组件

没Q群之类交流群吗?总不能让其他人自己摸索吧?

好的,忘记了。我创建一个qq群先 。群号:
252142505

下一个更新

下一个阶段,对json配置进行规范,将引入zod 敬请期待。谢谢大家。群号:
252142505

基于 DonkJS 的强入侵式数据库组件设计:支持多区架构的实现

引言

在实时服务器开发中,数据库的设计和管理是核心部分。DonkJS 的数据库组件采用了强入侵式设计,要求服务器必须配置三个数据库:globalserverzone,其中 zone 支持多个实例。这种设计使得服务器天然支持分区架构,适合高并发和多区场景。


数据库组件设计概述

DonkJS 的数据库组件通过 MongoComponent 实现,负责管理数据库连接和模型注册。组件的强入侵式设计确保了以下特性:

  1. 全局数据库(global:存储全局配置和数据。

  2. 服务器数据库(server:存储与当前服务器实例相关的数据。

  3. 分区数据库(zone:支持多个分区,每个分区独立存储数据。

这种设计使得服务器能够轻松扩展分区,适应不同的业务需求。


数据库配置

数据库配置文件位于 src/sysconfig/development/db_config.json,定义了 globalserverzone 的连接信息。

示例配置:


{

  "db_global": {

    "host": "192.168.101.108",

    "port": 27017,

    "db": "global_db"

  },

  "db_server": {

    "front_1": {

      "host": "192.168.101.108",

      "port": 27017,

      "db": "server_1"

    },

    "front_2": {

      "host": "192.168.101.108",

      "port": 27017,

      "db": "server_2"

    }

  },

  "db_zones": {

    "zone1": {

      "host": "192.168.101.108",

      "port": 27017,

      "db": "zone_1"

    },

    "zone2": {

      "host": "192.168.101.108",

      "port": 27017,

      "db": "zone_2"

    }

  }

}


MongoComponent 的实现

MongoComponent 是数据库管理的核心组件,负责初始化和管理数据库连接。

1. 全局数据库连接

全局数据库用于存储服务器的全局配置和数据。MongoComponent 会在启动时初始化连接:


if (sysCfgComp.db_global) {

  await this.initDbConnection(sysCfgComp.db_global, initializeGlobalModel);

  logger.debug("Global database initialized");

}

2. 服务器数据库连接

服务器数据库存储与当前服务器实例相关的数据。通过服务器 ID 获取对应的数据库配置:


const serverCfg = sysCfgComp.db_server_map.get(server);

assert(

  serverCfg !== undefined,

  `Server config not found for serverId: ${server}`

);

await this.initDbConnection(serverCfg, initializeServerModel);

logger.debug("Server database initialized");

3. 分区数据库连接

分区数据库支持多个实例,每个分区独立存储数据。MongoComponent 会遍历所有分区并初始化连接:


const zoneList = sysCfgComp.server.zoneIdList;

for (const zone of zoneList) {

  const zoneCfg = sysCfgComp.db_server_map.get(server);

  assert(zoneCfg !== undefined, `Server config not found for zone: ${zone}`);

  await this.initDbZoneConnection(zoneCfg, zone, initializeZoneModel);

  logger.debug(`Zone database initialized for ${zone}`);

}

4. 连接管理

MongoComponent 提供了通用的数据库连接方法,支持连接成功、错误和断开事件的处理:


initDbConnection(dbConfig: DBCfg, callback: Function): Promise<any> {

  return new Promise((resolve, reject) => {

    const url = `mongodb://${dbConfig.host}:${dbConfig.port}/${dbConfig.db}`;

    const connection = mongoose.createConnection(url);

    connection.on("connected", () => {

      const result = callback(connection);

      resolve(result);

      logger.debug("Database initialized", dbConfig);

    });

    connection.on("error", (error: Error) => {

      logger.error("Connection error:", error);

      reject(error);

    });

    connection.on("disconnected", () => {

      logger.debug("Connection disconnected");

    });

  });

}


天然支持分区架构

由于 zone 数据库支持多个实例,DonkJS 的服务器天然支持分区架构。每个分区的数据独立存储,互不干扰,适合以下场景:

  • 游戏分区:不同分区的玩家数据独立存储。

  • 业务隔离:不同业务模块的数据分开管理。

分区架构的优势:

  1. 高扩展性:可以根据业务需求动态增加分区。

  2. 高可靠性:单个分区的故障不会影响其他分区。

  3. 高性能:分区间的数据隔离减少了数据库的竞争。


示例:初始化数据库组件

以下是服务器启动时初始化数据库组件的完整流程:


const mongoComponent = new MongoComponent();

await mongoComponent.start();

MongoComponentstart 方法中,依次初始化全局数据库、服务器数据库和分区数据库:


async start() {

  const sysCfgComp = ComponentManager.instance.getComponent(EComName.SysCfgComponent);

  // 初始化全局数据库

  if (sysCfgComp.db_global) {

    await this.initDbConnection(sysCfgComp.db_global, initializeGlobalModel);

  }

  // 初始化服务器数据库

  const serverCfg = sysCfgComp.db_server_map.get(server);

  await this.initDbConnection(serverCfg, initializeServerModel);

  // 初始化分区数据库

  const zoneList = sysCfgComp.server.zoneIdList;

  for (const zone of zoneList) {

    const zoneCfg = sysCfgComp.db_server_map.get(server);

    await this.initDbZoneConnection(zoneCfg, zone, initializeZoneModel);

  }

}


总结

DonkJS 的数据库组件通过强入侵式设计,确保了全局、服务器和分区数据库的统一管理。分区架构的天然支持使得服务器能够轻松扩展,适应高并发和多区场景。

未来的优化方向包括:

  • 增加对其他数据库(如 Redis)的支持。

  • 提供动态分区管理功能,支持分区的动态添加和移除。

希望本文能为实时服务器的数据库设计提供一些启发!