SOLID 原則 - Single Responsibility Principle

SOLID 原則 - Single Responsibility

SOLID 代表著物件導向中,五種不同的開發原則,分別是:

  1. Single Responsibility Principle (單一職責原則)
  2. Open Closed Principle (開放封閉原則)
  3. Liskov Substitution Principle (里氏替換原則)
  4. Interface Segregation Principle (介面隔離原則)
  5. Dependency Inversion Principle (依賴反轉原則)

對我來說,在開發過程中嚴守這五種原則,比起去運用某些Design Pattern 來得重要許多,但並不是說Design Pattern 不重要。

而是Design Pattern 的實踐,有很大的部分就是讓你不違反 SOLID 原則。

Single Responsibility

A class should have one, and only one, reason to change.
一個class 應該只做一件事

降低類別對於其他事物的依賴程度,便是Single Responsibility 的核心準則。

一個未遵守Single Responsibility 開發出來的Class,像一個多功能的瑞士刀。
single-responsibility-swiss-army-knife

一個擁有各式各樣功能的Class,雖然酷炫華麗,但是有太多的要素會影響到該Class 功能,每一次的修改,便需重寫一隻測試程式來確保功能的正確性。

要做的事情很簡單:

Do one thing and do it well.

Example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php

class SalesReporter
{
public function between($startDate, $endDate)
{
$sales = $this->queryDBForSalesBetween($startDate, $endDate);

return $this->format($sales);
}

protected function queryDBForSalesBetween($startDate, $endDate)
{
return DB::table('sales')
->whereBetween('created_at', [$startDate, $endDate])
->sum('charge') / 100;
}

protected function format($sales)
{
return "<h1>Sales: " . $sales . "</h1>";
}
}

還記得上方提到的嗎?
A class should have one, and only one, reason to change.

這個範例已經違反了Single Responsibility 原則

  1. 如果要改變資料的取得,必須改變這個Class
  2. 如果要改變輸出的方式,必須改變這個Class

我們有兩種consumers 會影響到Class 的生成結構。

Solution

改變資料的取得

1
2
3
4
5
6
protected function queryDBForSalesBetween($startDate, $endDate)
{
return DB::table('sales')
->whereBetween('created_at', [$startDate, $endDate])
->sum('charge') / 100;
}

我們可以使用Repository 來取得資料

1
2
3
4
5
6
7
8
9
10
11
<?php

class SalesRepository
{
public function between($startDate, $endDate)
{
return DB::table('sales')
->whereBetween('created_at', [$startDate, $endDate])
->sum('charge') / 100;
}
}

因此,SalesReporter 則修改成如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php

class SalesReporter
{
private $repo;

public function __construct(SalesRepository $repo)
{
$this->repo = $repo;
}

public function between($startDate, $endDate)
{
$sales = $this->repo->between($startDate, $endDate);

return $this->format($sales);
}

protected function format($sales)
{
return "<h1>Sales: " . $sales . "</h1>";
}
}

有發現到嗎?我們把取得資料的相依關係透過SalesRepository 來處理

如此一來,當我們要更改取得資料這段邏輯時,便不需要更改 SalesReporter 了。

改變輸出的方式

1
2
3
4
protected function format($sales)
{
return "<h1>Sales: " . $sales . "</h1>";
}

為什麼這段會有問題呢?

當我們不想透過HTML 輸出,而是要改成CSV 或者Json 格式來輸出,要怎麼處理

是不是只能繼續擴充 method,例如:

1
2
3
4
5
6
7
8
9
protected function formatHtml($sales)
{
return "<h1>Sales: " . $sales . "</h1>";
}

protected function formatJson($sales)
{
return json_encode($sales);
}

我們可以預期有很多不同的輸出方式,因此可以將輸出的方式抽象出來:

1
2
3
4
interface SalesOutputInterface
{
public function output($sales);
}

而當需要輸出成HTML 格式時,只要擴充Class 即可:

1
2
3
4
5
6
7
class HtmlOutput implements SalesOutputInterface
{
public function output($sales)
{
return "<h1>Sales: " . $sales . "</h1>";
}
}

或者是你想輸出成Json 格式時:

1
2
3
4
5
6
7
class JsonOutput implements SalesOutputInterface
{
public function output($sales)
{
return json_encode($sales);
}
}

因此,SalesReporter 便能修改成如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php

class SalesReporter
{
private $repo;

public function __construct(SalesRepository $repo)
{
$this->repo = $repo;
}

public function between($startDate, $endDate, SalesOutputInterface $formatter)
{
$sales = $this->repo->between($startDate, $endDate);

return $formatter->output($sales);
}
}

將輸出的方法獨立出來,降低 SalesReporter 對於輸出的依賴關係

這樣一來 SalesReporter 的consumers 能降低,對於擴充的彈性也能變大。

最後新版的SalesReporter 使用方法如下:

1
2
3
4
5
6
7
8
9
function foo()
{
$report = new SalesReporter(new SalesRepository);

$begin = Carbon::now()->subDays(10);
$end = Carbon::now();

return $report->between($begin, $end, new HtmlOutput);
}