PUT vs. PATCH ~Kotlinでの部分更新はどちらを選ぶべきか?

Airレジ ハンディの開発を担当している早川です。

リソースを部分更新したい場合はHTTPリクエストメソッドにPUTまたはPATCHのどちらを使うべきでしょうか?この記事ではPUTとPATCHの比較と、Kotlin x Spring Bootで開発している私たちのチームがPATCHを選んで直面した問題を紹介します。

リソースの部分更新とは?

この記事におけるリソース部分更新とは、フォーム内のウィジェットの値が更新された時に、サブミットすることなくその値がサーバに保存されることを意味しています。例えば下のgifで示した通り、テキストフィールドの値が更新されるとHTTP通信が行われ、「保存しています…」状態となります。Googleドライブのドキュメントやスプレッドシートを編集して即座にクラウドに反映されるのに似た機能と思っていただければ良いと思います。

リアルタイム部分更新

PUTとPATCH

この機能を実装するにあたり私たちのチームで議論となったのが、リソースの部分更新を行う時はHTTPリクエストメソッドにPUTまたはPATCHのどちらを使うべきかです。私たちは直感的にはPATCHだと思いました。なぜなら一括でフォームのサブミットを行わず、特定のウィジェットのみ更新する場合は、すべてのフィールドを送信する必要がないからです。しかしPATCHには冪等性がないということが気にかかり、調査してみるとかなり奥が深かったのでその内容を記したいと思います。

まずはPUTとPATCHについてMDN web docsを参考に簡単にまとめてみました。

メソッド リソースに対する処理 冪等性
PUT 置換 あり
PATCH 部分変更 なし

この定義からも部分更新にはPATCHが向いていそうです。しかし冪等性の問題は解決されないのでもう少し調べてみました。するとPATCHリクエストはPUTリクエストとDELETEリクエストで代替でき、冪等性の担保が可能になることがわかりました(参考: リソースの一部更新におけるURL設計)。要点を引用すると以下になります。

例えば dropbox のようなファイル共有システムを作っているとして、そのファイルのロック機能を提供することになったとします。

/path/to/file に PATCH リクエストを投げても良いのですが、 /path/to/file/lock に PUT リクエストを投げることでロック、 DELETE リクエストを投げることでロック解除、という設計もできます。こうすれば、ロックの有無も GET で取得できるようになります。

こうすることで、単機能で冪等性のある機能を提供することができるようになるわけです。

私たちも「リソースの属性をリソースと捉える設計」については目から鱗でした。これで部分更新の場合もPUTで冪等性を担保することが可能であることがわかりました。

PATCHを選択した理由

部分更新もPUTでできることがわかったためPUTを選択しても良かったのですが、私たちは最終的にはPATCHを選択しました。なぜならPUTの場合、リソースごとにエンドポイントを実装する必要があり、冗長だと感じたからです。具体的には以下に示したようにカテゴリリソースの属性を更新するために、PATCHであれば1つのエンドポイントで済みますが、PUTとDELETEを使うと3つのエンドポイントが必要です。

1
2
3
4
5
# PATCHでカテゴリ名を更新するリクエスト
PATCH /categories/:categoryId { "categoryName": "hoge" }
# PATCHで表示を更新するリクエスト
PATCH /categories/:categoryId { "isDisplayed": true }
1
2
3
4
5
6
# PUTでカテゴリ名を更新するリクエスト
PUT /categories/:categoryId/categoryName { "categoryName": "hoge" }
# PUTで表示を更新するリクエスト
PUT /categories/:categoryId/isDisplayed
DELETE /categories/:categoryId/isDisplayed

PUTかPATCHかの選択を決めかねているとMDN web docsのPATCHのページに以下のような記述を見つけました。

ただし PATCH リクエストがべき等になるようにリクエストすることは可能です。

確かによく考えてみると上で挙げたカテゴリリソースへのリクエストは冪等性が担保されてると言えます。私たちのチームではこの事実を踏まえ、チームの規約として「冪等にならないPATCHリクエストは禁止する」ことにメンバーが合意した上で、PATCHを選択しました。

Kotlin x Spring Boot における問題

PATCHを選択して開発を進めていて、最初はとても順調でした。私たちが意図した通り、汎用性が高くコード量も少ないAPIを実装することができました。しかし開発を進めていくとKotlin x Spring Bootで開発していたが故に大きな問題に直面しました。

その問題とは、リソースにnullableの属性があったときに、nullでの更新がリクエストされているのか項目が送られてきていないかを判定する方法がないという問題です。例えば以下のリクエストで属性aがnullableである状態を考えます。

1
2
3
4
5
# ① aをnullで更新する
PATCH /resource/:id { "a": null, "b": "hoge" }
# ② aをリクエストボディに含めない
PATCH /resource/:id { "b": "hoge" }

①はaをnullで更新すれば良いことが明確です。②は一見aを更新しないように見えますが、リクエストボディを以下のようなKotlinのモデルにマッピングしたときにaはnullになるのでnullで更新されてしまうことになります。

1
2
3
4
data class RequestBody(
  val a: String?,
  val b: String,
)

この問題はWeb上でも議論されています。詳細を知りたい方はstack overflowの記事「Spring MVC PATCH method: partial updates」が参考になります。

現在私たちはこの問題に対して、フロントエンドでリクエストする際にnullableの項目は更新しない場合も送るという方法を取っています。そして、nullで更新したいときはnull送ることにしています。もちろん私たちは現在の解決策に満足しているわけではありません。とは言え後述する回避策も実装コストや複雑度の観点で受け入れられるものではないという状態です。

回避策はあるか?

回避策としてリクエストボディにHashMapを用いる方法やJSON Patchを用いる方法が考えられますがどちらも実装コストが高かったり、複雑でバグの温床になるリスクが高かったりというデメリットを感じ、導入を見送っています。私たちはこれらの2つの方法よりはPUTを用いる方法が良いと感じています。

まとめ

  • Kotlin x Spring Bootで開発している私たちのチームがリソースの部分更新をするHTTPリクエストメソッドにPATCHを選択した
  • nullableの属性があったときに、nullでの更新がリクエストされているのか項目が送られてきていないかを区別できない問題に直面している
  • 実はPUTの方が良かったのではないかと思い始めている