Construire un stockage global pour Vapor


Le problème avec les services d’application


La vapeur a une chose appelée prestations de service, vous pouvez ajouter de nouvelles fonctionnalités au système en suivant le modèle décrit dans la documentation. Les services en lecture seule sont excellents, il n’y a aucun problème avec eux, ils renvoient toujours une nouvelle instance d’un objet donné auquel vous souhaitez accéder.

Le problème est lorsque vous souhaitez accéder à un objet partagé ou en d’autres termes, vous souhaitez définir un service accessible en écriture. Dans mon cas, je voulais créer un dictionnaire de cache partagé que je pourrais utiliser pour stocker certaines variables préchargées de la base de données.

Ma première tentative était de créer un service inscriptible que je peux utiliser pour stocker ces paires clé-valeur. Je voulais également utiliser un middleware et tout charger à l’avance, avant les gestionnaires de route. 💡


import Vapor

private extension Application {
    
    struct VariablesStorageKey: StorageKey {
        typealias Value = [String: String]
    }

    var variables: [String: String] {
        get {
            self.storage[VariablesStorageKey.self] ?? [:]
        }
        set {
            self.storage[VariablesStorageKey.self] = newValue
        }
    }
}

public extension Request {
    
    func variable(_ key: String) -> String? {
        application.variables[key]
    }
}

struct CommonVariablesMiddleware: AsyncMiddleware {

    func respond(to req: Request, chainingTo next: AsyncResponder) async throws -> Response {
        let variables = try await CommonVariableModel.query(on: req.db).all()
        var tmp: [String: String] = [:]
        for variable in variables {
            if let value = variable.value {
                tmp[variable.key] = value
            }
        }
        req.application.variables = tmp
        return try await next.respond(to: req)
    }
}


Maintenant, vous pourriez penser que ça a l’air bien et que ça marchera et vous avez raison, ça marche, mais il y a un ÉNORME problème avec cette solution. Ce n’est pas du tout thread-safe. ⚠️


Lorsque vous ouvrez le navigateur et tapez http://localhost:8080/ la page se charge, mais lorsque vous commencez à bombarder le serveur avec plusieurs requêtes utilisant plusieurs threads (wrk -t12 -c400 -d30s http://127.0.0.1:8080/) l’application plantera tout simplement.

Il y a un problème similaire sur GitHub, qui décrit exactement le même problème. Malheureusement, je n’ai pas pu résoudre ce problème avec serruresje ne sais pas pourquoi mais cela a gâché encore plus de choses avec des erreurs étranges et comme je ne peux pas non plus exécuter d’instruments sur mon M1 Mac Mini, car les packages Swift ne sont pas code signé par défaut. J’ai passé tant d’heures là-dessus et je suis très frustré.



Construire un stockage global personnalisé


Après une pause, ce problème me tourmentait toujours l’esprit, alors j’ai décidé de faire plus de recherches. Le serveur Discord de Vapor est généralement un excellent endroit pour obtenir les bonnes réponses.


J’ai également recherché d’autres frameworks Web, et j’ai été assez surpris que Colibri propose une EventLoopStorage par défaut. Quoi qu’il en soit, je ne vais pas changer, mais c’est quand même une fonctionnalité agréable à avoir.


En regardant les suggestions, j’ai réalisé que j’avais besoin de quelque chose de similaire au req.auth propriété, j’ai donc commencé à enquêter sur la la mise en oeuvre détails de plus près.


Tout d’abord, j’ai supprimé les protocoles, car je n’avais besoin que d’un simple [String: Any] dictionnaire et un moyen générique de renvoyer les valeurs en fonction des clés. Si vous regardez de plus près, c’est un modèle de conception assez simple. Il existe une structure d’assistance qui stocke la référence de la requête et cette structure a une classe Cache privée qui contiendra nos pointeurs vers les instances. Le cache est disponible via une propriété et il est stocké à l’intérieur du req.storage.


import Vapor

public extension Request {

    var globals: Globals {
        return .init(self)
    }

    struct Globals {
        let req: Request

        init(_ req: Request) {
            self.req = req
        }
    }
}

public extension Request.Globals {

    func get<T>(_ key: String) -> T? {
        cache[key]
    }
    
    func has(_ key: String) -> Bool {
        get(key) != nil
    }
    
    func set<T>(_ key: String, value: T) {
        cache[key] = value
    }
    
    func unset(_ key: String) {
        cache.unset(key)
    }
}


private extension Request.Globals {

    final class Cache {
        private var storage: [String: Any]

        init() {
            self.storage = [:]
        }

        subscript<T>(_ type: String) -> T? {
            get { storage[type] as? T }
            set { storage[type] = newValue }
        }
        
        func unset(_ key: String) {
            storage.removeValue(forKey: key)
        }
    }

    struct CacheKey: StorageKey {
        typealias Value = Cache
    }

    var cache: Cache {
        get {
            if let existing = req.storage[CacheKey.self] {
                return existing
            }
            let new = Cache()
            req.storage[CacheKey.self] = new
            return new
        }
        set {
            req.storage[CacheKey.self] = newValue
        }
    }
}


Après avoir changé le code d’origine, j’ai trouvé cette solution. Peut-être que ce n’est toujours pas la meilleure façon de gérer ce problème, mais cela fonctionne. J’ai pu stocker mes variables dans un stockage global sans plantage ni fuite. La req.globals La propriété de stockage va être partagée et permet de stocker des données qui doivent être chargées de manière asynchrone. 😅


import Vapor

public extension Request {
    
    func variable(_ key: String) -> String? {
        globals.get(key)
    }
}

struct CommonVariablesMiddleware: AsyncMiddleware {

    func respond(to req: Request, chainingTo next: AsyncResponder) async throws -> Response {
        let variables = try await CommonVariableModel.query(on: req.db).all()
        for variable in variables {
            if let value = variable.value {
                req.globals.set(variable.key, value: value)
            }
            else {
                req.globals.unset(variable.key)
            }
        }
        return try await next.respond(to: req)
    }
}


Après avoir effectué plusieurs autres tests en utilisant travail J’ai pu confirmer que la solution fonctionne. Je n’ai eu aucun problème avec les threads et l’application n’a eu aucune fuite de mémoire. C’était un soulagement, mais je ne sais toujours pas si c’est la meilleure façon de gérer mon problème ou non. Quoi qu’il en soit, je voulais partager cela avec vous car je pense qu’il n’y a pas assez d’informations sur la sécurité des threads.

L’introduction de asynchrone / attendre dans Vapor résoudra de nombreux problèmes de concurrence, mais nous en aurons également de nouveaux. J’espère vraiment que Vapor 5 sera une énorme amélioration par rapport à la v4, les gens lancent déjà des idées et discutent de l’avenir de Vapor sur Discord. Ce n’est que le début de l’ère asynchrone / attente à la fois pour Swift et Vapor, mais c’est formidable de voir qu’enfin nous allons pouvoir nous débarrasser de EventLoopFutures. 🥳