Acteurs Swift pour une Persistance Thread-Safe
Patrons pour construire des couches de persistance de données thread-safe en utilisant les acteurs Swift. Combine le cache en mémoire avec le stockage sur fichier, en tirant parti du modèle d'acteur pour éliminer les accès concurrents au moment de la compilation.
Quand Activer
- Construire une couche de persistance de données en Swift 5.5+
- Besoin d'accès thread-safe à un état mutable partagé
- Vouloir éliminer la synchronisation manuelle (verrous, DispatchQueues)
- Construire des applications offline-first avec un stockage local
Patron Principal
Référentiel Basé sur un Acteur
Le modèle d'acteur garantit un accès sérialisé — pas d'accès concurrents, assuré par le compilateur.
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)
// Chargement synchrone pendant l'init (isolation de l'acteur pas encore active)
self.cache = Self.loadSynchronously(from: fileURL)
}
// MARK: - API Publique
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: - Méthodes Privées
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) })
}
}
Utilisation
Tous les appels sont automatiquement asynchrones en raison de l'isolation de l'acteur :
let repository = LocalRepository<Question>()
// Lecture — recherche rapide O(1) dans le cache en mémoire
let question = await repository.find(by: "q-001")
let allQuestions = await repository.loadAll()
// Écriture — met à jour le cache et persiste dans le fichier de manière atomique
try await repository.save(newQuestion)
try await repository.delete("q-001")
Combinaison avec un ViewModel @Observable
@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()
}
}
Décisions de Conception Clés
| Décision | Justification |
|---|---|
| Acteur (plutôt que classe + verrou) | Sécurité thread-safe assurée par le compilateur, pas de synchronisation manuelle |
| Cache en mémoire + persistance fichier | Lectures rapides depuis le cache, écritures durables sur le disque |
| Chargement synchrone dans l'init | Évite la complexité de l'initialisation asynchrone |
| Dictionnaire indexé par ID | Recherches O(1) par identifiant |
Générique sur Codable & Identifiable | Réutilisable pour tout type de modèle |
Écritures atomiques (.atomic) | Empêche les écritures partielles en cas de crash |
Bonnes Pratiques
- Utiliser des types
Sendablepour toutes les données traversant les frontières de l'acteur - Garder l'API publique de l'acteur minimale — n'exposer que les opérations métier, pas les détails de persistance
- Utiliser des écritures
.atomicpour éviter la corruption des données si l'application plante en pleine écriture - Charger de manière synchrone dans
init— les initialiseurs asynchrones ajoutent de la complexité avec peu d'avantages pour les fichiers locaux - Combiner avec des ViewModels
@Observablepour des mises à jour réactives de l'interface utilisateur
Anti-Patrons à Éviter
- Utiliser
DispatchQueueouNSLockplutôt que des acteurs pour du nouveau code de concurrence Swift - Exposer le dictionnaire de cache interne aux appelants externes
- Rendre l'URL du fichier configurable sans validation
- Oublier que tous les appels aux méthodes de l'acteur nécessitent
await— les appelants doivent gérer le contexte asynchrone - Utiliser
nonisolatedpour contourner l'isolation de l'acteur (cela va à l'encontre du but recherché)
Quand Utiliser
- Stockage de données local dans les applications iOS/macOS (données utilisateur, paramètres, contenu mis en cache)
- Architectures offline-first qui se synchronisent plus tard avec un serveur
- Tout état mutable partagé auquel plusieurs parties de l'application accèdent simultanément
- Remplacer la sécurité thread-safe basée sur
DispatchQueuepar la concurrence moderne de Swift