Swiftアクターによるスレッドセーフな永続化
Swiftアクターを使用したスレッドセーフなデータ永続化層を構築するためのパターンです。メモリ内キャッシュとファイルバックアップストレージを組み合わせ、アクターモデルを活用してコンパイル時にデータ競合を排除します。
起動タイミング
- Swift 5.5以降でデータ永続化層を構築する場合
- 共有可変状態へのスレッドセーフなアクセスが必要な場合
- 手動同期(ロック、DispatchQueue)を排除したい場合
- ローカルストレージを使用するオフラインファーストアプリの構築
コアパターン
アクターベースリポジトリ
アクターモデルは直列アクセスを保証します。データ競合は発生せず、コンパイラによって強制されます。
public actor LocalRepository<T: Codable & Identifiable> where T.ID == String {
private var cache: [String: T] = [:]
private let fileURL: URL
public init(directory: URL = .documentsDirectory, filename: String = "data.json") {
self.fileURL = directory.appendingPathComponent(filename)
// init中は同期的にロード(アクター分離はまだ有効でない)
self.cache = Self.loadSynchronously(from: fileURL)
}
// MARK: - 公開API
public func save(_ item: T) throws {
cache[item.id] = item
try persistToFile()
}
public func delete(_ id: String) throws {
cache[id] = nil
try persistToFile()
}
public func find(by id: String) -> T? {
cache[id]
}
public func loadAll() -> [T] {
Array(cache.values)
}
// MARK: - プライベート
private func persistToFile() throws {
let data = try JSONEncoder().encode(Array(cache.values))
try data.write(to: fileURL, options: .atomic)
}
private static func loadSynchronously(from url: URL) -> [String: T] {
guard let data = try? Data(contentsOf: url),
let items = try? JSONDecoder().decode([T].self, from: data) else {
return [:]
}
return Dictionary(uniqueKeysWithValues: items.map { ($0.id, $0) })
}
}
使用例
アクター分離により、すべての呼び出しは自動的に非同期になります:
let repository = LocalRepository<Question>()
// 読み取り — メモリキャッシュからの高速なO(1)ルックアップ
let question = await repository.find(by: "q-001")
let allQuestions = await repository.loadAll()
// 書き込み — キャッシュを更新し、ファイルにアトミックに永続化
try await repository.save(newQuestion)
try await repository.delete("q-001")
@Observable ViewModelとの組み合わせ
@Observable
final class QuestionListViewModel {
private(set) var questions: [Question] = []
private let repository: LocalRepository<Question>
init(repository: LocalRepository<Question> = LocalRepository()) {
self.repository = repository
}
func load() async {
questions = await repository.loadAll()
}
func add(_ question: Question) async throws {
try await repository.save(question)
questions = await repository.loadAll()
}
}
主要な設計判断
| 判断 | 根拠 |
|---|---|
| Actor(class+ロックではない) | コンパイラが強制するスレッドセーフ、手動同期不要 |
| メモリキャッシュ+ファイル永続化 | キャッシュからの高速読み取り、ディスクへの耐久性のある書き込み |
| 同期init読み込み | 非同期初期化の複雑さを回避 |
| IDをキーとする辞書 | IDによるO(1)ルックアップ |
Codable & Identifiable にジェネリック | 任意のモデル型で再利用可能 |
アトミックファイル書き込み(.atomic) | クラッシュ時の部分書き込みを防止 |
ベストプラクティス
- アクター境界を越えるすべてのデータに
Sendable型を使用する - アクターの公開APIは最小限に保つ — 永続化の詳細ではなく、ドメイン操作のみ公開する
.atomic書き込みを使用する — アプリが書き込み中にクラッシュしてもデータ破損を防ぐinitで同期的にロードする — 非同期イニシャライザはローカルファイルにはメリットが少なく複雑さが増す@ObservableViewModelと組み合わせる — リアクティブなUI更新のために
避けるべきアンチパターン
- 新しいSwift並行性コードでアクターの代わりに
DispatchQueueやNSLockを使用する - 内部キャッシュ辞書を外部呼び出し元に公開する
- 検証なしでファイルURLを設定可能にする
- すべてのアクターメソッド呼び出しが
awaitであることを忘れる — 呼び出し元は非同期コンテキストを処理する必要がある nonisolatedを使用してアクター分離をバイパスする(目的を無効にする)
使用すべきタイミング
- iOS/macOSアプリのローカルデータストレージ(ユーザーデータ、設定、キャッシュコンテンツ)
- 後でサーバーと同期するオフラインファーストアーキテクチャ
- アプリの複数の部分が同時にアクセスする共有可変状態
- レガシーな
DispatchQueueベースのスレッドセーフを近代的なSwift並行性に置き換える場合