1. 为什么我会认真用一个叫 Caveman 的框架写 Web 服务
先说结论:caveman 不是哪个原始人在山洞里写的玩具项目,它在开源世界里特指 Common Lisp 的 Web 框架 Caveman2——一套基于 Clack/Lack 生态的极简路由式框架。我最初是被这个名字勾去的,翻文档翻了十分钟发现,它比名字现代得多:路由声明式、模板可插拔、数据库层用 Datafly 接底层 DBI,启动一行 Clackup 就能挂到 Hunchentoot 或者 Woo 上。画风非常像“Sinatra 的一个法式新浪潮版本”,只是它跑在 Common Lisp 上。
Common Lisp 写服务的真正优势在于 REPL 驱动开发:你不需要“改代码—重启—等编译”,而是开着 REPL,直接重新定义函数和路由,跑一次请求看结果,进程状态全在。宏则让路由 DSL 可以做到非常贴合需求,比如 defroute 直接生成一个带匹配规则的处理器,配合 next 做穿透,和多数框架的路由分发思路完全不一样。这些体验我在其他语言里要组装半天,在 Caveman 这里几乎是开箱即得。
这篇文章按我实际做过的一个小型数据管理服务的路径来写:环境准备、项目骨架、路由、模板、数据库、中间件、部署,最后把踩过的坑单独拎出来聊。适合给想试试 Common Lisp 写 Web,以及准备用 Caveman 上一个小项目的人参考;你不一定需要先懂 Common Lisp 细节,跟着走到路由和数据库那节,基本就能把握住这个框架的脾气。
1.1 Caveman 在 Clack/Lack 生态里的定位
先把生态理清楚。Common Lisp 的 Web 开发不是只有 Caveman,但 Caveman 是最贴近“框架”这个直觉的层。往下拆:
- Clack:整个堆栈的接入层。它定义 Web 应用怎么和 HTTP 服务器对话,提供 clackup 启动器,支持切换 Hunchentoot、Woo 等后端,思路和 Ruby 的 Rack、Python 的 WSGI 一致。
- Lack:Clack 底层的组件库,提供了 request、response、builder 这些基础工具。Caveman 里路由处理器能拿到的参数、返回的响应,本质上都是在和 Lack 打交道。
- Caveman2(项目名 caveman 通常就是指它):在这两层之上做路由、视图渲染、配置、静态文件、错误处理的规整封装。真正写业务时你只需要面对 defroute、render 这类入口。
这个分层的价值在于:换 HTTP 服务器不需要动业务代码。开发时用 Hunchentoot 图稳,要压性能就切 Woo,业务路由一行不改。这一点非常实在,我在部署部分会展开。
1.2 它适合解决什么问题,又有什么不擅长
以我的经验,Caveman 最顺手的场景是:内部管理系统、数据展示面板、中小型 REST API、工具链里需要 Web 入口的部分,以及想用 Common Lisp 认真做个“可交付”服务的教学项目。它的特点是路由清晰、和 Lisp 数据结构无缝衔接——从数据库查出来的东西直接塞进模板变量,中间不需要序列化转来转去。
不擅长的地方也要说清楚。第一,生态规模没法跟主流 Web 框架比,很多常见轮子(OAuth 全家桶、富前端集成脚手架)要么自己拼,要么从零写。第二,前端渲染不是强项,纯 API 后端很舒服,要服务端渲染复杂 SPA 就比较吃力。第三,团队接手成本高,没有 Lisp 基础的团队,这个技术栈可能变成一个“只有你能维护”的黑盒。它适合喜欢把技术栈掌握在手里的团队,不适合什么都求省事的场景。我的态度是:工具是拿来解决问题的,不是拿来证明审美的,用之前先掂量清楚。
2. 环境准备:SBCL + Quicklisp + Roswell 的三件套组合
动手前先把环境讲透。我建议的搭配是 SBCL 当运行时、Quicklisp 管依赖、Roswell 管项目执行和发布。三样东西各有各的活,缺一个后面都会别扭。
2.1 安装与验证
- SBCL:Debian/Ubuntu 直接 apt install sbcl,macOS 用 brew install sbcl。装完跑一下 sbcl --version,我目前用的是 2.x 时代,现代版本对 UTF-8 的处理已经很省心。
- Quicklisp:到 quicklisp.org 下载 quicklisp.lisp,然后:
(load "quicklisp.lisp") (quicklisp-quickstart:install) (ql:add-to-init-file)第三行会把 Quicklisp 写进 ~/.sbclrc,之后每次启动 SBCL 都能直接用 ql:quickload。
- Roswell:面向 Common Lisp 的命令行工具,类似其他语言生态里的项目执行器。官网给的安装脚本跑完就行。它最大的价值是后面把项目打成可执行文件,以及用 ros run 起一个干净的 Lisp 交互环境。
装完建议先跑一句 (ql:quickload :caveman2)。第一次会拉一大堆依赖(Lack、Clack、SXQL、Datafly、PPCRE、Hunchentoot 等),编译可能要等几分钟,这是正常的,别以为是卡死。顺手观察一下构建日志,这也是你熟悉依赖树的好机会。
2.2 用 generate-project 生成项目骨架
Caveman 自带脚手架的入口是 generate-project,在 REPL 里执行:
(ql:quickload :caveman2) (caveman2:generate-project "my-app")命令会在当前目录(或你指定的目录)生成一个完整项目。接着把它放进 Quicklisp 的本地仓库目录,这样 ql:quickload 才能识别:
mv my-app ~/quicklisp/local-projects/然后启动验证:
(ql:quickload :my-app) (my-app:start :port 5000)浏览器打开 http://localhost:5000/,看到页面输出 Hello, Caveman2! 就说明骨架通了。很多教程在这里就停住了,其实光通骨架远远不够。生成的那几个文件是你后面 90% 改动发生的地方,下一篇幅逐个拆开讲。
2.3 生成的骨架文件逐个拆开看
| 文件 | 职责 | 你通常会改什么 |
|---|---|---|
| src/main.lisp | 入口,start 函数里调用 clackup | 默认端口、中间件组装 |
| src/app.lisp | 包定义 + defroute 路由 | 所有业务路由 |
| src/config.lisp | 配置项,端口、debug 开关 | 环境相关开关 |
| src/db.lisp | 数据库连接封装 | 连接字符串、连接类型 |
| src/view.lisp | render 封装,模板引擎配置 | 模板目录、默认数据 |
| static/ | 静态资源 | CSS/JS/图片 |
| templates/ | 模板文件 | 页面模板 |
| t/ | 测试目录 | 路由测试 |
先记住一个关键设计:src/app.lisp 里的 defpackage 通常长这样:
(defpackage #:my-app (:use #:cl #:caveman2 #:datafly #:sxql)) (in-package #:my-app)也就是说,你的业务代码里可以直接用 defroute、datafly:retrieve-all 这些符号。包名冲突问题等到你的项目真正大起来才会遇到,前期别为这个纠结,跟着默认走最省事。
3. defroute 路由:把框架最核心的机制吃透
路由是 Caveman 区别于一众 Lisp Web 库的最直接体现。它用宏把 HTTP 方法和路径模式绑定到一个普通函数上,函数体就是一个普通 Lisp 函数体,可以随心所欲写业务逻辑。
3.1 最简路由与常见选项
骨架自带的第一个路由长这样:
(defroute "/" () "Hello, Caveman2!")空括号是路由选项。别小看这个位置,它能指定很多行为。比如限制方法:
(defroute "/items" (:method :POST) ...)也可以用 :content-type 直接声明响应头,避免 JSON 接口忘了改 Content-Type 的情况。我第一次用觉得“这不太像以前的 Lisp 代码”,但一旦习惯了,比分散的路由装饰器好找得多——每个路由的行为就写在那一行里。
3.2 路径参数、Query 参数和方法限制
带参数的路径是冒号风格:
(defroute "/users/:id" (¶ms id) (format nil "user id = ~a" id))请求 /users/42 时,id 会拿到字符串 "42",注意一定是字符串,要做数字运算就 parse-integer 转一下。¶ms 还同时接收 Query String,也就是说 /users/42?debug=1 里的 debug 也能从 ¶ms 里取。这个细节很实用,不少框架把路径参数和查询参数分开处理,Caveman 直接合并到一个入口,简单直接。
我做过的数据服务里最常用的是这种组合:
(defroute "/api/items" (¶ms name min-price) (let ((conditions (remove-if #'null (list (when name (:= :name name)) (when min-price (:>= :price (parse-integer min-price))))))) (format nil "conditions: ~a" conditions)))这段代码本身就是 Lisp 写业务的好处:条件列表直接构造,天然支持“可选参数筛选”,不需要像其他语言那样堆一串 if 分支。
3.3 返回值到底是什么:字符串、响应对象与 next 穿透
Caveman 路由函数体的返回值有多种理解方式,这是新手的常见困惑。我的经验是把它分三层看:
第一层,返回字符串,直接作为响应体。最常用,前面例子全是。
第二层,返回一个标准的三元组 (状态码 响应头 响应体)。Lack 应用层的接口本身就接受这种返回,Caveman 路由沿用这个约定。我常用它做自定义状态码的 JSON 响应:
(defroute "/api/health" () (list 200 `(("Content-Type" . "application/json; charset=utf-8")) "{\"status\":\"ok\"}"))第三层,调用 next 穿透。这是 Caveman 路由调度里最妙的机制:如果某个路由决定不处理这个请求,可以 (next),框架会带着原请求去匹配下一条路由。类似中间件的“放行”,但作用在路由层。我经常用它做统一的前置校验后再放行,或者做多路由共享的前置逻辑。
3.4 with-html:用 Lisp 数据结构直接生成 HTML
如果你只是想要一个内部页面,不想引模板引擎,Caveman 自带的 with-html 就够了。它把 HTML 写成 Lisp 的 S 表达式,标签就是列表的第一个元素:
(defroute "/about" () (with-html (:html (:head (:meta :charset "utf-8") (:title "About Caveman")) (:body (:h1 "About") (:p "这是一个用 with-html 生成的页面")))))对写惯模板字符串的人需要适应一下,但好处是页面结构可以直接用 Lisp 表达式动态构造,比如根据数据结构循环生成列表,不用再学一套模板语法。内部工具我用 with-html 的频率比模板引擎还要高,因为它把“拼页面”这件事变成了纯代码逻辑。
4. 往骨架上长肉:模板、数据库和中间件
骨架通了、路由会写了,接下来是真实项目的三大件:页面模板、数据库访问、中间件。这三个部分决定了 Caveman 能不能真正支撑业务。
4.1 模板:先想清楚要不要用,再接 Djula
Caveman 的 render 是模板引擎无关的,引擎可插拔。脚手架生成的 src/view.lisp 会定义一个 render 封装,把模板路径和变量传下去:
(defun render (template-path &optional env) (caveman2:render template-path env))我的建议分两种情况。内部工具、页面结构不复杂的,直接用 with-html,别引模板引擎,少一层依赖少一堆坑;要给外部看的、需要前端协作的页面,再接 Djula。Djula 的语法是 {{ 变量 }} 和 {% 标签 %} 这种主流风格,跟 Jinja2 类似,前端同学上手成本低。
接 Djula 之后,最重要的两步是:把模板目录指到项目的 templates/ 下,并确认 render 的相对路径映射正确。具体绑定方式随 Caveman 版本略有差异,跑之前先看一眼当前版本文档,别凭旧教程硬抄。这里有个经验:模板变量不要传散装值,最好传 plist,比如 (render "user.html" (list :user user :articles articles)),模板里写 {{ user.name }} 比写一堆 {{ name0 }} 可读得多。
4.2 Datafly + SXQL:把 SQL 写成 Lisp 表达式再执行
Caveman 脚手架默认的数据库层是 Datafly 加 SXQL。Datafly 负责执行 SQL 并返回 Lisp 数据结构,SXQL 负责把 SQL 写成 S 表达式。日常用法:
;; 查询所有用户 (datafly:retrieve-all (sxql:select (:id :name) (sxql:from :users) (sxql:order-by :id))) ;; 插入一条记录 (datafly:execute (sxql:insert-into :users (sxql:set= :name "caveman")))第一次用的人最不习惯的是:明明 SQL 那么直观,为什么要绕一层 S 表达式?我的体会是,当你开始写动态条件时,这个选择就值了。比如根据请求参数拼查询条件,S 表达式可以安全地“组”出来,不会出现字符串拼接 SQL 的注入风险:
(datafly:retrieve-all (sxql:select (:id :name) (sxql:from :users) (sxql:where (sxql:and (when name (:= :name name)) (when status (:= :status status))))))数据库连接在 src/db.lisp 里做,连接类型和参数从配置读取。开发期用 SQLite 最省事,生产再切 PostgreSQL——连接方式的变化只体现在 connect 的地方,查询代码基本不用动。
4.3 中间件:session、静态文件与错误处理
Caveman 应用本质上是一个能被 Clack 加载的组件,想加什么中间件,就用 lack:builder 包一层。最典型的例子是 session:
(clack:clackup (lack:builder (:session) (my-app:app)) :port 5000)加 session 之后,登录态、防重复提交这些需求才有地方放。我通常还会在 builder 里加请求日志中间件、CSRF 校验中间件,以及生产环境用的安全响应头中间件。Lack 生态里的中间件本身是一个个普通函数,不够用就自己写:接收 app,返回一个接收 env 的函数,代码大致是这个形状:
(lambda (app) (lambda (env) ;; 前置处理 (funcall app env)))这个模型的成熟度我很喜欢:你在 Rack 里怎么写中间件,在 Lack 里就怎么写,迁移成本几乎为零。
静态文件这块,脚手架默认已经挂了 /static/ 路径的处理。生产环境部署时,我建议让 nginx 直接托管静态目录,应用只处理动态请求,后面详说。错误处理方面,defapp 支持 :error-handler 选项,可以统一接管 404/500,把 404 返回可读的 JSON,500 日志打全,排查问题会舒服很多。
5. 本地跑通到生产部署:Clackup、Woo 与 systemd
写完功能,总得上线。这一节讲从本地 REPL 到生产系统的完整路径,包括进程怎么保活、性能怎么提升、反向代理怎么配。
5.1 开发模式怎么跑:REPL 就是你的热重载
开发期流程是:启动 SBCL,加载 Quicklisp,quickload 项目,调用 start。改动代码后不需要重启整个进程:改路由,直接在源文件上重新编译(Slime 系编辑器里就是一次按键),下一次请求就命中新逻辑;改业务函数同理。这个“在运行的进程里改代码”的体验,写传统 Web 的人第一次用会有点回不去。
唯一要注意的是 Quicklisp 的编译缓存。如果你改了文件后用 (ql:quickload :my-app) 重新加载,它可能直接拿旧的 fasl 文件而不重新编译。我常用的强制重编译指令是:
(ql:quickload :my-app :force t)开发期每次依赖变化我都这样干,稳妥不上火。
5.2 生产进程:用 Roswell 把项目打成可执行文件
生产环境我最推荐把 Lisp 项目做成一个可执行文件,而不是扔给 sbcl --script。Roswell 的 ros build 能根据项目的构建脚本生成带启动入口的可执行程序。这个可执行文件本质是 SBCL 的镜像加载器,启动快、依赖固化在镜像里、不依赖宿主机的 Quicklisp 环境。
这一步的价值在于:交付一个服务时,别人拿到的就是一个可执行文件加一套配置,不用理解 Lisp 环境怎么装。我甚至会把构建脚本写进项目里,CI 里跑一条命令就能出产物。如果没有 Roswell,退而求其次用 sb-ext:save-lisp-and-die 手工 dump 镜像也行,但可维护性不如 Roswell。
5.3 nginx 反向代理与静态资源分离
生产拓扑我一般是这样:nginx 在最外层,托管 static/ 目录,动态请求反代到 Caveman 进程。Caveman 进程本机监听 127.0.0.1,比如端口 5000,由 systemd 保活。nginx 的关键配置:
server { listen 80; server_name myapp.example.com; location /static/ { alias /opt/myapp/static/; expires 7d; } location / { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }对应的 systemd 单元:
[Unit] Description=Caveman app myapp After=network.target [Service] User=www-data ExecStart=/opt/myapp/run.sh Restart=always RestartSec=3 [Install] WantedBy=multi-user.target这里有一个关键点:Clack 的 clackup 调用本身会建立监听并返回 server 句柄,如果你在脚本里执行完就直接退出,进程会立刻结束。我的做法是启动后进入一个永真循环,让 systemd 把进程养住。性能方面,如果压测发现 Hunchentoot 是瓶颈,把 :server 参数切到 :woo 就行,业务代码完全不动。Woo 基于事件循环,普通 CRUD 接口的吞吐会漂亮不少,但也要看实际场景,别为了炫技去切。
6. 我在实战中踩过的坑:每条都浪费过不少时间
这部分是全文最想让你直接拿走的内容。以下坑我都实际遇过,原因也确认过,照方抓药能省很多时间。
6.1 路由定义顺序与 next 的配合
Caveman 按路由定义顺序匹配。我曾经把 /users/:id 写在 /users/new 前面,结果访问 /users/new 时 id 拿到了 "new",后面那条路由永远没机会执行。排查时一度以为是路由没加载,其实是顺序问题。
解决办法很简单:把具体路径写在参数路径前面。如果你已经用 next 做穿透,更要留意穿透后落到的路由是否满足预期,最好在日志里把 path-info 打出来,确认请求到底走到了哪条路由。
6.2 中文乱码:先查响应头,再查源文件编码
Common Lisp 本身对字符串的 Unicode 支持没问题,但 Web 服务一旦出现乱码,问题通常发生在两个地方。第一是响应头没有带 charset=utf-8,浏览器按默认编码解析就会乱。第二是源文件本身的编码,如果你在 Windows 上编辑并保存成了 GBK,加载后字符串内容就是错的。
我的固定做法是:源文件全部 UTF-8,响应头里所有 text/html 和 application/json 都显式带 charset=utf-8。排查时先 curl -I 看响应头,再决定查网关还是查代码,别一上来就怀疑框架。
6.3 改代码不生效:Quicklisp 的 fasl 缓存
这是我踩过最隐蔽的坑。服务跑着跑着,改了路由、加载了、请求一打发现还是旧逻辑。原因就是 Quicklisp 认定库没变,直接拿旧的 fasl 文件加载。除了 :force t 之外,我还会在开发期把项目目录里生成的 .fasl 清掉再加载,双保险。
另外提醒一点:如果你同时在多个终端加载同一个项目,fasl 可能有写冲突,偶尔还会报权限错误。统一从一个 REPL 里加载和测试,会少很多灵异问题。
6.4 模板改完不生效:模板引擎的缓存
模板引擎通常会把编译好的模板缓存起来,开发期很容易出现“明明改了模板、页面还是旧样子”的假象。这不是代码问题,而是缓存在作怪。我的处理方式是在开发配置里把模板缓存关掉,上生产再打开。不同模板引擎的开关变量名不一样,记不住也没关系,关键是遇到这个问题时知道往缓存方向查,而不是去怀疑路由渲染写错了。就这一点,很多初学者会折腾半天路由重编译,最后才反应过来是缓存。
从我自己的使用习惯来说,Caveman 最合适的定位是“团队内部工具的快速交付层”和“想深入理解 Web 框架原理的教学载体”。它不追逐生态全覆盖,而是把路由、请求、响应、中间件这些核心概念做得很干净,干净到你能一眼看穿一个请求进来之后发生了什么。如果你想拿它做线上正式业务,我的建议是项目早期就把部署、日志、进程保活这套基础设施搭好,别等业务堆起来了再补。真要往深里扩展,可以试试把 OpenAPI 文档生成接到路由上,或者用 Woo 跑一轮压测对比数据——这些我后续也在整理,等跑顺手了再单独写一篇。