跨平台PKI SDK设计原则:抽象层、工厂模式、依赖注入

改造跨平台PKI SDK,说白了就是要把iOS和Android那套完全不同的底层实现,用一套统一的API包起来。你想想看,iOS那边用的是Security.framework,Android这边从4.0到14,密钥存储和证书验证的逻辑变了好几轮。如果代码里到处都是#ifdef和#available,那维护成本迟早会炸。

我在做这个改造之前,其实翻了不少开源项目。有些项目为了让两端接口一致,直接在Objective-C和Java层做桥接,结果每次系统升级都得跟着改一层。我今天想跟你聊聊怎么用抽象层、工厂模式、依赖注入这三个手段,把跨平台的耦合降到最低。

抽象层:屏蔽平台差异的第一道屏障

抽象层是什么?就是一个定义好的接口集。iOS和Android各自实现这个接口。上层调用方根本不需要知道底下是Keychain还是Android Keystore。

我个人习惯先列一个最小的核心接口清单。比如说:

  • 密钥生成:生成RSA或ECC密钥对
  • 证书存储:导入/导出X.509证书
  • 签名与验签:用私钥签名,用公钥验签
  • 信任链验证:从叶子证书验证到根证书

这些接口在iOS和Android上的实现差异有多大?我给你看个对比表:

功能iOS (Security.framework)Android (KeyStore)
密钥格式SecKeyRef / CFDataPrivateKey / PublicKey
证书验证SecTrustEvaluateAsyncTrustManager / CertPathValidator
存储方式Keychain (kSecClassKey / kSecClassCertificate)Android Keystore (需指定alias)
私钥访问SecKeyCreateWithData + kSecAttrKeyTypeKeyStore.getKey(alias, password)
系统支持iOS 10+ (SecKeyCopyExternalRepresentation)API 28+ (StrongBox / TEE)

你看,光是密钥格式这一项,两端就差了十万八千里。如果没有抽象层,上层代码就得写一大堆条件判断。我见过一个项目,一个签名函数里嵌套了四个if #available,那读起来真是……嗯,你懂的。

抽象层怎么设计?我用C++的纯虚类来定义:

class PKIKeyProvider {
public:
    virtual ~PKIKeyProvider() = default;

    // 生成密钥对,返回公钥数据
    virtual std::vector<uint8_t> generateKeyPair(
        const std::string &keyAlias,
        KeyAlgorithm algorithm,
        int keySize
    ) = 0;

    // 用私钥签名
    virtual std::vector<uint8_t> sign(
        const std::string &keyAlias,
        const std::vector<uint8_t> &data,
        SignatureAlgorithm algorithm
    ) = 0;

    // 导入证书到安全存储
    virtual bool importCertificate(
        const std::vector<uint8_t> &certData,
        const std::string &alias
    ) = 0;

    // 验证证书链
    virtual bool verifyCertificateChain(
        const std::vector<std::vector<uint8_t>> &certs,
        const std::vector<uint8_t> &trustAnchors
    ) = 0;
};

这里有个坑:C++虚函数的析构函数一定要写成 virtual ~PKIKeyProvider() = default;。我曾经因为忘了加 virtual,导致子类析构时资源没释放干净,在Android上跑了三天才出现崩溃。那定位过程……真是一言难尽。

抽象层定义完,iOS端和Android端各自写子类实现。编译时通过平台宏选择对应的实现文件。上层代码根本不知道底下是iOS还是Android,它只看到PKIKeyProvider这个接口。

工厂模式:让对象的创建不再硬编码

抽象层把接口定好了,那具体用哪个实现类?总不能在业务代码里写if (platform == IOS)吧?工厂模式就是用来解决这个问题的。

我个人习惯用一个静态工厂方法,把平台的判断完全封装在工厂内部:

class PKIFactory {
public:
    static std::unique_ptr<PKIKeyProvider> createKeyProvider() {
#if defined(__APPLE__)
        return std::make_unique<iOSKeyProvider>();
#elif defined(__ANDROID__)
        return std::make_unique<AndroidKeyProvider>();
#else
        return nullptr;
#endif
    }

    static std::unique_ptr<PKICertificateStore> createCertificateStore(
        const std::string &storePath
    ) {
#if defined(__APPLE__)
        return std::make_unique<iOSCertificateStore>(storePath);
#elif defined(__ANDROID__)
        return std::make_unique<AndroidCertificateStore>(storePath);
#else
        return nullptr;
#endif
    }
};

你看,工厂里只有两个宏开关。上层拿到PKIKeyProvider和PKICertificateStore之后,怎么用都一样。

这里有个小技巧:工厂方法返回std::unique_ptr。为什么不用裸指针?
我见过一些跨平台项目返回原始指针,结果调用方忘了delete,内存泄漏查都查不到。用智能指针的话,生命周期自动管理,省心很多。

那如果将来要加一个HarmonyOS版本呢?很简单,加一个#elif defined(__OHOS__)的分支,实现类写成HarmonyKeyProvider。工厂模式的好处就是:新增平台不影响已有代码。

依赖注入:让SDK的内部组件解耦

抽象层和工厂模式搞定之后,你可能会想:那SDK内部的各个模块之间,怎么通信?比如日志模块、网络模块、安全存储模块。总不能每个模块都自己去new吧?

我在做第一版改造的时候,就踩过这个坑。PKIEngine里直接new了一个Logger和一个NetworkClient。后来想换成自定义日志,结果发现要改PKIEngine的构造函数,还得重新编译整个SDK。那感觉……就跟盖房子时把承重墙砌歪了差不多。

依赖注入的做法是:把模块的创建权交给外部。SDK内部只声明需要什么,不负责怎么创建。

看个简化例子:

class PKIEngine {
public:
    // 依赖通过构造函数注入
    PKIEngine(
        std::shared_ptr<Logger> logger,
        std::shared_ptr<NetworkClient> netClient,
        std::unique_ptr<PKIKeyProvider> keyProvider
    ) : logger_(logger),
        netClient_(netClient),
        keyProvider_(std::move(keyProvider)) {}

    void performCertificateValidation(
        const std::vector<uint8_t> &certChain
    ) {
        logger_->info("开始证书验证");
        // 调用keyProvider进行验证
        bool valid = keyProvider_->verifyCertificateChain(
            decodeCerts(certChain),
            getTrustAnchors()
        );
        if (!valid) {
            logger_->warn("证书验证失败");
            netClient_->reportFailure(certChain);
            return;
        }
        logger_->info("证书验证通过");
    }

private:
    std::shared_ptr<Logger> logger_;
    std::shared_ptr<NetworkClient> netClient_;
    std::unique_ptr<PKIKeyProvider> keyProvider_;
};

注意这里用的指针类型:shared_ptr用于日志和网络客户端这类可能被多个对象共享的组件;unique_ptr用于密钥提供者这类独占资源。这两种指针的区别,我在调试一个共享密钥引用计数的Bug时体会特别深——用shared_ptr管理私钥,结果两个线程同时签名时引用计数错乱,私钥被提前释放。后来改成unique_ptr加显式生命周期管理才解决。

依赖注入还有一个好处:单元测试变得特别方便。你可以给PKIEngine注入一个Mock的Logger和一个Mock的NetworkClient,然后验证它是否调用了正确的方法。我写测试时,经常用这种模式来模拟网络超时、证书过期等异常场景。

三种设计原则的协同工作

抽象层、工厂模式、依赖注入,这三者不是孤立的。它们合在一起,才构成一个完整的跨平台架构。

我画了一张图来展示它们的关系:

跨平台PKI SDK设计原则协同图 上层业务应用 PKI抽象层(纯虚接口) PKIKeyProvider | PKICertificateStore | PKITrustManager PKI工厂 DI容器 创建实现 注入依赖 可选的工厂+DI组合 iOS实现 iOSKeyProvider Android实现 AndroidKeyProvider 未来平台实现 HarmonyKeyProvider iOS实现 Android实现 未来实现 抽象层定接口 → 工厂创建平台实现 → DI容器管理模块依赖 新增平台只需添加实现类,无需修改上层业务代码

从上图你可以看到:
上层业务只与抽象层打交道。
工厂负责在编译时选择合适的平台实现。
DI容器则把日志、网络、存储这些模块注入到核心引擎中。

改造完成之后,你再增加一个新的平台支持(比如HarmonyOS或者某个定制的安全硬件),只需要实现抽象层接口,然后在工厂里加一条宏分支就行。上层代码、测试用例、构建脚本,几乎不需要动。

几点实战忠告

关键点回顾:

  • 抽象层的接口粒度要适中。太细了编码烦琐,太粗了平台差异不好屏蔽。我一般按照「一次操作一个安全动作」来划分。
  • 工厂模式不要滥用。只在平台相关的对象创建点上使用。那些纯算法的辅助类,直接new也没问题。
  • 依赖注入的容器要轻量。不要引入像Spring那样的大框架,跨平台SDK的核心是体积和性能。我自己写了个不到200行的SimpleContainer,够用就行。

我曾经踩过的一个坑:
在抽象层中定义了vector<uint8_t>作为密钥数据的通用类型。结果iOS端的SecKeyRef转换成字节序列时,因为编码格式选错了(用了DER而不是PKCS#8),导致Android端解析出来的公钥长度不对,签名验证一直失败。后来我加了一个格式枚举参数,才把这个兼容问题堵住。所以,抽象层的数据类型定义,一定要明确编码格式和字节序。