API là “giao diện lập trình ứng dụng”, điều này có nghĩa là bất kỳ chương trình nào mà có thể được dùng bởi những mã lệnh nằm bên ngoài chương trình đó đó đều có thể coi là API.
Theo cách hiểu này, bất kỳ khi nào nhà phát triển viết ra một khối mã lệnh mà có thể được dùng bởi ai đó, cộng đồng, đồng nghiệp, chính họ, hay có khi tất cả, đều có thể coi là một nhà phát triển API. Vậy là bất kỳ nhà phát triển Java nào cũng là một nhà phát triển API. Do đó, biết các nguyên tắc cơ bản của một API tốt là rất quan trọng với tất cả các nhà phát triển Java.
Dựa trên ý tưởng đó, bài viết này giúp bạn học áp dụng các nguyên tắc khi viết mã Java 8 để tạo ra các API:
- Hiển hiện một thiết kế tốt, và không hiển hiện các implement chi tiết
- Có thể sử dụng được biểu thức lambda
- Có thể tiến hóa một cách an toàn
- Không gặp phải
NullPointerException
Mở đầu
Cần phải cân nhắc cẩn trọng khi thiết kế một API. Cần làm cho nó đúng ngay từ đầu. API được xuất bản có nghĩa là đã có thể sử dụng và có ai đó đã sử dụng. Ngay từ lúc đó nhà phát triển đã có một cam kết vĩnh viễn không đổi với những người sử dụng nó. Vậy nên bản thiết kế của API cần cân bằng được sự cam kết vững bền với sự linh hoạt trong implement.
API được thiết kế tốt thì mã khách (những mã sử dụng đến API) cũng được đẹp theo. API càng giấu được nhiều các chi tiết của implement thì mã khách càng được bớt đi các phụ thuộc.
Thiết kế vừa là một kỹ thuật vừa là một nghệ thuật khó nắm bắt thành thạo. Nhưng hãy tưởng tượng thao tác hát karaoke, bạn không nhất thiết phải là một ca sĩ sừng sỏ mới có thể thể hiện tốt một bài hát, chỉ cần bạn hát đúng nhịp và đúng cao độ là đã rất thành công rồi. Chúng ta cũng có thể áp dụng một số nguyên tắc dễ dàng tránh được những sai lầm nghiêm trọng để có thể thiết kế API hiệu quả hơn.
Tránh trả về null
Xử lý null không cẩn thận (dẫn tới sự xuất hiện của những NullPointerException
) có khi là nguồn lỗi phổ biến nhất của các chương trình Java. Vài nhà phát triển còn xếp null vào một trong những sai lầm tệ hại nhất lịch sử nghành khoa học máy tính.
May mắn, Java 8 đã giới thiệu lớp Optional
như một trong những nỗ lực để hạn chế vấn đề này. Theo đó, một phương thức sẽ có thể sử dụng một đối tượng Optional
thay vì null
để mô tả những kết quả trả về mà có thể bị null. Đừng lo ngại về hiệu năng, dù sao thì Java 8 Escape Analysis cũng sẽ phân tích và tối ưu hóa hầu hết các đối tượng Optional
.
Nhưng lưu ý, nhớ tránh sử dụng các Optional làm tham số và thuộc tính.
Nên:
public Optional<String> getComment() {
return Optional.ofNullable(comment);
}
Không nên:
public String getComment() {
return comment; // comment is nullable
}
Tránh sử dụng mảng làm input và output cho API
Một lỗi thiết kế khá lỗi đã được tạo ra ở API Enum
của Java 5. Chúng ta đều biết rằng lớp Enum
có một phương thức values()
trả về một mảng tất cả các giá trị khác nhau của Enum. Vấn đề là Java framework cần đảm bảo rằng mã khách không thể thay đổi tập các giá trị của Enum (chẳng hạn bằng cách sửa đổi đối tượng mảng), thế nên phương thức values()
được viết để tạo lại đối tượng mảng mới mỗi lần được gọi. Không nhưng hiệu năng thấp, mà còn không dễ sử dụng. Nếu như kiểu trả về là một read-only List
thì sẽ thuận tiện hơn nhiều.
Mảng không hề là kiểu thích hợp để làm tham số đầu vào cho các phương thức hiển hiện của API. Mã khách sẽ phải clone kết quả nhận được thành đối tượng mới thật cẩn thận, nếu không sẽ rất rủi ro trước các nguy cơ đến từ tính toán đồng thời.
Trong phần lớn trường hợp, nếu cần thực hiện vào-ra nhiều phần tử một lúc, chúng ta nên cân nhắc viện đến Stream. Stream
căn bản đã là read-only ngay từ đầu. Stream rất dễ thao túng. Và Stream kết hợp tối ưu với Java 8 Escape Analysis, cho phép trì hoãn việc tính toán, giúp đảm bảo có tối thiểu số lượng đối tượng được tạo vào Heap, và qua đó gia tăng hiệu năng.
Nên:
public Stream<String> comments() {
return Stream.of(comments);
}
Không nên:
public String[] comments() {
return comments; // Exposes the backing array!
}
Cân nhắc sử dụng phương thức factory
Phải tránh cho mã khách không bị phụ thuộc vào các interface implement của API, nói cách khác là tránh cho mã khách và API bị couping, nếu không mã khách sẽ khó bảo trì hơn, còn API thì gặp phải một phổ cam kết không được phép thay đổi hành vi lớn hơn.
Giải pháp là sử dụng các phương thức factory. Chẳng hạn, nếu có interface Point
(điểm tọa độ) với hai phương thức int x()
và int y()
, chúng ta có thể hiển hiện một phương thức tĩnh pointFactory(int x, int y)
trả về một implement của Point
. Nếu cả x
và y
đều bằng 0
, implement sẽ là PointOrigoImpl
(gốc tọa độ) – một lớp không cần và không có trường dữ liệu x
và y
, còn nếu không thì implement sẽ là PointImpl
mang trường dữ liệu x
và y
nhận được.
Nên:
Point point = Point.of(1, 2);
Không nên:
Point point = new PointImpl(1, 2);
Lưu ý rằng các lớp triển khai nên nằm trong một package riêng biệt với API (chẳng hạn đặt interface Point
trong package com.company.product.shape
và các implement trong package com.company.product.internal.shape
). Điều này hạn chế khả năng các implement vô tình xuất hiện trong mã khách bởi các câu lệnh import *
.
Thiết kể để tổ hợp thay vì kế thừa
Nên tránh hoàn toàn việc cho phép kế thừa thứ gì đó API. Nếu một API được thiết kế để mã khách sử dụng bằng cách kế thừa, thì không phải bao giờ mã khách cũng có thể sử dụng được, dù gì một lớp cũng chỉ có duy nhất một lớp cha. Chưa kể nếu mã khách kế thừa thứ gì đó ở API thì đó sẽ là một cam kết không chỉnh sửa rất nặng nề dành cho API.
Giải pháp là composition, và thực hiện điều này đã trở nên dễ dàng với Java 8. Sử dụng interface, định nghĩa cho nó các các phương thức tĩnh nhận các lambda làm tham số, và dựa vào các tham số đó để viết ra các implement. Nhớ giấu kỹ những implement này đi.
Composition cũng giúp mã khách trở nên dễ đọc hơn nhiều. Như trong ví dụ dưới đây, thay vì phải kế thừa một lớp public AbstractReader
và ghi đè abstract void handleError(IOException ioe)
, sẽ tốt hơn nhiều nếu API hiển hiện một builder trong interface Reader
mà nhận vào một Consumer<IOException>
– thứ sẽ được sử dụng bởi các implement của API.
Mã khách nên:
Reader reader = Reader.builder()
.withErrorHandler(IOException::printStackTrace).build();
Không nên:
Reader reader = new AbstractReader() {
@Override
public void handleError(IOException ioe) {
ioe.printStackTrace();
}
};
Đặt annotation cho các functional interface
Functional Interface là các interface chỉ có duy nhất một phương thức trừu tượng. Lợi ích chính mà chúng mang lại là chúng ta có thể tạo ra các instance của interface một cách rất nhanh chóng bằng lambda expression:
Square s = (int x) -> x * x;
Phải gắn @FunctionalInterface
cho các interface này. Mục đích đầu tiên để báo hiệu cho người sử dụng rằng bạn bạn cam kết rằng interface là một functional interface. Bên cạnh đó còn báo hiệu cho compiler để hạn chế các trường hợp bạn vô tình bổ sung thêm một phương thức trừu tượng vào interface, làm mất khả năng tạo instance của interface bằng lambda, và theo đó làm hỏng mã khách.
Nên:
@FunctionalInterface
interface Square {
int calculate(int x);
}
Không nên:
interface Square {
int calculate(int x);
}
Tránh nạp chồng các phương thức có tham số là functional interface
Việc nạp chồng các phương thức mà đều có tham số là functional interface sẽ làm cho mã khách trở nên khó đọc hay thậm chí mơ hồ và không complie được. Lấy ví dụ, interface Point
với hai phương thức add(Function<Point, String> renderer)
và add(Predicate<Point> logCondition)
dưới đây. Khi mã khách gọi point.add(p -> p + " lambda")
, compiler sẽ không thể xác định được phương thức cần dùng và tung ra lỗi biên dịch.
Nên đặt tên phương thức khác nhau:
public interface Point {
addRenderer(Function<Point, String> renderer);
addLogCondition(Predicate<Point> logCondition);
}
Thay vì nạp chồng:
public interface Point {
add(Function<Point, String> renderer);
add(Predicate<Point> logCondition);
}
Tránh định nghĩa các phương thức default trong interface
Java 8 cho phép đặt các phương thức default implement vào các interface. Đôi khi ta có lý do để làm điều đó. Để chọn một cách implement phương thức mặc định cho bất kỳ lớp dẫn xuất nào, hay để bổ sung phương thức cho các instance của functional interface chẳng hạn.
Dù vậy, lạm dụng cơ chế này có thể làm cho các interface của API trông không khác gì một lớp implement. Di chuyển các phương thức chứa mã logic sang một lớp Util riêng biệt hay đặt chúng trong các lớp implement thì tốt hơn.
Nên:
public interface Line {
Point start();
Point end();
int length();
}
Không nên:
public interface Line {
Point start();
Point end();
default int length() {
int deltaX = start().x() – end().x();
int deltaY = start().y() – end().y();
return (int) Math.sqrt(
deltaX * deltaX + deltaY * deltaY
);
}
}
Validate các tham số đầu của API vào trước khi xử lý
Luôn có khả năng mã khách kiểm tra đối số truyền vào cho API của bạn không đủ chặt chẽ. Nếu API của bạn tiếp tục truyền khối dữ liệu này sâu xuống lớp dưới, lỗi xảy ra nếu có sẽ nằm rất thấp trong stack trace và rất khó tìm hiểu nguyên nhân.
Hãy đảm bảo rằng bạn kiểm tra kiểm tra chặt chẽ các đối số trước khi chúng được sử dụng trong các lớp triển khai. Nếu cần kiểm null, phương thức Objects.requireNonNull()
rất hữu dụng, JVM tối ưu hóa những phép kiểm tra không cần thiết nên hiệu năng sẽ tăng đáng kể so với cách thông thường.
Đôi khi bạn mô tả các ràng buộc của đối số trong cam kết của API, trong trường hợp đó bạn cũng cần validate đối số đúng như thế, nếu không người dùng sẽ bị bối rối.
Nên:
public void addToSegment(Segment segment, Point point) {
Objects.requireNonNull(segment);
Objects.requireNonNull(point);
segment.add(point);
}
Không nên:
public void addToSegment(Segment segment, Point point) {
segment.add(point);
}
Tổng kết
Hãy học thông qua thực hành. Bạn đã biết quy tắc. Hầu hết đều không khó áp dụng. Và bạn luôn có thể bắt đầu thực hành một quy tắc nào đó phù hợp. Bạn sẽ thấy mình liên tục mở rộng phạm vi thực hành sang các nguyên tắc khác.
Tác giả: Nguyễn Bình Sơn