Skip to content
On this page

调度作业

调度作业又称定时任务,顾名思义,定时任务就是在特定的时间或符合某种时间规律自动触发并执行任务。

关于调度作业

使用场景

定时任务的应用场景非常广,几乎是每一个软件系统必备功能:

  • 叫你起床的闹钟
  • 日历日程提醒
  • 生日纪念日提醒
  • 定时备份数据库
  • 定时清理垃圾数据
  • 定时发送营销信息,邮件
  • 定时上线产品,比如预售产品,双十一活动
  • 定时发送优惠券
  • 定时发布,实现 Devops 功能,如 Jenkins
  • 定时爬虫抓数据
  • 定时导出报表,历史统计,考勤统计
  • ...

快速入门

  1. 定义作业处理程序 MyJob
cs
public class MyJob : IJob
{
    private readonly ILogger<MyJob> _logger;
    public MyJob(ILogger<MyJob> logger)
    {
        _logger = logger;
    }

    public Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
    {
        _logger.LogInformation($"{context}");
        return Task.CompletedTask;
    }
}
  1. Startup.cs 注册 Schedule 服务:
cs
services.AddSchedule(options =>
{
    // 注册作业,并配置作业触发器
    options.AddJob<MyJob>(Triggers.Secondly()); // 表示每秒执行
});
  1. 查看作业执行结果
bash
info: 2022-12-02 16:51:33.5032989 +08:00 星期五 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is running.
info: 2022-12-02 16:51:33.5180669 +08:00 星期五 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is preloading...
info: 2022-12-02 16:51:34.1452041 +08:00 星期五 L System.Logging.ScheduleService[0] #1
      The <job1_trigger1> trigger for scheduler of <job1> successfully appended to the schedule.
info: 2022-12-02 16:51:34.1541701 +08:00 星期五 L System.Logging.ScheduleService[0] #1
      The scheduler of <job1> successfully appended to the schedule.
warn: 2022-12-02 16:51:34.1748401 +08:00 星期五 L System.Logging.ScheduleService[0] #1
      Schedule hosted service preload completed, and a total of <1> schedulers are appended.
info: 2022-12-02 16:51:35.0712571 +08:00 星期五 L MyJob[0] #4
      <job1> [C] <job1 job1_trigger1> * * * * * * 1ts 2022-12-02 16:51:35.000 -> 2022-12-02 16:51:36.000
info: 2022-12-02 16:51:36.0317375 +08:00 星期五 L MyJob[0] #14
      <job1> [C] <job1 job1_trigger1> * * * * * * 2ts 2022-12-02 16:51:36.000 -> 2022-12-02 16:51:37.000
info: 2022-12-02 16:51:37.0125007 +08:00 星期五 L MyJob[0] #9
      <job1> [C] <job1 job1_trigger1> * * * * * * 3ts 2022-12-02 16:51:37.000 -> 2022-12-02 16:51:38.000
info: 2022-12-02 16:51:38.0179920 +08:00 星期五 L MyJob[0] #8
      <job1> [C] <job1 job1_trigger1> * * * * * * 4ts 2022-12-02 16:51:38.000 -> 2022-12-02 16:51:39.000

JobExecutionContext 重写了 ToString() 方法并提供以下几种格式:

bash
# 持续运行格式
<作业Id> 作业描述 [并行C/串行S] <作业Id 触发器Id> 触发器字符串 触发器描述 触发次数ts 触发时间 -> 下一次触发时间

# 触发停止格式
<作业Id> 作业描述 [并行C/串行S] <作业Id 触发器Id> 触发器字符串 触发器描述 触发次数ts 触发时间 [触发器终止状态]

指定作业 Id

默认情况下,不指定作业 Id 会自动生成 job[编号]

cs
services.AddSchedule(options =>
{
    options.AddJob<MyJob>("myjob", Triggers.Secondly());
});

查看作业执行结果:

bash
info: 2022-12-02 17:15:43.3024818 +08:00 星期五 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is running.
info: 2022-12-02 17:15:43.3107918 +08:00 星期五 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is preloading...
info: 2022-12-02 17:15:43.9498664 +08:00 星期五 L System.Logging.ScheduleService[0] #1
      The <myjob_trigger1> trigger for scheduler of <myjob> successfully appended to the schedule.
info: 2022-12-02 17:15:43.9532894 +08:00 星期五 L System.Logging.ScheduleService[0] #1
      The scheduler of <myjob> successfully appended to the schedule.
warn: 2022-12-02 17:15:43.9941565 +08:00 星期五 L System.Logging.ScheduleService[0] #1
      Schedule hosted service preload completed, and a total of <1> schedulers are appended.
info: 2022-12-02 17:15:44.1230353 +08:00 星期五 L MyJob[0] #6
      <myjob> [C] <myjob myjob_trigger1> * * * * * * 1ts 2022-12-02 17:15:44.000 -> 2022-12-02 17:15:45.000
info: 2022-12-02 17:15:45.0854893 +08:00 星期五 L MyJob[0] #9
      <myjob> [C] <myjob myjob_trigger1> * * * * * * 2ts 2022-12-02 17:15:45.000 -> 2022-12-02 17:15:46.000
info: 2022-12-02 17:15:46.0100813 +08:00 星期五 L MyJob[0] #13
      <myjob> [C] <myjob myjob_trigger1> * * * * * * 3ts 2022-12-02 17:15:46.000 -> 2022-12-02 17:15:47.000

多个作业触发器

有时候,一个作业支持多种触发时间,比如 每分钟 执行一次,每 5秒 执行一次,每分钟第 3/7/8秒 执行一次。

cs
services.AddSchedule(options =>
{
    options.AddJob<MyJob>(Triggers.Minutely()   // 每分钟开始
     , Triggers.Period(5000)   // 每 5 秒,还支持 Triggers.PeriodSeconds(5),Triggers.PeriodMinutes(5),Triggers.PeriodHours(5)
     , Triggers.Cron("3,7,8 * * * * ?", CronStringFormat.WithSeconds));  // 每分钟第 3/7/8 秒
});

查看作业执行结果:

cs
info: 2022-12-02 17:18:53.3593518 +08:00 星期五 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is running.
info: 2022-12-02 17:18:53.3663583 +08:00 星期五 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is preloading...
info: 2022-12-02 17:18:54.0381456 +08:00 星期五 L System.Logging.ScheduleService[0] #1
      The <job1_trigger1> trigger for scheduler of <job1> successfully appended to the schedule.
info: 2022-12-02 17:18:54.0708796 +08:00 星期五 L System.Logging.ScheduleService[0] #1
      The <job1_trigger2> trigger for scheduler of <job1> successfully appended to the schedule.
info: 2022-12-02 17:18:54.0770193 +08:00 星期五 L System.Logging.ScheduleService[0] #1
      The <job1_trigger3> trigger for scheduler of <job1> successfully appended to the schedule.
info: 2022-12-02 17:18:54.0800017 +08:00 星期五 L System.Logging.ScheduleService[0] #1
      The scheduler of <job1> successfully appended to the schedule.
warn: 2022-12-02 17:18:54.1206816 +08:00 星期五 L System.Logging.ScheduleService[0] #1
      Schedule hosted service preload completed, and a total of <1> schedulers are appended.
info: 2022-12-02 17:18:59.0040452 +08:00 星期五 L MyJob[0] #9
      <job1> [C] <job1 job1_trigger2> 5000ms 1ts 2022-12-02 17:18:58.927 -> 2022-12-02 17:19:03.944
info: 2022-12-02 17:19:00.0440142 +08:00 星期五 L MyJob[0] #15
      <job1> [C] <job1 job1_trigger1> * * * * * 1ts 2022-12-02 17:19:00.000 -> 2022-12-02 17:20:00.000
info: 2022-12-02 17:19:03.0149075 +08:00 星期五 L MyJob[0] #6
      <job1> [C] <job1 job1_trigger3> 3,7,8 * * * * ? 1ts 2022-12-02 17:19:03.000 -> 2022-12-02 17:19:07.000
info: 2022-12-02 17:19:03.9519350 +08:00 星期五 L MyJob[0] #15
      <job1> [C] <job1 job1_trigger2> 5000ms 2ts 2022-12-02 17:19:03.944 -> 2022-12-02 17:19:08.919
info: 2022-12-02 17:19:07.0116797 +08:00 星期五 L MyJob[0] #4
      <job1> [C] <job1 job1_trigger3> 3,7,8 * * * * ? 2ts 2022-12-02 17:19:07.000 -> 2022-12-02 17:19:08.000
info: 2022-12-02 17:19:08.0078132 +08:00 星期五 L MyJob[0] #15
      <job1> [C] <job1 job1_trigger3> 3,7,8 * * * * ? 3ts 2022-12-02 17:19:08.000 -> 2022-12-02 17:20:03.000
info: 2022-12-02 17:19:08.9298393 +08:00 星期五 L MyJob[0] #14
      <job1> [C] <job1 job1_trigger2> 5000ms 3ts 2022-12-02 17:19:08.919 -> 2022-12-02 17:19:13.897
info: 2022-12-02 17:19:13.9056247 +08:00 星期五 L MyJob[0] #8
      <job1> [C] <job1 job1_trigger2> 5000ms 4ts 2022-12-02 17:19:13.897 -> 2022-12-02 17:19:18.872
info: 2022-12-02 17:19:18.8791123 +08:00 星期五 L MyJob[0] #12
      <job1> [C] <job1 job1_trigger2> 5000ms 5ts 2022-12-02 17:19:18.872 -> 2022-12-02 17:19:23.846

串行 执行

默认情况下,作业采用 并行 执行方式,也就是不会等待上一次作业执行完成,只要触发时间到了就自动执行,但一些情况下,我们可能希望等待上一次作业完成再执行,如:

cs
services.AddSchedule(options =>
{
    options.AddJob<MyJob>(concurrent: false, Triggers.Secondly()); // 串行,每秒执行
});
cs
public class MyJob : IJob
{
    private readonly ILogger<MyJob> _logger;
    public MyJob(ILogger<MyJob> logger)
    {
        _logger = logger;
    }

    public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
    {
        _logger.LogInformation($"{context.JobId} {context.TriggerId} {context.OccurrenceTime} {context.Trigger}");
        await Task.Delay(2000, stoppingToken); // 这里模拟耗时操作,比如耗时2秒
    }
}

查看作业执行结果:

cs
info: 2022-12-02 17:23:27.3726863 +08:00 星期五 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is running.
info: 2022-12-02 17:23:27.3830366 +08:00 星期五 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is preloading...
info: 2022-12-02 17:23:27.9083148 +08:00 星期五 L System.Logging.ScheduleService[0] #1
      The <job1_trigger1> trigger for scheduler of <job1> successfully appended to the schedule.
info: 2022-12-02 17:23:27.9184699 +08:00 星期五 L System.Logging.ScheduleService[0] #1
      The scheduler of <job1> successfully appended to the schedule.
warn: 2022-12-02 17:23:27.9740028 +08:00 星期五 L System.Logging.ScheduleService[0] #1
      Schedule hosted service preload completed, and a total of <1> schedulers are appended.
info: 2022-12-02 17:23:28.0638789 +08:00 星期五 L MyJob[0] #9
      <job1> [S] <job1 job1_trigger1> * * * * * * 1ts 2022-12-02 17:23:28.000 -> 2022-12-02 17:23:29.000
warn: 2022-12-02 17:23:29.1119269 +08:00 星期五 L System.Logging.ScheduleService[0] #9
      12/02/2022 17:23:29: The <job1_trigger1> trigger of job <job1> failed to execute as scheduled due to blocking.
warn: 2022-12-02 17:23:30.0090551 +08:00 星期五 L System.Logging.ScheduleService[0] #9
      12/02/2022 17:23:30: The <job1_trigger1> trigger of job <job1> failed to execute as scheduled due to blocking.
info: 2022-12-02 17:23:31.0121694 +08:00 星期五 L MyJob[0] #9
      <job1> [S] <job1 job1_trigger1> * * * * * * 2ts 2022-12-02 17:23:31.000 -> 2022-12-02 17:23:32.000
warn: 2022-12-02 17:23:32.0243646 +08:00 星期五 L System.Logging.ScheduleService[0] #9
      12/02/2022 17:23:32: The <job1_trigger1> trigger of job <job1> failed to execute as scheduled due to blocking.

串行 执行规则说明

串行 执行如果遇到上一次作业还未完成那么它会等到下一次触发时间到了再执行,以此重复。

默认情况下,使用 串行 执行但因为耗时导致触发时间到了但实际未能执行会默认输出 warn 警告日志,如需关闭只需要:

cs
services.AddSchedule(options =>
{
    options.LogEnabled = false;
    options.AddJob<MyJob>(concurrent: false, Triggers.Secondly()); // 每秒执行
});

查看作业执行结果:

bash
info: 2022-12-02 17:27:13.1136450 +08:00 星期五 L MyJob[0] #12
      <job1> [S] <job1 job1_trigger1> * * * * * * 1ts 2022-12-02 17:27:13.000 -> 2022-12-02 17:27:14.000
info: 2022-12-02 17:27:16.0092433 +08:00 星期五 L MyJob[0] #8
      <job1> [S] <job1 job1_trigger1> * * * * * * 2ts 2022-12-02 17:27:16.000 -> 2022-12-02 17:27:17.000
info: 2022-12-02 17:27:19.0092363 +08:00 星期五 L MyJob[0] #6
      <job1> [S] <job1 job1_trigger1> * * * * * * 3ts 2022-12-02 17:27:19.000 -> 2022-12-02 17:27:20.000
info: 2022-12-02 17:27:22.0183594 +08:00 星期五 L MyJob[0] #9
      <job1> [S] <job1 job1_trigger1> * * * * * * 4ts 2022-12-02 17:27:22.000 -> 2022-12-02 17:27:23.000
info: 2022-12-02 17:27:25.0152323 +08:00 星期五 L MyJob[0] #4
      <job1> [S] <job1 job1_trigger1> * * * * * * 5ts 2022-12-02 17:27:25.000 -> 2022-12-02 17:27:26.000

打印作业完整信息

框架提供了四种方式打印作业完整信息。

  • 第一种:输出完整的作业 JSON 信息:context.ConvertToJSON()
cs
public class MyJob : IJob
{
    private readonly ILogger<MyJob> _logger;
    public MyJob(ILogger<MyJob> logger)
    {
        _logger = logger;
    }

    public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
    {
        _logger.LogInformation(context.ConvertToJSON());
        await Task.CompletedTask;
    }
}

查看作业打印结果:

json
info: 2022-12-02 18:00:59.4140802 +08:00 星期五 L MyJob[0] #13
      {
        "jobDetail": {
        "jobId": "job1",
        "groupName": null,
        "jobType": "MyJob",
        "assemblyName": "ConsoleApp32",
        "description": null,
        "concurrent": true,
        "includeAnnotations": false,
        "properties": "{}",
        "updatedTime": "2022-12-02 18:00:59.390"
      },
        "trigger": {
        "triggerId": "job1_trigger1",
        "jobId": "job1",
        "triggerType": "Penkar.Schedule.PeriodSecondsTrigger",
        "assemblyName": "Penkar",
        "args": "[5]",
        "description": null,
        "status": 2,
        "startTime": null,
        "endTime": null,
        "lastRunTime": "2022-12-02 18:00:59.326",
        "nextRunTime": "2022-12-02 18:01:04.358",
        "numberOfRuns": 1,
        "maxNumberOfRuns": 0,
        "numberOfErrors": 0,
        "maxNumberOfErrors": 0,
        "numRetries": 0,
        "retryTimeout": 1000,
        "startNow": true,
        "runOnStart": false,
        "resetOnlyOnce": true,
        "result": null,
        "elapsedTime": 100,
        "updatedTime": "2022-12-02 18:00:59.390"
      }
      }
  • 第二种:输出单独的作业 JSON 信息:jobDetail.ConvertToJSON()trigger.ConvertToJSON()
cs
public class MyJob : IJob
{
    private readonly ILogger<MyJob> _logger;
    public MyJob(ILogger<MyJob> logger)
    {
        _logger = logger;
    }

    public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
    {
        _logger.LogInformation(context.JobDetail.ConvertToJSON());
        _logger.LogInformation(context.Trigger.ConvertToJSON(NamingConventions.UnderScoreCase));    // 支持三种属性名输出规则

        await Task.CompletedTask;
    }
}

查看作业打印结果:

json
info: 2022-12-02 18:02:10.7923360 +08:00 星期五 L MyJob[0] #8
      {
        "jobId": "job1",
        "groupName": null,
        "jobType": "MyJob",
        "assemblyName": "ConsoleApp32",
        "description": null,
        "concurrent": true,
        "includeAnnotations": false,
        "properties": "{}",
        "updatedTime": "2022-12-02 18:02:10.774"
      }
info: 2022-12-02 18:02:10.8008708 +08:00 星期五 L MyJob[0] #8
      {
        "trigger_id": "job1_trigger1",
        "job_id": "job1",
        "trigger_type": "Penkar.Schedule.PeriodSecondsTrigger",
        "assembly_name": "Penkar",
        "args": "[5]",
        "description": null,
        "status": 2,
        "start_time": null,
        "end_time": null,
        "last_run_time": "2022-12-02 18:02:10.727",
        "next_run_time": "2022-12-02 18:02:15.733",
        "number_of_runs": 1,
        "max_number_of_runs": 0,
        "number_of_errors": 0,
        "max_number_of_errors": 0,
        "num_retries": 0,
        "retry_timeout": 1000,
        "start_now": true,
        "run_on_start": false,
        "reset_only_once": true,
        "result": null,
        "elapsed_time": 100,
        "updated_time": "2022-12-02 18:02:10.774"
      }
  • 第三种:输出单独的作业 SQL 信息:jobDetail.ConvertToSQL()trigger.ConvertToSQL()
cs
public class MyJob : IJob
{
    private readonly ILogger<MyJob> _logger;
    public MyJob(ILogger<MyJob> logger)
    {
        _logger = logger;
    }

    public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
    {
        var jobDetail = context.JobDetail;
        var trigger = context.Trigger;

        _logger.LogInformation(jobDetail.ConvertToSQL("作业信息表名", PersistenceBehavior.Appended));  // 输出新增语句
        _logger.LogInformation(trigger.ConvertToSQL("作业触发器表名", PersistenceBehavior.Removed, NamingConventions.Pascal));    // 输出删除语句
        _logger.LogInformation(trigger.ConvertToSQL("作业触发器表名", PersistenceBehavior.Updated, NamingConventions.UnderScoreCase));    // 输出更新语句

        await Task.CompletedTask;
    }
}

查看作业打印结果:

sql
info: 2022-12-02 18:03:11.8543760 +08:00 星期五 L MyJob[0] #13
      INSERT INTO 作业信息表名(
          jobId,
          groupName,
          jobType,
          assemblyName,
          description,
          concurrent,
          includeAnnotations,
          properties,
          updatedTime
      )
      VALUES(
          'job1',
          NULL,
          'MyJob',
          'ConsoleApp32',
          NULL,
          1,
          0,
          '{}',
          '2022-12-02 18:03:11.836'
      );
info: 2022-12-02 18:03:11.8636268 +08:00 星期五 L MyJob[0] #13
      DELETE FROM 作业触发器表名
      WHERE TriggerId = 'job1_trigger1' AND JobId = 'job1';
info: 2022-12-02 18:03:11.8669134 +08:00 星期五 L MyJob[0] #13
      UPDATE 作业触发器表名
      SET
          trigger_id = 'job1_trigger1',
          job_id = 'job1',
          trigger_type = 'Penkar.Schedule.PeriodSecondsTrigger',
          assembly_name = 'Penkar',
          args = '[5]',
          description = NULL,
          status = 2,
          start_time = NULL,
          end_time = NULL,
          last_run_time = '2022-12-02 18:03:11.778',
          next_run_time = '2022-12-02 18:03:16.794',
          number_of_runs = 1,
          max_number_of_runs = 0,
          number_of_errors = 0,
          max_number_of_errors = 0,
          num_retries = 0,
          retry_timeout = 1000,
          start_now = 1,
          run_on_start = 0,
          reset_only_once = 1,
          result = NULL,
          elapsed_time = 100,
          updated_time = '2022-12-02 18:03:11.836'
      WHERE trigger_id = 'job1_trigger1' AND job_id = 'job1';
  • 第四种:输出单独的作业 Monitor 信息:jobDetail.ConvertToMonitor()trigger.ConvertToMonitor()
cs
public class MyJob : IJob
{
    private readonly ILogger<MyJob> _logger;
    public MyJob(ILogger<MyJob> logger)
    {
        _logger = logger;
    }

    public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
    {
        _logger.LogInformation(context.JobDetail.ConvertToMonitor());
        _logger.LogInformation(context.Trigger.ConvertToMonitor());

        await Task.CompletedTask;
    }
}

查看作业打印结果:

bash
info: 2022-12-02 18:04:06.2833095 +08:00 星期五 L MyJob[0] #8
      ┏━━━━━━━━━━━  JobDetail ━━━━━━━━━━━
       MyJob
      
       jobId:                     job1
       groupName:
       jobType:                   MyJob
       assemblyName:              ConsoleApp32
       description:
       concurrent:                True
       includeAnnotations:        False
       properties:                {}
       updatedTime:               2022-12-02 18:04:06.254
      ┗━━━━━━━━━━━  JobDetail ━━━━━━━━━━━
info: 2022-12-02 18:04:06.2868205 +08:00 星期五 L MyJob[0] #8
      ┏━━━━━━━━━━━  Trigger ━━━━━━━━━━━
       Penkar.Schedule.PeriodSecondsTrigger
      
       triggerId:                job1_trigger1
       jobId:                    job1
       triggerType:              Penkar.Schedule.PeriodSecondsTrigger
       assemblyName:             Penkar
       args:                     [5]
       description:
       status:                   Running
       startTime:
       endTime:
       lastRunTime:              2022-12-02 18:04:06.189
       nextRunTime:              2022-12-02 18:04:11.212
       numberOfRuns:             1
       maxNumberOfRuns:          0
       numberOfErrors:           0
       maxNumberOfErrors:        0
       numRetries:               0
       retryTimeout:             1000
       startNow:                 True
       runOnStart:               False
       resetOnlyOnce:            True
       result:
       elapsedTime:              100
       updatedTime:              2022-12-02 18:04:06.254
      ┗━━━━━━━━━━━  Trigger ━━━━━━━━━━━

运行时(动态)操作作业

有时候,我们需要在运行时对作业动态的增加,更新,删除等操作,如动态添加作业:

  1. 注册 services.AddSchedule() 服务
cs
// 可以完全动态操作,只需要注册服务即可
services.AddSchedule();

// 也可以部分静态,部分动态注册
services.AddSchedule(options =>
{
    options.AddJob<MyJob>(concurrent: false, Triggers.PeriodSeconds(5));
});
  1. 注入 ISchedulerFactory 服务
cs
public class YourService: IYourService
{
    private readonly ISchedulerFactory _schedulerFactory;
    public YourService(ISchedulerFactory schedulerFactory)
    {
        _schedulerFactory = schedulerFactory;
    }

    public void AddJob()
    {
        _schedulerFactory.AddJob<MyJob>("动态作业 Id", Triggers.Secondly());
    }
}
  1. 查看作业执行结果
bash
info: 2022-12-02 18:07:33.7799062 +08:00 星期五 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is running.
info: 2022-12-02 18:07:33.7971487 +08:00 星期五 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is preloading...
info: 2022-12-02 18:07:33.8751390 +08:00 星期五 L System.Logging.ScheduleService[0] #1
      The <job1_trigger1> trigger for scheduler of <job1> successfully appended to the schedule.
info: 2022-12-02 18:07:33.8805159 +08:00 星期五 L System.Logging.ScheduleService[0] #1
      The scheduler of <job1> successfully appended to the schedule.
warn: 2022-12-02 18:07:33.9013656 +08:00 星期五 L System.Logging.ScheduleService[0] #1
      Schedule hosted service preload completed, and a total of <1> schedulers are appended.
info: 2022-12-02 18:07:38.9241031 +08:00 星期五 L MyJob[0] #9
      <job1> [C] <job1 job1_trigger1> 5s 1ts 2022-12-02 18:07:38.813 -> 2022-12-02 18:07:43.863
info: 2022-12-02 18:07:43.0865787 +08:00 星期五 L System.Logging.ScheduleService[0] #16
      The <动态作业 Id_trigger1> trigger for scheduler of <动态作业 Id> successfully appended to the schedule.
warn: 2022-12-02 18:07:43.0894163 +08:00 星期五 L System.Logging.ScheduleService[0] #16
      Schedule hosted service cancels hibernation and GC.Collect().
info: 2022-12-02 18:07:43.1129824 +08:00 星期五 L System.Logging.ScheduleService[0] #16
      The scheduler of <动态作业 Id> successfully appended to the schedule.
info: 2022-12-02 18:07:43.8810686 +08:00 星期五 L MyJob[0] #17
      <job1> [C] <job1 job1_trigger1> 5s 2ts 2022-12-02 18:07:43.863 -> 2022-12-02 18:07:48.848
info: 2022-12-02 18:07:44.0104025 +08:00 星期五 L MyJob[0] #16
      <动态作业 Id> [C] <动态作业 Id 动态作业 Id_trigger1> * * * * * * 1ts 2022-12-02 18:07:44.000 -> 2022-12-02 18:07:45.000
info: 2022-12-02 18:07:45.0092441 +08:00 星期五 L MyJob[0] #8
      <动态作业 Id> [C] <动态作业 Id 动态作业 Id_trigger1> * * * * * * 2ts 2022-12-02 18:07:45.000 -> 2022-12-02 18:07:46.000

作业触发器特性

默认情况下,框架不会扫描 IJob 实现类的作业触发器特性,但可以设置作业的 IncludeAnnotations 进行启用。

  1. 启用 IncludeAnnotations 扫描
cs
services.AddSchedule(options =>
{
    options.AddJob(JobBuilder.Create<MyJob>().SetIncludeAnnotations(true)
        , Triggers.PeriodSeconds(5));     // 这里可传可不传,传了则会自动载入特性和这里配置的作业触发器

    // 还可以更简单~~
    options.AddJob(typeof(MyJob).ScanToBuilder());

    // 还可以批量新增 Penkar 4.8.2.4+
    options.AddJob(App.EffectiveTypes.ScanToBuilders());
});
  1. MyJob 中添加多个作业触发器特性
cs
[Minutely]
[Cron("3,7,8 * * * * ?", CronStringFormat.WithSeconds)]
public class MyJob : IJob
{
    private readonly ILogger<MyJob> _logger;
    public MyJob(ILogger<MyJob> logger)
    {
        _logger = logger;
    }

    public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
    {
        _logger.LogInformation($"{context}");
        await Task.CompletedTask;
    }
}
  1. 查看作业执行结果
bash
info: 2022-12-02 18:12:56.4199663 +08:00 星期五 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is running.
info: 2022-12-02 18:12:56.4287962 +08:00 星期五 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is preloading...
info: 2022-12-02 18:12:56.6149505 +08:00 星期五 L System.Logging.ScheduleService[0] #1
      The <job1_trigger1> trigger for scheduler of <job1> successfully appended to the schedule.
info: 2022-12-02 18:12:56.6205117 +08:00 星期五 L System.Logging.ScheduleService[0] #1
      The <job1_trigger2> trigger for scheduler of <job1> successfully appended to the schedule.
info: 2022-12-02 18:12:56.6266132 +08:00 星期五 L System.Logging.ScheduleService[0] #1
      The <job1_trigger3> trigger for scheduler of <job1> successfully appended to the schedule.
info: 2022-12-02 18:12:56.6291006 +08:00 星期五 L System.Logging.ScheduleService[0] #1
      The scheduler of <job1> successfully appended to the schedule.
warn: 2022-12-02 18:12:56.6454334 +08:00 星期五 L System.Logging.ScheduleService[0] #1
      Schedule hosted service preload completed, and a total of <1> schedulers are appended.
info: 2022-12-02 18:13:00.0842828 +08:00 星期五 L MyJob[0] #15
      <job1> [C] <job1 job1_trigger2> * * * * * 1ts 2022-12-02 18:13:00.000 -> 2022-12-02 18:14:00.000
info: 2022-12-02 18:13:01.5260220 +08:00 星期五 L MyJob[0] #16
      <job1> [C] <job1 job1_trigger1> 5s 1ts 2022-12-02 18:13:01.494 -> 2022-12-02 18:13:06.492
info: 2022-12-02 18:13:03.0076111 +08:00 星期五 L MyJob[0] #6
      <job1> [C] <job1 job1_trigger3> 3,7,8 * * * * ? 1ts 2022-12-02 18:13:03.000 -> 2022-12-02 18:13:07.000
info: 2022-12-02 18:13:06.4954400 +08:00 星期五 L MyJob[0] #13
      <job1> [C] <job1 job1_trigger1> 5s 2ts 2022-12-02 18:13:06.492 -> 2022-12-02 18:13:11.463
info: 2022-12-02 18:13:07.0180453 +08:00 星期五 L MyJob[0] #6
      <job1> [C] <job1 job1_trigger3> 3,7,8 * * * * ? 2ts 2022-12-02 18:13:07.000 -> 2022-12-02 18:13:08.000
info: 2022-12-02 18:13:08.0114292 +08:00 星期五 L MyJob[0] #13
      <job1> [C] <job1 job1_trigger3> 3,7,8 * * * * ? 3ts 2022-12-02 18:13:08.000 -> 2022-12-02 18:14:03.000
info: 2022-12-02 18:13:11.4774564 +08:00 星期五 L MyJob[0] #16
      <job1> [C] <job1 job1_trigger1> 5s 3ts 2022-12-02 18:13:11.463 -> 2022-12-02 18:13:16.445

HTTP 请求作业

HTTP 请求作业通常用于定时请求/访问互联网地址。

cs
services.AddSchedule(options =>
{
      options.AddHttpJob(request =>
      {
            request.RequestUri = "https://www.chinadot.net";
            request.HttpMethod = HttpMethod.Get;
            // request.Body = "{}"; // 设置请求报文体
      }, Triggers.PeriodSeconds(5));
});

System.Net.Http.IHttpClientFactory 错误

如遇 Unable to resolve service for type 'System.Net.Http.IHttpClientFactory' while attempting to activate 'Penkar.Schedule.HttpJob'. 错误,请先注册 servces.AddHttpClient() 服务。

作业执行日志如下:

bash
info: 2023-03-11 11:05:36.3616747 +08:00 星期六 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is running.
info: 2023-03-11 11:05:36.3652411 +08:00 星期六 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is preloading...
info: 2023-03-11 11:05:36.5172940 +08:00 星期六 L System.Logging.ScheduleService[0] #1
      The <job1_trigger1> trigger for scheduler of <job1> successfully appended to the schedule.
info: 2023-03-11 11:05:36.5189296 +08:00 星期六 L System.Logging.ScheduleService[0] #1
      The scheduler of <job1> successfully appended to the schedule.
warn: 2023-03-11 11:05:36.5347816 +08:00 星期六 L System.Logging.ScheduleService[0] #1
      Schedule hosted service preload completed, and a total of <1> schedulers are appended.
warn: 2023-03-11 11:05:41.5228138 +08:00 星期六 L System.Logging.ScheduleService[0] #15
      Schedule hosted service will sleep <4970> milliseconds and be waked up at <2023-03-11 11:05:46.486>.
info: 2023-03-11 11:05:41.5542865 +08:00 星期六 L System.Net.Http.HttpClient.HttpJob.LogicalHandler[100] #9
      Start processing HTTP request GET https://www.chinadot.net/
info: 2023-03-11 11:05:41.5589056 +08:00 星期六 L System.Net.Http.HttpClient.HttpJob.ClientHandler[100] #9
      Sending HTTP request GET https://www.chinadot.net/
info: 2023-03-11 11:05:44.1305461 +08:00 星期六 L System.Net.Http.HttpClient.HttpJob.ClientHandler[101] #8
      Received HTTP response headers after 2566.7836ms - 200
info: 2023-03-11 11:05:44.1343977 +08:00 星期六 L System.Net.Http.HttpClient.HttpJob.LogicalHandler[101] #8
      End processing HTTP request after 2584.2327ms - 200
info: 2023-03-11 11:05:48.6475959 +08:00 星期六 L System.Logging.ScheduleService[0] #4
      Received HTTP response body with a length of <63639> output as follows - 200
      <!DOCTYPE html><html><head>
            <title>dotNET China |  .NET 开发更简单,更通用,更流行</title>
      ......
          </body></html>

委托方式作业

有时我们需要快速开启新的定时作业但不考虑后续持久化存储(如数据库存储),这时可以使用委托作业方式,如:

cs
services.AddSchedule(options =>
{
    // 和 IJob 的 ExecuteAsync 方法签名一致
    options.AddJob((context, stoppingToken) =>
    {
        // 可通过 context.ServiceProvider 解析服务;框架提供了 .GetLogger() 拓展方法输出日志
        context.ServiceProvider.GetLogger().LogInformation($"{context}");
        return Task.CompletedTask;
    }, Triggers.PeriodSeconds(5));
});

作业执行日志如下:

bash
info: 2023-03-21 14:22:34.1910781 +08:00 星期二 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is running.
info: 2023-03-21 14:22:34.1967420 +08:00 星期二 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is preloading...
info: 2023-03-21 14:22:34.6163320 +08:00 星期二 L System.Logging.ScheduleService[0] #1
      The <job1_trigger1> trigger for scheduler of <job1> successfully appended to the schedule.
info: 2023-03-21 14:22:34.6195112 +08:00 星期二 L System.Logging.ScheduleService[0] #1
      The scheduler of <job1> successfully appended to the schedule.
warn: 2023-03-21 14:22:34.6398162 +08:00 星期二 L System.Logging.ScheduleService[0] #1
      Schedule hosted service preload completed, and a total of <1> schedulers are appended.
info: 2023-03-21 14:22:39.7171392 +08:00 星期二 L System.Logging.DynamicJob[0] #9
      <job1> [C] <job1 job1_trigger1> 5s 1ts 2023-03-21 14:22:39.575 -> 2023-03-21 14:22:44.623
info: 2023-03-21 14:22:44.6986483 +08:00 星期二 L System.Logging.DynamicJob[0] #9
      <job1> [C] <job1 job1_trigger1> 5s 2ts 2023-03-21 14:22:44.623 -> 2023-03-21 14:22:49.657

IOC/DI 项目中使用

在一些不支持依赖注入的项目类型如 Console、WinForm、WPF 中,可以通过以下方式使用:

  • 方式一:无需获取其他服务对象
cs
_ = new ServiceCollection()
    .AddSchedule(options =>
    {
        options.AddJob<MyJob>(Triggers.Period(5000));
    })
    .GetScheduleHostedService()
    .StartAsync(new CancellationTokenSource().Token);
  • 方式二:需要后续解析服务
cs
// 注册服务并构建
IServiceProvider services = new ServiceCollection()
    .AddSchedule(options =>
    {
        options.AddJob<MyJob>(Triggers.Period(5000));
    })
    .BuildServiceProvider();

// 启动作业调度主机服务
services.GetScheduleHostedService()
        .StartAsync(new CancellationTokenSource().Token);

// 解析作业计划工厂
var schedulerFactory =  services.GetService<ISchedulerFactory>();

小知识

只需要将 services 对象用类的静态属性存储起来即可,如:

cs
public class DI
{
   public static IServiceProvider Services {get; set;}
}

之后通过 DI.Services = services; 即可,后续便可以通过 DI.Services.GetService<T>() 解析服务。

作业信息 JobDetail 及构建器

关于作业信息

框架提供了 JobDetail 类型来描述作业信息,JobDetail 类型提供以下只读属性

属性名属性类型默认值说明
JobIdstring作业 Id
GroupNamestring作业组名称
JobTypestring作业处理程序类型,存储的是类型的 FullName
AssemblyNamestring作业处理程序类型所在程序集,存储的是程序集 Name
Descriptionstring描述信息
Concurrentbooltrue作业执行方式,如果设置为 false,那么使用 串行 执行,否则 并行 执行
IncludeAnnotationsboolfalse是否扫描 IJob 实现类 [Trigger] 特性触发器
Propertiesstring"{}"作业信息额外数据,由 Dictionary<string, object> 序列化成字符串存储
UpdatedTimeDateTime?作业更新时间

关于作业信息构建器

作业信息 JobDetail 是作业调度模块提供运行时的只读类型,那么我们该如何创建或变更 JobDetail 对象呢?

JobBuilder 是作业调度模块提供可用来生成运行时 JobDetail 的类型,这样做的好处可避免外部直接修改运行时 JobDetail 数据,还能实现任何修改动作监听,也能避免多线程抢占情况。

作业调度模块提供了多种方式用来创建 JobBuilder 对象。

  1. 通过 Create 静态方法创建
cs
// 根据作业 Id 创建
var jobBuilder = JobBuilder.Create("job1");

// 根据 IJob 实现类类型创建
var jobBuilder = JobBuilder.Create<MyJob>();

// 根据程序集名称和类型完全限定名(FullName)创建
var jobBuilder = JobBuilder.Create("YourProject", "YourProject.MyJob");

// 根据 Type 类型创建
var jobBuilder = JobBuilder.Create(typeof(MyJob));

// 通过委托创造动态作业
var jobBuilder = JobBuilder.Create((context, stoppingToken) =>
{
      context.ServiceProvider.GetLogger().LogInformation($"{context}");
      return Task.CompletedTask;
});
  1. 通过 JobDetail 类型创建

这种方式常用于在运行时更新作业信息。

cs
var jobBuilder = JobBuilder.From(jobDetail);

//也可以通过以下方式
var jobBuilder = jobDetail.GetBuilder();
  1. 通过 JSON 字符串创建

该方式非常灵活,可从配置文件,JSON 字符串,或其他能够返回 JSON 字符串的地方创建。

cs
var jobBuilder = JobBuilder.From(@"{
 ""jobId"": ""job1"",
 ""groupName"": null,
 ""jobType"": ""MyJob"",
 ""assemblyName"": ""ConsoleApp13"",
 ""description"": null,
 ""concurrent"": true,
 ""includeAnnotations"": false,
 ""properties"": ""{}"",
 ""updatedTime"": null
}");

如果使用的是 .NET7,可使用 """ 避免转义,如:

cs
var jobBuilder = JobBuilder.From("""
{
 "jobId": "job1",
 "groupName": null,
 "jobType": "MyJob",
 "assemblyName": "ConsoleApp13",
 "description": null,
 "concurrent": true,
 "includeAnnotations": false,
 "properties": "{}",
 "updatedTime": "2022-12-02 18:00:59.390"
}
""");

关于属性名匹配规则

支持 CamelCase(驼峰命名法)Pascal(帕斯卡命名法) 命名方式。

不支持 UnderScoreCase(下划线命名法) ,如 "include_annotations": true

  1. 还可以通过 Clone 静态方法从一个 JobBuilder 创建
cs
var jobBuilder = JobBuilder.Clone(fromJobBuilder);

克隆说明

克隆操作只会克隆 AssemblyNameJobTypeGroupNameDescriptionConcurrentIncludeAnnotationsPropertiesDynamicExecuteAsync(动态作业)。

  • 不会克隆 JobIdUpdatedTime
  1. 还可以通过 LoadFrom 实例方法填充当前的 JobBuilder

比如可以传递匿名类型,类类型,字典 Dictionary<string, object> 类型:

cs
// 会覆盖所有相同的值
jobBuilder.LoadFrom(new
{
      Description = "我是描述",
      Concurrent = false
});

// 支持多个填充,还可以配置跳过 null 值覆盖
jobBuilder.LoadFrom(new
{
      Description = "我是另外一个描述",
      Concurrent = false,
      IncludeAnnotations = default(object)      // 会跳过赋值
}, ignoreNullValue: true);

// 支持忽略特定属性名映射
jobBuilder.LoadFrom(new
{
      Description = "我是另外一个描述",
      Concurrent = false,
      IncludeAnnotations = default(object)      // 会跳过赋值
}, ignorePropertyNames: new[]{ "description" });

// 支持字典类型
jobBuilder.LoadFrom(new Dictionary<string, object>
{
      {"Description", "这是新的描述" },
      {"include_annotations", false },
      {"updatedTime", DateTime.Now }
});

关于属性名匹配规则

支持 CamelCase(驼峰命名法)Pascal(帕斯卡命名法)UnderScoreCase(下划线命名法) 命名方式。

设置作业信息构建器

JobBuilder 提供了和 JobDetail 完全匹配的 Set[属性名] 方法来配置作业信息各个属性,如:

cs
services.AddSchedule(options =>
{
    var jobBuilder = JobBuilder.Create<MyJob>()
        .SetJobId("job1")   // 作业 Id
        .SetGroupName("group1") // 作业组名称
        .SetJobType("Penkar.Application", "Penkar.Application.MyJob") // 作业类型,支持多个重载
        .SetJobType<MyJob>()    // 作业类型,支持多个重载
        .SetJobType(typeof(MyJob))  // 作业类型,支持多个重载
        .SetDescription("这是一段描述")   // 作业描述
        .SetConcurrent(false)   // 并行还是串行方式,false 为 串行
        .SetIncludeAnnotations(true)    // 是否扫描 IJob 类型的触发器特性,true 为 扫描
        .SetProperties("{}")    // 作业额外数据 Dictionary<string, object> 类型序列化,支持多个重载
        .SetProperties(new Dictionary<string, object> { { "name", "Penkar" } })  // 作业类型额外数据,支持多个重载,推荐!!!
        .SetDynamicExecuteAsync((context, stoppingToken) => {
            context.ServiceProvider.GetLogger().LogInformation($"{context}");
            return Task.CompletedTask;
        })  // 动态委托处理程序,一旦设置了此委托,那么优先级将大于 MyJob 的 ExecuteAsync
        ;

    options.AddJob(jobBuilder, Triggers.PeriodSeconds(5));
});

作业信息/构建器额外数据

有时候我们需要在作业运行的时候添加一些额外数据,或者实现多个触发器共享数据,经常用于 串行 执行中(并行 也同样工作),后面一个触发器需等待前一个触发器完成。

cs
public class MyJob : IJob
{
    private readonly ILogger<MyJob> _logger;
    public MyJob(ILogger<MyJob> logger)
    {
        _logger = logger;
    }

    public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
    {
        var jobDetail = context.JobDetail;

        var count = jobDetail.GetProperty<int>("count");
        jobDetail.AddOrUpdateProperty("count", count + 1);  // 递增 count

        _logger.LogInformation($"count: {count} {context}");

        await Task.CompletedTask;
    }
}

查看作业运行日志:

bash
info: 2022-12-03 23:16:46.5150228 +08:00 星期六 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is running.
info: 2022-12-03 23:16:46.5197497 +08:00 星期六 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is preloading...
info: 2022-12-03 23:16:46.6987703 +08:00 星期六 L System.Logging.ScheduleService[0] #1
      The <job1_trigger1> trigger for scheduler of <job1> successfully appended to the schedule.
info: 2022-12-03 23:16:46.7003295 +08:00 星期六 L System.Logging.ScheduleService[0] #1
      The scheduler of <job1> successfully appended to the schedule.
warn: 2022-12-03 23:16:46.7248216 +08:00 星期六 L System.Logging.ScheduleService[0] #1
      Schedule hosted service preload completed, and a total of <1> schedulers are appended.
info: 2022-12-03 23:16:51.7013640 +08:00 星期六 L MyJob[0] #8
      count: 0 <job1> [C] <job1 job1_trigger1> 5s 1ts 2022-12-03 23:16:51.663 -> 2022-12-03 23:16:56.656
info: 2022-12-03 23:16:56.6768044 +08:00 星期六 L MyJob[0] #9
      count: 1 <job1> [C] <job1 job1_trigger1> 5s 2ts 2022-12-03 23:16:56.656 -> 2022-12-03 23:17:01.635
info: 2022-12-03 23:17:01.6454604 +08:00 星期六 L MyJob[0] #8
      count: 2 <job1> [C] <job1 job1_trigger1> 5s 3ts 2022-12-03 23:17:01.635 -> 2022-12-03 23:17:06.608
info: 2022-12-03 23:17:06.6247917 +08:00 星期六 L MyJob[0] #6
      count: 3 <job1> [C] <job1 job1_trigger1> 5s 4ts 2022-12-03 23:17:06.608 -> 2022-12-03 23:17:11.586

作业调度模块为 JobDetailJobBuilder 提供了多个方法操作额外数据:

cs
// 查看所有额外数据
var properties = jobDetail.GetProperties();

// 查看单个额外数据,返回 object
var value = jobBuilder.GetProperty("key");

// 查看单个额外数据泛型
var value = jobDetail.GetProperty<int>("key");

// 添加新的额外数据,支持链式操作,如果键已存在,则跳过
jobDetail.AddProperty("key", "Penkar").AddProperty("key1", 2);

// 添加或更新额外数据,支持链式操作,不存在则新增,存在则替换,推荐
jobDetail.AddOrUpdateProperty("key", "Penkar").AddOrUpdateProperty("key1", 2);

// 还可以通过委托的方式:如果键不存在则插入 count = newValue,否则更新为 value(旧值)+1
jobDetail.AddOrUpdateProperty("count", newValue, value => value + 1);

// 删除某个额外数据,支持链式操作,如果 key 不存在则跳过
jobDetail.RemoveProperty("key").RemoveProperty("key1");

// 清空所有额外数据
jobDetail.ClearProperties();

作业额外数据类型支持

作业额外数据每一项的值只支持 int32stringboolnull 或它们组成的数组类型。

作业信息特性

作业信息特性 [JobDetail] 是为了方便运行时或启动时快速创建作业计划构建器而提供的,可在启动时或运行时通过以下方式创建,如:

cs
[JobDetail("job1", "这是一段描述")]
[PeriodSeconds(5, TriggerId = "trigger1")]
public class MyJob : IJob
{
}
  • 启动 IncludeAnnotations 属性自动填充
cs
services.AddSchedule(options =>
{
    options.AddJob(JobBuilder.Create<MyJob>()
      .SetIncludeAnnotations(true));      // 此时 [JobDetail] 配置的非空属性将自动复制给 JobBuilder,[PeriodSeconds] 也会自动创建 TriggerBuilder
});
  • 手动扫描并创建作业计划构建器
cs
var schedulerBuilder = typeof(MyJob).ScanToBuilder();
  • 通过程序集类型扫描批量创建作业计划构建器

也可以用于作业持久化 Preload 初始化时使用:

cs
public IEnumerable<SchedulerBuilder> Preload()
{
      // 扫描所有类型并创建
      return App.EffectiveTypes.Where(t => t.IsJobType())
                              .Select(t => t.ScanToBuilder());

      // 还可以更简单~~
      return App.EffectiveTypes.ScanToBuilders();
}

作业信息特性还提供了多个属性配置,如:

  • JobId:作业信息 Id,string 类型
  • GroupName:作业组名称,string 类型
  • Description:描述信息,string 类型
  • Concurrent:是否采用并行执行,bool 类型,如果设置为 false,那么使用 串行 执行

使用如下:

cs
[JobDetail("jobId")]    // 仅作业 Id
[JobDetail("jobId", "这是一段描述")] // 描述
[JobDetail("jobId", false)] // 串行
[JobDetail("jobId", false, "这是一段描述")] // 串行 + 描述
[JobDetail("jobId", Concurrent = false, Description = "这是一段描述")]
[JobDetail("jobId", Concurrent = false, Description = "这是一段描述", GroupName = "分组名")]
public class MyJob : IJob
{
      // ....
}

多种格式字符串输出

JobDetailJobBuilder 都提供了多种将自身转换成特定格式的字符串。

  1. 转换成 JSON 字符串
cs
var json = jobDetail.ConvertToJSON();

字符串打印如下:

json
{
  "jobId": "job1",
  "groupName": null,
  "jobType": "MyJob",
  "assemblyName": "ConsoleApp13",
  "description": null,
  "concurrent": true,
  "includeAnnotations": false,
  "properties": "{}",
  "updatedTime": "2022-12-04 11:51:00.483"
}
  1. 转换成 SQL 字符串
cs
// 输出新增 SQL,使用 CamelCase 属性命名
var insertSql = jobDetail.ConvertToSQL("tbName"
      , PersistenceBehavior.Appended
      , NamingConventions.CamelCase);
// 更便捷拓展
var insertSql = jobDetail.ConvertToInsertSQL("tbName", NamingConventions.CamelCase);

// 输出删除 SQL,使用 Pascal 属性命名
var deleteSql = jobDetail.ConvertToSQL("tbName"
      , PersistenceBehavior.Removed
      , NamingConventions.Pascal);
// 更便捷拓展
var deleteSql = jobDetail.ConvertToDeleteSQL("tbName", NamingConventions.Pascal);

// 输出更新 SQL,使用 UnderScoreCase 属性命名
var updateSql = jobDetail.ConvertToSQL("tbName"
      , PersistenceBehavior.Updated
      , NamingConventions.UnderScoreCase);
// 更便捷拓展
var updateSql = jobDetail.ConvertToUpdateSQL("tbName", NamingConventions.UnderScoreCase);

字符串打印如下:

sql
-- 新增语句
INSERT INTO tbName(
      jobId,
      groupName,
      jobType,
      assemblyName,
      description,
      concurrent,
      includeAnnotations,
      properties,
      updatedTime
)
VALUES(
      'job1',
      NULL,
      'MyJob',
      'ConsoleApp13',
      NULL,
      1,
      0,
      '{}',
      '2022-12-04 11:53:05.489'
);
-- 删除语句
DELETE FROM tbName
WHERE JobId = 'job1';
-- 更新语句
UPDATE tbName
SET
      job_id = 'job1',
      group_name = NULL,
      job_type = 'MyJob',
      assembly_name = 'ConsoleApp13',
      description = NULL,
      concurrent = 1,
      include_annotations = 0,
      properties = '{}',
      updated_time = '2022-12-04 11:53:05.489'
WHERE job_id = 'job1';
  1. 转换成 Monitor 字符串
cs
var monitor = jobDetail.ConvertToMonitor();

字符串打印如下:

bash
┏━━━━━━━━━━━  JobDetail ━━━━━━━━━━━
 MyJob

 jobId:                     job1
 groupName:
 jobType:                   MyJob
 assemblyName:              ConsoleApp13
 description:
 concurrent:                True
 includeAnnotations:        False
 properties:                {}
 updatedTime:               2022-12-04 11:55:11.186
┗━━━━━━━━━━━  JobDetail ━━━━━━━━━━━
  1. 简要字符串输出
cs
var str = jobDetail.ToString();

字符串打印如下:

bash
<job1> 这是一段描述 [C]

自定义 SQL 输出配置

cs
services.AddSchedule(options =>
{
    options.JobDetail.ConvertToSQL = (tableName, columnNames, jobDetail, behavior, naming) =>
    {
      // 生成新增 SQL
      if (behavior == PersistenceBehavior.Appended)
      {
            return jobDetail.ConvertToInsertSQL(tableName, naming);
      }
      // 生成更新 SQL
      else if (behavior == PersistenceBehavior.Updated)
      {
            return jobDetail.ConvertToUpdateSQL(tableName, naming);
      }
      // 生成删除 SQL
      else if (behavior == PersistenceBehavior.Removed)
      {
            return jobDetail.ConvertToDeleteSQL(tableName, naming);
      }

      return string.Empty;
    };
});
  • ConvertToSQL 委托参数说明
    • tableName:数据库表名称,string 类型
    • columnNames:数据库列名:string[] 类型,只能通过 索引 获取
    • jobDetail:作业信息 JobDetail 对象
    • behavior:持久化 PersistenceBehavior 类型,用于标记 新增更新 还是 删除 操作
    • naming:命名法 NamingConventions 类型,包含 CamelCase(驼峰命名法)Pascal(帕斯卡命名法)UnderScoreCase(下划线命名法)

注意事项

如果在该自定义 SQL 输出方法中调用 jobDetail.ConvertToSQL(..) 会导致死循环。

启用作业执行日志输出

通常我们需要在 IJob 实现类中输出作业触发日志,如 _logger.LogInformation($"{context}");

cs
public class MyJob : IJob
{
    private readonly ILogger<MyJob> _logger;
    public MyJob(ILogger<MyJob> logger)
    {
        _logger = logger;
    }

    public Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
    {
        _logger.LogInformation($"{context}");
        return Task.CompletedTask;
    }
}

但这样的 范式代码 几乎每一个 IJob 实现类都可能输出,所以提供了更便捷的配置,无需每一个 IJob 编写 _logger.LogInformation($"{context}");

配置启用如下:

cs
services.AddSchedule(options =>
{
    options.JobDetail.LogEnabled = true;  // 默认 false
});

之后 MyJob 可以更加精简了,日志类别自动设置为 MyJob 类型完整限定名。

cs
public class MyJob : IJob
{
    public Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
    {
        // 这里写业务逻辑即可,无需调用 _logger.LogInformation($"{context}");
        return Task.CompletedTask;
    }
}

作业执行日志如下:

bash
info: 2022-12-14 11:56:12.3963326 +08:00 星期三 L Penkar.Application.MyJob[0] #4
      <job1> [C] <job1 job1_trigger2> 5s 1ts 2022-12-14 11:56:08.361 -> 2022-12-14 11:56:13.366
info: 2022-12-14 11:56:13.4100745 +08:00 星期三 L Penkar.Application.MyJob[0] #6
      <job1> [C] <job1 job1_trigger2> 5s 2ts 2022-12-14 11:56:13.366 -> 2022-12-14 11:56:18.376
info: 2022-12-14 11:56:18.3931380 +08:00 星期三 L Penkar.Application.MyJob[0] #9
      <job1> [C] <job1 job1_trigger2> 5s 3ts 2022-12-14 11:56:18.376 -> 2022-12-14 11:56:23.360

作业处理程序 IJob

作业处理程序是作业符合触发时间执行的业务逻辑代码,通常由程序员编写,作业处理程序需实现 IJob 接口。

如何定义

cs
public class MyJob : IJob
{
    public Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
    {
        // your code...
    }
}

JobExecutingContext 上下文

JobExecutingContext 上下文作为 ExecuteAsync 方法的第一个参数,包含以下运行时信息:

  • JobExecutingContext 属性列表
    • JobId:作业 Id
    • TriggerId:当前触发器 Id
    • JobDetail:作业信息
    • Trigger:作业触发器
    • OccurrenceTime:作业计划触发时间,最准确的记录时间
    • ExecutingTime:实际执行时间(可能存在误差)
    • RunId:本次作业执行唯一 IdPenkar 4.8.5.1+ 提供
    • Result:设置/读取本次作业执行结果,Penkar 4.8.7.7+ 提供
    • ServiceProvider:服务提供器,Penkar 4.8.7.10+ 提供
  • JobExecutingContext 方法列表
    • .ConvertToJSON(naming):将上下文转换成 JSON 字符串
    • .ToString():输出为字符串

作业处理程序实例

默认情况下,作业处理程序会在作业触发器符合触发条件下通过 ActivatorUtilities.CreateInstance 动态创建,也就是每次触发都会创建新的 IJob 实例,如:

cs
var jobHandler = ActivatorUtilities.CreateInstance(_serviceProvider, jobType);

其中 _serviceProvider单例服务提供器,所以 IJob 实现类只能通过构造函数注入 单例服务。如果没有范围作用域服务的需求,那么可以将 IJob 注册为单例服务,这样就可以避免每次重复创建 IJob 实例,对性能和减少内存占用有不小优化。 如:

cs
services.AddSingleton<YourJob>();

如果希望能够在构造函数注入范围作用域或瞬时作用域,可实现 IJobFactory 接口,如:

cs
using Penkar.Schedule;
using Microsoft.Extensions.DependencyInjection;

namespace Penkar.Application;

public class JobFactory : IJobFactory
{
    public IJob CreateJob(IServiceProvider serviceProvider, JobFactoryContext context)
    {
        return ActivatorUtilities.CreateInstance(serviceProvider, context.JobType) as IJob;

        // 如果通过 services.AddSingleton<YourJob>(); 或 serivces.AddScoped<YourJob>(); 或 services.AddTransient<YourJob> 可通过下列方式
        // return serviceProvider.GetRequiredService(context.JobType) as IJob;
    }
}

之后注册 JobFactory 即可,如:

cs
services.AddSchedule(options =>
{
      // 添加作业处理程序工厂
      options.AddJobFactory<JobFactory>();
});

这样作业就可以注入范围和瞬时服务了。

依赖注入

实现 IJob 的作业处理程序类型默认注册为 单例那么只要是单例的服务,皆可以通过构造函数注入,如:ILogger<>IConfiguration

cs
public class MyJob : IJob
{
    private readonly ILogger<MyJob> _logger;
    private readonly IConfiguration _configuration;

    public MyJob(ILogger<MyJob> logger
        , IConfiguration configuration)
    {
        _logger = logger;
        _configuration = configuration;
    }

    public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
    {
        _logger.LogInformation($"{context} {_configuration["key"]}");
        await Task.CompletedTask;
    }
}
  • 如果是非 单例 的接口,如 瞬时范围 服务,可通过 IServiceScopeFactory 创建

推荐 IJobFactory 方式

通过上一小节 IJobFactory 统一实现。推荐

cs
public class MyJob : IJob
{
    private readonly ILogger<MyJob> _logger;
    private readonly IConfiguration _configuration;
    private readonly IServiceScopeFactory _scopeFactory;

    public MyJob(ILogger<MyJob> logger
        , IConfiguration configuration
        , IServiceScopeFactory scopeFactory)
    {
        _logger = logger;
        _configuration = configuration;
        _schedulerFactory = scopeFactory;
    }

    public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
    {
        using var serviceScope = _scopeFactory.CreateScope();
        var repository = serviceScope.ServiceProvider.GetService<IRepository<User>>();

        _logger.LogInformation($"{context} {_configuration["key"]}");
        await Task.CompletedTask;
    }
}
  • 针对高频定时任务,比如每秒执行一次,或者更频繁的任务

推荐 IJobFactory 方式

通过上一小节 IJobFactory 统一实现。推荐

为了避免频繁创建作用域和销毁作用域,可创建长范围的作用域。

cs
public class MyJob : IJob, IDisposable
{
    private readonly ILogger<MyJob> _logger;
    private readonly IConfiguration _configuration;
    private readonly IServiceScope _serviceScope;

    public MyJob(ILogger<MyJob> logger
        , IConfiguration configuration
        , IServiceScopeFactory scopeFactory)
    {
        _logger = logger;
        _configuration = configuration;
        _serviceScope = scopeFactory.CreateScope();
    }

    public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
    {
        var repository = _serviceScope.ServiceProvider.GetService<IRepository<User>>();
        var user = await repository.GetAsync(1);

        _logger.LogInformation($"{context} {_configuration["key"]}");
        await Task.CompletedTask;
    }

    public void Dispose()
    {
        _serviceScope?.Dispose();
    }
}

动态作业 DynamicJob

框架提供了便捷的动态作业 DynamicJob 类型,可通过 Func<JobExecutingContext, CancellationToken, Task> 委托传入,无需创建 IJob 实现类型。

框架还为 JobExecutionContext 属性 ServiceProvder 提供了 .GetLogger() 拓展方法,方便快速获取 ILogger<System.Logging.DynamicJob> 日志对象实例。

cs
// 通过 JobBuilder 创建
var jobBuilder = JobBuilder.Create((context, stoppingToken) =>
{
      context.ServiceProvider.GetLogger().LogInformation($"{context}");
      return Task.CompletedTask;
});

// 通过 jobBuilder 方法 SetDynamicExecuteAsync 创建
jobBuilder.SetDynamicExecuteAsync((context, stoppingToken) =>
{
      context.ServiceProvider.GetLogger().LogInformation($"{context}");
      return Task.CompletedTask;
});

// 通过 AddJob 创建
service.AddSchedule(options =>
{
      options.AddJob((context, stoppingToken) =>
      {
            context.ServiceProvider.GetLogger().LogInformation($"{context}");
            return Task.CompletedTask;
      }, Triggers.PeriodSeconds(5));
});

// 通过 ISchedulerFactory 创建
_schedulerFactory.AddJob((context, stoppingToken) =>
{
      context.ServiceProvider.GetLogger().LogInformation($"{context}");
      return Task.CompletedTask;
}, Triggers.PeriodSeconds(5));

动态作业执行结果:

bash
info: 2022-12-04 12:26:18.6562296 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is running.
info: 2022-12-04 12:26:18.6618404 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is preloading...
info: 2022-12-04 12:26:18.8727764 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      The <job1_trigger1> trigger for scheduler of <job1> successfully appended to the schedule.
info: 2022-12-04 12:26:18.8745765 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      The scheduler of <job1> successfully appended to the schedule.
warn: 2022-12-04 12:26:18.9013540 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      Schedule hosted service preload completed, and a total of <1> schedulers are appended.
info: 2022-12-04 12:26:23.8753926 +08:00 星期日 L System.Logging.DynamicJob[0] #6
      <job1> [C] <job1 job1_trigger1> 5s 1ts 2022-12-04 12:26:23.837 -> 2022-12-04 12:26:28.835
info: 2022-12-04 12:26:28.8686474 +08:00 星期日 L System.Logging.DynamicJob[0] #6
      <job1> [C] <job1 job1_trigger1> 5s 2ts 2022-12-04 12:26:28.835 -> 2022-12-04 12:26:33.823
info: 2022-12-04 12:26:33.8531796 +08:00 星期日 L System.Logging.DynamicJob[0] #13
      <job1> [C] <job1 job1_trigger1> 5s 3ts 2022-12-04 12:26:33.823 -> 2022-12-04 12:26:38.820

动态作业和普通作业的区别

  • 动态作业处理程序类型是:DynamicJob 类型
  • 动态作业提供的 .GetLogger() 拓展输出日志类别是:System.Logging.DynamicJob
  • 如果普通作业同时设置了 SetJobTypeSetDynamicExecuteAsync,那么优先作为动态作业执行。
  • 动态作业无法将 Func<..> 进行序列化持久化存储

使用 Roslyn 动态创建

按照程序开发的正常思维,理应先在代码中创建作业处理程序类型,但我们可以借助 Roslyn 动态编译 C# 代码。

  1. 根据字符串创建 IJob 类型
cs
// 调用 Schedular 静态类提供的 CompileCSharpClassCode 方法
var jobAssembly = Schedular.CompileCSharpClassCode(@"
using Penkar.Schedule;
using Microsoft.Extensions.Logging;
using System;
using System.Threading;
using System.Threading.Tasks;

namespace YourProject;

public class MyJob : IJob
{
    private readonly ILogger<MyJob> _logger;

    public MyJob(ILogger<MyJob> logger)
    {
        _logger = logger;
    }

    public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
    {
        _logger.LogInformation($""我是 Roslyn 方式创建的:{context}"");
        await Task.CompletedTask;
    }
}
");

// 生成运行时 MyJob 类型
var jobType = jobAssembly.GetType("YourProject.MyJob");
  1. 注册作业
cs
// 可以在启动的时候添加
services.AddSchedule(options =>
{
     options.AddJob(jobType
            , Triggers.PeriodSeconds(5));
});

// 也可以完全在运行时添加(常用)
_schedulerFactory.AddJob(jobType
            , Triggers.PeriodSeconds(5));

查看作业执行日志:

bash
info: 2022-12-04 12:38:00.6249410 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is running.
info: 2022-12-04 12:38:00.6294089 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is preloading...
info: 2022-12-04 12:38:00.7496005 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      The <job1_trigger1> trigger for scheduler of <job1> successfully appended to the schedule.
info: 2022-12-04 12:38:00.7514579 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      The scheduler of <job1> successfully appended to the schedule.
warn: 2022-12-04 12:38:00.7836777 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      Schedule hosted service preload completed, and a total of <1> schedulers are appended.
info: 2022-12-04 12:38:05.7389682 +08:00 星期日 L YourProject.MyJob[0] #6
      我是 Roslyn 方式创建的:<job1> [C] <job1 job1_trigger1> 5s 1ts 2022-12-04 12:38:05.713 -> 2022-12-04 12:38:10.692
info: 2022-12-04 12:38:10.7108416 +08:00 星期日 L YourProject.MyJob[0] #11
      我是 Roslyn 方式创建的:<job1> [C] <job1 job1_trigger1> 5s 2ts 2022-12-04 12:38:10.692 -> 2022-12-04 12:38:15.673
info: 2022-12-04 12:38:15.6925578 +08:00 星期日 L YourProject.MyJob[0] #11
      我是 Roslyn 方式创建的:<job1> [C] <job1 job1_trigger1> 5s 3ts 2022-12-04 12:38:15.673 -> 2022-12-04 12:38:20.656

惊不惊喜,意外意外~

小知识

通过 Roslyn 的方式支持创建 IJobJobDetailTriggerScheduler 哦,自行测试。😊

作业执行异常处理

正常情况下,程序员应该保证作业执行程序总是稳定运行,但有时候会出现一些不可避免的意外导致出现异常,如网络异常等。

下面给出模拟出现异常和常见的处理方式例子:

cs
services.AddSchedule(options =>
{
    options.AddJob<MyJob>(Triggers.PeriodSeconds(3));
});
cs
public class MyJob : IJob
{
    private readonly ILogger<MyJob> _logger;
    public MyJob(ILogger<MyJob> logger)
    {
        _logger = logger;
    }

    public Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
    {
        _logger.LogInformation($"{context}");

        // 模拟异常
        var num = 10;
        var n = 0;
        var c = num / n;

        return Task.CompletedTask;
    }
}

输出日志如下:

bash
info: 2023-04-22 22:18:04.2149071 +08:00 星期六 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is running.
info: 2023-04-22 22:18:04.2189082 +08:00 星期六 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is preloading...
info: 2023-04-22 22:18:04.3216571 +08:00 星期六 L System.Logging.ScheduleService[0] #1
      The <job1_trigger1> trigger for scheduler of <job1> successfully appended to the schedule.
info: 2023-04-22 22:18:04.3230110 +08:00 星期六 L System.Logging.ScheduleService[0] #1
      The scheduler of <job1> successfully appended to the schedule.
warn: 2023-04-22 22:18:04.3521056 +08:00 星期六 L System.Logging.ScheduleService[0] #1
      Schedule hosted service preload completed, and a total of <1> schedulers are appended.
info: 2023-04-22 22:18:07.3782666 +08:00 星期六 L MyJob[0] #17
      <job1> [C] <job1 job1_trigger1> 3s 1ts 2023-04-22 22:18:07.288 -> 2023-04-22 22:18:10.308
fail: 2023-04-22 22:18:07.6652239 +08:00 星期六 L System.Logging.ScheduleService[0] #17
      Error occurred executing <job1> [C] <job1 job1_trigger1> 3s 1ts 2023-04-22 22:18:07.288 -> 2023-04-22 22:18:10.308.
      ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
      System.DivideByZeroException: Attempted to divide by zero.
         at MyJob.ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken) in C:\Users\MonkSoul\source\repos\ConsoleApp3\Program.cs:line 29
         at Penkar.Schedule.ScheduleHostedService.<>c__DisplayClass24_3.<<BackgroundProcessing>b__3>d.MoveNext() in C:\Workplaces\Penkar\framework\Penkar\Schedule\HostedServices\ScheduleHostedService.cs:line 233
      --- End of stack trace from previous location ---
         at Penkar.FriendlyException.Retry.InvokeAsync(Func`1 action, Int32 numRetries, Int32 retryTimeout, Boolean finalThrow, Type[] exceptionTypes, Func`2 fallbackPolicy, Action`2 retryAction) in C:\Workplaces\Penkar\framework\Penkar\FriendlyException\Retry.cs:line 79
         at Penkar.Schedule.ScheduleHostedService.<>c__DisplayClass24_2.<<BackgroundProcessing>b__2>d.MoveNext() in C:\Workplaces\Penkar\framework\Penkar\Schedule\HostedServices\ScheduleHostedService.cs:line 231
      ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
info: 2023-04-22 22:18:10.3507729 +08:00 星期六 L MyJob[0] #8
      <job1> [C] <job1 job1_trigger1> 3s 2ts 2023-04-22 22:18:10.308 -> 2023-04-22 22:18:13.318
fail: 2023-04-22 22:18:10.4292529 +08:00 星期六 L System.Logging.ScheduleService[0] #8
      Error occurred executing <job1> [C] <job1 job1_trigger1> 3s 2ts 2023-04-22 22:18:10.308 -> 2023-04-22 22:18:13.318.
      ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
      System.DivideByZeroException: Attempted to divide by zero.
         at MyJob.ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken) in C:\Users\MonkSoul\source\repos\ConsoleApp3\Program.cs:line 29
         at Penkar.Schedule.ScheduleHostedService.<>c__DisplayClass24_3.<<BackgroundProcessing>b__3>d.MoveNext() in C:\Workplaces\Penkar\framework\Penkar\Schedule\HostedServices\ScheduleHostedService.cs:line 233
      --- End of stack trace from previous location ---
         at Penkar.FriendlyException.Retry.InvokeAsync(Func`1 action, Int32 numRetries, Int32 retryTimeout, Boolean finalThrow, Type[] exceptionTypes, Func`2 fallbackPolicy, Action`2 retryAction) in C:\Workplaces\Penkar\framework\Penkar\FriendlyException\Retry.cs:line 79
         at Penkar.Schedule.ScheduleHostedService.<>c__DisplayClass24_2.<<BackgroundProcessing>b__2>d.MoveNext() in C:\Workplaces\Penkar\framework\Penkar\Schedule\HostedServices\ScheduleHostedService.cs:line 231
      ++++++++++++++++++++++++++++++++++++++++++++++++++++++++

从上面执行日志可以看出,作业执行出现异常只会影响当前触发时间的执行,但不会影响下一次执行。出现这种情况通常配置 重试策略 确保每次作业处理程序可能执行成功,如重试 3 次,如:

cs
services.AddSchedule(options =>
{
    options.AddJob<MyJob>(Triggers.PeriodSeconds(3)
                                  .SetNumRetries(3)); // 重试三次
});

输出日志如下:

bash
info: 2023-04-22 22:25:00.7244392 +08:00 星期六 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is running.
info: 2023-04-22 22:25:00.7293195 +08:00 星期六 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is preloading...
info: 2023-04-22 22:25:00.8796238 +08:00 星期六 L System.Logging.ScheduleService[0] #1
      The <job1_trigger1> trigger for scheduler of <job1> successfully appended to the schedule.
info: 2023-04-22 22:25:00.8852651 +08:00 星期六 L System.Logging.ScheduleService[0] #1
      The scheduler of <job1> successfully appended to the schedule.
warn: 2023-04-22 22:25:00.9348100 +08:00 星期六 L System.Logging.ScheduleService[0] #1
      Schedule hosted service preload completed, and a total of <1> schedulers are appended.
info: 2023-04-22 22:25:03.9357047 +08:00 星期六 L MyJob[0] #12
      <job1> [C] <job1 job1_trigger1> 3s 1ts 2023-04-22 22:25:03.840 -> 2023-04-22 22:25:06.888
warn: 2023-04-22 22:25:04.0147234 +08:00 星期六 L System.Logging.ScheduleService[0] #12
      Retrying 1/3 times for <job1> [C] <job1 job1_trigger1> 3s 1ts 2023-04-22 22:25:03.840 -> 2023-04-22 22:25:06.888
info: 2023-04-22 22:25:05.0243650 +08:00 星期六 L MyJob[0] #12
      <job1> [C] <job1 job1_trigger1> 3s 1ts 2023-04-22 22:25:03.840 -> 2023-04-22 22:25:06.888
warn: 2023-04-22 22:25:05.0963359 +08:00 星期六 L System.Logging.ScheduleService[0] #12
      Retrying 2/3 times for <job1> [C] <job1 job1_trigger1> 3s 1ts 2023-04-22 22:25:03.840 -> 2023-04-22 22:25:06.888
info: 2023-04-22 22:25:06.1100662 +08:00 星期六 L MyJob[0] #12
      <job1> [C] <job1 job1_trigger1> 3s 1ts 2023-04-22 22:25:03.840 -> 2023-04-22 22:25:06.888
warn: 2023-04-22 22:25:06.1785087 +08:00 星期六 L System.Logging.ScheduleService[0] #12
      Retrying 3/3 times for <job1> [C] <job1 job1_trigger1> 3s 1ts 2023-04-22 22:25:03.840 -> 2023-04-22 22:25:06.888
fail: 2023-04-22 22:25:07.3754596 +08:00 星期六 L System.Logging.ScheduleService[0] #16
      Error occurred executing <job1> [C] <job1 job1_trigger1> 3s 2ts 2023-04-22 22:25:03.840 -> 2023-04-22 22:25:09.884.
      ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
      System.DivideByZeroException: Attempted to divide by zero.
         at MyJob.ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken) in C:\Users\MonkSoul\source\repos\ConsoleApp3\Program.cs:line 30
         at Penkar.Schedule.ScheduleHostedService.<>c__DisplayClass24_3.<<BackgroundProcessing>b__3>d.MoveNext() in C:\Workplaces\Penkar\framework\Penkar\Schedule\HostedServices\ScheduleHostedService.cs:line 233
      --- End of stack trace from previous location ---
         at Penkar.FriendlyException.Retry.InvokeAsync(Func`1 action, Int32 numRetries, Int32 retryTimeout, Boolean finalThrow, Type[] exceptionTypes, Func`2 fallbackPolicy, Action`2 retryAction) in C:\Workplaces\Penkar\framework\Penkar\FriendlyException\Retry.cs:line 91
         at Penkar.FriendlyException.Retry.InvokeAsync(Func`1 action, Int32 numRetries, Int32 retryTimeout, Boolean finalThrow, Type[] exceptionTypes, Func`2 fallbackPolicy, Action`2 retryAction) in C:\Workplaces\Penkar\framework\Penkar\FriendlyException\Retry.cs:line 102
         at Penkar.Schedule.ScheduleHostedService.<>c__DisplayClass24_2.<<BackgroundProcessing>b__2>d.MoveNext() in C:\Workplaces\Penkar\framework\Penkar\Schedule\HostedServices\ScheduleHostedService.cs:line 231
      ++++++++++++++++++++++++++++++++++++++++++++++++++++++++

全局配置重试策略

推荐使用 【作业执行器 IJobExecutor 配置全局异常重试策略。

作业执行异常回退策略

作业处理程序执行异常除了配置 重试次数 或配置 全局异常重试策略 以外,还可以实现 IJob.FallbackAsync 进行回退配置。

cs
public class TestJob : IJob
{
    private readonly ILogger<TestJob> _logger;
    public TestJob(ILogger<TestJob> logger)
    {
        _logger = logger;
    }

    public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
    {
        _logger.LogWarning($"{context}");

        // 模拟运行第三次出异常
        if (context.Trigger.NumberOfRuns == 3)
        {
            throw new Exception("假装出错");
        }

        await Task.CompletedTask;
    }

    public Task FallbackAsync(JobExecutedContext context, CancellationToken stoppingToken)
    {
        Console.WriteLine("调用了回退");
        return Task.CompletedTask;
    }
}

输出日志如下:

bash
info: 2023-04-25 17:19:06.5448438 +08:00 星期二 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is running.
info: 2023-04-25 17:19:06.5523770 +08:00 星期二 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is preloading...
info: 2023-04-25 17:19:07.1156318 +08:00 星期二 L System.Logging.ScheduleService[0] #1
      The <job1_trigger1> trigger for scheduler of <job1> successfully appended to the schedule.
info: 2023-04-25 17:19:07.1293994 +08:00 星期二 L System.Logging.ScheduleService[0] #1
      The <job1_trigger2> trigger for scheduler of <job1> successfully appended to the schedule.
info: 2023-04-25 17:19:07.1360332 +08:00 星期二 L System.Logging.ScheduleService[0] #1
      The scheduler of <job1> successfully appended to the schedule.
warn: 2023-04-25 17:19:07.1614880 +08:00 星期二 L System.Logging.ScheduleService[0] #1
      Schedule hosted service preload completed, and a total of <1> schedulers are appended.
warn: 2023-04-25 17:19:11.1565118 +08:00 星期二 L Penkar.Application.TestJob[0] #9
      <job1> [C] <job1 job1_trigger2> 4s 1ts 2023-04-25 17:19:11.067 -> 2023-04-25 17:19:15.092
warn: 2023-04-25 17:19:15.1275434 +08:00 星期二 L Penkar.Application.TestJob[0] #18
      <job1> [C] <job1 job1_trigger2> 4s 2ts 2023-04-25 17:19:15.092 -> 2023-04-25 17:19:19.094
warn: 2023-04-25 17:19:19.1006636 +08:00 星期二 L Penkar.Application.TestJob[0] #17
      <job1> [C] <job1 job1_trigger2> 4s 3ts 2023-04-25 17:19:19.094 -> 2023-04-25 17:19:23.067
fail: 2023-04-25 17:19:19.2554424 +08:00 星期二 L System.Logging.ScheduleService[0] #17
      Error occurred executing in <job1> [C] <job1 job1_trigger2> 4s 3ts 2023-04-25 17:19:19.094 -> 2023-04-25 17:19:23.067.
      ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
      System.Exception: 假装出错
         at Penkar.Application.TestJob.ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken) in D:\Workplaces\OpenSources\Penkar\samples\Penkar.Application\TestJob.cs:line 22
         at Penkar.Schedule.ScheduleHostedService.<>c__DisplayClass24_3.<<BackgroundProcessing>b__3>d.MoveNext() in D:\Workplaces\OpenSources\Penkar\framework\Penkar\Schedule\HostedServices\ScheduleHostedService.cs:line 233
      --- End of stack trace from previous location ---
         at Penkar.FriendlyException.Retry.InvokeAsync(Func`1 action, Int32 numRetries, Int32 retryTimeout, Boolean finalThrow, Type[] exceptionTypes, Func`2 fallbackPolicy, Action`2 retryAction) in D:\Workplaces\OpenSources\Penkar\framework\Penkar\FriendlyException\Retry.cs:line 79
         at Penkar.Schedule.ScheduleHostedService.<>c__DisplayClass24_2.<<BackgroundProcessing>b__2>d.MoveNext() in D:\Workplaces\OpenSources\Penkar\framework\Penkar\Schedule\HostedServices\ScheduleHostedService.cs:line 231
      ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
info: 2023-04-25 17:19:19.2589045 +08:00 星期二 L System.Logging.ScheduleService[0] #17
      Fallback called in <job1> [C] <job1 job1_trigger2> 4s 3ts 2023-04-25 17:19:19.094 -> 2023-04-25 17:19:23.067.
调用了回退
warn: 2023-04-25 17:19:23.0840895 +08:00 星期二 L Penkar.Application.TestJob[0] #14
      <job1> [C] <job1 job1_trigger2> 4s 4ts 2023-04-25 17:19:23.067 -> 2023-04-25 17:19:27.050

如果 FallbackAsync 发生二次异常,如:

cs
public Task FallbackAsync(JobExecutedContext context, CancellationToken stoppingToken)
{
    Console.WriteLine("调用了回退");

    throw new Exception("回退了我还是出异常~");
    return Task.CompletedTask;
}

输出日志将合并前面所有异常并输出:

bash
info: 2023-04-25 17:24:46.0348224 +08:00 星期二 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is running.
info: 2023-04-25 17:24:46.0392736 +08:00 星期二 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is preloading...
info: 2023-04-25 17:24:46.4677115 +08:00 星期二 L System.Logging.ScheduleService[0] #1
      The <job1_trigger1> trigger for scheduler of <job1> successfully appended to the schedule.
info: 2023-04-25 17:24:46.4847108 +08:00 星期二 L System.Logging.ScheduleService[0] #1
      The <job1_trigger2> trigger for scheduler of <job1> successfully appended to the schedule.
info: 2023-04-25 17:24:46.4936590 +08:00 星期二 L System.Logging.ScheduleService[0] #1
      The scheduler of <job1> successfully appended to the schedule.
warn: 2023-04-25 17:24:46.6097957 +08:00 星期二 L System.Logging.ScheduleService[0] #1
      Schedule hosted service preload completed, and a total of <1> schedulers are appended.
warn: 2023-04-25 17:24:50.4988840 +08:00 星期二 L Penkar.Application.TestJob[0] #17
      <job1> [C] <job1 job1_trigger2> 4s 1ts 2023-04-25 17:24:50.419 -> 2023-04-25 17:24:54.436
warn: 2023-04-25 17:24:54.4704187 +08:00 星期二 L Penkar.Application.TestJob[0] #15
      <job1> [C] <job1 job1_trigger2> 4s 2ts 2023-04-25 17:24:54.436 -> 2023-04-25 17:24:58.436
warn: 2023-04-25 17:24:58.4441477 +08:00 星期二 L Penkar.Application.TestJob[0] #15
      <job1> [C] <job1 job1_trigger2> 4s 3ts 2023-04-25 17:24:58.436 -> 2023-04-25 17:25:02.411
fail: 2023-04-25 17:24:58.5704807 +08:00 星期二 L System.Logging.ScheduleService[0] #15
      Error occurred executing in <job1> [C] <job1 job1_trigger2> 4s 3ts 2023-04-25 17:24:58.436 -> 2023-04-25 17:25:02.411.
      ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
      System.Exception: 假装出错
         at Penkar.Application.TestJob.ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken) in D:\Workplaces\OpenSources\Penkar\samples\Penkar.Application\TestJob.cs:line 22
         at Penkar.Schedule.ScheduleHostedService.<>c__DisplayClass24_3.<<BackgroundProcessing>b__3>d.MoveNext() in D:\Workplaces\OpenSources\Penkar\framework\Penkar\Schedule\HostedServices\ScheduleHostedService.cs:line 233
      --- End of stack trace from previous location ---
         at Penkar.FriendlyException.Retry.InvokeAsync(Func`1 action, Int32 numRetries, Int32 retryTimeout, Boolean finalThrow, Type[] exceptionTypes, Func`2 fallbackPolicy, Action`2 retryAction) in D:\Workplaces\OpenSources\Penkar\framework\Penkar\FriendlyException\Retry.cs:line 79
         at Penkar.Schedule.ScheduleHostedService.<>c__DisplayClass24_2.<<BackgroundProcessing>b__2>d.MoveNext() in D:\Workplaces\OpenSources\Penkar\framework\Penkar\Schedule\HostedServices\ScheduleHostedService.cs:line 231
      ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
info: 2023-04-25 17:24:58.5737508 +08:00 星期二 L System.Logging.ScheduleService[0] #15
      Fallback called in <job1> [C] <job1 job1_trigger2> 4s 3ts 2023-04-25 17:24:58.436 -> 2023-04-25 17:25:02.411.
调用了回退
fail: 2023-04-25 17:24:58.5929688 +08:00 星期二 L System.Logging.ScheduleService[0] #15
      Fallback called error in <job1> [C] <job1 job1_trigger2> 4s 3ts 2023-04-25 17:24:58.436 -> 2023-04-25 17:25:02.411.
      ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
      System.AggregateException: One or more errors occurred. (Error occurred executing in  <job1> [C] <job1 job1_trigger2> 4s 3ts 2023-04-25 17:24:58.436 -> 2023-04-25 17:25:02.411.) (回退了我还是出异常~)
       ---> System.InvalidOperationException: Error occurred executing in  <job1> [C] <job1 job1_trigger2> 4s 3ts 2023-04-25 17:24:58.436 -> 2023-04-25 17:25:02.411.
       ---> System.Exception: 假装出错
         at Penkar.Application.TestJob.ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken) in D:\Workplaces\OpenSources\Penkar\samples\Penkar.Application\TestJob.cs:line 22
         at Penkar.Schedule.ScheduleHostedService.<>c__DisplayClass24_3.<<BackgroundProcessing>b__3>d.MoveNext() in D:\Workplaces\OpenSources\Penkar\framework\Penkar\Schedule\HostedServices\ScheduleHostedService.cs:line 233
      --- End of stack trace from previous location ---
         at Penkar.FriendlyException.Retry.InvokeAsync(Func`1 action, Int32 numRetries, Int32 retryTimeout, Boolean finalThrow, Type[] exceptionTypes, Func`2 fallbackPolicy, Action`2 retryAction) in D:\Workplaces\OpenSources\Penkar\framework\Penkar\FriendlyException\Retry.cs:line 79
         at Penkar.Schedule.ScheduleHostedService.<>c__DisplayClass24_2.<<BackgroundProcessing>b__2>d.MoveNext() in D:\Workplaces\OpenSources\Penkar\framework\Penkar\Schedule\HostedServices\ScheduleHostedService.cs:line 231
         --- End of inner exception stack trace ---
         --- End of inner exception stack trace ---
       ---> (Inner Exception #1) System.Exception: 回退了我还是出异常~
         at Penkar.Application.TestJob.FallbackAsync(JobExecutedContext context, CancellationToken stoppingToken) in D:\Workplaces\OpenSources\Penkar\samples\Penkar.Application\TestJob.cs:line 32
         at Penkar.Schedule.ScheduleHostedService.<>c__DisplayClass24_2.<<BackgroundProcessing>b__2>d.MoveNext() in D:\Workplaces\OpenSources\Penkar\framework\Penkar\Schedule\HostedServices\ScheduleHostedService.cs:line 309<---
      ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
warn: 2023-04-25 17:25:02.4212180 +08:00 星期二 L Penkar.Application.TestJob[0] #15
      <job1> [C] <job1 job1_trigger2> 4s 4ts 2023-04-25 17:25:02.411 -> 2023-04-25 17:25:06.388

作业调度器被取消处理

一般情况下,作业调度器意外关闭或手动关闭,但作业处理程序异步操作还未处理完成,这个时候我们可以选择取消还是继续执行,如果选择取消:

cs
public class MyJob : IJob
{
    private readonly ILogger<MyJob> _logger;
    public MyJob(ILogger<MyJob> logger)
    {
        _logger = logger;
    }

    public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
    {
        _logger.LogInformation($"{context}");

        try
        {
            await SomeMethodAsync(stoppingToken);
        }
        catch (TaskCanceledException)
        {
            _logger.LogWarning("作业被取消了~");
        }
        catch (AggregateException ex) when (ex.InnerExceptions.Count == 1 && ex.InnerExceptions[0] is TaskCanceledException)
        {
            _logger.LogWarning("作业被取消了~");
        }
        catch {}
    }

    private async Task SomeMethodAsync(CancellationToken stoppingToken)
    {
        // 模拟耗时
        await Task.Delay(1000 * 60 * 60, stoppingToken);
    }
}

这样当作业调度器被关闭时,SomeMethodAsync 如果未处理完成也会取消操作。

bash
info: 2022-12-04 12:49:00.2636929 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is running.
info: 2022-12-04 12:49:00.2686096 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is preloading...
info: 2022-12-04 12:49:00.4252737 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      The <job1_trigger1> trigger for scheduler of <job1> successfully appended to the schedule.
info: 2022-12-04 12:49:00.4266075 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      The scheduler of <job1> successfully appended to the schedule.
warn: 2022-12-04 12:49:00.4468654 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      Schedule hosted service preload completed, and a total of <1> schedulers are appended.
info: 2022-12-04 12:49:05.4397629 +08:00 星期日 L MyJob[0] #4
      <job1> [C] <job1 job1_trigger1> 5s 1ts 2022-12-04 12:49:05.390 -> 2022-12-04 12:49:10.393
info: 2022-12-04 12:49:08.6301592 +08:00 星期日 L Microsoft.Hosting.Lifetime[0] #14
      Application is shutting down...
warn: 2022-12-04 12:49:08.7247004 +08:00 星期日 L MyJob[0] #6
      作业被取消了~
warn: 2022-12-04 12:49:10.4257861 +08:00 星期日 L System.Logging.ScheduleService[0] #6
      Schedule hosted service cancels hibernation and GC.Collect().
crit: 2022-12-04 12:49:10.4360088 +08:00 星期日 L System.Logging.ScheduleService[0] #6
      Schedule hosted service is stopped.

设置本次执行结果

有时候我们希望能够记录本次作业触发器触发返回结果,可通过 context.Result 进行设置。

也可以通过该值来判断作业是否成功执行,如设置了 Result 值但实际发现 trigger.Resultnull,那么也就是本次执行未成功。

cs
public class MyJob : IJob
{
    private readonly ILogger<MyJob> _logger;
    public MyJob(ILogger<MyJob> logger)
    {
        _logger = logger;
    }

    public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
    {
        _logger.LogInformation($"{context}");

        context.Result = "执行成功并返回 OK";
        await Task.CompletedTask;
    }
}

HTTP 请求作业

HTTP 请求作业通常用于定时请求/访问互联网地址。

cs
services.AddSchedule(options =>
{
      options.AddHttpJob(request =>
      {
            request.RequestUri = "https://www.chinadot.net";
            request.HttpMethod = HttpMethod.Get;
            // request.Body = "{}"; // 设置请求报文体
      }, Triggers.PeriodSeconds(5));
});

作业执行日志如下:

bash
info: 2023-03-11 11:05:36.3616747 +08:00 星期六 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is running.
info: 2023-03-11 11:05:36.3652411 +08:00 星期六 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is preloading...
info: 2023-03-11 11:05:36.5172940 +08:00 星期六 L System.Logging.ScheduleService[0] #1
      The <job1_trigger1> trigger for scheduler of <job1> successfully appended to the schedule.
info: 2023-03-11 11:05:36.5189296 +08:00 星期六 L System.Logging.ScheduleService[0] #1
      The scheduler of <job1> successfully appended to the schedule.
warn: 2023-03-11 11:05:36.5347816 +08:00 星期六 L System.Logging.ScheduleService[0] #1
      Schedule hosted service preload completed, and a total of <1> schedulers are appended.
warn: 2023-03-11 11:05:41.5228138 +08:00 星期六 L System.Logging.ScheduleService[0] #15
      Schedule hosted service will sleep <4970> milliseconds and be waked up at <2023-03-11 11:05:46.486>.
info: 2023-03-11 11:05:41.5542865 +08:00 星期六 L System.Net.Http.HttpClient.HttpJob.LogicalHandler[100] #9
      Start processing HTTP request GET https://www.chinadot.net/
info: 2023-03-11 11:05:41.5589056 +08:00 星期六 L System.Net.Http.HttpClient.HttpJob.ClientHandler[100] #9
      Sending HTTP request GET https://www.chinadot.net/
info: 2023-03-11 11:05:44.1305461 +08:00 星期六 L System.Net.Http.HttpClient.HttpJob.ClientHandler[101] #8
      Received HTTP response headers after 2566.7836ms - 200
info: 2023-03-11 11:05:44.1343977 +08:00 星期六 L System.Net.Http.HttpClient.HttpJob.LogicalHandler[101] #8
      End processing HTTP request after 2584.2327ms - 200
info: 2023-03-11 11:05:48.6475959 +08:00 星期六 L System.Logging.ScheduleService[0] #4
      Received HTTP response body with a length of <63639> output as follows - 200
      <!DOCTYPE html><html><head>
            <title>dotNET China |  .NET 开发更简单,更通用,更流行</title>
      ......
          </body></html>

❤️ 如何自定义 HTTP 作业

默认情况下,Penkar 框架提供有限的 HTTP 配置参数,如果不能满足可自行定义。

  1. 自定义 Http 参数类:MyHttpJobMessage
cs
namespace YourProject.Core;

/// <summary>
/// HTTP 作业消息
/// </summary>
public class MyHttpJobMessage
{
    /// <summary>
    /// 请求地址
    /// </summary>
    public string RequestUri { get; set; }

    /// <summary>
    /// 请求方法
    /// </summary>
    public HttpMethod HttpMethod { get; set; } = HttpMethod.Get;

    /// <summary>
    /// 请求报文体
    /// </summary>
    public string Body { get; set; }
}
  1. 自定义 Http 作业处理程序:MyHttpJob
cs
/// <summary>
/// HTTP 请求作业处理程序
/// </summary>
public class MyHttpJob : IJob // 也可以继承内部的 HttpJob 类
{
    /// <summary>
    /// <see cref="HttpClient"/> 创建工厂
    /// </summary>
    private readonly IHttpClientFactory _httpClientFactory;

    /// <summary>
    /// 日志服务
    /// </summary>
    private readonly ILogger<MyHttpJob> _logger;

    /// <summary>
    /// 构造函数
    /// </summary>
    /// <param name="httpClientFactory"><see cref="HttpClient"/> 创建工厂</param>
    /// <param name="logger">日志服务</param>
    public MyHttpJob(IHttpClientFactory httpClientFactory
        , ILogger<MyHttpJob> logger)
    {
        _httpClientFactory = httpClientFactory;
        _logger = logger;
    }

    /// <summary>
    /// 具体处理逻辑
    /// </summary>
    /// <param name="context">作业执行前上下文</param>
    /// <param name="stoppingToken">取消任务 Token</param>
    /// <returns><see cref="Task"/></returns>
    public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
    {
        var jobDetail = context.JobDetail;

        // 解析 HTTP 请求参数,键名称为类名
        var httpJobMessage = Penetrates.Deserialize<MyHttpJobMessage>(jobDetail.GetProperty<string>(nameof(MyHttpJob)));

        // 空检查
        if (httpJobMessage == null || string.IsNullOrWhiteSpace(httpJobMessage.RequestUri))
        {
            return;
        }

        // 创建请求客户端
        using var httpClient = _httpClientFactory.CreateClient(); // CreateClient 可以传入一个字符串进行全局配置 Client

        // 添加请求报文头 User-Agent
        httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.81 Safari/537.36 Edg/104.0.1293.47");

        // 创建请求对象
        var httpRequestMessage = new HttpRequestMessage(httpJobMessage.HttpMethod, httpJobMessage.RequestUri);

        // 添加请求报文体,默认只支持发送 application/json 类型
        if (httpJobMessage.HttpMethod != HttpMethod.Get
            && httpJobMessage.HttpMethod != HttpMethod.Head
            && !string.IsNullOrWhiteSpace(httpJobMessage.Body))
        {
            var stringContent = new StringContent(httpJobMessage.Body, Encoding.UTF8);
            stringContent.Headers.ContentType = new MediaTypeHeaderValue("application/json");
            httpRequestMessage.Content = stringContent;
        }

        // 更多自定义参数========================
        // Your Code ....

        // 发送请求并确保成功
        var httpResponseMessage = await httpClient.SendAsync(httpRequestMessage, stoppingToken);

        // 解析返回值
        var bodyString = await httpResponseMessage.Content.ReadAsStringAsync(stoppingToken);

        // 输出日志
        _logger.LogInformation($"Received HTTP response body with a length of <{bodyString.Length}> output as follows - {(int)httpResponseMessage.StatusCode}{Environment.NewLine}{bodyString}");

        // 设置本次执行结果
        context.Result = Penetrates.Serialize(new
        {
            httpResponseMessage.StatusCode,
            Body = bodyString
        });
    }
}
  1. 注册自定义 Http 作业
cs
services.AddSchedule(options =>
{
      // 创建 HTTP 作业消息
      var httpJobMessage = new YourHttpJobMessage();
      var jobBuilder = JobBuilder.Create<MyHttpJob>()
                                       // 添加作业附加信息
                                       .AddProperty(nameof(MyHttpJob), Schedular.Serialize(httpJobMessage));
      // 添加作业
      options.AddJob(jobBuilder, Triggers.PeriodSeconds(5));
});

设置本次执行结果

在一些特定作业处理程序中,有时候希望输出(记录)本次作业一些返回值,此时可通过 context.Result 实现,如:

cs
public class MyJob : IJob
{
    private readonly ILogger<MyJob> _logger;
    public MyJob(ILogger<MyJob> logger)
    {
        _logger = logger;
    }

    public Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
    {
        _logger.LogInformation($"{context}");

        context.Result = "设置本次执行的值";
        return Task.CompletedTask;
    }
}

ResultProperties

除了通过 context.Result 设置作业本次执行结果以外,还可以通过 jobDetail.AddOrUpdateProperty(key, value) 的方式设置。区别在于前者会将值同步到 TriggerResult 中,后者会将值同步在 JobDetailProperties 中。

作业触发器 Trigger 及构建器

关于作业触发器

框架提供了 Trigger 类型来描述作业具体的触发时间,Trigger 类型提供以下只读属性

属性名属性类型默认值说明
TriggerIdstring作业触发器 Id
JobIdstring作业 Id
TriggerTypestring作业触发器类型,存储的是类型的 FullName
AssemblyNamestring作业触发器类型所在程序集,存储的是程序集 Name
Argsstring作业触发器参数,运行时将反序列化为 object[] 类型并作为构造函数参数
Descriptionstring描述信息
StatusTriggerStatusReady作业触发器状态
StartTimeDateTime?起始时间
EndTimeDateTime?结束时间
LastRunTimeDateTime?最近运行时间
NextRunTimeDateTime?下一次运行时间
NumberOfRunslong0触发次数
MaxNumberOfRunslong0最大触发次数,0:不限制,n:N 次
NumberOfErrorslong0出错次数
MaxNumberOfErrorslong0最大出错次数,0:不限制,n:N 次
NumRetriesint0重试次数
RetryTimeoutint1000重试间隔时间,毫秒单位
StartNowbooltrue是否立即启动
RunOnStartboolfalse是否启动时执行一次
ResetOnlyOncebooltrue是否在启动时重置最大触发次数等于一次的作业
Resultstring本次执行返回结果,Penkar 4.8.7.7+
ElapsedTimelong0本次执行耗时,单位 msPenkar 4.8.7.7+
UpdatedTimeDateTime?作业触发器更新时间

作业触发器状态

作业触发器状态指示了当前作业触发器的状态,使用 TriggerStatus 枚举类型(uint),该类型包含以下枚举成员。

枚举名枚举值说明
Backlog0积压,起始时间大于当前时间
Ready1就绪
Running2正在运行
Pause3暂停
Blocked4阻塞,本该执行但是没有执行
ErrorToReady5由失败进入就绪,运行错误当并未超出最大错误数,进入下一轮就绪
Archived6归档,结束时间小于当前时间
Panic7崩溃,错误次数超出了最大错误数
Overrun8超限,运行次数超出了最大限制
Unoccupied9无触发时间,下一次执行时间为 null
NotStart10初始化时未启动
Unknown11未知作业触发器,作业触发器运行时类型为 null
Unhandled12未知作业处理程序,作业处理程序类型运行时类型为 null

关于作业触发器构建器

作业触发器 Trigger 是作业调度模块提供运行时的只读类型,那么我们该如何创建或变更 Trigger 对象呢?

TriggerBuilder 是作业调度模块提供可用来生成运行时 Trigger 的类型,这样做的好处可避免外部直接修改运行时 Trigger 数据,还能实现任何修改动作监听,也能避免多线程抢占情况。

作业调度模块提供了多种方式用来创建 TriggerBuilder 对象。

  1. 通过 Create 静态方法创建
cs
// 根据作业触发器 Id 创建
var triggerBuilder = TriggerBuilder.Create("trigger1");

// 根据 Trigger 派生类类型创建
var triggerBuilder = TriggerBuilder.Create<PeriodTrigger>();

// 根据 Trigger 派生类类型 + 构造函数参数创建
var triggerBuilder = TriggerBuilder.Create<CronTrigger>("* * * * *", CronStringFormat.Default);

// 根据程序集名称和类型完全限定名(FullName)创建
var triggerBuilder = TriggerBuilder.Create("Penkar", "Penkar.Schedule.PeriodTrigger");

// 根据程序集名称和类型完全限定名(FullName) + 构造函数参数创建
var triggerBuilder = TriggerBuilder.Create("Penkar", "Penkar.Schedule.PeriodTrigger", 1000);

// 根据 Type 类型创建
var triggerBuilder = TriggerBuilder.Create(typeof(PeriodTrigger));

// 根据 Type 类型 + 构造函数参数创建
var triggerBuilder = TriggerBuilder.Create(typeof(CronTrigger), "* * * * *", CronStringFormat.Default);
  1. 通过 Trigger 类型创建

这种方式常用于在运行时更新作业触发器。

cs
var triggerBuilder = TriggerBuilder.From(trigger);

//也可以通过以下方式
var triggerBuilder = trigger.GetBuilder();
  1. 通过 JSON 字符串创建

该方式非常灵活,可从配置文件,JSON 字符串,或其他能够返回 JSON 字符串的地方创建。

cs
var triggerBuilder = Triggers.From(@"
{
 ""triggerId"": ""job1_trigger1"",
 ""jobId"": ""job1"",
 ""triggerType"": ""Penkar.Schedule.CronTrigger"",
 ""assemblyName"": ""Penkar"",
 ""args"": ""[\""* * * * *\"",0]"",
 ""description"": null,
 ""status"": 1,
 ""startTime"": null,
 ""endTime"": null,
 ""lastRunTime"": ""2022-12-04 16:13:00.000"",
 ""nextRunTime"": null,
 ""numberOfRuns"": 1,
 ""maxNumberOfRuns"": 0,
 ""numberOfErrors"": 0,
 ""maxNumberOfErrors"": 0,
 ""numRetries"": 0,
 ""retryTimeout"": 1000,
 ""startNow"": true,
 ""runOnStart"": false,
 ""resetOnlyOnce"": true,
 ""result"": null,
 ""elapsedTime"": 100,
 ""updatedTime"": ""2022-12-04 16:13:00.045""
}");

如果使用的是 .NET7,可使用 """ 避免转义,如:

cs
var triggerBuilder = Triggers.From("""
{
      "triggerId": "job1_trigger1",
      "jobId": "job1",
      "triggerType": "Penkar.Schedule.CronTrigger",
      "assemblyName": "Penkar",
      "args": "[\"* * * * *\",0]",
      "description": null,
      "status": 8,
      "startTime": null,
      "endTime": null,
      "lastRunTime": "2022-12-04 16:13:00.000",
      "nextRunTime": null,
      "numberOfRuns": 1,
      "maxNumberOfRuns": 0,
      "numberOfErrors": 0,
      "maxNumberOfErrors": 0,
      "numRetries": 0,
      "retryTimeout": 1000,
      "startNow": true,
      "runOnStart": false,
      "resetOnlyOnce": true,
      "result": null,
      "elapsedTime": 100,
      "updatedTime": "2022-12-04 16:13:00.045"
}
""");

关于属性名匹配规则

支持 CamelCase(驼峰命名法)Pascal(帕斯卡命名法) 命名方式。

不支持 UnderScoreCase(下划线命名法) ,如 "include_annotations": true

  1. 还可以通过 Clone 静态方法从一个 TriggerBuilder 创建
cs
var triggerBuilder = TriggerBuilder.Clone(fromTriggerBuilder);

克隆说明

克隆操作只会克隆 AssemblyNameTriggerTypeArgsDescriptionStartTimeEndTimeMaxNumberOfRunsMaxNumberOfErrorsNumRetriesRetryTimeoutStartNowRunOnStartResetOnlyOnce

不会克隆 TriggerIdJobIdStatusLastRunTimeNextRunTimeNumberOfRunsNumberOfErrorsResultElapsedTime,PersistentConnectionUpdatedTime

  1. 还可以通过 LoadFrom 实例方法填充当前的 TriggerBuilder

比如可以传递匿名类型,类类型,字典 Dictionary<string, object> 类型:

cs
// 会覆盖所有相同的值
triggerBuilder.LoadFrom(new
{
      Description = "我是描述",
      StartTime = DateTime.Now
});

// 支持多个填充,还可以配置跳过 null 值覆盖
triggerBuilder.LoadFrom(new
{
      Description = "我是另外一个描述",
      StartTime = default(object),
}, ignoreNullValue: true);

// 支持忽略特定属性名映射
triggerBuilder.LoadFrom(new
{
      Description = "我是另外一个描述",
      TriggerId = "trigger1"
}, ignorePropertyNames: new[]{ "description" });

// 支持字典类型
triggerBuilder.LoadFrom(new Dictionary<string, object>
{
      {"Description", "这是新的描述" },
      {"updatedTime", DateTime.Now }
});

关于属性名匹配规则

支持 CamelCase(驼峰命名法)Pascal(帕斯卡命名法)UnderScoreCase(下划线命名法) 命名方式。

内置作业触发器构建器

为了方便快速实现作业触发器,作业调度模块内置了 Period(间隔)Cron(表达式) 作业触发器,可通过 TriggerBuilder 类型或 Triggers 静态类创建。

  • TriggerBuilder 方式
c
// 创建毫秒周期(间隔)作业触发器构建器
var triggerBuilder = TriggerBuilder.Period(5000);

// 创建 Cron 表达式作业触发器构建器
var triggerBuilder = TriggerBuilder.Cron("* * * * *", CronStringFormat.Default);
  • Triggers 方式,❤️ 推荐

Triggers 静态类具备 TriggerBuilder 所有的静态方法同时还添加了不少更加便捷的静态方法。

cs
// 间隔 Period 方式
// 创建毫秒周期(间隔)作业触发器构建器
var triggerBuilder = Triggers.Period(5000);
// 创建秒周期(间隔)作业触发器构建器
var triggerBuilder = Triggers.PeriodSeconds(5);
// 创建分钟周期(间隔)作业触发器构建器
var triggerBuilder = Triggers.PeriodMinutes(5);
// 创建小时周期(间隔)作业触发器构建器
var triggerBuilder = Triggers.PeriodHours(5);

// Cron 表达式方式
// 创建 Cron 表达式作业触发器构建器
var triggerBuilder = Triggers.Cron("* * * * *", CronStringFormat.Default);
// 创建每秒开始作业触发器构建器
var triggerBuilder = Triggers.Secondly();
// 创建每分钟开始作业触发器构建器
var triggerBuilder = Triggers.Minutely();
// 创建每小时开始作业触发器构建器
var triggerBuilder = Triggers.Hourly();
// 创建每天(午夜)开始作业触发器构建器
var triggerBuilder = Triggers.Daily();
// 创建每月1号(午夜)开始作业触发器构建器
var triggerBuilder = Triggers.Monthly();
// 创建每周日(午夜)开始作业触发器构建器
var triggerBuilder = Triggers.Weekly();
// 创建每年1月1号(午夜)开始作业触发器构建器
var triggerBuilder = Triggers.Yearly();
// 创建每周一至周五(午夜)开始作业触发器构建器
var triggerBuilder = Triggers.Workday();

// Cron 表达式 Macro At 方式
// 每第 3 秒
var triggerBuilder = Triggers.SecondlyAt(3);
// 每第 3,5,6 秒
var triggerBuilder = Triggers.SecondlyAt(3, 5, 6);

// 每分钟第 3 秒
var triggerBuilder = Triggers.MinutelyAt(3);
// 每分钟第 3,5,6 秒
var triggerBuilder = Triggers.MinutelyAt(3, 5, 6);

// 每小时第 3 分钟
var triggerBuilder = Triggers.HourlyAt(3);
// 每小时第 3,5,6 分钟
var triggerBuilder = Triggers.HourlyAt(3, 5, 6);

// 每天第 3 小时正(点)
var triggerBuilder = Triggers.DailyAt(3);
// 每天第 3,5,6 小时正(点)
var triggerBuilder = Triggers.DailyAt(3, 5, 6);

// 每月第 3 天零点正
var triggerBuilder = Triggers.MonthlyAt(3);
// 每月第 3,5,6 天零点正
var triggerBuilder = Triggers.MonthlyAt(3, 5, 6);

// 每周星期 3 零点正
var triggerBuilder = Triggers.WeeklyAt(3);
var triggerBuilder = Triggers.WeeklyAt("WED");  // SUN(星期天),MON,TUE,WED,THU,FRI,SAT
// 每周星期 3,5,6 零点正
var triggerBuilder = Triggers.WeeklyAt(3, 5, 6);
var triggerBuilder = Triggers.WeeklyAt("WED", "FRI", "SAT");
// 还支持混合
var triggerBuilder = Triggers.WeeklyAt(3, "FRI", 6);

// 每年第 3 月 1 日零点正
var triggerBuilder = Triggers.YearlyAt(3);
var triggerBuilder = Triggers.YearlyAt("MAR");  // JAN(一月),FEB,MAR,APR,MAY,JUN,JUL,AUG,SEP,OCT,NOV,DEC
// 每年第 3,5,6 月 1 日零点正
var triggerBuilder = Triggers.YearlyAt(3);
var triggerBuilder = Triggers.YearlyAt(3, 5, 6);
var triggerBuilder = Triggers.YearlyAt("MAR", "MAY", "JUN");
// 还支持混合
var triggerBuilder = Triggers.YearlyAt(3, "MAY", 6);

自定义作业触发器

除了使用框架提供的 PeriodTriggerCronTrigger 以外,还可以自定义作业触发器,只需要继承 Trigger 并重写 GetNextOccurrence 方法即可,如实现一个间隔两秒的作业触发器。

cs
public class CustomTrigger : Trigger
{
    public override DateTime GetNextOccurrence(DateTime startAt)
    {
        return startAt.AddSeconds(2);
    }
}

之后可通过 TriggerBuilder.CreateTriggers.Create 创建即可:

cs
services.AddSchedule(options =>
{
    options.AddJob<MyJob>(Triggers.Create<CustomTrigger>());
});

查看作业执行结果:

bash
info: 2022-12-04 17:19:25.0980531 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is running.
info: 2022-12-04 17:19:25.1027083 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is preloading...
info: 2022-12-04 17:19:25.2702054 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      The <job1_trigger1> trigger for scheduler of <job1> successfully appended to the schedule.
info: 2022-12-04 17:19:25.2723418 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      The scheduler of <job1> successfully appended to the schedule.
warn: 2022-12-04 17:19:25.2999295 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      Schedule hosted service preload completed, and a total of <1> schedulers are appended.
info: 2022-12-04 17:19:27.2849015 +08:00 星期日 L MyJob[0] #8
      <job1> [C] <job1 job1_trigger1> 1ts 2022-12-04 17:19:27.234 -> 2022-12-04 17:19:29.232
info: 2022-12-04 17:19:29.2604639 +08:00 星期日 L MyJob[0] #4
      <job1> [C] <job1 job1_trigger1> 2ts 2022-12-04 17:19:29.232 -> 2022-12-04 17:19:31.225
info: 2022-12-04 17:19:31.2422514 +08:00 星期日 L MyJob[0] #10
      <job1> [C] <job1 job1_trigger1> 3ts 2022-12-04 17:19:31.225 -> 2022-12-04 17:19:33.207

另外,自定义作业触发器还支持配置构造函数参数

参数特别说明

如果自定义作业触发器包含参数,那么必须满足以下两个条件

  • 参数必须通过唯一的构造函数传入,有且最多只能拥有一个构造函数
  • 参数的类型只能是 intstringboolnull 或由它们组成的数组类型
cs
public class CustomTrigger : Trigger
{
    public CustomTrigger(int seconds) // 可支持多个参数
    {
        Seconds = seconds;
    }

    private int Seconds { get; set; }

    public override DateTime GetNextOccurrence(DateTime startAt)
    {
        return startAt.AddSeconds(Seconds);
    }
}

之后可通过 TriggerBuilder.CreateTriggers.Create 创建并传入参数。

cs
services.AddSchedule(options =>
{
      options.AddJob<MyJob>(Triggers.Create<CustomTrigger>(3));
});

查看作业执行结果:

bash
info: 2022-12-04 17:23:09.3029251 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is running.
info: 2022-12-04 17:23:09.3205593 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is preloading...
info: 2022-12-04 17:23:09.7081119 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      The <job1_trigger1> trigger for scheduler of <job1> successfully appended to the schedule.
info: 2022-12-04 17:23:09.7506504 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      The scheduler of <job1> successfully appended to the schedule.
warn: 2022-12-04 17:23:09.9380816 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      Schedule hosted service preload completed, and a total of <1> schedulers are appended.
info: 2022-12-04 17:23:12.6291716 +08:00 星期日 L MyJob[0] #6
      <job1> [C] <job1 job1_trigger1> 1ts 2022-12-04 17:23:12.590 -> 2022-12-04 17:23:15.582
info: 2022-12-04 17:23:15.6141563 +08:00 星期日 L MyJob[0] #9
      <job1> [C] <job1 job1_trigger1> 2ts 2022-12-04 17:23:15.582 -> 2022-12-04 17:23:18.572
info: 2022-12-04 17:23:18.5857464 +08:00 星期日 L MyJob[0] #8
      <job1> [C] <job1 job1_trigger1> 3ts 2022-12-04 17:23:18.572 -> 2022-12-04 17:23:21.551

自定义作业触发器除了可重写 GetNextOccurrence 方法之后,还提供了 ShouldRunToString 方法可重写,如:

cs
public class CustomTrigger : Trigger
{
    public CustomTrigger(int seconds)
    {
        Seconds = seconds;
    }

    private int Seconds { get; set; }

    public override DateTime GetNextOccurrence(DateTime startAt)
    {
        return startAt.AddSeconds(Seconds);
    }

    public override bool ShouldRun(JobDetail jobDetail, DateTime startAt)
    {
        // 在这里进一步控制,如果返回 false,则作业触发器跳过执行
        return base.ShouldRun(jobDetail, startAt);
    }

    public override string ToString()
    {
        return $"<{TriggerId}> 自定义递增 {Seconds}s 触发器";
    }
}

推荐重写 GetNextRunTimeToString 方法即可,如:

cs
public class MyJob : IJob
{
    private readonly ILogger<MyJob> _logger;
    public MyJob(ILogger<MyJob> logger)
    {
        _logger = logger;
    }

    public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
    {
        _logger.LogInformation($"{context}");
        await Task.CompletedTask;
    }
}

查看作业执行结果:

bash
info: 2022-12-04 17:26:43.9120082 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is running.
info: 2022-12-04 17:26:43.9166481 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is preloading...
info: 2022-12-04 17:26:44.1786114 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      The <job1_trigger1> trigger for scheduler of <job1> successfully appended to the schedule.
info: 2022-12-04 17:26:44.1816154 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      The scheduler of <job1> successfully appended to the schedule.
warn: 2022-12-04 17:26:44.2077386 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      Schedule hosted service preload completed, and a total of <1> schedulers are appended.
info: 2022-12-04 17:26:47.1904549 +08:00 星期日 L MyJob[0] #8
      <job1> [C] <job1_trigger1> 自定义递增 3s 触发器 2022-12-04 17:26:47.139 -> 2022-12-04 17:26:50.145
info: 2022-12-04 17:26:50.1652618 +08:00 星期日 L MyJob[0] #6
      <job1> [C] <job1_trigger1> 自定义递增 3s 触发器 2022-12-04 17:26:50.145 -> 2022-12-04 17:26:53.129
info: 2022-12-04 17:26:53.1426614 +08:00 星期日 L MyJob[0] #8
      <job1> [C] <job1_trigger1> 自定义递增 3s 触发器 2022-12-04 17:26:53.129 -> 2022-12-04 17:26:56.106

作业触发器特性及自定义

如果 JobBuilder 配置了 IncludeAnnotations 参数且为 true,那么将会自动解析 IJob 的实现类型的所有继承 TriggerAttribute 的特性,目前作业调度模块内置了以下作业触发器特性:

  • [Period(5000)]:毫秒周期(间隔)作业触发器特性
  • [PeriodSeconds(5)]:秒周期(间隔)作业触发器特性
  • [PeriodMinutes(5)]:分钟周期(间隔)作业触发器特性
  • [PeriodHours(5)]:小时周期(间隔)作业触发器特性
  • [Cron("* * * * *", CronStringFormat.Default)]:Cron 表达式作业触发器特性
  • [Secondly]:每秒开始作业触发器特性
  • [Minutely]:每分钟开始作业触发器特性
  • [Hourly]:每小时开始作业触发器特性
  • [Daily]:每天(午夜)开始作业触发器特性
  • [Monthly]:每月 1 号(午夜)开始作业触发器特性
  • [Weekly]:每周日(午夜)开始作业触发器特性
  • [Yearly]:每年 1 月 1 号(午夜)开始作业触发器特性
  • [Workday]:每周一至周五(午夜)开始触发器特性
  • [SecondlyAt]:特定秒开始作业触发器特性
  • [MinutelyAt]:每分钟特定秒开始作业触发器特性
  • [HourlyAt]:每小时特定分钟开始作业触发器特性
  • [DailyAt]:每天特定小时开始作业触发器特性
  • [MonthlyAt]:每月特定天(午夜)开始作业触发器特性
  • [WeeklyAt]:每周特定星期几(午夜)开始作业触发器特性
  • [YearlyAt]:每年特定月 1 号(午夜)开始作业触发器特性

使用如下:

cs
services.AddSchedule(options =>
{
    options.AddJob(JobBuilder.Create<MyJob>().SetIncludeAnnotations(true));

    // 也支持自定义配置 + 特性扫描
    options.AddJob(JobBuilder.Create<MyJob>().SetIncludeAnnotations(true)
                  , Triggers.PeriodSeconds(5));

    // 或者通过类型扫描
    options.AddJob(typeof(MyJobj).ScanToBuilder());

    // 还可以批量扫描 Penkar 4.8.2.4+
    options.AddJob(App.EffectiveTypes.ScanToBuilders());
});
cs
[Minutely]
[PeriodSeconds(5)]
[Cron("*/3 * * * * *", CronStringFormat.WithSeconds)]
public class MyJob : IJob
{
    private readonly ILogger<MyJob> _logger;
    public MyJob(ILogger<MyJob> logger)
    {
        _logger = logger;
    }

    public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
    {
        _logger.LogInformation($"{context}");
        await Task.CompletedTask;
    }
}

查看作业执行结果:

bash
info: 2022-12-04 17:35:47.0211372 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is running.
info: 2022-12-04 17:35:47.0267027 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is preloading...
info: 2022-12-04 17:35:47.2906591 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      The <job1_trigger1> trigger for scheduler of <job1> successfully appended to the schedule.
info: 2022-12-04 17:35:47.2921849 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      The <job1_trigger2> trigger for scheduler of <job1> successfully appended to the schedule.
info: 2022-12-04 17:35:47.2961669 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      The <job1_trigger3> trigger for scheduler of <job1> successfully appended to the schedule.
info: 2022-12-04 17:35:47.2979859 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      The scheduler of <job1> successfully appended to the schedule.
warn: 2022-12-04 17:35:47.3194555 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      Schedule hosted service preload completed, and a total of <1> schedulers are appended.
info: 2022-12-04 17:35:48.0588231 +08:00 星期日 L MyJob[0] #8
      <job1> [C] <job1 job1_trigger3> */3 * * * * * 1ts 2022-12-04 17:35:48.000 -> 2022-12-04 17:35:51.000
info: 2022-12-04 17:35:51.0240459 +08:00 星期日 L MyJob[0] #9
      <job1> [C] <job1 job1_trigger3> */3 * * * * * 2ts 2022-12-04 17:35:51.000 -> 2022-12-04 17:35:54.000
info: 2022-12-04 17:35:52.2643935 +08:00 星期日 L MyJob[0] #12
      <job1> [C] <job1 job1_trigger2> 5s 1ts 2022-12-04 17:35:52.246 -> 2022-12-04 17:35:57.227
info: 2022-12-04 17:35:54.0175524 +08:00 星期日 L MyJob[0] #6
      <job1> [C] <job1 job1_trigger3> */3 * * * * * 3ts 2022-12-04 17:35:54.000 -> 2022-12-04 17:35:57.000
info: 2022-12-04 17:35:57.0270544 +08:00 星期日 L MyJob[0] #9
      <job1> [C] <job1 job1_trigger3> */3 * * * * * 4ts 2022-12-04 17:35:57.000 -> 2022-12-04 17:36:00.000
info: 2022-12-04 17:35:57.2433514 +08:00 星期日 L MyJob[0] #12
      <job1> [C] <job1 job1_trigger2> 5s 2ts 2022-12-04 17:35:57.227 -> 2022-12-04 17:36:02.208
info: 2022-12-04 17:36:00.0151605 +08:00 星期日 L MyJob[0] #14
      <job1> [C] <job1 job1_trigger3> */3 * * * * * 5ts 2022-12-04 17:36:00.000 -> 2022-12-04 17:36:03.000
info: 2022-12-04 17:36:00.0315972 +08:00 星期日 L MyJob[0] #8
      <job1> [C] <job1 job1_trigger1> * * * * * 1ts 2022-12-04 17:36:00.000 -> 2022-12-04 17:37:00.000
info: 2022-12-04 17:36:02.2203934 +08:00 星期日 L MyJob[0] #12
      <job1> [C] <job1 job1_trigger2> 5s 3ts 2022-12-04 17:36:02.208 -> 2022-12-04 17:36:07.184

除了使用内置特性,我们还可以自定义作业触发器特性,如:

cs
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public class CustomAttribute : TriggerAttribute
{
    public CustomAttribute(int seconds)
        : base(typeof(CustomTrigger), seconds)
    {
    }
}

自定义作业触发器必备条件

  • 必须继承 TriggerAttribute 特性类
  • 至少包含一个构造函数且通过基类构造函数配置 :base(实际触发器类型, 构造函数参数)
  • 推荐添加 [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] 特性

使用如下:

cs
[Custom(3)]
public class MyJob : IJob
{
    private readonly ILogger<MyJob> _logger;
    public MyJob(ILogger<MyJob> logger)
    {
        _logger = logger;
    }

    public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
    {
        _logger.LogInformation($"{context}");
        await Task.CompletedTask;
    }
}

查看作业执行结果:

bash
info: 2022-12-04 17:44:12.2702884 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is running.
info: 2022-12-04 17:44:12.2872399 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is preloading...
info: 2022-12-04 17:44:12.5730241 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      The <job1_trigger1> trigger for scheduler of <job1> successfully appended to the schedule.
info: 2022-12-04 17:44:12.5751444 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      The scheduler of <job1> successfully appended to the schedule.
warn: 2022-12-04 17:44:12.6174459 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      Schedule hosted service preload completed, and a total of <1> schedulers are appended.
info: 2022-12-04 17:44:15.5850848 +08:00 星期日 L MyJob[0] #6
      <job1> [C] <job1_trigger1> 自定义递增 3s 触发器 2022-12-04 17:44:15.537 -> 2022-12-04 17:44:18.542
info: 2022-12-04 17:44:18.5693881 +08:00 星期日 L MyJob[0] #8
      <job1> [C] <job1_trigger1> 自定义递增 3s 触发器 2022-12-04 17:44:18.542 -> 2022-12-04 17:44:21.527
info: 2022-12-04 17:44:21.5396428 +08:00 星期日 L MyJob[0] #6
      <job1> [C] <job1_trigger1> 自定义递增 3s 触发器 2022-12-04 17:44:21.527 -> 2022-12-04 17:44:24.504

作业触发器特性还提供了多个属性配置,如:

  • TriggerId:作业触发器 Idstring 类型
  • Description:描述信息,string 类型
  • StartTime:起始时间,string 类型
  • EndTime:结束时间,string 类型
  • MaxNumberOfRuns:最大触发次数,long 类型,0:不限制;n:N 次
  • MaxNumberOfErrors:最大出错次数,long 类型,0:不限制;n:N 次
  • NumRetries:重试次数,int 类型,默认值 0
  • RetryTimeout:重试间隔时间,int 类型,默认值 1000
  • StartNow:是否立即启动,bool 类型,默认值 true
  • RunOnStart:是否启动时执行一次,bool 类型,默认值 false
  • ResetOnlyOnce:是否在启动时重置最大触发次数等于一次的作业,bool 类型,默认值 true

使用如下:

cs
[PeriodSeconds(5, TriggerId = "trigger1", Description = "这是一段描述")]
public class MyJob : IJob
{
      // ...
}

设置作业触发器构建器

TriggerBuilder 提供了和 Trigger 完全匹配的 Set[属性名] 方法来配置作业触发器各个属性,如:

cs
services.AddSchedule(options =>
 {
     var triggerBuilder = Triggers.Period(5000)
         .SetTriggerId("trigger1")   // 作业触发器 Id
         .SetTriggerType("Penkar", "Penkar.Schedule.PeriodTrigger")  // 作业触发器类型,支持多个重载
         .SetTriggerType<PeriodTrigger>()    // 作业触发器类型,支持多个重载
         .SetTriggerType(typeof(PeriodTrigger))  // 作业触发器类型,支持多个重载
         .SetArgs("[5000]")  // 作业触发器参数 object[] 序列化字符串类型,支持多个重载
         .SetArgs(5000)   // 作业触发器参数,支持多个重载
         .SetDescription("作业触发器描述")  // 作业触发器描述
         .SetStatus(TriggerStatus.Ready) // 作业触发器状态
         .SetStartTime(DateTime.Now) // 作业触发器起始时间
         .SetEndTime(DateTime.Now.AddMonths(1)) // 作业触发器结束时间
         .SetLastRunTime(DateTime.Now.AddSeconds(-5))    // 作业触发器最近运行时间
         .SetNextRunTime(DateTime.Now.AddSeconds(5)) // 作业触发器下一次运行时间
         .SetNumberOfRuns(1) // 作业触发器触发次数
         .SetMaxNumberOfRuns(100)    // 作业触发器最大触发器次数
         .SetNumberOfErrors(1)   // 作业触发器出错次数
         .SetMaxNumberOfErrors(100)  // 作业触发器最大出错次数
         .SetNumRetries(3)   // 作业触发器出错重试次数
         .SetRetryTimeout(1000)  // 作业触发器重试间隔时间
         .SetStartNow(true)  // 作业触发器是否立即启动
         .SetRunOnStart(false)    // 作业触发器是否启动时执行一次
         .SetResetOnlyOnce(true)    // 作业触发器是否在启动时重置最大触发次数等于一次的作业
         .SetResult("本次返回结果")    // 作业触发器本次执行返回结果,Penkar 4.8.7.7+
         .SetElapsedTime(100)    // 作业触发器本次执行耗时,Penkar 4.8.7.7+
         ;

     options.AddJob<MyJob>(triggerBuilder);
 });

作业触发器持久化方法

作业触发器构建器 TriggerBuilder 提供了三个标记作业持久化行为的方法:

  • Appended():标记作业触发器构建器是新增的,届时生成的 SQLINSERT 语句
  • Updated():标记作业触发器构建器已被更新,届时生成的 SQLUpdated 语句,如果标记为此操作,那么当前作业调度器初始化时将新增至内存中
  • Removed():标记作业触发器构建器已被删除,届时生成的 SQLDeleted 语句,如果标记为此操作,那么当前作业调度器初始化时将不会添加至作业计划中
cs
services.AddSchedule(options =>
{
      options.AddJob<MyJob>(
            Triggers.PeriodSeconds(5).SetTriggerId("trigger1").Appended()
            , Triggers.PeriodSeconds(5).SetTriggerId("trigger2").Updated()
            , Triggers.PeriodSeconds(5).SetTriggerId("trigger3").Removed());
});

查看作业调度器初始化日志:

bash
info: 2022-12-04 18:29:22.3997873 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is running.
info: 2022-12-04 18:29:22.4045304 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is preloading...
info: 2022-12-04 18:29:22.5473237 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      The <trigger3> trigger for scheduler of <job1> successfully removed to the schedule.
info: 2022-12-04 18:29:22.5504289 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      The <trigger1> trigger for scheduler of <job1> successfully appended to the schedule.
info: 2022-12-04 18:29:22.5521396 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      The <trigger2> trigger for scheduler of <job1> successfully appended and updated to the schedule.
info: 2022-12-04 18:29:22.5535657 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      The scheduler of <job1> successfully appended to the schedule.
warn: 2022-12-04 18:29:22.5896298 +08:00 星期日 L System.Logging.ScheduleService[0] #1
      Schedule hosted service preload completed, and a total of <1> schedulers are appended.
info: 2022-12-04 18:29:27.5981907 +08:00 星期日 L MyJob[0] #14
      <job1> [C] <job1 trigger2> 5s 1ts 2022-12-04 18:29:27.507 -> 2022-12-04 18:29:32.500
info: 2022-12-04 18:29:27.6002420 +08:00 星期日 L MyJob[0] #15
      <job1> [C] <job1 trigger1> 5s 1ts 2022-12-04 18:29:27.507 -> 2022-12-04 18:29:32.500
info: 2022-12-04 18:29:32.5850223 +08:00 星期日 L MyJob[0] #12
      <job1> [C] <job1 trigger2> 5s 2ts 2022-12-04 18:29:32.500 -> 2022-12-04 18:29:37.548
info: 2022-12-04 18:29:32.6034646 +08:00 星期日 L MyJob[0] #8
      <job1> [C] <job1 trigger1> 5s 2ts 2022-12-04 18:29:32.500 -> 2022-12-04 18:29:37.548

多种格式字符串输出

TriggerTriggerBuilder 都提供了多种将自身转换成特定格式的字符串。

  1. 转换成 JSON 字符串
cs
var json = trigger.ConvertToJSON();

字符串打印如下:

json
{
  "triggerId": "job1_trigger1",
  "jobId": "job1",
  "triggerType": "Penkar.Schedule.PeriodTrigger",
  "assemblyName": "Penkar",
  "args": "[5000]",
  "description": null,
  "status": 2,
  "startTime": null,
  "endTime": null,
  "lastRunTime": "2022-12-04 17:52:34.768",
  "nextRunTime": "2022-12-04 17:52:39.769",
  "numberOfRuns": 1,
  "maxNumberOfRuns": 0,
  "numberOfErrors": 0,
  "maxNumberOfErrors": 0,
  "numRetries": 0,
  "retryTimeout": 1000,
  "startNow": true,
  "runOnStart": false,
  "resetOnlyOnce": true,
  "result": null,
  "elapsedTime": 100,
  "updatedTime": "2022-12-04 17:52:34.803"
}
  1. 转换成 SQL 字符串
cs
// 输出新增 SQL,使用 CamelCase 属性命名
var insertSql = trigger.ConvertToSQL("tbName"
      , PersistenceBehavior.Appended
      , NamingConventions.CamelCase);
// 更便捷拓展
var insertSql = trigger.ConvertToInsertSQL("tbName", NamingConventions.CamelCase);

// 输出删除 SQL,使用 Pascal 属性命名
var deleteSql = trigger.ConvertToSQL("tbName"
      , PersistenceBehavior.Removed
      , NamingConventions.Pascal);
// 更便捷拓展
var deleteSql = trigger.ConvertToDeleteSQL("tbName", NamingConventions.Pascal);

// 输出更新 SQL,使用 UnderScoreCase 属性命名
var updateSql = trigger.ConvertToSQL("tbName"
      , PersistenceBehavior.Updated
      , NamingConventions.UnderScoreCase);
// 更便捷拓展
var updateSql = trigger.ConvertToUpdateSQL("tbName", NamingConventions.UnderScoreCase);

字符串打印如下:

sql
-- 新增操作
INSERT INTO tbName(
      triggerId,
      jobId,
      triggerType,
      assemblyName,
      args,
      description,
      status,
      startTime,
      endTime,
      lastRunTime,
      nextRunTime,
      numberOfRuns,
      maxNumberOfRuns,
      numberOfErrors,
      maxNumberOfErrors,
      numRetries,
      retryTimeout,
      startNow,
      runOnStart,
      resetOnlyOnce,
      result,
      elapsedTime,
      updatedTime
)
VALUES(
      'job1_trigger1',
      'job1',
      'Penkar.Schedule.PeriodTrigger',
      'Penkar',
      '[5000]',
      NULL,
      2,
      NULL,
      NULL,
      '2022-12-04 17:54:42.693',
      '2022-12-04 17:54:47.721',
      1,
      0,
      0,
      0,
      0,
      1000,
      1,
      0,
      1,
      NULL,
      100,
      '2022-12-04 17:54:42.754'
);
-- 删除操作
DELETE FROM tbName
WHERE TriggerId = 'job1_trigger1' AND JobId = 'job1';
-- 更新操作
UPDATE tbName
SET
      trigger_id = 'job1_trigger1',
      job_id = 'job1',
      trigger_type = 'Penkar.Schedule.PeriodTrigger',
      assembly_name = 'Penkar',
      args = '[5000]',
      description = NULL,
      status = 2,
      start_time = NULL,
      end_time = NULL,
      last_run_time = '2022-12-04 17:54:42.693',
      next_run_time = '2022-12-04 17:54:47.721',
      number_of_runs = 1,
      max_number_of_runs = 0,
      number_of_errors = 0,
      max_number_of_errors = 0,
      num_retries = 0,
      retry_timeout = 1000,
      start_now = 1,
      run_on_start = 0,
      reset_only_once = 1,
      result = NULL,
      elapsedTime = 100,
      updated_time = '2022-12-04 17:54:42.754'
WHERE trigger_id = 'job1_trigger1' AND job_id = 'job1';
  1. 转换成 Monitor 字符串
cs
var monitor = trigger.ConvertToMonitor();

字符串打印如下:

bash
┏━━━━━━━━━━━  Trigger ━━━━━━━━━━━
 Penkar.Schedule.PeriodTrigger

 triggerId:                job1_trigger1
 jobId:                    job1
 triggerType:              Penkar.Schedule.PeriodTrigger
 assemblyName:             Penkar
 args:                     [5000]
 description:
 status:                   Running
 startTime:
 endTime:
 lastRunTime:              2022-12-04 17:56:55.384
 nextRunTime:              2022-12-04 17:57:00.379
 numberOfRuns:             1
 maxNumberOfRuns:          0
 numberOfErrors:           0
 maxNumberOfErrors:        0
 numRetries:               0
 retryTimeout:             1000
 startNow:                 True
 runOnStart:               False
 resetOnlyOnce:            True
 result:
 elapsedTime:              100
 updatedTime:              2022-12-04 17:56:55.413
┗━━━━━━━━━━━  Trigger ━━━━━━━━━━━
  1. 简要字符串输出
cs
var str = trigger.ToString();

字符串打印如下:

bash
<job1 job1_trigger1> 5s 这是一段描述 1ts

自定义 SQL 输出配置

cs
services.AddSchedule(options =>
{
    options.Trigger.ConvertToSQL = (tableName, columnNames, trigger, behavior, naming) =>
    {
      // 生成新增 SQL
      if (behavior == PersistenceBehavior.Appended)
      {
            return trigger.ConvertToInsertSQL(tableName, naming);
      }
      // 生成更新 SQL
      else if (behavior == PersistenceBehavior.Updated)
      {
            return trigger.ConvertToUpdateSQL(tableName, naming);
      }
      // 生成删除 SQL
      else if (behavior == PersistenceBehavior.Removed)
      {
            return trigger.ConvertToDeleteSQL(tableName, naming);
      }

      return string.Empty;
    };
});
  • ConvertToSQL 委托参数说明
    • tableName:数据库表名称,string 类型
    • columnNames:数据库列名:string[] 类型,只能通过 索引 获取
    • trigger:作业信息 Trigger 对象
    • behavior:持久化 PersistenceBehavior 类型,用于标记新增,更新还是删除操作
    • naming:命名法 NamingConventions 类型,包含 CamelCase(驼峰命名法)Pascal(帕斯卡命名法)UnderScoreCase(下划线命名法)

注意事项

如果在该自定义 SQL 输出方法中调用 trigger.ConvertToSQL(..) 会导致死循环。

查看最近运行记录

GetTimelines() 方法,可获取内存中作业触发器最近运行的 5 条记录,如:

cs
var timelines = trigger.GetTimelines();   // => [{numberOfRuns: 2, lastRunTime: "2023-01-03 14:00:08"}, {numberOfRuns: 1, lastRunTime: "2023-01-03 14:00:03"}, ...]

timelines 返回值为 IEnumerable<TriggerTimeline> 类型,其中 TriggerTimeline 类型提供以下属性:

  • TriggerTimeline
    • NumberOfRuns:当前运行次数,long 类型
    • LastRunTime:最近运行时间,DateTime? 类型
    • NextRunTime:下一次运行时间,DateTime? 类型
    • Status:作业触发器状态,TriggerStatus 枚举类型
    • Result:本次执行结果,string 类型
    • ElapsedTime:本次执行耗时,long 类型,单位 ms

更改作业触发器触发时间

如果需要更改作业触发器触发时间通常需要程序员自行组合 .SetTriggerType<TTrigger>().SetArgs(args),但在实际开发中代码非常不直观,所以提供了一些列的 .AlterTo 方法,如:

cs
// 间隔 Period 方式
// 设置毫秒周期(间隔)作业触发器
triggerBuilder.AlterToPeriod(5000);
// 设置秒周期(间隔)作业触发器
triggerBuilder.AlterToPeriodSeconds(5);
// 设置分钟周期(间隔)作业触发器
triggerBuilder.AlterToPeriodMinutes(5);
// 设置小时周期(间隔)作业触发器
triggerBuilder.AlterToPeriodHours(5);

// Cron 表达式方式
// 设置 Cron 表达式作业触发器
triggerBuilder.AlterToCron("* * * * *", CronStringFormat.Default);
// 设置每秒开始作业触发器
triggerBuilder.AlterToSecondly();
// 设置每分钟开始作业触发器
triggerBuilder.AlterToMinutely();
// 设置每小时开始作业触发器
triggerBuilder.AlterToHourly();
// 设置每天(午夜)开始作业触发器
triggerBuilder.AlterToDaily();
// 设置每月1号(午夜)开始作业触发器
triggerBuilder.AlterToMonthly();
// 设置每周日(午夜)开始作业触发器
triggerBuilder.AlterToWeekly();
// 设置每年1月1号(午夜)开始作业触发器
triggerBuilder.AlterToYearly();
// 设置每周一至周五(午夜)开始作业触发器
triggerBuilder.AlterToWorkday();

// Cron 表达式 Macro At 方式
// 每第 3 秒
triggerBuilder.AlterToSecondlyAt(3);
// 每第 3,5,6 秒
triggerBuilder.AlterToSecondlyAt(3, 5, 6);

// 每分钟第 3 秒
triggerBuilder.AlterToMinutelyAt(3);
// 每分钟第 3,5,6 秒
triggerBuilder.AlterToMinutelyAt(3, 5, 6);

// 每小时第 3 分钟
triggerBuilder.AlterToHourlyAt(3);
// 每小时第 3,5,6 分钟
triggerBuilder.AlterToHourlyAt(3, 5, 6);

// 每天第 3 小时正(点)
triggerBuilder.AlterToDailyAt(3);
// 每天第 3,5,6 小时正(点)
triggerBuilder.AlterToDailyAt(3, 5, 6);

// 每月第 3 天零点正
triggerBuilder.AlterToMonthlyAt(3);
// 每月第 3,5,6 天零点正
triggerBuilder.AlterToMonthlyAt(3, 5, 6);

// 每周星期 3 零点正
triggerBuilder.AlterToWeeklyAt(3);
triggerBuilder.AlterToWeeklyAt("WED");  // SUN(星期天),MON,TUE,WED,THU,FRI,SAT
// 每周星期 3,5,6 零点正
triggerBuilder.AlterToWeeklyAt(3, 5, 6);
triggerBuilder.AlterToWeeklyAt("WED", "FRI", "SAT");
// 还支持混合
triggerBuilder.AlterToWeeklyAt(3, "FRI", 6);

// 每年第 3 月 1 日零点正
triggerBuilder.AlterToYearlyAt(3);
triggerBuilder.AlterToYearlyAt("MAR");  // JAN(一月),FEB,MAR,APR,MAY,JUN,JUL,AUG,SEP,OCT,NOV,DEC
// 每年第 3,5,6 月 1 日零点正
triggerBuilder.AlterToYearlyAt(3);
triggerBuilder.AlterToYearlyAt(3, 5, 6);
triggerBuilder.AlterToYearlyAt("MAR", "MAY", "JUN");
// 还支持混合
triggerBuilder.AlterToYearlyAt(3, "MAY", 6);

// 设置自定义作业触发器
// 泛型方式
trigger.AlterTo<CustomTrigger>();
trigger.AlterTo<CustomTrigger>(30);
// 程序集方式
trigger.AlterTo("YourAssembly", "YourAssembly.CustomTrigger");
trigger.AlterTo("YourAssembly", "YourAssembly.CustomTrigger", 30);
//类型方式
trigger.AlterTo(typeof(CustomTrigger));
trigger.AlterTo(typeof(CustomTrigger), 30);

作业计划 Scheduler 及构建器

关于作业计划

所谓的作业计划(Scheduler)是将作业信息(JobDetail),作业触发器(Trigger)和作业处理程序(IJob)关联起来,并添加到作业调度器中等待调度执行。

作业计划(Scheduler)类型对外是不公开的,但提供了对应的 IScheduler 接口进行操作。

关于作业计划构建器

作业计划 Scheduler 是框架提供运行时的内部只读类型,那么我们该如何创建或变更 Scheduler 对象呢?

SchedulerBuilder 是框架提供可用来生成运行时 Scheduler 的类型,这样做的好处可避免外部直接修改运行时 Scheduler 数据,还能实现任何修改动作监听,也能避免多线程抢占情况。

作业调度模块提供了多种方式用来创建 SchedulerBuilder 对象。

  1. 通过 Create 静态方法创建
cs
// 通过作业 Id 创建
var schedulerBuilder = SchedulerBuilder.Create("job1");

// 通过泛型方式
var schedulerBuilder = SchedulerBuilder.Create<MyJob>(Triggers.PeriodSeconds(5), Triggers.Minutely());
var schedulerBuilder = SchedulerBuilder.Create<MyJob>(true, Triggers.PeriodSeconds(5), Triggers.Minutely());
var schedulerBuilder = SchedulerBuilder.Create<MyJob>("job1", Triggers.PeriodSeconds(5), Triggers.Minutely());
var schedulerBuilder = SchedulerBuilder.Create<MyJob>("job1", true, Triggers.PeriodSeconds(5), Triggers.Minutely());

// 通过类型方式
var schedulerBuilder = SchedulerBuilder.Create(typeof(MyJob), Triggers.PeriodSeconds(5), Triggers.Minutely());
var schedulerBuilder = SchedulerBuilder.Create(typeof(MyJob), true, Triggers.PeriodSeconds(5), Triggers.Minutely());
var schedulerBuilder = SchedulerBuilder.Create(typeof(MyJob), "job1", Triggers.PeriodSeconds(5), Triggers.Minutely());
var schedulerBuilder = SchedulerBuilder.Create(typeof(MyJob), "job1", true, Triggers.PeriodSeconds(5), Triggers.Minutely());

// 通过委托方式
var schedulerBuilder = SchedulerBuilder.Create((context, stoppingToken) => {}, Triggers.PeriodSeconds(5), Triggers.Minutely());
var schedulerBuilder = SchedulerBuilder.Create((context, stoppingToken) => {}, true, Triggers.PeriodSeconds(5), Triggers.Minutely());
var schedulerBuilder = SchedulerBuilder.Create((context, stoppingToken) => {}, "job1", Triggers.PeriodSeconds(5), Triggers.Minutely());
var schedulerBuilder = SchedulerBuilder.Create((context, stoppingToken) => {}, "job1", true, Triggers.PeriodSeconds(5), Triggers.Minutely());

// 通过 JobBuilder 和 0 或 N 个 TriggerBuilder 创建
var schedulerBuilder = SchedulerBuilder.Create(
            JobBuilder.Create<MyJob>()
            , Triggers.PeriodSeconds(5), Triggers.Minutely());
  1. 通过 IScheduler 接口创建

这种方式常用于在运行时更新作业信息。

cs
var schedulerBuilder = SchedulerBuilder.From(scheduler);

//也可以通过以下方式
var schedulerBuilder = scheduler.GetBuilder();
  1. 通过 JSON 字符串创建

该方式非常灵活,可从配置文件,JSON 字符串,或其他能够返回 JSON 字符串的地方创建。

cs
var schedulerBuilder = SchedulerBuilder.From(@"
{
 ""jobDetail"": {
  ""jobId"": ""job1"",
  ""groupName"": null,
  ""jobType"": ""MyJob"",
  ""assemblyName"": ""ConsoleApp32"",
  ""description"": null,
  ""concurrent"": true,
  ""includeAnnotations"": false,
  ""properties"": ""{}"",
  ""updatedTime"": ""2022-12-04 11:51:00.483""
 },
 ""triggers"": [{
  ""triggerId"": ""job1_trigger1"",
  ""jobId"": ""job1"",
  ""triggerType"": ""Penkar.Schedule.PeriodTrigger"",
  ""assemblyName"": ""Penkar"",
  ""args"": ""[5000]"",
  ""description"": null,
  ""status"": 2,
  ""startTime"": null,
  ""endTime"": null,
  ""lastRunTime"": ""2022-12-04 17:52:34.768"",
  ""nextRunTime"": ""2022-12-04 17:52:39.769"",
  ""numberOfRuns"": 1,
  ""maxNumberOfRuns"": 0,
  ""numberOfErrors"": 0,
  ""maxNumberOfErrors"": 0,
  ""numRetries"": 0,
  ""retryTimeout"": 1000,
  ""startNow"": true,
  ""runOnStart"": false,
  ""resetOnlyOnce"": true,
  ""result"": null,
  ""elapsedTime"": 100,
  ""updatedTime"": ""2022-12-04 17:52:34.803""
 }]
}
");

如果使用的是 .NET7,可使用 """ 避免转义,如:

cs
var schedulerBuilder = SchedulerBuilder.From("""
{
      "jobDetail": {
            "jobId": "job1",
            "groupName": null,
            "jobType": "MyJob",
            "assemblyName": "ConsoleApp32",
            "description": null,
            "concurrent": true,
            "includeAnnotations": false,
            "properties": "{}",
            "updatedTime": "2022-12-04 11:51:00.483"
      },
      "triggers": [{
            "triggerId": "job1_trigger1",
            "jobId": "job1",
            "triggerType": "Penkar.Schedule.PeriodTrigger",
            "assemblyName": "Penkar",
            "args": "[5000]",
            "description": null,
            "status": 2,
            "startTime": null,
            "endTime": null,
            "lastRunTime": "2022-12-04 17:52:34.768",
            "nextRunTime": "2022-12-04 17:52:39.769",
            "numberOfRuns": 1,
            "maxNumberOfRuns": 0,
            "numberOfErrors": 0,
            "maxNumberOfErrors": 0,
            "numRetries": 0,
            "retryTimeout": 1000,
            "startNow": true,
            "runOnStart": false,
            "resetOnlyOnce": true,
            "result": null,
            "elapsedTime": 10,
            "updatedTime": "2022-12-04 17:52:34.803"
      }]
}
""");

关于属性名匹配规则

支持 CamelCase(驼峰命名法)Pascal(帕斯卡命名法) 命名方式。

不支持 UnderScoreCase(下划线命名法) ,如 "include_annotations": true

  1. 还可以通过 Clone 静态方法从一个 SchedulerBuilder 创建
cs
var schedulerBuilder = SchedulerBuilder.Clone(fromSchedulerBuilder);

克隆说明

克隆操作将克隆 JobBuilderTriggerBuilders,同时持久化行为会被标记为 Appended

设置作业计划构建器

SchedulerBuilder 提供了多个方法操作 JobBuilderTriggerBuilder,如:

cs
// 获取作业信息构建器
var jobBuilder = schedulerBuilder.GetJobBuilder();

// 获取所有作业触发器构建器
var triggerBuilders = schedulerBuilder.GetTriggerBuilders();

// 获取单个作业触发器构建器
var triggerBuilder = schedulerBuilder.GetTriggerBuilder("job1_trigger1");
var triggerBuilder = schedulerBuilder.GetTriggerBuilder("not_found_trigger_id"); // => null

// 更新作业信息构建器
schedulerBuilder.UpdateJobBuilder(jobBuilder);
// 如果通过 .GetJobBuilder() 方式获取,那么可直接更新,无需调用 .UpdateJobBuilder(jobBuilder);
schedulerBuilder.UpdateJobBuilder(newJobBuilder, replace: true);

// 添加作业触发器构建器
schedulerBuilder.AddTriggerBuilder(triggerBuilder1, triggerBuilder2, ...);

// 更新作业触发器构建器
schedulerBuilder.UpdateTriggerBuilder(triggerBuilder1, triggerBuilder2, ...);
// 还可以选择覆盖更新还是不覆盖
schedulerBuilder.UpdateTriggerBuilder(new[] { triggerBuilder1, triggerBuilder2, ... }, replace: true);

// 删除作业触发器构建器,注意不是真的删除,而是标记为 Removed 删除状态
schedulerBuilder.RemoveTriggerBuilder("trigger1", "trigger2", ...);

// 清除所有作业触发器构建器,注意不是真的删除,而是标记为 Removed 删除状态
schedulerBuilder.ClearTriggerBuilders();

// 输出为 JSON 格式
var json = schedulerBuilder.ConvertToJSON();
var json = schedulerBuilder.ConvertToJSON(NamingConventions.CamelCase);

// 将作业计划构建器转换成可枚举的 Dictionary<JobBuilder, TriggerBuilder>
foreach(var (jobBuilder, triggerBuilder) in schedulerBuilder.GetEnumerable())
{
      // ....
}

作业计划构建器持久化方法

作业计划构建器 SchedulerBuilder 提供了三个标记作业持久化行为的方法:

  • Appended():标记作业计划构建器是新增的,届时生成的 SQLINSERT 语句
  • Updated():标记作业计划构建器已被更新,届时生成的 SQLUpdated 语句,如果标记为此操作,那么当前作业调度器初始化时将新增至内存中
  • Removed():标记作业计划构建器已被删除,届时生成的 SQLDeleted 语句,如果标记为此操作,那么当前作业调度器初始化时将不会添加至调度器中
cs
services.AddSchedule(options =>
{
    options.AddJob(SchedulerBuilder.Create<MyJob>("job1").Appended()
        , SchedulerBuilder.Create<MyJob>("job2").Updated()
        , SchedulerBuilder.Create<MyJob>("job3").Removed());
});

查看作业调度器初始化日志:

bash
info: 2022-12-05 12:14:42.8481157 +08:00 星期一 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is running.
info: 2022-12-05 12:14:42.8597028 +08:00 星期一 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is preloading...
info: 2022-12-05 12:14:42.9360896 +08:00 星期一 L System.Logging.ScheduleService[0] #1
      The scheduler of <job1> successfully appended to the schedule.
info: 2022-12-05 12:14:42.9471072 +08:00 星期一 L System.Logging.ScheduleService[0] #1
      The scheduler of <job2> successfully appended and updated to the schedule.
info: 2022-12-05 12:14:42.9562673 +08:00 星期一 L System.Logging.ScheduleService[0] #1
      The scheduler of <job3> successfully removed to the schedule.
warn: 2022-12-05 12:14:42.9748930 +08:00 星期一 L System.Logging.ScheduleService[0] #1
      Schedule hosted service preload completed, and a total of <2> schedulers are appended.

多种格式字符串输出

Scheduler/ISchedulerSchedulerBuilder 都提供了多种将自身转换成特定格式的字符串。

  1. 转换成 JSON 字符串
cs
var json = schedulerBuilder.ConvertToJSON();

字符串打印如下:

json
{
  "jobDetail": {
    "jobId": "job1",
    "groupName": null,
    "jobType": "MyJob",
    "assemblyName": "ConsoleApp32",
    "description": null,
    "concurrent": true,
    "includeAnnotations": false,
    "properties": "{}",
    "updatedTime": "2022-12-04 11:51:00.483"
  },
  "triggers": [
    {
      "triggerId": "job1_trigger1",
      "jobId": "job1",
      "triggerType": "Penkar.Schedule.PeriodTrigger",
      "assemblyName": "Penkar",
      "args": "[5000]",
      "description": null,
      "status": 2,
      "startTime": null,
      "endTime": null,
      "lastRunTime": "2022-12-04 17:52:34.768",
      "nextRunTime": "2022-12-04 17:52:39.769",
      "numberOfRuns": 1,
      "maxNumberOfRuns": 0,
      "numberOfErrors": 0,
      "maxNumberOfErrors": 0,
      "numRetries": 0,
      "retryTimeout": 1000,
      "startNow": true,
      "runOnStart": false,
      "resetOnlyOnce": true,
      "result": null,
      "elapsedTime": 100,
      "updatedTime": "2022-12-04 17:52:34.803"
    }
  ]
}

作业调度器 ScheduleOptionsBuilder 配置选项

关于 ScheduleOptionsBuilder

ScheduleOptionsBuilder 配置选项主要是用来初始化作业调度器及相关服务配置的。只作为 services.AddSchedule 服务注册的配置参数,如:

cs
// 通过委托的方式配置
services.AddSchedule(options =>
{
      // options 类型为 ScheduleOptionsBuilder
});

// 自行创建对象实例方式配置
var scheduleOptionsBuilder = new ScheduleOptionsBuilder();
services.AddSchedule(scheduleOptionsBuilder);

ScheduleOptionsBuilder 内置属性和方法

  • 内置属性配置
cs
services.AddSchedule(options =>
{
      // 是否使用 UTC 时间,该配置主要用来作为作业调度器检查时间格式的依据
      options.UseUtcTimestamp = false;

      // 是否输出作业调度器日志
      options.LogEnabled = true;

      // 配置集群 Id,默认值为启动程序集的名称
      options.ClusterId = "cluster1";

      // 配置输出 SQL 的数据库类型,Penkar 4.8.2.3+
      options.BuildSqlType = SqlTypes.SqlServer;

      // 配置作业信息 JobDetail 相关配置,如配置自定义 SQL 输出
      options.JobDetail.ConvertToSQL((tableName, columnNames, jobDetail, behavior, naming) =>
      {
      });

      // 启用作业执行日志输出
      options.JobDetail.LogEnabled = true;      // 默认 false

      // 配置作业触发器 Trigger 相关配置,如配置自定义 SQL 输出
      options.Trigger.ConvertToSQL((tableName, columnNames, trigger, behavior, naming) =>
      {
      });

      // 定义未捕获的异常,通常是 Task 异常
      options.UnobservedTaskExceptionHandler = (obj, args) =>
      {
      };
});
  • 内置方法配置
cs
services.AddSchedule(options =>
{
      // 添加作业
      options.AddJob(schedulerBuilder);
      options.AddJob(schedulerBuilder, schedulerBuilder1, ...); // Penkar 4.8.2.4+
      options.AddJob(jobBuilder, triggerBuilder, ...);
      options.AddJob<MyJob>(triggerBuilder, ...);
      options.AddJob<MyJob>("作业 Id", triggerBuilder, ...);
      options.AddJob<MyJob>("作业 Id", concurrent: true, triggerBuilder, ...);
      options.AddJob<MyJob>(concurrent: true, triggerBuilder, ...);
      options.AddJob(typeof(MyJob), triggerBuilder, ...);
      options.AddJob(typeof(MyJob), "作业 Id", triggerBuilder, ...);
      options.AddJob(typeof(MyJob), "作业 Id", concurrent: true, triggerBuilder, ...);
      options.AddJob(typeof(MyJob), concurrent: true, triggerBuilder, ...);
      options.AddJob((context, stoppingToken) => {}, triggerBuilder, ...);
      options.AddJob((context, stoppingToken) => {}, "作业 Id", triggerBuilder, ...);
      options.AddJob((context, stoppingToken) => {}, "作业 Id", concurrent: true, triggerBuilder, ...);
      options.AddJob((context, stoppingToken) => {}, concurrent: true, triggerBuilder, ...);

      // 添加 HTTP Job,Penkar 4.8.7.7+
      options.AddHttpJob(request => {}, triggerBuilder, ...);
      options.AddHttpJob(request => {}, "作业 Id", triggerBuilder, ...);
      options.AddHttpJob(request => {}, "作业 Id", concurrent: true, triggerBuilder, ...);
      options.AddHttpJob(request => {}, concurrent: true, triggerBuilder, ...);
      options.AddHttpJob<YourHttpJob>(request => {}, triggerBuilder, ...);
      options.AddHttpJob<YourHttpJob>(request => {}, "作业 Id", triggerBuilder, ...);
      options.AddHttpJob<YourHttpJob>(request => {}, "作业 Id", concurrent: true, triggerBuilder, ...);
      options.AddHttpJob<YourHttpJob>(request => {}, concurrent: true, triggerBuilder, ...);

      // 添加作业执行监视器
      options.AddMonitor<YourJobMonitor>();

      // 添加作业执行器
      options.AddExecutor<YourJobMonitor>();

      // 添加作业持久化器
      options.AddPersistence<YourJobPersistence>();

      // 注册作业集群服务
      options.AddClusterServer<YourClusterServer>();
});

作业监视器 IJobMonitor

调度作业服务提供了 IJobMonitor 监视器接口,实现该接口可以监视所有作业处理程序执行事件,包括 执行之前、执行之后,执行异常

通过作业监视器可以实现作业完整生命周期控制,还能实现作业执行异常发送短信或邮件通知管理员或项目维护者。

如添加 YourJobMonitor

cs
public class YourJobMonitor : IJobMonitor
{
    private readonly ILogger<YourJobMonitor> _logger;
    public YourJobMonitor(ILogger<YourJobMonitor> logger)
    {
        _logger = logger;
    }

    public Task OnExecutingAsync(JobExecutingContext context, CancellationToken stoppingToken)
    {
        _logger.LogInformation("执行之前:{context}", context);
        return Task.CompletedTask;
    }

    public Task OnExecutedAsync(JobExecutedContext context, CancellationToken stoppingToken)
    {
        _logger.LogInformation("执行之后:{context}", context);

        if (context.Exception != null)
        {
            _logger.LogError(context.Exception, "执行出错啦:{context}", context);
        }

        return Task.CompletedTask;
    }
}

最后,在注册 Schedule 服务中注册 YourJobMonitor

cs
services.AddSchedule(options =>
{
    // 添加作业执行监视器
    options.AddMonitor<YourJobMonitor>();
});

执行结果如下:

bash
info: 2022-12-05 14:09:47.2337395 +08:00 星期一 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is running.
info: 2022-12-05 14:09:47.2401561 +08:00 星期一 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is preloading...
info: 2022-12-05 14:09:47.2780446 +08:00 星期一 L System.Logging.ScheduleService[0] #1
      The <job1_trigger1> trigger for scheduler of <job1> successfully appended to the schedule.
info: 2022-12-05 14:09:47.2810119 +08:00 星期一 L System.Logging.ScheduleService[0] #1
      The scheduler of <job1> successfully appended to the schedule.
warn: 2022-12-05 14:09:47.2941716 +08:00 星期一 L System.Logging.ScheduleService[0] #1
      Schedule hosted service preload completed, and a total of <1> schedulers are appended.
info: 2022-12-05 14:09:52.3190129 +08:00 星期一 L ConsoleApp32.YourJobMonitor[0] #4
      执行之前:<job1> [C] <job1 job1_trigger1> 5s 1ts 2022-12-05 14:09:52.241 -> 2022-12-05 14:09:57.260
info: 2022-12-05 14:09:52.3240208 +08:00 星期一 L MyJob[0] #4
      <job1> [C] <job1 job1_trigger1> 5s 1ts 2022-12-05 14:09:52.241 -> 2022-12-05 14:09:57.260
fail: 2022-12-05 14:09:52.5253398 +08:00 星期一 L System.Logging.ScheduleService[0] #4
      Error occurred executing <job1> [C] <job1 job1_trigger1> 5s 1ts 2022-12-05 14:09:52.241 -> 2022-12-05 14:09:57.260.
      ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
      System.Exception: 模拟出错
         at MyJob.ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken) in D:\Workplaces\Study\CSharp\ConsoleApp32\ConsoleApp32\Program.cs:line 28
         at Penkar.Schedule.ScheduleHostedService.<>c__DisplayClass23_3.<<BackgroundProcessing>b__3>d.MoveNext() in D:\Workplaces\OpenSources\Penkar\framework\Penkar\Schedule\HostedServices\ScheduleHostedService.cs:line 220
      --- End of stack trace from previous location ---
         at Penkar.FriendlyException.Retry.InvokeAsync(Func`1 action, Int32 numRetries, Int32 retryTimeout, Boolean finalThrow, Type[] exceptionTypes, Func`2 fallbackPolicy, Action`2 retryAction) in D:\Workplaces\OpenSources\Penkar\framework\Penkar\FriendlyException\Retry.cs:line 87
         at Penkar.Schedule.ScheduleHostedService.<>c__DisplayClass23_2.<<BackgroundProcessing>b__2>d.MoveNext() in D:\Workplaces\OpenSources\Penkar\framework\Penkar\Schedule\HostedServices\ScheduleHostedService.cs:line 218
      ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
info: 2022-12-05 14:09:52.5288429 +08:00 星期一 L ConsoleApp32.YourJobMonitor[0] #4
      执行之后:<job1> [C] <job1 job1_trigger1> 5s 1ts 2022-12-05 14:09:52.241 -> 2022-12-05 14:09:57.260
fail: 2022-12-05 14:09:52.5318526 +08:00 星期一 L ConsoleApp32.YourJobMonitor[0] #4
      执行出错啦:<job1> [C] <job1 job1_trigger1> 5s 1ts 2022-12-05 14:09:52.241 -> 2022-12-05 14:09:57.260
      ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
      System.InvalidOperationException: Error occurred executing <job1> [C] <job1 job1_trigger1> 5s 1ts 2022-12-05 14:09:52.241 -> 2022-12-05 14:09:57.260.
       ---> System.Exception: 模拟出错
         at MyJob.ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken) in D:\Workplaces\Study\CSharp\ConsoleApp32\ConsoleApp32\Program.cs:line 28
         at Penkar.Schedule.ScheduleHostedService.<>c__DisplayClass23_3.<<BackgroundProcessing>b__3>d.MoveNext() in D:\Workplaces\OpenSources\Penkar\framework\Penkar\Schedule\HostedServices\ScheduleHostedService.cs:line 220
      --- End of stack trace from previous location ---
         at Penkar.FriendlyException.Retry.InvokeAsync(Func`1 action, Int32 numRetries, Int32 retryTimeout, Boolean finalThrow, Type[] exceptionTypes, Func`2 fallbackPolicy, Action`2 retryAction) in D:\Workplaces\OpenSources\Penkar\framework\Penkar\FriendlyException\Retry.cs:line 87
         at Penkar.Schedule.ScheduleHostedService.<>c__DisplayClass23_2.<<BackgroundProcessing>b__2>d.MoveNext() in D:\Workplaces\OpenSources\Penkar\framework\Penkar\Schedule\HostedServices\ScheduleHostedService.cs:line 218
         --- End of inner exception stack trace ---
      ++++++++++++++++++++++++++++++++++++++++++++++++++++++++

还可以设置执行失败重试,如:

cs
services.AddSchedule(options =>
{
      options.AddJob<MyJob>(Triggers.PeriodSeconds(5).SetNumRetries(3));      // 重试 3 次
      options.AddMonitor<YourJobMonitor>();
});

执行结果如下:

bash
info: 2022-12-05 14:25:15.9316915 +08:00 星期一 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is running.
info: 2022-12-05 14:25:15.9391765 +08:00 星期一 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is preloading...
info: 2022-12-05 14:25:15.9737767 +08:00 星期一 L System.Logging.ScheduleService[0] #1
      The <job1_trigger1> trigger for scheduler of <job1> successfully appended to the schedule.
info: 2022-12-05 14:25:15.9754882 +08:00 星期一 L System.Logging.ScheduleService[0] #1
      The scheduler of <job1> successfully appended to the schedule.
warn: 2022-12-05 14:25:15.9892059 +08:00 星期一 L System.Logging.ScheduleService[0] #1
      Schedule hosted service preload completed, and a total of <1> schedulers are appended.
info: 2022-12-05 14:25:21.0056685 +08:00 星期一 L ConsoleApp32.YourJobMonitor[0] #4
      执行之前:<job1> [C] <job1 job1_trigger1> 5s 1ts 2022-12-05 14:25:20.937 -> 2022-12-05 14:25:25.949
info: 2022-12-05 14:25:21.0140485 +08:00 星期一 L MyJob[0] #4
      <job1> [C] <job1 job1_trigger1> 5s 1ts 2022-12-05 14:25:20.937 -> 2022-12-05 14:25:25.949
warn: 2022-12-05 14:25:21.0754973 +08:00 星期一 L System.Logging.ScheduleService[0] #4
      Retrying 1/3 times for <job1> [C] <job1 job1_trigger1> 5s 1ts 2022-12-05 14:25:20.937 -> 2022-12-05 14:25:25.949
info: 2022-12-05 14:25:22.0935914 +08:00 星期一 L MyJob[0] #4
      <job1> [C] <job1 job1_trigger1> 5s 1ts 2022-12-05 14:25:20.937 -> 2022-12-05 14:25:25.949
warn: 2022-12-05 14:25:22.1574937 +08:00 星期一 L System.Logging.ScheduleService[0] #4
      Retrying 2/3 times for <job1> [C] <job1 job1_trigger1> 5s 1ts 2022-12-05 14:25:20.937 -> 2022-12-05 14:25:25.949
info: 2022-12-05 14:25:23.1666732 +08:00 星期一 L MyJob[0] #4
      <job1> [C] <job1 job1_trigger1> 5s 1ts 2022-12-05 14:25:20.937 -> 2022-12-05 14:25:25.949
warn: 2022-12-05 14:25:23.2213212 +08:00 星期一 L System.Logging.ScheduleService[0] #4
      Retrying 3/3 times for <job1> [C] <job1 job1_trigger1> 5s 1ts 2022-12-05 14:25:20.937 -> 2022-12-05 14:25:25.949
info: 2022-12-05 14:25:24.2337356 +08:00 星期一 L MyJob[0] #4
      <job1> [C] <job1 job1_trigger1> 5s 1ts 2022-12-05 14:25:20.937 -> 2022-12-05 14:25:25.949
fail: 2022-12-05 14:25:24.3832385 +08:00 星期一 L System.Logging.ScheduleService[0] #4
      Error occurred executing <job1> [C] <job1 job1_trigger1> 5s 1ts 2022-12-05 14:25:20.937 -> 2022-12-05 14:25:25.949.
      ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
      System.Exception: 模拟出错
         at MyJob.ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken) in D:\Workplaces\Study\CSharp\ConsoleApp32\ConsoleApp32\Program.cs:line 28
         at Penkar.Schedule.ScheduleHostedService.<>c__DisplayClass23_3.<<BackgroundProcessing>b__3>d.MoveNext() in D:\Workplaces\OpenSources\Penkar\framework\Penkar\Schedule\HostedServices\ScheduleHostedService.cs:line 220
      --- End of stack trace from previous location ---
         at Penkar.FriendlyException.Retry.InvokeAsync(Func`1 action, Int32 numRetries, Int32 retryTimeout, Boolean finalThrow, Type[] exceptionTypes, Func`2 fallbackPolicy, Action`2 retryAction) in D:\Workplaces\OpenSources\Penkar\framework\Penkar\FriendlyException\Retry.cs:line 99
         at Penkar.FriendlyException.Retry.InvokeAsync(Func`1 action, Int32 numRetries, Int32 retryTimeout, Boolean finalThrow, Type[] exceptionTypes, Func`2 fallbackPolicy, Action`2 retryAction) in D:\Workplaces\OpenSources\Penkar\framework\Penkar\FriendlyException\Retry.cs:line 110
         at Penkar.Schedule.ScheduleHostedService.<>c__DisplayClass23_2.<<BackgroundProcessing>b__2>d.MoveNext() in D:\Workplaces\OpenSources\Penkar\framework\Penkar\Schedule\HostedServices\ScheduleHostedService.cs:line 218
      ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
info: 2022-12-05 14:25:24.3857991 +08:00 星期一 L ConsoleApp32.YourJobMonitor[0] #4
      执行之后:<job1> [C] <job1 job1_trigger1> 5s 1ts 2022-12-05 14:25:20.937 -> 2022-12-05 14:25:25.949
fail: 2022-12-05 14:25:24.3888126 +08:00 星期一 L ConsoleApp32.YourJobMonitor[0] #4
      执行出错啦:<job1> [C] <job1 job1_trigger1> 5s 1ts 2022-12-05 14:25:20.937 -> 2022-12-05 14:25:25.949
      ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
      System.InvalidOperationException: Error occurred executing <job1> [C] <job1 job1_trigger1> 5s 1ts 2022-12-05 14:25:20.937 -> 2022-12-05 14:25:25.949.
       ---> System.Exception: 模拟出错
         at MyJob.ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken) in D:\Workplaces\Study\CSharp\ConsoleApp32\ConsoleApp32\Program.cs:line 28
         at Penkar.Schedule.ScheduleHostedService.<>c__DisplayClass23_3.<<BackgroundProcessing>b__3>d.MoveNext() in D:\Workplaces\OpenSources\Penkar\framework\Penkar\Schedule\HostedServices\ScheduleHostedService.cs:line 220
      --- End of stack trace from previous location ---
         at Penkar.FriendlyException.Retry.InvokeAsync(Func`1 action, Int32 numRetries, Int32 retryTimeout, Boolean finalThrow, Type[] exceptionTypes, Func`2 fallbackPolicy, Action`2 retryAction) in D:\Workplaces\OpenSources\Penkar\framework\Penkar\FriendlyException\Retry.cs:line 99
         at Penkar.FriendlyException.Retry.InvokeAsync(Func`1 action, Int32 numRetries, Int32 retryTimeout, Boolean finalThrow, Type[] exceptionTypes, Func`2 fallbackPolicy, Action`2 retryAction) in D:\Workplaces\OpenSources\Penkar\framework\Penkar\FriendlyException\Retry.cs:line 110
         at Penkar.Schedule.ScheduleHostedService.<>c__DisplayClass23_2.<<BackgroundProcessing>b__2>d.MoveNext() in D:\Workplaces\OpenSources\Penkar\framework\Penkar\Schedule\HostedServices\ScheduleHostedService.cs:line 218
         --- End of inner exception stack trace ---
      ++++++++++++++++++++++++++++++++++++++++++++++++++++++++

关于参数 JobExecutionContext

IJobMonitor 提供的 OnExecutingAsyncOnExecutedAsync 接口方法都包含一个 context 参数,前者是 JobExecutingContext,后者是 JobExecutedContext,它们都有一个共同的基类 JobExecutionContext

JobExecutionContext 提供了以下公共属性和公共方法:

  • JobExecutionContext 属性列表
    • JobId:作业 Id
    • TriggerId:当前触发器 Id
    • JobDetail:作业信息
    • Trigger:作业触发器
    • OccurrenceTime作业计划触发时间,最准确的记录时间
    • RunId:本次作业执行唯一 IdPenkar 4.8.5.1+ 提供
    • Result:设置/读取本次作业执行结果,Penkar 4.8.7.7+ 提供
    • ServiceProvider:服务提供器,Penkar 4.8.7.10+ 提供
  • JobExecutionContext 方法列表
    • .ConvertToJSON(naming):将作业计划转换成 JSON 字符串
    • .ToString():将作业执行信息输出为简要字符串

  • JobExecutingContext 在基类基础上拓展了 ExecutingTime 属性:
    • ExecutingTime:执行前时间
  • JobExecutedContext 则在基类基础上拓展了 ExecutedTimeException 属性:
    • ExecutedTime:执行后时间
    • Exception:执行异常

作业执行器 IJobExecutor

调度作业服务提供了 IJobExecutor 执行器接口,可以让开发者自定义作业处理函数执行策略,如 超时控制,失败重试等等

实现重试策略

如添加 YourJobExecutor

cs
public class YourJobExecutor : IJobExecutor
{
    private readonly ILogger<YourJobExecutor> _logger;
    public YourJobExecutor(ILogger<YourJobExecutor> logger)
    {
        _logger = logger;
    }

    public async Task ExecuteAsync(JobExecutingContext context, IJob jobHandler, CancellationToken stoppingToken)
    {
        // 实现失败重试策略,如失败重试 3 次
        await Retry.InvokeAsync(async () =>
        {
            await jobHandler.ExecuteAsync(context, stoppingToken);
        }, 3, 1000
        // 每次重试输出日志
        , retryAction: (total, times) =>
        {
            _logger.LogWarning("Retrying {current}/{times} times for {context}", times, total, context);
        });
    }
}

接着模拟 MyJob 执行出错:

cs
public class MyJob : IJob
{
    private readonly ILogger<MyJob> _logger;
    public MyJob(ILogger<MyJob> logger)
    {
        _logger = logger;
    }

    public Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
    {
        _logger.LogInformation($"{context}");

        throw new Exception("模拟出错");
        return Task.CompletedTask;
    }
}

最后,在注册 Schedule 服务中注册 YourJobExecutor

cs
services.AddSchedule(options =>
{
      // 添加作业执行器
      options.AddExecutor<YourJobExecutor>();
});

执行结果如下:

bash
info: 2022-12-05 14:36:41.2085688 +08:00 星期一 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is running.
info: 2022-12-05 14:36:41.2162510 +08:00 星期一 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is preloading...
info: 2022-12-05 14:36:41.2885816 +08:00 星期一 L System.Logging.ScheduleService[0] #1
      The <job1_trigger1> trigger for scheduler of <job1> successfully appended to the schedule.
info: 2022-12-05 14:36:41.2912130 +08:00 星期一 L System.Logging.ScheduleService[0] #1
      The scheduler of <job1> successfully appended to the schedule.
warn: 2022-12-05 14:36:41.3102057 +08:00 星期一 L System.Logging.ScheduleService[0] #1
      Schedule hosted service preload completed, and a total of <1> schedulers are appended.
info: 2022-12-05 14:36:46.3329097 +08:00 星期一 L MyJob[0] #13
      <job1> [C] <job1 job1_trigger1> 5s 1ts 2022-12-05 14:36:46.249 -> 2022-12-05 14:36:51.274
warn: 2022-12-05 14:36:46.3910063 +08:00 星期一 L ConsoleApp32.YourJobExecutor[0] #13
      Retrying 1/3 times for <job1> [C] <job1 job1_trigger1> 5s 1ts 2022-12-05 14:36:46.249 -> 2022-12-05 14:36:51.274
info: 2022-12-05 14:36:47.4014898 +08:00 星期一 L MyJob[0] #13
      <job1> [C] <job1 job1_trigger1> 5s 1ts 2022-12-05 14:36:46.249 -> 2022-12-05 14:36:51.274
warn: 2022-12-05 14:36:47.4471172 +08:00 星期一 L ConsoleApp32.YourJobExecutor[0] #13
      Retrying 2/3 times for <job1> [C] <job1 job1_trigger1> 5s 1ts 2022-12-05 14:36:46.249 -> 2022-12-05 14:36:51.274
info: 2022-12-05 14:36:48.4539737 +08:00 星期一 L MyJob[0] #13
      <job1> [C] <job1 job1_trigger1> 5s 1ts 2022-12-05 14:36:46.249 -> 2022-12-05 14:36:51.274
warn: 2022-12-05 14:36:48.4880918 +08:00 星期一 L ConsoleApp32.YourJobExecutor[0] #13
      Retrying 3/3 times for <job1> [C] <job1 job1_trigger1> 5s 1ts 2022-12-05 14:36:46.249 -> 2022-12-05 14:36:51.274
info: 2022-12-05 14:36:49.4984333 +08:00 星期一 L MyJob[0] #13
      <job1> [C] <job1 job1_trigger1> 5s 1ts 2022-12-05 14:36:46.249 -> 2022-12-05 14:36:51.274
fail: 2022-12-05 14:36:49.6714485 +08:00 星期一 L System.Logging.ScheduleService[0] #13
      Error occurred executing <job1> [C] <job1 job1_trigger1> 5s 1ts 2022-12-05 14:36:46.249 -> 2022-12-05 14:36:51.274.
      ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
      System.Exception: 模拟出错
         at MyJob.ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken) in D:\Workplaces\Study\CSharp\ConsoleApp32\ConsoleApp32\Program.cs:line 31
         at ConsoleApp32.YourJobExecutor.<>c__DisplayClass2_0.<<ExecuteAsync>b__0>d.MoveNext() in D:\Workplaces\Study\CSharp\ConsoleApp32\ConsoleApp32\YourJobExecutor.cs:line 20
      --- End of stack trace from previous location ---
         at Penkar.FriendlyException.Retry.InvokeAsync(Func`1 action, Int32 numRetries, Int32 retryTimeout, Boolean finalThrow, Type[] exceptionTypes, Func`2 fallbackPolicy, Action`2 retryAction) in D:\Workplaces\OpenSources\Penkar\framework\Penkar\FriendlyException\Retry.cs:line 99
         at Penkar.FriendlyException.Retry.InvokeAsync(Func`1 action, Int32 numRetries, Int32 retryTimeout, Boolean finalThrow, Type[] exceptionTypes, Func`2 fallbackPolicy, Action`2 retryAction) in D:\Workplaces\OpenSources\Penkar\framework\Penkar\FriendlyException\Retry.cs:line 110
         at ConsoleApp32.YourJobExecutor.ExecuteAsync(JobExecutingContext context, IJob jobHandler, CancellationToken stoppingToken) in D:\Workplaces\Study\CSharp\ConsoleApp32\ConsoleApp32\YourJobExecutor.cs:line 18
         at Penkar.Schedule.ScheduleHostedService.<>c__DisplayClass23_2.<<BackgroundProcessing>b__2>d.MoveNext() in D:\Workplaces\OpenSources\Penkar\framework\Penkar\Schedule\HostedServices\ScheduleHostedService.cs:line 232
      ++++++++++++++++++++++++++++++++++++++++++++++++++++++++

实现超时控制

如添加 YourJobExecutor

cs
public class YourJobExecutor : IJobExecutor
{
    private readonly ILogger<YourJobExecutor> _logger;
    public YourJobExecutor(ILogger<YourJobExecutor> logger)
    {
        _logger = logger;
    }

    public async Task ExecuteAsync(JobExecutingContext context, IJob jobHandler, CancellationToken stoppingToken)
    {
        await jobHandler.ExecuteAsync(context, stoppingToken)
            .WaitAsync(TimeSpan.FromMilliseconds(3000));    // 设置 3 秒超时
    }
}

接着模拟 MyJob 执行超时:

cs
public class MyJob : IJob
{
    private readonly ILogger<MyJob> _logger;
    public MyJob(ILogger<MyJob> logger)
    {
        _logger = logger;
    }

    public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
    {
        _logger.LogInformation($"{context}");
        await Task.Delay(6000);     // 模拟耗时 6 秒
    }
}

执行结果如下:

bash
info: 2022-12-20 13:57:01.7251541 +08:00 星期二 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is running.
info: 2022-12-20 13:57:01.7336016 +08:00 星期二 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is preloading...
info: 2022-12-20 13:57:02.2846096 +08:00 星期二 L System.Logging.ScheduleService[0] #1
      The <job1_trigger1> trigger for scheduler of <job1> successfully appended to the schedule.
info: 2022-12-20 13:57:02.3448819 +08:00 星期二 L System.Logging.ScheduleService[0] #1
      The scheduler of <job1> successfully appended to the schedule.
warn: 2022-12-20 13:57:02.3800053 +08:00 星期二 L System.Logging.ScheduleService[0] #1
      Schedule hosted service preload completed, and a total of <1> schedulers are appended.
info: 2022-12-20 13:57:07.3261111 +08:00 星期二 L MyJob[0] #14
      <job1> [C] <job1 job1_trigger1> 5s 1ts 2022-12-20 13:57:07.240 -> 2022-12-20 13:57:12.260
fail: 2022-12-20 13:57:10.5743871 +08:00 星期二 L System.Logging.ScheduleService[0] #14
      Error occurred executing <job1> [C] <job1 job1_trigger1> 5s 1ts 2022-12-20 13:57:07.240 -> 2022-12-20 13:57:12.260.
      ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
      System.TimeoutException: The operation has timed out.
         at YourJobExecutor.ExecuteAsync(JobExecutingContext context, IJob jobHandler, CancellationToken stoppingToken) in D:\Workplaces\Study\CSharp\ConsoleApp32\ConsoleApp32\Program.cs:line 41
         at Penkar.Schedule.ScheduleHostedService.<>c__DisplayClass23_2.<<BackgroundProcessing>b__2>d.MoveNext() in D:\Workplaces\OpenSources\Penkar\framework\Penkar\Schedule\HostedServices\ScheduleHostedService.cs:line 234
      ++++++++++++++++++++++++++++++++++++++++++++++++++++++++

关于 WaitAsync 说明

WaitAsync.NET6+ 新增的 Task 拓展方法,如需在 .NET5 中支持,可添加以下拓展:

cs
public static async Task WaitAsync(this Task task, TimeSpan timeout)
{
      using var timeoutCancellationTokenSource = new CancellationTokenSource();
      var delayTask = Task.Delay(timeout, timeoutCancellationTokenSource.Token);

      if(await Task.WhenAny(task, delayTask) == task)
      {
            timeoutCancellationTokenSource.Cancel();
            await task;
      }
      else
      {
            throw new TimeoutException("The operation has timed out.")
      }
}

更多控制

作业执行器功能远不止于此,通过自定义作业执行器还可以实现分片作业,关联子作业,故障转移,集群等控制。

作业计划工厂 ISchedulerFactory

作业计划工厂提供了程序运行时操作作业调度器,作业计划等诸多方法。

ISchedulerFactory 被注册为 单例 服务,允许在任何可依赖注入的服务获取,如:

cs
public class YourService: IYourService
{
    private readonly ISchedulerFactory _schedulerFactory;
    public YourService(ISchedulerFactory schedulerFactory)
    {
        _schedulerFactory = schedulerFactory;

        // 也可以通过 App.GetService<ISchedulerFactory>() 获取
    }

    public void SomeMethod([FromServices]ISchedulerFactory schedulerFactory)
    {
    }

    // .NET7+ 或 Penkar 4.8.0+
    public void SomeMethod(ISchedulerFactory schedulerFactory)
    {
    }
}

查找所有作业

cs
// 查找所有作业,包括 JobType == null 的非有效作业
var jobs = _schedulerFactory.GetJobs();
var jobsOfModels = _schedulerFactory.GetJobsOfModels();

// 查找特定分组的作业,包括 JobType == null 的非有效作业
var jobs = _schedulerFactory.GetJobs("group1");
var jobsOfModels = _schedulerFactory.GetJobsOfModels("group1");

// 查找所有作业,仅 JobType != null 有效作业
var jobs = _schedulerFactory.GetJobs(active: true);
var jobsOfModels = _schedulerFactory.GetJobsOfModels(active: true);

// 查找特定分组的作业,仅 JobType != null 有效作业
var jobs = _schedulerFactory.GetJobs("group1", true);
var jobsOfModels = _schedulerFactory.GetJobsOfModels("group1", true);

查找下一批触发的作业

cs
// 查找下一批触发的作业
var nextRunJobs = _schedulerFactory.GetNextRunJobs(DateTime.Now);
var nextRunJobsOfModels = _schedulerFactory.GetNextRunJobsOfModels(DateTime.Now);

// 查找特定分组下一批触发的作业
var nextRunJobs = _schedulerFactory.GetNextRunJobs(DateTime.Now, "group1");
var nextRunJobsOfModels = _schedulerFactory.GetNextRunJobsOfModels(DateTime.Now, "group1");

获取单个作业

cs
// 返回 ScheduleResult 类型
var scheduleResult = _schedulerFactory.TryGetJob("job1", out var scheduler); // 如果存在返回 => ScheduleResult.Succeed
var scheduleResult = _schedulerFactory.TryGetJob("not_found", out var scheduler); // => ScheduleResult.NotFound
var scheduleResult = _schedulerFactory.TryGetJob("", out var scheduler); // => ScheduleResult.NotIdentify

// 返回 IScheduler 类型
var scheduler = _schedulerFactory.GetJob("job1"); // 如果存在返回 IScheduler
var scheduler = _schedulerFactory.GetJob("not_found"); // => null
var scheduler = _schedulerFactory.GetJob(""); // => null

保存作业

保存作业是框架提供强大且简单的方法,支持 新增编辑删除 作业,也就是三大操作都可以直接通过此方法直接操作。

cs
// 返回 ScheduleResult 类型
var scheduleResult = _schedulerFactory.TrySaveJob(schedulerBuilder, out var scheduler);

// 无返回值,支持多个
_schedulerFactory.SaveJob(schedulerBuilder1, schedulerBuilder2, ...)

关于保存作业的背后行为

默认情况下,保存作业需要传递 SchedulerBuilder 对象,这个对象可通过 GetJob(jobId) 获取,如:

cs
var schedulerBuilder = _schedulerFactory.GetJob("jobId")?.GetBuilder();

此时它的内部 Behavior 属性被标记为 PersistenceBehavior.Updated,也就是更新状态,那么对于这个构建器的任何操作都会标记为 更新 操作。

如果通过 .Appended().Removed() 方法标记之后,那么它的操作行为就发生变化了。

  • 如果被标记为 .Appended(),那么它将进行 新增 操作。如:
cs
schedulerBuilder.Appended();
  • 如果被标记为 .Removed(),那么它将进行 删除 操作。如:
cs
schedulerBuilder.Removed();

比如以下的代码实则是 新增删除更新 操作:

cs
// 实际做新增操作
var scheduleResult = _schedulerFactory.TrySaveJob(SchedulerBuilder.Create<MyJob>(), out var scheduler); // Create 方法默认标记为 Appended

// 实际做删除操作
var schedulerBuilder = _schedulerFactory.GetJob("jobId")?.GetBuilder();
var scheduleResult = _schedulerFactory.TrySaveJob(schedulerBuilder?.Removed(), out var scheduler); // 标记为 Removed

// 实际做更新操作
var scheduleResult = _schedulerFactory.TrySaveJob(SchedulerBuilder.Create<MyJob>().Updated(), out var scheduler); // Create 方法默认标记为 Appended,但调用 Updated() 方法

另外,作业触发器 Trigger 也具备相同的行为。

添加作业

框架提供了非常多的重载方法添加作业,如:

cs
// SchedulerBuilder 方式
var scheduleResult = _schedulerFactory.TryAddJob(schedulerBuilder, out var scheduler);
_schedulerFactory.AddJob(schedulerBuilder1, schedulerBuilder2, ...);

// JobBuilder + TriggerBuilders 方式
var scheduleResult = _schedulerFactory.TryAddJob(jobBuilder, new[] { triggerBuilder1, triggerBuilder2, ...}, out var scheduler);
_schedulerFactory.TryAddJob(jobBuilder, triggerBuilder1, triggerBuilder2, ...);

// 泛型方式
var scheduleResult = _schedulerFactory.TryAddJob<MyJob>(new[] { triggerBuilder1, triggerBuilder2, ...}, out var scheduler);
_schedulerFactory.AddJob<MyJob>(triggerBuilder1, triggerBuilder2, ...);
// 支持配置作业 Id
var scheduleResult = _schedulerFactory.TryAddJob<MyJob>("job1", new[] { triggerBuilder1, triggerBuilder2, ...}, out var scheduler);
_schedulerFactory.AddJob<MyJob>("job1", triggerBuilder1, triggerBuilder2, ...);
// 支持配置作业 Id + 串行/并行
var scheduleResult = _schedulerFactory.TryAddJob<MyJob>("job1", true, new[] { triggerBuilder1, triggerBuilder2, ...}, out var scheduler);
_schedulerFactory.AddJob<MyJob>("job1", true, triggerBuilder1, triggerBuilder2, ...);
// 支持配置 串行/ 并行
var scheduleResult = _schedulerFactory.TryAddJob<MyJob>(true, new[] { triggerBuilder1, triggerBuilder2, ...}, out var scheduler);
_schedulerFactory.AddJob<MyJob>(true, triggerBuilder1, triggerBuilder2, ...);

// 类型方式
var scheduleResult = _schedulerFactory.TryAddJob(typeof(MyJob), new[] { triggerBuilder1, triggerBuilder2, ...}, out var scheduler);
_schedulerFactory.AddJob(typeof(MyJob), triggerBuilder1, triggerBuilder2, ...);
// 支持配置作业 Id
var scheduleResult = _schedulerFactory.TryAddJob(typeof(MyJob), "job1", new[] { triggerBuilder1, triggerBuilder2, ...}, out var scheduler);
_schedulerFactory.AddJob(typeof(MyJob), "job1", triggerBuilder1, triggerBuilder2, ...);
// 支持配置作业 Id + 串行/并行
var scheduleResult = _schedulerFactory.TryAddJob(typeof(MyJob), "job1", true, new[] { triggerBuilder1, triggerBuilder2, ...}, out var scheduler);
_schedulerFactory.AddJob(typeof(MyJob), "job1", true, triggerBuilder1, triggerBuilder2, ...);
// 支持配置 串行/ 并行
var scheduleResult = _schedulerFactory.TryAddJob(typeof(MyJob), true, new[] { triggerBuilder1, triggerBuilder2, ...}, out var scheduler);
_schedulerFactory.AddJob(typeof(MyJob), true, triggerBuilder1, triggerBuilder2, ...);

// 动态作业委托方式
var scheduleResult = _schedulerFactory.TryAddJob((context, stoppingToken) => { }, new[] { triggerBuilder1, triggerBuilder2, ...}, out var scheduler);
_schedulerFactory.AddJob((context, stoppingToken) => { }, triggerBuilder1, triggerBuilder2, ...);
// 支持配置作业 Id
var scheduleResult = _schedulerFactory.TryAddJob((context, stoppingToken) => { }, "job1", new[] { triggerBuilder1, triggerBuilder2, ...}, out var scheduler);
_schedulerFactory.AddJob((context, stoppingToken) => { }, "job1", triggerBuilder1, triggerBuilder2, ...);
// 支持配置作业 Id + 串行/并行
var scheduleResult = _schedulerFactory.TryAddJob((context, stoppingToken) => { }, "job1", true, new[] { triggerBuilder1, triggerBuilder2, ...}, out var scheduler);
_schedulerFactory.AddJob((context, stoppingToken) => { }, "job1", true, triggerBuilder1, triggerBuilder2, ...);
// 支持配置 串行/ 并行
var scheduleResult = _schedulerFactory.TryAddJob((context, stoppingToken) => { }, true, new[] { triggerBuilder1, triggerBuilder2, ...}, out var scheduler);
_schedulerFactory.AddJob((context, stoppingToken) => { }, true, triggerBuilder1, triggerBuilder2, ...);

// HTTP 作业,Penkar 4.8.7.7+
// 泛型方式
var scheduleResult = _schedulerFactory.TryAddHttpJob<MyHttpJob>(request => {}, new[] { triggerBuilder1, triggerBuilder2, ...}, out var scheduler);
_schedulerFactory.AddHttpJob<MyHttpJob>(request => {}, triggerBuilder1, triggerBuilder2, ...);
// 支持配置作业 Id
var scheduleResult = _schedulerFactory.TryAddHttpJob<MyHttpJob>(request => {}, "job1", new[] { triggerBuilder1, triggerBuilder2, ...}, out var scheduler);
_schedulerFactory.AddHttpJob<MyHttpJob>(request => {}, "job1", triggerBuilder1, triggerBuilder2, ...);
// 支持配置作业 Id + 串行/并行
var scheduleResult = _schedulerFactory.TryAddHttpJob<MyHttpJob>(request => {}, "job1", true, new[] { triggerBuilder1, triggerBuilder2, ...}, out var scheduler);
_schedulerFactory.AddHttpJob<MyHttpJob>(request => {}, "job1", true, triggerBuilder1, triggerBuilder2, ...);
// 支持配置 串行/ 并行
var scheduleResult = _schedulerFactory.TryAddHttpJob<MyHttpJob>(request => {}, true, new[] { triggerBuilder1, triggerBuilder2, ...}, out var scheduler);
_schedulerFactory.AddHttpJob<MyHttpJob>(true, triggerBuilder1, triggerBuilder2, ...);

// 默认方式
var scheduleResult = _schedulerFactory.TryAddHttpJob(request => {}, new[] { triggerBuilder1, triggerBuilder2, ...}, out var scheduler);
_schedulerFactory.AddHttpJob(request => {}, triggerBuilder1, triggerBuilder2, ...);
// 支持配置作业 Id
var scheduleResult = _schedulerFactory.TryAddHttpJob(request => {}, "job1", new[] { triggerBuilder1, triggerBuilder2, ...}, out var scheduler);
_schedulerFactory.AddHttpJob(request => {}, "job1", triggerBuilder1, triggerBuilder2, ...);
// 支持配置作业 Id + 串行/并行
var scheduleResult = _schedulerFactory.TryAddHttpJob(request => {}, "job1", true, new[] { triggerBuilder1, triggerBuilder2, ...}, out var scheduler);
_schedulerFactory.AddHttpJob(request => {}, "job1", true, triggerBuilder1, triggerBuilder2, ...);
// 支持配置 串行/ 并行
var scheduleResult = _schedulerFactory.TryAddHttpJob(request => {}, true, new[] { triggerBuilder1, triggerBuilder2, ...}, out var scheduler);
_schedulerFactory.AddHttpJob(request => {}, true, triggerBuilder1, triggerBuilder2, ...);

更新作业

cs
// 返回 ScheduleResult 方式
var scheduleResult = _schedulerFactory.TryUpdateJob(schedulerBuilder, out var scheduler);

// 无返回值方式
_schedulerFactory.UpdateJob(schedulerBuilder1, schedulerBuilder2, ...);

删除作业

cs
// 返回 ScheduleResult 方式
var scheduleResult = _schedulerFactory.TryRemoveJob("job1", out var scheduler);
var scheduleResult = _schedulerFactory.TryRemoveJob(scheduler);

// 无返回值方式
_schedulerFactory.RemoveJob("job1", "job2", ...);
_schedulerFactory.RemoveJob(scheduler1, scheduler2, ...);

检查作业是否存在

cs
var isExist = _schedulerFactory.ContainsJob("job1");

// 还可以通过 group 查找
var isExist = _schedulerFactory.ContainsJob("job1", "group1");

启动所有作业

cs
_schedulerFactory.StartAll();

// 还可以通过 group 启动
_schedulerFactory.StartAll("group1");

暂停所有作业

cs
_schedulerFactory.PauseAll();

// 还可以通过 group 操作
 _schedulerFactory.PauseAll("group1");

删除所有作业

cs
_schedulerFactory.RemoveAll();

// 还可以通过 group 操作
 _schedulerFactory.RemoveAll("group1");

强制触发所有作业持久化操作

cs
_schedulerFactory.PersistAll();

// 还可以通过 group 操作
 _schedulerFactory.PersistAll("group1");

校对所有作业

cs
_schedulerFactory.CollateAll();

// 还可以通过 group 操作
 _schedulerFactory.CollateAll("group1");

强制唤醒作业调度器

正常情况下,作业调度器会自动管理 CPU 休眠和唤醒,但一些特殊情况下需要强制唤醒作业调度器(比如调度器假死了,被回收了...),可通过以下方式:

cs
_schedulerFactory.CancelSleep();

立即执行作业

cs
// 带返回值
var scheduleResult = _schedulerFactory.TryRunJob("job1");
// 不带返回值
_schedulerFactory.RunJob("job1");

注意事项

如果作业本身处于 (Pause)暂停(NotStart)初始化时未启动(Unoccupied)无触发时间 状态,那么点击 立即执行 后将自动转至 就绪 状态。

作业计划 IScheduler

作业计划 Scheduler 的默认实现接口是 IScheduler,该接口主要用来操作当前(单个)作业。

获取 SchedulerModel 实例

获取 SchedulerModel 之后可直接访问 JobDetailTrigger 对象。

cs
var schedulerModel = scheduler.GetModel();

获取 SchedulerBuilder

cs
var schedulerBuilder = scheduler.GetBuilder();

获取 JobBuilder

cs
var jobBuilder = scheduler.GetJobBuilder();

获取 TriggerBuilder 集合

cs
var triggerBuilders = scheduler.GetTriggerBuilders();

获取单个 TriggerBuilder

cs
var triggerBuilder = scheduler.GetTriggerBuilder("trigger1");

获取作业信息

cs
var jobDetail = scheduler.GetJobDetail();

获取作业触发器集合

cs
var triggers = scheduler.GetTriggers();

获取单个作业触发器

cs
// 返回 ScheduleResult 方式
var scheduleResult = scheduler.TryGetTrigger("trigger1", out var trigger);    // 如果存在返回 ScheduleResult.Succeed
var scheduleResult = scheduler.TryGetTrigger("not_found", out var trigger);    // => ScheduleResult.NotFound
var scheduleResult = scheduler.TryGetTrigger("", out var trigger);    // => ScheduleResult.NotIdentify

// 返回 Trigger 方式
var trigger = scheduler.GetTrigger("trigger1"); // 如果存在返回 Trigger
var trigger = scheduler.GetTrigger("not_found"); // => null
var trigger = scheduler.GetTrigger(""); // => null

保存作业触发器

保存作业触发器是框架提供强大且简单的方法,支持 新增编辑删除 作业触发器,也就是三大操作都可以直接通过此方法直接操作。

cs
// 返回 ScheduleResult 类型
var scheduleResult = scheduler.TrySaveTrigger(triggerBuilder, out var trigger);

// 无返回值,支持多个
scheduler.SaveTrigger(triggerBuilder1, triggerBuilder2, ...)

关于保存作业触发器的背后行为

默认情况下,保存作业触发器需要传递 TriggerBuilder 对象,这个对象可通过 GetTriggerBuilder(triggerId) 获取,如:

cs
var triggerBuilder = scheduler.GetTriggerBuilder("trigger1");

此时它的内部 Behavior 属性被标记为 PersistenceBehavior.Updated,也就是更新状态,那么对于这个构建器的任何操作都会标记为 更新 操作。

如果通过 .Appended().Removed() 方法标记之后,那么它的操作行为就发生变化了。

  • 如果被标记为 .Appended(),那么它将进行 新增 操作。如:
cs
triggerBuilder.Appended();
  • 如果被标记为 .Removed(),那么它将进行 删除 操作。如:
cs
triggerBuilder.Removed();

比如以下的代码实则是 新增删除更新 操作:

cs
// 实际做新增操作
var scheduleResult = scheduler.TrySaveTrigger(Triggers.PeriodSeconds(5), out var trigger); // Create 方法默认标记为 Appended

// 实际做删除操作
var triggerBuilder = scheduler.GetTriggerBuilder("trigger1");
var scheduleResult = scheduler.TrySaveTrigger(triggerBuilder?.Removed(), out var trigger); // 标记为 Removed

// 实际做更新操作
var scheduleResult = scheduler.TrySaveTrigger(Trigggers.PeriodSeconds(5).Updated(), out var trigger); // Create 方法默认标记为 Appended,但调用 Updated() 方法

更新作业信息

cs
// 返回 ScheduleResult 类型
var scheduleResult = scheduler.TryUpdateDetail(jobBuilder, out var jobDetail);

// 无返回值
scheduler.UpdateDetail(jobBuilder);

// Penkar 4.8.6+ 支持
// 返回 ScheduleResult 类型
var scheduleResult = Scheduler.TryUpdateDetail(jobBuilder =>
{
      jobBuilder.SetDescription("~~~");
}, out var jobDetail);

// Penkar 4.8.6+ 支持
// 无返回值
scheduler.UpdateDetail(jobBuilder =>
{
      jobBuilder.SetDescription("~~~");
});

添加作业触发器

cs
// 返回 ScheduleResult 类型
var scheduleResult = scheduler.TryAddTrigger(triggerBuilder, out var trigger);

// 无返回值,支持多个
scheduler.AddTrigger(triggerBuilder1, triggerBuilder2, ...);

更新作业触发器

cs
// 返回 ScheduleResult 类型
var scheduleResult = scheduler.TryUpdateTrigger(triggerBuilder, out var trigger);

// 无返回值,支持多个
scheduler.UpdateTrigger(triggerBuilder1, triggerBuilder2, ...);

// Penkar 4.8.6+ 支持
// 返回 ScheduleResult 类型
var scheduleResult = scheduler.TryUpdateTrigger("triggerId", triggerBuilder =>
{
      triggerBuilder.SetDescription("~~");
}, out var trigger);

// Penkar 4.8.6+ 支持
// 无返回值
scheduler.UpdateTrigger("triggerId", triggerBuilder =>
{
      triggerBuilder.SetDescription("~~");
});

删除作业触发器

cs
// 返回 ScheduleResult 类型
var scheduleResult = scheduler.TryRemoveTrigger("trigger1", out var trigger);

// 无返回值,支持多个
scheduler.RemoveTrigger("trigger1", "trigger2", ...);

删除当前作业

cs
// 返回 ScheduleResult 类型
var scheduleResult = scheduler.TryRemove();

// 无返回值
scheduler.Remove();

判断作业触发器是否存在

cs
bool isExist = scheduler.ContainsTrigger("trigger1");

启动作业触发器

cs
bool succeed = scheduler.StartTrigger("trigger1");

暂停作业触发器

cs
bool succeed = scheduler.PauseTrigger("trigger1");

强制触发作业持久化操作

cs
scheduler.Persist();

启动当前作业

cs
scheduler.Start();

暂停当前作业

cs
scheduler.Pause();

校对当前作业

cs
scheduler.Collate();

强制刷新当前作业

通常情况下我们通过 _schedulerFactory.GetJob("jobId") 获取到作业之后,然后对这个作业进行操作,但操作之后这个对象并不能同步更改,需要反复调用 GetJob 方法。

IScheduler 任何操作都将自动调用 Reload() 方法刷新变量

cs
// 也可以自己手动强制刷新(通常不需要调用下面代码~~)
scheduler.Reload();

转换成 JSON 格式

cs
var json = scheduler.ConvertToJSON();
var json = scheduler.ConvertToJSON(NamingConventions.CamelCase);

转换成可枚举字典

通常我们在开发应用时,需要将作业计划信息进行拆解,比如一个作业计划包含两个作业触发器,那么可以通过 scheduler.GetEnumerable() 方法生成可枚举字典对象,字典中的项数量等于作业触发器数量。

cs
foreach (var (jobDetail, trigger) in scheduler.GetEnumerable())
{
      // ....
}

立即执行作业

cs
scheduler.Run();

注意事项

如果作业本身处于 (Pause)暂停(NotStart)初始化时未启动(Unoccupied)无触发时间 状态,那么点击 立即执行 后将自动转至 就绪 状态。

作业持久化器 IJobPersistence

关于作业持久化器

作业持久化器指的是可以通过存储介质如数据库中加载作业信息到内存中,又可以将内存中作业调度器的作业信息实时同步回存储介质中。

实现作业持久化器

调度作业服务提供了非常简单的 IJobPersistence 接口,只需实现该接口即可实现持久化,如实现数据库持久化:

cs
public class DbJobPersistence : IJobPersistence
{
    public IEnumerable<SchedulerBuilder> Preload()
    {
        // 作业调度服务启动时运行时初始化,可通过数据库加载,或者其他方式
        return Array.Empty<SchedulerBuilder>();
    }

    public SchedulerBuilder OnLoading(SchedulerBuilder builder)
    {
        // 如果是更新操作,则 return builder.Updated(); 将生成 UPDATE 语句
        // 如果是新增操作,则 return builder.Appended(); 将生成 INSERT 语句
        // 如果是删除操作,则 return builder.Removed(); 将生成 DELETE 语句
        // 如果无需标记操作,返回 builder 默认值即可
        return builder;
    }

    public void OnChanged(PersistenceContext context)
    {
        var sql = context.ConvertToSQL("job_detail");
        // 这里执行 sql 即可 💖
    }

    public void OnTriggerChanged(PersistenceTriggerContext context)
    {
        var sql = context.ConvertToSQL("job_trigger");
        // 这里执行 sql 即可 💖
    }
}

之后在 Startup.cs 中注册:

cs
services.AddSchedule(options =>
{
      options.AddPersistence<DbJobPersistence>();
});

可能有些开发者看到这里一脸不解,持久化不应该这么简单啊!其实就是这么简单....

IJobPersistence 详细说明

IJobPersistence 接口提供了以下四个方法:

  • Preload:作业调度服务启动时调用,可在这里动态创建作业计划构建器并返回。
cs
public IEnumerable<SchedulerBuilder> Preload()
{
      // 可以这里查询数据库返回

      // 这里可以扫描程序集动态创建返回
      return App.EffectiveTypes.Where(t => t.IsJobType())
                               .Select(t => SchedulerBuilder.Create(JobBuilder.Create(t), t.ScanTriggers()));

      // 如果类型贴有 [JobDetail] 特性,还可以一键扫描返回
      return App.EffectiveTypes.Where(t => t.IsJobType())
                               .Select(t => t.ScanToBuilder());

      // 还可以更简单~~
      return App.EffectiveTypes.ScanToBuilders();

      // 也可以手动返回
      return new[]
      {
            SchedulerBuilder.Create(JobBuilder.Create<MyJob>(), Triggers.Minutely())
      }
}
  • OnLoading:作业计划初始化通知,通常在这里进一步修改初始化作业计划构建器。

在作业调度器服务启动时会遍历程序中所有作业计划构建器,然后逐条调用该方法,开发者可以在这里进一步修改作业计划构建器数据,之后选择性返回 SchedulerBuilder 的持久化行为

SchedulerBuilder 提供 Updated()Appended()Removed() 来作为持久化行为标记。此标记将决定最终生成的 SQLUPDATE 还是 INSERT 还是 UPDATE

cs
public SchedulerBuilder OnLoading(SchedulerBuilder builder)
{
      // 比如这里修改作业信息描述
      builder.GetJobBuilder()
             .SetDescription("这是描述~~");

      // 还可以修改触发器
      builder.GetTriggerBuilder("trigger1")
             .SetDescription("这是触发器描述~~");

      // 还可以通过数据库查询返回填充 😎
      builder.GetJobBuilder()
             .LoadFrom(dbJobDetail); // dbJobDetail 表示根据 jobId 查询数据库返回的对象

      // 还可以获取枚举对象逐条更新
      foreach(var (jobBuilder, triggerBuilder) in builder.GetEnumerable())
      {
            jobBuilder.SetDescription("....");
            triggerBuilder.Updated();     // 标记该触发器已被更新,并生成 UPDATE 语句
            triggerBuilder.Removed();     // 标记该触发器已被删除,并生成 DELETE 语句
      }

      // 标记从其他地方更新,比如数据库
      return builder;
}

如果存储介质(如数据库)已经删除该作业,开发者可以标记为 Removed(),这样该作业会从内存中移除。

cs
public SchedulerBuilder OnLoading(SchedulerBuilder builder)
{
      // 比如这里根据 jobId 查询数据库已经确认数据不存在了

      // 标记从其他地方移除
      return builder.Removed();
}

如果存储介质(如数据库)新增了新作业但内存中不存在,开发者可以标记为 Append(),这样该作业会添加到内存中,但原有的 builder 就会被丢弃

cs
public SchedulerBuilder OnLoading(SchedulerBuilder builder)
{
      // 比如在这里动态创建作业计划构建器
      var newBuilder = SchedulerBuilder.Create<MyJob>(Triggers.Minutely());
      // 还可以克隆一个
      var newBuilder = SchedulerBuilder.Clone(builder);
      // 还可以读取配置文件/JSON
      var newBuilder = SchedulerBuilder.From(json);

      // 返回新的作业计划构建器并标记为新增
      return newBuilder.Appended();
}
  • OnChanged:作业计划 SchedulerJobDetail 变化时调用。

只要作业计划有任何变化都将触发该方法,该方法有一个 PersistenceContext 类型的参数 contextPersistenceContext 包含以下成员:

  • PersistenceContext 属性列表
    • JobId:作业 Idstring 类型
    • JobDetail:作业信息,JobDetail 类型
    • Behavior:持久化行为,PersistenceBehavior 枚举类型,包含 AppendedUpdatedRemoved 三个枚举成员
  • PersistenceContext 方法列表
    • ConvertToSQL:将 PersistenceContext 转换成 SQL 字符串,Behavior 属性值不同,生成的 SQL 不同
    • ConvertToJSON:将 PersistenceContext 转换成 JSON 字符串
    • ConvertToMonitor:将 PersistenceContext 转换成 Monitor 字符串
    • ToString:将 PersistenceContext 转换成 简要 字符串
    • GetNaming:提供将特定字符串输出不同的命名规则字符串
cs
public void OnChanged(PersistenceContext context)
{
      // 输出 CamelCase(驼峰命名法)SQL 语句,默认值
      var sql = context.ConvertToSQL("job_detail");
      // 输出 Pascal(帕斯卡命名法) SQL 语句
      var sql = context.ConvertToSQL("job_detail", NamingConventions.Pascal);
      // 输出 UnderScoreCase(下划线命名法) SQL 语句
      var sql = context.ConvertToSQL("job_detail", NamingConventions.UnderScoreCase);

      // 你要做的只是执行 SQL 了!!! 😎
}
  • OnTriggerChanged:作业计划 Scheduler 的触发器 Trigger 变化时调用。

只要作业计划触发器有任何变化都将触发该方法,该方法有一个 PersistenceTriggerContext 类型的参数 contextPersistenceTriggerContext 继承自 PersistenceContext

  • PersistenceTriggerContext 属性列表
    • JobId:作业 Idstring 类型
    • JobDetail:作业信息,JobDetail 类型
    • TriggerId:作业触发器 Idstring 类型
    • Trigger:作业触发器,Trigger 类型
    • Behavior:持久化行为,PersistenceBehavior 枚举类型,包含 AppendedUpdatedRemoved 三个枚举成员
  • PersistenceTriggerContext 方法列表
    • ConvertToSQL:将 PersistenceTriggerContext 转换成 SQL 字符串,Behavior 属性值不同,生成的 SQL 不同
    • ConvertToJSON:将 PersistenceTriggerContext 转换成 JSON 字符串,只包含 Trigger
    • ConvertAllToJSON:将 PersistenceTriggerContext 转换成 JSON 字符串,包含 JobDetailTrigger
    • ConvertToMonitor:将 PersistenceTriggerContext 转换成 Monitor 字符串
    • ToString:将 PersistenceTriggerContext 转换成 简要 字符串
    • GetNaming:提供将特定字符串输出不同的命名规则字符串
cs
public void OnTriggerChanged(PersistenceTriggerContext context)
{
      // 输出 CamelCase(驼峰命名法)SQL 语句,默认值
      var sql = context.ConvertToSQL("job_trigger");
      // 输出 Pascal(帕斯卡命名法) SQL 语句
      var sql = context.ConvertToSQL("job_trigger", NamingConventions.Pascal);
      // 输出 UnderScoreCase(下划线命名法) SQL 语句
      var sql = context.ConvertToSQL("job_trigger", NamingConventions.UnderScoreCase);

      // 你要做的只是执行 SQL 了!!! 😎
}

小知识

默认情况下,生成的 SQL 属于标准 SQL 语句,但未必适合所有数据库类型,所以我们可以指定 BuildSqlType 来生成特定数据库的语句,如:

cs
services.AddSchedule(options =>
{
      // 配置输出 SQL 的数据库类型,Penkar 4.8.2.3+
      options.BuildSqlType = SqlTypes.SqlServer;
});

作业集群控制

框架提供简单的集群功能,但并不能达到负载均衡的效果,而仅仅提供了故障转移的功能,当一个服务的作业调度器宕机时,另一个服务的作业调度器会启动。

实现集群故障转移

  1. 创建 JobClusterServer 类并实现 IJobClusterServer
cs
public class JobClusterServer : IJobClusterServer
{
    /// <summary>
    /// 当前作业调度器启动通知
    /// </summary>
    /// <param name="context">作业集群服务上下文</param>
    public void Start(JobClusterContext context)
    {
        // 在作业集群表中,如果 clusterId 不存在,则新增一条(否则更新一条),并设置 status 为 ClusterStatus.Waiting
    }

    /// <summary>
    /// 等待被唤醒
    /// </summary>
    /// <param name="context">作业集群服务上下文</param>
    /// <returns><see cref="Task"/></returns>
    public async Task WaitingForAsync(JobClusterContext context)
    {
        var clusterId = context.ClusterId;

        while (true)
        {
            try
            {
                // 在这里查询数据库,根据以下两种情况处理
                // 1) 如果作业集群表已有 status 为 ClusterStatus.Working 则继续循环
                // 2) 如果作业集群表中还没有其他服务或只有自己,则插入一条集群服务或调用 await WorkNowAsync(clusterId); 之后 return;
                // 3) 如果作业集群表中没有 status 为 ClusterStatus.Working 的,调用 await WorkNowAsync(clusterId); 之后 return;

                await WorkNowAsync(clusterId);
                return;
            }
            catch { }

            // 控制集群心跳频率
            await Task.Delay(3000);
        }
    }

    /// <summary>
    /// 当前作业调度器停止通知
    /// </summary>
    /// <param name="context">作业集群服务上下文</param>
    public void Stop(JobClusterContext context)
    {
        // 在作业集群表中,更新 clusterId 的 status 为 ClusterStatus.Crashed
    }

    /// <summary>
    /// 当前作业调度器宕机
    /// </summary>
    /// <param name="context">作业集群服务上下文</param>
    public void Crash(JobClusterContext context)
    {
        // 在作业集群表中,更新 clusterId 的 status 为 ClusterStatus.Crashed
    }

    /// <summary>
    /// 指示集群可以工作
    /// </summary>
    /// <param name="clusterId">集群 Id</param>
    /// <returns></returns>
    private Task WorkNowAsync(string clusterId)
    {
        // 在作业集群表中,更新 clusterId 的 status 为 ClusterStatus.Working

        // 模拟数据库更新操作(耗时)
        await Task.Delay(3000);
    }
}
  1. 注册集群服务
cs
services.AddSchedule(options =>
{
      options.ClusterId = "cluster1";
      options.AddClusterServer<JobClusterServer>();
});
  1. 作业集群输出日志
bash
info: 2022-12-05 18:26:11.4045753 +08:00 星期一 L System.Logging.ScheduleService[0] #1
      Schedule hosted service is running.
info: 2022-12-05 18:26:11.4126431 +08:00 星期一 L System.Logging.ScheduleService[0] #1
      The job cluster of <cluster1> service has been enabled, and waiting for instructions.
warn: 2022-12-05 18:26:14.4333100 +08:00 星期一 L System.Logging.ScheduleService[0] #6
      The job cluster of <cluster1> service worked now, and the current schedule hosted service will be preloading.
info: 2022-12-05 18:26:14.4411758 +08:00 星期一 L System.Logging.ScheduleService[0] #6
      Schedule hosted service is preloading...
info: 2022-12-05 18:26:14.4684974 +08:00 星期一 L System.Logging.ScheduleService[0] #6
      The <job1_trigger1> trigger for scheduler of <job1> successfully appended to the schedule.
info: 2022-12-05 18:26:14.4701128 +08:00 星期一 L System.Logging.ScheduleService[0] #6
      The scheduler of <job1> successfully appended to the schedule.
warn: 2022-12-05 18:26:14.4765709 +08:00 星期一 L System.Logging.ScheduleService[0] #6
      Schedule hosted service preload completed, and a total of <1> schedulers are appended.
info: 2022-12-05 18:26:19.5089541 +08:00 星期一 L MyJob[0] #16
      <job1> [C] <job1 job1_trigger1> 5s 1ts 2022-12-05 18:26:19.434 -> 2022-12-05 18:26:24.441

作业集群数据库表设计

只需包含 IdClusterIdDescriptionStatusUpdatedTime 字段即可,其中 StatusClusterStatus 枚举类型。

  • ClusterStatus 包含以下枚举成员
    • Crashed:宕机
    • Working:正常工作
    • Waiting:等待被唤醒,默认值

如何实现负载均衡

框架只提供了简单的故障转移的集群功能,如需实现负载均衡,可通过 TCP/IP 套接字实现。

ScheduleServe 静态类

该功能 建议 仅限不能通过 services.AddXXX 方式使用,比如控制台,Winfrom/WPF 等。

cs
IDisposable dispose =  ScheduleServe.Run(options =>
{
    options.AddJob<MyJob>(Triggers.Secondly());
});

这种方式有一个隐藏的巨大隐藏 “骚操作”:可以在任何地方创建作业调度服务,多次调用可以创建多个作业调度器。

推荐使用 Serve.Run()Serve.RunGeneric() 方式替代

Penkar 框架提供了 Serve.Run() 方式支持跨平台使用,还能支持注册更多服务,如:

cs
Serve.Run(services =>
{
    services.AddSchedule(options =>
    {
        options.Add<MyJob>(Triggers.Secondly());
    });
})

如无需 Web 功能,可通过 Serve.RunGeneric 替代 Serve.Run

如何部署

部署建议

Worker Service 代码集成例子

1. 安装 PenkarSundial

bash
# 完整的开发框架
dotnet add package Penkar;

# 只需要定时任务服务功能
dotnet add package Sundial

2. 注册 Schedule 服务

cs
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Penkar.Schedule;
// using Sundial;

namespace FurionWorkers;

public class Program
{
      public static void Main(string[] args)
      {
            CreateHostBuilder(args).Build().Run();
      }

      public static IHostBuilder CreateHostBuilder(string[] args) =>
            Host.CreateDefaultBuilder(args)
                .ConfigureServices((hostContext, services) =>
                {
                      services.AddSchedule(options =>
                      {
                          options.AddJob<MyJob>(Triggers.PeriodSeconds(5));
                      });
                });
}

小知识

如果使用 Serve 模式,那么代码将非常精简,无需上面第二个步骤的代码~,如:

cs
Serve.RunGeneric(services =>
{
      services.AddSchedule(options =>
      {
            options.AddJob<MyJob>(Triggers.PeriodSeconds(5));
      });
})

Dashboard 看板功能

内置了一个嵌入的定时任务看板 UI,只需要在 Startup.cs 中启用即可,如:

Sundial 中使用

如果使用的是 Sundial 独立开源项目,只需要安装 Sundial.Dashboard 包即可,无需安装 Sundial,前者已添加了后者的引用。

cs
app.UseStaticFiles();
app.UseScheduleUI();

// 还可以配置生产环境关闭
app.UseScheduleUI(options =>
{
    options.RequestPath = "/custom-job";  // 必须以 / 开头且不以 / 结尾
    options.DisableOnProduction = true;
    options.SyncRate = 300;   // 控制看板刷新频率,默认 300ms,Penkar 4.8.7.43+ 支持
});

中间件说明

app.UseScheduleUI() 必须在 app.UseStaticFiles() 之后注册。

Worker Service 中注册

默认情况下,Worker Service 不提供 Web 功能,那么自然而然不能提供 Web 看板功能,如果想使其支持,可通过以下步骤:

  1. 添加日志配置(appsettings.jsonappsettings.Development.json
json
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.Hosting.Lifetime": "Information",
      "Microsoft.AspNetCore": "Warning",
      "System.Net.Http.HttpClient": "Warning"
    }
  }
}
  1. 注册中间件服务
cs
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using WorkerService1;

IHost host = Host.CreateDefaultBuilder(args)
    .ConfigureServices(services =>
    {
        services.AddSchedule();
        services.AddHostedService<Worker>();
    })
    .ConfigureWebHostDefaults(builder => builder.Configure(app =>
    {
        app.UseStaticFiles();
        app.UseScheduleUI();
    }))
    .Build();

host.Run();

看板配置选项

app.UseScheduleUI 提供了可选的 ScheduleUIOptions 配置选项,提供以下配置:

  • RequestPath:配置看板入口地址,string 类型,默认 /schedule
  • DisableOnProduction:是否在生产环境关闭,bool 类型,默认 false
  • SyncRate:控制看板刷新频率,int 类型,默认 300,单位 毫秒Penkar 4.8.7.43+ 支持

接着打开浏览器并访问 /schedule 地址即可:

常见问题

作业处理程序中获取当前时间存在偏差

通常我们会在 IJob 实现类型中获取当前时间,但是这个时间可能存在着极小的误差,如:

cs
public class MyJob : IJob
{
    public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
    {
        var nowTime = DateTime.Now; // 此时的时间未必是真实触发时间,因为还包含创建线程,初始化等等时间

        // 正确的做法是
        var nowTime = context.OccurrenceTime;
    }
}

作业触发器参数序列化/反序列化

框架提供了 Schedular 静态类方法:Serialize/Deserialize 可对作业触发器参数 object[] 类型进行序列化和反序列化操作,通常在开发定时任务管理后台时非常有用。如:

cs
// 序列化,方便组合 `UI` 不同输入框作业触发器参数
var args = new object[] { "* * * * * *", CronStringFormat.WithSeconds };
var stringArgs = Schedular.Serialize(args);

// 反序列化,方便拆开作业触发器参数在 `UI` 不同列展示
var stringArgs = "[\"* * * * *\",0]";
var args = Schedular.Deserialize<object[]>(stringArgs);

作业信息额外数据序列化/反序列化

框架提供了 Schedular 静态类方法:Serialize/Deserialize 可对作业信息额外是数据 Dictionary<string, object> 类型进行序列化和反序列化操作,通常在开发定时任务管理后台时非常有用。如:

cs
// 序列化,方便组合 `UI` 不同输入框作业信息额外数据
var jobData = new Dictionary<string, object> { { "name", "Penkar" } };
var stringJobData = Schedular.Serialize(jobData);

// 反序列化,方便拆开作业作业信息额外数据在 `UI` 不同列展示
var stringJobData = "{\"name\":\"Penkar\"}";
var args = Schedular.Deserialize<Dictionary<string, object>>(stringJobData);

作业处理程序延迟处理

在作业处理程序中如需使用到延迟线程操作,推荐使用 Task.Delay 而不是 Thread.Sleep,原因是后者是同步延迟会阻塞线程,而且不能取消。