kintoneで１万レコード以上のアプリでoffsetを検証（カーソル処理も試してみた）

#はじめに
kintoneを利用する際に注意しなければならない制限として、以下のサイトで説明されているようにoffsetの上限値10000があります。

**kintone API レコード一括取得APIのoffsetの上限値制限について**
https://cs.cybozu.co.jp/2019/006924.html
**offset の制限値を考慮した kintone のレコード一括取得について**
https://developer.cybozu.io/hc/ja/articles/360030757312

これまでこの制限の影響を受けないため詳細の調査を行っていませんでしたが、URLでoffsetの上限値を超える指定をした場合の挙動など、明記されてない部分が気になっていました。また、上記制限は2020年7月のアップデート前に利用開始された環境では若干挙動が異なるとの説明もあり、kintone Café 愛媛に登壇する機会に詳細を調査してみました。
なお、offsetの試験はkitnoeの環境に負荷をかけてしまう恐れもあるため、試験は平日を避けて実施しています。
（この内容は[kintone Café 愛媛 Vol.16](https://kintone-cafe-ehime.connpass.com/event/231091/)に登壇した内容をQiita用にまとめ直したものです。）

#offsetとは
offsetとは、指定順にデータを並べて、指定行からデータを取得するための指定です。このoffsetは良くkintone APIで利用されますが、Webブラウザで利用している際にもURLで利用されています。
例えば、更新日時が今日より前のデータを更新日時順に並べて9901行から表示（取得）する場合、以下のURLの例のようにoffset=9900と指定します。

```
https://<subdomain>.cybozu.com/k/999/?view=5524172&q=f5524160 >= "2017-12-31T15:00:00.000Z" and f5524160 <= "2021-11-21T15:00:00.000Z"#sort_0=f5524160&order_0=asc&size=20&offset=9900 
```

#URL指定でのoffset検証
結論としては、Webブラウザで利用する際にURLでoffset10000超えを指定しても、2020年7月のアップデート前、後とも何ら問題はありません。

```
https://<subdomain>.cybozu.com/k/999/?view=5524172&q=f5524160 >= "2017-12-31T15:00:00.000Z" and f5524160 <= "2021-11-21T15:00:00.000Z"#sort_0=f5524160&order_0=asc&size=20&offset=11000
```
以下のように問題なく表示されました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75892/eee746ec-deb1-aef8-4488-f7d0cb55184a.png)

#kintone APIでoffsetの検証
結論としては、kintone APIで10000を超えるoffsetを指定した場合、説明で書かれた通り2020年7月のアップデート前のkintoneではデータ取得は可能ですがアプリに警告が表示（とはいえ警告らしくない表示だか）され、2020年7月のアップデート後のkintoneではレコードが取得できない結果となりました。

検証には20000件近くレコードを保管しているアプリを2020年7月のアップデート前と後のkintone環境に作成し、Windows PCのアプリからkintone API経由でoffsetを11000に指定してレコードを取得しています。

## 2020年7月のアップデート前のkintone
以下のように、Windows PCのアプリからoffset 11000で問題なくレコードを取得できました。**但し、これはあくまで結果を見るための試験で、通常利用する場合はカーソル処理に移行しましょう！**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75892/ac6465ae-c55d-a872-82da-c2f3432025d7.png)
その後アプリを参照すると、アップデートの説明どおりの警告が表示されました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75892/f4b05baf-b875-fa59-ab76-deb2438b86e8.png)

## 2020年7月のアップデート後のkintone
以下のようにレスポンスがBad Request(400)となり、レコードを取得できませんでした。仕様通りの結果でした。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75892/ce59d583-3025-d04c-c389-24451bf332b1.png)
参考までに、レスポンスが速いことからqueryでoffsetが10000を超えていたら以降の処理をせずBad Request(400)を応答していると推測しています。

念のためkintoneアプリのJavaScriptでも挙動を確認しましたが、以下の通りレスポンスがBad Request(400)となり、Windowsアプリと同じ結果になりました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75892/94ef0159-90e0-12f6-d9f1-1d97e5418e0c.png)

但し、offsetが10000までなら以下のようにレスポンスが返ってくるので、問題なく利用できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75892/8eb23ad1-ff3f-3f07-ddcf-f24529cb2e66.png)
データ編成を考えると、offsetが10000以下でもデータベースの負荷はそれなりに発生していると想像されますので、この辺りは良心的な仕様ですね！

#カーソル処理の実装
2020年7月のアップデート後offset 10000を超える処理には、カーソル処理を行う必要があります。実際にkintone APIを以下のWindowsアプリを作成、問題なくレコードを取得できました。

![k10th510.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75892/c86326c7-d109-1180-4cf3-4f8b389e1e78.png)

カーソル処理は以下の手順で行います。
1.カーソルを作成（一度に取得できるレコード数は最大500、初期値100）
2.カーソルからデータ取得（カーソル作成時に指定したレコード数づつ取得）
3.全件取得前に処理を終わる場合のみ、カーソルを削除

kintoneのカーソル処理に関する情報はまだ少な目ですが、以下などを参考にすると良いでしょう。

**レコードの一括取得**
https://developer.cybozu.io/hc/ja/articles/360029152012
**cursor.jsonの使いどころ〜kintone REST API レコード一括取得**
https://www.joyzo.co.jp/blog/10678
**カーソルAPIを使ってレコードを一括取得したい（シンプル風味のソースを添えて）**
https://qiita.com/tarimo34/items/8169dcdcccc1b23d2cfb

今回試験に使ったC#で100件づつレコードを取得するコードは以下です。
.NET Framework 4.8のWindowsフォームアプリケーションにnugetでNewtonsoft.Json 13.0.1を追加しています。

```KintoneTools.cs
using Newtonsoft.Json;
using System;
using System.IO;
using System.Net;
using System.Text;
using System.Windows.Forms;

namespace kintoneCursorAccessTP
{
    public static class KintoneTools
    {
        /// <summary>
        /// カーソルでアプリのデータを取得する
        /// </summary>
        /// <param name="domain">サブドメイン</param>
        /// <param name="appId">アプリID</param>
        /// <param name="token">APIトークン</param>
        /// <param name="query">クエリ文</param>
        /// <returns>取得データ</returns>
        public static string GetDataFromCursor(string domain, string appId, string token, string query)
        {
            // TLS1.1、TLS1.2 対応
            ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls11 | SecurityProtocolType.Tls12;
            
            var apiUrl = "https://" + domain + ".cybozu.com/k/v1/records/cursor.json";
            string responseText;

            // 取得したデータをテキストファイルに保存
            File.WriteAllText("kintoneData.txt", "");

            // カーソルの作成
            Cursor cursor = new Cursor();
            responseText = SetCursor(apiUrl, appId, token, query);
            cursor = JsonConvert.DeserializeObject<Cursor>(responseText);
            responseText += "\r\n\r\n";
            File.AppendAllText("kintoneData.txt", "");

            // カーソルからデータを100レコードづつ取得
            int i = 1;
            string text = "";
            while (cursor.count >= i)
            {
                text = GetData(apiUrl, token, cursor.id);
                File.AppendAllText("kintoneData.txt", "Start Low = " + i.ToString() + " " + text + "\r\n");
                i += 100;
            }
            // レスポンスには最後に取得したデータのみ表示
            responseText += "Last Low = " + i.ToString() + " " + text + "\r\n";

            // 全レコードを取得するとカーソルは削除されるため、そのままレスポンスを返す
            // カーソルを削除しようとすると、404エラー
            return responseText;
        }

        /// <summary>
        /// カーソルの作成
        /// </summary>
        /// <param name="url">カーソルAPI URL</param>
        /// <param name="appId">アプリID</param>
        /// <param name="token">APIトークン</param>
        /// <param name="query">クエリ文</param>
        /// <returns>カーソルデータ</returns>
        private static string SetCursor(string url, string appId, string token, string query)
        {
            HttpWebRequest req = (HttpWebRequest)WebRequest.Create(url);
            req.Headers.Add("X-Cybozu-API-Token:" + token);
            req.Method = "POST";
            req.ContentType = "application/json";

            // カーソルを設定するアプリやクエリの情報はjsonでPOSTする
            string postData = "{ \"app\": " + appId + ", \"query\": \"" + query + "\" }";
            byte[] byteArray = Encoding.UTF8.GetBytes(postData);
            req.ContentLength = byteArray.Length;
            Stream stream = req.GetRequestStream();
            stream.Write(byteArray, 0, byteArray.Length);
            stream.Close();

            WebResponse response = req.GetResponse();
            using (stream = response.GetResponseStream())
            {
                var sr = new StreamReader(stream, Encoding.GetEncoding("UTF-8"));
                string text = sr.ReadToEnd();
                sr.Close();
                stream.Close();
                return text;
            }
        }

        /// <summary>
        /// カーソルからデータ取得
        /// </summary>
        /// <param name="url">カーソルAPI URL</param>
        /// <param name="token">APIトークン</param>
        /// <param name="id">カーソルID</param>
        /// <returns>アプリデータ</returns>
        private static string GetData(string url, string token, string id)
        {
            // カーソルIDをURLにセット
            url += "?id=" + Uri.EscapeUriString(id);

            HttpWebRequest req = (HttpWebRequest)WebRequest.Create(url);
            req.Headers.Add("X-Cybozu-API-Token:" + token);
            req.Method = "GET";

            WebResponse response = req.GetResponse();
            using (var stream = response.GetResponseStream())
            {
                var sr = new StreamReader(stream, Encoding.GetEncoding("UTF-8"));
                string text = sr.ReadToEnd();
                sr.Close();
                stream.Close();
                return text;
            }
        }

        /// <summary>
        /// カーソルの削除
        /// </summary>
        /// <param name="url">カーソルAPI URL</param>
        /// <param name="token">APIトークン</param>
        /// <param name="id">カーソルID</param>
        /// <returns>応答</returns>
        private static string RemoveCursor(string url, string token, string id)
        {
            HttpWebRequest req = (HttpWebRequest)WebRequest.Create(url);
            req.Headers.Add("X-Cybozu-API-Token:" + token);
            req.Method = "DELETE";
            req.ContentType = "application/json";

            string postData = "{ \"id\": \"" + id + "\" }";
            byte[] byteArray = Encoding.UTF8.GetBytes(postData);
            req.ContentLength = byteArray.Length;
            var stream = req.GetRequestStream();
            stream.Write(byteArray, 0, byteArray.Length);
            stream.Close();

            try
            {
                WebResponse response = req.GetResponse();
                using (stream = response.GetResponseStream())
                {
                    var sr = new StreamReader(stream, Encoding.GetEncoding("UTF-8"));
                    string text = sr.ReadToEnd();
                    sr.Close();
                    stream.Close();
                    return text;
                }
            }
            catch (WebException ex)
            {
                MessageBox.Show(ex.Message, "Confirmation!", MessageBoxButtons.OK, MessageBoxIcon.Warning);
                return ex.Message;
            }
        }
    }

    /// <summary>
    /// カーソル情報
    /// </summary>
    [JsonObject("Cursor")]
    class Cursor
    {
        [JsonProperty("id")]
        public string id { get; set; }

        [JsonProperty("totalCount")]
        public int count { get; set; }
    }
}
```

```Form1.cs
using System;
using System.Windows.Forms;

namespace kintoneCursorAcssessTP
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();
        }

        private void button1_Click(object sender, EventArgs e)
        {
            this.Respons.Text = "";
            try
            {
                this.Respons.Text = KintoneTools.GetDataFromCursor(
                    this.Domain.Text,
                    this.AppId.Text,
                    this.Token.Text,
                    this.Query.Text
                );
            }
            catch (Exception ex)
            {
                MessageBox.Show(ex.Message, "Error occurred!", MessageBoxButtons.OK, MessageBoxIcon.Error);
            }
        }
    }
}
```

# まとめ
- Webブラウザで利用時にURLでoffset 10000超え指定しても、2020年7月のアップデート前、後の環境とも何ら問題なくレコードを表示する
- 2020年7月のアップデート前の環境で、kintone APIでoffset 10000超え指定をすると、警告表示があるがレコードは取得できる
- 2020年7月のアップデート以降の環境でkintone APIでoffset 10000超え指定をすると、レコードは取得できないが、offset 10000まではレコードを取得できる
- 2020年7月のアップデート以降の環境でkintone APIでoffset 10000超え指定でkintone APIを実行する場合、外部アプリ、kintoneアプリのJavaScriptとも同じ結果（上記）となる
- offset 10000超えの処理を行う場合は、カーソル処理を利用する（少し実装は手間ですが）