Java OO & Functional/Mini-challenge: Sales report với stream pipeline
36/36
Bài 36 / 36~30 phútStream API & LambdaMiễn phí lượt xem

Mini-challenge: Sales report với stream pipeline

Bài thực hành khép lại Module — phân tích 10k bản ghi sales bằng stream chain: lọc, group, aggregate, top-N, output báo cáo.

Mini-challenge khép lại Module 4. Bạn sẽ viết một module phân tích List<Sale> (~10k record) và xuất báo cáo thuần Stream API — tuyệt đối không sử dụng vòng lặp imperative (for/while) truyền thống.

Task điển hình của backend/data engineer: đọc log/database → aggregate → report. Với stream, toàn bộ pipeline declarative, đọc như câu tiếng Anh, dễ maintain hơn imperative.

🎯 Đề bài

Cho class Sale:

record Sale(String region, String product, String customer, double amount, LocalDate date) { }

Generate 10k record (helper có sẵn trong starter). Viết các method sau — mỗi method là 1 stream pipeline (không for/while):

1. double totalRevenue(List<Sale> sales)

Tổng amount của tất cả các đơn hàng.

2. Map<String, Double> revenueByRegion(List<Sale> sales)

Tổng doanh thu theo region.

3. Map<String, Long> orderCountByProduct(List<Sale> sales)

Số đơn theo product.

4. Optional<Sale> topSale(List<Sale> sales)

Đơn có amount cao nhất. Trả Optional vì list có thể rỗng.

5. List<String> topNProducts(List<Sale> sales, int n)

N product có doanh thu cao nhất, sorted desc.

6. Map<String, Map<String, Double>> revenueByRegionThenProduct(List<Sale> sales)

Group 2 tầng: region → product → tổng amount.

7. double averageOrderInRegion(List<Sale> sales, String region)

Trung bình amount các đơn thuộc region. Nếu không có đơn nào → trả 0.

8. List<Sale> salesInLastNDays(List<Sale> sales, int days, LocalDate today)

Đơn trong N ngày gần nhất (tính từ today).

Constraint: Tất cả implementation chỉ dùng stream pipeline — không for/while loop.

📦 Concept dùng trong bài

ConceptDùng ở đây
Lambda, method referenceMọi pipeline
Stream pipeline, terminal opKích hoạt pipeline
filter/map/reduceCore operation
Collectors.groupingByTask 2, 3, 6
Collectors.summingDoubleTask 2, 6
Comparator, max/sortedTask 4, 5
OptionalTask 4

▶️ Starter code

import java.time.LocalDate;
import java.util.*;
import java.util.stream.*;

public class SalesReport {

    public record Sale(String region, String product, String customer, double amount, LocalDate date) { }

    // Helper: generate fake data
    public static List<Sale> generate(int count) {
        String[] regions = {"VN", "US", "JP", "DE", "BR"};
        String[] products = {"Pen", "Book", "Laptop", "Phone", "Desk", "Chair"};
        Random rnd = new Random(42);
        LocalDate base = LocalDate.of(2025, 1, 1);

        return IntStream.range(0, count)
            .mapToObj(i -> new Sale(
                regions[rnd.nextInt(regions.length)],
                products[rnd.nextInt(products.length)],
                "user" + rnd.nextInt(1000),
                Math.round(rnd.nextDouble() * 1000 * 100.0) / 100.0,
                base.plusDays(rnd.nextInt(365))))
            .toList();
    }

    // TODO: implement 8 methods below

    public static double totalRevenue(List<Sale> sales) {
        return 0;
    }

    public static Map<String, Double> revenueByRegion(List<Sale> sales) {
        return Map.of();
    }

    public static Map<String, Long> orderCountByProduct(List<Sale> sales) {
        return Map.of();
    }

    public static Optional<Sale> topSale(List<Sale> sales) {
        return Optional.empty();
    }

    public static List<String> topNProducts(List<Sale> sales, int n) {
        return List.of();
    }

    public static Map<String, Map<String, Double>> revenueByRegionThenProduct(List<Sale> sales) {
        return Map.of();
    }

    public static double averageOrderInRegion(List<Sale> sales, String region) {
        return 0;
    }

    public static List<Sale> salesInLastNDays(List<Sale> sales, int days, LocalDate today) {
        return List.of();
    }

    public static void main(String[] args) {
        List<Sale> sales = generate(10_000);

        System.out.printf("Total revenue: %.2f%n", totalRevenue(sales));
        System.out.println("Revenue by region: " + revenueByRegion(sales));
        System.out.println("Order count by product: " + orderCountByProduct(sales));
        topSale(sales).ifPresent(s -> System.out.println("Top sale: " + s));
        System.out.println("Top 3 products: " + topNProducts(sales, 3));
        System.out.println("Revenue by region -> product (VN only): "
            + revenueByRegionThenProduct(sales).get("VN"));
        System.out.printf("VN average order: %.2f%n", averageOrderInRegion(sales, "VN"));
        System.out.println("Last 7 days count: "
            + salesInLastNDays(sales, 7, LocalDate.of(2025, 12, 31)).size());
    }
}
javac SalesReport.java
java SalesReport

Dành 25–30 phút tự làm. Tự test với output kỳ vọng.

🚀 Hai chỉ dẫn tối ưu quan trọng

Để viết mã nguồn có chất lượng chuẩn enterprise, bạn cần vượt qua các cách tiếp cận ngây thơ và áp dụng triệt để hai quy tắc tối ưu hóa dưới đây:

1. Tránh Boxed Stream và Boxed Reduction (Tối ưu hóa bộ nhớ và hiệu năng)

Nhiều người mới thường thực hiện tính tổng doanh thu bằng cách viết:

// KHONG TOI UU (cach ngay tho):
double total = sales.stream()
    .map(Sale::amount) // Tra ve Stream<Double> (boxed)
    .reduce(0.0, Double::sum); // Boxed reduction

Vì sao không tối ưu? Với 10k bản ghi (hoặc hàng triệu bản ghi trong thực tế), phương án này ép JVM thực hiện quá trình tự động đóng hộp (autoboxing) và mở hộp (unboxing) liên tục. Mỗi phép tính cộng trung gian tạo ra một đối tượng Double mới trên Heap — tốn bộ nhớ và tăng áp lực Garbage Collector.

Giải pháp: Hãy dùng Primitive Stream mapToDouble(Sale::amount) để chuyển sang DoubleStream nguyên thủy, kết hợp tính trực tiếp qua .sum() hoặc gom nhóm bằng Collectors.summingDouble(). Pipeline khi đó thao tác trực tiếp trên giá trị double nguyên thủy, không tạo wrapper object trung gian — dữ liệu nguồn vẫn nằm trên Heap, nhưng tránh được toàn bộ chi phí boxing/unboxing per-element.

// TOI UU: dung DoubleStream nguyen thuy
double total = sales.stream()
    .mapToDouble(Sale::amount)
    .sum();

2. Bảo toàn tính bất biến bằng cách trả về Immutable Collection

Khi gom nhóm hoặc lọc danh sách kết quả (như trong Task 5 và Task 8), thói quen của thời kỳ Java 8 cũ là sử dụng:

// HAU QUA: tra ve mot List co the bi sua doi tu do tu ngoai (mutable)
return sales.stream()
    .filter(...)
    .collect(Collectors.toList());

Nếu Caller nhận được kết quả này và thực hiện thêm/xóa phần tử, nó sẽ trực tiếp phá vỡ tính bất biến của báo cáo, dẫn đến các lỗi khó debug hoặc rò rỉ dữ liệu.

Giải pháp: Bắt đầu từ Java 16+, hãy luôn ưu tiên sử dụng phương thức .toList() trực tiếp trên Stream hoặc Collectors.toUnmodifiableList() (từ Java 10+) thay cho Collectors.toList(). Các phương thức này trả về một Immutable Collection thực sự. Mọi hành vi cố tình chỉnh sửa nội dung danh sách kết quả sau đó sẽ bị ngăn chặn ngay lập tức ở runtime bằng UnsupportedOperationException.

💡 Gợi ý

💡 Gợi ý — đọc khi bị kẹt

Task 1 — totalRevenue: mapToDouble + sum. Tránh boxing.

return sales.stream().mapToDouble(Sale::amount).sum();

Task 2 — revenueByRegion: groupingBy với downstream summingDouble để gom nhóm và tính tổng hiệu năng cao không bị boxed.

return sales.stream()
    .collect(Collectors.groupingBy(
        Sale::region,
        Collectors.summingDouble(Sale::amount)));

Task 3 — orderCountByProduct: groupingBy với downstream counting.

return sales.stream()
    .collect(Collectors.groupingBy(Sale::product, Collectors.counting()));

Task 4 — topSale: max với Comparator.comparingDouble — trả Optional tự nhiên.

return sales.stream().max(Comparator.comparingDouble(Sale::amount));

Task 5 — topNProducts: 2 step: tính revenue per product (Map), rồi sort entries + limit + trả về immutable list bằng .toList().

return sales.stream()
    .collect(Collectors.groupingBy(Sale::product, Collectors.summingDouble(Sale::amount)))
    .entrySet().stream()
    .sorted(Map.Entry.<String, Double>comparingByValue().reversed())
    .limit(n)
    .map(Map.Entry::getKey)
    .toList(); // Tra ve immutable list (Java 16+)

Task 6 — revenueByRegionThenProduct: groupingBy lồng nhau.

return sales.stream()
    .collect(Collectors.groupingBy(
        Sale::region,
        Collectors.groupingBy(
            Sale::product,
            Collectors.summingDouble(Sale::amount))));

Task 7 — averageOrderInRegion: filter + mapToDouble.average, trả OptionalDouble. orElse(0) khi empty.

return sales.stream()
    .filter(s -> s.region().equals(region))
    .mapToDouble(Sale::amount)
    .average()
    .orElse(0);

Task 8 — salesInLastNDays: filter so sánh date. Dùng phủ định !isBefore để bao gồm cả ngày mốc. Trả về immutable list bằng .toList().

LocalDate cutoff = today.minusDays(days);
return sales.stream()
    .filter(s -> !s.date().isBefore(cutoff) && !s.date().isAfter(today))
    .toList(); // Tra ve immutable list

✅ Lời giải tối ưu

✅ Lời giải — xem sau khi đã thử
import java.time.LocalDate;
import java.util.*;
import java.util.stream.*;

public class SalesReport {

    public record Sale(String region, String product, String customer, double amount, LocalDate date) { }

    public static List<Sale> generate(int count) {
        String[] regions = {"VN", "US", "JP", "DE", "BR"};
        String[] products = {"Pen", "Book", "Laptop", "Phone", "Desk", "Chair"};
        Random rnd = new Random(42);
        LocalDate base = LocalDate.of(2025, 1, 1);

        return IntStream.range(0, count)
            .mapToObj(i -> new Sale(
                regions[rnd.nextInt(regions.length)],
                products[rnd.nextInt(products.length)],
                "user" + rnd.nextInt(1000),
                Math.round(rnd.nextDouble() * 1000 * 100.0) / 100.0,
                base.plusDays(rnd.nextInt(365))))
            .toList(); // Tra ve immutable list
    }

    public static double totalRevenue(List<Sale> sales) {
        // Toi uu: dung primitive DoubleStream de tranh boxing Double
        return sales.stream().mapToDouble(Sale::amount).sum();
    }

    public static Map<String, Double> revenueByRegion(List<Sale> sales) {
        // Toi uu: dung summingDouble de tranh gom nhom boxed
        return sales.stream()
            .collect(Collectors.groupingBy(
                Sale::region,
                Collectors.summingDouble(Sale::amount)));
    }

    public static Map<String, Long> orderCountByProduct(List<Sale> sales) {
        return sales.stream()
            .collect(Collectors.groupingBy(Sale::product, Collectors.counting()));
    }

    public static Optional<Sale> topSale(List<Sale> sales) {
        return sales.stream().max(Comparator.comparingDouble(Sale::amount));
    }

    public static List<String> topNProducts(List<Sale> sales, int n) {
        return sales.stream()
            .collect(Collectors.groupingBy(Sale::product, Collectors.summingDouble(Sale::amount)))
            .entrySet().stream()
            .sorted(Map.Entry.<String, Double>comparingByValue().reversed())
            .limit(n)
            .map(Map.Entry::getKey)
            .toList(); // Bao toan tinh bat bien cua ket qua
    }

    public static Map<String, Map<String, Double>> revenueByRegionThenProduct(List<Sale> sales) {
        return sales.stream()
            .collect(Collectors.groupingBy(
                Sale::region,
                Collectors.groupingBy(
                    Sale::product,
                    Collectors.summingDouble(Sale::amount))));
    }

    public static double averageOrderInRegion(List<Sale> sales, String region) {
        return sales.stream()
            .filter(s -> s.region().equals(region))
            .mapToDouble(Sale::amount)
            .average()
            .orElse(0);
    }

    public static List<Sale> salesInLastNDays(List<Sale> sales, int days, LocalDate today) {
        LocalDate cutoff = today.minusDays(days);
        return sales.stream()
            .filter(s -> !s.date().isBefore(cutoff) && !s.date().isAfter(today))
            .toList(); // Tra ve immutable list
    }

    public static void main(String[] args) {
        List<Sale> sales = generate(10_000);

        System.out.printf("Total revenue: %.2f%n", totalRevenue(sales));
        System.out.println("Revenue by region: " + revenueByRegion(sales));
        System.out.println("Order count by product: " + orderCountByProduct(sales));
        topSale(sales).ifPresent(s -> System.out.println("Top sale: " + s));
        System.out.println("Top 3 products: " + topNProducts(sales, 3));
        System.out.println("Revenue by region -> product (VN only): "
            + revenueByRegionThenProduct(sales).get("VN"));
        System.out.printf("VN average order: %.2f%n", averageOrderInRegion(sales, "VN"));
        System.out.println("Last 7 days count: "
            + salesInLastNDays(sales, 7, LocalDate.of(2025, 12, 31)).size());
    }
}

🎯 Ma trận Tự đánh giá Mã nguồn (Self-Assessment Matrix)

Sau khi hoàn thành bài thực hành, hãy sử dụng ma trận dưới đây để tự chấm điểm mã nguồn của mình. Hãy cố gắng đạt mức Xuất Sắc (Level 4) cho toàn bộ các tiêu chí để đảm bảo tư duy thiết kế hệ thống chuẩn mực!

Tiêu chíCần Cải Thiện (Level 1)Đạt Yêu Cầu (Level 2)Khá (Level 3)Xuất Sắc (Level 4)
Sử dụng Vòng lặpCòn sử dụng for/while hoặc forEach để gom cụm dữ liệu bên ngoài.Không dùng loop ngoài nhưng dùng forEach để add phần tử vào list trung gian mutable.Sử dụng stream pipeline hoàn toàn, nhưng cấu trúc còn rườm rà, chưa tối ưu.100% Declarative. Code thuần Stream pipeline. Tuyệt đối không tạo side-effect hay dùng loop ngoài.
Biến Trung gianTạo nhiều biến trạng thái mutable bên ngoài và thay đổi chúng trong quá trình chạy stream.Hạn chế biến mutable, nhưng vẫn tạo các bộ chứa (collections) rỗng để lưu dữ liệu tạm thời.Chỉ sử dụng các tham số effectively-final truyền vào stream.Không có trạng thái Mutable. Không khai báo bất kỳ biến trung gian thay đổi nào. Toàn bộ là luồng chảy dữ liệu thuần khiết.
Tính toán Số học (Boxing)Sử dụng Stream<Double> và tính tổng qua reduce(0.0, (a, b) -> a + b) gây tốn Heap nghiêm trọng.Sử dụng map(Sale::amount) rồi tính tổng qua Double::sum vẫn bị autoboxing.Dùng mapToDouble cho một vài phép tính đơn giản nhưng các thống kê phức tạp vẫn bị boxed.Primitive Stream Triệt để. 100% dùng mapToDouble(), DoubleStream kết hợp summingDouble() cho gom nhóm.
Tính Bất biến Kết quảTrả về ArrayList hoặc dùng Collectors.toList() cho phép chỉnh sửa kết quả tự do từ ngoài.Gom cụm qua Collections.unmodifiableList() nhưng list gốc truyền vào vẫn có khả năng bị sửa đổi.Trả về toList() nhưng chưa bảo vệ unmodifiable cho các DTO/Record bên trong.Bất biến nhất quán. Trả về immutable collection qua toList() (Java 16+) hoặc toUnmodifiableList().

Chúc mừng bạn đã hoàn thành Module Stream API & Lambda — module cuối của course Java OO & Functional!

Course tiếp theo: Java Internals — Process và Thread — bước vào concurrency, JVM memory model và CompletableFuture.

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên