logoMột hệ thống khó hiểu thì cũng khó thay đổi
SRP - "S" trong SOLID

Trong bộ nguyên lý thiết kế hướng đối tượng cơ bản, Single Responsibility Principle (SRP) là một trong những nguyên lý được nhắc tới nhiều nhất bên cạnh Open/Closed Principle (OCP).

Nếu đã từng hoặc đang gặp phải những vấn đề sau thì bạn nên đọc kỹ lại nguyên lý này (trong bài viết này) để xem có từng bước giải quyết được hay không:

  1. Khi cập nhật logic cho một class, bạn lo lắng không biết thay đổi này ngoài việc cho chức năng mình đang làm chủ thì còn có ảnh hưởng tới chức năng nào khác trong ứng dụng hay không?
  2. Mỗi lần xây dựng một phiên bản build mới (xem REP), bạn cùng các đồng nghiệp phải giải quyết nhiều mâu thuẫn cả trong và ngoài máy tính. Sau cả ngày trời, hoặc tệ hơn, mất thêm môt vài ngày chỉnh sửa, bóc tách code, cả team mới có thể phát hành một phiên bản thống nhất.

Hãy cùng xem SRP giúp ta những gì để giải quyết những thứ phiền toái, làm công việc của ta luôn căng thẳng như vậy.

SRP là nguyên lý được Bob Martin đưa ra trong bộ 5 nguyên lý SOLID mà chúng ta ai cũng biết. Tôi khá đắn đo cách tiếp cận khi viết bài phân tích này. Cuối cùng, tôi quyết định sẽ bám vào chính bài viết của ông để phân tích làm rõ hơn, giúp các bạn nắm chắc hơn nguyên lý quan trọng nhưng hay bị hiểu lầm này (hi vọng thế).

Phát biểu của SRP

Trích từ wikipedia:

"There should never be more than one reason for a class to change." In other words, every class should have only one responsibility.

Bob Martin cho rằng đây là nguyên lý dễ bị gây hiểu lầm nhất do mọi người hay nói với nhau rằng: Một class chỉ nên làm một việc! Tuyên bố này là cho method chứ không phải cho class.

Từ trích dẫn wikipedia, ta thấy:

Một class nên có một và chỉ một lý do thay đổi.

Tới đây có vẻ đúng như mục tiêu của nguyên lý đề cập và chúng ta đều nắm vững. Nhưng cùng phân tích sâu hơn một chút nữa, theo Bob Martin.

Một dự án phần mềm được sinh ra có thể bởi một cá nhân hay tổ chức nhận thấy nhu cầu nào đó trong xã hội, đó có thể là dịch vụ đặt hàng trực tuyến, dịch vụ cho vay, gửi tiền tại ngân hàng,… Họ đầu tư hoặc kêu gọi đầu tư. Họ có thể mời các bên chuyên sâu về nghiệp vụ domain hoặc chính họ cũng đưa ra những yêu cầu chức năng/phi chức năng cho phần mềm mục tiêu. Phần mềm này sẽ phục vụ cho những người sử dụng cuối (end user) như người mua hàng, người bán hàng, nhân viên ngân hàng, khách hàng của ngân hàng,… Những người dùng này cũng có thể đóng góp vào hoàn thiện chức năng của phần mềm họ sử dụng (có thể thông qua report, nghiên cứu trải nghiệm khách hàng,…). Phần mềm cũng phải đáp ứng các yêu cầu chung về tính bảo mật, các quy định của nhà nước như chính sách bảo vệ thông tin cá nhân (PII, GDPR,…). Những nhóm người này, mỗi nhóm phụ trách một nhóm hoặc một vài nhóm chức năng của phần mềm. Họ sẽ là người “đưa ra” yêu cầu cho phần mềm đó (từ “đưa ra” tôi để trong nháy kép hàm ý việc đưa ra có thể là chủ động hoặc thụ động). Họ là những “actor – tác nhân” của phần mềm.

Như vậy, ta thấy rằng lý do thay đổi tác động tới phần mềm nhưng cụ thể những lý do đó tới từ các tác nhân khác nhau. Mỗi tác nhân sẽ có những lý do thay đổi đặc thù của họ, đại diện cho một tập hợp người dùng, bên liên quan của phần mềm đó. Do đó, nguyên lý này được phát biểu chính xác là:

Một class nên có trách nhiệm cho một và chỉ một tác nhân.

Trải nghiệm

Để nắm rõ được lý thuyết và qua đó áp dụng được vào tình huống thực tế phát sinh, ta cần trải nghiệm nó. Nếu là trải nghiệm thực tế thì sẽ đau thương, còn trải nghiệm ở mức “lý thuyết” sẽ giúp củng cố hiểu biết với mức độ nào đó và giúp ta tránh được những cạm bẫy sau này.

Bài toán

Giả sử, có một ứng dụng quản lý lương, trong đó có một số yêu cầu (trong số nhiều nghiệp vụ khác) như sau:

  • Tính toán lương theo tháng hay một khoảng ngày bất kỳ. Đây là yêu cầu từ phòng kế toán, họ cần dữ liệu để báo cáo lên CFO.
  • Tính toán dữ liệu cho báo cáo giờ công của nhân viên, cũng theo tháng hoặc một khoảng ngày. Yêu cầu phục vụ cho phòng nhân sự nhằm gửi báo cáo lên COO.

Cùng mô hình hóa bài toán này.

Thiết kế

Tập trung vào nghiệp vụ lõi, ta có thể thấy ngay, nhân viên sẽ là trung tâm của bài toán này. Do đó, ta cần một thực thể xuất phát: Employee. Từ hai yêu cầu trên, tính toán lương và thời gian làm việc, ta có hai hành vi cho thực thể:

  • calculatePay: Tính toán lương.
  • reportHours: Tính toán thời gian làm việc.

Do đây là hai phương thức dạng truy vấn tính toán mà, giả sử, không cập nhật bất cứ dữ liệu nào, ta sẽ để return type là Pay và Report tương ứng. Input param là một khoảng thời gian giữa các ngày bất kỳ, Period.

Ngoài ra, ta muốn áp dụng Active Record pattern, do nghiệp vụ không quá phức tạp, nên ta bổ sung thêm hành vi giao tiếp với database là save – cập nhật dữ liệu vào database. Tuy nhiên, trong phạm vi bài viết này, pattern này sẽ không được đề cập, và do đó, ta tạm thời bỏ qua phương thức save().

Kết quả là class Employee như sau:

Hình 1: Thiết kế class Employee

Tại sao calculatePay() và reportHours() lại được gắn vào Employee?

Bởi khi thiết kế class Employee, mục đích của ta là để class này lưu giữ các thông tin cần thiết của nhân viên, các thông tin này là các thuộc tính của class. Những thông tin này là nguyên liệu chủ yếu để tính toán ra các dữ liệu mà ta mong muốn: lương (Pay), và thời gian làm việc (Report).

Nếu các method calculatePay() và reportHours() được đặt ở nơi khác, ví dụ thường gặp là một service class như EmployeeService, thì Employee phải phơi bày rất nhiều thuộc tính ra bên ngoài qua các getters khiến cho nó trở thành một data class thuần túy. Đây là điều mà chúng ta, những người đã đọc một số bài viết về Transaction Script, Layered Architecture muốn tránh.

Một điều rất cơ bản về Object-Oriented (OO) mà chúng ta thường quên: Đối tượng có những thuộc tính của riêng chúng, nếu muốn thay đổi các thuộc tính này, ta cần phải thay đổi qua hành vi của nó. Đáng tiếc khi các “hành vi” này tường là các setters – là những thứ không mấy khi mang ý nghĩa nghiệp vụ.

Ngoài ra, nếu một class khi xây dựng nghiệp vụ của chính nó lại dựa trên hành vi và dữ liệu của những class khác thì thiết kế cho thấy một sự “ghen tị chức năng” mà trong thiết kế gọi là Feature Envy – chủ đề này tôi sẽ có bài riêng. Transaction Script kết hợp với data class chính là thể hiện Feature Envy: Service class sử dụng rất nhiều getters, setters của data class để tính toán nghiệp vụ của nó.

Vì những lý do trên, ta thấy rất tự nhiên khi gắn hai method đó vào đối tượng Employee. Tính đóng gói của nghiệp vụ tăng lên.

Vấn đề

Xem lại hai vấn đề mà tôi đã đề cập từ đầu bài viết này rồi phân tích tình huống lần lượt nhé.

Lo sợ cập nhật của mình ảnh hưởng tới chức năng khác

Dùng từ “lo sợ” cũng hơi quá vì khi cập nhật một class, ta có thể xem user của nó là những class nào khác để có đánh giá ảnh hưởng. Nhưng dù thế nào, nếu user của nó là quá nhiều thì công sức của ta để đánh giá ảnh hưởng là rất đáng kể, thậm chí kéo theo nhiều nguồn lực chạy lại các bài kiểm thử của các chức năng đó, dù không liên quan khi nhận yêu cầu ban đầu. Điều này gây một số phiền toái khi nhóm bạn nhận một yêu cầu “nhỏ” nhưng sau đó phản hồi lại một plan lớn khiến cho người đưa ra yêu cầu – tác nhân – khó hiểu và thắc mắc. Bạn cùng cả nhóm sẽ phải trình bày, thậm chí đấu tranh để bảo vệ những “điều đúng đắn” đó.

Điều đúng đắn xuất phát từ đây: regularHours() – method phụ trách tính toán thời gian làm việc quy định, tức không tính overtime. Do cả hai phương thức calculatePay() và reportHours() đều cần thời gian này để tính toán nên hàm regularHours() được sinh ra và sử dụng chung. Điều này, trông rất tự nhiên vì ta đang gom một nghiệp vụ (tính toán thời gian theo quy định) vào một method và dùng nó khi cần. Rất ổn.

Hình 2: Hàm chung regularHours() được sử dụng bởi hai hàm hướng tác nhân

Ứng dụng được triển khai version 1.0 và mọi thứ là perfect.

Một thời gian trôi qua, CFO quyết định rằng cách tính lương cần thay đổi, mà cụ thể là cơ chế tính toán thời gian làm việc.

Lúc này, nhiều thứ đã thay đổi, ứng dụng đã phình to với nhiều source code chồng chéo, đội ngũ lập trình cũng thay đổi. Những lý lẽ này tôi đưa ra là để thể hiện một điều: lập trình viên được giao task này sau một hồi truy vết thấy rằng cần thay đổi phương thức regularHours() nhưng không biết rằng nó ngoài được gọi bởi calculatePay() còn được gọi bởi reportHours(). Tình huống này khá dễ phát hiện nhưng trong thực tế quá trình phụ thuộc sẽ rất sâu, trải qua nhiều lời gọi trung gian, hoặc nhiều nghiệp vụ khác nhau phụ thuộc vào phương thức này.

Anh lập trình viên thay đổi source code theo đúng yêu cầu và kiểm thử một cách cẩn thận. Quá trình UAT thành công, ứng dụng được triển khai.

Chỉ sau vài ngày, khi phòng ban nhân sự chạy báo cáo để gửi lên COO của họ, họ thấy dữ liệu có vấn đề. COO rất tức giận bởi dữ liệu sai đã gây ra thất thoát hàng chục tỷ đồng của công ty.

Sau sự cố này, đội phát triển của công ty đó, khi cập nhật một chức năng luôn hoặc lo sợ hoặc mất rất nhiều thời gian để cẩn thận đánh giá và kiểm thử. Hiệu suất giảm mà niềm vui trong công việc cũng dần mất đi.

Đây là sự cố do “trùng lặp tình cờ” giữa các nhóm chức năng thuộc về những tác nhân khác nhau. Mỗi tác nhân có một nhóm nghiệp vụ nhất định nhưng có một vùng xám nhất định giữa họ, và ở đó, sự vi phạm SRP xuất hiện.

Cùng đi tới một dấu hiệu khác.

Merge code trở nên khó khăn

Một trong những mục tiêu chính của kiến trúc phần mềm là giúp cho quá trình bảo trì hệ thống (ý tôi là cập nhật source code theo nghiệp vụ) được dễ dàng hơn. Nếu như bạn hoặc tôi có thấy một đội dự án thực hiện merge code của thành viên một cách suôn sẻ (nghĩa là nhanh gọn, mất ít thời gian) thì có khả năng cao kiến trúc của phần mềm đó là tốt. Sẽ không phải là vấn đề nếu một source base được phụ trách chỉ bởi một lập trình viên. Anh ấy sẽ làm lần lượt các task, cập nhật lần lượt vào source code của mình và chỉ cần merge code lên nhánh testing/master, sẵn sàng đưa vào các môi trường khác nhau. Nhưng khách hàng không thể chờ anh ấy, thông thường sẽ là một team bao gồm một số lập trình viên cùng cập nhật trên một source base. Mặc dù, ngày nay có những công cụ quản lý phiên bản rất mạnh mẽ nhưng những xung đột (conflict) code vẫn xảy ra, nhất là ở những dự án có lượng source code lớn.

Trong một agile team, thông thường, họ sẽ phát triển các phiên bản theo từng sprint kéo dài khoảng, ví dụ là 1 tuần. Một tuần làm việc từ thứ hai tới thứ sáu.

Dự án đưa ra quy định mọi người nhận task vào thứ hai, tự do code và tới đầu giờ chiều thứ sáu sẽ merge code với nhau để dựng bản build thống nhất (xem thêm REP).

Ban đầu, quy trình này đáp ứng được tiến độ nhưng theo thời gian, source code nhiều lên, thành viên nhiều hơn và những xung đột trong source code xuất hiện nhiều hơn. Việc cố gắng tạo ra một bản build ngày càng trở nên khó khăn do code của mọi người chồng chéo lên nhau ngày càng rắc rối. PM quyết định quá trình merge code này cần diễn ra từ sáng thứ sáu, tức là mọi người phải hoàn thành cập nhật của mình vào cuối ngày thứ năm. Nhưng rồi, bạn biết đó, mọi thứ vẫn dần phình to ra và vượt quá tầm kiểm soát. Đội dự án dần phải OT vào ngày thứ sáu để có một bản build ưng ý. PM thấy rằng, nếu để merge code vào ngày thứ năm thì không ổn vì thời gian phát triển sẽ bị ngắn quá mức. Anh ấy đấu tranh, đưa ra bằng chứng và rồi được đồng ý tạo sprint kéo dài hai tuần. Thời gian merge code sẽ là ngày thứ năm và thứ 6 của tuần thứ hai. Khả năng duy trì quá trình này trong tương lai cũng là thấp. Thực ra, công ty sở hữu phần mềm này vẫn nên thấy vui vì yêu cầu mới được cập nhật đều cho thấy họ đang kinh doanh khá tốt.

Nếu một dự án rất vất vả để merge code các thành viên nhằm build một phiên bản phần mềm, đó là một trong những dấu hiệu vi phạm SRP.

Giải pháp

Khi vi phạm SRP được xác định rõ, ta cần phải refactor thiết kế nhằm đáp ứng được nguyên lý.

Trong ví dụ này, method regularHours() cần phải được di chuyển sang cho riêng bộ phận nhân sự, hay nói cách khác, nó nên được dùng bởi riêng hàm reportHours().

Hình 3: Phân tách hàm bằng cách viết ra một hàm mới ngay khi vùng nghiệp vụ chung trở nên khác biệt

Lúc này CFO, vốn là actor của calculatePay(), sẽ sử dụng cơ chế tính giờ khác, là totalHours(). Còn COO, là actor của reportHours(), thì vẫn ở lại với cơ chế tính giờ cũ.

Tuy nhiên, đây chỉ là giải pháp tình thế. Ta cần một cách phân tách mạnh mẽ hơn.

Áp dụng ISP – Interface Segregation Principle

Nguyên lý ISP (xem thêm ở đây) cho rằng ta không nên ép user (những người sử dụng interface hoặc class mà ta phát hành) phụ thuộc vào những thứ mà họ không cần. Trong ví dụ này, CFO chỉ cần calculatePay() còn COO chỉ cần reportHours(), tại sao ta lại ép CFO phụ thuộc vào reportHours() và tương tự là COO phụ thuộc vào calculatePay()? Đó là vì, ta đang vi phạm SRP và cả ISP.

Giải pháp rất cụ thể, phân tách giao diện ra thành những phần nhỏ hơn, mỗi phần đáp ứng yêu cầu cho từng tác nhân.

Hình 4: Phân tách một class phục vụ nhiều tác nhân thành các class riêng biệt cho từng tác nhân

Theo cách này, Employee class trở thành một data class thuần túy vì nó chỉ chứa data mà không có hành vi nghiệp vụ. Hai hành vi nghiệp vụ đã đẩy ra hai class phụ trách: PayCalculator và HourReporter.

Lựa chọn này đã đáp ứng được SRP và ISP giúp cho việc bảo trì nghiệp vụ cho hai tác nhân được tự do hơn. Tuy nhiên, trông có vẻ hơi rời rạc vì giờ ta cần quan tâm tới hai class thay vì một class như cũ.

ISP + Façade

Đây là một khắc phục của cách trước, ta tạo ra một giao diện mỏng, façade (đọc là /fəˈsɑːd/), phía trước của hai class trên để user vẫn thấy một giao diện sử dụng không thay đổi:

Hình 5: Dựng thêm lớp façade để giúp user dễ sử dụng

Façade làm đúng với vai trò của nó: không chứa nghiệp vụ phức tạp, chỉ đóng vai trò sắp xếp các thành phần bên dưới nó (vốn phức tạp, gây khó khăn khi sử dụng cho user) ngăn nắp hơn, móc nối các thành phần lại với nhau và chìa ra một giao diện tinh gọn giúp user dễ sử dụng hơn. EmployeeFacade của ta là tương đối đơn giản:

@RequiredArgsConstructor
public class EmployeeFacade {
    private final PayCalculator payCalculator;
    private final HourReporter hourReporter;
    
    public Pay calculatePay(Period period) {
        return payCalculator.calculatePay(period);
    }
    
    public Report reportHours(Period period) {
        return hourReporter.reportHours(period);
    }
}

Hướng đối tượng hơn

Giải pháp trên vẫn khó chấp nhận với những người ưa thích hướng đối tượng. Đây có thể là một lựa chọn: ta giữ nguyên giao diện Employee như hình 1 nhưng trong đó có thay đổi như sau:

  • Giữ nguyên cách tính toán của calculatePay() vì ta nhận thấy đây là hành vi quan trọng nhất của class này.
  • Đối với reportHours(), ủy quyền thực thi tới HourReporter class bên trên.
Hình 6: Hướng đối tượng hơn

Code như sau:

public class Employee {
    private String name;
    private float payRate;
    ...
    public Pay calculatePay(Period period) {
        // Keep original code
        ...
    }
    
    public Report reportHours(Period period) {
        HourReporter hourReporter = new HourReporter();
        return hourReporter.reportHours(period);
    }
    ...
}

Chỉ có một vấn đề nhỏ trong code trên: Khởi tạo HourReporter bên trong method reportHours(). Điều này làm giảm đi lợi ích mà Open/Closed Principle (OCP) cũng như  DIP mang tới nếu như việc sinh ra báo cáo giờ công có nhiều cách khác nhau.

Áp dụng DIP để khắc phục, thông thường ta không nên khai báo HourReporter interface (chuyển thành interface) là thuộc tính của Employee rồi inject một implementation cho nó. Đây là một Domain Entity mà. Cách thường thấy trong trường hợp này là đưa HourReporter interface vào giao diện của method reportHours():

...
public Report reportHours(Period period, HourReporter hourReporter) {
    return hourReporter.reportHours(period);
}
...

Với cách làm này, ta có thể chủ động gọi phương thức reportHours() với thuật toán mong muốn.

Kết luận

Như vậy, bài viết này đã đi qua nguyên lý SRP, chữ “S” trong SOLID bằng cách phân tích các dấu hiệu nhận biết một phần mềm đang vi phạm và cùng phân tích chi tiết trên chính ví dụ mà Bob Martin đưa ra.

Nguyên lý SRP được phát biểu cho class và nếu bạn đã biết, còn một nguyên lý ở cấp cao hơn nó, cấp module, đó là CCP. Các nguyên lý này không phải chỉ là lý thuyết suông, bạn đã thấy thông qua nhiều ví dụ thực tế, chúng ta đều áp dụng tập hợp các nguyên lý thành phần (CCP, REP, CRP) và SOLID mang tới kết quả là source code trở nên trong sáng, dễ phát triển và bảo trì hơn rất nhiều. Hãy áp dụng mọi lúc trong công việc lập trình của bạn, kỹ năng phân tích, thiết kế và coding của bạn sẽ tăng lên theo thời gian.

 

 

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