GoogleAppsScript(GAS)でのcacheあれこれ

木内智史之介(シャッチョー)
ミンカさんけっこんしてくださいおねがいします(ズザー
SEGAさん、DIVAの筐体ください(ズザー

GASでの悩ましい問題、それは、遅いこと…!

GoogleAppsScript(以下GAS)は非常に便利なサービスであることは間違いありません。

サーバーの用意も不要。
スプレッドシートとの連携も容易。
公開もちょっとした手続きで可能。

と、三拍子そろったイケメンです。
ほれてまうやろー!!

ただし、遅い。
とにかく、遅い。

注意しながらコーディングしないと、すぐにレスポンスタイムがとんでもない事になってしまいます。

たとえば、自分が実装を進めているDQMJ3の配合早見表のアプリケーションでは、 対象のモンスターの配合ツリーを生成するまでにかかる時間はなんと「60秒越え」という 身の毛もよだつようなレスポンスタイムで愕然としました…。

これじゃ、公開しても、使い物にならんがぜよ…

遅いなら、キャッシュすればいいじゃない

そう。そんな事は分かっているのです。
なので、今日は、僕の試したキャッシュ方法をいくつか紹介したいと思います。

APIコール回数を減らすためのキャッシュ

まず、GASにおいて、最もボトルネックになる箇所はどこでしょうか? それは、「APIコール」です。

  • スプレッドシートの取得
  • スプレッドシートからレンジの取得
  • レンジからの値の取得
  • などなど

これらは、すべて、GAS自体が内部的にAPIをたたき、通信を行い、その結果を返却しているに過ぎません。

ですので、下記のようなコードは効率がわるくなることは分かりますでしょうか?

1
2
3
4
5
6
7
var ss = SpreadsheetApp.getActive();            // API CALL
var range = ss.getDataRange();                  // API CALL
for (var i = 0; i < range.getHeight(); i++)     // API CALL (?)
{
    var value = range.getCell(i, 1);            // API CALL
    Logger.log(value);
}

おそらく内部的にAPIコールが発生していると思われる箇所に、コメントの記載を行ってみましたが、 このコードの何がまずいって、ループ内で、APIコールが毎回発生している事です。

もし、こういうコードを書く際には、こんな感じで、可能な限りAPIコールを減らす心がけができるといいと思います。

1
2
3
4
5
6
7
8
var ss = SpreadsheetApp.getActive();            // API CALL
var range = ss.getDataRange();                  // API CALL
var values = range.getValues();                 // API CALL
for (var i = 0; i < values.length; i++)
{
    var value = values[i][0];
    Logger.log(value);
}

これであれば、どんなにシートのデータ量が増えても、APIコールは3回で固定されます。
パフォーマンスがデータ量にうける影響を最小に抑える事ができました。

この考え方は、シートの取得などに関しても同じ事がいえます。
たとえば、シートの取得処理を、異なる関数内で個別に行うのであれば、それは、やはり、何かしら変数の共有方法を考える必要性があるでしょう。
(関数の引数にシートを求める、シートの取得処理をラップしてシングルトン実装にする、グロバール変数を利用する、などなど)

ちなみに、自分はこんな感じでシートに関する処理をラップしています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
 * シートをラップするクラス
 *
 * シートに対してのメソッドはこのクラスを通して行うことで、
 * シート取得や、必要であればレンジの取得なども内部的にキャッシュさせ、
 * APIコールを減らしている
 */
(LibrarySS = function(ss)
{
  this.ss = ss;
}).prototype = new Object();

/**
 * シートの取得(シングルトン的挙動)
 */
LibrarySS.get = function()
{
  if (! LibrarySS._ss) {
    LibrarySS._ss = SpreadsheetApp.getActive() || SpreadsheetApp.openById("シートID");
  }
  return new LibrarySS(LibrarySS._ss);
}

これらの、APIコールに関する細やかな気遣いを徹底してみると、60秒かかっていたレスポンスタイムが、なんと…「20秒ほど」になりました!!

ひゃっほい!まだまだ全然遅いぜ!
ちくしょー!

キャッシュサービスを利用する

コーディング上の気遣いで行える、軽量化は、おそらくほとんどやり尽くしたはずです。
無駄なループを取り除き、APIコールを可能な限り節約し、残った手は、「結果のキャッシュ」以外ありません。

幸い、今回、自分が作成しているアプリケーションは「モンスターの配合ツリーを表示する」ためのものなので、特定のモンスターに対する結果は、誰が叩こうが同じです。

そこで目を付けたのが、「Cache Sevice」です。

Cache Seviceとは?

いわゆる、キーバリュータイプの、簡易ストレージキャッシュです。 現在3種類のキャッシュがサポートされています。

  • Document Cache: ドキュメントに対するキャッシュ
  • Script Cache: スクリプトに対するキャッシュ
  • User Cache: ユーザーに対するキャッシュ

それぞれのキャッシュで違うことは、まあ、そのキャッシュの参照スコープの違いです。
たとえば、User Cacheであれば、アクセスしてきたユーザー毎のキャッシュになるので、ユーザー毎に違う結果になるようなデータは、UserCacheを使うとよいでしょう。

使い方はこんな感じです。

1
2
3
var cache = CacheService.getDocumentCache();
var cached = cache.get('foo');
if (cached !== null) return cached;

これで勝つる…!

と、思いきや…。そんなに甘い話ではありませんでした。
ぐすん。

Cacheの制限

現在、キャッシュサービスで利用するキャッシュにはいくつかの制限があります。

  • キーの文字列長は250文字まで
  • 各キャッシュの容量は100KBまで
  • キャッシュの有効期限は最大6時間まで

今回、致命的だったのは、下二つの制限です。

結果JSONは余裕で100KBを超えているし、キャッシュの有効期限が6時間というのも短すぎます。
なんせ、データにミスがなければ、永遠にキャッシュされてもいいような内容なのです。 それを6時間だなんて…。6時間毎に60秒越えのレスポンスが発生してしまうなんて…。

そんなのってないよ…!!

独自キャッシュシステムを作成

もう万策尽きただろう、そろそろ諦めたらどうだって…?

ちっちっち。

ユー、分かってないよ。

智史之介という男を全然分かってないよ。

智史之介、諦めない、絶対に。

Cache Serviceを利用できないなら、自分でキャッシュ機構を作ってしまえばいいじゃない!

というわけで、作ってみたお

ババーン!!

Cache.gs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
(SheetCache = function(sheetName)
{
  this.sheetName = sheetName;

  /**
   * キャッシュのput
   *
   * 参照の高速化を図るため、キーはindexとする
   */
  this.put = function(index, value)
  {
    var library = LibrarySS.get();      // ※独自クラス
    var sheet = library.getCacheSheet(this.sheetName);
    var range = sheet.getCells(index, 1, 1, 10);

    if (typeof value == "object") {
      value = JSON.stringify(value)
    }

    // 10000chars / 1cell
    // ※1セル50000文字までという制限があるため
    var values = [];
    while (value) {
      values.push(value.substr(0, 10000));
      value = value.substr(10000);
    }
    while (values.length < 10) {
      values.push("");
    }

    range.setValues([values]);
  }

  /**
   * キャッシュのget
   */
  this.get = function(index, type)
  {
    if (! type) type = SheetCache.TYPE_STRING;

    var library = LibrarySS.get();
    var sheet = library.getCacheSheet(this.sheetName);
    var range = sheet.getCells(index, 1, 1, 10);

    var values = range.getValues()[0];
    var value = values.join("");
    if (! value) return null;

    switch (type) {
      case SheetCache.TYPE_JSON:
        value = JSON.parse(value);
        break;
    }

    return value;
  }
}).prototype = p = new Object();


/**
 * create instance
 */
SheetCache.create = function(sheetName)
{
  if (! SheetCache._instances) SheetCache._instances = {};
  if (SheetCache._instances[sheetName]) return SheetCache._instances[sheetName];

  return SheetCache._instances[sheetName] = new SheetCache(sheetName);
}


/**
 * cache data types
 */
SheetCache.TYPE_JSON = 'json';
SheetCache.TYPE_STRING = 'string';
SheetCache.TYPE_INT = 'int';
SheetCache.TYPE_BOOL = 'bool';

実際使う際はこんなかんじ

1
2
3
4
5
6
7
8
9
10
var cache = SheetCache.create('cache.recipe');
var cached = cache.get(101);
if (cached !== null) return cached;

var library = LibrarySS.get();  // ※独自クラス
var sheet = library.getSomeSheet();
var range = sheet.getDataRange();
var values = range.getValues();

cache.put(101, values[101]);

仕組み的には、スプレッドーシート上にキャッシュ用のシートを作成して、そこにJSONデータをぶち込んでおくような感じです。

キーが数字indexになっているのは、キーの一致判定をいれると、どうしてもデータ量に応じてレスポンス性能が悪くなってしまうため、数字indexで持たせました。
(幸い、DQMJ3のモンスターには「位階」というIDがあり、それに対するAPI結果が一意なので、違和感はありません)

この対応をいれることで、達成できたレスポンスタイムは…なんと「0.4秒」!!
60秒以上かかっていた処理を、1秒以下まで抑える事に成功しました!

ほれていいんやで?

以上!智史之介からでした!