跨平台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 / CFData | PrivateKey / PublicKey |
| 证书验证 | SecTrustEvaluateAsync | TrustManager / CertPathValidator |
| 存储方式 | Keychain (kSecClassKey / kSecClassCertificate) | Android Keystore (需指定alias) |
| 私钥访问 | SecKeyCreateWithData + kSecAttrKeyType | KeyStore.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,然后验证它是否调用了正确的方法。我写测试时,经常用这种模式来模拟网络超时、证书过期等异常场景。
三种设计原则的协同工作
抽象层、工厂模式、依赖注入,这三者不是孤立的。它们合在一起,才构成一个完整的跨平台架构。
我画了一张图来展示它们的关系:
从上图你可以看到:
上层业务只与抽象层打交道。
工厂负责在编译时选择合适的平台实现。
DI容器则把日志、网络、存储这些模块注入到核心引擎中。
改造完成之后,你再增加一个新的平台支持(比如HarmonyOS或者某个定制的安全硬件),只需要实现抽象层接口,然后在工厂里加一条宏分支就行。上层代码、测试用例、构建脚本,几乎不需要动。
几点实战忠告
关键点回顾:
- 抽象层的接口粒度要适中。太细了编码烦琐,太粗了平台差异不好屏蔽。我一般按照「一次操作一个安全动作」来划分。
- 工厂模式不要滥用。只在平台相关的对象创建点上使用。那些纯算法的辅助类,直接new也没问题。
- 依赖注入的容器要轻量。不要引入像Spring那样的大框架,跨平台SDK的核心是体积和性能。我自己写了个不到200行的
SimpleContainer,够用就行。
我曾经踩过的一个坑:
在抽象层中定义了vector<uint8_t>作为密钥数据的通用类型。结果iOS端的SecKeyRef转换成字节序列时,因为编码格式选错了(用了DER而不是PKCS#8),导致Android端解析出来的公钥长度不对,签名验证一直失败。后来我加了一个格式枚举参数,才把这个兼容问题堵住。所以,抽象层的数据类型定义,一定要明确编码格式和字节序。