Edited at

Rails5.2 Trailblazer 導入 7アクションコードサンプル(備忘録)

More than 1 year has passed since last update.


概要

 Ruby on Rails における gem Trailblazer についての記事になります。

 Trailblazer とは、 MVC モデルにおける M(Model) or C(Controller) の肥大化を防ぐための gem ですね!Model でValidationをはると、どうしても様々な問題が起きてきます。2つ目の問題点を解決するために Form ごと の Validation をはるようにしています。


  • コード量の増加

  • Model に無いパラメータをどこで対応するか

  • (etc …)

 https://github.com/trailblazer/trailblazer

 Trailblazer を用いたサンプルアプリ


目次


  • 開発環境

  • サンプルアプリの作成

  • new・create の実装

  • show・index の実装

  • edit・update の実装

  • destroy の実装

  • 関連が1対多 の場合


開発環境

OS: MaxOS High Sierra Version 10.13.6

Ruby: 2.5.0

Ruby on Rails: 5.2.0


サンプルアプリの作成


rails new


 まずはじめに以下のコマンドで、サンプルアプリを作成しましょう。

$ rails new trailblazer_app -d mysql


Gemfile の編集


 次に gem ファイルを編集します。今回は、デフォルトで生成されるものに、以下のものを追加します。


# 環境変数設定用 gem
gem 'dotenv-rails'

# trailblazer 用 gem
gem 'trailblazer-cells'
gem 'trailblazer-loader'
gem 'trailblazer-rails'
gem 'reform-rails'

# DB スキーマ管理用 gem
gem 'ridgepole'

 いつのものやつ実行します。

$ ./bin/bundle install --path vendor/bundle


.gitignore の編集


 次に .gitignore ファイルを編集します。こちらは適宜でお願いします。

# Ignore bundler config.

/.bundle
/vendor/bundle

# Ignore all logfiles and tempfiles.
/log/*
/tmp/*
!/log/.keep
!/tmp/.keep

# Ignore uploaded files in development.
/storage/*

/node_modules
/yarn-error.log

/public/assets
.byebug_history

# Ignore master key for decrypting credentials and more.
/config/master.key

# Ignore Rubymine config.
/.idea

# Ignore env file.
.env


DBの設定


 Rails5 + MySQL + Ridgepoleによる DB設計・開発(備忘録)を参照してください。今回の .env ファイル、 database.yml、 ER図 は以下のようにしておきます。

 以降、DBの作成・ユーザーの作成(・スキーマファイルの作成)までできているということで話を進めていきます。


(.env)

DATABASE_USERNAME="trailblazer_app"
DATABASE_PASSWORD="trailblazer_app"
DATABASE_HOSTNAME="localhost"

(database.yml)

default: &default
adapter: mysql2
encoding: utf8
pool: 5
username: <%= ENV['DATABASE_USERNAME'] %>
password: <%= ENV['DATABASE_PASSWORD'] %>
host: <%= ENV['DATABASE_HOSTNAME'] %>
database: trailblazer_app

production:
<<: *default

development:
<<: *default

test:
<<: *default
database: trailblazer_app_test

Screen Shot 2018-08-08 at 18.35.51.png


Model の作成


 以下のようなモデルになりますね。それでは、次から Teacher に対する操作を(最後に User に対する操作を)行なっていきたいと思います。

class User < ApplicationRecord

belongs_to :teacher
self.table_name = 'users'
end

class Teacher < ApplicationRecord

has_many :users
self.table_name = 'teachers'
end


new・create の実装


routes.rb の編集


 あらかじめ、すべてのアクションにおけるルーティングを作成しておきましょう。

Rails.application.routes.draw do

resources :users
resources :teachers
end


Trailblazer 用ファイルの作成


 Trailblazer 用ファイルは、主に2つあります。それらは、 concepts/.*/contractconcepts/.*/operation です。

 concepts/.*/contract/ では、 Form ごとのパラメータと Validation を記述します。

 concepts/.*/operation/ では、 ビジネスロジックを記述した各ファイルを格納します。


application_contract.rb の作成

 このファイルを全ての contract ファイルが継承するようにします。

class ApplicationContract < Reform::Form

include Reform::Form::ActiveRecord
require 'reform/form/validation/unique_validator'
end


application_operation.rb の作成

 このファイルを全ての operation ファイルが継承するようにします。

class ApplicationOperation < Trailblazer::Operation

protected

def handle_validation_error(options, **)
options['validation_errors'] = options['contract'].errors.to_hash
end

def handle_internal_error!(options, **)
options['operation_class'] = self.class
raise TrailblazerApp::Error::OperationError.new(options)
end
end


concepts/teachers/contract/create.rb の作成

 ここには、入力フォームの各パラメータを記述します。ただ、主にフォームの各パラメータは、 Model に準ずることが多いので、ある Model オブジェクトを使用できるようにしておきます。

class Teachers::Contract::Create < ApplicationContract

NAME_MAX_LENGTH = 64

model Teacher

property :name
# property :password_confirmation, virtual: true

validates(
:name,
presence: true,
length: { maximum: NAME_MAX_LENGTH }
# unique: { scope: %i[name class], case_sensitive: false },
)
end


concepts/teachers/operation/create.rb の作成

 ここには、入力フォームの各パラメータの Validation Check。DB との操作を行います。

class Teachers::Operation::Create < ApplicationOperation

step :validate
failure :handle_validation_error, fail_fast: true
step :persist!
failure :handle_internal_error!

private

def validate(options, params)
contract = Teachers::Contract::Create.new(Teacher.new)

contract.name = params[:name]
# contract.~~~ = params[:~~~]

options['contract'] = contract

contract.valid?
end

def persist!(options, **)
contract = options['contract']
model = Teacher.new(
name: contract.name
)

options['model'] = model

model.save!
end
end


TeachersController の作成


 今回、 Controller には、以下を記述しておきます。 index アクションは詳細に実装しませんが、画面遷移のため、記述だけしておきます。一応、エラーメッセージその他もろもろ表示できるように ApplicationControllerApplicationHelper も編集しておきましょう。

class TeachersController < ApplicationController

def index

end

def new
@form = Teachers::Contract::Create.new(Teacher.new)
end

def create
# teacher_params = params.require(:teacher).permit(:name. ~~~, ~~~)
teacher_params = params.require(:teacher).permit(:name)
result = Teachers::Operation::Create.call(teacher_params)

if result['validation_errors'].present?
@form = result['contract']
flash[:error] = translate_validation_errors(result)
render 'new'
else
redirect_to teachers_path, flash: { notice: 'Teacher を作成しました。' }
end
end
end


ApplicationController の編集

class ApplicationController < ActionController::Base

# error 文 を ja.yml を用いて翻訳する。
def translate_validation_errors(result)
validation_errors = result['validation_errors']
class_name = result['contract'].instance_variable_get(:@model).class.name
error_messages = []
validation_errors.each do |key, values|

values.each do |value|
error_messages << I18n.t('activerecord.attributes.' << class_name.downcase << '.' << key.to_s) + value.to_s
end
end
error_messages
end
end


ApplicationHelper の編集

module ApplicationHelper

# ページごとの完全なタイトルを返す。
def full_title(page_title = '')
base_title = 'Trailblazer App'
if page_title.empty?
base_title
else
"#{page_title} | #{base_title}"
end
end
end


config/initializers/locale.rb の作成

I18n.config.available_locales = :ja

I18n.default_locale = :ja


ja.yml の作成

ja:

# --- Default ja.yml (https://github.com/svenfuchs/rails-i18n/blob/master/rails/locale/ja.yml) ---
activerecord:
errors:
messages:
record_invalid: "バリデーションに失敗しました: %{errors}"
restrict_dependent_destroy:
has_one: "%{record}が存在しているので削除できません"
has_many: "%{record}が存在しているので削除できません"
date:
abbr_day_names:
-
-
-
-
-
-
-
abbr_month_names:
-
- 1月
- 2月
- 3月
- 4月
- 5月
- 6月
- 7月
- 8月
- 9月
- 10月
- 11月
- 12月
day_names:
- 日曜日
- 月曜日
- 火曜日
- 水曜日
- 木曜日
- 金曜日
- 土曜日
formats:
default: "%Y/%m/%d"
long: "%Y年%m月%d日(%a)"
short: "%m/%d"
month_names:
-
- 1月
- 2月
- 3月
- 4月
- 5月
- 6月
- 7月
- 8月
- 9月
- 10月
- 11月
- 12月
order:
- :year
- :month
- :day
datetime:
distance_in_words:
about_x_hours:
one: 約1時間
other: 約%{count}時間
about_x_months:
one: 約1ヶ月
other: 約%{count}ヶ月
about_x_years:
one: 約1年
other: 約%{count}年
almost_x_years:
one: 1年弱
other: "%{count}年弱"
half_a_minute: 30秒前後
less_than_x_minutes:
one: 1分以内
other: "%{count}分未満"
less_than_x_seconds:
one: 1秒以内
other: "%{count}秒未満"
over_x_years:
one: 1年以上
other: "%{count}年以上"
x_days:
one: 1日
other: "%{count}日"
x_minutes:
one: 1分
other: "%{count}分"
x_months:
one: 1ヶ月
other: "%{count}ヶ月"
x_years:
one: 1年
other: "%{count}年"
x_seconds:
one: 1秒
other: "%{count}秒"
prompts:
day:
hour:
minute:
month:
second:
year:
errors:
format: "%{attribute}%{message}"
messages:
accepted: を受諾してください
blank: を入力してください
present: は入力しないでください
confirmation: と%{attribute}の入力が一致しません
empty: を入力してください
equal_to: は%{count}にしてください
even: は偶数にしてください
exclusion: は予約されています
greater_than: は%{count}より大きい値にしてください
greater_than_or_equal_to: は%{count}以上の値にしてください
inclusion: は一覧にありません
invalid: は不正な値です
less_than: は%{count}より小さい値にしてください
less_than_or_equal_to: は%{count}以下の値にしてください
model_invalid: "バリデーションに失敗しました: %{errors}"
not_a_number: は数値で入力してください
not_an_integer: は整数で入力してください
odd: は奇数にしてください
required: を入力してください
taken: はすでに存在します
too_long: は%{count}文字以内で入力してください
too_short: は%{count}文字以上で入力してください
wrong_length: は%{count}文字で入力してください
other_than: は%{count}以外の値にしてください
template:
body: 次の項目を確認してください
header:
one: "%{model}にエラーが発生しました"
other: "%{model}に%{count}個のエラーが発生しました"
helpers:
select:
prompt: 選択してください
submit:
create: 登録する
submit: 保存する
update: 更新する
number:
currency:
format:
delimiter: ","
format: "%n%u"
precision: 0
separator: "."
significant: false
strip_insignificant_zeros: false
unit:
format:
delimiter: ","
precision: 3
separator: "."
significant: false
strip_insignificant_zeros: false
human:
decimal_units:
format: "%n %u"
units:
billion: 十億
million: 百万
quadrillion: 千兆
thousand:
trillion:
unit: ''
format:
delimiter: ''
precision: 3
significant: true
strip_insignificant_zeros: true
storage_units:
format: "%n%u"
units:
byte: バイト
gb: GB
kb: KB
mb: MB
tb: TB
pb: PB
eb: EB
percentage:
format:
delimiter: ''
format: "%n%"
precision:
format:
delimiter: ''
support:
array:
last_word_connector:
two_words_connector:
words_connector:
time:
am: 午前
formats:
default: "%Y年%m月%d日(%a) %H時%M分%S秒 %z"
long: "%Y/%m/%d %H:%M"
short: "%m/%d %H:%M"
pm: 午後
# --- Default ja.yml (https://github.com/svenfuchs/rails-i18n/blob/master/rails/locale/ja.yml) ---
# --- 各 Model のパラメータ ---
activerecord:
attributes:
teacher:
id: "ID"
name: "名前"


html.erb の作成


 今回は、次の4つのファイルを作成します。


  • views/teachers/index.html.erb(まだ特に記述はしません。)

  • views/teachers/new.html.erb

  • views/shared/_error_messages.html.erb

  • views/layouts/application.html.erb

<!-- index.html.erb -->

(まだ記述無し)

<!-- new.html.erb -->

<%= form_with model: @form, url: teachers_path, method: :post do |f|%>
<%= render 'shared/error_messages' %>

<label>名前</label>
<input type="text" name="teacher[name]", value="<%= @form.name %>", placeholder="名前" />
<button type="submit">登録する</button>
<%- end -%>

<!-- _error_messages.html.erb -->

<% if flash[:error].present? %>
<div>
<div class="error_text">
<h2><%= "#{flash[:error].count}個のエラーがあります。" %></h2>
<ul>
<% flash[:error].each do |e| %>
<li><%= e %></li>
<% end %>
</ul>
</div>
</div>
<% end%>

<!-- application.html.erb -->

<!DOCTYPE html>
<html>
<head>
<title><%= full_title(yield(:title)) %></title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>

<%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
<%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %>
</head>

<body>
<% flash.each do |message_type, messages| %>
<% if message_type == 'alert' || message_type == 'notice' || message_type == 'info' || message_type == 'warning' %>
<div class="flash_explanation_<%= message_type %>">
<% messages.each do |message| %>
<p><%= message %></p>
<% end %>
</div>
<% end %>
<% end %>
<%= yield %>
</body>
</html>


show・index の実装

 この部分に限っては、Trailblazer ファイルは作成されないので、簡単に流していきます。


TeachersController の編集

class TeachersController < ApplicationController

# --- 追加部分 ---
def index
if params[:name].nil?
@teachers = Teacher.all
else
@teachers = Teacher.where(name: params[:name])
end
end

def show
@teacher = Teacher.find(params[:id])
end
# --- 追加部分 ---

def new
@form = Teachers::Contract::Create.new(Teacher.new)
end

def create
# teacher_params = params.require(:teacher).permit(:name. ~~~, ~~~)
teacher_params = params.require(:teacher).permit(:name)

result = Teachers::Operation::Create.call(teacher_params)

if result['validation_errors'].present?
@form = result['contract']
flash[:error] = translate_validation_errors(result)
render 'new'
else
redirect_to teachers_path, flash: { notice: 'Teacher を作成しました。' }
end
end
end


ApplicationHelper

module ApplicationHelper

# ページごとの完全なタイトルを返す。
def full_title(page_title = '')
base_title = 'Trailblazer App'
if page_title.empty?
base_title
else
"#{page_title} | #{base_title}"
end
end

# ---追加部分---
# 日付フォーマット
def date_format(date, format)
# date: 日付 ex) created_at
# format: フォーマット
# %A -> 曜日の名称(Sunday, Monday …)
# %a -> 曜日の省略(Sun, Mon …)
# %B -> 月の名称(January, February …)
# %b -> 月の省略(Jan, Feb …)
# %d -> 日付(0-31)
# %H -> 時間(00-23)
# %I -> 時間(01-12)
# %M -> 分(00-59)
# %m -> 月の数字(01-12)
# %S -> 秒(00-60)
# %Y -> 西暦
# %y -> 西暦の下2桁
# %Z -> タイムゾーン
date.strftime(format)
end
# ---追加部分---
end


teachers/index.html.erb

<%= form_with url: teachers_path, method: :get, local: true do %>

<label>名前</label>
<input type="text" name="name" placeholder="名前"/>
<button type="submit">検索する</button>
<% end %>

<% @teachers.each do |teacher| %>
<p>ID: <%= link_to teacher.id, teacher_path(teacher.id) %></p>
<p>名前: <%= teacher.name %></p>
<p>作成日: <%= date_format(teacher.created_at, '%Y年 %m月 %d日') %></p>
<p>更新日: <%= date_format(teacher.updated_at, '%Y年 %m月 %d日') %></p>
<hr>
<% end %>


teachers/show.html.erb

<!-- show.html.erb -->

<p>ID: <%= @teacher.id %></p>
<p>名前: <%= @teacher.name %></p>
<p>作成日: <%= date_format(@teacher.created_at, '%Y年 %m月 %d日') %></p>
<p>更新日: <%= date_format(@teacher.updated_at, '%Y年 %m月 %d日') %></p>


edit・update の実装


concepts/teachers/contract/update.rb


 独自の validation メソッドを作成したい場合は、以下のようにします。今回は、 id が本当に存在するかどうかの validation をはることとします。

class Teachers::Contract::Update < ApplicationContract

NAME_MAX_LENGTH = 64

model Teacher

property :id
property :name

validates(
:name,
presence: true,
length: { maximum: NAME_MAX_LENGTH },
)

validate(
:existence_of_teacher_id
)

def existence_of_teacher_id
errors.add(:id, '存在しないTeacherです。') if Teacher.pluck(:id).exclude?(id.to_i)
end
end


concepts/teachers/operation/update.rb


class Teachers::Operation::Update < ApplicationOperation

step :validate
failure :handle_validation_error, fail_fast: true
step :persist!
failure :handle_internal_error!

private

def validate(options, params)
contract = Teachers::Contract::Update.new(Teacher.new)

contract.id = params[:id]
contract.name = params[:name]

options['contract'] = contract

contract.valid?
end

def persist!(options, **)
contract = options['contract']
model = Teacher.find(contract.id)

model.name = contract.name

options['model'] = model

model.save!
end
end


TeachersController


class TeachersController < ApplicationController

def index
if params[:name].nil?
@teachers = Teacher.all
else
@teachers = Teacher.where(name: params[:name])
end
end

def show
@teacher = Teacher.find(params[:id])
end

def new
@form = Teachers::Contract::Create.new(Teacher.new)
end

def create
# teacher_params = params.require(:teacher).permit(:name. ~~~, ~~~)
teacher_params = params.require(:teacher).permit(:name)
result = Teachers::Operation::Create.call(teacher_params)

if result['validation_errors'].present?
@form = result['contract']
flash[:error] = translate_validation_errors(result)
render 'new'
else
redirect_to teachers_path, flash: { notice: 'Teacher を作成しました。' }
end
end

# --- 追加部分 ---
def edit
@form = Teachers::Contract::Update.new(Teacher.find(params[:id]))
end

def update
teacher_params = params.require(:teacher).permit(:name, :id)
teacher_params[:id] = params[:id]

result = Teachers::Operation::Update.call(teacher_params)

if result['validation_errors'].present?
@form = result['contract']
flash[:error] = translate_validation_errors(result)
render 'edit'
else
redirect_to teachers_path, flash: { notice: 'Teacher を更新しました。' }
end
end
# --- 追加部分 ---
end


teachers/edit.html.erb


<%= form_with model: @form, url: teacher_path, method: :patch, local: true do %>

<%= render 'shared/error_messages' %>

<label>名前</label>
<input type="text" name="teacher[name]" value="<%= @form.name %>" placeholder="名前" />
<button type="submit">登録する</button>
<% end %>


destroy の実装


concepts/teachers/contract/destroy.rb


class Teachers::Contract::Destroy < ApplicationContract

NAME_MAX_LENGTH = 64

model Teacher

property :id

validates(
:id,
presence: true
)

validate(
:existence_of_teacher_id
)

def existence_of_teacher_id
errors.add(:id, '存在しないTeacherです。') if Teacher.pluck(:id).exclude?(id.to_i)
end
end


concepts/teachers/contract/destroy.rb


class Teachers::Operation::Destroy < ApplicationOperation

step :validate
failure :handle_validation_error, fail_fast: true
step :persist!
failure :handle_internal_error!

private

def validate(options, params)
contract = Teachers::Contract::Destroy.new(Teacher.new)

contract.id = params[:id]

options['contract'] = contract

contract.valid?
end

def persist!(options, **)
contract = options['contract']
model = Teacher.find(contract.id)

options['model'] = model

model.destroy!
end
end


TeachersController


class TeachersController < ApplicationController

def index
if params[:name].nil?
@teachers = Teacher.all
else
@teachers = Teacher.where(name: params[:name])
end
end

def show
@teacher = Teacher.find(params[:id])
@form = Teachers::Contract::Destroy.new(Teacher.find(params[:id]))
end

def new
@form = Teachers::Contract::Create.new(Teacher.new)
end

def create
# teacher_params = params.require(:teacher).permit(:name. ~~~, ~~~)
teacher_params = params.require(:teacher).permit(:name)
result = Teachers::Operation::Create.call(teacher_params)

if result['validation_errors'].present?
@form = result['contract']
flash[:error] = translate_validation_errors(result)
render 'new'
else
redirect_to teachers_path, flash: { notice: 'Teacher を作成しました。' }
end
end

def edit
@form = Teachers::Contract::Update.new(Teacher.find(params[:id]))
end

def update
teacher_params = params.require(:teacher).permit(:name, :id)
teacher_params[:id] = params[:id]

result = Teachers::Operation::Update.call(teacher_params)

if result['validation_errors'].present?
@form = result['contract']
flash[:error] = translate_validation_errors(result)
render 'edit'
else
redirect_to teachers_path, flash: { notice: 'Teacher を更新しました。' }
end
end

# --- 追加部分 ---
def destroy
teacher_params = params.require(:teacher).permit(:id)

result = Teachers::Operation::Destroy.call(teacher_params)

if result['validation_errors'].present?
@form = result['contract']
redirect_to teachers_path, flash: { alert: translate_validation_errors(result)}
else
redirect_to teachers_path, flash: { notice: 'Teacher を削除しました。' }
end
end
# --- 追加部分 ---
end


teachers/show.html.erb

<p>ID: <%= @teacher.id %></p>

<p>名前: <%= @teacher.name %></p>
<p>作成日: <%= date_format(@teacher.created_at, '%Y年 %m月 %d日') %></p>
<p>更新日: <%= date_format(@teacher.updated_at, '%Y年 %m月 %d日') %></p>

<!-- 追加部分 -->
<%= link_to '編集', edit_teacher_path(@teacher.id) %>
<%= form_with model: @form, url: teacher_path(@teacher.id), method: :delete, local: true do %>
<input type="hidden" name="teacher[id]" value="<%= @form.id %>" >
<button type="submit">削除</button>
<% end %>
<!-- 追加部分 -->


関連が1対多の場合

 それでは、 User Model に対する操作をやっていきます。

# (ja.yml: 末尾に以下を追記)

# --追加部分--
activerecord:
attributes:
teacher:
id: "ID"
name: "名前"
user:
id: "ID"
name: "名前"
teacher_id: "Teacher ID"
# --追加部分--

# (users/contract/create.rb)

class Users::Contract::Create < ApplicationContract
NAME_MAX_LENGTH = 64

model User

property :name
property :teacher_id

validates(
:name,
presence: true,
length: { maximum: NAME_MAX_LENGTH }
)

validate(
:existence_of_teacher_id
)

def existence_of_teacher_id
errors.add(:teacher_id, 'が不正です。') if Teacher.pluck(:id).exclude?(teacher_id.to_i)
end
end

# (users/operation/create.rb)

class Users::Operation::Create < ApplicationOperation
step :validate
failure :handle_validation_error, fail_fast: true
step :persist!
failure :handle_internal_error!

private

def validate(options, params)
contract = Users::Contract::Create.new(User.new)

contract.name = params[:name]
contract.teacher_id = params[:teacher_id]

options['contract'] = contract

contract.valid?
end

def persist!(options, **)
contract = options['contract']
teacher = Teacher.find(contract.teacher_id)
model = teacher.users.create(
name: contract.name
)

options['model'] = model

model.save!
end
end

# (UsersController)

class UsersController < ApplicationController
def index
@users = User.all
end

def new
@teachers = Teacher.all
@form = Users::Contract::Create.new(User.new)
end

def create
user_params = params.require(:user).permit(:teacher_id, :name)
result = Users::Operation::Create.call(user_params)

if result['validation_errors'].present?
@teachers = Teacher.all
@form = result['contract']
flash[:error] = translate_validation_errors(result)
render 'new'
else
redirect_to users_path, flash: { notice: 'User を作成しました。' }
end
end
end

<!-- (users/new.html.erb) -->

<%= form_with model: @form, url: users_path, method: :post, local: true do %>
<%= render 'shared/error_messages' %>

<label>教師</label>
<select name="user[teacher_id]">
<% @teachers.each do |teacher| %>
<option value="<%= teacher.id %>"><%= teacher.name %></option>
<% end %>
</select>

<label>名前</label>
<input type="text" name="user[name]" value="<%= @form.name %>" placeholder="名前" />
<button type="submit">登録する</button>
<% end %>

<!-- (users/index.html.erb) -->

<% @users.each do |user| %>
<p>ID: <%= user.id %></p>
<p>名前: <%= user.name %></p>
<p>教師名: <%= user.teacher.name %></p>
<p>作成日: <%= date_format(user.created_at, '%Y年 %m月 %d日') %></p>
<p>更新日: <%= date_format(user.updated_at, '%Y年 %m月 %d日') %></p>
<hr>
<% end %>