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-core
和 homebrew/homebrew-cask
等。
如下所示,这些仓库大多数都维护了一个 Formula
或
Casks
目录,其中存放了软件的包定义。这些包定义本质上是一个
Ruby 类定义,图中的 muesli/homebrew-tap
中虽然没有定义
Formula
或 Casks
目录,但其保存的 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 | class Cocoapods < Formula |
Formula 包定义本质上是定义一个 Formula
的子类,将子类的名称转换成小写,以 -
代替驼峰命名,即可得到
homebrew 对应的应用名称,比如:brew install cocoapods
。
Formula
包含一些必须的属性设置,比如:desc
、homepage
、url
、sha256
、license
等,用于描述应用的基本信息,源码下载地址,完整性校验值等。
此外,它支持了非常多的属性和方法,通过配置这些属性和方法,我们可以自定义应用的安装方式。比如:bottle
可以指定预编译二进制(针对不同系统)的相关配置;depends_on
可以指定应用安装所需的依赖;install
方法可以指定安装的具体操作,等等。关于 Formula
定义的更多细节,我们可以参考 homebrew/homebrew-core
中的其他示例,或者参考官方文档 Formula Cookbook。
总而言之,对于非原生应用,homebrew 会根据对应的 Formula 包定义,去下载对应的二进制或源码,然后在本地进行构建、安装。
Cask
Cask 是原生应用的包定义,如下所示是 SourceTree 的 Cask 定义。
1 | cask "sourcetree" do |
Cask 包定义本质上是初始化一个 cask
实例。它同样包含了一系列基本属性,如:token
、name
、desc
、homepage
、app
、url
、sha256
等。
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.rb
(extcmd
可替换成任意自定义的子命令),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
处理了一部分的逻辑:
- 查找内部的
cmd
和dev-cmd
目录下对应的脚本,如果有则执行,否则进入下一步。 - 查找
PATH
路径和 Taps 路径下查找的符合brew-<cmd>.rb
或brew-<cmd>
模式的脚本,如果有则执行,否则报错。
命令分发的整体流程如下图所示。
软件搜索
当我们希望安装某个应用时,我们会使用
brew search <formula>
或
brew search <cask> --cask
来搜索一下 homebrew
是否支持安装该应用。
通过上述的介绍,我们很容易猜到软件搜索的逻辑,其核心原理就是借助 Taps
进行搜索和查找,包括官方的 homebrew/homebrew-core
和
homebrew/homebrew-cask
,以及其他自定义的 Tap
仓库,从中查找各种 Formula 和 Cask
的定义,从而显示精确匹配或模糊匹配的应用。
软件安装
上文提到包定义是用于辅助完成软件安装,在安装过程中,homebrew
会使用包定义中指定的 url 下载对应的源码或预编译二进制,并根据对应的
sha256
值校验其完整性,防止被替换或篡改。
如果包定义中指定了安装过程所需要的依赖,那么 homebrew 会先下载并安装对应的依赖。
然后,执行 install
方法进行安装,对于源码则需要编译、构建,对于预编译二进制则可以执行安装。
最后,为应用创建软链接,软链接的存储路径加入了 PATH
环境变量,因此可以被系统索引。
总结
本文,我们首先介绍了 homebrew 中酿酒术语与软件术语的对应关系,从而理清了术语之间的关系,建立对 homebrew 的基本认知。然后介绍了 homebrew 安装的软件在文件系统中的存储结构、两种包定义的基本概念,以及外部命令的实现方式。最后介绍了 homebrew 中几个关键设计的工作原理,包括:命令分发、软件搜索、软件安装等。
这里我们没有深入探讨 homebrew 中的各种实现细节,而是着重介绍了整体的实现结构和理念。如果你有兴趣的话,可以自行探索其中的各种实现细节,相信也能获益不少。
通过学习开源软件的设计,我们能学到很多学习系统设计的方法,包括:如何规划软件的各个部分,比如,在什么地方存储日志,什么地方存储文件,软件的更新策略,软件的调度方式等。当然也能学到很多编程技巧,比如 homebrew 中对于 Shell 的使用,这些技巧简洁高效,能体现出作者深厚的编程功力。
后面,我还会继续学习各种开源软件的设计,总结并分享我的看法和理解~