【Google Apps Script】その24　GASの自家製Cronで複数スクリプトの定期実行を一元管理する（1/2）

この記事は[Google Apps Scriptを実例交えて基礎からざっくり学ぶ Advent Calendar 2017](https://qiita.com/advent-calendar/2017/gas_beginner)　24日目の記事です。

本アドベントカレンダーは@rt_pの個人プロジェクトですが、筆者は[Ateam Brides Inc. Advent Calendar 2017](https://qiita.com/advent-calendar/2017/a-t-brides)にも参加しています。そちらでも出張版記事を書いているので、覗いていただけると嬉しいです。

# はじめに

さて、長らく続いたGASアドベントカレンダーも24日目。今日と明日で最後になります。
最後ですが2日間使い、GASで自家製Cronを作ってみます。

え？GASにはそもそも定期実行元からあるじゃん？と。
その通りです。

ただ、GASの定期実行をスプレッドシートでどんどん作っていくとある問題にぶち当たります。
**どのファイルでどのタイミングで定期実行されているか分からなくなってきたぞ…**

ということで、それを解決する方法を考えてみました。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/199678/6fea9855-22fc-ff12-db6e-2e337ec2402b.png)

動作イメージは上記の通りです。
Cronの役割を果たすスプレッドシートが1分ごとに実行すべきファイルがないか監視。
あったらそれぞれのスクリプトで立てたAPIを実行するというものです。

# スプレッドシートの準備

シートに「稼働中」と名前を付け、A1（1行目）に以下をコピペします。

|分|時|日|月|曜日|API URL|roomId|mailto|シートURL|説明|最終実行日時|
|---|---|---|---|---|---|---|---|---|---|---|

![sheet.png](https://qiita-image-store.s3.amazonaws.com/0/199678/ae5878ac-6fab-c60c-f6c3-bf141e77be8b.png)

roomIdとmailtoは今日の記事では使いませんが、次回の記事で使うので今のうちに用意しておきます。

# GASの準備

スクリプトエディタを開き、以下コードに置き換えて実行します。
スクリプトエディタの開き方や*承認が必要です*メッセージが出た際の対処法が分からない場合は
[アドベントカレンダー1日目のHello, world!記事](https://qiita.com/rf_p/items/0535ec495df8f9d2b894)をご参照ください。

```javascript:cron.gs
var ALL = '*';
var LIMIT_MATCHES = {'minute': '[0-5]?[0-9]', 'hour': '[01]?[0-9]|2[0-3]', 'day': '0?[1-9]|[1-2][0-9]|3[01]', 'month': '0?[1-9]|1[0-2]', 'week': '[0-6]'};
var MAX_NUMBERS   = {'minute': 59, 'hour': 23, 'day': 31, 'month': 12, 'week': 6}; // 本物のcronは曜日の7を日曜と判定するが、手間なのでmax6とする
var COLUMNS       = ['minute', 'hour', 'day', 'month', 'week'];

function myFunction() {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('稼働中');
  var cronList = getCronList(sheet);
  var currentTime = new Date();
  var times  = {
    'minute': Utilities.formatDate(currentTime, 'Asia/Tokyo', 'm'),
    'hour':   Utilities.formatDate(currentTime, 'Asia/Tokyo', 'H'),
    'day':    Utilities.formatDate(currentTime, 'Asia/Tokyo', 'd'),
    'month':  Utilities.formatDate(currentTime, 'Asia/Tokyo', 'M'),
    'week':   Utilities.formatDate(currentTime, 'Asia/Tokyo', 'u')
  };
  for (var i = 1; i < cronList.length; i++) { // スプレッドシートから取得した一行目(key:0)はラベルなので、key1から実行
    executeIfNeeded(cronList[i], times, sheet, i + 1);
  }
}

// シートからCronの一覧を取得し配列で返す
function getCronList(sheet) {
  var lastRow = sheet.getLastRow();
  return sheet.getRange(1, 1, lastRow, 8).getValues(); // 1行ずつ取得すると都度通信が走り重いので、一括で範囲内全てを取得する
}

// 実行すべきタイミングか判定し、必要であれば実行
function executeIfNeeded(cron, times, sheet, row) {
  for (var i in COLUMNS) { // minute, hour, day, month, weekを順番にチェックして全て条件にマッチするようならcron実行
    var timeType = COLUMNS[i];
    var timingList = getTimingList(cron[i], timeType);
    if (!isMatch(timingList, times[timeType])) {
      return false;
    }
  }
  sheet.getRange(row, 11).setValue(new Date()); // 最終リクエスト送信日時
  executeAction(cron);
}

// 中身が*もしくは指定した数字を含んでいるか
function isMatch(timingList, time) {
  return (timingList[0] === ALL || timingList.indexOf(time) !== -1);
}

// APIにgetリクエストを送る
function executeAction(cron) {
  var url = cron[5];
  var response = UrlFetchApp.fetch(url);
}

// 文字列から数字のリストを返す
function getTimingList(timingStr, type) {
  var timingList = [];
  if (timingStr === ALL) { // * の時はそのまま配列にして返す
    timingList.push(timingStr);
    return timingList;
  }
  
  var limitPattern  = "(" + LIMIT_MATCHES[type] + ")";
  var numReg   = new RegExp("^" + limitPattern + "$");                      // 単一指定パターン ex) 2
  var rangeReg = new RegExp("^" + limitPattern + "-" + limitPattern + "$"); // 範囲指定パターン ex) 1-5
  var devReg   = new RegExp("^\\*\/" + limitPattern + "$");                 // 間隔指定パターン ex) */10
  var commaSeparatedList = timingStr.split(','); // 共存指定パターン ex) 1,3-5

  commaSeparatedList.forEach(function(value) {
  if (match = value.match(numReg)) { // 単一指定パターンにマッチしたら配列に追加
    timingList.push(toStr(match[1]));
  } else if ((match = value.match(rangeReg)) && toInt(match[1]) < toInt(match[2])) { // 範囲指定パターンにマッチしたら配列に追加
    for (var i = toInt(match[1]); i <= toInt(match[2]); i++) {
      timingList.push(toStr(i));
    }
  } else if ((match = value.match(devReg)) && toInt(match[1]) <= MAX_NUMBERS[type]) { // 間隔指定パターンにマッチしたら配列に追加
    var start = (type == 'day' || type == 'month') ? 1 : 0; // 月と日だけ0が存在しないので1からカウントする
    for (var i = start; i <= MAX_NUMBERS[type] / match[1]; i++) {
      timingList.push(toStr(i * match[1]));
    }
  }
  });
  return timingList;
}

// ifやforの判定を正しく行う為に文字列を10進数int型に変換
function toInt(num) {
  return parseInt(num, 10);
}
// 数値を10進数int型にして文字列に変換。実行タイミング一致判定（indexOf）で必要
function toStr(num) {
  return toInt(num).toFixed();
}
```

GASでの新しい機能は特に無いので説明はあまりすることがないですが…
上記ソースでは主に、Cronで*(アスタリスク)と数値以外の下記3パターンの指定方法をカバーできるように、数値リストの生成をしています。

リスト ex) 1,2,3,4,10,11,12→[1,2,3,4,10,11,12]に変換
範囲 ex) 1-10→[1,2,3,4,5,6,7,8,9,10]に変換
間隔 ex) */10→[0,10,20,30,40,50]に変換

厳密にはCronでは上記以外の記述も若干あるのですが、この3種類でほぼカバーできるのでそれ以外は実装していません。

# APIを立てる

それでは、実行ファイルの定期実行をストップして代わりにAPI化してみましょう。
今回は[8日目の記事「スクリプトを定期実行し、ビットコインの1分ごとの価格を自動取得する」](https://qiita.com/rf_p/items/267a8d9daa8c9f1ef027)を改造してみます。

まず定期実行をしていた場合はストップウォッチアイコンを押して、定期実行を停止させます。

次にソースコードを改変します。
と言っても**以下内容を追記するだけ**です。

```javascript:timer.gs
function myFunction() {
...
}

function getBitCoinData() {
...
}

function doGet(e) { // この3行だけ追加。GETアクセス時にmyFunctionを実行するだけ
  myFunction();
}
```

そして公開→ウェブアプリケーションとして導入を選択。
以下の通り設定して導入を押します。

![open.png](https://qiita-image-store.s3.amazonaws.com/0/199678/b3c5ee6a-cb49-a718-81a3-f6740078bac1.png)

生成されたAPIのURLが表示されるのでアクセスします。
すると、アクセスする度にビットコインの価格が自動取得されます。

# Cronファイルの定期実行を有効にする

ビットコイン価格取得が外部から叩けるAPIとして公開されたので、Cron用のスプレッドシートに戻ります。
以下のように全てA2:E2を*（アスタリスク）で指定し、F2に先程公開したAPIのURLを貼り付けます。あとの項目は任意です。

![sheet.png](https://qiita-image-store.s3.amazonaws.com/0/199678/ae5878ac-6fab-c60c-f6c3-bf141e77be8b.png)

続いてCronスプレッドシートのスクリプトエディタを開き、ストップウォッチアイコンから1分ごとのタイマーを設定します。

![trigger.png](https://qiita-image-store.s3.amazonaws.com/0/199678/c847bfcc-a249-36d2-ef41-c39fc58bc51b.png)

これで準備完了です。
あとはCronの書き方に沿ってスプレッドシートにAPIのURLを貼っていけば、指定した日時で実行されます。

Cronの書き方は下記ページを参考に。
参考：http://www.server-memo.net/tips/crontab.html

# 動作解説

Cronスプレッドシートの`myFunction()`が毎分実行されます。
すると「稼働中」スプレッドシートの各行のCron設定が評価され、実行時間とCron設定条件が一致した場合のみAPIを叩きに行きます。

これにより、各スプレッドシートは「アクセスがあると決められた処理を実行する」というAPIを公開し、Cronスプレッドシートはそれを叩きに行くという役割分担がされることになります。

利点としては3点あり、

- GAS定期実行の時タイマーや日タイマーは午前1～2時の間というざっくりした指定しかできなかったものが、誤差1分以内になる。（20 1 * * *と設定すれば1:20～1:21の1分間の間に実行される）
- より複雑な定期実行が指定可能に。（0 12 1-25 12 *と設定すれば、12/1～12/25の12:00実行という指定が可能）
- 最初に挙げたように、自動実行が一元管理される。Cronファイルを見れば、どのファイルでいつ自動実行されているか一目瞭然。設定変更も容易。

# おわりに

GASは便利な分、今回のような管理の仕組みを考えないと無法地帯化しやすいので注意です。
明日は更に改良を加え、ChatworkAPIのロジックをCronシートの1箇所にまとめます。

# 明日

[【Google Apps Script】その25　GASの自家製Cronで複数スクリプトの定期実行を一元管理する（2/2）](https://qiita.com/rf_p/items/c9f97b36a4e5d2843317)
となります。
今回のスクリプトを改良し、ロジックを更にまとめてみます。

前の記事
[【Google Apps Script】その23　パスワード管理ツールを作る](https://qiita.com/rf_p/items/4031f995c1b095553ace)
次の記事
[【Google Apps Script】その25　GASの自家製Cronで複数スクリプトの定期実行を一元管理する（2/2）](https://qiita.com/rf_p/items/c9f97b36a4e5d2843317)