前言
在之前发布的 Java AOT 编译框架 GraalVM 快速入门一文中,我们介绍了 GraalVM
编译器的基础知识,对比了 GraalVM 和传统 JVM 之间的优势和劣势,并通过 Demo 示例展示了如何将 JVM 程序编译为原生可执行程序。GraalVM 除了编译原生可执行程序外,还可以用于编译动态链接库,提供给 C
、C++
等原生语言调用,GraalVM 编译动态链接库的能力大大提升了 Java 和原生语言之间的互操作性。本文将为大家介绍如何使用 GraalVM 编译动态链接库,并使用 C 语言调用动态链接库,从而实现基于 SM4
加解密的 MySQL UDF
。
GraalVM 动态链接库规范
首先,我们来了解如何使用 GraalVM 编译动态链接库,参考官方文档 Build a Native Shared Library,GraalVM 编译动态链接库,需要将 --shared
参数传递给 native-image
工具,默认会将 main
方法作为动态链接库的入口点方法,具体的编译命令如下:
1 | # 指定 class name |
如果类中不包含 mian
方法,则需要通过 -o
参数指定库名称,并且在 Java 类中通过 @CEntryPoint
注解,指定需要导出的入口点方法,使用 -o
参数指定库名称的命令如下:
1 | native-image --shared -o <libraryname> <class name> |
使用 @CEntryPoint
注解导出某个方法为动态链接库,需要满足以下条件:
- 方法必须声明为静态方法;
- 在要导出的方法上,使用
@CEntryPoint
注解进行标记; - 方法参数需要增加额外的
IsolateThread
或Isolate
类型参数,该参数用于提供当前线程的执行上下文; - 方法返回类型只能是 Java 基础类型,以及
org.graalvm.nativeimage.c.type
包中的类型; - 导出方法的名称必须保证唯一,否则
native-image
构建将会失败,如果未在@CEntryPoint
注解中通过name
指定名称,则必须在构建时提供-o <libraryName>
选项。
下面展示了动态链接库入口方法示例,通过 name
指定了动态链接库函数名为 add
,该函数有 3 个参数:thread
、a
和 b
,thread
用于提供当前线程的执行上下文,a
和 b
都是基础类型,因此可以直接使用在函数中。函数逻辑比较简单,将入参 a
和 b
的值相加求和,然后返回 int
类型的结果。
1 |
|
当我们使用 native-image
工具编译动态链接库时,它会生成一个动态链接库文件,以及一个 C 头文件,头文件中声明了导出的函数,可以直接在 C 代码中引入头文件,并调用 add
函数。
1 | int add(graal_isolatethread_t* thread, int a, int b); |
MySQL UDF 规范
介绍完 GraalVM 编译动态链接库,我们再来了解下 MySQL UDF 的使用规范。MySQL 8.0 官方文档 Adding a Loadable Function 详细介绍了如何使用 C/C++
编写 MySQL UDF,并将其编译为动态链接库部署到 MySQL plugin
目录中,然后通过 CREATE FUNCTION ... RETURNS ... SONAME 'xxx.so'
语句创建 UDF 函数。
编写 MySQL UDF 需要实现 3 个函数,分别是:初始化函数 xxx_init()
、主函数 xxx()
和析构函数 xxx_deinit()
。MySQL 会先调用 xxx_init()
进行初始化,如果 xxx_init()
返回异常,则主函数 xxx()
和析构函数 xxx_deinit()
都不会被调用,整个语句会抛出异常信息。如果 xxx_init()
执行成功,MySQL 会调用主函数 xxx()
执行函数逻辑,通常情况下每行数据都会调用一次。当所有数据行调用完主函数后,最后会调用 xxx_deinit()
对资源进行清理。
xxx_init 函数
xxx_init()
函数是 MySQL UDF 的初始化函数,可以用来完成如下的初始化工作:
- 检查传入函数的参数个数;
- 检验传入函数的参数数据类型,是否可为空,以及对参数类型进行类型转换;
- 分配函数所需的内存;
- 指定返回值的最大长度;
- 指定返回值的精度(针对返回值是
REAL
类型的函数); - 指定返回值是否可以为
NULL
。
初始化函数的声明如下:
1 | bool xxx_init(UDF_INIT *initid, UDF_ARGS *args, char *message); |
initid
参数中存储了初始化信息,它会传递给 UDF 涉及的 3 个函数,UDF_INIT
结构包含如下成员:
bool maybe_null
:用于标识当前 UDF 函数是否可以返回NULL
,如果可以返回NULL
,则需要在初始化时设置成true
。如果函数中的任意参数设置了maybe_null
为true
,则函数maybe_null
的默认值也为true
;unsigned int decimals
:指定小数点右侧的小数位数。默认值是传递给函数参数中最大的小数位数,例如,函数参数1.34
、1.345
和1.3
,则默认值为 3,因为1.345
有 3 个小数数字。对于没有固定小数位数的参数,decimals
值设置为 31,这比DECIMAL
、FLOAT
和DOUBLE
数据类型允许的最大小数位数多 1;unsigned int max_length
:指定返回值的最大长度。对于不同的返回值类型,max_length
的默认值不同,对于STRING
类型,默认值和最长的函数参数相等。对于INTEGER
类型, 默认值为 21。而对于 BLOB 类型的,可以将它设置成 65KB 或 16MB;char *ptr
:可实现 UDF 特定需求的指针,一般在xxx_init()
中申请内存,在xxx_deinit()
中释放内存,例如:用来存储 UDF 函数过长(长度超过 255 个字符)的字符串结果,默认情况下 UDF 主函数中的result
只能存储 255 个字符,超过 255 个字符需要自行通过ptr
指针申请内存,用于存储字符串结果;
1 | initid -> ptr = allocated_memory; |
bool const_item
:如果xxx()
函数总是返回相同值,xxx_init()
中可以把该值设置成 true。
args
参数用于存储 UDF 函数的参数信息,UDF_ARGS
结构包含如下成员:
unsigned int arg_count
:UDF 函数参数个数,可以在xxx_init()
函数中检查参数个数是否符合预期,例如:
1 | if (args -> arg_count != 2) { |
enum Item_result *arg_type
:用于定义参数类型的数组,可选值包括:STRING_RESULT
、INT_RESULT
、REAL_RESULT
和DECIMAL_RESULT
,可以在xxx_init
函数中检查参数类型,也可以将arg_type
元素设置为所需的类型,MySQL 在每次调用xxx
函数时都会进行强制类型转换;
1 | // 类型检查 |
char **args
:对于初始化函数xxx_init()
,当参数是常量时,例如:3
、4 * 7 - 2
或SIN(3.14)
,args -> args[i]
指向参数值,当参数是非常量时args -> args[i]
为NULL
。对于主函数xxx()
,args -> args[i]
总是指向参数的值,如果参数i
为NULL
,则args -> args[i]
为NULL
。对于STRING_RESULT
类型,args -> args[i]
指向对应的字符串,args -> lengths[i]
代表字符串长度。对于INT_RESULT
类型,需要强制转型为long long
,对于REAL_RESULT
类型,需要转型为double
;
1 | // INT_RESULT 类型参数强转为 long long |
unsigned long *lengths
:对于初始化函数xxx_init()
,lengths
数组表示每个参数的最大长度,对于主函数xxx()
,lengths
数组表示每个参数的实际长度;char *maybe_null
:对于初始化函数xxx_init()
,maybe_null
表示对应的参数是否可以为NULL
;char **attributes
:表示传入参数的参数名,参数名对应的长度存储在args -> attribute_lengths[i]
中,以SELECT my_udf(expr1, expr2 AS alias1, expr3 alias2);
为例,对应的参数名和参数名长度如下;
1 | args -> attributes[0] = "expr1" |
unsigned long *attribute_lengths
:表示传入参数名称的长度。
xxx 函数
xxx
函数是 MySQL UDF 的主函数,在 SQL 中调用对应的 UDF 函数时,每行记录都会调用 xxx
函数,SQL 的数据类型和 C/C++
的数据类型对应关系如下,这些数据类型可以用于函数参数和返回值。
SQL 类型 | C/C++ 类型 |
---|---|
STRING | char * |
INTEGER | long long |
REAL | double |
注意:也可以声明
DECIMAL
函数,但该值只能以字符串形式返回,因此你应该将函数编写为STRING
函数。
对于不同的返回值类型,xxx
函数的定义不同,下面分别列举了返回值 STRING
、INTEGER
和 REAL
类型的 UDF 函数定义:
1 | // 返回值是 STRING 或 DECIMAL |
主函数 xxx
如果出现了异常,需要将 *error
设置为 1
:
1 | *error = 1; |
此时,MySQL 执行到主函数时,会直接返回 NULL 结果,并忽略后续的数据行。和 xxx_init()
初始化函数不同的是,xxx
函数无法通过 message
返回错误,只能返回 NULL 值,并通过 fprintf
函数将错误日志输出到 MySQL ERROR
日志中(如果笔者理解错误,欢迎 MySQL 大佬指正)。
xxx_deinit 函数
xxx_deinit()
函数是 UDF 的析构函数,用于释放初始化函数分配的内存,以及进行其他的清理工作。xxx_deinit()
函数是可选的,如果 UDF 函数无需释放资源,则可以不实现该函数。 xxx_deinit()
函数的定义如下:
1 | void xxx_deinit(UDF_INIT *initid); |
GraalVM 实现 MySQL UDF 实战
前文我们已经介绍了如何使用 GraalVM 编译动态链接库,并了解了实现 MySQL UDF 的规范,下面我们通过一个实战案例,具体介绍下如何使用 GraalVM 实现基于 SM4 的加解密 UDF 函数。
基于 SM4 实现加解密
SM4 加密算法是国家密码管理局于 2012 年发布的,它是一种分组加密算法,目前 SM4 系列算法已经广泛应用于国内的安全加密领域。由于 SM4 系列算法主要应用于国内,JDK 的标准库中并未提供支持,因此我们需要通过第三方库 BouncyCastle 来实现 SM4 加解密。
使用 BouncyCastle 比较简单,首先需要在项目中依赖 BouncyCastle 库的 jar 包,我们可以通过如下的 Maven 坐标进行依赖:
1 | <dependency> |
然后借助 java.security
提供的扩展机制,使用 Security.addProvider(new BouncyCastleProvider());
注册 BouncyCastle
提供器。下面展示了一个简单的 SM4 算法加解密示例,通过 Cipher.getInstance("SM4/ECB/PKCS5Padding"
可以获取一个采用 ECB
模式的 SM4
算法,然后调用 init
方法分别对加密和解密模式进行初始化,最后调用 doFinal
方法执行加密和解密的逻辑。
1 | public final class SM4CryptographicDemo { |
SM4 算法加密和解密的执行结果如下图:
编译 SM4 加解密动态链接库
使用 BouncyCastle 库实现基础的 SM4 加密和解密后,我们尝试使用 GraalVM 来编译一个动态链接库。根据前文的介绍,GraalVM 编译动态链接库需要通过 @CEntryPoint
注解,由于我们需要实现加密和解密两个函数,因此需要分别定义加密和解密方法,并使用 @CEntryPoint
进行标记。如下的 SM4CryptographicNativeLibrary
类,展示了 SM4 加密和解密动态链接库的逻辑:
1 |
|
首先,SM4CryptographicNativeLibrary
类 static
静态代码块中注册 BouncyCastle
,从而保证 java.security
扩展机制能够识别到 BouncyCastle。然后在类中我们定义了 sm4Encrypt
和 sm4Decrypt
两个方法,并使用 @CEntryPoint
进行了方法导出,分别命名为 sm4_encrypt
和 sm4_decrypt
。方法内部会调用 getCipher
获取 SM4
加解密方法,并通过 GraalVM 提供的 CTypeConversion.toCString()
方法,将加密、解密后的结果存储到 CCharPointer
类型的缓冲区对象中,存储到缓冲区过程中需要指定 bufferSize
,由于 C 语言中会在字符串结尾自动添加 \0
转义符,所以这里需要 +1
处理。
我们执行如下的命令进行编译:
1 | ./mvnw -Pnative clean package -f mysql-udf |
编译成功后,target
目录下会生成动态链接库 libsm4_udf.dylib
,以及头文件 libsm4_udf.h
,后续我们可以使用 C 语言编写 MySQL UDF,并调用 GraalVM 生成的动态链接库。
使用 C 实现 MySQL UDF
根据前文介绍我们知道,使用 C/C++ 编写 MySQL UDF,需要实现 xxx_init
、xxx
和 xxx_deinit
3 个函数,其中 xxx_init
和 init
函数是必须实现的,而 xxx_deinit
则是可选的,如果没有资源需要释放,则可以不实现。笔者由于水平有限,简单使用 C 语言实现了 sm4_encrypt_udf
和 sm4_decrypt_udf
两个函数,分别用于 SM4 加密和解密操作。thread
对象的创建参考了 GraalVM 动态链接库文档,需要在调用动态链接库函数时传入 thread 参数,并且在最后通过 graal_tear_down_isolate
函数清理 thread 对象。
1 |
|
我们尝试使用 GCC
工具将 C 源码编译为动态链接库,通过 -I
指定编译时头文件位置,-L/path/to
用于指定编译时库文件的位置,-l
则用于指定库的名称,通常库名称不包含前面的 lib 前缀。-Wl,-rpath=/path/to
指定了运行时的库搜索路径,经笔者测试 MacOS 指定该参数会编译报错,Linux 下不指定该参数,运行时会找不到依赖库报错(具体原因有待进一步探索)。-shared
表示当前编译结果是动态链接库,-o
指定的输出的位置及动态链接库名称,最后则指定了 C 源码的位置。
1 | # -I 指定编译时头文件位置 |
编译完成后,我们在 target
目录中可以看到 sm4_encrypt_udf.so
文件,这个就是我们需要的 MySQL UDF 动态链接库。
使用如下的命令将 libsm4_udf.dylib
和 sm4_encrypt_udf.so
库复制到 MySQL /lib/plugin
目录下:
1 | cp mysql-udf/target/libsm4_udf.dylib /opt/homebrew/opt/mysql/lib/plugin/ |
然后执行 CREATE FUNCTION
语句,创建 sm4_encrypt_udf
和 sm4_decrypt_udf
函数。
1 | CREATE FUNCTION sm4_encrypt_udf RETURNS STRING SONAME 'sm4_encrypt_udf.so'; |
我们使用 SELECT sm4_encrypt_udf('123');
测试 UDF 查询语句,但是出现了如下的异常信息,MySQL 服务由于 UDF 函数异常出现了重启。
1 | mysql> SELECT sm4_encrypt_udf('123'); |
为了搞清楚异常的原因,我们使用 tail -400f /opt/homebrew/var/mysql/duanzhengqiangdeMacBook-Pro.local.err
命令查看 MySQL Error
日志。如下展示了完整的异常信息,可以看出 GraalVM 生成的动态链接库没有找到 SM4/ECB/PKCS5Padding
算法,异常的根本原因是 GraalVM 只能在编译期注册 Security Provider
,无法在运行期注册,因此我们需要手动注册 Security Provider
。
参考从零放弃学习 Spring - 在 Native Image 中使用 Bouncy Castle,可以使用 GraalVM 的 Feature 机制注册 BouncyCastleProvider
,我们编写如下的 SM4BouncyCastleFeature
类,实现 org.graalvm.nativeimage.hosted.Feature
接口,并通过 RuntimeClassInitialization
初始化 org.bouncycastle
相关的动态类。
1 |
|
然后将 SM4BouncyCastleFeature
类路径配置到 native-maven-plugin
插件的 buildArgs
中,完整配置为 --features=com.strongduanmu.SM4BouncyCastleFeature
。
1 | <build> |
然后执行 ./mvnw -Pnative clean package -f mysql-udf
以及 gcc -I/opt/homebrew/opt/mysql/include/mysql -I./mysql-udf/target -L./mysql-udf/target -lsm4_udf -fPIC -g -shared -o ./mysql-udf/target/sm4_encrypt_udf.so ./mysql-udf/src/main/native/sm4_encrypt_udf.c
重新编译生成动态链接库,打包完成后将动态链接库复制到 /opt/homebrew/opt/mysql/lib/plugin/
目录下,并使用 brew services restart mysql
重启 MySQL 服务。
重启完成后,执行以下的 SQL 脚本,删除之前创建的 UDF 函数,并重新创建新的 UDF:
1 | # 删除已创建 UDF |
然后我们再次测试 SM4 UDF 加解密功能,可以看到现在加密和解密都能够完美支持,完整的示例程序请参考 mysql-udf。
1 | mysql> SELECT sm4_encrypt_udf('123'); |
结语
本文首先介绍了如何使用 GraalVM 编译动态链接库,需要在 GraalVM 编译时增加 --shared
参数,并将需要导出的动态链接库方法,使用 @CEntryPoint
注解进行标记,方法参数中需要增加额外的 IsolateThread
或 Isolate
参数,用于提供当前线程的执行上下文。
然后又结合 MySQL 官方文档,为大家介绍了 MySQL UDF 函数的实现规范,UDF 函数中通常包含了 xxx_init()
、xxx()
和 xxx_deinit()
3 个主要函数,大家可以结合 UDF 函数的逻辑需要,在不同的函数中进行参数校验、UDF 逻辑实现等处理。
最后一个部分,我们使用 GraalVM 实现了一个基于 SM4 国密算法的加解密 UDF,核心的算法逻辑部分使用 GraalVM 编译,MySQL UDF 部分使用 C 语言实现,并调用 GraalVM 编译的动态链接库,最终我们完美地实现了加密和解密功能。
GraalVM 为 Java 生态带来了全新的应用场景,如何使用 GraalVM 将 Java 生态连接到原生应用中,这些都需要大家不断地思考和探索,欢迎大家积极留言交流 GraalVM 应用场景。另外,由于笔者水平有限,本文如有问题,也欢迎留言指正。
欢迎关注
欢迎关注「端小强的博客」微信公众号,会不定期分享日常学习和工作经验,欢迎大家关注交流。