0%

SOLID 筆記 - (4)單一責任原則 SRP Single Responsibility Principle

1. 單一責任原則 SRP Single Responsibility Principle

  • A class should have only one reason to change
  • 一個類別應該只有一個改變的理由!

2. 何謂 “責任” (Responsibility)

  • 責任 = reason to change (改變的理由)
  • 當一個類別擁有多個不同的責任
    • 意味著一個類別負責多項不同的工作
    • 當需求變更時,更動一個類別的理由也可能不只一個

2.1. 隨堂問題

請問以下類別有多少責任?

1
2
3
4
5
6
7
8
9
10
public class OrderManager
{
public bool LoadOrder()
{
// 1.建立資料庫連線(包含寫死的連接字串)
// 2.執行 ADO.NET 資料存取(包含資料篩選)
// 3.附迴圈取得資料(包含資料格式轉換)
// 4.回傳資料
}
}

在這個OrderManager類別中,LoadOrder方法承擔了以下四個主要責任:

  1. 建立資料庫連線:負責建立與資料庫的連接,且連接字串是寫死的,這可能會降低靈活性與可維護性。若資料庫連接資訊有所變更,就需要修改這個方法的程式碼。
  2. 執行資料存取:進行 ADO.NET 資料存取操作,並且還包含了資料篩選的邏輯。這使得資料存取和篩選的邏輯與建立連接及其他操作混合在一起,增加了方法的複雜性。
  3. 循環取得並轉換資料格式:透過循環取得資料,並進行資料格式轉換。這又增加了一個不同的功能到此方法中,使得方法的職責更加多樣化。
  4. 回傳資料:最後,還負責將取得的資料回傳出去。 總的來說,這個方法承擔了過多的責任,違反了單一職責原則。理想情況下,這些不同的功能應該分別由不同的方法或類別來處理,以提高程式碼的可維護性、可讀性以及可測試性。

3. 關於SRP的基本精神

  • 一個”類別”負擔太多責任時,意味著該類別可以被切割
    • 可以透過定義一個全新的”類別”輕鬆做到
    • 對類別進行適度的切割,方便日後管理與維護!

SRP 主要精神就是提高內聚力

高內聚力意味著你可以想到一個清楚的理由去改它!

4. 常見的設計問題

  • 將所有功能寫在一個類別中
  • 類別複雜度過高
  • 維護時經常找不到應該要改哪裡
  • 發生邏輯問題時找不到Bug在哪裡
  • 使用類別時不知道應該要呼叫哪個方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class ReportGenerator
{
public void GenerateReport(Data data)
{
// 處理數據
ProcessData(data);

// 格式化數據
FormatData(data);

// 輸出至PDF
ExportToPDF(data);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class DataProcessor
{
public void Process(Data data) { /* 處理數據 */ }
}

public class DataFormatter
{
public void Format(Data data) { /* 格式化數據 */ }
}

public class PDFExporter
{
public void Export(Data data) { /* 輸出至PDF */ }
}

// 使用這些類別
var processor = new DataProcessor();
var formatter = new DataFormatter();
var exporter = new PDFExporter();

processor.Process(data);
formatter.Format(data);
exporter.Export(data);

5. 關於SRP的使用時機

  • 兩個責任會在不同時間點產生變更需求

    • 當你想改資料庫查詢語法與修改系統紀錄的邏輯時,都會改到同一個類別,那就需要拆開!
  • 類別中有一段程式碼有重複利用的需求

    • 這段程式碼在其他類別也用的到
  • 系統中有個非必要的功能(未來需求),老闆又逼你要實作時

    • 責任會直接依附在類別中,但對維護造成困擾

6. SRP討論事項

  • 你怎樣確認一個類別被賦予了過多的責任?
    • 在開發系統時,不可能完全通盤了解需求才開設計系統。雖然SRP精神很簡單,但實現很難…(面對現況)
  • 套用SRP可能會有副作用,因為類別變多導致耦合力增加。
    • 從類別A中,拆出不同責任方法到類別B(新類別)中,使其關注點分離。
    • image-20241106220831383

7. 關於SRP還需要注意的事

  • 參考YAGNI(You Ain’t Gonna Need It) 原則
    • 不用急於在第一時間就專注於分離責任
    • 尚未出現的需求(未來的需求)不需要預先分離責任
    • 當需求變更的時候,再進行類別分割即可!
  • SRP是SOLID中最簡單的,但卻是最難做到的
    • 需要不斷提升你的開發經驗重構技術
    • 如果你沒有足夠的經驗去定義一個物件的Responsibility,那麼建議你不要過早進行SRP規劃!

7.1. 隨堂測驗

修改前

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Program
{
static void Main()
{
DataAccess.InsertData();
}
}

class DataAccess
{
public static void InsertData()
{
Console.WriteLine("Data inserted into database successfully");
Console.WriteLine("Logged Time:" + DateTime.Now.ToLongTimeString() + " Log Data insertion completed successfully");
}
}

修改後

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class DataAccess
{
public static void InsertData()
{
Console.WriteLine("Data inserted into database successfully");
Logger.writeLog();
}
}
class Logger
{
public static void writeLog()
{
Console.WriteLine("Logged Time:" + DateTime.Now.ToLongTimeString() + "Log Data insertion completed successfully");
}
}

7.2. 隨堂測驗

修改前

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
using System;
using System.Net.Mail;

public class UserRegistrationService
{
public void RegisterUser(string username, string password, string email)
{
// 驗證使用者
if (string.IsNullOrWhiteSpace(username))
throw new Exception("Username is not valid");
if (string.IsNullOrWhiteSpace(password) || password.Length < 6)
throw new Exception("Password must be at least 6 characters");
if (string.IsNullOrWhiteSpace(email) || !email.Contains("@"))
throw new Exception("Email is not valid");

// 將使用者存入資料庫(此處假設使用假代碼)
Console.WriteLine("Saving user to database...");

// 發送確認郵件
SmtpClient client = new SmtpClient();
MailMessage mailMessage = new MailMessage("admin@example.com", email);
mailMessage.Subject = "Registration Confirmation";
mailMessage.Body = "Thank you for registering!";
client.Send(mailMessage);
}
}

修改後

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
using System;
using System.Net.Mail;

public class UserValidator
{
public void Validate(string username, string password, string email)
{
if (string.IsNullOrWhiteSpace(username))
throw new Exception("Username is not valid");
if (string.IsNullOrWhiteSpace(password) || password.Length < 6)
throw new Exception("Password must be at least 6 characters");
if (string.IsNullOrWhiteSpace(email) || !email.Contains("@"))
throw new Exception("Email is not valid");
}
}

public class UserRepository
{
public void SaveUser(string username, string password, string email)
{
// 將使用者存入資料庫(假設此處使用假代碼)
Console.WriteLine("Saving user to database...");
}
}

public class EmailService
{
public void SendConfirmationEmail(string email)
{
SmtpClient client = new SmtpClient();
MailMessage mailMessage = new MailMessage("admin@example.com", email);
mailMessage.Subject = "Registration Confirmation";
mailMessage.Body = "Thank you for registering!";
client.Send(mailMessage);
}
}

public class UserRegistrationService
{
private readonly UserValidator _validator;
private readonly UserRepository _repository;
private readonly EmailService _emailService;

public UserRegistrationService(UserValidator validator, UserRepository repository, EmailService emailService)
{
_validator = validator;
_repository = repository;
_emailService = emailService;
}

public void RegisterUser(string username, string password, string email)
{
_validator.Validate(username, password, email); // 驗證使用者
_repository.SaveUser(username, password, email); // 保存使用者
_emailService.SendConfirmationEmail(email); // 發送確認郵件
}
}

7.3. 隨堂測驗

修改前

1
2
3
4
5
6
7
8
9
public class OrderManager {
public List<Product> products = new List<Product>();
public void Processing()
{
// 1. 檢查商品庫存數量是否足夠
// 2. 執行付款處理程序
// 3. 進行送貨處理程序
}
}

修改後

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class Product { }
public class Customer { }
public class Stock
{
public void CheckAvailability(IEnumerable<Product> products) { }
}
public class Payment
{
public void Processing(Customer customer, IEnumerable<Product> products) { }
}
public class Shipment
{
public void SendProducts(Customer customer, IEnumerable<Product> products) { }
}
public class OrderManager
{
public List<Product> Products { get; set; }
public Customer Customer { get; set; }
public OrderManager()
{
Products = new List<Product>();
}
public void Processing()
{
var stock = new Stock();
stock.CheckAvailability(Products);
var payment = new Payment();
payment.Processing(Customer, Products);
var shipment = new Shipment();
shipment.SendProducts(Customer, Products);
}
}

因為現在OrderManager 耦合 Payment,所以如果後續發生付款方式增加LINE PAY ,則有可能既有其中一個的OrderManager 會被改壞(改A壞B),這時候就需要下一個原則。

8. 補充說明:遇到非必要需求,如何透過SRP抽離與設計

需求情境

假設我們有一個用戶註冊系統,並且需求突然提出希望在用戶註冊完成後額外「記錄用戶行為」(例如記錄註冊時間和 IP 地址)。此功能並非系統核心,僅為輔助紀錄,屬於非必要功能。

修改前

初版的註冊服務可能如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class UserRegistrationService
{
private readonly UserValidator _validator;
private readonly UserRepository _repository;
private readonly EmailService _emailService;

public UserRegistrationService(UserValidator validator, UserRepository repository, EmailService emailService)
{
_validator = validator;
_repository = repository;
_emailService = emailService;
}

public void RegisterUser(string username, string password, string email)
{
// 驗證使用者
_validator.Validate(username, password, email);

// 保存使用者
_repository.SaveUser(username, password, email);

// 發送確認郵件
_emailService.SendConfirmationEmail(email);

// 紀錄用戶行為(非必要功能,耦合進了主要流程)
LogUserActivity(username, email);
}

private void LogUserActivity(string username, string email)
{
Console.WriteLine($"User {username} registered with email {email} at {DateTime.Now}");
}
}

在這個程式碼中,LogUserActivity 被耦合到主業務流程中,增加了 UserRegistrationService 類的複雜度。這違反了 SRP,因為它同時包含了「用戶註冊」和「行為紀錄」兩個職責。

修改後

運用 SRP,我們可以將非必要的用戶行為紀錄功能抽離到一個獨立的類別,並透過依賴注入的方式,在需要時才使用它。

抽離非必要功能

1
2
3
4
5
6
7
public class UserActivityLogger
{
public void LogActivity(string username, string email)
{
Console.WriteLine($"User {username} registered with email {email} at {DateTime.Now}");
}
}

更新 UserRegistrationService

可以通過依賴注入將 UserActivityLogger 類作為可選項添加到 UserRegistrationService 中,讓它變成一個可插拔的功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class UserRegistrationService
{
private readonly UserValidator _validator;
private readonly UserRepository _repository;
private readonly EmailService _emailService;
private readonly UserActivityLogger _activityLogger;

public UserRegistrationService(
UserValidator validator,
UserRepository repository,
EmailService emailService,
UserActivityLogger activityLogger = null) // 可選參數
{
_validator = validator;
_repository = repository;
_emailService = emailService;
_activityLogger = activityLogger;
}

public void RegisterUser(string username, string password, string email)
{
// 驗證使用者
_validator.Validate(username, password, email);

// 保存使用者
_repository.SaveUser(username, password, email);

// 發送確認郵件
_emailService.SendConfirmationEmail(email);

// 如果提供了紀錄器,則記錄用戶行為
_activityLogger?.LogActivity(username, email);
}
}

類別或方法的功能過於複雜:如果一個類別或方法具有多個不相關的功能,這會使得類別變得難以維護、測試和擴展。例如,如果一個類別同時負責數據處理和資料庫儲存,這些職責應分開。

頻繁的變更需求:當一個類別或方法頻繁因不同的需求而修改,這表示它可能擁有多重職責。例如,假設你有一個「報表生成器」類別,既要處理數據格式化,又要負責文件匯出。若需求變更,你可能只想修改數據格式化部分,但匯出功能也可能受到影響。將不同的功能分離,可以讓變更更專注於單一職責,不會影響其他功能。

難以重用:當一個類別包含多種職責,這會限制重用性,因為在新環境中,並不總是需要該類別的所有職責。分離不同的功能後,每個類別只專注於一個職責,使得它們更容易重用。

測試困難:具有多重職責的類別通常難以測試,因為它需要配置不同的依賴關係來涵蓋所有職責。通過實踐SRP,可以使類別更易於單元測試,因為每個類別只需測試其單一職責。

耦合度過高:當某個類別與多個其他類別有大量的依賴或耦合,這表示它可能包含了多個職責。將不同的職責抽取到不同的類別,可以減少耦合,使系統更具可維護性。