Tin tức mới

Các nguyên tắc thiết kế API cho Java 8

Java 8

Bất cứ ai viết mã Java đều là một nhà thiết kế API! Mã của họ đều rồi sẽ được dùng bởi ai đó, cộng đồng, đồng nghiệp, chính họ, hay có khi tất cả. 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.

Bài viết này giúp bạn học cách trở thành một lập trình viên Java tốt hơn bằng cách đưa ra các nguyên tắc giúp cho lập trình viên Java có khả năng viết các API:

  •       Hiển hiện một thiết kế tốt, và giấu tốt được các chi tiết triển khai
  •       Đảm bảo mã gọi tới API có thể sử dụng biểu thức lambda
  •       Đảm bảo API có thể tiến hóa một cách có kiểm soát
  •       Loại bỏ tất cả các khả năng gặp phải NullPointerException

Từ triển khai được dùng thường xuyên trong bài viết này, là dịch nghĩa của thuật ngữ implement. Các thuật ngữ chuyên ngành sẽ được in nghiêng trong các trường hợp cần thiết.

Mở đầu

Thiết kế một API tốt đòi hỏi kinh nghiệm và sự cân nhắc cẩn trọng. Cần làm cho nó đúng ngay từ đầu. Một khi API đã được xuất bản nghĩa là ván đã đóng thuyền, và nhà phát triển đã tự tạo ra một cam kết vĩnh viễn không đổi với những người sử dụng nó. Do đó thiết kế API cần kết hợp hài hòa khả năng cam kết chắc chắn với sự linh hoạt nhất định trong triển khai cao.

API tốt cũng cần làm cho mã của người dùng (từ giờ sẽ được gọi là mã khách) được đẹp, thanh nhã và dễ đọc. Đồng thời, API tốt phải giấu được càng nhiều chi tiết về sự triển khai càng tốt. Và đó là nhiệm vụ của thao tác thiết kế.

Đó đều là những nhiệm vụ khó nhằn, trên thực tế, việc thiết kế nhiều khi khó hơn viết mã triển khai rất nhiều. Thiết kế cũng như một nghệ thuật, khó nắm bắt và thành thạo. Nhưng cũng như 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à mọi chuyện đã rất tốt rồi. Chúng ta cũng có thể áp dụng một checklist, để API dễ dàng tránh được những sai lầm nghiêm trọng nhất trong thiết kế, trở thành lập trình viên tốt hơn, tiết kiệm được nhiều thời gian và từ đó sống hạnh phúc mãi mãi.

Không dùng null để nói “không có kết quả trả về”

Xử lý null không cẩn thận (dẫn tới sự phổ biến của NullPointerException) có khi là nguồn gốc của nhiều lỗi nhất trong lịch sử các chương trình Java. Vài nhà phát triển còn cho rằng việc cung cấp khái niệm null là một trong những sai lầm tệ hại nhất của lịch sử 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 bước đầu tiên để giảm bớt 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ả trường hợp không có kết quả trả về. Đừng bị cám dỗ bởi một chút hiệu năng để rồi ngang bướng sử dụng null. 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.

Tất nhiên, nhớ tránh sử dụng cácOptional 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

}

Không sử dụng các mảng để truyền giá trị ra và vào API

Một lỗi thiết kế khá lỗi đã được tạo ra trong Java 5, nằm ở API Enum. Chúng ta đều biết rằng lớp Enum có một phương thức gọi là 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), nên phương thức values() buộc phải tạo lại một đối tượng mảng mới mỗi lần được gọi. Dĩ nhiên là mất cả hiệu năng lẫn tính dễ sử dụng. Giá mà API Enum có thể trả về một đối tượng List chỉ-đọc, khi đó nó sẽ có thể dùng lại đối tượng qua vô số lời gọi từ phía mã khách, và mã khách cũng sẽ nhận được một model dễ dùng hơn.

Mảng cũng không hề 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, bởi trừ khi bạn cẩn thận clone mảng nhận được thành đối tượng mới, nếu không sẽ chẳng thể lường được nó bị một luồng nào đó khác chỉnh sửa ngay trong trong khi phương thức đang thực thi.

Trong phần lớn trường hợp, nếu cần chuyển giao vào ra một bộ các phần tử, chúng ta nên cân nhắc viện đến cấu trúc dữ liệu Stream. Stream chỉ-đọc một cách tự nhiên (trái ngược với List – vốn đi cùng với phương thức set). Stream cho phép mã khách dễ dàng thao túng cũng như thu gom các phần tử thành một cấu trúc dữ liệu khác. Và Stream kết hợp tối ưu với Java 8 Escape Analysis, cho phép API trì hoãn việc gom các phần tử vào nó cho tới khi mã khách cần tới (chiến thuật “lazy”) chừng nào nguồn dữ liệu vẫn còn hiện diện, giúp đảm bảo có tối thiểu số lượng đối tượng được tạo vào Heap.

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

Tránh trao cho mã khách quyền lựa chọn lớp triển khai của một interface. Việc này khiến cho mã của API và mã khách “dính” vào nhau nhiều hơn. Khiến số lượng các lớp không được phép thay đổi hành vi với bên ngoài bị tăng lên, và phổ cam kết của API trở nên lớn hơn.

Hãy cân nhắc tạo các phương thức tĩnh chuyên dụng để giúp mã khách tạo đối tượng của lớp triển khai interface. Chẳng hạn, nếu có interface Point 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 (int x, int y) trả về một triển khai (ẩn) của interface. Để rồi nếu cả x và y đều bằng 0, trả về sẽ là một lớp triển khai tên là  PointOrigoImpl không có cả trường x lẫn y, còn ngược lại thì trả về sẽ là một lớp khác mang tên PointImpl mang giá trị x và y chỉ định.

Lưu ý đảm bảo rằng các lớp triển khai 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 lớp triển khai trong package com.company.product.internal.shape).

Nên:

Point point = Point.of(1,2);

Không nên:

Point point = new PointImpl(1,2);

Ưu tiên composition với các functional interface và lambda thay vì kế thừa

Chúng ta nên tránh hoàn toàn việc cho phép kế thừa từ API. Dù gì cũng chỉ có duy nhất một lớp cha cho bất kỳ lớp Java nào, nên không phải bao giờ mã khách cũng có thể kế thừa lớp nào đó từ API. Chưa kể đến, hiển hiện một lớp trừu tượng trong API đồng nghĩa với cho phép chúng được kế thừa bởi mã khách, và đó sẽ là một cam kết rất nặng nề cho API.

Thay vào đó, nên sử dụng interface, 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 lambda đó để cấu thành một lớp triển khai nội bộ. Điều này cũng giúp mã khách 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 cho mã khách nhiều nếu API hiển hiện một phương thức hay một builder trong interface Reader mà nhận vào một Consumer<IOException> và đưa chúng cho một triển khai nội bộ.

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();

  }

};

Đảm bảo rằng functional interface đi kèm với annotation @FunctionalInterface

Gắn một interface với annotation @FunctionalInterface báo hiệu rằng mã khách có thể sử dụng lambda để triển khai interface, nó cũng đảm bảo interface sẽ luôn chỉ có duy nhất một phương thức trừu tượng và nhờ đó duy trì được khả năng triển khai bằng lambda trong tương lai.

Nên:

@FunctionalInterface

public interface CircleSegmentConstructor {

  CircleSegment apply(Point cntr, Point p, double ang);

  // abstract methods cannot be added

}

Không nên:

public interface CircleSegmentConstructor {

  CircleSegment apply(Point cntr, Point p, double ang);

  // abstract methods may be accidently added later

}

Tránh nạp chồng các phương thức theo tham số functional interface

Việc hai hay nhiều phương thức có cùng tên đều lấy functional interface làm tham số có thể tạo ra sự mơ hồ cho lambda ở mã khách. Lấy ví dụ, với Point có hai phương thức add(Function<Point, String> renderer) và add(Predicate<Point> logCondition), khi chúng ta gọi point.add(p -> p + ” lambda”) ở mã khách, compiler sẽ không thể xác định được phương thức nào cần dùng và cho ta lỗi biên dịch. Thay vào đó, nên cân nhắc sử dụng các tên phương thức khác nhau cho mỗi trường hợp sử dụng đặc thù.

Nên:

public interface Point {

  addRenderer(Function<Point, String> renderer);

  addLogCondition(Predicate<Point> logCondition);

}

Không nên:

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 vào các interface và đôi khi ta có lý do để làm điều đó. Chẳng hạn để chọn một cách mặc định để triển khai phương thức cho bất kỳ lớp triển khai nào. Ngoài ra, như chúng ta đã biết, các functional interfacechỉ có khả năng chứa chính xác một phương thức trừu tượng, do đó khi cần bổ sung những phương-thức-kế-thừa-được, sử dụng phương thức default là một cách khả dĩ. Việc này rất hữu ích để phục vụ tính tương thích ngược khi nâng cấp API.

Dù vậy, lạm dụng quá có thể làm cho interface của API trông không khác gì một lớp triển khai sau khi bị nhồi nhét quá nhiều các phương-thức-không-trừu-tượng không cần thiết. Hãy cân nhắc di chuyển các phương thức chứa logic sang một lớp Util riêng biệt hay đặt chúng trong các lớp triển khai.

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ý

Nhiều trường hợp ai đó kiểm tra tham số đầu vào của API một cách cẩu thả, và khi có lỗi xảy ra, nguyên nhân thực sự bị che giấu nhiều lớp rất sâu dưới stack trace. Hãy đảm bảo rằng các tham số được kiểm tra tính hợp lệ 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. Về mặt hiệu năng, JVM có khả năng tối ưu hóa những phép kiểm tra không cần thiết.

Validate tham số cũng là một cách tốt để thi hành cam kết của API. Nếu một API được mô tả là không chấp nhận các null nhưng rồi vẫn nhận theo cách nào đó, 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

Điều quan trọng là áp dụng vào thực hành. Bạn đã biết một số quy tắc để thiết kế nên một API tốt. Hầu hết đều không khó áp dụng, và bạn luôn có thể bắt đầu bằng một quy tắc bất kỳ mà bạn cảm thấy phù hợp, sau đó mở rộng dần khả năng sang các quy tắc khác.

Tác giả: Nguyễn Bình Sơn

One thought on “Các nguyên tắc thiết kế API cho Java 8”

Leave a Reply

Your email address will not be published. Required fields are marked *