第 11 章 使用组件(Working with components)学习笔记
本章内容为原创学习笔记与概念整理,不是原书逐段翻译。
前面几章已经能构建一个由多个进程组成、带监督树和容错边界的小系统。从本章开始,关注点转向生产环境:如何把系统组织成可启动、可配置、可依赖、可替换的组件。
Elixir/OTP 中的“组件”不只是一个模块或目录,更常见的单位是 OTP application。一个 application 可以包含模块、监督树、运行时配置、依赖声明和启动入口。理解 application,是把示例程序推进到真实服务的重要一步。
11.1 从模块到组件
模块解决代码组织问题,进程解决并发和状态边界问题,监督树解决生命周期和容错问题。而组件要解决更大的问题:
- 这个子系统如何启动?
- 它依赖哪些库或其他 application?
- 它需要哪些配置?
- 它提供哪些公开 API?
- 它内部有哪些进程需要被监督?
- 其他组件应该如何使用它?
一个组件应尽量有清晰外壳。调用方通过公开模块 API 使用它,而不是知道内部监督树、ETS 表、worker 名称或文件路径。
在待办系统中,可以把核心业务组织成一个 Todo application。它对外暴露:
- 创建或获取某个列表。
- 添加条目。
- 查询条目。
- 删除或归档条目。
内部则可以自由使用 GenServer、DynamicSupervisor、Registry、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
可以用下面的步骤复盘本章:
- 在
mix.exs中声明 application 启动模块。 - 创建
Todo.Application,启动根监督树。 - 把数据库、注册表、动态监督者和缓存放入 children。
- 增加一个公开
Todo模块,隐藏内部进程查找。 - 为数据库路径、HTTP 端口等值设计配置。
- 为公开 API 写测试,而不是直接测内部 worker。
进阶思考:
- 如果 Web 层崩溃,核心待办服务是否应该重启?
- 如果数据库组件启动失败,HTTP server 是否还应该启动?
- 哪些配置缺失应该让 application 启动失败?
- 哪些依赖只是库,哪些依赖也是 OTP application?
本章复盘
- OTP application 是可启动、可配置、可依赖的系统组件。
- application 启动模块通常只描述监督树,不承载业务逻辑。
- 依赖可能带有自己的 OTP application、进程和配置。
- Web 层应作为协议适配边界,通过业务 API 调用核心组件。
- 配置应在清晰位置读取、校验并传给子进程,生产敏感值更适合运行时配置。
- 组件 API 应隐藏进程、注册表、ETS 和监督树细节。
- 返回值风格要稳定,便于 Web、CLI、测试和后台任务复用。
- application 拆分应服务于真实边界,而不是为了形式上的复杂架构。