第四十六节:后台托管服务(不同生命周期相互注入方案)、数据校验规则(内置、 FluentValidation)、程序发布部署
一. 托管服务
1. 简介
使用背景:代码运行在后台。比如服务器启动的时候在后台预先加载数据到缓存,再比如定时任务凌晨1点需要遍历数据库修改状态等等。
注意:
常驻后台的托管服务并不需要特殊的技术,我们只要while (!stoppingToken.IsCancellationRequested) 让ExecuteAsync中的代码一直执行不结束就行了, 但是不能部署在IIS上。
因为如果挂在IIS上,闲置超时20分钟,是指20分钟内没有任何请求进行访问,如果有请求则这个闲置超时时间会重新计算。如果场景是定时任务,且期间没有请求,该方案不适合,
因为IIS会回收它,这一点类似Quartz.Net 部署在IIS上是一个道理的(可以用控制台方案解决 或 其它部署方案解决)。
2.核心说明
(1). 托管服务实现IHostedService接口,但我们通常用BackgroundService这个基类来做
3. 托管服务的异常处理
(1).从.NET 6开始,当托管服务中发生未处理异常的时候,程序就会自动停止并退出(之前程序不会停止)。可以把HostOptions.BackgroundServiceExceptionBehavior设置为Ignore,程序会忽略异常,而不是停止程序。 不过推荐采用默认的设置,因为“异常应该被妥善的处理,而不是被忽略”。
(2).通常建议在ExecuteAsync方法中把代码用try-catch包裹起来,当发生异常的时候,记录日志中或发警报等。
(3). 代码实操:
A. 后台常驻服务,通过 while (!stoppingToken.IsCancellationRequested) 来判断
B. 业务代码要try-catch包裹
C. 通过 await base.StopAsync(stoppingToken); 停止后台服务
D. 服务注册 builder.Services.AddHostedService<TestBackService1>(); 【这是单例模式的注入】
后台服务代码
public class TestBackService1 : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
Console.WriteLine("----------后台任务开启-------------------");
while (!stoppingToken.IsCancellationRequested)
{
try
{
//模拟实际业务,输出当前时间
Console.WriteLine($"当前时间为:{DateTime.Now}");
//等待5s
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
//测试报错
//int.Parse("fdgdfg");
}
catch (Exception ex)
{
Console.WriteLine($"出错了:{ex.Message}");
//根据实际情况决定是否停止后台任务
await base.StopAsync(stoppingToken);
}
}
}
}
注册服务
//注册后台服务
builder.Services.AddHostedService<TestBackService1>();
运行结果
4. 托管服务中的DI
(1). 托管服务是以单例的生命周期注册到依赖注入容器中的。因此不能注入请求内单例或者瞬时的服务。比如注入EF Core的上下文的话(默认是请求内单例的),程序就会抛出异常。
(2). 解决方案
创建一个IServiceScope对象,这样我们就可以通过IServiceScope来创建所需声明周期的服务即可
这里通常有两种写法,
要么直接在ExecuteAsync中的using中构建出来所需声明周期的服务 【详见代码版本2】
要么在构造函数中创建出来所需声明周期的服务(需要dispose一下) 【详见代码版本3】
(3). 代码实操
A.新建EF上下午MyDBContext, 然后 builder.Services.AddScoped<MyDbContext>(); 请求内单例的
B.在后台服务中TestBackServiceDI中注入MyDBContext。 运行:直接报错,错误内容如下图,大体意思:不同生命周期的内容不能相互注入使用 【详见版本1代码】
代码分享:
public class TestBackServiceDI : BackgroundService
{
#region 版本1--构造函数注入MyDbContext【报错】
private readonly MyDbContext dbContext;
public TestBackServiceDI(MyDbContext dbContext)
{
this.dbContext = dbContext;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
Console.WriteLine("----------后台任务开启-------------------");
while (!stoppingToken.IsCancellationRequested)
{
try
{
//调用EF上下午
string result = dbContext.GetMsg();
Console.WriteLine(result);
}
catch (Exception ex)
{
Console.WriteLine($"出错了:{ex.Message}");
//根据实际情况决定是否停止后台任务
await base.StopAsync(stoppingToken);
}
}
}
#endregion
}
报错:
C.注入IServiceProvider service
D.在ExecuteAsync中通过using+ serviceScope.ServiceProvider.GetRequiredService<MyDbContext>(); 创建dbContext即可,运行代码,直接输出Hello world 【详见代码版本2】
E.详见代码版本3
解决方案1代码 【推荐】
public class TestBackServiceDI : BackgroundService
{
#region 版本2-通过service.CreateScope()创建
private readonly IServiceProvider service;
public TestBackServiceDI(IServiceProvider service)
{
this.service = service;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
Console.WriteLine("----------后台任务开启-------------------");
while (!stoppingToken.IsCancellationRequested)
{
try
{
using (IServiceScope serviceScope = service.CreateScope())
{
var dbContext = serviceScope.ServiceProvider.GetRequiredService<MyDbContext>();
//调用EF上下午
string result = dbContext.GetMsg();
Console.WriteLine(result);
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
catch (Exception ex)
{
Console.WriteLine($"出错了:{ex.Message}");
//根据实际情况决定是否停止后台任务
await base.StopAsync(stoppingToken);
}
}
}
#endregion
}
解决方案2代码
public class TestBackServiceDI : BackgroundService
{
#region 版本3-通过构造函数中创建符合生命期的EF上下文
private readonly IServiceScope serviceScope;
private readonly MyDbContext dbContext;
public TestBackServiceDI(IServiceProvider service)
{
this.serviceScope = service.CreateScope();
this.dbContext = serviceScope.ServiceProvider.GetRequiredService<MyDbContext>();
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
Console.WriteLine("----------后台任务开启-------------------");
while (!stoppingToken.IsCancellationRequested)
{
try
{
//调用EF上下午
string result = dbContext.GetMsg();
Console.WriteLine(result);
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
catch (Exception ex)
{
Console.WriteLine($"出错了:{ex.Message}");
//根据实际情况决定是否停止后台任务
await base.StopAsync(stoppingToken);
}
}
}
public override void Dispose()
{
base.Dispose();
serviceScope.Dispose();
}
#endregion
}
二. 内置数据校验
1. 说明
NET Core中内置了对数据校验的支持,在System.ComponentModel.DataAnnotations这个命名空间下,比如
[Required] 必填
[EmailAddress] 邮箱地址,默认的验证规则很简单,只要符合xxx@xxx即可,通常配合[RegularExpression] 正则验证
[RegularExpression] 正则验证
[StringLength(10, MinimumLength = 3)] 长度验证(最大长度最小长度)
[Compare(nameof(Password2), ErrorMessage = "两次密码必须一致")] 用于比较两个值是否相同
此外还有: CustomValidationAttribute 、 IValidatableObject
2. 存在的问题
A. 很多常用的校验都需要编写自定义校验规则,而且写起来麻烦
B. 校验规则都是和模型类耦合在一起,违反“单一职责原则”
PS:这种内置的校验规则,由于和模型类耦合在一起,通常用在action的接收参数上,并不用在EFCore实体模型上
3. 实操
A. 编写实体模型UserInfo, 配置校验规则
public class UserInfo
{
[Required]
public string userName { get; set; }
[Required]
[StringLength(10,MinimumLength =4,ErrorMessage ="密码长度应该为4-10位")]
public string pwd1 { get; set; }
[Compare(nameof(pwd1),ErrorMessage ="两次密码必须相同")]
public string pwd2 { get; set; }
[Required]
[EmailAddress]
[RegularExpression("^.*@(qq|163)\\.com$", ErrorMessage = "只支持QQ邮箱和163邮箱")]
public string email { get; set; }
}
B. 编写注册方法Register,用UserInfo接收
[HttpPost]
public string Register(UserInfo user)
{
return $"注册成功:userName:{user.userName},pwd:{user.pwd1},email:{user.email}";
}
C. 测试
①. 不填写userName,结果如图所示,校验不通过
②. 两次pwd不一致、邮箱格式不正确,结果如图所示,校验不通过
4. 配合axios测试
400 Bad Request 是由于明显的客户端错误(例如,格式错误的请求语法,太大的大小,无效的请求消息或欺骗性路由请求),服务器不能或不会处理该请求。
校验不通过,报400错误,进入的axios的catch中哦
代码如下:
详见 00-VueTest,注意:这里并没有改变request.js中的封装,只是在调用代码中通过catch获取打印了一下,届时根据实际情况考虑如何封装即可
<script setup name="xxxx">
import { myAxios1, myAxios2 } from "@/utils/request";
const myBaseUrl = "http://localhost:5244";
const checkData = async () => {
const result1 = await myAxios2({
baseURL: myBaseUrl,
url: "api/Test/Register",
method: "post",
data: {
userName: "ypf",
pwd1: "123456",
pwd2: "123456",
email: "ypf@qq.com",
},
}).catch(error => {
console.log(error.data.status);
console.log(error.data.errors);
});
console.log(" 结果为:" + result1);
};
</script>
运行结果:
5. 自定义扩展
(1). 新增 CheckperIdListAttribute 扩展类,继承ValidationAttribute特性,然后重写IsValid方法
局限:只能一种提示,IsValid方法中有多层判断,没办法根据不同的条件,进行不同的提示。
查看代码
/// <summary>
/// 自定义扩展perIdList字段的校验
/// (可以作用在属性、字段、参数上,同一载体不允许添加多个该特性)
/// 局限:只能一种提示,IsValid方法中有多层判断,没办法根据不同的条件,进行不同的提示。
/// </summary>
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = false)]
public class CheckperIdListAttribute : ValidationAttribute
{
public const string DefaultErrorMessage = "perIdList格式不正确";
public CheckperIdListAttribute() : base(DefaultErrorMessage) { }
/// <summary>
/// 重写检验规则
/// </summary>
/// <param name="value">校验内容</param>
/// <returns></returns>
public override bool IsValid(object value)
{
if (value == null)
{
return false;
}
string[] perIdList = value as string[];
//1. 非空校验
if (perIdList.Length == 0)
{
return false;
}
//2. 重复校验
else if (perIdList.Length != perIdList.Distinct().Count())
{
return false;
}
//3. 不能包含1的校验
else if (perIdList.Contains("1"))
{
return false;
}
else
{
return true;
}
}
}
(2). 加在属性上
可以自己走封装中的默认提示,也可以自定义提示
public class RoleInfo
{
public string roleName { get; set; }
public int roleAge { get; set; }
//[CheckperIdList] //走的默认提示
[CheckperIdList(ErrorMessage = "perIdList属性格式错了哦!!")]
public string[] perIdList { get; set; }
}
/// <summary>
/// 自定义扩展的校验规则
/// </summary>
/// <param name="role"></param>
/// <returns></returns>
[HttpPost]
public string AddRole1(RoleInfo role)
{
return $"添加成功:roleName:{role.roleName},roleAge:{role.roleAge},perIdList:{role.perIdList}";
}
(3). 加在参数上
/// <summary>
/// 自定义扩展的校验规则
/// </summary>
/// <returns></returns>
[HttpPost]
public string AddRole2([CheckperIdList]string[] perIdList)
{
return $"测试通过";
}
(4). 测试
三. FluentValidation
1.说明
用类似于EF Core中Fluent API的方式进行校验规则的配置,也就是我们可以把对模型类的校验放到单独的校验类中。
【官网:https://docs.fluentvalidation.net/en/latest/】
2.常用的校验方法
RuleFor:表示规则作用于哪个字段。
NotEmpty和NotNull:表示非空验证,其中NotEmpty更加严格,比如:null、空字符串、空格、空集合、类型的默认值 都认为格式错误。
WithMessage:用于提示客户端错误原因, 注意可以出现多次,加载哪个规则的后面则为谁提示。
MaximumLength 和 MinimumLength:最大长度和最小长度。
Length:长度验证。
Equal和NotEqual:相等 或 不相等。
Must:自定义验证规则,可以直接写,也可以传入一个验证函数。
when:条件验证。
GreaterThan:必须小于某个值
LessThan:必须小于某个值
....等等, 更多验证规则详见官网。
3.基本使用
A. 安装程序集【FluentValidation.AspNetCore 11.0.2】
B. 编写 UserInfoValidate校验类, 必须继承泛型类 AbstractValidator<T>, 其中T代表需要被校验的类
校验类代码
public class UserInfoValidate : AbstractValidator<UserInfo>
{
public UserInfoValidate()
{
//注意WithMessage跟在谁后面则为谁提示
RuleFor(x => x.userName).NotEmpty().WithMessage("userName不能为空")
.Length(4, 10).WithMessage("userName长度必须为4-10位");
RuleFor(x => x.pwd1).NotEmpty().WithMessage("pwd1不能为空")
.Length(3, 10).WithMessage("pwd1长度必须为3-10位");
RuleFor(x => x.pwd2).Equal(u => u.pwd1).WithMessage("pwd1和pwd2的值必须相同");
//RuleFor(x => x.email).Must(u => u.EndsWith("@163.com") || u.EndsWith("@qq.com")).WithMessage("仅支持qq邮箱和163邮箱");
RuleFor(x => x.email).Must(checkEmail).WithMessage("邮箱格式不正确");
}
/// <summary>
/// 自定义方法校验
/// </summary>
/// <param name="value"></param>
/// <returns></returns>
private bool checkEmail(string value)
{
if (value.Length > 4 && value.Contains("@"))
{
return true;
}
return false;
}
}
实体类代码
public class UserInfo
{
public string userName { get; set; }
public string pwd1 { get; set; }
public string pwd2 { get; set; }
public string email { get; set; }
}
C. 在Program通过反射的模式注册所有实现了AbstractValidator的校验规则类
//注册所有实现了AbstractValidator的校验规则类
builder.Services.AddFluentValidation(fv => {
Assembly assembly = Assembly.GetExecutingAssembly();
fv.RegisterValidatorsFromAssembly(assembly);
});
D.编写Register方法进行测试
① 漏掉userName,且两次密码不一致
② 测试通过
PS:这里返回值和内置验证相同,所以客户端如果用axios处理的模式也相同
[HttpPost]
public string Register(UserInfo user)
{
return $"注册成功:userName:{user.userName},pwd:{user.pwd1},email:{user.email}";
}
4. 注入服务
比如将MyDbContext先在Program中注册一下,然后在UserInfoValidate的构造函数中注入使用即可,详见代码
MyDbContext代码
public class MyDbContext
{
public List<string> name { get; set; }
public MyDbContext()
{
name = new List<string>()
{
"ypf1",
"ypf2",
"admin"
};
}
public bool isContains(string userName)
{
return this.name.Contains(userName);
}
}
注册代码
builder.Services.AddScoped<MyDbContext>();
构造函数注入代码
public UserInfoValidate(MyDbContext db)
{
//测试注入
RuleFor(x => x.userName).Must(db.isContains).WithMessage("该userName数据库中不存在");
RuleFor(x => x.pwd1).NotEmpty().WithMessage("pwd1不能为空")
.Length(3, 10).WithMessage("pwd1长度必须为3-10位");
RuleFor(x => x.pwd2).Equal(u => u.pwd1).WithMessage("pwd1和pwd2的值必须相同");
//RuleFor(x => x.email).Must(u => u.EndsWith("@163.com") || u.EndsWith("@qq.com")).WithMessage("仅支持qq邮箱和163邮箱");
RuleFor(x => x.email).Must(checkEmail).WithMessage("邮箱格式不正确");
}
5. 扩展规则+新写法
(1). 扩展类
/// <summary>
/// 扩展几个校验规则
/// </summary>
public static class EnumerableValidators
{
/// <summary>
/// 集合中没有重复元素
/// (集合为空,肯定没有重复元素)
/// </summary>
/// <returns></returns>
public static IRuleBuilderOptions<T, IEnumerable<TItem>> NotDuplicated<T, TItem>(this IRuleBuilder<T, IEnumerable<TItem>> ruleBuilder)
{
return ruleBuilder.Must(p => p == null || p.Distinct().Count() == p.Count());
}
/// <summary>
/// 集合中不包含指定的值value
/// (集合为空,肯定没有重复元素)
/// </summary>
/// <param name="value">待匹配的值</param>
/// <returns></returns>
public static IRuleBuilderOptions<T, IEnumerable<TItem>> NotContains<T, TItem>(this IRuleBuilder<T, IEnumerable<TItem>> ruleBuilder, TItem value)
{
return ruleBuilder.Must(p => p == null || !p.Contains(value));
}
}
(2). record类型+校验规则写在一起
/// <summary>
/// 角色信息
/// </summary>
/// <param name="roleName">角色名</param>
/// <param name="roleAge">角色年龄</param>
/// <param name="perIdList">权限数组</param>
public record RoleInfo(string roleName, int roleAge, string[] perIdList);
public class RoleInfoValidator : AbstractValidator<RoleInfo>
{
public RoleInfoValidator()
{
RuleFor(u => u.roleName).NotEmpty().WithMessage("角色名不能为空")
.Length(2, 6).WithMessage("角色名必须2-6位");
RuleFor(u => u.roleAge).GreaterThan(10).WithMessage("年龄必须大于10岁");
RuleFor(u => u.perIdList).NotDuplicated().WithMessage("权限id不能重复")
.NotContains("1").WithMessage("权限id中不能包含1");
}
}
(3). action方法
[HttpPost]
public string AddRole(RoleInfo role)
{
return $"添加成功:roleName:{role.roleName},roleAge:{role.roleAge},perIdList:{role.perIdList}";
}
(4). postMan测试
四. 程序发布部署
1. 两种发布模式
(1). 独立部署:将所需要的依赖环境和发布包一起打包起来
(2). 框架依赖:需要服务器上安装.Net环境
2. 部署环境
(1). windows+IIS:
(2). linux+nginx
(3). k8s
(4). Kestrel直接使用:做好的发布包在window环境下, 点击exe程序直接就能运行,这就是因为内置了Kestrel服务器的缘故
PS:尽管Kestrel已经强大到足以作为一个独立的Web服务器被使用了,但是一般仍然不会让Kestrel直接面对终端用户的请求
!
- 作 者 : Yaopengfei(姚鹏飞)
- 博客地址 : http://www.cnblogs.com/yaopengfei/
- 声 明1 : 如有错误,欢迎讨论,请勿谩骂^_^。
- 声 明2 : 原创博客请在转载时保留原文链接或在文章开头加上本人博客地址,否则保留追究法律责任的权利。