LoginSignup
9
5
お題は不問!Qiita Engineer Festa 2024で記事投稿!
Qiita Engineer Festa20242024年7月17日まで開催中!

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

Last updated at Posted at 2024-06-12

こんにちは。@masatomix です。

最近、現場でGraphQLとRESTサーバを構築する機会があったので、そのとき得られたナレッジを備忘としてまとめているのですが、その続きです。

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

TL;DR

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

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

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

image-20240610114259607.png

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

$ curl  http://localhost:8080/users | jq
{
  "_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
このManyToOneの関連をリンクで表現する仕様のようですね。なんだか独特です。
一般的に、関連の表現方法は、

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

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

修正前

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;
}

修正後

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を発行するようにしています。

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

$ curl  http://localhost:8080/users | jq
{
  "_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",が追加されました。さらに修正前はログに

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が発行されていましたが、修正後は

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

BFF も修正する

つづいてBFFです。

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

$ 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

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

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サーバを起動します。

$ 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

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

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

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

Pasted image 20240612234842.png

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

Pasted image 20240612233937.png

今回は 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  }\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
{
  "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
{
  "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も多数発行してしまうというある意味本末転倒になっているので、次回はここを見直してみます。

お疲れさまでした。

関連リンク

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

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

9
5
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
9
5