原文
是将所有东西放在一个进程里, 还是, 把我们所需的 state 中的每一小块各自放在单独的进程中, 这是个问题. 在本文中, 我将讨论使用和不使用进程. 我还会讨论如何将复杂的状态逻辑与其它关系分开, 例如时间行为以及跨进程通信.
由于这是一篇长文, 所以在开始之前, 我想先分享一下我的主要观点:
使用函数和模块来分离思维事物
使用进程来分离运行时事物
不要使用进程(或 Agents)来分离思维事物
这里的"思维事物"是指所存在与我们想法中的东西, 例如订单, 订单项, 产品等等. 如果这些概念变得更为复杂, 就值得将它们分离到不同的模块和函数中, 使得我们的代码的各个部分保持精简和专注.
使用进程(例如 agents)来做这些, 是我常常会看到人们犯的错误. 这种手段事实上是抛弃了 Elixir 中函数式的部分, 试图使用进程来模拟对象. 这种实现往往会低于普通 FP 的方法(甚至是 OO 语言中的等价物). 记住, 使用进程是有代价的(内存和消息传递). 因此, 应当只在有明确的益处可以抵消其代价时才使用进程. 管理代码, 并不在这些益处之中, 所以这不是一个使用进程的好理由.
进程是用于处理运行时事物的, 具有可在运行的系统中被观察的属性. 例如, 当你想避免某个事物的失败影响到系统中的其它活动时, 你会需要多个进程. 或者, 你想要使用并行的潜力, 允许多个作业同时运行. 这可以提升你的性能, 以及扩展的潜能. 还有一些不常见的使用进程的场景, 然而, 分离思维事物不在此列.
一个例子
但我们该如何管理复杂的 state 呢, 如果不借助 agents 和进程? 让我来通过一个简单的域模型来说明, 这是一个二十一点游戏的简单版本. 我将为你展示的代码here支持了二十一点游戏中的一个牌局.
一个牌局是一个行动的序列, 每次行动属于一个不同的玩家. 从第一个行动的玩家开始. 初始时玩家有两张牌, 然后可以行动: 再拿一张牌(一击), 或者跳过. 如果玩家手上的点数大于21, 那么就出局了. 否则, 该玩家还可以继续行动.
点数是指手中所有牌的值之和, 数字牌(2-10)的值就是它们自身, jack, queen 和 king 的值是 10. ace 的值可以是 1 或 11.
玩家选择跳过, 或者出局, 则轮到下一玩家. 当所有玩家都行动完毕, 获胜者就是未出局的玩家中点数最高者.
为了保持简单, 我没有引入庄家, 下注, 保证, 分拆, 多轮, 玩家加入和离开等概念.
进程边界
所以, 我们需要跟踪不同 state 的变化: 牌堆, 每位玩家的行动, 牌局的状态. 幼稚的策略是使用多个进程. 需要为每个玩家的提供一个进程, 牌堆一个进程, 以及驱动整个牌局的 "master" 进程. 我看到人们有时会使用类似的方法, 但我不认为这是合适的. 因为这个游戏本身是高度阻塞的. 事件是按顺序一件一件发生的: 拿牌, 行动一次或多次, 结束行动, 下一个玩家. 在一个牌局里的任何时候都只发生一个事件.
使用多进程来实现单一牌局是弊大于利的. 在多个进程中, 事件是并发的, 所以你必须付出额外的努力来阻塞每个事件. 你还需要注意进程的终结和清理. 如果你结束了牌局进程, 你还需要结束所有相关的进程. 在出错时也是一样: 牌局或者牌堆进程中的异常, 会终结所有东西(因为状态无法修复了). 或许单个玩家的异常可以被隔离, 因此提升了一点容错性, 但我认为这是过度关注容错性了.
我看到, 使用多进程来管理单一牌局, 具有许多潜在的缺点而没有太多益处. 然而, 多个牌局之间是相互独立的. 它们有这各自的数据流和状态, 相互不共享信息. 因此使用单个进程来管理多个牌局是不合适的. 这降低了容错性(一个牌局里的错误会导致所有崩溃), 而且可能降低性能(无法利用多核), 或遇到瓶颈(长时间处理某个牌局会使得其它牌局瘫痪). 所以, 不假思索地, 我们需要将不同的牌局放在不同的进程里.
在演讲时, 我常常提到, 在一个复杂系统中, 有着巨大的并发潜能, 所以我们会使用很多进程. 但要从中获益, 我们需要在有意义的地方使用进程.
经过思考, 我们非常确定, 要使用单个进程来管理单个牌局. 当我们引入了桌子的概念, 随着时间推移, 玩家将会发生变化, 那一定会很有趣.
函数式模型
那么, 不借助多进程, 我们该如何分离不同的事物呢? 当然时使用函数和模块. 如果我们能使用不同的函数来拆分逻辑, 给予它们合适的名字, 也许将它们放在合适的模块中, 我们就能很好地表现我们的思想, 而不需要用 agents 来模拟对象.
让我来向你展现我的方案, 先从最简单的开始.
牌堆
我想实现的第一个概念是牌堆. 我们需要一个52张牌的标准牌堆. 我们需要能洗牌, 还要能够一张一张地拿牌.
这是一个有状态的概念. 每当我们取一张牌, 牌堆的 state 就改变了. 尽管如此, 我们也可以使用纯函数来实现牌堆.
看代码. 我决定使用牌的列表来表示牌堆, 每张牌是一个有着面值和花色的map. 我可以在编译时生成所有牌:
@cards (
for suit <- [:spades, :hearts, :diamonds, :clubs],
rank <- [2, 3, 4, 5, 6, 7, 8, 9, 10, :jack, :queen, :king, :ace],
do: %{suit: suit, rank: rank}
)
现在, 我们可以添加 shuffle/0
函数来初始化一个洗过的牌堆:
def shuffled(), do:
Enum.shuffle(@cards)
最后, take/1
函数, 从牌堆顶端拿一张牌:
def take([card | rest]), do:
{:ok, card, rest}
def take([]), do:
{:error, :empty}
take/1
函数返回 {:ok, card_taken, rest_of_the_deck}
或者是 {:error, :empty}
. 这使得客户端(调用牌堆的用户)可以准确地处理每种情况.
我们可以这样使用它:
deck = Blackjack.Deck.shuffled()
case Blackjack.Deck.take(deck) do
{:ok, card, transformed_deck} ->
# do something with the card and the transform deck
{:error, :empty} ->
# deck is empty -> do something else
end
这是一个我使用 "函数式抽象" 的例子, 它是在描述这样一个东西:
一些列相关的函数,
描述性的命名,
没有side-effects,
可以被放在单独的模块里
对我来说这就是OO里的类和对象. 在OO语言中, 我可能需要一个Deck
类和相应的方法, 在这里我需要一个Deck
模块的相应的函数. 函数的优点(虽然并不总是值得的)是只转换数据, 而不处理时间逻辑或副作用(跨进程消息传递, 数据库, 网络请求, 超时, ...).
这些函数是否在一个专用的模块中并不重要. 这个抽象的代码非常简单, 只占用了一小块地方. 因此, 我也可以在客户端模块中定义私有的 shuffled_deck/0
和 take_card/1
函数. 当代码足够小时我常常会这样做. 如果事情变得更复杂了, 我会将他们抽离出来.
最重要的一点是牌堆的概念是由纯函数构成的. 不需要使用一个 agent 来管理一堆牌.
完整的模块代码在这里.
手牌
同样的技术可以被用于管理手牌. 这个抽象会跟踪手牌的变化, 它会知道如何计算分数, 并判断状态(:ok
或 :busted
). 这个实现放在 Blackjack.Hand 模块中.
这个模块有两个函数. 我们使用 new/0
来初始化, 然后使用 deal/2
发一张牌到手中. 这里是一个结合了手牌和牌堆的例子:
# create a deck
deck = Blackjack.Deck.shuffled()
# create a hand
hand = Blackjack.Hand.new()
# draw one card from the deck
{:ok, card, deck} = Blackjack.Deck.take(deck)
# give the card to the hand
result = Blackjack.Hand.deal(hand, card)
deal/2
函数的结果会是 {hand_status, transformed_hand}
, 这里 hand_status
可能是 :ok
或 :busted
.
牌局
这个抽象由 Blackjack.Round 模块支持, 它将所有东西联系在了一起. 它有如下职责:
保存牌堆的 state
保存牌局内所有手牌的 state
决定谁是下一个行动的玩家
接收并执行玩家的行动(发牌/跳过)
从牌堆拿牌并发到手牌中
计算出获胜者, 当所有玩家行动完毕
牌局抽象也使用和牌堆和手牌一样的函数式方法来实现. 然而, 牌局需要引入一些别的时间逻辑. 例如与玩家的互动, 当回合开始时, 首先要给第一个玩家发出两张牌, 然后告知其进行行动. 等到该玩家行动后, 牌局才可以继续.
让我惊讶的是, 许多人(包括经验丰富的erlang/elixir使用者), 会直接在一个 GenServer 或 :gen_statem
中实现牌局的概念. 这使得他们能够同时管理牌局状态与时间逻辑(例如与玩家的互动).
然而, 我认为这两个方面需要分开, 因为他们都有潜在的复杂度. 逻辑方面, 我们只涉及了单轮, 如果我们向加入游戏的其它内容, 例如投注, 分拆或庄家, 那么情况只会变得更复杂. 交流方面, 我们也会需要处理网络缓慢, 崩溃, 无响应等情况, 可能会添加重连, 或一些持久性的功能.
我不想将这两个复杂的问题结合在一起, 因为它们会变得纠缠不清, 而且处理代码将变得非常困难. 我想将时间事务移动到其它地方, 只留下一个纯净的二十一点规则模型.
所以我选择了一种不常见的方法. 我在一个简单的函数式抽象中实现了一个牌局的概念.
让我来展示一些代码. 我需要调用 start/1
来初始化一轮新的牌局:
{instructions, round} = Blackjack.Round.start([:player_1, :player_2])
需要传入的参数是玩家id 的列表. 它们可以是任意的元素, 将被用于多种目的:
实例化每个玩家
跟踪当前玩家
向玩家发出通知
这个函数返回一个元组. 元组的第一个元素是一个指令列表. 在本例中, 它将是:
[
{:notify_player, :player_1, {:deal_card, %{rank: 4, suit: :hearts}}},
{:notify_player, :player_1, {:deal_card, %{rank: 8, suit: :diamonds}}},
{:notify_player, :player_1, :move}
]
这些指令是在通知客户端应该执行的操作. 牌局一开始, 首先给一位玩家发两张牌, 然后告知其开始行动. 所以在这个例子中, 我们得到如下指令:
通知 player_1 得到红桃4
通知 player_1 得到方片8
通知 player_1 开始行动
客户端代码有责任将这些通知提供给相关玩家. 客户端代码可以说是一个 GenServer, 它将向玩家进程发送消息. 它还将等待玩家进行操作. 这类时间事务将完全与牌局模块隔离开.
返回的元组中的第二个元素是牌局state. 需要注意的是, 这个数据是不透明的. 这意味着客户端不应该读取 round
变量中的数据. 客户端需要的一切都由指令列表提供.
我们让 player_1 再拿一张牌:
{instructions, round} = Blackjack.Round.move(round, :player_1, :hit)
我需要传入玩家 id, 这样牌局抽象才可以验证是否是可行动的玩家在行动. 如果我传入的是错误的id, 抽象会给出指示, 通知玩家没有轮到其操作.
这里是我得到的指令:
[
{:notify_player, :player_1, {:deal_card, %{rank: 10, suit: :spades}}},
{:notify_player, :player_1, :busted},
{:notify_player, :player_2, {:deal_card, %{rank: :ace, suit: :spades}}},
{:notify_player, :player_2, {:deal_card, %{rank: :jack, suit: :spades}}},
{:notify_player, :player_2, :move}
]
这给列表告诉我们: player_1 得到黑桃10. 由于他之前已有红桃4 和 方片8, 所以他出局了, 牌局立刻轮到下一个玩家行动. 客户端被指示通知 player_2 得到了两张牌, 并开始行动.
让我们作为 player_2 进行行动:
{instructions, round} = Blackjack.Round.move(round, :player_2, :stand)
# instructions:
[
{:notify_player, :player_1, {:winners, [:player_2]}}
{:notify_player, :player_2, {:winners, [:player_2]}}
]
玩家2 选择跳过, 这样回合就结束了. 牌局抽象立刻算出了赢家, 并指示我们通知两位玩家结果.
让我们来看看 Round
模块是如何良好地建立在 Deck
和 Hand
抽象之上的. 下列 Round
模块中的函数会从牌堆中拿一张牌, 然后给到当前玩家:
defp deal(round) do
{:ok, card, deck} =
with {:error, :empty} <- Blackjack.Deck.take(round.deck), do:
Blackjack.Deck.take(Blackjack.Deck.shuffled())
{hand_status, hand} = Hand.deal(round.current_hand, card)
round =
%Round{round | deck: deck, current_hand: hand}
|> notify_player(round.current_player_id, {:deal_card, card})
{hand_status, round}
end
我们从牌堆中拿一张牌, 如果当前牌堆已用完, 则使用一个新的牌堆. 然后我们将牌传给当前玩家, 更新牌局中的玩家和牌堆状态, 在指令列表中添加关于新牌的指令, 并返回玩家状态(:ok
或 :busted
) 以及新的牌局 state. 没有引入额外的进程:-)
notify_player
是一个简单的机制, 它大大降低了本模块的复杂度. 没有它, 我们就需要向其它进程发送一个消息(另一个GenServer, 或是 Phoenix Channel). 我们必须以某种方式找到那个进程, 并考虑那个进程没有运行的情况. 许多额外的复杂度会混合到牌局的流程中.
多亏了指令机制, Round
模块得以专注与游戏的规则. notify_player
函数会保存指令列表. Round
模块中的函数在返回前会拉取所有积累的指令, 然后依次返回他们, 强制客户端执行这些指令.
此外, 这些代码可以由不同的驱动(客户端)来运行. 在上述例子中, 我在会话中手动操作它. 另一个例子是 在测试中驱动这些代码. 这个抽象现在可以很容易进行测试, 而不必担心副作用.
进程管理
纯模型完成之后, 现在我们该将注意转移到进程方面. 如我们之前提到的, 我会将每个牌局放在独立的进程中, 因为牌局之间不交换任何信息. 因此, 将它们分开运行可以增加效率, 扩展性和容错性.
牌局服务器
一个牌局由 Blackjack.RoundServer 模块来管理, 它是一个 GenServer
. Agent
也可以满足需求, 但我不是很热衷于使用 agnets. 所以我将使用GenServer
. 你的喜好也许不同, 当然, 我完全尊重你的选择:-)
为了启动进程, 我们需要调用 start_playing/2
函数. 我们选择使用它替代常用的 start_link
函数, 因为 start_link
会连接到调用者进程. 相反, start_playing
会在监控树之外启动牌局, 其进程不会连接到调用者.
该函数需要两个参数: 牌局id, 和玩家列表. 牌局id 是一个唯一的元素, 需要由客户端来选择. 服务器进程将使用这个 id 在内置的 Registry
中注册.
玩家列表中的每个元素都是一个描述客户端玩家的 map:
@type player :: %{id: Round.player_id, callback_mod: module, callback_arg: any}
一个玩家由他的 id, 回调模块和回调参数来描述. id 将被传递给牌局抽象. 当抽象指示服务器通知某个玩家, 则服务器会调用 callback_mod.some_function(some_arguments)
, some_arguments
包括牌局 id, 玩家 id, callback_arg
, 通知参数等等.
callback_mod
使得我们可以支持不同的玩家类型, 例如:
通过HTTP连接的玩家
通过自定义的TCP协议连接的玩家
在
iex
会话中的玩家自动(机器人)玩家
我们可以简单地在同一个牌局中处理这些玩家. 服务器不用关系这些, 它只需要调用回调模块中的回调函数, 然后让实现来处理.
回调模块中必须实现的函数如下:
@callback deal_card(RoundServer.callback_arg, Round.player_id,
Blackjack.Deck.card) :: any
@callback move(RoundServer.callback_arg, Round.player_id) :: any
@callback busted(RoundServer.callback_arg, Round.player_id) :: any
@callback winners(RoundServer.callback_arg, Round.player_id, [Round.player_id])
:: any
@callback unauthorized_move(RoundServer.callback_arg, Round.player_id) :: any
这种机制使得玩家无法管理其在 server 进程中的状态. 这样做是有意的, 能够使玩家运行在牌局进程之外. 这有助于我们保持牌局的独立. 如果玩家崩溃或者断开连接, 则牌局服务器仍然保持运行状态, 并且可以处理这些异常. 例如, 如果玩家没有在给定的时间内行动, 则让该玩家出局.
这种设计的另一个优点是方便测试. 可以通过从每个回调中向自己发送消息来实现对通知行为的测试. 在测试中可以调用 RoundServer.move/3
来模拟玩家的行动, 然后确认或否决特定的消息.
发送通知
当 server 进程接收到 Round
模块返回的指令列表后, 它会遍历并分发它们.
指令将会由独立的进程来发送. 这是一个我们可以从并发中获益的例子. 发送消息和管理牌局状态是两个独立的任务. 通知玩家的逻辑可能会受到网络连接缓慢或断开的影响, 所以应当独立于牌局进程. 此外, 向不同的玩家发送通知也应当使用额外的进程. 同时, 我们需要保证每个玩家受到的消息的顺序, 所以我们为每个玩家提供一个专门的通知进程.
这在 Blackjack.PlayerNotifier模块中, 由一个负责向某个玩家发送消息的GenServer
进程来实现. 当我们调用 start_playing/2
函数来启动牌局时, 同时启动了一个小型监控树, 它管理着这个牌局中属于每个玩家的通知进程.
每当牌局 server 行动一次, 就会从牌局抽象中得到一个指令列表. 然后, server 将每个指令转发到相应的通知服务器, 该服务器将读取指令并调用相应的 MFA 以通知玩家.
因此, 如果我们需要通知多个玩家, 我们会分开做(也许是并行的). 因此, 消息的总顺序不会被保留. 思考以下指令序列:
[
{:notify_player, :player_1, {:deal_card, %{rank: 10, suit: :spades}}},
{:notify_player, :player_1, :busted},
{:notify_player, :player_2, {:deal_card, %{rank: :ace, suit: :spades}}},
{:notify_player, :player_2, {:deal_card, %{rank: :jack, suit: :spades}}},
{:notify_player, :player_2, :move}
]
有可能出现这种情况: 在player_1
得知其出局的消息之前, player_2
先收到了消息. 但这是可接受的, 因为他们是两个不同玩家. 每个玩家的消息顺序当然是原来的.
在开始下一部分之前, 我想再次指出: 由于牌局模块的设计和函数式特性, 消息通知部分的所有复杂度都被隔离在了规则模型之外, 同样, 消息通知部分也不用关心规则逻辑.
21 点
至此, 我们完成了 :blackjack 应用(Blackjack 模块).启动该应用时, 将启动几个本地注册的进程: 一个内部注册表实例(用于注册牌局 和 通知服务器), 以及一个用于管理每个牌局的子进程树的 :simple_one_for_one
监控.
现在, 这个应用是一个可以管理多个牌局的基本的 blackjack 服务. 该服务是通用的, 不依赖特定的接口. 你可以将它和phoenix, cowboy, ranch(纯TCP)等等任何符合你的意图的东西结合使用. 你只需实现回调模块, 启动客户端进程, 然后启动牌局服务器.
你可以在 Demo 模块中看到一个例子, 它实现了 一个简单的自动玩家, 一个 GenServer 驱动的通知回调, 以及一个 启动五个玩家的开局逻辑 :
$ iex -S mix
iex(1)> Demo.run
player_1: 4 of spades
player_1: 3 of hearts
player_1: thinking ...
player_1: hit
player_1: 8 of spades
player_1: thinking ...
player_1: stand
player_2: 10 of diamonds
player_2: 3 of spades
player_2: thinking ...
player_2: hit
player_2: 3 of diamonds
player_2: thinking ...
player_2: hit
player_2: king of spades
player_2: busted
...
这是当有五个五人牌局时的监控树:
总结
我们可以在一个进程中管理复杂的 state 吗? 当然可以! 简单的函数抽象, 例如牌堆和手牌, 使得我们可以分离复杂的牌局中的事务, 而不需要存储在agents 中.
这并不意味着我们要保守地使用进程. 当使用进程能带来一些明显的益处时, 就用吧. 在独立的进程中运行单个牌局, 可以提高系统的可扩展性, 容错性和整体性能. 同样, 也适用于通知进程. 它们是不同的运行时事务, 所以不需要在相同的运行时上下文中运行.
如果时间, 规则逻辑很复杂, 请考虑分离它们. 我采用的方法允许我实现更多的运行时行为(并发通知), 而不会导致业务流程复杂化. 这种分离也使得我可以方便地对两个方面进行扩展. 添加对 庄家, 分池, 定金和其它业务概念的支持, 不会对运行时方面造成显著影响. 同样, 对网络, 重连, 玩家崩溃, 或者超时的支持, 不会需要规则逻辑进行修改.
最后, 值得记住的一点. 因为我们计划将这些代码运行在某种 Web 服务器上, 所以有一些决定是为了支持这种情况. 尤其是牌局 server 的实现, 它为每个玩家提供一个回调模块, 允许我们连接各种不同种类的客户端. 这使得 blackjack 服务不受限于特定的库和框架(当然, 标准库和OTP除外), 并且是完全灵活的.
Copyright 2017, Saša Jurić. This article is licensed under a Creative Commons Attribution-NonCommercial 4.0 International License.
The article was first published on The Erlangelist site
The source of the article can be found here.