☰
Caveman:Common Lisp 极简 Web 框架的上手与避坑指南
2026/10/28 20:33:42 网站建设 项目流程

“caveman”这个标题乍一看像个玩笑,往深了想却特别贴合一批人的做事方式:不要花架子,不要过度设计,用最简单粗暴的手段把东西跑起来,先把骨头搭好,再谈别的。我在 Common Lisp 社区折腾 Web 开发时,遇到一个真叫 Caveman 的极简 Web 框架,它把“原始人”这三个字贯彻到了代码里。这篇文章就围绕这个标题展开,讲 Caveman 框架是什么、怎么上手、请求怎么流转、有哪些坑,以及什么人适合拿它做正事。不管你是听过 Common Lisp 但没试过 Web 方向,还是已经在用其他后端框架想找点新思路,这篇都值得花十分钟扫一遍。

1. caveman 是什么:一句话解释,以及为什么“原始人”很有道理

1.1 先给结论:caveman 是 Common Lisp 世界里的极简 Web 框架

Caveman(也叫 Caveman 2)是 Common Lisp 生态里一个老牌微 Web 框架,作者是深町英太郎(Eitaro Fukamachi)。它的定位非常清楚:让你用 Common Lisp 写 Web 后端,但不强迫你接受一大堆抽象和“魔法”。框架本身只做几件事——路由分发、请求参数解析、模板渲染,再加一个简单的中间件机制。其余的东西,比如数据库访问、身份认证、ORM、队列、缓存,全部由你自己组合外部库。

这和 Python 里的 Flask、Ruby 里的 Sinatra 是同一类东西,只不过它运行在 SBCL 这种带 REPL 的 Lisp 环境里。换句话说,你用 Caveman 写应用时,不是在“写完代码然后启动”,而更像是“在 REPL 里把应用一点点搭出来”。框架会给你一个项目骨架,但这个骨架很克制,不会像某些重量级框架那样生成几十个陌生目录让你不知道哪个该删。

我在实际使用中最直接的感觉是:Caveman 没有试图把所有最佳实践都替你决定。它给你一套简洁的路由语法、一个 Clack 兼容的中间件链、一个默认的模板引擎入口,然后就退到一边去了。这种“框架退后”的风格,恰好是“caveman”这个标题的精髓——不搞花活,先能用再优化。

1.2 为什么叫“caveman”:极简、直接、不怎么打扮

Common Lisp 本身就是一个说话很直白的语言——函数调用是括号包起来的表,没有到处隐藏的隐式转换,错误信息一大串但往往第一行就点中要害。Caveman 延续了这种气质。它不会为你自动生成 RESTful 资源、不会偷偷替你创建数据库表,连默认模板引擎 Djula 的使用方式都像 HTML 里插标签一样直白,没有任何 DSL 黑魔法。

我用“原始人”来类比还有一个原因:这套框架的调试流程非常原始手段但有效。改完代码后在 REPL 里重新加载包、刷新页面、看堆栈,循环往复。它没有庞大的构建管线,没有热更新守护进程,也不需要“重启服务”这一说——你在 REPL 里直接改、直接跑,效果立刻出现。这种“简陋”反而让心智负担变小,你只需要关心 Lisp 本身和 HTTP 的基本逻辑。

当然,极简不等于没有设计。Caveman 的路由是基于 Lack/Clack 规范的,而 Clack 是整个 Common Lisp Web 生态的公共接口层,类似 Rack 之于 Ruby。这意味着你在 Caveman 里写的中间件,理论上可以迁移到其他 Clack 兼容框架上。所以,它的“原始”是一种有意识地建立在公共标准之上的原始,而不是闭门造车。

1.3 谁适合用 caveman,谁建议绕道

结合我自己的经验,我会把适合人群分成三类。第一类是已经在用 Common Lisp 写业务逻辑、想把 Web 壳子接到 REPL 工作流里的开发者;第二类是受够了框架魔法的人——你希望代码路径清晰可见,所有请求处理逻辑一眼就能扫完;第三类是极简主义者,项目只有几个页面和接口,不想为了一个表单引入 Spring Boot 那种庞然大物。

建议绕道的人也很明确:如果你需要一个活跃的社区、大量现成插件、中文文档,那 Caveman 会让人觉得冷清。它没有 Laravel 那种百科全书式文档,也没有 Go 语言那种开箱即用的中午餐体验。好多东西需要你读源码、看英文 README、自己从 Quicklisp 里翻库。另外,如果是纯前后端分离的大型项目,我也不会首选它——Caveman 的优势在中小型服务、内部工具和需要高控制力的场景,真要扛千万级 QPS,你自己得把上层基础设施搭得非常扎实,这套框架不会帮你解决分布式问题。

2. 上手 caveman 前必须搞懂的三块地基

2.1 从 Clack 到 Lack:一次说清请求是怎么进来的

你第一次接触 Caveman 时,大概率会看到三个词:Clack、Lack、Caveman。我的建议是别跳过它们,否则一旦出问题,你连报错里的模块名都看不懂。

Clack 是一个抽象的 Web 服务器接口层,类似于 Python 的 WSGI 或 Ruby 的 Rack。Common Lisp 下有很多 HTTP 服务器实现,比如 Hunchentoot、Woo、Clack 自己打包的 HttpOnly 等。Clack 的作用就是定义一套统一协议:你的应用是一个接收环境变量(environment)的函数,请求进来之后,服务器补全环境信息,Clack 中间件按顺序处理,最后返回一个三要素列表——状态码、响应头、响应体。

Lack 则是 Clack 生态里一个底层工具库,Caveman 就是建立在 Lack 之上的。Lack 提供了请求对象、响应输出、中间件 builder 等功能,你写 Caveman 时天天接触到的(getf (request-uri req) :path)这类操作,底层就是 Lack 的对象模型。

把这个关系用一个生活类比说清楚:Clack 是水管接口标准,Lack 是标准接头和阀门说明书,Caveman 是装修队。你只管跟装修队说“这里要一个洗手台”,而水管怎么接到市政管网,Clack 和 Lack 替你跟底层服务器打交道。所以你在 Caveman 里写的路由处理函数,本质上就是一个收到环境参数、返回响应列表的普通 Lisp 函数,没有特殊的“控制器基类”,也没有继承关系。理解了这一点,你就能理解 Caveman 为什么调试起来那么轻——你可以直接在 REPL 里手动构造一个请求环境,喂给路由函数看结果,根本不用起服务器。

2.2 defroute 路由背后的约定

Caveman 路由的核心是一个叫defroute的宏,它把 URL 模式、HTTP 方法、处理函数三者绑定在一起。用法非常简单,比如:

(defroute "/" (req) (format nil "Hello from Caveman"))

如果你想匹配带参数的路径,写法是把参数用冒号标在 URL 里:

(defroute "/user/:id" (req) (format nil "User ID: ~A" (getf (request-route-params req) :id)))

看到这里你可能觉得跟 Sinatra 很像,没错,Caveman 明确参考过 Sinatra 的设计思路。但有几个约定需要注意。

第一,defroute默认不区分 HTTP 方法,同一个/user/:id不管 POST 还是 GET 都会命中。如果你想区分,需要在路由里显式声明,比如(:POST "/user/:id")。我看到不少新手在defroute里漏写方法声明,导致 REST 接口的 POST 和 GET 行为串在一起,排查半天才发现。

第二,defroute可以定义在文件的任何位置,没有“先定义后使用”的严格顺序问题。但这不代表你可以随意摆放,因为宏在编译期就要知道路由表,跨文件的路由需要你保证 load 顺序正确。

第三,返回值的处理有一套固定规则。如果你返回字符串,Caveman 会把它当作响应体,并自动补上text/html的内容类型;如果你返回一个列表,格式必须是(status headers body)这样的完整响应;你也可以返回(format nil "Not Found")配合(setf (response-status ...) 404)。

真实项目中,我更推荐统一返回列表结构或使用模板渲染,避免依赖框架的自动补全,因为自动补全的内容类型不一定是你想要的。比如你提供一个 JSON 接口,如果直接返回字符串,前端拿到的是text/html,解析会莫名失败。正确做法是显式设置响应头:

(handler-case (progn (setf (getf (response-headers *response*) :content-type) "application/json") (json:encode-json-alist (list (cons "ok" t)))) ...)

Caveman 的*response*对象在整个请求内有效,你可以随时改它的状态码和响应头,然后返回一个nil或空字符串。这个模式看起来很原始,但它给了你最大的控制力,比一堆隐式 context 强得多。

2.3 模板、静态资源和中间件的组织方式

Caveman 生成的项目骨架里会有views/和static/两个目录。views/默认配合 Djula 模板引擎使用,模板文件以.html结尾,内部支持类似 Django 的变量和标签语法。比如:

<!-- views/index.html --> <html> <head><title>caveman demo</title></head> <body> <h1>{{ title }}</h1> <p>{{ content }}</p> </body> </html>

Lisp 侧的渲染代码非常简单:

(caveman2:render "index.html" (list :title "Welcome" :content "This is caveman"))

默认情况下,render会去views/下找模板文件,并把 plist 里的关键字作为模板变量。这套机制的好处是没有额外配置,坏处是嵌套模板、布局继承这些功能需要你自己从 Djula 文档里挖。我自己写 Caveman 项目时,通常会做一个简单的base.html加{% block %}的布局模板,但由于 Djula 的文档相对少,第一次调继承关系时会费点劲。

静态资源的处理就比较务实了。Caveman 默认把static/目录映射到/static/之下。我建议你别去改这个前缀,除非你有充分理由。因为它背后的中间件代码里有对..路径穿越的防护,你自定义映射时容易把这层防护漏掉。这是很多入门者没意识到的安全细节。

中间件构成了 Caveman 应用的骨干,标准骨架会在app.lisp里生成一个lack:builder表达式。你会看到它按顺序包裹了静态资源、会话、认证等中间件。中间件顺序非常关键,因为请求会按从上到下的顺序穿过它们,响应则反过来。举个例子,如果你把日志中间件放在最外层,它能看到所有请求包括静态资源;如果把它放在最里层,静态资源的访问就拿不到日志。这不是 Caveman 特有的坑,任何类 Rack 框架都一样,但因为 Caveman 要求你自己拼中间件列表,更容易犯顺序错误。

3. 从零生成一个 caveman 项目并跑起来

3.1 准备环境:SBCL、Quicklisp 和项目生成器

Common Lisp 的开发者环境不像 JavaScript 那么“开箱即用”,但只要装好三样东西,体验会很顺:SBCL 编译器、Quicklisp 包管理器、一个趁手的编辑器加 SLIME/Sly。SBCL 我不用多说,它是目前最活跃的 Common Lisp 实现之一。Quicklisp 是这个生态里的 npm/pip,负责下载和编译第三方库。

装好 Quicklisp 之后,先用它在 REPL 里拉取 Caveman 和项目生成工具。最省事的方式是用cl-project,它是一个项目骨架生成器,可以指定使用 Caveman 作为 Web 层。命令如下:

(ql:quickload :cl-project) (cl-project:add-project #P"~/quicklisp/local-projects/caveman-demo/" :web "caveman")

add-project会在指定路径生成一个完整的 Lisp 项目。注意,路径里的目录必须是不存在的,否则生成器会拒绝执行。":web \"caveman\""告诉 cl-project 把 Web 部分配置为 Caveman,这样骨架里就会包含app.lisp和views/目录,而不是一个纯库项目。

如果你不用 cl-project,也可以手动创建caveman-demo.asd、src/等文件,但没必要。我这段时间用下来,Cl-project 生成的结构干净好改,适合作为出发点。强调一句,这属于“基于常见实践的合理补充”——工具链的版本差异可能导致生成的文件名略有不同,但整体结构是一致的。

3.2 生成骨架后,目录里到底有什么

生成完你会看到这样的目录结构:

caveman-demo/ ├── caveman-demo.asd ├── README.md ├── app.lisp ├── config.lisp ├── db.lisp ├── views/ │ └── index.html ├── static/ │ └── css/ └── src/ └── main.lisp

简单梳理一下每个文件的职责。caveman-demo.asd是系统的定义文件,明确说明系统依赖哪些库,加载顺序是什么。app.lisp是核心,里面定义了*app*变量,也就是最终的 Clack 应用入口。config.lisp存放配置加载逻辑,比如端口号、日志开关,它默认读取环境变量,比较朴素。db.lisp是数据库连接的空壳,默认啥也不干,给你留了位置。views/和static/就是上一节说的模板和静态资源目录。src/main.lisp通常用来放路由定义,但很多人会把路由直接写在app.lisp里,因为项目不大时分开反而麻烦。

我自己的习惯是:路由定义全部集中在src/main.lisp,中间件组装留在app.lisp,模板文件按模块建子目录存放。理由很简单——Caveman 的路由宏在 REPL 里重载时特别容易,集中在同一个文件里,一次C-c C-c就能把整个文件重新编译,比在多个文件间跳来跳去省心。

3.3 写第一个页面:路由、参数和响应

既然骨架已经给了views/index.html,我建议第一件事就是改路由,让它真正响应用户输入。打开src/main.lisp,你会看到类似这样的定义:

(defroute "/" (req) (caveman2:render "index.html" (list :title "Hello" :content "World")))

先跑起来确认环境没问题,然后在此基础上加一个带参数的接口和一个 JSON 接口。带参数接口的写法前面已经介绍过,关键是request-route-params返回的 plist 中,键是关键字,值是字符串。如果你要拿它做算术,记得先parse-integer。

JSON 接口我建议使用json库,它是 Quicklisp 里的一个通用 JSON 编解码库。实现如下:

(defroute "/api/echo" (req) (let ((message (getf (request-params req) :message))) (setf (getf (caveman2:response-headers caveman2:*response*) :content-type) "application/json; charset=utf-8") (json:encode-json-alist (list (cons "message" message) (cons "length" (length message))))))

这里故意用了request-params而不是request-route-params,它的作用是把查询字符串和表单参数统一拿到手。导致两个函数行为的关键差异在于路径里的参数必须在路由里声明,query/form 里传的参数则直接给request-params。我见过有人为了拿 query 参数把路径写成/api/echo/:message,绕了一大圈,完全没必要。

此外,真实项目里设置content-type这件事最好封装成一个函数,不要在每段处理函数里重复写三行。Caveman 没有内置帮助函数,我笔者的做法是:

(defun json-response (alist) (setf (getf (caveman2:response-headers caveman2:*response*) :content-type) "application/json; charset=utf-8") (json:encode-json-alist alist))

然后每个接口直接(json-response (list (cons ...))),一眼清晰。

3.4 接入中间件:日志、静态资源和会话

骨架里的中间件已经包括静态资源处理,但默认没有日志中间件。开发时没有日志简直寸步难行,我强烈建议在app.lisp的lack:builder里加一个枚举请求信息和耗时记录的中间件。实现方式不复杂,你可以直接写一个 Lack 中间件函数。

Lack 中间件就是一个接收应用函数并返回新函数的函数,签名可以简化成这样:

(defun log-middleware (app) (lambda (env) (let ((start (get-internal-real-time))) (multiple-value-bind (status headers body) (funcall app env) (format *standard-output* "~A ~A ~A ms~%" (getf env :request-method) (getf env :request-uri) (/ (- (get-internal-real-time) start) internal-time-units-per-second 1/1000)) (values status headers body)))))

然后把它插到 builder 里注意顺序。我建议放在最外层,也就是静态资源之前,这样所有请求包括 CSS、JS 都有日志。如果放在最内层,你会发现静态资源请求悄悄消失了,那种排查体验很痛苦。

会话中间件你要自己确认是不是加进来了。Caveman 骨架不一定默认启用会话。如果你需要登录态、购物车这类功能,可以把lack.middleware.session包到 builder 里。它的原理是给客户端种一个签名 Cookie,服务端不存 session 数据。这跟有些框架的服务端 session 完全不同,内存占用小,但如果数据量大,会受 Cookie 大小限制。用不用它,取决于你的业务。

3.5 REPL 调试方法:出错了先别重启

Caveman 最迷人的地方是 REPL 调试。你不用每次改动都重启进程。流程是这样的:

  1. 在 REPL 里(ql:quickload :caveman-demo)加载系统。
  2. 执行(clack:clackup caveman-demo:*app* :port 5000)启动服务。
  3. 修改某个路由函数的代码。
  4. 在编辑器里重新编译这个函数,SBCL 会自动把它替换成新版本。
  5. 浏览器再刷新一次,看到的就是新代码的效果。

这里有个重要前提:修改路由表本身(新增defroute)时,你不能只编译那个defroute表达式,因为路由宏会在编译期向一个内部路由表添加条目,单次编译不一定能正确登记进去。最稳的做法是重新加载整个系统:(ql:quickload :caveman-demo :verbose nil),或者用 ASDF 的asdf:load-system。这样虽然比热更新单函数慢一点,但能避免很多“改了路由不生效”的诡异问题。

REPL 调试的另一个好处是能直接在 REPL 里调用路由函数测试,而不用起服务器。比如(caveman-demo::hello-handler ...)这样的函数,你可以手动构造请求环境,传入各种边界参数,看返回值是否正常。如果框架层面报错,SBCL 会弹出一个调试器,你可以查看调用栈、检查变量,甚至可以在这个栈上“断点续跑”。这套调试体验在传统编译语言里是要靠 IDE 断点才能做到一半效果的。

4. caveman 常见问题与避坑速查

4.1 更新慢、编译久、依赖冲突怎么办

很多第一次碰 Caveman 的人卡在 Quicklisp 下载慢或编译久。我的第一建议是加国内镜像,或者用(ql:update-dist "quicklisp")更新发行版到最新。如果你用的是中国大陆网络,Quicklisp 官方源有时候很慢,换到镜像源后,依赖能拉到每秒几兆。具体配置方法各镜像站有说明,这里不展开。

编译久这个问题,其实是 Lisp 库的正常现象——很多库首次编译要编译整个依赖树,几十秒到一两分钟都算正常。第二次加载就没这么慢了,因为 Quicklisp 会把编译产物缓存到~/.cache/common-lisp/目录。需要注意的是,如果你换了 SBCL 版本,缓存会全部失效,重新编译一次,这是正常现象,不是配置坏了。

依赖冲突的情况较少,但一旦遇到,往往是因为你同时加载了两个版本库。常见解决办法是先(ql:quickload :my-project)确认没有重复 dist 源,或者把 Quicklisp 的local-projects/里同名的旧版本目录挪走,保持只有一个版本可用。

4.2 路由匹配和大小写引发的血案

Caveman 的路由匹配以字符串字面量为准,路径参数我建议统一用小写,避免猜测。对比大小写敏感的问题,我的体会是 URL 路径本身区分大小写,这跟绝大多数 Web 服务器一样。但如果你从前端读出/User/123,后端定义的是/user/:id,那响应会是 404。这种问题不是 Caveman 的 bug,而是 URL 规范不一致导致的。

前端同学经常会混用大小写,为了避免联调摩擦,我建议在中间件层加一个统一小写化处理,但要注意静态资源路径别受影响——图片文件名大小写敏感。最常见的折中做法是只在路由分发前试着匹配一次原始路径,如果 404 再小写化匹配一次,这样既不阻断静态资源,又对 API 友好。实现有一点繁琐,但做完之后再也不用为userId和userid吵架了。

另外,Caveman 路由里的正则支持很有限。你别指望把整个正则表达式塞到路由字符串里,能用冒号参数解决的场景,用冒号参数;解决不了的,在路由处理函数里自己做字符串处理就行。

4.3 改了代码不生效:热加载的正确操作

只要在 REPL 里开发过 Caveman,一定会遇到“改了半天页面还是旧的”的情况。我先说结论:更新页面模板不需要重载 Lisp 代码,Djula 默认会从文件系统重新读取模板(前提是项目骨架中 Djula 的模板目录配置正确)。但更新 Lisp 代码就必须重载包改过的文件了。

重载时有一个大坑:旧的函数对象可能还被全局变量引用着。比如你在defvar *app*里把应用固定下来了,重新加载文件后*app*可能还是旧对象。这时候你需要重新执行(setf *app* (caveman2:make-app ...))或直接重新运行(clack:clackup ...)让新路由表生效。

还有一个非常隐蔽的问题:如果你在包定义文件里新增了 exported symbol 或改变了包结构,直接重新编译单个文件会提示“package ... does not exist”这类错误。解决方式是在 REPL 里先(asdf:load-system :caveman-demo :force t)强制重新构建整个系统,或者重启 SBCL。我自己一般只在涉及包结构变化时才重启进程,平时单文件C-c C-c足够。

4.4 性能和安全:简单不等于随便

很多初学者被 Caveman 的极简蛊惑,以为可以不管性能。事实上,Caveman 本身足够快,底层可以切换 Woo 这类高性能服务器,但你在处理函数里写的东西才是瓶颈。常见隐患有三个。

第一,不要在路由处理函数里同步做耗时操作,比如大量数据库查询或外部 HTTP 调用。Caveman 的请求处理模型是同步事件循环风格的,一个慢请求会占住 worker,其他请求只好排队。量大的话,你会看到服务响应全面变慢,而不是新请求被拒绝。解决办法是用异步库或者把任务丢进线程池,但 Lisp 的线程模型需要你自己小心,不是无脑开线程就能解决。

第二,会话数据写 Cookie 的框架里,千万不要把大对象塞进 session。我之前提到 Caveman 默认的 session 中间件是签名的 Cookie 方案,数据量超过几 KB,HTTP 头会变得很长,很多代理服务器直接拒绝转发。只要别往里塞大表数据,一般没事。

第三,静态资源中间件的目录穿越防护只在默认配置下有效。一旦你为了“优化结构”把静态资源目录改到奇怪的地方,或者自己添加了新的静态目录映射,一定要检查路径中是否包含..。我写过一次自定义静态目录映射,没做过滤,结果扫描工具直接报了一个目录遍历漏洞,教训很深刻。

下面这个速查表总结了我在真实项目里踩过的主要问题:

问题现象直接原因解决方案
新路由 404只编译了 defroute 表达式,路由表未重新登记重载整个系统,或重新 clackup
POST 请求命中 GET 路由defroute 未限制 HTTP 方法路由首元素声明 (:POST "...")
JSON 接口返回 text/html未设置响应头 content-type提前设置*response*头
页面修改不生效模板目录路径错误,或 Djula 缓存未清理检查 :path 配置,重启确认
依赖编译非常慢换了 SBCL 版本导致缓存失效删除 ~/.cache/common-lisp 后重编一次
请求处理阻塞拖垮一切同步长耗时调用使用线程池或异步处理

5. 用了一段时间后的个人体会

我最初接触 Caveman 只是图新鲜,觉得在 Common Lisp 里写 Web 服务挺异类。真正把它用在一个内部数据统计面板上之后,我转变了看法:这个“原始人”框架的效率其实非常高。面板有约二十个页面,六个 JSON 接口,数据来自两个数据库源,从需求确认到跑起来用了一个周末。大部分时间不是花在框架学习上,而是花在熟悉 SBCL 的调试器上——读懂那堆专业术语堆栈之后,排查错误的思路反而比在 Python 里靠打日志更直接。

Caveman 不适合所有人,但它适合那些愿意把“代码就是普通函数”这件事记住的人。它不会替你决定项目分层,不会帮你管理依赖之外的任何事,也不会替你写回归测试。骨架里的内容少得可怜,像还没打磨的石斧,但正是这把石斧,让我真正开始理解 HTTP、中间件和 Web 应用的构造逻辑。

如果你问我什么时候放弃它,我会说当项目规模到了需要专业模板引擎生态、丰富中间件市场、以及大量轮子时,我可能还是会切换回更主流的框架。但在此之前,在你想快速做一个内部工具、一个原型、或想用 Lisp 重新理解 Web 本质时,把 Caveman 拉出来用一周,绝对不亏。最后分享一个我自己常用的收尾技巧:不管项目多小,都把这个放到 README 第一行——“This project is a caveman: simple, direct, and not pretending to be pretty.”。

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

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

立即咨询