——所有的复杂性,都是在对“行为”和“数据”做排列组合,并用指针/引用把它们粘合起来
引言
ossl_hitls作为一个连接OpenSSL,OpenHiTLS以及 pqc provider的provider项目,其开发涉及对各个模块接口的调用。在这个过程中,各个模块的API本身具有很明显的面向对象特征多,这给我带来了很多启发与思考,在好奇心的驱使下,我去读了《Head First:设计模式》这本书,结合之前的一些项目经验,分享一些见解和讨论:
我们的项目在做什么?
provider architecture
从Provider最主要的目的,我们是在实现多态:让上层密码接口(EVP_)与底层实现解耦,使得基于OpenSSL开发的软件无需改动代码,就可以切换实际完成密码运算的算法实现。
进一步地,我们还希望代码能够变得易读,变得精简,我们让不同的部分能够继承共同的部分。我们希望代码能够易用且安全,只暴露必须的接口,保证类型安全——我们希望实现封装。
于是凑齐了向对象的核心三要素——封装、继承、多态——这次要在C语言中动手实现。
好消息是OpenSSL经历了20多年的维护与发展,为我们提供了非常好的范例,虽然这里没有类的概念,也就没法去说设计模式。但是这个库,或者说provider框架本身是按照设计模式中的思想来设计的,可以发现API是围绕着设计模式构造的。
案例:策略模式——SDF Integration
直接说Provider本身就是策略模式感觉太片面,Provider应该是各种模式的组合模式,但肯定用到了策略模式。
之前刚好接触过一个项目能体现普通的策略模式,这里拿来分析一下:
这个项目背景是这样:
在Tongsuo中的SDF功能,至少适配一种密码卡或密码机。
SDF是指《GM/T 0018-2023》,就是为上层接口提供硬件密码卡设备的实现,但不是通过Provider,而是单独的SDK集成。
实现方案就是加载厂商提供的动态库驱动,绑定函数指针,转换数据结构。但如何支持多厂商?
同样的场景,同样的问题,不做provider如何实现这样的多态需求?
(SDF集成)[https://github.com/Tongsuo-Project/Tongsuo/pull/768]
解决方案就是一个放着函数指针的结构体数组——通过一个_bind_init函数在编译期进行结构体数组指针的赋值,即绑定“方法表”,
将当前硬件设备加载到的函数绑定到库中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| DEFINE_RUN_ONCE_STATIC(ossl_sdf_lib_init) { # ifdef SDF_LIB_SHARED # ifndef LIBSDF
# define LIBSDF "swsds" # endif
sdf_dso = DSO_load(NULL, LIBSDF, NULL, 0); if (sdf_dso != NULL) { #include "sdf_bind.h" sdf_bind_init(&sdfm, sdf_dso); printf("Debug1: This is a debug message.\n"); } # else printf("Debug: This is a debug message.\n"); # endif return 1; } #endif int sdf_bind_init(SDF_METHOD *sdfm,DSO *dso) { if (sdfm == NULL) { fprintf(stderr, "SDF_METHOD pointer is NULL\n"); return -1; } if (dso == NULL) { fprintf(stderr, "DSO object is NULL\n"); return -1; } sdfm->OpenDevice = (SDF_OpenDevice_fn)DSO_bind_func(dso, "SDF_OpenDevice"); sdfm->CloseDevice = (SDF_CloseDevice_fn)DSO_bind_func(dso, "SDF_CloseDevice"); sdfm->OpenSession = (SDF_OpenSession_fn)DSO_bind_func(dso, "SDF_OpenSession"); sdfm->CloseSession = (SDF_CloseSession_fn)DSO_bind_func(dso, "SDF_CloseSession"); sdfm->GetDeviceInfo = (SDF_GetDeviceInfo_fn)DSO_bind_func(dso, "SDF_GetDeviceInfo"); sdfm->GenerateRandom = (SDF_GenerateRandom_fn)DSO_bind_func(dso, "SDF_GenerateRandom"); sdfm->GetPrivateKeyAccessRight = (SDF_GetPrivateKeyAccessRight_fn)DSO_bind_func(dso, "SDF_GetPrivateKeyAccessRight"); sdfm->ReleasePrivateKeyAccessRight = (SDF_ReleasePrivateKeyAccessRight_fn)DSO_bind_func(dso, "SDF_ReleasePrivateKeyAccessRight"); sdfm->ExportSignPublicKey_RSA = (SDF_ExportSignPublicKey_RSA_fn)DSO_bind_func(dso, "SDF_ExportSignPublicKey_RSA"); …… sdfm->ExternalKeyDecrypt = (SDF_ExternalKeyDecrypt_fn)DSO_bind_func(dso, "SDF_ExternalKeyDecrypt"); sdfm->ExternalKeyEncryptInit = (SDF_ExternalKeyEncryptInit_fn)DSO_bind_func(dso, "SDF_ExternalKeyEncryptInit"); sdfm->ExternalKeyDecryptInit = (SDF_ExternalKeyDecryptInit_fn)DSO_bind_func(dso, "SDF_ExternalKeyDecryptInit"); sdfm->ExternalKeyHMACInit = (SDF_ExternalKeyHMACInit_fn)DSO_bind_func(dso, "SDF_ExternalKeyHMACInit"); #endif return 0; }
|
最有意思的是你可以发现它非常类似策略模式的java实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
| interface MethodTable { void bind(); }
abstract class Provider { MethodTable methodTable; public Provider() { methodTable = createMethodTable(); methodTable.bind(); } protected abstract MethodTable createMethodTable(); }
class HSMMethodTable implements MethodTable { public void bind() { System.out.println("绑定 HSM 硬件设备的方法"); } }
class CPUMethodTable implements MethodTable { public void bind() { System.out.println("绑定 CPU 软件实现的方法"); } }
class HSM extends Provider { protected MethodTable createMethodTable() { return new HSMMethodTable(); } }
class CPU extends Provider { protected MethodTable createMethodTable() { return new CPUMethodTable(); } }
|
这样:
1 2 3 4 5 6
| public class HSM{ public static void main(Stirng[] args){ Provider pctx = new HSM(); # 这会调用HSM集成来的绑定对应方法表的办法,进而委托给改对象进行处理。 pctx.do_calculate(); } }
|
如果只用继承的话,给父类加一个方法就要求了每个子类必须有,要么就覆盖掉。这样当子类多了就很不方便。
这里体现的设计原则就是:多用组合,少用继承。
但在这个例子中我们也发现了缺陷:我们的C实现是编译期绑定,一旦编译完成,就无法在运行时切换。Java实现虽然在运行时绑定,
但一旦绑定,就无法在运行期修改,且无法在Provider和实例之间交换信息,无法根据配置文件动态选择……
这能实现Provider的基本功能要求,但对于一个在几乎所有系统中底层基础设施的OpenSSL Provider的存在,是远远不够的。
我们来看看如何处理真实世界中这样更加复杂的问题。
OpenSSL Provider的设计模式讨论
策略模式
Provider设计本身就是一个策略模式的完美实现,如前所述,provider希望替换底层实现,实现多态。
这就是策略模式的定义:
定义了算法族,分别封装起来,让它们之间可以互相替换,此模式让算法的变化独立于使用算法的客户。
基于回调的观察者模式
为什么是基于回调?
因为我们没有类这个概念,但回调可以当成具备类的一部分特征的结构,我们用
OpenSSL的出错机制就是一个典型的观察者模式实现:
1 2 3 4 5 6 7 8 9 10 11
| typedef void (*ERR_PrintCallback)(const char* str, size_t len, void* u);
void ERR_set_callback(ERR_PrintCallback cb, void* ctx);
static void err_print(const char* str, size_t len) { ERR_PrintCallback cb = get_current_callback(); if (cb) cb(str, len, get_callback_ctx()); }
|
错误系统是被观察者,回调函数是观察者。
工厂模式:_fetch 机制
OpenSSL 3.0 最大的架构革新是引入了Provider和_fetch系列函数:
这个非常经典,也很常见。
1 2 3 4 5 6 7 8 9 10 11 12
| EVP_MD* md = EVP_MD_fetch(NULL, "SHA3-256", NULL); EVP_CIPHER* cipher = EVP_CIPHER_fetch(NULL, "AES-256-GCM", NULL);
EVP_MD* EVP_MD_fetch(OSSL_LIB_CTX* ctx, const char* algorithm, const char* properties) { }
|
适配器模式:连接不同provider
在ossl_hitls项目中,需要同时调用OpenSSL和OpenHiTLS两套API。两者的设计哲学差异巨大:
| 维度 |
OpenSSL 3.0 |
OpenHiTLS |
| 架构模式 |
Core + Provider |
传统模块化 |
| 上下文传递 |
显式ctx参数 |
隐式全局状态 |
| 多态实现 |
函数指针表 |
条件编译 + 钩子 |
适配层的设计必须同时消化两种风格:
这个例子不引用具体代码,但是项目中有多处可以体现。比如在Encoder/Decoder讨论中,OpenSSL为这个操作提供的OBJECT_EXPORT函数,用统一接口包装不同的API。
副作用:
OpenSSL3.0这样的设计带来了两大副作用,性能下降和易用性降低。
你会发现使用的EVP接口成双成对,甚至一个及其简单的操作都需要初始化各种的上下文_CTX结构,调用重重接口才能实现。
这对于初学者是非常不友好的,但随着时间的推移,了解逐步深入:
你可以发现,所谓_CTX其实就是类,为了管理大量不同的算法,就会有父类,子类,子类的子类……
但是OSSL_LIB_CTX是你的工作台,它管理了一切,而EVP_PKEY_CTX等具体结构是你的操作对象,其中再初始化出具体的算法的CTX。
类之间如何传递信息?使用OSSL_PARAM,使用OSSL_DISPATCH,使用OSSL_ALGORITHM……
你无需记住各种各样的接口名,数据结构名,但通过对这里面向对象机制的了解,你知道在哪里需要做什么,为什么这样做,于是根据接口命名规范和文档查询,
就可以实现比较流畅的使用。时间长了就会不再觉得特别困难,还能被各种神奇的加密解密机制所吸引。
最关键的是,OpenSSL的接口设计也在潜移默化地规范你的编程习惯,锻炼实际的工程水平。
总结
上文中我们一直在尝试用面向对象的思考方式解释一个C库中的种种实现。但是这可能有点不太严谨,因为C中没有类的概念,自然也没有设计模式。
我们从中发现了不同编程语言的影子,发现了不同设计模式。
但我们发现了共同的目的:多态、继承、封装,共同的手段:抽象。共同的现实问题与业务场景。
那不同实现方式之间的联系是什么?
从C到面向对象
可以发现C语言中没有class关键字,但是各种各样的结构都有着类的特征,它们都具备一部分类的功能!
eg:
结构体嵌套:继承
函数指针数组:多态
不透明指针:封装
结构体+函数指针:类的简单模拟
回调函数 + 上下文指针:类的成员方法与this指针
……
我们发现共同点是不同结构的排列组合,实现一部分我们所需要的类的功能!
面向对象到C
类的各种特性就像是把各种C的基本结构的打包组合。
那我们如何用面向对象的方法实现一个C的函数指针?——一个没有成员变量的类!
回到开头的引言:——所有的复杂性,都是在对“行为”和“数据”做排列组合,并用指针/引用把它们粘合起来
把数据传递给行为,把行为传递给行为,把数据和行为传递给行为,以及之上的多层嵌套。这就是C语言中多级指针、泛函数、泛指针的实际行为,这也是所有
程序设计语言不断在重复的行为。
高级语言通用性、易用性换来的是性能降低,而C语言具有无可替代的灵活性与编程自由度。
这些设计都是不断的tradeoff。