Что такое пагинация в программировании
Перейти к содержимому

Что такое пагинация в программировании

  • автор:

Пагинация¶

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

../../_images/opennet.png

Paginate¶

  • webhelpers.paginate
  • https://github.com/Pylons/paginate
  • https://v4-alpha.getbootstrap.com/components/pagination/

Модуль paginate делит список статей на страницы. Номер страницы передается методом GET , в параметре page . По умолчанию берется первая страница.

p = paginate.Page( items, page=1, items_per_page=42 )

Пример Mako шаблона, использующего Bootstrap4 для пагинации.

inherit file="base.mako"/> block name="content"> h2>$tag.title()>h2> br/> div class="row"> %for item in p: div class="col"> div class="row"> a href="$_static_prefix>/item/$item.id>.html"> �� a> div> br/> div class="row"> pre id='id-$item.id>' width=100%> $item.text> pre> div> div> %endfor div> # https://v4-alpha.getbootstrap.com/components/pagination/ div class="row"> nav aria-label="Page navigation example"> ul class="pagination"> $, link_tag=lambda page: 'li class="page-item <> <>">a class="page-link" href="<>"><>a>li>'.format( 'active' if page['type'] == 'current_page' else '', 'disabled' if not len(page['href'].strip()) else '', page['href'], page['value'] ) )> ul> nav> div> block>

../../_images/bootstrap.png

Блог¶

Данные¶

Для начала наполним блог случайными статьями при помощи функции generate_lorem_ipsum из пакета jinja2.utils .

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
from jinja2.utils import generate_lorem_ipsum ARTICLES = [] for id, article in enumerate(range(100), start=1): title = generate_lorem_ipsum( n=1, # Одно предложение html=False, # В виде обычного текста min=2, # Минимум 2 слова max=5 # Максимум 5 ) content = generate_lorem_ipsum() ARTICLES.append( 'id': id, 'title': title, 'content': content> )

../../_images/long_list_blog_articles.png

Много статей не помещаются на экран

Paginate¶

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
class BlogIndex(BaseBlog): def __iter__(self): self.start('200 OK', [('Content-Type', 'text/html')]) # Get page number  from urllib.parse import parse_qs  values = parse_qs(self.environ['QUERY_STRING'])  # Wrap articles to paginated list  from paginate import Page  page = values.get('page', ['1', ]).pop()  paged_articles = Page(  ARTICLES,  page=page,  items_per_page=8,  )  yield str.encode( env.get_template('index.html').render( articles=paged_articles  ) )

templates/index.html ¶

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
 extends "base.html" %>  block title %>Index endblock %>  block content %> div class="blog__title">Simple Blogdiv> a href="/article/add" class="blog__button">add articlea> div class="blog-list">  for article in articles %> div class="blog-list__item"> div class="blog-list__item-id"> <article.id >>div> a href="/article/ <article.id >>" class="blog-list__item-link"> <article.title >>a> div class="blog-list__item-action"> a href="/article/ <article.id >>/edit" class="blog-list__item-edit">edita> a href="/article/ <article.id >>/delete" onclick="return confirm_delete();" class="blog-list__item-delete">deletea> div> div>  endfor %> div> div class="paginator">  <articles.pager(url="?page=$page") >>  div>  endblock %>

В результате на каждой странице отображаются только 8 статей.

../../_images/blog_with_page.png

Блог со страницами

Previous: Статика Next: WebOb

© Copyright 2020, Кафедра Интеллектуальных Информационных Технологий ИнФО УрФУ. Created using Sphinx 1.7.6.

Создаем кэшируемую пагинацию, которая не боится неожиданного добавления данных в БД

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

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

Существующие методы

1. Пагинация (разделение на отдельные страницы)

Пример с сайта habr.com

Пагинация или разделение на отдельные страницы — достаточно старый способ разделения контента, который, в том числе используется на Хабре. Основным преимуществом является его универсальность и простота реализации как со стороны сервера так и клиентской части.

Код запроса данных из бд чаще всего ограничивается парой строк.

Тут и далее примеры на языке arangodb aql, я скрыл код сервера т.к там пока ничего интересного.

// Возврат по 20 элементов для каждой страницы. LET count = 20 LET offset = count * $ FOR post IN posts SORT post.date DESC // сортируем от новых к старым LIMIT offset, count RETURN post

На стороне клиента мы запрашиваем и выводим получившийся результат, я использую vuejs с nuxtjs для примера, но то же самое можно проделать на любом другом стеке, все специфичные для vue моменты я буду подписывать.

# https://example.com/posts?page=3 main.vue   

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

   

Минусы данного способа

  • При достижении конца страницы пользователю нужно переключаться на следующую страницу вручную.
  • Не получится кешировать результаты, т.к посты находящиеся на странице 2, при добавлении новых, непременно сместятся на страницу 3, 4 и так далее, т.е одна и та же операция GET возвращает разные результаты в зависимости от количества постов.
  • Если в момент перелистывания добавятся новые посты, то мы повторно увидим просмотренные элементы на следующей странице и напротив, пропустим если будем листать в обратную сторону.
2. Бесконечный скроллинг

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

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

При таком подходе проблема №3 проявляются еще более явно, если раньше мы не могли увидеть 2 похожих элемента рядом, то теперь это станет обычной ситуацией, конечно можно воспользоваться грязным трюком и отфильтровывать элементы с совпадающим id прямо на клиенте, но что если добавится 40 новых элементов за раз? Мы потратим 3 запроса к серверу, чтобы достичь новых результатов, т.к прошлые сместятся на 2 страницы (при условии что на одной странице 20 элементов). Это не мой подход!

Как решают эту проблему люди из интернета:

  • Используют описанный мной выше подход, я не искал подтверждение, но я практически уверен в этом, т.к это самое простое решение которое может прийти на ум, его можно использовать для быстрого прототипирования или создания mvp.
  • Создают уникальный идентификатор при первом запросе, и сохраняют результаты запроса на сервере, а затем выдают порционно. Тут сразу напрашивается 2 минуса. Во-первых, это использование лишней памяти сервера для хранения результатов всех пользователей. Во-вторых, более сложная реализация, требующая и логики хранения результатов для каждого пользователя, и логики удаления устаревших запросов. Я уверен, что такие реализации существуют и возможно некоторым удалось решить проблему излишней памяти, но проще система от этого не стала, да и проблему кеширования это не решает, а лишь усугубляет ситуацию.
  • Возможны и другие более или менее изобретательные решения, но то что я хочу вам предложить я пока не встречал. В свое время мне бы очень помогла подобная статья, поэтому я и решил её создать. Думаю что людям с похожей задачей она окажется полезной!

Моя реализация

Основная идея в том, что нам придется немного изменить логику запроса к базе, при этом не потребуется добавлять новые сущности или добавлять новые параметры в запрос.

Обновляем код на сервере

Для начала решим проблему кеширования, для этого просто всё перевернем.

Теперь последняя страница станет страницей номер 0, а предпоследняя страница номером 1, слово страница (page) сюда уже не вписывается, т.к мы с детства привыкли что в книжках страницы идут с начала, поэтому используем более нейтральное слово offset (смещение).

LET count = 20 LET offset = $ FOR post IN posts SORT post.date ASC // для этого отсортируем всё в обратном порядке LIMIT offset, count RETURN post

Теперь сколько постов мы бы ни добавили, GET «/?offset=0» всегда будет возвращать один и тот же результат.

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

async getPosts() < const isOffset = offset !== undefined if (isOffset && isNaN(+offset)) throw new BadRequestException() const count = 20 // Смещение должно быть кратно количеству элементов, чтобы результаты не пересекались if (offset % count !== 0) throw new BadRequestException() const sort = isOffset ? ` SORT post.date DESC LIMIT $, $ ` : ` SORT post.date ASC LIMIT 0, $ // Возвращаем больше чем нужно если это первая страница* ` const q = < query: ` FOR post IN posts $RETURN post `, bindVars: <> > // получаем результат запроса вместе с общим количеством найденных элементов const cursor = await this.db.query(q, ) const fullCount = cursor.extra.stats.fullCount /* *Если общее число элементов в базе не кратно count то в начальном запросе приходит 2 страницы [21-39] элементов В таком случае вторую страницу нужно пропустить т.к она уже входит в первую Если общее число делится на 20 то в первом запросе приходит 1-я страница c count элементов */ let data; if (isOffset) < // отсекаем попытку получить вторую страницу если она встроена в первую const allow = offset else < const all = await cursor.all() if (fullCount % count === 0) < // отрезаем лишние 20 элементов, это можно сделать как тут, так и в запросе к бд, вопрос лишь в оптимизации data = all.slice(0, count) >else < /* Тут посложней, если ранее мы могли иметь на последней странице 0-20 элементов, то теперь там нам всегда возвращается 20 элементов и недостачу нужно компенсировать, для этого на первую страницу добавляются дополнительные 0-20 элементов к имеющимся, в запросе к бд для первой страницы мы возвращаем с запасом 40 элементов и затем здесь отрезаем лишние */ const pagesCountUp = Math.ceil(fullCount / count) const resultCount = fullCount - pagesCountUp * count + count * 2 data = all.slice(0, resultCount) >> if (!data.length) throw new NotFoundException() return < fullCount, count: data.length, data >>

Чего мы этим добились:

  • Теперь перекрытия id после добавления новых элементов стали невозможны.
  • Запросы теперь статичны и легко поддаются кешированию, единственным плавающим по количеству элементов и их id остался запрос без параметра offset.
  • Наш код на клиенте теперь не работает(

Минусы моего способа:

  • Вопрос что делать при удалении все ещё открыт, это не частая операция, поэтому можно каждый раз полностью сбрасывать кэш, либо возвращать null вместо отсутствующего элемента, это неплохое решение, т.к. зачастую реального удаления с сервера не происходит, элемент лишь помечается как удаленный, если таких «null-зомби» станет много, то можно удалить все null-зомби из выдачи и сбросить кэш для всех запросов.
  • Если новый элемент оказывается не в начале после сортировки по выбранному полю (например по названию), то данный алгоритм не сработает. Поэтому подходит только сортировка по возрастающим или убывающим полям (например по дате или по id).
Обновляем код на клиенте

Заодно я покажу как сделать бесконечную прокрутку из пункта №2.

   

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

Бонус: Добавляем гибкую систему перехода по страницам

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

Основа метода для генерации пагинации взята из этого обсуждения: https://gist.github.com/kottenator/9d936eb3e4e3c3e02598#gistcomment-3238804 и скрещена с моим решением.

Показать продолжение бонуса

В начале вам нужно добавить этот вспомогательный метод внутрь тега

const getRange = (start, end) => Array(end - start + 1).fill().map((v, i) => i + start) const pagination = (currentPage, pagesCount, count = 4) => < const isFirst = currentPage === 1 const isLast = currentPage === pagesCount let delta if (pagesCount else < // delta === 2: [1 . 4 5 6 . 10] // delta === 4: [1 2 3 4 5 . 10] delta = currentPage >count + 1 && currentPage < pagesCount - (count - 1) ? 2 : 4 delta += count delta -= (!isFirst + !isLast) >const range = < start: Math.round(currentPage - delta / 2), end: Math.round(currentPage + delta / 2) >if (range.start - 1 === 1 || range.end + 1 === pagesCount) < range.start += 1 range.end += 1 >let pages = currentPage > delta ? getRange(Math.min(range.start, pagesCount - delta), Math.min(range.end, pagesCount)) : getRange(1, Math.min(pagesCount, delta + 1)) const withDots = (value, pair) => (pages.length + 1 !== pagesCount ? pair : [value]) if (pages[0] !== 1) < pages = withDots(1, [1, '. ']).concat(pages) >if (pages[pages.length - 1] < pagesCount) < pages = pages.concat(withDots(pagesCount, ['. ', pagesCount])) >if (!isFirst) pages.unshift('<') if (!isLast) pages.push('>') return pages >

Добавляем недостающие методы

   

Теперь, при необходимости, можно перейти на нужную страницу.

Пейджинг Paging

Постраничная навигация (пейджинг или пагинация) — способ представления большого количества однородной информации, когда контент разбивается на страницы.

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

Когда использовать

Используйте пейджинг, когда другие способы структурирования информации (категории, фильтры) уже задействованы.

Описание работы

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

Вид пейджинга по умолчанию:

Пейджинг нужен только для двух и более страниц. Если контента только на одну страницу — не показывайте пейджинг.

Если последней страницы не существует и она не может быть нужна пользователю, ссылка на нее не показывается. Например, в результатах поиска:

Если страниц 7 и меньше, то показываются ссылки сразу на все 7 страниц:

Если больше 7 страниц, показывается ссылка на последнюю страницу после многоточия:

Слева и справа от текущей присутствуют ссылки на 2 соседние страницы. Чтобы пользователь мог перемещаться через одну. Плюс ссылки на первую и последние страницы.

Исключения — пятые страницы с начала и с конца. В таком случае показываются ссылки на все страницы. Иначе за многоточием придется скрывать 1 страницу:

Такое же правило и для пейджинга без последней страницы. При переходе «Дальше» всегда сохраняется две страницы вперед и назад от текущей:

Если новые записи добавляются в начало выборки очень быстро, это может вызывать неудобства: к тому времени пока пользователь решит перейти на вторую страницу, появится такое количество новых записей, что после перехода пользователь увидит то же самое, что видел на первой странице. Часто эту проблему решают использованием обратного пейджинга, когда контент жестко привязан к номеру страницы. Но обратный пейджинг — это плохой интерфейс, он перекладывает сложность на пользователя:

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

Все состояния

При переходе между первые тремя страницами, ссылка «Дальше» остается неподвижной. Это позволяет прокручивать страницу колесиком мыши, и кликать «Дальше» повторно не прицеливаясь.

Фокус и работа с клавиатурой

При переходе к пейджингу клавишей Tab первая страница получает фокус — появляется чёрная рамка.

Переключение фокуса между страницами производится клавишами со стрелками ← → .

Tab переводит фокус на следующий контрол. Это нужно, чтобы пользователь мог быстро перемещаться по странице табом, не «залипал» на сложносоставных контролах, типа пейджинга или группы радиокнопок.

Горячие клавиши

Между страницами можно переключаться используя сочетание клавиш Ctrl → и Ctrl ← в Windows, и ⌥ → и ⌥ ← на Mac.

Об этом подсказывает подпись под выбранной страницей.

По умолчанию такое управление доступно только когда пейджинг в фокусе, но компонент можно переключить в режим когда такое управление будет доступно всегда.

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

Дизайн

Для ссылки «Дальше» используется иконка из набора интерфейсных иконок

Маркер текущей страницы — черный круг  #000 с прозрачностью 9 %.

Маркер наведения — черный круг  #000 с прозрачностью 5 %.

Область клика больше самой цифры и совпадает по размерам с маркером наведения:

Начиная с двухзначных номеров страниц, круг превращается в овал:

Пейджинг страниц в соцсетях

Для пагинации страниц используют смещение (OFFSET) и курсорную пагинацию (по ID), как более быструю. Тем не менее есть ещё один малоизвестный вид пагинации по меткам страниц (MARKS). Она является разновидностью курсорной пагинации, но использует не идентификатор, а ряд полей перечисленных в ORDER BY SQL-запроса.

Рис.1. Пагинация по смещению, курсорная пагинация и пагинация по меткам страниц.

Всем известен LIMIT в Mysql, OFFSET LIMIT в Postgres и TOP в MSSQL. Именно через них и делают пейджинг страниц. Однако чтобы сместиться к странице с большим смещением, условию (WHERE) придётся поработать и sql-запрос начнёт тормозить.

Давайте бросим взгляд на проблему пагинации по смещению на примере:

SELECT product.id FROM product WHERE product.price > 3000 AND product.brand="Love Republic" AND EXISTS(SELECT 1 FROM stock JOIN shop ON stock.shop_id=shop.id WHERE stock.product_id=product.id AND shop.city_code=7800000000 LIMIT 1 ) ORDER BY product.innovation DESC LIMIT 1000000, 10

Допустим клиент интернет-магазина решил прокрутить каталог товаров сразу на середину. В этом случае придётся применить условия в WHERE к миллионам записей, пока мы дойдём до нужных.

Есть ли способ не использовать OFFSET, а сразу выбрать нужные записи по индексу? Такой способ есть. Правда он требует пожертвовать номерами страниц и использовать вместо них значения полей по которым будет происходить сортировка. Так вместо запроса GET /products/? page=100000&order=innovation, будет запрос GET /products/?order=innovation&page=2021-12-12,5000.

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

SELECT product.id FROM product WHERE product.innovation < "2021-12-12" OR product.innovation="2021-12-12" AND product.id >= 5000 . ORDER BY product.innovation DESC, product.id ASC LIMIT 10

Этот запрос аналогичен предыдущему. Троеточием я сократил условия оставшиеся неизменными. Смещение же было заменено на дополнительные условия: по индексу innovation тут же можно переместиться к товарам нужной страницы. Так как несколько товаров могли появится в одну и ту же дату, то добавлена сортировка по уникальному идентификатору.

Если же сортировка будет осуществляться по нескольким полям, то для каждого поля в сортировке вначале проверяем, что это поле больше своего значения (если направление сортировки ASC и меньше для DESC), а затем через OR, если же поле равно значению, указывающему на страницу, то проверяем следующее поле и так далее. Например, для ORDER BY product.innovation DESC, product.price, product.id:

(product.innovation < 2021-12-12 OR product.innovation = 2021-12-12 AND product.price >610 OR product.innovation = 2021-12-12 AND product.price = 610 AND product.id >= 5000)

Чтобы автоматизировать построение sql-запроса можно использовать функцию, вроде функции make_query_for_order на perl:

use strict; use warnings; # Оборачивает стрки в одинарные кавычки, а числа пропускает sub quote < my ($value) = @_; use bytes; $value =~ /^-?\d+(\.\d+)?$/? $value: do < $value =~ s/[\\']/\\$&/g; "'$value'" >> # Создаёт части sql-запроса для сортировки по условию, а не распространённому лимиту sub make_query_for_order(@) < my ($order, $next) = @_; my @orders = split /\s*,\s*/, $order; my @order_direct; my @order_sel = map < my $x=$_; push @order_direct, $x =~ s/\s+(asc|desc)$//i? uc $1: "ASC"; $x >@orders; my $select = @order_sel==1? $order_sel[0]: join "", "concat(", join(",',',", @order_sel), ")"; return $select, 1 if $next eq ""; my @next = map quote($_), split /,/, $next; my @op = map < /^A/? ">": " @order_direct; # id -> id >= next[0] # id, update -> id > next[0] OR and my @whr; for(my $i=0; $i elsif($j != $#orders) < push @opr, "$order_sel[$j] $op[$j] $next[$j]"; >else < push @opr, "$order_sel[$j] $op[$j]= $next[$j]"; >> push @whr, join " AND ", @opr; > my $where = join "\nOR ", map "$_", @whr; return $select, "($where)", \@order_sel; > $\ = "\n"; use Data::Dumper; print Dumper make_query_for_order "product.innovation DESC, product.price, product.id", ""; print Dumper make_query_for_order "product.innovation DESC, product.price, product.id", "2021-12-12,610,5000";

Функция make_query_for_order принимает два параметра:

  1. $order (строка): список столбцов из ORDER BY.
  2. $next (строка): параметр page, то есть — значения для столбцов сортировки с которых будет начинаться следующая страница через запятую.

Если $next пуст (нужна первая страница), то будет возвращён массив из двух элементов — для вставки в SELECT и WHERE sql-запроса:

$VAR1 = 'concat(product.innovation,\',\',product.price,\',\',product.id)'; $VAR2 = 1;

То есть sql-запрос будет таким:

SELECT product.id, concat(product.innovation,product.price,product.id) as next FROM product WHERE 1 . ORDER BY product.innovation DESC, product.price ASC, product.id ASC LIMIT 11

Тут LIMIT 11, а не 10, так как мы выбираем так же следующую строку после страницы и используем next как метку следующей страницы. Допустим она будет 2021-12-12,610,5000, как во втором вызове нашей функции:

$VAR1 = 'concat(product.innovation,\',\',product.price,\',\',product.id)'; $VAR2 = '(product.innovation < 2021-12-12 OR product.innovation = 2021-12-12 AND product.price >610 OR product.innovation = 2021-12-12 AND product.price = 610 AND product.id >= 5000)'; $VAR3 = [ 'product.innovation', 'product.price', 'product.id' ];

И мы получаем запрос для страницы с 2021-12-12,610,5000:

SELECT product.id, concat(product.innovation,product.price,product.id) as next FROM product WHERE (product.innovation < 2021-12-12 OR product.innovation = 2021-12-12 AND product.price >610 OR product.innovation = 2021-12-12 AND product.price = 610 AND product.id >= 5000) . ORDER BY product.innovation DESC, product.price ASC, product.id ASC LIMIT 11

Выводы

Такая схема меток начальной записи страницы, вместо нумерации страниц, подойдёт для последовательных листингов. Таких как новостной листинг в VK, когда нужно прокручивать страницы последовательно. Переместиться к какой-то далёкой странице можно по карте значений (если используются даты, то — календарю). Зато эти неудобства компенсируются мгновенным перемещением к нужной странице по индексу.

Ссылки

  1. Пейджинг на метках начальной записи страницы в листинге книг / https://kosmobook.ru/?next=2022-01-30+00:00:00,1567030.
  2. Листинг новостей Вконтакте / https://vk.com/feed.
  3. Статья о медленном OFFSET, но не раскрывающая решение / https://habr.com/ru/company/ruvds/blog/513766/.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *