☰
Common Lisp Web开发实战:caveman框架从入门到部署
2026/10/29 13:03:04 网站建设 项目流程

caveman 是我在 Common Lisp 项目里用过最久的 web 框架。如果你在 Lisp 社区里问"现在用什么写 web 服务",大概率会听到两三个名字,caveman 是其中出现频率最高的那个。我最初看到这个名字觉得挺怪——穴居人?跟 web 开发有什么关系?后来用顺手了才发现,它确实像石器时代走出来的工具:简单、直接、不绕弯子,该干活的时候绝不跟你谈设计哲学。

这篇文章我不会从"什么是 web 框架"这种基础讲起,而是直接按我自己从选型、搭项目、写路由、接数据库、到最终部署上线的完整经历来展开。适合已经会一点 Common Lisp、想用它做实际 web 项目的朋友参考,也适合那些正在犹豫选哪个框架、被网上碎片化信息搞晕的人。

1. caveman 到底解决什么问题:先搞清楚它的定位

很多人刚接触 Lisp 生态时最大的困惑不是语法,而是"东西太多了,我不知道该用哪个"。caveman 属于 web 开发这一层,但要讲清楚它,得先把它周围那几层东西一起说透。

1.1 它不是一个从零造轮子的服务器

caveman 本身不处理 TCP 连接,也不解析 HTTP 报文。它做的事情是:帮你把 HTTP 请求路由到对应的 Lisp 函数上,然后拿到返回值再变成 HTTP 响应。底层真正监听端口、收发数据的是 Hunchentoot——这是 Common Lisp 圈子里最老牌、最常用的 web 服务器。caveman 相当于站在 Hunchentoot 上面,把那些繁琐的细节包了一层。

我第一次看到这种分层时有点不习惯。在别的语言里,框架和服务器往往是绑死的,比如你用 Flask 就是 Werkzeug,用 Express 就是 Node 自带的 http 模块。但 Common Lisp 的哲学更像 Unix 工具链:每个组件只干一件事,然后通过接口组合起来。caveman 负责"路由和控制器"这一层,Hunchentoot 负责"HTTP 协议"这一层,中间还有一个叫 Clack 的抽象层负责把两者黏起来。

1.2 caveman 和 Clack、ningle 的关系:别把三者搞混

这是新手最容易踩的第一个坑。你查资料时经常会看到三个词一起出现:caveman、Clack、ningle,看起来好像是一回事,其实分工完全不同。

  • Clack 是 Common Lisp 版的 WSGI/Rack——它定义了一套统一的接口,让你的 web 应用可以跑在 Hunchentoot 上,也可以跑在其它服务器上。它相当于插座标准。
  • ningle 是一个极简路由库,只负责把 URL 映射到函数,别的什么都不管。
  • caveman 是建立在 Clack 和 ningle 之上的完整框架,它不仅要路由,还帮你处理了模板加载、静态资源、配置读取、开发调试这一类常见需求。

打个比方:Clack 是插座标准,ningle 是通电之后把电流引到电灯那一小段线,caveman 则是整间屋子的电路设计——包括开关面板、灯座、预留的插座位置。你当然可以只买一根线自己接(直接用 ningle),但大部分时候直接用 caveman 更省事。

2. 五分钟跑起来:建立第一个 caveman 项目

环境配置这部分我默认你已经装好了 SBCL 和 Quicklisp。如果没有,先去把这两样装好,这属于 Common Lisp 开发的基础设施,任何项目都绕不开。我用的是 SBCL + Quicklisp 的组合,操作系统是 Linux,macOS 下步骤完全一致,Windows 下稍微注意一下路径分隔符就行。

2.1 项目骨架到底长什么样

先加载 caveman 并生成项目骨架。在 REPL 里执行:

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

这时当前目录下会多出一个 myapp 文件夹,里面的结构是这样的:

myapp/ ├── src/ │ ├── app.lisp │ ├── config.lisp │ ├── db.lisp │ ├── main.lisp │ └── view/ ├── static/ │ ├── css/ │ └── js/ ├── templates/ ├── tests/ ├── myapp.asd ├── README.md └── .qlot

说实话,我第一次看到generate-project生成这堆文件时是有点懵的。但用久了你就知道,这个骨架简直是一个标准 Lisp web 项目的"最佳实践模板":app.lisp是整个应用的主体,路由定义在这里;config.lisp管配置;db.lisp是留着让你接数据库的;view/目录下是控层器需要用到的辅助代码;static/和templates/一个管静态资源、一个管模板文件。

2.2 编译加载:比你想的更接近"直接跑"

Common Lisp 的开发模式和其它语言很不一样——你很少有"写完后编译整个项目"这种动作,而是把代码一块一块往 REPL 里加载。caveman 也不例外。进入项目目录后这样启动:

(ql:quickload :myapp) (myapp:start :port 5000)

浏览器打开http://localhost:5000,看到Hello, Caveman就说明项目活了。

有人会觉得"这不就跟别的框架一样吗"。其实差的远。在其它语言里,改完代码通常要重启整个应用进程。但在 Common Lisp 里,你改完一个函数的定义,只需要重新编译这一个函数,然后在同一个 REPL 会话里继续用,连 HTTP 连接都不会断。我平时调接口就是这样:开三个终端,一个跑 REPL,一个用 curl 发请求,一个写代码。循环下来基本是"改一行、编译一行、测一下",整个节奏比传统"改完重启"要快很多。这个体验在别的语言里很难找到替代品。

2.3 start 函数藏在哪

myapp:start这个函数不是凭空出现的,它定义在src/main.lisp里,由项目模板自动生成。它内部大概做了这样几件事:读配置、确定要绑定哪个端口、用 Clack 启动一个 server,然后把这个 server 的实例保存下来。如果你去看main.lisp的源码,会看到类似这样的代码:

(defun start (&key (port (config :port))) (setf *server* (clack:clackup #'app/app :port port)))

clackup是 Clack 的启动入口,参数可以传端口、IP 地址、服务器类型等。默认情况下会使用 Hunchentoot。这也再次印证上一节说的:caveman 负责组织代码,Clack 负责启动,Hunchentoot 负责跑协议。

3. 路由设计:从"字符串通配"到"列表"的思路转变

路由是每个 web 框架的核心部分。caveman 的路由风格可能是你最先需要适应的东西——它不是用正则表达式写的,而是用 Lisp 的列表来描述路径结构。

3.1 defroute 的基本形态

先看一个最简单的例子:

(defroute ("/hello/:name" :method :get) (params) (format nil "Hello, ~a" (getf params :name)))

请求GET /hello/caveman会返回Hello, caveman。这里的:name是一个占位符,请求路径中对应位置的字符串会被提取出来,放进一个 plist(属性列表)里,通过params传递给你的 handler 函数。

:method :get表示只接收 GET 请求。如果你希望同一个路由同时支持 GET 和 POST,可以这样写:

(defroute ("/submit" :method (:get :post)) (params) ...)

3.2 为什么路径参数用 plist 而不是哈希表

很多从其它语言过来的人看到(getf params :name)会觉得别扭,为什么不给个 dict 或者 map?我一开始也这么想,后来才意识到 plist 在 Lisp 里是一种极其自然的数据结构——它本质上就是一个偶数长度的列表,(:name "alice" :age "30")这样,key 和 value 交替出现。getf就是从这个列表里按 key 取值。没有任何哈希计算、不需要初始化容器,纯函数式的操作,开销极小。

这个设计还有一个实际好处:调试时直接把params打印出来就能看到全部参数,不需要遍历 key。我自己在排查问题时经常用(format t "~a~%" params),一眼就能看出哪些参数收到了、哪些没值。这在用传统字典结构的框架里反而不这么方便。

3.3 路由表太长了怎么办

一个真实项目不可能只有两三个路由。等路由文件写到几百行时,全堆在一个defroute列表里会很难维护。caveman 允许你按模块拆分:在 app.lisp 里 search 一个 macro 把多个路由集中起来,再按业务模块分成多个文件。比如:

(defroute ("/api/users" :method :get) (params) (list-users)) (defroute ("/api/users/:id" :method :get) (params) (get-user (getf params :id)))

我自己的习惯是:所有路由集中在app.lisp里,但每个路由体只做三件事——解析参数、调用业务函数、把返回值包成响应。业务逻辑放到 src 下的其它模块。这样一来路由文件看起来就是个纯"转发清单",结构非常清晰。

4. 请求处理、模板和 JSON:真正写业务时才遇到的那些事

路由只是把请求接到你的代码上。真正写业务时,你需要处理请求体、返回 HTML 页面、输出 JSON 数据。这几块 caveman 都有对应的实践方式,但里面有些细节不是文档里会主动告诉你的。

4.1 接收表单和 JSON 请求是如何处理的

POST 表单在 caveman 里处理起来非常直接:表单字段会直接合并进params这个 plist 里。比如前端提交username=alice&age=30,你在 handler 里就能用(getf params :username)取值。

JSON 请求就没这么顺了。caveman 默认不会帮你解析请求体里的 JSON 字符串——你得自己把 body 读出来再解析。我常用的做法是配合 JON 这个 JSON 库:

(defroute ("/api/users" :method :post) (params) (let* ((body (getf params :body)) (data (jon:parse body))) ...))

这里要注意一个细节:caveman 的:body在需要的时候才会从请求流里读取,读完之后这个流就消耗掉了。所以如果你想先自己解析、再回退到表单解析,顺序一定要想好——别重复读。

4.2 返回 JSON:一个写死了的小 helper

每个项目我几乎都会做同一件事:封装一个 JSON 响应函数,统一设置 Content-Type。caveman 不会帮你强制做这件事,但它给你留了口子。我通常在 src/view/helpers.lisp 里写这样一个函数:

(defun json-response (data &optional (status 200)) (setf (hunchentoot:content-type*) "application/json; charset=utf-8") (setf (hunchentoot:return-code*) status) (jon:encode data))

huntentoot:content-type*是动态变量,在 handler 执行期间设置它,Hunchentoot 就会在当前响应里使用这个 Content-Type。return-code*同理。这个 helper 一旦定义好,后面写 API 就是一行事:

(defroute ("/api/status" :method :get) (params) (json-response '(:status "ok" :time (get-universal-time))))

4.3 模板引擎选谁:Djula 还是自拼返回

HTML 页面在 caveman 生态里最常配合的模板引擎是 Djula——它是一个 Django 风格的模板库,有{% for %}、{{ variable }}这类语法。如果你以前用过 Python 的 Django 或者 Jinja2,上手 Djula 几乎没有成本。

(djula:add-template-directory #p"templates/") (defparameter *index-template* (djula:compile-template* "index.html"))

然后在路由里:

(defroute ("/" :method :get) (params) (djula:render-template* *index-template* nil :message "hello from caveman"))

compile-template*是 Djula 提供的一个函数,参数是模板文件名,返回值是一个编译好的模板对象。每次渲染时直接传这个对象进去就行,不用重复读取文件。

不过说实话,如果只是写一个小工具或者纯粹返回 JSON 的 API 服务,我个人不是很建议上模板引擎——直接format拼 HTML 有时候反而更清楚。caveman 的好处就在这里,它不像某些框架那样"绑定"某个模板引擎,你可以完全自由选择。我见过有项目用 Django 风格模板,也有见过直接用字符串拼接的,两种都跑得好好的。

5. 数据库接入与配置管理:从写死参数到多环境切换

写 web 应用几乎绕不开数据库。caveman 的骨架里专门留了db.lisp的位置,但具体用什么库、怎么接,完全是你的自由。这一节我说说我自己摸索出来的实践方式,以及配置管理上最重要的一个思路。

5.1 用 sqlite 起步的方案,以 Mito 为例

Common Lisp 的 ORM 生态不像别的语言那么丰富,社区里比较常看到的是 Mito。它是一个轻量级 ORM,支持 SQLite、MySQL、PostgreSQL 等后端。如果你是刚开始做项目,我建议一切从简:先上 SQLite,表结构也小,不需要专门的数据库服务。

引用方式是在 .asd 文件的:depends-on里加上:mito,然后代码里大概这样用:

(mito:connect-toplevel :sqlite3 :database-name #p"app.db") (defclass user () ((name :col-type (:varchar 64)) (email :col-type (:varchar 128))) (:metaclass mito:dao-table-mixin))

mito:connect-toplevel是 Mito 的设置数据库连接入口,设置一次,全局生效。上面这个defclass不只是定义一个 Lisp 类,Mito 会把它同步成数据库表——name字段对应 varchar(64),email对应 varchar(128)。这套机制用起来非常接近别的语言里的 ActiveRecord 风格。

每次操作一个对象,Mito 会自动帮你生成对应的 insert/update/select 语句。例如:

(let ((u (make-instance 'user :name "caveman" :email "x@example.com"))) (mito:insert-dao u))

5.2 config.lisp 的动态变量机制

caveman 项目里的配置管理思路是:用 Lisp 的全局动态变量存配置,启动时从文件读取。模板在src/config.lisp里给你放了一个config函数,实现方式有点巧妙——它返回一个 plist,代码里通过 key 圈定的方式取值:

(defun config (&key key) (getf *config* key))

默认配置是这样:

(defparameter *config* (list :port 5000 :database #P"app.db"))

运行时读端口就是(config :port),读数据库路径就是(config :database)。想改配置,直接 recompileconfig.lisp里的这个defparameter,再重新启动就行。

5.3 开发环境和生产环境怎么切换

我刚接触这个配置机制时犯过个错误:直接把生产环境的数据库地址写死在 defparameter 里,结果本地开发和线上共用一份配置,经常出现"本地连不上生产库"或者更糟的"生产用到了本地测试数据"。后来我的做法是:

在 config.lisp 里根据环境变量做分支:

(defparameter *config* (let ((env (uiop:getenv "APP_ENV"))) (cond ((equal env "production") (list :port 8080 :database #P"/var/lib/app/app.db")) (t (list :port 5000 :database #P"dev.db")))))

这个思路非常简单,但非常有效。本地没设APP_ENV,走默认分支;服务器上设成production,走生产配置。如果你需要更多环境(staging、test),在这个cond里加分支就行。不要为了所谓优雅设计搞太复杂的配置框架,真实项目中这套就够了。

6. 调试和错误处理:Lisp 式开发体验的真正价值

用 caveman 写代码,最大的快乐其实不在框架本身,而在 Common Lisp 的调试环境。但这些东西不是开箱即有的,需要你做一些配置。这一节分享我怎么把调试体验调到舒服的状态。

6.1 报错时的全局调试器有多好用

默认情况下,caveman 应用如果抛了异常,调试器会在 REPL 那一侧弹出来。你可以在 REPL 里看到完整的后栈、局部变量、以及出错时的传参。

有一次我们线上环境报了个错,我的做法是看 REPL 后栈,输入数值变量的值,发现是一个列表里有个 nil 被传进来导致函数崩了。整个排查过程没有加一行 print 日志,完全是直接看运行现场。这个体验在其它语言里几乎不可能——你顶多看到一个 log 文件,然后靠猜。

要让这个体验跑通,只需要在启动时给 Clack 开启 debug 模式,也就是把环境变量设成不吞异常。如果你用的是 caveman 直接启动,默认调试器就是开的。上线时再把异常处理切到正常模式,返回 500 页面给客户端。

6.2 中间件顺序对错误处理的影响:这是个隐秘的坑

Clack 支持中间件,caveman 也可以插。中间件的执行顺序是严格按包裹顺序来的——第一个包上去的中间件最外层,最后添加的在最内层。这跟洋葱模型一样,请求从外层往里走,响应从里层往外走。

如果你的错误处理中间件写在内层,它就只能捕获内层路由的异常,捕获不到外层中间件的异常。我踩过的坑就是这个:把一个访问日志中间件加在最外层,错误处理加在内层,结果中间件自己崩了之后,错误根本传不到错误处理那里。最后调换顺序才解决。

记住一句话:错误处理中间件尽量放在最外层,这样任何内层抛出的错误都能被它截住。

6.3 404 和 500 页面的自定义:这是容易被忽略的细节

当路由不存在时,caveman 默认返回一个简单的 404。生产环境如果让用户看到默认页面,既不专业也可能泄漏框架信息。自定义 404 的方式有两种:

第一种是 hook 到底层的 Hunchentoot 的错误处理器:

(setf (hunchentoot:catch-errors-p) t)

第二种更推荐:在路由表最后加一个 catch-all 路由,匹配所有未匹配的路径。

(defroute ("/*" :method (:get :post :put :delete)) (params) (json-response '(:error "not found") 404))

/*会匹配所有路径,当所有具体路由都没命中时,这个兜底路由就被调用。这样做的坏处是要注意它放的位置——必须确保其它路由定义在后面;好处是很直观,你完全控制返回什么。

7. 静态资源与前后端协作的模式选择

caveman 项目里有static/目录,服务器会自动把/static/...路径下的文件当静态资源返回。但实际项目中,我见过两种不同的协作模式,各有利弊。

7.1 同源模式:模板输出 HTML + 局部 AJAX

这种模式下,页面主要用 Djula 模板服务端渲染,一些局部交互用 AJAX 请求 JSON。caveman 的static/目录里放编译好的前端 JS 和 CSS,模板中引用/static/js/app.js这种路径。开发时比较简单,一个应用同时管页面和 API。资源更新后需要清浏览器缓存,否则容易看到旧资源。

7.2 前后端分离模式:caveman 只做 API 服务

另一种是彻底分离:前端用 Vue/React 构建成纯静态站,caveman 只提供 JSON API。这套模式下,你根本不需要模板引擎,只关心 API 路由和数据库。前端资源可以部署在 Nginx、CDN 或任何会服务静态文件的地方,前后端通过 CORS 或 Nginx 反代通信。

我个人的选择是:小项目、公司内部系统用服务端渲染的同源模式,省事;面向公众、交互复杂的产品选前后端分离,caveman 专注 API。这两种模式都是 caveman 能支持的——它不像 Rails 这类框架强绑定模板渲染,也不像 Express 那样纯 API,而是两边都能干。

7.3 CORS 配置,如果你前后端分属不同域名

既然是前后端分离,几乎必须处理 CORS。这里有个很多人会踩的坑:直接在 handler 里设置 CORS 头,但 preflight 的 OPTIONS 请求会被路由拦截掉。正确做法是加一个中间件,或者其他方式统一设置响应头。

我实际用下来最省心的还是写一个中间件:

(defclass cors-middleware (clack:middleware) () (:documentation "set cors headers for api responses")) (defmethod clack:call ((m cors-middleware) env) (let ((res (call-next-method))) (setf (getf (getf res :headers) "Access-Control-Allow-Origin") "*") (setf (getf (getf res :headers) "Access-Control-Allow-Methods") "GET, POST, PUT, DELETE, OPTIONS") (setf (getf (getf res :headers) "Access-Control-Allow-Headers") "Content-Type") res))

然后把中间件加进去。call-next-method会先执行下层逻辑,拿到res之后再统一加头。这样不管走哪个路由,响应里都会带上 CORS 头。OPTIONS 请求如果你不想走到具体 handler,可以在中间件里直接短路返回。

8. 测试、部署与上线维护:把应用真正丢到生产环境

开发完只是第一步,上线才是考验。这一节结合我部署两个实际项目的经历,说几个 caveman 部署相关的经验和坑。

8.1 测试怎么写:重点是验证路由而非启动进程

首先明确一个观念:测试时不要启动 HTTP 服务去发网络请求,慢而且依赖端口。正确方式是直接调用 handler 函数——因为它们就是普通 Lisp 函数,传一个 plist 进去就能得到返回值。

(deftest test-hello-route () (let ((result (myapp::hello-handler (list :name "caveman")))) (is (equal "Hello, caveman" result))))

为了让测试可写,在定义路由时,我会尽量把 handler 逻辑做成纯粹的函数:输入是参数 plist,输出是响应数据或字符串。这样测试逻辑就独立于 web 层。如果测试需要真跑一套完整 HTTP 流程,可以用 Clack 的测试辅助库,但绝大多数场景下直接函数测就够了。

8.2 部署方式一:直接用 SBCL 作为守护进程

最简单的部署方式:在服务器上直接跑 sbcl,加载应用,然后让进程常驻。为了保证进程意外退出能自动拉起,用一个守护进程工具包一层:

while true; do sbcl --load start.lisp; sleep 1; done

这是一个粗糙但非常实用的脚本。进程崩了会自动重启。想优雅停机,可以把 start.lisp 里明确绑定端口,然后靠外部脚本发信号。

这种方式的优点是部署简单,缺点是没有独立进程管理(回收不了某些资源、没有优雅的滚动更新)。适合小流量工具型应用。

8.3 部署方式二:用 Docker 打包一个 Lisp 环境,再配合 Nginx 转发

如果你的应用除了 HTTP 还要处理并发、甚至以后想横向扩节点,更规范的方式是 Docker。写一个 Dockerfile:

FROM daewok/sbcl:latest RUN apt-get update && apt-get install -y build-essential COPY . /app WORKDIR /app CMD ["sbcl", "--load", "start.lisp"]

然后 Nginx 配置把/反代到容器的 5000 端口就好了。这种方式的优点是可复现,部署流程和环境依赖完全锁定。缺点是镜像比较大——sbcl 加 quicklisp 依赖集一般在几百 MB 到 1G 左右,但换取的是环境一致性,可以接受。

8.4 上线后必须做的几件小事

  • 打开错误处理:生产环境一定要把异常吞掉,返回通用 500 页面,不能把堆栈抛给用户。
  • 启动参数注意端口绑定:务必绑定 0.0.0.0,否则外部访问不到。
  • 数据库迁移:Mito 的表结构变更用 mito:migrate 处理,上线前跑一遍。

这些都不是什么高深技术,但每一条我都经历过真实的血泪教训,尤其是端口绑定这个——我第一次部署时一直监听 default 的 localhost 不说,前端页面死活连不上 API,排查了半天才发现是监听地址不对。

9. 最后:什么样的人、什么样的项目适合用 caveman

写了这么多,是时候给一个诚实的总结。caveman 不是万能的,它有自己的适用场景和不及之处。判断要不要用,可以先问自己几个问题。

如果你是一个 Common Lisp 学习者,想拿 Lisp 写点实际能跑的东西,caveman 是一个非常合适的学习载体——它让你把关注点放在 web 逻辑本身,又保住了 Lisp 的动态调试优势。模板、配置、数据库集成这些部分,能帮你把 Lisp 的生态串起来,学习路径也相对平滑。

如果你要做的是内部系统、管理后台、API 服务、中小流量的 web 应用,caveman 完全能够胜任。它不像那些大而全的框架那样自带全套规范化约束,但这恰恰是灵活性所在——你可以用它快速做出一个够用的服务,而不需要为用不上的功能付时间成本。

反过来,如果你的项目需要的是一个「极稳定、超大并发、团队里其它人不愿意写 Lisp」的长期产品,caveman 可能并不是适合的选项。它更适合高度个人化或小团队的场景——在 Lisp 生态里,这其实不是缺点,反而是一种选择上的自由。

以我自己的经验来看,caveman 最大的价值在于它把一个强大的语言和一个务实的 web 需求很好地结合起来——只要你能接受 Lisp 的思维方式,你就会发现它写 web 服务时的效率,确实不比那些主流框架差,而在开发体验上,又有很多不可替代的优势。如果你正在 Lisp 和另一个语言之间犹豫,不妨先拿 caveman 搭一个小项目试试,答案会很快浮现出来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询