Help us understand the problem. What is going on with this article?

Lernaを使ってFirebase環境のためのモノレポ環境一式をカッコよく構築する

Firebaseで必要な環境設定をまとめていたら思ったより長くなってしまいました。
ClientもServerもtypescriptで書いたり共通のlint設定などで管理する場合モノレポ形式でやると良いです。
ビルド環境からテスト環境、CIの環境まで解説します。

完成後のレポジトリはこちらです:https://github.com/kyasbal-1994/firebase-monorepo

イケてるモノレポ環境構築

Node.jsでサーバーサイドを書いたり、複数のクライアントプロジェクトがあると以下のような問題に直面すると思います。

  • 共用できるコードなのに別々に書いてる
  • 一度持ってきた共用コードなのに古くなってる
  • 同じようなプロジェクトなのにデプロイの設定とかいろいろがオリジナリティ溢れる
  • チーム内で分担によって知らないコードが増えてしまう

このような問題を解決すべく、Lernaを用いたプロジェクトを作成します。

image.png

先ほどのような実例としての問題だけではなく、これによって作れる複数レポジトリを一つにまとめて全体を管理するやり方では以下のような欠点、利点があるとされます。

Pros
パッケージ間が密に連携していることによる問題を解決できる
どちらのリポジトリにIssue登録するか問題
1つ問題を修正するのに、複数リポジトリを修正してそれぞれ別にプルリク送らないといけない問題
packageをまたいだテストを行いやすくなる
各パッケージで共通のnpm moduleはトップレベルのpackage.jsonおよびnode_modulesで管理でき、ストレージ節約になる
バージョン番号を統一できる
git submoduleで管理するのと比べるとgit submodule updateを意識する必要がなくなるので環境構築が楽になる(個人差ありそう)
Cons
リポジトリがでかくなる
エディタの動作が重くなるかもしれない
lernaの学習コストがかかる
lernaの扱いが面倒な部分がある
lernaを使ってnpmプロジェクトをモノレポ化するより引用

今回作ってみるプロジェクト構成

  • クライアントサイド
    • webpack
    • Typescript
    • babel
    • React.js
  • サーバーサイド
    • Node.js
    • Firebase function

さらにそれぞれstaging環境とproduction環境を分けた実践的なプロジェクト構成を作ってみるものとし、それぞれCircleCIを用いてデプロイされるような環境までを作成します。

プロジェクト構造作成編

モノレポの作成 / サブプロジェクトの作成

$ mkdir firebase-monorepo
$ cd firebase-monorepo
$ npm install lerna --save-dev
$ npx lerna init

最後のlerna initをすると以下のようなログが出力され、monorepoを構成する複数のプロジェクトが入るpackagesフォルダが生成されます。

lerna notice cli v3.4.3
lerna info Initializing Git repository
lerna info Creating package.json
lerna info Creating lerna.json
lerna info Creating packages directory
lerna success Initialized Lerna files

さらに、いい感じにgitignoreも作っておきます。lernaでは各サブプロジェクトにnode_modulesフォルダができるのでこれらもignoreしておきます。

$ gibo dump Node MacOS Windows Linux > .gitignore
$ echo "**/node_modules" >> .gitignore
$ echo "**/lib" >> .gitignore
$ echo "**/dist" >> .gitignore

以下のようにlerna createを用いてサブプロジェクトを作成します。createの後ろにつくプロジェクト名は必ずしも@XXXによって先行する必要はありませんが、統一のために共通のパッケージの名前空間(@XXXの部分のこと)を持つと良いでしょう。

$ lerna create @firebase-monorepo/server

すると以下のようにいろいろ聞かれ(npm initと基本は同じはずです)、サブプロジェクトがpackages/serverとして生成されます。

package name: (@firebase-monorepo/server)
version: (0.0.0)
description:
keywords:
homepage:
license: (ISC)
entry point: (lib/server.js) lib/index.js
git repository:

今後の進行のためentrypointlib/index.jsを指定します。
また、package.jsontypingsという属性を追加しlib/index.d.tsを指定します。

他のサブプロジェクトも作っておきましょう。作るサブプロジェクトは今作った@firebase-monorepo/serverを含めて以下の3つです。

  • @firebase-monorepo/server ・・・ Firebase functionにpublishされるサーバ本体
  • @firebase-monorepo/client ・・・ Firebase hostingにpublishされるクライアント
  • @firebase-monorepo/api-schema・・・クライアントとサーバが共有するAPIのスキーマ
$ lerna create @firebase-monorepo/client
$ lerna create @firebase-monorepo/api-schema

プロジェクトの参照関係を追加する

プロジェクト名を見るとわかる通り、api-schemaserverclientの両方から参照されています。
以下のようにしてserver,clientからapi-schemaへの参照関係を作ります。

$ lerna add @firebase-monorepo/api-schema --scope @firebase-monorepo/server
$ lerna add @firebase-monorepo/api-schema --scope @firebase-monorepo/client

各サブプロジェクトの中のnode_modulesがエイリアスを貼ってくれたのがわかると思います。

ビルド環境作成編

Typescriptの環境をセットアップする

今回のプロジェクトではtypescriptはどのプロジェクトでも参照します。ルート階層でlerna addを用いて外部のプロジェクトも追加することができます。

$ lerna add typescript

ルート階層に以下のような内容のtsconfig.jsonを作成します。

{
    "compilerOptions": {
        "module": "commonjs",
        "declaration": true,
        "noImplicitAny": false,
        "removeComments": true,
        "noLib": false,
        "emitDecoratorMetadata": true,
        "experimentalDecorators": true,
        "target": "es6",
        "sourceMap": true,
        "lib": [
            "es6"
        ]
    },
    "exclude": [
        "node_modules"
    ]
}

これらのtsconfig.jsonを参照するようにpackages/server/tsconfig.json,packages/client/tsconfig.json,packages/api-schema/tsconfig.jsonの3つを同じように以下のように作ります。

{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "outDir":"./lib",
    "declarationDir": "./lib",
    "rootDir": "./src",
    "baseUrl": "./"
  },
  "include": ["./src"]
}

サブプロジェクトそれぞれのpackage.jsonにbuildコマンドを追加します。

"scripts":{
  "build":"tsc"
}

packages/api-schema/src/index.tsに以下のように記述しましょう。

export class CounterResult{
  public count:number;
}

lerna run buildコマンドでプロジェクトそれぞれでビルドを走らせます。

$ lerna run build

他にファイルがないのでエラーが一部出ると思いますが、これでそれぞれのプロジェクトにnpm run buildしているのと同じ状況になります。

試しにserver.tsでこのファイルを参照してみましょう。

import {CounterResult} from "@firebase-monorepo/api-schema";

CounterResult.name

しっかりとTypescriptの型が効いていると思います。

Prettierを設定する

まずはビルド環境よりlint環境です。コードを書き始めてからでは遅いので先に設定します。
全プロジェクトで同じコーディング規約を定めることとしましょう。

lerna add prettier --devをしてPrettierを追加します。

$ lerna add prettier --dev

ルートディレクトリに.prettierrc.jsonを設置して、各プロジェクトに.prettierignoreを設置します。

.prettierignore.json

**/package-lock.json
*.scss.d.ts
**/lib/
**/node_modules/

.prettierrc.json

{
  "semi": false,
  "singleQuote": true
}

各プロジェクトのpackage.jsonのscriptsにlintを追加します。

"scripts":{
   "lint":"lint": "prettier --list-different './src/**/*.{js,ts,tsx,scss,json}'",
   "lint:format": "prettier --write './src/**/*.{js,ts,tsx,scss,json}'"
}

同様にしてlerna run lint:formatなどで動作をチェックすることが可能です。

Huskeyでgitコマンドのフックをしてlint-stagedでformatを強制する

$ npm install huskey --dev
$ lerna add lint-staged

huskeyだけはルートにだけ存在すればいいのでnpm installをする。

huskeyでprecommitをフックしてコミット前にlintしてstageすることを強制する。
ルートのpackage.jsonに以下のような設定を追加する。

  "husky": {
    "hooks": {
      "pre-commit": "lerna exec npx lint-staged"
    }
  }

これにより、pre-commit時に全てのサブプロジェクトでlint-stagedが実行される。これによってgitのステージングに上がっているファイルにprettierをかけることができる。

各プロジェクトのpackage.jsonには以下のようにlint-stagedの設定を置く。


  "lint-staged": {
    "./src/**/*.{js,ts,tsx,scss,json}": [
      "npm run lint:format",
      "git add"
    ]
  }

クライアント側のビルドの設定をする

必要そうなmoduleをどんどん入れます。lernaだと複数個を一気に追加できないので今回だけclientのフォルダに入ります。まずはバンドリング周りの設定から。今回はwebpackでreactの環境をビルドすることを想定します。

$ cd ./packages/client
$ npm i webpack webpack-cli webpack-dev-server  webpack-merge html-webpack-plugin \
 mini-css-extract-plugin uglifyjs-webpack-plugin \
 optimize-css-assets-webpack-plugin babel-loader typings-for-css-modules-loader \
 sass-loader postcss-loader style-loader \
 @babel/core @babel/plugin-proposal-class-properties \
 @babel/preset-typescript @babel/plugin-proposal-decorators @babel/polyfill \
 @babel/preset-env @babel/preset-react cpx --save-dev

各moduleの説明

  • webpack,webpack-cli : webpackを使うために最小限必要なもの
  • webpack-dev-server : 開発用のサーバを開いてくれるやつ
  • webpack-merge : webpackのコンフィグをマージするやつ
  • webpackプラグインたち
    • html-webpack-plugin : ビルド時に同時にhtmlを生成するプラグイン
    • mini-css-extract-plugin : 一つのcssにまとめ直してくれるプラグイン
    • uglifyjs-webpack-plugin : jsのminifyのプラグイン
    • optimize-css-assets-webpack-plugin : CSSの最適化を行なってくれるプラグイン
  • webpackのローダーたち
    • babel-loader : Babelを通すためのローダ。今回はtsのコンパイルにも用いる。
    • typings-for-css-modules-loader : CSS modulesの型定義を生成するローダ
    • scss-loader : SCSSのコンパイルをするローダ
    • postcss-loader : CSSのAutoprefixをするため
    • style-loader : デバッグ時にスタイルを埋め込むためのローダ
  • Babel系
    • @babel/core : Babel本体
    • babel-preset-env : Babelの基本的なプリセットを選んでくれるやつ
    • @babel/preset-typescript : Typescriptのビルドを行なってくれるプリセット
    • @babel/preset-react : Reactのjsx記法のビルドを行なってくれるプリセット
    • @babel/polyfill : 古いブラウザサポート用のpolyfill
    • @babel/plugin-proposal-decorators : デコレータ記法を使えるようにするためのplugin
    • @babel/plugin-proposal-class-properties : React周りでよく見るやつ。staticメンバを追加できるようになる
  • その他
    • cpx : コピーに使う。あとで出てきます。

これらをインストールした上で以下の3つのwebpack.config.jsによって成り立たせます。

  • webpack.config.js
  • webpack.dev.config.js
  • webpack.prod.config.js

packages/client/webpack.config.js

const path = require("path");
const webpack = require("webpack");
const HtmlPlugin = require("html-webpack-plugin");

module.exports = {
  entry: {
    index: ["@babel/polyfill", "./src/index.tsx"]
  },
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "[name].[hash].js"
  },
  plugins: [
    new HtmlPlugin({
      filename: "index.html",
      template: "template/index.html",
      minify: {
        removeComments: true,
        collapseWhitespace: true
      }
    })
  ],
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: [
          {
            loader: "babel-loader"
          }
        ]
      }
    ]
  },
  resolve: {
    extensions: [".js", ".json", ".ts", ".tsx"]
  }
};

webpack.dev.config.js

const webpack = require("webpack");
const common = require("./webpack.config");
const merge = require("webpack-merge");
const devSpecific = {
  mode: "development",
  devtool: "eval-source-map",
  plugins: [
    new webpack.DefinePlugin({
      "process.env.NODE_ENV": JSON.stringify("development")
    })
  ],
  module: {
    rules: [
      {
        test: /\.s?css$/,
        use: [
          {
            loader: "style-loader"
          },
          {
            loader: "typings-for-css-modules-loader",
            options: {
              modules: true,
              sass: true,
              namedExport: true,
              camelCase: true,
              localIdentName: '[path][name]__[local]--[hash:base64:5]'
            }
          },
          {
            loader: "sass-loader"
          }
        ]
      }
    ]
  }
};
module.exports = merge(common, devSpecific)

webpack.prod.config.js

const webpack = require("webpack");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const common = require("./webpack.config");
const merge = require("webpack-merge");
const UglifyJsPlugin = require("uglifyjs-webpack-plugin");
const OptimizeCssAssetsPlugin = require("optimize-css-assets-webpack-plugin");

const prodSpecific = {
  mode: "production",
  devtool: "source-map",
  optimization: {
    minimizer: [
      new UglifyJsPlugin({
        cache: true,
        parallel: true,
        sourceMap: false,
        extractComments: true
      }),
      new OptimizeCssAssetsPlugin()
    ]
  },
    plugins: [
    new webpack.DefinePlugin({
      "process.env.NODE_ENV": JSON.stringify("production")
    }),
    new MiniCssExtractPlugin(),
    new webpack.optimize.OccurrenceOrderPlugin()
  ],
  module: {
    rules: [
      {
        test: /\.s?css$/,
        use: [
          {
            loader: MiniCssExtractPlugin.loader
          },
          {
            loader: "typings-for-css-modules-loader",
            options: {
              modules: true,
              sass: true,
              namedExport: true,
              camelCase: true,
              localIdentName: '[hash:base64:4]'
            }
          },
          {
            loader:"postcss-loader"
          },
          {
            loader: "sass-loader"
          }
        ]
      }
    ]
  }
};
module.exports = merge(common, prodSpecific);

注釈

  • entry.outputをfilename: "[name].[hash].js"のように書いておくとjsにhashが追加された形で生成されるためにキャッシュされなくなります

このままだとstaticなファイルの置き場所に困るのでpackages/client/staticを作成します。その上でビルド後にstaticのファイルをdistにコピーされるように設定するため、cpxを入れます。

htmlのテンプレートとしてpackages/client/templates/index.htmlを作成します。内容は以下のようなもので、ビルドされたjsへの参照は自動的に追加されるようになっています。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>ものれぽ</title>
</head>

<body>
    <div id="app"></div>
</body>

</html>
lerna add cpx --scope @firebase-monorepo/client

client側のpackage.jsonにビルド、デバッグ用のscriptsを追加します。

"build": "webpack --config ./webpack.prod.config.js && cpx 'static/**/*' dist --verbose",
"build:dev": "webpack --config ./webpack.dev.config.js && cpx 'static/**/*' dist --verbose",
"start": "webpack-dev-server --watch --config ./webpack.dev.config.js --content-base static/"

さて、ここでlerna run buildでクライアントも一緒にビルドされる様子をみたいところなのですが、先ほどnpm installで一気にインストールしてしまったのでこのままだとサブプロジェクトへの参照がうまく機能しません。

lerna clean
lerna bootstrap

を行うとlerna run buildでクライアントがビルドできるようになります。

サーバー側のビルド環境を設定する

Firebase functionでサーバーを立てる場合、Lernaと一緒に使うには少々トリックが必要のようです。
その理由は

  • デプロイされるとnpm installを自動で行うので、Lernaがサブプロジェクトをdependenciesに含めた結果シンボリックリンクが動かず、結局動作しない
  • シンプルに全体をwebpackでバンドリングしてもトップにexportsが必要なのでうまく動作しない(?)

なんにせよサブプロジェクトの部分を含むべくサーバー側もwebpackに通してあげれば動作するはずです。

webpack.config.js

const path = require("path");
const webpack = require("webpack");

module.exports = {
  target: 'node',
  entry: {
    api: './src/api/api.ts'
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name]/[name].js',
    libraryTarget: 'commonjs2'
  },
  module: {
    rules: [
      {
        test: /\.ts$/,
        use: [
          {
            loader: 'babel-loader'
          }
        ]
      }
    ]
  },
  resolve: {
    extensions: ['.js', '.json', '.ts']
  }
}

webpackの設定はこんな感じです。targetnodeになっていること、output.libraryTargetcommonjs2になっていることを確認してください。

.babelrcは以下のようにします。Typescriptの設定しかしていません。

{
  "presets": [
    "@babel/preset-typescript"
  ]
}

src/api/api.tsを作成して以下のような内容を含めます。

import * as express from 'express'
import { CounterResult } from '@firebase-monorepo/api-schema'
const app = express()
const result = new CounterResult()
app.get("/",(req,res)=>{
  res.send(""+result.count);
});
export default app;

src/index.jsも作成し以下のような内容を含めます。

const functions = require('firebase-functions')
const APIRouter = require("./api/api").default;
module.exports = {
    api:functions.https.onRequest(APIRouter)
}

さらにデプロイ時に含めるためのsrc/package.jsonも作成してこのようにdependencyがほとんどないpackage.jsonを作ります。

{
    "name": "release",
    "scripts": {
    },
    "dependencies": {
        "firebase-admin": "^6.2.0",
        "firebase-functions": "^2.1.0"
    },
    "devDependencies": {
    }
}

こうした上で以下のようなビルドコマンドを書いてdistのサブフォルダにバンドリングされた結果が入っていくようにします。

    "build": "webpack && npm run build:copy_entry",
    "build:copy_entry": "cp ./src/*.* ./dist",

サーバーサイドをバンドリングするというのはなかなか奇妙な構成ですが、サブプロジェクトの動作もしっかりしますし、型も十分に動作します。サーバー側のエラーもソースマップを用いることができる構成です。

デプロイ環境作成編

Firebaseにデプロイできるようにします。今回はstagin環境production環境の2つを用意することにしましょう。
ここではFirebaseのコンソールから、ステージング用のプロジェクトと本番環境用のプロジェクトの二つを以下のように用意しました。

  • qiita-firebase-monorepo(本番環境)
  • qiita-firebase-monorepo-stg(ステージング環境)

同じ名前は取れないと思うので、実際に試す方は自分で好きな名前を入れて環境を作成しましょう。

ルートフォルダにてnpm install firebase-tools --save-devを実行してfirebase toolsを入れましょう。

ルートのpackage.jsonに以下のように追加します。

    "deploy": "lerna run build && npx firebase deploy"

.firebasercを設定する

ルートフォルダ直下に以下のような.firebasercを入れます。こうすると、firebase use stagingとするとデプロイ先がstagingになり、firebase use productionとするとデプロイ先が本番環境になります。

{
  "projects": {
    "production": "qiita-firebase-monorepo",
    "staging": "qiita-firebase-monorepo-stg"
  }
}

firebase.jsonを作成する

ルートフォルダ直下に以下のようなfirebase.jsonを含めます。`

{
    "hosting": {
        "public": "./packages/client/dist",
        "ignore":[
            "**/.*"
        ]
    },
    "functions": {
        "source": "./packages/server/dist"
    }
}

それぞれdistの中身がpublishされることに注意が必要です。さらにfunctionsの方はdistの中のpackage.jsonのdependencyはデプロイ時にリモート側でインストールされます。必要があればここに外部参照を記述して、webpackのビルドの時に一部のrequireだけ残すのも良いでしょう。
(本当はサブプロジェクトだけバンドリングできたら一番いいのだけれど)

テスト環境作成編

jestを導入する

jestを用いてテスト環境を構築してみましょう。
ルートフォルダで以下のようなコマンドを実行します。

$ lerna add jest --dev
$ lerna add ts-jest-babel-7 --dev
$ lerna add @types/jest --dev

次にサブプロジェクトのそれぞれのpackage.jsonに以下のような項目を追加します。

  "jest": {
    "transform": {
      "^.+\\.tsx?$": "ts-jest-babel-7"
    },
    "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
    "moduleFileExtensions": [
      "ts",
      "tsx",
      "js",
      "jsx",
      "json",
      "node"
    ]
  }

さらにpackage.jsonのscriptsのtestコマンドをjestにします。

    "test": "jest"

試しにserverのサブプロジェクトにテストを追加します。src/__test__を作成して、api.test.tsを追加します。

import server from "./../api"
import { CounterResult } from "@firebase-monorepo/api-schema";
describe('hello', () => {
  it('hello("jest") to be "Hello Jest!!"', () => {
    expect('Hello Jest!!').toBe('Hello Jest!!')
  })
  it('Requireing sub project should work', () => {
    expect(CounterResult).not.toBe(null)
  })
})

lerna run testをルートフォルダで実行すればテストが全サブプロジェクトで動作することが確認できます。

コードカバレッジを測定する

各プロジェクトにtest:coverageを以下のように追加します。jestはこれだけでカバレッジ出してくれます。神

    "test:coverage": "jest --coverage",

CI/CD環境作成編

CircleCIを用いていい感じにCI/CD環境を作成します。手動デプロイはどのバージョンがデプロイされているかわからなくなったりしますし、ビルドするの忘れてデプロイしてしまったりします。

.circleci/config.ymlを作成します。

依存関係のインストール+ビルド+テスト

circleciのキャッシュの設定が少々複雑です。というのも、それぞれのサブパッケージ単位でのインストールされたモジュールのキャッシュ化、ビルド済みファイルの同一リビジョンのjob間でのキャッシュの利用のためです。
とりあえずは以下のようなconfig.ymlにします。

# Javascript Node CircleCI 2.0 configuration file
#
# Check https://circleci.com/docs/2.0/language-javascript/ for more details
#
version: 2.1
executors:
  node:
    docker: 
      - image: circleci/node:8
commands:
  restore_built:
    description: "Restore the build result from cache"
    steps:
      - restore_cache:
          key: built-api-schema-{{ .Revision }}
      - restore_cache:
          key: built-client-{{ .Revision }}
      - restore_cache:
          key: built-server-{{ .Revision }}
  restore_dependency:
    description: "Restore the dependencies result from cache"
    steps:
      - restore_cache:
          keys:
            - dependencies-root-{{ checksum "package.json" }}
            - dependencies-root-
      - restore_cache:
          keys:
            - dependencies-api-schema-{{ checksum "packages/api-schema/package.json" }}
            - dependencies-api-schema-
      - restore_cache:
          keys:
            - dependencies-client-{{ checksum "packages/client/package.json" }}
            - dependencies-client-
      - restore_cache:
          keys:
            - dependencies-server-{{ checksum "packages/server/package.json" }}
            - dependencies-server-    
jobs:
  install_dependency:
    executor: node
    steps:
      - checkout
      - restore_dependency
      - run:
          name: ルートの依存関係のインストール
          command: npm install
      - run:
          name: サブプロジェクトの依存関係のインストール
          command: npx lerna bootstrap
      - run:
          name: package.jsonの避難
          command: cp ./package.json ./package.copy.json
      - run:
          name: package.jsonの避難
          command: cp ./packages/api-schema/package.json ./packages/api-schema/package.copy.json
      - run:
          name: package.jsonの避難
          command: cp ./packages/client/package.json ./packages/client/package.copy.json
      - run:
          name: package.jsonの避難
          command: cp ./packages/server/package.json ./packages/server/package.copy.json
      - save_cache:
          paths:
            - ./node_modules/
          key: dependencies-root-{{ checksum "package.copy.json" }}
      - save_cache:
          paths:
            - ./packages/api-schema/node_modules/
          key: dependencies-api-schema-{{ checksum "packages/api-schema/package.copy.json }}
      - save_cache:
          paths:
            - ./packages/client/node_modules/
          key: dependencies-client-{{ checksum "packages/client/package.copy.json" }}
      - save_cache:
          paths:
            - ./packages/server/node_modules/
          key: dependencies-server-{{ checksum "packages/server/package.copy.json" }}
  build:
    executor: node
    steps:
      - checkout
      - restore_dependency
      - run:
          name: ビルド
          command: npx lerna run build
      - save_cache:
          paths:
            - ./packages/api-schema/lib/
          key: built-api-schema-{{ .Revision }}
      - save_cache:
          paths:
            - ./packages/client/dist/
          key: built-client-{{ .Revision }}
      - save_cache:
          paths:
            - ./packages/server/dist/
          key: built-server-{{ .Revision }}
  test:
    executor: node
    steps:
      - checkout
      - restore_dependency
      - restore_built
      - run:
          name: テスト
          command: npx lerna run test:coverage
workflows:
  main:
    jobs:
      - install_dependency
      - build:
          requires:
            - install_dependency
      - test:
          requires:
            - build

キャッシュの復元部分は今後書いていく他のjobでも共通なので、commandsに書いておくと便利です。
npm installpackage.jsonを変えてしまう可能性があるので、依存関係をインストールする前にインストール後にハッシュが計算ができるように避難をします。

デプロイの設定をする

ビルドができているのでステージング環境に自動でデプロイされるようにします。

jobsに以下のコマンドを追加します。

  deploy_staging:
    executor: node
    steps:
      - checkout
      - restore_dependency
      - restore_built
      - run:
          name: デプロイ先の切り替え(Staging)
          command: npx firebase use staging
      - run:
          name: デプロイ
          command: npx firebase deploy
  pre_deploy_production:
    executor: node
    steps:
      - run:
          name: send notification to slack
          command: >
              curl -X POST -H 'Content-type: application/json' 
              --data "{\"text\": \"本番環境へのデプロイにはManual Approvalが必要です。https://XXXXK を確認の上、本番環境へデプロイしていい場合は次のアドレスから承認してください。<https://circleci.com/workflow-run/${CIRCLE_WORKFLOW_ID}|here>.\"}"
              $WEBHOOK_URL
  deploy_production:
    executor: node
    steps:
      - checkout
      - restore_dependency
      - restore_built
      - run:
          name: デプロイ先の切り替え(Production)
          command: npx firebase use production
      - run:
          name: デプロイ
          command: npx firebase deploy

workflow全体は以下のようになります。

workflows:
  main:
    jobs:
      - install_dependency
      - build:
          requires:
            - install_dependency
      - test:
          requires:
            - build
      - deploy_staging:
          requires:
            - test
          filters:
            branches:
              only: 
                - staging
                - master
      - pre_deploy_production:
          requires:
            - deploy_staging
          filters:
            branches:
              only: 
                - master
      - approve_deploy_production:
          type: approval
          requires:
            - pre_deploy_production
          filters:
            branches:
              only: 
                - master
      - deploy_production:
          requires:
            - approve_deploy_production
          filters:
            branches:
              only: master

この設定ファイルで行われるのは以下の通りです。

  • stagingブランチもしくはmasterブランチの場合、stagingの方のfirebaseプロジェクトとしてpublishされます。
  • masterだった場合、さらにslackなどに通知するjobを通った後で、productionにデプロイする手前でmanual approveを要求します。
  • CircleCI上でデプロイにOKをしたら勝手にデプロイされます。

デプロイ時にタグをつける

デプロイされているのがどのバージョンかわかるようにするためにタグをつけることにしましょう。

      - run:
          name: 'プロダクションリリースへのタグ付け'
          command: 'git tag production/rev-${CIRCLE_BUILD_NUM}'
      - run:
          name: 'タグのpush'
          command: 'git push origin --tags'

のようなコードをデプロイに含めるだけでCircleCIからgit上でタグづけされます。非常に便利。

有用な外部サービスと連携する

Codecovとの連携

https://codecov.io/

カバレッジが集計されているのでこれを可視化しましょう。ルートでnpm i codecov --save-devをします。
testジョブを以下のように書き換えるとcodecovにカバレッジが表示されるようになります。

  test:
    executor: node
    steps:
      - checkout
      - restore_dependency
      - restore_built
      - run:
          name: テスト
          command: npx lerna run test:coverage
      - run:
          name: Codecovに送る
          command: npx codecov

CircleCIの管理画面でCODECOV_TOKEN環境変数を登録する必要があります。

Cloudflareのキャッシュをパージする

Firebasehostingのドメインを独自ドメインにしている場合などにCloudflareを用いていると、デプロイした場合もCloudflareのキャッシュのせいでクライアントでリロードしても更新されない場合があります。このような場合にはデプロイの最後にキャッシュのパージをするAPIを叩く処理を入れれば大丈夫です。

      - run:
          name: 'CloudflareのキャッシュをPurge'
          command: 'curl -X POST "https://api.cloudflare.com/client/v4/zones/$CLOUDFLARE_ZONEID/purge_cache"\
 -H "X-Auth-Email: $CLOUDFLARE_AUTHMAIL"  -H "X-Auth-Key: $CLOUDFLARE_AUTHKEY" -H "Content-Type: \
application/json" --data "{\"purge_everything\":true}"'

CLOUDFLARE_ZONEID、CLOUDFLARE_AUTHMAIL CLOUDFLARE_AUTHKEYの環境変数を設定する必要があります。

CIの設定ファイルの振り返り

# Javascript Node CircleCI 2.0 configuration file
#
# Check https://circleci.com/docs/2.0/language-javascript/ for more details
#
version: 2.1
executors:
  node:
    docker: 
      - image: circleci/node:8
commands:
  restore_built:
    description: "Restore the build result from cache"
    steps:
      - restore_cache:
          key: built-api-schema-{{ .Revision }}
      - restore_cache:
          key: built-client-{{ .Revision }}
      - restore_cache:
          key: built-server-{{ .Revision }}
  restore_dependency:
    description: "Restore the dependencies result from cache"
    steps:
      - restore_cache:
          keys:
            - dependencies-root-{{ checksum "package.json" }}
            - dependencies-root-
      - restore_cache:
          keys:
            - dependencies-api-schema-{{ checksum "packages/api-schema/package.json" }}
            - dependencies-api-schema-
      - restore_cache:
          keys:
            - dependencies-client-{{ checksum "packages/client/package.json" }}
            - dependencies-client-
      - restore_cache:
          keys:
            - dependencies-server-{{ checksum "packages/server/package.json" }}
            - dependencies-server-    
jobs:
  install_dependency:
    executor: node
    steps:
      - checkout
      - restore_dependency
      - run:
          name: ルートの依存関係のインストール
          command: npm install
      - run:
          name: サブプロジェクトの依存関係のインストール
          command: npx lerna bootstrap
      - run:
          name: package.jsonの避難
          command: cp ./package.json ./package.copy.json
      - run:
          name: package.jsonの避難
          command: cp ./packages/api-schema/package.json ./packages/api-schema/package.copy.json
      - run:
          name: package.jsonの避難
          command: cp ./packages/client/package.json ./packages/client/package.copy.json
      - run:
          name: package.jsonの避難
          command: cp ./packages/server/package.json ./packages/server/package.copy.json
      - save_cache:
          paths:
            - ./node_modules/
          key: dependencies-root-{{ checksum "package.copy.json" }}
      - save_cache:
          paths:
            - ./packages/api-schema/node_modules/
          key: dependencies-api-schema-{{ checksum "packages/api-schema/package.copy.json" }}
      - save_cache:
          paths:
            - ./packages/client/node_modules/
          key: dependencies-client-{{ checksum "packages/client/package.copy.json" }}
      - save_cache:
          paths:
            - ./packages/server/node_modules/
          key: dependencies-server-{{ checksum "packages/server/package.copy.json" }}
  build:
    executor: node
    steps:
      - checkout
      - restore_dependency
      - run:
          name: ビルド
          command: npx lerna run build
      - save_cache:
          paths:
            - ./packages/api-schema/lib/
          key: built-api-schema-{{ .Revision }}
      - save_cache:
          paths:
            - ./packages/client/dist/
          key: built-client-{{ .Revision }}
      - save_cache:
          paths:
            - ./packages/server/dist/
          key: built-server-{{ .Revision }}
  test:
    executor: node
    steps:
      - checkout
      - restore_dependency
      - restore_built
      - run:
          name: テスト
          command: npx lerna run test:coverage
      - run:
          name: Codecovに送る
          command: npx codecov

  deploy_staging:
    executor: node
    steps:
      - checkout
      - restore_dependency
      - restore_built
      - run:
          name: デプロイ先の切り替え(Staging)
          command: npx firebase use staging
      - run:
          name: デプロイ
          command: npx firebase deploy --token "$FIREBASE_TOKEN"
      - run:
          name: 'プロダクションリリースへのタグ付け'
          command: 'git tag staging/rev-${CIRCLE_BUILD_NUM}'
      - run:
          name: 'タグのpush'
          command: 'git push origin --tags'
  pre_deploy_production:
    executor: node
    steps:
      - run:
          name: send notification to slack
          command: >
              curl -X POST -H 'Content-type: application/json' 
              --data "{\"text\": \"本番環境へのデプロイにはManual Approvalが必要です。http://XXXX を確認の上、本番環境へデプロイしていい場合は次のアドレスから承認してください。<https://circleci.com/workflow-run/${CIRCLE_WORKFLOW_ID}|here>.\"}"
              $WEBHOOK_URL
  deploy_production:
    executor: node
    steps:
      - checkout
      - restore_dependency
      - restore_built
      - run:
          name: デプロイ先の切り替え(Production)
          command: npx firebase use production
      - run:
          name: デプロイ
          command: npx firebase deploy --token "$FIREBASE_TOKEN"
      - run:
          name: 'プロダクションリリースへのタグ付け'
          command: 'git tag production/rev-${CIRCLE_BUILD_NUM}'
      - run:
          name: 'タグのpush'
          command: 'git push origin --tags'
workflows:
  main:
    jobs:
      - install_dependency
      - build:
          requires:
            - install_dependency
      - test:
          requires:
            - build
      - deploy_staging:
          requires:
            - test
          filters:
            branches:
              only: 
                - staging
                - master
      - pre_deploy_production:
          requires:
            - deploy_staging
          filters:
            branches:
              only: 
                - master
      - approve_deploy_production:
          type: approval
          requires:
            - pre_deploy_production
          filters:
            branches:
              only: 
                - master
      - deploy_production:
          requires:
            - approve_deploy_production
          filters:
            branches:
              only: master

全体としてこのような設定ファイルになっていれば大丈夫です。これは最低限度のCIの設定で、理想的にはE2Eのテスト環境を導入したかったり、Sentryなどのエラー管理ツールとの連携もする必要があるでしょう。

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away