おもしろwebサービス開発日記

Ruby や Rails を中心に、web技術について書いています

iCARE Dev Meetup #12 で登壇した

顧問先のiCAREさん主催のミートアップで登壇しました。技術顧問に対するインタビューを受けてから、Rails 6.1の新機能の話をするという構成。

技術顧問が語る最新Ruby on Rails/Vue.js iCARE Dev Meetup #12 - connpass

スライドはこちら。

speakerdeckだとPDFが持っていたリンク情報が消えてしまったので、gistにもほぼ同じ内容を置いておきました。どうぞご利用ください。

所感

こういう形でインタビューを受けるのは初めてでした。技術顧問についての話なのでそれなりに興味のある人はいそうだけど、基本的には僕の個人的な体験や感想でしかないので聞いてる人面白かったのかな…。

Rails 6.1の機能については時間が足りないことは予めわかっていたので話せるところまで話しました*1。急いで話して紹介する機能の数を稼いだけど、例えばstrict_loadingだけにするなど、数を絞ってそのテーマについて深堀りする、みたいな形でもよかったかもしれませんね。

あと話すつもりだったのだけどどこかのタイミングで抜け落ちてしまったトピックがあったので、気力があったら次のブログエントリで取り上げるかもしれません。

*1:お手伝い先各社については興味ある人いたら再演する予定

パーフェクト Ruby on Rails改訂2版のサンプルコードについて

パーフェクト Ruby on Railsの改訂2版を書きました - おもしろwebサービス開発日記の続き。

いよいよ明日発売日ですね。前のエントリで書き忘れてたことがあったので追記です。

本書の6章からは、Railsのサンプルアプリケーションを作っていきます。技術評論社さんのサポートページからサンプルアプリケーションのコードを手に入れることができますが、GitHub上でも手に入れることができます。

6章の内容を読みつつ写経する方は、GitHubのリポジトリを活用すると写経が捗ると思います。本の流れに沿ってコミットを積んでいるので「写経したんだけどうまく動かない!」という箇所があったら、リポジトリを巻き戻して比較することでtypoに気づけるはず*1各節終わりの段階でタグも作っているので、途中の過程を飛ばして気になるところだけ写経してみるということも簡単です。

特にビューを一字一句間違えずに写経するのは大変だと思うので、本と一緒にリポジトリをうまく活用して要点を掴んでいってもらえると幸いです(\( ⁰⊖⁰)/)。

*1:本のほうが間違えているのでは、と思ったら別途教えていただけると…><

パーフェクト Ruby on Railsの改訂2版を書きました

ここ数年、色んな人に「パーフェクト Ruby on Railsの改訂版まだですか」と言われて申し訳ない気持ちでいっぱいでした。が、ついに改訂版が発売されることになりました!もちろん最新のRailsである6.0に対応しています。

発売日は7月25日ですが、先行して発売している書店もあるそうです。

パーフェクトRuby on Rails 【増補改訂版】:書籍案内|技術評論社

ブログで振り返ると、第1版を書いたのは6年前だったようです。6年前といえばRailsは4.1がリリースされた頃で、フロントエンドはCoffeeScriptを書いてSprocketsでコンパイル、デプロイはCapistranoを使うのが主流だったような気がします。6年でだいぶRailsによる開発の進め方が変わりましたね。このあたりはもちろん第2版で更新されて、WebpackerやDockerに置き換わっています。

改訂2版の内容は第1版のものをベースにしていますが、単にRails6.0仕様に変更しておしまい、ではなくこの6年で著者陣に蓄積された知見をモリモリ追加しているので、ぜひぜひ手にとって一読のほどお願いします🙏

f:id:willnet:20200714175336j:plain

我々はConcernsとどう向き合うか

この文章は先日開催された大阪Ruby会議02での登壇内容Concerns about Concernsをブログエントリにしたものです。書いている内容は登壇内容とだいたい同じですが完全一致ではなく、構成を変更したり喋っていない情報を足したりしてます*1

大阪Ruby会議02に出席していない方でもスライドを読めば大体の内容を把握できると思いますが、これだと細かいニュアンスは伝えられない(し、この手の話はその細かいニュアンスが大事だったりする)のでちゃんとブログエントリにしておこうと思ったのでした。

意見がある人はこちらのスレに書いてもらえると嬉しいです(\( ⁰⊖⁰)/)

Concernsとはなにか

Concernsという概念は、Rails 4.0から導入されました。具体的にはrails newしたときに生成されるファイルたちの中に

  • app/models/concerns
  • app/controllers/concerns

という2つの空のディレクトリが追加され、かつConcernsを定義するのに便利なモジュールActiveSupport::Concernが追加されました。基本的にはこれだけです。これだけなのですが、僕たちにConcernsという概念を認識させるには十分でした。

使い方に関してはDHHのブログに書かれています*2

Put chubby models on a diet with concerns – Signal v. Noise

このブログに端を発して、いろんなブログや書籍などでConcernsの説明が書かれましたが、基本的には「Concernsは関心事を分離するものである」というようなことが書いてあるはずです。みなさんもそのように認識しているのではないでしょうか。

例えばDHHのブログ中では次のようなサンプルコードが書かれています。

module Taggable
  extend ActiveSupport::Concern

  included do
    has_many :taggings, as: :taggable, dependent: :destroy
    has_many :tags, through: :taggings 
  end

  def tag_names
    tags.map(&:name)
  end
end

このようにモデル中のタグ機能を分離するのはわかりやすい例ですね。しかし「関心事を分離する」というのは結構難しい作業です。僕は仕事柄いろんな会社のいろんなRailsプロジェクトのコードを読むのですが「名前をつけるのが難しいが処理としては複数のクラスで使われているので適当な単位でモジュールとして切り出されているなにか」をよく見かけます*3

DHHは上記のブログエントリ内で、Concernsを使うとモデルの本質的ではない箇所を「別クラスに切り出して単一責任原則」とかせずに切り出せて便利である。と言っているように見えます(下記の意訳なのですが間違ってたらごめんなさい)。

Concerns are also a helpful way of extracting a slice of model that doesn’t seem part of its essence (what is and isn’t in the essence of a model is a fuzzy line and a longer discussion) without going full-bore Single Responsibility Principle and running the risk of ballooning your object inventory.

僕個人としては、このConcernsの使い方はよくないと考えています。恐らくDHHの所属するBasecamp社のエンジニアはみんな腕利きで、Concernsを大量に使っていても破綻せずに開発できているのではないかと思いますが、そのような会社はごくごく少数です。普通の会社でDHHの書いていることを鵜呑みにしてConcernsを多用すると負債になってしまうのではないでしょうか。

Concernsアンチパターン

ではどのようにConcernsを扱うのが良いのかをアンチパターンを提示し、その改善案を出すという流れで書いていこうと思います。

コントローラのビジネスロジックをConcernsにする

次のようなコントローラがあったとします。

class PostsController < ApplicationController
  def show
    @post = Post.find(params[:id])
    @same_category_posts = same_category_posts(@post)
  end

  # 他のアクション...

  private

  def same_category_posts(post)
    category_ids = post.category_ids
    same_category_post_ids = PostCategory.where(category_id: category_ids)
                                         .pluck(:post_id)
    Post.where(id: same_category_post_ids - [post.id])
        .includes(:categories)
        .order(updated_at: :desc).limit(5)
  end  
end

ここでprivateメソッドとして書かれているsame_category_postsは、引数として受け取ったPostオブジェクトと同カテゴリのPostオブジェクトを最大5つ返すメソッドです。

仮に、このメソッドが他のコントローラでも定義されていたとします。そうするとDRYにしたくなりますよね。ではConcernsとしてモジュールに切り出してみましょう。

module PostFindable
  extend ActiveSupport::Concern
  
  def same_category_posts(post)
    category_ids = post.category_ids
    same_category_post_ids = PostCategory.where(category_id: category_ids)
                                         .pluck(:post_id)
    Post.where(id: same_category_post_ids - [post.id])
        .includes(:categories)
        .order(updated_at: :desc).limit(5)
  end
end

はい。そのままモジュールとして切り出しました。するともとのコントローラは次のようになります。

class PostsController < ApplicationController
  include PostFindable

  def show
    @post = Post.find(params[:id])
    @same_category_posts = same_category_posts(@post)
  end

  # 他のアクション...

メソッドが減って、なんとなくスッキリしたような気がしますね。しかしこれは良くないリファクタリング方法です。

そもそもsame_category_postsメソッドはビジネスロジックであり、コントローラに書くべきものではないからです。そこでsame_category_postsをPostモデルに書いてみます*4

class Post < ApplicationRecord
  def same_category_posts
    same_category_post_ids = PostCategory.where(category_id: category_ids)
                                         .pluck(:post_id)
    Post.where(id: same_category_post_ids - [id])
        .includes(:categories)
        .order(updated_at: :desc).limit(5)
  end
end

するとコントローラは次のようになります。

class PostsController < ApplicationController
  def show
    @post = Post.find(params[:id])
    @same_category_posts = @post.same_category_posts
  end

  # 他のアクション...

先程のConcernsを使った方法と対して変わらないのでは?という人もいるかも知れませんが、これはサンプルコードが単純だからそう見えるだけです*5。仕事で開発するRailsアプリケーションはもっと複雑なビジネスロジックを多数含んでいます。そのビジネスロジックがコントローラ、モデルの両方に存在していると、特定の処理を追いかけるときにいろんな場所を見に行かなければならず、コードの見通しが悪くなります。コントローラのConcernsはコントローラのコンテキストに存在するので、仮にコントローラそのものからメソッドが見えなくなっても「ビジネスロジックをモデルに寄せる」という原則からは外れることになります。

rubocopのClassLength対策でConcernsにする

開発フローにrubocopを取り入れており、ClassLength設定が有効なプロジェクトがあったとします。仮にClassLengthが300だとすると、次のように何らかの修正の結果Postモデルが300行を超えたときにCIが失敗します。

class Post < ApplicationRecord
  # たくさんのロジックが300行ある
end

CIが失敗したときにPostモデルを修正していた人は、CIを通すため、なんとしてでもPostの行数を減らさなければなりません。そこで次のようにしてみます。

class Post < ApplicationRecord
  include Previewable
  include Reservable
  include Bookmarkable
end

ここで新しく定義したPreviewable, Resarvable, BookmarkableはもちろんConcernsとしてモジュールに切り出したものです。これでPostの行数は劇的に削減され、ClassLengthに引っかかることはなくなりました。めでたしめでたし…本当にそうでしょうか?

これらのConcernsはPostでのみ参照されるモジュールです。そしてPostの持っているメソッドは何一つとして減っていません。単にpost.rbから別のファイルに移動しただけで、実行時にはPostモデルのメソッドとしてこれまでと同様に振る舞います。

このようなケースでは、Concernsとしてではなく別クラスとして切り出し、Postモデルの責務を減らす必要があります。このアンチパターンは抽象的*6なので、別のクラスとして切り出す実例は次の「複雑なビジネスロジックをConcernsにする」を参照してください。

現実にはこのアンチパターンを擁護する人もいそうです。そのときに考えられる意見としては「コードの塊に名前がついて切り出されたぶん少し可読性が上がったのでは?」というのがあります。しかし、Active Recordは同じことを同一ファイル内で実現する方法を提供してくれているので「名前をつけて切り出す」のであればこちらの方法で良さそうです。

class Post < ApplicationRecord
  concerning :Previewable do
    # ...
  end

  concerning :Reservable do
    # ...
  end

  concerning :Bookmarkable do
    # ...
  end
end

参考: Module::Concerning

rubocopのClassLength警告は「該当クラスが責務を持ちすぎである」ということの指摘であるはずなので、実質的な責務を減らさないConcernsは意味がないですし、モデルに所属しているコードが追いづらくなるのでかえって可読性を下げる結果になるのではないでしょうか。

複雑なビジネスロジックをConcernsにする

次のモジュールは、ぱっと見ただけだとなにをするものなのか分かりづらいですね。引数として渡されたjsonの形式を見て、モデルを更新または削除するメソッドupdate_by_api!を提供するものです。

module UpdatableByApi
  extend ActiveSupport::Concern

  def update_by_api!(json)
    parsed_json = JSON.parse(json)

    if parsed_json['_destroy'].to_i == 1
      resouce.destroy!
    else
      raise '不正な値です' if parsed_json['reason'].blank?
      resource.attributes = parsed_json.except('_destroy').merge('updated_by' => 'api')
      resource.save!
    end
  end
end

(このくらいならすんなり読めるよ、という人もいるかも知れませんが)全体として何が行われているか、すぐにはわかりづらいコードであると感じます。リファクタリングしてみましょう。モジュールではなく、別クラスに切り出す(委譲)の方式で試してみます。

class UpdatingByApi
  def self.call(json)
    new(json).call
  end

  def initialize(json)
    @json = json
  end

  def call
    if destroy?
      resource.destroy!
    else
      raise '不正な値です' if invalid?
      resource.update!(new_attributes)
    end
  end

  private

  attr_reader :json

  def parsed_json
    @parsed_json ||= JSON.parse(json)
  end

  def destroy?
    parsed_json['_destroy'].to_i == 1
  end

  def invalid?
    parsed_json['reason'].blank?
  end

  def new_attributes
    parsed_json.except('_destroy').merge('updated_by' => 'api')
  end
end

行数は長くなりましたが、次のような効能が得られました。

  • それぞれの処理の詳細がプライベートメソッドに切り出されたことで詳細を把握しやすくなった
  • #callはプライベートメソッドの名前として説明されているメソッド名を読むことで、詳細を見ずに概要を把握できるようになった

今回わざわざモジュールからクラスに変更してリファクタリングしましたが、「プライベートメソッドとして切り出す」だけならモジュールでもできそうです。モジュールで同様にプライベートメソッドへの切り出しをやってみましょう。

module UpdatableByApi
  extend ActiveSupport::Concern

  def update_by_api!(json)
    if destroy?
      destroy!
    else
      raise '不正な値です' if invalid?
      update!(new_attributes)
    end
  end

  private

  def parsed_json
    @parsed_json ||= JSON.parse(json)
  end

  def destroy?
    parsed_json['_destroy'].to_i == 1
  end

  def invalid?
    parsed_json['reason'].blank?
  end

  def new_attributes
    parsed_json.except('_destroy').merge('updated_by' => 'api')
  end
end

同じようにできましたね。ではわざわざクラスにする必要はないのでしょうか?

そんなことはありません。この例だと、invalid?メソッドがActive Recordの提供するinvalid?メソッドをオーバーライドしてしまっているため、どこか別のコードが上手く動かなくなっている可能性が高いです。

ではinvalid?をリネームしたら良いでしょうか?しかし他のメソッド名はどうでしょう。Active Recordの提供するメソッド名とは重複していませんが、別のモジュールやモデルで普通に定義していそうな名前ですよね。また、@parsed_jsonインスタンス変数も同様です。モジュールでプライベートメソッドやインスタンス変数を定義する場合、すべて他のモジュールやinclude先のクラスの定義と重複しないように気をつけて実装する必要があります。

では、Concernsはどのように使うとよいのでしょうか。例えば次のようにupdate_from_api!というインタフェースだけを提供するモジュールとして実装するという方法があります。update_from_api!メソッドの実態は別クラスとして実装することで、includeで手軽にメソッドを増やせるのと、コンテキストを分けることで実装をリファクタリングしやすくなるメリットがあります。

module UpdatableByApi
  def update_form_api!(user, json)
    UpdatingByAPI.call(user: user, json: json)
  end
end

hookをConcernsにする

次のように、Active RecordのコールバックをConcernsにしているコードを時々見かけます。

module PublishedAtSettable
  extend ActiveSupport::Concern

  included do
    before_create :set_published_at
  end

  private

  def set_published_at
    self.published_at ||= Time.zone.now
  end
end

class Post < ApplicationRecord
  include PublishedAtSettable
end

しかし、コールバックを共通化して複数のクラスに適用したいのであれば、次のようにコールバックメソッド(この例だと before_create)にオブジェクトを渡すやり方でも実現できます。

class SetPublishedAt
  def before_create(publishable)
    publishable.published_at ||= Time.zone.now
  end
end

class Post < ApplicationRecord
  before_create SetPublishedAt.new
end

こちらのほうが、「Postモデルはbefore_createコールバックを定義している」ということがわかりやすくなり可読性が高いのではないでしょうか。

まとめ

今回の話で特に言いたかったことをまとめると次の3点です。

  • Concerns(module)はinclude先とコンテキストを共有するので、クラスから単純にConcernsに切り出しても責務が減るわけではない
  • 責務を減らしたいのであればクラスとして切り出したほうが良い
  • 安易にConcernsとして切り出すのではなく、まず別の方法も検討してみましょう

Concernsとして処理を切り出すと、目の前からコードがなくなるのでなんとなくリファクタリングできた気分になりますが、これは部屋にあるたくさんの荷物を部屋の隅に寄せて掃除をした気分になるのに似ていると感じます。これからConcernsを使うときには、これで本当に可読性が上がるのか一度考えてみてもらえると嬉しいです!

*1:例えばActiveSupport::Concernの説明は省略しています。ググればわかるので

*2:Railsガイドなど公式のドキュメントには特に言及がありません

*3:複数のクラスで使われていないけどなぜか切り出されているモジュールもたまに見かけます

*4:複数モデルが関わるメソッドなので別のクラスを作りそこにメソッドを定義すべき、という人もいるかも知れませんが、今回の主題から逸れるので一旦Postモデルで話をすすめます

*5:シンプルなコード例でもっといい感じに違いを表現できる方法が思いつかなかったのでした…

*6:実際に300行のPostモデルを用意するのは難しすぎ

Ruby on Rails 6 エンジニア 養成読本という本を書きました

@sugamasaoさんと共著でRails本を執筆しました。Railsを始めたばかりの人向けの特集から、Rails 6の新機能紹介まで幅広く書かれたムック本です。今日から9日後の10月26日に発売予定です(電子書籍も同じくらいに発売されるはず)。

Ruby on Rails 6 エンジニア 養成読本
すがわら まさのり 前島 真一
技術評論社
売り上げランキング: 2,448

@sugamasaoさんの書籍紹介エントリはこちら

sugamasao.hatenablog.com

内容紹介

@sugamasaoさんが目次や対象読者を紹介しているので、こちらでは具体的にどんな内容を扱っているかを箇条書きにしておきます。Railsの新機能を追いかけていない人にとっては、なにこれ?となったり、名前は知ってるけど具体的にどんな感じなのかは知らないぞ?となったり、内容知ってるけど今どうなってるんだっけ?となる単語が多いのではないでしょうか。

  • Action Text
  • Action Mailbox
  • 複数DB対応
  • 並列テスト
  • Webpacker
  • Sprockets
  • Turbolinks
  • Stimulus
  • Active Storage
  • Credentials
  • Early Hints
  • CSP

全体を通して、普通のRails本だとあまり取り上げられないだろうトピックが多めかと思います。例えばStimulusについては日本語の情報はほとんどないはず。

この中から興味のあるトピックを拾い読みする、というのがよい使い方ではないでしょうか。もちろん通して読んでもらっても 🙆です。ぜひお買い求めください(\( ⁰⊖⁰)/)

大阪Ruby会議02でConcernsについて発表してきました

先日開催された大阪Ruby会議02で、なんとなく使われがちな機能であるConcernsの使い方について話してきました。資料はこちら。

発表内容について

Concernsに関する説明は「関心事を分離するぞ!」のような抽象的なものが多く、

  • 何を関心事として分離するとよいのか
  • Concerns以外のロジックを分離する方法

を知らずに自分なりの解釈でConcernsを使うとかえってコードを読みづらくする形でmoduleが作られることになりがちです。この発表を通じて少しでも読みやすいRailsアプリケーションのコードが増えてほしいなと思います。

もし内容について感想や質問などを書きたい方がいたら、clean-rails.orgこの発表用のスレが立っているので書き込みお願いします!

このスライドだけだと分かりづらいところがありそうなので、どこかで再演するなり別途文章として公開したいところです*1

追記: 別途文章として公開しました! 我々はConcernsとどう向き合うか - おもしろwebサービス開発日記

f:id:willnet:20190915153710j:plain

所感

うちの子(1歳児)が僕の発表を見学に来ていたのですが眠くてぐずっていたようで参加者の方々にはご迷惑おかけしました*2><

大阪、昔1年ほど仕事の関係で住んでいたので当時よくいた場所をまわってみたいな、と思っていたのですが諸事情あってあまり外に出れなかったので、また次回の大阪Ruby会議で来れるようにしようと思います。主催者の皆さん、素晴らしい機会をありがとうございました!

*1:最近めっちゃ忙しくしているのであまり期待できません…><

*2:温かい言葉をくれた方々ありがとうございます

ajax_error_renderer 0.2.0をリリースした

個人的に便利に使っているajax_error_rendererなのだけど、フォームが長いときに「エラーメッセージがブラウザのスクリーン外に表示されてしまいユーザが気づけない」というケースがあったので、デフォルトでエラーメッセージのある場所までスクロールするように修正しました。どうぞご利用ください(\( ⁰⊖⁰)/)

参考: turbolinksとform_withを便利に使うためのgemを作った - おもしろwebサービス開発日記