Come sviluppatori passiamo una buona parte del nostro tempo scrivendo, riscrivendo o facendo refactoring di codice già esistente; per qualcuno di noi si tratta di un lungo percorso di crescita professionale che ha come obiettivo un po’ utopico quello di ottenere il vero clean code.
Altri invece non hanno ancora colto uno dei principi cardine dell’ingegneria del software: evitare quanto più possibile di reinventare la ruota, promuovendo il riutilizzo del codice, sopratutto quando si ha a che fare con più prodotti.
Far crescere i propri prodotti su solide basi non solo è un buon modo per evitare rework in corsa, ma consente un ciclo di sviluppo per gli aggiornamenti più agile e meno complesso.
Con un ciclo più snello gli aggiornamenti sono più veloci, possono comprendere più funzioni e hanno un impatto sui tempi di sviluppo più limitato (un bel vantaggio sia per chi sviluppa sia per chi ha a che fare con i tempi).
La stragrande maggioranza delle applicazioni odierne comunicano in qualche modo con dei server ricevendo o inviando dati per poi presentarli all’utente.
Pur trattandosi solo di un tassello all’interno di ogni progetto è chiaro come esso sia però di fondamentale importanza; è proprio per questo motivo che il nostro progetto di standardizzazione dei processi di sviluppo mobile in IQUII è iniziato da questa componente.
Il progetto Atlas nasce con l’ambizioso obiettivo di creare un layer intermedio tra quello che è il codice applicativo, il sistema operativo e le varie SDK sottostanti; questo include un layer di rete, uno di persistenza, una metodologia comune per l’architettura, una gestione semplificata per le push, le impostazioni e tutto quello che solitamente è parte di una app mobile.
I benefit dietro un progetto del genere sono molteplici:
Abbiamo chiamato questo progetto Atlas come il titano che fu costretto da Zeus a tenere sulle spalle l’intera volta celeste: nella stessa maniera anche il nostro framework vuole essere lo strumento sul quale poggiare i prodotti presenti e futuri realizzati in IQUII.
In questa prima parte vedremo nel dettaglio come è stata realizzata la parte networking di Atlas; successivamente avremo modo di raccontare nel dettaglio le altre parti.
Alzi la mano chi, per realizzare un layer di rete, non si è mai affidato ad un singleton dove aggiungere tutte le chiamate di rete utilizzate nella propria applicazione.
Purtroppo si tratta di uno scenario abbastanza comune, anche tra gli sviluppatori più senior: in effetti si tratta della maniera più veloce con cui approcciare questo genere di problemi.
Non fraintendetemi: non sono tra chi crede che i singleton siano il male: il fatto è che questo genere di approccio va volontariamente contro uno dei principi cardine del buon software: il Single Responsibility Principle.
A prescindere dalla definizione che possiamo quando parliamo di buon codice, una caratteristica risulta però imprescindibile: il buon codice deve essere mantenibile nel tempo, adattandosi a cambiamenti che, si vogliano o no, fanno parte di un normale ciclo di vita.
Codice che risulta scarsamente adattabile, dove ogni modifica è motivo di frustrazione per il team, richiede un tempo largamente superiore alle stime e rischia di generare regressioni è un codice che diventa velocemente ingestibile e quindi obsoleto (richiedendo di conseguenza continui rework non previsti).
In qualunque codice di scarsa qualità si può sempre trovare una class con più di una responsabilità.
In un layer di rete di questo tipo il singleton è a conoscenza di ogni cosa; dalla connessione, ai path di ogni richiesta, all’utente attivo e ad altri elementi dell’app fintanto – nei casi peggiori – perfino view controller dell’interfaccia.
In pratica tutto vive all’interno di una grossa grassa classe che rende impossibile un sano testing e meno che mai operazioni dependency injection o unit testing.
Uno dei goal di Atlas è quello di fornire un layer di gestione di rete che possa interfacciarsi con la nostra controparte server, Athena.
Athena è un moderno backend creato dal nostro team server e basato su NodeJS/ MongoDB in grado di servire API RESTful.
In effetti, come Atlas rappresenta il fondamento di ogni app mobile in IQUII, anche Athena fa un lavoro simile lato backend: ogni nuovo progetto o prodotto viene costruito attraverso questa base comune.
Da questo punto di vista ci sono un set di funzionalità minime richieste:
Il compito di Atlas è gestire in maniera automatica e del tutto trasparente tutte le situazioni derivanti da questo approccio.
Una volta definiti i requisiti è necessario scegliere i tool giusti per realizzarli.
Nella versione iOS del framework, di cui mi sono occupato direttamente, abbiamo scelto:
Il seguente grafico mostra l’architettura alla base di Atlas:
ServiceConfig
Il SC agisce come storage della configurazione di rete; Atlas può istanziare questo oggetto in maniera programmatica oppure generarne uno a partire dalla configurazione esplicitata all’interno del file Info.plist dell’app host.
Al fine di automatizzare e semplificare la gestione degli ambienti (tipicamente Staging/ Production) creiamo per ogni chiave di configurazione (url,environment,headers…) una User Defined Settings legata all’ambiente. in questo modo sarà compito di XCode impostare i valori corretti a seconda dello schema scelto.
Chiaramente dalla lista delle chiavi va esclusa la APIKey che invece sarà definita a runtime dal programmatore e assegnata prima dell’utilizzo del layer.
Service
Service è il cuore del layer di rete; si tratta di una classe che risponde ad un protocollo il cui unico metodo richiesto è quello di execute() di una Request.
L’utilizzo del protocollo consente di implementare più versioni di un Service che possono utilizzare diversi engine di rete; in un buona sostanza consente di isolare completamente l’implementazione di rete da quella che è l’app: in questa prima versione siamo ricorsi ad Alamofire ma stiamo già lavorando per portare tutto sotto il classico NSURLSession semplificando e alleggerendo il nostro layer.
Con questo obiettivo in mente modificare lo strato più basso non comporterà alcuna modifica alle applicazioni che utilizzano Atlas portando contemporaneamente però i benefici di una modifica che in altre occasioni sarebbe stata quantomeno complessa.
L’implementazione che segue è quella fatta in Alamofire:
Request & Response
Request incapsula tutte le informazioni che riguardano una request:
Una singola richiesta è completamente indipendente dal servizio sulla quale verrà eseguita, ed è proprio questo l’obiettivo!
Ecco un esempio di Request:
let service: Service = ... let rq = Request(.post, "save/article/{art_cat}/id/{art_id}", ["art_id" : 55, "art_cat": "scifi"]) rq.timeout = 5 // if not specified Service's global timeout is used rq.body = RequestBody.json(["text" : "bla bla bla", "author": "Me"]) service.execute(rq, retry: 3).then( { response in // response ok, read inside }).catch({ err in // request fail with error })
La Response è ciò che viene ritornato dall’esecuzione di una Request all’interno di un Service; essa comprenderà:
Inoltre Response espone due funzioni importanti: toJSON() e toString() che possono essere utilizzate per eseguire il parsing di dati grezzi o effettuare debug delle response.
JSONOperation & DataOperation
Collocati appena sopra lo strato inferiore di rete, composto da Service, Request e Response, si trovano due altre classi: DataOperation e JSONOperation (sottoclasse della prima).
Entrambe sono concrete implementation del protocollo Operation, il cui scopo è quello di definire un ulteriore livello di astrazione sopra le operazioni di rete: in effetti una Operation raggruppa una serie di operazioni di business logic atte a preparare il dato grezzo trasformandolo in entità utilizzabile dall’applicazione host.
Un esempio tipico è l’operazione di Login con la quale viene si effettuata la login al backend ma a cui seguono una serie di altre operazioni quali l’estrapolazione del profilo utente e la gestione dei token.
Quando uno sviluppatore al lavoro su un’app mobile IQUII ha bisogno di aggiungere una nuova chiamata di rete, il suo unico compito sarà quello di creare una nuova Operation, tipicamente una JSONOperation.
JSONOperation mette a disposizione diverse funzionalità quali:
Un esempio
Nell’esempio sotto viene riportata (in maniera semplificata) la creazione di una operation per gestire la login di un utente verso Athena.
Atlas mette a disposizione un singleton Application nel quale viene esposto un primo Service (nulla toglie che il programmatore possa crearne un secondo a sua discrezione).
Application è l’oggetto fondamentale a cui accedere ai servizi di Atlas; al momento del bootstrap dell’applicazione l’ambiente viene configurato automaticamente secondo le impostazioni fornite.
Il sistema gestisce automaticamente il profilo dell’utente eventualmente loggato, la setup del device e tutte le informazioni riguardanti la sessione.
Ecco come appare la login:
public class LoginOp<T: UserModelProtocol>: AtlasOperation<T> { public init(email: String, password: String) { super.init() self.request = Request(method: .post, endpoint: type.endpoint) self.tokenRefreshAllowed = false self.request?.headers = [Constants.Headers.Authorization.rawValue : nil] // remove auth header self.request?.body = RequestBody.json(["email": email, "pwd": password]) self.request?.cachePolicy = URLRequest.CachePolicy.reloadIgnoringCacheData self.onGenerateOutputModel = { json in let newUser = T(json["data"]) return newUser } } }
Un punto interessante di questa classe è composto dalla funzione onGenerateOutputModel ; essa agisce come punto di ingresso per consentire allo sviluppatore di generare (manualmente se necessario) un modello dati a partire da dati grezzi.
Una secondo punto di ancoraggio, chiamato onValidateJSONResponse, consentirà l’eventuale verifica dei dati ottenuti prima di passare allo step successivo.
La presenza della proprietà tokenRefreshAllowed infine consente di disabilitare, qualora necessaria, la gestione automatica del token (nel caso di login si tratta di una operazione non rilevante).
Con la nostra operazione già pronta possiamo infine estendere il Service di Atlas fornendo una funzione asincrona per il login:
public extension AtlasService { public func login<T: UserModelProtocol>(email: String, pwd: String) -> Promise<T> { return Promise<T>({ r, rj, st in LoginOp<T>(email: email, password: pwd).in(self.app).exec().then({ loggedUser in self.app.setLoggedUser(loggedUser) r(loggedUser) }).catch({ err in rj(err) }) }) } }
Come si può notare si tratta semplicemente di una shortcut per l’esecuzione di LoginOp all’interno di un service esistente.
Giunti a questo punto chiamare la funzione è un gioco da ragazzi:
App.network.login(email: "...", pwd:: "...").then { user in // asynchronously return logged user }.catch({ err in // something went wrong })
Appena una linea di codice: gestione asincrona del codice, degli errori e conversione automatica dei dati grezzi al model. “Pura magia“!
Come anticipato ad inizio articolo Atlas è molto di più di un layer di rete; nelle successive occasioni avremo modo di approfondire l’oggetto Application, vero cuore centrale per accedere ad Atlas.
public let App: Application = Application.main public class Application { /// Main singleton reference. public static var main: Application = Application() /// Configuration internal var configuration: AppConfiguration! /// Main Networking service public lazy var network: AtlasService /// Currently logged user public private(set) var loggedUser: UserModelProtocol? = nil ... // and much more... }
Come abbiamo visto da questo veloce viaggio all’interno di Atlas, la creazione di uno strato unificato rappresenta un vero passo in avanti nella gestione del codice e nella realizzazione dei prodotti.
La necessità di una metodologia unificata all’interno del ciclo di vita del software è un requisito imprescindibile, la cui forza risiede sopratutto nella facilità con cui consente ai prodotti di evolvere nel tempo limitando rework e problematiche tipiche di approcci singoli.
Nei prossimi articoli continueremo questo viaggio esplorando altre componenti del nostro framework.