Chưa biết trừu tượng, đừng vội tìm hiểu Kiến Trúc Ứng Dụng - Phần 2

Chưa biết trừu tượng, đừng vội tìm hiểu Kiến Trúc Ứng Dụng - Phần 2

- 19 mins

Đây là bài tiếp theo trong loạt bài “Kiến trúc ứng dụng: Nên bắt đầu như thế nào”:

Kết thúc bài trước mình đã dẫn các bạn tới “bãi giữ xe” của thế giới KTUD rồi. Và bài này chính là chiếc vé để vào cổng, chính thức trở thành công dân của thế giới KTUD.

Trừu tượng, nó là gì trong lập trình

Trừu tượng (abstract) nghĩa là một cái gì đó chung chung, không rõ ràng.

Nếu có ai đó hỏi bạn “Con mèo thích ăn gì ?” có thể bạn sẽ có ngay câu trả lời là “Con mèo thích ăn cá”. Tuy nhiên nếu là “Động vật thích ăn gì ?” thì bạn sẽ trả lời thế nào ? Sẽ có 2 trường hợp xảy ra: Hoặc là không trả lời được, hoặc là có rất nhiều câu trả lời. Điều này hoàn toàn tương tự trong thế giới lập trình:

class Animal {
  func eat() {
    // ở đây hoặc là bỏ trống hoặc là if else đủ giống loài trên đời
  }
}

Giải quyết cái này quá dễ, ai từng học OOP sẽ biết là chỉ cần khai báo cho class Animal và method eat()astract. Sau đó cho các subclass cụ thể hơn kế thừa Animal và override lại hàm eat() là xong.

OK đó là trong trường lớp, chắc chắn rằng không ai dùng “if else” cho tất cả các trường hợp cả. E hèm thế mà cũng có đó, không ít bạn sau khi tốt nghiệp đã code khá nhiều ứng dụng có module người dùng như sau:

class User {
  func doSomething() {
    // if role == admin
    // else if role == mod
    // else if role == editor
    // else if role == customer
    // ...
  }
}

Lý do của việc trên cũng khá là tự nhiên vì ứng dụng chỉ có vài role thôi nên cứ thế mà “if else” là đủ, code cũng “trực quan”, đứng 1 chỗ là phơi bày tất cả. Nếu bạn đang nghĩ vậy, con đường bạn đang đi là thẳng tiến tới code editor. Công việc chính của bạn là edit code cũ, vì cứ khi ứng dụng thêm role mới hoặc edit role cũ là bạn sẽ mở đúng file code này ra, lục lọi trong đống code mình viết ra, tới một lúc nào đó vì stress bạn sẽ chửi luôn cái thằng viết ra file này, mà đó là chính bạn chứ không ai khác.

Mặt khác xét về ngữ nghĩa, nếu ta đặt lại câu hỏi ngược lại giống như class Animal trên “Người dùng có thể làm gì ?”, “Khách hàng có thể làm gì ?”, “Quản trị viên có thể làm gì ?”. Bạn sẽ có ngay câu trả lời, đó là đưa class User và hàm doSomething() về astract.

// Do Swift không có khái niệm abstract class và abstract method
// nên mình dùng tạm fatalError()

class User {
  func doSomething() {
    fatalError("Subclasses need to implement the `doSomething()` method.")
  }
}

class Admin : User {
  override func doSomething() {
    // To do: things admin can do
  }
}

class Customer : User {
  override func doSomething() {
    // To do: things customer can do
  }
}

//...

Trên thực tế, trong ứng dụng sẽ không có đối tượng thực thể nào gọi là “người dùng” cả, họ phải là một trong những role chúng ta định ra. Vì thế việc khởi tạo một instance của User và gọi hàm doSomething() là rất vô lý. Từ đó cho ta báo lỗi để ngăn chặn việc này.

Trong Swift có Protocol dùng làm abstract tốt hơn nhưng ở đây mình sẽ nói đến Protocol trong phần sau

Lợi ích của trừu tượng trong kiến trúc ứng dụng

Nếu bạn đã từng nghe lợi ích của KTUD đến phát chán như “giúp code dễ bảo trì và mở rộng” thì hẵn là bạn sẽ nghĩ “nó dùng cái gì để làm được điều đó ?”. Đó chính là “trừu tượng”. Đây cũng là một trong những công cụ để xây dựng lên các Design Pattern và kiến trúc ứng dụng.

Mặt khác việc trừu tượng hóa các model sẽ giúp ta tạo ra các ràng buộc cho những người làm việc với chúng ta (thường là các end developer). Như ví dụ “doSomething” chắc chắn sẽ được implement trong các subclass của User chứ không thể là tên hàm khác. Đây được coi như là sự thống nhất về tên hàm.

Cách rèn luyện tư duy trừu tượng hóa

Theo kinh nghiệm của mình. Đa số những người mới học lập trình, khi có ý tưởng sẽ lao ngay vào code rồi chạy để kiểm thử. Đây là một thói quen rất không tốt, nó sẽ giết chết tư duy xây dựng kiến trúc của các bạn. Thay vào đó chúng ta cứ liệt kê ra các tính năng cần làm, các model phải có, mối quan hệ giữa chúng ra sao và hành vi của chúng thế nào. Từ đó ta có thể nhìn ra được những điểm chung, những chỗ cần mô hình hóa chúng cho hợp lý hơn.

Viết code chi tiết (implement) thường sẽ dễ hơn xây dựng các đối tượng abstract. Mặc dù việc xây dựng các “bộ khung” này chẳng phải code gì nhiều cả. Tuy nhiên việc này định hình cho tất cả các code chi tiết về sau, khung mà sai thì có đắp cái gì vào nó cũng sai. Chính vì vậy, những kiến trúc sư thường là những người rất có kinh nghiệm, họ đã gặp rất nhiều bài toán thể loại này nên dễ dàng tổng quát hóa vấn đề. Nên bây giờ nếu bạn chưa thể làm được ngay cũng đừng nản lòng. KTUD là kỹ năng cần thời gian để rèn luyện.

Trong quá trình phát triển ứng dụng đây có thể là điểm bạn cần lưu ý:

Nếu bạn vẫn luôn gặp những điều trên nhưng vẫn chưa giải quyết được. Đừng lo, cái bạn cần tiếp theo là công cụ. Rất sớm thôi bạn cũng sẽ làm được.

Ở phần còn lại của bài viết này mình sẽ đưa ra một ví dụ về sức mạnh của việc “trừu tượng hóa”.

Sử dụng trừu tượng để giảm sự lệ thuộc giữa các đối tượng

Giảm lệ thuộc hay lệ thuộc thấp (loose coupling) là một khái niệm khá thú vị. Thông thường khi làm việc với các đối tượng sẽ có hiện tượng gọi đến các phương thức và thuộc tính của nhau. Điều này khiến chúng bị dính chặt (tightly) vào nhau và gây cản trở việc tái sử dụng chúng.

Ở đây để minh họa mình sẽ đưa ra một cặp đối tượng kinh điển là “Con nợ” và “Chủ nợ”. Dưới đây sẽ là một chương trình mô phỏng việc vay mượn tiền và trả tiền giữa 2 đối tượng này.

Cách 1: biết tuốt về nhau, cứ có đối tượng là moi hết ra xài

// Chủ Nợ vs Con Nợ
// Chủ Nợ: có thể đòi nợ con nợ
// Con Nợ: có thể mượn tiền chủ nợ

// Theo cách viết thông thường ta có:

class Person {
  var name:String
  var age:Int
  var money:Int = 0
  
  init(name:String, age:Int) {
      self.name = name
      self.age = age
  }
}

// Chủ nợ
class Lender:Person {
  var borrower:Borrower? // giả sử chủ nợ sẽ có 1 con nợ
  
  func requestPayment() {
    if let borrower = self.borrower {
      if borrower.money >= borrower.debt {
        borrower.money -= borrower.debt
        self.money += borrower.debt
        borrower.debt = 0
      }
    }
  }
}

// Con nợ

class Borrower:Person {

  // weak ở đây để chống retain cycle vì 2 class tham chiếu vào nhau
  weak var lender:Lender? // giả sử con nợ thì có 1 chủ nợ thôi
  
  var debt:Int = 0
  
  func borrowMoney(lender:Lender,money: Int) {
      
    if lender.money >= money {
            
      lender.money -= money
      self.money += money
      debt = money
          
      self.lender = lender
      lender.borrower = self
    }
  }
}

// logic vay tiền và đòi tiền đâu đó trong ứng dụng như sau

// Khởi tạo các đối tượng chủ nợ và con nợ
let lenderObj = Lender(name: "Teo", age: 22)
lenderObj.money = 2000

let borrowerObj = Borrower(name: "Ti", age: 18)

// Con nợ mượn chủ nợ 1000
borrowerObj.borrowMoney(lenderObj, money: 1000)

// Một thời gian sau chủ nợ tới đòi tiền con nợ
lenderObj.requestPayment()

Đoạn code trên đại khái là ta sẽ viết cho 2 đối tượng có thể gọi đến nhau thông qua các method của chúng để vay và trả tiền. Tới đây chương trình không có lỗi và ta tự tin rằng mình đã có kiến thức tốt về hướng đối tượng.

Nếu chúng ta để ý sẽ thấy rằng hình như có gì đó không ổn …. Người mượn tiền thì biết rõ số tiền của người cho mượn và người cho mượn cũng thế. Giống kiểu bạn giật bóp tiền người ta rồi tự lấy tiền và khi nợ tới hạn người ta cũng đòi tiền theo cách mà bạn mượn tiền họ. Tới đây ta nói 2 đối tượng chủ nợ và con nợ biết quá nhiều về nhau hay nói 1 cách khác là dính chặt vào nhau (tight). Mà đó là điều mà ta nên né tránh để xây dựng ứng dụng có kiến trúc tốt.

Thêm vào đó, cách viết trên ta giả sử nếu con nợ (Borrower) có thẻ tín dụng, tiền trong ngân hàng thì sao. Lúc đó ta phải update cả 2 object trên gần như toàn bộ logic cho vay tiền và trả tiền !!! Và điều này trong thực tế ta gặp rất nhìu, vd như mối quan hệ giữa đơn hàng (Order) và giỏ hàng (Cart) và Sản Phẩm hoặc Kho … Theo cách trên là cứ lấy hết ruột gan nhau ra mà xài (dù chúng có được encapsulation hay không).

Cách 2: Logic của class nào ra class nấy - Tôi biết bạn là ai và tôi biết bạn có thể làm cái tôi muốn

// Chủ nợ
class Lender:Person {
  var borrower:Borrower?
  
  // Cho con nợ vay tiền
  func lendMoney(borrower:Borrower, money:Int) -> Bool {
    // Nếu tiền không đủ để cho mượn thì return false
    guard self.money >= money else { return false }
    
    self.borrower = borrower
    self.money -= money
    
    // Đưa tiền cho người mượn
    borrower.receiveMoney(self, money: money)
    
    return true
  }
  
  // Đòi nợ
  func requestPayment() {

    if let borrower = self.borrower {
      if let returnMoney = borrower.payMoneyBack() {
        // Con nợ đã trả hết tiền nợ, xong !
        self.money += returnMoney
        self.borrower = nil
      } else {
        // Trường hợp returnMoney = nil, con nợ vẫn chưa trả đc tiền
      }
    }
  }
}

// Con nợ
class Borrower:Person {
  weak var lender:Lender?
  var debt:Int = 0
  
  // Con nợ nhận tiền từ chủ nợ
  func receiveMoney(lender:Lender, money:Int) {
    self.lender = lender
    debt = money
    self.money += money
  }
  
  // Trả nợ
  func payMoneyBack() -> Int? {

    var returnMoney:Int?

    // Nếu đủ tiền thì trả hết, hết nợ
    if money >= debt {
      money -= debt
      returnMoney = debt
      debt = 0
      self.lender = nil
    } else {
      // Chưa đủ tiển trả nên hẹn lần sau
      return nil
    }

    return returnMoney
  }
  
  // Hỏi mượn tiền từ chủ nợ
  func askForMoney(lender:Lender, money:Int) {
    if lender.lendMoney(self, money: money) {
      print("Yeah !!")
    } else {
      // Ặc phải tìm người khác để mượn rồi
    }
  }
}

// logic vay tiền và đòi tiền đâu đó trong project như sau

let lenderObj = Lender(name: "Teo", age: 22)
lenderObj.money = 2000
let borrowerObj = Borrower(name: "Ti", age: 18)

// Mượn tiền
borrowerObj.askForMoney(lenderObj, money: 1000)
// Chủ nợ đòi tiền
lenderObj.requestPayment()

Đoạn code sau phức tạp hơn khá nhiều nhưng đại khái lúc này ta nói rằng việc cho vay (của chủ nợ) và trả nợ (của con nợ) sẽ rõ ràng hơn. Đặc biệt ta không còn bị tình trạng mượn tiền kiểu côn đồ như cách đoạn code trước đó :D. Logic cho việc lấy tiền ở đâu là việc riêng của cả 2 mà không muốn cho đối phương biết. Cách viết này tốt hơn cách đầu tiên nhưng vẫn chưa thể giải quyết vấn đề Lender - Borrower dính chặt vào nhau.

Cách 3: Tôi không biết bạn là ai nhưng tôi biết bạn có thể làm được cái tôi muốn

Tới đây ta có thể giải quyết vấn đề trên bằng Protocol trong Swift

protocol LenderBehavior:class {
  func lendMoney(borrower:BorrowerBehavior, money:Int) -> Bool
  func requestPayment()
}

protocol BorrowerBehavior {
  func askForMoney(lender:LenderBehavior, money:Int)
  func receiveMoney(lender:LenderBehavior, money:Int)
  func payMoneyBack() -> Int?
}

// Chủ nợ
class Lender:Person, LenderBehavior {
  var borrower:BorrowerBehavior? // thay vì là Borrower, thay lại là BorrowerBehavior 
  
  func lendMoney(borrower:BorrowerBehavior, money:Int) -> Bool {
    // Code như cũ
  }
  
  func requestPayment() {
    // Code như cũ 
  }
}

// Con nợ
class Borrower:Person, BorrowerBehavior {
  weak var lender:LenderBehavior? // thay vì là Lender, thay lại là LenderBehavior
  var debt:Int = 0
  
  func receiveMoney(lender:LenderBehavior, money:Int) {
    // code như cũ
  }
  
  func payMoneyBack() -> Int? {
    // code như cũ
  }
  
  func askForMoney(lender:LenderBehavior, money:Int) {
    // Code như cũ
  }
}

// logic vay tiền và đòi tiền đâu đó trong project như sau

let lenderObj = Lender(name: "Teo", age: 22)
lenderObj.money = 2000
let borrowerObj = Borrower(name: "Ti", age: 18)

// Mượn tiền
borrowerObj.askForMoney(lenderObj, money: 1000)
// Chủ nợ đòi tiền
lenderObj.requestPayment()

Điều gì xảy ra nếu ra có class Worker nào đó mà muốn vay tiền ?? Nếu Worker là subclass của Person thì mọi thứ khá đơn giản, nhưng nếu nó đang là subclass của 1 class khác thì sao ?? Ta biết trong hướng đối tượng, 1 class không thể kế thừa từ 2 class. Vì có Protocol rồi thì mọi thứ sẽ đơn giản hơn nhiều:

class Worker:SomeClass, BorrowerBehavior {
  // mọi người thử viết cho class này nhé
}

// Hay thậm chí là 1 Company vừa phải vay tiền và lại vừa có thể đầu tư (cho vay)
class Company:OtherClass, BorrowerBehavior, LenderBehavior {
  // ...
}

Ta thấy rằng dù class Worker có nhìu logic của nó đến chừng nào thì việc mượn tiền chỉ cần adopt protocol BorrowerBehavior và viết chi tiết các method bên trong. Với Company cũng thế.

Lúc này 2 đối tượng Lender - Borrower không còn kết dính nữa, chúng cũng chẳng biết ai là ai, con nợ và chủ nợ lúc này chỉ còn là “người có khả năng cho vay” và “người có thể sẽ phải vay tiền”. Từ đó ta có thể linh hoạt sử dụng các đối tượng hơn, không cứ phải là Lender hay Borrower nữa.

Kết

Tới đây mình hy vọng các bạn sẽ hiểu thêm tầm quan trọng của việc trừu tượng hóa và có thể vận dụng chúng hiệu quả hơn trong ứng dụng. Nếu các bạn đã đọc đến đây và hiểu hết các vấn đề, chào mừng các bạn đã đến thế giới Kiến Trúc Ứng Dụng.

Còn tiếp …

Viet Tran

Viet Tran

A Man who is developing apps with Red Bull

comments powered by Disqus
rss facebook twitter github youtube mail spotify lastfm instagram linkedin google google-plus pinterest medium vimeo stackoverflow reddit quora