本書は@pospomeがサーバサイドアーキテクチャのあれこれに関してまとめたものです。
本書に記載された内容は、情報の提供のみを目的としています。したがって、本書を用いた開発、製作、運用は、必ずご自身の責任と判断によって行ってください。これらの情報による開発、製作、運用の結果について、著者はいかなる責任も負いません。
筆者が今までに執筆した書籍を紹介します。ここで紹介されている書籍はすべてBOOK TECHにて購入することができます。それぞれの書籍に関連性はないので、興味のあるものだけ手にとっていただくことも可能です。
第1弾目の書籍です。サーバサイドのアプリケーションアーキテクチャにおける"レイヤ構造"について体系的にまとめました。"Controllerパターン"から"クリーンアーキテクチャ"まで様々な実装が載っていますが、筆者がオススメするのは第4章の"詳細リポジトリパターン"です。ネット上にあるリポジトリパターンの説明に比べて1歩踏み込んだ内容になっています。詳細はこちらを確認してください。
第2弾目の書籍です。サーバサイドのアプリケーションにおける"デプロイ"についてまとめました。テクノロジースタックは"Go+Docker+CircleCI+GKE+Spinnaker"です。GoogleとNetflixが共同開発したCDツール"Spinnaker"についてまとめてある数少ない書籍の1つです。詳細はこちらを確認してください。
第3弾目の書籍です。いくつかの実装パターンをまとめたTips集です。それぞれの章は独立しているので、興味のあるところだけ読み進めることができます。筆者がオススメするのは第4章の"詳解仕様パターン"です。DDDにおいてマイナーながらも強力な仕様パターンについて詳しく説明しています。第1弾目の書籍"pospomeのサーバサイドアーキテクチャ"のようなアプリケーションアーキテクチャに興味のある方にはオススメです。詳細はこちらを確認してください。
筆者は"pospomeのサーバサイドアーキテクチャ"でGo言語を例にWebアプリケーションにおけるレイヤ構造について解説しました。この書籍を読むことでWebアプリケーションのレイヤ構造に対する理解が深まった方がいるのであれば、筆者としてはとても嬉しいことです。
しかし、その一方でレイヤ内のパッケージ設計に対する意識が薄くなってしまうのではないかという懸念もあります。さきほどの書籍でも言及しているのですが、レイヤ構造は機械的なテクニックなので、それを知ってしまえば誰でも再現できてしまいます。そのため筆者はレイヤ構造の設計スキル自体にあまり価値はないと思っています。コードの品質において重要なのはレイヤ化ではなく、レイヤ内の具体的な実装です。クリーンアーキテクチャを採用するだけで満足してしまうと、クリーンアーキテクチャを採用しながらも可読性の低いコードに悩まされる可能性が高くなってしまいます。最悪の場合、可読性が低いことにすら気づかずにコードを書き続けてしまうこともあるでしょう。設計はパターン化できない部分をどのように設計するかが重要であり、エンジニアとしてのスキルが問われるところです。
本章ではレイヤ内のパッケージ構成が少しでも良いものになるように、パッケージ設計に関する基礎的な部分を説明しています。内容としては筆者が普段意識していることになるので、多少の間違いや一般的ではない観点もあると思いますが、一つの例として参考にしていただけると嬉しいです。
説明するまでもないと思いますが、パッケージには階層が存在します。リスト2.1はGo言語の標準パッケージであるnetパッケージのパッケージ構成です。netの下にhttp,mailといったパッケージが存在しており、パッケージ内は階層構造になっています。
パッケージ階層は深くなればなるほど、パッケージが持つ責務の抽象度が下がります。例えばリスト2.1のパッケージ構成ではnetの1つ下の階層にhttp,mail,smtpなどが存在しますが、これはnetという概念よりもhttp,mail,smtpという概念の方が抽象度が低い(より具体的な概念である)からです。
深い階層にあるパッケージの責務は、そのパッケージよりも浅い場所にあるパッケージが持つ責務領域に限定されます。リスト2.2はECサイトのパッケージ構成をイメージしたものです。cart,searchという2つのパッケージの下に同名であるitemというパッケージが存在します。
これらのitemパッケージはそれぞれ親となるパッケージの責務領域に限定された責務を持ちます。例えば、cartパッケージの下にあるitemパッケージはECサイトの買い物かごの商品に関する責務を持ちます。買い物かごの商品なので、購入する商品の個数を指定する実装が含まれるでしょう。一方、searchパッケージの下にあるitemは検索画面に表示される商品に関する責務を持ちます。検索画面に表示する商品なので、買い物かごのように購入する商品の個数を指定する実装は含まれないかもしれません。それぞれitemというパッケージ名こそ同じですが、cart,searchという異なる親パッケージの責務領域に依存する責務を持ちます。
そしてcart,searchという2つのパッケージが同じ階層に存在するのはそれらの抽象度が同等だからです。"買い物かご機能","検索機能"という機能ベースのパッケージとして設計されているという意図を汲み取ることができます。
パッケージが持つ責務の抽象度とパッケージ階層の関係性はパッケージ設計において基本となる要素です。おそらく誰もが意識している点でしょう。しかし、チーム開発では人によって階層を切る際の粒度が違う可能性があるので、ある程度大きいアプリケーションになると全体のパッケージ階層を正しく保つことが難しくなってきます。そのため開発の最初の段階で"どのような粒度でパッケージの責務を定義し、それを階層に落とし込むのか"という方針を決め手おき、パッケージ構成からその意図を汲み取れるようにしておくとよいでしょう。仕様の増減や人の入れ替わりによってパッケージ構成の意図は次第に汲み取りづらくなっていくので、定期的にパッケージ構成全体の粒度を見直すことも必要です。
パッケージ階層は深くなればなるほどパッケージが持つ責務の抽象度は下がりますが、実際にパッケージ構成を考えていると抽象度を比較することが難しいものもあります。ここでは実装の種類という観点で抽象度の比較が難しいパッケージ設計について説明します。
実装の種類はざっくりと以下の3つに分類できます。
"開発対象独自の実装"というのは、例えばソーシャルゲームにおける対戦機能です。対戦機能というのはソーシャルゲームならではの機能であり、当然ながらECサイトに対戦機能は不要でしょう。対戦機能にしても、ソーシャルゲームそれぞれ特徴があり、全く同じ対戦機能というのは少ないはずです。開発対象独自の実装というのは、競合プロダクトとの差別化を図れる部分なので、企業として注力する開発領域になるでしょう。プログラミング言語が標準で提供していることはないですし、ライブラリとして提供されていることもないでしょう。自分たちで実装するしかない領域です。
"横断的関心事に対する実装"というのは、実装において横断的に利用される実装です。例としてはログ出力、エラーハンドリング、トランザクションなどがあります。これらの実装は実装対象に限定されず、あらゆる箇所で利用されます。このようにあらゆる箇所で利用される実装のことを横断的関心事に対する実装と呼びます。横断的関心事に対する実装はプログラミング言語が標準で提供していたり、ライブラリ化しやすいという特徴を持ちます。そのため、わざわざ自分たちでゼロから実装することは少なく、開発対象独自の実装とは正反対の性質を持ちます。
注意しなければならないのは例として挙げたログ出力が常に横断的関心事になるとは限らない点です。例えばログ出力のライブラリを実装する場合、ログ出力というのは横断的関心事ではなく、開発対象独自の実装になります。これはログ出力のライブラリとして他のライブラリと差別化を図ることができるからです。トランザクションに関しても開発対象がデータベースであれば、他のデータベースとの差別化を図ることができる重要な実装となるでしょう。大まかな傾向はありつつも、横断的関心事に対する実装とみなせるかどうかは都度判断する必要があります。
横断的関心事に対する実装はそれぞれが異なる責務を持ち、独立性が高い実装になることが多いので、それらのパッケージ同士の抽象度は"同程度である"と判断することが多いように思えます。そのため横断的関心事に対する実装はそれぞれのパッケージをフラットに展開するという方針が一般的なように思えます。リスト2.3はlog,handler,errorsという横断的関心事に対する実装をフラットに展開したパッケージ構成の例です。
"その他"というのは開発対象独自の実装でもなく、横断的関心事に対する実装でもない実装です。これに該当するものはあまり多くないと思うのですが、有名なものだとWebサービス実装におけるレイヤ構造(クリーンアーキテクチャなど)がこれに当たります。レイヤ構造は実装をいくつかの種類に分類し、それらの依存関係を整理したものです。ユースケースレイヤのパッケージやドメインレイヤのパッケージは"その他"に分類されます。
これらのパッケージを横断的関心事に対する実装に分類することができるケースもあるのですが、例えばレイヤ構造におけるドメインレイヤの配下にはソーシャルゲームの対戦機能のような開発対象独自の実装が存在します。インフラストラクチャレイヤの配下には横断的関心事に対する実装が存在するかもしれません。開発対象独自の実装や横断的関心事に対する実装に該当しないものが存在する可能性があるので、"その他"という分類を定義しています。
実装の種類には以下の3つがあると説明しましたが、これらの実装に対してパッケージが持つ責務の抽象度という観点でパッケージ設計をしようとすると、それぞれの抽象度の比較が難しくなってしまいます。
例えば、ソーシャルゲームの対戦機能とエラーハンドリングでは、どちらの方が抽象度が高いでしょうか。なんとなくエラーハンドリングの方が抽象度が高い気がするかもしれません。しかし、ものによっては上手く結論を出すことができないと思います。これはそもそも比較対象となる実装の種類が異なるからです。例えばソーシャルゲームの対戦機能は"開発対象独自の実装"であり、エラーハンドリングは"横断的関心事に対する実装"です。パッケージ設計では単純な比較が難しい場合を考慮し、最初にこれらをどのような方針で並べるかを決める必要があります。いきあたりばったりで1つ1つのパッケージ設計を考えると、設計の意図を失ってしまうことになるでしょう。
実装の種類が異なるパッケージを配置する場合、基本的にはフラットに配置するとよいでしょう。これは"比較できない = 責務としては他のパッケージと同程度の抽象度を持つ"という考え方です。例えばリスト2.4のようにエラーハンドリングのパッケージであるerrors、対戦機能のパッケージであるbattleをフラットに配置する設計は比較的一般的ではないでしょうか。
実際にエラーハンドリングは様々な場所で利用される汎用的なパッケージであり、パッケージが持つ責務の抽象度としては比較的高くなる傾向にあります。そのため、他のパッケージとフラットに配置するというのは妥当な選択であるように思えます。
一方でエラーハンドリングが特定の領域に限定される責務である場合、特定パッケージの配下に配置するのがよいでしょう。リスト2.5は対戦機能でのみ利用されるエラーハンドリングの実装として、battleパッケージの下にerrorsパッケージを配置した例です。
battleパッケージの下にerrorsパッケージを配置することで、errorsパッケージの実装が対戦機能に関連するものであることを示しています。もしerrorsパッケージがbattleパッケージ以外のパッケージで利用されると、errorsパッケージが対戦機能に関係ない実装を抱えてしま、最終的にパッケージが肥大化してしまう可能性があります。実装者は"battleパッケージの下にerrorsがある"という構成からこのような意図を読み取れるようにしましょう。
"その他"に分類されるレイヤ構造のパッケージ設計についても考えてみましょう。レイヤ構造のパッケージであるlayerパッケージをlogパッケージ、handlerパッケージのような他のパッケージに並べてフラットに配置するとリスト2.6のようなパッケージ構成になります。
あきらかに問題となっている点としては、battleパッケージとhandlerパッケージがあるでしょう。battleパッケージは明らかにlayer/domainの中に存在するべきパッケージですし、handlerパッケージはlayer/interfaceの中に存在すべきです。パッケージの抽象度が比較できない場合はそれぞれパッケージをフラットに配置すると説明しましたが、リスト2.6のように何も考えずフラットに配置するとおかしなパッケージ構成になってしまいます。それぞれのパッケージに対して"xxxのようなパッケージはyyyの直下にフラットに置く"という方針が必要になります。ものによっては悩むことがあるでしょう。例えばlogパッケージをlayer/infraの中に置くか、socialgameパッケージ直下に置くかは意見が分かれるかもしれません。筆者は最初にこういった方針を明確にしてから実装を進めることが多いです。当然その方針が間違っている可能性はありますが、間違いに気づいた場合はそのタイミングでパッケージ構成を修正すればいいだけなので、最初はあまり悩まず「とりあえずやってみよう」という程度で先に進んでしまうのがよいと思っています。設計の正しさは要件によって変わっていくので、ある意味結果論です。最初から100%正しい姿を目指すのではなく、要件の変化によって妥当な姿に修正していくという柔軟性のある考え方の方がよいでしょう。
パッケージ設計をするときに特定の実装をパッケージとして切り出すかどうかを悩むことはないでしょうか。特定の実装をパッケージとして切り出すというのはリスト2.7のように商品に関する実装をitemパッケージに切り出すというケースです。
パッケージの切り出し方は様々です。リスト2.8のようにcartパッケージに対してフラットに切り出すこともあるでしょう。
一方ファイルとして定義するというのはリスト2.9のようにitem.goとして既存のパッケージにファイルとして配置するケースです。
実装をパッケージとして切り出すか、それともファイルとして定義するかの基準についてですが、筆者は実装量、アクセス制御、循環参照を基準にすることが多いです。
実装量が少なく、単純な実装であればリスト2.9のようにファイルとして定義します。実装量が多い場合はリスト2.7やリスト2.8のようにパッケージに切り出します。実装量が多い場合は複雑な実装になっている傾向があり、構造体や関数の数も多くなっていることがあります。そのため、それらの構造体や関数に対して適切なアクセス制御をすることで、そのパッケージの利用者に実装の使い方を明確に提示できたり、触ってほしくない関数や構造体を隠すことができます。実装量が少ない、多いの判断基準については「2.4 パッケージの大きさ」にて説明しています。
実装量が少なくても特定の実装に対してアクセス制御したい場合はパッケージに切り出します。アクセス制御の観点は実装量の観点と似ています。実装量が多いと自然とアクセス制御したい実装も増えてくるでしょう。
アクセス制御すべきかどうかの基準は実装者の好みや考え方によって変わるので、判断が難しい部分はありますが、筆者は利用される側と利用する側が明確に別れている場合に実装を切り出すことが多いです。例えば、MVCにおけるモデルとコントローラーは明確に利用する側と利用される側が別れている典型的な例です。具体的にはコントローラーがモデルを利用します。そのため、コントローラーとモデルはそれぞれ別のパッケージにして、モデル側で実装に対する適切なアクセス制御ができるようにします。
循環参照は実装をファイルとして定義したときに発生することがあります。例えばリスト2.10のようなパッケージ構成です。
リスト2.11のようにcartパッケージのcart.goがpaymentパッケージに依存しており、paymentパッケージがitem.goに依存している場合、cartパッケージとpaymentパッケージで循環参照が発生してしまいます。
これを避けるには例えばリスト2.12のようにitem.goをitemパッケージとして切り出し、cartパッケージとpaymentパッケージをitemパッケージに依存させるように修正する必要があります。
このような単純な修正で循環参照を解消できればいいのですが、循環参照になる場合は広い範囲で適切なパッケージ構成になっていない可能性があります。問題となっている部分だけを修正するのではなく、パッケージ構成全体を見直した方がよいでしょう。循環参照の解消については「2.5 循環参照の解消」でも取り上げています。