ASP.NET Core 定时任务怎么做?FreeScheduler 可视化调度实战

发布时间:2026/10/8 2:09:32
ASP.NET Core 定时任务怎么做?FreeScheduler 可视化调度实战 后台里总有一些每天凌晨跑一次每月 1 号清理数据的需求。EasyAdminBlazor 2.3 的做法是底层用 FreeScheduler 做调度与持久化上层提供统一接口和可视化页面。一、接入builder.AddEasyAdminBlazor(newEasyAdminBlazorOptions{...}).AddEasyAdminBlazorScheduler();publicstaticWebApplicationBuilderAddEasyAdminBlazorScheduler(thisWebApplicationBuilderbuilder){builder.Services.AddSingletonEasyAdminBlazor.ISchedulerService(spnewDefaultSchedulerService(sp));// 注册 FreeScheduler.Scheduler 供直接使用了 FreeScheduler 类型的页面如 TaskScheduler.razor注入builder.Services.AddSingleton(sp((DefaultSchedulerService)sp.GetRequiredServiceEasyAdminBlazor.ISchedulerService()).GetInternalScheduler());returnbuilder;}核心包默认注册的是空的NullSchedulerService// AdminExtensions.csbuilder.Services.TryAddSingletonISchedulerService,NullSchedulerService();它的IsAvailable为 false框架里依赖调度的页面比如任务管理页会据此隐藏或重定向varschedulerServiceProvider.GetRequiredServiceISchedulerService();if(!scheduler.IsAvailablepath.Contains(taskscheduler)){admin.Redirect(/Admin/);return;}所以定时任务是可选扩展不装它系统照常运行只是没有调度能力。二、调度器初始化时做了什么publicDefaultSchedulerService(IServiceProviderserviceProvider){varoptionsserviceProvider.GetRequiredServiceIOptionsEasyAdminBlazor.EasyAdminBlazorOptions().Value;// 调度器是 Singleton 后台服务使用主库 ORM不能解析 Scoped 的 IFreeSqlvarfsqlserviceProvider.GetRequiredServiceMainOrmHandle().Orm;EnsureSchedulerTableMapping(fsql);BuildSchedulerAttributeTriggers(options,fsql);varscheduleTimeZoneResolveScheduleTimeZone(serviceProvider);_schedulernewFreeSchedulerBuilder().OnExecuting(task{Console.WriteLine($[{DateTime.Now:HH:mm:ss.fff}]{task.Topic}被执行);if(_schedulerAttributeTriggers.TryGetValue(task.Topic,outvartrigger)){trigger(serviceProvider);return;}options.SchedulerExecuting?.Invoke(serviceProvider,MapToData(task));}).UseTimeZone(scheduleTimeZone).UseStorage(fsql).UseCustomInterval(task{try{varnowDateTime.UtcNow;varnextTimeCrontabSchedule.Parse(task.IntervalArgument,newCrontabSchedule.ParseOptions{IncludingSecondstrue}).GetNextOccurrence(now);if(nextTimenow)returnTimeSpan.FromSeconds(5);returnnextTime.Subtract(now);}catch{// 非法 Cron 表达式避免调度器崩溃退化为 5 秒后重试returnTimeSpan.FromSeconds(5);}}).Build();}四个关键点必须用主库 ORMMainOrmHandle.Orm。调度器是单例后台服务直接解析 Scoped 的IFreeSql会踩生命周期问题源码注释明确写了这一点。UseStorage(fsql)任务和日志持久化到数据库重启不丢。OnExecuting优先匹配特性注册的任务[Scheduler]其次走options.SchedulerExecuting回调。UseCustomInterval用 NCrontab 计算下次执行时间支持含秒的 6 段 Cron。三、时区三级解析/// summary/// 解析调度器时区。////// 优先级Scheduler:TimeZoneIdIANA/Windows 时区名/// → Scheduler:UtcOffsetHours小时偏移/// → 默认 8保持既有中国时区行为避免破坏现有项目的既有计划任务。/// /summaryprivatestaticTimeSpanResolveScheduleTimeZone(IServiceProviderserviceProvider){varconfigurationserviceProvider.GetServiceMicrosoft.Extensions.Configuration.IConfiguration();// 1) 优先按 IANA/Windows 时区名解析可正确处理夏令时vartimeZoneIdconfiguration?[Scheduler:TimeZoneId];if(!string.IsNullOrWhiteSpace(timeZoneId)){try{returnTimeZoneInfo.FindSystemTimeZoneById(timeZoneId).BaseUtcOffset;}catch(Exceptionex)when(exisTimeZoneNotFoundExceptionorInvalidTimeZoneException){Console.WriteLine($[EasyAdminBlazor] Scheduler:TimeZoneId 无效{timeZoneId}回退到偏移配置);}}// 2) 其次按小时偏移配置varoffsetTextconfiguration?[Scheduler:UtcOffsetHours];if(!string.IsNullOrWhiteSpace(offsetText)double.TryParse(offsetText,System.Globalization.NumberStyles.Float,System.Globalization.CultureInfo.InvariantCulture,outvarhours)hoursis-14and14){returnTimeSpan.FromHours(hours);}// 3) 默认保持现有的中国时区行为returnTimeSpan.FromHours(8);}{Scheduler:{TimeZoneId:Asia/Shanghai,UtcOffsetHours:null}}为什么默认是 8 而不是 UTC源码注释给了答案保持既有行为避免升级后老项目的计划任务时间全部偏移。如果你的服务器在别的时区显式配置TimeZoneId即可。四、Cron 表达式与校验添加任务时会先校验 CronpublicstringAddTask(stringtopic,stringbody,intround,SchedulerIntervalinterval,stringargument){if(intervalSchedulerInterval.Custom!IsValidCronExpression(argument)){thrownewArgumentException(Cron 表达式无效请检查格式秒 分 时 日 月 周,nameof(argument));}returnFreeScheduler.Datafeed.AddTask(_scheduler,topic,body,round,(FreeScheduler.TaskInterval)(int)interval,argument);}privatestaticboolIsValidCronExpression(string?expression){if(string.IsNullOrWhiteSpace(expression))returnfalse;try{CrontabSchedule.Parse(expression,newCrontabSchedule.ParseOptions{IncludingSecondstrue});returntrue;}catch{returnfalse;}}格式是 6 段、含秒秒 分 时 日 月 周。0 0 0 1 * * → 每月 1 号 00:00:00 0 0/5 * * * * → 每 5 分钟 0 30 8 * * 1-5 → 工作日 08:30调度间隔的类型publicenumSchedulerInterval{/// summary按秒触发/summarySeconds1,/// summary每天固定时间触发如 15:55:59/summaryRunOnDay11,/// summary每星期几固定时间触发如 2:15:55:59/summaryRunOnWeek12,/// summary每月第几天固定时间触发如 5:15:55:59/summaryRunOnMonth13,/// summary自定义 Cron 表达式/summaryCustom21,}枚举值与 FreeScheduler 的TaskInterval数值对齐所以转换就是一次强转(FreeScheduler.TaskInterval)(int)interval。五、用代码注册任务[Scheduler]特性仓库里有一个真实用例/// summary/// 定时清理过期错误日志/// /summaryinternalstaticclassErrorLogCleanupJob{/// summary/// 每月 1 号凌晨清理一个月前的错误日志/// /summary[Scheduler(清理错误日志,0 0 0 1 * *)]internalstaticvoidClearErrorLogs(IServiceProviderservice){System.Console.WriteLine(清理错误日志 被触发...);varscopeFactoryservice.GetRequiredServiceIServiceScopeFactory();usingvarscopescopeFactory.CreateScope();varreposcope.ServiceProvider.GetServiceIBaseRepositorySysLog();if(repo!null){repo.Delete(xx.CreatedTimeDateTime.Now.AddMonths(-1));}}}特性接受两种构造方式[AttributeUsage(AttributeTargets.Method)]publicclassSchedulerAttribute:Attribute{publicstringName{get;set;}string.Empty;publicSchedulerIntervalInterval{get;set;}publicstringArgument{get;set;}string.Empty;publicintRound{get;set;}-1;publicSchedulerTaskStatusStatus{get;set;}publicSchedulerAttribute(stringname){this.Namename;}publicSchedulerAttribute(stringname,stringcron){this.Namename;this.IntervalSchedulerInterval.Custom;this.Argumentcron;}}启动时会扫描EasyAdminBlazorOptions.Assemblies里的静态方法并同步到任务表vartaskInfonewSchedTaskInfo{Topic$[SchedulerAttribute]{attr.Name},Interval(FreeScheduler.TaskInterval)(int)attr.Interval,IntervalArgumentattr.Argument,Roundattr.Round,Status(FreeScheduler.TaskStatus)(int)attr.Status,Bodystring.Empty,CreateTimeDateTime.Now,CurrentRound0,ErrorTimes0,LastRunTimenewDateTime(1970,1,1),};同步策略是保留运行状态、只更新定义varexistingfsql.SelectSchedTaskInfo().Where(aa.Topic.StartsWith([SchedulerAttribute])).ToList();foreach(varentryinallSchedulerMethods){varfindexisting.Find(aa.Topicentry.TaskInfo.Topic);if(find!null){entry.TaskInfo.Idfind.Id;entry.TaskInfo.Bodyfind.Body;entry.TaskInfo.CreateTimefind.CreateTime;entry.TaskInfo.CurrentRoundfind.CurrentRound;entry.TaskInfo.ErrorTimesfind.ErrorTimes;entry.TaskInfo.LastRunTimefind.LastRunTime;}else{entry.TaskInfo.Id${DateTime.Now:yyyyMMdd}.{YitIdHelper.NextId()};}}varrepofsql.GetRepositorySchedTaskInfo();repo.BeginEdit(existing);repo.EndEdit(allSchedulerMethods.Select(aa.TaskInfo).ToList());已存在的任务复用原 Id、保留执行次数与最后运行时间新任务生成新 Id。这样重启不会把任务的运行历史清零。特性任务的 Topic 带[SchedulerAttribute]前缀用来和页面手动创建的任务区分开——所以管理页里能看到哪些是代码定义的、哪些是后台加的。六、可视化管理页面/Admin/TaskScheduler用的是 BootstrapBlazor 的Table不是AdminTable因为任务数据来自调度器而不是业务表Table reftable TItemSchedulerTaskData EditDialogSizeSize.Large IsPaginationtrue IsStripedtrue IsBorderedtrue IsMultipleSelecttrue ShowToolbartrue ShowExtendButtonstrue ShowEditButtonfalse ShowExtendEditButtonfalse OnQueryAsyncOnQueryAsync OnSaveAsyncOnSaveAsync OnDeleteAsyncOnDeleteAsync页面提供的能力操作实现分页查询Scheduler.GetTasks(pageIndex, pageItems)新建任务OnSaveAsync→Scheduler.AddTask(topic, body, round, interval, argument)删除任务OnDeleteAsync→Scheduler.RemoveTask(id)暂停 / 恢复PauseTask/ResumeTask带[OperationLog(暂停了任务)]等审计标注立即触发RunNowTask查看日志弹窗内嵌TableSchedulerTaskLog调GetTaskLogs其中几个方法都标了操作日志特性[OperationLog(恢复了任务)]asyncTaskResumeTask(SchedulerTaskDatatask){Scheduler.ResumeTask(task.Id);awaittable.QueryAsync(1);}管理操作会被记进操作日志这在生产排查时很有用“谁在什么时候暂停了清理任务”。七、日志与失败信息任务执行结果由调度器记录映射成统一的SchedulerTaskLogprivatestaticSchedulerTaskLogMapToLog(FreeScheduler.TaskLoglog){returnnewSchedulerTaskLog{TaskIdlog.TaskId,Roundlog.Round,ElapsedMillisecondslog.ElapsedMilliseconds,Successlog.Success,Exceptionlog.Exception,Remarklog.Remark,CreateTimelog.CreateTime};}字段用途Round第几轮执行ElapsedMilliseconds耗时Success是否成功Exception异常信息StringLength -1不截断Remark备注任务本身还带ErrorTimes累计失败次数和Status运行中 / 已暂停 / 已结束列表页直接展示便于快速发现一直在失败的任务。八、多实例部署要注意什么任务定义和执行状态都在数据库里FreeScheduler_task/FreeScheduler_tasklog所以多实例共享同一份任务清单。同一时刻只有一个实例真正执行任务这一点由 FreeScheduler 的存储与抢占机制保证如果对执行频率有强要求建议在任务体内部再做一次幂等保护例如用分布式锁见第 19 篇。任务体要自己创建 Scope。看ErrorLogCleanupJob的写法varscopeFactoryservice.GetRequiredServiceIServiceScopeFactory();usingvarscopescopeFactory.CreateScope();varreposcope.ServiceProvider.GetServiceIBaseRepositorySysLog();任务由单例调度器触发直接解析 Scoped 服务会出问题。时区要统一配置。多实例部署在不同时区时Scheduler:TimeZoneId必须一致否则同一个 Cron 会在不同实例上解释成不同时间。九、常见问题现象原因处理菜单里没有任务计划未安装 Scheduler 扩展调用AddEasyAdminBlazorScheduler()页面被重定向回首页ISchedulerService.IsAvailable false同上添加任务报Cron 表达式无效段数不对或语法错误使用 6 段格式秒 分 时 日 月 周任务时间比预期差 8 小时时区配置设置Scheduler:TimeZoneId重启后任务丢失未使用UseStorage被改动过确认DefaultSchedulerService未被修改代码里加了[Scheduler]但没出现所在程序集不在EasyAdminBlazorOptions.Assemblies里把程序集加进去任务报错但没有日志任务体内部吞了异常让异常抛出调度器会记录到Scheduler_tasklog十、小结EasyAdminBlazor 的定时任务可以理解成三层层内容抽象层ISchedulerServiceNullSchedulerService不装扩展也能编译运行引擎层FreeScheduler 数据库持久化 时区 Cron 校验管理层/Admin/TaskScheduler可视化页面 操作日志 执行日志再加一个实用的代码注册方式[Scheduler(名称, cron)]就覆盖了后台定时任务的两类来源代码里写死的周期性任务和运营人员在页面上临时加的任务。如果你正在用 .NET 10 Blazor 做后台需要每天/每月跑一次的调度能力可以看看 EasyAdminBlazor 的 Scheduler 扩展任务持久化、可视化启停、执行日志、时区配置都开箱可用。文档https://easyadmin.wang-zhan.com.cn/doc源码https://gitee.com/gudufy/EasyAdminBlazor

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询