ASP.NET Core Razor Pages Notes


  1. 說明
    1. middleware
    2. PageModel
    3. TagHelper
    4. Client Side Library
    5. Section
    6. Dependency Injection
    7. Repository Pattern
    8. Html Helper
    9. Routing
    10. Environment Variables
    11. Data Binding
    12. Data Annotations
    13. Partial View
    14. ViewComponent
  2. EntityFramework
    1. 使用 EFCore 開發的順序
    2. 後續的開發作業
    3. One to Many
    4. Many to Many
  3. Hosting WebApplication

筆記 ASP.NET Core 開發相關知識點。

logo

說明

Model

Page (F7) PageMolde

  • single file (page code + code section)
  • code behind (index.cshtml + index.cshtml.cs)

middleware

app.UseHttpRedirection()
app.UseStaticFiles()
app.UseRouting()
app.UseAuthorization()
app.MapRazorPages()

PageModel

Handler Method

public class IndexModel : PageModel
{
    public IndexModel(MyRazorPages.Data.MyRazorPagesContext context, ILogger<IndexModel> logger)
    {
        _context = context;
        _logger = logger;
    }

    private readonly ILogger<IndexModel> _logger;

    public IList<Movie> Movie { get;set; } = default!;

    [BindProperty(SupportsGet = true)]
    public string? SearchString { get; set; }

    public async Task OnGetAsync()
    {
        ...
    }
}

TagHelper

_ViewImports.cshtml

@using MyRazorPages
@namespace MyRazorPages.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

Client Side Library

Section

<footer>
    @RenderSection("Footer", required: false)
</footer>
@RenderSection("Scripts", required: false)
@section Footer {
    <p>© 2024 - My Application</p>
}

@section Scripts {
    <script src="~/js/custom-scripts.js"></script>
}

Dependency Injection

vs MediatR ContosoUniversityDotNetCore-Pages

AddScoped
每個 HTTP 請求一個實例
每個請求一個實例
用於跨多個操作保持狀態數據
資料庫上下文(DbContext)

AddTransient
每次注入時創建新實例
每次請求一個新實例
用於無狀態服務或輕量級服務
短暫的輕量級服務,例如隨機數生成器

AddSingleton
應用程式生命周期內單個實例 (應用啟動時創建一次,後續重用)
應用程式內單個實例
用於耗費資源的共享服務或單一狀態數據
設定、記錄器(Logger)等

Repository Pattern

Models - Class Library

Service - Class Library
Interface of IRepository and Repository Implementation
ref to Models

RazorPage
ref to Models, Service

Html Helper

Display and Editor templates

Routing

@page "Index/{title?}"

<h1>@RouteData.Valeus["title"]</h1>

asp-route-id Anchor Tag Helper

asp-page

Environment Variables

藏的非常深,專案屬性、偵錯、開啟 debug 啟動設定檔 UI

Data Binding

[BindProperty(SupportsGet = true)]
public string? SearchString { get; set; }

[BindProperty(SupportsGet = true)]
public string? MovieGenre { get; set; }

透過 BindProperty 不需要再 OnPostAsync 當中提供參數 😊

[BindProperty]
public Movie Movie { get; set; } = default!;

// To protect from overposting attacks, see https://aka.ms/RazorPagesCRUD
public async Task<IActionResult> OnPostAsync()
{
    if (!ModelState.IsValid)
    {
        return Page();
    }

    _context.Movie.Add(Movie);
    await _context.SaveChangesAsync();

    return RedirectToPage("./Index");
}

Data Annotations

asp-validation-for

ModelState.IsValid

如果在前端驗證需要使用 jquery, jquery-validation, jquery-validation-unobtrusive

除了使用 Data Annotations 來驗證外,還可以使用 PageRemote 讓不需要送出完整表單的情況下,透過 ajax 動態地向伺服器端進行驗證 (使用情境例如驗證新建帳號或使用者名稱是否重複)

[BindProperty, PageRemote(
    ErrorMessage = "Name already exists.", AdditionalFields = "__RequestVerificationToken",
    HttpMethod = "post", PageHandler = "ValidateName")]
public string Name{ get; set; } = default!;

public JsonResult OnGetValidateName(string name)
{
    // Simulate checking for existing name
    var existingNames = new[] { "Alice", "Bob", "Charlie" };
    return new JsonResult(!existingNames.Contains(name));
}

同樣地也可以使用 ASP.NET MVC 時期就能夠使用的方法,直接擴充 ValidationAttribute

using System;
using System.ComponentModel.DataAnnotations;

public class DateAfter1990Attribute : ValidationAttribute
{
    public DateAfter1990Attribute()
    {
        ErrorMessage = "The date must be after January 1, 1990.";
    }

    protected override ValidationResult IsValid(object value, ValidationContext validationContext)
    {
        if (value is DateTime date)
        {
            if (date > new DateTime(1990, 1, 1))
            {
                return ValidationResult.Success;
            }
            else
            {
                return new ValidationResult(ErrorMessage);
            }
        }

        return new ValidationResult("Invalid date format");
    }
}

直接加入在 Model 上。

[BindProperty]
[DateAfter1990]
[DataType(DataType.Date)]
public DateTime DateOfBirth { get; set; }

Partial View

Partial View 可以插入在其他頁面當中與普通的 Razoe Page 差別在於沒有 @page

如果使用 Partiavl View

@await Html.RenderPartialAsync("_PartialViewName")

Html.RenderPartialAsyncHtml.PartialAsync 的比較

Html.RenderPartialAsync 此方法會直接將 HTML 加入到網頁,效能略好。

Html.PartialAsync 則需要搭配 <partial> 或者是 @ 來使用。

<partial name="_PartialViewName" model="Model" />

ViewComponent

相較於 Partial View,ViewComponent 的可以賦予更多的商業邏輯,彷如獨立的 Controller Action。

ViewComponent 與 Html.Action 搭配 PartialView 的方式相比,減少產生額外的 HTTP Request 效能較佳,也更為適合重複使用。

具體可以應用方式如下:

  1. 最新新聞 (Latest News):顯示網站上最新的新聞或博客文章,從資料庫中動態獲取數據並渲染到頁面上。
  2. 頁腳資訊 (Footer Information):顯示網站頁腳的靜態資訊,例如聯絡方式、版權聲明和社交媒體鏈接。
  3. 側邊欄廣告 (Sidebar Advertisements):顯示動態的廣告內容,可以根據用戶的偏好或瀏覽歷史來決定顯示哪些廣告。
  4. 用戶通知 (User Notifications):顯示用戶的個人通知,例如新的消息、提醒或系統公告。
  5. 熱門商品 (Popular Products):顯示電商網站上最熱門或最暢銷的商品列表,根據銷量或用戶評分動態更新。
  6. 評論區 (Comments Section):顯示文章或產品的評論區,包含用戶評論的加載和顯示,並支持新評論的提交。
  7. 活動日曆 (Event Calendar):顯示即將到來的活動或事件,從資料庫中獲取活動數據並動態渲染到頁面上。
  8. 頁面導航 (Page Navigation):顯示頁面的導航欄,根據用戶的角色或權限動態生成不同的導航選項。
  9. 搜索建議 (Search Suggestions):根據用戶輸入的搜索詞動態顯示搜索建議或歷史搜索記錄。
  10. 近期活動 (Recent Activities):顯示用戶最近的活動記錄,例如最近瀏覽的商品、最近發布的評論或最近參加的活動。

🐻 Define ViewComponent:

public class LatestNewsViewComponent : ViewComponent
{
    private readonly AppDbContext _context;

    public LatestNewsViewComponent(AppDbContext context)
    {
        _context = context;
    }

    public async Task<IViewComponentResult> InvokeAsync(int count)
    {
        var newsItems = await _context.NewsItems
                                      .OrderByDescending(n => n.PublishedDate)
                                      .Take(count)
                                      .ToListAsync();

        return View(newsItems);
    }
}

🐻 Define ViewComponet’s View:

@model IEnumerable<NewsItem>

<div class="latest-news">
    <h2>Latest News</h2>
    <ul>
        @foreach (var news in Model)
        {
            <li>
                <h3>@news.Title</h3>
                <p>@news.Summary</p>
                <small>@news.PublishedDate.ToString("MMMM dd, yyyy")</small>
            </li>
        }
    </ul>
</div>

🐻 Using in View.

<vc:latest-news count="5" />

EntityFramework

Service - Class Library
install: EntityFrameworkCore.SqlServer
install: EntityFrameworkCore.Tools

RazorPage
ref to Models, Service
install: EntityFrameworkCore.Design

使用 EFCore 開發的順序

  • 設計模型 (/Models)
  • 建立 DBContext (Page Scaffold 可以協助建立在 /Data)
  • 設定連線字串在 appsettings.json (Scaffold 會自動設定)
  • 安裝 EntityFramework Core (Scaffold 會自動安裝 😊)
  • 建立 Migration
  • 更新資料庫
add-migratino inital
update-database

後續的開發作業

⭐一對多的模型示範

public class Grade
{
    public int GradeId { get; set; }
    public string GradeName { get; set; }

    public ICollection<Student>? Students { get; set; }
}

public class Student
{
    public int Id { get; set; }
    public string Name { get; set; }

    public int GradeId { get; set; }
    public Grade? Grade { get; set; }
}

需要注意一對多的模型之間的 Navigation Property 要設定為 Nullable,否則在 Create 會被 ModelSatte 阻擋。

⭐設計 SeedData

SeedData.cs

public class SeedData
{
    public static void Initialize(IServiceProvider serviceProvider)
    {
        using (var context = new MyRazorPagesContext(
            serviceProvider.GetRequiredService<
                DbContextOptions<MyRazorPagesContext>>()))
        {
            if (context == null || context.Grade == null || context.Student == null)
            {
                throw new ArgumentNullException("Null RazorPagesMovieContext");
            }

            // Look for any movies.
            if (context.Grade.Any() || context.Student.Any())
            {
                return;
            }

            context.Grade.AddRange(
                // data for the Grade table
                new Grade
                {
                    GradeName = "A",
                },
                new Grade
                {
                    GradeName = "B",
                },
                new Grade
                {
                    GradeName = "C",
                }
            );
            context.SaveChanges();

            context.Student.AddRange(
                new Student
                {
                    Name = "Webber",
                    Grade = context.Grade.Single(g => g.GradeName == "A"),
                }
            );

            context.SaveChanges();
        }
    }
}

Program.cs

var app = builder.Build();

using (var scope = app.Services.CreateScope())
{
    var services = scope.ServiceProvider;
    SeedData.Initialize(services);
}
  • 擴充欄位、增加 Data Annotations
  • 新增資料表後調整 DBContext
  • 反覆增加 Migration 以及更新資料庫
add-migration syncDBSchema –IgnoreChanges

🐸 Repository Patterin in Service

🐸 Dependency Inject Service into RazorPage project

One to Many

public class Author
{
    public int AuthorId { get; set; }
    public string Name { get; set; }
    public ICollection<Book>? Books { get; set; }
}

public class Book
{
    public int BookId { get; set; }
    public string Title { get; set; }
    public int AuthorId { get; set; }
    public Author? Author { get; set; }
}

Author 的 ICollection Book 設定為 Nullable,否則在建立 Author 的時候必須一併建立 Book。

Book 的 Author 同樣設定為 Nullable,而 AuthorId 是必須的,EF Core 在建立物件後取得物件時,會自行關聯 AuthorId 以及 Author。

加入模型後,接著 add-migration 以及 update-database

處理資料的邏輯預設不會處理關聯項目,需要自行調整,以下的邏輯是如果關聯項目是新增的項目 (id 為 0) 就加入,否則就修改,但不會進行刪除。

if (customer.Orders != null)
{ 
    foreach (var order in customer.Orders)
    {
        if (order.OrderId == 0)
        {
            _context.Orders.Add(order);
        }
        else
        {
            _context.Entry(order).State = EntityState.Modified;
        }
    }
}

而在處理資料的邏輯,有時候會碰到 "A possible object cycle was detected. This can either be due to a cycle or if the object depth is larger than the maximum allowed depth of 32"

可以藉由在 program.cs 加入以下來避免錯誤。

builder.Services.AddControllers().AddJsonOptions(options =>
{
    //options.JsonSerializerOptions.ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.Preserve;
    options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
});

再回到 Index.cshtml 以及 Index.cshtml.cs 進行設計。

Index.cshtml

<div class="row row-cols-1 row-cols-md-6 g-4">
    @foreach (var book in Model.Book)
    {
        <div class="col">
            <div class="card h-100">
                <div class="card-body">
                    <h6 class="card-title">@book.Title</h6>
                    <p class="card-text">@book.Author.Name</p>
                    <img src="https://via.placeholder.com/300/110055?text=Book+Cover" class="card-img-top img-fluid">
                </div>
                <div class="p-2 text-end">
                    <a href="@Url.Page("./Edit", new { id = book.BookId })" class="btn btn-primary">Edit</a>
                </div>
            </div>
        </div>
    }
</div>

以上的設計使用 Bootstrap5 Card Grid 簡易排版,並且對齊按鈕以及使用 placeholder 來示意圖片。

Many to Many

Using Skip Navigations Many to Many Relationship

public class Course
{
    public int Id { get; set; }
    public string? Title { get; set; }
    public ICollection<Student> Students { get; set; } = new List<Student>();
}

public class Student
{
    public int Id { get; set; }
    public string Name { get; set; }
    public ICollection<Course> Courses { get; set; } = new List<Course>();
}

DBContext 只需要最簡單的 DbSet 不需要額外的 FluentAPI:

public DbSet<Models.Student> Student { get; set; } = default!;
public DbSet<Models.Course> Course { get; set; } = default!;

Controller 的部分,要同時考量 Edit Entity 本體的 Property 並且只對 Join Table 操作,要避免 OverPost 操作的另一端的 Entity。

所以要註解原本 Scaffold 提供的方法:

_context.Entry(student).State = EntityState.Modified;

改為以下的方式

if (student.Courses != null)
{
    existingStudent.Courses = new List<Course> { };
    foreach (var course in student.Courses)
    {
        var existingCourse = await _context.Course.FindAsync(course.Id);
        if (existingCourse != null)
        {
            existingStudent.Courses.Add(existingCourse);
        }
    }
}

雖然有順利地加入資料,但要刪除卻頭痛不已,使用 Courses.Clear 再加入新的 Course 達到 Put 的效果卻發生 Entity Tracking 已存在的問題。

最後還是改採直接加入 Join Table 的 Model 設計方式:

public class Student
{
    public int StudentId { get; set; }
    public string Name { get; set; }
    public ICollection<StudentCourse>? StudentCourses { get; set; }
}

public class Course
{
    public int CourseId { get; set; }
    public string Title { get; set; }
    public ICollection<StudentCourse>? StudentCourses { get; set; }
}


public class StudentCourse
{
    public int StudentId { get; set; }
    public Student? Student { get; set; }
    public int CourseId { get; set; }
    public Course? Course { get; set; }
    public string Grade { get; set; }
}

作業的邏輯則是如此:

將輸入的內容做為 DTO,比對 id 取得實際在資料庫當中的資料,先清空關聯項目,再重新新增。

var existingStudent = await _context.Student.Include(s => s.StudentCourses).FirstOrDefaultAsync(s => s.StudentId == id);

existingStudent.StudentCourses = new List<StudentCourse> {  };

if (student.StudentCourses != null)
{
    foreach (var course in student.StudentCourses)
    {
        existingStudent.StudentCourses.Add(new StudentCourse
        {
            StudentId = id,
            CourseId = course.CourseId,
            Grade = course.Grade
        });
    }
}

雖然在 Model 以及 Controller 的程式碼都囉嗦了些,但使用上更為明確也可以在 Join Table 擴充屬性。

Hosting WebApplication

In-Process Hosting Model 要在 IIS 部署 .NET (Razor Pages) 必須要先在伺服器上安裝 Hosting Bundle

接著在站台管理將 Razor Pages 建立為應用程式即可。

也可以使用 Out-Of-Process Hosting Model,透過 IIS 反向代理的方式,實際由 Kestrel 來提供網站服務,但效能較 In-Prcoess 差。

如果要進行設定,需要在專案檔當中進行 AspNetCoreHostingModel 設定。

<PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <AspNetCoreHostingModel>OutOfProcess</AspNetCoreHostingModel>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>