🗂

GASでGoogle Docs→Markdown APIを作ったら激遅だったので、Code.jsでやった高速化の工夫まとめ

に公開

Google Apps Script(GAS)で、Google Docs を Markdown に変換して返す Web API を作りました。

  • フロント:別リポジトリ(ブログ側)
  • バックエンド:GAS WebApp(このリポジトリ)

リポジトリ: https://github.com/freddiefujiwara/blog-gas

ただし、 激遅です。
GAS + DocumentApp + DriveApp は、呼び出し回数が増えるほど容赦なく遅くなります。

この記事では、実装の中で「少しでもマシにするために」やった工夫を、Code.js から抜き出して整理します。


何をしているAPIか

エンドポイントは doGet(e) 1本です。

  • ?id=無い:指定フォルダ配下の Docs の ID 一覧を返す
  • ?id=ある:その Doc がフォルダ内か確認して、本文を Markdown 化して返す
export function doGet(e) {
  const docId = e && e.parameter ? e.parameter.id : null;

  if (!docId) {
    const ids = listDocIdsSortedByName_(FOLDER_ID);
    return json_(ids);
  }

  const info = getDocInfoInFolder_(FOLDER_ID, docId);
  if (!info.exists) return jsonError_('Document not found in the specified folder');

  const doc = DocumentApp.openById(docId);
  const md = docBodyToMarkdown_(doc);

  return json_({ id: docId, title: info.name, markdown: md });
}

高速化の工夫1:フォルダ全走査(O(N))を避けて「親フォルダ確認」にした

「指定の docId がフォルダ内にあるか」を確認するのに、最初にやりがちなのがこれです:

  • フォルダ内のファイルを全部列挙
  • その中に docId があるか探す(O(N))

Docs が増えるほど遅くなるし、呼び出し回数も増えます。

そこで getDocInfoInFolder_ では逆向きに、

  • DriveApp.getFileById(fileId) でファイルを直接取得(1回)
  • そのファイルの 親フォルダ一覧 を見て一致するか確認(O(Parents))

にしています。

export function getDocInfoInFolder_(folderId, fileId) {
  try {
    const file = DriveApp.getFileById(fileId);
    if (file.getMimeType() !== MimeType.GOOGLE_DOCS) return { exists: false };

    const parents = file.getParents();
    while (parents.hasNext()) {
      if (parents.next().getId() === folderId) {
        return { exists: true, name: file.getName() };
      }
    }
  } catch (e) {}
  return { exists: false };
}

なぜ効く?

  • フォルダ配下のDocs数が増えても、チェックのコストがほぼ一定
  • APIの入り口で「重い処理をしない」方向に寄せられる

高速化の工夫2:Element変換で「ディスパッチテーブル」を使って分岐を簡素化

Docs の本文は、Paragraph / ListItem / Table ... と要素種別が多いです。
普通に if/elseswitch を増やすと、可読性も落ちがち。

ここでは element.getType() をキーにした ディスパッチテーブルで分岐しています。

export function elementToMarkdown_(el) {
  const t = el.getType();

  const converters = {
    [DocumentApp.ElementType.PARAGRAPH]: (e) => paragraphToMarkdown_(e.asParagraph()),
    [DocumentApp.ElementType.LIST_ITEM]: (e) => listItemToMarkdown_(e.asListItem()),
    [DocumentApp.ElementType.TABLE]: (e) => tableToMarkdown_(e.asTable()),
    [DocumentApp.ElementType.HORIZONTAL_RULE]: () => '\n---\n',
  };

  const converter = converters[t];
  if (converter) return converter(el);

  if (el.getText) {
    const text = (el.getText() || '').trim();
    return text ? text + '\n' : '';
  }
  return '';
}

これ、速度に効く?

正直ここは 微差です。
でもメリットは大きくて、

  • 分岐追加が安全(要素追加しやすい)
  • 「それ以外はテキスト化」のフォールバックが明示的

結果として 変換対象を増やした時に破綻しにくい=運用コストが下がります。


高速化の工夫3:文字列結合を push/join に寄せる(長文対策)

GASで長文を扱うとき、地味に効くのが「文字列結合の回数」です。

docBodyToMarkdown_out.push() でためて join() しています。

export function docBodyToMarkdown_(doc) {
  const body = doc.getBody();
  const out = [];
  const numChildren = body.getNumChildren();

  for (let i = 0; i < numChildren; i++) {
    out.push(elementToMarkdown_(body.getChild(i)));
  }

  return out.join('\n').replace(/\n{3,}/g, '\n\n').trim() + '\n';
}

同じ思想が、インラインスタイル変換にも入っています(後述)。


高速化の工夫4:インラインスタイル変換は「属性境界(indices)」で最小回数にする

Markdown化で一番クセがあるのが、太字/斜体/[リンク]の処理です。

ありがちな実装は、1文字ずつ属性を見に行ったりして爆死します。

ここでは Text.getTextAttributeIndices() を使って、

  • スタイルが変わる境界だけを抽出
  • 境界ごとにまとめて処理

という形になっています。

let indices = textEl.getTextAttributeIndices() || [];
if (indices.length === 0 || indices[0] !== 0) indices.unshift(0);
if (indices[indices.length - 1] !== fullText.length) indices.push(fullText.length);

for (let k = 0; k < indices.length - 1; k++) {
  const start = indices[k];
  const end = indices[k + 1];
  ...
  const attrs = textEl.getAttributes(start);
  ...
}

ここが“堅牢化ポイント”

  • indices が空になるケースを想定している(|| []
  • 0 と末尾境界を保証して、substring の抜けを防いでいる
  • start >= end の防御が入っている

速度的にも効く

  • 1文字ずつ属性取得しない
  • 属性取得は境界数だけ(スタイル変化が少ない文章ほど速い)

高速化の工夫5:Markdownのエスケープを「最低限」に寄せている

Markdownエスケープをガチると、変換ロジックが巨大化して遅くなります。

ここでは \\` だけに絞っていて、「強すぎない」設計になっています。

export function escapeMdInline_(s) {
  return s
    .replace(/\\/g, '\\\\')
    .replace(/`/g, '\\`');
}

これも、速度というより 運用上の割り切りが効いてます。
(ブログ用途なら “完璧なMarkdown” より “壊れにくい” が大事)


でも、まだ激遅です(どこがボトルネックになりやすいか)

この実装で「工夫」は入っていますが、遅い原因の本丸はここです。

1) DocumentApp.openById が重い

APIアクセスごとに1回開く。
ここは避けにくいですが、アクセスが増えると支配的になります。

2) 本文走査で getChild / getText を大量に呼ぶ

構造が複雑なDocs(表やリスト、長文)ほど遅くなります。

3) 「同じDocを何度も変換する」問題

変換結果をキャッシュしてないと、アクセスされるほど無駄が増えます。


次にやるなら:Advanced Drive API

調べてみるとAdvanced Drive APIというのがあるみたいで、それを来週は使って改善したいと思います。


おわりに

GASでバックエンドAPIつくるのは「気軽」ですが、いつもパフォーマンスに悩まされます。
なので Code.js では、

  • O(N) を避ける(親フォルダ確認)
  • 変換コストを削る(indicesで境界処理)
  • 文字列結合を抑える(push/join)
  • 運用で壊れにくい割り切り(エスケープ最小)

みたいな 方針でまとめました。

同じように「GASでWeb API作ったら遅すぎる」人の参考になれば。

もし、あなたの言う「激遅」が (A) 一覧取得が遅い のか (B) 変換が遅い のか (C) 両方 なのかが分かれば、この記事の後半をその症状に寄せてもっと刺さる構成に直せます。

Discussion