源宝导读:微软跨平台技术框架—.NET Core已经日趋成熟,已经具备了支撑大型系统稳定运行的条件。本文将介绍明源云ERP平台从.NET Framework向.NET Core迁移过程中的实践经验。
随着ERP的产品线越来越多,业务关联也日益复杂,应用间依赖关系也变得错综复杂,单体架构的弱点日趋明显。19年初,由于平台底层支持了分应用部署模式,将ERP从应用子系统层面进行了切割分离,迈出了从单体架构向微服务架构转型的坚实一步。不久的将来,ERP会进一步将各业务拆分成众多的微服务,而微服务势必需要进行容器化部署和运行管理,这就要求ERP技术底层必须支持跨平台,所以将现有ERP系统从.NET Framework迁移到 .NET Core平台势在必行。
前面我介绍了ERP的迁移的过程,整个Erp除了主站点之外,还有若干周边服务,我们本篇将讲述调度服务的迁移,调度服务因为功能比较简单,我们将已有功能做了重新的开发。
由于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的版本中重新做了梳理,采用了简单的分层结构,并且使用依赖注入,将接口和实现做了分离,便于以后进行扩展,下面是一个简单的架构图:
说明:
Host :启动工程,由于调度服务提供的功能比较简单(增,删,改,查,禁用,设置结果),所以这一层比较薄;
Manager :核心业务处理的工程,其中TaskFactory借鉴了DDD中领域工厂的概念,创建任务时候通过这个来解析数据创建任务对象,还需要负责加载Store中任务并放到ExecutorProvider中执行;
Store:即任务的配置文件存储,目前沿用原来的采用xml文件本地存储的方式。由于使用了接口定义所以很简单即可切换到数据库等其他存储引擎;
Common:一些通用帮助和功能的定义,本篇后续将重点介绍StrategyFactory部分;
Contract:定义了任务和执行引擎对外暴露的接口。
其中老版本调度任务没有Store的概念,直接使用xml文件存储。这种在服务器环境单机情况是没有问题的,但是当在docker环境中,由于docker的环境不同,如果在集群环境中,根据负载容灾的策略,可能会存在调度服务挂掉重新启动一个情况。这样无论你文件是存在docker中,或者映射到物理机中都会存在丢失情况,所以重新定义了接口是数据可以集中存储在数据库中,以免丢失。
这里将任务和调度引擎的对外接口定义到Contract,为了减少无论是调度任务还是执行引擎和调度服务宿主程序的耦合。针对调度引擎目前我们采用Quartz的方式,但是考虑到以后要支持集群模式,重新实现接口使用Hangfire实现即可实现集群的调度。而平台自定义的调度任务可能实现逻辑比较负责,单独定义一个接口作为执行入口也很有必要。
调度服务的核心逻辑就是任务的定时执行逻辑,我们使用Quartz来实现定时的任务调度,通过策略工厂来组织不同的任务来执行。
所有的任务都是通过TaskConfig这一个类来创建,TaskConfig是存储在Store的数据结构需要转换成不同类型的Task然后使用执行器进行执行,下面是执行器的类图:
说明:
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为了逻辑复用存在。
上述执行器的层次结构其实很像策略者模式,一般我们可以基于简单工厂就可以进行创建并使用,但是如果需要扩展的话难免会对工厂的代码做修改,这里我们定义了一个策略工厂来实现无需修改代码的扩展,下面是类图 :
说明:
IStragegyFactory
定义工厂的接口,TStrategy即工厂创建出来的策略;StragegyFactory
接口实现,用来使用TStrategyInitilizer获取策略类型并缓存,以及创建等逻辑;TStrategyInitilizer
策略初始化器,用来提供提供相关策略的类型;IStrategy策略的接口契约定义,主要是用来做泛型类型的限制。
在调度服务中我们IExecutor就是具体的策略,然后通过在对应的IExecutor子类上标记上StrategyAttribute,在程序集启动的时候扫描所有的类型继承自IExecutor,在StrategyFactory中获取StrategyAttribute的Description,缓存成策略-类型字典,然后在使用的时候传入策略,获取到类型,创建出对应的策略实例进行执行即可。
由于使用了反射机制,所以我们只要启动时候扫描程序集类型就可以加载新增加的策略,而无需修改代码,真正做到了对扩展开放,对更改关闭的开放封闭原则。
我们这里集成了.Net Core,所以StrategyFactory注入成单例生命周期,然后使用Ioc进行创建。如果是其他情况也建议是将策略工厂手动实现成单例,至于创建就可以使用.Net自带的Activator.CreateInstance。
我们使用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中创建执行策略进行执行即可。
针对Http请求可能由于网络超时原因失败,我们引入Polly进行了重试,这个主要应用在ApiExecutor和AsyncExecutor中。这里通过下面代码有个简单的了解:
var policy= Policy.Handle().Retry(10);
policy.Execute(() =>
{
// 执行http请求调用逻辑
}
我们针对http请求发送逻辑过程,如果产生超时异常,则进行重试10次。这里只是Polly的一个简单应用,Polly还广泛应用在熔断等分布式场景,这里只是个引子,有兴趣大家可以网上找找相关介绍。
dll版本兼容问题:在老板本中,由于平台未提供ApiExecutor,所以产品会写很多InProcessTask随调度任务一起发布,这样就会导致如果调度服务和产品开发所引用的dll冲突不好处理,在Framework版本中采用的是独立进程+应用程序域来解决的,而新版本中,我们规范了产品无法开发InProcessTask,这样所有的dll版本都在平台管控中;
git仓库散乱:在本次改造过程中,还将所有自定义的任务全部合并到一个仓库之中,并且配合脚本进行整体发布,这样避免以前发布一个调度任务需要人工多次操作之后的方式,直接一键完成;
写日志的问题:这里我们引入Serilog,在不同的任务写日志的时候,根据目录和任务标识,创建不同的日志对象来写日志,保证各个任务的日志之间不会相互影响;
多进程的静态变量:在之前多进程的执行方式中存在静态的变量,因为是不同执行在不同进程所以不会出现变量值被覆盖问题。这里全部做了改进,能使用Ioc就是用Ioc解决,不能通过Ioc也尽量通过单例解决;
多进程任务管理:之前多进程情况任务会难以关闭,并且如果结束调度服务进程之后还会有执行进程在运行,导致不可预期的结果,这一次采用Quartz之后,其本身提供了对应的api来管理作业,并且任务之间也是隔离的,所以这一次没有采用多进程方式进行执行。
在整个调度任务的改造过程中发现了很多类同行问题,这里做了一个总结:
不要自己造轮子:重试、定时执行日志,这些之前都是自己手写的,但是一直出问题一直改,使用开源成熟组件,简单省心;
软件生命周期:一个需要长时间维护的项目,一定需要根据职责划分一个清晰的层次结构,这样维护起来才不会导致大量臃肿的代码。
这一篇是这个系列的第六篇文章,加上前面几篇文章,几乎介绍了这次.Net Core改造的方方面面,最后一篇我们将介绍最后的发布部署。
------ END ------
作者简介
熊同学: 研发工程师,目前负责ERP运行平台的设计与开发工作。
也许您还想看
【复杂系统迁移 .NET Core平台系列】之WebApi改造
【复杂系统迁移 .NET Core平台系列】之认证和授权
【复杂系统迁移 .NET Core平台系列】之迁移项目工程
【复杂系统迁移 .NET Core平台系列】之界面层
【复杂系统迁移 .NET Core平台系列】之静态文件