前言
这篇文章感觉介绍语言是次,讲解各种各种分类以及面向过程的思想为主。本身我也没接触过Elixir,这次也权当开拓视野了。最后附加了一个英语单词的小解,当学习之余的做零头吧。
原文如下:
A Guide to Process-oriented Programming in Elixir and OTP
文章正文
人们喜欢将编程语言分类为范式。有面向对象(OO)语言, 命令式语言(imperative languages), 函数式语言( functional languages,)等。这有助于找出哪些语言解决了类似的问题,以及一种语言想要解决的问题类型。
在每种情况下,范式通常具有一种“主要”焦点和技术,这是该族语言的驱动力:
- 在OO语言中,类或对象作为一种通过操纵该状态(方法)封装状态(数据)的方法。
- 在函数式语言中,它可以是函数本身的操作,也可以是由函数传递到函数的不可变数据。
虽然Elixir(还有之前的Erlang)通常被归类为函数式语言,因为它们显示了函数语言常见的不可变数据,但我认为它们代表了不同函数语言的一个独立范式。它们之所以存在并被采用,是因为OTP的存在,所以我将它们归类为面向过程的语言。
在本文中,我们将了解使用这些语言时面向过程的编程的含义,探索与其他范例的不同和相似之处,了解培训和采用的意义,并以一个简短的面向流程的编程示例结束。
什么是面向过程的编程?
我们从一个定义开始:面向过程的编程是一种基于通信顺序过程的范例,最初由Tony Hoare在1977年的一篇论文中发表。这也被称为并发性的行为模型(actor model of concurrency)。与这项原创作品有某些关系的其他语言包括 Occam, Limbo, and Go。正式的论文只涉及同步通信;大多数参与者模型(包括OTP)也使用异步通信。在异步通信的基础上构建同步通信始终是可能的,OTP支持这两种形式。
在这段历史中,OTP通过通信顺序过程创建了一个容错计算系统。容错机制来自于一个让它失败的方法,它以管理员的形式出现了可靠的错误恢复,并使用了参与者模型所支持的分布式处理。“让它失败(let it fail)”可以与“防止它失败(prevent it from failing)”形成对照,因为前者更容易适应,并在OTP中被证明比后者更可靠。原因是防止故障所需的编程工作(如Java检查的异常模型所示)要更多的涉及和要求很高。
因此,面向过程的编程可以被定义为一种范式,在这种范式中,系统进程之间的过程结构和通信是最主要的关注点。
面向对象vs面向过程的编程
在面向对象编程中,数据和功能的静态结构是主要关注的问题。需要什么方法来处理封闭的数据,什么是对象或类之间的连接。因此,UML的类图是这个焦点的一个很好的例子,如图1所示。
可以注意到,关于面向对象编程的常见批评是没有可见的控制流程。由于系统由大量分开定义的类/对象组成,较不经验的人可能难以将系统的控制流可视化。对于具有大量继承性的系统尤其如此,它使用抽象接口或没有强大的类型。在大多数情况下,开发人员记住大量的系统结构是有效的(什么类有什么方法和哪些方式使用)变得很重要。
面向对象开发方式的优点是,只要新对象类型符合现有代码的期望,系统可以扩展到支持对现有代码影响有限的新类型的对象。
函数式vs面向过程的编程
许多函数式编程语言以各种方式解决了并发问题,但是它们的主要重点是在函数之间传递不可变数据,或者从其他函数(生成函数的更高阶函数)创建函数。在大多数情况下,语言的重点仍然是单一的地址空间或可执行文件,并且这些可执行文件之间的通信以操作系统特定的方式进行处理。
例如,Scala是一种基于Java虚拟机构建的函数式语言。虽然它可以访问Java设施进行通信,但它不是语言的固有部分。虽然它是Spark编程中使用的通用语言,但它又是一种与语言结合使用的库。
函数范式的一个优点是能够将一个给定最高级别函数的系统的控制流可视化。控制流程是显而易见的,因为每个函数调用其他函数,并将所有数据从一个传递到下一个。在函数范式中,没有副作用(side effects),这使得问题确定更容易。纯功能系统的挑战是需要“副作用”才能持续保持状态。在结构良好的系统中,状态的持续处理在控制流程的顶层处理,从而允许大部分系统无效。
Elixir / OTP和面向过程的编程
在Elixir / Erlang和OTP中,通信原语是执行语言的虚拟机的一部分。在进程之间和机器之间通信的能力建立在语言系统的中心。这强调了在这种范式和这些语言系统中沟通的重要性。
虽然Elixir语言主要在语言表达的逻辑方面起作用,但其使用是面向过程的。
以流程为导向意味着什么?
要在本文中定义的面向过程的是首先设计一个系统,其形式是存在哪些流程以及它们如何沟通。主要问题之一是哪些进程是静态的,哪些进程是动态的,它们是根据请求产生的,这些请求具有长期运行的目的,这些请求保持系统的共享状态或共享状态的一部分,以及该系统本质上是并发的。正如OO具有对象类型一样,功能具有类型的功能,面向过程的编程也有类型的进程。
因此,面向过程的设计是识别解决问题或解决需求所需的一组过程类型。
时间的方面很快进入到设计和需求的工作中。系统的生命周期是什么?哪些习惯需要是偶然的,哪些是不变的?系统中的负载在哪里,预期的速度和体积是多少?只有在这些类型的考虑被理解之后,面向过程的设计才开始定义每个进程的功能或要执行的逻辑。
培训意义(Training Implications)
这种分类对培训的意义是,培训不应该以语言语法或“Hello World”的例子开始,而是系统工程思维和设计重点放在流程分配上。
编码的关注点是流程设计和分配的次要问题,它们在更高的级别上得到了最好的解决,并且涉及到关于生命周期、QA、DevOps和客户业务需求的跨功能思考。Elixir或Erlang的任何培训课程必须(通常是)包括OTP,并且应该从一开始就有一个过程方向,而不是“现在你可以在Elixir中编写代码,所以让我们来做并发”类型的方式。
采用的意义(Adoption Implications)
采用的含义是,语言和系统更好地应用于需要通信和/或分布式计算的问题。单个计算机上的单一工作负载的问题在这个空间中不那么有趣,并且可以用另一种语言更好地处理。长时间的连续处理系统是该语言的主要目标,因为它具有从底层构建的容错能力。
对于文档和设计工作,使用图形符号(如OO语言的图1)是非常有用的。来自UML的Elixir和面向过程的编程的建议是序列图(图2中的例子),以显示进程之间的时间关系,并确定在服务请求过程中涉及哪些过程。没有一个用于捕获生命周期和过程结构的UML图表类型,但是可以用一个简单的方框和箭头关系图来表示流程类型及其关系。例如,图3:
过程导向示例(An Example of Process Orientation)
最后,我们将介绍一个将过程导向应用到问题的简短示例。假设我们的任务是提供一个支持全球选举的系统。这个问题是在许多单独的活动都是在突发事件中执行的,但是对结果的聚合或汇总是实时的,并且可能会看到大量的负载。
初始流程设计与分配
我们一开始可以看到,每个人的投票都是由许多离散的输入所产生的流量,不是时间有序的,并且可以承受高负荷。为了支持这个活动,我们希望大量的进程都收集这些输入,并将它们转发到更中心的制表过程。这些过程可能位于每个国家的人口附近,这些人口将投票,从而提供低延迟。他们将保留本地结果,立即记录其输入,并批量转发以进行制表,以减少带宽和开销。
我们最初可以看到,需要在每个管辖区域跟踪投票结果的过程。我们假设在这个例子中,我们需要跟踪每个国家的结果,并按省/州的形式跟踪每个国家的结果。为了支持这项活动,我们希望每个国家至少执行一个进程计算,并保留当前总计,另一个进程在每个国家/地区的每个州/省。这假设我们需要能够实时或低延迟地回答国家和州/省的总计。如果可以从数据库系统获得结果,我们可以选择不同的进程分配,其中总计通过临时进程更新。
最后,我们可以看到很多人会看到结果。这些进程可以以多种方式进行分区。我们可能希望通过在每个国家/地区的流程进行分配负责该国的结果。这些过程可以缓存计算过程的结果,以减少计算过程的查询负担,和/或计算过程可以将结果推送到适当的结果流程,当结果发生大量变化时,或者在计算过程变空闲,表明变化率变慢。
在这三种过程类型中,我们可以彼此独立地扩展进程,在地理上分布它们,并且确保在进程之间的数据传输活动中,不会丢失结果。
正如所讨论的,我们已经开始了一个独立于每个流程中的业务逻辑的流程设计的示例。在业务逻辑对数据聚合或地理位置有特定需求的情况下,可以迭代地影响过程分配。到目前为止,我们的流程设计如图4所示。
使用单独的进程来接收选票,使得每个投票都可以独立于任何其他的投票,记录在收据上,并对下一组进程进行批处理,从而显著地减少了这些系统的负载。对于一个消耗大量数据的系统,通过使用多层流程来减少数据量是一种常见且有用的模式。
通过在一组独立的进程中执行计算,我们可以管理这些进程的负载,并确保它们的稳定性和资源需求。
通过将结果表示放在一个独立的进程集合中,我们都可以将负载控制到系统的其余部分,并允许为负载动态地进行一组进程。
额外的要求
现在,我们来补充一些复杂的要求。让我们假设在每个司法辖区(国家或州),选票的表格可以产生一个比例的结果,一个获胜者的结果,或者如果没有足够的选票与该管辖区域的人口相比导致没有结果的结果。每个辖区都有对这些方面的控制。有了这种变化,各国的结果并不是简单的原始投票结果的汇总,而是国家/省份结果的汇总。这就改变了从原始状态到状态/省份过程的过程分配,从而将状态/省过程的结果输入到国家进程中。如果在投票集合和状态/省份和省份之间使用的协议是相同的,那么聚合逻辑可以被重用,但是需要不同的进程来保存结果,它们的通信路径是不同的,如图5所示。
代码
要完成此示例,我们将在Elixir OTP中查看示例的实现。为了简化事情,本例假定像Phoenix这样的Web服务器用于处理实际的Web请求,而这些Web服务会向上述确定的进程发出请求。这具有简化示例并将重点放在Elixir / OTP上的优点。在生产系统中,将这些分开的过程具有一些优点以及分离问题,允许灵活部署,分配负载并减少延迟。具有测试的完整源代码可以在https://github.com/technomage/voting找到。本文的源代码在这篇文章中被缩写为可读性。下面的每个过程都适合于OTP监控树,以确保进程在失败时重新启动。有关该示例的这方面的更多信息,请参阅参考资料。
投票记录器(Vote Recorder)
此过程接收投票,将其记录到持久存储,并将结果批量化到聚合器。VoteRecoder模块使用Task.Supervisor来管理短命的任务来记录每个投票。
代码语言:javascript复制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
投票聚合(Vote Aggregator)
这个过程聚合一个管辖范围内的投票,计算该管辖区的结果,并将投票摘要转发给下一个较高级别的管理机构或者一个结果主持人。
代码语言:javascript复制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
结果演示者(Result Presenter)
该过程从聚合器接收投票并将这些结果缓存到呈现结果的服务请求。
代码语言:javascript复制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
收获(Takeaway)
这篇文章从其作为面向过程的语言的潜在的角度探讨了Elixir / OTP,将其与面向对象和函数式范式进行了比较,并回顾了其对培训和采用的影响。
这篇文章还包含了一个简单的例子,将这个方向应用到一个示例问题上。如果你想要查看所有的代码,这里有一个链接到GitHub上的例子,这样你就不用再回去找它了。
关键的是将系统作为一个通信过程的集合来查看。首先从流程设计的角度来规划系统,其次是逻辑编码的观点。
了解基础知识
什么是Elixir和OTP?
Elixir是一种基于Erlang VM的功能编程语言。OTP是一个面向过程的编程框架,与Erlang和Elixir是一体的。
什么是面向过程的开发?
面向过程的开发重点是系统的过程结构,其次是系统的功能逻辑。
什么是最好的采用Elixir/OTP和面向过程的开发?
从培训或探索开始,着重于OTP和过程管理,然后是Elixir的语法和功能方面。避免从hello world编码示例开始的培训,并只能到达OTP的一半。
为什么选择Elixir / OTP和面向过程的开发?
Elixir / OTP的可靠性和并发方面是竞争堆栈上的头和肩,不需要太多熟练的编程技能,并且比Ruby on Rails或Node具有更好的性能。
何时选择Elixir / OTP?
Elixir / OTP适用于长时间运行的进程或需要多核性能的进程。他们更注重低延迟,而不是高吞吐量。它们对于要求单核吞吐量应用程序或者批处理或命令行环境中不经常运行的应用程序而言并不强大。
附录
takeaway
根据Oxford Dictionary, "takeaway"一词本意是餐厅的外卖。
第二个定义:
“A key fact, point, or idea to be remembered, typically one emerging from a discussion or meeting.”
简单地说,就是有些收获,学到了什么。经常使用的场景,就是听一个讲座,上一门课,看一本书,或者一部电影,最后有一些什么样的takeaway。
如果你去参加一个讲座或者活动,最后让你说说自己的感想时,你就可以说,"my biggest takeaway is..."
参考资料:小词 | “takeaway”。