Elixir 独门机制
协议 / 行为 / 宏 / 热代码——这些是 Elixir 区别于其他 FP 语言的独门武器,读完能看懂任何 Oban/Ecto 库的源码。
这节课假设你已经知道 FP 常见概念(不可变、模式匹配、高阶函数),所以只讲 Elixir 特有的:
- 协议(Protocol)——给已有类型加多态 dispatch,比 OO 继承更灵活
- 行为(Behaviour)——
@callback+@behaviour,等价于 Java interface - 宏(Macro)——编译期代码生成,
defmacro接收 AST 返回 AST - use / import / require / alias——四个容易混的导入机制
- 热代码升级(Hot Code Reload)——零停机部署
**预计时间:**60 分钟。
一、协议(Protocol)——给已有类型加 dispatch
OO 用”继承”实现多态。Elixir 用协议——可以给任何类型(哪怕是第三方库)加 dispatch。
1.1 例子:自定义”字符串化”
Elixir 自带 String.Chars 协议:
defprotocol String.Chars do
def to_string(value)
end
# Elixir 内置实现:
defimpl String.Chars, for: Integer do
def to_string(int), do: Integer.to_string(int)
end
defimpl String.Chars, for: List do
# 把 charlist 转 binary
def to_string(charlist), do: ...
end
# 现在任何 Integer / List 都能用 "..."
"age: #{42}" # 字符串插值内部调用 String.Chars.to_string(42) → "42"
"items: #{[97, 98, 99]}" # → "items: abc"
关键点:defimpl ... for: MyType do ... end 可以写在任何模块里——不需要修改 Integer 源码。这就是 “Open-Closed Principle” 的 Elixir 实现。
1.2 项目例子:JSON 序列化协议
Jason.Encoder 协议让你控制任意 struct 如何序列化成 JSON:
defprotocol Jason.Encoder do
def encode(value, opts)
end
# 自定义 struct 的实现
defimpl Jason.Encoder, for: Pipeline.PipelineExecution do
def encode(%Pipeline.PipelineExecution{} = e, opts) do
Jason.Encode.map(Map.take(e, [:dag_name, :run_id, :status, :row_count]), opts)
end
end
1.3 协议 vs OO 继承
| 维度 | OO 继承(Java/Kotlin) | Elixir 协议 |
|---|---|---|
| 加新行为 | 改基类,所有子类跟着改 | 新加 defimpl,不动已有 |
| 加新类型 | 写新子类 | 新写 defimpl ... for: NewType |
| 已有第三方类型 | 不能扩展(final class) | 可以扩展 |
| dispatch 速度 | vtable(快) | 模式匹配(极快) |
| 性能损耗 | 间接调用 | 编译期 静态分派,几乎零损耗 |
case type do; SomeType -> ...; OtherType -> ...; end,所以 运行时跟手写模式匹配一样快。这就是为什么 Phoenix 的 String.Chars 不会拖慢 HTTP 响应。二、行为(Behaviour)——Java interface 的 FP 版
协议是”给已有类型加 dispatch”,行为是”定义新接口让别人实现”。两者经常一起用:
2.1 完整例子(项目代码:lib/pipeline/llm.ex)
# 1. 定义行为(spec)
defmodule Pipeline.Llm do
@callback summarize_digest(String.t()) :: {:ok, map} | {:error, term}
# 2. 统一 API(运行时找实现)
def summarize_digest(text) when is_binary(text) do
impl().summarize_digest(text)
end
defp impl do
Application.get_env(:pipeline, :llm, Pipeline.Llm.Real)
end
end
# 3. 实现 A:真实 LLM(生产)
defmodule Pipeline.Llm.Real do
@behaviour Pipeline.Llm
def summarize_digest(text) do
# ... 调真实 API
end
end
# 4. 实现 B:测试桩(开发/测试)
defmodule Pipeline.Llm.Dummy do
@behaviour Pipeline.Llm
def summarize_digest(text) when is_binary(text) do
count = text |> String.split("\n", trim: true) |> length()
{:ok, %{summary_md: "...", stats_json: %{"count" => count}, needs_research: count > 10}}
end
end
# 5. 用法
Pipeline.Llm.summarize_digest("...") # 默认走 Real
# 切到 Dummy:Application.put_env(:pipeline, :llm, Pipeline.Llm.Dummy)
2.2 @behaviour 做了什么?
写 @behaviour Pipeline.Llm 后,编译器会:
- 读
Pipeline.Llm里的所有@callback - 检查当前模块是否实现了全部——没实现会编译警告
- 实现签名不匹配会编译错误
这跟 Java/Kotlin 的 interface 完全等价。
2.3 行为 vs 协议 速查
| 何时用 | 用行为 | 用协议 |
|---|---|---|
| 接口固定,实现可能多个 | ✓ | — |
| 已有一堆类型,想加统一接口 | — | ✓ |
| 依赖注入 / 运行时切换实现 | ✓ | — |
| “如何打印 / 序列化 / 比较” | — | ✓ |
三、宏(Macro)——编译期代码生成
这是 Elixir 跟 Clojure 同源最深的特性。代码 = 数据 (homoiconicity),所以宏能操作 AST。
3.1 quote 和 unquote
# quote 把代码转成 AST(Elixir term)
ast = quote do
1 + 2
end
# ast 实际是:
# {:+, _, [1, 2]} # 三元组:运算符 + 元数据 + 操作数列表
# unquote 把 AST 嵌入另一个 AST
x = 10
ast = quote do
1 + unquote(x) # 嵌入 10
end
# 编译后等价于 `1 + 10`
3.2 defmacro 定义宏
defmodule MyMacros do
defmacro my_assert(do: block) do
# 宏接收 block AST,返回新的 AST
quote do
case unquote(block) do
{:ok, value} -> value
{:error, reason} -> raise "Assertion failed: #{inspect(reason)}"
end
end
end
end
# 用法(用 require 让宏可见)
require MyMacros
def test_it(x) do
MyMacros.my_assert do
if x > 0, do: {:ok, x}, else: {:error, :negative}
end
end
# 调用 test_it(5) → 编译期展开 → 5
# 调用 test_it(-1) → 运行时抛 Assertion failed
3.3 真实项目例子:defstruct
defstruct 是个宏,编译时给模块加 __struct__/1、struct/2、字段访问:
defmodule User do
defstruct [:name, :age]
end
# defstruct 展开后等价于:
# defmodule User do
# def new(name, age) do
# %{__struct__: User, name: name, age: age}
# end
# # ...
# end
use Ecto.Schema → 调 Ecto.Schema.__using__/1 宏 → 注入 schema 宏、field 宏、cast/validate_* 等。所有这些都是宏。
3.4 何时用宏?
- 该用宏:定义 DSL(
schema、test、use) - 该用函数:业务逻辑(即使看起来"宏式")
3.5 宏 vs Lisp / Clojure
Elixir 宏跟 Clojure 宏设计哲学一致(homoiconicity + AST 操作),但:
| 维度 | Clojure | Elixir |
|---|---|---|
| AST 类型 | Clojure 数据结构(vector) | Elixir tuple({:+, _, [1, 2]}) |
| 卫生性(hygiene) | 完全 | 手动(var!) |
| 何时展开 | 编译期 | 编译期 |
四、use / import / require / alias——四个导入机制
这是初学者最容易混的。区别:
| 机制 | 作用 | 例子 | 类比其他语言 |
|---|---|---|---|
alias | 给模块起别名 | alias Pipeline.Repo → 写 Repo | Java import x.y.Z as Z |
import | 导入函数到当前命名空间 | import Enum, only: [map: 2] | Python from x import y |
require | 让宏可见(宏要先 require 才能用) | require Logger | — |
use | 调对方模块的 __using__/1 宏(注入代码) | use Oban.Worker | Java annotation processor(粗糙类比) |
4.1 use 到底是什么?
当你写 use SomeModule 时,编译器:
- 找
SomeModule.__using__/1(宏) - 把当前模块传给那个宏
- 宏返回一段 AST,插入到当前模块
例子:
# Oban 内部大致这样
defmodule Oban.Worker do
defmacro __using__(opts) do
quote do
use GenServer
@behaviour Oban.Worker
# ... 注入一堆 handle_call 处理
end
end
end
# 你写:
defmodule MyWorker do
use Oban.Worker, queue: :default # 注入上面所有代码
def perform(_job), do: :ok
end
五、编译时 vs 运行时的边界
Elixir 配置分两层:
# 1. 编译时(必须 mix compile 时定)
@schema_prefix Application.compile_env(:pipeline, :schema_prefix, "") <> "public"
# 2. 运行时(启动后改也生效)
Application.get_env(:pipeline, :llm, Pipeline.Llm.Real)
Application.put_env(:pipeline, :llm, Pipeline.Llm.Dummy)
Application.fetch_env!(:pipeline, Oban)
区别:
compile_env→ 编译时”烧”进 bytecode。改了需要重新编译。性能略好。get_env/put_env→ 运行时,存 VM 应用环境。改了立即生效。
项目例子(lib/pipeline/feishu_message.ex:5)用 compile_env 因为 schema prefix 跟测试有关,编译期就固定了。
六、热代码升级(Hot Code Reload)
BEAM 三十年的招牌特性:不停机更新代码。原理:
- BEAM 加载新模块到 新版本(v2),老版本(v1)继续运行
- 所有 新进程 跑 v2
- 老进程跑完当前请求后,下次调用 v2 模块(按 message 切换)
- 无重启、无流量中断
6.1 Phoenix 的 mix phx.gen.live 改了 live reload
开发模式下,文件一保存,VM 立即重载对应模块——这是 mix.exs 配置的:
def project do
[
# ...
watchers: [yarn: ...]
]
end
Elixir 自带 Code.eval_file/1、:code.load_file/1 可以做热加载。
6.2 生产热升级:AppOps
生产环境热升级是工程化重活——你的代码必须:
- 支持老 state 迁移到新 state(新字段加默认值)
- 避免老进程持有旧模块的 function reference
- 用
:gen_server的 code_change/3 回调做 in-flight state 迁移
对本项目(ex/elixir)来说:研究一下就行,实际生产热升级用 blue-green 部署 + K8s 更常见。
七、Spec 与 Dialyzer(可选类型)
@spec summarize_digest(String.t()) :: {:ok, map} | {:error, term()}
def summarize_digest(text) when is_binary(text) do
...
end
Elixir 动态类型,但 @spec 注释 + mix dialyzer 工具可以静态检查。可选,不像 Haskell/Rust 必须。
八、混合实践:完整 LLM 抽象(项目代码)
把所有知识合起来看 Pipeline.Llm 三件套:
# 文件 1: lib/pipeline/llm.ex
defmodule Pipeline.Llm do
@moduledoc "统一 LLM 客户端。worker 通过本模块调用,具体实现运行时切换。"
@callback summarize_digest(String.t()) :: {:ok, map} | {:error, term}
def summarize_digest(text) when is_binary(text) do
impl().summarize_digest(text)
end
defp impl do
Application.get_env(:pipeline, :llm, Pipeline.Llm.Real)
end
end
# 文件 2: lib/pipeline/llm/real.ex
defmodule Pipeline.Llm.Real do
@moduledoc "真实 LLM 实现:Finch POST OpenAI 兼容 chat/completions"
@behaviour Pipeline.Llm
require Logger
def summarize_digest(text) do
url = Application.get_env(:pipeline, :llm_url)
key = Application.get_env(:pipeline, :llm_key, "")
model = Application.get_env(:pipeline, :llm_model, "default")
body = Jason.encode!(%{
model: model,
messages: [
%{role: "system", content: system_prompt()},
%{role: "user", content: text}
],
response_format: %{type: "json_object"}
})
case Finch.build(:post, url, headers, body) |> Finch.request(Pipeline.Finch) do
{:ok, %Finch.Response{status: 200, body: resp}} -> parse(resp)
{:ok, %Finch.Response{status: code, body: resp}} -> {:error, "llm_http_#{code}"}
{:error, reason} -> {:error, reason}
end
end
# ... 内部 helper
end
# 文件 3: lib/pipeline/llm/dummy.ex
defmodule Pipeline.Llm.Dummy do
@moduledoc "确定性桩:不联网,便于测试与本地开发"
@behaviour Pipeline.Llm
def summarize_digest(text) when is_binary(text) do
count = text |> String.split("\n", trim: true) |> length()
{:ok, %{
summary_md: "## 摘要(测试桩)\n\n#{String.slice(text, 0, 240)}",
stats_json: %{"count" => count, "keywords" => ["测试", "快讯"]},
needs_research: count > 10
}}
end
end
你刚学到的概念都用上了:
- 行为(
@callback+@behaviour)— 抽象接口 - 运行时多态(
Application.get_env)— 选实现 - 协议(隐式,
Jason.Encoder)— JSON 序列化 - 监督树(
{Finch, name: Pipeline.Finch})— HTTP 客户端启动 - 错误处理(
{:ok, x}/{:error, reason})— 显式错误 - 管道(
text |> String.split(...) |> length())— 数据流
九、测验(10 道选择 + 2 道读代码)
协议(Protocol)跟 Java interface 的核心区别?
@behaviour 写在一个模块里,编译时会?
defmacro 跟 def 的关键区别?
use Oban.Worker 内部大致调什么?
quote do 1 + 2 end 返回什么?
Application.get_env vs Application.compile_env 关键区别?
协议 dispatch 在运行时是?
行为 + 协议同时使用常见模式?
defstruct 是?
Elixir 热代码升级的本质是?
读代码题
R1. 读下面代码,Pipeline.Llm.Dummy 编译时会怎样?
R2. 读下面代码,调用 Pipeline.Llm.summarize_digest("hello") 会实际调到哪个实现?