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

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


一、接入

csharp 复制代码
builder.AddEasyAdminBlazor(new EasyAdminBlazorOptions { ... })
       .AddEasyAdminBlazorScheduler();
csharp 复制代码
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:

csharp 复制代码
// AdminExtensions.cs
builder.Services.TryAddSingleton<ISchedulerService, NullSchedulerService>();

它的 IsAvailable 为 false,框架里依赖调度的页面(比如任务管理页)会据此隐藏或重定向:

csharp 复制代码
var scheduler = ServiceProvider.GetRequiredService<ISchedulerService>();
if (!scheduler.IsAvailable && path.Contains("taskscheduler"))
{
    admin.Redirect("/Admin/");
    return;
}

所以定时任务是可选扩展:不装它,系统照常运行,只是没有调度能力。


二、调度器初始化时做了什么

csharp 复制代码
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。

三、时区:三级解析

csharp 复制代码
/// <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);
}
json 复制代码
{
  "Scheduler": {
    "TimeZoneId": "Asia/Shanghai",
    "UtcOffsetHours": null
  }
}

为什么默认是 +8 而不是 UTC?源码注释给了答案:保持既有行为,避免升级后老项目的计划任务时间全部偏移。 如果你的服务器在别的时区,显式配置 TimeZoneId 即可。


四、Cron 表达式与校验

添加任务时会先校验 Cron:

csharp 复制代码
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 段、含秒 :秒 分 时 日 月 周。

text 复制代码
0 0 0 1 * *     → 每月 1 号 00:00:00
0 0/5 * * * *   → 每 5 分钟
0 30 8 * * 1-5  → 工作日 08:30

调度间隔的类型:

csharp 复制代码
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] 特性

仓库里有一个真实用例:

csharp 复制代码
/// <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));
        }
    }
}

特性接受两种构造方式:

csharp 复制代码
[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 里的静态方法并同步到任务表:

csharp 复制代码
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),
};

同步策略是"保留运行状态、只更新定义":

csharp 复制代码
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,因为任务数据来自调度器而不是业务表):

razor 复制代码
<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<SchedulerTaskLog>,调 GetTaskLogs

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

csharp 复制代码
[OperationLog("恢复了任务")]
async Task ResumeTask(SchedulerTaskData task)
{
    Scheduler.ResumeTask(task.Id);
    await table.QueryAsync(1);
}

管理操作会被记进操作日志,这在生产排查时很有用("谁在什么时候暂停了清理任务")。


七、日志与失败信息

任务执行结果由调度器记录,映射成统一的 SchedulerTaskLog:

csharp 复制代码
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 的写法:
csharp 复制代码
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 扩展:任务持久化、可视化启停、执行日志、时区配置都开箱可用。

相关推荐
金銀銅鐵2 小时前
[Java] 用 GUI 展示 EnumSet 增减元素时的内部变化(上)
java·后端·ai编程
念何架构之路3 小时前
go-grpc设计思想
开发语言·后端·golang
system_error_x3 小时前
SpringBoot 集成 Thymeleaf 简洁入门
java·spring boot·后端
步行cgn5 小时前
Spring MVC 视图机制的核心接口与类
java·开发语言·后端·spring·mvc
码事漫谈6 小时前
AI 读你的提示词,其实很没耐心
后端
用户8356290780517 小时前
如何使用 Python 格式化 Excel 单元格中的长文本
后端·python
我命由我123457 小时前
Python 开发 - Python 的 pathlib
java·开发语言·后端·python·学习·java-ee·学习方法
IT_陈寒7 小时前
Java性能优化完全指南
前端·人工智能·后端
电子IT笔记7 小时前
对接群晖NAS,上传和删除踩坑记录
前端·后端
Mk598787 小时前
P4:把 Agent 的记忆搬进 Redis —— AgentScope-Java 工程化实战
后端