Google Apps Scriptを使ってtarファイルを展開したい

この記事ではGoogle Apps Scriptを使ってtarファイルを展開するためのスクリプトを紹介させていただきます。

# 背景
スクリプトを作ってみようと思ったきっかけは以下の通りです。

- tarファイルの展開をGoogle Apps Scriptでできればと以前から探していたのですが、結局見つからないままでした。
- 残念ながらGoogle Apps Scriptではtarファイルを展開するためのメソッドは用意されていません。
	- 幸いなことに、[tarのwiki](https://en.wikipedia.org/wiki/Tar_(computing))にtarデータの構造が書かれていました。
- 丁度、StackoverflowでGoogle Apps Scriptを使ってtarファイルを展開したいとの内容の[質問](https://stackoverflow.com/q/54850028)を見つけました。
	- このことから、Google Apps Scriptを使ってtarファイルを展開したいと考える他のユーザの存在を知りました。
- tarファイルを展開するためのスクリプトを考えることは自分自身の勉強にもつながると考えました。

これらが同期になって、ここで紹介させていただくようなスクリプトを作成しました。リポジトリは[こちら](https://github.com/tanaikech/tarUnarchiver-for-Google-Apps-Script)です。

# tarデータの構造
[tarのwiki](https://en.wikipedia.org/wiki/Tar_(computing))を見ますと、基本はファイルの先頭から512バイトを一つのチャンクとして順に取得することで各ファイルの情報が書かれたヘッダ、ファイル本体を取得することができます。サンプルのtarファイルを作って確認したところ、ファイルのデータ自体も512バイトの倍数で内包されており、空いた部分は0で埋められていました。ヘッダの詳細については、wikiをご覧ください。シンプルに作成するため、展開に必要な情報として、ファイルパス（ファイル名も入っている）、ファイルサイズ、mimeTypeの3つに絞り、これらを使って展開します。ヘッダの先頭が``0``になった際、これをデータの終了の合図としました。

# ハマったところ
初めはサンプルとして小さな複数のファイルをtarファイルにして動作確認をしていたのですが、実際にファイルサイズが大きくなるとGoogle Apps Script独自の問題にぶつかってしまいました。既知の制限としてはblobの最大サイズが50 MB (52,428,800 bytes)であること。blobのことに気を取られていると、今度はメモリ不足のエラーにぶつかりました。このメモリエラーの解消には下記のような工夫が必要であることが分かりました。ただし、これは今の自分の知識を使っていくつか実験を行った結果でのことで、また別のより良い方法があるのではないかと考えています。そのため、下記は複数ある回避策のいくつかと考えてください。メモリ不足を解消するために次のことをスクリプトに反映させました。

- 大きな配列（今の場合、tarデータのbyte配列）を使用する際は、読み込みと同時にsplice関数などを使って配列を小さくしながらループする。
- 余計な変数やオブジェクトは作成しない。
- 可能な限り一つの関数で終了させる。他の関数を呼び出すとメモリ使用量が増えるよぅに思われました。

# スクリプト
上記の問題から完成したスクリプトは下記のようになりました。下記スクリプトをスクリプトエディタへ貼り付けて使用してください。サンプルとして実行する関数は、``run()``です。``tarUnarchiver(blob)``へはtarファイルのblobを引数として与えてください。このとき、blobにtarのmimeTypeが含まれていないとエラーを返すようにしていますのでご注意ください。

```javascript
// This function is the script for extracting files from a tar data.
function tarUnarchiver(blob) {
  var mimeType = blob.getContentType();
  if (!mimeType || !~mimeType.indexOf("application/x-tar")) {
    throw new Error("Inputted blob is not mimeType of tar. mimeType of inputted blob is " + mimeType);
  }
  var baseChunkSize = 512;
  var byte = blob.getBytes();
  var res = [];
  do {
    var headers = [];
    do {
      var chunk = byte.splice(0, baseChunkSize);
      var headerStruct = {
        filePath: function(b) {
          var r = [];
          for (var i = b.length - 1; i >= 0; i--) {
            if (b[i] != 0) {
              r = b.slice(0, i + 1);
              break;
            }
          }
          return r;
        }(chunk.slice(0, 100)),
        fileSize: chunk.slice(124, 124 + 11),
        fileType: Utilities.newBlob(chunk.slice(156, 156 + 1)).getDataAsString(),
      };
      Object.keys(headerStruct).forEach(function(e) {
        var t = Utilities.newBlob(headerStruct[e]).getDataAsString();
        if (e == "fileSize") t = parseInt(t, 8);
        headerStruct[e] = t;
      });
      headers.push(headerStruct);
    } while (headerStruct.fileType == "5");
    var lastHeader = headers[headers.length - 1];
    var filePath = lastHeader.filePath.split("/");
    var blob = Utilities.newBlob(byte.splice(0, lastHeader.fileSize)).setName(filePath[filePath.length - 1]).setContentTypeFromExtension();
    byte.splice(0, Math.ceil(lastHeader.fileSize / baseChunkSize) * baseChunkSize - lastHeader.fileSize);
    res.push({fileInf: lastHeader, file: blob});
  } while (byte[0] != 0);
  return res;
}

// Following function is a sample script for using tarUnarchiver().
// Please modify this to your situation.
function run() {
  // When you want to extract the files from .tar.gz file, please use the following script.
  var id = "### file ID of .tar.gz file ###";
  var gz = DriveApp.getFileById(id).getBlob().setContentTypeFromExtension();
  var blob = Utilities.ungzip(gz).setContentTypeFromExtension();

  // When you want to extract the files from .tar file, please use the following script.
  var id = "### file ID of .tar file ###";
  var blob = DriveApp.getFileById(id).getBlob().setContentType("application/x-tar");

  // Extract files from a tar data.
  var res = tarUnarchiver(blob);

  // If you want to create the extracted files to Google Drive, please use the following script.
  res.forEach(function(e) {
    DriveApp.createFile(e.file);
  });

  // You can see the file information by below script.
  Logger.log(res);
}
```

- 結果は``[{fileInf: "ファイル情報", file: blob}]``のような各要素がオブジェクトになった配列として返されます。``file``は展開したファイルのblobです。

# 制限
- tarファイルのファイルサイズは、50 MB（52,428,800バイト）以下のものを指定してください。
- 展開したファイルの一つ当たりのサイズが50 MBを超えると、エラーが発生します。展開したファイルの1ファイルサイズが50MBに近い場合もエラーが発生することがあります。
- 動作テストでは、49 MBのtarファイルは展開することができましたが、50 MBのtarファイルはメモリ不足のエラーが発生しました。

Blobの最大50 MB（52,428,800バイト）の制限は、この記事を書いた時点でのGoogle Apps Scriptの仕様によるものです。