基于 Bazel 的 iOS Monorepo 工程实践

打印 上一主题 下一主题

主题 816|帖子 816|积分 2448

在之前很长一段时间里,哔哩哔哩 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 的文本文件,这个文件包罗了构建产物所必要的外部依赖项的引用:
  1. 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:​​​​​​​
  1. 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:​​​​​​​
  1. 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:​​​​​​​
  1. 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 就是我们想要的终产物。
那么如何得到这个终产物呢?很简单,此时我们在终端运行如下指令:
  1. 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 中几乎不大概做到)。用上面我们提到的示例工程举几个例子:
  1. bazel query "deps(//bilianime-shell:bili-anime)"
复制代码
意为查找 bili-anime 这个target(application/library)的全部依赖。
  1. bazel query "allpaths(//bilianime-shell:bili-anime,//srcs/base/...)" --notool_deps
复制代码
意为查找 bili-anime 这个 target 到 srcs/base 目次下全部 target 的依赖链路。查找依赖链路是 SDK 开辟时相当常用的操作,SDK 一般必要保持纯净,不引入非必要的依赖项,这条指令可以资助我们快速找到哪条依赖链路引入了非必要的依赖项。
  1. bazel query 'kind("(objc|swift)_library rule", rdeps(//srcs/...,//srcs/base/network:BFCNetworking))'
复制代码
意为查找 srcs 下哪些 target 依赖到了 BFCNetworking,rdeps 的 r 是 reverse 之意。这在底层重构代码时非常有用,可以资助我们相识自己的改动大概影响到哪些上层库。
你甚至还可以直接天生一张清楚的依赖图:
  1. 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 感兴趣的同学和我们一起举行技术探讨,甚至加入我们团队!

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

梦见你的名字

金牌会员
这个人很懒什么都没写!

标签云

快速回复 返回顶部 返回列表