どうもこんにちは。雑食系エンジニアの勝又です。
最近は本業では技術顧問業務や開発内製化アドバイザー業務を掛け持ちしておりまして、複業でYouTuberやオンラインコミュニティ事業の運営等もやらせていただいております。
昨年は「Web系エンジニアになろう」という書籍も出版させていただきました。
ここ数年は、手を動かすエンジニアとしてはDevOps系の業務に従事することが多かったのですが、個人開発で久々にフロントエンドの技術を使う機会がありましたので、そこから得た知見や感想をまとめてみました。
なお、こちらは雑食系エンジニアサロンアドベントカレンダーの25日目の記事になります。
開発までの経緯
私は雑食系エンジニアサロンというコミュニティを運営しておりまして、そちらの上位プランでは「Web系エンジニアへのジョブチェンジ」を目指されている皆様向けのメンタリングサービスを提供しています。
その運営業務に携わる中で、「そもそもプログラミング学習に数十万円もお金をかけるような余裕がある人ならば、そのお金で実務経験を買っちゃう方が早いんだよなあ」と思うことが多く、そういった構想をTwitterで発信してみたところ、思いがけず色々な反響があり、エンジニアTypeさんでは下記のようなインタビューをしていただきました。
良い実務経験って何だ?「エンジニアが実務経験を買うサービス」構想で話題の勝又健太さんに聞いてみた - エンジニアtype
しかし残念ながらこういった「お金で実務経験を買う」系のサービスには労働法的な問題があり、悪意を持った企業が参入してくると搾取につながる危険性も大きいため、実現するためのハードルが非常に高いというのが現状です。
そこで、実務経験を買うまでには至らないものの、まずは「実際に手を動かしてそれなりのサイズのコードを複数ファイルに記述して一つの機能を実装して自動テストで正誤判定をおこなえるような、実務に非常に近いタイプのプログラミング問題集サービス」を自分で作ってしまおうと考えて、開発に着手したというのが経緯になります。
サービスの機能
機能としては非常にシンプルです。イメージとしてはKaggleに近いです。
(私が作成したひどいUIなのでお見せするのはちょっと恥ずかしいのですが、作業途中のWebサービスなんてこんなもんなんだなということでご笑覧ください)
まずサービスの画面上に問題集の一覧が表示され、いずれかの問題集の「学習する」ボタンを押すとGitHubの問題集用のテンプレートリポジトリのコピーが作成されて、そのリポジトリにユーザーのGitHubアカウントが外部コラボレーターとして招待されます。(同時にブランチ保護設定もおこなわれます)
ユーザーはそのリポジトリのコードを編集して何らかの機能を実装して、GitHubに作業ブランチをpushするとあらかじめ組み込まれているテストコードがGitHub Actionsによって実行されて、テストが全て成功すればマージ可能な状態になります。
そしてmainブランチへのマージが実行されるとGitHubからのフック通知をFirebaseのCloud Functionsが受け取り、その問題集の難易度に合わせてスコアがアップする、というような仕様になっています。
GitHubに対する各種処理はGitHub APIを使って実装しています。
現時点(2021年12月時点)ではとりあえず主要な機能の実装が一応完了したという状況です。UIに関しては専門の方にご依頼して本番公開用に改修中です。
技術選定
当初はRails + MySQL + AWSみたいな構成も検討しましたが、今後も個人開発に何度もチャレンジする可能性があることを考慮して、バックエンドとインフラに関しては個人開発に適したプラットフォームであるFirebaseを選択しました。
(ちなみにNoCodeでの開発も検討しましたが、NoCodeCampの識者の方に伺ってみたところ、GitHubの外部APIを呼び出したりフック通知を受け取ったりする辺りが仕様的にちょっと難しそうというご回答をいただいたので、その方式は断念しました)
フロントエンドに関してはNext.jsでも良かったのですが、以前Vue.jsを使った開発経験があったこと、新しい技術を一気に使いすぎると開発効率が大幅に悪化して挫折するリスクが高いことを懸念して、Nuxt.jsを選択しました。
(その他、私が運営しているメンタリングサービスにおいても、自社開発企業へのエンジニア転職を目指されている方たちがSPA構成のアプリケーションを開発する場合にNuxt.jsをご選択されるケースが多いので、技術的アドバイスをする上でもNuxt.jsの開発を経験しておくことは有用であると判断しました)
Web業界的には現在Next.jsが徐々に主流になってきているようですが、Nuxt.js+Firebaseの組み合わせに関しては後述する「神本」の存在によりベストプラクティスを効率的に学習できたということもあり、とりあえずこの選択で大きく外してはいなかったかなと思います。
学習過程
フロントエンド基礎
数年前にVue.jsを使ったことはあったものの普段フロントエンドに関わる機会は少なく、特にHTMLとCSSに関しては素人同然だったので、フロントエンド技術の基礎を下記のような教材を使って総復習していきました。
【ES6】 JavaScript初心者でもわかるPromise講座
TypeScript
TypeScriptに関しては下記の書籍で復習しました。内容が非常にコンパクトにまとまっている良書だと思います。
Nuxt.jsとFirebase
Nuxt.js学習の前準備用に、まずはVue.js 3を下記の書籍で復習しました。(Nuxt.jsの最新バージョンは現時点ではまだVue.js 3に対応していないのですが、Vue.js 3で導入された非常に重要な機能であるComposition APIはNuxt.jsでも一応使用できます)
同じ著者の方の「Vue.js&Nuxt.js超入門」という書籍もあるのですが、こちらはやや出版年月が古く、Vue.js 3(Composition API)に対応していないようだったので学習には使いませんでした。
FirebaseのデータベースとしてはRealtime DatabaseではなくFirestoreを選択し、下記の書籍で学習しました。評価が非常に高い本ですが、Firestoreを使うならば必読だと思います。
開発に取り掛かる前の学習の総仕上げとして、最後にこちらの書籍でNuxt.js+TypeScript+Firebaseによる開発の勘所を学習しました。こちらも非常に評価の高い書籍ですが個人的な感想としてはまさに「神本」です。Nuxt.js+TypeScript+Firebaseという技術スタックにおけるベストプラクティスが詳細に記載されているので大変参考になりました。
Nuxt.jsとFirebaseを使って爆速で何か作る前に読む本
ただしNuxt.jsおよびFirestoreを使った開発経験がゼロの状態だとこちらの書籍を読んでも理解できない箇所がかなり多かったため、この組み合わせでの開発に多少慣れた段階でもう一度最初から読み返しました。
開発手順
画面設計
画面設計に関しては、一応当初は最近のトレンドに合わせてFigmaを使ってみたのですが、「人に見せるわけでもないし画面数も少ないし手で書いてしまった方が早いな」ということで、最終的にはA3用紙に手書きでざっと各画面を適当に書き出す方式で設計しました。
データベース設計
一般的なRDBを使った開発であればこの時点でほぼ全てのテーブルを網羅して正規化した状態でER図等に落とし込めるのですが、Firestoreの場合はRDBのノウハウが使えない箇所が多いので、とりあえず最初の時点ではユーザー用ドキュメントと問題集用ドキュメントの項目を適当に設計して先に進みました。
実装
画面的には「問題一覧画面」→「問題詳細画面」→「サインアップ画面」→「サインイン画面」→「ユーザープロフィール画面」→「マイページ画面」→「問題へのレビュー投稿画面」→「問題検索画面」のような順序で、色々と悪戦苦闘しながら実装していきました。
Firestoreのスキーマに関しては、画面の実装を進めながらそれに合わせて色々とドキュメントを追加&変更を加えていきました。
他の業務もあったので週最大で3日、少ないと週1日程度の工数だったため進捗は遅かったです。7月頃に学習に着手して、主要な機能の実装が完了したのが11月末という感じでした。
各技術への所感やハマりどころなど
Firestore
今回の開発で最も難しかったのはFirestoreのスキーマ設計やセキュリティルールの設定です。
非正規化による不整合への対応
FirestoreはNoSQLであり、結合がおこなえないという制限があるためスキーマを非正規化する必要があります。この場合当然ドキュメント間の不整合が発生することになるので、その不整合を解消するための何らかの仕組みが必要になります。
基本的にはドキュメントの新規作成イベントや更新イベント発生時にCloud Functionsが起動するようにして、関連するドキュメントを一括で更新する方式で対応するのですが、こういったことを前提にしてドキュメント設計をおこなう必要があるため、ここら辺の理解が進むまでが中々大変でした。
公開情報と秘匿情報の分割
Firestoreでは「1つのドキュメントに"公開してもよいフィールド"と"秘匿したいフィールド"を混在させることはできない(可能ではあるが大きなセキュリティリスクが発生する)」という制限があります。
そのため、例えば「ユーザー情報用ドキュメント」に関しては、公開してもよい「ニックネーム」や「サムネイルのURL」等の情報は公開用のドキュメントに、当該ユーザー以外には秘匿すべき「購入済みのポイント数」等の情報は非公開のドキュメントに分割して保持する必要があります。
当初はここら辺の制限がよく分からなかったので、ユーザー情報用の各ドキュメントに関してはフィールド定義を何度もやり直すことになりました。
セキュリティルール
Firestoreはバックエンドを介さずにクライアントアプリケーションから直接アクセス可能であるため、「セキュリティルール」という機能を使用してデータの読み書きに制限をかける必要があります。
このルールは一般的にユースケースごとに異なるため、各ユースケースごとにルールを設定する必要がありますが、ユースケースがそれなりの数になるとこの設定はかなりややこしくなってきます。
トランザクションを使用して複数ドキュメントを更新するような場合はルールがさらに複雑になるので、この点は結構苦労しました。
セキュリティルールが複雑になることを嫌って、Firestoreに対する更新処理は全てCloud Functions + Firebase Admin SDKでおこなっている方もいるようですが、これがベストプラクティスなのかバッドプラクティスなのかの判断がつかなかったので、今回の開発ではこの方式は採用しませんでした。
Vuetify
UIフレームワークとしてはVue.jsのデフォルトであるVuetifyを選択しましたが、CSSやグリッドシステムに関してほとんど実戦経験が無かったのでこちらもかなり大変でした。
とりあえずヘッダーやフッターを設定して「なんちゃってレスポンシブ対応」をするところまでは自分で頑張ってみましたが、私のスキルセットでここら辺に工数を使うのはあまり賢い選択ではないなと判断して、最終的にはWebデザインのご経験がある方に外注させていただきました。
Vuetifyに関しては主に下記の記事でざっと学習しました。
また、Vuetifyを理解する上ではグリッドシステムの知識が必要だったので、下記のチュートリアルやドットインストールでBootstrapを改めて学習しました。
Bootstrap 4 Tutorial
Bootstrap 5入門 (全21回)
Composition API + TypeScript
Vue.js 3から導入されたComposition APIを使用する場合、コンポーネントからロジックを切り出して「コンポジション関数」として別ファイルに整理することが推奨されています。
コンポーネントを小さく・きれいに設計しよう。Vue Composition APIを活用したコンポーネント分割術
しかしネット上のNuxt.jsのコードサンプルでコンポジション関数方式 + TypeScriptで記述されているものはまだ非常に少なく、「コンポジション関数方式への翻訳」「TypeScriptへの翻訳」といった作業が必要だったため、ここら辺の理解や勘所の把握にかなり時間がかかりました。
Firebaseのログイン判定
Firebaseを使ったことのある方はご存知だと思いますが、ブラウザにサービスの画面が表示されてからFirebaseのログイン状態が確定するまでには多少のタイムラグが発生します。
Firebaseのログイン状態が確定する前にページ遷移やレンダリング処理等が実行されてしまうと困るので、ログインが確定するまで処理を中断する必要があります。これは「Nuxt.jsのPluginはPromiseを返す関数として作成すると、そのPromiseが解決されるまで後続の処理を実行しない」という仕様および「provide/inject機能」を活用して対処することになるのですが、こちらも情報が少なかったため実装にはそこそこ苦労しました。
FireStoreの入出力に型を設定する
FireStoreの入出力に型を付けて扱いたい場合は「Converter」を作成する必要があります。
Converterは基本的には定型的なやり方で作成できるのでこれ自体はそれほど大変ではないのですが、この際に使用する「DocumentReference」や「Timestamp」等の型が、Firebase SDKとFirebase Admin SDKとでは異なります。
(Firebase SDKはクライアント用ライブラリ、Firebase Admin SDKはサーバー用ライブラリなので、同じ型定義が使えないのはある意味当然ではあります)
そのため、Firebase Admin SDKの使用が前提となるCloud FunctionsでもFirestoreの入出力に型を付けたい場合は、Firebase SDKを使う場合とほぼ同様なConverterを重複定義する必要があります。しかしこれは保守性の面でかなり問題があります。
一応こちらのような方式で対応できるようではあるのですが、やや面倒ということもあって一旦後回しにしています。
Cloud Run
Nuxt.js本体はCloud Runにデプロイしています。
現在はまだ外部公開していないので--no-allow-unauthenticated
オプションを付加しています。
また、--ingress internal-and-cloud-load-balancing
オプションを付加することで、Cloud Runに対する直接アクセスを禁止して、GCLB(Google Cloud Load Balancing)からのアクセスのみを許可しています。
GCLBを使うと自動的にCloud Armorも設定されるので、DDoS対策機能も有効になっています。
さらに、開発途中のアプリケーションへのアクセスを制限するため、IAP(Identity-Aware Proxy)を設定して、特定のGoogleアカウントのみがアクセス可能になるように制御しています。
ここら辺は下記のドキュメントを参考にしました。
Cloud Run で Identity-Aware Proxy (IAP) を使う
ちなみに、GCLBを作成すると「グローバル転送ルール」というリソースも自動的に作成されるのですが、こちらの料金が1ヶ月で1,500円程度になるので個人開発としては地味に痛いです。
しかし開発環境のCloud Run上のアプリケーションへのアクセス制限を簡単かつ確実におこなう上では、GCLBとIAPを組み合わせる方式が現時点では最善の選択と思われるので、この費用は仕方ないかなと考えています。
Cloud Functions
GitHub APIを呼び出すコードはGitHubトークン等の秘匿情報が必要になるため、Cloud Functionsで実行しています。
Firestoreでドキュメントが新規作成/更新/削除された場合に実行されるイベントフック、およびGitHubでマージが実行された際にコールされるWebhookもCloud Functionsで作成しました。
全文検索
全文検索機能はalgoliaを用いて実装しました。
ネット上では「algoliaは料金が高い」という情報が多かったので他のサービスも一応検討しましたが、今回のサービスにおいては対象となるレコード数(問題集の数)が膨大になる可能性は低いこと、および「Search with Algolia」というFirebase拡張機能が非常に便利だったのでこちらを選択しました。
超お手軽にFirestoreに全文検索を導入できるSearch with Algoliaを試してみた
デプロイ
Firestoreのセキュリティルールやインデックスの設定、静的ファイルのホスティング、Cloud Runのデプロイ、Cloud Functionsのデプロイ等は、それぞれデプロイスクリプトを作成して対応しました。
これらのスクリプトはデプロイ先のGCP環境を引数で受け取る仕様になっていますが、firebaseコマンドやgcloudコマンドでは対象となるプロジェクトを明示しないと思わぬ事故が発生するので、複数環境を使っている方はなるべく早めにこういったスクリプトを作っておいた方がよいと思います。
まだCI/CDパイプラインは組み込んでいませんが、将来的にはGitHub Actionsを使って環境ごとに自動デプロイを実行できるようにする予定です。
全体的な感想
Nuxt.jsは比較的簡単だと言われていますが、TypeScriptとComposition APIを組み合わせる方式に関しては、これらを包括的に学習できる初心者向けの教材や公式ドキュメントが現時点で存在しないため、フロントエンド弱者の方にとっては結構手強い技術スタックだと思います。(TypeScriptやComposition APIを使わない場合は比較的すんなり進めるかもしれません)
また、Firebaseは確かに便利な開発プラットフォームなのですが、少なくともFirestoreは世間一般で言われるほどには簡単ではありません。
特に「ユースケースごとにセキュリティルールを個別に設定する必要がある」という点は、サービスの機能要件が増えてくると確実に開発のボトルネックになってしまうので、サービスがヒットして要件が膨れ上がってきた時には、観念してバックエンド + RDBという一般的な構成に入れ替える方が賢明だと思います。
(私のFirestoreに対するイメージは「ユーザー数の増加に対してはスケーラブルだが、ユースケースの増加に対してはスケーラブルではない」という感じになります)
最後に
私は「キャリアは横と上にバランス良く伸ばしていくのが良い」と考えています。
ここ数年は、インフラのアーキテクチャ設計あるいは技術顧問や開発内製化アドバイザーなど、技術的上流業務をご依頼されるケースがおかげさまで増えてきました。しかしそういった業務では高単価はいただけるものの、そればかりやっていると自分の技術スタックがどんどん陳腐化&レガシー化していってしまうというリスクがあります。
技術的上流業務で高単価を獲得し続けるためには、その基盤となる「手を動かすエンジニアとしてのスキル」をしっかりキープしてトレンドにキャッチアップして幅を広げ続けることが大前提になるので、今回の個人開発チャレンジはそういう意味で非常に良い取り組みだったかなと自己評価しています。
来年は、このサービスのスマホアプリ版をFlutterで作成してみようかなと考えていますが、こんな感じで常に「長期的利益」を重視して、目先のマネタイズに惑わされず、キャリアを横と上にバランス良く伸ばし続けられるような意思決定を心がけていきたいなと思っています。