前面的章节中,我们已经学习了开发 Web 应用的准备知识和部分常用的技术,应该可以应付开发任务了。Web 应用开发中数据库的相关知识,我们边做边学,把数据操作尽量转换成 LINQ ,用我们熟悉的 C# 知识来解决数据库相关问题。

我们准备的实战开发应用是一个电子书在线商店,完成后就可以作为真正的应用,但是把这个应用商业化,可能还需要更多细节上的打磨。
我们把这个实战开发主要作为一个整体的系统性练习,让自己能掌握尽量多的应用开发流程和尽可能多的细节。希望我们做完这个练习后,成为 C# 和 .NET 的准高手。

在开发过程中,为了练习和演示方便,我们会尽量简化单元测试。如果是开发商业软件,过程中越早整合单元测试越好。我们会在其他章节完整地学习单元测试。

在用 VS Code 打开项目之前,请先根据前一章地内容,添加必要地插件。

开始规定动作

在 VS Code 中选择 文件 > 打开文件夹,选择打开 EBooksSln 文件夹,再 VS Code 提示对话框选择打开 EBooksSln 解决方案。
修改 EBooksStore/Properties 文件夹里 launchSettings.json 文件的 HTTP 端口为 5000 。
添加应用必要的组件文件夹,请见列表:

文件夹名说明
Models数据模型文件夹
Controllers存放控制器类处理 HTTP 请求
Views存放 Razor 视图
Views/Home存放主页视图
Views/Shared给所有控制器分享的公用视图

我们在 EBooksStore 文件夹里新建所有以上的文件夹。

打开 EBooksStore 文件夹里的 Program.cs,添加 MVC 框架。
Listing 7-1 更新 Program.cs

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllersWithViews();

var app = builder.Build();

// app.MapGet("/", () => "Hello World!");

app.UseStaticFiles();
app.MapDefaultControllerRoute();

app.Run();

我们先让应用支持基本 MVC 框架和静态 Web 文件内容。

配置 Razor 视图

真实应用需要更有逻辑性的目录结构,我们开始构建视图目录结构,原则是存放位置符合日常逻辑,避免重复代码。
我们先在EBooksStore/Views文件夹里添加一个共享的引用视图文件 _ViewImports.cshtml,其他的视图都可以从这个文件引用必要的内容。 Listing 7-2 Veiws 文件夹的 _ViewImports.cshtml 内容

@using EBooksStore.Models
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

视图(View)都会引用数据模型(Models),因此我们添加 @using 语句。
TagHelper 添加这个引用是因为我们会频繁地使用 ASP.NET 内建的标签帮助功能。

接下来我们要增加一个视图的入口文件,告诉系统所有的视图都从这里开始。
Listing 7-3 Views 文件夹里新建入口文件 _ViewStart.cshtml

@{
  Layout = "_Layout";
}

Razor 文件的标识是 .cshtml ,很好地表示了 Razor 视图是一个 C# 语言和 HTML 的混合体。按照 Razor 的规则,对于非 HTML 的元素,比如: C# 的变量,语句块,我们必须在前面加 @ ,告诉编译器这段东西是 C# 而不是 HTML。

视图入口文件表示所有的视图需要用到一个 cshtml 文件 _Layout.cshtml,用于网页的基本布局。 。安装框架的约定,我们要把这个文件放在 Views/Shared 文件夹里。
Listing 7-4 Views/Shared 文件夹里的 _Layout.cshtml

<!DOCTYPE html>
<html>
    <head>
        <meta name="viewport" content="width=device-width" />
        <title>电子图书电商网站</title>
    </head>
    <body>
        <div>
            @RenderBody()
        </div>
    </body>
</html>

请注意 <body></body> 标签里的 @RenderBody() 表示渲染主体;我们的其他视图都会在这个 C# 方法的位置渲染各自的内容,而这个 _Layout 的 HTML 语句就渲染整个布局。

创建控制器和视图

按照框架约定,应用的客户端主页控制器是 HomeController.cs 的 Index 方法。
Listing 7-5 在 EBooksStore 项目的 Controllers 文件夹里创建 HomeController.cs 和它的 Index 方法

using Microsoft.AspNetCore.Mvc;

namespace EBooksStore.Controllers;

public class HomeController : Controller
{
    public IActionResult Index() => View();
}

Listing 7-6 在 Views/Home 文件夹里新建 Index.cshtml

<h2>欢迎光临逻辑电子书店!</h2>

添加数据模型

因为我们的电子商务网站主业是销售电子图书的,所以我们先构想一下现实的书店中的书需要哪些数据,试着构造一个 EBook 类。
Listing 7-6 在 Models 文件夹里新建一个 Ebook.cs 文件,内容如下

namespace EBooksStore.Models;

public class EBook
{
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public string Author { get; set; } = string.Empty;
    public string Description { get; set; } = string.Empty;
    public string Category { get; set; } = string.Empty;
    public decimal Price { get; set; }
}

添加数据

前面的章节,我们都是用静态数据,实战练习当然要用数据库了。如果我们的开发环境是 Windows ,当然用 SQL Server LocalDB 是比较好的选择,安装方便,设置简单。因为我们这个实战环境是可以跨平台开发的,所以有可能是 Linux 或者 MacOS,所以这里我们选一个安装稍微复杂一点,设置稍微繁琐一点的数据库环境。我们直接用 Docker 的 MS SQL Server Container 作为我们的数据库服务器;这样做更加接近实际可用的数据库环境,非常类似 Azure,AWS 的数据库。

用 Docker 安装 SQL Server Linux Container 镜像

  • 进入 Docker 官网 ,安装对应平台的 Docker (Docker Desktop) App ,你应该需要注册一个 docker 账号
  • 检查 Docker 版本,在终端窗口输入 docker --version,如果显示 docker 版本信息,那么 docker 安装成功了
  • 在终端输入 docker pull mcr.microsoft.com/mssql/server:2022-latest ,把最新版 sql-server 镜像拉到本地 docker 环境
  • 在本地 docker 环境设置和运行 sql-server
docker run -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=<password>" -p 1433:1433 --name sql1 --hostname sql1 -d mcr.microsoft.com/mssql/server:2022-latest

注意要设置复杂的密码。因为我们是开发环境,所以要有这条选项 "ACCEPT_EULA=Y",实际商用软件,我们需要购买sql-server,或者 Azure 数据库。
请记住,这个数据库的系统管理员是 sa
在 Windows 里,docker 会安装 Docker Desktop ,我们可以在这个应用的 GUI 里查看容器的状态,如果 sql-server 没有启动,点击右边的 Actions 里的右向三角形以启动数据库。见 Figure-7-1 :

Docker里的msslq容器

Figure-7-1

在 Windows 环境里,如果 Docker Desktop 没有设置随着系统启动,要手动启动,再启动里面的 ms SqlServer ,才是重新运行 SQL 数据库服务器。(如果是 Local SQL 一般无须手动启动)

安装 Entity Framework Core 包

我们会用 Entity Framework Core ,微软的对象对关系数据映射框架(ORM),来访问 docker 上的数据库。首先,我们要安装 Entity Framework Core 包:
Listing 7-7 在 EBooksStore 的文件夹位置打开 PowerShell,运行下列命令

dotnet add package Microsoft.EntityFrameworkCore.Design --version 9.0.0
dotnet add package Microsoft.EntityFrameworkCore.SqlServer --version 9.0.0

有这两个软件包后,我们的应用就可以支持 SQL Server 了,但是 Entity Framework Core 还需要一个命令行工具来操作数据库。运行以下命令,全局安装 dontnet-ef 工具。

dotnet tool uninstall --global dotnet-ef
dotnet tool install --global dontnet-ef

写这篇文档时,安装的 dotnet-ef 版本是 9.0.0 。

在 Json 文件里定义连接字符串

修改 EBooksStore 文件夹里的 appsettings.json 文件,添加数据库连接字符串。
Listing 7-8 在 appsettings.json 里添加连接字符串

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "EBooksStoreConnection": "Server=.,1433;Database=EBooksStore;User Id=sa;Password=你设置的密码;MultipleActiveResultSets=false;TrustServerCertificate=true"
  }
}

localhost的SQL Server 可以简写为 .,1433 ,默认是 1433 端口。最后一个选项 MultipleActiveResultSets=false 是因为是开发环境,不必要为了一点性能,牺牲数据记录的可读性。

创建数据库上下文类

Entity Framework Core 通过上下文类访问数据库,正确设置上下文类是关键的一步。
Listing 7-9 在 EBooksStore/Models 文件夹里添加一个新文件 DbContext.cs

using Microsoft.EntityFrameworkCore;

namespace EBooksStore.Models;

public class StoreDbContext : DbContext 
{
    public StoreDbContext(DbContextOptions<StoreDbContext> options) : base(options)
    {
    }

    public DbSet<EBook> EBooks => Set<EBook>();
}

按照一般约定,我们的上下文类继承父类后,无须添加任何初始化代码,但是必须要有一个 DbSet<Ebook> 映射数据库表 EBooks ,可以让上下文访问对应的数据表。

在主程序中配置 Entity Framework Core

IConfiguration 接口提供访问 ASP.NET Core 的配置系统,配置系统里当然包括 appsettings.json 里的配置。主程序 Program.cs 通过 builder 的 Configuration 属性访问配置,其中就包括数据库上下文配置。Entity Framework Core 通过 AddDbContext 方法注册数据库上下文类和相关配置。请记住,所有配置等对象都需要在 builder 里注册后,才能被使用。
Listing 7-10 修改 Program.cs 文件,添加数据库配置注册和使用

using EBooksStore.Models;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllersWithViews();

builder.Services.AddDbContext<StoreDbContext>(options => {
    options.UseSqlServer(
        builder.Configuration["ConnectionStrings:EBooksStoreConnection"]);
});

var app = builder.Build();

// app.MapGet("/", () => "Hello World!");

app.UseStaticFiles();
app.MapDefaultControllerRoute();

app.Run();

UseSqlServer 方法声明是要连接 SQL Server 数据库。

数据库迁移

Entity Framework Core 可以通过迁移功能,使用数据模型类为数据库维护(包括生成)数据表和其他相关数据。Entity Framework Core 会根据我们的数据模型和上下文设置,创建一个 C# 类,其中包含准备数据库所需的 SQL 命令。如果我们需要修改数据库,比如修改数据库结构,我们可以创建一个新的迁移,告诉 Entity Framework Core 生成包含反映更改所需的 SQL 命令。这样,我们可以避开 SQL 命令(假设我们对 SQL 语言不在行,况且我们比较是在做 Web 应用开发,主要关注 C# 的应用逻辑),而只需关注应用程序中的 C# 模型类。我们甚至保留了迁移历史,必要时可以控制和使用应用数据库结构版本。
反之,如果我们对 SQL 查询语言在行,也可以反过来做,叫数据库优先。先用 SQL 查询语言手动设计好数据库结构,再 scaffold 到对应的数据模型类。
我们更倾向于第一种设计数据库的方法,因为对 SQL 不是很在行。LOL!

在EBooksStore 文件夹打开 PowerShell 输入迁移初始化命令:

dotnet ef migrations add Initial

初始化命令会在项目文件夹生成一个 Migrations 文件夹,用于存放迁移数据类,文件名是时间+_Inital.cs ,表示这些文件是 Initial 迁移操作生成的,这些类会被用来创建服务器上的数据库的数据表结构。

填充种子数据

在运行生成数据表之前,我们再准备一个静态类用于给数据表填充种子数据,我们希望生成数据库结构后,马上可以试运行 Web 应用。
Listing 7-11 在 EBooksStore/Models 文件夹里新建一个种子数据文件 Seeds.cs

using Microsoft.EntityFrameworkCore;

namespace EBooksStore.Models;

public static class Seeds
{
    public static void InitPopulate(IApplicationBuilder app)
    {
        StoreDbContext context = app.ApplicationServices
            .CreateScope().ServiceProvider.GetRequiredService<StoreDbContext>();

        // if there are pending migrations, apply them
        if(context.Database.GetPendingMigrations().Any())
        {
            context.Database.Migrate(); 
        }

        // if there are no EBooks, add some
        if(!context.EBooks.Any())
        {
            context.EBooks.AddRange(
                new EBook
                {
                    Title = "The Great Gatsby",
                    Author = "F. Scott Fitzgerald",
                    Description = "The story of the mysteriously wealthy Jay Gatsby and his love for the beautiful Daisy Buchanan.",
                    Category = "Fiction",
                    Price = 7.99M
                },
                new EBook
                {
                    Title = "The Grapes of Wrath",
                    Author = "John Steinbeck",
                    Description = "The story of the Joad family and their migration to California from Oklahoma during the Great Depression.",
                    Category = "Fiction",
                    Price = 9.99M
                },
                new EBook
                {
                    Title = "1984",
                    Author = "George Orwell",
                    Description = "A dystopian novel set in a totalitarian society.",
                    Category = "Fiction",
                    Price = 6.99M
                },
                new EBook
                {
                    Title = "The Catcher in the Rye",
                    Author = "J.D. Salinger",
                    Description = "The story of Holden Caulfield, a teenager who struggles with the phoniness of the adult world.",
                    Category = "Fiction",
                    Price = 8.99M
                },
                new EBook
                {
                    Title = "To Kill a Mockingbird",
                    Author = "Harper Lee",
                    Description = "The story of Atticus Finch, a lawyer in the Depression-era South, defending justice and racial equality.",
                    Category = "Fiction",
                    Price = 10.99M
                },
                new EBook
                {
                    Title = "Pride and Prejudice",
                    Author = "Jane Austen",
                    Description = "The story of Elizabeth Bennet and her relationship with the wealthy Mr. Darcy.",
                    Category = "Fiction",
                    Price = 5.99M
                },
                new EBook
                {
                    Title = "The Hobbit",
                    Author = "J.R.R. Tolkien",
                    Description = "The story of Bilbo Baggins and his adventures with a group of dwarves to reclaim their homeland from the dragon Smaug.",
                    Category = "Fiction",
                    Price = 11.99M
                },
                new EBook
                {
                    Title = "The Lord of the Rings",
                    Author = "J.R.R. Tolkien",
                    Description = "The story of Frodo Baggins and his quest to destroy the One Ring.",
                    Category = "Fiction",
                    Price = 14.99M
                },
                new EBook
                {
                    Title = "The double helix",
                    Author = "James D. Watson",
                    Description = "The story of the discovery of the structure of DNA.",
                    Category = "Non-Fiction",
                    Price = 12.99M
                },
                new EBook
                {
                    Title = "The Selfish Gene",
                    Author = "Richard Dawkins",
                    Description = "The story of the gene-centered view of evolution.",
                    Category = "Non-Fiction",
                    Price = 13.99M
                },
                new EBook
                {
                    Title = "A Brief History of Time",
                    Author = "Stephen Hawking",
                    Description = "The story of the universe from the Big Bang to black holes.",
                    Category = "Non-Fiction",
                    Price = 15.99M
                }
            );

            context.SaveChanges();
        }
    }
}

这个静态方法用 IApplicationBuilder 作为参数,用于在 Program.cs 里注册中间件去处理 HTTP 请求,也可以提供对 Entity Framework Core 的数据库上下文的访问。
最后要用 SaveChanges 方法实现数据库交易,保存数据。
Listing 7-12 在 Program.cs 里加入填充种子语句

using EBooksStore.Models;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllersWithViews();

builder.Services.AddDbContext<StoreDbContext>(options => {
    options.UseSqlServer(
        builder.Configuration["ConnectionStrings:EBooksStoreConnection"]);
});

var app = builder.Build();

// app.MapGet("/", () => "Hello World!");

app.UseStaticFiles();
app.MapDefaultControllerRoute();

Seeds.InitPopulate(app);

app.Run();

急救包 如果数据库操作失误,可以在 EBooksStore 文件夹里重置数据库:

donnet ef database drop --force --context StoreDbContext

一般还要删除 EBooksStore 文件夹里的子文件夹 Migrations ,再运行一次初始化迁移命令。 再重新 dotnet run 运行应用,会重新创建数据表并填充数据。 这样数据库就可以恢复到最初的样子。

提示 如果不想要初始数据,只要构建数据库结构,那么运行初始化迁移命令后,可以运行:

dotnet ef database update

这样就只是创建数据库和数据表,数据表中没有数据。

显示数据

现在我们要开始有意思一点的部分了,显示我们的图书。我们用 MVC 开发的规定套路:准备 Controller 和View , 进行数据模型绑定,运行应用。 但是再这些动作之前,我们先学习一个重要的面向对象设计模式: 仓库模式 Repository Pattern 。这是一个广泛应用的模式,用于访问数据库数据真是方便又可靠,还可以减少重复代码,真乃居家旅行,杀人越货的必备良药。
Listing 7-13 在 Models 文件夹里新建一个文件夹 Interfaces ,在新建文件夹里创建一个新接口文件 IStoreRepository.cs

namespace EBooksStore.Models.Interfaces;

public interface IStoreRepository
{
    IQueryable<EBook> EBooks { get; }
}

这个接口的EBooks 属性是 IQueryable<EBook> ,前面章节我们学过,用这个接口的目的是让 SQL 查询在服务端完成动作,在我们需要的时候,再取得数据。有效利用内存和带宽,优化服务器的 I/O 。
我们还需要一个实现这个接口的类,并且按惯例,把它放在 Models 文件夹里。 Listing 7-14 在 Models 文件夹里创建一个新文件 StoreRepository.cs

using EBooksStore.Models.Interfaces;

namespace EBooksStore.Models;

public class StoreRepository(StoreDbContext context) : IStoreRepository
{
    private readonly StoreDbContext _context = context;

    public IQueryable<EBook> EBooks => _context.EBooks;
}

这里用到了新的类主构造器语法,简洁一些。要用这个类,我们需要让主运行程序的服务知道这个类的接口并找到对应接口的实现方法。这时候,和前面说的一样,我们要先注册这个接口和对应的方法到应用的服务里去。
Listing 7-15 修改 Program.cs ,注册接口和方法

using EBooksStore.Models;
using EBooksStore.Models.Interfaces;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllersWithViews();

builder.Services.AddDbContext<StoreDbContext>(options => {
    options.UseSqlServer(
        builder.Configuration["ConnectionStrings:EBooksStoreConnection"]);
});

//注册到服务里
builder.Services.AddScoped<IStoreRepository, StoreRepository>();

var app = builder.Build();


app.UseStaticFiles();
app.MapDefaultControllerRoute();

Seeds.InitPopulate(app);

app.Run();

接下来,给 HomeController 类添加仓库,准备连入数据。
Listing 7-16 修改 HomeController.cs 文件如下

using EBooksStore.Models.Interfaces;
using Microsoft.AspNetCore.Mvc;

namespace EBooksStore.Controllers;

public class HomeController(IStoreRepository repository) : Controller
{
    private readonly IStoreRepository _repository = repository;

    public IActionResult Index() => View(_repository.EBooks);
}

我们在这个类里面声明了一个 IStoreREpository 接口类型属性并在主构造器里初始化了它,这里用到 DI(dependency injection) 依赖注入设计模式,这个模式也是作为开发者不得不会的,并且鼓励大家多多使用。后面我们会有详细介绍。

接着,开始更新视图,让它显示电子图书列表。
Listing 7-17 修改 EBooksStore/Views/Home 文件夹里的 Index.cshtml 文件

@model IQueryable<EBook>

<h2>欢迎光临电子图书网店!</h2>

@foreach (var item in Model)
{
    <div>
        <h3>@item.Title</h3>
        <p>@item.Description</p>
        <p>价格:@item.Price</p>
        <p>作者:@item.Author</p>
    </div>
}

因为在 HomeController 的 Index 方法里, 返回到这个视图的参数类型是 IQueryable<EBook> , 所以我们在 cshtml 文件开头添加数据模型 IQueryable<EBook> 表示这个视图要用这个数据模型。

运行应用,用浏览器打开 *http://localhost:5000* 就会打开页面显示我们预录入的那些图书列表。

电子书简单列表

Figure 7-2

分页显示

如果把所有数据都取回并显示在页面上,不是一个好选择。首先,这一页会很长,不方便浏览;其次,也是最需要考虑的是,将来数据表中可能有上万条记录,从数据库服务器一下子取那么多数据,服务器负担过重。普遍的做法是分页取数据,分页显示。我们先取一页试试看。 Listing 7-18 修改 HomeController.cs 文件,只取一页数据传送给视图

using EBooksStore.Models.Interfaces;
using Microsoft.AspNetCore.Mvc;

namespace EBooksStore.Controllers;

public class HomeController(IStoreRepository repository, int PageSize = 4) : Controller
{
    private readonly IStoreRepository _repository = repository;

    public IActionResult Index(int pageNumber = 1) 
        => View(_repository.EBooks
            .OrderBy(e => e.Id)
            .Skip((pageNumber - 1) * PageSize)
            .Take(PageSize));
}

我们暂时让每页显示 4 条记录。所有 EBooks 表中的记录,数据库系统都会给它一个自动唯一的字段 Id ,所以我们这里用 Id 排序。 没有排序的记录分页取数据,结果将不能预料。
运行应用,页面将只显示前面 4 条记录内容。如果要查看其他页,我们要在浏览器地址栏输入类似这样的 URL :

http://localhost:5000/?pagenumber=2

对于最终用户,他们不可能为了看其他页面都要在地址栏输入这样的东西。我们需要给用户页面链接,让用户可以点击翻页。我们将借助 tag helper 给每页自动生成翻页链接。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注