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()
}
}
主要な設計上の決定
| 決定 | 根拠 |
|---|---|
| アクター(class + lock ではない) | コンパイラによるスレッドセーフの強制、手動同期不要 |
| メモリキャッシュ + ファイル永続化 | キャッシュからの高速読み取り、ディスクへの耐久性のある書き込み |
| 同期 init ロード | 非同期初期化の複雑さを回避 |
| ID をキーにした辞書 | 識別子による O(1) ルックアップ |
Codable & Identifiable でジェネリック | 任意のモデル型で再利用可能 |
アトミックファイル書き込み(.atomic) | クラッシュ時の部分書き込みを防止 |
ベストプラクティス
Sendable型を使用する — アクター境界を越えるすべてのデータに対して- アクターの公開APIは最小限に保つ — ドメイン操作のみを公開し、永続化の詳細は公開しない
.atomic書き込みを使用する — アプリが書き込み中にクラッシュした場合のデータ破損を防ぐinitでは同期的にロードする — 非同期イニシャライザはローカルファイルに対しては複雑さを増すだけで利点が少ない@ObservableViewModel と組み合わせる — リアクティブなUI更新のために
避けるべきアンチパターン
- 新しいSwift並行性コードでアクターの代わりに
DispatchQueueやNSLockを使用する - 内部キャッシュ辞書を外部呼び出し元に公開する
- バリデーションなしでファイルURLを設定可能にする
- すべてのアクターメソッド呼び出しが
awaitであることを忘れる — 呼び出し元は非同期コンテキストを処理する必要がある nonisolatedを使用してアクター分離をバイパスする(目的を損なう)
使用すべきタイミング
- iOS/macOSアプリでのローカルデータ保存(ユーザーデータ、設定、キャッシュコンテンツ)
- 後でサーバーと同期するオフラインファーストアーキテクチャ
- アプリの複数の部分が同時にアクセスする共有可変状態
- 従来の
DispatchQueueベースのスレッドセーフを最新のSwift並行性に置き換える場合