今回はRailsの慣例に沿った形でWordbookを作り直します。
- Convention Over Configuration
- モデル
- ルーティング
- レイアウト、ナビゲーションバー、フラッシュ
- indexアクション
- searchアクション
- showアクション
- newとcreateアクション
- editとupdateアクション
- destroyアクション
- 起動
- まとめ
Convention Over Configuration
Railsには「Convention Over Configuration」(設定より規約が優先)という哲学があります。 「規約」とは「プログラミングの約束事」ですが、「プログラミングの慣例」あるいは「習慣」と言う方が合っているかもしれません。 というのは、この「規約」というのは「絶対に守らなければいけないルール」ではなく、「Railsが勧めるより良いプログラミングの方法」のことなのです。
そのひとつに、コントローラのアクションは7つにまとめることができる、というのがあります。
- index: 最初の画面。モデル全体のリストを表示させることが多い
- show: ひとつのモデルのデータを表示。クライアントはindexから項目をクリックしてshowに遷移することが多い
- new: モデルの新規作成の画面
- create: new画面から送られたデータにより、モデルを新規作成するアクション
- edit: 既存のモデルのデータを変更するための画面。クライアントはshowからアクセスすることが多い
- update: edit画面から送られたデータにより、モデルを更新するアクション
- destroy: 既存のモデルを消去するアクション。クライアントはshowから遷移することが多い
Railsでは、「RESTful」(レストフル)なウェブアクセスでは、アクションをこの7つにまとめることができる、としています。 RESTfulは形容詞で「RESTな」ということです。 では、RESTとは何かというと、それは「Representational State Transfer」を短くしたもので、ウェブ・インターフェースのアーキテクチャスタイルです。 これでは説明にならないと思うので、具体的に説明すると次の4つを持つスタイルです
- ステートレス(「状態」を持たない)。 例えば、ログインするようなサイトは「ログイン状態」という状態を持っているので、ステートレスではありません。 ログイン無しのサイトは、ほぼ「ステートレス」と考えて良いと思います
- 上手く定義された操作のセット。 例えばHTTPプロトコルでは、GET、POST、PATCH、PUT、DELETEなどのメソッドがあり、これを使って上手く通信ができます
- リソースの一意的識別。 これはHTTPではURIで識別しています
- 文書の情報に状態遷移を含めることができる。 言葉は難しいですが、要するに「リンクを貼ることができる」ということです
これから、RESTfulというのは私達がブラウザでウェブにアクセスしているスタイルのことを概念的にまとめたものだと分かると思います。 ウィキペディアに情報がありますので、参考にしてください。
RailsではRESTfulなアクセスは上記の7つのアクションにまとめることができるから
ということなのです。
上記の7つのアクションの構成は、前回作ったwordbookの構成に似ていますが、名前が違っていました。
Railsの慣例によるアクション名 | 前回wordbookのアクション名 |
---|---|
new | append |
edit | change |
destroy | exec_delete |
delete | |
search |
deleteとsearchが前のwordbookにありましたが、これはRailsの慣例アクション名にはありません。
今回はこの慣例に合うようにWordbookを作り直します。 ただ、次の点は慣例と異なります。
- indexですべての単語のリストを表示することはしない(登録単語が多くなるとリストが長くなりすぎるため)。 そこでは使い方の説明を表示することにする
- searchアクションを作り、正規表現での検索をサポートする
モデル
それでは、WordBookの新版を作りましょう。 ディレクトリ名を「word_book_rails_resources」とします。 ディレクトリ、コントローラ、モデルを作り、データベースのマイグレーションをします。
$ rails new word_book_rails_resources -c bootstrap ... ... ... $ cd word_book_rails_resources $ ./bin/rails generate controller Words index show new create edit update delete search ... ... ... $ ./bin/rails generate model Word en:string jp:string note:text ... ... ... $ ./bin/rails db:migrate ... ... ...
モデルのフィールドには「英単語」「日本語訳」に加えて「備考」(note)を作りました。 フィールドタイプのstringは短い文字列(通常一行)で、textは複数行に渡るような文字列です。 備考には例文や解説を書くことを想定しています。
モデルのバリデーションは英単語と日本語訳に設定します。 備考については空欄でも構わないとします。
class Word < ApplicationRecord validates :en, presence: true, format: {with: /[a-zA-Z]+/}, uniqueness: true validates :jp, presence: true end
ルーティング
/config/routes.rb
を編集します。
Rails.application.routes.draw do root "words#index" get "words/search" resources :words end
rootとsearchは個別のルーティングを書いておきます。
Railsの7つのアクションは「resources」メソッドでルーティングを定義します。
これはRailsが用意した「便利な工夫」のひとつで、これだけで7つのルーティングが記述できます。
このとき、それぞれのルーティングがどうなっているかは、./bin/rails routes
コマンドで確認できます。
$ ./bin/rails routes Prefix Verb URI Pattern Controller#Action root GET / words#index words_search GET /words/search(.:format) words#search words GET /words(.:format) words#index POST /words(.:format) words#create new_word GET /words/new(.:format) words#new edit_word GET /words/:id/edit(.:format) words#edit word GET /words/:id(.:format) words#show PATCH /words/:id(.:format) words#update PUT /words/:id(.:format) words#update DELETE /words/:id(.:format) words#destroy ... ... ...
左の列「Prefix」に、「_path」をつけるとパスを返すメソッドが得られます。
root_path #=> / words_search_path #=> /words/search words_path #=> /words
このような形で他アクションへのメソッドも得られます。
createアクションはアドレスがindexアクションと同じなのでwords_path
でPOSTメソッドを使えばアクセスできます。
同様にupdateアクションはword_path
をPATCHまたはPUTメソッドで、destroyアクションはword_path
をDELETEメソッドでアクセスすることによりアクセスできます。
7つのルーティングがresources
メソッド1つで書けるところが、負担軽減になっています。
このようなルーティングを「リソースフル・ルーティング」と呼んでいます。
なお、「リソース」というのはウェブサーバで扱っているデータのことで、例えばWordインスタンスの表すデータはリソースです。
レイアウト、ナビゲーションバー、フラッシュ
レイアウト
レイアウト/app/views/layouts/application.html.erb
は前回のWordbookと同様ですが、画面幅は少し広めにしました。
<!DOCTYPE html> <html> <head> <title>WordBookRailsResources</title> <meta name="viewport" content="width=device-width,initial-scale=1"> <%= csrf_meta_tags %> <%= csp_meta_tag %> <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> <%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %> </head> <body> <div class="container"> <div class="col-lg-10 col-xl-8 mx-auto"> <%= render "navbar" %> <%= render "flash" %> <%= yield %> </div> </div> </body> </html>
ナビゲーションバー
ナビゲーションバー/app/views/words/_navbar.html.erb
も前回とほぼ同様ですが次の2点が異なります。
- 「変更」と「削除」はshowアクションからの画面でのみ有効にし、他のアクションからの画面では無効(disable)にする
- 「検索」の窓もナビゲーションバーに付ける
<nav class="navbar navbar-expand-lg navbar-light" style="background-color: #e3f2fd;"> <div class="container-fluid"> <a class="navbar-brand" href="/">Wordbook</a> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation"> <span class="navbar-toggler-icon"></span> </button> <div class="collapse navbar-collapse" id="navbarSupportedContent"> <ul class="navbar-nav me-auto mb-2 mb-lg-0"> <li class="nav-item"> <%= link_to "追加", new_word_path, style: "text-decoration: none;", class: "nav-link" %> </li> <li class="nav-item"> <% if action_name == "show" && @word %> <%= link_to "変更", edit_word_path(@word), style: "text-decoration: none;", class: "nav-link" %> <% else %> <a class="nav-link disabled" href="#">変更</a> <% end %> </li> <li class="nav-item"> <% if action_name == "show" && @word %> <%= link_to "削除", @word, style: "text-decoration: none;", class: "nav-link", data: {turbo_method: :delete, turbo_confirm: "Are you sure?"} %> <% else %> <a class="nav-link disabled" href="#">削除</a> <% end %> </li> </ul> <%= form_with url: words_search_path, method: :get, class: "d-flex" do |form| %> <%= form.search_field :search, placeholder: "検索", class: "form-control me-2" %> <%= form.submit "検索", class: "btn btn-outline-success text-nowrap" %> <% end %> </div> </div> </nav>
13、20行目に<% if action_name == "show" %>
があります。
action_name
はRailsのメソッドで、そのときのアクション名を文字列で返します。
したがって、if節はアクションがshowのときだけ実行され、それ以外はelse節(リンクdisable)になります。
また、削除リンクはdata: {turbo_method: :delete, turbo_confirm: "Are you sure?"}
により、HTTPメソッドがdeleteになり、「Are you sure?」の確認ダイアログが表示されます。
前回のwordbookではフォームの送信ボタンが削除のきっかけになっていましたが、今回はナビゲーションバーの「削除」メニューがきっかけになっています。
また、@word
だけでリンク先を表すことができ、そのパスはword_path(@word)
と同じになります。
下から7〜4行目が検索部分で、form_with
によるフォーム送信が埋め込まれています。
「検索」ボタンを押すとsearchアクションに飛ぶようにリンク先がwords_search_path
メソッドで与えられています。
フラッシュ
フラッシュは前回と同じです。
<% flash.each do |name, msg| %> <% if name == "success" %> <%= content_tag :div, msg, class: "text-success" %> <% elsif name == "alert" %> <%= content_tag :div, msg, class: "text-danger" %> <% end %> <% end %>
indexアクション
indexアクションは初期画面です。 Wordbookの使い方を表示します。
コントローラでは特にやることはありません。
def index end
ビューapp/views/words/index.html.erb
は次のようになります。
<h1 class="text-center">単語帳</h1> <h5 class="my-2">単語帳の使い方</h5> <ul class="list-group my-2"> <li class="list-group-item">Wordbook: 初期画面に戻ります</li> <li class="list-group-item">追加: 単語を追加します</li> <li class="list-group-item">検索: 単語を検索しマッチした単語を表示します</li> </ul> <p> 検索にRubyの正規表現を使うことができます。 各単語に対して「英単語」「日本語訳」「備考」を書き、保存することができます。 「備考」は複数行のテキストが可能なので、例文や解説などを書くのに適しています。 </p> <p> 単語の表示、変更、削除は次のようにしてください。 なお「単語の表示」または「表示画面」とは、ひとつの単語についてその日本語訳と備考を表示すること、またはその画面です。 検索結果の画面では備考は表示されません。 </p> <ul class="list-group my-2"> <li class="list-group-item">表示: 検索後の一覧において、各項目の左にあるボタンをクリック</li> <li class="list-group-item">変更: 表示画面から変更メニューをクリック</li> <li class="list-group-item">削除: 表示画面から削除メニューをクリック</li> </ul>
searchアクション
ナビゲーションバーの入力枠に正規表現を入れ、検索ボタンをクリックすることにより、/words/search
にgetメソッドでアクセスします。
これはsearchアクションにルーティングされます。
def search @search_word = params[:search] if @search_word == "" flash.now[:alert] = "検索ワードは入力必須です" render html: "", status: :unprocessable_entity end begin pattern = Regexp.compile(@search_word) rescue RegexpError flash.now[:alert] = "正規表現に構文エラーがあります" render :index, status: :unprocessable_entity else @words = Word.all.select{|word| word[:en] =~ pattern}.sort{|w1,w2| w1[:en] <=> w2[:en]} end end
検索ワードはsearchという名前で送られるので、params[:search]
で取り出すことができます。
searchアクションの内容は、前回のWordbookのlistアクションとほぼ同じです。
ビュー/app/views/words/search.html.erb
は次のようになります。
<h1 class="my-2">単語検索結果</h1> <p>検索ワード: <%= @search_word %></p> <% if @words %> <table class="table table-bordered"> <thead> <tr> <th scope="col">英語</th> <th scope="col">日本語</th> </tr> </thead> <tbody> <% @words.each do |word| %> <tr> <td><%= link_to word[:en], word %></td> <td><%= word[:jp] %></td> </tr> <% end %> </tbody> </table> <% end %>
ここでは、英語と日本語訳のみを表にして表示します。
英単語はshowアクションへのリンクになっています。
word
がshowアクションへのリンクです。
このリンクには該当の単語のid(データベースの通し番号)が挿入されます。
showアクション
showアクションはid番号を用いてデータベースからWordモデルを取り出します。
def show begin @word = Word.find(params[:id]) rescue ActiveRecord::RecordNotFound @word = nil flash.now[:alert] = "データベースにはid=#{params[:id]}のデータは登録されていません" end end
search画面のリンクからshowアクションに遷移した場合はエラーになることはありません。
クライアント側が適当なid番号で「words/id番号
」をgetでアクセスした場合にはエラーの発生する可能性があります。
そのためrescueを使ったエラー処理を入れておきました。
ビュー/app/views/words/show.html.erb
は次のようになります。
<% if @word %> <ul class="list-group my-2"> <li class="list-group-item"><%= @word.en %></li> <li class="list-group-item"><%= @word.jp %></li> <li class="list-group-item"><%= @word.note %></li> </ul> <% end %>
showでは英単語、日本語訳に加え備考(@word.note
)も表示します。
newとcreateアクション
newアクション
ナビゲーションバーの「追加」をクリックすると/words/new
にgetメソッドでアクセスします。
そして、newアクションにルーティングされます。
newコントローラはWordモデルを生成して@wordに代入します。
def new @word = Word.new @submit = "作成" end
ビュー/app/views/words/new.html.erb
は次のようになります。
<h1 class="my-2">単語登録</h1> <%= render "form" %>
formパーシャル
画面の組み立てはformパーシャルが行います。
<%= form_with model: @word do |form| %> <div> <%= form.label :en, "英単語", class: "form-label" %> <%= form.text_field :en, value: @word.en, class: "form-control" %> <% @word.errors.full_messages_for(:en).each do |message| %> <div class="text-danger"><%= "DB: 英単語は空欄にできません" if message == "En can't be blank" %></div> <div class="text-danger"><%= "DB: 英文字を入力してください" if message == "En is invalid" %></div> <div class="text-danger"><%= "DB: すでに同名の単語が登録されています" if message == "En has already been taken" %></div> <% end %> </div> <div> <%= form.label :jp, "日本語訳", class: "form-label" %> <%= form.text_field :jp, value: @word.jp, class: "form-control" %> <% @word.errors.full_messages_for(:jp).each do |message| %> <div class="text-danger"><%= "DB: 日本語訳は空欄にできません" if message == "Jp can't be blank" %></div> <% end %> </div> <div> <%= form.label :note, "備考", class: "form-label" %> <%= form.text_area :note, value: @word.note, class: "form-control" %> <% @word.errors.full_messages_for(:jp).each do |message| %> <div class="text-danger"><%= message %></div> <% end %> </div> <div class="my-2"> <%= form.submit @submit, class: "btn btn-primary" %> </div> <% end %>
ルーティングがリソースフルである場合は、form_with
にモデルを設定すればurlは省略できます。
送信ボタンを押すとcreateアクションにアクセスします。
「備考」の入力枠はform.text_area
で作ります。
複数行のテキストを想定した入力フォームです。
createアクション
createアクションはコントローラのみでビューはありません。
def create @word = Word.new(word_params) if @word.save flash[:success] = "単語を保存しました" redirect_to @word, status: :see_other else flash.now[:alert] = "単語を保存できませんでした" render :new, status: :unprocessable_entity end end private def word_params params.require(:word).permit(:en, :jp, :note) end
プライベートメソッドword_params
はクライアントからのデータを調べ、必要なものだけを取り出します。
この仕組みはparams
、require
、permit
の3つのメソッドで構成されています。
- params: ActionController::Parametersオブジェクトを返す。 このオブジェクトはクライアントから送られてきたデータを、キーと値の形式(ハッシュに似た形式)で保持している
- require: パラメータにキーが与えられたとき、その値があればその値を持つActionController::Parametersオブジェクトを返す。 そうでなければエラーになる。
- permit: パラメータにはキーのリストをとる。 与えられたキー以外のデータは捨てられ、指定されたキーと値のみのActionController::Parametersオブジェクトを返す
この仕組みはストロング・パラメータと呼ばれ、仮に不正なデータが送られたとしてもそれを除去することができます。
createメソッドでWord.new(word_params)
としても安全です。
これを使わず`Word.new(params)とするとサーバ側で予期していないデータを保存しようとする可能性があり、エラーになったり、何らかの問題を引き起こす可能性があります。
Wordbookではストロングパラメータを使わなくてもほとんど危険はありませんが、ユーザ登録の仕組みを持つサーバでは脆弱性が発生することがあります。
例えば、ユーザモデルUserが3つのフィールドname、email、adminを持っていて、最後のadminは管理者権限を表し、true=admin
、nil|false=not admin
だとします。
newアクションのビューではnameとemailのみをデータ送信するようになっているとします。
通常なら@user=User.new(params)
でnameとemailのみ代入され、admin=nilとなるのですが、
悪意のあるユーザがPOSTデータを改ざんしname、emailに加えadmin=trueを送ってきたとすると、新規ユーザは管理者になってしまいます。
ストロングパラメータがパラメータをnameとemailに限定すれば、このような危険は無くなるというわけです。
createアクションでは、単語登録できればshowにリダイレクト、失敗したら再度newの画面を表示します。
editとupdateアクション
editアクション
editアクションはshowアクションから遷移される前提です。
showアクションの画面ではナビゲーションバーの「変更」リンクが有効になります。
/words/:id/edit
にアクセスがあるとeditアクションにルーティングされ、:id
のところの数字がパラメータとしてparams[:id]
にセットされます。
def edit begin @word = Word.find(params[:id]) rescue ActiveRecord::RecordNotFound @word = nil flash[:alert] = "データベースにはid=#{params[:id]}のデータは登録されていません" redirect_to words_path, status: :see_other end @submit = "変更" end
show画面から遷移すればWord.find(params[:id])
がエラーになることはありませんが、ユーザがURLを作ってアクセスすると、そうとも限りません。
そこで、rescueを使ったエラーの捕捉をしています。
ビューは次のようになります。
<h1 class="my-2">単語の変更</h1> <%= render "form" %>
newと共有しているformパーシャルを使います。
editの場合は送られてきたデータによってフォーム入力枠に値が入った状態から編集することができます。
編集が終わるとupdateアクションに遷移します。
newではcreateアクションへの遷移でした。
どこでcreateとupdateを区別しているかと言うと、@wordのidフィールドがnilか数字かの違いです。
この判断はform_with
メソッドがしてくれるので、プログラマーが気にする必要はありません。
updateアクション
updateアクションはコントローラのみでビューはありません。
def update @word = Word.find(params[:id]) if @word.update(word_params) redirect_to @word, status: :see_other else flash.now[:alert] = "単語「#{@word.en}」は変更できませんでした" render :edit, status: :unprocessable_entity end end
アップデートができたらshowにリダイレクト、失敗したらedit画面を再表示します。
@word
だけで/words/:id
にリダイレクトできます。
word_path(@word)
より短い@word
だけでも同じリンク先を生成してくれるのは、リソースフル・ルーティングのさらなる恩恵です。
destroyアクション
show画面ではナビゲーションバーの「削除」リンクが使えるようになります。 それをクリックするとdestroyアクションに遷移します。 destroyアクションはコントローラだけでビューはありません。
def destroy @word = Word.find(params[:id]) if @word == nil flash[:alert] = "単語#{@en}は未登録のため削除できません" else begin @word.destroy rescue flash[:alert] = "単語#{@en}を削除できませんでした" else flash[:success] = "単語を削除しました" end redirect_to words_path, status: :see_other end end
削除ができてもできなくてもINDEX画面にリダイレクトします。 クライアント側はフラッシュ・メッセージによって削除できたかどうかを知ることができます。
起動
コマンドラインから起動します。
$ ./bin/rails server
ブラウザを起動しhttp://localhost:3000
にアクセスするとWordbookの初期画面が現れます。
railsを終了するには、コマンドラインからCTRL-Cを入力します。
まとめ
前回のWordbookと比べ、大きな差はないものの、リソースフル・ルーティングを使ったおかげで楽をした部分がありました。 また、Railsの慣例に従っているので、メンテナが変わっても分かりやすいはずです。 そのような保守管理の意味でも慣例を守るのは重要なことです。
Wordbookはシンプルで分かりやすいと思います。 実際のウェブの開発はもっと複雑でデータベース・テーブルも複数になり、それらが関係しあっていることもあります。 そのようなテーブル間の関係をRailsはサポートしているのですが、今回はそこまでは踏み込みませんでした。 詳しいことは「Rails Guide」を参照してください。
また、ソースコードはGitHubの_example/word_book_rails_resources
にありますので、
必要な方はダウンロードまたはクローンしてお使いください。