☰
Caveman实战:Common Lisp轻量级Web框架开发指南
2026/10/28 14:31:28 网站建设 项目流程

如果你在网上只搜到一个孤零零的 “caveman”,大概率会摸不着头脑:这到底是个工具、游戏、还是网络梗?我先给你一个确定答案:在 Common Lisp 社区里,Caveman 是一个轻量级 Web 框架的名字,地位有点像 Python 世界的 Flask、Ruby 世界的 Sinatra。我最早是被“两行代码起一个服务”这种说法吸引过去的,后来用它在公司内部做过数据查询面板、接口转发层,也给朋友的创业原型搭过后台。这篇文章不是官方文档翻译,而是我断断续续用了两年多的实战记录,从设计思路、环境搭建、路由模板中间件,到数据库、部署和一堆版本坑,一次讲清楚。

看这篇文章之前,你只需要知道 Common Lisp 是一门非常老但依然活着的语言,而 Quicklisp 是它最常用的包管理器,SBCL 是它最流行的实现。你不需要先成为一个 Lisp 高手,只要能在 REPL 里敲几行代码,就能跟着做下来。Caveman 适合这几类人:想在 Common Lisp 里快速写 Web 服务的、被其他语言框架体积吓到想换个极简方案的、以及想看看“用 Lisp 做业务开发”到底靠不靠谱的。

1. Caveman 到底是个什么定位:Common Lisp 里的“极简派”

1.1 一句话说清 Caveman 的定位

Caveman 不是一个从零实现 HTTP 协议的庞然大物,它更像一个“组织代码”的框架。它帮你把 URL 路径映射到 Lisp 函数,把请求参数变成 Lisp 变量,把响应内容用模板或 JSON 返回出去。你真正写的业务逻辑,就是一个个普通的 Lisp 函数。

这也是它最讨喜的地方:没有依赖注入容器,没有复杂的注解体系,没有必须继承的基类。一个defroute宏加上一个函数体,就是一个完整的接口。对比一下其他语言的同类框架,你会发现 Caveman 几乎把能省的都省了,但这种“省”不是功能缺失,而是把复杂度控制在了一个人脑能装得下的范围。

Caveman 还有一个“哥哥”叫 Caveman2,目前你新起的项目默认都是 Caveman2。它俩的关系有点像 Flask 1.x 和 2.x:核心思路一致,但 Caveman2 在路由、模板集成和项目生成器上更成熟。我下面说的所有内容,默认都是 Caveman2。

1.2 Common Lisp Web 生态图谱:Caveman 站在哪一层

很多人对 Common Lisp 写 Web 的误解是“什么都没有”,实际上这个生态非常完整,只是每一层的分工特别清楚,而且每个名字都起得很抽象。为了让你不迷路,我画个文字版分层图:

最底层是 HTTP 服务器,主流是 Hunchentoot,一个用 Lisp 写的 HTTP/1.1 服务器,支持静态文件、会话、SSL。再往上一层是 Clack,你可以把它理解成 Python 世界里的 WSGI 或 Node 世界的中间件规范。Clack 本身不处理业务,它只负责把 Web 服务器和你的 Lisp 应用连接起来,同时提供中间件机制。Caveman 就构建在 Clack 之上,负责路由分发、请求解析、参数绑定这些“控制器”层的事。模板引擎的选择很多,Caveman 默认集成 Djula,一个带 Django/Jinja2 风格的模板库。

层与层之间用标准接口对接,这意味着你可以换掉任何一层而不用改业务代码。比如不想用 Hunchentoot,可以在 Clack 层切换到别的后端;不想用 Djula,也可以在渲染层换模板引擎。这种解耦设计在 Common Lisp 里非常成熟,Caveman 只是把经常一起用的几样东西打包得更好用。

为了直观对比,我用表格整理一下它们的分工:

层级代表项目作用对应其他语言的概念
HTTP ServerHunchentoot处理 TCP/HTTP 请求Tomcat / Node http
中间件层Clack统一接口、静态文件、SessionWSGI / Rack / Express middleware
Web 框架Caveman路由、参数解析、控制器组织Flask / Sinatra
模板引擎Djula页面渲染、模板继承Jinja2 / ERB

这套分层的好处是什么?我在实践中最大的感受是:排查问题变得非常精准。当浏览器报 404,我知道问题大概率在 Caveman 的路由层;当样式表加载不出来,我直接去查 Clack 的静态文件中间件;当 POST 请求拿不到参数,我再检查 Caveman 的请求参数绑定。有了这个地图,你不会像个无头苍蝇一样到处乱试。

1.3 “caveman”这个名字和它的设计哲学

第一次看到这个名字的时候我笑了,一个 Web 框架叫“穴居人”,是想表达自己很原始吗?后来看作者 Eitaro Fukamachi 的分享,我明白了:它的哲学就是“用最原始、最直觉的方式写 Web 应用”。不要生成器帮你生成一堆看不懂的代码,不要框架替你决定项目结构,你想要一个服务,就写一个函数,把路径和函数绑在一起,完事。

这种哲学在 Lisp 社区其实很有渊源。Lisp 程序员普遍喜欢“小工具组合成大系统”,而不是“大框架约束小逻辑”。Caveman 继承了这种品味:它的核心路由代码很精简,你甚至可以直接读框架源码来理解它的工作方式。对想深入学习的人来说,这比读那些动辄几万行的框架源码友好太多了。

2. 动手搭一个 Caveman 项目:从安装到跑起来

2.1 环境准备:SBCL 和 Quicklisp

开始之前,先把基础环境跑通。我用的实现是 SBCL,当前主流 Linux、macOS 和 Windows 都有预编译包,直接去官网下载安装就行。装完 SBCL 后有命令行工具sbcl,在终端敲进去就进入 REPL。然后安装 Quicklisp,这是 Common Lisp 的包管理器,相当于 Python 的 pip 加仓库索引。按照 Quicklisp 官网的说明下载quicklisp.lisp,然后在 REPL 里执行:

(load "quicklisp.lisp") (quicklisp-quickstart:install) (ql:add-to-init-file)

这三步做完,以后打开 SBCL 就能直接用ql:quickload加载任何主流库。这里有个小提醒:如果你在国内网络环境,Quicklisp 下载可能比较慢,但一般多试几次或配置镜像源就能解决。装好之后我再安装两个库:

(ql:quickload :caveman2) (ql:quickload :caveman2-project)

第一个是框架本体,第二个是项目生成器。caveman2-project是独立于框架的一个小工具,它负责按照 Caveman 推荐的结构生成一套可以直接跑的代码骨架。

2.2 使用项目生成器创建骨架

在 REPL 里执行:

(caveman2-project:generate "myapp")

执行完会在当前工作目录生成一个myapp/文件夹。如果你是用sbcl命令启动的,当前目录通常是打开终端时所在的目录;如果你用的是 SLIME/Sly 这类编辑器环境,当前目录跟着 Emacs 的工作目录走。生成的目录结构经过了 Caveman 团队多年迭代,对一段小项目来说非常合理:

myapp/ ├── myapp.asd ; ASDF 系统定义,声明依赖和组件 ├── src/ │ ├── main.lisp ; 启动和停止的入口 │ ├── config.lisp ; 配置文件读取 │ ├── web.lisp ; 核心路由和应用类 │ └── view.lisp ; 模板渲染封装 ├── templates/ ; Djula 模板目录 ├── static/ ; 静态资源目录 ├── db/ ; 数据库目录 └── tests/ ; 测试目录

这个结构的好处是“约定的约定”:模板放哪、静态文件放哪、路由写在哪,一看就知道。尤其是从小项目慢慢长大的过程,这种统一结构能省掉很多“这个文件放哪”的纠结。

2.3 第一次启动:把服务跑起来

生成项目后,进入myapp/目录,在 SBCL 里加载系统:

(ql:quickload :myapp) (in-package :myapp) (start :port 5000)

然后浏览器访问http://127.0.0.1:5000/,你会看到 Caveman 默认的欢迎页面。从项目生成到看到页面,熟练之后五分钟以内就能搞定,这就是我前面说“两行代码一个服务”的实际含义。

这里有个容易卡住的地方:如果你之前没把myapp/的上级目录加到 Quicklisp 的本地项目搜索路径里,ql:quickload :myapp会提示找不到系统。最简单的方式是在生成项目后,把~/quicklisp/local-projects/作为工作目录,或者在 REPL 里执行:

(push #p"/path/to/your/projects/" ql:*local-project-directories*)

把项目目录加进去,然后再ql:quickload。

3. 路由、模板、中间件:Caveman 的核心三件套

3.1 路由的真实面目:一个宏搞定一切

Caveman 的路由用defroute宏定义,基本形式是:

(defroute "/" () (format nil "Hello, World!"))

这表示当浏览器访问根路径时,执行函数体,返回一个字符串作为 HTTP 响应。如果你不需要模板、不需要 JSON,最原始的字符串返回就够了。函数体里的()是参数列表,专门用来接收 URL 里的动态参数。

带参数的路径写法是:

(defroute "/user/:id" (&key id) (format nil "User ID: ~A" id))

访问/user/42时,id就被绑定为"42",注意它是字符串类型,需要自己做类型转换。Caveman 这种参数传递方式非常直接:你不需要写额外的类型声明或正则表达式,冒号开头的路径片段就自动变成关键字参数。这个设计看起来很原始,但实际用起来非常顺手,尤其是快速写接口时,少了一层中间抽象,反而更好调试。

还可以指定 HTTP 方法:

(defroute ("/article" :method :POST) (&key title content) ;; 处理表单提交,title 和 content 来自 POST body (format nil "Saved: ~A" title))

这种写法可以让你同一个路径同时服务 GET 和 POST,用方法区分动作。我在实际项目里习惯把所有接口都显式写明 method,第一是防止意外 GET 访问触发写操作,第二是便于后来的人理解这个接口的语义。

3.2 模板与页面渲染:Djula 的集成

只在 REPL 里返回字符串显然不够做真正的页面。Caveman 项目默认集成了 Djula 模板引擎,入口是src/view.lisp里的render函数。基本用法:

(defroute "/hello/:name" (&key name) (render "hello.html" (list (cons :name name))))

在templates/hello.html里:

<!DOCTYPE html> <html> <head><title>Hello</title></head> <body> <h1>Hello, {{ name }}!</h1> </body> </html>

render的第一个参数是模板文件名,第二个参数是变量列表。Caveman 会自动在templates/目录里找模板文件,所以你不必每次写完整路径。Djula 的变量语法是{{ name }},控制结构是{% if %}、{% for %},有过任何模板引擎使用经验的人都能零成本上手。

我在实际使用中建议一件事:模板里尽量别写复杂逻辑,所有数据处理放在 Lisp 函数里,做完再传给模板。这样模板只是单纯展示,出问题时一眼就能定位是数据处理的问题还是页面显示的问题。

3.3 中间件机制:静态文件、Session 和顺序

Caveman 本身不处理静态文件和 Session,这些功能靠 Clack 中间件完成。项目生成器默认就在web.lisp里配置好了静态文件中间件,所以static/目录下的 CSS、JS、图片能直接通过/static/xxx访问。

插一句我对中间件使用的感受:很多人一上来就把中间件堆成一长串,其实根本没必要。Caveman 项目我一般只加三个:静态文件、Session(如果需要登录态)、日志。中间件在应用启动时按顺序执行,请求会依次流经每个中间件。一个常见的坑是:静态文件中间件如果放在 Session 中间件之前,那么即使访问静态资源也会走一遍 Session 处理,理论上多了一点开销。反过来,Session 中间件在静态文件前面,静态资源就能少吃一次 Session 的序列化和反序列化。开发环境里感觉不出来,生产环境下确实有一点点性能差异,建议把静态文件放最后。

3.4 返回 JSON:把 Caveman 当接口网关用

现在前后端分离很流行,很多服务并不需要模板,只需要 JSON。Caveman 返回 JSON 也很直接,用cl-json库:

(ql:quickload :cl-json) (defroute "/api/user/:id" (&key id) (setf (getf (hunchentoot:reply-headers-out) :content-type) "application/json") (json:encode-json-to-string (list :id id :name "Alice")))

实际上 Caveman 内部有更优雅的返回方式,但核心思路都一样:往响应头里加上 content-type,再返回序列化后的 JSON 字符串。我在团队里做过一个统一数据网关,一个 Caveman 应用对应后面十几个异构数据源,每个数据源就是一个defroute,互相之间毫不起冲突,也不用引入额外框架。

4. 把数据库接进 Caveman 应用:业务逻辑落地

4.1 数据库连接层:CL-DBI

Web 应用只要涉及持久化,就得接数据库。Common Lisp 一个普及度很高的数据库接口库是 CL-DBI,它提供了一套类似 JDBC/DB-API 的统一接口,底层支持 SQLite、PostgreSQL、MySQL 等。

先加载库并连接 SQLite:

(ql:quickload :cl-dbi) (defvar *db* (dbi:connect :sqlite3 :database-name "myapp.db"))

执行查询:

(dbi:execute (dbi:prepare *db* "SELECT * FROM users WHERE id = ?") 1) (dbi:fetch-all (dbi:execute ...))

CL-DBI 的?占位符能有效避免 SQL 注入,这个比用format拼接 SQL 安全得多。我见过不少人图省事直接用字符串拼接 SQL,关键是如果参数里有单引号,轻则报错,重则被注入,新手千万不要这么干。

4.2 用一个简单的 DAO 封装数据访问

CL-DBI 偏底层,每个查询都要手写 SQL 和处理结果列表,稍微有点繁琐。为了不让业务代码里到处是 SQL,我习惯在每个模型上做一层薄薄的封装。以一个用户表为例:

(defun find-user-by-id (id) (let ((query (dbi:prepare *db* "SELECT * FROM users WHERE id = ?"))) (dbi:fetch-one (dbi:execute query id)))) (defun create-user (name email) (let ((query (dbi:prepare *db* "INSERT INTO users (name, email) VALUES (?, ?)"))) (dbi:execute-command query name email)))

然后路由里调用这些函数即可。这种手工封装的好处是:SQL 的意图清晰可见,调试的时候你能直接拿这条 SQL 去数据库客户端里跑,排查效率非常高。等瓶颈真出现了再上更复杂的 ORM,不要一上来就为了“省事”砸一堆抽象进去。

4.3 写一个完整的 REST 接口示例

现在把路由、数据库、JSON 串起来,做一个真正的用户注册接口:

(defroute ("/api/users" :method :POST) (&key name email) (create-user name email) (setf (getf (hunchentoot:reply-headers-out) :content-type) "application/json") (json:encode-json-to-string '(:code 0 :message "ok")))

再来一个查询接口:

(defroute "/api/users/:id" (&key id) (let ((user (find-user-by-id (parse-integer id :junk-allowed t)))) (setf (getf (hunchentoot:reply-headers-out) :content-type) "application/json") (if user (json:encode-json-to-string (list :id (getf user :id) :name (getf user :name) :email (getf user :email))) (json:encode-json-to-string (list :code 404 :message "not found")))))

这里特别注意一点:URL 参数id是字符串,用parse-integer转成数字后再传给 SQL,并且加:junk-allowed t防止非数字输入直接抛异常。这种小细节在实际项目中每天都会遇到,处理不好就是 500 错误。

4.4 配置文件与环境切换

Caveman 项目的src/config.lisp负责读取配置,默认会加载config.lisp或者环境变量。我常用的做法是用环境变量区分开发和生产,比如:

(defparameter *db-name* (or (uiop:getenv "MYAPP_DB_NAME") "myapp.db")) (defun config () (list :database *db-name* :host (or (uiop:getenv "MYAPP_HOST") "127.0.0.1")))

这样在本地开发时不设任何变量就是 SQLite,在服务器上设置MYAPP_DB_NAME就能切到 PostgreSQL,代码完全不用改。

5. 部署与调试:从 REPL 跑到生产环境

5.1 用 Clack 启动真实服务

前面我们(start :port 5000)启动的开发模式默认debug t,这意味着任何异常都会返回详细的堆栈页面,这个在开发时很爽,但生产环境绝对不能开。正确的启动方式:

(start :port 8080 :debug nil)

debug nil之后,框架会返回一个更友好的错误页,日志也能正常记录。我见过无数新手把 debug 模式直接带上生产环境,结果用户能看到堆栈里的服务器绝对路径和变量名,这就是给攻击者送地图。

生产环境建议不要裸跑 Hunchentoot,而是前面挂一个 Nginx 做反向代理和 TLS 终结。Caveman 应用只需要监听本地的某个端口,Nginx 把 80/443 的流量转发过来。这样静态文件直接让 Nginx 处理,应用只负责动态逻辑,性能能上一个台阶,而且 Nginx 重启不会影响到 Lisp 进程。

5.2 REPL 里的热更新:Lisp 开发的最大红利

Caveman 和 Sly/SLIME 配合时,有一个其他语言很难媲美的体验:改代码几乎不需要重启服务。在编辑器里修改某个defroute,然后C-c C-c重新编译当前函数,浏览器再刷新就已经是新逻辑了。运行中的请求队列不会被断开,这个特性在做线上问题修复时非常香。

我的工作习惯是:应用跑在一个长期存活的 SBCL 进程里,界面用 Sly 连接。需要改逻辑时,直接编辑、编译、生效,完全不用 systemd 重启服务。对于个人项目和内部工具来说,这种“零停机热更新”已经够用了;如果还想更稳妥,可以配上 Roswell 脚本做进程管理。

5.3 进程守护:让服务跑得像个服务

普通 Web 进程不能前台跑,生产环境一般用 systemd 或 supervisor 守护。我用一个最简单的 systemd 单元,核心就三步:ExecStart 指向启动脚本、Restart 设为 always、StandardOutput 重定向到日志文件。

启动脚本本质就是一段 SBCL 批处理:

#!/bin/bash exec sbcl --load /path/to/myapp/start.lisp

其中start.lisp内容是:

(ql:quickload :myapp) (in-package :myapp) (start :port 8080 :debug nil)

如果你用 Docker,那就更简单了:镜像里装好 SBCL + Quicklisp,启动命令直接指向这个脚本。Caveman 应用整个镜像可以做到非常小,因为没有完整 JVM、没有 Node 依赖,就一个二进制加源码。

6. 我在 Caveman 上踩过的坑:常见问题实录

6.1 中文编码乱码问题

这是 Comes with the territory 的头号麻烦。Common Lisp 默认文件编码在不同平台不一致,老版本 SBCL 甚至可能按 Latin-1 读取源码文件。如果你在源码文件里写中文注释或者中文字符串,编译时会报错或者显示乱码。

解决办法是在源文件开头加上编码声明:

;; -*- coding: utf-8 -*-

同时在启动 SBCL 时,最好让外部编码也统一:

sbcl --encoding utf-8

这个问题不在于 Caveman,而在于 Lisp 实现的默认编码行为,但因为它会直接破坏模板输出和 JSON 返回,我放到“Caveman 的坑”里,因为它们出现的场景往往就是写页面和接口的时候。

6.2 模板文件找不到或路径不对

开发环境里一切正常,一旦打包部署或换电脑,偶尔会出现“template not found”的错误。原因基本是*template-directory*用了相对路径,而启动时的当前目录不同。项目生成器默认用asdf:system-source-directory来定位模板目录,理论上很稳,但如果你手动改过.asd文件或者重新组织了目录,就可能出问题。

我的建议是:不要在运行期依赖当前目录,所有路径初始化都基于asdf:system-source-directory来拼。这样无论从哪里启动,都能正确定位资源。

6.3 ASDF 系统依赖顺序和快速重启

Quicklisp 安装包不是每次都完全一致,有时ql:quickload会加载到一个过旧的缓存版本。如果你改完依赖但没生效,试试:

(ql:update-dist :quicklisp)

或者干脆重启 SBCL。另外,当我修改了.asd里的:depends-on后,一定要重新编译整个系统:

(asdf:compile-system :myapp :force t)

否则新加的依赖可能不会被打包进镜像,部署到别的机器才会暴露。

6.4 常见问题速查表

问题可能原因解决方法
ql:quickload找不到系统项目目录不在 local-projects 路径里将目录加入ql:*local-project-directories*
启动端口被占用Hunchentoot 端口冲突换端口或netstat查看占用进程
路由返回 404路径与defroute不匹配检查路径末尾斜杠,Caveman 路径匹配是严格的
动态参数全是nil路由里的关键字参数名与路径冒号名不一致确保:id要由&key id接收
中文字符串乱码文件编码或控制台编码不一致加coding: utf-8声明,并设置外部编码
模板报错但不显示行号Djula 模板编译错误提示不够友好先在 REPL 手动调用render函数,看完整堆栈
修改代码后不生效没有重新编译文件在编辑器里对修改函数执行C-c C-c,或asdf:compile-system :force t

6.5 最后一个建议:从小工具开始,别一上来就重写业务

如果这是你第一个 Common Lisp Web 项目,不要试图用 Caveman 做一个电商平台。先拿它做一个内部小工具:一个 URL 短链服务、一个日志查询面板、或者一个给团队用的待办清单。因为 Caveman 的极简风格决定了它的主战场是接口层、中间层和服务层,你想用它硬撑一个十几张表的单体应用也不是不行,但那样会错过它真正的优势。

我个人现在的体会是:Caveman 的价值在于“让 Lisp 代码跑在浏览器能访问的地方”。当你习惯了在 REPL 里直接调试 Web 接口,再看其他语言里的重新编译、重启、等待部署,你会觉得那都是一种折磨。Caveman 不是最时髦的框架,它甚至带点古早味,但这恰好符合我对“工具就该直接解决眼前问题”的理解。先跑通一个最简单的页面,再加路由,再碰数据库,后面的路你会越走越顺。

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

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

立即咨询