2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【Google Apps Script】FileIteratorとFolderIteratorをiterableにして使いやすくする

Last updated at Posted at 2022-02-22

結論

最初に結論を言います。

これを

const getGoogleFiles = () => {
  const files = DriveApp.getFiles();

  const result = [];
  while (files.hasNext()) {
    const file = files.next();
    const fileName = file.getName().toLowerCase();
    if (fileName.includes('google')) result.push(file.getUrl());
  }

  Logger.log(result);
};

wrapper関数を1つ噛ませてこう書く方が好きという話です。

const getGoogleFiles = () => {
  const result = gasUtils.toIterable(
    DriveApp.getFiles(),
    {
      mapFn: (file) => file.getUrl(),
      filterFn: (file) => file.getName().toLowerCase().includes('google'),
    }
  );

  Logger.log([...result]);

ちなみにwrapper関数toIterableは以下です。

const gasUtils = {
  /**
   * DriveAppのFileIteratorとFolderIteratorをiterableにする関数
   * @template {DriveApp.FileIterator | DriveApp.FolderIterator} GASIterator
   * @template {DriveApp.File | DriveApp.Folder} GASFileOrFolder
   * @param {GASIterator} iter - Google Apps Scriptのイテレータ
   * @param {{
   *   mapFn: (val: GASFileOrFolder, index: number) => any
   *   filterFn: (val: GASFileOrFolder, index: number) => boolean
   * }}
   * @return {Generator}
   */
  toIterable: (iter, {
    mapFn = (val) => val,
    filterFn = () => true,
  }) => {
    return {
      *[Symbol.iterator]() {
        let i = 0;
        while (iter.hasNext()) {
          const [nv, ni] = [iter.next(), i++];
          if (filterFn(nv, ni)) yield mapFn(nv, ni);
        }
      }
    };
  },
};

ご興味があればどぞ。

はじめに

Google Apps Scriptは便利です。
ちょっとしたツールや自動化にはもってこいです。

Google系のサービスとは簡単に連携できます。
そんなわけで、Google Drive上のファイルやフォルダを操作したくなるわけです。
マイドライブの中でファイル名にgoogleの文字が入るもののURLの一覧が取得したい…!
それもSpreadsheetに一発で貼り付けられるように配列で取得したい…!!

調べてみればDriveAppなるものが簡単に見つかります。
早速試してみましょう。

/** !!これは動きません!! */
const getGoogleFiles = () => {
  // ファイルを全て取得
  const files = DriveApp.getFiles();
  
  // 名前に'google'を含むファイルのURLを取得
  const result = files.flatMap(file => {
    const fileName = file.getName().toLowerCase();
    return fileName.includes('google')? [file.getUrl()] : [];
  });
  
  // 出力を確認
  Logger.log(result);
};

これでエディタからgetGoogleFilesを実行すればめでたく目標のものがとれるはず。
とっても簡単だぁ。
と思いきや、帰ってくるのはエラー。

TypeError: files.flatMap is not a function

な、なんだってー?!
それもそのはず、DriveApp.getFilesの返り値の型はDriveApp.FileIterator
配列じゃないのです。イテレータなのです。

というわけで、目的の結果を得るには次のように書く必要があります。

const getGoogleFiles = () => {
  // ファイルを全て取得
  const files = DriveApp.getFiles();
  
  // 名前に'google'を含むファイルのURLを取得
  const result = [];
  while (files.hasNext()) {
    const file = files.next();
    const fileName = file.getName().toLowerCase();
    if (fileName.includes('google')) result.push(file.getUrl());
  }
  
  // 出力を確認
  Logger.log(result);
};

うーん。なんともコレジャナイ感。

何が不満なのか

個人的な嗜好…もとい志向として以下が気に入りません

  1. 最初に空の配列を定義してwhileループでpushするのがつらい。破壊的メソッドは滅ぶべし
  2. 終端判定のメソッドがhasNextなところ。MDNのイテレータの説明ではdoneなのに
  3. iterableじゃない。反復可能ならスプレッド構文なんかで簡単に配列化できるのに

といわけで、iterable(反復可能)にするwrapper関数を挟めば使い勝手が良くなりそう。
どちらにせよwrapper関数はあった方が将来的な保守性が上がるので、やらない手はない。

余談 反復可能にする。どうやって?
(ご存じの方は飛ばしてください)

JavaScriptのオブジェクトには2種類ある。
反復可能なオブジェクトと、反復可能ではないオブジェクトだ。

その違いは、反復可能なオブジェクトとは**@@iteratorメソッド**が実装されているか否かです。

// 反復可能じゃない
const notIterable = {
  hoge: 'hoge',
  fuga: 'fuga',
  piyo: 'piyo',
};

// 反復可能
const iterable = {
  [Symbol.iterator]: function* () {
    yield 'hoge';
    yield 'huga';
    yield 'piyo';
  }
};

// 省略記法 iterableと意味は同じ
const shortIterable = {
  *[Symbol.iterator]() {
    yield 'hoge';
    yield 'huga';
    yield 'piyo';
  }
};

この*がついている厳めしい関数はジェネレータと呼ばれるものです。
肩肘張るようなものでもなく、yieldで値を返して実行を一旦停止する関数といった感じ。

重要なのはこの**Symbol.iteratorという名前のメソッドがジェネレータで実装されていること**。
これが反復可能か、そうでないかの分かれ道です。
とにかく、この名前のジェネレータのメソッドを持っていればそのオブジェクトは反復可能です。

では反復可能だと何が嬉しいの?といったところですが、for-ofやスプレッド構文で各値を取り出すことができます。

// for-ofで各値を取り出せる
for (const item of iterable) {
  Logger.log(item);
  // expected output:
  // hoge
  // huga
  // piyo
}
  
// スプレッド構文で各値を取り出せる
Logger.log([...iterable]);
// expected output:
// [hoge, huga, piyo]

// for-ofで取り出せない
for (const item of notIterable) {
  Logger.log(item);
  // TypeError: notIterable is not iterable
  // 地味にこのエラーメッセージ面白い
}

察しの良い方はお気づきでしょうが、Arrayオブジェクトなんかには元々この@@iteratorメソッドが実装されています。
とっても便利だぁ!

ともあれこの@@iteratorメソッドがApps Scriptのイテレータ、厳密にいうとDriveApp.FileIteratorDriveApp.FolderIteratorといったオブジェクトに実装されていないことが問題なわけです。

ならどうすればいいか?
Apps Scriptのイテレータの各値をyieldするジェネレータを@@iteratorメソッドとして持ったオブジェクトを返す関数があれば必要十分ですね。
そう考えると案外簡単かもしれない。

Simple is best

複雑なのは良くないので、できるだけシンプルに反復可能性だけ付与します。

// Apps Scriptのイテレータ類をiterableにする
const toIterable = (iter) => {
  return {
    *[Symbol.iterator]() {
      while(iter.hasNext()) yield iter.next();
    }
  };
};

const getGoogleFiles = () => {
  const files = toIterable(DriveApp.getFiles());
  
  const result = [...files].flatMap(file => {
    const fileName = file.getName().toLowerCase();
    return fileName.includes('google')? [file.getUrl()] : [];
  });
  
  Logger.log(result);
};

うむ。悪くない。
大分最初の姿に近づくことができました。
wrapper関数のコード量もそれほど多くなく、必要十分にシンプルだと思います。

ただ、ちょっと寄り道が多すぎるかもしれない。
特に、一旦配列にしてからflatMapするところ。

ものごとはできるかぎりシンプルにすべきだ。しかし、シンプルすぎてもいけない。

先程の実装はスタート地点としてはこれ以上なく優秀でした。
しかし、もう少しwrapper関数に機能を追加すれば格段に便利かもしれません。

例えば、次のようなmapFnfilterFnを追加してみるのはどうでしょう。

// Apps Scriptのイテレータ類をiterableにする
const toIterable = (iter, { 
  mapFn = (val, index) => val, 
  filterFn = (val, index) => true,
}) => {
  return {
    *[Symbol.iterator]() {
      let i = 0;
      while (iter.hasNext()) {
        const [nv, ni] = [iter.next(), i++];
        if (filterFn(nv, ni)) yield mapFn(nv, ni);
      }
    }
  };
};

const getGoogleFiles = () => {
  const result = toIterable(
    DriveApp.getFiles(),
    {
      mapFn: (file) => file.getUrl(),
      filterFn: (file) => file.getName().toLowerCase().includes('google'),
    }
  );

  // これまでと同じ結果
  Logger.log([...result]);
};

filterFnで条件を満たしたものだけyieldされ、mapFnの処理を通したものを取得します。
それほどwrapper関数のコード量も増えず、かつ本関数内の変数がresult1つになります。
ループの回数も減るので、気持ちが穏やかになります。
ちなみにこのコード内では使用していませんが、mapFnfilterFnの二番目の引数でインデックスも使えるようにしています。こんなんなんぼあってもいいですからね。

個人的にはこれが一番使いやすいと思います。
getGoogleFilesの中の処理の見通しもこれが一番良く感じます。

JSDocで補完が効くようしたり

Apps ScriptではJSDocを利用してエディタの補完を効かせるようにすることができます。
よって以下のようにすればmapFnfilterFnの中でもきちんと補完が効くようになります。

const gasUtils = {
  /**
   * DriveAppのFileIteratorとFolderIteratorをiterableにする関数
   * @template {DriveApp.FileIterator | DriveApp.FolderIterator} GASIterator
   * @template {DriveApp.File | DriveApp.Folder} GASFileOrFolder
   * @param {GASIterator} iter - Google Apps Scriptのイテレータ
   * @param {{
   *   mapFn: (val: GASFileOrFolder, index: number) => any
   *   filterFn: (val: GASFileOrFolder, index: number) => boolean
   * }}
   * @return {Generator}
   */
  toIterable: (iter, {
    mapFn = (val) => val,
    filterFn = () => true,
  }) => {
    return {
      *[Symbol.iterator]() {
        let i = 0;
        while (iter.hasNext()) {
          const [nv, ni] = [iter.next(), i++];
          if (filterFn(nv, ni)) yield mapFn(nv, ni);
        }
      }
    };
  },
};

もちろん関数の動作は変わりません。
が、しれっとgasUtilsというオブジェクトで包みました。
なので、getGoogleFilestoIterableの参照をgasUtils.toIterableに変えてください。
ちなみにこれをすると

  • 名前空間が切れる
  • エディタの実行関数候補からtoIterableが消える

という効用があります。
エディタから実行する予定がない関数は積極的にオブジェクトで包みましょう。
みなが幸せになります。

おわりに

大体伝えたかったことは以上です。
強いて言うなら速度比較とかもしたかったですが、とりあえずここまでで一旦投稿しておきます。
よろしくお願いします。

2
0
0

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
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?