起因

最起初,我准备给Aegisub写通过Conan进行依赖解析的脚本。在此过程中有两个包出现了一个令人哭笑不得的依赖冲突:wxWidgets@bincrafters 包依赖 Expat@Pix4d,而 fontconfig 包依赖 expat@_

Conan与其贡献者的组织形式

Conan是一个开源的包管理工具,而与 aptchoco 等包管理器不同的是,Conan主要分发的是各种开发时候需要引用的库,且以 C/C++ 等本地语言为主。而其另一个特征是提供两种形式的包:

  1. 无论如何都会提供源包的构建脚本,即假设我在为包 expat 创建Conan打包,我必须提供一个脚本以下载 expat 的源代码并构建 expat,且通常要同时兼容Windows、Linux与macOS。
  2. 我可以提供任意数量的已经编译好的二进制包,每个包与某个平台、配置关联(例如:Windows+MSVC)。

因此这为C/C++的库分发带来了灵活性与便利性(相较于使用传统的包管理器或手动编译安装而言)。

而Conan的分发与传统包管理器相似,存在“源”的概念,即下载的服务器。对于开源项目而言,Bintray友情提供了大量的服务器资源。因此我们可以在Bintray上创建自己的Conan Repo并往里面上传软件包。在社区发展过程中产生了最大的两大贡献者:bincrafters与conan-community。这两个均是社区组织,且人员有较大穿插。虽然我们可以自己上传包,但是很多包的Conan脚本已经被这些大佬写好,我们只需要引用他们的库即可。

同时就不得不提到另一个库 conan-center,顾名思义这是Conan的中心仓库。但这个仓库本身并无任何包。与之相对应的是,bincrafters等其他组织写好了脚本之后提出“包含请求”,通过审核之后将bincrafters下的这个包“链接”到中心仓库。因此大部分情况我们是到中央仓库去寻找包,因为其汇聚了各路贡献者的构建脚本。

然而这些库毕竟都是由社区管理,难免出现存在bug、弃坑的情况。这次我们同时踩上了这两个坑。

expat 包的那些事儿

expat包是一个开源的XML处理包。最初其Conan脚本由称为Pix4d的商业公司维护并已进入中央仓库,而wxWidgets、fontconfig等包均依赖了这个包。然而后来这个公司没有继续维护该脚本,piponazo接过了接力棒。

同时Conan的中央仓库组织形式发生了大变:以前是各个贡献者自行维护后链接到中央仓库,但是现在开始逐渐迁移为各个贡献者将构建脚本上传到中央仓库,这样所有人都可以方便地维护这些脚本。而expat也有了一个真·中央仓库版本。

问题在于:现在引用expat的包分为了两类:一类引用Pix4D版本(由piponazo维护),一类引用Conan中央仓库的包。

更严重的是,Pix4D的包全名为 Expat/2.2.9@pix4d/stable,而中央仓库的 expat/2.2.7@_/_。注意到那个大小写了吗?问题在于,Conan将不同大小写的包视为不同的包,但Conan的目录组织形式为:

- data
--- Expat
----- pix4d

因此在Windows、macOS(未使用APFS区分大小写)等文件系统不区分大小写的系统上,无法做到区分 Expatexpat 文件夹。因此Conan会直接报错:

1> [CMake] ERROR: Requested 'Expat/2.2.6@pix4d/stable' but found case incompatible 'expat'
1> [CMake] Case insensitive filesystem can't manage this

解决方案听上去很简单:统一各个依赖expat的库的依赖就行了。例如目前我们正在将 wxWidgetsfontconfig 均统一为中央仓库的expat。然而事情没有那么简单.

第一次Pull Request

在最初出现这个问题之前,我就已经发现了 fontconfig 的构建脚本中的bug,并提交了Pull Request。在这个过程中仓库的维护者建议我将 fontconfig 的expat引用更换为中央仓库的版本:

Expat is now in CCI

@cqjjjzr I suggest you change it the dependency to expat/2.2.7 and we could merge this for now

替换之后这一Pull Request被通过并发布。

第二次Pull Request

仍然是在出现这个问题之前,我发现了 wxWidgets 库的Conan构建脚本给出的构建选项太少了,因此提出了一个Pull Request要求添加自定义构建开关的options。在交涉之后这一Pull Request得到了通过。同时在 wxWidgets 构建脚本的testing分支,expat 的引用更换成了中央仓库的版本。

然而 testing 分支的CI却持续报错,导致其不能被转移到 stable 分支并发布。部分错误是由于 Azure Pipelines 上的某些平台没有预构建包。但AppVeyor上另一部分的报错非常奇怪,首先出错的任务全部是Windows上MSVC工具链的Debug编译类型。

CMake Error at source_subfolder/build/cmake/functions.cmake:468 (add_library):
  Cannot find source file:
    C:/Users/appveyor/.conan/data/wxwidgets/3.1.2/bincrafters/testing/build/463db5591e13764936bd38b9982ad6251f59bd3e/source_subfolder/src/expat/expat/lib/xmlparse.c
  Tried extensions .c .C .c++ .cc .cpp .cxx .cu .m .M .mm .h .hh .h++ .hm
  .hpp .hxx .in .txx
Call Stack (most recent call first):
  source_subfolder/build/cmake/lib/expat.cmake:11 (wx_add_builtin_library)
  source_subfolder/build/cmake/lib/CMakeLists.txt:28 (include)

诡异的是,根据 conanfile.pywxWidgets 应该使用外部引用来的 expat,而日志中明显是在尝试构建内建的 expat(这需要先通过 git submodule 拉取 expat 代码)且上面的配置日志也说明了这一点:

-- Which libraries should wxWidgets use?
    wxUSE_REGEX: builtin (enable support for wxRegEx class)
    wxUSE_ZLIB: sys (use zlib for LZW compression)
    wxUSE_EXPAT: builtin (use expat for XML parsing) # 注意这里
    wxUSE_LIBJPEG: sys (use libjpeg (JPEG file format))
    wxUSE_LIBPNG: sys (use libpng (PNG image format))
    wxUSE_LIBTIFF: sys (use libtiff (TIFF file format))
    wxUSE_LIBLZMA: OFF (use liblzma for LZMA compression)
    wxUSE_STL: OFF (Use C++ STL classes)

根据 conanfile.py,此处 wxUSE_EXPAT 的值应该为 sys,即从系统寻找 expat。因此一定是某个 cMake 脚本中强行将这个值设置为了 builtin

经过非常困难定位(直接在代码中搜索 wxUSE_EXPAT 并不能找到目标),我发现了问题出现在 build/cmake/options.cmake 中调用的 wx_add_thirdparty_library 函数,调用点如下:

wx_add_thirdparty_library(wxUSE_EXPAT EXPAT "use expat for XML parsing" DEFAULT_APPLE sys)

这个函数的定义在 build/cmake/functions.cmake 中,其中关键在于:

    if(${var_name} STREQUAL "sys")
        # If the sys library can not be found use builtin
        find_package(${lib_name})
        string(TOUPPER ${lib_name} lib_name_upper)
        if(NOT ${${lib_name_upper}_FOUND})
            wx_option_force_value(${var_name} builtin)
        endif()
    endif()

显然,这里通过 find_package 尝试去定位 EXPAT 库。而在无法定位的情况下就会被强制改为 builtin

那么为什么在Debug编译的时候不能成功 find_package,但Release编译的时候可以呢?我们需要观察一下Debug编译时候引用的 expat 库。因此我们编写一个测试用的 conanfile.txt

[requires]
expat/2.2.7

[generators]
cmake

并拉取下来库:

conan install . -s compiler.version=16 -s build_type=Debug

再编写一个测试用的 CMakeLists.txt

cmake_minimum_required(VERSION 3.14)

project(Test)

include(conanbuildinfo.cmake)
conan_basic_setup()
find_package(EXPAT REQUIRED)
message(${EXPAT_LIBRARIES})

运行,不负众望地炸掉了:

CMake Error at C:/Program Files/CMake/share/cmake-3.14/Modules/FindPackageHandleStandardArgs.cmake:137 (message):
  Could NOT find EXPAT (missing: EXPAT_LIBRARY) (found version "2.2.7") 

但是奇怪的是,它成功地找到了 expat 的目录和头文件((found version "2.2.7") ),却没有找到 expat 的库文件((missing: EXPAT_LIBRARY))。我们打开Conan下载下来的 expat 文件夹一探究竟:

可以看到,Debug版 expat 的库文件为 expatd.lib。而再观察CMake中用于定位 expatFindEXPAT.cmake 中的代码:

find_library(EXPAT_LIBRARY NAMES expat libexpat HINTS ${PC_EXPAT_LIBRARY_DIRS})

显然,这里没有去尝试寻找 expatd.lib

第三次 Pull Request

那么如何修改呢?我们将其拷贝到 conan-wxwidgets 目录(wxWidgets 的Conan脚本目录),并将那一行改成这样:

find_library(EXPAT_LIBRARY NAMES expat libexpat HINTS ${PC_EXPAT_LIBRARY_DIRS})
if(EXPAT_LIBRARY STREQUAL "EXPAT_LIBRARY-NOTFOUND")
    find_library(EXPAT_LIBRARY NAMES expatd HINTS ${PC_EXPAT_LIBRARY_DIRS})
endif()

最后对 conanfile.py 稍事修改,即可(见这个Pull Request)。

然而,虽然我在本地测试成功,提交PR之后远端的AppVeyor测试也全部成功了(即修复了这个问题),但是远端的Azure Pipelines测试全部失败了,且是在开始构建的时候直接失败,这说明我们的构建脚本根本没有开始运行,整个构建就失败了。

查看日志:

+ python build.py
Traceback (most recent call last):
  File "build.py", line 19, in <module>
    builder = build_template_default.get_builder(pure_c=False)
  File "/opt/hostedtoolcache/Python/3.7.4/x64/lib/python3.7/site-packages/bincrafters/build_template_default.py", line 15, in get_builder
    builder = build_shared.get_builder(build_policy, cwd=cwd, **kwargs)
  File "/opt/hostedtoolcache/Python/3.7.4/x64/lib/python3.7/site-packages/bincrafters/build_shared.py", line 216, in get_builder
    username, version, kwargs = get_conan_vars(recipe=recipe, kwargs=kwargs)
  File "/opt/hostedtoolcache/Python/3.7.4/x64/lib/python3.7/site-packages/bincrafters/build_shared.py", line 125, in get_conan_vars
    username = kwargs.get("username", os.getenv("CONAN_USERNAME", get_username_from_ci() or "bincrafters"))
  File "/opt/hostedtoolcache/Python/3.7.4/x64/lib/python3.7/site-packages/bincrafters/build_shared.py", line 105, in get_username_from_ci
    username, _, _ = get_ci_vars()
  File "/opt/hostedtoolcache/Python/3.7.4/x64/lib/python3.7/site-packages/bincrafters/build_shared.py", line 100, in get_ci_vars
    channel, version = repobranch_split if len(repobranch_split) > 1 else ["", ""]
ValueError: too many values to unpack (expected 2)

看起来问题出在Bincrafters组织提供的打包工具 bincrafters_package_tools 上。我们观察出问题的 build_shared.py,可以发现出问题的位置在从Azure Pipeline处拉取当前分支信息这里。

def get_ci_vars():
    reponame = get_repo_name_from_ci()
    reponame_split = reponame.split("/")

    repobranch = get_repo_branch_from_ci()
    repobranch_split = repobranch.split("/")

    username, _ = reponame_split if len(reponame_split) > 1 else ["", ""]
    channel, version = repobranch_split if len(repobranch_split) > 1 else ["", ""]
    return username, channel, version

继续追溯,我们来到了 get_repo_branch_from_ci 方法。

def get_repo_branch_from_ci():
    repobranch_a = os.getenv("APPVEYOR_REPO_BRANCH", "")
    repobranch_t = os.getenv("TRAVIS_BRANCH", "")
    repobranch_c = os.getenv("CIRCLE_BRANCH", "")
    repobranch_azp = os.getenv("BUILD_SOURCEBRANCH", "")
    if repobranch_azp .startswith("refs/heads/"):
        repobranch_azp = repobranch_azp [11:]
    return repobranch_a or repobranch_t or repobranch_c or repobranch_azp

可以看到,这里对Azure Pipeline做了特殊处理,去除了分支名称前面的“refs/heads/”前缀。然而,需要我们在AZP中可以注意到当我们提交一个Pull Request的时候,分支名称的前缀是 refs/pull 开头。因此此种情况下这个前缀并未被去掉,于是 repobranch_split 的元素就大于了2,造成了这个错误。

然而,我们注意到 BUILD_SOURCEBRANCH 在Pull Request上下文中的内容是类似于 refs/pull/1,其中1是Pull Request的编号,而并未包含任何真正的分支名信息。Google之后发现此时我们需要去寻找 SYSTEM_PULLREQUEST_TARGETBRANCH 这个环境变量,其中包含了真正的分支名。

因此我们修复此处,并提出PR(其实在此之前我提出了几种非常不优雅的解决方案并已经开了PR,在我写出最后的解决方案之前这一打包工具的管理者已经写好了同样的方案并并入了主分支)。

总结

至此,我们成功地修复了在Conan上使用wxWidgets的问题,并由此解决了一连串的工具的bug。

这种一个小问题带出一大堆问题的情况实际上数见不鲜,虽然一步步调试的过程非常痛苦,但是全部完成后的成就感还是非常到位。

本文完结时候实际上最终的Expat补丁仍未并入conan-wxwidgets主分支,这是因为仓库管理者认为添加自定义CMake脚本的方式过于丑陋。但是其他解决方案就不是我需要考虑的了。

(完)


不想被自己的惰性打败。