Closed9

【GraphQL】GraphQLに入門してみた

1zushun1zushun

モチベーション

  • 実務でGraphQLが必要になったので素振りをする
  • 一年位前にNest.js + Prisma + GraphQLを詰め込んで学習してパンクした記憶がある
  • Next.js + ApolloServer + Prisma + GraphQLでキャッチアップを進めてみる
1zushun1zushun

GraphQLとは?については割愛。
こういった骨子の部分に関してはたくさん良書が出ているのでキャッチアップはしやすいはず。
過去、自分がなぜGraphQLに対して混乱したのか整理しながら色々調査していく。

混乱したポイント

GraphQLの実装方法が複数あるのが過去GraphQLに取り組んだ時に混乱した原因だと思った。例えばGraphQLサーバーだけでもNodeランタイムで使うならapollo-serverexpress-graphqlgraphql-yogaがある。

他にもgraphql-toolsを使っているか、codegenしているかなど関連ライブラリの使用有無で無数の記述例が生まれてしまっていて「これ!」という正解がないのがキャッチアップする側としては辛かった。

開発しているプロダクトがあればそれが正解になるのである程度道筋が見えてくると思うが、apollo-serverに関してはv4で変わったし、express-graphqlはdeprecatedになっているので変化が激しいのだろうか。

1zushun1zushun

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.

https://www.npmjs.com/package/express-graphql

graphql-http

GraphQLのドキュメントにも実装の例として記載されていた

公式のgraphql-httpパッケージは、完全に準拠したGraphQLサーバーを作成する簡単な方法を提供する。このパッケージには、Node.jsネイティブhttp用のハンドラと、Express、Fastify、Koaのような有名なフレームワーク用のハンドラ、そしてDenoやBunのような異なるランタイム用のハンドラがあります。

https://graphql.org/graphql-js/graphql-http/

expressgraphql-httpgraphqlruruを使ったチュートリアル

https://graphql.org/graphql-js/running-an-express-graphql-server/

細かいけれど上記のチュートリアルで気になったのは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', // ここ
      },
    },
  }),
});

https://github.com/graphql/graphql-http?tab=readme-ov-file#create-a-graphql-schema

1zushun1zushun

リゾルバっぽく振る舞っているrootValueとschemaにアタッチしたresolveは何が違うのか?

以下を起点にしてrootValueとschemaにアタッチしたresolveについて見ていく。schemaを引数にとってgraphqlHTTPしている。

https://github.com/graphql/graphql-http/blob/main/implementations/express-graphql/index.mjs

graphqlHTTPはイコールcreateHandler。createHandlerは別のファイルで管理されているので移動する。

https://github.com/graphql/graphql-http/blob/main/src/use/express.ts#L99

createHandlerは引数にschemaとrootValueをとっていることがわかる。またargsにrootValueとschemaを詰めていることが全体を見てわかる。argsを引数にexecuteしている。

https://github.com/graphql/graphql-http/blob/main/src/handler.ts#L730

execute関数を見るために、ここからはgraphql-jsに飛ぶ。executeの読み下ろしていくとexecuteFieldに辿り着く。exeContextにschemaが入っていて、sourceがrootValueになっている。

https://github.com/graphql/graphql-js/blob/main/src/execution/execute.ts#L594

トランスパイルされた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は以下のファイルを指している。

https://github.com/graphql/graphql-js/blob/main/src/type/introspection.ts

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になるので下記に該当する

https://github.com/graphql/graphql-js/blob/main/src/execution/execute.ts#L1648

関数であれば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)になる
1zushun1zushun

で、ここまで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,
});

https://www.apollographql.com/docs/apollo-server/api/apollo-server/#schema

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 like new ApolloServer({schema: makeExecutableSchema(...)}).

https://github.com/apollographql/apollo-server/blob/5cf29285f47a34f51d462b9653d27dd45b674f70/packages/server/src/ApolloServer.ts#L694

graphql-yogaでもmakeExecutableSchemaを使っている

https://github.com/dotansimha/graphql-yoga/blob/main/packages/graphql-yoga/src/schema.ts

補足①

2020年にtypeDefs + resolversだけじゃなくschemaを入れてくれと言うissueが上がっていた

https://github.com/apollographql/apollo-server/issues/4684

補足②

そうそう、これが発生すると思った。
graphql-jsのexecuteFieldを理解していればわかる。
アンサーも調査した内容と合致していたのでメモ

I'm wondering why my arguments seem to be switched around inside my GraphQL resolver. I'm using express-graphql.

https://stackoverflow.com/questions/48630023/wrong-order-of-graphql-resolver-arguments-root-args-context

1zushun1zushun

makeExecutableSchemaを見ようと思ったらHireroo社の記事がドンピシャで解説されていた。と言うより execute関数についても触れていた。少し自分が飲み込みやすいように咀嚼していく。

field resolver

名前があったのか

ソースコード上では field resolver という言葉がよくでてきており、以下のような GraphQLFieldResolver 型になります。 この型はsource, args, context, infoを引数にとり、フィールドの値を返す関数になります。

https://hireroo.io/journal/tech/graphql-internal-first-part

https://hireroo.io/journal/tech/graphql-internal-latter-part

1zushun1zushun

__typename

色々実装している中で__typenameってbranded typeぽいなと思ったので調べてみました。

Apollo Clientは結果をキャッシュする際に__typenameに依存するため、すべてのクエリのすべてのオブジェクトに自動的に__typenameが含まれます。

https://www.apollographql.com/docs/apollo-server/schema/schema/#the-__typename-field

Apolloの公式ドキュメントに書いてあった。結論を言うとキャッシュのためにApollo Clientが自動で付与する仕様になっているのか

このスクラップは4ヶ月前にクローズされました