Google 的工程师在实现 OAuth2 Web API 的客户端时玩起了套娃
为了简化开发者的程序与 Google Firebase 平台后端 Web 服务的交互过程,Google 提供了名为 Firebase Admin Go SDK(以下简称 Firebase SDK)的 Go 语言 Web API 客户端,让开发者可以像调用函数一样调用 Firebase 平台上的身份验证、实时数据库、消息推送等众多服务。
都说“禁止套娃”,但 Google 的工程师在实现 Firebase SDK 时却把“套娃”玩得炉火纯青。
本文就以 Firebase SDK 中调用 FCM API 的源代码为例,看看 Google 的工程师如何用“两组套娃”优雅地实现了一个基于 OAuth2 的 Web API 客户端。
FCM API 是什么
FCM(Firebase Cloud Messaging)是 Google Firebase 平台提供的跨平台消息推送(push notification)解决方案,支持开发者向 Android、iOS 和 Web 应用实时发送通知和消息,以增强用户体验和互动性。
FCM API(v1)是一套基于 OAuth2 的 Web API,客户端需要先获取访问令牌(access token)才能调用其中的 API。例如,调用 /messages:send
API 的流程如下所示。
从客户端的角度来看,在这个流程中需要管理的对象有 3 种:HTTP 客户端、访问令牌和要推送的消息。
由于每次推送的内容几乎都不相同,所以要推送的消息属于临时对象,我们暂且不去关注。而对于 HTTP 客户端和访问令牌,要考虑的点就比较多了,比如:
- 由于
/token
和/messages:send
这两个 API 的调用频率、重试策略以及连接池配置等可能有很大的不同,是否要为这两个 API 分别创建 HTTP 客户端? - 该如何存储以及刷新访问令牌,既避免频繁请求
/token
API 增加开销,又防止访问令牌失效? - 该如何使 API 的调用过程变得可观测?
面对这些问题,Google 的工程师创建了“两组套娃”——Transport 套娃和 Token Source 套娃。
Transport 套娃
从 Go 语言标准库的 http.Client
的实现可以看出,
package http
type RoundTripper interface {
RoundTrip(*Request) (*Response, error)
}
type Client struct {
// Transport specifies the mechanism by which individual
// HTTP requests are made.
// If nil, DefaultTransport is used.
Transport RoundTripper
...
}
实际负责发送 HTTP 请求的是存储在 Transport
字段中的对象,任何实现了 RoundTripper
接口的对象都可以充当 Transport(传输层)。
如下图所示,Google 的工程师在 Firebase SDK 中把带有各种扩展功能的 Transport
(或者说 RoundTripper
)一层层嵌套、包裹起来,最后形成了一个自带 OAuth2 认证功能的超级 Transport
,并把这个 Transport
填充到了一个 http.Client
的对象中。
我们先从内层(绿色)往外层(蓝色)看,
- 最内层的
defaultBaseTransport
实际上就是 Go 标准库中默认的 HTTP Transport,Firebase SDK 只是创建了它的副本,并修改了MaxIdleConnsPerHost
- 黄色的
parameterTransport
包裹着defaultBaseTransport
,也实现了RoundTripper
接口,并增加了在请求中添加特定 HTTP 头的功能 - 再往外一层的
otelhttp.Transport
是 OpenTelemetry*提供的 HTTP Transport 实现,提供了监控和追踪请求的功能,它同样实现了RoundTripper
接口并且又“套娃”了parameterTransport
- 最外层的
oauth2.Transport
是 OAuth2 客户端库提供的RoundTripper
实现,用于在每个 HTTP 请求中添加 OAuth2 的访问令牌
* OpenTelemetry 是由 CNCF(Cloud Native Computing Foundation)管理的开源项目,旨在统一不同的监控和追踪解决方案,提供一致的 API 和工具来收集、处理和导出应用程序的遥测数据。
下面我们再从外层往内层看,看一看这组 Transport 套娃的执行过程。
当调用 Go 标准库中 http.Client
对象的 Do()
方法后(最终会执行 net/http/client.go:send()
函数),首先被调用的是 oauth2.Transport
中的 RoundTrip()
方法。在该方法中,又调用了 otelhttp.Transport
实现的 RoundTrip()
方法。接下来又调用了 parameterTransport
的 RoundTrip()
方法。
最终才是最内层的“套娃”—— http.DefaultTransport
(副本)中的 RoundTrip()
方法被调用。随着一连串 RoundTrip()
调用下来,HTTP 请求也经过一系列修饰,直到此刻才算真正发送出去。
Token Source 套娃
Transport 套娃最外层的“娃娃” oauth2.Transport
会为每个 HTTP 请求添加 OAuth2 的访问令牌,那这个令牌又是怎么得到的呢?
答案是 oauth2.Transport
还关联着一组 Token Source 套娃。如图所示,
- 最外层的
errWrappingTokenSource
负责令牌获取过程中错误的捕获和处理 - 中间一层的
reuseTokenSource
负责缓存访问令牌以便复用,减少频繁的令牌请求 - 最内层的
jwtTokenSource
负责通过 JWT(JSON Web Token)生成新的访问令牌
这 3 个 Token Source 都实现了 TokenSource
这个接口。
// A TokenSource is anything that can return a token.
type TokenSource interface {
Token() (*Token, error)
}
在通过如下代码构建 oauth2.Transport
时,
trans = &oauth2.Transport{
Base: trans,
Source: ts,
}
Base
指向了 Transport 套娃,Source
则指向 TokenSource 的套娃。在 oauth2.Transport
的 RoundTrip()
方法中,
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
...
token, err := t.Source.Token() // ①
...
req2 := cloneRequest(req)
token.SetAuthHeader(req2) // ②
...
return t.base().RoundTrip(req2) // ③
}
先使用 Source
中的 Token Source 套娃获取 OAuth2 的访问令牌①,然后将这个令牌放入 HTTP 的请求头中②,最后通过 Transport 套娃的 RoundTrip()
链条发出 HTTP 请求③。
获取访问令牌时会调用 Token()
的方法链,先调用的是 errWrappingTokenSource
的 Token()
方法,在该方法中又会调用内层的 reuseTokenSource
中的 Token()
方法,最终会调用最内层的 jwtTokenSource
中的Token()
方法,通过 JWT*获取访问令牌。
* JWT(JSON Web Token)通过在系统之间传输经过签名的 JSON 对象来实现身份验证和信息交换。
在这组套娃中,值得一提的是 reuseTokenSource
的 Token()
方法的实现,
type reuseTokenSource struct {
new TokenSource // called when t is expired.
mu sync.Mutex // guards t
t *Token
expiryDelta time.Duration
}
func (s *reuseTokenSource) Token() (*Token, error) {
s.mu.Lock() // ①
defer s.mu.Unlock()
if s.t.Valid() { // ②
return s.t, nil
}
t, err := s.new.Token() // ③
if err != nil {
return nil, err
}
t.expiryDelta = s.expiryDelta
s.t = t // ④
return t, nil
}
这里首先采用了互斥锁 mu
确保对 t
(当前缓存在内存中的令牌)的并发访问是安全的。然后检查了当前缓存的令牌是否有效②,如果有效,直接返回缓存的令牌。若无效,才使用 s.new.Token()
获取新的令牌③。此时 s.new
中存放的正是最内层的“套娃” jwtTokenSource
。重新获取到的令牌最终又放到了 t
当中④。
怎么样,Google 的工程师还是挺会玩的吧,通过 Transport 和 Token Source 这两组套娃分别封装了对 /messages:send
和 /token
这两个 Web API 的调用,又通过 reuseTokenSource
十分合时宜地获取访问令牌。OpenTelemetry 也加入了套娃的行列,嵌入了个有遥测能力的“娃娃”。
套娃这种模式其实还挺常见的,TCP/IP 协议栈、Java 中的 java.io
包、Apache Thrift 中的传输层栈(transport stack)等场景中都能看到它的身影。
套娃模式(正式的名称应该是装饰器设计模式〔Decorator Design Pattern〕)通过创建一系列实现了共同接口的套娃,达到了功能扩展的目的,在不修改现有代码的情况下即可灵活地增强对象的功能。外层的套娃总会认为内层的套娃能够提供自己所需的服务,但可能一层层拆开后才发现,最内层最小的那个套娃才是实际的执行者,这之间有太多的包装。
胡译胡说 软件工程师、技术图书译者。译有《图解云计算架构》《图解量子计算机》《图解TCP/IP(第6版)》《计算机是怎样跑起来的》《自制搜索引擎》等。
转载自:https://juejin.cn/post/7387229539812491290