GoogleAppsScript(GAS)でのcacheあれこれ

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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コールを減らす心がけができるといいと思います。

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回で固定されます。
パフォーマンスがデータ量にうける影響を最小に抑える事ができました。

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

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

/**
* シートをラップするクラス
*
* シートに対してのメソッドはこのクラスを通して行うことで、
* シート取得や、必要であればレンジの取得なども内部的にキャッシュさせ、
* 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を使うとよいでしょう。

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

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

(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';

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

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秒以下まで抑える事に成功しました!

ほれていいんやで?

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