资讯动态

用Common Lisp写Web应用?Caveman框架从入门到上线

发布时间:2026/10/29 18:05:07 来源:尧图企业网站定制
如果你在技术社区里搜“caveman”这个词大概率会看到三种东西一款原始人题材的独立游戏、某个极简到离谱的编辑器主题还有一个名字里自带自嘲的 Common Lisp Web 框架。我今天要聊的是最后一个——Eitaro Fukamachi 维护的 Caveman作者自己调侃说是“给原始人用的 Web 框架”。我最初看到这个项目名时以为是个玩笑直到我真的拿它搭了一个带数据库、模板、用户 Session 的完整服务跑上线才发现这个名字比我想象的认真得多它确实试图把 Common Lisp 做 Web 开发的门槛压到“原始人也能一锤子凿出来”的程度。这篇文章不是抄文档是我从零到上线跑完一个实际项目之后的完整梳理。内容包括为什么我会在 2024 年选择用 Common Lisp 写 Web、Caveman 的骨架到底替你做了什么、路由和数据库怎么组合成一套能用的 CRUD、以及最后上线时踩过的那些文档里不会写清楚的坑。如果你正在犹豫要不要试试 Lisp 生态里的 Web 开发或者已经装了 SBCL 但不知道从哪里下手这篇应该刚好能帮你把路趟平。1. 三个“caveman”里只有一个是 Web 框架1.1 先别急着搜游戏Caveman 指代的三种东西在 GitHub 上搜 caveman靠前的结果通常有三类一个叫 Caveman 的考古题材游戏仓库、一个不知道更新了多少年的 Vim 配色 script剩下的就是 Fukamachi 的 Common Lisp Web 框架。前两种并不坏但如果你是想学 Web 开发认错对象会很浪费时间。正主项目的描述写得非常直白A web framework for cave men。它基于 ClackCommon Lisp 的 WSGI/ASGI 那一层抽象构建使用路由宏defroute定义端点默认集成 Djula 模板引擎项目里还顺手生成了一套完整的目录骨架包括静态资源目录、模板目录、数据库配置文件入口等。说白了它就是 Common Lisp 世界里的“Rails 生成器 Flask 路由 Django 模板”的混合体但带着浓厚的 Lisp 风味。1.2 Caveman 的设计哲学让“约定”帮你少做决定Caveman 最打动我的地方不是性能也不是某个高深特性而是它对“约定优于配置”的贯彻程度。你用(caveman2:generate-project myapp)生成一个项目后目录里会直接出现app.lisp、route.lisp、view.lisp、config.lisp、static/、templates/这样一套标准布局。所有文件各司其职应用实例定义在app.lisp路由统一写在route.lisp页面模板放在templates/CSS 和 JS 放在static/。这套骨架不是摆设。它迫使你从一开始就遵循某种节奏新功能来了先去route.lisp加路由然后在templates/里加模板最后在config.lisp里调配置。项目变大之后这种节奏能显著降低“代码该放哪里”的心智负担。我见过不少 Common Lisp 老手习惯从零手搓 Hunchentoot 处理器结果项目一超过五十个端点就乱成一团Caveman 的骨架反而给了一种工业化组织的框架感。1.3 Caveman 1 和 Caveman 2别装错版本Quicklisp 里同时存在 Caveman 1 和 Caveman 2 两个发行版两者 API 不兼容。Caveman 1 依赖 Clack 1.x路由宏和中间件配置方式都比较老旧Caveman 2 是当前维护的重点也是本文所有示例基于的版本。生成项目时用caveman2:generate-project加载时用(ql:quickload :caveman2)而不是:caveman。这件事看起来简单但实际上非常容易踩网上很多老教程、博客写的还是 Caveman 1 的defroute写法和 Middleware 配置照抄到 2 上会直接报错。如果你刚开始千万别被搜索引擎带偏认准 Caveman 2遇到“keyword 不匹配”“class not found”之类错误时先怀疑版本问题。2. 为什么敢用 Common Lisp 写 WebCaveman 的生态位与底气2.1 Common Lisp Web 开发长期被低估的三个事实很多人一听 Lisp 写 Web第一反应是“玩具语言”“学术界自嗨”。但实际情况是Common Lisp 里有一套完整的 Web 开发生态Caveman 只是其中最顺手的一层壳子。生态底下的 Clack 负责抽象服务器接口Woo 负责高性能 HTTP 处理Mito 负责 ORMDjula 负责模板再加上 Quicklisp 做依赖分发整条链路是通的。被低估的第一个事实是 REPL 驱动的开发体验。传统 Web 框架改代码要重启进程Lisp 不用。你在 REPL 里重新编译一个 defroute运行中的服务立刻用上新路由Session 不丢、连接不断。我在做内部工具的时候边开着浏览器边在 REPL 里调模板反馈周期以秒计这种体验确实会上瘾。第二个事实是稳定性。SBCL 是一个压了很多年、极其稳的运行时跑 Web 服务很少因为内存泄漏或者 GC 毛刺出问题。我有个朋友用 Caveman Woo 写过一个内部报表服务挂在服务器上八九个月没重启过也从来没崩过对一个内部工具来说这已经远超预期了。第三个事实是宏带来的表达能力。defroute本身就是一个宏它把路由路径、HTTP 方法、参数绑定整合到一个 form 里。你写路由的时候不是在解析字符串而是在直接构建一段可组合的 Lisp 代码。这种表达力在其他语言里很难等价地复制。2.2 Caveman 与 Flask、Rails、Express 的横向对比拿 Caveman 和主流框架对比不是为了证明谁更强而是为了找准它的位置。我用一个实际项目分别跑过 Flask 和 ROR对这三个框架的分工有很直观的感受。维度Caveman 2FlaskRuby on RailsExpress语言生态Common Lisp QuicklispPython pipRuby gemNode npm项目骨架生成器自带完整布局需要手动组织生成器非常完备脚手架可选路由风格defroute 宏装饰器DSL 路由函数链模板引擎Djula默认Jinja2ERB任意选择ORMMito 可选SQLAlchemyActiveRecord任意选择开发反馈REPL 热更新debugger 重启重启热重载生产服务器Woo / HunchentootGunicornPumaNode 原生从表里能看出来Caveman 的定位有点像“Lisp 世界的 Rails 轻量版”。它比 Flask 多出一套默认骨架比 Rails 灵活得多但同时又不像 Express 那样把所有决定权都交给你。它的生态位很明确如果你因为某些原因需要在 Common Lisp 里写 Web 应用Caveman 是当前最成熟、文档最全、社区用得最多的选择。2.3 什么样的项目和人群适合上 Caveman我个人的建议是如果是想做快速原型、内部工具、运维面板、语言学习型项目Caveman 非常合适如果是面向海量用户的 C 端高并发服务我会更谨慎——不是 C Les 做不了而是团队协作、人才供给、运维习惯这些方面生态还不够厚。适合 Caveman 的另一个强场景是“对 Lisp 好奇但怕走火入魔”的人。Caveman 的结构贴近主流 Web 框架学一遍之后你既能理解动态语言 Web 框架的通用模式又能感受到宏和 REPL 带来的不同。这是一个入门 Common Lisp 实际应用的好入口比写一堆纯函数练习题有趣得多。3. 环境准备与最小骨架让第一个 Hello World 跑起来3.1 装环境Roswell Quicklisp 一步到位用 Common Lisp 做任何事之前环境安装永远是第一道坎。我目前的推荐是统一使用 Roswell。Roswell 是一个 Common Lisp 的“环境管理器 命令运行器”它负责帮你安装、切换 SBCL 版本还会自动带上 Quicklisp省掉很多手工初始化工作。在 macOS 上用 Homebrew 装最省事brew install roswell在 Linux 上官方提供了一行脚本或者你用发行版自带的包管理器安装也行curl -L https://raw.githubusercontent.com/roswell/roswell/master/scripts/install-for-ci.sh | sh装完以后确认一下ros version sbcl --version我建议你把默认实现设为 SBCLros use sbcl。然后启动 REPL 试试 Quicklisp 是否可用(ql:quickload :caveman2)如果这一步能顺利加载你的基础环境就通了。顺便说一句Quicklisp 默认从远程分发源码编译第一次加载 Caveman2 会拉很多依赖需要一些时间属于正常现象不用慌。3.2 生成项目骨架的两种姿势有了 Roswell 之后生成 Caveman 项目有两种方式。第一种是在 REPL 里调用(ql:quickload :caveman2) (caveman2:generate-project myapp :force t)这里:force t的作用是覆盖同名目录。第二种方式是直接使用 Roswell 安装 Caveman2 提供的命令行工具然后在 shell 里执行ros install caveman2 caveman2 myapp两种方式都会生成一个名为myapp的新目录。我个人更推荐第一种因为可以直接在 REPL 里接着继续开发不需要切换上下文。生成的时候它会问几个问题比如是否使用数据库、选哪个后端如果你不确定先选 SQLite 和默认模板即可后面也能改。3.3 目录结构和每个文件的职责生成结束后你应该会看到这样一个目录myapp/ ├── app.lisp # 定义 app 类、中间件、静态文件服务 ├── config.lisp # 配置项包含应用路径、环境变量、数据库 DSN ├── db.lisp # 数据库连接初始化如果你选了数据库支持 ├── main.lisp # 启动入口定义 main 函数启动 HTTP 服务器 ├── route.lisp # 所有 defroute 路由定义 ├── view.lisp # 模板相关配置和辅助函数 ├── static/ # CSS / JS / 图片等静态资源 ├── templates/ # Djula 模板文件通常放 .html └── tests/ # 测试骨架理解这套布局之后你基本就理解 Caveman 了main.lisp 负责启动app.lisp 负责装配route.lisp 负责路由处理templates/ 负责展示db.lisp 负责数据。每一个文件都很薄组合起来却是一个完整的 MVC 分层。3.4 跑起第一个页面进入myapp目录在 REPL 里加载启动文件(load main.lisp) (main)Caveman2 默认会在 5000 端口启动服务浏览器打开http://localhost:5000你就能看到欢迎页。首次看到这个页面时说明骨架已经完整工作可以开始往里面塞自己业务了。修改完route.lisp后不需要重启服务直接在 REPL 里重新加载对应文件或者重新编译刚才修改的 defroute下一请求就会生效。这是 Lisp Web 开发最爽的一环。4. 路由与控制器的正经玩法写一个图书管理 API4.1 defroute 的核心语法与参数绑定Caveman 的路由定义核心就是defroute宏它的基本形式是(defroute path args body...)path 可以是字符串也可以是(字符串 :method :GET)这样的列表形式用来指定 HTTP 方法。args 部分传入 URL 里的动态参数以及 query string 里的参数。来看一个最简单的(defroute / () Hello, Caveman!)再来看带动态参数的(defroute /book/:id (key id) (format nil Book ID: ~A id))这里:id会被 URL 里的实际值替换例如/book/42于是id变量就绑定为字符串42。如果你需要整数自己parse-integer即可。Caveman 在参数绑定上有个很方便的设计URL 中的占位符和 query string 参数都会作为key传入。比如请求/book/list?page2你可以直接在路由里读取page参数(defroute /book/list (key page) (let ((page-num (or (parse-integer page :junk-allowed t) 1))) (format nil page: ~A page-num)))这一点比很多框架都简单省事。也正是因为参数绑定完全交给 Common Lisp 的 lambda list 机制路由处理函数天然地和普通 Lisp 函数风格保持一致。4.2 限定 HTTP 方法POST 提交的正确姿势写 API 时同一个路径往往需要根据 GET/POST 返回不同结果。Caveman 用带方法的 defroute 来实现(defroute (/book :method :POST) (key title author) (format nil add a book: ~A by ~A title author))这个定义只处理 POST/book不会干扰同路径的 GET。如果路径字符串本身以/book开头但方法不匹配Caveman 会继续往后匹配其他路由直到找到合适的处理者。如果全部不匹配再走 catch-all 或 404。这种方法的写法在初期很容易被忽略但强烈建议你从一开始就明确写 HTTP 方法。我在一个内部项目里偷懒过所有路由都不写方法结果 GET 和 POST 语义混在一起出问题后排查得怀疑人生。写清楚方法是 Web 开发的低成本高收益投资。4.3 一个图书管理 API 的完整实现下面是一个可以直接抄的小例子图书的增删改查用内存数据结构避免过早引入数据库。这个例子足够展示 Caveman 路由的核心用法。(defvar *books* (make-hash-table :test #equal)) (defvar *next-id* 1) (defroute /books (key page) (let ((result ())) (maphash (lambda (id book) (push (cons :id id) result) (push (cons :title book) result)) *books*) (format nil books on page ~A: ~S page result))) (defroute (/books :method :POST) (key title) (let ((id (write-to-string *next-id*))) (incf *next-id*) (setf (gethash id *books*) title) (format nil created book with id ~A id))) (defroute /book/:id (key id) (let ((book (gethash id *books*))) (if book (format nil Book ~A: ~A id book) (format nil 404: no such book: ~A id)))) (defroute (/book/:id :method :DELETE) (key id) (if (gethash id *books*) (progn (remhash id *books*) (format nil deleted ~A id)) 404: no such book))这个例子虽然玩具但覆盖了创建、查询单条、列出全部、删除四种基本操作也展示了 defroute 在不同路径下的组合方式。实际项目里只要把*books*换成 Mito 的 DAO 查询就能直接长成一个真正的 CRUD 服务。4.4 中间件和错误处理该放在哪Caveman 基于 Clack 的 Builder 模式组织中间件项目生成时app.lisp里默认会带有一个静态文件服务的 middleware。如果你需要 Session、日志、异常捕获等能力也是在app类的中间件列表里按顺序配置。中间件顺序不是随便排的外层中间件先处理请求然后层层向内最后到达你的路由。所以 CORS、Session 加载这类影响全局的东西靠外层业务相关的靠内层。错误处理是新手最容易忽视的地方。Caveman 中如果你在 defroute 里抛异常框架默认会把错误信息直接打到 REPL/标准输出并返回一个朴素的 500 页面生产环境里你大概率不希望把堆栈透给用户所以一般会加一个全局的错误捕获中间件。我自己的做法是开发期保持默认让错误快速可见生产期包一层统一格式的 JSON 错误返回。两套配置用环境变量切换非常灵活。5. 真正把页面丰满起来Djula 模板与 Mito 数据库5.1 为什么要用 Djula默认模板引擎的重点语法Caveman 2 默认集成 Djula 作为模板引擎。Djula 的语法很像 Django/Jinja2变量使用双大括号控制结构使用带百分号的标签。如果你以前写过 Flask几乎不需要额外学习。{% extends base.html %} {% block content %} h1Book List/h1 ul {% for book in books %} li{{ book.title }} — {{ book.author }}/li {% endfor %} /ul {% endblock %}上面这段代码里extends和block用来实现模板继承for用来循环渲染列表。你在 Lisp 里传给render的变量名在模板里直接用{{ 变量名 }}就可以读取不需要任何赋值或 import 操作。用模板继承是管理页面结构最有效的办法。项目一多几乎每个页面都有导航栏、页脚或公用的 JavaScript 引用把这些放到base.html的 block 外子模板只覆盖自己关心的 block维护成本会大幅下降。5.2 接数据库Mito 是一个 Lisp 风的 ORMCaveman 本身不强制绑定数据库官方社区常用的组合是 Mito。Mito 是 Common Lisp 生态里维护最积极的 ORM内置数据库迁移支持 SQLite、PostgreSQL、MySQL 等后端。在项目里初始化 Mito 连接一般是这样的(mito:connect-toplevel :sqlite3 :database-name myapp.db)连接一旦建立后续 DAO 操作都是面向对象的不需要手写 SQL 字符串。定义一张书表(mito:define-table book () (title :type (or null string) :initform nil) (author :type (or null string) :initform nil))插入和查询也非常直白(mito:insert-dao (make-instance book :title Lisp in Web :author someone)) (mito:select-dao book) (mito:find-dao book :id 1) (mito:delete-dao book)需要注意的是Mito 的表结构变更依赖迁移。开发阶段你经常改字段比较省事的做法是先用(mito:ensure-table-exists book)之类的方式同步表结构等模型稳定了再做正式迁移。别在生产环境直接 drop 表那会让你一夜回到解放前。5.3 把书单渲染到页面上一个完整的模板渲染路由把模板和数据库组合起来才是 Caveman 的完整形态。这里是一个渲染全部图书的路由(defroute /books () (render books.html (list :books (mito:select-dao book))))render是 Caveman 提供的辅助函数第一个参数是模板文件名第二个参数是一个 plist模板里的books变量就等于这个 plist 中:books对应的值。对应的templates/books.html可以写成{% extends base.html %} {% block content %} h1All Books/h1 ul {% for b in books %} li{{ b.title }} / {{ b.author }}/li {% endfor %} /ul {% endblock %}这样一套流程下来数据库查询、数据绑定、模板渲染、页面继承全部打通了。对于内部管理系统来说这一套组合拳已经能满足八成以上的业务需求。5.4 静态文件与前端资源处理Caveman 生成的项目中静态文件默认放在static/目录并由 app.lisp 内的 static 中间件提供服务。开发时你直接把 CSS、JS 丢进去然后在模板里用相对路径引用即可。有一个细节值得注意Caveman 的默认 static 路径处理是有限制的它只服务/static/前缀的路径以及根路径下的favicon.ico。如果你把图片放在static/img/logo.png访问路径是/static/img/logo.png不写前缀会 404。这个设定初看有点啰嗦但避免了很多框架常见的路径穿越和资源暴露问题属于安全下限建议不要改。6. 上线之后的体检部署、守护与踩坑合集6.1 生产环境服务器选型Woo 是我现在的默认选择Caveman2 默认使用 Woo 作为 HTTP 服务器。Woo 是一个事件驱动、非阻塞的 Common Lisp HTTP 服务器由同一作者维护专门为 Caveman 这类应用优化过。它在处理并发连接和静态文件 IO 上的表现在 Common Lisp 生态里属于第一梯队。相比另一个老牌选择 HunchentootWoo 通常更快更适合生产环境。启动生产服务通常不需要像开发时那样在 REPL 里手动敲命令。你可以把 Caveman 应用编译成可执行文件然后通过 systemd 或 Docker 守护运行。编译成原生程序之后部署机器甚至不需要安装 SBCL这对发布非常友好。6.2 用 nginx 做反向代理一份能直接用的配置无论 Woo 性能多好我都不会让它直接面对公网。标准做法是前面挂 nginx静态文件直接交给 nginx 处理动态请求反向代理给 Woo 监听的本机端口。这里给一份我用过很多遍的基础配置server { listen 80; server_name your-domain.com; location /static/ { alias /var/www/myapp/static/; expires 7d; } location / { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }这份配置最省心的点是静态文件不再经过 Lisp 进程nginx 的静态文件处理能力和缓存控制都比应用层做得更专业也可以有效减少 Woo 的负载。如果你的服务是一个 API 而不是渲染 HTML那 static 部分可以整个删掉只保留反向代理部分。6.3 我踩过的五个坑每一个都是文档里没写明白的第一个坑是 Quicklisp 的 dist 过期导致 Caveman2 加载失败。Common Lisp 生态依赖 Quicklisp 分发它的远端索引是定期更新的。你如果几个月没更新(ql:update-dist quicklisp)很可能拿到旧版依赖然后撞上各种奇怪的编译错误。解决方式很简单重要项目开始前先执行一次更新。第二个坑是 Windows 环境下的原生依赖编译问题。Mito 接 PostgreSQL 时底层要编译 C 扩展Windows 上经常缺编译器或者依赖库路径不对。不是说 Windows 不行而是如果你在这上面卡住了建议立刻切 WSL 或 Docker不要在环境问题上消耗热情。第三个坑是模板文件路径大小写。Djula 在部分平台上对模板文件名大小写敏感books.html和Books.html可能是两个文件。项目里我一度因为文件名大小写不一致在本地跑得好好的部署到 Linux 上 404查了半天。建议从一开始就统一小写文件名省得踩这种低级地雷。第四个坑是 REPL 开发和生产编译之间的行为差异。开发时你可以在 REPL 里改一个函数服务立刻更新但编译成可执行文件后代码的加载顺序、特殊变量初始化时机都可能不同。尤其要注意全局变量比如*books*这种开发期你是手动赋初值的编译后如果初始化代码不在恰当位置运行时会拿到 nil。所有全局状态请务必放在defvar的 initform 里或者放在显式的启动函数中重新初始化。第五个坑是字符编码。Web 应用里最隐蔽的问题永远是 UTF-8。开发环境、终端、数据库连接、HTTP 响应头有一层没设置 UTF-8中文就会出现乱码。我建议从搭骨架的第一天就统一三件事源文件用 UTF-8 保存、数据库连接字符串里显式指定编码、响应头加Content-Type: text/html; charsetutf-8或对应的 JSON 编码头。事后补编码要比一开始就做好惨痛得多。6.4 用 Docker 固定你的 Lisp 环境如果你不想在每台部署机器上都折腾 Roswell 和 QuicklispDocker 是一个很不错的方案。下面是一个基于 Roswell 官方镜像的简单 Dockerfile它能把源代码带依赖体积化地装进一个镜像FROM roswell/roswell:latest WORKDIR /app COPY . . RUN ros install caveman2 \ ros run --load build.lisp EXPOSE 5000 CMD [ros, run, --load, start.lisp]这个方案最大的价值是环境固定本地开发、测试、生产使用同一个镜像Quicklisp 版本、SBCL 版本、依赖版本全部一致。我遇到过“本地好好的服务器上编译失败”的惨案后来发现是两边 Quicklisp dist 时间不一样。Docker 化之后这个问题基本彻底消失。最后说点真心话跑完这整个项目之后我对 Caveman 的认知已经完全改变了。它不是一个生僻的学术玩具而是一套认真打磨过的生产力工具骨架帮你省掉决策defroute帮你把 Web 路由变成 Lisp 的一部分REPL 给你一种停不下来的开发节奏。它对快速原型和内部工具尤其友好也足够稳定到可以直接挂上线。如果你愿意给它一个下午的时间我建议你从生成一个项目开始然后试着写一个带模板的书单页面再加上 Mito 存储——从零到完整 CRUD 的整个过程不会超过两个小时。等真正体验到在 REPL 里改完路由、刷新浏览器立即看到效果的快感你大概就会理解为什么这么“原始人”的框架至今还有人愿意用它做完整个项目。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑