search
LoginSignup
11

More than 5 years have passed since last update.

posted at

updated at

nougat : Node.js と GoogleAppsScript のどちらでも使えるライブラリを作るための、むりくり感のある枠組みの試み

はじめに

以前の版では nougat version 0 系について書いていましたが、このたび、nougat version 1 系用に大幅に書き換えました。

概要

Node.js (以降 Node) と Google Apps Script (以降 GAS) の両方で使えるライブラリを作るための枠組み、の試作品です。
業務用のツールをもっぱら Node と GAS で作っている私にとって、たいへん便利な枠組みです。
いわゆる、「誰得?www 俺得!」な枠組みです。

Node と GAS、どっちでも使えるライブラリを作るにはいろいろと魔術が必要になると思いますが、その魔術をできるだけライブラリ化し、その魔術ライブラリ自体も Node/GAS 両用にしよう、というものです。

なお、「nougat」は「ヌガー」と発音するのが正しいと思われます。ついつい、「ノウガット」と読んでしまうのですが。
で、ライブラリを Node/GAS 両対応させることを「nougatize」「ヌガー化」と称することを今、決めました。

inflected-nougatized を例にしたヌガー化

さきほどリリースした inflected-nougatized という npm パッケージを例に、ヌガー化の方法と、ヌガー化されたライブラリの使用方法などを解説しようと思ったり。

inflected-nougatized の元になった npm パッケージ

inflected-nougatized は、inflected という、かの ActiveSupport (ってよく知らないけれど、Ruby on Rails の便利ななにかなのかな) の Inflector を Node 用にしたもの、だそうです。
Inflector は、単数形と複数形の間の相互変換、CamelCase と snake_case の相互変換などもやってくれる便利なヤツです。

ヌガー化の実際

fork & rename & clone

https://github.com/martinandert/inflected を fork & rename で inflected-nougatized リポジトリを作ります。
これをローカルに clone してから眺めてみました。わりと扱いやすいファイル構成になっています。

inflected/
+- package.json
+- index.js
+- lib/
|  +- defaults.js
|  +- hasProp.js
|  +- icPart.j
|  +- ...
+- ...

require('inflected') されたときに呼び出される index.js があって、そこから lib/ ディレクトリ直下の各 js ファイルが require される構図。

nougat ライブラリのリンク

ヌガー化するには、ヌガー化を助けてくれる nougat ライブラリを使うと便利です。nougat ライブラリ自体もヌガー化されています。

Node については、

inflected-nougatized/package.json
...
  "dependencies": {
    "nougat": "~1.0.0"
  },
...

と、package.json で npm のリポジトリ上の nougat ライブラリを使うよう指定してから

$ npm install

で取ってくるとよさげ。

GAS については、nougat ライブラリをリンクしてください。たぶん、GAS エディタの [リソース] -> [ライブラリ ...] とかたどったところでプロジェクトキー "Mgi5BONp-6aSyYfVnIfO1mGtj26Rh5Cwx" を指定してライブラリをリンクしてください。バージョンは 2 以上を指定してください。
あ、そうそう nougat という識別子 (つまりデフォルトの識別子) でリンクしておきます。

index.js のヌガー化

さて、index.jsの中身はこうなっていました。

inflected/index.js
module.exports = require('./lib/Inflector');

これをヌガー化すると次のようになります。

inflected/index.js
(this.UiApp ? nougat : require('nougat')).$(
  this.UiApp ? {g: this} : {g: global, m: module},
  function(glace) {
    'use strict';
    return { Inflector: glace.require('./lib/Inflector') };
  });

ごちゃごちゃっとなりましたね。

完全に余談ですが、nougat verion 0 系を使った場合は次のようになっていましたから、これでもだいぶすっきりしたんですけど。

inflected/index.js

(typeof nougat !== 'undefined'  ? nougat : require('nougat')).$(
  typeof global !== 'undefined' ? global : this,
  typeof module !== 'undefined' ? module : null,
  function(global, nougat) {
    "use strict";
    return { Inflector: global.Inflector || require('./lib/Inflector') };
  });

え、それはさておき。
まず全体の構造ですが、一つの関数呼び出しになっています。具体的には nougat オブジェクトの $ という関数に二つの引数を渡しています。二つ目の引数は関数オブジェクトで、ここに実装したい機能のコードを書きます。便宜的に「本体関数」と呼ぶことにしよう。

グローバル汚染を防ぐためにコード全体を即時関数にカプセル化するイディオムがよく使われますが、基本的にはそれと同じようなカプセル化を意図し、また、Node と GAS の違いを (ほんのりと) 吸収しています。

一行目、nougat オブジェクトの取得についても一応解説しておきます。GAS かどうかの判定に this.UiApp が定義されているかを利用しています。行儀のいい方法じゃないと思いますが、これがいちばん短そうなんで。で、GAS だと nougat ライブラリが nougat という名前でリンクされていますので、三項演算子の二項目の値が評価され、nougat ライブラリが取得できます。一方、Node 環境下では三項演算子の三項目 require('nougat') が評価されて nougat オブジェクトが取得できます。

二行目、$ 関数への第一引数 (JavaScript 的には第 0 引数というのが正しいのかな) は、まあなんというんですかね、$ 関数を呼び出したときのコンテキストとでも言いますかね、これを渡します。
ちなみに、この場所での globalthis の値は次のようになっているようです。

global this
Node global {}
GAS undefined global相当

で、三行目からが本体関数ですね。本体関数は第一引数 glace (グラッセ、と読むのがいいかな)が渡されます。この glace の使い方については、実際の使用例などを挙げて説明していきます(たぶん)。

で、ライブラリから外部に export されるもの、は本体関数の戻り値になります。Node なら module.exports = ... とか exports.Inflector = ... とかするところですよね。種を明かせば $ 関数の中で本体関数の戻り値を module.exports に突っ込んでいるだけなのです (Node 環境においては)。
注意点としては、この本体関数の戻り値は連想配列でなければならない、ということです。もとの inflected/index.js では

index.js
module.exports = require('./lib/Inflector');

となっており、この require は (連想配列ではなく) 関数オブジェクトを返すので、ライブラリの利用側では

var Inflector = require('inflected');
Inflector.pluralize('Category') // => 'Categories'

のように使えますが、GAS では

var Inflector = inflected;

とすることはできないので、

var Inflector = inflected.Inflector;

という形でライブラリが提供するオブジェクトにアクセスするしかありません。ので、ヌガー化したライブラリの場合、Node 側も

var Inflector = require('inflected').Inflector;
Inflector.pluralize('Category') // => 'Categories'

のように、require の戻り値をそのまま関数オブジェクトとして使うのではなく、require の戻り値のプロパティとして関数を取り出すようにする必要があります。

ということで、さきほどコードを示した本体関数の最後は

inflected/index.js
...
    return { Inflector: glace.require('./lib/Inflector') };
...

Inflector をキーに Inflector オブジェクトを値にするような連想配列を返すようになっていたわけです。

あ、出ましたね、グラッセ (glace)。ヌガー的に自分のパッケージ/プロジェクト内のファイルを require するには、glace.require() を使うと便利です。Node においては、(現在のところ)glace.require() したファイルのあるディレクトリからの相対パスを絶対パスに直したうえで require() してます。

さて、Node における require を GAS ではどう実現しようか、というところですが、標準では動的にロードする仕組みはないのですね。なので、global にモジュール間でやりとりする専用のオブジェクト (具体的には global._nougatport) に、機能を提供する側が突っ込む、という荒っぽい形にしてしまいました。
inflected-nougatized で言えば、読み込まれる Inflector.js の側で global._nougatport.Inflector に値を突っ込んじゃえ、ということになります。
でもって、glace.require() したときには、引数のファイル名のベース (この場合、"./lib/Inflector.js" の "Inflector" の部分) をそのままキーとして global._nougatport から引っ張ってくるという乱暴実装。

ようやく index.js の解説が終わりです。

index.js から呼び出される lib/ 以下の js ファイルのヌガー化

元になった inflected パッケージでは、lib/ 以下に九つの js ファイルが含まれています。これらは index.js から直接 require されたり、index.js から require された js から require されたりしているわけです。どれもファイルの最後で module.exports に外部に提供したいオブジェクトを突っ込むという素直な作りになっていて助かりました。

で、GAS の方なんですが、index.js を含む 10 個の js ファイルはフラットにプロジェクト内に束ねられていることになります。このとき、重要なのが各ファイルの順番です。GAS では同一プロジェクト内のファイルを動的にロードする (標準の) 機能はありません。プロジェクト内のファイルはすべて自動的にロードされるのですが、問題は順番で、利用される側のファイルが利用する側のファイルより先にロードされる必要があります。ロード順についてドキュメントに明記されたものをまだ見つけていないのですが、どうやら GAS エディタでプロジェクト内のファイルの一覧が出ていますが、この一覧の上から順にロードされるようです。
端的に言えば、最終的に全ファイルのロードが必要になる index.js はファイル一覧の最後にくる必要がある、ということです。

このファイル一覧の中での順番を変更する標準的な方法は、たぶんありません。ファイルの作成順がそのまま一覧での順番になります。index.js は最後に作成する必要がある、とも言えます。
今作成中のツール (gmx) においてファイルの順番を入れ替える機能を検討していますが、まだ実装できていません。

例として lib/Inflector.js を見てみます。このファイル、index.js から呼び出されていますし、逆に他の js を呼び出してもいますので、説明にちょうどいいかと。

inflected/lib/Inflector.js
'use strict';

var Inflections     = require('./Inflections');
var Transliterator  = require('./Transliterator');
var Methods         = require('./Methods');
var defaults        = require('./defaults');
var isFunc          = require('./isFunc');

var Inflector = Methods;

Inflector.inflections = function(locale, fn) {
  if (isFunc(locale)) {
    fn = locale;
    locale = null;
  }

  locale = locale || 'en';

  if (fn) {
    fn(Inflections.getInstance(locale));
  } else {
    return Inflections.getInstance(locale);
  }
};

Inflector.transliterations = function(locale, fn) {
  if (isFunc(locale)) {
    fn = locale;
    locale = null;
  }

  locale = locale || 'en';

  if (fn) {
    fn(Transliterator.getInstance(locale));
  } else {
    return Transliterator.getInstance(locale);
  }
}

for (var locale in defaults) {
  Inflector.inflections(locale, defaults[locale]);
}

module.exports = Inflector;

これをヌガー化するとこうなります。

inflected-nougatized/lib/Inflector.js
(this.UiApp ? nougat : require('nougat')).$(
  'Inflector', this.UiApp ? {g: this} : {g: global, m: module},
  function(glace) {
    'use strict';

    var Inflections     = glace.require('./Inflections');
    var Transliterator  = glace.require('./Transliterator');
    var Methods         = glace.require('./Methods');
    var defaults        = glace.require('./defaults');
    var isFunc          = glace.require('./isFunc');

    var Inflector = Methods;

    Inflector.inflections = function(locale, fn) {
      if (isFunc(locale)) {
        fn = locale;
        locale = null;
      }

      locale = locale || 'en';

      if (fn) {
        fn(Inflections.getInstance(locale));
      } else {
        return Inflections.getInstance(locale);
      }
    };

    Inflector.transliterations = function(locale, fn) {
      if (isFunc(locale)) {
        fn = locale;
        locale = null;
      }

      locale = locale || 'en';

      if (fn) {
        fn(Transliterator.getInstance(locale));
      } else {
        return Transliterator.getInstance(locale);
      }
    }

    for (var locale in defaults) {
      Inflector.inflections(locale, defaults[locale]);
    }

    return Inflector;
  });

さきの index.js のヌガー化とほぼ同じですが、トップレベル、ライブラリとして外部に機能を提供する際の nougat.$() 関数の使い方とはちょっと違うところがあります。
お気づきになったと思いますが、index.js のときは二つの引数を取っていた nougat.$() 関数ですが、ここでは三つの引数を取っています。増えたのは第一引数の "Inflector" という「名前」です。ここに名前が指定されている場合、(GAS 環境では)本体関数の戻り値がその名前をキーとして global._nougatport に格納されます。つまり、ここで nougat.$() の第一引数として "Inflector" という名前を渡したうえで、本体関数において Inflector 関数を return しているので、(先に示したように) index.jsglace.require() でこの関数を使えるわけです。
GAS の場合はこうやって global にがんがんプロパティを追加していくわけですが、この global オブジェクト自体はプロジェクト内 (= ライブラリ内) においてグローバルであり、これをいじってもプロジェクト外の変数を破壊することはありません。

そんな感じで全部の js をヌガー化します。

GAS ライブラリにする

ヌガー化の仕上げは GAS プロジェクトの版を定め、プロジェクトキーを取得することです。その前に、Node パッケージとしてローカルで管理していたコードを GAS に反映させる必要がありますが、私は今のところ gas-manager を利用させてもらっています (さらに自分好みに手を加えたツール gmx を作成中だがまだ実用レベルになっておらず)。

で、プロジェクトキーが定まったら package.json に書いておきます。今のところ、書いても何かに影響を与えることはないのですが、後々使うかも知れません。少なくとも今はプロジェクトキーをメモしておくところ、という用途には役立ちます。

inflected-nougatized/package.json
{
  "name": "inflected-nougatized",
  ...
  "config":  {
    "nougat": {
      "gasProject": {
        "id" : "18DmSweuv1G7TRRm95fXCZBeaP34JkwExbJfExL0LbajCJAtm4yqHBJOc",
        "key": "M5NHcHWRlfLLC58GrsO05CWtj26Rh5Cwx",
        "version": 2
      }
    }
  }
}

実行例

ヌガー化されたライブラリは、Node/GAS それぞれのスクリプトにおいて、(ヌガー化されていない)普通のライブラリと同じように呼び出して使えます。当たり前っちゃ当たり前ですが。

test4inflector_on_node.js
console.log(require('inflected-nougatized').Inflector.pluralize('category')); // -> categories
test4inflector_on_gas.gs
Logger.log(inflectednougatized.Inflector.pluralize('category')); // -> categories

ちなみに、後者は inflected-nougatized ライブラリを inflectednougatized という識別子でリンクしていることを想定しています。

ヌガー化したライブラリを使ったヌガー化ライブラリの作り方

eachize を例にして

inflected-nougatized を利用したヌガー化ライブラリ eachize を例に、ヌガー化ライブラリをヌガー化ライブラリから使う方法を書いてみます。

といっても、ヌガー化されたライブラリなら Node/GAS とも glace.require() で読み込めるよ、というだけのことですが (nougat 1.0.1 から提供している機能)。

長くなりますが、eachize の全スクリプトを掲載すると ...

eachize/index.js
(this.UiApp ? nougat : require('nougat')).$(
  this.UiApp ? {g: this} : {g: global, m: module},
  function(glace) {
    'use strict';
    var asRep = (function(Inflector) {
      return function(names) {
        var target = this;
        if (typeof names == 'string') names = names.split(',');
        names.forEach(function(single) {
          var plural = Inflector.pluralize(single);
          var methodName = 'each' + Inflector.camelize(single);
          target[methodName] = function(f) {
            var hash = this[plural];
            for (var key in hash) {
              f(hash[key], key, this);
            }
            return this;
          };
        });
      };
    })(glace.require('inflected-nougatized').Inflector);
    return {
      eachize: function(target, names) {
        asRep.call(target.prototype, names);
      }
    };
  });

glace.require('inflected-nougatized') のところで、Node 環境ではそのまま inflected-nougatized パッケージを require() し、GAS 環境では inflectednougatized という識別子でリンクしたライブラリを呼び出せます (ライブラリリンクの際の識別子にはハイフンを含められない模様)。

おまけ : eachize の利用

test4eachize_on_node.js
var Album = function() { this.init(); };
require('eachize').eachize(Album, 'photo');
Album.prototype.init = function() {
  this.photos = { 'first': 'FIRST', 'second': 'SECOND' };
};
new Album().eachPhoto(function(photo, key, x_album) {
  console.log({key:key, photo:photo});
});
test4eachize_on_gas.js
var Album = function() { this.init(); };
eachize.eachize(Album, 'photo');
Album.prototype.init = function() {
  this.photos = { 'first': 'FIRST', 'second': 'SECOND' };
};
new Album().eachPhoto(function(photo, key, x_album) {
   Logger.log({key:key, photo:photo});
  });
}

glace オブジェクトが提供する他の機能

本体関数の中で使える glace オブジェクト、いくつか機能を提供しています。

glace.isGAS , glace.isNode

それぞれ GAS 環境、Node 環境かどうかを表す真偽値です。

glace.slog(arg)

ログ関数です。しかし、Node の場合は console.log(arg) を呼び、GAS の場合は Logger.log(arg) を呼ぶ、という超手抜き実装なので、今後なんとかしたいところです。slog という中途半端な名前にしているのも、ちゃんとしたのを作ったらそれを glace.log() にするぞ、という思いを込めているらしいです。

glace.require()

とりあえず今は超手抜き実装です。特に GAS の場合、gs のファイル名と、そのファイル内で nougat.$() の第一引数に渡す名前が一致していなければならない、というしばりがあります。
プロジェクトの構成にあわせたフレキシブルな設定ができるようにしたほうがいいかも、と思っています。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
11