Il y a 7 mois -

Temps de lecture 5 minutes

Mobile : Safe dependency injection en Swift

En vogue depuis des décennies dans d’autres langages, l’injection de dépendances se voit même standardisée par certains, comme en Java avec l’annotation @Inject. D’autres ne se voient même pas faire un projet sans.
Et pourtant, ce pattern connu (et reconnu) est encore assez peu utilisé en Swift !
Dans cet article, je me propose de vous montrer comment facilement faire de l’injection de dépendances en Swift, les risques de l’utiliser et comment les mitiger voire les supprimer.

(In)dependency… what?

Si d’aventure vous ne saviez pas ce qu’est l’injection de dépendances (ou DI), sachez que cela consiste simplement à fournir les instances des objets dont une classe à besoin via constructeur ou setter. Rien de plus (quasiment) !

À l’inverse, une référence définie au sein d’un autre objet n’est pas de l’injection de dépendances :

// A définit une réf précise de B : aucune injection possible
class A {
  init() {
   self.b = B()
  }
}
// B est passé via init à A : injection possible
class A {
  init(b: B) {
    self.b = b
  }
}

 

Voila, vous êtes maintenant un pro de la DI. Pas si compliqué, non ? :)

Swinject

Utilisation

Swinject est un framework qui va nous aider à faire de l’injection de dépendances. J’ai dit juste avant que la DI n’était pas compliquée, alors pourquoi vouloir utiliser un framework ? Pas mal de gens en Swift font de l’injection « à la main » (souvent sans s’en rendre compte) :

class A {
  init(b: B = B.instance()) {
    self.b = b
  }
}

Ça fonctionne, c’est simple, et ça ne demande aucun framework. Mais dans une grosse application vous vous rendrez compte assez vite de plusieurs problèmes :

  • Aucune gestion du cycle de vie des dépendances : on va se baser principalement sur des singletons.
  • Modifier un service est difficile ; si j’ai A → B → C → D et que je souhaite injecter une version spécifique de D(D') pour A, je dois recréer tout le graphe : A(B(C(D'()))

Pour tous ces cas (et bien d’autres), Swinject fournit un conteneur d’injection, Container (on ne peut pas faire plus logique). C’est principalement le seul objet de Swinject que vous utiliserez.

Et que peut-on faire avec ? Plein de choses ! Vous pouvez bien sûr déclarer vos services, et les résoudre :

let container = Container()

container.register(HttpConnection.self) { _ in
    HttpConnection(baseURL: "https://xebia.fr")
}

container.register(WebServiceDataAccess.self) { _ in
    WebServiceDataAccess(connection: r.resolve(HttpConnection.self)!)
}

let dataAccess = container.resolve(WebServiceDataAccess.self)
print(dataAccess != nil) // true

Mais aussi définir des paramètres. Vous pourrez ainsi les passer au moment d’appeler resolve :

container.register(ProfileViewModel.self) { r, user in
  LoginViewModel(dataAccess: r.resolve(WebServiceDataAccess.self)!, user: user)
}

container.resolve(ProfileViewModel.self, argument: User(named: "Kratos"))
container.resolve(ProfileViewModel.self, argument: User(named: "Mario"))

C’est une fonctionnalité principalement utile si vous enregistrez des objets dont certains paramètres sont connus uniquement au runtime, comme pour vos ViewModel par exemple.

 

À utiliser avec parcimonie, car l’ordre et le nombre d’arguments sont importants quand Swinject détermine la résolution ! Ainsi les exemples suivants ne marchent pas :

container.resolve(ProfileViewModel.self) // nil
container.resolve(ProfileViewModel.self, arguments: WrongType()) // nil
container.resolve(ProfileViewModel.self, arguments: User(named: "Mario"), true) // nil

Et c’est là que l’on rentre dans certains problèmes de la DI.

You are doomed!

Que se passe-t-il si vous essayez de compiler avec les derniers exemples (faux) de résolution ? Aucune erreur.
Que se passe-t-il si vous essayez de compiler en oubliant la déclaration d’un service, par exemple celui de WebServiceDataAccess ? Aucune erreur.
C’est mauvais, très mauvais même. Nous aurons un crash au runtime alors même que notre graphe de résolution n’est pas complet/erroné.
Une meilleure solution serait de s’assurer à la compilation que tous les services sont bien présents. Mais comment faire ?

Somebody safe me!

Le problème

Jusqu’à présent, vous devez :

  • Enregistrer vos services à la main (container.register(WebServiceDataAccess))
  • Résoudre vos dépendances à la main (resolve(WebServiceDataAccess.self))…
  • …sans aucune assurance de sa présence, d’où les force unwrap (i.e. le !).

C’est lourd, et cela peut entraîner les problèmes que l’on a vu juste avant. Une solution pour éviter cela : utiliser des annotations pour générer les services à la compilation, et vérifier le tout. C’est l’approche choisie en Java par Dagger par exemple.

Problème, les annotations n’existent pas en Swift ! Mais heureusement, la communauté a crée Sourcery ! :hourra: Et grâce à Sourcery, je vous présente… AnnotationInject ! :tada:

Comment ça marche ?

Il vous suffit d’annoter vos classes avec inject pour que AnnotationInject les prenne en compte. Il va ensuite (grâce à Sourcery) générer un ensemble de code pour cette annotation, à savoir :

  • La méthode d’enregistrement (container.register(MyService.self))
  • Une méthode de résolution typée et sûre (func registeredService() -> MyService)

 

// sourcery: inject
class WebServiceDataAccess {
    init(connection: HttpConnection)
}

 

// Le code généré (simplifié)
container.register(WebServiceDataAccess.self) { resolver in
  return WebServiceDataAccess(connection: resolver.registeredService())
}

extension Resolver {
  func registeredService() -> WebServiceDataAccess {
    return self.resolve(WebServiceDataAccess.self)!
  }
}

Vous aurez remarqué que le code généré repose lui-même sur une méthode générée : registeredService().

En l’utilisant en lieu et place de resolve(), si le service n’a pas été annoté (et donc enregistré), registeredService() échouera à la compilation, et votre code ne compilera pas !
Ici par exemple, func registeredService() -> HttpConnection n’existe pas, donc WebServiceDataAccess(connection: resolver.registeredService()) ne compilera pas.

Conclusion

Si vous faites de l’injection de dépendances, faites bien attention de sécuriser le code que vous écrivez. Un refactoring, un service oublié ou un changement d’équipe et vous vous retrouvez très vite avec un crash.
Comme avec Swinject pour la gestion des ressources statiques, le mieux est d’ajouter un outil de générateur de code tel AnnotationInject ou autre. Votre application ne s’en portera que mieux !

 

Publié par Jean-Christophe Pastant

Jean-Christophe est consultant iOS et partage régulièrement ses bonnes pratiques avec la communauté iOS.

Commentaire

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Nous recrutons

Être un Xebian, c'est faire partie d'un groupe de passionnés ; C'est l'opportunité de travailler et de partager avec des pairs parmi les plus talentueux.