引子
通过「PodSpec 管理策略」对 CocaPods-Core 的分析,我们大体了解了 Pod
是如何被解析、查询与管理的。有了这些整体概念之后,我们就可以逐步深入 pod install
的各个细节。
今天我们就来聊聊 Pod
的依赖校验工具 --- Molinillo[1]。
开始前,需要聊聊依赖校验的背景。
依赖管理的挑战
同大多数包管理工具一样 Pod
会将传递依赖的包用扁平化的形式,安装至 workspace 目录 (即:Pods/
)。
依赖传递:pod A
依赖于 pod B
,而 pod B
依赖 Alamofire
。
![3ddaddb3a147157de73b067fb7af4342.png](https://i-blog.csdnimg.cn/blog_migrate/3f2cbae02120d19f7e74107cfbc1f9ad.png)
可以看到,经依赖解析原有的依赖树被拍平了,安装在同层目录中。
然而在大型项目中,遇到的更多情况可能像下面这样:
![656658a907f82111fc23d33c8c6e98e2.png](https://i-blog.csdnimg.cn/blog_migrate/ed78adcbfc71c24996733f951253121c.png)
依赖冲突: 即 pod A
和 pod B
分别依赖不同版本的 Alamofire
。这就是 依赖地狱[2] 的开始。
依赖地狱:指在操作系统中由于软件之间的依赖性不能被满足而引发的问题。
随着项目的迭代,我们不断引入依赖并最终形成错综复杂的网络。这使得项目的依赖性解析变得异常困难,甚至出现 致命错误[3]。
那么,产生的问题有哪些类型 ?
问题类型
依赖过多/多重依赖
即项目存在大量依赖关系,或者依赖本身有其自身依赖(依赖传递),导致依赖层级过深。像微信或淘宝这样的超级应用,其中的单一业务模块都可能存在这些问题,这将使得依赖解析过于复杂,且容易产生依赖冲突和依赖循环。
依赖冲突
即项目中的两个依赖包无法共存的情况。可能两个依赖库内部的代码冲突,也可能其底层依赖互相冲突。上面例子中因 Alamofire
版本不同产生的问题就是依赖冲突。
依赖循环
即依赖性关系形成一个闭合环路。如下图三个 pod 库之间互相依赖产生循环:
![22c9fc93bafd55c4b8d49dd42af1c88b.png](https://i-blog.csdnimg.cn/blog_migrate/532bf2fd74044e79bb588e7a30462b68.png)
要判断依赖关系中是否存在依赖环,则需要通依赖仲裁算法来解决。
依赖关系的解决
对于依赖过多或者多重依赖问题,我们可通过合理的架构和设计模式来解决。而依赖校验主要解决的问题为:
检查依赖图是否存在版本冲突;
判断依赖图是否存在循环依赖;
版本冲突的解决方案
对于版本冲突可通过修改指定版本为带兼容性的版本范围问题来避免。如上面的问题有两个解决方案:
通过修改两个
pod
的Alamofire
版本约束为~> 4.0
来解决。去除两个
pod
的版本约束,交由项目中的Podfile
来指定。
不过这样会有一个隐患,由于两个 Pod
使用的主版本不同,可能带来 API 不兼容,导致 pod install
即使成功了,最终也无法编译或运行时报错。
还有一种解决方案,是基于语言特性来进行依赖性隔离。如 npm 的每个传递依赖包如果冲突都可以有自己的 node_modules
依赖目录,即一个依赖库可以存在多个不同版本。
![77fd1a46d75afe2c15a10d63a1a3b708.png](https://i-blog.csdnimg.cn/blog_migrate/76201705086f801faf93aac44c3c417c.png)
循环依赖的解决方案
循环依赖则需要需要进行数学建模生成 DAG
图,利用拓扑排序的拆点进行处理。通过确定依赖图是否为 DAG
图,来验证依赖关系的合理性。
一个 DAG 图的示例:
![da683ec3646b98d49a38746d4b22125c.png](https://i-blog.csdnimg.cn/blog_migrate/86eee764757a66d19364499b0aac25f8.png)
DAG 是图论中常见的一种描述问题的结构,全称 有向无环图 (Directed Acyclic Graph)。想了解更多,可查看冬瓜的文章 --- 「从拓扑排序到 Carthage 依赖校验算法」。
另外,各种包管理工具的依赖校验算法也各不相同,有如 Dart 和 SwiftPM 所使用的 PubGrub[4],作者号称其为下一代依赖校验算法,Yarn 的 Selective dependency resolutions[5],还有我们今天聊到的 Molinillo。
Molinillo
Molinillo 作为通用的依赖解析工具,它不仅应用在 CocoaPods 中,在 Bundler 1.9 版本也采用 Molinillo。另外,值得注意的是 Bundler 在 Ruby 2.6 中被作为了默认的 Gem 工具内嵌。可以说 Ruby 相关的依赖工具都通过 Molinillo 完成依赖解析。
ResolutionState
Molinillo 算法的核心是基于回溯 (Backtracking)[6] 和 [向前检查 (forward checking)](https://en.wikipedia.org/wiki/Look-ahead_(backtracking "向前检查 (forward checking)")),整个过程会追踪栈中的两个状态 DependencyState
和 PossibilityState
。
module Molinillo
# 解析状态
ResolutionState = Struct.new(
# [String] 当前需求名称
:name,
# [Array<Object>] 未处理的需求
:requirements,
# [DependencyGraph] 依赖关系图
:activated,
# [Object] 当前需求
:requirement,
# [Object] 满足当前需求的可能性
:possibilities,
# [Integer] 解析深度
:depth,
# [Hash] 未解决的冲突,以需求名为 key
:conflicts,
# [Array<UnwindDetails>] 记录着未处理过的需要用于回溯的信息
:unused_unwind_options
)
class ResolutionState
def self.empty
new(nil, [], DependencyGraph.new, nil, nil, 0, {}, [])
end
end
# 记录一组需求和满足当前需求的可能性
class DependencyState < ResolutionState
# 通过不断 pop 过滤包含的可能性,找出最符合需求的解
def pop_possibility_state
PossibilityState.new(
name,
requirements.dup,
activated,
requirement,
[possibilities.pop],
depth + 1,
conflicts.dup,
unused_unwind_options.dup
).tap do |state|
state.activated.tag(state)
end
end
end
# 仅包含一个满足需求的可能性
class PossibilityState < ResolutionState
end
end
光看 state 定义大家可能觉得云里雾里。这里很有必要解释一下:
我们说的需求 (requirement
) 到底是指什么呢?大家可以理解为在 Podfile
中声明的 pod。之所以称为需求,是由于无法判断定义的 dependency 是否合法。 假设它合法,又是否存在符合需求限制版本的解呢 ?即是否存在对应的 PodSpec
我们不而知。因此,这些未知状态称为统一被可能性 possibility
。
Tips: 了解这个概念非常重要,这也是笔者在几乎写完本文的情况下,才想明白这些变量名的意义。💔
Resolution Loop
我们先通过图来了解一下 Molinillo 的核心流程 (先忽略异常流):
![e2c4a7090d72a21603922967a6a97240.png](https://i-blog.csdnimg.cn/blog_migrate/6f51ba53080781ed1a8d6a0e6ef5152b.png)
可以看到整个流程就是不断的将 requirement 的 possibility 过滤和处理,一层层剥离转换为 DependencyState
,如此循环往复。
Molinillo 的入口为 Resolution::resolve
方法,也是上图对应的实现,逻辑如下:
# lib/molinillo/resolution.rb
def resolve
# 1. 初始化 timer 统计耗时初始位置打点
# 2.