Mini-challenge: Sales report với stream pipeline
Bài thực hành khép lại Module 9 — 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 9. Bạn sẽ viết module phân tích List<Sale> (~10k record) và xuất báo cáo thuần stream API — không imperative loop.
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.
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
| Concept | Bài | Dùng ở đây |
|---|---|---|
| Lambda, method reference | 9.1 | Mọi pipeline |
| Stream pipeline, terminal op | 9.2 | Kích hoạt pipeline |
| filter/map/reduce | 9.3 | Core operation |
| Collectors.groupingBy | 9.3 | Task 2, 3, 6 |
| Collectors.summingDouble | 9.3 | Task 2, 6 |
| Comparator, max/sorted | 9.3, 9.4 | Task 4, 5 |
| flatMap, takeWhile | 9.4 | (không dùng trực tiếp nhưng tham khảo) |
| Optional | 9.5 | Task 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.
💡 Gợi ý
Task 1 — totalRevenue:
mapToDouble + sum. Tránh boxing.
return sales.stream().mapToDouble(Sale::amount).sum();
Task 2 — revenueByRegion:
groupingBy với downstream summingDouble.
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.
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();
Task 6 — revenueByRegionThenProduct:
groupingBy lồng.
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. isAfter/isBefore của LocalDate.
LocalDate cutoff = today.minusDays(days);
return sales.stream()
.filter(s -> !s.date().isBefore(cutoff) && !s.date().isAfter(today))
.toList();
✅ Lời giải
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();
}
public static double totalRevenue(List<Sale> sales) {
return sales.stream().mapToDouble(Sale::amount).sum();
}
public static Map<String, Double> revenueByRegion(List<Sale> sales) {
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();
}
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();
}
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());
}
}
Điểm chính:
mapToDoubletrướcsum/average: tránh boxingDouble→ nhanh hơn với 10k+ record.DoubleStream.average()trảOptionalDoublevì stream rỗng chia cho 0.Collectors.groupingBy+ downstream: pattern chuẩn cho aggregate per group. Downstream có thể lồng để group 2-3 tầng.maxtrảOptionaltự nhiên: không phải null-guard thủ công.- Top-N pattern: map → entries → sort → limit → extract key. Đây là pattern tái dùng được với bất kỳ tiêu chí sort nào.
- Date filter:
isBefore/isAfterkhông inclusive — dùng phủ định!isBefoređể include boundary. - Tất cả method declarative: đọc là hiểu, không cần mental-execute loop.
🎓 Mở rộng
Mức 1 — Handle empty list gracefully:
public static double totalRevenue(List<Sale> sales) {
return sales == null ? 0 : sales.stream().mapToDouble(Sale::amount).sum();
}
Hoặc dùng Objects.requireNonNullElse(sales, List.of()). Defensive với API input.
Mức 2 — Parallel stream benchmark:
long t1 = System.nanoTime();
double r1 = sales.stream().mapToDouble(Sale::amount).sum();
long t2 = System.nanoTime();
double r2 = sales.parallelStream().mapToDouble(Sale::amount).sum();
long t3 = System.nanoTime();
System.out.printf("Sequential: %d us%n", (t2 - t1) / 1000);
System.out.printf("Parallel: %d us%n", (t3 - t2) / 1000);
Với 10k element, sequential thường nhanh hơn (overhead fork/join > work). Tăng lên 10M → parallel thắng. Học bằng đo thật.
Mức 3 — Custom Collector:
Viết collector tính tuple (count, sum, min, max, avg) trong 1 pass:
record Stats(long count, double sum, double min, double max) {
double average() { return count == 0 ? 0 : sum / count; }
}
Collector<Sale, ?, Stats> statsCollector = Collectors.teeing(
Collectors.summarizingDouble(Sale::amount),
Collectors.counting(),
(summary, count) -> new Stats(count, summary.getSum(), summary.getMin(), summary.getMax())
);
Stats stats = sales.stream().collect(statsCollector);
Hoặc dùng Collectors.summarizingDouble built-in trả DoubleSummaryStatistics — 1 method đủ.
Mức 4 — Output formatted table:
Map<String, Map<String, Double>> grid = revenueByRegionThenProduct(sales);
grid.forEach((region, byProduct) -> {
System.out.println("== " + region + " ==");
byProduct.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.forEach(e -> System.out.printf(" %-10s %,.2f%n", e.getKey(), e.getValue()));
});
Dùng forEach + nested stream — format sạch cho CLI report.
✨ Điều bạn vừa làm được
Hoàn thành mini-challenge này, bạn đã:
- Viết 8 stream pipeline declarative, không for/while — code đọc như câu tiếng Anh.
- Dùng
groupingBy2 tầng cho aggregate grid (region × product). - Tận dụng primitive stream (
DoubleStream) để tránh boxing overhead. - Xử lý empty case không null check nhờ
Optional/OptionalDouble. - Pattern top-N: map → entries → sort → limit → extract — tái dùng được với mọi tiêu chí.
- Thực hành
Comparator.comparingDouble,Map.Entry.comparingByValue().reversed()— API idiom Java modern. - Hiểu vì sao 80% code enterprise Java hiện nay viết theo stream — maintainability + correctness.
Chúc mừng — bạn đã hoàn thành Module 9! Bạn giờ đã có toolkit functional Java: lambda, stream, collector, Optional. Module 10 sẽ bước sang Concurrency — thread, synchronized, ExecutorService, và virtual threads (Java 21) — cốt lõi để viết backend high-throughput.
Bài này có giúp bạn hiểu bản chất không?