一覧WB-14-1 · Web APIとアプリ構成

ページネーションと楽観的ロック

IDが10・20・30・40の一覧を二件ずつ取得し、途中でID=5を追加してoffset方式とcursor方式を比較します。その後、商品価格をETag付きで取得し、If-Matchによる更新条件と412のエラー本文、APIの版によるデータ形式の違いを確認します。

説明

APIから一覧を何回かに分けて取得するとき、途中でデータが追加されると取得範囲がずれることがあります。また、取得後に別の更新が入っていれば、手元の値をそのまま送り返すと変更を上書きする可能性があります。

IDが10・20・30・40の一覧を二件ずつ取得し、途中でID=5を追加してoffset方式とcursor方式を比較します。その後、商品価格をETag付きで取得し、If-Matchによる更新条件と412のエラー本文、APIの版によるデータ形式の違いを確認します。

1先頭の二件を取得する

IDの昇順で、offset=0・limit=2の一覧を取得します。

(globalThis.firstPage=await(await fetch("/items?offset=0&limit=2先頭から二件")).json(),firstPage一覧と続きの位置を保持)

最初の二件は10と20です。nextには続きの位置を表すcursorが返ります。offsetは先頭から何件飛ばすか、limitは今回返す最大件数です。

2次の取得前に先頭へ一件追加する

一覧にID=5を追加します。

await(await fetch("/insert",{method:"POST"一覧へ追加,body:JSON.stringify({id:5})先頭側に入るID})).json()

一覧は5・10・20・30・40になります。最初に取得した10・20の位置が、追加によって一つ後ろへずれました。

3offsetの続きには重複が出る

二件取得済みとしてoffset=2を指定します。

(globalThis.offsetPage=await(await fetch("/items?offset=2&limit=2現在の先頭から二件飛ばす")).json(),{first:firstPage.ids,next:offsetPage.ids,duplicates:前回との重複offsetPage.ids.filter(id=>firstPage.ids.includes(id))})

二回目は20と30で、20が重複します。offsetは現在の並びの位置を数えるため、前の取得結果の続きとは限りません。削除でも取得漏れが起きる場合があります。

4cursorで最後のIDから続ける

最初の応答にあったnextを使います。

(globalThis.cursorPage=await(await fetch("/items?after="+encodeURIComponent(firstPage.next)続きの位置をクエリへ渡す+"&limit=2")).json(),{first:firstPage.ids,next:cursorPage.ids取得した続き,cursor:cursorPage.next})

続きは30と40で、重複がありません。このAPIのcursorはID=20より後という条件です。挿入された5は既に通過した範囲なので、この取得には入りません。cursorでも全件の固定スナップショットを保証するとは限りません。

5このAPIのcursorの内容

説明用にcursorを復号し、含まれる条件を確認します。

JSON.parse含まれる条件を表示(atob(firstPage.next)この例のBase64を復号)

after=20とorder=idが含まれます。この例はBase64ですが、実際のcursorは署名付きなどサービスごとの形式です。クライアントは通常、構造を作り替えず返された値をそのまま次の要求へ渡します。並びのキーが同値になり得る場合には、一意なIDを加えるなど順序の確定も必要です。

6価格とETagを取得する

商品情報とその版を保持します。

(globalThis.productResponse=await fetch("/product"),globalThis.etag=productResponse.headers.get("ETag")取得時の版を保存,{etag,product:await productResponse.json()その版の商品情報})

価格120とETag="v1"を取得します。ETagはこの表現の版を識別する値として使い、更新時に「この版のままなら変更する」という条件を付けます。

7版が一致する更新

If-Matchに取得したETagを指定して価格130へ更新します。

(globalThis.updated=await fetch("/product",{method:"PATCH",headers:{"If-Match":etag取得時の版を条件にする,"Content-Type":"application/json"},body:'{"price":130}'新しい価格}),{status:updated.status更新の結果,etag:updated.headers.get("ETag"),body:await updated.json()})

版が一致したので更新され、ETagは"v2"へ変わります。クライアントの判定だけでなく、サーバーが現在の版と条件を比較することで、取得後の競合を検出できます。

8古い版での更新は拒否される

保存した古い"v1"を使い、価格140への更新を試します。

(globalThis.conflict=await fetch("/product",{method:"PATCH",headers:{"If-Match":etag古い版を指定,"Content-Type":"application/json"},body:'{"price":140}'}),{status:conflict.status条件不成立の412,type:conflict.headers.get("Content-Type"),problem:await conflict.json()機械処理できる問題情報})

412 Precondition Failedとなります。application/problem+jsonの本文には、問題の種類type、概要title、状態status、詳細detail、対象instanceがあります。古い版の値で更新を続ける前に、現在値を読み直して変更をどう反映するか判断します。

9拒否された変更は反映されない

現在の商品を再取得します。

(globalThis.current=await fetch("/product")拒否後の現在値,{etag:current.headers.get("ETag")現在の版,body:await current.json()})

価格は130、版は"v2"です。失敗した140への更新は反映されていません。競合を検出した後に無条件で再送すると保護を失うため、再取得した情報と意図する変更を照合します。

10APIの版で形式が変わる

v1とv2の表現を取得します。

({v1:await(await fetch("/v1/product")旧形式).json(),v2:await(await fetch("/v2/product")新形式).json()})

v1のpriceは数値、v2はamountとcurrencyを持つオブジェクトです。idも数値から文字列へ変わっています。このような形式変更は既存クライアントの参照や計算を壊すため、版・移行期間・互換性の条件をAPI契約として定めます。

まとめ

offsetの位置は追加・削除でずれます。cursorが何の順序を続けるかもAPIごとに確認します。

If-Matchに取得時のETagを渡すと、古い版による更新をサーバーで拒否できます。

失敗の構造化表現とAPIの版を、クライアント側の処理条件へ対応させます。

操作履歴と解説を振り返れます。

ブラウザ / Web API

1
(globalThis.firstPage=await(await fetch("/items?offset=0&limit=2")).json(),firstPage)
{
  "ids": [
    10,
    20
  ],
  "next": "eyJhZnRlciI6MjAsIm9yZGVyIjoiaWQifQ=="
}
2
await(await fetch("/insert",{method:"POST",body:JSON.stringify({id:5})})).json()
{
  "ids": [
    5,
    10,
    20,
    30,
    40
  ]
}
3
(globalThis.offsetPage=await(await fetch("/items?offset=2&limit=2")).json(),{first:firstPage.ids,next:offsetPage.ids,duplicates:offsetPage.ids.filter(id=>firstPage.ids.includes(id))})
{
  "first": [
    10,
    20
  ],
  "next": [
    20,
    30
  ],
  "duplicates": [
    20
  ]
}
4
(globalThis.cursorPage=await(await fetch("/items?after="+encodeURIComponent(firstPage.next)+"&limit=2")).json(),{first:firstPage.ids,next:cursorPage.ids,cursor:cursorPage.next})
{
  "first": [
    10,
    20
  ],
  "next": [
    30,
    40
  ],
  "cursor": null
}
5
JSON.parse(atob(firstPage.next))
{
  "after": 20,
  "order": "id"
}
6
(globalThis.productResponse=await fetch("/product"),globalThis.etag=productResponse.headers.get("ETag"),{etag,product:await productResponse.json()})
{
  "etag": "\"v1\"",
  "product": {
    "id": 1,
    "name": "ノート",
    "price": 120
  }
}
7
(globalThis.updated=await fetch("/product",{method:"PATCH",headers:{"If-Match":etag,"Content-Type":"application/json"},body:'{"price":130}'}),{status:updated.status,etag:updated.headers.get("ETag"),body:await updated.json()})
{
  "status": 200,
  "etag": "\"v2\"",
  "body": {
    "id": 1,
    "price": 130
  }
}
8
(globalThis.conflict=await fetch("/product",{method:"PATCH",headers:{"If-Match":etag,"Content-Type":"application/json"},body:'{"price":140}'}),{status:conflict.status,type:conflict.headers.get("Content-Type"),problem:await conflict.json()})
{
  "status": 412,
  "type": "application/problem+json",
  "problem": {
    "type": "about:blank",
    "title": "Precondition Failed",
    "status": 412,
    "detail": "The product version changed.",
    "instance": "/product"
  }
}
9
(globalThis.current=await fetch("/product"),{etag:current.headers.get("ETag"),body:await current.json()})
{
  "etag": "\"v2\"",
  "body": {
    "id": 1,
    "name": "ノート",
    "price": 130
  }
}
10
({v1:await(await fetch("/v1/product")).json(),v2:await(await fetch("/v2/product")).json()})
{
  "v1": {
    "id": 1,
    "price": 130
  },
  "v2": {
    "id": "1",
    "price": {
      "amount": "130",
      "currency": "JPY"
    }
  }
}

演習完了