写在前面
做企业系统迟早会撞上一个需求:"客户想加一点我们没做的功能。"硬编码的应对是改代码、发版本、走灰度;插件化的应对是把"扩展点"预留出来,第三方(或未来的自己)往槽里插实现。
我的 Workforce Hub 就留了这样一个槽。这篇不聊怎么加载 JAR(那是类加载器的一套套路),聊更本质的问题:**宿主和插件之间的边界怎么设计,才能既开放又不失控。**拆开看是四个边界:能力边界、安全边界、生命周期边界、数据边界。
一、SDK 就是边界:插件能看到的世界
插件的开发依赖只有 plugin-sdk 一个包,里面的类型清单就是这个系统的"宪法":
WorkforcePlugin — 插件入口接口(生命周期回调)
HostService — 宿主能力接口(向插件开放的唯一入口)
PluginActionHandler — 插件动作处理器(插件提供的能力)
RequiresCapability — 能力声明注解
InvocationContext — 调用上下文(谁、哪个插件、在调什么)
EventTypes / DomainEvent — 事件契约
ParamSchema — 参数结构描述
ServiceResult — 统一返回包装
设计原则一句话:SDK 里有的才能用,SDK 里没有的不存在。 插件代码里 import 不到宿主的 Repository、Service、实体类——宿主的内部世界对插件是编译期不可见的。这条边界在编译期就 enforced,比运行时的任何检查都早。
反面教材是"把整个 Spring 上下文暴露给插件"的方案:插件确实什么都能干了,但插件作者知道得越多,宿主重构的成本就越高——每次内部 API 调整都可能弄坏一批插件。边界越窄,宿主的自由度越大。
二、能力调用:默认拒绝的动态代理
插件调用宿主能力(比如"查一下这个员工的信息")走的是 HostService。关键设计:插件拿到的不是宿主实现类,而是一个 JDK 动态代理,每次调用过五关:
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// 1. 首参必须是 InvocationContext(声明的调用身份)
if (args == null || args.length == 0 || !(args[0] instanceof InvocationContext ctx)) {
throw new PermissionDeniedException("HostService 方法首参必须为 InvocationContext");
}
// 2. 身份必须匹配:ctx 里的 pluginId 必须等于代理绑定的 pluginId
if (!pluginId.equals(ctx.getPluginId())) {
throw new PermissionDeniedException("调用者 pluginId 不匹配: 期望 %s, 实际 %s");
}
// 3. 方法必须有非空的能力注解
RequiresCapability annotation = method.getAnnotation(RequiresCapability.class);
if (annotation == null || annotation.value().isBlank()) {
throw new PermissionDeniedException("方法缺少能力声明: " + method.getName());
}
// 4. 以数据库为准校验插件是否持有该能力
permissionService.checkPluginCapability(pluginId, annotation.value());
// 5. 全部通过,才反射调用真正的宿主实现
return method.invoke(delegate, args);
}
第 3 条是整个模型的点睛之笔:宿主方法忘写 @RequiresCapability 时,插件调用直接被拒,而不是放行。这叫默认拒绝(deny by default)——新增宿主能力时,开发者忘了配权限,结果是"插件调不通"(功能故障,日志里一眼可见),而不是"插件静默拿到了未授权能力"(安全事故,可能永远没人发现)。安全机制的失效方向必须是"不可用",而不是"不设防"。
第 4 条把权限判断放在数据库里而不是代码里:管理员可以在运行时收回某个插件的能力,下次调用立刻生效,无需重启、无需重新分发。
三、安装安全:签名是身份证
插件 JAR 的安装入口有签名校验:
private static final String SIGNATURE_ALGORITHM = "SHA256withRSA";
流程是上传时验证 JAR 的 RSA 签名(对照登记过的 signerId 公钥),通过后快照校验结果入库,版本服务只认"验过签的版本"。插件想升级?新 JAR 必须重新签名——同一插件身份的不同版本,必须出自同一私钥。
配套的还有配置加密(PluginConfigCipher):插件配置里的第三方 API Key 这类敏感值,落库前加密,运行时解密注入。宿主替插件管好钥匙,插件作者就不需要在配置文件里明文写密钥。
四、生命周期:停用一个插件比启用难十倍
启用一个插件,难点是初始化顺序;停用的难点是"它可能正干到一半":处理中的 HTTP 请求、执行中的动作调用、排队中的事件。我的方案是一个并发守卫(PluginLifecycleGuard),核心是"门闩 + 计数器":
// 请求入口:原子地检查门开 + 计数。失败则插件正在停用或未注册。
public boolean tryAcquire(String pluginId) {
PluginSlot slot = slots.get(pluginId);
if (slot == null) {
return false; // fail-closed:不创建 slot,未知插件直接拒绝
}
slot.lock.lock();
try {
if (!slot.gateOpen) {
return false; // 门已关(停用中)
}
slot.activeCount++;
return true;
} finally {
slot.lock.unlock();
}
}
停用流程:先关门(gateOpen = false,新请求全部被拒),再排水(等待 activeCount 归零,默认超时 30 秒),最后卸载。排水超时就放弃等待并报告——宁可停用失败让人处理,也不要强杀一个正持有数据库事务的插件。
这里有一个竞态的经典陷阱,注释里写得直白:“解决’状态检查后、计数前’竞态”。如果检查门开和计数加一不是原子的,停用流程就能在两步之间溜进来关门并以为没人了——于是插件在"已停用"的错觉下继续处理请求。先检查后行动(check-then-act)的竞态,必须在同一把锁里做掉,这是并发编程里最常被低估的一条纪律。
tryAcquire 返回 false 时不创建 slot 也是有意的:未知插件、已卸载插件的调用一律拒绝(fail-closed),而不是"顺手建个槽让它跑起来"。
五、数据边界:插件有自己的库表
插件要存数据怎么办?宿主提供数据库连接,但每个插件有自己的一套表、自己的 Flyway 迁移链(PluginFlywayMigrator 按插件管理迁移脚本),插件写的是自己的表,宿主的业务表对插件只读、且必须经 HostService。
这个边界省下的是未来的眼泪:插件卸载时,删掉它自己的表就是完整的数据清理,不用担心误伤宿主数据;插件升级改自己的表结构,走自己的迁移链,和宿主发版节奏无关。
事件方向是单向的:宿主的领域事件(文件上传、员工入职、审批完成)通过监听器桥接进插件的事件分发器,插件可以订阅;反过来,插件完成动作后通过 ServiceResult 返回,宿主按自己的审计规则记录。数据从宿主流向插件,信任从插件流向宿主——两个方向别搞反。
六、小结
三点体会:
- 边界的本质是"默认拒绝"。 能力调用没声明就拒绝、未知插件直接拒绝、没验签的版本不认——每一个"默认拒绝"都把失效方向钉死在"不可用"上,而不可用是能被立刻发现的。
- 停用比启用难,先设计停用。 门闩 + 计数 + 排水的守卫模式,适用于一切"热卸载"场景:插件、功能开关、灰度下线。check-then-act 的竞态在同一把锁里解决。
- SDK 越薄,宿主越自由。 插件能看到的世界就是 SDK 导出的接口面;宿主内部怎么重构,插件一概不知也不必知。
插件槽上线后接的第一个真实插件,是一个对接公司内部 OA 的通知桥——开发到上线两天,宿主代码零改动。这个数字比任何架构图都有说服力。
默认评论
Halo系统提供的评论