logoMột hệ thống khó hiểu thì cũng khó thay đổi
Hexagonal Architecture - Phần 1: Động lực chuyển đổi

Series bài viết về Hexagonal:

Khi nói về kiến trúc phần mềm, ta thường nói về việc tổ chức các chức năng hay use case vào các triển khai vật lý, hay nói cách khác, là các service khác nhau. Đó có thể là một monolithic service hay một tập các micro-services. Nhưng, dù triển khai theo cách nào, bên trong mỗi service thành phần đó cũng đều cần phải thực hiện nhiệm vụ của nó: một hoặc một nhóm use case. Cách tổ chức các thành phần trong từng service sẽ được bàn tới trong bài này và tôi hi vọng rằng, kiến trúc này sẽ tạo ra một thói quen mới cho các bạn khi thiết kế và phát triển các dự án phần mềm.

Nội dung bao gồm các phần sau:

Phần 1: Nhắc lại Layered Architecture.

Phần 2: Hexagonal Architecture.

Mỗi phần, đặc biệt là phần 2 sẽ bao gồm nhiều bài viết do nếu đưa vào cùng một bài sẽ dẫn tới quá dài, gây khó khăn cho việc đọc và nắm bắt.

Alistair Corkburn, người tạo ra Hexagonal Architecture đã viết về mục tiêu của kiến trúc này như sau: Kiến trúc giúp ta tạo ra một ứng dụng có thể thực hiện được các bài kiểm thử hồi quy tự động (automated regression-tests) mà không cần giao diện người dùng (UI) hoặc database, thậm chí có thể tiếp tục hoạt động khi database không khả dụng và liên kết các ứng dụng với nhau mà không cần sự tham gia của người dùng.

Chi tiết bạn có thể xem ở đây.

Cũng khá khó hiểu, ta sẽ một lần nữa phân tích từng khía cạnh để hiểu được mục đích, cách triển khai loại kiến trúc này. Nhưng trước hết, cần xem lại các vấn đề với cách code truyền thống theo kiến trúc phân lớp Layered Architecture.

Bài toán được sử dụng để minh họa: Use case tạo mới account và login. Nghiệp vụ cụ thể sẽ được làm rõ qua phần Layered Architecture này.

Phần 1: Nhắc lại về Layered Architecture

Một trong những yêu cầu tiên quyết của phần mềm là phải đáp ứng đúng, đủ chức năng. Mặc dù không hiếm khi phần mềm gặp lỗi nhưng dù sao đây cũng là điều kiện cần để chúng được sinh ra và đưa vào sử dụng. Chức năng được người dùng nhìn thấy nhưng có những thứ họ không hoặc khó thấy được, đó là những yêu cầu phi chức năng như: tính bảo mật (security), khả năng vận hành (operability), khả năng bảo trì (maintainability), khả năng mở rộng (scalability),… Đôi khi, nếu không chú trọng những yêu cầu như vậy có thể dẫn tới một phần mềm ngày càng suy yếu, các vấn đề xuất hiện với tần suất dày hơn theo thời gian, và bao gồm cả nợ nần chồng chất hơn (technical debt). Do đó, khi xây dựng một phần mềm, ta cần đánh giá mức độ quan trọng của nó để sớm có những đầu tư hoặc dự phòng các nguồn lực khác nhau, bao gồm cả thiết kế kiến trúc.

Một mô típ thường gặp

Thực tế, có nhiều ứng dụng phát triển trong thời gian ngắn mà ở đó, các lập trình viên không có quá nhiều thời gian để đưa ra những quyết định tối ưu. Cũng có những ứng dụng được tạo cấu trúc theo thói quen. Thói quen đó thường thuộc về cá nhân, những người khởi tạo nên source code ở giai đoạn đầu tiên. Tôi có gặp trường hợp một anh tech leader tại một công ty, sau khi xây dựng thành công một ứng dụng và chuyển sang dự án mới, anh ngay lập tức tạo ra một cấu trúc module (package trong Java) gần như y hệt với dự án anh vừa làm mặc dù dự án vẫn đang ở giai đoạn tiếp nhận yêu cầu, tức là anh ấy cùng team dev vẫn đang chờ phân tích từ BA. Bạn thử xem cấu trúc này có quen thuộc không nhé:

Hình 1: Cấu trúc package thường gặp tại các project không chỉ riêng ngôn ngữ Java

Cách phân package như vậy cho thấy khả năng ứng dụng đang sử dụng Kiến trúc phân lớp – Layered Archiecture.

- Package “entity”: chứa các class đại diện cho database.

- Package “repository”: chứa các class thao tác với database đó dựa trên các thực thể “entity” trên.

- Package “service”: là nơi chứa định nghĩa các quy tắc nghiệp vụ dựa trên các thực thể “entity” được lấy ra từ database bằng cách sử dụng các “repository”. Xong xuôi, “service” lưu lại dữ liệu xuống cũng nhờ “repository”.

- Package “controller”: là giao diện, chứa các API endpoint để bên ngoài có thể giao tiếp.

(Bạn có thể xem thêm về Transaction Script ở đây vì nó cũng khá liên quan tới bài viết này.)

Mỗi package đóng vai trò là một lớp trong Layered Architecture, và chúng có vai trò riêng như mô tả. Theo quy ước, lớp trên sẽ phụ thuộc vào lớp bên dưới theo chiều như sau: controller à service à repository à model. Mô típ chung cho kiểu xử lý này là: input được gửi tới, từ đó, ứng dụng lấy dữ liệu từ bên ngoài (thường là database), sau đó tính toán, và cuối cùng, dữ liệu được gửi đi đâu đó (cũng thường là database). Điểm khác biệt chủ yếu giữa các ứng dụng là cách xử lý dữ liệu, tức là code thể hiện luật nghiệp vụ.

Sơ đồ phụ thuộc thể hiện trong hình sau:

Hình 2: Sơ đồ phụ thuộc giữa các lớp trong Layered Architecture

Sự phổ biến của kiến trúc phân ứng dụng ra thành các lớp như vậy tôi thấy, theo chủ quan, do một số nguyên nhân sau:

  • Sự thiếu cân nhắc phương án thiết kế khi khởi tạo dự án. Khi đó, họ không có ý tưởng cụ thể mình cần đi theo hướng nào.
  • Thói quen được hình thành từ nhiều dự án. Đây là dạng tồn tại phổ biến ở các dự án tại Việt Nam trong giai đoạn cuối của thế hệ 7x tới toàn giai đoạn 8x, thậm chí kéo tới đầu thế hệ 9x. Hiện giờ, nhiều người vẫn bị hình thành thói quen này, nên họ nếu không cân nhắc nghiêm túc, cũng sẽ vẫn bước trên lối mòn này. Thói quen này được thể hiện rõ theo cách mà kiến trúc này được áp dụng, được mô tả ngay dưới đây.
  • Tiến độ thúc ép khiến dự án không có đủ thời gian đào tạo những thành viên đã quen với Layered Architecture hình thành thói quen khác.

Building a Layered Architecture

Bên trên, tôi đã nói về thói quen hình thành kiến trúc này và nó được thể hiện rõ ràng trong cách áp dụng. Ta sẽ cùng đi chi tiết qua ví dụ mà tôi đã đề cập ở đầu bài viết: Use case tạo mới account.

Bước 1: Tạo database

Ban đầu, đội phát triển tiếp nhận yêu cầu nghiệp vụ, họ tiến hành thiết kế database. Kết quả là một mô hình dữ liệu logic cho tới mô hình dữ liệu vật lý được tạo ra. Trong use case ví dụ, ta có bảng accounts:

CREATE TABLE accounts (
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(255) NOT NULL,
    password VARCHAR(255) NOT NULL,
    email VARCHAR(255) NOT NULL UNIQUE
);

Bước 2: Ánh xạ database sang ứng dụng

Đội sử dụng mô hình đó để tạo ra các entity trên ứng dụng. Lúc này mô hình mà entity thể hiện trùng với mô hình dữ liệu dưới database:

@Entity
@Table(name = "accounts")
public class Account {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;
    @Column(name = "name", nullable = false)
    private String name;
    @Column(name = "password", nullable = false)
    private String password;
    @Column(name = "email", nullable = false, unique = true)
    private String email;

 

Tương ứng với entities là repositories thể hiện thao tác với database:

@Repository
public interface AccountRepository extends JpaRepository {   
    // Custom query method to find an account by email
    Optional findByEmail(String email);
}

Hai package này hợp lại thành Data Access Layer vì:

  • Entities thuần túy là các data class (class chứa data nhưng không có hành vi nghiệp vụ) phản ánh mô hình dữ liệu dưới database.
  • Repositories thuần túy thao tác với database để lấy về các data class. Chúng không phản ánh đúng nghĩa vai trò mà Repository Pattern được tạo ra.

Bước 3: Tầng Service nghiệp vụ

Tạo tầng nghiệp vụ với class AccountService. Trong bài viết về Transaction Script, tôi đã đề cập tới việc không cần thiết phải sử dụng AccountService interface. Lớp AccountService cần có các hành vi tương ứng với hai use case: tạo mới account và login. Đầu tiên, ta cần inject AccountRepository để thao tác với database:

@Service
@RequiredArgsConstructor
public class AccountService {
    private final AccountRepository accountRepository;
    …
}

Nghiệp vụ tạo mới account được thực hiện bởi phương thức createAccount(), phương thức này nhận tham số là một Data Transfer Object (DTO): AccountDto. Sử dụng DTO ở đây là một điểm sáng bởi:

  • DTO sinh ra để giao tiếp giữa các lớp khác nhau, ở đây là giữa controller và service.
  • Ta thấy có một sự “cám dỗ” với tham số này, đó là dùng chính Account do Account và AccountDto về cơ bản là giống nhau. Nhưng không nên làm thế hoặc có thể làm khi biết điều này (để biết được rủi ro khi ra quyết định): Account là entity, trường hợp này nó là database, nó có thể thay đổi rất khác so với giao diện, là AccountDto. Nếu ta dùng Account làm tham số cho createAccount() thì lớp giao diện (controller) sẽ bị ảnh hưởng bởi lớp database, và tốc độ thay đổi, lý do thay đổi của database có thể sẽ rất khác so với nghiệp vụ cần thiết để tạo account. Do đó, định nghĩa DTO giữ AccountService cố định được một phần nghiệp vụ tạo account đối với database.

public void createAccount(AccountDto accountDto) throws Exception {
    isEmailAlreadyUsed(accountDto.email());
    var user = new Account(accountDto.name(), accountDto.password(), accountDto.email());
    accountRepository.save(user);
}

private void isEmailAlreadyUsed(String email) throws Exception {
    if (accountRepository.findByEmail(email).isPresent()) {
        throw new Exception("Email address already exists");
    }
}

Code nghiệp vụ trên, dù đã có những kỹ thuật tốt để thể hiện dễ hiểu (bằng cách gom thao tác xử lý vào method có tên mô tả nghiệp vụ) nhưng nó vẫn là Transaction Script. Nó còn phụ thuộc chặt chẽ vào database qua repository và entity.

Nghiệp vụ login cũng tương tự thế:

public void login(String email, String password) throws Exception {
    var optionalAccount = accountRepository.findByEmail(email);
    if (optionalAccount.isPresent()) {
        var account = optionalAccount.get();
        var isThePasswordValid = isThePasswordValid(account.getPassword(), password);
        if (!isThePasswordValid) {
            throw new InvalidCredentialsException("Invalid credentials");
        }
    } else {
        throw new InvalidCredentialsException("Invalid credentials");
    }
}

private boolean isThePasswordValid(String password, String uiPassword) throws Exception {
    return password.equals(uiPassword);
}

Đoạn code trên mô tả nghiệp vụ login: Với email và password truyền vào, ứng dụng sẽ truy vấn tìm ra account với email (là định danh duy nhất của account). Nếu không tìm thấy, ứng dụng báo lỗi, nếu có, so sánh password với password truyền vào. Ở đây, method so sánh hai password được làm đơn giản bằng cách so sánh hai chuỗi với nhau, và cần chú ý là hai chuỗi này đã được mã hóa (encrypted password). Vì thế, input của login là encrypted password cho thấy, khi UI gửi dữ liệu tới, password này được mã hóa tại lớp controller. Điều này trong thực tế bạn có thể cân nhắc vì: thuật toán mã hóa của use case này có thể nằm trong nghiệp vụ của ứng dụng, nghĩa là, áp dụng mã hóa nên nằm trong AccountService.

Bước 4: Tầng controller - giao diện của service

Tới giờ, mọi thứ đã dần hoàn thiện, ta còn bước tạo AccountController nữa là xong. Tại đây, có hai phương thức tương ứng với hai API cần chìa ra: createAccount() và login(). 

@PostMapping("/create")
public ResponseEntity createAccount(@RequestBody AccountDto accountDto) {
try {
accountService.createAccount(accountDto);
return ResponseEntity.status(HttpStatus.CREATED).body("Account created successfully");
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
}
}
@PostMapping("/login")
public ResponseEntity login(@RequestParam String email, @RequestParam String password) {
try {
String encodedPassword = passwordEncoder.encode(password);
accountService.login(email, encodedPassword);
return ResponseEntity.ok("Login successful");
} catch (InvalidCredentialsException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid credentials");
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("An error occurred");
}
}

Bước 5: Kiểm thử

Sử dụng SpringBootTest:

@SpringBootTest
class LayeredArcApplicationTests {
    @Autowired
    private AccountService accountService;

    @Test
    public void givenTheUserEmailAlreadyExistsAnExceptionIsThrown() throws Exception {
        var userDto = new AccountDto("Name", "password", "test@davivieira.dev");
        accountService.createAccount(userDto);

        Assertions.assertThrows(
                Exception.class,
                () -> accountService.createAccount(userDto)
        );
    }
}

Nhận xét chung

  • Để thực hiện thành công bài kiểm thử, ta sẽ cần thiết lập một database để tầng Data Layer có thể hoạt động và do đó là Service Layer (trường hợp này, tôi sử dụng H2).
  • Ngoài ra, ta thấy rằng lõi nghiệp vụ tại Service Layer phụ thuộc chặt chẽ vào Data Layer và do đó là database. Nếu database cần lưu thêm thông tin, ví dụ, ta cần thêm thông tin “last_updated” để tracking thời điểm thay đổi gần nhất trong database và nghiệp vụ ứng dụng không cần. Rõ ràng, domain model trên ứng dụng dù không cần cũng phải phản ánh lên Account.
  • Không chỉ vậy, Service Layer còn phụ thuộc vào Spring qua các annotation được sử dụng. Việc phụ thuộc vào framework khiến cho khả năng tái sử dụng khó khăn hơn ở những nơi không sử dụng framework này hoặc khác version. Kiểm thử cũng khó khăn hơn vì những bài kiểm thử thuần túy nên tập trung vào nghiệp vụ nhưng lại mất công sức cho framework và khiến khả năng cơ động của chúng giả phần m đi đáng kể.

Tóm lại, Layered Architecture có một đặc trưng chủ yếu là code nghiệp vụ và code công nghệ (giao tiếp database, messaging, RESTful, framework) bị trộn lẫn vào nhau gây ra nhiều vấn đề như bài phân tích này đã chỉ rõ.

Hình 3: Sự pha trộn hỗn tạp giữa code nghiệp vụ và code công nghệ trong Layered Archiecture

Source code: Hexagonal Architecture Demo

Phần tiếp theo, ta sẽ xem xét phương án và cách refactoring code hiện có sang Hexagonal Architecture.

 

 

 

 

 

 

 

 

 

Bình luận
Gửi bình luận
Bình luận