takuwan's blog

感じたことを書きますよ

Firebaseを利用したサーバーレス開発にはMonorepoが向いている

Firebaseを採用したサーバーレスのプロダクトでGitリポジトリをちゃんと管理しようとすると、多数のリポジトリが生まれてしまうことって、あるあるなのではないかと思います。あまり話題にはなりませんが、少なくとも私の経験上はそうなりがちです。

私の場合、Firestoreなどのセキュリティルールを管理・テストするリポジトリ、Cloud Functionsで実行するサーバー処理を管理するリポジトリ、マスターデータやマイグレーションスクリプトを管理するリポジトリ、Firestoreのバックアップなどを走らせるWorkerのリポジトリ、Webアプリのリポジトリなどを作成して、複数リポジトリを管理していたときがありました。

いまでは、Firebaseを利用したアプリケーションを開発する際には、lernaを採用したMonorepoでプロダクトを設計しています。Monorepoは、Firebase開発における一つのベストプラクティスであると捉えています。
github.com


なぜMonorepoなのか

Monorepoを採用する一番の理由は、ドメインに関するコードを一元化できることにあります。上記で述べたような、以前私がやっていたようなGitリポジトリの設計では、特にEntiyに関するコードがDRYにならず、辛みとなっていました。

プロダクトが進化するにつれて、フロントのWebアプリケーションが定義するEntityとサーバーサイドのCloud Functionsのリポジトリで定義するEntityが違ったり、マイグレーションスクリプト用のEntityをいじるたびに、マイグレーションが影響する全てのリポジトリのコードを編集する必要があったりと、手と頭を動かすコストが上がっていました。リポジトリ間でEntityの乖離が起きてしまい、何が正しい実装なのか確認する時間が発生したこともありましたが、そのときは「何をやっているんだ俺は...」と思わずにはいられなない無駄を感じました。

さらには、1つのプロダクト内でC向けWebアプリ・B向けWebアプリといった複数のアプリを開発しないといけなくなった際には、Entityの同期に一層の注意が必要になります。C向け・B向けで共有するEntityなんかはちゃんと同期をしないと、おかしな実装が紛れ込む原因になります。ドメインの実装はアプリケーションのUseCaseに引っ張られるべきではないと考えますが、アプリケーションごとにEntityがあることで誤解がうまれ、そういったものが紛れ込んでしまうこともありました。


Monorepoとは何か

そもそもMonorepoって何?って方は、簡潔にまとまっているBabelのドキュメントを読まれると良いと思います。 一言で言うなら、以前であれば複数のGitリポジトリに分けていたようなモジュールを、1つのGitリポジトリで管理するためのツール・考え方です。
Babelをはじめ、React、Angular、Jestなど有名どころもMonorepoだったりします。
github.com


 

FirebaseのプロジェクトにおけるMonorepoの構成

Firebaseを採用したsampleプロジェクトをMonorepoで構成する場合、下記のような構成になります。./packages 配下に各モジュールが並んでいます。アプリケーションが増えれば、./packagesに増やしていきます。

.
├── README.md
├── lerna.json
├── package-lock.json
├── package.json
└── packages
    ├── sample-domain # ドメインの実装
    ├── sample-functions # Cloud Functionsの実装
    ├── sample-scripts # マイグレーションスクリプトなどの実装
    ├── sample-storage # FirestoreやCloud Storageのセキュリティールールなどの実装
    ├── sample-web # ウェブアプリケーションの実装
    └── sample-worker # Workerの実装

lernaの良いところは、./packages配下のモジュールの依存関係を定義できることです。下記のようにコマンドを叩けば、sample-domainがnpm packageとしてsample-webのpackage.jsonに定義されます。sample-webのコードでsample-domainをimportし、sample-domainのエントリを呼び出すことができるようになります。

lerna add sample-domain --scope=sample-web

上記のtreeでいうならば、各moduleはsample-domainに依存する構成を想定しています。
sample-domainの実装如何ではありますが、私が直近開発しているプロダクトでは、下記のようにsample-domainを呼びだせるようにしてあります。

import Facade, { UserRepository } from "sample-domain";

const client = new Facade();
const repository = client.create(UserRepository);
const user = await repository.findByEmail("user@example.com");
...


Monorepoリポジトリでのデプロイ術

Monorepoで開発するうえでちょっと工夫が必要なのは、デプロイの方法です。とはいえ、Dockerコンテナを利用すればシンプルで簡単です。

下記のようなDockerfileを定義し、コンテナイメージを作成し、コンテナをデプロイすれば良いだけです。下記のDockerfileは、Herokuではbuildpackなしにそのまま使えますし、k8、ECSでも基本やることは同じです。

FROM node:12.16.1
WORKDIR /usr/app

COPY package.json .  # プロジェクトルートのpackage.jsonコピー
RUN npm install # lernaなどのpackageをinstall

# 余計なmoduleをCOPYしないことで、lerna bootstrapなどの時間を短縮できる
COPY /packages/sample-domain ./packages/sample-domain # ドメインmoduleをコピー
COPY /packages/sample-web ./packages/sample-web # デプロイ対象のmoduleをコピー

COPY lerna.json .
RUN npm run bootstrap # lerna bootstrapを実行して、各モジュールの依存packagesをインストール
RUN npm run build # 各moduleのビルド処理を走らせる

EXPOSE $PORT # ポートを開く

WORKDIR /usr/app/packages/sample-web
CMD npm run start # サーバーを起動


ちなみに、インフラ環境で楽をしたいならHerokuが無難でおすすめです。下記のように、moduleごとにDockerfileとheroku.yml(Herokuの実行環境を定義するファイル)を用意しておいて、Github ActionsなどのCIで両ファイルをcopy、mvして利用するのがシンプルではないかと考えています。もちろん、Zeit Nowでも似たようなことすれば動きますが、ちょっと凝ったことするとすぐ詰まるので(now.jsonがブラックボックスすぎます)、詰まりポイントが少なくエコシステムが充実しているHerokuのほうが後悔は少ないはずです。

プロジェクトの構成

.
├── README.md
├── lerna.json
├── package-lock.json
├── package.json
└── packages
    ├── sample-domain # ドメインの実装
    ├── sample-web-a # ウェブアプリケーションの実装 A
    │   ├── Dockerfile
    │   └── heroku.yml
    └── sample-web-b # ウェブアプリケーションの実装 B
        ├── Dockerfile
        └── heroku.yml

CIのスクリプト例

name: Deploy to Heroku
on:
  push:
    branches: [ release ]
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2
    - name: Setup node
      uses: actions/setup-node@v1
      with:
        node-version: '12.x'
    - name: Copy heroku.yml
      run: cp -f packages/sample-web-a/heroku.yml heroku.yml
    - name: Copy Dockerfile
      run: cp -f packages/sample-web-a/Dockerfile Dockerfile
    - name: Install dependencies
      run: npm install; npm run bootstrap
    - name: Build packages
      run: npm run build
    - name: Deploy to Heroku
      uses: AkhileshNS/heroku-deploy@v3.0.0
      with:
        heroku_api_key: ${{ secrets.HEROKU_API_KEY }}
        heroku_email: yourmail@example.com
        heroku_app_name: sample-web-a
    - name: Slack notify if success
      if: success()
      uses: rtCamp/action-slack-notify@v2.0.0
      env:
        SLACK_CHANNEL: sample-channel
        SLACK_MESSAGE: 'Success to deploy sample-web-a to Heroku.'
        SLACK_TITLE: Succeeded in deployment
        SLACK_USERNAME: sample name
        SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
    - name: Slack notify if failed
      if: failure()
      uses: rtCamp/action-slack-notify@v2.0.0
      env:
        SLACK_CHANNEL: sample-channel
        SLACK_MESSAGE: 'Failed to deploy sample-web-a to Heroku.'
        SLACK_TITLE: Failed to deploy
        SLACK_COLOR: '#f44336'
        SLACK_USERNAME: sample name
        SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}


Monorepoのデメリット

基本的にはBabelのドキュメントに書いてある通りです。また、一見するとlernaはひとクセあるので、利用方法をキャッチアップする必要があります。

 

おわりに

Firebaseを利用したサーバーレス開発においては、Monorepoを利用しプロダクトのドメイン層を共通化することで、実装の無駄・工数の無駄を省くとともに、共通言語としてのドメインを明示できるようになります。
もちろん、Git submoduleを利用することでもドメインの共通化は実現可能ではありますが、lernaを利用した依存関係の解決(npm packageとしてdomain moduleを利用すること)の利便性は結構良いものです。リポジトリの管理自体も楽です。
もっとも、iOS・AndroidをSwift・Kotlinでネイティブ開発していたり、JavaScriptエコシステムから出るとMonorepo(lerna)の恩恵には与れないため、iOS・Android側でもEntityを定義・管理する必要は出てきます。
こういうときこそ、ReactNativeの生産性は高まるよなと考える次第です。