Outboxアーキテクチャ:メッセージを取りこぼさないための設計

課題:送信即クリアでも、メッセージを取りこぼさないには

Obsidian連携シンプルメモでは、ユーザーが送信ボタンをタップした瞬間にUIがクリアされ、すぐに次のメモを書き始められる。これがCaptioスタイルの根幹をなすUXだ。

しかし現実のネットワークは300ms〜2秒のレイテンシがあり、失敗もするし、そもそもオフラインかもしれない。多くのアプリはネットワーク応答を待ってからUIをクリアする。安全だが、UXは悪い。逆に即座にクリアすると、メッセージを失うリスクがある。

Outboxパターンは、この相反する要件を同時に解決する。送信タップの瞬間にメッセージをローカルの暗号化ストレージ(Outbox)に永続保存し、UIを即座にクリアし、バックグラウンドでネットワーク送信を行う。送信に成功したらOutboxから削除し、失敗したら自動リトライする。これにより未送信のメッセージを端末に残して再送しつつ、体感レイテンシはゼロに近づく。

Outboxパターンの設計フロー

以下が、送信ボタンをタップしてからメッセージが送信されるまでの全フローだ。

  1. 送信タップ → まずOutboxにAES-GCM暗号化して永続保存
    ユーザーが送信ボタンをタップした瞬間、メッセージはAES-GCM 256bitで暗号化され、デバイスのローカルストレージに永続保存される。このステップが完了するまで、UIクリアは行わない。つまり、Outbox保存が取りこぼしを防ぐ最初の防波堤になる。
  2. UIを即座にクリア(ユーザーは次のメモを書き始められる)
    Outbox保存が成功した時点で、テキストビューをクリアし、送信アニメーションを再生する。ユーザーから見れば「送信した」と同義。ネットワーク応答を一切待っていない。Send-to-Resetの目標は、待ちを感じない短さ(アニメーション込みで0.25秒)。
  3. バックグラウンドでRelay API経由でメール送信
    UIクリアと並行して、SendManagerがCloudflare Workers上のRelay APIへHTTPリクエストを送信する。この処理はメインスレッドをブロックしない完全非同期。
  4. 成功したらOutboxから削除
    Relay APIから200レスポンスを受信したら、該当メッセージをOutboxから安全に削除する。これでメッセージのライフサイクルが完了する。
  5. 失敗したら自動で送り直す
    ネットワークエラーやサーバーエラーの場合は、送信待ちに残したまま、回線が戻ったとき・アプリを開いたとき・バックグラウンドの処理(BGTaskScheduler。実行のタイミングは iOS が決める)をきっかけに送り直す。自動の再送には回数の上限がある。
  6. オフラインならNWPathMonitorで回線復帰を検知して自動再送
    送信時点でオフラインの場合、Network.frameworkのNWPathMonitorが回線状態を監視し、Wi-Fiやセルラーの復帰を検知すると、Outboxの未送信のメッセージを自動で送り直す(上の5と同じく、自動の再送には回数の上限がある)。

SendManagerのコード

SendManagerのsend()関数は、Outboxパターンのエントリーポイントだ。まずOutboxに保存し、ネットワーク接続がなければキューイングし、接続があれば即座に送信を実行する。

// 保存完了と送信結果を分ける説明用の簡略API
@MainActor
func send(message: String, onSaved: () -> Void,
          completion: @escaping (SendResult) -> Void) {
    // まずOutboxに保存(クラッシュや圏外で失わないように)
    let outboxMessage: OutboxMessage
    do {
        outboxMessage = try OutboxManager.shared.add(body: message)
    } catch {
        DispatchQueue.main.async { completion(.failure(error)) }
        return
    }

    // 同期保存に成功した場合だけ、メインスレッドでUIへ通知する
    onSaved()

    // ネットワーク接続がない場合はキューに入れたことを通知
    guard NetworkMonitor.shared.isConnected else {
        BackgroundTaskManager.shared.scheduleRetryTask()
        DispatchQueue.main.async { completion(.queued) }
        return
    }

    // 送信処理を実行
    performSend(message: message, outboxId: outboxMessage.id, ...)
}

重要なポイントは、OutboxManager.shared.add(body:)が同期的に永続ストレージに書き込むこと。この呼び出しが成功した時点で、メッセージは端末に安全に保存されている。ネットワーク送信がどのような結果になっても、メッセージはOutboxに残り続ける。

AES-GCM暗号化の実装

Outbox に保存されるメッセージは、ユーザーのプライベートなメモだ。ローカルストレージとはいえ、平文で保存するわけにはいかない。Apple CryptoKit の AES-GCM を使い、暗号化に関しては外部 SDK を一切介さない実装にしている。

  • Apple CryptoKit — iOS 13以降で利用可能。暗号化処理に外部 SDK を介さない(Apple 純正のみ)。
  • 256-bit対称鍵 — Keychainに保存する。
  • 認証付き暗号化 — AES-GCMはデータの機密性と完全性を同時に保証。改ざんされたデータは復号時に検知される。
enum OutboxEncryption {
    private static var encryptionKey: SymmetricKey {
        if let existingKey = KeychainHelper.load(key: "outbox_encryption_key") {
            return SymmetricKey(data: existingKey)
        }
        let newKey = SymmetricKey(size: .bits256)
        let keyData = newKey.withUnsafeBytes { Data($0) }
        KeychainHelper.save(key: "outbox_encryption_key", data: keyData)
        return newKey
    }

    static func encrypt(_ plainText: String) throws -> Data {
        guard let data = plainText.data(using: .utf8) else {
            throw OutboxError.encryptionFailed
        }
        let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
        guard let combined = sealedBox.combined else {
            throw OutboxError.encryptionFailed
        }
        return combined
    }
}

鍵は初めて必要になったときに自動で作られ、Keychainに保存される。保存の設定は「この端末だけ・最初のロック解除のあとから読める」(kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)で、ほかのアプリからは読めず、バックアップから別の端末へ移ることもない。再起動のあと一度ロックを解除すれば、ロック中のバックグラウンド処理からも読める。なお、上のコード例は記事を書いた時点の簡略版で、今のコードでは、キーチェーンの読み取りに失敗したときに「鍵が無い」と扱って新しい鍵を作ることはしない。

データパスを Apple 純正のみで構成する意味

Outboxアーキテクチャで使用しているフレームワークはすべてAppleネイティブだ。

  • CryptoKit — AES-GCM暗号化。サードパーティの暗号化ライブラリは不要。
  • Network.framework — NWPathMonitorによる回線状態監視。Reachabilityライブラリは不要。
  • BackgroundTasks — BGTaskSchedulerによるバックグラウンドリトライ。

送信待ちの経路に外部のライブラリが無いことには、次の意味がある。

  • 外部の更新に左右されない — 送信待ちの処理が、サードパーティのライブラリの更新や提供終了で動かなくなることがない。
  • セキュリティ — 送信待ちのメモの本文に触れる処理(暗号化・保存・送信)に、第三者のコードが入らない。

なお、アプリ全体では外部のSDK(Googleサインイン、Firebase、AppsFlyer)も使っている(プライバシーファースト設計を参照)。ここで言う「Apple純正のみ」は、送信待ちのデータの経路の話だ。

UIクリアとネットワーク送信の分離

Outboxパターンの真の価値は、UIレイヤーとネットワークレイヤーの完全な分離にある。送信ボタンのタップハンドラを見てみよう。

@objc private func sendButtonTapped() {
    PerformanceLogger.shared.beginSendToReset()
    let message = textView.text ?? ""
    isSending = true
    sendBarButton.isEnabled = false

    SendManager.shared.send(message: message, onSaved: { [weak self] in
        // 端末への保存成功後にだけ消す。ネットワーク応答は待たない。
        self?.performSendAnimation { [weak self] in
            guard let self = self else { return }
            // アニメーション中に編集された入力は残す。
            if self.textView.text == message {
                self.clearTextView()
            }
            PerformanceLogger.shared.endSendToReset()
        }
    }) { [weak self] result in
        self?.isSending = false
        self?.updateSendButtonState()
        switch result {
        case .success:
            PerformanceLogger.shared.logEvent(name: "SendSuccess")
        case .failure(let error):
            self?.handleSendError(error)
        case .queued:
            self?.showQueuedFeedback()
        }
    }
}

この簡略例では、onSavedを端末への保存成功時だけ呼び出し、そこからアニメーションと入力消去を行う。保存に失敗した場合は入力を残す。completionは送信結果やキューへの登録を知らせる別の通知であり、UIクリアはネットワーク応答を待たない。

Send-to-Resetの目標は待ちを感じない短さ。アニメーション込みでも0.25秒。ユーザーは送信ボタンをタップした直後に、次のメモを書き始められる。これがCaptioスタイルのUXだ。

よくある質問

Q. オフラインで送信したメモはどうなりますか?

Outboxに暗号化して保存され、回線の復帰(NWPathMonitorで検知)・アプリを開いたとき・バックグラウンドの処理をきっかけに、自動で送り直します。自動の再送には回数の上限があり、上限に達したメモは「履歴」で長押しして「再送信」を選びます。

Q. アプリを閉じてもメモは失われませんか?

Outboxは永続ストレージです。アプリを終了しても、iPhoneを再起動しても残ります。次回アプリ起動時、またはバックグラウンドタスクにより自動的にリトライされます。

Q. なぜAES-GCMを選んだのですか?

Apple CryptoKitがネイティブサポートしており、認証付き暗号化(Authenticated Encryption)により改ざん検知も可能です。暗号化の処理に外部ライブラリが要らない点も利点です。

Q. 送信失敗時のリトライ回数は?

自動で送り直します。きっかけは、回線が戻ったとき、アプリを開いたとき、バックグラウンドの処理(BGTaskScheduler。実行の時刻は iOS が決める)です。送れなかったメモは送信待ちから消さずに送り直しますが、自動の再送には回数の上限があります。

関連記事

参考文献