+1

Làm sao để kết hợp Domain Driven Design (DDD), Onion, Command Query Responsibility Segregation (CQRS),...

Chia sẻ
  • 1014 1
 Xuất bản thg 1 23, 2019 10:41 SA 1014 1 1 0
  • 1014 1

Nội dung bài viết:

  • Những khối cơ sở của một hệ thống
  • Công cụ (tools)
  • Kết nối các công cụ và cơ chế truyền phát với nhân ứng dụng
    • Bộ chuyển đổi (Adapters)
    • Cổng (Port)
    • Bộ điều hợp (Driving Adapters)
    • Bộ chuyển hợp (Driven Adapters)
    • Điều nghịch (Inversion of Control)
  • Tổ chức Nhân ứng dụng
    • Lớp ứng dụng (Application Layer)
    • Lớp phạm vi (Domain Layer)
      • Domain Services
      • Domain Model
    • Thành phần (Component)
      • Phân tách các thành phần (Decoupling the components)
        • Gọi logic trong component khác
        • Lấy data từ component khác
          • Data storage chia sẻ giữa các component
          • Data storage tách trên các component
    • Flow of Control

Những khối cơ sở của một hệ thống

Một số kiến thức nguyên thủy và đơn giản mà rất nhiều người biết, 3-tier, Ports & Adapters architectures. Chúng đều phân chia một cách rõ ràng giữa phần code bên trong ứng dụng, phần code bên ngoài ứng dụng và phần code dùng để kết nối 2 phần.


Ta có thể phân loại thành 3 loại chính:

  • Code thuộc về User Interface (UI)
  • Code thuộc về Logic nghiệp vụ (Bussiness Logic)
  • Infrastructure Code, khối code cơ sở, để kết nối phần nhân ứng dụng với các công cụ như Database, Search Engine, hay API bên thứ 3,...

3 Khối code cơ sở

Phần nhân ứng dụng (Application Core - phần này quan trọng) là nơi logic nghiệp vụ thực hiện. Nó có thể dùng nhiều giao diện người dùng (User Interfaces: progressive web, app, mobile, CLI, API, ...) nhưng cách thức hoạt động của code ở phần này là giống nhau và được đặt ở trong nhân ứng dụng, không quan tâm loại UI tác động lên nó là gì.

Quy trình của một ứng dụng điển hình đi từ khối code ở UI, qua nhân ứng dụng, tới khối code cơ sở và trở lại khối code cơ sở rồi kết thúc ở phần code UI với một response cụ thể. Flow of Control

Công cụ (Tools)

Chúng ta cũng cần những công cụ ngoài để phục vụ mục đích cụ thể của ứng dụng. Ví dụ: Database Engine, Search Engine, Web server hay CLI console,... Cùng là công cụ nhưng chúng đều có sự khác biệt để phân loại. Sự khác biệt giữa CLI console và Web Server, trong khi CLI console "ra lệnh" cho ứng dụng thì Database Engine lại "thi hành mệnh lệnh" từ ứng dụng.

Kết nối các công cụ và cơ chế truyền phát với nhân ứng dụng

  • Bộ chuyển đổi (Adapters): Trong kiến trúc Ports & Adapters, phần code dùng để kết nối các công cụ với nhân ứng dụng được gọi là Adapter.

  • Cổng (Port): Những adapters được tạo một cách có chủ đích và để dùng tại một điểm đầu vào (Entry Point) của nhân ứng dụng. Điểm đầu vào đó được gọi là Port. Ports đơn giản là những đặc tả của của ứng dụng. Ports (interfaces) luôn nằm bên trong ứng dụng, trong khi adapters lại nằm bên ngoài ứng dụng. Như đã nói, các ports được tạo ra là để phù hợp với các yêu cầu từ bên trong nhân ứng dụng, chứ không đơn thuần là để mapping với các công cụ bên ngoài.

    Ví dụ: Trong Java, Port có thể là inteface và cũng có thể là DTOs ( Data Transfer Objects).

  • Bộ điều hợp (Driving Adapters): chứa một cổng (port) và dùng nó để giao tiếp với nhân ứng dụng. Nó dựa trên những tác nhân từ cơ chế chuyển phát (Delivery Mechanism) để tìm ra method sẽ gọi trong nhân ứng dụng. Nôm na là những adapters nói cho nhân ứng dụng phải làm gì. Driving Adapters

  • Bộ chuyển hợp (Driven Adapters): Ngược lại với bộ điều hợp (Driving Adapters), Driven Adapters thực hiện trên một port, một interface. Nôm na là những adapters được nhân ứng dụng "ra lệnh" phải làm gì.

    Ví dụ: Ứng dụng của ta cần xử lý persist data, nên ta cần tạo ra 1 persistance interface bao gồm các method CRUD thích hợp và inject vào nhân ứng dụng. Khi cần thực hiện persist data thì ta chỉ việc sử dụng những interface này. Nếu về sau có thay đổi sử dụng MySQL sang MongoDB thì ta chỉ cần tạo những adapters tương ứng để thay thế adapters trước đó rồi lại inject vào nhân ứng dụng. Driven Adapters

  • Điều nghịch ( Inversion of Control): Đối với Ports & Adapters pattern, adapters sẽ phụ thuộc vào các tools hoặc ports. Nhưng nhân ứng dụng chỉ phụ thuộc vào thiết kế của ports để phù hợp với logic nghiệp vụ, mà không phụ thuộc vào adapters hay tool nào. Inversion of Control Tool -> Adapter

    Adapter -> Port

    Port -> Application Core

    Như ta thấy, hướng của sự phụ thuộc đi từ ngoài vào ( hướng về tâm của nhân ứng dụng) và layer trong không biết layer ngoài hoạt động ra sao, ta gọi là inversion of control principle ở cấp kiến trúc (architectural level).

    Tổ chức nhân ứng dụng

    Kiến trúc Onion sử dụng ý tưởng của Ports & Adapters Architecture và kết hợp với các layers từ Domain Driven Design.

    • Kiến trúc Onion: Tư tưởng của kiến trúc Onion cũng là tách biệt phần nhân ứng dụng với phần hạ tầng bên ngoài bằng cách tạo ra các adapters giúp phần hạ tầng bên ngoài giao tiếp được với nhân ứng dụng và không bị rò dỉ vào trong nhân ứng dụng. Như vậy, nó cung cấp cho chúng ta khả năng dễ dàng thay đổi các tools, delivery mechanism. Ngoài ra, có thể chạy ứng dụng mà không cần phần hạ tầng thật bên ngoài, nôm na là ta có thể fake dữ liệu để test. Tuy là kiến trúc onion có nhiều hơn 2 layers nhưng nó vẫn có thể thêm nhiều layers khác với logic nghiệp vụ thích hợp. Điểm này khác và để phân biệt với Domain Driven Design. Onion Architecture

      Ta thấy, kiến trúc Onion vẫn tuân theo nguyên tắc Inversion of Control. Do đó, việc thay đổi ở lớp ngoài không ảnh hưởng đến lớp bên trong.

      Những nguyên tắc cốt lõi của kiến trúc Onion:

      • Ứng dụng được xây dựng dựa trên một mô hình đối tượng độc lập ( independent object model)
      • Layers trong tạo interface, layers ngoài dùng intefaces
      • Hướng của sự phụ thuộc hướng vào trong tâm.
      • Nhân ứng dụng có thể biên dịch và chạy mà không cần phần hạ tầng ngoài.
      • Layers ngoài có thể gọi trực tiếp layers bên trong, không chỉ mỗi layer ngay bên dưới.
    • Domain Driven Design (DDD): Chia vấn đề lớn thành các phần nhỏ hơn với những nhiệm vụ cụ thể mà vẫn dựa trên những quy tắc có thiết kế khác.

      Ví dụ: Trong một hệ thống doanh nghiệp lớn phục vụ rất rất nhiều đối tượng người dùng khác nhau, vậy ta cần chia nhỏ hệ thống thành các hệ thống nhỏ, đơn lẻ nhiệm vụ và đối tượng người dùng.

      Vì bài viết đã dài rồi mình không muốn đề cập hết những nguyên tắc thiết kế cụ thể trong pattern mà chỉ đề cập đến một số chú ý và những khái niệm cơ bản:

      • Ngôn ngữ chung (Ubiquitous language): Mapping những thuật ngữ nghiệp vụ và thuật ngữ code với một ngôn ngữ chung nhằm có một sự thống nhất từ đó dễ dàng trao đổi, maintain, refactoring,...
      • Layers: các layers cơ bản tương ứng với các layers trong EBI (3-tier pattern).
        • User Interface <-> Boundary : actor có thể là con người hoặc ứng dụng khác sử dụng API.
        • Application Layer <-> Controller: nơi tổng hợp, sử dụng tất cả các loại object khác nhau (Repositories, Domain Services, Entities, Value Objects, ...) nhằm đáp ứng yêu cầu của use-case.
        • Domain Layer <-> Entity: lớp chứa tất cả nghiệp vụ, Domain Services.
        • Infrastructure: cung cấp các kỹ thuật hỗ trợ các lớp trên. Ví dụ: persistence, messaging. Layers in DDD
      • Bounded contexts: Giải pháp phân chia vấn đề lớn thành vấn đề nhỏ hơn, gọi là "bounded contexts".
      • Anti-Corruption layer: cơ bản là một middleware giữa hai hệ thống con nhằm cô lập 2 hệ thống con và 2 hệ thống con đấy giao tiếp với nhau qua layer này. Anti-Corruption Layer Kỹ thuật này hợp lý khi ta cần tạo 1 hệ thống con mới mà không hiểu hoàn toàn các hệ thông con trước đó.
      • Shared Kernel: Trong một số trường hợp bất khả kháng, ta vẫn phải cho phép các components khác nhau, độc lập mà vẫn sử dụng một phần mã code chia sẻ. Phần mã này gọi là shared kernel.
      • Generic Subdomain: là một subdomain biệt lập, có thể dùng ở bất kỳ đâu trong ứng dụng. Ví dụ: trong phần mềm kế toán, sử dụng thư viện bên thứ 3 để visualize số liệu. Như vậy, thư viện này không phải là phần quan trọng, mà có thể sử dụng ở nhiều khâu, nhiều hệ thống con, và có thay thế bởi thư viện khác.

    Giờ ta đề cập những layers khi kết hợp:

    • Application Layer: Layer này chứa application services ( và interfaces của chúng, ví dụ: ORM interfaces, search engines interfaces, ...). Application service chứa những logic để thực thi một use case, một xử lý nghiệp vụ:
      • Dùng repository để tìm ra một hoặc nhiều entities.
      • Ra lệnh cho entities thực hiện một số logic nghiệp vụ.
      • Dùng repository để thực hiện trên persist data với entities, ví dụ: lưu lại thay đổi của dữ liệu.

    Application Layer Application Layer có thể phát ra Application Events khi thực hiện một use case, ví dụ: gửi email, gửi thông báo, hay bắt đầu một use case nằm trong một component khác của ứng dụng.

    • Domain Layer: Đây là tầng trong cùng của nhân ứng dụng, chứa data, logic thuộc về nghiệp vụ. Domain layer độc lập, và không quan tâm với cách thức hoạt động của các tầng bên ngoài.

    Domain Layer Để giải quyết vấn đề tái sử dụng domain logic, ta cần tạo ra domain service nằm bên trong domain layer và không phụ thuộc vào các class ở layer bên ngoài.

    Ở phía trong cùng là Domain Model chứa các đối tượng business objects đại diện cho domain, nghiệp vụ. Ví du: với ứng dụng tự động bán vé, Class Ticket là 1 domain model.

    • Components: Từ đầu đến giờ, ta thiết kết theo hướng layers, tiếp theo ta phân tách chúng thành những khối mang ý nghĩa như một sub-domain và bounded contexts.Có 3 cách thông thường được sử dụng: “Package by feature”, “Package by component” và “Package by layer”.

    Package by feature Package by component Package by layer

    Phổ biến nhất có lẽ là Package by component. Package by Component Component có thể là Billing, User, Account, ... nhưng phải liên quan tới doamin. Bounded contexts như Authorization / Authentication được coi là external tools được tích hợp vào hệ thống thông qua các ports. Component

    • Tách components: Các components được tách ra và không phụ thuộc và quan hệ trực tiếp với nhau. Nói cách khác, không có bất kỳ unit code ( kể cả interfaces) của component này lại được refer trong component khác. Những kỹ thuật cần sử dụng trong việc tách components: Dependecy Injection, Dependency Inversion, Events, Shared Kernel, Eventual consistency, và có thể là Discovery Service.

    • Gọi logic trong component khác:

      Vấn đề: component A cần thực hiện một số công việc tượng tự như đã có trong component B, ta không thể gọi hàm từ A đến B vì khi đó, A sẽ phụ thuộc vào B.

      Giải pháp: để loại bỏ sự phụ thuộc đó, ta cần tạo ra một thư viện với một tập các function cốt lõi rồi chia sẻ cho tất cả các components, gọi là Shared Kernel. Nghĩa là, component A và B đều phụ thuộc vào Shared Kernel, chứ không phụ thuộc lẫn nhau.

      Lưu ý: Shared Kernel cần càng nhỏ càng tốt, vì các thay đổi trong Shared Kernel sẽ ảnh hưởng đến phạm vi toàn hệ thống. Các components có thể được viết bởi nhiều ngôn ngữ khác nhau, khi đó Shared Kernel cần phải sự dụng ngôn ngữ bất khả tri (language agnostic) để tất cả components có thể hiểu được. Ví dụ: JSON, XML,...

      logic trigeer

      Hướng giải quyết này có thể áp dụng cho cả monolithic applications và distributed applications (micro-service ecosystems). Tuy nhiên, khi events chỉ có thể được gửi một cách bất đồng bộ mà trong các component khác cần phải được thực hiện ngay, vậy là cách tiếp cận này sẽ chưa tối ưu. Ví dụ: trong trường hợp component A cần gửi một request HTTP tới component B. Nếu gửi trực tiếp thì A sẽ phụ thuộc vào B. Để tách ra, ta cần tạo ra một Discovery Service để A giao tiếp. Như vậy, Discovery Service đóng vai trò như một proxy với các component. Nhận request từ components và gửi đến component tương ứng rồi đợi và trả kết quả về lại component gửi request. Vậy là components sẽ phụ thuộc vào Discovery Service, chứ không phụ thuộc lẫn nhau.

    • Lấy data từ những components khác:

      Một component không được phép thay đổi data mà nó không sở hữu, nhưng được truy vấn và dùng bất kỳ data nào

      • Data storage được chia sẻ giữa các components: Sẽ có một data storage chứa tất cả data của tất cả component. Khi component A cần sử dụng data của component B thì tạo 1 query lên data storage để lấy dữ liệu đó về nhưng chỉ có quyền đọc (read-only).
      • Data storage được tách trên các components: Mỗi component sẽ "nắm trong tay" một bản copy của data mà nó cần từ những component khác. Khi data thay đổi trong component chủ, thì component chủ sẽ phát ra một sự kiện mang thông tin của sự thay đổi cho các component nắm bản copy để update bản local copy tương ứng.
  • Flow of Control:

    • Không có Command/Query Bus:
      Trong trường hợp này, controllers sẽ phụ thuộc vào Application Service hoặc Query Object.

      Without a command, query bus Query Object chứa những câu optimized query trả về raw data để hiển thị cho user. Data này được trả về trong DTO đã được tiêm vào một ViewModel. ViewModel lại có một vài view logic trong nó và được dùng để hiện thị một View.

      Application Service chứa use case logic nhằm thực hiện một nhiệm vụ trong hệ thống. Application Service phụ thuộc vào Repositories mà chúng trả về Entity. Entity lại chứa những logic nghiệp vụ cần được thực thi. Application Service cũng có thể phụ thuộc Domain Service để xác định cách thức xử lý với nhiều entities.

      Lý do ta đặt interface trên cả persistence engine và repositories là Persistence interface là một lớp trừu tượng cho các ORM vì vậy chúng ta có thể sway các ORM khác nhau mà không ảnh hướng đến nhân ứng dụng. Và Repositories interface là một lớp trừu tượng cho các persistence engine. Nếu ta muốn đổi từ MySQL sang MongoDB, ta chỉ cần thay đổi Query vì query language là hoàn toàn khác nhau.

    • Có Command/Query Bus:

      Trong trường hợp này, controllers lại phụ thuộc vào Command/Query Bus. Nó sẽ tạo command hoặc query và chuyển tới bus để tìm handler tương ứng.

      With Command, Query Bus Ta thấy là không tồn tại sự phụ thuộc giữa Bus với Command (Query, hay Handlers). Bus biết Handler sẽ xử lý Command/Query nào là dựa trên cấu hình có sẵn.

    Như vậy cả 2 trường hợp, sự phụ thuộc đều hướng vào nhân ứng dụng, đều thoải mãn những quy tắc cơ sở của kiến trúc Ports & Adapters và kiến trúc Onion. Dependencies

Kết luận

Bài dịch có thể nghe không xuôi tai, nhưng mình hy vọng đã đem lại cho các bạn có một cái nhìn tổng quát về một số architecture kinh điển để có thể thấy được tầm quan trọng của việc thiết kế.

Quotes:

"Plans are worthless, but planning is everything." - Eisenhower

"The map is not the territory." - Alfred Korzybski

Nguồn: https://herbertograca.com/2017/11/16/explicit-architecture-01-ddd-hexagonal-onion-clean-cqrs-how-i-put-it-all-together/#triggering-logic-in-other-components

Chia sẻ

NỘI DUNG


Series này không có bất kỳ bài đăng nào

BÌNH LUẬN


Avatar Ha Ho @sihalala
thg 6 12, 2022 4:59 SA

Bài này hay , share chủ thớt for sharing

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí