This page looks best with JavaScript enabled

Phoenixにおける現代的なアーキテクチャを考える

 ·  ☕ 10 min read  ·  ✍️ さんぽし

この記事はElixir Advent Calendar 2020の20日目、CAMPHOR- Advent Calendar 2020の18日目の記事です。

この記事ではDDDの思想を取り入れ、Phoenixの恩恵を受けられるようある程度のPhoenixの思想を残しながら、Phoenixデフォルトでの設計の辛さを改善するアーキテクチャを提案します。

Phoenixの生成するコードに関して

PhoenixはRailsのように自動でコードを生成し、基本的なCRUDの機能を何もせずとも構築してくれます。

実際にプロダクションレベルで使用するコードに自動生成のコードをそのまま使用することは少ないかもしれませんが、似たようなモジュールの構成で開発を行なっている人も少なくないのではないでしょうか

Phoenixが実際に生成するコードの一部を見てみましょう。
知っとるわい!という方は適当に読み飛ばしてください

mix phx.gen.jsonを用いてCRUDを行うAPIを生成します

$  mix phx.gen.json Accounts User users name:string age:integer
* creating lib/sample_web/controllers/user_controller.ex
* creating lib/sample_web/views/user_view.ex
* creating test/sample_web/controllers/user_controller_test.exs
* creating lib/sample_web/views/changeset_view.ex
* creating lib/sample_web/controllers/fallback_controller.ex
* creating lib/sample/accounts/user.ex
* creating priv/repo/migrations/20201129141729_create_users.exs
* creating lib/sample/accounts.ex
* injecting lib/sample/accounts.ex
* creating test/sample/accounts_test.exs
* injecting test/sample/accounts_test.exs

Add the resource to your :api scope in lib/sample_web/router.ex:

    resources "/users", UserController, except: [:new, :edit]


Remember to update your repository by running migrations:

    $ mix ecto.migrate

簡単のために生成されるコードのうち/(userの一覧を返すエンドポイント)の処理に関わる部分のみを抜粋して処理の流れを追ってみましょう

controllerでは以下のようにlist_users/0の関数を呼び出しています、明らかにuserの一覧が返ってきそうな関数ですね

defmodule SampleWeb.UserController do
  use SampleWeb, :controller

  alias Sample.Accounts
  alias Sample.Accounts.User

  action_fallback SampleWeb.FallbackController

  def index(conn, _params) do
    users = Accounts.list_users()
    render(conn, "index.json", users: users)
  end
end

controllerで呼び出している関数は以下のモジュールのものです

@docの通り、userの一覧を実際にDBから取得して返しています

defmodule Sample.Accounts do
  @moduledoc """
  The Accounts context.
  """

  import Ecto.Query, warn: false
  alias Sample.Repo

  alias Sample.Accounts.User

  @doc """
  Returns the list of users.

  ## Examples

      iex> list_users()
      [%User{}, ...]

  """
  def list_users do
    Repo.all(User)
  end
end

viewは以下のような様子で生成されています

controllerのrender/3によってuserの一覧がjsonになって返されていることだけを理解すればOKです。

defmodule SampleWeb.UserView do
  use SampleWeb, :view
  alias SampleWeb.UserView

  def render("index.json", %{users: users}) do
    %{data: render_many(users, UserView, "user.json")}
  end

  def render("user.json", %{user: user}) do
    %{id: user.id,
      name: user.name,
      age: user.age}
  end
end

Phoenixが生成するコードの内容はざっくりと理解できたのではないでしょうか、他のエンドポイントに関しても、DB操作→viewでjsonを生成の流れで処理が行われます

Phoenixの生成するコードの何がイけてないのか

SampleWeb.UserControllerSample.AccountsSampleWeb.UserViewの関数を直接呼び出しているため、依存しているといえます。

依存しているとどう言った問題が発生するでしょうか

ソフトウェアが密結合になってゆく

例えば、何かしらの理由でUserの情報をRDBではなく、NoSQLに挿入したくなったとしましょう

その場合、変更の必要のある範囲はSample.Accountsだけでなく、依存しているSampleWeb.UserControllerにも及びます。
「高々二つのモジュールの変更くらいええやん!」と思われるかもしれませんが、Sample.Accountsが同様に他のモジュールからも呼び出されていた場合、すなわち、他のモジュールからの依存も受けていた場合、当然ですがそのモジュールの変更も必要になります。

これはモジュールの依存により、密結合なアプリケーションになっているためです。

DBの種類の変更といった大きな変更に限らず、下位モジュールの変更のたびにそのモジュールを呼び出している(= そのモジュールに依存している)上位モジュールにも変更していくことは明らかに手間のかかることです。

また、Elixirは動的型付けの言語です。コンパイルのチェックが存在するとはいえ、モジュール外の関数のチェックなどは行われません。
これは、上記の変更を行う際の大きな辛さの要因となります。

controllerにロジックが存在する

現状のコードはSample.AccountsがDBの操作のみを行い、アプリケーションの中心となるロジックの部分はcontrollerに存在します。

これは、似たようなロジックが色々な関数に点在する原因となります。それにより、一つロジックを変更しようとなったときに同時に変更の必要がある部分が増加してしまいます。

また、極端な例ですが、GUIなアプリケーションからCLIなアプリケーションに変更する必要が生じた際、controllerに書いていたロジックの部分を丸々かき直す必要が出てきます

テストを行いづらい

前述の「controllerにロジックが存在する」と関連していますが、アプリケーションのロジックのチェックをcontrollerにて行う必要が出てきます。

また、controllerが直接Sample.Accountsを呼び出していることから、

  1. テストのためにテスト用のDBを用意する必要がある
  2. テストがfailした際にcontrollerが誤っているのか、controllerが呼び出すSample.Accountsの内容が誤っているのかを判断できない

と言う問題点があります

これらの改善を如何にして行うべきか

上記の問題達は以下を達成することである程度の解決になります

  • 関数の呼び出し単位で型のチェックを行う
  • **依存性の逆転(DIP)**を実現する
  • アプリケーションのロジックをcontrollerでは表現しない

一つ一つ詳細を見ていきましょう

関数呼び出し単位での型チェック

Elixirにはtypespecと言う仕組みが存在し、関数の引数と戻り値の型を定義することができます。これ自体にはドキュメント以上の働きはありません。
しかし、typespecはDialyzerと言う静的解析ツールを使用した際に大きく効果を発揮します。Dialyzerは静的解析を通して様々なことを確認してくれますが、その中でtypespecに準じた関数の使用が行われているかもチェックしてくれます。

この辺りの詳細は別の記事としてまとめているので参考にしてみてください。

ElixirでDialyzerを用いた静的解析を行い、型(Typespec)を最大限に活用する

DIPを実現する

依存性の逆転(DIP)とは「上位モジュールは直接下位モジュールに依存するのではなく、抽象に依存しようぜ」と言う考えです。ElixirではBehaviourと言う機能を利用することで実現が可能です。
こちらも以前に別の記事としてまとめているので詳しくはそちらを参考にしてください。

ElixirでBehaviourを使用してDIPを実現する基礎知識

また、このBehaviourを用いた設計を行うことの良い点として、DIするモジュールを変更することで、テストが容易に行えるようになるという面が存在します。

上位モジュールは下位モジュールに直接依存するのではなく、Behaviourに依存することになるため、その実体をテスト用のmockに入れ替えることが可能になります。そして、ElixirにはそういったBehaviourを元にmockを作成するためのmoxと言うライブラリが存在します。

moxに関しても別の記事として紹介する記事を書いたのでそちらを参照してみてください。

【Elixir】moxを使用してmock用いたテストを書く

これによって副作用が存在するモジュールと単にロジックの書かれているモジュールのテストを切り離すことができます。

上記の例で言うと、

  • controllerが持つロジックの正しさをcontrollerのテストで担保する
  • DB操作に関するロジックはSample.Accountsのテストで担保する

といった風にテストの切り分けを行うことができます。

controllerのテスト内ではテスト用のDBを用いるのではなく、Sample.Accountsのmockを使用することで、

  1. テストがfailした際にcontrollerが誤っているのか、controllerが呼び出すSample.Accountsの内容が誤っているのかを判断できない

といった問題も発生しません。

アプリケーションのロジック

アプリケーションのロジックはどこに書くべきなのでしょうか?

現状の構成だとcontrollerにロジックの中心が置かれていることになります。もしくは何も気にしていないプロジェクトだとSample.AccountsにあたるようなDB操作のモジュールにも散らばっているといった可能性もあるかもしれません。

「アプリケーションロジックを書く層」が現状存在しないことが原因であるため、「Usecase層」を作成しましょう。

Usecaseとはクリーンアーキテクチャーなどに登場する概念です。

上記の全てを実現してみる

以下にサンプルとなるリポジトリを作成しました

sanposhiho/phoenix-architecture-sample

最近個人開発で使っていたリポジトリです。途中で飽きて全然開発しなくなったのでそのままサンプルにしました

lib
├──tsundoku_buster
    ├── behaviour
         └── repository
    ├── database
    ├── schema
    └── usecase
├── tsundoku_buster_web
    ├── controllers
    ├── views

(アーキテクチャに関係する部分のみを抜粋)

behaviour
副作用のあるモジュール(Databaseの操作を行うモジュールなど)のbehaviourを定義します。

database
RDBの操作を行うモジュールを定義します

schema
schemaを定義するモジュールを定義します

usecase
アプリケーションのロジックを実現します。

また、アーキテクチャとは関係がないですが、testなどに加えて、dializerの確認をGitHub Actionsで行なっていたり、手元でサーバーを立ち上げる際などもMakeコマンドから立ち上げることで同時にdialyzerのチェックも行うように設計しています。

細かい部分で言うとlocalでの開発で使用するDBをdocker-composeで立てるなどのことも対応しています。

上記のアーキテクチャの問題点

このアーキテクチャは全てを解決したように思えますが、かなり大きな問題が残っています。

それはRDBの使用を前提とする必要がある点です。
理由はPhoenixのアーキテクチャ上でidに被りがないことや必要な項目が揃っているかどうかのチェックがEctoに依存しているためです。

上記のサンプルで言うところのこの部分ですね

これではDBにNoSQLを使用するなどの変更が加わった際に大きな変更を必要とされます。

そのため上記のサンプルのREADMEには

Phoenixにおいて、 RDBの使用を前提とした アーキテクチャのサンプルです。

との記載をしています

Phoenixの機能は全体的にEctoを使用してRDBを使用するのを前提とした設計となっているのが原因です。これは実際悪いことではなく、ほとんどのWebアプリケーションではRDBを使用することの方が多いと思われるので実際は上記のサンプルがPhoenixの思想を活かしつつ前述の諸々の問題を解決するのにはベストな構成のうちの一つだと思います。

そしてこのEctoの依存を脱して本当の疎結合(?)を目指すとき、そこから先は本格的なDDDを行うことになります。(ここまで紹介してきた概念もほとんどがDDDの思想によるものですが)
この記事は入門なのでそこから先は紹介しませんが、興味のある方は如何にしたらRDBへの依存を剥がすことができるのか考えてみるのも楽しいと思います。そこまで行くとPhoenixの思想から外れることになるので個人的にはPhoenixを使用する必要がなくなってくるような感じもします。

終わりに

今回の記事では入門的にDDDの思想を取り入れながら以下にしてアプリケーションを疎結合に保ち、構築していくかをご紹介しました。ここでの考え方は一例に過ぎないので他にも多くの方法が存在すると思います。

この記事がどなたかの参考になれば幸いです。

Share on

さんぽし
WRITTEN BY
さんぽし
Web Developer /w Elixir, Go