hexagonal

Bài viết này nằm trong series về kiến trúc xây dựng ứng dụng của Grzegorz Ziemonski.

#1 Tại sao lại gọi là kiến trúc lục giác?

Kiến trúc lục giác là một cách tổ chức xây dựng ứng dụng. Khác với kiến trúc phân tầng, kiến trúc lục giác chỉ chia 1 ứng dụng thành 2 vòng: vòng trong - vòng ngoài (inside - outside).

Inside - vòng trong - chứa những thành phần tương tự như 2 tầng application và domain trong kiến trúc phân tầng

Outside - vòng ngoài - chứa tất cả những gì còn lại: UI, database, hệ thống messaging,...

Phần liên kết giữa 2 vòng này được đảm nhiệm bởi "adapter" và "port".

"adapter" là một implementation của "port", "port" lại là một dạng trừu tượng hóa (abstraction) của các kết nối giữa inside và outside. "port" sẽ gắn liền với yêu cầu của "vòng trong", việc triển khai "adapter" sẽ phụ thuộc vào "port" tương ứng và các yêu cầu đến từ "vòng ngoài".

Một ứng dụng có thể có nhiều port và adapter. Và hình lục giác trong trường hợp này chính là một hình ảnh ẩn dụ từ đó.

hexagonal

Kiến trúc lục giác cũng kéo theo 3 quy tắc:

  1. Vòng trong "không biết gì" về vòng ngoài
  2. Có thể implement bất kì adapter nào cho một port được yêu cầu
  3. Vòng ngoài - outside part - không chứa use case hoặc domain logic

#2 Bản chất của kiến trúc lục giác

Theo ý kiến của tác giả, khi xem xét kiến trúc lục giác, ta cần chú ý đến 2 đặc trưng của nó:

  1. Việc chia tách phần core của ứng dụng
  2. Tư duy lập trình theo "port" và "adapter"

Chia tách phần core của ứng dụng đồng nghĩa với việc các use case và domain logic sẽ không xuất hiện trong các dependency ở "vòng ngoài". Chúng ta trừu tượng hóa các business rules của ứng dụng thông qua "port". Việc này đem lại 2 thuận lợi:

  • Phần logic xử lý của ứng dụng sẽ không bị lệ thuộc vào mô hình triển khai.
  • Dễ dàng kiểm thử phần lập trình cho logic xử lý..

Về vấn đề tư duy lập trình theo "port" và "adapter", trong kiến trúc phân tầng, có một khác biệt lớn giữa tầng application và tầng infastructure. Tầng infrastructure luôn được đánh giá là "quan trọng hơn" bởi nó liên quan trực tiếp tới application logic và domain logic. Tuy nhiên với kiến trúc lục giác, sự khác biệt đó không tồn tại bởi các thành phần tương ứng với tầng application và infrastructure chỉ nằm ở vòng ngoài. Việc tương tác giữa 2 vòng đảm nhận bởi port và adapter. Triển khai port thường liên quan tới "vòng trong" nhiều hơn trong khi triển khai "adapter" liên quan tới "port" tương ứng và các yêu cầu xử lý của vòng ngoài.

Thực tập NodeJS Full Stack: xây dựng ứng dụng Microservices

#3 Triển khai kiến trúc lục giác qua một ví dụ đơn giản

Trong ngôn ngữ lập trình Java, có thể xem interface tương ứng với port, còn adapter thì đã được mô tả trong cuốn sách này.

Ví dụ: game Tic-tac-Toe

Trong game này, khi bạn cần ghi lại lượt đi của người chơi, bạn có thể dùng Scanner và đọc trực tiếp lượt đi đó. Tuy nhiên cách này lại khó test và nó làm phát sinh một vấn đề liên quan tới phân biệt lượt đi của máy và của người chơi.

Nếu áp dụng kiến trúc lục giác vào trường hợp này, ta sẽ tạo một "port" phụ trách liên lạc với người chơi. Khi đó, việc xử lý lượt đi mà người chơi nhập vào sẽ thuộc "vòng ngoài" và hoàn toàn độc lập với logic xử lý ở vòng trong của game.

Tạo port:

public interface PlayerPort {
    Coordinates nextMove();
}

Implement Adapter từ Port mới tạo:

public class ConsolePlayerAdapter implements PlayerPort {
 
    @Override
    public Coordinates nextMove() {
        // actual console reading stuff
    }
}

Ví dụ cũ : Pet Clinic

Tiếp theo chúng ta sẽ xét ví dụ về ứng dụng Pet Clinic đượ trình bày từ bài viết trước.

pet clinic hexagonal

Chắc chắn, controller và các config class sẽ thuộc vòng ngoài. Các domain class như Owner, Pet sẽ thuộc vòng trong. Các Repository là interface - như vậy khi chuyển sang mô hình lục giác, chúng sẽ ứng với port. Tuy nhiên ở các Repository này có thể chứa các dependency trên Spring và chứa ngôn ngữ truy vấn sử dụng @Query annotation. Tình huống này dẫn đến một câu hỏi thú vị:

Spring, JPA... có phải là một phần của "vòng ngoài"

Với việc sử dụng port và implement chúng thành các adapter vừa ý, chúng ta vừa gỡ bỏ được sự cồng kềnh trong use case và domain logic, vừa có được sự linh hoạt khi implement port ra các adapter. Tuy nhiên có người sẽ thắc mắc rằng ap dụng port và adapter mang lại sự linh hoạt nhưng lại phải thêm một s...

Theo ý kiến của tác giả,

Hãy tạm gác các vấn đề liên quan tới ràng buộc của framework và quay trở lại với ví dụ Pet Clinic.

Bước đầu tiên, chúng ta cần phân tách các class thuộc vòng trong và vòng ngoài. Với ví dụ này, ta cần tạo tầng Presentation...

hexagonal

Tiếp theo, ta tách use case logic ra khỏi controlller. Trong ví dụ này, thử áp dụng với tính năng sắp xếp một buổi khám:

@RequestMapping(value = "/owners/{ownerId}/pets/{petId}/visits/new", method = RequestMethod.POST)
public String processNewVisitForm(@Valid Visit visit, BindingResult result) {
    if (result.hasErrors()) {
        return "pets/createOrUpdateVisitForm";
    } else {
        this.visits.save(visit);
        return "redirect:/owners/{ownerId}";
    }
}

Method này kiểm tra tính hợp lệ của request visit và trả về 1 trong 2 trang kết quả tương ứng. Như vậy, khi chuyển sang kiến trúc lục giác, ta sẽ tạo một port xử lý phần UI trong trường hợp này.

public interface UserInterfacePort {
    void promptForCorrectVisit();
    void presentOwner();
}

Tiếp theo ta có thể trừu tượng hóa phần view details và tách nó ra khỏi use case logic:

public class VisitService {
    private final VisitRepository visits;
 
    public VisitService(VisitRepository visits) {
        this.visits = visits;
    }
 
    public void scheduleNewVisit(Visit visit, BindingResult result, UserInterfacePort userInterfacePort) {
        if (result.hasErrors()) {
            userInterfacePort.promptForCorrectVisit();
        } else {
            this.visits.save(visit);
            userInterfacePort.presentOwner();
        }
    }
}

Bước cuối cùng, ta implement một adapter và sử dụng nó trong controller:

ublic class ViewNameUiAdapter implements UserInterfacePort {
    private String viewName;
 
    @Override
    public void promptForCorrectVisit() {
        this.viewName = "pets/createOrUpdateVisitForm";
    }
 
    @Override
    public void presentOwner() {
        this.viewName = "redirect:/owners/{ownerId}";
    }
 
    String getViewName() {
        return viewName;
    }
}
@RequestMapping(value = "/owners/{ownerId}/pets/{petId}/visits/new", method = RequestMethod.POST)
public String processNewVisitForm(@Valid Visit visit, BindingResult result) {
    ViewNameUiAdapter viewNameUiAdapter = new ViewNameUiAdapter();
    visitService.scheduleNewVisit(visit, result, viewNameUiAdapter);
    return viewNameUiAdapter.getViewName();
}

#4 Được lợi gì?

  • Dễ học - Chỉ cần "biết" tạo port và adapter một cách hợp lý
  • Mang lại sự "trong sạch" cho application và domain logic: phần code quan trọng nhất này sẽ không bị dính các chi tiết "phụ"
  • Linh hoạt: thể hiện qua việc implement các adapter theo bất cứ yêu cầu nào đến từ vòng ngoài
  • Dễ test: ta có thể dễ dàng viết test cho các dependecy của vòng ngoài mà không cần dùng đến các công cụ mocking.

#5 Mất gì?

  • Cồng kềnh: ngay từ ví dụ trên ta đã chia tách từ 1 method trong 1 class thành 1 interface, 3 class, 6 method
  • Khó khăn trong việc theo dõi flow of control
  • Khó khăn khi áp dụng ra các framework khác nhau
  • Không có quy chuẩn tổ chức code

#6 Áp dụng khi nào?

Cần sự "trong sạch" trong domain logic : theo ý tác giả, các ứng dụng kiểu này thường không chứa UI hoặc các chi tiết lặt vặt khác, tập tung chủ yếu vào việc tương tác dưới dạng request/response. Do đó có thể tự do sử dụng các pattern Repository, Gateway,...

Khi ứng dụng thực sự cần tính linh hoạt: việc sử dụng port và adapter rất thích hợp cho trường hợp này.

Yêu cầu ứng dụng phải đảm bảo khả năng dễ kiểm thử

Kết luận

Kiến trúc lục giác tiếp cận ứng dụng của chúng ta thông qua việc tổ chức code thành 2 vòng: vòng trong và vòng ngoài. Vòng trong chứa use case và domain logic, vòng ngoài chứa tất cả những thứ còn lại: UI, database, messaging. 2 vòng này được "kết nối" thông qua "port" và "adapter". "port" sinh ra tương ứng với yêu cầu của "vòng trong" và "adapter" được implement từ "port" bởi vòng ngoài.

Khi áp dụng kiến túc này, chúng ta tách phần use case và domain code (được xem như phần code quan trọng nhất của ứng dụng) ra khỏi các chi tiết "gây rối". Nhờ vậy, ứng dụng trở nên linh hoạt và dễ dàng kiểm thử.

Techmaster via TidyJava