Pull to refresh
89.94
beeline cloud
Безопасный облачный провайдер

Пагинация. Нестандартное использование Spring’овых Page и Pageable

Reading time5 min
Views8.8K

Привет, Хабр! На связи Николай Пискунов, ведущий разработчик в подразделении 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));
}

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


 Больше дополнительных статей — в нашем проекте вАЙТИ. Все материалы основаны на реальных событиях и опыте:

beeline cloud — secure cloud provider. Разрабатываем облачные решения, чтобы вы предоставляли клиентам лучшие сервисы.

Tags:
Hubs:
Total votes 6: ↑4 and ↓2+3
Comments3

Articles

Information

Website
cloud.beeline.ru
Registered
Founded
Employees
501–1,000 employees
Location
Россия