Áp dụng các nguyên lý solid trong thiết kế và lập trình hướng đối tượng

Ngày 26 tháng 9 năm 2017 | 721 views

CodeR - Cứng

Có lẽ mọi sinh viên IT và lập trình viên đều không thể không biết đến khái niệm lập trình hướng đối tượng. Ngay trong những năm đầu tiên của thời sinh viên, chúng ta đã được học về OOP. Và các câu hỏi về OOP xuất hiện trong mọi cuộc phỏng vấn đối với lập trình viên. Luôn luôn là như vậy. Trong quá trình học, các bạn sinh viên đều được học một số khái niệm OOP cơ bản đó là: Abstraction, Encapsulation, Inheritance và Polymophirsm. Nhưng đối với OOP, đa phần các bạn sẽ dừng lại ở mức: define class và khởi tạo object mà ít khi sử dụng đến các concept khác như abstract class, interface, inheritance…

Trong môi trường làm việc thực tế, chúng ta sẽ nhận thấy rằng sản phẩm chúng ta làm ra luôn luôn có sự thay đổi và mở rộng chức năng theo thời gian. Không có phần mềm nào có thể đứng vững theo thời gian mà không thay đổi. Và chúng ta phải luôn đáp ứng được sự thay đổi đó. Chính vì lẽ đó nên trong quy trình phát triển phần mềm, khâu phân tích và thiết kế là cực kỳ quan trọng. Người thiết kế phải làm thế nào để kiến trúc phần mềm có thể dễ dàng đáp ứng với thay đổi nhất. Và để làm được điều đó thì cần phải có kiến thức rất sâu rộng trong hướng đối tượng, vận dụng linh hoạt các đặc trưng của OOP. Để thiết kế một phần mềm có độ linh hoạt cao thì cần phải áp dụng thuần thục các kiến thức về Design pattern các nguyên tắc trong thiết kế và lập trình. Và SOLID là một tập hợp trong các nguyên tắc đó.

Đây là những nguyên lý được đúc kết bởi máu xương vô số developer, rút ra từ hàng ngàn dự án thành công và thất bại. Một project áp dụng những nguyên lý này sẽ có code dễ đọc, dễ test, rõ ràng hơn. Và việc quan trọng nhất là việc maintenance code sẽ dễ hơn rất nhiều (Ai có kinh nghiệm trong ngành IT đều biết thời gian code chỉ chiếm 20-40%, còn lại là thời gian để maintainance: thêm bớt chức năng và sửa lỗi).  Nắm vững những nguyên lý này, đồng thời áp dụng chúng trong việc thiết kế + viết code sẽ giúp bạn tiến thêm 1 bước trên con đường trở thành kiến trúc sư trưởng của các hệ thống phần mềm. 

SOLID nghĩa là "cứng", áp dụng nguyên lý này nhiều thì bạn sẽ trở thành một tay code "cứng". Nói vui là vậy nhưng cũng không hẳn là sai. "SOLID" là tập hợp 5 nguyên tắc sau:

  1. Single responsibility principle
  2. Open/closed principle
  3. Liskov substitution principle
  4. Interface segregation principle
  5. Dependency inversion principle

Trong nội dung bài viết, mình sẽ giới thiệu sơ lược để các bạn có cái nhìn tổng quan về các nguyên lý này.


1. Single responsibility principle

Nguyên lý đầu tiên, tương ứng với chữ S. Có nội dung như sau:

Một class chỉ nên giữ 1 trách nhiệm duy nhất (Nghĩa là chỉ có thể sửa đổi class đó với 1 lý do duy nhất).

Để hiểu nguyên lý này, ta hãy lấy ví dụ với 1 class vi phạm nguyên lý. Ta có 1 class như sau

class Customer {
   public void Add() {
      try {
         // Database code goes here
      } catch (Exception ex) {
         System.IO.File.WriteAllText(@"C:\log.txt", ex.ToString());
      }
   }
}

Class Customer ngoài việc thực hiện các xử lý đến đối tượng Customer còn thực hiện cả việc ghi log nữa. Ghi log thì tất nhiên là rất quan trọng rồi. Nhưng việc thực hiện nó như trên thì không tốt. Rõ ràng class Customer chỉ nên làm những việc như là validations dữ liệu, xử lý logic liên quan tới các dữ liệu của Customer thôi. Điều này sẽ gây ra khó khăn khi chúng ta muốn thay đổi việc ghi log này. Mỗi lần muốn thay đổi cách thức ghi log file chúng ta lại vào class Customer để sửa. Không tốt chút nào phải không. Và để đảm bảo nguyên lý này thì chúng ta sẽ chuyển đoạn code ghi log sang một class khác, class đó chỉ làm việc với Log mà thôi.

class FileLogger {
   public void Handle(string message) {
      System.IO.File.WriteAllText(@"c:\log.txt", message);
   }
}
class Customer {
   private FileLogger logger = new FileLogger();
   publicvirtual void Add() {
      try {
         // Database code goes here
      } catch (Exception ex) {
         logger.Handle(ex.ToString());
      }
   }
}

Mọi thứ đã trở nên rõ ràng hơn trước. Class Customer chỉ làm việc với đối tượng Customer và class FileLogger sẽ chuyên tâm làm việc với nhiệm vụ ghi log. Ở đây có thể dễ dàng thấy được lợi ích của việc tách 2 class này ra.

Lưu ý:

  • Về bản chất nguyên lý này chỉ là hướng dẫn không phải là nguyên tắc tuyệt đối.

Có những trường hợp như các class Helper xét cho cùng toàn bộ các hàm trong class này đều thực hiện những tác vụ nhỏ nên nếu số lượng hàm ít vẫn có thể cho các hàm này vào cùng 1 class.

Tuy nhiên khi số lượng hàm tăng lên quá nhiều thì nên cân nhắc sử dụng nguyên lý này để chia nhỏ module thuận tiện cho việc quản lý.

  • Việc hiểu và áp dụng nguyên này giúp cho việc viết code dễ đọc, dễ hiểu, dễ quản lý hơn.

Tuy nhiên nguyên lý đơn nhiệm là nguyên lý đơn giản nhưng khó áp dụng đúng, việc xác định khi nào cần áp dụng khi nào không còn phụ thuộc vào việc người code xác định được đúng chức năng của module đang làm.


2. Open/closed principle

Nguyên lý thứ hai, tương ứng với chữ O trong SOLID. Có nội dung như sau:

Có thể thoái mái mở rộng 1 class, nhưng không được sửa đổi
bên trong class đó (open for extension but closed for modification).

Theo nguyên lý này, mỗi khi ta muốn thêm chức năng cho chương trình, chúng ta nên viết class mới mở rộng class cũ (bằng cách kế thừa hoặc sở hữu class cũ) chứ không nên sửa đổi class cũ. Việc này dẫn đến tình trạng phát sinh nhiều class, nhưng chúng ta sẽ không cần phải test lại các class cũ nữa, mà chỉ tập trung vào test các class mới, nơi chứa các chức năng mới.

Thông thường việc mở rộng thêm chức năng thì phải viết thêm code, vậy để thiết kế ra một module có thể dễ dàng mở rộng nhưng lại hạn chế sửa đổi code ta cần làm gì. Cách giải quyết là tách những phần dễ thay đổi ra khỏi phần khó thay đổi mà vẫn đảm bảo không ảnh hưởng đến phần còn lại.

Ví dụ:

  • Đặt vấn đề: Ta cần 1 lớp đảm nhận việc kết nối đến CSDL. Thiết kế ban đầu chỉ có SQL Server và MySQL. Thiết kế ban đầu có dạng như sau:
class ConnectionManager
{
    public function doConnection(Object $connection)
    {
        if($connection instanceof SqlServer) {
            //connect with SqlServer
        } elseif($connection instanceof MySql) {
            //connect with MySql
        }
    }
}

Sau đó yêu cầu đặt ra phải kết nối thêm đến Oracle và một vài hệ CSDL khác.
Để thêm chức năng ta phải thêm vào code những khối esleif khác, việc này làm code cồng kềnh và khó quản lý hơn.

  • Giải pháp:
    • Áp dụng Abstract thiết kế lại các lớp SqlServer, MySql, Oracle...
    • Các lớp này đều có chung nhiệm vụ tạo kết nối đến csdl tương ứng có thể gọi chung là Connection.
    • Cách thức kết nối đến csdl thay đổi tùy thuộc vào từng loại kết nối nhưng có thể gọi chung là doConect.
    • Vậy ta có lớp cơ sở Connection có phương thức doConnect, các lớp cụ thể là SqlServer, MySql, Oracle... kế thừa từ Connection và overwrite lại phương thức doConnect phù hợp với lớp đó.

Thiết kế sau khi làm lại có dạng như sau:

abstract class Connection()
{
        public abstract function doConnect();
}

class SqlServer extends Connection
{
    public function doConnect()
    {
        //connect with SqlServer
    }
}

class MySql extends Connection
{
    public function doConnect()
    {
        //connect with MySql
    }
}

class ConnectionManager
{
    public function doConnection(Connection $connection)
    {
        //something
        //.................
        //connection
        $connection->doConnect();
    }
}

Với thiết kế này khi cần kết nối đến 1 loại csdl mới chỉ cần thêm 1 lớp mới kế thừa Connection mà không cần sửa đổi code của lớp ConnectionManager, điều này thỏa mãn 2 điều kiện của nguyên lý OCP.

Lưu ý:

  • Trên thực tế không có mô hình nào thỏa mãn hoàn toàn OCP, sẽ luôn có những thay đổi khiến mô hình không thỏa mãn OCP.

Ví dụ với yêu cầu show ra toàn bộ các hình được đưa vào ta có mô hình sau:

abstract class Shape
{
    public abstract function Draw();
}

class Square extends Shape
{
    public function Draw()
    {
        // show Square
    }
}

class Circle extends Shape
{
    public function Draw()
    {
        // show Circle
    }
}

class DrawShape
{
    public function DrawAllShape(array $shapes)
    {
        // $shapes is an array of shape object
        foreach($shapes as $shape) {
            $shape->draw();
        }
    }
}

Nếu các thay đổi đưa ra như thêm các hình mới thì mô hình vẫn thỏa mãn OCP.
Nhưng nếu yêu cầu thay đổi là show hình tròn trước hoặc không show ra những hình đặc biệt thì chúng ta vẫn phải thay đổi code của function DrawAllShape và việc này làm thiết kế không thỏa mãn OCP.

  • Khi áp dụng nguyên lý này để thiết kế cần phải xác định được những thứ dễ bị thay đổi để thiết kế phù hợp với thay đổi đó. Việc này cần kinh nghiệm và một tầm nhìn xa.

3. Liskov Substitution Principle

Nguyên lý thứ ba, tương ứng với chữ L trong SOLID. Có nội dung như sau:

Trong một chương trình, các object của class con có thể
thay thế class cha mà không làm thay đổi tính đúng đắn của chương trình.

Giải thích:

  • Nội dung nguyên lý có thể hiểu như sau trong một chương trình, các object của class con có thể thay thế class cha mà không làm thay đổi tính đúng đắn của chương trình.
  • Mối quan hệ IS-A (là một) thường được dùng để xác định kế thừa. Class B kế thừa class A khi B là một A, do đó B có thể thay thay thế hoàn toàn cho A mà không làm mất đi tính đúng đắn.

Ví dụ: Thực tế ta biết hình vuông là hình chữ nhật đặc biệt có chiều cao = chiều rộng.
Vậy class hình vuông có phải con của class hình chữ nhật.
Xem xét mối quan hệ kế thừa giữa hình vuông và hình chữ nhật như sau:

class Rectangle
{
    protected $m_width;
    protected $m_height;

    public function setWidth(int $width) {
        $this->m_width = $width;
    }

    public function setHeight(int $height) {
        $this->m_height = $height;
    }

    public function getWidth() {
        return $this->m_width;
    }

    public function getHeight() {
        return $this->m_height;
    }

    public function getArea() {
        return $this->m_width * $this->m_height;
    }
}

class Square extends Rectangle
{
    public function setWidth(int $width) {
        $this->m_width = $width;
        $this->m_height = $width;
    }

    public function setHeight(int $height) {
        $this->m_height = $height;
        $this->m_width = $height;
    }
}

Theo nguyên lý Liskov thì class Square có thể thay thế class Rectangle nên ta thực hiện việc test như sau:

class Test
{
    public function checkArea(Rectangle $r)
    {
        $r->setWidth(10);
        $r->setHeight(5);

        if($r->getArea() == 50) {
            return 'true';
        }

        return 'false';
    }
}

$test = new Test;
echo 'Test with Rectangle: '.$test->checkArea(new Rectangle).PHP_EOL;// Test with Rectangle: true
echo 'Test with Square: '.$test->checkArea(new Square).PHP_EOL;// Test with Rectangle: false

Phương thức test hoạt động đúng (50) khi $r là một thể hiện của Rectangle nhưng khi thay thế $r là thể hiện của Square thì kết quả là false (25).
Lớp Square đã làm mất đi tính đúng đẵn của chương trình.

Vậy hình vuông không phải là 1 hình chữ nhật (?).
Xét về mặt hành vi thì 1 hình vuông không phải là 1 hình chữ nhật vì hành vi của hình vuông không thỏa mãn yêu cầu của hàm checkArea.

Lưu ý & kết luận:

  • Trong đời sống, 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.
  • Trong lập trình chỉ cho class A kế thừa class B khi class A thay thế được cho class B.
    Cần xem xét các đối tượng về mặt hành vi chứ không nên sử dụng những mối quan hệ trong đời thật.

4. Interface Segregation Principle

Nguyên lý thứ tư, tương ứng với chữ I trong SOLID. Có nội dung 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 đích cụ thể.

Nguyên lý này rất dễ hiểu. Hãy tưởng tượng chúng ta có 1 interface lớn, khoảng 100 methods. Việc implements sẽ rất vất vả vì các class impliment interface này sẽ bắt buộc phải phải thực thi toàn bộ các method của interface, ngoài ra cò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 các method liên quan tới nhau, việc implement và quản lý sẽ dễ hơn.

Ví dụ:

  • Đặt vấn đề: Xây dựng 1 interface Phone, các lớp SmartPhone, PhoneBox implement Phone.
interface Phone
{
    public function call();
    public function sms();
    public function picture();
}

class SmartPhone implements Phone
{
    public function call()
    {
        // do call
    }

    public function sms()
    {
        // send sms
    }

    public function picture()
    {
        // take picture
    }
}

class PhoneBox implements Phone
{
    public function call()
    {
        // do call
    }

    public function sms()
    {
        throw new Exception("PhoneBox can't send SMS");
    }

    public function picture()
    {
        throw new Exception("PhoneBox can't take picture");
    }
}

Nếu lớp Phone có thêm các phương thức mới thì các lớp SmartPhone, PhoneBox sẽ phải thay đổi theo trong khi nó không cần hoặc không thế thực hiện.

  • Giải pháp:
    • Chia nhỏ interface Phone thành những interface mang nhiệm vụ đặc thù
    • Các lớp SmartPhone, PhoneBox implement interface đặc thù mà nó cần
interface Callable()
{
    public function call();
}

interface Smsable()
{
    public function sms();
}

interface Pictureable()
{
    public function sms();
}

class SmartPhone implements Callable, Smsable, Pictureable
{
    public function call()
    {
        // do call
    }

    public function sms()
    {
        // send sms
    }

    public function picture()
    {
        // take picture
    }
}

class PhoneBox implements Callable
{
    public function call()
    {
        // do call
    }
}

Như vậy khi mở rộng ta thêm các interface đặc thù với chức năng mở rộng thì sẽ không ảnh hưởng đến các lớp dẫn xuất.

Lưu ý & kết luận:

  • Nguyên lý ISP (tách biệt giao tiếp) có mối liên hệ với nguyên lý Open - Close (nguyên lý mở rộng - hạn chế OCP).
    Vi phạm ISP có khả năng dẫn tới sự vi phạm OCP.
  • Những lớp trừu tượng chứa quá nhiều thuộc tính và chức năng gọi là những lớp bị ô nhiễm (polluted).
  • Các lớp dẫn xuất phụ thuộc vào polluted interface làm tăng sự kết dính giữa các thực thể.
    Khi nâng cấp, sửa đổi đòi hỏi các interface này thay đổi, các lớp dẫn xuất này buộc phải thay đổi theo, điều này vi phạm nguyên lý OCP.

5. Dependency inversion principle

Nguyên lý cuối cùng, tương ứng với chữ D trong SOLID. Có nội dung như sau:

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.

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 (abstraction), không phải thông qua implementation.)

Giải thích:

  • Có thể hiểu nguyên lí này như sau: những thành phần trong 1 chương trình chỉ nên phụ thuộc vào những cái trừu tượng (abstraction). Những thành phần trừu tượng không nên phụ thuộc vào các thành phần mang tính cụ thể mà nên ngược lại.
  • Những cái trừu tượng (abstraction) là những cái ít thay đổi và biến động, nó tập hợp những đặc tính chung nhất của những cái cụ thể. Những cái cụ thể dù khác nhau thế nào đi nữa đều tuân theo các quy tắc chung mà cái trừu tượng đã định ra. Việc phụ thuộc vào cái trừu tượng sẽ giúp chương trình linh động và thích ứng tốt với các sự thay đổi diễn ra liên tục.

Ví dụ:

  • Đặt vấn đề: Ta cần 1 lớp đảm nhiệm chức năng thông báo khi nhận được 1 event từ hệ thống. Thiết kế ban đầu có dạng như sau:

 

class Notice
{
    public function pushNotification()
    {
        //push notic
    }
}

class EventListener
{
    public function listen($event)
    {
        $notice = new Notice;
        $notice->pushNotification();
    }
}

Như thiết kế này lớp EventListener muốn hoạt động thì phụ thuộc vào lớp Notice.

Khi yêu cầu thay đổi ta cần 1 số event thì thông báo 1 số event thì ghi lại log. Lúc này ta thêm 1 lớp LogWriter và sửa code của hàm listen():

class LogWriter
{
    public function writeLog()
    {
        // write something
    }
}

class EventListener
{
    public function listen($event)
    {
        if($event == 'notice') {
            $notice = new Notice;
            $notice->pushNotification();
        } elseif($event == 'write') {
            $writer = new LogWriter;
            $writer->writeLog();
        }
    }
}

Như vậy với mỗi yêu cầu tăng thêm lớp EventListener sẽ càng phụ thuộc nhiều lớp, đồng thời phải sửa hàm listen() nhiều lần điều này vi phạm nguyên lý OCP (nguyên lý mở rộng - hạn chế).

  • Giải pháp:
    • Xây dựng 1 interface Handler, các lớp cụ thể Notice, LogWriter sẽ implements Handler.
    • Lớp EventListener phụ thuộc vào Handler thay vì các lớp cụ thể Notice, LogWriter.
interface Handler
{
    public function fire();
}

class Notice implements Handler
{
    public function fire()
    {
        //push notic
    }
}

class LogWriter implements Handler
{
    public function fire()
    {
        // write something
    }
}

class EventListener
{
    public function listen(Handler $event)
    {
        $event->fire();
    }
}

 

Như vậy lớp EventListener không còn phụ thuộc vào các lớp cụ thể mà chỉ phụ thuộc vào 1 lớp trừu tượng Handler, ta có thể thêm các lớp cụ thể mà không hề ảnh hưởng đến lớp EventListener.

Lưu ý & kết luận:

  • Nguyên lý DIP (nghịch đảo phụ thuộc) có mối liên hệ với nguyên lý OCP (nguyên lý mở rộng - hạn chế). Vi phạm DIP có khả năng dẫn tới sự vi phạm OCP.
  • Dependency Inversion Principles khác với Dependency Injection.
    Dependency Injection chỉ là 1 trong những pattern để thực hiện Dependency Inversion Principles.

 

 

Nguồn: toidicodedao.com

Hiểu Về Synchronized Trong Java

Ngày 13 tháng 9 năm 2018

13-09-2018
56
Dependency Inversion Principle

Ngày 24 tháng 10 năm 2017

24-10-2017
135
Interface Segregation Principle

Ngày 24 tháng 10 năm 2017

24-10-2017
146