Un composant Angular a pour vocation de s'intéresser uniquement à l'affichage graphique d'informations en utilisant du code HTML. Afin d'améliorer la modularité des applications, il est conseillé de ne pas embarquer dans les composants du code récupérant des données ou réalisant des calculs intensifs sur celles-ci. A cet effet, Angular propose de réaliser des services qui seront utilisés par les composants. Un des usages des services est de permettre la récupération et l'envoi de données auprès de services web REST.
Création d'un service Angular
Tout comme pour les composants, la CLI d'Angular propose une commande pour créer un nouveau service :
ng generate service RandomNumber
Deux fichiers sont créés dans src/app :
- random-number.service.ts contenant l'implantation du service
- random-number.service.spec.ts contenant le tests unitaires du service
On constate que par défaut le service possède une annotation @Injectable qui indique que celui-ci est injectable dans d'autres services ou composants :
import { Injectable } from '@angular/core'; @Injectable({ providedIn: 'root' }) export class RandomNumberService { static counter = 0; public id: Number; constructor() { this.id = RandomNumberService.counter++; } getRandomNumber(low: number, high: number) { return Math.floor(Math.random() * (high - low + 1)) + 1; } }
L'annotation @Injectable indique ici que le service est disponible depuis l'injecteur root qui est l'injecteur racine de l'application. Le service existera donc en une seule instance pour toute l'application, il sera créé dès qu'un élément le nécessitera.
Injection de dépendances
Un service peut exister comme un singleton global avec l'annotation @Injectable({ providedIn: 'root'}). On peut préférer dans certaines circonstances injecter une instance différente pour chaque composant (ou service) utilisant le service. Dans ce cas :
- On utilise l'annotation @Injectable() sur le service
- On ajoute dans l'annotation @Component({ ... }) une entrée providers: [...] pour indiquer les services dont on souhaite injecter les instances :
import { RandomNumberService } from '../random-number.service'; @Component({ selector: 'app-random-number', providers: [ RandomNumberService ], template: ` <div>{{ randomNumberService.getRandomNumber() }} using RNS #{{ randomNumberService.id }}</div> ` }) export class RandomNumberComponent { randomNumber: number; constructor(private randomNumberService: RandomNumberService) { this.randomNumber = randomNumberService; } }
Une nouvelle instance de RandomNumberService est créée pour chaque composant RandomNumber utilisé.
Un service peut hériter d'un autre service. Par exemple, nous pourrions créer un SecureRandomNumberService dont la mission serait de fournir des nombres aléatoires "de meilleure qualité" qui hériterait de RandomNumberService. On pourrait alors réécrire l'entrée providers dans l'annotation @Component pour indiquer que l'on préfère ce service :
providers: [ { provide: RandomNumberService, useClass: SecureRandomNumberService } ]
providers peut aussi spécifier des services qui ne sont pas directement utilisés par le composant mais par un service. Par exemple si le composant C a besoin du service S qui lui-même a besoin du service T. Si C veut l'implantation spécifique S1 de S et souhaite que S1 utilise T1 comme implantation du service T, nous indiquons :
providers: [ { provide: S, useClass: S1 }, { provide: T, useClass: T1 } ]
Observer/Observable avec RxJS
Le pattern observateur-observé est classique pour communiquer unidirectionnellement entre différentes éléments d'un logiciel. Il est très utilisé par les frameworks d'interface graphique afin que la vue soit informée de modification du modèle de données ou pour que le contrôleur soit notifié d'événements graphiques nécessitant l'exécution d'actions.
RxJS est une bibliothèque mettant en œuvre ce pattern utilisé par Angular.
Typiquement les composants Angular ont besoin d'être des observateurs (observer) d'un service fournissant des données tandis que lesdits services sont considérés observables par les composants.
Un observable est un émetteur d'événements que peut écouter zéro, un ou plusieurs observers. Durant sa vie un observable peut émettre zéro, un (par exemple pour le retour d'une unique interrogation d'API) ou plusieurs événements (pour des interrogations périodiques).
Un observer est un objet qui peut fournir trois méthodes de callback (dont le type du paramètre est libre) :
- next (obligatoire) : cette méthode est appelée à chaque fois qu'un nouvel élément est publié par l'observable
- error (optionnel) : cette méthode est appelée par l'observable lorsqu'il rencontre un problème et qu'il doit cesser son fonctionnement
- complete (optionnel) : cette méthode est appelée lorsque l'observable se termine avec un résultat final
Pour tester l'utilisation de RxJS, nous proposons de créer un service Angular simple diffusant par un observable toutes les secondes un événement avec l'actuel timestamp Unix (nombre de secondes écoulées depuis le 1/1/1970). Un composant utilise ce service par injection et souscrit un abonnement auprès de l'observable de celui-ci (il devient donc observer). Notons que dans le cas présent, il fait sens d'avoir une seule instance du service sur toute l'application : plusieurs composants peuvent donc s'abonner à l'observable.
Crééons le service :
import { Injectable } from '@angular/core'; import { Observable, Observer } from 'rxjs'; @Injectable({ providedIn: 'root' }) export class TimestampService { getTimestamp(): number { return Math.floor((new Date().getTime()) / 1000); } timestampSubscriber() { const observers: Set<Observer<number>> = new Set(); // an array when we store all the subscribed observers let intervalHandle: any = null; // no interval scheduled yet let that = this; // reference to the service return (observer) => { if (intervalHandle === null) { intervalHandle = setInterval(() => { // action done at each period // we inform the observers about the timestamp // by calling their next method observers.forEach((observer) => observer.next(that.getTimestamp())); }, 1000); } observers.add(observer); // we must return an object with a method allowing the observer to unsubscribe return { unsubscribe() { observers.delete(observer); // remove the observer from the set if (observers.size == 0) { // if the set is empty, we can clear the interval clearInterval(intervalHandle); intervalHandle = null; } } } }; } readonly timestampObservable = new Observable(this.timestampSubscriber()); subscribe(observer) { this.timestampObservable.subscribe(observer); } constructor() { } }
Et maintenant créons le composant affichant le timestamp Unix :
import { Component, OnInit, OnDestroy } from '@angular/core'; import { TimestampService } from '../timestamp.service'; @Component({ selector: 'app-timestamp', template: `<div>Timestamp: {{ timestamp }}` }) export class TimestampComponent implements OnInit, OnDestroy { timestamp = -1; subscription: any = null; constructor(private timestampService: TimestampService) { } ngOnInit() { let that = this; // to have a reference to the component this.subscription = this.timestampService.subscribe({ next(timestamp) { that.timestamp = timestamp; }, error(msg) { /* not used yet */ }, complete(msg) { /* not used yet */ } }); } ngOnDestroy() { if (this.subscription) this.subscription.unsubscribe(); } }
rxjs propose de créer des observables directement depuis des arguments avec of ou from pour un argument qui est un tableau ou un itérable :
- of(42, 43) retourne un observable qui renvoie deux éléments (appels de next) 42 et 43
- from([42, 43]) réalise la même chose avec un tableau en paramètres ou une fonction itérateur
Les valeurs retournées par un observable sont filtrables et transformables avec des fonctions filter et map passées à pipe. Ainsi si l'on veut créer un observable émettant tous les nombres pairs, nous pouvons utiliser :
function* generateIntegers(start) { i = start; while (true) yield i++; } let evenNumber = of(generateIntegers(0)).pipe(map( x => 2 * x)); let evenNumber2 = of(generateIntegers(0)).pipe(filter ( x => x % 2 == 0 ));
Nous pouvons consommer des éléments de ces observables. Ceux-ci étant infinis, nous devons nous limiter dans le nombre d'éléments à récupérer avec take :
// nous affichons les 100 premiers nombres pairs dans la console let evenNumber100 = evenNumber.pipe(take(100)).subscribe( x => console.log(x) );
Client HTTP avec Angular
Requête GET de récupération de données JSON
Angular propose une implantation de client HTTP qui est considéré comme un observable RxJS. Elle est mise en œuvre avec la classe HttpClient qui est instanciable automatiquement par injection. L'émission d'une requête HTTP se fait ainsi :
httpClient.get(url).subscribe( data => { /* action to do with the received data */ }, error => { /* action to do with the error */ } )
Par défaut le client HTTP s'attend à recevoir des données au format JSON. Nous pouvons typer la requête en spécifiant une interface avec le type de données attendues. A titre d'exemple, nous interrogerons un service REST délivrant le dernier cours du bitcoin par rapport à l'euro. Voici un exemple des données JSON retournées :
GET https://api.cryptonator.com/api/ticker/btc-eur {"ticker":{"base":"BTC","target":"EUR","price":"3411.40936861","volume":"6049.22117886","change":"-1.96983356"},"timestamp":1546692241,"success":true,"error":""}
Nous en déduisons l'interface suivante pour les données :
interface BitcoinQuote { ticker: { base: string, target: string, price: string, volume: string, change: string }, timestamp: number, success: boolean, error: string }
Et nous pouvons mettre en œuvre un service utilisant le client HTTP pour interroger l'API :
import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable, of } from 'rxjs'; import { map, catchError } from 'rxjs/operators'; interface BitcoinQuote { ticker: { base: string, target: string, price: string, volume: string, change: string }, timestamp: number, success: boolean, error: string } export class SimpleBitcoinQuote { constructor(public baseCurrency: string, public targetCurrency: string, public price: number, public date: Date) {} } @Injectable() export class BitcoinTickerService { readonly API_URL = "https://api.cryptonator.com/api/ticker/btc-%target%"; readonly CACHE_LIFE = 60; // in seconds, if we have a result younger, we do not query the API /** Store here the last known results */ lastResults = new Map<string, SimpleBitcoinQuote>(); constructor(private httpClient: HttpClient) { } /** Tell if a SimpleBitcoinQuote is expired or not */ isFresh(quote: SimpleBitcoinQuote): boolean { return quote.date.getTime() + this.CACHE_LIFE * 1000 < new Date().getTime(); } newQuoteObservable(targetCurrency: string): Observable<SimpleBitcoinQuote> { // maybe there is a recent result (aged from less than CACHE_LIFE seconds) let lastResult = this.lastResults.get(targetCurrency); if (lastResult instanceof SimpleBitcoinQuote && this.isFresh(lastResult)) return of(lastResult); // no need to query again the service const url = this.API_URL.replace("%target%", targetCurrency.toLowerCase()); return this.httpClient.get<BitcoinQuote>(url).pipe( // we transform the BitcoinQuote to a SimpleBitcoinQuote map( data => { if (! data.success) throw new Error(`An error was returned by the API: data.error`); let result = new SimpleBitcoinQuote( data.ticker.base, data.ticker.target, parseFloat(data.ticker.price), new Date(data.timestamp * 1000)); this.lastResults.set(result.targetCurrency, result); return result; })); } }
Requête POST d'envoi de données JSON
Une requête POST permet d'envoyer des données vers le serveur (la méthode PUT peut également être utilisable selon les contextes). HttpClient s'utilise alors en indiquant l'URL, les donnés à envoyer (sous la forme d'un objet converti en JSON) ainsi que de possibles en-têtes personnalisés. Il est souvent nécessaire d'indiquer un en-tête spécifique avec un jeton d'authentification.
import { HttpHeaders } from '@angular/common/http'; class Poster { // ... postObject (myObject: MyObject): Observable<ReturnedResult> { const httpOptions = { headers: new HttpHeaders({ 'Content-Type': 'application/json', 'Authorization': 'specify-here-your-token' }) }; return this.http.post<ReturnedResult>(this.postUrl, myObject, httpOptions) .pipe( catchError(this.handleError(myObject)) ); } handleError(myObject: MyObject) { // do some action when an error is encountered } }
Récupération de donnés autres que du JSON
Une requête HTTP peut retourner autre chose que des données formattées en JSON. Il peut s'agir de données XML, de texte brut, de données binaires quelconques... Observons la signature d'une méthode (get, post...) de HttpClient :
post(url: string, body: any | null, options: { headers?: HttpHeaders | { [header: string]: string | string[]; }; observe?: HttpObserve; params?: HttpParams | { [param: string]: string | string[]; }; reportProgress?: boolean; responseType?: 'arraybuffer' | 'blob' | 'json' | 'text'; withCredentials?: boolean; } = {}): Observable<any>
L'argument options peut accueilir :
- un objet d'en-têtes (entrée headers)
-
observe peut prendre les valeurs :
- 'body' : mode classique retournant le corps directement
-
'response' : mode retournant la réponse enveloppée dans un objet HttpResponse<T>
- body permet de récupérer le corps
- headers contient les en-têtes retournés dans la réponse
- status est le code de statut (200 si tout va bien, 404 si la ressource n'est pas trouvée...)
- statusText contient le message explicatif accompagnant le code de statut
- url est l'URL demandée (éventuellement réécrite en cas de redirection)
-
'events' : mode diffusant une série d'événements vers les observateurs qui peuvent être de plusieurs natures (le type est récupérable avec event.type) :
- HttpSentEvent : la requête a été envoyée au serveur
- HttpHeaderResponse : reception partielle de la réponse avec uniquement les en-têtes (activé si reportProgress)
- HttpResponse<T> : réponse complète du serveur
- HttpProgressEvent : objet {type: HttpEventType.DownloadProgress|HttpEventType.UploadProgress, loaded: number, total?: number} pour connaître la progression de l'upload ou du téléchargement
- HttpUserEvent<T> : événement personnalisé
- responseType indique le type de corps attendu pour la réponse (par défaut json, blob si on attend des données binaires)
Pour illustrer la récupération de données binaires, nous implantons un service de récupération de photos diverses (données binaires au format JPEG) qui interroge cette URL (on remplace width et height par les coordonnées de la photo) :
https://picsum.photos/$width/$height?random
Nous écrivons le service :
import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; import { flatMap } from 'rxjs/operators'; const PICTURE_FETCHER_URL = "https://picsum.photos/$width/$height?random"; /** Make the service globally reachable as a singleton */ @Injectable({ providedIn: 'root' }) /** A service that fetches a random picture using a public web API */ export class PictureFetcherService { constructor(private httpClient: HttpClient) { } getPictureURL(width: number, height: number) { return PICTURE_FETCHER_URL.replace("$width", ""+width).replace("$height", ""+height); } /** This method converts a blob to an image (under the form of a data URL) with a FileReader */ convertBlobToDataURL(blob: Blob): Observable<string> { return new Observable<string>( observer => { let reader = new FileReader(); reader.addEventListener("load", () => { if (typeof(reader.result) === 'string') observer.next(reader.result) }, false); reader.readAsDataURL(blob); }); } /** Return an Observable<Blob> with the JPEG picture */ getPictureBlobObservable(width: number, height: number): Observable<Blob> { return this.httpClient.get(this.getPictureURL(width, height), { responseType: 'blob' }); } /** Return an Observable<string> with the data URL of the picture */ getPictureDataURLObservable(width: number, height: number): Observable<string> { return this.getPictureBlobObservable(width, height).pipe( flatMap( blob => this.convertBlobToDataURL(blob) ) ); } }
Nous implantons maintenant le composant utilisant ce service :
import { Component, OnInit, Input } from '@angular/core'; import { PictureFetcherService } from '../picture-fetcher.service'; import { Router, ActivatedRoute, ParamMap } from '@angular/router'; @Component({ selector: 'app-picture-viewer', template: `<img *ngIf="imageDataURL" [src]="imageDataURL" (click)="loadNewImage()"> <button *ngIf="! imageDataURL" (click)="loadNewImage()">Load image</button>` }) export class PictureViewerComponent implements OnInit { @Input() width: number; @Input() height: number; imageDataURL: string|null; constructor(private pictureFetcher: PictureFetcherService, private route: ActivatedRoute, private router: Router) { } ngOnInit() { let paramMap = this.route.snapshot.paramMap; let [w, h] = [paramMap.get('width'), paramMap.get('height')]; if (typeof(w)==="string") this.width = +w; if (typeof(h)==="string") this.height = +h; } loadNewImage() { this.pictureFetcher.getPictureDataURLObservable(this.width, this.height).subscribe( (dataURL) => { this.imageDataURL = dataURL; } ); } }
API JavaScript standard
Angular reposant sur l'usage de JavaScript, il est possible aussi d'utiliser des APIs web standard pour réaliser des requêtes HTTP :
- L'API historique XMLHttpRequest
- La nouvelle API Fetch avec objets Request et Reponse plus pratiques à utiliser
- L'API Beacon spécialisée pour l'envoi de requêtes POST qui ne nécessitent pas de récupération de réponse (utilité pour des applications de suivi et statistiques de visites)