原文git
是将全部东西放在一个进程里, 仍是, 把咱们所需的 state 中的每一小块各自放在单独的进程中, 这是个问题. 在本文中, 我将讨论使用和不使用进程. 我还会讨论如何将复杂的状态逻辑与其它关系分开, 例如时间行为以及跨进程通讯.github
因为这是一篇长文, 因此在开始以前, 我想先分享一下个人主要观点:数据库
使用函数和模块来分离思惟事物服务器
使用进程来分离运行时事物网络
不要使用进程(或 Agents)来分离思惟事物并发
这里的"思惟事物"是指所存在与咱们想法中的东西, 例如订单, 订单项, 产品等等. 若是这些概念变得更为复杂, 就值得将它们分离到不一样的模块和函数中, 使得咱们的代码的各个部分保持精简和专一.框架
使用进程(例如 agents)来作这些, 是我经常会看到人们犯的错误. 这种手段事实上是抛弃了 Elixir 中函数式的部分, 试图使用进程来模拟对象. 这种实现每每会低于普通 FP 的方法(甚至是 OO 语言中的等价物). 记住, 使用进程是有代价的(内存和消息传递). 所以, 应当只在有明确的益处能够抵消其代价时才使用进程. 管理代码, 并不在这些益处之中, 因此这不是一个使用进程的好理由.ide
进程是用于处理运行时事物的, 具备可在运行的系统中被观察的属性. 例如, 当你想避免某个事物的失败影响到系统中的其它活动时, 你会须要多个进程. 或者, 你想要使用并行的潜力, 容许多个做业同时运行. 这能够提高你的性能, 以及扩展的潜能. 还有一些不常见的使用进程的场景, 然而, 分离思惟事物不在此列.函数
但咱们该如何管理复杂的 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
先收到了消息. 但这是可接受的, 由于他们是两个不一样玩家. 每一个玩家的消息顺序固然是原来的.
在开始下一部分以前, 我想再次指出: 因为牌局模块的设计和函数式特性, 消息通知部分的全部复杂度都被隔离在了规则模型以外, 一样, 消息通知部分也不用关心规则逻辑.
至此, 咱们完成了 :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.