1.项目搭建与完成路线模块

发布时间 2023-12-20 12:51:38作者: Purearc

一、DotNet Core的发展

(一)DotNetFramework和DotNetCore

​ 在DotNetCore出现之前,微软的应用开发主体是面向自家的Windows操作系统,早在2002年的时候,微软发布了.NetFrameWork的早期版本,即DotNet1.0版本,秉承着开源侵犯知识产权的心理,对于DotNetFrameWork这一条产品线,微软始终没有进行开源,而且DotNetFramework也只能在自家Windows平台上跑。

​ 直到2015年,微软决定开启一条全新的跨平台产品线:.NET Core,并完全开源。并且也继续维持着DotNetFramework这条产品线,当然这个历史的孤儿、微软的爹、国内Java大环境下的孙子最终也在2019年的.NET4.8版本被微软放弃——4了。在这篇文章中所说的.net指的都是.NETCore,截至到目前,微软已经在这个月发布了DotNETCore8.0版本。

image-20231122210048451

(二)DotNet和C#

​ 此处将会和Java和JVM的关系进行类推比较,如果你学过JVM的相关知识,相信理解DotNet也会非常轻松。

​ C# 是在 .NET 平台上的主要开发语言之一,而 .NET 平台同时支持其他语言,如 VB.NET、F# 等。在 Java 中,跨平台的特性主要是由于 Java 虚拟机(JVM)的存在,其口号是“一次编译,到处运行”(Write Once, Run Anywhere)。相比之下,C# 和 .NET 平台的跨平台性主要是由 .NET 平台本身提供的,不仅限于特定语言的编译产物。

​ 与 Java 不同,C# 通过 .NET 平台的编译器(如 csc)生成的中间语言代码(IL)会被包含在托管程序集中,这可以是 .exe.dll 文件。这些文件通常包含 .NET 代码和元数据,而不是直接的机器指令。.NET 平台充当了运行托管程序集的环境,提供了 .NET Runtime(也称为 Common Language Runtime,CLR)。CLR 负责将 IL 编译成机器代码,并在运行时执行。这使得在不同操作系统上运行相同的 .NET 程序成为可能。

​ 因此,C# 和 .NET 平台的跨平台支持并不依赖于类似 Java 的中间模块翻译成机器指令的工具。相反,它依赖于 CLR 在不同平台上的实现,这使得 .NET 程序能够在各种操作系统上运行,包括 Windows、Linux、和 macOS 等。

image-20231123151545549

二、从InteljiIDEA 到 Rider你需要了解什么

​ 在 Rider 中创建一个 WebAPI 的解决方案默认会有以下文件

image-20231201160826537

(一)依赖项

​ .NET 平台使用 NuGet(读作new get)作为依赖的管理工具,对应在进行 Java 开发时的 Maven,所生成的依赖项相关的文件以.csproj结尾,对应 Spring 项目中的 pom.xml 文件。

image-20231201161059530

​ 把上面的视图中的“解决方案”更改为“文件系统”就能看到。

image-20231201161158062

(二)项目的启动信息

Properties 文件夹通常包含一些项目的属性配置和设置,其中的 launchSettings.json是由 .NET 自动生成,用于配置项目启动时的设置,比如说生成的这个文件就包含对启动 URL 的配置,包括是通过 IIS 启动还是 HTTP 启动。

image-20231201161629218

(三)应用程序的配置信息

appsettings.json用于存储应用程序的配置信息。这种配置信息可能包括数据库连接字符串、API密钥、日志级别、第三方服务的配置等。相当于 SpringBoot 中的application.yml

appsettings.Development.jsonappsettings.json 的环境特定配置文件之一。当你的应用程序在开发环境中运行时,ASP.NET Core会加载 appsettings.json 以及 appsettings.Development.json 文件,将它们的内容组合起来形成应用程序的配置。这样,你可以在不同的环境中配置不同的参数,例如数据库连接字符串、日志级别等。

(四)HTTP请求文件

WebApplication1.http 文件通常是由 JetBrains Rider IDE 自动生成的 HTTP 请求文件。这种文件主要用于测试和调试你的Web API或Web应用程序。在这个文件中,你可以定义HTTP请求,并执行它们来测试你的API的不同端点。这对于调试和验证API端点的行为非常有用。这些HTTP请求文件使用的是一种简单的文本格式,可以通过Rider的界面轻松编辑和执行。

@WebApplication1_HostAddress = http://localhost:5262

GET {{WebApplication1_HostAddress}}/weatherforecast/
Accept: application/json

###

​ 这些请求可以通过右键点击文件中的请求并选择执行,或者使用Rider的一些快捷键和命令来运行。这样你就可以直接在IDE中测试你的API而无需使用专门的API测试工具。(谁™用这玩意?)

(五)Program.cs

​ 在.NET Core和ASP.NET Core应用程序中,Program.cs 文件是应用程序的入口点(entry point)。该文件包含应用程序的 Main 方法,是应用程序启动时第一个执行的代码块。

​ 在 .NET6 之前的版本中除了生成Program.cs还会有Startup.cs,对于 .NET 6 项目,现在已经找不到 Startup.cs 文件。默认情况下,此文件已经被删除,并且 Program.cs 是配置依赖注入服务和 Middleware 的新位置。

​ 1、 创建 WebApplication 构建器

var builder = WebApplication.CreateBuilder(args);

​ 创建了一个 WebApplication 构建器,用于配置和构建应用程序,这就非常像mybatis中的SqlSessionFactoryBuilder sqlSessionFactoryBuilder = new SqlSessionFactoryBuilder();二者都用到了建造者模式。

​ 2、注册服务

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

​ 在服务集合中注册了 API Explorer 和 Swagger 生成器,这两个服务通常用于生成和展示 API 文档。

​ 3、构建 WebApplication 对象 、配置 Swagger

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

​ 这里使用 builder.Build() 构建了 WebApplication 对象。如果应用程序运行在开发环境中,就启用 Swagger 相关的中间件,允许通过 /swagger 访问 Swagger UI。

​ 4、启用 HTTPS 重定向中间件

app.UseHttpsRedirection();

​ 启用 HTTPS 重定向,将 HTTP 请求重定向到 HTTPS。

​ 5、模拟天气概要测试数据

var summaries = new[]
{
    "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};

​ 创建了一个包含一些天气概要的字符串数组。

6、注册 MapGet 端点并配置 OpenAPI 支持

app.MapGet("/weatherforecast", () =>
    {
        var forecast = Enumerable.Range(1, 5).Select(index =>
                new WeatherForecast
                (
                    DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
                    Random.Shared.Next(-20, 55),
                    summaries[Random.Shared.Next(summaries.Length)]
                ))
            .ToArray();
        return forecast;
    })
    .WithName("GetWeatherForecast")
    .WithOpenApi();

​ 注册了一个处理 GET 请求的 Lambda 表达式,表示 /weatherforecast 端点。使用 .WithName 方法为该端点命名,使用 .WithOpenApi 方法启用 OpenAPI(前身是 Swagger)支持。

​ 7、定义天气预报记录类型

record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
{
    public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}

​ 定义了一个 C# 记录类型 WeatherForecast,表示天气预报的数据结构。这个记录类型包含日期、摄氏温度和概要等属性。

​ 8、运行应用程序

app.Run();

三、项目框架搭建

​ .NET平台提供了EntityFramework对象关系映射(ORM)框架,用于简化在.NET应用程序中与数据库的交互,这和在SpringBoot中使用到的 Mybatis 是一样的作用,使用 EF 需要在 NuGet 中引入两个依赖Microsoft.EntityFrameworkCoreMicrosoft.EntityFrameworkCore.Tools,前者是 EF 的核心库,包含了定义实体、创建和维护数据库模型、执行查询等基本功能,后者包含了进行数据迁移的工具等 。

image-20231127215933046

​ EF 支持三种工作方式:

​ DBFirst -> 基于已存在的数据库,利用某些工具(如VS提供的EF设计器)创建实体类,数据库对象与实体类的匹配关系.

​ Model First -> 利用某些工具(如VS的EF设计器)设计出可视化的实体数据模型及他们之间的关系,然后再根据这些实体、关系去生成数据库对象及相关代码文件

​ Code First -> 先写一些代码,如实体对象,数据关系等,然后根据已有的代码描述,自动创建数据对象。

在这里我们使用的是第三种,个人感觉也是最方便的一种。

(一)定义Models

​ 此处主要包含两个实体对象 TouristRouteTouristRoutePicture,这里由于二者都是一个实体,并不是实体的集合,所以采用单数形式,同时需要注意namespace的写法。

​ 在编写好实体类之后可以使用ystem.ComponentModel.DataAnnotations命名空间对实体类中的字段加Attribute(Java中称为Annotation)来对字段做说明或者限制;以下用到的应该都是见名知意的,需要注意的是对于值类型的字段如果是可以为空在 CS 的语法中体现为 public int? Id;若是不为空可加上Attribute来进行说明。

namespace FakeXiecheng.Models;
public class TouristRoute
{
    [Key]
    public Guid Id { get; set; }
    [Required]
    [MaxLength(100)]
    public string Title { get; set; }
    [Required]
    [MaxLength(1500)]
    public string Description { get; set; }
    [Column(TypeName = "decimal(18, 2)")]
    public decimal OriginalPrice { get; set; }
    [Range(0.0, 1.0)]
    public double? DiscountPresent { get; set; }
    public DateTime CreateTime { get; set; }
    public DateTime? UpdateTime { get; set; }
    public DateTime? DepartureTime { get; set; }
    [MaxLength]
    public string Features { get; set; }
    [MaxLength]
    public string Fees { get; set; }
    [MaxLength]
    public string Notes { get; set; }
    public ICollection<TouristRoutePicture> TouristRoutePictures { get; set; } 
        = new List<TouristRoutePicture>();
    public double? Rating { get; set; }
    public TravelDays? TravelDays { get; set; }
    public TripType? TripType { get; set; }
    public DepartureCity? DepartureCity { get; set; }
}

​ 其中最后的几个字段可以设计成枚举来保证实体类设计的合理性;

namespace FakeXiecheng.Models;
public enum DepartureCity
{
    Beijing, //北京
    Shanghai, //上海
    Canton, //广州
    Shenzhen, //深圳
}
public enum TravelDays
{
    One,
    Two,
    Three,
    Four,
    Five,
    Six,
    Seven,
    Eight,
    EightPlus 
}
public enum TripType
{
    HotelAndAttractions, //酒店+景点
    Group, //跟团游
    PrivateGroup, //私家团
    BackPackTour, //自由行
    SemiBackPackTour//半自助游
}

​ 在旅游路线之中不仅将TouristRouteId作为和实体TouristRoute相关联的外键,还加入导航属性public TouristRoute TouristRoute即指明该图片是属于哪个TouristRoute实体,这样就可以直接通过TouristRoute来获取与其相关的图片。

namespace FakeXiecheng.Models;
public class TouristRoutePicture
{
    [Key] 
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int Id { get; set; }
    [MaxLength(100)]
    public string Url { get; set; }
    [ForeignKey("TouristRouteId")]
    public Guid TouristRouteId { get; set; }
    public TouristRoute TouristRoute { get; set; }
}

(二)连接Mysql并生成数据

​ 在进行数据操作之前首先要连接上数据库,就像是在 Spring 中需要导入mysql-java-connector一样,在 .NET 中也需要导入相关依赖Pomelo.EntityFrameworkCore.MySql(官方的太拉了,用柚子社的);我使用的是JetBrainsRider自己的习惯是现在右侧边栏连接上数据库建好自己的库之后再进行开发,选项就是UTF8mb4 utf8mb4_general_ci ,创建的数据库的名字叫FakeXiechengDb

image-20231127222317751

​ 在有了相关依赖之后就可以创建数据库的上下文对象了,在Program.cs中将相关服务添加到IoC容器中,并通过读取配置文件的方式获得数据库连接的字符串;

// 添加对 appsettings.json 的配置支持
builder.Configuration.AddJsonFile("appsettings.json");
var conn = builder.Configuration.GetValue<string>("ConnectionStrings:mysql");
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseMySql(conn,new MySqlServerVersion(new Version(8, 0, 34)))
);
  "ConnectionStrings": {
    "mysql": "Server=; port=3306; Database=; uid=; pwd="
  }

​ 此时我们还没有获得 DB的上下文对象,下面自定义一个类继承DbContext来获得数据库的上下文对象,并且通过 JSON 文件来生成种子数据;通过重写void OnModelCreating(ModelBuilder modelBuilder)以修改实体框架模型的默认配置。可以使用该方法添加或删除实体、配置关系、添加用户定义的配置等;

​ 向构造函数中传递的这个option就是在上边的program.cs中配置的option;在 DbContext 类的构造函数中,需要接收 DbContextOptions 的实例,以便配置和构建数据库上下文。因此,通过将参数传递给父类的构造函数你实际上是在告诉 Entity Framework Core如何配置数据库上下文。

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
    {
    }
    public DbSet<TouristRoute> TouristRoutes { get; set; }
    public DbSet<TouristRoutePicture> TouristRoutePictures { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // 通过 json 文件获取种子数据生成数据,更加灵活
        var touristRouteJsonData = File.ReadAllText(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) + @"/Data/touristRoutesMockData.json");
        IList<TouristRoute> touristRoutes = JsonConvert.DeserializeObject<IList<TouristRoute>>(touristRouteJsonData);
        modelBuilder.Entity<TouristRoute>().HasData(touristRoutes);

        var touristRoutePictureJsonData = File.ReadAllText(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) + @"/Data/touristRoutePicturesMockData.json");
        IList<TouristRoutePicture> touristRoutePictures = JsonConvert.DeserializeObject<IList<TouristRoutePicture>>(touristRoutePictureJsonData);
        modelBuilder.Entity<TouristRoutePicture>().HasData(touristRoutePictures);

        base.OnModelCreating(modelBuilder);
    }
}

​ 在上面编写完 Models 之后就可以通过 EF 的 Code First生成相关的数据了,如果你使用的是VS系列的 IDE ,你可以直接打开一个PM控制台(视图->其他窗口->程序包管理控制器),输入命令add-migration <name>来创建一个数据迁移,之后你就会发现在项目中生成了一个Migrations 文件夹,在其中还包含以时间戳开头的类文件,之后执行update-database就可以执行生成的这个迁移计划,等一大串控制台信息然后出现Done之后就说明数据生成完毕了。

image-20231127221917523

​ 如果是JetBrainsRider开发话,好像没有 PM 控制台,所以只能打开终端输入dotnet ef migrations add <name>来生成数据迁移,使用dotnet ef database update来执行计划(需要注意在执行的时候一定要 CD 到csproj文件所在的文件目录中,否则会提醒你No project was found. Change the current working directory or use the --project option.),这其实就相当于在项目中打开 cmd 了。

四、完成产品模块-Get

(一)完成两个基础接口

​ 经过上面的努力我么已经有了操纵数据库的对象dbContext、设计好的数据实体和经过migration后的数据,万事俱备之后就可以编写接口测试了,现在使用最简单最常用的HTTPget请求去获得旅游路线的信息。

1.使用 AutoMapper 映射 Dtos

​ 在前后端进行数据传输的时候,如果我们直接将数据仓库对应的实体进行传输显然是不安全的,而且需要的数据也不一定就和数据实体一一对应,这时候就需要去创建 DTO (Data Transfer Object)来映射实体类。

public class TouristRouteDto
{
        public Guid Id { get; set; }
        public string Title { get; set; }
        public string Description { get; set; }
        // 计算方式:原价 * 折扣
        public decimal Price { get; set; }
        //public decimal OriginalPrice { get; set; }
        //public double? DiscountPresent { get; set; }
        public DateTime CreateTime { get; set; }
        public DateTime? UpdateTime { get; set; }
        public DateTime? DepartureTime { get; set; }
        public string Features { get; set; }
        public string Fees { get; set; }
        public string Notes { get; set; }
        public double? Rating { get; set; }
        public string TravelDays { get; set; }
        public string TripType { get; set; }
        public string DepartureCity { get; set; }
}

​ 所需要的依赖为AutoMapper.Extensions.Microsoft.DependencyInjection,对于相同的字段,AutoMapper 会自动进行映射,首先需要将 AutoMapper 的服务注入到容器中;

// 扫描项目下的 Profile 文件夹,并在构造器中自动映射
builder.Services.AddAutoMapper(AppDomain.CurrentDomain.GetAssemblies());

​ 其次在项目中创建目录Profiles并在该目录下创建 TouristRouteProfile 继承自 AutoMapper 的Profile类,在这个 Profile 中通过构造器中的 CreateMap<源model--目的model>()来进行映射,并使用 ForMember配置源类型和目标类型之间的映射关系。

​ 下面的代码使用了 AutoMapper 库中的 CreateMap 方法,该方法用于创建源类型到目标类型的映射关系,在下面的代码中源类型是 TouristRoute,目标类型是 TouristRouteDto。紧接着使用ForMember方法进行属性映射配置;

​ 以 Price为例,在 Models 中有OriginalPriceDiscountPresent两个属性,在进行数据传输的时候我们只需要前端看见最终的价格就可以,所以直接从原数据(src)中处理并将结果映射给目标(dest)属性(委托)。

namespace FakeXiecheng.Profiles;

public class TouristRouteProfile: Profile
{
    public TouristRouteProfile()
    {
        CreateMap<TouristRoute, TouristRouteDto>()
            // 投影
            .ForMember(
                dest => dest.Price,
                opt => opt.MapFrom(src => src.OriginalPrice * (decimal)(src.DiscountPresent ?? 1))
            )
            .ForMember(
                dest => dest.TravelDays,
                opt => opt.MapFrom(src => src.TravelDays.ToString())
            )
            .ForMember(
                dest => dest.TripType,
                opt => opt.MapFrom(src => src.TripType.ToString())
            )
            .ForMember(
                dest => dest.DepartureCity,
                opt => opt.MapFrom(src => src.DepartureCity.ToString())
            );
    }
}

2.编写控制器

​ 要想成为一个控制器,最规范的方式就是继承ControllerBase类(当然也可以将其命名为 XXXController或者加上[controller]特性),不过都需要在Program.cs中添加builder.Services.AddControllers();注册控制器服务的扩展。

builder.Services.AddControllers();

​ 在控制器中,注入 IMapperITouristRouteRepository的类对象(在springboot-mybatis中这里就是XXXMapper),以api/TouristRoutesapi/TouristRoutes/{touristRouteId}为例分别获取全部和指定 Id 的数据集合。

[ApiController]将表示这是一个 MVC 的控制器,相当于 SpringBoot 中的 RestController,而[Route("api/[controller]")]用于配置访问的路由,相当于 SpringBoot 中的 XXXMapping。当然对于访问量路由[controller]部分,.net 会将其解析它所标注控制器的名字(去掉Controller部分),所以对于这个 url 访问路径就是 GET api/TouristRoutes(或者GET api/touristRoutes无所谓大小写)。

​ 在从数据仓库拿到数据集合之后自然需要_mapper.Map<IEnumerable<TouristRouteDto>>(touristRoutesFromRepo);方法将 Models 之中的数据映射到我们所需要的 Dto 之中,Map 作为一个泛型方法,接受一个<T>和原类型 ,也就是将从数据仓库查出来的touristRoutesFromRepo封装成touristRoutesDto并返回。

IActionResult是 .Net 提供的的用于表示返回值的接口,在之中封装了对 HTTP 响应的各种不同表示形式

namespace FakeXiecheng.Controllers;

[ApiController]
[Route("api/[controller]")]
public class TouristRoutesController : ControllerBase
{
    private ITouristRouteRepository _touristRouteRepository;
    private readonly IMapper _mapper;

    public TouristRoutesController(ITouristRouteRepository touristRouteRepository, IMapper mapper)
    {
        _touristRouteRepository = touristRouteRepository ?? throw new ArgumentNullException(nameof(touristRouteRepository));
        _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper));
    }

    [HttpGet]
    public IActionResult GerTouristRoutes()
    {
        var touristRoutesFromRepo = _touristRouteRepository.GetTouristRoutes();
        if (touristRoutesFromRepo == null || touristRoutesFromRepo.Count() <= 0)
        {
            return NotFound("没有旅游路线");
        }

        var touristRoutesDto = _mapper.Map<IEnumerable<TouristRouteDto>>(touristRoutesFromRepo);
        return Ok(touristRoutesDto);
    }

    // api/touristroutes/{touristRouteId}
    [HttpGet("{touristRouteId}")]
    public IActionResult GetTouristRouteById(Guid touristRouteId)
    {
        var touristRouteFromRepo = _touristRouteRepository.GetTouristRoute(touristRouteId);
        if (touristRouteFromRepo == null)
        {
            return NotFound($"旅游路线{touristRouteId}找不到");
        }
        var touristRouteDto = _mapper.Map<TouristRouteDto>(touristRouteFromRepo);
        return Ok(touristRouteDto);
    }
}

3.Service——数据仓库

​ 在 Services 中定义数据仓库的服务,实现上边控制器中用到的两个服务,获取所有和根据 id 获得。当然在使用自己的服务时也需要提前将其注入到 IoC 容器中;

// 注入自己的服务
builder.Services.AddTransient<ITouristRouteRepository, TouristRouteRepository>();

​ 在 SpringBoot-MybatisPlus 项目中,这一部分就是 Mybatis 的 XXXMapper.java 或者 Mapper 文件了,这一部分是对数据库的 CURD,所以先统称为数据仓库。

​ 在数据仓库中自然少不了数据库上下文,所以使用构造器将 DbContext 注入,我们就有了和数据库进行交互的工具。在数据仓库中我们主要使用到的就是 LINQ的语法,这种集成查询的方式非常第银杏化。对于查询所有的数据直接使用 DB上下文 -- OGNL -- 数据实体就直接 return 到控制器中进行和 Dto 的映射,而查询单个使用到了聚合函数FirstOrDefault用 lambda 表达式传入参数。

namespace FakeXiecheng.Services;
public interface ITouristRouteRepository
{
    IEnumerable<TouristRoute> GetTouristRoutes();
    TouristRoute GetTouristRoute(Guid touristRouteId);
}

namespace FakeXiecheng.Services.impl;
public class TouristRouteRepository: ITouristRouteRepository
{
    private readonly AppDbContext _dbContext;

    public TouristRouteRepository(AppDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public IEnumerable<TouristRoute> GetTouristRoutes()
    {
        return _dbContext.TouristRoutes;
    }

    public TouristRoute GetTouristRoute(Guid touristRouteId)
    {
        return _dbContext.TouristRoutes.FirstOrDefault(n => n.Id == touristRouteId);
    }
}

​ 经过上边的步骤,简单的框架就搭建完成。

(二)内容协商

​ 内容协商是一种根据客户端的需求和服务器资源的特性来选择合适的表示形式(通常是媒体类型)的过程。

​ 在单点登录系统中,OAuth(Open Authorization)和 SAML(Security Assertion Markup Language)是两种不同的身份验证和授权协议。OAuth 要求服务器端返回 JSON 格式的数据,而 SAML 需要返回 XML 的数据,所以能让服务器端返回想要的格式也是非常重要的。

​ 访问 “获取所有旅游路线的信息” 时在 HEADER 中设置返回数据格式为 XML ,依旧返回了 JSON 的默认格式,响应码为 200OK;这明显是不符合要求。

image-20231202202250759

​ 在 “注册控制器服务” 时,使用 lambda 表达式配置内容:①配置当服务器无法满足客户端请求的内容类型时返回406;②开启对 XML 返回格式的支持。

builder.Services.AddControllers(
    setUpAction =>
    {
        // 在无法满足客户端请求的内容类型时返回406
        setUpAction.ReturnHttpNotAcceptable = true;
        // 传统方式
        // setUpAction.OutputFormatters.Add(new XmlDataContractSerializerOutputFormatter());
    }   
    ).AddXmlDataContractSerializerFormatters(); //开启对xml的格式支持

​ 配置之后的效果为:

image-20231202201825496

​ 此时请求 XML 格式的返回就可以正常返回

image-20231202211947025

(三)获取嵌套对象关系型数据

1. 根据路线 ID 获得图片

​ 在前面的内容中已经实现了“获取路由路线的信息”服务,而旅游路线的图片作为旅游路线信息的二级对象,对其父对象有很高的依赖性,我们希望用户通过 旅游路线信息 去获得相关的图片,也就是 通过旅游路线的 id 获得相关的图片。在此之前,我们同样需要把要传输的信息给 “Model” 一下——创建获得图片的 Dto 并使用 AutoMapper 进行映射的配置。

​ 在 TouristRoutePictures 的 Model 中并不需要在映射时对某个属性做特殊的映射处理,所以直接照抄过来就行,同样在 Profile 中使用构造器配置将TouristRoutePicture--TouristRoutePictureDto>

namespace FakeXiecheng.Dtos;
public class TouristRoutePictureDto
{
    public int id { get; set; }
    public string url { get; set; }
    public Guid touristRouteId { get; set; }
}

namespace FakeXiecheng.Profiles;
public class TouristRoutePictureProfile : Profile
{
    public TouristRoutePictureProfile()
    {
        CreateMap<TouristRoutePicture, TouristRoutePictureDto>();
    }
}

​ 注意下面控制器的 URL api/touristRoutes/{touristRouteId}/pictures ,同样需要使用 Service 层的数据仓库 touristRouteRepositoryIMapper进行 Dto 和 Model 的映射。

[Route("api/touristRoutes/{touristRouteId}/pictures")]
    [ApiController]
    public class TouristRoutePicturesController : ControllerBase
    {
        private ITouristRouteRepository _touristRouteRepository;
        private IMapper _mapper;

        public TouristRoutePicturesController(ITouristRouteRepository touristRouteRepository,IMapper mapper)
        {
            _touristRouteRepository = touristRouteRepository ??
                throw new ArgumentNullException(nameof(touristRouteRepository));
            _mapper = mapper ??
                throw new ArgumentNullException(nameof(mapper));
        }
        [HttpGet]
        public IActionResult GetPictureListForTouristRoute(Guid touristRouteId)
        {
            // 先进行父对象的判断
            if (!_touristRouteRepository.TouristRouteExists(touristRouteId)) 
            {
                return NotFound("旅游线路不存在");    
            }
            var picturesFromRepo = _touristRouteRepository.GetPicturesByTouristRouteId(touristRouteId);
            if(picturesFromRepo==null|| picturesFromRepo.Count() <= 0)
            {
                return NotFound("照片不存在");
            }
            return Ok(_mapper.Map<IEnumerable<TouristRoutePictureDto>>(picturesFromRepo));
        }

​ 对于可能用到判空直接将其抽取成一个单独的服务,需要注意 LINK 有延时执行的特性,也就是不论是TouristRouteExists中的ToList()Any()FirstOrDefault() 都是为了真正去执行 SQL ,如果return _dbContext.TouristRoutePictures.Where(n => n.TouristRouteId == touristRouteId)相当于 SQL 处于就绪的状态,仅仅是构建好了待执行的语句。

namespace FakeXiecheng.Services.impl;    
    public bool TouristRouteExists(Guid touristRouteId)
    {
        return _dbContext.TouristRoutes.Any(n => n.Id == touristRouteId);
    }

    public IEnumerable<TouristRoutePicture> GetPicturesByTouristRouteId(Guid touristRouteId)
    {
        return _dbContext.TouristRoutePictures.Where(n => n.TouristRouteId == touristRouteId).ToList();
    }

image-20231205154123463

2.单独获得子资源

​ 对于上面的接口获得了一条路线对应图片的集合,假如以后要有一个下载图片的服务,肯定是要具体下载某一张图片,所以还需单独获得子资源的接口GET api/touristRoutes/{touristRouteId}/{pictureId},在设计 REST Api 的时候对于有依赖关系的资源,最好先去判断父资源的存在与否,如果父资源不存在最好不要去找子资源,所以 Action 中也应该接受两个参数

    namespace FakeXiecheng.Controllers;
    [HttpGet("{pictureId}")]
    public IActionResult GetPictureById(Guid touristRouteId, int pictureId)
    {
        if (!_touristRouteRepository.TouristRouteExists(touristRouteId))
        {
            return NotFound("路线不存在");
        }
        var touristRoutePictureFromReop = _touristRouteRepository.GetPictureById(pictureId);
        var touristRoutePictureDto = _mapper.Map<TouristRoutePictureDto>(touristRoutePictureFromReop);
        return Ok(touristRoutePictureDto);
    }
    
    public TouristRoutePicture GetPictureById(int pictureId)
    {
        return _dbContext.TouristRoutePictures.FirstOrDefault(n => n.Id == pictureId);
    }

​ 现在访问的 URL 就被分成了两部分 touristroutes/{父资源ID}picture/{子资源ID} ,并且在接口内我们先是根据传入的父资源 ID 去检查了父资源是否存在。

image-20231205160440045

3.完善获取路线信息的接口

​ 在上文获取旅游路线信息的时候我们并没有将 “图片” 这一嵌套子对象放入 TouristRouteDto 中,而在实际上,在筛选出信息条目之后肯定要向用户展示对应的图片,即在获得父资源的同时就要同时获得子资源要实现这个功能需要完善TouristRoutDto,将TouristRoutePicture对象放进去;另外在TouristRouteRepository进行数据库查询的时候还要通过TouristRouteId这一外键将相关的图片一起查询出来。

​ 在 LINQ to Entities 中,Include 方法用于指定在查询中包含关联实体的数据。通常,当你在 LINQ 查询中获取一个实体,并且该实体有关联的导航属性时(),如果你想要一次性获取关联实体的数据,而不是后续再通过额外的数据库查询获取,就可以使用 Include。在表达式中n => n.TouristRoutePictures 表示每个 TouristRoute 实体要包含其导航属性 TouristRoutePictures 属性。

namespace FakeXiecheng.Dtos;
public class TouristRouteDto
{
// 其他属性
    	// 顺便进行初始化防止出现空指针
        public ICollection<TouristRoutePictureDto> TouristRoutePictures { get; set; } =
                new List<TouristRoutePictureDto>();
}
namespace FakeXiecheng.Service.impl;
    public IEnumerable<TouristRoute> GetTouristRoutes()
    {
        return _dbContext.TouristRoutes.Include(n => n.TouristRoutePictures);
    }

    public TouristRoute GetTouristRoute(Guid touristRouteId)
    {
        return _dbContext.TouristRoutes.Include(n => n.TouristRoutePictures).FirstOrDefault(n => n.Id == touristRouteId);
    }

image-20231206145841178

4.添加Head请求支持

​ 在ASP.NET Core中,[HttpHead] 是一个特性(Attribute),用于标记一个方法或控制器动作(action)支持处理 HTTP HEAD 请求。HTTP HEAD 请求与 HTTP GET 请求类似,但服务器在响应中不返回实体主体。它通常用于检索资源的头部信息,而不需要实际的资源内容。使用 HttpHead 请求不仅能减少带宽的开销,和可以实现获取资源的元数据、获取资源的元数据、验证缓存、检查资源的更新状态等。

    // api/touristroutes/{touristRouteId}
    [HttpGet("{touristRouteId}")]
    [HttpHead("{touristRouteId}")]
    public IActionResult GetTouristRouteById(Guid touristRouteId)
    
    [HttpGet]
    [HttpHead]
    public IActionResult GerTouristRoutes()

image-20231206154331663

(四)添加筛选条件

1.了解.NETCore 向 API 传递参数的方式

​ 在.NET Core中,可以使用多种方式向API传递参数:

image-20231128093948163

[FromeQuery]:http://example.com/api/controller/action?param1=value1&param2=value2

[FromRoute]:指定路由为[HttpGet("api/controller/action/{param1}/{param2}")],则传入参数的方式为http://example.com/api/controller/action/value1/value2

[FromBody]:配置路由[HttpPost("api/controller/action")],在Action中将参数加上Attributepublic IActionResult PostAction([FromBody] YourDtoClass dto)

[FromForm]:从表单获取数据将路由配置为[HttpPost("api/controller/action")],在Action中为public IActionResult PostAction([FromForm] YourFormModel formModel)

2.向 API 中传入筛选条件

​ 通过传递参数,可以实现对指定条件的过滤,在 .NET 中通过 EFCore 实现和动态SQL+模糊查询一样的效果。

​ 对于 Action 一样返回需要的 Dto,就可以,只不过需要添加向 API 内传入的参数,由于添加了[ApiController] 所以 .NETCore 会自动识别传入参数的方式,但是为了更易读,加上还是更好,况且使用name属性还能解决前后端参数不匹配的问题。

 [HttpGet]
    [HttpHead]
    // api/touristroutes?keyword=xxx
    public IActionResult GetTouristRoutes([FromQuery(Name = "keyword")]string keyword)

​ 完成条件查询的重点就存在于Service中:

​ 这里使用IQueryable<>接受 LINQ to SQL 的返回结果,相当于在 Mybatis中的QueryWapper,来先将关联路线图片构造进将要执行的 SQL 语句中;如果不传该值(或为空)则不进行筛选,否则返回符合的结果集,此处用于筛选的字段为 DB 中的 Title ,以此实现动态 SQL 的效果。

    public IEnumerable<TouristRoute> GetTouristRoutes(string keyword)
    {
        IQueryable<TouristRoute> result = _dbContext.TouristRoutes.Include(t => t.TouristRoutePictures);
        if (!string.IsNullOrWhiteSpace(keyword))
        {
            result = result.Where(t => t.Title.Contains(keyword));
        }
        return result.ToList();
    }

​ 请求 API 为/api/touristroutes?keyword=XX

image-20231211105505568

3.IQueryable 的执行过程

IQueryable 接口代表一个可查询的数据源,通常与数据库查询交互。它允许你构建查询表达式,而不会立即执行查询。查询表达式最终会转换为底层数据存储的查询语句,例如 SQL 查询语句。

(1)创建查询: 通过 LINQ 查询语法或方法语法创建一个 IQueryable 查询

(2)构建查询表达式: 查询表达式描述了要在数据源上执行的操作。

(3)延迟加载: IQueryable 具有延迟加载的特性,因此在构建查询时,实际上并没有执行查询。只有在需要结果时,如迭代查询的结果或执行像 ToList()First()Single() 这样的操作时,查询才会被执行。

(4)转换为目标存储的查询语句:IQueryable 查询被执行时,框架会将查询表达式转换为目标存储的查询语句。

(5)执行查询: 转换后的查询语句被发送到底层数据存储(如数据库),并且结果被返回。

image-20231211111116929

4.数据过滤

​ 使用数据库中的字段rating来进行数据过滤,前端向Action中发送的请求将包含对rating的限定,比如说lessThan、largerThan、equalTo,通过这些条件限定过滤不符合条件的数据。

​ 除了需要加入新的参数之外,还需要对传入的参数进行处理,传入数据分为两部分lessThan5——前部分的运算符和后边要比较的数据,使用正则表达式匹配前面的若干个英文字母和后边的若干个数字就可以获得结果;将结果拆分成operatorTyperatingValue传递给 Service 处理;

    // api/touristroutes?keyword=xxx
    public IActionResult GetTouristRoutes([FromQuery(Name = "keyword")]string keyword, [FromQuery]string rating)
    {
        var regex = new Regex(@"([A-Za-z0-9\-]+)(\d+)");
        string operatorType = "";
        int ratingValue = -1;
        var match = regex.Match(rating);
        if (match.Success)
        {
            operatorType = match.Groups[1].Value;
            ratingValue = Int32.Parse(match.Groups[2].Value);
        }
        var touristRoutesFromRepo = _touristRouteRepository.GetTouristRoutes(keyword, operatorType, ratingValue);
        if (touristRoutesFromRepo == null || touristRoutesFromRepo.Count() <= 0)
        {
            return NotFound("没有旅游路线");
        }
        var touristRoutesDto = _mapper.Map<IEnumerable<TouristRouteDto>>(touristRoutesFromRepo);
        return Ok(touristRoutesDto);
    }

​ 在原来构造的IQueryable<> 对象的基础上加入新的条件即可:

        if (ratingValue >= 0)
        {
            result = operatorType switch
            {
                "largerThan" => result.Where(t => t.Rating >= ratingValue),
                "lessThan" => result.Where(t => t.Rating < ratingValue),
                _ => result.Where(t => t.Rating == ratingValue)
            };
        }

image-20231211150416320

5.封装资源过滤器

​ 对于数据集的筛选,上面只给出了两个限定条件,如果以后需要有根据更多的条件进行查询,就需要从Action到数据仓库全部修改一遍,这明显是不符合开闭原则的,不妨将所有的条件都封装到一个对象内,以后有需求直接在该对象中添加属性就行了。

​ 对于要传入的字段Rating来说,使用构造方法将其拆分成RatingOperatorRatingValue两部分,需要注意的是我们在逻辑判断的时候条件为if(ratingValue >= 0)但是真正传入参数的时候可能是/api/TouristRoutes?keyword=X&lessThan这种形式,所以要将该属性声明为 nullable 。

​ 这样前端通过 [FromeQuery] 传入的两个参数在解析的时候被封装成了 TouristRouteResourceParamaters处理,在该类的内部将拆分匹配做好直接传递给数据仓库进行服务的逻辑处理。

amespace FakeXiecheng.ResourceParameters;

public class TouristRouteResourceParamaters
{
    // 对于 Title 字段的关键词查询
    public string? Keyword { get; set; }
    // 对于 Rating 字段的筛选
    public string? RatingOperator { get; set; }
    public int? RatingValue { get; set; }
    
    private string _rating;

    public string? Rating
    {
        get { return _rating; }
        set
        {
            if (!string.IsNullOrWhiteSpace(value))
            {
                Regex regex = new Regex(@"([A-Za-z\-]+)(\d+)");
                Match match = regex.Match(value);
                if (match.Success)
                {
                    RatingOperator = match.Groups[1].Value;
                    RatingValue = Int32.Parse(match.Groups[2].Value);
                }
            }
            _rating = value;
        }
    }
}

​ 由于引入了封装好的资源过滤器,所以 Action 也不用再写复杂的逻辑,专心处理接受和返回即可。

[HttpGet]
    [HttpHead]
    // api/touristroutes?keyword=xxx
    public IActionResult GetTouristRoutes([FromQuery] TouristRouteResourceParamaters paramaters)
    {
        var touristRoutesFromRepo = _touristRouteRepository.GetTouristRoutes(paramaters.Keyword, paramaters.RatingOperator, paramaters.RatingValue);
        if (touristRoutesFromRepo == null || touristRoutesFromRepo.Count() <= 0)
        {
            return NotFound("没有旅游路线");
        }

        var touristRoutesDto = _mapper.Map<IEnumerable<TouristRouteDto>>(touristRoutesFromRepo);
        return Ok(touristRoutesDto);
    }

五、完成产品模块-Post

(一) HTTP方法的安全性和幂等性

​ 安全性是指使用某个 HTTP 方法不会改变服务器状态和资源。安全方法是指对服务器端数据的请求操作,而不会引起服务器状态的变化。安全性考虑的是取值。

​ 同样的操作不管经过多少次调用,返回的数据、或者产生的效果都是一致的。幂等性考虑的是赋值, N次变换与一次变换的结果是相同的

image-20231128104021522

(二)创建旅游路线资源

1.创建路线资源

​ 对于创建旅游路线自然和查询一样需要创建一个 Data Transfer Object,虽然下面的 Dto 和查询的那个大差不差,但是为不同的请求创建独立的 Dto 以后再修改的时候能减少对 UI 层的改动;下面部分暂时不考虑嵌套子对象touristRoutePicture

public class TouristRouteForCreationDto
{
    public string Title { get; set; }
    public string Description { get; set; }
    // 计算方式:原价 * 折扣
    // public decimal Price { get; set; }
    public decimal OriginalPrice { get; set; }
    public double? DiscountPresent { get; set; }
    public DateTime CreateTime { get; set; }
    public DateTime? UpdateTime { get; set; }
    public DateTime? DepartureTime { get; set; }
    public string Features { get; set; }
    public string Fees { get; set; }
    public string Notes { get; set; }
    public double? Rating { get; set; }
    public string TravelDays { get; set; }
    public string TripType { get; set; }
    public string DepartureCity { get; set; }
}

​ 随后我们还要在 Profile 中添加TouristRouteForCreationDto和``TouristRoute`的映射,因为 ID 我们设置的是 GUID ,所以在传参数的时候让服务器生成就行,不需要客户端传 ID 这一参数。

        CreateMap<TouristRouteForCreationDto, TouristRoute>()
            .ForMember(
                dest => dest.Id,
                opt
                    => opt.MapFrom(src => Guid.NewGuid())
            );	

​ 使用[HttpPost]进行请求新建一个资源,前端传入JSON,.NETCore 进行自动数据绑定到TouristRouteForCreationDto,使用 AutoMapper 获得和 Model 相对应的数据传递到 Service 中进行保存;

​ 创建资源成功后会返回201状态码,还是使用 .NET 提供的CreatedAtRoute方法,它可以在资源创建成功之后返回这个资源的位置信息,三个参数为:routeName: 指定要生成 URL 的路由名称;routeValues: 包含用于生成 URL 的路由参数的对象。;value: 要返回的对象。自然是选择GetById来去获得刚刚创建的资源,所以会在GetTouristRouteById这个Action上面给他指定一个Name。

​ 这个Action中实际上做了两次映射,第一次是将DtoForCreation对象映射到 Model上共给服务来进行数据库的写入操作,而第二次是将Model的数据映射到TouristRouteDto上,这个 DTO 是为了查询而建立的 DTO。

    // Post /api/touristroutes
    [HttpPost]
    public IActionResult CreateTouristRoute([FromBody] TouristRouteForCreationDto touristRouteForCreationDto)
    {
        var touristRouteModel = _mapper.Map<TouristRoute>(touristRouteForCreationDto);
        _touristRouteRepository.AddTouristRoute(touristRouteModel);
        _touristRouteRepository.Save();
        var touristRouteToReturn = _mapper.Map<TouristRouteDto>(touristRouteModel);
        return CreatedAtRoute(
            "GetTouristRouteById", new { touristRouteId = touristRouteToReturn.Id },
            touristRouteToReturn
        );
    }

    [HttpGet("{touristRouteId}",Name = "GetTouristRouteById")]
    public IActionResult GetTouristRouteById(Guid touristRouteId)

​ 数据仓库这边没声明可说的,需要注意的是写入数据库我们可以单独提出来,我估计是和 Spring 接管事务的机制一样会把自动提交事务关闭。(等哥们研究出来再细说)

    public void AddTouristRoute(TouristRoute touristRoute)
    {
        if (touristRoute == null)
        {
            throw new ArgumentNullException(nameof(touristRoute));
        }

        _dbContext.TouristRoutes.Add(touristRoute);
    }
    
    public bool Save()
    {
        return (_dbContext.SaveChanges() >= 0);
    }
{
    "title":"test-title2",
    "description":"test-description2",
    "originalPrice":1145.14,
    "discountPercent":0.9,
    "points":220,
    "coupons":null,
    "fees":"test-fees2",
    "notes":"test-notes2",
    "features":"test-features2",
    "tripType":"Group",
    "travelDays":"Four",
    "departureCity":"Beijing"
}	

image-20231212121629743

​ 可以看到在Header中返回了我们需要的资源所在位置,通过头部的返回信息,就实现了一个轻量级的 API 自我发现功能。

image-20231212143011618

2.单独创建子资源

​ 对于图片资源的创建,为创建图片添加 Data Transfer Object,由于图片的其他两个属性主键和外键都是由数据库或者程序创建的,所以我们并不关心。

public class TouristRoutePictureForCreationDto
{
    public string Url { get; set; }
}

​ 在 Profile 文件中添加 Dto 和 Model 的映射关系;

public class TouristRoutePictureProfile : Profile
{
    public TouristRoutePictureProfile()
    {
        CreateMap<TouristRoutePicture, TouristRoutePictureDto>();
        // 映射关系
        CreateMap<TouristRoutePictureForCreationDto, TouristRoutePicture>();
    }
} 

​ 编写Action:在下面的Action中接受两个参数——touristRouteId作为外键和路线关联,touristRoutePictureForCreationDto需要创建的资源的主要信息。单独创建子资源依赖其父对象,故需要检测父资源的存在性,经过第一次AutoMapper映射成 Model 调用 Repo中的服务进行新建资源操作,成功之后反向映射回馈给客户端新建资源的位置,同样需要在GetPictureById标注名字,表示:插入成功之后调用改Action找到资源位置。

    // Post api/touristRoutes/{touristRouteId}/pictures
    [HttpPost]
    public IActionResult CreateTouristRoutePicture([FromRoute] Guid touristRouteId, [FromBody] TouristRoutePictureForCreationDto touristRoutePictureForCreationDto)
    {
        if (!_touristRouteRepository.TouristRouteExists(touristRouteId))
        {
            return NotFound($"旅游路线{touristRouteId}找不到");
        }
        var pictureModel = _mapper.Map<TouristRoutePicture>(touristRoutePictureForCreationDto);
        _touristRouteRepository.AddTouristRoutePicture(touristRouteId, pictureModel);
        _touristRouteRepository.Save();
        var pictureToReturn = _mapper.Map<TouristRoutePictureDto>(pictureModel);
        return CreatedAtRoute(
            "GetPicture",
            new
            {
                touristRouteId = pictureModel.TouristRouteId,
                pictureId = pictureModel.Id
            },
            pictureToReturn
        );
    }

 [HttpGet("{pictureId}", Name = "GetPicture")]
    public IActionResult GetPictureById(Guid touristRouteId, int pictureId)

​ 在数据仓库中完成 Add 操作,其中的touristRoutePicture.TouristRouteId = touristRouteId;为该图片的外键赋值。

    public void AddTouristRoutePicture(Guid touristRouteId, TouristRoutePicture touristRoutePicture)
    {
        if (touristRouteId == Guid.Empty)
        {
            throw new ArgumentNullException(nameof(touristRouteId));
        }
        if (touristRoutePicture == null)
        {
            throw new ArgumentNullException(nameof(touristRoutePicture));
        }
        touristRoutePicture.TouristRouteId = touristRouteId;
        _dbContext.TouristRoutePictures.Add(touristRoutePicture);
    }

​ 这样就在TouristRoute 2430bf64-fd56-460c-8b75-da0a1d1cd74c 这个父对象下创建了 id 为69 的子对象。

image-20231213153741469

3.完善路线创建

​ 在上面创建路线的时候没有将创建图片这一项目加入,使用AutoMapper就能以简单的方式完成这一功能:在TouristRouteForCreationDto中加入下面的属性即可,这是因为AutoMapper会自动映射相同的名称,对于这个属性我们在Model中的名字也是TouristRoutePictures,所以 .NET 在接受 JSON 数据完成数据绑定之后进行 .Map操作的时候就将TouristRoutePictureForCreationDto上的TouristRoutePictures属性自动映射到Model上。

        public ICollection<TouristRoutePictureForCreationDto> TouristRoutePictures { get; set; }
            = new List<TouristRoutePictureForCreationDto>();

image-20231213154650460

(三)数据验证

​ 在开发中,对数据进行验证是确保应用程序接收到有效和符合预期的数据的一项关键任务。数据验证有助于确保系统的安全性、稳定性和正确性。

​ 假设在新建路线的 JSON 数据中将title的数据类型改成了int,发送请求后会返回400的异常并告诉我们数据校验发生了错误,在之前的代码设计实体类中使用到了System.ComponentModel.DataAnnotations中的内置约束对数据库的字段进行了限制,但是这个错误明显是在写入数据库的时候产生的,不正确的数据到达了数据库是非常危险的,所以在到达和数据库的直接交互之前,也就是在数据传输到服务器端之后就应该先进行数据校验。

image-20231214104734381

1.使用内置约束

​ 在 DTO 中引入 System.ComponentModel.DataAnnotations;命名空间,为Title等属性加上限制。

namespace FakeXiecheng.Dtos;

public class TouristRouteForCreationDto
{
    [Required(ErrorMessage = "title 不可为空")]
    [MaxLength(100)]
    public string Title { get; set; }
    [Required]
    [MaxLength(1500)]
    public string Description { get; set; }
    // 省略部分
 }

​ 使用该命名空间的Attribute和属性就能够在 DTO 层面上对传入的数据进行限制并将错误信息反馈给客户端。

image-20231214105433737

2.在属性上自定义约束

​ 使用System.ComponentModel.DataAnnotations;中的内置Attribute只是对某个数据起了限制作用,如果是其中的多个数据有关联关系的限制,使用实现接口的方式会是更好的选择。

public class TouristRouteForCreationDto: IValidatableObject
{
    [Required(ErrorMessage = "title 不可为空")]
    [MaxLength(100)]
    public string Title { get; set; }
    // 其他属性

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        if (Title == Description)
        {
            yield return new ValidationResult(
                "路线名称必须与路线描述不同",
                new[] { "TouristRouteForCreationDto" }
            );
        }
    }
}

yield return 的语法用于在迭代器方法中产生一个值,并在下一次调用迭代器时从上次停止的地方继续执行。这使得在需要的时候逐步生成值,而不是一次性生成所有值。

image-20231214111356755

3.在类上自定义约束

​ 在 Spring 中我们可以根据 AOP 的原理自定义 Annotation,通过继承ValidationAttribute就可以自定义Attribute在类层面上定义约束。

namespace FakeXiecheng.ValidationAttributes;
public class TouristRouteTitleMustBeDifferentFromDescriptionAttribute : ValidationAttribute
{
    protected override ValidationResult IsValid(object value, ValidationContext validationContext)
    {
        var touristRouteDto = (TouristRouteForCreationDto)validationContext.ObjectInstance;
        if (touristRouteDto.Title == touristRouteDto.Description)
        {
            return new ValidationResult(
                "路线名称必须与路线描述不同",
                new[] { "TouristRouteForCreationDto" }
            );
        }
        return ValidationResult.Success;
    }
}

​ 将自定义的类作为Attribute引入Dto(不需要实现IValidatableObject接口,故而不需要实现Validate方法)

namespace FakeXiecheng.Dtos;
[TouristRouteTitleMustBeDifferentFromDescription]
public class TouristRouteForCreationDto

image-20231218145749585

4.返回合适的状态码

​ 对于数据校验失败的状态码返回400客户端错误是没有问题的,但是更加准确的方式应该是返回422UnprocessableEntity。在Program.cs中配置非法状态模型响应工厂。

​ 解释一下下面代码的含义:

setUpAction.InvalidModelStateResponseFactory:配置在模型状态验证失败时的响应生成工厂。这个工厂接受一个 context 参数,该参数包含有关当前请求和模型状态的信息。在这里,配置了一个自定义的处理方式,将验证问题详细信息包装成 ValidationProblemDetails 对象,并以 UnprocessableEntityObjectResult 的形式返回,状态码为 422(Unprocessable Entity)。

problemDetail.Extensions.Add("traceId",context.HttpContext.TraceIdentifier);:在 ValidationProblemDetails 对象的扩展属性中添加了一个名为 "traceId" 的跟踪标识符,用于标识当前请求的跟踪信息。

new UnprocessableEntityObjectResult(problemDetail) {...}: 创建一个 UnprocessableEntityObjectResult,将前面构建的 ValidationProblemDetails 对象作为内容返回。

ContentTypes = {"application/problem+json"}: 明确指定返回的内容类型为 "application/problem+json",表示返回的是一个符合 RFC 7807 规范的问题详细信息。

builder.Services.AddControllers(
    setUpAction =>
    {
        // 在无法满足客户端请求的内容类型时返回406
        setUpAction.ReturnHttpNotAcceptable = true;
        // 传统方式
        // setUpAction.OutputFormatters.Add(new XmlDataContractSerializerOutputFormatter());
    }   
    ).AddXmlDataContractSerializerFormatters()//开启对xml的格式支持
    // ++
    .ConfigureApiBehaviorOptions(setUpAction =>
    {
        setUpAction.InvalidModelStateResponseFactory = context =>
        {
            var problemDetail = new ValidationProblemDetails(context.ModelState)
            {
                Type = "类型",
                Title = "数据验证失败",
                Status = StatusCodes.Status422UnprocessableEntity,
                Instance = context.HttpContext.Request.Path
            };
            problemDetail.Extensions.Add("traceId",context.HttpContext.TraceIdentifier);
            return new UnprocessableEntityObjectResult(problemDetail)
            {
                ContentTypes = {"application/problem+json"}
            };
        };
    }); 

image-20231218152406466

六、完成产品模块-Put/Patch

image-20231219094350685

(一)使用 HttpPut 更新资源

1.Put 请求对资源更新

​ 为了更新资源需要创建AutoMapper的映射,并且新建一个TouristRouteForUpdateDto,这个 Dto 中的属性直接照搬TouristRouteForCreationDto就行。

namespace FakeXiecheng.Profiles;
// public class TouristRouteProfile : Profile ++
CreateMap<TouristRouteForUpdateDto, TouristRoute>();
namespace FakeXiecheng.Dtos;
public class TouristRouteForUpdateDto
{
    [Required(ErrorMessage = "title 不可为空")]
    [MaxLength(100)]
    public string Title { get; set; }
    [Required]
    [MaxLength(1500)]
    public string Description { get; set; }
// 其他属性
    public ICollection<TouristRoutePictureForCreationDto> TouristRoutePictures { get; set; }
        = new List<TouristRoutePictureForCreationDto>();
}

​ 使用 HttpPut 请求的Action:确定 Id 对应的资源存在后,使用Json数据更新仓库并写入。在检验资源存在性之后,就需要将 dto 中的内容映射成 model 并覆盖原来的 model 并保存,使用AutoMapper仅需一行代码就能完成这个步骤_mapper.Map(touristRouteForUpdateDto, touristRouteFromRepo);

​ 在使用 AutoMapper 进行映射的过程中,源对象的属性值会被映射到目标对象的相应属性上,从而完成了对目标对象的更新;

​ 使用_touristRouteRepository.Save(); 的目的是将对数据库模型的修改保存到数据库上下文中,并最终提交到数据库。

    [HttpPut("{touristRouteId}")]
    public IActionResult UpdateTouristRoute([FromRoute]Guid touristRouteId, [FromBody] TouristRouteForUpdateDto touristRouteForUpdateDto)
    {
        if (!_touristRouteRepository.TouristRouteExists(touristRouteId))
        {
            return NotFound("路线找不到");
        }
        var touristRouteFromRepo = _touristRouteRepository.GetTouristRoute(touristRouteId);
        // 映射 dto 更新 dto 映射 model 
        _mapper.Map(touristRouteForUpdateDto, touristRouteFromRepo);
        _touristRouteRepository.Save();
        return NoContent();
    }

​ 更新资源成功后返回 200系列的状态码,根据前端要求不同可以选择是否返回更新的数据,或者直接返回空。

image-20231219101156509

2.Put 请求的数据验证

​ 上面的TouristRouteForUpdateDto使用了和TouristRouteForCreationDto一样的属性,但是在进行数据的校验的时候,可能更新对数据的要求和创建对数据的要求不一样,所以可以创建一个类似BaseDto的基类,让不同的 Dto 从中继承基本的属性,对于可能需要特殊定制的属性设置为允许子类重写;这样共享一些通用的属性,同时又能够根据具体的需求进行定制。

// BaseDto 直接从 CreationDto 或者 UpdateDto 复制过来
namespace FakeXiecheng.Dtos;
[TouristRouteTitleMustBeDifferentFromDescription]
public class TouristRouteForManipulationDto
{
    [Required(ErrorMessage = "title 不可为空")]
    [MaxLength(100)]
    public string Title { get; set; }
    [Required]
    [MaxLength(1500)]
    public virtual string Description { get; set; }
    // 其他属性
    public ICollection<TouristRoutePictureForCreationDto> TouristRoutePictures { get; set; }
        = new List<TouristRoutePictureForCreationDto>();
}

TouristRouteForUpdateDto有对Description字段的要求,使用override特殊定制了该属性,而TouristRouteForCreationDto不想特殊定制,直接使用了父类的[Required][MaxLength(1500)]两个约束。

// Description 父类提供了重写的操作
namespace FakeXiecheng.Dtos;
public class TouristRouteForUpdateDto :TouristRouteForManipulationDto
{
    [Required(ErrorMessage = "必须的更新需求")]
    [MaxLength(1500)]
    public override string Description { get; set; }
}

​ 同时对于自定义的数据校验Attribute也需要更改原来的CreationDto部分为TouristRouteForManipulationDto,使其作用在父类上。

namespace FakeXiecheng.ValidationAttributes;

public class TouristRouteTitleMustBeDifferentFromDescriptionAttribute : ValidationAttribute
{
    protected override ValidationResult IsValid(object value, ValidationContext validationContext
    )
    {
        //                      modify ↓
        var touristRouteDto = (TouristRouteForManipulationDto)validationContext.ObjectInstance;
        if (touristRouteDto.Title == touristRouteDto.Description)
        {
            return new ValidationResult(
                "路线名称必须与路线描述不同",
                new[] { "TouristRouteForCreationDto" }
            );
        }

        return ValidationResult.Success;
    }
}

​ 从父类继承的属性Title不可为 null;

image-20231219103603065

​ 父类的要求Descirutiontitle不能相同也被TouristRouteForUpdateDto继承了下来;

image-20231219104228780

​ 子类TouristRouteForUpdateDto对字段Description有特殊的需求,于是在父类的基础上进行了拓展。

image-20231219110543887

(二)使用 HttpPatch 更新资源

ASP.NET Core Web API 中的 JSON 修补程序 | Microsoft Learn

JsonPatch 是一种用于执行基于 JSON 文档的部分更新的机制。它基于 RFC 6902(JSON Patch)标准,并为 Web API 提供了一种轻量级、灵活的方式来表示和应用 JSON 文档的更改。JSONPatch 仅支持内置的六种操作:

添加(add): 在指定路径上添加一个新的值。{"op": "add", "path": "/newProperty", "value": "some value"}

删除(remove): 从指定路径上删除一个值。{"op": "remove", "path": "/address/city"}

替换(replace): 使用新值替换指定路径上的现有值。{"op": "replace", "path": "/name", "value": "Jane"}

移动(move): 将指定路径上的值移动到新的路径。{"op": "move", "from": "/address/city", "path": "/newCity"}

复制(copy): 将指定路径上的值复制到新的路径。{"op": "copy", "from": "/name", "path": "/copyOfName"}

测试(test): 验证指定路径上的值是否等于给定的值。{"op": "test", "path": "/age", "value": 30}

1.Patch 请求对资源更新

​ 安装JsonPatch的依赖,第一个为JsonPatch的框架,第二个为JsonPatch的服务注册框架,前者提供了用于处理 JSON Patch 操作的基本支持,后者提供了对 Newtonsoft.Json 库的集成,用于处理 JSON 的序列化和反序列化。

image-20231219143254017

​ 安装Microsoft.AspNetCore.Mvc.NewtonsoftJson之后去程序中注入该服务;

image-20231219143813204

​ 启用使用 Newtonsoft.Json 库进行 JSON 处理。设置 JSON 序列化时使用的 ContractResolverContractResolver 在 Newtonsoft.Json 中是用于控制对象序列化的规则的一种机制。在这里,通过设置 CamelCasePropertyNamesContractResolver,属性名称将按照驼峰命名规则进行序列化。例如,对于属性 SomeProperty,使用 Camel Case 后会序列化为 someProperty

builder.Services.AddControllers(
    setUpAction =>
    {
        // 在无法满足客户端请求的内容类型时返回406
        setUpAction.ReturnHttpNotAcceptable = true;
        // 传统方式
        // setUpAction.OutputFormatters.Add(new XmlDataContractSerializerOutputFormatter());
    }  
    // ++
    ).AddNewtonsoftJson(setupAction => {
        setupAction.SerializerSettings.ContractResolver =
            new CamelCasePropertyNamesContractResolver();
    })
    .AddXmlDataContractSerializerFormatters()//开启对xml的格式支持

​ 首先,通过 _touristRouteRepository.GetTouristRoute(touristRouteId) 获取原始资源对象,然后使用 AutoMapper 将其映射到 TouristRouteForUpdateDto 类型的对象 touristRouteToPatch 上。接着,通过 patchDocument.ApplyTo(touristRouteToPatch) 将 JSON Patch 文档应用到 touristRouteToPatch 上,从而实现对部分属性的更新。最后,再次使用 AutoMapper 将更新后的 touristRouteToPatch 映射回原始对象 touristRouteFromRepo,然后通过 _touristRouteRepository.Save() 方法保存到数据库中。

​ 和上面的HttpPut相比,参数传入的不再是一个Dto对象,而是一个JsonPatchDocument对象,在部分更新的操作中我们引入了一个Dto类型的touristRouteToPatch空模板对象来接受RepoJsonDocument的数据,等这个中间变量把需要替换的内容替换完成之后,在使用AutoMapper进行映射到Repo中并Save

    [HttpPatch("{touristRouteId}")]
    public IActionResult PartialUpdateTouristRoute([FromRoute]Guid touristRouteId,  [FromBody]JsonPatchDocument<TouristRouteForUpdateDto> patchDocument)
    {
        if (!_touristRouteRepository.TouristRouteExists(touristRouteId))
        {
            return NotFound("路线找不到");
        }
        var touristRouteFromRepo = _touristRouteRepository.GetTouristRoute(touristRouteId);
        var touristRouteToPatch = _mapper.Map<TouristRouteForUpdateDto>(touristRouteFromRepo);
        patchDocument.ApplyTo(touristRouteToPatch);

        _mapper.Map(touristRouteToPatch, touristRouteFromRepo);
        _touristRouteRepository.Save();

        return NoContent();
    }

​ 既然在上面的Action中使用到了touristRouteToPatch这个Dto类型的中间变量,所以在AutoMapper中添加映射;

public class TouristRouteProfile : Profile
{
    public TouristRouteProfile()
    {
		// 部分省略
        CreateMap<TouristRouteForUpdateDto, TouristRoute>();
        // ++
        CreateMap<TouristRoute, TouristRouteForUpdateDto>();
    }
}
namespace FakeXiecheng.API.Profiles
{
    public class TouristRoutePictureProfile: Profile
    {
        public TouristRoutePictureProfile()
        {
            CreateMap<TouristRoutePicture, TouristRoutePictureDto>();
            CreateMap<TouristRoutePictureForCreationDto, TouristRoutePicture>();
            // ++
            CreateMap<TouristRoutePicture, TouristRoutePictureForCreationDto>();
        }
    }
}

image-20231219152600824

2.Patch 请求的数据验证

​ 当我们更新descriptiontitle字段并将两个字段的value设置相同的时候,之前设置的不相等的约束并没有起到作用,在约束配置中我们对传入的Dto做了参数校验,但是部分更新传入的是JsonPatchDocument对象,所以校验没有起作用也正常,但是依然可以在中间变量Dto类型的touristRouteToPatch验证。

image-20231219152728070

ModelState 对象是 ASP.NET Core 中用于存储模型验证错误信息的容器。在控制器中,通过 ModelState 可以获取到模型验证的结果,并在需要时进行相应的处理。在这里,通过 ValidationProblem(ModelState) 返回验证问题,将模型验证的错误信息返回给客户端。

[HttpPatch("{touristRouteId}")]
    public IActionResult PartialUpdateTouristRoute([FromRoute] Guid touristRouteId,
        [FromBody] JsonPatchDocument<TouristRouteForUpdateDto> patchDocument)
    {
        if (!_touristRouteRepository.TouristRouteExists(touristRouteId))
        {
            return NotFound("路线找不到");
        }

        var touristRouteFromRepo = _touristRouteRepository.GetTouristRoute(touristRouteId);
        var touristRouteToPatch = _mapper.Map<TouristRouteForUpdateDto>(touristRouteFromRepo);
        //                                        modify
        // 如果在应用过程中发生了错误,例如客户端提供的操作不符合规范或无法应用到对象上,
        // 相关的错误信息将被添加到 ModelState 中
        patchDocument.ApplyTo(touristRouteToPatch, ModelState);
		// ++
        // TryValidateModel 方法用于手动触发模型验证,即对 touristRouteToPatch 对象进行验证。
        // 如果验证失败,表示客户端提供的数据不符合模型的要求,相关的错误信息将被添加到 ModelState 中。
        if (!TryValidateModel(touristRouteToPatch))
        {
            return ValidationProblem(ModelState);
        }
        _mapper.Map(touristRouteToPatch, touristRouteFromRepo);
        _touristRouteRepository.Save();
        return NoContent();
    }

image-20231219155413332

七、完成产品模块-Delete

(一)删除资源

​ 没什么好说的,根据id找到数据实体,将这个数据体传到数据仓库中让数据库上下文删除即可。

    [HttpDelete("{touristRouteId}")]
    public IActionResult DeleteTouristRoute([FromRoute] Guid touristRouteId)
    {
        if (!_touristRouteRepository.TouristRouteExists(touristRouteId))
        {
            return NotFound("路线找不到");
        }
        var touristRouteFromRepo = _touristRouteRepository.GetTouristRoute(touristRouteId);
        _touristRouteRepository.DeleteTouristRoute(touristRouteFromRepo);
        _touristRouteRepository.Save();
        return NoContent();
    }
    public void DeleteTouristRoute(TouristRoute touristRoute)
    {
        _dbContext.TouristRoutes.Remove(touristRoute);
    }

​ 第二次请求404表示该资源已经被移除。

image-20231219162648724

(二)删除嵌套资源

​ 在进行操作的时候可能只是想删除某个路线中的某张图片:

    [HttpDelete("{pictureId}")]
    public IActionResult DeletePicture([FromRoute] Guid touristRouteId, [FromRoute] int pictureId)
    {
        if (!_touristRouteRepository.TouristRouteExists(touristRouteId))
        {
            return NotFound("旅游线路不存在");
        }

        var picture = _touristRouteRepository.GetPictureById(pictureId);
        if (picture == null)
        {
            return NotFound("图片不存在");
        }
        _touristRouteRepository.DeleteTouristRoutePicture(picture);
        _touristRouteRepository.Save();

        return NoContent();
    }
namespace FakeXiecheng.Services.impl;

// public class TouristRouteRepository : ITouristRouteRepository    
public void DeleteTouristRoutePicture(TouristRoutePicture picture)
    {
        _dbContext.TouristRoutePictures.Remove(picture);
    }

image-20231220121640880

(三)批量删除资源

​ 在进行批量操作的时候可以选择使用[FromQuery]的方式进行引导:api/touristRoute?Ids=1,2,3,也可以使用[FromRoute]尽心引导:``api/touristRoute/(1,2,3)下面使用[FromRoute]`实现。

​ 由于从URL中获得的是字符串,我们要将其转化为GUID的列表,在工具类中提供了ArrayModelBinder来实现这个功能。同时需要注意这个URL的写法 ({touristIDs})使用()表示参数是一个列表。

​ 通过GetTouristRoutesByIDList获得要删除的实体数据,将实体数据传入Repo中通过数据库上下文删除即可。

 [HttpDelete("({touristIDs})")]
    public IActionResult DeleteByIDs(
        [ModelBinder( BinderType = typeof(ArrayModelBinder))][FromRoute]IEnumerable<Guid> touristIDs)
    {
        if(touristIDs == null)
        {
            return BadRequest();
        }

        var touristRoutesFromRepo = _touristRouteRepository.GetTouristRoutesByIDList(touristIDs);
        if (touristRoutesFromRepo == null || touristRoutesFromRepo.Count() <= 0)
        {
            return NotFound("旅游路线不存在");
        }
        _touristRouteRepository.DeleteTouristRoutes(touristRoutesFromRepo);
        _touristRouteRepository.Save();

        return NoContent();
    }
    /// <summary>
    /// 批量删除路线资源
    /// </summary>
    /// <param name="touristRoutes"></param>
    public void DeleteTouristRoutes(IEnumerable<TouristRoute> touristRoutes)
    {
        _dbContext.TouristRoutes.RemoveRange(touristRoutes);
    }
    
    /// <summary>
    /// 根据id列表获得路线资源集
    /// </summary>
    /// <param name="ids"></param>
    /// <returns></returns>
    public IEnumerable<TouristRoute> GetTouristRoutesByIDList(IEnumerable<Guid> ids)
    {
        return _dbContext.TouristRoutes.Where(t => ids.Contains(t.Id)).ToList();
    }

​ 工具类代码:

using System.ComponentModel;
using System.Reflection;
using Microsoft.AspNetCore.Mvc.ModelBinding;

namespace FakeXiecheng.Utils;

public class ArrayModelBinder : IModelBinder
{
    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        // Our binder works only on enumerable types
        if (!bindingContext.ModelMetadata.IsEnumerableType)
        {
            bindingContext.Result = ModelBindingResult.Failed();
            return Task.CompletedTask;
        }

        // Get the inputted value through the value provider
        var value = bindingContext.ValueProvider
            .GetValue(bindingContext.ModelName).ToString();

        // If that value is null or whitespace, we return null
        if (string.IsNullOrWhiteSpace(value))
        {
            bindingContext.Result = ModelBindingResult.Success(null);
            return Task.CompletedTask;
        }

        // The value isn't null or whitespace,   
        // and the type of the model is enumerable. 
        // Get the enumerable's type, and a converter 
        var elementType = bindingContext.ModelType.GetTypeInfo().GenericTypeArguments[0];
        var converter = TypeDescriptor.GetConverter(elementType);

        // Convert each item in the value list to the enumerable type
        var values = value.Split(new[] { "," }, StringSplitOptions.RemoveEmptyEntries)
            .Select(x => converter.ConvertFromString(x.Trim()))
            .ToArray();

        // Create an array of that type, and set it as the Model value 
        var typedValues = Array.CreateInstance(elementType, values.Length);
        values.CopyTo(typedValues, 0);
        bindingContext.Model = typedValues;

        // return a successful result, passing in the Model 
        bindingContext.Result = ModelBindingResult.Success(bindingContext.Model);
        return Task.CompletedTask;
    }
}

image-20231220123702850