我接了一个看似有点“自找麻烦”的项目:所有东西都从零搭,技术栈不选最主流的,项目代号就两个字——caveman。一开始我以为这个名字只是个玩笑,等我真正用它把一个小型Web服务跑起来之后,才理解这个代号背后的含义:原始的、能打的、不啰嗦。如果你正在找一种比Flask更“结实”、比Go更“有文化”的Web开发体验,或者单纯想看看Common Lisp到底能怎么落地写服务器,这篇就是你的参考。
这篇文章不打算做框架介绍式的水文,而是完整记录我用caveman搭一个小书签应用的实战过程:从环境安装、路由设计、模板渲染、数据持久化到打包部署,每一段都有真实代码和踩坑记录。一句话总结caveman能干什么:它是一个跑在Common Lisp上、依赖Clack的轻量Web框架,专门解决“用Lisp快速写HTTP服务”这件事。
1. 为什么最终选了caveman:一个不在主流视野里的Web框架
1.1 它解决的痛点与我的真实使用场景
先说个背景。我的日常开发环境长期被Python和JavaScript占据,写惯了Flask和Express之后,总会有一种“框架什么都能干,但也就那样”的感觉。直到我开始认真研究Common Lisp,发现一个很尴尬的问题:Lisp本身的表达力很强,但Web生态相比主流语言确实零散,很久都没有一个能“开箱即用”的轻量框架。
caveman就是来补这个缺的。它由Common Lisp社区维护,之前叫Caveman,后来迭代成Caveman2,定位非常清楚:做类Sinatra的微Web框架。它在Clack之上工作——你可以把Clack理解成Lisp世界的WSGI或Rack,caveman负责把HTTP请求接到你写的Lisp函数上。它不像Hunchentoot那样给你一大坨底层API,而是提供了一个模板化的项目骨架:路由、控制器、视图目录、静态文件目录、数据库初始化全都分好。你只需要写业务。
我这次的实际项目是一个个人书签收藏服务,需求很小:能添加书签、能列出全部书签、能按标签查看、能删除。说人话就是一个“给自己用的收藏夹API加一个简单网页”。这种规模正好是caveman的舒适区——再大的项目也能做,但它的优势在中小型内部工具、自动化面板、教学项目这类场景里发挥得最充分。
1.2 和Flask/Sinatra对比时,我真正在意的取舍逻辑
很多人会问:既然Flask已经那么成熟,为什么还要折腾一个冷门框架?这问题我在做之前也问过自己。我整理了当时的对比表,纯主观,但也正是这套比较让我下定了决心。
| 对比维度 | Flask (Python) | Sinatra (Ruby) | caveman (Common Lisp) |
|---|---|---|---|
| 部署形态 | 解释器+虚拟环境+依赖安装 | 解释器+Gems | 可编译成单个可执行文件 |
| 运行性能 | 一般,受GIL限制 | 一般 | SBCL编译后接近原生性能 |
| 生态丰富度 | 极丰富 | 丰富 | 小而全,基础件都有 |
| 学习曲线 | 平缓 | 平缓 | 需要一点Lisp基础,但值得 |
| 开发体验 | 热重载靠扩展 | 热重载靠扩展 | SLIME连接后改函数即生效 |
这张表里最打动我的其实是第一行:部署形态。用caveman写完的服务,可以直接用SBCL的save-lisp-and-die打成单个二进制文件扔到服务器上跑,没有Python的venv、没有Gemfile、没有node_modules。对一个常年被环境问题折磨的人来说,这个优势几乎是致命的。
另一个让我选择它的理由是开发体验。Common Lisp配合SLIME之后,你可以在跑着的服务里直接重新编译某个函数,服务不用重启,状态还保留着。这种“手术刀式”的开发方式,我从没在其他语言里体验得这么舒服。caveman的defroute恰好是函数级别的,路由改完立即生效,调试效率非常惊人。
1.3 代码即配置:caveman最容易被低估的价值
caveman没有配置文件。没有YAML,没有TOML,没有JSON。它的配置就是一个Lisp文件。你打开src/config.lisp,看到的是defparameter、make-pathname和几个变量,改路径就是改代码,改端口就是改参数。刚开始你会觉得“这有点原始”,可真用起来会发现,所有东西都是可编程的,你可以在配置里写循环、写条件判断,而不是被一套模板语法限制死。
这也是为什么框架叫“caveman”——它不给你那些花哨的东西,但给的东西全都能直接上手,粗暴、原始、可靠。
2. 跑通caveman项目骨架:从安装到浏览器看到页面
2.1 环境准备:Roswell + SBCL + Quicklisp的安装链路
在开始之前,你需要一套能跑的Common Lisp环境。我强烈建议直接装Roswell,不要手动去下载SBCL,因为Roswell统一处理了Lisp实现的下载、依赖管理和脚本执行。
curl -L https://setup.roswell.io | sh安装完成后配置PATH,然后装SBCL作为默认实现:
ros install sbcl-bin ros run --version然后进入REPL,安装caveman2和它的项目生成器:
(ql:quickload :caveman2)这段过程看起来简单,但有一个实际中很容易卡住的点:Roswell默认会把依赖下载到~/.roswell/lisp/quicklisp下,如果你用的是国内网络,第一次 quickload 时下载可能会比较慢。我当时的处理方式是先配置好Quicklisp的镜像源,或者直接挂代理(注:这里指的是合法的网络加速手段,最终以官方下载源可用为准)。装完之后,caveman2这个包就常驻环境了。
2.2 用生成器创建项目,解读骨架目录结构
caveman2附带一个项目生成器,在REPL里调用一次就能得到完整骨架:
(caveman2-project:generate "caveman-bookmark")生成完你会看到一个叫caveman-bookmark的目录,结构大致如下:
caveman-bookmark/ ├── Makefile ├── README.md ├── db/ # sqlite等数据库文件放这里 ├── src/ │ ├── app.lisp # 应用主类、路由、启动入口 │ ├── config.lisp # 根目录、视图目录、数据库连接等配置 │ └── db.lisp # 数据库驱动与连接的初始化 ├── static/ │ ├── css/ # 原生静态文件目录 │ └── js/ ├── test/ # 预留测试目录 └── view/ # Djula模板目录第一次看到这个结构的时候,我的第一反应是“干净”,和Flask那种你不得不自己创建templates/static/目录的传统不同,caveman把一切安排得明明白白。src/app.lisp是一个类的定义,通常长这样:
(defclass <app> (caveman2:<app>) ())这个<app>就是你的应用对象。caveman在启动时会实例化它,然后负责把HTTP请求路由到对应的defroute函数上。理论上你可以定义多个应用类,每个应用类一套路由,这个能力在做多租户或不同模块时很好用。
2.3 启动服务并验证热重载
直接用Roswell进入项目目录后运行:
cd caveman-bookmark make run它默认监听5000端口,浏览器打开http://localhost:5000就能看到caveman的欢迎页。如果你更习惯REPL风格,也可以:
ros run --load src/app.lisp然后在REPL里执行(start :port 5000)启动。服务运行后,配合SLIME,你修改任意defroute函数体并C-c C-c编译,下一请求就会用新代码处理,无需重启。这种体验我实测下来非常舒服,尤其是调路由参数时,省掉了大量“改完代码→重启→刷新页面”的重复劳动。
提示:
make run只是开发用。生产环境别用这种方式,后面第6节我会演示如何编译成真正的可执行文件。
3. 路由、控制器与JSON API:caveman真正把玩起来的部分
3.1 defroute路由语法:方法、动态参数与通配
caveman的路由由一个宏defroute完成,语法非常直接:
(defroute ("/api/bookmarks/:id" :method :get) (params) (format nil "bookmark id: ~A" (getf params :id))):id是路径参数,实际请求/api/bookmarks/42时,params里就会包含:id 42。注意这里param是plist,取值用getf,这是Common Lisp里最常用的键值对结构。
方法通过:method关键字指定,可以是:get、:post、:put、:delete,不写则默认匹配所有方法。我发现一个很有用的做法:同一个路径可以写两个defroute,一个处理GET一个处理POST,caveman会根据请求方法自动分流,逻辑清晰,不需要像Flask那样用request.method做一堆判断。
(defroute ("/api/bookmarks" :method :get) () (list-all-bookmarks)) (defroute ("/api/bookmarks" :method :post) (params) (create-bookmark params))动态参数还有一个细节:如果你的路径里有带点的值,比如/api/users/1.json这种,caveman也支持通配和正则,不过个人经验是小项目基本用不上,:id足够。
3.2 请求参数读取:querystring、表单和JSON Body
HTTP接口不可能只拿路径参数,params这个变量里其实已经帮你合好了路由参数和query string。比如请求/api/bookmarks?tag=lisp,直接写:
(defroute ("/api/bookmarks" :method :get) (params) (let ((tag (getf params :tag))) (format nil "tag=~A" tag)))表单POST同样走params,前端用application/x-www-form-urlencoded提交,caveman会解析好放进plist。但如果你像我一样习惯用fetch发JSON,那就需要自己读body了。我的做法是在启动时给应用装一个中间件,把JSON body解析成可用的plist放进env,这样后续每个路由都能直接用params取到值。中间件的具体写法在第6节会展开,这里先埋个伏笔。
3.3 用Jonathan库输出JSON响应
直接返回字符串虽然能跑,但做API服务必须输出结构化的JSON。caveman生态里最常用的JSON库是Jonathan,简单到令人发指:
(ql:quickload :jonathan) (jonathan:to-json '(:status 200 :message "ok")) ;; => {"status":200,"message":"ok"}在我这个书签项目里,列表接口是这样写的:
(defun bookmark-to-alist (bookmark) (list (cons :id (mito:dao-id bookmark)) (cons :title (slot-value bookmark 'title)) (cons :url (slot-value bookmark 'url)) (cons :tags (slot-value bookmark 'tags)))) (defroute ("/api/bookmarks" :method :get) () (jonathan:to-json (mapcar #'bookmark-to-alist (mito:select-dao 'bookmark))))jonathan:to-json接受alist或plist,嵌套list会被输出成JSON数组。这样一套组合拳打下来,接口层基本就不需要再引入其他重量级东西了。
3.4 控制器组织:别把所有逻辑都堆在路由里
caveman本身没有强制你分层,所以很容易出现一个坏习惯:把所有业务逻辑直接写在defroute函数体里。刚开始图快没问题,但当你写了十几个路由之后,重复代码会越来越多。我的经验是:defroute只负责参数提取、调用业务函数、返回响应,真正的CRUD和算法逻辑放到单独的src/logic.lisp里。
比如上面那个bookmark-to-alist,我就放在logic文件里,路有需要就调用,测试也好写。Common Lisp的包系统在这里很有用,你可以为业务逻辑单独开一个package,路由文件use它,职责边界一清二楚。
4. 模板与前端资源:Djula渲染页面的完整做法
4.1 为什么还要用服务端模板
做纯后端API的时候你可以完全不用模板,但很多内部工具需要“能直接打开的页面”。与其单独开一个前端项目,不如让caveman直接渲染页面。caveman官方搭配的模板引擎是Djula,语法和Jinja2/DTL非常接近,如果你用过Django,几乎零成本迁移。
Djula能处理的场景是:把ORM查询出来的数据传进模板,渲染成HTML列表、表格,还能处理循环和条件。这样整个服务部署时仍然只有一个进程,不需要额外带静态服务器,对小型项目非常友好。
4.2 Djula模板语法速览与数据传递方式
Djula的模板文件放在view/目录下,比如view/index.html,基本语法是:
<h1>{{ title }}</h1> <ul> {% for bm in bookmarks %} <li> <a href="{{ bm.url }}">{{ bm.title }}</a> <span>{{ bm.tags }}</span> </li> {% endfor %} </ul>Lisp端调用render函数传数据:
(defroute "/" () (render #P"index.html" (list :title "我的书签" :bookmarks (mapcar #'bookmark-to-plist (mito:select-dao 'bookmark)))))这里有个关键点:DAO对象直接传进模板是可以的,但我更推荐先转成plist或alist,再传给模板。原因有两个:一是模板里访问slot-name的行为在不同环境下有微妙的差异,转成普通list最稳妥;二是你可以顺便剪裁字段,避免把内部数据全部暴露到模板变量里。
4.3 静态资源目录与前端联调
caveman会自动处理static/目录下的文件。比如static/css/style.css,页面里直接写/static/css/style.css就能访问到,不用额外配置路由。这个机制来自caveman的handle-static方法,它在每次请求进来时先检查是否是静态文件,是就直接返回,不是再交给路由。
我实际联调页面和API时用的是这个流程:页面由caveman渲染,页面里的JavaScript用fetch调用/api/bookmarks这样的接口,接口返回JSON由前端动态渲染。页面本身则由服务端模板负责首屏,两者职责很清楚。
浏览器 → GET / → 渲染 index.html 浏览器 → GET /api/bookmarks → JSON 数据这种“服务端模板做壳、API做肉”的架构,特别适合那种一个人维护的小工具。你不需要为了一个收藏夹页面去引入Vue或React全家桶,但想要更现代一点的交互,也随时可以在静态目录里写原生JS。
4.4 一个完整的增删页面示例
我在书签项目里做了一个最简单的页面:顶部一个输入框和按钮,下面一个列表,点删除按钮就调DELETE接口。核心模板大概是:
<form id="bookmark-form"> <input name="title" placeholder="标题" /> <input name="url" placeholder="链接" /> <button type="submit">保存</button> </form> <ul id="bookmark-list"></ul> <script> async function loadList() { const res = await fetch('/api/bookmarks'); const items = await res.json(); const list = document.getElementById('bookmark-list'); list.innerHTML = items.map(bm => `<li>${bm.title} - ${bm.url} <button onclick="del(${bm.id})">删</button></li>` ).join(''); } async function del(id) { await fetch('/api/bookmarks/' + id, { method: 'DELETE' }); loadList(); } document.getElementById('bookmark-form').addEventListener('submit', async e => { e.preventDefault(); const fd = new FormData(e.target); await fetch('/api/bookmarks', { method: 'POST', body: new URLSearchParams(fd) }); loadList(); }); loadList(); </script>这样一个页面,把caveman的模板渲染、静态文件、API三个能力全串起来了,运行起来非常轻,浏览器响应也快。
5. 数据持久化:用Mito DAO与SQLite把书签存下来
5.1 Mito的角色与SQLite选择
Web服务总得有地方存数据。caveman项目生成时默认引入的是DataFly和Mito两个库。DataFly负责数据库连接和SQL抽象,Mito则是一个类似ActiveRecord的DAO层——你定义一个类,它自动帮你建表、提供增删改查。对书签这种结构化数据,这一套刚好合适。
数据库我选了SQLite,原因很简单:单文件、零配置、适合个人工具类应用。Mito对SQLite的支持在SBCL下没问题,唯一要注意的坑我放在5.4节专门讲。
5.2 定义模型类并自动建表
在src/db.lisp里,先建立连接:
(mito:connect-toplevel :sqlite3 :database-name #P"db/bookmark.db")然后定义书签模型:
(defclass bookmark () ((title :col-type (:varchar 255) :initarg :title) (url :col-type (:varchar 255) :initarg :url) (tags :col-type (:varchar 255) :initarg :tags)) (:metaclass mito:dao-table-class))这里最核心的是:metaclass mito:dao-table-class,它告诉Mito这个CLOS类是数据库表。:col-type指定字段类型。定义完之后执行:
(mito:ensure-table-exists 'bookmark)表就建好了。注意SQLite会自动加上整数主键id,所以你的类里不需要手动定义id字段。这个设计一开始可能不太习惯,但用起来确实省事。
5.3 CRUD操作:create-dao / select-dao / find-dao / delete-dao
创建一条书签:
(mito:create-dao 'bookmark :title "学习Common Lisp" :url "https://lisp-lang.org/" :tags "lisp")查询所有书签:
(mito:select-dao 'bookmark)按条件查询:
(mito:select-dao 'bookmark :tags "lisp")按id查找并删除:
(let ((bm (mito:find-dao 'bookmark :id 42))) (mito:delete-dao bm))更新则是先改slot值,再调用mito:update-dao:
(let ((bm (mito:find-dao 'bookmark :id 42))) (setf (slot-value bm 'title) "新标题") (mito:update-dao bm))把这些操作封装到业务函数里,路由层调用起来就非常干净。比如创建接口:
(defroute ("/api/bookmarks" :method :post) (params) (let ((bm (mito:create-dao 'bookmark :title (getf params :title) :url (getf params :url) :tags (getf params :tags)))) (jonathan:to-json (bookmark-to-plist bm))))5.4 必须提前知道的坑:SQLite的连接初始化问题
我在这部分踩过一个非常典型的坑。刚开始我在src/app.lisp里用mito:connect-toplevel建立连接,开发时一切正常,因为开发服务器是单线程的。但一旦用socket服务器跑起来,多线程接管请求之后,SQLite连接会变得很不稳定,甚至出现“Database is locked”的错误,连接一会儿有一会儿没有。
后来我跟踪Mito源码才发现,mito:connect-toplevel并不是在所有SBCL线程里都共享连接。解决方法是显式让连接跨线程可用:
(mito:connect-across-all-threads :sqlite3 :database-name #P"db/bookmark.db")把初始化代码改成这个之后,多线程环境下就稳定了。这是怎么回事?SQLite本身的线程模型比较特殊,不是所有连接都能跨线程使用,而Clack的线程池恰恰是多线程的。所以这个坑只要做Web服务就一定会遇到,越早知道越好。
注意:如果你用的是PostgreSQL或MySQL,这个坑会小很多,但SQLite这个单文件方案对个人项目实在太方便了,一个
connect-across-all-threads就能解决,完全没必要换。
6. 上线前必须处理的三件事:中间件、错误页与部署
6.1 用Lack中间件补上访问日志与异常回溯
开发期我们直接在终端看日志,但上线之后你需要一个持久的访问日志,以及错误发生时的回溯记录。Common Lisp世界里的中间件库叫Lack,它有一系列现成中间件可以直接叠。
在启动函数里,把app包上一层builder:
(defun start (&key (port 5000)) (setf *handler* (clack:clackup (lack:builder (:accesslog :output #P"log/access.log") (:backtrace :output #P"log/backtrace.log") (lambda (env) (let ((*app* (make-instance '<app>))) (handle-static *app* env) (handle-request *app* env)))) :port port)))(:accesslog ...)负责记录每个请求的方法、路径、状态码和耗时,(:backtrace ...)会在发生未捕获异常时把完整回溯写入文件。这两个中间件一加,线上问题定位能力立刻上升一个档次。
6.2 统一错误响应:把异常变成JSON而不是HTML堆栈
默认情况下,caveman遇到未处理异常会返回一个带有堆栈的HTML错误页。这对浏览器访问没问题,但对接API的客户端就很痛苦了——前端拿到的不是JSON,无法统一解析。我的做法是写一个简单的错误处理中间件,包在最外层:
(defun json-error-middleware (app) (lambda (env) (handler-case (funcall app env) (error (e) (values (format nil "{\"error\":\"~A\"}" e) 500 (list (cons :content-type "application/json"))))))) (defun start (&key (port 5000)) (setf *handler* (clack:clackup (json-error-middleware (lack:builder (:accesslog :output #P"log/access.log") (:backtrace :output #P"log/backtrace.log") (lambda (env) (let ((*app* (make-instance '<app>))) (handle-static *app* env) (handle-request *app* env))))) :port port)))当业务代码抛出任意异常时,它会被捕获并转成{"error":"...}"返回,状态码500。这样前端统一处理错误逻辑就简单很多。你还可以根据异常类型返回404、400等不同状态码,思路是一样的,只是多几个handler-case分支。
6.3 编译成单个可执行文件:save-lisp-and-die实战
这是Common Lisp最吸引人的特性之一,也是caveman这类框架的天然优势。开发完成后,在项目里创建一个build.lisp:
(ql:quickload :caveman-bookmark) (sb-ext:save-lisp-and-die #P"caveman-bookmark" :executable t :toplevel #'(lambda () (caveman-bookmark:start :port 8080)))然后执行:
ros run --load build.lisp跑完你会得到一个原生的可执行文件caveman-bookmark。把它直接扔到服务器上,./caveman-bookmark就启动了,服务器上不需要装SBCL、不需要装Roswell、不需要任何Lisp库。这一点对运维来说太舒服了,我第一次成功打出这个二进制文件的时候甚至有点感动——终于不用跟Python虚拟环境搏斗了。
6.4 两个让我熬夜到凌晨的部署坑
坑一:编译后的可执行文件找不到资源路径。
开发时用make run,当前目录就是项目根目录,所以view/、static/、db/的相对路径都能找到。但编译成的二进制文件只能从它自己所在目录找相对路径,如果你在服务器上把二进制放到/opt/caveman/,而没有把view/、static/、db/目录也一起拷过去,服务一启动就各种404和数据库错误。
我的解决方案是写一个启动时修正路径的函数:
(defun chdir-to-project-root () (uiop:chdir (uiop:pathname-directory-pathname *load-pathname*)))编译时通过*load-pathname*拿到build.lisp所在目录,启动时先切过去,再加载配置。这样二进制和资源文件放在同一个目录下就没有任何问题。
坑二:静态文件和数据库文件“消失”了。
这还是路径问题,但表现得非常迷惑——一会儿能找到,一会儿找不到。原因是我曾经在start函数里动态拼接路径,请求多线程并发时,当前工作目录被某个线程改了。后来我统一改成绝对路径,把根目录在启动时绑定到一个全局变量里,后面所有文件路径都基于这个变量,问题彻底消失。
(defparameter *project-root* (uiop:pathname-directory-pathname (or *load-pathname* *default-pathname-defaults*))) (defparameter *db-path* (merge-pathnames #P"db/bookmark.db" *project-root*))这两个坑都需要实际部署时才会暴露,开发环境完全察觉不到。我写在这里,就是希望你在走这条路的时候直接绕过去。
结尾想再分享一点个人体会
整个项目从caveman这个名字开始,到书签服务跑起来,前后大概花了一个周末。我最大的感受是:Common Lisp写Web服务并没有想象中那么“远古”,反而是caveman这个框架真的太“工具人”了——不给你花里胡哨,但一切都精确、可控、能编译成单文件。项目往后走,我会给书签加上搜索和导入导出功能,路由结构一加就行,routing和model层已经铺平了。
最后留一个实用小技巧:给项目Makefile加一个build目标,把第6.3节的build.lisp串进去,你以后重新打包只需要一句make build。当初第一次编译出可执行文件的兴奋感,你试一次就会懂。