【复杂系统迁移 .NET Core平台系列】之调度服务改造

【复杂系统迁移 .NET Core平台系列】之调度服务改造_第1张图片

源宝导读:微软跨平台技术框架—.NET Core已经日趋成熟,已经具备了支撑大型系统稳定运行的条件。本文将介绍明源云ERP平台从.NET Framework向.NET Core迁移过程中的实践经验。

一、背景

    随着ERP的产品线越来越多,业务关联也日益复杂,应用间依赖关系也变得错综复杂,单体架构的弱点日趋明显。19年初,由于平台底层支持了分应用部署模式,将ERP从应用子系统层面进行了切割分离,迈出了从单体架构向微服务架构转型的坚实一步。不久的将来,ERP会进一步将各业务拆分成众多的微服务,而微服务势必需要进行容器化部署和运行管理,这就要求ERP技术底层必须支持跨平台,所以将现有ERP系统从.NET Framework迁移到 .NET Core平台势在必行。

    前面我介绍了ERP的迁移的过程,整个Erp除了主站点之外,还有若干周边服务,我们本篇将讲述调度服务的迁移,调度服务因为功能比较简单,我们将已有功能做了重新的开发。

二、Windows服务

    由于IIS的定期回收机制,所以调度服务这类需要一直在后台运行的应用我们采用Windows服务的方式来运行。并且由于启用.Net Core的目的也是为了支持容器化,所以也支持控制台的方式运行。这里我们采用在Main函数中加入参数的方式进行启动,即可解决上述问题,下面是示例代码:

class Program
{
    public static void Main(string[] args)
    {
        var isService = !(Debugger.IsAttached || args.Contains("--console"));
         
        var builder = CreateWebHostBuilder(args.Where(arg => arg != "--console").ToArray());
 
        var host = builder.Build();
 
        if (isService)
        {
            //设置当前目录
            var processModule = Process.GetCurrentProcess().MainModule;
            if (processModule != null)
            {
                var pathToExe = processModule.FileName;
                var pathToContentRoot = Path.GetDirectoryName(pathToExe);
                Directory.SetCurrentDirectory(pathToContentRoot);
                Console.WriteLine(pathToContentRoot);
            }
            var webHostService = new SchedulerWebHostService(host);
            ServiceBase.Run(webHostService);//以服务方式运行
        }
        else
        {
            host.Run();//以控制台方式运行
        }
    }
 
    private static IWebHostBuilder CreateWebHostBuilder(string[] args)
    {
        return WebHost.CreateDefaultBuilder(args)
                //接入Serilog
                .UseSerilog((hostingContext, loggerConfiguration) => loggerConfiguration
                    .ReadFrom.Configuration(hostingContext.Configuration))
                .UseStartup();
    }
}
 
internal class SchedulerWebHostService : WebHostService
{
    private ILogger _logger;
 
    public SchedulerWebHostService(IWebHost host) : base(host)
    {
        _logger = host.Services
            .GetRequiredService>();
    }
 
    protected override void OnStarting(string[] args)
    {
        _logger.LogInformation("OnStarting method called.");
        base.OnStarting(args);
    }
 
    protected override void OnStarted()
    {
        _logger.LogInformation("OnStarted method called.");
        base.OnStarted();
    }
 
    protected override void OnStopping()
    {
        _logger.LogInformation("OnStopping method called.");
        base.OnStopping();
    }
}

    Docker和Debug模式采用Console方式运行,只需要在启动的时候增加—console参数即可,Windows服务的话只需要使用系统的sc命令创建启动服务即可。

三、架构优化

    原来的调度服务因为历史发展的原因,结构比较混乱,在Core的版本中重新做了梳理,采用了简单的分层结构,并且使用依赖注入,将接口和实现做了分离,便于以后进行扩展,下面是一个简单的架构图:

【复杂系统迁移 .NET Core平台系列】之调度服务改造_第2张图片

说明:

  • Host :启动工程,由于调度服务提供的功能比较简单(增,删,改,查,禁用,设置结果),所以这一层比较薄;

  • Manager :核心业务处理的工程,其中TaskFactory借鉴了DDD中领域工厂的概念,创建任务时候通过这个来解析数据创建任务对象,还需要负责加载Store中任务并放到ExecutorProvider中执行;

  • Store:即任务的配置文件存储,目前沿用原来的采用xml文件本地存储的方式。由于使用了接口定义所以很简单即可切换到数据库等其他存储引擎;

  • Common:一些通用帮助和功能的定义,本篇后续将重点介绍StrategyFactory部分;

  • Contract:定义了任务和执行引擎对外暴露的接口。

    其中老版本调度任务没有Store的概念,直接使用xml文件存储。这种在服务器环境单机情况是没有问题的,但是当在docker环境中,由于docker的环境不同,如果在集群环境中,根据负载容灾的策略,可能会存在调度服务挂掉重新启动一个情况。这样无论你文件是存在docker中,或者映射到物理机中都会存在丢失情况,所以重新定义了接口是数据可以集中存储在数据库中,以免丢失。

    这里将任务和调度引擎的对外接口定义到Contract,为了减少无论是调度任务还是执行引擎和调度服务宿主程序的耦合。针对调度引擎目前我们采用Quartz的方式,但是考虑到以后要支持集群模式,重新实现接口使用Hangfire实现即可实现集群的调度。而平台自定义的调度任务可能实现逻辑比较负责,单独定义一个接口作为执行入口也很有必要。

四、调度引擎

    调度服务的核心逻辑就是任务的定时执行逻辑,我们使用Quartz来实现定时的任务调度,通过策略工厂来组织不同的任务来执行。

4.1、任务执行器

    所有的任务都是通过TaskConfig这一个类来创建,TaskConfig是存储在Store的数据结构需要转换成不同类型的Task然后使用执行器进行执行,下面是执行器的类图:

【复杂系统迁移 .NET Core平台系列】之调度服务改造_第3张图片

说明:

  • IExecutor定义了两个方法 Init用来初始化,Run用来执行;

  • BaseExecutor类似模板方法定义了执行的逻辑;

  • ApiExecutor 用来执行Http请求;

  • AsyncExecutor用来执行异步任务的请求,也是通过Http方法执行,区别在于执行的是固定url,并且需要回调调度任务告诉执行结果;

  • SqlExecutor用来执行sql任务;

  • InProecessExecutor用来实现自定义的执行逻辑,例如数据分发,日志清理等等。

    在整个体系中最重要就是BaseExecutor的逻辑,因为它定义了整个执行的逻辑,而其他任务只是不同的实现方式而已,下面我们稍微分析一下其实现接口的init和run方法:

public virtual void Init(TaskConfig taskConfig)
{
    _taskConfig = taskConfig;
    Logger = _builder.GetLogger(taskConfig.TaskName, Path.GetDirectoryName(_taskConfig.ConfigFilePath));
    Task = new TTask
    {
        TaskGuid = taskConfig.TaskGuid,
        TaskName = taskConfig.TaskName,
        CreateBy = taskConfig.CreateBy,
        CreateTime = taskConfig.CreateTime,
        ConfigFilePath = taskConfig.ConfigFilePath,
        Description = taskConfig.Description,
        Triggers = taskConfig.Triggers,
        Status = taskConfig.Status,
    };
    InnerInit(taskConfig);
}
 
public void Run()
{
    DateTime startTime = Clock.Now;
    try
    {
        Begin();
        InnerRun();
        Finish(startTime);
    }
    catch (SchedulingException ex) /* 记录回调调度服务的错误,写入日志 */
    {
        var errorMsg = "执行任务发生异常,详情:" + ex.Message;
        Error(errorMsg, startTime, ex);
    }
    catch (Exception ex)
    {
        var errorMsg = "执行任务发生异常,详情:" + ex.Message;
        Error(errorMsg, startTime, ex);
    }
}
  • 基于Init的方法主要目的是为了初始化Task类的通用属性,子类只需要实现InnerInit实现自己的数据进行赋值就好了;

  • Run方法只要实现了日志记录和执行时间的统计,而具体的执行放到InnerRun里面去实现;

  • 总体来说Init方法为了代码复用存在,Run为了逻辑复用存在。

4.2、策略工厂

    上述执行器的层次结构其实很像策略者模式,一般我们可以基于简单工厂就可以进行创建并使用,但是如果需要扩展的话难免会对工厂的代码做修改,这里我们定义了一个策略工厂来实现无需修改代码的扩展,下面是类图 :

【复杂系统迁移 .NET Core平台系列】之调度服务改造_第4张图片

说明:

  • IStragegyFactory

    定义工厂的接口,TStrategy即工厂创建出来的策略;
  • StragegyFactory

    接口实现,用来使用TStrategyInitilizer获取策略类型并缓存,以及创建等逻辑;
  • TStrategyInitilizer

    策略初始化器,用来提供提供相关策略的类型;
  • IStrategy策略的接口契约定义,主要是用来做泛型类型的限制。

    在调度服务中我们IExecutor就是具体的策略,然后通过在对应的IExecutor子类上标记上StrategyAttribute,在程序集启动的时候扫描所有的类型继承自IExecutor,在StrategyFactory中获取StrategyAttribute的Description,缓存成策略-类型字典,然后在使用的时候传入策略,获取到类型,创建出对应的策略实例进行执行即可。

    由于使用了反射机制,所以我们只要启动时候扫描程序集类型就可以加载新增加的策略,而无需修改代码,真正做到了对扩展开放,对更改关闭的开放封闭原则。

    我们这里集成了.Net Core,所以StrategyFactory注入成单例生命周期,然后使用Ioc进行创建。如果是其他情况也建议是将策略工厂手动实现成单例,至于创建就可以使用.Net自带的Activator.CreateInstance。

4.3、Quartz

    我们使用Quartz作为定时执行的触发器,由于其相关内容也比较多,我们这里讲述下我这里的使用,在QuartZ中有三个重要元素,执行计划,执行的作业和执行的策略,首先来看看代码:

//执行的作业
public class Job: IJob
{
    System.Threading.Tasks.Task IJob.Execute(IJobExecutionContext context)
    {
        var executor = context.JobDetail.JobDataMap.Get("JobExecutor") as JobExecutor;
        executor?.Action();
        return System.Threading.Tasks.Task.CompletedTask;
 
    }
}
//执行器
public class JobExecutor
{
    public Action Action { get; set; }
}
 
//执行引擎
public class ExecutorProvider : IExecutorProvider
{
    //启动任务
    public void Start(TaskConfig taskConfig)
    {
        //构造job执行器
        var jobExecutor = new JobExecutor
        {
            Action = () =>
            {
                
                var strategy = _factory.GetStrategy(taskConfig.Type);
                strategy.Init(taskConfig);
                AssemblyHelper.LoadAssemblies(Path.GetDirectoryName(taskConfig.ConfigFilePath), SearchOption.TopDirectoryOnly);
                strategy.Run();
            }
        };
        //将执行作业添加到执行计划
        IJobDetail job = new JobDetailImpl(taskConfig.TaskGuid.ToString(), taskConfig.Type, typeof(Job));
        job.JobDataMap.Put("JobExecutor", jobExecutor);
        _scheduler.ScheduleJob(job, CreateTrigger(taskConfig));
    }
 
    // 根据Cron表达式创建执行策略
    private ITrigger CreateTrigger(TaskConfig taskConfig)
    {
        //cronExpression = "1/1 * * * * ? ";//1秒执行一次
        var triggerBuilder = TriggerBuilder.Create()
            .WithCronSchedule(taskConfig.Triggers.First())
            .WithIdentity(taskConfig.TaskGuid.ToString())
            .StartAt(DateTime.Now);
        return triggerBuilder.Build();
    }
}
 
//启动所有的任务
public static IServiceCollection StartAllTask(this IServiceCollection services)
{
    var provider = services.BuildServiceProvider();
    provider.GetService().StartAll();
    StrategyInitializer.SetServices(provider);
    var scheduler = StdSchedulerFactory.GetDefaultScheduler().Result;
    scheduler.Start();
    return services;
}

    在上述代码中,整个逻辑其实分为两段在ExecutorProvider中我们定义了任务使用QuartzJob进行执行的逻辑, 在StartUp的ConfigureServices的最后调用服务获取store中的task进行执行。

    在创建Job过程中,因为我们是进程内执行,所以直接使用委托进行传递参数,如果是后续考虑到分布式环境运行,则需要将任务参数传递然后再Job中创建执行策略进行执行即可。

4.4、Http请求重试

    针对Http请求可能由于网络超时原因失败,我们引入Polly进行了重试,这个主要应用在ApiExecutor和AsyncExecutor中。这里通过下面代码有个简单的了解:

var policy= Policy.Handle().Retry(10);
policy.Execute(() =>
{
    // 执行http请求调用逻辑
}

    我们针对http请求发送逻辑过程,如果产生超时异常,则进行重试10次。这里只是Polly的一个简单应用,Polly还广泛应用在熔断等分布式场景,这里只是个引子,有兴趣大家可以网上找找相关介绍。

五、遇到的问题

  1. dll版本兼容问题:在老板本中,由于平台未提供ApiExecutor,所以产品会写很多InProcessTask随调度任务一起发布,这样就会导致如果调度服务和产品开发所引用的dll冲突不好处理,在Framework版本中采用的是独立进程+应用程序域来解决的,而新版本中,我们规范了产品无法开发InProcessTask,这样所有的dll版本都在平台管控中;

  2. git仓库散乱:在本次改造过程中,还将所有自定义的任务全部合并到一个仓库之中,并且配合脚本进行整体发布,这样避免以前发布一个调度任务需要人工多次操作之后的方式,直接一键完成;

  3. 写日志的问题:这里我们引入Serilog,在不同的任务写日志的时候,根据目录和任务标识,创建不同的日志对象来写日志,保证各个任务的日志之间不会相互影响;

  4. 多进程的静态变量:在之前多进程的执行方式中存在静态的变量,因为是不同执行在不同进程所以不会出现变量值被覆盖问题。这里全部做了改进,能使用Ioc就是用Ioc解决,不能通过Ioc也尽量通过单例解决;

  5. 多进程任务管理:之前多进程情况任务会难以关闭,并且如果结束调度服务进程之后还会有执行进程在运行,导致不可预期的结果,这一次采用Quartz之后,其本身提供了对应的api来管理作业,并且任务之间也是隔离的,所以这一次没有采用多进程方式进行执行。

六、总结

    在整个调度任务的改造过程中发现了很多类同行问题,这里做了一个总结:

  1. 不要自己造轮子:重试、定时执行日志,这些之前都是自己手写的,但是一直出问题一直改,使用开源成熟组件,简单省心;

  2. 软件生命周期:一个需要长时间维护的项目,一定需要根据职责划分一个清晰的层次结构,这样维护起来才不会导致大量臃肿的代码。

    这一篇是这个系列的第六篇文章,加上前面几篇文章,几乎介绍了这次.Net Core改造的方方面面,最后一篇我们将介绍最后的发布部署。


------ END ------

作者简介

熊同学: 研发工程师,目前负责ERP运行平台的设计与开发工作。

也许您还想看

【复杂系统迁移 .NET Core平台系列】之WebApi改造

【复杂系统迁移 .NET Core平台系列】之认证和授权

【复杂系统迁移 .NET Core平台系列】之迁移项目工程

【复杂系统迁移 .NET Core平台系列】之界面层

【复杂系统迁移 .NET Core平台系列】之静态文件

你可能感兴趣的:(【复杂系统迁移 .NET Core平台系列】之调度服务改造)