ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Caveman框架实战:基于Erlang/OTP构建轻量级RESTful API

Caveman框架实战:基于Erlang/OTP构建轻量级RESTful API

Caveman 这个名字,在程序员圈子里本来就有个梗——Caveman Debugging,指那种靠打日志和打印输出来摸黑定位问题的最原始调试方式。但我今天要聊的,是 Erlang 生态里一个同样取名 Caveman 的轻量级 Web 框架。说实话,我第一次看到这个项目时心里想的是:又一个给自己起怪名字的轮子?真上手跑了一遍之后,反而觉得这个名字起得挺妙的——它确实像个穴居人:不穿花哨的衣服,不带多余的装备,但手里那根棍子,砸开高并发场景下的 RESTful API 开发需求,完全够用。

这篇文章我就从自己踩过的坑出发,把这个框架从环境搭建、路由控制器、模板渲染到部署打包整个链路捋一遍。如果你正好在找一种够轻、够快、又能练练 Erlang/OTP 并发思维的后端方案,或者你就是想给内部工具做个 API 壳子,那 Caveman 值得你花一个下午试试。文章里所有命令和代码我都实测跑过,版本基于 Erlang/OTP 25 + Caveman 3.x,不同版本细节有出入的话我也会标注出来。

1. 先说清楚 Caveman 是个什么货色

1.1 它解决了什么问题

Caveman 是一个基于 Erlang/OTP 的 Web 框架,核心目标非常朴素:用最少的抽象,把 HTTP 请求接到你的 Erlang 函数上。它不像 Django、Rails 那样自带一堆全家桶组件,也不像 Cowboy 那样只给你一个裸 socket 处理器,而是夹在中间:路由、控制器、模板渲染、静态文件服务、中间件这些 Web 开发最常用的零件给配齐,再往上的东西——ORM、Admin 后台、表单校验、用户认证——一概不管,你自己找库拼,或者干脆手写几十行完事。

我第一眼看到这种设计觉得是不是太偷懒了,但实际用下来才理解它的底气:Erlang 的 BEAM 虚拟机天生就把并发、容错、热更新这些难题解决了大半,Web 框架只需要做好 HTTP 协议适配和请求分发就够。Caveman 把资源全砸在这两件事上,反而让代码特别干净。你要做的,就是写控制器、定义路由、返回数据,剩下的事情交给 VM。

1.2 为什么选它而不是 Cowboy 或 ChicagoBoss

Erlang 生态里做 Web,主流的选项其实就那几条线:直接上手 Cowboy,写路由、写协议处理,灵活度高但重复造轮子的地方多;用 ChicagoBoss,该有的都有像框架界的瑞士军刀,但集成的模块多、学习成本高,有时候想改底层行为反而被框架限制住。Caveman 刚好卡在中间档位。

我对照过三个框架写同一个预约接口的成本:Cowboy 大概要手动管理路由表、请求上下文、响应构造,代码量多了快一倍;ChicagoBoss 得先学它的 Model 层和模板约定,改个字段要动好几个文件;Caveman 则是 routes 文件加一个 controller 文件,run 起来就完事。对于中小团队做内部系统、工具类 API、原型验证这类场景,Caveman 的性价比是最舒服的:代码量小、边界清晰、出问题好排查,整个框架源码翻下来也就一天时间,心里踏实。

2. 环境准备与项目初始化全流程

2.1 安装 Erlang/OTP 和 rebar3

Caveman 是 Erlang 库,所以第一件事是把 Erlang/OTP 装上版本等于 24 的都行,我自己在 24 和 25 上都跑过,没用上什么新版本独家特性。macOS 上用 brew install erlang,Ubuntu/Debian 上用 apt install erlang 或者从 Erlang Solutions 仓库装,Windows 上就直接用官方安装包。装完在终端里敲 erl 看到 Erlang/OTP 的版本信息就算成了。

接下来装 rebar3,这是 Erlang 社区标准构建工具,作用类似 Maven 之于 Java、Cargo 之于 Rust。macOS 上 brew install rebar3,其它平台直接去 GitHub releases 拉二进制文件放到 PATH 里就行。注意 rebar3 和 Erlang 版本有个兼容谱系,OTP 25 配 rebar3 3.22+ 比较稳,我刚开始用老版本 rebar3 编新 OTP 的依赖,就撞见过莫名其妙的编译错误,后来升级就天下太平了。

2.2 用 rebar3 模板生成基础骨架

Caveman 官方推荐用 rebar3 的模板插件来创建项目。先全局安装这个模板插件,在你家目录的 ~/.config/rebar3/rebar.config 里加上一行:

{plugins, [rebar3_caveman]}.

然后在工作目录执行:

rebar3 new caveman_demo

这会在当前目录下生成一个名为 caveman_demo 的完整项目骨架,里面包含了 release 配置、src 目录、priv 静态资源目录。虽然有模板,但我更建议你顺带看一眼生成的文件结构,因为后面所有调试都建立在对这个结构的理解上。生成出来的核心目录大概是这样的:

caveman_demo/ ├── rebar.config ├── src/ │ ├── caveman_demo.app.src │ ├── caveman_demo.erl % 应用入口 │ ├── routes.erl │ └── controllers/ │ └── root_controller.erl ├── templates/ │ └── index.html.dtl ├── priv/ │ └── static/ └── config/ ├── sys.config └── vm.args

2.3 配置应用与启动入口

项目的 rebar.config 里会默认依赖 caveman、cowboy、jsx 这几个库。需要注意,Caveman 3.x 对 Cowboy 2.x 已经适配完整了,所以依赖版本不需要刻意锁旧。如果你用的是老教程,看到 cowyy 1.0 之类的旧依赖声明,那多半是好几年前的帖子,建议照新模板走。

应用配置文件 src/caveman_demo.app.src 里,重点看一下 mod 字段是指向 caveman_demo_app 模块的:

{application, caveman_demo, [ {description, "A Caveman based web application"}, {vsn, "0.1.0"}, {modules, []}, {registered, []}, {applications, [kernel, stdlib, caveman]}, {env, [{port, 8080}]}, {mod, {caveman_demo_app, []}} ]}.

这里有个非常容易踩的坑:Caveman 应用默认监听的端口是在 app 的 env 里写的 8080。你改端口的时候,优先改这里而不是去代码里硬编码。我第一次跑项目时想换端口,直接在控制器里用 cowboy 的接口起监听,结果全家拧成一团。正确做法就是改这个 env 后重新编译,框架自己会处理监听启动。

启动入口 src/caveman_demo_app.erl 中,Caveman 帮你整好了 application 的 start 回调,它会去加载路由并启动 Cowboy。正常执行 rebar3 shell 就能直接在本地 8080 端口看到服务跑起来。

3. 路由定义与控制器书写实操

3.1 路由文件想清楚再动手

Caveman 的路由集中在 src/routes.erl 里,写法非常直白,每条路由就是一个元组,把 HTTP 方法、路径模式和控制器函数映射起来。我项目里的 routes.erl 长这样:

-module(routes). -export([get_routes/0]). get_routes() -> [ {"/", root_controller, index}, {"/health", root_controller, health}, {"/user/:id", user_controller, show}, {"/api/user/:id", user_controller, show_json}, {"/submit", form_controller, submit} ].

你看,路径里用 :id 这种冒号语法声明动态参数,不需要写正则,框架自动从 URL 里把参数抠出来。动态参数在控制器里通过 req 对象里的 bindings 字段拿到,这个后面讲控制器时会细说。我有一个心得:Caveman 的路由层不做 RESTful 资源自动映射,所以路径设计尽量扁平、业务化,别搞太深的嵌套,反正控制器里你还是要手动取参数。

3.2 控制器是纯 Erlang 函数的世界

先看一个最基础的控制器的完整代码:

-module(root_controller). -export([index/1, health/1]). index(Req) -> Req1 = caveman:render(Req, "index.html.dtl", #{title => <<"Caveman Demo">>}), {ok, Req1, Req}. health(Req) -> Req1 = caveman:respond(Req, 200, <<"ok">>), {ok, Req1, Req}.

这里有几个点你必须理解:

每个控制器函数接收一个 Req 对象(Cowboy 的请求对象),经过处理后必须返回 {ok, Req1, Req} 这个三元组。Req1 是你加工后的响应,最后那个 Req 是用来让 Cowboy 在请求结束后继续执行后续流程的,如果你漏了它,请求会挂住不返回。我调试时好几次响应超时,最后发现是 return 结构少了元素,这种低级错误真的会耗掉一小时。

拿到动态参数的方式是在函数里用 cowboy_req:binding/2:

show(Req) -> UserId = cowboy_req:binding(id, Req), %% UserId 此时是 binary 类型,比如 <<"42">> Body = io_lib:format("User id is ~p", [UserId]), Req1 = caveman:respond(Req, 200, list_to_binary(Body)), {ok, Req1, Req}.

注意绑定出来的参数是二进制字符串,不是 Number。你在数据库查 ID 之前得先把 binary 转成整数:binary_to_integer(UserId)。这个坑不踩一次你是记不住的——别问我是怎么知道的。

3.3 返回 JSON 接口的三板斧

Caveman 默认集成了 jsx 库用来处理 JSON,所以写 API 特别顺手。给一个返回 JSON 列表的接口示例:

-module(user_controller). -export([show_json/1]). show_json(Req) -> UserId = cowboy_req:binding(id, Req), User = get_user_from_db(UserId), %% 这个映射直接构建一个 proplist 或者 map 交给 jsx:encode JsonBin = jsx:encode(#{id => UserId, name => <<"Alice">>, age => 18}), Req1 = caveman:respond_json(Req, 200, JsonBin), {ok, Req1, Req}.

caveman:respond_json/3 这个函数会自动设置 Content-Type 为 application/json,不需要你手动设置响应头。如果你想要自定义头信息,可以用 cowboy_req:set_resp_header/3 先处理 req:

Req0 = cowboy_req:set_resp_header(<<"X-Custom-Header">>, <<"hello">>, Req), Req1 = caveman:respond(Req0, 200, <<"hello">>),

这里我特别想提醒:jsx:encode 接受的值类型是有限的,你传一个 Erlang atom 进去它也能编码,但解码回来就成了 binary,不会还原成 atom。所以如果你有复杂嵌套结构,比如 list 套 tuple 再套 atom,建议先转成 map 再编码,不然某些值序列化出来跟你预期完全不一样。

3.4 中间件的使用场景

路由文件中还可以声明整个路由表级别的中间件,目前 Caveman 内置的中间件主要是做路由前置处理。比如你要给整个 /api 前缀的路径加 Basic Auth 校验,可以在 routes.erl 里嵌一个处理模块:

{"/api/[...]", api_middleware, handler},

这种包头式的写法中,[...] 是路径通配符,api_middleware 会先执行校验逻辑,校验通过后再转发到真正的业务控制器。中间件本质上也是一个普通 Erlang 模块,你只需实现一个回调函数并返回 {ok, Req, State} 或拒绝响应。就我自己经验,Caveman 的中间件写起来比 Cowboy 原生简洁得多,适合做鉴权、限流、日志记录这类横切关注点。

4. 模板渲染、静态文件与表单交互

4.1 用 erlydtl 模板组织页面

Caveman 默认的模板引擎是 ErlyDTL,也就是 Django 模板语法在 Erlang 世界的移植版。模板文件默认放在项目根目录的 templates/ 文件夹下,并以 .html.dtl 结尾。比如首页模板长这样:

<html> <head><title>{{ title }}</title></head> <body> <h1>{{ title }}</h1> <p>Welcome to Caveman demo.</p> {% for user in users %} <li>{{ user.name }}</li> {% endfor %} </body> </html>

控制器里往模板传变量就用 map:

Req1 = caveman:render(Req, "index.html.dtl", #{ title => <<"Caveman Demo">>, users => [#{name => <<"Alice">>}, #{name => <<"Bob">>}] }),

模板渲染有个明显的好处:它会直接帮你把 content-type 设置成 text/html,布局也统一,适合管理后台这类页面。但如果是纯 JSON API,建议不要为了省事硬套模板,直接用 respond_json 反而更干净。Caveman 在这方面有个癖好,模板路径是相对 templates/ 根目录的,模板放在子目录时要写相对路径子目录/xxx.dtl,这个细节点一下免得大家踩了发懵。

4.2 静态资源挂载与缓存策略

Caveman 会自动去 priv/static/ 目录找静态文件。你把 CSS、JS、图片丢进去,浏览器请求对应路径就能拿到。例如 priv/static/css/app.css 对应的 URL 就是 /css/app.css。

有个小细节值得注意:开发模式下静态文件改动后刷新页面就能生效,因为 Caveman 默认不设强缓存头。生产环境我建议你在 Nginx 层再加一层 expires 配置,这样浏览器会把静态资源缓存住,减少后端压力。上面这套在 priv/static 下的命名约定几乎不可能记错,因为整个项目只有一个 static 目录,路径关系就是直观的目录映射。

4.3 处理表单 POST 请求

表单提交是 Web 开发的基本功,Caveman 这边处理起来也很直接。看一个处理登录表单的示例:

submit(Req) -> {Fields, Req0} = cowmulter:parse_form(Req), %% Fields 是一个 [{Key, Value}] 列表,Key 和 Value 都是 binary Username = proplists:get_value(<<"username">>, Fields), Password = proplists:get_value(<<"password">>, Fields), case auth:check(Username, Password) of true -> Req1 = caveman:respond(Req0, 302, <<"redirect">>), Req2 = cowboy_req:set_resp_header(<<"Location">>, <<"/dashboard">>, Req1), {ok, Req2, Req0}; false -> Req1 = caveman:respond(Req0, 401, <<"Unauthorized">>), {ok, Req1, Req0} end.

注意这里有一个很别扭的点,Caveman 3.x 沿用 Cowboy 2.x 的 API,表单解析函数在 cowmulter 模块里,不同版本可能叫 cowmulter 或者 cowboy_multipart,你要到自己依赖的实际源码里看一眼。cowboy_req 的 parse 函数在不同版本改了名字,我吃过一次苦头,后来学乖了——遇到编译不过,第一件事就是去 deps/cowboy/src 目录 grep 函数名,而不是盲改代码。

如果你的接口接收 JSON body,需要这么办:

show_post_json(Req) -> {ok, Body, Req0} = cowboy_req:read_body(Req), Decoded = jsx:decode(Body, [return_maps]), Name = maps:get(<<"name">>, Decoded, undefined), %% 业务处理...

read_body/1 是一次性读取整个请求体,只适用于体量不大的请求。如果你准备接收大文件上传,这条路走不通,得考虑流式处理,而 Caveman 对此没有特别包装——说实话你也别指望它,老老实实读 Cowboy 文档去。

5. 调试技巧与 Caveman Debugging 的现代演绎

5.1 打日志永远是第一工具

前面说了 Caveman Debugging 这个词,其实在 Erlang 世界里打日志比别的语言更好用,因为 Erlang 的运行时能让你在系统跑着的时候把日志打出来看,而不会阻塞所有请求。开发阶段我一般直接在控制器里加 io:format:

index(Req) -> io:format("~p~n", [Req]), ...

配合 rebar3 shell 跑起来,每个请求都会在终端窗口打印出完整的 Req 结构。你会清晰地看到 method、path、headers、bindings、body 等字段长什么样,这对新手理解请求对象帮助巨大。等业务流程稳定后再把这些裸 print 换成 lager 或 logger,打上等级和时间戳,方便生产排查。Caveman 老版本和 Erlang 的 logger 标准库集成得比较精细,新项目直接上 logger:info() 也行。

5.2 交互式 Shell 里手动调试

Erlang 最大的福利之一就是可以开着 shell 连到正在跑的应用上执行任意代码,这是其他语言羡慕不来的调试体验。Caveman 开发时你可以启动 rebar3 shell,在 Erlang shell 里直接调用 controller 函数检查返回结果:

% 在 rebar3 shell 内执行 application:ensure_all_started(inets). caveman_demo_web:start().

不过更实用的手段是给 project 开启远程 shell:在 vm.args 里配 -name node1@127.0.0.1 -setcookie abc,然后用 rebar3 shell --name debug@127.0.0.1 --setcookie abc 连接上去。连上之后你可以调用一切已加载的函数,看 ETS 表、查进程字典、手动发送消息。我排查过一个诡异的内存增长问题,就是靠远程 shell 连续查看进程数量的变化定位到是某个 supervisor 的子进程没有退出。

5.3 观察者 Observer 可视化监控

Erlang 发布包自带 Observer 工具,rebar3 shell 里输入 observer:start(). 就能弹出图形界面,看到 BEAM 上跑的进程树、系统负载、内存分布、甚至 ETS 表细节。Caveman 框架本身非常轻,但你的应用一旦处理并发请求,observer 就能帮你确认每个请求是不是被分配到一个独立的 Erlang 进程,去向是哪里,有没有进程堆积。这个工具配 Caveman 简直是绝配——你不需要理解框架内部每个细节,但你能看到整个运行时的一切状态。

6. 构建发布与生产部署实操记录

6.1 用 rebar3 release 打一个发布包

Caveman 项目搭建好之后,用 rebar3 release 就能把整套应用连同 Erlang 运行时打包在一起,做成一个独立的发布包。rebar.config 里需要加入 relx 配置:

{relx, [ {release, {caveman_demo, "0.1.0"}, [caveman_demo]}, {dev_mode, false}, {include_erts, true}, {sys_config, "./config/sys.config"}, {vm_args, "./config/vm.args"} ]}.

执行 rebar3 release 之后,成果会输出在 _build/default/rel/caveman_demo/ 目录下。这个目录里 bin/ 下有个可直接执行的控制脚本,启动命令是:

./_build/default/rel/caveman_demo/bin/caveman_demo start

停止是 stop,重启是 restart。还有一个 attach 命令能直接连上生产节点的 shell,这在线上排查问题时是神器。注意发布时 vm.args 里如果没有加 -name 节点名参数,生产环境远程调试就用不了,建议正式部署时都显式配置一把,哪怕后面不用,具备调试能力本身也是一种备份方案。

6.2 置于 Nginx 后端的端口与路径配置

Caveman 默认监听 8080 端口,生产环境前面再挂一层 Nginx 做 TLS 终结、静态资源缓存和反向代理。Nginx 关键配置如下:

server { listen 80; server_name api.example.com; client_max_body_size 50m; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /static/ { alias /opt/caveman_demo/priv/static/; expires 7d; } }

我习惯把所有非静态请求都塞到 location /api/ 这一个前缀下,让 Caveman 只处理业务 API,静态文件交给 Nginx 直接返回,后端压力瞬间降一截。同时别忘了设置 client_max_body_size,否则用户传一个大文件直接被 Nginx 截断,后端可能连请求都没收到,排查时很容易误判成是框架的问题。

6.3 systemd 管理服务生命周期

把 release 包丢到 /opt/caveman_demo 路径后,用 systemd 管理进程。一个细腻的 service 配置大概是:

[Unit] Description=Caveman Demo After=network.target [Service] Type=forking User=caveman ExecStart=/opt/caveman_demo/bin/caveman_demo start ExecStop=/opt/caveman_demo/bin/caveman_demo stop Restart=on-failure RestartSec=5s KillMode=mixed [Install] WantedBy=multi-user.target

Erlang VM 启动后有一系列子进程,KillMode=mixed 能保证 systemd 先给主进程发 SIGTERM,再清理残留子进程,不会出现服务停了端口还占着的尴尬场景。生产环境我还会额外跑一个日志收集进程,读取 Caveman 打到 stdout 的日志,配合 systemd journald 统一管理,满了自动切割,省心很多。

7. 常见问题与避坑指南

7.1 编译报错和依赖版本冲突

这类问题占了我实际使用过程里遇到 bug 的一半。Caveman 老教程里动不动让锁 cowboy 1.x,但 Caveman 3.x 源码默认用的 cowyy 2.x,两者 API 差异很大,混用必炸。统一做法:生成新项目后不要手动改依赖,一切以模板生成版本为准,如果需要升级加依赖,优先用 rebar3 upgrade 而不是手动改版本号。我建议你在 rebar.config 里明确写上 cowboy 版本约束,比如 {cowboy, "2.9.0"},锁死大版本,避免某次依赖解析自动给你升到不兼容的版本。

7.2 请求挂起和超时的元凶

控制器返回格式写错、忘了返回三元组、中途某些分支 return 写岔了,这些都会导致请求卡住不返回。排查办法简单粗暴:先在各个分支加 io:format 日志,再配合 curl 的 --max-time 3 设置超时,别什么都不做就等着看它挂多久。另外,如果你的控制器函数里调用了一个耗时很长的外部服务,比如同步请求第三方 API,这个请求也会卡住整个 request process 直到外部返回。Caveman 不会自作主张加超时,所以你在业务代码里给所有外部调用都包上 timer:tc 手动控制超时。

7.3 端口已被占用和双实例冲突

如果你启动时弹 {listen_error, eaddrinuse},那就是端口被占用。要么杀掉占用进程,要么改端口。改端口我前面强调过,改 app 的 env 里 port 字段。但有同事改完重启发现还是旧端口,一问才知道他改的是 sys.config 里的应用配置,两边配置都写了 port 时以 sys.config 为准,相互不一致就会产生这种诡异现象。一个项目里只维护一份端口配置,别两处都写。

7.4 热更新和三方依赖的坑

Erlang 支持代码热更新,但 Caveman 的路由表和模板不会因为你改了源码就自动重新加载,开发过程中你改了 routes.erl 或者某个 controller,rebar3 shell 不会自动编译,必须手动在 shell 里执行 c(routes) 或者干脆重启 shell。有个提升效率的办法:用 rebar3 shell 启动时,它默认只会编译当前项目,你在另一个终端重新跑 rebar3 compile 再在 shell 里 code:load_file,不过说实话对于 Caveman 这种轻量框架,开发模式重启一下项目也就两秒钟,热不热更新反而无所谓了。

7.5 生产环境的日志可不是摆设

开发时 io:format 随便打,生产环境就别这么干了。Erlang/OTP 25 内置的 logger 支持分级和格式化,Caveman 应用也默认接入标准 logger。一个简单的生产日志配置(放在 sys.config 里):

[ {kernel, [{logger_level, info}]}, {caveman, [{port, 8080}]} ].

然后代码里把 io:format 换成 logger:logger:info("user ~p login ok", [UserId])。这样日志带了时间戳和级别,故障处理时才能串起时间线来。我自己之前嫌配置麻烦,在生产环境留了不少 debug 级别的 io:format,结果日志多到没法看,后来花了一晚上清理全部换掉,纯属自酿苦果。

8. 用 Caveman 做完一个真实项目的复盘

这几个月我用 Caveman 给团队搭了一个轻量版的预约管理 API,服务大概承担了内部两个前端应用的所有数据交互。真实跑下来,最直接的体会是:选型时没被各种花哨框架绑架,Caveman 给了我最核心的 HTTP 处理能力,配合 Erlang/OTP 的稳定性,三个月里一次 OOM 都没见过,BEAM 的进程模型自动把并发请求隔离得明明白白,这在 Node 或 Python 里都得精心调优才能做到的。让我不适的地方也有,比如生态周边少,遇到常见问题要自己扒源码,跟轮子巨多的 Java/Script 生态没法比。但如果你要做的是小规模 API、内部系统、临时原型,或者单纯想体验一门函数式语言做 Web 开发的爽感,Caveman 这套“原始而够用”的思路,会让你少掉不少头发。

最后再分享一个我自己的小习惯:每新建一个 Caveman 项目,先把 sys.config、routes.erl、app.erl 这三个文件的路径和职责写进项目 README 里,免得过两周自己都忘了配置在哪里。毕竟 Caveman 的原意是原始人,但咱们写代码的人,可不能真的像原始人一样靠记忆力硬扛所有细节。

返回列表