graphql — оптимизация запросов к базе данных

habr.png

При работе с базами данных существует проблема которую принято называть «SELECT N + 1» — это когда приложение вместо одного запроса к базе данных, который выбирает все необходимые данные из нескольких связанных таблиц, коллекций, — делает дополнительный подзапрос для каждой строки результата первого запроса, чтобы получить связанные данные. Например, сначала мы получаем список студентов университета, в котором его специальность обозначена идентификатором, а потом для каждого из студентов делаем дополнительный подзапрос в таблицу или коллекцию специальностей, чтобы по идентификатору специальности получить наименование специальности. Поскольку каждый из подзапросов может потребовать еще один подзапрос, и еще один подзапрос — колчество запросов к базе данных начинает расти в геометрической прогрессии.

При работе с graphql очень просто породить проблему «SELECT N + 1», если в resolver-функции сделать подзапрос к связанной таблице. Первое что приходит в голову — сделать запрос сразу с учетом всех связанных данных, но это, согласитесь, нерационально, если связанные данные не запрашиваются клиентом.

Один из вариантов решения проблемы «SELECT N + 1» для graphql будет рассмотрен в этом сообщении.
Для примера возьмем две коллекции: «Авторы» (Author) и «Книги» (Book). Связь, как и следует полагать, «многие-ко-многим». У одного Автора может быть несколько Книг, и одна Книга может быть написана несколькими Авторами. Для хранения информации будем использовать базу данных mongodb и библиотеку mongoose.js

Связь между коллекциями «многие-ко-многим» реализуем при помощи вспомогательной коллекции «BookAuthor» и «виртуальных» полей.

// Author.js
const mongoose = require('mongoose');
const Schema = mongoose.Schema;

const schema = new Schema({
  name: String
});

schema.virtual('books', {
  ref: 'BookAuthor',
  localField: '_id',
  foreignField: 'author'
});

module.exports = schema;

// Book.js
const mongoose = require('mongoose');
const Schema = mongoose.Schema;

const schema = new Schema({
  title: String
});

schema.virtual('authors', {
  ref: 'BookAuthor',
  localField: '_id',
  foreignField: 'book'
});

module.exports = schema;

// BookAuthor.js
const mongoose = require('mongoose');
const Schema = mongoose.Schema;

const schema = new Schema({
  author: { type: mongoose.Schema.Types.ObjectId, ref: 'Author' },
  book: { type: mongoose.Schema.Types.ObjectId, ref: 'Book' }
});

module.exports = schema;

// mongoSchema.js
const mongoose = require('mongoose');
const Author = require('./Author');
const Book = require('./Book');
const BookAuthor = require('./BookAuthor');

mongoose.connect('mongodb://localhost:27017/books')
mongoose.set('debug', true);

exports.Author =  mongoose.model('Author', Author);
exports.Book =  mongoose.model('Book', Book);
exports.BookAuthor =  mongoose.model('BookAuthor', BookAuthor);


Теперь определим типы Author и Book в graphql. Есть небольшая проблема с тем, что эти типы взаимно ссылаются друг на друга. Поэтому для их взаимного доступа используется привязка ссылок к объекту модуля exports, а не привязка нового объекта к module.exports (который заменяет исходный объект), а также поле fields реалзовано в виде функции, что позволяет «отложить» чтение ссылки на объект при его создании до того момента, когда все циклические ссылки станут доступными:

// graphqlType.js
exports.Author = require('./Author');
exports.Book = require('./Book');

// Author.js
const graphql = require('graphql')
const graphqlType = require('./index')

module.exports = new graphql.GraphQLObjectType({
  name: 'author',
  description: 'Авторы',
  fields: () => ({
    _id: {type: graphql.GraphQLString},
    name: {
      type: graphql.GraphQLString,
    },
    books: {
      type: new graphql.GraphQLList(graphqlType.Book),
      resolve: obj => obj.books && obj.books.map(book => book.book)
    }
  })
});

// Book.js
const graphql = require('graphql')
const graphqlType = require('./index')

module.exports = new graphql.GraphQLObjectType({
  name: 'book',
  description: 'Книги',
  fields: () => ({
    _id: {type: graphql.GraphQLString},
    title: {
      type: graphql.GraphQLString,
    },
    authors: {
      type: new graphql.GraphQLList(graphqlType.Author),
      resolve: obj => obj.authors && obj.authors.map(author => author.author)
    }
  })
});


Теперь определим запрос Авторов, возможно, с перечнем их книг, и, возможно, с перечнем авторов (соавторов) этих книг.

const graphql = require('graphql');
const getFieldNames = require('graphql-list-fields');
const graphqlType = require('../graphqlType');
const mongoSchema = require('../mongoSchema');

module.exports = {
  type: new graphql.GraphQLList(graphqlType.Author),
  args: {
    _id: {
      type: graphql.GraphQLString
    }
  },
  resolve: (_, {_id}, context, info) => {
    const fields = getFieldNames(info);
    const where = _id ? {_id} : {};
    const authors = mongoSchema.Author.find(where)
    if (fields.indexOf('books.authors.name') > -1 ) {
      authors.populate({
        path: 'books',
        populate: {
          path: 'book',
          populate: {path: 'authors', populate: {path: 'author'}}
        }
      })
    } else if (fields.indexOf('books.title') > -1 ) {
      authors.populate({path: 'books', populate: {path: 'book'}})
    }
    return authors.exec();
  }
};


Для того чтобы определить, запрос каких полей пришел с клиента, используется библиотека graphql-list-fields. И если пришел запрос со вложенными объектами — то вызывается метод populate () библиотеки mongoose.

Теперь поэкпериментируем с запросами. Максимально возможный для нашей реализации запрос:

{
  author {
    _id
    name
    books {
      _id
      title
      authors {
        _id
        name
      }
    }
  }
}


будет выполнен 5-ю обращениями к базе данных:

authors.find({}, { fields: {} })

bookauthors.find({ author: { '$in': [ ObjectId("5b0fcab305b15d38f672357d"), ObjectId("5b0fcabd05b15d38f672357e"), ObjectId("5b0fcac405b15d38f672357f"), ObjectId("5b0fcad705b15d38f6723580"), ObjectId("5b0fcae305b15d38f6723581"),  ObjectId("5b0fedb94ad5435896079cf1"), ObjectId("5b0fedbd4ad5435896079cf2") ] } }, { fields: {} })

books.find({ _id: { '$in': [ ObjectId("5b0fcb7105b15d38f6723582") ] } }, { fields: {} })

bookauthors.find({ book: { '$in': [ ObjectId("5b0fcb7105b15d38f6723582") ] } }, { fields: {} })

authors.find({ _id: { '$in': [ ObjectId("5b0fcab305b15d38f672357d"), ObjectId("5b0fcad705b15d38f6723580") ] } }, { fields: {} })


Как видим, функция mongoose.js — populate () — не использует относительно новую возможность mongodb — $lookup, а создает дополнительные запросы. Но это не проблема «SELECT N + 1» т.к. новый запрос создается не для каждой строки, а для все коллекции. (Желание проверить как на самом деле работет функция mongoose.js populate () — одним запросом или несколькими — была одним из мотивов выбора не реляционной базы для этого примера).

Если же мы используем минималистический запрос:

{
  author {
    _id
    name
  }
}


то он сформирует только одно обращение к базе данных:

 authors.find({}, { fields: {} })


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

Для удобства читателей работающий пример расположил в репозитории.

apapacy@gmail.com
31 мая 2018 года

© Habrahabr.ru