🔔

汎用性の高い通知の設計

2022/02/19に公開

通知まわりのノウハウが溜まってきたので整理。

通知の種類

通知には2種類存在する。Push通知とPull通知。Push通知はスマホとかの通知で、Pull通知はサイトにアクセスした際に表示される通知。Pull通知はよくベルマークで表示されることも多い。

今回はPull通知をメインに話す。Push通知はやったことないのでよくわからないけど、応用できる内容もあるかも?

以下の説明はzennのような記事投稿アプリを例にする。

DB設計

Pull通知のDB設計は以下の通り。

  • 宛先userId
  • メッセージ
  • url
  • 作成日時
  • イベントタイプ

宛先userIdは通知を表示したいユーザーのid。
メッセージは通知で表示する内容そのもの。
urlは通知をクリックした際のページ移動先(ディープリンクっていうらしい)。
イベントタイプは、どのイベントをもとに通知を発火したかどうかの種別。イベントん関しては後で詳しく説明するが、例えば、誰かが記事にいいねをしたら、"article.like.added"のようなものが入ってくる。

もし、バックエンドにurlを保持することにいモヤッときた方がいれば、記事の最後のコラムを見てほしい。僕もそう思った。

API設計

通知のAPIは取得のみになる。通知の保存はAPIではなく、イベント駆動的に、何かが行われたあとについでに実行されるからだ。

通知取得

ユーザーがログインした際に、フロント側で通知APIを発火する。通知APIは宛先userIdがログインユーザーと等しい通知データを取得し、表示する。

通知保存

例えば、誰々がいいねしましたという通知を通知テーブルに保存したいとする。これはいいねAPIの最後に非同期で宛先userIdとメッセージとurlを作成し、保存してあげれば良い。

既存のAPIにイベントという概念を付与する。

通知はイベントソーシングの一種だと思う。イベントの代わりに、メッセージと宛先userIdを保存しているだけ。そこで完全にイベントソーシングにはしなくても、部分的にその考え方を取り入れることで、通知が作りやすくなる。

例: 記事にいいねいいねをするAPI

いいねAPIがあるとする。例えばこんなHTTPリクエスト。

// #PUT example.com/articles/:articleId/like

// リクエストボディ
{
  makeLike: boolean; // trueならいいねする。falseならいいね外す。
}

そしてこのAPIを叩くと、ArticleServiceaddLikeメソッドが呼ばれるとする。こんな感じかな?

ArticleService.ts
ArticleService {
  addLike: (args: {
    articleId: number,
    requestUser: user,
  }) => {
    // 記事idをもとに記事を取得。
    const article = ArticleRepository.findById(args.articleId);
    // 記事にいいねを付与。
    article.addLike(args.requsetUser.id);
    // 記事を保存。
    ArticileRepository.save(article);
  }
}

ArticileRepositoryは記事DBに保存するやつ。

ここで、いいねされたことを記事主に通知したいとする。ArticleServiceの最後にドメインイベントを実行する。

ArticleService.ts
import { doArticleLikeAddedFlow } from './doArticleLikeAddedFlow';
import { ArticleLikeAdded } from './articleLikeAdded';

ArticleService {
  addLike: (args: {
    articleId: number,
    requestUser: user,
  }) => {
    // 記事idをもとに記事を取得。
    const article = ArticleRepository.findById(args.articleId);
    // 記事にいいねを付与。
    article.addLike(args.requsetUser.id);
    // 記事を保存。
    ArticileRepository.save(article);
    
    // 非同期でドメインイベントを実行。
    doArticleLikeAddedFlow(new ArticleLikeAdded({
      article: article,
      likeUser: requsetUser,
    }));
  }
}

doArticleLikeAddedFlowArticleLikeAddedが呼ばれた際に実行するものをまとめた関数。ArticleLikeAddedはドメインイベントと呼ばれるもので、いいねしたよというイベントをクラスとして表現している。これをdoArticleLikeAddedFlowないで使ってもらう。

doArticleLikeAddedFlowの部分は今回は関数呼び出しにしているが、例えば外部メッセージシステムでも、独自のpub/subライブラリでもなんでもいいと思う。

doArticleLikeAddedFlow.ts
import { ArticleLikeAdded } from './articleLikeAdded';
export const doArticleLikeAddedFlow = (event: ArticleLikeAdded) => {
  // ここで通知テーブルへ保存する処理を書く
  // 宛先userId
  const destinationUserId = event.article.creatorId;
  // message
  const message = `${event.likeUser.fullName}さんがいいねしました!`;
  // url
  const url = `/articles/${event.article.id}`;
  
  // 保存
  NotificationRepository(new Notification({
    destinationUserId: destinationUserId,
    message: message,
    eventType: event.eventType,
    createdAt: event.createdAt,
    url: url,
  }))
}
articleLikeAdded.ts
import { DomainEvent, EventType } from './domainEvent'
class ArticleLikeAdded implements DomainEvent {
  private eventType: EventType;
  
  private createdAt: Date;
  
  private article: Article;
  
  private likeUser: User;
  
  constructor(args: { article: Article; likeUser: User; }){
    this.eventType = 'article.like.added';
    this.createdAt = new Date();
    this.article = args.article;
    this.likeUser = args.likeUser;
  }
}
domainEvent.ts
type EventType = 'article.like.added' | 'article.like.removed'

export interface DomainEvent {
  eventType: EventType;
  createdAt: Date;
}

イベントタイプは各イベントごとにユニーク。名前の付け方は
名詞 + 過去分詞
にする。リソースがネストする場合は、ドットでつなげる。

ここらへんのイベント周りはStripeのイベントの扱い方が美しいなと思った。
https://stripe.com/docs/api/events

コラム: バックエンド側にフロントの管轄であるurlを持たせるの責務範囲外じゃない?

フロントとバックエンドが別れているとき、DBに通知をクリックした際のリンクを保持することに違和感があった。アプリがどんなurl構造をもつかは、バックエンドの預かり知らぬ領域だし、フロントの責務だと思ってる。でもこの設計だとバックエンドがフロントのurl構造を知ってしまってる。これはどうなんだろう?

実は、僕が最初に試した設計はこれではなかった。イベントと共にイベントを構成するメタデータをDB上に保持して、フロントに渡し、フロントにそのデータをもとにurlを作成してもらっていた。DBの構成はこんな感じ。

  • 宛先userId
  • メタデータ(JSONで保持)
  • 作成日時
  • イベントタイプ

これの何が辛いかというと、メタデータがJSONなので、スキーマとして管理しにくいところ。スキーマとして管理しにくいと、たとえばイベントを構成するリソースのデータ構造が変わった際に、新しいメタデータのJSONの構造が変わるかもしれない。また、どんなイベントがどんなメタデータを持っているのか別で管理しなければいけなくなる(メタデータ用のクラスとか作ってた)。そこらへんがしんどくなってきたので、このやり方はやめた。あとから再構成するのではなく、最初から構成したものを保持するほうがかなり楽ではある。

おまけ

nestjsにおけるドメインイベントの実装例を記事にしました!
https://zenn.dev/dove/articles/40476144e8bf9a

Discussion