Webbserverprogrammering 1 · område 6 · guide
Filmarkivet – datadriven applikation: CRUD, relationer och inloggning
Tjugosex steg som gör Filmarkivet till en applikation som går att förvalta utan att öppna en kodfil. Filmerna går att ändra och ta bort, varje film hör till en genre, sökningen görs av databasen, och allt som ändrar något ligger bakom en inloggning. Guiden är uppdelad efter lektionerna, så att du kan slå upp den del du behöver.
Guiden går att följa på egen hand. Så här hänger stegen ihop med passen, och ungefär hur lång tid de tar om du gör dem själv:
- Lektion 36 (tis 19 jan) – steg 1 till steg 4, ungefär 90 minuter. Ändra och ta bort, och listvyn med sortering och sidor. Steg 3 har två fällor som ser ut att fungera fast de inte gör det – läs det stycket två gånger.
- Lektion 37 (ons 20 jan) – steg 5 till steg 9, ungefär 75 minuter. Genren och relationen. I steg 6 kan migrationen avbrytas på din egen databas – det är väntat, och steget säger vad du gör.
- Lektion 38 (tis 26 jan) – steg 10 till steg 13, ungefär 60 minuter. Slå på SQL-loggningen först – resten av stegen går inte att förstå utan den.
- Lektion 39 (ons 27 jan) – steg 14 till steg 16, ungefär 45 minuter. Tre sorters validering. Skriv ditt eget attribut för en regel i din egen domän.
- Lektion 40 (tis 2 feb) – steg 17 till steg 23, ungefär 90 minuter. Inloggningen. Hoppa inte över steg 21: där ser du att en dold länk inte skyddar något.
- Lektion 41 (ons 3 feb) – steg 24, ungefär 20 minuter, och granskningen i uppgiftens etapp 4. Be någon granska din kod, även om du missade passet.
- Lektion 43 (ons 10 feb) – steg 25 och steg 26, ungefär 30 minuter. Prova i ett testprojekt, inte i ditt eget.
- Arbetspassen 42 och 44–47 – uppgiften, etapp för etapp. Varje etapp har en kontroll du gör själv.
Stegen bygger på varandra, men följer inte en rak linje genom ditt projekt: ändra- och ta bort-sidorna i steg 3 och 4 byggs utan genre, och får sin rullista i steg 9.
Det här området har fler fel än något tidigare som inte ger något felmeddelande alls: en ändring som inte sparas, en genre som inte visas, en sida som svarar fast länken är borta, en utloggning som går igenom utan token. Sidan ser rätt ut, terminalen är tyst.
Därför har varje steg en Kontrollera-rad. Gör den, också när allt ser ut att fungera. Tabellen När något går fel har de tysta felen med.
Kodblocken på den här sidan går inte att markera, och det är med flit. Att skriva av koden för hand är där en stor del av inlärningen sker: du läser raden, håller den i huvudet och upptäcker vid tangentbordet vad du inte förstod. Klistrar du in den hoppar du över precis det steget. Terminalkommandon och SQL är undantag – de är figurer, inte kod att öva på.
Guide · Område 6 · Webbserverprogrammering 1
Vad du bygger här
Filmarkivet blir en applikation som går att förvalta utan att öppna en kodfil.
Efter område 5 kan man lägga till en film. När den här guiden är slut kan man också ändra och ta bort filmer, varje film hör till en genre, listan går att sortera och bläddra i, sökningen görs av databasen, och allt som ändrar något ligger bakom en inloggning.
Filmarkivet
├── /admin/filmer Alla filmer, sorterbara, fyra per sida 🔒
├── /filmer/ny Formuläret från område 5, nu med genre 🔒
├── /filmer/andra/{id} Samma formulär mot en befintlig film 🔒
├── /filmer/tabort/{id} Bekräftelse, och en POST som tar bort 🔒
├── /genrer/{id} En genre och alla dess filmer
├── /linq Samma sökning på två sätt
├── /konto/logga-in Inloggningen
└── /renderingslagen/… Två tabeller som bara skiljer sig på en rad
🔒 betyder att sidan kräver inloggning. Det färdiga projektet ligger i referensprojekt/Filmarkivet.
Det här området sätter ihop allt du byggt under hösten. Komponenterna från område 3 visar. Tjänsterna från område 4 gör arbetet. Databasen från område 5 minns. Ingen av de delarna byts ut. Det som tillkommer lägger sig i lagret där det hör hemma, och i nästan varje steg kan du peka ut vilket lager det är.
Guiden är lång, eftersom området är sju genomgångar. Den är uppdelad efter lektionerna, så att du kan slå upp den del du behöver:
| Lektion | Vad | Steg |
|---|---|---|
| 36 | CRUD i praktiken | 1–4 |
| 37 | Relationer i code-first | 5–9 |
| 38 | LINQ: filtrera, sortera och söka | 10–13 |
| 39 | Egen validering | 14–16 |
| 40 | Inloggning och behörighet | 17–23 |
| 41 | Kodningsstandard och dokumentation | 24 |
| 43 | Renderingslägen | 25–26 |
Vad du ska göra själv efteråt
Samma sak i ditt projekt. Det är Projekt 1, och kraven per betygsnivå står i uppgiften. Det här är vad guiden går igenom:
- Alla fyra CRUD-operationerna, hela vägen genom tjänstelagret
- En listvy med sortering och sidindelning i adressen
- Din kategorientitet i en en-till-många-relation, med egen migration
- En sida som visar data från båda tabellerna, hämtad med
Include - En sökning som görs i databasen, och ett motiverat beslut om var sorteringen görs
- Ett eget valideringsattribut, en regel över två fält, och en regel som kräver databasen
- Inloggning med Identity, och sidorna som ändrar data skyddade, inte bara dolda
Översättningstabellen
| Filmarkivet | Ditt projekt |
|---|---|
Film | din huvudentitet |
Genre | din kategorientitet |
GenreId och Genre på Film | främmande nyckel och navigationsegenskap på din huvudentitet |
List<Film> Filmer på Genre | listan åt andra hållet, på din kategori |
DbSet<Genre> Genrer | ditt andra DbSet, i plural |
GenreOchRelation | namnet på din andra migration |
UppdateraAsync, TaBortAsync | dina metoder som ändrar och tar bort |
HamtaMedGenreAsync | din metod som hämtar med Include |
IGenreService / GenreService | tjänsten för din kategori |
/admin/filmer | din listvy |
/filmer/andra/{id}, /filmer/tabort/{id} | dina sidor för att ändra och ta bort |
/genrer/{id} | sidan för en kategori |
?sortera=titel, ar, betyg, genre | dina sorteringsfält – minst tre |
SokAsync på Titel | din sökning, på ditt sökbara fält |
RimligtUtgivningsarAttribute | ditt eget attribut, för en regel som hör till din domän |
Validate i Film | din regel över två fält |
TitelFinnsAsync | din kontroll mot databasen |
admin@filmarkivet.se | din första användare |
/konto/logga-in | samma adress – den behöver inte bytas |
Fyll i högerkolumnen innan du börjar koda. Hittar du ingen regel över två fält eller ingen regel som kräver databasen i din domän är det värt att fundera en stund. De finns nästan alltid, och det är du som känner domänen.
Innan du börjar
Ditt projekt från område 5: en entitet med valideringsattribut, en DbContext, en migration, en tjänst som läser databasen bakom ett gränssnitt, och ett EditForm som skapar nya poster.
Kontrollen tar tio sekunder:
dotnet run
Lägg till en post via formuläret, stoppa servern, starta den igen. Finns posten kvar är du redo. Gör den inte det är det område 5 som ska bli klart först.
Du har minst en egen rad i databasen, eftersom område 5:s uppgift kräver det. Det spelar roll i steg 6, där en migration kan gå sönder just på grund av den raden.
Begreppen i det här momentet
| Begrepp | Vad det är |
|---|---|
| CRUD | Create, Read, Update, Delete – de fyra sakerna man gör med data |
| Främmande nyckel | En kolumn som pekar på en rad i en annan tabell. GenreId pekar på en genre |
| Navigationsegenskap | En egenskap som håller det relaterade objektet, Genre på Film. Ligger inte i någon kolumn |
| En-till-många | Många filmer hör till en genre. En genre har många filmer |
Include | Säger åt EF Core att hämta relaterad data i samma fråga |
| Cascade delete | När en genre tas bort följer dess filmer med |
| LINQ | Metoderna Where, OrderBy, Select med flera – samma i C# och mot databasen |
IQueryable<T> | En fråga som inte körts än. Den går till databasen först vid ToListAsync |
| Frågesträng | Delen av adressen efter ?, till exempel ?sortera=titel&sida=2 |
ValidationAttribute | Basklassen för [Required], [Range] – och för dina egna attribut |
IValidatableObject | Ett gränssnitt för regler som gäller hela objektet |
| Identity | ASP.NET Cores system för användare, lösenord och inloggning |
| Hash | En envägsomvandling av lösenordet. Det som lagras, i stället för lösenordet |
| Autentisering | Att ta reda på vem du är. Inloggningen |
| Auktorisering | Att avgöra vad du får göra. [Authorize] |
| Endpoint | En adress som svarar på förfrågningar utan att vara en sida |
| Renderingsläge | Om en komponent renderas en gång på servern, eller hålls levande över en uppkoppling |
| Förrendering | Att en interaktiv komponent först renderas statiskt, så att något syns direkt |
---
Steg 1 – Två operationer saknas
Vad vi gör. Ger tjänsten de två operationer den inte har.
Räkna vad IFilmService kan i dag. Den kan läsa – HamtaAllaAsync och HamtaAsync. Sedan område 5 kan den skapa – SkapaAsync. Den kan inte ändra och inte ta bort. Två av fyra bokstäver i CRUD saknas.
Filen. Services/IFilmService.cs, sist i gränssnittet:
// Nya i område 6. Med dem är alla fyra CRUD-operationerna på plats:
// HamtaAllaAsync/HamtaAsync läser, SkapaAsync skapar, UppdateraAsync
// ändrar, TaBortAsync tar bort.
Task UppdateraAsync(Film film);
Task TaBortAsync(int id);
Filen. Services/FilmService.cs, två nya metoder:
// Update säger åt contextet att hela objektet ska skrivas tillbaka. Filmen
// kommer från formuläret och är inte spårad av det här contextet - därför
// Update och inte bara SaveChangesAsync.
public async Task UppdateraAsync(Film film)
{
using var db = await _fabrik.CreateDbContextAsync();
db.Filmer.Update(film);
await db.SaveChangesAsync();
_logg.LogInformation("Film ändrad: {Titel} (id {Id})", film.Titel, film.Id);
}
public async Task TaBortAsync(int id)
{
using var db = await _fabrik.CreateDbContextAsync();
Film? film = await db.Filmer.FindAsync(id);
if (film is null)
{
_logg.LogWarning("Försökte ta bort film {Id} som inte finns", id);
return;
}
db.Filmer.Remove(film);
await db.SaveChangesAsync();
_logg.LogInformation("Film borttagen: {Titel} (id {Id})", film.Titel, id);
}
Varför det ser ut så. Båda metoderna följer mönstret från SkapaAsync: ett eget context från fabriken, en ändring, SaveChangesAsync. Det är skillnaden mellan dem som är intressant.
**Update tar emot en hel film.** Objektet kommer från formuläret i steg 3 och har aldrig varit i det här contextet. Contextet vet alltså ingenting om det, och SaveChangesAsync ensamt hade inte sparat någonting. Update säger: det här objektet finns redan i databasen, skriv tillbaka alla fält. EF Core letar upp raden med hjälp av Id. Det är därför id:t måste följa med formuläret, och det kommer att spela roll i steg 3.
**TaBortAsync tar emot bara ett id.** För att ta bort räcker det att veta vilken rad. Metoden hämtar filmen först, av två skäl: den kan redan vara borttagen, och loggraden vill ha titeln. Finns filmen inte loggas det och metoden gör ingenting. Att ta bort något som inte finns är inte ett fel värt att krascha för.
Gränssnittet växer av funktionalitet, inte av teknik. Samma sak som med SkapaAsync i område 5. Det står ingenting om databaser i IFilmService, och det ska det inte göra.
Kontrollera. dotnet build. Noll fel. Ingenting syns ännu – det finns ingen sida som anropar metoderna. Det är nästa steg.
I ditt projekt. Samma två metoder i ditt gränssnitt och din implementation, med dina namn. Parametertyperna följer samma mönster: hela objektet för att ändra, bara id för att ta bort.
Steg 2 – Listvyn, sorterad och i sidor
Vad vi gör. Bygger administrationens startpunkt: en tabell med alla filmer, där varje rad har länkar till Ändra och Ta bort.
Filen. Components/Pages/AdminFilmer.razor:
@page "/admin/filmer"
@inject IFilmService Arkivet
<PageTitle>Alla filmer – Filmarkivet</PageTitle>
<h1>Alla filmer</h1>
<p><a href="filmer/ny">Lägg till en film</a></p>
@* Kolumnrubrikerna är länkar, inte knappar. Sorteringen ligger i adressen,
och därför fungerar den utan interaktivt renderingsläge - sidan hämtas om
med ett nytt värde i frågesträngen. Samma sak som sökningen i område 3. *@
<div class="tabellsvep">
<table class="tabell">
<thead>
<tr>
<th scope="col"><a href="admin/filmer?sortera=titel">Titel</a></th>
<th scope="col"><a href="admin/filmer?sortera=ar">År</a></th>
<th scope="col"><a href="admin/filmer?sortera=betyg">Betyg</a></th>
<th scope="col">Ändra</th>
</tr>
</thead>
<tbody>
@foreach (var film in sidan)
{
<tr>
<td><a href="filmer/@film.Id">@film.Titel</a></td>
<td>@film.Ar</td>
<td>@film.Betyg</td>
<td>
<a href="filmer/andra/@film.Id">Ändra</a> ·
<a href="filmer/tabort/@film.Id">Ta bort</a>
</td>
</tr>
}
</tbody>
</table>
</div>
<p class="tomt">
Sida @sida av @antalSidor · @antal filmer totalt
@if (Sortera is not null)
{
<text> · sorterat på @Sortera</text>
}
</p>
<p>
@if (sida > 1)
{
<a href="admin/filmer?sortera=@Sortera&sida=@(sida - 1)">← Föregående</a>
}
@if (sida < antalSidor)
{
<a href="admin/filmer?sortera=@Sortera&sida=@(sida + 1)">Nästa →</a>
}
</p>
@code {
[SupplyParameterFromQuery(Name = "sortera")]
public string? Sortera { get; set; }
// En sträng och inte int?. Ett int? som får ?sida=abc kastar ett
// undantag innan komponenten ens körs, och besökaren får en 500.
[SupplyParameterFromQuery(Name = "sida")]
public string? SidaFraga { get; set; }
private const int PerSida = 4;
private List<Film> sidan = new();
private int antal;
private int antalSidor = 1;
// Sidan som visas. Den kommer ur adressen, och adressen kan vem som helst
// skriva: ?sida=99 och ?sida=-3 ska ge en sida som finns, inte en tom tabell.
private int sida = 1;
// OnParametersSetAsync och inte OnInitializedAsync: värdena kommer ur
// adressen, och adressen ändras när man klickar på en kolumnrubrik.
protected override async Task OnParametersSetAsync()
{
List<Film> alla = await Arkivet.HamtaAllaAsync();
IEnumerable<Film> ordnade = Sortera switch
{
"titel" => alla.OrderBy(f => f.Titel),
"ar" => alla.OrderBy(f => f.Ar),
"betyg" => alla.OrderByDescending(f => f.Betyg),
_ => alla.OrderBy(f => f.Id)
};
antal = alla.Count;
antalSidor = Math.Max(1, (int)Math.Ceiling(antal / (double)PerSida));
// TryParse från område 2. Misslyckas den blir talet 0, och Clamp
// gör 0 till 1.
int.TryParse(SidaFraga, out int onskad);
sida = Math.Clamp(onskad, 1, antalSidor);
sidan = ordnade.Skip((sida - 1) * PerSida).Take(PerSida).ToList();
}
}
Filen. Components/Layout/NavMenu.razor, en länk till efter Ny film:
<div class="nav-item px-3">
<NavLink class="nav-link" href="admin/filmer">Administrera</NavLink>
</div>
Varför det ser ut så. Det är mycket på en gång, så ta det i tre delar.
En egen sida för administrationen. /filmer är besökarens lista, med kort och stjärnor. /admin/filmer är redaktörens: en tät tabell där varje rad går att ändra. Två sidor mot samma tjänst, med olika syften. I steg 22 får bara den ena kräva inloggning. Det hade inte gått om de varit samma sida.
Sorteringen ligger i adressen. Kolumnrubrikerna är vanliga länkar till ?sortera=titel, och [SupplyParameterFromQuery] läser värdet – samma sak som ?sok= i område 3. Den närliggande tanken är en knapp med @onclick, men under statisk serverrendering finns det ingen som tar emot klicket. Sidan har renderats färdigt och skickats. En länk fungerar alltid, för den gör en ny förfrågan. Varför knappen inte fungerar är en hel lektion, och den kommer i steg 25.
Att sorteringen står i adressen har en bieffekt som är värd något: den går att bokmärka och skicka till någon annan.
**switch-uttrycket väljer ordning.** Det är samma switch som i område 2, i den korta formen som ger ett värde. _ fångar allt annat, också null och ?sortera=hittepa. Ett okänt värde ger standardordningen, inte ett fel.
**Sidindelningen är två metoder: Skip och Take.** Sida 2 med fyra per sida betyder: hoppa över fyra, ta fyra. (sida - 1) * PerSida är hela uträkningen.
Sidnumret går inte att lita på. Adressen skriver besökaren själv, och ?sida=99, ?sida=-3 och ?sida=abc är lika lätta att skriva som ?sida=2. Därför läses numret som en sträng och tolkas med int.TryParse, och Math.Clamp håller det mellan 1 och sista sidan. Hade parametern varit int? hade ?sida=abc gett en 500 med Cannot parse the value 'abc' – innan en enda rad av din kod körts.
Allt som kommer ur adressen är något besökaren har skrivit. Behandla det så.
**OnParametersSetAsync, inte OnInitializedAsync.** Samma val som på detaljsidan i område 3. Klickar man på en kolumnrubrik är det samma sida med nya parametrar, och det är OnParametersSetAsync som körs då.
Sorteringen görs i C#, inte i databasen. Tjänsten hämtar alla filmer och sidan ordnar dem. Det är ett medvetet val, och det är lektion 38 som reder ut varför. Här räcker det att veta att det finns ett skäl.
Kontrollera. Gå till /admin/filmer. Sex filmer, fyra på första sidan. Klicka på Titel – adressen blir ?sortera=titel och ordningen ändras. Klicka på Nästa. Skriv sedan ?sida=99 i adressfältet för hand. Du ska hamna på sista sidan, inte på en tom tabell.
I ditt projekt. Din listvy med minst tre sorterbara kolumner. Välj en som är text, en som är ett tal och en till. PerSida får du bestämma, men välj ett tal som ger minst två sidor med din frödata – annars syns inte sidindelningen.
Steg 3 – Ändra: samma formulär, en befintlig post
Vad vi gör. Bygger sidan som ändrar en film.
Filen. Components/Pages/AndraFilm.razor:
@page "/filmer/andra/{id:int}"
@inject IFilmService Arkivet
@inject NavigationManager Navigering
<PageTitle>@(Film?.Titel ?? "Ändra film") – Filmarkivet</PageTitle>
@if (Film is null)
{
<p class="tomt">Filmen finns inte.</p>
}
else
{
<p class="brodsmula"><a href="admin/filmer">← Alla filmer</a></p>
<h1>Ändra @Film.Titel</h1>
@* Samma EditForm som NyFilm.razor. Skillnaden är att objektet hämtas ur
databasen först, och att det har ett Id som följer med i ett dolt fält. *@
<EditForm Model="Film" OnValidSubmit="Spara" FormName="andrafilm">
<DataAnnotationsValidator />
<ValidationSummary />
<input type="hidden" name="Film.Id" value="@Film.Id" />
<p>
<label>Titel<br />
<InputText @bind-Value="Film.Titel" /></label>
<ValidationMessage For="() => Film.Titel" />
</p>
<p>
<label>År<br />
<InputNumber @bind-Value="Film.Ar" /></label>
<ValidationMessage For="() => Film.Ar" />
</p>
<p>
<label>Längd i minuter<br />
<InputNumber @bind-Value="Film.LangdMinuter" /></label>
<ValidationMessage For="() => Film.LangdMinuter" />
</p>
<p>
<label>Betyg 1–5<br />
<InputNumber @bind-Value="Film.Betyg" /></label>
<ValidationMessage For="() => Film.Betyg" />
</p>
<p>
<label>Beskrivning<br />
<InputTextArea @bind-Value="Film.Beskrivning" rows="4" /></label>
<ValidationMessage For="() => Film.Beskrivning" />
</p>
<p>
<label><InputCheckbox @bind-Value="Film.Tillganglig" /> Tillgänglig</label>
</p>
<button type="submit">Spara ändringen</button>
<a href="admin/filmer">Avbryt</a>
</EditForm>
}
@code {
[Parameter]
public int Id { get; set; }
// Vid en GET är den null och fylls ur databasen. Vid en POST fyller
// ramverket den ur formuläret, och då ska den inte hämtas om - annars
// skrivs ändringarna över med det som redan står i databasen.
[SupplyParameterFromForm]
private Film? Film { get; set; }
protected override async Task OnInitializedAsync()
{
Film ??= await Arkivet.HamtaAsync(Id);
}
private async Task Spara()
{
await Arkivet.UppdateraAsync(Film!);
Navigering.NavigateTo("admin/filmer");
}
}
Varför det ser ut så. Jämför med NyFilm.razor. Formuläret är nästan identiskt – samma EditForm, samma DataAnnotationsValidator, samma fält. Det är poängen: attributen på Film gäller oavsett om filmen är ny eller gammal, och de är skrivna en gång.
Tre saker skiljer, och alla tre är lätta att få fel.
**Sidan anropas två gånger, och ??= håller isär dem.** Första gången är en GET: man klickar på Ändra. Film är null, och ??= hämtar filmen ur databasen så att fälten fylls i. Andra gången är en POST: man trycker Spara. Nu har ramverket redan fyllt Film med det som stod i formuläret, och ??= gör ingenting.
Skriv = i stället för ??= och ingenting ser fel ut. Sidan svarar, du kommer tillbaka till listan. Men ändringen är borta. Filmen hämtades om ur databasen ovanpå det du skrev, och det som sparades var det gamla. Kontrollerat i Filmarkivet: med = står titeln oförändrad efter en lyckad sparning.
Id:t följer med i ett dolt fält. <input type="hidden" name="Film.Id"> står inte på skärmen men skickas med formuläret. Utan det kommer filmen tillbaka med Id = 0, och Update tolkar det som en ny film. Kontrollerat: utan det dolda fältet blir ändringen en sjunde film i stället för en ändrad sjätte. Inget felmeddelande.
Namnet Film.Id är inte godtyckligt. Det är så EditForm namnger sina fält – egenskapens namn, en punkt, fältets namn. Titta i sidkällan så ser du name="Film.Titel" på de andra fälten.
Efter sparningen går sidan vidare. NavigateTo skickar tillbaka till listan, så att man ser resultatet. Under statisk serverrendering blir det en 302 – webbläsaren får ett svar som säger gå dit i stället. Det är också det som gör att en omladdning av sidan inte skickar formuläret en gång till.
Kontrollera. Gå till /admin/filmer, klicka Ändra på en film, byt titeln och spara. Du ska hamna på listan med den nya titeln. Prova sedan /filmer/andra/99 – Filmen finns inte.
I ditt projekt. Kopiera inte formuläret från din Ny-sida rakt av. Gå igenom de tre skillnaderna ovan en i taget, och kontrollera var och en: ??=, det dolda fältet, och vart sidan går efteråt.
Steg 4 – Ta bort: aldrig på en GET
Vad vi gör. Bygger en bekräftelsesida med ett formulär som tar bort.
Filen. Components/Pages/TaBortFilm.razor:
@page "/filmer/tabort/{id:int}"
@inject IFilmService Arkivet
@inject NavigationManager Navigering
<PageTitle>Ta bort – Filmarkivet</PageTitle>
@if (film is null)
{
<p class="tomt">Filmen finns inte, eller är redan borttagen.</p>
<p><a href="admin/filmer">Tillbaka till listan</a></p>
}
else
{
<h1>Ta bort @film.Titel?</h1>
@* Borttagning är oåterkallelig och får aldrig ske på en GET. En länk
räcker för att visa den här sidan; det som tar bort är formuläret,
och det skickar POST med antiforgery-token. *@
<div class="notis varna">
<p>
<b>@film.Titel</b> (@film.Ar) tas bort ur databasen. Det går inte att
ångra.
</p>
</div>
<EditForm Model="film" OnValidSubmit="TaBort" FormName="tabortfilm">
<button type="submit">Ja, ta bort</button>
<a href="admin/filmer">Avbryt</a>
</EditForm>
}
@code {
[Parameter]
public int Id { get; set; }
private Film? film;
protected override async Task OnInitializedAsync()
{
film = await Arkivet.HamtaAsync(Id);
}
private async Task TaBort()
{
await Arkivet.TaBortAsync(Id);
Navigering.NavigateTo("admin/filmer");
}
}
Varför det ser ut så. Det enklaste hade varit att låta länken Ta bort i listan göra jobbet direkt. Gör inte det. Det finns två skäl, och båda handlar om vad en GET är.
En GET ska inte ändra något. Det är en regel i HTTP, och andra program litar på den. Webbläsare hämtar länkar i förväg för att sidor ska gå snabbare. Sökmotorer följer varenda länk de hittar. En länk som tar bort en film kan alltså tömma databasen utan att någon klickat på den.
En GET saknar antiforgery-token. Formuläret skickar en POST, och EditForm lägger in token själv – samma skydd som visningslistans formulär i område 4. Kontrollerat: en POST till /filmer/tabort/5 utan token svarar 400, och filmen finns kvar. En länk hade inte haft något sådant skydd. Vem som helst hade kunnat lägga den på en annan sajt.
EditForm här har inga fält och ingen validator – det är bara en knapp. Den är ändå EditForm och inte <form>, för att få token och FormName utan att skriva dem själv.
Kontrollera. Klicka Ta bort på en film. Bekräftelsesidan visas och filmen finns kvar i listan – att visa sidan tar inte bort något. Tryck Ja, ta bort. Nu är den borta. Ladda om /filmer/tabort/ med samma id: Filmen finns inte, eller är redan borttagen.
I ditt projekt. Samma mönster. Det som är värt att tänka på är texten i varningen: vad behöver användaren se för att vara säker på att det är rätt post? Titeln räcker inte alltid – två recept kan heta Pannkakor.
Steg 5 – Genren och den främmande nyckeln
Vad vi gör. Lägger till en ny entitet och kopplar ihop den med Film.
Filen. Models/Genre.cs – ny:
using System.ComponentModel.DataAnnotations;
namespace Filmarkivet.Models;
// Kategorientiteten, ny i område 6. Många filmer hör till en genre – det är en
// en-till-många-relation, och den syns från båda hållen: Film har GenreId och
// Genre, Genre har listan Filmer.
public class Genre
{
public int Id { get; set; }
[Required(ErrorMessage = "Genren måste ha ett namn.")]
[StringLength(50, ErrorMessage = "Namnet får vara högst 50 tecken.")]
public string Namn { get; set; } = "";
[StringLength(200, ErrorMessage = "Beskrivningen får vara högst 200 tecken.")]
public string Beskrivning { get; set; } = "";
// Navigationsegenskapen åt andra hållet. Den ligger inte i någon kolumn –
// EF Core fyller den när man ber om det med Include.
public List<Film> Filmer { get; set; } = new();
}
Filen. Models/Film.cs, två egenskaper till efter Tillganglig:
// Främmande nyckel, ny i område 6. Namnet GenreId gör att EF Core förstår
// att den hör ihop med navigationsegenskapen nedan – ingen konfiguration
// behövs.
[Range(1, int.MaxValue, ErrorMessage = "Välj en genre.")]
public int GenreId { get; set; }
// Navigationsegenskapen. Ligger inte i någon kolumn: den fylls när man
// hämtar filmen med Include, och är null annars.
public Genre? Genre { get; set; }
Varför det ser ut så. En relation i code-first är tre egenskaper, och de har tre olika uppgifter:
| Egenskap | Var | I databasen | Vad den är till för |
|---|---|---|---|
int GenreId | Film | en kolumn i Filmer | Talet som pekar ut genren. Det är det som sparas |
Genre? Genre | Film | ingenting | Så att koden kan skriva film.Genre.Namn |
List<Film> Filmer | Genre | ingenting | Så att koden kan gå åt andra hållet |
Bara den första blir en kolumn. De andra två är till för C#, och de är tomma tills någon ber EF Core fylla dem. Det är steg 7.
Namnet gör jobbet. GenreId bredvid en egenskap Genre av typen Genre – EF Core ser mönstret och förstår att det är en främmande nyckel. Samma sorts konvention som gjorde Id till primärnyckel i område 5. Du skriver ingen konfiguration, bara rätt namn.
**GenreId är int, inte int?. Det betyder att varje film måste** ha en genre. Hade det varit int? hade filmer utan genre varit tillåtna. Det är ett beslut om domänen, inte om tekniken.
**[Range(1, int.MaxValue)] på en främmande nyckel ser konstigt ut.** Den behövs för formuläret. En rullista som inte valts skickar 0, och 0 är ett giltigt int. Utan attributet hade valideringen släppt igenom det, och databasen hade svarat med ett fel. Med attributet får användaren Välj en genre.
**Genre? med frågetecken.** Navigationsegenskapen är null så fort den inte hämtats – och det är för det mesta. Frågetecknet säger det rakt ut, och kompilatorn varnar om du glömmer.
Kontrollera. dotnet build. Noll fel. Databasen vet ännu ingenting om genrer.
I ditt projekt. Din kategorientitet med minst ett namn och en beskrivning, och de två egenskaperna på din huvudentitet. Namnet på den främmande nyckeln ska vara **navigationsegenskapens namn plus Id**: KategoriId och Kategori, KokId och Kok. Bryter du mönstret förstår EF Core inte att de hör ihop.
Steg 6 – Migrationen som kan gå sönder
Vad vi gör. Ger contextet den nya tabellen, frödata och en rad som räddar befintliga databaser. Sedan migrationen.
Filen. Data/FilmarkivetContext.cs, ett DbSet till:
public DbSet<Genre> Genrer => Set<Genre>();
I OnModelCreating, före filmernas HasData:
// Befintliga rader får GenreId 1 när kolumnen läggs till. Utan den
// här raden blir standardvärdet 0, som inte pekar på någon genre, och
// migrationen avbryts med FOREIGN KEY constraint failed på varje
// databas som redan har filmer.
byggare.Entity<Film>()
.Property(f => f.GenreId)
.HasDefaultValue(1);
byggare.Entity<Genre>().HasData(
new Genre { Id = 1, Namn = "Drama", Beskrivning = "Berättelser där konflikten är mellan människor." },
new Genre { Id = 2, Namn = "Äventyr", Beskrivning = "Resan är större än den som gör den." },
new Genre { Id = 3, Namn = "Skräck", Beskrivning = "Det som är obehagligt just för att det är nästan vanligt." }
);
Och varje film i frödatan får sin genre, sist på raden:
new Film { Id = 1, Titel = "Nyckeln till frihet", Ar = 1994, LangdMinuter = 142, Betyg = 5, Beskrivning = "En bankman döms för ett mord han inte begått och bygger under nitton år i Shawshank en vänskap som bär honom.", Tillganglig = true, GenreId = 1 },
Sedan migrationen:
dotnet ef migrations add GenreOchRelation
dotnet ef database update
Varför det ser ut så. HasDefaultValue(1) är raden som är värd hela steget.
Tänk på vad migrationen ska göra med din databas. Tabellen Filmer har rader. Nu ska den få en ny kolumn, GenreId, som inte får vara tom. Vad ska det stå i den på raderna som redan finns? Frödatans sex filmer har fått ett GenreId i koden, och de uppdateras. Men den film du själv lade till via formuläret i område 5 finns inte i koden. Den får standardvärdet.
Utan HasDefaultValue är standardvärdet för en int 0. Det finns ingen genre med id 0, och den främmande nyckeln vägrar:
SQLite Error 19: 'FOREIGN KEY constraint failed'
Migrationen avbryts, och databasen står kvar där den stod. Kontrollerat åt båda hållen: utan raden avbryts uppgraderingen, med den behåller en databas med sju filmer alla sju. Den sjunde hamnar i Drama, och det kan du ändra efteråt på ändringssidan.
Här gör databasen det attributen inte gjorde. Minns steg 8 i område 5: [Range] blev ingenting i schemat, och betyget 99 gick rakt in. En främmande nyckel är annorlunda. Titta på vad migrationen skapade:
dotnet ef migrations script ForstaMigrationen GenreOchRelation
CREATE TABLE "ef_temp_Filmer" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_Filmer" PRIMARY KEY AUTOINCREMENT,
"Ar" INTEGER NOT NULL,
"Beskrivning" TEXT NOT NULL,
"Betyg" INTEGER NOT NULL,
"GenreId" INTEGER NOT NULL DEFAULT 1,
"LangdMinuter" INTEGER NOT NULL,
"Tillganglig" INTEGER NOT NULL,
"Titel" TEXT NOT NULL,
CONSTRAINT "FK_Filmer_Genrer_GenreId" FOREIGN KEY ("GenreId") REFERENCES "Genrer" ("Id") ON DELETE CASCADE
);
CONSTRAINT … FOREIGN KEY är ett villkor som databasen själv håller. Försök spara en film med GenreId = 99 och det är databasen som säger nej, inte C#.
Två saker till i utskriften:
**ef_temp_Filmer.** SQLite kan inte lägga till en främmande nyckel i en befintlig tabell. EF Core bygger därför en ny tabell, kopierar över raderna och byter namn. Du behöver inte göra något åt det, men det förklarar varför migrationen är längre än du väntat dig.
**ON DELETE CASCADE. Det här betyder: tas en genre bort, tas alla dess filmer bort också.** EF Core väljer det själv för en obligatorisk främmande nyckel. Kontrollerat i Filmarkivet: en raderad Drama tog med sig fem av sju filmer, utan fråga.
Det går att välja något annat – DeleteBehavior.Restrict i OnModelCreating får databasen att vägra ta bort en genre som har filmer. Filmarkivet löser det enklare: det finns ingen sida som tar bort genrer. Det är ett beslut, och det är värt att fatta det med vett och vilja i stället för att upptäcka det.
Kontrollera. dotnet ef database update ska sluta med Done. Filmlistan fungerar som förut. Genren syns inte någonstans ännu, och det är inte ett fel – det är nästa steg.
Försök inte skapa en film ännu. Formuläret har inget fält för genre, så det skickar GenreId = 0, och ValidationSummary säger Välj en genre. Det rättas i steg 9.
I ditt projekt. Samma tre delar: DbSet, HasDefaultValue med id:t på en kategori som finns i frödatan, och frödata för minst tre kategorier. Glöm inte att ge varje rad i huvudentitetens frödata en kategori. Kör sedan migrations script och leta upp ON DELETE CASCADE i din egen utskrift.
Steg 7 – `Include`: relationen fylls inte av sig själv
Vad vi gör. Visar genren på filmens detaljsida.
Filen. Services/IFilmService.cs, två metoder till:
// Filmen med sin genre ifylld. Egen metod, för Include kostar en join och
// de flesta anrop behöver den inte.
Task<Film?> HamtaMedGenreAsync(int id);
Task<List<Film>> HamtaAllaMedGenreAsync();
Filen. Services/FilmService.cs:
// Include hämtar genren i samma fråga. Utan den är Genre null, och sidan
// som skriver ut @film.Genre?.Namn visar ingenting - utan felmeddelande.
public async Task<Film?> HamtaMedGenreAsync(int id)
{
using var db = await _fabrik.CreateDbContextAsync();
return await db.Filmer
.Include(f => f.Genre)
.FirstOrDefaultAsync(f => f.Id == id);
}
public async Task<List<Film>> HamtaAllaMedGenreAsync()
{
using var db = await _fabrik.CreateDbContextAsync();
return await db.Filmer
.Include(f => f.Genre)
.ToListAsync();
}
Filen. Components/Pages/Filmdetalj.razor. Metoden byts i OnParametersSetAsync:
film = await Arkivet.HamtaMedGenreAsync(Id);
och faktaraden får genren, som en länk:
<p class="filmkort-fakta">
@film.Ar · @film.LangdMinuter minuter
@if (film.Genre is not null)
{
<text>· <a href="genrer/@film.GenreId">@film.Genre.Namn</a></text>
}
</p>
Varför det ser ut så. Prova först att bara ändra faktaraden och behålla HamtaAsync. Starta och gå till /filmer/1. Ingen genre. Inget fel, ingen varning, ingen rad i terminalen. Faktaraden ser ut som förut.
Det är relationens vanligaste fallgrop, och den är tyst. HamtaAsync gör FindAsync, som hämtar raden ur Filmer – och i den raden står GenreId = 1, inte genren. film.Genre är null, och @if hoppar över den.
Include säger åt EF Core: hämta genren också, i samma fråga. Slå på SQL-loggningen (steg 10) så ser du vad det blir:
SELECT "f"."Id", "f"."Ar", … "g"."Id", "g"."Beskrivning", "g"."Namn"
FROM "Filmer" AS "f"
INNER JOIN "Genrer" AS "g" ON "f"."GenreId" = "g"."Id"
WHERE "f"."Id" = @id
LIMIT 1
En JOIN. Två tabeller i en fråga.
**Varför egna metoder och inte Include överallt?** Därför att en JOIN inte är gratis, och de flesta anrop behöver den inte. Filmlistan visar inte genren. Statistiken bryr sig inte om den. Att relaterad data ska med är något man beställer, och när det är en egen metod i gränssnittet syns beställningen i koden. Det är ett sätt att göra fallgropen synlig.
FirstOrDefaultAsync i stället för FindAsync: FindAsync går inte att kombinera med Include. Den gör en sak – letar på primärnyckeln – och tar inte emot några tillägg.
Kontrollera. /filmer/1 visar Drama i faktaraden. Länken går till /genrer/1, som inte finns ännu. Det är nästa steg.
I ditt projekt. Gör försöket ovan: ändra faktaraden först och se att ingenting syns. Byt sedan metod. Det tar en minut och är värt det, för nästa gång det händer i ditt projekt vet du var du ska leta.
Steg 8 – Genresidan och en egen tjänst
Vad vi gör. Bygger sidan som visar en genre och alla dess filmer – relationen från andra hållet.
Filen. Services/IGenreService.cs – ny:
using Filmarkivet.Models;
namespace Filmarkivet.Services;
// Kategorientiteten får en egen tjänst, av samma skäl som filmerna fick en i
// område 4: komponenten ska inte veta var genrerna kommer ifrån.
public interface IGenreService
{
Task<List<Genre>> HamtaAllaAsync();
Task<Genre?> HamtaAsync(int id);
// Med filmerna ifyllda. Egen metod, för de flesta anrop behöver dem inte
// och Include kostar en join.
Task<Genre?> HamtaMedFilmerAsync(int id);
}
Filen. Services/GenreService.cs – ny:
using Filmarkivet.Data;
using Filmarkivet.Models;
using Microsoft.EntityFrameworkCore;
namespace Filmarkivet.Services;
public class GenreService : IGenreService
{
private readonly IDbContextFactory<FilmarkivetContext> _fabrik;
public GenreService(IDbContextFactory<FilmarkivetContext> fabrik)
{
_fabrik = fabrik;
}
public async Task<List<Genre>> HamtaAllaAsync()
{
using var db = await _fabrik.CreateDbContextAsync();
return await db.Genrer.OrderBy(g => g.Namn).ToListAsync();
}
public async Task<Genre?> HamtaAsync(int id)
{
using var db = await _fabrik.CreateDbContextAsync();
return await db.Genrer.FindAsync(id);
}
// Include säger åt EF Core att hämta de relaterade filmerna i samma fråga.
// Utan den är Filmer en tom lista – navigationsegenskaper fylls inte av sig
// själva.
public async Task<Genre?> HamtaMedFilmerAsync(int id)
{
using var db = await _fabrik.CreateDbContextAsync();
return await db.Genrer
.Include(g => g.Filmer)
.FirstOrDefaultAsync(g => g.Id == id);
}
}
Filen. Program.cs, bredvid de andra registreringarna:
builder.Services.AddScoped<IGenreService, GenreService>();
Filen. Components/Pages/Genresidan.razor – ny:
@page "/genrer/{id:int}"
@inject IGenreService Genrer
@inject NavigationManager Navigering
<PageTitle>@(genre?.Namn ?? "Genren finns inte") – Filmarkivet</PageTitle>
@if (genre is not null)
{
<p class="brodsmula"><a href="filmer">← Alla filmer</a></p>
<h1>@genre.Namn</h1>
<p class="beskrivning">@genre.Beskrivning</p>
@* genre.Filmer är fylld eftersom tjänsten hämtade den med Include. Hade
vi använt HamtaAsync i stället hade listan varit tom - utan fel, utan
varning. Det är relationens vanligaste fallgrop. *@
<h2>@genre.Filmer.Count filmer i den här genren</h2>
<ul class="filmlista">
@foreach (var film in genre.Filmer.OrderBy(f => f.Titel))
{
<li><a href="filmer/@film.Id">@film.Titel</a> (@film.Ar)</li>
}
</ul>
}
@code {
[Parameter]
public int Id { get; set; }
private Genre? genre;
protected override async Task OnParametersSetAsync()
{
genre = await Genrer.HamtaMedFilmerAsync(Id);
// Samma sak som på detaljsidan i område 3: en genre som inte finns
// ska svara 404, inte en sida som säger att den inte finns.
if (genre is null)
{
Navigering.NotFound();
}
}
}
Varför det ser ut så. En tjänst per entitet är samma resonemang som i område 4. Genresidan ska inte veta att genrerna ligger i SQLite, och den ska inte ha ett DbContext. Det hade gått att lägga genremetoderna i IFilmService – men då växer den till en klass som gör allt, och det var precis det område 4 ville undvika.
Tjänsten behöver ingen logg och är kortare än FilmService. Den behöver inte vara lika stor för att vara värd att skilja ut.
**Include(g => g.Filmer) går åt andra hållet.** I steg 7 hämtade en film sin genre. Här hämtar en genre sina filmer. SQL:en visar skillnaden:
SELECT "g0"."Id", "g0"."Beskrivning", "g0"."Namn", "f"."Id", "f"."Ar", …
FROM (
SELECT "g"."Id", "g"."Beskrivning", "g"."Namn"
FROM "Genrer" AS "g"
WHERE "g"."Id" = @id
LIMIT 1
) AS "g0"
LEFT JOIN "Filmer" AS "f" ON "g0"."Id" = "f"."GenreId"
LEFT JOIN och inte INNER JOIN. En genre utan filmer ska ändå hittas – den får bara en tom lista. En film utan genre finns däremot inte, för GenreId är obligatorisk, så i steg 7 räckte INNER JOIN. EF Core väljer åt dig, utifrån hur relationen är beskriven.
**genre.Filmer.OrderBy(…) i markupen.** Sorteringen görs i C# på en lista som redan är hämtad, och den är liten. Det är samma avvägning som i listvyn, och lektion 38 säger mer om den.
Kontrollera. Gå till /filmer/1 och klicka på Drama. Genresidan visar fyra filmer – fem, om du har en egen som hamnade där i steg 6. Klicka dig vidare till en film och tillbaka. /genrer/99 ska ge sidan för 404, precis som /filmer/99.
I ditt projekt. Din kategoritjänst och din kategorisida. Det här är lektion 37:s aktivitet: data från båda tabellerna i samma vy.
Steg 9 – Genren i formulären och i listvyn
Vad vi gör. Låter användaren välja genre när en film skapas och ändras, och visar genren i administrationens tabell.
Filen. Components/Pages/NyFilm.razor. En injektion till överst:
@inject IGenreService Genrer
ett fält bland de andra i formuläret:
<p>
<label>Genre<br />
<InputSelect @bind-Value="Film!.GenreId">
<option value="0">— välj genre —</option>
@foreach (var genre in genrer)
{
<option value="@genre.Id">@genre.Namn</option>
}
</InputSelect></label>
<ValidationMessage For="() => Film!.GenreId" />
</p>
och genrerna hämtas när sidan initieras:
private List<Genre> genrer = new();
protected override async Task OnInitializedAsync()
{
genrer = await Genrer.HamtaAllaAsync();
Film ??= new Film();
}
AndraFilm.razor får samma tre tillägg. Där heter fältet Film.GenreId utan utropstecken, precis som de andra fälten på den sidan.
Filen. Components/Pages/AdminFilmer.razor. En kolumn till i tabellhuvudet:
<th scope="col"><a href="admin/filmer?sortera=genre">Genre</a></th>
en cell till i raden:
<td>@(film.Genre?.Namn ?? "—")</td>
och i OnParametersSetAsync byts metoden, och sorteringen får ett fall till:
List<Film> alla = await Arkivet.HamtaAllaMedGenreAsync();
IEnumerable<Film> ordnade = Sortera switch
{
"titel" => alla.OrderBy(f => f.Titel),
"ar" => alla.OrderBy(f => f.Ar),
"betyg" => alla.OrderByDescending(f => f.Betyg),
"genre" => alla.OrderBy(f => f.Genre!.Namn).ThenBy(f => f.Titel),
_ => alla.OrderBy(f => f.Id)
};
Varför det ser ut så. Rullistan skickar ett tal, inte ett namn. Det som står på skärmen är Äventyr, men det som följer med formuläret är 2. Det är precis det som ska sparas – GenreId är en siffra – och det gör att en genre kan byta namn utan att en enda film behöver ändras.
**Första alternativet har värdet 0.** Det är dit [Range(1, int.MaxValue)] från steg 5 pekar. Väljer användaren ingenting skickas 0, attributet säger nej, och Välj en genre. står under rullistan. Kontrollerat.
Försöker någon lura formuläret med GenreId = 99, en genre som inte finns, går den förbi attributet – 99 är större än 1. Då är det databasens främmande nyckel från steg 6 som stoppar posten, och ingen film sparas. Kontrollerat. Men besökaren får en 500 i stället för ett felmeddelande, och det är inte snyggt. Att fånga det ordentligt är område 7:s sak. Här räcker det att veta att databasen håller.
**Listvyn behöver Include**, annars är genrekolumnen tom. Det är fallgropen från steg 7 igen, och den här gången syns den som en kolumn full av streck.
**Genre!.Namn i sorteringen.** Utropstecknet säger åt kompilatorn att genren inte är null. Det stämmer, för HamtaAllaMedGenreAsync har Include, och GenreId är obligatorisk. ThenBy sorterar filmerna inom samma genre på titel, så att ordningen blir densamma varje gång.
Kontrollera. Skapa en film och välj Skräck. Den ska dyka upp på /genrer/3. Ändra en films genre och se den flytta. I /admin/filmer, klicka på Genre – filmerna ska ligga i genreordning, och i bokstavsordning inom varje genre.
I ditt projekt. Samma tre ändringar. Kontrollera sedan listvyns fjärde sorteringskolumn: sorterar du på kategorins namn behöver du både Include och !, och en ThenBy för att ordningen ska vara stabil.
Steg 10 – Se den SQL som körs
Vad vi gör. Slår på loggningen av den SQL som EF Core skickar till databasen.
Filen. appsettings.Development.json:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
}
}
}
Varför det ser ut så. Allt du skrivit mot databasen hittills har varit C#. ToListAsync, FindAsync, Include. Någonstans blir det SQL, och det är EF Core som skriver den. Den här raden låter dig läsa vad den skrev.
Loggningen styrs per kategori. Microsoft.EntityFrameworkCore.Database.Command är kategorin för en fråga som skickats till databasen, och Information betyder att varje fråga skrivs ut. Det är samma ILogger som du använde i område 4 – EF Core loggar genom den, precis som din tjänst.
**Varför i appsettings.Development.json och inte i appsettings.json?** För att den ska gälla bara på din dator. En driftsatt applikation med den här raden skriver en logg per databasanrop, och det vill man inte betala för. Område 8 driftsätter, och då är raden automatiskt borta.
Kontrollera. Starta om och gå till /filmer. I terminalen:
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT "f"."Id", "f"."Ar", "f"."Beskrivning", "f"."Betyg", "f"."GenreId", "f"."LangdMinuter", "f"."Tillganglig", "f"."Titel"
FROM "Filmer" AS "f"
Gå till /filmer/1 och leta efter INNER JOIN. Det är Include från steg 7.
I ditt projekt. Samma rad. Låt den stå kvar under resten av området – nästa tre steg går inte att förstå utan den.
Steg 11 – Sökningen flyttar in i databasen
Vad vi gör. Skriver om sökningen så att databasen gör urvalet, och bygger en sida som jämför den gamla varianten med den nya.
Sökningen från område 3 och 4 ser ut ungefär så här. Den finns kvar i tjänsten, under ett nytt namn:
Filen. Services/FilmService.cs:
// Variant A, från område 4: hämta allt och gå igenom det i C#.
// Fungerar, och gör fel sak. Behålls som jämförelse till lektion 38.
public async Task<List<Film>> SokIMinnetAsync(string? sok)
{
List<Film> alla = await HamtaAllaAsync();
List<Film> traffar = new();
foreach (var film in alla)
{
if (string.IsNullOrWhiteSpace(sok)
|| film.Titel.Contains(sok, StringComparison.OrdinalIgnoreCase))
{
traffar.Add(film);
}
}
return traffar;
}
// Variant B: samma svar, men urvalet görs av databasen. Where läggs på
// frågan innan ToListAsync körs - och det är först där den går till
// databasen.
public async Task<List<Film>> SokAsync(string? sok)
{
_logg.LogInformation("Sökning på {Sok}",
string.IsNullOrWhiteSpace(sok) ? "(allt)" : sok);
using var db = await _fabrik.CreateDbContextAsync();
IQueryable<Film> fraga = db.Filmer;
if (!string.IsNullOrWhiteSpace(sok))
{
fraga = fraga.Where(f => EF.Functions.Like(f.Titel, $"%{sok}%"));
}
return await fraga.OrderBy(f => f.Titel).ToListAsync();
}
Filen. Services/IFilmService.cs, efter SokAsync:
// Bara till jämförelsen i lektion 38. Samma svar som SokAsync, men
// urvalet görs i C# efter att allt hämtats.
Task<List<Film>> SokIMinnetAsync(string? sok);
Filen. Components/Pages/Linq.razor – ny:
@page "/linq"
@inject IFilmService Arkivet
<PageTitle>Två sökningar – Filmarkivet</PageTitle>
<h1>Samma svar, två sätt att komma fram</h1>
<form method="get">
<label>Sök på titel
<input name="sok" value="@Sok" /></label>
<button type="submit">Sök</button>
</form>
<p class="tomt">Titta i terminalen medan du söker. Där står den SQL som
faktiskt kördes.</p>
<h2>I minnet: hämta allt, gå igenom i C#</h2>
<p>@iMinnet.Count träffar</p>
<ul>
@foreach (var film in iMinnet)
{
<li>@film.Titel</li>
}
</ul>
<h2>I databasen: <code>Where</code> på frågan</h2>
<p>@iDatabasen.Count träffar</p>
<ul>
@foreach (var film in iDatabasen)
{
<li>@film.Titel</li>
}
</ul>
@code {
[SupplyParameterFromQuery(Name = "sok")]
public string? Sok { get; set; }
private List<Film> iMinnet = new();
private List<Film> iDatabasen = new();
protected override async Task OnParametersSetAsync()
{
iMinnet = await Arkivet.SokIMinnetAsync(Sok);
iDatabasen = await Arkivet.SokAsync(Sok);
}
}
Varför det ser ut så. Sök på kel och läs terminalen. Två frågor:
-- SokIMinnetAsync
SELECT "f"."Id", "f"."Ar", "f"."Beskrivning", … FROM "Filmer" AS "f"
-- SokAsync
SELECT "f"."Id", "f"."Ar", "f"."Beskrivning", … FROM "Filmer" AS "f"
WHERE "f"."Titel" LIKE @Format
ORDER BY "f"."Titel"
Samma två träffar på skärmen. Men den första frågan hämtade alla filmer över till C#, och slängde sedan de flesta. Den andra lät databasen välja och hämtade bara det som skulle visas. Med sex filmer är skillnaden ingenting. Med sex hunderttusen är den allt.
**IQueryable<Film> är en fråga som inte körts.** db.Filmer hämtar ingenting. fraga.Where(…) hämtar ingenting. De bygger bara på en beskrivning. Det är först ToListAsync som gör om beskrivningen till SQL och skickar den. Det är därför Where kan läggas till i en if: frågan byggs i steg, och körs en gång.
Jämför med SokIMinnetAsync, där HamtaAllaAsync redan har kört ToListAsync. Efter det finns det ingen fråga kvar att bygga på, bara en lista.
**EF.Functions.Like blir LIKE i SQL.** Procenttecknen betyder vad som helst före och efter, så %kel% hittar Cykeltjuven. LIKE i SQLite skiljer inte på versaler och gemener, så CYKEL hittar den också.
**Sökordet är en parameter, @Format.** Det står inte inklistrat i SQL:en – det skickas bredvid. Hade det klistrats in hade någon kunnat skriva SQL i sökrutan och få den körd. Det heter SQL-injektion, och det är det första område 7 handlar om. EF Core skyddar dig mot det så länge du skriver LINQ.
Kontrollera. Gå till /linq?sok=kel. Båda listorna visar samma två filmer. Terminalen visar två olika frågor.
I ditt projekt. Din sökning på ditt sökbara fält, med Where på en IQueryable. Behåll den gamla varianten under ett annat namn tills du gjort nästa steg. Du behöver båda för att se vad som skiljer.
Steg 12 – Databasen kan inte svenska
Vad vi gör. Hittar var de två sökningarna ger olika svar, och bestämmer var urvalet och sorteringen ska göras.
Sök på gökboet på /linq. Båda hittar Gökboet. Sök sedan på GÖKBOET.
| Söker på | SokIMinnetAsync | SokAsync |
|---|---|---|
gökboet | Gökboet | Gökboet |
GÖKBOET | Gökboet | inget |
CYKEL | Cykeltjuven | Cykeltjuven |
Varför det ser ut så. SQLite viker bara A till Z mellan versaler och gemener. C och c räknas som samma bokstav. Ö och ö gör det inte. Det gäller LIKE, och det gäller de inbyggda funktionerna:
lower('GÖKBOET')gergÖkboetCOLLATE NOCASEgör samma sak
C# har inte det problemet. StringComparison.OrdinalIgnoreCase vet att Ö och ö hör ihop.
Sorteringen har samma problem, åt två håll. Med fyra filmer till i databasen – apans år, Åkes bil, Änglagård och Övermodet – blir ordningen:
C# OrderBy apans år · Cykeltjuven · … · Åkes bil · Änglagård · Övermodet
SQL ORDER BY Cykeltjuven · … · apans år · Änglagård · Åkes bil · Övermodet
I SQLite hamnar gemener efter alla versaler, och Ä kommer före Å. Det är inte svensk ordning, och det går inte att rätta med en inställning.
Så var ska det göras? Det finns inget svar som gäller alltid. I Filmarkivet ligger de två olika, med avsikt:
| Var | Varför | |
|---|---|---|
| Sökningen | i databasen, SokAsync | Den ska fungera också när arkivet är stort. Att GÖKBOET med versaler inte hittas är ett pris värt att betala |
| Sorteringen i listvyn | i C#, AdminFilmer.razor | Listan är liten, och ordningen ska vara rätt på svenska. En redaktör som ser Ä före Å tror att något är trasigt |
Det är avvägningen lektion 38 ska landa i. Regeln är inte alltid i databasen. Den är: vet var det görs, och varför.
Identity, som kommer i steg 17, löser samma problem på ett tredje sätt. Den lagrar en versaliserad kopia av e-postadressen i en egen kolumn, gjord i C# där Ö blir Ö, och söker mot den. Det fungerar, men det är mer än området behöver.
Kontrollera. Gör försöket i tabellen. Leta sedan upp en titel med Å, Ä eller Ö i din egen data och gör samma sak.
I ditt projekt. Det här är aktiviteten på lektion 38. Logga SQL:en, jämför de två varianterna med din egen data, och bestäm var sökningen och sorteringen ska göras. Skriv beslutet och skälet i README.md. Har din domän inga svenska tecken i sökfältet är det också ett svar – men skriv det.
Steg 13 – Statistiken räknas där datan finns
Vad vi gör. Skriver om statistiken så att databasen räknar.
Filen. Services/FilmService.cs:
// Område 4 hämtade hela listan och loopade. Nu räknar databasen, och
// svaret som kommer tillbaka över nätverket är fem tal i stället för
// alla rader. Skillnaden syns i SQL:en: COUNT, AVG och MAX i stället för
// SELECT *.
public async Task<Arkivstatistik> HamtaStatistikAsync()
{
using var db = await _fabrik.CreateDbContextAsync();
if (!await db.Filmer.AnyAsync())
{
return new Arkivstatistik();
}
int antal = await db.Filmer.CountAsync();
double snitt = await db.Filmer.AverageAsync(f => (double)f.Betyg);
int tillgangliga = await db.Filmer.CountAsync(f => f.Tillganglig);
int langsta = await db.Filmer.MaxAsync(f => f.LangdMinuter);
// Titeln på den längsta kräver en rad till: MAX ger talet, inte
// filmen det hörde till.
string titel = await db.Filmer
.Where(f => f.LangdMinuter == langsta)
.Select(f => f.Titel)
.FirstAsync();
return new Arkivstatistik
{
Antal = antal,
Snittbetyg = snitt,
LangstaTitel = titel,
LangstaMinuter = langsta,
AntalTillgangliga = tillgangliga
};
}
Varför det ser ut så. Loopen från område 4 är borta. Varje rad är en fråga som databasen svarar på med ett enda tal:
SELECT COUNT(*) FROM "Filmer"
SELECT AVG(CAST("f"."Betyg" AS REAL)) FROM "Filmer" AS "f"
SELECT COUNT(*) FROM "Filmer" AS "f" WHERE "f"."Tillganglig"
SELECT MAX("f"."LangdMinuter") FROM "Filmer" AS "f"
**AnyAsync först.** AverageAsync och MaxAsync på en tom tabell kastar ett undantag – det finns inget snitt av ingenting. Frågan finns det några filmer alls ställs därför innan.
**(double)f.Betyg.** Betyget är ett heltal, och snittet av heltal är inte ett heltal. Utan omvandlingen hade 4,5 blivit 4. Det syns i SQL:en som CAST … AS REAL.
**Select(f => f.Titel) hämtar bara titeln.** Inte hela filmen, bara den kolumn som behövs. Det är LINQ:s Select – samma namn som i SQL, fast det står sist.
Var ärlig om vad det kostade. Fem tal över nätverket i stället för alla rader – men sex frågor i stället för en. Med sex filmer var loopen snabbare. Med sextusen är frågorna det. Det finns ett sätt att få allt i en enda fråga, med GroupBy, men det är svårare att läsa, och det skrivs i område 8.
Kontrollera. Startsidan visar samma siffror som förut. Terminalen visar COUNT, AVG och MAX i stället för en SELECT av alla kolumner.
I ditt projekt. Skriv om din statistik på samma sätt. Räkna frågorna i terminalen, och fundera på om det blev bättre. Det är inte säkert att det blev det, och det är en bra sak att kunna säga.
Steg 14 – Ett eget valideringsattribut
Vad vi gör. Skriver ett attribut för en regel som [Range] inte kan uttrycka.
Utgivningsåret har [Range(1888, 2100)] sedan område 5. Tvåtusenetthundra är orimligt långt fram. Det man vill säga är högst ett par år fram i tiden – och det går inte med [Range].
Filen. Validering/RimligtUtgivningsarAttribute.cs – ny mapp, ny fil:
using System.ComponentModel.DataAnnotations;
namespace Filmarkivet.Validering;
// Ett eget valideringsattribut, nytt i område 6.
//
// Varför inte [Range(1888, 2100)]? Därför att argumenten till ett attribut
// måste vara konstanter - de skrivs in i den kompilerade filen. Regeln här
// beror på vilket år det är när koden körs, och det vet kompilatorn inte.
//
// Attributet ärver ValidationAttribute och skriver över IsValid. Resten -
// när det körs, hur felet visas - sköter DataAnnotationsValidator, precis
// som för de inbyggda attributen.
public class RimligtUtgivningsarAttribute : ValidationAttribute
{
public int Tidigast { get; set; } = 1888;
// Hur många år fram i tiden som accepteras. Filmer annonseras i förväg,
// så ett par år är rimligt.
public int ArFramat { get; set; } = 2;
protected override ValidationResult? IsValid(object? varde, ValidationContext sammanhang)
{
// Tomt fält är inte den här regelns ansvar. [Required] avgör om
// fältet måste fyllas i; vi säger bara om det som står är rimligt.
if (varde is null)
{
return ValidationResult.Success;
}
if (varde is not int ar)
{
return new ValidationResult("Året måste vara ett tal.",
new[] { sammanhang.MemberName! });
}
int senast = DateTime.Now.Year + ArFramat;
if (ar < Tidigast || ar > senast)
{
return new ValidationResult(
$"Året måste ligga mellan {Tidigast} och {senast}.",
new[] { sammanhang.MemberName! });
}
return ValidationResult.Success;
}
}
Filen. Models/Film.cs. [Range] på Ar byts mot det nya attributet, och en using till överst:
using Filmarkivet.Validering;
// Eget attribut i stället för [Range]: den övre gränsen beror på vilket
// år det är, och ett attributargument måste vara en konstant.
[RimligtUtgivningsar]
public int Ar { get; set; }
Varför det ser ut så. Skriv [Range(1888, DateTime.Now.Year + 2)] och se vad kompilatorn säger. Den vägrar: An attribute argument must be a constant expression. Allt inom attributets parentes skrivs in i den kompilerade filen, och vilket år det är vet man inte förrän koden körs.
Ett eget attribut löser det genom att flytta uträkningen in i en metod som körs vid valideringen.
**Klassen ärver ValidationAttribute.** Det är samma basklass som [Required] och [Range] har. Därför vet DataAnnotationsValidator redan hur den ska köras, och felet hamnar i ValidationSummary och intill fältet utan att du gör något mer.
**Namnet slutar på Attribute, men skrivs utan.** Det är en C#-konvention: klassen heter RimligtUtgivningsarAttribute, och ovanför egenskapen skriver du [RimligtUtgivningsar]. Kompilatorn lägger till resten.
**IsValid svarar på en fråga.** ValidationResult.Success betyder godkänt. Ett ValidationResult med en text betyder underkänt, och texten är det användaren ser. sammanhang.MemberName är namnet på egenskapen – Ar – och det är det som gör att felet hamnar intill rätt fält.
**null är inte attributets ansvar.** Det är en konvention som alla inbyggda attribut följer: [Range] godkänner ett tomt fält, och [Required] är det som säger nej. Blandar du ihop dem blir det svårt att förstå vilket attribut som gav vilket fel.
Kontrollerat hösten 2026: 1800 och 2031 avvisas, 2028 godkänns, och felmeddelandet räknar ut gränsen själv: Året måste ligga mellan 1888 och 2028.
Kontrollera. Försök skapa en film från år 2040. Felet ska stå intill årsfältet, med rätt årtal i texten.
I ditt projekt. Ett attribut för en regel som hör till din domän – inte en kopia av det här. Regeln ska vara något [Range], [StringLength] och [Required] inte klarar. Beror den på något som ändras – dagens datum, ett värde i konfigurationen – är det ett gott tecken.
Steg 15 – En regel över två fält
Vad vi gör. Skriver en regel som varken hör till Ar eller till Tillganglig, utan till båda.
Filen. Models/Film.cs. Klassen får ett gränssnitt:
public class Film : IValidatableObject
och en metod, sist i klassen:
// En regel som spänner över två fält kan inte ligga på något av dem.
// IValidatableObject körs efter att attributen körts, och får se hela
// objektet.
public IEnumerable<ValidationResult> Validate(ValidationContext sammanhang)
{
if (Tillganglig && Ar > DateTime.Now.Year)
{
yield return new ValidationResult(
"En film som inte kommit ut kan inte vara tillgänglig.",
new[] { nameof(Tillganglig), nameof(Ar) });
}
}
Varför det ser ut så. Regeln är: en film från nästa år kan inte vara tillgänglig i dag. Var ska den stå? Inte på Ar – år 2027 är ett helt godkänt år. Inte på Tillganglig – true är ett helt godkänt värde. Det är kombinationen som är fel.
Ett attribut ser bara sitt eget fält. IValidatableObject ser hela objektet.
**DataAnnotationsValidator hittar metoden själv.** Klassen implementerar gränssnittet, och validatorn anropar Validate efter att attributen körts. Du behöver inte registrera något.
Efter attributen – och bara om de gick igenom. Kontrollerat: en film med tom titel, år 2028 och Tillgänglig ikryssad får ett fel, Titeln måste fyllas i. Fyll i titeln och skicka igen, så kommer det andra. Det kan se ut som att regeln inte fungerar första gången, men den körs bara på ett objekt vars fält var för sig är giltiga. Det är avsiktligt: det är ingen mening att jämföra två fält om ett av dem är trasigt.
**yield return** lämnar ifrån sig ett fel i taget. Har klassen flera regler blir det en if per regel, och alla fel som hittas kommer med. Det är samma yield som i en iterator, om du stött på det. Här räcker det att veta att det är så man returnerar noll, ett eller flera resultat.
**nameof(Tillganglig)** i stället för strängen "Tillganglig". Byter du namn på egenskapen följer nameof med. En sträng hade blivit fel utan att någon märkte det.
Kontrollera. Skapa en film från 2028, kryssa i Tillgänglig och spara. Du ska få En film som inte kommit ut kan inte vara tillgänglig. Ta bort krysset – nu går det.
I ditt projekt. En regel över två fält i din domän. Ett slutdatum som inte får vara före ett startdatum. Ett pris som måste vara noll om varan är gratis. En tillagningstid som måste vara längre än förberedelsetiden. Tänk på fält som betyder något tillsammans.
Steg 16 – En regel som kräver databasen
Vad vi gör. Hindrar att två filmer får samma titel.
Filen. Services/IFilmService.cs:
/// <summary>
/// Svarar på om en annan film redan har exakt den här titeln.
/// </summary>
/// <param name="titel">Titeln som ska kontrolleras. Jämförs exakt,
/// med skiftläge och mellanslag.</param>
/// <param name="utomId">Id på en film som inte räknas. Ange filmens eget
/// id när den ändras, så att den inte krockar med sig själv.</param>
/// <returns><c>true</c> om titeln är upptagen.</returns>
Task<bool> TitelFinnsAsync(string titel, int utomId = 0);
Filen. Services/FilmService.cs:
public async Task<bool> TitelFinnsAsync(string titel, int utomId = 0)
{
using var db = await _fabrik.CreateDbContextAsync();
return await db.Filmer
.AnyAsync(f => f.Titel == titel && f.Id != utomId);
}
Filen. Components/Pages/NyFilm.razor. EditForm får en referens:
<EditForm Model="Film" OnValidSubmit="Spara" FormName="nyfilm" @ref="formularet">
och Spara kontrollerar innan den sparar:
private EditForm? formularet;
// Den tredje sortens validering: den som kräver databasen. Attributen
// och IValidatableObject har redan godkänt objektet när vi kommer hit -
// men de kan inte veta vad som redan står i tabellen.
//
// Felet läggs i en ValidationMessageStore, samma ställe som
// DataAnnotationsValidator skriver till. Då hamnar det i
// ValidationSummary och intill fältet, precis som de andra felen.
private async Task Spara()
{
if (await Arkivet.TitelFinnsAsync(Film!.Titel))
{
var sammanhang = formularet!.EditContext!;
var meddelanden = new ValidationMessageStore(sammanhang);
meddelanden.Add(() => Film!.Titel, "Det finns redan en film med den titeln.");
sammanhang.NotifyValidationStateChanged();
return;
}
await Arkivet.SkapaAsync(Film!);
sparad = true;
Film = new Film();
}
Filen. Components/Pages/AndraFilm.razor får samma @ref, och en Spara som skickar med filmens eget id:
private EditForm? formularet;
// Samma kontroll som i NyFilm.razor, med en skillnad: filmens eget id
// skickas med. Annars krockar den med sig själv och går aldrig att spara.
private async Task Spara()
{
if (await Arkivet.TitelFinnsAsync(Film!.Titel, Film.Id))
{
var sammanhang = formularet!.EditContext!;
var meddelanden = new ValidationMessageStore(sammanhang);
meddelanden.Add(() => Film!.Titel, "Det finns redan en film med den titeln.");
sammanhang.NotifyValidationStateChanged();
return;
}
await Arkivet.UppdateraAsync(Film);
Navigering.NavigateTo("admin/filmer");
}
Varför det ser ut så. Tre sorters validering, och de svarar på tre olika frågor:
| Sort | Frågan den svarar på | Ser |
|---|---|---|
| Attribut | Är fältet rimligt? | ett fält |
IValidatableObject | Hänger objektet ihop? | hela objektet |
| Mot databasen | Stämmer det med det som redan finns? | hela tabellen |
Den tredje kan inte ligga i ett attribut, för ett attribut kan inte fråga databasen. Den kan inte ligga i entiteten heller, av samma skäl. Den hör hemma i tjänsten, där databasen finns, och frågan ställs från sidan.
**AnyAsync svarar ja eller nej.** Det blir SELECT EXISTS (…) i SQL – ingen film hämtas, databasen svarar bara om det finns en.
**utomId behövs när man ändrar.** Ändrar du beskrivningen på Gökboet och sparar, finns det redan en film som heter Gökboet – den du håller på med. Utan utomId hade den krockat med sig själv, och ingen film hade gått att ändra utan att byta titel. Kontrollerat: med utomId går det att spara under samma titel, men inte under en annan films titel.
**ValidationMessageStore är dit felen går.** DataAnnotationsValidator lägger sina fel i en sådan, knuten till formulärets EditContext. Lägger du ditt fel på samma ställe behandlas det som alla andra: det står i ValidationSummary, under titelfältet, och sidan visas igen med det som skrevs kvar. @ref är det som ger dig tag i formuläret och därmed i dess EditContext.
Kontrollen är exakt, och det står i XML-kommentaren. Gökboet avvisas. Men gökboet, GÖKBOET och Gökboet med ett mellanslag på slutet går igenom – kontrollerat. == blir = i SQL, och SQLite jämför tecken för tecken. Att göra jämförelsen okänslig för skiftläge stöter på samma Å, Ä och Ö som i steg 12. Begränsningen är inte dold. Den står i dokumentationen till metoden, där nästa programmerare ser den. Mer om sådana kommentarer i steg 24.
Kontrollera. Försök skapa en film som heter Gökboet. Det finns redan en film med den titeln. står under fältet. Ändra Sju samurajer och spara utan att byta titel – det ska gå. Försök döpa om den till Gökboet – det ska inte gå.
I ditt projekt. En regel som kräver databasen. Unikhet är det vanligaste, men det finns fler: att en kategori inte får tas bort om den har poster, att ett datum inte får krocka med en annan bokning. Glöm inte utomId om regeln gäller också när man ändrar.
Steg 17 – Identity i contextet
Vad vi gör. Ger databasen tabeller för användare, utan att skriva en enda av dem.
dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore --version 10.0.12
Versionen står utskriven med avsikt. Utan den hämtas den senaste, och när en ny huvudversion av .NET kommit ut passar den inte ihop med ett projekt för .NET 10.
Filen. Data/FilmarkivetContext.cs. Två using till, en ny basklass, och en rad först i OnModelCreating:
using Filmarkivet.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
namespace Filmarkivet.Data;
// Kopplingen mellan klasserna och databasen. Ett DbSet per entitet.
//
// Från område 6 ärver den IdentityDbContext i stället för DbContext. Då får
// den med sig Identitys egna tabeller - användare, roller och några till -
// utan att vi skriver en enda av dem.
public class FilmarkivetContext : IdentityDbContext<IdentityUser>
{
protected override void OnModelCreating(ModelBuilder byggare)
{
// Måste anropas först. Det är basklassen som beskriver Identitys
// tabeller, och utan raden byggs de inte.
base.OnModelCreating(byggare);
Sedan migrationen:
dotnet ef migrations add Identity
dotnet ef database update
Varför det ser ut så. Skriv aldrig din egen användartabell. Det låter enkelt – ett namn, en e-post, ett lösenord – men lösenordet är det svåra. Det ska inte lagras, det ska hashas: omvandlas till något som går att jämföra med men inte att räkna tillbaka. Det ska saltas, så att två användare med samma lösenord får olika hash. Det ska ta tid att räkna ut, så att den som stjäl databasen inte kan prova en miljard lösenord i sekunden. Identity gör allt det, och den som skriver det själv gör fel på minst en punkt.
**IdentityDbContext<IdentityUser>** är en DbContext som redan har användare, roller och det som hör till. Du ärver från den i stället för från DbContext, och dina egna DbSet står kvar precis som förut.
Migrationen skapar sju tabeller:
AspNetUsers AspNetRoles AspNetUserRoles
AspNetUserClaims AspNetRoleClaims AspNetUserLogins AspNetUserTokens
Bara AspNetUsers spelar roll i kursen. De andra finns för roller, inloggning via andra tjänster och liknande. AspNetUsers har femton kolumner, och de som betyder något här är UserName, Email, PasswordHash, LockoutEnd och AccessFailedCount.
**base.OnModelCreating(byggare) först.** Det är basklassens metod som beskriver de sju tabellerna. Du skriver över den med din egen, och utan anropet försvinner basklassens beskrivning. Felet är inte självklart att tolka:
The entity type 'IdentityPasskeyData' requires a primary key to be defined.
IdentityPasskeyData har du aldrig skrivit, och det är just poängen. Det är en av Identitys typer, och den blev aldrig beskriven.
Kontrollera. dotnet ef database update ska sluta med Done. Applikationen startar och fungerar som förut. Ingenting syns ännu.
I ditt projekt. Samma paket, samma basklass, samma rad. Rita ingen egen användarentitet i din datamodell – användarna är Identitys.
Steg 18 – Registreringen
Vad vi gör. Kopplar in Identity i Program.cs.
Filen. Program.cs, efter de andra registreringarna:
// Identity: användare, lösenordshashning och inloggningscookien.
builder.Services.AddIdentityCore<IdentityUser>(alternativ =>
{
alternativ.Password.RequiredLength = 8;
alternativ.Password.RequireNonAlphanumeric = false;
alternativ.User.RequireUniqueEmail = true;
})
.AddEntityFrameworkStores<FilmarkivetContext>()
.AddSignInManager();
builder.Services.AddAuthentication(IdentityConstants.ApplicationScheme)
.AddIdentityCookies();
// Utan de här raderna skickar Identity den utloggade till /Account/Login,
// som inte finns i det här projektet - och eleven får en 404 i stället för
// inloggningssidan.
builder.Services.ConfigureApplicationCookie(alternativ =>
{
alternativ.LoginPath = "/konto/logga-in";
alternativ.AccessDeniedPath = "/konto/logga-in";
alternativ.ReturnUrlParameter = "tillbaka";
});
builder.Services.AddAuthorization();
builder.Services.AddCascadingAuthenticationState();
Bland Use-raderna, efter UseSession och före UseAntiforgery:
app.UseAuthentication();
app.UseAuthorization();
Överst i filen:
using Microsoft.AspNetCore.Identity;
Filen. Components/_Imports.razor, en rad till:
@using Microsoft.AspNetCore.Components.Authorization
Varför det ser ut så. Det är många rader, men de gör fyra saker:
| Rader | Vad de gör |
|---|---|
AddIdentityCore … AddSignInManager | Användarna, lösenordsreglerna, var de lagras, och tjänsten som loggar in |
AddAuthentication … AddIdentityCookies | Att inloggningen sparas i en cookie |
ConfigureApplicationCookie | Var den som inte är inloggad ska skickas |
AddAuthorization, AddCascadingAuthenticationState | Att sidor kan kräva inloggning, och att komponenter kan fråga vem som är inloggad |
Lösenordsreglerna. Åtta tecken, men inga krav på specialtecken. Det är närmare vad som faktiskt gör ett lösenord starkt – längden – än Identitys standard, som kräver en blandning av allt.
**AddEntityFrameworkStores<FilmarkivetContext>.** Användarna lagras i ditt context, i tabellerna från steg 17. Samma databas som filmerna.
**ConfigureApplicationCookie är den rad man glömmer.** Utan den skickar Identity den som inte är inloggad till /Account/Login – det är den engelska standardadressen. Den finns inte i ditt projekt, och användaren får en 404 i stället för en inloggningssida. Kontrollerat. ReturnUrlParameter byter namnet på parametern som säger vart användaren ska tillbaka, från ReturnUrl till tillbaka.
**Ordningen på Use-raderna spelar roll.** UseAuthentication ska stå före UseAuthorization: först ta reda på vem det är, sedan avgöra vad de får göra.
**AddCascadingAuthenticationState** gör inloggningen tillgänglig för varje komponent. Den behövs först i steg 21, när menyn får en AuthorizeView. Utan den svarar då varje sida med en 500, eftersom menyn finns på alla: Authorization requires a cascading parameter of type Task<AuthenticationState>. Kontrollerat.
Kontrollera. dotnet build och starta. Allt fungerar som förut. Det finns ännu ingen användare och ingen sida att logga in på.
I ditt projekt. Samma rader, med ditt context. Adresserna i ConfigureApplicationCookie behåller du som de är.
Steg 19 – Den första användaren
Vad vi gör. Lägger in en användare när applikationen startar.
Filen. Program.cs, efter MapRazorComponents och före app.Run():
// Den första användaren måste komma någonstans ifrån. Lösenord kan inte
// ligga i en migration - de ska hashas, och det gör UserManager.
using (var omfang = app.Services.CreateScope())
{
var anvandare = omfang.ServiceProvider.GetRequiredService<UserManager<IdentityUser>>();
if (await anvandare.FindByEmailAsync("admin@filmarkivet.se") is null)
{
var admin = new IdentityUser
{
UserName = "admin@filmarkivet.se",
Email = "admin@filmarkivet.se",
EmailConfirmed = true
};
await anvandare.CreateAsync(admin, "Filmarkivet1");
}
}
Varför det ser ut så. Filmerna och genrerna kom in som frödata med HasData. Varför inte användaren?
För att HasData skriver värdena som de står in i migrationen. Ett lösenord måste hashas först, och det är UserManager som gör det. Den finns bara när applikationen kör. Därför görs det här, vid start, och bara om användaren inte redan finns.
**CreateScope** är nytt. UserManager är en scoped tjänst, och i område 4 såg du att scoped betyder en per förfrågan. Vid start finns det ingen förfrågan. CreateScope skapar ett eget omfång, bara för det här, och using städar bort det efteråt.
**Lösenordet står i klartext i Program.cs.** Det är en förenkling, och den ska sägas rakt ut: ett lösenord i koden hamnar i Git, och den som ser repot ser lösenordet. Det är ett av de hot område 7 tar upp, och det är där det flyttar ut ur koden. Så länge applikationen bara kör på din dator är det ett medvetet val.
Kontrollera. Starta applikationen. Ingenting syns på sidan. Men öppna databasen och titta i AspNetUsers: en rad. PasswordHash börjar med AQAAAAIAAYag… och fortsätter en god bit. Någonstans i den strängen finns Filmarkivet1, och det går inte att få ut det.
I ditt projekt. Din första användare, med din adress och ett eget lösenord. Skriv inloggningsuppgifterna i README.md – den som ska bedöma ditt projekt måste kunna logga in.
Steg 20 – Inloggningssidan
Vad vi gör. Bygger sidan där man loggar in.
Filen. Components/Pages/LoggaIn.razor:
@page "/konto/logga-in"
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Identity
@inject SignInManager<IdentityUser> Inloggning
@inject NavigationManager Navigering
<PageTitle>Logga in – Filmarkivet</PageTitle>
<h1>Logga in</h1>
<EditForm Model="Formular" OnValidSubmit="Skicka" FormName="loggain">
<DataAnnotationsValidator />
<ValidationSummary />
<p>
<label>E-post<br />
<InputText @bind-Value="Formular!.Epost" autocomplete="username" /></label>
<ValidationMessage For="() => Formular!.Epost" />
</p>
<p>
<label>Lösenord<br />
<InputText type="password" @bind-Value="Formular!.Losenord"
autocomplete="current-password" /></label>
<ValidationMessage For="() => Formular!.Losenord" />
</p>
<button type="submit">Logga in</button>
</EditForm>
@if (fel is not null)
{
<p class="tomt"><b>@fel</b></p>
}
@code {
[SupplyParameterFromForm]
private Inloggningsuppgifter? Formular { get; set; }
[SupplyParameterFromQuery(Name = "tillbaka")]
public string? Tillbaka { get; set; }
private string? fel;
protected override void OnInitialized()
{
Formular ??= new Inloggningsuppgifter();
}
private async Task Skicka()
{
// PasswordSignInAsync hashar det inskrivna lösenordet och jämför med
// hashen i AspNetUsers. Stämmer det skrivs inloggningscookien.
var resultat = await Inloggning.PasswordSignInAsync(
Formular!.Epost, Formular.Losenord, isPersistent: false,
lockoutOnFailure: true);
if (resultat.Succeeded)
{
Navigering.NavigateTo(Vidare());
return;
}
// Ett enda meddelande för både fel användare och fel lösenord. Skulle
// de skilja sig går det att lista ut vilka adresser som finns.
fel = resultat.IsLockedOut
? "Kontot är låst en stund efter för många försök."
: "Fel e-post eller lösenord.";
}
// Adressen kommer utifrån, ur frågesträngen, och får därför inte följas
// blint: en adress till en annan sajt hade gjort inloggningssidan till en
// vidarebefordran åt vem som helst. Bara adresser inom sajten släpps
// igenom. Område 7 heter det här open redirect.
private string Vidare()
{
if (string.IsNullOrEmpty(Tillbaka))
{
return "admin/filmer";
}
return Navigering.ToAbsoluteUri(Tillbaka).Host == Navigering.ToAbsoluteUri(Navigering.BaseUri).Host
? Tillbaka
: "admin/filmer";
}
public class Inloggningsuppgifter
{
[Required(ErrorMessage = "Fyll i din e-post.")]
[EmailAddress(ErrorMessage = "Det där ser inte ut som en e-postadress.")]
public string Epost { get; set; } = "";
[Required(ErrorMessage = "Fyll i ditt lösenord.")]
public string Losenord { get; set; } = "";
}
}
Varför det ser ut så. Formuläret är ett vanligt EditForm mot en modell med attribut – samma mönster som NyFilm.razor. Modellen är en liten klass inuti sidan, för den används ingen annanstans och ska inte sparas i databasen.
**PasswordSignInAsync gör allt det svåra.** Den letar upp användaren, hashar det inskrivna lösenordet på samma sätt som när kontot skapades, och jämför med PasswordHash. Stämmer det skriver den inloggningscookien. Lösenordet i klartext lämnar aldrig metoden.
**lockoutOnFailure: true** räknar misslyckade försök i AccessFailedCount. Efter fem låses kontot i fem minuter, och LockoutEnd får ett datum. Under den tiden hjälper inte ens rätt lösenord. Det gör det dyrt att gissa. Kontrollerat: det femte felet ger Kontot är låst en stund efter för många försök.
Samma felmeddelande för fel adress och fel lösenord. Kontrollerat: båda ger Fel e-post eller lösenord. Hade sidan sagt Användaren finns inte hade den som försökte kunna lista ut vilka adresser som finns registrerade, en i taget. Det är ett litet hål, och det är lätt att öppna av misstag genom att vara hjälpsam.
Men läs de två meddelandena i koden igen. Efter fem fel på en adress som finns säger sidan Kontot är låst. Fem fel på en adress som inte finns ger fortfarande Fel e-post eller lösenord. Kontrollerat. Så går det ändå att lista ut vilka adresser som finns, bara långsammare. Den frågan lämnar vi öppen här – den är en av de första område 7 tar upp.
**tillbaka följs inte blint.** När [Authorize] skickar hit någon följer adressen de försökte nå med, som ?tillbaka=/admin/filmer. Efter inloggningen skickas de dit. Men adressen står i frågesträngen, och den kan vem som helst skriva. ?tillbaka=https://example.com/ hade gjort din inloggningssida till en länk som ser ut att gå till Filmarkivet men landar någon annanstans. Det heter open redirect. Vidare släpper bara igenom adresser på samma värd. Kontrollerat: ?tillbaka=/filmer/ny följs, ?tillbaka=https://example.com/kapat ignoreras.
**autocomplete="username" och "current-password"** talar om för webbläsarens lösenordshanterare vad fälten är. Det gör att den kan fylla i dem, och det gör att användaren kan ha ett långt lösenord utan att skriva det.
Kontrollera. Gå till /konto/logga-in. Prova ett fel lösenord först – felmeddelandet. Logga sedan in med admin@filmarkivet.se och Filmarkivet1. Du ska hamna på /admin/filmer. Öppna DevTools, fliken Application, och titta under Cookies: .AspNetCore.Identity.Application. Det är inloggningen.
I ditt projekt. Samma sida. Den behöver inte översättas – adressen, fälten och meddelandena är desamma i ditt projekt. Det enda du byter är vart användaren ska efter inloggningen, om din listvy heter något annat.
Steg 21 – Att dölja länken skyddar ingenting
Vad vi gör. Döljer administrationslänkarna för den som inte är inloggad – och ser sedan varför det inte räcker.
Filen. Components/Layout/NavMenu.razor. Länkarna till Ny film och Administrera flyttar in i en AuthorizeView:
@* AuthorizeView visar innehållet bara för den som är inloggad.
Lägg märke till vad det INTE gör: sidan finns kvar på sin adress. *@
<AuthorizeView>
<Authorized>
<div class="nav-item px-3">
<NavLink class="nav-link" href="filmer/ny">Ny film</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="admin/filmer">Administrera</NavLink>
</div>
</Authorized>
<NotAuthorized>
<div class="nav-item px-3">
<NavLink class="nav-link" href="konto/logga-in">Logga in</NavLink>
</div>
</NotAuthorized>
</AuthorizeView>
Varför det ser ut så. AuthorizeView visar <Authorized> för den som är inloggad och <NotAuthorized> för alla andra. Den som inte är inloggad ser Logga in i stället för administrationen. Det ser ut som att sidan är skyddad.
Den är inte det. Gör så här:
- Logga ut. Utloggningen kommer i steg 23 – till dess stänger du webbläsaren,
eller tar bort cookien i DevTools.
- Kontrollera att Administrera är borta ur menyn.
- Skriv
/admin/filmeri adressfältet.
Sidan visas. Alla filmer, med länkar till Ändra och Ta bort. Servern svarar 200. Kontrollerat.
AuthorizeView bestämmer vad som ritas. Den bestämmer inte vad som går att nå. Adressen finns kvar, och den som vet den – eller gissar den – kommer in. /admin/filmer är inte svår att gissa.
Att dölja en länk är inte att skydda något. Det är att hoppas att ingen letar.
Kontrollera. Gör de tre stegen ovan. Det är viktigt att du ser det här, inte bara läser det.
I ditt projekt. Samma AuthorizeView runt dina administrationslänkar. Gör sedan samma försök mot din egen listvy.
Steg 22 – Att skydda sidan
Vad vi gör. Kräver inloggning på de fyra sidorna som ändrar data.
Filen. Components/Pages/AdminFilmer.razor, NyFilm.razor, AndraFilm.razor och TaBortFilm.razor. En rad i var och en, bland de andra direktiven överst:
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
Varför det ser ut så. @attribute sätter ett attribut på hela komponentklassen. [Authorize] betyder: den här sidan kräver en inloggad användare. Gör om försöket från steg 21:
| Menylänken | /admin/filmer utloggad | |
|---|---|---|
Bara AuthorizeView | borta | 200, filmerna visas |
AuthorizeView + [Authorize] | borta | 302 till /konto/logga-in?tillbaka=%2Fadmin%2Ffilmer |
| Inloggad | synlig | 200 |
Nu skyddas sidan av servern, innan den ens börjat rendera. Det är auktorisering: inte vad användaren ser, utan vad användaren får.
Alla fyra sidorna, inte bara listan. Listvyn är ingången, men /filmer/tabort/3 är en adress i sig. Skyddar du bara listan kan den som vet adressen till borttagningssidan fortfarande ta bort filmer. Varje sida som ändrar något ska ha raden. /filmer, /filmer/{id} och /genrer/{id} ska inte ha den – det är besökarens sidor.
Det fullständiga namnet. Microsoft.AspNetCore.Authorization.Authorize är långt. Det går att lägga @using Microsoft.AspNetCore.Authorization i _Imports.razor och skriva [Authorize], men då ser man inte på sidan var attributet kommer ifrån. Här står det fullt ut, på de fyra ställen där det används.
**Routes.razor behöver inte ändras.** I många exempel på nätet ser du AuthorizeRouteView i stället för RouteView. Det behövs i interaktiva renderingslägen. Under statisk serverrendering är det MapRazorComponents som läser [Authorize] och skickar vidare till inloggningen – kontrollerat med RouteView kvar.
Kontrollera. Utloggad: /admin/filmer, /filmer/ny, /filmer/andra/1 och /filmer/tabort/1 ska alla skicka dig till inloggningen. Logga in – du ska hamna på sidan du försökte nå, inte på startsidan.
I ditt projekt. Gå igenom varje sida och avgör: ändrar den något? Då ska den ha raden. Gör sedan försöket mot varje adress, utloggad.
Steg 23 – Utloggningen
Vad vi gör. Ger den inloggade en knapp för att logga ut.
Filen. Program.cs, efter den första användaren och före app.Run():
// Utloggningen är en endpoint och inte en sida. Knappen ligger i menyn, som
// finns på varje sida, och formuläret behöver en adress som är densamma
// oavsett vilken sida man står på.
//
// En endpoint kontrollerar inte antiforgery-token av sig själv – det gör bara
// Blazors sidor och EditForm. Utan kontrollen nedan kan vilken sajt som helst
// logga ut besökaren med ett eget formulär.
app.MapPost("/konto/logga-ut", async (HttpContext http, IAntiforgery antiforgery,
SignInManager<IdentityUser> inloggning) =>
{
if (!await antiforgery.IsRequestValidAsync(http))
{
return Results.BadRequest();
}
await inloggning.SignOutAsync();
return Results.Redirect("/");
});
Överst i filen, bredvid den från steg 18:
using Microsoft.AspNetCore.Antiforgery;
Filen. Components/Layout/NavMenu.razor, sist i <Authorized>:
<div class="nav-item px-3">
<form method="post" action="/konto/logga-ut">
<AntiforgeryToken />
<button type="submit" class="nav-link">Logga ut</button>
</form>
</div>
Varför det ser ut så. Utloggning är att ta bort cookien, och det gör SignOutAsync. Frågan är var den ska anropas.
Inloggningen i steg 20 är en sida med ett eget formulär. Utloggningen är en knapp i menyn, och menyn finns på varje sida. Ett formulär i en komponent skickar tillbaka till sidan det står på – så står man på /genrer/2 skulle knappen skicka dit, och på /filmer/5 dit. Utloggningen behöver ett mål som är detsamma var man än står.
Därför är den en endpoint: en adress som svarar med kod, utan att vara en sida. Formuläret har action="/konto/logga-ut", och det pekar alltid på samma ställe.
app.MapPost är nytt. Den säger: *en POST till /konto/logga-ut ska köra den här metoden.* Metoden får SignInManager genom dependency injection – samma mekanism som @inject, fast i en parameter. Den loggar ut och skickar vidare till startsidan.
POST, inte GET. Samma skäl som i steg 4. En länk till /konto/logga-ut hade kunnat läggas på en annan sajt, och den som klickade hade loggats ut hos dig.
Men en POST räcker inte, och det här är stegets viktigaste stycke. Formuläret i menyn har <AntiforgeryToken />, precis som visningslistans i område 4. Skriv endpointen utan if-satsen, logga in, och ta bort det dolda fältet __RequestVerificationToken ur menyns formulär i DevTools. Tryck Logga ut. Webbläsaren skickar cookien men ingen token – precis som ett formulär på en annan sajt hade gjort. Användaren loggas ut. Kontrollerat.
Token fanns i formuläret, men ingen krävde den. Blazors sidor och EditForm kontrollerar token åt dig. En endpoint från app.MapPost gör det inte, och app.UseAntiforgery() i Program.cs ändrar inte på det. Därför frågar endpointen själv: IAntiforgery.IsRequestValidAsync svarar på om token fanns och stämde. Gör den inte det svarar endpointen 400, och användaren är kvar inloggad. Kontrollerat åt båda hållen.
Ett skydd som skickas men inte kontrolleras är inget skydd. Det är samma sak som med valideringen i område 5: det som räknas är vad servern gör.
Kontrollera. Logga in. Menyn visar Ny film, Administrera och Logga ut. Tryck Logga ut. Du hamnar på startsidan, menyn visar Logga in, och i DevTools är cookien .AspNetCore.Identity.Application borta. Försök nå /admin/filmer – inloggningen.
Logga sedan in igen och skicka en POST utan token från terminalen, med den adress dotnet run skrev ut:
curl.exe -X POST http://localhost:5222/konto/logga-ut -w "%{http_code}"
Skriv curl.exe, inte curl: i PowerShell är curl ett annat kommando, och det svarar A parameter cannot be found that matches parameter name 'X'. På Mac heter det bara curl.
Svaret slutar med 400. Ladda om sidan i webbläsaren – du är fortfarande inloggad. curl har ingen cookie, så det här visar bara att endpointen vägrar; det riktiga försöket, med cookien, är det som kontrollerades ovan.
I ditt projekt. Samma endpoint, med kontrollen, och samma formulär. Nu kan du göra hela försöket från steg 21 och 22 utan att stänga webbläsaren.
Steg 24 – Koden någon annan ska läsa
Vad vi gör. Går igenom Filmarkivet mot en konventionslista, och tittar på de två sorters kommentarer som finns i projektet.
Det här steget ändrar ingen kod. Det är lektion 41, och aktiviteten är att du granskar någon annans projekt – och de ditt.
Konventionerna. Microsofts regler för C# är långa. De här är de som syns i Filmarkivet, och de som oftast bryts:
| Regel | I Filmarkivet |
|---|---|
| Klasser, metoder och egenskaper i PascalCase | FilmService, HamtaMedGenreAsync, GenreId |
| Lokala variabler och parametrar i camelCase | alla, ordnade, utomId |
| Privata fält med understreck | _fabrik, _logg |
Asynkrona metoder slutar på Async | SokAsync, TitelFinnsAsync |
Gränssnitt börjar på I | IFilmService, IGenreService |
| En klass per fil, och filen heter som klassen | Genre.cs, GenreService.cs |
Attribut slutar på Attribute | RimligtUtgivningsarAttribute |
| Mappen säger vilket lager | Models/, Data/, Services/, Validering/, Components/ |
| Korta metoder som gör en sak | TaBortAsync hämtar, tar bort, loggar – inget annat |
Två sorters kommentarer. Filmarkivet har mest vanliga kommentarer med //. De förklarar varför – varför ??= och inte =, varför en fabrik, varför HasDefaultValue. De förklarar aldrig vad en rad gör, för det kan man läsa i raden.
// Dåligt: säger vad raden gör
// Sätter sida till onskad mellan 1 och antalSidor
sida = Math.Clamp(onskad, 1, antalSidor);
En metod har en annan sorts kommentar: TitelFinnsAsync i steg 16.
/// <summary>
/// Svarar på om en annan film redan har exakt den här titeln.
/// </summary>
/// <param name="titel">Titeln som ska kontrolleras. Jämförs exakt,
/// med skiftläge och mellanslag.</param>
Tre snedstreck och XML-taggar. Det är en XML-kommentar, och den är skriven för den som anropar metoden, inte för den som läser den. Håll musen över TitelFinnsAsync någonstans där den används, så visar editorn texten. Den som anropar metoden behöver veta att jämförelsen är exakt – annars tror de att den skyddar mot gökboet. Det är därför just den metoden fick en.
Skriv XML-kommentarer på gränssnitt och på metoder vars beteende inte syns i namnet. Skriv // där något i koden ser konstigt ut men är rätt.
**README.md.** Filmarkivets README.md säger hur man kör projektet, hur man loggar in, vilka adresser som finns, och vad som är värt att peka på. Det är vad en ny läsare behöver först.
Kontrollera. dotnet build ska ge noll varningar. En varning är något kompilatorn tycker ser fel ut, och i ett projekt med noll varningar syns den nya direkt.
I ditt projekt. Byt projekt med någon, och gå igenom deras kod mot tabellen. Skriv ned vad du hittar – inte för att rätta det åt dem, utan för att de ska se det. Skriv sedan en XML-kommentar på minst en metod i ditt eget gränssnitt, och se till att din README.md säger hur man installerar, kör och loggar in.
Steg 25 – Tabellen vars knappar inte gör något
Vad vi gör. Bygger samma listvy med QuickGrid, Microsofts färdiga tabellkomponent – och ser den inte fungera.
dotnet add package Microsoft.AspNetCore.Components.QuickGrid --version 10.0.12
Filen. Components/_Imports.razor, en rad till:
@using Microsoft.AspNetCore.Components.QuickGrid
Filen. Components/Pages/StatiskTabell.razor:
@page "/renderingslagen/statisk"
@inject IFilmService Arkivet
@inject ILogger<StatiskTabell> Logg
@* Demo till lektion 43. Identisk med InteraktivTabell.razor - utom en enda
rad. Den raden är @rendermode InteractiveServer, och den saknas här. *@
<PageTitle>Statisk rendering – Filmarkivet</PageTitle>
<h1>Statisk serverrendering</h1>
<p class="tomt">
Renderingsläge: <b>@(RendererInfo.IsInteractive ? "interaktivt" : "statiskt")</b>
· <code>OnInitializedAsync</code> har körts <b>@antal</b> gång(er) i det här objektet
</p>
<p>
Klicka på en kolumnrubrik. Tabellen har sorteringsknappar – de är riktiga
<code><button></code>-element, och de gör ingenting.
</p>
<QuickGrid Items="filmer" Pagination="sidor">
<PropertyColumn Property="@(f => f.Titel)" Title="Titel" Sortable="true" />
<PropertyColumn Property="@(f => f.Ar)" Title="År" Sortable="true" />
<PropertyColumn Property="@(f => f.Betyg)" Title="Betyg" Sortable="true" />
</QuickGrid>
<Paginator State="sidor" />
<p class="tomt">
Titta i terminalen. Här loggas <code>OnInitializedAsync</code> <b>en gång</b>.
</p>
<p><a href="renderingslagen/interaktiv">Samma tabell, en rad till →</a></p>
@code {
private IQueryable<Film>? filmer;
private PaginationState sidor = new() { ItemsPerPage = 3 };
private int antal;
protected override async Task OnInitializedAsync()
{
antal++;
Logg.LogWarning("OnInitializedAsync på /renderingslagen/statisk, IsInteractive={Ia}",
RendererInfo.IsInteractive);
filmer = (await Arkivet.HamtaAllaAsync()).AsQueryable();
}
}
Varför det ser ut så. Jämför med AdminFilmer.razor från steg 2. Där behövdes en switch, Skip, Take, länkar och uträkningar av sidnummer. Här är det tre kolumner och en Paginator. QuickGrid ser ut som det självklara valet.
Gå till /renderingslagen/statisk och klicka på Titel. Ingenting. Klicka på nästa sida. Ingenting. Knapparna är riktiga <button>-element. Inspektera rubriken i DevTools: aria-sort="none", och det står kvar vad du än klickar.
Knapparna har ingen att prata med. Allt du byggt i kursen renderas med statisk serverrendering: servern kör komponenten en gång, gör om den till HTML, skickar HTML:en och glömmer bort den. Komponenten finns inte längre när sidan når webbläsaren. QuickGrid sorterar med @onclick, och ett @onclick är C#-kod som ska köras när man klickar – i en komponent som inte längre finns.
Det är därför listvyn i steg 2 använder länkar. En länk gör en ny förfrågan, och servern kör komponenten igen med ?sortera=titel. Det fungerar utan att något hålls vid liv.
Kontrollera. Klicka på rubrikerna och på sidknapparna. Titta i terminalen: OnInitializedAsync loggas en gång när sidan laddas, och aldrig mer.
I ditt projekt. Ingenting. Det här är en demo, och steg 26 förklarar varför ditt projekt stannar där det är.
Steg 26 – En rad till
Vad vi gör. Gör samma tabell interaktiv, och ser vad det kostar.
Filen. Program.cs. Två rader utökas:
// AddInteractiveServerComponents gör det interaktiva läget tillgängligt.
// Det slår inte på det: en komponent utan @rendermode renderas statiskt som
// förut. Demot till lektion 43 ligger på /renderingslagen/.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
Filen. Components/Pages/InteraktivTabell.razor. En kopia av StatiskTabell.razor med en annan adress, en annan logger – och en rad till, direkt under @page:
@page "/renderingslagen/interaktiv"
@rendermode InteractiveServer
@inject IFilmService Arkivet
@inject ILogger<InteraktivTabell> Logg
Resten av filen är densamma, utom texterna. Hela filen ligger i referensprojektet.
Varför det ser ut så. Gå till /renderingslagen/interaktiv och klicka på Titel. Tabellen sorteras. aria-sort="ascending". Nästa sida fungerar: Page 2 of 2. Sidan laddas inte om.
/renderingslagen/statisk | /renderingslagen/interaktiv | |
|---|---|---|
| Tabellen renderas | ja | ja |
| Klick på kolumnrubrik | ingenting | sorterar |
| Nästa sida | ingenting | Page 2 of 2 |
| Uppkoppling | ingen | WebSocket till /_blazor |
OnInitializedAsync körs | 1 gång | 2 gånger |
**@rendermode InteractiveServer** betyder: håll den här komponenten vid liv på servern. Webbläsaren öppnar en uppkoppling som står öppen – en WebSocket, syns i DevTools under Network som /_blazor. Varje klick skickas över den till servern, komponenten kör sin C#-kod, och det som ändrats i HTML:en skickas tillbaka. Uppkopplingen och komponenten på servern kallas tillsammans en krets.
Titta på sista raden i tabellen. Båda sidorna säger har körts 1 gång i det här objektet. Men terminalen loggar två anrop på den interaktiva:
OnInitializedAsync på /renderingslagen/interaktiv, IsInteractive=False
OnInitializedAsync på /renderingslagen/interaktiv, IsInteractive=True
Det är inte samma objekt som körde två gånger. Sidan förrenderas först, statiskt, så att något syns direkt medan uppkopplingen öppnas. När kretsen är klar byggs komponenten om från början, i ett nytt objekt, och det första slängs. Därför står det 1 på skärmen och 2 i terminalen.
Det betyder att allt i livscykeln händer två gånger. En databasfråga i OnInitializedAsync körs två gånger. Det är det första interaktivitet kostar.
Det andra är scope. I område 4 var en scoped tjänst en per förfrågan. En krets är en enda lång förfrågan, som varar så länge fliken är öppen. En scoped tjänst lever alltså lika länge – och det är ett av skälen till att DbContext hämtas ur en fabrik i varje metod, i stället för att injiceras.
Resten av applikationen är opåverkad. AddInteractiveServerComponents gör läget tillgängligt. Det slår inte på det. En komponent utan @rendermode renderas statiskt precis som förut. Kontrollerat efter att demot lagts in: alla andra sidor svarar som förut, och /admin/filmer skickar fortfarande den utloggade till inloggningen.
Så när väljer man vilket?
| Statisk serverrendering | Interaktiv serverrendering |
|---|---|
| Sidor som visar något och formulär som skickas | Sidor där användaren klickar och förväntar sig svar direkt |
| Fungerar utan JavaScript och utan uppkoppling | Kräver en öppen uppkoppling per besökare |
| Servern glömmer besökaren efter varje sida | Servern håller minne för varje öppen flik |
| Livscykeln körs en gång | Livscykeln körs två gånger, med förrendering |
Det är inte att det ena är modernt och det andra gammalt. Det är olika verktyg, och kursen har byggt allt med det första för att du ska förstå vad som händer med varje förfrågan.
Kontrollera. Klicka i båda tabellerna. Öppna DevTools, fliken Network, och ladda om den interaktiva sidan: en rad _blazor med typen websocket. På den statiska finns ingen sådan rad.
I ditt projekt. Ingenting. Ditt projekt stannar på statisk serverrendering, och uppgiften kräver det. Vill du prova, gör det i ett testprojekt eller i en kopia. Det du ska kunna är att förklara skillnaden – det kommer på provet.
---
När något går fel
Slå upp felmeddelandet här innan du fastnar. Skriv en bit av det du ser.
26 rader
| Felmeddelande | Vad det betyder | Gör så här |
|---|---|---|
| Ändringen sparas inte, men sidan svarar som vanligt | = i stället för ??= i OnInitializedAsync | Filmen hämtas om ovanpå formulärets värden. Skriv ??= (steg 3) |
| En ändring blir en ny post | Det dolda fältet med Id saknas | <input type="hidden" name="Film.Id" value="@Film.Id" /> i formuläret |
Cannot parse the value 'abc' as type 'System.Nullable…Int32' | Parametern ur frågesträngen är int? och någon skrev text | Läs den som string? och tolka med int.TryParse (steg 2) |
Tom tabell på ?sida=99 | Sidnumret är inte begränsat | Math.Clamp(onskad, 1, antalSidor) |
| En POST svarar 400 | Antiforgery-token saknas | EditForm eller <AntiforgeryToken />, aldrig en <form> utan |
SQLite Error 19: 'FOREIGN KEY constraint failed' vid database update | Befintliga rader fick 0 som främmande nyckel | HasDefaultValue(1) på nyckeln i OnModelCreating, sedan en ny migration (steg 6) |
SQLite Error 19: 'FOREIGN KEY constraint failed' när en post sparas | GenreId pekar på en kategori som inte finns | Kontrollera rullistans värden. Det är databasen som håller – bra |
| Välj en genre. på varje försök | Formuläret har inget fält för genre, och 0 skickas | InputSelect med @bind-Value="Film.GenreId" (steg 9) |
| Genren syns inte, inget fel | Include saknas | Använd HamtaMedGenreAsync, inte HamtaAsync (steg 7) |
| Kategorisidan säger 0 poster | Include saknas åt andra hållet | Include(g => g.Filmer) (steg 8) |
| En kategori som inte finns svarar 200 | NotFound() saknas | Navigering.NotFound() när tjänsten svarar null, som på detaljsidan i område 3 |
The entity type 'IdentityPasskeyData' requires a primary key to be defined | base.OnModelCreating(byggare) saknas | Lägg raden först i OnModelCreating (steg 17) |
Unable to create a 'DbContext' of type 'FilmarkivetContext' vid dotnet ef | Samma sak, sett från verktyget | Läs resten av meddelandet. Oftast raden ovan |
There is no registered service of type 'SignInManager…' | .AddSignInManager() saknas | Lägg den sist i kedjan efter AddIdentityCore (steg 18) |
Authorization requires a cascading parameter of type Task<AuthenticationState> | AddCascadingAuthenticationState saknas | builder.Services.AddCascadingAuthenticationState(); |
RZ10012: Found markup element with unexpected name 'AuthorizeView' | @using saknas | @using Microsoft.AspNetCore.Components.Authorization i _Imports.razor |
Utloggad hamnar på /Account/Login och får 404 | ConfigureApplicationCookie saknas | LoginPath = "/konto/logga-in" (steg 18) |
| Utloggad kommer åt adminsidan | Bara menylänken är dold | @attribute [Microsoft.AspNetCore.Authorization.Authorize] på sidan (steg 22) |
| Utloggningen går igenom utan token | Endpointen kontrollerar inte token | IAntiforgery.IsRequestValidAsync i endpointen (steg 23) |
| Fel e-post eller lösenord. med rätt uppgifter | Användaren skapades inte, eller lösenordet bröt mot reglerna | CreateAsync misslyckas tyst om lösenordet är för kort. Minst åtta tecken |
| Kontot är låst en stund | Fem misslyckade försök | Vänta fem minuter, eller radera LockoutEnd i AspNetUsers |
| En titel som redan finns går att spara med annan stavning | Jämförelsen är exakt | Så fungerar den. Står i XML-kommentaren (steg 16) |
An attribute argument must be a constant expression | Något som räknas ut i ett attributs argument | Skriv ett eget attribut (steg 14) |
| Regeln över två fält visas inte | Ett attributfel stoppade den | Validate körs bara när attributen gått igenom. Rätta det första felet först |
Knapparna i QuickGrid gör ingenting | Statisk serverrendering | Det är demot i steg 25. Använd länkar i frågesträngen |
OnInitializedAsync körs två gånger | Förrendering i interaktivt läge | Förväntat. Se steg 26 |
Det som är lätt att missa när du gör det själv
**Din främmande nyckel måste heta som navigationsegenskapen plus Id.** KategoriId och Kategori. Heter den KatId förstår EF Core inte att de hör ihop, och du får en extra kolumn i stället för en relation.
Din egen rad i databasen gör migrationen svår. Glöm inte HasDefaultValue – och se till att id:t du anger finns i kategoriernas frödata.
**Include är en beställning, inte en inställning.** Det finns inget sätt att säga alltid. Varje fråga som behöver relaterad data ska be om den, och glömmer den det blir det tyst fel, inte ett felmeddelande.
Sorteringsfältet i adressen är ditt eget ord. ?sortera=titel i Filmarkivet, ?sortera=namn eller ?sortera=pris hos dig. Det är ett värde i en switch, och det du inte tar med i switch får standardordningen.
Svenska tecken i ditt sökfält är ett beslut. Gör försöket i steg 12 med din egen data och skriv ned vad du valde. Att inte ha märkt problemet är något annat än att ha valt bort det.
Din regel över två fält och din regel mot databasen ska vara dina. Kopierar du tillgänglig och inte utkommen och titeln finns redan har du översatt namnen, inte regeln. Det är översättningen av regeln som visar att du förstått.
Skydda sidorna, inte menyn. Gå igenom varje adress utloggad, också de du inte har länkar till.
**Inloggningsuppgifterna ska stå i README.md.** Den som bedömer ditt projekt kan inte gissa ditt lösenord.
Behåll statisk serverrendering. @rendermode hör hemma i ett testprojekt, inte i Projekt 1.
Checklista
Guidens egen checklista. Den är inte betygskraven – de står i uppgiften och är uppdelade på E, C och A. Den här listan säger bara om du gjort klart det guiden går igenom.
Klart när allt är ikryssat
Kryssen sparas i din webbläsare.
Vill du mer
- Relationships – hur EF Core känner igen en relation ur namnen, och hur man beskriver den när namnen inte räcker.
- Cascade Delete – vad som händer med de relaterade raderna, och hur
DeleteBehaviorändrar det. - Eager Loading of Related Data –
Include, och varför navigationsegenskaper är tomma utan den. - How Queries Work – när en
IQueryableblir SQL, och vad som körs i databasen respektive i C#. - Simple Logging – fler sätt att se den SQL som genereras.
- Blazor forms validation – egna attribut,
IValidatableObjectochValidationMessageStore. - Introduction to Identity on ASP.NET Core – vad Identity gör, och vilka tabeller det skapar.
- ASP.NET Core Blazor authentication and authorization –
AuthorizeView,[Authorize]och skillnaden mellan statisk och interaktiv rendering. - ASP.NET Core Blazor render modes – statisk och interaktiv serverrendering, förrendering, och när man väljer vad.
- C# identifier naming rules and conventions – hela listan som tabellen i steg 24 är ett utdrag ur.
- XML documentation comments – taggarna som går att använda, och var de syns.