GASでQiita投稿記事のバックアップを一括で取ろうとしてしくじった話 ~ 並列処理

# 結論

Google Driveに対してGASのタイムアウト防止を目的とした並列処理は(ごく小規模を除いて)実用的ではない。

# 動機

古い話ですが当時こういう記事
[「Qiita」運営会社、スマホゲームのエイチームが買収 - ITmedia ビジネスオンライン](http://www.itmedia.co.jp/business/articles/1712/22/news108.html)
を読みました。

>　Incrementsは2012年創業。Qiitaを運営するほか、チーム内情報共有ツール「Qiita-Team」を開発している。2016年12月期の売上高は8995万円、最終損益は8022万円の赤字。

赤字はまあどうでもいいんですが、よくはないんですが、買収をきっかけにすばらしいバックアップツールができたりして

[Qiita API v2 を使って自身の全投稿をエクスポートするJavascriptを書いた - Qiita](https://qiita.com/yumetodo/items/ea9ecb5a30dbd120f955)

過去にもいろいろバックアップを取る方法は書かれてきたのですが、
Qiita以外することがない私といえども思うところもありいつ袂を分かつかもわからない。
ありえないがQiitaが突然「明日閉鎖」とデータ移行期間を設けず宣言するかもしれない。

よっしゃ私もいっちょかみするで！
と相成りました。

失敗しとるんですが。

# 作るもの

GASデス。

バックアップ取るなら自動化したいですよね。
自動化するならスクリプトが動く環境とストレージが要りますよね。
自前で用意したらそれ自体のバックアップも必要になり不採用ですよね。
というか無料でしたいですよね。
メンテナンスフリーで。

なので一つ覚えでGASで取ってGoogle Driveに保存しようとしました。

# 誤算

最初は

[Qiita API v2 を使って自身の全投稿をエクスポートするJavascriptを書いた - Qiita](https://qiita.com/yumetodo/items/ea9ecb5a30dbd120f955)
がJavaScriptなのでちょちょいと書き換えGASで動くようにすれば終わりだと思っていましたが、
Node.jsで動かすちゃんとした作りのコードだったので、こりゃ私めのようなモノには移植は無理だ、となりました。

手習いがてらポチポチ自作することに…

# できた(できない)コード

とりあえず現状のコードを見てもらいます。
全投稿を配列で取得し、各投稿のmdと生のjsonを記事名ディレクトリに書き込む処理です。
常に上書きです。

```js
// 目標:Qiitaから自分の投稿のmarkdown(とできれば画像)をdriveに保存する
// driveへ毎回アクセス・上書きなので画像抜きでも5分上限来る…
// 投稿数が増えるごとに危機感が増す。


// 参考そうな記事
// [最新1万件の平均いいね数は4.66 - Qiita](https://qiita.com/Akira07/items/a816f295f6fa2670ce76)

function main() {
  const startTime = new Date();
  
  const items = getAllItems()
  saveItems(items)
  
  Logger.log('処理時間')
  Logger.log(((new Date) - startTime) + 'ms')
}

function getAllItems() {
  const token = 'ヒミツ'
  const headers = {'Authorization' : 'Bearer ' + token}
  const params = {'headers' : headers}
  const url = 'https://qiita.com/api/v2/authenticated_user/items?per_page=100&page='
  
  // ページはけっきょくiで回すのが楽なんだよなあ
  var i = 1
  var allItems = []
  while(true || i < 5) {
    var response = UrlFetchApp.fetch(url + i, params)
    var content = response.getContentText('UTF-8')
    var items = JSON.parse(content)
    Logger.log(items.length)
    if (!items.length) {
      Logger.log('break')
      break
    }
    allItems = allItems.concat(items)
    i++
  }
  return allItems
}

function saveItems(items) {
  var workers = createWorkers(items)
  var slicedWorkers = []
  // 上限を超えています: DriveApp が場所不定でちょくちょくでてしまう。低めにしたいが時間が… 
  const MAX_WORKERS = 5
  for (i = 0; i < workers.length; i += MAX_WORKERS) {
        slicedWorkers.push(workers.slice(i, i + MAX_WORKERS))
  }
  
  slicedWorkers.forEach(function(partWorkers){
    Logger.log(RunAll.Do(partWorkers))
  })
  
  //console.log(workers)
  //Logger.log(workers)
  //console.log(RunAll.Do(workers))
  //Logger.log(RunAll.Do(workers))
  //items.forEach(function(item) {
  //  saveItem(item)
  //})
}

function createWorkers(items) {
  var workers = []
  items.forEach(function(item) {
    workers.push({
      functionName: 'saveItem',
      arguments: [item],
    })
  })
  return workers
}

// @return Folder
function createFolder(name) {
  /// 呼び出し回数低減のために、workerのarguments段階でrootFolderを取得し、引数で渡そうとしたが失敗した。
  const rootFolderName = 'Qiita_backup'
  const rootFolder = DriveApp.getFoldersByName(rootFolderName).next()

  // 同名ディレクトリは一つという暗黙
  folders = rootFolder.getFoldersByName(name)
  if (folders.hasNext()) {
    folder = folders.next()
  } else {
    folder = rootFolder.createFolder(name)
  }
  return folder
}

// 非同期処理にするためフォルダー作成まで含める手抜きをした。
// @return File[]
function saveItem(item) {
  folder = createFolder(item.title)
  return [
    saveFile(folder, 'markdown.md', item.body),
    saveFile(folder, 'row.json',  JSON.stringify(item))
  ]
}

// @return File
function saveFile(folder, name, contents) {
  files = folder.getFilesByName(name)
  if (files.hasNext()) {
    file = files.next()
    // ここ差分チェックで書き込みしたらworkerの負担減るかなあ
    file.setContent(contents)
  } else {
    file = folder.createFile(name, contents)
  }
  return file
}
```

# 失敗

## 普通に書くとタイムアウト！

最初は書き込み処理をコメントアウトしてる

```js
function saveItems(items) {
  items.forEach(function(item) {
    saveItem(item)
  })
}
```

で処理しようとしていました。
しかし、動かすとタイムアウト。(GASは6分制限
記事のフェッチ自体は4回なので、書き込みに時間を食っているとの判断です。
思ったよりDriveへの書き込みが遅いらしい…

さてどうするかと悩んだところ、書き始めるすこし前にGASの並列処理の記事を見たことを思い出しました。

[Google Apps Scriptで並列処理をしたい - Qiita](https://qiita.com/tanaike/items/f6da88f0f769f6096c37)

非同期で動く仕組みについてはなんのこっちゃ！ですが幸いにも簡単に動かせるようライブラリにしてくださっているので、
書かれている内容を真似してなんとか導入。
処理数は最大28と書かれていますが、キリよく25で動かしてみました。

## 並列処理が失敗する！

さすが並列処理。6分以内に高速で終わりました！
…が、Driveの方を見るとかなり数が少ない…
そこで並列処理の結果を確認すると…
各書き込み処理でたくさん
`上限を超えています: DriveApp`
とエラーが出力されていました。

そのままの意で、並列化したことによるDriveへの過剰アクセスエラーのようです。

思ったよりDriveへの書き込みが遅く、同時処理数の制限も厳しいらいし…

## worker数を減らす

Driveへの同時書き込み数を減らすため、単純にworkerを減らしました。当然処理速度は落ちます。

徐々に減らすもエラー0にはならず、worker数5でやや安定度が増しました。
しかし、並列処理の常で、常に結果は安定するわけではなく、5でもまだエラーが出るときがあります。

しかも、処理は完了するものの処理時間がかなりギリギリ…
ここからさらに画像やコメントを取得して書き込まなければならないのに、現状でも投稿数があと少し増えるだけでタイムアウトは避けられません。

これ以上並列処理数を減らしても、

* エラーがゼロになる保証は少ない
* タイムアウトの危険性が増大する
* そもそも5未満ってなんだかもう並列処理と言えなくない？

という考えになり、**この手法は失敗だった**と私の中で結論づけられました。

# 反省 どうすればよかったのか

一度に全件処理しようとしたことが処理数増加に繋がり誤りでした。
「Qiita記事のバックアップ」は緊急なものではないし、一度に全部が最新の状態になっている必要もなかったです。

たとえば、一時間、一日に一回、10～100件処理するような、まったりめのスパンで常に部分部分のバックアップを取るような作りにしておけば、新規投稿・更新後即座とはいかずともしばらくすればバックアップされている環境を作れるはずです。

GASにはストレージ(プロパティ)があるので、取得するページ数を保存しておいて、
起動(or終了)ごとに指定ページ数を+1して記事取得→取得数が0ならページ数を1に変更して保存
としておけば、順番にバックアップできるはずです。


# よかった点

うまくはいきませんでしたが、知見は深まったので「よい失敗」だと思います。
また、並列処理は有意に処理スピードが向上したので、今後も使えるテクニックだと思います。
もしかしたらSpreadsheetはDriveより並列アクセスに耐えられるかもしれませんし、GAS単体で完結する処理ならば元記事で検証済みなので有効です。

# 参考

* [Drive Service  |  Apps Script  |  Google Developers](https://developers.google.com/apps-script/reference/drive/)
* [Qiita API v2 を使って自身の全投稿をエクスポートするJavascriptを書いた - Qiita](https://qiita.com/yumetodo/items/ea9ecb5a30dbd120f955)
* [最新1万件の平均いいね数は4.66 - Qiita](https://qiita.com/Akira07/items/a816f295f6fa2670ce76)
* [Google Apps Scriptで並列処理をしたい - Qiita](https://qiita.com/tanaike/items/f6da88f0f769f6096c37)


***

* [Node.jsで処理をマルチプロセスで分担して結果を回収し処理を継続する - Qiita](https://qiita.com/khsk/items/48d3c44ec75e20ab85db)

***

* [GAS ビギナーが GAS を使いこなすために知るべきこと 10 選 - Qiita](https://qiita.com/tanabee/items/2c51681396fe12b6a0e4) 
 実行時間対策もありますが、やや大げさで不採用

# というわけで

一起動ごとに一ページだけ処理するコードに書き換えて、画像の保存ぐらいまでできるようになったら再度投稿したいですね。
こんどはしくじり話でありませんように…。