运行一个最小业务组合实验

在博客源码仓库根目录运行:

1
bash examples/business-extension/run.sh

示例仅依赖 JDK 8 或更新 JDK,不下载第三方依赖。脚本在临时目录编译,运行后清理编译产物。完整代码位于仓库的 examples/business-extension/ExtensionLab.java,实验说明位于同目录 README.md。

这是自建教学模型,不是 TMF、Lattice 或美团 BPF 的源码。它验证 SPI 发现、注解注册、业务配置激活和本地扩展调用。库存、资格与销售时间仅记录 mock 事件,没有连接真实系统。

在 Java 8 下执行,三个业务输出如下:

1
2
3
4
5
retail v1 payable=10000 [validate.input, price.list, inventory.audit, inventory.stock(mock)]
presale v1 payable=9000 [validate.input, validate.presale-window(mock), price.presale, inventory.audit, inventory.presale-quota(mock)]
group v1 payable=8000 [validate.input, validate.group-membership(mock), price.group, inventory.audit, inventory.group-quota(mock)]
PASS: scenarios, order, activation, validation, conflicts, fallback, failure, snapshot, money
PASS: typed handle, nominal DTO, checked input/output, duplicate ID, forged contract

三次输入的原始金额都是 10000 分。九折、八折是人为规定的教学规则,不代表真实平台定价,也没有模拟订金、尾款或成团状态机。

最后一行来自独立的 TypedExtensionLab.java。它验证带类型句柄、输入输出检查与伪造契约拒绝。该文件包含故意绕过泛型检查的 raw 类型反例,编译时会显示 unchecked 警告;运行时预期在输出检查处拒绝错误类型。普通注册与调用代码不使用未经检查的强转。

第一篇 解释业务模型,第二篇 解释注解与配置。实验将这些概念缩小到一个可直接观察的调用过程。

相同流程怎样得到三种行为

产品关系用 Java Map 表达:

1
2
3
4
5
6
7
8
9
10
businesses.put("retail", Arrays.asList("base", "retail"));
businesses.put("presale", Arrays.asList("base", "presale"));
businesses.put("group", Arrays.asList("base", "group"));

products.put("base", Arrays.asList("input", "list-price", "audit"));
products.put("retail", Arrays.asList("stock"));
products.put("presale",
Arrays.asList("presale-check", "presale-price", "presale-quota"));
products.put("group",
Arrays.asList("group-check", "group-price", "group-quota"));

这里的产品是教学上的组合单元。三个业务都复用基础校验和审计;预售与团购分别增加自己的校验、价格和额度动作。所有实现都已经加载进 JVM,但只有被当前业务关联的规则能成为候选。

实现类的注解只声明身份和所属调用点:

1
2
3
4
5
6
7
@Extension(id = "presale-price", point = Point.PRICE)
static final class PresalePrice implements Action {
public void apply(Context c) {
c.payableCents = discount(c.originalCents, 90);
c.events.add("price.presale");
}
}

90 是实现代码中的常量,改变它需要修改代码。这个实验没有把折扣率也做成动态配置。可配置的是实现选择、优先级和最低适用金额;不能看到配置表就宣称所有业务规则都能在线修改。

执行引擎也没有判断“这是预售,所以调用预售价格”。它只运行固定的三个阶段:

1
2
3
4
invoke(Point.VALIDATE, context, rules);
invoke(Point.PRICE, context, rules);
require(context.payableCents >= 0, "negative result");
invoke(Point.INVENTORY, context, rules);

候选收集读取注解和配置,而不枚举业务名称:

1
2
3
4
5
6
for (Rule rule : rules) {
Entry entry = registry.entries.get(rule.implementation);
if (entry.metadata.point() == point && rule.matches(context)) {
candidates.add(rule);
}
}

rules 已经由业务身份对应的产品关系算出。原价规则与预售价规则都适用时,预售价优先级 10 高于原价的 0,SINGLE 只执行预售价。库存阶段的 ALL 则先执行优先级 100 的审计,再执行业务额度动作。

图 1:以 10000 分输入对照三个业务;阶段顺序相同,配置与候选选择决定实际动作。

flowchart TB
  accTitle: 三个业务共用流程与不同候选选择
  Engine["同一执行引擎:VALIDATE → PRICE → INVENTORY"]
  subgraph Retail["retail:base + retail"]
    direction TB
    RV["VALIDATE<br/>基础校验"] --> RP["PRICE<br/>仅原价候选"]
    RP --> RI["INVENTORY<br/>审计 → 普通库存 mock"]
    RI --> RO["应付 10000 分"]
  end
  subgraph Presale["presale:base + presale"]
    direction TB
    PV["VALIDATE<br/>基础 → 预售时间 mock"] --> PP["PRICE<br/>原价 / 预售价<br/>选择高优先级预售价"]
    PP --> PI["INVENTORY<br/>审计 → 预售额度 mock"]
    PI --> PO["应付 9000 分"]
  end
  subgraph Group["group:base + group"]
    direction TB
    GV["VALIDATE<br/>基础 → 团购资格 mock"] --> GP["PRICE<br/>原价 / 团购价<br/>选择高优先级团购价"]
    GP --> GI["INVENTORY<br/>审计 → 团购额度 mock"]
    GI --> GO["应付 8000 分"]
  end
  Engine --> RV
  Engine --> PV
  Engine --> GV

这证明了配置可以改变现有实现的组合。它没有证明平台能够自动发现产品,也没有证明相同接口下的任意实现可以安全组合。

接入一个复用现有能力的新业务

若新业务 community 恰好完全沿用既有团购规则,只需在样例的业务关系中增加:

1
businesses.put("community", Arrays.asList("base", "group"));

通过发布校验后,运行 engine.execute("community", 10000) 会选择团购价与团购额度。无需修改 Engine.invoke(),无需增加 CommunityPrice。完整程序包含该组合的断言,用于检验这项复用结论。

图 2:教学示意:新增 community 业务只增加组合关系,复用既有团购实现。

flowchart TB
  accTitle: 教学示意:community 复用既有团购实现
  Change["本次配置差异<br/>增加 community → base、group"] --> Publish["构建版本 5 快照并通过 publish 校验"]
  Publish --> Request["execute:community,10000 分"]
  Request --> Expand["Snapshot.rules 展开产品关系<br/>按能力 ID 去重"]
  Base["复用 base<br/>input、list-price、audit"] --> Expand
  Group["复用 group<br/>group-check、group-price、group-quota"] --> Expand
  Expand --> Run["既有 Engine 执行三个阶段"]
  Run --> Result["应付 8000 分<br/>事件列表与 group 业务相同"]
  Scope["样例仍需重新编译<br/>未实现配置中心或在线热发布"] -.-> Change

这里配置仍然写在 Java 样例里,所以修改 Map 后需要重新编译实验程序;“无需修改执行引擎”不等于已经实现线上无重启发布。生产实现可以从 JSON 或配置中心构建相同的候选快照,这需要额外的配置读取与发布设施。

如果新业务还要求“成团后才预留库存”,则现有三阶段执行顺序可能不足。此时需要增加状态或修改流程契约,不能只把 group 和 presale 产品都安装上。教学代码的 SINGLE 冲突保护恰好会拒绝两个同优先级价格同时适用的情形。

成功输出以外还需要验证什么

实验主程序的断言按行为组织:

场景 预期结果 要防止的错误
未知业务或不存在的配置引用 拒绝请求或拒绝发布 拼错配置却使用默认逻辑
SINGLE 最高优先级并列 拒绝执行 依赖类加载或 Map 顺序碰巧选中
没有适用计价候选 使用唯一默认价 缺省行为含混
已选计价实现抛异常 异常继续传播 依赖故障被伪装成正常原价
非法发布与合法新版本 非法版本不替换;请求使用一次取得的快照 请求混用不同版本的产品和规则

金额校验额外覆盖负数、按分舍入和大整数。计算折扣使用 BigDecimal,最终转换为 long 分;避免先用 long 乘百分比造成中间溢出。

图 3:教学示意:PRICE 无候选、候选冲突与实现故障走不同分支。

flowchart TB
  accTitle: 教学示意:PRICE 的默认、冲突与故障分支
  Filter["筛选已激活的 PRICE 规则<br/>原始金额达到 minCents"] --> Sort["优先级降序<br/>同优先级按 implementation 排序"]
  Sort --> Empty{"候选为空?"}
  Empty -->|是| Default["执行注册表中的 default-price<br/>保留原价,记录 price.default"]
  Empty -->|否| Tie{"最高优先级并列?"}
  Tie -->|是| Reject["抛出 ambiguous SINGLE<br/>本次请求终止"]
  Tie -->|否| Selected["只执行最高优先级候选"]
  Selected --> Outcome{"实现正常返回?"}
  Outcome -->|否| Error["异常继续传播<br/>不改用 default-price"]
  Outcome -->|是| Next["校验应付金额非负<br/>通过后进入 INVENTORY"]
  Default --> Next

实验的快照测试证明已有请求对象持有固定引用,以及非法发布后旧快照仍可用。它没有做多进程一致性或并发压力测试。两个应用各自更新到不同版本的问题,不能靠单 JVM 的 AtomicReference 解决。

ALL 顺序执行也不意味着事务。如果第二个库存动作抛异常,第一个动作的副作用可能已经发生。当前 mock 只记录事件,没有真实扣减,因此不会产生库存损失;生产系统需要独立设计幂等、持久化状态与补偿。

图 4:教学示意:库存阶段只追加内存事件,未验证真实库存事务。

sequenceDiagram
  title 教学示意:库存 mock 的执行与验证边界
  participant Engine as Engine.invoke
  participant Audit as Audit
  participant Quota as PresaleQuota
  participant Events as Context.events
  Note over Engine,Events: 预售业务的 INVENTORY 阶段,全部在单 JVM 内执行
  Engine->>Audit: apply(context),优先级 100
  Audit->>Events: 追加 inventory.audit
  Audit-->>Engine: 正常返回
  Engine->>Quota: apply(context),优先级 10
  Quota->>Events: 追加 inventory.presale-quota(mock)
  Quota-->>Engine: 正常返回
  Note over Quota,Events: 未连接库存服务或数据库<br/>未执行真实库存扣减与事务提交
  Note over Engine,Events: 事件顺序可以断言<br/>真实扣减的幂等与补偿仍需单独实现和验证

接口与实现究竟有没有远端绑定

需要分别回答两个问题:

1
2
业务扩展选择:这次应该采用哪个业务实现?
远程服务调用:该实现或它依赖的服务在哪里,怎样通信?

注册表可以返回直接执行本地逻辑的实现,也可以返回封装远程调用的客户端或代理。是否跨进程,取决于调用链是否发送网络请求;代理本身也可以只做日志、鉴权或本地路由。

JDK 动态代理把接口方法调用交给 InvocationHandler;处理器可以直接调用本地对象,也可以编码请求并交给 RPC 客户端。JDK Proxy 文档。本实验直接调用本地 Action,没有伪装成已经实现 RPC。

Lattice:有明确的远程扩展机制

Lattice 的 2022-10-01 教程展示了 protocolType = ProtocolType.REMOTE。调用方使用远程客户端与 Dubbo runner,另一进程运行插件容器,业务实现部署在那里。教程提供了使用 Dubbo、Nacos 的样例,而不是 Java RMI。Lattice:RPC 调用扩展点、远程示例仓库。

该版本文档还限制 REMOTE 的业务叠加,并建议必要时在远端容器内部完成本地叠加。这是有意限制远程调用规模的设计,不能把本地多实现归约无条件搬到网络上。

图 5:Lattice 文档中的远程扩展角色与调用方向。

sequenceDiagram
  title Lattice 文档中的远程扩展角色与调用方向
  participant App as 业务主服务
  participant Client as Lattice远程客户端
  participant RPC as Dubbo调用层
  participant Container as 远端插件容器
  participant Impl as 业务扩展实现
  App->>Client: 扩展点调用与业务上下文
  Client->>RPC: 使用远程runner发起调用
  RPC->>Container: 网络请求
  Container->>Impl: 定位并执行业务实现
  Impl-->>Container: 返回结果
  Container-->>RPC: 响应
  RPC-->>Client: 调用结果
  Client-->>App: 扩展点结果

图表达公开文档中的职责关系,未声称列出了所有内部类与报文字段。本次未部署该分布式示例,也不能把它证明的功能直接归给阿里内部 TMF2 的当前版本。

OCTO:从接口调用进入服务治理与通信链

OCTO 是美团公开的微服务通信与治理体系。它的 Appkey、服务实例发现、路由与负载均衡解决远端服务定位问题;这些对象并不天然包含“预售产品安装哪些业务能力”。美团 OCTO 开源介绍,2019-08-08。

公开 Dorado 源码可以确认一条具体链路。在提交 b7aaefe900368cfc4edf1fd793a2521e9f48aec1 中,JdkProxyFactory 调用 Proxy.newProxyInstance,把接口代理绑定到 DefaultInvocationHandler;后者将方法与参数封装为 RpcInvocation,交给 handler 处理。

接口在调用方得到的是代理,真正的业务处理在远端进程完成。代理提供相似的调用语法,却不能消除网络失败、超时和重复请求。

图 6:OCTO 代理入口依据固定提交源码,后续通信与服务端仅表示职责角色。

sequenceDiagram
  title OCTO 从 Java 接口代理到远端服务的职责时序
  participant App as 调用方
  participant Proxy as JDK接口代理
  participant Handler as DefaultInvocationHandler
  participant Chain as 调用与通信链(角色)
  participant Server as 远端服务(角色)
  App->>Proxy: 调用接口方法(args)
  Proxy->>Handler: invoke(proxy, method, args)
  Handler->>Handler: 将接口、Method与参数封装为RpcInvocation
  Handler->>Chain: handler.handle(invocation)
  Chain->>Chain: 实例选择与请求编码
  Chain->>Server: 经网络发送请求
  Server->>Server: 分派并执行服务方法
  Server-->>Chain: 网络响应
  Chain-->>Handler: RpcResult
  Handler-->>Proxy: result.getReturnVal()
  Proxy-->>App: 返回业务结果

Dorado 还把代理工厂等基础设施定义为 SPI 扩展。这与“根据业务身份挑选预售价”处于不同层次:前者决定框架如何生成代理或如何通信,后者决定业务采用什么规则。ProxyFactory.java、OCTO-Dorado 官方 Wiki。

华为云 CSE 与 ServiceComb:服务引用注解的对照

华为云 CSE 的公开定位包括注册发现、配置管理和服务治理,并支持 ServiceComb 等生态。这里选用 Apache ServiceComb 的公开文档作对照,不推断华为内部存在与 TMF 相同的业务产品模型。华为云:什么是微服务引擎。

ServiceComb 的跨应用调用文档给出了服务引用声明。其关键形态是:

1
2
3
@RpcReference(schemaId = "hello",
microserviceName = "helloApp:helloProvider")
private Hello hello;

这段历史 API 示例指向一个应用下的微服务与 schema。它说明注解可以声明“需要哪个远程服务契约”,而实际实例地址由框架与服务发现处理。具体集成应匹配所使用的 ServiceComb 版本。ServiceComb:Cross-application invocation。

该跨应用示例还要求服务提供方设置 service_description.properties.allowCrossApp: true。仅添加字段上的引用注解,并不足以完成跨应用调用。

它与 @Extension(id="presale-price", point=PRICE) 的建模对象不同:一个描述服务引用,一个描述本地业务扩展实现。业务组件可以依赖远程引用,但不能因此把远程引用本身解释为领域能力组合。

与RMI相似的部分,及其失效的地方

RMI 与这些 RPC 方案都可以让调用方通过接口或代理发起调用,参数被传到另一个进程执行。相似性停留在远程调用抽象;Java RMI 有自己的远程对象与调用体系,OCTO 使用其通信协议和治理机制,Lattice 示例接入 Dubbo。不能因为“像调用本地接口”就把它们称为 RMI。Java RMI 系统概述。

把业务组件远程化后,至少增加三个必须明确的契约:

  1. 调用结果未知时如何处理。客户端超时可能发生在远端完成写入之后,重试需要幂等键或可查询的执行记录。
  2. 上下文如何跨进程传递。业务身份、租户、trace 和配置版本的语义需要双方约定;传一个同名字段不等于双方规则一致。
  3. 失败能否降级。资格校验或价格计算失败后使用默认实现,可能改变商业结果,必须由业务契约明确授权。

图 7:响应丢失后的同键重试教学设计;业务幂等由应用实现,不表示 OCTO 提供此保证。

sequenceDiagram
  title 远端写入成功但响应丢失后的幂等重试
  participant Client as 业务客户端
  participant Service as 库存服务
  participant Store as 持久化存储
  Client->>Service: 预留库存(幂等键K,请求内容P)
  Service->>Store: 事务:登记K与P、扣减库存、保存结果R
  Note right of Store: K具有唯一约束<br/>业务写入与幂等记录<br/>处于同一事务
  Store-->>Service: 提交成功,返回R
  Service--xClient: 成功响应在网络中丢失
  Client->>Client: 等待超时,结果未知
  Client->>Service: 使用相同K与P重试
  Service->>Store: 查找K对应的请求与结果
  Store-->>Service: 已完成:P与R
  Service->>Service: 核对请求内容一致,不再扣减
  Service-->>Client: 返回已保存的结果R

网络开销也会影响扩展颗粒度。一个领域能力内部十个细小扩展点若各自跨进程,调用数量、失败概率和版本兼容面都会扩大。将具有一致事务或数据需求的一组规则放在同一个远程边界内,通常更容易管理;最终仍需要实际延迟与失败预算验证。

图 8:教学设计:跨进程传递配置版本,还需约定对应规则的含义。

flowchart TB
  accTitle: 教学设计:跨进程配置版本的语义约定
  subgraph Local["已实现的本地实验"]
    Read["execute 读取一次 published"] --> Ref["Context 持有 Snapshot 引用"]
    Ref --> Rules["三个阶段使用该快照展开的规则"]
  end
  subgraph Design["远程化时需要补充的契约:实验未实现"]
    Context["请求上下文<br/>业务身份、租户、trace、配置版本"] --> Transport["跨进程传递字段"]
    Transport --> Meaning{"双方是否约定版本<br/>对应的规则内容?"}
    Meaning -->|已约定| Check["按约定选择规则<br/>并验证兼容性与失败语义"]
    Meaning -->|未约定| Gap["同名字段不足以证明<br/>双方执行相同规则"]
  end
  Ref -.->|扩展到跨进程时| Context
  Boundary["本地 AtomicReference<br/>只更新本 JVM 的引用"] -.-> Ref

一条可实施的组合链路

图 5 是基于前述机制推导的架构,不代表已证实的 BPF 内部实现:

图 9:业务规则选择与远程服务定位分层的教学架构。

flowchart TB
  accTitle: 业务规则选择与远程服务定位分层的教学架构
  Request["可信业务身份与请求事实"] --> Config["业务配置版本"]
  Config --> Product["激活产品与能力"]
  Product --> Policy["选择业务扩展实现"]
  Policy --> Local["本地计算"]
  Policy --> Adapter["远程服务适配器"]
  Adapter --> Proxy["RPC接口代理"]
  Proxy --> Discover["服务发现、路由、实例选择"]
  Discover --> Remote["远端领域服务"]

适配器对上保持领域契约,对下使用 OCTO、ServiceComb 或其他 RPC 客户端。业务身份在上层选择规则,服务标识在下层选择服务,实例选择再决定某台机器。三个决策可以关联,但不宜复用同一套命名和生命周期。

平台能力复用到这里仍有可观察的判据:相同实现是否被多个业务调用,路由是否能解释,新增配置是否通过契约测试,失败时是否保持预期语义。注解是建立目录的一种方式;是否能快速接入业务,要由这些行为证明。

实验范围

本次运行证明了单进程教学模型的组合、选择、默认与错误处理。Lattice 远程扩展依据公开教程,OCTO 代理链依据固定提交源码,ServiceComb 依据公开文档;未运行它们的跨进程环境。

美团 BPF 的正式定义与实现仍未由本次检索确认。这里提供的是可追溯的公开机制与可运行推导,不以推导填补厂商事实。