第3章 SDK架构与组件介绍:模块划分、依赖关系、核心接口设计思路

说实话,拿到一个DSPM集成SDK,第一反应千万别急着看代码。我个人习惯先摸清它的骨架——模块怎么拆、谁依赖谁、接口是抽给谁用的。这套东西捋顺了,后面接入改造才不会踩坑。

几年前我第一次对接一个国产SDK,上来就改业务代码,结果编译通过了但运行就崩。后来发现核心模块和网络模块有循环依赖,编译期检测不出来,运行时才炸。从那以后,我再也不敢跳过架构分析这一步。

模块划分:拆得越清楚,改起来越舒服

大多数DSPM SDK都会遵循分层+领域驱动的方式拆模块。我见过比较合理的一种划分如下:

模块名职责典型组件
Core(核心层)生命周期管理、上下文传递、调度引擎SDKContext、LifecycleManager、TaskScheduler
Network(网络层)通信协议封装、连接池、重试与超时HttpClient、WebSocketClient、RetryPolicy
Security(安全层)加密、签名、鉴权、证书管理CryptoProvider、Signer、AuthManager
Config(配置层)远程拉取配置、本地缓存、动态刷新ConfigFetcher、ConfigCache、ConfigListener
Monitor(监控层)性能采集、错误上报、日志聚合MetricsCollector、ErrorReporter、Logger
API(对外开放接口层)业务方法、数据模型、异常定义DataReportApi、ConnectionApi、SDKException
小提示: 我习惯把API层单独拎出来,不跟任何内部模块耦合。这样外部业务方只依赖这一个编译单元,内部重构时改接口签名的影响面最小。

依赖关系:别让模块“抱得太紧”

模块拆完了,下一步看依赖。这是最容易出鬼的地方。我记得有一次评审同事的PR,发现Monitor模块直接依赖了Network层的HttpClient——这是个典型的反向依赖。按规范,监控层应该只依赖Core和API,不能碰具体实现。

下面这张图是我自己整理的典型依赖关系,你们可以对照手头的SDK看看有没有跑偏。

DSPM SDK模块依赖关系图 DSPM SDK 典型模块依赖关系 Core(核心层) Network(网络层) Security(安全层) Config(配置层) Monitor(监控层) API(对外开放) 箭头方向:被依赖方指向依赖方(下游) 说明: API层为纯接口定义,不依赖任何内部模块;Core负责运行时编排

你想想看,如果Monitor依赖Network,万一我们要切换网络库(比如从OkHttp换到Netty),监控代码也得跟着改,这不就耦合了么?正确的做法是:所有非API模块都只依赖Core,而Core不依赖任何具体实现,只定义抽象接口。Network、Security这些模块各自实现Core定义的接口,然后通过依赖注入组装。

核心接口设计思路:不过度设计,但要有弹性

接口设计这块,我吃过不少亏。早期我总喜欢“一次性把接口想全”,结果加了一堆没人用的参数。后来我学乖了——核心接口只解决三个问题:

  • 初始化:把SDK启动起来,配置加载完毕,连接就绪。
  • 数据上报:业务方只需传业务数据,剩下的压缩、加密、重试全由SDK内部搞定。
  • 状态反馈:监听连接状态、错误信息、性能指标,方便业务做降级。

下面是我现在用的一份伪代码,实际项目也差不多这个味道:

public class DspmSdk {
    // 初始化——参数链式构建,方便扩展
    public static DspmSdk init(Config config) {
        // 内部的Core会根据config自动组装各模块
        return new DspmSdk(CoreFactory.create(config));
    }

    // 数据上报——异步非阻塞,调用方不用关心结果
    public CompletableFuture<ReportResult> report(ReportData data) {
        // 内部走拦截器链:压缩、加密、签名、发送、重试……
        return core.getReportPipeline().execute(data);
    }

    // 状态监听——业务方可注册回调
    public void setStateListener(StateListener listener) {
        core.getStateManager().addListener(listener);
    }

    // 优雅关闭
    public void shutdown() {
        core.destroy();
    }
}
核心原则: 接口要面向业务场景设计,而不是面向实现细节。一个report方法就够了,别把“aesEncrypt”“compress”这些内部函数暴露出来。暴露得越多,以后改内部实现就越难。

避坑指南:接口粒度的取舍

举个例子。我曾经参与一个项目,设计师把数据上报接口拆成五个粒度:reportBinary、reportJson、reportBatch、reportStream、reportRaw。看起来很灵活对吧?现实是业务方根本搞不清用哪个,最后全用reportRaw传字符串,搞的SDK内部的压缩加密完全失效。

后来我们重构成了只留一个report(ReportData data),内部根据data的类型自动选择序列化和传输策略。接口变简单了,但内部可扩展性反而更强了。

警告: 接口暴露过多实现细节,等于把内部依赖关系丢给了调用方。记住:你设计接口时,心里得装着“三年后换底层通信协议,调用方代码一行都不用改”的目标。

模块间的双向依赖:能解则解,不惜代价

嗯,这里必须单独提一下双向依赖。我在做代码审计时,发现一些SDK为了“方便”,让Config模块直接回调Core模块的方法来刷新配置。这不就是双向依赖么?编译没问题,但你要做单元测试时就傻了——Mock来Mock去,测一个配置模块得拉起整个Core。

正确做法是引入事件总线或者观察者模式。Core只往外发事件,Config模块监听事件后自行更新,绝不反向调用Core的接口。我建议在架构层面就禁止任何双向依赖,哪怕增加一点代码复杂度也要拆掉。

小结一下

模块划分的核心是“高内聚、低耦合”,依赖关系要画一张清晰的单向图,接口设计要克制——少即是多。你想想看,如果能做到这三条,后续的接入改造至少能少踩70%的坑。我个人这几年接手的SDK,凡是架构混乱的,无一例外都是这三条没做好。

接下来,我们把目光放远一点。记住这张模块图,后面讲改造流程时,我会反复引用它。