EF Core SQL Server 添加视图映射

最近的项目(后端:.Net Core Web API 、数据库: SQL Server、前端:Blazor WebAssembly、前端Ui框架:Ant Design Blazor)中有些列表页面需要从后端拿到多表查询的结果,一种做法是从后端多个接口分别拿到数据在前端进行整合,比如:

@page "/orders"

@inject IDictService DictService
@inject IUserService UserService
@inject IOrderService OrderService
@inject IProductService ProductService
@inject ICustomerService CustomerService
@inject NavigationManager NavigationManager

@implements IDisposable

@attribute [Authorize]

<PageTitle>全部订单</PageTitle>

<Breadcrumb Style="margin:16px 0;">
    <BreadcrumbItem>订单</BreadcrumbItem>
    <BreadcrumbItem>全部订单</BreadcrumbItem>
</Breadcrumb>

<div class="site-layout-background" style="padding: 24px;">
    <GridRow>
        <GridCol Span="24">
            <Table TItem="OrderMainDTO" DataSource="@orders" @bind-PageSize="pageSize" ScrollX="1300px">
                <ChildContent>
                    <PropertyColumn Property="c=>c.OrderRequestNo" Title="订单申请编号" Sortable Filterable>
                        @context.OrderRequestNo
                    </PropertyColumn>
                    <PropertyColumn Property="c=>c.OrderCode" Title="外部订单编号" Sortable Filterable>
                        @context.OrderCode
                    </PropertyColumn>
                    <PropertyColumn Property="c=>c.CustomerId" Title="客户" Sortable Filterable>
                        <Template>
                            @{
                                var customer = CustomerService.Customers.FirstOrDefault(c => c.Id == context.CustomerId);
                                if (customer != null)
                                {
                                    @customer.CustomerName
                                }
                            }
                        </Template>
                    </PropertyColumn>
                    <PropertyColumn Property="c=>c.IndustryId" Title="行业" Sortable Filterable>
                        <Template>
                            @{
                                var industry = ProductService.Industries.FirstOrDefault(c => c.Id == context.IndustryId);
                                if (industry != null)
                                {
                                    @industry.IndustryName
                                }
                            }
                        </Template>
                    </PropertyColumn>
                    <PropertyColumn Property="c=>c.ProductLineId" Title="产品系列" Sortable Filterable>
                        <Template>
                            @{
                                var productLine = ProductService.ProductLines.FirstOrDefault(c => c.Id == context.ProductLineId);
                                if (productLine != null)
                                {
                                    @productLine.ProductLineName
                                }
                            }
                        </Template>
                    </PropertyColumn>
                    <PropertyColumn Property="c=>c.SalesOwnerId" Title="销售负责人" Sortable Filterable>
                        <Template>
                            @{
                                var salesOwner = UserService.Employees.FirstOrDefault(c => c.Id == context.SalesOwnerId);
                                if (salesOwner != null)
                                {
                                    @salesOwner.FullName
                                }
                            }
                        </Template>
                    </PropertyColumn>
                    <PropertyColumn Property="c=>c.ProductLineOwnerId" Title="产品负责人" Sortable Filterable>
                        <Template>
                            @{
                                var productLineOwner = UserService.Employees.FirstOrDefault(c => c.Id == context.ProductLineOwnerId);
                                if (productLineOwner != null)
                                {
                                    @productLineOwner.FullName
                                }
                            }
                        </Template>
                    </PropertyColumn>
                    <PropertyColumn Property="c=>c.ApprovalStatus" Title="审批状态" Sortable Filterable>
                        @{
                            var color = "";
                            var approvalTag = "";
                            @switch (context.ApprovalStatus)
                            {
                                case 0:
                                    approvalTag = "草稿";
                                    color = "orange";
                                    break;
                                case 1:
                                    approvalTag = "审批中";
                                    color = "purple";
                                    break;
                                case 2:
                                    approvalTag = "审批完成";
                                    color = "green";
                                    break;
                                case 3:
                                    approvalTag = "拒绝";
                                    color = "red";
                                    break;
                                case 4:
                                    approvalTag = "退回";
                                    color = "magenta";
                                    break;
                                default:
                                    approvalTag = "无";
                                    color = "default";
                                    break;
                            }

                            <Tag Color="@color">
                                @approvalTag
                            </Tag>
                        }
                    </PropertyColumn>
                    <PropertyColumn Property="c=>c.ExecutingStatus" Title="执行状态" Sortable Filterable>
                        @{
                            var color = "";
                            var executingTag = "";

                            @switch (context.ExecutingStatus)
                            {
                                case 0:
                                    executingTag = "未执行";
                                    color = "orange";
                                    break;
                                case 1:
                                    executingTag = "执行中";
                                    color = "geekblue";
                                    break;
                                case 2:
                                    executingTag = "执行完成";
                                    color = "green";
                                    break;
                                default:
                                    executingTag = "无";
                                    color = "default";
                                    break;
                            }

                            <Tag Color="@color">
                                @executingTag
                            </Tag>
                        }
                    </PropertyColumn>
                    <PropertyColumn Property="c=>c.SubmitterId" Title="提交人" Sortable Filterable>
                        <Template>
                            @{
                                var submitter = UserService.Employees.FirstOrDefault(c => c.Id == context.SubmitterId);
                                if (submitter != null)
                                {
                                    @submitter.FullName
                                }
                            }
                        </Template>
                    </PropertyColumn>
                    <PropertyColumn Property="c=>c.CreateTime" Title="提交时间" Format="yyyy-MM-dd hh:mm:ss" Sortable Filterable></PropertyColumn>
                     <ActionColumn Title="操作" Width="100" Fixed="right">
                         <Tooltip Title="查看">
                             <Button Shape="@ButtonShape.Circle" Icon="@IconType.Outline.Eye" OnClick="() => GoToOrder(context.Id)" />
                         </Tooltip>
                     </ActionColumn>
                 </ChildContent>
                 <PaginationTemplate>
                     <Pagination Class="@(context.PaginationClass + " my-custom-pagination")"
                                 Total="context.Total"
                                 PageSize="context.PageSize"
                                 Current="context.PageIndex"
                                        ShowSizeChanger
                                 OnChange="context.HandlePageChange" />
                 </PaginationTemplate>
             </Table>
         </GridCol>
     </GridRow>
 </div>

 @code {
    List<OrderMainDTO> orders = new();
    IEnumerable<Dict> dicts = [];
    int total => orders.Count();
    int pageSize = 20;

    void GoToOrder(Guid orderId)
    {
        NavigationManager.NavigateTo($"/orders/detail/{orderId}");
    }

    protected override async Task OnInitializedAsync()
    {
        var dictResponse = await DictService.GetDicts();
        if (dictResponse.Success)
        {
            dicts = dictResponse.Data?.Count > 0 ? dictResponse.Data : [];
        }

        // 获取所有行业数据
        await ProductService.GetIndustries();
        // 获取所有产品线数据
        await ProductService.GetProductLines();
        // 获取所有产品
        await ProductService.GetProducts();
        // 获取所有客户数据
        await CustomerService.GetCustomers();
        // 获取所有用户数据
        await UserService.GetEmployees();

        var result = await OrderService.GetOrders();
        if (result.Success)
        {
            orders = result.Data != null ? result.Data : [];
        }

        ProductService.OnChange += StateHasChanged;
        CustomerService.OnChange += StateHasChanged;
        UserService.OnChange += StateHasChanged;
    }

    public void Dispose()
    {
        ProductService.OnChange -= StateHasChanged;
        CustomerService.OnChange -= StateHasChanged;
        UserService.OnChange -= StateHasChanged;
    }
}

从这个Blazor WebAssembly 组件中可以看到为了实现一个列表分别调用了:
行业数据、产品线数据、客户数据、用户数据、字典数据等接口,并且在Table中进行适配,这种方式虽然能实现这个订单列表的页面,然而产生了一个和UI框架的冲突,如下图:

Ant Design Blazor 的 Table 带有过滤器(Filterable),由于 Table 中的各列是遍历的原始值,再通过适配转成了文本,这直接导致过滤器作用的是未适配前的原始值,比如截图中过滤器它应该过滤审批状态,你填入“审批完成”再点确定是查不到任何结果的,审批完成对应的是2,如果你输入2再查询是可以有结果的。

订单列表直接使用视图数据可以减少调用接口的数量也顺带解决掉Table过滤器的这个冲突,EF Core 如何映射视图呢?从微软的官方文档通过关键字没找到合适的内容,搜索引擎上找了一会儿拿到的内容一尝试还是错的,花了一些时间踩坑和试错。

  • 数据库中新建好视图
  • 后端新建一个用于适配该视图的模型
  • DbContext 数据库上下文中新增视图
  • 服务和Controller中修改数据库操作和查询返回的类型
  • 前端接口请求服务中类型修改
  • 前端组件中重新适配新接口以及绑定

新建视图-下列SQL Case When 遇到中文需要在引号前加N,否则会乱码

SELECT 
O.Id,
O.OrderRequestNo,
O.OrderCode,
C.CustomerName,
I.IndustryName,
PL.ProductLineName,
O.SalesOwnerId,
U1.FullName AS SalesOwner,
O.SubmitterId,
U3.FullName AS Submitter,
O.ProductLineOwnerId,
U2.FullName AS ProductLineOwner,
O.ApprovalStatus,
CASE
	WHEN O.ApprovalStatus = 0 THEN N'草稿'
	WHEN O.ApprovalStatus = 1 THEN N'审批中'
	WHEN O.ApprovalStatus = 2 THEN N'审批完成'
	WHEN O.ApprovalStatus = 3 THEN N'拒绝'
	WHEN O.ApprovalStatus = 4 THEN N'退回'
END AS ApprovalStatusName,
O.ExecutingStatus,
CASE
	WHEN O.ExecutingStatus = 0 THEN N'未执行'
	WHEN O.ExecutingStatus = 1 THEN N'执行中'
	WHEN O.ExecutingStatus = 2 THEN N'执行完成'
END AS ExecutingStatusName,
O.CreateTime,
O.IsDisabled
FROM [dbo].[T_OrderMains] AS O 
LEFT JOIN [dbo].[T_Industries] AS I ON O.IndustryId = I.Id
LEFT JOIN [dbo].[T_ProductLines] AS PL ON PL.Id = O.ProductLineId
LEFT JOIN [dbo].[T_Users] AS U1 ON U1.Id = O.SalesOwnerId
LEFT JOIN [dbo].[T_Users] AS U2 ON U2.Id = O.ProductLineOwnerId
LEFT JOIN [dbo].[T_Users] AS U3 ON U3.Id = O.SubmitterId
LEFT JOIN [dbo].[T_Customers] AS C ON C.Id = O.CustomerId

新建模型

namespace Gatcis.Plus.Shared.Models.OrderModel
{
    public class OrderView
    {
        public Guid Id { get; set; }
        public string? OrderRequestNo { get; set; }
        public string? OrderCode { get; set; }
        public string? CustomerName { get; set; }
        public string? IndustryName { get; set; }
        public string? ProductLineName { get; set; }
        public Guid SalesOwnerId { get; set; }
        public string? SalesOwner { get; set; }
        public Guid SubmitterId { get; set; }
        public string? Submitter { get; set; }
        public Guid ProductLineOwnerId { get; set; }
        public string? ProductLineOwner { get; set; }
        public int ApprovalStatus { get; set; }
        public string? ApprovalStatusName { get; set; }
        public int ExecutingStatus { get; set; }
        public string? ExecutingStatusName { get;set; }
        public DateTime CreateTime { get; set; }
        public bool IsDisabled { get; set; }
    }
}

DbContext 中新增视图-这样去映射视图是不需要进行 Migration (数据库迁移)的

namespace Gatcis.Plus.Service.Data
{
    public class IDbContext: IdentityDbContext<IUser, IRole, Guid>
    {
        // ... 其他
        // 订单视图
        public DbSet<OrderView> View_OrderLists { get; set; }
        public IDbContext(DbContextOptions<IDbContext> options) : base(options) { }
        protected override void OnModelCreating(ModelBuilder builder)
        {
            builder.Entity<OrderView>().ToView("View_OrderLists");
        }
    }
}

修改后端接口服务

        public async Task<ServiceResponse<List<OrderView>>> GetMyOrders(Guid operatorId)
        {
            var response = new ServiceResponse<List<OrderView>>();
            var orders = await context.View_OrderLists
                .Where(o => o.IsDisabled == false && (o.ProductLineOwnerId == operatorId || o.SalesOwnerId == operatorId || o.SubmitterId == operatorId))
                .OrderByDescending(o => o.CreateTime)
                .ToListAsync();

            response.Code = 200;
            response.Data = orders;

            return response;
        }

最终前端页面适配

@page "/orders/my"

@inject IOrderService OrderService
@inject NavigationManager NavigationManager

@attribute [Authorize]

<PageTitle>我的订单</PageTitle>

<Breadcrumb Style="margin:16px 0;">
    <BreadcrumbItem>订单</BreadcrumbItem>
    <BreadcrumbItem>我的订单</BreadcrumbItem>
</Breadcrumb>

<div class="site-layout-background" style="padding: 24px;">
    <GridRow>
        <GridCol Span="24">
            <Table TItem="OrderView" DataSource="@orders" @bind-PageSize="pageSize" ScrollX="1300px">
                <ChildContent>
                    <PropertyColumn Property="c=>c.OrderRequestNo" Title="订单申请编号" Sortable Filterable>
                        @context.OrderRequestNo
                    </PropertyColumn>
                    <PropertyColumn Property="c=>c.OrderCode" Title="外部订单编号" Sortable Filterable>
                        @context.OrderCode
                    </PropertyColumn>
                    <PropertyColumn  Property="c=>c.CustomerName" Title="客户" Sortable Filterable>
                        @context.CustomerName
                    </PropertyColumn>
                     <PropertyColumn Property="c=>c.IndustryName" Title="行业" Sortable Filterable>
                        @context.IndustryName
                     </PropertyColumn>
                     <PropertyColumn Property="c=>c.ProductLineName" Title="产品系列" Sortable Filterable>
                        @context.ProductLineName
                     </PropertyColumn>
                     <PropertyColumn Property="c=>c.SalesOwner" Title="销售负责人" Sortable Filterable>
                        @context.SalesOwner
                     </PropertyColumn>
                     <PropertyColumn Property="c=>c.ProductLineOwner" Title="产品负责人" Sortable Filterable>
                        @context.ProductLineOwner
                     </PropertyColumn>
                    <PropertyColumn Property="c=>c.ApprovalStatusName" Title="审批状态" Sortable Filterable>
                        @{
                            var color = "";
                            @switch (context.ApprovalStatus)
                            {
                                case 0:
                                    color = "orange";
                                    break;
                                case 1:
                                    color = "purple";
                                    break;
                                case 2:
                                    color = "green";
                                    break;
                                case 3:
                                    color = "red";
                                    break;
                                case 4:
                                    color = "magenta";
                                    break;
                            }

                            <Tag Color="@color">
                                @context.ApprovalStatusName
                            </Tag>
                        }
                    </PropertyColumn>
                    <PropertyColumn Property="c=>c.ExecutingStatusName" Title="执行状态" Sortable Filterable>
                        @{
                            var color = "";
                            @switch (context.ExecutingStatus)
                            {
                                case 0:
                                    color = "orange";
                                    break;
                                case 1:
                                    color = "geekblue";
                                    break;
                                case 2:
                                    color = "green";
                                    break;
                            }

                            <Tag Color="@color">
                                @context.ExecutingStatusName
                            </Tag>
                        }
                    </PropertyColumn>
                    <PropertyColumn Property="c=>c.Submitter" Title="提交人" Sortable Filterable>
                        @context.Submitter
                    </PropertyColumn>
                     <PropertyColumn Property="c=>c.CreateTime" Title="提交时间" Format="yyyy-MM-dd hh:mm:ss" Sortable Filterable></PropertyColumn>
                     <ActionColumn Title="操作" Width="100" Fixed="right">
                        @if (context.ApprovalStatus == 1 && context.ProductLineOwnerId == currentUserId)
                        {
                            <Tooltip Title="审批">
                                <Button Shape="@ButtonShape.Circle" Icon="@IconType.Outline.Check" OnClick="() => GoToOrder(context.Id)" />
                            </Tooltip>
                        }
                        else
                        {
                            <Tooltip Title="查看">
                                <Button Shape="@ButtonShape.Circle" Icon="@IconType.Outline.Eye" OnClick="() => GoToOrder(context.Id)" />
                            </Tooltip>
                        }
                     </ActionColumn>
                 </ChildContent>
                 <PaginationTemplate>
                     <Pagination Class="@(context.PaginationClass + " my-custom-pagination")"
                                 Total="context.Total"
                                 PageSize="context.PageSize"
                                 Current="context.PageIndex"
                                        ShowSizeChanger
                                 OnChange="context.HandlePageChange" />
                 </PaginationTemplate>
             </Table>
         </GridCol>
     </GridRow>
 </div>

 @code {
    [CascadingParameter]
    private Task<AuthenticationState>? AuthenticationState { get; set; }

    public ClaimsPrincipal? authenticatedUser { get; set; }
    Guid? currentUserId = null;

    List<OrderView> orders = new();

    int total => orders.Count();
    int pageSize = 20;

    void GoToOrder(Guid orderId)
    {
        NavigationManager.NavigateTo($"/orders/detail/{orderId}");
    }

    protected override async Task OnInitializedAsync()
    {
        var result = await OrderService.GetMyOrders();
        if (result.Success)
        {
            orders = result.Data != null ? result.Data : [];
        }

        if (AuthenticationState is not null)
        {
            var state = await AuthenticationState;
            authenticatedUser = state.User;

            string? userId = authenticatedUser.FindFirst(c => c.Type.Contains("nameidentifier"))?.Value;
            if (userId != null)
            {
                currentUserId = Guid.Parse(userId);
            }
        }
    }
}

基于Redis缓存的Token失效策略实现

有这么一个简单场景,某个应用采用前后端分离架构,前端有用于浏览器访问的网站、手机APP、桌面APP等,前端通过JWT认证和后端接口进行交互,用户可能会出现相同账号同时多端登录的问题,能否在验证JWT时去避免呢?Token字符中只有类似用户名、用户ID、用户Role以及过期时间等信息,只要在Token未过期前验证Token是不能规避这种问题的。

这样看来,必须对Token字符有一个能比较出差异的服务器端保存,比如往Token字符中增加版本ID,当用户每次登录系统,登录接口都往数据库表的Token版本ID进行一次自增,再将Token版本ID写入Token发放到用户终端,程序自身再构建一个拦截器,用户端的每次请求所携带的JWT Token都会校验版本是否一致,比如请求中的Token版本小于了系统数据库表中的Token版本,就给用户返回 401 Unauthorized。

上述的,使用数据库存储Token版本号是一种解决办法,不过每次用户访问都会迫使拦截器进行一次数据库查询,考虑降低数据库的压力,使用Redis对Token进行缓存也是被广泛使用的一种机制。

Mack没有看过其他人如何去写这段代码,自己琢磨着写了一个示例代码,示范项目是一个.NET Core Web API项目,它包含以下包:

  • Microsoft.AspNetCore.Authentication.JwtBearer
  • Microsoft.AspNetCore.Identity.EntityFrameworkCore
  • Microsoft.AspNetCore.OpenApi
  • Microsoft.EntityFrameworkCore.SqlServer
  • Microsoft.EntityFrameworkCore.Tools
  • Microsoft.Extensions.Caching.StackExchangeRedis
  • Swashbuckle.AspNetCore
  • System.IdentityModel.Tokens.Jwt

项目使用了Identity 这个RBAC框架,对于数据库映射操作依赖EF Core,数据库使用Sql Server,为了进行缓存操作也安装了Redis。

用户的每次登录所生成的Token都会被写进Redis,代码如下:

/*Controllers AuthController.cs*/
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using System.Text.Json;

namespace ProjectTodo.Controllers
{
    [Route("api/[controller]/[action]")]
    [ApiController]
    [Produces("application/json")]
    public class AuthController : ControllerBase
    {
        private readonly UserManager<User> _userManager;
        private readonly IDistributedCache _distributedCache;
        public AuthController(UserManager<User> userManager, IDistributedCache distributedCache)
        {
            _userManager = userManager;
            _distributedCache = distributedCache;
        }
        private static string BuildToken(IEnumerable<Claim> claims, JWTSettings options)
        {
            DateTime expires = DateTime.Now.AddSeconds(options.ExpireSeconds);
            byte[] keyBytes = Encoding.UTF8.GetBytes(options.SigningKey);
            var secKey = new SymmetricSecurityKey(keyBytes);
            var credentials = new SigningCredentials(secKey, SecurityAlgorithms.HmacSha256Signature);
            var tokenDescriptor = new JwtSecurityToken(expires: expires, signingCredentials: credentials, claims: claims);
            return new JwtSecurityTokenHandler().WriteToken(tokenDescriptor);
        }
        [HttpPost]
        public async Task<ActionResult> Login([FromBody] AuthLoginViewModel model, [FromServices]IOptions<JWTSettings> jwtSettingsOpt)
        {
            var userName = model.UserName;
            var password = model.Password;
            var user = await _userManager.FindByNameAsync(userName);
            if (user == null)
            {
                return BadRequest();
            }
            var result = await _userManager.CheckPasswordAsync(user, password);
            if (!result)
            {
                return BadRequest();
            }
            var claims = new List<Claim>();
            claims.Add(new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()));
            claims.Add(new Claim(ClaimTypes.Name, user.UserName!));

            var roles = await _userManager.GetRolesAsync(user);
            foreach (var role in roles)
            {
                claims.Add(new Claim(ClaimTypes.Role, role));
            }
            string jwtToken = BuildToken(claims, jwtSettingsOpt.Value);

            // 获取Redis缓存中的用户token
            string? s = await _distributedCache.GetStringAsync("token-" + user.UserName!);
            if (s == null)
            {
                // 没有token就写入
                await _distributedCache.SetStringAsync("token-" + user.UserName!, JsonSerializer.Serialize(jwtToken));
            }
            else
            {
                // 删除旧的token,写入新的token
                await _distributedCache.RemoveAsync("token-" + user.UserName!);
                await _distributedCache.SetStringAsync("token-" + user.UserName!, JsonSerializer.Serialize(jwtToken));
            }      
            return Ok(jwtToken);
        }
    }
}

项目配置一个专门校验Token的过滤器,代码如下:

/* JWTValidationFilter.cs */
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Caching.Distributed;
using System.Security.Claims;

namespace ProjectTodo
{
    //[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
    public class JWTValidationFilter :Attribute, IAsyncActionFilter
    {
        private readonly IDistributedCache _distributedCache;
        public JWTValidationFilter(IDistributedCache distributedCache)
        {
            _distributedCache = distributedCache;
        }
        public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
        {
            var userName = context.HttpContext.User.FindFirst(ClaimTypes.Name)?.Value;
            if (userName == null)
            {
                await next();
                return;
            }
            string? redisToken = await _distributedCache.GetStringAsync("token-" + userName!);
            string token;
            if (redisToken != null)
            {
                redisToken = redisToken.Trim('"');
                var authorizationHeader = context.HttpContext.Request.Headers.Authorization.ToString();
                if (authorizationHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
                {
                    token = authorizationHeader.Substring("Bearer ".Length).Trim();
                    bool isValid = string.Equals(token, redisToken);
                    if (!isValid)
                    {
                        context.Result = new ObjectResult("Token 已失效,请重新登录") { StatusCode = 401 };
                        return;
                    }
                }
                await next();
                return;

            }
            await next();
        }
    }
}

过滤器会从当前对接口进行访问的请求中获取到Token,也会从Redis中拿到Token,进行比较,发现差异会返回401。

过滤器需要在项目根目录Program.cs中进行注册。

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using ProjectTodo;
using System.Reflection;
using System.Text;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddMemoryCache();
builder.Services.AddLogging();

string connStr = builder.Configuration.GetConnectionString("DefaultConnection")!;

// 添加数据库上下文注册
builder.Services.AddDbContext<ProjectTodoContext>(options => options.UseSqlServer(connStr));

// 添加JWT配置
builder.Services.Configure<JWTSettings>(builder.Configuration.GetSection("JWT"));
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(x =>
{
    var jwtOpt = builder.Configuration.GetSection("JWT").Get<JWTSettings>();
    byte[] keyBytes = Encoding.UTF8.GetBytes(jwtOpt!.SigningKey);
    var secKey = new SymmetricSecurityKey(keyBytes);
    x.TokenValidationParameters = new()
    {
        ValidateIssuer = false,
        ValidateAudience = false,
        ValidateLifetime = true,
        ValidateIssuerSigningKey = true,
        IssuerSigningKey = secKey
    };
});

// 添加全局过滤器
//builder.Services.Configure<MvcOptions>(options =>
//{
//    options.Filters.Add<JWTValidationFilter>();
//    //options.Filters.Add<ExceptionHandleFilter>();
//    // ... 这里还可以增加其他 Filter,但是需要注意顺序
//});

builder.Services.AddScoped<JWTValidationFilter>();

// Swagger 添加 Authorization
builder.Services.AddSwaggerGen(options => 
{
    var scheme = new OpenApiSecurityScheme()
    {
        Description = "Authorization header. \r\nExample: 'Bearer 12345abcdef'",
        Reference = new OpenApiReference
        {
            Type = ReferenceType.SecurityScheme,
            Id = "Authorization"
        },
        Scheme = "oauth2",
        Name = "Authorization",
        In = ParameterLocation.Header,
        Type = SecuritySchemeType.ApiKey,
    };
    options.AddSecurityDefinition("Authorization",scheme);
    var requirement = new OpenApiSecurityRequirement();
    requirement[scheme] = new List<string>();
    options.AddSecurityRequirement(requirement);

    // using System.Reflection;
    var xmlFilename = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
    options.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, xmlFilename));
});

// Redis 缓存服务注册
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = "localhost";
    options.InstanceName = "mack_";  // key 前缀
});


IServiceCollection services = builder.Services;
services.AddDbContext<IdDbContext>(opt =>
{
    opt.UseSqlServer(connStr);
});
services.AddDataProtection();
services.AddIdentityCore<User>(options =>
{
    //options.Lockout.MaxFailedAccessAttempts = 10;  // 密码错误十次锁定
    //options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromDays(1);  // 默认锁定时间,可以通过FromDays等进行天、小时、分钟、秒的设置
    options.Password.RequireDigit = false;
    options.Password.RequireLowercase = false;
    options.Password.RequireNonAlphanumeric = false;
    options.Password.RequireUppercase = false;
    options.Password.RequiredLength = 6;
    // 如果发重置链接到用户邮箱,可以注释掉下面一行
    options.Tokens.PasswordResetTokenProvider = TokenOptions.DefaultEmailProvider;  // 这个决定了重置密码的验证码长度
    options.Tokens.EmailConfirmationTokenProvider = TokenOptions.DefaultEmailProvider;
});
var idBuilder = new IdentityBuilder(typeof(User), typeof(Role), services);
idBuilder.AddEntityFrameworkStores<IdDbContext>().AddDefaultTokenProviders().AddRoleManager<RoleManager<Role>>().AddUserManager<UserManager<User>>();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

// 在app.UseAuthorization();上面添加
app.UseAuthentication();

app.UseAuthorization();

// 启用服务器缓存需要在 app.MapControllers() 前加上 app.UseResponseCaching()
// 如果启用了 app.UseCors 跨域,需要 app.UseCors 写到 app.UseResponseCaching 前面

/*app.UseResponseCaching();*/  // 启用服务器端缓存
app.MapControllers();

app.Run();

过滤器以[ServiceFilter(typeof(JWTValidationFilter), Order = -1)]这种标签方式作用到一个接口上(顺带吐槽一下,放在Python项目上接口被叫成视图函数,会更贴切),Order = -1 是表明过滤器的执行优先级,如果没有这个,程序会优先执行[Authorize]

        /// <summary>
        /// View project by id
        /// </summary>
        /// <param name="id"></param>
        /// <returns></returns>
        [HttpGet("{id}")]
        [ServiceFilter(typeof(JWTValidationFilter), Order = -1)]
        [Authorize]
        public async Task<ActionResult<ProjectDTO>> GetProject(long id)
        {
            var project = await context.Projects.SingleOrDefaultAsync(x=>x.Id == id);
            if (project == null)
            {
                return NotFound();
            }
            var projectDTO = new ProjectDTO(project);

            return Ok(projectDTO);
        }

围绕上述的实现代码,调试出的结果:

使用已过期Token,接口直接返回401

使用未过期Token,并在Swagger界面对登录接口重新登录,使得Redis缓存最新Token,访问接口触发过滤器的验证

ASP.NET WEB API 缓存处理

在Web Api中,假设有用户频繁的对一个并不经常更新数据的接口发起请求,为了减少不必要的资源开销,可通过缓存进行优化:

一、 客户端缓存

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;

namespace ProjectTodo.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class CacheLearnController : ControllerBase
    {
        // 当用户十秒内多次向该接口发起请求,浏览器会从缓存中获取
        [ResponseCache(Duration = 10)]
        [HttpGet]

        public DateTime Now()
        {
            return DateTime.Now;
        }
    }
}

我们通过装饰器[ResponseCache(Duration = 10)]来设定接口内容缓存10秒,用户在十秒内频繁刷新接口拿到的时间都是相同的,这是一个客户端缓存方式,不同的用户进行上述操作他们会有不同的缓存结果;

不过这也需要一个前提,浏览器=>网络,设置未勾选“禁用缓存”。

二、 服务器端缓存

开启服务器端缓存,需要在 Program.cs 中增加 app.UseResponseCaching(); 也有需要注意的事项,注释在代码中了。

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

app.UseAuthorization();

// 启用服务器缓存需要在 app.MapControllers() 前加上 app.UseResponseCaching()
// 如果启用了 app.UseCors 跨域,需要 app.UseCors 写到 app.UseResponseCaching 前面

app.UseResponseCaching();  // 启用服务器端缓存
app.MapControllers();

app.Run();

服务器端缓存意味着在一定时间范围内,无论是哪个客户端进行的请求,在缓存未更新前,都是相同的。

无论是客户端缓存或是服务器端缓存,都会受到浏览器“禁用缓存”设置的影响,由请求时头部报文中的参数决定,比如下面的两种图,当浏览器勾选了“禁用缓存”,请求标头中会多出Cache-Control: no-cache。

三、 缓存的绝对过期时间

通过 IMemoryCache Interface设置缓存的绝对过期时间,完整的示例代码如下:

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using System.Runtime.CompilerServices;

namespace ProjectTodo.Controllers
{
    [Route("api/[controller]/[action]")]
    [ApiController]
    public class ProjectTodoController : ControllerBase
    {
        private readonly IMemoryCache memoryCache;

        private readonly ILogger<ProjectTodoController> logger;

        public ProjectTodoController(IMemoryCache memoryCache, ILogger<ProjectTodoController> logger)
        {
            this.memoryCache = memoryCache;
            this.logger = logger;
        }

        [HttpGet]
        public async Task<ActionResult<ProjectDTO>> GetAllProjects()
        {
            
            using ProjectTodoContext ctx = new ProjectTodoContext();

            logger.LogInformation("开始");  // 用于在控制台输出内存缓存的作用

            // 二合一的方法,从缓存取数据,没有数据会从数据库取,并且保存进缓存
            var items = await memoryCache.GetOrCreateAsync("AllProjects", async (e) =>
            {
                // 用于在控制台输出内存缓存的作用
                logger.LogInformation("缓存里没有找到,从数据库拿");

                // 缓存有效期(绝对过期时间)
                // 设置内存缓存过期时间为 10 秒
                e.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(10);

                return await ctx.Projects.Select(x => new ProjectDTO(x)).ToListAsync();
            });
            // 用于在控制台输出内存缓存的作用
            logger.LogInformation($"{items}");

            return Ok(items);
        }
}

示例代码中设置了获取所有Project的API接口采用了绝对过期时间10秒,当一个用户在某时间访问了该接口,余下的十秒时间内,任何对该接口的访问都获得相同的内容。

四、 缓存滑动过期时间

内存缓存也可以使用滑动过期时间,当缓存内容在设定的时间内没有被访问,到达设定有效期时间之后缓存失效,当缓存内容在设定时间内被访问了,它将重置延续有效期。

应用场景如某些登录鉴权的状态,如Token失效等。

// 滑动过期时间,当缓存内容在设定时间内没有被访问,到达设定有效期时间之后缓存失效
// 当缓存内容在设定时间内又被访问,它将继续延长有效期
// 某些软件的 Token 采用的滑动过期时间
e.SlidingExpiration = TimeSpan.FromSeconds(10);

五、 绝对过期与滑动过期共用

当两种方法混用时,需要注意绝对过期时间要大于滑动过期时间。

// 两种过期时间可以混用
// 可以将绝对过期时间设置的比滑动过期时间长,这样在绝对过期时间的范围内,滑动过期的方式是有效的
e.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(30);
e.SlidingExpiration = TimeSpan.FromSeconds(10);

六、 内存缓存存取的一个简单示范(缓存穿透)

当客户端访问了一个不存在的id,依照下列代码,程序会先从缓存中查找,查找结果为 Null,程序会从数据库查找,数据库查找的结果为 Null 并赋值给 b,最终这个 null值又被设置到缓存中,判断的条件成立之后,实际每次通过不存在的id进行访问时都会触发数据库的查询,这就是典型的缓存穿透。

string cacheKey = "Book" + id;  // 缓存键
Book? b = memCache.Get<Book?>(cacheKey);
if(b == null)
{
  b = await dbCtx.Books.FindAsync(id);
  memCache.Set(cacheKey, b);
}

缓存穿透的解决方案:

  1. “查不到”也当成一个数据放入缓存。
  2. 使用GetOrCreateAsync方法即可,该方法会将 null 值当成合法的缓存值。
string cacheKey = "Book" + id;
var book = await memCache.GetOrCreateAsync(cacheKey, async(e)=>{
  var b = await dbCtx.Books.FindAsync(id);
  return b;
});

七、 缓存雪崩

缓存项的集中过期引起缓存雪崩,固定的缓存过期时间在周期性的访问中存在波动,比如刚好缓存过期时又出现了大量的请求而产生的突发性数据库压力。

在基础的过期时间之上,再加上一个随机的过期时间来应对和解决缓存雪崩问题。

// 随机过期时间来应对缓存雪崩
e.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(Random.Shared.Next(10, 15));

八、 分布式缓存(Redis)

Redis 是一个键值对数据库,它有丰富的数据类型,可以用来保存列表、字典、集合等数据类型,也可以用作消息队列、缓存服务器;

首先,在项目中通过NuGet安装Microsoft.Extensions.Caching.StackExchangeRedis,并在Program.cs中增加:

builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = "localhost";  // 使用的本地Redis
    options.InstanceName = "mack_";  // key 前缀
});

然后在需要进行缓存的controller中写入如下代码:

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Memory;
using System.Diagnostics.Eventing.Reader;
using System.Runtime.CompilerServices;
using System.Text.Json;

namespace ProjectTodo.Controllers
{
    [Route("api/[controller]/[action]")]
    [ApiController]
    public class ProjectTodoController : ControllerBase
    {
        private readonly IDistributedCache distributedCache;

        public ProjectTodoController(IDistributedCache distributedCache)
        {
            this.distributedCache = distributedCache;
        }

        [HttpGet]
        public async Task<ActionResult<ProjectDTO>> GetAllProjects()
        {
            
            using ProjectTodoContext ctx = new ProjectTodoContext();

            List<ProjectDTO> projects;
            var opt = new DistributedCacheEntryOptions();
            opt.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(Random.Shared.Next(10, 15));
            string? s = await distributedCache.GetStringAsync("AllProjects");

            if (s == null)
            {
                Console.WriteLine("从数据库里取数据");
                projects = await ctx.Projects.Select(x => new ProjectDTO(x)).ToListAsync();
                Console.WriteLine("数据写进 Redis");
                await distributedCache.SetStringAsync("AllProjects", JsonSerializer.Serialize(projects), opt);
                Console.WriteLine($"数据失效时间为 {opt}");
            }
            else
            {
                Console.WriteLine("从 Redis 中取数据");
                projects = JsonSerializer.Deserialize<List<ProjectDTO>>(s)!;
            }
            if(projects == null)
            {
                return NotFound();
            }
            else
            {
                Console.WriteLine("返回数据");
                return Ok(projects);
            }
        }
    }
}

这段代码先从Redis缓存中去获取所有的Project,当缓存中的Project不存在时再去数据库去取,也设定了缓存的失效时间,这里采用了一个随机范围,避免缓存雪崩,当取出数据为null时也会被赋值给Redis的键,代码判断为null成立调用NotFound()函数,从而避免了的缓存穿透,不过本段代码如果取不到数据返回的是一个空[],并不会触发NotFound()。

由于快发机是Windows环境,在对Redis版本没有过高要求的前提下,博主去https://github.com/tporadowski/redis/releases 下载安装了可运行在Windows系统上的Redis安装包,为了查看缓存的内容,通过https://github.com/uglide/RedisDesktopManager/releases/download/0.9.3/redis-desktop-manager-0.9.3.817.exe下载了Redis桌面管理工具。

下图是Redis分区缓存的所有的项目,键的前缀以及键组合命名:mack_AllProjects,absexp 的 value 是缓存的失效时间,失效时间一到,数据自动清除。

结合代码为了模拟出代码工作节点,通过控制台打印,可以看到:

一个ASP.NET Core Web Api示例

这是一个基于微软官方文档的 Web Api 示例,同时示范了 ORM 工具 EF Core 的简单使用,控制器的各接口都采用异步操作以及LINQ语句,C Sharp 语法上有一些空和非空处理,这点像极了微软自家的 TypeScript。

假设我们需要一个用于展示项目待办事项的 BS 应用,它运行在公司内网的一台 PC 上,用在一个大屏设备上轮播展示那些亟需完成的工作项,这听上去很像一个工厂流水线上常见的场景。由于它只是用于展示“研发部门”未完成的项目工作项,不深度去考虑信息隔离、信息安全这些条件,作为后端只需要提供一个能够CRUD操作数据库的Web API,由于数据库使用 SQL Server ,ORM 自然就使用 EF Core ,在定义数据模型时采用了 Fluent 方式。

接着,我们准备相关的 CRUD 接口:

  • 用于获取所有项目的接口 GetAllProjects
  • 用于获取单个项目信息的接口 GetProject
  • 用于获取单个项目所有的工作项的接口 GetProjectAllTodo
  • 用于新建项目的接口 AddProject
  • 用于新建项目工作项的接口 AddProjectTodo
  • 用于修改项目的接口 EditProject
  • 用于修改项目工作项的接口 EditProjectTodo
  • 用于删除项目的接口 DeleteProject
  • 用于删除项目工作项的接口 DeleteProjectTodo

从接口名称(路径)上看这并不是一个严格遵循 Restful 的 API,是的,它看上去有些 RPC风格,由于这仅仅是一个简单的案例,并没有专门为其写一个返回状态码和消息的函数,所以也省去了400派和200派的纠结。

IDE 毫无疑问使用 Visual Studio 2022,数据库使用 SQL Server 2022,先使用 Visual Studio 建立项目,先建立一个空白解决方案,比如命名成 ProjectTodoListSlideShow,再创建一个 ASP.NET Core Web API 项目,比如命名成 ProjectTodo,项目目录大致如下图:

添加一个控制器 ProjectTodoController ;

NuGet 包安装:
Microsoft.EntityFrameworkCore.SqlServer
Microsoft.EntityFrameworkCore.Tools

项目根目录新建 Model 类
# Model.cs

using Microsoft.EntityFrameworkCore;

namespace ProjectTodo
{
    public class ProjectTodoContext: DbContext
    {
        public DbSet<Project> Projects { get; set; }
        public DbSet<ProjectTodo> ProjectTodos { get; set; }
        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            base.OnConfiguring(optionsBuilder);
            string connStr = "Server=.;Database=project;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=True";
            optionsBuilder.UseSqlServer(connStr);
        }
    }

    public class Project
    {
        public long Id { get; set; }
        public string Name { get; set; } = null!;
        public string? Description { get; set; }
        public string? Owner { get; set; }
        public DateTime Created { get; set; }
        public string? Status { get; set; }
        public List<ProjectTodo>? projectTodos { get; set; }
    }

    public class ProjectTodo
    {
        public long Id { get; set; }
        public string Item { get; set; } = null!;
        public string? Description { get; set; }
        public string? Owner { get; set; }
        public string? Remark { get; set; }
        public DateTime Deadline { get; set; }
        public DateTime Created { get; set; }
        public string? Status { get; set; }
        public long ProjectId { get; set; }
        public Project? Project { get; set; }
    }
}

项目根目录新建 Schema 类
# Schema.cs

namespace ProjectTodo
{
    public class ProjectDTO
    {
        public long Id { get; set; }
        public string Name { get; set; } = null!;
        public string? Description { get; set; }
        public string? Owner { get; set; }
        public DateTime? Created { get; set; }
        public string? Status { get; set; }
        public ProjectDTO() { }
        public ProjectDTO(Project project) => (Id, Name, Description, Owner, Created, Status) = (project.Id, project.Name, project.Description, project.Owner, project.Created, project.Status);
    }
    public class ProjectTodoDTO
    {
        public long Id { get; set; }
        public string Item { get; set; } = null!;
        public string? Description { get; set; }
        public string? Owner { get; set; }
        public string? Remark { get; set; }
        public DateTime? Deadline { get; set; }
        public DateTime? Created { get; set; }
        public string? Status { get; set; }
        public long ProjectId { get; set; }
        public ProjectTodoDTO() { }
        public ProjectTodoDTO(ProjectTodo projectTodo) => (Id, Item, Description, Owner, Remark, Deadline, Created, Status, ProjectId) = (projectTodo.Id, projectTodo.Item, projectTodo.Description, projectTodo.Owner, projectTodo.Remark, projectTodo.Deadline, projectTodo.Created, projectTodo.Status, projectTodo.ProjectId);
    }
    public class ProjectAddDTO
    {
        public long Id { get; set; }
        public string Name { get; set; } = null!;
        public string? Description { get; set; }
        public string? Owner { get; set; }
        public DateTime? Created { get; set; }
        public string? Status { get; set; }
    }
    public class ProjectTodoAddDTO
    {
        public long Id { get; set; }
        public string Item { get; set; } = null!;
        public string? Description { get; set; }
        public string? Owner { get; set; }
        public string? Remark { get; set; }
        public DateTime? Deadline { get; set; }
        public DateTime? Created { get; set; }
        public string? Status { get; set; }
        public long ProjectId { get; set; }
    }
    public class ProjectEditDTO
    {
        public long Id { get; set; }
        public string Name { get; set; } = null!;
        public string? Description { get; set; }
        public string? Owner { get; set; }
        public DateTime? Created { get; set; }
        public string? Status { get; set; }
    }
    public class ProjectTodoEditDTO
    {
        public long Id { get; set; }
        public string Item { get; set; } = null!;
        public string? Description { get; set; }
        public string? Owner { get; set; }
        public string? Remark { get; set; }
        public DateTime? Deadline { get; set; }
        public DateTime? Created { get; set; }
        public string? Status { get; set; }
        public long ProjectId { get; set; }
    }
}

Controllers/ProjectTodoController.cs 类代码如下:

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace ProjectTodo.Controllers
{
    [Route("api/[controller]/[action]")]
    [ApiController]
    public class ProjectTodoController : ControllerBase
    {
        [HttpGet]
        public async Task<ActionResult<ProjectDTO>> GetAllProjects()
        {
            using ProjectTodoContext ctx = new ProjectTodoContext();

            var projects = await ctx.Projects.Select(x=>new ProjectDTO(x)).ToListAsync();

            return Ok(projects);
        }

        [HttpGet("{id}")]
        public async Task<ActionResult<ProjectDTO>> GetProject(long id)
        {
            using ProjectTodoContext ctx = new ProjectTodoContext();

            var project = await ctx.Projects.SingleOrDefaultAsync(x=>x.Id == id);
            if (project == null)
            {
                return NotFound();
            }
            var projectDTO = new ProjectDTO(project);

            return Ok(projectDTO);
        }

        [HttpGet("{id}")]
        public async Task<ActionResult<List<ProjectTodoDTO>>> GetProjectAllTodos(long id)
        {
            using ProjectTodoContext ctx = new ProjectTodoContext();

            var projectTodoDTOs = await ctx.ProjectTodos
                                .Where(t => t.ProjectId == id)
                                .Select(t => new ProjectTodoDTO(t))
                                .ToListAsync();

            return Ok(projectTodoDTOs);
        }

        [HttpPost]
        public async Task<ActionResult<ProjectAddDTO>> AddProject(ProjectAddDTO projectAddDTO)
        {
            using ProjectTodoContext ctx = new ProjectTodoContext();
            
            var project = new Project 
            { 
                Name = projectAddDTO.Name, 
                Description = projectAddDTO.Description, 
                Owner = projectAddDTO.Owner, 
                Created = DateTime.Now,
                Status = projectAddDTO.Status
            };

            ctx.Projects.Add(project);
            await ctx.SaveChangesAsync();

            projectAddDTO.Created = project.Created;
            projectAddDTO.Id = project.Id;

            return Ok(projectAddDTO);
            
        }

        [HttpPost]
        public async Task<ActionResult<ProjectTodoAddDTO>> AddProjectTodo(ProjectTodoAddDTO projectTodoAddDTO)
        {
            using ProjectTodoContext ctx = new ProjectTodoContext();

            var todo = new ProjectTodo
            {
                Item = projectTodoAddDTO.Item,
                Description = projectTodoAddDTO.Description,
                Owner = projectTodoAddDTO.Owner,
                Remark = projectTodoAddDTO.Remark,
                Deadline = (DateTime)projectTodoAddDTO.Deadline!,
                Created = DateTime.Now,
                Status = projectTodoAddDTO.Status,
                ProjectId = projectTodoAddDTO.ProjectId
            };

            ctx.ProjectTodos.Add(todo);
            await ctx.SaveChangesAsync();

            projectTodoAddDTO.Created = todo.Created;
            projectTodoAddDTO.Id = todo.Id;

            return Ok(projectTodoAddDTO);
        }

        [HttpPut("{id}")]
        public async Task<ActionResult<ProjectEditDTO>> EditProject(ProjectEditDTO projectEditDTO, long id)
        {
            using ProjectTodoContext ctx = new ProjectTodoContext();

            var project = await ctx.Projects.FindAsync(id);
            if (project == null)
            {
                return NotFound();
            }

            project.Name = projectEditDTO.Name;
            project.Description = projectEditDTO.Description;
            project.Owner = projectEditDTO.Owner;
            project.Status = projectEditDTO.Status;
            
            await ctx.SaveChangesAsync();

            projectEditDTO.Id = project.Id;
            projectEditDTO.Created = project.Created;

            return Ok(projectEditDTO);
        }

        [HttpPut("{id}")]
        public async Task<ActionResult<ProjectTodoEditDTO>> EditProjectTodo(ProjectTodoEditDTO projectTodoEditDTO, long id)
        {
            using ProjectTodoContext ctx = new ProjectTodoContext();

            var todo = await ctx.ProjectTodos.FindAsync(id);
            if (todo == null)
            {
                return NotFound();
            }

            todo.Item = projectTodoEditDTO.Item!;
            todo.Description = projectTodoEditDTO?.Description;
            todo.Owner = projectTodoEditDTO?.Owner;
            todo.Remark = projectTodoEditDTO?.Remark;
            todo.Status = projectTodoEditDTO?.Status;
            if (projectTodoEditDTO?.Deadline != null)
            {
                todo.Deadline = projectTodoEditDTO.Deadline.Value;
            }

            await ctx.SaveChangesAsync();

            projectTodoEditDTO!.Id = todo.Id;
            projectTodoEditDTO.ProjectId = todo.ProjectId;

            return Ok(projectTodoEditDTO);
        }

        [HttpDelete("{id}")]
        public async Task<IActionResult> DeleteProject(long id)
        {
            using ProjectTodoContext ctx = new ProjectTodoContext();

            var project = await ctx.Projects.FindAsync(id);
            if (project == null)
            {
                return NotFound();
            }

            ctx.Projects.Remove(project);
            await ctx.SaveChangesAsync();

            return NoContent();
        }

        [HttpDelete("{id}")]
        public async Task<IActionResult> DeleteProjectTodo(long id)
        {
            using ProjectTodoContext ctx = new ProjectTodoContext();

            var todo = await ctx.ProjectTodos.FindAsync(id);
            if (todo == null)
            {
                return NotFound();
            }

            ctx.ProjectTodos.Remove(todo);
            await ctx.SaveChangesAsync();

            return NoContent();
        }
    }
}

使用 SQL Server Management Studio 创建数据库 project ,再从 Visual Studio 上打开程序包管理控制器,分别输入:Add-Migration xxxx、Update-Database,通过 EF Core 建立数据库迁移,建表;

最后在 Visual Studio 上运行项目,在一个控制台应用被启动之后,项目也就跑起来了,上述代码中包含了异步操作,也有一些 LINQ操作,基本演示了一个 ASP.NET CORE WEB API的小项目。