【GraphQL】GraphQLに入門してみた
モチベーション
- 実務でGraphQLが必要になったので素振りをする
- 一年位前にNest.js + Prisma + GraphQLを詰め込んで学習してパンクした記憶がある
- Next.js + ApolloServer + Prisma + GraphQLでキャッチアップを進めてみる
ここら辺が素振りに適していそう?取り組んでみる
取り組みが完了したので、一旦振り返りと公式ドキュメントで疑問点を解消していく
GraphQLとは?については割愛。
こういった骨子の部分に関してはたくさん良書が出ているのでキャッチアップはしやすいはず。
過去、自分がなぜGraphQLに対して混乱したのか整理しながら色々調査していく。
混乱したポイント
GraphQLの実装方法が複数あるのが過去GraphQLに取り組んだ時に混乱した原因だと思った。例えばGraphQLサーバーだけでもNodeランタイムで使うならapollo-server・express-graphql・graphql-yogaがある。
他にもgraphql-toolsを使っているか、codegenしているかなど関連ライブラリの使用有無で無数の記述例が生まれてしまっていて「これ!」という正解がないのがキャッチアップする側としては辛かった。
開発しているプロダクトがあればそれが正解になるのである程度道筋が見えてくると思うが、apollo-serverに関してはv4で変わったし、express-graphqlはdeprecatedになっているので変化が激しいのだろうか。
express-graphql is deprecated
express-graphql
は非推奨でgraphql-http
を使ってください。とREADMEに記載されていたので少しgraphql-http
について調べてみる
npmの方でも詳細が記載されていたので引用する。
This package is no longer maintained. We recommend using
graphql-http
instead. Please consult the migration document https://github.com/graphql/graphql-http#migrating-express-grpahql.
graphql-http
GraphQLのドキュメントにも実装の例として記載されていた
公式のgraphql-httpパッケージは、完全に準拠したGraphQLサーバーを作成する簡単な方法を提供する。このパッケージには、Node.jsネイティブhttp用のハンドラと、Express、Fastify、Koaのような有名なフレームワーク用のハンドラ、そしてDenoやBunのような異なるランタイム用のハンドラがあります。
express
・graphql-http
・graphql
・ruru
を使ったチュートリアル
細かいけれど上記のチュートリアルで気になったのはrootValueのところ。リゾルバの登録をしていると思うけれど多分ApolloServerでも見たことがないかも。
var express = require("express")
var { createHandler } = require("graphql-http/lib/use/express")
var { buildSchema } = require("graphql")
var { ruruHTML } = require("ruru/server")
// Construct a schema, using GraphQL schema language
var schema = buildSchema(`
type Query {
hello: String
}
`)
// The root provides a resolver function for each API endpoint
var root = {
hello: () => {
return "Hello world!"
},
}
var app = express()
// Create and use the GraphQL handler.
app.all(
"/graphql",
createHandler({
schema: schema,
rootValue: root, // ここ
})
)
// Serve the GraphiQL IDE.
app.get("/", (_req, res) => {
res.type("html")
res.end(ruruHTML({ endpoint: "/graphql" }))
})
// Start the server at port
app.listen(4000)
console.log("Running a GraphQL API server at http://localhost:4000/graphql")
想定していたのは以下。graphql-http
のREADMEから拝借。
schemaにリゾルバをアタッチしている
import { GraphQLSchema, GraphQLObjectType, GraphQLString } from 'graphql';
/**
* Construct a GraphQL schema and define the necessary resolvers.
*
* type Query {
* hello: String
* }
*/
const schema = new GraphQLSchema({
query: new GraphQLObjectType({
name: 'Query',
fields: {
hello: {
type: GraphQLString,
resolve: () => 'world', // ここ
},
},
}),
});
リゾルバっぽく振る舞っているrootValueとschemaにアタッチしたresolveは何が違うのか?
以下を起点にしてrootValueとschemaにアタッチしたresolveについて見ていく。schemaを引数にとってgraphqlHTTPしている。
graphqlHTTPはイコールcreateHandler。createHandlerは別のファイルで管理されているので移動する。
createHandlerは引数にschemaとrootValueをとっていることがわかる。またargsにrootValueとschemaを詰めていることが全体を見てわかる。argsを引数にexecuteしている。
execute関数を見るために、ここからはgraphql-jsに飛ぶ。executeの読み下ろしていくとexecuteFieldに辿り着く。exeContextにschemaが入っていて、sourceがrootValueになっている。
トランスパイルされたexecuteField
function executeField(exeContext, parentType, source, fieldNodes, path) {
var _fieldDef$resolve;
const fieldDef = getFieldDef(exeContext.schema, parentType, fieldNodes[0]);
if (!fieldDef) {
return;
}
const returnType = fieldDef.type;
const resolveFn =
(_fieldDef$resolve = fieldDef.resolve) !== null &&
_fieldDef$resolve !== void 0
? _fieldDef$resolve
: exeContext.fieldResolver;
const info = buildResolveInfo(
exeContext,
fieldDef,
fieldNodes,
parentType,
path,
); // Get the resolve function, regardless of if its result is normal or abrupt (error).
try {
// Build a JS object of arguments from the field.arguments AST, using the
// variables scope to fulfill any variable references.
// TODO: find a way to memoize, in case this field is within a List type.
const args = (0, _values.getArgumentValues)(
fieldDef,
fieldNodes[0],
exeContext.variableValues,
); // The resolve function's optional third argument is a context value that
// is provided to every resolve function within an execution. It is commonly
// used to represent an authenticated user, or request-specific caches.
const contextValue = exeContext.contextValue;
const result = resolveFn(source, args, contextValue, info);
let completed;
if ((0, _isPromise.isPromise)(result)) {
completed = result.then((resolved) =>
completeValue(exeContext, returnType, fieldNodes, info, path, resolved),
);
} else {
completed = completeValue(
exeContext,
returnType,
fieldNodes,
info,
path,
result,
);
}
if ((0, _isPromise.isPromise)(completed)) {
// Note: we don't rely on a `catch` method, but we do expect "thenable"
// to take a second callback for the error case.
return completed.then(undefined, (rawError) => {
const error = (0, _locatedError.locatedError)(
rawError,
fieldNodes,
(0, _Path.pathToArray)(path),
);
return handleFieldError(error, returnType, exeContext);
});
}
return completed;
} catch (rawError) {
const error = (0, _locatedError.locatedError)(
rawError,
fieldNodes,
(0, _Path.pathToArray)(path),
);
return handleFieldError(error, returnType, exeContext);
}
}
※コードを追いやすくするためにnode_modulesの中身を見ています。
function getFieldDef(schema, parentType, fieldNode) {
const fieldName = fieldNode.name.value;
if (
fieldName === _introspection.SchemaMetaFieldDef.name &&
schema.getQueryType() === parentType
) {
return _introspection.SchemaMetaFieldDef;
} else if (
fieldName === _introspection.TypeMetaFieldDef.name &&
schema.getQueryType() === parentType
) {
return _introspection.TypeMetaFieldDef;
} else if (fieldName === _introspection.TypeNameMetaFieldDef.name) {
return _introspection.TypeNameMetaFieldDef;
}
return parentType.getFields()[fieldName];
}
const fieldDef = getFieldDef(exeContext.schema, parentType, fieldNodes[0]);
条件に合っていれば_introspection.〇〇
を返す。そうでなければparentType.getFields()[fieldName]
を返す。ちなみにintrospectionは以下のファイルを指している。
const resolveFn =
(_fieldDef$resolve = fieldDef.resolve) !== null &&
_fieldDef$resolve !== void 0
? _fieldDef$resolve
: exeContext.fieldResolver;
const result = resolveFn(source, args, contextValue, info);
fieldDef.resolve
があればfieldDef.resolve
の処理をするし、そうでなければexeContext.fieldResolver
をする。exeContext.fieldResolver
は初期値がなければdefaultFieldResolver
になるので下記に該当する
関数であればsource[info.fieldName](args, contextValue, info)
を返すし、そうでなければpropertyを返す。
まとめ
リゾルバっぽく振る舞っているrootValueとschemaにアタッチしたresolveは何が違うのか調査してきたが、具体的には以下のように違うことがわかった。
- 優先度が違う
- schemaにアタッチされたresolveが優先される
- 引数が異なる
- schemaにアタッチされたresolveは
resolve: (_source, _args, _context, { schema }) => schema
などintrospectionファイルの形になる、rootValueはsource[info.fieldName](args, contextValue, info)
になる
- schemaにアタッチされたresolveは
で、ここまでschemaとresolveの関係性を見てきたが、アールエフェクトで素振りをした時に触ったApolloServerではどのように実装されているのか気になった。rootValueを使っている?schemaを使っている?
ApolloServerとSchemaの関係性
またまた混乱、①と②は何が違うのか調査する。
import { makeExecutableSchema } from "@graphql-tools/schema";
import { typeDefs } from "./type-defs";
import { resolvers } from "./resolvers";
import { ApolloServer } from "@apollo/server";
export const schema = makeExecutableSchema({
typeDefs,
resolvers,
});
const apolloServer = new ApolloServer({ schema }); // ①
const apolloServer = new ApolloServer({ typeDefs, resolvers }); // ②
ドキュメントを見る限りスキーマの作成にgraphql-toolsのmakeExecutableSchemaのような関数を使用するか、typeDef + resolversを引数に当てること、両方とも正解ぽい。exampleではmake ExecutableSchemaは使っていない。以下になる。前述したどこかのタイミングでresolverをアタッチするのでコードを追ってみる。
import { ApolloServer } from '@apollo/server';
const server = new ApolloServer({
typeDefs,
resolvers,
});
schemaへのアタッチはmakeExecutableSchemaで行っていた。 コメントアウトもあった。要約するとmakeExecutableSchemaを使いこなしたい場合は①で実装して、とのこと。なので特に理由がなければ②の方法。ApolloServerのexampleと同じで問題ない。
For convenience, we allow you to pass a few options that we pass through
to a particular version of@graphql-tools/schema
's
makeExecutableSchema
. If you want to use more of this function's
features or have more control over the version of the packages used, just
call it yourself likenew ApolloServer({schema: makeExecutableSchema(...)})
.
graphql-yogaでもmakeExecutableSchemaを使っている
補足①
2020年にtypeDefs + resolversだけじゃなくschemaを入れてくれと言うissueが上がっていた
補足②
そうそう、これが発生すると思った。
graphql-jsのexecuteFieldを理解していればわかる。
アンサーも調査した内容と合致していたのでメモ
I'm wondering why my arguments seem to be switched around inside my GraphQL resolver. I'm using express-graphql.
makeExecutableSchemaを見ようと思ったらHireroo社の記事がドンピシャで解説されていた。と言うより execute関数についても触れていた。少し自分が飲み込みやすいように咀嚼していく。
field resolver
名前があったのか
ソースコード上では field resolver という言葉がよくでてきており、以下のような GraphQLFieldResolver 型になります。 この型はsource, args, context, infoを引数にとり、フィールドの値を返す関数になります。
ある程度GraphQL関連の記事を見る上で混乱していたところを解消できたので、また個人開発してみて都度疑問が生まれたら解消していく。次は下記に触れていく。
以下GraphQL公式にあるトレーニング一覧からApolloをやってみる
__typename
色々実装している中で__typenameってbranded typeぽいなと思ったので調べてみました。
Apollo Clientは結果をキャッシュする際に__typenameに依存するため、すべてのクエリのすべてのオブジェクトに自動的に__typenameが含まれます。
Apolloの公式ドキュメントに書いてあった。結論を言うとキャッシュのためにApollo Clientが自動で付与する仕様になっているのか