こちらはエムスリー Advent Calendar 2023の15日目の記事です。
デジスマチームの田口です。 去年のアドベントカレンダーでイベント駆動アーキテクチャの記事を書きましたが、ありがたいことにデジスマ診療のサービスとしての成長も著しく、開発も一層活発になっています。
去年と比較してどれくらいスケールしたかはCTOでありデジスマPdMでもある山崎さんがpmconf 2023で発表した資料にもあるので、興味がある方は是非ご覧ください。
本記事では去年の記事でも少し触れた「冪等性」について、デジスマチームでの考え方・実装についてまとめます。
なぜ冪等性が重要か
デジスマ診療(以降デジスマ)はQRコードによるチェックインや自動後払い、オンライン診療など新しい医療体験を提供するサービスです。
デジスマではマイクロサービスアーキテクチャを採用しており、各業務ドメインを扱うサービスや、フロントエンド向けの所謂BFFに相当するサービスが存在します。 執筆時点で15個ほどのマイクロサービスが稼働しており、サービス間の通信はRESTベースのHTTP通信で行われます。
ネットワーク上での通信が多くなりますが、サービス間の通信は様々な理由で失敗することがあります。 特にネットワークエラーなど、呼び出し側では処理の成否が確認できないケースも多々あります。 また、基本的にトランザクションがサービス毎に閉じるため、複数のドメインを跨ぐようなユースケースでは途中で処理が失敗した場合に中途半端な状態になる可能性もあります。
これらの課題を解決する方法の1つとして、デジスマではどのAPIも気軽にリトライできる状態にしておくことで、何らか通信が失敗してもリトライにより復帰できるようにしています。 API実装時に何度同じリクエストが来ても同一の結果を返す、つまり冪等になるように気を付けることで、一時的なエラーをリトライ*1でカバーできるようにしています。
Idempotency-Key ヘッダ
HTTP通信で冪等性を担保するために提案された、Idempotency-KeyヘッダというIETF Draftがあります。 ここではPOSTやPATCHといった冪等ではない(可能性がある)メソッドに対して、主に次のような仕様を必須としています。
Idempotency-Key
というkeyのヘッダにUUIDなどの文字列をvalueとして指定する。- 異なるリクエストに同じIdempotency-Keyヘッダの値を指定してはならない。
- リソースの同一性はリソースオーナーが定義し、クライアントがその通りに実装しなければならない。
他にも任意の仕様として、次のようなものもあります。
- idempotency keyに有効期限を設定しても良い
- リクエストの同一性検証のため、リクエストペイロードのチェックサムなどのフィンガープリントを組み合わせても良い
実際にStripe APIやAmazon Pay APIなど、これらの仕様をベースに実装されているものもあります。 やはり支払い系など多重にリソースが作成されると大きな問題になりやすいAPIほど、積極的に実装されている印象です。 デジスマにも医療機関に自動で支払いをする機能があるため、冪等性は重要な要素になります。
デジスマではこのIdempotency-Keyヘッダをベースに冪等性を担保する仕組みを実装しています。 今の所APIはデジスマサービス内での利用のみのため、次のような方針となっています。
- Idempotency-Keyヘッダ相当の独自ヘッダに文字列(特に理由がなければUUID)を指定する
- リクエストの同一性はクライアントサイドで管理し、サーバーサイドで検証等は特にしない(フィンガープリントを用いた検証等をしない)
- サーバーサイドでユースケース毎にidempotency keyの有効期限を設定する
デジスマでの実装
上記冪等性の方針に関して、デジスマでの実装について紹介します。 医療機関に新規予約をする時の疑似コードを例にすると、次のようになります。
// idempotency keyと1対1となるようなIDを生成 val idempotencyKey = request.headers["Idempotency-Key"] val reservationId = idempotencyLockRepository.getLockOrSet( key = "create-reservation-${authContext.userId}-$idempotencyKey", value = UUID.randomUUID().toString(), ).let { UUID.fromString(it) } // 既に存在する予約IDの場合はクライアントに返却 val reservation = reservationRepository.find(reservationId) if (reservation != null) return reservation // 新規登録処理 ...
仕組みとしては、ユースケース毎にidempotency keyに対応する何らかの文字列(今回の場合は予約ID)をグローバルに管理しています。
idempotencyLockRepository
はAmazon DynamoDBを使って文字列を管理しており、次のようなコードになります。
fun getLockOrSet(key: String, value: String, ttl: Duration = Duration.ofDays(1)): String { return dynamoDbClient.updateItem( UpdateItemRequest.builder() .tableName(TABLE_NAME) .key( mapOf( "key" to AttributeValue.builder().s(key).build(), ), ) .updateExpression("SET #value = if_not_exists(#value, :value), #ttl = if_not_exists(#ttl, :ttl)") .expressionAttributeValues( mapOf( ":value" to AttributeValue.builder().s(value).build(), ":ttl" to AttributeValue.builder().n(Instant.now().plus(ttl).epochSecond.toString()).build(), ), ) .expressionAttributeNames( mapOf( "#value" to "value", "#ttl" to "ttl", ), ) .returnValues(ReturnValue.ALL_NEW) .build(), ).attributes()["value"]!!.s() }
DynamoDBのテーブルについて、key
というハッシュキーに呼び出し元から渡されたキー文字列と、value
というフィールドにidempotency keyに対応する文字列、ttl(有効期限)をセットするよう試みます。
呼び出し元にはハッシュキーに既に value
がセットされている場合はその値を、セットされていない場合は渡された値を保存して返却します。
これにより一度リクエストされたidempotency keyに対しては毎回同じ予約IDが発行されます。
ハッシュキーを指定する際にidempotency key以外のものも文字列に含めるようにしています(今回の例では "create-reservation-${authContext.userId}-$idempotencyKey"
)。
idempotency keyはクライアントから指定されるので、攻撃者に漏れたり推測しやすい値の場合に悪用されるのを回避するためにこのような形式になっています。
今回は認証情報である(と仮定してください) authContext.userId
を含めることで安全にしています。
また、この実装では並列にリクエストが来た場合に完全に冪等にならないケース*2もありますが、大きな問題になることはないため実装しやすさを優先しています。
おわりに
今回はHTTP通信で冪等性を担保する仕組みと、デジスマでの実装について紹介しました。 Idempotency-KeyヘッダもまだDraftではあるので、今回紹介したものは一例として、実装の際は何を実現したいかを最初に検討するのが良さそうに思います。 また、今回の例だとビジネスロジックにidempotency keyが入り込んでしまうため、フレームワークのinterceptorやmiddlewareといった層に実装するのも良さそうです。
We are hiring!!
エムスリーでは絶賛エンジニアを募集中です! デジスマ診療以外にも様々なプロダクトがありますので、ご興味ある方は是非カジュアル面談等ご応募ください!