酔いどれ設計ナイト2019 - connpassの発表資料です。
- イベントのテーマ
- 「DB設計とAP設計をつなぐナニカ」
ということでこの記事では、アプリケーションサーバの利用者であるクライアントの視点から、どういう構造が嬉しいのか語ります。
自己紹介
-
iOSアプリ設計パターン入門という本の前半で、「設計とは何か」という主語の大きい話をしたり、GUIアーキテクチャの40年の歴史をまとめたりしました
- 題材をSwiftに絞っただけで、内容としては他プラットフォームにも通用する感じのやつなのでよかったらおひとつどうぞ
- Qiitaだと、お前らがModelと呼ぶアレをなんと呼ぶべきか。近辺の用語(EntityとかVOとかDTOとか)について整理しつつ考える - Qiitaという記事がよく読まれてます
議論の前提
今回の議論にはいくつかの前提があります。
- クライアントチームとサーバチームが充分に協調し、コミュニケーションをとれる開発体制である
- API利用者としてはクライアントアプリが念頭に置かれ、不特定多数による利用は想定していない
- クライアントアプリはコードオンデマンド(=プログラムをサーバーからクライアントが受け取り、それをクライアント上で実行するアーキテクチャスタイル)ではない
- アプリのバージョンを上げるタイミングはユーザーに任されている
- サーバは古いクライアントバージョンをサポートする必要がある
- 強制アップデートという手段はないこともないが、さまざまなビジネス判断によって乱発を避けなくてはならない
- レスポンスの表現形式は主にJSONを想定している
- protobufなど別のスキーマ言語では話が違ってくるかも
また、筆者はiOSアプリエンジニアなので、クライアントアプリの実装言語はSwiftやKotlinを想定し、資料中のコード例はSwiftで記述します。
目次
- エンドポイントがこうなっていると嬉しい 編
- バージョニングされ、バージョン内では破壊的変更は加えないでほしい
- レスポンス構造がこうなっていると嬉しい 編
- 同じ概念(リソース)は同じ構造(スキーマ)で表現されてほしい
- どのエンドポイントでも整合性のある情報を返してほしい
- リソースを小さく保ち、コンポジションで表現してほしい
- リソースの制約は、その表現形式よりも高い解像度で存在することを意識してほしい
- ときにはリソースを直和型として捉える
エンドポイントがこうなっていると嬉しい 編
バージョニングされ、バージョン内では破壊的変更は加えないでほしい
バージョニングの必要性については省略。
詳しくは Web API: The Good Partsを読んだので「設計変更しやすいWeb API」についてまとめた - Qiita あるいは元の本を参照。
バージョン内では破壊的変更は加えないでほしい
破壊的変更とそうでない変更って?
非破壊的変更
- キーが増えるのは問題ない(クライアントが認識しないだけなので)
- enumの列挙子が減るのは問題ない(クライアントで処理が使われなくなるだけなので)
つまり、
struct Hoge: Decodable {
enum Kind: String, Decodable {
case dog, cat, bird
}
let id: Int
let kind: Kind
}
struct Root: Decodable {
let before: Hoge
let after: Hoge
}
に対して
{
"before": {
"id": 1,
"kind": "cat" // 👈 "dog" | "cat" | "bird"
},
"after": {
"id": 2,
"kind": "dog", // 👈 "dog" | "bird" (※ "cat" が減った)
"new_value": "🆕" // 👈 keyが増えた
}
}
を与えると、 before
も after
も問題なくデコードできます。
破壊的変更
- 存在していたキーがなくなる
- nullableなキーなら大丈夫? そんなことはない(これについては後述)
-
クライアントと合意していない列挙子が増える
- enum(Swift) や sealed class(Kotlin) はdefault抜きでパターンマッチ可能なので、なるべくそうしたい
つまり、
{
"before": {
"id": 1,
"kind": "cat" // 👈 "dog" | "cat" | "bird"
},
"after": {
// "id": 2, 👈 減った
"kind": "🆕", // 👈 "dog" | "cat" | "bird" | "🆕" (※増えた)
}
}
が与えられると、デコードに失敗します。
let response: Root = decode(json)
// error: "No value associated with key CodingKeys(stringValue: \"id\", intValue: nil)"
// error: "Cannot initialize Kind from invalid String value 🆕"
ちなみに
クライアントと合意していない列挙子が増える
と但し書きをつけたのは意味があって、
クライアントと合意が取れていれば、enum / sealed classに unknown
のようなケースを用意してフォールバックさせる設計は可能です。
enum RobustEnum: Decodable {
case aaa, bbb, ccc, unknown
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
switch try container.decode(String.self) {
case "aaa": self = .aaa
case "bbb": self = .bbb
case "ccc": self = .ccc
default: self = .unknown
}
}
}
struct Fuga: Decodable {
let x: RobustEnum
}
let json = """
[
{ "x": "aaa" },
{ "x": "bbb" },
{ "x": "ccc" },
{ "x": "🆕" },
{ "x": "🤔" }
]
"""
let fugas: [Fuga] = decode(json)
// [{aaa}, {bbb}, {ccc}, {unknown}, {unknown}]
レスポンス構造がこうなっていると嬉しい 編
同じ概念(リソース)は同じ構造(スキーマ)で表現されてほしい
そもそもリソース指向とは?
- 名前が付いている。
- 形式が定まっている(key名, 各valueの型など)。
- jsonであればjson schemaで定められるような感じ。
- 複数のエンドポイント間で使い回されることが想定されている。
引用: RESTful Web API を厳密なリソース指向にする - Qiita
つまり、あるエンドポイントではUserリソースが
{
"id": 42,
"user_name": "名前",
"image_url": "https://..."
}
なのに、別のエンドポイントでは
{
"id": 42,
"name": "名前",
"avatar": {
"url": "https://..."
}
}
だったりすると辛いです。
クライアントは、Userという概念を示すものは全て同じリソースの構造で表現され、同じ User
型へとデコードしたい。
これはモノリシックなサービスだと「まあそうだよね」と言ってもらえそうですが、マイクロサービスだとなかなか厳しいことも多い。
ただ、それらのツケをクライアントが全部払うとなるとキツいので、なるべくなら根っこからスキーマを統一したいなというお気持ちです。
JSON SchemaやOpenAPI、Protocol Buffersのようなスキーマ言語が台頭していることからしても、みんなそう思ってるんだなーと。
クライアントが払いたくないツケを払うために、中間層としてBFF(Backends For Frontends)を立てるってのもひとつの解ではあります。
が、BFF越しではない通信がひとつでも発生すると結局同じ問題が発生し、それを避けるためにはすべてのAPIの土管としてBFFを運用し続ける必要があり辛い。根っこで解決できるならそれに越したことはない。
どのエンドポイントでも整合性のある情報を返してほしい
リソースの構造が揃ったからといって、中のデータが適当だとやっぱり辛い。
- IntのフィールドがたまにStringになるなど、型が違うのは論外
- Objectの中の構造がケースによって違うことは場合によっては可能だが、その場合もよく考えて運用しやすい設計を用意すべき(後述)
- 型が合っていたとしても、埋めるのが面倒なフィールドに空文字や0を突っ込むのは大罪(説明不要ですよね)
- nullableなフィールドだからってnullぶっこむのもやりがちだけどやばい
- nullableなキーは「セットをサボっていい」という意味ではない
具体的な困る局面としては、たとえばクライアントにキャッシュ機構を導入しようとしたら大きな障害になります。
信頼できないデータが少し混ざるだけで全体が汚染されて、後から重大なバグに結びつきかねない。
反論「でも、そのエンドポイントのユースケースでは不要な情報だったら、労力掛けて埋めるのは辛いよ…」
そういうこともありますよね。
たとえばこんなUserリソースがあるとします。
{
"user": {
"id": 1234,
"name": "ユーザー",
...
"is_pro": null
}
}
"is_pro"
には課金ユーザーだったらtrueが入るんだけど、課金情報は別サーバから引っ張ってこないといけないので、基本的にはnullにして、必要なエンドポイントでだけBooleanを入れたい。
よくありそうな悩みですが…こうは考えられないでしょうか。
そもそも不要な情報を要求されるのは、リソース設計の粒度が大きすぎているせいでは?
リソースを小さく保ち、コンポジションで表現してほしい
つまり、こうしたほうがいいのでは。
{
"account": {
"user": {
"id": 1234,
"name": "ユーザー",
...
},
"is_pro": true
}
}
Userから "is_pro"
を切り出せば、普段はUserとして必要最低限の情報だけを取り扱うことができます。
- SOLID原則のI: インターフェイス分離の原則(Interface Segregation Principle)
- クライアントに、クライアントが利用しないメソッドへの依存を強制してはならない
上位の概念は継承ではなくコンポジションで表現してほしい
「継承よりコンポジション」という金言があります。
is_pro
を外に切り出してAccount概念とUser概念を分離したとき、
それは Account is an User
という継承関係を Account has an User
というコンポジションで捉え直したと見ることができます。
このコンポジションの構造は強い静的型付け言語で書かれたクライアント的にはとても扱いやすいです。
SwiftのstructもKotlinのdata classも継承はできません。
これは機能の不足ではなく、リスコフの置換原則が容易に破られうるという歴史的反省から意図的に導入されている言語設計です。
ライフサイクルの違うデータもコンポジションで表現してほしい
別の観点からリソースを切り分けたいものもあります。
{
"user": {
"id": 1234,
"name": "ユーザー",
...
"is_follwed_by_me": true,
"followers_count": 530000,
"followees_count": 1
}
}
ユーザーの基本情報はそうそう変化しませんが、
フォロー状態やフォロワー数などの情報はフォロー/アンフォローのたびに変化します。
これらの情報は、独立したエンドポイントでやりとりすることが多いでしょう。
そのエンドポイントでUserリソース全体を引き回すのはけっこう骨が折れます。また、リソースをイミュータブルに扱えないのもつらいです。
{
"followable_user": {
"user": {
"id": 1234,
"name": "ユーザー",
...
},
"follow_info": {
"user_id": 1234,
"is_follwed_by_me": true,
"followers_count": 530000,
"followees_count": 1
}
}
}
リソースを分けると取り扱いやすくなりました。
リソースの制約は、その表現形式よりも高い解像度で存在することを意識してほしい
あんまりうまく言い表せなかったんですが、すみません。
つまり、リソースの表現形式がJSONだったとき、JSONの型には String, Number, Boolean, Object, Array, Null という種別がありますが、リソースに課せられる制約はそれよりもっと強いはず。
JSON SchemaなりOpenAPIなりでバリデーションかけとけよ、って話かと思われるかもしれませんが違います。
たしかに、たとえばJSON Schema Validationでは正規表現やURLなどのフォーマットを使ったバリデーションができますが、それだけでは足りない。
- enumとなる文字列をタイポする
- numberにありえない負数が入る
- urlなのに空文字などのurlとして成立しない文字列が入る
などはSchemaのバリデーションに頼ればなんとかなるけど、それ以前の意識が伴ってないときついパターンもあります。
ただのIntではない典型例: id
{
"items": [
{ "id": 1234, "type": "user", "name": "桃太郎", ... }, // 👈このidは user_id を示す
{ "id": 1234, "type": "group", "name": "鬼ヶ島滅ぼし隊", ... }, // 👈このidは group_id を示す
...
]
}
同一のリソースとして User と Group が扱われています。idは同じNumberなのでフィールドを使い回していますが、これは正しいでしょうか?
表現形式の都合上Numberという型に丸められてしまうけど、これらのidは、本来はまったく別物のリソースのはず。
プロトコルとして通るからといって取り決められた仕様を満たしていないデータを入れるのは、本質的には、NumberのフィールドにStringを入れるのと変わらない罪といえます。
静的型付け言語なら、型ごと分けてしまいたいところです。1
struct UserId { let value: Int }
struct GroupId { let value: Int }
id
と名付けられたフィールドは特に気をつけて、「そのリソースをidentifyするためのポインタ」として扱えるかを意識したいです。
RESTful APIで GET hoges/{id}
で一意に特定できないようなidであれば、何かが間違っているはず。
上記例なら、同じリソースなのにtypeによってidの指すものが変わり GET items/{id}
でリソースが定まらないとしたら、それはItemリソースのidではない。
キーを区別したほうがよさそう。
{ "type": "user", "user_id": 1234, ... }
{ "type": "group", "group_id": 1234, ... }
ライフサイクルの違うデータ
と id
の合わせ技の例
たとえば Task
というリソースを考えてみます。
マスターデータがあり、そのタスクにユーザーがassignされる
{
"task": { // 未アサインのタスク
"id": 1,
....,
"assigned_id": null, // nullable。assignされたら生える
"created_at": null, // nullable。assignされたら生える
"assignee": null // nullable。assignされたら生える
},
"task_assigned": { // アサインされたタスク
"id": 1,
....,
"assigned_id": 2,
"created_at": "2019-04-12T19:05:00.000+09:00",
"assignee": { ... }
}
}
これも、 Taskのマスターデータ
と Taskのアサイン情報
というライフサイクルの違うふたつのリソースをごっちゃにしてしまっています。
さらに、 id
がリソースを特定するための役に立ちません。
コンポジションで表現すれば、idとリソースが適切に紐付き、扱いやすくなります。
{
"task": {
"id": 1,
....
}
"assigned_task": { // assigned_task has a task という関係に整理
"id": 2,
"created_at": "2019-04-12T19:05:00.000+09:00",
"assignee": { ... },
"task": {
"id": 1,
....
}
}
}
ときにはリソースを直和型として捉える
あるkeyの値によって存在したりしなかったりするkeyはよくあるよね? どうするの?
その場合は、objectでグルーピングしていてほしい
objectであれば直和型、Tagged Unionと見做せるのでアリ
という話を書きかけでずっとこの記事が止まっていたのですが、その後、SekkeiKaigi - connpass
にてようやく発表しました。
タイミングを見てQiita記事にもしようと思っています。
APIレスポンスにおける直和型の表現を考える / 20190730 sekkeikaigi - Speaker Deck
まとめ
というわけで、以上、
- エンドポイントがこうなっていると嬉しい
- バージョニングされ、バージョン内では破壊的変更は加えないでほしい
- レスポンス構造がこうなっていると嬉しい
- 同じ概念(リソース)は同じ構造(スキーマ)で表現されてほしい
- どのエンドポイントでも整合性のある情報を返してほしい
- リソースを小さく保ち、コンポジションで表現してほしい
- リソースの制約は、その表現形式よりも高い解像度で存在することを意識してほしい
- ときにはリソースを代数的データ型として捉える
というのがアプリクライアントが期待する設計でした。
-
こういうときにはスキーマ言語でも、protobufなら別の型を用意できるけど、そういう慣習はあるんだろうか? 詳しい人に聞いてみたい。 ↩