Chapter 28

techniques-taskscheduling

kisihara.c
kisihara.c
2021.04.10に更新

タスクスケジューリング

タスクスケジューリングとは、任意のコード(メソッドや関数)を、定期実行したり、定期間隔で実行したり、指定した時間で1日実行するスケジューリング機能だ。Linuxでは、OSレベルではcronなどのパッケージが使われる。Node.jsアプリでもエミュレートできるパッケージがいくつかある。Nestは@nestjs/scheduleパッケージを提供しており、これは人気のnode-cron(Node.js)と統合されている。このパッケージについて説明していこう。

インストール

まず必要な依存関係をインストールしよう。

$ npm install --save @nestjs/schedule
$ npm install --save-dev @types/cron

ジョブスケジューリングを有効するには、SchedulingModuleをルートのAppModuleににインポートして、以下のようにforRoot()静的メソッドを実行する。

import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';

@Module({
  imports: [
    ScheduleModule.forRoot()
  ],
})
export class AppModule {}

.forRoot()の呼び出しは、スケジューラを初期化し、アプリ内に存在する宣言的なcronジョブ、タイムアウト、インターバルを登録する。登録はonApplicationBootstrapライフサイクルフックが発生したときに行われ、すべてのモジュールのロード、全てのジョブのスケジュールの宣言を確実に行う。

宣言型cronジョブ

cronジョブは、任意の関数(メソッド呼び出し)を自動的に実行するようにスケジュールする。以下のように実行可能。

  • 指定した日時に一度だけ実行。
  • 指定した間隔で定期的に実行。(例:一時間に一介、一週間に一回、五分に一回)

以下のように、実行されるコードを含むメソッド定義の前に@Cron()デコレータをつけてcronジョブを宣言する。

import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';

@Injectable()
export class TasksService {
  private readonly logger = new Logger(TasksService.name);

  @Cron('45 * * * * *')
  handleCron() {
    this.logger.debug('Called when the current second is 45');
  }
}

この例では、現在の秒数が45になるたびにhandleCron()メソッドが呼び出される。言い換えれば、このメソッドは1分間に1回実行される。

@Cron()デコレータは標準的なcronパターンを全てサポートしている。

  • Asterisk(*)
  • Ranges(例:1-3,5)
  • Steps(例:*/2)

上の例では、45****をデコレータに渡した。次のキーは、cronのパターン文字列の各位置がどのように解釈されるかを示す。

* * * * * *
| | | | | |
| | | | | day of week
| | | | month
| | | day of month
| | hour
| minute
second (省略可能)
* * * * * * 秒ごと
45 * * * * * 毎分45秒
0 10 * * * * 毎時間、10分0秒
0 */30 9-17 * * * 9時と5時の間のすべての30分
0 30 11 * * 1-5 月曜から金曜日の11時30分

@nestjs/scheduleパッケージでは、よく使われるcronパターンを集めた便利なenumを提供している。以下のように使用できる。

import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';

@Injectable()
export class TasksService {
  private readonly logger = new Logger(TasksService.name);

  @Cron(CronExpression.EVERY_45_SECONDS)
  handleCron() {
    this.logger.debug('45秒ごとに呼び出される');
  }
}

この例では、handleCron()メソッドが45秒ごとに呼び出される。

別の方法として、JavaScriptのDateオブジェクトを@Cron()デコレータに指定する事もできる。こうすると、ジョブは指定された日付に一度だけ実行される。

HINT
JavaScriptの日付計算を使って、現在の日付を基準にしてジョブをスケジュールしてみよう。たとえば@Cron(new Date(Date.now() + 10 * 1000))と入力すると、アプリが起動してから10秒後にジョブがスケジュールされる。

また、@Cron()デコレータの2番めのパラメータとして、追加のオプションを与える事ができる。

name 宣言されたcronジョブにアクセスして制御する為に便利
timeZone 実行時のタイムゾーンを指定する。すると、指定したタイムゾーンを基準として、実際の時間を修正する(modify the actual time)。タイムゾーンが無効な場合はエラーが発生する。利用可能な全てのタイムゾーンは、Moment Timezoneにて確認可能。
utcOffset timeZoneパラメータを使う代わりに、タイムゾーンのオフセットを指定することができる。
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';

@Injectable()
export class NotificationService {
  @Cron('* * 0 * * *', {
    name: 'notifications',
    timeZone: 'Europe/Paris',
  })
  triggerNotifications() {}
}

宣言済みのcronジョブにアクセスして制御する事も、Dynamic APIを使用してcronジョブを動的に作成する事もできる(その際cronパターンは実行時に定義される(where its cron pattern is defined at runtime…ちゃんと読めてるか怪しいです))。APIを使用して宣言型のcronジョブにアクセスするには、省略可能なオプションオブジェクト nameプロパティをデコレータの第2引数として渡して、ジョブに名前を関連付ける必要がある。

宣言型インターバル

指定の間隔で定期的にメソッドを実行する事を宣言するには、メソッド定義の前に@Interval()デコレータをつける。インターバルの値をミリ秒単位の数値として、以下のようにデコレータに渡す。

@Interval(10000)
handleInterval() {
  this.logger.debug('Called every 10 seconds');
}

HINT
内部ではJavaScriptのsetInterval()関数を利用した仕組みとなっている。cronジョブを使って定期的なジョブをスケジューリングする事もできる。

宣言したクラスの外からDynamic APIを使って宣言した間隔を制御したい場合は、次のようにしてインターバルに名前をつける。

@Interval('notifications', 2500)
handleInterval() {}

Dynamic APIは実行時にインターバルのプロパティが定義されるダイナミック・インターバルの作成や、インターバルのリスト化削除も行える。

宣言型タイムアウト

指定したタイムアウト時に(一度だけ)メソッドを実行する事を宣言する場合は、メソッドの定義の前に@Timeout()デコレータをつける。以下のように、アプリケーション起動時からの相対的なタイムオフセット(ミリ秒単位)をデコレータに渡す。

@Timeout(5000)
handleTimeout() {
  this.logger.debug('5秒後に呼ばれる');
}

HINT
JavascriptのsetTimeout()関数を仕組みとしている。

宣言したクラスの外からDynamic APIを使って制御したい場合は、以下のようにタイムアウトに名前をつける。

@Timeout('notifications', 2500)
handleTimeout() {}

Dynamic APIは実行時にタイムアウトのプロパティが定義されるダイナミック・タイムアウトの作成や、タイムアウトのリスト化削除も行える。

動的スケジュールモジュールのAPI

nestjs/scheduleモジュールは、宣言的なcronジョブ、タイムアウト、インターバルを管理できるDynamic APIを提供する。このAPIでは実行時にプロパティが定義される、動的なcronジョブ、タイムアウト、およびインターバルの作成と管理も可能である。

動的なcronジョブ

SchedulerRegistryAPIを利用して、コードのどこからでもCronJobインスタンスへ、名前を使って参照できるようになる。まず標準的なコンストラクタインジェクションを使ってSchedulerReggisteryをインジェクションしよう。

constructor(private schedulerRegistry: SchedulerRegistry) {}

HINT
SchedulerRegistry@nestjs/scheduleパッケージからインポートする。

これを次のようにクラスで使用してみよう。以下のような宣言でcronジョブが作成されたとする。

@Cron('* * 8 * * *', {
  name: 'notifications',
})
triggerNotifications() {}

次のコードを使ってこのジョブにアクセスしよう。

const job = this.schedulerRegistry.getCronJob('notifications');

job.stop();
console.log(job.lastDate());

getCronJob()メソッドは指定されたcronジョブを返す。返されたCronJobオブジェクトは以下のメソッドを持つ。

  • stop() 実行が予定されているジョブを停止する。
  • start() 停止されたジョブを再起動する。
  • setTime(time:CronTime)ジョブを停止し、新しい時間を設定した後、ジョブを開始する。
  • lastDate()ジョブが最期に実行された日付の文字列表現を返す。
  • nextDates(count:number) 次のジョブ実行日を返すmomentオブジェクトの配列を(サイズはcountで)返す。

HINT
toDate()を使って、momentオブジェクトを人間が読める形にしよう。

以下のようにSchedulerRegistry.addCronJob()メソッドを使用して、新しいcronジョブを動的に作成する。

addCronJob(name: string, seconds: string) {
  const job = new CronJob(`${seconds} * * * * *`, () => {
    this.logger.warn(`毎 (${seconds}) 秒 でjob ${name} が動く!`);
  });

  this.schedulerRegistry.addCronJob(name, job);
  job.start();

  this.logger.warn(
    `job ${name} が毎分 ${seconds} 秒で動く!`,
  );
}

このコードでは、cronパッケージのCronJobオブジェクトを使用してcronジョブを作成している。CronJobのコンストラクタは、最初の引数としてcronパターン(@Cron()デコレータのようなもの)をとり、2番めの引数としてcronタイマーが起動したときに実行されるコールバックを取る。SchedulerRegistry.addCronJob()メソッドはCronJobの名前とCronJobオブジェクト自体を引数として取る。

WARNING
アクセスする前にSchedulerRegistryをインジェクションする事を忘れないでほしい。cronパッケージからCronJobをインポートしてほしい。

以下のようにSchedulerRegistry.deleteCronJob()メソッドを使って名前付きのcronジョブを削除してみよう。

deleteCron(name: string) {
  this.schedulerRegistry.deleteCronJob(name);
  this.logger.warn(`job ${name} deleted!`);
}

以下のようにSchedulerRegistry.getCronJobs()メソッドを使って全てのcronジョブをリスト化してみよう。

getCrons() {
  const jobs = this.schedulerRegistry.getCronJobs();
  jobs.forEach((value, key, map) => {
    let next;
    try {
      next = value.nextDates().toDate();
    } catch (e) {
      next = 'error: next fire date is in the past!';
    }
    this.logger.log(`job: ${key} -> next: ${next}`);
  });
}

getCronJobs()mapを返す。このコードではマップをイテレートして、各CronJobnextDates()にアクセスしようとしている。CronJob APIでは、ジョブが既に起動していて、将来の起動日がない場合は、例外を投げる。

動的なインターバル

SchedulerRegistry.getInterval()メソッドを使ってインターバルへの参照を取得する。先述のように、標準的なコンストラクタ・インジェクションを使用してSchedulerRegistryをインジェクションする。

constructor(private schedulerRegistry: SchedulerRegistry) {}

そして以下のように使う。

const interval = this.schedulerRegistry.getInterval('notifications');
clearInterval(interval);

次のようにSchedulerRegistry.addInterval()メソッドを使用して、新しいインターバルを動的に作成してみよう。

addInterval(name: string, milliseconds: number) {
  const callback = () => {
    this.logger.warn(`Interval ${name} executing at time (${milliseconds})!`);
  };

  const interval = setInterval(callback, milliseconds);
  this.schedulerRegistry.addInterval(name, interval);
}

このコードでは、標準的なJavaScriptのインターバルを作成し、それをScheduleRegistry.addInterval()メソッドに渡している。このメソッドはインターバルの名前とインターバル自体、2つの引数を取る。

SchedulerRegistry.deleteInterval()メソッドを使うとインターバルを削除できる。

deleteInterval(name: string) {
  this.schedulerRegistry.deleteInterval(name);
  this.logger.warn(`Interval ${name} deleted!`);
}

SchedulerRegistry.getIntervals()メソッドを使うと全てのインターバルをリスト化できる。

getIntervals() {
  const intervals = this.schedulerRegistry.getIntervals();
  intervals.forEach(key => this.logger.log(`Interval: ${key}`));
}

動的なタイムアウト

タイムアウトへの参照は、SchedulerRegistry.getTimeout()メソッドで得られる。先述のように、標準的なコンストラクタ・インジェクションを使用してSchedulerRegistryをインジェクションする。

constructor(private schedulerRegistry: SchedulerRegistry) {}

以下のように使う。

const timeout = this.schedulerRegistry.getTimeout('notifications');
clearTimeout(timeout);

次のようにSchedulerRegistry.addTimeout()メソッドを使用して、新しいタイムアウトを動的に作成してみよう。

addTimeout(name: string, milliseconds: number) {
  const callback = () => {
    this.logger.warn(`Timeout ${name} executing after (${milliseconds})!`);
  };

  const timeout = setTimeout(callback, milliseconds);
  this.schedulerRegistry.addTimeout(name, timeout);
}

このコードでは、標準的なJavaScriptのタイムアウトを作成し、それをScheduleRegistry.addTimeout()メソッドに渡している。このメソッドはタイムアウトの名前とタイムアウト自体、2つの引数を取る。

SchedulerRegistry.deleteTimeout()メソッドで名前付きのタイムアウトを削除してみよう。

deleteTimeout(name: string) {
  deleteTimeout(name: string) { this.schedulerRegistry.deleteTimeout(name);
  this.logger.warn(`Timeout ${name} deleted!`);
}

SchedulerRegistry.getTimeouts()メソッドで全てのタイムアウトをリスト化してみよう。

getTimeouts() {
  const timeouts = this.schedulerRegistry.getTimeouts();
  timeouts.forEach(key => this.logger.log(`Timeout: ${key}`));
}

サンプル

動く例はこちら