2018年9月17日

Phoenix_3_Ecto

Ecto 是 RDB 的 wrapper。他有個稱為 changesets 的功能,可保存所有對 database 的修改內容,他會封裝在寫入 RDB 前的 receiving external data, casting 及 validating 這些程序。

設定 Ecto

修改 /lib/rumbl/repo.ex

defmodule Rumbl.Repo do
    use Ecto.Repo, otp_app: :rumbl
end

修改 /lib/rumbl.ex,enable Ecto supervisor

defmodule Rumbl do
  use Application

  # See http://elixir-lang.org/docs/stable/elixir/Application.html
  # for more information on OTP Applications
  def start(_type, _args) do
    import Supervisor.Spec

    # Define workers and child supervisors to be supervised
    children = [
      # Start the Ecto repository
      supervisor(Rumbl.Repo, []),
      # Start the endpoint when the application starts
      supervisor(Rumbl.Endpoint, []),
      # Start your own worker by calling: Rumbl.Worker.start_link(arg1, arg2, arg3)
      # worker(Rumbl.Worker, [arg1, arg2, arg3]),
    ]

    # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html
    # for other strategies and supported options
    opts = [strategy: :one_for_one, name: Rumbl.Supervisor]
    Supervisor.start_link(children, opts)
  end

  # Tell Phoenix to update the endpoint configuration
  # whenever the application is updated.
  def config_change(changed, _new, removed) do
    Rumbl.Endpoint.config_change(changed, removed)
    :ok
  end
end

設定 DB /config/dev.exs

# Configure your database
config :rumbl, Rumbl.Repo,
  adapter: Ecto.Adapters.MySQL,
  username: "root",
  password: "password",
  database: "rumbl",
  hostname: "localhost",
  pool_size: 10

產生資料庫

$ mix ecto.create

DB Schema and Migration

修改 /web/models/user.ex 的內容

defmodule Rumbl.User do
    use Rumbl.Web, :model
    schema "users" do
        field :name, :string
        field :username, :string
        field :password, :string, virtual: true
        field :password_hash, :string
        timestamps
    end
end

DSL 是用 Elixir macro 實作的,schema 及 field 讓我們指定 DB table 及 Elixir struc,每個 field 會關聯到 DB table 的一個欄位,以及 User struct 的 field。

另外有增加一個 virtual 欄位,這是 password 的 hash 前結果,virtual field 不需要對應到 DB table 的欄位,因為 DB 只要紀錄 hash 後的密碼,Ecto 會自動定義 Elixir struct,所以可以直接使用 %Rumbl.User{}。

在 /web/web.ex 裡面 model 定義為

  def model do
    quote do
      use Ecto.Schema

      import Ecto
      import Ecto.Changeset
      # 增加  , only: [from: 1, from: 2]
      import Ecto.Query, only: [from: 1, from: 2]
    end
  end

現在我們需要用 "migrations" 的功能,將 DB schema 的定義反應到實際的 DB 裡面,migration 會修改資料庫以符合 application 的需要。

$ mix ecto.gen.migration create_user
* creating priv/repo/migrations
* creating priv/repo/migrations/20170903125816_create_user.exs

打開 priv/repo/migrations/20170903125816createuser.exs ,填入 change 的 function 內容(create table: users)

defmodule Rumbl.Repo.Migrations.CreateUser do
  use Ecto.Migration

  def change do
    create table(:users) do
      add :name, :string
      add :username, :string, null: false
      add :password_hash, :string
      timestamps()
    end
    create unique_index(:users, [:username])
  end
end

Ecto.Migration API 提供了 create, remove, and change database tables, fields, and indexes 這些 functions。

$ mix ecto.migrate
[info] == Running Rumbl.Repo.Migrations.CreateUser.change/0 forward
[info] create table users
[info] create index users_username_index
[info] == Migrated in 0.0s

要注意,目前是針對 dev 環境的處理,如果要換別的環境,就要設定 MIX_ENV 這個環境變數。

利用 Repository 新增 data

利用 IEX 產生測試資料

$ iex -S mix
iex(1)> alias Rumbl.Repo
Rumbl.Repo
iex(2)> alias Rumbl.User
Rumbl.User


iex(3)> Repo.insert(%User{name: "Jose", username: "josie", password_hash: "<3<3elixir"})
[debug] QUERY OK db=2.7ms
INSERT INTO `users` (`name`,`password_hash`,`username`,`inserted_at`,`updated_at`) VALUES (?,?,?,?,?) ["Jose", "<3<3elixir", "josie", {{2017, 9, 3}, {13, 9, 55, 939011}}, {{2017, 9, 3}, {13, 9, 55, 941294}}]
{:ok,
 %Rumbl.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">, id: 1,
  inserted_at: ~N[2017-09-03 13:09:55.939011], name: "Jose", password: nil,
  password_hash: "<3<3elixir", updated_at: ~N[2017-09-03 13:09:55.941294],
  username: "josie"}}
  
  
iex(4)> Repo.insert(%User{name: "Bruce", username: "bruce", password_hash: "7langs"})
[debug] QUERY OK db=1.8ms
INSERT INTO `users` (`name`,`password_hash`,`username`,`inserted_at`,`updated_at`) VALUES (?,?,?,?,?) ["Bruce", "7langs", "bruce", {{2017, 9, 3}, {13, 10, 3, 333839}}, {{2017, 9, 3}, {13, 10, 3, 333851}}]
{:ok,
 %Rumbl.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">, id: 2,
  inserted_at: ~N[2017-09-03 13:10:03.333839], name: "Bruce", password: nil,
  password_hash: "7langs", updated_at: ~N[2017-09-03 13:10:03.333851],
  username: "bruce"}}
  
  
iex(5)> Repo.insert(%User{name: "Chris", username: "chris", password_hash: "phoenix"})
[debug] QUERY OK db=182.8ms
INSERT INTO `users` (`name`,`password_hash`,`username`,`inserted_at`,`updated_at`) VALUES (?,?,?,?,?) ["Chris", "phoenix", "chris", {{2017, 9, 3}, {13, 10, 13, 487813}}, {{2017, 9, 3}, {13, 10, 13, 487825}}]
{:ok,
 %Rumbl.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">, id: 3,
  inserted_at: ~N[2017-09-03 13:10:13.487813], name: "Chris", password: nil,
  password_hash: "phoenix", updated_at: ~N[2017-09-03 13:10:13.487825],
  username: "chris"}}
  
  
iex(6)> Repo.all(User)
[debug] QUERY OK source="users" db=2.6ms
SELECT u0.`id`, u0.`name`, u0.`username`, u0.`password_hash`, u0.`inserted_at`, u0.`updated_at` FROM `users` AS u0 []
[%Rumbl.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">, id: 1,
  inserted_at: ~N[2017-09-03 13:09:55.000000], name: "Jose", password: nil,
  password_hash: "<3<3elixir", updated_at: ~N[2017-09-03 13:09:55.000000],
  username: "josie"},
 %Rumbl.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">, id: 2,
  inserted_at: ~N[2017-09-03 13:10:03.000000], name: "Bruce", password: nil,
  password_hash: "7langs", updated_at: ~N[2017-09-03 13:10:03.000000],
  username: "bruce"},
 %Rumbl.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">, id: 3,
  inserted_at: ~N[2017-09-03 13:10:13.000000], name: "Chris", password: nil,
  password_hash: "phoenix", updated_at: ~N[2017-09-03 13:10:13.000000],
  username: "chris"}]
  
  
iex(7)> Repo.get(User, 1)
[debug] QUERY OK source="users" db=1.9ms
SELECT u0.`id`, u0.`name`, u0.`username`, u0.`password_hash`, u0.`inserted_at`, u0.`updated_at` FROM `users` AS u0 WHERE (u0.`id` = ?) [1]
%Rumbl.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">, id: 1,
 inserted_at: ~N[2017-09-03 13:09:55.000000], name: "Jose", password: nil,
 password_hash: "<3<3elixir", updated_at: ~N[2017-09-03 13:09:55.000000],
 username: "josie"}

從 mix phoenix.server 的 console log 也能發現,查詢的語法已經改用 SQL 連接 DB,而不是 memory DB。

$ mix phoenix.server

[info] GET /users
[debug] Processing by Rumbl.UserController.index/2
  Parameters: %{}
  Pipelines: [:browser]
[debug] QUERY OK source="users" db=2.2ms decode=4.6ms
SELECT u0.`id`, u0.`name`, u0.`username`, u0.`password_hash`, u0.`inserted_at`, u0.`updated_at` FROM `users` AS u0 []
[info] Sent 200 in 82ms

Building Forms

Phoenix form builder

修改 /web/controllers/user_controller.ex,增加 new 新增 User 的 function

defmodule Rumbl.UserController do
  use Rumbl.Web, :controller

  # 取得所有 users
  def index(conn, _params) do
    users = Repo.all(Rumbl.User)
    render conn, "index.html", users: users
  end

  # 顯示某個 id 的 user
  def show(conn, %{"id" => id}) do
    user = Repo.get(Rumbl.User, id)
    render conn, "show.html", user: user
  end

  alias Rumbl.User
  # 利用 changeset 功能新增 user
  def new(conn, _params) do
    changeset = User.changeset(%User{})
    render conn, "new.html", changeset: changeset
  end
end

修改 /web/models/user.ex

defmodule Rumbl.User do
  use Rumbl.Web, :model
  schema "users" do
    field :name, :string
    field :username, :string
    field :password, :string, virtual: true
    field :password_hash, :string
    timestamps()
  end

  # 參數: a User struct and parameters
  # User struct 傳入 cast,限制 name 及 username 都是必要欄位
  # 並將 requied & optional values 轉成 schema types
  # 接下來傳入 validate_length,檢查 username 資料長度

  # 如果沒有任何 parameters,必須傳入 empty map  %{}
  def changeset(model, params \\ :empty) do
    model
    |> cast(params, ~w(name username), [])
    |> validate_length(:username, min: 1, max: 20)
  end
end

注意 User.changeset 的部分,他會接收一個 struct 及 controller 的參數,回傳 Ecto.Changeset。

Changeset 可讓 Ecto 管理 record changes, cast parameters, perform validations。可使用 changeset 製作 customized strategy 處理各種資料修改的功能,例如新增使用者或更新資訊。

注意: 在舊版 Ecto 要用 :empty 當作 empty map,但在 Ecto 2.0 後,要改寫為 %{}

ref: Empty atom in Ecto changeset

接下來修改 /web/router.ex 的 scope

  scope "/", Rumbl do
    pipe_through :browser # Use the default browser stack

    get "/", PageController, :index
    resources "/users", UserController, only: [:index, :show, :new, :create]
  end

resources 是一個簡化的一組 REST action implementations,包含 create, read, update, delete

resources "/users", UserController

等同以下這些網址的定義

get "/users", UserController, :index
get "/users/new", UserController, :new
get "/users/:id", UserController, :show

get "/users/:id/edit", UserController, :edit

post "/users", UserController, :create
patch "/users/:id", UserController, :update
put "/users/:id", UserController, :update
delete "/users/:id", UserController, :delete

剛剛的定義用 only: 限制只使用某幾個 route 定義

resources "/users", UserController, only: [:index, :show, :new, :create]

可在 console 直接用這個指令查看整個 application 的 routes

$ mix phoenix.routes
page_path  GET   /           Rumbl.PageController :index
user_path  GET   /users      Rumbl.UserController :index
user_path  GET   /users/new  Rumbl.UserController :new
user_path  GET   /users/:id  Rumbl.UserController :show
user_path  POST  /users      Rumbl.UserController :create

新增網頁

<h1>New User</h1>

<%= form_for @changeset, user_path(@conn, :create), fn f -> %>
  <div class="form-group">
    <%= text_input f, :name, placeholder: "Name", class: "form-control" %>
  </div>
  <div class="form-group">
    <%= text_input f, :username, placeholder: "Username", class: "form-control" %>
  </div>
  <div class="form-group">
    <%= password_input f, :password, placeholder: "Password", class: "form-control" %>
  </div>
  <%= submit "Create User", class: "btn btn-primary" %>
<% end %>

http://localhost:4000/users/new

<h1>New User</h1>

<form accept-charset="UTF-8" action="/users" method="post"><input name="_csrf_token" type="hidden" value="VQAbE0ACaEdYJglWOgIbMioeICosAAAA60MX/k8+nSByckvzFyogeg=="><input name="_utf8" type="hidden" value="✓"><div class="form-group">
<input class="form-control" id="user_name" name="user[name]" placeholder="Name" type="text"></div>
<div class="form-group">
<input class="form-control" id="user_username" name="user[username]" placeholder="Username" type="text"></div>
<div class="form-group">
<input class="form-control" id="user_password" name="user[password]" placeholder="Password" type="password"></div>
<button class="btn btn-primary" type="submit">Create User</button></form>

另外有個 protocol: Phoenix.HTML.FormData,可分離畫面以及後端實作,Ecto.Changeset 也實作了這個 Protocol,並把 Table 的 data strcuture 直接轉換到 Phoenix form。

Creating Resources

  def create(conn, %{"user" => user_params}) do
    changeset = User.changeset(%User{}, user_params)
    # Repo.insert 的結果有兩種,正確時,會 redirect 到 users index 首頁
    # 錯誤時,會停留在 new.html 的畫面
    case Repo.insert(changeset) do
      {:ok, user} ->
        conn
        |> put_flash(:info, "#{user.name} created!")
        |> redirect(to: user_path(conn, :index))
      {:error, changeset} ->
        render(conn, "new.html", changeset: changeset)
    end
  end

修改 /web/templates/user/new.html.eex ,加上 error message

<h1>New User</h1>

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

  <div class="form-group">
    <%= text_input f, :name, placeholder: "Name", class: "form-control" %>
    <%= error_tag f, :name %>
  </div>
  <div class="form-group">
    <%= text_input f, :username, placeholder: "Username", class: "form-control" %>
    <%= error_tag f, :username %>
  </div>
  <div class="form-group">
    <%= password_input f, :password, placeholder: "Password", class: "form-control" %>
    <%= error_tag f, :password %>
  </div>
  <%= submit "Create User", class: "btn btn-primary" %>
<% end %>

error_tag helper 定義在 web/views/error_helpers.ex 可顯示錯誤訊息。


$ iex -S mix

iex(1)> changeset = Rumbl.User.changeset(%Rumbl.User{username: "eric"})
#Ecto.Changeset<action: nil, changes: %{}, errors: [], data: #Rumbl.User<>,
 valid?: true>

iex(2)> import Ecto.Changeset
Ecto.Changeset

iex(3)> changeset = put_change(changeset, :username, "eric")
#Ecto.Changeset<action: nil, changes: %{}, errors: [], data: #Rumbl.User<>,
 valid?: true>
iex(4)> changeset.changes
%{}

iex(5)> changeset = put_change(changeset, :username, "ericname")
#Ecto.Changeset<action: nil, changes: %{username: "ericname"}, errors: [],
 data: #Rumbl.User<>, valid?: true>
iex(6)> changeset.changes
%{username: "ericname"}
iex(7)>  get_change(changeset, :username)
"ericname"

Ecto 使用 changesets 儲存 persistence 前後異動的資料。

References

Programming Phoenix

2018年9月10日

Phoenix_2_Controller_View_Template

首先 create 新的 project rumbl

$ mix phoenix.new rumbl --database mysql

修改 config/dev.exs 的 DB 設定,然後 create database

$ mix ecto.create

啟動 server

mix phoenix.server

或是

iex -S mix phoenix.server

網址在 http://localhost:4000

調整首頁 /web/templates/page/index.html.eex

<div class="jumbotron">
  <h2>Welcome to Rumbl.io</h2>
  <p class="lead">Rumble out loud.</p>
</div>

Create User

系統通常會以 User 開發為起點。首先定義 Rumbl.User module,包含欄位 id, name, username, password

/web/models/user.ex

defmodule Rumbl.User do
  defstruct [:id, :name, :username, :password]
end

為什麼要使用 struct 而不是 map 定義 User model?

使用 map,在撰寫 model 定義,並不會在編譯時發現有打錯字的狀況,另外也不能為所有欄位都填上預設值。

在 project folder 直接測試

$ iex -S mix
Generated rumbl app

# 可以使用 User
iex(1)> alias Rumbl.User
Rumbl.User

# 以 Map 定義 user
iex(2)> user = %{usernmae: "jose", password: "elixir"}
%{password: "elixir", usernmae: "jose"}

# 因為剛剛定義 user map 時,把 username 打錯了
iex(3)> user.username
** (KeyError) key :username not found in: %{password: "elixir", usernmae: "jose"}

# 如果用 User struct 就不會發生這個問題,還將其他欄位都填上預設值 nil
iex(3)> jose = %User{name: "Jose"}
%Rumbl.User{id: nil, name: "Jose", password: nil, username: nil}
iex(4)> jose.name
"Jose"

# 編譯時就會檢查欄位名稱
iex(5)>  chris = %User{nmae: "chris"}
** (KeyError) key :nmae not found in: %Rumbl.User{id: nil, name: nil, password: nil, username: nil} ....

# struct 就跟 map 一樣,差別只在於 struct 是一個擁有 __struct__ key 的 map
iex(5)> jose.__struct__
Rumbl.User

Working with Repositories

Repository 是一組儲存資料的 API,可快速建立測試資料的 data interface,待完成 view, template 後,就可以替換為完整的 database-backend repository。換句話說,可以將 data model 跟 database 完全分離。

修改 /lib/rumbl/repo.ex

defmodule Rumbl.Repo do
  #  use Ecto.Repo, otp_app: :rumbl

  # 所有 User 的資料
  def all(Rumbl.User) do
    [
      %Rumbl.User{id: "1", name: "Jose", username: "josie", password: "elixir"},
      %Rumbl.User{id: "2", name: "Bruce", username: "bruce", password: "pass"},
      %Rumbl.User{id: "3", name: "Chris", username: "chris", password: "phx"}
    ]
  end

  def all(_module), do: []

  # get user by id
  def get(module, id) do
    Enum.find all(module), fn map -> map.id == id end
  end

  # get user by a custom attribute
  def get_by(module, params) do
    Enum.find all(module), fn map ->
      Enum.all?(params, fn {key, val} -> Map.get(map, key) == val end)
    end
  end

end

將 /lib/rumbl.ex,關掉跟 Ecto 相關的 supervisor

#      supervisor(Rumbl.Repo, []),

檢查 User 的測試資料庫

$ iex -S mix
iex(1)> alias Rumbl.User
Rumbl.User
iex(2)> alias Rumbl.Repo
Rumbl.Repo

iex(3)> Repo.all User
[%Rumbl.User{id: "1", name: "Jose", password: "elixir", username: "josie"},
 %Rumbl.User{id: "2", name: "Bruce", password: "pass", username: "bruce"},
 %Rumbl.User{id: "3", name: "Chris", password: "phx", username: "chris"}]
 
iex(4)> Repo.all Rumbl.Other
[]

iex(5)> Repo.get User, "1"
%Rumbl.User{id: "1", name: "Jose", password: "elixir", username: "josie"}

iex(6)> Repo.get_by User, name: "Bruce"
%Rumbl.User{id: "2", name: "Bruce", password: "pass", username: "bruce"}

撰寫 Controller & View

修改 /web/router.ex,將 /users 及 /users/:id 對應到 UserController 的 :index 及 :show 兩個 functions

  scope "/", Rumbl do
    pipe_through :browser # Use the default browser stack

    get "/users", UserController, :index
    get "/users/:id", UserController, :show
    get "/", PageController, :index
  end

新增 /web/controllers/user_controller.ex

defmodule Rumbl.UserController do
  use Rumbl.Web, :controller

  def index(conn, _params) do
    users = Repo.all(Rumbl.User)
    render conn, "index.html", users: users
  end

end

/web/views/user_view.ex

defmodule Rumbl.UserView do
  use Rumbl.Web, :view

  alias Rumbl.User
  def first_name(%User{name: name}) do
    name
    |> String.split(" ")
    |> Enum.at(0)
  end

end

/web/templates/user/index.html.eex

<h1>Listing Users</h1>
<table class="table">
    <%= for user <- @users do %>
    <tr>
        <td><b><%= first_name(user) %></b> (<%= user.id %>)</td>
        <td><%= link "View", to: user_path(@conn, :show, user.id) %></td>
    </tr>
    <% end %>
</table>

因為剛剛有修改 /lib/rumbl/Repo.ex,因為 lib 目錄更新程式後不會自動 reload,記得要重新啟動 phoenix.server,修改後的程式才會有作用。

mix phoenix.server

Using Helpers

link function 封裝了很多有用的 functions,可用來處理很多 HTML structures。

link 的第二個參數是 keyword list

iex(1)> Phoenix.HTML.Link.link("Home", to: "/")
{:safe, [60, "a", [[32, "href", 61, 34, "/", 34]], 62, "Home", 60, 47, "a", 62]}

iex(2)> Phoenix.HTML.Link.link("Delete", to: "/", method: "delete")
{:safe,
 [60, "a",
  [[32, "data-csrf", 61, 34,
    "DSMgIAMOCzQqQTczRR0/ElEUFDIJJgAA8KZynKSYp9CZ0/MYaNCwDA==", 34],
   [32, "data-method", 61, 34, "delete", 34], [32, "data-to", 61, 34, "/", 34],
   [32, "href", 61, 34, "#", 34], [32, "rel", 61, 34, "nofollow", 34]], 62,
  "Delete", 60, 47, "a", 62]}

HTML helper 會放在每個 view 的最上層,Phoenix.HTML 負責處理 HTML functions in views

/web/web.ex 裡面 view 的區塊內容為

  def view do
    quote do
      use Phoenix.View, root: "web/templates"

      # Import convenience functions from controllers
      import Phoenix.Controller, only: [get_csrf_token: 0, get_flash: 2, view_module: 1]

      # Use all HTML functionality (forms, tags, etc)
      use Phoenix.HTML

      import Rumbl.Router.Helpers
      import Rumbl.ErrorHelpers
      import Rumbl.Gettext
    end
  end

ref: Phoenix.HTML Helpers for working with HTML strings and templates.

不能把自己做的 function 直接寫在 web.ex,要用 import 的方式處理

Showing a User

剛剛在 /web/router.ex 有定義這個 url route

get "/users/:id", UserController, :show

修改 /web/controllers/user_controller.ex,增加 show function,注意參數的部分為 %{"id" => id},這是從網址來的

defmodule Rumbl.UserController do
  use Rumbl.Web, :controller

  def index(conn, _params) do
    users = Repo.all(Rumbl.User)
    render conn, "index.html", users: users
  end

  def show(conn, %{"id" => id}) do
    user = Repo.get(Rumbl.User, id)
    render conn, "show.html", user: user
  end
end

新增 /web/templates/user/show.html.eex

<h1>Showing User (<%= @user.id %>)</h1>
<b><%= first_name(@user) %></b> (<%= @user.id %>)'s username is <b><%= @user.username %></b>

http://localhost:4000/users/1


Naming Conventions

當 Phoenix 在 controller 要 render templates 時,他會使用 controler module 名稱 Rumbl.UserController 參考到 view module 的名稱為 Rumbl.UserView,template 目錄在 /web/templates/user/。

在後面可知道如何 customize 這些 conventions


Nesting Templates,共用 template

新增 /web/templates/user/user.html.eex

<b><%= first_name(@user) %></b> (<%= @user.id %>)'s username is <b><%= @user.username %></b>

修改 /web/templates/user/show.html.eex

<h1>Showing User (<%= @user.id %>)</h1>
<%= render "user.html", user: @user %>

修改 /web/templates/user/index.html.eex

<h1>Listing Users</h1>
<table class="table">
    <%= for user <- @users do %>
    <tr>
        <td><%= render "user.html", user: user %></td>
        <td><%= link "View", to: user_path(@conn, :show, user.id) %></td>
    </tr>
    <% end %>
</table>

<%= render "user.html", user: user %> 這就是共用的部分

iex -S mix 測試 view

$ iex -S mix
iex(1)> user = Rumbl.Repo get Rumbl.User, "1"
** (SyntaxError) iex:1: syntax error before: get

iex(1)> user = Rumbl.Repo.get Rumbl.User, "1"
%Rumbl.User{id: "1", name: "Jose", password: "elixir", username: "josie"}
iex(2)> view = Rumbl.UserView.render("user.html", user: user)
{:safe,
 [[[[[[["" | "<b>"] | "Jose"] | "</b> ("] | "1"] | ")'s username is <b>"] |
   "josie"] | "</b>"]}
iex(3)> Phoenix.HTML.safe_to_string(view)
"<b>Jose</b> (1)'s username is <b>josie</b>"

每個 template 最後都會變成 render(template_name, assigns),因此 rendering template 就是 template name 及 function 的 pattern matching。

我們可以跳過整個 template 機制,自訂自己的 render clause,這也是 Rumbl.ErrorView 提供的自訂錯誤頁面的方式。

/web/views/error_view.ex

defmodule Rumbl.ErrorView do
  use Rumbl.Web, :view

  def render("404.html", _assigns) do
    "Page not found"
  end

  def render("500.html", _assigns) do
    "Internal server error"
  end

  # In case no render clause matches or no
  # template is found, let's render it as 500
  def template_not_found(_template, assigns) do
    render "500.html", assigns
  end
end

Phoenix.View module 也提供了 render view 的 functions,包含 a function to render 及 轉換 rendered template 為 string的 function。

Phoenix.View 會呼叫 view 的 render functions。

iex(5)> user = Rumbl.Repo.get Rumbl.User, "1"
%Rumbl.User{id: "1", name: "Jose", password: "elixir", username: "josie"}


iex(6)> Phoenix.View.render(Rumbl.UserView, "user.html", user: user)
{:safe,
 [[[[[[["" | "<b>"] | "Jose"] | "</b> ("] | "1"] | ")'s username is <b>"] |
   "josie"] | "</b>"]}


iex(7)> Phoenix.View.render_to_string(Rumbl.UserView, "user.html", user: user)
"<b>Jose</b> (1)'s username is <b>josie</b>"

Layouts

在 controller 呼叫 render 時,controller 會先 render the layout view,然後再依照 predefined markup 去 render the actual template

因為 layout 也是 view with templates,每個 template 會收到一些特定的變數,

查看 /web/templates/layout/app.html.eex 的內容有一段 <%= render @view_module, @view_template, assigns %>1

layouts 就是 HTML templates that embed an action’s HTML

  <body>
    <div class="container">
      <header class="header">
        <nav role="navigation">
          <ul class="nav nav-pills pull-right">
            <li><a href="http://www.phoenixframework.org/docs">Get Started</a></li>
          </ul>
        </nav>
        <span class="logo"></span>
      </header>

      <p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>
      <p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>

      <main role="main">
        <%= render @view_module, @view_template, assigns %>
      </main>

    </div> <!-- /container -->
    <script src="<%= static_path(@conn, "/js/app.js") %>"></script>
  </body>

References

Programming Phoenix

Using MySQL with the Phoenix Framework

2018年9月3日

Phoenix_1_Installation

Phoenix 是一個 Productive. Reliable. Fast. 的 web framework,以 elixir 實作,運作在 erlang VM 上。

installation

  • erlang vm

  • elixir

  • hex: elixir's package manager

    mix local.hex
  • MySQL/PostgreSQL

  • Node.js for Assets

    必須要 v5.3.0 以上

    node --version
  • Phoenix

    $ mix archive.install https://github.com/phoenixframework/archives/raw/master/phoenix_new.ez
  • mix phoenix.new

    mix 會增加 phoenix.new 的選項

    $ mix -h
    ...
    mix phoenix.new       # Creates a new Phoenix v1.2.5 application

create hello project,預設 DB 是使用 PostgreSQL,我們改用 MySQL

$ mix phoenix.new hello --database mysql

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

Fetch and install dependencies? [Yn] y
* running mix deps.get
* running npm install && node node_modules/brunch/bin/brunch build

We are all set! Run your Phoenix application:

    $ cd hello
    $ mix phoenix.server

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

    $ iex -S mix phoenix.server

Before moving on, configure your database in config/dev.exs and run:

    $ mix ecto.create

設定 DB 連線資訊

config/dev.exs

config :hello, Hello.Repo,
  adapter: Ecto.Adapters.MySQL,
  username: "root",
  password: "password",
  database: "hello",
  hostname: "localhost",
  pool_size: 10

執行 ecto.create

$ mix ecto.create

啟動 phoenix

$ mix phoenix.server
[info] Running Hello.Endpoint with Cowboy using http://localhost:4000
16:42:29 - info: compiled 6 files into 2 files, copied 3 in 1.1 sec

或是用 IEx (Interactive Elixir) 啟動

$ iex -S mix phoenix.server

網址在 http://localhost:4000

Simple tutorial

在 /web/router.ex 裡面有一段 scope 的定義

  scope "/", Hello do
    pipe_through :browser # Use the default browser stack

    get "/", PageController, :index
  end

所有以 ./ 開頭的 route 都會符合這個規則,pipe_through :browser macro 負責處理一般 browser 的 requests。

增加 /hello 的網址,指定給另一個 Controller

    get "/hello", HelloController, :world
    get "/", PageController, :index

http://localhost:4000/hello 會出現錯誤訊息

UndefinedFunctionError at GET /hello
function Hello.HelloController.init/1 is undefined (module Hello.HelloController is not available)

新增 /web/controllers/hello/hello_controller.ex

defmodule Hello.HelloController do
  @moduledoc false

  use Hello.Web, :controller
  def world(conn, _params) do
    render conn, "world.html"
  end

end

再瀏覽一次 http://localhost:4000/hello 會出現不同的錯誤訊息

UndefinedFunctionError at GET /hello
function Hello.HelloView.render/2 is undefined (module Hello.HelloView is not available)

新增 /web/views/hello_view.ex

defmodule Hello.HelloView do
  use Hello.Web, :view
end

新增 /web/templates/hello/world.html.eex

<h1>From template: Hello world!</h1>

不需要重新啟動 server,reload 網頁就可以看到結果


修改 web/router.ex,把網址當作變數

get "/hello/:name", HelloController, :world

修改 hello_controller.ex

defmodule Hello.HelloController do
  @moduledoc false

  use Hello.Web, :controller
  # external 參數定義為 "name" => name,但內部卻是用atom,也就是 name: name,這是因為 atom table 不會被 GC。
  def world(conn, %{"name" => name}) do
    render conn, "world.html", name: name
  end

end

修改 world.html.eex

<h1>Hello <%= String.capitalize @name %>!</h1>

瀏覽網址 http://localhost:4000/hello/test


lib 跟 web 資料夾的用途

supervision trees 及 long-running processes 要放在 lib

web-related code 包含 models, views, templates, and controllers 這些放在 web

當 code reloading 功能打開時,web 資料夾裡面的程式在被修改後,會自動 reload,但是 lib 資料夾的程式並不會自動 reload,因此 lib 很適合放 long-running services,例如 Phoenix’s PubSub system, the database connection pool 或是自訂的 supervised processes。

.exs 是 Elixir scripts,不會編譯為 .beam files,因此 mix 設定檔 mix.exs 是用 script 而不是 .ex

Phoenix 支援master configuration 加上其他不同環境的設定檔,所以可看到主設定檔 config.exs 裡面有一段

import_config "#{Mix.env}.exs"

就是說明,這是透過 MIX_ENV 環境變數,判斷 prod/dev/test 三種環境,會嵌入不同環境的設定檔

dev.exs
prod.exs
test.exs

另外 production 環境有一個獨立另外嵌入的設定檔 prod.secret.exs,這是用來儲存 production 環境需要被保護的一些密碼,獨立的檔案可保持設定不會進入 git server。

Endpoint

config.exs 包含了 loggin, endpoint 的設定

Endpoint 是 web server 處理 connection 的介面,設定檔裡面只有一個 Hello.Endpoint

# Configures the endpoint
config :hello, Hello.Endpoint,
  url: [host: "localhost"],
  root: Path.dirname(__DIR__),
  secret_key_base: "g8c9YZ5dYGeA.....XkLz5",
  render_errors: [accepts: ~w(html json)],
  pubsub: [name: Hello.PubSub,
           adapter: Phoenix.PubSub.PG2]

而 /lib/hello/endpoint.ex 中 Hello.Endpoint 裡面包含了這些 chaing of functions 及 plugs

defmodule Hello.Endpoint do
    use Phoenix.Endpoint, otp_app: :hello
    
    plug Plug.Static, ...
    plug Plug.RequestId
    plug Plug.Logger
    plug Plug.Parsers, ...
    plug Plug.MethodOverride
    plug Plug.Head
    plug Plug.Session, ...
    plug Hello.Router
end

這在內部其實是用 pipeline 做的,Endpoints 就是對每一個 request 執行 chain of functions。

connection
    |> Plug.Static.call
    |> Plug.RequestId.call
    |> Plug.Logger.call
    |> Plug.Parsers.call
    |> Plug.MethodOverride.call
    |> Plug.Head.call
    |> Plug.Session.call
    |> Hello.Router.call

Endpoint 也是一個 plug,尤其他 plugs 組合而成。application 是以 series of plugs 組成,由 endpoint開始,以 controller 結束,最後一個 plug 是 controller,也就是 web/router.ex 定義的 controller。

connection
    |> endpoint
    |> plug
    |> plug
    ...
    |> router
    |> HelloController

通常 application 只需要一個 endpoint,但也沒有限制只能有一個,如果要支援 http 80 及 https 443 或是 特別的 8080 for admin,就可以增加 endpoints

Router Flow

web/router.ex 是由兩個部分組成的: pipelines 及 route table

defmodule Hello.Router do
  use Hello.Web, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_flash
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/", Hello do
    pipe_through :browser # Use the default browser stack

    get "/", PageController, :index
  end

  # Other scopes may use custom stacks.
  # scope "/api", Hello do
  #   pipe_through :api
  # end
end

有時會需要針對不同的網址,提供不同的 set of tasks/transformations,例如 :browser 是針對 HTML,有提供取得 session 的方法,並有一個稱為 flash 的 user message system,這裡也提供 security service,例如 request forgery protection。

第二個 pipeline 為 :api,是針對 JSON 的 API,只會處理 JSON requests,如果像要轉換為只處理 XML,就要修改這裡的 plug。

hello application 只使用了 :browser,是用 pipe_through :browser 指定的。

一般的 Phoenix application 是這樣組成的

connection
|> endpoint
|> router
|> pipeline
|> controller
  • endpoint: functions for every request
  • connection: 會經過 named pipeline,針對幾種主要類型的 request提供一般化的 functions
  • controller: 會呼叫 data model 並透過 view template 產生頁面

Controllers, Views, and Templates

web 目錄為

└── web
    ├── channels
    ├── controllers
    │ ├── page_controller.ex
    │ └── hello_controller.ex
    ├── models
    ├── static
    ├── templates
    │ ├── hello
    │ │ └── world.html.eex
    │ ├── layout
    │ │ └── app.html.eex
    │ ├── page
    │ │ └── index.html.eex
    ├── views
    │ ├── error_view.ex
    │ ├── layout_view.ex
    │ ├── page_view.ex
    │ └── hello_view.ex
    ├── router.ex
    └── web.ex

最底層為 router.ex 及 web.ex,web.ex 定義了整個 application structure。

在後面會提到 channel 的部分。

  • Erlang VM 會提供 application scalability
  • endpoint 能過濾 static request,並 parse request 然後呼叫 router
  • browser pipeline 會處理 "Accept" header,取得 session,也能防止 Cross-Site Request Forgery(CSRF) 的攻擊

hello application 相關的檔案

connection                      # Plug.Conn
    |> endpoint                 # lib/hello/endpoint.ex
    |> browser                  # web/router.ex
    |> HelloController.world    # web/controllers/hello_controller.ex
    |> HelloView.render(        # web/views/hello_view.ex
        "world.html")           # web/templates/hello/world.html.eex

References

Programming Phoenix

Using MySQL with the Phoenix Framework