seri::diary

プログラミングのこととかポエムとか

自分が2016年に作ったrails拡張系gemとその解説

この記事はトレタ Advent Calendar 2016の4日目です。
3日目は弊社CTOのmasuidriveによる【定番】 新しいWebサービスを開発・運営するときに使いたいサービス 【2016年末版】でした。私の推しサービスはBugsnagです。詳細はこちらのスライドを参照ください。

さて、3日目のこの記事では、私が今年railsの実装についての調査と暇つぶしを兼ねて作ったrails拡張系gemについて紹介します。

flatten_routes

github.com

routes.rb を書くときにresouceresourcesを使った書き方をしているとroutes.rbだけでは実際のURLがパッと見でわかりにくくなることがある。

$ rake routes を使えばよいのだが、routes.rb が膨大になってくると表示まで3~4秒待たされることもあり、プロジェクトが佳境を迎え、1秒でも惜しい状況では結構なストレスになる。

そこで、routes.rbを書くときのもう一つの記法である verb {directory} => {controller}/{action} の書き方もコメントで併記できたらよさそうと考えてこのgemを作った。

実行例は以下の通りである。 仮にroutes.rbが以下の通りだったとすると

Rails.application.routes.draw do
  ActiveAdmin.routes(self)
  resources :todos, except: [:new, :edit] do
    member do
      post :start
      post :finish
      post :restart
      post :giveup
      put :awesome
    end
    collection do
      get :finished
      get :not_yet
    end
  end
end

Rails.rootで以下のコマンドを実行することで

$ rake flatten_routes:annotate

こうなる

Rails.application.routes.draw do
# == generated by flatten_routes from here 2016-02-27 17:56:31 +0900
#  get    '/admin'                    => 'admin/dashboard#index'
#  get    '/admin/dashboard'          => 'admin/dashboard#index'
#  post   '/admin/todos/batch_action' => 'admin/todos#batch_action'
#  get    '/admin/todos'              => 'admin/todos#index'
#  post   '/admin/todos'              => 'admin/todos#create'
#  get    '/admin/todos/new'          => 'admin/todos#new'
#  get    '/admin/todos/:id/edit'     => 'admin/todos#edit'
#  get    '/admin/todos/:id'          => 'admin/todos#show'
#  patch  '/admin/todos/:id'          => 'admin/todos#update'
#  put    '/admin/todos/:id'          => 'admin/todos#update'
#  delete '/admin/todos/:id'          => 'admin/todos#destroy'
#  get    '/admin/comments'           => 'admin/comments#index'
#  post   '/admin/comments'           => 'admin/comments#create'
#  get    '/admin/comments/:id'       => 'admin/comments#show'
#  delete '/admin/comments/:id'       => 'admin/comments#destroy'
#  post   '/todos/:id/start'          => 'todos#start'
#  post   '/todos/:id/finish'         => 'todos#finish'
#  post   '/todos/:id/restart'        => 'todos#restart'
#  post   '/todos/:id/giveup'         => 'todos#giveup'
#  put    '/todos/:id/awesome'        => 'todos#awesome'
#  get    '/todos/finished'           => 'todos#finished'
#  get    '/todos/not_yet'            => 'todos#not_yet'
#  get    '/todos'                    => 'todos#index'
#  post   '/todos'                    => 'todos#create'
#  get    '/todos/:id'                => 'todos#show'
#  patch  '/todos/:id'                => 'todos#update'
#  put    '/todos/:id'                => 'todos#update'
#  delete '/todos/:id'                => 'todos#destroy'
# == generated by flatten_routes to here
  ActiveAdmin.routes(self)
  resources :todos, except: [:new, :edit] do
    member do
      post :start
      post :finish
      post :restart
      post :giveup
      put :awesome
    end
    collection do
      get :finished
      get :not_yet
    end
  end
end

コメントするだけでなく、いっそのことresorucesresourceを使う記法をすべてconvertしたい、という時には

$ rake flatten_routes:convert

を実行することで、上記の例でコメントされていた部分が記述され、オリジナルのresourcesresouceを使っていた方がコメントアウトされる。

railsからrouting情報を取り出す方法について

$ rake routeがそれっぽい情報を表示してくれているのでその辺のソースを参考に調べた。(このgemを書いた当時はrails5が出る前だったので4-2-stableブランチを参照した)

rails4.2におけるrake routesタスクは以下の様に実装されている

desc 'Print out all defined routes in match order, with names. Target specific controller with CONTROLLER=x.'
task routes: :environment do
  all_routes = Rails.application.routes.routes
  require 'action_dispatch/routing/inspector'
  inspector = ActionDispatch::Routing::RoutesInspector.new(all_routes)
  puts inspector.format(ActionDispatch::Routing::ConsoleFormatter.new, ENV['CONTROLLER'])
end

Rails.application.routes.routesでrouting情報の構造を取得し、それを ActionDispatch::Routing::RoutesInspectorに渡して、 ActionDispatch::Routing::ConsoleFormatter で定義されたformat(定義箇所はここ)で整形されたものをputsしているということがわかる。ちなみに、ActionDispatch::Routing::RoutesInspectorActionController::RoutingErrorがraiseされたときに出るエラーページのルーティング一覧を表示する部分でも使われており、htmlのtableに整形するためのformatterも実装されている

なので、今回はオリジナルのformatterを作ってそれをinspectorに渡すように実装すればいいかなと思ったが、別にformatterを差し替えて使いまわすような使い方を提供するgemでもないので、ベタっとformatterを書いて実装した。中身はroutes.rbのフォーマットに合わせて置換したり見た目を揃えるためにスペース入れたりとかそういう当たり前のことしかしていないので割愛。

補足

単に実際のURLをコメントするだけなら他のgemとしてはannotateというgemがある。
自分はプライベートでも仕事でもよく使っており、ActiveRecordのModelにコメントでschema情報を付けるだけのgemかと思っていたのだが、READMEをよく読んだらrouting情報もコメントしてくれる機能があったらしい。じゃあこれ使えばいいか

error_arranger

github.com

Controller内部でエラーが起きて、rescue_fromに渡されたりそのままraiseされてrack layerで処理される前に何か前処理を入れたりできないかなぁと思って作ったgem。

例えば

class PostsController < ActionController
end

みたいなcontrollerがあったとして、PostsController内部で発生したエラーメッセージに何か注釈をつけたりしたい、みたいなケースがあったら(あるのか?)以下のようにarrange_exception!を実装することで実現できる。

class PostsController < ApplicationController

  private

  def arrange_exception!(exception)
    exception.message << 'This error raised in PostsController'
  end
end

エラーがrailsで処理される前にcallbackを差し込む仕組みについて

結論から言えばモンキーパッチを当てているので、あまり行儀が良い方法ではない。

で、モンキーパッチの内容は下記である。

module ActionController
  module Rescue
    extend ActiveSupport::Concern
    include ActiveSupport::Rescuable

    private
      def process_action(*args)
        super
      rescue Exception => exception
        request.env['action_dispatch.show_detailed_exceptions'] ||= show_detailed_exceptions?
        arrange_exception!(exception) if self.respond_to?(:arrange_exception!)
        rescue_with_handler(exception) || raise(exception)
      end
  end
end

単にcontrollerにarrange_exception!という名前のメソッドが生えていたらそれを呼んでからrescue_fromで定義されたhandlerに渡すかそのままraiseする、という感じである。

このgemを作った当時、Controllerのactionで発生したエラーがどこでhandlingされるか分からなくて散々railsのソースを読んでようやく見つけた記憶があるのだが、rails/actionpack/lib/action_controller/metal/rescue.rbに実装されている。

ソースをちゃんと読み切れていないのだが、どうもrails/actionpack/lib/action_controller/metal配下に実装されているモジュールがロードされて、そのモジュールの中でprocess_actionというメソッドをoverrideしていってstackされていき、実際にリクエストが来てactionが実行されるときにcallbackとしてcontrollerからprocess_actionが呼ばれる。。という感じのように見えるのだが、よくわかっていない。謎い。

practical_errors

github.com

先に説明したerror_arrangerを使って、railsでよく見るエラーにもう少し丁寧な解説をつけてみたらrails初心者に優しくなるんじゃなかろうか?と思って作ってみたgem。

例えば、Post.find(1000)を実行してActiveRecord::RecordNotFoundがraiseされたとする。

そのときには以下のようなメッセージをerrorオブジェクトに追加してくれる。

ActiveRecord::RecordNotFound (
========== Practical Errors appends message from here ==========

Rails says, "Couldn't find Post with 'id'=1000".

You might have called ActiveRecord::FinderMethods#find, but the record for provided id was not found.
If you DO NOT want to raise ActiveRecord::RecordNotFound even if the record was not found,
you should use ActiveRecord::FinderMethods#find_by instead.

Post.find(1000) # raise ActiveRecord::RecordNotFound
Post.find_by(id: 1000) # just returns nill


========== Practical Errors appends message to here ==========
See more detail about Practical Errors: https://github.com/serihiro/practical_errors

)

idで検索してnilになるケースが想定される場合はfind_by使ってね、的なrails初心者のコードをレビューする時につけるようなコメントをrailsに勝手にやってもらえる。もちろん、ちゃんとエラーメッセージを読む相手であるということが前提だ。読まない相手の場合は頑張って教育しよう。

ちなみに、ActiveRecord::RecordNotUniqueのときの追加メッセージはこうだ。

The column included in the record that you were about to insert or update, is not allowed duplicated value.
This error was detected by database, not Rails.
You might have configured uniqueness validation to this column, but uniqueness validation is not panacea.
In race condition, uniqueness validation was passed easily.
For more detail, see rails document
http://api.rubyonrails.org/classes/ActiveRecord/Validations/ClassMethods.html#method-i-validates_uniqueness_of

uniqueness: trueなvalidationを入れてもrace conditionではすり抜けるから気をつけような、的な。あまり役には立たないがActiveRecord::RecordNotUniqueがrescueされずに突き抜けてしまった時の慰めにはなるだろう。

ちなみにいまのところ対応しているエラーはActiveRecord::RecordNotFoudActiveRecord::RecordNotUniqueの2種類しかない。他にも色々作る予定だったが力尽きてしまったので、ぜひPRを送って熱いエラーメッセージを追加してもらえるとうれしい。

実装部分については特筆すべき事項はなく、前出のerror_arrangerを使ってraiseされたエラーのクラスを見てそのクラスに該当するAdviserクラスがあったらAdviserクラスにメッセージを付与してもらう、ぐらいの感じである。

      if PracticalErrors::ErrorAdvisers::const_defined?(exception.class.to_s)
        @customised_message << PracticalErrors::ErrorAdvisers::const_get(exception.class.to_s).advise(exception)
      else
        @customised_message << <<-"EOS".strip_heredoc
          hmm... I don't know this error :(
          Tell me => https://github.com/serihiro/practical_errors
        EOS
      end

Adviserがいない時は教えてくれ!っていうメセージを入れているが今の所一件も報告がないので誰も使ってないと思われる。PRお待ちしております。


トレタ Advent Calendar 2016の5日目は2回目の登場horimislimeによる「ここ最近作ってたMacアプリの話とか」の予定です。