基于 Bazel 的 iOS Monorepo 工程实践
背景
在之前很长一段时间里,哔哩哔哩 iOS 工程是使用 Polyrepo(或者说 Multirepo,即多仓库)的传统模式进行开发。但是随着业务的发展,我们的代码仓库的数量也随之膨胀,我们慢慢发现 Polyrepo 模式并不一定是适合哔哩哔哩客户端的工程模式。
具体一点来说,哔哩哔哩是一家发展极快的公司,播放器、APM、网络库等作为业务底层的能力也在日新月异地完善之中,因此 Polyrepo 模式的一些痛点也开始凸显出来:
首先就是繁琐的代码共享,一旦某个新组件想要复用其他组件中的一段代码,那么佳实践应该是把这些公共代码下沉。这就意味着要为这些公用代码创建一个新的代码仓库,然后重新设置新的代码仓库的 CI 工具和环境,把代码贡献者加入这个仓库,设置好发布流程,调整老组件的代码和依赖,然后才能开始新组件的编写工作。这还算顺利的,某些情况下为了解决不同代码仓库的不兼容的依赖版本,你甚至需要额外付出更多的精力。
随之而来就是严重的 copy-paste 问题,由于代码共享的繁琐,几乎没有人愿意经历上面的这些苦难,所以各团队倾向于在自己的代码库中 copy 一份相同的副本代码。如果copy的代码不能完全满足自身业务场景,各团队也更倾向于自己修改这些副本代码的实现,来满足自己的定制化场景,而不是沉淀一份更加通用的公共组件。久而久之,整个工程充斥着大量相似的 copy-paste 代码。而这种代码一般是为了快速满足定制化场景的一次性低质量代码,是无法维护、没有可持续更新能力的。如果原版代码存在 bug 需要修复或者有功能迭代的话,那么这些分裂的副本代码并不能时间应用这些修复和更新,也没有人愿意花费额外的精力去适配这些副本代码,这些副本代码成为了一坨一坨没有人愿意管的“屎山”。如果副本代码必须应用这些修复和更新的话,则需要付出额外的时间和人力成本,正是“复制粘贴一时爽,修复更新火葬场”。
如果说上面这些勉强还在容忍度之内,那么比这些更严重的问题在于——底层代码库的高额修改成本。如果某个底层库有重大 bug 需要修复或者有破坏性变更,那么这个工作量可以称之为灾难,这让代码重构成为天方夜谭。举个具体的例子,播放器是哔哩哔哩的核心组件,是其他上层业务的基石,作为一家拥抱变化的公司,我们的播放器核心也在发展之中,几乎每过几个版本就会有一些 API 层面的变更。这些变更对于整个工程来说是破坏性变更,需要上层所有仓库都要应用这一变更,几乎每个仓库都要提相应的 merge request,所有正在开发的功能分支也要 rebase 并适配新的代码版本,否则合入后很可能导致主分支的编译错误。这些变更的工作量已经非常巨大,更不用说不同 App 之间的版本控制和发布过程中繁琐的协调工作了。
Polyrepo 模式基于版本控制,它本身这么流行的原因在于团队自治——团队希望自己决定用什么库,谁可以贡献或使用他们的代码;但同时这种团队自治是由隔离提供的,而隔离会损害协作,上面这些问题就是由于隔离带来的。这促使我们思考是否存在更适合哔哩哔哩客户端工程的开发模式。
很幸运的是,我们找到了答案——Monorepo。
什么是 Monorepo?
Monorepo 的 mono 就是“单独”的意思(立即联想到高达中使用Mono-eye 独眼技术的扎古)。从字面意义上来看就是“单一仓库”,许多同学望文生义:所有代码放在一个代码仓库里,这不是原始落后的仓库模式吗?这也能吹?
非也,如果一个代码仓库只是包含了多个 package/library,却没有明确定义这些 package/library 之间的依赖关系,那么我们不能称之为 Monorepo;同样的,如果一个代码仓库包含一个大型应用,但这个大型应用却没有被适当地隔离封装成不同的组件,那么我们也不能称之为 Monorepo。也就是说,这些没有模块化管理的仓库只能被称之为 Monolith Repo(我称之为一锅乱炖仓库,事实上一些小体量App确实是这样做的),但不能称之为 Monorepo。
因此 Monorepo 一般意义上一定是高度模块化且可管理的。要实现在单一仓库的模块化和可管理,就必须借助于 Monorepo 工具。
国内的互联网从业者可能对 Monorepo 比较陌生,但其实 Monorepo 在全球互联网已经是一个比较成熟概念了,已经有许多的团队基于不同的理念开发出了各种的 Monorepo 工具,如:谷歌的 Blaze(内部使用)和 Bazel(Blaze 的子集,开源项目),微软的 Lage 和 Rush 都是非常成熟的 Monorepo 工具。根据我们的深入调研,终我们决定使用 Bazel 作为我们 Monorepo 工具的选型。
为什么选型 Bazel?
首先 Bazel 有 Google 这样技术驱动的互联网巨头作为背书,这注定了它的社区氛围是相当活跃的,你在 Bazel 的 Github 上经常可以看到新的 issue 和 pull request,你可以在上面提出你遇到的任何问题,他们的回复也非常积极。
当然这只是选型理由中微不足道的一点,重要的是 Bazel 是一个现代化的多语言构建/测试工具,它类似于 Make、Gradle,具有正确、快速、可管理、可扩展的特点。
我们先来了解一下 Bazel 是如何组织 iOS 工程结构的。为了方便大家理解,我简化了一下我们的工程结构:
首先,每一个 Bazel 项目的根目录都有一个 WORKSPACE 的文本文件,这个文件包含了构建产物所需要的外部依赖项的引用:
workspace(name = 'bili-ios')
load('@bazel_tools//tools/build_defs/repo:git.bzl', 'git_repository')
git_repository(
name = "build_bazel_rules_apple",
remote = "https://github.com/bazelbuild/rules_apple.git",
tag = "0.33.0"
)
git_repository(
name = "build_bazel_apple_support",
remote = "https://github.com/bazelbuild/apple_support.git",
tag = "0.13.0"
)
load("@build_bazel_rules_apple//apple:repositories.bzl", "apple_rules_dependencies")
apple_rules_dependencies()
git_repository(
name = "build_bazel_rules_swift",
remote = "https://github.com/bazelbuild/rules_swift.git",
tag = "0.27.0",
)
load(
"@build_bazel_rules_swift//swift:repositories.bzl",
"swift_rules_dependencies")
swift_rules_dependencies()
load(
"@build_bazel_rules_swift//swift:extras.bzl",
"swift_rules_extra_dependencies",
)
swift_rules_extra_dependencies()
上述 WORKSPACE 文件定义了我们的工作区名为 bili-ios。然后引入了 git_repository 这个 Bazel 官方规则来引入其他 git 仓库,由于 Bazel 是一个多语言的构建工具,我们需要让 Bazel 知道我们构建的是一个 iOS 应用,所以我们需要引入 build_bazel_rules_apple,build_bazel_rules_swift 等 iOS 项目构建所需要依赖的构建规则,这些外部依赖项都需要在 WORKSPACE 文件中进行声明。
然后我们需要编写 BUILD 文件,告知 Bazel 我们需要构建什么,以及如何构建。熟悉 CocoaPods 的同学把这个 BUILD 文件类比为 podspec 文件即可。这个 BUILD 文件可以处在工程目录的任何位置,一般我们会在库所在目录建立 BUILD 文件,Bazel 使用 Starlark 语言(一种类似 python,为 Bazel 量身定制的动态类型语言)在 BUILD 文件中声明构建 target。构建规则会指定 Bazel 将使用的构建工具,比如编译器和链接器以及它们的配置;对于 iOS 应用来说,上面提到的 build_bazel_rules_apple,build_bazel_rules_swift 这些构建规则会指定 Xcode 底层工具链中 xcrun 的 clang 和 swiftc 为编译器和链接器。
这么讲比较抽象,我们来实际看看BUILD文件长什么样吧,我简化了我们工程中BUILD文件的内容:
srcs/base/network/BUILD:
objc_library(
name = "BFCNetworking",
srcs = glob(["**/*.m","**/*.h",]),
hdrs = glob(["**/*.h"]),
deps = [ ],
includes = ["include"],
visibility = ["//visibility:public"],
)
这个 BUILD 文件位于 srcs/base/network 目录,它定义了一个用 Objective-C 编写的通用网络库 BFCNetworking,其源码输入为 srcs/base/network 下的所有 .h 和 .m 文件,没有系统库以外的其他依赖,对外暴露的头文件则位于 srcs/base/network/include 下,它的可见域声明为public,即对外完全公开,任何库都可以依赖到它。
srcs/app/anime/BUILD:
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "BBAnime",
srcs = glob(["*.swift",]),
deps = ["//srcs/base/network:BFCNetworking"],
visibility = ["//bilianime-shell:__pkg__"],
)
这个 BUILD 文件位于 srcs/app/anime 目录,它定义了一个用 Swift 编写的名叫 BBAnime 的业务库,其源码输入为 srcs/app/anime 下的所有 swift 文件,它有一个依赖,即为上面提到的 srcs/base/network 目录下名为 BFCNetworking 的通用网络库,它声明的可见域代表它只能被 bilianime-shell 下的库所依赖。
值得注意的是此 BUILD 文件行的 load 语句表明 swift_library 是一个外部依赖规则,它被定义在 WORKSPACE 中声明的 build_bazel_rules_swift 仓库内部,而 srcs/base/network/BUILD 里却不需要声明 objc_library 规则,这是因为 objc_library 是 Bazel 内置规则。
bilianime-shell/BUILD:
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
load("@build_bazel_rules_apple//apple:ios.bzl", "ios_application")
swift_library(
name = "main",
srcs = ["main.swift"],
deps = ["//srcs/app/anime:BBAnime"],
)
ios_application(
name = "bili-anime",
bundle_id = "tv.danmaku.bilianime",
app_icons = glob(["Assets.xcassets/AppIcon.appiconset/*.png"])
+ glob(["Assets.xcassets/AppIcon.appiconset/*.json"]),
launch_storyboard = "Launch Screen.xib",
families = ["iphone", "ipad"],
minimum_os_version = "10.0",
infoplists = ["info.plist"],
deps = [":main"],
)
bilianime-shell 目录为我们 iOS 应用的壳工程目录,这个目录下的 BUILD 文件定义了一个名为 bili-anime 的 iOS 应用及其应用程序入口 main,入口又依赖了上面我们提到的 BBAnime 这个业务库。bili-anime.ipa 就是我们想要的终产物。
那么如何得到这个终产物呢?很简单,此时我们在终端运行如下指令:
bazel build //bilianime-shell:bili-anime
Bazel 就会编译 bili-anime 这个 App,包括其所有直接和间接的依赖库:BBAnime 和 BFCNetworking。
上面这个简单例子的依赖关系是非常清晰的,名为 bili-anime 的 iOS 应用依赖了 main 这个应用程序入口,应用程序入口又依赖了业务库 BBAnime,业务库又依赖了通用网络库 BFCNetworking。在构建 bili-anime 的时候就会根据它的依赖树依次编译所有它依赖的 objc_library 和 swift_library 为 .a 文件,后将这些 .a 链接成 App 的二进制产物,然后和对应的资源打包成为我们熟悉的 ipa 文件。
Xcode 呢?
可能有同学会问了,你说的这些 BUILD 文件什么的,似乎都是 Bazel 特有的,完全脱离了传统 iOS 工程的范畴,甚至根本没看到我们熟悉的 xcodeproj 文件,没有这个文件怎么使用我们熟悉的 Xcode呢?代码索引什么的不用了吗?当真用 vim 纯手写代码吗?
是的,有一点说的没错,Bazel 组织的 iOS 工程根本不需要xcodeproj文件,在 CI/CD 的构建指令中就完全不会用到 xcodeproj 文件。但是 Google 官方考虑到了大家 Xcode 的使用习惯,提供了将 Bazel 工程转化出 xcodeproj 文件的能力,这个工具叫 Tulsi。
Tulsi 会分析你配置的 WORKSPACE 文件以及你想要生成的 Target 来找到对应的依赖树并生成 xcodeproj 文件。然后使用 Xcode 打开刚才生成的 xcodeproj 工程文件就可以看到我们熟悉的界面了,代码索引这些都在!cmd+R 也会编译并启动真机/模拟器,和以前的Xcode 使用体验无异!所以放心使用 Bazel 吧!
对 Bazel 有了个大致的认识后,让我们来看看 Monorepo 以及 Bazel 给我们带来的优势:
1. 可管理
1.1 可读性
Bazel 的 BUILD 文件是人类可读的,并且它精准地描述了 target(library/application)的构建规则以及 target 之间的依赖关系。所有 target 的 BUILD 文件和源码都在一个大仓库中,这些 target 之间没有物理隔离,但正是由于 BUILD 文件的这种精准描述,它们仍然是高度模块化的。
1.2 源码共享
所有库都在一个 Monorepo 中,任何开发者都可以查看其他模块的代码,这使得模块间定位问题,下沉公共模块变得非常简单。下沉公共模块只需要移动源码所在的目录,重新整理 BUILD 文件,一次提交,一次 Merge Request 就可以完成,没有 Polyrepo 的那种需要新建仓库,发起多个仓库的 Merge Requests 的这种心理负担。
在 Monorepo 中,任何开发者都可以了解其他模块是如何工作的,并且可以亲自参与修改代码。这种情况下,遇到相似的需求,大家更倾向于在已有模块中扩展代码,只要这个修改通过对应模块的 Owner 的 Review 即可合入。因此 copy-paste 问题也得到大大缓解。
1.3 原子提交
源码共享造就了 Monorepo 的原子提交特性。在 Monorepo 中,跨仓库操作多个 Merge Request 的现象消失了,几乎所有提交都是原子提交:涉及多个库的修改,要么同时被应用,要么都不应用。这使得下沉公共模块、重构代码变得简单起来。
1.4 易用的依赖查询
Bazel 针对 Monorepo 还开发了十分便利的 query 指令,来帮助我们查找并掌握 Monorepo 中库之间的依赖关系,比如我依赖了哪些库,谁又依赖了我的库(这在 Polyrepo 中几乎不可能做到)。用上面我们提到的示例工程举几个例子:
bazel query "deps(//bilianime-shell:bili-anime)"
意为查找 bili-anime 这个target(application/library)的所有依赖。
bazel query "allpaths(//bilianime-shell:bili-anime,//srcs/base/...)" --notool_deps
意为查找 bili-anime 这个 target 到 srcs/base 目录下所有 target 的依赖链路。查找依赖链路是 SDK 开发时相当常用的操作,SDK 一般需要保持纯净,不引入非必要的依赖项,这条指令可以帮助我们快速找到哪条依赖链路引入了非必要的依赖项。
bazel query 'kind("(objc|swift)_library rule", rdeps(//srcs/...,//srcs/base/network:BFCNetworking))'
意为查找 srcs 下哪些 target 依赖到了 BFCNetworking,rdeps 的 r 是 reverse 之意。这在底层重构代码时非常有用,可以帮助我们了解自己的改动可能影响到哪些上层库。
你甚至还可以直接生成一张清晰的依赖图:
bazel query --noimplicit_deps 'kind("(objc|swift)_library rule", deps(//bilianime-shell:bili-anime))' --output graph > deps_graph.in
意为查找 bili-anime 这个 target 的 objc_library 和 swift_library 类型的依赖,并生成一张依赖图文件(.in文件,可以作为 graphviz 的输入生成一张真正的png图片):
1.5 代码可见域
Bazel 可以设置所有 target 的可见域,开发者可以将 target 标记为私有,以防止被其他项目错误地依赖,这种约束帮助我们规范代码仓库之中各个库之间的依赖关系。
1.6 标准化工具链
Monorepo 模式天然倾向于承载多个 project,哔哩哔哩现在也从单个 App 发展为 App 矩阵模式。除了大家熟知的哔哩哔哩主 App、哔哩哔哩 HD、必剪和直播姬以外,还有更多的移动端项目正在孵化中。
对于哔哩哔哩而言,这些项目的孵化是相当方便的,Monorepo 有完整统一的标准化工具链,如果需要创建一个新的 App,只需要一些简单的配置就可以迅速接入 Monorepo 统一的 CI/CD 流程,使用同一套代码 Review 机制和 lint 标准。新 App 的开发者不需要从头编写这些工具链和脚本,大大节省了开发人员的时间,降低了新项目的孵化成本。
1.7 去版本化
Monorepo 模式就是反隔离的,因此版本化的概念也不复存在,除了第三方库以外,绝大部分哔哩哔哩自研库的代码都是没有版本概念的,所有 App 都默认使用新的主分支的代码。这样带来的好处就是所有 App 都可以享受底层库以及工具链的新能力,传统 Polyrepo 模式的版本化带来的库版本冲突问题不复存在,尤其是不同 App 跨版本升级 SDK 的情况在 Polyrepo 模式可能是相当痛苦的。
当然所有 App 都默认使用新的主分支的库代码,这也意味着可能会引入主分支的新 bug,因此这对底层库代码质量要求是非常高的。对于一些重要的底层库,我们要求必须编写单元测试用例,CI 流程中会对这些用例进行严格的检查,通不过单元测试的修改是无法合入主分支的。只有经过严格并且充分的测试后,这些修改才能进入主分支并被所有 App 所使用。
2. 正确
上面说到,运行 bazel build //bilianime-shell:bili-anime 这条命令就会构建整个依赖树并终生成 ipa,Bazel 是怎么实现的呢?
实际上 bazel build 命令时,Bazel 会执行以下操作:
根据target的依赖树加载与该target相关的所有 BUILD 文件。
分析输入及其依赖项,应用指定的构建规则,并生成操作图,操作图决定了所有 target 的构建顺序,一般情况下是底层优先进行编译。
根据操作图的构建顺序执行构建操作,直到生成终构建产物。对于 iOS 应用来说每个库(objc_library和swift_library)的中间产物为 .a 二进制文件,这些中间产物经过link再加上其他资源文件,生成了终产物 ipa 文件。
实际项目的工程结构和依赖关系会比上面的例子复杂百倍,但不管工程有多复杂,Bazel 一定会遵循上面的构建流程。明确的依赖树、操作图,明确的构建流程,保证了 Bazel 构建的正确性。
3. 快速
3.1 任务编排
Bazel 的构建流程中生成的操作图保证它以正确的顺序并行地运行任务。任务是否可以并行,主要取决于任务之间是否有对它们产物的依赖。
3.2 本地缓存
Bazel 具有在本地存储和重用中间产物的能力,在同一台机器上,你永远不会执行两次相同的构建/测试任务(这里所谓的相同指的是源代码文件和编译参数不改变的情况下)。
3.3 分布式缓存
或许大部分构建系统都对本地缓存有自己的实现,但是分布式缓存就不一定了,这可以说是 Bazel 的一大特点。Bazel 拥有跨不同环境共享构建产物缓存的能力,也就是远程缓存的能力,前提是公司需要具备一个稳定的文件服务器来存放远程缓存。
这意味着你的整个开发组,包括你的 CI 构建集群,都永远不会重新构建或测试相同的东西两次(这里所谓的相同指的是源代码文件和编译参数不改变的情况下)。
下图是我们 App Store 包和 Adhoc 包的编译时间对比,数据组A(绿色)为 App Store 包,数据组B(紫色)为 Adhoc 包,二者的编译参数存在一定差异,因此缓存不共享,App Store 包编译频率较低,缓存命中率低,因此其编译时间可以近似认为 clean build 的时间。
可以看到哔哩哔哩的 iOS 工程的 App Store 包的编译时长平均为81分钟,可以近似地认为 clean build 时长为81分钟(因为 Bazel 的正确性,我们实际上开启了缓存,并不是完全的 clean build,只是因为 App Store 包构建次数少,缓存命中低)。可以看出来我们的iOS工程体量是非常大的。
但是同样配置的构建集群在 Adhoc 包的平均表现直接降低到了24分钟,编译效率提高了约 70%。这是由于在日常开发中 Adhoc 的打包频率非常高,不同机器都会通过 Bazel 上传缓存到我们的远程文件缓存服务器上,使得不同的机器都可以共享这些产物,如果只是修改上层代码,这个时间甚至可以缩短到3分钟以内。
而开启分布式缓存这一特性,客户端竟然只需要在本地简单地配置一个 --remote_cache 的参数,缓存如何命中这些问题客户端完全不用去关心。
简单、高效,这就是分布式缓存的魔力。
3.4 分布式任务执行
Bazel 支持在多台机器上分发任务的能力。这个特性使用起来比分布式缓存更复杂一些,现阶段我们的编译瓶颈不在任务并发量上,因此现在我们还没有正式启用这一能力,但这的确是 Bazel 的特色*之一,未来我们也有开启分布式编译能力的计划。
4. 可扩展
Bazel的另一大特色就是其可扩展能力,众所周知,Bazel是一个支持多语言的构建工具,你甚至可以自己实现一个官方还没有实现的语言的构建规则。
这种扩展能力也可以用于代码生成。举个例子,我们哔哩哔哩后端维护了一个 protobuf 的仓库,叫 bapis。我们客户端配合 bapis 仓库自建了一套代码生成规则,在后端更新 proto 文件后,我们的 iOS 仓库中会自动生成文件对应的 .h、.m 或者 .swift 代码,.proto 文件中定义的 message 和 service 会自动生成客户端需要的类和方法。这样一方面节省了客户端开发者的 coding 时间,另一方面杜绝了人工出错的可能性,降低了整体的开发成本,提升了开发效率。
此图为原始的 bapis 仓库的 proto 文件,由后端开发同学负责更新。
此图为客户端开发同学在编译 iOS 客户端时在项目中自动生成 .h 和 .m 文件。
由于可以自建规则,如果你认为官方的 objc_library 和 swift_library 实现得不够好,你甚至可以直接弃用他们,自己介入编译过程,编写适合自己的 rules,做一些特别定制的“骚操作”。
结语
正因为 Bazel 和 Monorepo 以上这些特性,终让我们选择了它们。
在我看来,Monorepo 和 Polyrepo 一个强调协作,一个强调自治,孰优孰劣并没有标准答案,深入讨论这个的问题终都会演变为哲学探讨。我的看法是,不存在正确的技术方案,只有适合自己的技术方案。No silver bullet!
目前B站客户端的 Monorepo 模式还在进化中,未来会有越来越多的编译优化的自研规则实装到我们的iOS项目中来,分布式编译能力也已经提上日程。欢迎对 Monorepo 或者 Bazel 感兴趣的同学和我们一起进行技术探讨,甚至加入我们团队!
参考
[1] https://monorepo.tools/
[2] https://bazel.build/
[3] https://tulsi.bazel.build/
相关文章