JavaScript
GoogleAppsScript
GoogleSpreadSheet

Google Apps Script でライブラリからサイドバーを表示させる

ソースはGitHubに置いてあります。
(メインは Code.gs, lib.gs と sidebar.html で、他のごちゃごちゃしたものはESLint等の支援ツールの設定)
https://github.com/kuinaein/gas-libs

ペインポイント

  • 事業所毎(10箇所ほど)に作成・管理しているスプレッドシートがあり、これにスクリプトを仕込む依頼が来ている。
    • なお事業所は今後も増加する予定である。
  • これまで手作業で全事業所分コピペしてきていたが案外手間だしアホらしいので、メインの処理はライブラリスクリプト一本にまとめてしまい事業所ごとのファイルは極力書き換えないで済むようにしたい。

  • しかしスクリプトを分割すると、変数のスコープ周りで下記のような制限がかかる。

    • 少なくとも PropertyService に関しては、他のスクリプトプロジェクトのデータを直接読むことができない。
    • 逆にサイドバー等の UI では、フロントエンドから google.script.run 等で直接ライブラリ内のメソッドを呼ぶことができない。
    • これらの制限については事業所ごとのファイルに仕込むスクリプトであらかじめケアしておく必要がある。

シーケンス図

ライブラリの作成とリンク

ライブラリのリンク自体は簡単で、ライブラリ側にて「版を管理」で最低一つのバージョンを保存したあとで、呼び出し側のスクリプトエディタでメニュー「リソース>ライブラリ」から追加するのみです。一応、ライブラリにはエンドユーザーもアクセスできるようにしておく必要があります。

ライブラリは、管理ダイアログで「識別子」に指定した名前のグローバル変数に読み込まれるので、それも適切な変数名にしておきます。

また、「デベロッパーモード」有効だと常に最新版のライブラリが読み込まれるようになります。今回は有効にしておきます。(デベロッパーモード有効でもライブラリに最低1バージョンないとエラーになります)

なお、どこかのスクリプトから呼ばれているライブラリを削除したり、利用中のバージョンを削除したりすると、リカバリが大変だった記憶があります。ご注意を。

とりあえずライブラリ側には下記のようなスタブを作っておき、次に進みます。

lib.gs
var MENUITEM_NAME = 'サンプルライブラリ';

function createInstance(options) {
  return {
    invoke: function(){},
    showSidebar: function(){},
  };
}

呼び出し側

ペインポイントで書いたとおり、ファイル毎のスクリプトは極力更新せず済むよう薄くしたいので、ライブラリを呼ぶだけの簡単なお仕事をやらせるようにします。

ただし、一部の制約について呼び出し側でケアする必要があります。

  • 呼び出し側スクリプトに紐付いている Properties オブジェクトの引き渡し
    • PropertiesService では他のスクリプトにi紐付いてるものを直接読み書きできないため。
    • ちなみに getActiveSpreadsheet() 等の返り値はちゃんと呼び出し側のものが帰ってきます。Properties だけ謎の挙動をする。。
  • フロントエンドからのAjaxリクエストの転送
    • フロントからライブラリ内のメソッドを直接呼べないため。

極力やることを削った上で残ったコードは下記の通り。

コード.gs
// あらかじめ前述のライブラリを識別子「SampleLibrary」でリンクしておく必要がある

function onOpen() {
  var menu = SpreadsheetApp.getUi().createAddonMenu();
  menu.addItem(SampleLibrary.MENUITEM_NAME, proxySampleLibrary.name);
  menu.addToUi();
}

function proxySampleLibrary(operation, args) {
  var opts = {
    proxyFn: proxySampleLibrary,
    callerProperties: PropertiesService.getDocumentProperties(),
  };
  var inst = SampleLibrary.createInstance(opts);
  return operation ? inst.invoke(operation, args) : inst.showSidebar();
}

ライブラリ側:サイドバー表示

こちらもほぼフロントエンドにプロパティの現在値を引き渡すだけの簡単なお仕事になります。(プロトタイプ継承をまともにやっているのでごちゃごちゃしてますが……)

lib.gs
function createInstance(options) {
  return new SampleLibraryInstance(options);
}

var SampleLibraryInstance = (function() {
  var PROPERY_NAME = 'SAMPLE_PROP';

  function SampleLibraryInstance(context) {
    this.context = context;
  }

  SampleLibraryInstance.prototype.invoke = function(operation, args) {
    this[operation].apply(this, args);
  };

  SampleLibraryInstance.prototype.showSidebar = function() {
    var tmpl = HtmlService.createTemplateFromFile('sidebar.html');
    tmpl.proxyFnName = this.context.proxyFn.name;
    tmpl.callerValue = this.context.callerProperties.getProperty(PROPERY_NAME);
    tmpl.libraryValue = PropertiesService.getScriptProperties().getProperty(
      PROPERY_NAME
    );
    var sidebar = tmpl.evaluate().setTitle('実験用ライブラリ');
    SpreadsheetApp.getUi().showSidebar(sidebar);
  };

  return SampleLibraryInstance;
})();

サイドバーのHTMLの記述

ライブラリプロジェクト内にsidebar.htmlを作成します。今回はVue.jsを利用します。Bootstrap4のレイアウトを端折るとだいたい下記の通り。

sidebar.html
<div id="sample-library">
  <div>
    <label>呼び出し側のプロパティ</label>
    <input type="text" v-model="callerValue" />
  </div>
  <div>
    <label>ライブラリ側のプロパティ</label>
    <input type="text" v-model="libraryValue" />
  </div>
  <div>
    <button type="button" @click="save">保存</button>
    <button type="button" @click="saveByEval">evalで保存</button>
  </div>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.js"></script>
<script>
window.sampleLibraryApp = new Vue({
  el: '#sample-library',
  data() {
    const newData = {
      proxyFnName: '<?= proxyFnName ?>',
      callerValue: '<?= callerValue ?>',
      libraryValue: '<?= libraryValue ?>',
    };
    console.log(newData);
    return newData;
  },
  methods: {
    save() {},
    saveByEval() {},
  }
});
</script>

もっと複雑なデータを渡したいときはJSON.parse('<?!= JSON.stringify(foo) ?>)'で。

プロパティの保存処理

これまでのスクリプトには表示処理しか入っていませんので、更新処理を記述していきます。

まずフロントからの Apps Script のメソッドの呼び出しはgoogle.script.runを使います。非同期処理のインターフェースが独特なので Promise でラップしてしまいます。

sidebar.html
save() {
  this.callBackend('save', this.callerValue, this.libraryValue)
    .then(() => {
      alert('保存しました');
    });
},
callBackend(op, ...args) {
  return new Promise((resolve, reject) => {
    google.script.run.withSuccessHandler(resolve)
      .withFailureHandler((err) => {
        alert('!!!エラー!!!' + err);
        throw err;
      })[this.proxyFnName](op, args);
  });
},

Ajaxリクエストの処理もかなり重いので完了通知を入れないと危いです。

前述の通り、ライブラリ側のメソッドを直で呼び出せないのでいったんスプレッドシート側のスクリプトを経由しますが、既になんでも転送できるように設定済み。

あとはライブラリのgsに下記を書き出せば完了です。

lib.gs
SampleLibraryInstance.prototype.save = function(callerValue, libraryValue) {
  this.context.callerProperties.setProperty(PROPERY_NAME, callerValue);

  PropertiesService.getScriptProperties().setProperty(
      PROPERY_NAME,
      libraryValue
  );
};

前述の通りPropertiesService はライブラリ側から呼び出し側の値を読み書きできないので先に渡したものを使っていますが、スプレッドシート等の場合は呼び出し側に紐付いているドキュメントをそのまま取得できるので、わざわざ呼び出し側で仕込む必要はありません。

evalで呼び出し側のプロパティを読み書きする

基本的に PropertiesService からは他のスクリプトを読み書きできないのですが、実は呼び出し側に eval() を叩かせることで自由に読み書きできてしまいます。

呼び出し側のスクリプトを下記のように変更します。

Code.gs
function proxySampleLibrary(operation, args) {
  var opts = {
    proxyFn: proxySampleLibrary,
    evalInCaller: evalInMe, // これを追加
    callerProperties: PropertiesService.getDocumentProperties(),
  };
  var inst = SampleLibrary.createInstance(opts);
  return operation ? inst.invoke(operation, args) : inst.showSidebar();
}

function evalInMe(s) {
  return eval(s);
}

すると下記のように他のスクリプトからもプロパティの値を読み書きできてしまいます。

lib.gs
SampleLibraryInstance.prototype.saveByEval = function(
  callerValue,
  libraryValue
) {
  var script =
    'PropertiesService.getDocumentProperties().setProperty("' +
    PROPERY_NAME +
    '",' +
    JSON.stringify(callerValue) +
    ')';
  this.context.evalInCaller(script);

  PropertiesService.getScriptProperties().setProperty(
    PROPERY_NAME,
    libraryValue
  );
};

eval is evil. まあこの挙動がいつまで続くか分かりませんので豆ですが。。

TODO