Skip to main content

第 11 章 使用组件(Working with components)学习笔记

本章内容为原创学习笔记与概念整理,不是原书逐段翻译。

前面几章已经能构建一个由多个进程组成、带监督树和容错边界的小系统。从本章开始,关注点转向生产环境:如何把系统组织成可启动、可配置、可依赖、可替换的组件。

Elixir/OTP 中的“组件”不只是一个模块或目录,更常见的单位是 OTP application。一个 application 可以包含模块、监督树、运行时配置、依赖声明和启动入口。理解 application,是把示例程序推进到真实服务的重要一步。

11.1 从模块到组件

模块解决代码组织问题,进程解决并发和状态边界问题,监督树解决生命周期和容错问题。而组件要解决更大的问题:

  • 这个子系统如何启动?
  • 它依赖哪些库或其他 application?
  • 它需要哪些配置?
  • 它提供哪些公开 API?
  • 它内部有哪些进程需要被监督?
  • 其他组件应该如何使用它?

一个组件应尽量有清晰外壳。调用方通过公开模块 API 使用它,而不是知道内部监督树、ETS 表、worker 名称或文件路径。

在待办系统中,可以把核心业务组织成一个 Todo application。它对外暴露:

  • 创建或获取某个列表。
  • 添加条目。
  • 查询条目。
  • 删除或归档条目。

内部则可以自由使用 GenServerDynamicSupervisorRegistry、ETS 或数据库进程。只要外部 API 保持稳定,内部实现就可以演进。

11.2 OTP application 的角色

在 Mix 项目中,mix.exs 描述项目元信息、依赖和 application 配置。一个典型片段可能包含:

def application do
[
extra_applications: [:logger],
mod: {Todo.Application, []}
]
end

这里有两个重要信息:

  • extra_applications 声明需要启动的 OTP application,例如 :logger
  • mod 指定当前 application 的启动模块。

启动模块通常实现 Application behaviour:

defmodule Todo.Application do
use Application

@impl Application
def start(_type, _args) do
children = [
Todo.Database,
{Registry, keys: :unique, name: Todo.Registry},
{DynamicSupervisor, strategy: :one_for_one, name: Todo.ListSupervisor},
Todo.Cache
]

Supervisor.start_link(children, strategy: :one_for_one, name: Todo.Supervisor)
end
end

这个模块是系统启动入口。它不应包含大量业务逻辑,而应描述启动哪些子进程以及监督策略。

application 和 supervision tree 的关系

application 负责告诉 BEAM:“这个系统启动时运行这棵监督树。”监督树负责具体子进程生命周期。

可以把层次理解为:

OTP application
|
v
root supervisor
|
+-- registry
+-- dynamic supervisor
+-- database worker
+-- cache/server workers

application 自身不是业务进程;它是可启动单元。真正干活的是监督树下的 worker 和 supervisor。

11.3 依赖管理

真实系统通常依赖外部库。Mix 用 deps/0 声明依赖:

defp deps do
[
{:plug_cowboy, "~> 2.7"},
{:jason, "~> 1.4"}
]
end

依赖会引入新的模块,也可能引入新的 OTP application。某些依赖需要随系统启动,某些只是编译或运行时函数库。

依赖管理要关注:

  • 版本范围是否过宽或过窄。
  • 依赖是否需要配置。
  • 依赖是否启动自己的监督树。
  • 依赖失败会不会影响当前 application 启动。
  • 升级依赖是否改变运行时行为。

不要把依赖当成普通代码文件。很多 Elixir 库本身就是 OTP application,可能有自己的进程、配置和生命周期。

11.4 Web 入口作为组件边界

一个服务端系统通常需要对外提供 HTTP API。Elixir 生态中可以用 Plug、Cowboy 或 Phoenix 等工具构建 Web 层。

本章关心的重点不是具体 Web 框架,而是边界:

  • Web 层负责协议适配:HTTP 请求、参数解析、状态码、JSON 编码。
  • 业务组件负责领域操作:添加待办、查询列表、归档条目。
  • 持久化组件负责数据存取。

不要让 Web handler 直接操作内部进程结构。例如,不要在路由处理函数里拼接底层消息,或者直接访问 ETS 表。更好的方式是调用业务 API:

case Todo.add_entry(list_name, entry) do
:ok ->
send_resp(conn, 201, "")

{:error, reason} ->
send_resp(conn, 400, inspect(reason))
end

这样 Web 层可以替换,业务组件也可以被 CLI、测试、后台任务复用。

Web server 的启动

Web server 本身也可以作为监督树中的 child。这样应用启动时,业务进程和 HTTP 入口一起被监督。

概念结构:

Todo.Application
|
+-- Todo.CoreSupervisor
|
+-- TodoWeb.Endpoint / Plug.Cowboy child

如果 Web server 崩溃,监督者能重启它。如果核心业务监督树崩溃,也可以按策略让整个 application 失败并交给更上层恢复。

11.5 配置

生产系统不能把所有值写死在代码里。端口、数据库路径、外部服务地址、日志级别、功能开关,都属于配置。

Elixir 中常见配置来源包括:

  • config/*.exs 编译期或启动期配置。
  • Application.get_env/3 读取 application 环境。
  • 运行时配置文件,例如 runtime.exs
  • 系统环境变量。
  • release 启动参数。

配置设计要注意两类时间:

  • 编译期配置:编译时确定,变更后通常需要重新构建。
  • 运行时配置:启动时或运行时读取,更适合部署环境差异。

生产部署中,端口、密钥、数据库 URL 这类值通常应来自运行时环境,而不是编译进构建产物。

配置读取位置

不要在代码各处随意读取配置。更好的习惯是:

  • 在 application 启动时读取配置。
  • 把配置转成明确参数传给子进程。
  • 对必需配置做校验。
  • 为可选配置提供合理默认值。

例如:

database_path =
Application.fetch_env!(:todo, :database_path)

children = [
{Todo.Database, database_path: database_path}
]

这样配置缺失会在启动时暴露,而不是运行到某个请求才失败。

配置不是状态

配置描述部署环境和启动参数,不应该被当作业务状态存放。业务状态应进入数据库、ETS、进程状态或事件流。把业务数据塞进 application env 会让测试、并发和热更新都变得混乱。

11.6 组件 API 设计

组件对外 API 应隐藏内部结构,同时表达业务语义。比如待办系统可以提供:

Todo.add_entry("work", %{date: ~D[2026-07-04], title: "Write notes"})
Todo.entries("work", ~D[2026-07-04])
Todo.archive_done("work")

调用方不需要知道:

  • 列表进程是否存在。
  • 进程是否通过 Registry 查找。
  • 状态是否存在 ETS。
  • 写入是否经过数据库 worker。
  • 监督树如何组织。

这样做有两个好处:

  • 测试更贴近业务行为。
  • 组件内部可以重构,而不破坏外部调用方。

返回值风格

生产组件应有稳定返回值约定。常见风格是:

  • 成功且有值:{:ok, value}
  • 成功无值::ok
  • 失败:{:error, reason}:error

不要在同一 API 中混合过多返回形状。稳定返回值能让 Web 层、CLI 和测试都更清楚。

11.7 应用边界与拆分

一个 Mix 项目可以包含一个 application,也可以在 umbrella 项目中包含多个 application。是否拆分取决于边界是否真的独立。

可以考虑拆分的信号:

  • 子系统有独立生命周期。
  • 子系统可以被其他项目复用。
  • 子系统有自己的配置和依赖。
  • 子系统团队或发布节奏不同。
  • 当前 application 已经过大,边界清晰但耦合在一起。

不要为了“架构感”过早拆分。拆分会带来依赖管理、配置、测试和发布复杂度。先让模块边界和 API 清楚,再考虑 application 级拆分。

练习:把待办系统整理成 application

可以用下面的步骤复盘本章:

  1. mix.exs 中声明 application 启动模块。
  2. 创建 Todo.Application,启动根监督树。
  3. 把数据库、注册表、动态监督者和缓存放入 children。
  4. 增加一个公开 Todo 模块,隐藏内部进程查找。
  5. 为数据库路径、HTTP 端口等值设计配置。
  6. 为公开 API 写测试,而不是直接测内部 worker。

进阶思考:

  • 如果 Web 层崩溃,核心待办服务是否应该重启?
  • 如果数据库组件启动失败,HTTP server 是否还应该启动?
  • 哪些配置缺失应该让 application 启动失败?
  • 哪些依赖只是库,哪些依赖也是 OTP application?

本章复盘

  • OTP application 是可启动、可配置、可依赖的系统组件。
  • application 启动模块通常只描述监督树,不承载业务逻辑。
  • 依赖可能带有自己的 OTP application、进程和配置。
  • Web 层应作为协议适配边界,通过业务 API 调用核心组件。
  • 配置应在清晰位置读取、校验并传给子进程,生产敏感值更适合运行时配置。
  • 组件 API 应隐藏进程、注册表、ETS 和监督树细节。
  • 返回值风格要稳定,便于 Web、CLI、测试和后台任务复用。
  • application 拆分应服务于真实边界,而不是为了形式上的复杂架构。