こんにちは。@masatomix です。
最近、現場でGraphQLとRESTサーバを構築する機会があったので、そのとき得られたナレッジを備忘としてまとめているのですが、その続きです。
さてさて前回のコードを地道にリファクタリングやいじってナレッジを記録していきます。
今回やること
- スキーマファイルを外出しファイルにします。
- Unionを使うことで、実行結果によって戻り電文の型を変更してみます。
やってみる
スキーマ情報を外だしします
コード上のGraphQLのスキーマ情報ですが、
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
}
`;
ってコード上にベタがきしていましたが、
下記の通り別ファイルにしておくと見通しが良さそうです。
import { readFileSync } from 'fs';
import { join } from 'path';
export const typeDefs = gql(readFileSync(join(__dirname, 'schema.gql'), 'utf8'));
type Query {
users: [User]
companies: [Company]
user(id: ID!): User
company(id: ID!): Company
}
type Mutation {
createUser(user: UserInput): User
updateUser(user: UserInput): Response
}
type SuccessResponse {
success: Boolean!
}
type FailResponse {
success: Boolean!
message: String
error: String
}
union Response = SuccessResponse | FailResponse
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
}
unionを使って、実行結果によって戻り電文の型を変更する
たとえば処理が成功したときはsuccess
プロパティだけだけど、失敗したときはエラーメッセージなど追加情報プロパティを付与したいなどがあります。
具体的には、先日のコードでは、
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)
},
},
と戻りの型は固定的でしたが、失敗したときだけ message やerror をつけたいな、みたいなケースです。上記のようにスキーマは固定的のままで不要なプロパティは値をnullにする、でも良いのですが、戻り電文の型の仕様自体を変更したい場合は、 GraphQLのunionという機構を使います。
unionを使ったスキーマ定義はこんな感じ。
type SuccessResponse {
success: Boolean!
}
type FailResponse {
success: Boolean!
message: String
error: String
}
union Response = SuccessResponse | FailResponse
成功(SuccessResponse
)と失敗(FailResponse
)それぞれ別のプロパティの型を定義し、Response
はそれらのunionと定義しました。状況に応じてどっちか、みたいな意味です。
さてそれぞれのリゾルバです。
SuccessResponse: {
success: checkSuccess,
},
FailResponse: {
success: checkSuccess,
message: (parent: RestResponse) => {
return parent.result ?
parent.result.status :
parent.error.message
},
error: (parent: RestResponse) => {
return parent.result ?
parent.result.status :
JSON.stringify(parent.error)
},
},
ここまではいいですよね。成功の時は success
、失敗の時はその他のプロパティも返しています。で、Responseのリゾルバは、
Response: {// GraphQLでのunion型は、どちらの型を返すかのロジックを定義しないといけない
__resolveType(obj: { result: any; }, context: any, info: any) {
return obj.result ? 'SuccessResponse' : 'FailResponse'
}
},
としました。unionであるResponse
については、どちらの型を返すかの判定ロジックを記述しています。
コード全体をのせると以下の通りです。
import { gql } from 'apollo-server-express';
import {
AppUserEntityControllerApi,
AppUserRequestBody,
CompanyEntityControllerApi,
EntityModelAppUser,
EntityModelCompany
} from './generated'
import axios, { AxiosResponse } from 'axios';
import DataLoader from 'dataloader';
import { readFileSync } from 'fs';
import { join } from 'path';
export const typeDefs = gql(readFileSync(join(__dirname, 'schema.gql'), 'utf8'));
type UserInput = {
userId: string
firstName?: string
lastName?: string
email?: string
age?: number
companyCode?: string
}
type RestResponse = {
result?: AxiosResponse<EntityModelAppUser, any>;
error?: any;
}
const checkSuccess = (parent: RestResponse): boolean => {
return parent.result ?
parent.result.status >= 200 && parent.result.status < 300 :
false
}
const dataLoader = new DataLoader<string, EntityModelCompany>(async (ids): Promise<EntityModelCompany[]> => {
console.log('batch start')
console.log(ids)
const idsStr = ids.join(',')
const data = (await axios.get('http://localhost:8080/companies/search/findAllByIdIn',
{
params: { ids: idsStr }
})).data;
const companies: EntityModelCompany[] = data._embedded?.company
// 戻り値のデータは、引数のデータの順番通りとはかぎらない
// 引数のidsの順番にあうように、結果を並べ替える処理
// companyCodeとCompanyのMap
type CompanyMap = { [key: string]: EntityModelCompany }
const companyMap: CompanyMap = companies.reduce((map, company) => {
map[company.companyCode!] = company
return map
}, {} as CompanyMap)
return ids.map(id => companyMap[id])
})
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
},
user: async (parent: {}, args: { id: string }) => {
const p = new AppUserEntityControllerApi().getItemResourceAppuserGet(args.id)
const data = (await p).data
console.log(data)
return data
},
company: 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) => {
// DataLoaderを使う方法。ここではこのコードの登録(?)のみが行われ、
// 実際は全部のcompanyがそろった時点で、DataLoader.BatchLoadFn 関数がコールされる
const data = (await dataLoader.load(parent.companyCode!))
// この方式だと、PK指定でN+1問題が発生しちゃう
// 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}`,
},
Response: {// GraphQLでのunion型は、どちらの型を返すかのロジックを定義しないといけない
__resolveType(obj: { result: any; }, context: any, info: any) {
return obj.result ? 'SuccessResponse' : 'FailResponse'
}
},
SuccessResponse: {
success: checkSuccess,
},
FailResponse: {
success: checkSuccess,
message: (parent: RestResponse) => {
return parent.result ?
parent.result.status :
parent.error.message
},
error: (parent: RestResponse) => {
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 }): Promise<RestResponse> => {
try {
const userPromise = new AppUserEntityControllerApi()
.getItemResourceAppuserGet(args.user.userId)
const instance = (await userPromise).data
console.log(instance)
const userInput = args.user
console.log(userInput)
const body: AppUserRequestBody = {
userId: userInput.userId,
firstName: userInput.firstName ?? instance.firstName,
lastName: userInput.lastName ?? instance.lastName,
age: userInput.age ?? instance.age,
companyCode: userInput.companyCode ?? instance.companyCode,
email: userInput.email ?? instance.email,
}
try {
const updatePromise = new AppUserEntityControllerApi()
.postCollectionResourceAppuserPost(body)
const result = await updatePromise
return { result }
} catch (error) {
console.log(error)
return { error } // 例外のときもがんばって正常終了させる
}
} catch (error) {
console.log(error)
return { error } // 例外のときもがんばって正常終了させる
}
}
}
}
DataLoader とか見 慣れないコードはまた次回。
とりあえず投げてみましょう。
投げるときはこのように、
mutation Mutation($input1: UserInput) {
updateUser(user: $input1) {
... on SuccessResponse {
success
}
... on FailResponse {
error
message
success
}
}
}
成功した場合と失敗した場合を並べて記述することができます。戻り値は、
成功した場合
{
"data": {
"updateUser": {
"success": true
}
}
}
失敗した場合
{
"data": {
"updateUser": {
"error": "{\"message\":\"Request failed with status code 409\",\"name\":\"AxiosError\",\"stack\":\"AxiosError: d...割愛...{"kino1@example.com\\\"}\",\"url\":\"http://localhost:8080/users\"},\"code\":\"ERR_BAD_REQUEST\",\"status\":409}",
"message": "Request failed with status code 409",
"success": false
}
}
}
というように、プロパティのあるなしに違いがあるのがわかりますね!
ちなみにcurlでも投げておきましょう。
$ cat data.txt
{
"query":
"
mutation Mutation($input1: UserInput) {
updateUser(user: $input1) {
... on SuccessResponse {
success
}
... on FailResponse {
error
message
success
}
}
}
",
"variables":{
"input1": {
"companyCode": "com003",
"userId": "u001"
}
}
$ cat data.txt | curl --data @- \
--request POST \
--header 'content-type: application/json' \
--url http://localhost:3000/graphql | jq
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 329 100 41 100 288 1491 10474 --:--:-- --:--:-- --:--:-- 12185
{
"data": {
"updateUser": {
"success": true
}
}
}
$
できました。
まとめ
実際は、成功・失敗をこのように使い分けるか、それともシンプルに失敗は例外をスローしちゃうかはプロジェクトによるかとおもいます1。バックエンドのAPI(用のプロキシライブラリ)が例外をスローしちゃってるのに、わざわざtry/catchして正常終了に詰め替えているのも微妙かもしれません。
ひきつづき、こんどはバックエンドが例外ならGraphQLサーバも例外を投げてしまうケースや、上記で説明をペンディングしたDataLoaderなども記事にしていきます!
おつかれさまでした。
関連リンク
- GraphQLサーバとRESTサーバをさっと立ちあげて、実際に触ってみる。つづき。関連のあるデータの取得 前回の記事
- https://github.com/masatomix/spring-data-rest-example/releases/tag/practice_union 今回のソースです
- Spring Data REST 公式
- Apollo Server 公式
- Spring Data RESTの要点と利用方法 Spring Data RESTについての記事。参考にさせてもらいました。
- GraphQLにおけるN+1問題の解決策 N+1問題についての解決策を提示しています。このDataLoaderを使って先ほどの問題を解決します
-
成功失敗をunion型で判定する際、さらに失敗を種類によって型を分けて、何エラーが発生したかを呼び元でわかるようにするとか、?あんまないかなこのパタンは。 ↩