系列定位 :jeeflow 系列第 11 篇(第三季「多语言联邦」第 3 篇 · 季终) 平台 :掘金(深度对比)/ 公众号(故事线) 素材版本:引擎 Java 1.8.19 / Go·Python·Node 1.8.21 / PHP 1.3.6 / Rust 1.0.6(2026-08-30 六语言同日发版)
一、先看今天发生的一件事
写这篇的时候(2026-08-30),jeeflow 刚完成一次六语言同步发版:
| 语言 | 新版本 | 发布渠道 |
|---|---|---|
| Java | 1.8.19 | Maven Central |
| Go | 1.8.21 | goproxy |
| Python | 1.8.21 | PyPI |
| Node.js | 1.8.21 | npm |
| PHP | 1.3.6 | Packagist |
| Rust | 1.0.6 | crates.io |
起因是一条契约变更:删除/启停类接口的入参要兼容 {ids} 批量形态,空值不许静默成功(台账 issues/95)。SPEC 先改,六个语言依次实现、各自跑完仓内全测与真库冒烟、各自发版------同一天,六个 registry 实测都能取到新包。
前两篇讲了多语言联邦"怎么不漂移":唯一事实源、共享输入、分层门票、串行传播。这篇是本季收尾,换个角度------把六个实现并排打开:同一副骨架在六种语言里长成什么样?哪些地方各语言不得不妥协?哪些差异被刻意保留、哪些必须修死?
一句话提纲:同构是骨架,妥协是血肉,差异进台账。
二、同一副骨架:六语言的模块拓扑
jeeflow 引擎无论哪门语言,职责都切成同一组:
| 职责 | 干什么 |
|---|---|
| engine | 引擎核心:发起/办理/驳回/跳转/会签的状态机推进 |
| facade | 统一门面:40+ action 的单一入口,出入参整形 |
| model | 领域模型:ProcessInstance 聚合根、任务、流程定义 |
| spi | 扩展点:仓储/用户/JSON/表达式/事务/ID 生成 |
| repository | 仓储实现:内存版(测试用)+ JDBC/ORM 版 |
| persist | 写侧持久化:流程定稿后写业务表(ARCHIVE/SYNC) |
| metadata | 元数据:枚举字典 + Handler 注册清单 |
骨架相同,打包粒度完全跟着各自的语言生态走:
| 语言 | 打包形态 | 粒度 |
|---|---|---|
| Java | 8 个 Maven 模块 | 最细:core / repository-jdbc / persist / autoconfigure / boot2·3·4 三套 starter / demo |
| Go | 单 module,engine/facade/spi 等包 | 扁平,22 个源文件 |
| Python | 单包 + repository 子包 | 扁平,16 个源文件 |
| Node/TS | 单包 + jdbc 子目录 | 扁平,18 个源文件 |
| PHP | Composer monorepo,5 个包 | core / persist / repository-pdo / web-contract / web-psr,83 个源文件 |
| Rust | Cargo workspace,5 个 crate | core / repository-sqlx / persist / facade / demo-salvo,18 个源文件 |
两个值得说的观察:
- Java 拆得最细是生态逼的。Spring Boot 2/3/4 三个大版本的自动配置互不兼容,一个 starter 兜不住,只能一个版本一个模块;核心引擎(core)反而一个多余依赖都没有,连 JSON 库都走 SPI。
- 文件数不等于代码量 。Java 95 个
.java文件(仅 core)最多,强类型 + 细粒度分包的代价;Go 22 个文件最精简;Rust 只有 18 个.rs文件,但单文件密度极高------model.rs一个文件里塞了 23 个内联测试。
对使用者这是好消息:你从任何一门语言切到另一门,找东西的地图是一样的------想改取人逻辑找 spi,想看状态机找 engine,想接库找 repository。变的是语法,不是结构。
三、一个 SPI 的六种写法
"对齐的是行为,不是代码"------这句话说起来抽象,看一个 SPI 就具体了。
UserProvider 的契约只有一句:一个方法,一次查询,返回完整用户信息,查不到返回空(引擎内部按字段取用,避免展示一个名字查五次库)。同一个契约,六种语言的地道写法:
Java------接口 + DTO 类,同步:
java
public interface IUserProvider {
/** 一次返回用户全部信息(为空时返回 null) */
UserInfo getUser(String userId);
}
Go------接口,错误走返回值:
go
type UserProvider interface {
GetUser(userID string) (*model.UserInfo, error)
}
Python------抽象类,async:
python
class UserProvider(ABC):
@abstractmethod
async def get_user(self, user_id: str) -> Optional[UserInfo]: ...
Node/TS------接口,Promise:
ts
export interface UserProvider {
getUser(userId: string): Promise<UserInfo | null>
}
PHP------接口,返回数组形状:
php
interface UserProviderInterface
{
/**
* @return array{userId:string,realName:string,deptId:?string,
* deptName:?string,postId:?string,postName:?string}|null
*/
public function getUser(string $userId): ?array;
}
Rust------trait,线程安全约束写在签名里:
rust
pub trait UserProvider: Send + Sync {
fn get_user(&self, user_id: &str) -> JeeflowResult<Option<UserInfo>>;
}
六段代码,六个妥协点:
| 语言 | 妥协在哪 |
|---|---|
| Java | 同步调用,错误走异常------Java 生态的默认姿势 |
| Go | (result, error) 双返回值,没有异常机制的惯用法 |
| Python | 全链路 async------FastAPI 集成方是异步的,SPI 必须异步 |
| Node | Promise 同理 |
| PHP | 没有强类型 DTO,返回数组,用 PHPDoc 钉死数组形状------弱类型生态的契约写法 |
| Rust | Send + Sync 上在 trait 约束里------引擎跨 tokio 线程使用,编译期就拦住非线程安全实现 |
契约钉住的是"查一次、返全量、空返 null";语言决定的是"怎么写才地道"。强行把六种写法统一成一种(比如全用 Java 风格),得到的不是对齐,是六门语言里的别扭代码和一堆绕路适配。
四、语言特性逼出来的妥协:三个实录
上一节是"主动选择的地道写法",这一节是"没得选的硬妥协"------语言运行时机制逼着你改掉原本的设计。三个真实案例,都进过台账。
4.1 Rust 异步:同步接口只能是个壳
契约没有规定引擎必须同步还是异步------各语言跟自己的生态走:Java/Go/PHP 同步,Python/Node 异步。Rust 的特殊之处在于:它的仓储实现(sqlx)天生异步,引擎真身只能跑在 async 运行时里,同步 trait 方法于是只剩一个壳:
rust
// jeeflow-rust jeeflow-core/src/engine.rs:trait 同步方法的实现
fn start_process_instance(&self, _define_id: i64, _operator: &str, _args: &FlowData)
-> JeeflowResult<ProcessInstance> {
// Synchronous wrapper --- in practice the engine is called via async facade
Err(JeeflowError::Internal("Use async start_process_instance_async".into()))
}
真身在异步侧,每一步写库都返回 Result,? 逐层冒泡:
rust
// jeeflow-rust jeeflow-core/src/engine.rs
fn persist_tasks(&self, instance: &ProcessInstance, new_tasks: &mut [ProcessTask])
-> JeeflowResult<()> {
for task in new_tasks.iter_mut() {
if task.task_id == 0 {
task.task_id = self.next_id();
}
task.process_instance_id = instance.instance_id;
self.repo().save_task(task)?;
if !task.actor_ids.is_empty() {
self.repo().add_task_actor(task.task_id, &task.actor_ids)?;
}
}
Ok(())
}
同一个"发起"动作,Java 是一个同步方法走到底、整段包在事务模板里,失败抛异常整体回滚;Rust 被运行时模型切成同步壳 + 异步真身两副面孔,失败通道钉在类型上。对齐的是行为------发起后待办立刻可查,失败时调用方拿到错误码------不是调用的形状。
4.2 壳与真身的代价:一次"170 测试全绿"的翻车
更疼的妥协在异步运行时。Rust facade 最初是同步接口,内部用 block_on 驱动异步引擎。单跑没问题,直到接进 Salvo(基于 tokio 的 Web 框架):handler 线程已有 runtime,tokio 禁止嵌套阻塞------直接 panic,工作线程挂掉。台账 issues/83。
最扎心的是根因:facade 的测试全是同步 #[test],跑在没有 runtime 的上下文里,永远走"新建 runtime"的分支------170 个测试全绿,一个都没拦住。
修复没有绕路:flow() 改成 async fn 直接 .await 引擎,全部测试改 #[tokio::test] 在真实 runtime 里跑。代价是同步接口只剩一个返回错误的壳("请用异步版本")------这是 Rust 的运行时模型决定的,另外五语言要么天然 async(Python/Node),要么根本没有这层概念(Java/Go/PHP),不存在这个问题。
教训也进了测试纪律:测试环境和生产运行时不一致的绿,是假绿。
4.3 方言税:PDO、mysql2 与 \[\]byte
第三类妥协是数据库方言。真 MySQL 下:
- PHP 的 PDO 把
LIMIT ?绑成LIMIT '5',语法直接错(issues/67); - Node 的 mysql2
execute()对LIMIT ?占位符不友好,分页整段失败(issues/66); - Go 的驱动把 VARCHAR 扫成
[]byte,JSON 字段出来是 Base64(issues/65)。
这三个在各自的内存库测试里全部通过 ,只在真库现形。处理方式上一篇讲过(T1 真库冒烟门票),这里补一个视角:这类问题不是实现者的错,是语言税------每个生态的数据库驱动都有自己的脾气,多语言联邦的义务不是消灭方言,而是给每种方言立一个必测清单(分页占位、主键类型、JSON 明文、列名大小写),发版前逐一交税。
三个案例的共同点:**妥协发生在"语言机制"层,从不下沉到"契约行为"层。**存库可以显式、门面可以异步、SQL 可以改写,但"发起后待办可查、分页五键齐全、JSON 明文落库"一条都不能动。
五、测试矩阵:六语言的门票长什么样
对齐靠测不靠说。六语言的测试体系结构一致------T0 内存库快测保语义,T1 真 MySQL 冒烟保方言------但规模和形态各有性格。以下是 2026-08-30 当天在维护机上实测的数字,六语言全部绿:
| 语言 | 命令 | 用例数 | 构成 |
|---|---|---|---|
| PHP | composer test(PHPUnit) |
286(1474 断言) | 五包全量,含 MysqlSmoke 真库套件 |
| Rust | cargo test --workspace |
252 | core 116 / facade 76 / persist 33 / sqlx 27,全部内联 #[test] |
| Python | pytest + 脚本 | 125 | spec 48 + persist/metadata/meta 32 + JDBC 真库 45 |
| Java | mvn test(core/jdbc/persist) |
110 | core 69 + jdbc 10 + persist 31 |
| Go | go test ./... |
94 | facade 37 个占大头,jdbc 真库 15.8s |
| Node | node --test × 5 套件 |
92 | spec 53 + persist 22 + jdbc 7 + metadata 6 + meta 4 |
三个值得看的点:
- 测试最多的不是参考实现 。PHP 286 个用例居首------它是第五语言,进场时契约已被四个语言钉死,追赶期只能用密度换信心,连弱类型带来的每个数组形状都要断言一遍;Rust 252 个次之,且 facade 的 76 个全部是
#[tokio::test]------第四节那次"170 个同步测试全绿没拦住 runtime panic"的学费,直接改写了测试形态。 - 参考实现的测试反而是中等偏少的。Java 110 个用例不是不严谨,而是验证重心在别处:语义回归交给集成仓的 L0--L3 契约矩阵和四语言 demo 交叉跑,仓内测试只守核心与回归。谁的责任谁扛,测试规模跟着风险分布走,不跟着资历走。
- T1 打的是同一个库 。六语言的真库冒烟连的是同一台 MySQL、同一个
jeeflow库、同一套五张表,测试用的流程定义共用固定 ID 段、测完自清理。这是跨语言互验的物理基础------同一个流程实例,Java 引擎写进去,Go 引擎能原样读出来接着办,因为连方言坑都在同一口锅里被踩过。
六、已知差异清单:哪些留着,哪些必须死
多语言项目最忌讳两种态度:把已知差异当回归乱修,把待修缺陷当"本来就这样"。jeeflow 的差异管理是双轨的:
可接受差异------语言特性或生态分层决定的,记录在案,不动:
| 差异 | 为什么留着 |
|---|---|
| Rust 同步门面是壳,调用走 async | tokio 运行时模型决定,见 4.1 |
| PHP 返回数组形状而非 DTO | 弱类型生态的地道写法,PHPDoc 钉形状 |
demo 层实例列表过滤字段:Java 按 operator,Python/Node 按 createUser |
创建时两者等同,仅 demo 展示层,不进契约 |
| PHP demo 独立成段(Slim),不参与四端口矩阵 | 集成验证走 Laravel 一键部署,更贴近真实使用 |
待修缺陷------契约行为不一致,进台账、修到闭环。这一季里最有代表性的是"串行会签一次建全":
- issues/93(Java/PHP):串行会签启动就建出全部任务,3 人会签瞬间 3 个待办,串行变并行。修复后逐个创建、计数存任务变量,对齐 Go/Python/Node 的既有正确行为------这次参考实现是错的一方,锚点是行为一致的多数派,不是资历;
- issues/94(Rust):同类缺陷,随 v1.0.5 返工闭环(连带补齐一票否决条件化、否决后废弃残留任务)。
到今天为止,引擎语义层面挂账为零 ;剩下的只有非语义残留------比如 Rust 的负向报错文案还是「缺少id参数」(其余五语言为「id 缺失或非法」),负向用例只断 code,文案挂 issues/77 主题残留后续对齐。这也是本文敢叫"对比"而不是"差距"的底气。而今天这次 {ids} 批量契约的六语言同发(开篇那张版本表),说明台账机制跑通的不只是修缺陷,也包括加契约:SPEC 改一处,六语言在一个发布窗口内全部跟上。
结语:同构不是六份一样的代码
第三季三篇,各回答一个问题:
- 第 9 篇回答能用------一条命令十分钟,六种语言任选一个栈起全流程;
- 第 10 篇回答不漂------唯一事实源、共享输入、三层门票、串行传播;
- 这篇回答凭什么------同构不是复制粘贴,是六种语言各自交出地道的写法,把妥协摆在明面上,把差异关进台账里。
回到开头那次六语言同发:它之所以平淡得像一次日常提交,是因为所有惊险都被机制提前消化了------方言坑有真库门票挡,运行时有真实环境测试挡,行为分歧有台账和多数派锚点裁决。多语言一致性从来不是承诺出来的,是每一层门票测出来的。
下一季回归主线。下一篇是第四季开篇:第 12 篇 · 与 mldong 框架对接:code=0/msg 契约对齐实录------jeeflow 是怎么从 mldong 框架的工作流模块里独立出来,又怎么靠一份接口契约接回去的。
参考资料
- jeeflow GitHub(Java 参考实现) · jeeflow-rust
- jeeflow 文档站(SPEC 与多语言指南)
- 开源演示站(一前端多后端,nginx path 前缀切换语言)
- 集成演示站
- 一键部署区(八栈命令)
下一篇预告:第 12 篇 · 与 mldong 框架对接:code=0/msg 契约对齐实录------从框架内置工作流模块到独立 SDK,再靠一份契约接回去。