六语言引擎实现对比:同构背后的妥协与差异

0 阅读12分钟

系列定位: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 刚完成一次六语言同步发版:

语言新版本发布渠道
Java1.8.19Maven Central
Go1.8.21goproxy
Python1.8.21PyPI
Node.js1.8.21npm
PHP1.3.6Packagist
Rust1.0.6crates.io

起因是一条契约变更:删除/启停类接口的入参要兼容 {ids} 批量形态,空值不许静默成功(台账 issues/95)。SPEC 先改,六个语言依次实现、各自跑完仓内全测与真库冒烟、各自发版——同一天,六个 registry 实测都能取到新包。

前两篇讲了多语言联邦"怎么不漂移":唯一事实源、共享输入、分层门票、串行传播。这篇是本季收尾,换个角度——把六个实现并排打开:同一副骨架在六种语言里长成什么样?哪些地方各语言不得不妥协?哪些差异被刻意保留、哪些必须修死?

一句话提纲:同构是骨架,妥协是血肉,差异进台账。


二、同一副骨架:六语言的模块拓扑

jeeflow 引擎无论哪门语言,职责都切成同一组:

职责干什么
engine引擎核心:发起/办理/驳回/跳转/会签的状态机推进
facade统一门面:40+ action 的单一入口,出入参整形
model领域模型:ProcessInstance 聚合根、任务、流程定义
spi扩展点:仓储/用户/JSON/表达式/事务/ID 生成
repository仓储实现:内存版(测试用)+ JDBC/ORM 版
persist写侧持久化:流程定稿后写业务表(ARCHIVE/SYNC)
metadata元数据:枚举字典 + Handler 注册清单

骨架相同,打包粒度完全跟着各自的语言生态走

语言打包形态粒度
Java8 个 Maven 模块最细:core / repository-jdbc / persist / autoconfigure / boot2·3·4 三套 starter / demo
Go单 module,engine/facade/spi 等包扁平,22 个源文件
Python单包 + repository 子包扁平,16 个源文件
Node/TS单包 + jdbc 子目录扁平,18 个源文件
PHPComposer monorepo,5 个包core / persist / repository-pdo / web-contract / web-psr,83 个源文件
RustCargo workspace,5 个 cratecore / repository-sqlx / persist / facade / demo-salvo,18 个源文件

两个值得说的观察:

  1. Java 拆得最细是生态逼的。Spring Boot 2/3/4 三个大版本的自动配置互不兼容,一个 starter 兜不住,只能一个版本一个模块;核心引擎(core)反而一个多余依赖都没有,连 JSON 库都走 SPI。
  2. 文件数不等于代码量。Java 95 个 .java 文件(仅 core)最多,强类型 + 细粒度分包的代价;Go 22 个文件最精简;Rust 只有 18 个 .rs 文件,但单文件密度极高——model.rs 一个文件里塞了 23 个内联测试。

对使用者这是好消息:你从任何一门语言切到另一门,找东西的地图是一样的——想改取人逻辑找 spi,想看状态机找 engine,想接库找 repository。变的是语法,不是结构。


三、一个 SPI 的六种写法

"对齐的是行为,不是代码"——这句话说起来抽象,看一个 SPI 就具体了。

UserProvider 的契约只有一句:一个方法,一次查询,返回完整用户信息,查不到返回空(引擎内部按字段取用,避免展示一个名字查五次库)。同一个契约,六种语言的地道写法:

Java——接口 + DTO 类,同步:

public interface IUserProvider {
    /** 一次返回用户全部信息(为空时返回 null) */
    UserInfo getUser(String userId);
}

Go——接口,错误走返回值:

type UserProvider interface {
    GetUser(userID string) (*model.UserInfo, error)
}

Python——抽象类,async:

class UserProvider(ABC):
    @abstractmethod
    async def get_user(self, user_id: str) -> Optional[UserInfo]: ...

Node/TS——接口,Promise:

export interface UserProvider {
  getUser(userId: string): Promise<UserInfo | null>
}

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,线程安全约束写在签名里:

pub trait UserProvider: Send + Sync {
    fn get_user(&self, user_id: &str) -> JeeflowResult<Option<UserInfo>>;
}

六段代码,六个妥协点:

语言妥协在哪
Java同步调用,错误走异常——Java 生态的默认姿势
Go(result, error) 双返回值,没有异常机制的惯用法
Python全链路 async——FastAPI 集成方是异步的,SPI 必须异步
NodePromise 同理
PHP没有强类型 DTO,返回数组,用 PHPDoc 钉死数组形状——弱类型生态的契约写法
RustSend + Sync 上在 trait 约束里——引擎跨 tokio 线程使用,编译期就拦住非线程安全实现

契约钉住的是"查一次、返全量、空返 null";语言决定的是"怎么写才地道"。强行把六种写法统一成一种(比如全用 Java 风格),得到的不是对齐,是六门语言里的别扭代码和一堆绕路适配。


四、语言特性逼出来的妥协:三个实录

上一节是"主动选择的地道写法",这一节是"没得选的硬妥协"——语言运行时机制逼着你改掉原本的设计。三个真实案例,都进过台账。

4.1 Rust 异步:同步接口只能是个壳

契约没有规定引擎必须同步还是异步——各语言跟自己的生态走:Java/Go/PHP 同步,Python/Node 异步。Rust 的特殊之处在于:它的仓储实现(sqlx)天生异步,引擎真身只能跑在 async 运行时里,同步 trait 方法于是只剩一个壳:

// 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? 逐层冒泡:

// 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 当天在维护机上实测的数字,六语言全部绿

语言命令用例数构成
PHPcomposer test(PHPUnit)286(1474 断言)五包全量,含 MysqlSmoke 真库套件
Rustcargo test --workspace252core 116 / facade 76 / persist 33 / sqlx 27,全部内联 #[test]
Pythonpytest + 脚本125spec 48 + persist/metadata/meta 32 + JDBC 真库 45
Javamvn test(core/jdbc/persist)110core 69 + jdbc 10 + persist 31
Gogo test ./...94facade 37 个占大头,jdbc 真库 15.8s
Nodenode --test × 5 套件92spec 53 + persist 22 + jdbc 7 + metadata 6 + meta 4

三个值得看的点:

  1. 测试最多的不是参考实现。PHP 286 个用例居首——它是第五语言,进场时契约已被四个语言钉死,追赶期只能用密度换信心,连弱类型带来的每个数组形状都要断言一遍;Rust 252 个次之,且 facade 的 76 个全部是 #[tokio::test]——第四节那次"170 个同步测试全绿没拦住 runtime panic"的学费,直接改写了测试形态。
  2. 参考实现的测试反而是中等偏少的。Java 110 个用例不是不严谨,而是验证重心在别处:语义回归交给集成仓的 L0–L3 契约矩阵和四语言 demo 交叉跑,仓内测试只守核心与回归。谁的责任谁扛,测试规模跟着风险分布走,不跟着资历走。
  3. T1 打的是同一个库。六语言的真库冒烟连的是同一台 MySQL、同一个 jeeflow 库、同一套五张表,测试用的流程定义共用固定 ID 段、测完自清理。这是跨语言互验的物理基础——同一个流程实例,Java 引擎写进去,Go 引擎能原样读出来接着办,因为连方言坑都在同一口锅里被踩过。

六、已知差异清单:哪些留着,哪些必须死

多语言项目最忌讳两种态度:把已知差异当回归乱修,把待修缺陷当"本来就这样"。jeeflow 的差异管理是双轨的:

可接受差异——语言特性或生态分层决定的,记录在案,不动:

差异为什么留着
Rust 同步门面是壳,调用走 asynctokio 运行时模型决定,见 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 框架的工作流模块里独立出来,又怎么靠一份接口契约接回去的。


参考资料


下一篇预告:第 12 篇 · 与 mldong 框架对接:code=0/msg 契约对齐实录——从框架内置工作流模块到独立 SDK,再靠一份契约接回去。