Help us understand the problem. What is going on with this article?

Phoenix 1.3のディレクトリ構造とContext

More than 3 years have passed since last update.

Phoenix 1.3のrc-2が先月リリースされそろそろPhoenix 1.3にアプデも考えだしてきた今日この頃、まだアップデートはしないけど1.3正式版がリリースされることに備えてディレクトリ構造とか学んでおきたい
ということでざっと慣れるためにPhoenix 1.3を触ってみます

What's changed in Phoenix 1.3

Phoenix 1.3での主な変更点は以下です

  • webディレクトリがlib/<project>/web以下になった
  • デフォルトのジェネレータにContextという概念が追加されアプリケーション境界を分割出来るようになった
  • Fallback Action Plugが追加された
    • Controllerの構成がシンプルに出来るようになった

ディレクトリ構造

まず大きく変わった点としてデフォルトのディレクトリ構造が変わったということもあるので、phoenixプロジェクトを一から作成してみます

プロジェクト作成のコマンドの名前がphoenix.newからphx.newに変更になり1.2と1.3で意識的に変えたことが分かりますね

$ mix phx.new my_app  
* creating my_app/config/config.exs
* creating my_app/config/dev.exs
* creating my_app/config/prod.exs
* creating my_app/config/prod.secret.exs
* creating my_app/config/test.exs
* creating my_app/lib/my_app/application.ex
* creating my_app/lib/my_app/web/channels/user_socket.ex
* creating my_app/lib/my_app/web/views/error_helpers.ex
* creating my_app/lib/my_app/web/views/error_view.ex
* creating my_app/lib/my_app/web/endpoint.ex
* creating my_app/lib/my_app/web/router.ex
* creating my_app/lib/my_app/web/web.ex
* creating my_app/mix.exs
* creating my_app/README.md
* creating my_app/test/support/channel_case.ex
* creating my_app/test/support/conn_case.ex
* creating my_app/test/test_helper.exs
* creating my_app/test/my_app/web/views/error_view_test.exs
* creating my_app/lib/my_app/web/gettext.ex
* creating my_app/priv/gettext/en/LC_MESSAGES/errors.po
* creating my_app/priv/gettext/errors.pot
* creating my_app/lib/my_app/repo.ex
* creating my_app/priv/repo/seeds.exs
* creating my_app/test/support/data_case.ex
* creating my_app/lib/my_app/web/controllers/page_controller.ex
* creating my_app/lib/my_app/web/templates/layout/app.html.eex
* creating my_app/lib/my_app/web/templates/page/index.html.eex
* creating my_app/lib/my_app/web/views/layout_view.ex
* creating my_app/lib/my_app/web/views/page_view.ex
* creating my_app/test/my_app/web/controllers/page_controller_test.exs
* creating my_app/test/my_app/web/views/layout_view_test.exs
* creating my_app/test/my_app/web/views/page_view_test.exs
* creating my_app/.gitignore
* creating my_app/assets/brunch-config.js
* creating my_app/assets/css/app.css
* creating my_app/assets/css/phoenix.css
* creating my_app/assets/js/app.js
* creating my_app/assets/js/socket.js
* creating my_app/assets/package.json
* creating my_app/assets/static/robots.txt
* creating my_app/assets/static/images/phoenix.png
* creating my_app/assets/static/favicon.ico

Fetch and install dependencies? [Yn] n

We are almost there! The following steps are missing:

    $ cd my_app
    $ mix deps.get
    $ cd assets && npm install && node node_modules/brunch/bin/brunch build

Then configure your database in config/dev.exs and run:

    $ mix ecto.create

Start your Phoenix app with:

    $ mix phx.server

You can also run your app inside IEx (Interactive Elixir) as:

    $ iex -S mix phx.server

構造を確認してみます

$ tree
.
├── assets
│   ├── brunch-config.js
│   ├── css
│   │   ├── app.css
│   │   └── phoenix.css
│   ├── js
│   │   ├── app.js
│   │   └── socket.js
│   ├── package.json
│   ├── static
│   │   ├── favicon.ico
│   │   ├── images
│   │   │   └── phoenix.png
│   │   └── robots.txt
│   └── vendor
├── config
│   ├── config.exs
│   ├── dev.exs
│   ├── prod.exs
│   ├── prod.secret.exs
│   └── test.exs
├── lib
│   └── my_app
│       ├── application.ex
│       ├── repo.ex
│       └── web
│           ├── channels
│           │   └── user_socket.ex
│           ├── controllers
│           │   └── page_controller.ex
│           ├── endpoint.ex
│           ├── gettext.ex
│           ├── router.ex
│           ├── templates
│           │   ├── layout
│           │   │   └── app.html.eex
│           │   └── page
│           │       └── index.html.eex
│           ├── views
│           │   ├── error_helpers.ex
│           │   ├── error_view.ex
│           │   ├── layout_view.ex
│           │   └── page_view.ex
│           └── web.ex
├── mix.exs
├── priv
│   ├── gettext
│   │   ├── en
│   │   │   └── LC_MESSAGES
│   │   │       └── errors.po
│   │   └── errors.pot
│   └── repo
│       ├── migrations
│       └── seeds.exs
├── README.md
└── test
    ├── my_app
    │   └── web
    │       ├── channels
    │       ├── controllers
    │       │   └── page_controller_test.exs
    │       └── views
    │           ├── error_view_test.exs
    │           ├── layout_view_test.exs
    │           └── page_view_test.exs
    ├── support
    │   ├── channel_case.ex
    │   ├── conn_case.ex
    │   └── data_case.ex
    └── test_helper.exs

29 directories, 41 files

まず大きな点として1.2でルートにあったweblib以下にまとめられたことで通常のElixirライブラリと同じような構成になっています。

1.2ではRailsライクなフレームワークという側面が強かったですが1.3になってElixirライブラリ標準を意識してきた感がありますね。ちなみにPhoenix 1.2でなぜwebというディレクトリなのかというと、技術的な制限からそうなったそうです。

Context

Phoenix 1.3からはデフォルトのジェネレータにmix phx.gen.contextが追加され、Contextという概念が持ち込まれました。
DDDとかやられている方にはお馴染みの概念かもしれませんが簡単に説明するとDDDではある一定のドメイン知識のまとまりを表すのにコンテキストという言葉が使われています。

Elixir ForumでもDDDのContextの概念を持ち込むのは新規参入者の壁になるのでは?という議論もされていたContextですが実際使ってみた感じ必ずしもDDDと同じように考える必要はなく、どちらかというとカジュアルにアプリケーションの境界を考える手段として提供されているものだと思いました。

個人的にもDDDに関しては「りろんはしってる」レベルなので最初はガチガチなDDD来たらどうしようと身構えていたけど実際使ってみるとなるほどという感想になりました。
ここは実際にphoenixジェネレータのContext関係のコマンドで出力されるファイルを見た方が理解が早いと思うので見てみましょう

$ mix phx.gen.context Accounts User users name:string

* creating ./lib/my_app/accounts/user.ex
* creating priv/repo/migrations/20170625042439_create_accounts_user.exs
* creating ./lib/my_app/accounts/accounts.ex
* injecting ./lib/my_app/accounts/accounts.ex
* creating ./test/my_app/accounts/accounts_test.exs
* injecting ./test/my_app/accounts/accounts_test.exs

Remember to update your repository by running migrations:

    $ mix ecto.migrate

phx.gen.contextに第一引数でコンテキスト名、第二第三でコンテキスト内に生成するモデルのsingularとpluralな名前、最後に任意の数のフィールド名とフィールド型のペアを渡しています。
結果、生成されたファイルを見ると第一引数のコンテキスト名(accounts)でPhoenixプロジェクト内にディレクトリが作られ、その中にスキーマ(user.ex)及びコンテキスト(accounts.ex)が作られていることが分かります。

スキーマは以下のような感じ。必要最低限ですね。

$ cat user.ex 
defmodule MyApp.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset
  alias DevApp.Accounts.User


  schema "accounts_users" do
    field :name, :string

    timestamps()
  end

  @doc false
  def changeset(%User{} = user, attrs) do
    user
    |> cast(attrs, [:name])
    |> validate_required([:name])
  end
end

accounts.exとしてコンテキスト名のファイルが作られています

$ cat accounts.ex 
defmodule MyApp.Accounts do
  @moduledoc """
  The boundary for the Accounts system.
  """

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

  alias MyApp.Accounts.User

  @doc """
  Returns the list of users.

  ## Examples

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

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

  @doc """
  Gets a single user.

  Raises `Ecto.NoResultsError` if the User does not exist.

  ## Examples

      iex> get_user!(123)
      %User{}

      iex> get_user!(456)
      ** (Ecto.NoResultsError)

  """
  def get_user!(id), do: Repo.get!(User, id)

  @doc """
  Creates a user.

  ## Examples

      iex> create_user(%{field: value})
      {:ok, %User{}}

      iex> create_user(%{field: bad_value})
      {:error, %Ecto.Changeset{}}

  """
  def create_user(attrs \\ %{}) do
    %User{}
    |> User.changeset(attrs)
    |> Repo.insert()
  end

  @doc """
  Updates a user.

  ## Examples

      iex> update_user(user, %{field: new_value})
      {:ok, %User{}}

      iex> update_user(user, %{field: bad_value})
      {:error, %Ecto.Changeset{}}

  """
  def update_user(%User{} = user, attrs) do
    user
    |> User.changeset(attrs)
    |> Repo.update()
  end

  @doc """
  Deletes a User.

  ## Examples

      iex> delete_user(user)
      {:ok, %User{}}

      iex> delete_user(user)
      {:error, %Ecto.Changeset{}}

  """
  def delete_user(%User{} = user) do
    Repo.delete(user)
  end

  @doc """
  Returns an `%Ecto.Changeset{}` for tracking user changes.

  ## Examples

      iex> change_user(user)
      %Ecto.Changeset{source: %User{}}

  """
  def change_user(%User{} = user) do
    User.changeset(user, %{})
  end
end

The boundary for the Accounts system.というmoduledocから見る通りアカウントシステムというバウンダリを提供するモジュールという位置づけなのが分かります。
各コンテキスト内にスキーマを定義出来るので例えばアカウントシステムとバックエンドシステムでモデルを切り変えたい場合もコンテキストを切り替えるだけでいいことが分かります。

実際の例としてはhex.pmリポジトリが既にPhoenix 1.3に切り替わっていて運用されているので例として参考になると思います。

Difference between DDD's Context

ちなみにPhoenix作者のChris McCordは説明のためにDDDを引用しましたがDDDのContextとは違うものだとJoséは説明しているので、DDDのコンテキストはこうだからといってPhoenixのContextもこうあるべきだという議論は恐らく生産的ではないのでやめておいた方がよさそうです。

Cross context relationship

コンテキストを超えてリレーションが必要になるケース、例えばSalesコンテキストのOrderモデルがAccountsコンテキストのUserモデルにN:1のリレーションを貼りたいというようなケースはどうすればいいのかということがForumでも議論されていましたが、こういう場合はそもそもなぜコンテキストを超える必要があるのかを考える必要があります。

Salesコンテキスト内にUserモデルを定義して同じテーブルを参照することはphoenixでは可能なのでそうすれば解決します。冗長なコードになると考えるかもしれませんが、コンテキストが正しく分割されていればそのモデルで表される知識は別の知識になるので正しいでしょう。逆にコンテキストの分割が間違っていたら同じドメイン知識が複数の箇所で表現されるので抽象化には失敗するでしょう。

将来的にそのコンテキストだけ切り出してUmbrella Appにしたい場合もコンテキストが正しく分割されていればそこまで難しくないし、逆にコンテキストが正しく分割されていなければ切り出すのが難しくなるのでリファクタリングが必要になるはずです。

コンテキストという概念を導入することでよくなるのはたぶんこういうアプリケーションの境界について考える機会が増えるということなのだと思います。
オレオレ抽象化に頼らず正しいバウンダリが設定されているか考えることでよりよい抽象化に繋がるという、DDDの概念のいいとこ取りみたいな感じですがこういう節操なく他のツールのいいとこどりをするのもPhoenixのいいところだと思うので個人的には支持したいです。

オレオレコンテキストが増えそうなのでしばらくはCoC(Convention over Configuration)にのっとってなるべくジェネレータ経由で…

Umbrella

Elixirデフォルトで提供されているUmbrella Project(傘のように一つのアプリ内に複数のアプリを配置するプロジェクト構成)での抽象化ではプロジェクトをサブモジュールとして扱えるのでmix.exsのDependencyを分割出来たり各サブモジュール単位でリリースを行えるのでより複雑なシステムでは有効になりそうですが、プロジェクト初期には少しオーバースペックでOver Optimizeになるんじゃないか?と手を出しづらいところがありました。

Contextはそれよりはソフトな抽象化でモノリシックなプロジェクト内でコンテキストの切り替えを行うことでUmbrella Projectで行っていたアプリ単位での切り替えをコンテキストの切り替えで簡易的に出来るようにしたという印象です。

Phoenix 1.3ではphx.new--umbrellaオプションが追加され、Umbrella構成でPhoenixプロジェクトを作成することも出来ます。

$ mix phx.new my_app --umbrella
* creating my_app_umbrella/.gitignore
* creating my_app_umbrella/config/config.exs
* creating my_app_umbrella/config/dev.exs
* creating my_app_umbrella/config/test.exs
* creating my_app_umbrella/config/prod.exs
* creating my_app_umbrella/mix.exs
* creating my_app_umbrella/README.md
* creating my_app_umbrella/apps/my_app_web/config/config.exs
* creating my_app_umbrella/apps/my_app_web/config/dev.exs
* creating my_app_umbrella/apps/my_app_web/config/prod.exs
* creating my_app_umbrella/apps/my_app_web/config/prod.secret.exs
* creating my_app_umbrella/apps/my_app_web/config/test.exs
* creating my_app_umbrella/apps/my_app_web/lib/my_app_web.ex
* creating my_app_umbrella/apps/my_app_web/lib/my_app_web/application.ex
* creating my_app_umbrella/apps/my_app_web/lib/my_app_web/channels/user_socket.ex
* creating my_app_umbrella/apps/my_app_web/lib/my_app_web/endpoint.ex
* creating my_app_umbrella/apps/my_app_web/lib/my_app_web/router.ex
* creating my_app_umbrella/apps/my_app_web/lib/my_app_web/views/error_helpers.ex
* creating my_app_umbrella/apps/my_app_web/lib/my_app_web/views/error_view.ex
* creating my_app_umbrella/apps/my_app_web/mix.exs
* creating my_app_umbrella/apps/my_app_web/README.md
* creating my_app_umbrella/apps/my_app_web/test/test_helper.exs
* creating my_app_umbrella/apps/my_app_web/test/support/channel_case.ex
* creating my_app_umbrella/apps/my_app_web/test/support/conn_case.ex
* creating my_app_umbrella/apps/my_app_web/test/my_app_web/views/error_view_test.exs
* creating my_app_umbrella/apps/my_app_web/lib/my_app_web/gettext.ex
* creating my_app_umbrella/apps/my_app_web/priv/gettext/en/LC_MESSAGES/errors.po
* creating my_app_umbrella/apps/my_app_web/priv/gettext/errors.pot
* creating my_app_umbrella/apps/my_app_web/lib/my_app_web/controllers/page_controller.ex
* creating my_app_umbrella/apps/my_app_web/lib/my_app_web/templates/layout/app.html.eex
* creating my_app_umbrella/apps/my_app_web/lib/my_app_web/templates/page/index.html.eex
* creating my_app_umbrella/apps/my_app_web/lib/my_app_web/views/layout_view.ex
* creating my_app_umbrella/apps/my_app_web/lib/my_app_web/views/page_view.ex
* creating my_app_umbrella/apps/my_app_web/test/my_app_web/controllers/page_controller_test.exs
* creating my_app_umbrella/apps/my_app_web/test/my_app_web/views/layout_view_test.exs
* creating my_app_umbrella/apps/my_app_web/test/my_app_web/views/page_view_test.exs
* creating my_app_umbrella/apps/my_app_web/.gitignore
* creating my_app_umbrella/apps/my_app_web/assets/brunch-config.js
* creating my_app_umbrella/apps/my_app_web/assets/css/app.css
* creating my_app_umbrella/apps/my_app_web/assets/css/phoenix.css
* creating my_app_umbrella/apps/my_app_web/assets/js/app.js
* creating my_app_umbrella/apps/my_app_web/assets/js/socket.js
* creating my_app_umbrella/apps/my_app_web/assets/package.json
* creating my_app_umbrella/apps/my_app_web/assets/static/robots.txt
* creating my_app_umbrella/apps/my_app_web/assets/static/images/phoenix.png
* creating my_app_umbrella/apps/my_app_web/assets/static/favicon.ico
* creating my_app_umbrella/apps/my_app/config/config.exs
* creating my_app_umbrella/apps/my_app/config/dev.exs
* creating my_app_umbrella/apps/my_app/config/prod.exs
* creating my_app_umbrella/apps/my_app/config/prod.secret.exs
* creating my_app_umbrella/apps/my_app/config/test.exs
* creating my_app_umbrella/apps/my_app/lib/my_app/application.ex
* creating my_app_umbrella/apps/my_app/test/test_helper.exs
* creating my_app_umbrella/apps/my_app/README.md
* creating my_app_umbrella/apps/my_app/mix.exs
* creating my_app_umbrella/apps/my_app/.gitignore
* creating my_app_umbrella/apps/my_app/lib/my_app/repo.ex
* creating my_app_umbrella/apps/my_app/priv/repo/seeds.exs
* creating my_app_umbrella/apps/my_app/test/support/data_case.ex

Fetch and install dependencies? [Yn] n

We are almost there! The following steps are missing:

    $ cd my_app_umbrella
    $ mix deps.get
    $ cd assets && npm install && node node_modules/brunch/bin/brunch build

Then configure your database in apps/my_app/config/dev.exs and run:

    $ mix ecto.create

Start your Phoenix app with:

    $ mix phx.server

You can also run your app inside IEx (Interactive Elixir) as:

    $ iex -S mix phx.server

ディレクトリツリーを見てみます

$ tree
.
├── apps
│   ├── my_app
│   │   ├── config
│   │   │   ├── config.exs
│   │   │   ├── dev.exs
│   │   │   ├── prod.exs
│   │   │   ├── prod.secret.exs
│   │   │   └── test.exs
│   │   ├── lib
│   │   │   └── my_app
│   │   │       ├── application.ex
│   │   │       └── repo.ex
│   │   ├── mix.exs
│   │   ├── priv
│   │   │   └── repo
│   │   │       ├── migrations
│   │   │       └── seeds.exs
│   │   ├── README.md
│   │   └── test
│   │       ├── support
│   │       │   └── data_case.ex
│   │       └── test_helper.exs
│   └── my_app_web
│       ├── assets
│       │   ├── brunch-config.js
│       │   ├── css
│       │   │   ├── app.css
│       │   │   └── phoenix.css
│       │   ├── js
│       │   │   ├── app.js
│       │   │   └── socket.js
│       │   ├── package.json
│       │   ├── static
│       │   │   ├── favicon.ico
│       │   │   ├── images
│       │   │   │   └── phoenix.png
│       │   │   └── robots.txt
│       │   └── vendor
│       ├── config
│       │   ├── config.exs
│       │   ├── dev.exs
│       │   ├── prod.exs
│       │   ├── prod.secret.exs
│       │   └── test.exs
│       ├── lib
│       │   ├── my_app_web
│       │   │   ├── application.ex
│       │   │   ├── channels
│       │   │   │   └── user_socket.ex
│       │   │   ├── controllers
│       │   │   │   └── page_controller.ex
│       │   │   ├── endpoint.ex
│       │   │   ├── gettext.ex
│       │   │   ├── router.ex
│       │   │   ├── templates
│       │   │   │   ├── layout
│       │   │   │   │   └── app.html.eex
│       │   │   │   └── page
│       │   │   │       └── index.html.eex
│       │   │   └── views
│       │   │       ├── error_helpers.ex
│       │   │       ├── error_view.ex
│       │   │       ├── layout_view.ex
│       │   │       └── page_view.ex
│       │   └── my_app_web.ex
│       ├── mix.exs
│       ├── priv
│       │   └── gettext
│       │       ├── en
│       │       │   └── LC_MESSAGES
│       │       │       └── errors.po
│       │       └── errors.pot
│       ├── README.md
│       └── test
│           ├── my_app_web
│           │   ├── channels
│           │   ├── controllers
│           │   │   └── page_controller_test.exs
│           │   └── views
│           │       ├── error_view_test.exs
│           │       ├── layout_view_test.exs
│           │       └── page_view_test.exs
│           ├── support
│           │   ├── channel_case.ex
│           │   └── conn_case.ex
│           └── test_helper.exs
├── config
│   ├── config.exs
│   ├── dev.exs
│   ├── prod.exs
│   └── test.exs
├── mix.exs
└── README.md

37 directories, 56 files

出力されたファイルを見てみるとルートディレクトリにappsディレクトリが作成されその中にUmbrella傘下内の一アプリとしてphoenixプロジェクトが作成されているのが分かります。

デフォルトでは<project><project>_webディレクトリがapps内に作成され、<project>内にはEctoの依存のみ存在しDBのインターフェースになっていることが分かります。また<project>_webディレクトリは--no-ectoオプションで作成した時のようにEctoがdependencyに存在せず素のphoenixプロジェクトになっているのが分かります。

実際にこうしろという規則はありませんがUmbrella Projectのお手本みたいな構成なのでPhoenixプロジェクトが成長してきてUmbrella化したいような場合は参考になるかもしれません。
Umbrellaをいつ使うかについてはPhoenix作者のChris McCordがLonestar Elixir Confで話していますが、必ずしも全てに当てはまるわけではないけどContextがそれ専用のストレージを持つ場合はUmbrella、それ以外なら同一App内にすると考えている、と言っています。

Microservicesを念頭においているのかもしれませんが、Contextで適切に分割出来ていればUmbrella化からのMicroservices化への道も想像出来るのでモノリス時からContextを使うことは有用だと思います。

まとめ

Phoenix 1.3でのディレクトリ構造やContext、Umbrella Projectについて確認しました。

Phoenix 1.2まではRailsライクな構成が新規参入のしやすさに一役買っていたところもあると思いますがOOでないElixirにそうなる必然性はないといえばないので、Umbrellaプロジェクトのオーバースペック感も否めなかったところにPhoenix 1.3のContextという概念は調度いい落とし所を作ったなという印象です。

※2017-06-27現在Phoenix 1.3-rc2で正式版はリリースされていないのでこの記事の内容は変わる可能性があります

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away