日常学习

Phoenix framework 1.3 context

August 07, 2017

Design

思考设计

context 是专门用来 组织、暴露 相关的功能的module。比如我们每次调用Elixir标准库 Logger.info, 其实是在接触不同的context, 在内部, Elixir Logger是由 诸如 Logger.Config. Logger.Backends 的module组成的, 但是我们从来不会直接跟这些module交互, 我们使用Logger context, 因为他组织并且暴露接口。
Phoenix 组织目录类似于其他的Elixir Project, 我们拆分代码到context中, 一个context 将会组织相关的功能代码, 比如post,comment, 经常封装诸如 验证、数据存取的功能。应用context, 我们拆分系统到 容易管理、互相独立的 组成部分中。

建立Account context

    mix phx.gen.html Accounts User user users name:string username:string:unique
    resources "/users", UserController
    mix ecto.migrate

    scopes "/" HelloWeb do
      pipe_through :browser

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

starting with Generators

  defmodule HelloWeb.UserController do
    use HelloWeb, :controller
    alias Hello.Accounts

    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(%Hello.Accounts.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 create success")
          |> redirect(to: user_path(conn, :show, user))
        {:error, %Ecto.Changeset{} = changeset} ->
          render(conn, "new.html", changeset: changeset)
      end
    end
  end

In-context relationship

 * 我们基础的用户功能实现的非常好,让我们补充实现用户登录,我们不会实现一个完整的认证系统,而只是给我们一个系统成长的好的开始。许多的验证系统的解决方案是 凭证跟用户是一对一的。这样经常造成问题,比如支持不同的登录方式,例如social的给你路, email邮箱登录。将会造成主要代码逻辑改变,我们要建立一个凭证、用户一对一,但是容易支持其他功能。

mix phx.gen.context Accounts Credenttial credentials email:string:unique user_id:references:users
我们使用phx.gen.context 区别与 phx.gen.html 除了不会生成 html,因为我们已经有了Controller
Template
我们可以从输出看到Phoenix为我们的Accounts.Credential 生成了 accounts/credential.ex , 还有migration
在执行迁移之前,我们需要修改生成的迁移文件。我们需要删除用户的凭证,当删除用户的时候,执行下面的改动

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

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

我们将:on_delete 从nothing 改变为:delete_all,这就会生成外键约束,约束会删除所有的用户凭证,当用户删除的时候, 我们设定null: false 来阻止 创建没有关联用户的凭证创建。应用数据库约束,我们在数据层面限制data, 而不是在应用层面。
在我们与web 层面交互之前,我们需要 让我们的context 知道关联的user 和credentials , 编辑lib/accoutns/user.ex


  alias Hello.Accounts.{User, Credential}

  schema "users" do
    field :name, :string
    field :username, :string
    has_one :credential, Credential
    timestamps()
  end

我们使用Ecto.Schema’s has_one macro来让Ecto知道如何关联User and Credential, 同样的编辑accounts/credential.ex

  alias Hello.Accounts.{Credential, User}
  schema "credential" do
    field :email, :string
    field :user_id, :id
    belongs_to :user, User
    timestamps()
  end
  def list_users do
    User
    |> Repo.all()
    |> Repo.preload(:credenttial)
  end

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

我们重写了list_user 和get_user以便于主动加载credential 关联关系,无论什么时候我们获取用户,Repo.preload 函数获取schema的关联关系。当我们操作一个集合,比如list_users, Ecto可以高效的预加载关联关系在一个sql中, 这允许我们直接调用credients,而不需要额外的 查询。
下面我们在页面中加入credential

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

  <div class="form-group">
    <%= submit "Submit", class: "btn btn-primary" %>
  </div>

使用 Phoenix.HTML’s inputs_for 函数来添加一个关联的嵌套的fields, 在嵌套的inputs中,我们渲染了 credfential‘s 的email error_tag 等。
展示用户的user 的email地址在用户的show页面中。添加下面的代码到show.html.eex中

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

现在如果我们访问users/new 我们会看到email的输入框,如果保存,你会发现email自大un被护士了,没有验证告诉你字段没有被保存。
我们是哟个Ecto’s belongs_to has_one 关联关系来贯穿起来,为了关联用户输入到数据库,我们需要在changeset中处理,改动Accounts context中的create_user , update_user 函数。

  def update_user(%User{} = user, attrs) do
    user
    |> User.changeset(attrs)
    |> Ecto.Changeset.cast_assoc(:credential, with: &Credential.changset/2)
    |> Repo.update()
  end

  def create_user(attrs \\ %{}) do
    %User{}
    |> User.changset(attrs)
    |> Ecto.Changeset.cast_assoc(:credential, with &Credential.changset/2)
    |> Repo.insert()
  end

Ecto’s cast_assoc 告诉changset如何转换用户的输入到schema 关系中。我们使用with告诉changset使用Credential.changeset函数来转换用户输入,这样,Credential.changset 中所有的验证会应用到 User中。
最后,我们可以测试,会见到Email的验证。

Adding Account functions

如你所见, context是一个包粗一组相关函数的module, Phoenix 生成一般的函数, 例如list_users, upate_users, 但是他们只提供一个基础的,为了使用一些真是的功能来扩展我们的Account context, 让我们修复我们应用中的明显的问题, -我们可以创建带有凭证的用户,但是却无法使用凭证登录,建立一个负载的完整的用户验证系统超出了本书的范围,让我们着手建立一个基于email的登录页面来跟踪用户的session, 这会让我们聚焦在Accounts context上, 并且给你一个建立完成的认证系统上一个好的开始。
首先,开始想名字,为了使用email地址来验证用户,我们需要一个方法来查找用户和验证凭证是否正确,我们只暴露一个函数来完成这件事情。

    > user = Accounts.authenticate_by_email_password(email, password)

这看起来很好, 一个描述的名字暴露了我们代码的意图。这一功能使它清楚它充当什么目的,同时允许调用者仍然一无所知的内部细节。 lib/hello/accounts/accounts.ex

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

我们定义的authenticate_by_email_password 函数, 我们忽略掉了password 字段,你可以使用FIX ME 留在以后构建。我们需要的函数是使用验证凭证 来寻找到user, 返回包含%Accounts.User{} and :ok tuple, 或者一个{:error, :unauthorized} 来让调用者知道他们的验证是错误的。
现在我们使用context来验证用户,我们添加一个login page 在我们的web,首先创建一个新的controller 在 lib/hello_web/controllers/session_controller.ex

  defmodule HelloWeb.SessionController do
      use HelloWeb, :controller
      alias Hello.Accounts

      def new(conn, _) do
          render(conn, "new.html")
      end

      def create(conn,k %{"user" => %{"email" => email, "password" => password}}) do
          case Accounts.authenticate_by_email_password(email, password) do
              {:ok, user} ->
                  conn
                  |> put_flash(:info, "welcome back")
                  |> put_session(:user_id, user.id)
                  |> configure_session(renew: true)
                  |> redirect(to: "/")
              {:error, :unauthorized} ->
                  conn
                  |> put_flalsh(:error, "bad email/password combination")
                  |> redirect(to: session_path(conn, :new))
          end
      end
  end

我们定义了一个SessionController来处理用户signing and out,new action 只是简单的渲染 “new session”表单, 表单简单的post 到create action, 我们使用模式匹配然后调用 Accounts.authenticate_by_email_password, 如果成功的话,我们使用Plug.Conn.put_session 来将验证过的用户ID放到 session中, 然后跳转到home page, 我们需要调用 configure_session(conn, renew: true) 在重定向之前,来避免session fixation 个哦高年级, 如果验证错误, 我们添加一个falsh error, 并重定向 四个in-in, 我们添加一个delete action 简单的调用Plug.Conn.configure_session 来删除掉session并重定向到home page
lib/hello_web/router.ex

  scope "/" HelloWeb do
      pipe_through :brower

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

我们使用resources 来生成 一系列的routes 在”/sessions”下,使用:only 来限制我们的route 生成。因为我么只需要支持 :new, :create, :delete action, 我们添加singleton: true 选项,来定义restful routes, 但是不需要一个resource ID 在URL中。 我们不需要一个ID在URL中,因为我们的action 总是知道current_user, 这个ID总是在session中,在我们完成controller之前, 我们添加一个验证的plug 在route中来限制用户访问, lib/hello_web/router.ex

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

我们定义了authenticate_user plug 在路由中,只是简单的使用get_sessioin 来检查session中的:user_id, 如果我们找到了, 以为着user 已经验证过了,我们就调用Accounts.get_user!,并在当前的connection assigns中赋值:current_user, 如果没有找到,我们就flash 一个错误信息, 重定向到 home page上, 我们使用Halt来组织后面的代码被进一步调用,
最后,我们需要SessionView来渲染模板, lib/hello_web/views/session_view.ex

  defmodule HelloWeb.SessioinView do
      use HelloWeb, :view
  end

cross-context dependencies

我们已经开始了 用户的 帐号与登录凭证 功能,我们开始开发我们应用的重要功能, 管理页面内容,我们开发一个支持用户创建、编辑 网站页面的cms内容。我们可以拓展用户系统。如果我们回国头来想想应用的独立性,这个并不合适,一个用户系统并不应该关心cms系统,Account context 的职责是管理用户与凭证,不处理任何的页面内容。这时候需要一个独立的context来进行管理。CMS
我们创建一个CMS context 来处理cms 的职责,在我们写代码之前,可以想想CMS 的功能有这些:

  1. Page 的create、 update
  2. Page 属于创建的用户
  3. 用户的信息应该展示在页面中
    从描述中,我们清楚的知道需要一个Page resource来存储page, 那author信息怎么办? 我们拓展现有的Account.User来包含, Role等信息, 这违反了我们定义的context原则,我们定义了User context为什么User context 需要注意到author 的信息呢?
    存在用户的应用中,会自然的成为一个严重的User驱动应用。但是,我们的应用总是为用户使用设计的。与其扩展Account.User结构来追踪平台每个字段的变化,不如,让拥有职责功能的module来负责。在我们的应用中,我们创建CMS.Author 结构来管理Author中管理CMS 的字段信息。
  mix phx.gen.html CMS Page pages title:string body:text views:integer --web CMS
  mix ecto.migrate
  scope "/cms", HelloWeb.CMS as: :cms do
      pipe_through [:bowser, :authenticate_user]
      resources "/pages", PageController
  end

我们添加了 authenticate_user 来要求在CMS scope中的路由都需要用户登录。
创建Author

  mix phx.gen.context CMS Author authors bio:text role:string gere:string user_id:reference:users:unique
  mix ecto.migrate

我们添加了 bio, role,genre, user_id在author中。这样可以保证,CMS API不会因为User context的改动而改动。

  def change do
      create table(:authors) do
          add :bio, :text
          add :role, :string
          add :genre, :string
          add :user_id, references(:users, on_delete: :nothing)
          add :user_id, references(:users, on_delete: :delete_all), null: false

          timestamps()
      end

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

我们添加了delete_all,来限制当用户删除的时候,我们不要依靠应用代码来清理CMS author代码。
开始之前, 我们需要一个新的数据迁移,我们有一个author表,我们需要关联pages authors, 我们添加个author_id字段到pages,

  mix ecto.gen.migration add_author_id_to_pages

  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

  mix ecto.migrate

Cross-context data

依赖在系统中总是不可避免的,但我们进最大的努力来限制他们,当有可能减少以来的时候,我们已经完美的使用context 来区分应用的不同部分,但是我们依然需要处理依赖。
Author 来负责CMS中的author, CMS context 会对Account context存在一个数据上的依赖。我们有两个选择,1:在Account context中暴露API 来允许我们在CMS中方便的获取User。 2:通过数据库的join操作来获取依赖部分的数据,都是可能的选项,但是join data 对于一个硬的数据依赖,对于大应用来说是很好的,如果你决定拆分context 分散到不同应用,数据中,你依然享受隔离的好处,因为你的开放的接口保持不变。
/lib/hello/cms/page.ex

  - alias Hello.CMS.Page
  + alis Hello.CMS.{Page, Author}

  schema "pages" do
      field :body, :string
      field :title, :string
      field :views, :integer
      belongs_to :author, Author
      timestamps()
  end

添加了author page, 之间的关联关系

  - alias Hello.CMS.Author
  + alias Hello.CMS.{Author, 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
      timestamps()
  end

下面我们来改变 CMS context需要一个author当更新、创建page的时候,

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

  def list_pages do
      Page
      |> Repo.all()
      |> Repo.preload(author: [user: :credential])

  end

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

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

我们在list_pages, get_page中,preload 关联的author, user,credential 从数据库中,
我们来处理 数据的存储, 在创建、编辑page的时候,保存author, 编辑文件, lib/hello/cms/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

lib/helo_web/controllers/cms/page_controller.ex

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

      def require_existing_author(conn, _) do
          author = CMS.ensure_author_exists(conn.assigns.current_user)
          asign(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 this page")
              |> redirect(to: cms_page_path(conn, :index))
              |> halt()
          end
      end

我们在 CMS.PageController中添加了 两个Plug, 地一个plug, :require_existing_author, 在每个action都需要执行, 函数 调用CMS.ensure_author_exists 并传递current_user过去,当找到 或者创建author,我们使用Plug.Conn.assign 来将 current_author 传递到下去。
第二步,我们使用:authorized_page来过滤特定的action, 函数 地一个从connection params获取page,,然后跟current_author 进行验证, 如果current_author 的ID 跟Page的author_id一样,可以通过验证, 如果不一样,我们Plug.Conn.halt 来阻止, action的进一步执行。

   def create(conn, %{"page" => page_params}) do
  +   case CMS.create_page(conn.assigns.current_author, page_params) do
      {:ok, page} ->
        conn
        |> put_flash(:info, "Page created successfully.")
        |> redirect(to: cms_page_path(conn, :show, page))
      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, "new.html", changeset: changeset)
    end
  end

  def update(conn, %{"page" => page_params}) do
  +   case CMS.update_page(conn.assigns.page, page_params) do
      {:ok, page} ->
        conn
        |> put_flash(:info, "Page updated successfully.")
        |> redirect(to: cms_page_path(conn, :show, page))
      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, "edit.html", changeset: changeset)
    end
  end

  def delete(conn, _) do
  +   {:ok, _page} = CMS.delete_page(conn.assigns.page)

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

我们改变了create 的写法,从connection assign中获取current_author, authenticate_user 会存放进去, 然后我们传递current_author 到 CMS.create_page 中 来关联到page。
在web view中展示 author’s name
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

完成!我现在有两个独立的分别负担 user context 和内容管理