こんにちは。@masatomix です。
最近、現場でGraphQLとRESTサーバを構築する機会があったので、そのとき得られたナレッジを備忘としてまとめているのですが、その続きです。
前回の初期状態から、すこし手を入れていきます。
TL;DR
- Spring Data RESTが自動生成するRESTサービスについて、リンクになっているところを微調整します。
- ついでに、よけいなSQL文を発行しないよう微調整します
- GraphQL がQueryで指定するプロパティに応じて、必要なときだけBackendにデータを取りに行くようにする
Spring Data RESTが自動生成するRESTの戻り値について、FKがオブジェクトじゃなくてリンクになっている
今回もこのデータ構造です。
さて、前回も呼んでみた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サービスは
この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などを追加したり、しています。
修正後のコードはこんな感じ。
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 へアクセス。
使い方の詳細は割愛しますが、左メニューで自分が定義したQueryやMutationを選んで、ほしいプロパティを指定することで GraphQLのリクエストを送信することができます。
例1 Userのみでcompanyを含めない場合
下記のように、companyプロパティを外して実行してみます。このまま実行しても良いですし、
下記のところからcurlのコマンドも取得できるので、、、
今回は 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も多数発行してしまうというある意味本末転倒になっているので、次回はここを見直してみます。
お疲れさまでした。
関連リンク
- https://github.com/masatomix/spring-data-rest-example/releases/tag/init2 今回のソースです
- Spring Data REST 公式
- Apollo Server 公式
- Spring Data RESTの要点と利用方法 Spring Data RESTについての記事。参考にさせてもらいました。
- GraphQLにおけるN+1問題の解決策 N+1問題についての解決策を提示しています。このDataLoaderを使って先ほどの問題を解決します