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

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

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

/** </div></div>
* シートをラップするクラス
*
* シートに対してのメソッドはこのクラスを通して行うことで、
* シート取得や、必要であればレンジの取得なども内部的にキャッシュさせ、
* APIコールを減らしている
*/</span>
(LibrarySS = function(ss)
{
this.ss = ss;
}).prototype = new Object();
/** </div></div>
* シートの取得(シングルトン的挙動)
*/</span>
LibrarySS.get = function()
{
if (! LibrarySS._ss) {
LibrarySS._ss = SpreadsheetApp.getActive() || SpreadsheetApp.openById("シートID");
}
return new LibrarySS(LibrarySS._ss);
}
</pre></div></figure> これらの、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;
/** </div></div>
* キャッシュのput
*
* 参照の高速化を図るため、キーはindexとする
*/</span>
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]);
}
/** </div></div>
* キャッシュのget
*/</span>
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();
/** </div></div>
* create instance
*/</span>
SheetCache.create = function(sheetName)
{
if (! SheetCache._instances) SheetCache._instances = {};
if (SheetCache._instances[sheetName]) return SheetCache._instances[sheetName];
return SheetCache._instances[sheetName] = new SheetCache(sheetName);
}
/** </div></div>
* cache data types
*/</span>
SheetCache.TYPE_JSON = 'json';
SheetCache.TYPE_STRING = 'string';
SheetCache.TYPE_INT = 'int';
SheetCache.TYPE_BOOL = 'bool';
</pre></div></figure> #### 実際使う際はこんなかんじ
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秒以下まで抑える事に成功しました! **ほれていいんやで?** 以上!智史之介からでした!