Firebaseで運用するKomercoの管理用アプリケーションの開発

こんにちは。Komerco事業部エンジニアの高橋(id:yosuke403)です。「料理が楽しくなるマルシェアプリ」であるKomercoの開発を行っています。

Webサービス開発と聞くとユーザが利用するWebアプリやモバイルアプリの開発を思い浮かべますが、運営スタッフがサービスのデータを閲覧・更新するための管理用アプリケーションの開発も必要になることがほとんどです。

KomercoはバックエンドにFirebaseを活用しているのを一つの特徴としているサービスです。 今回はKomercoの開発事例を通して、Firebaseを用いた管理アプリケーション開発の知見をご紹介したいと思います。

Komercoの管理用アプリケーションについて

KomercoではFirebaseのHostingを利用し、Webで管理用アプリケーション(以下、管理アプリ)を提供しています。

f:id:yosuke403:20190705083445p:plain

Komercoの管理アプリでできることとして、

  • 登録商品の監視
  • 販売許可証の審査
  • コメルコバナシ(アプリ内の記事コンテンツ)や特集(テーマに沿った商品のピックアップ)の更新
  • Push通知

などがあります。

管理アプリの利用者はKomercoのスタッフですが、エンジニア以外のメンバーが触ることが多いです。また、社外の業務委託先にも管理アプリを利用してもらい、一部の業務を依頼しています。

アクセスログを残す

管理アプリからは一般ユーザのプライベートな情報も閲覧できますし、またデータの更新が一般ユーザに影響を与えることもあるので、トラブルに備えて誰が、いつ、どんな操作をしたかを記録する必要があります。

KomercoではデータベースにFirebaseのFirestoreを利用していますが、Firestoreには誰がどんな操作をしたのかをログ出力する機能がありません。 そのため面倒ではありますが、Webフロントエンドから直接Firestoreへはアクセスさせず、Cloud Functionsを経由して読み込み、書き込みを行うようにしています。

Cloud Functionsはイベント駆動でアプリケーションを実行できるFirebase(GCP)の機能です。イベントトリガーにはいろいろな種類がありますが、 functions.https.onCallをトリガーとしてセットした場合、渡されてくるCallableContextのデータから簡単にユーザIDが取得できます。このユーザIDはFirebase Authenticationに登録されているIDで、これを確認することで誰がこのFunctionを呼んだかが分かります。

またCloud FunctionsからFirestoreへのアクセスは通常Firebase Admin SDKを使用しますが、 Firestoreへのアクセスを記録するためにの薄いラッパーを作っていて、それを経由してSDKを呼ぶようにしています。 これによりFirestoreへのデータ読み込み・書き込みが発生すると、誰がFirestoreへどんなアクセスしたか、ログに吐き出されるようになります。

f:id:yosuke403:20190705085904p:plain

こちらは実際に出力されているログの例です。uidに対応するスタッフがproductとadminのドキュメントをgetしたことが分かります。

f:id:yosuke403:20190705083450p:plain

Webフロントエンドから直接Firestoreへアクセスできないのが面倒ではありますが、Firestoreのセキュリティルールが複雑化しなくて済むというメリットもあります。 もし直接Firestoreにアクセスできるようにするなら、一般ユーザ向けアプリ用のルールと管理アプリ用のルールが混在するようになってしまいます。

管理アプリユーザごとの権限を設定する

管理アプリユーザが誤って意図しない操作を行ってしまったり、閲覧してはいけないデータを参照したりしないよう、管理アプリユーザごとにロールを設定しています。 Functionが実行されたときは、まず最初にアクセスユーザのロールをチェックし、リクエストのあった機能の利用権限があるかを判定しています。

現状は簡易な設計をしていて、Firestoreに管理者を登録するコレクションを作成し、各ドキュメントにはユーザIDをドキュメントIDとし、ロールに対応する文字列の配列をフィールドとして持たせています。 Functionが実行されたときにこのドキュメントを参照して権限チェックを行います。

f:id:yosuke403:20190705083510p:plain

パフォーマンス問題

Cloud Functions経由でデータ取得・更新するようになって問題になったのが、管理アプリのデータ表示にかかる時間が長くなったことです。

管理アプリは基本的に限られたスタッフしか利用しないので、Functionが呼ばれる頻度が低く、その結果多くの場合においてFunctionのコールドスタート(実行環境がゼロから初期化されてスタートする状態)が発生しやすい状況になっていました。 コールドスタート時に遅くなる原因はいくつかありますが、大きな原因の一つがFunctionを実行するインスタンスとFirestore間で新規にコネクションを張る処理時間でした。 これはFirestoreへの最初のリクエストの時点で発生します。

Functionが変わればそれを実行するインスタンスも変わるので、コネクションが新規に必要になります。 管理アプリでは画面ごとに異なるFunctionが呼ばれることがほとんどで、新しい画面を開く度に時間がかかり、非常にストレスでした。

f:id:yosuke403:20190705083504p:plain

メモリ割り当てを増やす

Firestoreとのコネクションの確立を速くするにはメモリ割り当てを増やすのが効果的です。

次のグラフはメモリ割り当てを変更してコールドスタートの実行時間を計測したものです。 Firestoreから指定IDのドキュメントを一つだけ取得する処理を行っています。

export const testFunction = functions.https.onRequest(async (req, res) => {
    const result = await admin.firestore().doc('/aaa/bbb').get()
    console.log(result)
    return res.send('succeeded')
})

f:id:yosuke403:20190705083443p:plain

実行時間が半分ぐらいになっているのが分かります。 GCPのコンソールを見れば分かるのですがメモリ使用量としては256Mでも十分で、実はメモリ割り当てに紐付いて変更されるCPU割り当てが効いているものと思われます。 もちろん料金はその分上がるのですが、今のところ管理アプリ用のFunctionは実行回数がそこまで多くないので、高めに設定しています。

Functionを統合して呼び出しごとのコールドスタートを減らす

もう一つの対策として、管理アプリから利用するのFunctionを1つに統一し、クエリパラメータに応じてロジックを分岐するようにしました。 これにより一度確立したFirestoreのコネクションを、どのロジックを実行する場合でも流用できるため、画面遷移ごとにコールドスタートすることは少なくなりました。

f:id:yosuke403:20190705083506p:plain

Function統合のメリット・デメリット

Functionの統合はコネクションの流用以外にもメリットがあります。Functionが分かれないので、先の項目で説明したロールのチェックをはじめとする全Function共通の処理を一箇所に置くことができます。また、Functionの数が減るのでデプロイの負荷も抑えることができます。

Function統合のデメリットとしては、本来Functionごとに分かれるログがひとつに混ざってしまうことです。 しかし、現状限られたスタッフのみが管理アプリを利用している状況であるため、今のところログの量も比較的少なく、Webコンソールの検索機能から十分追えています。

パフォーマンスの改善だけであればメモリ割り当ての変更のみでもよいのですが、Komercoでは以上を踏まえ両方の対応を行いました。

データの検索

管理アプリとしてよく要求される機能がユーザや商品の検索機能なのですが、Firestoreで文字列検索を行うのは難しく、必要ならAlgolia等の外部サービスと連携するする必要があります。とはいえ、わざわざ外部サービスと連携するのは面倒です。

完全な解決作ではありませんが、前方一致だけなら対応できます。 例えばproductコレクションのnameフィールドに対して前方一致検索をかけたい場合、

firebase.firestore()
  .collection('product')
  .orderBy('name')
  .where('name', '>=', input)
  .where('name', '<=', input + '\uf8ff')

と書くことができます。

複雑な検索が不要な場合はこれで対応しています。

Cloud FunctionsのエラーをSlackに通知

Cloud Functionsで発生したエラーはSlackに流してすぐに気づけるようにしています。 Cloud Functionsには直接Slackに通知する機能はありませんが、ログはGCPのStackdriverに記録されているため、StackdriverのError Reporting機能を使うことができます。これによりメール通知としてエラー報告を受け取れます。Gmailでこのメールを受信し、フィルタと転送、及びSlackのEmail Integration機能を使うことでSlackに通知が流れるようになります。

f:id:yosuke403:20190705083412p:plain

プレビュー機能で結果を分かりやすく

管理アプリのいくつかの機能では、データ入力後にユーザにどう見えるかをプレビューする機能をつけています。 管理アプリはエンジニア以外のメンバーが使うことが多いので、自分が入力した情報がユーザにどのように表示されるのかを伝える必要があります。 プレビュー機能があると、管理アプリのユーザは安心して情報入力できるようになります。

例えばこちらはコメルコバナシの編集画面です。右半分がアプリに表示される様子をプレビューしています。

f:id:yosuke403:20190705083440p:plain

こちらはユーザへのPush通知画面画面です。画面下にiOSで通知される様子が表示されています。 FirebaseコンソールにもPush通知を送る画面はありますが、こちらの方が結果がより分かりやすいかと思います。

f:id:yosuke403:20190705083455g:plain

スタッフから喜ばれる機能なので、エンジニアも自主的に開発に取り組んだりしています。

最後に

Komercoの管理アプリを通して、Firebaseの特徴を踏まえた管理アプリの開発の知見をご紹介しました。

管理アプリはユーザの目には直接触れませんが、スタッフの効率に直接影響するため、なるべくよいものを提供したいと思っています。 Firebaseは運用にかかるコストが低く、エンジニアがサービス開発に専念できるのがとてもよいところです。 管理アプリも積極的に開発して全体の効率を上げていきたいです。

Komercoでは、モノで料理を楽しくしたいエンジニアを募集しています。 Firebaseを使ったサービス開発に興味のある方いましたらぜひご連絡ください! ご応募お待ちしております!

www.wantedly.com

www.wantedly.com

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/