GraphQL сейчас, без преувеличения, это — последний писк IT-моды. И если вы пока не знаете о том, что это за технология, о том, как ей пользоваться, и о том, почему она может вам пригодиться, значит статья, перевод которой мы сегодня публикуем, написана специально для вас. Здесь мы разберём основы GraphQL на примере реализации схемы данных для API компании, которая занимается попкорном. В частности, поговорим о типах данных, запросах и мутациях.



Что такое GraphQL?


GraphQL — это язык запросов, используемый клиентскими приложениями для работы с данными. C GraphQL связано такое понятие, как «схема» — это то, что позволяет организовывать создание, чтение, обновление и удаление данных в вашем приложении (то есть — перед нами четыре базовые функции, используемые при работе с хранилищами данных, которые обычно обозначают акронимом CRUD — create, read, update, delete).

Выше было сказано, что GraphQL используется для работы с данными в «вашем приложении», а не «в вашей базе данных». Дело в том, что GraphQL — это система, независимая от источников данных, то есть, для организации её работы неважно — где именно хранятся данные.

Если взглянуть, ничего не зная о GraphQL, на название этой технологии, то может показаться, что перед нами что-то очень сложное и запутанное. В названии технологии имеется слово «Graph». Означает ли это, что для того, чтобы её освоить, придётся учиться работать с графовыми базами данных? А то, что в названии есть «QL» (что может значить «query language», то есть — «язык запросов»), означает ли, что тем, кто хочет пользоваться GraphQL, придётся осваивать совершенно новый язык программирования?

Эти страхи не вполне оправданы. Для того чтобы вас успокоить — вот жестокая правда об этой технологии: она представляет собой всего лишь приукрашенные GET или POST запросы. В то время как GraphQL, в целом, вводит некоторые новые концепции, касающиеся организации данных и взаимодействия с ними, внутренние механизмы этой технологии полагаются на старые добрые HTTP-запросы.

Переосмысление технологии REST


Гибкость — это то, что отличает технологию GraphQL от широко известной технологии REST. При использовании REST, если всё сделано правильно, конечные точки обычно создают с учётом особенностей некоего ресурса или типа данных приложения.

Например, при выполнении GET-запроса к конечной точке /api/v1/flavors ожидается, что она отправит ответ, выглядящий примерно так:

[
  {
   "id": 1,
    "name": "The Lazy Person's Movie Theater",
    "description": "That elusive flavor that you begrudgingly carted yourself to the theater for, now in the comfort of your own home, you slob!"
  }, {
    "id": 2,
    "name": "What's Wrong With You Caramel",
    "description": "You're a crazy person that likes sweet popcorn. Congratulations."
  }, {
    "id": 3,
    "name": "Gnarly Chili Lime",
    "description": "The kind of popcorn you make when you need a good smack in the face."}
]

В таком ответе ничего катастрофически неправильного нет, но подумаем о пользовательском интерфейсе, или скорее о том, как мы намереваемся потреблять эти данные.

Если мы хотим вывести в интерфейсе простой список, который содержит лишь названия имеющихся видов попкорна (и ничего другого), то этот список может выглядеть так, как показано ниже.


Список видов попкорна

Видно, что тут мы попали в непростую ситуацию. Мы вполне можем решить не использовать поле description, но собираемся ли мы сидеть сложа руки и делать вид, будто мы не отправляли это поле клиенту? А что нам ещё остаётся делать? А когда нас, через несколько месяцев, спросят о том, почему приложение так медленно работает у пользователей, нам останется лишь дать дёру и больше не встречаться с руководством компании, для которой мы сделали это приложение.

На самом деле, то, что сервер отправляет в ответ на запрос клиента ненужные данные, это не полностью наша вина. REST — это механизм получения данных, который можно сравнить с рестораном, в котором официант спрашивает посетителя: «Чего вы хотите?», и, не особенно обращая внимание на его пожелания, говорит ему: «Я принесу вам то, что у нас есть».

Если же отбросить в сторону шутки, то в реальных приложениях подобное может вести к проблемным ситуациям. Например, мы можем выводить различные дополнительные сведения о каждом виде попкорна, наподобие сведений о цене, информации о производителе или диетологических сведений («Веганский попкорн!»). При этом негибкие конечные точки REST сильно усложняют получение специфических данных о конкретных видах попкорна, что ведёт к неоправданно высокой нагрузке на системы и к тому, что получающиеся решения оказываются далеко не такими, которыми разработчики могли бы гордиться.

Как технология GraphQL улучшает то, для чего использовалась технология REST


При поверхностном анализе вышеописанной ситуации может показаться, что перед нами всего лишь незначительная проблема. «Что плохого в том, что мы отправляем клиенту ненужные данные?». Для того чтобы понять масштабы, в которых «ненужные данные» могут стать большой проблемой, вспомним о том, что технология GraphQL была разработана компанией Facebook. Этой компании приходится обслуживать миллионы запросов в секунду.

Что это значит? А то, что при таких объёмах значение имеет каждая мелочь.

GraphQL, если продолжить аналогию с рестораном, вместо того, чтобы «нести» посетителю «то, что есть», приносит именно то, что посетитель заказывает.

Мы можем получить от GraphQL ответ, ориентированный на тот контекст, в котором используются данные. При этом нам не нужно добавлять в систему «одноразовые» точки доступа, выполнять множество запросов или писать многоэтажные условные конструкции.

Как работает GraphQL?


Как мы уже говорили, GraphQL, для передачи данных клиенту и получения их от него, полагается на простые GET или POST-запросы. Если подробнее рассмотреть эту мысль, то оказывается, что в GraphQL есть два вида запросов. К первому виду относятся запросы на чтение данных, которые в терминологии GraphQL называются просто запросами (query) и относятся к букве R (reading, чтение) акронима CRUD. Запросы второго вида — это запросы на изменение данных, которые в GraphQL называют мутациями (mutation). Они относятся к буксам C, U и D акронима CRUD, то есть — с их помощью выполняют операции создания (create), обновления (update) и удаления (delete) записей.

Все эти запросы и мутации отправляют на URL GraphQL-сервера, который, например, может выглядеть как https://myapp.com/graphql, в виде GET или POST-запросов. Подробнее об этом мы поговорим ниже.

Запросы GraphQL


Запросы GraphQL — это сущности, представляющие собой запрос к серверу на получение неких данных. Например, у нас есть некий пользовательский интерфейс, который мы хотим заполнить данными. За этими данными мы и обращаемся к серверу, выполняя запрос. При использовании традиционных REST API наш запрос принимает вид GET-запроса. При работе с GraphQL используется новый синтаксис построения запросов:

{
  flavors {
    name
  }
}

Это что, JSON? Или JavaScript-объект? Ни то и ни другое. Как мы уже говорили, в названии технологии GraphQL две последние буквы, QL, означают «query language», то есть — язык запросов. Речь идёт, в буквальном смысле, о новом языке написания запросов на получение данных. Звучит всё это как описание чего-то довольно сложного, но на самом деле ничего сложного тут нет. Разберём вышеприведённый запрос:

{
  // Сюда помещают описания полей, которые нужно получить.

}

Все запросы начинаются с «корневого запроса», а то, что нужно получить в ходе выполнения запроса, называется полем. Для того чтобы избавить себя от путаницы, лучше всего называть эти сущности «полями запроса в схеме». Если вам такое наименование кажется непонятным — подождите немного — ниже мы подробнее поговорим о схеме. Здесь мы, в корневом запросе, запрашиваем поле flavors.

{
  flavors {
    // Вложенные поля, которые мы хотим получить для каждого значения flavor.

  }
}

Запрашивая некое поле, мы, кроме того, должны указать вложенные поля, которые нужно получить для каждого объекта, который приходит в ответе на запрос (даже если ожидается, что в ответ на запрос придёт всего один объект).

{
  flavors {
    name
  }
}

Что в итоге получится? После того, как мы отправим такой запрос GraphQL-серверу, мы получим хорошо оформленный аккуратный ответ наподобие следующего:

{
  "data": {
    "flavors": [
      { "name": "The Lazy Person's Movie Theater" },
      { "name": "What's Wrong With You Caramel" },
      { "name": "Gnarly Chili Lime" }
    ]
  }
}

Обратите внимание на то, что здесь нет ничего лишнего. Для того чтобы было понятнее — вот ещё один запрос, выполняемый для получения данных на другой странице приложения:

{
  flavors {
    id
    name
    description
  }
}

В ответ на этот запрос мы получим следующее:

{
  "data": {
    "flavors": [
      { "id": 1, "name": "The Lazy Person's Movie Theater", description: "That elusive flavor that you begrudgingly carted yourself to the theater for, now in the comfort of your own home, you slob!" },
      { "id": 2, "name": "What's Wrong With You Caramel", description: "You're a crazy person that likes sweet popcorn. Congratulations." },
      { "id": 3, "name": "Gnarly Chili Lime", description: "A friend told me this would taste good. It didn't. It burned my kernels. I haven't had the heart to tell him." }
    ]
  }
}

Как видите, GraphQL — очень мощная технология. Обращаемся мы к одной и той же конечной точке, а ответы на запросы в точности соответствуют тому, что нужно для наполнения той страницы, с которой выполняются эти запросы.

Если нам нужно получить лишь один объект flavor, то мы можем воспользоваться тем фактом, что GraphQL умеет работать с аргументами:

{
  flavors(id: "1") {
    id
    name
    description
  }
}

Тут мы жёстко задали в коде конкретный идентификатор (id) объекта, сведения о котором нам нужны, но в подобных случаях можно использовать и динамические идентификаторы:

query getFlavor($id: ID) {
  flavors(id: $id) {
    id
    name
    description
  }
}

Здесь, в первой строке, мы даём запросу имя (имя выбирается произвольным образом, getFlavor можно заменить на нечто вроде pizza, и запрос останется работоспособным) и объявляем переменные, которые ожидает запрос. В данном случае предполагается, что запросу будет передан идентификатор (id) скалярного типа ID (о типах мы поговорим ниже).

Независимо от того, статический или динамический id используется при выполнении запроса, вот как будет выглядеть ответ на подобный запрос:

{
  "data": {
    "flavors": [
      { "id": 1, "name": "The Lazy Person's Movie Theater", description: "That elusive flavor that you begrudgingly carted yourself to the theater for, now in the comfort of your own home, you slob!" }
    ]
  }
}

Как видите, всё устроено очень удобно. Вероятно, вы уже начинаете размышлять о применении GraphQL в собственном проекте. И, хотя то, о чём мы уже говорили, выглядит замечательно, красота GraphQL по-настоящему проявляется там, где работают с вложенными полями. Предположим, что в нашей схеме есть ещё одно поле, которое называется nutrition и содержит сведения о пищевой ценности разных видов попкорна:

{
  flavors {
    id
    name
    nutrition {
      calories
      fat
      sodium
    }
  }
}

Может показаться, что в нашем хранилище данных, в каждом объекте flavor, будет содержаться вложенный объект nutrition. Но это не совсем так. Используя GraphQL можно комбинировать обращения к самостоятельным, но связанным источникам данных в одном запросе, что позволяет получать ответы, дающие удобство работы с вложенными данными без необходимости денормализации базы данных:

{
  "data": {
    "flavors": [
      {
        "id": 1,
        "name": "The Lazy Person's Movie Theater",
        "nutrition": {
          "calories": 500,
          "fat": 12,
          "sodium": 1000
        }
      },
      ...

    ]
  }
}

Это способно значительно увеличить продуктивность труда программиста и скорость работы системы.

До сих пор мы говорили о запросах на чтение. А как насчёт запросов на обновление данных? Их использование даёт нам те же удобства?

Мутации GraphQL


В то время как запросы GraphQL выполняют загрузку данных, мутации ответственны за внесение в данные изменений. Мутации могут быть использованы в виде базового механизма RPC (Remote Procedure Call, вызов удалённых процедур) для решения различных задач наподобие отправки данных пользователя API стороннего разработчика.

При описании мутаций используется синтаксис, напоминающий тот, который мы применяли при формировании запросов:

mutation updateFlavor($id: ID!, $name: String, $description: String) {
  updateFlavor(id: $id, name: $name, description: $description) {
    id
    name
    description
  }
}

Здесь мы объявляем мутацию updateFlavor, указывая некоторые переменные — id, name и description. Действуя по той же схеме, которая применяется при описании запросов, мы «оформляем» изменяемые поля (корневую мутацию) с помощью ключевого слова mutation, за которым следует имя, описывающее мутацию, и набор переменных, которые нужны для формирования соответствующего запроса на изменение данных.

Эти переменные включают в себя то, что мы пытаемся изменить, или то, мутацию чего мы хотим вызвать. Обратите внимание также и на то, что после выполнения мутации мы можем запросить возврат некоторых полей.

В данном случае нам нужно получить, после изменения записи, поля id, name и description. Это может пригодиться при разработке чего-то вроде оптимистичных интерфейсов, избавляя нас от необходимости выполнять запрос на получение изменённых данных после их изменения.

Разработка схемы и подключение её к GraphQL-серверу


До сих пор мы говорили о том, как GraphQL работает на клиенте, о том, как выполняют запросы. Теперь поговорим о том, как на эти запросы реагировать.

?GraphQL-сервер


Для того, чтобы выполнить GraphQL-запрос, нужен GraphQL-сервер, которому можно такой запрос отправить. GraphQL-сервер представляет собой обычный HTTP-сервер (если вы пишете на JavaScript — то это может быть сервер, созданный с помощью Express или Hapi), к которому присоединена GraphQL-схема.

import express from 'express'
import graphqlHTTP from 'express-graphql'
import schema from './schema'

const app = express()

app.use('/graphql', graphqlHTTP({
  schema: schema,
  graphiql: true
}))

app.listen(4000)

Под «присоединением» схемы мы понимаем механизм, который пропускает через схему запросы, полученные от клиента, и возвращает ему ответы. Это похоже на воздушный фильтр, через который воздух поступает в помещение.

Процесс «фильтрации» связан с запросами или мутациями, отправляемыми клиентом на сервер. И запросы и мутации разрешаются с использованием функций, связанных с полями, определёнными в корневом запросе или в корневой мутации схемы.

Выше приведён пример каркаса HTTP-сервера, созданного с помощью JavaScript-библиотеки Express. Используя функцию graphqlHTTP из пакета express-graphql от Facebook, мы «прикрепляем» схему (предполагается, что она описана в отдельном файле) и запускаем сервер на порту 4000. То есть, клиенты, если говорить о локальном использовании этого сервера, смогут отправлять запросы по адресу http://localhost:4000/graphql.

?Типы данных и распознаватели


Для того чтобы обеспечить работу GraphQL-сервера, нужно подготовить схему и присоединить её к нему.

Вспомните о том, что выше мы говорили об объявлении полей в корневом запросе или в корневой мутации.

import gql from 'graphql-tag'
import mongodb from '/path/to/mongodb’ // Это - лишь пример. Предполагается, что `mongodb` даёт нам подключение к MongoDB.


const schema = {
  typeDefs: gql`
    type Nutrition {
      flavorId: ID
      calories: Int
      fat: Int
      sodium: Int
    }

    type Flavor {
      id: ID
      name: String
      description: String
      nutrition: Nutrition
    }

    type Query {
      flavors(id: ID): [Flavor]
    }

    type Mutation {
      updateFlavor(id: ID!, name: String, description: String): Flavor
    }
  `,
  resolvers: {
    Query: {
      flavors: (parent, args) => {
        // Предполагается, что args равно объекту, наподобие { id: '1' }
        return mongodb.collection('flavors').find(args).toArray()
      },
    },
    Mutation: {
      updateFlavor: (parent, args) => {
        // Предполагается, что args равно объекту наподобие { id: '1', name: 'Movie Theater Clone', description: 'Bring the movie theater taste home!' }

        // Выполняем обновление.

        mongodb.collection('flavors').update(args)

        // Возвращаем flavor после обновления.

        return mongodb.collection('flavors').findOne(args.id)
      },
    },
    Flavor: {
      nutrition: (parent) => {
        return mongodb.collection('nutrition').findOne({
          flavorId: parent.id,
        })
      }
    },
  },
}

export default schema

Определение полей в схеме GraphQL состоит из двух частей — из объявлений типов (typeDefs) и распознавателей (resolver). Сущность typeDefs содержит объявления типов для данных, используемых в приложении. Например, ранее мы говорили о запросе на получение с сервера списка объектов flavor. Для того чтобы к нашему серверу можно было бы выполнить подобный запрос, нужно сделать следующие три шага:

  1. Сообщить схеме о том, как выглядят данные объектов flavor (в примере, приведённом выше, это выглядит как объявление типа type Flavor).
  2. Объявить поле в корневом поле type Query (это свойство flavors значения type Query).
  3. Объявить функцию-распознаватель объекта resolvers.Query, написанную в соответствии с полями, объявленными в корневом поле type Query.

Обратим теперь внимание на typeDefs. Здесь мы сообщаем схеме сведения о форме (shape) наших данных. Другими словами, мы сообщаем GraphQL о разных свойствах, которые могут содержаться в сущностях соответствующего типа.

type Flavor {
  id: ID
  name: String
  description: String
  nutrition: Nutrition
}

Объявление type Flavor указывает на то, что объект flavor может содержать поле id типа ID, поле name типа String, поле description типа String и поле nutrition типа Nutrition.

В случае с nutrition мы используем здесь имя другого типа, объявленного в typeDefs. Здесь конструкция type Nutrition описывает форму данных о пищевой ценности попкорна.

Обратите внимание на то, что мы тут, как и в самом начале этого материала, говорим о «приложении», а не о «базе данных». В вышеприведённом примере предполагается, что у нас есть база данных, но данные в приложение могут поступать из любого источника. Это может быть даже API стороннего разработчика или статический файл.

Так же, как мы поступали в объявлении type Flavor, здесь мы указываем имена полей, которые будут содержаться в объектах nutrition, используя, в качестве типов данных этих полей (свойств) то, что в GraphQL называется скалярными типами данных. На момент написания этого материала в GraphQL поддерживалось 5 встроенных скалярных типов данных:

  • Int: целое 32-битное число со знаком.
  • Float: число двойной точности с плавающей точкой со знаком.
  • String: последовательность символов в кодировке UTF-8.
  • Boolean: логическое значение true или false.
  • ID: уникальный идентификатор, часто используемый для многократной загрузки объектов или в качестве ключа в кэше. Значения типа ID сериализуются так же, как строки, однако указание на то, что некое значение имеет тип ID, подчёркивает тот факт, что это значение предназначено не для показа его людям, а для использования в программах.

В дополнение к этим скалярным типам мы можем назначать свойствам и типы, определённые нами самостоятельно. Именно так мы поступили, назначив свойству nutrition, описанному в конструкции type Flavor, тип Nutrition.

type Query {
  flavors(id: ID): [Flavor]
}

В конструкции type Query, в которой описывается корневой тип Query (тот «корневой запрос», о котором мы говорили ранее), мы объявляем имя поля, которое может быть запрошено. Объявляя это поле, мы, кроме того, вместе с типом данных, который ожидаем вернуть, указываем аргументы, которые могут поступить в запросе.

В данном примере мы ожидаем возможного поступления аргумента id скалярного типа ID. В качестве ответа на такой запрос ожидается массив объектов, устройство которых напоминает устройство типа Flavor.

?Подключение распознавателя запросов


Теперь, когда в корневом type Query имеется определение поля field, нам нужно описать то, что называется функцией-распознавателем.

Это — то место, где GraphQL, более или менее, «останавливается». Если мы посмотрим на объект resolvers схемы, а затем на объект Query, вложенный в него, мы можем увидеть там свойство flavors, которому назначена функция. Эта функция и называется распознавателем для поля flavors, которое объявлено в корневом type Query.

typeDefs: gql`…`,
resolvers: {
  Query: {
    flavors: (parent, args) => {
      // Предполагается, что args равно объекту наподобие { id: '1' }
      return mongodb.collection('flavors').find(args).toArray()
    },
  },
  …
},

Эта функция-распознаватель принимает несколько аргументов. Аргумент parent — это родительский запрос, если таковой существует, аргумент args тоже передаётся запросу в том случае, если он существует. Здесь ещё может использоваться аргумент context, который в нашем случае не представлен. Он даёт возможность работать с различными «контекстными» данными (например — со сведениями о текущем пользователе в том случае, если сервер поддерживает систему учётных записей пользователей).

Внутри распознавателя мы делаем всё, что нужно для того, чтобы выполнить запрос. Именно здесь GraphQL «перестаёт беспокоиться» о происходящем и позволяет нам выполнять загрузку и возврат данных. Тут, повторимся, можно работать с любыми источниками данных.

Хотя GraphQL и не интересуют источники поступления данных, эту систему чрезвычайно сильно интересует то, что именно мы возвращаем. Мы можем вернуть JSON-объект, массив JSON-объектов, промис (его разрешение GraphQL берёт на себя).

Тут мы используем мок-обращение к коллекции flavors базы данных MongoDB, передавая args (если соответствующий аргумент передан распознавателю) в вызов .find() и возвращая то, что будет найдено в результате выполнения этого вызова, в виде массива.

?Получение данных для вложенных полей


Выше мы уже разобрали кое-что, относящееся к GraphQL, но сейчас, возможно, пока непонятно то, как быть с вложенным полем nutrition. Помните о том, что данные, представленные полем Nutrition, мы, на самом деле, не храним совместно с основными данными, описывающими сущность flavor. Мы исходим из предположения о том, что эти данные хранятся в отдельной коллекции/таблице базы данных.

Хотя мы сообщили GraphQL о том, что type Flavor может включать в себя данные nutrition в форме type Nutrition, мы не пояснили системе порядок получения этих данных из хранилища. Эти данные, как уже было сказано, хранятся отдельно от данных сущностей flavor.

typeDefs: gql`
    type Nutrition {
      flavorId: ID
      calories: Int
      fat: Int
      sodium: Int
    }

    type Flavor {
      […]
      nutrition: Nutrition
    }

    type Query {…}

    type Mutation {…}
  `,
  resolvers: {
    Query: {
      flavors: (parent, args) => {…},
    },
    Mutation: {…},
    Flavor: {
      nutrition: (parent) => {
        return mongodb.collection('nutrition').findOne({
          flavorId: parent.id,
        })
      }
    },
  },

Если присмотреться повнимательнее к объекту resolvers в схеме, то можно заметить, что тут имеются вложенные объекты Query, Mutation и Flavor. Они соответствуют типам, которые мы объявили выше в typeDefs.

Если посмотреть на объект Flavors, то окажется, что поле nutrition в нём объявлено как функция-распознаватель. Заметной особенностью такого решения является тот факт, что мы объявляем функцию непосредственно в типе Flavor. Другими словами, мы говорим системе: «Мы хотим, чтобы ты загрузила поле nutrition именно так для любого запроса, использующего type Flavor».

В этой функции мы выполняем обычный запрос к MongoDB, но тут обратите внимание на то, что мы используем аргумент parent, переданный функции-распознавателю. То, что представлено здесь аргументом parent, представляет собой то, что содержится в полях, имеющихся во flavors. Например, если нам нужны все сущности flavor, мы выполним такой запрос:

{
  flavors {
    id
    name
    nutrition {
      calories
    }
  }
}

Каждое поле flavor, возвращённое из flavors, мы пропустим через распознаватель nutrition, при этом данное значение будет представлено аргументом parent. Если присмотреться к этой конструкции, то окажется, что мы, в запросе к MongoDB, используем поле parent.id, которое представляет собой id сущности flavor, обработкой которой мы занимаемся в данный момент.

Идентификатор parent.id передаётся в запросе к базе данных, где производится поиск записи nutrition с идентификатором flavorId, которая соответствует обрабатываемой сущности flavor.

?Подключение мутаций


То, что мы уже знаем о запросах, отлично переносится и на мутации. На самом деле, процесс подготовки мутаций практически полностью совпадает с процессом подготовки запросов. Если взглянуть на корневую сущность type Mutation, то можно увидеть, что мы объявили в ней поле updateFlavor, принимающее аргументы, задаваемые на клиенте.

type Mutation {
  updateFlavor(id: ID!, name: String, description: String): Flavor
}

Этот код можно расшифровать так: «Мы ожидаем, что мутация updateFlavor принимает id типа ID (восклицательный знак, !, сообщает GraphQL о том, что это поле необходимо), name типа String и description типа String». Кроме того, после завершения выполнения мутации мы ожидаем возврат некоторых данных, структура которых напоминает тип Flavor (то есть — объект, который содержит свойства id, name, description, и, возможно, nutrition).

{
  typeDefs: gql`…`,
  resolvers: {
    Mutation: {
      updateFlavor: (parent, args) => {
        // Предполагается, что args равно объекту наподобие { id: '1', name: 'Movie Theater Clone', description: 'Bring the movie theater taste home!' }

        // Выполняем обновление.

        mongodb.collection('flavors').update(
          { id: args.id },
          {
            $set: {
              ...args,
            },
          },
        )

        // Возвращаем flavor после обновления.

        return mongodb.collection('flavors').findOne(args.id)
      },
    },
  },
}

Внутри функции-распознавателя для мутации updateFlavor мы делаем именно то, чего от подобной функции можно ожидать: организуем взаимодействие с базой данных для того, чтобы изменить в ней, то есть — обновить, сведения об интересующей нас сущности flavor.

Обратите внимание на то, что сразу же после выполнения обновления обновление мы выполняем обращение к базе данных для того, чтобы снова найти ту же сущность flavor и вернуть её из распознавателя. Почему это так?

Вспомните о том, что на клиенте мы ожидаем получить объект в том состоянии, в которое он был приведён после завершения мутации. В данном примере мы ожидаем, что будет возвращена сущность flavor, которую мы только что обновили.

Можно ли просто вернуть объект args? Да, можно. Причина, по которой мы решили в данном случае этого не делать, заключается в том, что мы хотим быть на 100% уверенными в том, что операция обновления информации в базе данных прошла успешно. Если мы прочтём из базы данных информацию, которая должна быть изменённой, и окажется, что она и правда изменена, тогда можно сделать вывод о том, что операция выполнена успешно.

Зачем может понадобиться использовать GraphQL?


Хотя то, созданием чего мы только что занимались, выглядит не особенно масштабно, сейчас у нас есть функционирующее, хотя и простое, GraphQL-API.

Как и в случае с любой новой технологией, после первого знакомства с GraphQL вы можете задаться вопросом о том, зачем вам может пригодиться нечто подобное. Честно говоря, на этот вопрос нельзя дать однозначного и простого ответа. Очень уж много всего нужно учесть для того, чтобы такой ответ найти. И можно, кстати, подумать о том, чтобы вместо GraphQL просто выбрать проверенную временем технологию REST или напрямую обращаться к базе данных. Собственно говоря, вот несколько идей, над которыми стоит поразмыслить в поисках ответа на вопрос о том, нужна ли вам технология GraphQL.

?Вы стремитесь уменьшить количество запросов, выполняемых с клиента


Многие приложения страдают от того, что им приходится выполнять слишком много HTTP-запросов, от того, что делать это приходится слишком часто, и от того, что это — сложные запросы. В то время как использование технологии GraphQL не позволяет полностью отказаться от выполнения запросов, эта технология, если ей правильно пользоваться, способна значительно уменьшить количество запросов, выполняемых со стороны клиента (во многих случаях для получения некоего набора связанных данных достаточно лишь одного запроса).

Является ли ваш проект приложением с множеством пользователей, или приложением, обрабатывающим огромные объёмы данных (например — это нечто вроде системы для работы с медицинскими данными), использование GraphQL определённо улучшит производительность его клиентской части.

?Вы хотите избежать денормализации данных, проводимой лишь ради того, чтобы оптимизировать работу механизмов построения пользовательского интерфейса


В приложениях, в которых используются большие объёмы реляционных данных, часто может возникать «ловушка денормализации». Хотя такой подход и оказывается рабочим, он, вне всякого сомнения, далёк от идеала. Его применение может плохо влиять на производительность систем. Благодаря использованию GraphQL и вложенных запросов необходимость в денормализации данных значительно уменьшается.

?У вас есть множество источников информации, к которым вы обращаетесь из разных приложений


Эта проблема может быть частично решена с помощью традиционных REST API, но даже при таком подходе одна проблема всё ещё остаётся: единообразие запросов, выполняемых с клиентской стороны. Предположим, что в ваш проект входят веб-приложение, приложения для iOS и Android, а также API для разработчиков. В подобных условиях вам, вероятнее всего, придётся, на каждой платформе, «мастерить из подручных материалов» средства для выполнения запросов.

Это ведёт к тому, что приходится поддерживать, на разных платформах, несколько реализаций HTTP, это означает отсутствие единообразия в средствах выполнения запросов и наличие запутанных конечных точек API (вы, наверняка, такое уже видели).

?Может быть технология GraphQL — это верх совершенства? Стоит ли мне прямо сейчас выбросить мой REST API и перейти на GraphQL?


Нет, конечно. Ничто не совершенно. И, надо отметить, работать с GraphQL не так уж и просто. Для того чтобы создать работающую схему GraphQL, нужно выполнить множество обязательных шагов. Так как вы только изучаете данную технологию, это может вывести вас из равновесия, так как нелегко бывает понять то, чего именно не хватает в вашей схеме для правильной работы системы. При этом сообщения об ошибках, возникающих на клиенте и на сервере, могут оказаться не особенно полезными.

Далее, использование GraphQL на клиенте, в том, что выходит за рамки языка запросов, не стандартизовано. Хотя работу с GraphQL могут облегчить различные библиотеки, самыми популярными из которых являются Apollo и Relay, каждая из них отличается собственными специфическими особенностями.

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

Итоги


Несмотря на то, что внедрение GraphQL может оказаться непростой задачей, эта технология представляет собой впечатляющий шаг вперёд в сфере обработки данных. GraphQL нельзя назвать лекарством от всех болезней, но с этой технологией, определённо, стоит поэкспериментировать. Начать можно, например, поразмыслив о самой запутанной и неопрятной подсистеме, используемой в вашем проекте при работе с данными, и попытавшись реализовать эту подсистему средствами GraphQL.

Кстати, тут у меня для вас приятная новость: GraphQL можно реализовывать инкрементно. Для того чтобы извлечь выгоды из применения этой технологии нет нужды переводить на GraphQL абсолютно всё. Так, постепенно вводя в проект GraphQL, можно разобраться с этой технологией самому, заинтересовать команду, и, если то, что получится, всех устроит, двигаться дальше.

Главное — помните о том, что GraphQL — это, в конечном счёте, всего лишь инструмент. Применение GraphQL не означает необходимости в полной переработке всего, что было раньше. При этом надо отметить, что GraphQL — это технология, с которой, определённо, стоит познакомиться. Многим стоит подумать и о применении этой технологии в своих проектах. В частности, если ваши проекты кажутся не особенно производительными, если вы занимаетесь разработкой сложных интерфейсов, наподобие панелей управления, лент новостей или профилей пользователей, то вы уже знаете о том, где именно вы можете опробовать GraphQL.

Уважаемые читатели! Если сегодня состоялось ваше первое знакомство с GraphQL — просим рассказать нам о том, планируете ли вы использовать эту технологию в своих проектах.

Комментарии (23)


  1. TheGodfather
    03.04.2019 13:56
    +1

    Боюсь, что меня заминусуют, но из статьи непонятно, чем это принципиально лучше, чем существующий подход, когда можно отправить запрос типа
    GET /flavors

    {
    "fields": ["name", "description"]
    }


    Такое уже есть и вполне себе применяется в некоторых местах, зачем городить что-то, когда проблема успешно (вроде) решается?


    1. Sioln
      03.04.2019 14:45

      Принципиально (на мой взгляд) только поддержкой батчей, когда на 100500 действий не нужно отправлять 100500 запросов.


      1. rraderio
        03.04.2019 17:50

        1. Iv38
          03.04.2019 18:58

          Есть еще multipart/batch запросы. Только всё это уже какой-то не true REST. Нужен отдельный эндпоинт, понимающий только POST. И нам же надо делать зависимые запросы, чтобы получить поля связанных сущностей, и у PayPal такая возможность заложена, но тогда получается что эндпоинты ресурсов получают идентификаторы предыдущих запросов, а это уже не stateless.

          GraphQL, на мой взгляд, честнее и удобнее делает то же самое.


    1. dolphin4ik
      03.04.2019 14:50

      Это лучше тем что выборка полей динамическая и можно за один запрос взять с сервера все необходимые коллекции/свойства с вложенными полями. Как если бы нужно было взять с сервера все фильмы за последние 5 лет, но только названия и длину фильма. И сразу взять список всех актёров, но только имена без фамилий… А ещё можно у актёров взять сразу фильмы в которых играли они и тоже только названия и/или длину, а у этих фильмов ещё и список актеров (и т.д.) почти до бесконечности. Дерево вложенное в дерево.


      1. Iv38
        03.04.2019 19:08

        Такой запрос можно и без GraphQL сотворить аналогично тому что выше:
        GET /movies?fields={name,actors.name}&release_date_from=2014-04-03

        В GraphQL просто стандартизирован синтаксис подобных запросов


    1. kolbaskinmax
      03.04.2019 15:08

      Думаю, тут основная фишка в том, что бизнес-логика работы с данными переносится на клиента. Сервер только предоставляет некий АПИ для выполнения произвольных (но ограниченных схемой) запросов. Хотя, я могу и ошибаться.


      1. Sioln
        03.04.2019 15:21
        +1

        Да, это выглядит, как ORM, протянутая аж на клиента.
        Silverlight data services такое предлагал когда-то.


    1. trawl
      04.04.2019 06:45

      Вы GET-запрос отправляете с телом?


  1. Sioln
    03.04.2019 14:49
    +1

    А что у этой штуки с авторизацией доступа к данным?
    1. Как она реализуется, какими механизмами?
    2. Что происходит, если, скажем, я хочу собрать Имя, Фамилию и SecurityToken у пользователей. А SecurityToken читать права не имею?
    Обломается вообще весь запрос? (Как тогда узнать по какой конкретно причине)
    В SecurityToken вернётся null? (Как тогда отличить от действительно null у пользователя)


    1. gibson_dev
      03.04.2019 15:01

      Авторизация как хотите через токен, куки или еще что — вам решать
      Механизмов много и на самом деле к каждому полю можно сделать `resolver` в котором можно решать и проблемы доступа и прочее, нет весь запрос не обломается


    1. Iv38
      03.04.2019 15:15

      Об обработке ошибок в этой статье почему-то вообще ничего не сказано. Вообще в GraphQL ответ может содержать поля data и errors, причем одновременно тоже можно. Таким образом можно вернуть данные и одновременно сообщить об ошибке доступа к какому-либо полю, если хочется.


  1. shaggyone
    03.04.2019 16:11

    На мой взгляд в статье стоило затронуть вопрос о том, как быть, если клиент попробует запросить у сервера сразу все данные. Тем паче, что graphql (если разработчик приложения об этом позаботился) неплохо умеет такие ситуации разруливать.


  1. transcengopher
    03.04.2019 21:10

    Flavor: {
      nutrition: (parent) => {
        return mongodb.collection('nutrition').findOne({
            flavorId: parent.id,
        });
      }
    }

    findOne

    Жуть какая. А можно там как-то сделать поиск по nutrition для списка родительских объектов? Чтобы было не 1+M запросов, а ну хотя бы два?


    1. Kant8
      04.04.2019 02:54

      Не видел ни одной статьи, где описывалось бы как это нормально сделать. Предлагают только вместо выполнения одинаковых запросов складывать их в некий батчер, который должен догадаться, что вот это надо бы кучкой выполнить. И пляски с бубном как это прикрутить к QraphQL чтобы он и не заметил.
      Типа вот medium.com/slite/avoiding-n-1-requests-in-graphql-including-within-subscriptions-f9d7867a257d

      Причем эта особенность рубит на корню использование GraphQL где-либо кроме сайтика на 10 человек. В статье героически ускорили с 20 секунд до 200 миллисекунд, а это всего лишь один подзапрос, а таких могут быть десятки. Не говоря уже о том, что ORMки для реляционных баз как минимум все 1 к 1 склеят в один единственный запрос, а тут их всё равно будет много.


      1. vladar
        04.04.2019 11:38

        Да нет там никаких танцев с бубнами. В GraphQL резолвер может вернуть Promise. То как именно вы группируете запросы и в какой момент резолвите — для GraphQL не важно.

        > Причем эта особенность рубит на корню использование GraphQL где-либо кроме сайтика на 10 человек

        Сильное утверждение. Но, конечно, ложное. Тот же бэкенд фейсбука вполне себе живет на GraphQL + Dataloader.

        > Не говоря уже о том, что ORMки для реляционных баз как минимум все 1 к 1 склеят в один единственный запрос, а тут их всё равно будет много.

        В реальности GraphQL + Dataloader (который в статье упоминается) также объединят в один запрос. А в некоторых случаях — еще и эффективнее (например, когда одинаковые сущности на разных уровнях запрашиваются).

        Ну и надо помнить, что Dataloader'ом легко оборачивать и соединять разные источники данных (кэш, search engine, база, web-service), в отличие от ORM.

        В общем на практике все это вполне себе хорошо работает. Проблема N+1 там решается неплохо.


        1. Kant8
          04.04.2019 13:11

          И как оно предполагается работать для склеивания?
          То, что упоминается в статье, очевидно вызывает лоадер для дочерних коллекций после того, как был загружен родитель, иначе ему просто нечего передать как параметр хоть и в промис. В такой схеме даже теоретически невозможно поклеить всё в один запрос, только если самому всё распарсить, но собственно зачем тогда нужен QraphQL?

          Для, например, условных сущностей юзер, список товаров в корзине, производитель товара, придется в любом случае сначала достать юзера, потом отдельно достать товары, а потом отдельно производителей, тк резолвер вызовет это всё последовательно. И чем больше сущностей, тем больше таких последовательных вызовов.

          Даже в упомянутом Dataloader в описании пример с юзером, топ 5 друзей и лучшим другом у каждого юзера, где обещают не более 4 запросов (вместо 13), когда ормка превратит это в 1 или 2, в зависимости от реализации 1 к N.

          Про кэши и ORM вообще не понял, причем тут они. Кэши прикручиваются куда угодно без особых проблем, паттер репозиторий в помощь или что угодно другое. Ормка лишь источник данных, кого дергать это проблемы конкретного проекта и конкретного случая.


        1. rraderio
          04.04.2019 15:41

          Тот же бэкенд фейсбука вполне себе живет на GraphQL + Dataloader.
          Можно пруф?



        1. transcengopher
          04.04.2019 16:38

          Dataloader'ом легко оборачивать и соединять разные источники данных (кэш, search engine, база, web-service)

          Фактически, это значит, что в кэше можно хранить данные из нескольких функций — и это уже не такое же смелое заявление, как в исходной форме.
          У меня, к слову, вот такой DataLoader-подобный кеш на промисах в проекте крутится в одном-двух компонентах — как жаль, что я не фейсбук и пиарить свои isFunction так же хорошо не умею, ведь про мой кеш даже в моём проекте мало кто знает.


          в отличие от ORM

          Назначение ORM — оградить бизнес-логику от знаний, как именно хранятся данные. Поэтому, достаточно развитые ORM позволяют точно так же объединять разные источники данных — с вас потребуется только определение PersistenceUnit'а (переводчика из диалекта QCRUD ORM на диалект, нативный для Store). И работать будет уже не только для загрузка, но и запись.


          Выглядит примерно как по ссылке ниже. Ограничения, по сути, те же самые, что у вашего DataLoader'а — N + M(>=N) запросов для чтения, при записи всплывает поддержка распределённых транзакций, что есть не у всех Store'ов. Но у модной нынче модели EventualConsistency и транзакций-то, по сути, нет, так что это вроде как уже не большой минус.
          https://wiki.eclipse.org/EclipseLink/UserGuide/JPA/Advanced_JPA_Development/Composite_Persistence_Units


        1. transcengopher
          04.04.2019 17:02

          В GraphQL резолвер может вернуть Promise

          А на вход он может принять коллекцию вместо одного объекта? Если нет — то выходит, что новая крутая технология в очередной раз способствует уменьшению производительности клиентских приложений. Потому что если вы посмотрите хорошо на мой вопрос — конкретно на то, что я цитировал — то выходит, что GraphQL на вложенных запросах как раз задыхается. Во-первых, потому, что в исходной цитате нету никакой проекции — значит это, что мы читаем всё, а умный GraphQL потом выбрасывает прочитанное? Но мне не нужен целый GraphQL чтоб из объекта свойства поудалять, понимаете? Или же в статье всё-таки представили технологию без раскрытия реальных киллер-фич? Во-вторых, если не пользоваться хаками EventLoop'а, как это делают в DataLoader — читать предлагается строго по одному элементу, чего я ну никак не ожидаю от действительно зрелой технологии, заточенной на оптимизацию доступа к данным.
          Смотрите. Если при запросе


          flavors {
            id
            name
            description
            nutrition {
              id
              sodium
            }
          }

          В процессе выполнения какая-нибудь подсистема получит внутренний запрос в виде


          nutrition(id: $.flavors.id) { /*это JSONPath такой, надеюсь суть ясна*/
             id
             sodium
          }

          То я возражать не буду — многие так сделают, решение неидеальное, зато гибкое. А если такого нет, и грузить буду по одному nutrition, то последует мой вывод — "съешь ещё этих мягких калифорнийских фреймворков да выпей смузи, а клиенту скажем оперативы побольше купить".


          1. vladar
            04.04.2019 19:15

            > А на вход он может принять коллекцию вместо одного объекта?

            Я делал реализацию с таким подходом (когда на входе коллекция), но у нее есть свои грабли — иногда у вас тип может быть в какой-нибудь generic обертке и у вас будет список оберток, а не конечных сущностей (В GraphQL типичный пример — Pagination and Edges).

            В таком случае список надо резолвить на уровне этой обертки. И тогда она должна знать обо всех возможных своих детях и знать контекст запроса.

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

            > Потому что если вы посмотрите хорошо на мой вопрос — конкретно на то, что я цитировал — то выходит, что GraphQL на вложенных запросах как раз задыхается.

            Как напишите. Напишите с асинхронной буферизацией (а-ля даталоадер) — будет работать хорошо и быстро.

            > Во-вторых, если не пользоваться хаками EventLoop'а, как это делают в DataLoader

            Я бы не сказал, что это хаки. Просто для оптимального результата нужно контролировать последовательность буферизации и загрузки следующего чанка (грубо говоря задавать стратегию по которой промисы резолвятся). Стандартный node не дает этого делать.

            В принципе если работаешь не в node-окружении и можешь контролировать порядок исполнения промисов и добавления новых в очередь — нет там никаких хаков. Очереди и стратегии по их проходу.

            > читать предлагается строго по одному элементу, чего я ну никак не ожидаю от действительно зрелой технологии, заточенной на оптимизацию доступа к данным.

            Это немного некорректное понимание GraphQL. GraphQL — этой уйма компромисов самых разных областей. Связь фронтенда и бэкенда, доступ к данным, возможность использования с разными типами хранилищ, организация работы разных команд в компании и т.д. Сейчас даже микросервисы через него объединяют, чтоб с ума не сойти.

            Если нужна только оптимизация доступа к данным — используйте SQL, Lucene, map/reduce и т.п. %)

            Ваш пример не совсем понял. С DataLoader'ом конечно там будет один запрос для всех nutrition.

            В вашем примере будет следующее:
            1. Резолвер очередного nutrition добавит id родительского flavor в буффер даталоадера и вернет промис
            2. GraphQL таким образом пройдет по всем flavor и получит у себя массив промисов для nutrition.
            3. Когда не останется данных для синхронного обхода — будет ждать резолва промисов.
            4. Тут в буфере DataLoader будут все flavor_id, и он выполнит один запрос для получения всех nutrition (в SQL что-то вроде `SELECT * FROM nutrition WHERE flavor_id IN (?);`)
            5. По окончании — DataLoader зарезолвит все промисы для nutrition (вот здесь надо контролировать стратегию — либо GraphQL будет продолжать свой Loop после каждого зарезолвленного промиса, либо подождет, когда зарезолвятся все и будет уже проходить по всем новым данным. Все «хаки» DataLoader'а — чтобы обеспечить второй вариант)


    1. borv
      04.04.2019 18:51

      Ну собственно это и иллюстрирует основную проблему GraphQl, ODATA и прочих "да ну его нафиг этот сервисный уровень" подходов. Мы отдали клиенту право запрашивать все что он хочет. На практике это выливается в одну из трех альтернатив:


      • либо в написание довольно сложного транслятора, который умеет транслировать GraphQl запросы в наш бэкенд (предотвращение 1+M, кэширование, аффинность, индексы, вшивание проверки доступа и т.д.)
      • либо в написание 100500 вирутальных неявно связанных "вьюшек" в схеме (привет, REST),
      • либо в перекладывание ответственности на клиента (в жестком виде — 0.5 сек на запрос и дальше получи таймаут, или в более дружественном — мониторинг и уговаривание клиента поменять кривые запросы, с шантажом и угрозами если потребуется)

      Скорее всего первый вариант не пойдет в качестве самописного, если вы не фейсбук. Так что реально у вас будет или middleware слой (вроде https://www.apollographql.com/) снаружи сервисов, либо прямой биндинг к БД внутри сервисов (вроде https://github.com/graphile/postgraphile), что, по сути как раз и есть альтернатива №3. В итоге, "золотая середина" — это альтернатива №2, по сути — тот же REST только сбоку.


      Отдельно замечу, что прямая трансляция из GraphQl в БД не особо отличается от обмена SQL запросами, просто язык другой. Не ведитесь на обещания "GraphQl отлично работает в микросервисной среде". Ровно так он и работает — либо живите с 1+M проблемой, либо разбирайте запросы руками.