资讯详情

Common Lisp Web开发实战:caveman框架从入门到部署

📅 2026/10/29 13:03:11 | 华诺云谱 👁 阅读
Common Lisp Web开发实战:caveman框架从入门到部署
caveman 是我在 Common Lisp 项目里用过最久的 web 框架。如果你在 Lisp 社区里问现在用什么写 web 服务大概率会听到两三个名字caveman 是其中出现频率最高的那个。我最初看到这个名字觉得挺怪——穴居人跟 web 开发有什么关系后来用顺手了才发现它确实像石器时代走出来的工具简单、直接、不绕弯子该干活的时候绝不跟你谈设计哲学。这篇文章我不会从什么是 web 框架这种基础讲起而是直接按我自己从选型、搭项目、写路由、接数据库、到最终部署上线的完整经历来展开。适合已经会一点 Common Lisp、想用它做实际 web 项目的朋友参考也适合那些正在犹豫选哪个框架、被网上碎片化信息搞晕的人。1. caveman 到底解决什么问题先搞清楚它的定位很多人刚接触 Lisp 生态时最大的困惑不是语法而是东西太多了我不知道该用哪个。caveman 属于 web 开发这一层但要讲清楚它得先把它周围那几层东西一起说透。1.1 它不是一个从零造轮子的服务器caveman 本身不处理 TCP 连接也不解析 HTTP 报文。它做的事情是帮你把 HTTP 请求路由到对应的 Lisp 函数上然后拿到返回值再变成 HTTP 响应。底层真正监听端口、收发数据的是 Hunchentoot——这是 Common Lisp 圈子里最老牌、最常用的 web 服务器。caveman 相当于站在 Hunchentoot 上面把那些繁琐的细节包了一层。我第一次看到这种分层时有点不习惯。在别的语言里框架和服务器往往是绑死的比如你用 Flask 就是 Werkzeug用 Express 就是 Node 自带的 http 模块。但 Common Lisp 的哲学更像 Unix 工具链每个组件只干一件事然后通过接口组合起来。caveman 负责路由和控制器这一层Hunchentoot 负责HTTP 协议这一层中间还有一个叫 Clack 的抽象层负责把两者黏起来。1.2 caveman 和 Clack、ningle 的关系别把三者搞混这是新手最容易踩的第一个坑。你查资料时经常会看到三个词一起出现caveman、Clack、ningle看起来好像是一回事其实分工完全不同。Clack 是 Common Lisp 版的 WSGI/Rack——它定义了一套统一的接口让你的 web 应用可以跑在 Hunchentoot 上也可以跑在其它服务器上。它相当于插座标准。ningle 是一个极简路由库只负责把 URL 映射到函数别的什么都不管。caveman 是建立在 Clack 和 ningle 之上的完整框架它不仅要路由还帮你处理了模板加载、静态资源、配置读取、开发调试这一类常见需求。打个比方Clack 是插座标准ningle 是通电之后把电流引到电灯那一小段线caveman 则是整间屋子的电路设计——包括开关面板、灯座、预留的插座位置。你当然可以只买一根线自己接直接用 ningle但大部分时候直接用 caveman 更省事。2. 五分钟跑起来建立第一个 caveman 项目环境配置这部分我默认你已经装好了 SBCL 和 Quicklisp。如果没有先去把这两样装好这属于 Common Lisp 开发的基础设施任何项目都绕不开。我用的是 SBCL Quicklisp 的组合操作系统是 LinuxmacOS 下步骤完全一致Windows 下稍微注意一下路径分隔符就行。2.1 项目骨架到底长什么样先加载 caveman 并生成项目骨架。在 REPL 里执行(ql:quickload :caveman) (caveman:generate-project myapp)这时当前目录下会多出一个 myapp 文件夹里面的结构是这样的myapp/ ├── src/ │ ├── app.lisp │ ├── config.lisp │ ├── db.lisp │ ├── main.lisp │ └── view/ ├── static/ │ ├── css/ │ └── js/ ├── templates/ ├── tests/ ├── myapp.asd ├── README.md └── .qlot说实话我第一次看到generate-project生成这堆文件时是有点懵的。但用久了你就知道这个骨架简直是一个标准 Lisp web 项目的最佳实践模板app.lisp是整个应用的主体路由定义在这里config.lisp管配置db.lisp是留着让你接数据库的view/目录下是控层器需要用到的辅助代码static/和templates/一个管静态资源、一个管模板文件。2.2 编译加载比你想的更接近直接跑Common Lisp 的开发模式和其它语言很不一样——你很少有写完后编译整个项目这种动作而是把代码一块一块往 REPL 里加载。caveman 也不例外。进入项目目录后这样启动(ql:quickload :myapp) (myapp:start :port 5000)浏览器打开http://localhost:5000看到Hello, Caveman就说明项目活了。有人会觉得这不就跟别的框架一样吗。其实差的远。在其它语言里改完代码通常要重启整个应用进程。但在 Common Lisp 里你改完一个函数的定义只需要重新编译这一个函数然后在同一个 REPL 会话里继续用连 HTTP 连接都不会断。我平时调接口就是这样开三个终端一个跑 REPL一个用 curl 发请求一个写代码。循环下来基本是改一行、编译一行、测一下整个节奏比传统改完重启要快很多。这个体验在别的语言里很难找到替代品。2.3 start 函数藏在哪myapp:start这个函数不是凭空出现的它定义在src/main.lisp里由项目模板自动生成。它内部大概做了这样几件事读配置、确定要绑定哪个端口、用 Clack 启动一个 server然后把这个 server 的实例保存下来。如果你去看main.lisp的源码会看到类似这样的代码(defun start (key (port (config :port))) (setf *server* (clack:clackup #app/app :port port)))clackup是 Clack 的启动入口参数可以传端口、IP 地址、服务器类型等。默认情况下会使用 Hunchentoot。这也再次印证上一节说的caveman 负责组织代码Clack 负责启动Hunchentoot 负责跑协议。3. 路由设计从字符串通配到列表的思路转变路由是每个 web 框架的核心部分。caveman 的路由风格可能是你最先需要适应的东西——它不是用正则表达式写的而是用 Lisp 的列表来描述路径结构。3.1 defroute 的基本形态先看一个最简单的例子(defroute (/hello/:name :method :get) (params) (format nil Hello, ~a (getf params :name)))请求GET /hello/caveman会返回Hello, caveman。这里的:name是一个占位符请求路径中对应位置的字符串会被提取出来放进一个 plist属性列表里通过params传递给你的 handler 函数。:method :get表示只接收 GET 请求。如果你希望同一个路由同时支持 GET 和 POST可以这样写(defroute (/submit :method (:get :post)) (params) ...)3.2 为什么路径参数用 plist 而不是哈希表很多从其它语言过来的人看到(getf params :name)会觉得别扭为什么不给个 dict 或者 map我一开始也这么想后来才意识到 plist 在 Lisp 里是一种极其自然的数据结构——它本质上就是一个偶数长度的列表(:name alice :age 30)这样key 和 value 交替出现。getf就是从这个列表里按 key 取值。没有任何哈希计算、不需要初始化容器纯函数式的操作开销极小。这个设计还有一个实际好处调试时直接把params打印出来就能看到全部参数不需要遍历 key。我自己在排查问题时经常用(format t ~a~% params)一眼就能看出哪些参数收到了、哪些没值。这在用传统字典结构的框架里反而不这么方便。3.3 路由表太长了怎么办一个真实项目不可能只有两三个路由。等路由文件写到几百行时全堆在一个defroute列表里会很难维护。caveman 允许你按模块拆分在 app.lisp 里 search 一个 macro 把多个路由集中起来再按业务模块分成多个文件。比如(defroute (/api/users :method :get) (params) (list-users)) (defroute (/api/users/:id :method :get) (params) (get-user (getf params :id)))我自己的习惯是所有路由集中在app.lisp里但每个路由体只做三件事——解析参数、调用业务函数、把返回值包成响应。业务逻辑放到 src 下的其它模块。这样一来路由文件看起来就是个纯转发清单结构非常清晰。4. 请求处理、模板和 JSON真正写业务时才遇到的那些事路由只是把请求接到你的代码上。真正写业务时你需要处理请求体、返回 HTML 页面、输出 JSON 数据。这几块 caveman 都有对应的实践方式但里面有些细节不是文档里会主动告诉你的。4.1 接收表单和 JSON 请求是如何处理的POST 表单在 caveman 里处理起来非常直接表单字段会直接合并进params这个 plist 里。比如前端提交usernamealiceage30你在 handler 里就能用(getf params :username)取值。JSON 请求就没这么顺了。caveman 默认不会帮你解析请求体里的 JSON 字符串——你得自己把 body 读出来再解析。我常用的做法是配合 JON 这个 JSON 库(defroute (/api/users :method :post) (params) (let* ((body (getf params :body)) (data (jon:parse body))) ...))这里要注意一个细节caveman 的:body在需要的时候才会从请求流里读取读完之后这个流就消耗掉了。所以如果你想先自己解析、再回退到表单解析顺序一定要想好——别重复读。4.2 返回 JSON一个写死了的小 helper每个项目我几乎都会做同一件事封装一个 JSON 响应函数统一设置 Content-Type。caveman 不会帮你强制做这件事但它给你留了口子。我通常在 src/view/helpers.lisp 里写这样一个函数(defun json-response (data optional (status 200)) (setf (hunchentoot:content-type*) application/json; charsetutf-8) (setf (hunchentoot:return-code*) status) (jon:encode data))huntentoot:content-type*是动态变量在 handler 执行期间设置它Hunchentoot 就会在当前响应里使用这个 Content-Type。return-code*同理。这个 helper 一旦定义好后面写 API 就是一行事(defroute (/api/status :method :get) (params) (json-response (:status ok :time (get-universal-time))))4.3 模板引擎选谁Djula 还是自拼返回HTML 页面在 caveman 生态里最常配合的模板引擎是 Djula——它是一个 Django 风格的模板库有{% for %}、{{ variable }}这类语法。如果你以前用过 Python 的 Django 或者 Jinja2上手 Djula 几乎没有成本。(djula:add-template-directory #ptemplates/) (defparameter *index-template* (djula:compile-template* index.html))然后在路由里(defroute (/ :method :get) (params) (djula:render-template* *index-template* nil :message hello from caveman))compile-template*是 Djula 提供的一个函数参数是模板文件名返回值是一个编译好的模板对象。每次渲染时直接传这个对象进去就行不用重复读取文件。不过说实话如果只是写一个小工具或者纯粹返回 JSON 的 API 服务我个人不是很建议上模板引擎——直接format拼 HTML 有时候反而更清楚。caveman 的好处就在这里它不像某些框架那样绑定某个模板引擎你可以完全自由选择。我见过有项目用 Django 风格模板也有见过直接用字符串拼接的两种都跑得好好的。5. 数据库接入与配置管理从写死参数到多环境切换写 web 应用几乎绕不开数据库。caveman 的骨架里专门留了db.lisp的位置但具体用什么库、怎么接完全是你的自由。这一节我说说我自己摸索出来的实践方式以及配置管理上最重要的一个思路。5.1 用 sqlite 起步的方案以 Mito 为例Common Lisp 的 ORM 生态不像别的语言那么丰富社区里比较常看到的是 Mito。它是一个轻量级 ORM支持 SQLite、MySQL、PostgreSQL 等后端。如果你是刚开始做项目我建议一切从简先上 SQLite表结构也小不需要专门的数据库服务。引用方式是在 .asd 文件的:depends-on里加上:mito然后代码里大概这样用(mito:connect-toplevel :sqlite3 :database-name #papp.db) (defclass user () ((name :col-type (:varchar 64)) (email :col-type (:varchar 128))) (:metaclass mito:dao-table-mixin))mito:connect-toplevel是 Mito 的设置数据库连接入口设置一次全局生效。上面这个defclass不只是定义一个 Lisp 类Mito 会把它同步成数据库表——name字段对应 varchar(64)email对应 varchar(128)。这套机制用起来非常接近别的语言里的 ActiveRecord 风格。每次操作一个对象Mito 会自动帮你生成对应的 insert/update/select 语句。例如(let ((u (make-instance user :name caveman :email xexample.com))) (mito:insert-dao u))5.2 config.lisp 的动态变量机制caveman 项目里的配置管理思路是用 Lisp 的全局动态变量存配置启动时从文件读取。模板在src/config.lisp里给你放了一个config函数实现方式有点巧妙——它返回一个 plist代码里通过 key 圈定的方式取值(defun config (key key) (getf *config* key))默认配置是这样(defparameter *config* (list :port 5000 :database #Papp.db))运行时读端口就是(config :port)读数据库路径就是(config :database)。想改配置直接 recompileconfig.lisp里的这个defparameter再重新启动就行。5.3 开发环境和生产环境怎么切换我刚接触这个配置机制时犯过个错误直接把生产环境的数据库地址写死在 defparameter 里结果本地开发和线上共用一份配置经常出现本地连不上生产库或者更糟的生产用到了本地测试数据。后来我的做法是在 config.lisp 里根据环境变量做分支(defparameter *config* (let ((env (uiop:getenv APP_ENV))) (cond ((equal env production) (list :port 8080 :database #P/var/lib/app/app.db)) (t (list :port 5000 :database #Pdev.db)))))这个思路非常简单但非常有效。本地没设APP_ENV走默认分支服务器上设成production走生产配置。如果你需要更多环境staging、test在这个cond里加分支就行。不要为了所谓优雅设计搞太复杂的配置框架真实项目中这套就够了。6. 调试和错误处理Lisp 式开发体验的真正价值用 caveman 写代码最大的快乐其实不在框架本身而在 Common Lisp 的调试环境。但这些东西不是开箱即有的需要你做一些配置。这一节分享我怎么把调试体验调到舒服的状态。6.1 报错时的全局调试器有多好用默认情况下caveman 应用如果抛了异常调试器会在 REPL 那一侧弹出来。你可以在 REPL 里看到完整的后栈、局部变量、以及出错时的传参。有一次我们线上环境报了个错我的做法是看 REPL 后栈输入数值变量的值发现是一个列表里有个 nil 被传进来导致函数崩了。整个排查过程没有加一行 print 日志完全是直接看运行现场。这个体验在其它语言里几乎不可能——你顶多看到一个 log 文件然后靠猜。要让这个体验跑通只需要在启动时给 Clack 开启 debug 模式也就是把环境变量设成不吞异常。如果你用的是 caveman 直接启动默认调试器就是开的。上线时再把异常处理切到正常模式返回 500 页面给客户端。6.2 中间件顺序对错误处理的影响这是个隐秘的坑Clack 支持中间件caveman 也可以插。中间件的执行顺序是严格按包裹顺序来的——第一个包上去的中间件最外层最后添加的在最内层。这跟洋葱模型一样请求从外层往里走响应从里层往外走。如果你的错误处理中间件写在内层它就只能捕获内层路由的异常捕获不到外层中间件的异常。我踩过的坑就是这个把一个访问日志中间件加在最外层错误处理加在内层结果中间件自己崩了之后错误根本传不到错误处理那里。最后调换顺序才解决。记住一句话错误处理中间件尽量放在最外层这样任何内层抛出的错误都能被它截住。6.3 404 和 500 页面的自定义这是容易被忽略的细节当路由不存在时caveman 默认返回一个简单的 404。生产环境如果让用户看到默认页面既不专业也可能泄漏框架信息。自定义 404 的方式有两种第一种是 hook 到底层的 Hunchentoot 的错误处理器(setf (hunchentoot:catch-errors-p) t)第二种更推荐在路由表最后加一个 catch-all 路由匹配所有未匹配的路径。(defroute (/* :method (:get :post :put :delete)) (params) (json-response (:error not found) 404))/*会匹配所有路径当所有具体路由都没命中时这个兜底路由就被调用。这样做的坏处是要注意它放的位置——必须确保其它路由定义在后面好处是很直观你完全控制返回什么。7. 静态资源与前后端协作的模式选择caveman 项目里有static/目录服务器会自动把/static/...路径下的文件当静态资源返回。但实际项目中我见过两种不同的协作模式各有利弊。7.1 同源模式模板输出 HTML 局部 AJAX这种模式下页面主要用 Djula 模板服务端渲染一些局部交互用 AJAX 请求 JSON。caveman 的static/目录里放编译好的前端 JS 和 CSS模板中引用/static/js/app.js这种路径。开发时比较简单一个应用同时管页面和 API。资源更新后需要清浏览器缓存否则容易看到旧资源。7.2 前后端分离模式caveman 只做 API 服务另一种是彻底分离前端用 Vue/React 构建成纯静态站caveman 只提供 JSON API。这套模式下你根本不需要模板引擎只关心 API 路由和数据库。前端资源可以部署在 Nginx、CDN 或任何会服务静态文件的地方前后端通过 CORS 或 Nginx 反代通信。我个人的选择是小项目、公司内部系统用服务端渲染的同源模式省事面向公众、交互复杂的产品选前后端分离caveman 专注 API。这两种模式都是 caveman 能支持的——它不像 Rails 这类框架强绑定模板渲染也不像 Express 那样纯 API而是两边都能干。7.3 CORS 配置如果你前后端分属不同域名既然是前后端分离几乎必须处理 CORS。这里有个很多人会踩的坑直接在 handler 里设置 CORS 头但 preflight 的 OPTIONS 请求会被路由拦截掉。正确做法是加一个中间件或者其他方式统一设置响应头。我实际用下来最省心的还是写一个中间件(defclass cors-middleware (clack:middleware) () (:documentation set cors headers for api responses)) (defmethod clack:call ((m cors-middleware) env) (let ((res (call-next-method))) (setf (getf (getf res :headers) Access-Control-Allow-Origin) *) (setf (getf (getf res :headers) Access-Control-Allow-Methods) GET, POST, PUT, DELETE, OPTIONS) (setf (getf (getf res :headers) Access-Control-Allow-Headers) Content-Type) res))然后把中间件加进去。call-next-method会先执行下层逻辑拿到res之后再统一加头。这样不管走哪个路由响应里都会带上 CORS 头。OPTIONS 请求如果你不想走到具体 handler可以在中间件里直接短路返回。8. 测试、部署与上线维护把应用真正丢到生产环境开发完只是第一步上线才是考验。这一节结合我部署两个实际项目的经历说几个 caveman 部署相关的经验和坑。8.1 测试怎么写重点是验证路由而非启动进程首先明确一个观念测试时不要启动 HTTP 服务去发网络请求慢而且依赖端口。正确方式是直接调用 handler 函数——因为它们就是普通 Lisp 函数传一个 plist 进去就能得到返回值。(deftest test-hello-route () (let ((result (myapp::hello-handler (list :name caveman)))) (is (equal Hello, caveman result))))为了让测试可写在定义路由时我会尽量把 handler 逻辑做成纯粹的函数输入是参数 plist输出是响应数据或字符串。这样测试逻辑就独立于 web 层。如果测试需要真跑一套完整 HTTP 流程可以用 Clack 的测试辅助库但绝大多数场景下直接函数测就够了。8.2 部署方式一直接用 SBCL 作为守护进程最简单的部署方式在服务器上直接跑 sbcl加载应用然后让进程常驻。为了保证进程意外退出能自动拉起用一个守护进程工具包一层while true; do sbcl --load start.lisp; sleep 1; done这是一个粗糙但非常实用的脚本。进程崩了会自动重启。想优雅停机可以把 start.lisp 里明确绑定端口然后靠外部脚本发信号。这种方式的优点是部署简单缺点是没有独立进程管理回收不了某些资源、没有优雅的滚动更新。适合小流量工具型应用。8.3 部署方式二用 Docker 打包一个 Lisp 环境再配合 Nginx 转发如果你的应用除了 HTTP 还要处理并发、甚至以后想横向扩节点更规范的方式是 Docker。写一个 DockerfileFROM daewok/sbcl:latest RUN apt-get update apt-get install -y build-essential COPY . /app WORKDIR /app CMD [sbcl, --load, start.lisp]然后 Nginx 配置把/反代到容器的 5000 端口就好了。这种方式的优点是可复现部署流程和环境依赖完全锁定。缺点是镜像比较大——sbcl 加 quicklisp 依赖集一般在几百 MB 到 1G 左右但换取的是环境一致性可以接受。8.4 上线后必须做的几件小事打开错误处理生产环境一定要把异常吞掉返回通用 500 页面不能把堆栈抛给用户。启动参数注意端口绑定务必绑定 0.0.0.0否则外部访问不到。数据库迁移Mito 的表结构变更用 mito:migrate 处理上线前跑一遍。这些都不是什么高深技术但每一条我都经历过真实的血泪教训尤其是端口绑定这个——我第一次部署时一直监听 default 的 localhost 不说前端页面死活连不上 API排查了半天才发现是监听地址不对。9. 最后什么样的人、什么样的项目适合用 caveman写了这么多是时候给一个诚实的总结。caveman 不是万能的它有自己的适用场景和不及之处。判断要不要用可以先问自己几个问题。如果你是一个 Common Lisp 学习者想拿 Lisp 写点实际能跑的东西caveman 是一个非常合适的学习载体——它让你把关注点放在 web 逻辑本身又保住了 Lisp 的动态调试优势。模板、配置、数据库集成这些部分能帮你把 Lisp 的生态串起来学习路径也相对平滑。如果你要做的是内部系统、管理后台、API 服务、中小流量的 web 应用caveman 完全能够胜任。它不像那些大而全的框架那样自带全套规范化约束但这恰恰是灵活性所在——你可以用它快速做出一个够用的服务而不需要为用不上的功能付时间成本。反过来如果你的项目需要的是一个「极稳定、超大并发、团队里其它人不愿意写 Lisp」的长期产品caveman 可能并不是适合的选项。它更适合高度个人化或小团队的场景——在 Lisp 生态里这其实不是缺点反而是一种选择上的自由。以我自己的经验来看caveman 最大的价值在于它把一个强大的语言和一个务实的 web 需求很好地结合起来——只要你能接受 Lisp 的思维方式你就会发现它写 web 服务时的效率确实不比那些主流框架差而在开发体验上又有很多不可替代的优势。如果你正在 Lisp 和另一个语言之间犹豫不妨先拿 caveman 搭一个小项目试试答案会很快浮现出来。
📝

华诺云谱内容团队

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

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

你可能需要的服务

订阅华诺云谱资讯周报

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

↑