Elixir 和 OTP 中面向过程的编程指南
已发表: 2022-03-11人们喜欢将编程语言分类为范式。 有面向对象 (OO) 语言、命令式语言、函数式语言等。这有助于确定哪些语言解决了类似的问题,以及一种语言打算解决哪些类型的问题。
在每种情况下,范式通常都有一个“主要”焦点和技术,这是该语言家族的驱动力:
在 OO 语言中,它是类或对象,作为封装状态(数据)和操作该状态(方法)的一种方式。
在函数式语言中,它可以是函数本身的操作,也可以是从函数传递到函数的不可变数据。
虽然 Elixir(和之前的 Erlang)通常被归类为函数式语言,因为它们展示了函数式语言共有的不可变数据,但我认为它们代表了与许多函数式语言不同的范式。 它们之所以存在并被采用是因为 OTP 的存在,所以我将它们归类为面向过程的语言。
在这篇文章中,我们将了解使用这些语言时面向过程编程的含义,探索与其他范例的异同,了解培训和采用的含义,并以一个简短的面向过程编程示例结束。
什么是面向过程的编程?
让我们从一个定义开始:面向过程的编程是一种基于通信顺序过程的范式,最初来自于 Tony Hoare 在 1977 年的一篇论文。这也通常称为并发的参与者模型。 与此原始作品有一定关系的其他语言包括 Occam、Limbo 和 Go。 正式论文只涉及同步通信; 大多数参与者模型(包括 OTP)也使用异步通信。 总是可以在异步通信之上构建同步通信,而 OTP 支持这两种形式。
在这段历史上,OTP 通过通信顺序进程创建了一个容错计算系统。 容错设施来自“让它失败”的方法,以监督者的形式进行可靠的错误恢复,并使用参与者模型启用的分布式处理。 “让它失败”可以与“防止它失败”形成对比,因为前者更容易适应,并且在 OTP 中被证明比后者更可靠。 原因是防止失败所需的编程工作(如 Java 检查异常模型中所示)涉及更多且要求更高。
因此,面向过程的编程可以定义为一种范式,其中系统的进程结构和进程之间的通信是主要关注点。
面向对象与面向过程的编程
在面向对象编程中,数据和函数的静态结构是主要关注点。 操作封闭的数据需要什么方法,对象或类之间的连接应该是什么。 因此,UML 的类图是这个焦点的一个主要例子,如图 1 所示。
可以注意到,对面向对象编程的一个常见批评是没有可见的控制流。 因为系统是由大量单独定义的类/对象组成的,所以经验不足的人可能难以可视化系统的控制流。 对于具有大量继承、使用抽象接口或没有强类型的系统尤其如此。 在大多数情况下,对于开发人员来说,记住大量的系统结构以使其有效变得很重要(哪些类有哪些方法,哪些以什么方式使用)。
面向对象的开发方法的优势在于,只要新的对象类型符合现有代码的期望,系统就可以扩展以支持对现有代码影响有限的新类型的对象。
功能与面向过程的编程
许多函数式编程语言确实以各种方式解决并发问题,但它们的主要关注点是函数之间的不可变数据传递,或者从其他函数(生成函数的高阶函数)创建函数。 在大多数情况下,该语言的重点仍然是单个地址空间或可执行文件,并且此类可执行文件之间的通信以操作系统特定的方式处理。
例如,Scala 是一种建立在 Java 虚拟机上的函数式语言。 虽然它可以访问 Java 工具进行通信,但它不是该语言的固有部分。 虽然它是 Spark 编程中使用的一种通用语言,但它又是一个与该语言结合使用的库。
功能范式的优势在于能够在给定顶级功能的情况下可视化系统的控制流。 控制流是明确的,每个函数调用其他函数,并将所有数据从一个函数传递到下一个函数。 在功能范式中没有副作用,这使得问题确定更容易。 纯功能系统面临的挑战是“副作用”需要具有持久状态。 在架构良好的系统中,状态的持久化在控制流的顶层处理,允许大多数系统没有副作用。
Elixir/OTP 和面向过程的编程
在 Elixir/Erlang 和 OTP 中,通信原语是执行语言的虚拟机的一部分。 进程之间和机器之间的通信能力是语言系统的内置和核心。 这强调了在这种范式和这些语言系统中交流的重要性。
虽然 Elixir 语言在语言中表达的逻辑方面主要是功能性的,但它的使用是面向过程的。
面向过程意味着什么?
这篇文章中定义的面向流程是首先以存在哪些流程以及它们如何通信的形式设计一个系统。 主要问题之一是哪些进程是静态的,哪些是动态的,哪些是根据请求产生的,哪些服务于长期运行的目的,哪些持有系统的共享状态或部分共享状态,哪些特征系统本质上是并发的。 正如 OO 有对象类型,功能有函数类型一样,面向过程的编程也有过程类型。
因此,面向过程的设计是对解决问题或满足需求所需的一组过程类型的标识。
时间方面很快进入了设计和需求工作。 系统的生命周期是什么? 哪些定制需求是偶尔的,哪些是不变的? 系统中的负载在哪里,预期的速度和体积是多少? 只有在理解了这些类型的考虑之后,面向流程的设计才开始定义每个流程的功能或要执行的逻辑。
培训意义
这种分类对培训的含义是,培训不应从语言语法或“Hello World”示例开始,而应从系统工程思维和对流程分配的设计重点开始。
编码问题次要于流程设计和分配,最好在更高级别解决,并且涉及对生命周期、QA、DevOps 和客户业务需求的跨职能思考。 Elixir 或 Erlang 的任何培训课程都必须(并且通常确实)包括 OTP,并且应该从一开始就面向过程,而不是像“现在你可以在 Elixir 中编码,所以让我们做并发”类型的方法。
收养影响
采用的含义是语言和系统更好地应用于需要通信和/或计算分布的问题。 单台计算机上的单一工作负载问题在这个领域不太有趣,可能用另一种语言更好地解决。 长寿命的连续处理系统是这种语言的主要目标,因为它从头开始就内置了容错能力。
对于文档和设计工作,使用图形表示法会非常有帮助(如图 1 中的 OO 语言)。 来自 UML 的 Elixir 和面向流程编程的建议将是序列图(图 2 中的示例),以显示流程之间的时间关系并确定哪些流程参与服务请求。 没有用于捕获生命周期和流程结构的 UML 图类型,但可以用简单的方框图和箭头图表示流程类型及其关系。 例如,图 3:
过程导向的一个例子
最后,我们将通过一个简短的示例将过程导向应用于问题。 假设我们的任务是提供一个支持全球选举的系统。 之所以选择此问题,是因为许多单独的活动是突发执行的,但结果的聚合或汇总是实时的,并且可能会看到大量负载。
初始流程设计和分配
我们最初可以看到,每个人的投票是来自许多离散输入的系统流量的爆发,没有时间顺序,并且可能具有高负载。 为了支持这项活动,我们需要大量的流程都收集这些输入并将它们转发到更中心的制表流程。 这些过程可以位于每个国家/地区将产生选票的人口附近,从而提供低延迟。 他们将保留本地结果,立即记录他们的输入,并将它们分批转发以供制表,以减少带宽和开销。
我们最初可以看到,需要有流程来跟踪每个司法管辖区的投票情况,必须在其中提交结果。 让我们假设对于这个例子,我们需要跟踪每个国家的结果,并按省/州在每个国家内跟踪结果。 为了支持这项活动,我们希望每个国家/地区至少有一个进程执行计算,并保留当前的总数,并为每个国家/地区的每个州/省设置另一组。 这假设我们需要能够实时或低延迟地回答国家和州/省的总数。 如果可以从数据库系统获得结果,我们可能会选择不同的进程分配,其中总数由瞬态进程更新。 为这些计算使用专用进程的优点是结果以内存的速度发生并且可以以低延迟获得。
最后,我们可以看到有很多人会查看结果。 这些过程可以以多种方式进行划分。 我们可能希望通过在每个负责该国家/地区结果的国家/地区放置流程来分配负载。 进程可以缓存来自计算进程的结果以减少计算进程的查询负载,和/或计算进程可以定期将其结果推送到适当的结果进程,当结果发生显着变化时,或计算过程变得空闲表明变化率变慢。
在所有三种流程类型中,我们可以相互独立地扩展流程,在地理上分布它们,并通过主动确认流程之间的数据传输来确保结果永远不会丢失。
如前所述,我们以独立于每个流程中的业务逻辑的流程设计开始示例。 如果业务逻辑对数据聚合或地理有特定要求,可能会反复影响流程分配。 到目前为止,我们的流程设计如图 4 所示。

使用单独的进程来接收选票允许每个选票独立于任何其他选票被接收,在收到时记录,并批处理到下一组进程,从而显着减少这些系统的负载。 对于消耗大量数据的系统,通过使用进程层来减少数据量是一种常见且有用的模式。
通过在一组隔离的进程中执行计算,我们可以管理这些进程的负载并确保它们的稳定性和资源需求。
通过将结果表示放在一组隔离的进程中,我们既可以控制系统其余部分的负载,又可以动态扩展进程集以适应负载。
其他要求
现在,让我们添加一些复杂的要求。 让我们假设在每个司法管辖区(国家或州),投票制表可能会导致成比例的结果、赢家通吃的结果,或者如果相对于该司法管辖区的人口投票不足,则没有结果。 每个司法管辖区都可以控制这些方面。 随着这种变化,国家的结果不是原始投票结果的简单聚合,而是州/省结果的聚合。 这改变了原来的流程分配,要求将州/省流程的结果输入国家流程。 如果投票收集与州/省、省到国家进程之间使用的协议相同,则聚合逻辑可以复用,但需要不同的进程持有结果,并且它们的通信路径不同,如图5.
代码
为了完成该示例,我们将回顾 Elixir OTP 中示例的实现。 为简化起见,此示例假设使用像 Phoenix 这样的 Web 服务器来处理实际的 Web 请求,并且这些 Web 服务向上述过程发出请求。 这样做的好处是简化了示例并将重点放在 Elixir/OTP 上。 在生产系统中,让这些独立的进程具有一些优点以及分离关注点、允许灵活部署、分配负载并减少延迟。 带有测试的完整源代码可以在 https://github.com/technomage/voting 找到。 为了便于阅读,本文对源代码进行了缩写。 下面的每个进程都适合 OTP 监督树,以确保进程在失败时重新启动。 有关示例的这方面的更多信息,请参阅源代码。
投票记录器
这个过程接收投票,将它们记录到持久存储中,并将结果批处理到聚合器。 VoteRecoder 模块使用 Task.Supervisor 来管理短期任务以记录每次投票。
defmodule Voting.VoteRecorder do @moduledoc """ This module receives votes and sends them to the proper aggregator. This module uses supervised tasks to ensure that any failure is recovered from and the vote is not lost. """ @doc """ Start a task to track the submittal of a vote to an aggregator. This is a supervised task to ensure completion. """ def cast_vote where, who do Task.Supervisor.async_nolink(Voting.VoteTaskSupervisor, fn -> Voting.Aggregator.submit_vote where, who end) |> Task.await end end
投票聚合器
这个过程聚合一个辖区内的投票,计算该辖区的结果,并将投票摘要转发给下一个更高的过程(更高级别的辖区或结果提交者)。
defmodule Voting.Aggregator do use GenStage ... @doc """ Submit a single vote to an aggregator """ def submit_vote id, candidate do pid = __MODULE__.via_tuple(id) :ok = GenStage.call pid, {:submit_vote, candidate} end @doc """ Respond to requests """ def handle_call {:submit_vote, candidate}, _from, state do n = state.votes[candidate] || 0 state = %{state | votes: Map.put(state.votes, candidate, n+1)} {:reply, :ok, [%{state.id => state.votes}], state} end @doc """ Handle events from subordinate aggregators """ def handle_events events, _from, state do votes = Enum.reduce events, state.votes, fn e, votes -> Enum.reduce e, votes, fn {k,v}, votes -> Map.put(votes, k, v) # replace any entries for subordinates end end # Any jurisdiction specific policy would go here # Sum the votes by candidate for the published event merged = Enum.reduce votes, %{}, fn {j, jv}, votes -> # Each jourisdiction is summed for each candidate Enum.reduce jv, votes, fn {candidate, tot}, votes -> Logger.debug "@@@@ Votes in #{inspect j} for #{inspect candidate}: #{inspect tot}" n = votes[candidate] || 0 Map.put(votes, candidate, n + tot) end end # Return the published event and the state which retains # Votes by jourisdiction {:noreply, [%{state.id => merged}], %{state | votes: votes}} end end
结果演示者
此过程从聚合器接收投票并将这些结果缓存到服务请求以呈现结果。
defmodule Voting.ResultPresenter do use GenStage … @doc """ Handle requests for results """ def handle_call :get_votes, _from, state do {:reply, {:ok, state.votes}, [], state} end @doc """ Obtain the results from this presenter """ def get_votes id do pid = Voting.ResultPresenter.via_tuple(id) {:ok, votes} = GenStage.call pid, :get_votes votes end @doc """ Receive votes from aggregator """ def handle_events events, _from, state do Logger.debug "@@@@ Presenter received: #{inspect events}" votes = Enum.reduce events, state.votes, fn v, votes -> Enum.reduce v, votes, fn {k,v}, votes -> Map.put(votes, k, v) end end {:noreply, [], %{state | votes: votes}} end end
带走
这篇文章探讨了 Elixir/OTP 作为面向过程语言的潜力,将其与面向对象和功能范式进行了比较,并回顾了这对培训和采用的影响。
该帖子还包括一个简短的示例,将这种方向应用于示例问题。 如果您想查看所有代码,这里是指向我们在 GitHub 上的示例的链接,这样您就不必向后滚动查找它。
关键要点是将系统视为通信过程的集合。 首先从流程设计的角度规划系统,然后再从逻辑编码的角度规划系统。