はじめに
potato4d こと花谷拓磨です。
普段は ElevenBack という屋号のもと、フロントエンドを中心としながらも、デザインやサーバーサイドなどをも含めて作業することが多いのですが、屋号での活動の一環として、 JSLounge という渋谷で活動するハンズオン団体の運営を行っています。
その JSLounge にて過去に開催したハンズオン資料を入手できるサイトを構築することとしました。
通常、ハンズオンとなればその場でメンターがついて 1:1 で進めますが、 JSLounge の資料は Git 管理していること、 Git のコミットごとに手順書が書かれていることから、ある程度自走できる人であれば資料単体でも学習が可能なことが判明した1ため、 JSLounge Archives としてリリースしました。
その JSLounge Archives について、今回は Firebase と Nuxt.js の SPA モード、それに決済基盤の SaaS である Stripe をフル活用して、完全なサーバーレス SPA として構築したため、本エントリでは、その内部実装についてご紹介できればと思います。
Payment を扱う会員制 EC サイトであっても、たった 2 日で構築できてしまうほどの、現代のサーバーレス SPA の魅力が伝わると幸いです。
「2 日で開発」について
こういった記事の場合、「○日で開発」と言いつつも、実際は土日をフル活用して30~40時間をかけて開発されているケースも多いですが、今回は実時間ではなく、作業時間 8 時間を 1 日として定義し、 16 時間で開発しています。
そのため、開発期間自体は余暇などを利用したものであり、複数日にまたがっています。しかし、今回レギュレーションとして「手を動かしている時以外はプロジェクトのことを考えない」を課すこととしました。
作成した成果物について
実際どの程度のアプリケーションであれば 2 日で構築できるのかを見てもらうため、はじめに簡単に JSLounge Archives についてご紹介します。
JSLounge Archives は、 CRUD のうち Create / Read / Update と決済機能を持つ SPA と、ランディングページに近い単一の Web ページで構成されています。現在、 Firebase Hosting によって https://jslounge-archives.elevenback.jp/ にて公開されています。
それぞれのルックアンドフィールは以下のようなものとなっています。詳しく見てみたいかたは、実際にアクセスしてみてみることとしてください。
今回、オフラインでのイベント参加者向けにオンライン資料を配布したくなった場合を考慮し、通常決済のほかにシリアルキーからのアクティベートを用意したため、機能を網羅すると以下となります。
- ユーザー登録と認証
- 決済などでのトラブル時の連絡先を確保するため、ソーシャルログインではなくメールとパスワード形式
- アーカイブの購入機能
- 決済基盤と連携して、成功した場合に限り有効化する処理
- 一度購入したものは購入したデータのリストへと格納される
- 購入したアーカイブ情報の閲覧とダウンロード機能
- 購入済みのコンテンツに限定してリスト化し、 zip ファイルのダウンロードを提供
- シリアルキーでの有効化機能
- 何らかの手段でシリアルキーを入手した場合に、アーカイブ購入と同様の処理を無償で行う機能
オーソドックスな EC サイトで共通で使われる要素を一通り網羅したものとなっています。クーポンシステムなどを開発する場合もありますが、今回はシリアルキーという形での提供の機会のほうが多いと判断したため、代わりにそちらを実装することとしました。
インフラストラクチャの構成
今回の構成を俯瞰して書くと以下のようなものとなります。
今回は、高速に開発することができかつメンテナンスしやすい構成を目的としているため、使い慣れた Nuxt.jsをフロントエンドに据えた上で、バックエンドは Firebase から、 FireStore, Auth, Functions を利用しました。その他決済のために Stripeを、CI/CDをするためには Firebase にはビルドサービスがないため CircleCIを、それぞれピンポイントで利用しました。
それぞれの領域における利用技術・サービス・フレームワーク・ライブラリとその特徴は以下となります。
フロントエンド
- Nuxt.js
- https://nuxtjs.org/
- 最近は 一休.com や note.mu にも導入され知らない人はいない存在に
- Firebase SDK
- FireStore とのやりとりは Cloud Functions からのものに限定、直で書き込まない
- Firebase Auth のために SDK から Auth のみを利用
- Stripe SDK
- Stripe は SDK を読み込んだ上で Stripe Elements を利用
- vuesax
- https://github.com/lusaxweb/vuesax
- UI フレームワークとしてオレオレデザイン + vuesax の構成を利用
- トップページは全て自作、アプリケーション部も vuesax はボタンやローディングなどでの利用がメイン
- vuesax は SSR 対応がまだ弱いので絶賛プルリクにて改善中
バックエンド
すべて Firebase のサービスとなっています。
- Firebase Functions
- 今回は payment / FireStore の IO など基本的に全ての機能を担当
- フロントエンドから FireStore などを操作することもできるが、設定ミスによるリスクが大きいため Functions に寄せている
- 現状東京リージョンで動かすことができるが、後述する Firestore が東京リージョンが存在しないため、海外リージョンを選ぶか東京を選んで functions 内でのリージョン間通信を許容するかの二択となる
- AWS Lambda でいうところのコールドスタートがもちろんあるため、開発中や人が少ないとき、スピンアップ時は 5~10 秒ほど待たされる
- Firebase Auth
- 認証基盤として利用
- 認証後はアプリケーションを動かすために最低限必要なデータを FireStore に格納した上で、 JWT をベースに利用される
- Firestore
- Functions の裏側として利用
- 正直こういう使い方をするなら RTDB と利便性は変わらないが、全体的なインターフェースのモダンさが魅力
- ただまた東京リージョンが存在しないため、レイテンシはひどい
その他
- Stripe
- 決済処理を自前で管理するのはリスキーかつ煩雑であるため Stripe を利用
- 国内の場合 PAY.jp なども有力ではあるが、このサービスのためにまた長々と利用申請を書くのがつらいため Stripe に
- Firebase Hosting
- フロントエンドは Firebase Hosting でホストすることとしました。
- CircleCI
- CI/CD 両方を受け持つ
- 今回は master 投入時に Firebase の設定が全て本番に反映されるように
Step 1/5: フロントエンドの構築
今回はフロントエンドから実装をはじめました。
作るものが決まっており、かつ今回はデータの永続化先が FireStore ベースなので、 JSON をイメージしながら仮想の API レスポンスを考えていけば良いので、こういった場合はフロントエンドから開発をすすめると円滑と考えています。
また、 Firebase Auth など、フロントエンドだけで完結できる作業を済ませておくことによって、スムーズに API の開発を行うことができるというメリットもあります。
今回は、新興の UI フレームワークである vuesax を用いて、レイアウトと微調整だけを自分でおこなうようにしました。
フロントエンドを先に実装するメリットとして、 Web サイトや Web アプリケーションでは重要となる UI の手触りについて、早期からフィードバックを受けることができるというものもあります。
今回も、最優先で CircleCI による Firebase Hosting への自動デプロイ環境を構築し、すぐにフィードバックを受けられる環境を作っていました。
全ページについて、ユーザーへのヒントの表示(何ができるかなどの説明とそれぞれのアクションへの導線提供)などを含め、一通り作ったところで一旦フロントエンド開発は完了しました。
ここまで大体 4 時間強程度です。時間がかかりがちなロゴのアセット準備は、 Adobe Stock のダウンロード数が 70 個余ってたので適当に買いました。
Step 2/5: Functions での API の実装
フロントエンドの仮組みが終わったので、次はサーバーサイドに移ります。
バックエンド利用の是非について
まず、 Firebase を使うにあたって考えるべきことは、「mBaaS を利用していながら自分でバックエンドを書くのか。」ということです。
Firestore は非常に柔軟な書き込み設定が可能なため、簡単なアプリケーションであれば、フロントエンド側で Firestore を利用して完結しても良いところです。ですが、今回は全て Functions に寄せることとしました。
Functions に寄せた理由は以下となります。
- 決済操作のためにどのみち CloudFunctions は利用するため
- Firestore にデータを配置した上で Firestore トリガーでバッチを実行する手段もあるが、不正な操作に弱く、かつエンバグしたときにリスキーなので控えたい
- どのみち実行するなら HTTP トリガーでしっかりとロールバックまで含めて失敗時の操作をしたい
- アーカイブダウンロードも可能な限り CloudFunctions に寄せたいため
- 例えばダウンロード URL を提供する方法もあるが、外に露出した場合に少しばかり具合が悪い
- HTTP トリガーを経由してユーザーのトークンを受け取っておけば最悪露出しても Expire が行われた段階で無効となる
- 2箇所以上で HTTP トリガーが必要なのであれば、 Firestore と HTTP の多重状態管理は控えたい
- そもそもリアルタイム性が必要ないアプリケーションで必要以上にアーキテクチャを複雑にする必要はない
- 結果として HTTP 通信をして Vuex ストアで取り扱うオーソドックスな SPA-API と同じスタイルに
実装
今回は Express スタイルではなく、 export const
スタイルで開発。Firebase Admin SDK 及び firebase-functions は TypeScript と相性が良いので TypeScript で開発することに。
基本的には認証ガードを実装した上で Firestore を噛ませるだけなので特筆事項なし。環境の構築から諸々行った結果、3時間弱で作成完了。全体としての作業は 8 時間程度となっており、ちょうど一日仕事したような状況です。
なお、この時点では次につなぎこみを行うため、決済は行わないで完了した扱いにしておくことに。
実装時の悩み
開発したものとしては特別なことはしてないですが、ネットワークレイテンシ周りの改善に最後の1時間程度はかけていました。 Firestore のリソースを複数取得した際の重さなどがネックとなっていました。
が、こちらは知人の Firebase をたびたび使っている開発者が改善方法をツイートしていたのでそれを試してみると、目に見えて改善されたので今回はよしとしました。これによって、それぞれ 1s 程度かかることはあれど、 Promise.all が可能な形で直列的なリクエストに依存しないような構造にしておいたこともあって、リクエスト全体でも 1.5s 程度となりました。
Step 3/5: Nuxt.js と API のつなぎ込み
ここまでできたら一度 SPA と API の結合を行います。Stripe などの外部サービスが出てくるとつなぎこみ作業のときに毎回リクエストが飛んで面倒なので、仮のうちに全部つなぎ込み終わっておくのがラクです。
ここは Nuxt.js 側では @nuxtjs/axios
を利用し、 http://localhost:5000 に立ててあるローカル実行版の CloudFunctions にリクエストを飛ばすという具合で実装しました。
ここで実は 0 件の場合やこのデータがない場合にどうやって表示してやるとベストか。みたいなことも考え出した結果、つなぎこみとコーディング作業の追加をやることになってさらに 3 時間ほどかかることになりました。
Step 4/5: Payment の実装と全体動作検証
開発も最後の仕上げです。決済基盤の実装とそれを踏まえて全体のフローが動くかの確認を行っていきます。今回は、 Stripe の決済では公式の UI ウィジェットである Stripe Elements のラッパーである fromAtoB/vue-stripe-elements を使って実装することにしました。
Stripe Elements を利用すると、簡単にスクリーンショットのような入力フォームと送信ロジックをセキュアな形で提供できます。なお、こういったウィジェットは、 Stripe に限らず PAY.jp など競合サービスにも存在するため、もし他の決済基盤を利用することになった場合でも、こういったウィジェットがないか探してみることをオススメします。
https://github.com/fromAtoB/vue-stripe-elements
バックエンドは公式の stripe/stripe-node を使って特に複雑なことはせず実装しました。ポイントとしては、どのみち Firestore をクライアントからは読み書き不要としているため、 Stripe 側のユーザー情報も、クリティカルなトークンなど以外は Firestore に入れた上で、 CloudFunctions 側でデータを整形している点となります。とはいえ、特にアーキテクチャ的に凝ったことをしているわけではないため、サッと実装しました。
https://github.com/stripe/stripe-node
決済のテストについて
決済の動作確認時はテスト用のキーを使っていると基本的に問題はありませんが、オペミスによる予期せぬ請求などを考えると、テスト用のクレジットカードを利用することをオススメします。利用できるテスト用カードは、それぞれのサービスのWebサイトにまとまっています。
https://stripe.com/docs/testing#cards
休憩: Landing Page の作成
この辺りでアプリケーション開発が少し疲れてくるので、気分転換でトップを作ってしまいます。特に Web アプリケーション部は、今回自分のリソースを 2 人日しか使わないという前提で作業しているため、気にしだすと疲れます。自分の場合、特に作業の合間にサイト側を仕上げてしまうとリフレッシュになってモチベーション向上にプラスとなりやすい傾向にあります。
とはいえこちらの所要時間もある程度気にしておきたいところではあるので、インブラウザデザインで全て仕上げてしまいます。
特別なことはせず、 Nuxt.js 側で layouts/home.vue を用意し、その上で pages/index.vue にドカドカコーディングしていく形となります。Nuxt.js のレイアウト機能は、メイン画面を default.vue にコーディングしておくと、ブラックリスト的に default にフォールバックさせないようにすれば良いので楽で漏れもなく設定できます。
多くの場合 Web サービスには、共通画面, トップページ, 規約やお問い合わせ表示用のシンプルな 1 カラムの画面がほしくなるので、それぞれを順番に default.vue, home.vue, blank.vue として作っておくと、3パターンだけで考えると良いので、鉄板パターンとして覚えておくと便利です。
Web ページよりの画面を高速で作成する際のポイントとして、 Vue / Nuxt を利用している場合は、「Webページでしか使わないコンポーネント」であってもどんどん切ってしまうという点があります。
Web アプリケーション側でスタイル観点を優先してコンポーネントを切ることは、全体としてのコンポーネントの粒度や関心がばらつくことで問題となることが多いですが、基本的に Web サイトは画面内での再利用が多いこと、ロジックが少ないことから、 Scoped CSS と UI 分割の恩恵をフルに受けるメリットのほうが大きくなります。今回の例では、トップの Hero だけでもコンポーネントを切っています。
なお、これはアプリケーション側の UI フレームワークを使い回さないことを前提とした場合であるため、例えば「Webページでも Element UI が表示されても問題ない」という場合は素直に UI フレームワークを活用すると良いでしょう。
なお、今回のアプリケーションでは、トップページに置いては以下のモーダルのボタンとローディングアニメーション以外では一切 vuesax を利用していません。
Web サイト自体は制作は2時間半ほどでしたが、文言を考えたり画像集めを休憩がてらやっていたので、その辺りも含めると大体4時間程度作業していました。
Step 5/5: 公開設定
最後に、面倒な公開用の設定の諸々を行っています。具体的には、以下のような作業が含まれます。
- Stripe の本番用キーの設定
- Google Analytics のアカウント作成
- OpenGraph 画像の作成と表示確認
- 本番ドメインの紐づけ
- テストデータのリセット
- マスタデータの作成
- 上記を全て行った上での本番環境でのシステムの動作確認
今回の場合、過去のハンズオンのデータをまとめてアップロードし、それに応じたマスタデータを記述するところが一番時間がかかりました。マスタデータの作成が 1 時間半ほど、その他も含めて全てで 3 時間程度かかることとなりました(DNSの反映待ちなどの時は作業をしていなかったがカウント)
これでサービスのリリース作業は一通り終了です。
ここまでで Web サイトを含めずに 14 時間。含めた場合は 18 時間で仕上がりました。
完成後は……
このままリリースしてTwitterなりで拡散して終了でも良いところですが、今回は折角 JAMStack なサーバーレス SPA として作成したので、 Qiita で実際の選定で作業のときに意識したことをまとめることにしました。
ジャストアイデアによって、この記事は今回の開発の Step 6 とも言える立ち位置になりました。
Firebase でサーバーレス SPA を実用的な形で利用するために気をつけること
ここまでで開発の流れは書いたので、注意点についても書いておくこととします。今回筆者が Production 開発レベルではじめて利用した技術は Firestore となりますが、現状はいくつかの注意点がありました。そのあたりを中心に、ポイントをご紹介していきます。
普段はインフラは AWS を利用することが多く、データベースも RDB が中心という中で Google 管轄かつ NoSQL 型データベースの利用となったので、もしかしたらよりよい改善方法があるかもしれません。
RTDB / Firestore のリアルタイム性の是非
今回、 Firebase のデータベースを利用していながら、 CloudFunctions でデータをやりとりするという一見回りくどい方法をとることとなりました。
せっかくリアルタイム性が存在するのであれば、その特性を活用したほうがユーザー体験としてもプラスなのではないかという考えができなくもありませんが、あくまでも個人的な意見としては、必要であるかどうかを吟味した上で、必要である場合にのみリアルタイム性を伴う形で開発をすることを推奨しています。
現代のアプリケーション開発の場合、 Web API と接続し、単一のストアをベースとした状態管理を行っていることがほとんどです。しかし、 Firebase と接続する場合、その点は大きく変わります。
データが常にバックエンドの状態と同期されて書き込まれることとなり、ユーザーのアクションを契機とせず、常に状態が変更されうることを考慮した設計をする必要が出てきます。
また、書き込み処理についても、例えば今回のような決済基盤を付与したい場合などは、直接の書き込みではなく CloudFunctions を通すことになるケースも多くあります。こういった場合、もし CloudFunctions を通したリクエスト結果をフロントエンドでもつ場合、独自での状態管理と Firestore との同期の多重管理となる可能性があります。
そのため、はじめから「Read処理はすべてFirestoreで行うが、Write処理はAPIを叩いて実現する」などある程度破綻しない設計を考えて作るための勘と能力が必要となります。
普段のフロントエンド開発の時の感覚のままにリアルタイム性の恩恵を享受しようとした場合、多くのケースにおいて多重管理などに悩まされることとなるため、十分に考えて利用することをオススメします。
適切なリージョン選択
https://twitter.com/GCPcloud/status/1027306405659926529
公式に近い内(next several months)に解消されるというアナウンスがありますが、現状まだ Firestore は東京リージョンが存在しません。常に海外のサーバーと接続することとなるため、一定以上のレイテンシについての許容が必要となります。
その際、例えば今回のように CloudFunctions から Firestore へと接続するケースなどでは、リージョン間通信のレイテンシのほか、通信の課金コストについても調査する必要があります。
実際のリクエストボリュームや頻度から、パフォーマンスの計測と試算を十分に行った上で、リージョンを選択していくことをオススメします。
NoSQL型データベースにおける決済処理
NoSQL型のデータベースのみで EC サイトのようなアプリケーションを開発するにあたって、一番考えるべき点はトランザクションと決済処理となるかと思います。
今回はダウンロードコンテンツ販売かつ Firestore を利用しているという点で問題とならず済みましたが、多くの場合問題となるほうが多く感じたので書いておきます。
トランザクションの話
多くの NoSQL 型のデータベースには、いわゆるトランザクション処理が存在しません。これは、 ACID 特性よりパフォーマンスを重視しているためであり、ある意味仕方のないことではある反面、何かを購入したり、決済する場面では非常に大きな問題となりえます。
例えば物理的な在庫を持っているショップのECサイトの場合、以下のような形で決済が失敗することが考えられます。
- ユーザーAとユーザーBがそれぞれ在庫が一つしかない商品を、ほぼ同時に購入した
- この場合所持しているカートのうち競合した商品があれば後に実行されたほうは失敗としなければならない
- ユーザーの購入処理の途中でアプリケーションやデータベースに障害が発生し、一部のデータだけが書き込まれることとなった
- 決済だけが行われては問題となるため、辻褄が合うようなデータ設計をしなければならない
- 決済基盤側に障害が発生している、もしくはユーザーの入力不備によってカード情報がうまく通らなかった
- 購入した事実は存在してはならない
これらの問題を共通する解消方法として、データベースのトランザクションが存在することが挙げられます。多くの人が、トランザクションの存在するデータベースにおける解消法であれば思いつくのではないでしょうか。しかし、そのトランザクションが存在しない環境となるため、利用せずに解決する必要があります。
解消方法
ではトランザクションなしでどのように対処するかとなると、一時的に決済の状態を格納できる領域をデータベース上に作成し、中間状態を逐一保持していくこととなります。
今回はユーザー同士の競合を考慮したケースの実装ではないため、ここではコードについては触れませんが、おおよそ以下のような実装となります。
- 共通読み出し可能領域(以下ロック)に決済中である情報を格納する
- 個人用のデータ領域に決済開始情報を格納する
- 決済処理を行う
- 決済処理が完了後に決済完了データを格納する
- 個人用のデータ領域決済開始データを削除する
- ロックデータを削除する
フローをリストアップするだけではそこまで複雑には見えませんが、それぞれのステップにおける失敗などを考慮すると、膨大なケースに対応する実装が必要となります。
私見ですが、こういった領域が出てきた時点で、完全にサーバーレス + NoSQL に頼るのではなく、 RDB ベースの構成が必要となるような気がしました。少なくとも、決済について十分に熟知したエンジニアでないと、責任が取れない領域に達していることは明白です。
増えつつあるトランザクションのある NoSQL
ここまで書いておいてですが、冒頭に書いた通り、 Firestore を利用したことでここは問題となりませんでした。
その理由としては、 Firestore は新しいデータベースとして、これまでの Firebase RealTime DataBaseには存在しなかったトランザクションが、機能として実装されたためです。
https://firebase.google.com/docs/firestore/manage-data/transactions?hl=ja
そのほか、 MongoDB もトランザクションが実装されることとなり、徐々にトランザクションを備えた NoSQL データーベースは増えています。データ競合やロールバックが存在しうる環境では、トランザクションが利用可能なデータベースかどうかも検討基準にいれる事が重要だと感じました。
[速報] ACID トランザクションをサポート! MongoDB 4.0 がリリースされました! #MDBW18
https://dev.classmethod.jp/server-side/db/introducing-mongodb-4/
おわりに
今回紹介した JSLounge Archives は、サービスの性質上非常に toB アプリケーションに近い性質を持っていたため、このような構成となりました。
例えば toC アプリケーションの性質を強くもつ場合は、 Firebase の金銭的コストには注意を払うことが大切になる半面、ここまでシビアに Cloud Functions のみを利用する形でのデータ I/O をする必要はないかもしれません。
フロントエンドにおいて、圧倒的な開発効率を誇る Nuxt.js と、 mBaaS として高い生産性を誇る Firebase は、どちらも世界を変えるプロダクトとなる可能性を大いに秘めています。ですが、 Real world においては必ずしもそれだけが有効打ではないことを深く刻んでおいていただけると幸いです。
JSLounge Archives では、 Nuxt.js や Firebase のハンズオン資料も公開しているため、もし興味のあるかたは、アクセスしてみていただけると幸いです。
https://jslounge-archives.elevenback.jp/
-
ハンズオン参加者で資料で学習する形のリモートでの参加希望者が一定数現れたため ↩