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/else や switch を増やすと、可読性も落ちがち。
ここでは 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