kintoneカスタマイズ時のアーキテクチャを考える

## はじめに
kintoneのカスタマイズは、ほぼほぼ通常のWebアプリケーションのフロントエンド開発と同じように進められるかと感じています。
ただ、[kintone JavaScript API](https://developer.cybozu.io/hc/ja/articles/360000361686)を使用していると、単体テストがやりづらかったりする点で、解消する良い方法を模索していました。
レイヤードアーキテクチャやクリーンアーキテクチャについて色々調べてみて、kintoneのカスタマイズに当てはめることで、
前述の課題が解消できそうでしたので、試してみます。

## 実装する内容
例として実装する内容は以下のとおりです。
**レコード一覧画面のkintone.app.getHeaderSpaceElement()で取得できるエリアの右端に、
[合計]フィールドの値を合算した値を表示します。**
※あまり見栄えは良くないですが、今回の本題ではないのであまり気にせず進みます。
![ｓｓ１](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/620452/a28ff075-3b5b-681a-6c4b-065b7094a5db.png)

## 使用するアーキテクチャ
以下4層で構成します。
矢印は依存の方向を表し、他のアーキテクチャ同様、内から外へは依存しないようにします。
![ss2](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/620452/ab73e997-bad0-7132-f15c-18b60e28daa7.png)

## 各層の説明

### event
kintone.events.on()のイベントハンドラを定義します。
kintoneでは、kintone.events.on()のイベントハンドラでユーザーからのアクションを受け取るので、
この層が最も外側の層となります。

### infrastructure
通常のWebアプリケーションで、DBとのアクセスを実装する層と対応します。
kintoneでは、kintone.api()を使用して、アプリのレコードについてCRUD操作を行います。
クリーンアーキテクチャと同様、ドメイン層でinterfaceを経由して使用されます。

### presentation
カスタマイズビューなど、独自のDOMを描画する場合は、この層で行います。
今回は例としてReactを使用している為、この層に定義します。

### domain
ビジネスロジックを定義する最も内側の層となります。
この層にmodel、repository interface、serviceを定義します。

## コードについて（実装部分）
### event
イベントハンドラ定義部分は以下になります。
もっと複雑なシステムになる場合、間にControllerを噛ませる形になるかと見ています。
```javascript
import React from 'react';
import { render } from 'react-dom';
import Sum from '../presentation/ui/Sum';
(() => {
  kintone.events.on("app.record.index.show", async (e: any) => {
    const element = kintone.app.getHeaderSpaceElement();
    render(<Sum />, element);
    return e;
  });
})();
```

### presentation > ui
ここからドメイン層を利用し、処理結果を返却します。
```javascript
import React, { useEffect, useState } from "react"
import OrderService from "../../domain/services/OrderService";
import { OrderRepository } from "../../infrastructure/repositories/OrderRepository";

export default () => {
  const repository = new OrderRepository();
  const service = new OrderService();
  const [sum, setSum] = useState(0);
  useEffect(() => {
    (async () => {
      const orders = await repository.getAll();
      setSum(service.sum(orders));
    })();
  }, []);
  return (
    <div>
      <div style={{
        textAlign: "right",
        marginRight: "100px",
      }}>
        <span>¥ </span>
        <span data-testid="sum">{sum}</span>
      </div>
    </div>
  );
}
```

### infrastructure > repositories
今回はJSを適用するアプリに対してのみ、CRUD操作を行う為、kintone.app.getId()でアプリIDを取得しています。
複数のアプリを参照するシステムの場合、対象アプリごとにリポジトリを定義し、アプリIDを指定します。
```javascript
import { Order } from "../../domain/models/Order";
import { IOrderRepository } from "../../domain/repositories/IOrderRepository";

export class OrderRepository implements IOrderRepository{
  async getAll(): Promise<Order[]> {
    const body = {
      'app': kintone.app.getId(),
    };

    const resp = await kintone.api(kintone.api.url('/k/v1/records.json', true), 'GET', body);
    let orders = [];
    for (let i=0; i<resp.records.length; i++) {
      orders.push(new Order(resp.records[i]));
    }
    return orders;
  }
}
```

### domain > models
レコードのデータを持つためのモデルです。
迷った（＝今も迷っている）ところも幾つかありますので、解決したら更新します。
- メンバについては、対象アプリの全フィールドではなく、システム内で使用するもののみ定義していますが、妥当か
- constructorでrecordを受け取り、Partialで全てのプロパティをメンバに設定していますが、
TypeScript側のチェックで初期化されていないとの判定になっているため、＠ts-ignoreを設定しているが、
これを書かずに済むキレイな書き方はあるか
```javascript
export class Order {
  // @ts-ignore
  #注文日: {
    value: Date
  }
  // @ts-ignore
  #商品名: {
    value: string
  }
  // @ts-ignore
  #数量: {
    value: number
  }
  // @ts-ignore
  #単価: {
    value: number
  }
  // @ts-ignore
  public readonly 合計: {
    value: number
  }
  constructor(record: Partial<Order>) {
    Object.assign(this, record);
  }
}

```

### domain > services
modelは、あくまでも自分のデータしか知らない為、複数データにまたがる操作はserviceを使用します。
今回は合計処理ですが、他で言えば重複チェックなどが該当します。

```javascript
import { Order } from "../models/Order";

export default class {
  sum(orders: Order[]) {
    let sum = 0;
    for (let i=0; i<orders.length; i++) {
      sum += Number(orders[i]["合計"]["value"]);
    }
    return sum;
  }
}
```

## コードについて（テスト部分）

### セットアップ処理
全てのテストの実行前に実行する処理を定義します。
今回はrepositoryのgetAll関数について、mockを行います。
```typescript
const { Order } = require("../src/domain/models/Order");
const { OrderRepository } = require("../src/infrastructure/repositories/OrderRepository");

beforeAll(() => {
  jest.spyOn(OrderRepository.prototype, "getAll").mockReturnValue(
    [
      new Order({
        "合計": {
          "value": "100"
        }
      }),
      new Order({
        "合計": {
          "value": "200"
        }
      }),
      new Order({
        "合計": {
          "value": "300"
        }
      }),
    ]
  );
});
```

### service
serviceで合計を行うsum関数についてテストを定義します。
```typescript
import { OrderRepository } from "../../infrastructure/repositories/OrderRepository";
import OrderService from "./OrderService";

describe("service[OrderService]のテスト", () => {
  test("sum()で合計されること", async () => {
    const repository = new OrderRepository();
    const orders = await repository.getAll();
    const service = new OrderService();
    expect(service.sum(orders)).toEqual(600);
  });
});
```

### presentation > ui
コンポーネントのテストを定義します。
event層には依存していない為、コンポーネントのみでテスト可能としています。
```typescript
import React from "react";
import {render, screen} from "@testing-library/react"
import '@testing-library/jest-dom'
import Sum from "./Sum";
import { act } from "react-dom/test-utils";

describe("ui component[Sum]のテスト", () => {
  test("合計が表示されること", async () => {
    await act(async () => {
      await render(<Sum />);
    });
    expect(screen.getByTestId("sum")).toHaveTextContent("600");
  });
});
```

## 全体コード
GitHubにコードをあげましたので、ご確認ください。

https://github.com/nkwtnb/kintone-customize-architecture


## おわりに
今回は小規模なカスタマイズを想定した為、コード量が増えた割には恩恵が少ないように見受けられますが、
規模が大きくなればなるほどコードが散らからずに済み、メリットを感じられるようになるかと感じました。
また、明確にevent層、infra層を切り離したことで、コンポーネントテストが可能になったことは大きいメリットに感じます。