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

December 19, 2020

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

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

このエントリーをはてなブックマークに追加