どうも、靖宗です。
流石に今回で終わらす!
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_author
とauthorize_page
という2種類のPlugを作成しました。
require_existing_author
は分かりやすく、ensure_author_exists
を利用して接続情報にAuthorの情報を載せています。(無ければ作ってくれる)
authorize_page
は対象のページの編集者と閲覧者が一致する場合のみ正常に動作し、一致しない場合はフラッシュメッセージを送出してPlugの処理を止めています。このプラグは編集と削除の時だけでいいのでwhen action in~~
のオプションを使用しております。
ついでにこのプラグ内でページのIDを取得し、ページ情報も接続情報に登録(assign)しています。
Plugや他の変更に合わせてコントローラを修正する
これらを踏まえてcreate
、edit
、update
、delete
を編集します。
... 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とタイポ。
修正して再アクセス。
表示はできたけどセッションが生きてる?
ログアウトしたらCSRFと勘違いされたので、サーバーを再起動。
の後再ログイン。
作成ページは問題無さそう。作成してみる。
タイトルと本文ちゃんと入力したのに怒られた・・・
まずはコントローラーを疑ってみる。
... def create(conn, %{"page" => page_params}) do # , page_params ないやん ↓ case CMS.create_page(conn.assigns.current_author) do {:ok, page} -> ...
そらそうなるわ。修正してもう一度。
こんどはうまくいった!
一応別のユーザーを作成して編集できないかチェック。
authorize_page
のPlugもよさそうです。
Adding CMS functions
AccountsのContextにauthenticate_by_email_password/2
という関数を作成して機能を拡張したようにCMSにも機能を追加で来ます。Contextを使って閲覧数をカウントアップする機能を追加していきます。
機能の仕様を考える
編集したときと同じ様にCMS.update_page
で実装するのは色々と問題があります。
まず第一に競合が起こりやすくなります。PV数がかなり少ないようなショボいケースであればほぼ問題無いのですが、複数人が同時にアクセスした場合にカウントが正しくないケースが考えられます。
例えば
- User 1がカウント13のページをロードする
- User 1がページのカウントを14にする
- User 2がカウント14のページをロードする
- User 2がページのカウントを15にする
こうなれば良いんですが、同時アクセスなどがある場合下記が考えられます。
- User 1がカウント13のページをロードする
- User 2がカウント13のページをロードする
- User 1がページのカウントを14にする
- User 2がカウント14のページをロードする
- 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_in
でpage.views
にviews
の値を代入し、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
に先ほどの機能を実装します。
これで実装は完了されたはずです。サーバーを起動して確認してみます。
インクリメントされてます!完成です!
パスワード認証などははしょりましたが、これでCMSまでをも作る事ができました。
割とこの辺は他のシステムなどにも応用が利きそうです。
FAQ
Returning Ecto structures from context APIs
(Contextはカプセル化するための概念なのになんでcreate_user/1
みたいな関数は失敗したときにEcto.Changeset
を生で返すねん!)
Phoenixでは%Ecto.Changeset{}
は一般的な構造体として考えられているそうで、Phoenix.Param
やPhoenix.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()
あたりも活用できるサンプルを公開できればなぁと思います。