Swiftly

Injection et inversion de dépendances en pratique avec Async Await

Dans son livre Clean Code a handbook of software craftsmanship, Robert C. Martin connu sous le nom de oncle Bob définit deux concepts: injection de dépendances et inversion de dépendances. Plusieurs développeurs iOS comme moi ou d’autres, appliquent sans connaitre l’arrière plan et les avantages qui ont donnée naissance au pattern DI et du principe d’inversion de dépendances. Dans cet article, je vais essayer d’éclaircir ces deux concepts en pratique sur une application iOS à base de Swift UI et Async Await.

Injection de dépendances:

Un objet aura besoin d’autres pour réaliser une fonctionnalité bien particulière. Par exemple, dans un view model on aura besoin d’effectuer un appel d’un web service pour retourner un résultat généralement sous format json et anticiper les cas d’erreurs possibles.

final class ListViewModel: ObservableObject {
   session = URLSession.shared
}

Dans l’exemple ci dessus, session constitue une dépendance utilisé par ListViewModel. On remarque que le view model a crée sa dépendance. L’injection de dépendances interdit à un objet de créer ses dépendances et délègue ce traitement à un autre objet au niveau supérieur qu’on appelle l’injecteur (injector). Alors on aura un code pareil à ça:

final class ListViewModel: ObservableObject {
    private let session: URLSession
    
    init(session: URLSession) {
        self.session = session
    }
}
struct ListView: View {
    @StateObject var viewModel =  ListViewModel(session: URLSession.shared)
}

Alors l’injection de dépendances dans notre cas se fera au niveau du constructeur de l’objet view model. C’est le premier type ou manière d’injecter une dépendance. On peut injecter d’une autre manière en passant la dépendance comme une propriété non privée :

final class ListViewModel: ObservableObject {
    var session: URLSession?
    init() {}
}

struct ListView: View {
    @StateObject var viewModel =  ListViewModel()
    var body: some View {
    /// construire UI
    .OnAppear {
      viewModel.session = URLSession.shared
      viewModel.fetch()
     }
   }
}

l’injection peut se faire au niveau OnAppear de notre view ListView ou en créant un Task. Cette manière d’injection n’est pas adéquate pour notre cas vu qu’on sera obligé soit de déclarer en optionnel notre session ou lui donner une valeur par défaut.

On a jusqu’à maintenant deux types d’injection de dépendances:

  • par constructeur
  • par propriété

Le dernier type d’injection c’est de passer la dépendance comme paramètre d’une fonction.

final class ListViewModel: ObservableObject {
    init() {}
    func fetch(session: URLSession) async throws -> [Repos?{
         do {
         let url = URL(string: "www.walidsassi.com/repos.json)!"
         let (data,response) = try await session.data(from: url)
         /// conversion et gestion des erreurs
    }
}
struct ListView: View {
    @StateObject var viewModel =  ListViewModel()
    var body: some View {
    /// construire UI
    .OnAppear {
      viewModel.fetch(session:  URLSession.shared)
     }
   }
}

Si par exemple on aura besoin de notre dépendance session au niveau de la méthode fetch, il est plus simple de passer comme paramètre de la fonction.

La question la plus simple qui peut se poser après avoir vu les trois types que le design pattern d’injection propose, quelle est l’utilité de créer les dépendances en dehors de notre classe view model?

Avantages de l’injection de dépendances:

  • La séparation des préoccupations : separation of concerns

Le view model ou d’autres couches de mon application n’est pas responsable de créer ses dépendances et comment ils seront créer. En effet, la création peut être faite dans la couche supérieur par exemple notre vue ou dans des coordinators dans le cas de MVVM-C, un singleton qui permet de créer les dépendances ou utiliser un système à base de factories méthodes comme Swinject….

  • Les tests unitaires:

Un test unitaire permet de tester le comportement d’un objet indépendamment des couches inférieurs. Par exemple, au moment de lancer le fetch, il faut pas lancer la session réel mais plutôt un objet Double qui simulera le traitement fait par URLSession sans lancer un appel réseau réel. Sinon on parlera d’un test d’intégration au lieu d’un test unitaire. Pour réaliser, il faut que notre dépendance sera plutôt un interface (protocole), On parlera à ce moment de l’inversion de dépendances 🙂

final class ListViewModel: ObservableObject {
    private let session: URLSessionProtocol
    
    init(session: URLSessionProtocol) {
        self.session = session
    }
}


Inversion de dépendances:

L’inversion de dépendances constitue le 5 eme et dernier principe SOLID que oncle Bob l’a évoqué dans plusieurs de ces livres dont le plus connu clean architecture. Pour résumer, le principe se base sur deux assertions:

  1. Les modules de haut niveau ne doivent pas dépendre des modules de bas niveau. Les deux doivent dépendre d’abstractions.
  2. Les abstractions ne doivent pas dépendre des détails. Les détails doivent dépendre des abstractions.

Pour simplifier, au lieu de passer des objects concrètes genre URLSession, CoreData… On utilisera des abstractions, du coup des protocoles. Cela éliminera le couplage entre les couches supérieurs et les couches inférieurs (la couche n n’est pas dépendante de n-1). Dans notre exemple, j’ai choisi d’ajouter une couche (boundary) entre notre viewModel et le service que j’ai appelé HTTPClient.

enum HTTPClientResult {
    case success(Data, HTTPURLResponse)
    case failure(Error)
}

protocol HTTPClient {
    func get(from url: URL) async throws -> HTTPClientResult
}

final class ListViewModel: ObservableObject {
    @Published var repos: [Repo] = []
    private let httpClient: HTTPClient
    
    init(httpClient: HTTPClient) {
        self.httpClient = httpClient
    }


    func fetch() async throws {
        guard let url = URL(string: "www.walidsassi.com/repos.json") else { return }
        do {
            let result = try await httpClient.get(from: url)
            ....
           } catch {}
     }
}

Au moment de la création de mon view model, j’injecterai l’implémentation concrete par exemple de cette manière

@StateObject var viewModel =  SwiftReposListViewModel(httpClient: URLSessionHTTPClient(session: URLSession.shared))

En effet URLSessionHTTPClient implémente concrètement le protocole HTTPClient en utilisant URLSession. Imaginons que je veux implémenter d’une autre manière en utilisant Alamofire ou Moya, j’aurai à créer seulement ma classe AlamofireHTTPClient ou MoyaHTTPClient qui implémentera le protocole HTTPClient.

L’intérêt de l’inversion se manifeste au niveau des tests unitaires ou on peut créer un HTTPClientSpy qui respecte le protocole HTTPClient et on l’implémente sans effectuer un appel réel réseau via URLSession ou d’autres API.

class ListViewModelTests: XCTestCase {

    func testListViewModelCreationShouldNotCallApi() {
        
        let (_,httpClient) = makeSUT()
        XCTAssertEqual(httpClient.callCount, 0)
    }
    
    func testListViewModelcallFetchShouldCallAPI() async throws {
        let (sut,httpClient) = makeSUT()
        try await sut.fetch()
        XCTAssertEqual(httpClient.callCount, 1)
    }

    private func makeSUT() -> (sut: ListViewModel, client: HTTPClientSpy) {
        let client = HTTPClientSpy()
        let sut  = ListViewModel(httpClient: client)
        return (sut,client)
    }
    
    private class HTTPClientSpy: HTTPClient {
        var callCount = 0

        func get(from url: URL) async throws -> HTTPClientResult {
            callCount += 1
            return .success(Data(), HTTPURLResponse())
        }
    }

Sans l’utilisation des abstractions du genre le protocole HTTPClient, effectuer des tests unitaires peut être impossible malgré qu’on peut utiliser l’héritage et redéfinir les méthodes de la couche inférieur de la même manière qu’on a fait. L’utilisation des protocoles, pour inverser les dépendances et créer un boundary entre les couches de notre application, a des avantages énormes si on veut par exemple transformer une couche vers une framework ou librairie aussi.

retournons à notre session à base de Async await comment peut ont inverser la dépendance?

protocol URLSessionProtocol {
    func data(from url: URL, delegate: URLSessionTaskDelegate?) async throws -> (Data, URLResponse)
}

extension URLSessionProtocol {
    func data(from url: URL, delegate: URLSessionTaskDelegate? = nil) async throws -> (Data, URLResponse){
        try await data(from: url, delegate: nil)
    }
}

extension URLSession: URLSessionProtocol {}
class URLSessionHTTPClient: HTTPClient {
    private let session: URLSessionProtocol
    
    init(session: URLSessionProtocol = URLSession.shared) {
        self.session = session
    }
    
    struct UnexpectedValuesRepresentation:  Error {}
    
    func get(from url: URL) async throws -> HTTPClientResult {
        do {
            let (data, response) = try await session.data(from: url)
            if let response = response as?  HTTPURLResponse, response.statusCode == 200 {
                return .success(data, response)
            } else {
                return .failure(UnexpectedValuesRepresentation())
            }
        } catch {
            return .failure(error)
        }
    }
}

l’idée est de créer un URLSessionProtocol qui contient une fonction qui possède la même signature que celle définit par la nouvelle api async await ensuite implementer le protocole en faisant appel à la vrai méthode try await data(from: url, delegate: nil) et enfin on étendra la classe URLSession afin qu’elle respecte le nouveau protocole. Cette technique nous permettra ensuite de tester notre URLSessionHTTPClient en injectant un fake qui respecte le protocole URLSessionProtocole au lieu d’injecter un objet de type URLSession.

Conclusion

En programmation orienté object, l’injection et l’inversion des dépendances sont sources de confusion chez la communauté des développeurs indépendamment du nombre d’années d’expériences. L’inversion constitue un principe qui rassure que notre logiciel ou application ne soit pas rigide en changements grâce à l’utilisation des abstractions(protocoles) et que notre code soit le minimum relié à l’infrastructure (les couches inférieurs de notre application liés à la communication réseau ou la gestion de cache, envoie des données analytics à travers une framework bien spécifique).

Références:

Meet async/await in Swift

iOS unit testing by examples – Jon Reid

Use async/await with URLSession

fr_FRFrench