给企业系统留一个插件槽:宿主与插件的边界设计

SailTrack
2026-07-16
点 赞
0
热 度
5
评 论
0
  1. 首页
  2. 后端开发
  3. 给企业系统留一个插件槽:宿主与插件的边界设计

写在前面

做企业系统迟早会撞上一个需求:"客户想加一点我们没做的功能。"硬编码的应对是改代码、发版本、走灰度;插件化的应对是把"扩展点"预留出来,第三方(或未来的自己)往槽里插实现。

我的 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 返回,宿主按自己的审计规则记录。数据从宿主流向插件,信任从插件流向宿主——两个方向别搞反。

六、小结

三点体会:

  1. 边界的本质是"默认拒绝"。 能力调用没声明就拒绝、未知插件直接拒绝、没验签的版本不认——每一个"默认拒绝"都把失效方向钉死在"不可用"上,而不可用是能被立刻发现的。
  2. 停用比启用难,先设计停用。 门闩 + 计数 + 排水的守卫模式,适用于一切"热卸载"场景:插件、功能开关、灰度下线。check-then-act 的竞态在同一把锁里解决。
  3. SDK 越薄,宿主越自由。 插件能看到的世界就是 SDK 导出的接口面;宿主内部怎么重构,插件一概不知也不必知。

插件槽上线后接的第一个真实插件,是一个对接公司内部 OA 的通知桥——开发到上线两天,宿主代码零改动。这个数字比任何架构图都有说服力。


让我们忠于理想,让我们面对显示

SailTrack

entp 辩论家

站长

不具版权性
不具时效性

文章内容不具时效性。若文章内容有错误之处,请您批评指正。

目录

欢迎来到SailTrack的站点,为您导航全站动态

65 文章数
11 分类数
5 评论数
60标签数