モチベーション
最近Cloud Functions for Firebase
の開発を少しやってみて色々右往左往してたので、初心者なりにフローをまとめてみました。
フロント側を最近Vueを使った開発を行っており、極力現在のフロント環境(es6, webpackによるバンドル化など
)と合わせる構成にしています。
ゴール
firebase functinonsの開発/運用するために以下の点をできるようにする
- デバッグ(ローカル上での実行およびホットリロード)
- テスト
- デプロイ
Cloud Functions for Firebaseとは
GCPサービスとして提供されているCloud Functionsを簡単にfirebaseの各種サービスと連携して使える、いわゆるFaasサービスです。詳しく書くとにわかがばれそうですが、公式リファレンスをみることをおすすめします。
作成したもの
こちらにサンプルをおいています。これを通して説明をしていきます。
また、firebaseの設定に関しては深くはふれません。今回は.firebaserc
に予め開発用プロジェクトIDを設定し、firebase login
も済んでいる状態とします。
また以下のツール、フレームワークを利用しました。
ディレクトリ構成
今回は下記のような構成にしました。
- プロジェクトルートディレクトリにpackge.jsonなどの設定ファイル
-
src/*
配下に開発用のソースコード -
test/*
配下にテストコード
︙
├── firebase.json
├── node_modules
├── package.json
├── src
│ └── main.js
├── tests
│ └── main.test.js
├── babel.config.js
└── webpack.config.js
しかし、デフォルトはfunctions
ディレクトリ以下にfirebase.json
とpackage.json
とエントリーポイントなjs(例はindex.js
)が存在することが求められており、今回はwebpackで無理やり解決しました。
├── functions
│ ├── firebase.json
│ ├── package.json
│ ├── node_modules
│ └── index.js
├── package.json
├── babel.config.js
└── webpack.config.js
ビルド設定
const nodeExternals = require('webpack-node-externals');
const GenerateJsonPlugin = require('generate-json-webpack-plugin')
const env = process.env.NODE_ENV;
const dist = "functions";
const distDir =`${__dirname}/${dist}`;
const mode = env && env == "prod" ? "production" : "development";
module.exports = {
mode: mode,
target: 'node',
entry: ["./src/main.js"],
output: {
filename: "index.js",
path: distDir
},
plugins: [
//firebaseは各種jsonをfunctions内に入れないといけないのでコピー
new GenerateJsonPlugin('package.json', require('./package')),
new GenerateJsonPlugin('firebase.json', require('./firebase'))
],
watchOptions: {
poll: 1000
},
externals: [nodeExternals({
whitelist: []
})]
};
基本的にはHow to build firebase function by webpackを参考にしました。多分以下の点が肝かなと思います。
-
generate-json-webpack-plugin を使って、functions内にpackage.jsonとfirebase.jsonを無理やり一緒にバンドルさせる。
-
watchOptions.poll
の指定- macとLinuxのファイルシステムの違いでデフォルトでウォッチが機能しない。(参考: Docker内で Webpack の watch でファイルの変更を検知する )
アプリケーションコード
アプリケーションコード。
hello world
を返すだけのシンプルなものです。
expressを使った書き方に関してはCloud Functions for Firebaseでexpressを使うの記事を参考にさせていただきました。
import express from "express"
import cors from "cors";
import * as functions from "firebase-functions"
import * as bodyParser from "body-parser";
const app = express();
app.use(cors({origin: true}));
app.get('/', bodyParser.urlencoded({extended: false}), (req, res) => {
return res.send("hello world");
});
exports.sample = functions.https.onRequest(app);
クロスオリジン設定は今後の開発で私が必要なので設定を追加しているだけなので、不要なら削除してください。
app.use(cors({origin: true}));
なお、今回はfunctions名をsample
としてします。export.{functions名}
で分けられるようですね。
exports.sample = functions.https.onRequest(app);
テストコード
import supertest from 'supertest'
import functions from "../src/main"
const request = supertest(functions.sample);
jest.mock('firebase-admin', () => ({
initializeApp: jest.fn()
}));
describe('hello world sample', () => {
it('successfully invokes function', async () => {
let actual = await request.get('/');
let {ok, status, body, text} = actual;
expect(ok).toBe(true);
expect(status).toBeGreaterThanOrEqual(200);
expect(body).toBeDefined();
expect(text).toEqual("hello world");
});
});
今回はユニットテストの体なのでいわゆるオフラインテストになります。firebaseにアクセスはしませんので、認証系は握り潰す必要があるので握り潰しました。
jest.mock('firebase-admin', () => ({
initializeApp: jest.fn()
}));
また、本来は公式テストフレームワークのfirebase-functions-testを使いたかったのですが、expressを使ったエンドポイントがうまく機能してくれなかったのでこちらを参考にHttpテストライブラリのsupertestを使いました。
describe('hello world sample', () => {
it('successfully invokes function', async () => {
let actual = await request.get('/');
let {ok, status, body, text} = actual;
expect(ok).toBe(true);
expect(status).toBeGreaterThanOrEqual(200);
expect(body).toBeDefined();
expect(text).toEqual("hello world");
});
});
簡単に書けるのいいですね。
es6でテストが書けるようにjest.config.js
に設定を追加させています。
こちらをほぼコピペしています。
module.exports = {
moduleFileExtensions: ["js", "json", "jsx", "ts", "tsx", "json"],
transform: {
'^.+\\.(js|jsx)?$': 'babel-jest'
},
testEnvironment: 'node',
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1'
},
testMatch: [
'<rootDir>/**/*.test.(js|jsx|ts|tsx)', '<rootDir>/(tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx))'
],
transformIgnorePatterns: ['<rootDir>/node_modules/']
};
async/await
と使っているとReferenceError: regeneratorRuntime is not defined
のエラーが発生するので、
babel.config.js
に下記のように現在のnodeのバージョンの指定もしておく事も忘れずに必要です。(参考: async/awaitを使用して、"regeneratorRuntime is not defined "エラーが出た時の対処)
module.exports = {
"presets": [
[
"@babel/preset-env", {
"targets": {
"node": "current"
}
}
]
]
}
実行と結果
実行コマンドはpackage.jsonのscript
に集約させるようにしました。
ゴール設定していた、デバッグ
, テスト
, デプロイ
に関して解説します。
前述しましたが、今回は.firebaserc
に予め開発用プロジェクトIDを設定し、firebase login
も済んでいる状態とします。
"scripts": {
"start": "npm run watch & npm run serve",
"watch": "NODE_ENV=dev webpack -w",
"build": "NODE_ENV=dev webpack",
"release": "NODE_ENV=prod webpack",
"serve": "npm run build && firebase serve -o 0.0.0.0 -p 8080 --only functions",
"deploy": "npm run release && firebase deploy --only functions:sample",
"lint": "eslint src/* tests/*",
"test": "jest"
},
デバッグ
$ npm run start
serve自体をwatchせずに動かす場合もあると思ってるので、watch
とserve
は一応別コマンドで作成しています。
テスト
$ npm run test
deploy
$ npm run deploy
なお今回デプロイするにあたって、firebase deploy --only functions:sample"
とデプロイする関数名を明記しています。理由はデプロイする対象のものは明示的に指定するほうが誤った削除を防ぐ意味で事故防止になって良いかなと考えています。
感想
一通り運用のための最低限のことは最低限できました。模索しながら色々やったのでほぼ1日費やしてしまいましたが、サクッと試す分は5分もかからずできるのでfirebase大好きになりました。
webpack周りで結構ハマったので、es6にこだわず素直にcommonjsで素直に書けば良かったかもですね。そこまでやるならおとなしくtype scriptをやるべきだったなと反省しました。
なお、ほとんどJSでテストコード書いたことがなく、ほぼ今回始めてだったのですがjestは学習コスト低そうで活用していきたいですね。jsのテストフレームワークを調べると色々組み合わせがあるようで辟易してたのですが、jestが色々できそうなのでまずは寄せて書くことから始めてみようとおもいました。
余談ですが、testには本来はfirebase-testを使いたかったのですが、下記のところで怒られており、誰か教えてください。作成した関数に__trigger.labels.deployment-callable
はなかったです。
https://github.com/firebase/firebase-functions-test/blob/master/src/main.ts#L75
if (has(cloudFunction, '__trigger.httpsTrigger') &&
(get(cloudFunction, '__trigger.labels.deployment-callable') !== 'true')) {
throw new Error('Wrap function is only available for `onCall` HTTP functions, not `onRequest`.');
}