おもこん

おもこんは「思いつくままにコンピュターの話し」の省略形です

徒然Ruby(31)Rails7 慣例にそったプログラミング

今回はRailsの慣例に沿った形でWordbookを作り直します。

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つのアクションにまとめることができるから

  • プログラマーは、このパターンを主に使うようにすると良い
  • Railsは「このパターンによるプログラミング」が楽になるような様々な工夫をしている

ということなのです。

上記の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_nameRailsのメソッドで、そのときのアクション名を文字列で返します。 したがって、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はクライアントからのデータを調べ、必要なものだけを取り出します。 この仕組みはparamsrequirepermitの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=adminnil|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にありますので、 必要な方はダウンロードまたはクローンしてお使いください。