每個Web開發者都應該知道的SOLID原則

作者|Chidume Nnamdi

每個Web開發者都應該知道的SOLID原則

面向對象的編程並不能防止難以理解或不可維護的程序。因此,Robert C. Martin 制定了五項指導原則,使開發人員很容易創建出可讀性強且可維護的程序。這五項原則被稱為 S.O.L.I.D 原則。

面向對象編程帶來了新的軟件開發設計方法。它使得開發人員能夠將具有相同作用 / 功能的數據組合到一個類中,實現唯一的目的,而不管整個應用程序如何。

但是,這種面向對象的編程並不能防止難以理解或不可維護的程序。因此,Robert C. Martin 制定了五項指導原則,使開發人員很容易創建出可讀性強且可維護的程序。這五項原則被稱為 S.O.L.I.D 原則(這種縮寫是由 Michael Feathers 提出的):

  • S:單一職責原則
  • O:開閉原則
  • L:里氏替換原則
  • I:接口隔離原則
  • D:依賴倒置原則

下面我們將展開詳細的討論。

注意:本文中的大多數示例可能不能滿足實際情況或不能應用於實際的應用程序。這完全取決於你自己的設計和場景。最重要的是理解並知道如何應用 / 遵循這些原則。

提示:SOLID 原則是為構建模塊化、可擴展和可組合的封裝組件而設計的。Bit 是實踐這一原則的一個強大的工具:它能夠幫助你在不同的項目中、在團隊範圍內輕鬆地隔離、共享和管理這些組件。

單一職責原則(SRP)

一個類只應該負責一件事。如果一個類有多個職責,那麼它變成了耦合的。對一個職責的修改會導致對另一個職責的修改。

注意:這個原則不僅適用於類,也適用於軟件組件和微服務。

例如,考慮下面的設計:

<code>class Animal {
constructor(name: string){ }
getAnimalName() { }
saveAnimal(a: Animal) { }
}/<code>

上面的 Animal 就違反了單一職責原則(SRP)。

它為什麼違反了 SRP?

SRP 指出,類應該有一個職責,在這裡,我們可以得出兩個職責:動物數據庫管理和動物屬性管理。構造函數和 getAnimalName 管理動物屬性,而 saveAnimal 管理 Animal 在數據庫中的存儲。

這種設計將來會帶來什麼問題?

如果應用程序的修改影響了數據庫管理功能,使用 Animal 屬性的類就必須修改和重新編譯,以適應這種新的變化。這個系統就有點像多米諾骨牌,觸碰一張牌就會影響到其他牌。

為了使這個類符合 SRP,我們創建了另一個類,它負責將動物存儲到數據庫中這個單獨的職責:

<code>class Animal {
constructor(name: string){ }
getAnimalName() { }
}
class AnimalDB {
getAnimal(a: Animal) { }
saveAnimal(a: Animal) { }
}/<code>

在設計我們的類時,我們應該把相關的特性放在一起,這樣,每當它們需要改變的時候,它們都是因為同樣的原因而改變。如果它們因不同的原因而改變,我們就應該嘗試將它們分開。——Steve Fenton

恰當運用這條原則,我們的應用程序就會變成高內聚的。

開閉原則(OCP)

軟件實體(類、模塊、函數)應該對擴展開放,對修改關閉。

讓我們繼續以 Animal 類為例。

<code>class Animal {
constructor(name: string){ }
getAnimalName() { }
}/<code>

我們希望遍歷一個動物列表,發出它們的聲音。

<code>//...
const animals: Array<animal> = [
new Animal('lion'),
new Animal('mouse')
];
function AnimalSound(a: Array<animal>) {
for(int i = 0; i <= a.length; i++) {
if(a[i].name == 'lion')
log('roar');
if(a[i].name == 'mouse')
log('squeak');
}
}
AnimalSound(animals);/<animal>/<animal>/<code>

函數 AnimalSound 不符合開閉原則,因為它不能對新的動物關閉。如果我們添加一種新的動物蛇:

<code>//...
const animals: Array<animal> = [
new Animal('lion'),
new Animal('mouse'),
new Animal('snake')
]
//.../<animal>/<code>

我們就不得不修改 AnimalSound 函數:

<code>//...
function AnimalSound(a: Array<animal>) {
for(int i = 0; i <= a.length; i++) {
if(a[i].name == 'lion')
log('roar');
if(a[i].name == 'mouse')
log('squeak');
if(a[i].name == 'snake')
log('hiss');
}
}
AnimalSound(animals);/<animal>/<code>

如你所見,對於每一種新的動物,一段新的邏輯會被添加到 AnimalSound 函數。這是一個非常簡單的例子。當應用程序變得龐大而複雜時,你會看到,每添加一種新動物,if 語句就得在 AnimalSound 函數中重複一遍。

如何使它(AnimalSound)符合 OCP?

<code>class Animal {
makeSound();
//...
}
class Lion extends Animal {
makeSound() {
return 'roar';
}
}
class Squirrel extends Animal {
makeSound() {
return 'squeak';
}
}
class Snake extends Animal {
makeSound() {
return 'hiss';
}
}
//...
function AnimalSound(a: Array<animal>) {
for(int i = 0; i <= a.length; i++) {
log(a[i].makeSound());

}
}
AnimalSound(animals);/<animal>/<code>

Animal 現在有了一個虛方法 makeSound。我們讓每一種動物擴展 Animal 類並實現 makeSound 方法。

每一種動物都加入自己的發聲方法(makeSound)實現。AnimalSound 遍歷動物數組並調用每種動物的 makeSound 方法。

現在,如果我們添加一種新動物,AnimalSound 不需要修改。我們需要做的就是把新動物加入到動物數組中。

AnimalSound 方法符合 OCP 原則了。

再舉個例子。假如你有一家商店,你使用下面的類給自己最喜歡的客戶 20% 的折扣:

<code>class Discount {
giveDiscount() {
return this.price * 0.2
}
}/<code>

當你決定給 VIP 客戶雙倍的折扣(40%)時,你可能會這樣修改這個類:

<code>class Discount {
giveDiscount() {
if(this.customer == 'fav') {
return this.price * 0.2;
}
if(this.customer == 'vip') {
return this.price * 0.4;
}
}
}/<code>

這就違反了 OCP 原則。OCP 禁止這樣做。如果想給不同類型的客戶一個新的折扣百分比,就得添加一段新的邏輯。

為了使它遵循 OCP 原則,我們將新建一個類來擴展 Discount。在這個新類中,我們將重新實現它的行為:

<code>class VIPDiscount: Discount {
getDiscount() {
return super.getDiscount() * 2;
}
}/<code>

如果你決定給超級 VIP 客戶 80% 的折扣,那麼代碼是下面這個樣子:

<code>class SuperVIPDiscount: VIPDiscount {
getDiscount() {
return super.getDiscount() * 2;
}
}/<code>

就是這樣,擴展而不修改。

里氏替換原則(LSP)

子類必須可以替換它的超類。

這個原則的目的是確保子類可以替換它的超類而沒有錯誤。如果你發現自己的代碼在檢查類的類型,那麼它一定違反了這個原則。

讓我們以 Animal 為例。

<code>//...
function AnimalLegCount(a: Array<animal>) {

for(int i = 0; i <= a.length; i++) {
if(typeof a[i] == Lion)
log(LionLegCount(a[i]));
if(typeof a[i] == Mouse)
log(MouseLegCount(a[i]));
if(typeof a[i] == Snake)
log(SnakeLegCount(a[i]));
}
}
AnimalLegCount(animals);/<animal>/<code>

上述方法違反了 LSP 原則(也違反了 OCP 原則)。它必須知道每一種 Animal 類型,並調用相應的數腿函數。

每次創建一個新的動物類,都得修改這個函數:

<code>//...
class Pigeon extends Animal {

}
const animals[]: Array<animal> = [
//...,
new Pigeon();
]
function AnimalLegCount(a: Array<animal>) {
for(int i = 0; i <= a.length; i++) {
if(typeof a[i] == Lion)
log(LionLegCount(a[i]));
if(typeof a[i] == Mouse)
log(MouseLegCount(a[i]));
if(typeof a[i] == Snake)
log(SnakeLegCount(a[i]));
if(typeof a[i] == Pigeon)
log(PigeonLegCount(a[i]));
}
}
AnimalLegCount(animals);/<animal>/<animal>/<code>

為了使這個函數符合 LSP 原則,我們將遵循 Steve Fenton 提出的 LSP 要求:

  • 如果超類(Animal)有一個方法接受超類類型(Anima)的參數,那麼它的子類(Pigeon)應該接受超類類型(Animal 類型)或子類類型(Pigeon 類型)作為參數。
  • 如果超類返回一個超類類型(Animal), 那麼它的子類應該返回一個超類類型(Animal 類型)或子類類型(Pigeon 類型)。

現在,我們可以重新實現 AnimalLegCount 函數了:

<code>function AnimalLegCount(a: Array<animal>) {
for(let i = 0; i <= a.length; i++) {
a[i].LegCount();
}
}
AnimalLegCount(animals);/<animal>/<code>

AnimalLegCount 函數並不關心傳遞的動物類型,它只管調用 LegCount 方法。它只知道參數必須是 Animal 類型,要麼是 Animal 類,要麼是它的子類。

現在,Animal 類必須實現 / 定義一個 LegCount 方法:

<code>class Animal {
//...
LegCount();
}/<code>

而它的子類必須實現 LegCount 方法:

<code>//...
class Lion extends Animal{
//...
LegCount() {
//...
}
}
//.../<code>

當它被傳遞給 AnimalLegCount 函數時,它會返回一頭獅子的腿數。

如你所見,AnimalLegCount 不需要知道動物的類型就可以返回它的腿數,它只調用了 Animal 類型的 LegCount 方法,因為根據約定,Animal 類的一個子類必須實現 LegCount 函數。

接口隔離原則(ISP)

創建特定於客戶端的細粒度接口。不應該強迫客戶端依賴於它們不使用的接口。

這個原則是為了克服實現大接口的缺點。讓我們看看下面的 IShape 接口:

<code>interface IShape {
drawCircle();
drawSquare();
drawRectangle();
}/<code>

這個接口可以繪製正方形、圓形、矩形。實現 IShape 接口的類 Circle、Square 和 Rectangle 必須定義方法 drawCircle()、drawSquare()、drawRectangle()。

<code>class Circle implements IShape {
drawCircle(){
//...
}
drawSquare(){
//...
}
drawRectangle(){
//...
}
}
class Square implements IShape {
drawCircle(){
//...
}
drawSquare(){
//...
}

drawRectangle(){
//...
}
}
class Rectangle implements IShape {
drawCircle(){
//...
}
drawSquare(){
//...
}
drawRectangle(){
//...
}
}/<code>

上面的代碼很有趣。類 Rectangle 實現了它沒有使用的方法 drawCircle 和 drawSquare,同樣,Square 實現了 drawCircle 和 drawRectangle,Circle 實現了 drawSquare 和 drawRectangle。

如果我們向 IShape 接口添加另一個方法,比如 drawTriangle():

<code>interface IShape {
drawCircle();
drawSquare();
drawRectangle();
drawTriangle();
}/<code>

那麼,這些類就必須實現新方法,否則就會拋出錯誤。

我們看到,不可能實現這樣一種形狀類,它可以畫圓,但不能畫矩形、正方形或三角形。我們在實現方法時可以只拋出一個錯誤,表明操作無法執行。

ISP 反對 IShape 接口的這種設計。客戶端(這裡是 Rectangle、Circle 和 Square)不應該被迫依賴於它們不需要或不使用的方法。另外,ISP 指出,接口應該只執行一個任務(就像 SRP 原則一樣),任何額外的行為都應該抽象到另一個接口中。

在這裡,我們的 IShape 接口執行了應該由其他接口獨立處理的操作。為了使 IShape 接口符合 ISP 原則,我們將對不同接口的操作進行隔離:

<code>interface IShape {
draw();
}
interface ICircle {
drawCircle();
}
interface ISquare {
drawSquare();
}
interface IRectangle {
drawRectangle();
}
interface ITriangle {
drawTriangle();
}
class Circle implements ICircle {
drawCircle() {
//...
}
}
class Square implements ISquare {
drawSquare() {
//...
}
}
class Rectangle implements IRectangle {
drawRectangle() {
//...
}
}
class Triangle implements ITriangle {
drawTriangle() {
//...
}
}
class CustomShape implements IShape {
draw(){
//...
}
}/<code>

ICircle 接口僅處理圓的繪製,IShape 處理任何形狀的繪製,ISquare 只處理正方形的繪製,IRectangle 處理矩形的繪製。

或者,類(Circle、Rectangle、Square、Triangle)必須繼承 IShape 接口,並實現自己的繪製行為。

<code>class Circle implements IShape {
draw(){
//...
}
}

class Triangle implements IShape {
draw(){
//...
}
}

class Square implements IShape {
draw(){
//...
}
}

class Rectangle implements IShape {
draw(){
//...
}
} /<code>

然後,我們可以使用 I- 接口創建具體的形狀,如半圓、直角三角形、等邊三角形、鈍邊矩形等。

依賴倒置原則(DIP)

  • 依賴應該是抽象的,而不是具體的。
  • 高級模塊不應該依賴於低級模塊。兩者都應該依賴於抽象。
  • 抽象不應該依賴於細節。細節應該依賴於抽象。

在軟件開發中,我們的應用程序最終主要是由模塊組成。當這種情況出現時,我們必須使用依賴注入來解決。高級組件依賴於低級組件發揮作用。

<code>class XMLHttpService extends XMLHttpRequestService {}
class Http {
constructor(private xmlhttpService: XMLHttpService) { }
get(url: string , options: any) {
this.xmlhttpService.request(url,'GET');
}
post() {
this.xmlhttpService.request(url,'POST');
}
//...
}/<code>

這裡,Http 是高級組件,而 HttpService 是低級組件。這種設計違反了 DIP A:高級模塊不應該依賴於低級模塊。它應該依賴於它的抽象。

該 Http 類被迫依賴於 XMLHttpService 類。如果我們要修改 Http 連接服務,也許我們想通過 Nodejs 連接到互聯網,甚至模擬 http 服務。我們將不得不費力地遍歷所有 Http 實例來編輯代碼,這違反了 OCP 原則。

Http 類不應該關心使用的 Http 服務的類型。我們做了一個 Connection 接口:

<code>interface Connection {
request(url: string, opts:any);
}/<code>

Connection 接口有一個 request 方法。有了這個接口,我們就可以向 Http 類傳遞一個 Connection 類型的參數:

<code>class Http {
constructor(private httpConnection: Connection) { }
get(url: string , options: any) {
this.httpConnection.request(url,'GET');
}
post() {
this.httpConnection.request(url,'POST');
}
//...
}/<code>

因此,無論傳遞給 Http 類的 Http 連接服務是什麼類型,它都可以輕鬆地連接到網絡,而無需知道網絡連接的類型。

現在,我們重新實現 XMLHttpService 類來實現 Connection 接口:

<code>class XMLHttpService implements Connection {
const xhr = new XMLHttpRequest();
//...
request(url: string, opts:any) {
xhr.open();
xhr.send();
}
}/<code>

我們可以創建許多 Http 連接類型,並將其傳遞給 Http 類,而不必擔心錯誤。

<code>class NodeHttpService implements Connection {
request(url: string, opts:any) {
//...
}
}
class MockHttpService implements Connection {
request(url: string, opts:any) {
//...
}
}/<code>

現在,我們可以看到,高級模塊和低級模塊都依賴於抽象。Http 類(高級模塊)依賴於 Connection 接口(抽象),而 Http 服務類型(低級模塊)也依賴於 Connection 接口(抽象)。

此外,DIP 原則會強制我們遵循里氏替換原則:Connection 類型 Node-XML-MockHttpService 可以替換它們的父類型連接。

小結

本文介紹了每個軟件開發人員都必須遵守的五個原則。首先,要遵守所有這些原則可能會令人生畏,但是隨著不斷的實踐和堅持,它們會成為我們的一部分,並將對應用程序的維護產生巨大的影響。

關於這些原則,如果你覺得有什麼需要添加、糾正或刪除,請在下面的評論區留言,我非常樂意與你討論!

https://blog.bitsrc.io/solid-principles-every-developer-should-know-b3bfa96bb688


分享到:


相關文章: