(この記事は、**「fukuoka.ex Elixir/Phoenix Advent Calendar 2018」**の2日目です)
昨日はzacky1972さんの「ZEAM開発ログ2018年総集編その1: Elixir 研究構想についてふりかえる(前編)」です!こちらもぜひぜひ!
学生エンジニアKoyoです!
紅葉の季節ですね~
今までCakePHP, RailsからGoやVueまで色んな言語(フレームワーク)を触ってきました
フルスタックのフレームワークには大体「ブログチュートリアル」がありますよね
ElixirのフレームワークであるPhoenixは、公式ドキュメントにはいくつかチュートリアルはあるのですが、日本語の情報が少ないです><
(結構苦労して笑)先日パスワード認証&リレーションありの「ブログチュートリアル」を自分でやってみたので、その過程をシェアしたいと思います
|> 後編はこちら
【Phoenix1.4】(後編)パスワード認証&リレーションあり「ブログチュートリアル」
#|> 対象読者
|> Elixir, Phoenixをインストールして少し動かしてみたけど、もうすこしステップアップしたい方
特にfukuoka.ex代表のpiacereさんのコラムは入門に最適なので、まだやってない方は是非全6回やってみてください!
|> Excelから関数型言語マスター1回目:行の「並べ替え」と「絞り込み」
また、今回はつい先日リリースされたPhoenix 1.4
でやってみようかなと思うので、以下のコラムも見てみるといいと思います!(1.3の場合はRoutes.user_path(...)
という記述のRoutes.
をすべて除けば動作すると思われます)
|> ブログチュートリアル
|> 筆者の実行環境
- Windows10
- WSL(Windows Subsystem for Linux)
- Erlang/OTP 21
- Elixir 1.7.2 (compiled with Erlang/OTP 20)
- Phoenix1.4
動作未確認ですが他の環境でも(Macとか)でもおそらく大丈夫だと思います!
|> プロジェクトを作成
mix phx.new ex_blog
でex_blogという名前でプロジェクトを作成しましょう
※今回はwebpack使わないからといって--no-webpack
つけたりすると、deleteメソッドが動かない罠にハマるので気を付けましょう(筆者はハマった笑)
以下のプロンプトが出たらY
で依存関係をインストールしましょう
以下の指示に従ってDBを生成しましょう
cd ex_blog
mix ecto.create
iex -S mix phx.server
として以下が表示されればOKです!
ここで上手くいかない場合はPhoenixのインストールやDBの設定が上手くいっていない場合が考えられるので、以下を参考に見直してみましょう
Excelから関数型言語マスター3回目:WebにDBデータ表示【PostgreSQL or MySQL編】
※ちなみに筆者はPostgreSQL起動し忘れていたのでsudo service postgresql start
しました笑
|> Userリソースの追加
Accountsという名前でコンテキストを生成し、その中にUserリソースを追加します
Userはname, email, passwordカラムを持ち、emailは一意である(ユニーク制約)としましょう
mix phx.gen.html Accounts User users name:string email:string:unique password:string
ちなみにRails触っていた人はコンテキストの概念がよく分からないと思いますが、モデルのロジック相当と捉えるとしっくりくるかと思います(合ってるか分かりませんが筆者はそう解釈しています)
似たようなロジックは同じコンテキスト内にまとめていくとよいでしょう
指示に従ってrouter.ex
にresources "/users", UserController
を追加
defmodule ExBlogWeb.Router do
use ExBlogWeb, :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 "/", ExBlogWeb do
pipe_through :browser
get "/", PageController, :index
resources "/users", UserController # ここ追加!
end
# Other scopes may use custom stacks.
# scope "/api", ExBlogWeb do
# pipe_through :api
# end
end
これで以下のようにRESTfulなルーティングが追加されますbb
|> Articleリソースの追加
title, content属性を持つArticleリソースを作成しましょう
リレーションはこんな感じ
- User has many Articles
- Article belongs to user
以下のように書くとuser_idを外部キーとしてBlogコンテキストのArticleリソースを作成できます!
mix phx.gen.html Blog Article articles user_id:references:users title:string content:string
今回Articleリソースは、userリソースが属するAccountコンテキストと同じグループではないと考えられるので、新しくBlogコンテキストを作ってその中にいれてみました
例えばArticleに対するコメント機能などを作成する場合は、commentリソースはBlogコンテキストに属すると思います
resources "/articles", ArticleController
をroute.exに追加しましょう
その後mix ecto.migrate
してみましょう
※エラーになる場合はmix ecto.reset
してみましょう
|> DB周りの設定
|> Migrationファイルをいじる
priv/repo 以下にタイムスタンプつきのcreate_users.exs
ファイルができていると思います
defmodule ExBlog.Repo.Migrations.CreateUsers do
use Ecto.Migration
def change do
create table(:users) do
add :name, :string
add :email, :string
add :password, :string
timestamps()
end
create unique_index(:users, [:email])
end
end
DBレベルでnullを許容したくないので以下のように書き換えます
defmodule ExBlog.Repo.Migrations.CreateUsers do
use Ecto.Migration
def change do
create table(:users) do
add :name, :string, null: false # ここ追加
add :email, :string, null: false # ここ追加
add :password, :string, null: false # ここ追加
timestamps()
end
create unique_index(:users, [:email])
end
end
同様にarticleの方も
defmodule ExBlog.Repo.Migrations.CreateArticles do
use Ecto.Migration
def change do
create table(:articles) do
add :title, :string
add :content, :string
add :user_id, references(:users, on_delete: :nothing)
timestamps()
end
create index(:articles, [:user_id])
end
end
userリソースが削除されたら関連するArticleも削除してほしいので、以下のように書き換えます
defmodule ExBlog.Repo.Migrations.CreateArticles do
use Ecto.Migration
def change do
create table(:articles) do
add :title, :string, null: false ## ここ変更
add :content, :string
add :user_id, references(:users, on_delete: :delete_all), null: false ## ここ変更
timestamps()
end
create index(:articles, [:user_id])
end
end
このあたりの設定は、Phoenixが依存しているEctoSQLに詳しく載っているので、カスタマイズしてみたい方は試してみてください!
最後にmix ecto.reset
してmigrationを再設定しましょう
|> Schemaファイルをいじる
自動生成されたSchemaファイルは以下のようになっていると思います
defmodule ExBlog.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
field :name, :string
field :password, :string
timestamps()
end
@doc false
def changeset(user, attrs) do
user
|> cast(attrs, [:name, :email, :password])
|> validate_required([:name, :email, :password])
|> unique_constraint(:email)
end
end
Ecto(PhoexnixのORM, Railsで言えばActionRecord)でリレーションたどれるように設定していきます
defmodule ExBlog.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
alias ExBlog.Blog.Article # ここ追加
schema "users" do
field :email, :string
field :name, :string
field :password, :string
has_many :articles, Article # ここ追加
timestamps()
end
@doc false
def changeset(user, attrs) do
user
|> cast(attrs, [:name, :email, :password])
|> validate_required([:name, :email, :password])
|> unique_constraint(:email)
end
end
Articleスキーマも同様に
defmodule ExBlog.Blog.Article do
use Ecto.Schema
import Ecto.Changeset
schema "articles" do
field :content, :string
field :title, :string
field :user_id, :id
timestamps()
end
@doc false
def changeset(article, attrs) do
article
|> cast(attrs, [:title, :content])
|> validate_required([:title, :content])
end
end
以下のように変更します
defmodule ExBlog.Blog.Article do
use Ecto.Schema
import Ecto.Changeset
alias ExBlog.Accounts.User # ここ追加
schema "articles" do
field :content, :string
field :title, :string
belongs_to :user, User # ここ追加
timestamps()
end
@doc false
def changeset(article, attrs) do
article
|> cast(attrs, [:title, :content])
|> validate_required([:title, :content])
end
end
|> ここまでで画面の確認
さて、ここまででリレーションの土台ができました
これでArticleを投稿したときにログインしたユーザのuser_id
をカラムに追加するように設定したりできます
iex -S mix phx.server
してlocalhost:4000にアクセスしてみましょう
localhost:4000/users
ここでパスワード入力する際にパスワードがガッツリ表示されています
また、indexやshowにパスワードが表示されるようになっています
これはあまり好ましくないので以下を参考に修正します
Phoenixのphx.gen.htmlでpasswordフィールドを生成するときは忘れずにtemplateを変更しよう!
<h1>Listing Users</h1>
<table>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
- <th>Password</th>
<th></th>
</tr>
</thead>
<tbody>
<%= for user <- @users do %>
<tr>
<td><%= user.name %></td>
<td><%= user.email %></td>
- <td><%= user.password %></td>
<td>
<%= link "Show", to: Routes.user_path(@conn, :show, user) %>
<%= link "Edit", to: Routes.user_path(@conn, :edit, user) %>
<%= link "Delete", to: Routes.user_path(@conn, :delete, user), method: :delete, data: [confirm: "Are you sure?"] %>
</td>
</tr>
<% end %>
</tbody>
</table>
<span><%= link "New User", to: Routes.user_path(@conn, :new) %></span>
<h1>Show User</h1>
<ul>
<li>
<strong>Name:</strong>
<%= @user.name %>
</li>
<li>
<strong>Email:</strong>
<%= @user.email %>
</li>
- <li>
- <strong>Password:</strong>
- <%= @user.password %>
- </li>
</ul>
<span><%= link "Edit", to: Routes.user_path(@conn, :edit, @user) %></span>
<span><%= link "Back", to: Routes.user_path(@conn, :index) %></span>
<%= 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, :name %>
<%= text_input f, :name %>
<%= error_tag f, :name %>
<%= label f, :email %>
<%= text_input f, :email %>
<%= error_tag f, :email %>
<%= label f, :password %>
- <%= text_input f, :password %>
+ <%= password_input f, :password %>
<%= error_tag f, :password %>
<div>
<%= submit "Save" %>
</div>
<% end %>
次は実際にリレーションを反映するためにログイン機能を実装しましょう
|> Guardianでパスワード認証の実装
パスワード認証などを行うにはセッションを扱う必要があります
以下の分かりやすい記事のようにPhoenixのSession機能を使って実装する方法もありますが今回はGuardianというライブラリを使ってみます
Guardianについては公式ドキュメントのほかに以下が参考になりました
- Session Authentication Example For Phoenix 1.3 Using Guardian 1.0-beta
- Elixir + Phoenix Frameworkにおけるユーザ認証
|> Guardianと必要なライブラリの導入
以下をmix.exs
に書いて、mix deps.get
します
{:guardian, "~> 1.1"},
{:comeonin, "~> 4.1"},
{:bcrypt_elixir, "~> 1.1"}
次にmix guardian.gen.secret
を実行して出てきた文字列を使って以下のように設定します
# This file is responsible for configuring your application
# and its dependencies with the aid of the Mix.Config module.
#
# This configuration file is loaded before any dependency and
# is restricted to this project.
# General application configuration
use Mix.Config
config :ex_blog,
ecto_repos: [ExBlog.Repo]
# Configures the endpoint
config :ex_blog, ExBlogWeb.Endpoint,
...
+ config :ex_blog, ExBlog.Accounts.Guardian,
+ issuer: "ex_blog",
+ secret_key: # mix guardian.gen.secret の結果を貼り付け
# Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{Mix.eznv()}.exs"
|>ちょっと余談
ちなみに学習用は構いませんが本番環境でsecret_keyを直書きするのは良くないので、.bashrc
にexport EX_BLOG_GUARDIAN_KEY=.....
などで環境変数をセットしておいて、以下のように書く方法もあります
secret_key: "${EX_BLOG_GUARDIAN_KEY}"
このときSystem.get_env
を使う方法と${}
を使う方法2通りあるんですが、System.get_envだと(後述するGigalixirなどで必要になる)Distillery
などでコンパイルするときに、コンパイル時の実行時の環境変数で固定されてしまうので後から環境変数を注入できず不便です
${}
だと現在の環境変数読んでくれるみたいなのでこっちがいいかもです
https://twitter.com/KoyoMiyamura/status/1056952578745892864
https://twitter.com/KoyoMiyamura/status/1056948597957156864
|> Guardianを使ったログイン機能の実装
|> ルーティング
まず最終的なルーティングを以下のようにします
2つのパイプライン:authと:ensure_authを定義します
:authはセッションを取得したりする機能を使うために必要で、これはログインしているユーザを判別するために必要なので全ページに適用します
:ensure_authはログイン済みかどうかを判定してくれて、userのcreate/new(ユーザ登録)以外のuserリソースとarticleリソースはログイン済みのみ閲覧可能にしたいので以下のように適用させます
またログイン/ログアウト用にSessionコントローラを新しく定義します
defmodule ExBlogWeb.Router do
use ExBlogWeb, :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
+ pipeline :auth do
+ plug ExBlog.Accounts.Pipeline
+ end
+ pipeline :ensure_auth do
+ plug Guardian.Plug.EnsureAuthenticated
+ end
scope "/", ExBlogWeb do
- pipe_through :browser
+ pipe_through [:browser, :auth]
+ get "/", PageController, :index
+ get "/signin", UserController, :new
+ post "/signin", UserController, :create
+ get "/login", SessionController, :new
+ post "/login", SessionController, :create
+ delete "/logout", SessionController, :delete
+ end
- resources "/users", UserController
+ scope "/", ExBlogWeb do
+ pipe_through [:browser, :auth, :ensure_auth]
+ resources "/users", UserController, except: [:new, :create]
resources "/articles", ArticleController
end
# Other scopes may use custom stacks.
# scope "/api", ExBlogWeb do
# pipe_through :api
# end
end
|> パイプラインの実装
先ほど紹介した参考記事やGuardianの公式を参考に実装していきます
defmodule ExBlog.Accounts.Guardian do
use Guardian, otp_app: :ex_blog
alias ExBlog.Accounts
def subject_for_token(user, _claims) do
{:ok, to_string(user.id)}
end
def resource_from_claims(claims) do
user = claims["sub"]
|> Accounts.get_user!
{:ok, user}
# If something goes wrong here return {:error, reason}
end
end
defmodule ExBlog.Accounts.Pipeline do
use Guardian.Plug.Pipeline,
otp_app: :ex_blog,
error_handler: ExBlog.Accounts.ErrorHandler,
module: ExBlog.Accounts.Guardian
# If there is a session token, validate it
plug Guardian.Plug.VerifySession, claims: %{"typ" => "access"}
# If there is an authorization header, validate it
plug Guardian.Plug.VerifyHeader, claims: %{"typ" => "access"}
# Load the user if either of the verifications worked
plug Guardian.Plug.LoadResource, allow_blank: true
end
defmodule ExBlog.Accounts.ErrorHandler do
import Plug.Conn
def auth_error(conn, {type, _reason}, _opts) do
body = to_string(type)
conn
|> put_resp_content_type("text/plain")
|> send_resp(401, body)
end
end
|> Accountsリソースにログイン機能の実装
コントローラで使いたいので、コンテキストにログイン機能を追加します
authenticate_userはログインemailとパスワードを受け取ってログイン可能かを判定します
check_passwordの定義がすごくElixirらしいですね!
第一引数がnilの場合とそうでない場合で処理を分けるためにパターンマッチを使って分けています
これにより一つの関数は小さく保ちつつ複雑な処理を記述することを可能にします
alias ExBlog.Accounts.User
+ alias Comeonin.Bcrypt
...
+ def authenticate_user(email, plain_text_password) do
+ query = from u in User, where: u.email == ^email
+ Repo.one(query)
+ |> check_password(plain_text_password)
+ end
+ defp check_password(nil, _), do: {:error, "Incorrect username or password"}
+ defp check_password(user, plain_text_password) do
+ case Bcrypt.checkpw(plain_text_password, user.password) do
+ true -> {:ok, user}
+ false -> {:error, "Incorrect username or password"}
+ end
+ end
+ end
end
|> userスキーマに保存時にパスワードのハッシュ化と簡単なバリデーションの実装
userスキーマにパスワード保存時のハッシュ化処理を追加します
同名関数を定義して、パターンマッチするかどうかで処理を分岐させるやりかたはいかにもElixirらしいスタイルですね!
defmodule ExBlog.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
alias ExBlog.Blog.Article
+ alias Comeonin.Bcrypt
schema "users" do
field :email, :string
field :name, :string
field :password, :string
has_many :articles, Article # ここ追加
timestamps()
end
@doc false
def changeset(user, attrs) do
user
|> cast(attrs, [:name, :email, :password])
|> validate_required([:name, :email, :password])
|> unique_constraint(:email)
+ |> validate_format(:email, ~r/\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i)
+ |> validate_length(:password, min: 5)
+ |> put_pass_hash()
end
+
+ defp put_pass_hash(%Ecto.Changeset{valid?: true, changes: %{password: password}} = changeset) do
+ change(changeset, password: Bcrypt.hashpwsalt(password))
+ end
+ defp put_pass_hash(changeset), do: changeset
end
layoutのViewファイルを変更して、eex中でcurrent_userという関数で現在ログイン中のユーザを取得できるようにします
defmodule ExBlogWeb.LayoutView do
use ExBlogWeb, :view
+ alias ExBlog.Accounts.Guardian
+ def current_user(conn) do
+ Guardian.Plug.current_resource(conn)
+ end
end
|> 細かい部分の変更
user登録完了後にuser/showではなくpage/indexに飛ぶようにします(好みなので必須では無いです)
def create(conn, %{"user" => user_params}) do
case Accounts.create_user(user_params) do
- {:ok, user} ->
+ {:ok, _user} ->
conn
|> put_flash(:info, "User created successfully.")
- |> redirect(to: Routes.user_path(conn, :show, user))
+ |> redirect(to: Routes.page_path(conn, :index))
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, "new.html", changeset: changeset)
end
end
|> sessionコントローラ/ビューの実装
defmodule ExBlogWeb.SessionController do
use ExBlogWeb, :controller
alias ExBlog.Accounts
alias ExBlog.Accounts.User
alias ExBlog.Accounts.Guardian
def new(conn, _params) do
changeset = Accounts.change_user(%User{})
render(conn, "new.html", changeset: changeset)
end
def create(conn, %{"user" => %{"email" => email, "password" => password}}) do
Accounts.authenticate_user(email, password)
|> login_reply(conn)
end
defp login_reply({:error, error}, conn) do
conn
|> put_flash(:error, error)
|> redirect(to: Routes.session_path(conn, :new))
end
defp login_reply({:ok, user}, conn) do
conn
|> put_flash(:info, "Welcome back!")
|> Guardian.Plug.sign_in(user)
|> redirect(to: "/")
end
def delete(conn, _) do
conn
|> Guardian.Plug.sign_out()
|> put_flash(:info, "Logout successfully.")
|> redirect(to: Routes.page_path(conn, :index))
end
end
Contorllerには必ず対応するViewファイルが必要なので作成しましょう(ないとエラーになる)
Railsやったことある人はViewってerbテンプレートの置き場所だと感じるのですが、Phoenixの場合はコントローラから渡ってきた変数をビュー用に整形したり、表示用のhelper関数を定義する場所として使われるようです
Railsでいうhelperの役割と、コントローラ/テンプレート間の仲立ちをする層というイメージみたいです
ちなみにJSON APIを作る場合はここでレスポンスデータの定義をします
defmodule ExBlogWeb.SessionView do
use ExBlogWeb, :view
end
ログイン画面の実装
<h2>Login Page</h2>
<%= form_for @changeset, Routes.session_path(@conn, :create), fn f -> %>
<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">
<%= label f, :password, class: "control-label" %>
<%= password_input f, :password, class: "form-control" %>
<%= error_tag f, :password %>
</div>
<div class="form-group">
<%= submit "Submit", class: "btn btn-primary" %>
</div>
<% end %>
ヘッダー部分にLogoutリンクを追加し、userがログインしている場合のみ表示するようにしてみましょう
<!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.0"/>
<title>ExBlog · Phoenix Framework</title>
<link rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
</head>
<body>
<header>
<section class="container">
<nav role="navigation">
<ul>
- <li><a href="https://hexdocs.pm/phoenix/overview.html">Get Started</a></li>
+ <%= if current_user(@conn) do %>
+ <li><%= link "Logout", to: Routes.session_path(@conn, :delete), method: :delete %></li>
+ <% end %>
</ul>
</nav>
<a href="http://phoenixframework.org/" class="phx-logo">
<img src="<%= Routes.static_path(@conn, "/images/phoenix.png") %>" alt="Phoenix Framework Logo"/>
</a>
</section>
</header>
<main role="main" class="container">
<p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>
<p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>
<%= render @view_module, @view_template, assigns %>
</main>
<script type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
</body>
</html>
|> ここまでで画面表示
さて、iex -S mix phx.server
してlocalhost:4000
を開いてみましょう
ログインしていない場合に、ログインが必要なリソースを表示させると以下のようにエラーページが表示されます
localhost:4000/signin
にアクセスし、適当なユーザを作成してみましょう
今回はユーザ登録完了後にログインを自動で行う実装ではないので、ログインを行う必要があります
(UX的にはユーザ登録後にログインした方がよさげ)
ログインするとヘッダー部分にLogoutのリンクができていますね!
localhost:4000/users
にアクセスするとログイン前は見ることが出来なかったページが見れるようになっています
フラッシュが表示されます
もう一度localhost:4000/users
やlocalhost:4000/articles
にアクセスしてみましょう
ちゃんとアクセスできなくなっています!すごい!笑
|> ※続きはElixir Advent Calendarで!
若干睡眠不足で執筆していたので、今見返したらめちゃ長いですねこの記事・・・!
後半は分割・修正してElixir Advent Calandar の方にしようと思います
|> 後編はこちら
【Phoenix1.4】(後編)パスワード認証&リレーションあり「ブログチュートリアル」
|> 次回
次回はsym_numさんのはじめての並行処理 -Queensパズルを題材にして-です!こちらも是非どうぞ~