mcpField
G
gleam-practice

gleam-practice

github.com/mizchi开发41663 次安装1 周前更新
代码仓库

关于 gleam-practice

gleam-practice 是用于创建和维护 Gleam 项目的技能,也涵盖采用 wisp、mist 和 gleam_otp 的应用。内容包括项目结构、测试、监督树与 CI 实践。

主要功能

  1. 01Gleam 项目搭建指南
  2. 02领域逻辑与 Web 层分离
  3. 03OTP Actor 与监督模式
  4. 04单元、快照和属性测试
  5. 05统一本地与 CI 工作流

适用场景

  1. 01启动 Erlang 目标的 Gleam 项目
  2. 02组织 wisp 与 mist Web 应用
  3. 03测试纯逻辑和 Actor API
  4. 04配置一致的 CI 检查

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/mizchi/skills --skill gleam-practice

安装后在 Claude Code 中输入/gleam-practice确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

README.md
SKILL-ja.md
assets/
justfile
README.md
assets/bench/
http.js
assets/github-actions/
ci.yml
assets/test/
property_test.gleam
qcheck_parallel_test.gleam
snapshot_test.gleam
timeout_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.md
  • assets/justfile
  • assets/github-actions/ci.yml
  • assets/bench/http.js
  • assets/test/snapshot_test.gleam
  • assets/test/property_test.gleam
  • assets/test/qcheck_parallel_test.gleam
  • assets/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 按用途将监督器拆分为三个模块。

类型模块使用场景
staticgleam/otp/static_supervisor子进程在启动时固定(数据库连接池、预配置的 Actor)。仅处理重启。
factorygleam/otp/supervisor_factory使用 ID 动态启动相同的规格(例如,每位用户一个会话)。start_child(name, arg)
dynamicgleam/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 cleam
  • snapshot-review: gleam run -m birdie
  • test-watch: gleam test -- --glacier
  • bench-http: k6 run bench/http.js
  • serve-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 基本工作流:

  1. gleam test
  2. 检查失败或新生成的快照。
  3. gleam run -m birdie
  4. 审查快照并提交。

qcheck 基本工作流:

  1. 为某个纯函数选择一项属性。
  2. 通过 qcheck.given(...) 向其传入生成器。
  3. 出现反例时,使用缩减后的结果编写单元测试。
  4. 对于长时间运行的测试或并行执行,使用 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 check
  • gleam build --warnings-as-errors
  • gleam test

补充检查:

  • 跟踪已弃用的 API:gleam fix
  • 检查文档:gleam docs build
  • 检测未使用的导出项:gleam run -m cleam

性能测量

分三个层级测量。

  1. 纯函数的微基准测试。
  2. Actor/工作区的集成基准测试。
  3. 针对 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 边界是否过宽?

参考资料

评论

支持 Markdown