この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。
Phoenixは生産性、信頼性、速度に強みを持つElixir製のWebアプリケーションフレームワークです。 前回は環境準備とPhoenixアプリケーションを作成するところまで書きました。
高生産性、高信頼性、高速のElixir製Webアプリケーションフレームワーク、Phoenixを始める
その続きで、今回はCRUDなWebアプリケーションを作成したいと思います。
プロジェクト作成
以下のコマンドでプロジェクトを作成します。
$ mix phoenix.new hello
プロジェクトができたら、前回の記事を元にプロジェクトの初期化を行ってください。
Modelの定義
PhoenixではDatabase操作にEcto
というモジュールを使います(RailsのActiveRecordに相当するものですが、Elixirは関数型言語のためORマッパーではありません)。
Ectoについては今回深く説明しませんが、特徴はデータベース操作やクエリの組み立てを関数合成のように出来るところや、Changesets
という機能でバリデーションやパラメータのキャストを行うところです。
それではユーザーのmodelクラスを定義しましょう
hello/web/models/user.ex
defmodule Hello.User do
use Hello.Web, :model
schema "users" do
field :name, :string
field :email, :string
field :password, :string, virtual: true
field :password_hash, :string
timestamps
end
@required_fields ~w(name email)
@optional_fields ~w()
end
DSLでスキーマを定義しています。このschema
やfield
はmacroで定義されていて、それぞれのフィールド名はテーブルの各カラム名と一致します。
コードでは書かれていないですが、テーブルの主キーであるid
フィールドは自動で定義されます。
また、passwordフィールドにはvirtual
の指定がありますが、このフィールドはDBには存在しません。
DBのpassword_hashフィールドにハッシュ化したパスワードを登録するので、そのためのパスワードを保持するフィールドです。
続いて、いま定義したUserクラスの情報を保存するUserテーブルのmigrationファイルを作成しましょう。
以下のコマンドで作ります。
$ mix ecto.gen.migration create_user
生成されたファイルにindexを追加します(create uniqu_index..
のところです)。
hello/priv/repo/migrations/yyyyMMddHHmmss_create_user.exs
defmodule Hello.Repo.Migrations.CreateUser do
use Ecto.Migration
def change do
create table(:users) do
add :name, :string
add :email, :string, null: false
add :password_hash, :string
timestamps
end
create unique_index(:users, [:email])
end
end
ファイルを作成したら、以下のコマンドでmigrationを実行しUserテーブルを作ります。
mix ecto.migrate
Controllerの定義
ルートの定義
まず、router.ex
にユーザー情報のルートを書きます。
hello/web/router.ex
scope "/", Hello do
pipe_through :browser
resources "/users", UserController
end
reourcesの定義をすると、index, show, edit, update, deleteのルートが定義されます。
試しにコマンドでルートの確認をしてみましょう。
$ mix phoenix.routes
user_path GET /users Hello.UserController :index
user_path GET /users/:id/edit Hello.UserController :edit
user_path GET /users/new Hello.UserController :new
user_path GET /users/:id Hello.UserController :show
user_path POST /users Hello.UserController :create
user_path PATCH /users/:id Hello.UserController :update
PUT /users/:id Hello.UserController :update
user_path DELETE /users/:id Hello.UserController :delete
もちろん、resources "/users"
ではなく、以下のように定義しても同じようにルート定義できます。
hello/web/router.ex
scope "/", Hello do
pipe_through :browser
get "/users", UserController, :index
get "/users/:id", Usercontroller, :show
# その他のメソッドについても同様に定義する
end
indexメソッド
それではcontrollerクラスを作成します。
まず、indexメソッドです。
hello/web/controllers/user_controller.ex
defmodule Hello.UserController do
use Hello.Web, :controller
alias Hello.User
def index(conn, _params) do
users = Repo.all(User)
render conn, "index.html", users: users
end
end
Repo.all(User)
でDBにあるユーザー一覧を取得してビューをレンダリングしています。
alias Hello.User
はHello.User
モジュールをUser
だけで呼び出せるようにするために記述しています。
次にビューファイルを作成しましょう。
Phoenixでは一つの画面でview
とtemplate
の二つのファイルを作ります。
それぞれ以下の役割を持っています。
- viewはデータを表示用に加工するための関数を定義するモジュール
- templateはHTMLもしくはJSONに変換されるファイル
templateファイルが従来のWeb Application Frameworkのviewファイルに近いですね。
viewファイルはとりあえず以下のように空のまま定義します。
hello/web/view/user_view.ex
defmodule Hello.UserView do
use Hello.Web, :view
end
templateファイルも作成しましょう。
hello/web/templates/user/index.html.eex
<div class="row">
<aside class="col-md-4">
<%= for user <- @users do %>
<section>
<%= render "user.html", user: user, conn: @conn %>
<%= link "Show", to: user_path(@conn, :show, user), class: "btn btn-default btn-xs" %>
<%= link "Edit", to: user_path(@conn, :edit, user), class: "btn btn-default btn-xs" %>
</section>
<% end %>
</aside>
</div>
hello/web/templates/user/user.html.eex
<a href="<%= user_path(@conn, :show, @user) %>">
<img src="<%= get_gravatar_url(@user) %>" class="gravatar">
</a>
<h1><%= @user.name %></h1>
(get_gravatar_urlはユーザーのavator画像を表示するための関数です、後ほど説明します)
newメソッド
新規ユーザーを作成するnewメソッドを追加します。
hello/web/controller/user_controller.ex
defmodule Hello.UserController do
# 省略
# ここを追加
def new(conn, _params) do
changeset = User.changeset(%User{})
render conn, "new.html", changeset: changeset
end
end
User.changeset(%{User{})
はこれからUserクラスに作る関数です。
パラメータを受け取って、パラメータのキャスト、バリデーション、レコードの変更を行い、処理結果を保持しているEcto.Changeset
を返却するようにします。ここではユーザーの作成に必要なフォーム情報を作成し返却しています。
それではUserモデルにchangeset関数を追加します
hello/web/models/user.ex
defmodule Hello.User do
# 省略
# ここを追加
def changeset(model, params \\ :empty) do
model
|> cast(params, @required_fields, @optional_fields)
|> validate_length(:name, min: 1, max: 20)
|> validate_format(:email, ~r/@/)
|> unique_constraint(:email)
end
end
ここではパラメータを必須フィールド、任意フィールドに割り当て、それぞれのフィールドのバリデーションを実行しています。
Railsの場合、各フィールドのバリデーションをフィールドの定義と同時に行っているのでここはPhoenixとRailsの違いが明確に出ています。
templateファイルも作成します
hello/web/templates/user/new.html.eex
<h1>New User</h1>
<%= form_for @changeset, user_path(@conn, :create), fn f -> %>
<%= if @changeset.action do %>
<div class="alert alert-danger">
<p>エラーです、以下のメッセージを確認してください</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, :email, placeholder: "Email", class: "form-control" %>
<%= error_tag f, :email %>
</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 %>
createメソッド
ユーザー作成画面から呼ばれるcreateメソッドを作成します。
ユーザーのレコードをinsertし、成功したらユーザー一覧画面に遷移させます。
hello/web/controllers/user_controller.ex
defmodule Hello.UserController do
# 省略
# ここから追加
def create(conn, %{"user" => user_params}) do
changeset = User.changeset(%User{}, user_params)
case Repo.insert(changeset) do
{:ok, user} ->
conn
|> put_flash(:info, "#{user.name}を作成しました")
|> redirect(to: user_path(conn, :index))
{:error, changeset} ->
render(conn, "new.html", changeset: changeset)
end
end
end
show
ユーザー詳細画面と画面を呼び出すshowメソッドを追加します。
hello/web/controllers/user_controller.ex
defmodule Hello.UserController do
# 省略
# ここから追加
def show(conn, %{"id" => id}) do
user = Repo.get(User, id)
render conn, "show.html", user: user
end
end
hello/web/templates/user/show.html.eex
<div class="row">
<aside class="col-md-4">
<section>
<%= render "user.html", user: @user, conn: @conn %>
<%= link "Edit", to: user_path(@conn, :edit, @user), class: "btn btn-default btn-xs" %>
<%= button "Delete", to: user_path(@conn, :delete, @user),
method: :delete,
onclick: "return confirm(\"本当に削除しますか?\");",
class: "btn btn-danger btn-xs" %>
<%= link "Back", to: user_path(@conn, :index), class: "btn btn-default btn-xs"%>
</section>
</aside>
</div>
edit, update
ユーザー更新のメソッドと更新画面を作成します
hello/web/controllers/user_controller.ex
defmodule Hello.UserController do
# 省略
# ここから追加
def edit(conn, %{"id" => id}) do
user = Repo.get(User, id)
changeset = User.changeset(user)
render(conn, "edit.html", user: user, changeset: changeset)
end
def update(conn, %{"id" => id, "user" => user_params}) do
user = Repo.get(User, id)
changeset = User.changeset(user, user_params)
case Repo.update(changeset) do
{:ok, user} ->
conn
|> put_flash(:info, "更新しました")
|> redirect(to: user_path(conn, :show, user.id))
{:error, changeset} ->
render(conn, "edit.html", user: user, changeset: changeset)
end
end
end
templateファイルも用意します。フォームを別ファイルに切り出して共通化します。
hello/web/templates/user/edit.html.eex
<%= render "form.html", changeset: @changeset, action: user_path(@conn, :update, @user) %>
<%= link "Back", to: user_path(@conn, :index) , class: "btn btn-default btn-xs" %>
hello/web/templates/user/form.html.eex
<%= form_for @changeset, @action, fn f -> %>
<%= if @changeset.action do %>
<div class="alert alert-danger">
<p>error! Please check the errors below.</p>
</div>
<% end %>
<div class="form-group">
<%= label f, :name, class: "control-label" %>
<%= text_input f, :name, class: "form-control" %>
<%= error_tag f, :name %>
</div>
<div class="form-group">
<%= label f, :email, class: "control-label" %>
<%= text_input f, :email, class: "form-control" %>
<%= error_tag f, :email %>
</div>
<div class="form-group">
<%= submit "Submit", class: "btn btn-primary" %>
</div>
<% end %>
delete
最後にユーザー削除のメソッドを追加します。
hello/web/controllers/user_controller.ex
defmodule Hello.UserController do
# 省略
# ここから追加
def delete(conn, %{"id" => id}) do
user = Repo.get(User, id)
Repo.delete(user)
conn
|> put_flash(:info, "削除しました")
|> redirect(to: user_path(conn, :index))
end
end
認証、認可
このままですと、認証していない状態でも各操作ができてしますので認証の仕組みを作ります。
comeonin
というライブラリーを利用して簡単なパスワード認証機能を実装します。
comeonin
パスワード認証
mix.exsファイルにcomeonin
を追加します。
hello/mix.exs
defmodule Hello.Mixfile do
use Mix.Project
# 省略
# applications:に:commeoninを追加する
def application do
[mod: {Hello, []},
applications: [:phoenix, :phoenix_html, :cowboy, :logger, :gettext,
:phoenix_ecto, :postgrex, :comeonin]]
end
# 省略
# 依存ライブラリにcomeoninとそのバージョンを指定します
defp deps do
[{:phoenix, "~> 1.1.4"},
{:postgrex, ">= 0.0.0"},
{:phoenix_ecto, "~> 2.0"},
{:phoenix_html, "~> 2.4"},
{:phoenix_live_reload, "~> 1.0", only: :dev},
{:gettext, "~> 0.9"},
{:cowboy, "~> 1.0"},
{:comeonin, "~> 2.0"}]
end
# 省略
end
以下のコマンドでライブラリをダウンロードします
mix deps.get
続いてUserクラスにユーザー登録時のパスワードチェック、ハッシュ化処理を追加します
hello/web/models/user.ex
defmodule Hello.User do
# 省略
# ここから追加
def registration_changeset(model, params) do
model
|> changeset(params)
|> cast(params, ~w(password), [])
|> validate_length(:password, min: 6, max: 100)
|> put_pass_hash()
end
defp put_pass_hash(changeset) do
case changeset do
%Ecto.Changeset{valid?: true, changes: %{password: pass}} ->
put_change(changeset, :password_hash, Comeonin.Bcrypt.hashpwsalt(pass))
_ ->
changeset
end
end
end
定義済みのchangeset(params)
でパスワード以外のパラメータのバリデーションを行います。
そのあとにパスワードのバリデーション(文字列長チェック)を行い最後にput_pass_hash()
でパスワードをハッシュ化してDBに保存しています。
controllerのcreateメソッドを、registration_changeset
メソッドを使用するように修正します。
hello/web/controllers/user_controller.ex
defmodule Hello.UserController do
# 省略
def create(conn, %{"user" => user_params}) do
# ここをregistration_changesetに変更
changeset = User.registration_changeset(%User{}, user_params)
case Repo.insert(changeset) do
{:ok, user} ->
conn
|> put_flash(:info, "#{user.name}を作成しました")
|> redirect(to: user_path(conn, :index))
{:error, changeset} ->
render(conn, "new.html", changeset: changeset)
end
end
end
認証、認可モジュール
認証、認可用のモジュールを作り複数のコントローラから使用できるようにします。
また、ログイン、ログアウトのメソッドもこのモジュールに記述しましょう。
init
とcall
メソッドはPlugとして必ず必要なメソッドです。詳細は今回説明しませんが、initで取得したconn(Plug.Conn構造体)をcallで受け取りDB接続処理を行っています。
hello/web/controllers/auth.ex
defmodule Hello.Auth do
import Plug.Conn
import Comeonin.Bcrypt, only: [checkpw: 2, dummy_checkpw: 0]
import Phoenix.Controller
alias Hello.Router.Helpers
alias Hello.User
def init(opts) do
Keyword.fetch!(opts, :repo)
end
def call(conn, repo) do
user_id = get_session(conn, :user_id)
user = user_id && repo.get(User, user_id)
assign(conn, :current_user, user)
end
def login_by_name_and_pass(conn, name, given_pass, opts) do
repo = Keyword.fetch!(opts, :repo)
user = repo.get_by(User, name: name)
cond do
user && checkpw(given_pass, user.password_hash) ->
# ここでpasswordのチェックが成功した場合にloginメソッドを呼び出してセッションにユーザーのidを保存しています(処理はloginメソッドに委譲しています)
{:ok, login(conn, user)}
user ->
{:error, :unauthorized, conn}
true ->
dummy_checkpw()
{:error, :not_found, conn}
end
end
def login(conn, user) do
conn
|> assign(:current_user, user)
|> put_session(:user_id, user.id)
|> configure_session(renew: true)
end
def logout(conn) do
configure_session(conn, drop: true)
end
# 認可用メソッド
def authenticate_user(conn, _opts) do
if conn.assigns.current_user do
conn
else
conn
|> put_flash(:error, "ログインしてください")
|> redirect(to: Helpers.session_path(conn, :new))
|> halt()
end
end
end
コントローラ、ルーターで共通してauthenticate_user
メソッドを使用できるようにweb.ex
にも追加します
hello/web/web.ex
defmodule Hello.Web do
# 省略
def controller do
quote do
use Phoenix.Controller
alias Hello.Repo
import Ecto
import Ecto.Query, only: [from: 1, from: 2]
import Hello.Router.Helpers
import Hello.Gettext
import Hello.Auth, only: [authenticate_user: 2] # ここを追加
end
end
# 省略
def router do
quote do
use Phoenix.Router
import Hello.Auth, only: [authenticate_user: 2]
end
end
end
認可処理がリクエスト時に呼ばれるようにrouterを変更します。
pipeline :browser
とresouces "/users"
の中に今回作ったモジュールと、認可用メソッドのauthenticae_user
を追加します。
hello/web/router.ex
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
plug Hello.Auth, repo: Hello.Repo # ここを追加
end
scope "/", Hello do
pipe_through :browser
get "/", PageController, :index
resources "/users", UserController do
pipe_through [:authenticate_user] # ここを追加
end
end
end
続いて、コントローラの各メソッドで認可処理が走るように記述を追加します。
hello/web/controllers/user_controller.ex
defmodule Hello.UserController do
use Hello.Web, :controller
alias Hello.User
plug :authenticate_user when action in [:index, :show, :edit, :update, :delete] # ここを追加
# 省略
end
ログイン、ログアウト
SessionController
という名称でlogin, logout処理を実装したコントローラを作成します。
まずはrouterに追加します。
hello/web/router.ex
defmodule Hello.Router do
use Hello.Web, :router
# 省略
scope "/", Hello do
pipe_through :browser
get "/", PageController, :index
resources "/users", UserController do
pipe_through [:authenticate_user]
end
resources "/sessions", SessionController, only: [:new, :create, :delete]
end
end
次にコントローラを作成します
hello/web/controllers/session_controller.ex
defmodule Hello.SessionController do
use Hello.Web, :controller
def new(conn, _) do
render conn, "new.html"
end
def create(conn, %{"session" => %{"name" => user, "password" => pass}}) do
case Hello.Auth.login_by_name_and_pass(conn, user, pass, repo: Repo) do
{:ok, conn} ->
conn
|> put_flash(:info, "Welcome")
|> redirect(to: page_path(conn, :index))
{:error, _reason, conn} ->
conn
|> put_flash(:error, "ユーザー名/パスワードが不正です")
|> render("new.html")
end
end
def delete(conn, _) do
conn
|> Hello.Auth.logout()
|> redirect(to: page_path(conn, :index))
end
end
ログイン画面用のviewとtemplateファイルを作成します。
hello/web/views/session_view.ex
defmodule User.SessionView do
use User.Web, :view
end
hello/web/templates/session/new.html.eex
<h1>Login</h1>
<%= form_for @conn, session_path(@conn, :create), [as: :session], fn f -> %>
<div class="form-group">
<%= text_input f, :name, placeholder: "Name", class: "form-control" %>
</div>
<div class="form-group">
<%= password_input f, :password, placeholder: "Password", class: "form-control" %>
</div>
<%= submit "Log in", class: "btn btn-primary" %>
<% end %>
アプリケーショントップ画面にユーザー登録画面とログイン画面へのリンクを追加しましょう。
ユーザーがセッションに存在する場合はログアウト、存在しない場合はユーザー登録、もしくはログイン画面へのリンクを表示しています。
hello/web/templates/layout/app.html.eex
<!-- 省略 -->
<body>
<div class="container">
<div class="header">
<ol class="breadcrumb text-right">
<%= if @current_user do %>
<li><%= @current_user.name %></li>
<li>
<%= link "Log out", to: session_path(@conn, :delete, @current_user), method: "delete" %>
</li>
<% else %>
<li><%= link "Register", to: user_path(@conn, :new) %></li>
<li><%= link "Log in", to: session_path(@conn, :new) %></li>
<% end %>
</ol>
<span class="logo"></span>
</div>
<!-- 省略 -->
その他微調整
avator画像
ユーザー画面が文字列だけだと寂しいのでGravatorに登録してあるイメージファイルを表示するようにします。
まずGravatorのAPIを叩くモジュールを作成します。
APIの詳細については説明を省きますが、emailのMD5値をパラメータとして渡すのでMD5暗号化する関数を作成しています。
hello/lib/Hello/gravator.ex
defmodule Hello.Gravatar do
def get_gravatar_url(email) do
gravatar_id = email_to_gravator_id(email)
"https://secure.gravatar.com/avatar/#{gravatar_id}?s=50"
end
defp email_to_gravator_id(email) do
email
|> email_downcase
|> email_crypt_md5
end
defp email_crypt_md5(email) do
:erlang.md5(email)
|> :erlang.bitstring_to_list
|> Enum.map(&(:io_lib.format("~2.16.0b", [&1])))
|> List.flatten
|> :erlang.list_to_bitstring
end
defp email_downcase(email) do
String.downcase(email)
end
end
作成した関数を呼び出すようにUserのviewファイルを変更します
hello/web/views/user_view.ex
defmodule Hello.UserView do
use Hello.Web, :view
alias Hello.{User, Gravatar}
def get_gravatar_url(%User{email: email}) do
Gravatar.get_gravatar_url(email)
end
end
CSS
cssで画面レイアウトの微調整をします。 以下のcssファイル作成します。
hello/priv/static/css/custom.css
html {
overflow-y: scroll;
}
body {
padding-top: 60px;
}
section {
overflow: auto;
}
textarea {
resize: vertical;
}
.center {
text-align: center;
}
.center h1 {
margin-bottom: 10px;
}
h1, h2, h3, h4, h5, h6 {
line-height: 1;
}
h1 {
font-size: 3em;
letter-spacing: -2px;
margin-bottom: 30ps;
text-align: left;
}
h2 {
font-size: 1.2em;
letter-spacing: -1px;
margin-bottom: 30px;
text-align: center;
font-weight: normal;
color: #777777;
}
p {
font-size: 1.1em;
line-height: 1.7em;
}
.gravatar {
float: left;
margin-right: 10px;
}
aside section {
padding: 10px 0;
border-top: 1px solid #eeeeee;
}
aside section:first-child {
border: 0;
padding-top: 0;
}
aside section span {
display: block;
margin-bottom: 3px;
line-height: 1;
}
aside section h1 {
font-size: 1.4em;
text-align: left;
letter-spacing: -1px;
margin-bottom: 3px;
margin-top: 0px;
}
.content {
display: block;
}
.timestamp {
color: #777777;
}
aside textarea {
height: 100px;
margin-bottom: 5px;
}
templateファイルでこのファイルを読み込むように変更します。
hello/web/templates/layout/app.html.eex
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="">
<meta name="author" content="">
<title>Hello Phoenix!</title>
<link rel="stylesheet" href="<%= static_path(@conn, "/css/app.css") %>">
<!-- 以下の行を追加してください -->
<link rel="stylesheet" href="<%= static_path(@conn, "/css/custom.css") %>">
</head>
<body>
<div class="container">
<div class="header">
<ol class="breadcrumb text-right">
<%= if @current_user do %>
<li><%= @current_user.name %></li>
<li><%= link "Register", to: user_path(@conn, :new) %></li>
<li> <%= link "Log out", to: session_path(@conn, :delete, @current_user), method: "delete" %> </li>
<% else %>
<li><%= link "Register", to: user_path(@conn, :new) %></li>
<li><%= link "Log in", to: session_path(@conn, :new) %></li>
<% end %>
</ol>
<span class="logo"></span>
</div>
<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>
<script src="http://code.jquery.com/jquery-2.1.4.min.js"></script>
</body>
</html>
画面の確認
それではここまでに作成した画面を表示してみましょう!
トップ画面
ログイン画面
ユーザー一覧画面
ユーザー詳細画面
ユーザー更新画面
ユーザー新規作成画面
ログインしないでアクセスした場合
まとめ
ユーザー情報のCRUD操作を実装してきました。Ruby on RailsのようなMVC Frameworkをオブジェクト指向言語ではなく関数型言語でも実現しているところがPhoenixの面白いところですね。
また、ここまで触ってみた感じではかなりRuby on Railsの開発スタイルに近いと感じました。Railsのような開発生産性とElixirによる並列処理性能の両方を実現したFrameworkというのは、他の言語でもなかなかなく非常に可能性を感じます。
まだ、関連を持ったリソースの複雑な操作やPhoenixプロジェクトのディレクトリ構成については説明していないので、次回以降書いていきたいと思います。