Appearance
SPI 实现指南
引擎核心零依赖:仓储、用户、JSON、表达式全部走 SPI(
jeeflow/spi.py)。接入自己的业务时实现这些接口,用EngineImpl构造注入。
ProcessRepository(必须)
仓储是唯一必须实现的 SPI,映射 规范 01 · 数据模型 的 5 张表(wf_process_define/instance/task/task_actor/cc_instance):
python
class MyRepository(ProcessRepository):
async def find_define_by_id(self, id: int) -> Optional[ProcessDefine]: ...
async def find_instance_by_id(self, id: int) -> Optional[ProcessInstance]: ...
async def save_instance(self, inst: ProcessInstance) -> None: ...
async def update_instance(self, inst: ProcessInstance) -> None: ...
async def find_task_by_id(self, task_id: int) -> Optional[ProcessTask]: ...
async def save_task(self, task: ProcessTask) -> None: ...
async def update_task(self, task: ProcessTask) -> None: ...
async def find_doing_tasks(self, instance_id: int, task_names=None) -> list[ProcessTask]: ...
async def find_done_tasks(self, instance_id: int, task_names=None) -> list[ProcessTask]: ...
async def find_history_tasks(self, instance_id: int) -> list[ProcessTask]: ...
async def find_task_actors(self, task_id: int) -> list[str]: ...
async def add_task_actor(self, task_id: int, actors: list[str]) -> None: ...
async def remove_task_actor(self, task_id: int, actors: list[str]) -> None: ...
async def create_cc_instance(self, instance_id: int, creator: str, *actor_ids: str) -> None: ...
async def update_cc_status(self, instance_id: int, actor_id: str) -> None: ...
# 抄送写侧判重(issues/141 G2,带 default:不覆写=维持旧行为,第三方仓储源码兼容不破)
async def find_cc_actor_ids(self, instance_id: int) -> list[str]: ...
async def create_cc_instance_if_absent(self, instance_id: int, creator: str,
actor_ids: list[str]) -> list[str]: ...抄送写侧判重=幂等空操作(G2 · 规范 06 · 门面 §4):同一
(instance_id, actor_id)已有 cc 行时跳过——不新增行、不重置未读状态、不更新原行时间, 并且不 fireCC_CREATE(码 4)(规范 11 §11.2 原则 1「码值表达发生了什么事实」)。 建 cc 的三条入口(发起f_ccActors/办理tf_ccActors/门面手动createCCInstance)在本栈 全部收敛到engine.handle_cc_actors这一个漏斗,那里统一调create_cc_instance_if_absent, 逐人 fire 的入参=它返回的实际新建子集(子集为空整支不发)。查询侧不引入DISTINCT、 历史重复行不清理(owner 2026-09-29 拍为接受既成事实)。 自带两仓(JdbcRepository/MemoryRepository)都覆写find_cc_actor_ids;集成方自实现 仓储时也应覆写,否则 default 返回空集=不判重,重复抄送会照旧逐条 INSERT。
空不创建行(G10 · 规范 06 · 门面 §2.10):抄送人集合里的 空串、纯空白、
None一律丢弃,落库与比较值一律取 trim 后的串(" 123 "与"123"是 同一个人);丢完为空 ⇒ 不建任何 cc 行、也不 fireCC_CREATE(码 4)。判据落在两层: 漏斗层engine.parse_cc_actors(逗号串与数组两形共用spi.normalize_actors那一支——§2.11 起 它是任务侧与抄送侧共用的唯一判据点,旧名normalize_cc_actors保留为同一对象的别名), 以及写侧兜底——create_cc_instance_if_absent的 default、两仓的create_cc_instance各自再挡一次。集成方自实现create_cc_instance时同样要丢空值并 trim(绕过引擎/门面直连 仓储的调用方不得把空归属值灌进actor_id,那是 那族"空 operator 读全库"的病根)。 反向哨兵:"0"是正常 id,不是空值,不许丢。手动腿createCCInstance丢完为空时与 "空actorIds"同档(报processInstanceId/actorIds 缺失,不新造错误码/文案)。
任务参与者写侧同一条尺子(B 批 · 规范 06 · 门面 §2.11): 上面那四点逐字搬到
wf_process_task_actor.actor_id。四条腿都过同一枚spi.normalize_actors——门面processTask/addCandidate·surrogate(_to_actor_ids)、processTask/transfer的fromActor/toActor(标量档spi.normalize_actor_value,判空写== "",严禁if not x, 否则"0"/int0被吃掉)、引擎f_/tf_nextNodeOperator(逗号串与数组两形同判据,全空档 回落节点assignee)、processInstance/updateCCStatus的operator(归一后再比,空值 ⇒ no-op, 不把state=1打到历史actor_id=''的脏行)。集成方自实现add_task_actor时同样要 trim+丢空+判重(见ProcessRepository.add_task_actor的 docstring 义务清单),并且 主键另判一档:task_id缺失/空串/0一律ValueError(spi.require_present_id), 不得拿''/0当 id 落库——归属值可有可无,主键没有就是调用方写错了。
开箱即用:
MemoryRepository(jeeflow/memory.py)供演示/测试;JdbcRepository(jeeflow/repository/)— 多数据库 JDBC 实现:共享核心base.py(SQL 逻辑唯一维护点)+ 每库一个薄适配器。按库安装依赖(核心零依赖):
python
# MySQL(pip install jeeflow[mysql])
import aiomysql
from jeeflow import JdbcRepository, MySqlAdapter
pool = await aiomysql.create_pool(
host="127.0.0.1", user="root", password="pwd", db="jeeflow",
autocommit=True, # 适配器要求:无事务时每条语句立即提交
)
repo = JdbcRepository(MySqlAdapter(pool)) # 关系表主键用内置时间戳 ID 生成器
# PostgreSQL(pip install jeeflow[postgres])
# import asyncpg
# from jeeflow import JdbcRepository, PostgresAdapter
# pool = await asyncpg.create_pool("postgresql://root:pwd@127.0.0.1/jeeflow")
# repo = JdbcRepository(PostgresAdapter(pool))新增数据库 = 写一个适配器(约 80 行,参考
repository/mysql.py):实现SqlAdapter(占位符风格 + acquire/release)+ 连接包装(execute/fetchone/fetchall/ begin/commit/rollback)。SQL 核心统一用?占位符,由适配器转换 (MySQL%s/ PostgreSQL$n)。建表 SQL 各语言自带(tests/schema/schema-<db>.sql,使用者单语言下载即用)。
仓储方法自动映射 wf_* 5 张表(spec §2)。content 为流程定义 JSON,variable 为变量 JSON。
事务(规范 05「事务约定」节):with_tx 用 contextvars.ContextVar 把事务连接绑定到当前协程上下文,回调内所有仓储调用走同一连接;异常自动回滚:
python
async def do_biz():
await repo.save_instance(inst)
await repo.create_cc_instance(inst.id, "zhangsan", "lisi", "wangwu")
await repo.with_tx(do_biz)约定:业务层是事务 owner——先
with_tx再调引擎方法,引擎对事务是「可选参与」而非「不感知」(权威=规范 05 事务约定第 1 条/规范 12 §12.1 原则 3)。
page_cc_instances的归属条件必填(G1 · 规范 06 §2.5): "抄送我"这类取数入口必须带归属列cc.actor_id的有效条件——本栈有两形(Java 只有一形): 专用入参actor_id,或conditions里column="cc.actor_id"的那条;有效=值非None、 字符串strip()后非空、集合非空。两形都没给有效值 ⇒ 返回空页([], 0), 严禁退化成"这条条件不加"而放出全部实例。SQL 仓与内存仓在同一条判据上必须给同一个答案 (判据见memory.py::_has_cc_ownership与repository/base.py::_has_cc_ownership,同名同实现)。 门面processInstance/ccList恒挂这条条件(operator经归一化后走actor_id入参), 这条义务防的是绕过门面直连仓储的调用方与下一版门面的漏挂。 ⚠️ 只收归属谓词:m_LIKE_*这类可选过滤传空串仍按"没填"忽略,不跟着收紧。
UserProvider(可选)
一次返回用户全部信息,引擎注入 u_* 变量:
python
class MyUserProvider(UserProvider):
async def get_user(self, user_id: str) -> Optional[UserInfo]:
return UserInfo(userId=user_id, realName="张三",
deptId="D01", deptName="研发部",
postId="P01", postName="工程师")OrgUserProvider(可选,v1.6.0)
组织维度取人——内置组织 handler(部门领导/分管领导/角色)的数据源。 业务方只实现数据接口,不写 handler:
python
from jeeflow.spi import OrgUserProvider
class MyOrgUserProvider(OrgUserProvider):
async def find_dept_leaders(self, dept_id: str) -> list[str]:
return await self.org_svc.leader_ids(dept_id)
async def find_dept_main_leaders(self, dept_id: str) -> list[str]:
return await self.org_svc.main_leader_ids(dept_id)
async def find_by_role(self, role_code: str) -> list[str]:
return await self.org_svc.user_ids_by_role(role_code)注册内置 handler(注册名与 Java 类全限定名一致,流程 JSON 四语言通用):
python
from jeeflow import HandlerRegistry, EngineExtensions, register_builtin_assignments
registry = HandlerRegistry()
register_builtin_assignments(registry, user_prov, org_prov) # 组织维度依赖注入
engine.set_extensions(EngineExtensions(registry=registry))内置 handler 的场景/配置/注意事项见 用户指南 07 · 参与者解析。
IDGenerator(可选)
python
class MyIdGen(IDGenerator):
def next_id(self) -> int:
return int(time.time() * 1000) # 简易雪花即可ExpressionEvaluator(可选)
决策/会签表达式求值。不实现也照样定支——引擎自带内置件 jeeflow.default_evaluator.DefaultExpressionEvaluator(spec/06 §4.6 义务 2 的"求值通道"条): 变量代入(${var} / #var / 裸名)+ 比较运算(>= <= != == > <)+ 布尔字面量, 不含 && / ||;关系运算要求两侧都是数字,非数字操作数判 false(不拿字典序蒙方向)。 运行时的决策定支与门面 highLight 的边重算共用这一个出口,宿主注册了本 SPI 则宿主优先:
python
class MyExpr(ExpressionEvaluator):
async def eval(self, expr: str, vars: dict) -> Any:
return eval_expr(expr, vars) # 简易比较器即可示例:最小接入
python
from jeeflow import EngineImpl
engine = EngineImpl(MyRepository(), MyUserProvider(), MyIdGen(), MyExpr())
inst = await engine.start_process_instance_by_id(define_id, operator, args)集成测试
tests/jdbc_test.py 双库可跑(同一套断言,与数据库无关):
bash
python tests/jdbc_test.py mysql # 开发服务器 MySQL(3306)
python tests/jdbc_test.py postgres # 开发服务器 PostgreSQL(5432,Docker mldong-pg)建表 SQL 自动从本仓 tests/schema/ 执行(IF NOT EXISTS,幂等;维护者改 jeeflow-java 仓 resources 后跑 jeeflow-hub/scripts/sync-schema.sh 同步)。已实测:mysql 20/20、postgres 20/20 全过。
管理扩展与统一门面(v1.1.0)
设计稿 / 历史 / 委托由扩展仓储 SPI 提供读写(文档站 spec §10),统一门面 flow(action, map) 按 action 路由(spec §11.2),返回 {code, msg, data}, deploy 自动版本管理,execute 按 submitType 全分发,操作人由 args.operator 显式传入。
扩展仓储实现(JDBC + 内存)与门面均在本仓库:
- 扩展仓储:
<repository>/jdbc/ext.*(JDBC)、memory 内存实现 - 门面:
facade.*/jeeflow/facade.py/src/facade.ts
三张扩展表(wf_process_design / design_his / surrogate)SQL 已随 schema 分发 (schema-<db>.sql,维护源 jeeflow-java resources)。
分页说明(v1.1.0):核心表分页 SPI(pageDefines/pageTodoTasks 等)目前 Java 提供, 本语言对应分页 action 返回明确错误,计划 1.2.0 补齐;设计/委托分页全支持。
委托代理自动生效(引擎内置,默认开启)
processSurrogate/* 五个 action 只是台账 CRUD;真正的能力是建任务那一刻自动应用生效中的委托 (文档站 spec 06 §4.5「运行期语义」)。Python 引擎内置该行为:参与者解析完成后、落库前, 对每个参与者查一次 ProcessExtRepository.get_surrogate,命中则把代理人并入该任务的参与者集合 (随 save_task 一起写 wf_process_task_actor,授权人保留、任一可办)。实现见 jeeflow/surrogate.py。
零配置:把扩展仓储传给门面即生效(门面会自动 engine.attach_ext_repository(ext_repo))。
python
ext_repo = MemoryExtRepository() # 或 JdbcProcessExtRepository(adapter)
engine = EngineImpl(repo, user_prov, idgen)
facade = JeeflowFacade(engine, repo, ext_repo) # ← 委托自此自动生效显式关闭(回到"仅台账")两条路,任选其一:
python
# ① 配置开关
engine.set_extensions(EngineExtensions(surrogate_enabled=False))
engine.ext.surrogate_enabled = False # 运行期改也可以
# ② 注册空实现(也可换成自定义数据源的 SurrogateApplier)
from jeeflow.surrogate import NullSurrogateApplier
engine.set_extensions(EngineExtensions(ext_repository=ext_repo,
surrogate_applier=NullSurrogateApplier()))未配置扩展仓储时静默跳过:ext_repository is None 不查不抛,建单流程零影响; 查询本身报错也只记日志不外溢(委托是增强能力,不得打断建单)。
委托查询四判据(内存仓 MemoryExtRepository 与 SQL 仓 JdbcProcessExtRepository 同答案): 空 processName 全流程兜底、时间窗任一侧 NULL=不限、surrogate <> operator 自委托过滤、 enabled 只认整数 1(脏值按停用,写入侧见 processSurrogate/save)。判据本体收口成单条裁决ProcessSurrogate.is_effective(operator, at)(对齐 Java 参考实现 jeeflow-java 6feeae6)。
⚠️ 判据④只认整数,不吃"等价写法"(案 A,与 Java Integer.valueOf(1).equals(enabled) 同阵营): '1' / 1.0 / True 与 0 / 2 / 脏值 / None 一律不生效。整数列被读回成字符串(enabled 建表是 INT;文本协议经代理/网关、列类型漂移、遗留 VARCHAR 台账,PHP 同栈已由 PDO 缓冲查询实证)属边界事实, 由 surrogate.hydrate_enabled() 在内置 SQL 仓装行处(JdbcProcessExtRepository._map_surrogate)还原后再交判据 —— 只认规范整数串 -?(0|[1-9]\d*),'abc' / '1.0' / ' 1' / '01' 不还原,不是把 (int) 强转换个地方做。 内存仓没有驱动,刻意不还原;业务方自定义 SPI 仓储传非整数按停用,需要生效就实现侧自己还原。
⚠️ 取数顺序本身是契约(spec 06 §4.5 条款 1.4):每个作用域先按主键 id 取最新的 一条(SQL 侧 ORDER BY id DESC LIMIT 1,不带 enabled = 1 / 时间窗 / surrogate <> ? 谓词), 再把这一条交 is_effective 裁决;精确作用域判否后仍要看全流程作用域的最新一条(跨层兜底)。 反过来写(先按判据滤掉不生效的、剩下的才取最新)等于"历史上留过一条窗内委托就永久生效"—— 用户随后改停用 / 改到未来 / 改成自委托全都判不动它。窗口比较与写入侧同一把尺子 (datetime.now() 宿主本地时间的 naive 时刻,spec 条款 5 /),不得拿 UTC 比本地时间戳。
查询用的流程名取值口径(spec 06 §4.5 条款 1.1,单点实现 EngineImpl._surrogate_process_name): 以流程模型的 name 为准(对齐内置版迁移基线 processModel.getName()),模型未带 (键缺失 / null / 空串 / 仅空白)才回落 wf_process_define.name;判据是先 trim 再判空, 传给 get_surrogate 的值一律 trim 后(" 名 " 与 "名" 命中同一条委托)。 回落读定义行按 defineId 缓存复用,不逐任务解析。覆盖全部建任务路径:发起、办理推进、 串行会签的每一步推进、跳转(JUMP)、回退(ROLLBACK)。