关于 gleam-practice
gleam-practice 是用于创建和维护 Gleam 项目的技能,也涵盖采用 wisp、mist 和 gleam_otp 的应用。内容包括项目结构、测试、监督树与 CI 实践。
主要功能
- 01Gleam 项目搭建指南
- 02领域逻辑与 Web 层分离
- 03OTP Actor 与监督模式
- 04单元、快照和属性测试
- 05统一本地与 CI 工作流
适用场景
- 01启动 Erlang 目标的 Gleam 项目
- 02组织 wisp 与 mist Web 应用
- 03测试纯逻辑和 Actor API
- 04配置一致的 CI 检查
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ mizchi/ skills --skill gleam-practice安装后在 Claude Code 中输入/gleam-practice确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
README.mdSKILL-ja.mdassets/justfileREADME.mdassets/bench/http.jsassets/github-actions/ci.ymlassets/test/property_test.gleamqcheck_parallel_test.gleamsnapshot_test.gleamtimeout_test.gleam详细介绍
Gleam 实践
在创建新的 Gleam 项目、改进现有项目,或使用 wisp + mist + gleam_otp + just 技术栈进行实现时使用。
默认工作流
- 从
gleam new和gleam add开始。让求解器选择依赖项,而不是手动编写。 - 按 TDD 风格推进:
探索 -> Red -> Green -> 重构。 - 将纯逻辑与状态管理分离。将领域模块与 actor/服务端模块拆分开。
- 保持公共 API 精简。优先使用
pub opaque type,避免随意公开内部构造器或协议类型。 - 将
@external作为最后手段。使用时,将其封装在精简的适配器模块中。 - 保持 Web 层精简。
wisp处理程序应专注于解码、调用服务、编码响应。 - 将任务执行集中在
justfile中,并让 CI 也调用just ci。
项目设置
将此作为新建 Erlang 目标项目的起点。
gleam new my_app --template erlang
cd my_app
gleam add wisp mist gleam_otp gleam_erlang
gleam add gleam_json envoy
gleam add --dev gleeunit
按需添加以下内容。
- HTTP 客户端:
gleam add gleam_http gleam_httpc - 文件 I/O:
gleam add simplifile filepath - 快照测试:
gleam add --dev birdie - 属性测试:
gleam add --dev qcheck qcheck_gleeunit_utils - 交互式测试循环:
gleam remove gleeunit && gleam add --dev glacier - 未使用导出检查:
gleam add --dev cleam - 基准测试:
gleam add --dev glychee - 日志配置:
gleam add logging - 实时重载开发体验:
gleam add --dev olive
外部 CLI:
- HTTP 负载测试:
k6
使用兼容的 wisp 和 mist 组合。与其分别固定版本,不如同时运行 gleam add wisp mist,让求解器同时解析它们,这样更安全。
模板:
assets/README.mdassets/justfileassets/github-actions/ci.ymlassets/bench/http.jsassets/test/snapshot_test.gleamassets/test/property_test.gleamassets/test/qcheck_parallel_test.gleamassets/test/timeout_test.gleam
gleam.toml 默认值
至少填写以下内容。
name = "my_app"
version = "0.1.0"
target = "erlang"
description = "Short package summary"
licences = ["Apache-2.0"]
repository = { type = "github", user = "owner", repo = "repo" }
# Hide modules that are only used inside the package
internal_modules = [
"my_app/internal",
"my_app/http/internal",
]
原则:
- 不要让元数据留空。
- 使用
internal_modules隐藏包内部模块。 - 可以使用宽泛的 semver 范围,但始终要在 CI 中强制使用固定的锁文件。
推荐布局
从这种拆分方式开始。
src/
my_app.gleam # entrypoint
my_app/app.gleam # DI and supervision startup
my_app/web.gleam # router / request handling
my_app/domain.gleam # pure domain logic
my_app/domain_server.gleam # actor wrapper
my_app/http_json.gleam # encoder / decoder
test/
my_app_test.gleam
docs/
reference-ja.md
reference-en.md
bench/
main.gleam
justfile
拆分标准:
domain.gleam:仅包含纯函数*_server.gleam:actor/监督/超时/邮箱web.gleam:路由和 HTTP 映射http_json.gleam:JSON 架构和编解码器app.gleam:仅负责接线
不要将路由器、JSON 编解码器、业务逻辑和 actor 状态混在一个文件中。
随着项目扩展,按以下方式拆分。
web_*.gleam:按功能拆分处理程序web_json_*.gleam:按领域拆分编码器/解码器agent_*.gleam:协议、运行器、工具、传输workspace_*.gleam:覆盖层、补丁、git、会话、运行时*_test_support.gleam:按职责拆分测试辅助代码
Wisp + Mist 框架骨架
src/my_app.gleam
import envoy
import gleam/erlang/process
import gleam/int
import gleam/result
import mist
import my_app/app
import my_app/web
import wisp/wisp_mist
const default_port = 4000
const default_secret_key_base =
"local-dev-secret-key-base-local-dev-secret-key-base-1234567890"
pub fn main() -> Nil {
let port =
envoy.get("PORT")
|> result.map(int.parse)
|> result.flatten
|> result.unwrap(default_port)
let secret_key_base =
envoy.get("SECRET_KEY_BASE")
|> result.unwrap(default_secret_key_base)
let assert Ok(app_state) = app.start()
let assert Ok(_) =
web.app(app_state)
|> wisp_mist.handler(secret_key_base)
|> mist.new
|> mist.bind("0.0.0.0")
|> mist.port(port)
|> mist.start
process.sleep_forever()
}
src/my_app/web.gleam
import gleam/http
import my_app/app.{type App}
import wisp
pub fn app(app: App) -> fn(wisp.Request) -> wisp.Response {
fn(request) {
case request.method, wisp.path_segments(request) {
http.Get, ["healthz"] -> wisp.text_response(200, "ok\n")
_, _ -> wisp.not_found()
}
}
}
Web 层原则:
- 使用
wisp.require_json读取负载。 - 解码失败时返回
400。 - 将领域错误明确映射到
4xx/5xx。 - 功能增多后,按功能拆分处理程序。
OTP 模式
基本结构分为两层:纯状态 和 服务端。
重要:gleam_otp 是 Erlang gen_server 的静态类型封装,其 API 与 gen_server 不兼容。不要直接调用 :gen_server.call/:gen_server.cast。所有内容都应保留在 Subject 和 actor.Message 的体系中。
纯状态
pub opaque type State {
State(hits: Dict(String, Int), limit: Int)
}
pub fn new(limit: Int) -> State {
State(hits: dict.new(), limit: limit)
}
pub fn incr(state: State, key: String) -> #(State, Bool) {
let n = dict.get(state.hits, key) |> result.unwrap(0) + 1
#(State(..state, hits: dict.insert(state.hits, key, n)), n <= state.limit)
}
actor 封装(具体示例)
import gleam/erlang/process.{type Subject}
import gleam/otp/actor
import gleam/otp/supervision
pub opaque type Message {
Check(key: String, reply_to: Subject(Bool))
Reset(key: String)
}
pub opaque type Server { Server(subject: Subject(Message)) }
pub fn start(limit: Int) -> Result(Server, actor.StartError) {
actor.new(new(limit))
|> actor.on_message(handle)
|> actor.start
|> result.map(fn(started) { Server(subject: started.data) })
}
pub fn supervised(limit: Int) -> supervision.ChildSpecification(Server) {
supervision.worker(fn() { start(limit) })
}
fn handle(state: State, msg: Message) -> actor.Next(State, Message) {
case msg {
Check(key, reply_to) -> {
let #(next, ok) = incr(state, key)
process.send(reply_to, ok)
actor.continue(next)
}
Reset(key) ->
actor.continue(State(..state, hits: dict.delete(state.hits, key)))
}
}
// sync call pattern: the caller creates a reply subject and passes it in
pub fn check(s: Server, key: String) -> Bool {
let reply = process.new_subject()
process.send(s.subject, Check(key, reply))
process.receive(reply, 1000) |> result.unwrap(False)
}
pub fn reset(s: Server, key: String) -> Nil {
process.send(s.subject, Reset(key))
}
三种监督树
gleam_otp 按用途将监督器拆分为三个模块。
| 类型 | 模块 | 使用场景 |
|---|---|---|
| static | gleam/otp/static_supervisor | 子进程在启动时固定(数据库连接池、预配置的 Actor)。仅处理重启。 |
| factory | gleam/otp/supervisor_factory | 使用 ID 动态启动相同的规格(例如,每位用户一个会话)。start_child(name, arg) |
| dynamic | gleam/otp/supervisor_dynamic | 动态添加/移除任意规格(例如,作业工作进程池)。start_child(name, spec) / terminate_child |
具体的 API 名称因版本而异。请每次都查阅官方 gleam_otp hex 文档。以下模板假设使用 v0.16+:
// src/my_app/app.gleam
import gleam/otp/static_supervisor as sup
import my_app/db_pool
import my_app/rate_limiter
pub fn start() -> Result(Nil, actor.StartError) {
sup.new(sup.OneForOne)
|> sup.restart_tolerance(intensity: 3, period: 60) // allow up to 3 restarts in 60s
|> sup.add(db_pool.supervised()) // static child
|> sup.add(rate_limiter.supervised(limit: 100)) // static child
|> sup.start
|> result.replace(Nil)
}
如何选择重启策略:
Permanent:退出时始终重启(如数据库连接池或认证服务等基础设施)。Transient:正常退出无需处理,仅在异常退出时重启(作业工作进程)。Temporary:从不重启(一次性任务)。
当超过 period(默认 5 秒)内允许的 restart_intensity(默认 3)时,监督器自身会崩溃,并将故障向上传播。
指南
- 保持
Server不透明(不要让调用方直接操作 Subject)。 - 不要从 HTTP 直接访问状态(始终通过 Actor 操作)。
- 在
app.gleam中汇总监督树。 - 从
OneForOne重启策略开始,并在顶层使用static_supervisor。 - 使用命名 Actor,让测试更轻松。
- 根据任务选择合适的监督器类型:
static(固定)、factory(相同规格、动态)、dynamic(任意规格、动态)。
JSON 编解码器(gleam/dynamic/decode + gleam/json)
Gleam 的 JSON API 往往会随版本变化(gleam_json v2+)。当前惯用的用法:
解码
import gleam/dynamic/decode
import gleam/json
pub type NewTodo { NewTodo(title: String, priority: Int) }
pub fn new_todo_decoder() -> decode.Decoder(NewTodo) {
use title <- decode.field("title", decode.string)
use priority <- decode.optional_field("priority", 0, decode.int)
decode.success(NewTodo(title:, priority:))
}
// Inside a Wisp handler
case decode.run(body, new_todo_decoder()) {
Ok(nt) -> // ... use nt
Error(errors) -> wisp.bad_request("invalid json: " <> string.inspect(errors))
}
decode.field 为必需字段;decode.optional_field(key, default, decoder) 为可选字段。对于嵌套对象,使用 decode.field("user", user_decoder())。
编码
pub fn encode_todo(t: Todo) -> json.Json {
json.object([
#("id", json.int(t.id)),
#("title", json.string(t.title)),
#("done", json.bool(t.done)),
])
}
// Return it from Wisp
wisp.json_response(json.to_string_tree(encode_todo(t)), 201)
列表:json.array(items, of: encode_todo)。
LSP 代码操作:gleam-language-server v1.2+ 提供“生成 JSON 编码器/解码器”功能。将光标放在 Todo 类型上,然后调用代码操作,即可自动生成 encode_todo / todo_decoder。比手动编写更准确。
justfile 模板
如需复用,请先将 assets/justfile 复制到项目根目录,然后进行调整。
set shell := ["bash", "-cu"]
default:
@just --list
deps:
gleam deps download
format:
gleam format
format-check:
gleam format --check .
typecheck:
gleam check
build:
gleam build --warnings-as-errors
test:
gleam test
run:
gleam run
docs:
gleam docs build
check:
gleam format --check .
gleam check
gleam build --warnings-as-errors
gleam test
ci: deps check
clean:
gleam clean
bench *args:
gleam run -m bench/main -- {{args}}
策略:
- Shell 使用
bash。 - CI 入口为
just ci。 - 不要创建专用的
lint命令;使用format-check + build --warnings-as-errors覆盖相关检查。
如有需要,可添加以下命令。
exports: gleam run -m cleamsnapshot-review: gleam run -m birdietest-watch: gleam test -- --glacierbench-http: k6 run bench/http.jsserve-dev: gleam run -m olive
CI 工作流模板
如果使用 GitHub Actions,请先将 assets/github-actions/ci.yml 复制到 .github/workflows/ci.yml,然后只添加项目专属步骤。
原则:
- 将
just ci作为 CI 和本地环境共用的唯一入口。 - 在工作流中固定 Erlang / Gleam 版本。
- 仅当存在
@external、NIF、Wasm 或 Elixir 依赖时,才添加额外的工具链。 - 不要在工作流中堆积 Shell 脚本;将逻辑下沉到
justfile中。
测试
先锁定纯函数,再测试 Actor,最后测试 HTTP。
test/my_app_test.gleam
import gleam/http
import gleeunit
import my_app/app
import my_app/web
import wisp/simulate
pub fn main() -> Nil {
gleeunit.main()
}
pub fn healthz_test() {
let assert Ok(app_state) = app.start()
let response = web.app(app_state)(simulate.request(http.Get, "/healthz"))
assert response.status == 200
assert simulate.read_body(response) == "ok\n"
}
测试策略:
- 直接测试纯模块的状态转换。
- 对于 Actor 模块,只测试公共 API。
- HTTP 测试优先使用
wisp/simulate。 - 为外部 API 注入模拟服务。
- 使用轮询/重试辅助函数,避免使用不稳定的休眠。
- 适合使用快照的输出,使用
birdie。 - 适合进行基于属性的测试的纯逻辑,使用
qcheck。 - 需要加快本地迭代时,使用
glacier。
birdie 基本工作流:
gleam test- 检查失败或新生成的快照。
gleam run -m birdie- 审查快照并提交。
qcheck 基本工作流:
- 为某个纯函数选择一项属性。
- 通过
qcheck.given(...)向其传入生成器。 - 出现反例时,使用缩减后的结果编写单元测试。
- 对于长时间运行的测试或并行执行,使用
qcheck_gleeunit_utils。
关于 qcheck_gleeunit_utils 的说明:
- 仅适用于 Erlang 目标平台。
- 要并行运行所有测试,请使用
run.run_gleeunit。 - 要仅包装一个长时间运行的测试,请使用
test_spec.make。 - 要附加自定义超时时间,请使用
test_spec.make_with_timeout。 - 要明确指定测试组,请使用
test_spec.run_in_parallel/run_in_order。
测试结构
功能逐渐增多后,按职责拆分测试文件。
domain_test.gleam:纯领域逻辑runtime_test.gleam/app_test.gleam:监督和 Actor 集成web_app_test.gleam:HTTP 契约agent_test.gleam:外部 API 编排workspace_session_server_test.gleam:服务端/状态辅助函数workspace_bit_runtime_test.gleam:FFI/git/wasm 集成workspace_session_http_test.gleam:会话 API 契约
也要拆分支持模块。
*_app_test_support.gleam:应用启动、处理器构造、运行时辅助函数*_workspace_test_support.gleam:测试夹具目录、工作区辅助函数
不要把所有内容都塞进一个庞大的测试文件。随着重构推进,也要相应拆分测试。
代码检查与质量
与单独使用第一方 lint 工具相比,Gleam 更适合严格使用格式化工具和编译器警告。
最低要求:
gleam format --check .gleam checkgleam build --warnings-as-errorsgleam test
补充检查:
- 跟踪已弃用的 API:
gleam fix - 检查文档:
gleam docs build - 检测未使用的导出项:
gleam run -m cleam
性能测量
分三个层级测量。
- 纯函数的微基准测试。
- Actor/工作区的集成基准测试。
- 针对 HTTP 端点的负载测试。
原则:
- 将纯函数与 I/O 区分开来,分别作为基准测试目标。
- 将预热与测量分开。
- 关闭调试日志。
- 基准测试前运行
gleam clean并预下载依赖项。 - 不要将微基准测试的数值与 HTTP 负载测试的数值直接比较。
- 对于 FFI/Wasm/Ports,分别测量冷启动和热路径。
工具:
- 微基准测试:创建
bench/模块,并使用gleam run -m bench/main运行。 - 如果需要使用库,请使用
glychee。 - HTTP 负载测试:
k6或wrk。 - BEAM 热点:在
erl/gleam shell中使用:eprof、:fprof、:erlang.statistics(:reductions)。
最简 HTTP 测量示例:
just run
k6 run bench/http.js
首先将 assets/bench/http.js 复制到 bench/http.js。
FFI 和原生集成
使用 @external 时,请遵循以下规则。
- 创建单个适配器模块。
- 仅公开
pub opaque type Handle。 - 不要泄露过多 Erlang/Elixir 类型或 pid。
- 将路径、ETS、NIF、Port 和 Wasm 运行时的细节保留在内部模块中。
- 同时编写正常运行测试和失败测试。
编译器不会验证 @external 实现,因此应尽量缩小 Gleam 侧的接口范围。
其他工具
可以按以下方式考虑常用的外部工具。
gleeunit:默认的单元测试运行器。从这里开始。birdie:快照测试。适合固定 HTTP 响应和生成的文本。qcheck+qcheck_gleeunit_utils:基于属性的测试。适合纯领域逻辑。glacier:交互式/增量测试循环。可作为gleeunit的直接替代品。cleam:检测未使用的导出。适合精简公共 API。glychee:微基准测试。适合比较纯函数和小型集成。k6:HTTP/WebSocket 负载测试。它是外部 CLI,不是 Gleam 软件包。logging:Erlang logger 的配置。适合运维需求较重的项目。olive:支持实时重载的开发代理。只有在想改善 Wisp/Mist 开发体验时才需要使用。
选择:
- 默认:
gleam format/check/build/test。 - 值得优先考虑的补充工具:
birdie、qcheck、cleam、glychee。 glacier和olive:适用于想改善本地开发体验的情况。logging:适用于长期运行的服务或生产应用。
文档
README 至少应涵盖:
- 已实现的内容,以及明确不在范围内的内容。
- 主要模块前缀及其职责。
- 如何启动、运行
just ci,以及主要端点。 - 如何阅读测试文件。
对于中型或更大的项目,应添加 docs/ 目录,并引导读者了解其中的内容。
docs/reference-ja.md:日文实现指南。docs/reference-en.md:英文实现指南。
请在 README 顶部明确说明该项目属于“入门”项目还是“实用参考实现”。
参考项目定位
将 Gleam 项目作为参考实现介绍时,不要让其定位含糊不清。
- 入门示例:采用最精简 OTP 的小型 API。
- 中型参考项目:
wisp + mist + gleam_otp + just + CI + docs。 - 高级集成:FFI、Wasm、外部 LLM、工作区/会话编排。
包含高级内容时,应分别说明每个部分属于“标准 Gleam 风格”,还是“该项目特有的复杂性”。
收尾检查清单
以下是可以认为一个版本“完成”的良好检查点:
- 公共接口与内部实现的边界已整理妥当。
just ci运行通过。- 测试文件已按功能拆分。
- 支持模块已按职责拆分。
- README 涵盖了实现状态和测试布局。
- 如有需要,
docs/中包含参考指南。
审查清单
- 公共构造函数确实有必要吗?
- 能否将
pub type改为pub opaque type? - 是否有模块应该隐藏在
internal_modules中? - Web 层是否承担了超出解码/调用/编码的工作?
- 纯逻辑是否与 Actor 状态混杂?
just ci是否是本地和 CI 共用的入口?gleam build --warnings-as-errors是否运行通过?- 是否写入了绝对路径或仅适用于本机的配置?
- 外部/FFI 边界是否过宽?
参考资料
- Gleam 文档:https://gleam.run/
- Gleam
gleam.toml:https://gleam.run/writing-gleam/gleam-toml/ - Gleam 外部函数:https://gleam.run/documentation/externals/
- Gleam CLI:https://gleam.run/reference/cli/
- Wisp 文档:https://hexdocs.pm/wisp/
- Mist 文档:https://hexdocs.pm/mist/
- just 手册:https://just.systems/man/en/