先说实话,Caveman这个名词在程序员圈子里有两种画风。一种是老电影里裹着兽皮追猛犸象的穴居人,另一种是Common Lisp世界里的Web框架。我要聊的是后者,而且聊得很诚实:它是我近几年用过最“自由”的后端开发工具,没有之一。
Caveman是Common Lisp生态中最成熟的Web框架之一,当前主流版本是Caveman 2,出自日本开发者fukamachi之手。它做的事和Django、Rails这类框架差不多:创建项目骨架、管理路由、渲染模板、对接ORM、挂中间件,但它有一个本质区别——一切都可以在REPL里实时修改、即时生效,不需要等编译器重新跑完整个项目。我最初把它当成一个“用奇怪语言写的Rails”来看,实际用下来才发现,它真正打动人的不是“能用”,而是“改起来太痛快了”。对于想体验函数式语言做Web开发的人、被全家桶框架约束得想换口气的独立开发者、以及需要为内部工具做一个长期可维护后台的团队,Caveman都值得花一个周末折腾一次。
这篇不打算写成官方文档翻译,就按照我实际踩过的流程,从为什么选它、怎么跑起来、路由模板ORM怎么配,到那些文档不会告诉你的坑,一条条讲清楚。
1. Caveman是什么?——Lisp世界的“小而美”Web框架
1.1 项目定位与核心需求解析
Caveman不是一个像Django那样“从数据库到前端模板全包圆”的巨型框架,它的定位更接近Flask和Sinatra——给你提供Web应用最核心的路由分发、请求处理、底层HTTP接口接入,再把模板引擎、ORM、中间件这些模块留给你按需自由组合。
这个定位非常符合Lisp社区的哲学:核心要小而稳,扩展要自由。Caveman生成的项目目录里有明确的模板、静态资源和源码分层,让人第一眼就知道代码该往哪里放。同时它没有强迫你使用某种数据库、某个模板引擎或者特定的项目部署方式。你完全可以把Caveman当成一个“带路由功能的HTTP处理器”,然后在它周围按照项目需求堆叠自己的组件。
最早我关注到它,是因为一个老前辈在邮件列表里推荐“如果你想认真做一个长线维护的工具站,试试Caveman”。我当时正好被手上一个内部管理系统缠得头疼:用PHP写的旧代码没人敢动,想重写但不想进Java那套工程化的迷宫。Caveman的项目体量和灵活的组件组合方式,恰好适合这种“要长期维护、但要清爽”的诉求。
1.2 Caveman能解决什么问题
这个框架解决的核心问题,我可以归纳成三条。第一,它让Lisp写Web应用有了标准和骨架。Common Lisp社区历史悠久,库很多,但没有一个统一的路由规范,如果从零开始写HTTP服务,光是处理请求分发就要折腾很久。Caveman把这块做成了一套固定且好用的DSL,把Lisp在工作效率上的优势真正引入到了Web开发场景。第二,它让“运行时改代码”成为可能。Lisp程序运行时的映像可以动态修改,Caveman充分利用了这个特性,后端开发完全不需要像传统编译型语言那样改完重启。第三,它提供了一个可商用的生产方案。在Caveman之上,有Clack作为Lack应用的服务器抽象,可以方便地接入Hunchentoot等HTTP服务器,有Mito做ORM,有SxQL写SQL查询,生态虽小但五脏俱全。
1.3 它适合谁,不适合谁
如果你属于下面三类人中的任何一类,Caveman值得试:一是对Lisp语言感兴趣,想用它做点实际项目而不是只写SICP作业的人;二是被现有框架的“约定大于配置”逼得难受,希望把每一层结构都牢牢握在手里的开发者;三是想为团队快速搭建内部管理后台,而且这个后台要稳定运行好几年的人。
但如果你追求的是“一个命令生成全套增删改查页面”,是希望官方提供“从登录到后台管理一键生成”的能力,那Caveman会让你失望。它是给“愿意自己动手拼积木”的人准备的。用一句话概括:Caveman不替你思考,它把思考的自由还给你。
2. 为什么偏偏选Common Lisp?——设计哲学与技术选型
2.1 宏系统带来的“路由DSL自由”
大多数Web框架的路由定义,本质上就是字符串匹配加上一层函数分发。Caveman也做这件事,但因为Common Lisp有宏系统,它可以做得更彻底。用defroute定义路由,这在语法上很接近函数定义,写起来非常顺手。
(defroute "/users/:id" (&key id) (format nil "Hello user ~A" id))这里宏在编译期就解析了路径模式,把:id自动绑定到请求参数上。这种表达方式,在Rails里是“约定”,在Caveman里是“语言本身的语法能力”。你写多了就会有一种感觉:不是框架在限制你“必须怎么写”,而是Lisp在帮你把想法直接翻译成代码。
2.2 Clack/Lack:Lisp自己的“WSGI”
Web开发绕不开HTTP服务器和业务代码之间的适配问题。Python有WSGI,Ruby有Rack,Common Lisp对应的就是Lack和Clack这两个分层。Lack定义了应用函数的标准接口:一个应用就是一个函数,接收环境对象,返回包含状态码、响应头和响应体的列表。Clack则是更上层的服务器适配层,它让同一个应用可以跑在Hunchentoot、Wookie或其他服务器上,还支持中间件堆叠。
Caveman构建在Lack之上,所以它能享受整个Lack中间件生态。这使得Caveman在保持自身轻量的同时,不至于在功能上捉襟见肘。我第一次理解这个架构的时候,脑子里冒出来的类比是:Caveman像一家装修公司,负责把毛坯房做成能住的格局;Lack是水电标准线,任何符合标准的家电(中间件)都可以装进这间屋子。
2.3 Caveman与主流框架的对比
| 维度 | Caveman | Flask | Rails | Express |
|---|---|---|---|---|
| 语言 | Common Lisp | Python | Ruby | JavaScript |
| 项目骨架 | 自动生成 | 手动搭建 | 自动生成 | 手动搭建 |
| 路由DSL | 宏实现,编译期解析 | 装饰器+字符串匹配 | 约定式路由 | 字符串模式 |
| 运行时修改 | 支持,REPL实时生效 | 调试模式需重启 | 开发模式可热加载 | 需nodemon等工具 |
| 默认模板引擎 | Djula | Jinja2 | ERB | 无 |
| ORM可选 | Mito | SQLAlchemy | ActiveRecord | 任意 |
| 数据层自由度 | 极高 | 较高 | 受约定限制 | 较高 |
| 生产部署 | 需要自己拼 | 成熟方案多 | 成熟方案多 | 成熟方案多 |
这张表不是为了证明Caveman“更好”,而是说明它的取舍。它牺牲了一部分开箱即用的便利,换来了极高的组态自由度和运行时操控能力。如果你享受“每一层都由自己选择”的掌控感,这种取舍非常舒服;如果你希望遇到不懂的问题能一键搜到标准答案,那主流框架显然更合适。
3. 上手实操:从零搭一个Caveman项目
3.1 环境准备:SBCL + Quicklisp + Roswell
Common Lisp世界目前的实践中,SBCL(Steel Bank Common Lisp)是性能和稳定性最均衡的实现,绝大多数开源项目都在它上面测试,Caveman自然不例外。而为了使环境管理更顺滑,我建议直接用Roswell——可以把它理解为Lisp界的rbenv,专门替你管理SBCL版本、安装Quicklisp、启动REPL。
安装Roswell之后,只需两行命令先把SBCL装好:
ros install sbcl-binRoswell会默认带上Quicklisp。Quicklisp是Lisp社区最重要的包管理器,类似Python的pip,但它做得更狠的一点是,它会把选定的库版本快照固定下来,保证你一个项目在两年后打开还能用同一个版本组合跑起来。在Quicklisp里加载Caveman极其简单,在REPL里执行:
(ql:quickload :caveman2)如果网络状况正常,依赖会自动拉取编译。第一次编译大概需要几分钟,因为要编译整个依赖链,包括Clack、Lack、Hunchentoot、DJula这些。
3.2 生成项目骨架
依赖装好之后,生成项目骨架就非常快了。这一步对应的是Rails里的rails new myapp。在REPL里继续:
(caveman2:generate "myapp")命令会在当前目录下生成一个myapp文件夹,里面就是完整的Caveman项目结构。不同小版本对生成命令的入口名称可能略有调整,如果提示找不到函数,使用(ql:quickload :caveman2)后查看包的导出符号,确认一下具体方法名即可,思路是完全一致的。
我生成过好几个项目,实测下来骨架非常干净,不会像一些框架那样生成几百个你根本看不懂的文件。
3.3 目录结构解读
生成后的目录大致是这样的:
myapp/ ├── myapp.asd ├── README.md ├── src/ │ ├── config.lisp │ ├── app.lisp │ ├── db.lisp │ └── route.lisp ├── templates/ │ └── index.html ├── static/ │ ├── css/ │ └── js/ ├── test/ │ └── test.lisp └── .gitignoremyapp.asd是这个项目的系统定义文件,它告诉ASDF(另一个构建系统)这个项目由哪些源文件组成、依赖哪些外部库。它的角色相当于package.json加Makefile的综合体。src/config.lisp放配置信息,包括端口号、数据库连接参数之类;src/app.lisp定义应用的入口和Lack中间件堆叠;src/route.lisp专门放路由定义;templates/目录放Djula模板;static/目录放静态资源,CSS、JS和图片都往这里丢。
3.4 启动开发服务器
Caveman项目可以用Clackup工具直接启动,不需要先编译成可执行文件。在项目根目录执行:
ros run -e '(ql:quickload :myapp)' -e '(clack:clackup (myapp:app) :port 5000)'默认监听5000端口。启动之后浏览器访问http://localhost:5000,就能看到默认的欢迎页面。这个流程的核心在于:整个应用运行在REPL的环境里,如果你在Emacs里用SLIME或Sly连接到这个REPL,修改src/route.lisp后编译当前文件,刷新浏览器即可看到效果,完全不需要重启服务器。这个开发体验是Caveman区别于几乎所有主流框架的灵魂所在。
4. 核心开发:路由、模板、ORM与中间件
4.1 路由定义与参数绑定
Caveman的路由定义语法非常直观。最基础的GET请求,只需要写一个路径和处理函数:
(defroute "/" () (render "index.html"))带路径参数的路由用:参数名占位,参数会自动进入处理函数的关键字参数:
(defroute "/users/:id" (&key id) (format nil "User ID: ~A" id))这里有个很实用的细节:Caveman会自动做URL解码,如果id里带了特殊字符,拿到的已经是解码后的干净值。我还习惯给路由明确指定HTTP方法:
(defroute ("/users" :method :POST) (&key name email) ;; 处理用户创建 ):method可以取:GET、:POST、:PUT、:DELETE等,同一个路径可以按方法分别定义多个处理函数,这个设计比在函数体里手动判断request-method要清晰得多。另外.lisp表达式求值非常自由,路由处理函数里完全可以直接调用CLOS对象的方法,做复杂的业务逻辑一点不会被框架束缚。
4.2 模板渲染:Djula
Caveman默认使用Djula,这是Common Lisp里最成熟的Django风格模板引擎。之所以默认选它而不是其他模板库,是因为Djula的模板语法足够简洁,而且它的渲染实现基于编译后的函数,性能足够满足绝大多数业务场景。
在src/route.lisp里渲染模板,一行就够:
(defroute "/hello" (&key name) (render "hello.html" (list :name name)))render的第二个参数是一个属性列表,模板里用{{ name }}就能输出。模板文件示例:
<!DOCTYPE html> <html> <head><title>Hello</title></head> <body> <h1>Hello, {{ name }}</h1> {% for user in users %} <p>{{ user.name }} - {{ user.email }}</p> {% endfor %} </body> </html>和Django一样,Djula支持{% if %}、{% for %}、{% include %}这些控制结构。我用它写过不少报表页面,嵌套循环配合局部模板拆解,维护起来并不痛苦。唯一要注意的是,模板变量上调用方法有时需要显式包装,这与Lisp的函数调用风格有关,写多了就习惯了。
4.3 数据访问:Mito + SxQL
数据库是最能体现Caveman“自由组合”的地方。官方并不强绑数据库方案,但社区里最常见的搭配是Mito做ORM、SxQL构建查询。用Mito定义一个用户表模型,代码非常紧凑:
(mito:deftable user () ((name :col-type (:varchar 64)) (email :col-type (:varchar 128) :unique t)))这个定义在首次运行时就会自动生成对应的CREATE TABLE语句。Mito会为表自动创建make-user、select-user、update-user、delete-user等一系列CRUD方法,完全遵循Common Lisp的命名风格。复杂查询用SxQL写,安全性比拼接字符串好得多:
(sxql:select (:id :name :email) (from :user) (where (:and (:= :age 20) (:like :name "%张%"))))这串查询可以直接传给Mito的检索方法,例如mito:retrieve-by-sql。我最初从SQLAlchemy切换过来时最大的感受是:SxQL的语法和最终生成的SQL几乎一一对应,说白了它就是“带括号的SQL”,写起来非常直观,不会出现“用ORM半年不知道底层SQL是什么”的尴尬。
4.4 中间件与静态资源
Caveman项目里有一个专门的中间件堆叠配置,默认生成的src/app.lisp大致是这个结构:
(lack:builder (:static :path (lambda (path) (and (ppcre:scan "^/static/" path)))) :accesslog :session app)我习惯在这个基础上按需加东西。比如给接口打统一的JSON响应头,就写一个简单的Lack中间件函数;想给全站加Basic Auth,也可以在这里插一个自定义环节。Lack中间件的写法遵循Lack规范,本质上就是高阶函数:
(lambda (app) (lambda (env) (let ((res (funcall app env))) ;; 修改响应... res)))这套中间件机制,让我想起Node.js里的Koa,但Lack的接口更简单——一个入参环境,一个返回值响应,没有那么多异步和上下文规则。理解成本很低。我在实际项目里用过它做请求日志、错误捕获、响应压缩,基本都能在几行代码内解决。
5. 常见问题与排查技巧实录
5.1 启动报错“Package not found”
最常见的一种错误是加载项目时报Package MYAPP not found。这通常是因为ASDF没有找到项目的.asd文件。在Quicklisp环境下,最简单的方式是把项目目录软链到Quicklisp的local-projects目录,然后重新(ql:quickload :myapp)。如果你是在项目外部用ros run直接加载,需要先把项目路径加入asdf:*central-registry*,或者用(asdf:load-asd "...")显式加载描述文件。
这个坑我踩过不止一次,后来干脆养成了习惯:新建项目后第一件事就是把它链到local-projects下面,一劳永逸。
5.2 Quicklisp加载慢或依赖冲突
第一次ql:quickload :caveman2如果很慢,多半是网络问题。可以配置国内镜像源,把Quicklisp的dist指向镜像地址,编译速度会有明显改善。依赖冲突的情况较少见,但如果你同时加载了两个版本的某个库,Quicklisp会提示错误。解决办法是保持Quicklisp版本快照一致,项目里记录一份固定的dist版本,别频繁ql:update-dist。
5.3 热重载不生效
很多人从Node.js或Python转过来,默认认为Caveman有个类似nodemon的自动重启工具,其实没有。Caveman的热重载机制依赖REPL:你用Emacs+SLIME连接REPL,改完代码后按C-c C-k编译当前缓冲区,改动才会进入运行中的服务器。如果你只是在终端开了一个裸REPL,改完文件后还必须手动重新加载系统:
(asdf:load-system :myapp :force t)这个区别其实反映了两种文化的差异:主流框架是“改文件,触发工具重跑”,Lisp是“改函数,编译进当前运行环境”。我建议开发Caveman项目时一定配上Emacs或者VS Code的Lisp插件,否则体验会大打折扣。
5.4 中文编码问题
Caveman的默认配置下,模板和数据库交互偶尔会遇到中文乱码。原因是Common Lisp实现对外部编码的默认值可能不是UTF-8。解决办法是:在系统环境里设置语言环境,比如启动时加上--locale参数;或者显式指定外部格式。数据库连接部分,我通常在数据库连接参数里加上编码配置;MySQL连接串带上:encoding :utf8参数。
还有一个值得注意的点:如果你在Windows上开发,SBCL的默认编码和文件系统编码可能不同,模板文件建议一律存成UTF-8无BOM格式,避免各种诡异问题。
5.5 性能与并发注意事项
Caveman默认跑在Hunchentoot上,这是一个成熟的HTTP服务器,足以应付内部系统级别的并发。但如果你要做高并发的公开API,需要了解Lisp的线程模型。Common Lisp的锁机制粒度可以做到很细,但随意共享可变状态会带来麻烦。在多线程处理请求时,如果多个线程同时访问同一个全局变量,要加上适当的锁或原子操作。这不是Caveman的问题,是所有Lisp多线程开发的共性。
我做过的项目里,一个支撑几十人同时访问的内部系统在Caveman+Hunchentoot下运行得非常稳定,一年的宕机次数为零。如果真有更高的并发需求,可以通过Clack接入更底层的服务器方案,或者把Caveman应用当作内部服务,前面再加一层缓存。
5.6 问题排查速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 项目加载失败 | ASD文件不在搜索路径 | 链接到local-projects或用asdf:load-asd |
| 500端口被占用 | 开发环境其他服务占用该端口 | 更换clackup的:port参数 |
| 模板找不到 | 当前工作目录不在项目根目录 | 显式设置模板根目录或从REPL内启动 |
| 中文显示乱码 | 编码未指定为UTF-8 | 设置外部格式为utf-8,数据库连接加编码参数 |
| 修改路由无效果 | 未重新编译代码 | 在Emacs中编译当前缓冲区或使用asdf:load-system强制加载 |
6. 我用下来的整体感受
6.1 它让我重新理解了“项目结构”
这两年用Caveman做内部工具,我心里最强烈的感受是:原来项目骨架可以这么干净。它不像某些框架那样堆很多层抽象,反而是所有代码都坦坦荡荡写在几个文件里。一个几百行代码的两千行业务需求,文件组织依然清晰可读。
个人经验是,用小框架最怕写着写着变成一坨没有结构的函数堆。Caveman虽然自由,但它清晰的路由文件里按模块组织路由,用CLOS类组织数据模型,这种组合天然就是清晰结构的前提。
6.2 值得尝试的扩展方向
做Web开发,难免遇到前端交互需求。Caveman静态资源目录里可以挂任何前端框架,我用Parenthesis和自定义的JS实现过不少动态页面。此外,用cl-json输出JSON响应、用Bordeaux-Threads处理并发任务、用cl-cron做定时任务,这些组合起来就是一个功能完备的生产系统。
6.3 最后一个小提醒
如果你决定入坑Caveman,请一定先花半小时配置好Emacs+SLIME,或者用支持Sly的编辑环境。这个框架的全部魔力都藏在“运行中修改程序”里,如果把它当成普通Java框架去写、去编译、去重启,你只体验到了它一半的威力。配置好REPL工作流之后,你会在刷新浏览器的瞬间感受到那种很少在现代开发中出现的痛快感——程序在你编辑它的时候就“活”着,而你是那位在洞穴墙壁上随手作画的画家。