Homebrew 的设计哲学

如果你是一位 MacOS 用户,那么你一定知道 Homebrew。Homebrew 是 MacOS 下的包管理工具,类似 apt-get/apt 之于 Linux,yum 之于 CentOS。如果一款软件发布时支持了 homebrew 安装渠道,那么我们就可以通过 homebrew 一键安装,省时省力省心。

本文,我们将来探索一下 homebrew 的底层工作原理。

通过学习其工作原理,我们可以举一反三,推测并理解其他平台的包管理工具的设计思想。此外,我们还能借此理解开源软件的设计范式,从而为软件设计提供思路和指导。当然,最直接的收益则是加深对于 homebrew 的理解,可以基于其原理来解决日常工作中的相关问题。

酿酒术语

Homebrew 的作者 Max Howell 借用了西方的酿酒文化,为软件定义了一系列的术语。因此,想要捋清楚各个术语及其之间的关系,我们有必要先简单了解一下酿酒文化中的相关术语。

对于工厂而言,酒一般会以 木桶(Cask) 的形式存放在规模较大的厂房中,即 酒桶房(Caskroom)。通常,木桶可以直接安装 酒龙头(Tap) 来打酒或装罐。具体如下图所示。

对于家庭而言,酒一般会以 瓶装酒(Bottle)罐装酒(Keg) 的形式存放在规模较小的屋子里,即 酒窖(Cellar)。由于瓶装酒和罐装酒体积较小,同时为了便于分类和存取,一般会摆放在 酒架(Rack) 上。具体如下图所示。

软件术语

Homebrew 将软件比喻成酒,对于不同类型的软件,其管理(保存)方式有所不同:

  • 对于原生应用,将其比作桶装酒,以 Cask 作为容器,保存在 Caskroom 中。
  • 对于非原生应用,将其比作瓶装酒或罐装酒,以 Bottle 或 Keg 作为容器,保存在 Cellar 的 Rack 中。

什么是 MacOS 原生应用? MacOS 原生应用是指为 MacOS 操作系统专门设计和开发的应用程序。通常使用 Apple 提供的软件开发工具(如 Xcode)和编程语言(Swift 或 Objective-C)进行开发,直接调用操作系统提供的 API 进行各种操作。 每个 MacOS 原生应用都会有一个唯一的Bundle Identifier,系统以此标识符来管理和区分不同应用。

上图所示,为 MacOS 系统中 homebrew 对于软件管理的层级结构。这里有一个细节,我们发现 Caskroom 和 Casks 构建了一个两层关系,而 Cellar、Racks、Kegs/Bottles 则构建了一个三层关系。对此,我的理解是:

  • 对于非原生应用,其索引方式是通过软链接实现的,因此同一台机器中可以存储同一应用的不同版本,通过修改软链接的指向来使用不同的版本。
  • 对于原生应用,系统以应用的 Bundle Identifier 作为唯一标识,同一应用的不同版本的 Bundle Identifier 是相同的,因此同一台机器中只能覆盖安装,不同版本无法共存。

综合上述原因,非原生应用需要三层结构进行管理,而原生应用只需两层结构进行管理。从这个角度来看,正好与 Cellar 和 Caskroom 的层级结构相匹配。

从图中,我们还可以看到 Cellar 的管理下包含了两种类型的软件,分别使用 罐装酒(Keg)瓶装酒(Bottle) 来描述,它们是存在一些细微的区别的:

  • 对于 Keg,表示的是 homebrew 通过使用源码进行编译构建的软件。
  • 对于 Bottle,表示的是 homebrew 直接下载预编译的二进制的软件。

既然 homebrew 将软件比喻成酒,那么很显然,软件的安装过程则对等比喻成酿酒。对此,homebrew 使用 木桶(Cask)配方(Formula) 作为软件安装的两个基本元素,它们分别作为原生应用的包定义和非原生应用的包定义。为了便于管理,homebrew 统一将它们放在 酒龙头(Tap) 下进行管理,如下所示。

对于 homebrew 这样设计 Tap 和 Formula、Cask 之间的关系,我个人认为,从语义上来说属实有点牵强,因为它们在任何维度上都不是包含关系。这里,我们只要知道它们之间存在着包含关系即可,无需深究。

存储结构

通过上文,我们大致了解了 homebrew 中术语的含义与关系。下面,我们来看一下它们具体在文件系统中的存储结构。

对于 MacOS 系统,homebrew 在 ARM 架构(Apple Silicon)和 X86 架构(Intel)中的存储位置所有不同,但是术语之间相对关系是一致的。作出这种区分的主要原因是,当从 X86 架构迁移至 ARM 架构时,支持在 Rosetta 模式下继续运行在 X86 架构下安装的软件应用。

对于 X86 架构,Caskroom 的路径是 /usr/local/Caskroom,Cellar 的路径是 /usr/local/Cellar,Taps 的路径是 /usr/local/Homebrew/Library/Taps

对于 ARM 架构,Caskroom 的路径是 /opt/homebrew/Caskroom,Cellar 的路径是 /opt/homebrew/Cellar,Taps 的路径是 /opt/homebrew/Library/Taps

Caskroom

Caskroom 主要负责管理原生应用,由于原生应用无法同时维护多个版本,所以在 Caskroom 下对应只会存在一个版本目录。如下所示,以 aerial 为例,在两次安装时,后一次会覆盖前一次的版本数据。

Cellar

Cellar 主要负责管理非原生应用,由于是通过软链接进行版本管理,所以在 Cellar 下对应会存在多个版本目录。如下所示,以 git 为例,它会保存多个版本的数据。

对于非原生应用,我们还可以在 /usr/local/bin(Intel)目录下看到 homebrew 为命令行应用创建的所有软链接,如下所示。

由于 PATH 环境变量包含了 /usr/local/bin,所以系统能查找 homebrew 所安装的软件的软链接,进而找到真正的可执行文件。不过在某些情况下,我们可能需要让 homebrew 安装的软件对于用户不可见,比如:避免版本冲突、仅用于依赖构建等。这时候,homebrew 不会为这些软件创建软链接,对于这种类型的软件,homebrew 称之为 keg-only,比如:openjdk

Taps

Taps 主要负责管理 包定义外部命令

  • 包定义:一个包定义对应一款软件,主要用于指导对应软件的安装。
  • 外部命令:支持用户对 homebrew 进行扩展,提供更多的命令和功能。

Taps 目录维护了多个 Git 仓库(Tap 仓库å),包括开发者自建的仓库,以及官方维护的仓库,比如:homebrew/homebrew-corehomebrew/homebrew-cask 等。

如下所示,这些仓库大多数都维护了一个 FormulaCasks 目录,其中存放了软件的包定义。这些包定义本质上是一个 Ruby 类定义,图中的 muesli/homebrew-tap 中虽然没有定义 FormulaCasks 目录,但其保存的 Ruby 文件都是包定义。

除此之外,部分仓库维护了一个 cmd 目录,其中存放了一些外部命令的定义,我们可以以文件名作为子命令进行调用,比如:cmd 目录中有一个 check-ci-status.rb 文件,我们可以通过 brew check-ci-status 命令来调用执行。通过这种方式,我们可以对 homebrew 的命令进行扩展。

包定义

作为一个软件包管理工具,homebrew 中最核心的设计便是包定义(Package Definition)。通过包定义,homebrew 才能够正确地安装对应的软件。

在上文 Taps 一节中,我们知道 homebrew 支持两种管理方式,具体而言分别是:

  • 官方仓库管理,我们可以实现自己的包定义,并向 homebrew/homebrew-core(用于 Formula)或 homebrew/homebrew-cask(用于 Cask)提交 Pull Request。这种方式会对包定义有着严格的规范和约束。
  • 自建仓库管理,我们在自定义的仓库中实现包定义,并自行维护。在安装软件时,我们将该仓库加入至 Taps 目录进行管理。

对于自建仓库,我们可以使用 brew tap-new <user>/<repo> 命令来创建一个模板仓库。如下所示,我们使用 brew tap-new baochuquan/homebrew-nox 创建了一个 Tap 仓库。命令会在 Taps 目录下创建一个仓库,并默认创建一个 Formula 目录用于存放 Formula 包定义。如果希望存放 Cask 包定义,我们可以再手动创建一个 Casks 目录。

Homebrew 中的包定义有两种:Formula 和 Cask。

Formula

Formula 是非原生应用的包定义,如下所示是 CocoaPods 的 Formula 包定义。

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
class Cocoapods < Formula
desc "Dependency manager for Cocoa projects"
homepage "https://cocoapods.org/"
url "https://github.com/CocoaPods/CocoaPods/archive/1.12.1.tar.gz"
sha256 "da018fc61694753ecb7ac33b21215fd6fb2ba660bd7d6c56245891de1a5f061c"
license "MIT"

bottle do
sha256 cellar: :any, arm64_ventura: "6f1fca1cb0df79912e10743a80522e666fe605a1eaa2aac1094c501608fb7ee4"
sha256 cellar: :any, arm64_monterey: "8f7eff899cc1807286374e29e634c1008e286c3360df6cbcb90e27b0fe5567a9"
sha256 cellar: :any, arm64_big_sur: "346833fef239df933ddb67341c55c9c4a7e547fc03afdc332861ac2ae8ba3372"
sha256 cellar: :any, ventura: "b114ec0a11a2e472026f0f7337d17558bead2ac1122d9c2bb9278fc6b31fd744"
sha256 cellar: :any, monterey: "946f0282afe0000ba9e23f30ce2175bc4b1f0c6d7e27145f01be4665b9786f8a"
sha256 cellar: :any, big_sur: "1fe6f0c45e0c13e122aa1d8bf1f9bd9496fa3bb00fe7bc19286425e029e5c278"
sha256 cellar: :any_skip_relocation, x86_64_linux: "e297731632b715118c13688acff976ce56c49df705ba2ae616445fb68cb49152"
end

depends_on "pkg-config" => :build
depends_on "ruby"
uses_from_macos "libffi", since: :catalina

def install
if MacOS.version >= :mojave && MacOS::CLT.installed?
ENV["SDKROOT"] = ENV["HOMEBREW_SDKROOT"] = MacOS::CLT.sdk_path(MacOS.version)
end

ENV["GEM_HOME"] = libexec
system "gem", "build", "cocoapods.gemspec"
system "gem", "install", "cocoapods-#{version}.gem"
# Other executables don't work currently.
bin.install libexec/"bin/pod", libexec/"bin/xcodeproj"
bin.env_script_all_files(libexec/"bin", GEM_HOME: ENV["GEM_HOME"])
end

test do
system "#{bin}/pod", "list"
end
end

Formula 包定义本质上是定义一个 Formula 的子类,将子类的名称转换成小写,以 - 代替驼峰命名,即可得到 homebrew 对应的应用名称,比如:brew install cocoapods

Formula 包含一些必须的属性设置,比如:deschomepageurlsha256license 等,用于描述应用的基本信息,源码下载地址,完整性校验值等。

此外,它支持了非常多的属性和方法,通过配置这些属性和方法,我们可以自定义应用的安装方式。比如:bottle 可以指定预编译二进制(针对不同系统)的相关配置;depends_on 可以指定应用安装所需的依赖;install 方法可以指定安装的具体操作,等等。关于 Formula 定义的更多细节,我们可以参考 homebrew/homebrew-core 中的其他示例,或者参考官方文档 Formula Cookbook

总而言之,对于非原生应用,homebrew 会根据对应的 Formula 包定义,去下载对应的二进制或源码,然后在本地进行构建、安装。

Cask

Cask 是原生应用的包定义,如下所示是 SourceTree 的 Cask 定义。

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
67
68
69
70
cask "sourcetree" do
on_sierra :or_older do
version "2.7.6a"
sha256 "d60614e9ab603e0ed158b6473c36e7944b2908d9943e332c505eba03dc1d829e"

url "https://downloads.atlassian.com/software/sourcetree/Sourcetree_#{version}.zip",
verified: "downloads.atlassian.com/software/sourcetree/"

livecheck do
skip "Legacy version"
end
end
on_high_sierra do
version "3.2.1,225"
sha256 "4bd82affa3402814c3d07ff613fbc8f45da8b0cda294d498ffbb0667bf729c9f"

url "https://product-downloads.atlassian.com/software/sourcetree/ga/Sourcetree_#{version.csv.first}_#{version.csv.second}.zip",
verified: "product-downloads.atlassian.com/software/sourcetree/ga/"

livecheck do
skip "Legacy version"
end
end
on_mojave do
version "4.2.1,248"
sha256 "3dac6ab514c7debe960339e2aee99f018342a41baf743dbb59524728b373561f"

url "https://product-downloads.atlassian.com/software/sourcetree/ga/Sourcetree_#{version.csv.first}_#{version.csv.second}.zip",
verified: "product-downloads.atlassian.com/software/sourcetree/ga/"

livecheck do
skip "Legacy version"
end
end
on_catalina :or_newer do
version "4.2.4,254"
sha256 "62dfaeedd63ac491ba3e49a5129d338c60886cb935e3654622147369023daf77"

url "https://product-downloads.atlassian.com/software/sourcetree/ga/Sourcetree_#{version.csv.first}_#{version.csv.second}.zip",
verified: "product-downloads.atlassian.com/software/sourcetree/ga/"

livecheck do
url "https://product-downloads.atlassian.com/software/sourcetree/Appcast/SparkleAppcast.xml"
strategy :sparkle
end
end

name "Atlassian SourceTree"
desc "Graphical client for Git version control"
homepage "https://www.sourcetreeapp.com/"

auto_updates true
depends_on macos: ">= :el_capitan"

app "Sourcetree.app"
binary "#{appdir}/Sourcetree.app/Contents/Resources/stree"

uninstall launchctl: "com.atlassian.SourceTreePrivilegedHelper2",
quit: "com.torusknot.SourceTreeNotMAS"

zap trash: [
"~/Library/Application Support/com.apple.sharedfilelist/com.apple.LSSharedFileList.ApplicationRecentDocuments/com.torusknot.sourcetreenotmas.sfl2",
"~/Library/Application Support/SourceTree",
"~/Library/Caches/com.torusknot.SourceTreeNotMAS",
"~/Library/Logs/Sourcetree",
"~/Library/Preferences/com.torusknot.SourceTreeNotMAS.LSSharedFileList.plist",
"~/Library/Preferences/com.torusknot.SourceTreeNotMAS.plist",
"~/Library/Saved Application State/com.torusknot.SourceTreeNotMAS.savedState",
]
end

Cask 包定义本质上是初始化一个 cask 实例。它同样包含了一系列基本属性,如:tokennamedeschomepageappurlsha256 等。

Cask 的安装逻辑基上和 Formula 是类似的。示例中,SourceTree 针对不同平台提供了不同的下载地址和 sha256 校验值,如:sierra、high sierra、mojave、catalina 等。

除此之外,Cask 也包含了大量的属性和方法,关于 Cask 的更多细节,我们可以参考 homebrew/homebrew-cask 中的其它示例,或者参考官方文档 Cask Cookbook

外部命令

类似于 git,homebrew 也支持外部命令,通过这种方式可以允许用户对 brew 进行定制和扩展,其运行方式如下所示,extcmd 可以替换成任意自定义的子命令。

1
$ brew extcmd --option1 --option2 <formula>

Homebrew 支持外部命令,从编程语言实现角度而言,可以分两种,分别是:Ruby 和其他语言。从本质上而言,它们都是可执行的(chmod+ x)脚本,存放在 PAHT 环境变量的路径中,支持系统索引。

由于 homebrew 是使用 Ruby 实现的,因此基于 Ruby 的外部命令会比较特殊。只要我们将脚本命名为 brew-extcmd.rbextcmd 可替换成任意自定义的子命令),homebrew 通过 require 加载后,brew-extcmd.rb 会进入 homebrew 的执行环境,因此可以访问 homebrew 定义的所有环境变量和功能模块,开发者可使用的工具和模块会非常多。

对于其他语言实现的脚本,脚本实现中必须使用 #! 来指定脚本的解释器,因此可支持 Python、Bash、Perl 等各种脚本语言。不同于 Ruby 脚本,对于其他语言的脚本,homebrew 要求脚本的名称不能有后缀,比如:brew-extcmd.sh 脚本必须命名为 brew-extcmd。在运行时,homebrew 会导入脚本参数和一部分环境变量。相比 Ruby 脚本而言,homebrew 对于其他语言的脚本,在功能上支持会相对弱一些。

上述两种方式都是在本地扩展外部命令,如果我们希望外部命令能给其他用户使用,那应该怎么办?对此,我们仍然可以通过 Taps 来实现。 类似于 Formula 使用 Formula 目录管理,Cask 使用 Casks 目录管理,对于外部命令,我们使用 cmd 管理外部命令的实现脚本。当然,外部命令的维护也分为官方仓库和自建仓库,只是官方仓库的要求和规范会更加严格。

关于外部命令的具体细节,我们可以参考 homebrew/homebrew-core/cmd 中的例子,也可以参考官方文档 External Commands

工作原理

整体而言,homebrew 的设计架构是比较清晰的。下面,我们来介绍 homebrew 中的一些重要设计的工作原理,主要包括:

  • 命令分发
  • 软件搜索
  • 软件安装

命令分发

命令分发是所有命令行工具的核心功能之一,绝大部分的设计思路是:通过入口脚本对命令进行解析,一个子命令匹配一个脚本,最终由对应的脚本来解析参数、选项,并执行

Homebrew 也不例外,我们执行的 brew 命令本质上是一个指向 Homebrew/bin/brew 脚本的软链接,子命令、参数、选项都会作为脚本的输入进行解析。

Homebrew/bin/brew 脚本的核心作用是 初始化一系列环境变量,并将导入 homebrew 的执行环境。它并没有对命令的参数和选项进行解析,而是直接转发给了 Homebrew/brew.sh 脚本。

Homebrew/brew.sh 脚本的职责相对而言更多,主要包括以下几分部:

  • 初始化并导入一系列环境变量。
  • 定义了一部分工具方法,主要包括:不同等级的打印方法、自动更新方法等。
  • 处理根命令选项和特定子命令,直接派发至 Homebrew/cmd/ 目录下对应的 Shell 脚本,比如:shellenv.sh--cellar.sh 等。
  • 设置执行环境,包括 CA 证书、语言设置、常用工具(git、curl)等。
  • 处理命令缩写,比如:brew ls 识别为 brew list
  • 根据子命令加载 Homebrew/cmd/ 目录下对应的 Shell 脚本,如果加载成功,则执行匹配的方法;否则将转发至 Homebrew/brew.rb 脚本继续解析。

Homebrew/brew.rb 脚本的职责相对简单,主要是负责处理 Homebrew/brew.sh 未识别的命令、选项、参数。上文,我们提到 brew 支持外部命令。因此,这里 Homebrew/brew.rb 处理了一部分的逻辑:

  • 查找内部的 cmddev-cmd 目录下对应的脚本,如果有则执行,否则进入下一步。
  • 查找 PATH 路径和 Taps 路径下查找的符合 brew-<cmd>.rbbrew-<cmd> 模式的脚本,如果有则执行,否则报错。

命令分发的整体流程如下图所示。

软件搜索

当我们希望安装某个应用时,我们会使用 brew search <formula>brew search <cask> --cask 来搜索一下 homebrew 是否支持安装该应用。

通过上述的介绍,我们很容易猜到软件搜索的逻辑,其核心原理就是借助 Taps 进行搜索和查找,包括官方的 homebrew/homebrew-corehomebrew/homebrew-cask,以及其他自定义的 Tap 仓库,从中查找各种 Formula 和 Cask 的定义,从而显示精确匹配或模糊匹配的应用。

软件安装

上文提到包定义是用于辅助完成软件安装,在安装过程中,homebrew 会使用包定义中指定的 url 下载对应的源码或预编译二进制,并根据对应的 sha256 值校验其完整性,防止被替换或篡改。

如果包定义中指定了安装过程所需要的依赖,那么 homebrew 会先下载并安装对应的依赖。

然后,执行 install 方法进行安装,对于源码则需要编译、构建,对于预编译二进制则可以执行安装。

最后,为应用创建软链接,软链接的存储路径加入了 PATH 环境变量,因此可以被系统索引。

总结

本文,我们首先介绍了 homebrew 中酿酒术语与软件术语的对应关系,从而理清了术语之间的关系,建立对 homebrew 的基本认知。然后介绍了 homebrew 安装的软件在文件系统中的存储结构、两种包定义的基本概念,以及外部命令的实现方式。最后介绍了 homebrew 中几个关键设计的工作原理,包括:命令分发、软件搜索、软件安装等。

这里我们没有深入探讨 homebrew 中的各种实现细节,而是着重介绍了整体的实现结构和理念。如果你有兴趣的话,可以自行探索其中的各种实现细节,相信也能获益不少。

通过学习开源软件的设计,我们能学到很多学习系统设计的方法,包括:如何规划软件的各个部分,比如,在什么地方存储日志,什么地方存储文件,软件的更新策略,软件的调度方式等。当然也能学到很多编程技巧,比如 homebrew 中对于 Shell 的使用,这些技巧简洁高效,能体现出作者深厚的编程功力。

后面,我还会继续学习各种开源软件的设计,总结并分享我的看法和理解~

参考

  1. brew
  2. Homebrew Documentation
  3. How to Create and Maintain a Tap
  4. Formula Cookbook
  5. Cask Cookbook
  6. External Commands