Các nguyên lý thiết kế hướng đối tượng – SOLID – GP Coder (Lập trình Java)

Chào những bạn, trong những bài viết trước tôi đã ra mắt với những bạn 4 đặc thù cơ bản của lập trình hướng đối tượng người dùng trong Java. Đây là những đặc thù rất quan trọng của lập trình hướng đối tượng người dùng ( OOP ) mà hầu hết tất cả chúng ta đã biết, nhưng phương pháp để phối hợp những đặc thù này với nhau để tăng hiệu suất cao của ứng dụng thì không phải ai cũng nắm được. Một trong những nguyên tắc để giúp tất cả chúng ta kiến thiết xây dựng được những ứng dụng OOP hiệu suất cao hơn đó là SOLID, nó là một bộ 5 nguyên tắc đã được nhắc tới từ lâu bởi những nhà tăng trưởng ứng dụng và được tổng hợp, phát biểu thành nguyên tắc bởi Robert C. Martin, cũng chính là tác giả của những cuốn sách nỗi tiếng : Clean Code : A Handbook of Agile Software Craftsmanship, The Clean Coder : A Code of Conduct for Professional Programmers, …

Trong phần tiếp theo của bài viết này tôi sẽ giới thiệu với các bạn các nguyên lý thiết kế hướng đối tượng (Object Oriented Design Principle), đó là S.O.L.I.D. Đây được xem là 5 nguyên lý hàng đầu trong việc thiết kế chương trình ở mức lớp và đối tượng.

solid

Mục tiêu của những nguyên tắc này là tạo ra những sự thay đổi mã code ít ảnh hưởng các phần còn lại. Điều đó có nghĩa là khi sửa hoặc cải tiến code nên gây ra ảnh hưởng đến các phần còn lại càng ít càng tốt. Chúng ta giảm bớt chi phí bảo trì thông qua một thiết kế được thực hiện để thích ứng với thay đổi.

Single responsibility principle ( SRP ) – Nguyên lý đơn công dụng

Nguyên tắc này được phát biểu như sau :

Một class chỉ nên giữ 1 nghĩa vụ và trách nhiệm duy nhất, chỉ hoàn toàn có thể sửa đổi class với 1 nguyên do duy nhất .A class should have one and only one reason to change, meaning that a class should have only one job .

Để hiểu nguyên tắc này, hãy xem ví dụ với 1 class vi phạm nguyên tắc như sau :


package com.gpcoder.solid;

/**
 * SRP – Single responsibility principle example
 * 
 * @author gpcoder
 */
class UserService {
	// Get data from database
	public User getUser() {
		return null;
	}

	// Check validation
	public boolean isValid() {
		return true;
	}

	// Show Notification
	public void showNotification() {

	}

	// Logging
	public void logging() {
		System.out.println("...");
	}

	// Parsing
	public User parseJson(String json) {
		return null;
	}
}

Như bạn thấy, class này triển khai rất nhiều nghĩa vụ và trách nhiệm khác nhau : lấy tài liệu từ DB, validate, thông tin, ghi log, giải quyết và xử lý tài liệu, … Khi chỉ cần ta đổi khác cách lấy tài liệu DB, đổi khác cách validate, … ta sẽ phải sửa đổi class này, càng về sau class sẽ càng phình to ra. Rất khó khăn vất vả khi maintain, tăng cấp, fix bug, test, …Theo đúng nguyên tắc này, ta phải tách class này ra làm nhiều class riêng, mỗi class chỉ làm một trách nhiệm duy nhất. Tuy số lượng class nhiều hơn những việc sửa chữa thay thế sẽ đơn thuần hơn, thuận tiện tái sử dụng hơn, class ngắn hơn nên cũng ít bug hơn .Chẳng hạn, với chương trình trên tất cả chúng ta hoàn toàn có thể tách thành những class : UserRepository, UserValidator, SystemLogger, JsonConverter, … .Một số ví dụ về SRP cần xem xét hoàn toàn có thể cần được tách riêng gồm có : Persistence, Validation, Notification, Error Handling, Logging, Class Instantiation, Formatting, Parsing, Mapping, …

Open-Closed principle ( OCP ) – Nguyên lý đóng mở

Nguyên tắc này được phát biểu như sau :

Có thể tự do lan rộng ra 1 class, nhưng không được sửa đổi bên trong class đó .Objects or entities should be open for extension, but closed for modification .

Nghe qua thấy nguyên tắc có sự xích míc do thường tất cả chúng ta thấy rằng dễ lan rộng ra là phải dễ biến hóa, đằng nay dễ lan rộng ra nhưng không cho biến hóa. Thực sự theo nguyên tắc này, tất cả chúng ta không được biến hóa thực trạng của những lớp có sẵn, nếu muốn thêm tính năng mới, thì hãy lan rộng ra class cũ bằng cách thừa kế để thiết kế xây dựng class mới. Làm như vậy sẽ tránh được những trường hợp làm hỏng tính không thay đổi của chương trình đang có .Lợi ích của nguyên tắc này là tất cả chúng ta không phải lo ngại về code sử dụng những class nguồn do tại tất cả chúng ta đã không sửa đổi chúng, thế cho nên hành vi của chúng phải giống nhau. Tuy nhiên, tất cả chúng ta nên chú ý quan tâm vào ý nghĩa của những công dụng, tránh tạo ra quá nhiều class dẫn xuất. Mặc dù những sửa đổi nhỏ trong class thường không ảnh hưởng tác động, tất cả chúng ta cũng cần phải test cẩn trọng. Và đó là nguyên do chính tại sao tất cả chúng ta cần phải viết test case cho những tính năng, để hoàn toàn có thể nhận thấy hành vi không mong ước xảy ra trong code .Quay lại với ví dụ ở đoạn code ở trên, nếu những thao tác validation để cùng với logic thì tất cả chúng ta hoàn toàn có thể gặp yếu tố sau :

  • Thêm một validation mới chúng ta phải trực tiếp sửa code bằng if-else condition.
  • Sửa code nếu validation bị thay đổi logic.
  • Testing khó khăn, chúng ta phải test cả phần thực hiện logic và validation.

Bây giờ, nếu chúng ta chuyển các thao tác validation sang các lớp khác để xử lý. Cách giải quyết này được gọi là Dependence Injection. Nếu chúng ta muốn thay đổi cách validate khác cho user, chỉ cần thay đổi class validator truyền vào.

Thực hiện theo cách này chúng ta đã hoàn thành nguyên tắc Single responsibility principle (chúng ta đã chuyển trách nhiệm bổ sung sang một class khác). Bây giờ, chúng ta không phải sửa đổi class gốc nếu chúng ta muốn thêm một class khác để validate dữ liệu. Chúng ta chỉ cần tạo một class thích hợp mới và gọi nó là tham số trong trường hợp phù hợp.


package com.gpcoder.solid;

/**
 * OCP - Open/ Closed principle example
 * 
 * @author gpcoder
 */
class UserServiceV2 {
	private Validator validator;

	public UserServiceV2(Validator validator) {
		this.validator = validator;
	}

	public void saveUser() {
		if (this.validator.isValid()) {
			// Do save
		} else {
			// Show error
		}
	}
}

interface Validator {
	boolean isValid();
}

class UserValidator1 implements Validator {
	@Override
	public boolean isValid() {
		return true;
	}
}

class UserValidator2 implements Validator {
	@Override
	public boolean isValid() {
		return false;
	}
}

public class OCPExample {
	public static void main(String[] args) {
		UserServiceV2 userService1 = new UserServiceV2(new UserValidator1());
		UserServiceV2 userService2 = new UserServiceV2(new UserValidator2());
	}
}

Nguyên lý Open-Closed cũng có thể đạt được bằng nhiều cách khác, bao gồm cả việc sử dụng thừa kế (inheritance) hoặc thông qua các mẫu thiết kế tổng hợp (compositional design patterns) như Strategy pattern. Chúng ta sẽ tìm hiểu các mẫu thiết kế này trong các bài viết tiếp theo.

Liskov substitution principle ( LSP ) – Nguyên lý thay thế sửa chữa

Nguyên tắc này được phát biểu như sau :

Trong một chương trình, những object của class con hoàn toàn có thể thay thế sửa chữa class cha mà không làm biến hóa tính đúng đắn của chương trình .Objects in a program should be replaceable with instances of their subtypes without altering the correctness of that program .

Ví dụ class con đổi khác hành vi class cha

Để minh họa điều này, tất cả chúng ta sẽ đi với một ví dụ tầm cỡ tầm cỡ về hình vuông vắn và hình chữ nhật mà mọi người thường dùng để lý giải LSP vì nó rất đơn thuần và dễ hiểu .


package com.gpcoder.solid;

/**
 * LSP - Liskov substitution principle example
 * 
 * @author gpcoder
 */
class Rectangle {
	private int width;
	private int height;

	public int calculateArea() {
		return this.width * this.height;
	}

	public void setWidth(int width) {
		this.width = width;
	}

	public void setHeight(int height) {
		this.height = height;
	}
}

class Square extends Rectangle {

	@Override
	public void setWidth(int width) {
		super.setWidth(width);
		super.setHeight(width);
	}

	@Override
	public void setHeight(int height) {
		super.setWidth(height);
		super.setHeight(height);
	}
}

public class LSPExample1 {
	public void example1() {
		Rectangle rect = new Rectangle();
		rect.setWidth(5);
		rect.setHeight(10);
		System.out.println(rect.calculateArea()); // 50

		Square square = new Square();
		square.setWidth(5);
		square.setHeight(10);
		System.out.println(rect.calculateArea()); // 100
	}
}

Nhìn ví dụ trên ta thấy mọi thống kê giám sát đều rất hài hòa và hợp lý. Do hình vuông vắn có 2 cạnh bằng nhau, mỗi khi set độ dài 1 cạnh thì ta set luôn độ dài của cạnh còn lại .Tuy nhiên, Class Square kế thừa từ class Rectangle nhưng class Square có những hình vi khác và nó đã biến hóa hành vi của của class Rectangle, dẫn đến vi phạm LSP .Như ví dụ trên, do Class Square kế thừa từ class Rectangle nên tất cả chúng ta hoàn toàn có thể sử dụng như sau :


public class LSPExample1 {
	public void example2() {
		Rectangle rect = new Square();
		rect.setWidth(5);
		rect.setHeight(10);
		System.out.println(rect.calculateArea()); // 100
	}
}

Rõ ràng hiệu quả không đúng, diện tích quy hoạnh của hình chữ nhật phải là 5 * 10 = 50 .Theo nguyên tắc này, tất cả chúng ta phải bảo vệ rằng khi một lớp con kế thừa từ một lớp khác, nó sẽ không làm đổi khác hành vi của lớp đó .Trong trường hợp này, để code không vi phạm nguyên tắc LSP, tất cả chúng ta phải tạo 1 class cha là class Shape, sau đó cho Square và Rectangle thừa kế class Shape này .

Ví dụ class con throw exception khi gọi hàm

Một trường hợp khác cũng vi phạm LSP là class con throw exception khi gọi hàm .


package com.gpcoder.solid;

/**
 * LSP - Liskov substitution principle example
 * 
 * @author gpcoder
 */
interface FileService {
	void getFiles();
	void deleteFiles();
}

class ImageFileService implements FileService {

	@Override
	public void getFiles() {
		// Load image files
	}

	@Override
	public void deleteFiles() {
		// Delete image files
	}
}

class TempFileService implements FileService {

	@Override
	public void getFiles() {
		// Load temp files
	}

	@Override
	public void deleteFiles() {
		// Delete temp files
	}
}

Những class implement ở trên không có yếu tố gì, mọi thứ đều chạy tốt. Bây giờ tất cả chúng ta thêm một class SystemFileService mới. Với nhu yếu là không được xóa những file mạng lưới hệ thống, nên lớp này sẽ quăng ra lỗi UnsupportedOperationException. Một phương pháp được phong cách thiết kế nhưng không được sử dụng, đây cũng không phải là một phong cách thiết kế tốt .Khi thực thi phương pháp deleteFiles ( ), class SystemFileService gây lỗi khi chạy. Nó không sửa chữa thay thế được class cha của nó là FileService, vì vậy nó đã vi phạm LSP .


class SystemFileService implements FileService {

	@Override
	public void getFiles() {
		// Load temp files
	}

	@Override
	public void deleteFiles() {
		throw new UnsupportedOperationException();
	}
}

Những vi phạm về nguyên tắc LSP

Một số dấu hiệu điển hình có thể chỉ ra rằng LSP đã bị vi phạm:

  • Các lớp dẫn xuất có các phương thức ghi đè phương thức của lớp cha nhưng với chức năng hoàn toàn khác.
  • Các lớp dẫn xuất có phương thức ghi đè phương thức của lớp cha là một phương thức rỗng.
  • Các phương thức bắt buộc kế thừa từ lớp cha ở lớp dẫn xuất nhưng không được sử dụng.
  • Phát sinh ngoại lệ trong phương thức của lớp dẫn xuất.

Lưu ý

Đây là nguyên tắc … dễ bị vi phạm nhất, nguyên do đa phần là do sự thiếu kinh nghiệm tay nghề khi phong cách thiết kế class. Thuông thường, design những class dựa theo đời thật : hình vuông vắn là hình chữ nhật, file nào cũng là file. Tuy nhiên, không hề bê nguyên văn mối quan hệ này vào code. Hãy nhớ 1 điều :

  • Trong thực tế, A là B (hình vuông là hình chữ nhật) không có nghĩa là class A nên kế thừa class B. Chỉ cho class A kế thừa class B khi class A thay thế được cho class B.
  • File hệ thống cũng là file nhưng không thay thế được cho file, do đó ví dụ này vi phạm LSP.

Nguyên lý này ẩn giấu trong hầu hết mọi đoạn code, giúp cho code linh động và không thay đổi mà ta không hề hay biết. Ví dụ như trong Java, ta hoàn toàn có thể chạy hàm foreach với List, ArrayList, LinkedList chính do chúng cùng thừa kế interface Iterable. Các class List, ArrayList, … đã được phong cách thiết kế đúng LSP, chúng hoàn toàn có thể thay thế sửa chữa cho Iterable mà không làm hỏng tính đúng đắn của chương trình .

Interface segregation principle ( ISP ) – Nguyên lý phân tách

Nguyên tắc này được phát biểu như sau :

Thay vì dùng 1 interface lớn, ta nên tách thành nhiều interface nhỏ, với nhiều mục tiêu đơn cử .Many client-specific interfaces are better than one general-purpose interface .

Nguyên lý này khá dễ hiểu. Hãy tưởng tượng tất cả chúng ta có 1 interface lớn, khoảng chừng 100 methods. Việc implements sẽ khá cực khổ, ngoài những còn hoàn toàn có thể dư thừa vì 1 class không cần dùng hết 100 method. Khi tách interface ra thành nhiều interface nhỏ, gồm những method tương quan tới nhau, việc implement và quản trị sẽ dễ hơn .Ví dụ :


package com.gpcoder.solid;

/**
 * ISP – Interface segregation principle example
 * 
 * @author gpcoder
 */
interface Repository {

	Iterable findAll();

	T findOne(Long id);

	T save(T entity);

	void update(T entity);

	void delete(T entity);

	Page findAll(Pageable pageable);

	Iterable findAll(Sort sort);
}

Như bạn thấy interface Repository, class này bao gồm các rất nhiều phương thức: lấy danh sách, lấy theo id, insert, update, delete, lấy danh sách có phân trang, sắp xếp, … Việc implement tất cả các phương thức này hết sức cực khổ, đôi khi không cần thiết do chúng ta không sử dụng hết.

Thay vào đó tất cả chúng ta hoàn toàn có thể rách nát ra như sau :


interface CrudRepository {

	Iterable findAll();

	T findOne(Long id);

	T save(T entity);

	void update(T entity);

	void delete(T entity);

}

interface PagingAndSortingRepository extends CrudRepository {

	Page findAll(Pageable pageable);

	Iterable findAll(Sort sort);
}

Đối với một số chức năng đặc biệt chúng ta mới cần implement từ interface PagingAndSortingRepository, với chức năng thông thường chỉ cần CrudRepository là đủ.

Một vi phạm khác cũng thường gặp trong dự án là class CommonUtil. Lớp này bao gồm rất nhiều phương thức: xử lý ngày giờ, số, chuỗi, chuyển đổi định dạng JSON, … tất cả mọi thức liên quan đến xử lý common đều được đặt vào đây. Càng ngày số lượng các phương thức càng lớn, khi đó sẽ phát sinh rất nhiều vấn đề như: trùng code, nhiều phương thức giống nhau không biết sử dụng phương thức nào, khi phát sinh bug rất khó bảo trì. Đối với những trường hợp này chúng ta nên chia nhỏ theo chức năng của nó, ví dụ như: DateTimeUtil, StringUtil, NumberUtil, JsonUtil, ReflectionUtil, … như vậy sẽ dễ dàng quản lí và sử dụng hơn.

Dependency Inversion Principle (DIP) – Nguyên lý đảo ngược phụ thuộc

Nguyên tắc này được phát biểu như sau :

  1. Các module cấp cao không nên phụ thuộc vào các modules cấp thấp. Cả 2 nên phụ thuộc vào abstraction.
  2. Interface (abstraction) không nên phụ thuộc vào chi tiết, mà ngược lại. ( Các class giao tiếp với nhau thông qua interface, không phải thông qua implementation.)
  1. High-level modules should not depend on low-level modules. Both should depend on abstractions.
  2. Abstractions should not depend upon details. Details should depend upon abstractions.

Với cách code thông thường, các module cấp cao sẽ gọi các module cấp thấp. Module cấp cao sẽ phụ thuộc và module cấp thấp, điều đó tạo ra các dependency. Khi module cấp thấp thay đổi, module cấp cao phải thay đổi theo. Một thay đổi sẽ kéo theo hàng loạt thay đổi, giảm khả năng bảo trì của code.

Nếu tuân theo Dependendy Inversion principle, các module sẽ cùng phụ thuộc vào một interface không đổi. Nghĩa là thay vì để các module cấp cao sử dụng các interface do các module cấp thấp định nghĩa và thực thi, thì nguyên lý này chỉ ra rằng các lớp module cấp cao sẽ định nghĩa ra các interface, sau đó các lớp module cấp sẽ thực thi các interface đó. Khi đó, ta có thể dễ dàng thay thế, sửa đổi module cấp thấp mà không ảnh hưởng gì tới module cấp cao.

Ví dụ :


package com.gpcoder.solid;

/**
 * DIP – Dependency inversion principle example
 * 
 * @author gpcoder
 */
interface DBConnection {
	void connect();
}

class OracleConnection implements DBConnection {
	@Override
	public void connect() {
		System.out.println("Oracle connected");
	}
}

class MySQLConnection implements DBConnection {
	@Override
	public void connect() {
		System.out.println("MySQL connected");
	}
}

class PostgreSQLConnection implements DBConnection {
	@Override
	public void connect() {
		System.out.println("PostgreSQL connected");
	}
}

class DatabaseConfig {
	private DBConnection dbConnection;

	public DatabaseConfig(DBConnection dbConnection) {
		this.dbConnection = dbConnection;
		this.dbConnection.connect();
	}

	public DBConnection getConnection() {
		return this.dbConnection;
	}
}

public class DIPExample {

	public static void main(String[] args) {
		DBConnection conn = new OracleConnection();
		DatabaseConfig config = new DatabaseConfig(conn);
	}
}

Như bạn thấy, những module của tất cả chúng ta không nhờ vào vào nhau. Việc biến hóa code của một module này không tác động ảnh hưởng đến những module còn lại. Nếu muốn tương hỗ thêm SQLServer chỉ việc tạo thêm class mới, implement từ DbConnection. Nếu muốn đổi liên kết sang Oracle chỉ việc đổi khác trong config, …

Lời kết

Khi phát triển bất kỳ phần mềm nào, có hai khái niệm rất quan trọng cần nắm: sự gắn kết (cohesion – khi các phần khác nhau của một hệ thống sẽ làm việc cùng nhau để có kết quả tốt hơn nếu mỗi phần sẽ hoạt động riêng lẻ) và ghép nối (coupling – có thể được xem là mức độ phụ thuộc của một lớp, phương thức hoặc bất kỳ thực thể phần mềm nào khác).

Có rất nhiều nguyên tắc trong phong cách thiết kế ứng dụng và trên đây là 5 nguyên tắc phong cách thiết kế hướng đối tượng người dùng ( SOLID ) quan trọng nhất mà bất kể lập trình viên nào cũng phải nắm vững nếu như muốn cải tổ kĩ năng code của mình. Việc nắm vững và vận dụng được những nguyên tắc trong phong cách thiết kế sẽ giúp cho mã nguồn chương trình của tất cả chúng ta nhìn rõ ràng hơn, tận dụng được những ưu điểm của OOP, những thành phần không bị phụ thuộc vào quá nhiều vào nhau, để thuận tiện cho việc bảo dưỡng và lan rộng ra sau này .

Tuy nói nhiều lợi ích như vậy, nhưng các nguyên lý này chỉ mới chỉ ra cho chúng ta biết: thiết kế nào là đúng, thiết kế nào là sai chứ chưa giúp chúng ta giải quyết được vấn đề. Trong khi làm thực tế, gặp phải những vấn đề cụ thể như: làm thế nào để giảm thiểu số lượng các đối tượng phải tạo ra trong chương trình, làm thế nào để tích hợp một module có sẵn vào trong hệ thống của mình, … Tất cả những chuyện như vậy sẽ được giải quyết bằng cách áp dụng các “Mẫu thiết kế” (Design Pattern). Nói đơn giản hơn, các mẫu thiết kế sẽ giúp chúng ta giải quyết những bài toán thường gặp trong những ngữ cảnh nhất định. Các mẫu thiết kế cũng tuân theo các nguyên lý thiết kế hướng đối tượng làm cơ sở. Vì vậy việc nắm vững các nguyên lý này là điều cần thiết nếu các bạn muốn tiến sâu hơn trong việc tạo ra 1 sản phẩm có kiến trúc đẹp, chất lượng.

Tài liệu tham khảo:

4.8

Nếu bạn thấy hay thì hãy chia sẻ bài viết cho mọi người nhé!

Shares

Bình luận

phản hồi