caveman框架实战:Common Lisp中的轻量Web开发

发布时间:2026/10/28 12:42:10
caveman框架实战:Common Lisp中的轻量Web开发

做Web开发这些年,我见过太多框架“出道即巅峰”,也见过不少工具在角落里闷声发威。今天想聊的这个项目,名字叫“caveman”,如果你混Common Lisp圈子,多半已经听说过它——一个老牌且依然能打的Web框架。我第一次接触它的时候,心里想的是“2025年还在用Lisp写Web服务?”,但真正把它跑起来、部署上线之后,我只能说:这套东西放在今天依然有它不可替代的位置。这篇文章不打算写长篇大论的语言之争,只从实际使用的角度,把它能做什么、怎么上手、有哪些坑、适合什么场景,一条条讲清楚。

我在生产环境里用caveman维护过两个内部服务,一个负责报表生成,一个做配置中心。说实话,初期成本主要花在熟悉Lisp的开发范式上,一旦过了那个坎儿,后面不管是迭代速度还是运行稳定性,都给了我相当大的惊喜。如果你正好在找一个“轻量、无依赖、能在REPL里反复改”的Web后端方案,或者你只是想看看Lisp系Web开发到底长什么样,这篇笔记应该能帮你省不少时间。

1. 项目定位与设计哲学

1.1 caveman到底是什么

caveman是Common Lisp社区里一个相当经典的Web应用框架,名字很硬核,但用起来比想象中要“现代化”不少。它最早是作为“Lisp版的Rails式脚手架”出现的,目标很明确:让Lisp程序员也能像写其他主流语言一样,快速搭出路由、控制器、模板、数据访问这一整套东西。

它的核心依托是Hunchentoot这个HTTP服务器。如果你把Web框架比作一家餐厅,Hunchentoot是炉灶和厨房,caveman则负责设计菜单、安排上菜流程、管理后厨分工。两者配合,配合上Clack(Lisp世界的WSGI标准),caveman就能处理中间件、静态资源、Session这类通用问题。

我最初选择它,其实是被一句话打动的:caveman不尝试解决所有问题,它只提供一套让你舒服地解决自己的问题的骨架。没有强制的ORM,没有跑不动的“全家桶”依赖,路由定义直白到让人怀疑“就这么简单?”——但正是这份直白,让我可以在一个很小的项目里快速跑通全流程。

1.2 它解决了什么痛点

Common Lisp做Web开发一直有个尴尬:语言本身很强,库也不少,但缺少一个“开箱即用”的壳。你要独立搭建的话,得自己拼HTTP解析、路由分发、请求参数绑定、错误页面……这些活儿不难,但很琐碎。caveman把这些“全世界Web应用都要做的事”从底层抽象出来,给你一套约定。

更关键的是,caveman把开发体验和REPL揉在了一起。传统改代码的流程是:改文件、编译、重启、看效果。Caveman的流程是:改文件、在REPL里重新加载、刷新浏览器。我甚至可以直接在REPL里改正在运行的路由函数、换掉模板、调整数据库连接参数,系统完全不用重启。对于“调一个接口要对一遍参数”这种日常,体验比改Java后端舒适太多。

如果你是一个被Spring Boot或Express“宠坏”的开发者,第一次看到caveman可能会觉得它“缺东西”。但缺ORM、缺Admin后台、缺自动生成代码?这些恰恰是它的优点——你要什么,自己插进去,框架不拦着你,也不逼你接受它的世界观。

1.3 适合谁用、不适合谁用

caveman适合这几类人:

  • 已经在用Common Lisp做业务逻辑,需要一个Web外壳的人;
  • 想低成本维护一个长期运行的服务,不想隔三差五为框架升级和依赖冲突头疼的人;
  • 喜欢函数式风格、习惯通过REPL驱动开发的人;
  • 对技术栈选型有自主权,不依赖生态链保护的人。

不太适合的场景也很明显:如果你要快速的JSON API搭配一大堆现成中间件(比如JWT、文件上传组件、WebSocket消息推送),caveman的生态确实比不过那些主流语言的同辈。它更像一个“可靠的基础设施”,而不是“包罗万象的平台”。

2. 核心机制详解:框架是怎么工作起来的

2.1 路由系统:不需要魔法的匹配

caveman的路由写法和大多数框架一样,定义在routes.lisp文件里。但它有个很有意思的特点:路由本质上就是一个字面量和类型参数组成的表达式,编译器直接处理,不依赖反射或字符串匹配。看一个例子:

(defroute ("/user/:id" :method :get) (&key id) (format nil "User: ~a" id)) (defroute ("/user/:id/posts" :method :get) (&key id) (list-posts (parse-integer id)))

注意到&key id了吗?URL里的:id参数会自动变成绑定到函数里的变量。不需要request.getParam("id")这种显式操作,也不用手动做URL decode,caveman全帮你处理完了。我当时第一次写完这个接口,在浏览器里传了个/user/abc,然后直接打印出来,心里想:这个表现有点像Python的Flask,但背后做的事情完全不同。

它支持字符串模式,也支持正则模式。字符串模式直接匹配字面路径,正则模式用(:regex ...)声明。比如匹配形如/files/2024/report.pdf的路径:

(defroute (:regex "/files/[0-9]{4}/[^/]+\\.pdf") () (serve-file))

用下来我的经验是:绝大多数场景下字符串模式就够了,正则模式适合做“批处理式”路由,比如统一拦截某个目录下的所有资源。但要注意,正则模式不要写得太贪心,过度匹配会吞掉后面的路由。

2.2 请求与响应:在REPL里玩转HTTP

caveman的路由函数天然是“请求进来,响应出去”的模式。你把请求体、查询参数、Header都当成变量来绑就行。比如解析POST表单数据,框架默认把form字段解析成keyword参数,开发时非常方便。

(defroute ("/submit" :method :post) (&key name email) (if (and name email) (progn (save-to-db name email) (redirect "/success")) (render-template "error.html")))

而响应就更灵活了:可以直接返回字符串(当成普通HTML输出)、返回列表(会被序列化成JSON)、返回hunchentoot的Response对象(精细控制状态码和Header)。这个“列表即JSON”的特性我特别推荐,内部接口调试时不用专门写JSON库,直接:

(defroute "/api/stats" () (list :total 1024 :ok t :items (list (list :name "a" :count 1) (list :name "b" :count 2))))

浏览器一访问,自动变成{"total":1024,"ok":true,"items":[...]}。注意别想着往列表里塞太复杂的对象,嵌几个层没问题,但可读性会下降。要我建议的话,正式项目里还是配一个专门的JSON序列化库(比如cl-json或jsown),家常调试才用这套。

2.3 模板与静态资源:不带太多花活

caveman默认集成了Djula模板引擎,模板文件放在views/目录,支持模板继承、局部模板和基本循环。你写Lisp代码时最舒服的一点是,模板里可以直接嵌入Lisp表达式,不用学第二套模板语法。

<!-- views/user.html --> <html> <body> {% if user %} <h1>Welcome, {{ user.name }}!</h1> {% else %} <h1>Please log in</h1> {% endif %} </body> </html>

Djula的语法比较贴近Django模板,熟悉Python的人会觉得很亲切。而静态资源放在static/目录,访问路径直接映射到/static/...。这里有个细节要注意:默认只路由你明文declare过的path,如果放了一个robots.txt想直接访问,得确认它是不是在static/下,并检查*app*的静态处理器是否正确加载。

模板这块我的个人习惯是:凡是页面内容稍微重复的(比如导航栏、页脚),一定要用模板继承做掉,别把HTML到处复制,否则后面改一个copyright还是小事,遇到改导航结构就要哭。

2.4 中间件:层次分明的处理链

和Express的中间件很像,Clack中间件可以挂载到caveman上,在请求进入路由前、响应返回客户端前做一些处理。官方推荐的形式是在app.lisp的defapp过程里配置:

(defapp *app* :middlewares (list <clack-error-middleware> <csrf-protection>) :static-directory (merge-pathnames #P"static/" *base-directory*))

中间件可以自己实现,只要符合Clack的“函数返回响应”约定。我自己实现过一个简单的“记日志”中间件,把每次请求的URL、方法、耗时写进日志文件,代码就几十行,派上了不小用场。这个设计让我很满意:框架不内置一堆你不知道用的东西,但你想加什么,都有标准插槽给你。

3. 从零搭建一个完整应用

3.1 环境准备与安装

你要装caveman,首先得有SBCL(或者CCL)和Quicklisp。如果机器上还没有Quicklisp,先装它(这里不展开,快速搞定后继续),然后:

(ql:quickload :caveman)

想看它都能提供什么,可以直接:

(ql:quickload :caveman/project) (caveman-project:generate "myapp")

这一步会生成一个完整的项目骨架,包含src/、views/、static/、tests/等目录,连README.md和.gitignore都给你铺好了。我建议你一定要看一下生成的myapp.asd文件——System Definition是Common Lisp里的“模块配置文件”,它决定了系统怎么被编译和加载。

如果你不想现写骨架,也可以直接用Sly或SLIME(Lisp的IDE插件)配合Emacs,来体验真正的“飞行模式”开发。在REPL里加载完系统以后,任意改代码都可以热生效,不用重跑服务器。

3.2 构建项目骨架

生成完骨架以后,最主要的工作就是改三个文件:src/app.lisp(应用主体)、src/routes.lisp(路由定义)、src/views/*.html(模板)。我建议把config.lisp里面端口、数据库DSN等配置项也看一眼,caveman把配置分成了“默认配置”和“环境配置”两个维度,你可以在环境变量里临时覆盖,很方便。

在routes.lisp里定义路由前,先定义*app*对象,然后所有路由函数挂在*app*上。比如:

(defroute *app* "/" () (render-template "index.html" (list :title "Hello caveman")))

如果什么都不写,直接默认启动的服务访问/会返回404。别惊讶,caveman不会自作聪明地给你搞一个“Welcome页面”,一切从空白开始。我第一回启动看到404,愣了几秒,后来反而更喜欢这种克制——它把“写代码”这件事完全交给你。

(hunchentoot:start *app* :port 5000)

上面这段如果在REPL里执行,你就能在浏览器访问http://localhost:5000/了。如果后面改动,不用重启服务,直接重新编译再刷新页面即可。

3.3 路由、参数与页面跳转

路由是Web应用的心脏。caveman的defroute支持常见的:method选项,比如:get、:post、:put、:delete,还可以一次指定多个方法:

(defroute ("/item" :method (:get :post)) (&key id) ...)

方括号里还能写复杂的嵌套路径。举个例子,把RESTful风格接口写出来,就非常自然:

(defroute ("/api/v1/items" :method :get) () (list-items)) (defroute ("/api/v1/items" :method :post) (&key name price) (create-item name price)) (defroute ("/api/v1/items/:id" :method :delete) (&key id) (delete-item (parse-integer id)))

注意一个容易踩的坑:caveman的:id参数默认是字符串,你如果要做数学运算或者比较,需要显式转换。我习惯在参数绑定的地方直接parse-integer,别留到后面,因为一旦类型不匹配,错误定位会麻烦一点。

页面跳转我一般返回redirect函数,它返回一个302响应,浏览器自动去新地址。表单处理这种典型场景,建议遵循“Post/Redirect/Get”模式,避免用户刷新导致重复提交。

(defroute ("/register" :method :post) (&key username email) (register-user username email) (redirect "/welcome"))

3.4 集成数据库(以SQLite为例)

caveman不绑定数据库,你可以自由使用cl-dbi、Mito(ORM)或Postmodern(PostgreSQL客户端)。这里演示一个和SQLite配合的最小例子,用cl-dbi直接写SQL:

(ql:quickload :cl-dbi) (defvar *db* (dbi:connect :sqlite3 :database-name "app.db")) (defun query-items () (dbi:fetch-all (dbi:prepare *db* "SELECT id, name, price FROM items")))

然后路由里直接调用:

(defroute "/items" () (loop for row in (query-items) collect (list :id (getf row :id) :name (getf row :name) :price (getf row :price))))

我对这个组合的整体感受是:直连SQL的方式非常透明,你清楚地知道每一条语句在做什么,没有脏话、没有预处理器魔法。配合caveman,写一个轻量后台管理工具非常合适。假如要上更大的系统,建议把数据库连接封装成单例,开启连接池,写一个with-db的宏来统一管理事务。

3.5 编译与部署:脱离REPL的正式运行

依赖REPL的开发当然舒服,但生产环境总得有个“正经运行”的样子。caveman应用可以编译成可执行文件,用buildapp或者update-asdf打包。一个典型的buildapp命令可能是:

buildapp --load-system myapp --entry myapp:start-server --output myapp-server

这样你就可以得到一个独立的二进制文件,拷到服务器上直接跑。如果想做得更工程化,可以写systemd service,设置自动重启。因为caveman应用本身是原生代码编译出来的,启动速度相当快,内存占用比Java那派低得多,跑一台小机器做内部服务绰绰有余。

Docker部署也是常走的路,用daewok/sbcl或daewok/quicklisp这类基础镜像,把源码和编译产物放进去。我踩过的坑是:不要把Quicklisp缓存整个打进镜像,镜像会飞速膨胀,保留~/.quicklisp再清理没用的包即可。

4. 常见问题与调试实录

4.1 路由匹配不符合预期

遇到过好几次,/foo/:id和/foo/bar同时存在时,caveman按定义顺序匹配,前面的优先。如果你/foo/:id在前,就会把/foo/bar吸收掉。所以同一层级下,越具体的路径要放越前面:

(defroute "/user/me" () ...) ;; 这个要放在 :id 前面 (defroute ("/user/:id" :method :get) (&key id) ...)

这个教训特别典型,我一开始把/user/:id写在前面,导致/user/me永远匹配不到。后来学乖了,后缀式匹配全部放在字面量路由之后。

4.2 参数变成NIL或者类型不对

参数缺失的时候,绑定的值会是NIL。如果直接把NIL传给数据库或模板,轻则报错,重则产生脏数据。我的习惯是:所有外部参数进入业务逻辑前,先做一个“必填校验”,缺了就返回400。还可以封装一个小函数:

(defun required (value name) (unless value (error "Missing required parameter: ~a" name)) value)

数据库里如果传parse-integer,一定要考虑用户传了abc的情况。我见过有同事直接把异常抛给框架,最后返回500和一段Lisp堆栈信息,这体验可太糟糕了。你自己用没问题,但如果服务对外开放,建议在defroute里包一层handler-case,或者写一个统一的错误中间件。

4.3 热加载失效或缓存旧代码

在REPL里改完代码刷新浏览器没变化?最常见的原因是:你改的是模板文件,而Djula默认缓存了编译后的模板。你要是坚持热加载,可以在app.lisp里关闭缓存:

(setf djula:*cache-mode* nil)

但上线时记得改回来,否则每次请求都重新解析模板,性能损失很明显。另一个原因是:你的修改在某个没加载的包或文件里,REPL里的in-package没切对。热更新时,多留意当前包的符号是否指向你改的函数。调试了半天,发现我改的是foo:bar,而路由调用的却是baz:bar,这种命名空间错位真的太常见了。

4.4 中文编码问题

Lisp处理字符串很流畅,但和Web前端交互时,中文乱码问题绕不开。记得在响应里设置UTF-8,方法是在应用配置里加:

(setf hunchentoot:*default-content-type* "text/html; charset=utf-8")

模板文件也务必用UTF-8保存,并且检查数据库连接字符串是否启用了UTF-8。如果你在页面看到一串“锟斤拷”,基本就是编码在某个环节断掉了。可以说,这个坑比程序逻辑bug更让人崩溃。

4.5 线程安全与共享状态

Hunchentoot默认支持多线程,表面上看起来一切正常,但你的Lisp代码并不是默认线程安全的。比如一个全局变量*items*,如果多个请求同时在改它,不加锁就会产出脏读。我的建议:能做成纯函数就做纯函数;有共享状态加sb-thread:with-mutex保护。还有一次,我在一个defparameter里存了临时数据,上线后遇到奇怪数据错乱,查了半天才发现是这个变量被某条请求改了。

5. 从caveman出发:还能怎么玩出花

5.1 集成Lisp的AI能力

Common Lisp虽然是小众,但在AI/符号计算领域依然有深厚的历史资产。我记得有一个项目用caveman包裹了一套规则引擎,前端把需求提交进来,后端跑上百条规则,最后生成推理结果展示。我试着把一个简单的决策树推理服务接到caveman上,从定义请求到输出结果只用了不到半小时。如果你的业务逻辑里有大量条件判断、状态转换,用Lisp写那部分规则,比在Java里堆if-else舒服太多。

5.2 做配置中心或管理后台

公司业务一多,配置文件就散成灾。用caveman搭了个内部配置中心,每个服务通过HTTP接口拿自己的配置。因为caveman本身很轻,启动一个实例放在内网,几乎不占资源。而且因为路由可以按正则批量接,实现“按服务名读取配置”这种接口就是几行的事。这样的场景下,caveman的价值完全靠“小而精”三个字体现。

5.3 构建可交互的代码沙箱

还有一个很“极客”的玩法:用caveman暴露一个执行Lisp表达式的接口,前端丢一段代码,后端在REPL里跑完返回结果。这就是一个简化版的“在线Lisp REPL”。注意权限和安全,千万别裸奔在内网之外。这也是caveman和REPL结合得天衣无缝的证明,别的Web框架很难给你这种“代码即REPL”的顺滑感。

6. 最后的几点体会

回头看看这段caveman之旅,我的核心体会是:选型一定要匹配场景,caveman不是万金油,但在“快速写一个稳定的内部服务”这个赛道里,它有种独特的从容。它不像某些框架那样追着你做“参数校验、权限、分页、API文档”,而是把控制权交还给你。你为它付出的学习成本,会通过调试效率和运行稳定性赚回来。

如果你手揉过REPL,经历过“改一个bug,刷一下浏览器,世界清净”的体验,你会理解我为什么对它有着偏爱。如果你正被一群庞大笨重的框架折磨,不妨花一个下午,拿这个小家伙写个玩具业务,感受一下“轻”到底有多轻。最后提醒一句:如果哪天你真把它部署到生产环境,记得关掉Djula缓存、配好UTF-8、加好权限校验——这三件事,每一项我都实际踩过坑。