Всем привет! Решил написать статью с практическими примерами использования Stream API. В данной статье не будет теории - только хардкор и практические примеры. Поехали!

Сразу хочу отметить: данная статья написана исключительно в целях демонстрации основ работы Stream API и структура проекта, используемая в ней для примера, не подходит для применения на реальных проектах ;)

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

Для тестирования буду использовать Postman.

В проекте есть три сущности с которыми мы будем работать: Client, Product и Booking. Связи между ними можно посмотреть на следующей картинке.

В сущности Product есть enum Category. Центральная сущность - Booking, в которую входит Client, List<Product>, а также enum Status. То есть Booking - это заказ перечня продуктов, который делает клиент.

Я в проекте буду использовать базу данных H2. Для просмотра данных можно после запуска проекта пройти по ссылке http://localhost:8080/h2-console , ввести там username: user и password: password и вы увидите это:

Доступ к данной штуке происходит за счет spring.h2.console.enabled=true, которую я прописал в application.properties, здесь также можно поменять логин и пароль доступа к базе данных.

Генерацию таблиц сделает за нас Hibernate за счет spring.jpa.hibernate.ddl-auto=create, которая прописана в application.properties, так как прописано create - при каждом запуске проекта будут удалятся и создаваться заново таблицы в базе данных. Это можно увидеть в логах так как прописаны spring.jpa.show-sql=true и spring.jpa.properties.hibernate.format_sql=true.

Для генерации данных в таблицы базу я написал следующих класс

@Component
public class DataLoader {
    private final long NUMBER_BOOKINGS = 30;

    @Bean
    public CommandLineRunner loadDataClient(ClientRepository clientRepository, ProductRepository productRepository, BookingRepository bookingRepository) {
        return (args) -> {
            Faker faker = new Faker();
            long indexForProduct = 1;
            for (long i = 0; i <= NUMBER_BOOKINGS; i++) {
                Set<Product>products = new HashSet<>();
                long indexToIncreaseProduct =0;
                for (long j = indexForProduct; j <= faker.number().numberBetween(indexForProduct, indexForProduct+10); j++) {
                    indexToIncreaseProduct = j;
                        Product product = productRepository.save(new Product(j, faker.lorem().word(),
                                Category.valueOf(Category.values()[faker.number().numberBetween(0, 5)].name()),
                                faker.number().randomDouble(2, 1, 100)));
                        products.add(product);
                }
                indexForProduct += indexToIncreaseProduct;

                Client client = clientRepository.save(new Client(i, faker.name().firstName() + " " + faker.name().lastName()));

                bookingRepository.save(new Booking(i, LocalDate.of(2022, Month.JANUARY, 1).plusDays(faker.number().numberBetween(1, 365)), Status.valueOf(Status.values()[faker.number().numberBetween(0, 3)].name()),
                        client, products));
            }


        };
    }
}

Данный код генерирует 30 заказов, клиентов и случайное количество продуктов в заказе и сохраняет все это в базу данных. Число заказов можете менять на свое усмотрение. Для генерации данных я использовал стороннюю библиотеку Faker, кому интересно можете про нее почитать тут https://www.baeldung.com/java-faker.

С вводной частью все можно приступать к Stream API.


Пример 1. Найти все продукты категории "Book", с ценой более 50

@Override
    public List<Product> findAllProductsBelongsCategoryBookWithPriceMore50() {
        return productRepository.findAll()
                .stream()
                .filter(product -> product.getCategory().equals(Category.BOOK))
                .filter(product -> product.getPrice() > 50)
                .collect(Collectors.toList());
    }

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

Для тестирования данного кода можете в Postman отправить запрос http://localhost:8080/api/v1/product/findAllProductsBelongsCategoryBookWithPriceMore50

и получите

или что-то похожее, так как первоначальные данные будут у всех генерироваться разные. Также для тестирования всех примеров есть два контроллера (BookingController и ProductController) с написанными эндпоинтами, вызывая которые можно протестировать примеры.

Пример 2. Найти все продукты, заказанные за определенный, заданный период времени

@Override
    public List<Product> findAllProductOrderedBetweenDates(LocalDate start, LocalDate finish) {
        return bookingRepository.findAll()
                .stream()
                .filter(booking -> booking.getOrderDate().compareTo(start) >= 0)
                .filter(booking -> booking.getOrderDate().compareTo(finish) <= 0)
                .flatMap(booking -> booking.getProducts().stream())
                .collect(Collectors.toList());
    }

Находим все заказы, после применяем два фильтра, которые фильтруют наши заказы, чтобы дальше прошли только заказанные в заданный период.  После применяем flatMap(), которая делает один новый stream из всех продуктов в заказах. Последняя – это терминальная операция, которая объединяет все продукты в один список.

Для тестирования этого примера нам необходимо в Postman, также передать даты периода, за который мы хотим сделать выборку.

Пример 3. Найти самый дешевый продукт в категории "Medicine"

 @Override
    public Optional<Product> findCheapestProductInCategoryMedicine() {
        return productRepository.findAll()
                .stream()
                .filter(product -> product.getCategory().equals(Category.MEDICINE))
                .min(Comparator.comparing(Product::getPrice));
    }

Получаем stream со всеми продуктами, потом применяем фильтр, который оставляет только продукты категории “Medicine” и потом с помощью операции min() находим самый дешевый продукт.

Пример 4. Найти все продукты, заказанные в определенный день

    @Override
    public List<Product> findAllProductOrderedInDate(LocalDate date) {
        return bookingRepository.findAll()
                .stream()
                .filter(booking -> booking.getOrderDate().isEqual(date))
                .flatMap(booking -> booking.getProducts().stream())
                .collect(Collectors.toList());
    }

Находим все заказы, после применяем фильтр, где мы сравниваем дату нашего заказа с заданной датой.  После применяем flatMap(), которая делает один новый stream из всех продуктов в заказах. Последняя – это терминальная операция, которая объединяет все продукты в один список.

Пример 5. Получить статистические данные по продуктам категории “Food”

    @Override
    public DoubleSummaryStatistics obtainCollectionOfStaticAllProductsCategoryFood() {
        return productRepository.findAll()
                .stream()
                .filter(product -> product.getCategory().equals(Category.FOOD))
                .mapToDouble(Product::getPrice)
                .summaryStatistics();

    }

Получаем stream со всеми продуктами, потом применяем фильтр, который оставляет только продукты категории “ Food ”, а затем с помощью операции mapToDouble() образуем один поток данных с ценами всех продуктов данной категории и после с помощью терминальной операции summaryStatistics() получаем статистические данные.

Отправив в Postman запрос http://localhost:8080/api/v1/product/obtainCollectionOfStaticAllProductsCategoryFood мы получим статистические данные:

у вас конкретные значения будут отличаться.

Пример 6. Сгруппировать продукты по категориям

@Override
    public Map<String, List<String>> getMapWithListProductsNameByCategory() {
        return productRepository.findAll()
                .stream()
                .collect(Collectors.groupingBy(
                        product -> product.getCategory().name(),
                        Collectors.mapping(Product::getName, Collectors.toList())
                ));
    }

Первоначально получаем список всех продуктов и делаем из него поток. После с помощью операции groupingBy() задаем по чем мы хотим группировать продукты (по имени категории) и с помощью операции mapping() указываем какие данные должны быть собраны в список и относиться к данной категории.

Пример 7. Получить самые дорогие продукты по категориям

 @Override
    public Map<String, Optional<Product>> getMostExpensiveProductByCategory() {
        return productRepository.findAll()
                .stream()
                .collect(
                        Collectors.groupingBy(
                        product -> product.getCategory().name(),
                                Collectors.maxBy(Comparator.comparing(Product::getPrice)))
                );
    }

Первоначально получаем список всех продуктов и делаем из него поток. После с помощью операции groupingBy() задаем по чем мы хотим группировать продукты (по имени категории) и с помощью операции maxBy() указываем какой продукт (в нашем случае самый дорогой) должен относиться к данной категории.

Пример 8. Получить все заказы, которые принадлежат категории “Sport”

@Override
    public List<Booking> findAllBookingWithProductsBelongCategorySport() {
        return bookingRepository.findAll()
                .stream()
                .filter(booking -> booking.getProducts().stream().anyMatch(product -> product.getCategory().equals(Category.SPORT)))
                .collect(Collectors.toList());
    }

Находим все заказы и делаем из него поток. После применяем фильтр, в котором из каждого заказа достаем список продуктов и делаем из него поток и с помощью операции anyMatch() проверяем есть ли в данном списке хотя бы один продукт с категорией “Sport”. После все собираем в один список с помощью терминальной операции collect().

Пример 9. Получить три последних заказа

@Override
    public List<Booking> findThreeMostRecentBooking() {
        return bookingRepository.findAll()
                .stream()
                .sorted(Comparator.comparing(Booking::getOrderDate).reversed())
                .limit(3)
                .collect(Collectors.toList());
    }

Получаем список заказов и делаем из него поток, далее сортируем данный поток с помощью операции sorted(), где в качестве того по чем мы будем сортировать передаем дату заказа (Booking::getOrderDate) и так как список будет отсортирован от самого старого заказа до самого последнего, то необходимо применить операцию reserved(), чтобы список развернуть в обратном направлении – от самого нового заказа до самого старого. Операция limit() оставляет только нужное количество нам элементов и последняя терминальная операция собирает все в список.

Пример 10. Посчитать общую сумму всех заказов за определенный период

 @Override
    public Double calculateTotalSumAllBookingsBetweenDates(LocalDate start,
                                                           LocalDate finish) {
        return bookingRepository.findAll()
                .stream()
                .filter(booking -> booking.getOrderDate().compareTo(start) >= 0)
                .filter(booking -> booking.getOrderDate().compareTo(finish) < 0)
                .flatMap(booking -> booking.getProducts().stream())
                .mapToDouble(Product::getPrice)
                .sum();
    }

Получаем список заказов, после применяем два фильтра, с помощью которых оставляем только заказы с нужного нам диапазона времени. Затем с помощью операции flatMap() делаем общий поток всех продуктов и применяем операцию mapToDouble(), которая делает из общего потока продуктов поток цен. Завершающая терминальная операция sum() суммирует все числа.

Пример 11. Найти среднее значение стоимости всех заказов со статусом APPROVED за определенную дату

 @Override
    public Double calculateAverageAllBookingsWithStatusApprovedOnDate(LocalDate start) {
        return bookingRepository.findAll()
                .stream()
                .filter(booking -> booking.getOrderDate().isEqual(start))
                .filter(booking -> booking.getStatus().equals(Status.APPROVED))
                .flatMap(booking -> booking.getProducts().stream())
                .mapToDouble(Product::getPrice)
                .average()
                .orElse(0);
    }

Находим все заказы, после применяем два фильтра. Первый оставляет только заказы за определенную дату, а второй выбирает только заказы со статусом APPROVED. Далее с помощью операции flatMap() из всех заказов достаем списки продуктов и делаем из них общий поток и с помощью операции mapToDouble(), делам из общего потока продуктов поток их цен. Завершающая терминальная операция average() находит среднее значение данных цен.

Пример 12. Получить Map<>, где ключ – Id заказа, а значение -  количество продуктов в данном заказе

@Override
    public Map<Long, Integer> getMapWithBookingIdAndCountProduct() {
        return bookingRepository.findAll()
                .stream()
                .collect(Collectors.toMap(Booking::getId, booking -> booking.getProducts().size()));
    }

Получаем список всех заказов и делаем из него поток. После применяем операцию collect(), где указываем, что хотим собрать все в Map<> и указываем, что ключом будет являться Id заказа, а значением -  количество продуктов в данном заказе.

Пример 13. Получить Map<>, где ключ – клиент, а значение -  список заказов, которые сделал данный клиент

    @Override
    public Map<Client, List<Booking>> getMapWithClientAndListBookings() {
        return bookingRepository.findAll()
                .stream()
                .collect(Collectors.groupingBy(Booking::getClient));
    }

Получаем список всех заказов и делаем из него поток. После применяем операцию collect(), где указываем, что хотим сгруппировать по клиентам и все. На сколько упрощается код при использовании stream API.

Пример 14. Получить Map<>, где ключ – заказ, а значение - общая сумма стоимости всех продуктов в нем

    @Override
    public Map<Booking, Double> getMapWithBookingAndProductTotalSum() {
        return bookingRepository.findAll()
                .stream()
                .collect(Collectors.toMap(
                        Function.identity(),
                        booking -> booking.getProducts().stream()
                                .mapToDouble(Product::getPrice).sum()
                ));
    }

Получаем список всех заказов и делаем из него поток. После применяем операцию collect(), где указываем, что хотим собрать все в Map<> и указываем, что ключом будет являться заказ (Function.identity()), а значением -  мы из всех заказов достаем списки продуктов и делаем из них общий поток и с помощью операции mapToDouble(), делам из общего потока продуктов поток их цен. Завершающая терминальная операция sum() находит сумму данных цен.

Всем спасибо кто дочитал до конца.

Всем удачи в изучении stream API.

Комментарии (16)


  1. ggo
    27.08.2022 09:21
    +17

    По моему забыли добавить такое предупреждение:

    Уважаемые разработчики! Используйте данные примеры, только для изучения принципов работы со Stream API. На реальных проектах использовать эти примеры для фильтрации данных из БД категорически не нужно.


    1. MiSta1984 Автор
      27.08.2022 14:25
      +1

      Спасибо за комментарий. Добавил в статью: "данная статья написана исключительно в целях демонстрации основ работы Stream API и структура проекта, используемая в ней для примера, не подходит для применения на реальных проектах".


      1. ads83
        27.08.2022 22:50
        +2

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


        На мой взгляд, просто красного флажка "так делать нельзя! ни за что никогда!" недостаточно. Да какого флажка! Нужен транспарант с аршинными буквами!
        Кстати, у вас я такого не заметил. Ну не считать же фразу про "структура проекта не подходит для использования на проде" достаточным объяснением? Структуру нельзя, а запросы — можно? ;-)


        1. Bakuard
          28.08.2022 06:59

          Думаю, будет достаточно упомянуть, что исходных данных может очень и очень много.


          1. ads83
            28.08.2022 10:42

            Мы же обсуждаем статью "для начинающих"? Мне кажется, одной фразы недостаточно.


            Во-первых, ее можно просто пропустить. То есть лучше сказать об этом дважды: при первом использовании, и в отдельном разделе.
            Во-вторых, важный вопрос "а как делать правильно"? Как раз для отдельного раздела — хотя бы просто отсылки на способы, чтобы заинтересованный знал куда копать дальше.
            В-третьих, разобрать ленивость стримов: что такое, когда работает, насколько эффективно, почему не работает тут. Лично я не могу сказать, что без подготовки готов ответить на все эти вопросы.
            Ну и в-последних — а когда данных становится много? Хотя бы примерные оценки по числу сущностей, чтобы решать проблему не на проде код-ревью.


        1. MiSta1984 Автор
          28.08.2022 17:21

          Спасибо за комментарий. Сами запросы - это демонстрация основ работы Stream API. :-)


  1. Z55
    27.08.2022 13:30
    +1

    Было бы интересней, если бы к каждому стриму вы приложили SQL, который улетает в базу. Что-то мне подсказывает, там есть на что посмотреть и над чем задуматься )

    И отредактируйте заголовок примера 9.


    1. MiSta1984 Автор
      27.08.2022 14:26
      +1

      Спасибо за комментарий. Отредактировал.


  1. BugM
    27.08.2022 15:44
    +5

    Так же нельзя писать в проде. Никогда нельзя.

    Даже для статьи можно подобрать примеры которые можно использовать в продакшене. Джейсон прилетевший из внешней системы. Вызов апишки которая не позволят нужную фильтрацию (поискать у кого такие есть чтобы еще реальнее было). Валидаторы какие-нибудь. Потоковая обработка чего-то из Кафки. Куча реальных примеров.


    1. SadComedian
      29.08.2022 09:06

      Можете раскрыть мысль, почему именно нельзя?


      1. aleksandy
        29.08.2022 12:20

        Потому что тащить из БД 100500 записей, чтобы, например, найти в них всего одну, у которой какой-то атрибут равен true, - идиотизм и работа на отопление помещения датацентра.


      1. ads83
        29.08.2022 14:03

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


        Отвечаю по теме: Repository.findAll() загружает из базы все данные, а потом мы с ними что-то делаем.
        На проде обычно миллионы записей, и на каждый запрос получать их все чтобы показать десяток — это ужас-ужас. Дикая нагрузка на БД, на сборщик мусора и сам сервис.


  1. venum
    27.08.2022 19:19
    -1

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


  1. md_backend_binance
    27.08.2022 21:57
    -2

    это что EntityFW из C# до java докатился?


  1. 3735928559
    29.08.2022 09:05
    +2

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

    Если начинать с БД, то лучше рассматривать, как формировать запросы, а не запрашивать сразу все данные и фильтровать на месте. Есть и решения с DSL для запросов, которые в итоге выглядят как использование streams.


  1. dimaloop
    29.08.2022 09:06
    +2

    Что мешало сгенерировать без базы нужные данные? С бд так действительно категорически нельзя работать