Il y a 2 années · 8 minutes · iOS, Mobile

Comment architecturer ses web services en iOS

Il fut un temps où intégrer des web services en iOS était un vrai parcours du combattant : concaténation des NSData à la main, mapping manuel…

Aujourd’hui l’écosystème s’est enrichi et des outils comme AFNetworking, Alamofire ou Mantle nous simplifient toutes ces tâches.

Cependant il est une chose que ces bibliothèques n’ont pas su résoudre : le Separation of Concern. (Trop) souvent, les développeurs iOS ont la fâcheuse tendance de mélanger la gestion des web services au sein d’une seule couche ou classe, rendant cette dernière difficile à maintenir.

Comment améliorer les choses ? Comment assurer la pérennité de sa couche réseau malgré les évolutions de technologie et/ou les changements d’API ? En utilisant une architecture 3-tiers.

Architecture 3-tiers

L’architecture 3-tiers consiste à modéliser notre application comme un empilement de 3 grandes couches :

  1. Présentation, pour tout ce qui est UI, interaction utilisateur, …

  2. Business/Application, pour la logique intrinsèque à l’application

  3. Data, pour récupérer/sauvegarder les données

Chaque ligne de code écrite fait alors partie d’une de ces couches, avec une règle en tête : chaque couche n ne peut communiquer que avec la couche n+1. Ainsi, Présentation ne communique qu’avec Business qui, à son tour, ne communique qu’avec Data. De plus, les communications inverses (Business vers Présentation, Data vers Business) ne se font que de manière non couplée, c’est-à-dire via des callbacks (par exemple, à l’aide de blocks, delegates, notifications, etc…).

En iOS, les couches Data et Business sont traditionnellement et trop souvent entremêlées et peu distinctes ; c’est le cas, notamment, de la gestion des données métier. Pour remédier à cela nous allons créer 3 niveaux ultérieurs au sein de Business et Data :

  1. Data Abstraction, pour abstraire les composants et l’utilisation de PONSO (Plain Old NSObject)

  2. Data Access, pour la gestion des requêtes de lecture ou écriture

  3. Stores, pour gérer la logique métier, mais uniquement celles liées aux données

Architecture 3-tiers

Data Abstraction

Le but de cette couche est de faire la liaison entre le modèle de données utilisé par l’application et la couche d’accès aux données (WS, CoreData). Par exemple, c’est ici que l’on retrouvera le code pour transformer les données brutes reçues via HTTP en objets métier.

Dans le meilleur des mondes, cette couche se doit de :

  1. Proposer sa propre API, indépendante de toute bibliothèque. Cela permet de plus facilement factoriser les avant/après des requêtes.

  2. Travailler avec des protocoles plutôt que directement avec des classes. Cela facilite les changements d’outils, par exemple une migration d’AFNetworking vers Alamofire.

Voici un exemple:

typedef void(^SuccessBlock)(id object);
typedef void(^ErrorBlock)(NSError *error);

// AFNetworkingAdapter et MantleAdapter ne sont pas fournis, nous les utilisons pour montrer
// l'utilisation de protocoles.
@implementation DataAbstract

- (instancetype)initWithURL:(NSURL *)baseURL {
    return [self initWithManager:[AFNetworkingAdapter adapterWithURL:baseURL]];
}

// (2) on travaille avec des protocoles plutôt qu'avec AFNetworking/Mantle directement
- (instancetype)initWithManager:(id<HTTPManager>)manager {
    if (self = [super init]) {
        _networkManager = manager;

        _dataMapper = [MantleAdapter new];
    }

    return self;
}

// (1) API custom
- (void)GET:(NSString *)path parameters:(NSDictionary *)params forClass:(Class)klass success:SuccessBlock error:ErrorBlock {
    [self.networkManager GET:path
                  parameters:params
                     success:^(id responseObject) {
        id object = [self JSON:responseObject toObject:klass];
        
        // Pour abréger l'exemple, nous partons du principe que `success` et `error` existent
        [object isKindOfClass:NSError.class] ? error(object) : success(object);
    }
                      error:error];
}


- (id)JSON:(id)JSON toObject:(Class)klass {
    NSError *error;
    id obj;

    if ([JSON isKindOfClass:[NSArray class]]) {
        obj = [self.dataMapper create:klass fromArray:JSON error:&error];
    }
    else {
        obj = [self.dataMapper create:klass fromDictionary:JSON error:&error];
    }

    return error ?: obj;
}

Pour vous simplifier la tache, vous pouvez aussi utiliser Sculptor qui fait le lien entre les requêtes HTTP (via le pod AFNetworking) et le modèle objet (via le pod Mantle). Il vous manquera cependant l’abstraction d’API, que je vous conseille fortement de faire.

Data Access

Data Access repose sur la Data Abstraction développée à l’étape précédente. D’ailleurs, vous pouvez l’implémenter soit sous forme de couche (par composition), soit sous forme d’héritage avec Data Abstraction.

Son but est de se mapper au plus près de l’API fournie par le backend. Ainsi on aura une méthode pour chaque service.

@interface UserDataAccess : DataAbstract
@end

@implentation UserDataAccess

// Pour abréger l'exemple, les arguments `success:` et `error:` ne sont pas ajoutés
// mais ils sont bien présents

- (instancetype)init {
    return [self initWithURL:[NSURL URLWithString:@"http://<yourURL>/user"]];
}

- (void)login:(LoginInfo *)loginInfo {
    [self POST:@"login" parameters:loginInfo forClass:User.class];
}

- (void)preferences:(User *)user {
    [self GET:[NSString stringWithFormat:@"preferences/%@", user.userId] forClass:UserPreferences.class];
}

Très vite, vous allez vous poser de nombreuses questions dans l’élaboration de vos Data Access et notamment si vos méthodes doivent plutôt prendre en paramètre des primitives (NSNumber, NSString…) ou des objets (User). Dans la mesure du possible préférez l’approche objet : cela évite d’avoir des méthodes aux signatures trop longues. De plus cela explicite plus clairement les dépendances.

De la même manière vous vous demanderez certainement sur quel critère séparer les classes de Data Access. La réponse est simple: par base URL. Vous aurez ainsi généralement deux types de DataAccess:

  1. GoogleDataAccess dont la base URL est “google.com”, et BingDataAccess dont la base url est “bing.com”

  2. GoogleUserDataAccess dont la base url est "google.com/user"

Stores

Les stores, à la différence des deux composants précédents, résident dans la couche Business. C’est donc avec eux que votre application (ViewController et autres) est censé interagir : Aux yeux de celle-ci Data Abstraction et DataAccess n’existent pas.

Leur but est très simple : fournir une API orientée business, c’est-à-dire par rapport aux besoins de votre application. Cela se traduit généralement par une méthode publique qui appelle une ou plusieurs méthodes d’un ou plusieurs DataAccess. C’est aussi ici que vous trouverez les traitements à effectuer comme réinitialiser une valeur de l’objet quand la requête a echoué, etc.

Un exemple valant mieux que 1000 mots :

@implementation UserStore

- (instancetype)initWithDataAccess:(UserDataAccess *)userDataAccess {
    if (self = [super init]) {
        self.userDataAccess = userDataAccess;
    }
    
    return self;
}

- (void)login:(LoginInfo *)loginInfo success:SuccessBlock{
    [self.userDataAccess login:loginInfo
                       success:^(User *user) {
        [self.userDataAccess preferences:user success:^(UserPreferences *pref) {
            user.pref = pref;
            // résultat final: un objet User avec ses préférences remplies
            success(user);
        }];
    }];
}

À la différence des Data Access, les Stores sont découpés d’un point de vue métier : UserStore, BookStore, AuthorStore, MovieStore… sont autant de stores que vous pouvez trouver dans une application, qu’ils utilisent ou non le(s) même DataAccess.

Ce qui est super avec les Stores c’est que leur API est indépendante des Data Access : que vous utilisiez CoreData, Realm ou des services HTTP votre API sera toujours la même pour votre application ! Vous pouvez modifier et/ou combiner vos différents DataAccess pour par exemple, effectuer une requête HTTP puis stocker le résultat dans CoreData sans avoir à modifier votre API.

One more thing: Promises

Si vous avez lu les exemples, vous aurez remarqué que nous avons omis la plupart du temps les blocks success et failure pour garder le code succinct. En fait cela montre que :

  1. Ecrire 3 couches avec des block success et failure est long et fastidieux
  2. Le risque de commettre une erreur (oublier d’appeler un block ou appeler le mauvais) est élevé

Nous pouvons éviter tous ces écueils grâce aux Promises. Cerise sur le gâteau : nous sommes beaucoup plus flexibles (surtout dans les Stores) puisque maintenant nous pouvons très facilement chaîner plusieurs appels !

Vous trouverez beaucoup de Pod proposant les Promises en Objective-C/Swift, mais je vous conseille PromiseKit qui est le plus complet et le plus abouti à ce jour. 

Voici ce que donnent les différentes couches créées tout au long de cette article en y ajoutant PromiseKit :

@implementation DataAbstract

- (PMKPromise *)GET:(NSString *)path parameters:(NSDictionary *)params forClass:(Class)klass {
    [self setupHTTPHeader];

    [self.networkManager GET:path parameters:params]
    // PromiseKit gère automatiquement les NSError comme étant... des erreurs !
    .then(^(id JSON) { return [self JSON:responseObject toObject:klass]; });
}


- (id)JSON:(id)JSON toObject:(Class)klass {
    NSError *error;
    id obj;

    if ([JSON isKindOfClass:[NSArray class]]) {
        obj = [self.dataMapper create:klass fromArray:JSON error:&error];
    }
    else {
        obj = [self.dataMapper create:klass fromDictionary:JSON error:&error];
    }

    return error ?: obj;
}
@end

@implentation UserDataAccess

/// @return PMKPromise<User>
- (PMKPromise *)login:(LoginInfo *)loginInfo {
    return [self POST:@"login" parameters:loginInfo forClass:User.class];
}

/// @return PMKPromise<UserPreferences>
- (PMKPromise *)preferences:(User *)user {
    return [self GET:[NSString stringWithFormat:@"preferences/%@", user.userId] forClass:UserPreferences.class];
}
@end

@implementation UserStore

/// @return PMKPromise<User>
- (PMKPromise *)login:(LoginInfo *)loginInfo {
    return [self.userDataAccess login:loginInfo]
    .then(^(User *user) {
        return [self.userDataAccess preferences:user]
        .then(^(UserPreferences *pref) { user.pref = pref; })
        // résultat final: un objet User avec ses préférences remplies
        .then(^{ return user; });
    });
}
@end

Swift loves Promises

Créer une promise en Swift est un exercice rapide de programmation. Cependant, un bon nombre de librairies apporte des fonctionnalités ultérieures à ce paradigme et vous permettra de chainer les callbacks de façon plus fonctionnelle : c’est, par exemple le cas de SwiftTask et BrightFutures.  

Conclusion

En séparant notre code convenablement en 3 couches et en utilisant les bons outils (les Promises), implémenter la couche de gestion des données devient un vrai jeu d’enfant. Mieux, le code est beaucoup plus maintenable et le risque de bugs ou d’effets de bord lors d’une modification est grandement diminué.

Chez Xebia, nous avons adopté cette approche sur notre dernier projet et une chose est sûre: nous ne reviendrons pas en arrière !

Jean-Christophe Pastant
Consultant iOS, fervent défenseur du code de qualité.

Laisser un commentaire

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