CocoaPods Source 管理机制

CocoaPods-Core 主要包括三部分功能,分别是:Podfile 解析、Podspec 解析、Source 管理,前两个功能我们在之前的文章中已经分别进行了介绍,本文我们再来介绍一下最后一个功能——CocoaPods Source 管理机制。

基本原理

Source,即 podspec 源。Source 管理机制,本质上就是对 podspec 进行管理。CocoaPods 采用 集中式 的源管理方案,其基本原理 使用一个源仓库管理所有 pod 所发布的不同版本的 podspec 文件,每一个 podspec 记录了 pod 库的资源地址以及其他相关信息

CocoaPods 在集中式管理的基础上,实现了两套具体方案:master 和 trunk。其中,master 是 CocoaPods 1.8.0 之前版本所采用的默认方案;trunk 是 CocoaPods 1.8.0 以之后版本所采用的的默认方案。

默认,CocoaPods 会把源仓库拷贝或克隆到本地用户根目录下的 ~/.cocoapods/repos/。在 ~/.cocoapods/repos/ 目录下,其实我们可能看到两套方案各自对应一个目录:mastertrunk,如下所示。

1
2
$ ls ~/.cocoapods/repos
Spec_Lock master trunk

下面,我们对这两种方案分别进行介绍。

Master

对于 master 方案,其源仓库(下文简称 master repo)的组织结构如下图所示。其具体方案是:

  • 根据 pod 的名称计算得到 MD5 值。如:md5("AFNetworking") => a75d452377f3996bdc4b623a5df25820
  • 获取 MD5 值的前 N 位,默认是 3 位。如:a75
  • 对前 N 位值进行拆解,每一位对应一个目录,构成一个多级目录。如:a/7/5
  • 将 pod 及其各个版本的 podspec 文件存放在对应的目录下。

那么问题来了,为什么要使用一个多级目录结构存储 podspec 呢?

其实,在 2016 年 master repo 仓库下的所有文件都在同级目录,对目录进行分级是为了解决 Github 下载慢 的问题。

那么问题又来了,为什么对目录进行分级能加速 github 下载呢?

本质上,这与 git 的底层原理有关。在 git 对象模型中,对于目录的每次更改都会生成一个新的树对象,许多 git 操作需要遍历这棵树,内部必须通过多步增量重新创建,每一步都必须找到并解压缩。当一个目录下存在非常多的子目录的情况下,一个变更操作会生成非常大的树对象。因此,采用多级目录结构可以降低 git 对象模型的大小,从而降低 git 仓库大小,提升下载速度。

Trunk

尽管 master repo 采用多级目录的方式优化了仓库的大小,但是架不住 pod 库以及版本迭代实在太多了。于是,CocoaPods 1.7.0 支持了 CDN。

CDN 的基本思路是:在网络各处放置节点服务器,构成一个虚拟网络,CDN 系统能够实时地根据网络流量和各节点的连接、负载状况、响应时间等综合信息,将用户请求转发到里用户最近的服务节点,从而在网络链路上进行加速。

然而,CDN 并没有解决本质问题,master repo 这种全量下载的方式其实是有很大的优化空间的。于是,CocoaPods 1.8.0 支持了按需下载的方式,即 trunk 方案。

对于 trunk 方案,其源仓库(下文简称 trunk repo)的组织结构如下图所示。其具体方案是:

  • 使用 .txt 文件记录一系列 pod 名称及其所有版本号。
  • 同一 .txt 文件所记录的所有 pod 的名称的 MD5 值前 N 位相同,同时 .txt 文件以此 N 位 MD5 值作为文件名称的标识元素。如:all_pods_version_a_7_5.txt
  • 当请求某个 pod 的 podspec 文件时,根据 pod 名称计算得到 .txt 文件名称。如:AFNetworking -> all_pods_version_a_7_5.txt
  • 使用对应的 .etag 文件(如果没有则不使用)请求对应的 .txt 文件。.etag 文件用于表示文件是否更新。如果远端文件未更新,则使用本地已下载的 .txt 文件,如果远端文件已更新,则下载最新的 .txt 文件。

整体架构

下图所示为 source 管理机制的整体架构类图,主要有以下几个核心部分组成:

  • Manager:source 的管理类,作为 source 管理机制的代理,外部操作由 Manager 类提供接口并进行转发。
  • Aggregate:source 的聚合类,管理所有 source,承接 Manager 类接口转发,并转发至各个 source,对外隐藏了内部查找逻辑。
  • Source:描述一个 podspec 源,master repo、trunk repo 各自对应一个 Source 实例。
  • Metadata:描述一个源的元信息。
  • Specification:描述一个 podspec 文件。
  • Dependency:描述一个 pod 的一个依赖,如:podsepc 文件中定义的 dependency
  • Set:描述一个 pod 所对应的源信息,主要用于描述查询结果。

Manager

如下所示,为 Source::Manager 类核心部分的定义,Manager 使用 repos 目录作为初始化参数,默认是 ~/.cocoapods/repos

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# source/manager.rb
module Pod
class Source
class Manager
# 包含多个 repo 的目录
attr_reader :repos_dir

def initialize(repos_dir)
@repos_dir = Pathname(repos_dir).expand_path
end

# source repos 的聚合
def aggregate
aggregate_with_repos(source_repos)
end

# source repos 的聚合
def aggregate_with_repos(repos)
sources = repos.map { |path| source_from_path(path) }
@aggregates_by_repos ||= {}
@aggregates_by_repos[repos] ||= Source::Aggregate.new(sources)
end

# 给定名称,返回对应的源
def source_with_name(name)
source = sources([name]).first
return nil unless source.repo.exist?
source
end

# 给定路径,返回对应的源
def source_from_path(path)
@sources_by_path ||= Hash.new do |hash, key|
hash[key] = case
when key.basename.to_s == Pod::TrunkSource::TRUNK_REPO_NAME
TrunkSource.new(key)
when (key + '.url').exist?
CDNSource.new(key)
else
Source.new(key)
end
end
@sources_by_path[path]
end

# 返回所有源的路径
def source_repos
return [] unless repos_dir.exist?
repos_dir.children.select(&:directory?).sort_by { |d| d.basename.to_s.downcase }
end

# 给定一组名称,返回对应的一组源
def sources(names)
dirs = names.map { |name| source_dir(name) }
dirs.map { |repo| source_from_path(repo) }
end

# 返回所有的源
def all
aggregate.sources
end

# ...
end
end
end

Manager 负责初始化 ~/.cocoapods/repos 目录下所有的 source,并将它们聚合至 Aggregate 中,具体步骤包括以下几个关键步骤:

  • 执行 source_repos 方法,获取所有 source 路径
  • 执行 source_from_path 方法,根据 source 路径初始化 Source 对象。
    • 如果路径节点目录的名称为 trunk,则实例化 TrunkSource 对象。
    • 如果名为 .url 文件,则实例化 CDNSource 对象。
    • 其他情况则实例化 Source 对象。
  • 聚合所有 source 对象,实例化 Aggregate 对象。

Aggregate

Manager 实现了初始化 Aggregate 相关方法,除此之外,Manager 提供了一系列查询方法,这些方法最终会转发至 Aggregate

如下所示,为 Aggregate 类核心部分的定义,其提供了一系列查询方法,实现 Manager 转发过来的查询方法,如:search(dependency) 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
module Pod
class Source
class Aggregate
# Aggregate 聚合的所有 source
attr_reader :sources

def initialize(sources)
raise "Cannot initialize an aggregate with a nil source: (#{sources})" if sources.include?(nil)
@sources = sources
end

# 查找依赖
def search(dependency)
found_sources = sources.select { |s| s.search(dependency) }
unless found_sources.empty?
Specification::Set.new(dependency.root_name, found_sources)
end
end

end
end
end

Source

Source 用于描述一个 podspec 源,其负责管理其包含的所有 podspec 文件。其核心定义如下所示:

1
2
3
4
5
6
7
8
module Pod
class Source
# source 所对应的元信息
attr_reader :metadata
# source 所对应的 repo 路径
attr_reader :repo
end
end

Source 类还提供了一系列方法以供查询 podspec,这些方法是 Manager 所提供的查询方法的最终实现,具体的方法实现不在详细介绍,有兴趣可以去阅读源码。

CDNSource

CDNSourceSource 的子类,其提供了一种按需加载的策略,该策略的核心逻辑由 download_and_save_with_retries_async 方法实现,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
module Pod
class CDNSource < Source
# ...

def download_and_save_with_retries_async(partial_url, file_remote_url, etag, retries = MAX_NUMBER_OF_RETRIES)
# 获取 txt 文件
path = repo + partial_url
# 获取 etag 文件路径
etag_path = path.sub_ext(path.extname + '.etag')

download_task = download_typhoeus_impl_async(file_remote_url, etag).then do |response|
case response.response_code
when 301, 302
# 使用 CDN 分发的重定向位置,重新请求
redirect_location = response.headers['location']
debug "CDN: #{name} Redirecting from #{file_remote_url} to #{redirect_location}"
download_and_save_with_retries_async(partial_url, redirect_location, etag)
when 304
# 根据 etag 判断远端 txt 文件未修改,则使用本地文件
debug "CDN: #{name} Relative path not modified: #{partial_url}"
# We need to update the file modification date, as it is later used for freshness
# optimization. See #initialize for more information.
FileUtils.touch path
partial_url
when 200
# 获取远端最新 txt 文件,并更新 etag 文件内容
File.open(path, 'w') { |f| f.write(response.response_body.force_encoding('UTF-8')) }

etag_new = response.headers['etag'] unless response.headers.nil?
debug "CDN: #{name} Relative path downloaded: #{partial_url}, save ETag: #{etag_new}"
File.open(etag_path, 'w') { |f| f.write(etag_new) } unless etag_new.nil?
partial_url
when 404
debug "CDN: #{name} Relative path couldn't be downloaded: #{partial_url} Response: #{response.response_code}"
nil
when 502, 503, 504
#服务器出错,重试
if retries <= 1
raise Informative, "CDN: #{name} URL couldn't be downloaded: #{file_remote_url} Response: #{response.response_code} #{response.response_body}"
else
debug "CDN: #{name} URL couldn't be downloaded: #{file_remote_url} Response: #{response.response_code} #{response.response_body}, retries: #{retries - 1}"
exponential_backoff_async(retries).then do
download_and_save_with_retries_async(partial_url, file_remote_url, etag, retries - 1)
end
end
when 0
# 网络层错误,重试
if retries <= 1
raise Informative, "CDN: #{name} URL couldn't be downloaded: #{file_remote_url} Response: #{response.return_message}"
else
debug "CDN: #{name} URL couldn't be downloaded: #{file_remote_url} Response: #{response.return_message}, retries: #{retries - 1}"
exponential_backoff_async(retries).then do
download_and_save_with_retries_async(partial_url, file_remote_url, etag, retries - 1)
end
end
else
raise Informative, "CDN: #{name} URL couldn't be downloaded: #{file_remote_url} Response: #{response.response_code} #{response.response_body}"
end
end

TrunkSource

TrunkSourceCDNSource 的子类,其实现非常简单,仅仅是重写了 trunk repo 的远端地址,具体如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
module Pod
class TrunkSource < CDNSource
# On-disk master repo name
TRUNK_REPO_NAME = 'trunk'.freeze

# Remote CDN repo URL
TRUNK_REPO_URL = 'https://cdn.cocoapods.org/'.freeze

def url
@url ||= TRUNK_REPO_URL
super
end
end
end

Metadata

Metadata 使用一个 CocoaPods-version.yml 文件记录 source 的元信息,并保存在source repo 的根目录下。CocoaPods 通过该文件实例化一个 Metadata 对象,如下所示为 Metadata 类的核心部分定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
module Pod
class Source
class Metadata
# 最低可支持的 CocoaPods 版本,对应 `min` 字段
attr_reader :minimum_cocoapods_version
# 最高可支持的 CocoaPods 版本,对应 `max` 字段
attr_reader :maximum_cocoapods_version
# 最新 CocoaPods 版本,对应 `last` 字段
attr_reader :latest_cocoapods_version
# 定义 pod name hash 前缀的长度和数量
attr_reader :prefix_lengths
# 可兼容的 CocoaPods 最新版本
attr_reader :last_compatible_versions

def initialize(hash = {})
hash = hash.with_indifferent_access
@minimum_cocoapods_version = hash['min']
@minimum_cocoapods_version &&= Pod::Version.new(@minimum_cocoapods_version)
@maximum_cocoapods_version = hash['max']
@maximum_cocoapods_version &&= Pod::Version.new(@maximum_cocoapods_version)
@latest_cocoapods_version = hash['last']
@latest_cocoapods_version &&= Pod::Version.new(@latest_cocoapods_version)
@prefix_lengths = Array(hash['prefix_lengths']).map!(&:to_i)
@last_compatible_versions = Array(hash['last_compatible_versions']).map(&Pod::Version.method(:new)).sort
end

# 加载 YAML 文件进行实例化
def self.from_file(file)
hash = file.file? ? YAMLHelper.load_file(file) : {}
new(hash)
end

# pod 存储路径的各级目录名称截取
def path_fragment(pod_name, version = nil)
prefixes = if prefix_lengths.empty?
[]
else
hashed = Digest::MD5.hexdigest(pod_name)
prefix_lengths.map do |length|
hashed.slice!(0, length)
end
end
prefixes.concat([pod_name, version]).compact
end
end
end
end

Metadata 中定义的 path_fragment 方法会根据 pod name 和 version 来生成 pod 对应的索引目录:

  • 根据 pod name 计算 MD5 值
  • 遍历 prefix_lengths 对生成的 MD5 值迭代截取指定长度,并作为分级目录的名称

AFNetworking 为例,其 MD5 值如下所示:

1
2
irb> Digest::MD5.hexdigest("AFNetworking")
=> "a75d452377f3996bdc4b623a5df25820"
由于 prefix_lengths 的值为 [1, 1, 1],那么其生成的目录名称分别是:a75

Specification

Specification 用于表示 podspec 文件,其相关的内容我们在 《CocoaPods podspec 解析原理》 中进行了介绍,这里不再进行赘述。

Dependency

Dependency 用于描述一个 pod 的依赖,我们在 Podfile 中为一个 pod 声明的 dependency,或者,在 podspec 指定的 dependency,最终都由 Dependency 进行描述。其核心定义如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
module Pod
class Dependency
# dependency 所关联的 pod name
attr_accessor :name
# 通过外部 source 提供 podspec 文件
attr_accessor :external_source
# 解析 denpendency 所使用的 podspec repo
attr_accessor :podspec_repo
# 指定的 dependency 版本
attr_accessor :specific_version
end
end

总结

本文,通过阅读 CocoaPods-Core 源码,梳理了一下 source 管理机制及其设计结构,简化的结构示意图如下所示。

此外,我们还了解了 CocoaPods 中的两种 source 管理机制:master、trunk。这两种机制基本原理相同,在实现细节上有所区别,一种是全量下载,一种是按需下载,因此在下载速度方面有着明显的区别。

后面有时间的话,我们再根据一些具体的 pod 命令来分析它们的执行逻辑,了解 CocoaPods 中更多的实现细节。

参考

  1. CocoaPods-Core
  2. CocoaPods CDN 机制中文解析
  3. GitHub taking a very long time to download changes to the Specs Repo
  4. Master spec-repo rate limiting post‑mortem
  5. 什么是etag工作原理及配置