【笔记】ASP.NET Core技术内幕与项目实现:基于DDD与前后端分离

最近在写论文,想使用ASP.NET Core Web API技术,但对它还不是很熟,鉴权组件也没用过,于是在网上查找资料,发现了杨中科老师写的这本书(微信读书上可以免费看),说起来我最初自学C#时看过他的盗版视频。

 

这本书使用的是.NET 6,但我用.NET Core 3.1也能实现书中的绝大部分功能,前几章主要讲理论,框架是简单的控制台,后面会使用到 ASP.NET Core Web API和鉴权组件。

但后来我发现要使用Identity鉴权组件需要使用EF Core的Code First模式,这种模式不符合我的习惯,我打算去掉这部分的功能,只实现到用JWT获取到Token为止,只判断能否登陆,不判断角色。

2023-02-28

后来我发现可以通过新建一个专用于存放用户信息的库来分离两种模式,这样一来与业务逻辑相关的表使用Datebase First模式,与用户相关的表使用Code First模式。它们之间就不会互相影响了。

第一章 .NET Core入门

.NET Core的优点

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等反向代理服务器完成。

以上两个优点我觉得是很明显的,支持独立部署可以减少运维的工作量,不用再去安装运行环境,内置服务器是跨平台的基础。

.NET Core对.NET Framework中哪些技术不支持

我使用过的框架包括:Winform,WPF,ASP.NET WebForms,WCF

Windows特有的技术:绘图,Windows服务,Windows注册表,在.NET Core中,我们可以通过Microsoft.Windows.Compatibility Nuget包继续使用这些技术,但是使用这些技术开发的程序只能运行在Windows下。如需移植到其他平台,可通过重写这些代码或编写平台检测代码。

.NET Standard

.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,因为它实现了最大公约数。

第二章 .NET Core重难点知识

异步编程

什么是异步?它和同步有什么区别?

作者举了一个点餐的例子,同步是你在点餐时服务员一直站在那里等你决定吃什么,选好后提交上去;异步是在点餐时给你一张菜单然后就去服务其他人,等你选好后让服务员过来将菜单递交上去,这个过程可能比前一种方法慢一些,但能够同时服务更多的人。

结论就是异步比同步的优势在于可以增加同时处理的请求数,但响应的时间不一定比它快。

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<List<string>> 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,再去取结果,否则会引起线程阻塞。

LINQ

扩展方法

判断一个集合是否有一条数据时,不仅可以使用Count方法,也可以使用Any方法,而且这种方法的效率比前者更高。

获取一条数据时,我常常会使用FirstOrDefault方法,但这个方法有局限性,如果返回的结果应该只有一条,但实际返回了多条,该方法也会正常返回,隐藏异常信息。使用SingleOrDefault方法可以在返回多条时会抛出异常,开发人员也能及时的发现问题,提高程序的正确性。

第三章 .NET Core核心基础组件

依赖注入

负责提供对象的注册和获取功能的框架叫作“容器”,注册到容器中的对象叫作“服务”(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<TestServiceImpl>();
                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<ILogger<BasicLogger>>();  // 获得服务
                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("普通信息");   // 输出日志

第4章 Entity Framework Core基础

它是一个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语句打印到控制台。

第五章 EF Core高级技术

既生IEnumerable,何生IQueryable

它们之间有什么不同呢?

IQueryable是继承与IEnumerable,其用法相同,这里要引述两个概念:服务器端评估和客户端评估。

服务器端评估:使用SQL语句在数据库服务器上完成数据筛选的过程、

客户端评估:把数据首先加载到应用程序的内存中,然后在内存中进行数据筛选的过程。

很显然,对于大部分情况来讲,“客户端评估”性能比较低,我们要尽量避免“客户端评估”。

Enumerable类中定义的供普通集合用的Where等方法都是“客户端评估”,因此微软创造了IQueryable类型,它使用的是“服务器端评估”。如果打开了EF Core的日志,可以查看到生成SQL语句也不同,IQueryable生成的语句代入了Where条件,Enumerable没有代入该条件。

DataReader和DataTable的区别

ADO.NET中有DataReader和DataTable两种读取数据库查询结果的方式。如果查询结果有很多条数据,DataTable会把所有数据一次性地从数据库服务器加载到客户端内存中,而DataReader则会分批从数据库服务器读取数据。DataReader的优点是客户端内存占用小,缺点是如果遍历读取数据并进行处理的过程缓慢的话,会导致程序占用数据库连接的时间较长,从而降低数据库服务器的并发连接能力;DataTable的优点是数据被快速地加载到了客户端内存中,因此不会较长时间地占用数据库连接,缺点是如果数据量大的话,客户端的内存占用会比较大。

第六章 ASP.NET Core Web API基础

在.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派与4XX派的“对决”

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()这样的声明方式。

第七章 ASP.NET Core 基础组件

ASP.NET Core中的依赖注入

1、高频注入(基于控制器类)

实现步骤:

1.1 Startup类的ConfigureServices方法注入服务

1.2 目标控制器类中添加一个类变量,类型是注入服务;添加一个构造函数,参数是注入服务,给那个类变量赋值。

2、低频注入(基于行为方法)

2.1 Startup类的ConfigureServices方法注入服务

2.2 目标行为方法中添加一个参数,类型是注入服务,参数添加一个[FromServices]标记。

    public ActionResult<string> 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等文件中编写开发环境等的特有配置。

用户机密

在项目中右键 - 点击【管理用户机密】后会在项目文件中添加用户机密配置,同事也会打开一个机密文件,由于这文件不存在于项目目录中,而是在当前系统的用户文件夹下,所以不会出现数据库连接字符串随着配置文件被提交到互联网上的情况。

但是由于该配置项是唯一的,多个项目用到同一个机密也需要手动修改,所以如果是团队开发,使用起来会比较麻烦。

使用配置中心可以解决上面的问题。

性能优化“万金油”:缓存

客户端缓存

给客户端返回cache-control请求头赋值max-age=xxx后,再次发送相同参数的请求后,后端接口不会接收新的请求,而是浏览器直接返回结果。

这样做的好处是提高了系统的性能,减轻了服务器的负担。

在ASP.NET Core Web API中只需要在方法上添加ResponseCache特性,并设置Duration就可以实现,如下图所示:

        [HttpGet]
        [ResponseCache(Duration = 60)]
        public async Task<ActionResult<ReceiveObject<TOrder>>> GetOrder(int id)
        {
            // 业务逻辑代码        
        }

我们可以在方法内打断点,首次访问该接口后断点会命中,之后马上再次访问该接口断点不会命中,按F12打开浏览器的开发者模式,可以看到第一次传输了266字节,第二次是已缓存的数据。

 

服务端缓存

先说结论,不建议开发人员使用服务端缓存,因为开发调试比较麻烦(浏览器无法调试,只能使用PostMan并关闭Cache-Control头),限制比较大(响应状态码为200的GET或者HEAD响应才可能被缓存;报文头中不能含有Authorization、Set-Cookie)

服务器端响应缓存的使用也比较复杂,如果设置不当的话,会导致缓存的数据错误,比如发送给用户A的响应被缓存起来,然后发送给用户B,导致数据安全风险。

开启它的方式很简单,只需要在MapControllers方法前面,UseCors方法后面添加UseResponseCaching方法即可。

            // 启用中间件缓存
            app.UseResponseCaching();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllers();
            });

内存缓存

除了响应缓存中间件这样自动化的服务器端缓存机制之外,ASP.NET Core还提供了允许开发人员手动进行缓存管理的机制,内存缓存就是一种把缓存数据放到应用程序内存中的机制。

内存缓存中保存的是一系列的键值对,就像Dictionary类型一样,每个不同的缓存内容具有不同的“缓存键”,每个缓存键对应一个“缓存值”。

对于ASP.NET Core MVC项目,框架会自动地注入内存缓存服务;对于ASP.NET Core Web API等没有自动注入内存缓存服务的项目,我们需要在Program.cs的builder.Build之前添加builder.Services.AddMemoryCache来把内存缓存相关服务注册到依赖注入容器中。

我们一般使用依赖注入的方式来获得IMemoryCache服务,因此我们为控制器类注入IMemoryCache。

// 获取或创建一个名为GetAppointList的缓存键值对
var item = await this._cache.GetOrCreateAsync("GetAppointList",
async (e) => 
{
     // 设置缓存过期时间的两种策略
     e.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(30);    // 固定缓存(一到过期时间,缓存就过期)
     e.SlidingExpiration = TimeSpan.FromSeconds(10);    // 滑动缓存(在缓存期间如果命中,会续期)
 
    // 查询数据库... 
    // 将查询结果返回,这一步会将数据记录进去
    return result;
}

// 返回结果,如果命中缓存,就不会再查库,如果未命中会查库
return item;

两种缓存过期策略

1、绝对过期时间

2、滑动过期时间

绝对过期时间是到了指定的时间后,缓存会过期。

滑动过期时间是在过期时间内命中了缓存,它的有效期会续期,直到超过过期时间就会过期。

我们在选择内存缓存的过期时间策略的时候,如果缓存项的条数不多或者大部分缓存数据被访问的频率都差不多的话,我们可以使用绝对过期时间策略;

如果只有部分数据访问频率比较高并且数据库中的数据不会被更新的话,我们可以使用滑动过期时间策略;

如果缓存项的数据量比较大且只有其中一部分会被频繁访问,而且数据库中的数据会被更新的话,用绝对过期时间和滑动过期时间混合的策略更合适。

当然,无论用哪种过期时间策略,程序中都会存在缓存数据不一致的情况。对于有的系统,这种数据不一致的情况是可以接受的,比如我们把文章的点击量放到缓存中,就会存在文章被访问后没有立即显示新的点击量,而是几秒后等对应缓存项过期之后才更新显示,这个一般来讲是可以接受的。但是在有的系统中,这种延时是无法接受的,比如银行系统中用户的余额如果在用户转账后没有立即更新,则会有非常大的影响。对于这种无法接受缓存延时的系统,如果对应的从数据源获取数据的频率不高的话,可以不用缓存;

缓存穿透

当访问的数据在数据库中不存在时,会查询数据库,如果有人恶意高频用这种数据访问,有可能造成数据库的奔溃。

缓存雪崩

缓存雪崩是在短时间内缓存大量过期,导致数据库被频繁访问,从而导致数据库服务器被压垮。规避的方法是在写入缓存时,在基础过期时间上,再随机添加一个过期时间,这样过期时间就会均匀地分布在一个时间段内。

分布式缓存

在分布式环境下,每次查询数据都会将将数据缓存在内存中。如果只有几台服务器问题还不大,但如果是多台服务器会造成成本的上升。这时候就可以考虑单独将一台服务器作为缓存服务器,专门用来保存缓存数据。

常用的分布式缓存服务器有Redis、Memcached等,当然我们也可以把SQL Server等关系数据库当作分布式缓存服务器使用。.NET Core中提供了统一的分布式缓存服务器的操作接口IDistributedCache,无论用什么类型的分布式缓存服务器,我们都可以统一使用IDistributedCache接口进行操作。

在使用分布式缓存的时候,我们还要选择合适的缓存服务器。微软官方提供了用SQL Server作为缓存服务器的DistributedSqlServerCache,但是用关系数据库来保存缓存的性能并不好。

Memcached是一个专门的缓存服务器,在缓存数据量比较小的时候,性能非常高,但是Memcached在集群、高可用等方面比较弱,而且有“缓存键的最大长度为250B”等限制。如果要使用Memcached作为分布式缓存服务器,我们可以安装EnyimMemcachedCore这个第三方NuGet包。

Redis是一个键值对数据库,提供了丰富的数据类型,它不仅可以被当作缓存服务器,也可以用来保存列表、字典、集合、地理坐标等数据类型,更可以用来作为消息队列。在某些情况下,Redis作为缓存服务器比Memcached性能稍差,但是Redis在高可用、集群等方面非常强大,非常适合在数据量大、需要高可用性等场合使用。微软官方也提供了用Redis作为缓存服务器的NuGet包,本书中将会使用Redis作为分布式缓存服务器。

首先下载并启动Redis服务,在命令提示符中输入redis-cli确认连接正常,我使用的是3.2版本。

接着,因为我们要连接的缓存服务器是Redis,所以需要通过NuGet安装Microsoft.Extensions.Caching.StackExchangeRedis。建议使用3.x版本,与redis的版本对应,否则在运行时redis保存数据可能会出现错误。

然后再Startup文件中的ConfigureServices方法中注册分布式缓存服务:

            // 注册分布式缓存服务
            services.AddStackExchangeRedisCache(option =>
            {
                option.Configuration = "localhost";    // 设置连接配置
                option.InstanceName = "pms_";    // 设置缓存前缀
            });

最后使用该服务:

        private readonly IDistributedCache _distributedCache;

        public RoomController(db_hotelContext context, IDistributedCache distributedCache)
        {
            _context = context;
            _distributedCache = distributedCache;
        }
                
        [HttpGet]
        public async Task<ActionResult> GetCount()
        {
            ReceiveObject<string> model = new ReceiveObject<string>();
            try
            {
                // 从缓存数据库中取值
                var count = this._distributedCache.GetString("GetCount");
                if(count == null)
                {
                    // 如果取不到值,则从数据库中查询,并写入到缓存中.
                    count = xxx;
                    var opt = new DistributedCacheEntryOptions();
                    opt.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(30);
                    this._distributedCache.SetString("GetCount", count, opt);
                }
             
                model.code = 0;
                model.data = count;
            }
            catch (Exception ex)
            {
                model.code = 999999;
                model.msg = "系统异常";
            }

            return Ok(model);
        }

使用Redis客户端,我们可以查看到Redis中确实创建了一个Hash类型的数据,20秒后过期。如果缓存不设置过期时间,那么它就不会过期TTL的值会是-1。

总结

经过前面的学习,我们知道,.NET中的缓存分为客户端响应缓存、服务器端响应缓存、内存缓存、分布式缓存等。缓存可以极大地提升系统的性能,在进行系统设计的时候,我们要根据系统的特点选择合适的缓存方式。

客户端响应缓存能够充分利用客户端的缓存机制,它不仅可以降低服务器端的压力,也能够提升客户端的操作响应速度并且降低客户端的网络流量。但是我们需要合理设置缓存相关参数,以避免客户端无法及时刷新到最新数据的问题。

服务器端响应缓存能够让我们几乎不需要编写额外的代码就轻松地降低服务器的压力。但是由于服务器端响应缓存的启用条件比较苛刻,因此要根据项目的情况决定是否使用它。

内存缓存能够降低数据库以及后端服务器的压力,而且内存缓存的存取速度非常快;分布式缓存能够让集群中的多台服务器共享同一份缓存,从而降低数据源的压力。如果集群节点的数量不多,并且数据库服务器的压力不大的话,推荐读者使用内存缓存,毕竟内存的读写速度比网络快很多;如果集群节点太多造成数据库服务器的压力很大的话,可以采用分布式缓存。无论是使用内存缓存还是分布式缓存,我们都要合理地设计缓存键,以免出现数据混乱。

这些缓存方式并不是互斥的,我们在项目中可以组合使用它们。比如对于论坛系统,论坛首页中的版块信息变动不频繁,我们可以为版块信息的客户端响应缓存设置24h的过期时间;对于所有的帖子详情信息,我们同时启用内存缓存和分布式缓存,当加载帖子详情页面的数据的时候,我们先到内存缓存中查找,内存缓存中找不到再到分布式缓存中查找,这样就既可以利用内存缓存读取速度快的优点,也能利用分布式缓存的优点。

 

第八章 ASP.NET Core 高级组件

Authentication与Authorization

Authentication的音标:[ɔˌθentɪˈkeɪʃ(ə)n]

Authorization的音标:[ˌɔθərɪˈzeɪʃ(ə)n]

Authentication与Authorization区别在于中间的entication和rization

Authentication的意思是授权,验证,它是用来验证是否登录成功

Authorization的意思是授权,它是用来判断用户是否有权限访问,它应该是基于Authentication之上的。

Identity(标识)框架

Identity框架的作用:大部分系统中都需要通过数据库保存用户、角色等信息,并且需要注册、登录、密码重置、角色管理等功能。ASP.NET Core提供了标识(identity)框架,它采用RBAC(role-based access control,基于角色的访问控制)策略,内置了对用户、角色等表的管理及相关的接口,从而简化了系统的开发。

Identity框架的适用范围:标识框架使用EF Core对数据库进行操作,由于EF Core屏蔽了底层数据库的差异,因此标识框架支持几乎所有数据库。

具体实现步骤:

第1步,创建ASP.NET Core Web API项目,并通过NuGet安装Microsoft.AspNetCore.Identity.EntityFrameworkCore。

第2步,创建用户实体类User和角色实体类Role。在这个演示中,我们使用自增标识列类型的主键,因此我们编写分别继承自IdentityUser、IdentityRole的User类和Role类

  public class User: IdentityUser<long>
  {
     public DateTime CreationTime { get; set; }
     public string? NickName { get; set; }
  }
  public class Role:IdentityRole<long>
  {
  }

第3步,创建继承自IdentityDbContext的类,这是一个EF Core中的上下文类,我们可以通过这个类操作数据库。IdentityDbContext是一个泛型类,有3个泛型参数,分别代表用户类型、角色类型和主键类型。

  public class IdDbContext : IdentityDbContext<User, Role, long>
  {
     public IdDbContext(DbContextOptions<IdDbContext> options)
        : base(options)
     {
     }
     protected override void OnModelCreating(ModelBuilder modelBuilder)
     {
        base.OnModelCreating(modelBuilder);
       modelBuilder.ApplyConfigurationsFromAssembly(this.GetType().Assembly);
    }
 }

第4步,我们需要向依赖注入容器中注册与标识框架相关的服务,并且对相关的选项进行配置

  IServiceCollection services = builder.Services;
  services.AddDbContext<IdDbContext>(opt => {
      string connStr = builder.Configuration.GetConnectionString("Default");  // 需要在配置文件中添加数据库的连接字符串
      opt.UseSqlServer(connStr);  // 如果使用的是其他数据库,需要修改这里
  });
  services.AddDataProtection();
  services.AddIdentityCore<User>(options =>
      {
          options.Password.RequireDigit = false;
         options.Password.RequireLowercase = false;
         options.Password.RequireNonAlphanumeric = false;
         options.Password.RequireUppercase = false;
         options.Password.RequiredLength = 6;
         options.Tokens.PasswordResetTokenProvider = TokenOptions.DefaultEmailProvider;
         options.Tokens.EmailConfirmationTokenProvider = TokenOptions.DefaultEmailProvider;
     });
 var idBuilder = new IdentityBuilder(typeof(User), typeof(Role), services);
 idBuilder.AddEntityFrameworkStores<IdDbContext>()
     .AddDefaultTokenProviders()
     .AddRoleManager<RoleManager<Role>>()
     .AddUserManager<UserManager<User>>();

第5步,通过执行Add-Migration、Update-database等命令执行EF Core的数据库迁移,然后程序就会在数据库中生成多张数据库表。这些数据库表都由标识框架负责管理,开发人员一般不需要直接访问这些表。

我在执行迁移时由于存在多个DbContext类,所以需要用到-Context参数指定实体类

参数为:

Add-Migration init -Context IdDbContext

Update-Database -Context IdDbContext

第6步,编写控制器的代码。我们在控制器中需要对角色、用户进行操作,也需要输出日志,因此通过控制器的构造方法注入相关的服务

        private readonly IConfiguration _configuration;
        private readonly UserManager<User> userManager;
        private readonly RoleManager<Role> roleManager;

        public UserController(IConfiguration configuration, RoleManager<Role> roleManager, UserManager<User> userManager)
        {
            this._configuration = configuration;
            this.roleManager = roleManager;
            this.userManager = userManager;
        }

第7步,获取验证邮箱有效性的令牌

            var flag = await roleManager.RoleExistsAsync("admin");
            if (flag == false)
            {
                // 如果admin角色不存在,则创建它
                Role role = new Role()
                {
                    Name = "admin"
                };

                var result = await roleManager.CreateAsync(role);
                if (result.Succeeded == false)
                {
                    return Ok(new ReceiveObject<string>()
                    {
                        code = 999999,
                        msg = "创建角色失败"
                    });
                }
            }

            User user = await this.userManager.FindByNameAsync(userName);
            if (user == null)
            {
                // 如果该用户不存在,则创建它
                user = new User()
                {
                    UserName = userName,
                    Email = emailName,
                    EmailConfirmed = false,
                    CreateTime = DateTime.Now
                };

                var result = this.userManager.CreateAsync(user);
                if (result.Result.Succeeded == false)
                {
                    return Ok(new ReceiveObject<string>()
                    {
                        code = 999999,
                        msg = "创建用户失败"
                    });
                }
            }

            // 创建一个令牌用于确认邮件的有效性
            var result_confirm = this.userManager.GenerateEmailConfirmationTokenAsync(user);
            return Ok(new ReceiveObject<string>()
            {
                code = 999999,
                data = string.Format("已向邮箱{0}发送验证码,验证码为{1}", emailName, result_confirm.Result)
            });

第8步,编写创建角色和用户的方法CreateUserRole

            var flag = await roleManager.RoleExistsAsync("admin");
            if(flag == false)
            {
                // 如果admin角色不存在,则创建它
                Role role = new Role()
                {
                    Name = "admin"
                };

                var result = await roleManager.CreateAsync(role);
                if (result.Succeeded == false)
                {
                    return Ok(new ReceiveObject<string>()
                    {
                        code = 999999,
                        msg = "创建角色失败"
                    });
                }
            }

            User user = await this.userManager.FindByNameAsync(userName);
            if(user == null)
            {
                return Ok(new ReceiveObject<string>()
                {
                    code = 999999,
                    msg = "请先获取邮箱验证码"
                });
            }
            else
            {
                // 该用户已创建
                // 验证邮件令牌的有效性
                var result = this.userManager.ConfirmEmailAsync(user, token);
                if (result.Result.Succeeded == false)
                {
                    return Ok(new ReceiveObject<string>()
                    {
                        code = 999999,
                        msg = "邮箱验证码无效"
                    });
                }

                user.EmailConfirmed = true;
                result = this.userManager.UpdateAsync(user);
                result = this.userManager.AddPasswordAsync(user, password);
            }

            // 给该用户赋予admin角色权限
            var result_list = this.userManager.GetRolesAsync(user);
            if(result_list.Result.Count(m => m.Equals("admin")) > 0)
            {
                // 已经给该用户赋予角色权限
                return Ok(new ReceiveObject<string>()
                {
                    code = 0,
                    msg = "成功"
                });
            }

            var result_role = this.userManager.AddToRoleAsync(user, "admin");
            if (result_role.Result.Succeeded == false)
            {
                return Ok(new ReceiveObject<string>()
                {
                    code = 999999,
                    msg = "给用户赋予角色失败"
                });
            }
            else
            {
                return Ok(new ReceiveObject<string>()
                {
                    code = 0,
                    msg = "成功"
                });
            }

第9步,编写处理登录请求的操作方法LoginAlpla

            var user = await userManager.FindByNameAsync(username);
            if (user == null)
            {
                return Ok(new ReceiveObject<string>()
                {
                    code = 999999,
                    msg = $"用户名不存在{username}"
                });
            }

            if (await userManager.IsLockedOutAsync(user))
            {
                return Ok(new ReceiveObject<string>()
                {
                    code = 999999,
                    msg = "用户名已锁定"
                });
            }

            var success = await userManager.CheckPasswordAsync(user, password);
            if (success)
            {
                return Ok(new ReceiveObject<string>()
                {
                    code = 0,
                    msg = "成功"
                });
            }
            else
            {
                await userManager.AccessFailedAsync(user);
                return Ok(new ReceiveObject<string>()
                {
                    code = 999999,
                    msg = "用户名或密码错误"
                });
            }

JWT

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编码规则

JWT由三个部分组成:头部信息(head),有效载荷(payload),签名(secret),前面两者使用Base64URL编码,它与Base64编码之前有一定的区别,转换流程如下:

BASE64URL编码的流程:1、明文使用BASE64进行加密 2、在BASE64的基础上进行一下的编码:2.1)去除尾部的"=" 2.2)把"+"替换成"-" 2.3)把"/"替换成"_"

BASE64URL解码的流程:1)把"-"替换成"+". 2)把"_"替换成"/" . 3)(计算BASE64URL编码长度)%4 a)结果为0,不做处理 b)结果为2,字符串添加"==" c)结果为3,字符串添加"="

签名的作用:防止信息被篡改,在JWT的发放方和接收方需要定义一个密钥,发放方用它加密数据,接收方使用密钥验证是否被篡改。

下图是如何获取并使用JWT的流程图:

1、应用程序或客户端向授权服务器请求授权。

2、当授权被授予时,授权服务器向应用程序返回一个访问令牌。

3、应用程序使用访问令牌访问受保护的资源(如 API)。

参考:

(JWT中文网:JWT中文文档网

(C#技术栈入门到精通系列19——鉴权授权IdentityServer JWT:C#技术栈入门到精通系列19——鉴权授权IdentityServer JWT - BigBox777 - 博客园

JWT实现登录的流程

1、客户端向服务器端发送用户名、密码等请求登录。

2、服务器端校验用户名、密码,如果校验成功,则从数据库中取出这个用户的ID、角色等用户相关信息。

3、服务器端采用只有服务器端才知道的密钥来对用户信息的JSON字符串进行签名,形成签名数据。

4、服务器端把用户信息的JSON字符串和签名拼接到一起形成JWT,然后发送给客户端。

5、客户端保存服务器端返回的JWT,并且在客户端每次向服务器端发送请求的时候都带上这个JWT。

6、每次服务器端收到浏览器请求中携带的JWT后,服务器端用密钥对JWT的签名进行校验,如果校验成功,服务器端则从JWT中的JSON字符串中读取出用户的信息。

由此可以看出,在JWT机制下,登录用户的信息保存在客户端,服务器端不需要保存数据,这样我们的程序就天然地适合分布式的集群环境,而且服务器端从客户端请求中就可以获取当前登录用户的信息,不需要再去状态服务器中获取,因此程序的运行效率更高。虽然用户信息保存在客户端,但是由于有签名的存在,客户端无法篡改这些用户信息,因此可以保证客户端提交的JWT的可信度。

在ASP.NET Web API中使用JWT

第1步,我们先在配置系统中配置一个名字为JWT的节点,并在节点下创建SigningKey、ExpireSeconds两个配置项,分别代表JWT的密钥和过期时间(单位为秒)。我们再创建一个对应JWT节点的配置类JWTOptions,类中包含SigningKey、ExpireSeconds这两个属性。

做这件事的目的是方便从配置文件中取值,当然也可以使用通过字符串的方式取值。

第2步,通过NuGet为项目安装Microsoft.AspNetCore.Authentication.JwtBearer包,这个包封装了简化ASP.NET Core中使用JWT的操作。

第3步,编写代码对JWT进行配置,把以下内容添加到Program.cs的builder.Build之前。

            // 配置JWT
            services.Configure<JWTOptions>(Configuration.GetSection("JWT"));    // 注册JWT对象
            services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options =>
            {
                var jwtOption = Configuration.GetSection("JWT").Get<JWTOptions>();
                var secretByte = Encoding.UTF8.GetBytes(jwtOption.SigningKey);
                options.TokenValidationParameters = new TokenValidationParameters()
                {
                    //只有配置的发布者donesoft.cn才会被接受
                    ValidateIssuer = false,

                    //只有配置的使用者donesoft.cn才会被接受
                    ValidateAudience = false,

                    //验证token是否过期
                    ValidateLifetime = true,

                    ValidateIssuerSigningKey = true,

                    //对密码进行加密
                    IssuerSigningKey = new SymmetricSecurityKey(secretByte)
                };
            });

第4步,在Program.cs的Configure方法中的app.UseAuthorization之前添加app.UseAuthentication。

            // 能否登录成功
            app.UseAuthentication();

            // 有哪些访问权限
            app.UseAuthorization();

第5步,在UserController类中修改LoginAlpha方法,将JWT的逻辑添加进去。

            var user = await userManager.FindByNameAsync(username);
            if (user == null)
            {
                return Ok(new ReceiveObject<string>()
                {
                    code = 999999,
                    msg = $"用户名不存在{username}"
                });
            }

            if (await userManager.IsLockedOutAsync(user))
            {
                return Ok(new ReceiveObject<string>()
                {
                    code = 999999,
                    msg = "用户名已锁定"
                });
            }

            var success = await userManager.CheckPasswordAsync(user, password);
            if (success)
            {
                // 验证通过,返回令牌
                var claims = new List<Claim>();
                claims.Add(new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()));
                claims.Add(new Claim(ClaimTypes.Name, user.UserName));
                var roles = await userManager.GetRolesAsync(user);
                foreach (var item in roles)
                {
                    claims.Add(new Claim(ClaimTypes.Role, item));
                }

                var jwtOption = _configuration.GetSection("JWT").Get<JWTOptions>();
                string jwtToken = BuildToken(claims, jwtOption);
                return Ok(new ReceiveObject<string>()
                {
                    code = 0,
                    msg = "成功",
                    data = jwtToken
                });
            }
            else
            {
                await userManager.AccessFailedAsync(user);
                return Ok(new ReceiveObject<string>()
                {
                    code = 999999,
                    msg = "用户名或密码错误"
                });
            }

BuildToken方法:

        private string BuildToken(List<Claim> claims, JWTOptions jwtOption)
        {
            string key = jwtOption.SigningKey;
            DateTime expires = DateTime.Now.AddSeconds(jwtOption.ExpireSeconds);
            byte[] secBytes = Encoding.UTF8.GetBytes(key);
            var secKey = new SymmetricSecurityKey(secBytes);
            var credentials = new SigningCredentials(secKey, SecurityAlgorithms.HmacSha256Signature);
            var tokenDescriptor = new JwtSecurityToken(claims: claims,
                expires: expires, signingCredentials: credentials);
            string jwt = new JwtSecurityTokenHandler().WriteToken(tokenDescriptor);
            return jwt;
        }

BuildToken方法的作用是根据登录用户信息,JWT的密钥,有效期生成令牌。

扩展

Claim对象可以看成是身份证上面的一个个键值对,比如:姓名-张三,出生日期-1999年9月9日。

Claim还有一个列表对象ClaimsIdentity,它是Claim对象的集合,比如:身份证包含了个人的姓名,出生日期,证件号码这些信息。

ClaimsIdentity也有一个列表对象ClaimsPrincipal,它是ClaimsIdentity的集合,比如一个人除了拥有身份证外也可以有其他证件,比如护照,驾照,户口薄。

参考:https://www.cnblogs.com/jlion/p/12543486.html

第6步,验证令牌是否有效,在需要登录才能访问的控制器类上添加[Authorize]这个ASP.NET Core内置的Attribute。

            var user = this.User;
            var id = user.FindFirst(m => m.Type == ClaimTypes.NameIdentifier);
            var name = user.FindFirst(m => m.Type == ClaimTypes.Name);
            var list_role = user.FindAll(m => m.Type == ClaimTypes.Role);
            var result = string.Format("编号:{0},用户名:{1},角色:{2}", id, name, string.Join(',', list_role));
            return Ok(new ReceiveObject<string>()
            {
                code = 0,
                data = result
            });

User对象是控制器类ControllerBase的属性,通过它我们可以快速拿到当前用户的授权信息。

[Authorize]标注

它的作用:[Authorize]这个Attribute既可以被添加到控制器类上,也可以被添加到操作方法上。我们可以在控制器类上标注[Authorize],那么这个控制器类中的所有操作方法都会被进行身份验证和授权验证;对于标注了[Authorize]的控制器类,如果其中某个操作方法不想被验证,我们可以在这个操作方法上添加[AllowAnonymous]。如果没有在控制器类上标注[Authorize],那么这个控制器类中的所有操作方法都允许被自由地访问;对于没有标注[Authorize]的控制器类,如果其中某个操作方法需要被验证,我们也可以在操作方法上添加[Authorize]。

使用令牌调用接口:在发送请求的时候,我们只要按照HTTP的要求,把JWT按照“Bearer JWT”格式放到名字为Authorization的请求报文头中即可。ASP.NET Core会按照HTTP的规范,从Authorization中取出令牌,并且进行校验、解析,然后把解析结果填充到User属性中,这一切都是ASP.NET Core完成的,不需要开发人员自己编写代码。但是,如果由于设置或者代码错误导致校验失败,服务器端只会给出状态码为401的响应,开发人员很难得知问题到底出在哪里。

在Swagger中调试JWT

修改Startup.cs的注册Swagger服务的AddSwaggerGen方法

                // 通过对OpenAPI的配置实现从Swagger中发送Authorization报文头
                var scheme = new OpenApiSecurityScheme()
                {
                    Description = "Authorization header. \r\nExample: 'Bearer 12345abcdef'",
                    Reference = new OpenApiReference
                    {
                        Type = ReferenceType.SecurityScheme,
                        Id = "Authorization"
                    },
                    Scheme = "oauth2",
                    Name = "Authorization",
                    In = ParameterLocation.Header,Type = SecuritySchemeType.ApiKey,
                };

                c.AddSecurityDefinition("Authorization", scheme);
                var requirement = new OpenApiSecurityRequirement();
                requirement[scheme] = new List<string>();
                c.AddSecurityRequirement(requirement);

然后在Swagger的首页中点击【Authorize】按钮后输入Bearer + 令牌后保存,令牌从登陆接口获取。这样调用需要授权的接口后系统会自动带入令牌,获取访问权限。

(待续未完)

  • 1
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Spring Boot是一个用于创建独立的、生产级别的基于Java的应用程序的框架。它的特点是简单、快速、方便,适合用于构建单体应用程序。在传统的Spring Boot项目中,前后端通常没有明确的分离,而是将前端代码和后端代码放在同一个项目中。 下面是一些笔记,可以帮助你理解如何在Spring Boot项目中进行前后端不分离的开发: 1. 项目结构:在项目中创建一个统一的目录结构,将前端和后端代码放置在不同的子目录中。可以按照功能或模块来组织代码。 2. 视图层:使用模板引擎(如Thymeleaf)来生成前端页面。在后端代码中编写HTML模板文件,将动态数据注入到模板中,然后将渲染后的HTML页面返回给客户端。 3. 控制器:编写后端的控制器类来处理请求和返回数据。控制器类负责接收前端请求,处理业务逻辑,并将相应的数据返回给前端。 4. 数据交互:使用Spring Boot提供的HTTP请求处理功能来处理前后端之间的数据交互。可以使用@RestController注解标记控制器类,使用@RequestMapping注解标记方法,然后通过方法参数接收请求参数或请求体。 5. 安全性:可以使用Spring Security来保护应用程序的安全性。通过配置安全规则,可以限制访问某些URL或资源的权限。 6. 数据库操作:可以使用Spring Data JPA来进行数据库操作。通过定义实体类和仓库接口,可以方便地进行CRUD操作。 7. 测试:可以使用JUnit或Spring Boot提供的测试框架来编写单元测试和集成测试。通过测试可以确保代码的质量和功能的正确性。 请注意,虽然在前后端不分离的项目中,前端代码和后端代码放在同一个项目中,但仍可以通过组织代码结构和使用合适的技术实现代码的模块化和可维护性。如果你希望实现前后端分离的开发方式,可以考虑使用前后端分离的框架(如Vue.js、React等)来构建前端,并通过RESTful API来进行数据交互。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值