从SpringBoot到DotNet_4.完善篇

image-20240413204305811

第一章 分页

​ 在开发 RESTful API 时,分页功能是非常常见的需求,尤其是在处理大量数据时。分页不仅能提高查询效率,还能改善用户体验,让前端能够逐步加载数据而不是一次性全部加载。

​ 在第一章中,我们将探讨如何实现分页功能,并且确保分页参数通过查询字符串传递、设置合理的默认值和阈限值,并在数据库中实现高效分页。

image-20240413204157220

1.简单分页

​ 对参数进行改造,添加pageNumberpageSize两个字段,并设定默认值,同时为了性能保证,当pageSize过大的时候将会设定一个默认值。

copy
using System.Text.RegularExpressions; namespace 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; } } private int _pageNumber = 1; public int PageNumber { get { return _pageNumber; } set { if (value >=1) { _pageNumber = value; } } } private int _pageSize = 10; private int maxValue = 1; public int PageSize { get { return _pageSize; } set { if (value >= 1) { _pageSize = (value > int.MaxValue) ? maxValue : value; } } } }

​ 对于控制器,我们只需要在获得参数的时候添加两个TouristRouteResourceParamaters的属性即可。

copy
[HttpGet] [HttpHead] // api/touristroutes?keyword=xxx public async Task<IActionResult> GetTouristRoutes([FromQuery] TouristRouteResourceParamaters paramaters) { var touristRoutesFromRepo = await _touristRouteRepository.GetTouristRoutesAsync( paramaters.Keyword, paramaters.RatingOperator, paramaters.RatingValue, paramaters.PageNumber, paramaters.PageSize ); if (touristRoutesFromRepo == null || touristRoutesFromRepo.Count() <= 0) { return NotFound("旅游路线不存在"); } var touristRoutesDto = _mapper.Map<IEnumerable<TouristRouteDto>>(touristRoutesFromRepo); return Ok(touristRoutesDto); }

​ 在进行分页操作的时候大体分为三步:1、计算需要调过的数据;2、调过之前的数据确定起始位置;3、从起始位置选择以pageSize为大小的数据。

copy
/// <summary> /// 获得所有的信息 /// </summary> /// <returns></returns> public async Task<IEnumerable<TouristRoute>> GetTouristRoutesAsync(string keyword, string operatorType, int? ratingValue, int pageSize, int pageNumber) { IQueryable<TouristRoute> result = _dbContext.TouristRoutes.Include(t => t.TouristRoutePictures); if (!string.IsNullOrWhiteSpace(keyword)) { result = result.Where(t => t.Title.Contains(keyword)); } if (ratingValue >= 0) { result = operatorType switch { "largerThan" => result.Where(t => t.Rating >= ratingValue), "lessThan" => result.Where(t => t.Rating < ratingValue), "equals" => result.Where(t => t.Rating == ratingValue) }; } // 分页操作 var skip = (pageNumber - 1) * pageSize; result = result.Skip(skip); result =result.Take(pageSize); return await result.ToListAsync(); }

image-20240807220340257

2.模组化分页

​ 模组化分页是一种将分页逻辑封装到一个独立模块中的技术。这种方法有许多优点,尤其是在处理大量数据时,它可以显著提高系统的性能和可维护性。在 PaginationList<T> 构造函数中,AddRange(items) 被用来将从数据库查询到的分页数据添加到当前的 PaginationList<T> 对象中。

​ 下面这个类用于实现分页功能。它继承自 List<T>,并添加了两个额外的属性:CurrentPagePageSize,用来存储当前页码和每页的记录数。

copy
namespace FakeXiecheng.Helper { public class PaginationList<T> : List<T> { public int CurrentPage { get; set; } public int PageSize { get; set; } public PaginationList(int currentPage, int pageSize, List<T> items) { CurrentPage = currentPage; PageSize = pageSize; AddRange(items); } public static async Task<PaginationList<T>> CreateAsync( int currentPage, int pageSize, IQueryable<T> result) { // pagination // skip var skip = (currentPage - 1) * pageSize; result = result.Skip(skip); // 以pagesize为标准显示一定量的数据 result = result.Take(pageSize); // include vs join var items = await result.ToListAsync(); return new PaginationList<T>(currentPage, pageSize, items); } } }

​ 在需要分页的地方使用工厂创造出一个PaginationList实例。

copy
public async Task<PaginationList<TouristRoute>> GetTouristRoutesAsync(string keyword, string operatorType, int? ratingValue, int pageNumber, int pageSize) { IQueryable<TouristRoute> result = _dbContext.TouristRoutes.Include(t => t.TouristRoutePictures); if (!string.IsNullOrWhiteSpace(keyword)) { result = result.Where(t => t.Title.Contains(keyword)); } if (ratingValue >= 0) { result = operatorType switch { "largerThan" => result.Where(t => t.Rating >= ratingValue), "lessThan" => result.Where(t => t.Rating < ratingValue), "equals" => result.Where(t => t.Rating == ratingValue) }; } return await PaginationList<TouristRoute>.CreateAsync(pageNumber, pageSize, result); }

3.复用分页模组

​ 对于当前的项目来说,将分页代码模组化最大的好处就是一次编写,到处可以使用:

copy
namespace FakeXiecheng.ResourceParameters { public class PaginationResourceParamaters { private int _pageNumber = 1; public int PageNumber { get { return _pageNumber; } set { if (value >= 1) { _pageNumber = value; } } } private int _pageSize = 10; const int maxPageSize = 50; public int PageSize { get { return _pageSize; } set { if (value >= 1) { _pageSize = (value > maxPageSize) ? maxPageSize : value; } } } } } public async Task<IActionResult> GetTouristRoutes([FromQuery] TouristRouteResourceParamaters paramaters, [FromQuery] PaginationResourceParamaters paramaters2)

​ 以获取订单GetOrdersByUserId为例,这是个同样要求有分页的操作

copy
[HttpGet] [Authorize(AuthenticationSchemes = "Bearer")] public async Task<IActionResult> GetOrders([FromQuery] PaginationResourceParamaters paramaters2) { // 1. 获得当前用户 var userId = _httpContextAccessor .HttpContext.User.FindFirst(ClaimTypes.NameIdentifier).Value; // 2. 使用用户id来获取订单历史记录 var orders = await _touristRouteRepository.GetOrdersByUserId(userId, paramaters2.PageNumber, paramaters2.PageSize); return Ok(_mapper.Map<IEnumerable<OrderDto>>(orders)); }
copy
public async Task<PaginationList<Order>> GetOrdersByUserId(string userId, int pageSize, int pageNumber) { IQueryable<Order> result = _dbContext.Orders.Where(o => o.UserId == userId); return await PaginationList<Order>.CreateAsync(pageNumber, pageSize, result); }

image-20240809180947177

4.分页导航

​ PC端商品、资讯内容的列表页面,通常会有个分页的功能,通过翻页等操作,用户可以跳转到其他页面查看新的内容。页码展示包括当前页码展示、当前页码相邻几个页码的展示以及首末页页码展示。

image-20240809233018149

​ 页码展示帮助用户定位内容:例如用户在某个商品的搜索结果页浏览时,看到第5页,这时还是觉得第2页的一件商品更想买,于是就可以通过点击页码2回到展示该商品的页面;这就达到了通过页码快速定位商品位置的目的,而不用逐个商品逐个商品地往回去查找该商品在哪里。

分页的子功能主要有页码展示、数据量展示以及翻页操作,分别都有各自的作用,例如内容定位、对内容的预期把控等;我们在设计分页功能时,可以根据业务需要来选择不同的构成元素。

在下面的响应中,数据列表将会出现在响应主体中,而分页的信息与数据列表彻底分开,这是由于请求使用application/json,目的是获取资源,而分页信息并不是资源,而是元数据,所以以`metadata``的形式在header中输出。

image-20240809181130755

​ 从本质上来说分页导航属于 api 成熟度 level3 级别,因为他实现了 API 的自我发现机制。

4.1改造分页模组

​ 之前引入了PagianationList<T>工具类,里面存放当前页、单页数据量和数据本体,现在对他进行改造,需要添加的信息有:是否有上/下一页、总页数、(符合要求的)数据总量这四个属性。

TotalCount我们可以使用内部提供的方法进行异步获得,有了总的数据量就能直接算出总的页数,所以只把TotalCount作为参数传入即可。

copy
using Microsoft.EntityFrameworkCore; namespace FakeXiecheng.Helper { public class PaginationList<T> : List<T> { public int TotalPages { get; private set; } public int TotalCount { get; private set; } public bool HasPrevious => CurrentPage > 1; public bool HasNext => CurrentPage < TotalPages; public int CurrentPage { get; set; } public int PageSize { get; set; } public PaginationList(int totalCount, int currentPage, int pageSize, List<T> items) { CurrentPage = currentPage; PageSize = pageSize; AddRange(items); TotalCount = totalCount; TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize); } public static async Task<PaginationList<T>> CreateAsync( int currentPage, int pageSize, IQueryable<T> result) { var totalCount = await result.CountAsync(); // pagination // skip var skip = (currentPage - 1) * pageSize; result = result.Skip(skip); // 以pagesize为标准显示一定量的数据 result = result.Take(pageSize); // include vs join var items = await result.ToListAsync(); return new PaginationList<T>(totalCount, currentPage, pageSize, items); } }

4.2创建分页导航信息

​ 确定分页模组能够提供我们需要的信息之后下一步就是想办法将这些信息写到Response之中

​ 使用 IUrlHelper 生成 URL 、,它提供了一种方便的方式在控制器或服务中生成与路由相关的 URL,为了使用这个东西,需要注册IActionContextAccessor的服务,使得我们可以在应用程序的任何地方方便地访问当前请求的上下文信息。(尤其是我们需要处理与当前请求相关的业务逻辑)

copy
builder.Services.AddSingleton<IActionContextAccessor, ActionContextAccessor>();

​ 注入的 IUrlHelper 是一个用于生成 URL 的帮助类,它提供了一种方便的方式在控制器或服务中生成与路由相关的 URL;

copy
private ITouristRouteRepository _touristRouteRepository; private readonly IMapper _mapper; private readonly IUrlHelper _urlHelper; public TouristRoutesController( ITouristRouteRepository touristRouteRepository, IMapper mapper, IUrlHelperFactory urlHelperFactory, IActionContextAccessor actionContextAccessor ) { _touristRouteRepository = touristRouteRepository; _mapper = mapper; _urlHelper = urlHelperFactory.GetUrlHelper(actionContextAccessor.ActionContext); }

​ 创建GenerateTouristRouteResourceURL用于来生成完整的URL链接;

copy
namespace FakeXiecheng.Helper { public enum ResourceUriType { PreviousPage, NextPage } }
copy
private string GenerateTouristRouteResourceURL( TouristRouteResourceParamaters paramaters, PaginationResourceParamaters paramaters2, ResourceUriType type ) { return type switch { ResourceUriType.PreviousPage => _urlHelper.Link("GetTouristRoutes", new { keyword = paramaters.Keyword, rating = paramaters.Rating, pageNumber = paramaters2.PageNumber - 1, pageSize = paramaters2.PageSize }), ResourceUriType.NextPage => _urlHelper.Link("GetTouristRoutes", new { keyword = paramaters.Keyword, rating = paramaters.Rating, pageNumber = paramaters2.PageNumber + 1, pageSize = paramaters2.PageSize }), _ => _urlHelper.Link("GetTouristRoutes", new { keyword = paramaters.Keyword, rating = paramaters.Rating, pageNumber = paramaters2.PageNumber, pageSize = paramaters2.PageSize }) }; } /

​ 改造控制器,这里主要修改的内容是在获得前/后一页的URL链接之后使用Response.Headers.Add将信息写到返回的Header中。

copy
/ api/touristRoutes?keyword=传入的参数 [HttpGet(Name = "GetTouristRoutes")] [HttpHead] public async Task<IActionResult> GerTouristRoutes( [FromQuery] TouristRouteResourceParamaters paramaters, [FromQuery] PaginationResourceParamaters paramaters2 //[FromQuery] string keyword, //string rating // 小于lessThan, 大于largerThan, 等于equalTo lessThan3, largerThan2, equalTo5 )// FromQuery vs FromBody { var touristRoutesFromRepo = await _touristRouteRepository .GetTouristRoutesAsync( paramaters.Keyword, paramaters.RatingOperator, paramaters.RatingValue, paramaters2.PageSize, paramaters2.PageNumber ); if (touristRoutesFromRepo == null || touristRoutesFromRepo.Count() <= 0) { return NotFound("没有旅游路线"); } var touristRoutesDto = _mapper.Map<IEnumerable<TouristRouteDto>>(touristRoutesFromRepo); var previousPageLink = touristRoutesFromRepo.HasPrevious ? GenerateTouristRouteResourceURL( paramaters, paramaters2, ResourceUriType.PreviousPage) : null; var nextPageLink = touristRoutesFromRepo.HasNext ? GenerateTouristRouteResourceURL( paramaters, paramaters2, ResourceUriType.NextPage) : null; // x-pagination var paginationMetadata = new { previousPageLink, nextPageLink, totalCount = touristRoutesFromRepo.TotalCount, pageSize = touristRoutesFromRepo.PageSize, currentPage = touristRoutesFromRepo.CurrentPage, totalPages = touristRoutesFromRepo.TotalPages }; Response.Headers.Add("x-pagination", Newtonsoft.Json.JsonConvert.SerializeObject(paginationMetadata)); return Ok(touristRoutesDto); }

image-20240810012644173

第二章 资源排序

​ 以GerTouristRoutes为例,如果想要把排序的参数传入后端,就需要在TouristRouteResourceParamaters中再添加一个String类型的数据来表示以什么排序/升序或降序

copy
public string? OrderBy { get; set; }

​ 如果我们只想针对某个数据做排序像下面这样写死就行了,但是用户只需要像后端发送自己想要数据的请求就行了,而开发者要考虑的就多了。直接在下面代码中写if或者switch判断又显得我们很呆,所以这时候就可以参考Mybatis中的动态sql。

image-20240811004136129

OrderByEF给我提供的,里面又没有办法接受我们DTO传过来的字符串

​ 在Mybatis中,我们构造QueryWrapper来完成sql语句的构造,mybatis会按照约定自动将这个字符串映射到entity上;在mybatis项目中,

copy
if (!StringUtils.isEmpty(orderBy)) { queryWrapper.orderByDesc(orderBy);}

​ 走到这一步要求我们已经将url中的排序相关关键字全部拆分成约定的形式,比如说我们传入了orderby=price desc,在上面这一行代码中,我们要求传过来的这个orderBy参数的值已经是price这个字符串;

​ 在.NET中,通过 LINQ 和表达式树,开发者可以更加灵活地定义自己的映射规则,生成相应的SQL语句。这种方式虽然需要手动定义映射规则,但也提供了更大的灵活性和可扩展性,适用于更复杂的场景。

​ 在这里我们需要自己定义自己的映射规则,让(string)OrderBy字段映射到实体上从而让IQueryable生成相应的sql。

我们想要的效果就是result.ApplySort(orderBy,_mappingDict),方法参数的前者是从url获得的排序字符串,第二个则是映射到尸体上的映射法则。

​ 安装依赖包 System.linq.dynamic.core

image-20240811012336955

1. 需求设计

需求:前端传递排序参数(如 orderby=price desc),后端需要根据这个参数对数据进行排序。由于 DTO 字段和数据库实体字段可能不同,需要通过属性映射将前端的排序字段映射到实际的数据库字段上,然后对数据进行排序。

2. 服务设计

关键点:我们通过 PropertyMappingService 来处理 DTO 到 Model 的字段映射,通过扩展方法 ApplySort<T>IQueryable 数据源进行动态排序。

3. 具体实现

3.1 PropertyMappingService: IPropertyMappingService

作用:这是一个服务接口,用于管理 DTO 和实体类之间的属性映射。通过面向接口的方式,我们可以将映射的具体实现与业务逻辑分离。

copy
public interface IPropertyMappingService { Dictionary<string, PropertyMappingValue> GetPropertyMapping<TSource, TDestination>(); }

实现类 PropertyMappingService

  • 私有成员 _propertyMappings:这是一个包含多个属性映射的列表 (IList<IPropertyMapping>),用于管理不同 DTO 和 Model 之间的映射关系。
  • 构造函数:在构造函数中,我们将 DTO (TouristRouteDto) 和实体类 (TouristRoute) 的属性映射关系添加到 _propertyMappings 列表中。
  • GetPropertyMapping<TSource, TDestination> 方法:该方法根据 DTO 和 Model 类型,返回对应的属性映射字典 (Dictionary<string, PropertyMappingValue>),供后续的排序逻辑使用。
copy
using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using FakeXiecheng.Dtos; using FakeXiecheng.Models; namespace FakeXiecheng.Services { public class PropertyMappingService : IPropertyMappingService { private Dictionary<string, PropertyMappingValue> _touristRoutePropertyMapping = new Dictionary<string, PropertyMappingValue>(StringComparer.OrdinalIgnoreCase) { { "Id", new PropertyMappingValue(new List<string>(){ "Id" }) }, { "Title", new PropertyMappingValue(new List<string>(){ "Title" })}, { "Rating", new PropertyMappingValue(new List<string>(){ "Rating" })}, { "OriginalPrice", new PropertyMappingValue(new List<string>(){ "OriginalPrice" })}, }; private IList<IPropertyMapping> _propertyMappings = new List<IPropertyMapping>(); public PropertyMappingService() { _propertyMappings.Add( new PropertyMapping<TouristRouteDto, TouristRoute>( _touristRoutePropertyMapping)); } public Dictionary<string, PropertyMappingValue> GetPropertyMapping<TSource, TDestination>() { // 获得匹配的映射对象 var matchingMapping = _propertyMappings.OfType<PropertyMapping<TSource, TDestination>>(); if (matchingMapping.Count() == 1) { return matchingMapping.First()._mappingDictionary; } throw new Exception( $"Cannot find exact property mapping instance for <{typeof(TSource)},{typeof(TDestination)}"); } } }

3.2 PropertyMapping<TSource, TDestination>: IPropertyMapping

作用:用于定义具体的属性映射关系。

_mappingDictionary:这是一个字典类型 (Dictionary<string, PropertyMappingValue>),用于存储 DTO 字段和 Model 字段的映射关系。键是 DTO 字段名,值是 PropertyMappingValue,包含了对应的 Model 字段名列表。

copy
public class PropertyMapping<TSource, TDestination> : IPropertyMapping { public Dictionary<string, PropertyMappingValue> _mappingDictionary { get; set; } public PropertyMapping(Dictionary<string, PropertyMappingValue> mappingDictionary) { _mappingDictionary = mappingDictionary; } } namespace FakeXiecheng.API.Services { public class PropertyMapping<TSource, TDestination> : IPropertyMapping { public Dictionary<string, PropertyMappingValue> _mappingDictionary { get; set; } public PropertyMapping(Dictionary<string, PropertyMappingValue> mappingDictionary) { _mappingDictionary = mappingDictionary; } } }

3.3 PropertyMappingValue

作用:用于存储 DTO 字段与 Model 字段的对应关系。

  • DestinationProperties:这是一个字符串列表,包含对应的 Model 字段名。
copy
namespace FakeXiecheng.API.Services { public class PropertyMappingValue { public IEnumerable<string> DestinationProperties { get; private set; } public PropertyMappingValue(IEnumerable<string> destinationProperties) { DestinationProperties = destinationProperties; } } }

3.4. 扩展方法实现排序

需求:前端传入一个排序字符串(如 "price desc"),需要将其应用到 IQueryable 数据源上,并根据 DTO 和 Model 的映射关系进行排序。

ApplySort<T> 扩展方法

作用:将排序逻辑应用到 IQueryable<T> 数据源上。

  • 参数说明
    • source:要进行排序的数据源。
    • orderBy:排序字符串,由前端传入。
    • mappingDictionary:DTO 字段与 Model 字段的映射字典。
  • 逻辑步骤
    1. 检查 sourcemappingDictionary 是否为 null
    2. 如果 orderBy 为空,直接返回未排序的数据源。
    3. 解析 orderBy 字符串,将其分割为多个排序条件。
    4. 对每个排序条件:
      • 提取属性名和排序方向(升序或降序)。
      • 使用 mappingDictionary 将属性名映射到实际的 Model 字段名。
      • 将排序条件拼接成一个字符串,用于 IQueryable 的排序。
    5. 使用生成的排序字符串对 IQueryable 进行排序,并返回结果。
copy
public static IQueryable<T> ApplySort<T>( this IQueryable<T> source, string orderBy, Dictionary<string, PropertyMappingValue> mappingDictionary ) { // ...参数检查... var orderByString = string.Empty; var orderByAfterSplit = orderBy.Split(','); foreach(var order in orderByAfterSplit) { var trimmedOrder = order.Trim(); var orderDescending = trimmedOrder.EndsWith(" desc"); var indexOfFirstSpace = trimmedOrder.IndexOf(" "); var propertyName = indexOfFirstSpace == -1 ? trimmedOrder : trimmedOrder.Remove(indexOfFirstSpace); if (!mappingDictionary.ContainsKey(propertyName)) { throw new ArgumentException($"Key mapping for {propertyName} is missing"); } var propertyMappingValue = mappingDictionary[propertyName]; foreach(var destinationProperty in propertyMappingValue.DestinationProperties.Reverse()) { orderByString = orderByString + (string.IsNullOrWhiteSpace(orderByString) ? string.Empty : ", ") + destinationProperty + (orderDescending ? " descending" : " ascending"); } } return source.OrderBy(orderByString); }

3.5 整体流程总结

从前端获取排序参数:如 orderby=price desc

解析排序参数:在 ApplySort<T> 扩展方法中,解析出排序字段和排序方向。

获取属性映射:通过 PropertyMappingService 获取 DTO 到 Model 的属性映射关系。

映射并排序:将解析出的排序字段映射到实体类属性上,然后通过扩展方法动态地对 IQueryable 数据源进行排序。

返回排序结果:返回已经排序的查询结果。

image-20240812000105833

4.排序参数的分页导航

​ 在相应参数的头部,排序参数并没有放到响应中,在生成链接的时候加入这两个参数即可。

copy
private string GenerateTouristRouteResourceURL( TouristRouteResourceParamaters paramaters, PaginationResourceParamaters paramaters2, ResourceUriType type ) { return type switch { ResourceUriType.PreviousPage => _urlHelper.Link("GetTouristRoutes", new { orderBy = paramaters.OrderBy, keyword = paramaters.keyword, rating = paramaters.Rating, pageNumber = paramaters2.PageNumber - 1, pageSize = paramaters2.PageSize }), ResourceUriType.NextPage => _urlHelper.Link("GetTouristRoutes", new { orderBy = paramaters.OrderBy, keyword = paramaters.keyword, rating = paramaters.Rating, pageNumber = paramaters2.PageNumber + 1, pageSize = paramaters2.PageSize }), _ => _urlHelper.Link("GetTouristRoutes", new { orderBy = paramaters.OrderBy, keyword = paramaters.keyword, rating = paramaters.Rating, pageNumber = paramaters2.PageNumber, pageSize = paramaters2.PageSize }) };

image-20240812011212906

5.处理400级别的错误信息

​ 在上面的排序中,对于不存在的排序字段,请求仍然会返回500级别的错误,但是为了符合标准,这时候应该是返回400类型的错误并提示信息。

image-20240812011504213

​ 也就是说在我们定义的字典中如果没有用户的输入,就需要返回400类型的错误。

copy
namespace FakeXiecheng.Services; public bool IsMappingExists<TSource, TDestination>(string fields) { var propertyMapping = GetPropertyMapping<TSource, TDestination>(); if (string.IsNullOrWhiteSpace(fields)) { return true; } //逗号来分隔字段字符串 var fieldsAfterSplit = fields.Split(","); foreach(var field in fieldsAfterSplit) { // 去掉空格 var trimmedField = field.Trim(); // 获得属性名称字符串 var indexOfFirstSpace = trimmedField.IndexOf(" "); var propertyName = indexOfFirstSpace == -1 ? trimmedField : trimmedField.Remove(indexOfFirstSpace); if (!propertyMapping.ContainsKey(propertyName)) { return false; } } return true; }

​ 注入服务private readonly IPropertyMappingService _propertyMappingService;在控制器中先进行过滤即可。

copy
if (!_propertyMappingService .IsMappingExists<TouristRouteDto, TouristRoute>( paramaters.OrderBy)) { return BadRequest("请输入正确的排序参数"); } var touristRoutesFromRepo = await _touristRouteRepository

image-20240812012459693

第三章 数据塑性

​ RESTful API的一个常见缺点是数据粒度过粗,即API可能会返回大量不必要的数据,这不仅影响了性能,还可能增加了网络传输的开销。在RESTful API中,数据塑性指的是API所提供的数据结构能够灵活适应不同的客户端需求和应用场景。这种塑性体现在数据的可扩展性、可变形性以及在多种请求场景下的灵活应对能力。

​ 通过数据塑形,RESTful API可以更好地支持不同客户端的需求,减少不必要的数据传输,提高整体性能,同时增强API的灵活性和适应性。这也是提升API数据塑性的重要途径之一。

1.构建动态对象

​ 在处理RESTful API的响应数据时,传统的固定DTO结构往往无法满足多变的客户端需求。为了提升API的灵活性,我们可以结合.NET中的ExpandoObject与反射机制,通过动态生成数据结构来实现定制化的数据塑形。

1.1拓展方法和动态检查

​ 首先确定要对IEnumerable的方法进行拓展,传入的是待处理的数据源,我们希望该方法返回一个可以动态更改的ExpandoObject对象,需要处理的数据类型为泛型<T>,最后进行为空的异常处理。

copy
namespace FakeXiecheng.Helper { public static class IEnumerableExtensions { public static IEnumerable<ExpandoObject> ShapeData<TSource>( this IEnumerable<TSource> source, string fields ) { if (source == null) { throw new ArgumentNullException(nameof(source)); }

1.2通过反射获得属性

​ 创建一个List类型的容器放处理完的动态对象,若source为空说明不需要进行筛选字段,直接将传入类的所有字段都放到要返回的对象propertyInfoList中即可;

​ 若要求对该数据进行塑性,则首先处理传入后端的字符串,基于约定处理成可识别的形式,使用反射机制获取对应的字段存储到propertyInfo之中。

copy
var expandoObjectList = new List<ExpandoObject>(); //避免在列表中遍历数据,创建一个属性信息列表 var propertyInfoList = new List<PropertyInfo>(); if (string.IsNullOrWhiteSpace(fields)) { // 希望返回动态类型对象ExpandoObject所有的属性 var propertyInfos = typeof(TSource) .GetProperties(BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance); propertyInfoList.AddRange(propertyInfos); } else { //逗号来分隔字段字符串 var fieldsAfterSplit = fields.Split(','); foreach (var filed in fieldsAfterSplit) { // 去掉首尾多余的空格,获得属性名称 var propertyName = filed.Trim(); var propertyInfo = typeof(TSource) .GetProperty(propertyName, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance); if (propertyInfo == null) { throw new Exception($"属性 {propertyName} 找不到" + $" {typeof(TSource)}"); } propertyInfoList.Add(propertyInfo); } }

1.3遍历并复制属性到expendoObejct

​ 获得对应的属性和值之后,以字典的形式存储到expandoObjectList中返回。

copy
foreach (TSource sourceObject in source) { // 创建动态类型对象, 创建数据塑性对象 var dataShapedObject = new ExpandoObject(); foreach (var propertyInfo in propertyInfoList) { //获得对应属性的真实数据 var propertyValue = propertyInfo.GetValue(sourceObject); ((IDictionary<string, object>)dataShapedObject) .Add(propertyInfo.Name, propertyValue); } expandoObjectList.Add(dataShapedObject); } return expandoObjectList; } } }

2.列表数据的塑性

​ 完成上面的处理之后首先更改TouristRouteResourceParamaters添加属性Fields来代表需要筛选的数据,再将要返回dto的时候调用写好的ShapeData方法对数据进行塑性。

copy
return Ok(touristRoutesDto.ShapeData(paramaters.Fields));

​ 另外,我们还需要将这个信息添加到返回体的Header之中。

copy
private string GenerateTouristRouteResourceURL{ fileds = paramaters.Fields, }

image-20240814231131463

3.单一资源的塑性

​ 对于Api:GetTouristRoutes其返回的数据是列表类型的一组数据

​ 在IEnumerable中进行反射的操作是非常大的,通过一次性获取所有所需的属性信息并将其存储在 List<PropertyInfo> 中,可以避免在遍历每个对象时重复进行反射操作。反射操作相对昂贵,因此减少其调用频率有助于提升性能,所以我们创建了 var propertyInfoList = new List<PropertyInfo>();

copy
namespace FakeXiecheng.Helper { public static class ObjectExtensions { public static ExpandoObject ShapeData<TSource>(this TSource source, string fields) { if (source == null) { throw new ArgumentNullException(nameof(source)); } var dataShapedObject = new ExpandoObject(); if (string.IsNullOrWhiteSpace(fields)) { // all public properties should be in the ExpandoObject var propertyInfos = typeof(TSource) .GetProperties(BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance); foreach (var propertyInfo in propertyInfos) { // get the value of the property on the source object var propertyValue = propertyInfo.GetValue(source); // add the field to the ExpandoObject ((IDictionary<string, object>)dataShapedObject) .Add(propertyInfo.Name, propertyValue); } return dataShapedObject; } // the field are separated by ",", so we split it. var fieldsAfterSplit = fields.Split(','); foreach (var field in fieldsAfterSplit) { // trim each field, as it might contain leading // or trailing spaces. Can't trim the var in foreach, // so use another var. var propertyName = field.Trim(); // use reflection to get the property on the source object // we need to include public and instance, b/c specifying a // binding flag overwrites the already-existing binding flags. var propertyInfo = typeof(TSource) .GetProperty(propertyName, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance); if (propertyInfo == null) { throw new Exception($"Property {propertyName} wasn't found " + $"on {typeof(TSource)}"); } // get the value of the property on the source object var propertyValue = propertyInfo.GetValue(source); // add the field to the ExpandoObject ((IDictionary<string, object>)dataShapedObject) .Add(propertyInfo.Name, propertyValue); } // return the list return dataShapedObject; } } }
copy
[HttpGet("{touristRouteId}", Name = "GetTouristRouteById")] [HttpHead("{touristRouteId}")] public async Task<IActionResult> GetTouristRouteById(Guid touristRouteId, string fileds) { var touristRouteFromRepo = await _touristRouteRepository.GetTouristRouteAsync(touristRouteId); if (touristRouteFromRepo == null) { return NotFound($"旅游路线{touristRouteId}找不到"); } var touristRouteDto = _mapper.Map<TouristRouteDto>(touristRouteFromRepo); return Ok(touristRouteDto.ShapeData(fileds)); }

image-20240814235654386

4.处理400级别错误

​ 对于不存在的字段(比如说输入一个itle)找不到对应的字段应该返回400类型的错误,目前返回的是500

image-20240814235638671

​ 在Api:GerTouristRoutes中,对于参数的检查都放在_propertyMappingService之中,所以可以直接把参数检查放到该服务中:

copy
public bool IsPropertiesExists<T>(string fields) { if (string.IsNullOrWhiteSpace(fields)) { return true; } //逗号来分隔字段字符串 var fieldsAfterSplit = fields.Split(','); foreach (var field in fieldsAfterSplit) { // 获得属性名称字符串 var propertyName = field.Trim(); var propertyInfo = typeof(T) .GetProperty( propertyName, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance ); // 如果T中没找到对应的属性 if (propertyInfo == null) { return false; } } return true; }

image-20240815005556828

image-20240815005644221

第四章 HATEOAS

​ 在现代的 Web 开发中,RESTful API 已经成为了与服务器交互的标准方式。然而,传统的 RESTful 设计常常要求客户端对服务器的结构和功能有严格的了解,这种紧耦合使得服务的演化和更新变得复杂。HATEOAS(Hypermedia As The Engine Of Application State)则提出了一种突破这种束缚的方式。HATEOAS 是 REST 架构的一个重要特性,它通过超媒体的使用,将应用的状态和可用操作直接嵌入到资源的表示中,使得客户端与服务器之间的契约不再严格。

​ HATEOAS 的实现核心在于链接(Link)。通过在资源的响应中嵌入超媒体链接,客户端可以动态地发现和访问 API 的其他部分。这些链接包含三个重要元素:hrefrelmethod。其中,href 是指向相关资源的 URI,客户端可以通过这个 URI 来检索资源或改变应用状态。rel 描述了当前资源和 URI 之间的关系,例如,self 表示当前资源的自我描述。method 则指明了对该 URI 进行操作时所需的 HTTP 方法。这种方式让 API 的演化变得更加灵活,客户端不再需要对服务器的内部实现做出硬编码,而是可以根据超媒体提供的信息自适应地进行操作。

​ 比如说当引入了HATEOAS之后,对于 API: GET /touristRoutes/1,在返回的信息中有了下面的数据:

rel: 表示当前链接的关系类型。self 表示该链接用于获取当前资源自身的信息;update 表示用于更新当前资源;delete 表示用于删除当前资源;relatedRoutes 表示获取与当前资源相关的其他资源。

href: 提供了资源的 URI,客户端可以通过该 URI 执行相关操作。

method: 指定了需要使用的 HTTP 方法,如 GETPUTDELETE

copy
{ "id": 1, "title": "Great Wall Adventure", "links": [ { "rel": "self", "href": "/touristRoutes/1", "method": "GET" }, { "rel": "update", "href": "/touristRoutes/1", "method": "PUT" }, { "rel": "delete", "href": "/touristRoutes/1", "method": "DELETE" }, { "rel": "relatedRoutes", "href": "/touristRoutes?relatedTo=1", "method": "GET" } ] }

​ 通过实现 HATEOAS,REST 服务可以更容易地进行演化和扩展。传统的 RESTful 设计要求客户端和服务器之间保持严格的契约,这使得服务的更新和变更往往需要协调和修改客户端代码。而 HATEOAS 通过将可用操作和资源的关系直接嵌入到响应中,打破了这种严格的契约限制。客户端可以动态地根据服务提供的超媒体链接进行导航,从而适应 API 的变更。这种方法不仅提高了服务的灵活性,还大大简化了客户端的维护和更新工作。

​ 在 HATEOAS 模型中,每个资源不仅包含其自身的数据,还包括了一组与其他相关资源的链接。这些链接允许客户端在不知道完整 API 结构的情况下,依据当前资源的状态进行进一步的操作。这样的设计理念使得 API 具有自描述的能力,客户端可以通过分析响应中的链接信息,动态地探索和操作资源。这种方法使得客户端和服务器之间的交互更加灵活,并且为 API 的演化提供了自然的支持。

1.使用HATOEAS处理单一资源

1.1处理GetTouristRouteById

​ 以API:GetTouristRouteById为例

LinkDto 类是一个简单的数据传输对象(DTO),用于表示超媒体链接。也就是上面的json数据中可能会出现的东西,之后我们将对返回的Dto进行改造,使其包含LinkDto的内容。

copy
namespace FakeXiecheng.Dtos { public class LinkDto { // 链接的 URI public string Href { get; set; } // 描述了链接的关系类型,如 "self"、"update" 等 public string Rel { get; set; } // 对该 URI 执行操作所需的 HTTP 方法 public string Method { get; set; } public LinkDto(string href, string rel, string method) { Href = href; Rel = rel; Method = method; } } }

CreateLinkForTouristRoute 方法生成一个包含多种链接的列表,这些链接允许客户端对特定旅游路线执行各种操作,每个 LinkDto 对象表示一个操作链接。

copy
private IEnumerable<LinkDto> CreateLinkForTouristRoute( Guid touristRouteId, string fields) { var links = new List<LinkDto>(); links.Add( new LinkDto( Url.Link("GetTouristRouteById", new { touristRouteId, fields }), "self", "GET" ) ); // 更新 links.Add( new LinkDto( Url.Link("UpdateTouristRoute", new { touristRouteId }), "update", "PUT" ) ); // 局部更新 links.Add( new LinkDto( Url.Link("PartiallyUpdateTouristRoute", new { touristRouteId }), "partially_update", "PATCH") ); // 删除 links.Add( new LinkDto( Url.Link("DeleteTouristRoute", new { touristRouteId }), "delete", "DELETE") ); // 获取路线图片 links.Add( new LinkDto( Url.Link("GetPictureListForTouristRoute", new { touristRouteId }), "get_pictures", "GET") ); // 添加新图片 links.Add( new LinkDto( Url.Link("CreateTouristRoutePicture", new { touristRouteId }), "create_picture", "POST") ); return links; }

​ 更改控制器的内容,主要在于使用:

​ 调用了 CreateLinkForTouristRoute 方法,传入了 touristRouteIdfields 参数。CreateLinkForTouristRoute 方法生成了一组与该旅游路线相关的超媒体链接,并返回一个 IEnumerable<LinkDto> 类型的集合。

​ 通过上面资源排序的内容我们知道ExpandoObject 是 C# 中的一个类,提供了一种在运行时动态添加和修改属性的方式。它实现了 IDictionary<string, object> 接口,这意味着它可以被当作一个字典来操作。所以使用 as IDictionary<string, object>;操作将result转化成字典类型方便之后我们进行.Add("links", linkDtos)

copy
// api/touristroutes/{touristRouteId} [HttpGet("{touristRouteId}", Name = "GetTouristRouteById")] [HttpHead("{touristRouteId}")] public async Task<IActionResult> GetTouristRouteById(Guid touristRouteId, string fields) { var touristRouteFromRepo = await _touristRouteRepository.GetTouristRouteAsync(touristRouteId); if (touristRouteFromRepo == null) { return NotFound($"旅游路线{touristRouteId}找不到"); } var touristRouteDto = _mapper.Map<TouristRouteDto>(touristRouteFromRepo); //return Ok(touristRouteDto.ShapeData(fields)); var linkDtos = CreateLinkForTouristRoute(touristRouteId, fields); var result = touristRouteDto.ShapeData(fields) as IDictionary<string, object>; result.Add("links", linkDtos); return Ok(result); }

image-20240815015009873

1.2在POST请求中复用创建Link组件

​ 创建资源的响应不仅需要返回新创建资源的数据,还需附带相关的操作链接。以下代码示例展示了如何处理 POST 请求以创建旅游路线资源,并与之前获取资源的操作相比,它不需要进行数据塑性,但是需要将result通过ShapeData方法转化成字典:

copy
[HttpPost] [Authorize(AuthenticationSchemes = "Bearer")] [Authorize] public async Task<IActionResult> CreateTouristRoute([FromBody] TouristRouteForCreationDto touristRouteForCreationDto) { var touristRouteModel = _mapper.Map<TouristRoute>(touristRouteForCreationDto); _touristRouteRepository.AddTouristRoute(touristRouteModel); await _touristRouteRepository.SaveAsync(); var touristRouteToReture = _mapper.Map<TouristRouteDto>(touristRouteModel); var links = CreateLinkForTouristRoute(touristRouteModel.Id, null); var result = touristRouteToReture.ShapeData(null) as IDictionary<string, object>; result.Add("links", links); return CreatedAtRoute( "GetTouristRouteById", new { touristRouteId = result["Id"] }, result ); }

image-20240815020131846

2.使用HATOEAS处理列表资源

​ 对于API: GerTouristRoutes加入HATEOAS支持,之前在生成 metadata 的时候使用到了ResourceUriType其中就包含了前一页和后一页的资源链接,而且当时也创建了相关的方法GenerateTouristRouteResourceURL来生成url,加入新的字段表示当前页对应links中的self

copy
namespace FakeXiecheng.Helper { public enum ResourceUriType { PreviousPage, NextPage, CurrnetPage } }

​ 通过传入相应参数调用GenerateTouristRouteResourceURL生成self的地址,且POST:api/touristRoutes也应该被加入。

copy
private IEnumerable<LinkDto> CreateLinksForTouristRouteList( TouristRouteResourceParamaters paramaters, PaginationResourceParamaters paramaters2) { var links = new List<LinkDto>(); // 添加self,自我链接 links.Add(new LinkDto( GenerateTouristRouteResourceURL( paramaters, paramaters2, ResourceUriType.CurrnetPage), "self", "GET" )); // "api/touristRoutes" // 添加创建旅游路线 links.Add(new LinkDto( Url.Link("CreateTouristRoute", null), "create_tourist_route", "POST" )); return links; }

​ 改造控制器,具体操作为创建集合级别的 HATEOAS 链接并为每个资源添加 HATEOAS 链接,最后构建相应结果返回。

copy
var shapedDtoList = touristRoutesDto.ShapeData(paramaters.Fields); var linkDto = CreateLinksForTouristRouteList(paramaters, paramaters2); var shapedDtoWithLinklist = shapedDtoList.Select(t => { var touristRouteDictionary = t as IDictionary<string, object>; var links = CreateLinkForTouristRoute( (Guid)touristRouteDictionary["Id"], null); touristRouteDictionary.Add("links", links); return touristRouteDictionary; }); var result = new { value = shapedDtoWithLinklist, links = linkDto }; return Ok(result);

image-20240816011347300

3.给项目添加API根文档

​ API 根文档是至关重要的,因为它提供了对 API 功能的清晰概述,帮助开发者和用户理解如何使用 API。它提高了 API 的自描述性,减少了开发和使用过程中的错误,并且使 API 的维护和扩展更加容易。过请求根文档的 URL,通常可以获得一组操作链接。

copy
namespace FakeXiecheng.Controllers { [Route("api")] [ApiController] public class RootController : ControllerBase { [HttpGet(Name = "GetRoot")] public IActionResult GetRoot() { var links = new List<LinkDto>(); // 自我链接 links.Add( new LinkDto( Url.Link("GetRoot", null), "self", "GET" )); // 一级链接 旅游路线 “GET api/touristRoutes” links.Add( new LinkDto( Url.Link("GetTouristRoutes", null), "get_tourist_routes", "GET" )); // 一级链接 旅游路线 “POST api/touristRoutes” links.Add( new LinkDto( Url.Link("CreateTouristRoute", null), "create_tourist_route", "POST" )); // 一级链接 购物车 “GET api/orders” links.Add( new LinkDto( Url.Link("GetShoppingCart", null), "get_shopping_cart", "GET" )); // 一级链接 订单 “GET api/shoppingCart” links.Add( new LinkDto( Url.Link("GetOrders", null), "get_orders", "GET" )); return Ok(links); } } }

image-20240816014225062

4.HATEOAS与请求媒体类型

​ 在处理 HATEOAS 时,当前面临的问题是资源数据和操作链接的混合,这种做法可能违反了 RESTful 的设计原则,因为操作(即链接)与资源数据被混合在了一起。为了解决这个问题,我们可以使用内容协商(Content Negotiation),即客户端通过 Accept 头部请求不同的响应格式,从而确保响应数据的结构符合客户端的需求。

​ 媒体类型(Media Types),也称为 MIME 类型,是一种标准,用于表示文档、文件或字节流的性质和格式。它由主要类别(type)和子类别(subtype)组成,例如 application/json 表示标准 JSON 格式,而 application/vnd.arc.hateoas+json 是用于处理 HATEOAS 的自定义媒体类型。 在 .NET 中,MediaTypeHeaderValue 类可用于处理这些媒体类型,其中 SubType 属性可以用来获取子类型部分,从而帮助开发者根据不同的需求处理和解析响应数据。

image-20240816014450343

copy
GET /api/touristRoutes/12345 Accept: application/vnd.arc.hateoas+json "Id": "12345", "Title": "Great Wall of China", "Description": "A historic landmark.", "links": [ { "href": "/api/touristRoutes/12345", "rel": "self", "method": "GET" }, 。。。。。。 { "href": "/api/touristRoutes/12345/pictures", "rel": "create_picture", "method": "POST" } ] }

​ 在实现 HATEOAS (Hypermedia as the Engine of Application State) 时,通常需要使用特定的媒体类型来标识 HATEOAS 响应格式。通过配置媒体类型,服务器能够正确地处理和返回符合 HATEOAS 规范的响应数据,同时确保客户端可以按照预期解析这些数据。

copy
// 为 ASP.NET Core 的 MVC 配置支持特定的媒体类型 builder.Services.Configure<MvcOptions>(config => { var outputFormatter = config.OutputFormatters .OfType<NewtonsoftJsonOutputFormatter>()?.FirstOrDefault(); if (outputFormatter != null) { outputFormatter.SupportedMediaTypes .Add("application/vnd.arc.hateoas+json"); } });

​ 针对控制器主要做了如下修改:

​ 添加了一个 FromHeader 属性的参数 mediaType,用于从请求头中获取 Accept 媒体类型。这使得方法能够根据客户端请求的媒体类型来决定如何格式化响应;

​ 使用 MediaTypeHeaderValue.TryParse 方法解析 mediaType 参数,以确保它符合媒体类型的格式;

​ 根据解析出的媒体类型来决定响应的格式。如果 Accept 头的媒体类型是 application/vnd.arc.hateoas+json,则将响应数据进行 HATEOAS 处理:如果媒体类型为 HATEOAS 特定类型,构造包含 HATEOAS 链接的响应;否则,仅返回简单的形状化数据。

copy
// api/touristRoutes?keyword=传入的参数 [HttpGet(Name = "GetTouristRoutes")] [HttpHead] public async Task<IActionResult> GerTouristRoutes( [FromQuery] TouristRouteResourceParamaters paramaters, [FromQuery] PaginationResourceParamaters paramaters2, [FromHeader(Name = "Accept")] string mediaType //[FromQuery] string keyword, //string rating // 小于lessThan, 大于largerThan, 等于equalTo lessThan3, largerThan2, equalTo5 )// FromQuery vs FromBody { if (!MediaTypeHeaderValue .TryParse(mediaType, out MediaTypeHeaderValue parsedMediatype)) { return BadRequest(); } if (!_propertyMappingService .IsMappingExists<TouristRouteDto, TouristRoute>( paramaters.OrderBy)) { return BadRequest("请输入正确的排序参数"); } if (!_propertyMappingService .IsPropertiesExists<TouristRouteDto>(paramaters.Fields)) { return BadRequest("请输入正确的塑性参数"); } var touristRoutesFromRepo = await _touristRouteRepository .GetTouristRoutesAsync( paramaters.keyword, paramaters.RatingOperator, paramaters.RatingValue, paramaters2.PageSize, paramaters2.PageNumber, paramaters.OrderBy ); if (touristRoutesFromRepo == null || touristRoutesFromRepo.Count() <= 0) { return NotFound("没有旅游路线"); } var touristRoutesDto = _mapper.Map<IEnumerable<TouristRouteDto>>(touristRoutesFromRepo); var previousPageLink = touristRoutesFromRepo.HasPrevious ? GenerateTouristRouteResourceURL( paramaters, paramaters2, ResourceUriType.PreviousPage) : null; var nextPageLink = touristRoutesFromRepo.HasNext ? GenerateTouristRouteResourceURL( paramaters, paramaters2, ResourceUriType.NextPage) : null; // x-pagination var paginationMetadata = new { previousPageLink, nextPageLink, totalCount = touristRoutesFromRepo.TotalCount, pageSize = touristRoutesFromRepo.PageSize, currentPage = touristRoutesFromRepo.CurrentPage, totalPages = touristRoutesFromRepo.TotalPages }; Response.Headers.Add("x-pagination", Newtonsoft.Json.JsonConvert.SerializeObject(paginationMetadata)); var shapedDtoList = touristRoutesDto.ShapeData(paramaters.Fields); if (parsedMediatype.MediaType == "application/vnd.arc.hateoas+json") { var linkDto = CreateLinksForTouristRouteList(paramaters, paramaters2); var shapedDtoWithLinklist = shapedDtoList.Select(t => { var touristRouteDictionary = t as IDictionary<string, object>; var links = CreateLinkForTouristRoute( (Guid)touristRouteDictionary["Id"], null); touristRouteDictionary.Add("links", links); return touristRouteDictionary; }); var result = new { value = shapedDtoWithLinklist, links = linkDto }; return Ok(result); } return Ok(shapedDtoList); //return Ok(touristRoutesDto.ShapeData(paramaters.Fields)); }

image-20240816020246510

image-20240816020424662

第五章 部署

posted @   Purearc  阅读(11)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 【译】Visual Studio 中新的强大生产力特性
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 【设计模式】告别冗长if-else语句:使用策略模式优化代码结构
点击右上角即可分享
微信分享提示
🚀