技術メモ

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

Phoenix入門 (第14章 Mix Tasks その1)

f:id:ysmn_deus:20190219130922p:plain

どうも、靖宗です。
今回はMix Tasksということでmix phx.hogehogeのコマンドあたりの説明でしょうか。

Mix Tasks

Phoenix Specific Mix Tasks

Phoenixで使いそうなコマンド一覧があります。

mix local.phx          # Updates the Phoenix project generator locally
mix phx                # Prints Phoenix help information
mix phx.digest         # Digests and compresses static files
mix phx.digest.clean   # Removes old versions of static assets.
mix phx.gen.cert       # Generates a self-signed certificate for HTTPS testing
mix phx.gen.channel    # Generates a Phoenix channel
mix phx.gen.context    # Generates a context with functions around an Ecto schema
mix phx.gen.embedded   # Generates an embedded Ecto schema file
mix phx.gen.html       # Generates controller, views, and context for an HTML resource
mix phx.gen.json       # Generates controller, views, and context for a JSON resource
mix phx.gen.presence   # Generates a Presence tracker
mix phx.gen.schema     # Generates an Ecto schema and migration file
mix phx.gen.secret     # Generates a secret
mix phx.new            # Creates a new Phoenix application
mix phx.new.ecto       # Creates a new Ecto project within an umbrella project
mix phx.new.web        # Creates a new Phoenix web project within an umbrella project
mix phx.routes         # Prints all routes
mix phx.server         # Starts applications and their servers

細かい機能はPhoenix公式ドキュメントの「MIX TASKS」を見れば良さそうです。
幾つか簡単に取り上げてくれているので、一応目を通しておきます。

mix phx.new

言わずと知れたPhoenixプロジェクトを生成する際に使うコマンドです。
--no-ecto--no-webpackというオプションをつける事でEctoの実装を無くしたり、Webpackの実装を無くしたりできるそうですが、基本的には必要なんじゃないでしょうか。
APIサーバーとして機能するときはWebpackは不要?)

実際に生成するプロジェクト名はスネークケースがいいっぽいです。

> mix phx.new task_tester

相対パスでも絶対パスでもヨシ。

> mix phx.new ../task_tester
> mix phx.new /Users/me/work/task_tester

mix phx.newでプロジェクトを生成するとアプリケーション名はプロジェクト名(上記だとtask_tester)になります。
もしアプリケーション名を変更したい場合はmix phx.new task_tester --app hello--appオプションを利用するそうです。

mix.exsでアプリケーション名が変更されています。

defmodule Hello.MixProject do
  use Mix.Project

  def project do
    [app: :hello,
     version: "0.1.0",
...

このアプリケーション名を変更するとモジュール名などの接頭辞が変わるようです。

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

--appオプションでhelloを設定したのでモジュール名がHelloから始まってます。
lib/フォルダ以下のディレクトリやファイル名なども変わります。

基本は--appでいいような気はしますが、「モジュール名の接頭辞だけ変えたい!」という場合には--moduleオプションで指定出来るようです。

$  mix phx.new task_tester --module Hello
* creating task_tester/config/config.exs
* creating task_tester/config/dev.exs
* creating task_tester/config/prod.exs
* creating task_tester/config/prod.secret.exs
* creating task_tester/config/test.exs
* creating task_tester/lib/task_tester/application.ex
* creating task_tester/lib/task_tester.ex
* creating task_tester/lib/task_tester_web/channels/user_socket.ex
* creating task_tester/lib/task_tester_web/views/error_helpers.ex
* creating task_tester/lib/task_tester_web/views/error_view.ex
* creating task_tester/lib/task_tester_web/endpoint.ex
* creating task_tester/lib/task_tester_web/router.ex
* creating task_tester/lib/task_tester_web.ex
* creating task_tester/mix.exs
* creating task_tester/README.md
* creating task_tester/.gitignore
* creating task_tester/test/support/channel_case.ex
* creating task_tester/test/support/conn_case.ex
* creating task_tester/test/test_helper.exs
* creating task_tester/test/task_tester_web/views/error_view_test.exs
* creating task_tester/lib/task_tester_web/gettext.ex
* creating task_tester/priv/gettext/en/LC_MESSAGES/errors.po
* creating task_tester/priv/gettext/errors.pot
* creating task_tester/lib/task_tester/repo.ex

mix.exsは下のようなかんじ。

defmodule Hello.MixProject do
  use Mix.Project

  def project do
    [app: :task_tester,
...

アプリケーション名はtask_testerのままで、接頭辞のみがHelloとなっています。
ややこしいことになりそうなので、変えたいなら--appを使った方が良さそうです。

mix phx.gen.html

これはContextあたりで使いました。HTMLでのresourcerouter.exresource指定する奴)を生成する際に便利なコマンドです。
Webレイヤーの実装(コントローラ、ビュー、テンプレート)はもちろん、EctoのマイグレーションファイルやContextも生成してくれます。

このコマンドは引数が多くてややこしいですが、その中身は至ってシンプルで
mix phx.gen.html Context名 スキーマ名 リソース名 スキーマのリスト(項目名:型)
です。

$ mix phx.gen.html Blog Post posts body:string word_count:integer
* creating lib/hello_web/controllers/post_controller.ex
* creating lib/hello_web/templates/post/edit.html.eex
* creating lib/hello_web/templates/post/form.html.eex
* creating lib/hello_web/templates/post/index.html.eex
* creating lib/hello_web/templates/post/new.html.eex
* creating lib/hello_web/templates/post/show.html.eex
* creating lib/hello_web/views/post_view.ex
* creating test/hello_web/controllers/post_controller_test.exs
* creating lib/hello/blog/post.ex
* creating priv/repo/migrations/20170906150129_create_posts.exs
* creating lib/hello/blog/blog.ex
* injecting lib/hello/blog/blog.ex
* creating test/hello/blog/blog_test.exs
* injecting test/hello/blog/blog_test.exs

mix系のコマンドに一般的に言えることですが、次に何やったらいいか注釈がでてくれます。

Add the resource to your browser scope in lib/hello_web/router.ex:

    resources "/posts", PostController

Remember to update your repository by running migrations:

    $ mix ecto.migrate

ルーティングの設定とマイグレーションしろよ!と出ています。基本これだけでリソースが作成できます。

大体無いとは思うんですが、Contextいらないよ!って時(Bootstrapのテストなど、なんらかのテストに使う?)は--no-contextというオプションがあるそうです。
活用法があまり思い浮かばないので省略。 同様に--no-schemaオプションもあり。

mix phx.gen.json

こちらは使ったことはないですが、見た目からして上記のJSON版といったところでしょうか。

$ mix phx.gen.json Blog Post posts title:string content:string
* creating lib/hello_web/controllers/post_controller.ex
* creating lib/hello_web/views/post_view.ex
* creating test/hello_web/controllers/post_controller_test.exs
* creating lib/hello_web/views/changeset_view.ex
* creating lib/hello_web/controllers/fallback_controller.ex
* creating lib/hello/blog/post.ex
* creating priv/repo/migrations/20170906153323_create_posts.exs
* creating lib/hello/blog/blog.ex
* injecting lib/hello/blog/blog.ex
* creating test/hello/blog/blog_test.exs
* injecting test/hello/blog/blog_test.exs

コマンドの仕様もほぼhtmlと変わらないようです。
コマンド実行後の注釈はちょっと変わってきています。

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

    resources "/posts", PostController, except: [:new, :edit]


Remember to update your repository by running migrations:

    $ mix ecto.migrate

:new:editのアクションを除外しています。
JSONなので新規生成画面と編集画面がないためでしょう。HTTPリクエストなどで直接:create:updateを実行するということでしょう。

htmlと同様に--no-context--no-schemaオプションもあり。

mix phx.gen.context

Contextの章でAccountsにCredentialを追加するときに使いました。
追加するように使った場合はinjecting lib/hello/accounts.exというように既存のContextに追記する形で生成されましたが、Contextのみ生成したい場合にも利用できるようです。
主な使用方法はhtmljsonの時と同じ。

$ mix phx.gen.context Accounts User users name:string age:integer
* creating lib/hello/accounts/user.ex
* creating priv/repo/migrations/20170906161158_create_users.exs
* creating lib/hello/accounts/accounts.ex
* injecting lib/hello/accounts/accounts.ex
* creating test/hello/accounts/accounts_test.exs
* injecting test/hello/accounts/accounts_test.exs

mix phx.gen.schema

HTMLやJSONのリソースも作らず、Contextも不要というレアケース用?でもコマンドが用意されてるってことは必要なタイミングがあるのかも。
コマンド引数はhtmljsonからContext名を省略したようなかんじ。

$ mix phx.gen.schema Accounts.Credential credentials email:string:unique user_id:references:users
* creating lib/hello/accounts/credential.ex
* creating priv/repo/migrations/20170906162013_create_credentials.exs

mix phx.gen.channel

Channelを生成するためのコマンド。Channelの章では手動で作成しましたが、コマンドでも生成してくれるようです。
引数は純粋にチャンネル名のみ。

$ mix phx.gen.channel Room
* creating lib/hello_web/channels/room_channel.ex
* creating test/hello_web/channels/room_channel_test.exs

ちゃんとメッセージも出してくれます。

Add the channel to your `lib/hello_web/channels/user_socket.ex` handler, for example:

    channel "rooms:lobby", HelloWeb.RoomChannel

mix phx.gen.presence

これはPresenceの章で使いました。
一応モジュール名を指定できますが、省略すればPresenceで作成されます。

$ mix phx.gen.presence
* creating lib/hello_web/channels/presence.ex

こちらもきちんと注釈を付けてくれます。

Add your new module to your supervision tree,
in lib/hello/application.ex:

    children = [
      ...
      HelloWeb.Presence
    ]

You're all set! See the Phoenix.Presence docs for more details:
http://hexdocs.pm/phoenix/Phoenix.Presence.html

mix phx.routes

Routingの章で利用しました。
コマンドを実行すればルーティングルールが表示されます。

$ mix phx.routes
page_path  GET  /  TaskTester.PageController.index/2

ルーティングファイルが2個以上あるときは、ルーティングファイル名を指定するとソイツだけみれるようです。

$ mix phx.routes TaskTesterWeb.Router
page_path  GET  /  TaskTesterWeb.PageController.index/2

mix phx.server

サーバーを立ち上げるコマンド。DoesNotExistオプションとかもあるけどよくわかんない。(本来は「DoesNotExist」が必要だけど省略されてる?)
iexで立ち上げたい時はiex -S mix phx.serverを実行してやればよいそうで。

$ iex -S mix phx.server
Erlang/OTP 17 [erts-6.4] [source] [64-bit] [smp:8:8] [async-threads:10] [hipe] [kernel-poll:false] [dtrace]

[info] Running TaskTesterWeb.Endpoint with Cowboy on port 4000 (http)
Interactive Elixir (1.0.4) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)>

これ地味に便利そうですね。

mix phx.digest

静的ファイル(static assets)の名前にダイジェスト(MD5)を付与し、圧縮するコマンドだそうです。
機能的にほぼデプロイ用(改ざん防止、転送量軽減)と考えて問題無さそうです。

assets/ディレクトリにあるファイルに処理を行い、デフォルトではpriv/staticにコピーするようです。
出力されるファイルの場所や処理するファイルの場所を指定したいときは引数で調整可能。

$ mix phx.digest priv/static -o www/public
Check your digested files at 'www/public'.

コンフィグに:を指定すると圧縮するファイルの対象を変更できるようです。

config :phoenix, :gzippable_exts, ~w(.js .css)

役割的にconfig/config.exsあたりにでも書けば良いのでしょうか。

デプロイは別の章でやるので、そのときにもう一度確認します。

飛ばし飛ばしやってるつもりが結構長くなったのでまた次回!

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()あたりも活用できるサンプルを公開できればなぁと思います。

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

f:id:ysmn_deus:20190219130922p:plain

どうも、靖宗です。
前回かなり中途半端なところで終わりましたが、気にせず続けます。

Cross-context dependencies

前回はCMSってContextを作ろう!というところで終わってました。なので作って行きます。

スキーム

今回は

  • title:string タイトル。文字列。
  • body:string 本文。文字列。
  • views:integer 閲覧数?数値。

というモデルになっているようです。

mix phx.gen.htmlでContext生成

PS \hello> mix phx.gen.html CMS Page pages title:string body:text views:integer --web CMS
* creating lib/hello_web/controllers/cms/page_controller.ex
* creating lib/hello_web/templates/cms/page/edit.html.eex
* creating lib/hello_web/templates/cms/page/form.html.eex
* creating lib/hello_web/templates/cms/page/index.html.eex
* creating lib/hello_web/templates/cms/page/new.html.eex
* creating lib/hello_web/templates/cms/page/show.html.eex
* creating lib/hello_web/views/cms/page_view.ex
* creating test/hello_web/controllers/cms/page_controller_test.exs
* creating lib/hello/cms/page.ex
* creating priv/repo/migrations/20190403003143_create_pages.exs
* creating lib/hello/cms.ex
* injecting lib/hello/cms.ex
* creating test/hello/cms/cms_test.exs
* injecting test/hello/cms/cms_test.exs

Add the resource to your CMS :browser scope in lib/hello_web/router.ex:

    scope "/cms", HelloWeb.CMS, as: :cms do
      pipe_through :browser
      ...
      resources "/pages", PageController
    end


Remember to update your repository by running migrations:

    $ mix ecto.migrate

復習にはなりますがmix phx.gen.html Context名 モデル名(コントローラとかで使われる) スキーム名 スキーム (--web 名前空間)でリソースに必要なファイルやContextが生成できます。
--web 名前空間のオプションですが、今回Pageという名前のモデル?モジュール名を利用しているので、そのまま使うとPageControllerとかが被ってしまいます。
そういうときにはこのオプションを利用するようです。(基本的にContext毎に分けた方がいいのでは?とは思いますが、まだ慣れてないので今はそういう物だと考えておきます。)
生成されているファイルがlib/hello_web/controllers/cms/page_controller.exとなっている所からも重複が回避されているのが分かります。

スキームに合わせたテンプレートの調整

viewsという項目はユーザーが直接編集するものではないのでフォームのビューからは消しておきます。lib/hello_web/templates/cms/page/form.html.eexviewsに該当する箇所を消去します。

<%= form_for @changeset, @action, fn f -> %>
  <%= if @changeset.action do %>
    <div class="alert alert-danger">
      <p>Oops, something went wrong! Please check the errors below.</p>
    </div>
  <% end %>

  <%= label f, :title %>
  <%= text_input f, :title %>
  <%= error_tag f, :title %>

  <%= label f, :body %>
  <%= textarea f, :body %>
  <%= error_tag f, :body %>

  <div>
    <%= submit "Save" %>
  </div>
<% end %>

あっても動くとは思いますがドキュメントに従いましょう。

スキームに合わせたchangesetの変更

上記と同様の理由でスキームに付属するchangesetのバリデーションも変更します。
Contextで対応するのかな?と思っていましたがchangesetで落としてしまうようです。
lib/hello/cms/page.exを編集します。

...
  @doc false
  def changeset(page, attrs) do
    page
    |> cast(attrs, [:title, :body]) # :viewsを消去
    |> validate_required([:title, :body]) # :viewsを消去
  end
end

マイグレーションファイルの調整

priv/repo/migrationsの該当するファイル(mix phx.gen.htmlで生成されたやつ)を編集します。

defmodule Hello.Repo.Migrations.CreatePages do
  use Ecto.Migration

  def change do
    create table(:pages) do
      add :title, :string
      add :body, :text
      add :views, :integer, default: 0 # この箇所にデフォルト値を設定

      timestamps()
    end

  end
end

今回はスキームの変更によるものでは無いですが、おおよそスキームを変更した場合はマイグレーションファイルの調整が必要になるでしょう。

ルーティングの追加(router.ex)

CMSのContextを追加したのでPageのリソースが見れる様にします。
lib/hello_web/router.exを修正します。

...
  scope "/", HelloWeb do
    pipe_through :browser

    get "/", PageController, :index
    resources "/users", UserController
    resources "/sessions", SessionController, only: [:new, :create, :delete], singleton: true
  end

  scope "/cms", HelloWeb.CMS, as: :cms do
    pipe_through [:browser, :authenticate_user]

    resources "/pages", PageController
  end
...

scope "/", HelloWeb do句を真似て書けば問題無いと思います。
ただし、今回はCMSに関連するページは認証が必要なようにしたいので:authenticate_userプラグを適応するようpipe_through [:browser, :authenticate_user]となっています。

マイグレートと認証機能の確認

ここまで来ると機能は実装されてないものの、だいたいの表示はできる筈です。
mix ecto.migrateでデータベースをマイグレートしてサーバーを起動してみます。

PS \hello> mix ecto.migrate
Compiling 6 files (.ex)
Generated hello app
[info] == Running 20190403003143 Hello.Repo.Migrations.CreatePages.change/0 forward
[info] create table pages
[info] == Migrated 20190403003143 in 0.0s
PS \hello> mix phx.server

生成したPageのURLはhttp://localhost:4000/cms/pagesです。ここにアクセスしてみます。

f:id:ysmn_deus:20190403100232p:plain

ログインしていないのでput_flashで怒られています。
http://localhost:4000/sessions/newにて、前回までに作成したユーザーでログインしてみます。

f:id:ysmn_deus:20190403102543p:plain

ヨシ!
認証系の機能がここまで簡単に追加できるのは驚愕です。
Plugの拡張性の高さヤバイ。

Authorの追加

ではPageを追加・・・と行きたいところですが、作成者の情報を紐付けておかないと面倒なことになりそうなので先にそちらを済ませましょう。
emailの情報をAccountsのContextに追加したようにphx.gen.contextCMSのContextにAuthorの情報を追加していきます。

スキームはこんなかんじ。

  • bio:text 出身?text形?昔のバージョンによく見られるっぽいんですが、たぶん:stringと同義。生成されたスキームでは:stringになってた。
  • role:string 役割。文字列。
  • genre:string ジャンル。文字列。
  • user_id:references:users:unique ユーザーID、ユーザーの情報と関連している
PS \hello> mix phx.gen.context CMS Author authors bio:text role:string genre:string user_id:references:users:unique
You are generating into an existing context.
The Hello.CMS context currently has 6 functions and 1 files in its directory.

  * It's OK to have multiple resources in the same context as     long as they are closely related
  * If they are not closely related, another context probably works better

If you are not sure, prefer creating a new context over adding to the existing one.

Would you like to proceed? [Yn] Y
* creating lib/hello/cms/author.ex
* creating priv/repo/migrations/20190403013056_create_authors.exs
* injecting lib/hello/cms.ex
* injecting test/hello/cms/cms_test.exs

Remember to update your repository by running migrations:

    $ mix ecto.migrate

UserとAuthorのContextを分けておくことで開発上いろんな利点がありそうです。
Authorに別情報が必要になってCMSのContextを編集する際でもAccounts側のUserは変更不要ですしCMS側にAcountsのUserデータ以外が流出することもないです。

マイグレーションファイルの調整

Accountsで認証情報を追加したときのように、従属関係にある際はon_deletenull: falseを設定しておいた方が良いです。(不用意にデータが残ってしまう可能性がある。)
なのでpriv/repo/migrationsに先ほどのmix phx.gen.contextで生成されたマイグレーションファイルを調整します。

defmodule Hello.Repo.Migrations.CreateAuthors do
  use Ecto.Migration

  def change do
    create table(:authors) do
      add :bio, :text
      add :role, :string
      add :genre, :string
      add :user_id, references(:users, on_delete: :delete_all), null: false # ここを修正

      timestamps()
    end

    create unique_index(:authors, [:user_id])
  end
end

これでAuthorに関しては問題ありませんが、まだ従属関係はあります。
PageがAuthorに関連付いているので、この関連付けをデータベースに登録するためにマイグレーションファイルを作成します。
mix ecto.gen.migrationで任意のマイグレーションファイルを作成できるようです。

PS \hello> mix ecto.gen.migration add_author_id_to_pages
Compiling 2 files (.ex)
Generated hello app
* creating priv/repo/migrations/20190403015018_add_author_id_to_pages.exs

いつものようなタイムスタンプ+名前.exsが生成されました。
一応中身を見てみます。

defmodule Hello.Repo.Migrations.AddAuthorIdToPages do
  use Ecto.Migration

  def change do

  end
end

このchange関数の中に処理を記載してマイグレーションすれば良さそうです。
いつものマイグレーションファイルはcreate table(hogehoge)doなどが記載されています。

今回はドキュメントに従い、下記のようにしました。

defmodule Hello.Repo.Migrations.AddAuthorIdToPages do
  use Ecto.Migration

  def change do
    alter table(:pages) do
      add :author_id, references(:authors, on_delete: :delete_all), null: false
    end

    create index(:pages, [:author_id])
  end
end

create tableでテーブル作成ですが、alter tableでテーブルにカラムを追加できるようです。
add以降は割といつも通りでしょうか。
作成者が削除されたらページも削除されてしまうのはなんだか寂しい気がしますが、今回はCMSを作る練習と言うことでスルーします。

マイグレーションファイルの調整が完了すれば、やることは一つ。mix ecto.migrateマイグレーションします。

PS \hello> mix ecto.migrate
warning: variable "null" does not exist and is being expanded to "null()", please use parentheses to remove the ambiguity or change the variable name
  priv/repo/migrations/20190403013056_create_authors.exs:9

** (CompileError) priv/repo/migrations/20190403013056_create_authors.exs:9: undefined function add/4
    (elixir) src/elixir_locals.erl:107: :elixir_locals."-ensure_no_undefined_local/3-lc$^0/1-0-"/2
    (elixir) src/elixir_locals.erl:107: anonymous fn/3 in :elixir_locals.ensure_no_undefined_local/3
    (stdlib) erl_eval.erl:680: :erl_eval.do_apply/6
    (elixir) lib/code.ex:715: Code.load_file/2
    (ecto_sql) lib/ecto/migrator.ex:489: Ecto.Migrator.load_migration/1
    (elixir) lib/enum.ex:1327: Enum."-map/2-lists^map/1-0-"/2
    (ecto_sql) lib/ecto/migrator.ex:435: Ecto.Migrator.do_migrate/4
    (ecto_sql) lib/ecto/migrator.ex:429: Ecto.Migrator.migrate/4
    (ecto_sql) lib/ecto/adapters/sql.ex:820: anonymous fn/3 in Ecto.Adapters.SQL.checkout_or_transaction/4
    (db_connection) lib/db_connection.ex:1415: DBConnection.run_transaction/4
    (ecto_sql) lib/ecto/adapters/sql.ex:727: Ecto.Adapters.SQL.lock_for_migrations/5
    (ecto_sql) lib/ecto/migrator.ex:318: Ecto.Migrator.lock_for_migrations/3
    (ecto_sql) lib/mix/tasks/ecto.migrate.ex:110: anonymous fn/4 in Mix.Tasks.Ecto.Migrate.run/2

null: falsenull, falseって打ってた、アホス。
修正して再実行。

PS \hello> mix ecto.migrate
[info] == Running 20190403013056 Hello.Repo.Migrations.CreateAuthors.change/0 forward
[info] create table authors
[info] create index authors_user_id_index
[info] == Migrated 20190403013056 in 0.0s
[info] == Running 20190403015018 Hello.Repo.Migrations.AddAuthorIdToPages.change/0 forward
[info] alter table pages
[info] create index pages_author_id_index
[info] == Migrated 20190403015018 in 0.0s

問題無さそう。
データベース周りの準備は良さそうなのでCMSとしての機能を実装していきます。

かなり長くなってるのでまた区切ります。
流石に次回アタリでケリをつけたい。

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

f:id:ysmn_deus:20190219130922p:plain

どうも、靖宗です。
Contextsの3回目です。なんか3回じゃ終わらないかも・・・(´゚'ω゚`)

前回はContext内でリレーションを作りました。
今回はContextに機能を追加していくところになるかと。

Adding Account functions

とりあえず前回まででユーザー情報(IDとパスワード)とメール情報のCRUDはできました。(メールはユーザー情報経由ですが。)
ですが、基本的な機能だけで認証機能とかはまだありません。
お次は認証情報(今回はメールアドレス)を使ってセッションを作成したりするお話っぽいです。

想定としては、メールアドレスとパスワードを入力してユーザー情報を取得する、という流れです。
なので

> user = Accounts.authenticate_by_email_password(email, password)

という形でユーザー情報が得られ、この情報をセッションに渡せば良さそうです。(ユーザー情報のやりとりなんかはChannelの章あたりが参考になるかもしれません。)

Contextに関数を追加

この関数をContextのlib/hello/accounts.exに追記します。

...
  def authenticate_by_email_password(email, _password) do
    query =
      from u in User,
        inner_join: c in assoc(u, :credential),
        where: c.email == ^email
  
    case Repo.one(query) do
      %User{} = user -> {:ok, user}
      nil -> {:error, :unauthorized}
    end
  end
end

一番最後に追記しました。

ここで、^emailとなっていて、「ピンオペレータ?」と思ったんですが右辺だしなんやねんこれ、と思っていたんですがどうやらEctoのクエリとして外部の変数(クエリ内で出てこない、Elixir上の変数)を利用するときはハット(^)を変数の名前の前に付けるそうです。

hexdocs.pm

もうちょっとEctoと仲良くなる必要があるかもしれません。

とりあえずこの章ではパスワードは破棄してますが、もしパスワード認証を利用したい場合はGuardianComeoninというトークンを発行したりハッシュ化したりするElixirのライブラリがあるそうなので、それらを使うのが良いそうです。
今回はメールアドレスがあるかないかだけ判定します。

Webレイヤーを実装

それでは先ほどの関数を利用して認証ページのようなものを作成していきます。
なにはともあれまずはコントローラです。lib/hello_web/controllers/session_controller.exを作成します。




大枠は普通のコントローラと同じです。createでセッションを生成してPlug.ConnにユーザーIDを渡しています。(本来であればトークンの方が望ましい?)
configure_sessionあたりはセッションID固定化攻撃(Session Fixation)対策だそうです。

次に、ルーターを編集します。lib/hello_web/router.exです。

...
  scope "/", HelloWeb do
    pipe_through :browser

    get "/", PageController, :index
    resources "/users", UserController
    resources "/sessions", SessionController, only: [:new, :create, :delete], singleton: true
  end
...

resources "/sessions"の行を追記しました。
コントローラには:new, :create, :deleteの3つしか実装していないので、resourcesの3つを利用するよう:onlyオプションを利用しています。
また、singletonオプションはURIにリソースのIDを作成しない設定だそうです。今回はセッションの作成用のURIなので/sessions/12345のようなIDは不要です。(セッション情報は接続情報に載っていくので)

あとは認証確認をPlugにしてルーター内で認証機能が必要なときに接続情報のパイプラインに乗せれるようにしておきます。同様にrouter.exの最後にでも書いときます。

...
  defp authenticate_user(conn, _) do
    case get_session(conn, :user_id) do
      nil ->
        conn
        |> Phoenix.Controller.put_flash(:error, "Login required")
        |> Phoenix.Controller.redirect(to: "/")
        |> halt()
      user_id ->
        assign(conn, :current_user, Hello.Accounts.get_user!(user_id))
    end
  end
end

use HelloWeb, :routerがあるのでrouter.exではimport Plug.Connが既になされています。なのでPlug.Conn.get_session/2get_session/2として利用できます。
この処理で:user_idがセッション情報内にあるかチェックしています。無ければput_flashでメッセージを送信してルートページへリダイレクトの後、Plug処理を停止という流れです。
あればセッション情報内に:current_userというキーでユーザーidを登録します。

このプラグは今は使いませんが、後々使うそうです。

ウェブレイヤー実装の最後にセッション生成用のビューを作成します。
lib/hello_web/views/session_view.exを作成して記載していきます。

defmodule HelloWeb.SessionView do
    use HelloWeb, :view
end

特に処理は不要なのでいたってシンプル。
テンプレートを作成します。lib/hello_web/templates/session/new.html.eex

<h1>Sign in</h1>

<%= form_for @conn, Routes.session_path(@conn, :create), [method: :post, as: :user], fn f -> %>
  <div class="form-group">
    <%= text_input f, :email, placeholder: "Email" %>
  </div>

  <div class="form-group">
    <%= password_input f, :password, placeholder: "Password" %>
  </div>

  <div class="form-group">
    <%= submit "Login" %>
  </div>
<% end %>

<%= form_for @conn, Routes.session_path(@conn, :delete), [method: :delete, as: :user], fn _ -> %>
  <div class="form-group">
    <%= submit "logout" %>
  </div>
<% end %>

シンプルなフォームです。
ここまで来ると、ようやくウェブ上で確認できます。
http://localhost:4000/sessions/newにアクセスします。

f:id:ysmn_deus:20190402105043p:plain

とりあえず表示は問題無さそうです。
試しに空送信してみると、きちんとエラーが表示されます。
パスワードは無視していますが、メールアドレスはデータベースを参照しています。試しに前回作成したメールアドレスでログインできるか入力します。

f:id:ysmn_deus:20190402105258p:plain

メールアドレスがあっていればルートページにリダイレクトされ、「Welcome back!」のメッセージが着いていると思います。

きりが良いのでココで終わらせたいのですが、逆にキリが悪い方が学習効率は上がる(ツァイガルニク効果)筈なのであとちょっとだけ進めます。

Cross-context dependencies

ユーザー認証はもう良さそうです(ホントはトークン発行にしたりなどありますが)。
お次はCMSたる所以のページをマネージする機能を実装していきたいと思います。

想定としては承認されたユーザー(管理者的な)はページを作成したり修正したりできるといったものです。現状ではAccountsというContextが存在してますが、CMSの機能はアカウント管理機能とは分離されていないと機能を実装したり拡張したりするときに非常にヤヤコシイことになります。ですのでContextを分けます。
ただし、タイトル的にはContext同士で依存関係をかくことにはなると思います。(100%分離するのは無理)

ということでCMSのContextを作成していくのですが、CMSの仕様を考えておきましょう。

  1. ページを作成したりアップデートしたりする
  2. ページは作成者に従属し、作成者に変更する権限がある
  3. 作成者の情報がページ上にあり、その役割("editior"や"writer"など)の情報もある

といったところでしょうか。
仕様やからページのリソースが必要なのは明確ですが、あとは「作成者」をどうするかです。
Accountsを拡張して役割を付与するのも手ですが、関係性がかなり複雑になるのが目に見えています。
ここではCMSのContextにAuthorというスキーマを作成し、このAuthorAccountsUserを関連付けるのが良さそうです。
基本的にユーザー情報と紐付けてなんかする、なんてのは山のようにあると思いますので、この辺の設計はこの章の設計を踏襲するのがよさそうです。というか、Accountsの設計はなんにでも流用できそうです。

さて、次回はmix phx.gen.htmlCMSのContextを作成して行きます。

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

f:id:ysmn_deus:20190219130922p:plain

どうも、靖宗です。
引き続きContextsです。この分量だと3回に別れるかな・・・

In-context Relationships

Relationshipsということはようやくテーブルの関連付けなどでしょうか。

前回までに作成したAccountuserですが、メールアドレスなど登録情報が何もありません。(パスワードはありますが・・・)
そこで、ログイン用のメールアドレスを追加するのですが、これをuserテーブルに追加してしまうと外部認証(Googleアカウントとかでログインするやつ)に対応しようとすると大変なことになります。
なのでアカウント情報にメールアドレスのデータを紐付けて管理することにします。

Contextに追加する

そこでまたContextを作成していくのですが、前回作成したAccountにContextを追加していくようなことができるそうです。
phx.gen.htmlのようなコマンド、mix phx.gen.contextで追加していきます。

PS \hello> mix phx.gen.context Accounts Credential credentials email:string:unique user_id:references:users
You are generating into an existing context.
The Hello.Accounts context currently has 6 functions and 1 files in its directory.

  * It's OK to have multiple resources in the same context as     long as they are closely related
  * If they are not closely related, another context probably works better

If you are not sure, prefer creating a new context over adding to the existing one.

Would you like to proceed? [Yn] Y
* creating lib/hello/accounts/credential.ex
* creating priv/repo/migrations/20190401004125_create_credentials.exs
* injecting lib/hello/accounts.ex
* injecting test/hello/accounts/accounts_test.exs

Remember to update your repository by running migrations:

    $ mix ecto.migrate

今回はウェブ上でのCRUD操作は要らないのでmix phx.gen.contextで追加したようです。
(userのviewなどに機能を追加していく)
* injectinglib/hello/accounts.exファイルに追記されているのが分かります。

参照先が削除されたときの振る舞い(ON DELETE)

Credentialの情報(今回はメールアドレスだけ)はユーザーIDと1対1で関連付いているので、usersのデータが消去されたときには消えて欲しいです。
なのでその情報をmigrationsのテーブル追加スクリプトに追記します。
* creating priv/repo/migrations/20190401004125_create_credentials.exsを編集します(環境によってタイムスタンプは違う)。

defmodule Hello.Repo.Migrations.CreateCredentials do
  use Ecto.Migration

  def change do
    create table(:credentials) do
      add :email, :string
      add :user_id, references(:users, on_delete: :delete_all), null: false # :nothing を :delete_all に

      timestamps()
    end

    create unique_index(:credentials, [:email])
    create index(:credentials, [:user_id])
  end
end

, null: falseも追記されてるのは、ユーザーと紐付いてない謎の認証情報が発生しないようにということでしょう。
これで良さそうなのでマイグレーションします。

PS \hello> mix ecto.migrate
Compiling 2 files (.ex)
Generated hello app
[info] == Running 20190401004125 Hello.Repo.Migrations.CreateCredentials.change/0 forward
[info] create table credentials
[info] create index credentials_email_index
[info] create index credentials_user_id_index
[info] == Migrated 20190401004125 in 0.0s

これでデータベース上のテーブルなどは良さそうです。

Contextで扱うスキーマの関係性

次にPhoenix側での関係性も記載しておきます。Contextのlib/hello/accounts/user.exに対応関係を追記します。

defmodule Hello.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset
  alias Hello.Accounts.Credential # 忘れないで(戒め)

  schema "users" do
    field :name, :string
    field :username, :string
    has_one :credential, Credential # 追加

    timestamps()
  end
...

has_oneマクロはEctoの機能だそうで、他のスキーマとの関連性をEcto側で使える様にするものだそうです。
同様にcredential.exの方にも関連性を記載します。

defmodule Hello.Accounts.Credential do
  use Ecto.Schema
  import Ecto.Changeset
  alias Hello.Accounts.User # 忘れないで(戒め)

  schema "credentials" do
    field :email, :string
    belongs_to :user, User # 通常のフィールドからbelongs_toに変更

    timestamps()
  end
...

belongs_toマクロは先ほど同様Ectoのマクロで、Userに対して従属していることをEctoに知らせます。
この辺自動でやってくれないかなぁ。

preloadの設定

お次はgetなどの取得時にCredentialのデータを取っておくように書いておく設定をContextに書いておきます。
lib/hello/accounts.exを編集します。

...
  def list_users do
    User
    |> Repo.all()
    |> Repo.preload(:credential)
  end
...
  def get_user!(id) do
    User
    |> Repo.get!(id)
    |> Repo.preload(:credential)
  end
...

Repo.preload(:credential)を入れておくことで%Accounts.User{}の構造体の中にデータが載るようです。
あとなんか効率的にデータベースのデータが取得できる?かもしれないです。この辺はちょっとよく分かりませんでしたが、関連付いてて、十中八九使用するデータはpreloadしておくのが吉かもしれません。

入力のテンプレートに入力フォームを追加

ここまできてようやくウェブ上の方に機能を追加していきます。
まずは入力フォームにemailを記入できるようにlib/hello_web/templates/user/form.html.eexを編集します。

...
  <div class="form-group">
    <%= inputs_for f, :credential, fn cf -> %>
      <%= label cf, :email %>
      <%= text_input cf, :email %>
      <%= error_tag cf, :email %>
    <% end %>
  </div>

  <div>
    <%= submit "Save" %>
  </div>
<% end %>

分かりにくいですが、基本的に<%= ○○ %>を除けばElixirのコードです。
inputs_for関数?はf(ここではUserのフォーム)に関連付いた情報で:credentialの入力を作成する、という構文のようです。fn cf ->以降は他の入力フォームと同じです。
一応ソースに直すのであれば

inputs_for f, :credential, fn cf ->
  label cf, :email
  text_input cf, :email
  error_tag cf, :email
end

のようになってる感じです。各関数?マクロ?は入力のタグを追加するってところでしょうか。

出力のテンプレートに表示を追加

あとは表示を追加しておきましょう。
lib/hello_web/templates/user/show.html.eexを編集します。

<h1>Show User</h1>

<ul>

  <li>
    <strong>Name:</strong>
    <%= @user.name %>
  </li>

  <li>
    <strong>Username:</strong>
    <%= @user.username %>
  </li>

  <li>
    <strong>Email:</strong>
    <%= @user.credential.email %>
  </li>
...

このへんは楽勝ですね。
この時点でPhoenixは機能すると思います。一応mix phx.serverhttp://localhost:4000/users/newにアクセスしてみます。

f:id:ysmn_deus:20190401105646p:plain

('ω')。o(????????????)

UndefinedFunctionError at GET /users/new
function Credential.__struct__/0 is undefined (module Credential is not available)

Credentialが使えない、なんでや。
ちょっとソースを見直した結果、Contextのそれぞれ(lib/hello/accounts/user.excredential.ex)にエイリアスを書くのを忘れていた。アホス。
追記して再度アクセス。

f:id:ysmn_deus:20190401110102p:plain

ヨシ!

ContextのCreateとUpdateを変更(関連データのバリデーション)

とはいっても、現段階ではただ表示されているだけで実際に保存しようとすると

UndefinedFunctionError at GET /users/2
function nil.email/0 is undefined

になると思います。これはContextのupdate_userなどが古いままなのでメール情報を格納するステップが抜けてるので、「メール情報ないやんけ!」と怒られてるのでしょう。
この辺を追加します。Contextのlib/hello/accounts/accounts.exを編集します。

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

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

  alias Hello.Accounts.{User, Credential} # Accountsの後ろに.をつけ忘れてた
...
  def create_user(attrs \\ %{}) do
    %User{}
    |> User.changeset(attrs)
    |> Ecto.Changeset.cast_assoc(:credential, with: &Credential.changeset/2)
    |> Repo.insert()
  end
...
  def update_user(%User{} = user, attrs) do
    user
    |> User.changeset(attrs)
    |> Ecto.Changeset.cast_assoc(:credential, with: &Credential.changeset/2)
    |> Repo.update()
  end
...
#  alias Hello.Accounts.Credential # 消してもヨシ、上で呼んでるので冗長
...

Repo.insertなどする前のパイプラインにEcto.Changeset.cast_assoc(:credential, with: &Credential.changeset/2)を追加しました。
Ecto.Changeset.cast_assoc/3はどうやら第一引数に大元の関連付いた構造体(今回だとユーザーの構造体)、第二引数に関連データを示すアトム(今回だと:credential、たぶんユーザー構造体に追加されたキーとして読み込んでる)、第三引数に関連データのchangesetを渡してバリデーションするといった流れでしょう。

これで行けるそうです。やってみます。

SyntaxError
lib/hello/accounts.ex:9: syntax error before: '{'

(╬´◓ω◔╬) 凡ミスです。alias Hello.Accounts{User, Credential}となっていたのでalias Hello.Accounts.{User, Credential}`に修正。

f:id:ysmn_deus:20190401112417p:plain

行けました。メールアドレスをブランクで保存しようとしても、きちんとバリデーションではじかれます。
もっときっちりするならCredentialでメールアドレスの形式でバリデーションしてもいいかもしれません。

もう一項目行きたかったんですがちょっと長くなってるのでこの辺で区切ります。

S3の静的ウェブサイトホスティング(2019年03月31日現在)

f:id:ysmn_deus:20190331130214p:plain

どうも、靖宗です。
S3で静的ウェブサイトを公開するときにちょっと詰まったのでメモ。
最初は下記のQiita記事を参考にさせていただきました。

qiita.com

また、当然のことだとは思いますが、アクセス権などをいじるので、その辺は自己責任でお願いします。

基本的な方針

基本的には

  • S3のバケットのルート(直下)にindex.htmlを配置する
  • CloudFrontは挟まない

という想定で行います。一般公開するならPVにもよるんでしょうがCloudFrontは挟んだ方が良いと思います。(Basic認証なんかもLambda使えばすぐですし。)

バケットの作成

なにはともあれバケットを作成しないことには始まりません。
S3のコンソールからバケットを作成します。

f:id:ysmn_deus:20190331130741p:plain

適当に名前を付けて次へ。
リージョンはどこでも良いと思うんですが、日本向けなら東京リージョンが良いんじゃ無いでしょうか。
プロパティとかはすっ飛ばして次へ。

f:id:ysmn_deus:20190331130855p:plain

ここで、パブリックアクセスの設定が出てきます。

f:id:ysmn_deus:20190331131104p:plain

ここでコントロールしてもいいんですが、一旦推奨設定を利用するという事で次へに行きます。
慣れてきたらここで後ほど説明する設定を適応しても良いと思います。

最後に確認画面が出てくるので問題無ければ「バケットを作成」
成功すれば一覧の所に先ほど決めた名前でバケットができてると思います。

f:id:ysmn_deus:20190331131314p:plain

ファイルのアップロード

早速ファイルをアップロードしていきます。
一応ルートディレクトリでなくてもホスティングできると思いますが、今回はルートに配置します。
適当に作ったindex.htmlを利用します。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>S3</title>
  <link href="" rel="stylesheet">
</head>
<body>
hoge
</body>
</html>

S3にアップロード

f:id:ysmn_deus:20190331131705p:plain

f:id:ysmn_deus:20190331131923p:plain

f:id:ysmn_deus:20190331131939p:plain

f:id:ysmn_deus:20190331131953p:plain

深いことは考えずとりあえずアップロードしました。
ここでもパブリックアクセスなどの設定で後々の手順をスキップできるのですが、今回は手順を極力分離します。

静的ウェブサイトホスティングの機能を有効にする

ホスティング機能を有効にします。S3のバケットのページからプロパティのページへ移動します。

f:id:ysmn_deus:20190331132243p:plain

Static website hostingと書かれた箇所をクリックします。

f:id:ysmn_deus:20190331132331p:plain

f:id:ysmn_deus:20190331132445p:plain

何もしなければここで記載されている「エンドポイント」がアクセスするURLになります。
独自ドメインの設定なんかはまた別の機会に。(というかぐぐれば出てくるとは思います) 設定を「ウェブサイトのホスティングを無効にする」から「このバケットを使用してウェブサイトをホストする」に変更します。

f:id:ysmn_deus:20190331132549p:plain

インデックスドキュメントのところに先ほどアップロードしたindex.htmlを指定します。
たぶんhtmlとかいうディレクトリに格納してる場合はhtml/index.htmlとかでもいけるとは思います。

設定を変更したら保存を押して設定完了。

こうすると、先ほどのエンドポイントで指定したURLでウェブサイトのホスティング

できないんですよこれが。

バケットポリシーの変更

S3のバケットのページからアクセス権限のページへ移動します。

f:id:ysmn_deus:20190331133444p:plain

f:id:ysmn_deus:20190331133730p:plain

いままで通りにバケットを作成していれば、上記のようになっていると思います。
ここの「パブリックバケットポリシーを管理する」がFalseになっていないとバケットポリシーを変更できません。ですので、この設定と、「バケットにパブリックポリシーがある場合、パブリックアクセスとクロスアカウントアクセスをブロックする」という設定をFalseにします。二個目の奴はバケットポリシーが変更されてもアクセスをはじく設定なので消しときます。

f:id:ysmn_deus:20190331135442p:plain

f:id:ysmn_deus:20190331135455p:plain

f:id:ysmn_deus:20190331135546p:plain

これでバケットポリシーが変更できるようになりました。

お次はバケットポリシーを変更していきます。

f:id:ysmn_deus:20190331135708p:plain

ポリシーエディターでポリシーを設定します。 自分は下記のように設定しましたが、ここは適宜変更して下さい(バケット名など)

 {
  "Version":"2012-10-17",
  "Statement":[
    {
      "Sid":"AddPerm",
      "Effect":"Allow",
      "Principal": "*",
      "Action":["s3:GetObject"],
      "Resource":["arn:aws:s3:::バケット名/*"]
    }
  ]
}

保存して完了です。公開できる設定になっていれば、アクセス権限やバケットポリシーの所に「パブリック」の表示が出ていると思います。

これでエンドポイントにアクセスして表示されれば静的ウェブサイトホスティングができています!
今回はホスティング機能だけ記載しましたが、本来であればCloudFrontを経由してアクセスさせるのが適切だと思います。

補足

もしかしたら場合によってはindex.htmlのアクセス権限をいじる必要があるかもしれません。
先ほど試した限りでは大丈夫でしたが、もしうまくいかない方がいらっしゃったらその辺をいじってみて下さい。

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

f:id:ysmn_deus:20190219130922p:plain

どうも、靖宗です。
最近AWSばっかり触っててご無沙汰でしたが、若干落ち着きそうなのでPhoenixの学習を再開していきます。
今回はContextsということですが、これもまた結構長そうな項目です。

Contexts

この章(機能?)いままでの機能を組み合わせるにはどうするか、などでしょうか。
確かに各々の機能にだけ注目してもなかなか実装できないことってありますよね。

Thinking about design

Elixirの標準ライブラリを利用する際に、内部実装を気にする必要は特にありません。Logger.info/1などは一度使い方を学べば特に内部で○○モジュールが動いてて・・・などと気にする人はそこまでいないと思います。(場合によっては必要だと思いますが)
Contextを利用することでPhoenixプロジェクトもElixirのモジュールのように外部から利用できるようにする機能という感じでしょうか。

この章では簡単なCMSを作成していくことで、このContextを学習しましょう!という方針のようです。

Adding an Accounts Context

まずはアカウントの実装からやっていくようです。
どんなシステムでもアカウントの登録や編集などはあると思いますし、わりかしこの辺のデザインがシステム全体に大きな影響を及ぼします。

前回のEctoの章で使ったプロジェクトを利用していくようですが、自分は新しく作り直します(作業してたプロジェクトがどっかいった)。
前回はユーザーのスキームを手動で作成しましたが、なにやらGeneratorsというもので生成するっぽいので、もし引き続きの人がいれば一旦消します。

$ rm lib/hello/user.ex
$ rm priv/repo/migrations/*_create_users.exs

マイグレーションのファイルも消してるっぽいです。
データベースのリセットも行っています。

mix ecto.reset

自分は新しく作り直したのでmix ecto.createまではいつも通りと同じです。

さて、ここまできてようやくContextを利用していくようです。GeneratorでアカウントのContextを作成していきます。
プロジェクトのルートフォルダでmix phx.gen.htmlとしていくと、どうやらCRUDのような機能まで実装してくれるっぽいです。

PS \hello> mix phx.gen.html Accounts User users name:string username:string:unique
* creating lib/hello_web/controllers/user_controller.ex
* creating lib/hello_web/templates/user/edit.html.eex
* creating lib/hello_web/templates/user/form.html.eex
* creating lib/hello_web/templates/user/index.html.eex
* creating lib/hello_web/templates/user/new.html.eex
* creating lib/hello_web/templates/user/show.html.eex
* creating lib/hello_web/views/user_view.ex
* creating test/hello_web/controllers/user_controller_test.exs
* creating lib/hello/accounts/user.ex
* creating priv/repo/migrations/20190331023135_create_users.exs
* creating lib/hello/accounts.ex
* injecting lib/hello/accounts.ex
* creating test/hello/accounts/accounts_test.exs
* injecting test/hello/accounts/accounts_test.exs

Add the resource to your browser scope in lib/hello_web/router.ex:

    resources "/users", UserController


Remember to update your repository by running migrations:

    $ mix ecto.migrate

今まではlib/hello_web/側のPhoenixのウェブ機能側にしか色々ファイルは自動生成されませんでしたが、lib/hello/側にもファイルが生成されました。これがContextでしょうか。
lib/hello/accounts/user.exスキーマの定義ファイルも生成されています。

とりあえずウェブ側の実装をしちゃいます。
lib/hello_web/router.exを編集します。

...
  scope "/", HelloWeb do
    pipe_through :browser

    get "/", PageController, :index
    resources "/users", UserController
  end
...

先ほどのmix phx.gen.htmlでuser関連のファイルは生成されているので、大体これで終わっちゃいます。やべえ。
スキーマの追加などされているのでデータベースをマイグレーションして起動してみます。

PS \hello> mix ecto.migrate
Compiling 6 files (.ex)
Generated hello app
[info] == Running 20190331023135 Hello.Repo.Migrations.CreateUsers.change/0 forward
[info] create table users
[info] create index users_username_index
[info] == Migrated 20190331023135 in 0.0s
PS \hello> mix phx.server

追加したページのhttp://localhost:4000/usersにアクセスします。

f:id:ysmn_deus:20190331114745p:plain

入力フォームまで既に用意されています。

f:id:ysmn_deus:20190331114832p:plain

なんでもいいけどとっとと実装したいときとかめっちゃ便利そうですね。
バリデーションやエラー表示までデフォルトでやってくれます。

f:id:ysmn_deus:20190331114946p:plain

ちなみに成功する表示もバッチリです。

f:id:ysmn_deus:20190331115220p:plain

f:id:ysmn_deus:20190331115313p:plain
ちゃんとリストページにも追加されてる

リストページからは編集や削除もできます。ありがたや。
デフォルトで生成されるCRUDAPIとしてのベストプラクティスみたいなものという認識でも結構有り難い機能です。

Starting With Generators

ただ、一応今回の目的はPhoenixプロジェクトのウェブ表示の機能以外でもユーザーの情報が取り扱えるようにContexを利用してみることです。
これじゃPhoenixのウェブ表示便利機能やんけ!と思うかもしれませんが、ここでuser_controller.exを見てみます。

defmodule HelloWeb.UserController do
  use HelloWeb, :controller

  alias Hello.Accounts
  alias Hello.Accounts.User

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

  def new(conn, _params) do
    changeset = Accounts.change_user(%User{})
    render(conn, "new.html", changeset: changeset)
  end

  def create(conn, %{"user" => user_params}) do
    case Accounts.create_user(user_params) do
      {:ok, user} ->
        conn
        |> put_flash(:info, "User created successfully.")
        |> redirect(to: Routes.user_path(conn, :show, user))
      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, "new.html", changeset: changeset)
    end
  end
  ...
end

そうなのです、

  alias Hello.Accounts
  alias Hello.Accounts.User

で、AccountsのContextを利用してコントローラが実装されていることが分かります。
これによって、ウェブ表示の機能とデータベース周りの機能が分離されていることが分かります。
仮にデータベースがPostgreSQLからMySQLに変わろうがPhoenix側ではなんら問題無いですし、なんなら設計次第ではテーブル構造が変わっても吸収できる実装も可能ということです。

次にAccountのContextであるlib/hello/accounts.exを見ていきます。

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

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

  alias Hello.Accounts.User

  @doc """
  Returns the list of users.

  ## Examples

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

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

非常にシンプルなモジュールです。Repoに対する操作をラップするようなモジュールになっています。
このContextに関数を追加することで、操作をカスタマイズしたり、新しい機能を実装したりして、様々な箇所でその機能を共有するのでしょう。この辺はわりとオブジェクト指向っぽい(カプセル化)ですが、機能のみの実装ですので似て非なる物です。

ここで、ユーザー作成の関数Accounts.create_user/1を見てみます。

  @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

(すっかり忘れてましたがcreate_user(attrs \\ %{})\\ %{}はデフォルトの引数は%{}やで!という事を意味してます。)
スキーマの作成や更新はchangesetでやるというのは前回やりました。
changesetの実装を見てみます。

defmodule Hello.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset
  alias Hello.Accounts.User


  schema "users" do
    field :name, :string
    field :username, :string

    timestamps()
  end

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

前回はスルーしましたが、changesetの前に@doc属性がfalseになっています。
これはどうやらドキュメントにこの情報が載らないようにするための属性だそうで、「プライベートな(他のAPIからは参照されない)実装」というところを意味するそうです。
(実際に外部から使えなくなる訳では無い)
なので、別のモジュールからuserの作成や更新をするときは、必ずAccountのContextを通して行うという方針を採るようです。
このへんはローカルルールみたいなもんなんで指針だけ把握してれば良いんじゃ無いかと思います。

今回はこの辺で!