Инструкция: как сделать фильтрацию и пагинацию на 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

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

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