Amazon API Gatewayを使ってリアルタイムkintone

kintoneの「一覧をリアルタイムで更新したい」，「同レコードを複数人同時に編集したい」と感じることはありませんか？
[Amazon API GatewayのWebSocket API](https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/apigateway-websocket-api-overview.html)を利用すると，他ユーザーの操作をリアルタイムで検知して，上述機能を作成できます．

しかも超簡単，[お手頃価格](https://aws.amazon.com/jp/api-gateway/pricing/)で．

## デモ

一覧をリアルタイム更新するデモです．

左がレコード一覧を見ている人，右がレコードを追加編集している人です．
右の人がレコード操作すると，左の人の一覧が勝手に更新されます．

![demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/528077/9e061f65-faba-837d-72fc-4b06e806c08a.gif)

## 実装方法

### WebSocketサンプルアプリのデプロイ

AWSがWebSocketのサンプルアプリを用意してくれているので，そのまま利用します．
AWSアカウントがない方は，[AWS アカウント作成の流れ](https://aws.amazon.com/jp/register-flow/)を参考に作成してください．

アカウントを作成したら，[simple-websockets-chat-app](https://serverlessrepo.aws.amazon.com/applications/arn:aws:serverlessrepo:us-east-1:729047367331:applications~simple-websockets-chat-app)にアクセスします．

アクセスしたら，右上にあるDeployボタンをクリックします．
![スクリーンショット 2021-04-26 22.13.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/528077/a88fe081-b1bc-d264-88e5-74ff02714e1c.png)

Deployボタンを押すと，simple-websockets-chat-appの設定とデプロイ画面に遷移します．
右下の方にある，「このアプリがカスタム IAM ロールとリソースポリシーを作成することを承認します。」にチェックを入れ，デプロイボタンを押します．
![スクリーンショット 2021-04-26 22.15.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/528077/a7ec546f-dcdf-b9e8-fd98-f9fd5f003731.png)
デプロイボタンを押すとデプロイが開始します．

### WebSocket URLのメモ

デプロイが完了したらWebSocket URLをメモします．

ヘッダーの検索バーからAPI Gatewayを検索してクリックします．
![スクリーンショット 2021-04-26 22.29.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/528077/e9a1f70b-c968-aa07-6b00-c8551b9594f6.png)

API Gatewayを開いたら，「SimpleChatWebSocket」をクリックします．
![スクリーンショット 2021-04-26 22.32.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/528077/6e8a3825-b67d-4437-aea0-3ad7d7ba1682.png)

サイドバーのダッシュボードをクリックして，ページ上部に表示されたWebSocket URLをメモします．
![スクリーンショット 2021-04-26 22.36.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/528077/a6545e7d-de9d-597d-bf28-93353330b774.png)


### kintoneの設定

#### カスタマイズビューHTML(一覧名: リアルタイム更新)

```HTML
<div id="async-view"></div>
```

#### JavaScript

下記を順に読み込みます．

- https://js.cybozu.com/kintone-rest-api-client/1.12.1/KintoneRestAPIClient.min.js
- https://cdnjs.cloudflare.com/ajax/libs/izitoast/1.4.0/js/iziToast.min.js
- AsyncTable.js
- sample.js

・AsyncTable.js

```JavaScript
class AsyncTable {
  constructor(columns) {
    this.columns = columns;
    this.table = document.createElement('table');
    this.thead = document.createElement('thead');
    this.tbody = document.createElement('tbody');
    this.table.appendChild(this.thead);
    this.table.appendChild(this.tbody);
    this.table.classList.add('async-table');
  }
  getTable () {
    return this.table;
  }
  setFormFields (formFields) {
    this.thead.innerHTML = '';
    const tr = document.createElement('tr');
    tr.innerHTML = '<th>$id</th>';
    this.columns.forEach((column) => {
      const th = document.createElement('th');
      th.innerText = formFields.properties[column].label;
      tr.appendChild(th);
    });
    this.thead.appendChild(tr);
  }
  createRowContent (record) {
    const fragment = document.createDocumentFragment();
    this.columns.forEach((column) => {
      const td = document.createElement('td');
      td.innerText = record[column].value;
      fragment.appendChild(td);
    });
    return fragment;
  }
  createRow (record) {
    const id = record.$id.value;
    const tr = document.createElement('tr');
    tr.id = `record-${id}`;
    tr.innerHTML = `<td><a href="show#record=${id}">${id}</a></td>`;
    tr.appendChild(this.createRowContent(record));
    return tr;
  }
  updateRow (record) {
    const id = record.$id.value;
    const tr = document.getElementById(`record-${id}`);
    tr.innerHTML = `<td><a href="show#record=${id}">${id}</a></td>`;
    tr.appendChild(this.createRowContent(record));
    return tr;
  }
  focusRow (tr) {
    tr.classList.add('focused');
    setTimeout(() => tr.classList.remove('focused'), 5000);
  }
  setInitialRecords (records) {
    const _this = this;
    this.tbody.innerHTML = '';
    const fragment = document.createDocumentFragment();
    records.forEach((record) => {
      fragment.appendChild(_this.createRow(record));
    });
    this.tbody.appendChild(fragment);
  }
  addRecord (record) {
    const tr = this.tbody.insertBefore(this.createRow(record), this.tbody.firstChild);
    this.focusRow(tr);
  }
  updateRecord (record) {
    const tr = this.updateRow(record);
    this.focusRow(tr);
  }
}
```

・sample.js
2行目の「WebSocket URL」と6行目の「一覧に表示するフィールドのフィルドコード」は適宜変更してください．

```JavaScript
(() => {
  const url = 'wss://*****.execute-api.ap-northeast-1.amazonaws.com/Prod/'; // WebSocket URL
  kintone.events.on([
    'app.record.index.show'
  ], (event) => {
    if (event.viewName !== 'リアルタイム更新') return event;
    const columns = [// 一覧に表示するフィールドのフィルドコード
      '品名',
      '値段',
    ];
    const containerId = 'async-view';
    const client = new KintoneRestAPIClient();
    const asyncTable = new AsyncTable(columns);
    kintone.Promise.all([
      client.app.getFormFields({ app: kintone.app.getId() }).then((formFields) => {
        asyncTable.setFormFields(formFields);
      }),
      client.record.getAllRecordsWithCursor({ app: kintone.app.getId() }).then((records) => {
        asyncTable.setInitialRecords(records);
      }),
    ]).then(() => {
      document.getElementById(containerId).appendChild(asyncTable.getTable());
    });
    const socket = new WebSocket(url);
    socket.addEventListener('message', (e) => {
      const sentEvent = JSON.parse(e.data);
      if (sentEvent.type === 'app.record.create.submit.success') {
        iziToast.info({
          title: 'レコードが追加されました',
          message: `$id: ${sentEvent.record.$id.value}`,
        });
        asyncTable.addRecord(sentEvent.record);
      }
      if (sentEvent.type === 'app.record.edit.submit.success') {
        iziToast.info({
          title: 'レコードが更新されました',
          message: `$id: ${sentEvent.record.$id.value}`,
        });
        asyncTable.updateRecord(sentEvent.record);
      }
    });
    return event;
  });
  kintone.events.on([
    'app.record.create.submit.success',
    'app.record.edit.submit.success'
  ], (event) =>
      new kintone.Promise((resolve) => {
        const socket = new WebSocket(url);
        socket.addEventListener('open', () => {
          socket.send(`{"action": "sendmessage", "data": ${JSON.stringify(JSON.stringify(event))}}`);
          resolve(event);
        });
      })
  );
})();
```

#### CSS

下記を順に読み込みます．

- https://cdnjs.cloudflare.com/ajax/libs/izitoast/1.4.0/css/iziToast.min.css
- AsyncTable.css

・AsyncTable.css

```CSS
.async-table{
  width: 100%;
}
.async-table tbody tr:nth-child(2n+1){
  background: #f5f5f5;
}
.async-table th,
.async-table td{
  border: 1px solid #e3e7e8;
  padding: 10px;
  font-weight: normal;
  text-align: left;
}
.async-table tbody tr.focused{
  background: #9ddeff;
}
```

設定は以上です．

気が向いたら，同レコードを複数人同時に編集の記事も書こうかと思います．