реклама
разместить

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

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

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

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

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

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

2222
реклама
разместить
10 комментариев

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

2

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

1

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

1

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

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

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

1

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

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

Раскрывать всегда
«Если честно — *********»: создатель «Глаза Бога» рассказал, что закрыл Telegram-бота «до момента, пока не станет понятно, можно ли возобновить работу»
2222
44
11
Он так и не понял, что занимается незаконной деятельностью, даже в случае когда это "официальный" запрос.
Массовое кидалово. История про собеседование.

Когда-то я работал в СтеклоДоме и из пары человек мы быстро выросли в инхаус на 10+ человек.
Нанимаем верстальщика. Посмотрели анкеты, тестовые задачи. Назначаем встречу. Приходим я и разработчик Антон. На месте дожидается HR. Встреча, допустим, в 16-00.
Сидим, заготовили вопросы, ждём. 16-00. Тишина. 16-10 тишина.

Массовое кидалово. История про собеседование.
3333
2323
55
44
11
11
11
Привет. История с другой стороны. Нанимаюсь в одну фирму, еду из другого города. Чтобы я успел должны сойтись звезды и не подвести несколько видов транспорта. Договариваюсь на 10 утра. Успеваю. У работодателя на месте нет ни эйчара (он говорил, что подойдет позже), ни специалиста, который должен меня встречать. Жду полчаса, приходит специалист. Всё показывает, говорит об условиях, выясняется, что они отличаются от озвученных ранее. Время близится к обеду, звоню эйчару, чтобы поговорить вживую, выясняется, что его не будет сегодня. Занавес.
Как стать застройщиком в 2025 году? Кто такой GR в коммерческой недвижимости и почему без него не обойтись?

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

Кто такой GR в коммерческой недвижимости и почему без него не обойтись?
Мой бизнес умер за неделю: Как случайная встреча в кафе вернула мне 3.000.000 рублей за полгода

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

Мой бизнес умер за неделю: Как случайная встреча в кафе вернула мне 3.000.000 рублей за полгода
1111
11
Компания Light представила обновлённую версию телефона Phone — без магазина приложений и с кнопкой для съёмки фотографий

Стоит $599.

Источник: The Verge
1616
44
Стартаперы переизобрели бабушкофон
В Бангкоке ЧП после землетрясения — высотки эвакуируют, работу метро и торги на таиландской бирже ограничили

Правительство Таиланда предупредило о возможных повторных толчках.

1818
1313
33
Как хорошо, что мы живем в России
«Почувствовала, что я не самозванец»: чему учат на PR-стажировке ITCOMMS

Выпускники PR-стажировки by ITCOMMS из Узбекистана, Молдовы и Казахстана — о том, как программа помогает систематизировать знания и выйти из режима «самоучкин».

«Почувствовала, что я не самозванец»: чему учат на PR-стажировке ITCOMMS
11
SoftBank собрался вложить до $1 трлн в разработку ИИ в США — Nikkei

В январе 2025 года OpenAI, Oracle и SoftBank создали компанию Stargate для строительства дата-центров в стране.

44
«Наша структура — блокчейн в ритейле»: менеджер дискаунтера «Светофор» — об управлении, проверках и конкуренции

Несколько тезисов из интервью РБК с главой сети в Центральном федеральном округе Еленой Захаренко.

Захаренко. Источник фото: РБК
1515
66
44
- Мы торгуем фальсификатом, самым дешевым говном какое можем найти, нарушаем условия хранения, не платим налоги и кидаем поставщиков - Простите, что?... - Я говорю у нас блокчейн в ритейле
реклама
разместить
Минцифры определило порядок оплаты сбора за интернет-рекламу — «не позднее пятого числа третьего месяца квартала, следующего за платёжным периодом»

Контролировать платежи будет Роскомнадзор.

Фото РБК
2121
77
11
Господи, как же задолбали 😡
ФАС запросила у интернет-провайдеров информацию о препятствовании доступу в дома застройщика ПИК

В случае выявления нарушений, служба «примет меры».

2121
88
Вот это новость. Всегда был монопольный провайдер в ЖК пика, самолёта и пр и жильцы жаловались на это, а тут вдруг ФАС спохватился что оказывается есть такая практика!
День 1129: первый полёт «полностью импортозамещённого» SSJ 100 запланирован на апрель 2025 года

Собираем новости, события и мнения о рынках, банках и реакциях компаний.

Фото ТАСС
1515
66
22
Расскажите почему SSJ, которых довольно много в авиапарке России и с которыми нет катастроф - плохо.
[]