Athens 的设计

本文档旨在详细阐述 Athens 的设计理念。我们希望为您提供一个良好的开端,通过文字和图表而非代码来描述 Athens 的架构。您可以阅读源码并提出疑问(我们始终很乐意回答)!

本节内容概览

我们将本节分为两个核心部分:

  1. 代理内部原理 - Athens 代理架构的基础知识和主要特性
  2. 通信流程 - Athens 代理如何与外部组件交互以获取和存储代码、响应用户请求等

如何阅读本节

与其他部分不同,本文档作为参考资料,无需按顺序通读即可发挥其价值。我们希望您在参与项目贡献时能最大程度地利用本节内容。

Athens 的设计 的子部分

代理

Athens 代理

Athens 代理有两个主要用途:

  • 内部部署
  • 公共镜像部署

本文档详细介绍了 Athens 代理的功能,您可以使用这些功能来实现任一用途。

Athens 代理的角色

将代理主要部署在企业内部可以:

  • 托管私有模块
  • 限制对公共模块的访问
  • 存储公共模块

重要的是,Athens 代理并不打算作为上游代理的完整镜像。对于公共模块,其角色是在本地存储模块并提供访问控制。

当公共模块未存储时会发生什么

当用户从代理请求模块 MxV1,而 Athens 代理的存储中没有 MxV1 时,它首先确定 MxV1 是私有模块还是非私有模块。

如果是私有模块,它立即从内部 VCS 将模块存储到代理存储中。

如果不是私有模块,Athens 代理会查询其排除列表以获取非私有模块(见下文)。如果 MxV1 在排除列表上,Athens 代理返回 404 并且终止其他流程。如果 MxV1 不在排除列表上,Athens 代理执行以下算法:

upstreamDetails := lookUpstream(MxV1)
if upstreamDetails == nil {
	return 404 // if the upstream doesn't have the thing, just bail out
}
return upstreamDetails.baseURL

这个算法的重要部分是 lookUpstream。该函数查询上游代理上的一个端点:

  • 如果在其存储中没有 MxV1,则返回 404
  • 如果其存储中有 MxV1,则返回 MxV1 的 base URL

在项目的更高版本中,我们可能会在代理上实现事件流,任何其他代理都可以订阅并监听其关心的模块的删除/弃用信息

排除列表和私有模块过滤器

为了适应私有(企业)部署,Athens 代理维护两个重要的访问控制机制:

  • 私有模块过滤器
  • 公共模块排除列表

私有模块过滤器

私有模块过滤器是字符串通配符模式,告诉 Athens 代理什么是私有模块。例如,字符串 github.internal.com/** 告诉 Athens 代理:

  • 永远不要向公共互联网(即上游代理)发出关于此模块的请求
  • 从 VCS 的 github.internal.com 下载模块代码(在其存储机制中)

公共模块排除列表

公共模块排除列表也是通配符模式,告诉 Athens 代理它永远不应该从任何上游代理下载这些模块。例如,字符串 github.com/arschles/** 告诉 Athens 代理始终向客户端返回 404 Not Found

目录端点

代理提供了一个 /catalog 服务端点,用于获取本地存储中包含的所有模块及其版本。该端点接受一个分页Token和一个页面大小参数来分页查询。

查询格式为:

https://proxyurl/catalog?token=foo&pagesize=47

其中 token 是可选的分页参数,pagesize 是返回页大小。 首次调用时不需要 token 参数,它用于分页查询。

结果是以下结构的 json:

{"modules": [{"module":"github.com/athens-artifacts/no-tags","version":"v1.0.0"}],
 "next":""}

如果没有返回 next token,则表示当前是最后一页。默认分页大小为 1000。

通信

通信流程

在软件构建过程中,不可重现性一直是困扰开发者的难题。Athens 旨在解决这一问题,其存储机制尤为关键。

空存储状态

初始状态下,Athens 代理的存储为空。

用户在此阶段发出请求时,工作方式如下图所示:

  • 用户运行 go get 来获取新模块。
  • Go CLI 联系 Athens 代理,请求模块 M,版本 v1.0。
  • Athens 代理检查其存储中是否存在此模块?不存在。
  • Athens 代理从底层 VCS 下载代码并将其转换为 Go 模块格式。
  • 收到所有数据后,代理将其存储到自己的存储中并提供给用户。
  • 用户成功获取模块。

整个过程为同步流程。

空存储状态通信流程 空存储状态通信流程

Happy Path

此刻 Athens 代理已知晓模块 M 的 v1.0 版本,可立即将该模块提供给用户,无需再从 VCS 获取。

新代理通信流程 新代理通信流程

从 VCS 到用户

您阅读了代理通信文档,然后打开代码库并对自己说:这并不像文档描述的那么简单。

Athens 有一系列架构组件,负责处理 Go 模块从 VCS 进入存储再到用户的整个过程。如果您对这些组件如何协同工作感到困惑,请继续阅读!

通信中,您知道当模块没有在存储中找到时,它会从 VCS(如 github.com)下载,然后提供给用户。您还知道整个过程是同步的。但阅读源码时,您会遇到模块获取器和下载协议暂存器等组件,难以区分其功能。本文将帮助您理解整个流程。

组件

请参阅下图了解各组件架构:

组件架构图 组件架构图

如图所示,存在多层包装器。您在代码中首先遇到的组件是 Storage 和 Fetcher,下面我们首先从这两个组件开始介绍。

Storage 存储

Storage 名称即其功能描述。通过 proxy/storage.goGetStorage 函数创建存储实例。

基于传入的存储类型环境变量,可创建内存、文件系统、MongoDB 等多种存储。

模块存储于此。

Fetcher 获取器

Fetcher 是我们介绍的第一个组件。从名称可以推断,Fetcherpkg/module/fetcher.go)负责从 VCS 获取源代码。

为此,需要两项要素:go 二进制文件和 afero.FileSystem,在初始化期间传递给 Fetcher

mf, err := module.NewGoGetFetcher(goBin, fs)
if err != nil {
    return err
}

app_proxy.go

当请求新模块时,会调用 Fetch 函数。

Fetch(ctx context.Context, mod, ver string) (*storage.Version, error)

fetch 函数

Fetcher 的工作流程如下:

  • 使用注入的 FileSystem 创建一个临时目录
  • 在临时目录中构建一个虚拟的 Go 项目,包含简单的 main.gogo.mod,以便使用 go CLI
  • 调用 go mod download -json {module}

此命令将模块下载到存储中。下载完成后:

  • Fetch 函数从存储中读取模块数据并返回给调用者
  • go mod 命令在返回的 JSON 响应中会包含模块文件的确切路径。

Stash 暂存

为保持组件精简和可读,我们不愿将存储功能放到 Fetcher中使其膨胀。对于将模块保存到存储中,我们使用 Stasher,这是 Stasher 的唯一职责。

我们认为保持组件小而正交很重要,所以 Fetcherstorage.Backend 不相互交互。相反,Stasher 将它们组合在一起,并协调获取代码并存储的过程。

New 方法接受 Fetcherstorage.Backend 以及一组包装器(稍后解释)。

New(f module.Fetcher, s storage.Backend, wrappers ...Wrapper) Stasher

stasher.go

pkg/stash/stasher.go 中的代码并不复杂,但很重要。其主要完成两项工作:

  • 调用 Fetcher 获取模块数据
  • 使用 storage 存储数据

仔细阅读代码,您会注意到传递给基本 Stasher 实现的包装器。这些包装器添加了更高级的逻辑,有助于保持组件整洁。

新方法返回一个包装器包装后的 Stasher

for _, w := range wrappers {
    st = w(st)
}

stasher.go

Stash wrapper - Pool 池

由于下载模块是资源密集型(内存)操作,Pool(pkg/stash/with_pool.go)帮助我们控制并发下载数量。

它使用 N-worker 模式,启动指定数量的 worker,然后等待任务完成。Worker 完成任务后返回结果,等待下一个任务。

在这种情况下,一个任务就是对底层 Stasher 的 Stash 函数调用。

Stash wrapper - SingleFlight

我们知道模块获取是资源密集型操作,我们刚刚限制了并行下载的数量。为了帮助我们节省更多资源,我们希望避免多次处理同一个模块。

SingleFlight 包装器(pkg/stash/with_singleflight.go)在内部使用 map 跟踪当前下载,避免重复处理。

如果任务到来且 map[moduleVersion] 为空,用回调通道初始化它,并在底层 Stasher 上开启一个 Stash 任务。

s.subs[mv] = []chan error{subCh}
go s.process(ctx, mod, ver)

如果请求的模块已有条目,SingleFlight 将订阅结果:

s.subs[mv] = append(s.subs[mv], subCh)

一旦任务完成,模块被传递至上一层 download protocol(或可能包装的 stasher)。

Download protocol 下载协议

最外层是 download protocol 下载协议。

dpOpts := &download.Opts{
    Storage: s,
    Stasher: st,
    Lister: lister,
}
dp := download.New(dpOpts, addons.WithPool(protocolWorkers))

它包含我们前面提到的两个组件:StorageStasher,以及一个额外的组件:Lister

Lister 用于在 ListLatest 函数中用于在上游代理中查找可用版本。

Storage 在这里又出现了,之前在 Stasher 中用于保存。在 Download protocol 中,其用于检查模块是否已存在。如果已存在,则直接从 storage 获取。

否则,Download protocol 使用 Stasher 下载模块,将其存储到 storage,然后返回给用户。

您还可以在上面的代码片段中看到 addons.WithPool。这个 addon 类似于 Stash wrapper - Pool。它控制代理可以处理的并发请求数量。

Fork me on GitHub