Dive into Ruby on Rails 2025

はじめに

筆者がRailsに初めて触れてアプリを作ってみてからもう10年経ちます。その間、様々な脇道に逸れましたが、やっぱりここに戻ってきます。唯一無二の最強フレームワーク!というつもりはないんですが、土台になっているRubyのエコシステムを含め、開発者に寄り添った優しい哲学が通底していることが感じられるようになると、居心地がどんどんと良くなってくるフレームワークです。

Ruby on Railsを体系的に学ぶには「Railsチュートリアル」や「Railsガイド」などの有名なウェブサイトの利用をオススメします。本書はこうした網羅的なドキュメントには及びませんが、ちょっとした読み物的な感じで、イチ開発者の目線からみたRailsの現状や開発のコツなどに触れて頂ければ幸いです。

対象読者・前提知識

macOS(またはlinux)の基本的な操作に加え、後述するVSCodeのようなプログラミング向けのテキストエディタの基本的な取り扱い、及びターミナルの操作ができることを前提とします。書籍内でも必要な説明は適宜挟みますが、Rubyを仕事などで実用的に使って行きたい場合は「UNIXコマンド」と端末の操作についても並行して学んでおくことを推奨します。多くのRuby実行環境では、これらは切っても切り離せない関連性があります。巻末に「付録」として主要なUNIXコマンドの解説を付記しています。

Windows環境でもDockerやRubyをインストールすることで、原則として同じことが可能です。ターミナルアプリの操作をコマンドプロンプトに置き換えて頂ければ、ほとんどは動作するはずです。しかしながら、本書ではWindowsでの利用を想定した解説は行いませんので、予めご了承ください。

本書は、「完全な初心者」をターゲットにした入門書ではありません。解答編でなるべく説明はこころがけますが、基本的には読者自身の「調べる力」の涵養を目した構成になっています。つまり、問題が与えられた時に「今の自分にこれを解くことが出来るか?」という判断に、まずは全力で取り組んで貰いたいと思います。そして「今の自分に足りない知識は何か?」と、問いを進めてみてください。最終的に正解にたどり着かなくても、これらの問いを繰り返すことで、「自分が学ぶべきこと」の概形がぼんやりとでも見えてくれば、学びのステップは確実に一歩進んでいます。

前著との違い

初版ではVagrant、前著ではDockerを使って仮想環境を作っていましたが、今回はHomebrew(rbenv)を使用しています。Dockerそのものの学習コストも無視できないほど大きいことがこの2年間の取り組みで見えてきました。ならばもう、直接開発機に環境を用意してレイヤを減らした方がスピードも出るし、ということでHomebrewに回帰しています。

中心となるRubyとRailsもバージョンアップをしています。執筆時点(2025.01)で最新のRuby3.4を前提に解説をします。

(初版、2021版、2023版(本書)の主な推移)

Vagrant -> Docker -> Homebrew
Bash -> Zsh
Ruby 2.1 -> 2.7 -> 3.1 -> 3.4
Rails 5 -> 6.1 -> 7.0 -> 8.0
Atom -> VSCode

環境構築(一般)

最初に強みとして紹介した「高速プロトタイピング」をいきなり否定する話になってしまいますが、 Railsはここで躓く人が多いのではなかろうか、と考えています。rails newと打ってプロジェクトを開始するまでが意外とハードルが高い。名前の通りレールの上を走ればあっというまに遠くまで行ける、そういう思想のフレームワークだったはずなんですが、Rubyという処理系、そして周辺のライブラリが多様な環境で動作するようになった代償として、あまりにも色んなタイプのレールが存在しすぎているような気がします。

例として、筆者が現在管理していたり、導入を検討してみた環境を以下に挙げてみました。

大まかに言うと、OSの上にRubyを載せて、その上にRailsが乗っかるイメージです。 全て問題なく動作している限りにおいては、それぞれの違いを意識することはないのですが、、 それはあくまで理想的な状況であって、実際には上記の組み合わせ次第で様々なエラーに出くわします。

インターネット上に解決策が存在している、と言っても、それがどの組み合わせに対して有効なものなのか…はっきり言って大抵の人はゲンナリしてしまう複雑さだと思います。上述のRailsチュートリアルを読んでも、冒頭のから「〜の環境の場合は」という注意書きが多いことに気づかれると思います。これこそが、Railsが広く普及した証左でもあるのですが、新規の参入者を戸惑わせるポイントにもなっていると思います。

そのため、本書ではこれ以降、可能な限り脇道の解説は省いて、この本の中で作りたいものをインターネットに公開するまでの一直線に絞って解説をします。

macOS

https://www.apple.com/macos/

Apple社の開発するUNIXベースのオペレーティング・システムです(2025.01時点の最新版は「Sequoia」)。シンプルなユーザインタフェースや操作性が特徴として挙げられることが多いですが、開発者にとっては「UNIXベース」である、という点がより重要になってきます。Termnal(ターミナル)というアプリを介して、CUI(コマンドライン・ユーザ・インタフェース)から様々な機能を呼び出すことが出来ます。後述するRailsやDockerもCUIからの操作を前提としていますので、Terminalを操る知識はほとんど必須と言っても過言ではないでしょう。

Terminal.app

macOSに標準添付されているアプリケーションです。「端末エミュレータ」と呼ばれることもあります。この中でさらに「シェル」と呼ばれるプログラムが起動し、ユーザからのコマンド入力やバックグラウンドプロセスを処理します。「Zsh」という名前のシェルプログラムが標準で起動します。このアプリの使い方は実際の開発や運用の現場で非常に重要になってくることがありますので、興味のある方は「Zsh」関連の書籍や情報にも目を通しておかれることをお勧めします。

Ruby

https://www.ruby-lang.org/

本書を読まれている方ならご存知でしょうけれども、折角なので改めてご紹介を。Rubyはオブジェクト指向のLL(ライトウェイト)言語です。PerlやPythonなどと並び世界的に利用されるメジャーな言語です。筆者は元々、Pythonを利用していましたが、日本語を扱う際の親和性などが気に入り、こちらに鞍替えしてきた経緯があります。実際に使ってみるとその柔軟な構文と拡張性の高さにすっかり魅了されること請け合いです。

Rubygems

https://rubygems.org/

Rubyのパッケージ管理ツールです。Linuxを使われる方ならaptやyum、(大雑把に言えば)MacのApp StoreやGoogleのGoogle Playなどを想像して頂ければ良いかもしれません。Rubyの開発に利用できる各種ライブラリやフレームワークが公開されていて、コマンドラインからそれらをインストール・管理することができます。このツールで配布される個々のライブラリやフレームワークは「gem(ジェム)」と呼ばれます。

Bundler

http://bundler.io/

Bundlerはgemの依存性を管理するためのツールです。gemは便利な仕組みですが、長期間に渡って、或いは多数のプロジェクトを同一ホストで同時並行的に運用する場合に、バージョン番号の不整合が起こってくることがあります。例えば、あるプログラムは古いバージョンのままのライブラリが必要な一方で、別のプログラムは新しいバージョンでなければ動かない、というような状況です。Bundlerはこの問題を解決してくれます。Ruby on railsで新しいプロジェクトを作成すると、自動的にBundlerのセットアップを行います。

Ruby on Rails

http://rubyonrails.org/

最後に紹介するのが、本書の主役、Ruby on Railsです。手軽にデータベースと連携したウェブアプリを立ち上げることができるウェブブレームワークとして(一部の業界では)一世を風靡しましたが、今は押しも押されもせぬ地位に収まった感があります(筆者の主観ですが)。WebrickとSQLiteという簡易ウェブサーバとデータベースを使って、ものの数分でアプリが立ち上がるスピードは確かに目を見張るものがありますが、一方で、これらの組み合わせでは実運用に耐える構成にはそのまま持ち込めないという問題もあります。つまり、プロトタイピングにはとても便利なのですが、運用までしっかり検討しだすと、実はそれほどのスピード感はない、というのが実情ではないでしょうか。しかしながら、冒頭でも述べたように、「プロトタイピング」用途での優位性はありますし、その他にも優れた機能を多く有していることも間違いありません。本書で実際にアプリケーションを構築するプロセスを通して、じっくりと一つ一つRailsの強力な機能を使いこなせるようになってゆきましょう。

VSCode

https://code.visualstudio.com

Atomの後継としてこちらのエディタを使用しています。Microsoftが提供しており、無償で利用できます。開発者向けのエディタとして十分な機能を備えつつ、拡張機能をインストールすることで、様々な言語や環境に対応できる柔軟性が魅力です。

Homebrew

https://brew.sh/

macOSのパッケージマネージャです。なんだかんだと使えるパッケージが多く、結局こちらに戻ってきました。インストールされていない場合は、公式サイトの手順に従って brew コマンドが使える状態にしておいてください(早速次から使います)。

環境構築例

Ruby on Railsのプロジェクトは複数管理することを想定して、rbenvという構成管理ツールを挟んでいます。

brew install rbenv git-lfs
rbenv install 3.4.1
rbenv init  # ここで表示されるメッセージに従って、シェルの設定ファイル(.zshrc)などを書き換えます

railsは以下のようにインストールします。

gem install rails

一通りのインストールが出来たら、以下のようにバージョンを確認してください。コメント(#から後ろ)は筆者の環境での出力例(抜粋)です。完全に一致していなくても、この先に進むことができますが、もし、ここよりも古い(=数字の小さい)バージョンがインストールされている場合は、ここまでの手順を見直してください。

ruby -v  # ruby 3.4.1
rails -v # Rails 8.0.1

以下のコマンドで最初のrailsプロジェクトを作ります。

rails new rails01-inquiry --minimal

プロジェクトの作成の中で、Railsが必要とするライブラリのインストール(bundle install)とが動作するため、ある程度時間がかかります。気長に待ちましょう。正常に完了すると、現在のディレクトリの中にrailsプロジェクトの雛形となる多数のファイルが生成されているはずです。

以降のセットアップは次章で実際にアプリケーションを作りながら説明します。

RaspberryPiでの実施など、リソースが限られている場合は、--skip-gitをつけてリポジトリの初期化をスキップすることが可能です。

本書で作成するサンプルプログラムは以下のGitHubリポジトリで公開しています。

https://github.com/lumbermill/2501-rails

サンプルプロジェクト1 - inquiry

前章までに開発用の環境が整いましたので、ここから実際に動作するアプリケーションの構築にかかろうと思います。まず最初に作るのは、とてもシンプルなお問い合わせフォームです。以下のように仕様(やりたいこと)を決めてみましょう。

とりあえず上記のようなシンプルなウェブサイトを目指してみましょう。デザインはBootstrapでお手軽に済ませてしまうこととします。データベースはデフォルトのSQLiteです(8.0より前はSQLiteは開発専用という位置付けでしたが、8.0で運用にも使えるようになりました)。

新しいプロジェクトを作成する

以下のコマンドで新しいプロジェクトを作成します。前節から進んで来ている場合は、既に同じコマンドを実行済みのはずなので不要です(そのままrails01-inquiryディレクトリに入ってください)。

rails new rails01-inquiry --minimal
cd rails01-inquiry

とりあえずウェブサーバを立ち上げてみましょう。

rails s

以下をブラウザのアドレスバーに打ち込むと…

http://localhost:3000/

以下のような画面が表示されたら、ここまでは順調です!

Yay! you’re on Rails!

お問い合わせの骨組みを作る(scaffold)

先程、rails sを実行したターミナルでは、PostgreSQLとRailsのWebサーバが引き続き動作しています。終了する場合はCtrl+Cを入力します。ここからの作業は、このサーバを立ち上げたままの状態で行いますので、もう一つ新しいターミナルのタブまたはウィンドウを準備してください(ディレクトリも同じ”rails01-inquiry”に居る状態にします)。

以下のコマンドを入力します。

rails g scaffold inquiry name email purpose:integer body:text
rails db:migrate

このコマンドを実行すると各種のファイルが生成され、以下のURLにアクセスすると、

http://localhost:3000/inquiries

このような画面が表示されます。

Inquiries

「Create Inquiry」というボタンを押すと、新しいお問い合わせを作成する画面に遷移します。 ここが今回、ユーザがお問い合わせを送信する画面になります。

Create Inquiry

purpose列は、冒頭に定義した問い合わせの種類、例えば質問・見積依頼・苦情などを格納するために作成しています。 他の列と同様に文字列型で定義して、データベースを直接覗いても、中身がわかるようにした方が、 運用面では便利ではありますが、今回、異なるタイプの型も扱ってみたいのであえて数値(integer)型を使っています。 body列も同様に、一般的な問い合わせ本文だと長くても数百文字程度かと想定されますので、文字列型で良いのですが、 異なるタイプの文字型(text)を指定しています。

この辺り、Railsが簡単そうに見えて実は運用で落とし穴にハマる最初のポイントになるかと思います。 どんな型を選ぶかという決定がリリース後の運用や規模拡大の際の設計に大きく影響します。 どのデータベースエンジンを選ぶかによっても適切な列の型は変わりますので、総合的な判断が必要です。

今のままだとトップページに何も表示されませんので、お問い合わせの新規作成画面をトップに登録します。config/routes.rb に以下の一行を追加してください(元々ある行をコメントアウトして書き換えてもOKです)。

  root 'inquiries#new'

メールを送信する(action_mailer)

RailsにはActionMailerというメール送信のための仕組みがを作成します。 先程のscaffoldと同様に、必要なコードの雛形を自動生成することが出来ます。 以下の例ではsent(問い合わせをしたユーザに送る受付確認)とreceived(管理者に問い合わせがあったことを知らせるメール) という2つのメールを送るメーラをinquiriesという名前で定義しています。

rails g mailer inquiries sent received

実際にメールを送出する処理は、コントローラ内に記載します。先ほどのscaffold実行時に生成されているcreateメソッドの中に以下のように追加してください。

app/controllers/inquiries_controller.rb

# POST /inquiries
# POST /inquiries.json
def create
  @inquiry = Inquiry.new(inquiry_params)
  if @inquiry.save
    InquiriesMailer.sent(to: @inquiry.email).deliver_later
    InquiriesMailer.received.deliver_later
    redirect_to @inquiry, notice: 'Inquiry was successfully created.'
  else
    render :new
  end
end

sentメソッドの宛先はフォームに入力されたアドレスを利用するため、 app/mailers/inquiries_mailer.rb を編集して、メールアドレスを引数にセットできるように変更してください。

class InquiriesMailer < ActionMailer::Base
  default from: "info@lmlab.net"

  def sent(to: 'dummy@lmlab.net')
    @greeting = "Hi"
    mail to: to
  end
  :
end

また、今回は、--minimalオプションをつけてプロジェクトを生成しているため、config/application.rbを編集して以下のモジュールをコメントアウト(有効化)しておく必要があります。

require "action_mailer/railtie"

config/environments/development.rb に以下の設定を追加します。

  # Don't care if the mailer can't send.
  config.action_mailer.raise_delivery_errors = false

  # Make template changes take effect immediately.
  config.action_mailer.perform_caching = false

  # Set localhost to be used by links generated in mailer templates.
  config.action_mailer.default_url_options = { host: "localhost", port: 3000 }

この状態で、問い合わせを適当に作成してみると、サーバが起動しているターミナルに以下のようなログが書き出され、 Railsがメールを送信しようとしてくれていることが確認できます(この環境では実際にメールが飛んでいくことはないです)。

[ActiveJob] [ActionMailer::MailDeliveryJob] [c43f51c6-bbbd-4947-95d6-c396f9972b1e] Date: Sun, 16 Jul 2023 17:43:56 +0900
From: from@example.com
To: to@example.org
Message-ID: <64b3adcc235c5_ef662788598b6@ryonoMacBook-Pro.local.mail>
Subject: Received
Mime-Version: 1.0
Content-Type: multipart/alternative;
 boundary="--==_mimepart_64b3adcc2132e_ef6627885965";
 charset=UTF-8
Content-Transfer-Encoding: 7bit


----==_mimepart_64b3adcc2132e_ef6627885965
Content-Type: text/plain;
 charset=UTF-8
Content-Transfer-Encoding: 7bit

Notifications#received

Hi, find me in app/views/notifications_mailer/received.text.erb

メールサーバの設定については、最終節の配備のところで実際に試してみたいと思います。 ここでは、ログ上で送信しようとしているメールが確認できればOKとします。

BASIC認証を行う

お問い合わせが送信されて、データベースにレコードが登録されるようになりましたが、 このままでは、誰でもお問い合わせの履歴が見えてしまいます。 BASIC認証というシンプルな仕組みを利用してパスワードを知らないユーザにはアクセスが 出来ないようにしてしまいましょう。app/controllers/application_controller.rbに 以下のような関数(authenticate)を定義します。 このクラスにはアプリケーション全体で利用できるメソッドや変数を定義することができます。

class ApplicationController < ActionController::Base
  def authenticate
    authenticate_or_request_with_http_basic do |username, password|
      pw = Rails.application.credentials.basis_auth_password
      if (username == "kanri" && password == pw)
        session[:user] = username
      end
    end
  end
end

本質的な対策ではない、ちょっとしたテクニックですが、管理者の名前としての adminroot などは、 一般的すぎてブルートフォースアタックの標的にされやすいということがあります。 ここで定義する名前は管理者にしか思いつかないような、一般的でないものの方が”ちょっとだけ”安全です。

パスワードは以下のコマンドで暗号化されたストレージの中に書き込みます(vi,nanoエディタの操作説明は省略しています)。

EDITOR=nano rails credentials:edit

最下行に以下のように追記してください。

basis_auth_password: secret

authenticateメソッドは、app/controllers/inquiries_controller.rbの一番上の部分で以下のように呼び出します。 お問い合わせの新規作成は誰でもアクセスできる必要があるので、それらは除外します。

  before_action :authenticate, except: [:show, :new, :create]

一般ユーザが問い合わせをするとRailsのコントローラ上では new -> create -> show という順番で処理が遷移します。 送信完了した際にshowが呼ばれるのですが、ここに認証無しで詳細を表示してしまうと、やはり情報漏えいのリスクに繋がります。

上記authenticateメソッドでセットしたsession[:user]変数を利用して、ログイン済みの管理者であれば詳細を表示、 ログインしていない一般ユーザであれば、(問い合わせ送信後にしか表示されないので)お礼のメッセージのみを返すように、 app/views/inquiries/show.html.erbを変更します。

<% if session[:user] == "kanri" %>
  <%= render @inquiry %>
  : 元々のあったコード全体

<% else %>
  <p>Thank you for the inquiry!</p>
<% end %>

レスポンシブ対応にする(bootstrap)

このままでも、最低限の機能はありますが、スマホファーストのこのご時世に流石にちょっと物足りないかもしれません。 Bootstrapというレスポンシブのフレームワークを入れて、スマートフォンから閲覧しても使いやすいインタフェースに書き換えましょう。

https://getbootstrap.com/

幾つか方法がありますが、ここでは一番お手軽にCDNを使う方法を紹介します。 25年1月時点、バージョン5.3が最新です。app/views/layouts/application.html.erbにlink,scriptタグを追加します。

<html>
  <head>
    : (略)
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
  </head>
  <body>
    : (略)
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
  </body>
</html>

gemやnpmを使う方法もありますが、依存関係が複雑になるため推奨していません。CDNへのアクセスに不安がある環境の場合は、上記のファイルを直接ダウンロードして手元のパスを指定する方法を取るのが結果としてシンプルに運用できると思います。

これで、Bootstrapの導入ができましたので、あとは既存のHTML(.erb)をBootstrapの流儀で修正してゆきます。 ここから先はどんなデザインのサイトにしたいかによって幾つかのパターンがあると思いますが、ひとまずシンプルな例を紹介します。

まずは、app/views/layouts/application.html.erbの中のyieldを以下のdivタグで囲っておきます。

  :
  <body>
    <div class="container">
      <%= yield %>
    </div>
  :

続けて、app/views/inquiries/new.html.erbを以下のように修正します。 スタイルをつけるついでに「Back to inquiries」のボタンをログイン済みのユーザにしか見えないように隠しています。

<div class="row">
  <div class="offset-md-3 col-md-6">
    <h1>New Inquiry</h1>

      <%= render 'form', inquiry: @inquiry %>

      <% if session[:user] == "admin" %>
      <%= link_to 'Back to inquiries', inquiries_path %>
      <% end %>
  </div>
</div>

app/views/inquiries/_form.html.erb内のフォームの要素には以下のようにクラス名を追加します。

  : (省略)
  <div class="form-group">
    <%= form.label :body %>
    <%= form.text_area :body, class: "form-control" %>
  </div>

  <div class="mt-3">
    <%= form.submit nil, class: "btn btn-primary" %>
  </div>
<% end %>

app/views/inquiries/index.html.erbもこんな感じ(cardスタイルのdivで包んで)でBootstrapぽくしてみます。

<% @inquiries.each do |inquiry| %>
  <div class="card mb-3">
    <div class="card-body">
      <%= render inquiry %>
      <p class="card-text">
        <%= link_to "Show this inquiry", inquiry, class: "btn btn-light" %>
      </p>
    </div>
  </div>
<% end %>
:
<%= link_to "New inquiry", new_inquiry_path, class: "btn btn-primary" %>

改めて一覧画面を表示すると、以下のようなスタイルが適用された状態になっているはずです。

inquiries

Rails6までは、テーブル形式で一覧表が生成されていました。多くの件数を一気に表示して視認性を良くしたい場合などは、引き続きこちらの方が都合が良い場合もあると思います。

<table class="table table-striped">
  :
  <td><%= inquiry.name %></td>
  :
  <td><%= link_to 'Show', inquiry, class: "btn btn-light btn-sm" %></td>
</td>
  :
<%= link_to 'New Inquiry', new_inquiry_path, class: "btn btn-primary"  %>
inquiries

フォームのデザインは以下のような感じで、スマートフォンなどの小さなデバイスでアクセスした際の使いやすさも考慮された形になっています。

inquiry-new

flashメッセージを利用する

bootstrapの続きでもあり、railsの基本機能でもあります。

レコードの保存などの何らかの処理が終わり別のページにリダイレクトされた後にメッセージを表示する仕組みに利用されています。scaffoldで生成されたHTMLの中にも入っていますが、bootstrapのスタイルに合わせて表示する場合、以下のようなコードをlayouts/application.html.erbに入れておくと、どんなページに遷移してもflashメッセージが表示されるようになります。

<% if notice %>
  <div id="notice" class="alert alert-success"><%= notice %></div>
<% end %>
<% if alert %>
  <div id="alert" class="alert alert-warning"><%= alert %></div>
<% end %>

補足:application_helperに以下のようなメソッドを追加して利用する方法も便利なのでよく使っています。

  def bootstrap_flash
    flash_messages = []
    flash.each do |type, message|
      next if message.blank?

      t = "light"
      t = "info" if type == "notice"
      t = "danger" if type == "alert"

      flash_messages << content_tag(:div, message.html_safe, class: "alert alert-#{t}")
    end
    flash_messages.join("\n").html_safe
  end

画像も投稿できるようにしてみる(active_storage)

お問い合わせフォームで画像も送信できるようにします。ActiveStorageという仕組みを利用します。

今回は、--minimalオプションをつけてプロジェクトを生成しているため、config/application.rbを編集して以下のモジュールをコメントアウト(有効化)しておく必要があります。

require "active_storage/engine"

まず、以下のコマンドでActiveStorageの管理テーブルを用意します。

rails active_storage:install
rails db:migrate

続けて、config/storage.ymlを新しく作成し、以下の内容を記載します。

local:
  service: Disk
  root: <%= Rails.root.join("tmp/storage") %>

config/environments/development.rbにも設定の追記が必要です(これはエラーが分かりづらいので要注意)。

  config.active_storage.service = :local

モデルに以下の記述(has_many_attached)を追加します。一つで十分な場合は、has_one_attached になります。

class Inquiry < ApplicationRecord
  has_many_attached :images
end

フォームのHTMLにファイル添付用のフィールドを追加します。

<div class="form-field">
  <%= form.label :images %>
  <%= form.file_field :images, multiple: true, class: "form-control" %>
</div>

コントローラでも許可が必要です(ちょっと書き方が変わっているので要注意かもしれません)。

def inquiry_params
  params.expect(inquiry: [:name, :email, :purpose, :body, images:[]])
end

以上でファイルをサーバに送信できるようになります。 参照も追加したフィールド名を使って簡単に行うことができます。

<% if inquiry.images.attached? %>
  <p>
  <% inquiry.images.each do |i| %>
    <%= image_tag i %>
  <% end %>
  </p>
<% end %>

リソース(画像)のURLを使いたい場合、url_forへルパメソッドが利用できます。

<%= link_to "Image", url_for(i) %>

ActiveRecord以外にも、データベースにblob列を保持したり、carrierwaveというgemを使うなど、様々な方法がありますが、AWS S3などのストレージに拡張していくことまで考えると、この方法が一番お手軽なように思います。ただし、アプリケーションの性質によってケースバイケースになることもあります。この方法は、一般的にオブジェクトストレージというアプリケーションが稼働するサーバやデータベースとは別の領域に実際のデータを保存します。そこにアクセスするためのURLが払い出される格好になるのですが、通常の設定のままだとこのURLに直接アクセスされると誰でも画像が見えてしまいます。公開前提のデータなら問題ありませんが、お問い合わせ本文に含めるような秘匿性の求められる場合には、検討が必要です。

完成!

ここまでの手順でごくシンプルなものではありますが、お問合せのフォームが完成しました!ここで作成したプロジェクトはGitHubにも公開しています。次の章に進むともう少しだけ複雑なアプリの構築を行います。これで十分!という場合には、「配備・運用」の章に進んでください。

その他のTips

上記のアプリには採用しなかったものの、よく必要となる機能の紹介をまとめました。

JavaScriptをアセットに加える

最初のサンプルプロジェクトのように最小構成だと、JavaScriptを記述するファイルが一切ない状態になっています。 app/assetsの下にjavascriptsフォルダを作成して、application.jsを置き、layoutsビューに以下のタグを追加します。

<%= javascript_include_tag :application %>

importmapを使用している場合は不要で、app/javascript下に拡張していくことができます。

時間のかかる処理を実行する(active_job)

例えば、非常に重たい検索クエリを実行する場合など、リクエストの中で実行してしまうと、画面が固まったような状態になってしまいユーザビリティが良くありません。最悪、タイムアウトエラーになってしまい何も得られない…ということも起こります。

RailsではActiveJobという仕組みが利用できます。

class SearchJob < ApplicationJob
  queue_as :default

  def perform(*args)
    logger.debug("Start searching!! "+args.to_s)
    sleep(10)
    logger.debug("Finish searching!! "+args.to_s)
  end
end

コントローラなどから以下のように呼び出すことが出来ます。

SearchJob.perform_later(keyword)

この例だとThreadと大差ありませんが、、もう少し大規模化してきたときに恩恵があるはず…と思います(例えば、未処理のジョブを抱えた状態で再起動がかかっても残った処理を安全に再開できるなど)。

非同期化された処理の中からは、sessionなどを直接見に行くことは出来ませんので、ファイルやDBを介して結果をやり取りする仕組みは別途検討する必要があります。

テストを修正する

ある程度動くようになった段階(人によっては「遅すぎる」と感じてしまうかもしれませんが)テスト用のコードも記述が必要です。 ここまでの操作でもある程度の数のテストが自動生成されています。それらをまとめて実行するには以下のコマンドを実行します。

rails test   

ここまで一切、ケアをしてこなかったので、ここで幾つかエラーやテストの失敗が表示されてしまうはずです。 テストの結果で、Eはエラーが起こったことを、Fはアサーション(結果の確認)に失敗したことを意味します。

# Running:

........F

なぜエラーになっているか分からない場合、@responseオブジェクトの中身を表示してみるとヒントが得られるかもしれません

test "should destroy inquiry" do
  assert_difference('Inquiry.count', -1) do
    delete inquiry_url(Inquiry.first)
    p @response
    :

コードを修正する都度、全てのテストを走らせていると時間がかかります。以下のようにファイル名と行番号を指定して、個別に実行することが可能です。このコマンド(rails test..以降)は失敗した全てのテストに対して出力されるので、コピー&ペーストで実行することができます。

rails test test/controllers/inquiries_controller_test.rb:41

今回の場合、最初にBASIC認証を入れていたため、テストコードから管理画面にアクセスできずに失敗しています。 (ベストなやり方ではありませんが)以下のようにBASIC認証をテスト時には無効化することで回避できます。

class ApplicationController < ActionController::Base
  def authenticate
    return if Rails.env == "test"
    :

本来的には、もっと本格的な認証機能を入れたり、認証機能の有効性(つまり認証していないユーザに情報が見えてしまわないことの確認)も含めてテストすることが望ましいと考えられます(次の章で、deviseという認証機能を提供するgemを紹介します)。

htmlを表示またはエスケープする

Railsのビューにはコントローラから渡してきた様々な値を表示することができます。しかし、悪意を持った(もしくは何も知らない)ユーザが、htmlやJavaScriptのコードを文字列に格納して送信し、それがそのまま表示されてしまうと様々な不具合、場合によってはセキュリティ上の脅威になってしまいます。

そのためRailsの文字列をそのままビューに渡すと、タグが無効化(=エスケープ)された状態で表示されるようになっています。それをせず、htmlをそのまま表示したい場合は、以下のように記載する必要があります。

test.html_safe

逆に、不要なタグを消してしまいたい場合、sanitizeが利用できます。また、下記の例では文字列中の改行をhtmlの改行タグ(br)に変換して、改行もレンダリングされるように工夫しています。

<%= sanitize(text.gsub("\n","<br>")) %> <%# br(改行)は残る! %>

歴史上の都合でしょうか、他にも似たような目的の様々なメソッドがあります。基本的にはsanitizeだけ覚えておけば事足りそうです。

<% text = "<b>1</b>\n<i>2</i>\n3 >= a" %>
<%= text %> <%# そのまま(タグはエスケープされる) %>
<hr/>
<%= h(text) %> <%# そのままと一緒(使う意味ない?) %>
<hr/>
<%= simple_format(text) %> <%# 勝手に改行 & pタグが付く(お好みで..) %>
<hr/>
<%= strip_tags(text) %> <%# タグが消える %>
<hr/>
<%= sanitize(text) %> <%# 危ないタグだけ消える %>
<hr/>
<%= sanitize(text.gsub("\n","<br>")) %> <%# br(改行)は残る! %>

エラーをユーザに通知する

処理の途中で想定外のデータが入ってきたり、または何らかの理由で情報を表示すべきでない場合があります。 Railsではコントローラで発生したエラーは、開発環境(development)では、スタックトレースとして詳細が表示され、 運用環境(production)で発生した場合には、詳細が隠された状態でユーザに通知されます。

つまり、(極端に言えば)以下のように例外を投げるだけでエラー処理が可能です。

  def show
    raise “Page not found!”
  end

ただ、これでは、実際のユーザにとってエラーの原因が全く伝わらないことになってしまいます。 (詳細を出し過ぎても分からないですし、なによりセキュリティ上のリスクになり得ます) renderメソッドを使って場面ごとに適切なページを表示するように作ることができます。

# エラーメッセージ(text)のみを返します
render status: 404, body: "Inquiry not found"
# public/404.htmlを表示します
render status: 404, file: “#{Rails.root}/public/404.html”
# app/views/shared/not_found.html.erbを表示します
render status: 404, file: "shared/not_found"
# ステータスコード404を返します。(通常のビューが表示されます)
render status: 404

詳しくはhttpのステータスコードなどを調べてみてください。

フォームをカスタマイズする

scaffoldで生成されたコードを見ながら、基本的な書き方は押さえられますが、ここでは、リファレンスを見ないと迷ってしまいそうな、フィールドの書き方を紹介します。

セレクトボックスは以下のように作成可能です。1つめは別のモデルから、2つめは配列から選択肢を取得します。

<%= form.select :page_id, options_from_collection_for_select(Page.all, :id, :name) %>
<%= form.select :status, [["Published","published"],["Hidden","hidden"]], {}, class: "form-control" %>

今回のサンプルではpurpose列をセレクトボックスに書き換えてみます。RailsのDRY原則に乗っ取って、定数の値は一箇所(モデル内)に宣言します。

app/models/inquiry.rb

PURPOSES = ["質問","見積依頼"]

def purpose_value
  PURPOSES[purpose] || purpose.to_s
end

def self.purposes_for_select
  PURPOSES.map.with_index.to_a
end

app/views/inquiries/_form.html.erb

<%= form.select :purpose, Inquiry.purposes_for_select, {}, class: "form-control" %>

app/views/inquiries/index.html.erb

<td><%= inquiry.purpose_value %></td>

長いテキストを途中で切って表示する

truncateというメソッドが用意されています。

app/views/inquiries/index.html.erb

<td><%= inquiry.body.truncate(80) %></td>

入力のバリデーション

現在のフォームは、実は何も入力しなくても送信が出来てしまいます。以下のようにモデルに設定を追加することで入力がない場合にエラーを表示することが出来るようになります。

app/models/inquiry.rb

validates :email, presence: true

一覧表示の順番を修正する

ActiveRecordのallメソッドで、データベースに入っている全てのレコードを取得できますが、表示順が固定ではありません。決まった順序で表示したい場合、orderメソッドを追加します。下記例ではid列の順にソートして表示します。

def index
  @inquiries = Inquiry.all.order(:id)
end

標準のテンプレート以外を書き出す

レイアウトを適用せずに、viewsの中身だけをレンダリングするには、以下のようにfalseを渡します。

render layout: false

Railsガイド にはもっと多くの適用例があります。

Timezoneの設定

Time.nowはUTC(標準時)を返します。日本標準時を使いたい場合、Time.zoneを使います。

Time.zone.now.strftime("%Y%m%d")

国際化対応は単純ではない問題です。詳しい対処法についてはRailsガイドの解説などが参考になります。まずは日本語(または他の単一の言語)で提供が出来れば十分、という場合、config/application.rbでタイムゾーンとロケール(言語)を固定してしまうことも可能です。

config.time_zone = 'Tokyo'
config.i18n.default_locale = :ja

scaffoldをカスタマイズする

たくさんのモデルを扱うアプリケーションでは、scaffoldを何回も繰り返して使うことがあります。そのような場合、繰り返し修正するのは大変なのでカスタマイズしたテンプレートをプロジェクト内に置いておくことができます(注:以前は rails:templates:copy というタスクが存在していたようなのですが、今は見当たりません。推奨される方法ではない可能性もある点に留意してください)。

まずはlib以下に対象のフォルダを作ります。

mkdir -p lib/templates/erb/scaffold

規定のテンプレートを参考にしたいので、その場所を探します。

bundle show railties
/Users/lmuser/.rbenv/versions/3.4.1/lib/ruby/gems/3.4.0/gems/railties-8.0.1  # 一例です

この中からさらに lib/rails/generators/erb/scaffold/templates/ と降りていくと、拡張子がttのファイルが見つかりますので、それを上記のフォルダにコピーしてカスタマイズしてください。

削除実行前に確認ダイアログを出す

以下はTurboを有効にしている場合の実装例です。btn-dangerクラスの付いたボタンが押されたら、まず確認ダイアログを表示して、「はい」と答えた場合のみ実際に処理を実行します。app/javascript/application.jsなどに以下を追記してください。

document.addEventListener("turbo:load", function() {
    for (const elm of document.getElementsByClassName("btn-danger")) {
        elm.addEventListener("click", function(event) {
            var confirmed = confirm("本当に削除しますか?");
            if (!confirmed) {
              event.preventDefault();
            }
        });
        console.log("Added confirm dialog to:"+elm);
    }
});

Turboフレームワークを利用していない場合は、DOMContentLoaded に対してリスナーを定義することで同等の動作が可能です。

もっと知りたい時は

次の章で、もう少し詳しく帳票形のアプリケーションの作成に必要なライブラリなどの説明をしますが、 方向性の違うアプリケーションを作りたい場合、ここで本書を離れていくのも一つの区切り目かと思います。

Railsはバージョンごとに挙動の違う部分もあったり、プロジェクトの作り方によって様々なことが起こり得ます。 Ruby on Rails ガイドなど網羅的に開設された資料を参考にして、深掘りしていくことをおすすめします。 また、拡張機能については、Ruby gemsというライブラリを探してみてください。 同じことをするライブラリでも複数の選択肢がある場合がありますが、その場合は、例えば、ダウンロード数の多寡や ライブラリ自体の更新頻度なども一つの指標になります(あくまで指標ですが…)。

次の章では以下のようなgemを追加していきます。

gem 'devise', '~> 4.7'
gem 'font-awesome-sass', '~> 5.12'
gem 'jquery-rails', '~> 4.4'
gem 'paper_trail', '~> 10.3'
gem 'prawn', '~> 2.4.0'

gemを追加したら、bundle installの実行が必要です。

うまく行かなかった時は

scaffoldをやり直したい時は、destroyサブコマンドが使えます。

rails destroy scaffold inquiry

データベースを削除して再作成する場合、db:dropまたはdb:resetが使えます。

rails db:reset

他のユーザが使用中です、とエラーが出てdropできない場合、PostgreSQLのデータベースを再起動するとうまくいくことがあります(もちろん開発環境での話です!)。

本書では説明していませんが、個人作業の段階からgitのリポジトリを作り、作業のまとまりごとにバージョンを管理するのが一番地道で確実な方法です。OSから入れ直しになるような致命的なケースでも、GitHubなどのクラウド側にソースが残っていれば復旧が可能です。

[課題]拡張機能を検討する

サンプルプロジェクト2 - work

ここでは、以下のようなプロジェクトを作ります。

普段はペーパレス化を推進する発信に余念のない筆者ですが…紙を出力するアプリを作ります。 一見して古い・無駄と思えるようなことでも、実はそれなりの意義がある場合があります。 ドラスティックな変化を求めるよりも、紙の世界とも繋がりを持ちつつ、システム化を進めていく上で実はPDFって大事な役割を果たす場面が多いように思います。

前章ではSQLiteを使いましたが、今度はデータベースエンジンにPostgreSQLを採用します。デザインはBootstrapでお手軽に済ませてしまうこととします。

共通の準備

rails new rails02-work --database=postgresql --minimal

必要に応じて config/database.ymlを編集し、default設定に接続情報を追記します(筆者の環境では不要でした)。

  username: postgres
  password: secret

前節のプロジェクト同様に--minimalオプションを付けてプロジェクトを生成していますので、config/application.rbを編集してこのあと必要になるモジュールを予め有効化しておきます。

require "action_mailer/railtie"

その後、空のデータベースを作成し、とローカル環境にRailsのwebサーバ(Puma)を立ち上げるところまで確認しましょう。

rails db:create
rails s

続けてこのアプリ上で管理したいテーブルの定義をします。

rails g scaffold work user_id:integer work_on:date start_at:datetime end_at:datetime location body:text approved:boolean
rails db:migrate

ホストのルート(URLのホスト名以下に何も書かない状態)からアクセスできるように、rootを定義しておきます(先程のRailsの画面が見えなくなる代わりに新しく作ったモデルの一覧ページが表示されるようになります)。

config/routes.rb

  root 'works#index'

このモデルを使って色んなgemの紹介をしながら、最終的に仕様を満たすアプリケーションを作ります。

各節の冒頭に書かれている、gem 'foobar'...という記述をGemfileの最後尾に追加してください。 Gemfileを変更する都度、bundle installが必要です。

認証機能の導入(authentication)

8.0からRails標準で提供されている認証機構を利用します(これまでは、主にdeviseというgemにお世話になっていました)。

rails g authentication

データベースのマイグレーションファイル(yymmdd_create_users.rb)を編集して、ユーザに持たせたい情報を追加します。 以下は名前を格納する列を定義する例です。

t.string :name, null: false, default: ''

編集が終わったらテーブルを作成します。

rails db:migrate

サンプルのユーザも作成します。これはrails cで起動したコンソール上で実行してください。

User.create(email_address: "test@lmlab.net", password: "secret")

db/seeds.rbに記載して開発時に利用できるようにしても便利です。

rails db:seed

PDFの生成(prawn)

gem 'prawn', '~> 2.4.0'

PDF生成のライブラリは幾つか存在しているようで、描画の方法にも様々なアプローチがあります。HTMLとCSSのページをそのまま変換してくれるものもありますが、prawnはそれとは異なり、2次元の座標を指定しながら一つ一つ図形や文字、画像などを配置していく形でPDFをレイアウトします。

つまり、後から要素を挟み込んだり、用紙のサイズを変更したりといった変更に(HTMLと比べて)大きな手間がかかります。一方で、用紙上の決まった位置に常に決まった要素を配置するという”かっちりした”レイアウトが得意です。今回は、「帳票」という常に同じ形式で出力されることが期待されるドキュメントを想定してこのgemを選んでいます。実装するドキュメントの種類に応じて柔軟に検討することをお勧めします。また、そもそも「紙が必要なの?」というところから(業務などの)流れを再考するのも(新しいシステムを作ろうとしているなら)やってみても良いかもしれません。

PDFを描画するクラスはPrawn::Documentを拡張して定義します。置き場所は(実際よく悩むのですが…)、controllers/concernsの下としています。

app/controllers/concerns/report_pdf.rb

class ReportPdf < Prawn::Document
  PAGE_WIDTH = 500
  PAGE_HEIGHT = 740

  def initialize
    super :page_size => 'A4',
      :page_layout => :portrait,
      :top_margin => 50,
      :bottom_margin => 50,
      :left_margin => 50,
      :right_margin => 50,
      :compress => true

    stroke_axis
    rectangle [2, PAGE_HEIGHT-2], PAGE_WIDTH-4, PAGE_HEIGHT-4
    stroke
    # draw_text "PDF出力のサンプルです。", at: [4, 700], size: 12
  end
end

works_controller.rbにアクションを定義して、上記クラスのrenderメソッドを呼び出すことでPDFを出力することができます。

def report
  filename = 'sample.pdf'
  pdf = ReportPdf.new.render
  send_data pdf, type: 'application/pdf', filename: filename
end

routes.rbにも忘れずに追記します。

get 'report' => 'works#report'

追加したパスにアクセスすると以下のようにガイド線だけが表示されたPDFがダウンロードされるはずです。

Exported PDF

ここに座標指定で文字や画像、図形などを描画していきます。ただし、このままでは日本語を扱うことができません。 Prawnのエラーメッセージでも指摘されるように、日本語に対応したフォントを明示する必要があります。

文字情報技術促進協議会で配布されているIPAフォントを利用する場合、 以下のような指定になります。他にもGoogle Fontsなどを調べると目的にあったフォントが見つかるかもしれません。

ダウンロードしたフォントを任意のフォルダ(ここではapp/assets/fontsというフォルダを作りました)に置いて、 initializerで以下のように読み込みます。

ipaexm_path = "#{Rails.root}/app/assets/fonts/ipaexm.ttf"
ipapm_spec  = {file: ipaexm_path, font: 'IPAPMincho'}
font_families.update("ipapm" => {normal: ipapm_spec,
                            bold: ipapm_spec,
                            italic: ipapm_spec,
                            bold_italic: ipapm_spec})
font "ipapm"

その後、上記でコメントアウトされていたdraw_textが利用できるようになります。

draw_text "PDF出力のサンプルです。", at: [4, 700], size: 12
Exported PDF

画像を配置したり、任意の図形を描画したりすることも可能です。

image "#{Rails.root}/app/assets/images/file_icon_text_pdf.png", at: [100,600], width: 200
stroke_color 'ff0000'
stroke do
  move_down 600
  horizontal_rule
  line [200,200], [300,150]
end

ActiveRecordで保存されている画像を利用する場合は、以下のように記述します(.keyがキーです)。

image ActiveStorage::Blob.service.path_for(@model.picture.key), at: at, width: width
Exported PDF

(画像は「いらすとや」より)

その他の詳しい使い方は、Prawnの公式マニュアルなどを参照してみてください。

表を書くことに特化した拡張機能のPrawn Tableというgemもあります。複雑な表組みが必要な場合、検討すると良いでしょう。

変更履歴の管理(paper_trail)

gem 'paper_trail', '~> 12.0'

paper_trailは、モデルの変更履歴(生成,更新,削除)を記録してくれます。papertrail(アンダースコアなし)という似たgemが存在するので注意してください。

バージョン履歴を保存するテーブルを作ったりなどするために、以下のコマンドを実行します。

rails generate paper_trail:install
rails db:migrate

バージョン管理を行いたいモデルにhas_paper_trailと記載します。

app/models/page.rb

class Page < ApplicationRecord
  has_paper_trail
end

これだけで、モデルの各操作に対する履歴が保存されるようになります。例えば、最初に「1」という内容で作成されたpageモデルが、続けて「2」「3」と2回更新された場合、以下のように3つのバージョン履歴が残っていて、それぞれの時点で保存されていた値を参照することが可能です。

irb> p = Page.last
irb> p.body
=> "3"
irb> p.versions[2].reify.body
=> "2"
irb> p.versions[1].reify.body
=> "1"
irb> p.versions[0].reify
=> nil

IDをベースに記録されているため、モデルが削除された場合でも、同一IDのモデルを新しく作ってから、versionsを参照すると過去の状態を参照することができます。以下の例では一度削除したモデルを削除直前の状態に戻しています。

irb> p = Page.new(id:3)
irb> p = p.versions.last.reify
irb> p.save
=> true

devise などのgemと組み合わせると「誰が操作したか」という記録も合わせて保管できます。

markdownの導入(redcarpet)

近年、多くのウェブサービスでテキストのマークアップ(修飾)に使われているMarkdown記法のテキストをhtmlにパースしてくれるのが redcarpet です。Gemfileに以下のバージョンを登録しました(Gemfileを更新したらbundleを実行してください、これ以降も同様です)。

Gemfile

gem 'redcarpet', '~> 3.5'

直接Markdownとは関係ないですが、複数行のテキスト入力をするために、bodyフィールドの編集エリアを text_field から text_area に変更します。

app/views/pages/_form.html.erb
<div class="field">
  <%= form.label :body %>
  <%= form.text_area :body %>
</div>

Redcarpetのパーザを使うためのヘルパメソッドを定義します。

app/helpers/pages_helper.rb

  def markdown(text)
    unless @renderer
      @renderer = Redcarpet::Markdown.new(Redcarpet::Render::HTML, autolink: true, tables: true)
    end
    @renderer.render(text).html_safe
  end

このメソッドでbodyの出力を囲うと、Markdownで保存されたテキストがHTMLとして整形されて出力されます。

app/views/pages/show.html.erb

<p>
  <strong>Body:</strong>
  <%= markdown(@page.body) %>
</p>
redcarpet

画像のリサイズ(image_processing)

ユーザからの画像アップロードを受け付けていると、例えばスマホで撮影したままの画像データそのままだと非常にサイズが大きくなってしまいページの表示が遅くなってしまうなどの弊害があります。また、個別のページでは大きな画像では構わないけれど、一覧表示をする場合に大きなままだとやはり表示速度に悪影響が出てしまう場合もあります。

このような時に、予めリサイズしておいた画像を返す機能がActiveStorageには備わっています。まず、必要なgemをGemfileに追加します。

gem 'image_processing', '~> 1.12'

その後、以下のようにvariantというメソッドを使ってリサイズされた画像を参照することができます。この指定の場合、100x100pxのサイズに収まる(縦横比は保持したまま)サイズに縮小されて表示されます。

<%= image_tag @page.image.variant(resize_to_limit: [100, 100]) %>

Rails5系では、上記のパラメタが使えない場合があるようです。その場合は、以下の記載で代替することができるようです。

<%= image_tag @page.image.variant(resize: "100x100") %>

アイコンを使ってみる(font-awesome-sass)

メニューの文字やボタンにアイコンがあると、視認性が良くなります。Font Awesomeというアイコンのセットが手軽に利用可能です。有料ですが、無料で利用可能なものも多くあります。

gem 'font-awesome-sass', '~> 5.12'

任意のscssファイルにimport文を追記します。

@import "font-awesome-sprockets";
@import "font-awesome";

iconというメソッドを使ってアイコンをリンクやボタンに配置することが出来るようになります。

<%= link_to icon("fas","calendar")+' Calendar', '#' %>

アイコンを使ってみる(bootstrap icons)

パッケージマネージャでインストールすることも可能ですが、装飾的に使う場合は、CDNで十分かもしれません。application.html.erbなどの<head>タグ内に以下の記述を足すことで利用出来ます。

<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css" rel="stylesheet" >

<i>タグに以下のようにクラスを指定して利用します。

<i class="bi bi-gem"></i>

利用可能なアイコンの一覧は公式サイト icons.getbootstrap.comで検索することが可能です。

サンプルプロジェクト3 - vote

ここでは、以下のようなプロジェクトを作ります。

共通の準備

まずは新規プロジェクトの作成から。

rails new --skip-rubocop rails03-vote

scaffoldを行う際に参照されるテンプレートをlibフォルダの下にコピーして置いておきます。これは必須の作業ではありませんが、沢山のプロジェクトを作る時に覚えておくと便利そうな機能です。本書で使っているテンプレートはGitHubに公開していますので、それを置いて頂いてもいいし、これは使わずに後で個別にカスタマイズしても構いません。

cp -r templates rails03-vote/lib/

プロジェクトの中に移動して、必要なモデルをscaffoldで生成します。

cd rails03-vote
rails g scaffold events title description schedules user_id:integer
rails g scaffold votes event_id:integer name answers comment

app/views/layouts/application.html.erb の yield は containerで囲んであげておいてください。

    <div class="container">
      <%= yield %>
    </div>

サイトのトップページにあたるアクションを events_controller.rb に定義します。

  def root
  end

config/routes.rb の定義も修正します。

  root "events#root"

イベントの作成と日程候補への回答は誰でも(ログインなしで)出来るようにしますが、一部の操作はやはり制限しておいた方が安全そうです(例えば、削除とか)。

認証機能の準備(authentication)

認証機能を導入するには以下のコマンドを実行します。

rails g authentication

connection.rbでコンフリクトを起こしますが、上書きして進みます。 データベースのマイグレーションファイル(yymmdd_create_users.rb)を編集して、ユーザに持たせたい情報を追加します。 以下は名前を格納する列を定義する例です。

t.string :name, null: false, default: ''

編集が終わったらテーブルを作成します。

rails db:migrate

サンプルのユーザも作成します。これはrails cで起動したコンソール上で実行してください。

User.create(email_address: "test@lmlab.net", password: "secret")

この状態だと、全てのページが認証済みユーザ専用になってしまいますので、一般のユーザでもアクセスできるアクションをそれぞれのコントローラに定義します。

events_controller.rb

allow_unauthenticated_access only: %i[ root new create show ]

votes_controller.rb

allow_unauthenticated_access only: %i[ new create show ]

認証済みであるかどうかは、authenticated?メソッドを使って確認できます。以下のコード内にあるように Current.session.user の値を見て…ということもビューの中でなら可能なのですが、コントローラ内で参照する場合は、認証用のフィルタの適用順に注意してください(nilが返ってくる場合があります)。

<% if authenticated? %>
  <%= link_to "<i class='bi bi-house'></i>".html_safe, events_path, class: "btn btn-sm btn-outline-secondary ms-2", title: Current.session.user.email_address %>
  <%= link_to "Sign out", session_path, data: {turbo_method: :delete}, class: "btn btn-sm btn-outline-secondary ms-2" %>
<% else %>
  <%= link_to "<i class='bi bi-lock'></i>".html_safe, new_session_path, class: "btn btn-sm btn-outline-secondary" %>
<% end %>

モデルとコントローラのカスタマイズ

以下、体裁を整えるための細かい修正も多数適用しているのですが、説明は省略して処理のコアとなる部分だけに絞って解説します。完全なソースコードはGitHubプロジェクトのrails03-voteフォルダを参照してください。

app/controllers/events_controller.rb

作成済みのイベントがそのまま見えてしまうと、IDを推測して別のイベントも簡単に探せてしまいますので、合言葉(トークン)を用意して、それが一致した場合だけ表示するようにしています。

 def show
    unless authenticated?
      if params[:token] != @event.token
        redirect_to root_path(@event), notice: "イベントにアクセスするにはログインするかトークンが必要です。"
        return
      end
    end
    @votes = @event.votes.order(:id)
    @selected_rows = @event.indexes_of_top_schedules
  end

app/controllers/votes_controller.rb

こちらは回答を受け付けるアクションです。update_answersというメソッドが別個に呼ばれています。

  def create
    @vote = Vote.new(vote_params)
    @vote.update_answers(params)
    respond_to do |format|
      if @vote.save
        format.html { redirect_to event_path(@vote.event, token: @vote.event.token), notice: "予定を登録しました!" }
        format.json { render :show, status: :created, location: @vote }
      else
        format.html { render :new, status: :unprocessable_entity }
      end
    end
  end

answersに関しては上記で更新するため、標準のパラメータから除外しておきます。

  def vote_params
      params.expect(vote: [ :event_id, :name, :comment ]) # answers will be set in Vote.update_answers
    end

app/models/events.rb

ここがこのシステムの核になる部分です。イベントの情報を保持するモデルですが、これに紐づいた回答(vote)を集計して、どの候補に一番票が集まっているのかを判定しています。

class Event < ApplicationRecord
  has_many :votes, dependent: :destroy
  belongs_to :user, optional: true

  validates :title, presence: true
  validates :schedules, presence: true

  def schedules_array
    schedules.split(',').map(&:strip)
  end

  def count_votes
    l = schedules_array.length
    o = [0] * l
    x = [0] * l
    votes.each do |vote|
      vote.answers_array.each_with_index do |answer, index|
        if answer == 'o'
          o[index] += 1
        elsif answer == 'x'
          x[index] += 1
        end
      end
    end
    {o: o, x: x}
  end

  def indexes_of_top_schedules
    counts = count_votes
    o = counts[:o]
    max_count = o.max
    indexes = []
    return indexes if max_count == 0
    o.each_with_index do |count, index|
      indexes << index if count == max_count
    end
    if indexes.length > 1
      x = counts[:x]
      min_count = x.each_with_index.select { |v, i| indexes.include?(i) }.map { |count, index| count }.min
      indexes = []
      x.each_with_index do |count, index|
        indexes << index if count == min_count
      end
    end
    indexes
  end

  def token
    Digest::SHA1.hexdigest("#{id}-#{created_at}").slice(0, 8)
  end
end

app/models/vote.rb

こちらはイベントの候補日時に対する回答(o:参加可能、x:参加不可、?:不明)をフォームから受け取って保存するためのメソッドupdate_answersが定義されています。

class Vote < ApplicationRecord
  belongs_to :event

  validates :name, presence: true
  validates :answers, presence: true

  def answers_array
    answers.split(",").map(&:strip)
  end

  def update_answers(params)
    raise "params can not be nil" unless params
    raise "can not find event for updating answers" unless event
    answers_array = [nil] * event.schedules_array.count
    answers_array.count.times do |i|
      answers_array[i] = params["answers_#{i}"]
    end
    self.answers = answers_array.join(",")
  end
end

app/views/events/show.html.erb

コントローラとモデルから得られた情報を描画するビューは以下のようになりました。参加可能な回答が一番多い行にはtable-successというクラスが追加されて背景が緑色にハイライトされて表示されます。

<% content_for :title, @event.title+"の日程調整" %>

<%= render @event %>

<table class="table">
  <tr>
    <th>予定</th>
    <% @votes.each do |vote| %>
      <th><%= vote.name %></th>
    <% end %>
  </tr>
  <% @event.schedules_array.each_with_index do |schedule,i| %>
    <tr class="<%= 'table-success' if @selected_rows.include? i %>">
      <th><%= schedule %></th>
      <% @votes.each do |vote| %>
        <td><%= vote.answers_array[i] %></td>
      <% end %>  
    </tr>
  <% end %>
  <tr>
    <th></th>
    <% @votes.each do |vote| %>
      <td class="text-muted small"><%= vote.comment %></td>
    <% end %>  
  </tr>
</table>

<div class="row">
  <div class="col-md-6">    
    <p>予定を教えてください!</p>
    <% v = Vote.new; v.event = @event %>
    <%= render 'votes/form', vote: v, show_event_title: false %>
  </div>
  <div class="col-md-6">
    <p>他の人にこのイベントのURLを共有して回答を追加してもらうことができます。<br>※不特定の人が見える場所(SNSのポストなど)に貼り付けないように注意してください!</p>
    <%= link_to event_url(@event, token: @event.token), event_url(@event, token: @event.token), class: "form-control" %>
  </div>
</div>

<% if authenticated? %>
<div class="mt-5 for-admin">
  <%= link_to "編集", edit_event_path(@event), class: "btn btn-light" %>
  <%= link_to "一覧", events_path, class: "btn btn-light" %>
  <%= link_to "回答一覧", votes_path(event_id: @event.id), class: "btn btn-light" %>

  <%= button_to "削除する", @event, method: :delete, class: "btn btn-danger mt-2" %>
</div>
<% end %>

リッチエディタの導入(action_text)

イベントの説明部分にリッチテキスト(装飾や箇条書きなどのスタイル)を入れられるように改造してみます。編集エリアは以下のようになります。

action_text

action_textというフレームワークを使います。まずはインストールと、DBのマイグレーションから。

rails action_text:install
rails db:migrate

インストールの中で勝手に有効化してくれるようですが image_processing というgemが必須になるようですので、場合によっては bundle install を実行する必要もあるかもしれません。

モデルapp/model/event.rbには以下の行を追加します。

  has_rich_text :description

ビューapp/views/events/_form.html.erbの入力エリアも変更します。

<%= form.rich_text_area :description, class: "form-control" %>

これで、一通り準備完了です。

rich_textに指定したフィールドは専用のクラスが返ってくるため、truncateなどで中身を加工したい場合は、以下のような工夫が必要になる点に注意してください。

<%= strip_tags(event.description.to_s).truncate(80) %>

もう一つの注意点として、has_rich_textに指定したフィールド(この場合 description)がDB上のテーブル(この場合 events)に既に存在していても、そこに値が保存される訳ではなく、上記のマイグレーションで作成された専用のテーブルに保存されることが挙げられそうです。

動作イメージ

サンプルのコードを動作させると以下のようなアプリが立ち上がります。

トップページ
候補日時の集計結果と入力フォーム

実際に動作させているURLは以下になります。配備方法については、次章を参照してみてください。試験的に立ち上げたものですので、将来、予告なく終了する可能性もあります。ご了承ください。

https://vote.lmlab.net

配備・運用編

ここまでに作成したRailsのプロジェクトをインターネットに公開するための手順を確認します。プロジェクトの規模や目的によって無数の手段があり、どれが正解と言い切るのがとても難しい部分です。筆者自身は、VPSサーバ(仮想のLinuxが動くマシン)の中に、Apacheとpassengerを組み込んで使う方法をよく使っていましたが、ActionCable(WebSocket)に対応していないなど制約を受ける場面も増えてきたため、ここではApache(Proxyサーバとして)とPumaを組み合わせる方法を紹介します。サーバOSはUbuntuを使っています。

アプリケーションをサーバに転送する

以下はmyhostというUbuntuのホストの/var/www/myapp以下にアプリケーションの本体を転送する手順の例です。rsyncなどのコマンドを使って手元の環境をそのまま同期すると、tmp以下のキャッシュやログ、作業中のファイルなども一緒に運ばれてしまい、思わぬトラブルの原因になることがありますので、以下のようにgitリポジトリに登録されたものだけを転送&展開する方法を推奨しています。master.keyは通常、リポジトリに含まれていないはずなので、別送する必要があります。

git archive main > tmp/archive.tar
scp tmp/archive.tar myhost:/var/www/myapp/
scp config/master.key myhost:/var/www/myapp/
ssh myhost "cd /var/www/myapp && tar xf archive.tar -C current"

myhostにsshでログインした状態で、以下の準備が必要です。Ruby(rbenv経由)が既にインストール済みの想定です。

cd /var/www/myapp/current
bundle install
rails assets:precompile
rails db:prepare

Pumaをデーモンとして起動する

etc/systemd/system/puma-myapp.service を作成します。root権限が必要です。ディレクトリやポート番号は環境に合わせて適宜書き換えてください。Ruby(puma)の実行環境は.rbenvを利用してインストールしている前提になっていますので、ここも環境によっては書き換えが必要です。

[Unit]
Description=Puma HTTP Server
After=network.target

[Service]
Type=notify
WatchdogSec=10

User=ubuntu
WorkingDirectory=/var/www/myapp/current
Environment="RAILS_ENV=staging"
Environment="PORT=3002"

ExecStart=/home/ubuntu/.rbenv/shims/puma -C /var/www/myapp/current/config/puma.rb

Restart=always

[Install]
WantedBy=multi-user.target

以下のコマンドで登録、有効化を行います。

sudo systemctl daemon-reload
sudo systemctl enable puma
sudo systemctl start puma

ログファイルは(developmentとは異なり)logディレクトリには出力されなくなりますので、journalctlを利用して確認することになります。

journalctl -u puma-myapp

ApacheをProxyとしてPumaにアクセス

続けて、Apache(Proxy)の設定です。 /etc/apache2/sites-available/002-myapp.conf を作成します。ファイル名の番号は起動順を表していますので、他にApacheが提供しているコンテンツがある場合は、適宜調整してください。

<IfModule mod_ssl.c>
<VirtualHost *:443>
  ServerName myapp.lmlab.net
  ProxyPass /assets !
  ProxyPass / http://localhost:3002/
  ProxyPassReverse / http://localhost:3002/
  Alias /assets /var/www/myapp/current/public/assets

  <Directory /var/www/myapp>
    AllowOverride All
  </Directory>

  SSLCertificateFile /etc/letsencrypt/live/myapp.lmlab.net/fullchain.pem
  SSLCertificateKeyFile /etc/letsencrypt/live/myapp.lmlab.net/privkey.pem
  Include /etc/letsencrypt/options-ssl-apache.conf
</VirtualHost>
</IfModule>

<VirtualHost *:80>
  ServerName myapp.lmlab.net
  Redirect / https://myapp.lmlab.net/
</VirtualHost>

上記で使用しているSSL証明書はLet’s encryptで取得したものです。以下のようなコマンドで取得可能です(DNSの設定に関する説明は省略しています、このFQDNの宛先が当該ホストのアドレスになっていることが前提です)。

sudo certbot -d myapp.lmlab.net
sudo systemctl reload apache2

staging(検証)環境の作成

運用環境(production)とは別に、検証用のサーバ(staging)が必要になることがあります。productionで動作するサーバを2台用意してしまうのも手なのですが、検証が必要になる規模のアプリの場合、そのまま動いては困る機能が含まれていることが良くあります(外部サービスと繋いでデータ連携をするなど)。その場合、なるべくそっくりだけど、一部の機能だけ制限された別の環境(staging)を用意するとスムーズです。

以下の要領で設定を追加します。

config/environments/staging.rb

load(Rails.root.join('config','environments',"production.rb"))

config/database.yml

staging:
  <<: *default
  database: /var/www/myapp/shared/storage/staging.sqlite3
  cache:
    <<: *default
    database: storage/staging_cache.sqlite3
    migrations_paths: db/cache_migrate
  queue:
    <<: *default
    database: storage/staging_queue.sqlite3
    migrations_paths: db/queue_migrate
  cable:
    <<: *default
    database: storage/staging_cable.sqlite3
    migrations_paths: db/cable_migrate

config/cable.yml

staging:
  adapter: solid_cable
  connects_to:
    database:
      writing: cable
  polling_interval: 0.1.seconds
  message_retention: 1.day

config/cache.yml

staging:
  database: cache
  <<: *default

[付録]Herokuを利用する

(見出しは駄洒落…ではありません)

23年版まではHerokuをデプロイ環境として紹介していましたが、無料版もなくなったり、ちょっとでも拡張をするとなると、やっぱりそれなりのサーバやネットワークの知識(linuxとか)を求められるようになったりとすることがあったりして少し消極的になっていました。加えて、Rails8の強みであるProduction readyなSQLiteがreadyになってない!(多分やればできるけど、永続化の設定が難しそう…)ということで今回、Herokuに関連する情報をごそっと落として通常のVPS(Ubuntuサーバ)に配備する手順に書き換えています。

アップデートは入って完全に同じではありませんが、それでもHeroku!という場合は、23年版を参照してください。

active_storageの設定(AWS S3)

active_storageはローカルにファイルを保存する以外に、Amazon Web Service(AWS)などの提供するオブジェクトストレージサービスを利用することも可能です。AWSのアカウントが必要です。

ログイン -> サービス一覧からS3を選択 -> Create Bucket 名前とリージョンと決定し、その他は全てそのままにします。

サービス一覧からIAM(Identity and Access Management)を選択 -> Create User

Access TypeをProgrammatic access AmazonS3FullAccessを持ったグループを作成し、ロールを割り当てます。

作成ウィザードの最後またはSecurity credentialsページから、access_keyとsecret_access_keyを取得してRailsのcredentialsに保管します。

rails credentials:edit
aws:
   access_key_id: アクセスキー
   secret_access_key: 秘密キー

config/storage.ymlに設定を追加します。regionとbucketは適宜、上記で設定したものに書き換えてください。

amazon:
  service: S3
  access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
  secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
  region: ap-northeast-1
  bucket: rails-practice1

config/environments/production.rb を編集して、production環境ではawsを利用する設定にします。

config.active_storage.service = :amazon

s3を操作するためのgemも追加が必要です。

gem "aws-sdk-s3", require: false

mailerの設定(SendGrid)

action_mailerが有効化されていない場合は、config/application.rbを編集して有効化してください(本書のサンプルでは有効化済みのはずです)。

require "action_mailer/railtie"

SendGridのアカウントが必要です。Dashboard -> Settings -> API Keyと進みAPIキーを取得(新規作成)しておいてください。

AWSのキーを追加した際と同じ要領で、credentialsに保存します。

rails credentials:edit
sendgrid:
  apikey: APIキー

config/environments/production.rbに以下のように設定を追加します。

config.action_mailer.default_url_options = { host: 'rails-practice1.herokuapp.com', protocol: 'https' }
ActionMailer::Base.smtp_settings = {
  user_name: 'apikey',
  password: Rails.application.credentials.sendgrid[:apikey],
  address: 'smtp.sendgrid.net',
  port: 2525,  # 587?
  authentication: :plain,
  enable_starttls_auto: true,
  domain: 'myapp.lmlab.net'  # optional
}

mailerの設定(AWS SES)

config/environments/production.rbに以下のように設定を追加します。SendGrid(前節)の場合と同様に必要な、認証情報をcredentialsに登録してください。

ActionMailer::Base.smtp_settings = {
  user_name: Rails.application.credentials.aws_ses[:access_key_id],
  password: Rails.application.credentials.aws_ses[:password],
  address: 'email-smtp.ap-northeast-1.amazonaws.com',
  port: 587,
  authentication: :login,
  domain: 'myapp.lmlab.net'
}

トラブルシュート

起動しない

deviseを入れている時に起こります。

rails zeitwerk:check:
expected file /usr/local/bundle/gems/devise-4.7.1/app/mailers/devise/mailer.rb to define constant Devise::Mailer,

deviseのissueによるとconfig/application.rbでActionMailerを有効化するのが簡単な回避方法のようです。

require "action_mailer/railtie"

プラットフォームが対応していない

RaspberryPiなど、Intel以外のCPUで開発をしていると、IntelのCPUで動作しているHerokuにデプロイができません。エラーメッセージの指示に従ってGemfile.lockにプラットフォームを追記することで回避できます。

bundle lock --add-platform x86_64-linux

SSL(https)経由のアクセスのみ受け付けたい

最近のブラウザは(sのつかない)http経由のアクセスに際して常に警告マークを表示するようになっています。普通のウェブページで特に通信を秘匿する必要がなければ、問題のないことですが、警告が出ているのは見た目が良くありませんし、SSL通信に特別なコストがかかるわけでもないので、常にhttpsでアクセスするように設定してしまうのも一つの方法です。config/environments/production.rb でその指定をすることが出来ます。

config.force_ssl = true

補遺

参考資料

更新履歴

本書について

本書は、宮崎県ソフトウェアセンター様主催の集合研修向けリソースとして、2015年頃に書いた「Ruby on Railsで作る『イベント告知サイト』」を再構成する形で編成したものを、最新の開発事情に合わせて書き換えたものになります。

「CC BY 4.0」に則った再利用・再頒布が可能です。

著者紹介:伊藤陽生 株式会社ランバーミルの代表取締役。中小規模のシステム開発を20年近く続けています。 Cから始まりJava、JavaScript、Objective-C、PHP、 Python、SQLなどを主に業務で扱っています。Linuxサーバの構築だったり、ウェブサイトのデザインをしたり、これらの分野の研修講師として働いていたこともあります。技術の移り変わりの 速いこの領域においては、それぞれの経験値そのものよりも、いかに新しい技術にうまくキャッチアップし、チームで共有し、身近な問題の解決に結びつけられるか、という、もう一段抽象的な技能こそが重要なのではないかと考えています。

本書に関するお問い合わせ、誤字・脱字等のご指摘は、下記URLにお願い致します(SNSのメッセージでも大歓迎です)。

https://lumber-mill.co.jp/contact.php

謝辞とご協力のお願い

ここまで目を通してくださりありがとうございます。10年近くに渡りお仕事の機会をくださっているクライアント企業の方々のおかげで私自身が学びを深める機会を持つことができ、このような形でこれまでの経験とノウハウを蓄積することができました。

とはいえ執筆には少なくない時間が必要です。当初はこのテキストをepubやPDFに変換し、オンライン書店(AmazonやApple Books)にて販売していましたが、たびたび大幅な書き換えが必要になりながらも、そのメンテナンスが追いついていない現状があります。今後、こうした不整合を避けるためウェブに一本化していく予定です(コピーガードなしのepubも配布しています)。もしこの本の情報が何らかの役に立った、という部分があれば、以下のリンクから応援購入を頂ければ幸いです。今後も継続してアップデートを続ける上で大変励みになります。

ご要望や誤字・誤りの指摘などは(こちらではなく)上記のお問い合わせフォームやSNS等から直接筆者にフィードバックを頂ければ幸いです。

https://buy.stripe.com/bIYbLF7jIdqzaNa146

payment link by Stripe