IT-инфраструктура для бизнеса и творчества
Разработка
Artem Kulakov

Инструкция: как сделать фильтрацию и пагинацию на GraphQL и Go

Привет! Меня зовут Артем и я занимаюсь архитектурой решений в Redmadrobot. По долгу службы мне приходится разбираться с разными технологиями и подходами. В этой инструкции я хотел бы показать, как реализовать фильтрацию и два вида пагинации, если у вас Go и вы планируете использовать GraphQL.

Мы уже довольно давно применяем Go для написания backend-сервисов, но обычно все ограничивается старым добрым REST-ом или gRPC. На одном из новых проектов было принято решение использовать GraphQL для клиентского API.

Эту технологию часто ругают за потенциально бесконечную глубину запросов, за проблему «N+1 запросов», отсутствие механизма пагинации «из коробки» и много за что еще. Для решения большинства этих проблем мы выбрали библиотеку gqlgen, которая умеет справляться с «N+1», бесконечной глубиной , и всем остальным. Кроме фильтрации и пагинации. О них и пойдет речь ниже.

Быстрое погружение в тему

В веб-дизайне и дизайне программного обеспечения под пагинацией понимают постраничный вывод информации, то есть показ ограниченной части информации на одной (веб)-странице (например, 10 результатов поиска или 20 форумных тредов). Она повсеместно используется в веб-приложениях для разбиения большого массива данных на странице и включает в себя навигационный блок для перехода на другие страницы — пагинатор.

Различают два вида пагинации:

  • Нумерованные страницы;
  • Последовательные страницы.

Есть еще подход с бесконечным скроллом, но это скорее UI-трюк, нежели какой-то отдельный вид пагинации.

Реализация нумерованных страниц

Этот подход вводит такие понятия, как limit и offset, которые хорошо известны любому знающему SQL человеку. Фактически, это самый простой и понятный способ сделать пагинацию. Для начала нужно создать необходимые модели в файле schema.graphqls:

type Query { todos(limit: Int = 10, offset: Int = 0): [Todo!]! } type Todo { id: ID! text: String! }

Потом дополнить структуру Resolver в файле resolver.go строкой todos []*model.Todo, сгенерировать резолверы командой gqlgen generate и убедиться, что сгенерировался файл с резолверами schema.resolvers.go с пустым резолвером для списка Todo.

type Resolver struct{ todos []*model.Todo }
func (r *queryResolver) Todos(ctx context.Context, filter *model.TodosFilter, limit *int, offset *int) ([]*model.Todo, error) { panic(fmt.Errorf("not implemented")) }

Теперь можно написать реализацию пагинации. Для простоты в примере использован обычный массив с несколькими моделями Todo.

func (r *queryResolver) Todos(ctx context.Context, filter *model.TodosFilter, limit *int, offset *int) ([]*model.Todo, error) { var allTodos []*model.Todo for i := 0; i < 20; i++ { todo := model.Todo{ ID: strconv.Itoa(i), Text: fmt.Sprintf("Todo #%d", i), } allTodos = append(allTodos, &todo) } var result []*model.Todo result = allTodos if limit != nil && offset != nil { start := *offset end := *limit + *offset if end > len(allTodos) { end = len(allTodos) } return result[start:end], nil } return result, nil }

Можно запустить сервер и убедиться, что он стал отзываться на запросы вида:

{ todos(limit:5, offset:15) { text } }
{ "data": { "todos": [ { "text": "Todo #15" }, { "text": "Todo #16" }, { "text": "Todo #17" }, { "text": "Todo #18" }, { "text": "Todo #19" } ] } }

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

Реализация последовательных страниц

Пагинация с применением курсоров несколько сложнее, т.к. вводит ряд дополнительных понятий, характерных только для GraphQL.

  • Connection — поле, данные из которого нужно пагинировать, содержит в себе edge-ы;

  • Edge — мета-информация об объекте списка. Cодержит в себе поля node и cursor;
  • Node — объект списка, он же модель данных;
  • PageInfo — параметры пагинации.

Более подробно об этих понятиях можно почитать здесь.

Для реализации этого подхода потребуется чуть больше кода и правок конфигов. В первую очередь необходимо убедиться, что файл gqlgen.yml содержит параметр autobind, смотрящий на каталог с моделями:

autobind: - "github.com/Fi5t/go-graphql-playground/graph/model"

После этого необходимо создать go-модели для обеспечения механизмов пагинации. Для этого в каталоге с моделями можно создать файл model.go с таким содержанием:

package model import ( "encoding/base64" "fmt" ) type UsersConnection struct { Users []*User From int To int } func (u *UsersConnection) TotalCount() int { return len(u.Users) } func (u *UsersConnection) PageInfo() PageInfo { return PageInfo{ StartCursor: EncodeCursor(u.From), EndCursor: EncodeCursor(u.To - 1), HasNextPage: u.To < len(u.Users), } } func EncodeCursor(i int) string { return base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("cursor%d", i+1))) }

А потом добавить соответствующие модели в schema.graphqls:

type Query { usersConnection(first: Int, after: ID): UsersConnection! } type User { id: ID! firstName: String! lastName: String! } type UsersEdge { node: User cursor: ID! } type UsersConnection { edges: [UsersEdge] pageInfo: PageInfo! totalCount: Int } type PageInfo { startCursor: ID! endCursor: ID! hasNextPage: Boolean! }

Теперь нужно дополнить структуру Resolver в файле resolver.go строкой users []*model.User, сгенерировать резолверы и убедиться, что они создались корректно:

type Resolver struct{ users []*model.User }
func (r *queryResolver) UsersConnection(ctx context.Context, first *int, after *string) (*model.UsersConnection, error) { panic(fmt.Errorf("not implemented")) } func (r *usersConnectionResolver) Edges(ctx context.Context, obj *model.UsersConnection) ([]*model.UsersEdge, error) { panic(fmt.Errorf("not implemented")) } // UsersConnection returns generated.UsersConnectionResolver implementation. func (r *Resolver) UsersConnection() generated.UsersConnectionResolver { return &usersConnectionResolver{r} } type usersConnectionResolver struct{ *Resolver }

Осталось добавить реализацию для UsersConnection и Edges. Здесь также для простоты реализации используется обычный массив с пользователями:

func (r *queryResolver) UsersConnection(ctx context.Context, first *int, after *string) (*model.UsersConnection, error) { var allUsers []*model.User for i := 0; i < 20; i++ { id := strconv.Itoa(i) user := model.User{ ID: id, FirstName: "Simple", LastName: "User", } allUsers = append(allUsers, &user) } from := 0 if after != nil { b, err := base64.StdEncoding.DecodeString(*after) if err != nil { return nil, err } i, err := strconv.Atoi(strings.TrimPrefix(string(b), "cursor")) if err != nil { return nil, err } from = i } to := len(allUsers) if first != nil { to = from + *first if to > len(allUsers) { to = len(allUsers) } } return &model.UsersConnection{ Users: allUsers, From: from, To: to, }, nil }
func (r *usersConnectionResolver) Edges(ctx context.Context, obj *model.UsersConnection) ([]*model.UsersEdge, error) { edges := make([]*model.UsersEdge, obj.To-obj.From) for i := range edges { edges[i] = &model.UsersEdge{ Node: obj.Users[obj.From+i], Cursor: model.EncodeCursor(obj.From + i), } } return edges, nil }

Можно запустить сервер и убедиться, что он реагирует на запросы с пагинацией через курсор:

{ usersConnection(first:5, after:"Y3Vyc29yMg==") { pageInfo { startCursor endCursor hasNextPage } edges { cursor node { firstName lastName } } } }

В ответе должен быть json похожий на этот:

{ "data": { "usersConnection": { "pageInfo": { "startCursor": "Y3Vyc29yMw==", "endCursor": "Y3Vyc29yNw==", "hasNextPage": true }, "edges": [ { "cursor": "Y3Vyc29yMw==", "node": { "firstName": "Simple", "lastName": "User" } }, { "cursor": "Y3Vyc29yNA==", "node": { "firstName": "Simple", "lastName": "User" } }, { "cursor": "Y3Vyc29yNQ==", "node": { "firstName": "Simple", "lastName": "User" } }, { "cursor": "Y3Vyc29yNg==", "node": { "firstName": "Simple", "lastName": "User" } }, { "cursor": "Y3Vyc29yNw==", "node": { "firstName": "Simple", "lastName": "User" } } ] } } }

Реализация фильтрации

Фильтрация — довольно простая концепция, поэтому ее я оставил напоследок. Покажу на примере с моделью Todo, которую мы уже реализовали выше. Нужно добавить новый input-параметр в файл с моделями GraphQL и указать этот параметр в запросе:

type Query { todos(filter: TodosFilter): [Todo!]! } input TodosFilter { id: ID! }

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

func (r *queryResolver) Todos(ctx context.Context, filter *model.TodosFilter, limit *int, offset *int) ([]*model.Todo, error) { var allTodos []*model.Todo for i := 0; i < 20; i++ { todo := model.Todo{ ID: strconv.Itoa(i), Text: fmt.Sprintf("Todo #%d", i), } allTodos = append(allTodos, &todo) } var result []*model.Todo if filter != nil { for _, todo := range allTodos { if filter.ID == todo.ID { result = append(result, todo) } } } else { result = allTodos } return result, nil }

Теперь можно проверить работоспособность фильтрации:

{ todos(filter: { id: 5 }) { id text } }
{ "data": { "todos": [ { "id": "5", "text": "Todo #5" } ] } }

Заключение

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

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

Полезные ссылки

(function () { let cdnUrl = `https://specialsf378ef5-a.akamaihd.net/SelectelBranding/images/` let previousArticleNumber = null let currentArticleNumber = 0 let platform = 'Desktop' let articles = [ { name: 'camera', url: `${cdnUrl}CameraCat`, text: 'умную камеру для\u00A0наблюдения за\u00A0котиками', link: 'https://vc.ru/selectel/306690', num: 3 }, { name: 'chill', url: `${cdnUrl}ChillCat`, text: 'трекер, который подскажет, когда пора отдохнуть', link: 'https://vc.ru/promo/288561-eye-tracker', num: 1 }, { name: 'cloud', url: `${cdnUrl}CloudCat`, text: 'котика: даёшь ему «пять», а\u00A0он делает бэкап в облако', link: 'https://vc.ru/dev/294799-maneki-neko', num: 2 } ] let buttonCycle = document.querySelector('.button--cycle') let buttonChoose = document.querySelector('.button--choose') let buttonMobile = document.querySelector('.button--mobile') let textField = document.querySelector('.selectel-footer-subtitle') let imageAgent = document.querySelector('.image--agent') let banner = document.querySelector('.selectel-footer') buttonCycle.addEventListener('click', cycleClick) buttonChoose.addEventListener('click', () => sendEvent(`Promo ${articles[currentArticleNumber].num} Left`, 'Click')) buttonMobile.addEventListener('click', () => sendEvent(`Promo ${articles[currentArticleNumber].num} Left`, 'Click')) let media = window.matchMedia("(max-width: 570px)") media.addEventListener('change', matchMedia) function matchMedia() { if (media.matches) { platform = 'Mobile' } else { platform = 'Desktop' } update() } matchMedia() function cycleClick(event) { sendEvent(`Promo ${articles[currentArticleNumber].num} Right`, 'Click') if (event) { event.preventDefault() event.stopPropagation() } window.open('https://vc.ru/tag/selectelDIY', '_blank') //cycle(event) } function cycle(event) { // incrementArticleNumber() textField.innerHTML = generatedText() imageAgent.src = articles[currentArticleNumber].url + platform + '.svg?3' imageAgent.setAttribute("class", "") imageAgent.classList.add('image--agent', articles[currentArticleNumber].name) banner.href = articles[currentArticleNumber].link } function update() { banner.href = articles[currentArticleNumber].link imageAgent.src = articles[currentArticleNumber].url + platform + '.svg' textField.innerHTML = generatedText() } function incrementArticleNumber() { previousArticleNumber = currentArticleNumber if (currentArticleNumber >= articles.length - 1) { currentArticleNumber = 0 } else { currentArticleNumber++ } } const sendEvent = (label, action = 'Click') => { const value = `SelectelDIY — loc: Footer — ${label} — ${action}`; if (window.dataLayer !== undefined) { window.dataLayer.push({ event: 'data_event', data_description: value, }); } }; function generatedText() { let defaultText if (platform === 'Desktop') { defaultText = `Мы тут собрали %text%. Хотите научим?` } else { defaultText = `Мы тут собрали %text%.` } return defaultText.replace('%text%', articles[currentArticleNumber].text) } function getRandom(min, max) { min = Math.ceil(min) max = Math.floor(max) return Math.floor(Math.random() * (max - min + 1)) + min } (function create() { currentArticleNumber = getRandom(0, articles.length - 1) cycle() let page = document.querySelector('.page--entry') if (page) { function insertAfter() { let parents = page.querySelectorAll('[data-id="7"]') let referenceNode = parents[0] referenceNode.parentNode.insertBefore(banner, referenceNode.nextSibling); loaded() } setTimeout(() => insertAfter(), 0) } }()) function loaded() { banner.classList.add('loaded') } loadImages([ `${cdnUrl}CameraCatDesktop.svg`, `${cdnUrl}ChillCatDesktop.svg`, `${cdnUrl}CloudCatDesktop.svg`, `${cdnUrl}CameraCatMobile.svg`, `${cdnUrl}ChillCatMobile.svg`, `${cdnUrl}CloudCatMobile.svg?3`, ]) function loadImages(urls) { return Promise.all(urls.map(function (url) { return new Promise(function (resolve) { var img = document.createElement('img'); img.onload = resolve; img.onerror = resolve; img.src = url; }); })); } }())
0
10 комментариев
Популярные
По порядку
Написать комментарий...
Артем Бирюков

Как сделать фильтрацию и пагинацию на GraphQL и Go?
Использовать Prisma - https://github.com/prisma/prisma-client-go

Ответить
2
Развернуть ветку
Artem Kulakov

Prisma это клиент. В статье идет речь о реализации серверной части.

Ответить
1
Развернуть ветку
Артем Бирюков

Prisma - это серверная часть между GraphQL и базой, где есть разные клиенты. И она нужна лишь для того чтобы самому не педалить свой бэк на GraphQL с нуля.

Ответить
2
Развернуть ветку
Artem Kulakov

Посмотрю подробнее, но в нашем случае "педалить свой бэк" это именно та задача, которую нужно было решить.

Ответить
0
Развернуть ветку
Леонид Стасюков

Если у вас база на PostgresSql то я бы на Hasura взглянул бы. Быстрая, простая, удобная.

Ответить
1
Развернуть ветку
Artem Kulakov

Благодарю, добавил себе в закладки. Но у нас на этом проекте не PostgreSQL, поэтому рассмотрю на будущее.

Ответить
0
Развернуть ветку
Alina Iudina

Для простоты в примере использован обычный массив с несколькими моделями Todo
Здесь также для простоты реализации используется обычный массив с пользователями

 []*model.User и []*model.Todo - это срезы

Ответить
1
Развернуть ветку
Artem Kulakov

Если совсем занудствовать, то можно сказать, что слайсы это абстракция над массивами, но спасибо за ваше уточнение.

Ответить
0
Развернуть ветку
Shepard

Сообщение удалено

Ответить
0
Развернуть ветку
Денис Петров

Сообщение удалено

Ответить
0
Развернуть ветку
Читать все 10 комментариев
Украли деньги с карты Тинькофф

30.12.2021 года я с семьей дочери поехала в магазин "Спортмастер" в г. Москва покупать коньки. Уведомлений от Тинькофф не было. 31.12.2021 года я увидела что есть какие то пуш уведомление при нажатии на него оно исчезло. Когда я зашла в онлайн приложение, то увидела что у меня с кредитной карты сняли 106 000 рублей , было снятие 7 суммами. Кроме…

Сервис поиска временных сотрудников Ventra Go! привлек 700 млн рублей от фонда «ВТБ Капитал Инвестиции» Статьи редакции

Платформа помогает компаниям находить кассиров, продавцов, курьеров и других сотрудников.

Геозоны: разделяй и властвуй

Ильдар Бикташев, руководитель отдела картографии и алгоритмов, уже рассказывал, какие задачи стоят перед картографическими сервисами Master Delivery. Сегодня продолжаем разговор о том, как геоаналитические разработки решают маркетинговые и логистические задачи бизнеса.

Один небанальный совет в борьбе с бессонницей

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

Uber извинился перед собаками

Генеральный директор Uber Дара Хосровшахи принес извинения и даже дал скидку в 10 долларов…собакам.

Анализ тональности текста с использованием фреймворка LightAutoML

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

Для абонентов Мегафон и Yota. Вы не знали, а вам открыли счет в банке!

Для тех кому интересна развязка, она внизу (выделено жирным).

Акции роста российских эмитентов на ближайшие 5 лет

Пандемия меняет мир и формируется новая тенденция на ближайшие годы. Ниже будут представлены компании, акции которых способны в новых мировых реалиях опередить динамику фондовых индексов.

2 года в Сбере. Как это было

Привет, я Стас 👋. Последние 7 лет я называю себя дизайнером. Успел поработать с Breadhead, AIC, Яндексом, Сменой (она же дизайн государственных систем) и Сбером.

Круговая видеостена на 96 панелей и умное управление: как мы внедряли автоматизацию в ресторане в «Сколково» и что вышло

За красивой картинкой — 8200 метров кабеля и два двухметровых трековых шкафа с оборудованием. При этом энергопотребление ресторана сильно не увеличено.

Идеальный тимлид: миф или реальность?

Кто такой идеальный тимлид? Что ожидает руководство от такого сотрудника? Колонка Алексея Кирсанова, руководителя разработки «Битрикс24».

null