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

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

我々は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サービス開発日記

Rails Developers Meetup 2019で、再び綺麗なテストコードの書き方について発表した

先日行われたRails Developers Meetup 2019で、Clean Test Code Revisedというタイトルで発表しました。スライドはこちら。

動画も上がっているようなので興味のある方はどうぞ*1

所感

ご存知のかたもいると思いますが、この発表は2017年5月に行われたRails Developers Meetup第一回目で発表した内容を更に一歩進めたものとなっています。

Rails Developers Meetup で綺麗なテストコードの書き方について発表した - おもしろwebサービス開発日記

当時僕の頭の中にあった「こういうケースのときはこう書く。なぜならこうだから」というものを点で出したのが前回の発表で、それらを「脳に負荷をかけない」という線でつなげてまとめたのが今回の発表になります。

テストコードをレビューしたときに「これなんか読みづらいな…」と思っても「なぜこれが読みづらいのか」が説明できないとスルーしてacceptしてしまいがちになります。現状、RSpecで書かれたテストコードの可読性について書かれた書籍やネット上の記事はほとんどなく、みんな試行錯誤で(多くの場合はいろいろ諦めつつ)頑張っているのではないでしょうか。なのでこの発表内容を活用して今一度テストコードの可読性について見直してみてもらえると嬉しいです。

今回の発表内容は、総論としてはみんな賛同してもらえたようなのですが各論(例: letよりなるべくlet!を使う)は違う意見を持っている人が結構いるみたいです。議論の場を作ってみたので「俺はこう書いている」みたいな意見があればぜひ教えてほしいです!

Clean Test Code Revisedの発表内容について - clean-rails.org

f:id:willnet:20190323111424j:plain

*1:僕は自分の動画は見てないです。恥ずかしいので><

株式会社ウィルネットは設立2周年を迎えました

昨日、2月21日は弊社の設立記念日でした*1

というわけで法人成りして2年経ちました。最初はフリーランスの延長のつもりだったのですが、会社という形態にしたことで意識が少しだけ変わってきた気がします。

これまではなんでも全部自分一人でやるというのが自然だったのですが、会社の予算を使って誰かに仕事を手伝ってもらう、という形態を徐々に受け入れられるようになってきました。空いた時間で少しずつ開発を進めているsavanna.ioも、いまはデザインに関しては本職にお願いするようになっています。

昔は、一つのスキルだけを伸ばすのではなくいろんな分野を学んでいくのがよい、と思ってデザイン含めいろいろ勉強してたのですが、現状では一つのスキルをガンガン伸ばして他は誰か得意な人にお願いする方が効率良いのでは?という気持ちになっています。

予算を使ってコーポレートサイトもちゃんとしたものを作りました。もともとペライチというサービスでそれっぽいものを作っていたのですが、問い合わせフォーム経由でスパムが毎日のように届くのでもう少し自分でコントロールできる環境が欲しくなったのでした*2

株式会社ウィルネット|Webサービス開発コンサルティング

なにか相談がある方は↑の問い合わせフォームからでもtwitterなどからでもよいのでお気軽に問い合わせください。

おかげさまで技術顧問業、savanna.ioの開発、執筆、登壇など相変わらず忙しくしています。直近だとRails Developers Meetup 2019に登壇予定です。テストの話をする予定なので興味ある人はどうぞ。

3期目も頑張ってやっていくのでよろしくおねがいします(\( ⁰⊖⁰)/)

*1:景気よくホールケーキを買おうかと思ったけど太るのでやめました

*2:まれに本当の問い合わせも来るので迷惑メール扱いにできないのがつらい

銀座Railsで「個人でつくるwebサービス」という話をした

昨日行われた銀座Railsで登壇させていただきました。

資料はこちら。

所感

複数の主張したいことを一つの発表に盛り込んでしまったので、ちょっとぼんやりした発表になってしまったかもなーという反省があります。

  • 個人でwebサービス作るのはいいぞ
  • 個人開発をモチベーションを落とさず継続する仕組み
  • Railsは個人or少人数で小規模なサービスを作るのに向いてるめっちゃべんりなライブラリなんですよ
  • turbolinks&stimulusはいいぞ
  • rubocopデフォルト設定でも全然普通に開発できるんですよ

それぞれのトピックごとにもっと深掘りして(もしくは角度を変えて)話せそうなので、どこか別の機会があれば喋ろうかなと思います。

次は3月のRails Developers Meetup 2019で登壇予定ですが、今回とは全く違った話になる予定です。乞うご期待。