1. Vấn đề: ngoại lệ trong mã bất đồng bộ
Trong mã thông thường (đồng bộ), mọi thứ khá đơn giản: nếu trong phương thức xảy ra ngoại lệ, nó “bắn” lên ngăn xếp lời gọi và ta có thể bắt nó bằng try-catch. Ví dụ:
try {
int x = 1 / 0;
} catch (ArithmeticException ex) {
System.out.println("Chia cho 0!");
}
Trong mã bất đồng bộ, tình hình phức tạp hơn. Khi ta khởi chạy một tác vụ qua CompletableFuture.supplyAsync, nó chạy ở một luồng khác. Nếu ở đó xảy ra ngoại lệ, nó sẽ không được ném ra ở luồng chính! Thay vào đó, nó sẽ được “đóng gói” bên trong đối tượng CompletableFuture, và nếu sau đó bạn gọi get() hoặc join(), bạn sẽ nhận ngoại lệ đó dưới dạng ExecutionException.
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
// Ôi, có lỗi ở đây!
return 1 / 0;
});
try {
Integer result = future.get(); // một ngoại lệ sẽ được ném ra ở đây!
} catch (Exception ex) {
System.out.println("Đã xảy ra lỗi: " + ex.getMessage());
}
Nhưng nếu bạn không gọi get() (mà thực ra bản thân nó cũng không mấy “bất đồng bộ”), mà xây chuỗi qua thenApply và các phương thức khác, lỗi có thể bị “lạc mất”. Vì vậy trong lập trình bất đồng bộ, việc biết bắt và xử lý lỗi ngay trong các chuỗi CompletableFuture là cực kỳ quan trọng.
2. Phương thức exceptionally: xử lý lỗi và trả về giá trị
Phương thức exceptionally cho phép bạn bắt ngoại lệ nếu nó phát sinh ở các bước trước đó trong chuỗi, xử lý nó và trả về một giá trị thay thế. Nó giống như catch, chỉ khác là dành cho dòng dữ liệu bất đồng bộ.
Chữ ký:
CompletableFuture<T> exceptionally(Function<Throwable, ? extends T> fn)
Ví dụ sử dụng
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
System.out.println("Đang thực hiện phép tính nguy hiểm...");
if (Math.random() > 0.5) {
throw new RuntimeException("Đã có gì đó không ổn!");
}
return 42;
});
future = future.exceptionally(ex -> {
System.out.println("Đã xảy ra lỗi: " + ex.getMessage());
return 0; // Trả về giá trị "an toàn"
});
Ví dụ với thenAccept
future.thenAccept(result -> System.out.println("Kết quả: " + result));
Kết quả (ví dụ):
Đang thực hiện phép tính nguy hiểm...
Đã xảy ra lỗi: Đã có gì đó không ổn!
Kết quả: 0
Đang thực hiện phép tính nguy hiểm...
Kết quả: 42
Quan trọng! Phương thức exceptionally chỉ kích hoạt nếu trong chuỗi trước đó có ngoại lệ chưa được xử lý. Nếu mọi thứ đều ổn, nó đơn giản “chuyển tiếp” kết quả đi tiếp.
3. Phương thức handle: bộ xử lý tổng quát cho kết quả và lỗi
Đôi khi chúng ta cần xử lý cả kết quả lẫn lỗi cùng lúc. Ví dụ, nếu mọi thứ ổn — trả về kết quả; nếu có lỗi — trả về phương án dự phòng hoặc ghi log lỗi.
Chữ ký:
CompletableFuture<U> handle(BiFunction<? super T, Throwable, ? extends U> fn)
- Đối số thứ nhất — kết quả (hoặc null nếu có lỗi),
- Đối số thứ hai — ngoại lệ (hoặc null nếu mọi thứ tốt).
Ví dụ sử dụng
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
if (Math.random() > 0.5) throw new RuntimeException("Lỗi ngẫu nhiên!");
return 100;
});
CompletableFuture<Integer> safeFuture = future.handle((result, ex) -> {
if (ex != null) {
System.out.println("Phát hiện lỗi: " + ex.getMessage());
return -1;
}
return result;
});
safeFuture.thenAccept(r -> System.out.println("Kết quả cuối cùng: " + r));
Kết quả:
Phát hiện lỗi: Lỗi ngẫu nhiên!
Kết quả cuối cùng: -1
Kết quả cuối cùng: 100
handle nên dùng khi bạn muốn hành động bất kể tác vụ kết thúc như thế nào — thành công hay lỗi. Đây là một bộ xử lý tổng quát luôn được gọi và nhận hai đối số: kết quả (nếu mọi thứ ổn) và ngoại lệ (nếu có gì đó không ổn).
Phương thức này rất phù hợp nếu bạn cần ghi log lỗi tập trung, trả về giá trị mặc định mà không làm gãy chuỗi, hoặc đơn giản là kết thúc kịch bản bất đồng bộ một cách gọn gàng.
Ví dụ:
CompletableFuture<Integer> future = CompletableFuture
.supplyAsync(() -> 10 / 0) // sẽ xảy ra lỗi ở đây
.handle((result, ex) -> {
if (ex != null) {
System.out.println("Lỗi: " + ex.getMessage());
return 0; // giá trị mặc định
}
return result;
});
System.out.println(future.join()); // sẽ in ra 0
Không giống exceptionally — chỉ phản ứng khi có lỗi — handle luôn kích hoạt, cho phép xử lý cả hai trường hợp tại một nơi và giữ cho toàn bộ chuỗi mượt mà.
4. Phương thức whenComplete: hành động phụ sau khi hoàn tất
Đôi khi ta không cần thay đổi kết quả, mà chỉ muốn thực hiện một hành động sau khi tác vụ kết thúc — ví dụ ghi log rằng tác vụ đã hoàn tất, bất kể thành công hay lỗi.
Chữ ký:
CompletableFuture<T> whenComplete(BiConsumer<? super T, ? super Throwable> action)
- Đối số thứ nhất — kết quả (hoặc null khi lỗi),
- Đối số thứ hai — ngoại lệ (hoặc null khi thành công).
Ví dụ sử dụng
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
if (Math.random() > 0.5) throw new RuntimeException("Lỗi!");
return 10;
});
future.whenComplete((result, ex) -> {
if (ex != null) {
System.out.println("Lỗi khi thực thi: " + ex.getMessage());
} else {
System.out.println("Hoàn tất thành công, kết quả: " + result);
}
});
Điểm khác biệt quan trọng:
whenComplete không thay đổi kết quả hay lỗi, mà chỉ thực thi hành động. Nếu trong whenComplete xảy ra ngoại lệ, nó sẽ được gắn kèm vào ngoại lệ hiện có.
Ví dụ: ghi log nhưng không can thiệp
future
.whenComplete((res, ex) -> {
System.out.println("Nhiệm vụ đã hoàn thành. Có lỗi không? " + (ex != null));
})
.thenAccept(r -> System.out.println("Kết quả cho người dùng: " + r));
5. Đặc điểm và lưu ý triển khai
Best practices: cách xử lý lỗi đúng trong CompletableFuture
- Luôn thêm xử lý lỗi (exceptionally, handle hoặc whenComplete) vào các chuỗi tác vụ bất đồng bộ. Nếu không, lỗi có thể không được chú ý và ứng dụng sẽ hành xử khó lường.
- Không dùng get() hoặc join() trên luồng chính mà không có try-catch — điều này biến mã bất đồng bộ thành đồng bộ và có thể dẫn tới khóa chờ.
- Nếu cần trả về giá trị “dự phòng” khi có lỗi — hãy dùng exceptionally hoặc handle.
- Đối với các tác dụng phụ (ghi log, thông báo người dùng) — hãy dùng whenComplete.
- Có thể kết hợp trong chuỗi: ví dụ, xử lý lỗi bằng exceptionally, sau đó ghi log qua whenComplete, rồi tiếp tục xử lý kết quả.
- Hãy nhớ: nếu lỗi không được xử lý, nó sẽ “chảy” tới lần gọi get()/join() tiếp theo và có thể làm ứng dụng sập.
Thứ tự phương thức
- Nếu bạn dùng exceptionally, nó chỉ bắt các lỗi phát sinh trước nó trong chuỗi.
- Nếu sau exceptionally trong chuỗi lại phát sinh lỗi (ví dụ ở thenApply), bạn cần xử lý riêng lỗi đó.
- handle là tổng quát — nó luôn kích hoạt, bất kể có lỗi hay không.
Kết hợp các phương thức
CompletableFuture.supplyAsync(() -> {
// ...
})
.handle((result, ex) -> {
if (ex != null) return "Lỗi: " + ex.getMessage();
return result;
})
.whenComplete((res, ex) -> {
System.out.println("Nhiệm vụ đã kết thúc, kết quả: " + res);
});
Điều gì xảy ra nếu không xử lý lỗi?
Nếu ngoại lệ không được xử lý và bạn gọi get() hoặc join(), nó sẽ bị ném ra dưới dạng ExecutionException (hoặc CompletionException), và ứng dụng có thể kết thúc với lỗi.
6. Những sai lầm thường gặp khi xử lý lỗi trong CompletableFuture
Lỗi số 1: không có xử lý lỗi. Nếu không thêm exceptionally, handle hoặc whenComplete, lỗi sẽ “lạc mất” tới lần gọi get()/join() tiếp theo, có thể rất xa nơi phát sinh.
Lỗi số 2: dùng get()/join() trên luồng chính mà không có try-catch. Điều này biến mã bất đồng bộ thành đồng bộ và có thể dẫn tới khóa chờ hoặc ứng dụng sập bất ngờ.
Lỗi số 3: hiểu sai nơi bộ xử lý được kích hoạt. exceptionally chỉ bắt lỗi trước nó trong chuỗi. Nếu sau nó lại phát sinh lỗi, phương thức này sẽ không xử lý lỗi đó.
Lỗi số 4: xử lý lỗi nhưng không trả về giá trị. Trong exceptionally hoặc handle bạn bắt buộc phải trả về một giá trị, nếu không bước tiếp theo sẽ nhận null (hoặc không nhận gì).
Lỗi số 5: nhầm lẫn giữa handle và whenComplete. handle có thể thay đổi kết quả, còn whenComplete — chỉ thực hiện hành động (ví dụ, ghi log). Nếu bạn muốn thay đổi kết quả — hãy dùng handle.
Lỗi số 6: trùng lặp logic xử lý lỗi. Thường có thể gom xử lý lỗi vào một nơi để tránh trùng lặp mã — ví dụ, qua handle tập trung hoặc một bộ xử lý chung.
GO TO FULL VERSION