技術メモ

プログラミングとか電子工作とか

Phoenix入門 (第13章 Contexts その5)

f:id:ysmn_deus:20190219130922p:plain

どうも、靖宗です。
流石に今回で終わらす!

Cross-context data

CMSとAccountsというContextが介在していますが、場合によっては1個のContextで表現した方がシンプルになり得ます。(規模が小さいときとか)
この辺は各自の判断だとは思うんですが、個人的には今回みたいに細かくContextを作成し、繋がりを最小限にとどめる設計が拡張しやすいんじゃないかとは思います。

依存関係の編集(CMS

なにはともあれ、CMSのContext内でもまだ依存関係を追記していません。その辺を修正していきます。
まずはlib/hello/cms/page.exから。

defmodule Hello.CMS.Page do
  use Ecto.Schema
  import Ecto.Changeset
  alias Hello.CMS.Author # 追記

  schema "pages" do
    field :body, :string
    field :title, :string
    field :views, :integer
    belongs_to :author, Author # 追記
...

PageはAuthorに対して従属関係なのでこれで良さそうです。
お次はlib/hello/cms/author.ex

defmodule Hello.CMS.Author do
  use Ecto.Schema
  import Ecto.Changeset
  alias Hello.CMS.Page # 追記

  schema "authors" do
    field :bio, :string
    field :genre, :string
    field :role, :string
#    field :user_id, :id #消去
    has_many :pages, Page # 追記
    belongs_to :user, Hello.Accounts.User # 追記
...

pagesに対してAuthorは一対多です。なのでhas_manyを指定します。
userに対しては一対一の従属関係なのでbelongs_toです。
belongs_toの項目があるので:user_idの行は不要です。

ContextのAPI編集

preloadの追加

お次にContextのlist_pagesとかを編集していきます。
Accounts.Credentialを追加したときのように、Pageがロードされる前に従属関係であるAuthorをpreloadしておく必要があります。
lib/hello/cms.exを編集します。

defmodule Hello.CMS do

...

  alias Hello.CMS.{Page, Author}
  alias Hello.Accounts

...

  def list_pages do
    Page# Repoにしててエラーが起こった。詳細はもうちょっと先の項目で。
    |> Repo.all()
    |> Repo.preload(author: [user: :credential])
  end

...

  def get_page!(id) do
    Page
    |> Repo.get!(id)
    |> Repo.preload(author: [user: :credential])
  end

...

#  alias Hello.CMS.Author # 消去(上でaliasしてるので)

...

  def get_author!(id) do
    Author
    |> Repo.get!(id)
    |> Repo.preload(user: :credential)
  end

...

どうやらRepo.preload(author: [user: :credential])でAuthor、User、Credentialがロードされるようです。

ページを生成するときや編集する時のAuthorの取り扱い

先ほどはデータアクセス(読み込み)の際に必要なpreloadを追加しました。
ではここでは書き込みの際に必要な処理を追記していきます。
同様にlib/hello/cms.exを編集していきます。

...

  def create_page(%Author{} = author, attrs \\ %{}) do
    %Page{}
    |> Page.changeset(attrs)
    |> Ecto.Changeset.put_change(:author_id, author.id)
    |> Repo.insert()
  end

  def ensure_author_exists(%Accounts.User{} = user) do
    %Author{user_id: user.id}
    |> Ecto.Changeset.change()
    |> Ecto.Changeset.unique_constraint(:user_id)
    |> Repo.insert()
    |> handle_existing_author()
  end
  defp handle_existing_author({:ok, author}), do: author
  defp handle_existing_author({:error, changeset}) do
    Repo.get_by!(Author, user_id: changeset.data.user_id)
  end

...

まずはcreate_pageの変更点ですが、引数にAuthorの構造体が必要になりました。
Ecto.Changeset.put_change(:author_id, author.id)でPageの構造体にAuthorのIDを追加し、リポジトリに保存という流れです。

次にensure_author_exists以降の箇所ですが、まず前提を整理します。
このCMSはページを作る前には作成者(Author)の情報が必要(生成するページ情報に紐付くので)です。ですので、もしページを作ろうとしているユーザーがAuthorに登録されていなければ登録するというような処理が必要になります。
フォームを用意してバリデーションではじくという方針も考えられそうですがいちいち手間ですし、バックエンドで処理するべきです。ですので、そういった処理用の関数ensure_author_existsを作成しているのでしょう。

内容は、アカウント情報を渡してAuthorとしてリポジトリに保存し、その後handle_exisiting_authorリポジトリに挿入できたか否かをチェックしているようです。
Ecto.Changeset.unique_constraint(:user_id)でAuthor中に同じuser_idのAuthorがいないかチェックする情報を乗せて、Repo.insert()の際に判断されるようです。既に存在している場合はRepo.get_by!でAuthorの情報を返すようになっているようです。

以上でCMSのContextはだいたいおっけーのはずです。

Webレイヤーの実装

お次はCMSのページを作成したりする画面を編集していきます。
ログインできるかどうかは前回確認しましたが、ページを生成するあたりはスルーしました。(リストは表示されてましたが。)
ページを生成するにあたっては、上の項目で作成したensure_author_existsを利用してユーザーがAuthorに登録されているかどうか、されていなければ登録するPlugを作成してこのPlugを通過するように編集するのが良さそうです。

作成者関係のPlugを作成する

これはコントローラ内に実装します。
lib/hello_web/controllers/cms/page_controller.exを編集します。

defmodule HelloWeb.CMS.PageController do
  use HelloWeb, :controller

  alias Hello.CMS
  alias Hello.CMS.Page

  plug :require_existing_author # 追加
  plug :authorize_page when action in [:edit, :update, :delete] # 追加

...

# 以下追加
  defp require_existing_author(conn, _) do
    author = CMS.ensure_author_exists(conn.assigns.current_user)
    assign(conn, :current_author, author)
  end

  defp authorize_page(conn, _) do
    page = CMS.get_page!(conn.params["id"])

    if conn.assigns.current_author.id == page.author_id do
      assign(conn, :page, page)
    else
      conn
      |> put_flash(:error, "You can't modify that page")
      |> redirect(to: Routes.cms_page_path(conn, :index))
      |> halt()
    end
  end # 忘れてた
end

require_existing_authorauthorize_pageという2種類のPlugを作成しました。
require_existing_authorは分かりやすく、ensure_author_existsを利用して接続情報にAuthorの情報を載せています。(無ければ作ってくれる)
authorize_pageは対象のページの編集者と閲覧者が一致する場合のみ正常に動作し、一致しない場合はフラッシュメッセージを送出してPlugの処理を止めています。このプラグは編集と削除の時だけでいいのでwhen action in~~のオプションを使用しております。
ついでにこのプラグ内でページのIDを取得し、ページ情報も接続情報に登録(assign)しています。

Plugや他の変更に合わせてコントローラを修正する

これらを踏まえてcreateeditupdatedeleteを編集します。

...

  def create(conn, %{"page" => page_params}) do
    # create_pageは上の項目でAuthorの情報が必要になった
    # あと, page_paramsを忘れていた
    case CMS.create_page(conn.assigns.current_author, page_params) do
      {:ok, page} ->
        conn
        |> put_flash(:info, "Page created successfully.")
        |> redirect(to: Routes.cms_page_path(conn, :show, page))

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, "new.html", changeset: changeset)
    end
  end

...

  # idの情報はconnに載ってるので冗長
  def edit(conn, _) do
    # connからページ情報を取り出す
    changeset = CMS.change_page(conn.assigns.page)
    # ページ情報はconnに載ってるので冗長
    render(conn, "edit.html", changeset: changeset)
  end

...

  # idの情報はconnに載ってるので冗長
  def update(conn, %{"page" => page_params}) do
    # connからページ情報を取り出す
    case CMS.update_page(conn.assigns.page, page_params) do
      {:ok, page} ->
        conn
        |> put_flash(:info, "Page updated successfully.")
        |> redirect(to: Routes.cms_page_path(conn, :show, page))

      {:error, %Ecto.Changeset{} = changeset} ->
        # ページ情報はconnに載ってるので冗長
        render(conn, "edit.html", changeset: changeset)
    end
  end

  # idの情報はconnに載ってるので冗長
  def delete(conn, _) do
    # connからページ情報を取り出す
    {:ok, _page} = CMS.delete_page(conn.assigns.page)

    conn
    |> put_flash(:info, "Page deleted successfully.")
    |> redirect(to: Routes.cms_page_path(conn, :index))
  end

...

大量に修正点がありますが、主にauthorize_pageで既にページ情報はPlug.Connにロードしてるのでそこから取り出すように変更したところです。

作成者名を表示する

ついでにページの表示に作成者を表示するように修正しておきます。
まずはビューのlib/hello_web/views/cms/page_view.exを編集します。

defmodule HelloWeb.CMS.PageView do
  use HelloWeb, :view

  alias Hello.CMS

  def author_name(%CMS.Page{author: author}) do
    author.user.name
  end
end

ページ情報に基づき、作成者の名前情報を返す関数を作成しました。
これをテンプレートで呼びます。lib/hello_web/templates/cms/page/show.html.eexを編集します。

...

  <li>
    <strong>Views:</strong>
    <%= @page.views %>
  </li>

  <li>
    <strong>Author:</strong>
    <%= author_name(@page) %>
  </li>

</ul>

...

他の項目と並べるように作成者の表示を追加しました。これでOKな筈です。

mix phx.serverで試す(間違い探し)

それではPageは閲覧数以外もう大丈夫な筈なのでチェックしてみます。
mix phx.serverで実行します。

PS \hello> mix phx.server
Compiling 2 files (.ex)

== Compilation error in file lib/hello_web/controllers/cms/page_controller.ex ==
** (TokenMissingError) lib/hello_web/controllers/cms/page_controller.ex:79: missing terminator: end (for "do" starting at line 1)
    (elixir) lib/kernel/parallel_compiler.ex:208: anonymous fn/4 in Kernel.ParallelCompiler.spawn_workers/6

lib/hello_web/controllers/cms/page_controller.exの最後の箇所でエラーが出ています。
end忘れでした。再実行。
サーバーは立ち上がったのでhttp://localhost:4000/cms/pagesにアクセス。

Request: GET /cms/pages
** (exit) an exception was raised:
    ** (Protocol.UndefinedError) protocol Ecto.Queryable not implemented for Hello.Repo, the
given module does not provide a schema. This protocol is implemented for: Atom, BitString, Ecto.Query, Ecto.SubQuery, Tuple
        (ecto) lib/ecto/queryable.ex:40: Ecto.Queryable.Atom.to_query/1
        (ecto) lib/ecto/repo/queryable.ex:14: Ecto.Repo.Queryable.all/3
        (hello) lib/hello/cms.ex:23: Hello.CMS.list_pages/0
...

('ω')。o(????????????)
Ecto絡みということはWebレイヤー側の実装ミスでは無い筈。
Context周りを確認します。list_pagesって書いてあるしそのへん?

...

  def list_pages do
    Repo # ←Pageやんけ・・・
    |> Repo.all()
    |> Repo.preload(author: [user: :credential])
  end

...

寝てたんかな。PageをRepoとタイポ。
修正して再アクセス。

f:id:ysmn_deus:20190404125044p:plain

表示はできたけどセッションが生きてる?
ログアウトしたらCSRFと勘違いされたので、サーバーを再起動。
の後再ログイン。

f:id:ysmn_deus:20190404125339p:plain

作成ページは問題無さそう。作成してみる。

f:id:ysmn_deus:20190404125445p:plain

タイトルと本文ちゃんと入力したのに怒られた・・・
まずはコントローラーを疑ってみる。

...

  def create(conn, %{"page" => page_params}) do
    # , page_params ないやん ↓
    case CMS.create_page(conn.assigns.current_author) do
      {:ok, page} ->

...

そらそうなるわ。修正してもう一度。

f:id:ysmn_deus:20190404130230p:plain

こんどはうまくいった!
一応別のユーザーを作成して編集できないかチェック。

f:id:ysmn_deus:20190404130546p:plain

authorize_pageのPlugもよさそうです。

Adding CMS functions

AccountsのContextにauthenticate_by_email_password/2という関数を作成して機能を拡張したようにCMSにも機能を追加で来ます。Contextを使って閲覧数をカウントアップする機能を追加していきます。

機能の仕様を考える

編集したときと同じ様にCMS.update_pageで実装するのは色々と問題があります。
まず第一に競合が起こりやすくなります。PV数がかなり少ないようなショボいケースであればほぼ問題無いのですが、複数人が同時にアクセスした場合にカウントが正しくないケースが考えられます。
例えば

  1. User 1がカウント13のページをロードする
  2. User 1がページのカウントを14にする
  3. User 2がカウント14のページをロードする
  4. User 2がページのカウントを15にする

こうなれば良いんですが、同時アクセスなどがある場合下記が考えられます。

  1. User 1がカウント13のページをロードする
  2. User 2がカウント13のページをロードする
  3. User 1がページのカウントを14にする
  4. User 2がカウント14のページをロードする
  5. User 2がページのカウントを14にする

ではどのような仕様が望ましいか。
ページ情報のロードの際にインクリメントして呼び出されるのが望ましいです。つまり

page = CMS.inc_page_views(page)

となるようなinc_page_views/1を定義して、Page情報取得の箇所にパイプラインとして渡せば綺麗におさまりそうです。

CMSに実装する

それではCMSのContextに実装していきます。
lib/hello/cms.exを編集します。

...

  def inc_page_views(%Page{} = page) do
    {1, [%Page{views: views}]} =
      Repo.update_all(
        from(p in Page, where: p.id == ^page.id),
        [inc: [views: 1]], returning: [:views])
  
    put_in(page.views, views)
  end

...

場所はどこでも良いと思いますが、一応Context内の整理を考慮してchange_page関数の次に記載しました。
Ectoのクエリを詳しく見る必要がありそうですが、Repo.update_allの箇所はfrom(p in Page, where: p.id == ^page.id)で該当ページをヒットさせ、[inc: [views: 1]]でヒットしたPageのviewsを1インクリメントして保存し、返値にviewsを要求する、という処理のようです。
PageのIDはユニークなので成功すれば、帰ってくるタプルは必ず{1(updateした項目数), hogehoge}となるというところでしょう。
put_inpage.viewsviewsの値を代入し、pageを返すといった流れでしょうか。

これでinc_page_viewsの実装は良さそうです。
いつものごとくWebレイヤーを修正していきます。

コントローラを修正する

HTTPアクセスの度に呼び出される処理はコントローラの役目です。lib/hello_web/controllers/cms/page_controller.exを編集します。

...

  def show(conn, %{"id" => id}) do
    page = 
    id
    |> CMS.get_page!()
    |> CMS.inc_page_views()
    
    render(conn, "show.html", page: page)
  end

...

閲覧した時にインクリメントされて欲しいのでshowに先ほどの機能を実装します。
これで実装は完了されたはずです。サーバーを起動して確認してみます。

f:id:ysmn_deus:20190404134021p:plain
1回目

f:id:ysmn_deus:20190404134039p:plain
2回目

インクリメントされてます!完成です!

パスワード認証などははしょりましたが、これでCMSまでをも作る事ができました。
割とこの辺は他のシステムなどにも応用が利きそうです。

FAQ

Returning Ecto structures from context APIs

(Contextはカプセル化するための概念なのになんでcreate_user/1みたいな関数は失敗したときにEcto.Changesetを生で返すねん!)

Phoenixでは%Ecto.Changeset{}は一般的な構造体として考えられているそうで、Phoenix.ParamPhoenix.HTML.FormDataでハンドリングできるそうです。あと、エラーメッセージとかバリデーションのどこがアカンかったとか見やすいからだそうで。

Strategies for cross-context workflows

今回はページを生成する際にAuthorをバックエンドで作成してましたが、これは必ずしも全ユーザーがAuthorである必要性がない前提です。もしユーザーがAuthorのデータを必要とするときはどのような依存関係になるのでしょうか。

アカウントを生成する際に依存関係を結ぶようなケースを考えると、たとえばAccountsのContextのcreate_user

def create_user(attrs) do
  %User{}
  |> User.changeset(attrs)
  |> Ecto.Changeset.cast_assoc(:credential, with: &Credential.changeset/2)
  |> Ecto.Changeset.put_assoc(:author, %Author{...}) # これ
  |> Repo.insert()
end

となります。
一見問題無さそうに見えますが、この設計だとCMSはAccountsの構造に依存しており、AccountsもまたCMSの構造に依存することになります。
AccountsはCMSから完全に独立しているからこそ拡張性が高いのですが、上記のような循環参照みたいな関係になってしまうと拡張性も糞も無くなります。

ただ、よくある事のようなので、こういうケースは新しくContextを生成するのがベストプラクティスとされているようです。

例えば、今回であればAccountsとCMSはそのままで、UserRegistrationというContextを新しく作ります。このContextからAccountsとCMSを呼び出し、CMSのAuthorの関連付けを行います。こうすることでAccountsとCMSのそれぞれの結びつきを最小限にできるだけでなく、APIとしても明瞭(Context名から何を意味しているのかが推測できたりとか?)になるはずです。このアプローチを採用する際にはEcto.Multiが有用だそうです。
例を見ます。

defmodule Hello.UserRegistration do
  alias Ecto.Multi
  alias Hello.{Accounts, CMS}

  def register_user(params) do
    Multi.new()
    |> Multi.run(:user, fn _ -> Accounts.create_user(params) end)
    |> Multi.run(:author, fn %{user: user} ->
      {:ok, CMS.ensure_author_exists(user)}
    end)
    |> Repo.transaction()
  end
end

Multi.new()から続くパイプラインでそれぞれの依存関係の処理を行い、最後にトランザクション処理がなされる流れです。
もしどこかの処理で失敗すれば、全てがロールバックする仕組みだそうです。

おわりに

Context長かったのですが、割とPhoenixの中枢の話な気はします。
また復習してMulti.new()あたりも活用できるサンプルを公開できればなぁと思います。