Cloud Functions(第二世代)のドキュメントを読んでいくと、ソースのディレクトリ構造のサンプルとして以下のような構造が出てきます。
.
├── myfunction.go
├── go.mod
└── cmd/
└── main.go
ドキュメントに出てくるようなシンプルな例ならこれでも十分かもしれませんが、Cloud Functions の関数も含めてモノレポにまとめて、共通部分を切り出して…などやっていく場合、どういう構成にすれば良いか悩む人も多そうだな、と。
そこで今回、一つの例として実際に実装してみたのでシェアします。
なお、Cloud Functions の前提知識として、前回の記事 もあわせて読んでいただけると、分かりやすいかもしれません。
前提整理
- Go言語で実装
- 複数のCloud Functions関数を同一リポジトリで扱う
- 実装例では Cloud Event 関数としているが、HTTP関数でも同様にできると思われる
- Layered Architecture チックなソフトウェアアーキテクチャを想定
- usecase層 や infrastructure 層などを関数間で共用するイメージ
- 今回は usecase層 でログ出力するまでに留めています
-
internal/
ディレクトリを利用する(これには議論があるのは承知していますが、より縛りが強い状態での解決策を模索してみるために採用しています。)
実装してみたサンプルリポジトリ
ディレクトリ構造
.
├── event.go
├── go.mod
├── go.sum
├── cmd
│ └── function
│ └── event
│ └── main.go
├── env
│ ├── local
│ │ └── function
│ │ ├── a
│ │ └── b
│ └── prd
│ └── function
│ ├── a
│ │ ├── env.yaml
│ │ └── secret.yaml
│ └── b
│ └── env.yaml
└── internal
├── domain
│ └── model
│ └── message
│ └── message.go
├── ui
│ └── function
│ └── event
│ ├── a
│ │ └── function.go
│ └── b
│ └── function.go
└── usecase
├── a.go
├── b.go
└── usecase.go
工夫ポイント1) init関数をまとめつつ、環境変数で serve する関数を切り替える
前回の記事 で整理した通り、Cloud Functions のデプロイでは、go.mod/go.sum と init 関数を持つ go ファイルが配置されたディレクトリの配下が Cloud Storage にアップロードされ、Cloud Build でビルドされることになる。
init関数を提供する .go ファイルを関数ごとに用意することも考えたが、その場合 共通実装部(例えば usecase層 や infrastructure層)を各関数ディレクトリの配下にどうにか持ってくる必要が出てくる。
ドキュメントでは vendor/
の利用などが紹介されているが、ここで前提に置いた internal/
縛りが効いてきて、利用できない状態になる。
(逆を言うと、internal/
を使っていなければ、各関数のディレクトリ配下に vendoring することで解決できると思われる。ただ、それに伴って生まれる複雑さ・課題がある点に注意が必要。)
そこで今回のサンプルでは、init関数は共通化し、環境変数でEntryPoint名をもらって、それごとに serve する関数を切り替える仕組みとしている。
func init() {
name := config.MustGetFunctionEntryPointName()
switch name {
case "function_a":
functions.CloudEvent(
"function_a",
eventA.CloudEventFunc,
)
case "function_b":
functions.CloudEvent(
"function_b",
eventB.CloudEventFunc,
)
}
}
init関数を共通化したことで、テスト時や Cloud Functions 以外へホスティングする際に利用する cmd/function/event/main.go
を同一の実装にまとめることができた。
なお、funcframework では、複数を serve した場合、curl するURLのパスで呼び分けもできる(※)。そのため、今回のサンプルでは EntryPoint 名は単一の想定にしているが、カンマ区切りなどで複数を受け取り全てを serve することにしても良いかもしれない。
※ 以下参照
...
func initServer() (*http.ServeMux, error) {
server := http.NewServeMux()
// If FUNCTION_TARGET is set, only serve this target function at path "/".
// If not set, serve all functions at the registered paths.
if target := os.Getenv("FUNCTION_TARGET"); len(target) > 0 {
...
工夫ポイント2) デプロイ時の Secret Manager 指定を yaml ファイルにする
今回のサンプルリポジトリでは、Github Actions でデプロイする Workflow サンプルも含めてみた。
Composite Actions で gcloud functions deploy
コマンドを使用したデプロイ部分を作り、各関数ごとに Workflow 側でパラメータを渡してデプロイする形としている。
その中で、Secret Manager を使ったシークレット情報の注入について、少し工夫を入れてある。
gcloud functions deploy
コマンドでは、シークレット関連のオプションとして以下が用意されている。
- --set-secrets=[SECRET_ENV_VAR=SECRET_VALUE_REF,/secret_path=SECRET_VALUE_REF,/mount_path:/secret_file_path=SECRET_VALUE_REF,…]
- --update-secrets=[SECRET_ENV_VAR=SECRET_VALUE_REF,/secret_path=SECRET_VALUE_REF,/mount_path:/secret_file_path=SECRET_VALUE_REF,…]
今回のサンプルでは、SECRET_ENV_VAR=SECRET_VALUE_REF
の組み合わせのセットを yaml ファイルで定義し、shell でオプションを追加する方式としている。
以下、該当部の shell を参考までに記載しておく。
if [ "${{ inputs.secret-env-vars-file }}" != "" ]; then
secrets=$(awk -F ': ' '{print $1 "=" $2}' ${{ inputs.secret-env-vars-file }} | tr '\n' ',' | sed 's/,$//')
CMD="$CMD --set-secrets=$secrets"
fi
まとめ
以上、一つのリポジトリで管理するにあたって気になるであろう所を対応してみました。
もちろん、解き方は他にも色々あると思いますが、それを考えるきっかけにでもなれば幸いです。