
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 或 ChicagoBossErlang 生态里做 Web主流的选项其实就那几条线直接上手 Cowboy写路由、写协议处理灵活度高但重复造轮子的地方多用 ChicagoBoss该有的都有像框架界的瑞士军刀但集成的模块多、学习成本高有时候想改底层行为反而被框架限制住。Caveman 刚好卡在中间档位。我对照过三个框架写同一个预约接口的成本Cowboy 大概要手动管理路由表、请求上下文、响应构造代码量多了快一倍ChicagoBoss 得先学它的 Model 层和模板约定改个字段要动好几个文件Caveman 则是 routes 文件加一个 controller 文件run 起来就完事。对于中小团队做内部系统、工具类 API、原型验证这类场景Caveman 的性价比是最舒服的代码量小、边界清晰、出问题好排查整个框架源码翻下来也就一天时间心里踏实。2. 环境准备与项目初始化全流程2.1 安装 Erlang/OTP 和 rebar3Caveman 是 Erlang 库所以第一件事是把 Erlang/OTP 装上版本等于 24 的都行我自己在 24 和 25 上都跑过没用上什么新版本独家特性。macOS 上用 brew install erlangUbuntu/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.args2.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/2show(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 先处理 reqReq0 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 headtitle{{ title }}/title/head body h1{{ title }}/h1 pWelcome to Caveman demo./p {% for user in users %} li{{ user.name }}/li {% endfor %} /body /html控制器里往模板传变量就用 mapReq1 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:formatindex(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 node1127.0.0.1 -setcookie abc然后用 rebar3 shell --name debug127.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] DescriptionCaveman Demo Afternetwork.target [Service] Typeforking Usercaveman ExecStart/opt/caveman_demo/bin/caveman_demo start ExecStop/opt/caveman_demo/bin/caveman_demo stop Restarton-failure RestartSec5s KillModemixed [Install] WantedBymulti-user.targetErlang VM 启动后有一系列子进程KillModemixed 能保证 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 或者某个 controllerrebar3 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 换成 loggerlogger: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 的原意是原始人但咱们写代码的人可不能真的像原始人一样靠记忆力硬扛所有细节。