EasyAdminBlazor 定时任务:FreeScheduler 可视化调度与任务管理

文章来源声明: 原文作者:用户EasyAdminBlazor; 来源站点:掘金; 原文链接:https://juejin.cn/post/7691537931250745384; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

把后台定时任务的两类来源——代码写死与运营临时加——统一到一套可视化调度里,持久化、启停、日志、时区都开箱可用。适合用 .NET 与 Blazor 做后台、需要每日或每月周期任务的团队直接接入。

后台里总有一些"每天凌晨跑一次""每月 1 号清理数据"的需求。EasyAdminBlazor 2.3 的做法是:底层用 FreeScheduler 做调度与持久化,上层提供统一接口和可视化页面。

一、接入

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();
}

四个关键点:

  1. 必须用主库 ORM(MainOrmHandle.Orm)。调度器是单例后台服务,直接解析 Scoped 的 IFreeSql 会踩生命周期问题(源码注释明确写了这一点)。
  2. UseStorage(fsql):任务和日志持久化到数据库,重启不丢。
  3. OnExecuting 优先匹配特性注册的任务([Scheduler]),其次走 options.SchedulerExecuting 回调。
  4. 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`,调 `GetTaskLogs`

其中几个方法都标了操作日志特性:

[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(运行中 / 已暂停 / 已结束),列表页直接展示,便于快速发现"一直在失败"的任务。


八、多实例部署要注意什么

  1. 任务定义和执行状态都在数据库里(FreeScheduler_task / FreeScheduler_tasklog),所以多实例共享同一份任务清单。
  2. 同一时刻只有一个实例真正执行任务这一点由 FreeScheduler 的存储与抢占机制保证;如果对执行频率有强要求,建议在任务体内部再做一次幂等保护(例如用分布式锁,见第 19 篇)。
  3. 任务体要自己创建 Scope。看 ErrorLogCleanupJob 的写法:
var scopeFactory = service.GetRequiredService<IServiceScopeFactory>();
using var scope = scopeFactory.CreateScope();
var repo = scope.ServiceProvider.GetService<IBaseRepository<SysLog>>();

任务由单例调度器触发,直接解析 Scoped 服务会出问题。

  1. 时区要统一配置。多实例部署在不同时区时,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 扩展:任务持久化、可视化启停、执行日志、时区配置都开箱可用。