Comme dans les langages objet, il est possible de déclarer des interfaces en Angular. Cela sera particulièrement utile pour échanger des données avec le backend car on pourra annoncer le type des données transmises. Voici un exemple d’interface :
export interface Course {
nom: string;
nb_etuds : number;
}
Ici, comme on peut le voir, on définit des propriétés et leur type. Utilisons cela dans un composant :
1import {Component, signal} from '@angular/core';
2import {FormsModule} from '@angular/forms';
3
4export interface Course {
5 nom: string;
6 nb_etuds : number;
7}
8
9@Component({
10 selector: 'app-courses',
11 imports: [
12 FormsModule
13 ],
14 templateUrl: './courses.component.html',
15 styleUrl: './courses.component.scss'
16})
17export class CoursesComponent {
18 titre = signal('composant courses');
19
20 UE : Course[] = [
21 {nom: 'c1', nb_etuds: 3},
22 {nom: 'c2', nb_etuds: 5}
23 ];
24}
Les lignes 21 et 22 créent des objets JavaScript qui implémentent l’interface. Ensuite, ces objets peuvent être utilisés comme n’importe quel objet JavaScript.
Un template HTML possible pour cette classe CoursesComponent :
<p>{{titre()}}</p>
<ul>
<li>{{UE[0].nom}} : {{UE[0].nb_etuds}} étuds</li>
<li>{{UE[1].nom}} : {{UE[1].nb_etuds}} étuds</li>
</ul>
Ce qui donne l’affichage suivant :
L’exemple précédent est « bancal » en ce sens que les affichages des items du
tableau UE sont encodés en dur dans le template HTML. Si on rajoute un
élément dans UE, il faut mettre à jour manuellement le fichier
courses.component.html
.
Pour pallier cela, Angular propose une boucle @for que l’on utilise dans le fichier
HTML (anciennement, c’était la directive *ngFor). L’idée c’est que cette
boucle s’utilise comme un for(let elt of collection) de JavaScript. Il existe
toutefois une subtile différence : le @for prend un deuxième argument track
qui indique à Angular où se trouvent les données du TypeScript dans le DOM. Cela lui
permet d’optimiser la mise à jour du DOM. Ci-dessous, j’ai indiqué que les
données sont repérées via leur index ($index) dans le tableau UE.
<p>{{titre()}}</p>
<ul>
@for (module of UE; track $index) {
<li>{{module.nom}} : {{module.nb_etuds}} étuds</li>
}
</ul>
Si les données possèdent un identifiant, on peut également l’utiliser pour repérer les données :
<p>{{titre()}}</p>
<ul>
@for (module of UE; track module.nom) {
<li>élément {{$index}} : {{module.nom}} : {{module.nb_etuds}} étuds</li>
}
</ul>
Pour plus d’informations sur cette boucle, vous pouvez vous reporter à la page :
https://angular.dev/guide/templates/control-flow#repeat-content-with-the-for-block
Évidemment, le tableau UE pourrait être vide et, dans ce cas, on pourrait avoir envie de le signaler. On a deux options pour cela : utiliser le @if que l’on verra plus bas ou bien utiliser le mot-clef @empty associé au @for :
<p>{{titre()}}</p>
<ul>
@for (module of UE; track module.nom) {
<li>élément {{$index}} : {{module.nom}} : {{module.nb_etuds}} étuds</li>
} @empty {
<li>Pas d'élément dans UE</li>
}
</ul>
Le mot-clef @empty est une alternative (elle n’affiche du code que lorsque la boucle @for est vide) mais elle reste limitée aux boucles @for. Angular propose des alternatives plus générales avec les mots-clefs @if, @else et @else if :
<p>{{titre()}}</p>
@if (UE.length == 0) {
<li>Pas d'élément dans UE</li>
} @else if (UE.length == 1) {
<li>un seul élément dans UE</li>
} @else {
<li>plusieurs éléments dans UE</li>
}
Angular propose également des switch, similaires à ceux des autres langages que vous connaissez :
<p>{{titre()}}</p>
@switch (UE.length) {
@case(0) {
<li>Pas d'élément dans UE</li>
}
@case(1) {
<li>un seul élément dans UE</li>
}
@default {
<li>plusieurs éléments dans UE</li>
}
}
Dans le code vu plus haut, les données sont stockées en « dur » dans le TypeScript du composant courses. Ce n’est pas réaliste. Dans une vraie application, elles seraient récupérées d’un serveur (backend). C’est précisément le rôle des services de réaliser cette opération. Découpler le transfert des données de leur utilisation par les composants permet d’obtenir un code propre et maintenable. Pour générer un service en Angular, il faut utiliser la commande :
ng generate service nom_du_service
Si le nom du service contient des /, Angular créera des répertoires puis placera les fichiers du service dedans. Personnellement, je trouve pratique de placer tous les services dans un même répertoire. Par exemple :
ng generate service services/mon-service
créera un répertoire services
et placera dedans deux fichiers : mon-service.service.ts
et mon-service.service.spec.ts
. Le deuxième sert à réaliser
des tests automatiques, le premier contient le code TypeScript de votre service. Il
s’agit d’une classe comme une autre dont les méthodes nous serviront à récupérer les
données qui nous intéressent. Voici comment on peut modifier le code de la classe
Courses afin qu’elle utilise le service pour récupérer ses données :
La première chose à faire est de créer la classe du service :
import { Injectable } from '@angular/core';
// placer ici l'interface indiquant le type des données que le
// composant Courses va récupérer
export interface Course {
nom: string;
nb_etuds : number;
}
@Injectable({
providedIn: 'root'
})
export class MonServiceService {
constructor() { }
// la méthode pour récupérer les données
getCourses() : Course[] {
return [
{nom: 'c1', nb_etuds: 3},
{nom: 'c2', nb_etuds: 5}
];
}
}
Ici, il y a plusieurs choses à noter :
J’ai déplacé l’interface Course du composant Courses vers le service. C’est sa place logique. En effet, chaque fois que l’on échangera des données avec le serveur, c’est le service qui réalisera cette opération et il faudra indiquer le type des données que l’on recevra, donc l’interface Course.
Le service est une classe dont le nom est MonServiceService. Le composant Courses devra donc utiliser une instance de cette classe pour récupérer ses données. Juste au dessus du mot-clef class, vous voyez une annotation @Injectable. Elle signifie que le composant Courses pourra (et devra) récupérer l’instance du service par dependency injection (que l’on verra plus bas).
La classe MonServiceService est une classe comme les autres, on peut donc lui ajouter des méthodes pour interagir avec les composants. C’est ce que fait la méthode getCourses().
Le template HTML du composant Courses n’a pas besoin d’être modifié. En revanche, le TypeScript doit l’être afin d’utiliser le service :
import {Component, signal} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {Course, MonServiceService} from '../services/mon-service.service';
@Component({
selector: 'app-courses',
imports: [
FormsModule
],
templateUrl: './courses.component.html',
styleUrl: './courses.component.scss'
})
export class CoursesComponent {
titre = signal('composant courses');
// au départ, UE est vide. On récupérera plus tard ses données. Du coup,
// il est bienvenu d'indiquer le type d'objets contenus dans le tableau.
UE : Course[] = [];
constructor() {
// on crée le service et on l'utilise pour récupérer les données
const service = new MonServiceService();
this.UE = service.getCourses();
}
}
Ici, on voit que l’attribut UE est initialisé à un tableau vide. C’est seulement dans le constructeur qu’on y place les vraies données. Pour cela, le constructeur peut créer une instance du service et on peut alors exécuter la méthode getCourses() pour placer toutes les données dans l’attribut UE.
Le code ci-dessus fonctionne mais il a toutefois deux gros problèmes :
il crée dans le constructeur l’instance du service. Par conséquent, si la page que l’on affiche contient 20 instances du composant Courses, on va également créer 20 instances du service, ce qui n’est pas efficace.
Le constructeur exécute la méthode getCourses(). Or, si celle-ci récupère ses données d’un serveur, ce ne sera pas forcément une opération très rapide. Malheureusement, le constructeur est bloquant pour les affichages (ceux-ci ne peuvent être réalisés tant que le constructeur n’a pas terminé son exécution). Donc le code ci-dessus peut amener à une expérience utilisateur de médiocre qualité.
Pour pallier le premier problème, on va utiliser la technique du dependency injection. L’idée est d’utiliser le design pattern Singleton, comme on l’a vu au premier semestre. Pour cela, il suffit de passer en paramètre du constructeur l’instance du service dont on a besoin. Angular générera alors cette instance une seule fois dans toute l’application et c’est cette instance qui sera utilisée chaque fois qu’on demandera une instance par dependency injection.
Pour pallier le deuxième problème, on va placer l’appel à getCourses() non pas dans le constructeur mais dans une méthode appelée ngOnInit() qui est appelée après le constructeur lorsque l’on crée les instances de Courses et qui n’est pas bloquante. Dans le code ci-dessous, pour être plus propre, j’ai indiqué que la classe Courses implémente l’interface OnInit, qui précise que Courses possède une méthode ngOnInit().
import {Component, OnInit, signal} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {Course, MonServiceService} from '../services/mon-service.service';
@Component({
selector: 'app-courses',
imports: [
FormsModule
],
templateUrl: './courses.component.html',
styleUrl: './courses.component.scss'
})
export class CoursesComponent implements OnInit {
titre = signal('composant courses');
UE : Course[] = [];
constructor(private service : MonServiceService) {} // dependency injection
ngOnInit() { // placer ici les appels au service
this.UE = this.service.getCourses();
}
}
Règle
Créez systématiquement les instances de vos services par Dependency Injection.
Évidemment, l’objectif d’un service n’est pas de stocker en « dur » les données mais bien de les demander à des backends distants. Or, communiquer sur internet prend du temps. C’est pourquoi le fonctionnement usuel de tels services est asynchrone. Vous verrez plus bas que c’est le fonctionnement des services HTTP. L’utilisation d’un service asynchrone se fait en 5 étapes :
Lorsque ngOnInit exécute le service, celui-ci retourne tout de suite un Observable, avant même de récupérer les données du backend. On va voir ci-dessous ce qu’est un observable mais considérez que, pour l’instant, c’est une sorte de coquille vide.
ngOnInit récupère l’observable.
ngOnInit souscrit à l’observable en lui passant une callback. Celle-ci est une fonction qui sera exécutée quand l’observable contiendra les données que l’on souhaitait récupérer. Pour l’instant, elle n’est pas exécutée.
ngOnInit continue son exécution sans attendre les données.
Les données arrivent. Alors, seulement maintenant, l’observable émet une valeur (les données en question) et la callback est appelée avec cette valeur.
Pour illustrer ce fonctionnement, on va modifier légèrement notre service afin qu’il
devienne asynchrone : on commence par indiquer que la fonction ne retourne plus
un Course[] mais un Observable<Course[]>, autrement dit un Observable qui, quand
les données auront été reçues, contiendra un tableau de Course. Ensuite, au lieu
de retourner directement ce tableau, on retourne l’observable. Pour cela, ici, j’ai
utilisé of, qui est une fonction de rxjs
produisant un observable.
import {Observable, of} from 'rxjs';
export interface Course {
nom: string;
nb_etuds : number;
}
@Injectable({
providedIn: 'root'
})
export class MonServiceService {
constructor() { }
getCourses() : Observable<Course[]> {
return of([
{nom: 'c1', nb_etuds: 3},
{nom: 'c2', nb_etuds: 5}
]);
}
}
Le code TypeScript du composant Courses doit alors être mis à jour de manière à exploiter cet observable. La partie soulignée en jaune montre comment appeler le service. On ne fait plus directement d’affectation this.UE = this.service.getCourses() mais plutôt on appelle le service et on y souscrit. C’est uniquement dans la fonction passée en paramètre du subscribe que l’on peut mettre à jour this.UE.
import {Component, OnInit, signal} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {Course, MonServiceService} from '../services/mon-service.service';
@Component({
selector: 'app-courses',
imports: [
FormsModule
],
templateUrl: './courses.component.html',
styleUrl: './courses.component.scss'
})
export class CoursesComponent implements OnInit {
titre = signal('composant courses');
UE : Course[] = [];
constructor(private service : MonServiceService) {}
ngOnInit() {
this.service.getCourses().subscribe(result => {
// ici, on met toutes les instructions qui vont utiliser les
// données récupérées.
console.log("ici, on a récupéré les données");
this.UE = result;
});
// ici, les instructions ne doivent pas dépendre des données récupérées
console.log("les instructions qui sont après le subscribe");
}
}
Attention
Si vous exécutez le code ci-dessus, vous verrez sans doute le message « ici, on a récupéré les données » apparaître avant « les instructions qui sont après le subscribe » mais quand vous requêterez des données d’un backend, ce ne sera plus le cas. Il faut donc impérativement que toutes les instructions en lien avec les données récupérées du backend se trouvent dans la callback du subscribe().
Pour terminer avec les services, nous allons faire en sorte que le nôtre récupère réellement ses données sur un serveur en allant les chercher via une requête http. Pour cela, nous allons sous-traiter la partie communication frontend-backend au service HttpClient déjà existant dans Angular. À cet effet, il faut commencer par importer ce service. Cela se fait, pour toute l’application, dans le fichier app.config.ts :
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import {provideHttpClient} from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
// ici, il faut déclarer que notre application va utiliser
// la librairie contenant la classe HttpClient, qui réalisera
// effectivement les échanges entre frontend et backend
provideHttpClient()
]
};
La deuxième étape va simplement consister à ce que notre service utilise le service HttpClient. Pour cela, il va en récupérer une instance par dependency injection et utiliser les méthodes get ou post de cette instance.
import { Injectable } from '@angular/core';
import {Observable, of} from 'rxjs';
import {HttpClient} from '@angular/common/http';
export interface Course {
nom: string;
nb_etuds : number;
}
@Injectable({
providedIn: 'root'
})
export class MonServiceService {
constructor(private http: HttpClient) { }
getCourses() : Observable<Course[]> {
// get retourne un observable
return this.http.get<Course[]>('http://127.0.0.1/getCourses.php');
}
}
<?php
// on indique ici que le script va transmettre des données JSON
header('Content-type:application/json;charset=utf8');
// Ici, on ajoute un header pour contourner le problème de CORS.
// Notez que l'on doit le faire, ici, parce que le frontend Angular est servi sur
// le port 4200 alors que le backend PHP est servi par Apache sur le port 80.
header("Access-Control-Allow-Origin: " . $_SERVER['HTTP_ORIGIN']);
header("Access-Control-Allow-Credentials: true");
echo json_encode ([
[ 'nom' => 'c1', 'nb_etuds' => 2 ],
[ 'nom' => 'c2', 'nb_etuds' => 5 ],
[ 'nom' => 'c3', 'nb_etuds' => 6 ]
]);
?>
Si vous exécutez ces codes, vous verrez dans la console que le message « les instructions qui sont après le subscribe » s’affichera avant « ici, on a récupéré les données », ce qui prouve bien que tout ce qui concerne les données doit être inclus dans la callback du subscribe(). Cela signifie également que le code suivant (cf. le code souligné en jaune) est incorrect car, même si UE contient 3 éléments, la page affichera via le @switch que UE est vide. En effet, l’affectation de this.nb_UE est réalisée avant que la callback ne soit appelée.
import {Component, OnInit, signal} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {Course, MonServiceService} from '../services/mon-service.service';
@Component({
selector: 'app-courses',
imports: [
FormsModule
],
templateUrl: './courses.component.html',
styleUrl: './courses.component.scss'
})
export class CoursesComponent implements OnInit {
titre = signal('composant courses');
UE : Course[] = [];
nb_UE !: number; // le ! indique à TypeScript que c'est normal de ne pas
// avoir initialisé nb_UE (normalement, c'est une erreur)
constructor(private service : MonServiceService) {}
ngOnInit() {
this.service.getCourses().subscribe(result => {
// ici, on met toutes les instructions qui vont utiliser les
// données récupérées.
console.log("ici, on a récupéré les données");
this.UE = result;
});
// ici, les instructions ne doivent pas dépendre des données récupérées
console.log("les instructions qui sont après le subscribe");
this.nb_UE = this.UE.length;
}
}
<p>{{titre()}}</p>
<ul>
@for (module of UE; track module.nom) {
<li>élément {{$index}} : {{module.nom}} : {{module.nb_etuds}} étuds</li>
}
</ul>
@switch (nb_UE) {
@case(0) {
<li>Pas d'élément dans UE</li>
}
@case(1) {
<li>un seul élément dans UE</li>
}
@default {
<li>plusieurs éléments dans UE</li>
}
}
Les requêtes POST se font de la même manière que les GET, excepté que l’on doit
transmettre les données qui seront stockées dans le $_POST des scripts PHP du
backend. Ici, je ne transmets aucune donnée, donc je passe un null, sinon,
comme dans les exercices, il faudra transmettre un objet de type form-data
,
cf. l’url :
https://developer.mozilla.org/fr/docs/Web/API/FormData/FormData
import { Injectable } from '@angular/core';
import {Observable, of} from 'rxjs';
import {HttpClient} from '@angular/common/http';
export interface Course {
nom: string;
nb_etuds : number;
}
@Injectable({
providedIn: 'root'
})
export class MonServiceService {
constructor(private http: HttpClient) { }
getCourses() : Observable<Course[]> {
return this.http.post<Course[]>(
'http://127.0.0.1/xmobile_cours/getCourses.php', // l'url du backend
null // les data du POST
);
}
}
On a déjà vu le principe des cookies de session utilisés par PHP via sa fonction
session_start(). Le code ci-dessus ne les gère pas, ce qui veut dire que si,
sur la page de login de votre forum, vous saisissez un login/password correct,
le script checkLogin.php
vous indiquera que tout est ok, votre frontend Angular
passera alors sur la page d’affichage des cours, qui requêtera le backend pour
obtenir la liste des cours. Mais celui-ci vous retournera que vous n’êtes pas
connecté(e) car le frontend ne renverra pas le cookie de session. Bref, il faut
un moyen de le retransmettre et cela passe par un 3ème argument du post()
qui contient des options. Celle qui nous intéresse s’appelle withCredentials.
Si vous lui affectez la valeur true, le cookie sera retransmis et votre
forum fonctionnera correctement.
import { Injectable } from '@angular/core';
import {Observable, of} from 'rxjs';
import {HttpClient} from '@angular/common/http';
export interface Course {
nom: string;
nb_etuds : number;
}
@Injectable({
providedIn: 'root'
})
export class MonServiceService {
constructor(private http: HttpClient) { }
getCourses() : Observable<Course[]> {
return this.http.post<Course[]>(
'http://127.0.0.1/xmobile_cours/getCourses.php',
null,
{ withCredentials: true } // capture les cookies de session
);
}
}
Ci-dessous, j’ai reporté le fichier PHP que l’on avait vu plus haut
pour répondre à la requête de
MonServiceService. On peut noter les headers ajoutés sur les lignes en jaune. Ils
sont importants. En effet, si vous les supprimez, vous verrez que votre frontend
ne pourra plus communiquer avec votre backend. La raison en est le
Cross Origin Resource Sharing (CORS). Le problème vient du fait que le
backend est sur un serveur Apache (port 80) tandis que le frontend est servi sur
un serveur du port 4200. Autrement dit, il faut échanger des données entre 2 serveurs et
c’est potentiellement dangereux. Du coup, c’est par défaut interdit. Pour pallier cela,
les headers soulignés en jaune indiquent qu’on autorise tout de même ces transferts de
données. Lorsque vous déploierez votre forum, le problème ne se posera plus car backend
et frontend seront tous les deux sur le même serveur Apache. Il n’apparaît qu’en phase de
développement. Vous pourrez noter que le fichier helper.php
que vous avez placé dans
votre backend dans une séance précédente contient déjà ces headers.
<?php
// on indique ici que le script va transmettre des données JSON
header('Content-type:application/json;charset=utf8');
// Ici, on ajoute un header pour contourner le problème de CORS.
// Notez que l'on doit le faire, ici, parce que le frontend Angular est servi sur
// le port 4200 alors que le backend PHP est servi par Apache sur le port 80.
header("Access-Control-Allow-Origin: " . $_SERVER['HTTP_ORIGIN']);
header("Access-Control-Allow-Credentials: true");
echo json_encode ([
[ 'nom' => 'c1', 'nb_etuds' => 2 ],
[ 'nom' => 'c2', 'nb_etuds' => 5 ],
[ 'nom' => 'c3', 'nb_etuds' => 6 ]
]);
?>
Créez un service message, qui sera utilisé pour abstraire la transmission des requêtes http transmises au backend.
Rajoutez à votre classe MessageService une méthode sendMessage qui prend en argument une chaîne de caractères url représentant une URL ainsi qu’un deuxième argument data de type any, qui représente les données du POST à envoyer au backend. Pour l’instant, indiquez que votre fonction renverra une valeur de n’importe quel type (any).
Pour rendre votre programme assez générique, la chaîne url ne va pas contenir l’URL complète à laquelle on essaye d’accéder, mais seulement la partie après le dernier « / », et sans l’extension « .php ». Par exemple, si l’URL complète est :
https://christophe-gonzales.pedaweb.univ-amu.fr/forum/fr-FR/backend/checkLogin.php
le paramètre url ne contiendra que checkLogin. Vous allez sauvegarder toute la partie de l’URL complète avant le dernier « / », autrement dit le préfixe de l’URL complète, dans un attribut de votre classe MessageService. La première opération que doit donc réaliser votre méthode sendMessage consiste à recréer l’URL complète en concaténant le préfixe, l’argument url et l’extension « .php ».
Pour tester votre méthode sendMessage, faites en sorte qu’elle retourne l’URL que vous avez complétée. Faites également en sorte que, sur la page de login, lorsque l’on clique sur le bouton « se connecter », la méthode sendMessage soit exécutée et que ce qu’elle retourne soit affiché dans la console.
Il faut définir le format des messages qui vont transiter de votre backend vers
votre frontend Angular. Pour cela, regardez ce que renvoient les fonctions
sendMessage et sendError du fichier helper.php
de votre backend. Cela vous
indique les champs des objets JSON qui vous seront transmis. Créez dans le fichier
message.service.ts
une interface PhpData qui représente ce type d’objets JSON.
Le 2ème argument de la méthode sendMessage de votre classe MessageService, appelons-le data est un objet Javascript/TypeScript (dont le type sera any) contenant tous les paramètres permettant de spécifier à quel user/cours/topic/post, on souhaite accéder. Par exemple, pour authentifier votre utilisateur, data devrait être égal à un objet similaire à :
{ login: 'mon_login', password: 'mon_password' }
Pour transmettre cet objet Javascript à votre backend, il faut le transformer en FormData. Opérez cette transformation, cf. l’url :
https://developer.mozilla.org/fr/docs/Web/API/FormData
Vous devez faire en sorte que cette transformation fonctionne quels que soient les champs de l’objet, pas seulement le login/password.
Modifiez le type de retour de votre méthode sendMessage : c’est maintenant un Observable<PhpData>. Rajoutez à votre méthode les instructions qui envoient les informations du FormData à votre backend par requête http. Modifiez la méthode du LoginComponent associée au bouton de connexion de sorte à ce qu’elle affiche dans la console les données retournées par sendMessage.
Faites en sorte que, si le backend vous indique une erreur de login/password, le message d’erreur soit affiché sur la page web dans une alerte de bootstrap, cf. l’url :