Elixir in Action, Third Edition(2)

第一部分 函数式 Elixir

本书第一部分是对 Elixir 作为一种函数式语言的介绍。

我们首先对 Elixir 和 Erlang 进行概述,讨论这两种技术的目标和优势。

在第二章中,您将学习 Elixir 语言的基本构建模块,例如模块、函数和类型系统。

第三章详细介绍模式匹配和控制流惯用法。

在第四章中,您将学习如何使用不可变数据结构实现更高层次的数据抽象。

第1章 入门

本章内容包括:

■ Erlang 概述

■ Elixir 的优势

这标志着您踏入 Elixir 和 Erlang 世界的旅程正式开始。Elixir 和 Erlang 是两种高效且实用的技术,能够显著简化大型、可扩展系统的开发。您很可能正在阅读本书,以学习 Elixir。但由于 Elixir 构建于 Erlang 之上,并且高度依赖于 Erlang,因此您应该首先了解一下 Erlang 是什么以及它所提供的优势。那么,让我们简要地了解一下 Erlang。

1.1 关于 Erlang

Erlang 是一个开发平台,用于构建可扩展、可靠的系统,这些系统能够持续提供服务,几乎不会出现停机时间。这听起来或许有些夸张,但这正是 Erlang 的设计初衷。Erlang 由瑞典电信巨头爱立信公司于 20 世纪 80 年代中期构思,其开发动力源于该公司自身电信系统的需求,在这些系统中,可靠性、响应速度、可扩展性和持续可用性至关重要。电话网络必须始终保持运行,不受并发呼叫数量、意外故障或硬件/软件升级的影响。

尽管 Erlang 最初是为电信系统而设计的,但它并非专门针对该领域。它没有提供对电话、交换机或其他电信设备的显式编程支持。相反,Erlang 是一个通用开发平台,它为并发性、可扩展性、容错性、分布式和高可用性等技术性、非功能性挑战提供专门支持。

在 20 世纪 80 年代末和 90 年代初,大多数软件都基于桌面系统,因此对高可用性的需求仅限于电信等专业系统。如今,情况截然不同:焦点集中在互联网和网络上,大多数应用程序都由服务器系统驱动和支持,这些服务器系统负责处理请求、处理数据并将相关信息推送给众多连接的客户端。 如今流行的系统更侧重于通信和协作;例如社交网络、内容管理系统、点播多媒体和多人游戏。

这些系统有一些共同的非功能性需求。无论连接的客户端数量多少,系统都必须保持响应。意外错误的影响必须尽可能小,而不是影响整个系统。偶尔因程序错误导致请求失败是可以接受的,但如果整个系统完全不可用,那就成了大问题。理想情况下,系统永远不应该崩溃或宕机,即使是在软件升级期间也不行。它应该始终保持运行,为客户端提供服务。

这些目标看似难以实现,但对于构​​建人们赖以生存的系统而言至关重要。如果系统响应速度慢、可靠性低,最终将无法发挥其应有的作用。因此,在构建服务器端系统时,确保系统持续可用至关重要。

这正是 Erlang 的目标所在。Erlang 通过可扩展性、容错性和分布式等技术概念,明确地支持高可用性。与其他大多数现代开发平台不同,这些概念是 Erlang 开发的主要动机和驱动力。由 Joe Armstrong 领导的爱立信团队花费数年时间进行设计、原型制作和实验,最终创建了这个开发平台。Erlang 在 20 世纪 90 年代初期的应用可能较为有限,但如今,几乎所有系统都能从中受益。

Erlang 近年来备受关注。它为各种大型系统提供支持,例如 WhatsApp 即时通讯应用、Discord 即时通讯平台、RabbitMQ 消息队列、金融系统和多人游戏后端,并且已经应用​​了三十年之久。 Erlang 是一项久经考验、规模庞大的技术。但 Erlang 的奥秘究竟是什么?让我们一起来看看 Erlang 如何帮助您构建高可用性、高可靠性的系统。

1.1.1 高可用性

Erlang 的设计初衷就是为了支持高可用性系统的开发——这些系统始终在线,即使面对意外情况也能为客户提供服务。表面上看,这似乎很简单,但正如您可能知道的,生产环境中可能会出现很多问题。为了使系统 24/7 全天候不间断运行,您首先必须解决一些技术挑战:

容错性——系统必须在发生意外情况时继续运行。可能会出现意外错误、程序漏洞、组件偶尔故障、网络连接中断,甚至运行系统的整个机器崩溃。无论发生什么,您都希望尽可能地将错误的影响范围缩小,从错误中恢复,并保持系统运行并提供服务。

可扩展性——系统应该能够处理任何可能的负载。当然,您不会为了应对未来全球人口都可能使用您的系统而购买大量的硬件,但您应该能够在无需任何软件干预的情况下,通过添加更多硬件资源来应对负载增加。理想情况下,无需重启系统即可实现这些操作。

分布式部署——为了构建一个永不停歇的系统,您需要将其运行在多台机器上。这有助于提升系统的整体稳定性:如果一台机器宕机,另一台机器可以接管其工作。此外,分布式部署还提供了横向扩展的能力——您可以通过向系统中添加更多机器来应对负载增加,从而增加工作单元以支持更高的需求。

响应速度——毋庸置疑,系统应该始终保持合理的速度和响应能力。即使负载增加或出现意外错误,请求处理也不应大幅延长。尤其重要的是,偶尔的耗时任务不应阻塞系统的其他部分或对性能产生显著影响。

实时更新——在某些情况下,您可能需要在不重启任何服务器的情况下推送新版本的软件。例如,在电话系统中,您不希望在软件升级期间中断已建立的通话。

如果您能够应对这些挑战,系统将真正实现高可用性,并能够持续不断地为用户提供服务,无论晴雨。

Erlang 提供了应对这些挑战的工具——这正是它存在的意义。借助 Erlang 并发模型的强大功能,系统可以获得所有这些特性,并最终实现高可用性。 让我们来看看 Erlang 中的并发是如何工作的。

<p>Elixir in Action, Third Edition(2)</p>

1.1.2 Erlang 并发

并发是 Erlang 系统的核心所在。几乎所有重要的基于 Erlang 的生产系统都具有高度并发性。甚至 Erlang 编程语言本身有时也被称为面向并发的语言。Erlang 不依赖于重量级线程和操作系统进程,而是自行处理并发,如图 1.1 所示。

<p>Elixir in Action, Third Edition(2)</p>

图 1.1 Erlang 虚拟机中的并发

基本的并发原语称为 Erlang 进程(不要与操作系统进程或线程混淆),典型的 Erlang 系统运行着成千上万甚至数百万个这样的进程。Erlang 虚拟机,也称为 Bogdan/Björn 的 Erlang 抽象机 (BEAM),使用其自身的调度器将进程的执行分布到可用的 CPU 核心上,从而尽可能地并行化执行。进程的实现方式带来了诸多优势。

容错性

Erlang 进程之间完全隔离。它们不共享内存,一个进程的崩溃不会导致其他进程崩溃。这有助于隔离意外错误的影响。如果发生错误,它只会产生局部影响。此外,Erlang 还提供了检测进程崩溃并采取相应措施的方法;通常,您会启动一个新进程来代替崩溃的进程。

可扩展性

由于不共享内存,进程之间通过异步消息进行通信。这意味着没有复杂的同步机制,例如锁、互斥锁或信号量。因此,并发实体之间的交互更容易开发和理解。

典型的 Erlang 系统被划分为大量并发进程,这些进程协同工作以提供完整的服务。虚拟机可以尽可能高效地并行执行进程。由于可以利用所有可用的 CPU 核心,这使得 Erlang 系统具有良好的可扩展性。

分布式

进程间的通信方式相同,无论这些进程位于同一个 BEAM 实例中,还是位于两台不同的远程计算机上的两个不同实例中。因此,典型的基于 Erlang 的高并发系统可以自动部署到多台机器上。这反过来又使您能够横向扩展——运行一个机器集群来分担系统的总负载。此外,在多台机器上运行使系统具有真正的弹性;如果一台机器崩溃,其他机器可以接管

响应速度

运行时经过专门优化,以提高系统的整体响应速度。我之前提到过,Erlang 通过使用专用调度器来管理多个进程的执行,这些调度器可以交替执行多个 Erlang 进程。调度器是抢占式的——它会为每个进程分配一个很小的执行窗口,然后暂停该进程并运行另一个进程。由于执行窗口很小,单个长时间运行的进程不会阻塞系统的其他部分。 此外,I/O 操作在内部被委托给单独的线程,或者在可用的情况下使用底层操作系统的内核轮询服务。这意味着任何等待 I/O 操作完成的进程都不会阻塞其他进程的执行。

甚至垃圾回收机制也经过专门优化,以提高系统响应速度。回想一下,进程之间完全隔离,不共享内存。这使得每个进程都可以进行垃圾回收;无需停止整个系统,而是根据需要单独回收每个进程的垃圾。这种回收方式速度更快,并且不会长时间阻塞整个系统。事实上,在多核系统中,一个 CPU 核心可以执行短暂的垃圾回收,而其他核心则可以执行常规处理。

正如你所见,并发是 Erlang 的一个关键要素,它不仅仅与并行性相关。由于其底层实现,并发能够提升容错性、分布式特性和系统响应速度。典型的 Erlang 系统会运行大量并发任务,使用成千上万甚至数百万个进程。这在开发服务器端系统时尤其有用,因为服务器端系统通常可以完全用 Erlang 实现。

<p>Elixir in Action, Third Edition(2)</p>

1.1.3 服务器端系统

Erlang 可用于各种应用程序和系统。例如,Erlang 可以用于桌面应用程序,并且常用于嵌入式环境。在我看来,它的优势在于服务器端系统——运行在一个或多个服务器上,并且必须同时服务多个客户端的系统。服务器端系统这个术语表明它不仅仅是一个处理请求的简单服务器。它是一个完整的系统,除了处理请求之外,还必须运行各种后台作业并管理某种服务器级内存状态,如图 1.2 所示。

<p>Elixir in Action, Third Edition(2)</p>

图 1.2 服务器端系统

服务器端系统通常分布在多台机器上,这些机器协同工作以创造业务价值。您可以将不同的组件放置在不同的机器上,也可以将某些组件部署在多台服务器上以实现负载均衡或支持故障转移场景。

而 Erlang 正是能够显著简化这些场景的理想选择。通过提供使代码并发、可扩展和分布式的原语,Erlang 允许您完全使用 Erlang 实现整个系统。图 1.2 中的每个组件都可以实现为一个 Erlang 进程,这使得系统具有可扩展性、容错性和易于分布式部署的特性。借助 Erlang 的错误检测和恢复原语,您可以进一步提高可靠性并从意外错误中恢复。

让我们来看一个实际的例子。我曾参与过两个 Web 服务器的开发,它们的技术需求类似:服务大量客户端、处理长时间运行的请求、管理服务器级内存状态、持久化必须在操作系统进程和机器重启后仍然保留的数据,以及运行后台作业。表 1.1 列出了每个服务器中使用的技术。

表 1.1 两个实际 Web 服务器所用技术的比较(见表格图)

Technical requirement Server A Server B
HTTP server NGINX and Phusion Passenger Erlang
Request processing Ruby on Rails Erlang
Long-running requests Go Erlang
Server-wide state Redis Erlang
Persistable data Redis and MongoDB Erlang
Background jobs cron, Bash scripts, and Ruby Erlang
Service crash recovery Upstart Erlang

服务器 A 由多种技术驱动,其中大多数技术在社区中广为人知。使用这些技术各有其原因:每项技术的引入都是为了解决系统中现有技术的不足。例如,Ruby on Rails 在不同的操作系统进程中处理并发请求。我们需要一种方法在这些不同的进程之间共享数据,因此我们引入了 Redis。同样,MongoDB 用于管理持久化的前端数据,通常是用户相关信息。因此,服务器 A 中使用的每项技术都有其合理性,但整个解决方案看起来却很复杂。它并非包含在一个项目中;各个组件是单独部署的,而且在开发机器上启动整个系统并非易事。我们甚至不得不开发一个工具来帮助我们在本地启动系统!

相比之下,服务器 B 仅依赖一项技术即可满足相同的技术要求,它利用了专为此目的而创建并在大型系统中经过验证的平台特性。此外,整个服务器是一个运行在单个 BEAM 实例中的单个项目——在生产环境中,它仅运行在一个操作系统进程中,使用少量操作系统线程。并发完全由 Erlang 调度器处理,并且系统具有可扩展性、响应速度和容错性。由于它是作为一个单个项目实现的,因此该系统更易于管理、部署和在开发机器上本地运行。

值得注意的是,Erlang 工具并非总是主流解决方案(例如 NGINX 等 Web 服务器、MongoDB 等数据库服务器以及 Redis 等内存键值存储)的完全替代品。但 Erlang 提供了多种选择,允许您先完全使用 Erlang 实现初始解决方案,然后在 Erlang 解决方案不足以满足需求时再采用其他技术。这使得整个系统更加同质化,从而更易于开发和维护。

此外,Erlang 并非孤立存在。它可以运行用 C、C++ 或 Rust 等语言编写的进程内代码,并且可以与消息队列、内存键值存储和外部数据库等外部组件进行通信。因此,选择 Erlang 并不意味着您无法使用现有的第三方技术。相反,您可以根据需要选择使用这些技术,而不是因为您的主要开发平台没有提供相应的工具来解决问题。既然您已经了解了 Erlang 的优势和它擅长的领域,那么让我们更深入地了解一下 Erlang 是什么。

1.1.4 开发平台

Erlang 不仅仅是一种编程语言,它是一个功能齐全的开发平台,由四个不同的部分组成:语言、虚拟机、框架和工具。

Erlang 语言是编写在 Erlang 虚拟机中运行的代码的主要方式。它是一种简单、函数式的语言,具有基本的并发原语。

用 Erlang 编写的源代码会被编译成字节码,然后在 BEAM 中执行。真正的魔法就在这里发生。虚拟机并行化你的并发 Erlang 程序,并负责进程隔离、分布式处理以及系统的整体响应速度。

标准发行版本包含一个名为 Open Telecom Platform (OTP) 的框架。尽管它的名字可能有些误导,但该框架与电信系统没有任何关系。这是一个通用框架,它抽象了许多典型的 Erlang 任务,包括:

  • 并发和分布式模式
  • 并发系统中的错误检测和恢复
  • 将代码打包成库
  • 系统部署
  • 代码实时更新

OTP 在许多生产系统中经过了实战检验,并且已成为 Erlang 不可分割的一部分,两者之间几乎难以区分。甚至官方发行版也名为 Erlang/OTP

这些工具用于执行多种典型任务,例如编译 Erlang 代码、启动 BEAM 实例、创建可部署版本、运行交互式 shell、连接到正在运行的 BEAM 实例等等。BEAM 及其配套工具都是跨平台的。您可以在大多数主流操作系统上运行它们,例如 Unix、Linux 和 Windows。整个 Erlang 发行版都是开源的,您可以在官方网站 (https://www.erlang.org/) 或 Erlang GitHub 代码库 (https://github.com/erlang/otp) 上找到源代码。爱立信仍然负责开发过程,每年发布一个新版本。

1.1.5 与微服务的关系

由于 Erlang 的并发模型及其在提升系统可用性方面的应用,它有时会被拿来与微服务进行比较。因此,让我们花些时间分析一下两者之间的异同。在本节中,“服务”指的是运行在独立操作系统进程中的系统组件。这样的定义过于简化和机械化,但足以满足我们的需求。

将系统拆分为多个服务可以提高系统的容错性和可扩展性。由于系统由多个操作系统进程驱动,因此即使其中一个进程崩溃,对整个系统的影响也会较小。此外,服务可以分布在多台机器上,从而增强系统对硬件故障的抵抗力。最后,运行多个服务实例可以实现系统的水平扩展。

乍一看,将系统拆分为多个服务似乎可以让我们获得 Erlang 的所有优势,尤其是在保持服务规模和范围较小(即微服务)的情况下。

虽然微服务和 Erlang 并发之间存在一些重叠,但值得指出的是,后者能够实现更细粒度的并发。例如,在在线多人游戏中,每个玩家以及每个游戏会话至少会运行一个进程。这将提高系统响应速度,提供垂直扩展的可能性,并增强容错能力。

仅靠微服务无法真正模拟这种并发,因为它需要过多的操作系统进程。通常情况下,一个服务实例会管理多个活动。为了提高响应速度和垂直扩展性,需要结合使用非阻塞 I/O 和操作系统级并发(例如,在每台机器上运行多个服务实例)。为了提高容错能力,则需要采用防御性编码,在代码中手动添加 try…catch 或类似的结构。最终结果是代码更加复杂,但容错能力却更差。

另一方面,微服务提供了一些BEAM难以实现的重要优势。特别是,围绕微服务实践发展起来的生态系统,包括Docker和Kubernetes等工具,显著简化了部署、横向扩展和粗粒度容错等运维任务。理论上,单独使用BEAM也能获得这些优势,但这需要大量的底层手动工作。

因此,BEAM并发性和微服务相辅相成,在实践中经常一起使用。将基于BEAM的服务打包到Docker容器中既简单又可行。服务容器化后,可以轻松地将其部署到托管环境,例如Kubernetes集群。

由于其并发模型,Erlang在架构选择方面提供了很大的灵活性,而无需牺牲系统的可用性。您可以选择更粗粒度的拆分,仅使用与组织结构相匹配的少量服务。在许多情况下,部署在 PaaS 平台(例如 Heroku、Fly.io 或 Gigalixir)上的单体应用就足够了。但如果情况并非如此(例如,系统规模和复杂性不断增长),您可以逐步迁移到(微)服务架构。

Erlang 的故事到此结束。但如果 Erlang 如此出色,为什么还需要 Elixir 呢?下一节将解答这个问题。

1.2 关于 Elixir

Elixir 是 Erlang 虚拟机的替代语言,它允许您编写更简洁、更精炼的代码,更好地表达您的意图。您可以使用 Elixir 编写程序,并在 BEAM 中正常运行它们。

Elixir 是一个开源项目,最初由 José Valim 创建。与 Erlang 不同,Elixir 更注重协作;目前,它拥有约 1200 名贡献者。新功能经常在邮件列表、GitHub 问题跟踪器以及 Libera.Chat (https://libera.chat/) 上的 #elixir-lang IRC 频道中进行讨论。José 拥有最终决定权,但整个项目是一个真正的开源协作项目,吸引了经验丰富的 Erlang 老手和才华横溢的年轻开发者。源代码可以在 GitHub 仓库 https://github.com/elixir-lang/elixir 上找到。

Elixir 的目标运行时是 Erlang。编译 Elixir 源代码的结果是符合 BEAM 规范的字节码文件,这些文件可以在 BEAM 实例中运行,并且通常可以与纯 Erlang 代码协同工作——你可以在 Elixir 中使用 Erlang 库,反之亦然。Erlang 中能做的事情,Elixir 中也几乎都能做到,而且 Elixir 代码的性能通常与其对应的 Erlang 代码一样出色。

Elixir 在语义上与 Erlang 非常接近:它的许多语言结构可以直接映射到 Erlang 中的对应结构。但 Elixir 提供了一些额外的结构,可以大幅减少样板代码和重复代码。此外,它还优化了标准库中的一些重要部分,并提供了一些简洁的语法糖以及用于创建和打包系统的统一工具。Erlang 中能做的事情,Elixir 中也能做到,反之亦然,但根据我的经验,Elixir 解决方案通常更容易开发和维护。

让我们更深入地了解 Elixir 是如何改进 Erlang 的一些特性的。我们将从样板代码和降噪开始。

1.2.1 代码简化

Elixir 最重要的优势之一在于它能够大幅减少样板代码并消除代码中的冗余信息,从而编写出更简洁、更易于维护的代码。让我们通过对比 Erlang 和 Elixir 代码来了解这一点。

在 Erlang 并发系统中,服务器进程是一个常用的构建模块。您可以将服务器进程视为类似并发对象的东西——它们嵌入了私有状态,并且可以通过消息与其他进程交互。由于是并发的,不同的进程可以并行运行。典型的 Erlang 系统高度依赖进程,运行着成千上万甚至数百万个进程。

以下 Erlang 代码示例实现了一个简单的服务器进程,用于将两个数字相加。

示例 1.1 基于 Erlang 的服务器进程,用于将两个数字相加

-module(sum_server).
-behaviour(gen_server).

-export([
 start/0, sum/3,
 init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2,
 code_change/3
]).

start() -> gen_server:start(?MODULE, [], []).
sum(Server, A, B) -> gen_server:call(Server, {sum, A, B}).

init(_) -> {ok, undefined}.
handle_call({sum, A, B}, _From, State) -> {reply, A + B, State}.
handle_cast(_Msg, State) -> {noreply, State}.
handle_info(_Info, State) -> {noreply, State}.
terminate(_Reason, _State) -> ok.
code_change(_OldVsn, State, _Extra) -> {ok, State}.

即使完全不懂 Erlang,对于一个仅仅实现两个数字相加的功能来说,这段代码也显得过于冗长。诚然,这个加法运算是并发的,但即便如此,由于代码量巨大,我们还是很难看清它的本质。这段代码的功能绝对不是一目了然的。此外,编写这样的代码也并非易事。即使拥有多年生产级 Erlang 开发经验,我仍然无法在不查阅文档或从之前的代码中复制粘贴的情况下编写出这样的代码。

Erlang 的问题在于,这种样板代码几乎无法消除,即使在大多数地方都完全相同(就我的经验而言,情况确实如此)。该语言几乎没有提供任何消除这种冗余代码的支持。公平地说,可以使用一种名为解析转换(parse transform)的结构来减少样板代码,但这使用起来既笨拙又复杂。实际上,Erlang 开发者在编写服务器进程时仍然会使用上述模式。

由于服务器进程是 Erlang 中一个重要且常用的工具,Erlang 开发者不得不不断地复制粘贴这些冗余代码并继续使用,这着实令人遗憾。令人惊讶的是,许多人已经习以为常,这或许要归功于 BEAM 的出色功能。人们常说 Erlang 能把难的事情变得简单,把简单的事情变得复杂。然而,之前的代码仍然让人觉得应该可以做得更好。

让我们来看看 Elixir 版本的同一个服务器进程。

清单 1.2:基于 Elixir 的服务器进程,用于实现两个数字的加法。

defmodule SumServer do
 use GenServer

 def start do
   GenServer.start(__MODULE__, nil)
 end

 def sum(server, a, b) do
   GenServer.call(server, {:sum, a, b})
 end

 def handle_call({:sum, a, b}, _from, state) do
   {:reply, a + b, state}
 end
end

Elixir 版本所需的代码量显著减少,因此更易于阅读和维护。它的意图更加清晰,也更少冗余代码。然而,它的功能和灵活性与 Erlang 版本不相上下。它在运行时表现完全相同,并保留了完整的语义。Erlang 版本能做到的,Elixir 版本都能做到。

尽管 Elixir 版本的求和服务器进程代码量显著减少,但考虑到它只是简单地将两个数字相加,仍然会感觉有些冗余。这种冗余的出现是因为 Elixir 与用于创建服务器进程的底层 Erlang 库保持着一一对应的语义关系。

不过,Elixir 提供了一些工具,可以进一步消除你认为的冗余和重复代码。例如,我开发了自己的 Elixir 库 ExActor,它可以使服务器进程定义更加简洁,如下所示。

清单 1.3 基于 Elixir 的服务器进程

defmodule SumServer do
 use ExActor.GenServer

 defstart start

 defcall sum(a, b) do
   reply(a + b)
 end
end

即使是没有任何 Elixir 经验的开发者,也能轻松理解这段代码的意图。运行时,这段代码与前两个版本几乎完全相同。使其行为与之前示例一致的转换发生在编译时。就字节码而言,这三个版本都非常相似。

注意

我提到 ExActor 库只是为了说明 Elixir 的抽象程度。本书不会用到这个库,因为它是一个第三方抽象库,隐藏了服务器进程运行方式的重要细节。要充分利用服务器进程,理解其运行机制至关重要,因此本书将重点介绍底层抽象。一旦理解了服务器进程的工作原理,就可以自行决定是否使用 ExActor 来实现服务器进程。

最终的求和服务器进程实现依赖于 Elixir 的宏机制。宏是编译时运行的 Elixir 代码。宏以源代码的内部表示形式作为输入,并可以生成不同的输出。 Elixir 宏的设计灵感源自 Lisp,不应与 C 风格的宏混淆。与处理纯文本的 C/C++ 宏不同,Elixir 宏基于抽象语法树 (AST) 结构运行,这使得对输入代码进行复杂的操作以获得不同的输出变得更加容易。当然,Elixir 也提供了辅助结构来简化这种转换。

让我们再看一下清单 1.3 中求和运算的定义:

defcall sum(a, b) do
 reply(a + b)
end

注意开头的 defcall。Elixir 中没有这个关键字。这是一个自定义宏,它将给定的定义转换为类似以下内容:

def sum(server, a, b) do
 GenServer.call(server, {:sum, a, b})
end

def handle_call({:sum, a, b}, _from, state) do
 {:reply, a + b, state}
end

由于宏是用 Elixir 编写的,因此它们既灵活又强大,使得扩展语言和引入看起来像是语言固有组成部分的新结构成为可能。例如,旨在将 LINQ 风格的查询(https://learn.microsoft.com/en-us/dotnet/csharp/linq/)引入 Elixir 的开源项目 Ecto 也得益于 Elixir 宏的支持,并提供了一种富有表现力的查询语法,这种语法看起来与语言本身非常相似:

from w in Weather,
 where: w.prcp > 0 or w.prcp == nil,
 select: w

由于 Elixir 拥有宏支持和智能编译器架构,其大部分代码都是用 Elixir 编写的。诸如 if 和 unless 之类的语言结构都是通过 Elixir 宏实现的。只有极小的核心部分是用 Erlang 编写的——其他所有功能都是基于这个核心部分用 Elixir 构建的!

Elixir 宏有点像一门深奥的艺术,但它们使得在编译时消除大量冗余代码成为可能,并且还可以使用类似 DSL 的结构来扩展语言。

但 Elixir 的优势远不止于宏。另一个值得称道的改进是一些看似简单的语法糖,它们极大地简化了函数式编程。

1.2.2 函数组合

Erlang 和 Elixir 都是函数式语言。它们依赖于不可变数据和用于转换数据的函数。这种方法的一个优点是,代码可以拆分成许多小型、可重用、可组合的函数。

然而,Erlang 的组合性机制却不太理想。让我们来看一个我工作中的例子。我负责的一段代码维护着一个内存模型,并接收用于修改该模型的 XML 消息。当收到 XML 消息时,必须完成以下操作:

  • 将 XML 应用于内存模型。
  • 处理由此产生的更改。
  • 持久化模型。

以下是相应函数的 Erlang 代码草图:

process_xml(Model, Xml) ->
 Model1 = update(Model, Xml),
 Model2 = process_changes(Model1),
 persist(Model2).

我不知道你怎么想,但我觉得这段代码不太适合组合。相反,它看起来相当冗杂且容易出错。这里引入临时变量 Model1 和 Model2 的目的仅仅是为了接收一个函数的返回值并将其传递给下一个函数。

当然,你也可以去掉这些临时变量,直接内联函数调用:

process_xml(Model, Xml) ->
 persist(
   process_changes(
     update(Model, Xml)
   )
 ).

这种被称为阶梯式调用的风格,虽然确实不使用临时变量,但却笨拙且难以阅读。要理解其中的逻辑,你必须手动逐字逐句地解析它。

尽管 Erlang 程序员或多或少只能使用这种笨拙的方法,但 Elixir 提供了一种优雅的方式来将多个函数调用链接在一起:

def process_xml(model, xml) do
 model
 |> update(xml)
 |> process_changes()
 |> persist()
end

管道运算符 |> 将前一个表达式的结果作为第一个参数传递给下一个表达式。生成的代码简洁明了,不包含临时变量,并且像散文一样流畅易读——从上到下,从左到右。实际上,这段代码在编译时会被转换成阶梯式结构。这同样得益于 Elixir 的宏系统。

管道运算符凸显了函数式编程的强大之处。你可以将函数视为数据转换工具,然后以不同的方式组合它们,从而获得所需的效果。

1.2.3 概览

Elixir 在许多其他方面都超越了 Erlang 的原始方法。标准库的 API 经过了优化,并遵循一些既定的约定。它引入了语法糖,简化了常见的惯用法。Elixir 还提供了简洁的结构化数据处理语法。字符串操作得到了改进,并且该语言明确支持 Unicode 操作。在工具方面,Elixir 提供了一个名为 Mix 的工具,可以简化创建应用程序和库、管理依赖项以及编译和测试代码等常见任务。此外,它还提供了一个名为 Hex (https://hex.pm/) 的包管理器,可以更轻松地打包、分发和重用依赖项。

Elixir 的优点不胜枚举,但与其逐一介绍,我更想根据自己的实际应用经验来表达一下我的感受。就我个人而言,我发现用 Elixir 编写代码要愉快得多。最终生成的代码似乎更简洁、更易读,并且减少了样板代码、冗余代码和重复代码。同时,您还能保留纯 Erlang 代码的完整运行时特性。您还可以使用 Erlang 生态系统中所有可用的库,包括标准库和第三方库。

1.3 缺点

没有任何技术是万能的,Erlang 和 Elixir 当然也不例外。因此,有必要提及它们的一些不足之处。

1.3.1 速度

Erlang 绝对不是目前速度最快的平台。如果你查看网上各种综合性能测试,通常不会在榜单上看到 Erlang 的身影。Erlang 程序运行在 BEAM 中,因此无法达到 C 和 C++ 等机器编译语言的速度。但这并非 Erlang/OTP 团队的疏忽或设计缺陷。

该平台的目标并非追求每秒处理尽可能多的请求,而是尽可能保持性能的可预测性和可控性。 在给定机器上,您的 Erlang 系统性能水平不应显著下降,这意味着不会出现因垃圾回收器启动等原因导致的意外系统卡顿。此外,如前所述,长时间运行的 BEAM 进程不会阻塞或显著影响系统的其他部分。最后,随着负载增加,BEAM 可以使用所有可用的硬件资源。如果硬件容量不足,您可以预期系统会优雅地降级——请求处理时间会延长,但系统不会瘫痪。这是因为 BEAM 调度器的抢占式特性,它频繁地进行上下文切换,从而保持系统运行,并优先处理短时运行的进程。当然,您可以通过添加更多硬件来应对更高的系统需求。

然而,密集型 CPU 计算的性能不如 C/C++ 等语言的同类计算,因此您可以考虑使用其他语言实现此类任务,然后将相应的组件集成到您的 Erlang 系统中 。如果你的系统大部分逻辑都严重依赖于 CPU,那么你可能应该考虑采用其他技术。

1.3.2 生态系统

围绕 Erlang 构建的生态系统规模不小,但肯定不如其他一些语言的生态系统庞大。撰写本文时,在 GitHub 上快速搜索一下,会发现大约有20,000 个基于 Erlang 的代码库和大约 45,000 个 Elixir 的代码库。相比之下,有超过 1,500,000 个基于 Ruby 的代码库,以及近 7,000,000 个基于JavaScript 的代码库。

你应该意识到,可供选择的库可能不如你习惯的那样丰富,因此,你最终可能会花费更多的时间去做一些在其他语言中只需几分钟就能完成的事情。如果这种情况发生,请记住 Erlang 为你带来的所有好处。正如我所解释的,Erlang 在构建能够长时间运行且几乎没有停机时间的容错系统方面发挥了重要作用。这是一个重大的挑战,也是 Erlang 平台的一个重点。 虽然Erlang的生态系统不够完善确实令人遗憾,但就我的经验而言,它在解决难题方面发挥的显著作用使其成为一个有用的工具。当然,这些难题并非总是那么重要。也许你并不预期系统会承受高负载,或者系统不需要持续运行并具备极高的容错能力。在这种情况下,你或许应该考虑其他拥有更完善生态系统的技术栈。

小结

  • Erlang 是一种用于开发高可用性系统的技术,能够持续提供服务,且停机时间极短甚至为零。它在各种大型系统中经过了三十年的实战检验。
  • Elixir 是一种现代语言,它使 Erlang 平台的开发更加轻松愉快。它有助于更​​高效地组织代码,并抽象化掉样板代码、冗余代码和重复代码。

正文完
 0
评论(没有评论)