🎃

Outgoing Webhookの実装方法について考えてみた

2023/05/12に公開

はじめに

株式会社CastingONEでバックエンドエンジニアをしている村上です。

弊社システムでは派遣会社などのお仕事や社員さんのデータを管理しており、最近そのデータを外部システム(お客さんの基幹システム)に送信するWebhookを実装しました。既にリリースされており、日々新しいデータをお客さんのシステムにお届けしています。

今回は、Webhookを実装する中で気づいたことや反省点をまとめます。これからWebhookを実装する方にとって、すこしでもプラスの情報になれば嬉しいです。

実装するもの

大きく分けると、下記の3つを実装しました。

  • Webhookの設定画面
  • 更新イベントの保存
  • Webhookの送信

Webhookの設定画面

Webhookは外部システムに対するHTTPリクエストなので、送信先のURLが分からなければお話になりません。まずは設定画面を用意して、お客さんが任意のURLを入力できるようにしました。

任意といっても、最低限のバリデーションはかけています。localhostや127.0.0.1を設定されると自分自身にリクエストを送ってしまいますし、DNSでの名前解決ができないURLも通信エラーになってしまいます。これらはバリデーションをかけてNGとしています。

また、「本当にCastingONEからのリクエストなのか?」を検証できるように、シークレットトークンも用意しました。

Webhook設定画面

更新イベントの保存処理

データを更新した直後にそのままリクエストを送信すると、エラーが発生した場合のリトライが悩ましくなります。簡単にリトライできるように、更新イベントをDBやメッセージングサービスに保存して、それをもとに非同期でWebhookを送信することになると思います。

弊社では、お手軽感のあるGCPのCloud Tasksを選びました。ただ、Cloud Tasksでも色々とハマったので、それについては下記のページにまとめています。
https://zenn.dev/castingone_dev/articles/70a9901364c8c0

更新イベントの保存は、レイヤードアーキテクチャにおけるUseCase層の中でデータを更新した後に行うようにしました。これにはデメリットがあって、Webhook送信対象のデータを更新するUseCaseが増えたときに、イベントの保存が漏れがちであることです。

例えば、よくある単一データのCRUDに加えて、「◯◯項目の一括更新機能を作ろう!」といったケースです。ここで実装を忘れると、データを更新しただけでWebhookが送信されないバグにつながります。

組織の規模が大きくなるとこのルール(データを更新した後はイベントも保存する)の徹底はどんどん難しくなりますし、伸び代がありそうなところです。

代替案としてデータ更新とイベント保存の共通化(下記コードを参照)も考えましたが、下記の理由でボツにしました。

  • 変更前のデータがWebhookで送信される可能性がある
    • この方法だとコミットよりもイベント保存が先になってしまうので、Cloud Tasksのディスパッチが先行して変更前のデータを検索してしまうかも
    • タスクのScheduleTimeを遅らせるとしても、加減が難しい
  • コミットに失敗するとイベントだけ残る

※ Cloud TasksではなくDBでイベントを管理すると解決するので、DBもありだったのかもなぁ...と思ってます。

func (uc *useCase) updateData(ctx context.Context, data *domain.Data) error {
    tx, err := db.Begin()
    if err != nil {
        return err
    }

    // 他の処理

    err = updateDataAndCreateWebhookEvent(ctx, tx, data)
    if err != nil {
        return err
    }

    // 他の処理

    if err := tx.Commit(); err != nil {
        // コミットに失敗してもイベントは保存されたまま
        // 成功しても、コミット前にWebhookが送信される可能性あり
        return err
    }
}

// データ更新とイベント保存を共通化したメソッド
func (uc *useCase) updateDataAndCreateWebhookEvent(ctx context.Context, tx *sql.Tx, data *domain.Data) error {
    err := uc.repository.UpdateData(ctx, tx, data)
    if err != nil {
        return err
    }

    // Webhook
    return createWebhookEvent(ctx, data.ID)
}

Webhookの送信処理

Webhookを送るためのAPIエンドポイントを用意して、Cloud Tasksからリクエストを受けるようにしました。

  1. メッセージから該当データのIDを取り出して、
  2. IDでそのデータを検索して、
  3. JSON形式でPOSTリクエストを送信する

...みたいな流れにしました。

データの登録 -> 更新 -> 削除の順番でイベントが作成されたとしても、登録 -> 削除 -> 更新の順番でリクエストが届く可能性もあります(特にリトライが発生した場合)
その場合は更新のWebhookを送らないなど、イレギュラーなケースの考慮も必要でした。

また、後からリクエストのデータ形式を変更すると受け取る側(外部システム)の変更も必要になるので、リクエストの内容を精査して、変なところはリリース前に修正した方が無難だと思いました。それ以外はわりと一直線な実装でした。

おわりに

というわけで、Outgoing Webhookの実装を振り返ってみて、いろいろ書き連ねてみました。なにかの参考になれば嬉しいです。

弊社でいっしょに働いてくれるエンジニアを募集中です。社員でもフリーランスでも、フルタイムでも短時間の副業でも大歓迎なので、気軽にご連絡ください!

https://www.wantedly.com/projects/1130967

https://www.wantedly.com/projects/768663

Discussion