第 1 章 第一步(First steps)
本章涵盖内容
- Erlang概览
- Elixir的优势
这标志着您正式开启Elixir与Erlang世界的探索之旅。这两项高效实用的技术能极大简化大规模可扩展系统的开发。您阅读本书大概率是为了学习Elixir,但由于Elixir构建于Erlang之上并深度依赖其底层机制,我们首先需要了解Erlang的核心价值。让我们先对Erlang进行高层次的整体性观察。
1.1 关于Erlang(About Erlang)
Erlang是用于构建可扩展、高可靠性系统的开发平台,其设计目标是实现近乎永不停机的持续服务。这个宣言或许显得大胆,却正是Erlang诞生的初衷。20世纪80年代中期,瑞典电信巨头爱立信为满足其电信系统对可靠性、响应能力、可扩展性与持续可用性的严苛要求,孕育了Erlang技术。电话网络必须确保无论并发通话量如何波动、突发故障何时出现,抑或软硬件升级如何进行,服务都永不停歇。
尽管脱胎于电信领域,Erlang绝非该领域的专用工具。它并未内置对电话设备、交换机等电信硬件的编程支持,而是作为一个通用开发平台,专门为解决并发性、可扩展性、容错性、分布式部署与高可用性等技术性非功能性挑战提供支撑。
回溯20世纪80年代末至90年代初,当多数软件还局限于桌面应用时,高可用性需求仅存于电信等特定系统。如今互联网与网络应用已成为主流,绝大多数系统依赖服务端处理请求、运算数据并向海量客户端推送信息。现代主流系统——无论是社交网络、内容管理系统、按需多媒体服务还是多人在线游戏——都侧重于通信与协作,它们共享着以下非功能性需求:系统必须在任意客户端连接量下保持响应;突发错误的影响必须最小化,绝不能波及整个系统;个别请求因故障失败尚可接受,但系统整体瘫痪则构成严重事故;理想情况下系统应当永不崩溃,甚至能在软件升级时保持运行,始终为客户端提供不间断服务。
这些目标看似艰巨,却是构建可信赖系统的必然要求。响应迟缓或可靠性不足的系统终将失去存在价值。因此,构建服务端系统时,保障持续可用性至关重要——而这正是Erlang的设计使命。它通过可扩展性、容错性与分布式等技术理念直接支撑高可用性实现。与多数现代开发平台不同,这些理念是Erlang发展的核心驱动力。在乔·阿姆斯特朗带领的爱立信团队历经多年设计、原型验证与实践后,该平台在90年代初虽应用有限,但如今几乎所有系统都能从中获益。
近年来Erlang重获瞩目,已持续三十年为各类大型系统提供支持:从WhatsApp即时通讯、Discord聊天平台、RabbitMQ消息队列,到金融系统与多人在线游戏后端,其技术价值已在时间与规模的维度上得到充分验证。但Erlang的魔力究竟何在?让我们解析它如何助力构建高可用性系统。
1.1.1 高可用性(High availability)
Erlang专为支撑高可用性系统而生——这类系统能始终保持在线,即使在突发状况下仍持续为客户提供服务。表面看来简单,但实际生产中隐患无处不在。要实现系统7×24小时不间断运行,必须攻克以下技术挑战:
- 容错性(Fault tolerance)——当不可预见的问题发生时系统必须持续运行。无论是突发错误、潜在漏洞、组件偶发故障、网络连接中断,还是运行主机彻底崩溃,都需要尽可能将错误影响局部化,实现故障恢复,保障系统持续服务。
- 可扩展性(Scalability)——系统需能应对任意量级负载。当然无需为"全球用户可能涌入"的极端假设过度配置硬件,但应能在不修改软件的前提下,仅通过增加硬件资源来响应负载增长,理想情况下甚至无需重启系统。
- 分布式部署(Distribution)——要构建永不停止的系统,必须实现多机集群运行。这既提升系统整体稳定性(单机下线时其他节点可接管),又为水平扩展提供可能:通过增加机器来应对负载增长,扩充工作单元以满足更高需求。
- 响应能力(Responsiveness)——系统必须始终保持合理速度与响应性。即使负载激增或突发错误,请求处理也不应严重延迟。特别需要注意的是,偶发的长时任务绝不能阻塞系统其他部分或显著影响整体性能。
- 实时更新(Live update)——某些场景下需要在不重启服务的情况下部署新版本软件。例如电话系统进行软件升级时,必须确保已建立的通话不被中断。
若能成功应对这些挑战,系统将成为真正的高可用性系统,无论晴雨皆能为用户提供持续服务。而Erlang正是为解决这些挑战而生——它提供了一系列工具来应对这些难题。通过Erlang并发模型的力量,系统能够获得所有这些特性,最终实现高可用性的终极目标。接下来让我们深入解析Erlang的并发运行机制。
1.1.2 Erlang并发模型(Erlang concurrency)
并发性是Erlang系统的核心与灵魂。几乎所有基于Erlang的复杂生产系统都是高度并发的,以至于这门编程语言有时被称为"面向并发的语言"。如图1.1所示,Erlang没有依赖重量级的操作系统线程或进程,而是将并发控制权掌握在自己手中。

其基本的并发原语称为Erlang(轻量)进程(注意区别于操作系统的进程或线程)。典型的Erlang系统会运行成千上万个,甚至数百万个这样的进程。Erlang虚拟机(称为Bogdan/Björn的Erlang抽象机,简称BEAM)使用自身的调度器,在可用的CPU核心间分配进程的执行,从而尽可能地实现并行化。这种进程的实现方式带来了诸多优势:
容错性(FAULT TOLERANCE) Erlang进程彼此完全隔离。它们不共享内存,一个进程的崩溃不会导致其他进程崩溃。这有助于将意外错误的影响隔离在局部。即使发生严重问题,其影响范围也仅限于单个进程。此外,Erlang提供了检测进程崩溃并采取补救措施(通常是重启一个新进程来替代崩溃的进程)的机制。
可扩展性(SCALABILITY) 由于不共享内存,进程之间通过异步消息进行通信。这意味着没有复杂的同步机制(如锁、互斥量或信号量)。因此,开发人员可以更简单、更清晰地理解和实现并发实体之间的交互。
典型的Erlang系统被划分为大量并发进程,这些进程相互协作以提供完整的服务。虚拟机能够尽可能高效地将这些进程的执行并行化。因为它们可以利用所有可用的CPU核心,所以Erlang系统具有良好的可扩展性。
分布式能力(DISTRIBUTION) 无论进程是驻留在同一个BEAM实例中,还是分散在两台独立远程计算机的两个不同实例上,它们之间的通信机制是完全相同的。因此,一个典型的高度并发的、基于Erlang的系统天生就具备跨多台机器分布式部署的能力。这使得系统能够进行横向扩展——通过运行一个机器集群来分担总体的系统负载。此外,在多台机器上运行也使系统真正具备了弹性:如果一台机器崩溃,其他机器可以接管其工作。
响应能力(RESPONSIVENESS) Erlang运行时环境经过专门调优,以提升系统的整体响应性。如前所述,Erlang通过使用专门的调度器来交替执行大量Erlang进程,从而将多个进程的执行管理权掌握在自己手中。调度器是抢占式的——它给予每个进程一个小的执行时间片,然后暂停该进程并运行另一个进程。由于执行时间片很短,单个长时间运行的进程不会阻塞系统的其余部分。此外,I/O操作在内部被委托给单独的线程处理;如果底层操作系统支持,还会利用其内核轮询服务。这意味着任何等待I/O操作完成的进程都不会阻塞其他进程的执行。
甚至垃圾回收机制也经过专门调优,以提升系统响应性。请记住,进程是完全隔离且不共享内存的。这使得按进程进行垃圾回收成为可能:无需停止整个系统,每个进程可以根据需要单独进行垃圾回收。这样的回收过程更快速,不会长时间阻塞整个系统。事实上,在多核系统中,完全可能一个CPU核心在进行短暂的垃圾回收,而其他核心仍在正常处理任务。
由此可见,并发性在Erlang中是一个至关重要的元素,其意义远超单纯的并行执行。得益于其底层实现,并发性有力地促进了系统的容错能力、分布式部署和整体响应性。典型的Erlang系统运行着许多并发任务,使用成千上万甚至数百万个进程。这在开发服务端系统时尤其有用,而这些系统往往可以完全用Erlang来实现。
1.1.3 服务端系统(Server-side systems)
Erlang可应用于各种类型的程序和系统,既有基于Erlang的桌面应用实例,也常被用于嵌入式环境。但在我看来,其最擅长的领域在于服务端系统——这类系统运行在一台或多台服务器上,必须同时服务众多客户端。这里的"服务端系统"不仅仅是处理请求的简单服务器,它更是一个完整的体系。如图1.2所示,除了处理请求外,它还必须运行各种后台作业,并管理某种服务器范围内的内存状态。

服务端系统通常分布在多台协作产生业务价值的机器上。您可能将不同组件部署在不同机器上,也可能将某些组件部署在多台服务器上以实现负载均衡或支持故障转移场景。
这正是Erlang能极大简化您工作的地方。通过提供使代码具备并发性、可扩展性和分布式能力的原语,它允许您完全使用Erlang来实现整个系统。图1.2中的每个组件都可以实现为一个Erlang进程,这使得系统具备可扩展性、容错性且易于分布式部署。借助Erlang的错误检测和恢复原语,您可以进一步提高系统的可靠性并从意外错误中恢复。
让我们看一个真实案例。我曾专业参与过两个Web服务器的开发,它们都有相似的技术需求:服务大量客户端、处理长时间运行的请求、管理服务器范围内的内存状态、持久化必须在操作系统进程和机器重启后仍能保留的数据,以及运行后台作业。表1.1列出了每个服务器所使用的技术。
表1.1 两个真实Web服务器所用技术对比
| 技术需求 | 服务器A | 服务器B |
|---|---|---|
| HTTP服务器 | NGINX 和 Phusion Passenger | Erlang |
| 请求处理 | Ruby on Rails | Erlang |
| 长时间请求 | Erlang | Erlang |
| 服务器范围状态 | Erlang | Erlang |
| 可持久化数据 | Redis | Erlang |
| 后台作业 | Redis 和 MongoDB | Erlang |
| 服务崩溃恢复 | cron、Bash脚本和Ruby | Erlang |
服务器A由多种技术支持,其中大多数在社区中广为人知。使用这些技术都有特定原因:每一项都是为了解决系统中已有技术的不足而引入的。例如,Ruby on Rails在独立的操作系统进程中处理并发请求。我们需要一种在这些不同进程之间共享数据的方法,因此引入了Redis。同样,MongoDB用于管理持久化的前端数据(主要是用户相关信息)。因此,服务器A中使用的每一项技术背后都有其逻辑,但整个解决方案看起来相当复杂。它不包含在单个项目中;各个组件是分开部署的,并且在开发机器上启动整个系统也非易事。我们甚至不得不开发一个工具来帮助我们在本地启动系统!
相比之下,服务器B在满足相同技术要求的同时,仅依赖单一技术(Erlang),利用该平台专门为此类目的创建并经过大型系统验证的特性。此外,整个服务器是一个单一项目,运行在单个BEAM实例中——在生产环境中,它仅运行在一个操作系统进程内,使用少量操作系统线程。并发性完全由Erlang调度器处理,系统具备可扩展性、响应性和容错性。由于它被实现为一个单一项目,因此系统更容易管理、部署以及在开发机器上本地运行。
需要注意的是,Erlang工具并非总是主流解决方案的完全替代品,例如像NGINX这样的Web服务器、像MongoDB这样的数据库服务器以及像Redis这样的内存键值存储。但Erlang为您提供了选择:它使您能够首先使用纯粹的Erlang来实现初始解决方案,仅在Erlang方案不足时才求助于其他技术。这使得整个系统更加同质化,从而更易于开发和维护。
同样值得注意的是,Erlang并非一座孤岛。它可以运行用C、C++或Rust等语言编写的进程内代码,并且可以与外部组件(如消息队列、内存键值存储和外部数据库)进行通信。因此,选择Erlang并不会剥夺您使用现有第三方技术的权利。相反,您可以根据实际需要选择使用它们,而不是因为您的主要开发平台没有提供解决问题的工具。
现在您已经了解了Erlang的优势及其擅长领域,让我们更深入地看看Erlang究竟是什么。
1.1.4 开发平台(The development platform)
Erlang不仅是一门编程语言,更是一个成熟的开发平台,由四个独立部分组成:语言、虚拟机、框架和工具。
Erlang语言是编写运行在Erlang虚拟机中代码的主要方式。它是一门简单的函数式语言,具备基本的并发原语。用Erlang编写的源代码被编译成字节码,然后在BEAM中执行。真正的魔力就发生在这里:虚拟机将你的并发Erlang程序并行化,并负责进程隔离、分布式部署以及系统的整体响应性。
其发行版的标准组成部分是一个名为**开放式电信平台(OTP)**的框架。尽管名称有些误导性,但该框架与电信系统毫无关系。它是一个通用框架,抽象了许多典型的Erlang任务,包括:
-
并发与分布式模式
-
并发系统中的错误检测与恢复
-
将代码打包成库
-
系统部署
-
实时代码更新
OTP已在许多生产系统中经过实战检验,并且与Erlang结合得如此紧密,以至于很难在两者之间划清界限。甚至官方发行版也被称为Erlang/OTP。
工具集用于完成多种典型任务,例如编译Erlang代码、启动BEAM实例、创建可部署的发行包、运行交互式Shell、连接到运行中的BEAM实例等等。BEAM及其配套工具都是跨平台的,可以在大多数主流操作系统(如Unix、Linux和Windows)上运行。整个Erlang发行版是开源的,您可以在官方网站或Erlang的GitHub仓库找到源码。爱立信仍然负责开发流程,每年发布一个新版本。
1.1.5 与微服务的关系(Relationship to microservices)
由于其并发模型及其在提升系统可用性方面的应用方式,Erlang有时会与微服务进行比较。因此,让我们花些时间分析两者的异同。在本节中,"服务"指的是运行在独立操作系统进程中的系统部分。这个定义过于简化且非常机械,但足以满足我们的讨论需要。
将系统拆分为多个服务可以提高系统的容错性和可扩展性。由于系统由多个操作系统进程驱动,如果一个进程崩溃,对整个系统的影响较小。此外,服务可以分布在多台机器上,这使得系统对硬件故障更具弹性。最后,运行服务的多个实例可以使系统实现水平扩展。
乍一看,似乎通过将系统拆分为服务(特别是如果我们保持服务的规模和范围较小,即微服务),就能获得Erlang的所有优势。虽然微服务与Erlang并发确实存在一些重叠,但值得指出的是,后者带来了更细粒度的并发性。例如,在一个在线多人游戏中,您可能会为每个参与的玩家以及每个游戏会话至少运行一个(Erlang轻量)进程。这将提高系统响应性,提供垂直扩展的潜力,并增强容错能力。仅靠微服务很难真正模拟这一点,因为那将需要过多的操作系统进程。相反,您通常会用一个服务实例来管理多项活动。为了提高响应性和垂直可扩展性,您需要结合使用非阻塞I/O和操作系统级并发(例如,在每台机器上运行几个服务实例)。为了提高容错性,您需要采用防御性编码,手动在代码各处放置try...catch或类似结构。最终结果是代码更复杂,而保障却更弱。
另一方面,微服务提供了一些BEAM不易实现的重要优势。特别是围绕该实践发展起来的生态系统,包括Docker和Kubernetes等工具,极大地简化了部署、水平扩展和粗粒度容错等运维任务。理论上,单独使用BEAM也可以获得这些好处,但这需要大量底层的、手动的工作。
因此,BEAM的并发模型与微服务可以很好地互补,在实践中经常结合使用。将BEAM驱动的服务打包到Docker容器中是可行且直接的。一旦服务被容器化,就可以轻松地将其部署到某些托管环境中,例如Kubernetes集群。
得益于其并发模型,Erlang在选择架构时为您提供了很大的灵活性,而不会迫使您在系统可用性上妥协。您可以选择更粗粒度的拆分,仅使用少数几个与组织结构对齐的服务。在许多情况下,部署到Heroku、Fly.io或Gigalixir等平台即服务上的单体架构就足够了。如果情况发生变化(可能是因为系统规模和复杂性增长),您可以逐步转向(微)服务架构。
关于Erlang的介绍到此为止。但如果Erlang如此出色,为什么还需要Elixir呢?下一节旨在回答这个问题。
1.2 关于(About Elixir)
Elixir是Erlang虚拟机的另一种语言选择,它能让你编写更清晰、更简洁的代码,从而更好地表达你的意图。你用Elixir编写程序,并在BEAM中正常运行它们。
Elixir是一个开源项目,最初由José Valim发起。与Erlang不同,Elixir更注重社区协作;目前已有约1200位贡献者。新功能经常在邮件列表、GitHub问题追踪器以及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 代码简化(Code simplification)
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的问题在于,这种样板代码几乎无法移除,即使它在大多数地方都是相同的(根据我的经验,情况确实如此)。这门语言几乎不支持消除这种干扰项。公平地说,有一种叫做"解析转换"的结构可以减少样板代码,但它使用起来笨拙且复杂。在实践中,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库保持着1:1的语义关系。
但Elixir为你提供了工具,可以进一步消除你可能视为干扰项和重复的东西。例如,我开发了自己的Elixir库,名为ExActor,它使服务器进程的定义非常紧凑,如下所示。
代码清单1.3 基于ExActor的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编写的,所以它们既灵活又强大,使得扩展语言并引入看起来像语言固有部分的新结构成为可能。例如,旨在为Elixir带来LINQ风格查询的开源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 函数组合(Composing functions)
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 全局视角(The big picture)
Elixir在改进原始Erlang方法的其他许多方面也有所建树。标准库的API经过整理,遵循一些明确的约定。引入了简化典型惯用法的语法糖。提供了用于处理结构化数据的简洁语法。字符串操作得到改进,语言明确支持Unicode操作。在工具方面,Elixir提供了一个名为Mix的工具,简化了常见任务,例如创建应用程序和库、管理依赖项以及编译和测试代码。此外,还有一个名为Hex的包管理器,使得打包、分发和复用依赖项变得更加简单。
优点不胜枚举,但我不想逐一列举每个功能,而是希望基于我个人的生产经验表达一点感受。就个人而言,我发现用Elixir编码要愉快得多。生成的代码似乎更简单、更易读,并且较少受样板代码、干扰项和重复的拖累。同时,您保留了纯Erlang代码的完整运行时特性。您还可以使用来自Erlang生态系统的所有可用库,无论是标准的还是第三方的。
1.3 缺点(Disadvantages)
没有哪种技术是万能的,Erlang和Elixir当然也不例外。因此,有必要提及它们的一些不足之处。
1.3.1 速度(Speed)
Erlang显然不是最快的平台。如果你查看互联网上的各种综合性能基准测试,通常不会看到Erlang名列前茅。Erlang程序运行在BEAM中,因此无法达到像C和C++这类机器编译语言的速度。但这并非偶然,也非Erlang/OTP团队的工程能力不足。
该平台的目标不是尽可能榨取每秒请求数,而是尽可能保持性能的可预测性和稳定性。你的Erlang系统在给定机器上达到的性能水平不应显著下降,这意味着不会因为例如垃圾回收器启动而产生意外的系统卡顿。此外,如前所述,长时间运行的BEAM进程不会阻塞或显著影响系统的其余部分。最后,随着负载增加,BEAM可以利用所有可用的硬件资源。如果硬件容量不足,系统会出现性能的优雅降级——请求处理时间会变长,但系统不会瘫痪。这得益于BEAM调度器的抢占式特性,它执行频繁的上下文切换,使系统保持运行,并优先处理短时进程。当然,你可以通过添加更多硬件来应对更高的系统需求。
尽管如此,密集的CPU计算性能不如C/C++等语言,因此你可以考虑用其他语言实现此类任务,然后将相应组件集成到你的Erlang系统中。如果你的系统大部分逻辑都是计算密集型的,你可能需要考虑其他技术。
1.3.2 生态系统(Ecosystem)
围绕Erlang建立的生态系统不算小,但肯定不如某些其他语言的生态系统庞大。在撰写本文时,在GitHub上快速搜索显示,基于Erlang的仓库约有20,000个,基于Elixir的约有45,000个。相比之下,基于Ruby的仓库超过1,500,000个,基于JavaScript的则接近7,000,000个。
你应该意识到,可用的库选择可能不如你习惯的那么丰富,因此你最终可能会在一些事情上花费额外时间,而这些事情在其他语言中只需几分钟就能解决。如果遇到这种情况,请记住你从Erlang中获得的所有好处。正如我所解释的,Erlang在实现能够长时间运行、几乎零停机的容错系统方面大有作为。这是一个重大挑战,也是Erlang平台的特定重点。尽管不可否认,生态系统不如它可能达到的那样健壮是件憾事,但根据我的经验,Erlang在解决难题方面提供的巨大助力使其成为一个非常有用的工具。当然,这些难题并不总是那么重要。也许你不需要应对高负载,或者系统不需要持续运行并具备极高的容错性。在这种情况下,你可以考虑其他具有更成熟生态系统的技术栈。
总结(Summary)
- Erlang是用于开发高可用性系统的技术,这类系统能够持续提供服务,停机时间极少甚至为零。它已在各种大型系统中经过三十年的实战检验。
- Elixir是一门现代语言,它使得为Erlang平台进行开发变得更加愉快。它有助于更高效地组织代码,并抽象掉样板代码、干扰项和重复部分。