URLCacheはクエリの順番に気を付ける

はじめに

URLCacheを使用する機会がありましてその際にはまったことを書きました。

実行環境

この記事は以下の動作環境で動作確認しています。

  • Xcode (11.4)
  • Swift (5.2)

URLCacheについて

使い方

まずはURLCacheの使い方について簡単に紹介します。

URLCacheはURLRequestをkeyとして保存・取り出しをすることができます。コードで書くと以下のような形です。

// キャッシュを保存する

let cachedResponse = CachedURLResponse(response: response, data: data)
URLCache.shared.storeCachedResponse(cachedResponse, for: request)

// キャッシュを取り出す

let cache = URLCache.shared.cachedResponse(for: request)

保存したい時にはstoreCachedResponse()に保存したいURLResponseとkeyとなるURLRequestを引数にします。取り出す時には同じ内容のURLRequestをcachedResponse()の引数とすれば取り出すことが可能です。

挙動

先ほどURLRequestがkeyとなると言いましたが、URLのクエリの順番も影響します。普段API通信をする際にクエリの順番がhoge=1&fuga=2とfuga=2&hoge=1では気にすることがないかと思います。しかしURLCacheを使う場合にはこの順番が違うと異なるリクエストと判断されてしまいます。

つまり以下の2つのURLRequestは異なるkeyになるので異なるキャッシュが返ってきます。

// クエリの順番がhoge, fuga
let request1 = URLRequest(url: URL(string: "https://caraquri.com?hoge=1&fuga=2")!)

// クエリの順番がfuga, hoge
let request2 = URLRequest(url: URL(string: "https://caraquri.com?fuga=2&hoge=1")!)

GETのAPIでクエリの順番が違くても同じレスポンスを返すのでデータを見て同じレスポンスだと勘違いするかもしれないですが、インスタンスは異なっています。

実際にあった問題

どのような問題だったか

GETのAPI通信で管理画面をいじると違うレスポンスを返すようなエンドポイントがありました。バグとしては以前に設定した内容が画面に表示されるというものです。

レスポンスが変わる可能性があれば毎回APIを叩いてもいい気はしますが、生存期間を決めてキャッシュを使うことが仕様として存在していました。

ただAPI側が不安定なこともありこのような問題は良くあったので当初はアプリ側の問題ではないように見えていました(キャッシュサーバーとか)。

何が原因だったのか

このプロジェクト自体がAPI通信部分を自前で実装していました。クエリ部分も自前で実装しています。その実装方法はDictionaryを用いていました。クエリはkeyとvalueがセットで複数存在しているのでDictionaryで表現するのは良さそうに見えます。

イメージとしては以下のような感じです。

// 各エンドポイントで定義する
var queries: [String:String]

// queriesを元にURLQueryItemを生成
var queryItems: [URLQueryItem] {
  queries.map { key, value -> URLQueryItem in
    URLQueryItem(name: key, value: value)
  }
}

実はこのDictionaryが原因です。DictionaryはArrayと違って順番に保証がありません。つまりqueryItemsは毎回異なる順番で生成されます。

結果的にURLRequestを生成するタイミングでもクエリの順番が違うことになります。

解決方法

この問題を解決する方法はいくつかありそうですが、すでにプロジェクト全体でqueriesを定義していたこともあったのでqueryItemsをソートすることにしました。

以下のような感じです。

var queryItems: [URLQueryItem] {
  queries.map { key, value -> URLQueryItem in
    URLQueryItem(name: key, value: value)
  }.sorted { (item1, item2) -> Bool in
    item1.name > item2.name
  }
}

サンプル

どのような挙動なのかを確認できるようにサンプルアプリを作ってみました。

画面

ボタンを押してキャッシュがあればそれをログに表示し、なければAPI通信をするボタンを2つ作ってます。クエリだけが異なるように設定しているのでキャッシュの挙動がわかるようになってます。

ソース

ソースはGitHubで公開しています。

https://github.com/hyoshimi-cq/URLCacheWithQuery

おわりに

気づけば大したことではないですが、はまれば時間が溶けていくような問題なので同じような問題があった人の助けになれば嬉しいです。