个性化阅读
专注于IT技术分析

Elixir和OTP中面向过程的编程指南

本文概述

人们喜欢将编程语言分类为范式。有面向对象(OO)语言, 命令式语言, 功能语言等。这有助于弄清楚哪些语言可以解决类似的问题, 以及一种语言要解决的问题类型。

在每种情况下, 范式通常都有一个”主要”重点和技术, 这是该语言家族的驱动力:

  • 在OO语言中, 它是类或对象, 用于通过状态(数据)的操作来封装状态(数据)。

  • 在函数式语言中, 它可以是函数本身的操纵, 也可以是函数之间传递的不可变数据。

尽管Elixir(及其之前的Erlang)通常被归类为功能语言, 因为它们展现了功能语言所共有的不变数据, 但我认为它们代表了与许多功能语言不同的范例。它们的存在是由于OTP的存在而被采用, 因此我将它们归类为面向过程的语言。

在本文中, 我们将捕捉使用这些语言时面向过程的编程的含义, 探索与其他范例的异同, 了解培训和采用的含义, 并以一个简短的面向过程的编程示例结尾。

什么是面向过程的编程?

让我们从一个定义开始:面向过程的编程是一种基于通信顺序过程的范例, 该范例最初来自于Tony Hoare于1977年发表的一篇论文。这也被普遍称为并发参与者模型。与该原创作品有某些关系的其他语言包括Occam, Limbo和Go。正式文件仅涉及同步通信;大多数参与者模型(包括OTP)也使用异步通信。总是可以在异步通信之上构建同步通信, 并且OTP支持两种形式。

在此历史上, OTP通过通信顺序过程创建了一个用于容错计算的系统。容错设施来自”让它失败”的方法, 该方法以监督者的形式进行可靠的错误恢复, 并使用参与者模型启用的分布式处理。 “让它失败”可以与”防止失败”形成对比, 因为前者更容易容纳, 并且在OTP中已被证明比后者更可靠。原因是防止失败(如Java检查的异常模型中所示)所需的编程工作量更大且要求更高。

因此, 面向过程的编程可以定义为一种范式, 其中系统的过程结构和过程之间的通信是主要关注点。

面向对象与面向过程的编程

在面向对象的编程中, 数据和函数的静态结构是首要考虑的问题。需要什么方法来处理封闭的数据, 对象或类之间的连接应该是什么。因此, UML的类图是此重点的主要示例, 如图1所示。

面向过程的编程:示例UML类图

可以注意到, 对面向对象编程的普遍批评是没有可见的控制流。因为系统由分别定义的大量类/对象组成, 所以对于经验不足的人来说, 很难可视化系统的控制流程。对于具有大量继承, 使用抽象接口或没有强类型的系统尤其如此。在大多数情况下, 重要的是开发人员必须记住大量有效的系统结构(哪些类具有什么方法, 哪些类以什么方式使用)。

面向对象开发方法的优势在于, 只要新对象类型符合现有代码的期望, 就可以扩展系统以支持对现有代码影响有限的新型对象。

功能性与面向过程的编程

许多函数式编程语言确实以各种方式解决并发问题, 但是它们的主要重点是在函数之间传递不可变的数据, 或者从其他函数创建函数(生成函数的高阶函数)。在大多数情况下, 语言的焦点仍然是单个地址空间或可执行文件, 并且此类可执行文件之间的通信以操作系统特定的方式进行处理。

例如, Scala是在Java虚拟机上构建的一种功能语言。尽管它可以访问Java设施进行通信, 但它不是该语言的固有部分。虽然它是Spark编程中使用的通用语言, 但还是与该语言结合使用的库。

功能范例的强项是可视化具有顶级功能的系统的控制流的能力。控制流程是明确的, 因为每个函数都调用其他函数, 并将所有数据从一个传递到下一个。在功能范例中, 没有副作用, 这使问题确定变得容易。纯功能系统的挑战在于, “副作用”必须具有持久状态。在架构良好的系统中, 状态的持久性是在控制流的最高级别处理的, 从而使大多数系统无副作用。

Elixir / OTP和面向过程的编程

在Elixir / Erlang和OTP中, 通信原语是执行该语言的虚拟机的一部分。进程之间以及机器之间的通信能力是内置的, 并且位于语言系统的中心。这强调了在这种范式和这些语言系统中交流的重要性。

尽管就语言表达的逻辑而言, Elixir语言主要发挥功能, 但其使用是面向过程的。

面向过程意味着什么?

如本文所定义的, 以过程为导向是首先以存在的过程以及它们如何通信的形式设计一个系统。主要问题之一是哪些进程是静态的, 哪些进程是动态的, 是按需生成的请求, 这些进程具有长期运行的目的, 它们拥有系统的共享状态或部分共享状态, 以及哪些功能?该系统本质上是并发的。就像OO具有对象类型, 功能具有功能类型一样, 面向过程的编程也具有过程类型。

这样, 面向过程的设计就是确定解决问题或满足需求所需的一组过程类型。

时间方面迅速进入设计和需求工作。系统的生命周期是什么?偶尔有哪些自定义需求, 哪些是不变的?系统中的负载在哪里, 预期的速度和体积是多少?只有在理解了这些类型的考虑因素之后, 面向流程的设计才开始定义每个流程的功能或要执行的逻辑。

训练意义

这种分类对培训的含义是, 培训不应从语言语法或” Hello World”示例开始, 而应从系统工程思想和针对流程分配的设计开始。

编码问题是流程设计和分配的第二要务, 最好在更高级别上解决, 并且涉及跨功能的生命周期, QA, DevOps和客户业务需求的思考。使用Elixir或Erlang进行的任何培训课程都必须(并且通常包括)OTP, 并且从一开始就应具有面向流程的方向, 而不是像”现在可以在Elixir中进行编码, 让我们开始并发”这样的方法。

采用的含义

采用的含义是, 语言和系统可以更好地应用于需要通信和/或分布计算的问题。在此空间中, 单台计算机上单一工作负载的问题并不那么有趣, 可以使用另一种语言更好地解决。长寿命的连续处理系统是该语言的主要目标, 因为它具有从零开始内置的容错能力。

对于文档和设计工作, 使用图形表示法(如OO语言的图1)可能非常有帮助。 UML对Elixir和面向过程的编程的建议将是序列图(图2中的示例), 以显示过程之间的时间关系并确定服务请求中涉及哪些过程。没有用于捕获生命周期和流程结构的UML图类型, 但是可以用一个简单的方框图和箭头图来表示流程类型及其关系。例如, 图3:

面向过程的编程示例UML序列图
面向过程的编程示例过程结构图

流程导向示例

最后, 我们将通过一个简短的示例介绍将流程导向应用于问题。假设我们的任务是提供一个支持全球选举的系统。选择此问题是因为许多单独的活动都是突发执行的, 但是结果的汇总或汇总是实时可取的, 并且可能会遇到很大的负担。

初始流程设计和分配

最初我们可以看到, 每个人的投票都是从许多离散输入到系统的流量突发, 不是按时间顺序排列的, 并且可能具有很高的负载。为了支持此活动, 我们希望有大量的过程都收集这些输入并将它们转发到更中央的过程以进行制表。这些过程可能位于每个国家中将产生选票的人口附近, 因此延迟时间很短。他们将保留本地结果, 立即记录其输入, 然后将其分批转发以进行制表, 以减少带宽和开销。

最初我们可以看到, 在每个必须显示结果的辖区中, 都需要有跟踪投票的过程。在此示例中, 我们需要跟踪每个国家/地区以及每个国家/地区内每个国家/地区的结果。为了支持此活动, 我们希望每个国家至少有一个进程来执行计算并保留当前总数, 并为每个国家/地区的每个州/省设置另一个进程。假设我们需要能够实时或低延迟地回答国家和州/省的总数。如果可以从数据库系统中获得结果, 则可以选择其他过程分配, 其中临时过程更新总数。使用专用过程进行这些计算的优点是结果以内存的速度发生, 并且可以低延迟获得。

最后, 我们可以看到很多人都在查看结果。这些过程可以通过多种方式进行分区。我们可能希望通过在每个国家/地区负责结果的国家/地区分配流程来分配负载。这些过程可以缓存来自计算过程的结果, 以减少计算过程中的查询负载, 和/或当结果发生显着变化时, 或者当结果变化很大时, 计算过程可以将其结果定期推送到适当的结果过程中。计算过程变得空闲, 表明变化速度减慢。

在所有这三种流程类型中, 我们都可以彼此独立地缩放流程, 在地理上进行分配, 并通过主动确认流程之间的数据传输来确保结果不会丢失。

如上所述, 我们从与每个流程中的业务逻辑无关的流程设计开始了该示例。如果业务逻辑对数据聚合或地理位置有特定要求, 这可能会反复影响流程分配。到目前为止, 我们的流程设计如图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上链接到我们的示例, 以免你滚动查看。

重点是将系统视为通信过程的集合。首先从过程设计的角度来计划系统, 然后从逻辑编码的角度来计划系统。

赞(0)
未经允许不得转载:srcmini » Elixir和OTP中面向过程的编程指南

评论 抢沙发

评论前必须登录!