我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

Caveman 2 实战:用 Common Lisp 高效开发 Web 应用

Caveman 2 实战:用 Common Lisp 高效开发 Web 应用 不夸张地说Caveman 是我在 Common Lisp 里折腾 Web 开发以来第一个让我觉得“能正经干活”的框架。在这之前我的体验基本停留在“库很多、但什么都要自己拼”的阶段路由要手写正则、模板要自己接、表单解析要靠翻文档一套小工具做下来一半时间在拼脚手架。Caveman 把这一整套东西全部打包好了项目生成器一跑一个带路由、模板、ORM、中间件配置的完整骨架就躺在你面前。这篇文章我打算从实际使用的角度把 Caveman 2 的环境搭建、核心机制、真实项目实操、部署打包和排坑经验完整讲一遍适合想在 Common Lisp 里做 Web 开发但迟迟不敢动手的人抄作业。1. 先把话说清楚Common Lisp Web 开发的“痛点”与 Caveman 的解法1.1 为什么 Lisp 写 Web 会被嘲“老古董”圈外人一听“Lisp 写网站”第一反应多半是这不是上个世纪的东西吗说实话我自己早年也有这种偏见。Common Lisp 不是不能写 Web而是过去二十年的生态太“散装”了。底层 HTTP 服务器有 Hunchentoot 这类成熟方案但 Hunchentoot 只解决“接受 HTTP 请求并返回响应”这一层。你要处理路由得自己写 dispatch 逻辑要渲染 HTML得自己选模板库再手动绑定要解析表单得自己处理 URL-encoded 数据要管理会话又得自己接内存存储或者 Redis。这些工作不是做不到而是每个项目都得重来一遍效率和心情都扛不住。更麻烦的是Common Lisp 社区缺乏一个“标准答案”。Python 有 Flask 和 DjangoRuby 有 Sinatra 和 RailsJava 有 Spring它们背后是一套完整的最佳实践。CL 这边库不少但没人帮你把积木拼成乐高套装。每个开发者的项目结构都长得不一样看别人的代码就像在考古。这种局面下Caveman 的出现算是一个转折点。1.2 Caveman 给了我们什么Caveman 是日本开发者 Eitaro Fukamachi 主导的 Web 框架目前主流版本是 Caveman 2。它的定位非常明确做 Common Lisp 里的 Flask 或 Sinatra。它构建在 Clack 之上Clack 可以理解为 Lisp 世界的 WSGI——它定义了一套统一接口让你可以无缝切换底层的 HTTP 服务器实现。Caveman 的价值在于它把 Web 开发最常用的组件全部集成到了一个生成器里路由、请求参数解析、模板渲染、静态文件服务、会话中间件、ORM 集成开箱即用。我记得第一次跑(caveman2:generate-project demo)的时候看到生成的目录结构几乎感动因为这些文件不是空壳而是能直接启动的完整应用。src/下有主入口、配置、数据库、视图、Web 路由的拆分templates/下放 Djula 模板static/下放静态资源。你只要往里面填业务逻辑就行架构层面的东西都给你安排妥当了。1.3 和主流微框架的对比谁更接近“开箱即用”维度Flask (Python)Sinatra (Ruby)Caveman (CL)定位微框架微框架微框架路由样式装饰器DSLdefroute DSL默认模板Jinja2ERB/TiltDjulaORM 偏好SQLAlchemy 外接Sequel 外接Mito 内置配套服务器接口WSGIRackClack上手难度低低中等需要先适应 Lisp说 Caveman 和 Flask 是同类的确有道理但它有一个比 Flask 激进的地方它把数据库层也给了一个默认答案。Flask 不会替你决定用不用 SQLAlchemyCaveman 则是把 Mito ORM 直接塞进了生成骨架里。你要是不知道选什么数据库层那就直接用 Mito这套组合是经过实战验证的。这种“帮你做决定”的设计对新手来说反而是好事因为决策成本被大幅降低了。2. 从生成骨架开始10 分钟跑起你的第一个 Caveman 应用2.1 环境准备SBCL、Roswell、Quicklisp 三件套先把基础环境说清楚。我选 SBCL 作为 Common Lisp 实现原因很简单性能好、社区最活跃、Debug 信息友善。装好 SBCL 之后接下来要装 Quicklisp 和 Roswell。Quicklisp 是 CL 的包管理器Roswell 则是一个环境管理工具它可以帮你管理多个 Lisp 实现、跑脚本、构建可执行文件有点像是“CL 界的 nvm”。Roswell 不是强制要求但有了它开发和打包会舒服很多。macOS 上可以直接用 Homebrewbrew install sbcl roswellDebian/Ubuntu 上可以这样apt install sbcl curl -o roswell https://github.com/roswell/roswell/releases/download/v22.09.14.114/roswell_22.09.14.114_amd64.deb装好之后在 REPL 里加载 Quicklisp(ql:quickload :quicklisp)实际上 Quicklisp 的初次安装是通过官网下载quicklisp.lisp然后在 REPL 里(load quicklisp.lisp)再(quicklisp-quickstart:install)这一步会把它写进启动文件。装好后加载 Caveman 2 就是一条命令的事(ql:quickload :caveman2)这一步会自动把 Clack、Djula、Mito、Hunchentoot 等一堆依赖拉进来。第一次会比较久因为要下载编译之后就会快很多。2.2 生成项目骨架caveman2:generate-project环境就绪后生成一个项目非常简单(caveman2:generate-project caveman-bookmarks)这里我给项目起名caveman-bookmarks后面小节会用这个名字做一个完整应用。生成器会在当前目录下创建caveman-bookmarks/文件夹注意项目名字里如果带连字符生成的包名、ASDF 系统名都会保持一致这也是 Common Lisp 的命名惯例。生成的目录结构大致是这样的caveman-bookmarks/ ├── caveman-bookmarks.asd ├── README.md ├── src/ │ ├── config.lisp │ ├── db.lisp │ ├── main.lisp │ ├── view.lisp │ └── web.lisp ├── static/ │ ├── css/ │ └── js/ ├── templates/ │ ├── index.html │ └── _layout.html └── tests/caveman-bookmarks.asd是 ASDF 的系统定义文件告诉你整个系统有哪些文件、依赖哪些库。src/下是源码config.lisp管配置项db.lisp管数据库连接main.lisp是启动入口web.lisp写路由逻辑view.lisp负责模板渲染。这个拆分其实很接近工程化的分层小项目可以直接用大项目照着这个思路扩展也不会乱。2.3 第一次启动在 REPL 里看到你的首页进入项目目录启动 SBCL然后这样操作(ql:quickload :caveman-bookmarks) (caveman-bookmarks:start :port 5000)打开浏览器访问http://localhost:5000就能看到 Caveman 的默认欢迎页。这个页面虽然简陋但证明从 HTTP 服务器到路由再到模板渲染的整条链路已经跑通了。第一次启动时如果有编译警告不用太紧张只要没有 error 就能用。这里要提一个开发时非常舒服的点Caveman 默认开启的 Clack 服务器是 Hunchentoot你在 REPL 里改完代码直接重新编译那个文件就能让改动生效不需要重启进程。这种“改完立刻跑起来”的反馈速度是传统编译语言很难给的。3. 核心机制拆解defroute、参数、模板和中间件3.1 路由是怎么工作的defroute 的魔法Caveman 最核心的 API 就是defroute它的写法和 Sinatra 非常像(defroute / () (render #Pindex.html))这一段代码表示当用户访问根路径/时渲染index.html模板。defroute是宏它在编译期就把路径模板解析好了所以运行时的路由匹配非常快。路径参数用冒号开头(defroute /bookmarks/:id (key id) (format nil 查看书签 ~A id))当用户访问/bookmarks/123时变量id会被绑定为字符串123。需要注意Caveman 的路由参数默认拿到的都是字符串需要真正数值时要自己parse-integer这一步很多新手会忘记。指定 HTTP 方法也很简单用列表形式即可(defroute (/bookmarks :method :POST) () 新增书签)我习惯把一组资源的 GET、POST、DELETE 路由写在一起这样业务逻辑集中在同一个角落里后期维护时一眼能找到。3.2 取参数、重定向、Cookie 这些高频动作框架好不好用看它处理高频动作有多顺手。Caveman 在这一点上做得相当务实常用操作都有对应的辅助函数。获取查询参数或表单参数(defroute (/bookmarks :method :POST) () (let ((url (get-parameter url))) (format nil 当前 URL 是~A url)))重定向(redirect /bookmarks)设置 Cookie(set-cookie user_id 123 :max-age 3600)这些函数在处理请求的上下文中可以直接调用不需要自己去解析请求头、响应头。对写过裸 Hunchentoot 的人来说这种体验简直是坐电梯和爬楼梯的区别。3.3 模板渲染 Djula变量、循环和布局复用Caveman 默认的模板引擎是 Djula语法和 Django/Jinja2 非常接近。最基础的用法是给模板传一个变量列表(defroute / () (render #Pindex.html (list :bookmarks (mito:select-dao bookmark))))这里的render是 Caveman 帮你封装好的函数默认从templates/目录下找模板文件。在模板里你可以这样用ul {% for bookmark in bookmarks %} lia href{{ bookmark.url }}{{ bookmark.title }}/a/li {% endfor %} /ulDjula 的{% for %}循环、{% if %}条件和{{ variable }}表达式都是从 Jinja2 那一路过来的写惯了 Django 的人几乎零学习成本。布局方面Caveman 生成的模板结构中一定有一个_layout.html它相当于 Django 里的 base.html用 block 机制实现页面复用!DOCTYPE html html head title{% block title %}{% endblock %}/title /head body {% block content %}{% endblock %} /body /html子模板里写{% extends _layout.html %} {% block content %} p这里是我的页面内容/p {% endblock %}这样公共的导航、页脚、CSS 引用只写一次就够了页面多了之后能省下大量重复劳动。3.4 中间件和静态文件Caveman 都替你接好了Caveman 的make-app在生成应用时已经默认挂载了会话中间件和静态文件中间件。静态文件放在static/目录下直接通过/static/...路径访问。比如你把style.css放在static/css/style.css模板里就能写link relstylesheet href/static/css/style.css这背后是 Clack 的中间件机制在起作用以:static中间件将静态文件映射到 URL 路径上。如果你需要加自己的中间件比如日志记录或者自定义拦截逻辑可以在main.lisp里构造 app 时通过:middleware参数指定。不过就大多数小型 Web 应用而言默认配置已经足够不用动手改。4. 完整实例做一个带 SQLite 存储的“穴居人书签”应用前面讲了工具和能力这里来一个完整实战。我做一个极简的书签应用功能只有三个展示所有书签、新增书签、删除书签。数据库用 SQLiteORM 用 Caveman 默认集成的 Mito。这个案例基本覆盖了模板渲染、表单处理、数据库读写和带参数路由四个核心场景。4.1 用 Mito 定义 Bookmark 模型打开src/db.lisp这个文件默认已经写好了数据库连接函数通常长这样(defpackage caveman-bookmarks.db (:use :cl :mito :caveman-bookmarks.config) (:export :connect :bookmark)) (in-package :caveman-bookmarks.db) (defvar *db* nil) (defun connect () (setf *db* (mito:connect-toplevel :database-type :sqlite3 :database-name bookmarks.db)))connect用的mito:connect-toplevel是 Mito 建立顶层数据库连接的入口指定 SQLite 类型和数据库文件名即可。如果数据库文件不存在SQLite 驱动会在第一次连接时自动创建。然后在同一个文件里定义模型类(mito:defclass bookmark () ((url :col-type (:varchar 2083) :initarg :url :accessor bookmark-url) (title :col-type (:varchar 255) :initarg :title :accessor bookmark-title)) (:metaclass mito:dao-table-class))这段代码看着有点魔法其实核心就两点一mito:defclass定义了bookmark这个 ORM 实体二url和title两个字段对应数据库的 VARCHAR 列。(:metaclass mito:dao-table-class)让这个 CLOS 类拥有 DAO数据访问对象能力Mito 会自动把对象的槽位映射成数据库表列。定义好模型后在 REPL 里执行建表(ql:quickload :caveman-bookmarks) (caveman-bookmarks.db:connect) (mito:create-table (find-class caveman-bookmarks.db:bookmark))这里要特别注意建表的前提是已经调用了connect不然 Mito 会报没有活跃连接。这一步新手经常跳过去直接查数据结果整个应用跑起来就报错。4.2 书签列表页读数据并渲染模板现在回到src/web.lisp这个文件是路由仓库。我先去掉生成器给的默认首页路由改成一个展示所有书签的列表页。为了让web.lisp里的代码能引用到db.lisp里的模型需要在web.lisp的 defpackage 里加上对caveman-bookmarks.db的引用。生成器默认已经帮你把这个依赖关系列好了所以你直接用就行。(in-package :caveman-bookmarks.web) (defroute / () (render #Pindex.html (list :bookmarks (mito:select-dao bookmark))))mito:select-dao接收一个类名返回这个表的所有行对应的对象列表。把列表传给模板模板循环渲染即可。修改templates/index.html{% extends _layout.html %} {% block content %} h1穴居人书签/h1 form action/bookmarks methodpost input typetext nameurl placeholderURL input typetext nametitle placeholder标题 button typesubmit添加书签/button /form ul {% for bookmark in bookmarks %} li a href{{ bookmark.url }} target_blank{{ bookmark.title }}/a form action/bookmarks/{{ bookmark.id }}/delete methodpost styledisplay:inline button typesubmit删除/button /form /li {% empty %} li还没有书签先加一个吧/li {% endfor %} /ul {% endblock %}这里露出了一个关键信息Mito 的 DAO 对象会自动拥有id槽位这是dao-table-class默认给每个模型加的代理主键。所以在模板里直接{{ bookmark.id }}就能拿到自增 ID。4.3 新增书签POST 路由与表单处理列表页里的表单用 POST 提交到了/bookmarks现在实现这个路由。在web.lisp里补一段(defroute (/bookmarks :method :POST) () (let ((url (get-parameter url)) (title (get-parameter title))) (unless (or (null url) (null title)) (mito:insert-dao (make-instance bookmark :url url :title title)) (redirect /)) (redirect /)))这段逻辑很简单取表单参数、创建 DAO 对象、插入数据库、重定向回首页。我加了一个unless判断防止空数据直接插入。这里的redirect是 Caveman 内部辅助函数它会给浏览器返回一个 303 或 302 响应让用户重新请求列表页。这种做法叫 Post/Redirect/Get 模式能避免刷新页面时重复提交表单。4.4 删除书签带参数路由里的数值转换删除功能的表单把 POST 请求发到了/bookmarks/{{ bookmark.id }}/delete对应路由这样写(defroute (/bookmarks/:id/delete :method :POST) (key id) (let ((bookmark (mito:find-dao bookmark :id (parse-integer id)))) (when bookmark (mito:delete-dao bookmark)) (redirect /)))这里藏着 Caveman 新手最容易踩的坑之一路径参数id是字符串。我特意写了(parse-integer id)做转换如果不转换Mito 会把字符串1拿去做数值字段查询SQLite 在某些情况下会隐式转换但更多时候会查出奇怪问题或者直接报类型错误。养成“路径参数拿到手就转换”的习惯能少踩很多坑。经过这几个路由一个完整的 CRUD 就已经跑通了。实际上对于很多内部工具、后台面板、数据展示网站来说这就是全部需要的功能了。4.5 启动配置与数据持久化注意点把所有代码写完、(caveman-bookmarks:start :port 5000)一跑应用就能用了。但这里有个容易让人困惑的点bookmarks.db数据库文件到底生成在哪里答案是取决于你在哪个目录启动的 REPL。SBCL 的默认当前目录就是数据库文件的创建位置。如果你想让数据库文件固定放在项目目录下可以在connect里写绝对路径(defun connect () (setf *db* (mito:connect-toplevel :database-type :sqlite3 :database-name (merge-pathnames #Pbookmarks.db (asdf:system-source-directory :caveman-bookmarks)))))asdf:system-source-directory返回项目根目录路径这样合并出来的地址就是项目根目录下的bookmarks.db。这个细节在开发时无所谓部署到服务器上之后如果工作目录不对数据库连接就会失败提前处理好能省去后面的排查时间。5. 部署到生产这才是真正考验 Lisp 的地方5.1 选后端服务器Hunchentoot 稳Woo 快Clack 的默认后端是 Hunchentoot它实现了完整的 HTTP/1.1 协议稳定性非常好我做日常开发时就直接用它。但在生产环境面对更高并发流量时可以考虑切换到 Woo。Woo 是构建在 libuv 上的异步 HTTP 服务器在不少基准测试中比 Hunchentoot 快出几倍。切换方式很简单(clack:clackup (app) :server :woo :port 5000)如果你不传:serverClack 默认就用 Hunchentoot传了:woo之后服务器底层就换了。因为这个差异被 Clack 屏蔽了所以应用代码完全不用改。不过我不建议一上来就追性能先看自己的场景。如果只是给公司内部几十个人用的工具Hunchentoot 绰绰有余。Woo 更适合那些真的需要压榨单机性能的场景。5.2 打成单一可执行文件的两种姿势Common Lisp 应用部署的一个巨大优势就是可以打包成单一的可执行文件目标机器上不需要任何 Lisp 运行时。第一种方式是用 SBCL 提供的save-lisp-and-die(sb-ext:save-lisp-and-die bookmark-server :toplevel #caveman-bookmarks:main :executable t)这里的main是 Caveman 骨架里定义好的入口函数它负责解析命令行参数并启动服务器。执行完这句之后当前目录下会出现一个bookmark-server二进制文件直接扔到服务器上就能跑。第二种方式是用 Roswell它可以把入口脚本打包成可执行文件。Roswell 的优势在于更容易整合多个文件、更容易跨平台还能用ros build写成构建脚本放进 CI。5.3 进程守护用 systemd 把服务挂起来实际生产环境里不可能让一个二进制文件挂在终端前台跑否则你一旦退出 SSH进程就没了。推荐用 systemd 做守护。一个极简的 service 文件长这样[Unit] DescriptionCaveman Bookmark Service [Service] ExecStart/opt/bookmarks/bookmark-server --port 8000 WorkingDirectory/opt/bookmarks Restartalways Userwww-data [Install] WantedBymulti-user.target写好后放到/etc/systemd/system/bookmarks.service然后systemctl daemon-reload systemctl enable bookmarks systemctl start bookmarks以后更新代码只需要替换二进制文件再systemctl restart bookmarks整个流程非常干净。如果机器上连 systemd 都没有那用 supervisord 或其他进程管理器同理核心就是保证进程常驻并在崩溃后自动拉起。5.4 UTF-8 和字符编码中文乱码的根源这是我最开始踩得最狠的坑。Caveman 默认的编码处理在大多数情况下正常但你一旦在代码里写了中文字面量或者往 SQLite 里存中文就有概率出现乱码。原因是 SBCL 默认的外部格式可能不是你预期的 UTF-8。解决方法是在入口函数里强制设置外部编码。在main.lisp的main函数里加上(defun main () (setf sb-impl::*default-external-format* :utf-8) (start :port (parse-integer (or (second (uiop:command-line-arguments)) 5000))))*default-external-format*这个变量是 SBCL 内部的它控制文件读写、字符串编码转换等默认行为。设成:utf-8之后中文的生命周期就顺畅了。虽然这个变量名带impl字样看起来不像官方 API但在 SBCL 社区这几乎是约定俗成的做法。另一个和编码相关的坑是 HTTP 响应头。如果发现浏览器显示的字符是乱码检查一下响应里的Content-Type是否带charsetutf-8。Hunchentoot 通常会处理但如果你手动设置过响应头很容易丢掉这个参数。6. 常见问题与排查技巧实录6.1 依赖加载失败、REPL 里提示包不存在我身边的 CL 新手遇到最多的报错是Package CAVEMAN2 does not exist或者Cannot FIND package。大多数情况都是 Quicklisp 没有正确加载过 Caveman2。先执行(ql:quickload :caveman2)确认加载成功之后再使用caveman2:generate-project。如果 Quicklisp 里的 dist 版本太旧也可能会拉不到较新的依赖。常见解法是更新 Quicklisp(ql:update-dist :quicklisp)还有一种情况是生成后的项目里引用了某个不在 Quicklisp dist 里的开发版依赖。这时可以把对应仓库手动 clone 到 Quicklisp 的local-projects目录下Quicklisp 在后续加载时会优先使用本地版本。6.2 端口被占用、服务起不来启动时如果 Hunchentoot 提示Address already in use说明这个端口已经被别的进程占了。先排查一下lsof -i :5000找到占用进程杀掉或者换个端口启动。Caveman 的start函数第三个参数是:port改一下重启就行。我自己在开发时经常遇到多个 Lisp 进程同时挂着导致端口冲突所以后来养成了一个习惯每次启动前先看一眼本机有没有旧进程。6.3 模板找不到、渲染不生效render找不到模板文件通常是因为模板没放在templates/目录下或者文件名大小写不匹配。Linux 文件系统对大小写敏感Index.html和index.html是两个完全不同的文件这一点容易被 Windows 用户忽略。另一个现象是改了模板但页面没有变化。Djula 默认会缓存编译后的模板开发模式下你需要手动关闭缓存或者在每次改完模板后重新调用编译函数。Caveman 的view.lisp里会定义模板目录和渲染辅助函数如果你想省心可以查一下 Djula 的*djula-execute-package*设置确保模板运行环境指向了正确的包。6.4 数据库报错未连接、未建表、字段对不上数据库相关的报错一般分三种。第一种是No database is connected解决方法是启动应用前调用(caveman-bookmarks.db:connect)。这个连接是全局的一旦建立后续操作都能用。第二种是Table bookmark doesnt exist说明建表没执行。注意 Mito 的create-table只要执行一次但它不会自动建表必须手动调用。生产部署时我会在启动脚本里增加一个检查逻辑——如果表不存在就自动建表省得上线时顿一下。第三种是字段对不上比如往bookmark表插入数据时报no such column: url。这通常是因为模型改了字段定义但表结构没更新。SQLite 不支持直接改列最省事的办法是删表重建或者用迁移脚本处理。开发阶段可以直接(mito:table-return) (mito:create-table)? 不对正确的做法是手动 DROP TABLE 再 create-table生产环境务必谨慎操作。6.5 排查技巧速查表症状最常见原因快速解法依赖加载失败Quicklisp dist 旧/缺包ql:update-dist或 clone 到 local-projects端口起不来端口被占用lsof查进程换端口或杀进程中文字符乱码SBCL 默认外部格式不对设置*default-external-format* :utf-8模板没变化Djula 缓存关闭模板缓存或重启服务数据库报错没连接或没建表先connect再create-table路径参数类型不对参数默认是字符串手动parse-integer转换这些坑没有一个是原理复杂的但每个都能卡住你半小时以上。我建议你把这些记下来等真正遇到的时候直接对照排查省下的时间都能多写一个路由。我个人在实际项目里对 Caveman 的定位是如果我要做高并发的对外网站我不会首选 Lisp 生态但如果目标是内部工具、数据后台、开发面板这类“够用就好、开发要快”的场景Caveman 的体验真的非常舒适。整个开发过程像在 REPL 里捏陶器改完代码立刻就能看到效果反馈速度快得离谱。想尝试的朋友建议先照着这篇文章做一遍书签应用把路由、模板、Mito 这几条主线跑通你已经比 90% 只“看过文档”的人更进一步了。后面再根据自己的需求去扩展中间件、接入 Redis、做鉴权那都是水到渠成的事。
返回列表