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 SQL, GHERKIN 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 McCall, Doug 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
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 🙂
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
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.
Le compilateur est généreux :), il nous indique clairement la ligne qui a déclenché le problème
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
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/
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.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:
- URL Loading System https://developer.apple.com/documentation/foundation/url_loading_system
URLProtocol
https://developer.apple.com/documentation/foundation/urlprotocol- Testing Tips & Tricks (WWDC 2018 – Session 417) https://developer.apple.com/videos/play/wwdc2018/417
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
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:
URLSession
reference https://developer.apple.com/documentation/foundation/urlsession- Testing Tips & Tricks (WWDC 2018 – Session 417) https://developer.apple.com/videos/play/wwdc2018/417
- Modern Concurrency in Swift By Marin Todorov