Learning
VOL. XIII · NO. 08 · Elixir · 01 JAN 1970

Elixir 独门机制

Elixir 编程 · 01 JAN 1970 · 11 min read · 1,780 words
· · ·

协议 / 行为 / 宏 / 热代码——这些是 Elixir 区别于其他 FP 语言的独门武器,读完能看懂任何 Oban/Ecto 库的源码。

这节课假设你已经知道 FP 常见概念(不可变、模式匹配、高阶函数),所以只讲 Elixir 特有的:

  1. 协议(Protocol)——给已有类型加多态 dispatch,比 OO 继承更灵活
  2. 行为(Behaviour)——@callback + @behaviour,等价于 Java interface
  3. 宏(Macro)——编译期代码生成,defmacro 接收 AST 返回 AST
  4. use / import / require / alias——四个容易混的导入机制
  5. 热代码升级(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(快)模式匹配(极快)
性能损耗间接调用编译期 静态分派,几乎零损耗
关键直觉
Elixir 协议在编译期"展开"成 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 后,编译器会:

  1. Pipeline.Llm 里的所有 @callback
  2. 检查当前模块是否实现了全部——没实现会编译警告
  3. 实现签名不匹配会编译错误

这跟 Java/Kotlin 的 interface 完全等价。

2.3 行为 vs 协议 速查

何时用用行为用协议
接口固定,实现可能多个
已有一堆类型,想加统一接口
依赖注入 / 运行时切换实现
“如何打印 / 序列化 / 比较”

三、宏(Macro)——编译期代码生成

这是 Elixir 跟 Clojure 同源最深的特性。代码 = 数据 (homoiconicity),所以宏能操作 AST。

3.1 quoteunquote

# 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__/1struct/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 何时用宏?

金科玉律
"Don't write a macro if a function can do it"(能用函数就别用宏)。
  • 该用宏:定义 DSL(schematestuse
  • 该用函数:业务逻辑(即使看起来"宏式")

3.5 宏 vs Lisp / Clojure

Elixir 宏跟 Clojure 宏设计哲学一致(homoiconicity + AST 操作),但:

维度ClojureElixir
AST 类型Clojure 数据结构(vector)Elixir tuple({:+, _, [1, 2]}
卫生性(hygiene)完全手动(var!
何时展开编译期编译期

四、use / import / require / alias——四个导入机制

这是初学者最容易混的。区别:

机制作用例子类比其他语言
alias给模块起别名alias Pipeline.Repo → 写 RepoJava 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.WorkerJava annotation processor(粗糙类比)

4.1 use 到底是什么?

当你写 use SomeModule 时,编译器:

  1. SomeModule.__using__/1(宏)
  2. 把当前模块传给那个宏
  3. 宏返回一段 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 三十年的招牌特性:不停机更新代码。原理:

  1. BEAM 加载新模块到 新版本(v2),老版本(v1)继续运行
  2. 所有 新进程 跑 v2
  3. 老进程跑完当前请求后,下次调用 v2 模块(按 message 切换)
  4. 无重启、无流量中断

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 道读代码)

1

协议(Protocol)跟 Java interface 的核心区别?

2

@behaviour 写在一个模块里,编译时会?

3

defmacrodef 的关键区别?

4

use Oban.Worker 内部大致调什么?

5

quote do 1 + 2 end 返回什么?

6

Application.get_env vs Application.compile_env 关键区别?

7

协议 dispatch 在运行时是?

8

行为 + 协议同时使用常见模式?

9

defstruct 是?

10

Elixir 热代码升级的本质是?

读代码题

R1

R1. 读下面代码,Pipeline.Llm.Dummy 编译时会怎样?

R2

R2. 读下面代码,调用 Pipeline.Llm.summarize_digest("hello") 会实际调到哪个实现?

**下一步:**看 Lesson 0004 · 与 Go/Clojure/C/Java/Kotlin 核心差异