image/svg+xml $ $ ing$ ing$ ces$ ces$ Res Res ea ea Res->ea ou ou Res->ou r r ea->r ch ch ea->ch r->ces$ r->ch ch->$ ch->ing$ T T T->ea ou->r

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 :

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 :

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 } ]

Plus de détails sur le fonctionnement de l'injection de dépendance sont disponibles dans la documentation Angular.

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) :

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 :

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 :

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 :