Collectors.groupingBy và partitioningBy qua ví dụ
1. Tổng quan
Khi làm việc với Stream, bạn thường cần gom phần tử theo một tiêu chí. Ví dụ: đơn hàng theo thành phố, tổng tiền theo khách hàng, hoặc tách đơn lớn và đơn nhỏ. Collectors.groupingBy và Collectors.partitioningBy được tạo ra cho đúng việc này.
Bài viết đi qua từng dạng dùng phổ biến, kèm output cụ thể. Bạn sẽ biết cách kết hợp downstream collector, chọn kiểu Map trả về, và tránh vài lỗi hay gặp.
2. Dữ liệu mẫu
Mọi ví dụ trong bài dùng chung một tập đơn hàng:
import java.util.List;
enum OrderStatus { PENDING, PAID, CANCELLED }
record Order(String id, String customer, String city, OrderStatus status, long amount) {}
class OrderFixtures {
static List<Order> sample() {
return List.of(
new Order("O1", "An", "Hà Nội", OrderStatus.PAID, 500_000),
new Order("O2", "Bình", "Đà Nẵng", OrderStatus.PAID, 1_200_000),
new Order("O3", "An", "Hà Nội", OrderStatus.CANCELLED, 300_000),
new Order("O4", "Chi", "Hồ Chí Minh", OrderStatus.PAID, 2_000_000),
new Order("O5", "Bình", "Đà Nẵng", OrderStatus.PENDING, 150_000),
new Order("O6", "Chi", "Hồ Chí Minh", OrderStatus.PAID, 800_000)
);
}
}
amount tính bằng VND và dùng kiểu long cho gọn. Trong hệ thống thật, bạn nên dùng BigDecimal cho tiền tệ.
Các ví dụ bên dưới giả định đã có import static java.util.stream.Collectors.*; và List<Order> orders = OrderFixtures.sample();.
3. groupingBy cơ bản
Dạng đơn giản nhất chỉ nhận một hàm phân loại (classifier):
Map<String, List<Order>> byCity = orders.stream()
.collect(groupingBy(Order::city));
byCity.forEach((city, list) ->
System.out.println(city + " -> " + list.stream().map(Order::id).toList()));
Hà Nội -> [O1, O3]
Đà Nẵng -> [O2, O5]
Hồ Chí Minh -> [O4, O6]
Order::city quyết định key. Mỗi key ánh xạ tới một List chứa các đơn có cùng giá trị đó. Bên trong, phiên bản này tương đương groupingBy(Order::city, HashMap::new, toList()).
Lưu ý: Map trả về là
HashMap, nên thứ tự key không được đảm bảo. Output trên máy bạn có thể khác. Nếu cần thứ tự, xem mục 5.
4. Downstream collector
Tham số thứ hai của groupingBy là một Collector khác, gọi là downstream. Nó quyết định cách gom các phần tử trong cùng một nhóm. Đây là phần làm cho groupingBy thực sự mạnh.
4.1. Đếm và cộng
Map<OrderStatus, Long> countByStatus = orders.stream()
.collect(groupingBy(Order::status, counting()));
Map<String, Long> totalByCustomer = orders.stream()
.collect(groupingBy(Order::customer, summingLong(Order::amount)));
System.out.println(countByStatus);
System.out.println(totalByCustomer);
{PAID=4, PENDING=1, CANCELLED=1}
{An=800000, Bình=1350000, Chi=2800000}
counting() luôn trả về Long, không phải Integer. Bạn cần nhớ điều này khi khai báo kiểu biến. Ngoài summingLong, còn có summingInt, summingDouble, averagingLong và summarizingLong. Collector cuối trả về LongSummaryStatistics, chứa cùng lúc count, sum, min, max, average.
4.2. Biến đổi phần tử với mapping
Nếu chỉ cần một thuộc tính thay vì cả object, hãy dùng mapping:
Map<String, Set<String>> customersByCity = orders.stream()
.collect(groupingBy(Order::city, mapping(Order::customer, toSet())));
System.out.println(customersByCity);
{Hà Nội=[An], Đà Nẵng=[Bình], Hồ Chí Minh=[Chi]}
mapping(Order::customer, toSet()) lấy tên khách của từng đơn, rồi gom vào Set để loại trùng. An có hai đơn ở Hà Nội nhưng chỉ xuất hiện một lần.
4.3. Lấy phần tử lớn nhất trong nhóm
maxBy trả về Optional<Order>, vì về lý thuyết nhóm có thể rỗng. Trong groupingBy, một nhóm chỉ tồn tại khi có ít nhất một phần tử. Do đó bạn có thể “mở” Optional bằng collectingAndThen:
Map<String, Order> biggestByCustomer = orders.stream()
.collect(groupingBy(
Order::customer,
collectingAndThen(maxBy(Comparator.comparingLong(Order::amount)), Optional::get)
));
biggestByCustomer.forEach((customer, order) ->
System.out.println(customer + " -> " + order.id() + " (" + order.amount() + ")"));
An -> O1 (500000)
Bình -> O2 (1200000)
Chi -> O4 (2000000)
collectingAndThen chạy collector bên trong trước, sau đó áp dụng hàm hoàn thiện (Optional::get) lên kết quả.
Có một cách khác cũng hay dùng: toMap(Order::customer, Function.identity(), BinaryOperator.maxBy(...)). Cách này cho kết quả tương tự và không phải đi qua Optional.
4.4. Lọc trong nhóm với filtering
Collectors.filtering có từ Java 9. Nó khác với việc gọi .filter() trước khi nhóm:
Map<String, Long> cancelledPerCustomer = orders.stream()
.collect(groupingBy(Order::customer,
filtering(o -> o.status() == OrderStatus.CANCELLED, counting())));
Map<String, Long> filteredFirst = orders.stream()
.filter(o -> o.status() == OrderStatus.CANCELLED)
.collect(groupingBy(Order::customer, counting()));
System.out.println(cancelledPerCustomer);
System.out.println(filteredFirst);
{An=1, Bình=0, Chi=0}
{An=1}
Với filtering, mọi khách hàng vẫn có mặt trong kết quả, kể cả khi số đếm bằng 0. Khi bạn .filter() trước, Bình và Chi biến mất hoàn toàn. Hãy chọn theo nhu cầu: báo cáo cần đủ danh sách thì dùng filtering.
Tương tự, flatMapping (Java 9) giúp làm phẳng một collection bên trong mỗi nhóm.
5. Chọn kiểu Map trả về
Phiên bản ba tham số cho phép truyền vào một Supplier tạo Map:
Map<String, Long> sortedTotals = orders.stream()
.collect(groupingBy(Order::customer, TreeMap::new, summingLong(Order::amount)));
Map<OrderStatus, Long> statusCounts = orders.stream()
.collect(groupingBy(Order::status,
() -> new EnumMap<>(OrderStatus.class), counting()));
System.out.println(sortedTotals);
System.out.println(statusCounts);
{An=800000, Bình=1350000, Chi=2800000}
{PENDING=1, PAID=4, CANCELLED=1}
TreeMap sắp key theo thứ tự tự nhiên. EnumMap giữ key theo thứ tự khai báo trong enum và nhanh hơn HashMap khi key là enum. Nếu muốn giữ thứ tự xuất hiện đầu tiên trong stream, dùng LinkedHashMap::new.
Lưu ý:
TreeMapvớiStringsắp theo mã Unicode, không theo quy tắc tiếng Việt. Ví dụ “Đà Nẵng” sẽ đứng sau “Hồ Chí Minh”. Khi cần sắp đúng tiếng Việt, hãy truyền() -> new TreeMap<>(Collator.getInstance(Locale.of("vi"))).
6. Nhóm lồng nhau
Vì downstream là một collector bất kỳ, bạn có thể đặt một groupingBy khác vào đó:
Map<String, Map<OrderStatus, Long>> cityThenStatus = orders.stream()
.collect(groupingBy(Order::city, TreeMap::new,
groupingBy(Order::status, counting())));
System.out.println(cityThenStatus);
{Hà Nội={PAID=1, CANCELLED=1}, Hồ Chí Minh={PAID=2}, Đà Nẵng={PAID=1, PENDING=1}}
Kết quả là một Map hai tầng. Cách này rất tiện cho báo cáo dạng pivot. Tuy nhiên, quá hai tầng thì kiểu dữ liệu trở nên khó đọc. Khi đó, hãy cân nhắc dùng key ghép, ví dụ record CityStatus(String city, OrderStatus status), rồi groupingBy(o -> new CityStatus(o.city(), o.status())).
7. partitioningBy
partitioningBy là trường hợp đặc biệt của việc nhóm. Classifier là một Predicate, và key luôn là Boolean:
Map<Boolean, List<String>> bigOrders = orders.stream()
.collect(partitioningBy(o -> o.amount() >= 1_000_000,
mapping(Order::id, toList())));
Map<Boolean, Long> bigOrderCount = orders.stream()
.collect(partitioningBy(o -> o.amount() >= 1_000_000, counting()));
System.out.println(bigOrders);
System.out.println(bigOrderCount);
System.out.println("Đơn lớn: " + bigOrders.get(true));
{false=[O1, O3, O5, O6], true=[O2, O4]}
{false=4, true=2}
Đơn lớn: [O2, O4]
partitioningBy cũng nhận downstream collector giống hệt groupingBy. Nhưng nó không có phiên bản nhận Supplier cho Map, vì Map trả về là một cài đặt nội bộ chỉ có đúng hai key.
7.1. Khác biệt quan trọng: key luôn tồn tại
Đây là điểm khiến nhiều người chọn sai giữa hai collector:
List<Order> smallOnly = orders.stream().filter(o -> o.amount() < 1_000_000).toList();
Map<Boolean, List<Order>> partitioned = smallOnly.stream()
.collect(partitioningBy(o -> o.amount() >= 1_000_000));
Map<Boolean, List<Order>> grouped = smallOnly.stream()
.collect(groupingBy(o -> o.amount() >= 1_000_000));
System.out.println(partitioned.get(true));
System.out.println(grouped.get(true));
[]
null
partitioningBy luôn có cả true và false, kể cả khi một phía rỗng. groupingBy chỉ tạo key khi có ít nhất một phần tử. Gọi grouped.get(true).size() ở đây sẽ ném NullPointerException.
Vì vậy, khi tiêu chí là đúng/sai, hãy dùng partitioningBy. Ý định của code rõ hơn, và bạn không phải lo key bị thiếu.
8. Viết test cho logic nhóm
Thêm JUnit 5 và AssertJ vào pom.xml. Nếu dự án dùng Spring Boot 3.x, spring-boot-starter-test đã có sẵn cả hai:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
Đặt logic báo cáo vào một service riêng để dễ kiểm tra:
class OrderReportService {
Map<String, Long> paidTotalByCustomer(List<Order> orders) {
return orders.stream()
.collect(groupingBy(Order::customer, TreeMap::new,
filtering(o -> o.status() == OrderStatus.PAID,
summingLong(Order::amount))));
}
Map<Boolean, Long> countBigOrders(List<Order> orders, long threshold) {
return orders.stream()
.collect(partitioningBy(o -> o.amount() >= threshold, counting()));
}
}
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
class OrderReportServiceTest {
private final OrderReportService service = new OrderReportService();
@Test
void shouldSumOnlyPaidOrdersPerCustomer() {
var result = service.paidTotalByCustomer(OrderFixtures.sample());
assertThat(result)
.containsExactly(
entry("An", 500_000L),
entry("Bình", 1_200_000L),
entry("Chi", 2_800_000L));
}
@Test
void shouldAlwaysContainBothPartitions() {
var result = service.countBigOrders(OrderFixtures.sample(), 10_000_000);
assertThat(result)
.containsEntry(true, 0L)
.containsEntry(false, 6L);
}
}
Test đầu tiên dùng containsExactly được vì kết quả là TreeMap, nên thứ tự cố định. An chỉ được cộng 500.000, vì đơn O3 đã bị hủy. Bình chỉ được cộng 1.200.000, vì O5 còn đang chờ thanh toán.
Test thứ hai xác nhận rằng ngay cả khi không có đơn nào vượt ngưỡng, key true vẫn tồn tại với giá trị 0.
9. Lỗi hay gặp
Key null. groupingBy không chấp nhận classifier trả về null:
var orders = List.of(new Order("O9", "Dung", null, OrderStatus.PAID, 100_000));
orders.stream().collect(groupingBy(Order::city));
java.lang.NullPointerException: element cannot be mapped to a null key
Bạn có hai cách xử lý. Cách thứ nhất là thay null bằng giá trị mặc định: groupingBy(o -> Objects.requireNonNullElse(o.city(), "Không rõ")). Cách thứ hai là lọc bỏ đơn thiếu thành phố trước khi nhóm.
Nhầm kiểu trả về. counting() trả về Long. averagingLong trả về Double. maxBy trả về Optional. Nếu khai báo sai kiểu, bạn sẽ gặp lỗi biên dịch khó đọc. Khi phân vân, hãy dùng var hoặc để IDE suy kiểu.
Dùng groupingBy khi mỗi key chỉ có một giá trị. Nếu id là duy nhất, groupingBy(Order::id) tạo ra Map<String, List<Order>> với toàn list một phần tử. Trường hợp này hãy dùng toMap(Order::id, Function.identity()).
Sửa Map kết quả. Javadoc không đảm bảo kiểu, tính mutable hay thread-safety của Map trả về từ phiên bản mặc định. Nếu bạn định sửa Map sau đó, hãy truyền Supplier rõ ràng, ví dụ HashMap::new.
Lưu ý: Với parallel stream,
groupingByphải merge nhiều Map con, nên có thể chậm.groupingByConcurrentghi trực tiếp vào mộtConcurrentHashMapvà nhanh hơn. Đổi lại, nó không giữ thứ tự phần tử trong mỗi nhóm. Với stream tuần tự, cứ dùnggroupingBy.
10. Kết luận
groupingBy nhóm phần tử theo một key bất kỳ. partitioningBy chia đúng hai phía theo một Predicate và luôn trả về đủ cả true lẫn false. Sức mạnh thật sự nằm ở downstream collector: counting, summingLong, mapping, filtering, collectingAndThen hay một groupingBy lồng bên trong.
Bạn nên nhớ ba điểm khi dùng hai collector này:
- Chọn kiểu Map phù hợp (
TreeMap,EnumMap,LinkedHashMap) khi cần thứ tự. - Xử lý key null trước khi nhóm.
- Ưu tiên
partitioningBycho tiêu chí đúng/sai.
Nắm được những điểm này, phần lớn logic báo cáo của bạn sẽ chỉ còn vài dòng Stream dễ đọc và dễ test.