Конкурс инструкций
Разработка
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 нет. При этом все еще хотелось бы иметь эти механизмы доступными «из коробки», а не писать каждый раз руками.

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

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

{ "author_name": "Artem Kulakov", "author_type": "self", "tags": ["\u043f\u0430\u0433\u0438\u043d\u0430\u0446\u0438\u044f","selectel_\u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f","graphql","go","backend","5","19","18","17","16","15"], "comments": 10, "likes": 19, "favorites": 21, "is_advertisement": false, "subsite_label": "dev", "id": 154699, "is_wide": true, "is_ugc": true, "date": "Thu, 03 Sep 2020 14:12:55 +0300", "is_special": false }
0
10 комментариев
Популярные
По порядку
Написать комментарий...
2

Как сделать фильтрацию и пагинацию на GraphQL и Go?

Использовать Prisma - https://github.com/prisma/prisma-client-go

Ответить
1

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

Ответить
2

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

Ответить
0

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

Ответить
1

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

Ответить
0

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

Ответить
1

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

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

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

Ответить
0

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

Ответить
0

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

Ответить
0

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

Ответить
Читать все 10 комментариев
В «Газпромбанке» теперь можно менять валюту по биржевому курсу
TikTok Hashtag Challenge: как запускать рекламные кампании, которые процитируют миллионы пользователей

Реклама конкурирует со всем существующим контентом, и простых просмотров уже недостаточно, чтобы привлечь внимание аудитории к бренду. 64% пользователей хотят, чтобы бренды обращались к ним лично. 57% увеличивают расходы, когда это происходит.

Сервис Pump запустил подписку на бензин и заправку без участия водителя в Москве, Петербурге и Сочи Статьи редакции

Количество бензина в баке контролируют датчики, а заливает топливо заправщик.

Кейс. Как увеличить конверсию приложения на 6,3% за счет графического ASO?

Показываем на примере приложения для занятий спортом, как с помощью качественной и продающей графики на странице в Google Play увеличить конверсию из просмотра в установку на 6,3%. Бонус: пять примеров конвертирующей графики.

Офис в Discord. Наши лайфхаки по организации рабочего пространства студии в геймерском софте

Конкретные советы, полезные боты и наш практический опыт по работе в Discord.

X5 Group начала тестировать подписку на свои сервисы под рабочим названием «Пакет» Статьи редакции

На время тестов она стоит от 199 до 499 рублей в месяц.

Массовый сбой у некоторых владельцев Android телефонов

С вечера 21 июня 2021 года у некоторых владельцев телефонов наблюдается проблема - появляется ошибка Google сервисов:
"в приложении "google" гугл снова произошел сбой" или "Google все время закрывается".

Уроки 2020 года или как мы выросли в два раза быстрее рынка
Курс биткоина и других криптовалют упал на 10-37% на фоне запретов майнинга в Китае Статьи редакции

Альтернативные криптовалюты пострадали больше, чем биткоин, отмечают эксперты.

Как начать экспортировать и продавать свои товары онлайн: советы Почты России

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

Сооснователь «Самоката» — о первых деньгах, армейском опыте, конкуренции между сервисами и транспорте курьеров

Вячеслав Бочаров окончил военно-финансовое училище, два года отслужил по контракту в Чечне, а потом ушел в ритейл и логистику — работать с «Магнитом» и «Почтой России». Теперь он развивает свой стартап, оборот которого за прошлый год — 9,7 млрд рублей.

Комментарии
null