Walid SASSI

Création d’un EDSL avec ResultBuilder (une approche déclarative en Swift)

Durant des années, j’ai confondu langage de programmation et DSL (Domain Specific Language).

Ne pensant pas que HTML pouvait être un DSL tout comme SQLGHERKIN  et bien d’autres.

Dans l’écosystème d’Apple, on utilise très souvent les DSL sans les connaitre, par exemple, le fichier podfile de CocoaPods ou même SwiftUI.

En effet, l’objectif de cet article est de simplifier le concept de DSL en présentant un exemple pratique qui nous permet d’écrire à travers des fonctions dites « builder » un DSL permettant d’appliquer des mises en forme sur des chaines de caractères.

Pour simplifier, on veut écrire un DSL qui permet d’écrire un code déclaratif plutôt que l’approche impérative que l’on implémente couramment lors de la mise en forme d’une chaine de caractères.

Chronologiquement, on va essayer de présenter:

  • Qu’est ce qu’un DSL ou langage dédié (traduction Wikipedia)?
  • Qu’est ce qu’un ResultBuilder?
  • Comment on peut utiliser en pratique un ResultBuilder pour créer un DSL qui permet de simplifier l’écriture des contraintes (NSAttributedConstraint)?

Qu’est ce qu’un DSL?

Un DSL est un langage dédié à un domaine particulier. Par exemple, SQL est un DSL pour interagir avec une base de données relationnelles. Si vous utilisez SPM, CocoaPods ou Carthage vous utilisez un DSL pour installer vos frameworks. Par exemple, le fichier podfile est un fichier en Ruby et toutes les commandes qu’on écrit genre pod « nom de framework », ~> 2.0 respecte un syntaxe bien spécifique qui va faire appel à du code en Ruby.

// Cocoapods
platform :ios, '15.0'
use_frameworks!

target 'MyApp' do
  pod 'Utils', '~> 0.2'
  pod 'rxswift'
end

Sans cette écriture assez déclarative, on sera dans l’obligation d’écrire de pure Ruby pour pouvoir installer ces pods.

De la même manière pour SPM, le fichier package.swift est écrit dans un langage spécifique au domaine d’installation des frameworks mais à la base c’est du langage Swift.

// swift-tools-version:5.1
import PackageDescription

let package = Package(
    name: "MyLibrary",
    platforms: [
        .macOS(.v10_15),
    ],
    products: [
        .library(name: "MyLibrary", targets: ["MyLibrary"]),
    ],
    dependencies: [
        .package(url: "https://url/of/another/package/named/Utility", from: "1.0.0"),
    ],
    targets: [
        .target(name: "MyLibrary", dependencies: ["Utility"]),
        .testTarget(name: "MyLibraryTests", dependencies: ["MyLibrary"]),
    ]
)

Cette manière d’écrire du code déclaratif est assez répondu dans les DSL que j’ai vu. Cela est du à mon avis que l’écriture déclarative est plus simple et élégante que celle qu’on écrit généralement en pensant d’une façon impérative.

Dans son livre « Domain-Specific Languages« , Martin Fowler distingue entre deux types de DSL:

  • Les DSL internes qu’on appelle généralement Embeded Domain specific Language.
  • Les DSL externes

EDSL utilise généralement un autre langage pour mettre ses règles et son syntaxe qui est à la base du langage accueillant. Par exemple, SPM utilise Swift pour écrire le fichier package.swift alors il respecte le syntaxe de Swift.

Par contre SQL est un DSL externe qui possède sa syntaxe à lui et qui n’est pas lié à un langage existant.

ResultBuilder:

ResultBuilder est une évolution qui arrivait en Swift 5.4. C’est une évolution pourquoi? car son ancêtre est les functions builder apparus avec Swift 5.1. vous pouvez suivre la proposition faite par John McCallDoug Gregor ici SE-0289. En effet, ResultBuilder permet de créer une nouvelle valeur à la base d’une séquence entré en paramètre. Dans notre cas, nous voulons retourner un NSAttributedString qui constitue la combinaison de plusieurs NSAttributedString (un nombre qu’on maitrise pas). Pour ce faire, on peut créer un nouveau type qu’on appellera par exemple

Comment peut-on utiliser en pratique un ResultBuilder pour créer un DSL simplifiant l’écriture des contraintes (NSAttributedConstraint)

L’utilité de notre EDSL sera d’écrire d’une manière déclarative très proche de SwiftUI une fonction permettant de mettre en forme une liste de chaine de caractères.

On veut par exemple afficher ce texte:

On fera un appel à une fonction qu’on peut appeler welcomeBuilder avec ses paramètres :

welcomeBuilder(name: "Walid",titles: ["Tunisian", "iOS Developer", "work at Sephora"])

Ce qui nous intéresse comment on veut écrire cette fonction de la manière la plus déclarative possible. Imaginons par exemple que notre code à la fin sera écrit de cette manière 🙂 :

func welcomeBuilder(name: String, titles: [String]) -> NSAttributedString {
    if !titles.isEmpty && !name.isEmpty {
        Text("Hello ")
        Text(name).color(.red)
        for title in titles {
            SpecialCharacters.comma
            SpecialCharacters.lineBreak
            Text(title)
                .font(.systemFont(ofSize: 20))
                .color(.black)
        }
    } else {
        Text("No Title")
    }
}

Vous remarquez surement qu’on est très proche de SwiftUI. Si par exemple, nous écrivons le code d’une manière habituel, on aura un code très proche de celui la:

func welcome(name: String, titles: [String]) -> NSAttributedString {
    let message = NSMutableAttributedString()
    if !titles.isEmpty && !name.isEmpty {
        let attributes = [NSAttributedString.Key.backgroundColor :
                            UIColor.red]
        message.append(NSAttributedString(string: "Hello "))
        message.append(NSAttributedString(string: name, attributes:
                                            attributes))
        for title in titles {
            message.append(NSAttributedString(string: ", "))
            message.append(NSAttributedString(string: "\n"))
            let attributes2 = [
                NSAttributedString.Key.font : UIFont.systemFont(ofSize: 20),
                NSAttributedString.Key.backgroundColor : UIColor.black
            ]
            message.append(NSAttributedString(string: title, attributes: attributes2))
        }
        
        return message
    } else {
        message.append(NSAttributedString(string: "No Title"))
        return message
    }
}

Vous remarquez surement la différence entre les deux méthodes et le nombre de lignes de code qu’on peut gagner.

Pour réaliser la création du DSL relatif à la première méthode, on peut utiliser un nouveau type de fonctions qui sont les result Builder.

@resultBuilder
struct AttributedStringBuilder { ... }

On ajoute l’annotation @resultBuilder avant le nom du nouveau type (auparavant on utilisait @_functionBuilder).

En effet, l’ajout de l’annotation nous ramène à implémenter obligatoirement une méthode static buildBlock

static buildBlock

Cette méthode permet de construire le résultat final produit par le nouveau type AttributedStringBuilder qui sera normalement de type NSAttributedString. En effet, la méthode prendra en paramètre un nombre inconnu de NSAttributedString. L’idée ensuite, est de nous permettre de passer une séquence de NSAttributedString à cette méthode afin de nous retourner le résultat finale attendu.

La méthode utilise un variadic paramètre pour gérer le nombre inconnu de paramètres qu’on peut envoyer à notre builder.

static func buildBlock(_ components: NSAttributedString...) -> NSAttributedString {
  let resultAttributedString = NSMutableAttributedString()
    for component in components {
        resultAttributedString.append(component)
    }
  return resultAttributedString
}

Vous remarquez que la logique faite par le buildBlock est très simple, on essayera de « append » à chaque fois un composant de type NSAttributedString à notre resultAttributedString.

On peut exploiter notre AttributedStringBuilder de cette manière. En effet, la méthode welcomeBuilder ajoute à chaque fois un NSAttributedString relatif à un texte. N’oubliez pas d’ajouter une nouvelle annotation relative à notre nouvelle type AttributedStringBuilder. Vous remarquez la magie derrière qui permet au compilateur de transférer nos Text(« Hello ») et Text(name) vers la méthode buildBlock afin de construire le résultat final.

typealias Text = NSMutableAttributedString
extension NSMutableAttributedString {
  convenience init(_ string: String) {
    self.init(string: string)
  }
}
@AttributedStringBuilder
func welcomeBuilder(name: String, titles: [String]) -> NSAttributedString {
    Text("Hello ")
    Text(name)
}

Imaginons maintenant qu’on veut appliquer une mise en forme spécifique à un Text (changement de couleur de text ou de font par exemple). On peut le faire simplement en ajoutant une petite extension sur NSAttributedString.

extension NSMutableAttributedString {
    .....
    func color(_ color: UIColor) -> NSMutableAttributedString {
        self.addAttribute(
            .backgroundColor,
            value: color,
            range: NSRange(location: 0, length: self.length)
        )
        return self
    }
}

Ce changement n’affectera pas notre EDSL et comment notre ResultBuilder transforme ces Text vers le NSAttributedString final. On écrira par exemple:

func welcomeBuilder(name: String, titles: [String]) -> NSAttributedString {
    Text("Hello ")
    Text(name).color(.red)
}

Poussons notre ResultBuilder DSL

Imaginons maintenant qu’on veut afficher un message d’erreur à la place de Hello ‘name’. On est dans l’obligation d’ajouter une condition pareil à celle la:

@AttributedStringBuilder
func welcomeBuilder(name: String, titles: [String]) -> NSAttributedString {
    if !name.isEmpty {
        Text("Hello ")
        Text(name).color(.red)
    } else {
        Text("No Title")
    }
}

Cependant notre ResultBuilder aura des problèmes à interpréter ce code Swift, vu qu’il ne connait pas comment il doit interagir avec les conditions et quel résultat il doit retourner.

ResultBuilder control flow error

La solution est d’implémenter deux méthodes statics dans notre AttributedStringBuilder qui sont :

static func buildEither(first component: NSAttributedString) -> NSAttributedString {
   return component
}

static func buildEither(second component: NSAttributedString) -> NSAttributedString {
   return component
}

La première méthode permet de gérer la première condition de vérité en retournant le composant tel qu’il est. Concernant la deuxième méthode, elle permet de gérer le cas de else.

Imaginons que l’on veuille appliquer  une mise en forme sur les deux components texts lorsque la condition est vrai. On peut à ce moment écrire notre méthode buildEither(first component) de cette manière :

static func buildEither(first component: NSAttributedString) -> NSAttributedString {
    var resultAttributedString = NSMutableAttributedString()
    resultAttributedString = component as! NSMutableAttributedString
    resultAttributedString.addAttribute(
          .foregroundColor,
          value: UIColor.green,
          range: NSRange(location: 0, length: component.length)
     )
    return resultAttributedString
}

Vous constatez surement qu’au niveau de l’appel de ma fonction welcomeBuilder on envoie un tableau de String pour lequel on applique une mise en forme bien spécifique.

On écrit maintenant le code suivant :

@AttributedStringBuilder
func welcomeBuilder(name: String, titles: [String]) -> NSAttributedString {
    if !name.isEmpty && !titles.isEmpty {
        Text("Hello ")
        Text(name).color(.red)
        for title in titles {
            Text(title).font(.systemFont(ofSize: 20))
        }
    } else {
        Text("No Title")
    }
}

pareil à la structure conditionnelle, le ResultBuilder ne sait pas comment il va faire pour itérer sur une séquence de valeur genre un tableau de String. Alors on doit l’informer à travers une méthode static qui est le buildArray:

static func buildArray(_ components: [NSAttributedString]) -> NSAttributedString {
   let attributedString = NSMutableAttributedString()
   for component in components {
      attributedString.append(component)
   }
return attributedString
}

En effet, le résultat de l’itération sur le tableau titles est un NSAttributedString que l’on retourne ensuite à la méthode principal static buildBlock.

notre DSL commence à prendre forme sauf que le résultat final n’est pas assez lisible:

L’idée maintenant c’est d’ajouter des séparateurs mais bien sur en utilisant notre langage spécifique AttributedStringBuilder

if !name.isEmpty && !titles.isEmpty {
   Text("Hello ")
   SpecialCharacters.lineBreak
   Text(name).color(.red)
   for title in titles {
       SpecialCharacters.comma
       SpecialCharacters.lineBreak
       Text(title).font(.systemFont(ofSize: 20))
   }
} else {
   Text("No Title")
}

Pour ce faire, j’ai ajouté un nouveau type énumère appelé SpecialCharacters avec deux cases jusqu’à maintenant:

enum SpecialCharacters {
    case lineBreak
    case comma
}

Afin que mon nouveau type soit reconnu par mon DSL, je dois implémenter une nouvelle méthode qui est le buildExpression:

static func buildExpression(_ expression: SpecialCharacters) -> NSAttributedString {
  switch expression {
     case .lineBreak:
       return Text("\n")
     case .comma:
       return  Text(",")
     }
}

Vous remarquez que la méthode transforme une expression à base de SpecialCharacters vers son equivalent NSAttributedString ou en autre Text.

Mais on remarquera qu’on a des erreurs bizarres après l’ajout de la méthode buildExpression:

Le problème provient du fait que la méthode sera appelé systématiquement pour chaque component quel que soit Text ou SpecialCharacters alors pour résoudre ce problème on doit implémenter buidExpression pour gérer le cas spécifique de Text :

static func buildExpression(_ expression: NSAttributedString) -> NSAttributedString {
    return expression
}

La différence réside dans le paramètre d’entré qui sera de type NSAttributedString.

Conclusion

Nous constatons qu’un ResultBuilder constitue un outil assez poussé pour créer des DSL élégants. Le sujet peut être creusé avec des exemples différents notre AttributedStringBuilder. Vous pouvez retrouvez dans ce repo GitHub plusieurs idées de DSL à base de result builder.

Références:

https://www.hackingwithswift.com/swift/5.4/result-builders

https://www.swiftbysundell.com/articles/deep-dive-into-swift-function-builders/

https://theswiftdev.com/result-builders-in-swift/

https://www.raywenderlich.com/books/swift-apprentice/v7.0/chapters/20-result-builders

https://martinfowler.com/dsl.html

Gestion des accès concurrentiels en iOS avec les Actors

Depuis la version 5.5 de Swift, Apple a introduit un nouveau type de données dit Actor. Le nouveau type permet d’assurer que l’état d’un objet de type référence ne peut pas être modifié que par un seul thread. Cela permet d’assurer l’atomicité des opérations et d’éviter les problèmes dit « Data Races » ou accès concurrentiel à une ressource partagée. Cette solution ne constitue pas une nouveauté car elles existent d’autres bien anciennes et connues comme les mutex, les sémaphores, les moniteurs etc.. (toutes ses mécanismes sont inclus par les API Lock et GCD au niveau de swift). La nouveauté , que Apple a crée un nouveau type qui abstrait derrière toutes les solutions qu’on a évoqué et qui assure une serialisation des actions dans un environnement multi-threading. Cette article constitue un peu un « état de l’art » des articles et des vidéos que j’ai lu et que j’ai regardé sur ce sujet et ne constitue pas une nouveauté en termes des connaissances.

Je commencerai tout d’abord par présenter les catégories des types de données en iOS. Puis Je présenterai avec des exemples réels des situations d’exclusion mutuelles concrètes et comment on peut les détecter avec Xcode. Ensuite , comment l’utilisation des actors peut résoudre les problèmes d’exclusion.

Référence vs copie(valeur)/ Heap vs Stack

Dans la majorité des langages de programmation, les types de données qu’on utilise se distingue par leur manière de stockage au niveau de la mémoire centrale. Cela influe ensuite sur comment on peut lire et surtout modifier nos données.

Swift distingue deux catégories de types de données selon ce critère. Ceux gérés par référence et d’autres par copie.

  • Les structures, les enum (tout type scalaire) sont en copie.
  • Les classes, les fonctions (y compris les closures) et maintenant les Actors sont en référence.

Au niveau mémoire, les types dits par copie ou valeur sont stockés au niveau de la pile( stack) du programme. Par contre, ceux par référence existent dans la mémoire dynamique dite heap.

La mémoire heap permet une allocation dynamique des ressources au moment de l’exécution (Runtime). Par contre la pile est une mémoire statique (allocation se fait au moment de la compilation) limitée en terme de capacités mémoire par rapport à la heap. Cependant cette mémoire est thread safe, vu qu’on peut pas avoir deux threads qui accèdent à la même stack. En autre, au niveau de la pile, il n’y pas d’objet partagé. Cela, élimine la possibilité d’avoir le problème d’exclusion mutuelle (Data Races). Par contre, au niveau de la heap vu que la mémoire peut être partagée entre plusieurs threads, le risque d’avoir deux threads qui accèdent au même objet est fort probable. Cela se manifeste généralement au niveau des tests réels sur les appareils mais il y a des outils qui nous permettent de détecter à travers Xcode les data races que je vais montrer ensuite 🙂

le multithreading Heap vs Stack

Détection des Data races:

Nous avons ce code qui permet d’incrémenter un compteur puis de créer de créer un taskGroup pour 10000 appels de la méthode Increment.

class Counter {
    private var accounter = 0
    func increment() async {
        accounter += 1
        print("\(accounter) - \(Thread.current)")
    }
}

class AccounterViewModel {
    let counter = Counter()
    func printMessage() async {
        _ = await withTaskGroup(of: Void.self, body: { group -> Void in
            for _ in 1...10000 {
                group.addTask{ [weak self] in
                    await self?.counter.increment()
                }
            }
        })
    }
}

struct ContentView: View {
    var viewModel = AccounterViewModel()
    var body: some View {
        Text("Hello, world!")
            .padding()
            .task {
                await viewModel.printMessage()
            }
        
    }
}

Si vous exécutez ce code, vous allez voir que tout passe correctement à premier vu au niveau du terminal. Vous aurez un affichage normale de 1 à 10000. En effet, pour détecter qu’il y a un problème, il faut activer la détection des data races au niveau de Xcode de cette manière:

  • On sélectionner le schema de la target -> on clique sur éditer le schema -> on choisit l’action run -> puis l’option diagnostics -> on coche l’option thread Sanitizer
Activation du Thread Sanitizer

Maintenant, si vous exécutez une autre fois, vous aurez une alerte et un blocage d’exécution au niveau de deux thread (par exemple dans mon cas T1 et T6) qui veulent incrémenter en même temps la variable partagé accounter.

Détection de la data race

Le compilateur est généreux :), il nous indique clairement la ligne qui a déclenché le problème

indication du problème par Xcode

NB: L’activation de thread Sanitizer peut agir sur la performance de la machine. Il est recommandé de ne pas activer énormément et surtout pas sur la CI/CD. Pour plus d’informations sur ce sujet, vous pouvez consulter cet article.

Une solution avec GCD

Une première solution pour garantir l’exclusion mutuelle c’est d’utiliser le GCD. On peut utiliser un dispatch queue de type concurrent avec une barrier qui contrôle l’accès en écriture sur la variable accounter

 private var queue = DispatchQueue(label: "accounter", attributes: .concurrent)
    func increment() async {
        queue.sync(flags: .barrier) {
            accounter += 1
            print("\(accounter) - \(Thread.current)")
        }
    }

Utilisation des actors

Afin de résoudre ce problème, la solution est devenu très simple avec Apple, c’est juste de transformer notre class Counter vers un actor

actor Counter {
    private var accounter = 0
    func increment() {
        accounter += 1
        print("\(accounter) - \(Thread.current)")
    }
}

Un actor se comporte de la même manière qu’une classe (implémente un protocole, on peut l’étendre…) et il existe dans le heap vu qu’il est de type référence. Cependant on peut pas hériter d’un actor ce qui implique qu’on peut pas utiliser les annotations override, final, open et pas de convenience Initializer :).

RQ: La méthode increment ne contient pas async dans sa déclaration vu que toute fonction ou attribut existant dans un actor est par défaut async et isolé.

@MainActor

L’annotation @MainActor vient pour remplacer API GCD Dispatch.queue.async. Elle permet de s’assurer qu’un code qui agit sur la partie UI s’execute dans le main thread. J’ai modifié le code afin d’afficher les valeurs des compteurs de cette manière:

actor Counter {
    private var accounter = 0
    func increment() -> Int {
       accounter += 1
       return accounter
    }
}

class AccounterViewModel {
    let counter = Counter()
    func printMessage() async -> String {
        var finalString = ""
        _ = await withTaskGroup(of: Int.self, body: { group -> Void in
            for _ in 1...10000 {
                group.addTask{ [unowned self] in
                    let currentCounter = await self.counter.increment()
                    return currentCounter
                }
            }
            for await counterValue in group {
                finalString += "\(counterValue) \t"
            }
        })
        return finalString
    }
}

La méthode increment retourne un entier et chaque task crée va s’occuper de retourner un entier. Vous avez remarqué que le type de retour de chaque task du group est maintenant Int (withTaskGroup(of: Int.self). Ensuite, on parcourt notre groupe de valeur et on ajoute chaque valeur à la chaine finalString avec un petit espace :). Maintenant, on passera à la modification du contentView

struct ContentView: View {
    var viewModel = AccounterViewModel()
    @State var text: String?
    var body: some View {
        Text( text ?? "Hello, world!")
            .padding()
            .task {
                text = await viewModel.printMessage()
            }
    }
}

@State permet de modifier une variable de type valeur à l’intérieur d’une struct. Il ne faut pas oublier qu’au moment qu’on modifie un attribut dans une structure, on modifie toute la structure derrière (une nouvelle structure est crée derrière).

Vous remarquez aussi ce message de warning qui nous indique que le système craint que la modification de la valeur text peut ne pas se faire dans le main thread. Ce message ne peut être afficher qu’en activant un swift flags au niveau de build settings de cette manière:

  • -Xfrontend
  • -warn-concurrency
  • -Xfrontend
  • -enable-actor-data-race-checks

Activation Concurrency et data races warning

Une des solutions est soit c’est d’ajouter l’annotation @MainActor avant la structure ContentView

@MainActor
struct ContentView: View {.....}

ou de déclarer notre viewModel comme étant MainActor cela nous ramène à le rendre adapté au traitement asynchrone d’un actor. Une solution est de le mettre compatible avec le protocole ObservableObject et de l’annoter avec @StateObject lors de la déclaration.

Problème déclaration de viewModel comme @MainActor

@MainActor class AccounterViewModel: ObservableObject { ...}
struct ContentView: View {
@StateObject private var viewModel = AccounterViewModel()
....}

Références

https://www.hackingwithswift.com/books/ios-swiftui/why-state-only-works-with-structs

https://www.andyibanez.com/posts/structured-concurrency-with-group-tasks-in-swift/

https://www.swiftbysundell.com/articles/swift-actors/

https://www.guru99.com/stack-vs-heap.html

Test unitaire avec Async Await – Continuation et closures dans un client HTTP

Dans les deux premiers articles concernant async await et les tests unitaires, nous avons remarqué que la nouvelle API transforme d’une manière assez radicale la façon de tester (pas d’expectation, il faut ajouter async throws au niveau de chaque méthode de test…). Notre manière de tester ou d’écrire notre code de production change et on devient assez couplé avec la nouvelle concurrency API. Notre objectif, était de trouver une solution qui découple notre code de production de celle de test. Si jamais un jour, on veut s’en passer de async await vers une solution notre de tests ne serra pas cassé. Dans le dernier article, nous avons présenté la première partie de notre solution qui est l’utilisation de Loading system de Apple (appelé aussi URLProtocol). Dans cette dernière partie, je vais parler du Continuation et comment elle peut nous aider pour réaliser notre objectif.

Continuation

La continuation est un objet qui track l’état d’un program à partir d’un point précis. C’est tout simplement une autre présentation d’un thread. On peut dire que le système derrière crée pour chaque appel asynchrone une continuation au lieu d’un thread qui constitue ensuite le point de reprise lorsque le traitement asynchrone se termine.

Prenons par exemple ce code asynchrone à base de async await:

func HelloAsync() async throws {
    try await Task.sleep(nanoseconds: 2_000_000)
    print("Hello Async Await")
}
// Appel de la méthode 
await try HelloAsync()

Lorsque le code asynchrone se bloque pendant 2 second du à l’appel du Task.sleep, le système crée derrière une continuation qui capture l’état du système à ce moment précise. Après deux secondes, le système utilisera la continuation comme point de reprise correcte de l’état afin de continuer l’exécution (c’est pour cela on l’appel continuation).

L’utilité des continuation ne se limite pas seulement à ça, mais elle constitue un bridge entre nos anciens code écrit en utilisant les completions handler ou les delegates et API Async await et c’est pour cela qu’on va l’exploiter :).

Il y a deux types de continuations:

  • CheckedContinuation: mécanisme qui permet la reprise d’un code asynchrone tout en vérifiant que tout se passe bien en runtime.
  • UnCheckedContinuation: même mécanisme mais sans la vérification.

Pour plus de détails sur comment fonctionne les deux types de continuation vous pouvez regarder cette vidéo au WWDC 2021.

Une continuation permet de wrapper l’appel d’une closure et de retourner le résultat de cette dernière en utilisant certaines méthodes:

  • resume(returning:): resume le code asynchrone en cours (closure) et retourne la valeur de même type retourné par la closure.
  • resume(throwing:): resume le code asynchrone en cours (closure) et throw erreur.
  • resume(with:): resumes avec un Result<T?,Error?>, qui retourne une possible valeur ou une potentielle erreur.
  • resume(): ne retourne rien si la closure ne retourne rien.

Application sur un cas réel:

Reprenons notre exemple du URLSession en utilisant Async await. Notre problème que notre code de production et totalement couplé avec async await même en utilisant la technique d’inversion de dépendances citée dans le premier article de cette série

class URLSessionHTTPClient: HTTPClient {

func get(from url: URL) async throws -> HTTPClientResult {
            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())
            }
       }
}

L’idée de la continuation comme cité dans ce très bon article de Paul Hudson est de cohabiter async await avec notre ancien code qui n’est pas à la base mais comment le faire?

On va reprendre notre code à base de URLSession avant async await et on va faire appel à notre méthode à base de complétion handler dans une méthode qui utilise la continuation et les différents API qui découlent derrière:

func get(from url: URL, completion: @escaping (HTTPClientResult) -> Void ) {
        session.dataTask(with: url) { data, response, error in
            if let error = error {
                completion(.failure(error))
                return
            }
            if let data = data, let response = response as?  HTTPURLResponse, response.statusCode == 200 {
                return completion(.success(data, response))
            } else {
                return completion(.failure(UnexpectedValuesRepresentation()))
            }
        }.resume()
    }
    
    func get(from url: URL) async throws -> HTTPClientResult {
       try await withCheckedThrowingContinuation { continuation in
            get(from: url) { result in
                switch result {
                case let .failure(error):
                    continuation.resume(throwing: error)
                case .success:
                    continuation.resume(returning: result)
                }
            }
        }
    }

Le code de la première à base de closure (completion handler) me parait compréhensible et nécessite pas beaucoup de connaissance en swift. Cependant, vous remarquez que la deuxième méthode à base de continuation nécessite une explication plus profonde. Pour lancer une continuation, on commence par

try await withCheckedThrowingContinuation

Le checked throwing continuation indique que ce completion handler peut throw une erreur et que le système doit s’assurer que le resume doit se faire une et une seul fois. Ensuite, on appel notre méthode normal à base de completion handler. Selon le résultat retourné on va faire notre continuation.resume(…). Je rappelle qu’on a deux cas selon notre type énumère HTTPClientResult

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

Dans notre code de test rien ne va changer, mais on va faire appel à la méthode async await à base de continuation par exemple de cette manière.

func testGetFromURLShouldReturnHTTPClientResultOnRequestSuccessed() async throws {
        let httpResponse = anyHTTPURLResponse()
        let dataResponse = anyData()
        
        do {
        let result = try await resultValuesFor(data: dataResponse, response: httpResponse, error: nil)
            XCTAssertEqual(result?.response.statusCode, httpResponse.statusCode)
            XCTAssertEqual(result?.data, dataResponse)
        } catch {}
}

// MARK Helpers

private func anyHTTPURLResponse() -> HTTPURLResponse {
   return HTTPURLResponse(url: anyURL(), statusCode: 200, httpVersion: nil, headerFields: nil)!
}
  
private func anyData() -> Data {
   return "any Data".data(using: .utf8)!
}
private func resultValuesFor(data: Data?, response: URLResponse?, error: Error?, file: StaticString = #file, line: UInt = #line) async throws -> (response: HTTPURLResponse, data: Data)? {
        URLProtocolStub.stub(error: error, data: data, response: response)
        var receivedValues: (HTTPURLResponse, Data)?
        do {
            let result = try await makeSUT(file:file,line: line).get(from: anyURL())
            switch result {
            case let .success(receivedData, receivedResponse):
                receivedValues = (receivedResponse, receivedData)
            default:
                XCTFail("Expected failure, got \(result)", file: file, line: line)
            }
        } catch {}
        return receivedValues
 }

Ce code de test permet de s’assurer que lorsque j’effectue un stub à base URLProtocol avec une bonne réponse de type HTTPURLResponse et une Data, on attend à avoir un success. Pour minimiser la taille du code de la méthode de test testGetFromURLShouldReturnHTTPClientResultOnRequestSuccessed(), j’ai opté pour refactoriser l’appel de mon SUT vers la méthode resultValuesFor(data…). Lorsqu’on attend une réponse de success, on retournera un tuple qui contiendra logiquement la data et le HTTPURLResponse. On récupère ces deux valeurs et on effectue ses deux assertions:

XCTAssertEqual(result?.response.statusCode, httpResponse.statusCode)
XCTAssertEqual(result?.data, dataResponse)

Je rappelle toujours qu’on peut pas comparer depuis iOS 14 deux HTTPURLResponse, c’est pour cela je me limite à comparer les status codes.

PS: vous remarquez que je passe deux paramètres  file: StaticString = #filePath, line: UInt= #line au niveau de l’entête de la méthode resultValuesFor(…) ainsi qu’au niveau de notre méthode factory makeSUT(file: StaticString = #file, line: UInt = #line) -> HTTPClient. Ils sont nécessaires pour indiquer le nom de fichier et l’emplacement de la ligne d’erreur vu que ce code n’existe pas dans le corps de la fonction de test mais dans notre méthode privé helper.

Pour notre système à base de Loading System ou URLProtocol, rien à changé dans le code.

private class URLProtocolStub: URLProtocol {
    private static var stub: Stub?
    private struct Stub {
        let error: Error?
        let data: Data?
        let response: URLResponse?
     }
        
     static func stub(error: Error? = nil, data: Data? = nil, response: URLResponse? = nil) {
         stub = Stub(error: error,data: data,response: response)
     }
        
     static func startInterceptingRequests() {
            URLProtocol.registerClass(URLProtocolStub.self)
     }
        
     static func stopInterceptingRequest() {
      URLProtocol.unregisterClass(URLProtocolStub.self)
      stub = nil
     }

     override class func canInit(with request: URLRequest) -> Bool {
        return true
     }
        
    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        return request
    }

    override func startLoading() {
        if let error = URLProtocolStub.stub?.error {
           client?.urlProtocol(self, didFailWithError:error)
        }
       if let response = URLProtocolStub.stub?.response {
         client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
       }
      if let data = URLProtocolStub.stub?.data {
         client?.urlProtocol(self, didLoad: data)
       }
        client?.urlProtocolDidFinishLoading(self)
        
    }
        
     override func stopLoading() {}

  }

Conclusion

Durant ces trois articles concernant async await et les tests unitaires, l’idée principale était de trouver un compromis entre l’utilisation de la nouvelle API Concurency sans casser notre code de production qui généralement sera à la base de completion handler ou de delegates. L’objectif du prochain article c’est d’appliquer ces mêmes idées avec un code à base des bibliothèques réactives Combine ou Moya.

A très bientôt

For each desired change, make the change easy (warning: this may be hard), then make the easy change

Références:

https://www.hackingwithswift.com/quick-start/concurrency/how-to-use-continuations-to-convert-completion-handlers-into-async-functions

https://www.swiftbysundell.com/articles/connecting-async-await-with-other-swift-code/

Modern Concurency in Swift (Marin Todorov)

Kent Beck

Test unitaires avec Async Await – Utilisation de URL Loading System (URLProtocol) pour un client HTTP (Partie 2)

Introduction

Dans le dernier article, nous avons présenté deux des trois méthodes pour mocker nos appels HTTP à savoir:

  • Mocking à travers l’héritage.
  • Mocking à travers les protocoles.

On a expliqué que la première méthode ne peut pas être appliquée avec Async Await du à des restrictions techniques de l’API et que la meilleur façon jusqu’à maintenant c’est l’utilisation du mocking à base de protocoles. Cependant, il existe une solution bien meilleure que Apple a présenté au WWDC 2018, c’est l’utilisation de URL Loading system a base de URLProtocol.

Qu’est ce que l’URL Loading System?

URL Loading System constitue une interface entre votre application et toute URL qui respecte le(s) protocole(s) HTTP(s). Le système est composé principalement de URLSession et URLSessionConfiguration.

Apple offre derrière le capot de URL Session API et ses subordonnées URLSessionConfiguration, une classe abstraite URLProtocol qui peut intercepter la communication avec toute URL et offre 4 méthodes à implémenter qui sont:

class func canInit(with request: URLRequest) -> Bool
class func canonicalRequest(for request: URLRequest) -> URLRequest 
func startLoading()
func stopLoading()

Les 4 méthodes permettent de redéfinir selon notre besoin comment notre client HTTP réagit aux différents états de la communication (lancement, blocage, erreur, recevoir les données etc). Mais, c’est à nous ensuite de redéfinir selon notre besoin et indépendamment du type de notre client HTTP (URLSession, Async Await, Alamofire, Moya).

En plus de ces 4 méthodes, URLProtocol nous offre un URLProtocolClient qui permet de communiquer avec l’URL Loading System. Du coup, comment on peut exploiter URLProtocol afin de rendre notre code de test agnostic du code production?

Comment utiliser URLProtocol?

Tout d’abord, renommons notre URLSessionSpy vers URLProtocolStub qui implémentera la classe abstraite URLProtocol.

Au lieu de:

private class URLSessionSpy: URLSessionProtocol

On aura:

private class URLProtocolStub: URLProtocol 

On gardera toujours notre Stub avec les trois réponses attendus:

private static var stub: Stub?
    private struct Stub {
        let error: NSError?
        let data: Data?
        let response:URLResponse?
    }

Afin que notre URLProtocolStub commence ou arrête l’interception des requêtes HTTP , URLProtocol nous offre deux méthodes qui inscrit ou annule notre URLProtocolStub comme classe qui implémente URLProtocol (le nom protocol met en confusion beaucoup de développeurs, n’oubliez pas c’est une classe abstraite qu’on va redéfinir ces méthodes 🙂 ).

Pour des raisons de lisibilité de notre code de test, j’ai opté de mettre l’enregistrement et l’arrêt dans deux méthodes static 🙂

static func startInterceptingRequests() {
    URLProtocol.registerClass(URLProtocolStub.self)
}

static func stopInterceptingRequest() {
    URLProtocol.unregisterClass(URLProtocolStub.self)
}

Nous avons appris jusqu’à maintenant comment initialiser et dé-inscrire notre URLProcolStub. Maintenant passons à l’implémentation des deux premières méthodes.

override class func canInit(with request: URLRequest) -> Bool {
     return true
}

override class func canonicalRequest(for request: URLRequest) -> URLRequest {
     return request
}

La méthode canInit permet d’authoriser URLProtocolStub à intercepter les requêtes HTTP. Par contre, la deuxième méthode permet de retourner la forme canonique d’une requête. En effet, La forme canonique d’une URL constitue l’url d’origine. Si vous n’êtes pas sûr de votre URL alors URLProtocol vous offre une autre méthode qui permet de transformer une URLRequest vers une forme canonique:

canonicalRequest(for:)

Passons à la méthode startLoading() qui permet de gérer tous les résultats que notre Stub peut retourner:

override func startLoading() {
if let error = URLProtocolStub.stub?.error {
     client?.urlProtocol(self, didFailWithError:error)
}

if let data = URLProtocolStub.stub?.data {
    client?.urlProtocol(self, didLoad: data)
}
             
if let response = URLProtocolStub.stub?.response {
     client?.urlProtocol(
           self,
           didReceive: response,
           cacheStoragePolicy: .notAllowed
       )
  }
  
client?.urlProtocolDidFinishLoading(self)
}

Rappelons que notre URLProtocolClient constitue notre « Stub » des différents retours possibles. Pour realiser, ce dernier dispose d’un ensemble de méthodes qui réalisent ce stubbing. En cas d’erreur par exemple: client?.urlProtocol(self, didFailWithError:error). De la même manière en cas de recevoir une URLResponse, notre client nous renvoie cette réponse (ici, on n’a pas autorisé de mettre en cache la réponse retourné).

Finalement, on est doit redéfinir la dernière méthode stopLoading

override func stopLoading() {}

Comment exploiter URLProtocol dans les tests Async Await?

Passons maintenant à comment utiliser URLProtocol dans les tests qu’on a déjà écrit avec Async Await.

func testGetFromURLShouldThrowErrorOnRequestError() async throws {
    URLProtocolStub.startInterceptingRequests()
    let url = URL(string: "http://anyURL.com")!
    let error = NSError(domain: "any error", code: 1)
    URLProtocolStub.stub(error: error, data: nil, response: nil)
    let sut = URLSessionHTTPClient()
     do {
        _ = try await sut.get(from: url)
        } catch let receivedError as NSError {
             XCTAssertEqual(receivedError.domain, error.domain)
             XCTAssertEqual(receivedError.code, error.code)
     }
     URLProtocolStub.stopInterceptingRequest()
 }

Vous remarquez que notre SUT n’a pas besoin d’injecter une session de type URLSessionProtocol. Dans notre test, on attend à ce que notre méthode get(from: URL) déclenche une erreur lorsque l’appel à async await throw une erreur. Pour réaliser ce test, on stub une erreur en implémentant une méthode helper static à l’intérieur de la classe URLProtocolStub:

static func stub(error: NSError? = nil, data: Data? = nil, response: URLResponse? = nil) {
    stub = Stub(error: error,data: data,response: response)
}

Vous remarquez aussi qu’on doit déclencher et arrêter notre URLProtocolStub en appelant au début de notre méthode de test : URLProtocolStub.startInterceptingRequests() et URLProtocolStub.stopInterceptingRequest().

Notre assertion de test est tout simplement une vérification que l’erreur qu’on a déclenché à travers le stubbing est la même qu’on va recevoir après l’appel de la méthode get(from: URL).

Depuis iOS 14, Apple a modifié la structure de l’erreur retourné en ajoutant des informations concernant la dataTask ce qui ne permet pas de faire des comparaisons de genre:

XCTAssertEqual(receivedError, error)

La solution possible qui marche maintenant c’est la comparaison entre les domains et les codes des erreurs.

Vous remarquez que notre code de test est quasi indépendant de async await à minima au niveau du système de mocking. Cependant Async await couple la manière d’écrire les tests. en effet, nous avons des tests qui se terminent par async throws et une manière d’écrire des tests dépendantes avec la nouvelle API. Jusqu’à maintenant la solution unique de garder nos tests agnostic de async await c’est l’utilisation des Continuations qui seront l’object de la troisième partie sur Async await :).

A très bientôt

“Clean code always looks like it was written by someone who cares.” Michael Feathers

Références:

Test unitaires avec Async Await – comment rendre votre client HTTP indépendant de l’infrastructure? (Partie 1)

Introduction:

Dans cette article, qui constitue une continuation de mon dernier article sur l’injection et l’inversion de dépendances à base de async await, j’essayerai de vous présenter les méthodes qui existent pour faire des tests unitaires de valeurs à base de async await et comment ensuite rendre nos tests indépendamment de async await ou d’autres API réseau que notre clientHTTP peut l’implémenter.

Approches pour tester un client HTTP

En effet, ils existent trois approches pour effectuer des tests unitaires d’un client HTTP:

  • Mocking à travers l’héritage
  • Mocking à base des protocoles
  • URL Loading system

Rappelons tout d’abord notre code de production


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

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

class URLSessionHTTPClient: HTTPClient {

func get(from url: URL) async throws -> HTTPClientResult {
            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())
            }
       }
}
 

Notre URLSessionHTTPClient constitue une implémentation à base de URLSession de notre HTTPClient afin de donner la possibilité de changer notre clientHTTP en respectant toujours le même contrat avec la couche supérieur qui va utiliser notre client HTTP (on respecte l’inversion de dépendances ici). Cependant vous remarquez surement ici que notre protocole est devenu dépendant de async await. On est dans l’obligation ajouter async throws dans la déclaration de notre méthode get(from url). Alors, on respecte pas un autre principe qui est l’OCP (open close principle) vu qui si nous voulons par exemple retourner à utiliser les completions handlers, nous devons toucher à notre protocole et casser un code qui marche et tout nos tests. Mais continuons de cette manière, je vais vous montrer dans la deuxième partie une technique qui permet de remettre de l’ordre aux deux principes OCP et DI 🙂 .

Tout d’abord, la question qui se pose on veut tester quoi ?. On veut tester le comportement de notre méthode get(From url) par rapport aux différents résultats possibles de la communication avec un serveur distant. Je peux imaginer plusieurs tests unitaires qui permettent par exemple de vérifier :

  • Si ma méthode get(from url: URL) throw une erreur au moment que session.data(from: url) déclenche une exception.
  • Si ma méthode get(from url: URL) me retournera un success dans le cas ou session.data(from: url) retourne une data plus une URLResponse avec un status code == 200.
  • etc

Il y a plusieurs cas de tests qu’ils faut penser à coder, je me limiterai à ces cas.

Pour réaliser des tests unitaires correctes, on doit pas lancer la méthode session.data(from: url) car on aura un appel réel à ce moment et on parlera plutôt d’un test end to end. D’autre part, imaginons que notre back n’est prêt jusqu’à maintenant mais seulement on a un contrat d’interface avec le payload de retour back. Il est recommandé de créer une méthode double (mocker, stuber…).

PS: Afin de comprendre la différence entre les différents types de Doubles, je vous laisse avec cet article de Martin Fowler

public func data(from url: URL, delegate: URLSessionTaskDelegate? = nil) async throws -> (Data, URLResponse)

Mocking à travers l’héritage

Tester URLSession version Async Await à travers la technique de redéfinition ne peut pas s’effectuer pour la simple raison que la méthode data(from url, delegate: URLSessionTaskDelegate? = nil) n’est open 🙁 alors on peut la redéfinir

Le compilateur ne permet de redéfinir une fonction ou méthode non open

redéfinir la méthode data(from url) de URLSession Async Await

Cette méthode reste possible si on veut par exemple utiliser la version non async await de URLSession par exemple ici:

private class URLSessionSpy: URLSession {
        private static var stub: Stub?
        private struct Stub {
            let error: NSError?
            let data: Data?
            let response: URLResponse?
        }
  
        static func stub(error: NSError? = nil, data: Data? = nil, response: URLResponse? = nil) {
            stub = Stub(error: error,data: data,response: response)
        }
        override func dataTask(with url: URL, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask {
/// Je peux ici faire le stub d'une erreur et ma méthode retournera la méme erreur que je passerai dans mon test plus un fake de URLSessionDataTask
            if let error = URLSessionStub.stub?.error {
                completionHandler(nil,nil,error)
            }
            return URLSessionDataTaskFake()
        }
        private class URLSessionDataTaskFake: URLSessionDataTask {}
 }

Dans mon test je peux faire écrire ce scénario

func testGetFromURLShouldThrowErrorOnRequestError() {
        let url = URL(string: "http://anyURL.com")!
        let error = NSError(domain: "any error", code: 1)
        let sessionSpy = URLSessionSpy()
        URLSessionSpy.stub(error: error, data: nil, response: nil)
        let sut = URLSessionHTTPClient(session: sessionSpy)
        let exp = expectation(description: "waiting for error")
        sut.get(from: url) { result in
            switch result {
            case let .failure(receivedError as NSError):
                XCTAssertEqual(receivedError , error)  
            default: break
            }
            exp.fulfill()
        }
        wait(for: [exp], timeout: 1.0)
    }

PS: il faut bien sur à ce moment modifier la méthode get(from url) pour ne pas être en forme async await et retourner un completion handler de cette manière

protocol HTTPClient {
    func get(from url: URL, completion: @escaping (HTTPClientResult) -> Void)
}

Vous remarquez surement l’impact de choix de async await ou non sur nos tests et notre code de production et qu’un passage à cette technologie peut casser notre code de production ainsi que nos tests qui sont couplés avec notre code de production.

Mocking à base de protocole

Le mocking à base de protocole nous permet d’écrire nos tests unitaires à base de async await. Dans le dernier article, on a parlé de cette technique qui permet d’inverser la dépendance à async await en créant un URLSessionProtocol qui contiendra la même signature de méthode data(from url) de API Async Await

Au niveau de notre code de production on aura

protocol URLSessionProtocol {
  func data(from url: URL, delegate: URLSessionTaskDelegate? = nil) async throws -> (Data, URLResponse)
}
 /// étendre notre protocole pour fournir une implémentation par défaut de ou on fait appel à la méthode data(from url) de Async Await 

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

 /// enfin étendre URLSession pour se confirmer au protocole
 /// URLSessionProtocol
extension URLSession: URLSessionProtocol {}

Au niveau de code test, on aura tout d’abord un URLSessionSpy qui respectera le protocole URLSessionProtocole:

private class URLSessionSpy: URLSessionProtocol {
        private static var stub: Stub?
        private struct Stub {
            let error: NSError?
            let data: Data?
            let response: URLResponse?
        }
  
        static func stub(error: NSError? = nil, data: Data? = nil, response: URLResponse? = nil) {
            stub = Stub(error: error,data: data,response: response)
        }

      func data(from url: URL, delegate: URLSessionTaskDelegate? = nil) async throws -> (Data, URLResponse) {
      if let error = URLSessionSpy.stub.error {
         throw error 
      }
}

reformulons notre test un peu maintenant:

func testGetFromURLShouldThrowErrorOnRequestError() async throws {
        let url = URL(string: "http://anyURL.com")!
        let error = NSError(domain: "any error", code: 1)
        URLProtocolStub.stub(error: error, data: nil, response: nil)
        let sessionSpy = URLSessionSpy()
        let sut = URLSessionHTTPClient(session: sessionSpy)
        do {
            _ = try await sut.get(from: url)
        } catch let receivedError as NSError {
            XCTAssertEqual(receivedError , error)
        }
    }

Cette technique nous permet de tester node code de production à base de Async Await sans redéfinir la méthode data(from url) (rappelons qu’on peut pas la redéfinir vu qu’elle n’est pas open). Cependant, cette technique constitue une contrainte pour notre code de production qui pour la seule raison de faire des tests, on doit lui ajouter un protocole couplé avec la technologie async await.

Conclusion

Tester le comportement de la couche service ou client HTTP est primordiale si on veut s’assurer qu’on a rien cassé au niveau de notre code de production. Cependant, avec Async Await malgré qu’elle simplifie énormément l’écriture de notre code asynchrone, elle peut engendré une réfactorisation assez consistante en tests unitaires afin qu’ils passent. Dans la deuxième partie de cet article, je vous présenterai un troisième technique que je pense peut rendre notre code de production et de tests indépendants de Async Await qui sont URL Loading system et la Continuation.

Références: