こんにちは。メドピアの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_mention
とcreate_channel_mention
、create_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
メソッドを呼ぶだけになり、処理の概要がつかみやすくなりました。また、members
やnot_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:コールバックについてはまた別のエントリでとりあげるかもしれません