複数のCloud Functionsをモノレポ化して1つのリポジトリで管理してみた

複数のCloud Functionsをモノレポ化して1つのリポジトリで管理してみた

はじめに

Cloud Functionsを複数作成し運用していると、各Cloud Functionsごとにリポジトリを作成する必要があり、管理が大変になります。
そして、それぞれのCloud Functionsで共通して使うlinter, formatter, ビルドツールなどの周辺ツールの設定はリポジトリが別れているがために共通化することができません。また、CI/CDの設定も各リポジトリごとに作成する必要があります。
そこで今回は、複数のCloud Functionsをモノレポ化し、1つのリポジトリにまとめて、linter, formatter, CI/CDなどの設定を共通化してみます。

ちなみに、Cloud Functions for Firebaseを使っている場合は、モノレポ化する方法が公式ドキュメントに記載されているためこれに従い実装するのが良いと思います。

モノレポ(Monorepo)とは

複数プロジェクトのコードベースを同一レポジトリで管理すること。またはその管理下における開発手法のこと。プロジェクトは相互依存可能で、プロジェクト間のコード共有もできます。
Babelはモノレポを採用している代表的なリポジトリです。

複数のCloud Functionsをモノレポ化するメリット・デメリット

メリット

  • すべてのCloud Functionsを単一リポジトリで管理できる。
  • linter, formatter, ビルドツールなどの周辺ツールやCI/CDなどの設定を共通化できる。
  • 型定義等のコードの共通がしやすい。

デメリット

  • linterやformatterなどの周辺ツールを共通化しているため一部のCloud Functionsを別の言語で書くことが難しい。

モノレポを支える技術

npm workspaces

  • npm自体に組み込まれた機能であり、公式のnpmコマンドラインツールで直接使用可能。
  • 単一のルートパッケージで複数のパッケージを管理するための機能を提供します。異なるパッケージ間で依存関係を簡単に管理し、共通の依存関係を効率的に共有することができます。ただし、プロジェクト全体のビルドプロセスをカスタマイズするための機能は限られます。

Turborepo

  • Vercel が開発した JavaScript/TypeScript のモノレポ環境に特化したビルドツールです。 yarn, npm, pnpm を利用している環境で利用可能。
  • 管理しているパッケージ間の依存関係を考慮した順でビルドが可能。
  • キャッシュを利用し、差分がある箇所のみのビルドを行うためビルド時間が高速。

ディレクトリ構成

Before

モノレポ化前の構成は各Cloud Functionsごとにリポジトリを作成していました。

# function-aリポジトリ
.
|--node_modules
|--.gitignore
|--.prettierrc
|--package.json
|--package-lock.json
|--tsconfig.json
|--.gitlab-ci.yml # CI/CDの設定ファイル
|--dist
|  |--index.js # トランスパイルされたファイル。Cloud Functionsのエントリーポイント
|--src
|  |--index.ts

# function-bリポジトリ
.
|--node_modules
|--.gitignore
|--.prettierrc
|--package.json
|--package-lock.json
|--tsconfig.json
|--.gitlab-ci.yml
|--dist
|  |--index.js
|--src
|  |--index.ts

# function-cリポジトリ
|--function-c

After

ルートの.gitlab-ci.ymlでは、各Cloud Functionsディレクトリ内の.gitlab-ci.ymlをincludeして実行している。共通化するジョブはルートのymlファイルに記載し、個別の環境変数の設定やデプロイ先の設定は各Cloud Functionsディレクトリ配下のymlファイルに書くようにしている。

.
|--.gitignore
|--.npmrc # npm設定ファイル
|--.prettierrc
|--.vscode
|--README.md
|--packages # 各Cloud Functionsで共通して使うパッケージ。外部パッケージのラッパーなど
|  |--common
|  |  |--package.json
|  |  |--src
|  |  |  |--index.ts
|  |--bigquery
|--functions
|  |--function-a
|  |  |--.env
|  |  |--.env.template
|  |  |--package.json
|  |  |--.gitlab-ci.yml
|  |  |--src
|  |  |  |--index.ts
|  |--function-b
|  |--function-c
|  ・
|  ・
|  ・
|--node_modules
|--package-lock.json
|--package.json
|--tsconfig.base.json
|--turbo.json
|--.gitlab-ci.yml

動作環境

$ node -v
v18.14.0

$ npm -v
9.3.1

モノレポ化手順のポイント

各ワークスペースで共通で使うパッケージはルートディレクトリでインストールする

TypeScriptなど各ワークスペースで共通して使うパッケージはルートディレクトリでインストールすることで、npmの巻き上げ(hoisting)により、ルートのnode_modulesからワークスペースのシンボリックリンクができるため、各ワークスペースで個別にインストールする必要がない。

# /cloud-functions-monorepo-template
$ npm install -D typescript

functions/function-aで他のワークスペース@package/commonを参照する設定

まずは@packcaes/commonの実装を追加します。

export const hello = (): string => {
  return 'Hello World!';
};

@packages/common@functions/function-aにインストールする場合下記コマンドでパッケージをインストールする。

# /cloud-functions-monorepo-template
$ npm install @packages/common -w @functions/function-a

*に変更することで、依存関係の最新バージョンを参照することができます。これにより、パッケージのバージョンが変更された場合に、依存関係のバージョンを上げる必要がなくなります。

{
  "dependencies": {
-   "@packages/common": "^1.0.0"
+   "@packages/common": "*"
  }
}

TypeScriptをJavaScriptへトランスパイルする。
ルートディレクトリでnpm run buildを実行すると、Turborepoにより各ワークスペースのnpm run buildが依存関係を考慮した順で実行される。

# /cloud-functions-monorepo-template
$ npm run build
{
  "scripts": {
    "build": "turbo run build"
  }
}
{
  "scripts": {
    "build": "tsc --build"
  }
}

他のワークスペースの関数をimportするにはreferencesを使い、packages/commonへの参照を設定する必要があります。

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "dist"
  },
  "references": [
    {
      "path": "../../packages/common"
    }
  ]
}
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "dist"
  }
}
export const hello = (): string => {
  return 'Hello World!';
};
import ff from '@google-cloud/functions-framework';
import { hello } from '@packages/common'; // 内部パッケージのインポート

export async function main(req: ff.Request, res: ff.Response) {
  res.send(hello());
}

Cloud Functionsをローカルで動かすための設定

env-cmdを使い、環境変数を使えるように設定します。各ワークスペースで使うため、ルートでインストールします。

# /cloud-functions-monorepo-template
$ npm install -D env-cmd

Function Frameworkを使うと、Cloud Functionsをローカルで実行することが出来ます。

# /cloud-functions-monorepo-template
$ npm install @google-cloud/functions-framework

concurrentlyを使い、scriptを並列で実行できるようにします。

# /cloud-functions-monorepo-template
$ npm install -D concurrently

functions/function-a/package.jsonを修正

{
  "name": "@functions/function-a",
  "version": "1.0.0",
  "scripts": {
+   "build": "tsc --build",
+   "dev": "concurrently \"npm run dev:*\"",
+   "dev:build": "tsc -w",
+   "dev:functions": "env-cmd -r .env npx functions-framework --source=dist/src --target=main --signature-type=http"
  }
}

Cloud Functionsの起動

# /cloud-functions-monorepo-template
$ npm run dev
@functions/function-a:dev: [functions] Serving function...
@functions/function-a:dev: [functions] Function: main
@functions/function-a:dev: [functions] Signature type: http
@functions/function-a:dev: [functions] URL: http://localhost:8080/
@functions/function-a:dev: [build]
@functions/function-a:dev: [build] 10:21:08 - Found 0 errors. Watching for file changes.

Cloud Functionsのデプロイ設定

Cloud Functionsをデプロイする際にTurborepoのturbo pruneコマンドを使い、ワークスペース間の依存関係を考慮したデプロイ用のディレクトリが作成し、それをgcloudコマンドでデプロイします。

variables:
  FUNCTION_NAME: 'function-a'

build:
  stage: build
  image: node:18.14
  script:
    - apt-get update && apt-get install -y jq
    - npm ci
    - npm run build
    - npx turbo prune --scope=@functions/$FUNCTION_NAME --out-dir=build-function
    - cp .gcloudignore ./build-function
    - jq --arg deploy_function_name "$FUNCTION_NAME" '.main = "functions/" + $deploy_function_name + "/dist/src/index.js"' package.json > ./build-function/package.json
  .
  |--.gitignore
  |--.npmrc # npm設定ファイル
  |--.prettierrc
  |--.vscode
  |--README.md
  |--packages
  |  |--common
  |  |--bigquery
  |--functions
  |  |--function-a
  |  |--function-b
  |  |--function-c
+ |--build-functions # turbo pruneコマンドで作成されたデプロイ用のディレクトリ
+ |  |--functions
+ |  |  |--function-a
+ |  |  |--package.json
+ |  |  |--.gitlab-ci.yml
+ |  |  |--src
+ |  |  |  |--index.ts

build-functionsディレクトリのpackage.jsonはルートのpackage.jsonをコピーしたファイルになるため、Cloud Functions毎にエントリポイント(main)を動的にする必要がある。
そのためbuild script内でjqコマンドを使って動的に書き換えている。
(調べたかぎりでは、Turborepoの設定でいい感じにできる方法が見つからなかったためこのように対応)

{
- "main": ""
+ "main": "functions/function-a/dist/src/index.js"
}

モノレポ化した結果

良かった点

  • 各Cloud Functionsで共通して使うパッケージをルートのpackagesディレクトリにまとめることができた。
  • linter, formatter, ビルドツールなどの周辺ツールやCI/CDなどの設定を共通化できた。
  • 新しくCloud Functionsを作成する際の実装が楽になった。

イマイチだった点

  • CI/CDで各Cloud Functionsをデプロイする際にエントリポイントを動的にするために、scriptを書いて対応する必要があった。

おわりに

複数のCloud Functionsをモノレポ化する方法について調査しましたが、実際に実装している例はあまりなかったため実装方法を調べるのに苦労しました。この方法がベストプラクティスであるかどうかはわからないため、他に良い方法があればコメントで教えていただけると嬉しいです。

ちなみに今回のソースコードは自分のリポジトリにありますで、参考にしてみてください。
https://github.com/seiya0429/cloud-functions-monorepo-template

この記事が誰かの参考になれば幸いです。

参考文献