封装、发布一个腾讯 mars xlog 的 KMP lib(续):更多平台

Posted by Piasy on June 12, 2023
本文是 Piasy 原创,发表于 https://blog.piasy.com,请阅读原文支持原创 https://blog.piasy.com/2023/06/12/KMP-XLog2/

本文是封装、发布一个腾讯 mars xlog 的 KMP lib 的续集,添加了更多平台的支持:JS/Linux/Windows/macOS。也探索了这些平台的库发布方案,以及单元测试,最后测试了一下 iOS 平台引入 KMP 的包大小成本(147KB)。

JS 支持

我首先添加的是 JS 平台的支持,但显然腾讯的 mars xlog 是没有 JS 平台的。经过一番调研,我发现在浏览器环境下,JS 是不能随意读写文件的,要么就是 console log 然后用户 save as,要么就是先放内存,再给用户做一个 download 功能,而后者我也还没有找到现成的解决方案,后面可以考虑使用 worker + web assembly 进行压缩,并采用腾讯 mars xlog 相同的压缩方式,根据之前 AvConf 的日志文件来看,大约半小时有 1.5MB 左右的内容,对于 PC 端来说,这点内存开销应该不成问题。

不过目前就还是先用 console log 实现好了,AvConf web 端也是这么做的。具体的实现代码很简单,而且 Kotlin 代码可以直接调用 JS 代码,所以也不涉及 JS 代码发布的问题。不过即便需要发布 JS 代码,也可以通过 npm 进行发布,KMP gradle 插件支持引入 npm 依赖,和 iOS 的 cocoapods 类似,很方便。

感兴趣的朋友可以看 GitHub 上的提交

build script 编写

接下来在添加 Linux 平台支持的时候,就遇到了一个 build script 的问题:之前的 Android/iOS/JS 都可以在 macOS 上进行编译、发布,但是 Linux/Windows 则不行,必须在 Linux/Windows 上编译、发布,而在 Linux/Windows 平台上,显然是没法编译 iOS 平台的。所以启用哪些平台就需要判断当前系统类型,而 kts 语法只支持 plugins 代码块里引入的插件的语法糖,而 plugins 代码块里不支持 if 语法,所以要么就是 Android/iOS 相关插件的代码都写得很冗长复杂(且丑陋),要么就得放弃 kts 语法。两害相较取其轻,最后我还是切换回了 groovy 语法。

Linux 支持

添加 Linux 平台的支持我着实费了一番工夫。wrapper 代码的编写倒是简单,之前做过,稍微修改下就可以直接用。但把 example 跑起来就折腾了大半个月(当然不是持续在搞,而是持续玩耍的间隙里搞一搞,今时不同往日,搞技术的热情少了很多,大家从 blog 更新频率就可以看出来)。

在编译 wrapper 静态库(就是 mars xlog 的主体代码,以及 cinterop 封装函数的实现)的时候,我最初是设想把所有的静态库合并成为一个,方便后面的分发(这时候我设想的还是让使用者自行从我的 repo 里下载,因为 Linux 平台也没有很成熟的依赖管理方案),那我就需要先确定有哪些静态库需要被合并,所以我尝试先把 wrapper 库编译成动态库,但我发现 Linux 编译动态库的时候默认不强制要求所有引用的符号都有定义,搜索一番也没有找到对应的选项,倒是又搜出了之前用过的 --whole-archive 选项,可以把所有静态库的符号都打包进动态库,而不是只打包引用到了的符号,但显然这个选项不满足我的需求。所以最后我只好用了一个笨办法:搞一个 executable target,这样链接的时候就需要所有引用的符号都能找到定义了。

在请教了做 linker 的同学后,这个问题还是找到了答案,是 -z defs 选项,在 cmake 里可以通过 target_link_options(${PROJECT_NAME} PRIVATE "LINKER:-z,defs") 进行添加。

GNU ld, gold, ld.lld 对于 shared object(就是动态库)默认 -z undefs,对于 executable 默认 -z defs

多个静态库的合并,比较简单,用 ar 命令即可,比如:

pushd build/tmp/zstd/
ar -x libzstd.a
popd

pushd build/tmp/kmp_xlog/
ar -x libkmp_xlog.a
popd

pushd build/tmp
ar -q libkmp_xlog.a zstd/*.o kmp_xlog/*.o
popd

为了避免多个静态库的 .o 文件存在重名,所以我把每个静态库的 .o 都解压到了单独的目录里,最后再用 ar -q 合并。

搞出了单个完整静态库之后,我在编译 example 的时候,又遇到了报错:

The /root/.konan/dependencies/x86_64-unknown-linux-gnu-gcc-8.3.0-glibc-2.19-kernel-4.9-2/x86_64-unknown-linux-gnu/bin/ld.gold command returned non-zero exit code: 1.
output:
/root/.konan/dependencies/x86_64-unknown-linux-gnu-gcc-8.3.0-glibc-2.19-kernel-4.9-2/x86_64-unknown-linux-gnu/bin/ld.gold: internal error in read_header_prolog, at /home/ct/build-x86_64-unknown-linux-gnu/.build/x86_64-unknown-linux-gnu/src/binutils/gold/dwarf_reader.cc:1678

首先当然是去 Google ld.gold: internal error in read_header_prolog,不过没找到 Kotlin/Native 相关的,那就搜 kotlin native ld.gold: internal error in read_header_prolog,别说还真能搜到 Kotlin slack 里的一条聊天记录,但是 2018 年的,当年的问题似乎早已解决,也没有发现什么启发。

无奈之下只好采用屡试不爽的老办法:从最简 demo 开始尝试。

  • 用 IDEA 创建一个 KMP Native Application 项目,能跑起来;
  • 然后添加 cinterop 并直接在 def 文件里空实现,能跑;
  • 去掉 def 文件里的实现,改成链接上面的静态库,不行;
  • 把 wrapper 代码改为空实现,并链接静态库,能跑;
  • 加回一些 wrapper 的实现代码,不断尝试,并最终发现当有未定义的符号时,就会报上面的错;

这时我突然意识到,编译 executable target 的时候,其实是链接了 pthread 和 z 这两个库的,而 KMP 项目里面没有加,加上之后果然就可以了。其实这个办法类似于 git bisect,就是有一个能跑的版本,一个不能跑的版本,那就逐步去定位是什么因素把能跑变成了不能跑。

不得不说,ld.gold: internal error in read_header_prolog 这个错误信息很具有迷惑性,而且 x86_64-unknown-linux-gnu 里的 unknown 以及 dwarf_reader.cc:1678 也有一定的误导性,我一度还以为是 KMP 项目配置问题,没有找到正确的 linux 平台工具链,或者是静态库里的 debug 符号有什么问题。

C/C++ 静态库发布

最初我设想的是让使用者自行从我的 repo 里下载,因为 Linux 平台也没有很成熟的依赖管理方案,不过一个很偶然的机会,我在 Kotlin/Native 的 slack 里看到了一条消息

Steven Van Bael: For binary projects (for example an application I want to distribute) I can configure this custom sqlite3 build in my build scripts and provide the .so (or static .a) as part of the application package. But I’m not sure how to handle this situation when writing a library.

Kevin Galligan: You can add a native library to a klib. There are 2 ways that I know of: -include-binary to add static libs (*.a) and -native-library to add a compiled bitcode library (*.bc). We include C/Objc code into libraries using a library that we definitely do not support because even with that disclaimer we get all kinds of questions, usually related to not understanding llvm, etc. https://github.com/touchlab/cklib. It uses kotlin compiler settings to build C/Objc and pack it in klibs. Live example: https://github.com/cashapp/zipline/blob/trunk/zipline/build.gradle.kts#L154

意思就是 Kotlin/Native 已经有机制可以把 C/C++ 静态库打包到 klib 里了,而 klib 就是 Kotlin/Native 用 maven 分发库的形式,那这不正是我想要的效果嘛!这样使用者只需要添加 gradle 依赖就行,也不用下载预编译的静态库,并按照指定目录放置,并配置 KMP 项目了。

这位 Kevin Galligan 就是我之前咨询过一半的大哥,不愧是浸淫 KMP 多年的老手,这些没有文档的东西,外人确实是难以知晓的。不过他们开源的 cklib 只适用于少量无外部依赖的 C/C++/ObjC 代码,我的需求显然是需要直接给 KMP 项目配置 -include-binary 选项的了。

研究了一番 cklib 的代码,最终我摸索出来了在 build.gradle 里设置的方式,其实挺简单的:

kotlin {
  linuxX64 {
    compilations.main {
      cinterops {
        xlog {
          defFile "src/cppCommon/cinterop/xlog.def"
          includeDirs {
            allHeaders "${project.projectDir}/src/cppCommon/cpp"
          }
        }
      }

      kotlinOptions.freeCompilerArgs += [
          "-include-binary",
          "${project.projectDir}/src/linuxMain/libs/x64/libkmp_xlog.a".toString()
      ]
    }
  }
}

就是设置 kotlinOptions.freeCompilerArgs

既然到了这一步,那我自然就想到 -lpthread-lz 这两个选项能否可以通过我的 lib 项目的配置,免去使用者在他们项目里配置的必要了,这样会让使用者用起来更简单。这个问题 Google 一番也是无果,我在 Kotlin/Native 的 slack 里提问了看看有没有热心群众解答

slack 果然没有让我失望,上面给我激发了灵感的 Steven Van Bael 很快就回复了,只要把链接选项加到 def 文件里即可:

# xlog.def
headers = MarsXLog.h
headerFilter = *
linkerOpts = -lpthread -lz

刚好他还在 slack 和我闲聊了两句,我看他时区和我手机里的荷兰时区一样,Van 这个中间名好像也是荷兰人常见的,我就问了一嘴他是不是在荷兰,不过他说我猜得很近了,他在比利时。我地图上一看,果然比利时就在荷兰旁边。回想 19 年荷兰大伙伴跟我说 Netherland is a small piece of land between England and Germany 犹如昨日,而如今他们业务因为新冠疫情结束的冲击,已经关停,而我也就失去了一笔睡后收入

Windows 支持

我们要把库发布到 maven local 或者 maven central 的时候,需要配置 gpg 签名,之前 Linux 我是在 macOS 上跑的 docker,可以复用本地的 key 配置,在 Windows 上就需要新创建一套。

Windows 配置 gpg 签名

创建 gpg key 很简单,网上可以搜到,在 git bash 里执行:

gpg --default-new-key-algo rsa4096 --gen-key

一路按照命令行提示操作即可创建完成,然后执行下面的命令找到刚才生成的 key:

$ gpg --list-secret-keys --keyid-format=long
/Users/hubot/.gnupg/secring.gpg
------------------------------------
sec   4096R/3AA5C34371567BD2 2016-03-10 [expires: 2017-03-10]
uid                          Hubot <hubot@example.com>
ssb   4096R/4BB6D45482678BE3 2016-03-10

然后执行下述命令导出私钥:

gpg --output 3AA5C34371567BD2.gpg --export-secret-keys 3AA5C34371567BD2

注意不要加 --armor 选项,否则到处的是 ASCII 格式,gradle 插件不支持。

然后在 local.properties 里配置签名:


signing.keyId=71567BD2
signing.password=
signing.secretKeyRingFile=/Users/hubot/.gnupg/3AA5C34371567BD2.gpg

最后,还需要把公钥上传到 key server:

gpg --keyserver http://keyserver.ubuntu.com:11371 --send-keys 3AA5C34371567BD2
gpg --keyserver https://keys.openpgp.org --send-keys 3AA5C34371567BD2
gpg --keyserver http://pgp.mit.edu:11371 --send-keys 3AA5C34371567BD2

注意,上面只是举了个例子,路径、key id、密码需要根据自己的实际情况替换signing.keyId 就是导出的 key id 的后八位。

Windows wrapper 编译

这个过程也遇到了不少问题,并且卡了我好几个月

wrapper 代码和 CMakeLists 在 Linux 支持的时候就已经写好了,但是在编译 Windows 静态库的时候会报错:

lld-link: error: /failifmismatch: mismatch detected for 'RuntimeLibrary':

runtime library 不匹配的问题之前倒是也多次遇见过,这次 Google 出来是说要给 cmake 命令加上 -DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded 参数,不过没有用,最后还是老办法,在 CMakeLists.txt 里修改编译选项:

set(CompilerFlags
    CMAKE_CXX_FLAGS
    CMAKE_CXX_FLAGS_DEBUG
    CMAKE_CXX_FLAGS_RELEASE
    CMAKE_CXX_FLAGS_RELWITHDEBINFO
    CMAKE_C_FLAGS
    CMAKE_C_FLAGS_DEBUG
    CMAKE_C_FLAGS_RELEASE
    CMAKE_C_FLAGS_RELWITHDEBINFO)
    
foreach(CompilerFlag ${CompilerFlags})
    string(REPLACE "/MD" "/MT" ${CompilerFlag} "${${CompilerFlag}}")
endforeach()

也就是把 C/CXX 的编译选项里的 /MD 都替换为 MT,实际上 mars xlog 里也有这个代码。

接下来就是在 gradle 编译的时候报错了:

lld-link: error: duplicate symbol: _atexit
>>> defined at E:/mingwbuild/mingw-w64-crt-git/src/mingw-w64/mingw-w64-crt/crt\crtexe.c:438
>>>            C:\Users\Admin\.konan\dependencies\msys2-mingw-w64-i686-2\i686-w64-mingw32\lib\crt2.o
>>> defined at LIBCMT.lib(utility.obj)

lld-link: error: duplicate symbol: __onexit
>>> defined at libmsvcrt.a(dshjes00632.o)
>>> defined at LIBCMT.lib(utility.obj)
clang++: error: linker command failed with exit code 1 (use -v to see invocation)

看起来是 x86 和 x64 混用了,检查之后发现确实编译 wrapper 库和 gradle 配置的架构确实不一样。适逢看到 Kotlin 1.8.20 的 release note 里弃用 mingwX86 支持,那就只搞一下 x64 好了。

但都统一为 x64 后,依然有问题:

lld-link: error: LIBCMT.lib(std_type_info_static.obj): machine type x86 conflicts with x64
lld-link: error: LIBCMT.lib(delete_scalar_size.obj): machine type x86 conflicts with x64
lld-link: error: LIBCMT.lib(delete_scalar.obj): machine type x86 conflicts with x64

甚至我创建了一个最简单的 demo,也还是有问题,那我就给 Kotlin 官方提 bug 了。官方很快就给了回复,建议我要么是用 Kotlin Native 的 toolchain 去编译我的 C/C++ 代码,要么在 cinterops 代码块里加上 extraOpts("-Xcompile-source", "src/nativeMain/cpp/MarsXLog.cpp") 这样的选项,让 gradle 任务用 Kotlin Native 的 toolchain 去编译我的 C/C++ 代码。

因为 mars xlog 项目本身具有一定的复杂性,那首选当然是通过 CMake 的一些选项使用 KN toolchain 了。不过很遗憾,这条路并没有走通,当然还是有几点收获的(或者趟平了几个坑吧):

  • 在 Windows 上,只通过 -DCMAKE_TOOLCHAIN_FILE/-DCMAKE_C_COMPILER/-DCMAKE_CXX_COMPILER 或者在 CMakeLists.txt 里设置变量,是无法切换编译器的,最终都是使用的 MSVC 编译器,使用 -T ClangCL 倒是可以切换到 ClangCL 编译器,但无法使用其他指定的编译器,只有搭配 -G Ninja 选项才行;
  • 成功切换到 Kotlin Native toolchain 里的 clang/clang++ 之后,cmake 命令就报错了:
FAILED: cmTC_8ee21.exe
cmd.exe /C "cd . && C:\Users\Administrator\.konan\dependencies\
llvm-11.1.0-windows-x64-essentials\bin\clang.exe -fuse-ld=lld-link 
-nostartfiles -nostdlib -g -Xclang -gcodeview -O0 -D_DEBUG -D_DLL 
-D_MT -Xclang --dependent-lib=msvcrtd -Xlinker /subsystem:console 
CMakeFiles/cmTC_8ee21.dir/testCCompiler.c.obj -o cmTC_8ee21.exe 
-Xlinker /implib:cmTC_8ee21.lib -Xlinker /pdb:cmTC_8ee21.pdb 
-Xlinker /version:0.0   -lkernel32 -luser32 -lgdi32 -lwinspool 
-lshell32 -lole32 -loleaut32 -luuid -lcomdlg32 -ladvapi32 -loldnames && cd ."
clang: error: unable to execute command: program not executable
clang: error: linker command failed with exit code 1 (use -v to see invocation)
  • 使用同版本的原生 LLVM clang/clang++,同样报错:
FAILED: cmTC_e4358.exe
cmd.exe /C "cd . && C:\PROGRA~1\LLVM11.1\bin\clang.exe 
-fuse-ld=lld-link -nostartfiles -nostdlib -g -Xclang -gcodeview 
-O0 -D_DEBUG -D_DLL -D_MT -Xclang --dependent-lib=msvcrtd 
-Xlinker /subsystem:console CMakeFiles/cmTC_e4358.dir/testCCompiler.c.obj 
-o cmTC_e4358.exe -Xlinker /implib:cmTC_e4358.lib -Xlinker /pdb:cmTC_e4358.pdb 
-Xlinker /version:0.0   -lkernel32 -luser32 -lgdi32 -lwinspool -lshell32 
-lole32 -loleaut32 -luuid -lcomdlg32 -ladvapi32 -loldnames && cd ."
lld-link: error: <root>: undefined symbol: mainCRTStartup
clang: error: linker command failed with exit code 1 (use -v to see invocation)

无奈之下只好放弃,尝试让 gradle 任务帮我编译。

这个过程也是踩了一些坑,不过好歹是跑通了。

  • 通过 -Xcompile-source 可以指定要编译的 C/C++ 文件,通过 -Xsource-compiler-option 可以指定编译选项,包括头文件搜索路径 -I<path>,宏定义 -DXXXX=xx,或者链接库、库搜索路径等等,具体可以查阅 clang 的文档
  • zlib 里的 C 代码是 K&R C 规范,按照 C++ 去编译会报错,需要通过 "-Xsource-compiler-option", "-xc" 选项指定为 C 规范才能编译过,但是同一个 cinterops 里不能针对部分文件用 C++ 规范,部分文件用 C 规范,为此我们需要把 zlib 的 C 代码搞一个单独的 cinterops;
local block_state deflate_slow(s, flush)
    deflate_state *s;
    int flush;
{
  ...
}
  • mars 里还有少量代码编译有问题,通过宏修改、隔离一下就行了;

具体可以看 GitHub 的提交

macOS 支持

macOS 的支持非常顺利,mars xlog 自身的 macOS 编译脚本可以直接用,KMP 也只需要加一个 target 即可,具体可以看 GitHub 的这个提交

单元测试

以前用 Java 开发 Android 的时候,用 mockito 非常舒服,Kotlin 上也有类似的 mock 库:mockk

不过它并不支持 Native 平台(iOS/macOS/Windows/Linux),所以对于多平台共用的代码,测例只能在 JVM/Android target 上运行,我们可以把测试代码放在 androidUnitTest 目录下,其实代码都是同一份,在一个平台上跑单测代码也可以了。

具体可以看 LoggingTest.kt 这个测例

包大小

对于大公司或者大 App 来说,包大小会是很严格的指标,所以这里我也关注一下。

Android 上如果已经引入了 Kotlin,那引入 KMP 是没有额外成本的,所以引入 KMP 的包大小成本就是引入 Kotlin 的包大小成本。

iOS 上我把 demo 进行 Archive 导出,对比了一下 App 二进制文件的大小(arm64 架构):

  未压缩大小 zip 压缩大小
空 demo 90512B 10023B
使用 mars xlog 573408B 204576B
使用 kmp xlog 997648B 355285B

可以看到,在 iOS 上引入 KMP 的额外包大小,未压缩的 App 二进制文件是 414KB,zip 压缩的则是 147KB。还是很小的。

在 macOS/Linux/Windows/JS 平台,包大小其实就不会太关键了,这里就不做测试了。


这篇文章从一月份就开始筹划,期间因为各种事情延期,不过我已决定重出江湖,接下来会有更多的分享,朋友们,再会 :)