CTI — Dezvoltarea Aplicațiilor Web — Laborator 10
Many-to-Many, Middleware și logging structurat cu Serilog
Obiective
Laboratorul continuă proiectul News Portal din Lab 9. La finalul acestui laborator, aplicația va avea tag-uri pentru articole (relație Many-to-Many cu junction table explicit), un pipeline de middleware custom (logging request/response + global exception handling) și logging structurat prin Serilog cu sinks Console și File.
Ce facem:
- Many-to-Many în EF Core — entitatea
Tag, junction explicitArticleTag, configurareHasMany().WithMany().UsingEntity<>(), migrație, JOIN cuTagsprinInclude() - UI — afișare tag-uri ca badge-uri pe
Details, multi-select peCreate/Edit - Pipeline-ul de middleware ASP.NET Core — ce e un middleware, în ce ordine rulează, cum scriem
LoggingMiddlewarecuStopwatchșiILogger<T> - Global exception handling —
ExceptionHandlingMiddlewarecare prinde tot, mapează tipuri de excepții la status codes și răspunde cu JSON - Serilog — instalare, configurare cu sinks Console + File rolling pe zi, integrare prin
builder.Host.UseSerilog() - Logging structurat — diferența între
LogInformation("X: {Id}", id)și interpolare$"X: {id}"
Punctaj (4p)
- 3 exerciții: Ex 1 = 1p, Ex 2 = 1p, Ex 3 = 2p.
- Ex 1 acoperă Partea 1 (Many-to-Many), Ex 2 Partea 2 (middleware), Ex 3 Partea 3 (Serilog).
Definițiile complete sunt la finalul fiecărei părți.
Recapitulare Lab 9
Din laboratoarele anterioare avem:
- Lab 6: Repository Pattern + Service Layer + MVC + async/await
- Lab 7: ASP.NET Core Identity, Roles (
Admin,User), content ownership peAuthorId - Lab 8: Web API RESTful cu DTOs, Swagger, JWT Bearer pe scrieri
- Lab 9: Proiect de teste —
Lab09.Tests/cu unit tests peArticleService(EF InMemory) + integration tests prinWebApplicationFactory<Program>cu JWT real
Lab10_start continuă fără să strice nimic din ce s-a construit. Modelul Article rămâne la fel, controller-ele sunt neschimbate. Adăugăm o entitate nouă (Tag), două middleware-uri și înlocuim sistemul de logging cu Serilog.
În Lab10_start aveți deja:
Models/Tag.csșiModels/ArticleTag.cs— clase goale cu TODO-uriMiddleware/LoggingMiddleware.csșiMiddleware/ExceptionHandlingMiddleware.cs— schelete cuInvokeAsyncpass-throughSerilog.AspNetCore,Serilog.Sinks.Console,Serilog.Sinks.File— preinstalate înLab10.csproj(nu trebuiedotnet add package)- TODO-uri în
Program.csla locurile unde trebuie configurat Serilog și înregistrate middleware-urile
De ce un junction table explicit?
EF Core suportă două stiluri pentru Many-to-Many:
| Stil | Configurare | Junction | Când |
|---|---|---|---|
| Implicit | HasMany().WithMany() |
Tabel auto-generat ArticleTag cu doar ArticleId + TagId |
Când nu vă pasă de junction (cazul comun) |
| Explicit | UsingEntity<ArticleTag>(...) |
Clasă ArticleTag pe care o controlați |
Când junction-ul are coloane proprii (AddedAt, AddedByUserId, ordering) sau când vreți acces direct la el |
Forma completă a configurării implicite arată așa: HasMany(a => a.Tags).WithMany(t => t.Articles).
Pentru tag-urile noastre, explicit e mai mult din motive pedagogice — vedeți toate cele trei tabele în BD (Articles, Tags, ArticleTags), înțelegeți ce face EF în spate și aveți unde să adăugați coloane în lab-urile viitoare. La un proiect real, dacă junction-ul rămâne pur „cuplaj fără date", stilul implicit e suficient.
Cum încărcăm tag-urile (recap din Lab 6)
Tags din Article e o proprietate de navigare. Fără .Include(a => a.Tags) în query, EF nu o populează — article.Tags rămâne goală chiar dacă în BD există relațiile. Soluția: cerem explicit JOIN-ul prin Include — apare în SQL log, predictibil, fără surprize la runtime.
var article = await _context.Articles
.Include(a => a.Tags) // JOIN cu Tags
.FirstOrDefaultAsync(a => a.Id == id);
De ce middleware?
Pipeline-ul ASP.NET Core e o listă de funcții care se apelează una pe alta — fiecare poate face ceva înainte și după restul pipeline-ului:
Ordinea în care apelați app.UseMiddleware<X>() e ordinea în care X vede request-ul. Pe response, ordinea e inversă. De-asta ExceptionHandling trebuie să fie primul — vrea să prindă tot ce poate arunca în spatele lui.
Cazuri tipice:
| Middleware | Ce face | Built-in? |
|---|---|---|
UseHttpsRedirection |
redirect HTTP → HTTPS | da |
UseStaticFiles |
servește fișiere din wwwroot |
da |
UseRouting |
identifică ce controller/action răspunde | da |
UseAuthentication / UseAuthorization |
auth real (cookie / JWT) | da |
LoggingMiddleware (custom) |
logăm method, path, status, durată | nu — îl scriem noi |
ExceptionHandlingMiddleware (custom) |
prindem excepții → JSON + status | nu — îl scriem noi |
De ce Serilog?
ILogger<T> built-in funcționează — dar:
- Sinks limitate — built-in scrie în Console și Debug. Pentru File / Seq / Elasticsearch / Application Insights, trebuie pachete.
- Structured logging funcționează cu built-in, dar arhivarea e simplistă — nu poți filtra după
{Id}decât parsând text. - Configurare — built-in are o singură secțiune
Loggingînappsettings.json. Serilog acceptă config bogat (sinks multiple, enrichers, filtre per-sink).
Serilog e standardul de-facto în ecosistemul .NET pentru aplicații care chiar țin la log-uri. Sintaxa e identică cu ILogger<T> — doar adăugați câteva linii la pornire.
Notă: Serilog înlocuiește logger-ul built-in. Toate
ILogger<T>injectate (în service-uri, controller-e, middleware) merg automat prin Serilog după ce apelațibuilder.Host.UseSerilog(). Nu modificați nimic în service-urile existente.
Parte 1 — Many-to-Many cu junction explicit
Pasul 1 — Modelul Tag
Un articol poate avea mai multe tag-uri; un tag poate eticheta mai multe articole. Asta e Many-to-Many.
În Lab10_start/Models/Tag.cs aveți o clasă goală cu TODO-uri. Completați-o:
using System.ComponentModel.DataAnnotations;
namespace Lab10.Models;
public class Tag : BaseEntity
{
[Required]
[MinLength(2)]
public string Name { get; set; } = string.Empty;
// Navigare Many-to-Many catre Article
public List<Article> Articles { get; set; } = new();
}
Articles e proprietate de navigare — EF o populează când cereți Include(t => t.Articles). Fără Include, lista rămâne goală.
De ce
: BaseEntity? Pentru căRepository<T>(din Lab 6) are constraintwhere T : BaseEntity.BaseEntitydoar expuneId- moștenirea ne dăIdautomat și ne faceTagcompatibil cu repository-ul generic.
Pasul 2 — Junction-ul explicit ArticleTag
În Lab10_start/Models/ArticleTag.cs, definiți junction-ul:
using System.ComponentModel.DataAnnotations.Schema;
namespace Lab10.Models;
[Table("ArticleTags")]
public class ArticleTag
{
public int ArticleId { get; set; }
public Article Article { get; set; } = null!;
public int TagId { get; set; }
public Tag Tag { get; set; } = null!;
}
Cele două perechi (FK + navigare) sunt convenția EF pentru junction. EF deduce că PK-ul compus este (ArticleId, TagId) din configurarea pe care o adăugăm în OnModelCreating.
De ce
[Table("ArticleTags")]? Implicit EF folosește numele clasei pentru numele tabelului - aici ar fiArticleTag(singular). Pentru consistență cu celelalte tabele plurale (Tags,Articles), forțăm numele plural cu atributul[Table]. Alternativ, puteți folosij.ToTable("ArticleTags")în configurareaUsingEntity<>dinOnModelCreating.
De ce
null!? Cu<Nullable>enable</Nullable>în.csproj, compilatorul cere ca proprietățile non-nullable să fie inițializate. La entități EF, navigările nu pot fi inițializate la construcție — sunt setate de EF la materializare.null!e promisiunea către compilator: „știu, n-o să fie null când o citesc".
Pasul 3 — Article primește o navigare către Tags
Deschideți Models/Article.cs și adăugați navigarea Many-to-Many (alături de cele existente către Category și Author):
public class Article : BaseEntity
{
// ... proprietati existente (Title, Content, PublishedAt, CategoryId, AuthorId, ...) ...
// Navigare Many-to-Many catre Tag
public List<Tag> Tags { get; set; } = new();
}
Nu adăugați List<ArticleTag> ArticleTags. EF descoperă junction-ul prin configurarea din OnModelCreating; navigarea „primară" (cea pe care o folosiți în cod) e direct către List<Tag>.
Pasul 4 — Configurare în AppDbContext
În Data/AppDbContext.cs, adăugați DbSet<Tag> și configurarea Many-to-Many:
public DbSet<Tag> Tags { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// ... configurari existente (Article <-> Category, Article <-> Author) ...
// Many-to-Many: Article <-> Tag, prin junction explicit ArticleTag
modelBuilder.Entity<Article>()
.HasMany(a => a.Tags)
.WithMany(t => t.Articles)
.UsingEntity<ArticleTag>(
j => j.HasOne(at => at.Tag).WithMany().HasForeignKey(at => at.TagId),
j => j.HasOne(at => at.Article).WithMany().HasForeignKey(at => at.ArticleId),
j => j.HasKey(at => new { at.ArticleId, at.TagId }));
}
Cele trei lambda-uri ale lui UsingEntity<ArticleTag> configurează:
- Cum e legat junction-ul de
Tag(FK cătreTags.Id) - Cum e legat junction-ul de
Article(FK cătreArticles.Id) - PK-ul compus al junction-ului
Pasul 5 — Migrația
cd C:\Daniel\ore\DAW-2025-2026\labs\lab10\Lab10_start
dotnet ef migrations add AddTagsAndManyToMany
dotnet ef database update
Migrația ar trebui să creeze două tabele:
Tags(Id,Name)ArticleTags(ArticleId,TagId, PK compus, FK-uri cătreArticlesșiTags)
Verificați în SQL Server Object Explorer (sau echivalentul vostru) că ambele apar.
Pasul 6 — Seed pentru tag-uri
În Data/SeedData.cs, după seed-ul de categorii și articole, adăugați un seed pentru tag-uri și asociați-le la articolele existente:
if (!await context.Tags.AnyAsync())
{
var tags = new[]
{
new Tag { Name = "C#" },
new Tag { Name = "Web" },
new Tag { Name = "Tutorial" },
new Tag { Name = ".NET" },
new Tag { Name = "Database" }
};
context.Tags.AddRange(tags);
await context.SaveChangesAsync();
// Atasam cateva tag-uri la primul articol seed-uit
var firstArticle = await context.Articles
.Include(a => a.Tags)
.FirstOrDefaultAsync();
if (firstArticle != null)
{
firstArticle.Tags.Add(tags[0]); // C#
firstArticle.Tags.Add(tags[3]); // .NET
await context.SaveChangesAsync();
}
}
Pasul 7 — Include Tags în repository (toate cele 4 metode)
În Repositories/ArticleRepository.cs, toate metodele care fac JOIN cu Category + Author trebuie să adauge și .Include(a => a.Tags). Sunt patru: GetAllWithDetailsAsync, GetByIdWithDetailsAsync, GetByCategoryAsync, GetPagedAsync. Dacă uitați una, articolele întoarse de acea metodă vor avea Tags lista goală chiar dacă în BD există relațiile.
public async Task<List<Article>> GetAllWithDetailsAsync(CancellationToken cancellationToken = default)
{
return await _context.Articles
.Include(a => a.Category)
.Include(a => a.Author)
.Include(a => a.Tags) // <-- nou
.OrderByDescending(a => a.PublishedAt)
.ToListAsync(cancellationToken);
}
public async Task<Article?> GetByIdWithDetailsAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.Articles
.Include(a => a.Category)
.Include(a => a.Author)
.Include(a => a.Tags) // <-- nou
.FirstOrDefaultAsync(a => a.Id == id, cancellationToken);
}
public async Task<List<Article>> GetByCategoryAsync(int categoryId, CancellationToken cancellationToken = default)
{
return await _context.Articles
.Where(a => a.CategoryId == categoryId)
.Include(a => a.Category)
.Include(a => a.Author)
.Include(a => a.Tags) // <-- nou
.OrderByDescending(a => a.PublishedAt)
.ToListAsync(cancellationToken);
}
public async Task<List<Article>> GetPagedAsync(int page, int pageSize, CancellationToken cancellationToken = default)
{
return await _context.Articles
.Include(a => a.Category)
.Include(a => a.Author)
.Include(a => a.Tags) // <-- nou
.OrderByDescending(a => a.PublishedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
}
Atenție: dacă faceți
.Include(a => a.Category).Include(a => a.Tags)pe o listă mare, EF generează un singur JOIN cu cartesian explosion — pentruNarticole ×Mtag-uri iesN×Mrânduri. La volum mic e ok; la volum mare folosițiAsSplitQuery()(un round-trip perInclude).
Pasul 8 — Afișare pe Details.cshtml
Tag-urile au navigare; ViewModel-ul trebuie să le expună. În ViewModels/ArticleViewModel.cs:
public class ArticleViewModel
{
// ... proprietati existente ...
public List<string> Tags { get; set; } = new();
}
În controller (ArticlesController.Details), la mapare:
Tags = article.Tags?.Select(t => t.Name).ToList() ?? new()
În Views/Articles/Details.cshtml, adăugați un bloc care afișează tag-urile ca badge-uri:
@if (Model.Tags?.Any() == true)
{
<div class="mt-3">
<strong>Etichete:</strong>
@foreach (var tag in Model.Tags)
{
<span class="badge bg-secondary">@tag</span>
}
</div>
}
Pasul 9a — Repository + Service pentru Tag (consistență cu arhitectura Lab 6+)
Pentru multi-select avem nevoie de acces la lista de tag-uri din controller. Pentru a păstra arhitectura layered (controller → service → UoW → repository) introdusă în Lab 6, adăugăm un mini-stack pentru Tag (parallel cu Category).
Repositories/ITagRepository.cs
namespace Lab10.Repositories;
using Lab10.Models;
public interface ITagRepository : IRepository<Tag>
{
}
Repositories/TagRepository.cs
using Lab10.Data;
using Lab10.Models;
namespace Lab10.Repositories;
public class TagRepository : Repository<Tag>, ITagRepository
{
public TagRepository(AppDbContext context) : base(context) { }
}
Update IUnitOfWork:
public interface IUnitOfWork
{
IArticleRepository ArticleRepository { get; }
ICategoryRepository CategoryRepository { get; }
ITagRepository TagRepository { get; } // <-- nou
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}
Update UnitOfWork:
private ITagRepository? _tagRepository;
// ...
public ITagRepository TagRepository
=> _tagRepository ??= new TagRepository(_context);
Services/ITagService.cs
namespace Lab10.Services;
using Lab10.Models;
public interface ITagService
{
Task<List<Tag>> GetAllAsync(CancellationToken cancellationToken = default);
}
Services/TagService.cs
using Lab10.Models;
using Lab10.Repositories;
namespace Lab10.Services;
public class TagService : ITagService
{
private readonly IUnitOfWork _unitOfWork;
public TagService(IUnitOfWork unitOfWork) => _unitOfWork = unitOfWork;
public async Task<List<Tag>> GetAllAsync(CancellationToken cancellationToken = default)
=> (await _unitOfWork.TagRepository.GetAllAsync(cancellationToken))
.OrderBy(t => t.Name).ToList();
}
Înregistrare în Program.cs:
builder.Services.AddScoped<ITagService, TagService>();
Pasul 9 — Multi-select pe Create / Edit
Pentru a permite atașarea de tag-uri la creare/editare, ViewModel-ul de scriere primește o listă de ID-uri selectate:
public class CreateArticleViewModel
{
// ... proprietati existente ...
public List<int> SelectedTagIds { get; set; } = new();
public List<Tag> AvailableTags { get; set; } = new();
}
În ArticlesController, injectați ITagService (pe lângă IArticleService/ICategoryService deja existente) și populați AvailableTags în LoadDropdownsAsync (sau direct în acțiunile Create [GET] / Edit [GET]):
private readonly ITagService _tagService;
public ArticlesController(
IArticleService articleService,
ICategoryService categoryService,
ITagService tagService,
IWebHostEnvironment env)
{
_articleService = articleService;
_categoryService = categoryService;
_tagService = tagService;
_env = env;
}
private async Task LoadDropdownsAsync(CreateArticleViewModel viewModel, CancellationToken cancellationToken)
{
var categories = await _categoryService.GetAllAsync(cancellationToken);
viewModel.Categories = categories
.Select(c => new SelectListItem { Value = c.Id.ToString(), Text = c.Name })
.ToList();
viewModel.AvailableTags = await _tagService.GetAllAsync(cancellationToken);
}
În Views/Articles/Create.cshtml, multi-select clasic:
<div class="mb-3">
<label class="form-label">Tag-uri</label>
<select asp-for="SelectedTagIds" class="form-select" multiple
asp-items="@(new SelectList(Model.AvailableTags, "Id", "Name"))">
</select>
</div>
În Create [POST], după ce salvați articolul, atașați tag-urile selectate. Folosim repository-ul deja existent pentru a încărca entitățile Tag corespunzătoare ID-urilor primite din formular - alternativă la a expune încă o metodă în service, putem accesa IUnitOfWork printr-un mic refactor sau adăuga GetByIdsAsync(IEnumerable<int>) în ITagService. Pentru simplitate alegem a doua variantă.
Adăugăm în ITagService:
Task<List<Tag>> GetByIdsAsync(IEnumerable<int> ids, CancellationToken cancellationToken = default);
Implementare în TagService:
public async Task<List<Tag>> GetByIdsAsync(IEnumerable<int> ids, CancellationToken cancellationToken = default)
{
var idSet = ids.ToHashSet();
var all = await _unitOfWork.TagRepository.GetAllAsync(cancellationToken);
return all.Where(t => idSet.Contains(t.Id)).ToList();
}
Apoi în controller, după _articleService.AddAsync(article, cancellationToken):
if (viewModel.SelectedTagIds.Any())
{
var selected = await _tagService.GetByIdsAsync(viewModel.SelectedTagIds, cancellationToken);
article.Tags = selected;
await _articleService.UpdateAsync(article, cancellationToken);
}
Edit — același pattern, plus 3 detalii
EditArticleViewModel din starter moștenește CreateArticleViewModel, deci SelectedTagIds și AvailableTags sunt deja disponibile pe Edit. Nu mai e nevoie să le redeclarați. Dar trebuie să interveniți în trei locuri:
1. Edit [GET] în ArticlesController — pre-completați SelectedTagIds cu ID-urile tag-urilor pe care articolul le are deja, ca multi-select-ul să apară cu ele bifate când se deschide pagina:
var viewModel = new EditArticleViewModel
{
Id = article.Id,
Title = article.Title,
// ... celelalte campuri ...
SelectedTagIds = article.Tags?.Select(t => t.Id).ToList() ?? new()
};
await LoadDropdownsAsync(viewModel, cancellationToken);
return View(viewModel);
2. Edit [POST] — relațiile vechi nu se ștergeu automat când re-asignați. Folosiți Clear() + Add() pe colecția trackată, ca EF să șteargă doar ce dispare și să insereze doar ce e nou:
article.Tags ??= new();
article.Tags.Clear();
if (viewModel.SelectedTagIds.Any())
{
var selectedTags = await _tagService.GetByIdsAsync(viewModel.SelectedTagIds, cancellationToken);
foreach (var tag in selectedTags)
article.Tags.Add(tag);
}
await _articleService.UpdateAsync(article, cancellationToken);
3. Views/Articles/Edit.cshtml — exact aceleași 5 linii de multi-select ca în Create.cshtml (același asp-for="SelectedTagIds", același asp-items din Model.AvailableTags):
<div class="mb-3">
<label class="form-label">Tag-uri</label>
<select asp-for="SelectedTagIds" class="form-select" multiple
asp-items="@(new SelectList(Model.AvailableTags, "Id", "Name"))">
</select>
</div>
Tag Helper-ul Razor face automat marcarea opțiunilor din SelectedTagIds ca selected.
De ce mini-stack-ul
Tag? Pentru a respecta separarea de layere stabilită în Lab 6 - controller-ul nu cunoaște directAppDbContext. Categoriile au dejaICategoryRepository/ICategoryService; tag-urile primesc același tratament. La proiecte mici unde tag-urile sunt strict lookup, o variantă mai pragmatică ar fi să injectațiAppDbContextdirect doar pentru această lookup - dar mixează pattern-uri în același controller, motiv pentru care preferăm aici varianta consistentă.
Exercițiul 1 — Many-to-Many end-to-end (1p)
Implementați tot fluxul Pașii 1–9:
- [ ]
TagșiArticleTagcompletate corect (Pașii 1–2) - [ ]
Articleare navigareaList<Tag> Tags(Pasul 3) - [ ]
AppDbContextconfigurează M2M cuUsingEntity<ArticleTag>(Pasul 4) - [ ] Migrația
AddTagsAndManyToManyrulează clean — vedețiTagsșiArticleTagsîn BD (Pasul 5) - [ ] Seed cu 5 tag-uri și cel puțin un articol primește tag-uri (Pasul 6)
- [ ] Repository-ul include
Tagsîn toate cele 4 metode care fac JOIN cu detaliile (Pasul 7) - [ ]
Details.cshtmlafișează badge-uri (Pașii 7–8) - [ ]
Createare multi-select funcțional — atașarea persistă la SaveChanges (Pasul 9) - [ ]
Editare multi-select cu tag-urile existente pre-bifate; salvarea modifică relațiile (Pasul 9, sub-secțiunea Edit)
Validare: porniți aplicația, navigați la /Articles/Details/{id} pentru un articol cu tag-uri seed-uite — vedeți badge-urile. Creați un articol nou cu 2 tag-uri selectate — refresh la Details, badge-urile apar. Editați același articol, deselectați un tag și selectați altul, salvați — Details reflectă noua listă.