conf-talks

5 подходов построения GraphQL-схем

На данный момент существует 5 способов построения GraphQL-схем в NodeJS:

На мой вкус и на текущий момент самым крутым и продвинутым является type-graphql (по состоянию на апрель 2019).

Давай построим простую GraphQL-схему на каждом из этих подходов. Представим что у нас есть два типа Author и Article со следующими данными

export const articles = [
  { title: 'Article 1', text: 'Text 1', authorId: 1 },
  { title: 'Article 2', text: 'Text 2', authorId: 1 },
  { title: 'Article 3', text: 'Text 3', authorId: 2 },
  { title: 'Article 4', text: 'Text 4', authorId: 3 },
  { title: 'Article 5', text: 'Text 5', authorId: 1 },
];

export const authors = [
  { id: 1, name: 'User 1' },
  { id: 2, name: 'User 2' },
  { id: 3, name: 'User 3' },
];

graphql

Это базовая реализация спецификации GraphQL. Вы создаете свои типы, сразу указываете в них всю бизнес логику. Вы не можете редактировать и расширять типы. Пакету graphql это и не нужно. Его задача, жестко и квадратно задать конфигурацию схемы и уже быстро выполнять на ней запросы в рантайме.

Сперва вы импортируете нужные для вашей схемы классы типов:

import {
  GraphQLSchema,
  GraphQLObjectType,
  GraphQLString,
  GraphQLInt,
  GraphQLList,
  GraphQLNonNull,
} from 'graphql';
import { authors, articles } from './data';

Далее создаете тип для Автора:

const AuthorType = new GraphQLObjectType({
  name: 'Author',
  description: 'Author data',
  fields: () => ({
    id: { type: GraphQLInt },
    name: { type: GraphQLString },
  }),
});

Затем тип для Статьи. Особое внимание обратите на поле author, где используется созданный на предыдущем шаге тип Автор и указывается resolve-метод для получения данных автора согласно значению Article.authorId. Таким образом задается связь между двумя типами. В рамках REST API это бы звалось подзапросом - для каждой записи Статьи сделать подзапрос для получения данных Автора:

const ArticleType = new GraphQLObjectType({
  name: 'Article',
  description: 'Article data with related Author data',
  fields: () => ({
    title: {
      type: new GraphQLNonNull(GraphQLString),
    },
    text: {
      type: GraphQLString,
    },
    authorId: {
      type: new GraphQLNonNull(GraphQLInt),
      description: 'Record id from Author table',
    },
    author: {
      type: AuthorType,
      resolve: source => {
        const { authorId } = source;
        return authors.find(o => o.id === authorId);
      },
    },
  }),
});

После того как мы объявили узлы нашей схемы, нам надо описать вершину - точку входа. Корневой узел, который будет вам доступен на верхнем уровне вашей схемы для начала получения данных. Корневых узла в GraphQL-схеме три - это Query, Mutation и Subscriptions.

У нас схема простая - мы только читаем данные, поэтому объявляем только Query. Мы объявим два поля:

const Query = new GraphQLObjectType({
  name: 'Query',
  fields: {
    articles: {
      args: {
        limit: { type: GraphQLInt, defaultValue: 3 },
      },
      type: new GraphQLList(ArticleType),
      resolve: (_, args) => {
        const { limit } = args;
        return articles.slice(0, limit);
      },
    },
    authors: {
      type: new GraphQLList(AuthorType),
      resolve: () => authors,
    },
  },
});

Когда корневой тип Query создан, мы можем построить схему:

const schema = new GraphQLSchema({
  query: Query,
});

export default schema;

Полный код построения схемы на подходе graphql доступен в этом файле.

graphql-tools

graphql-tools использует под капотом graphql, только меняет принцип сборки вашей схемы. Вы отдельно объявляете все типы на SDL-языке (текстовый формат), и отдельно объявляете resolve-методы.

Вам нужно импортировать только метод makeExecutableSchema, который склеит ваши типы и resolve-методы:

import { makeExecutableSchema } from 'graphql-tools';
import { authors, articles } from './data';

Дальше вы объявляете типы:

const typeDefs = `
  "Author data"
  type Author {
    id: Int
    name: String
  }

  "Article data with related Author data"
  type Article {
    title: String!
    text: String
    "Record id from Author table"
    authorId: Int!
    author: Author
  }

  type Query {
    articles(limit: Int = 10): [Article]
    authors: [Author]
  }
`;

Затем для ключевых типов, вы объявляете методы получения данных. Объявляем как в типе Article надо получать автора, и как в корневом типе Query получить список статей и авторов:

const resolvers = {
  Article: {
    author: source => {
      const { authorId } = source;
      return authors.find(o => o.id === authorId);
    },
  },
  Query: {
    articles: (_, args) => {
      const { limit } = args;
      return articles.slice(0, limit);
    },
    authors: () => authors,
  },
};

Ну а теперь после того, как есть описание типов и resolve-методы их надо склеить вместе, чтоб получить схему. makeExecutableSchema занимается именно этим, создавая под капотом объекты типов как бы мы это делали в самом первом подходе graphql:

const schema = makeExecutableSchema({
  typeDefs,
  resolvers,
});

export default schema;

Полный код построения схемы на подходе graphql-tools доступен в этом файле.

Бонус статического анализа для graphql-tools

Рекомендую использовать пакет graphql-code-generator, который сможет вам генерировать тайпинги для резолверов исходя из SDL схемы.

Прописываете следующий конфиг codegen.yml:

overwrite: true
schema: src/**/*.gql
documents: null
generates:
  src/__generated__/graphql.ts:
    plugins:
      - "typescript-common"
      - "typescript-server"
      - "typescript-resolvers"
  src/__generated__/schema.graphql.json:
    plugins:
      - "introspection"
  src/__generated__/schema.graphql:
    plugins:
      - "graphql-codegen-schema-ast"

Выполняете команду:

gql-gen --config codegen.yml

А потом облагораживаете свои резолверы следующим образом:

import { IResolvers } from './__generated__/graphql';

const resolvers: IResolvers = {
  Article: {
    author: () => {},
  },
  Query: {
    articles: (_, args) => {},
    authors: () => authors,
  },
};

И получаете отменную проверку от TypeScript’а.

graphql-compose

graphql-compose - под капотом использует graphql. При этом позволяет конструировать схемы несколькими способами:

Но самое главное graphql-compose, позволяет модифицировать типы, перед тем как будет построена GraphQL-схема. Это открывает возможности генерировать ваши схемы, комбинировать несколько схем, либо редактировать уже существующие (например генерировать урезанную публичную схему из полной админской).

Строится GraphQL-схема следующим образом. Импортируем schemaComposer глобальный регистр типов, которые позволяет создавать новые типы множеством удобных способов:

import { schemaComposer } from 'graphql-compose';
import { authors, articles } from './data';

Метод createObjectTC() позволяет создать Object-тип с помощью SDL, как в graphql-tools. Давайте объявим простой тип Author:

const AuthorType = schemaComposer.createObjectTC(`
  "Author data"
  type Author {
    id: Int
    name: String
  }
`);

Также createObjectTC() позволяет создать Object-тип как в подходе с graphql в формате GraphQLObjectType. Но при этом добавляя кучу сахара:

const ArticleType = schemaComposer.createObjectTC({
  name: 'Article',
  description: 'Article data with related Author data',
  fields: {
    title: 'String!',
    text: 'String',
    authorId: {
      type: 'Int!',
      description: 'Record id from Author table',
    },
    author: {
      type: () => AuthorType,
      resolve: source => {
        const { authorId } = source;
        return authors.find(o => o.id === authorId);
      },
    },
  },
});

После того, как мы создали два типа Author и Article нам необходимо задать поля для корневого типа Query. Он уже сразу есть в схеме – schemaComposer.Query. Query это инстанс ObjectTypeComposer который можно редактировать, добавляя или удаляя существующие поля. ObjectTypeComposer имеет много полезных методов по чтению и редактированию конфигурации GraphQL-типа.

Чтобы добавить два новых поля articles и authors, мы воспользуемся методом addFields():

schemaComposer.Query.addFields({
  authors: {
    type: [AuthorType],
    resolve: () => authors,
  },
  articles: {
    args: {
      limit: { type: 'Int', defaultValue: 3 },
    },
    type: [ArticleType], // замениться на `new GraphQLList(ArticleType)`
    resolve: (_, args) => {
      const { limit } = args;
      return articles.slice(0, limit);
    },
  },
});

Ну а после того как мы добавили необходимые поля в Query, можно сгенерировать GraphQL-схему:

const schema = schemaComposer.buildSchema();

export default schema;

Полный код построения схемы на подходе graphql-compose доступен в этом файле.

Миграция с graphql-tools на graphql-compose

Мигрировать с graphql-tools на graphql-compose и получить все плюшки редактирования, модификации и генерации типов достаточно просто:

- import { makeExecutableSchema } from 'graphql-tools';
+ import { schemaComposer } from 'graphql-compose';

- const schema = makeExecutableSchema({
-  typeDefs,
-  resolvers,
- });
+ schemaComposer.addTypeDefs(typeDefs);
+ schemaComposer.addResolveMethods(resolvers);
+ const schema = schemaComposer.buildSchema();

Методы addTypeDefs и addResolveMethods могут вызываться много раз, позволяя собирать ваши схемы из разных модулей.

Рабочий код можно посмотреть в этом файле.

type-graphql

type-graphql - создает GraphQL-схему используя классы и декораторы (пока работает только c TypeScript). Из коробки предоставляются следующие виды декораторов:

Для работы с пакетом type-graphql необходимо использовать TypeScript c правильными настройками для декораторов в tsconfig.json:

{
  "compilerOptions": {
    "target": "es6", // при es5 не работает
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
}

Также необходимо использовать полифилл reflect-metadata, который позволит использовать TypeScript дефинишены в рантайме. Т.е. при построении GraphQL-схемы значения типов будут браться из тайпскрипта. Этот полифил подключается один раз в самом начале вашего кода.

Наш пример с Authors и Articles будет выглядеть следующим образом.

Сперва импортируем необходимые методы, декораторы и типы:

import 'reflect-metadata';
import {
  // methods
  buildSchemaSync,
  // decorators
  Root,
  Query,
  ObjectType,
  Field,
  FieldResolver,
  Arg,
  Resolver,
  // types
  ID,
} from 'type-graphql';
import { authors, articles } from './data';

Строим класс Author из которого будет сгенерирован наш GraphQL-тип благодаря декораторам @ObjectType и @Field. И создадим класс c резолверами AuthorResolver, который в Query добавит поле authors:

@ObjectType({ description: 'Author data' })
class Author {
  @Field(type => ID)
  id: number;

  @Field({ nullable: true })
  name: string;
}

@Resolver(of => Author)
class AuthorResolver {
  @Query(returns => [Author])
  authors(): Array<Author> {
    return authors as any;
  }
}

Похожим образом добавим класс Article и ArticleResolver, только в резолвере помимо добавления поля в Query через @Query декоратор, будет еще использоваться @FieldResolver() декоратор для описания метода получения данных автора для поля author:

@ObjectType({ description: 'Article data with related Author data' })
class Article {
  @Field()
  title: string;

  @Field({ nullable: true })
  text: string;

  @Field(type => ID)
  authorId: number;

  @Field({ nullable: true })
  author: Author;
}

@Resolver(of => Article)
class ArticleResolver {
  @Query(returns => [Article])
  articles(@Arg('limit', { nullable: true }) limit: number = 3): Array<Article> {
    return articles.slice(0, limit) as any;
  }

  @FieldResolver()
  author(@Root() article: Article) {
    return authors.find(o => o.id === article.authorId);
  }
}

Ну и затем останется только собрать GraphQL-схему с помощью метода buildSchemaSync:

const schema = buildSchemaSync({
  resolvers: [ArticleResolver, AuthorResolver],
  // Or it may be a GLOB mask:
  // resolvers: [__dirname + '/**/*.ts'],
});

export default schema;

Пример рабочего кода доступен в этом файле.

nexus

nexus – декларативный конструктор схемы со встроенным генератором дефинишенов для TypeScript. Продвигается Prisma‘ой.

На данный момент чтобы заработали тайпинги, вы должны запустить сервер. Он будет генерировать вам дефинишены типов, которые будет подхватывать TypeScript. Поэтому для удобной работы в dev-режиме рекомендуется использовать nodemon или более шустрый ts-node-dev для перезапуска сервера при изменении файлов. Поменяли файл схемы, перезапустился сервер, перегенерились тайпинги, TypeScript подхватил изменения и перевалидировал код.

Для построения простой схемы Article и Author необходимо подключить пакет nexus:

import { objectType, queryType, intArg, makeSchema } from 'nexus';
import { authors, articles } from './data';

Далее объявляем типы схемы очень интересным способом, используя функцию objectType для создания типов, в котором используется метод definition(t) для определения полей:

const Author = objectType({
  name: 'Author',
  definition(t) {
    t.int('id', { nullable: true });
    t.string('name', { nullable: true });
  },
});

const Article = objectType({
  name: 'Article',
  definition(t) {
    t.string('title');
    t.string('text', { nullable: true });
    t.int('authorId', { description: 'Record id from Author table' });
    t.field('author', {
      nullable: true,
      type: 'Author',
      resolve: source => {
        const { authorId } = source;
        return authors.find(o => o.id === authorId) as any;
      },
    });
  },
});

А вот для конструирования корневых типов используется специальные функции, в нашем случае queryType:

const Query = queryType({
  definition(t) {
    t.list.field('articles', {
      nullable: true,
      type: Article,
      args: {
        limit: intArg({ default: 3, required: true })
      },
      resolve: (_, args) => {
        const { limit } = args;
        return articles.slice(0, limit);
      },
    });
    t.list.field('authors', {
      nullable: true,
      type: Author,
      resolve: () => authors,
    });
  },
});

Ну а дальше используется метод makeSchema в который необходимо передать все инстансы наших типов – не очень удобно если в вашей схеме сотни типов. Наверняка в будущем придумают что-нибудь получше. Также вы обязательно должны передать пути, куда будут генерироваться тайпинги при запуске сервера:

const schema = makeSchema({
  types: [Query, Article, Author],
  outputs: {
    schema: __dirname + '/nexus-generated/schema.graphql',
    typegen: __dirname + '/nexus-generated/typings.ts',
  },
});

export default schema;

Рабочий код можно посмотреть в этом файле.

В итоге что имеем по подходам

graphql graphql-tools graphql-compose type-graphql nexus
Дата создания 2012/2015 2016.04 2016.07 2018.02 2018.11
GitHub starts
NPM downloads
Язык для разработки схемы JS, TS, Flow JS, TS, Flow JS, TS, Flow TS JS, TS
Schema-first (SDL-first) - да да - -
Code-first да - да да да
Редактирование GraphQL-типов - - да - -
Статическая типизация в резолверах 1/5
нет
3/5
через сторонние пакеты
2/5
кроме аргументов
5/5
из коробки через рефлексию
4/5
через генерацию файлов из коробки
Простота в изучении 3/5 5/5 2/5 4/5 4/5
Чистота в коде схемы 1/5 5/5 4/5 4/5 3/5
Типы полей (модификатор по умолчанию) optional optional optional Required Required

Обратите внимание, пакет type-graphql на данный момент не имеет возможности использовать Namespaced-мутации.

Также рекомендую прочитать хорошую статью про разницу в подходах Schema-first и Code-first

На закуску — Генераторы

Есть решения, которые позволяют вам генерировать схемы с уже имеющихся баз данных или ORM-моделей. Это совершенно отдельная каста инструментов, и чистыми инструментами по созданию схем их уже назвать нельзя. Т.к. они ограничены БД/моделью – вы не конструируете схему, она для вас генерируется.

Обсуждение генераторов происходит в этом issue.

Есть что добавить? Откройте пожалуйста Pull Request.