GraphQLサーバとRESTサーバをさっと立ちあげて、実際に触ってみる。つづき。関連のあるデータの取得



こんにちは。@masatomix です。

最近、現場でGraphQLとRESTサーバを構築する機会があったので、そのとき得られた[ナレッジを備忘としてまとめている](https://qiita.com/masatomix/items/53003a34d413206bb619)のですが、その続きです。

前回の初期状態から、すこし手を入れていきます。

## TL;DR

- Spring Data RESTが自動生成するRESTサービスについて、リンクになっているところを微調整します。
- ついでに、よけいなSQL文を発行しないよう微調整します
- GraphQL がQueryで指定するプロパティに応じて、必要なときだけBackendにデータを取りに行くようにする


## Spring Data RESTが自動生成するRESTの戻り値について、FKがオブジェクトじゃなくてリンクになっている

今回もこのデータ構造です。


![image-20240610114259607.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73777/e02c3ae5-3b96-c6e0-7623-ebadc25fcdef.png)

さて、前回も呼んでみたRESTですが、

```console
$ curl  http://localhost:8080/users | jq
```

```json
{
  "_embedded": {
    "user": [
      {
        "userId": "u001",
        "firstName": null,
        "lastName": "木野1",
        "email": "kino1@example.com",
        "age": 48,
        "_links": {
          "self": {
            "href": "http://localhost:8080/users/u001"
          },
          "appUser": {
            "href": "http://localhost:8080/users/u001"
          },
          "company": {
            "href": "http://localhost:8080/users/u001/company"
          }
        }
      },
      ...
    ]
  },
  "_links": {
    "self": {
      "href": "http://localhost:8080/users?page=0&size=20"
    },
    "profile": {
      "href": "http://localhost:8080/profile/users"
    },
    "search": {
      "href": "http://localhost:8080/users/search"
    }
  },
  "page": {
    "size": 20,
    "totalElements": 6,
    "totalPages": 1,
    "number": 0
  }
}
```

戻り電文はこんなJSONデータでした。Spring Data RESTが自動生成するRESTサービスは

![image-20240610114259607.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73777/e02c3ae5-3b96-c6e0-7623-ebadc25fcdef.png)
このManyToOneの関連をリンクで表現する仕様のようですね。なんだか独特です。
一般的に、関連の表現方法は、

- このようにリンクで表現する
- AppUser クラスに Company クラスのオブジェクトをもたせる
- AppUser クラスに company_codeのプロパティをもたせる

などがありそうですが、 今回は**company_codeのプロパティをもたせてみます**。


修正前

```java
package com.example.demo.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import lombok.Data;

@Data
@Entity
public class AppUser {
    @Id
    @Column(length = 6, nullable = false)
    private String userId;

    private String firstName;

    private String lastName;

    private String email;

    private int age;

    @ManyToOne
    @JoinColumn(name = "COMPANY_CODE", nullable = false)
    private Company company;
}
```

修正後
```java
package com.example.demo.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import lombok.Data;

@Data
@Entity
public class AppUser {
    @Id
    @Column(length = 6, nullable = false)
    private String userId;

    private String firstName;

    private String lastName;

    private String email;

    private int age;

    @Column(name = "COMPANY_CODE", nullable = false)
    private String companyCode;

    @ManyToOne(fetch= FetchType.LAZY)
    // @ManyToOne
    @JoinColumn(name = "COMPANY_CODE", nullable = false, insertable = false, updatable = false)
    private Company company;
}
```

companyCodeプロパティを生やしてみました[^1]。ついでに Companyの参照に対してFetchType.LAZY を指定して、Companyの情報がほしいときにSQLを発行するようにしています。

[^1]: そのままだと、もともとあるCompanyとプロパティが重複してしまうようで、insertable = false, updatable = false というおまじないも追加しています。

Spring Bootを再起動して、再度curlしてみると、、、。

```console
$ curl  http://localhost:8080/users | jq
```

```json
{
  "_embedded": {
    "user": [
      {
        "userId": "u001",
        "firstName": null,
        "lastName": "木野1",
        "email": "kino1@example.com",
        "age": 48,
        "companyCode": "com001",
        "_links": {
          "self": {
            "href": "http://localhost:8080/users/u001"
          },
          "appUser": {
            "href": "http://localhost:8080/users/u001"
          },
          "company": {
            "href": "http://localhost:8080/users/u001/company"
          }
        }
      },
      ...
    ]
  },
  "_links": {
    "self": {
      "href": "http://localhost:8080/users?page=0&size=20"
    },
    "profile": {
      "href": "http://localhost:8080/profile/users"
    },
    "search": {
      "href": "http://localhost:8080/users/search"
    }
  },
  "page": {
    "size": 20,
    "totalElements": 6,
    "totalPages": 1,
    "number": 0
  }
}
```

"companyCode": "com001",が追加されました。さらに修正前はログに

```console
select au1_0.user_id,au1_0.age,au1_0.company_code,au1_0.email,au1_0.first_name,au1_0.last_name from app_user au1_0 offset ? rows fetch first ? rows only
select c1_0.company_code,c1_0.company_name from company c1_0 where c1_0.company_code=?
select c1_0.company_code,c1_0.company_name from company c1_0 where c1_0.company_code=?
select c1_0.company_code,c1_0.company_name from company c1_0 where c1_0.company_code=?
```
って、いわゆる**N+1問題をはらんだSQLが発行されていました**が、修正後は

```console
select au1_0.user_id,au1_0.age,au1_0.company_code,au1_0.email,au1_0.first_name,au1_0.last_name from app_user au1_0 offset ? rows fetch first ? rows only
```
となっていました。Companyテーブルの情報はこの時点では不要なので、そのSQLは呼ばれないようになりました。
ただ、これでもCompanyの情報を取得するには別途SQLが必要で、結局N+1問題が発生しちゃうのですが、その件はおいおい解消していこうと思います[^2]。


[^2]: AppUser取得時点で、CompanyもFetch Joinしちゃう案 (N+1問題は解決するけど、Companyが不要な時にムダなJOINをしちゃう )や、AppUser取得時点ではCompanyを取得せず、ほしいときに In句をつかってCompanyを1回で取得する案(1+1問題にする案)などがありそうです。


### BFF も修正する

つづいてBFFです。

まずはライブラリの再生成。

```console
$ curl http://localhost:8080/v3/api-docs -o api-docs.json
$ openapi-generator-cli generate -i api-docs.json -g typescript-axios -o ./src/generated/
$
```

TypeScriptのコードは以下の通り変更します。
GraphQLのcompanyプロパティがリンク表現するStringとかだったのですが、Companyオブジェクトにしたり、companyCodeなどを追加したり、しています。

![Pasted image 20240612231642.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73777/4d1cd89a-4abf-c427-c9a0-0c39ae639d20.png)


修正後のコードはこんな感じ。

```TypeScript
import { gql } from 'apollo-server-express';
import {
  AppUserEntityControllerApi,
  AppUserRequestBody,
  CompanyEntityControllerApi,
  EntityModelAppUser,
  EntityModelCompany
} from './generated'
import { AxiosResponse, RawAxiosRequestConfig } from 'axios';
// import { readFileSync } from 'fs';
// import { join } from 'path';

// export const typeDefs = gql(readFileSync(join(__dirname, 'schema.gql'), 'utf8')); 
export const typeDefs = gql`
  type Query {
    users: [User]
    companies: [Company]
    findUserById(id: ID!): User
    findCompanyById(id: ID!): Company
  }

  type Mutation {
    createUser(user: UserInput): User
    updateUser(user: UserInput): SuccessResponse
  }

  type SuccessResponse {
    success: Boolean!
    message: String
    error: String
  }

  type User{
    id: ID!
    firstName: String
    lastName: String
    email: String
    age: Int
    companyCode:String
    company: Company
  }

  type Company{
    code: ID!
    name: String
  }
  
  input UserInput {
    userId: ID!
    firstName: String
    lastName: String
    email: String
    age: Int
    companyCode:String
  }
`;

type UserInput = {
  userId: string
  firstName?: string
  lastName?: string
  email?: string
  age?: number
  companyCode?: string
}

export const resolvers = {
  Query: {
    users: async () => {
      const api = new AppUserEntityControllerApi()
      const data = (await api.getCollectionResourceAppuserGet1()).data
      console.table(data._embedded?.user)
      return data._embedded?.user
    },

    companies: async () => {
      const api = new CompanyEntityControllerApi()
      const data = (await api.getCollectionResourceCompanyGet1()).data
      console.table(data._embedded?.company)
      return data._embedded?.company
    },

    findUserById: async (parent: {}, args: { id: string }) => {
      const p = new AppUserEntityControllerApi().getItemResourceAppuserGet(args.id)
      const data = (await p).data
      console.log(data)
      return data
    },

    findCompanyById: async (parent: {}, args: { id: string }) => {
      const p = new CompanyEntityControllerApi().getItemResourceCompanyGet(args.id)
      const data = (await p).data
      console.log(data)
      return data
    }

  },

  User: {
    id: (parent: EntityModelAppUser) => `${parent.userId}`,
    // company: (parent: EntityModelAppUser) => parent._links!.company.href
    company: async (parent: EntityModelAppUser) => {
      const api = new CompanyEntityControllerApi()
      const data = (await api.getItemResourceCompanyGet(parent.companyCode!)).data

      console.log(data)
      return data
    }
  },

  Company: {
    code: (parent: EntityModelCompany) => `${parent.companyCode}`,
    name: (parent: EntityModelCompany) => `${parent.companyName}`,
  },

  SuccessResponse: {
    success: (parent: Response) => {
      return parent.result ?
        parent.result.status >= 200 && parent.result.status < 300 :
        false
    },
    message: (parent: Response) => {
      return parent.result ?
        parent.result.status :
        parent.error.message
    },
    error: (parent: Response) => {
      return parent.result ?
        parent.result.status :
        JSON.stringify(parent.error)
    },
  },

  Mutation: {
    createUser: async (parent: {}, args: { user: UserInput }) => {
      const api = new AppUserEntityControllerApi()
      return (await api.postCollectionResourceAppuserPost(args.user)).data
    },

    updateUser: async (parent: {}, args: { user: UserInput }) => {
      const findUserResult = await getItemResourceAppuserGet(args.user.userId)
      if (findUserResult.result) {
        const instance = findUserResult.result!.data
        console.log(instance)

        const body: AppUserRequestBody = {
          userId: args.user.userId,
          firstName: args.user.firstName ?? instance.firstName!,
          lastName: args.user.lastName ?? instance.lastName!,
          age: args.user.age ?? instance.age!,
          companyCode: args.user.companyCode ?? instance.companyCode!,
          email: args.user.email ?? instance.email!,
        }
        return await postCollectionResourceAppuserPost(body)
      }
      return findUserResult;
    }
  }
}

type Response = {
  result?: AxiosResponse<EntityModelAppUser, any>;
  error?: any;
}


// try/catch True/Falseに変換する
const getItemResourceAppuserGet =
  (id: string, options?: RawAxiosRequestConfig): Promise<Response> => {
    return new Promise((resolve, reject) => {
      new AppUserEntityControllerApi()
        .getItemResourceAppuserGet(id, options)
        .then(result => resolve({ result }))
        .catch(error => resolve({ error }))
    })
  }

// try/catch True/Falseに変換する
const postCollectionResourceAppuserPost =
  (body: AppUserRequestBody, options?: RawAxiosRequestConfig): Promise<Response> => {
    return new Promise((resolve, reject) => {
      new AppUserEntityControllerApi()
        .postCollectionResourceAppuserPost(body, options)
        .then(result => resolve({ result }))
        .catch(error => resolve({ error }))
    })
  }
```

BFFサーバを起動します。

```console
$ yarn start
yarn run v1.22.22
$ ts-node src/index.ts
Server is running on http://localhost:3000
GraphQL Playground available at http://localhost:3000/graphql
```

今回はGraphQLの便利なフロントエンドを使ってみます。ブラウザで http://localhost:3000/graphql へアクセス。

![Pasted image 20240612233446.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73777/12726957-d55b-9a75-0211-92f947d3a243.png)


使い方の詳細は割愛しますが、左メニューで自分が定義したQueryやMutationを選んで、ほしいプロパティを指定することで GraphQLのリクエストを送信することができます。

### 例1 Userのみでcompanyを含めない場合

下記のように、companyプロパティを外して実行してみます。このまま実行しても良いですし、

![Pasted image 20240612234842.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73777/3781696c-95a8-2872-f6a1-4f5a2089e98c.png)

下記のところからcurlのコマンドも取得できるので、、、

![Pasted image 20240612233937.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73777/cadcd160-7445-905b-4523-c90169050e76.png)

今回は curlで実行してみます。

```console
$ curl --request POST \
    --header 'content-type: application/json' \
    --url http://localhost:3000/graphql \
    --data '{"query":"query Users {\r\n  users {\r\n    id\r\n    firstName\r\n    lastName\r\n    email\r\n    age\r\n    companyCode\r\n  }\r\n}"}' | jq
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0curl: (6) Could not resolve host: curl
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   951  100   815  100   136  41975   7004 --:--:-- --:--:-- --:--:-- 50052
```

```json
{
  "data": {
    "users": [
      {
        "id": "u001",
        "firstName": null,
        "lastName": "木野1",
        "email": "kino1@example.com",
        "age": 48,
        "companyCode": "com001"
      },
      {
        "id": "u002",
        "firstName": null,
        "lastName": "木野2",
        "email": "kino2@example.com",
        "age": 47,
        "companyCode": "com001"
      },
      ....
      {
     "id": "u009",
        "firstName": null,
        "lastName": null,
        "email": null,
        "age": 0,
        "companyCode": "com001"
      }
    ]
  }
}
```
結果が取得できました。

Spring Bootのログを見てみるとSQL文は

```
select au1_0.user_id,au1_0.age,au1_0.company_code,au1_0.email,au1_0.first_name,au1_0.last_name from app_user au1_0 offset ? rows fetch first ? rows only
```
**app_userテーブルにアクセスするのみ**でした。

### 例2 Userとともにcompanyも取得してみる場合

curlでやってみると、、
```
$ curl --request POST \
    --header 'content-type: application/json' \
    --url http://localhost:3000/graphql \
    --data '{"query":"query Users {\r\n  users {\r\n    id\r\n    firstName\r\n    lastName\r\n    email\r\n    age\r\n    companyCode\r\n    company {\r\n      code\r\n      name\r\n    }\r\n  }\r\n}"}' | jq
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  1373  100  1183  100   190  47649   7652 --:--:-- --:--:-- --:--:-- 57208
```

```json
{
  "data": {
    "users": [
      {
        "id": "u001",
        "firstName": null,
        "lastName": "木野1",
        "email": "kino1@example.com",
        "age": 48,
        "companyCode": "com001",
        "company": {
          "code": "com001",
          "name": "会社01"
        }
      },
      {
        "id": "u002",
        "firstName": null,
        "lastName": "木野2",
        "email": "kino2@example.com",
        "age": 47,
        "companyCode": "com001",
        "company": {
          "code": "com001",
          "name": "会社01"
        }
      },
      ....
      {
        "id": "u009",
        "firstName": null,
        "lastName": null,
        "email": null,
        "age": 0,
        "companyCode": "com001",
        "company": {
          "code": "com001",
          "name": "会社01"
        }
      }
    ]
  }
}
```

Companyデータも一緒に取得することができました。SQL文を見てみると、

```
select au1_0.user_id,au1_0.age,au1_0.company_code,au1_0.email,au1_0.first_name,au1_0.last_name from app_user au1_0 offset ? rows fetch first ? rows only
select c1_0.company_code,c1_0.company_name from company c1_0 where c1_0.company_code=?
select c1_0.company_code,c1_0.company_name from company c1_0 where c1_0.company_code=?
select c1_0.company_code,c1_0.company_name from company c1_0 where c1_0.company_code=?
select c1_0.company_code,c1_0.company_name from company c1_0 where c1_0.company_code=?
select c1_0.company_code,c1_0.company_name from company c1_0 where c1_0.company_code=?
select c1_0.company_code,c1_0.company_name from company c1_0 where c1_0.company_code=?
select c1_0.company_code,c1_0.company_name from company c1_0 where c1_0.company_code=?
select c1_0.company_code,c1_0.company_name from company c1_0 where c1_0.company_code=?
```
今回はCompanyテーブルにもアクセスして、必要なデータを取得することができました。ただ、見事にN+1問題が発生しています。。

以上でGraphQLがQueryで指定したプロパティに応じて、必要なBackendを呼び出すさまを確認する事ができました。

## まとめ

- Spring Data RESTが自動生成するRESTサービスについて、リンクになっているところをcompanyCodeを返すように調整しました。
- GraphQL がQueryで指定するプロパティに応じて、必要な時にBackendにデータを取りに行くようにすることができました。。

ただ、Companyデータが不要なときによけいなJOINなどをなくす事はできましたが、逆にCompanyデータが必要な時に、つどBackendを呼び出してしまい、SQLも多数発行してしまうというある意味本末転倒になっているので、次回はここを見直してみます。

お疲れさまでした。

## 関連リンク

- https://github.com/masatomix/spring-data-rest-example/releases/tag/init2 今回のソースです
- [Spring Data REST](https://spring.io/projects/spring-data-rest) 公式
- [Apollo Server](https://www.apollographql.com/docs/apollo-server/) 公式
- [Spring Data RESTの要点と利用方法](https://qiita.com/umiushi_1/items/b369f659bbd94576b8f4) Spring Data RESTについての記事。参考にさせてもらいました。
- [GraphQLにおけるN+1問題の解決策](https://qiita.com/nakawodayo/items/2f2f98c9833659da66f0) N+1問題についての解決策を提示しています。このDataLoaderを使って先ほどの問題を解決します