最近在写论文,想使用ASP.NET Core Web API技术,但对它还不是很熟,鉴权组件也没用过,于是在网上查找资料,发现了杨中科老师写的这本书(微信读书上可以免费看),说起来我最初自学C#时看过他的盗版视频。
这本书使用的是.NET 6,但我用.NET Core 3.1也能实现书中的绝大部分功能,前几章主要讲理论,框架是简单的控制台,后面会使用到 ASP.NET Core Web API和鉴权组件。
但后来我发现要使用Identity鉴权组件需要使用EF Core的Code First模式,这种模式不符合我的习惯,我打算去掉这部分的功能,只实现到用JWT获取到Token为止,只判断能否登陆,不判断角色。
1、.NET Core支持独立部署,也就是说,可以把.NET Core运行时环境和开发的程序打包到一起部署。这样就不需要在服务器上安装.NET Core运行环境,只要把程序复制到服务器上,程序就能运行,这对容器化、无服务器(Serverless)等非常友好。
2、ASP.NET Core程序内置了简单且高效的Web服务器—Kestrel。Kestrel被嵌入ASP.NET Core程序中运行,因此整个ASP.NET Core程序其实就是一个控制台程序。Kestrel可被配置上安全、HTTPS、限流、压缩、缓存等功能,从而成为直接面向终端用户的Web服务器,这样网站运行不依赖于IIS;也可以将其配置成轻量级的Web服务器,而安全、HTTPS、限流、压缩、缓存等功能则由部署在它前面的IIS、Nginx等反向代理服务器完成。
以上两个优点我觉得是很明显的,支持独立部署可以减少运维的工作量,不用再去安装运行环境,内置服务器是跨平台的基础。
我使用过的框架包括:Winform,WPF,ASP.NET WebForms,WCF
Windows特有的技术:绘图,Windows服务,Windows注册表,在.NET Core中,我们可以通过Microsoft.Windows.Compatibility Nuget包继续使用这些技术,但是使用这些技术开发的程序只能运行在Windows下。如需移植到其他平台,可通过重写这些代码或编写平台检测代码。
.NET Standard是一套.NET API规范,不是具体的实现,它是为了开发人员从.NET Framework过渡到.NET Core时迁移代码的工作尽可能得减少,提高了代码的复用性。在日常的开发过程中,在使用Nuget时我们会经常看到它,那么如何选择呢?下图是.NET Starnard与.NET Core,.NET Framework版本之间的关系,可以看到.NET Standard的版本越高,对应.NET Core和.NET Framework的版本也越高,兼容性虽然有所下降,但可实现的功能却提高了很多,例如.NET Standard 1.0 提供 37,118 个可用 API 中的 7,949 个,.NET Standard 2.0 提供 37,118 个可用 API 中的 32,638 个,到了.NET Standard 2.1 提供 37,118 个可用 API 中的 37,118 个。官方推荐是.NET Standard 2.0,因为它实现了最大公约数。
什么是异步?它和同步有什么区别?
作者举了一个点餐的例子,同步是你在点餐时服务员一直站在那里等你决定吃什么,选好后提交上去;异步是在点餐时给你一张菜单然后就去服务其他人,等你选好后让服务员过来将菜单递交上去,这个过程可能比前一种方法慢一些,但能够同时服务更多的人。
结论就是异步比同步的优势在于可以增加同时处理的请求数,但响应的时间不一定比它快。
C#中使用async声明一个异步方法,await调用一个异步方法,确认一个方法是不是异步方法可以参考返回值是否是Task或者Task,按照约定方法名使用Async结尾,微软引入它的目的是降低异步编程的难度,具体的例子:
private async void button2_Click(object sender, EventArgs e)
{
// 异步调用,调用了有async的异步方法,为了让它实现异步调用,需使用await关键字,并且方法自身
// 添加async关键字修饰
this.textBox1.Text = "查询开始\r\n" + this.textBox1.Text;
var result = await QueryAsync();
this.textBox1.Text = "查询结束\r\n" + this.textBox1.Text;
}
private async Task> QueryAsync()
{
// 异步调用,Task.Delay返回Task,那么就可以使用await关键字调用,这样就是调用异步方法了
// 调用异步方法的代价就是,需要将方法自身添加async关键字,并在方法的后缀添加Async标记
await Task.Delay(5000);
return null;
}
Delay方法的声明
public static Task Delay(int millisecondsDelay);
同步方法中使用异步方法
我们也可以使用同步的方式调用异步方法,只要不使用async和await关键字,用同步的方式包装含异步的方法时,需要返回空的Task时可使用Task.CompletedTask,返回自定义的Task时使用Task.FromResult(string msg)。
当不能使用async和await关键字时如何使用异步方法
调用返回值为Task类型时可以在异步方法后继续调用GetAwaiter().GetResult(),但这种方式不推荐。
异步休眠方法:Task.Delay(int millisecondsDelay),不要使用Thread.Sleep(int millisecondsDelay)会引起调用线程的阻塞。
同时执行多个异步方法
使用Task.WhenAll(),可以同时执行多个任务,并且等待所有任务执行完毕后再返回结果,适用于需要将一个任务拆分成多个子任务的场景,可以提高查询的速度。如果各个任务返回的Task值不一致,需要用到WhenAll(IEnumerable tasks),传递一个Task的列表进去。
使用Task.WhenAny(),也可以同时执行多个任务,但它是只要有一个任务执行完毕就会返回结果,获取任务的结果前需要对Status属性判断是否为TaskStatus.RanToCompletion,再去取结果,否则会引起线程阻塞。
扩展方法
判断一个集合是否有一条数据时,不仅可以使用Count方法,也可以使用Any方法,而且这种方法的效率比前者更高。
获取一条数据时,我常常会使用FirstOrDefault方法,但这个方法有局限性,如果返回的结果应该只有一条,但实际返回了多条,该方法也会正常返回,隐藏异常信息。使用SingleOrDefault方法可以在返回多条时会抛出异常,开发人员也能及时的发现问题,提高程序的正确性。
负责提供对象的注册和获取功能的框架叫作“容器”,注册到容器中的对象叫作“服务”(service)。
使用依赖注入时需要引用Microsoft.Extensions.DependencyInjection
依赖注入的三个生命周期:
(1)瞬态(transient):每次被请求的时候都会创建一个新对象。这种生命周期适合有状态的对象,可以避免多段代码用于同一个对象而造成状态混乱,其缺点是生成的对象比较多,会浪费内存。
(2)范围(scoped):在给定的范围内,多次请求共享同一个服务对象,服务每次被请求的时候都会返回同一个对象;在不同的范围内,服务每次被请求的时候会返回不同的对象。这个范围可以由框架定义,也可以由开发人员自定义。在ASP.NET Core中,服务默认的范围是一次HTTP请求,也就是在同一次HTTP请求中,不同的注入会获得同一个对象;在不同的HTTP请求中,不同的注入会获得不同的对象。这种方式适用于在同一个范围内共享同一个对象的情况。
(3)单例(singleton):全局共享同一个服务对象。这种生命周期可以节省创建新对象的资源。为了避免并发修改等问题,单例的服务对象最好是无状态对象。
注意:不要在长生命周期的对象中引用比它短的生命周期的对象。比如不能在单例服务中引用范围服务,否则可能会导致被引用的对象已经释放或者导致内存泄漏。
获取服务(对象)的方式
调用IServiceCollection的BuildServiceProvider方法创建一个ServiceProvider对象,这个ServiceProvider对象就是一个服务定位器。由于ServiceProvider对象实现了IDisposable接口,因此需要使用using对其进行资源的释放。在我们需要获取服务的时候,可以调用ServiceProvider类的GetRequiredService方法。
using (ServiceProvider provider = services.BuildServiceProvider())
{
var testService = provider.GetRequiredService();
testService.Name = "Tom";
testService.SayHi();
}
从配置文件中读取出字符串
使用了配置系统需要用到Microsoft.Extensions.Configuration和Microsoft.Extensions.Configuration.Json
ConfigurationBuilder builder = new ConfigurationBuilder();
// AddJsonFile的第二个参数为true时,如果配置文件不存在程序会报错,为false时会报错
// 第三个参数表示配置文件被修改后是否会重新加载配置
builder.AddJsonFile("YZK\\配置系统\\config.json", false, false);
IConfigurationRoot root = builder.Build(); // IConfigurationRoot用来读取配置项
string name = root["name"];
// 访问更深层次的节点
string address = root.GetSection("proxy:address").Value;
这种方式无法实现自动刷新。
从选项中读取出对象
使用选项方式读取配置是.NET Core中推荐的方式,因为它不仅和依赖注入机制结合得更好,而且它可以实现配置修改后自动刷新,所以使用起来更方便。
使用选项的方式读取配置需要用到Microsoft.Extensions.Options和Microsoft.Extensions.Configuration.Binder
读取配置时我们需要创建一个类用于获取注入的选项值。声明接收选项注入的对象的类型不能直接使用DbSettings、SmtpSettings,而要使用IOptions、IOptionsMonitor、IOptionsSnapshot等泛型接口类型,因为它们可以帮我们处理容器生命周期、配置刷新等。它们的区别在于,IOptions在配置改变后,我们不能读到新的值,必须重启程序才可以读到新的值;IOptionsMonitor在配置改变后,我们能读到新的值;IOptionsSnapshot也是在配置改变后,我们能读到新的值,和IOptionsMonitor不同的是,在同一个范围内IOptionsMonitor会保持一致性。IOptionsSnapshot更符合大部分场景的需求。
从命令行中读取字符串
需要安装Microsoft.Extensions.Configuration.CommandLine
实现:调用ConfigurationBuilder的AddCommandLine(args)方法
从环境变量中读取字符串
需要安装Microsoft.Extensions.Configuration.EnvironmentVariables
实现:调用ConfigurationBuilder的AddEnvironmentVariables方法或者Environment.GetEnvironmentVariable方法
输出控制台
需要安装Microsoft.Extensions.Logging和Microsoft.Extensions.Logging.Console
ServiceCollection services = new ServiceCollection();
services.AddLogging(log => { log.AddConsole(); }); // 将日志服务注册到容器中
using (var sp = services.BuildServiceProvider())
{
var logger = sp.GetRequiredService>(); // 获得服务
logger.LogInformation("普通信息"); // 输入日志
}
输出日志文件
需要安装log4net
ILoggerRepository repository = LogManager.CreateRepository("MyRepository"); // 创建一个日志仓库
XmlConfigurator.Configure(repository, new FileInfo("YZK/日志系统/config.xml")); // 注册,读取配置文件
ILog log = LogManager.GetLogger(repository.Name, "MyLog"); // 获得服务
log.Info("普通信息"); // 输出日志
它是一个ORM框架,用于提高开发效率,让开发人员减少对数据库的关注,即使不会写SQL也能实现数据的持久化。它的底层是ADO.NET,通过它访问数据库。
本书的作者是提倡使用Code First模式,而我不喜欢使用它,而是使用DataBase First模式。
环境:MySQL5.7,Navicat
1、首先是新建数据库,表结构
2、安装实现了指定数据库的EF Core包,对应MySQL的包我安装的是Pomelo.EntityFrameworkCore,据说Bug比较少。版本是3.1.32,因为它依赖.NETStandard 2.0。
继续安装Pomelo.EntityFrameworkCore.MySql,版本是3.2.7。
3、使用工具生成实体类
在VS 里找到视图 > 其他窗口 > 程序包控制管理台,输入:
Scaffold-DbContext -Force "server=127.0.0.1;Port=3306;database=db_name;uid=root;pwd=123456;" -Provider "Pomelo.EntityFrameworkCore.Mysql" -OutputDir Models
Scaffold-DbContext的作用是生成DbContext的代码,表中必须要有主键,对数据库做了任何修改操作后,使用它都能快速同步到项目中。
下面是微软文档中EF -> EF Core -> 命令行参考 -> 程序包管理控制台中,Scaffold-DbContext的参数和说明:
Scaffold-DbContext
为 DbContext 生成代码,并为数据库生成实体类型。 为了让 Scaffold-DbContext 生成实体类型,数据库表必须具有主键。
参数:
参数 |
说明 |
-Connection |
用于连接到数据库的连接字符串。 对于 ASP.NET Core 2.x 项目,值可以是连接字符串>的 name=。 在这种情况下,名称来自为项目设置的配置源。 这是一个位置参数,并且是必需的。 |
-Provider |
要使用的提供程序。 通常,这是 NuGet 包的名称,例如: Microsoft.EntityFrameworkCore.SqlServer。 这是一个位置参数,并且是必需的。 |
-OutputDir |
要在其中放置实体类文件的目录。 路径相对于项目目录。 |
-ContextDir |
要在其中放置 DbContext文件的目录。 路径相对于项目目录。 |
-Namespace |
要用于所有生成的类的命名空间。 默认设置为从根命名空间和输出目录生成。 已在 EF Core 5.0 中添加。 |
-ContextNamespace |
要用于生成的 DbContext类的命名空间。 注意:重写 -Namespace。 已在 EF Core 5.0 中添加。 |
-Context |
要生成的 DbContext类的名称。 |
-Schemas |
要为其生成实体类型的表的架构。 如果省略此参数,则包含所有架构。 |
-Tables |
要为其生成实体类型的表。 如果省略此参数,则包含所有表。 |
-DataAnnotations |
使用属性配置模型(如果可能)。 如果省略此参数,则仅使用 Fluent API。 |
-UseDatabaseNames |
使用与数据库中显示的名称完全相同的表和列名。 如果省略此参数,数据库名称将更改为更符合 C# 名称样式约定。 |
-Force |
覆盖现有文件。 |
-NoOnConfiguring |
不生成 DbContext.OnConfiguring。 已在 EF Core 5.0 中添加。 |
-NoPluralize |
请勿使用复数化程序。 已在 EF Core 5.0 中添加。 |
1、普通自增
自增类型的主键使用起来很简单,大部分主流数据库都支持这个功能,它有着占用磁盘空间小,可读性强,但它在数据库迁移和分布式系统(如分库分表,数据库集群)使用起来很麻烦。而且在高并发插入的时候性能比较差。
2、Guid算法
使用Guid作为主键时,虽然能保证唯一性,但会遇到性能问题,因为在使用Guid类型作为主键的时候,不能把主键设置为聚集索引。因为聚集索引是按照顺序保存主键的,在插入Guid类型主键的时候,它将会导致新插入的每条数据都要经历查找合适插入位置的过程,在数据量大的时候将会导致非常糟糕的数据插入性能。
在SQL Server中,可以设置主键为非聚集索引,但是在MySQL中,如果我们使用InnoDB引擎,那么主键是强制使用聚集索引的。
在SQL Server中,如果我们使用Guid类型(也就是uniqueidentifier类型)作为主键,一定不能把主键设置为聚集索引;在MySQL中,如果使用InnoDB引擎,并且数据插入频繁,那么一定不要用Guid类型作为主键,如果确实需要用Guid类型作为主键的话,我们只能把这个主键字段作为逻辑主键,而不是作为物理主键;
3、自增 + Guid算法
目前,还有一种主键使用策略是把自增主键和Guid结合起来使用,也就是表有两个主键(注意不是复合主键),用自增列作为物理主键,而用Guid列作为逻辑主键。物理主键是在进行表结构设计的时候把自增列设置为主键,而从表结构上我们是看不出来Guid列是主键的,但是在和其他表关联及和外部系统通信的时候(比如前端显示数据的标识的时候),我们都使用Guid列。这样不仅保证了性能,利用了Guid的优点,而且减少了主键自增导致主键值可被预测带来的安全性问题。
4、Hi/Lo算法
对于普通自增列来讲,每次获取新ID的时候都要锁定自增资源,因此在并发插入数据频繁的情况下,使用普通自增列的数据插入效率相对来讲比较低。EF Core支持使用Hi/Lo算法来优化自增列的性能。
Hi/Lo算法生成的主键值由两部分组成:高位(Hi)和低位(Lo)。高位由数据库生成,两个高位之间相隔若干个值;由程序在本地生成低位,低位的值在本地自增生成。
比如,数据库的两个高位之间相隔10,程序向数据库请求获得一个高位值50。程序在本地获取主键的时候,会首先获得Hi=50,再加上本地的Lo=0,因此主键值为50;程序再获取主键的时候,会继续使用之前获得的Hi=50,再加上本地的低位自增,Lo=1,因此主键值为51,以此类推。当Lo=9之后,再获取主键值,程序发现Hi=50的低位值已经用完了,因此就再向数据库请求一个新的高位值,数据库也许再返回一个Hi=80(因为也许Hi=60和Hi=70已经被其他服务器获取了),然后加上本地的Lo=0,最终获取主键值80,以此类推。
Hi/Lo算法的高位由服务器生成,因此保证了不同进程或者集群中不同服务器获取的高位值不会重复,而本地进程计算的低位则可以保证在本地高效率地生成主键值。
打印SQL语句
在Context类的OnConfiguring方法中,添加optionsBuilder.LogTo(Console.WriteLine)后,每次执行操作都会将SQL语句打印到控制台。
在.NET Framework中,ASP.NET MVC是用来进行基于视图的MVC模式开发的框架,而ASP.NET Web API 2是用来进行Web API开发的框架,这是两个不同的框架。而在ASP.NET Core中,不再做这样的区分,严格来讲,只有ASP.NET Core MVC这一个框架,ASP.NET Core MVC既支持基于视图的MVC模式开发,也支持Web API开发和Razor Pages开发等。不过在Visual Studio中创建项目的时候,仍然存在“ASP.NET Core Web API”和“ASP.NET Core应用(模型-视图-控制器)”这两种向导,分别用来创建Web API项目和传统的基于视图的MVC项目。
ASP.NET Core MVC的优点与流程
在MVC模式中,视图和控制器不直接交互、不互相依赖,彼此之间通过模型进行数据传递。使用MVC模式的优点是视图和控制器降低了耦合,系统的结构更清晰。
浏览器端提交的请求会被封装到模型类的对象中并传递给控制器,控制器中对浏览器端的请求进行处理,然后将处理结果放到模型类的对象中传递给视图,而视图则解析模型对象,然后将其渲染成HTML内容输出给浏览器。
ASP.NET Core MVC的新工具:热重载
从.NET 6开始,.NET中增加了热重载(hot reload)功能,它允许我们在以调试方式运行程序的时候,也无须重启程序而让修改的代码生效。它的用法很简单,只要在修改完代码以后单击Visual Studio工具栏中的热重载图标,修改的代码就会立即生效。
建议的开发模式
在开发的时候,作者建议平时使用【启动(不调试)】的方式运行程序,这样在修改完代码后重新生成程序就能让修改的代码生效。在需要调试程序的时候,再以调试的方式运行程序,并且使用热重载功能来应用修改后的代码。
是什么?
连续做相同的操作,返回的结果是相同的,例如连续两次插入相同的操作,数据库中只会插入一条数据。
如何实现?
每个请求带上唯一的标识,服务查询该标识是否存在,存在则创建一个对象,否则告知已存在。
但这种方法无法处理以下情况:
1、两次请求的频率很高,第一次请求还在判断,创建对象的过程中,第二次请求就已经来了,这时就会创建重复对象的情况。
2、分布式的环境中,两次请求可能在不同的服务器,这时对象锁,分布式事务就失效了。
解决方法是使用Redis,因为它现成、简单、易用。使用redis的incr方法可以帮我们解决这个重复创建问题。创建时,把唯一标识作为key并incr一下,并获取返回值,如果是1,那就说明没有创建过此对象,如果大于1,那就说明已经创建过了。同时key缓存时间保证对象保存到数据库即可。
200派:业务逻辑的错误,如创建用户失败时,服务器会返回200状态码
理由是:对于数据库连接失败,内存不足,请求格式错误等问题返回4XX和5XX是合理的,但对于用户已存在这种业务逻辑错误,返回这种错误码,会让服务器的错误信息被淹没掉。
而且业务逻辑的错误返回200,服务器的问题返回500,这样也便于区分,减少了工作量。
4XX派:业务逻辑的错误,如创建用户失败时,服务器会返回4XX状态码
理由是:由于网关等中间件可以监测HTTP状态码,对于频繁出现的4XX和5XX错误可以发出警告,帮助运维人员及时发现问题。
RPC风格
控制器上添加的[Route("[controller]")]改为[Route("[controller]/[action]")],这样[controller]就会匹配控制器的名字,而[action]就会匹配操作方法的名字
Restful风格
1、微软提供的WebAPI控制器默认就是Restful风格
2、Get操作可以通过缓存提高访问速度,添加对于冥等操作使用PUT请求。
3、参数统一化
对于保存,更新的操作使用Post,Put请求,参数全部放到请求体中,对于查询,删除的操作使用Get,Delete请求,参数全部放到QueryString中。
为了避免打开swagger时由于方法未被[HttpGet]和[HttpPost]等标记,而报错,所以需要添加[ApiExplorerSettings(IgnoreApi = true)]标记
在ASP.NET Core Web API中,我们应该使用ActionResult来作为操作方法的返回值;如果操作方法可以声明为异步方法,那么我们就用async Task>XXX()这样的声明方式。
1、高频注入(基于控制器类)
实现步骤:
1.1 Startup类的ConfigureServices方法注入服务
1.2 目标控制器类中添加一个类变量,类型是注入服务;添加一个构造函数,参数是注入服务,给那个类变量赋值。
2、低频注入(基于行为方法)
2.1 Startup类的ConfigureServices方法注入服务
2.2 目标行为方法中添加一个参数,类型是注入服务,参数添加一个[FromServices]标记。
public ActionResult Login([FromServices]LoginService loginService)
注意:第一种方法使用范围更广,第二种方法适用于调用频率低,资源消耗高的情况。
配置系统与ASP.NET Core的集成
在ASP.NET Core项目中,WebApplication类的CreateBuilder方法会按照下面的顺序来提供默认的配置。.NET会按照“后面的提供者覆盖之前的提供者”的方式进行加载。
(1)加载现有的IConfiguration。
(2)加载项目根目录下的appsettings.json。
(3)加载项目根目录下的appsettings.Environment.json,其中Environment代表当前运行环境的名字,7.2.2小节将会详细介绍这一点。
(4)当程序运行在开发环境下,程序会加载“用户机密”配置。
(5)加载环境变量中的配置。
(6)加载命令行中的配置。
环境变量中的配置
在开发环境下,如图7-3所示,我们可以看到Visual Studio自动为项目的调试属性中的环境变量设置了ASPNETCORE_ENVIRONMENT=Development,这就是我们以调试模型启动项目的时候,会加载开发环境相关配置的原因。
配置文件中的配置
在测试、开发环境下,我们还可以分别再创建appsettings.Staging.json、appsettings.Production.json文件。一般来讲,我们在appsettings.json中编写开发、测试、生产环境下都共用的配置,然后在appsettings.Development.json等文件中编写开发环境等的特有配置。
用户机密
在项目中右键 - 点击【管理用户机密】后会在项目文件中添加用户机密配置,同事也会打开一个机密文件,由于这文件不存在于项目目录中,而是在当前系统的用户文件夹下,所以不会出现数据库连接字符串随着配置文件被提交到互联网上的情况。
但是由于该配置项是唯一的,多个项目用到同一个机密也需要手动修改,所以如果是团队开发,使用起来会比较麻烦。
使用配置中心可以解决上面的问题。
Authentication的音标:[ɔˌθentɪˈkeɪʃ(ə)n]
Authorization的音标:[ˌɔθərɪˈzeɪʃ(ə)n]
Authentication与Authorization区别在于中间的entication和rization
Authentication的意思是授权,验证,它是用来验证是否登录成功
Authorization的意思是授权,它是用来判断用户是否有权限访问,它应该是基于Authentication之上的。
JSON Web Token (JWT) 是一个开放标准 ( RFC 7519 ),它定义了一种紧凑且自包含的方式,用于在各方之间以 JSON 对象的形式安全传输信息。此信息可以验证和信任,因为它是数字签名的。
使用JWT的场景:
授权:这是使用 JWT 最常见的场景。用户登录后,每个后续请求都将包含 JWT,从而允许用户访问该令牌允许的路由、服务和资源。单点登录是当今广泛使用 JWT 的一项功能,因为它的开销很小并且能够在不同的域中轻松使用。
除了JWT之外还有一种鉴权授权方式Session,这是一种有状态的登录方式,而JWT是无状态登录,常用来做单点登录系统。
在日常的使用中Session有以下痛点:
在分布式应用中如果有多个后台Web服务,需要实现共享Session,增加服务器的负担。
由于Session需要配合Cookie使用,容易遭到CSRF的攻击。
如果令牌在Authorization标头中发送,则跨域资源共享 (CORS) 不会成为问题,也不会遭到CSFR攻击,因为它不使用 cookie。
下图是如何获取并使用JWT的流程图:
1、应用程序或客户端向授权服务器请求授权。
2、当授权被授予时,授权服务器向应用程序返回一个访问令牌。
3、应用程序使用访问令牌访问受保护的资源(如 API)。
参考:
(JWT中文网:JWT中文文档网)
(C#技术栈入门到精通系列19——鉴权授权IdentityServer JWT:C#技术栈入门到精通系列19——鉴权授权IdentityServer JWT - BigBox777 - 博客园)