一、接入
builder.AddEasyAdminBlazor(new EasyAdminBlazorOptions { ... })
.AddEasyAdminBlazorScheduler();
public static WebApplicationBuilder AddEasyAdminBlazorScheduler(this WebApplicationBuilder builder)
{
builder.Services.AddSingleton<EasyAdminBlazor.ISchedulerService>(sp => new DefaultSchedulerService(sp));
// 注册 FreeScheduler.Scheduler 供直接使用了 FreeScheduler 类型的页面(如 TaskScheduler.razor)注入
builder.Services.AddSingleton(sp =>
((DefaultSchedulerService)sp.GetRequiredService<EasyAdminBlazor.ISchedulerService>()).GetInternalScheduler());
return builder;
}
核心包默认注册的是空的 NullSchedulerService:
// AdminExtensions.cs
builder.Services.TryAddSingleton<ISchedulerService, NullSchedulerService>();
它的 IsAvailable 为 false,框架里依赖调度的页面(比如任务管理页)会据此隐藏或重定向:
var scheduler = ServiceProvider.GetRequiredService<ISchedulerService>();
if (!scheduler.IsAvailable && path.Contains("taskscheduler"))
{
admin.Redirect("/Admin/");
return;
}
所以定时任务是可选扩展:不装它,系统照常运行,只是没有调度能力。
二、调度器初始化时做了什么
public DefaultSchedulerService(IServiceProvider serviceProvider)
{
var options = serviceProvider.GetRequiredService<IOptions<EasyAdminBlazor.EasyAdminBlazorOptions>>().Value;
// 调度器是 Singleton 后台服务,使用主库 ORM(不能解析 Scoped 的 IFreeSql)
var fsql = serviceProvider.GetRequiredService<MainOrmHandle>().Orm;
EnsureSchedulerTableMapping(fsql);
BuildSchedulerAttributeTriggers(options, fsql);
var scheduleTimeZone = ResolveScheduleTimeZone(serviceProvider);
_scheduler = new FreeSchedulerBuilder()
.OnExecuting(task =>
{
Console.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] {task.Topic} 被执行");
if (_schedulerAttributeTriggers.TryGetValue(task.Topic, out var trigger))
{
trigger(serviceProvider);
return;
}
options.SchedulerExecuting?.Invoke(serviceProvider, MapToData(task));
})
.UseTimeZone(scheduleTimeZone)
.UseStorage(fsql)
.UseCustomInterval(task =>
{
try
{
var now = DateTime.UtcNow;
var nextTime = CrontabSchedule.Parse(task.IntervalArgument, new CrontabSchedule.ParseOptions { IncludingSeconds = true }).GetNextOccurrence(now);
if (nextTime < now) return TimeSpan.FromSeconds(5);
return nextTime.Subtract(now);
}
catch
{
// 非法 Cron 表达式:避免调度器崩溃,退化为 5 秒后重试
return TimeSpan.FromSeconds(5);
}
})
.Build();
}
四个关键点:
- 必须用主库 ORM(
MainOrmHandle.Orm)。调度器是单例后台服务,直接解析 Scoped 的IFreeSql会踩生命周期问题(源码注释明确写了这一点)。 UseStorage(fsql):任务和日志持久化到数据库,重启不丢。OnExecuting优先匹配特性注册的任务([Scheduler]),其次走options.SchedulerExecuting回调。UseCustomInterval用 NCrontab 计算下次执行时间,支持含秒的 6 段 Cron。
三、时区:三级解析
/// <summary>
/// 解析调度器时区。
///
/// 优先级:Scheduler:TimeZoneId(IANA/Windows 时区名)
/// → Scheduler:UtcOffsetHours(小时偏移)
/// → 默认 +8(保持既有中国时区行为,避免破坏现有项目的既有计划任务)。
/// </summary>
private static TimeSpan ResolveScheduleTimeZone(IServiceProvider serviceProvider)
{
var configuration = serviceProvider.GetService<Microsoft.Extensions.Configuration.IConfiguration>();
// 1) 优先按 IANA/Windows 时区名解析,可正确处理夏令时
var timeZoneId = configuration?["Scheduler:TimeZoneId"];
if (!string.IsNullOrWhiteSpace(timeZoneId))
{
try
{
return TimeZoneInfo.FindSystemTimeZoneById(timeZoneId).BaseUtcOffset;
}
catch (Exception ex) when (ex is TimeZoneNotFoundException or InvalidTimeZoneException)
{
Console.WriteLine($"[EasyAdminBlazor] Scheduler:TimeZoneId 无效({timeZoneId}),回退到偏移配置");
}
}
// 2) 其次按小时偏移配置
var offsetText = configuration?["Scheduler:UtcOffsetHours"];
if (!string.IsNullOrWhiteSpace(offsetText) &&
double.TryParse(offsetText, System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var hours) &&
hours is >= -14 and <= 14)
{
return TimeSpan.FromHours(hours);
}
// 3) 默认保持现有的中国时区行为
return TimeSpan.FromHours(8);
}
{
"Scheduler": {
"TimeZoneId": "Asia/Shanghai",
"UtcOffsetHours": null
}
}
为什么默认是 +8 而不是 UTC?源码注释给了答案:保持既有行为,避免升级后老项目的计划任务时间全部偏移。 如果你的服务器在别的时区,显式配置 TimeZoneId 即可。
四、Cron 表达式与校验
添加任务时会先校验 Cron:
public string AddTask(string topic, string body, int round, SchedulerInterval interval, string argument)
{
if (interval == SchedulerInterval.Custom && !IsValidCronExpression(argument))
{
throw new ArgumentException("Cron 表达式无效,请检查格式(秒 分 时 日 月 周)", nameof(argument));
}
return FreeScheduler.Datafeed.AddTask(_scheduler, topic, body, round, (FreeScheduler.TaskInterval)(int)interval, argument);
}
private static bool IsValidCronExpression(string? expression)
{
if (string.IsNullOrWhiteSpace(expression)) return false;
try
{
CrontabSchedule.Parse(expression, new CrontabSchedule.ParseOptions { IncludingSeconds = true });
return true;
}
catch
{
return false;
}
}
格式是 6 段、含秒:秒 分 时 日 月 周。
0 0 0 1 * * → 每月 1 号 00:00:00
0 0/5 * * * * → 每 5 分钟
0 30 8 * * 1-5 → 工作日 08:30
调度间隔的类型:
public enum SchedulerInterval
{
/// <summary>按秒触发</summary>
Seconds = 1,
/// <summary>每天固定时间触发(如 15:55:59)</summary>
RunOnDay = 11,
/// <summary>每星期几固定时间触发(如 2:15:55:59)</summary>
RunOnWeek = 12,
/// <summary>每月第几天固定时间触发(如 5:15:55:59)</summary>
RunOnMonth = 13,
/// <summary>自定义 Cron 表达式</summary>
Custom = 21,
}
枚举值与 FreeScheduler 的 TaskInterval 数值对齐,所以转换就是一次强转((FreeScheduler.TaskInterval)(int)interval)。
五、用代码注册任务:[Scheduler] 特性
仓库里有一个真实用例:
/// <summary>
/// 定时清理过期错误日志
/// </summary>
internal static class ErrorLogCleanupJob
{
/// <summary>
/// 每月 1 号凌晨清理一个月前的错误日志
/// </summary>
[Scheduler("清理错误日志", "0 0 0 1 * *")]
internal static void ClearErrorLogs(IServiceProvider service)
{
System.Console.WriteLine("清理错误日志 被触发...");
var scopeFactory = service.GetRequiredService<IServiceScopeFactory>();
using var scope = scopeFactory.CreateScope();
var repo = scope.ServiceProvider.GetService<IBaseRepository<SysLog>>();
if (repo != null)
{
repo.Delete(x => x.CreatedTime < DateTime.Now.AddMonths(-1));
}
}
}
特性接受两种构造方式:
[AttributeUsage(AttributeTargets.Method)]
public class SchedulerAttribute : Attribute
{
public string Name { get; set; } = string.Empty;
public SchedulerInterval Interval { get; set; }
public string Argument { get; set; } = string.Empty;
public int Round { get; set; } = -1;
public SchedulerTaskStatus Status { get; set; }
public SchedulerAttribute(string name) { this.Name = name; }
public SchedulerAttribute(string name, string cron)
{
this.Name = name;
this.Interval = SchedulerInterval.Custom;
this.Argument = cron;
}
}
启动时会扫描 EasyAdminBlazorOptions.Assemblies 里的静态方法并同步到任务表:
var taskInfo = new SchedTaskInfo
{
Topic = $"[SchedulerAttribute]{attr.Name}",
Interval = (FreeScheduler.TaskInterval)(int)attr.Interval,
IntervalArgument = attr.Argument,
Round = attr.Round,
Status = (FreeScheduler.TaskStatus)(int)attr.Status,
Body = string.Empty,
CreateTime = DateTime.Now,
CurrentRound = 0,
ErrorTimes = 0,
LastRunTime = new DateTime(1970, 1, 1),
};
同步策略是"保留运行状态、只更新定义":
var existing = fsql.Select<SchedTaskInfo>()
.Where(a => a.Topic.StartsWith("[SchedulerAttribute]"))
.ToList();
foreach (var entry in allSchedulerMethods)
{
var find = existing.Find(a => a.Topic == entry.TaskInfo.Topic);
if (find != null)
{
entry.TaskInfo.Id = find.Id;
entry.TaskInfo.Body = find.Body;
entry.TaskInfo.CreateTime = find.CreateTime;
entry.TaskInfo.CurrentRound = find.CurrentRound;
entry.TaskInfo.ErrorTimes = find.ErrorTimes;
entry.TaskInfo.LastRunTime = find.LastRunTime;
}
else
{
entry.TaskInfo.Id = $"{DateTime.Now:yyyyMMdd}.{YitIdHelper.NextId()}";
}
}
var repo = fsql.GetRepository<SchedTaskInfo>();
repo.BeginEdit(existing);
repo.EndEdit(allSchedulerMethods.Select(a => a.TaskInfo).ToList());
已存在的任务复用原 Id、保留执行次数与最后运行时间;新任务生成新 Id。这样重启不会把任务的运行历史清零。
特性任务的 Topic 带 [SchedulerAttribute] 前缀,用来和页面手动创建的任务区分开——所以管理页里能看到"哪些是代码定义的、哪些是后台加的"。
六、可视化管理页面
/Admin/TaskScheduler 用的是 BootstrapBlazor 的 Table(不是 AdminTable,因为任务数据来自调度器而不是业务表):
<Table @ref="table" TItem="SchedulerTaskData" EditDialogSize="Size.Large"
IsPagination="true" IsStriped="true" IsBordered="true" IsMultipleSelect="true"
ShowToolbar="true" ShowExtendButtons="true" ShowEditButton="false" ShowExtendEditButton="false"
OnQueryAsync="@OnQueryAsync" OnSaveAsync="OnSaveAsync" OnDeleteAsync="OnDeleteAsync">
页面提供的能力:
| 操作 | 实现 |
|---|---|
| 分页查询 | `Scheduler.GetTasks(pageIndex, pageItems)` |
| 新建任务 | `OnSaveAsync` → `Scheduler.AddTask(topic, body, round, interval, argument)` |
| 删除任务 | `OnDeleteAsync` → `Scheduler.RemoveTask(id)` |
| 暂停 / 恢复 | `PauseTask` / `ResumeTask`(带 `[OperationLog("暂停了任务")]` 等审计标注) |
| 立即触发 | `RunNowTask` |
| 查看日志 | 弹窗内嵌 `Table |
其中几个方法都标了操作日志特性:
[OperationLog("恢复了任务")]
async Task ResumeTask(SchedulerTaskData task)
{
Scheduler.ResumeTask(task.Id);
await table.QueryAsync(1);
}
管理操作会被记进操作日志,这在生产排查时很有用("谁在什么时候暂停了清理任务")。
七、日志与失败信息
任务执行结果由调度器记录,映射成统一的 SchedulerTaskLog:
private static SchedulerTaskLog MapToLog(FreeScheduler.TaskLog log)
{
return new SchedulerTaskLog
{
TaskId = log.TaskId,
Round = log.Round,
ElapsedMilliseconds = log.ElapsedMilliseconds,
Success = log.Success,
Exception = log.Exception,
Remark = log.Remark,
CreateTime = log.CreateTime
};
}
| 字段 | 用途 |
|---|---|
| `Round` | 第几轮执行 |
| `ElapsedMilliseconds` | 耗时 |
| `Success` | 是否成功 |
| `Exception` | 异常信息(`StringLength = -1`,不截断) |
| `Remark` | 备注 |
任务本身还带 ErrorTimes(累计失败次数)和 Status(运行中 / 已暂停 / 已结束),列表页直接展示,便于快速发现"一直在失败"的任务。
八、多实例部署要注意什么
- 任务定义和执行状态都在数据库里(
FreeScheduler_task/FreeScheduler_tasklog),所以多实例共享同一份任务清单。 - 同一时刻只有一个实例真正执行任务这一点由 FreeScheduler 的存储与抢占机制保证;如果对执行频率有强要求,建议在任务体内部再做一次幂等保护(例如用分布式锁,见第 19 篇)。
- 任务体要自己创建 Scope。看
ErrorLogCleanupJob的写法:
var scopeFactory = service.GetRequiredService<IServiceScopeFactory>();
using var scope = scopeFactory.CreateScope();
var repo = scope.ServiceProvider.GetService<IBaseRepository<SysLog>>();
任务由单例调度器触发,直接解析 Scoped 服务会出问题。
- 时区要统一配置。多实例部署在不同时区时,
Scheduler:TimeZoneId必须一致,否则同一个 Cron 会在不同实例上解释成不同时间。
九、常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 菜单里没有"任务计划" | 未安装 Scheduler 扩展 | 调用 `AddEasyAdminBlazorScheduler()` |
| 页面被重定向回首页 | `ISchedulerService.IsAvailable == false` | 同上 |
| 添加任务报"Cron 表达式无效" | 段数不对或语法错误 | 使用 6 段格式:秒 分 时 日 月 周 |
| 任务时间比预期差 8 小时 | 时区配置 | 设置 `Scheduler:TimeZoneId` |
| 重启后任务丢失 | 未使用 `UseStorage`(被改动过) | 确认 `DefaultSchedulerService` 未被修改 |
| 代码里加了 `[Scheduler]` 但没出现 | 所在程序集不在 `EasyAdminBlazorOptions.Assemblies` 里 | 把程序集加进去 |
| 任务报错但没有日志 | 任务体内部吞了异常 | 让异常抛出,调度器会记录到 `Scheduler_tasklog` |
十、小结
EasyAdminBlazor 的定时任务可以理解成三层:
| 层 | 内容 |
|---|---|
| 抽象层 | `ISchedulerService` + `NullSchedulerService`(不装扩展也能编译运行) |
| 引擎层 | FreeScheduler + 数据库持久化 + 时区 + Cron 校验 |
| 管理层 | `/Admin/TaskScheduler` 可视化页面 + 操作日志 + 执行日志 |
再加一个实用的代码注册方式([Scheduler("名称", "cron")]),就覆盖了后台定时任务的两类来源:代码里写死的周期性任务,和运营人员在页面上临时加的任务。
如果你正在用 .NET 10 + Blazor 做后台,需要"每天/每月跑一次"的调度能力,可以看看 EasyAdminBlazor 的 Scheduler 扩展:任务持久化、可视化启停、执行日志、时区配置都开箱可用。
把后台定时任务的两类来源——代码写死与运营临时加——统一到一套可视化调度里,持久化、启停、日志、时区都开箱可用。适合用 .NET 与 Blazor 做后台、需要每日或每月周期任务的团队直接接入。