第四十六节:后台托管服务(不同生命周期相互注入方案)、数据校验规则(内置、 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 : 原创博客请在转载时保留原文链接或在文章开头加上本人博客地址,否则保留追究法律责任的权利。
 
posted @ 2022-05-30 20:40  Yaopengfei  阅读(407)  评论(3编辑  收藏  举报