「通知」という機能について考える

f:id:YusukeIwaki:20180426131044p:plain

Webサービスを作ってると、メール通知とかアプリのプッシュ通知のような機能が求められることは非常によくある。 「ユーザがいちいちWebページを見に来なくてもいいように通知してあげないといけない」というのは誠にそのとおりだ。

さて、とりあえずRailsで作ったWebサービスがあるとしよう。どうやって通知する?

とりあえず購入時に購入内容をメール通知しよう

「最初からいろんな通知方法を考えて設計するのは無駄だし、とりあえずあとで必要になったらリファクタリングしよう。まずは必要な場所にぺぺって書いてしまおう」

class PaymentRecordController

  def create
    if record = PaymentRecord.create(payment_record_params)
      redirect_to dashbord_path

      send_notification(record)
    else
      flash[:error] = record.errors.full_messages.join("\n")
    end
  end

  private

  def send_notification(payment_record)
    UserMailer.item_purchased(payment_record)
  end
end

はい。

自分が書いた商品レビューに「いいね!」がついたらメール通知してほしいな

「ん、それってユーザ的に本当に必要な"通知"なんだっけ?」という若干の疑問をもちつつ、コントローラに書いて複雑化するレベルには達していないので、とりあえずコントローラに同様に書きます、と。

class LikeController
  def create
    if like = Like.create(like_params)
      send_notificatioin(like)

      ...

  private

  def send_notification(like)
    target_user = like.likeable.owner
    from_user = like.user
    UserMailer.review_liked(target_user, from_user, like.likeable)
  end
end

はい。

アプリを作ることになったので、スマホアプリのプッシュにも対応してほしいな

「まじか!!でも確かに必要だよね。」「でも、いいね!が付いたくらいで通知がくるのはうざいよね。」うーん、コントローラに・・・書く・・・?

class PaymentRecordController

  ...

  private

  def send_notification(payment_record)
    UserMailer.item_purchased(payment_record)
    PushNotification.send_item_purchased(payment_record)
  end
end
class LikeController

  ...

  private

  def send_notification(like)
    target_user = like.likeable.owner
    from_user = like.user
    UserMailer.review_liked(target_user, from_user, like.likeable)
  end
end

はい・・・だんだん危険な香りがしてきましたね。

プッシュ通知は消したら見えなくなるから、アプリ上で "通知した内容一覧" が見れるようにAPIをつくりたい

「なん・・・だと・・・?!」

詰みました・・・。

というのも、通知というのは今までは「投げっぱなし」とおもっていました、メール通知もプッシュ通知も。なので、Rails上で通知に関するモデルは作っていません。つまり、今まで通知した内容はどこからも取得ができません。

「とりあえず今までの内容は取れないのでそこは許してください!」

心機一転、通知まわりの機構を1から作り直せることになりました。さあどうする?

要件としては

  • メール通知
  • プッシュ通知
  • 通知したもの一覧API

です。

# id: integer  primary key
# title: string     not null
# body: string    not null
# created_at: datetime    not null
# notifiable_type: string
# notifiable_id: integer
class NotificationItem < ApplicationRecord
  belongs_to :notifiable, polymorphic: true
  after_create_commit :send_notification

  private

  def send_notification(record)
    NotificationService.new(record).execute
  end
end


class NotificationService
  def initialize(notification_item)
    @notification_item = notification_item
  end

  def execute
    # ここでメール通知とプッシュ通知のロジックがいろいろ
  end
end

いろいろやり方は有ると思います。たとえば、こんな感じでしょうか?

「あれ、これだと通知一覧が作れない?」

そう、各ユーザにどういう通知を届けたのか、という履歴がないと通知一覧は作れませんね。

# id: integer  primary key
# notification_item_id: integer foreign_key
# user_id: integer foreign_key
# created_at: datetime not null
class NotificationHistory < ApplicationRecord
  belongs_to :notification_item
  belongs_to :user
end
class NotificationService
  def initialize(notification_item)
    @notification_item = notification_item
  end

  def execute
    # ここでメール通知とプッシュ通知のロジックがいろいろ

    # 通知した内容を保存する
    NotificationHistory.create!(
      user: target_user,
      notification_item: @notification_item
    )
  end
end
class Api::User::NotificationHistoriesController < Api::ApplicationController
  def index
    @notification_histories = NotificationHistory.where(user: current_user)
                                             .order(created_at: :desc)
                                             .includes([:notification_item])
  end
end
json.notifications @notification_histories do |notification_history|
  json.partial! "notification_item", notification_history.notification_item
  json.sent_at notification_history.created_at
end

みたいなかんじでしょうか。だいぶ端折ってますが

通知文言はユーザのセグメントごとに変えたいな

「む。NotificationItem側にtitle/bodyがあるとダメじゃん!」「じゃあ、NotificationHistory側に・・・とおもったけどこれは履歴テーブルだ」「うーん、結局タイトルとかを入れるのはNotificationService・・・?」

そろそろ辛くなってきましたね。


さて、擬似サンプルコードも書き疲れたのでようやく本題に。

結局 "通知" ってなんなの?

通知って「投げっぱなし」で実装しがちなんですが、大抵の場合はそうではなくて、「リアクティブなイベント購読」と考えるべきなんじゃないかと最近感じ始めてます。

  • 通知が必要かもしれない イベント がある(Pub)
  • イベントを 適宜フィルタ/変換しながら購読する サブシステムがある(Sub)
  • 投げっぱなしに見える通知というのは、Subの create/updateトリガーである

ようは、Railsのコントローラとかでやるのは、「ユーザAがアイテムXを購入した」とか「ユーザBがアイテムYにレビューRを書いた」とか「ユーザAがレビューRにいいね!を付けた」とかそういうイベントをPublishするまで。個々の通知実装はその先で、たとえば

  • Event.where(type: item_purchased).on_new_record(:send_item_purchase_confirmation_notification)
  • Event.where(type: review_liked).on_new_record(:send_review_liked)

みたいな感じで、いい感じにsubscribeする。

そうすると、たとえばアプリのプッシュにくわえてブラウザプッシュも対応したいとかなっても、NotificationHistoryからエスパーして履歴を作ったりしなくても済む。

f:id:YusukeIwaki:20180426130420p:plain

まとめ

とりあえず「通知」っぽいものを作るときには、今すぐ必要でなくても「イベント」と「通知履歴」テーブルは作っておいたほうがいいんじゃないかな。とおもった。以上。

(どうやって実装すればいいのかはまだよくわかっていない