+11

15 bài tập thực hành giúp bạn thành thạo Java Stream API

Trong hành trình trở thành một lập trình viên Java xuất sắc, việc hiểu và thành thạo Java Stream API là một bước quan trọng. Java Stream API mang lại một cách tiếp cận mạnh mẽ và linh hoạt cho xử lý dữ liệu trong Java, giúp tối ưu hóa mã nguồn và làm cho mã trở nên dễ đọc hơn.

Trong bài viết này, chúng ta sẽ khám phá 15 bài tập thực hành Java Stream API để củng cố kiến thức và kỹ năng lập trình của bạn. Từ các phương thức cơ bản như filter và map đến những kỹ thuật nâng cao như flatMap và reduce, chúng ta sẽ đi sâu vào thế giới của Java Stream API và áp dụng kiến thức vào các ví dụ thực tế.

Hãy cùng nhau bắt đầu hành trình này và khám phá những bài tập thực hành giúp bạn trở thành một chuyên gia trong việc sử dụng Java Stream API!

1. Tổng quan

Trước tiên hãy để mình cho các bạn thấy được sức mạnh của StreamAPI. Ví dụ, nhiệm vụ của chúng ta là nhóm các nhân viên vào một Map và sắp xếp nhân viên theo chức vụ. Dưới đây là cách truyền thống sử dụng vòng lặp For.

public Map<String, List<Employee>> groupByJobTitle(List<Employee> employeeList) {
  Map<String, List<Employee>> resultMap = new HashMap<>();
  for (int i = 0; i < employeeList.size(); i++) {
      Employee employee = employeeList.get(i);
      List<Employee> employeeSubList = resultMap.getOrDefault(employee.getTitle(), new ArrayList<Employee>());
      employeeSubList.add(employee);
      resultMap.put(employee.getTitle(), employeeSubList);
  }  
  return resultMap;
}

Khi chuyển sang sử dụng với Stream API, ta có thể dễ dàng thấy được rằng code đã ngắn hơn và đơn giản hơn khá nhiều nhưng vẫn đạt được cùng một kết quả

public Map<String, List<Employee>> groupByJobTitle(List<Employee> employeeList) {
  return employeeList.stream()
     .collect(Collectors.groupingBy(Employee::getTitle));
}

Java Stream API không chỉ hữu ích cho việc thao tác dữ liệu và còn giúp ích rất nhiều cho việc hợp nhất và tính toán trên dữ liệu. Hãy cùng xem một ví dụ về cách tính lương trung bình của tất cả các nhân viên trong danh sách

Cách truyền thống là ta sẽ sử dụng vòng lặp để tính tổng lương của từng nhân viên và sau đó tính trung bình bằng cách chia tổng cho số bản ghi

public double calculateAverage(List<Employee> employeeList) {  
  int sum = 0;
  int count = 0;
  for (int i = 0; i < employeeList.size(); i++) {
      Employee employee = employeeList.get(i);
      sum += employee.getSalary(); // tính tổng lương nhân viên
     count++;
  }
  return (double) sum / count;
}

Bây giờ, hãy cùng triển khai nó với StreamAPI

public double calculateAverage(List<Employee> employeeList) {
  return employeeList.stream()
          .mapToInt(employee -> employee.getSalary())  // Chuyển từ ds nhân viên sang ds lương của nhân viên
          .average() // Hàm tính giá trị trung bình của các phần tử trong luồng
          .getAsDouble(); // trả về giá trị double
}

2. Các khái niệm chính về Java Stream API

Java Stream API là một phần quan trọng của Java 8, được thiết kế để cung cấp một cách mới trong việc xử lý và tổng hợp các tập dữ liệu theo cách khai báo, chức năng. Sự ra mắt của Java Stream API kể từ Java 8 đã tạo ra một cách thức mới giúp đơn giản hóa đáng kể logic mã và giảm số dòng mã cho nhiều tác vụ lập trình. Thay vì lặp qua từng mục từ một danh sách hoặc mảng, StreamAPI hoạt động với luồng dữ liệu, do đó bạn có thể đạt được các yêu cầu của mình bằng cách thêm một loạt thao tác vào luồng.

Như chúng ta đã thấy trong ví dụ trên, hoạt động của luồng có thể được giải thích theo ba giai đoạn:

  • Tạo Stream (stream source).
  • Thực hiện các thao tác trung gian (intermediate operations) trên stream ban đầu để chuyển đổi nó thành một stream khác và tiếp tục thực hiện các hoạt động trung gian khác.
  • Thực hiện thao tác đầu cuối (terminal operation) trên stream cuối cùng để nhận kết quả và sau đó bạn không thể sử dụng lại chúng.

Một Stream pipeline bao gồm: 1 stream source, 0 hoặc nhiều intermediate operation, và 1 terminal operation.

image.png

3. Java Stream API Exercises

Nào giờ chúng ta hãy cùng đi vào những ví dụ thực tế sử dụng Stream API, với 15 ví dụ này bạn sẽ có thể nắm vững cũng như hiểu được về cách thức làm việc của StreamAPI.

Các bài tập dựa trên mô hình dữ liệu - khách hàng, đơn đặt hàng và sản phẩm. Tham khảo các lớp entity bên dưới, khách hàng có thể đặt nhiều đơn hàng nên là mối quan hệ một-nhiều trong khi mối quan hệ giữa sản phẩm và đơn hàng là nhiều-nhiều

image.png

Đây là các lớp đối tượng:

@Data
@Entity
@NoArgsConstructor
public class Customer {
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  
  private String name;
  private Integer tier;
}

@Data
@NoArgsConstructor
@Entity
@Table(name = "product_order")
public class Order {
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  
  private LocalDate orderDate;
  private LocalDate deliveryDate;
  private String status;
  
  @ManyToOne
  @JoinColumn(name = "customer_id")
  private Customer customer;
  
  @ManyToMany
  @JoinTable(
      name = "order_product_relationship",
      joinColumns = { @JoinColumn(name = "order_id") },
      inverseJoinColumns = { @JoinColumn(name = "product_id") }
  )
  @ToString.Exclude
  Set<Product> products;
    
}


@Data
@NoArgsConstructor
@Entity
public class Product {
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  
  private String name;
  private String category;
  @With private Double price;
  
  @ManyToMany(mappedBy = "products")
  @ToString.Exclude
  private Set<Order> orders;
}

Excercise 1: Lấy tất cả các sản phẩm thuộc danh mục (Category) là "Books" với giá > 100

  • Yêu cầu đưa ra hai yêu cầu lọc. Một là lọc theo category là "Books", hai là lọc các sản phẩm có giá trên 100. Bạn hoàn toàn có thể áp dụng 2 filter() để có được kết quả mong muốn.
List<Product> result = productRepo.findAll()
  .stream()
  .filter(p -> p.getCategory().equalsIgnoreCase("Books"))
  .filter(p -> p.getPrice() > 100)
  .collect(Collectors.toList());
  • .filter(p -> p.getCategory().equalsIgnoreCase("Books")): Sử dụng phương thức filter để giữ lại chỉ những sản phẩm (p) thuộc danh mục "Books".

  • .filter(p -> p.getPrice() > 100) : Tiếp theo, sử dụng phương thức filter khác để giữ lại chỉ những sản phẩm có giá trị (p.getPrice()) lớn hơn 100.

Excercise 2: Lấy ra danh sách đơn hảng có sản phẩm (Products) thuộc Category là "Baby"

  • Đầu tiên chúng ta cần phải đi từ Đơn hàng, kiểm tra xem đơn hàng có sản phẩm nào thuộc danh mục Baby hay không
List<Order> result = orderRepo.findAll()
        .stream()
        .filter(o -> 
          o.getProducts()
          .stream()
          .anyMatch(p -> p.getCategory().equalsIgnoreCase("Baby"))
        )
        .collect(Collectors.toList()); 
  • .filter(o -> o.getProducts().stream().anyMatch(p -> p.getCategory().equalsIgnoreCase("Baby"))): Sử dụng phương thức filter để giữ lại chỉ những đơn hàng (Order) thỏa mãn điều kiện được xác định trong biểu thức lambda.

    • o -> o.getProducts().stream().anyMatch(p -> p.getCategory().equalsIgnoreCase("Baby")): Đối với mỗi đơn hàng (o), kiểm tra xem có ít nhất một sản phẩm (p) trong danh sách sản phẩm của đơn hàng đó thuộc danh mục "Baby" hay không. Nếu có ít nhất một sản phẩm như vậy, thì đơn hàng đó sẽ được giữ lại.
  • .collect(Collectors.toList()): Chuyển đổi kết quả của bước lọc thành một danh sách (List<Order>) bằng cách sử dụng phương thức collect và Collectors.toList().

Exercise 3: Lấy danh sách các sản phẩm có category = "Toys" và đó áp dụng giảm giá 10%

  • Trong bài tập này, bạn sẽ biết cách chuyển đổi dữ liệu bằng StreamAPI. Sau khi bạn có được danh sách sản phẩm có danh mục là "Toys” bằng cách sử dụng filter() , bạn có thể áp dụng giảm giá 10% cho giá sản phẩm bằng cách sử dụng map() .
  • map() trong Java Stream API được sử dụng để chuyển đổi mỗi phần tử của một luồng thành một giá trị mới bằng cách áp dụng một hàm chuyển đổi (lambda expression) cho mỗi phần tử. Kết quả là một luồng mới chứa các giá trị đã được chuyển đổi.
    List<Product> result = productRepo.findAll()
        .stream()
        .filter(p -> p.getCategory().equalsIgnoreCase("Toys"))
        .map(p -> p.withPrice(p.getPrice() * 0.9))
        .collect(Collectors.toList());  

Exercise 4: Lấy danh sách sản phẩm được khách hàng cấp 2 đặt hàng từ ngày 01 tháng 2 năm 2021 đến ngày 01 tháng 4 năm 2021

  • Ví dụ này sẽ minh họa cho các bạn cách sử dụng của flatMap() . Đầu tiên, bạn có thể bắt đầu với một danh sách Oder và lọc chúng theo cấp độ của khách hàng và ngày order. Tiếp theo lấy ra danh sách khách hàng từ những các Order và sử dụng flatMap() để đưa các sản phẩm vào trong stream. Ví dụ, nếu bạn có 3 Orders và mỗi Order chứa 10 sản phẩm, thì flatMap() sẽ lấy ra 10 phần tử đó từ mỗi Order và tạo ra 30 (3 x 10) sản phẩm ở đầu ra của stream.
  • Vì danh sách sản phẩm có thể chứa các bản ghi trùng lặp nếu các đơn hàng bao gồm các sản phẩm giống nhau. Để tạo danh sách sản phẩm duy nhất, áp dụng distinct() có thể giúp bạn tạo ra danh sách duy nhất
    List<Product> result = orderRepo.findAll()
          .stream()
          .filter(o -> o.getCustomer().getTier() == 2)
          .filter(o -> o.getOrderDate().compareTo(LocalDate.of(2021, 2, 1)) >= 0)
          .filter(o -> o.getOrderDate().compareTo(LocalDate.of(2021, 4, 1)) <= 0)
          .flatMap(o -> o.getProducts().stream())
          .distinct()
          .collect(Collectors.toList());
  • Khi làm việc với đối tượng Stream trong Java, đôi khi chúng ta sẽ có những đối tượng Stream của những đối tượng List, Set hay là Array...Sử dụng phương thức flatMap() chúng ta có thể chuyển đổi đối tượng Stream của những đối tượng List, Set or Array thành đối tượng Stream của những đối tượng đơn giản hơn. Có thể mình sẽ nói rõ hơn và có những ví dụ cụ thể hơn trong các bài viết sau nhé.

Exercise 5 - Lấy ra sản phẩm có giá rẻ nhất trong danh mục "Books"

  • Một trong những cách hiệu quả nhất để có được sản phẩm với giá rẻ nhất là sắp xếp danh sách sản phẩm theo giá tiền tăng dần và lấy ra sản phẩm đầu tiên. Và điều này Java Stream API cũng cấp cấp cho chúng ta một phương thức min() để tìm ra các sản phẩm có giá trị thấp nhất.
 Optional<Product> result = productRepo.findAll()
        .stream()
        .filter(p -> p.getCategory().equalsIgnoreCase("Books"))
        .min(Comparator.comparing(Product::getPrice));

Exercise 6: Lấy 3 đơn hàng được đặt gần nhất

  • Giải pháp ở đây là sắp xếp các bản ghi đơn hàng theo trường ngày đặt. Điều khó khăn là việc sắp xếp lần này phải theo thứ tự giảm dần để bạn có thể lấy được bản ghi đơn hàng bới ngày đặt gần nhất. Nó có thể đạt được chỉ bằng cách gọi Comparator.reversed()
      List<Order> result = orderRepo.findAll()
            .stream()
            .sorted(Comparator.comparing(Order::getOrderDate).reversed())
            .limit(3)
            .collect(Collectors.toList());

Exercise 7: Lấy danh sách các đơn hàng đã được đặt vào ngày 15 tháng 3 năm 2021, ghi nhật ký đơn hàng vào console và sau đó trả về danh sách sản phẩm của nó

  • Bạn có thể thấy rằng bài tập này gồm 3 hành động là lấy ra đơn đặt hàng vào ngày 15/3/2021, sau đó ghi nhật ký đơn hàng vào console và trả về danh sách sản phẩm của nó. Không thể chạy qua một luồng 2 lần, vậy làm sao để đáp ứng được yêu cầu này. Ở ví dụ này, chúng ta sẽ sử dụng một operation rất hay đó là peek(), mục đích là để ghi đơn hàng vào console và tiếp tục thao tác với luồng (stream) như bình thường, thay vì phải chạy luồng hai lần

  • Trong Java Stream API, peek() là một phương thức được sử dụng để thực hiện một hành động (được chỉ định bởi một biểu thức lambda) trên mỗi phần tử của stream mà không ảnh hưởng đến các phần tử trong stream. Nó thường được sử dụng cho mục đích gỡ rối (debugging) hoặc theo dõi các phần tử khi chúng được truyền qua stream.

      List<Product> result = orderRepo.findAll()
            .stream()
            .filter(o -> o.getOrderDate().isEqual(LocalDate.of(2021, 3, 15)))
            .peek(o -> System.out.println(o.toString()))
            .flatMap(o -> o.getProducts().stream())
            .distinct()
            .collect(Collectors.toList());

Exercise 8 - Tính tổng số tiền gộp của tất cả các đơn đặt hàng trong tháng 2 năm 2021

  • Tất cả bài tập trước đó là xuất danh sách bản ghi bằng thao tác đầu cuối, lần này chúng ta hãy thực hiện một số phép tính. Bài tập này nhằm tổng hợp tất cả các sản phẩm được đặt hàng trong tháng 2 năm 2021. Như đã thực hiện các bài tập trước, bạn có thể dễ dàng lấy danh sách sản phẩm bằng cách sử dụng các thao tác filter()FlatMap() . Tiếp theo, bạn có thể sử dụng thao tác mapToDouble() để chuyển đổi luồng thành luồng có kiểu dữ liệu Double bằng cách chỉ định trường giá làm giá trị ánh xạ. Cuối cùng, thao tác terminal sum() sẽ giúp bạn cộng tất cả các giá trị và trả về tổng giá trị.
      Double result = orderRepo.findAll()
            .stream()
            .filter(o -> o.getOrderDate().compareTo(LocalDate.of(2021, 2, 1)) >= 0)
            .filter(o -> o.getOrderDate().compareTo(LocalDate.of(2021, 3, 1)) < 0)
            .flatMap(o -> o.getProducts().stream())
            .mapToDouble(p -> p.getPrice())
            .sum();

Exercise 9 - Tính số tiền thanh toán trung bình của các đơn hàng được đặt vào ngày 14 tháng 3 năm 2021

  • Ngoài tổng số tiền, API luồng còn cung cấp hoạt động để tính giá trị trung bình. Bạn có thể thấy rằng kiểu dữ liệu trả về khác với sum() vì đây là kiểu dữ liệuOptional . Lý do là vì luồng stream có thể sẽ trống và do đó phép tính sẽ không đưa ra được giá trị trung bình cho luồng dữ liệu trống.
    Double result = orderRepo.findAll()
        .stream()
        .filter(o -> o.getOrderDate().isEqual(LocalDate.of(2021, 3, 15)))
        .flatMap(o -> o.getProducts().stream())
        .mapToDouble(p -> p.getPrice())
        .average().getAsDouble();

Exercise 10 - Lấy tập hợp các số liệu thống kê (tổng, trung bình, tối đa, tối thiểu, số lượng) cho tất cả các sản phẩm thuộc danh mục “Books”

  • Điều gì sẽ xảy ra nếu bạn cần lấy tổng, trung bình, tối đa, tối thiểu và số lượng cùng một lúc? Chúng ta có nên chạy luồng dữ liệu 5 lần để lấy từng số liệu đó không? Cách tiếp cận như vậy không thực sự hiệu quả. May mắn là streamAPI cung cấp một cách thuận tiện để ta có thể nhận tất cả các giá trị đó cùng một lúc bằng cách sử dụng thao tác đầu cuối summaryStatistics() . Nó trả về kiểu dữ liệu DoubleSummaryStatistics chứa tất cả các số liệu được yêu cầu.
DoubleSummaryStatistics statistics = productRepo.findAll()
    .stream()
    .filter(p -> p.getCategory().equalsIgnoreCase("Books"))
    .mapToDouble(p -> p.getPrice())
    .summaryStatistics();
  
  System.out.println(String.format("count = %1$d, average = %2$f, max = %3$f, min = %4$f, sum = %5$f", 
        statistics.getCount(), statistics.getAverage(), statistics.getMax(), statistics.getMin(), statistics.getSum())));

Exercise 11 - Tạo Map với id đơn hàng và số lượng sản phẩm của đơn hàng

  • Ngoại trừ việc tính giá trị, tất cả các bài tập trước chỉ đưa ra danh sách bản ghi. Lớp Collectors còn cung cấp một số thao tác hữu ích để hợp nhất dữ liệu và thu thập dữ liệu đầu ra. Hãy xem bài tập tạo Map với khóa là id đơn hàng trong khi giá trị là số lượng sản phẩm. Collectors.toMap() chấp nhận hai đối số để bạn chỉ định khóa và giá trị tương ứng.
  • Collectors.toMap() là một phương thức thuộc lớp Collectors trong Java Stream API, được sử dụng để thu thập các phần tử của một Stream thành một Map. Phương thức này cung cấp cách tiện lợi để tạo Map từ các phần tử trong Stream bằng cách xác định cách chuyển đổi key và value.
    Map<Long, Integer>  result = orderRepo.findAll()
        .stream()
        .collect(
            Collectors.toMap(
                order -> order.getId(),
                order -> order.getProducts().size()
            ));

Exercise 12 - Tạo Map với đơn hàng được nhóm theo khách hàng

  • Ở ví dụ này, ta sẽ sử dụng Collectors.groupingBy() để thực hiện việc nhóm dữ liệu
  • Collectors.groupingBy() là một phương thức trong Java Stream API thuộc lớp Collectors, được sử dụng để nhóm các phần tử của một Stream thành các nhóm dựa trên một tiêu chí nhất định. Phương thức này tạo ra một Map trong đó các key là các nhóm và các values là danh sách các phần tử thuộc cùng một nhóm.
        Map<Customer, List<Order>> result = orderRepo.findAll()
        .stream()
        .collect(
            Collectors.groupingBy(Order::getCustomer)
            );

Exercise 13 - Map dữ liệu giữa đơn hàng và tổng sản phẩm

  • Đầu ra của map lần này không phải là trích xuất đơn giản các trường dữ liệu từ luồng, bạn cần tạo luồng phụ cho mỗi đơn hàng để tính tổng sản phẩm. Vì phần tử khóa chính là đơn hàng thay vì id đơn hàng, nên Function.identity() được sử dụng để báo cho Collectors.toMap() sử dụng phần tử dữ liệu làm khóa.
 Map<Order, Double> result = orderRepo.findAll()
        .stream()
        .collect(
          Collectors.toMap(
              Function.identity(), 
              order -> order.getProducts().stream()
                    .mapToDouble(p -> p.getPrice()).sum());

Exercise 14 - Map danh sách tên sản phẩm theo danh mục

  • Bài tập này giúp bạn làm quen với cách chuyển đổi dữ liệu đầu ra của Map. Nếu bạn chỉ sử dụng Collectors.groupingBy(Product::getCategory) thì đầu ra sẽ là Map<Category, List of Products> nhưng đầu ra dự kiến sẽ là Map<Category, List of Product Name>. Bạn có thể sử dụng Collectors.mapping() để chuyển đổi đối tượng sản phẩm thành tên sản phẩm để xây dựng Map.
    Map<String, List<String>> result = productRepo.findAll()
        .stream()
        .collect(
            Collectors.groupingBy(
                Product::getCategory,
                Collectors.mapping(product -> product.getName(), Collectors.toList()))
            );

Exercise 15 – Lấy ra sản phẩm đắt nhất theo danh mục

  • Tương tự như chuyển đổi dữ liệu bằng cách sử dụng Collectors.mapping() , Collectors.maxBy() giúp thu được bản ghi có giá trị tối đa như một phần của quá trình xây dựng bản đồ dữ liệu. Bằng cách cung cấp bộ so sánh giá sản phẩm, maxBy() có thể nhận được sản phẩm có giá trị lớn nhất cho mỗi danh mục.
Map<String, Optional<Product>> result = productRepo.findAll()
        .stream()
        .collect(
            Collectors.groupingBy(
                Product::getCategory,
                Collectors.maxBy(Comparator.comparing(Product::getPrice)))

4. Thêm một vài ví dụ nhỏ để các bạn luyện tập thêm

  • Lọc danh sách : Cho một danh sách các chuỗi, hãy sử dụng Stream để lọc ra những chuỗi có độ dài lớn hơn 3.

    List<String> strings = Arrays.asList("Stream", "API", "Java", "Code", "Practice");

  • Tính tổng và trung bình : Cho một danh sách các số nguyên, hãy tính tổng và trung bình của chúng sử dụng Stream.

    List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);

  • Chuyển Đổi Danh Sách : Cho một danh sách các số nguyên, hãy tạo một danh sách mới với mỗi phần tử được nhân đôi.

    List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);

  • Tìm Tên Ngắn Nhất : Cho một danh sách tên, hãy tìm tên có độ dài ngắn nhất.

    List<String> names = Arrays.asList("Anna", "Bob", "Alexandra", "Zoe");

  • Chuyển Đổi Danh Sách Thành Map : Tạo một Map từ danh sách tên, với key là tên và value là độ dài của tên đó.

    List<String> names = Arrays.asList("Anna", "Bob", "Alexandra", "Zoe");

  • Lọc và Sắp Xếp : Lọc ra các số lớn hơn 5 trong một mảng và sắp xếp chúng theo thứ tự tăng dần.

    int[] numbers = {3, 7, 2, 5, 6, 8, 4};

  • Tìm Phần Tử Duy Nhất : Cho một mảng số nguyên, hãy tìm số xuất hiện chỉ một lần trong mảng.

    int[] numbers = {2, 3, 4, 2, 3, 5, 4};

  • Tạo Chuỗi Từ Danh Sách: Tạo một chuỗi, nối tất cả các chuỗi trong danh sách bằng dấu phẩy.

    List<String> strings = Arrays.asList("Java", "Python", "C++", "JavaScript");

    Để lại phương pháp, cách giải của các bạn ở dưới phần comment nhé 😘😘😘

5. Kết bài

Những bài tập cũng như những kiến thức mình đề cập ở trên sẽ giúp bạn hiểu rõ hơn về cách sử dụng Java Stream API trong việc xử lý dữ liệu một cách hiệu quả. Tuy nhiên trong thực tế, sẽ gặp các dạng bài tập khó hơn, đòi hòi nhiều sự biến đổi phức tạp hơn. Vì vậy thành thạo các hàm, kiến thức cơ bản này sẽ giúp bạn tự tin hơn trong việc sử dụng Stream API dù ở bất kỳ tình huống nào. Hãy cố gắng luyện tập thật nhiều và sử dụng Stream API bất cứ khi nào có thể nhé - đó mới chính là chìa khóa quan trọng nhất để các bạn thành thạo được nó.


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.