caveman这个词,对大多数人来说第一反应是“穴居人”,但混Common Lisp圈子的人听到它,通常会想到那个小而美的web框架。我在去年一个内部工具项目里用了Caveman,从初始化到上线前后折腾了两周,过程中踩了不少文档里没写明白的坑。这篇就把我的实际使用经验整理出来,包括为什么在Lisp生态里选中它、目录结构怎么摆、路由和模板怎么配合、数据库怎么接,以及最后部署时容易忽略的几个细节。不管你是刚接触Lisp想找web方案,还是已经在用Hunchentoot之类的东西想换个写法,这篇都能给你省点时间。
1. 为什么我会在项目里选中Caveman
1.1 先把Caveman在Lisp生态里的位置说清楚
Common Lisp的web开发一直给人“能用但不主流”的印象,主要原因是框架分散、资料少、示例代码对新手不友好。早期大家爱用Hunchentoot,功能齐全但偏“全栈”,你需要自己管理session、模板、静态文件这些琐碎的东西。后来Clack出现,相当于把“web服务器抽象层”这件事做标准了,类似Python界的WSGI或者Ruby界的Rack。Caveman正是建立在Clack之上的一个微框架——它不帮你决定数据库用什么、模板用什么,只负责把“请求进来、路由匹配、调用处理函数、返回响应”这条链路理清楚。
我当初选中它,核心原因是项目本身不大:一个内部的数据查询和管理面板,大概二十来个页面,要接PostgreSQL,要能输出JSON给前端。这种规模用Hunchentoot裸写路由会显得啰嗦,用更重的框架又没必要。Caveman刚好卡在中间:它把web层该有的东西给了,但没绑架你的其他选择。
1.2 和同生态方案的对比:它赢在哪,又差在哪
我整理了一张当时做选型时用的对比表,都是真实情况,不是网上抄来的:
| 框架/方案 | 路由表达 | 中间件支持 | 学习曲线 | 适合场景 |
|---|---|---|---|---|
| Hunchentoot | 手写handler,绑定URI字符串 | 有,但偏底层 | 中等偏陡 | 想完全掌控请求生命周期 |
| Snooze | 基于restas风格的声明式路由 | 有,但资料少 | 中等 | 喜欢RESTful风格的人 |
| Caveman | defroute宏,路径参数直接绑变量 | Clack中间件生态 | 平缓 | 中小型web应用、API服务 |
| 直接用Clack | 自己写dispatch逻辑 | 全手动 | 陡 | 想从零搭建的人 |
Caveman最被我欣赏的一点是“路由即函数”:一个defroute宏,路径、HTTP方法、参数绑定全在里面搞定了。相比之下,Hunchentoot里写一个带URL参数的路由要绑定多个回调,代码散落得到处都是。实际写下来,Caveman的代码密度确实最高,一个文件能放下一组相关路由,维护起来不用跳来跳去。
当然它也有短板。最明显的是框架的“意见”太少,导致团队协作时约束感弱。比如表单参数命名、错误响应格式、session方案,官方只是给了默认值,没有强推最佳实践。如果你的项目是两三人长期维护的大系统,这点会成为隐患——每个人可能写出风格迥异的handler。另一个缺点是社区资料确实少,很多问题得直接翻源码才能搞明白,尤其是defroute参数解析那部分。
1.3 什么人适合用它,什么人不适合
说到底还是选型边界问题。我的建议很直接:如果你的项目是中小型web应用、内部管理系统、REST API,或者你已经在用Clack想要一个轻量的路由层,Caveman值得试。如果你要写的是面向海量用户的复杂电商系统,或者团队里全是Lisp新手且没有老手带,它不是一个省心的答案——你大概率会在中间件组织、错误处理这些地方花掉比写业务逻辑更多的时间。
我自己的项目最后跑得挺顺,但中间有几次想放弃的冲动。坚持下来之后确实发现,一旦适应了defroute的思维方式,Caveman是那种“平常不会觉得惊艳、但离开后才发现顺手”的框架。
2. 从零初始化一个Caveman项目,目录结构怎么看懂
2.1 Quicklisp依赖和项目骨架生成
安装这块没什么悬念,前提是你已经装好Quicklisp并且能用。Caveman本身对Lisp实现没有挑剔,我是在SBCL上跑的,2.x版本在CCL上也应该没问题。
核心系统有两个:caveman2是框架本体,caveman2project是项目生成器。用后者一条命令就能拉出一个完整骨架:
(ql:quickload :caveman2project) (caveman2project:generate "myapp")执行完之后,当前目录下会多出一个myapp/文件夹。这里有个容易迷惑的点:生成器叫caveman2project,生成出来的项目asd文件里依赖的却是caveman2系统。很多人第一次看到会以为是不是搞错了,其实这是正常的——project生成器和框架本体从某个版本开始就分开了,你需要两个都装上。
2.2 生成出来的每个目录是干嘛的
刚生成时你会看到一堆文件,文件关系其实比想象中简单:
myapp/ ├── myapp.asd ; ASD定义,加载入口 ├── src/ │ ├── config.lisp ; 配置和数据库连接 │ ├── web.lisp ; 应用实例和全局中间件 │ └── controllers/ │ ├── index.lisp ; 示例路由都在这 │ └── package.lisp ; 控制器的包定义 ├── static/ ; CSS、JS、图片 ├── templates/ ; Djula模板 └── tests/ ; 测试目录web.lisp里通常只有一个make-instance '<app>的动作,实际路由都在controllers/index.lisp。config.lisp里放的是数据库连接配置和密钥,这个文件在部署时会被替换成服务器上的真实配置。static/和templates/分别对应静态资源和模板,两者互不干扰。
刚生成的骨架默认带了几个示例路由和一个测试页面,我第一次跑起来根本没改业务代码,先熟悉了“改模板、刷新页面、看效果”的循环。
2.3 跑通Hello World的最小改动
生成骨架后,进入myapp目录,在你的Lisp REPL里加载项目:
(ql:quickload :myapp)然后启动web服务:
(myapp.web:start :port 8080)浏览器打开http://localhost:8080,如果能看到“Hello, Caveman!”之类占位页面,说明环境通了。我用的是默认端口8080,如果你机器上8080被占了,start函数接受:port参数,换成别的就行。
这一步最容易出问题的是Quicklisp找不到myapp系统。原因是你的~/quicklisp/local-projects/目录里没有创建指向项目文件夹的软链。解决办法很朴素:
- 把
myapp/整个目录复制到~/quicklisp/local-projects/下,或者 - 在
myapp所在目录执行ln -s $(pwd) ~/quicklisp/local-projects/myapp
之后重新(ql:quickload :myapp)就正常了。这个坑我在不同机器上栽过两三次,每次都是忘了软链。
3. 路由不是写写路径那么简单:defroute背后的东西
3.1 defroute宏到底替你做了什么
在Caveman里定义一个路由,最小形式是这样的:
(defroute "/" () "Hello, Caveman!")看起来就是“声明一个路径、一个空参数列表、一个函数体”。但实际上,这行代码在宏展开阶段生成了不少东西。最明显的是它自动注册了一个handler到Clack的路由表里:请求路径匹配/时就调用这个函数。你不需要手动维护路由注册表,不需要在任何地方push这个handler,它已经被写进宏展开的逻辑里了。
第二个帮你做的事是参数绑定。定义带路径参数的路由:
(defroute "/users/:id" (:method :GET) (id) (format nil "User ~A" id))请求/users/42时,函数体里的id会被自动绑定为"42"。这个绑定发生在请求分发给函数之前,你不用自己解析路径字符串。我第一次用的时候觉得“这也太普通了吧”,后来想想,就是这种普通,恰恰是最省心的地方。
3.2 动态URL参数与查询字符串的配合
路径里的:id对应URL片段,查询字符串则对应命名参数。比如请求/search?q=caveman&page=1,路由可以这么写:
(defroute "/search" (&key |q| |page|) (format nil "query=~A page=~A" |q| |page|))这里有个很隐蔽的坑:查询参数的key如果不带竖线,会被Lisp自动转成大写符号(因为&key接受的是keyword)。比如写(&key q page),从request里取到的可能是:Q而不是:q,一旦前端传的是小写q,匹配就会出问题。我第一次就是没加竖线,结果q参数一直是NIL,排查了半天。解决办法是用|q|这种竖线标注,明确告诉Lisp这是一个大小写敏感的符号。
POST表单的处理方式和查询字符串几乎一样,只是要指定方法:
(defroute "/login" (:method :POST) (&key |username| |password|) (if (and (equal |username| "admin") (equal |password| "secret")) "login ok" "login failed"))Caveman会从请求体里解析出表单字段,然后按同样的规则绑定到参数上。GET、POST之外的PUT、DELETE也支持,语法类似。
3.3 返回JSON还是HTML:响应内容协商
Caveman的handler默认返回字符串时,响应头是text/html。如果你的路由要提供API,得显式设置Content-Type。我的做法是写一个小工具函数替换手写:
(defun render-json (object) (setf (getf (response-headers clack:*response*) :content-type) "application/json; charset=utf-8") (jonathan:to-json object))然后路由里直接:
(defroute "/api/users" () (render-json (list (list :id 1 :name "alice") (list :id 2 :name "bob"))))这里用了jonathan库做JSON序列化,它在Common Lisp世界里比cl-json的语法更顺手,支持keyword直接输出成JSON字符串。响应头那块,我强烈建议封装成工具函数,因为多个handler都要用,每次都写三行头很烦。
还有一个小技巧:Caveman支持在路由路径里用通配符*来匹配剩余路径,比如/files/*path。这在处理静态文件后路由时有用,但注意别和真实文件系统太深地纠缠,否则容易在静态资源和动态路由之间打架。
4. 模板、布局和静态资源:页面拼装实战
4.1 为什么默认模板引擎是Djula
Caveman自带模板渲染的入口函数render-template,默认对接的引擎是Djula。Djula的语法对从Django模板过来的人很亲切:{% block %}、{% extends %}、{% for %},变量的插值用{{ var }}。
它胜在三个地方:
- 和Common Lisp配合得好,模板里可以直接使用已经传入的变量,不用像其他模板引擎那样搞复杂的过滤器注册。
- 模板文件放在
templates/目录下,天然和组织目录结构匹配。 - 自带的
{% include %}和{% extends %}机制做布局继承非常方便。
如果你觉得Djula不好,Caveman也允许换。这是它“不绑架选择”的另一个体现。你只要在渲染时用自己的函数替代render-template就行。实际项目里我最后还是老老实实用Djula,因为它就是默认的,省得折腾。
4.2 layout继承和多模板组合的实操
我项目里页面种类很少但结构类似,所以layout继承帮了大忙。先建一个基础页base.html:
{# templates/base.html #} <!DOCTYPE html> <html> <head> <title>{% block title %}默认标题{% endblock %}</title> <link rel="stylesheet" href="/static/style.css"> </head> <body> <div class="container"> {% block content %}{% endblock %} </div> </body> </html>然后每个页面模板都extends它,比如index.html:
{# templates/index.html #} {% extends "base.html" %} {% block title %}主页{% endblock %} {% block content %} <h1>{{ greeting }}</h1> <p>这里是系统首页。</p> {% endblock %}handler里只需要给模板传变量:
(defroute "/" () (render-template "index.html" (list :greeting "你好,Caveman")))这里有个值得注意的细节:render-template传入的变量列表是一个plist,模板里的{{ greeting }}对应:greeting这个key。如果你的模板里变量名带连字符,比如page-title,plist里就要写成:|page-title|,否则照样是大小写转换的问题。这个坑和路由参数是同一类,都是在“符号大小写”上翻车。
4.3 静态资源路径与缓存问题
Caveman对static/目录提供默认的中间件支持,路径是/static/...。所以CSS放在static/style.css,页面里就直接/static/style.css引用。
但有个问题官方文档几乎没提:如果你改动了static目录下的文件,浏览器可能从缓存里拿旧版本。我一开始在模板里改颜色,刷新页面怎么都不生效,后来才发现不是服务器的问题,是HTTP缓存。最简单的处理是在CSS文件名后面加版本参数:/static/style.css?v=2。手动改版本号虽然土,但在内部工具里很可靠,不值得为这个配置复杂的构建管线。
万一某些场景下static/目录不生效,你要检查中间件是否正确加载。最常见的原因是把*app*实例创建的时机搞错了,导致静态文件中间件没有挂到Clack上。排查方法是直接访问/static/favicon.ico之类的已知文件,如果连文件夹里的文件都返回404,那就基本锁定是中间件没挂上。
5. 把数据库接进来:ORM选型和常见坑
5.1 Caveman不管数据层,那么谁来管
Caveman对数据库的态度是“不管”。这是它的设计哲学,不是缺陷。项目需要持久化时,你自己选方案。Common Lisp生态里最常用的搭配是三层:
postmodern:PostgreSQL驱动,负责最底层的连接和SQL执行。SxQL:一个SQL构建库,让你在Lisp里用S表达式拼SQL,避免写一堆字符串拼接。Mito:在postmodern之上的ORM,提供deftable定义表、select-dao查询、save-dao保存这些操作。
我用这一套组合跑了半年的生产环境,感觉是:Mito帮你把基础CRUD变成了直观的函数调用,SxQL负责那些不能直接用ORM表达的复杂查询,postmodern则保证底层的连接池和事务行为是可靠的。
5.2 Mito/SxQL/Postmodern的分工方式
在config.lisp里先建立连接:
(mito:connect-toplevel :database-type :postgres :database-name "myapp" :username "myapp_user" :password "secret" :host "127.0.0.1")然后定义模型:
(mito:deftable user () ((name :col-type (or :text :null) :initarg :name) (email :col-type (or :text :null) :initarg :email)) (:primary-key id))执行建表:
(mito:ensure-table-exists 'user)之后增删改查就是函数调用:
;; 增 (mito:create-dao 'user :name "Alice" :email "alice@example.com") ;; 查 (mito:select-dao 'user (sxql:where (sxql:= :name "Alice"))) ;; 删 (mito:delete-dao user-instance)每层的职责其实很清晰。postmodern管连接,SxQL管SQL语句的拼装,Mito管DAO对象的生命周期。用SxQL,是因为当查询条件动态变化时(比如用户在前端筛选),你需要在一个运行的handler里按条件组合SQL。手写字符串拼接在Lisp里非常痛苦,SxQL至少保证了结构和引号是安全的。
5.3 一个完整的列表页示例
下面是一段完整代码,展示了“取所有user、渲染到模板”的典型流程:
(defroute "/users" () (let ((users (mito:select-dao 'user))) (render-template "users.html" (list :users users))))模板里遍历:
{% for user in users %} <li>{{ user.name }} <{{ user.email }}></li> {% else %} <li>暂无用户</li> {% endfor %}放进模板后还有一个细节:{{ user.name }}这种属性访问,Djula会调用user.name这个slot的accessor。如果你的模型没有定义accessor,模板会抛出奇怪的错误。解决方法是deftable时自动生成的user-name、user-email这类函数,模板里可以用点号访问,前提是accessor存在。
这个组合最让我头疼的一次,是Mito默认生成的表的id列是serial自增主键,但在做批量导入时你必须绕过主键冲突。后来用SxQL的:insert-into配合:values手动控制字段,绕开DAO层才搞定。所以我的建议是:基础CRUD用Mito没问题,但涉及批量操作或复杂事务时,别怕直接跳到SxQL层面写SQL。
6. 部署与自动化:让项目真正跑在服务器上
6.1 Clack server的选择与端口配置
开发时我直接在REPL里用(myapp.web:start :port 8080),这是Clack自带的开发服务器,单线程的,适合调试。部署到服务器就绝对不能这么用了,要换成正式的server后端。
Clack支持不同的server实现,最常用的两个是clack-handler-hunchentoot和clack-handler-flexi。前者基于Hunchentoot,成熟但有一定内存开销;后者比较轻量。我个人习惯在开发机上用clack-handler-hunchentoot,因为它在调试错误时信息更完整。
启动方式上,不要直接跑裸SBCL。我的做法是用Roswell做一个可执行文件,然后交给systemd管理。
6.2 用Roswell构建可执行文件
Roswell是Lisp开发的常用工具,它能把你的程序打包成独立的二进制启动文件。第一步,在项目根目录建一个run.ros脚本:
#!/bin/sh #|-*- mode:lisp -*-|# #| exec ros -Q -- $0 "$@" |# (ql:quickload :myapp) (myapp.web:start :port 8080)最后用命令行构建可执行文件:
ros build run.ros执行完会生成一个run文件——这就是最终的二进制产物,扔到服务器上就能跑。构建时注意把~/.sbclrc里可能加载的奇怪配置清掉,否则打出来的二进制可能会带一些本地环境的路径依赖,在服务器上会报找不到文件。
如果你不想用Roswell,buildapp的老办法也还能用,但Roswell对Quicklisp依赖的处理更顺滑,构建失败率更低。
6.3 我遇到的问题和解决思路
第一个坑:可执行文件在服务器上启动后,端口监听在0.0.0.0还是127.0.0.1。需要根据你的架构来决定,如果前面有Nginx做反向代理,那么后端监听127.0.0.1就够了;如果裸奔对外,就要监听0.0.0.0。我改过几次这个配置,用环境变量来控制最稳妥,避免每次部署改代码。
第二个坑:systemd的EnvironmentFile加载。我的config.lisp里读数据库密码从环境变量拿,systemd单元文件里就要写清楚:
[Unit] Description=myapp web After=network.target [Service] User=myapp WorkingDirectory=/srv/myapp EnvironmentFile=/etc/myapp/env ExecStart=/srv/myapp/run Restart=always [Install] WantedBy=multi-user.target这里值得注意:EnvironmentFile里的变量在Lisp里怎么拿?用(uiop:getenv "DATABASE_PASSWORD"),这个函数在SBCL下是原生支持的。别依赖(sb-ext:posix-getenv)这种实现相关的函数,换CCL就没了。
第三个坑:日志。开发时打印到stdout没问题,部署时systemd会把stdout收走,但Lisp程序的错误信息有时候会直接写stderr。我在代码里过滤了一部分日志,生产环境保留请求日志和错误堆栈,其他debug信息直接关掉。否则日志文件会瞬间涨到几个GB。
整套流程走下来,从开发机到服务器部署一次之后,再重复就很顺了。我后面接了好几次迭代,基本是“改代码、ros build、scp上去、systemctl restart”四步走,稳定得很。
如果要再往深了说,Caveman还有几个值得玩味的地方,比如中间件机制、错误页面定制、会话管理。但对我来说,历史最深刻的还是那些坑:
- 符号大小写问题贯穿路由参数、查询字符串、模板变量的整个生命周期,一不留意就是NIL。
- Djula模板的变量访问、块继承,花了不少时间看文档才摸清规律。
- 数据库层用Mito/SxQL/Postmodern组合,看起来复杂,实际用顺了之后比很多动态语言的ORM还直观。
我在实际项目中最后悔的一件事是没早一点把响应头封装成工具函数,导致一开始手写Content-Type重复代码写了一大堆。如果你正好也要用Caveman,建议开项目第一天就把render-json、统一的错误格式、日志开关这些基础设施放着,后面会省非常多事。