FCM Go SDK 代码结构解析
- |
- #FCM
- #Go
This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.
最近工作中有接入 FCM 推送的需求, 因此对 Firebase SDK 的 FCM 相关代码做了一番调研.
首先, 来一段官方 demo:
package main
import (
"fmt"
"context"
firebase "firebase.google.com/go"
"firebase.google.com/go/messaging"
"google.golang.org/api/option"
)
func main() {
opt := option.WithCredentialsFile("service-account.json")
app, err := firebase.NewApp(context.Background(), nil, opt)
if err != nil {
fmt.Println(err)
}
// Obtain a messaging.Client from the App.
ctx := context.Background()
client, err := app.Messaging(ctx)
// This registration token comes from the client FCM SDKs.
registrationToken := "YOUR_REGISTRATION_TOKEN"
// See documentation on defining a message payload.
message := &messaging.Message{
Data: map[string]string{
"score": "850",
"time": "2:45",
},
Token: registrationToken,
}
// Send a message to the device corresponding to the provided
// registration token.
response, err := client.Send(ctx, message)
if err != nil {
fmt.Println(err)
}
// Response is a message ID string.
fmt.Println("Successfully sent message:", response)
}
证书解析
首先, 来看这一行代码
opt := option.WithCredentialsFile("service-account.json")
返回的 opt
是一个 interface, 定义如下:
type ClientOption interface {
Apply(*internal.DialSettings)
}
这个 interface 只有一个 Apply 方法, 接受一个 DialSettings 结构体的指针. DialSettings 的定义如下:
type DialSettings struct {
Endpoint string
Scopes []string
TokenSource oauth2.TokenSource
Credentials *google.DefaultCredentials
CredentialsFile string // if set, Token Source is ignored.
CredentialsJSON []byte
UserAgent string
APIKey string
HTTPClient *http.Client
GRPCDialOpts []grpc.DialOption
GRPCConn *grpc.ClientConn
NoAuth bool
}
在 package option 中对每一个字段都有一个对应的函数, 如 option.WithEndpoint()
,
option.WithScopes()
等等, 这些函数都返回一个 ClientOption, 而调用 ClientOption.Apply()
方法的效果就是修改 DialSettings 中对应字段的值. 这样做的好处是, 可以使用多个
ClientOption 来修改一个 DialSettings 的不同字段.
因此, 如果调用上面的 opt.Apply()
方法, 会修改 DialSettings.CredentialsFile
字段.
创建 app
app, err := firebase.NewApp(context.Background(), nil, opt)
在 NewApp
内部做了许多操作, 但对于使用 FCM 有用的, 只有两处:
- 解析 json 证书文件, 得到 project_id.
- 除了第一步证书解析的 opt 外, 另增加一个修改 Scopes 的
ClientOption
.
创建 Messaging client
client, err := app.Messaging(context.Background())
在 app.Messaging()
方法中可以看到实际上 FCM 推送的 client 只使用了 app 的 projectID
和 opts 两个字段:
func (a *App) Messaging(ctx context.Context) (*messaging.Client, error) {
conf := &internal.MessagingConfig{
ProjectID: a.projectID,
Opts: a.opts,
Version: Version,
}
return messaging.NewClient(ctx, conf)
}
而在 messaging.NewClient()
函数中, 最重要的是调用 transport.NewHTTPClient()
这一步, 在这个函数里完成了 FCM 推送最关键的获取 access_token 以及发送请求时增加
Authorizion
的处理:
func NewClient(ctx context.Context, c *internal.MessagingConfig) (*Client, error) {
if c.ProjectID == "" {
return nil, errors.New("project ID is required to access Firebase Cloud Messaging client")
}
hc, _, err := transport.NewHTTPClient(ctx, c.Opts...)
if err != nil {
return nil, err
}
return &Client{
fcmEndpoint: messagingEndpoint,
iidEndpoint: iidEndpoint,
client: &internal.HTTPClient{Client: hc},
project: c.ProjectID,
version: "Go/Admin/" + c.Version,
}, nil
}
由于 transport.NewHTTPClient()
中有多层嵌套, 这里只捡重要的来看. 首先,
经过多个函数调用后, 走到 google.golang.org/api/transport/http
package 的
newTransport()
函数这里:
func newTransport(ctx context.Context, base http.RoundTripper, settings *internal.DialSettings) (http.RoundTripper, error) {
trans := base
trans = userAgentTransport{
base: trans,
userAgent: settings.UserAgent,
}
trans = addOCTransport(trans)
switch {
case settings.NoAuth:
// Do nothing.
case settings.APIKey != "":
trans = &transport.APIKey{
Transport: trans,
Key: settings.APIKey,
}
default:
creds, err := internal.Creds(ctx, settings)
if err != nil {
return nil, err
}
trans = &oauth2.Transport{
Base: trans,
Source: creds.TokenSource,
}
}
return trans, nil
}
由于使用的是 FCM 的 HTTP v1 接口, 因此这个 switch 判断会走到 default 这个 label. 这里做了两件事:
- 使用 DialSettings 再次初始化了一个证书实例, 用于管理 access_token.
- 创建了一个 oauth2 的 transport, 用于请求时附加
Authorization
头.
管理 access_token
internal.Creds()
方法经过一系列调用, 执行到 golang.org/x/oauth2/google
package
的如下方法:
func (f *credentialsFile) tokenSource(ctx context.Context, scopes []string) (oauth2.TokenSource, error) {
switch f.Type {
case serviceAccountKey:
cfg := f.jwtConfig(scopes)
return cfg.TokenSource(ctx), nil
case userCredentialsKey:
cfg := &oauth2.Config{
ClientID: f.ClientID,
ClientSecret: f.ClientSecret,
Scopes: scopes,
Endpoint: Endpoint,
}
tok := &oauth2.Token{RefreshToken: f.RefreshToken}
return cfg.TokenSource(ctx, tok), nil
case "":
return nil, errors.New("missing 'type' field in credentials")
default:
return nil, fmt.Errorf("unknown credential type: %q", f.Type)
}
}
由于 credentialsFile.Type
的值为 service_account
(查看 json 证书文件可以证明),
因此会走到这里:
func (c *Config) TokenSource(ctx context.Context) oauth2.TokenSource {
return oauth2.ReuseTokenSource(nil, jwtSource{ctx, c})
}
oauth2.ReuseTokenSource()
函数略去不表, 它的作用就是当 access_token 过期时重新生成一个.
而真正获取 access_token 是在 jwtSource.Token()
方法中处理. 代码太多就不贴了,
可以看 这里.
请求时的处理
上一步生成的 TokenSource
被传给了 oauth2.Transport
. 在 Transport.RoundTrip()
方法中,
就会调用 TokenSource.Token()
方法来获取 access_token 了.
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
// 省略部分代码
if t.Source == nil {
return nil, errors.New("oauth2: Transport's Source is nil")
}
token, err := t.Source.Token()
if err != nil {
return nil, err
}
req2 := cloneRequest(req) // per RoundTripper contract
token.SetAuthHeader(req2)
t.setModReq(req, req2)
res, err := t.base().RoundTrip(req2)
// 省略部分代码
}
发送请求
无非就是 json 序列化, 然后使用上面的 transport 对 HTTP 请求进行处理并发送罢了.
问题
在 Firebase 文档 中,
有说到在获取 access_token 时需要
https://www.googleapis.com/auth/firebase.messaging
这个 scope,
但我在代码中似乎并没有找到相关处理?