ティーポットは珈琲を淹れられない

ソフトウェアエンジニアK5のブログ

Nx で Fireabase Functions を扱う方法

2021年4月5日

Nx って何?

Nx は Angular や React を使った企業での Web 開発で利用されることを想定した monorepo ツールです。 公式では Angluar と React をサポートしています。React が使われている静的サイトジェネレータの Gatsby も。 サーバーサイドでは Express, Nest.js, Next.js がサポートされています。

プラグイン機構になっており、他にもサードパーティが提供するプラグインで色々サポートすることができる仕組みになっています。 Vue.js のプラグイン もあります。同じところが Nuxt.js のプラグイン も提供しています。

あるシステムを開発するとして、

  • ユーザー用 Web アプリ
  • 管理者用 Web アプリ
  • バックエンドのサーバーアプリ

といった具合に幾つかのプロジェクトで構成されていることが多いかと思います。

  • それぞれリポジトリ分けてると面倒だなぁ
  • このデータ型定義、あっちのアプリでもこっちのアプリでも同じの使ってるなぁ、1箇所にまとめたいなぁ
  • ユーザー用と管理者用でどっちでも利用する画面コンポーネントがあるんだけどなぁ、1箇所にまとめたいなぁ
  • ESLint の設定やら TypeScript の設定やら、同じような設定をあっちにもこっちにも、共通定義を拡張する形にできないかなぁ

といった、「共通化したい」要求が出てきます。 それを叶えるために monorepo 構成にするわけですが、Nx は 特に Angular や React などの Web アプリ開発に特化した monorepo 管理ツールになります1

英語ですが動画付きでチュートリアルもあります。どんな感じのものか掴めるかと思います。

全ての npm パッケージはプロジェクトルートの node_modules にインストールされ、一括管理されます。

動機

勤め先の creato(クリート) では React も Vue.js も利用していますが、第一選択肢は Angular です。 Nx は親和性が高く利用したいのですが、Nest.js より Firebase を使うプロジェクトの方が多いです。 ところが Nx は Firebase(というか Functions)にデフォルトでは対応していません。

Nx で Firebase Functions を扱う方法が Allow development of Firebase Cloud Functions で議論されています。

実際に試してみたので、ここで日本語で手順をまとめて共有したいと思います。

前提

Node.js が動作する環境を用意してください。 Git も必要です。

Unix コマンドを使います。Linux や macOS は良いですが、Windows でコマンドプロンプトや PowerShell を使う場合は、記載されているコマンドで動作しないものがある(rm など)ため、各環境でのコマンドに置き換えてください。

実際の作業は Docker コンテナを作ってやったのですが、ここでは理解を簡単にするために Docker を使わない前提で説明します。 前提知識や用意するものを増やしたくないので。

yarn は利用せずに Node.js 付属の npm を利用して説明します。

自分が試した環境は以下です。

  • Node.js 14.16.0
  • Nx 11.4.0 および 11.6.1

Nx ワークスペース作成

git の user.email と user.name は設定しておいてください。 ワークスペース作成の最後で email と name が取得できずにエラーが発生します。

Nx の monorepo ワークスペースを作成します。

npx create-nx-workspace@latest

どのタイプのワークスペースを作成するか聞かれます。 ここでは Functions 以外に何を利用するかは特に想定していないので empty を選択しておきます。 “Use Nx Cloud?” は “NO” で。

empty を選択しておいて後から Angular でも React でも、必要なアプリケーションは追加可能です。 empty 以外を選択した場合はあくまでお手軽セットアップができるだけです2

ワークスペース名はリポジトリ名となるものを指定すれば良いかと。

>  NX   NOTE  Nx CLI is not installed globally.

  This means that you might have to use "yarn nx" or "npx nx" to execute commands in
  the workspace.
  Run "yarn global add nx" or "npm install -g nx" to be able to execute command directly.

と表示されます。 もし nx コマンドの実行時に npx nx と打つのが面倒なら nx パッケージをグローバルインストールしてください。

npm i -g nx

グローバルを汚したくないなら3npx nx でローカル node_modules 内の nx を実行できるので必須ではないです。 ここではコマンドを簡略化して記載するために、グローバルインストールされている前提で説明します。 グローバルインストールしない場合は、nx の前に npx を付けて実行してください。

Firebase プロジェクト作成

先に Firebase コンソールでプロジェクトを作成しておきます。 Firestore を利用するなら、利用開始をしておきます(リージョンは後から変更できないので注意。asia-northeast1 が東京、asia-northeast2 が大阪。asia-northeast3 はソウル、日本ではないので注意。)

firebase-tools をグローバルインストールしておきます。

npm i -g firebase-tools

Firebase にログインして、プロジェクトの初期化を行います。

firebase login
firebase init

ここでは Functions を利用するように答える想定で説明します。 他は利用したいものを指定してください。

Functions は TypeScript 利用、ESLint 利用を選択。 install dependencies は n を選択 (後で Nx ワークスペース機能を利用するので、ここでインストールする必要がないためです)。

.gitignore

最初から .gitignore が存在していることで、firebase init した際に .gitignore が生成されません。 普通に作成した際に生成される .gitignore の内容で、Nx が生成した .gitignore に足らなくて必要そうな firebase 関係のものを追加しておきましょう。

また firebase ディレクトリも .gitignore に追加しておきます。 この中にユーザー認証情報やエミュレータの実行ファイルなどが格納されます。 これらは git 管理しなくても、他の開発者が firebase loginfirebase emulators:start した際に、必要なものが作成されるようです(全部 ignore して良いか自信ないので、firebase ディレクトリ以下で必要なものがあるなら、もう少し細かく指定してください)。

以下を .gitignore に追加しておきます。

#
# Firebase
#

firebase

# logs
firebase-debug.log*
firebase-debug.*.log*

# Firebase cache
.firebase/

Node アプリケーション追加

Functions アプリケーションを Node.js アプリケーションとして追加します。

コンソールで以下を実行します。

npm i -D @nrwl/node
nx g @nrwl/node:application functions

最初の npm コマンドは Node アプリケーションの作成機能を Nx に追加します。 このように作成できるアプリケーションの種類をプラグインとして追加していく仕組みになっています。 (Nx 11.4.0 の時点では nx add @nrwl/node で追加しましたが、11.6.1 では nx add コマンドは削除されていました。)

次の nx コマンドで Node アプリケーションを追加しています(nx gnx generate の省略形)。

apps/functions が生成されます。 (アプリケーションは apps の下に、ライブラリは libs の下に配置されます)

ESLint の tsconfig や ESLint の設定は、親にある共通設定が利用されるようになっています。 Functions でだけ適用したいものがあれば追加できるけれど、ここではこのままで。

プログラムの中身は firebase init が生成するものもほぼ空っぽだけれど、一応入れ替えておきます。 ここに Functions の関数を実装していくことになります。

cp functions/src/index.ts apps/functions/src/main.ts

Firebase 関係のパッケージ追加

firebase init が生成した package.json に含まれているパッケージのうち、足らないものを Nx ワークスペースにインストールします。

npm i firebase-admin firebase-functions
npm i -D firebase-functions-test

デプロイ用 package.json 生成ツール追加

tools/scripts/build-firebase-functions-package-json.ts を作成します。内容は以下です。

途中にある engines: {node: '14' } のところは、Firebase Functions で利用する Node バージョンに置き換えてください。 この記事執筆時点(2021/04)では 14 はまだ Public Preview です。

import * as depcheck from 'depcheck';
import * as fs from 'fs';
import * as path from 'path';

import * as packageJson from '../../package.json';

const PACKAGE_JSON_TEMPLATE = {
  engines: { node: '14' },
  main: 'main.js',
};

async function main(): Promise<void> {
  const args = process.argv.slice(2);
  if (!args?.length || !args[0]) {
    throw new Error('Application name must be provided.');
  }

  const APPLICATION_NAME = args[0];
  console.log(`Application name: ${APPLICATION_NAME}`);

  /*****************************************************************************
   * package.json
   * - Filter unused dependencies.
   * - Write custom package.json to the dist directory.
   ****************************************************************************/
  const ROOT_PATH = path.resolve(__dirname + '/../..');
  const DIST_PROJECT_PATH = `${ROOT_PATH}/dist/apps/${APPLICATION_NAME}`;

  console.log('Creating cloud functions package.json file...');

  // Get unused dependencies
  const { dependencies: unusedDependencies } = await depcheck(DIST_PROJECT_PATH, {
    package: {
      dependencies: packageJson.dependencies,
    },
  });

  // Filter dependencies
  const requiredDependencies = Object.entries(packageJson.dependencies as { [key: string]: string })
    ?.filter(([key, _value]) => !unusedDependencies?.includes(key))
    ?.reduce<{ [key: string]: string }>((previousValue, [key, value]) => {
      previousValue[key] = value;
      return previousValue;
    }, {});

  console.log(`Unused dependencies count: ${unusedDependencies?.length}`);
  console.log(`Required dependencies count: ${Object.values(requiredDependencies)?.length}`);

  // Write custom package.json to the dist directory
  await fs.promises.mkdir(path.dirname(DIST_PROJECT_PATH), { recursive: true });
  await fs.promises.writeFile(
    `${DIST_PROJECT_PATH}/package.json`,
    JSON.stringify(
      {
        ...PACKAGE_JSON_TEMPLATE,
        dependencies: requiredDependencies,
      },
      undefined,
      2
    )
  );

  console.log(`Written successfully: ${DIST_PROJECT_PATH}/package.json`);
}

main()
  .then(() => {
    // Nothing to do
  })
  .catch(error => {
    console.error(error);
  });

要するに、ワークスペースルートにある package.json を元に、

  • Functions のソースコードが依存していないものを除外する
  • Node.js ランタイムのバージョン指定を追加

したデプロイ用の package.json を生成するスクリプト。

依存関係を調べるのに depcheck パッケージを利用するのでインストール。

npm i -D depcheck

このスクリプトのディレクトリに tsconfig や ESLint 関係のファイルを追加しておきます。 ルートにあるものを参照するだけでいいので、apps/functions と同じでいいです(ちょうど階層的にも ../.. がルート)。 一応 Jest でテストも作成可能なように jest.config.js もコピーしておきます。

cp apps/functions/*.json tools/scripts
cp apps/functions/.eslintrc.json tools/scripts
cp apps/functions/jest.config.js tools/scripts

ただ、このスクリプトでは json ファイルを import していて、このままだと resolveJsonModule オプションを付けることを考えろというエラーが出ます。 また Node.js は import を使うとエラーになる4ので、TypeScript から JavaScript へ変換後の形式は commonjs にしておきます。 tools/scripts/tsconfig.json に以下を追加。

{
  // 略
  "compilerOptions": { "resolveJsonModule": true, "module": "commonjs" },

なおツールの実行時には ts-node に --project ./tools/scripts/tsconfig.json オプションを指定し、利用する tsconfig を明示しないと自動認識はしてくれませんでした。

workspace.json 編集

Nx ワークスペース作成時に empty を指定してれば、プロジェクトルートに workspace.json があるはずです。 なんでこんなことを書いているかというと、angular を選択したときは angular.json という名前で作成されたからです。

workspace.json(または angular.json)を編集して、build, serve, deploy などのコマンド実行時の処理を変更、追加します。 Nx で Node アプリケーションとして追加した時点で、workspace.json にアプリ設定が追加されていますが、それをさらに編集します。

コメントは説明のために記載しているもので、実際のファイルには記載しません。

    "functions": {
      // 略
      "architect": {
        // build の項目を build-node に変更。
        // build 時に他にも実行したことがあるので、サブコマンド扱いにする。
        "build-node": {
          // 略
        },
        // build の項目を新しく追加
        // build-node を実行して TypeScript ビルドした上で、package.json の用意も行う。
        "build": {
          "builder": "@nrwl/workspace:run-commands",
          "options": {
            "commands": [
              {
                "command": "nx run functions:build-node"
              },
              {
                "command": "ts-node --project ./tools/scripts/tsconfig.json tools/scripts/build-firebase-functions-package-json.ts functions"
              },
              {
                "command": "cd dist/apps/functions && npm install --package-lock-only"
              }
            ],
            "parallel": false
          },
          "configurations": {
            "production": {
              "prod": true
            }
          }
        },
        // ビルドした上で、Firebase Emulator を使って実行するように書き換え
        "serve": {
          "builder": "@nrwl/workspace:run-commands",
          "options": {
            "command": "nx run functions:build && firebase emulators:start --only functions --inspect-functions"
          }
        },
        // ビルドした上で functions:shell を利用できる設定を追加
        "shell": {
          "builder": "@nrwl/workspace:run-commands",
          "options": {
            "command": "nx run functions:build && firebase functions:shell --inspect-functions"
          }
        },
        // shell と同じ
        "start": {
          "builder": "@nrwl/workspace:run-commands",
          "options": {
            "command": "nx run functions:shell"
          }
        },
        // デプロイ設定を追加
        "deploy": {
          "builder": "@nrwl/workspace:run-commands",
          "options": {
            "command": "firebase deploy --only functions"
          }
        },
        // lint と test はそのまま
        // 略
      }
    }

大事なのはビルド設定だけです。

あとはビルドと firebase コマンドの組み合わせだったり、firebase コマンドを実行するだけだったり。 それらを追加する必要性を感じないけれど、firebase init が生成した package.json の scripts にあるものを移植した感じです。

いらなくなった functions ディレクトリ削除

参考にするために残していましたが、もう移植し終わったので firebase init が生成した functions ディレクトリを削除します。

rm -rf functions

デプロイ設定

Functions のデプロイ時のビルド設定を行います。

ソースコードの場所の階層がプロジェクトルートに対して ./functions から ./apps/functions に変わっています。 このままだとデプロイ時にエラーが発生するので、明示的に source を指定する必要があります5

firebase.json を編集します。

  "functions": {
    "source": "dist/apps/functions",
    "predeploy": [
      "nx lint functions",
      "nx build functions"
   ]
  },

なお nx コマンドがサブコマンドとして認識できる lint や build, test, serve, e2e などに関しては、nx run functions:lintnx lint functions のように書くことができます。

まとめ

Angular や React を使った企業での Web 開発で利用されることを想定した monorepo ツールである Nx で、残念ながら企業での Angular や React を使った Web 開発でよく利用される Firebase に対応したプラグインが提供されていなかったため6、手作業で対応する方法を解説しました。

元々は GitHub の Issue で議論されていた内容 です。やっている内容をプラグイン化すれば、次回からは楽をできるし、他の人も助かると思うのですが誰も面倒でやってないという状況ですね。 自分もやる気がないです。

  • 実際に手を動かして設定することで Nx の仕組みや設定方法がよく分かる(次回も手作業すると思い出す)
  • この記事のようにやり方がまとまっていれば、作業が酷く面倒というほどでもない
  • どうせプロジェクトセットアップの最初にやるだけ
  • プラグイン化するには、様々な状況下を想定して、プラグインが追加する設定が他に干渉をしないかを考える必要がある(手作業なら作業者が考えて回避したり調整できる)
  • 機械学習のプロジェクトにアサインされてしまったので、暫く Web 開発することはなさそう

あたりが理由です。

最初の理由が大きいですね。こういったツールは便利ですが、トラブった時に対処できるようになっておきたいので、中身の仕組みをある程度把握しておきたいと考えてしまいます。

最後のは完全に自分個人の事情ですが、専門が特にないエンジニアなので、必ずしも Web 開発してるってわけではないんです。 作るモチベーションがあっても、メンテするモチベーションはないなぁと。

この記事を読んでプラグイン化することに興味を持った方は、チャレンジしてみてはどうででしょうか?


  1. 他に有名な monorepo ツールに Lerna があります。Nx のブログに Why you should switch from Lerna to Nx という記事があります。オープンソースでライブラリ開発なら Lerna だけど、企業でアプリ開発するなら Nx だという主張で理由が述べられています。

  2. Angular を試してみましたが、empty にしておいて後から追加した方がオプションで指定できるものが多いので好ましいかもと思いました。

  3. 複数の Nx ワークスペースがある場合、ワークスペースごとに利用している Nx のバージョンが異なることは容易に考えられます。グローバルとローカルの nx バージョンが合わないことで要らないトラブルを招くより、ローカルの nx を実行した方が良さそうに思います。自分は Docker で環境を分離するので、Docker コンテナ内でグローバルインストールしますが。

  4. 一応 Node 12 から ES Modules はサポートされていますが、実験的機能のため --experimental-modules オプションを指定する必要があったり、拡張子を mjs にする必要があったり。TypeScript が commonjs にトランスパイルしてくれるのに、それらを使う意味がありません。

  5. Error: An unexpected error has occurred. と表示され、firebase-debug.log を見ると TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of type string. Received undefined というエラーが出てました。エラーが分かりにくいけど、そういう理由でした。

  6. 正確には Google Cloud Functions Generator が見つかったのですが、Functions の関数1つにつき1つのアプリ扱いされるもので、およそ企業での Web 開発に向くものではありませんでした。


© 2016-2020 K5