Эта статья была первоначально написана Кевином Каннингемом в блоге разработчиков Honeybadger.
Вы, несомненно, слышали о GraphQL, языке запросов на основе графов от Facebook. С момента его выпуска в 2015 году все большее количество поставщиков данных предлагают конечную точку GraphQL. Эта конечная точка обычно предлагается наряду с традиционным API на основе REST.
Я предпочитаю использовать конечную точку GraphQL на фронтенде. Мне нравится, что я могу запрашивать конкретные данные, которые мне нужны, избегая проблем с избыточной или недостаточной выборкой. Мне нравится самодокументирующая природа GraphQL, поскольку его схема, основанная на типах, описывает именно то, что ожидается и возвращается. Мне слишком часто приходилось работать с REST API, чтобы понять, что документация устарела или неверна.
Однако в бэкенде я продолжал предоставлять конечные точки REST. Традиционные HTTP-глаголы и маршрутизация знакомы, и я могу очень быстро создать что-то функциональное.
Вопрос, на который я хотел бы ответить в этой статье, заключается в том, что нужно сделать, чтобы GraphQL API начал работать?
Контекст
Чтобы придать этой статье некоторый контекст, я создал вымышленный магазин для серфинга. Этим летом я много плавал на каяке, и именно это продает этот магазин. Код, сопровождающий эту статью, можно найти здесь.
Мой магазин для серфинга использует базу данных MongoDB и имеет готовый сервер Fastify. Вы можете найти начальный код для этого магазина здесь, а также скрипт посева, если хотите следовать за ним. Вам потребуется установить Node и MongoDB, что выходит за рамки этой статьи, но нажмите на названия, чтобы перейти на страницы установки.
Чтобы это был реалистичный сценарий, я хочу, чтобы мои текущие клиенты, использующие REST API, не пострадали, когда я добавлю конечную точку GraphQL.
Давайте начнем!
Схема GraphQL
Чтобы начать работу с GraphQL, нам нужно добавить в наш проект две библиотеки. Первая, что неудивительно, это graphql
, а вторая — mercurius
. Mercurius — это адаптер Fastify для GraphQL. Давайте установим их:
yarn add graphql mercurius
GraphQL основан на схемах, что означает, что наш API всегда будет документирован и безопасен для типов. Это значительное преимущество для наших потребителей и помогает нам, когда мы думаем о взаимосвязях между данными.
Наш магазин имеет два типа, Craft
и Owner
. Перейдя к моделям Mongoose, вы можете увидеть, какие поля доступны для каждой из них. Давайте рассмотрим модель Owner
.
Модель Mongoose выглядит следующим образом:
const ownerSchema = new mongoose.Schema({
firstName: String,
lastName: String,
email: String,
});
Мы создадим каталог схемы, который представляет собой файл index.js, а затем создадим нашу схему GraphQL. Этот OwnerType
в этой схеме будет выглядеть очень похоже на схему Mongoose.
const OwnerType = `type OwnerType {
id: ID!
firstName: String
lastName: String
email: String
}`;
Для определения наших типов используются строки шаблонов, начинающиеся с ключевого слова type
и имени нашего типа. В отличие от объекта JavaScript, после каждой строки определения типа не ставится запятая. Вместо этого в каждой строке указывается имя поля и его тип, разделенные двоеточием. В своем определении я использовал типы ID
и String
. Вы заметите, что за ID следует восклицательный знак, !
, который обозначает, что это обязательное, не нулевое поле. Все остальные поля являются необязательными.
Сейчас я собираюсь добавить этот тип к типу Query
моей схемы.
const schema = `
type Query {
Owners: [OwnerType]
Owner(id: ID!): OwnerType
}
${OwnerType}
`;
Вы увидите, что тип Owners
возвращает массив OwnerType
, указанный квадратными скобками.
Owner
требует, чтобы запрашивающий потребитель передал поле id. Оно обозначается значением в круглых скобках, (id: ID!)
, показывая как имя поля, так и тип, которому оно должно соответствовать.
Наконец, мы экспортируем эту схему из этого файла и импортируем ее в наш основной файл index.js
.
module.exports = { schema };
и
const { schema } = require("./schema");
Пока мы импортируем схему, мы можем импортировать плагин mercurius и зарегистрировать его в Fastify.
const mercurius = require("mercurius");
fastify.register(mercurius, {
schema,
graphiql: true,
});
В опциях плагина мы передадим схему и еще одно свойство — мы установим graphiql
равным true.
GraphiQL
GraphiQL — это браузерный интерфейс, предназначенный для изучения и работы с конечной точкой GraphQL. Теперь, когда это значение равно true, мы можем запустить наш сервер и перейти по адресу http://localhost:3000/graphiql
, чтобы найти эту страницу.
С помощью этого инструмента мы можем сделать следующее:
- Написать и проверить наши запросы.
- Добавить переменные запроса и заголовки запроса, чтобы помочь в тестировании.
- Получать результаты от нашего API.
- Изучить документацию, созданную нашей схемой.
Изучение схемы теперь показывает корневой тип query: Query
. Именно к этому типу мы добавили наши Owner
и Owners
. Щелчок на этом типе показывает следующее:
и щелчок на любом из них показывает соответствующий тип:
Я собираюсь продолжить и настроить остальные определения типов. Вы можете посмотреть исходный код, чтобы увидеть, как я добавил тип Craft
и добавил поле crafts
к типу Owner
.
После этого мой тип Query теперь выглядит следующим образом:
Все связи между полями установлены, но мы пока не можем получить из них данные. Чтобы сделать это, нам нужно изучить две концепции: запросы и резольверы.
Запросы на GraphQL
По своей сути GraphQL — это язык запросов; это даже в названии! Но до сих пор мы не выполняли никаких запросов. Инструмент GraphiQL имеет автозаполнение, поэтому мы можем начать составлять наши запросы прямо сейчас. Следующий запрос должен вернуть название всех Crafts
.
query {
Crafts {
name
}
}
Однако при выполнении мы получаем ответ null
.
{
"data": {
"Crafts": null
}
}
Это потому, что мы не настроили никаких резольверов. Резольвер — это функция, которую GraphQL запускает для поиска данных, необходимых для разрешения запроса.
Для этого проекта я собираюсь определить резольверы в файле schema/index.js
, вместе со схемой. У меня уже есть контроллеры для обоих типов данных, используемых в моих маршрутах REST API. Я собираюсь использовать эти контроллеры, с некоторой адаптацией, для обслуживания конечной точки GraphQL.
Сначала я импортирую контроллеры:
const craftController = require("../controllers/craftController");
const ownerController = require("../controllers/ownerController");
Затем я создам объект resolvers:
const resolvers = {}
Этот объект должен иметь ключ для каждого корневого типа, для которого мы хотим предоставить резольверы. Для нашего использования у нас есть один корневой тип, а именно Query
. Значением для этого ключа должна быть функция, выполняемая для получения необходимых данных. Вот как это будет выглядеть для нашего поля Crafts:
const resolvers = {
Query: {
async Crafts() {
return await craftController.getCrafts();
},
},
};
Затем мы экспортируем функцию resolvers, импортируем ее в наш основной index.js
и передаем ее объекту опций нашего плагина вместе со схемой.
// in /src/schema/index.js
module.exports = { schema, resolvers };
// in /src/index.js
const { schema, resolvers } = require("./schema");
fastify.register(mercurius, {
schema,
resolvers,
graphiql: true,
});
Теперь, когда мы выполним предыдущий запрос, мы должны получить все названия ремесел в нашей базе данных.
Потрясающе! Однако что, если мы хотим получить запрос на конкретное ремесло? Это требует немного больше работы. Сначала давайте составим запрос в редакторе GraphiQL.
Настройка запроса выглядит очень похоже, с некоторыми отличиями:
- Мне нужно передать переменную запроса. После ключевого слова
query
мы указываем имя и тип передаваемой переменной. Переменная должна начинаться со знака доллара ($
). - Здесь я использую переменную
$id
в качестве значения поля для запроса по полю Craft. - Значение переменной запроса передается в виде JSON.
- Наконец, я получаю ответ.
На данный момент у меня нет никаких возвращенных данных. Давайте это исправим!
Вернемся к моим резольверам и добавим функцию для Craft. Первый позиционный аргумент — это родитель, который мне не нужен для этой операции, поэтому я использую здесь подчеркивание. Второй — это аргументы, переданные в запросе, из которых я хочу разложить id:
const resolvers = {
Query: {
async Crafts() {
return await craftController.getCrafts();
},
async Craft(_, { id }) {
return await craftController.getCraftById({id})
},
},
};
В настоящее время моя функция getCraftById
ожидает объект запроса. Мне нужно будет обновить функцию в src/controllers/craftController.js
.
Эта оригинальная функция
// Get craft by id
exports.getCraftById = async (request, reply) => {
try {
const craft = await Craft.findById(request.params.id);
return craft;
} catch (error) {
throw boom.boomify(error);
}
};
становится
exports.getCraftById = async (request, reply) => {
try {
const id = request.params === undefined ? request.id : request.params.id;
const craft = await Craft.findById(id);
return craft;
} catch (error) {
throw boom.boomify(error);
}
};
Великолепно! Теперь, когда мы выполним наш запрос, будет возвращен результат.
Нам понадобится помощь GraphQL для заполнения полей, которые связаны с другими типами. Если бы наш потребитель запрашивал текущего владельца ремесла, он бы получил ответ null
. Мы можем добавить некоторую логику для получения владельца на основе owner_id
, который хранится в базе данных. Затем это значение может быть добавлено к нашему объекту craft перед передачей пользователю.
async Craft(_, { id }) {
const craft = await craftController.getCraftById({ id });
if (craft && craft.owner_id) {
const owner = await ownerController.getOwnerById({
id: craft.owner_id,
});
craft.owner = owner;
}
return craft;
},
Наш ownerController.getOwnerById
нужно будет обновить так же, как и соответствующую функцию craft. Но как только это будет сделано, мы сможем свободно запрашивать владельца.
Вы можете проверить каталог finished-code, чтобы найти резольверы для всех остальных полей и обновленные функции контроллера.
Мутации GraphQL
Теперь я могу уверенно предоставлять запросы к конечной точке GraphQL; все операции чтения являются некоторой адаптацией того, что мы уже сделали. А как насчет других операций? В частности, что насчет Create
, Update
и Delete
?
В GraphQL каждая из этих операций называется мутацией. Мы каким-то образом изменяем данные. Настройка бэкенда для мутации почти полностью совпадает с настройкой запроса. Нам нужно определить мутацию в схеме, а затем предоставить функцию resolver, которая будет выполняться при вызове мутации.
Итак, в /schema/index.js
я собираюсь расширить тип Mutation
и добавить мутацию addCraft
.
type Mutation {
addCraft(
name: String
type: String
brand: String
price: String
age: Int
): CraftType
}
Как и в предыдущих определениях полей, значения в круглых скобках показывают, какие поля могут быть переданы в функцию. Каждое из них передается вместе с их типами. Далее следует то, что вернет мутация. В данном случае — объект в форме нашего CraftType.
Проверяя это в GraphiQL, мы видим, что mutation
теперь является корневым типом, когда мы щелкаем по нему, то видим, что наша мутация addCraft существует в схеме.
Конструирование мутации в GraphiQL выглядит идентично конструированию запроса. Нам нужно будет передать переменные запроса, как мы это делали раньше, и он будет выглядеть примерно так:
Однако при выполнении мы получаем ответ null
. Это, надеюсь, неудивительно, потому что мы еще не создали резольвер для этой мутации. Давайте сделаем это сейчас!
Мы добавим ключ Mutation
в наш объект resolvers и функцию для нашей мутации addCraft
.
Mutation: {
async addCraft(_, fields) {
const { _id: id } = await craftController.addCraft({ ...fields });
const craft = { id, ...fields };
return craft;
},
},
Наша текущая функция addCraft
возвращает только ответ Mongoose, который представляет собой поле _id
. Мы извлечем его и вернем введенные поля, что позволит нам соответствовать CraftType, который мы объявили ранее.
Функции update и destroy идентичны по своей конфигурации и настройке. В каждом случае мы расширяем тип Mutation в схеме и добавляем соответствующий резольвер.
Вы можете проверить каталог finished-code, чтобы найти резольверы для некоторых других мутаций.
Заключение
Я начал эту работу с размышлений о том, будет ли создание GraphQL-сервера огромной ненужной хлопотой. Я закончил со спокойной уверенностью, что буду использовать GraphQL для своего следующего бэкенд-проекта.
Изначально требуется немного больше настроек и шаблонов, чем при обращении непосредственно к Mongo через наш REST API. Потенциально это может стать камнем преткновения. Однако я думаю, что есть несколько убедительных моментов, которые делают это стоящим.
Вам больше не нужно предоставлять конечную точку для какого-то нишевого использования вашего приложения. Потребителю нужно обращаться только к тем полям, которые ему нужны в данном контексте. Это избавляет вас от беспорядочного файла маршрутов и многочисленных обращений к API, когда достаточно одного.
Обновляя схему и резольверы, вы делаете эти данные немедленно доступными для ваших потребителей. Хотя вы можете пометить поля как устаревшие, вы можете оставить устаревшие поля на месте без особых затрат для пользователя. Более того, это самодокументирующийся API. Никогда больше ваш сайт документации не будет синхронизироваться с текущим состоянием вашего API.
Вы убедились? Перейдете ли вы на GraphQL, или вы навсегда останетесь в команде REST API?