20250312-elixir-in-action.org
介绍
状态
- 许多早期的客户端服务器系统都是有 状态 的。服务器在内存中保持 工作状态 。他们通过 持久连接 与客户端来回传递消息。
- Tim Berners-Lee 发明了一种新的客户端服务器系统,称为 万维网 。
- 随着 Web 的发展和传播,HTTP 也在发展和传播。
- HTTP 是一种 无状态 协议,因此我们也认为 Web 应用程序是无状态的。这是一种 错觉 。
- 状态对于应用程序执行任何有趣的作都是必需的,但是我们不是将其保存在服务器的内存中,而是将其推送到数据库中,等待下一个请求。
- 将状态卸载到数据库提供了一些真正的优势。
- 基于 HTTP 的应用程序只需要在发送响应之前保持与客户端的临时连接,因此它们需要更少的资源来处理相同数量的请求。
- 大多数语言无法提供必要的 并发性 来维护足够的持久连接。
- 无状态 让我们得以扩展。但无状态是有代价的。
- 它会引入显著的延迟,因为应用程序需要对数据库进行一次或多次访问以获取数据以准备响应。
- 它使数据库成为扩展瓶颈,并使我们习惯于为数据库而不是应用程序代码建模数据。
- Elixir 提供了足够的并发性来支持有状态服务器。
- Phoenix Channels 提供管道。
- 单个 Phoenix 应用程序可以同时保持与数十万甚至数百万个客户端的持久 Channel 连接。
- 这些客户端都可以通过服务器相互协调地相互广播消息。
- 在处理这些消息时,应用程序将保持敏捷和响应迅速。
- Elixir 和 Phoenix 为无状态服务器提供了一种合法的替代方案,能够处理现代 Web 流量。
使用 Phoenix 添加 Web 界面
- Phoenix MVC 部件(路由器、控制器、视图和一些模板)。
- Ecto 是 Phoenix 附带的数据库层。
- 通过 Phoenix 通道将 HTTP 的临时客户端 - 服务器连接替换为持久连接。
- 通道为前端应用程序之间闪电般快速的消息传递提供了一个管道,在我们的例子中,是一个有状态的后端服务器。
- 我们将充分利用频道命名约定,允许两个玩家连接到他们自己的私人 GenServer 运行 Islands。我们将能够在单个服务器上同时运行数千款游戏。
- 完成后,我们将拥有一个指向 Islands 引擎的 Web 界面。主要组件将是一个 Phoenix Channel,能够将两个玩家直接连接到单个 Islands 游戏。
- 我们将自定义 Phoenix 提供的 JavaScript 文件,使其为您最喜欢的前端框架做好准备。完成后,与传统的 Web 应用程序相比,它的代码和移动部件将少得多。
功能性 Web 开发
- 函数式编程最具特征的模式之一是组合。通过函数组合,我们将一个大而复杂的工作分解成更小、解耦且更集中的函数。
- 我们将从函数级别引入组合的概念,并将其扩展到应用程序级别。
- 我们将 Web 应用程序的完整、复杂的行为分为独立的解耦层。
- 每个层都将有一个集中的责任。它会完成它的工作,而不是其他任何事情。
- 然后,我们将通过让 每一层调用下一层 来重新创建应用程序的完整行为,将返回值传回链上并传出给客户端。通过这样做,我们将获得整个应用程序的清晰度和可维护性。
使用代理的模型状态
有/无状态的Web应用
有状态
- BEAM 是 Erlang 的虚拟机,本质上是一个有状态的系统。
- 在 Elixir 中编写的每个应用程序都是有状态的。
- 使用 Phoenix 构建的每个 Web 界面也可以是有状态的。
- 有状态服务器在许多请求期间将所需的数据持久保存在内存中。
无状态
- 无状态的应用程序状态存储在数据库中,在每个请求中访问它,并将任何更改写回数据库。
- 通常使用 ORM 模型定义域实体,ORM 模型是直接与数据库中的表交互的类。
举例
- 考虑一个典型的 HTTP GET 请求,其中包含资源的 ID。
- 为了处理请求,服务器将使用 id 从数据库中获取该资源的数据。
- 该数据将成为应用程序的工作状态,但仅在发送响应所需的几分之一秒内。
- 发送响应后,服务器会立即清除所有状态,为下一个请求做准备。
用Erlang和Elixir改进
- 在 Erlang 和 Elixir 中,我们可以生成进程,这些进程可以在内存中持续很长时间。
- 他们可以持有 state 并做工作。
- 我们可以为这些进程编写公共函数,这些函数允许我们查询和作该状态,以及执行我们需要的任何其他工作。
- 这些过程是超轻量级的。如果需要,我们可以生成数十万甚至数百万个。考虑到这一点,我们可以开始将保存状态的责任从数据库转移到虚拟机中的多个长期进程。
- 这为我们打开了一个全新的世界,让我们能够对应用领域进行建模。我们将能够以一种对应用程序(而不是数据库)来说自然的方式工作。这意味着我们将使用常见的数据结构(如列表和映射)来替换外键并联接表来对实体之间的关系进行建模。
优点
- 通过划分 state 并将其存储在单独的、长期存在的进程中,我们将分离关注点。
- 每个进程将仅具有处理其持有的特定类型数据所需的功能。
- 这意味着,为了处理跨多个流程的数据,这些流程需要协调。我们将在这些进程之间传递消息以查询和作状态。
- 由于应用程序在每次需要满足请求时都不需要通过网络访问数据库,因此我们也将减少延迟。
- 我们将设计我们的系统,使每个 state 只存在于一个地方,在一个进程中。每个状态在系统中都有一个表示形式。
- 以这种方式使用进程可以最大程度地减少争用条件。
- 进程通过相互传递消息来进行通信。
- 每个进程都有一个邮箱,该邮箱按接收这些邮件的顺序处理这些邮件。
- 邮箱在其他异步系统中充当同步机制。
- Elixir 提供了一个易于使用的抽象,用于生成进程来保存称为 Agent 的状态。
Islands建模
- Islands 游戏有五个主要实体:坐标、棋盘、岛屿、玩家和游戏本身。
- 对于我们建模的每个实体,我们将定义一个单独的模块,定义一个函数以默认数据结构作为其状态启动新流程,并向其添加公共函数以定义其行为。
Processes
- Elixir 进程是完全独立的超轻量级虚拟机级进程。他们彼此之间不共享内存。
- 每个进程都异步执行其工作,独立于任何其他进程的工作。
- Erlang 虚拟机具有特殊的 OS 级线程,它们充当虚拟机级进程的调度程序。调度程序会尽最大努力尽可能高效地使用机器的所有内核。
- 只要虚拟机正在运行,进程就可以存在,也可以是短暂的、终止的,并在任务完成后立即对其内存进行垃圾回收。
- 对于持有状态,长寿命流程是最佳选择。
- 由于可能有很多很多单独的进程都是异步工作的,因此它们需要相互协调才能完成复杂的任务。
- 他们通过相互发送消息来实现这一点。他们指定要向其发送消息的给定进程的方式是通过称为 PID 的第一类数据类型,即进程标识符。
- 每个进程都确定自己对给定消息的响应,即使该响应是忽略该消息。
- 进程都有邮箱,它们在其中接收队列中的消息,然后按 顺序 处理它们。这非常重要,因为这意味着邮箱是异步系统中的同步机制。
state
- Elixir 是一种函数式语言,它通过递归和数据转换以函数式方式处理状态。
- 在 Elixir 中保存状态的每个长寿命进程都依赖于一个以数据结构为参数的无限递归函数。该数据结构就是状态。每个递归在将数据传递回自身以进行下一次递归之前,可能会也可能不会转换数据。
def loop(state) do new_state = receive do :a_transforming_message -> transform(state) anything_else -> state end loop(new_state) end
代码解释
- 最外层是 loop/1 函数。这是一个普通的 Elixir 函数,而不是像其他语言中的 while 或 for 那样的循环。它需要一个参数 — 进程要保持的状态。
- 在 loop/1 中,我们有一个 receive 块。receive块的工作是:
- 按照接收顺序从进程邮箱中选取消息,
- 对它们进行模式匹配,
- 并执行与模式匹配的代码。
- 如果邮箱中没有邮件,则 receive 块将变为空闲状态。
- 在 receive 块中执行的消息代码可以使用进程的当前状态来创建新状态。
- 在 receive 块的末尾,有一个对 loop/1 的递归调用,并将新状态作为参数。
- 进程邮箱中存在新邮件会触发递归。如果没有新消息,接收块将保持空闲状态,并且不会发生 loop/1 的下一次递归。
- 如果当前状态没有变化,我们将简单地将其用作递归调用的 “new state”。然后,此新状态将成为进程的状态。
Elixir状态与行为是分开的
- 进程可以将数据结构作为状态保存,但它们永远不会将状态和行为混合在一起。
- 函数不会自动访问 state。要么我们需要传入它,要么函数需要去获取它。
Agent
- Elixir Agents 消除了使用 Elixir 流程管理状态的复杂性。我们不需要编写自己的递归函数来查询邮箱中的新消息。Agent 存在的全部原因是保存状态并允许我们查询和使用它。
- Agent 是在 Elixir 中使用 OTP 的最简单方法。
- OTP 代表开放电信平台。正是 Erlang 超级库 / 设计模式的组合使 Elixir 和 Erlang 令人难以置信的并发性、容错性和分发成为可能。
- 当我们使用 Agent 时,Elixir 几乎抽象掉了所有的 OTP 模式。
- Agent 模块本身提供了一些简单的功能,允许我们启动新进程以及访问和更新代理的状态。
为了构建代理,我们将从一个普通的 Elixir 模块开始。
- 为了添加新行为,我们将定义包装 Agent 模块函数的公共函数。
- 对于我们构建的每个代理,我们将有一个公共函数,该函数将启动具有一些初始状态的新代理进程。
- 我们可以编写其他公共函数来查询代理的状态并将响应返回给调用者。公共函数也可以转换代理的状态。
- 所有公共函数都将 PID 作为其第一个参数。这将提供要将邮件发送到的地址。
- 代理的状态可以是任何有效的 Elixir 数据类型: 字符串、整数、列表、映射或结构体。
- 通常,你会看到 state 在 agent 模块中被定义为一个结构体。
- 这将显示为以 agent 模块命名的结构体。
- Agent 的状态可能是包含其他 Agent 的列表。
- 反过来,这些 Agent 可能会持有更多 Agent 的映射。
- 这就是我们如何对更大、更复杂的数据结构进行建模。
建模:坐标 IslandsEngine.Coordinate
defmodule IslandsEngine.Coordinate do defstruct in_island: :none, guessed?: false alias IslandsEngine.Coordinate end
代码说明
- in_island 当前坐标是否属于岛屿的一部分;类型:原子;默认值: :none
- guessed? 显示玩家是否猜到了该坐标;类型:布尔值;默认值: false
- alias 别名,以便我们可以将坐标结构定义为 % Coordinate {} 而不是 % IslandsEngine.Coordinate {}。
使用Map.put/3 设置新值
- 结构体是 map 的一种特殊情况。
- 它包括一个 struct 字段,其值为模块名称。
- 因此,Map 函数也可以与结构体一起使用,而 Map.put/3 非常适合在绑定到 coord 变量的结构体中设置新值。
- Map.put/3 将返回一个全新的结构体。
iex> coord = %Coordinate{} %IslandsEngine.Coordinate{guessed?: false, in_island: :none} iex> coord = Map.put(coord, :guessed?, true) %IslandsEngine.Coordinate{guessed?: true, in_island: :none}
Struct 缺陷
- Struct 的寿命很短。一旦它们超出范围,垃圾回收器就会回收它们的内存,它们就会永远消失。
- 为了保持我们的结构体在整个游戏中可用,我们需要一个持久化进程来保存它。
- 结构体也只能在单个进程中使用,但在 Islands 中,多个进程将需要访问坐标的状态。我们还需要一些可以与许多进程通信的东西。
Struct 缺陷解决办法
- 将Struct 转换为 Agent。Agent 是构建在 GenServer 之上的抽象,它带有 OTP。
- Agent是保存状态的长寿命进程,
- Agent响应来自其他进程的请求。
将 IslandsEngine.Coordinate 模块转换为将 % Coordinate {} 作为其状态的 Agent
- 第一步是使用我们想要的状态启动一个新的代理进程。
- 我们可以使用四个函数来执行此作。
- 它们之间的主要区别在于代理是否创建指向调用它的进程的链接。
- 当我们在(目前)未成文的 chapter.design_for_recovery 中谈论监督树时,这将发挥作用。
- 要 link Agent时,请使用 Agent.start_link 函数。
- 否则,请使用 Agent.start。
- 当我们 link 两个进程时,如果一个进程崩溃,另一个进程也会终止。如果我们链接两个以上的进程并且一个进程崩溃,则其余所有进程都将终止。