☰
Caveman框架实战:用Common Lisp快速搭建内部日志分析工具
2026/10/28 3:08:46 网站建设 项目流程

caveman这名字,第一次看到是在Lisp社区的邮件列表里,我愣了一下,哪有人会给自己Web框架起名叫“原始人”。后来我要在Common Lisp生态里做一个内网日志分析工具,原本想着用现成的Web库拼一拼,结果被各种接口约定和依赖关系烦得够呛。转了一圈,最后真的把手伸向了这个代号caveman的小框架,花了一个周末把工具页搭出来。这篇东西不是框架官方的文档翻译,就是我自己从搭环境到上线的实操记录,里面有选它的理由、项目怎么分层、哪些配置坑过我、以及最后怎么打包成一个可执行文件。你要是想在Common Lisp里快速出一个小工具Web应用,不想被复杂架构绑架,可以参考参考。

1. 为什么一个Common Lisp老家伙,最后会选caveman

1.1 项目背景:不是炫技,是真的要干活

先说清楚这个项目到底长什么样。我要做的不是给人看的官网,也不是高并发的业务后端,而是一个给组里几个同事用的内部小站:每天上传日志文件或者从数据库拉出当天的访问记录,按关键字过滤,把异常状态码标红,最后导出成CSV。界面就有三张表单,几个统计表,不需要登录体系,不需要权限分级,更不需要评论区。

这种工作如果丢给一个团队,大家多半会用Python写个Flask或FastAPI,一个小时出原型。但问题在于,我这边工具链多数时候在Common Lisp里,日志处理代码已经写好了一大半,包括文本清洗、统计聚合、规则匹配。硬要换编程语言等于把前面那些测试和重跑逻辑再整一遍,这不合理。所以唯一的选择就是,在Common Lisp里找一个Web框架,把我手头的处理模块暴露成HTTP接口。

这个场景和很多人想的不一样:不是先有了框架,再决定做什么产品,而是先有一堆Excel表格之前的东西,比如脚本和函数库,然后被迫找一个能快速套壳的Web层。caveman在这种处境里,刚好比其他方案顺眼。

1.2 选型对比:为什么没有用一个更“主流”的方案

Common Lisp生态里做Web,大致有几个路线:直接用Hunchentoot这个HTTP服务器,或者用更上层的封装比如Clack,再往上才是caveman。如果你只看社区文档,Hunchentoot听起来最正统,支持HTTP/1.1 keep-alive、SSL、多线程,还有很多底层控制能力。很多老教程教你用Hunchentoot处理一切,你自己也真的能学到很多东西,比如一个请求是怎么从TCP连接一步步走到业务回调里的。

但问题也在这里。Hunchentoot太贴近HTTP协议了,它不会管你的路由怎么组织,也不关心HTML模板在哪,更不会替你做JSON序列化。我在早期尝试时,常常发现自己写的东西有一半是实现那些“别的框架免费送的”边角工程,比如请求体解析、Cookie存取、静态文件处理、错误页。Caveman这一类框架的价值就体现在这里:它把Hunchentoot和Clack包了一层,给了一套固定的项目组织方式,让你一打开编辑器就知道控制器该放哪、模板该放哪、URL怎么注册。

和Flask、Rails之类的重量级框架相比,caveman本身很克制,不该给你做的东西绝对不越界。没有自动生成一大堆CRUD代码,没有自带复杂的ORM抽象,也不会在目录里铺满几十个配置文件。它的思路反而是,你必须自己在代码里把逻辑写清楚,框架只保证请求能正确到你的函数、响应能正确回到浏览器。对我这种不想被“约定优于配置”绑架的人来说,这反而舒服。

1.3 Caveman真正擅长解决什么问题

说人话:caveman擅长的是让你用最少的心智负担把一个Lisp函数变成可访问的网页功能。最适合的场景就是内部工具、管理后台、数据展示面板、自动化任务的Web外壳、快速原型。不合适的是什么?你要是打算做一个面向百万用户的公开站点,或者需要团队十几个人并行协作长期维护,那确实别拿它当什么大平台的地基,它不是干这个的。

我当时还有一个非常现实的考量:Common Lisp的线程模型和错误处理跟我后端代码的交互方式,已经确定了。我希望框架不要抢我的错误处理,比如某个统计函数跑挂了,最好直接把堆栈信息打出来,不要给我套一层什么“可读性优雅页面”。caveman这名字虽然听着原始,但它的默认行为其实很老派工程师:异常就异常,日志记清楚,页面再难看也没关系,内网工具没有人看你配色。

还有一点让我很放心:它的路由写法非常直接,直接用defroute宏声明URL路径和参数,不需要先读一堆路由配置文件再猜它到底指向哪个函数。这种“所见即所得”的写法,省下了很多来回翻文件的精力。

2. 项目骨架与核心设计

2.1 生成器给出的第一印象:装完就能跑

caveman官方提供了一个项目生成器,用法非常简单,在REPL里执行:

(ql:quickload :caveman) (caveman:generate-project "logview")

执行完,目录结构大概是这样:

logview/ ├── README.md ├── app.lisp ├── config.lisp ├── db.lisp ├── main.lisp ├── public/ │ ├── css/ │ └── js/ ├── src/ │ ├── admin.lisp │ ├── web.lisp │ ├── ... ├── views/ │ ├── index.html │ └── ... ├── test/

第一次看到这个结构,其实我是有点看不上眼的,怎么这么简单,不像某些框架一生成就铺满几十个模块。但真正跑起来以后,我理解了它的设计初衷:它给你一个可以运行的起点,而不是一个包含所有可能的空壳。你不需要在第一周先装修一个注定要拆的样板间,而是直接在毛坯房里拉水电。

除了目录,生成器还会给你一个app.lisp,里面是一个默认的首页路由。你只要运行(logview:start),浏览器访问http://localhost:5000就能看到一个欢迎页。这个“先能跑”的状态极其重要,搭建Web项目的信心全靠第一步,要是第一步就倒在依赖地狱里,那项目大概率就烂尾了。

2.2 目录和文件拆解:每一个文件是干什么的

很多人的误区是看到一堆.lisp文件就开始手动给它们分类。实际用下来,我的建议是:先不要扩展目录,生成器给什么就用什么,最多加一个src/utils.lisp放你自己的函数。

  • app.lisp是应用的入口,负责创建实例、启动server、注册Clack handler。
  • config.lisp是配置入口,端口、host、数据库连接串、日志等级这些东西我都习惯放这里。
  • db.lisp用来放数据库连接。如果你的工具根本不用数据库,这个文件可以一分钟清空。
  • src/下面放真正的控制器逻辑,也就是那些路由函数。
  • views/放模板,默认引擎是Djula,也就是Django模板的Lisp移植版。
  • public/放纯静态文件。

我的做法是,把日志处理的纯函数全部写在src/core.lisp里,这部分不接受任何Web框架的依赖,理论上可以脱离caveman单独测试。然后另写一个src/web.lisp,里面的函数只是把HTTP参数接进来,调到core.lisp的函数上,再把结果组装成响应。这样的分层让整个项目保持了两层逻辑,一层是纯粹的数据加工,另一层是简单的接口适配,互不污染。

2.3 路由和控制器设计:defroute是最好用的那一部分

caveman的路由入口是defroute,用法直观到几乎不需要思考:

(caveman:defroute app "/ping" (&key) (format nil "pong"))

这个宏会在实例app上注册一个URL规则,后面的参数列表就是可以从请求里提取到的值。比如:

(caveman:defroute app "/log/:id" (&key id) (format nil "log id: ~A" id))

URL里面带冒号的片段会自动变成参数,不需要你手动去parse-query-string,也不用写什么复杂的动态路由匹配。我写了一个分页接口,长这样:

(caveman:defroute app "/api/logs" (&key page size keyword) (let ((page (parse-integer (or page "1") :junk-allowed t))) (list :json (query-logs :keyword keyword :page page :size (parse-integer (or size "50") :junk-allowed t)))))

注意我返回的是一个list :json结构。caveman会自己识别,把后续内容走JSON编码,然后设置响应头的Content-Type为application/json。这一点是我最喜欢的,你不需要在每个控制器里手工拼接JSON字符串,更不会犯忘记设置响应头这种低级错误。

回到路由设计,我只有一个原则:能用路径参数表达的就不要用Query String。比如查单条日志,优先/log/:id,而不是/log?id=123。这样API日志里一眼就能看出访问的是什么资源,排查起来也直观。另一个细节是,前缀相同但功能不同的路由千万别藏在两个文件里,caveman的路由顺序就是注册顺序,所以我习惯把和日志相关的路由全部集中在一个Lisp文件里,按功能分组,而不是按URL长度分组。

3. 实操过程:把caveman跑出第一行可用的功能

3.1 搭建Common Lisp环境,不推荐硬啃配置

大多数人听到Lisp,第一反应是“我没有IDE”。其实现在不需要多复杂的环境。我在Linux服务器上用的组合是:SBCL作为运行时,Quicklisp作为包管理器,Emacs配SLIME当编辑器,但如果你不习惯Emacs也可以只用SBCL在终端里写。

安装SBCL在Ubuntu上就是一条命令:

sudo apt install sbcl

然后装Quicklisp:

curl -O https://beta.quicklisp.org/quicklisp.lisp sbcl --load quicklisp.lisp

在REPL里执行:

(quicklisp-quickstart:install) (ql:add-to-init-file) (ql:quickload :caveman)

第一次执行ql:quickload会把Caveman以及它的依赖都拉下来,这个过程可能持续几分钟。如果网络环境一般,可以用国内的镜像源,或者预先把Quicklisp的dist缓存拉好再离线安装。这里没有太多可提的性能问题,一次装好,后面就顺了。

3.2 从生成项目开始,而不是从空目录开始

我见过不少人喜欢在空目录里手写一切,美其名曰“理解原理”。对于caveman这种自带生成器的框架,我强烈建议用生成器起手,因为生成器拉出来的默认代码包含了正确的最小配置,你想改的前提也是先有一个能跑的东西。

生成完项目,先运行一次默认的欢迎页,确认SBCL、Quicklisp、Caveman三者联动没问题。这一步过了,后面所有坑都变成了代码问题而不是环境问题。我进入一个新项目时,宁可多花十分钟做这种“最小冒烟测试”,也不要一头扎进功能开发,结果调半天发现是端口被防火墙挡了。

启动项目的方式是:

(ql:quickload :logview) (logview:start)

如果你改动了代码,想重新加载,一般是:

(ql:quickload :logview :force t)

force t表示强制重新加载,不依赖缓存的fasl文件。这也是我日常开发最常用的一个命令,改完Lisp文件后敲一下,比整体重启REPL快多了。

3.3 写一个真正的功能:日志查询接口

下面我贴一段我从项目里简化出来的示例,作用是根据时间范围、关键字和状态码筛选日志:

(defun query-logs (start-time end-time keyword &key status) (remove-if-not (lambda (entry) (and (or (null keyword) (search keyword (getf entry :message))) (or (null status) (= status (getf entry :status))))) (read-entries-between start-time end-time)))

这不是caveman的代码,是我自己的纯逻辑部分。然后我在Web层包一截:

(caveman:defroute app "/api/search" (&key start end keyword status) (let* ((start (parse-timestamp start)) (end (parse-timestamp end)) (result (query-logs start end keyword :status (when status (parse-integer status))))) (list :json result)))

整个过程就是这样,控制器并没有复杂到什么程度,它只是负责把HTTP字符串转成Lisp数据类型,然后交给核心函数,再序列化回JSON。真正复杂的逻辑全在纯函数里,这也让单元测试变得容易,我不用开着Web服务就能测query-logs。

这里有个小技巧想提醒你:HTTP参数全都是字符串,所以status为0和没传参数一定要区分开,不然你想象中是过滤状态码0,实际却变成不过滤。我在代码里用了(when status ...)判断,确保只有参数真的存在时才转换类型。这种边界情况在内部工具里尤其容易出现,因为使用者不会像测试工程师一样帮你造数据。

3.4 模板渲染与前端交互:别把页面写复杂

到了页面展示环节,caveman默认用的模板引擎是Djula,它的语法对用过Django模板的人来说几乎零门槛。例如:

{% extends "base.html" %} {% block content %} <table> {% for row in rows %} <tr> <td>{{ row.time }}</td> <td>{{ row.level }}</td> <td>{{ row.message }}</td> </tr> {% endfor %} </table> {% endblock %}

控制器里要做的,只是把变量丢进模板上下文:

(caveman:defroute app "/" (&key) (render-template "index.html" (list :rows (recent-logs 20))))

官方支持把模板输出当成响应体,也可以直接返回一个字符串。我自己的习惯是,把需要重量的表格页面交给模板,把需要被脚本消费的数据接口全部返回JSON。两者在同一个caveman项目里可以共存,前端代码直接拿fetch请求后端接口,然后在前端做DOM更新,避免每次刷新都整页重载。

我还发现一个不太起眼但很实用的特性:caveman返回(list :json ...)为JSON时,如果你在开发模式下,它会把请求的细节一并在日志里打出来,包括路由、时间、状态码、响应大小。靠这个日志,我能很方便地定位跟前端联调时的参数问题,而不用像以前那样在浏览器开发者工具和服务器日志之间来回切。

4. 中间件、静态资源和数据访问的取舍

4.1 用Lack中间件控制请求生命周期

caveman底层用的是Clack规范,你可以把它理解成Python Web里的WSGI、Ruby里的Rack。这意味着你可以在应用外层叠加一系列中间件。我项目里用到最多的有三个:静态文件服务、访问日志、异常显示。

在app.lisp里,我的最终配置大概长这样:

(lack:builder (:static :path "/public" :root #p"/path/to/logview/public/") (:accesslog :format :apache) (:backtrace) app)

:static中间件解决的是CSS、JS这类静态资源的暴露问题。Caveman项目生成的时候会给你一个public/目录,但如果你不挂对应的静态中间件,浏览器请求/public/style.css是拿不到文件的,这个坑我第一次用的时候踩过,后来才搞清楚。

:accesslog是请求日志,格式可以设成:apache,这样日志格式跟nginx默认格式类似,后续导入日志分析系统也不用专门写解析规则。如果对日志字段有自己的要求,比如要记录用户自定义的X-Header,也可以自己写一个小的Clack中间件函数。

4.2 数据库访问:没有绑定,反而更自由

caveman本身不绑定数据库,它的作用域只到路由和视图这一层。到底用不用数据库、用什么数据库,全看你自己。我早早在db.lisp里放了依赖:

(defparameter *db-host* "localhost") (defparameter *db-name* "logs") (defparameter *db-user* "ops") (defparameter *db-pass* File not found: unknown

如果你不想引入OR M,也可以直接用PostgreSQL的底层驱动postmodern,执行原生SQL。对内部工具来说,原生SQL往往比ORM更直观。举个例子,我要查最近一小时的错误日志:

(defun recent-errors (minutes) (query (:select '* :from 'access_log :where (:and (:> :time (now-interval minutes 'minute)) (:= :status 500))) :connection *conn*))

这里完全绕开了ORM的自动建表、自动迁移那一套。我觉得这个设计很合理:框架管框架的事,数据访问管数据访问的事。如果我把逻辑都写在框架的约定里,将来换框架就麻烦了。现在我所有数据访问都集中在db.lisp,哪天caveman不维护了,我只需要重写路由层的适配,核心代码不用挪动。

4.3 几个我后来固定下来的配置项

配置不一定多复杂,但一定要能改。我的config.lisp最终长这样:

(defparameter *config* '(:host "0.0.0.0" :port 8080 :debug t :use-thread t))
  • :host "0.0.0.0"表示监听所有网卡,这样同事才能从内网其他机器访问,否则默认只听本机回环地址。
  • :port 8080是我固定的端口。注意如果端口被占,caveman启动时会直接报错,日志里会写“address already in use”。
  • :debug t开启调试模式,出现异常时会把堆栈打到响应页面上。开发期我很依赖这个,上线前我会把它关掉,改成统一返回500页和日志。
  • :use-thread t开启线程模式,让每个请求在线程中处理。虽然内部工具并发量不高,但这个开关没必要关。

5. 依赖管理、打包与部署的完整流程

5.1 Quicklisp让你装库,但项目自己要版本化

使用Quicklisp最大的好处是装依赖很方便,缺点是它默认只装“最新稳定版”,不会为你的项目做一个严格锁定的环境。这意味着同一个环境里如果升级了某个库,可能导致caveman应用行为变化。

我这里推荐的保守策略,是在项目的README.md里记录Quicklisp dist的日期或版本。比如“依赖的是2024年某月版本”。更严格的做法是使用ql:bundle-system把依赖固定到某个目录,我在打包部署时才会这么做,开发阶段其实用不上那么严谨。

如果你要写一个小型可复用模块,也可以用ASDF系统定义,在.asd文件里写明依赖。比如:

(defsystem "logview" :serial t :components ((:file "packages") (:file "config") (:file "db") (:file "src/core") (:file "src/web") (:file "app")) :depends-on (:caveman :djula :postmodern))

这样每次加载logview时,Quicklisp会按.asd里的依赖顺序自动处理,不依赖你手工逐个quickload。依赖管理这件事,别懒,项目能稳定复现比什么都重要。

5.2 把应用打包成单一可执行文件

Common Lisp的传统工作流是在REPL里开发和运行,但部署到服务器上,我不希望还得专门下一个SBCL环境再执行源码。我选择用SBCL自带的save-lisp-and-die把程序保存成一个可执行文件。做法是在REPL里加载完所有代码后:

(sb-ext:save-lisp-and-die "logview-server" :executable t :toplevel #'logview:start)

这一步执行完,目录下就多了一个logview-server二进制文件。把它拷贝到服务器上直接运行就可以。我第一次成功的时候想说原理很简单,但实际使用中还是要小心:save-lisp-and-die保存的是当前内存镜像,所以你必须确认所有依赖都已经加载完毕、全局状态初始化正确,否则生成的二进制可能缺东西。

后面我改用Roswell来做这个事,因为它封装了环境管理、脚本运行和构建发布,直接写一个roswell/logview.ros脚本就行。但对于只想跑内网工具的人来说,SBCL自带的这个已经够了。如果部署机器上不想装SBCL,这个静态打包方案才是真正的出路。

5.3 HTTP服务器怎么选才不浪费力气

Caveman默认的后端HTTP服务器是Hunchentoot,它成熟稳定,支持多线程,文档也多。但也有人会在Clack层换成别的服务器,比如woo。我试过后者的体验是:并发性能更强,代码更轻量,但某些极端错误处理和Hunchentoot的习惯不太一样。内部工具还是“追求稳”优先,所以我没有换,一直跑Hunchentoot。

要说注意什么,最需要盯的是线程安全。我自己的核心函数暴露给多个请求线程同时调用时,就要小心共享变量的竞争。如果你在写Web应用时用了全局哈希表,多线程并发读写可能要加锁或者用线程安全的库。这一点挺重要,caveman不会替你做这件事,因为它把“共享数据”的决策完全交给你了。

6. 踩坑实录:这套框架栽跟头的地方都集中在这里

6.1 中文路径和请求参数编码

这是我第一个遇到的实际问题。同事在搜索框里输入中文关键字,结果接口里拿到的是乱码字符串,查不到任何日志。排查后确认,curl直接传中文没问题,但从浏览器走的请求要经过URL编码,caveman的默认参数解析没有把%XX百分号编码完整还原成UTF-8字符串。

我的方案是,前端传参时使用encodeURIComponent把中文转成百分号编码,后端再用(quri:url-decode)处理一遍:

(caveman:defroute app "/api/search" (&key keyword) (let ((keyword (and keyword (quri:url-decode keyword)))) ...))

这类问题只有在真实浏览器环境里才会暴露,单元测试很难发现。建议大家在做完一个带中文参数的接口后,马上用浏览器的开发者工具实际发一次请求,而不是只在REPL里测函数。

6.2 开发热更新别硬重启

传统Common Lisp开发非常依赖REPL的增量编译,但Web应用里面涉及路由注册,如果只是改了一个函数体,直接重新编译函数就能生效。如果新增了一条路由,需要重新执行定义这条defroute的顶层表单。最省事的做法是重新加载整个文件:

(load "src/web.lisp")

或者用Quicklisp整体强制刷新:

(ql:quickload :logview :force t)

这种方式比重启进程快很多,而且保留了内存中的缓存数据。我遇到过一种非常隐蔽的问题:改了模板文件,但浏览器还是显示旧页面。这个不是caveman的问题,是Djula的模板缓存。解决办法是清掉模板缓存或重新加载模板模块。你可以把模板目录设成一个开发专用的、每次请求都重新解析的模式,或者干脆编辑后重启一下,代价也不大。

6.3 并发阻塞和共享状态

内部工具本来并发量不高,但有一次我做了个报表导出接口,因为处理数据量特别大,一个请求几乎占满了主线程。同事那边再打开首页就卡住了。后来我把耗时的导出任务放在单独的后台线程里跑,接口里只返回“任务已提交”,等处理完成后再去另一个接口取结果。

这就是Caveman和很多现代框架的区别:它不会帮你做异步任务调度,也没有内置队列。你得知道服务器是多线程处理的,但你的代码不能让某个请求无限期占据CPU。一个简单的解决办法是使用sb-thread:make-thread起新线程执行重任务,再用一个线程安全的哈希表保存任务状态。逻辑不复杂,但没有这个意识的人第一次都会卡一卡。

6.4 静态文件缓存奇怪病

页面的CSS和JS改了好几次,但是同事浏览器里永远加载旧版本。原因很简单,静态文件请求到了浏览器后,HTTP缓存策略按标准走了。Caveman本身不会主动给静态文件加Cache-Control头,但浏览器默认会缓存一部分。

我的土办法是在HTML里的静态资源URL后面加版本参数,比如:

<link rel="stylesheet" href="/public/css/app.css?v=20250321">

这样每次发版本时改一下版本号,浏览器就会重新拉取。这种办法不够优雅,但在内部工具里特别管用,因为改动频率低,手动改一个版本号也不会烦到哪去。

7. 到最后,我还想分享的几个实操心得

先聊一个不是技术,但胜过技术的点:命名。caveman这个项目代号,其实很适合作为“低配置成本”项目的提醒。我在做这个小工具时就告诉自己,不要为了架构而架构,不要为了引入某个炫酷概念而把简单的东西变复杂。日志分析工具就是个跑在内部的小服务,它需要的只是一层薄薄的Web壳。

如果你现在也在犹豫要不要用caveman,或者是不是该换一个更主流的技术栈,我建议你先想想项目的生命周期。如果是3个月内就要交活、用完可能就不维护的工具,那caveman非常合适,因为它的代码量少,依赖少,出了问题看一眼源码就能明白。如果这个项目准备长期演进、多人维护、标准化要求很高,那你要做的其实是重新评估需求,而不是单纯怪框架。

另外,不管用什么框架写Web应用,把“纯逻辑”和“框架胶水”分开,永远是个好习惯。我这次的核心逻辑全部放在不依赖caveman的Lisp模块里,甚至在SBCL命令行里也能直接调用。正因为这块够干净,后面就算有人告诉我“我们换语言了”,也只会动最外面那层壳,不会伤筋动骨。

最后再分享一个日常开发的小习惯:每次写完一个接口,我都先用curl测一遍,再看浏览器表现。有很多所谓“框架问题”,最后查出来都是请求参数少传了或响应头不对。curl能快速暴露这些问题,因为它不会像浏览器一样悄无声息地帮你补全一些信息。保持这个习惯之后,我在caveman项目里排查问题的速度明显快了很多。希望这篇记录能帮到正在Lisp生态里做小工具的你,少走几步弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询