ぷくぺたの交換機能をCloudKitだけでつくるまで

交換機能が欲しくなってCloudKitのドキュメントを読んだところから、自前のサーバーを持たずに実装するまでの設計の記録

ぷくぺたでシールをつくっていると、友達と交換できたら楽しそうだと思うようになりました。自分のシールを1枚渡して、相手のシールを1枚受け取る機能が欲しかったのですが、実装を考え始めると、この「交換」が意外と難しく感じました。

画像をコピーするだけなら簡単でも、交換として扱うなら、渡したシールは手元から移動してほしいです。ただ、先に自分のシールを消してから相手のシールを保存すると、途中で通信が切れたときに、自分のシールだけ失うかもしれません。

片方だけ成功した状態を残したくないので、交換をトランザクションにできないか、ACIDの保証をどこまで使えるかを調べることにしました。

ぷくぺたの交換確認画面。猫のシールと、交換後は自分のライブラリからなくなること、途中でやめた場合は手元に残ることの案内

交換前には、シールが相手へ渡ることと、途中でやめた場合は手元に残ることを画面で案内しています。

ドキュメントで知った「同じゾーン」の制約

ぷくぺたではCloudKitを使っているので、まずAppleのドキュメントを読みました。複数のレコードをまとめて更新するCKModifyRecordsOperationには、isAtomicという設定があります。

これで双方のシールを一緒に更新できればよさそうだと思ったのですが、説明には次のように書かれていました。

You use atomic updates when you want to write multiple records to the same record zone.

— Apple Developer Documentation: isAtomic

「同じレコードゾーンに複数のレコードを書き込むときに、アトミックな更新を使う」という説明です。ここで気になったのは、「同じレコードゾーン」という条件でした。

さらに説明を読むと、ゾーンがatomic capabilityを持つ必要があり、複数のゾーンにまたがる操作では、あるゾーンの失敗が別のゾーンの更新を止めるわけではありません。isAtomicを指定すれば、どこにあるデータでもまとめて成功か失敗にできる、というものではありませんでした。AppleのisAtomicの説明

自分と相手のシールは、それぞれのPrivate Databaseに保存されています。その2か所の更新をそのまま1つのトランザクションにする、という最初に考えた形は使えなさそうです。

自前のサーバーは持たない

交換を管理するサーバーを用意して、そこに確定を任せる方法も考えました。でも、シール交換のためだけにサーバーをデプロイして、運用し続けるのはできれば避けたいです。

友達とシールを交換して楽しむアプリなので、改造されたクライアントまで含めて所有権を厳密に守ることより、普通に使っていてシールが消えず、通信に失敗してもやり直せることを優先したいと考えました。

固定IDのレコードや署名付きの譲渡履歴も検討しましたが、IDを固定しても書き換えや削除はでき、署名だけでは最新の所有者を決められません。記録の形式を工夫するだけで、別々の保存先をまたぐ交換が原子的になるわけでもありませんでした。

CloudKitだけで完結させるなら、まず「交換を確定する場所」をCloudKitの保証が使える範囲にまとめ、そのあとに各端末へ反映する形にできないかと考えました。

交換専用ゾーンの共有

そこで、交換を始めた人のPrivate Databaseに交換専用のゾーンを作り、CKShareで相手と共有する形にしました。相手はShared Databaseを通して、同じゾーンを参照します。

そのゾーンに置くのは、交換全体の状態を持つtradeレコードと、双方が出すシールの情報です。それぞれのシールの保存先を直接つなぐ代わりに、2人が同じ交換の記録を見るようにしました。

交換を確定するか、キャンセルするか、期限切れにするかは、tradeレコードのstatusで決めます。

open → committed
     → cancelled
     → expired

ただ、同じ場所に置くだけでは、確定とキャンセルが同時に来た場合の上書きを防げません。ここでもドキュメントを読み、保存ポリシーの.ifServerRecordUnchangedを使うことにしました。

A policy that instructs CloudKit to only proceed if the record’s change tag matches that of the server’s copy.

— Apple Developer Documentation: ifServerRecordUnchanged

取得したレコードのchange tagがサーバー側と一致するときだけ保存を進める設定で、先に誰かが更新していた場合はserverRecordChangedになります。古い状態を使って、確定とキャンセルの両方を上書きしてしまうことを防ぐために使っています。Appleの保存ポリシーの説明

実装では、双方の同意が同じシールの組み合わせにそろっているかを確認したあと、次のように確定を保存しています。saveはCloudKitへの保存と、レコードごとのエラー確認をまとめた関数です。

tradeRecord[Field.status] = TradeStatus.committed.rawValue as CKRecordValue
tradeRecord[Field.committedAt] = Date() as CKRecordValue
try await save([tradeRecord], policy: .ifServerRecordUnchanged, in: database)

同意も単なるオン・オフにはせず、双方のシールIDを組にしたagreedPairを記録しています。現在の「おまかせ交換」では両方のシールが選ばれると同意を自動で記録しますが、内部では、何と何を交換する同意なのかを確認する形にしました。

交換の確定と端末への反映を分ける

ここまでで交換を確定する場所はまとまりましたが、相手の画像をダウンロードして、自分のシール一覧に反映する処理はまだ残っています。共有ゾーンでの更新と、2台の端末での保存を、まとめて原子的に実行することはできません。

そこで、各端末では次の順番で進めるようにしました。

  1. 相手のシールを手元に取り込みます
  2. 自分のシールに「あげた」記録を付けます
  3. 自分の完了を共有ゾーンに記録します

最初に相手のシールを受け取るのが大事だと考えました。画像が取れないうちは自分のシールを手元から外さず、受け取りに成功してから「あげた」状態にすることで、通信断で手元のシールだけ失うことを避ける処理順にしています。

途中では渡す前と受け取ったあとのシールが一時的に両方見えることもありますが、表示を2台で同時に切り替えることより、失敗しても続きから進められることを重視しました。「交換が確定した」と「この端末への反映が終わった」は、別の状態として扱っています。

再試行による重複を防ぐ

次に気になったのは、再試行です。通信エラーが返ってきても、保存自体は終わっているかもしれないので、同じ処理をもう一度実行するだけでは、受け取ったシールを二重に追加してしまう可能性があります。

受け取ったシールにreceivedTradeIDを残し、同じ交換で受け取り済みなら追加しないようにしました。交換の控えも端末に保存して、アプリを開いたときなどにCloudKitの状態を確認し、まだ終わっていない手順を進めます。

共有ゾーンを片付けるのは、双方の完了を確認してからです。確定前にキャンセルした場合はシールの予約を解除し、途中の交換が残った場合も、再開や中断を扱えるようにしています。

成功する手順をつくるだけでなく、受け取り前に止まったらどうするか、受け取り後にアプリが落ちたらどうするかと順番に考えていくと、交換の控えや重複チェックが必要な理由が見えてきました。

CloudKitで保証する範囲

最初は交換全体をACIDなトランザクションにできないかと考えていましたが、実装では、CloudKitの同じゾーン内で使える原子性、1つのレコードで決める交換の確定、各端末で再試行できる反映処理に分けました。

これで2台の端末と複数の保存先全体が厳密なACIDになるわけではありません。交換中のシールの予約は端末ローカルの制御ですし、別レコードにある同意の確認と確定の間にシールが選び直される場合など、競合を検証する必要もあります。

それでも、どこで交換を確定するか、確定したあとに何を残すか、途中で止まったらどう続けるかを決めることで、自前のサーバーを持たずに実装する形が見えてきました。ドキュメントの「同じゾーン」という条件を読んだところから、ぷくぺたの交換はこの設計になりました。

ぷくぺたをApp Storeで見る