
Привет, Хабр! На связи Николай Пискунов, ведущий разработчик в подразделении Big Data. В прошлый раз в блоге beeline cloud я рассказывал о Spring Data JPA и Hibernate — поднимал вопрос решения проблемы динамически изменяемого запроса к базам данных. В этой статье я покажу, как применить спринговую пагинацию на интерфейсе List<>.
Помнится, в одном из проектов на SpringBoot, фронт запрашивал данные у бэкенда и выдавал результат в постраничном формате. Для получения данных из БД использовался Spring Data JPA и PagingAndSortingRepository. В этом кейсе не обошлось без пагинации на List<>, но обо всём по порядку.
Структура проекта, если исключить остальные, не связанные с данным примером классы, имела примерно такую архитектуру:

На этом шаге запрос из контроллера попадает в сервис:
@Service @RequiredArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) public class FooService { FooMapper fooMapper; FooEntityRepository fooEntityRepository; public PagedFooDto getFooDto(int page, int pageSize) { Page<FooEntity> fooEntities = fooEntityRepository.findAll(PageRequest.of(page, pageSize)); return fooMapper.toPagedFooDto(fooEntities); } }
Сервис получает данные из БД. После этого маппер собирает для фронта:
@Mapper(componentModel = "spring", nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS, nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE) public interface FooMapper { @Mapping(target = "totalPages", source = "totalPages") @Mapping(target = "totalElements", source = "totalElements") @Mapping(target = "pageSize", source = "size") @Mapping(target = "pageElements", source = "numberOfElements") @Mapping(target = "items", source = "content") PagedFooDto toPagedFooDto(Page<FooEntity> entities); FooDto toFooDto(FooEntity entity); }
и возвращает данные. На это уходит порядка 80—360 ms в зависимости от параметров и нагрузки.
Задача: ввиду сильной нагрузки и запроса «а давайте вот по этому эндпоинту отдавать данные за 20ms», было решено выделить логику, связанную с этим эндпоинтом в отдельный микросервис и «придерживать» большие массивы данных в кеше. Здесь я опущу историю с перебором множества различных решений кеширования, но в тайминги уложился инмемори кеш на основе caffeine.
Первое, что захотелось сделать, это написать свою реализацию, которая получает данные из кеша, разбивает на страницы и возвращает нужную. Довольно быстро получилось накидать простенькую пагинацию для листа:
@UtilityClass public class PaginationListUtils { public <E> List<E> returnPagedList(List<E> data, int page, int pageSize) { // Вычисляем индекс элемента int startIndex = page * pageSize; // Проверяем на null и что индекс не выходит за рамки data.size() if(data == null || data.size() <= startIndex){ return Collections.emptyList(); } // Вычисляем список элементов, которые соответствуют запросу return data.subList(startIndex, Math.min(startIndex + pageSize, data.size())); } }
Но сразу встал вопрос рефакторинга. Либо придется вводить дополнительный класс-обертку, в котором помимо коллекции с данными будут храниться метаданные (которые ранее хранились в спринговом объекте Page<> ), либо на уровне этого или соседнего метода (который тоже потребуется реализовать), производить вычисления, чтобы заполнить требуемые фронту метаданные, типа количества страниц, общего количества элементов и т.д. А значит нужно переписывать и маппер, и контроллер … и сервис, так как «зацепит всех».
Поскольку пагинация спрингдаты запускает фуллскан по целевой таблице, а после проводит вычисления, я сделал предположение, что где-то существует готовая реализация пагинации, и полез в глубину. Наткнулся на класс PageImpl<>, у которого есть прекрасный конструктор:
public PageImpl(List<T> content, Pageable pageable, long total) { super(content, pageable); this.total = pageable.toOptional().filter(it -> !content.isEmpty())// .filter(it -> it.getOffset() + it.getPageSize() > total)// .map(it -> it.getOffset() + content.size())// .orElse(total); }
Но особо не разобравшись, я просто немного переписал сервис-класс:
@Service @RequiredArgsConstructor @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) public class FooService { FooMapper fooMapper; Cache<String, FooEntity> cache; public PagedFooDto getFooDtoFromCache(int page, int pageSize) { List<FooEntity> fooEntities = cache.asMap().values().stream().toList(); return fooMapper.toPagedFooDto(new PageImpl<>(fooEntities, PageRequest.of(page, pageSize), fooEntities.size())); } }
На тестах, предварительно сохранив в кеше 10 000 элементов, я подал на вход страницу 0, указав количество элементов — 15. Но получил не тот результат, на который надеялся. Метаданные были рассчитаны неверно и на каждой странице возвращались все элементы:

Как видно из скрина выше, всего было 667 страниц и 10 000 элементов items. Результат меня удивил и я решил подать на вход страницу 668. Вот что получилось:

Мало того, что вернулись все данные, вдобавок удвоились показатели элементов и страниц. И меня осенило, что вычисления, которые производятся под капотом спринга ориентируются на готовые данные, вырезанные из общего массива, который хранится в кэше.
А поскольку у нас уже готов метод, который получает саблист (PaginationListUtils->returnPagedList), то достаточно дополнить его, чтобы вместо List<> возвращать Page<>:
public static <E> Page<E> returnPagedList(Pageable pageable, List<E> data) { List<E> result; int pageSize = pageable.getPageSize(); int page = pageable.getPageNumber(); int startIndex = page * pageSize; // Проверяем на null и что индекс не выходит за рамки data.size() if (CollectionUtils.isEmpty(data) || data.size() <= startIndex) { result = Collections.emptyList(); } else { // Вычисляем список элементов которые соответствуют запросу result = data.subList(startIndex, Math.min(startIndex + pageSize, data.size())); } return new PageImpl<>(result, pageable, data.size()); }
А дальше немного меняем сервис-класс:
public PagedFooDto getFooDtoFromCache(int page, int pageSize) { List<FooEntity> fooEntities = cache.asMap().values().stream().toList(); return fooMapper.toPagedFooDto(PaginationListUtils.returnPagedList(PageRequest.of(page, pageSize), fooEntities)); }
Таким образом, добавив новый класс и немного поменяв сервис, мы получим требуемый результат.
Больше дополнительных статей — в нашем проекте вАЙТИ. Все материалы основаны на реальных событиях и опыте:
История хакерского взлома и его устранения. Статья о том, как компания решала последствия взлома инфраструктуры и атаки вируса-шифровальщика. Пришлось восстанавливать контроллеры домена и сетевое хранилище, настраивать AD, DHCP и DNS, внедрять политики Active Directory. Материал в формате инструкции для тех, кто попадает в похожую ситуацию.
Shodan, Censys, SpiderFoot — краткий гайдлайн по оценке привлекательности организации для хакеров. Практическое руководство по оценке внешнего периметра компании на наличие полезных для хакера данных. Про определение векторов атак, а также сервисы, автоматизирующие сбор информации (грабберы, дорки). Материал будет полезен как сотрудникам по ИБ, так и топ-менеджменту.
Как защитить биометрические данные от кражи и взлома. В чем главная сила биометрических данных? В способах защиты от подделок. Это — компактный материал о лучших практиках и подходах к работе с отпечатками пальцев, голосом и аутентификацией по лицу.
Как привить сотрудникам культуру кибербезопасности. В 90% случаев виновником атаки на корпоративную инфраструктуру становится сотрудник. Статья о том, как развивать культуру кибергигиены внутри компании и чему обучать коллег.
Как мы ускоряем рабочие процессы с помощью ChatGPT. Тимлид команды разработки рассказывает, как чат-бот помогает экономить время на рутинных задачах и прототипировании.
beeline cloud — secure cloud provider. Разрабатываем облачные решения, чтобы вы предоставляли клиентам лучшие сервисы.
