Rails Application Templates による管理ツール基盤づくりでハマったときの解決方法

upscent
upscent
Mar 25 · 12 min read

こんにちは! CRE の上埜です。

ミクシィの CRE では、サービス毎にカスタマーサポート (CS) 専用の管理ツール(以降 CS ツールと呼びます)を作っており、現在7つのサービスの CS ツールを運用・保守しています。また、リリースを控えたサービスの CS ツールも既存ツールの運用と並行して開発しています。

CRE メンバー5名に対しサービスのリリースがいくつも重なると開発リソースが逼迫してしまう課題があり、以前同じく CRE の橋本が Elixir の Phoenix で CS ツールの基盤を自動生成するツールを開発し、 自作のコードジェネレータで管理ツールを素早く立ち上げる で紹介しました。

最近は Ruby on Rails で開発する機会が増えてきており、今回新たに Rails Application Templates を使ってCSツール基盤を自動生成できるようにしたので、本記事では Rails Application Templates を使ってハマったところを2点お話ししたいと思います。

Rails Application Templates を使った理由

rails_adminAdministrate などの Ruby on Rails 向けの管理画面生成 gem がありますが、ミクシィのCSツールではそれらの gem を使っていません。下記のように細やかなカスタマイズをしているからです。

  • 各スタッフの権限設定を、ハードコーディングではなく、管理者スタッフがCSツールで変更できるようにしている
  • サービスの内部構造を知らないスタッフでも見やすいようにデータの表示を調整している
  • スタッフは各サービスを横断的に見ているため、既存サービスのCSツールとレイアウトが変わると学習コストがかかる

そのため、既存のCSツールと同等の基盤を整えるには、そこそこのコストがかかる割にほぼコピペ作業でした。そこで目をつけたのが Rails Application Templates でした。

Rails Application Templates とは

Rails Application Templates は Ruby on Rails が提供する機能です。設定手順を記述した Ruby のテンプレートファイルを1つ作ると、1コマンドで簡単にアプリケーションを構築することができます。

rails new -m <テンプレートファイルのパス or URL>
# もしくは
rails app:template LOCATION=<テンプレートファイルのパス or URL>

テンプレートファイルは、Rails Application Templates の Advanced Usage

The application template is evaluated in the context of a Rails::Generators::AppGenerator instance. It uses the apply action provided by Thor

と記載されている通り、 Rails::Generators::AppGenerator のインスタンスのコンテキストで評価されます。

そのため、テンプレートファイルではRuby on Rails 独自の Template API の他に Thor::Actions に定義されている豊富なファイル操作APIも利用することができます。

また、 Thor を利用しているため、ファイル生成時に既存ファイルを上書きする場合でも環境変数 THOR_MERGE にマージツールを設定していればテンプレート適用時に適切に編集することができます。今回の CS ツール基盤自動生成ではリリース時の負担を下げることを目的としたのでツールの立ち上げのみにスコープを絞りましたが、 Thor のマージ機能を利用すれば既存ツールの改修においても役立つシーンはありそうです。

Rails Application Templates でハマったところ

テンプレートファイル内で定義したモジュール名を正しく取得できない?

テンプレートファイル内で下記のように CodeGenrator モジュールを定義し、それを継承する形で ModelGenerator モジュールを定義しました。

class CodeGenerator
def self.name
self.to_s.underscore.split(“_”).first
end
end
class ModelGenerator < CodeGenerator
end
class ControllerGenerator < CodeGenerator
end
# 下記のような戻り値になることを期待
# ModelGenerator.name => "model"
# ControllerGenerator.name => "controller"

しかし ModelGenerator.name が期待通りの値 "model" になりません。

Rake ファイルと同じ感覚で書いていた私は、テンプレートファイルがトップレベルではなく Rails::Generators::AppGenerator のインスタンスのコンテキストで評価されることを忘れていました。 そのため、 ModelGenerator モジュールは、実際は #<Class:0x00007fbf1d98dd78>::ModelGenerator というようなモジュールになっていたのです。

ModelGenerator.name
=> “#<class:0x00007fbf1d98dd78>/model”

期待通りに動作させるには、下記のように明示的にトップレベルに定義するか、 CodeGenerator.name の実装を変更する必要があります。

class CodeGenerator
def self.name
self.to_s.underscore.split(“_”).first
end
end
class ::ModelGenerator < Generator
end
class ::ControllerGenerator < Generator
end

Template API の file API を使う際、意図しない差分が出る

Template API の file で下記のようなファイルを生成しようとすると、既存ファイル app/models/user.rb が全く同じテキストでも差分が出てしまいます。

file "app/models/user.rb", <<-CODE
class User < ApplicationRecord
# パスワード変更時
with_options on: :password_change do
validates :current_password, presence: true, password_correctness: true
validates :new_password, confirmation: true, password_strength: true
end
# ログイン時のパスワード強度チェック
with_options on: :login do
validates :current_password, password_strength: true
end
end
CODE

file は、 Thor::Actionscreate_fileを呼んでいるので、 Thor の実装を見てみます。(関連する部分だけ抜き出しています)

https://github.com/erikhuda/thor/blob/master/lib/thor/actions.rb

class Thor
module Actions
# (1) Template API の file から呼ばれる
def create_file(destination, *args, &block)
config = args.last.is_a?(Hash) ? args.pop : {}
data = args.first
action CreateFile.new(self, destination, block || data.to_s, config)
end
# (2) Thor::Actions::CreateFile#create_file から呼ばれる
def action(instance)
if behavior == :revoke
instance.revoke!
else
instance.invoke!
end
end
end
end

https://github.com/erikhuda/thor/blob/master/lib/thor/actions/empty_directory.rb

class Thor
module Actions
class EmptyDirectory
# (3) Thor::Actions#action から呼ばれる
def invoke!
invoke_with_conflict_check do
require "fileutils"
::FileUtils.mkdir_p(destination)
end
end
protected # (4) Thor::Actions::EmptyDirectory#invoke! から呼ばれる
def invoke_with_conflict_check(&block)
if exists?
on_conflict_behavior(&block)
else
yield unless pretend?
say_status :create, :green
end
end
end
end
end

https://github.com/erikhuda/thor/blob/master/lib/thor/actions/create_file.rb

class Thor
module Actions
class CreateFile < EmptyDirectory
# (6) Thor::Actions::CreateFile#on_conflict_behavior から呼ばれる
def identical?
exists? && File.binread(destination) == render
end

protected
# (5) Thor::Actions::EmptyDirectory#invoke_with_conflict_check から呼ばれる
def on_conflict_behavior(&block)
if identical?
say_status :identical, :blue
else
options = base.options.merge(config)
force_or_skip_or_conflict(options[:force], options[:skip], &block)
end
end
end
end
end

file から順番に下記のAPIを呼び出しています。

  • Thor::Actions#create_file
  • Thor::Actions#action
  • Thor::Actions::EmptyDirectory#invoke!
  • Thor::Actions::EmptyDirectory#invoke_with_conflict_check
  • Thor::Actions::CreateFile#on_conflict_behavior
  • Thor::Actions::CreateFile#identical?

最後の Thor::Actions::CreateFile#identical? を見てみましょう。

def identical?
exists? && File.binread(destination) == render
end

ここで既存ファイル destination と上書きしようとしているテキスト render に差分がないかを確認しています。その際に既存ファイルを File.binread で読み出しているのが、意図しない差分が出る原因でした。

File.binreadIO.binread (Ruby 2.7.0 リファレンスマニュアル)

ファイルを開くときの mode"rb:ASCII-8BIT" です。

と書いてる通り ASCII-8BIT で読み出すので、日本語のようにマルチバイト文字を使用していると文字化けしてしまうのです。

この問題は、テンプレートファイル内でモンキーパッチを当てることで解決しました。

class ::Thor
module Actions
class CreateFile < EmptyDirectory
def identical?
exists? && File.read(destination) == render
end
end
end
end

このとき、 Thor クラスを定義する際に先頭に :: をつけないとトップレベルで定義されないためパッチを正しく当てることができないので注意してください。

ちなみに、同じようにマルチバイト文字で躓いた人が Thor に PR utf8 identical checking by chenkovsky · Pull Request #656 · erikhuda/thor を送ってくれています。これが入ればモンキーパッチを当てなくてもよくなりそうです。

おわりに

Rails Application Templates は API が豊富に揃っているので、凝った使い方をしなければ簡単にコード生成を自動化できます!ぜひみなさんも Rails Application Templates を使って車輪の再開発をやめて楽しましょう。

そして私のように躓いた人がいたらぜひ記事にして教えてください!

upscent

Written by

upscent

mixi developers

ミクシィグループのエンジニアやデザイナーによるブログです。

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade