24
13

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.

kintoneAdvent Calendar 2020

Day 15

kintone APIの一括処理を、bulkRequestとPromise.allSettledで堅牢に

Last updated at Posted at 2020-12-15

はじめに

みなさん、kintoneの bulkRequest(バルクリクエスト)使ってますか?
恥ずかしながら、僕は今まであまり使ってなかったのですが、
今年ようやく「これ超いいやん!」と実感する機会が多くありました。

bulkRequestを解説した記事が、そもそもネット上に少ないですよね。
developer network内に良い記事が2本あるのですが、
Qiitaや個人ブログでは、ここまでちゃんとした記事は見つかりませんでした。
この2本だけでは人の目に触れる機会も少なく、勿体ないです。

多分、存在をそもそも知らない人が多いんじゃないだろうか。
@kintone/rest-api-client の allXXX系メソッドなどで内部的に使われてるので、
SDK経由で間接的に叩いている人は多そうですけどもね。

なので今回はdeveloper networkの記事と多少内容が被ることも覚悟のうえで、
「もっとbulkRequest使っていこうぜ!」という啓蒙の意味を込めて書いてみたいと思います。
ついでに、ES2020から新たにJavaScriptに加わった関数
Promise.allSettled()も一緒に解説します。
この2つをセットで使うと、安定した一括処理を実現しやすくてお勧めですよ!

サンプルアプリ概要

シンプルな「売上」「請求」の2アプリを考えてみます。
image.png

売上アプリ

  • 売上日、顧客名、商品名、単価、数量 を入力する
  • 金額は自動計算
  • 請求状況は自動設定されるので編集不要(本当はdisabled制約あった方が良い)
  • マスタ系、消費税、納品管理などは無視

データは何でもいいんですが、「お弁当屋さん」ぽくしてみました。
image.png
image.png

請求アプリ

  • 一覧画面にボタンを配置する
  • クリックすると以下の処理が走る
    • 「今月の未請求の売上」を売上アプリから抽出
    • 顧客別に集計して請求アプリにレコード追加
    • 売上アプリの請求状況フィールドを「請求済」に更新

image.png

JavaScriptカスタマイズの前提

  • webpackは不要(もちろん使ってもOK)
  • IE対応はしない(必要ならBabelで)
  • 100件越えの一括追加、一括更新は想定しない(改良すれば対応可能)
  • kintone REST APIは @kintone/rest-api-client 経由で叩く
  • 日付の処理は Luxon を使用
  • CSSとして 51-modern-default.css を使用

設定画面はこんな感じになります。
image.png

以下、自作のbilling.jsについて解説します。

カスタマイズ例

ありがちなコード

まずは全体を載せます。こいつを少しずつ改良していきます。

const SALES_APP_ID = 1234

// 顧客ごとの金額を合計
const sumAmountByCustomers = orderRecords => {
  const result = {}
  for (const record of orderRecords) {
    const customer = record.顧客名.value
    if (!result[customer]) {
      result[customer] = 0
    }
    result[customer] += Number(record.金額.value)
  }
  return result
}

// 請求アプリに追加するレコードオブジェクト生成
const generateBillingRecords = orderRecords => {
  const amountByCustomers = sumAmountByCustomers(orderRecords)
  return Object.entries(amountByCustomers).map(([customerName, amount]) => ({
    顧客名: { value: customerName },
    税別金額: { value: amount },
    請求日: { value: luxon.DateTime.local().toISODate() },
  }))
}

// 売上アプリを更新するレコードオブジェクト生成
const generateOrderRecordsAsBilled = orderRecords =>
  orderRecords.map(record => ({
    id: record.$id.value,
    revision: record.$revision.value,
    record: { 請求状況: { value: '請求済' } },
  }))

// メイン
const onClickBillingButton = async () => {
  if (!confirm('請求レコードを一括作成します。よろしいですか?')) return

  try {
    const client = new KintoneRestAPIClient()
    const orderRecords = await client.record.getAllRecords({
      app: SALES_APP_ID,
      condition: '売上日 = THIS_MONTH() and 請求状況 in ("未請求")',
    })
    if (orderRecords.length !== 0) {
      alert('請求対象の売上が見つかりません。')
      return
    }

    await client.record.addRecords({
      app: kintone.app.getId(),
      records: generateBillingRecords(orderRecords),
    })
    await client.record.updateRecords({
      app: SALES_APP_ID,
      records: generateOrderRecordsAsBilled(orderRecords),
    })

    alert('完了しました')
    location.reload()
  } catch (e) {
    console.error(e)
    alert('エラーが発生しました。')
  }
}

// イベントハンドラ、ボタン配置処理
kintone.events.on('app.record.index.show', event => {
  if (document.querySelector('#billing-button')) return
  const button = document.createElement('button')
  button.id = 'billing-button'
  button.className = 'kintoneplugin-button-dialog-ok'
  button.innerText = '請求レコード作成'
  button.onclick = onClickBillingButton
  kintone.app.getHeaderMenuSpaceElement().appendChild(button)
})

全体を即時関数で括るのは省略してます。本当はこうやってね。

(() => {
  const SALES_APP_ID = 1234
  // 以下略
})()

version-1.0

全体の中で、この部分に注目。
「請求レコード追加」からの「売上レコード更新」をしている箇所です。
addRecords getRecordsを、それぞれawaitしてます。

    await client.record.addRecords({
      app: kintone.app.getId(),
      records: generateBillingRecords(orderRecords),
    })
    await client.record.updateRecords({
      app: SALES_APP_ID,
      records: generateOrderRecordsAsBilled(orderRecords),
    })
    alert('完了しました')

以降では上記の処理に絞って、
少しずつ改善しながらbulkRequest allSettledのメリットを解説していきます。

version-1.0の問題点

この記事で一貫して考えていくポイントは、
「途中で1か所エラーが起きた時、他にどんな影響が及ぶか?」です。

kintoneでは、APIで複数レコードの一括登録・更新を行う場合、
途中でエラーが起きると、そのAPI一発分はすべてロールバックしてくれます。
version-1.0のコードでは、こんな感じですね。

  • 請求レコード追加時にエラーが起きると、全部のレコード追加をロールバック
  • 売上レコード更新時にエラーが起きると、全部のレコード更新をロールバック

さて、今回はそれぞれを順番にawaitしているわけですが、ここに落とし穴があります。
以下のようになった場合、全体として何が起きるでしょう?

  • 請求レコード追加は全件成功
  • 売上レコード更新の途中でエラー発生→ロールバック

これだと、「実際には請求レコードが追加されているのに、請求状況はすべて未請求」
というチグハグな状態が起きてしまいます。これはよろしくない。

version-1.1

1.0の問題点を解決する前に、
JSのPromiseに慣れている人がやりがちな別問題にも触れておきます。

初心者は「非同期処理に何でもawaitつける」のをやりがちですが、
それやると本来の非同期のメリットがなくなって、パフォーマンスが遅くなります。
中級者以上は、Promise.allで並列処理をすることも多いでしょう。
しかし、、、

    await Promise.all([
      client.record.addRecords({
        app: kintone.app.getId(),
        records: generateBillingRecords(orderRecords),
      }),
      client.record.updateRecords({
        app: SALES_APP_ID,
        records: generateOrderRecordsAsBilled(orderRecords),
      }),
    ])
    alert('完了しました')

version-1.1の問題点

これ、パフォーマンスは速くなりますが、
「エラー時の堅牢さ」という意味ではむしろversion-1.0よりも悪化してます。

Promise.all()は「最初にエラーが起きた時点で全部止まる」ように見えるものの、
実際には「裏で残りのPromiseは動き続けて、それらの成功・失敗は把握できない」のです。
一度走り出したPromiseは、途中キャンセルされないので、覚えておきましょう。

てことは、1.1のコードの場合、

  • 請求レコード追加が失敗した
    • けど、売上レコードの更新は成功したかもしれない
    • もしくは、売上レコードの更新も失敗したかもしれない
  • 売上レコード更新が失敗した
    • けど、請求レコードの追加は成功したかもしれない
    • もしくは、請求レコードの追加も失敗したかもしれない

最初に発生したエラー以外は何も把握できないので、
「かもしれない」部分は、実際のレコード値を自分で確認するしかないのです。
その点 version-1.0 は、「確実にこれ以降が失敗した」とわかるので、まだマシなんですね。

Promise.all()は「レコード取得系の処理」で使う分には大変便利なのですが、
追加系・更新系では使わない方が安全です。

では、どうすればいいか?
いよいよbulkRequestの登場です!

version-2.0

こんな感じでbulkRequestを送ることで、
「請求レコード追加」「売上レコード更新」をセットで一括処理することができます。

    await client.bulkRequest({
      requests: [
        {
          method: 'POST',
          api: '/k/v1/records.json',
          payload: {
            app: kintone.app.getId(),
            records: generateBillingRecords(orderRecords),
          },
        },
        {
          method: 'PUT',
          api: '/k/v1/records.json',
          payload: {
            app: SALES_APP_ID,
            records: generateOrderRecordsAsBilled(orderRecords),
          },
        },
      ],
    })
    alert('完了しました')

Promise.all()では「一度走り出したPromiseは、途中キャンセルされない」のですが、
bulkRequestを使うと、「並列処理の1つでも失敗すると、すべてキャンセル(ロールバック)」してくれるのが素晴らしいところです。
どこかでエラーが起きても、こんな感じで確実に全部ロールバックされます。

  • 請求レコード追加が失敗した
    • 売上レコード更新もすべてキャンセル
  • 売上レコード更新が失敗した
    • 請求レコード追加のすべてキャンセル

version-2.0の問題点

「エラー時に中途半端な状態にならない」という意味では問題なくなりましたが、
あとは「1つでもエラーが起きたら全部キャンセルされる」という部分ですね。

100件処理するときに、1件だけおかしいのに99件も全部止まったら悲しいじゃないですか。
そこを何とかしたい。

あと、本題とは逸れますが、
rest-api-clientbulkRequestを送るには、リクエストボディは生で書く必要があるんですよねー。これがイマイチ。
以前のkintone-js-sdkでは、メソッドチェーンでbulkRequestを送ることができたので良かったんですけどね。退化しちゃいましたね。今後に期待!
GitHubにはIssue送り済みですw https://github.com/kintone/js-sdk-ja/issues/12

version-3.0

1.0 -> 1.1 -> 2.0と「APIの叩き方」を変えてきましたが、
3.0では「リクエストボディの作り方」に手を加えます。

    const billingRecords = generateBillingRecords(orderRecords)
    const recordsForBulkRequest = billingRecords.map(billingRecord => {
      const orderRecordsOfThisCustomer = orderRecords.filter(
        orderRecord => orderRecord.顧客名.value === billingRecord.顧客名.value
      )
      const orderRecordsAsBilled = generateOrderRecordsAsBilled(orderRecordsOfThisCustomer)
      // 顧客別に、請求追加用(1件) / 売上更新用(複数件)をペアにする
      return { billingRecord, orderRecordsAsBilled }
    })

    for (const { billingRecord, orderRecordsAsBilled } of recordsForBulkRequest) {
      await client.bulkRequest({
        requests: [
          {
            method: 'POST',
            api: '/k/v1/record.json',
            payload: {
              app: kintone.app.getId(),
              record: billingRecord,
            },
          },
          {
            method: 'PUT',
            api: '/k/v1/records.json',
            payload: {
              app: SALES_APP_ID,
              records: orderRecordsAsBilled,
            },
          },
        ],
      })
    }
    alert('完了しました')

リクエストボディの分け方を簡略化して比べると、こんなイメージ

before
{
  "請求": [{}, {}],
  "売上": [{}, {}, {}, {}, {}]
}
after
[
  {
    // A社への請求
    "請求": {},
    "売上": [{}, {}, {}]
  },
  {
    // B社への請求
    "請求": {},
    "売上": [{}, {}]
  }
]

「アプリ単位」ではなくて、「トランザクション単位」とでも言いますか。
エラーが起きた時に、この単位でロールバックしたいという塊にしておきます。

その上で、for-ofでループさせて一件ずつ処理をしています。
これなら途中でエラーが起きても、それ以前に成功していたリクエストは、
無駄にロールバックされることなく生き残ってくれます。

version-3.0の問題点

例えば10件中の6件目でエラーが起きた場合、こうなります。

  • 前半5件は処理成功のまま残る
  • エラーが起きた6件目はロールバック
  • 後半4件は全く処理されない(try-catchで抜けてしまう)

version-2.0よりはマシになりましたが、もう一声。
ぜひとも「後半の4件も」無視せずに救いたいですよね。

さて、いよいよ次が最終形態、Promise.allSettled()の登場です。

version-4.0

3.0では「forの中で1件ずつawait」という、素人っぽい方法を使っちまいましたw

最終形態4.0では、ループ内ではawaitせずに、pending状態のPromiseオブジェクトを
ひたすら配列に突っ込んでいき、ループを回しきる。
そして、一番最後にawait Promise.allSettled()で待機します。

    const promises = []
    for (const { billingRecord, orderRecordsAsBilled } of recordsForBulkRequest) {
      // ここではawaitせず、pending状態で突っ込むだけ
      const promise = client.bulkRequest({
        requests: [
          {
            method: 'POST',
            api: '/k/v1/record.json',
            payload: {
              app: kintone.app.getId(),
              record: billingRecord,
            },
          },
          {
            method: 'PUT',
            api: '/k/v1/records.json',
            payload: {
              app: SALES_APP_ID,
              records: orderRecordsAsBilled,
            },
          },
        ],
      })
      promises.push(promise)
    }
    // 失敗が混ざってもキャンセルせず、すべての実行結果を取得
    const results = await Promise.allSettled(promises)

    // 成功・失敗それぞれを抽出
    const successes = results.filter(_ => _.status === 'fulfilled')
    const errors = results.filter(_ => _.status === 'rejected')

    alert(`請求レコード作成 実行結果

請求対象顧客:${results.length}件
処理成功:${successes.length}件
処理失敗:${errors.length}件`)

    if (errors.length > 0) {
      console.error(errors)
      return
    }

結果、こちら!
テストデータ手を抜いて少ないですが、「成功・失敗」の件数が全部出ます!

image.png

Promise.all()の問題点が、
Promise.allSettled()を使うことで見事に解決されました!

settledってのはfulfilled(成功) rejected(失敗)が混在する総称。
「成功・失敗どちらにしろ全部終わるまで待機」ってことですね。
詳しくはこの記事あたりが参考になります。

allSettled()全体としては、エラーが起きてもtry-catchではキャッチされません。
必ず成功するので、玉石混合の実行結果を自分でフィルタして、successes errorsに分けてあげます。

あとはalertでいい感じにメッセージ出してあげればOK。
ちゃんとやるならSweetAlertなんか使って、
エラーオブジェクトの中身も奇麗に表示してあげると良いでしょう。

改善後 全コード

最後に、改めて最終形態のコード全体を載せておきます。

const SALES_APP_ID = 1234

// 顧客ごとの金額を合計
const sumAmountByCustomers = orderRecords => {
  const result = {}
  for (const record of orderRecords) {
    const customer = record.顧客名.value
    if (!result[customer]) {
      result[customer] = 0
    }
    result[customer] += Number(record.金額.value)
  }
  return result
}

// 請求アプリに追加するレコードオブジェクト生成
const generateBillingRecords = orderRecords => {
  const amountByCustomers = sumAmountByCustomers(orderRecords)
  return Object.entries(amountByCustomers).map(([customerName, amount]) => ({
    顧客名: { value: customerName },
    税別金額: { value: amount },
    請求日: { value: luxon.DateTime.local().toISODate() },
  }))
}

// 売上アプリを更新するレコードオブジェクト生成
const generateOrderRecordsAsBilled = orderRecords =>
  orderRecords.map(record => ({
    id: record.$id.value,
    revision: record.$revision.value,
    record: { 請求状況: { value: '請求済' } },
  }))

// メイン
const onClickBillingButton = async () => {
  if (!confirm('請求レコードを一括作成します。よろしいですか?')) return

  try {
    const client = new KintoneRestAPIClient()
    const orderRecords = await client.record.getAllRecords({
      app: SALES_APP_ID,
      condition: '売上日 = THIS_MONTH() and 請求状況 in ("未請求")',
    })
    if (orderRecords.length === 0) {
      alert('請求対象の売上が見つかりません。')
      return
    }

    const billingRecords = generateBillingRecords(orderRecords)
    const recordsForBulkRequest = billingRecords.map(billingRecord => {
      const orderRecordsOfThisCustomer = orderRecords.filter(
        orderRecord => orderRecord.顧客名.value === billingRecord.顧客名.value
      )
      const orderRecordsAsBilled = generateOrderRecordsAsBilled(orderRecordsOfThisCustomer)
      // 顧客別に、請求追加用(1件) / 売上更新用(複数件)をペアにする
      return { billingRecord, orderRecordsAsBilled }
    })

    const promises = []
    for (const { billingRecord, orderRecordsAsBilled } of recordsForBulkRequest) {
      // ここではawaitせず、pending状態で突っ込むだけ
      const promise = client.bulkRequest({
        requests: [
          {
            method: 'POST',
            api: '/k/v1/record.json',
            payload: {
              app: kintone.app.getId(),
              record: billingRecord,
            },
          },
          {
            method: 'PUT',
            api: '/k/v1/records.json',
            payload: {
              app: SALES_APP_ID,
              records: orderRecordsAsBilled,
            },
          },
        ],
      })
      promises.push(promise)
    }
    // 失敗が混ざってもキャンセルせず、すべての実行結果を取得
    const results = await Promise.allSettled(promises)

    // 成功・失敗それぞれを抽出
    const successes = results.filter(_ => _.status === 'fulfilled')
    const errors = results.filter(_ => _.status === 'rejected')

    alert(`請求レコード作成 実行結果

請求対象顧客:${results.length}件
処理成功:${successes.length}件
処理失敗:${errors.length}件`)

    if (errors.length > 0) {
      console.error(errors)
      return
    }

    location.reload()
  } catch (e) {
    console.error(e)
    alert('エラーが発生しました。')
  }
}

kintone.events.on('app.record.index.show', event => {
  if (document.querySelector('#billing-button')) return
  const button = document.createElement('button')
  button.id = 'billing-button'
  button.className = 'kintoneplugin-button-dialog-ok'
  button.innerText = '請求レコード作成'
  button.onclick = onClickBillingButton
  kintone.app.getHeaderMenuSpaceElement().appendChild(button)
})

おわりに

ステップ・バイ・ステップで、少しずつコードを改善して、
一括処理を安定化させてきました。いかがだったでしょうか?

RDBのトランザクションに比べるとkintoneは頼りない部分も多いですが、
それでも、ここまで出来ればかなり幅広い用途に耐えられるのではないかと思っとります。
bulkRequestPromise.allSettled、とても便利なのでぜひ使いこなしてみてください。

アドベントカレンダーでは、ネタ系の楽しい投稿がとても多いですよね。
僕もいつも楽しく読ませてもらってます。
言ってみれば**「J-POP」**であります :guitar:

しかしながら、自分が書く記事はやはり
「こうすれば今後の開発にとっても役に立つよ!」という実用的でガチなネタを
今後も中心にしたいと思っております。
言ってみれば**「演歌」**ですね :microphone:

kintone演歌歌手 @the_red の次回作に、今後もご期待ください :smile:
それでは、みなさま良いクリスマスを~ :christmas_tree:

24
13
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
24
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?