资讯详情

caveman:用Common Lisp快速构建可掌控的轻量级Web应用

📅 2026/10/29 12:36:04 | 华诺云谱 👁 阅读
caveman:用Common Lisp快速构建可掌控的轻量级Web应用
如果你在开发者语境里搜索“caveman”大概率会先被各种健身食谱、原始生活方式、史前题材游戏刷屏。但搞 Common Lisp 的人看到这个词第一反应通常是那个轻量级 Web 框架——caveman。这个项目在 Lisp 社区里算不上大红大紫但它的设计思路非常对得起“穴居人”这个名字原始、直接、不搞花活却能快速搭出一个能跑的 Web 应用。这篇文章我想聊聊我折腾 caveman 的实际经验它到底是什么、和周边生态是什么关系、怎么从零跑起来、正式开发时有哪些坑以及最后怎么把它扔到服务器上跑生产。如果你正在学 Common Lisp或者想找一个“能完全掌控”的 Web 框架这篇应该能帮你省不少时间。1. “caveman”在 Lisp 生态里到底指什么一个被名字耽误的极简 Web 框架1.1 先分清 caveman、Hunchentoot、Clack 这三个名字很多第一次接触 Common Lisp Web 开发的人会被一堆名字绕晕Hunchentoot、Clack、caveman、Snooze、Radiance……它们到底有啥关系我用自己的话说一遍。Hunchentoot 是一个 HTTP 服务器也是 Common Lisp 里最老牌的 Web 工具之一。它负责监听端口、解析 HTTP 请求、把响应写回去属于最底层的东西。Clack 则是一层抽象接口灵感来自 Python 的 WSGI 和 Ruby 的 Rack它把“HTTP 服务器”和“Web 应用”解耦。你可以把 Clack 理解成一个翻译官不管底层是 Hunchentoot 还是别的服务器你的应用代码只需要面对 Clack 定义的一套接口就行。而 caveman 是建立在 Clack 之上的路由层负责 URL 分发、请求参数提取、模板渲染这些业务最关心的东西。所以完整的调用链是这样的浏览器请求 → Hunchentoot 接收 → Clack 转译 → caveman 路由分发 → 你的业务代码。这个分层非常像 Ruby 里 Rack Sinatra 的组合或者 Python 里 WSGI Flask。如果你之前用过 Sinatra那 caveman 的上手成本几乎为零。1.2 为什么选它对比 Flask/Sinatra 式的微框架体验你可能想问既然有 Hunchentoot直接用它写不行吗当然行但你会发现自己要手动处理的东西太多了URL 解析、参数绑定、静态文件服务、会话管理……这些在 caveman 里都是开箱即用的。caveman 的定位就是微框架它不强迫你用任何 ORM、任何模板引擎、任何目录结构只把 Web 开发最核心的路由和请求处理给你理顺剩下的自己选。用 caveman 写一个接口体感上很像写一个 Sinatra 应用定义路由、处理参数、返回内容完事。但它比 Sinatra 多了一层 Clack 中间件机制所以日志、会话、错误捕获这些需求可以通过插入中间件来解决不需要污染业务代码。这种“核心极简 周边可插拔”的设计恰好是 Common Lisp 社区一贯的哲学语言本身很小但你可以随意扩展。1.3 1.x 与 2.x 的差异别一上来就 Load 错包这里有个特别容易踩的坑我必须单独拿出来说。caveman 有两个大版本但它们的关系不像一般的 1.x → 2.x 升级那么直观。caveman 1.x 是一个真正意义上的“框架”你加载:caveman包之后就能用defroute定义路由。而 caveman 2.x 的定位变成了“项目生成器”——作者更愿意叫它 project generator它帮你生成一个完整的项目骨架真正的运行时库实际是lack,caveman2等一批包组合起来的。所以如果你(ql:quickload :caveman)然后写(caveman:defroute ...)那是 1.x 的玩法如果你用(ql:quickload :caveman2)然后通过caveman2:generate-project生成项目那才是现在社区的主流用法。提示快速区分就是看包名。凡是用:caveman2的基本都是生成骨架 中间件链的玩法凡是直接用:caveman的是老的微框架方式。两者 API 不完全兼容别混着用。2. 从零跑通第一个 caveman 应用环境准备与项目骨架2.1 环境准备Quicklisp、SBCL 与 caveman2我默认你已经有 Common Lisp 环境了。如果还没有最快的路就是装 SBCL 再用 Quicklisp 拉依赖。SBCL 是目前最活跃、性能也最均衡的实现caveman 官方文档里的示例基本也都基于 SBCL。在 REPL 里执行(ql:quickload :caveman2)这条命令会把 caveman2 以及它依赖的 lack、clack、routes 等一堆库拉下来。如果你的网络不太好这一步会有点慢因为依赖树不小。我第一次跑的时候卡在编译clack上等了一两分钟属正常现象。接着生成一个项目(caveman2:generate-project my-blog :port 5000)这条命令会在当前目录下创建一个my-blog/文件夹里面已经给你铺好了最基本的骨架。2.2 看懂生成的项目骨架生成的目录结构大致是这样my-blog/ ├── my-blog.asd ├── src/ │ ├── main.lisp │ ├── app.lisp │ └── config.lisp ├── views/ │ ├── include/ │ └── index.html ├── static/ │ └── css/ │ └── style.css ├── tests/ │ └── my-blog.lisp └── db/ └── ...有 Web 开发经验的人应该秒懂src/放代码views/放模板static/放前端静态资源。关键文件是src/main.lisp和src/app.lisp。main.lisp是整个应用的入口它负责拼接 Clack 中间件链最后把处理后的 handler 交给 Clack 启动。app.lisp是路由定义的地方你写的defroute基本都在这里。config.lisp管配置比如端口、数据库连接串这些。2.3 定义第一个路由与启动流程打开src/app.lisp你会看到类似这样的内容(defpackage my-blog (:use :cl :caveman2) (:export :start)) (in-package :my-blog) (defroute / () Hello, Caveman!)启动(ql:quickload :my-blog) (my-blog:start :port 5000)浏览器打开http://localhost:5000/看到页面上的 “Hello, Caveman!”你的第一个 Lisp Web 应用就跑通了。这里有个值得说的设计细节defroute的第二个参数是“上下文”可以声明对这个路由可用的变量。最简单的写法是()表示什么都不需要但如果你想接收路径参数、查询参数或 POST 体就会在这里做文章下一节我细讲。3. 正式写业务时最容易绊倒的五个点路由、模板、JSON 与静态资源3.1 路由参数的三种写法路径参数、查询参数、POST 参数caveman 的defroute支持多种路径模式。最常用的是路径参数(defroute /user/:id (optional id) (format nil User ID: ~A id))请求/user/42id就绑定为42。注意这里用了optional因为路由变量是可选的就算你没在路径里拼出这个值也不会报错。查询参数和表单参数则是通过key接收。比如(defroute /search (key q page) (format nil q~A page~A q page))请求/search?qlisppage2q和page会被自动绑定。POST 表单也是一样的只要 HTTP 方法是 POST且 body 里有字段用key就能直接拿到。如果你想让这个方法只响应 POST可以显式声明(defroute (/api/posts :method :POST) (key title content) ...)我自己的经验是尽量把路径参数放在 URL 里把筛选条件放在 query key 里把大段内容放 POST body 里。caveman 不会替你校验参数类型拿到的全是字符串所以你最好在业务层做类型转换和合法性检查。3.2 返回 JSON 的正确姿势Content-Type 是个大坑这是我在实际项目里踩得最深的一个坑。默认情况下defroute返回的字符串会被当作 HTML 或纯文本输出响应头里的Content-Type是text/html; charsetutf-8。如果你直接返回一个 JSON 字符串前端拿到它一样能解析但严格来说这是错的而且如果你返回中文内容某些客户端可能会乱码。正确做法是在返回前手动设置响应头(defroute /api/user () (setf (hunchentoot:content-type*) application/json; charsetutf-8) {\name\:\John\,\id\:42})如果你不想手动拼 JSON 字符串可以用jonathan这个库做序列化(ql:quickload :jonathan) (defroute /api/user () (setf (hunchentoot:content-type*) application/json; charsetutf-8) (jonathan:to-json (:name John :id 42)))jonathan:to-json可以接受 plist、hash-table 或自定义对象输出对应的 JSON 字符串比手动 format 靠谱得多。提示如果不确定一个响应到底返回了什么类型最好的调试办法是用curl -I看响应头。我每次写完接口都会习惯性检查一下Content-Type尤其是前端写死application/json的接口。3.3 模板渲染Djula 还是直接拼字符串caveman 本身不绑定模板引擎但生成的项目默认能用 Djula这是 Common Lisp 社区最常用的模板库语法风格接近 Django。在views/目录下你可以放index.htmlh1{{ title }}/h1 ul {% for item in items %} li{{ item }}/li {% endfor %} /ul然后在路由里渲染(defroute / () (render index.html (list :title My Blog :items (one two three))))render是 caveman2 提供的便捷函数它会自动遍历views/目录、加载模板、把 plist 里的变量填充进去。如果你只是返回一个简单页面这个方式非常顺手。但我的个人建议是如果你只写几个 JSON API没有复杂页面直接用format拼 HTML 字符串就够了。模板引擎的意义在于页面多了以后避免重复堆 HTML但代价是你得再学一套模板语法。caveman 的好处就是它允许你这么做不会有任何框架层面的阻力。3.4 静态资源与前端资源路径caveman2 生成的项目里有个static/目录里面放 CSS、JS、图片。默认情况下Clack 中间件clack-middleware-static会把/static/*路径映射到该目录。所以你在 HTML 里引用link relstylesheet href/static/css/style.css就能正常加载。如果你在开发时改了 CSS 但浏览器还是旧样式多半是缓存问题用强制刷新或加版本号参数解决。如果你想把静态资源放到 CDN 上只需要在模板里把路径前缀改成 CDN 地址底层逻辑不受影响。caveman 对前端的策略就是“不管”你走 Nginx 也好、走 S3 也好它都无感。3.5 数据库接入caveman 不绑定 ORM自己选caveman 对数据库的态度很明确不绑定任何 ORM也不内置迁移工具。你可以用任何你喜欢的库。Common Lisp 生态里最常见的组合是直接用CL-DBI连接数据库手写 SQL用Mito作为 ORM 层类似 Rails 的 ActiveRecord用Postmodern操作 PostgreSQL享受它提供的方言支持。如果你想快速跑一个带数据库的 CRUD我推荐先用CL-DBI因为它足够底层你能看到真实的 SQL 在发生什么排错也容易。比如连 SQLite(ql:quickload :cl-dbi) (defvar *db* (dbi:connect :sqlite3 :database-name my-blog.db)) (dbi:execute *db* CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)) (dbi:execute *db* INSERT INTO users (name) VALUES (?) (Alice)) (dbi:fetch-all (dbi:execute *db* SELECT * FROM users))这里唯一要注意的是dbi:execute返回的是 result set 对象不是行数据要拿到实际数据得用dbi:fetch-all或dbi:fetch逐行读。新手经常在这里懵一下其实理解了就好了。如果你担心手写 SQL 容易出错再上一层Mito也行。但我的建议是别一上来就上 ORM——先用原生 SQL 把业务逻辑跑通等表结构稳定了再决定要不要引入 ORM。caveman 的灵活性就在这里你随时可以在几行代码之间切换。4. 性能感知与调优实践一个“穴居人”能跑多快4.1 启动速度与内存用数据说话Common Lisp 的 Web 应用常被误解为“慢”但实际体验下来caveman 的启动速度和运行性能都相当不错。我本机的实测数据SBCL 2.2.xmacOS是编译启动一个最小 caveman 应用到start完成大约 0.3 秒内存占用约 25MB 到 35MB 左右。作为对比一个同样体量的 Ruby on Rails 应用启动时间通常以秒计内存占用随随便便上 100MB。这里的核心原因在于 SBCL 是原生编译的代码在加载时就会被编译成机器码而不是像解释型语言那样边跑边解释。所以 caveman 应用的启动快、执行快非常契合“微服务”的场景——你可以毫无压力地在一台机器上同时跑几十个不同端口的 Lisp 服务。4.2 并发模型与 Hunchentoot 的线程化caveman 默认跑在 Hunchentoot 之上而 Hunchentoot 是线程化的服务器每个请求会分配一个线程处理。配合 SBCL 的 native threads多核利用是没问题的。但这里有个需要留意的地方你的业务代码里如果有全局可变状态比如一个全局变量存计数器并发请求产生了竞争。Common Lisp 的标准做法是使用sb-thread:synchronized或bt:make-lock加锁。如果你写的是无状态 API那基本不用担心并发问题。我见过不少人抱怨 Lisp Web 框架并发差但实测下来只要不在请求 handler 里做锁粒度很大的重活默认的线程模型完全够用。一个只有数据库查询和 JSON 返回的接口压测到每秒几千请求是没问题的和你的数据库性能有关。如果你追求更高并发可以考虑把 caveman 背后的服务器从 Hunchentoot 换成Clack支持的woo——它是个异步服务器性能和 Node.js 比较接近但这是另一个话题了这里先不展开。4.3 两个有效的调优动作堆内存与编译策略如果你发现应用在压力测试下频繁 GC 导致响应抖动一个最简单的动作就是增大 SBCL 的堆内存。在启动命令里加上sbcl --dynamic-space-size 4000这是 SBCL 在启动时预留的最大堆内存单位 MB。默认值可能只有 1GB 左右如果你的应用缓存了很多数据GC 压力会很大。调高这个值能显著减少 GC 频率。另一个调优动作是调整编译策略。Common Lisp 有个declaim可以用来切换编译优先级比如(declaim (optimize (speed 3) (safety 1) (debug 1) (space 1)))放在源文件头部告诉编译器优先追求速度、降低安全检查。开发机用默认配置safety 默认更高便于排错生产环境再切到这个配置。这个技巧对所有 SBCL 项目都适用不止 caveman。4.4 别被“极简”绑架复杂场景下的妥协说一些不好听但真实的体会。caveman 作为微框架在项目变大以后会遇到一些不舒服的地方它没有内置的文件上传处理函数需要自己去读hunchentoot:raw-post-data然后自己解析 multipart 格式。而且文件上传这种需求很常见但藏得很深。它没有现成的用户认证体系Session 中间件只给你一个 session-id具体怎么用你来写。它没有一个完整的 Admin 后台生成器这种活你得自己拼。这些不是 bug而是“极简”的代价。如果你预见到项目要快速长出大量 CRUD 页面和后台管理radiance 或直接用 Hunchentoot 自己抽象一套 CRUD 生成器可能更合适。caveman 适合的是那种“接口清晰、逻辑不复杂、但我要完全掌控代码”的场景。比如个人博客、内部工具、微服务网关、定时任务的管理面板等等。这几个场景我实际用下来都挺顺手但如果让我用 caveman 写一个大型 SaaS 的完整后端我会再考虑一下。5. 把它搬到生产环境可执行文件、systemd 与 Nginx 反向代理5.1 编译成独立可执行文件开发时直接在 REPL 里交互运行很舒服但生产环境不可能开个 REPL 挂着。最干净的做法是把整个应用编译成独立可执行文件。最简单的方式是用 ASDF 的make动作sbcl --load my-blog.asd --eval (asdf:make :my-blog)前提是在my-blog.asd里正确配置了:build-operation和asdf:defsystem的输出目标。生成后你会得到一个可执行的二进制文件直接运行它./my-blog --port 8080它会自己启动 SBCL 并运行你的应用。这个二进制里已经包含了所有依赖的 Lisp 代码不需要目标机器上安装 SBCL 和 Quicklisp。依赖管理这一步对于很多人来说是 Common Lisp 项目部署的主要障碍编译成二进制后这个障碍就没了。如果你想用 Roswell也可以用它来管理 SBCL 版本和构建脚本但 Roswell 更适合脚本分发做 Web 服务的 standalone binary 反倒不如 ASDF 生成干净。我个人建议按 ASDF 的流程走。5.2 systemd 托管与端口控制有了二进制之后把它交给 systemd 管理是 Linux 上最省心的做法。写一个 unit 文件比如/etc/systemd/system/my-blog.service[Unit] DescriptionMy Blog Caveman App Afternetwork.target [Service] ExecStart/opt/my-blog/my-blog --port 8080 WorkingDirectory/opt/my-blog Restartalways RestartSec2 Userwww-data EnvironmentSBCL_HOME/usr/lib/sbcl [Install] WantedBymulti-user.target然后sudo systemctl enable my-blog sudo systemctl start my-blog注意我用了一个www-data用户这是个低权限用户。生产环境不要让服务跑在 root 下这是常识就算你想图省事也得忍住。如果你有环境变量比如数据库密码用EnvironmentFile/etc/my-blog.conf把所有敏感配置集中在那个文件里别写死在代码里。5.3 Nginx 反向代理与日志虽然 caveman 应用自己就能跑 HTTP 服务但我强烈建议在前面放一层 Nginx。原因很简单Nginx 处理静态文件更高效能帮你做 TLS 终止、域名路由、请求体大小限制还能统一日志格式。整个配置没什么玄机server { listen 80; server_name yourdomain.com; location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /static/ { alias /opt/my-blog/static/; expires 7d; access_log off; } }location /static/那一段直接把静态文件交给 Nginx一方面减轻 Lisp 进程的压力另一方面可以开启浏览器缓存性能立竿见影。API 请求走location /转发给 caveman 应用。日志方面caveman 的访问日志和开发日志在开发模式下是打到 REPL 标准输出的切换到生产环境通过环境变量或 config.lisp 控制后你可以让它们输出到 stdout然后由 systemd 的 journald 统一收集journalctl -u my-blog -f这样你就有了集中日志不用自己写日志库里轮转的逻辑。nginx 那边的 access log 默认打到/var/log/nginx/access.log如果你想分析请求量用goaccess或导出到别的地方都行。这一套组合下来运维成本不高但能覆盖 90% 的小型 Web 服务的需求。根据我个人折腾 Lisp Web 开发这段时间的感受caveman 最打动我的地方不是某个具体功能而是它让我重新理解了 Web 框架的本质一个框架真正该做的是帮你把请求进来、响应出去这条链路的复杂度降到最低而不是替你把所有事情都决定了。caveman 把路由、参数、渲染这几件事做到“够用且透明”剩下的全交给你掌控。这种掌控感是 Rails 和 Django 给不了你的。如果你也在找一个能完全驾驭的轻量级 Web 框架caveman 值得你花一个下午试试。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑