从零搭建Common Lisp Web服务:caveman框架实战指南

发布时间:2026/10/28 20:30:07
从零搭建Common Lisp Web服务:caveman框架实战指南 我接了一个看似有点“自找麻烦”的项目所有东西都从零搭技术栈不选最主流的项目代号就两个字——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或Rackcaveman负责把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 APIcaveman真正把玩起来的部分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一个处理POSTcaveman会根据请求方法自动分流逻辑清晰不需要像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 BodyHTTP接口不可能只拿路径参数params这个变量里其实已经帮你合好了路由参数和query string。比如请求/api/bookmarks?taglisp直接写(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 %} /ulLisp端调用render函数传数据(defroute / () (render #Pindex.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 idbookmark-form input nametitle placeholder标题 / input nameurl placeholder链接 / button typesubmit保存/button /form ul idbookmark-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 onclickdel(${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 #Pdb/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 #Pdb/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 #Plog/access.log) (:backtrace :output #Plog/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 #Plog/access.log) (:backtrace :output #Plog/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 #Pcaveman-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 #Pdb/bookmark.db *project-root*))这两个坑都需要实际部署时才会暴露开发环境完全察觉不到。我写在这里就是希望你在走这条路的时候直接绕过去。结尾想再分享一点个人体会整个项目从caveman这个名字开始到书签服务跑起来前后大概花了一个周末。我最大的感受是Common Lisp写Web服务并没有想象中那么“远古”反而是caveman这个框架真的太“工具人”了——不给你花里胡哨但一切都精确、可控、能编译成单文件。项目往后走我会给书签加上搜索和导入导出功能路由结构一加就行routing和model层已经铺平了。最后留一个实用小技巧给项目Makefile加一个build目标把第6.3节的build.lisp串进去你以后重新打包只需要一句make build。当初第一次编译出可执行文件的兴奋感你试一次就会懂。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询