Конкурс инструкций

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

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

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

0
10 комментариев
Написать комментарий...
Артем Бирюков
Как сделать фильтрацию и пагинацию на GraphQL и Go?

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Ответить
Развернуть ветку
7 комментариев
Раскрывать всегда