要点
做一个书签收藏 API:POST /bookmarks 收一条 JSON、GET /bookmarks/:id 取一条、GET /bookmarks 列出全部,数据放内存里,多请求并发不丢不乱。这个”BEAM 上没有共享可变内存”的约束会让每个设计决策都变重——状态放哪个进程、请求怎么拿到它、崩了算谁的,一个都绕不开。技术底座是 mist 5(BEAM 原生 HTTP 服务器)与 wisp 2(2025 年 9 月初发布 2.0,本文写作时的当前大版本);开山篇(Gleam 入门)只给了 hello world 一瞥,本文把它变成能跑的服务。
目标与验收
| 指标 | 目标 | 怎么测 |
|---|---|---|
| 接口契约 | 状态码语义准确:201/415/422/404/405 | gleeunit 覆盖每个状态码(simulate 不起端口直调 handler) |
| 并发正确性 | 并发写入不丢不重 | 并发发 50 条 Add 后校验总数;或双终端 curl 对照 |
| 崩溃兜底 | 处理函数 panic 不拖垮服务器,调用方拿到 500 | 临时加一条 panic 路由,curl 确认返回 500 且服务继续响应 |
| 可部署 | shipment 产物在有 OTP 的机器上直接跑 | gleam export erlang-shipment 后执行产物内 run 脚本 |
性能不在验收清单里:单机小服务的瓶颈不在框架层,压测本站未实测,文末用公开口径交代边界。
整体架构
mist.new > mist.port(8000) > mist.start # BEAM 上的 HTTP 服务器,每连接一个进程
│ 连接包装成 wisp Request
▼
wisp_mist.handler(router.handle_request) # wisp 适配层
│
web.middleware 栈 # method_override → log_request → rescue_crashes → ...
│
router.handle_request # path_segments 路由分发
│ process.call(请求-响应消息)
▼
Store (actor) # 全服务唯一"可变点":Dict 状态串行化
选型一句话:HTTP 层用 wisp 而不是裸拼 gleam_http + mist——内容协商、请求日志、崩溃兜底、方法覆盖这些”每个服务都要重写一遍”的东西,wisp 做成了中间件,代价是出问题时多读一层源码:
| 方案 | 定位 | 优点 | 代价与适用 |
|---|---|---|---|
| gleam_http + mist 裸拼 | 类型库 + 服务器,中间全自己写 | 依赖最少,贴底可控 | 日志、兜底、表单解析自己造,适合学原理 |
| wisp 2.x(本文) | 实用向全家桶 | require_json/log_request/rescue_crashes 开箱即用 | 多一层抽象;本文场景首选 |
| Elixir Phoenix | 同 VM 的重型框架 | 生态最厚 | 引入整套 mix/ecto 体系,与”小服务”目标不符 |
克制建议:状态放内存 actor 就够,不要第一版就上数据库——Gleam 生态的数据库包装尚在早期,先让接口契约稳定。
难点一:路由与中间件栈
wisp 的入口是一个”请求进、响应出”的函数,路由就是对这个函数做模式匹配。依赖 gleam add wisp mist gleam_json gleam_erlang gleam_otp 后(wisp_mist 适配模块随 wisp 包附带),服务器骨架十行:
import bookmark/router
import gleam/erlang/process
import mist
import wisp
import wisp/wisp_mist
pub fn main() {
wisp.configure_logger()
let secret_key_base = wisp.random_string(64)
let assert Ok(_) =
wisp_mist.handler(router.handle_request, secret_key_base)
|> mist.new
|> mist.port(8000)
|> mist.start
process.sleep_forever() // 服务器跑在别的进程,主进程挂起等它
}
中间件栈单独一个模块,顺序即语义:
import wisp
pub fn middleware(
req: wisp.Request,
handle_request: fn(wisp.Request) -> wisp.Response,
) -> wisp.Response {
let req = wisp.method_override(req) // _method 参数模拟 PUT/DELETE
use <- wisp.log_request(req) // 每请求一条访问日志
use <- wisp.rescue_crashes // handler 崩了回 500,服务器不死
use req <- wisp.handle_head(req) // HEAD 改写为 GET,返回空体
handle_request(req)
}
路由分发用 path_segments 拆路径、按方法分流:
import bookmark/store
import gleam/http.{Get, Post}
import wisp.{type Request, type Response}
pub fn handle_request(req: Request) -> Response {
use req <- web.middleware(req)
case wisp.path_segments(req) {
["bookmarks"] -> bookmarks(req)
["bookmarks", id] -> show(req, id)
_ -> wisp.not_found()
}
}
fn bookmarks(req: Request) -> Response {
case req.method {
Post -> create(req)
Get -> list()
_ -> wisp.method_not_allowed([Get, Post]) // 自动带 Allow 头
}
}
反模式一:把”客户端的错”当成”服务器的错”。输入问题正路是 415(类型不对)与 422(字段不合法)——require_json 已替你回 415/413/400,decode 失败要自己回 422。见”解析失败”就 panic 或回 500,监控全是假警报,真故障被淹没。正确姿势:
import gleam/dynamic/decode
import gleam/json
import wisp
pub type Bookmark {
Bookmark(id: String, url: String, title: String)
}
fn bookmark_decoder() -> decode.Decoder(Bookmark) {
use url <- decode.field("url", decode.string)
use title <- decode.optional_field("title", "未命名", decode.string)
decode.success(Bookmark(id: wisp.random_string(8), url: url, title: title))
}
fn create(req: Request) -> Response {
use json <- wisp.require_json(req) // 415/413/400 在这一行里替你处理完
case json.parse(from: json, using: bookmark_decoder()) {
Ok(bm) -> store.add(bm) // Subject 经中间件栈带入(为省篇幅未展开)
Error(_) -> wisp.unprocessable_content() // 字段非法:客户端的错,422
}
}
难点二:内存状态与 actor 串行化
BEAM 进程之间不共享内存,”全局变量”不存在——多个 HTTP 连接进程要改同一份收藏数据,唯一的类型安全解法是把状态装进一个 actor(gleam_otp 1.x 的 Builder API),所有读写变成消息:
import gleam/dict.{type Dict}
import gleam/erlang/process
import gleam/otp/actor.{type Next}
pub type Msg {
Add(bm: Bookmark, reply: process.Subject(Bookmark))
Get(id: String, reply: process.Subject(Result(Bookmark, Nil)))
}
pub fn start() {
actor.new(dict.new())
|> actor.on_message(handle)
|> actor.start // => Result(Started(Subject(Msg)), StartError)
}
fn handle(store: Dict(String, Bookmark), msg: Msg) -> Next(Dict(String, Bookmark), Msg) {
case msg {
Add(bm, reply) -> {
process.send(reply, bm)
actor.continue(dict.insert(store, bm.id, bm))
}
Get(id, reply) -> {
process.send(reply, dict.get(store, id))
actor.continue(store)
}
}
}
actor 单线程消费消息,并发请求在消息队列里自然排队——这就是”并发正确性”指标的实现方式:不是靠锁,是靠把变更收拢到一个进程。HTTP 侧不直接摸 Dict,而是用 process.call 同步拿结果,把封装成 store 模块的公开函数(get/list 同型):
pub fn add(subject: Subject(Msg), bm: Bookmark) -> Bookmark {
process.call(subject, waiting: 5000, sending: fn(reply) { Add(bm, reply) })
}
反模式二:用 FFI 上 ETS 绕开类型系统。Gleam 调 Erlang 的 @external 只有一行,搜”并发字典”很容易抄到直接操作 :ets 的野路子——丢掉类型包装后,键写错、表名拼错全部推迟到运行时才炸,静态类型的收益在数据层整层退还。要用 ETS 包装包,也得包成 Gleam 类型再进来;几百行的小服务,actor 串行化的余量绰绰有余。
还有一个要诚实交代的边界:call 等不到回复会 panic,而 panic 会被 wisp 的 rescue_crashes 兜成 500——actor 崩了,用户看到的是 500 而不是挂死的连接,这是设计内行为。但内存态意味着 actor 一崩数据就没了(新进程从空 Dict 重新 init),验收清单里”崩溃兜底”只保证服务不死,不保证数据存活;要数据存活就落库,那是生产化阶段的事。
难点三:测试不起端口
wisp 自带 simulate 模块,直接构造 Request 调 handler,断言用 1.11 起的 assert 关键字(失败打印两侧实际值):
import bookmark/router
import gleam/http
import gleam/json
import wisp/simulate
pub fn create_test() {
let body =
json.object([#("url", json.string("https://example.com/post-one"))])
let response =
simulate.browser_request(http.Post, "/bookmarks")
|> simulate.json_body(body)
|> router.handle_request()
assert response.status == 201
assert simulate.read_body(response) |> string.contains("post-one")
}
pub fn wrong_content_type_test() {
// 不带 JSON 体的 POST 应得 415:browser_request 默认无 content-type
let response =
router.handle_request(simulate.browser_request(http.Post, "/bookmarks"))
assert response.status == 415
}
不起端口意味着测试毫秒级完成、可并行跑。注意测试直接调 router.handle_request——middleware 栈在路由入口函数内部,415/422 这些”中间件行为”同样测得到,这是把栈放进入口函数而不是 main 的原因。
部署与验证
部署用官方导出:gleam export erlang-shipment 生成 build/erlang-shipment 目录,目标机装好 Erlang/OTP 后执行其中的 run 脚本,无需 gleam 工具链。验证顺序与瓶颈定位:
| 阶段 | 现象 | 瓶颈在哪 | 怎么改 |
|---|---|---|---|
| 单请求 | POST 返回 415 | curl 忘了 -H “content-type: application/json” | 补头;契约本身就是这么设计的 |
| 单请求 | POST 返回 500 | actor 未启动,call panic 被 rescue_crashes 兜住 | main 里先 start 再进 sleep_forever,检查 Started 匹配 |
| 并发 | 总数少于写入数 | 状态存进了每个请求自己的进程 | 确认所有读写都走 actor,别在 handler 里 dict 重建 |
| 重启 | 数据全丢 | 内存态,设计内行为 | 落库或导出快照,接受重启语义的就用内存态 |
关于数字要诚实:本文未实测压测,不背书任何 QPS/P99。公开口径是 BEAM 单机维持大量轻量连接属常规能力(每个 HTTP 连接就是一个小进程),上限取决于内核参数与宿主配额。Workerman 常驻内存编程里 PHP 的每连接内存账,在这边换成了”每连接一个进程”的调度账,量纲不同、思路同源。
与同门的边界
同样的服务在 Elixir 实战篇里是 Phoenix LiveView 实时看板:那边赢在”会话进程 + DOM 补丁”的实时交互,这边赢在”类型进函数签名”的接口契约。给现有 BEAM 服务补一个类型安全的 HTTP 端点,Gleam 模块能直接混进现有 release;反过来在 Gleam 服务里复用 Phoenix 生态,就得走 FFI 一层层包装。分水岭是交互形态:API 服务走 wisp,重实时交互选 LiveView,进程模型与容错哲学两边是同一套。
总结
这个项目教会四件事:中间件栈放路由入口函数里,测试才够得着;并发正确性靠把变更收拢进一个 actor,不靠锁;415/422 归客户端、500 归服务器,监控才不造假警报;erlang-shipment 让”有 OTP 就能跑”成立。不适合的场景同样清楚:需要持久化与复杂查询的,该直接上数据库方案;重前端交互的,不如用 Gleam 出 JSON 配合前端框架。下一步学什么、按什么顺序学,见《Gleam 学习路线》。
版本核实(以官方文档为准):写作时 Gleam 稳定线 1.12.x;wisp 2.0.0 发布于 2025-09-04,本文代码对齐 wisp 2.0 的 handler/middleware/simulate API(官方 hello_world、routing、working_with_json 示例同款写法);mist 5.0.x、gleam_otp 1.1.0(1.0 GA 于 2025-06-12,Builder/Next API)、gleam_erlang 1.3.0(call 超时 panic 语义)、gleam_json 3.0.x(parse 配 decode.Decoder)、gleam_http 4.x;assert 关键字为 Gleam 1.11(2025-06-02)引入,wisp 2.0 官方测试示例即用 assert。性能与容量均为公开口径或定性描述,本站未实测;安装命令不钉版本号,以 hex.pm 与 wisp.hexdocs.pm 当前文档为准。