イベントソーシングを用いた通知サービスの設計

目次

はじめに

こんにちは!株式会社ハイヤールーの新谷(@s_shintani)です。この記事はアドベントカレンダー24 日目の記事です。HireRoo では試験の作成、終了、評価といった特定のタイミングで候補者や面接官に向けて自動で下記画像のようなメール通知が届くようになっています。本記事ではこのような通知機能を実現するための設計とその実装についてお話しさせていただきます。

設計

前提として HireRoo ではマイクロサービスアーキテクチャを採用しており、Spot と呼ばれるサービスが試験をドメインとして扱っています。

愚直に実装するのであれば、作成や評価のリクエストが呼ばれたタイミングで Spot サービスから直接メールを送信することも可能です。しかしこのような実装をしてしまうと、Spot サービス内で試験のロジックと通知のロジックの両方を管理しなければならなくなるほか、試験以外のイベントをトリガーとして通知を送信したい場合、それら全てのサービスに通知のためのロジックを実装をしなくてはならなくなります

本来マイクロサービスには各サービスごとに独立した開発・デプロイが可能になるというメリットがありますが、サービス同士の境界が不適切な場合サービス間の独立性が失われ、いわゆる「分散モノリス」と呼ばれるアンチパターンに陥ってしまいます。

また通知を担うマイクロサービス(以下 Notfication サービス)を別に切り出したとしても、Spot サービスから Notification サービスを直接呼び出すのであれば、Spot サービスと Notification サービスとの間に依存関係が生じます。サービス同士が不必要に依存関係を持つことは上記と同じくマイクロサービスの利点を損なうことに繋がります。

そこで私たちは Notification サービスを切り出した上、Spot サービスから Notification サービスを直接呼び出すのではなく、イベントソーシングを活用して Spot サービスのドメインイベントを Notification サービスがサブスクライブすることによりマイクロサービスアーキテクチャの利点を維持できるようにしました。

このようにメッセージングサービスを経由することでイベントメッセージを生成する Spot サービスとイベントメッセージを処理する Notification サービスを切り離すことができます。なお、メッセージングサービスには GCP の Pub/Sub を使用しています。

実装

それでは具体的な実装について見ていきます。

イベントハンドラの定義

まず Notification サービスに Spot イベントのハンドラを定義します。Pub/Sub のトピックに Spot イベントが入ると Push ベースでこのハンドラが呼ばれます。

func (s *notificationService) HandleSpotEvent(ctx context.Context, event *eventPb.Event) (*empty.Empty, errors.ServiceError) {
  eventId := event.Message.Attributes.Uuid

  if err := s.db.CreateNotifiedEvent(nil, &model.NotifiedEvent{EventID: eventId}); err != nil {
     s.logger.Infof("notified event %s already exists", eventId)
     return &empty.Empty{}, nil
  }

  spotEvent := spoteventPb.SpotEvent{}
  if err := proto.Unmarshal(event.Message.Data, &spotEvent); err != nil {
     return nil, errors.NewFromError(err, errors.InvalidRequest, failure.Messagef("failed to parse spot event %s", eventId))
  }

  switch spotEvent.EventType {
  case spoteventPb.SpotEvent_EVENT_TYPE_SPOT_CREATED:
     if err := s.handleSpotCreated(ctx, spotEvent.Details.(*spoteventPb.SpotEvent_SpotCreated)); err != nil {
        return nil, errors.Wrap(err, failure.Messagef("failed to handle spot created event %s", eventId))
     }
  case spoteventPb.SpotEvent_EVENT_TYPE_SPOT_COMPLETED:
     if err := s.handleSpotCompleted(ctx, spotEvent.Details.(*spoteventPb.SpotEvent_SpotCompleted)); err != nil {
        return nil, errors.Wrap(err, failure.Messagef("failed to handle spot completed event %s", eventId))
     }
  case spoteventPb.SpotEvent_EVENT_TYPE_SPOT_EVALUATED:
     if err := s.handleSpotEvaluated(ctx, spotEvent.Details.(*spoteventPb.SpotEvent_SpotEvaluated)); err != nil {
        return nil, errors.Wrap(err, failure.Messagef("failed to handle spot evaluated event %s", eventId))
     }
     // ...more
  }

  return &empty.Empty{}, nil
}

Pub/Sub の Push サブスクリプションにおける配信ポリシーはat-least-onceであるため、イベントが重複して入ってくる可能性があります。その際に通知が重複して送られないように、イベントの ID を DB に保存しておき、すでに ID が存在する場合はハンドラに処理を委ねる前に早期リターンしています。なお、Pull サブスクリプションではexactly-onceの配信がサポートされているため、将来的にはそちらを使用することも視野に入れています。

以下は試験作成イベントのハンドラです。試験作成時の通知対象は基本的には試験の閲覧者(Reviewer)ですが、試験に招待用のメールアドレスが登録されている場合は候補者(Candidate)も通知対象に含まれます。

処理の流れとしては以下の通りです。

  1. イベントメッセージに含まれる試験情報から通知対象となる閲覧者を抽出する
  2. 試験情報をもとにテンプレートを埋めてメール本文を構成する(後述)
  3. 2 で構成したメール本文をすべての閲覧者に送信し、送信ログをデータベースに保存する
  4. 試験に招待メールが設定されている場合は、候補者に対しても同様に通知を行う
func (s *notificationService) handleSpotCreated(ctx context.Context, event *spoteventPb.SpotEvent_SpotCreated) errors.ServiceError {
  // 1. Get all employees from spot reviewers
  reviewers, serr := s.reviewersToEmployees(ctx, event.SpotCreated.Spot.Reviewers)
  if serr != nil {
     return errors.Wrap(serr, failure.Message("failed to extract employees from reviewers"))
  }

  // 2. Fill a message template with spot
  reviewerMsgSet, err := s.fillTemplate(messageTemplate[SpotCreatedForReviewer], event.SpotCreated.Spot)
  if err != nil {
     return errors.NewFromError(err, errors.Internal, failure.Message("failed to fill employee message set"))
  }

  // 3-1. Send emails to each reviewer
  for _, reviewer := range reviewers {
     reviewerMessageId, sErr := s.sendEmail(ctx, reviewer.DisplayName, reviewer.Email, reviewer.Language, reviewerMsgSet)
     if sErr != nil {
        return sErr
     }

     reviewerNotification := &model.EmployeeNotification{
        EmployeeID:    reviewer.Uid,
        CompanyID:     event.SpotCreated.Spot.Company.Id,
        MessageID:     reviewerMessageId,
        TitleJa:       reviewerMsgSet.TitleJa,
        TitleEn:       reviewerMsgSet.TitleEn,
        DescriptionJa: reviewerMsgSet.DescriptionJa,
        DescriptionEn: reviewerMsgSet.DescriptionEn,
        ActionLink:    getDetailLink(event.SpotCreated.Spot, s.env.ServiceUrl),
        Method:        model.Email,
        IsRead:        false,
     }

     // 3-2. Save a notification to display in the client
     if sErr := s.db.CreateEmployeeNotification(nil, reviewerNotification); sErr != nil {
        return sErr
     }
  }

  // 4. Send an invitation email to the candidate
  if event.SpotCreated.Spot.InvitationMethod == spotPb.InvitationMethod_INVITATION_METHOD_EMAIL {
     candidateMsgSet, err := s.fillTemplate(messageTemplate[SpotCreatedForCandidate], mergedSpot)
     if err != nil {
        return errors.NewFromError(err, errors.Internal, failure.Message("failed to fill candidate message set"))
     }

     _, sErr := s.sendEmail(ctx, "", event.SpotCreated.Spot.InvitationEmail, event.SpotCreated.Spot.InvitationLanguage, candidateMsgSet)
     if sErr != nil {
        return sErr
     }
  }
  return nil
}

送信ログを DB に保存しておくことで、通知を HireRoo のアプリケーション内でも確認することができます。

SendGrid によるメール配信

実際のメール送信には SendGrid の API を利用しています。

クライアントの初期化処理と、メール送信用関数のインターフェースを定義します。

type Service interface {
  SendEmail(ctx context.Context, fromName string, fromAddress string, toName string, toAddress string, subject string, plainTextContent string, htmlContent string) (string, errors.ServiceError)
}

type service struct {
  client        *sendgrid.Client
  senderName    string
  senderAddress string
}

func New(ctx context.Context, env *config.Env) (Service, error) {
  client := sendgrid.NewSendClient(env.SendgridApiKey)
  return &service{
     client:        client,
     senderName:    env.SenderName,
     senderAddress: env.SenderAddress,
  }, nil
}

以下はメール送信用の関数の中身です。引数として送信元と送信先の情報、送信内容を受け取りそれらをもとに実際にメールを送信します。

func (s *service) SendEmail(ctx context.Context, fromName string, fromAddress string, toName string, toAddress string, subject string, plainTextContent string, htmlContent string) (string, errors.ServiceError) {
  from := &mail.Email{
     Name:    fromName,
     Address: fromAddress,
  }
  to := &mail.Email{
     Name:    toName,
     Address: toAddress,
  }
  m := mail.NewSingleEmail(from, subject, to, plainTextContent, htmlContent)
  res, err := s.client.Send(m)
  if err != nil {
     return "", errors.NewFromError(err, errors.Internal, failure.Messagef("failed to send an email to %s %s", to.Name, to.Address))
  }

  if len(res.Headers["X-Message-Id"]) == 0 {
     return "", errors.New(errors.Internal, failure.Message("message ID is empty"))
  }

  return res.Headers["X-Message-Id"][0], nil
}

テンプレートを用いた動的なメッセージの生成

メールのフォーマットは共通ですが、メールの内容自体はイベントの種類や試験の情報に応じて異なるため、動的にメール本文を生成する必要があります。私たちは Go の標準パッケージとして提供されているtext/templateを用いることで動的なメッセージの生成を実現しています。

各イベントと通知先ごとにメール本文のテンプレートを用意します。以下は試験作成時に送信される候補者に向けたメールのテンプレートです。

## {{ .Name }} に招待されました

提出期限は **{{ formatDate .WillEndAt }} (JST)** です。
[こちら]({{ serviceURL "c" "interviews" .Id }})から試験を開始してください。

### 選考概要

<table border="1" style="border-collapse: collapse;">
   <tbody>
       <tr>
           <td style="padding:10px;background-color: #ebedef">企業名</td>
           <td style="padding:10px">{{ .Company.Name }}</td>
       </tr>
       <tr>
           <td style="padding:10px;background-color: #ebedef">タイトル</td>
           <td style="padding:10px">{{ .Name }}</td>
       </tr>
       <tr>
           <td style="padding:10px;background-color: #ebedef">面接官</td>
           <td style="padding:10px">{{ .Employee.DisplayName }}</td>
       </tr>
       <tr>
           <td style="padding:10px;background-color: #ebedef">日時</td>
           <td style="padding:10px">{{ formatDate .WillStartAt }} (JST) ~ {{ formatDate .WillEndAt }} (JST)</td>
       </tr>
       <tr>
           <td style="padding:10px;background-color: #ebedef">URL</td>
           <td style="padding:10px">
               <a href="{{ serviceURL "c" "interviews" .Id }}"
               >{{ serviceURL "c" "interviews" .Id }}</a>
           </td>
       </tr>
       {{ if isTruthyString .MessageForCandidate }}
       <tr>
           <td style="padding:10px;background-color: #ebedef">{{ .Company.Name }}からのメッセージ</td>
           <td style="padding:10px">{{ formatString .MessageForCandidate }}</td>
       </tr>
       {{ end }}
   </tbody>
</table>

{{ .変数名 }} となっている部分が変数で、後から試験情報がオブジェクトとして注入されます。アプリケーションコードと同様にオブジェクトのフィールドにアクセスすることができます。またテンプレート内では formatDateserviceURL のような事前に定義したオリジナルの関数を使用することができます。

以下はテンプレートに変数を代入するための関数です。引数としてテンプレートのセット(タイトルと本文がそれぞれ日本語と英語について用意されています。)、任意の変数 v を受け取ります。試験に関する通知の場合、Spot イベントのメッセージに含まれている試験情報が渡されます。

処理の流れとしては以下の通りです。

  1. テンプレート内で使用する関数の定義
  2. テンプレートの初期化
  3. テンプレートの実行(変数の代入)
func (s *notificationService) fillTemplate(msgSet *MessageSet, v interface{}) (*MessageSet, error) {
  // 1. Define original functions
  funcMap := map[string]interface{}{
     "serviceURL": func(paths ...string) string {
        return fmt.Sprintf("%s/%s", s.env.ServiceUrl, path.Join(paths...))
     },
     "formatDate": func(ts *timestamppb.Timestamp) string {
        t := ts.AsTime()
        jst := time.FixedZone("Asia/Tokyo", 9*60*60)
        return t.In(jst).Format("01/02 15:04")
     },
     "join": func(strSlice []string, sep string) string {
        return strings.Join(strSlice, sep)
     },
     // ...more
  }

  // 2. Initialize templates
  titleJaTmpl := textTemplate.New("titleJa").Funcs(funcMap)
  titleEnTmpl := textTemplate.New("titleEn").Funcs(funcMap)
  descriptionJaTmpl := textTemplate.New("descriptionJa").Funcs(funcMap)
  descriptionEnTmpl := textTemplate.New("descriptionEn").Funcs(funcMap)

  var (
     titleJaBody           bytes.Buffer
     titleEnBody           bytes.Buffer
     descriptionJaBody     bytes.Buffer
     descriptionEnBody     bytes.Buffer
     descriptionHtmlJaBody bytes.Buffer
     descriptionHtmlEnBody bytes.Buffer
  )

  // 3. Fill each field in MessageSet one by one in-place
  titleJa, err := titleJaTmpl.Parse(msgSet.TitleJa)
  if err != nil {
     return nil, err
  }
  if err = titleJa.Execute(&titleJaBody, v); err != nil {
     return nil, err
  }

  titleEn, err := titleEnTmpl.Parse(msgSet.TitleEn)
  if err != nil {
     return nil, err
  }
  if err = titleEn.Execute(&titleEnBody, v); err != nil {
     return nil, err
  }

  descriptionJa, err := descriptionJaTmpl.Parse(msgSet.DescriptionJa)
  if err != nil {
     return nil, err
  }
  if err = descriptionJa.Execute(&descriptionJaBody, v); err != nil {
     return nil, err
  }

  descriptionEn, err := descriptionEnTmpl.Parse(msgSet.DescriptionEn)
  if err != nil {
     return nil, err
  }
  if err = descriptionEn.Execute(&descriptionEnBody, v); err != nil {
     return nil, err
  }

  return &MessageSet{
     TitleJa:           titleJaBody.String(),
     TitleEn:           titleEnBody.String(),
     DescriptionJa:     descriptionJaBody.String(),
     DescriptionEn:     descriptionEnBody.String(),
     DescriptionHtmlJa: descriptionHtmlJaBody.String(),
     DescriptionHtmlEn: descriptionHtmlEnBody.String(),
  }, nil
}

将来的に試験以外の通知を行うことも踏まえ、メッセージの生成といった共通関数は Spot に依存せず、interface 型を引数として受け取るなど汎用性を担保している点がポイントです。

まとめ

いかがだったでしょうか?イベントソーシングによりサービス間を疎結合に保つことで、Spot サービスは試験に関するロジックに、Notification サービスは通知に関するロジックに専念することができます。また Spot 以外のサービスのイベントをトリガーとして通知を送信したい場合でも Notification に新しくイベントハンドラを定義するだけで済みます。通知のような汎用的な機能を実装する場合は、将来的な拡張性を最初の設計段階で十分に考慮しておくことが重要ではないかと思います。

最終日の記事は@kkouskeeによる「Building World-Class Product」です。そちらもぜひご期待ください!