我开发了一个基于 Beancount 的账本托管服务 HostedBeans,欢迎大家来了解纯文本复式记账并试用我的服务。
标签 #LeanCloud

Play Cards: 探索通用的游戏后端方案

当我们公司决定推出一个「多人在线游戏后端解决方案」的时候,我其实很疑惑会有一个「通用」的方案可以解决所有在线游戏的后端需求么?

于是在前一段时间,我尝试开发了一个「多人在线卡牌对战游戏」,支持了「斗地主」游戏规则的一个子集,可以在 play-cards.leanapp.cn 访问到,源代码在 jysperm/play-cards。不过请不要指望能够匹配到其他玩家 —— 你需要自己开三个窗口,通过左右手互搏的方式来体验游戏,下面是一个演示视频:

https://streamable.com/belpq

动作同步和状态同步

其实这个游戏的关键就是在多个客户端之间来同步数据、实现联机游戏,为此我先了解了一下业界总结出的两种同步模型:「动作同步」和「状态同步」。

在动作同步(帧同步)中:

  • 客户端发送操作,服务器只转发客户端的操作
  • 游戏逻辑主要在客户端运行(通常客户端需要掌握所有数据)
  • 延迟低,适合 RTS、MOBA、FPS
  • 可以让所有客户端有一个完全一致的时间轴

游戏状态的计算必须是确定的,不能有随机数,这样才能保证不同的客户端在应用相同的一系列动作之后能够得到相同的状态(游戏画面)。

在状态同步(C/S 同步)中:

  • 客户端发送操作,服务器转发计算后的游戏状态
  • 游戏逻辑主要在服务器运行(可以只向客户端发送部分数据)
  • 易于反作弊,适合 MMORPG、回合制(卡牌)游戏

因为游戏逻辑需要运行在服务器,所以服务器程序还是必不可少的。

选型

因为这是一个实验性质的项目,因此我想同时实现这两种同步模式来进行对比。在这里我挑选了一个回合制、属于非对称博弈的卡牌游戏作为游戏的内容。

我首先实现了动作同步,在动作同步中服务器几乎不需要做什么事情,只是作为一个「消息服务」来转发客户端的动作。于是我在这里使用了 LeanCloud Play 来完成这个消息转发的工作,每局游戏对应 Play 中的一个 Room,Play 允许客户端在房间中向其他玩家广播消息。

然后我又尝试将这个游戏改为了状态同步 —— 我引入一个运行在服务器上的特殊客户端(称为 MasterClient)。这个客户端中同样连入 Play 的消息服务,其中运行着完整的游戏逻辑,它将其他真正玩家的动作作为输入,然后输出游戏状态给其他真正玩家的客户端来展示,防止客户端作弊。

动作和状态

前面我提到业界已经总结出了两种同步模式,即动作同步和状态同步,「动作(Action)」和「状态(State)」这两个概念会贯穿这篇文章。

动作即玩家对于游戏的「输入」,可以是按键、点击,或者更抽象的动作,例如在这个游戏中我定义了两种动作 —— 出牌和放弃出牌:

type GameAction = PlayCardsAction | PassAction

interface PlayCardsAction {
  action: 'playCards'
  player: Player
  cards: Card[]
}

interface PassAction {
  action: 'pass'
  player: Player
}

状态即用来表示整个游戏局势所需的所有数据,例如在这个游戏中:

export interface GameState {
  players: Player[]
  playersCardsCount: PlayersCardsCount

  myCards: Card[]

  previousCards: Card[]
  previousCardsPlayer?: Player
  currentPlayer?: Player

  winer?: Player
}

游戏抽象

有了动作和状态的概念,我们就可以对游戏进行一个抽象了,我设计了一个 Game 类,是对一局游戏整个生命周期的封装,这个类将会同时运行于客户端和服务器:

// 事件:action(当前玩家的动作)、stateChanged(游戏状态变化)、error
class Game extends EventEmitter {
  constructor(seed: string, players: Player[])

  // 获取游戏状态(供 UI 调用)
  public getState(player: Player): GameState
  // 设置游戏状态(状态同步时),会触发 stateChanged 事件
  public setState(player: Player, state: GameState)

  // 当前玩家执行动作,会触发 action 事件
  public performAction(action: GameAction)
  // 应用其他玩家的动作(动作同步时)
  public applyAction(action: GameAction)
}

在客户端中,当 UI 捕捉到用户的输入时,执行 Game.performAction,动作(Action)的执行会改变状态(State),触发 stateChanged 事件,UI 收到这个事件后根据新的游戏状态来重绘 UI。

至于其他游戏逻辑则主要是关于「一组牌能够管得上另一组牌」的判断,在此不再罗列。

结构

在这个游戏中,我们将消息转发(由 LeanCloud Play 提供)视作一项服务、视作一个中心。所有玩家的客户端都连接到消息转发服务上,同时每局游戏我们还需加入一个运行在服务器上的 MasterClient 来提供特殊的管理能力。

为了将两种同步模式做成可简单替换的,我其实将服务器程序作为了一个必选组件(master-client 目录),但这个服务器程序在动作同步中只是负责创建房间(可以移到客户端),并不参与游戏逻辑。

我的代码分为 3 个部分:

common
├── game.ts
└── types.ts
browser-client
├── app.tsx
└── client-sync.ts
master-client
├── server-sync.ts
└── server.ts
  • common 部分会同时运行在服务器和客户端,包含游戏的核心逻辑
  • browser-client 是运行在浏览器中的客户端,包含 UI
  • master-client 是运行在服务器端中的 MasterClient

动作同步

我首先实现的是动作同步(client-sync.tsserver-sync.ts 中的 actionSyncController),这种模式下客户端发送动作(Action),服务器只转发动作,游戏逻辑主要在客户端运行,客户端掌握所有的数据(包括其他玩家的手牌)。

客户端的工作:

  • game.on('action') 时(表示用户在 UI 上进行了一个动作),通过 Play 将动作广播给其他客户端play.sendEvent(action)
  • play.on('customEvent') 时(表示收到其他客户端广播的动作),应用其他玩家的动作 game.applyAction(action)

而服务器端几乎没有什么工作,只是帮助客户端创建一个房间而已。

状态同步

在状态同步(client-sync.tsserver-sync.ts 中的 statusSyncContorller)中,客户端发送动作(Action),服务器运行游戏逻辑后,转发计算后的游戏状态(State),游戏逻辑主要在服务器运行,客户端只做展现,只知道自己的手牌。

客户端的工作:

  • game.on('action') 时(表示玩家在 UI 上进行了一个动作),通过 Play 将动作单独发送给 MasterClient play.sendEvent(action)(需加参数 {receiverGroup: ReceiverGroup.MasterClient}
  • play.on('customEvent') 时(表示收到 MasterClient 广播的最新状态),从服务器覆盖游戏状态 game.setState(state)

MasterClient 在服务器的工作:

  • 为每局游戏创建一个 Game 对象。
  • play.on('customEvent') 时(表示玩家进行了一个操作),在游戏对象上执行动作 game.performAction(action)
  • game.on('stateChanged') 时,给每一个玩家发送最新的游戏状态 play.sendEvent(state)

复用代码

在完成这个实验性质的项目后,我们需要来思考一下,哪些代码是可以复用的。

  • Game 类为一个游戏的过程提供了一个非常基本的抽象(即通过动作去改变状态),也为数据同步提供了基础的支持(action 事件和 stateChanged 事件)。
  • MasterClient 中对房间的管理是通用的,并且还有很大的改善空间,比如支持多实例允许并实现负载均衡等。

我将我使用的这种开发多人在线游戏的方式称之为 MasterClient 模式,它的好处是:

  • 在服务器和客户端之间复用大部分的游戏逻辑 如果你像我一样使用 JavaScript 的话,那么可以在服务器和客户端运行完全相同的代码。
  • 单机游戏 => 动作同步 => 状态同步 的迁移过程非常平滑 只要一开始能够区分好动作和状态,那么这个迁移的过程中只需改动少数代码。
  • 服务器端的游戏逻辑和消息转发服务解耦 消息转发服务可以更加稳定;MasterClient 则可以更快速地迭代。
  • 符合开发者直觉 至少我作为一个游戏开发的小白是觉得挺符合直觉的

Play & Client Engine

在我完成这个项目的过程中,我也不断地与公司的同事保持着沟通,经过几个月的努力,LeanCloud 在 Play 基础上发布了 Client Engine —— 一个用于托管 MasterClient 的容器平台(类似于云引擎),同时我们也提供了一个项目骨架,集成了我前面提到的功能,帮助开发者实现服务器端逻辑:

  • 游戏抽象 提供了一个类似的 Game 类来帮助开发者管理房间和玩家、填充游戏逻辑,在 Play 的基础上提供 RxJS 风格的高层次 API 来操作游戏动作和状态。
  • 负载均衡 提供了一个 GameManager 类来管理房间的创建,允许 Master Client 以多实例的集群模式运行,以便进行横向扩展,消除容量瓶颈。
  • 平滑部署 当你部署新版本的时候,旧实例会等待已有的房间完成游戏再退出,你可以在任何时候部署新的版本而不必担心影响用户的游戏。

我的这个项目早于 Client Engine 成型,是对通用游戏后端的一个实验。如果你希望编写类似的游戏,请阅读 Client Engine 的文档,并基于 Client Engine 的脚手架来进行开发,而不要直接基于本项目修改。

小结

本文探索的是一种「通用游戏后端」的解决方案,在这个小项目中我们用到了两个 LeanCloud 的服务:Play 和 Client Engine。

  • LeanCloud Play 扮演的是一个「消息转发服务」,它会维持与所有客户端(包括 Master Client)的长链接,允许客户端之间广播或单发消息。
  • LeanCloud Client Engine 提供了一个可信的服务器端环境来运行客户端 —— 在本文的例子中是 MasterClient,可以将游戏逻辑运行在服务器端来实现反作弊。

借助这两个服务,我们将对游戏服务器的开发需求降到了最低,只需编写运行于服务器端的游戏逻辑即可,而不必关心链接的保持、消息的转发和服务器环境和扩容等问题。

在 LeanCloud 中使用 GraphQL

GraphQL 是 FaceBook 开源的一套数据查询语言,也是 Relay 钦定的组件,可以在客户端以一种非常灵活的语法来获取数据,但目前支持 GraphQL 的服务还比较少,最近 GitHub 也宣布了其开放 API 支持了 GraphQL

因为 GraphQL 的支持需要服务器端的更改,因此我选择了在 LeanCloud 的数据服务的基础上用 Node.js 编写一个中间层,运行在云引擎上,将 GraphQL 的查询翻译成对 LeanCloud SDK 的调用,为客户端提供 GraphQL 支持。

我也参考了其他语言和框架的 GraphQL 支持,它们都需要开发者进行很多的开发或配置工作。这是因为无论在 MySQL 还是 MongoDB 中都并没有记录数据之间的关联关系(Id 和 ObjectId 都不会记录指向的表或集合,MySQL 的外键倒是会记录,但可惜用户不多);而且即使你定义了数据之间的关联,你还是需要去定义权限 —— 哪些用户可以访问哪些数据。

而 LeanCloud 的 Relation 和 Pointer 都记录了所指向的 Class,同时 LeanCloud 本身也有一套基于 sessionToken 和 ACL 的权限控制机制,因此我们完全可以做到从 LeanCloud 的数据服务获取数据之间的管理,然后遵循现有的 ACL 来自动实现对 GraphQL 的支持。

leancloud-graphql 就是这样的一个项目,你只需将它部署到云引擎上,不需要改动一行代码,便可以用 GraphQL 查询你在 LeanCloud 上的所有数据。

相较于 RESTful 和 SQL,GraphQL 为数据定义了严格的类型,你可以使用这样一个灵活的语言将数据通过关系组合起来,所见即所得地得到你想要的数据。得益于 GraphQL 的类型系统,你还可以在调试工具(graphql.leanapp.cn)中得到精确的错误提示和补全建议。

例如这里我们查询 Todo 这个 Class 中按优先级排序的前两条数据,获取 title、priority,并将 owner 这个 Pointer 展开:

query {
  Todo(ascending: priority, limit: 2) {
    title, priority, owner {
      username
    }
  }
}

结果:

{
  Todo: [{
    title: "紧急 Bug 修复",
    priority: 0,
    owner: {
      username: "someone"
    }
  }, {
    title: "打电话给 Peter",
    priority: 5,
    owner: {
      username: "someone"
    }
  }]
}

目前 leancloud-graphql 已经实现了 LeanCloud 中大部分的查询参数和查询条件,你可以任意地组合这些条件。例如我们可以查询优先级大于 5 且存在 content 属性的数据:

query {
  Todo(exists: {content: true}, greaterThan: {priority: 5}) {
    title, content, priority
  }
}

GraphQL 最大的亮点还是对关系查询的支持,无论是 Relation 还是 Pointer 你都可以任意地展开,而不必受到 LeanCloud RESTful API 只能展开一层的限制。例如我们查询所有的 TodoFolder 中的 Todo(Relation)并展开 owner(Pointer):

query {
  TodoFolder {
    name,
    containedTodos {
      title, owner {
        username, email
      }
    }
  }
}

结果(省略了一部分):

{
  TodoFolder: [{
    name: "工作",
    containedTodos: [{
      title: "紧急 Bug 修复",
      owner: {
        username: "someone",
        email: "test@example.com"
      }
    }, // ...
    ]
  }, // ...
  ]
}

你也可以在关系查询上附加查询参数或条件。例如我们查询所有 TodoFolder 中优先级最高的一个 Todo:

query {
  TodoFolder {
    name, containedTodos(limit: 1, ascending: priority) {
      title, priority
    }
  }
}

结果:

{
  TodoFolder: [{
    name: "工作",
    containedTodos: [
      {title: "紧急 Bug 修复", priority: 0}
    ]
  },
    name: "购物清单",
    containedTodos: [
      {title: "买酸奶", priority: 10}
    ]
  }, {
    name: "someone",
    containedTodos: [
      {title: "紧急 Bug 修复", priority: 0}
    ]
  }]
}

在实现一对多关系时,我们经常会在「多」上面保存一个到「一」的指针,例如一个 Todo 会有一个叫 owner 的 Pointer 指向用户表。在这时,leancloud-graphql 会自动在用户表上添加一个叫 ownerOfTodo 的属性用来表示这个反向关系,你可以像展开一个 Relation 一样展开这个反向关系,例如我们查询每个用户的 Todo 并展开 title:

query {
  _User {
    username, ownerOfTodo {
      title
    }
  }
}

结果:

{
  _User: [{
    username: "someone",
    ownerOfTodo: [
      {title: "紧急 Bug 修复"},
      {title: "打电话给 Peter"},
      {title: "还信用卡账单"},
      {title: "买酸奶"}
    ]
  }]
}

leancloud-graphql 的简单介绍就到这里,更多使用方法和功能介绍可以在项目的 GitHub 主页上看到,这个项目本身也是开源的。

QPS 和并发:如何衡量服务器端性能

在 LeanCloud 的控制台和文档中大家会接触到「并发连接数(Concurrent Connections)」这个衡量服务器负荷或处理能力的概念,但很多人并不了解什么是并发 —— 甚至在我们团队内部,很多没有服务器端开发经验的工程师对这个词的理解也不是很准确。我们还在继续优化文案来减少用户的困惑,但与此同时不如听我仔细介绍一下并发这个概念。

和并发相关不得不提的一个概念就是 QPS(Query Per Second),QPS 其实是衡量吞吐量(Throughput)的一个常用指标,就是说服务器在一秒的时间内处理了多少个请求 —— 我们通常是指 HTTP 请求,显然数字越大代表服务器的负荷越高、处理能力越强。作为参考,一个有着简单业务逻辑(包括数据库访问)的程序在单核心运行时可以提供 50 - 100 左右的 QPS,即每秒可以处理 50 - 100 个请求,LeanCloud 目前也是按照请求数量进行收费的。

但 QPS 只能粗略地衡量请求的数量,完全不关心服务器处理每个请求的开销。例如一个命中缓存的请求和一个需要进行多次数据库查询的请求的开销可能会有一个数量级的差距,所以 QPS 并不能十分精确地衡量服务器的负载或处理能力,因此我们引入了一个非常抽象的概念 —— 并发。

大部分请求的响应时间在 15 - 30 毫秒左右,这里的响应时间是指服务器处理这个请求所花费的时间,从客户端测量到的时间可能会稍长一些。想象如果服务器上只有一个 CPU 核心在逐个地在处理请求,如果每个请求花费 15 毫秒的话,那么每秒可以处理 66 个请求,也就是我们前面提到的 66 QPS;而如果都是复杂的请求,每个需要 30 毫秒的话,那么服务器就只有 33 QPS 了。可以看到在处理能力不变的情况下(只有一个核心),响应时间越高,QPS 就越低。又如果在响应时间不变的情况下,如果我们增加一个 CPU,QPS 就会翻倍,这三者之间的关系可以简单地描述成:吞吐量(QPS)= 处理能力(CPU)/ 响应时间。

其实 CPU 的数量就是并发最基本的概念,即有多少个 CPU 在工作。当然在实际的服务器端环境中,我们在 CPU 的基础上建立起了进程、线程、协程这样复杂的抽象、通过异步的 IO 提高 CPU 的利用率 —— 当需要从硬盘或网络读取数据时,CPU 会去做其他工作,所以并发和 CPU 的比值会比 1 高一些,IO 越多,这个比值会越高。

这时我们可以观测到的并发数就是服务器在同时处理多少个请求,也即「并发连接数」。对于 Web 后端的场景来说(而不考虑推送等长链接的场景),我们希望尽快地给客户端响应,所以请求在服务器端花费的几十毫秒中每一毫秒都是必不可少的:可能是在进行计算、也可能是在向磁盘或网络读写数据,都在占用着服务器的资源,因此并发依然是衡量服务器负荷和处理能力的关键指标。

除了并发本身,我们还经常提到「最大并发」的概念,最大并发就是在单位时间(通常是一天)里并发最高的那一刻有多少个 CPU 在为你工作。大部分应用的请求量并不是均匀地分布在一天中的,因为用户们往往会集中在傍晚的几个小时中使用手机,这些时段中的请求量要远远高于凌晨。所以人人都希望在傍晚得到更多的计算能力,但遗憾的是这些计算能力需要原子世界中的 CPU 去支持,你不可能在傍晚购买一批服务器然后在凌晨卖掉(当然,这其实是云计算要解决的问题),所以为了支撑傍晚的高并发,我们必须去准备那么多的服务器、必须在凌晨让很多服务器闲置,因此其实我们只关心一天中最高的并发数 —— 这代表了我们需要采购多少硬件资源。

当然,LeanCloud 的存在就是为了帮助开发者减轻维护后端的负担,应用开发者往往更关注的是「我有 100 万用户对应多少并发」。但这个问题往往得不到一个答案,因为有太多的因素在影响着最后的结果,例如你的 100 万用户中可能并不是每个人每天都会打开你的应用(每日活跃用户比例);而且用户对于不同类型的应用使用的频率也并不相同(平均打开次数);不同类型的应用在工作期间发起的请求数量也不相同(平均请求数量);对于不同类型的请求,需要占用服务器的计算能力同样不同(平均响应时间);最后还要考虑到你的大部分用户会集中在傍晚的几个小时使用你的应用,对于游戏抽奖、电商秒杀之类的场景,用户会更加集中在几分钟内使用你的应用。前些天我根据这些指标编写了一个简单的计算器(https://budget.leanapp.cn),将最大并发数的计算抽象为了前面提到的几个指标,如果你能给每个指标一个相对准确的估算,那么就可以计算出一个可供参考的并发数。

1

精子生于 1995 年,英文 ID jysperm.

订阅推送

通过 Telegram Channel 订阅我的博客日志、产品和项目的动态:

王子亭的博客 @ Telegram


通过邮件订阅订阅我的博客日志、产品和项目的动态(历史邮件):

该博客使用基于  Hexo  的  simpleblock  主题。博客内容使用  CC BY-NC-ND  授权发布。最后生成于 2023-12-20.