.Net Core Web Api

1、什么是.Net Core Api

Api:Application Programming Interface,应用程序编程接口

前后端分离:

2、.Net Core Api 与 MVC开发区别

返回数据格式不同,Api返回json格式。

MVC:返回整个html页面。

3、Api 项目创建

在创建项目的时候,我们选择的是Asp.net Core Web Api 的项目。

在创建项目的时候把启用OpenAPI支持,这一个选项选中。

创建好的项目中有Controllers文件夹,但是没有Views文件夹,因为现在我们不需要操作视图。

因为,视图也就是我们所说的页面,都是有前端开发人员进行开发的,而我们作为服务端开发人员,不用关心页面的实现,

也就是说,我们服务端开发人员,只负责实现服务端的业务,并且向前端提供数据,前端页面应该怎样展示数据,服务端开发人员不用关心,都是有前端开发人员实现的。

当启动默认创建好的Api程序以后,会呈现出Swagger

这是由于我们在创建项目的时候,选择了OpenAPI这个选项以后所提供的。

因为Api项目没有界面,所以提供了Swagger访问我们所创建的Api程序。普通用户是无法看到这个Swagger页面的。

普通用户是通过浏览器,App,微信小程序来访问我们的api程序的。所以说Swagger页面是我们开发人员,对Api接口程序进行调试所使用的。

1
2
[ApiController] // 表示的是一个Api的控制器
[Route("[controller]")] // 在请求的时候需要添加控制器的名字
1
[Route("api/[controller]")] // 添加了前缀api,所以在访问的时候需要添加该前缀,如下所示:https://localhost:7081/api/WeatherForecast

如果发送的请求是一个get请求,就会被Get这个方法所处理。

当然,这里我们也可以直接在浏览器的地址栏中输入api的地址进行访问。

4、第一个Api程序

在这一小节中,我们重新创建一个控制器

注意:这里在创建控制器的时候,一定要选择的是API控制器

UserInfoController.cs这个控制器中添加如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[Route("api/[controller]")]
[ApiController]
public class UserInfoController : ControllerBase
{
[HttpGet]
public Person GetPerson()
{
return new Person()
{
Id = 1,
Name = "Test",
Password = "123"
};
}
}

当以Get请求的方式,请求GetPerson方法的时候,会返回用户的信息,这里返回的是一个对象。

这里需要在项目中创建Models文件夹,在该文件夹中创建一个Person.cs类,该类中的代码如下所示:

1
2
3
4
5
6
public class Person
{
public int Id { get; set; }
public string? Name { get; set; }
public string? Password { get; set; }
}

下面我们在发送一个Post请求

1
2
3
4
5
6
7
8
9
10
11
12
[HttpPost]
public ApiResult Login(Person p) // 接收数据
{
if(p.Name =="admin" && p.Password == "123")
{
return new ApiResult { Code = 200, Message = "登录成功" };
}
else
{
return new ApiResult { Code = 500, Message = "登录失败" };
}
}

返回的结果是通过ApiResult这个类来表示的。

所以在Models目录下面创建ApiResult这个类,该类中的代码如下所示:

1
2
3
4
5
public class ApiResult
{
public int Code { get; set; }
public string Message { get; set; }
}

启动程序进行测试。

这里会要求输入请求体。

5、什么是REST/Restful

RestRestful是一样的,只是不同的叫法。

针对Web Api的开发风格(所谓的风格,指的是请求方式怎么使用,状态码怎样使用,路径怎样使用),有两种。

分别是面向过程(RPCRemote Procedure Call】), 面向RestRepresentational State Transfer

先来看一下面向过程:

1
2
3
在RPC风格的WebAPI中,我们通过“控制器/操作方法”的形式把服务器端的代码当成方法去调用。这样的接口只是把HTTP当成一个传输数据的通道,而不关心HTTP谓词的语义(`http`语义,可以理解成协议的一种规范、约定、要求,。例如,删除数据,按照`http`的语义来讲,应该发送`delete`请求,但是我们前面发送的是`get`请求,这就是说明我们定义的`api`不符合`http`协议的语义)、状态码。比如:/Persons/GetAll、/Persons/GetById?id=8、/Persons/Update、/Persons/DeleteById/8;
这种方式简单,粗暴,很多人都习惯采用这种方式来开发`Web Api`

例如:

1
2
3
4
5
[HttpGet("[action]")]
public string GetName(int id)
{
return "zhangsan" + id;
}

这里访问的地址:https://localhost:7081/api/UserInfo/GetName?id=10

下面看一下REST

REST:按照http的语义来使用http协议。

http语义,可以理解成协议的一种规范、约定、要求。例如,删除数据,按照http的语义来讲,应该发送delete请求,但是我们前面发送的是get请求,这就是说明我们定义的api不符合http协议的语义,也不是rest方式。还有关于状态码,如果新增成功,按照http的语义,应该返回的是201(表示新增成功),但是前面我们写的都是200,说明我们前面写的也不符合http语义,也不符合rest风格。

我们只有将符合http语义的web api开发方式称作为rest风格。

当然,我们不使用rest方式,使用rpc方式程序也不会出错。

举一个生活的例子来进一步理解语义的含义:

小轿车的作用就是拉人,但是你拉货,也没有问题,但是不符合小轿车的最初设计的用途。

所以说按照http语义来设计Web api的接口,我们就说所设计的web api接口符合rest风格。

常见的语义:

第一:域名

应该尽量将API部署在专用域名之下。

1
https://api.example.com

如果确定API很简单,不会有进一步扩展,可以考虑放在主域名下。(我们默认创建的WebApi项目符合这个规范)

1
https://example.org/api/

第二:版本

应该将API的版本号放入URL

1
https://api.example.com/v1/

第三:路径

路径又称”终点”(endpoint),表示API的具体网址。

RESTful架构中,每个网址代表一种资源(resource),所以网址中不能有动词,只能有名词,而且所用的名词往往与数据库的表格名对应。一般来说,数据库中的表都是同种记录的”集合”(collection),所以API中的名词也应该使用复数。

举例来说,有一个API提供动物园(zoo)的信息,还包括各种动物和雇员的信息,则它的路径应该设计成下面这样。

1
2
3
https://api.example.com/v1/zoos
https://api.example.com/v1/animals
https://api.example.com/v1/employees

还有如下形式

1
2
3
/users/5  // 获取编号为5的用户信息

/users/5/orders // 获取编号为5的用户的所有的订单信息

第四:HTTP动词(谓词)

对于资源的具体操作类型,由HTTP动词表示。

常用的HTTP动词有下面五个(括号里是对应的SQL命令)。

1
2
3
4
5
GET(SELECT):从服务器取出资源(一项或多项)。
POST(Insert):在服务器新增一个资源。
PUT(UPDATE):在服务器更新资源(整体更新,更新全部字段)。
PATCH(UPDATE):在服务器更新资源(局部更新,只是更新部分字段)。
DELETE(DELETE):从服务器删除资源。

例如:

1
2
3
4
5
6
7
8
GET /zoos:列出所有动物园
POST /zoos:新建一个动物园
GET /zoos/ID:获取某个指定动物园的信息
PUT /zoos/ID:更新某个指定动物园的信息(提供该动物园的全部信息)
PATCH /zoos/ID:更新某个指定动物园的信息(提供该动物园的部分信息)
DELETE /zoos/ID:删除某个动物园
GET /zoos/ID/animals:列出某个指定动物园的所有动物
DELETE /zoos/ID/animals/ID:删除某个指定动物园的指定动物

第五:状态码

服务器向用户返回的状态码和提示信息,常见的有以下一些【方括号中是该状态码对应的HTTP动词】

1
2
3
4
5
6
7
8
9
10
11
12
200 OK - [GET]:服务器成功返回用户请求的数据
201 CreatedAtAction - [POST/PUT/PATCH]:用户新建或修改数据成功。
202 Accepted - [*]:表示一个请求已经进入后台排队(异步任务)
204 NO CONTENT - [DELETE]:用户删除数据成功。
400 INVALID REQUEST - [POST/PUT/PATCH]:用户发出的请求有错误,服务器没有进行新建或修改数据的操作
401 Unauthorized - [*]:表示请求需要用户认证信息,用户未经过身份的验证,。
403 Forbidden - [*] 表示服务端已经知道客户端的身份,只是用户未被授权,用户没有足够的权限来执行某些操作。
404 NOT FOUND - [*]:用户发出的请求针对的是不存在的记录,服务器没有进行操作,例如请求编号为1的用户,如果不存在,返回404
406 Not Acceptable - [GET]:用户请求的格式不可得(比如用户请求JSON格式,但是只有XML格式)。
410 Gone -[GET]:用户请求的资源被永久删除,且不会再得到的。
422 Unprocesable entity - [POST/PUT/PATCH] 当创建一个对象时,发生一个验证错误。
500 INTERNAL SERVER ERROR - [*]:服务器发生错误,用户将无法判断发出的请求是否成功。

以下定义的符合rest风格

如何请求这些控制器中的方法呢?
例如:

1
2
3
4
5
6
[HttpGet("{id}")]
public string GetName(int id)
{
return "zhangsan" + id;
}

以上方法请求的方式:

1
https://localhost:7081/api/UserInfo/12

注意:在以上的地址中没有添加方法的名字。与传统MVC访问是不同的

以上常见的restful的风格,其他的在后面用到的时候再进行讲解。

路由:https://learn.microsoft.com/zh-cn/aspnet/core/mvc/controllers/routing?view=aspnetcore-7.0

6、REST优缺点

优点:

  1. 通过URL对资源定位,语义更清晰;

  2. 通过HTTP谓词表示不同的操作,接口统一且具有自描述性,减少了前端开发人员对接口文档的依赖性。

    例如:删除编号为12用户请求的就是:/UserInfos/12, 而且发送的是delete请求,这里我们不需要查看稳当就知道。

  3. 可以用GET请求做缓存

  4. 通过HTTP状态码反映服务器端的处理结果统一错误处理机制。

缺点:

  1. 真实系统资源复杂,很难清晰划分,对业务技术水平要求高;Restful 风格对设计人员的IT技能和业务知识的水平要求都非常高。

2、不是所有操作都能简单的对应到确定的HTTP谓词操作;(有些接口中包含了更新,包含了删除)

3、通过URL进行资源定位不符合中文用户习惯;

4、HTTP状态码个数有限;有些复杂的情况,仅通过状态码无法描述

选择:

第一:Rest是学术化的概念,仅供参考。国内很多公司也并不是Restful1

第二:根据公司情况,进行Rest的选择和裁剪。

实现业务才是王道。

7、获取用户信息。

在这个小节中,我们需要创建两个api接口。

第一个获取用户的所有信息,第二个获取获取指定的用户信息。

这里我们在原有的MVC架构的基础上做修改。

使用原有项目架构的模版(使用原有的服务层,数据仓储层)

创建一个Web Api项目,在该项中把原来MVC项目中使用到的Filters,Attributes,AutofaceDI文件夹,都拷贝到Web Api项目中

然后修改命名空间的名称

1
2
3
4
namespace Cms.WebApi.Filters // 这里修改成了Cms.WebApi
{
public class UnitOfWorkFilter : IAsyncActionFilter
{

下面修改AutofaceDI文件夹中AutofacModuleRegister类的命名空间

1
2
3
4
namespace Cms.WebApi.AutofaceDI
{
public class AutofacModuleRegister:Autofac.Module
{

Attributes目录下的类UnitOfWorkAttribute.cs的命名空间

1
2
namespace Cms.WebApi.Attributes
{

同时Cms.WebApi这个WebApi项目需要引入其他的类库项目

同时还需要包含如下项目的引用

1
2
3
4
5
6
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Autofac" Version="7.0.0" />
<PackageReference Include="Autofac.Extensions.DependencyInjection" Version="8.0.0" />

下面在Program.cs文件中进行注入的操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
builder.Services.AddControllers();

//注入DbContext,同时获取数据库链接字符串
builder.Services.AddDbContext<MyDbContext>(opt =>
{
string connStr = builder.Configuration.GetConnectionString("StrConn")!;
opt.UseSqlServer(connStr);
});
// 完成Filter的注册
builder.Services.Configure<MvcOptions>(c =>
{
c.Filters.Add<UnitOfWorkFilter>();
});

// 完成Autofac的创建
builder.Host.UseServiceProviderFactory(new AutofacServiceProviderFactory()).ConfigureContainer<ContainerBuilder>(builder =>
{
builder.RegisterModule(new AutofacModuleRegister());
});

最后,还需要注意链接字符串,在Cms.WebApi项目的appsettings.json配置文件中不要忘记添加数据库链接字符串

1
2
3
4
5
6
7
8
9
10
11
12
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"StrConn": "server=localhost;database=CmsDb;uid=sa;pwd=123456;TrustServerCertificate=true"
}
}

完成以上的配置修改以后创建控制器UsersController.cs,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class UsersController : ControllerBase
{
private readonly IUserInfoService _userInfoService;
public UsersController(IUserInfoService _userInfoService)
{
this._userInfoService = _userInfoService;
}
[HttpGet] // api/v1/users get
public IActionResult GetAllUsers()
{
var users = _userInfoService.LoadEntities(c=>true);
return Ok(users); // 返回的状态码是200
}
s
}

以上实现的就是返回所有用户信息的接口实现。

1
2
3
4
5
6
[HttpGet("{id}")]  /
public IActionResult GetUser(int id)
{
var user = _userInfoService.LoadEntities(u=>u.Id==id).FirstOrDefault;
return Ok(user);
}

8、返回正确的状态码

在访问GetUser方法的时候,传递过滤的用户编号是不存在的应该怎样进行处理呢?

可以测试一下,输入一个不存在的用户编号,发现返回的状态码还是200,但是这种情况下返回的状态码应该是404.

1
2
3
4
5
6
7
8
9
10
[HttpGet("{id}")]
public async Task<IActionResult> GetUser(int id)
{
var user = await _userInfoService.LoadEntities(u=>u.Id==id).FirstOrDefaultAsync();
if(user == null)
{
return NotFound($"编号为{id}的用户不存在!!");
}
return Ok(user);
}

同理修改一下GetAllUsers这个方法,如下所示:

1
2
3
4
5
6
7
8
9
10
[HttpGet]
public IActionResult GetAllUsers()
{
var users = _userInfoService.LoadEntities(c => true);
if(users==null || users.Count() == 0)
{
return NotFound("用户信息不存在!!");
}
return Ok(users);
}

也可以在Apifox中进行测试

9、数据传输对象DTO((Data Transfer Object))

关于数据传输对象我们前面也已经讲过。

如果我们直接将数据实体模型Model返回到前端,一般会有如下两个问题

第一个问题:直接项前端返回实体模型,会暴露系统的业务核心。

第二个问题:返回的颗粒度太粗,也就是返回到前端的数据无法精细的调整

例如,有一个电商系统。

该系统针对商品有两个价格,一个是内部的员工价格,另外一个是市场价格

公司的内网使用的是员工价格,外网使用的是市场价格

问题是:产品的数据模型只有一个,难道要因为区别不同的价格而拆分模型吗?

这样就会很麻烦,而我们这里只是要求根据不同的场景对外传递不同数据对象,而这个向外传递数据的对象就是DTO.

简单的理解就是DTO是面向界面的,而Model是根据业务来进行设计的,所以它是面向业务的。

例如:前面我们所设计的UserInfo这个实体数据模型,里面包含了关于用户很多的信息,但是如果在页面上只需要展示用户的用户名,这时候就需要用到DTO.

所以说,使用DTO完全可以解决上面我们提到的两个问题。

第一:使用DTO的时候可以屏蔽我们不希望对外暴露的核心业务

第二:通过不同DTO的组合,又可以调整输出数据的结果,从而解决颗粒度太粗的问题。

10、分离ModelDTO

Cms.WebApi项目中添加一个Dtos文件夹,在该文件夹中创建UserInfoDTO.cs这个类,该类中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class UserInfoDto
{
/// <summary>
/// 主键id
/// </summary>
public long Id { get; set; }

/// <summary>
/// 添加数据时间
/// </summary>
public string CreateTime { get; set; } //// 注意:这里的类型与UserInfo这个Model实体类中的CreateTime的类型是不一样的,所以需要单独的进行映射投影操作。
/// <summary>
/// 更新数据时间
/// </summary>
public DateTime UpdateTime { get; set; }

public string? UserName { get; set; }
/// <summary>
/// 邮箱
/// </summary>
public string? UserEmail { get; set; }
/// <summary>
/// 电话
/// </summary>
public string? UserPhone { get; set; }
/// <summary>
/// 区号
/// </summary>
public string? AreaCode { get; set; }
/// <summary>
/// 性别:0表示男,1表示女
/// </summary>
public int Gender { get; set; }
/// <summary>
/// 用户头像,存储头像路径
/// </summary>
public string? PhotoUrl { get; set; }
}

这里对比Models中的UserInfo.cs中发现缺少了Password,也就是说前端展示的时候不需要展示密码。

上面的UserInfoDto.cs中定义的属性类型也可以根据前端页面实际展示情况进行调整,例如:前端展示的用户添加日期为xxxx年xx月xx日形式,这里就可以将CreateTime修改成string类型。

下面可以通过前面我们所学习的AutoMapper实现数据模型DTO之间的映射。

Cms.WebApi项目中安装如下包

1
Install-Package AutoMapper.Extensions.Microsoft.DependencyInjection

下面配置映射关系

Cms.WebApi这个项目中再创建一个Profiles文件夹,在该文件夹中创建UserInfoProfile.cs 类,该类中的代码如下所示:

1
2
3
4
5
6
7
8
  public class UserInfoProfile:Profile
{
public UserInfoProfile() {
CreateMap<UserInfo, UserInfoDto>() .ForMember(dest=>dest.CreateTime,opt=>opt.MapFrom(src=>src.CreateTime.Year+"年"+src.CreateTime.Month+"月"+src.CreateTime.Day+"日"));

}
}
}

这里是将UserInfo这个数据模型映射成UserInfoDto,如果两个类中的属性名字,类型是一样的,会自动进行映射。

但是这里的UserInfoDto中的CreateTime的类型是字符串类型,与UserInfo这个实体类中的CreateTime的类型是不一样的,所以这里需要借助于ForMember函数进行单独的配置映射。将UserInfo这个实体类中的CreateTime属性映射成UserInfoDto中的CreateTime属性,这时候该属性的值就变成了xxxx年xx月xx日的形式。

下面将AutoMapper添加到容器中

修改Program.cs文件中的代码

1
2
3
4
builder.Services.AddAutoMapper(typeof(UserInfoProfile)); // 将AutoMapper添加到容器中

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

当然,这里也可以修改成如下的形式:

1
builder.Services.AddAutoMapper(AppDomain.CurrentDomain.GetAssemblies());

AppDomain: 应用程序域,表示的是一组程序集的逻辑容器。AppDomain.CurrentDomain.GetAssemblies()获取到当前应用程序域下的所有的程序集。综合的起来就是获取当前应用程序集下的所有继承了Profile的类。

返回到UsersController控制器中进行代码的修改:

1
2
3
4
5
6
7
8
9
10
public class UsersController : ControllerBase
{
private readonly IUserInfoService _userInfoService;
private readonly IMapper mapper;
// 这里完成了mapper的注入操作
public UsersController(IUserInfoService _userInfoService,IMapper mapper)
{
this._userInfoService = _userInfoService;
this.mapper = mapper;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[HttpGet("{id}")]
public async Task<IActionResult> GetUser(int id)
{
// 可以进行测试
// Console.WriteLine("AppDomain=="+ AppDomain.CurrentDomain);//Cms.WebApi,当前的程序集
/* foreach(var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
Console.WriteLine(assembly.FullName); // 打印的是当前Cms.WebApi所引用的所有程序集
}*/
var user = await _userInfoService.LoadEntities(u=>u.Id==id).FirstOrDefaultAsync();
var userDTO = mapper.Map<UserInfoDto>(user);
if(user == null)
{
return NotFound($"编号为{id}的用户不存在!!");
}
return Ok(userDTO);
}

在上面的代码中,当我们获取到了数据以后,调用Map方法,将获取到的数据都映射到了UserInfoDto对象的属性中。

进行测试,返回的数据如下所示:

1
2
3
4
5
6
7
8
9
10
{
"id": 1,
"createTime": "2012年12月1日", // 这里的日期发生了变化。
"updateTime": "2012-12-03T00:00:00",
"userName": "张三",
"userEmail": "zhangsan@126.com",
"userPhone": "12345678901",
"gender": 0,
"photoUrl": "/images/f.jpg"
}

下面我们又修改了GetAllUsers方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[HttpGet]
public IActionResult GetAllUsers()
{
var users = _userInfoService.LoadEntities(c => true);
// 这里有映射成了List集合
var usersDto = mapper.Map<List<UserInfoDto>>(users);
// 判断的是usersDto
if(usersDto == null || usersDto.Count() == 0)
{
return NotFound("用户信息不存在!!");
}
else
{
// 这里返回的是usersDto
return Ok(usersDto);
}

}

启动项目进行测试,发现数据中的createTime的值都发生了变化,改成了我们想要的格式。

11、获取文章信息

11.1 配置一对多关系

定义文章的数据模型,这里为了简单,定义的文章实体模型如下所示,在Cms.Entity中创建Articel.cs

1
2
3
4
5
6
7
public class Articel:BaseEntity<long>
{
public string? Title { get; set; }
public string? Content { get; set; }
// 表示一篇文章只能属于一个用户
public UserInfo? UserInfo { get; set; }
}

定义ArticelConfig.cs配置类,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
public class ArticelConfig : IEntityTypeConfiguration<Articel>
{
public void Configure(EntityTypeBuilder<Articel> builder)
{
builder.ToTable("T_Articels");
builder.Property(x => x.Title).HasMaxLength(100).IsRequired();
builder.Property(x => x.Content).IsRequired();
// 配置一对多的关系
builder.HasOne(c=>c.UserInfo).WithMany(a=>a.Articels).IsRequired();
}
}
}

修改UserInfo.cs这个实体类,代码如下所示:

1
2
3
4
5
public string? PhotoUrl { get; set;}
/// <summary>
/// 一个用户可以发布多篇文章
/// </summary>
public List<Articel> Articels { get; set;}=new List<Articel>();

修改Cms.EntityFrameworkCore项目中的MyDbContext.cs数据上下文类,这里需要增加DbSet

1
2
3
4
public class MyDbContext:DbContext
{
public DbSet<UserInfo>UserInfos { get; set; }
public DbSet<Articel> Articels { get; set; }

同时还需要修改MyDbContextDesign.cs这个类,我们这里换了数据库

1
2
3
4
5
public MyDbContext CreateDbContext(string[] args)
{
DbContextOptionsBuilder builder = new DbContextOptionsBuilder<MyDbContext>();
string connStr = "server=localhost;database=APIDemo;uid=sa;pwd=123456;TrustServerCertificate=true";
builder.UseSqlServer(connStr);

这里我们更换了一个新的数据库APIDemo,当然这里我们可以先在SQLServer中创建APIDemo这个数据库。

当然,Cms.WebApi项目中的配置文件appsettings.json也需要修改一下数据库链接字符串。

1
2
3
"ConnectionStrings": {
"StrConn": "server=localhost;database=APIDemo;uid=sa;pwd=123456;TrustServerCertificate=true"
}

链接到的数据库也是APIDemo

下面生成数据迁移

[程序包管理器控制台]中选择Cms.EntityFrameworkCore,执行

1
2
Add-Migration createArticel
Update-database

下面在数据库中添加测试数据。

下面再创建数据仓储

创建IArticelRepository.cs

1
2
3
4
5
6
7
namespace Cms.IRepository
{
public interface IArticelRepository:IBaseRepository<Articel>
{
}
}

创建ArticelRepository.cs

1
2
3
4
5
6
7
8
namespace Cms.Repository
{
public class ArticelRepository:BaseRepository<Articel>,IArticelRepository
{
public ArticelRepository(MyDbContext context) : base(context) { }
}
}

下面创建服务

IArticelService.cs

1
2
3
4
5
6
namespace Cms.IService
{
public interface IArticelService:IBaseService<Articel>
{
}
}

创建ArticelService.cs

1
2
3
4
5
6
7
8
9
10
11
12
namespace Cms.Service
{
public class ArticelService:BaseService<Articel>,IArticelService
{
private readonly IArticelRepository articelRepository;
public ArticelService(IArticelRepository articelRepository)
{
base.repository = articelRepository;
this.articelRepository = articelRepository;
}
}
}

11.2 实现信息的查询

Cms.WebApi项目中的Dtos文件夹下面创建ArticelDto.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class ArticelDto
{
/// <summary>
/// 主键id
/// </summary>
public long Id { get; set; }

/// <summary>
/// 添加数据时间
/// </summary>
public DateTime CreateTime { get; set; }
/// <summary>
/// 更新数据时间
/// </summary>
public DateTime UpdateTime { get; set; }

public string? Title { get; set; }
public string? Content { get; set; }

}

Profiles文件夹下面创建ArticelProfile.cs类,代码如下所示:

1
2
3
4
5
6
7
8
9
namespace Cms.WebApi.Profiles
{
public class ArticelProfile:Profile
{
public ArticelProfile() {
CreateMap<Articel,ArticelDto>();
}
}
}

UsersController.cs控制器中添加如下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/// <summary>
/// 获取某个用户发布的文章
/// </summary>
/// <param name="userId"></param>
/// <returns></returns> api/v1/users/1/articels
[HttpGet("{userId}/articels")] // 这里的userId与参数userId名字一样
public async Task<IActionResult> GetArticelForUser(int userId)
{
var userInfo = await _userInfoService.LoadEntities(u => u.Id == userId).FirstOrDefaultAsync();
if (userInfo == null)
{
return NotFound("用户不存在");
}
var articels = _articelService.LoadEntities(a => a.UserInfo == userInfo).ToList();
if (articels.Count() <= 0)
{
return NotFound("文章不存在");
}
return Ok(mapper.Map<List<ArticelDto>>(articels));
}

当然,在UsersController.cs中需要注入文章服务。

1
2
3
4
5
6
7
8
9
10
11
12
public class UsersController : ControllerBase
{
private readonly IUserInfoService _userInfoService;
private readonly IArticelService _articelService;
private readonly IMapper mapper;
public UsersController(IUserInfoService _userInfoService,IMapper mapper, IArticelService _articelService)
{
this._userInfoService = _userInfoService;
this.mapper = mapper;
// 注入文章服务
this._articelService = _articelService;
}

12、加载所有用户以及用户发布的文章

这里,我们希望,当获取用户信息的时候,顺便把用户发布的文章也给查询出来。

我们知道,在Cms.Entity中的UserInfo.cs这个实体类中,定义了如下内容:

1
2
3
4
/// <summary>
/// 一个用户可以发布多篇文章
/// </summary>
public List<Articel> Articels { get; set;}=new List<Articel>();

表示的是一个用户发布的多篇文章。

当然,在UserInfoDto.cs中也可以进行表示:

1
2
3
4
5
6
7
 
public string? PhotoUrl { get; set; }

// 注意这里的属性名字Articels必须与上面`UserInfo.cs`实体类中的Articels属性名字保持一致
// 只不过这里返回的List中的内容类型是ArticelDto

public List<ArticelDto>? Articels { get; set; }

下面在UsersController.cs控制器中添加如下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[HttpGet("articels")]  // api/v1/users/articels
public IActionResult GetUsersAndArticels()
{
// 使用了Include,在查询用户的时候,关联对应的文章,也就是查询用户的同时,把用户发布的文章也查询出来。
var users = _userInfoService.LoadEntities(c => true).Include(c=>c.Articels);
// 映射到List<UserInfoDto>
var usersDto = mapper.Map<List<UserInfoDto>>(users);
if (usersDto == null || usersDto.Count() == 0)
{
return NotFound("用户信息不存在!!");
}
else
{
return Ok(usersDto);
}
}

启动程序进行测试。

13、文章搜索

修改ArticelsController这个控制器中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
namespace Cms.WebApi.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class ArticelsController : ControllerBase
{
private readonly IArticelService _articelService;

private readonly IMapper mapper;
public ArticelsController(IArticelService articelService, IMapper mapper) {
this.mapper = mapper;
// 完成了注入的操作
this._articelService = articelService;
}
//这是要求搜索的url地址: api/articels?keyword=传入的参数
[HttpGet]
public IActionResult GetArticel([FromQuery]string keyword) // 通过FromQuery来接收参数keyword
{
var articels = _articelService.LoadEntities(a=>a.Title!.Contains(keyword)); // 针对文章标题进行搜索
if (articels.Count() <= 0)
{
return NotFound("没有找到文章");
}
var articelList = mapper.Map<List<ArticelDto>>(articels);
return Ok(articelList);

}

}
}

以上实现了一个基本的搜索。

如果是多条件搜索,可以继续在url地址中添加搜索的条件。

下面我们来修改上面的代码实现多条件搜索。

Cms.Entity这个类库项目中创建Search目录,在该目录中创建ArticelSeach.cs这个类,该类中的代码如下所示:

1
2
3
4
5
6
public class ArticelSearch:BaseSearch
{
public string? title { get; set; }
public string? FormDatepicker { get; set; }
public string? ToDatepicker { get; set; }
}

同时在Seach目录中创建BaseSearch.cs这个类,该类中的代码如下所示:

1
2
3
4
5
6
7
public class BaseSearch
{
public int PageIndex { get;set; }
public int PageSize { get; set; }
public int TotalCount { get; set; }
public bool Order { get; set; }
}

修改IArticelService.cs这个文章服务类中的代码,如下所示:

1
2
3
4
public interface IArticelService:IBaseService<Articel>
{
IQueryable<Articel> LoadSearchEntities(ArticelSearch articelSearch,bool delFlag);
}

这里增加了一个LoadSearchEntities接口。

下面在ArticelService.cs类中实现上面的接口,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class ArticelService:BaseService<Articel>,IArticelService
{
private readonly IArticelRepository articelRepository;
public ArticelService(IArticelRepository articelRepository)
{
base.repository = articelRepository;
this.articelRepository = articelRepository;
}

public IQueryable<Articel> LoadSearchEntities(ArticelSearch articelSearch, bool delFlag)
{
var temp = articelRepository.LoadEntities(a=>a.DelFlag==delFlag);
if (!string.IsNullOrWhiteSpace(articelSearch.title))
{
temp = temp.Where(a=>a.Title!.Contains(articelSearch.title));
}
// 开始时间与结束时间必须都完整
if (!string.IsNullOrWhiteSpace(articelSearch.FormDatepicker)&&!string.IsNullOrWhiteSpace(articelSearch.ToDatepicker))
{
DateTime startDateTime = Convert.ToDateTime(articelSearch.FormDatepicker);
DateTime endDateTime = Convert.ToDateTime(articelSearch.ToDatepicker);
temp = temp.Where(a=>a.CreateTime>=startDateTime &&a.CreateTime<=endDateTime);
}
articelSearch.TotalCount = temp.Count(); // 统计总的记录数
// 判断是升序还是降序
if(articelSearch.Order)
{
return temp.OrderBy<Articel, long>(a => a.Id).Skip<Articel>((articelSearch.PageIndex - 1) * articelSearch.PageSize).Take<Articel>(articelSearch.PageSize);
}
else
{
return temp.OrderByDescending<Articel, long>(a => a.Id).Skip<Articel>((articelSearch.PageIndex - 1) * articelSearch.PageSize).Take<Articel>(articelSearch.PageSize);
}

}
}

修改控制器ArticelsController中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// api/articels?keyword=传入的参数
[HttpGet]
public IActionResult GetArticel([FromQuery]ArticelSearch articelSearch)
{
// var articels = _articelService.LoadEntities(a=>a.Title!.Contains(keyword));
articelSearch.PageSize = 5;
articelSearch.PageIndex = 1;
int totalCount = 0;
articelSearch.TotalCount = totalCount;
var articels = _articelService.LoadSearchEntities(articelSearch,Convert.ToBoolean(DelFlagEnum.Normal));
if ( articelSearch.TotalCount <= 0)
{
return NotFound("没有找到文章");
}
var articelList = mapper.Map<List<ArticelDto>>(articels);
return Ok(articelList);

}

在上面的代码中,我们用ArticelSearch接收前端发送过滤的搜索条件的数据

当然,这里关于分页的内容,我们暂时先不考虑,所以将pageSize,pageIndex属性的值先固定具体的值。

启动项目进行测试。

通过测试页面,我们发现这里也需要前端发送TotalCount,这是不合理的,因为TotalCount表示的是满足条件的总的记录数,是有服务端对数据进行过滤后统计出来的,并不是有前端发送过来的。而PageIndex,PageSize是有前端发送过来的。

所以说这里,我们通过ArticelSearch这个类型作为GetArticel方法接收前端发送过来的搜索内容的类型是不合适的。

所以这里还需要进一步处理。

Cms.WebApi这个项目中创建文件夹ResourceParams文件夹,该文件中创建ArticelParams.cs这个类,该类中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
public class ArticelParams
{
public string? title { get; set; }
public string? FormDatepicker { get; set; }
public string? ToDatepicker { get; set; }

public int PageIndex { get; set; }
public int PageSize { get; set; }
public bool Order { get; set; }
}

目前我们暂时不考虑分页,只考虑多条件搜索的情况。

下面在修改控制器中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// api/articels?keyword=传入的参数
[HttpGet]
public IActionResult GetArticel([FromQuery] ArticelParams articelParams) // 这里接收到的参数的类型是ArticelParams
{
articelParams.PageIndex = 1; // 这里暂时不考虑分页的实现,所以先将数据固定
articelParams.PageSize = 10;
// var articels = _articelService.LoadEntities(a=>a.Title!.Contains(keyword));
int totalCount = 0;
// 把articelParams中接收到的前端传递过来的数据重新赋值给ArticelSearch对象。因为在业务层使用该对象进行的搜索功能柜的实现。
ArticelSearch articelSearch = new ArticelSearch()
{
title = articelParams.title,
FormDatepicker = articelParams.FormDatepicker,
ToDatepicker = articelParams.ToDatepicker,
PageSize = articelParams.PageSize,
PageIndex = articelParams.PageIndex,
Order = articelParams.Order,
TotalCount = totalCount
};
// 传递到业务层中的代码还是ArticelSearch这个对象
var articels = _articelService.LoadSearchEntities(articelSearch,Convert.ToBoolean(DelFlagEnum.Normal));
// 这里可以对articelSearch.TotalCount进行判断。
if (articelSearch.TotalCount <= 0)
{
return NotFound("没有找到文章");
}
var articelList = mapper.Map<List<ArticelDto>>(articels);
return Ok(articelList);

}

启动项目进行测试。

通过测试,我们发现数据都是通过url地址参数传递过来的。

14、创建用户

这里,我们主要学习的是post请求。

创建用户的时候,我们需要接收前端发送过来的数据,最终保存到数据库中。

这里我们接收到的数据还是要交给DTO对象,然后映射到数据模型中。

前面我们已经在Dtos目录中创建了UserInfoDto.cs,是否可以直接使用该对象呢?

不建议,前面创建的UserInfoDto.cs是负责输出的,也就是用来展示数据的。而现在我们创建的Dto对象是负责接收的,也就是输入的。因为,不同的操作对数据的要求是不同的,例如:在输入的时候,不需要id,同时还需要对数据进行校验(输出的数据是最终来自数据库,不需要对数据进行校验)。Dto对象针对的是前端的逻辑,不同的前端页面的逻辑是不一样的。这样职责更加的明确。

所以在Dtos文件夹中创建一个UserInfoCreateDto.cs类,作为输入用户信息的数据传输对象。

该类中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class UserInfoCreateDto
{


public string? UserName { get; set; }
/// <summary>
/// 邮箱
/// </summary>
public string? UserEmail { get; set; }
/// <summary>
/// 电话
/// </summary>
public string? UserPhone { get; set; }

/// <summary>
/// 密码 //----------------------------------这里在添加用户的时候必须指定密码
/// </summary>
public string? UserPassword { get; set; }
/// <summary>
/// 性别:0表示男,1表示女
/// </summary>
public int Gender { get; set; }
/// <summary>
/// 用户头像,存储头像路径
/// </summary>
public string? PhotoUrl { get; set; }
}

以上代码我们是从UserInfoDto.cs中直接拷贝过来的,但是这里缺少了Id属性,我们知道,在添加数据的时候,前端页面不需要传递Id,因为Id的值是有服务端在向数据库中插入数据的时候确定的。同时也没有添加CreateTimeUpdateTime(因为添加信息的时候,日期时间是不需要用户输入的,只需要获取当前系统的日期时间就可以了)

但是这里需要密码,所以上面添加了UserPassword这个属性。

同时这里我们少了 public List<ArticelDto>? Articels { get; set; }

关于Articels这个集合的处理我们后面在进行讲解。

下面返回到控制器中,添加如下方法:

1
2
3
4
5
6
7
8
9
/// <summary>
/// 创建用户
/// </summary>
/// <returns></returns>
[HttpPost]
public IActionResult CreatUser([FromBody]UserInfoCreateDto userInfoCreateDto)
{

}

这里我们创建了CreateUser方法来完成用户信息的创建,这里是一个Post请求,接收前端页面发送过来的数据,需要使用到[FromBody],然后数据都传递给了UserInfoCreateDto这个对象。

下面我们需要将UserInfoCreateDto这个Dto对象映射到UserInfo这个实体模型类。

修改Profiles/UserInfoProfile.cs文件的配置,如下所示:

1
2
3
4
5
6
7
public UserInfoProfile() {
CreateMap<UserInfo, UserInfoDto>()
.ForMember(dest=>dest.CreateTime,opt=>opt.MapFrom(src=>src.CreateTime.Year+"年"+src.CreateTime.Month+"月"+src.CreateTime.Day+"日"));
// 这里将UserInfoCreateDto,映射到UserInfo
// 由于UserInfoCreateDto中没有添加CreateTime与UpdateTime两个属性,所以将其映射给UserInfo的时候,这里只能将当前时间映射给UserInfo中的CreateTime和UpdateTime
CreateMap<UserInfoCreateDto, UserInfo>().ForMember(dest=>dest.CreateTime,opt=>opt.MapFrom(src=>DateTime.Now)).ForMember(dest=>dest.UpdateTime,opt=>opt.MapFrom(src=>DateTime.Now));
}

下面修改控制器UsersController.cs中的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/// <summary>
/// 创建用户: http://localhost:5154/api/Users
/// </summary>
/// <returns></returns>
[HttpPost]
[UnitOfWork(new Type[] { typeof(MyDbContext) })] //注意,添加该Attribute才能把数据保存到数据库中。
public async Task<IActionResult> CreatUser([FromBody]UserInfoCreateDto userInfoCreateDto)
{
//这里是将userInfoCreateDto映射到UserInfo
var userInfoModel = mapper.Map<UserInfo>(userInfoCreateDto);
// 将userInfoModel添加到DbContext中
await _userInfoService.InsertEntityAsync(userInfoModel);
// 添加完成后,把userInfoModel映射成UserInfoDto中,方面前端页面展示
var user = mapper.Map<UserInfoDto>(userInfoModel);
// return CreatedAtRoute("GetUser",new {id =user.Id },user); // 返回
// 第一个参数:表示的是控制器中的方法
// 第二个参数:表示的是添加用户成功后,将添加成功的用户信息返回
return CreatedAtAction("GetAllUsers", user);// 返回的状态码是201,同时把添加成功的用户信息返回到前端,而且在返回的响应头中会有 location: http://localhost:5154/api/Users 这个信息,告诉前端,可以通过这个地址,访问所有用户的信息。
// 以get请求上面的地址,访问的就是GetAllUsers这个方法

}

启动项目进行测试。

15、用户发布文章

在这一小节中,我们实现的是指定的某个用户发布文章的api接口

Dtos目录下面创建ArticelForCreateDto.cs这个类,该类中的代码如下所示:

1
2
3
4
5
6
7
public class ArticelForCreateDto
{

public string? Title { get; set; }
public string? Content { get; set; }

}

上面代码中,表示用户在发布文章的时候,只需要输入文章的标题和文章的内容就可以了。

下面需要进行配置,也就是将ArticelForCreateDto映射到Articel这个实体类中(但是在映射的时候,要注意的是ArticelForCreateDto这个数据传输对象中没有CreateTime,UpdateTime,DelFlag属性,需要再配置中进行指定,如下所示:)。

修改Profiles文件夹下的ArticelProfile.cs中的代码,如下所示:

1
2
3
4
5
6
7
public ArticelProfile() { 
CreateMap<Articel,ArticelDto>();
// 这里将`ArticelForCreateDto`映射到Articel中。
// 这里同样针对Articel这个模型类中的CreateTime和UpdateTime属性赋值为当前时间
// 对Articel中的DelFlag属性默认值为true
CreateMap<ArticelForCreateDto, Articel>().ForMember(dest => dest.CreateTime, opt => opt.MapFrom(src => DateTime.Now)).ForMember(dest => dest.UpdateTime, opt => opt.MapFrom(src => DateTime.Now)).ForMember(dest=>dest.DelFlag,opt=>opt.MapFrom(src=>true)) ;
}

下面修改UsersController.cs控制器中的代码,该该控制器中添加如下方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/// <summary>
/// 指定的某个用户发布文章 /api/Users/9/articels
/// </summary>
/// <returns></returns>
[HttpPost("{userId}/articels")]
[UnitOfWork(new Type[] { typeof(MyDbContext) })]
public async Task<IActionResult> CreateArticelForUser([FromRoute]int userId, [FromBody] ArticelForCreateDto articelForCreateDto)
{
var userInfo = await _userInfoService.LoadEntities(u=>u.Id==userId).FirstOrDefaultAsync();
if (userInfo == null)
{
return NotFound("用户不存在!");
}
// 这里将articelForCreateDto映射到Articel
var articelModel = mapper.Map<Articel>(articelForCreateDto);
articelModel.UserInfo = userInfo; // 我们知道在Articel中的UserInfo属性表示的是,文章是谁添加的,所以必须确定其只。
await _articelService.InsertEntityAsync(articelModel); // 把articelModel添加到EFCore 上下文中
var articel = mapper.Map<ArticelDto>(articelModel); // 把articelModel在映射成ArticelDto,也就是添加成功后前端展示的内容
// 返回的状态码是201,同时返回的响应头中会有 location:http://localhost:5154/api/Users/9/articels
// 表示以get请求该地址,会查看对应的用户发送的文章.该地址请求的是GetArticelForUser方法。
// 第一个参数:当前控制器中的方法名
// 第二个参数:在通过url访问GetArticelForUser方法的时候需要传递的参数.
// 第三个参数:当文章发布成功以后,将发布成功的文章信息返回到前端。
return CreatedAtAction("GetArticelForUser",new { userId=userInfo.Id},articel); //这里也会将用户发布成功的文章信息返回。
}

首先参数userId是从路由中获取所以使用了[FromRoute]articelForCreateDto参数接收的就是文章的数据,这是数据是通过json格式传递过来的。所以使用了[FromBody].

16、数据验证

当我们接收到前端页面发送过来的数据的时候,都需要对数据进行校验。

例如:创建用户的时候,发布文章的时候,都需要对数据进行校验。

关于校验的方式在前面讲解.net core mvc课程的时候都给大家讲解过,关键是当数据没有通过校验以后,返回给前端的状态码是多少呢?

一般返回的状态码是422,该状态码的含义是服务端接收到的前端数据是有错误的。当然,除了返回状态码,还要给前端返回错误的信息。

16.1 添加数据验证

1
2
3
4
5
6
7
8
9
public class ArticelForCreateDto
{
[Required(ErrorMessage ="title 不能为空!")]
[MaxLength(100)]
public string? Title { get; set; }
[Required(ErrorMessage = "content 不能为空!")]
public string? Content { get; set; }

}

启动项目进行测试,输入空字符串进行校验。

1
2
3
4
{
"title": ""
"content": ""
}

进行测试,返回的响应报文是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"traceId": "00-810c18ca31baa2592ee25638dd409c64-16d3580cb5db9a6d-00",
"errors": {
"Title": [
"title 不能为空!"
],
"Content": [
"content 不能为空!"
]
}
}

当然,除了这种数据验证方式以外,还可以给当前需要校验的DTO数据传输类添加Attribute.

Cms.WebApi这个api项中创建ValidationAttributes文件夹,在该文件夹中创建ArticelValidateAttribute.cs,这里必须使用Attribute进行结尾。

ArticelValidateAttribute.cs文件中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
using Cms.WebApi.Dtos;
using System.ComponentModel.DataAnnotations;

namespace Cms.WebApi.ValidationAttributes
{
// 这里继承ValidationAttribute类,该类来自 System.ComponentModel.DataAnnotations;命名空间
public class ArticelValidateAttribute:ValidationAttribute
{
// 这里重写IsValid方法
protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
{
// 通过校验的上下文获取到需要校验的对象,当然这里需要强制转换成ArticelForCreateDto,因为这里就是对输入的文章内容进行校验。
var articelDto = (ArticelForCreateDto) validationContext.ObjectInstance;
if (string.IsNullOrWhiteSpace(articelDto.Title))
{
// 第一个参数:错误信息
// 第二个参数: 错误路径
return new ValidationResult("title 文章标题不能为空!!", new[] { "ArticelForCreateDto" });

}
if (string.IsNullOrWhiteSpace(articelDto.Content))
{
return new ValidationResult("content 文章内容不能为空!!", new[] { "ArticelForCreateDto" });

}
return ValidationResult.Success; // 表示校验通过了。
}
}
}

下面使用ArticelValidateAttribute.

修改Dtos/ArticelForCreateDto.cs类中的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
using Cms.Entity;
using Cms.WebApi.ValidationAttributes;
using System.ComponentModel.DataAnnotations;

namespace Cms.WebApi.Dtos
{
[ArticelValidate] // 使用ArticelValidate对输入的文章数据进行校验。
public class ArticelForCreateDto
{
public string? Title { get; set; }

public string? Content { get; set; }

}
}

启动项目进行测试。

例如:如果不输入标题,会出现如下错误信息

1
2
3
4
5
6
7
8
9
10
11
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"traceId": "00-9e8a2ff25d30f264e249bab2eb0eaca2-8c70b91fc63e5a93-00",
"errors": {
"ArticelForCreateDto": [ // 出现错误的数据传输对象。
"title 文章标题不能为空!!"
]
}
}

16.2 输出状态码422

现在我们可以看到,当数据没有通过校验的时候,返回的状态码是400,但是实际上在http语义中,数据没有通过校验要求返回的状态码是422.

修改Program.cs文件中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 在AddControllers方法后面添加ConfigureApiBehaviorOptions方法
builder.Services.AddControllers().ConfigureApiBehaviorOptions(c =>
{
c.InvalidModelStateResponseFactory = context =>
{
var problemDetails = new ValidationProblemDetails(context.ModelState)
{

Title = "数据校验有问题",
Status = StatusCodes.Status422UnprocessableEntity,
Instance = context.HttpContext.Request.Path

};
// UnprocessableEntityObjectResult:执行时将生成无法处理的实体 (422) 响应。
return new UnprocessableEntityObjectResult(problemDetails)
{
ContentTypes = { "application/problem+json" }
};

};

});

重新启动项目进行测试,这里不输入文章的标题,查看一下返回的响应信息。

17、数据更新操作

关于数据更新有两项操作

putpatch

put:对某个资源所有的字段进行更新操作 // update table set username=”admin”,userpwsd=123,email = where id=1

patch:对某个资源的某几个字段进行部分更新操作。

17.1 使用put请求更新资源

这里我们先使用put的方式来更新用户的信息。

首先在Dtos目录下面创建一个更新用户的数据传输对象UserInfoForUpdateDto.cs,该类中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class UserInfoForUpdateDto
{

public string? UserName { get; set; }
/// <summary>
/// 邮箱
/// </summary>
public string? UserEmail { get; set; }
/// <summary>
/// 电话
/// </summary>
public string? UserPhone { get; set; }

/// <summary>
/// 密码
/// </summary>
public string? UserPassword { get; set; }

/// <summary>
/// 性别:0表示男,1表示女
/// </summary>
public int Gender { get; set; }
/// <summary>
/// 用户头像,存储头像路径
/// </summary>
public string? PhotoUrl { get; set; }
}

Profiles/UserInfoProfile.cs中进行配置,如下所示:

1
2
3
4
5
6
7
8
public UserInfoProfile() {
CreateMap<UserInfo, UserInfoDto>()
.ForMember(dest=>dest.CreateTime,opt=>opt.MapFrom(src=>src.CreateTime.Year+"年"+src.CreateTime.Month+"月"+src.CreateTime.Day+"日"));
CreateMap<UserInfoCreateDto, UserInfo>().ForMember(dest=>dest.CreateTime,opt=>opt.MapFrom(src=>DateTime.Now)).ForMember(dest=>dest.UpdateTime,opt=>opt.MapFrom(src=>DateTime.Now));
// 这里是将UserInfoForUpdateDto映射给UserInfo.
// 这里的UpdatetTime是需要更新的,但是在前端我们是不需要用户输入更新时间的,所以这里只能用当前时间重新给UpdateTime赋值。
CreateMap<UserInfoForUpdateDto, UserInfo>().ForMember(dest=>dest.UpdateTime,opt=>opt.MapFrom(src=>DateTime.Now));
}

下面修改UsersController.cs控制器

在该控制器中添加了如下的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/// <summary>
/// 更新用户信息
/// </summary>
/// <param name="userId"></param>
/// <param name="userInfoForUpdateDto"></param>
/// <returns></returns>
[HttpPut("{userId}")]
[UnitOfWork(new Type[] { typeof(MyDbContext) })]
public async Task<IActionResult> EditUser([FromRoute]int userId, [FromBody]UserInfoForUpdateDto userInfoForUpdateDto)
{
var userInfo = await _userInfoService.LoadEntities(u=>u.Id ==userId).FirstOrDefaultAsync();
if(userInfo == null)
{
return NotFound("用户不存在");
}
// 把userInfoForUpdateDtao映射给userInfo这个数据模型
mapper.Map(userInfoForUpdateDto,userInfo);
return NoContent();// 返回204状态码(当然,这里也可以自己定义返回的信息)
}

注意:这里是全部更新,如果某个字段的值不更新,也要给其原先的值。(但是,我们发现CreateTimeDelFlag两个字段中的值还是原来的值,原因是:在UserInfoForUpdateDto中我们没有创建这两个属性,所以在将UserInfoForUpdateDto映射给UserInfo的时候,这两个字段,没有进行映射,当向数据库中保存的时候,还是使用的UserInfo这个数据模型中的CreateTime和DelFlag

启动项目进行测试。

17.2 put请求的数据校验

在更新用户数据的时候,也是要对数据进行校验,并且校验规则也是与创建用户的时候是一样的。

而且,我们所创建的UserInfoCreateDto.csUserInfoForUpdateDto.cs两个数据传输类中的属性都是一样的,所以这里完全可以做进一步的封装处理。

Dtos目录下面创建一个UserInfoForAbstractDto.cs类,该类中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public abstract class UserInfoForAbstractDto
{
public string? UserName { get; set; }
/// <summary>
/// 邮箱
/// </summary>
public string? UserEmail { get; set; }
/// <summary>
/// 电话
/// </summary>
public string? UserPhone { get; set; }

/// <summary>
/// 密码
/// </summary>
public string? UserPassword { get; set; }

/// <summary>
/// 性别:0表示男,1表示女
/// </summary>
public int Gender { get; set; }
/// <summary>
/// 用户头像,存储头像路径
/// </summary>
public string? PhotoUrl { get; set; }
}

由于这里是输入的用户DTO对象和输出的用户DTO对象都需要继承UserInfoForAbstractDto这个类,并且我们也不是直接使用该类,所以UserInfoForAbstractDto这个类定义成抽象类就可以了。

修改Dto/UserInfoCreateDto.cs中的代码,如下所示:

1
2
3
4
5
public class UserInfoCreateDto:UserInfoForAbstractDto
{
// 这里需要继承抽象类UserInfoForAbstractDto

}

修改Dto/UserInfoForUpdateDto.cs中的代码,如下所示:

1
2
3
4
5
public class UserInfoForUpdateDto: UserInfoForAbstractDto
{
// 这里需要继承抽象类UserInfoForAbstractDto

}

如果完成校验,我们还需要在ValidationAttributes目录下面创建UserInfoValidateAttribute.cs类,该类的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class UserInfoValidateAttribute: ValidationAttribute
{
protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
{
// 这里我们修改成了抽象类UserInfoForAbstractDto
var userDto = (UserInfoForAbstractDto)validationContext.ObjectInstance;
if (string.IsNullOrWhiteSpace(userDto.UserName))
{
return new ValidationResult("UserName 用户名不能为空!!", new[] { "UserInfoForAbstractDto" });

}
if (string.IsNullOrWhiteSpace(userDto.UserPhone))
{

return new ValidationResult("UserPhone 用户电话号码不能为空!!", new[] { "UserInfoForAbstractDto" });

}

return ValidationResult.Success;
}
}

下面在UserInfoCreateDto.csUserInfoForUpdateDto.cs这两个类中使用上面所定义的UserInfoValidateAttribute,如下代码所示:

1
2
3
4
5
[UserInfoValidate]
public class UserInfoCreateDto:UserInfoForAbstractDto
{

}
1
2
3
4
5
[UserInfoValidate]
public class UserInfoForUpdateDto: UserInfoForAbstractDto
{

}

启动项目进行测试。

在更新的时候,如果输入的用户名是一个空字符串会给出错误提示。

问题:如果在更新的时候,UserName如果为空的情况下,给出的错误提示与创建的时候给出的错误提示不一样应该怎样进行处理呢?

修改UserInfoForAbstractDto.cs抽象类中的代码:

1
2
3
4
5
6
7
8
public abstract class UserInfoForAbstractDto
{
// 这里添加了virtual
public virtual string? UserName { get; set; }
/// <summary>
/// 邮箱
/// </summary>
public string? UserEmail { get; set; }

这里给UserName添加了virtual,那么对应的子类中就可以进行重写了,例如:

1
2
3
4
5
6
[UserInfoValidate]
public class UserInfoForUpdateDto: UserInfoForAbstractDto
{
[Required(ErrorMessage ="用户名不能为空,难道你不知道??")]
public override string? UserName { get ; set; }
}

UserInfoForUpdateDto.cs类中,这里对UserName进行了重写,并且通过ErrorMessage给出了不同的错误提示。

下面可以进行测试,在创建用户和更新用户的时候,当用户名为空字符串的时候,给出的错误提示是不一样的。

17.3 patch数据的局部更新

在上一小节中,我们通过put完成了数据的全部更新,但是如果只是更新部分字段,还是建议使用patch的方式。

前端发送patch请求给服务端,服务端需要将前端发送过来的json数据转换成以下的数据格式:如下所示:

1
2
3
4
[
{"op":"replace","path":"/UserName","value":"admin"}, // 这里是对UserName这个字段进行替换的操作,将其值替换成admin
{"op":"remove","path":"/Gender",}
]

以上这种表示如何修改数据的结构,我们称作JSON Patch

op指定操作,path指定资源,也就是对哪个属性进行操作,value表示的就是对应的值.

当然,对没有指定的字段,会保留原有的值。

关于op操作,包含了6个选项,分别是:add添加某个字段,remove:删除某个字段,replace:替换某个字段的数据

“move”:转移,”copy”:赋值,test:测试

服务端对patch请求的处理,需要安装如下两个包,安装到Cms.WebApi项目中

1
2
Install-Package microsoft.aspnetcore.jsonpatch
Install-Package microsoft.aspnetcore.mvc.NewtonsoftJson

下面在UsersController.cs控制器中添加如下方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/// <summary>
/// 部分更新用户信息
/// </summary>
/// <param name="userId"></param>
/// <param name="patchDocument"></param>
/// <returns></returns>
[HttpPatch("{userId}")]
[UnitOfWork(new Type[] { typeof(MyDbContext) })]
public async Task<IActionResult> UpdateUser([FromRoute] int userId, [FromBody] JsonPatchDocument<UserInfoForUpdateDto> patchDocument) // 表示接收前端传递过来的JsonPatch文档,不再是UserInfoForUpdateDto对象
{
var userInfo = await _userInfoService.LoadEntities(u => u.Id == userId).FirstOrDefaultAsync();
if (userInfo == null)
{
return NotFound("用户不存在");
}
// 把UserInfo映射给UserInfoForUpdateDto,因为UserInfoForUpdateDto中属性是没有值的,而经过上面的查询UserInfo中是有值的
var userPatch = mapper.Map<UserInfoForUpdateDto>(userInfo);
// 通过ApplyTo方法,就知道更新哪些数据了,patchDocument中保存的是要更新的数据,与userPatch进行比对,就知道修改userPatch中哪些属性了。
patchDocument.ApplyTo(userPatch);
// 在更新数据到数据库之前,还是要将`userPath`,映射到UserInfo中,就相当于以前需要将userInfoForUpdateDto映射到UserInfo中是一样的。
mapper.Map(userPatch,userInfo);
return NoContent();
}

这里还需要修改Profiles/UserInfoProfile.cs中的代码,在最后添加

1
CreateMap<UserInfo, UserInfoForUpdateDto>();

这里需要将UserInfo这个数据模型,映射到UserInfoForUpdateDto中。

最后,还需要注意的一个点,就是前端发送过来的JSON数据,需要转换成JsonPatchDocument,这里就需要使用到我们前面所安装的NewtonsoftJson包,这个包需要在Program.cs中进行配置,如下所示:

1
2
3
4
5
6
7
8
// 在AddControllers方法后面添加AddNewtonsoftJson方法
builder.Services.AddControllers().AddNewtonsoftJson(a =>
{
// 在转换的时候,使用驼峰命名的方式。
a.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver();
})
.ConfigureApiBehaviorOptions(c =>
{

启动项目进行测试,在界面窗口中输入:

输入:

1
2
3
4
5
6
7
8
9
[
{
"operationType": 0,
"path": "/UserName",
"op": "replace",
"from": "string",
"value": "admin"
}
]

主要考虑:path,op,value

在进行测试的时候,给控制器中的UpdateUser这个方法打上断点,看一下数据的变化情况,更容易理解方法中的代码。

17.4 patch请求的数据校验

前面在更新数据的时候,我们添加了相应的校验,那么我们来测试一下通过patch请求更新数据的时候,校验是否还起作用。

当输入:

1
2
3
4
5
6
7
8
9
[
{
"operationType": 0,
"path": "/UserName",
"op": "replace",
"from": "string",
"value": "" // 这里输入的是空字符串
}
]

发现已经定义的校验规则没有起作用。

原因是:我们现在获取到的数据是JsonPatchDocument,而不是原来的DTO数据传输对象。所以添加到数据传输对象上的校验规则没有起作用。

所以这里需要修改控制器中的UpdateUser方法,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public async Task<IActionResult> UpdateUser([FromRoute] int userId, [FromBody] JsonPatchDocument<UserInfoForUpdateDto> patchDocument)
{
var userInfo = await _userInfoService.LoadEntities(u => u.Id == userId).FirstOrDefaultAsync();
if (userInfo == null)
{
return NotFound("用户不存在");
}
var userPatch = mapper.Map<UserInfoForUpdateDto>(userInfo);
patchDocument.ApplyTo(userPatch,ModelState);// 这里将全局属性ModelState与对应的UsserInfoFouUpdateDto进行了绑定
// 对userPatch进行校验,TryValidateModel:验证成功返回true,没有验证通过返回false
if (!TryValidateModel(userPatch))
{
// 返回错误的信息。ModelState在前面已经与具体的`DTO`对象绑定了,所以知道具体的错误信息
return ValidationProblem(ModelState);
}
mapper.Map(userPatch,userInfo);
return NoContent();
}

再次进行测试。

问题,如果修改多个字段,可以采用如下方式进行测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[
{
"operationType": 0,
"path": "/UserName",
"op": "replace",
"from": "string",
"value": "admin12"
},
{
"operationType": 0,
"path": "/UserPassword",
"op": "replace",
"from": "string",
"value": "123"
}
]

18、数据删除

18.1 删除指定的用户信息

1
2
3
4
5
6
7
8
9
10
11
12
[HttpDelete("{userId}")] // 指定HttpDelete
[UnitOfWork(new Type[] { typeof(MyDbContext) })]
public async Task<IActionResult> DeleteUser([FromRoute]int userId)
{
var userInfo = await _userInfoService.LoadEntities(u => u.Id == userId).FirstOrDefaultAsync();
if (userInfo == null)
{
return NotFound("用户不存在");
}
await _userInfoService.DeleteEntityAsync(userInfo); // 这里直接进行物理删除
return NoContent();// 直接返回204状态码
}

18.2 删除用户发布的文章

这里我们需要删除的是某个用户发布的谋篇文章。

这里我们还在UsersController.cs控制器中添加代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/// <summary>
/// 删除指定用户的指定文章
/// </summary>
/// <param name="userId">用户编号</param>
/// <param name="articelId">文章编号</param>
/// <returns></returns>
[HttpDelete("{userId}/articels/{articelId}")] // ---------注意请求地址的设置
[UnitOfWork(new Type[] { typeof(MyDbContext) })]
public async Task<IActionResult> DeleteUserArticel([FromRoute]int userId, [FromRoute]int articelId)
{
var userInfo = await _userInfoService.LoadEntities(u => u.Id == userId).FirstOrDefaultAsync();
if (userInfo == null)
{
return NotFound("用户不存在");
}
var articelInfo = await _articelService.LoadEntities(a => a.Id == articelId&&a.UserInfo==userInfo).FirstOrDefaultAsync(); // 注意-----这里指定的查询条件
if (articelInfo == null)
{
return NotFound("文章不存在");
}
await _articelService.DeleteEntityAsync(articelInfo);
return NoContent();

}

18.3 批量删除用户

在这一小节中,我们来看一下怎样创建批量删除用户信息的Api接口。

批量删除的URL构建

1
2
3
4
方式1: 参数使用?号的方式
delete api/users?Ids=1,2,3,4,5
方式2:参数构建URL片段
delete api/users/(1,2,3,4,5)

方式1,在前面讲解.net core mvc的时候,给大家演示过。

在这以小节中,我们主要采用的是方式2.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/// <summary>
/// 批量删除用户信息
/// </summary>
/// <param name="userIds"></param>
/// <returns></returns>
[HttpDelete("({userIds})")] // 注意:这里使用了小括号包裹了参数名
[UnitOfWork(new Type[] { typeof(MyDbContext) })]
public async Task<IActionResult> DeleteAllUsers([FromRoute]List<string>userIds)
{
var strIds = userIds[0].Split(','); // 集合中第一个元素中存储了用小括号括起来的用户编号,所以这里取出来以后使用逗号进行分割
List<long> newList=new List<long>();
foreach(var id in strIds)
{
newList.Add(Convert.ToInt32(id));
}
var userInfoList = _userInfoService.LoadEntities(u=>newList.Contains(u.Id));
foreach(var user in userInfoList)
{
await _userInfoService.DeleteEntityAsync(user);
}
return NoContent();
}

启动项目进行测试。

以下代码采用了方式1来进行处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/// <summary>
/// 批量删除用户信息
/// </summary>
/// <param name="userIds"></param>
/// <returns></returns>
[HttpDelete("{userIds}/alluser")] // 这里不用给参数{userId}添加小括号,同时为了区分删除单条记录的url地址,所以后面添加了一个alluser
[UnitOfWork(new Type[] { typeof(MyDbContext) })]
public async Task<IActionResult> DeleteAllUsers([FromRoute]string userIds) // 这里接收的是字符串
{
var strIds = userIds.Split(',');// 字符串中每个编号都是使用逗号进行分割的
List<long> newList=new List<long>();
foreach(var id in strIds)
{
newList.Add(Convert.ToInt32(id));
}
var userInfoList = _userInfoService.LoadEntities(u=>newList.Contains(u.Id));
foreach(var user in userInfoList)
{
await _userInfoService.DeleteEntityAsync(user);
}
return NoContent();
}

启动项目进行测试,可以监视浏览器中的请求,看一下请求的地址。

19、JWT

19.1、JWT介绍

参考文档:https://www.cnblogs.com/jingboweilan/p/14569434.html

https://www.cnblogs.com/yuanrw/p/10089796.html

JSON Web Token(JWT)

Internet服务无法与用户身份验证分开。一般过程如下。

1.用户向服务器发送用户名和密码。

2.验证服务器后,相关数据(如用户角色,登录时间等)将保存在当前会话中。

3.服务器向用户返回session_id,session信息都会写入到用户的Cookie。

4.用户的每个后续请求都将通过在Cookie中取出session_id传给服务器。

5.服务器收到session_id并对比之前保存的数据,确认用户的身份。

这种模式最大的问题是,没有分布式架构,无法支持横向扩展。如果使用一个服务器,该模式完全没有问题。但是,如果它是服务器群集或面向服务的跨域体系结构的话,则需要一个统一的session数据库库来保存会话数据实现共享,这样负载均衡下的每个服务器才可以正确的验证用户身份。

例如:站点A和站点B提供同一公司的相关服务。现在要求用户只需要登录其中一个网站,然后它就会自动登录到另一个网站。怎么做?

一种解决方案是听过持久化session数据,写入数据库或文件持久层等。收到请求后,验证服务从持久层请求数据。该解决方案的优点在于架构清晰,而缺点是架构修改比较费劲,整个服务的验证逻辑层都需要重写,工作量相对较大。而且由于依赖于持久层的数据库或者问题系统,会有单点风险,如果持久层失败,整个认证体系都会挂掉。

所以这里,可以通过客户端保存数据,而服务器根本不保存会话数据,每个请求都被发送回服务器。 JWT是这种解决方案的代表。

19.2. JWT的原则

JSON Web Tocken

JWT的原则是在服务器身份验证之后,将生成一个JSON对象并将其发送回用户,如下所示。

1
2
3
4
5
6
7
8
9
10
{

"UserName": "Chongchong",

"Role": "Admin",

"Expire": "2018-08-08 20:15:56"

}

之后,当用户与服务器通信时,客户在请求中发回JSON对象。服务器仅依赖于这个JSON对象来标识用户。为了防止用户篡改数据,服务器将在生成对象时添加签名

服务器不保存任何会话数据,即服务器变为无状态,使其更容易扩展。

19.3. JWT的数据结构

典型的,一个JWT看起来如下图。

改对象为一个很长的字符串,字符之间通过”.”分隔符分为三个子串。注意JWT对象为一个长字串,各字串之间也没有换行符,此处为了演示需要,我们特意分行并用不同颜色表示了。每一个子串表示了一个功能块,总共有以下三个部分:

JWT的三个部分如下。JWT头、有效载荷和签名,将它们写成一行如下。

JWT头部分是一个描述JWT元数据的JSON对象,通常如下所示。

1
2
3
4
5
6
7
8
{

"alg": "HS256",

"type" "JWT"

}

在上面的代码中,alg属性表示签名使用的算法,默认为HMAC SHA256(写为HS256);typ属性表示令牌的类型,JWT令牌统一写为JWT。

最后,使用Base64 URL算法将上述JSON对象转换为字符串保存。

3.2 有效载荷

有效载荷部分,是JWT的主体内容部分,也是一个JSON对象,包含需要传递的数据。 JWT指定七个默认字段供选择。

iss:发行人

exp:到期时间

sub:主题

aud:用户

nbf:在此之前不可用

iat:发布时间

jti:JWT ID用于标识该JWT

除以上默认字段外,我们还可以自定义私有字段,如下例:

1
2
3
4
5
6
7
8
9
10
{

"sub": "1234567890",

"name": "chongchong",

"admin": true

}

请注意,默认情况下JWT载荷是未加密的,任何人都可以解读其内容,因此不要构建隐私信息字段,存放保密信息,以防止信息泄露。

JSON对象也使用Base64 URL算法转换为字符串保存。

3.3签名哈希

签名哈希部分是对上面两部分数据签名,通过指定的算法生成哈希,以确保数据不会被篡改。

首先,需要指定一个密钥(secret)。该密码仅仅为保存在服务器中,并且不能向用户公开。然后,使用标头中指定的签名算法(默认情况下为HMAC SHA256)根据以下公式生成签名。

1
2
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload),secret)

在计算出签名哈希后,JWT头,有效载荷和签名哈希的三个部分组合成一个字符串,每个部分用”.”分隔,就构成整个JWT对象。

4. JWT的用法

客户端接收服务器返回的JWT,将其存储在Cookie或localStorage中。

此后,客户端将在与服务器交互中都会带JWT。如果将它存储在Cookie中,就可以自动发送,但是不会跨域,因此一般是将它放入HTTP请求的Header Authorization字段中。

Authorization: Bearer

当跨域时,也可以将JWT被放置于POST请求的数据主体中

总结内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
 它是一个很长的字符串,中间用点(`.`)分隔成三个部分。注意,JWT 内部是没有换行的,这里只是为了便于展示,将它写成了几行。 

JWT 的三个部分依次如下。

```
Header(头部)
Payload(负载)
Signature(签名)
```

写成一行,就是下面的样子。

```
Header.Payload.Signature
```

下面依次介绍这三个部分。

**Header**

Header 部分是一个 JSON 对象,描述 JWT 的元数据,通常是下面的样子。

```json
{
"alg": "HS256",
"typ": "JWT"
}
```

上面代码中,`alg`属性表示签名的算法(algorithm),默认是 HMAC SHA256(写成 HS256);`typ`属性表示这个令牌(token)的类型(type),JWT 令牌统一写为`JWT`。

最后,将上面的 JSON 对象使用 Base64URL 算法转成字符串。

**Payload**

Payload 部分也是一个 JSON 对象,用来存放实际需要传递的数据。JWT 规定了7个官方字段,供选用。

```
iss (issuer):签发人
exp (expiration time):过期时间
sub (subject):主题
aud (audience):受众
nbf (Not Before):生效时间
iat (Issued At):签发时间
jti (JWT ID):编号
```

除了官方字段,你还可以在这个部分定义私有字段,下面就是一个例子。

```
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
```

这个 JSON 对象也要使用 Base64URL 算法转成字符串。

**Signature**

Signature 部分是对前两部分的签名,防止数据篡改。

首先,需要指定一个密钥(secret)。这个密钥只有服务器才知道,不能泄露给用户。然后,使用 Header 里面指定的签名算法(默认是 HMAC SHA256),按照下面的公式产生签名。

```
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
```

算出签名以后,把 Header、Payload、Signature 三个部分拼成一个字符串,每个部分之间用"点"(`.`)分隔,就可以返回给用户。

19.4 JWT的基本使用

我们首先完成的是用户登录,登录成功以后,会创建JWT,然后返回给客户端。

当然,在项目中使用JWT,需要再Cms.WebApi项目中安装如下的包。

1
Install-Package  Microsoft.AspNetCore.Authentication.JwtBearer

这里首先会进行登录的校验,所以针对登录,先创建一个DTO数据传输对象。

Dtos这个目录下面创建LoginDto.cs类,该类中的代码,如下所示:

1
2
3
4
5
6
7
public class LoginDto
{
[Required]
public string? UserName { get; set; }
[Required]
public string? UserPassword { get; set; }
}

针对登录,很难创建一个标注的Restful的接口,但是我们前面也已经讲解过,不用为了RestfulRestful

这里创建一个LoginController.cs控制器,该控制器中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
using Cms.IService;
using Cms.WebApi.Dtos;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Permissions;
using System.Text;

namespace Cms.WebApi.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class LoginController : ControllerBase
{
private readonly IUserInfoService _userInfoService;
private readonly IConfiguration configuration;
// 注入IUserInfoService和IConfiguration(这里获取配置文件中的内容)
public LoginController(IUserInfoService _userInfoService,IConfiguration configuration)
{
this._userInfoService = _userInfoService;
this.configuration = configuration;
}
[HttpPost]
public async Task<IActionResult> login([FromBody] LoginDto loginDto)
{
if(string.IsNullOrWhiteSpace(loginDto.UserName)||string.IsNullOrWhiteSpace(loginDto.UserName))
{
return BadRequest("用户名或密码不能为空!");
}
// 1、验证用户
var loginUsr = await _userInfoService.LoadEntities(u=>u.UserName==loginDto.UserName&&u.UserPassword==loginDto.UserPassword).FirstOrDefaultAsync();
if (loginUsr == null)
{
return BadRequest("用户名或密码错误!");
}
// 2、创建JWT
// 创建header,内部定义了JWT编码的算法
var securityAlgorithm = SecurityAlgorithms.HmacSha256;
// 创建payload,需要根据项目的需求来进行创建,例如可能会使用到用户的编号,用户名,邮箱等
// 创建payload的内容,需要使用到Claim(每创建一个Claim对象,表示用户的一个信息)
var claims = new[]
{
// 第一项是,用户的ID,但是在JWT中,关于ID在JWT中有一个专用的名词叫做sub
new Claim(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Sub,loginUsr.Id.ToString())
};
// 创建签名,签名会使用到私钥,可以把私钥保存在配置文件中
// 读取配置文件中的私钥,然后转成字节
var secretByte = Encoding.UTF8.GetBytes(configuration["Authentication:SecretKey"]!);
// 使用加密算法,对私钥进行加密
var signingKey = new SymmetricSecurityKey(secretByte);
// 构建数字签名
var signingCredentials = new SigningCredentials(signingKey, securityAlgorithm);
// 构建token 内容
var token = new JwtSecurityToken(
// 谁发布的token数据,一般是服务端的地址
issuer: configuration["Authentication:Issuer"],
// 把token数据发布给谁,一般就是前端项目,这里也可以填写服务端的地址,或者不填写也可以
audience: configuration["Authentication:Audience"],
claims,
// 发布时间
notBefore:DateTime.Now,
// 有效期
expires:DateTime.Now.AddDays(1),
// 数字签名
signingCredentials

);
// 将token生成字符串的形式进行输出
var tokenStr = new JwtSecurityTokenHandler().WriteToken(token);
//3、返回 200状态码和JWT内容
return Ok(tokenStr);
}
}
}

在上面的代码中,我们构建数字签名的时候,使用了私钥,私钥必须保存到服务端,同时这里我们将其保存在了appsettings.json文件中

1
2
3
4
5
6
7
8
"ConnectionStrings": {
"StrConn": "server=localhost;database=APIDemo;uid=sa;pwd=123456;TrustServerCertificate=true"
},
"Authentication": {
"SecretKey":"abc123456789@126.com" // 这里的值一定要长,否则会出错。
"Issuer": "xxx.com",
"Audience": "xxx.com"
}

19.5 启动授权

当用户访问某些资源的时候,需要进行验证。

这就是使用前面所生成的token.

当然,这里需要注入jwt的认证服务。

修改Program.cs文件中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 注入jwt的认证服务(这里采用默认的认证)
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options =>
{
// 获取配置文件中存储的密钥
var secretByte = Encoding.UTF8.GetBytes(builder.Configuration["Authentication:SecretKey"]!);
options.TokenValidationParameters = new TokenValidationParameters()
{
// 验证token的发布者
ValidateIssuer = true,
ValidIssuer = builder.Configuration["Authentication:Issuer"],
// 验证token的持有者
ValidateAudience = true,
ValidAudience = builder.Configuration["Authentication:Audience"],
// 验证toen是否过期
ValidateLifetime = true,
// 使用私钥
IssuerSigningKey = new SymmetricSecurityKey(secretByte)

};

});

//注入DbContext,同时获取数据库链接字符串
builder.Services.AddDbContext<MyDbContext>(opt =>

以上代码就是在注入DbContext服务的上面,注入了JWT的认证服务。

1
2
3
4
5
6
7
8
9
10
11
12
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
// 你是谁,是否登录
app.UseAuthentication();
// 你可以干什么,有什么权限?
app.UseAuthorization();

app.MapControllers();

同时,在Program.cs文件的下面添加了app.UseAuthentication(),app.UseAuthorization();app.MapControllers();

下面,我们进行测试。

我们考虑到用户登录了才能创建其他的用户。

所以说,在UsersController.cs控制器中的CreateUser方法上面添加了Authorize,进行校验。

1
2
3
4
5
6
7
8
9
/// <summary>
/// 创建用户: http://localhost:5154/api/Users
/// </summary>
/// <returns></returns>
[HttpPost]
[Authorize] // 启用授权
[UnitOfWork(new Type[] { typeof(MyDbContext) })]
public async Task<IActionResult> CreatUser([FromBody]UserInfoCreateDto userInfoCreateDto)
{

下面进行测试,首先在swagger页面中进行登录,当登录成功以后,才能获取到token信息。

下面访问[Post]/api/Users的地址,去创建用户,这里我们输入完用户信息以后,单击执行的Execute按钮以后,会返回401状态码,表示要求进行身份认证,原因是:针对这次创建用户的这次POST请求我们没有带上token信息。

才导致了返回401状态码,在swagger中配置请求中添加token稍微麻烦,这里我们可以使用ApifOX软件。

20、分页处理

分页的参数传递方式,通常都是通过查询字符串来进行传递的。

例如:/api/users?当前页=1&每页显示记录数=10

当然,如果用户没有设置页码或者是每页显示的记录数,可以设置默认值,例如:默认页码值为1,每页显示的记录数默认值为10.

这里我们以文章分页来进行演示。

这里我们首先修改ResourceParams/ArticelParams.cs中的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class ArticelParams
{


public string? title { get; set; }
public string? FormDatepicker { get; set; }
public string? ToDatepicker { get; set; }
// 定义一个成员变量
private int _pageIndex = 1;
// 当前页码值,默认值是1
public int PageIndex {
get
{
return _pageIndex;
}
set {

if(value >=1 )
{
_pageIndex = value;
}

}
}
// 定义成员变量_pageSize,默认值是10
private int _pageSize = 10;
// 表示最大值是50,也就是说如果用户随意的输入了每页显示的记录数,如果大于了50,最多每页显示的记录数也是50
const int maxPageSize = 50;
public int PageSize {
get
{
return _pageSize;
}
set {
if (value >= 1)
{

_pageSize = value > maxPageSize?maxPageSize : value;
}
}
}
public bool Order { get; set; }
}

在以上的代码中,通过定义私有成员变量 _pageIndex 和_pageSize对当前的页码与每页显示的记录数都做了一定的限制。

返回到ArticelsController.cs这控制器中,把注释测试代码注释掉。

1
2
3
4
5
6
7
8
// api/articels?keyword=传入的参数
[HttpGet]
public IActionResult GetArticel([FromQuery] ArticelParams articelParams)
{
/* articelParams.PageIndex = 1;
articelParams.PageSize = 10;*/
// var articels = _articelService.LoadEntities(a=>a.Title!.Contains(keyword));
int totalCount = 0;

当然,这里我们也完全可以定义一个BaseParams.cs封装公共的分页属性,然后让ArticelParams类继承BaseParams

怎样返回分页的数据?

在前面我们只是将数据源返回到前端了,但是相关的分页信息我们也需要返回,因为前端在进行分页的时候,还需要使用相关的信息,例如总的记录,或者是总的页数等。

这里怎样将这些信息返回到前端呢?

这里可以将分页的信息放到响应头中返回,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public  IActionResult GetArticel([FromQuery] ArticelParams articelParams)
{
/* articelParams.PageIndex = 1;
articelParams.PageSize = 10;*/
// var articels = _articelService.LoadEntities(a=>a.Title!.Contains(keyword));
int totalCount = 0;
ArticelSearch articelSearch = new ArticelSearch()
{
title = articelParams.title,
FormDatepicker = articelParams.FormDatepicker,
ToDatepicker = articelParams.ToDatepicker,
PageSize = articelParams.PageSize,
PageIndex = articelParams.PageIndex,
Order = articelParams.Order,
TotalCount = totalCount
};

var articels = _articelService.LoadSearchEntities(articelSearch,Convert.ToBoolean(DelFlagEnum.Normal));
if (articelSearch.TotalCount <= 0)
{
return NotFound("没有找到文章");
}
var articelList = mapper.Map<List<ArticelDto>>(articels);
// 这里添加了分页的信息,如下
var paginationInfo = new {
totalCount = articelSearch.TotalCount,
pageSize = articelSearch.PageSize,
pageIndex = articelSearch.PageIndex,
totalPages =(int)Math.Ceiling(articelSearch.TotalCount / (double)articelSearch.PageSize)
};
// 将分页的信息序列化json字符串后,写到`x-pagination`这个响应头中,然后返回到前端
HttpContext.Response.Headers.Add("x-pagination", Newtonsoft.Json.JsonConvert.SerializeObject(paginationInfo));
return Ok(articelList);

}

21、数据排序

关于数据的排序,我们前面也实现了,但是我们当时将排序的字段固定死了,就是根据ID排序,但是很多的时候,我们需要指定不同的字段来进行排序。

这里我们会使用到一个包

1
System.Linq.Dynamic.Core

这个包的作用:可以生成Linq查询表达式。

可以将传递过来的字符串,通过这个包转换成Linq查询表达式。

在访问排序的接口的时候,请求的地址形式如下所示:

1
/api/articels?orderBy=id,createTime desc

为了对排序进行处理,这里我们为IQueryable接口进行了扩展,也就是为其定义了一个扩展的方法。

Cms.Repository这个数据仓储项目中创建一个文件夹Extensions,在该文件夹下面创建一个RepositoryExtensions.cs类,该类中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
using System.Linq.Dynamic.Core; // ---------------导入了对应的包
namespace Cms.Repository.Extensions
{
public static class RepositoryExtensions
{
public static IQueryable<T> OrderByQuery<T>(this IQueryable<T> queryable, string queryString)
{
if (string.IsNullOrEmpty(queryString))
{
return queryable;
}
// id,crateTime desc
var orderParams = queryString.Trim().Split(',');
// 利用反射技术,获取要查询的模型中(实体类)的所有属性。

var propertyInfos = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);
var orderQueryBuilder = new StringBuilder();
foreach (var param in orderParams)
{
// 有可能传递过来的排序参数是: id,,crateTime desc 这种形式,这时候,通过分号分隔完,然后进行遍历,param就会出现空字符串的情况
if (string.IsNullOrEmpty(param))
{
continue;
}

// 这里会按照空格继续分隔,因为按照前面的处理,会得到:crateTime desc.
// 而排序的字段与排序方式之间是通过空格进行分割的。
// 这时候propertyFromQueryName变量中存储的是crateTime,也就是排序的字段。
var propertyFromQueryName = param.Split(" ")[0];
// 前面通过反射获取到了实体类中所有的属性,这里又通过分割处理,获取到了排序的字段,看一下排序字段是否与实体类中的属性一致。如果不一致,则表示传递过来的搜索字段是错误的。
var objectProperty = propertyInfos.FirstOrDefault(p => p.Name.Equals(propertyFromQueryName, StringComparison.InvariantCultureIgnoreCase));
if (objectProperty == null)
{

continue;
}
var sortingOrder = param.EndsWith(" desc") ? "descending" : "ascending";
// 这里还需要注意,两个{}括号之间是有空格的。
orderQueryBuilder.Append($"{objectProperty.Name} {sortingOrder},"); // 注意:这里进行了逗号分割

} // 循环结束

var orderQuery = orderQueryBuilder.ToString().TrimEnd(',', ' ');// 去掉最后的逗号
// 调用 System.Linq.Dynamic.Core 包中的OrderBy方法,该方法需要的参数是字符串
return queryable.OrderBy(orderQuery); // createTime descending
}
}
}

上面为IQueryable泛型接口定义了扩展方法OrderByQuery.下面看一下怎样调用该扩展方法。

我们可以在ArticelService.cs这个业务类中使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
 /// <summary>
/// 多条件搜索
/// </summary>
/// <param name="articelSearch"></param>
/// <param name="flag"></param>
/// <returns></returns>
public IQueryable<Articel> LoadSearchEntities(ArticelSearch articelSearch, bool flag)
{
var temp = articelRepository.LoadEntities(a => a.DelFlag == flag);
if (!string.IsNullOrEmpty(articelSearch.title))
{
temp = temp.Where<Articel>(a => a.Title!.Contains(articelSearch.title));
// var d = temp.ToList();
}
if (!string.IsNullOrEmpty(articelSearch.FormDatepicker))
{
DateTime startDate = Convert.ToDateTime(articelSearch.FormDatepicker);
temp = temp.Where<Articel>(a => a.CreateTime >= startDate);
}
if (!string.IsNullOrEmpty(articelSearch.ToDatepicker))
{
DateTime endDate = Convert.ToDateTime((articelSearch.ToDatepicker));
temp = temp.Where<Articel>(a => a.CreateTime <= endDate);
}
articelSearch.TotalCount = temp.Count(); // select count(*) from T_Articels where
//-----------------------------这里调用了扩展方法OrderByQuery
temp = temp.OrderByQuery<Articel>(articelSearch.OrderBy!).Skip<Articel>((articelSearch.PageIndex - 1) * articelSearch.PageSize).Take<Articel>(articelSearch.PageSize);
/* if (articelSearch.Order)
{
temp = temp.OrderBy<Articel, long>(a => a.Id).Skip<Articel>((articelSearch.PageIndex - 1) * articelSearch.PageSize).Take<Articel>(articelSearch.PageSize);

}
else
{
temp = temp.OrderByDescending<Articel, long>(a => a.Id).Skip<Articel>((articelSearch.PageIndex - 1) * articelSearch.PageSize).Take<Articel>(articelSearch.PageSize);
// var data = temp.Count();

}*/
return temp;
}

在对应的分页方法中调用了排序的扩展方法。

在调用扩展方法OrderByQuery的时候,需要传递参数,这个参数就是排序的字段与排序方式,类型是字符串。

当然,需要ArticelSearch.cs类中定义OrderBy属性。当然定义到了其父类BaseSearch.cs中。

1
2
3
4
5
6
7
8
9
public class BaseSearch
{
public int PageIndex { get; set; }
public int PageSize { get; set; }
public int TotalCount { get; set; }
public bool Order { get; set; }

public string? OrderBy { get; set; }
}

下面修改ArticelsController.cs控制器中的GetArticels方法中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public IActionResult GetArticels([FromQuery] ArticelParams articelParams)
{
int pageIndex = articelParams.PageIndex == 0 ? 1 : articelParams.PageIndex;
int pageSize = articelParams.PageSize == 0 ? 2 : articelParams.PageSize;
int totalCount = 0;
ArticelSearch articelSearch = new ArticelSearch()
{
FormDatepicker = articelParams.FormDatepicker,
Order = articelParams.Order,
PageIndex = pageIndex,
PageSize = pageSize,
title = articelParams.title,
ToDatepicker = articelParams.ToDatepicker,
TotalCount = totalCount,
OrderBy = articelParams.OrderBy, //--------- 将ArticelParams对象中的OrderBy属性值赋值给了ArticelSearch对象中的OrderByS属性。

};

下面,修改ArticelParams类。

1
2
3
4
5
6
7
8
9
10
11
12
public class ArticelParams
{
public string? title { get; set; }
public string? FormDatepicker { get; set; }
public string? ToDatepicker { get; set; }

public int PageIndex { get; set; }
public int PageSize { get; set; }
public bool Order { get; set; }
public string? Fields { get; set; }
public string? OrderBy { get; set; }//--------------添加OrderBy属性。
}

返回到浏览器中进行测试。

22、数据塑形

22.1 什么是数据塑形?

所谓的数据塑性:就是客户端可以定制化选择后端输出数据的技术。

例如:针对用户信息,只希望返回,用户名和邮箱,这时候的api地址就可以采用如下的形式:

1
/api/users?fields =username,useremail

数据塑性可以极大的提高数据处理的能力。例如:用户有20个字段,每次展示50条数据,这样就是1000条数据,

如果只传递以上两个字段,只需要传输的数据就是100条

22.2 处理动态类型对象

下面我们来看一下, ArticelsController.cs控制器中的GetArticel方法,该方法返回的List<ArticelDto>.

但是经过数据的塑性以后,我们不再返回ArticelDto,而是返回一个仅仅包含所需要的字段的对象来代替。

我们会在程序运行的时候,根据ArticelDto来动态创建这个对象。

在这一小节中,我们会处理动态类型对象dynamic object

ExpandoObject 是专为动态类型封装的类型,是对动态类型对象的处理。可以在程序运行的时候,动态的添加或者是删除一个类的成员变量。也就是可以动态的处理对象的字段。而动态处理对象字段,就是数据塑性需要具有的能力。

Cms.WebApi项目中创建Helper目录,在该目录下面创建IEnumerableExtensions.cs,该类是对IEnumerable类型的扩展,从而实现数据的塑性。

该类中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
using System.Dynamic;
using System.Reflection;

namespace Cms.WebApi.Helper
{
/// <summary>
/// 这里是对IEnumerable的拓展,同时定义的类必须是static
/// </summary>
public static class IEnumerableExtensions
{
// 创建拓展的函数,必须是静态的
// 拓展的是IEnumerable,列表返回的类型是ExpandoObject
// 第一个参数是数据源,需要添加this
// 第二个参数需要塑性后输出的字段的名称
public static IEnumerable<ExpandoObject>ShapeDatad<TSource>(this IEnumerable<TSource> source,string fields)
{
if(source == null)
{
throw new ArgumentNullException("source");
}
// 创建一个集合用来存储动态类型
var expandoObjectList = new List<ExpandoObject>();
// 为了获取对应的字段,需要使用反射机制。
// 这里会创建一个属性的信息列表
// PropertyInfo:会包含对象中的所有属性的信息
var propertyInfoList = new List<PropertyInfo>();

if (string.IsNullOrWhiteSpace(fields))
{
// 如果这里没有指定塑性后输出的字段的名称,这里我们就返回动态类型对象ExpandoObject的所有属性。
// 首选获取数据源类型TSource的类型,然后通过PropertyInfo获取数据源中所有属性的信息
// 获取属性的信息的时候,忽略大小写,获取的是公共的,并且是非静态类,
var propertyInfos = typeof(TSource).GetProperties(BindingFlags.IgnoreCase|BindingFlags.Public|BindingFlags.Instance);

propertyInfoList.AddRange(propertyInfos);
}
else
{
// 使用逗号分割字符串

var fieldsAfterSplit = fields.Split(',');
foreach ( var field in fieldsAfterSplit)
{
// 去掉首位多余的空格,获取的属性名称
var propertyName = field.Trim();
// 获取到属性名以后,就可以获取该属性对应的信息了

var propertyInfo = typeof(TSource).GetProperty(propertyName, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance);
if (propertyInfo == null)
{
throw new Exception($"属性{propertyName}找不到");
}
propertyInfoList.Add(propertyInfo);
}

}

// 下面遍历数据源
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;

}
}
}

下面修改ResourceParams/ArticelParams.cs中的代码

1
2
3
4
5
public bool Order { get; set; }

// 该属性接收的是要展示的字段
public string? Fields { get; set; }
}

这里添加了Fields属性,通过该属性接收前端传递过来的要展示的字段的名称。

下面修改ArticelsController.cs控制器中的代码

1
2
HttpContext.Response.Headers.Add("x-pagination", Newtonsoft.Json.JsonConvert.SerializeObject(paginationInfo));
return Ok(articelList.ShapeDatad(articelParams.Fields!));//这里接收到前端传递过来的要展示的字段,然后传递到ShapeDatad方法中进行数据的塑性,这里是`IEnumerable`的扩展方法,所以可以直接调用。

22.3 单一资源的塑形

在上一小节中,我们扩展了IEnumerable,实现了对集合的塑性。

在这一小节中,我们实现对单一资源的塑性。

同样在Cms.WebApi项目的Helper文件夹中创建ObjectExtensions.cs,该类中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
public static class ObjectExtensions
{
public static ExpandoObject ShapeData<TSource>(this TSource source,string fields)
{
if(source == null)
{
throw new ArgumentNullException("source");
}
var dataShapedObject = new ExpandoObject();
if (string.IsNullOrWhiteSpace(fields))
{
var propertyInfos = typeof(TSource).GetProperties(BindingFlags.IgnoreCase|BindingFlags.Public|BindingFlags.Instance);
foreach( var propertyInfo in propertyInfos)
{
var propertyValue = propertyInfo.GetValue(source);
((IDictionary<string,object>)dataShapedObject!).Add(propertyInfo.Name, propertyValue!);

}
return dataShapedObject;
}
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 dataShapedObject;
}
}

这里是对object对象的扩展。(因为这里是对单一的资源对象进行数据的塑性)

这里单独创建一个类来实现单一资源的数据塑性的原因是:职责明确,再有方便管理,性能高。

下面我们返回的到UsersController.cs控制器中进行测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public async Task<IActionResult> GetUser(int id,string fields) //------------------- 添加fields参数
{
// Console.WriteLine("AppDomain=="+ AppDomain.CurrentDomain);//Cms.WebApi
/* foreach(var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
Console.WriteLine(assembly.FullName); // 打印的是当前Cms.WebApi所引用的所有程序集
}*/
var user = await _userInfoService.LoadEntities(u=>u.Id==id).FirstOrDefaultAsync();
var userDTO = mapper.Map<UserInfoDto>(user);
if(user == null)
{
return NotFound($"编号为{id}的用户不存在!!");
}
return Ok(userDTO.ShapeData(fields)); // ----------------------调用`ShapeData`方法对数据进行塑性
}

GetUser方法,是根据用户传递过来的id,查询单一用户的方法,所以这里我们又给其添加了一个参数fields,接收前端传递过来的需要展示的字段。

启动项目进行测试。(多个字段名之间使用逗号进行分割)

22.4 数据塑形错误的处理

当前端给fields参数传递的是不存在的字段的时候,服务端的程序会出现500的错误。

但是,这种情况是前端输入错误,应该给出400的状态码,同时给出相应的错误提示。

具体的处理方式如下所示:

Helper目录下面创建PropertyCheck.cs类,该类中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class PropertyCheck
{
public static bool IsPropertiesExists<T>(string fields)
{
// 如果前端没有输入任何字段,表示的就是直接返回dto对象,所以这里就不需要向下处理了,直接返回true
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.Public|BindingFlags.IgnoreCase|BindingFlags.Instance);
// 如果泛型T所表示的对象中,没有找到对应的属性信息,直接返回false
if (propertyInfo == null)
{
return false;
}

}
// 如果循环执行完毕以后,所有的属性验证都没有问题,表示前端用户输入的字段信息是合法的。

return true;

}
}

返回到UsersController.cs控制器中的GetUser这个方法进行测试,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
[HttpGet("{id}",Name ="GetUser")]
public async Task<IActionResult> GetUser(int id,string fields)
{
// Console.WriteLine("AppDomain=="+ AppDomain.CurrentDomain);//Cms.WebApi
/* foreach(var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
Console.WriteLine(assembly.FullName); // 打印的是当前Cms.WebApi所引用的所有程序集
}*/
// ---------------------------这里添加了对前端传递过来的字段的校验,如果校验不通过,返回400的状态码------------------------------------------------------------
if (!PropertyCheck.IsPropertiesExists<UserInfoDto>(fields))
{
return BadRequest("传递的字段是错误的!");
}

var user = await _userInfoService.LoadEntities(u=>u.Id==id).FirstOrDefaultAsync();
var userDTO = mapper.Map<UserInfoDto>(user);
if(user == null)
{
return NotFound($"编号为{id}的用户不存在!!");
}
return Ok(userDTO.ShapeData(fields));
}

启动项目进行测试。

23、HATEOAS应用

23.1 HATEOAS介绍

hateoasrest架构风格中最复杂的约束,也是构建成熟rest服务的核心,它的重要性在于打破了客户端和服务器之间严格的契约,使得客户端可以更加智能和自适应,而Rest服务本身的演化和更新也变得更加容易。

hateoas:Hypermedia as the engine of application state(超媒体即应用状态引擎)

hateoas核心思想:在响应中包含其他资源的链接,客户端可以使用这些链接与服务器进行交互。

下面通过一个例子来体会一下heateoas

在返回的资源中添加了Links属性,表示可以对该资源还可以进行哪些操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
{
"id":1,
"userName":"zhangsan",
"userEmail":"zhangsan@126.com",
"Links":[
{
"href":"https://localhost:5050/api/users/1",
"rel":"self",
"method":"Get"
},
{
"href":"https://localhost:5050/api/users/1",
"rel":"更新",
"method":"Put"
},
{
"href":"https://localhost:5050/api/users/1",
"rel":"局部更新",
"method":"PATCH"
},
{
"href":"https://localhost:5050/api/users/1",
"rel":"删除",
"method":"Delete"
}
]
}

Links中的这些链接返回到前端以后,前端可以直接使用。

这样做的好处,就是服务端可以根据实际的情况,返回Links,例如:后端可以根据用户具有的不同的权限,在Links中返回不同的链接,这样前端可以根据Links中的链接向服务端发送请求,前端不需要参考任何的接口文档。

LinkHATEOAS的核心

Link中的三个成员的含义:

href:表示的是资源的地址。

rel: 用来描述资源和url的关系。例如:self:表示了url自我描述的关系。

method:表示的是访问资源对应的方法。

当然Links中的内容是根据业务来定义的。

23.2 处理单一资源

Dtos目录中创建LinkDto.cs类,在该类中定义Link中的核心成员,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
namespace Cms.WebApi.Dtos
{
public class LinkDto
{
public string? Href { get; set; }
public string? Rel { get; set; }
public string? Method { get; set; }
// 通过构造方法,为定义的属性赋值
public LinkDto(string href,string rel,string method)
{
Href = href;
Rel = rel;
Method = method;
}
}
}

下面修改UsersController.cs中的代码,如下所示:

在控制器的GetUser方法的下面定义CreateLinkForUsers方法,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 创建hatoeas的Links数组

private IEnumerable<LinkDto> CreateLinkForUsers(int id,string fields)
{
var links = new List<LinkDto>();
links.Add(new LinkDto(Url.Link("GetUser",new {id,fields })!,"self","GET")); // self:表示自身
int userId = id; // 这里一定要将id参数赋值给userId变量,原因是下面创建href的时候,所指定的方法,例如EditUser,UpdateUser,DeleteUser等,所需要的参数是userId.如果这里不赋值给userId,无法创建herf属性所需要的地址。
// 更新
links.Add(new LinkDto(Url.Link("EditUser", new { userId })!, "update", "PUT"));
// 局部更新
links.Add(new LinkDto(Url.Link("UpdateUser", new { userId })!, "update_patch", "PATCH"));
// 删除
links.Add(new LinkDto(Url.Link("DeleteUser", new { userId })!,"delete","Delete"));
// 获取用户发布的文章
links.Add(new LinkDto(Url.Link("GetArticelForUser", new { userId })!, "get_articels", "GET"));
return links;
}

CreateLinkForUsers方法中,创建一个一个的LinkDto对象,然后添加到links这个List集合中。

在创建LinkDto这个对象的时候,在确定Href这个属性的值的时候,是通过.net core中内置的Url.Link方法来创建的,该方法的第一个参数是方法对应的路由的名字。

所以说,在GetUser方法对应的HttpGet中指定了Name属性,取值就是GetUser.

1
2
3
[HttpGet("{id}",Name ="GetUser")]
public async Task<IActionResult> GetUser(int id,string fields)
{

下面对应的给其他的方法添加相应的name属性值

1
2
3
4
5
6
7
8
/// <summary>
/// 获取某个用户发布的文章
/// </summary>
/// <param name="userId"></param>
/// <returns></returns>
[HttpGet("{userId}/articels",Name = "GetArticelForUser")]
public async Task<IActionResult> GetArticelForUser(int userId)
{
1
2
3
4
5
6
7
8
9
10
/// <summary>
/// 更新用户信息
/// </summary>
/// <param name="userId"></param>
/// <param name="userInfoForUpdateDto"></param>
/// <returns></returns>
[HttpPut("{userId}",Name ="EditUser")]
[UnitOfWork(new Type[] { typeof(MyDbContext) })]
public async Task<IActionResult> EditUser([FromRoute]int userId, [FromBody]UserInfoForUpdateDto userInfoForUpdateDto)
{
1
2
3
4
5
6
7
8
9
10
/// <summary>
/// 部分更新用户信息
/// </summary>
/// <param name="userId"></param>
/// <param name="patchDocument"></param>
/// <returns></returns>
[HttpPatch("{userId}",Name = "UpdateUser")]
[UnitOfWork(new Type[] { typeof(MyDbContext) })]
public async Task<IActionResult> UpdateUser([FromRoute] int userId, [FromBody] JsonPatchDocument<UserInfoForUpdateDto> patchDocument)
{
1
2
3
4
5
6
7
8
9
/// <summary>
/// 删除用户
/// </summary>
/// <param name="userId">要删除的用户编号</param>
/// <returns></returns>
[HttpDelete("{userId}",Name ="DeleteUser")]
[UnitOfWork(new Type[] { typeof(MyDbContext) })]
public async Task<IActionResult> DeleteUser([FromRoute]int userId)
{

问题:在什么位置调用CreateLinkForUsers这个方法呢?

这一次是对单一资源的处理,所以这里是在GetUser这个方法中进行调用的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
[HttpGet("{id}",Name ="GetUser")]
public async Task<IActionResult> GetUser(int id,string fields)
{
// Console.WriteLine("AppDomain=="+ AppDomain.CurrentDomain);//Cms.WebApi
/* foreach(var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
Console.WriteLine(assembly.FullName); // 打印的是当前Cms.WebApi所引用的所有程序集
}*/
if (!PropertyCheck.IsPropertiesExists<UserInfoDto>(fields))
{
return BadRequest("传递的字段是错误的!");
}

var user = await _userInfoService.LoadEntities(u=>u.Id==id).FirstOrDefaultAsync();
var userDTO = mapper.Map<UserInfoDto>(user);
if(user == null)
{
return NotFound($"编号为{id}的用户不存在!!");
}
/* return Ok(userDTO.ShapeData(fields));*/

//---------------------调用CreateLinkForUsers这个方法-----------------
var linkDtos = CreateLinkForUsers(id,fields);
// 对数据进行塑形,并且转成字典集合
var result= userDTO.ShapeData(fields)as IDictionary<string, object>;
// 把links添加到字典集合中
result.Add("links", linkDtos);
return Ok(result);
}

启动项目进行测试。在返回的响应报文中包含了links

23.2 post请求的处理

我们知道,当创建一个用户的时候,会执行如下的CreatUser方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/// <summary>
/// 创建用户: http://localhost:5154/api/Users
/// </summary>
/// <returns></returns>
[HttpPost(Name ="CreateUser")]
[Authorize]
[UnitOfWork(new Type[] { typeof(MyDbContext) })]
public async Task<IActionResult> CreatUser([FromBody]UserInfoCreateDto userInfoCreateDto)
{

var userInfoModel = mapper.Map<UserInfo>(userInfoCreateDto);
await _userInfoService.InsertEntityAsync(userInfoModel);
var user = mapper.Map<UserInfoDto>(userInfoModel);
// return CreatedAtRoute("GetUser",new {id =user.Id },user); // 返回
return CreatedAtAction("GetAllUsers", user);

}

我们这里可以看到,当创建完一个用户以后,最终返回的是一个所创建的用户的信息。

所以这里给post请求,添加links,也是很有必要的。

下面修改CreateUser方法,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/// <summary>
/// 创建用户: http://localhost:5154/api/Users
/// </summary>
/// <returns></returns>
[HttpPost(Name ="CreateUser")]
[Authorize]
[UnitOfWork(new Type[] { typeof(MyDbContext) })]
public async Task<IActionResult> CreatUser([FromBody]UserInfoCreateDto userInfoCreateDto)
{

var userInfoModel = mapper.Map<UserInfo>(userInfoCreateDto);
await _userInfoService.InsertEntityAsync(userInfoModel);
var user = mapper.Map<UserInfoDto>(userInfoModel);
// return CreatedAtRoute("GetUser",new {id =user.Id },user); // 返回
//--------------------------这里调用CreateLinkForUsers方法,获取links数组---------------------------
// 这里不需要进行数据的塑形,所以第二个参数传递的是null
var links = CreateLinkForUsers( Convert.ToInt32(userInfoModel.Id), null!);
//------------------------这里不需要进行数据的塑形,所以给ShapeData方法传递的参数是null,同时这里也是转换成字典的集合
var result = user.ShapeData(null!) as IDictionary<string, object>;
//------------------------------这里把`links`添加到字典的集合中
result.Add("links", links);
// return CreatedAtAction("GetAllUsers", user);
//-------------------------最后返回字典集合
return CreatedAtAction("GetAllUsers", result);

}

由于这里,添加了 [Authorize],所以只能有登录的用户才能创建用户,这里登录了以后,拷贝一下token信息,然后在apifox软件中进行测试

添加的完用户以后,返回的信息是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
{
"id": 0,
"createTime": "2023年4月17日",
"updateTime": "2023-04-17T14:58:13.4652567+08:00",
"userName": "maliu23",
"userEmail": "maliu2@126.com",
"userPhone": "1312222222",
"gender": 0,
"photoUrl": "/images/b.jpg",
"articels": [],
"links": [
{
"href": "http://localhost:5154/api/Users/0",
"rel": "self",
"method": "GET"
},
{
"href": "http://localhost:5154/api/Users/0",
"rel": "update",
"method": "PUT"
},
{
"href": "http://localhost:5154/api/Users/0",
"rel": "update_patch",
"method": "PATCH"
},
{
"href": "http://localhost:5154/api/Users/0",
"rel": "delete",
"method": "Delete"
},
{
"href": "http://localhost:5154/api/Users/0/articels",
"rel": "get_articels",
"method": "GET"
}
]
}

这里添加完用户以后,也将添加成功的用户的信息返回了,同时包含了links数组。

问题:这里的用户编号为什么是0呢?

原因是与savechanges方法的执行时机有关。解决的方案:我们可以在CreateUser方法中,获取原有的用户数据源中最大的用户编号,然后这里进行加1的操作。

23.3 列表资源的处理

这里我们以ArticelsController控制器中的GetArticel方法为例来说明一下。

ArticelsController控制器中增加如下两个私有的方法。

当调用CreateLinksForArticelList这个私有的方法的时候,根据传递过来的相关参数,构建Links数组。这里只是增加了自身,其他的内容与前面讲解的是一样的。同时在该方法中还调用了私有方法createArticeParams方法,该方法的作用就是对搜索参数的处理。

createArticeParams方法的内部创建了一个匿名对象,来对搜索的参数进行处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private IEnumerable<LinkDto> CreateLinksForArticelList(ArticelParams articelParams)
{
var links = new List<LinkDto>();
links.Add(new LinkDto(Url.Link("GetArticel", createArticeParams(articelParams))!, "self", "GET"));
return links;
}
private object createArticeParams(ArticelParams articelParams)
{
return new
{
title = articelParams.title,
PageIndex = articelParams.PageIndex,
PageSize = articelParams.PageSize,
FormDatepicker = articelParams.FormDatepicker,
ToDatepicker = articelParams.ToDatepicker,
Order = articelParams.Order,
Fields = articelParams.Fields,

};
}

下面看一下GetArticel方法的修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
     HttpContext.Response.Headers.Add("x-pagination", Newtonsoft.Json.JsonConvert.SerializeObject(paginationInfo));
//--------------------------------------------------------------
// 这里获取到了塑形后的数据
var shapedDtoList = articelList.ShapeDatad(articelParams.Fields!);
// 然后调用CreateLinksForArticelList这个私有方法,获取到了对应的Links数组
var linkDto = CreateLinksForArticelList(articelParams);


// 构建匿名对象,具体的数据赋值给了value属性,链接内容赋值给了links属性
var result = new
{
value = shapedDtoList,
links = linkDto
};

return Ok(result);// 返回到前端。
// return Ok(articelList.ShapeDatad(articelParams.Fields!));

启动项目进行测试。

23.4 媒体类型

目前面临的问题:links成为了响应数据的一部分,但是它们并不属于资源。当响应数据中包含这里links这些链接的时候,已经违反restful的定义了。

解决的办法就是通过内容协商来解决,

内容协商指的是:在请求中包含不同的媒体类型,服务端的api返回不同的响应数据。

例如:假设请求中包含的媒体类型是:

1
"Accept":"application/json"

这表示请求的是一个资源类型。

什么是媒体类型呢?

Multipurpose Interrnet Mail Extensions(MIME) 是一种标准,用来表示文档,文件或字节流的性质和格式。

它的结构:

1
type/subtype    // 中间不能出现空格

type:表示的是独立类别

subtype:表示细分后的子类型

媒体类型对大小写不敏感,但是传统写法都是小写。

常见的媒体类型结构

以上表格就是常见的媒体类型的分类。

自定义媒体类型

当然,我们开发人员也可以自定义媒体类型

格式:

1
application/vnd.mycompany.hateoas+json

vnd:全称”vendor”的缩写,表示供应商,也就是说这个媒体类型是特定的供应商所使用的

“mycompany”:是自定义的标识,可以是公司名

“hateoas”:表示返回的响应中要包含超媒体链接

”json“:表示输出的数据结构

23.5 媒体类型应用

这里我们修改ArticelsController控制器中的GetArticel方法。

我们知道当请求该方法的时候,会将具体的数据资源以及hateoas对应的links链接返回,这样会导致数据传输的性能降低。

我们希望的是,当请求的Acceptapplication/json的时候,只返回具体的资源数据,也是文章信息,不在返回hateoas

对应的links数据。

要实现这种需求,就需要使用到自定义的媒体类型。

格式:

1
application/vnd.mycompany.hateoas+json

当然,如果请求的是以上媒体类型,才会将具体的资源数据以及hateoas中的linksjson格式的形式返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
        // api/articels?keyword=传入的参数
[HttpGet(Name ="GetArticel")]
// ------------------这里添加了FromHeader接收Accept请求的媒体类型
public IActionResult GetArticel([FromQuery] ArticelParams articelParams, [FromHeader(Name ="Accept")]string mediaType)
{
//-------------------------注意:这里导入的是 using Microsoft.Net.Http.Headers;命名空间
// ---------------------对媒体类型进行解析,解析后的内容,都存储到MediaTypeHeaderValue类型的parsedMediatype变量中
if (!MediaTypeHeaderValue.TryParse(mediaType,out MediaTypeHeaderValue? parsedMediatype))
{
// --------------------------对媒体类型解析失败以后,直接返回400的错误
return BadRequest("错误的媒体类型");
}

/* articelParams.PageIndex = 1;
articelParams.PageSize = 10;*/
// var articels = _articelService.LoadEntities(a=>a.Title!.Contains(keyword));
int totalCount = 0;
ArticelSearch articelSearch = new ArticelSearch()
{

对媒体类型解析完成以后,下面就需要进行判断了,继续修改CreateArticel这个方法中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
 HttpContext.Response.Headers.Add("x-pagination", Newtonsoft.Json.JsonConvert.SerializeObject(paginationInfo));
//--------------------------------------------------------------
var shapedDtoList = articelList.ShapeDatad(articelParams.Fields!);
//---------------------------------判断接收到的媒体类型是否是application/vnd.xxx.hateoas+json,如果是则返回links
if (parsedMediatype.MediaType == "application/vnd.xxx.hateoas+json")
{
var linkDto = CreateLinksForArticelList(articelParams);

var result = new
{
value = shapedDtoList,
links = linkDto
};

return Ok(result);
}
//------------------------这里只返回具体的数据资源。
return Ok(shapedDtoList);

下面进行测试

当请求的媒体类型是application/json的时候,可以返回具体的文章数据。

这里我们测试的时候,我们需要使用Apifox工具进行测试

这里我们首先,指定了Params项,传递的参数是Fields,参数值是id,title,表示的是只展示这两个字段的数据,完成了数据的塑形操作。

这里指定的是Header中的Accept的值是application/vnd.xxx.hateoas+json会出错,如果指定的是applicaiton/json则会返回正确的数据,这里指定application/vnd.xxx.hateoas+json出错的原因是:

对于自定义的媒体类型,需要再系统中进行注册。否则无法处理。

修改Program.cs文件中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 完成AutoMapper的注册
builder.Services.AddAutoMapper(AppDomain.CurrentDomain.GetAssemblies());
//---------------------------------完成自定义媒体类型的处理
builder.Services.Configure<MvcOptions>(config =>
{
//OutputFormatters是一个集合,该集合中个存储了所有的媒体类型处理器
// 获取媒体类型处理器
var outputFormatter = config.OutputFormatters.OfType<NewtonsoftJsonOutputFormatter>().FirstOrDefault();
if (outputFormatter != null)
{
// 将自定义媒体类型添加到媒体类型处理器中。
outputFormatter.SupportedMediaTypes.Add("application/vnd.xxx.hateoas+json");
}
});

重新启动项目进行测试

当然,我们在请求头中如果不指定Accept,默认是application/json

以上就是媒体类型的处理。