Quantcast
Channel: メドピア開発者ブログ
Viewing all articles
Browse latest Browse all 215

Railsで処理を別クラスに切り出す方法について

$
0
0

こんにちは。メドピアのRuby(Rails)化をお手伝いしている@willnetです。最近はエンジニアが増えた影響か、Railsの質問に答えていることが多いです。

以前、Railsの太ったモデルをダイエットさせる方法についてというタイトルでPOROを使っていこうという話を書きました。その際にコード例などもなるべく多く載せるようにしたのですが、このエントリだけを読んだ状態では、いざ「POROを使ってみよう!」としたときにまだ悩む余地がありそうです。

POROはその名の通り普通のRubyオブジェクトなので、いろんな書き方ができてしまいます。それなりに経験がある人でないと、どのように書いたらいいんだろう…と悩んで時間を使ってしまいそうですね。さらに、複数人で開発しているチームだと書き方のバラツキも気になるところです。きっと、POROを書くときのお作法が決まっている方が開発しやすいはず。

そこで、お作法を決める手助けをするために例を出してみます。

コード例

slackのようなサービスを作っていると想像してみてください。Messageモデルをsaveしたときに、それがhereメンションのときはチャンネル内のアクティブなメンバーのみ、channelメンションのときはチャンネル内のすべてのメンバーに対してMentionを作るという処理をMessageモデルに定義しています。

classMessage< ApplicationRecord
  has_many :mentions
  belongs_to :creator, class_name: 'User'
  belongs_to :channel

  after_create :create_here_mention, if: :here?
  after_create :create_channel_mention, if: :channel?defhere?; end# 省略defchannel?; end#省略defcreate_here_mention
    members = channel.members.active - [creator]
    create_mentions(members)
  enddefcreate_channel_mention
    members = channel.members - [creator]
    create_mentions(members)
  endprivatedefcreate_mentions(members)
    members.each do |member|
      mentions.create!(to: member, chennel: channel)
    endendend

そもそもコールバック使うのどうなの?など議論の余地があるコードですが、そこまで考え出すとこのエントリで取り上げる範囲が広がりすぎてしまうためそのあたりは無視してください*1。このコードからcreate_here_mentioncreate_channel_mentioncreate_mentionsを別クラスに切り出してみるとします。さてどう切り出すのが良いでしょうか。

切り出し方にいろいろな選択肢が存在します。

  • クラスやメソッドの名前はどのような観点で決めると良いでしょうか?
  • メソッドはクラスメソッドにすべきでしょうか。インスタンスメソッドにしたほうが良いでしょうか?
  • クラスは一つでいいでしょうか?切り出すメソッドごとにクラスを作ったほうがよいでしょうか?

これらの点について、僕は自分なりの意見を持っています。それが正しいかはさておき、他の人がどういう観点で判断をしているのかを知ることで、みなさんがPOROを書くときに迷うことが減るのではないかと思います。

POROに切り出した後のコード

説明の前に、切り出した後のコードを載せます。このコードを参考にしつつ、どういう観点で切り出しているのか書いていきます。

classMessage< ApplicationRecord
  has_many :mentions
  belongs_to :creator, class_name: 'User'
  belongs_to :channel

  after_create :create_here_mention, if: :here?
  after_create :create_channel_mention, if: :channel?defhere?; end# 省略defchannel?; end#省略defcreate_here_mentionHereMentionCreator.call(message: self)
  enddefcreate_channel_mentionChannelMentionCreator.call(message: self)
  endend
classHereMentionCreator
  delegate :channel, :creator, to: :messagedefself.call(message:)
    new(message: message).call
  enddefinitialize(message:)
    @message = message
  enddefcall
    members.each do |member|
      message.mentions.create!(to: member, chennel: channel)
    endendprivateattr_reader:messagedefmembers@members ||= channel.members.active - [creator]
  endend
classChannelMentionCreator
  delegate :channel, :creator, to: :messagedefself.call(message:)
    new(message: message).call
  enddefinitialize(message:)
    @message = message
  enddefcall
    members.each do |member|
      message.mentions.create!(to: member, chennel: channel)
    endendprivateattr_reader:messagedefmembers@members ||= channel.members - [creator]
  endend

メソッド名は統一する

POROに切り出したとき、publicなインターフェースはcallもしくはnew(つまりinitialize)で統一するようにしています。基本的にはcallで、インスタンス化したオブジェクトを返すだけでよいときのみnewという使い分けをしています。

まずメソッド名を考え、それから属するクラスを決めるものだ、という言説があるのは知っていて(要出典)以前はそのように実装していました。しかしHereMentionCreatorのようなクラス名をつけることで、callメソッドがhereメンションを作るのだな、と十分推測可能です。またメソッド名が統一されていると「このクラスのメソッド名ってなんだっけ?」とならずに便利なので最近は統一するようにしています。

処理の実態はインスタンスメソッドに書く

今回やろうとしていることは、「hereメンションを作る」と「channelメンションを作る」という手続きを切り出すことです。なので次のようにクラスメソッドで実装する人も時々見かけます。

classHereMentionCreatordefself.call(message:)  
    channel = message.channel
    members = channel.members.active - [message.creator]
    
    members.each do |member|
      message.mentions.create!(to: member, chennel: channel)
    endendend

サンプルコードが簡単なので、なんだかこれでも問題なさそうに見えますね。しかし手続きがもっと多くなるとどうでしょうか。

チャンネルのミュートの概念を追加し、さらにプッシュ通知もするように機能追加したコードを書いてみます。

classHereMentionCreatorPUSH_NOTIFICATION_LIMIT = 100defself.call(message:)
    channel = message.channel
    members = channel.members.includes(:mute_channels).active - [message.creator]

    members.each do |member|
      message.mentions.create!(to: member, chennel: channel, mute: member.mute?(channel))
    end

    not_mute_members = members.reject { |member| member.mute?(channel) }
    not_mute_members.map(&:id).each_slice(PUSH_NOTIFICATION_LIMIT).with_index do |ids, index|
      PushNotificationWorker.perform_in(index.minutes, message.id, uids)
    endendend

これでも読める人は問題なく読めると思いますが、さっきよりも概要を掴みづらくなったのは間違いないはず。

インスタンスメソッドで実装すると次のように書くことができます。

classHereMentionCreatorPUSH_NOTIFICATION_LIMIT = 100

  delegate :channel, :creator, to: :messagedefself.call(message:)
    new(message: message).call
  enddefinitialize(message:)
    @message = message
  enddefcall
    create_notifications
    create_push_notifications
  endprivateattr_reader:messagedefcreate_notifications
    members.each do |member|
      message.mentions.create!(to: member, chennel: channel, mute: member.mute?(channel))
    endenddefcreate_push_notifications
    not_mute_members.map(&:id).each_slice(PUSH_NOTIFICATION_LIMIT).with_index do |ids, index|
      PushNotificationWorker.perform_in(index.minutes, message.id, uids)
    endenddefmembers@members ||= channel.members.includes(:mute_channels).active - [message.creator]
  enddefnot_mute_members@not_mute_members ||= members.reject { |member| member.mute?(channel) }
  endend

callメソッドがcreate_notificationsメソッドとcreate_push_notificationsメソッドを呼ぶだけになり、処理の概要がつかみやすくなりました。また、membersnot_mute_membersもローカル変数からインスタンスメソッドに切り出されたことで、それぞれのメソッドの行数が減り、処理の内容を把握しやすくなっています。

このように、メソッド分割することで抽象化がしやすくなるのがインスタンスメソッドを利用する主な理由です。

こう書くとクラスメソッドでもメソッド分割できるのでは?という意見がでてきそうですが、クラスメソッドで同様のことをやろうとするとクラスインスタンス変数を更新するコードになり、結果としてスレッドセーフではないコードになってしまいます。

一つのクラスには一つの公開インターフェース

今回はHereMentionCreatorとChannelMentionCreatorのように2つのクラスに切り出しましたが、次のように単一のクラスにhereメンションをするメソッドとchannelメンションをするメソッドを定義する人もいるのではないでしょうか。

classMentionCreator
  delegate :channel, :creator, to: :messagedefself.here(message:)
    new(message: message, type: :here).call
  enddefself.channel(message:)
    new(message: message, type: :channel).call
  enddefinitialize(message:, type:)
    @message = message
    @type = type
  enddefcall
    members.each do |member|
      message.mentions.create!(to: member, chennel: channel)
    endendprivateattr_reader:message, :typedefmembers@members ||= if type == :here
      channel.members.active - [creator]
    else
      channel.members - [creator]
    endendend

一見これでも問題なさそうに見えます。しかし仕様が変更されるについてメンテナンスが難しくなってきます。例えば新しくeveryoneメンションもMentionCreatorで扱うようにするとどうなるでしょうか。everyoneメンションは基本的にchannelメンションと同じですが、すべての人が参加しているチャンネル(generalチャンネル)以外ではメンションとして扱われないという仕様です。

素直にMentionCreatorクラスを拡張してみます。

classMentionCreator
  delegate :channel, :creator, to: :messagedefself.here(message:)
    new(message: message, type: :here).call
  enddefself.channel(message:)
    new(message: message, type: :channel).call
  end# 追加defself.everyone(message:)
    new(message: message, type: :everyone).call
  enddefinitialize(message:, type:)
    @message = message
    @type = type
  enddefcallreturnif type == :everyone&& !channel.general? # 追加

    members.each do |member|
      message.mentions.create!(to: member, chennel: channel)
    endendprivateattr_reader:message, :typedefmembers@members ||= if type == :here
      channel.members.active - [creator]
    else
      channel.members - [creator]
    endendend

結果として、callメソッドに分岐が一つ増えることになりました。このように分岐が増えていくと、1つのユースケース(例えばhereメンションのとき)だけについて考えたい状況でも別のユースケース(channelやeveryoneメンションのとき)のコードについて理解しなければいけなくなり、そのぶん可読性が落ちます。また、コードを修正したときに想定していない箇所でバグを仕込んでしまう、というケースも次第に増えていくことでしょう。

最初の例のように1つの処理ごとにクラスを作り、できるかぎり分岐を避けることでメンテナンスしやすくなります。

まとめ

僕がPOROを書くときの書き方について、それぞれ根拠を添えて説明しました。他にも良いやり方はあると思うので「俺はもっと良い書きかたを採用している!」という人がいたらどのように書いているのか教えていただけると嬉しいです(\( ⁰⊖⁰)/)


(☝︎ ՞ਊ ՞)☝︎是非読者になってください


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html

*1:コールバックについてはまた別のエントリでとりあげるかもしれません


Viewing all articles
Browse latest Browse all 215

Trending Articles