iOSのダウンロード再開をローカルRangeサーバーで検証する

iOSのダウンロード再開をローカルRangeサーバーで検証する

大きなオフラインリソースをダウンロードするiOSアプリをリモートで開発しているとき、通信が途切れた後に進捗バーが元の位置から動き出しても、ダウンロード済みのデータを実際に再利用した証拠にはなりません。画面上は以前の進捗を引き継ぎながら、最初からダウンロードし直している可能性もあります。クラウドMacでは、シミュレータから同じMac上のローカルサーバーにアクセスさせ、サーバーの記録とファイル検証で再現可能な受け入れテストを行えます。公開ファイルサービスは不要です。

成功条件を先に決める

今回テストするのは1ファイルの単一バイト範囲だけで、並列分割ダウンロードは対象外です。機密情報を含まず、ダウンロードの途中でキャンセルできる程度に大きいファイルを用意します。最初のリクエストでは 200 が返るはずです。クライアントが有効な再開情報を保持していれば、次のリクエストに Range: bytes=N- が付き、サーバーから 206 と正確な Content-Range が返る可能性があります。最終ファイルの長さとSHA-256は、元ファイルと一致しなければなりません。

resumeData はクライアントがダウンロードの再開を試みるためのデータであり、サーバーが部分レスポンスを返した証拠ではありません。また、キャンセルすれば必ず利用可能な resumeData が得られるわけでもありません。

まず専用のテストディレクトリにファイルを生成し、比較用の値を記録します。ファイルを大きくするのは手動でキャンセルする時間を確保するためで、製品構成や性能の指標ではありません。

mkdir -p ~/range-lab && cd ~/range-lab
python3 - <<'PY'
with open("fixture.bin", "wb") as out:
    for _ in range(64):
        out.write(b"XCZone-range-check" * 65536)
PY
shasum -a 256 fixture.bin
stat -f '%z bytes' fixture.bin

ローカルからのみアクセスできるRangeサーバーを立てる

以下の最小構成のサーバーは、カレントディレクトリの fixture.bin だけを配信します。ファイルからETagを生成し、指定されたオフセットから末尾までの読み取りに対応します。対応していないRangeリクエストには 416 を返します。シミュレータで中断しやすいよう、各ブロックの送信後に短く待機します。公開運用向けのファイルサーバーではありません。

from hashlib import sha256
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from time import sleep
import re

path = Path("fixture.bin")
size = path.stat().st_size
etag = '"' + sha256(path.read_bytes()).hexdigest() + '"'

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path != "/fixture.bin":
            self.send_error(404)
            return
        requested = self.headers.get("Range")
        if self.headers.get("If-Range") not in (None, etag):
            requested = None
        start = 0
        if requested:
            match = re.fullmatch(r"bytes=(\d+)-", requested)
            if not match or int(match.group(1)) >= size:
                self.send_response(416)
                self.send_header("Content-Range", f"bytes */{size}")
                self.end_headers()
                return
            start = int(match.group(1))
        self.send_response(206 if requested else 200)
        self.send_header("Content-Type", "application/octet-stream")
        self.send_header("Accept-Ranges", "bytes")
        self.send_header("ETag", etag)
        self.send_header("Content-Length", str(size - start))
        if requested:
            self.send_header("Content-Range", f"bytes {start}-{size - 1}/{size}")
        self.end_headers()
        with path.open("rb") as source:
            source.seek(start)
            while chunk := source.read(65536):
                try:
                    self.wfile.write(chunk)
                    self.wfile.flush()
                except (BrokenPipeError, ConnectionResetError):
                    break
                sleep(0.01)

HTTPServer(("127.0.0.1", 8765), Handler).serve_forever()

range_server.py として保存し、そのディレクトリで python3 range_server.py を実行します。別のターミナルを開き、まず curl -i -r 1024-2047 http://127.0.0.1:8765/fixture.bin -o /dev/null でステータスコードとレスポンスヘッダーを確認します。このコマンドでは転送の進捗が表示されることがありますが、それを内容の判定に使う必要はありません。curl -sD - -r 1024-2047 -o /dev/null http://127.0.0.1:8765/fixture.bin を使えば、206、Content-Range: bytes 1024-2047/…、Content-Length を明示的に確認できます。テスト後はキーボードからサーバーを中断し、待ち受けアドレスを外部向けのネットワークインターフェースに変更しないでください。

シミュレータで中断・再開する

テスト版アプリで URLSessionDownloadTask を使い、http://127.0.0.1:8765/fixture.bin をダウンロードします。シミュレータからは、実行元のMacと同じローカルのテスト用アドレスにアクセスできます。実機を使う場合、127.0.0.1 はその端末自身を指すため、このアドレスをそのまま使うことはできません。平文HTTPの例外が必要なら、独立したテスト用設定でのみネットワークセキュリティの許可を追加し、正式ビルドにその例外が含まれていないことを確認します。

ダウンロード開始後に cancel(byProducingResumeData:) を呼び出し、コールバックで渡されたデータを保存します。続いて downloadTask(withResumeData:) で新しいタスクを作成します。サーバー側のターミナル出力を残し、リクエストごとのRange、レスポンスステータス、最終ファイルサイズを記録してください。中断テストの前に、通常のダウンロードが完了することを確認します。そうしないと、接続の問題と再開の問題を切り分けられません。

再開と最初からの再ダウンロードを区別する

2回目のリクエストにRangeがなく、サーバーが 200 を返したなら、クライアントはファイル全体の再ダウンロードを選んだ可能性があります。これは途中からの再開が成功したことを意味しません。Range付きのリクエストでも、ファイルのETagが変わったために全体のレスポンスを受け取った場合は、ファイルを先頭から書き直す必要があります。どちらの場合も、全体のレスポンスを古い断片の末尾に追記してはいけません。テストコードでは、キャンセル後に resumeData を取得できない場合も扱い、タスクを「再開待ち」のまま止めず、明示的に最初からやり直せるようにします。

ファイル検証で結果を確定する

ダウンロードタスクの完了後、生成された一時ファイルをテストディレクトリにコピーします。コピー先に対して shasum -a 256 ダウンロード後のファイルパス を実行し、元ファイルのハッシュと一字ずつ照合するとともに、長さも確認します。206 はサーバーが部分データを返したことしか示しません。ハッシュが一致しなければ、オフセット、書き込み方法、2回のリクエストの間にファイルが変わっていないかを調べる必要があります。

最後に反例も試します。ダウンロードを中断してから元ファイルを変更し、テストサーバーを再起動してETagを変えます。このとき、クライアントが古い断片を新しいファイルの前半として扱ってはいけません。リクエストの記録、ステータスコード、長さ、ハッシュを1つのテスト記録にまとめておくと、進捗バーのスクリーンショットだけを残すよりも、回帰の原因を特定しやすくなります。

よくある質問

206応答があれば再開成功と判断できますか?

いいえ。要求した開始位置に加え、完成ファイルの長さとSHA-256も確認します。

再開時にサーバーが200を返したらどうしますか?

完全な応答として最初から保存します。既存の断片に追記してはいけません。

XCZone · クラウドMac

リモート接続できる専有物理Macをお探しですか?

M4 Start、M4 Work、M4 Proのスペック、ロケーション、課金期間を比較して、用途に合ったプランをお選びください。

クラウドMacのプランを見る