RESTに囚われずパスパラメータを排除したAPI設計について考えてみた
この記事は、Finatextグループ Advent Calendar 2024の1日目の記事です。
株式会社Finatextでサーバーサイドエンジニアをしている阿部です。
2024年8月にFinatextに転職し、サーバーサイドエンジニアとして働き始めて4ヶ月ほど経過しました。
現在、新規サービスの開発に従事しており、その中でフロントエンドや別システムからコールされる内部APIの設計・実装を行いました。
今回はそこで採用したパスパラメータを用いないAPI設計についてこの場で共有させていただければなと思います!
従来のREST API設計とその課題
RESTにおいてURIは識別されたリソースを表現するもの[1]と考え、一般的に一意で明確な名詞をつけます。例えばユーザー情報を表すURIとして、
http://example.jp/users/1
のように設計します。この場合、1
はユーザーを一意に識別するIDを想定しています。
これに対し行いたい操作(=動詞)がGET, POST, PUT, DELETE等になるということです。
このようにURIが情報がある場所を一意に示していることによって、どのリソースに対してどのような操作を行おうとしているのかが明確になります。
パスパラメータを含むURIの課題
画面のあるシステムの場合、各ページが特定のリソースやインデックスを表すため、このような設計が適していると言えます。
しかしシステム間のAPIという観点から考えると、パラメータの場所が分散するというデメリットがあります。パラメータの一部はパス、他はクエリやボディと分かれてしまっており、実装やテストが煩雑になってしまう側面があります。
パスパラメータをなくすアプローチ
そこでいっそのこと、内部向けAPIではパスパラメータを用いないという選択肢があっても良いのでは?と考えました。
ここではパスパラメータを用いないAPIのことをNoPP(ノップ; No Path Parameter)なAPIと呼ぶことにします。
NoPPの設計方法
NoPPの設計は以下のような流れで行います。
-
まず従来のRESTで考える
ユーザー(userId
)の持つペット(:id
)の情報を指すパスは/users/:userId/pets/:id
-
パスパラメータを
p
に置き換える
/users/:userId/pets/:id
→/users/p/pets/p
※p
はパラメータ(Parameter)の意味。別の文字でも良い。 -
p
に置き換えたパラメータをGETならquery、その他のメソッドの場合はbodyに持っていってrequiredとする- GET:
/users/p/pets/p?userId=123&petId=456
- POST, PUT, DELETE等:
{ "userId": "123", "petId": "456" }
- GET:
そもそもなぜREST起点で考えるのか
上記の例をみて「パスパラメータさえ排除できていればいいのだから、もっと自由にパス設計をしてもいいのでは?」となるかもしれません。チーム内でパス命名規則を作り、それで運用できれば問題ないのではないかと。
しかしゼロからルールを作りチームに浸透させ運用していくよりも、すでに広く知られ馴染みのあるRESTに乗っかるところからはじめ、最後に機械的な処理でNoPPにするほうがより簡単で設計コストも低いと考えました。
メリット
NoPPにすることにより、以下のようなメリットがあります。
-
同じ処理に対して常に同じパスを使用する
例えば、ユーザーのペットに関する情報にアクセスしたい場合、userId
やid
によらずパスが常に/users/p/pets/p
になる。(やりたい処理が同じならパスが同じ)
従来であれば、"ユーザーのペット情報を更新する"という処理を行うとき、POST /users/1/pets/1 POST /users/1/pets/2 POST /users/1/pets/3 …
となるが、NoPPでは
POST /users/p/pets/p
となってパスが同じになる。
-
パラメータがまとまる
例としてユーザーの情報をPOSTする場合を考える。■ 従来 POST /users/1 { "field": "hoge" } ■ NoPP POST /users/p { "userId": 1, "field": "hoge" }
クライアント側
従来の設計に基づいてGoでクライアント側を実装し、リクエストを組み立てようとすると以下のようになる。// 従来 type PathParam struct { ID int } type Body struct { Field string } serverURL, _ := url.Parse("http://example.jp") pathParam := PathParam{ID: 1} body := Body{Field: "hoge"} requestPath := fmt.Sprintf("/users/%d", pathParam.ID) queryURL, _ := serverURL.Parse(requestPath) buf, _ := json.Marshal(body) bodyReader = bytes.NewReader(buf) req, err := http.NewRequest("POST", queryURL.String(), bodyReader)
NoPPの場合はパスパラメータがなくなるため、以下のようにパラメータはBodyだけですむ。
// NoPP type Body struct { ID int Field string } serverURL, _ := url.Parse("http://example.jp", "/users/p") body := Body{ID: 1, Field: "hoge"} buf, _ := json.Marshal(body) bodyReader = bytes.NewReader(buf) req, err := http.NewRequest("POST", serverURL.String(), bodyReader)
サーバー側
GoのEchoを使って実装している場合、パラメータをstructにbindしようとすると以下の様な実装になる。// 従来 type User struct { ID int `param:"id"` Field string `query:"field"` // paramとqueryが混在。 } e.PUT("/users/:id", func(c echo.Context) error { var user User if err := c.Bind(&user); err != nil { ... } })
一方、NoPPの場合はすべて
query
に統一できるため、// NoPP type User struct { ID int `query:"id"` Field string `query:"field"` // queryのみ }
となり、よりシンプルなstructとなる。
また別の例として、PATCHでユーザー情報を更新する場合を従来とNoPPで比較してみる。
クライアント側
■ 従来PATCH /users/1/pets/1 { "userName": "update", "petType": "hoge" }
type Pet struct { userId int `param:"userId" json:"-"` id int `param:"id" json:"-"` userName string `json:"userName"` petType string `json:"petType"` } e.PATCH("/users/:userId/pets/:id", func(c echo.Context) error { var pet Pet err := c.Bind(&pet) ... })
■ NoPP
PATCH /users/p/pets/p { "userId": 1, "id": 1, "userName": "update", "petType": "hoge" }
// NoPP type Pet struct { userId int `json:"userId"` id int `json:"id"` userName string `json:"userName"` petType string `json:"petType"` } var pet Pet err := json.Unmarshal([]byte(body), &pet)
このようにNoPPでは
- パラメータからどのリソースを更新しようとしているのかがわかりやすい
- structに余計なタグを付ける必要がなくなる
というメリットもある。
-
ハンドラー単位の統計やログを取得するクエリやコマンドが簡素になる
例えば、ユーザーのペットに関する情報に関するログをCloudWatch Logs Insightで調べる際、NoPPであればfields @timestamp, @message | sort @timestamp desc | filter path = '/users/p/pets/p'
として、ハンドラー単位のログを取得することできる。
従来のパス(
/users/:userId/pets/:id
)の場合は何かしらの方法で集約する必要がある。fields @timestamp, @message | sort @timestamp desc | filter path = '/users/*/pets/*'
のようにワイルドカードが使えればよいのだが、ワイルドカードが使えたとしてもLIKEとなってしまうため、
/users/:userId:/owners/:ownerId/pets/:petId
のように*部分に更に階層があるものもヒットしてしまう。
デメリット
-
サーバーによってはDELETEのリクエストボディに対応していない可能性がある
DELETEにリクエストボディを設定すること自体は禁止されていないが[2]、サーバーが対応していない場合は使用することができない。特に古いサーバーの場合は注意が必要です。
実際にNoPPにしてみて
パスパラメータを用いないAPIにすることで、
- メソッド+パス:なにをしている?
- パラメータ:具体的にどの情報を取ろうとしている?
という解釈で統一できるようになりました。
またパラメータがクエリとボディだけになるため、テスト時に値を設定する場所が少なくなったのも良かったです。
ただし、URIが識別されたリソースを表現するという役割をもたなくなるため、以下のようなことが起こるのは抑えておきたい点です。
- パスにリソースの情報(
userId
やpetId
)が含まれなくなるので、URIのみからは具体的にどのリソースを指しているかがわからなくなる。(RESTfulではなくなる) - 従来の設計ではURIは1つのリソースを指していたが、NoPPでは抽象度の高い概念を指すIdentifierとして機能するようになる。
- 従来:
/users/:userId/pets/:id
→ ユーザーの持つペットを具体的に1つ指す - NoPP:
/users/p/pets/p
→ ユーザーの持つペットという概念を指す
- 従来:
結論
今回はシステム間で用いられるAPIの設計方針としてNoPPを紹介させていただきました。
NoPPのメリットを主張しましたが、NoPPにすることでRESTfulではなくなるというデメリットも存在します。
そのためRESTの代替案ではなく、あくまで内部向けAPIの選択肢の1つとして捉えていただければなと思います。
Finatextグループでは、サーバーサイドエンジニアを募集しております!
カジュアル面談も実施しておりますので、少しでも興味を持っていただけた方はお気軽にご応募ください!
Discussion