早いもので、筆者が初めてWebアプリケーション開発に従事してから15年くらいが経とうとしている。15年間のエンジニア生活はもちろん楽しいことばかりではなかったが、今でも毎日が新しい学びと挑戦にあふれている。
筆者はこれまで職業エンジニアとして、いくつかの会社でいくつものWebアプリケーション開発に従事してきた。どのWebサービスも、未来永劫開発が続けられるという期待のもとに開発されているのだ。現実の世界は非情であり、すべてのビジネスが成功して成長し続けるということはない。成功して何年も続くビジネスもあるだろうし、数年で撤退するビジネスもある。それでも(あるいはそれだからこそ)、一度開発したアプリケーションはいつまでも保守・運用され続けると信じられている。
そしてある日突然、Webアプリケーションの寿命が思っていたよりも短いことを認識させられる。フレームワークのEOL、蓄積した技術的負債による開発スピードの目に見える鈍化、採用技術がトレンド落ちしたことによってエンジニアが採用できなくなるなど、きっかけは様々だがとにかくその日はやってくる。
いま、我々は「ビジネスが成長するに伴って、Webアプリケーションもずっと成長し続ける」という期待が幻想であることを知っている。Webアプリケーションにも電化製品と同様に耐用年数があり、せいぜい5年から10年くらい生き延びればいいほうだ。ろくに考えられていないアーキテクチャのもとに構築されたWebアプリケーションは、もっと早く腐敗するだろう。
こうした環境的な背景もあり、ビジネスの要求に答え続けながら、古くなった部分を高速に作り直すことができる、変化に強いシステム設計の価値が高まっている。変化に強い設計は、疎結合・高凝集なモジュールを適切に組み合わせることによって実現できる。近年のマイクロサービスアーキテクチャやクリーンアーキテクチャのようなアーキテクチャ設計は、大きくて複雑な問題をうまく分割統治することに挑戦している。
この本では、そのようなアーキテクチャに基づいて構築されたWebアプリケーションを「捨てやすいWebアプリケーション」と呼ぼうと思う。捨てやすさを評価する方法はいろいろ考えられるが、大きいものや複雑に絡み合ったもの、中身のわからないものが総じて捨てにくいことに異論はないだろう。
NuxtとFirebaseを本書の題材に選んだ理由は、これらの技術が捨てやすいWebアプリケーションを実装するために必要な要素を備えているからである。Vueのリアクティブの概念やComposition API、Firebaseのイベントドリブン設計やサーバレス特性などは、どれも欠かせない要素である。
本書の目的は、Nuxt 3とFirebaseを用いたWebアプリケーション開発を通じて、以下に挙げるスキルを獲得することである。
・Nuxt 3やFirebaseの基本的な使い方に習熟する
・プログラミング原則に対する理解を深める
・Webアプリケーションの設計ポリシーを自分で構築できるようになる
本書の内容は、VueやNuxtで簡単なアプリケーションを実装したことがあるエンジニアを想定して記述している。Nuxt 3で追加された機能に関する項目を除き、Vueの基礎的な知識に関する詳細な説明は省略した。
Firebaseに関しては詳細に理解している必要はない。Nuxtで開発したアプリケーションをWeb上で公開するためになんらかのホスティングサービスが必要、ということを知っているレベルであれば差し支えない。
この本は、3つの部に分かれている。
第1章から第3章では、捨てやすいWebアプリケーションを設計するための前提となるプログラミング原則を紹介する。
この本では、ここで説明した内容に基づいて設計の良し悪しを評価する。前提となる知識をすり合わせるため、ぜひ一度目を通してほしい。
第4章から第6章では空のリポジトリーにプロジェクトを作成して、NuxtアプリケーションをFirebase上に公開するまでの手順を解説する。開発環境のセットアップや自動デプロイの設定など、何もないところから始めると意外と難しい作業を丁寧に説明している。
第7章から第11章ではNuxtアプリケーションの全体的な設計について説明する。Nuxtアプリケーションの基本的な構成要素であるコンポーネントやコンポーザブルの設計に際して注意すべきポイントや現時点で筆者が考えるベストプラクティス、捨てやすさを維持するための規約などに触れている。
サンプルコードは、TypeScriptやVueを用いて記述されている。そのまま書き写しても動作するようになるべく配慮したが、説明にあたり本質的に関係のない部分は省略している。
export const useAsyncData = () => {
const { asyncData, pending, error } = useAsyncData<DataType>({ /* ...... */ })
return { asyncData, pending, error }
}
SOLIDとは、オブジェクト指向プログラミングの世界においてメンテナンス性の高いソフトウェアを作るために守るべき、5つの原則の頭文字を集めたものである。2000年ごろにRobert C. Martin氏が中心となって世に出した論文の中で言及されたものが最初と言われている。
それ以前のオブジェクト指向プログラミングの成果物でSOLIDの原則を守っているものがあれば、それは奇跡か天才の所業といって差し支えない。すでに発表から20年が経過しているものの、現代において新しく生み出されるコードにおいてもこれらの原則が守られていないことがしばしばある。
変化に強いアプリケーションを構築するには、アプリケーションを構成する個々のモジュールも変化に強くなくてはならない。SOLIDの諸原則は変更しやすい・再利用しやすいモジュールを開発するための有力な指針であり、近代的なアプリケーション開発においても必須の概念である。
Robert C. Martin氏の近年の言説1によれば、単一責任の原則は次のように説明されている。
モジュールはたったひとつのアクターに対して責務を負うべきである。
名称から勘違いされがちだが、単一責任の原則は、あるモジュールがひとつの機能性をもつようにせよ、ということを意味しているのではない。言おうとしていることはむしろ逆であり、次のふたつの観点にしたがって機能性を凝集させることを意味しています。
・ひとつの変更要求に対して修正しなければならないモジュールがひとつになるようにする。
・ひとつのモジュールに対して変更を発生させる原因が複数存在しないようにする。
なんらかの変更を行うときに修正しなければならないモジュールが複数ある場合や、複数の異なる利害関係者(アクター)の修正要求をかなえるためにひとつのモジュールに対して修正が必要になる場合は、いずれも単一責任の原則に反している。
最も簡単にこの原則を破る方法は、現実の事物をそのままモデル化してしまうことだ。たとえば、ユーザーとサービス提供者が利用するプラットフォームサービスにおいて「1人のユーザーを表すモデルとしてUserクラスを作り、現実世界の人物とアプリケーション上のオブジェクトを1対1で対応させる」ような設計をよく目にする。このような設計では、Userクラスはユーザーに起因する変更要求とサービス提供者に起因する変更要求の両方に対応しなければならないから、単一責任の原則を守ることは難しい。
Userクラスはアプリケーションの利用者から見ればユーザー(サービス上における人格)だが、サービス提供者から見れば顧客である。したがって、Userクラスとは別にユーザーの顧客としての側面を表すCustomerクラスを用意する必要があるだろう。現実世界では同一のオブジェクトであっても、アプリケーション上ではUserとCustomerは振る舞いも属性も異なる別の概念として表現することが必要だ。現実のオブジェクトが同一だからといって、システム上でもひとつの方法で表現しなければならないというわけではない。
異なる性質のものをシステム上で同一のモジュール(クラス)に抽象化してしまうと、そのモジュールは利用者とサービス提供者というふたつのアクターに対する責務を負う。「現実世界ではひとつのオブジェクトであったとしても、それを表現するシステム上のモデルはアクターに応じてそれぞれ異なる」というような設計ができるようにならなければならない。もし複数のモデルが共通の性質をもつのであれば、インターフェースや抽象クラスを用いた抽象化が有効に働く。
単一責任の原則を守っているコードは、変更を行ったときの影響範囲の特定が容易になる。これは単に改修コストが低くなるだけでなく、改修に伴うテストの範囲を限定したりコードレビューの負荷を下げることにも繋がる。総じて、変更スピードと変更に携わる人の心理的安全性を向上させることができるだろう。
開放閉鎖原則はソフトウェア・エンティティーは拡張に対して開いており、修正に対して閉じているべきであるという原則である。「機能追加するときは新しいクラス(や関数etc...)を追加することによって実現し、(バグ以外の理由で)既存のコードを改修するな」というような説明をよく見かける。これを見ると現実的ではないことを言っているように見えるが、開放閉鎖原則の本質的なメッセージは次の3点に集約される。
・単一責任の原則に基づき、適切なモジュール分割を行う。
・モジュール間の依存関係は階層構造を成すようにする。
・階層構造における上位のモジュールが、下位のモジュールに対する変更の影響を受けないようにする。
この原則に従うことで、既存のコードを修正することなく機能の拡張ができるように拡張性が担保される。機能追加時に余計な副作用を気にする必要がなくなり、品質保証にかかるコストを削減できる。その結果として、変更に強いシステムの実現が一歩近づく。
残念ながら、新しいコードを書くときに初めからこの原則を守ることは非常に難しい。実装上はインターフェースや継承をうまく使うと開放閉鎖原則を守れるということに、あらかたコードを書き終わってから気づく。保守性の高いコードを継続的に書こうとするなら、開放閉鎖原則に従うためのリファクタリングを行う余力を常に確保したい。
また、将来に発生するであろう変更を(それが起こるかどうかを含めて)完全に予測するのは不可能である。厳密に開放閉鎖原則を守ろうとすることにこだわるのではなく、エッセンスを理解して適用し続ける努力をすることが重要だ。適切なコードの分割と一貫した制御フローの設計、さらにはインターフェースを利用した抽象化などの技法を用いて、依存関係が正しく階層化されていることを確認しながらコーディングしよう。
リスコフの置換原則とは、「S型がT型の派生(サブクラス)であれば、プログラム内でT型のオブジェクトが使われている場所は、すべてS型のオブジェクトで置換可能でなければならない」というものである。SOLIDの諸原則の中で最も守られなかった原則をひとつ選ぶなら、このリスコフの置換原則になるだろう。
Webアプリケーション開発で広く使われていたMVCフレームワークでは、フレームワークの用意した基底クラスを継承して、モデルやコントローラを実装する方式がよく採用されている。これ自体が直ちにリスコフの置換原則に違反しているわけではないが、結果として継承の不適切な使い方を世に広く拡散したことは間違いない2。
class BaseController {
// 共通機能が詰め込まれた『便利な』基底クラス
}
class MyPageController extends BaseController {
// マイページで使う機能...
}
class SignInController extends BaseController {
// サインインページで使う機能...
}
リスト1.1は、MVCフレームワークでよく見るコントローラの継承の例だ。基底クラスであるBaseControllerにはさまざまなページで使える共通機能を詰め込み、個別のページで必要なロジックはBaseControllerを継承したクラスに実装する。
このような設計では、そもそもBaseControllerの派生クラス(MyPageControllerやSignInController)の置換可能性が議論されることはないだろう。というよりも、置換不可能であることがほぼ自明である(マイページを表示する際にSignInControllerを利用することはできない)。ユースケースと派生(具象)クラスが強く結びついており、もはやアプリケーション内でこれらの派生クラスをBaseControllerとして参照できる部分は残らないだろう。
抽象クラスとして参照することができないのであれば、そもそも基底クラスを継承することそのものの意義が怪しくなってくる。複数の具象クラスに共通の機能性を持たせるためのテクニックとしては、継承ではなく委譲を使うほうが適切だ。つまり、MVCフレームワークのコントローラはリスト1.2のような実装をするほうが優れている。
// 基底クラスは継承しない
class MyPageController {
private baseController: BaseController
// 代わりに依存性注入を行う
constructor(baseController: BaseController) {
this.baseController = baseController
}
// マイページで使う機能...
}
継承に話を戻そう。「抽象に安心して依存できるように派生を作成せよ」というのが、リスコフの置換原則の主旨だ。変化に強いアプリケーションを構築するには、注意深く依存関係と結合度・凝集度をコントロールしなければならない。抽象クラス(あるいはインターフェース)を用いた依存の締めつけは、結合度を低く抑えるために必要不可欠な手段である。派生クラスが置換可能性を損なえば、依存モジュール(クライアント)はその派生クラスの拡張された部分に依存せざるをえなくなる。抽象クラスは置換可能性を失い、抽象として参照することはできなくなるだろう。
一度でも置換可能性を無視した継承をアプリケーションコードに導入してしまえば、その過ちを正すことは非常に困難な作業になる。MVCフレームワークの時代からWebアプリケーション開発に従事しているエンジニアは、悲しいことにこの不適切な継承に慣れ親しんでしまっている。共通機能を利用するための継承の便利さに抗うのは難しい。強い意志を持って、継承を正しく使わなければならない。
インターフェース分離の原則は「あらゆるクライアント(ソフトウェア・エンティティーの利用者)は自身が利用しないメソッドに依存することを強いられてはならない」という原則だ。クライアントが常に最小のインターフェースにのみ依存している状態を担保せよ、と言い換えることもできる。依存する範囲が最小になるように、インターフェースを分離することでこの原則に従うことができる。
リスト1.3のような一般的な従業員を表すクラスEmployeeがあるとしよう。Employeeは人間なので、仕事をしたり休憩したり不満を言ったりする。
class Employee {
// 仕事をする
work() {
// .......
}
// 休憩する
rest() {
// .......
}
// 不満を言う
complain() {
// .......
}
}
Employeeを利用する立場(=クライアント)である上司は、Employeeを働かせる能力を持つ。
class Boss {
// 社員を働かせる
manage(employee: Employee) {
employee.work()
}
}
上司は従業員に働いてもらえさえすればよく、従業員のworkメソッドにのみ依存している。しかし、Employeeクラスはrestメソッドとかcomplainメソッドとか上司から見て余計な公開メソッド(インターフェース)も持っている。これが「クライアントが利用しないメソッドに依存している」といわれる状態だ。
利用しないメソッドへ依存している関係では、クライアントがどのように依存先のクラスを利用しているかが(実装の詳細を調べてみるまで)わからないという問題を引き起こす。上司クラスは従業員クラスのすべての公開インターフェースを利用することができるため、なんらかの機能追加に際して、ある日突然restメソッドやcomplainメソッドを呼び出すようになるかもしれない。従業員クラスの側を改修しなければならなくなったときには、すべての依存関係でどのような利用のされ方をしているかを慎重に調査しなければならない。システムの規模が大きくなればなるほど必要となる調査コストは膨れあがり、改修のスピードが劣化してしまう。
では、インターフェース分離の原則に従ってこの問題を解決しよう。上司は従業員に仕事をしてほしい(workメソッドにのみ依存している)ので、仕事をする能力だけをもつWorkerインターフェイスを作る(この操作がインターフェース分離である)。上司クラスはEmployeeではなく、Workerに依存するように修正する。
interface Worker {
work(): void
}
class Boss {
manage(worker: Worker) {
worker.work()
}
}
そして、従業員クラスはWorkerインターフェースを実装する。
class Employee implements Worker {
work() {
//......
}
rest() {
//......
}
complain() {
//......
}
}
上司はWorkerを実装しているオブジェクトであれば、なんでも働かせることができるようになると同時に、従業員が他にどんなインターフェースを公開しているかとは関係がなくなった。従業員のWorkerとしての側面しか参照しないことが担保されているため、Workerの機能ではないrestメソッドやcomplainメソッドを修正しても、上司クラスに対する改修は発生しないことが担保される。
さらに、将来的に休憩もしないし不満も言わないエリート従業員(EliteEmployee)が入社してきても、EliteEmployeeクラスがWorkerを実装してさえいれば、上司クラスには修正を入れることなくこれまでの従業員と同様に扱うことができる。
class EliteEmployee implements Worker {
work() {
//......
}
}
もしWorkerというインターフェースが用意されてなかったならば、上司クラスに対してエリート従業員クラスを利用できるようにする修正が必要になる。上司クラスは従業員クラスとエリート従業員クラスにそれぞれ依存することになり、依存関係の複雑度が増す。
インターフェースを分離することで、複雑な依存関係が発生するリスクを前もって排除しよう。現時点で問題が起こっていないからよいとするのではなく、よく考えられた設計により将来起こりうる問題の芽を未然に摘み取るのだ。
依存性逆転の原則とは、大雑把に言うと「上位レイヤーのモジュールは下位レイヤーのモジュールに依存してはならない」ということを述べている原則である。ここでいう依存とは、フロントエンド開発でよく使われるTypeScriptで言うならば、import文にあたる。つまり、上位モジュールは下位のモジュールから何かをimportしてはいけないという意味になる。
依存性逆転の原則を遵守するコードでは、上位モジュールは下位モジュールからimportするかわりに、下位モジュールに対して抽象(インターフェース)をexportする。下位モジュールはこの抽象をimportし、上位モジュールの要求に答えるための具象を実装する。
Vue 3の世界では、Composition APIのprovideとinjectを用いた依存性注入によって依存性逆転を行うことができる。ここでは、上位モジュールとして現在ログインしているユーザーを提供する関数を例に考えよう。
依存性を逆転させる前の処理フローと依存の方向が一致しているコードはリスト1.8ならびにリスト1.9のようになる。上位モジュールはユーザーのデータを取得するために、下位モジュールが提供する関数をimportして呼び出す。
import { fetchCurrentUser } from '~/composables/service'
export const useCurrentUser = () => {
const currentUser = await fetchCurrentUser()
return { currentUser }
}
下位モジュールには、何らかの方法でユーザーのデータを取得する具体的な処理が記述される。
type User = {
displayName: string
}
export const fetchCurrentUser = async () => {
const currentUser = ref<User | null>(null)
// currentUserを取得する具体的な処理
currentUser.value = await $fetch(/* ...... */)
return currentUser
}
依存性逆転を適用すると、上位モジュールはリスト1.10、下位モジュールはリスト1.11のようになる。Userがどんなものかを決めるのは上位モジュールになり、下位モジュールからのimportはもはや必要なくなった。
import { ref, inject } from '#imports'
import type { Ref, InjectionKey } from 'vue'
type User = {
displayName: string
}
type UserService = {
getCurrentUser: () => Ref<User | null>
}
// 依存性注入のためのキー
export const UserServiceKey: InjectionKey<UserService> = Symbol()
export const useCurrentUser = () => {
const service = inject(UserServiceKey)
const currentUser = service.getCurrentUser()
return { currentUser }
}
下位モジュールは上位モジュールからUserをimportし、依存性注入を用いてUserを取得するサービスを提供する。アプリケーションの初期化プロセスなどでこのprovideCurrentUser関数を呼び、上位モジュールが依存サービスを解決できるようにする。
import { User, UserServiceKey } from '~/composables/user'
import { ref, provide } from '#imports'
export const provideCurrentUser = () => {
// currentUserを取得する処理
const currentUser = ref<User | null>(null)
// ......
// 依存性の注入
const service = { getCurrentUser: () => currentUser })
provide(UserServiceKey, service)
}
上位モジュールが下位モジュールから何もimportするものがなくなったことにより、上位モジュールの再利用性が高まる。また、下位モジュールを比較的容易に入れ替えることができるようになる。
ここでは説明のためにVueのprovideとinjectによって依存性逆転を実装する例を示したが、現実的にはこの実装方式は余計なオーバーヘッドを生むだけだろう。Vueの依存性注入の仕組みには制約も多く、依存性逆転の原則を遵守する用途で使うにはやや力不足である。
依存性逆転の原則については、具象への依存を抽象への依存に変換するテクニックとして理解するとよい。特に、Vueコンポーネントがimportするモジュールに関しては、実装上の制約3により依存性を逆転することは難しい。コンポーネントからカプセル化して分割したビジネスロジックの実装に際して、抽象に依存できる可能性を模索するとよいだろう。