Apache Thrift 的 Common Lisp 客户端/服务端开发完全指南:从 IDL 翻译到 RPC 实战
【免费下载链接】thriftApache Thrift项目地址: https://gitcode.com/gh_mirrors/thrift2/thrift
导读
本文基于 Apache Thrift 仓库中 lib/cl/README.md 官方文档,系统讲解如何在 Common Lisp 中消费 Thrift 协议:从理解.thriftIDL 文件被翻译为 Lisp 源码后生成的包(package)、类型、def-struct/def-service形态,到实现服务端逻辑、用 thrift 编译器生成 Lisp 接口、加载 ASDF 系统、启动服务端并用客户端远程调用。读完本文,你将掌握with-client、serve两大核心宏的用法,以及如何基于仓库自带的 tutorial/cl 教程代码跑通一个完整的 Calculator 示例,并理解生成代码与手写实现之间的分工边界。
一、Thrift Common Lisp 库概述
Thrift 是一套语言无关的进程间通信协议与库:合作的进程之间通过请求/响应消息进行通信,消息的结构在使用前通过一份共享的**接口定义(IDL,Interface Definition Language)**预先约定。在 Common Lisp 场景下,这份.thrift定义文件会被翻译成若干 Lisp 源文件,翻译产物包含以下几类定义:
- 三个包(package):一个用于实现操作符的命名空间,另外两个分别用于请求操作符与响应操作符;
- 各种类型定义:作为 Thrift 的
typedef与enum定义的 Lisp 实现; def-struct与def-exception形态:对应 Thrift 的struct与exception定义;def-service形态:对应 Thrift 的service定义。
其中,def-service是最核心的产物。每个服务定义会展开为一组泛型函数(generic function)定义:对于服务中的每一个操作op,会生成两个函数:
| 函数 | 使用方 | 签名要点 | 职责 |
|---|---|---|---|
op-request | 客户端 | 接受一个额外的、位于首位的protocol参数 | 充当客户端代理,通过 Thrift 编码的传输流与远程进程交互 |
op-response | 服务端 | 只接受一个protocol参数 | 解码请求消息、调用基函数op并传入消息参数、编码并回送结果、处理异常 |
需要特别指出的是:翻译生成的代码只负责消息的编解码与传输,真正执行op业务逻辑的函数需要由程序员实现,其命名空间与生成的请求/响应函数相隔离(详见下一节)。库本身不提供从服务实现反向生成 IDL 的能力,因此如果你要新建服务,必须自己编写 IDL 文件(参见官方流程说明中 "no facility to generate them from a service implementation" 的表述,见 lib/cl/README.md)。
二、客户端与服务端的两大核心接口
2.1 客户端接口:with-client
客户端侧唯一的操作符是一个宏:
(with-client (variable location) . body)它的语义是:在动态上下文中建立一个连接,并在退出时关闭它。variable被绑定到一个客户端代理的流/协议实例上,该实例将底层的 I/O 流(socket、文件等)用实现 Thrift 协议与传输机制的算子包装起来。location则是形如#u"thrift://127.0.0.1:9091"的 Thrift URI(由puri库提供的 uri 类解析)。
典型用法如下(取自文档,原样可运行):
(in-package :cl-user) (macrolet ((show (form) `(format *trace-output* "~%~s =>~{ ~s~}" ',form (multiple-value-list (ignore-errors ,form))))) (with-client (protocol #u"thrift://127.0.0.1:9091") (show (tutorial.calculator:ping protocol)) (show (tutorial.calculator:add protocol 1 2)) (show (tutorial.calculator:add protocol 1 4)) (let ((task (make-instance 'tutorial:work :op operation.subtract :num1 15 :num2 10))) (show (tutorial.calculator:calculate protocol 1 task)) (setf (tutorial:work-op task) operation.divide (tutorial:work-num1 task) 1 (tutorial:work-num2 task) 0) (show (tutorial.calculator:calculate protocol 1 task))) (show (shared.shared-service:get-struct protocol 1)) (show (zip protocol))))注意这里每个生成的请求函数都以protocol作为第一个参数,这正是文档所强调的"接受一个额外的初始protocol参数"的体现;macrolet中的show只是一个辅助打印宏,用于把返回的多个值与可能的异常一并显示。
2.2 服务端接口:serve
服务端接口组合了服务端对象(server)与服务对象(service):
(serve (location service))它会在指定端口上接受连接,并响应服务所声明操作的请求。service参数是def-service生成的一个全局变量所绑定的服务实例(见下文"运行服务端"小节)。
三、构建与加载库(Building)
Thrift Common Lisp 库以ASDF系统thrift的形式发布。要构建它,需要先把以下依赖系统注册给 ASDF:
| 依赖系统 | 用途 |
|---|---|
puri | 提供 Thrift 的 uri 类(用于解析#u"thrift://..."这样的 location) |
closer-mop | 提供类元数据(class metadata)支持 |
trivial-utf-8 | 字符串编解码 |
usocket | socket 传输(transport) |
ieee-floats | int 与 float 之间的转换 |
trivial-gray-streams | gray streams 的抽象层 |
alexandria | 常用工具函数 |
依赖已随库打包,用于本地构建测试与教程二进制,也可以直接使用这些打包好的依赖来加载库本身。注册完成后,执行:
(asdf:load-system :thrift)这行代码会编译并加载四类内容:
- Thrift 定义文件的 Lisp 编译器(即生成器运行时支持);
- 传输(transport)与协议(protocol)实现;
- 客户端与服务端的接口函数;
- 若要实际使用,还需编写并加载远程服务的接口定义;如果是实现服务方,还需定义 Thrift 要代理调用的真实业务函数。
3.1 仓库中的加载脚本
仓库在 lib/cl/load-locally.lisp 中提供了一份"使用捆绑依赖加载库本身"的脚本,它是构建自测与跨语言测试二进制的基础:
(require "asdf") (load (merge-pathnames "externals/bundle.lisp" *load-truename*)) (asdf:load-asd (merge-pathnames "lib/de.setf.thrift-backport-update/thrift.asd" *load-truename*)) (asdf:load-system :thrift)依赖是通过 lib/cl/ensure-externals.sh 脚本从 Quicklisp 打包下来的——它会下载 quicklisp、调用quicklisp:bundle-systems把puri、usocket、closer-mop、trivial-utf-8、ieee-floats、trivial-gray-streams、alexandria、bordeaux-threads、cl-ppcre、fiasco、net.didierverna.clon等系统打包到externals/,再拉取de.setf.thrift的backport-update分支作为库本体的来源。教程目录下的 tutorial/cl/load-locally.lisp 与该脚本内容一致,保证测试与教程使用同一套加载路径。
四、按照 Thrift 教程实现一个服务
文档其余部分以 Thrift 官方教程为线索,演示完整流程的五步:
- 实现服务(业务逻辑);
- 翻译 Thrift IDL;
- 加载翻译后的 Lisp 服务接口;
- 运行服务端;
- 用客户端远程访问服务。
4.1 第一步:实现服务
教程服务包含若干函数:add、ping、zip和calculate。每一个被翻译的 IDL 文件都会为每个服务生成三个包。以教程文件为例,相关的包是:
tutorial.calculator—— 请求包(生成)tutorial.calculator-implementation—— 实现包(由程序员填写)tutorial.calculator-response—— 响应包(生成)
这种划分的目的是把 Thrift 方法的请求函数(生成)、**响应函数(生成)与实现函数(程序员编写)**分离开。
文档建议在tutorial-implementation包中实现服务逻辑,因为它import了common-lisp包,而服务专属的那些包不导入common-lisp——这是为了避免 Thrift 方法名与common-lisp中的函数名发生冲突。仓库里对应的实现文件是 tutorial/cl/tutorial-implementation.lisp。
文档给出的是"基函数"定义示例(注意函数名带包限定符):
;; define the base operations (in-package :tutorial-implementation) (defun tutorial.calculator-implementation:add (num1 num2) (format t "~&Asked to add ~A and ~A." num1 num2) (+ num1 num2)) (defun tutorial.calculator-implementation:ping () (print :ping)) (defun tutorial.calculator-implementation:zip () (print :zip)) (defun tutorial.calculator-implementation:calculate (logid task) (calculate-op (work-op task) (work-num1 task) (work-num2 task)))其中calculate的实现借助了一个defgeneric分派,把operation枚举值分派到对应的算术实现上,并通过:around方法记录日志:
(defgeneric calculate-op (op arg1 arg2) (:method :around (op arg1 arg2) (let ((result (call-next-method))) (format t "~&Asked to calculate: ~d on ~A and ~A = ~d." op arg1 arg2 result) result)) (:method ((op (eql operation.add)) arg1 arg2) (+ arg1 arg2)) (:method ((op (eql operation.subtract)) arg1 arg2) (- arg1 arg2)) (:method ((op (eql operation.multiply)) arg1 arg2) (* arg1 arg2)) (:method ((op (eql operation.divide)) arg1 arg2) (/ arg1 arg2)))仓库中的实际实现 tutorial/cl/tutorial-implementation.lisp 还展示了更完整的边界处理:calculate用handler-case捕获division-by-zero,并抛出 Thrift 异常tutorial:invalidoperation(带有:why与:what-op关键字参数),这样客户端就能收到结构化的远程异常。shared服务的实现见 tutorial/cl/shared-implementation.lisp:它用一个 hash table 作为"日志存储",get-struct负责按键取值,add-log负责把计算结果以shared:sharedstruct实例的形式存入。
4.2 第二步:翻译 Thrift IDL
IDL 文件使用.thrift扩展名。教程场景有两个文件需要翻译:
tutorial.thriftshared.thrift
由于前者include了后者,只需用前者即可生成全部接口:
$THRIFT/bin/thrift -r --gen cl $THRIFT/tutorial/tutorial.thrift-r表示递归翻译被 include 的文件;--gen cl指定目标语言为 Common Lisp(cl是 thrift 编译器注册的语言名)。
从编译器源码 compiler/cpp/src/thrift/generate/t_cl_generator.cc 可以看到,CL 生成器会按 IDL 的namespace cl声明来决定输出的包前缀(第 164 行program->get_namespace("cl")),并据此输出三类文件。
4.3 第三步:加载 Lisp 翻译后的服务接口
翻译器为每个 IDL 文件生成三个文件(生成器源码第 129/130/142 行印证了这一点):
tutorial-types.lisp—— 类型定义(def-struct、def-exception、枚举与 typedef 的 Lisp 实现);tutorial-vars.lisp—— 变量与常量定义;- 一个
.asd文件(ASDF 系统定义,形如thrift-gen-tutorial.asd),用来加载以上两者,并把其他 include(如教程中的shared)作为依赖拉进来。
仓库教程里的 tutorial/cl/make-tutorial-server.lisp 演示了完整加载序列:加载本地依赖、加载 CLON 命令行框架、依次asdf:load-asd两个生成系统(gen-cl/shared/thrift-gen-shared.asd与gen-cl/tutorial/thrift-gen-tutorial.asd),最后加载手写实现所在的 tutorial/cl/thrift-tutorial.asd。这个教程级 ASDF 系统把shared-implementation与tutorial-implementation两个手写文件串行编译进thrift-tutorial系统,依赖thrift-gen-tutorial(即生成代码)。
4.4 第四步:运行服务端
服务在def-service形态中声明的实际名称(教程里是calculator)。每个服务定义都会定义一个以服务名命名的全局变量,并将其绑定到一个描述该服务操作的服务实例上。
启动服务只需指定一个 location 与服务实例:
(in-package :tutorial) (serve #u"thrift://127.0.0.1:9091" calculator)仓库教程服务端入口 tutorial/cl/make-tutorial-server.lisp 使用thrift:serve绑定#u"thrift://127.0.0.1:9090",并通过clon:dump "TutorialServer" main生成可执行二进制。
4.5 第五步:用客户端远程访问
在另一个进程中运行客户端(前文的with-client代码段即此环节),即可依次发起ping、add、calculate(含触发除零异常)与shared.shared-service:get-struct等调用。仓库客户端入口 tutorial/cl/make-tutorial-client.lisp 展示了异常路径的预期行为:调用5 / 0时会捕获tutorial:invalidoperation并打印异常信息,随后调用15 - 10正常返回,最后读取服务端日志中的sharedstruct。
五、已知问题与设计取舍(Issues)
文档明确列出了该库当前的一些实现限制与设计讨论,理解它们有助于规避陷阱:
5.1 optional 字段的歧义
当 IDL 把某个字段声明为optional时,生成的def-struct形态不会为该 slot 提供 initform,编码算子也会跳过未绑定的 slot。这会在布尔(bool)字段上留下歧义:无法区分"字段未设置"与"字段显式设置为 NIL"。
5.2 实例化协议
struct类是标准类(standard class),exception类则由具体实现决定;- 解码器对 initargs 列表应用
make-struct来构造实例; - 文档指出,在服务端一侧,复用(resourcing)struct 并直接对 slot-value 做副作用式解码是有优势的——这暗示未来可能的优化方向。
5.3 map 的表示方式
map 目前用hash table表示。由于通过 call/reply 接口传输的数据全部是静态类型的,对象本身无需自描述编码形式,用association list(alist)其实就已足够。文档还论证了为什么 property list 并不更优:虽然 key 类型任意,但getf使用eq比较,若要支持任意 key 就得新建访问接口,且 plist 无法直接用于函数应用(如作为泛型函数分派依据)。
六、测试与回归
仓库为 CL 库提供了自动化测试框架:lib/cl/Makefile.am 中的run-tests目标调用 SBCL 执行 lib/cl/test/make-test-binary.lisp 生成测试二进制,再由check-local运行./run-tests。构建产物(run-tests、quicklisp、依赖目录等)可通过clean-local清理。此外 lib/cl/READMES/readme-cassandra.lisp 还附带了一个 Cassandra 场景的补充说明,可作为扩展阅读。
总结
Apache Thrift 的 Common Lisp 支持遵循与其他语言一致的"先定义 IDL、再生成骨架、后填写实现"的开发范式,但又有鲜明的 Lisp 特色:
- 命名空间三分:请求包 / 响应包 / 实现包相互隔离,从机制上避免 Thrift 方法名污染
common-lisp; - 泛型函数驱动:每个操作展开为一对
op-request /op-response 泛型函数,配合with-client与serve两个顶层算子,客户端与服务端代码极度精简; - 声明式传输:
#u"thrift://host:port"这样的 URI 即完整描述了连接目标,底层 socket、协议编解码全部由库封装。
若要在自己的项目中使用,完整的落地路径是:编写.thrift文件 → 用thrift -r --gen cl翻译 → 用 ASDF 加载生成系统与手写实现系统 → 服务端调用thrift:serve、客户端在thrift:with-client中发起调用。需要注意 optional 字段、map 表示等已知限制,并在实现服务时始终把业务函数写在*-implementation包中。
【免费下载链接】thriftApache Thrift项目地址: https://gitcode.com/gh_mirrors/thrift2/thrift
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考