企业应用架构21:插件、业务扩展点与产品变化
设备租赁的客户差异有时只是一项费率,有时改变整个计费算法。把所有差异塞进配置,配置会逐渐拥有条件与循环;把每个字段都做成插件,简单产品又需要维护加载器、版本协议和异常边界。扩展点应由实际变化决定,并且留下普通条件分支可以解决问题的对照。
本篇保留日计费和整周优惠两种策略,再增加一个受限封顶插件。实验使用 Python 3.14 的标准库从批准的本地文件加载插件,不使用 Java ServiceLoader,也不把进程内插件说成安全沙箱。相同八天、一百分日费率,三种行为分别产生 800、700、500 分,差异来自明确规则,不来自名称不同的空实现。
参数、策略与插件分别改变什么
日计费按天数乘单价。整周优惠将每七天计为六天,剩余天数照常计费;八天对应七个计费日,因此是 700 分。封顶规则取普通日租总额与 500 分的较小值,八天得到 500 分。金额统一使用整数分,输入只描述受限报价,不包含税率、押金、跨币种转换和合同变更历史。
若客户差异只是每日单价,保留同一函数并传不同参数足够。整周优惠改变运算规则,适合独立策略函数。封顶规则由另一个批准模块提供,需要加载时验证能力与接口版本,这才引入插件边界。三种形式可以同时存在,不能因为系统支持插件,就要求每种价格都必须变成动态模块。
插件目录中的代码是在应用进程里执行的。版本检查发生在模块被加载之后,恶意模块甚至可以在导入期间执行动作。因此加载路径必须来自发布方批准的文件集合,租户配置只能选择已登记的名字。任意上传脚本、任意指定模块路径和远程下载执行不在这个设计中;需要运行不可信代码时,应另行设计进程、操作系统权限与资源隔离。
Fowler 的 Plugin 模式关注运行配置期间连接实现的方式。本实验只采用这一小段思想,没有构建插件市场或通用容器。动态加载本身也不意味着热更新安全;正在执行的报价引用哪个版本、旧合同如何追溯和模块卸载时如何释放资源,均需要额外契约。
下图展示发布侧登记和租户侧选择的区别。它是配置依赖图,省略文件签名与发布审批流程,不表示这些运维能力已实现。
flowchart TD
R[发布方批准文件] --> L[加载器]
L --> V[版本与能力检查]
V --> G[名称到函数的登记表]
T[租户配置中的策略名称] --> A[租户允许名单]
A --> Q[报价边界]
G --> Q
Q --> M[整数金额校验]
扩展协议应小到可以逐项检查
样例插件声明 API=1、NAME=cap、CAPABILITIES={CNY, quote},并提供 quote(days,cents)。加载器要求接口版本与能力集合严格相等,然后检查名称是否已经注册。重复名称没有“最后加载覆盖前一个”的隐含规则,应用直接拒绝启动该登记表,避免加载顺序决定客户价格。daily 与 weekly 也是保留名称,插件不能用它们替换内置策略;报价边界会再次拒绝包含这些名称的外部登记表,直接传入字典也不能绕过检查。
严格相等是这个小实验的选择。真实协议可能允许能力的子集协商,也可能区分可选与必须能力;在定义这些规则之前,宽松接受未知能力会让配置看起来有效,却无法解释失败行为。版本号也不能只是装饰字段,接口参数、币种语义或金额精度变化时,需要明确定义兼容关系。
cap_plugin.py 是真正由 importlib 加载的独立文件;bad_plugin.py 声明 API=2,加载时被拒绝。两个文件使用相同 NAME 并不会自动让它们兼容。测试分别覆盖接口不匹配和重复登记,因此能够区分“文件能导入”与“应用愿意接受此实现”。这两个判断属于不同层次。
样例没有额外为日计费创建抽象基类。函数形状已经足以表达扩展协议,登记表只管理确实需要按配置选择的插件。若仅有日计费一个需求,直接调用函数更清楚;即使有两种固定策略,一个明确分支仍可能成本更低。插件的价值需要由独立发布或客户扩展需求解释,类数量不能证明收益。
租户不能指定可执行类
报价函数先校验 actor 与 tenant 的对应关系,再查策略允许名单。A 可以选择 daily、weekly,B 可以选择 daily、cap。alice 使用 B 配置会被拒绝;bob 把策略写成 os.system 也会被拒绝。输入从未流入 importlib 的模块路径参数,这才构成受限选择的关键。
允许名单应由服务端管理,不能由同一份租户输入同时提供“选择值”和“允许值”。样例把映射写在代码里方便审阅,实际产品可将其保存到管理配置,但仍需保持写权限边界。展示页面隐藏某个策略只影响交互,不构成授权;后台任务和 API 都需要调用同一个报价边界。
配置校验还需要处理删除。B 已选择 cap,若部署包中没有该插件,报价明确报 plugin missing,不会偷偷回退日计费。静默回退可能让客户支付不同金额,比显式失败更难发现。默认行为只在用户明确选择 daily 时成立,不能把“未知配置”与“默认配置”混成一个分支。
此实验没有保存真实合同,因此插件失败没有数据库写入。后续接入应用服务时,应在接受报价和持久化合同之间划清边界:报价失败就不进入提交,成功报价携带所用版本、币种与金额。重新计算历史合同需要使用历史规则,而不是直接调用今天安装的最新插件。
输出校验不能依赖插件自觉
函数签名相同不保证业务结果合法。插件可以抛出异常,也可能返回负数、浮点数、布尔值或极大金额。报价边界要求结果的类型恰为 int,并限定在零到十亿分之间。采用 type(amount) is int 可以拒绝 Python 中属于 int 子类的布尔值,金额域不会把 True 当成一分钱。
实验注入返回负一与返回 1.5 的函数,均被拒绝;故意抛出 RuntimeError 的插件也不能产生成功报价。这里异常传播到调用方,未设计自动重试。纯计费函数若存在副作用,重试可能重复执行,因而扩展协议必须先规定是否允许读写外部资源,不能从函数名字推断幂等性。
下图关注一次报价中的失败位置。加载错误发生在登记前,输入与输出错误发生在调用边界;没有任何箭头通向合同提交,持久化留给应用用例。
sequenceDiagram
participant C as 调用方
participant Q as 报价边界
participant P as 已登记策略
C->>Q: 主体、租户、策略、天数、分
Q->>Q: 授权与输入范围
Q->>P: quote
alt 正常返回
P-->>Q: 金额
Q->>Q: 类型与范围校验
Q-->>C: 有效报价
else 插件异常
P-->>Q: 错误
Q-->>C: 报价失败
end
输出范围的十亿分只是实验上限,不能自动当成所有租赁公司的业务约束。真实金额上限应由业务契约规定,超出范围还需要区分输入错误、规则缺陷与系统能力限制。测试中固定上限的作用,是让“插件输出也必须校验”成为可失败的程序行为。
直接分支的对照如何判定
实验对一到二十一天逐个输入,以直接算式计算整周优惠,再与 weekly 策略结果比较,二十一个案例全部一致。这证明提取函数没有改变当前有限输入集合的规则,不证明所有整数或未来新规则都相同。测试保留边界七天、八天、十四天与二十一天,能够发现整周与余数的典型错误。
边界回归额外生成协议版本与能力均合法、但名称分别为 daily 和 weekly 的临时插件。它们都返回七分,以便区分是否真的执行了错误实现。加载和直接调用两条路径均明确拒绝,之后正常日计费仍为八百分,周优惠仍为七百分。只检查两份插件之间重名会漏掉与内置策略的碰撞,允许名单也无法识别一个已获准名字背后的实现已经改变。
日计费仍然是普通函数,插件缺失不会改变 daily 的结果。加载器只承担登记和协议验证,不负责金额计算、租户授权或合同持久化。把这些职责拆开之后,读者可以指出每个新增层解决的具体问题;若某层只有转发而没有策略、约束或替换需求,应考虑直接删除。
增加第四种策略以前,需要先描述第二种独立变化:谁发布代码、谁选择策略、何时生效、怎样回退,以及错误由谁承担。若答案仍是同一团队同时发布少量固定规则,条件分支可能继续足够。选择插件会新增版本兼容、加载失败和代码信任三个故障面,只有收益能抵消成本时才值得保留。
独立运行
在仓库根目录执行;Python 3.14 为本次验证版本,代码只使用标准库。
1 | |
入口 lab.py 提供 load、quote 与完整正反例。normal.stdout.txt 记录三种实际金额,以及 missing、duplicate、incompatible、tenant-config、class-injection、negative、float、exception 的拒绝结果,最后输出 direct-branch-equivalence=PASS cases=21。原始输出与环境位于 examples/enterprise-application-architecture/evidence/21/,退出码为零才表示完整场景结束。
这个实验的交付边界是受信任文件、单进程报价和固定能力协议。动态卸载、插件超时隔离、内存配额、真实客户配置迁移以及生产灰度均为 NOT_RUN。它们不能靠多写几个登记字段自动获得,需要新的需求与对应实验。
参考资料
实验下载:独立实验与原始证据包
后篇:企业应用架构22:Strangler 与 Branch by Abstraction
- Martin Fowler:Plugin,模式定义与适用背景。
- Python 3.14 importlib,从文件位置构造模块规范与执行模块的标准库入口。
