9. Architecture en couches et accès aux données
Les exemples précédents gardaient leurs données en mémoire. Une vraie application les garde dans une base de données, et s'organise en couches (cf. schéma de l'introduction) :
- la couche web (contrôleurs, vues) : lire la requête, appeler la couche métier, choisir la vue ;
- la couche métier : les règles de gestion ;
- la couche DAO ([Data Access Object]) : les requêtes à la base de données.
Chaque couche ne connaît que la couche du dessous, et le moins possible de ses détails : la couche web ignore le SQL, la couche métier ignore le HTML. Cette séparation rend chaque couche plus simple à comprendre, à tester et à faire évoluer. L'exemple 28 l'applique à la gestion d'une liste de personnes : c'est, en plus petit, la gestion des médecins et des clients de l'étude de cas.
L'accès à la base est confié à Entity Framework Core (EF Core), l'ORM ([Object-Relational Mapper]) de [.NET] : il fait correspondre des classes [C#] (les entités) aux tables de la base, traduit en SQL les requêtes écrites en [LINQ], et transforme les lignes lues en objets. EF Core dialogue avec chaque type de base par un fournisseur ([provider]) : pour [MySQL], le paquet [NuGet] [Pomelo.EntityFrameworkCore.MySql], qui apporte avec lui EF Core et le pilote [MySqlConnector].
9.1. Exemple 28 : une gestion de personnes en trois couches
| 28-couches/
├── Controllers/
│ └── PersonnesController.cs
├── Dao/
│ └── PersonnesDao.cs
├── Data/
│ └── PersonnesDbContext.cs
├── database/
│ └── dbpersonnes.sql
├── Entities/
│ └── Personne.cs
├── Metier/
│ ├── MetierException.cs
│ └── PersonnesMetier.cs
├── Models/
│ └── FormulairePersonne.cs
├── Views/
│ ├── Personnes/
│ │ ├── Formulaire.cshtml
│ │ └── Liste.cshtml
│ ├── Shared/
│ │ ├── _Champ.cshtml
│ │ ├── _Confirmation.cshtml
│ │ └── _Layout.cshtml
│ ├── _ViewImports.cshtml
│ └── _ViewStart.cshtml
├── wwwroot/
│ ├── css/
│ │ └── styles.css
│ └── js/
│ └── app.js
├── appsettings.json
├── Exemple28.csproj
└── Program.cs
|
9.1.1. La base de données
La base [dbpersonnes] est créée par un script SQL. Avec [Laragon], on l'exécute par exemple dans l'outil [HeidiSQL] (menu Fichier / Exécuter un fichier SQL), ou en ligne de commande :
| mysql -u root < database/dbpersonnes.sql
|
Le fichier [database/dbpersonnes.sql] :
| -- base de l'exemple 28 : une seule table, [personnes]
DROP DATABASE IF EXISTS dbpersonnes;
CREATE DATABASE dbpersonnes CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE dbpersonnes;
CREATE TABLE personnes (
id INT NOT NULL AUTO_INCREMENT,
version INT NOT NULL DEFAULT 1,
prenom VARCHAR(30) NOT NULL,
nom VARCHAR(30) NOT NULL,
age INT NOT NULL,
PRIMARY KEY (id),
-- deux personnes ne peuvent pas avoir les mêmes prénom et nom
CONSTRAINT unq_personnes_prenom_nom UNIQUE (prenom, nom)
) ENGINE = InnoDB;
INSERT INTO personnes (prenom, nom, age) VALUES
('Marie', 'Pélissier', 42),
('Jacques', 'Bromard', 35),
('Philippe', 'Jandot', 58);
|
- ligne 3 : le jeu de caractères [utf8mb4] accepte tous les caractères (accents, emojis...). La collation [utf8mb4_unicode_ci] compare les chaînes sans tenir compte des majuscules ni des accents : pour [MySQL], [pelissier] et [Pélissier] sont égaux ;
- ligne 8 : un numéro de version, augmenté à chaque modification de la ligne (cf. verrou optimiste, plus bas) ;
- ligne 14 : une contrainte d'unicité nommée : la base refuse deux personnes de mêmes prénom et nom. Son nom servira à reconnaître l'erreur.
9.1.2. La configuration
Le fichier [Exemple28.csproj] :
| <Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<!-- Entity Framework Core et son fournisseur MySQL / MariaDB -->
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
</ItemGroup>
<ItemGroup>
<!-- le script SQL n'est pas un fichier de l'application -->
<Content Remove="database\**" />
<None Include="database\**" />
</ItemGroup>
</Project>
|
- ligne 5 : le fournisseur [MySQL] d'EF Core ;
- lignes 10-11 : le script SQL n'est pas un fichier de l'application : on l'exclut des fichiers publiés.
Le fichier [appsettings.json] :
| {
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DbPersonnes": "server=localhost;port=3306;database=dbpersonnes;user=root;password="
},
"MySql": {
"ServerVersion": "8.0.30-mysql"
}
}
|
- ligne 6 : EF Core écrit dans le journal chaque ordre SQL envoyé à la base, au niveau [Information]. Le niveau [Warning] les masque ; il suffit de le remplacer par [Information] pour les voir dans la console ;
- lignes 10-11 : la chaîne de connexion : serveur, port, base, utilisateur, mot de passe. Ce sont les valeurs par défaut de [Laragon] (utilisateur [root], sans mot de passe) ;
- ligne 14 : la version du serveur [MySQL]. Le fournisseur [Pomelo] adapte le SQL qu'il produit à cette version. Une version de [MariaDB] s'écrirait par exemple [10.11.0-mariadb].
Le fichier [Program.cs] :
| using System.Text.Encodings.Web;
using System.Text.Unicode;
using Exemple28.Dao;
using Exemple28.Data;
using Exemple28.Metier;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.FileProviders;
var builder = WebApplication.CreateBuilder(args);
// la connexion à la base : chaîne de connexion et version du serveur, lues dans appsettings.json
var chaine = builder.Configuration.GetConnectionString("DbPersonnes")!;
var version = ServerVersion.Parse(builder.Configuration["MySql:ServerVersion"]!);
// AddDbContext : un DbContext par requête (durée de vie Scoped)
builder.Services.AddDbContext<PersonnesDbContext>(options => options.UseMySql(chaine, version));
// les couches DAO et métier : Scoped, comme le DbContext qu'elles utilisent
builder.Services.AddScoped<PersonnesDao>();
builder.Services.AddScoped<PersonnesMetier>();
builder.Services.AddControllersWithViews(options =>
options.ModelBindingMessageProvider.SetAttemptedValueIsInvalidAccessor((valeur, champ) => $"La valeur [{valeur}] n'est pas valide."));
builder.Services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All));
var app = builder.Build();
app.UseStaticFiles();
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(Path.GetFullPath(Path.Combine(builder.Environment.ContentRootPath, "..", "commun", "bootstrap"))),
RequestPath = "/bootstrap",
});
app.MapControllers();
app.Run();
|
- ligne 12 : [GetConnectionString("DbPersonnes")] lit la propriété [DbPersonnes] de la section [ConnectionStrings] ;
- ligne 15 : [AddDbContext] enregistre le [DbContext] (cf. plus bas), avec une durée de vie [Scoped] : un par requête. [UseMySql] choisit le fournisseur [MySQL] ;
- lignes 18-19 : les couches DAO et métier, de durée de vie [Scoped] elles aussi : elles utilisent le [DbContext], qui ne peut pas vivre plus longtemps qu'une requête (cf. chapitre 6) ;
- ligne 22 : le message d'une erreur de conversion (cf. exemple 19).
9.1.3. L'entité Personne
Le fichier [Entities/Personne.cs] :
| using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Exemple28.Entities;
// une ligne de la table [personnes]
[Table("personnes")]
public class Personne
{
// la clé primaire : une clé entière est, par convention, générée par la base
// (AUTO_INCREMENT) ; EF Core relit sa valeur après chaque INSERT
[Key]
[Column("id")]
public int Id { get; set; }
// le verrou optimiste : chaque UPDATE porte la condition "AND version = <valeur lue>"
[Column("version")]
[ConcurrencyCheck]
public int Version { get; set; } = 1;
[Column("prenom")]
[MaxLength(30)]
public string Prenom { get; set; } = "";
[Column("nom")]
[MaxLength(30)]
public string Nom { get; set; } = "";
[Column("age")]
public int Age { get; set; }
}
|
- ligne 7 : la classe [Personne] correspond à la table [personnes] : chaque ligne de la table sera un objet [Personne] ;
- lignes 12-14 : la clé primaire ([[Key]]). [[Column]] donne le nom de la colonne correspondant à chaque propriété ;
- lignes 18-19 : la colonne de version. [[ConcurrencyCheck]] demande à EF Core d'ajouter à chaque [UPDATE] la condition [AND version = <valeur lue>] : c'est le verrou optimiste, expliqué plus loin ;
- ligne 22 : les autres colonnes. [[MaxLength]] indique la taille de la colonne.
9.1.4. Le DbContext
Le fichier [Data/PersonnesDbContext.cs] :
| using Exemple28.Entities;
using Microsoft.EntityFrameworkCore;
namespace Exemple28.Data;
// le DbContext : la connexion à la base, et le suivi des entités lues
public class PersonnesDbContext(DbContextOptions<PersonnesDbContext> options) : DbContext(options)
{
// la "table" des personnes : on l'interroge avec LINQ
public DbSet<Personne> Personnes => Set<Personne>();
// juste avant chaque enregistrement : la version de toute personne
// MODIFIÉE est augmentée de 1. L'UPDATE généré compare l'ANCIENNE valeur :
// UPDATE personnes SET ..., version = 2 WHERE id = 1 AND version = 1
public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
{
foreach (var entree in ChangeTracker.Entries<Personne>())
{
if (entree.State == EntityState.Modified)
{
entree.Entity.Version++;
}
}
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}
}
|
- ligne 7 : le DbContext est l'objet central d'EF Core. Il représente une session de travail avec la base : il ouvre les connexions, envoie les requêtes, et suit les entités qu'il a lues (il sait lesquelles ont été modifiées) ;
- ligne 10 : un [DbSet<Personne>] représente la table : on l'interroge avec [LINQ], on y ajoute des entités ;
- lignes 15-24 : [SaveChangesAsync] envoie à la base toutes les modifications en attente. On la redéfinit pour augmenter la version de chaque personne modifiée. [ChangeTracker] est le suivi des modifications : pour chaque entité suivie, il connaît son état ([Added], [Modified], [Deleted], [Unchanged]). EF Core, contrairement à [TypeORM], ne gère pas lui-même une colonne de version entière : c'est à nous de l'augmenter.
9.1.5. La couche DAO
Le fichier [Dao/PersonnesDao.cs] :
| using Exemple28.Data;
using Exemple28.Entities;
using Microsoft.EntityFrameworkCore;
namespace Exemple28.Dao;
// la couche DAO : des requêtes, aucune règle de gestion
public class PersonnesDao(PersonnesDbContext db)
{
// SELECT ... FROM personnes ORDER BY nom, prenom
public Task<List<Personne>> ListerAsync() =>
db.Personnes.AsNoTracking().OrderBy(p => p.Nom).ThenBy(p => p.Prenom).ToListAsync();
// SELECT ... FROM personnes WHERE id = @id LIMIT 1 ; null si absente
public Task<Personne?> LireAsync(int id) =>
db.Personnes.FirstOrDefaultAsync(p => p.Id == id);
// une personne de mêmes prénom et nom, autre que [saufId]
public Task<Personne?> HomonymeAsync(string prenom, string nom, int saufId) =>
db.Personnes.FirstOrDefaultAsync(p => p.Prenom == prenom && p.Nom == nom && p.Id != saufId);
// INSERT si la personne est nouvelle (Id = 0), UPDATE des colonnes modifiées sinon
public async Task<Personne> SauvegarderAsync(Personne personne)
{
if (personne.Id == 0)
{
db.Personnes.Add(personne);
}
await db.SaveChangesAsync();
return personne;
}
// DELETE FROM personnes WHERE id = @id, sans lire la personne d'abord
public Task SupprimerAsync(int id) =>
db.Personnes.Where(p => p.Id == id).ExecuteDeleteAsync();
}
|
- ligne 8 : le DAO reçoit le [DbContext] par injection ;
- lignes 11-12 : une requête [LINQ], traduite en SQL par EF Core. Rien n'est envoyé à la base avant l'appel de [ToListAsync], qui exécute la requête et rend la liste. [AsNoTracking()] indique que ces entités ne seront pas modifiées : le [DbContext] n'a pas à les suivre ;
- lignes 15-16 : la première personne qui vérifie la condition, ou [null] ;
- lignes 19-20 : une personne de mêmes prénom et nom, autre que [saufId] : utile pour la modification d'une fiche, qui ne doit pas se trouver en conflit avec elle-même. La comparaison est faite par [MySQL], donc selon la collation de la base : sans tenir compte des majuscules ni des accents ;
- lignes 23-30 : une nouvelle personne ([Id] à 0) est ajoutée au [DbSet] ; [SaveChangesAsync] fait alors un [INSERT], et relit l'identifiant attribué par la base. Une personne lue par ce même [DbContext] et modifiée est détectée par le suivi des modifications : [SaveChangesAsync] fait un [UPDATE] des seules colonnes modifiées ;
- lignes 34-35 : [ExecuteDeleteAsync] envoie directement un [DELETE] à la base, sans lire les personnes d'abord.
La couche DAO ne contient aucune règle : seulement des requêtes. Ces méthodes sont asynchrones ([Task], [async] / [await]) : pendant que la base travaille, le fil d'exécution est libéré et sert d'autres requêtes.
9.1.6. La couche métier
Le fichier [Metier/MetierException.cs] :
| namespace Exemple28.Metier;
// une règle de gestion refuse l'opération : le message est destiné à l'utilisateur
public class MetierException(string message) : Exception(message);
|
Une [MetierException] signale qu'une règle de gestion refuse une opération. Ce n'est pas une anomalie du programme : son message est destiné à l'utilisateur.
Le fichier [Metier/PersonnesMetier.cs] :
| using Exemple28.Dao;
using Exemple28.Data;
using Exemple28.Entities;
using Microsoft.EntityFrameworkCore;
using MySqlConnector;
namespace Exemple28.Metier;
public record DonneesPersonne(string Prenom, string Nom, int Age);
// la couche MÉTIER : les règles de gestion, et elles seules
public class PersonnesMetier(PersonnesDao dao, PersonnesDbContext db)
{
private const string Homonyme = "Une personne porte déjà ces prénom et nom.";
public Task<List<Personne>> ListerAsync() => dao.ListerAsync();
public async Task<Personne> LireAsync(int id) =>
await dao.LireAsync(id) ?? throw new MetierException("Cette personne n'existe pas (ou plus).");
public Task<Personne> AjouterAsync(DonneesPersonne donnees) =>
TransactionAsync(async () =>
{
// règle 1 : pas deux personnes de mêmes prénom et nom
if (await dao.HomonymeAsync(donnees.Prenom, donnees.Nom, 0) is not null)
{
throw new MetierException(Homonyme);
}
var personne = new Personne { Prenom = donnees.Prenom, Nom = donnees.Nom, Age = donnees.Age };
return await dao.SauvegarderAsync(personne);
});
public Task<Personne> ModifierAsync(int id, int version, DonneesPersonne donnees) =>
TransactionAsync(async () =>
{
var personne = await LireAsync(id);
// règle 2 : on ne modifie pas une fiche modifiée entre-temps par un autre
if (personne.Version != version)
{
throw new MetierException("Cette fiche a été modifiée entre-temps : rechargez-la.");
}
if (await dao.HomonymeAsync(donnees.Prenom, donnees.Nom, id) is not null)
{
throw new MetierException(Homonyme);
}
personne.Prenom = donnees.Prenom;
personne.Nom = donnees.Nom;
personne.Age = donnees.Age;
return await dao.SauvegarderAsync(personne);
});
public async Task<Personne> SupprimerAsync(int id)
{
var personne = await LireAsync(id);
await dao.SupprimerAsync(id);
return personne;
}
// [travail] s'exécute dans une transaction : COMMIT s'il réussit, ROLLBACK
// s'il lève une exception. Les erreurs de la base deviennent des MetierException
private async Task<R> TransactionAsync<R>(Func<Task<R>> travail)
{
await using var transaction = await db.Database.BeginTransactionAsync(); // START TRANSACTION
try
{
var resultat = await travail();
await transaction.CommitAsync(); // COMMIT
return resultat;
}
catch (DbUpdateConcurrencyException)
{
// l'UPDATE "WHERE id = ... AND version = ..." n'a modifié aucune ligne
throw new MetierException("Cette fiche a été modifiée entre-temps : rechargez-la.");
}
catch (DbUpdateException e) when (e.InnerException is MySqlException { ErrorCode: MySqlErrorCode.DuplicateKeyEntry } erreurSql
&& erreurSql.Message.Contains("unq_personnes_prenom_nom"))
{
// deux ajouts simultanés : la contrainte d'unicité de la base a refusé le second
throw new MetierException(Homonyme);
}
// toute autre exception poursuit son chemin ; la transaction, refermée
// sans COMMIT par "await using", est annulée (ROLLBACK)
}
}
|
Commentons ce code :
- ligne 9 : les données d'une personne, telles que la couche web les transmet ;
- ligne 12 : la couche métier utilise le DAO, et le [DbContext] pour ouvrir des transactions. Tous deux sont des services [Scoped] : au cours d'une requête, le DAO et la couche métier reçoivent le même [DbContext], donc la même connexion et la même transaction. (Avec [TypeORM], il fallait passer à chaque méthode du DAO l'objet de la transaction) ;
- lignes 18-19 : une personne absente est une [MetierException]. L'opérateur [??] fournit une valeur de remplacement ; [throw] peut en tenir lieu ;
- lignes 21-30 : l'ajout, dans une transaction. Règle : pas deux personnes de mêmes prénom et nom. On la vérifie avant l'écriture pour donner un message clair ;
- lignes 33-49 : la modification. Règle supplémentaire, le verrou optimiste (lignes 38-40) : le formulaire de modification a transporté la version de la fiche telle qu'elle était à l'ouverture du formulaire. Si elle a changé entre-temps (un autre utilisateur a modifié la même fiche), on refuse, plutôt que d'écraser silencieusement son travail ;
- lignes 61-67 : [BeginTransactionAsync] ouvre une transaction ([START TRANSACTION]), [CommitAsync] la valide ([COMMIT]). [await using] referme la transaction à la sortie du bloc, quoi qu'il arrive : refermée sans [COMMIT], elle est annulée ([ROLLBACK]). Ainsi, une exception levée par [travail] (une [MetierException] comprise) annule tout ce qui a été fait ;
- lignes 70-73 : la vérification de la version, plus haut, ne suffit pas toujours : deux utilisateurs peuvent enregistrer la même fiche au même instant, et passer tous deux la vérification. L'[UPDATE ... WHERE id = ... AND version = ...] du second ne modifie alors aucune ligne ; EF Core s'en aperçoit et lève une [DbUpdateConcurrencyException], qui devient la même [MetierException] ;
- lignes 75-79 : de même, deux ajouts simultanés de la même personne passent la vérification des homonymes, mais la contrainte d'unicité de la base, elle, ne laisse rien passer. L'erreur [MySQL] (code [DuplicateKeyEntry], n° 1062) arrive enveloppée dans une [DbUpdateException] (propriété [InnerException]) ; son message cite le nom de la contrainte. La clause [when] n'intercepte l'exception que si la condition est vraie. [is MySqlException { ErrorCode: ... } erreurSql] est un filtrage par motif : il teste le type et la valeur d'une propriété, et range l'objet dans une variable.
9.1.7. La couche web
Le fichier [Models/FormulairePersonne.cs] :
| using System.ComponentModel.DataAnnotations;
namespace Exemple28.Models;
// les champs du formulaire et leurs contraintes (cf. exemple 19)
public class FormulairePersonne
{
private string? prenom;
private string? nom;
[Required(ErrorMessage = "Le prénom est obligatoire.")]
[MaxLength(30, ErrorMessage = "30 caractères au plus.")]
public string? Prenom { get => prenom; set => prenom = value?.Trim(); }
[Required(ErrorMessage = "Le nom est obligatoire.")]
[MaxLength(30, ErrorMessage = "30 caractères au plus.")]
public string? Nom { get => nom; set => nom = value?.Trim(); }
[Required(ErrorMessage = "L'âge est obligatoire.")]
[Range(0, 150, ErrorMessage = "L'âge doit être compris entre 0 et 150.")]
public int? Age { get; set; }
// la version de la fiche, transportée par un champ caché (modification seulement)
public int? Version { get; set; }
}
// le modèle de la vue du formulaire
public class FormulaireVue
{
public required string Titre { get; init; } // "Ajouter une personne"...
public required string Action { get; init; } // l'adresse d'envoi du formulaire
public FormulairePersonne Valeurs { get; init; } = new();
public Dictionary<string, string> Erreurs { get; init; } = new(StringComparer.OrdinalIgnoreCase);
}
// le modèle de la vue partielle _Champ
public record ChampModele(string Nom, string Libelle, string Type, string? Valeur, string? Erreur);
|
Le formulaire et ses contraintes (cf. exemple 19), avec la version de la fiche (ligne 24) ; le modèle de la vue du formulaire, qui sert à l'ajout et à la modification (titre et adresse d'envoi différents) ; le modèle de la vue partielle [_Champ] (cf. exemple 19).
Le fichier [Controllers/PersonnesController.cs] :
| using Exemple28.Metier;
using Exemple28.Models;
using Microsoft.AspNetCore.Mvc;
namespace Exemple28.Controllers;
// la couche WEB : elle ne connaît que la couche métier
public class PersonnesController(PersonnesMetier metier) : Controller
{
[HttpGet("/")]
public IActionResult Accueil() => RedirectToAction(nameof(Liste));
[HttpGet("personnes")]
public async Task<IActionResult> Liste()
{
return View(await metier.ListerAsync());
}
[HttpGet("personnes/ajout")]
public IActionResult Ajout() => Formulaire(Vue("Ajouter une personne", "/personnes/ajout", new()));
[HttpPost("personnes/ajout")]
public async Task<IActionResult> Ajouter(FormulairePersonne saisie)
{
var vue = Vue("Ajouter une personne", "/personnes/ajout", saisie);
if (!ModelState.IsValid)
{
return Formulaire(vue);
}
try
{
var personne = await metier.AjouterAsync(new DonneesPersonne(saisie.Prenom!, saisie.Nom!, saisie.Age!.Value));
TempData["Succes"] = $"{personne.Prenom} {personne.Nom} a été ajouté(e).";
return RedirectToAction(nameof(Liste));
}
catch (MetierException e)
{
// une règle métier refuse : on réaffiche le formulaire, le message en bas de page
return Formulaire(vue, e.Message);
}
}
[HttpGet("personnes/{id:int}/modification")]
public async Task<IActionResult> Modification(int id)
{
try
{
var p = await metier.LireAsync(id);
var valeurs = new FormulairePersonne { Prenom = p.Prenom, Nom = p.Nom, Age = p.Age, Version = p.Version };
return Formulaire(Vue("Modifier une personne", $"/personnes/{id}/modification", valeurs));
}
catch (MetierException e)
{
TempData["Erreur"] = e.Message;
return RedirectToAction(nameof(Liste));
}
}
[HttpPost("personnes/{id:int}/modification")]
public async Task<IActionResult> Modifier(int id, FormulairePersonne saisie)
{
var vue = Vue("Modifier une personne", $"/personnes/{id}/modification", saisie);
if (!ModelState.IsValid)
{
return Formulaire(vue);
}
try
{
var donnees = new DonneesPersonne(saisie.Prenom!, saisie.Nom!, saisie.Age!.Value);
var personne = await metier.ModifierAsync(id, saisie.Version ?? 0, donnees);
TempData["Succes"] = $"{personne.Prenom} {personne.Nom} a été modifié(e).";
return RedirectToAction(nameof(Liste));
}
catch (MetierException e)
{
return Formulaire(vue, e.Message);
}
}
[HttpPost("personnes/{id:int}/suppression")]
public async Task<IActionResult> Supprimer(int id)
{
try
{
var personne = await metier.SupprimerAsync(id);
TempData["Succes"] = $"{personne.Prenom} {personne.Nom} a été supprimé(e).";
}
catch (MetierException e)
{
// pas de formulaire à réafficher : l'erreur voyage elle aussi dans TempData
TempData["Erreur"] = e.Message;
}
return RedirectToAction(nameof(Liste));
}
// le modèle de la vue du formulaire, avec les erreurs de ModelState rangées par champ
private FormulaireVue Vue(string titre, string action, FormulairePersonne valeurs) => new()
{
Titre = titre,
Action = action,
Valeurs = valeurs,
Erreurs = ModelState
.Where(c => c.Value!.Errors.Count > 0)
.ToDictionary(c => c.Key, c => c.Value!.Errors[0].ErrorMessage, StringComparer.OrdinalIgnoreCase),
};
private ViewResult Formulaire(FormulaireVue vue, string? erreur = null)
{
ViewData["Erreur"] = erreur;
return View("Formulaire", vue);
}
}
|
Commentons ce code :
- ligne 8 : le contrôleur ne connaît que la couche métier ;
- ligne 11 : la racine du site redirige vers la liste ;
- lignes 14-16 : une action asynchrone : elle rend un [Task<IActionResult>], et attend ([await]) la liste lue en base ;
- lignes 23-39 : l'ajout, en trois temps : validation du formulaire (erreurs : réaffichage), appel de la couche métier (succès : message dans [TempData] et redirection, cf. exemple 20), [MetierException] (réaffichage avec le message en bas de page). Une autre exception n'est pas interceptée : c'est une anomalie, qui produira une erreur 500 ;
- lignes 44-55 : le formulaire de modification, prérempli avec la fiche lue en base, y compris sa version ;
- lignes 60-76 : même schéma que l'ajout ; la version renvoyée par le formulaire est transmise à la couche métier ;
- lignes 81-93 : la suppression. Il n'y a pas de formulaire à réafficher : une éventuelle erreur voyage, elle aussi, dans [TempData] ;
- lignes 97-104 : le modèle de la vue du formulaire, avec les erreurs de [ModelState] rangées par champ (cf. exemple 19).
Le fichier [Views/Personnes/Formulaire.cshtml] :
| @model FormulaireVue
@{
string? Erreur(string champ) => Model.Erreurs.GetValueOrDefault(champ);
}
<h1 class="h3">@Model.Titre</h1>
<form method="post" action="@Model.Action" novalidate>
@* la version de la fiche lue : elle reviendra avec le formulaire *@
<input type="hidden" name="version" value="@Model.Valeurs.Version">
<partial name="_Champ" model='new ChampModele("prenom", "Prénom", "text", Model.Valeurs.Prenom, Erreur("prenom"))' />
<partial name="_Champ" model='new ChampModele("nom", "Nom", "text", Model.Valeurs.Nom, Erreur("nom"))' />
<partial name="_Champ" model='new ChampModele("age", "Âge", "number", Model.Valeurs.Age?.ToString(), Erreur("age"))' />
<button class="btn btn-primary" type="submit">Enregistrer</button>
<a class="btn btn-link" href="/personnes">Annuler</a>
</form>
|
- ligne 8 : le champ caché qui transporte la version de la fiche. La vue sert à l'ajout et à la modification : son titre et l'adresse d'envoi ([action]) sont fournis par le contrôleur.
Le fichier [Views/Shared/_Layout.cshtml] :
| <!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Personnes</title>
<link rel="stylesheet" href="/bootstrap/css/bootstrap.min.css">
<link rel="stylesheet" href="/css/styles.css">
</head>
<body>
<div class="container py-3" style="max-width: 40rem">
@* le message de succès (TempData, après une redirection) : en haut *@
@if (TempData["Succes"] is string succes)
{
<div class="alert alert-success" role="status">@succes</div>
}
@RenderBody()
@* l'erreur : de la requête en cours (ViewData) ou d'avant la redirection (TempData) *@
@if ((ViewData["Erreur"] ?? TempData["Erreur"]) is string erreur)
{
<div class="zone-erreur" role="alert">@erreur</div>
}
</div>
<partial name="_Confirmation" />
<script src="/js/app.js"></script>
</body>
</html>
|
- ligne 12 : le message de succès, en haut de la page ;
- ligne 18 : le message d'erreur, en bas de page : celui de la requête en cours ([ViewData]), ou celui déposé avant une redirection ([TempData]).
Les vues [Liste.cshtml] et [_Champ.cshtml], et le script de confirmation, reprennent ceux des exemples 19 et 21.
9.1.8. Exécution
Si les valeurs de [appsettings.json] ne conviennent pas (un mot de passe pour [root], par exemple), on peut les remplacer sans modifier le fichier, par des variables d'environnement : le séparateur de sections [:] s'y écrit [__] (deux caractères de soulignement). Sous [Windows] ([PowerShell]) :
| $env:ConnectionStrings__DbPersonnes="server=localhost;database=dbpersonnes;user=root;password=secret"; dotnet run
|
La liste, à l'URL http://localhost:5000/ :

Un formulaire invalide :

Un homonyme : la règle métier le détecte. [MySQL] comparant selon la collation de la base, [marie pelissier], écrit en minuscules et sans accent, serait lui aussi refusé :

Un ajout réussi :

Avec le niveau de journalisation [Information] pour [Microsoft.EntityFrameworkCore.Database.Command], la console montre alors, pour chaque ordre SQL, le texte de la requête, ses paramètres (masqués par défaut, par prudence) et sa durée d'exécution : on y reconnaît la transaction, la recherche d'homonyme ([SELECT ... LIMIT 1]), l'[INSERT] suivi de la lecture de l'identifiant attribué, le [COMMIT], puis, après la redirection, la lecture de la liste ([SELECT ... ORDER BY]).
Le verrou optimiste : deux formulaires sont ouverts sur la même fiche ; le premier est enregistré (la version passe à 2) ; le second, qui transporte encore la version 1, est refusé :

La suppression, avec confirmation :

