編碼最佳實踐——單一職責原則

CoderFocus發表於2018-11-05

SOLID是一組最佳編碼實踐的首字母縮寫

  • S 單一職責原則
  • O 開放與封閉原則
  • L Liskov(裡式)替換原則
  • I 介面分離原則
  • D 依賴注入原則

同時應用這些最佳實踐,可以提升程式碼適應變更的能力。但是凡事要有度,過度使用雖然可以讓程式碼有很高的自適應能力,但是會導致層次粒度過小而難以理解或使用,還會影響程式碼的可讀性。

mark

單一職責原則

單一職責原則(Single Responsibility principle)要求開發人員編寫的程式碼有且只有一個變更理由。如果一個類有多個變更理由,那麼它就具有多個職責。這個時候就要進行重構,將多職責類拆解為多個單職責類。通過委託和抽象,包含多個變更理由的類應該把一個或多個職責委託給其他的單職責類

之前看過一篇文章,講為什麼物件導向比程式導向更能適應業務變化?從其中也可以看出單一職責原則帶來的好處,職責明確,只需要修改區域性,不會對外部造成影響,影響可以控制在足以掌控的範圍內。

物件將需求用類一個個隔開,就像用儲物箱把東西一個個封裝起來一樣,需求變了,分幾種情況,最嚴重的是大變,那麼每個儲物箱都要開啟改,這種方法就不見得有好處;但是這種情況發生概率比較小,大部分需求變化都是侷限在一兩個儲物箱中,那麼我們只要開啟這兩個儲物箱修改就可以,不會影響其他儲物櫃了。

而程式導向是把所有東西都放在一個大儲物箱中,修改某個部分以後,會引起其他部分不穩定,一個BUG修復,引發新的無數BUG,最後程式設計師陷入焦頭爛額。

我們一段程式碼為例,通過重構的過程,體會一下單一職責原則的好處。

程式導向編碼

public class TradeRecord
{
    public int TradeAmount { get; set; }

    public decimal TradePrice { get; set; }
}
複製程式碼
public class TradeProcessor
{
    public void ProcessTrades(Stream stream)
    {
        var lines = new List<string>();

        using (var reader = new StreamReader(stream))
        {
            string line;
            while((line =reader.ReadLine()) != null)
            {
                lines.Add(line);
            }
        }

        var trades = new List<TradeRecord>();
        var lineCount = 1;
        foreach (var line in lines)
            {
                var fields = line.Split(new char[] { ',' });

                if(fields.Length != 3 )
                {
                    Console.WriteLine("WARN: Line {0} malformed. Only {1} fields found",lineCount, fields.Length);
                }

                int tradeAmount;
                if (!int.TryParse(fields[0], out tradeAmount))
                {
                    Console.WriteLine("WARN: Trade amount on line {0} not a valid integer :{1}",lineCount, fields[0]);
                }

                decimal tradePrice;
                if (!decimal.TryParse(fields[1], out tradePrice))
                {
                    Console.WriteLine("WARN: Trade Price on line {0} not a valid decimal :{1}", lineCount, fields[1]);
                }

                var tradeRecord = new TradeRecord
                {
                    TradeAmount = tradeAmount,
                    TradePrice = tradePrice
                };
                trades.Add(tradeRecord);
                lineCount++;
            }
        
        using (var connection = new SqlConnection("DataSource=(local);Initial Catalog=TradeDataBase;Integrated Security = True;"))
                {
                    connection.Open();
                    using (var transaction = connection.BeginTransaction())
                    {
                        foreach (var trade in trades)
                        {
                            var command = connection.CreateCommand();
                            command.Transaction = transaction;
                            command.CommandType = System.Data.CommandType.StoredProcedure;
                            command.CommandText = "insert_trade";

                            command.Parameters.AddWithValue("@tradeamount", trade.TradeAmount);
                            command.Parameters.AddWithValue("@tradeprice", trade.TradePrice);
                        }
                        transaction.Commit();
                    }
                    connection.Close();
                }

        Console.WriteLine("INFO: {0} trades processed",trades.Count);
    }
}
複製程式碼

上面的程式碼不僅僅是一個類擁有太多的職責,也是一個單一方法擁有太多的職責。仔細分析一下程式碼,原始的ProcessTrades方法程式碼可以分為三個部分:從流中讀取交易資料、將字串資料轉換為TradeRecord例項、將交易資料持久化到永久儲存。

單一職責原則可以表現在類和方法層面上。從方法的層面上,一個方法只能做一件事情;從類的層面上,一個類只能有一個職責。否則,就要對類和方法進行拆分重構。對於方法的拆分重構,目標是清晰度,能提升程式碼的可讀性,但是不能提升程式碼的自適應能力。要提升程式碼的自適應能力,就要做抽象,將每個職責劃分到不同的類中。

重構清晰度

上面我們分析過ProcessTrades方法程式碼可以分為三個部分,我們可以將每個部分提取為一個方法,將工作委託給這些方法,這樣ProcessTrades方法就變成了:

public void ProcessTrade(Stream stream)
{
    var lines = ReadTradeData(stream);
    var trades = ParseTrades(lines);
    StoreTrades(trades);
}
複製程式碼

提取的方法實現分別為:

/// <summary>
/// 從流中讀取交易資料
/// </summary>
/// <param name="stream"></param>
/// <returns></returns>
private IEnumerable<string> ReadTradeData(Stream stream)
{
    var tradeData = new List<string>();
    using (var reader = new StreamReader(stream))
    {
        string line;
        while ((line = reader.ReadLine()) != null)
        {
            tradeData.Add(line);
        }
    }
    return tradeData;
}
複製程式碼
/// <summary>
/// 將字串資料裝換位TradeRecord例項
/// </summary>
/// <param name="tradeData"></param>
/// <returns></returns>
private IEnumerable<TradeRecord> ParseTrades(IEnumerable<string> tradeData)
{
    var trades = new List<TradeRecord>();
    var lineCount = 1;
    foreach (var line in tradeData)
    {
        var fields = line.Split(new char[] { ',' });

        if(!ValidateTradeData(fields,lineCount))
        {
            continue;
        }

        var tradeRecord = MapTradeDataToTradeRecord(fields);
        trades.Add(tradeRecord);

        lineCount++;
    }
    return trades;
}
複製程式碼
/// <summary>
/// 交易資料持久化
/// </summary>
/// <param name="trades"></param>
private void StoreTrades(IEnumerable<TradeRecord> trades)
{
    using (var connection = new SqlConnection("DataSource=(local);Initial Catalog=TradeDataBase;Integrated Security = True;"))
    {
        connection.Open();
        using (var transaction = connection.BeginTransaction())
        {
            foreach (var trade in trades)
            {
                var command = connection.CreateCommand();
                command.Transaction = transaction;
                command.CommandType = System.Data.CommandType.StoredProcedure;
                command.CommandText = "insert_trade";

                command.Parameters.AddWithValue("@tradeamount", trade.TradeAmount);
                command.Parameters.AddWithValue("@tradeprice", trade.TradePrice);
            }
            transaction.Commit();
        }
        connection.Close();
    }

    Console.WriteLine("INFO: {0} trades processed", trades.Count());
}
複製程式碼

其中ParseTrades方法的實現比較特殊,負責的是將字串資料轉換為TradeRecord例項,包含資料的驗證和例項的建立。同理,將這些工作委託給了ValidateTradeData方法和MapTradeDataToTradeRecord方法。ValidateTradeData方法負責資料的驗證,只有合法的資料格式才能繼續組裝為TradeRecord例項,不合法的資料將會被記錄在日誌中。ValidateTradeData方法將記錄日誌的工作也委託給了LogMessage方法,具體實現如下:

/// <summary>
/// 驗證交易資料
/// </summary>
/// <param name="fields"></param>
/// <param name="currentLine"></param>
/// <returns></returns>
private bool ValidateTradeData(string[] fields,int currentLine)
{
    if (fields.Length != 3)
    {
        LogMessage("WARN: Line {0} malformed. Only {1} fields found", currentLine, fields.Length);
        return false;
    }

    int tradeAmount;
    if (!int.TryParse(fields[0], out tradeAmount))
    {
        LogMessage("WARN: Trade amount on line {0} not a valid integer :{1}", currentLine, fields[0]);
        return false;
    }

    decimal tradePrice;
    if (!decimal.TryParse(fields[1], out tradePrice))
    {
        LogMessage("WARN: Trade Price on line {0} not a valid decimal :{1}", currentLine, fields[1]);
        return false;
    }
    return true;
}
複製程式碼
/// <summary>
/// 組裝TradeRecord例項
/// </summary>
/// <param name="fields"></param>
/// <returns></returns>
private TradeRecord MapTradeDataToTradeRecord(string[] fields)
{
    int tradeAmount = int.Parse(fields[0]);
    decimal tradePrice = decimal.Parse(fields[1]);
    var tradeRecord = new TradeRecord
    {
        TradeAmount = tradeAmount,
        TradePrice = tradePrice
    };
    return tradeRecord;
}
複製程式碼
/// <summary>
/// 記錄日誌
/// </summary>
/// <param name="message"></param>
/// <param name="args"></param>
private void LogMessage(string message,params object[] args)
{
    Console.WriteLine(message,args);
}
複製程式碼

重構清晰度之後,程式碼的可讀性提高了,但是自適應能力並沒有提升多少。方法做到了只做一件事情,但是類的職責並不單一。還所以,要繼續重構抽象。

重構抽象

重構TradeProcessor抽象的第一步就是設計一個或一組介面來執行三個最高階別的任務:讀取資料、處理資料和儲存資料。

mark

public class TradeProcessor
{
    private readonly ITradeDataProvider tradeDataProvider;
    private readonly ITradeParser tradeParser;
    private readonly ITradeStorage tradeStorage;

    public TradeProcessor(ITradeDataProvider tradeDataProvider,
        ITradeParser tradeParser,
        ITradeStorage tradeStorage)
    {
        this.tradeDataProvider = tradeDataProvider;
        this.tradeParser = tradeParser;
        this.tradeStorage = tradeStorage;
    }

    public void ProcessTrades()
    {
        var tradeData = tradeDataProvider.GetTradeData();
        var trades = tradeParser.Parse(tradeData);
        tradeStorage.Persist(trades);
    }
}
複製程式碼

作為客戶端的TradeProcessor類現在不清楚,當然也不應該清楚StreamTradeDataProvider類的實現細節,只能通過ITradeDataProvider介面的GetTradeData方法來獲取資料。TradeProcesso將不再包含任何交易流程處理的細節實現,取而代之的是整個流程的藍圖

對於ITradeparser介面的實現Simpleradeparser類,還可以繼續提取更多的抽象,重構之後的UML圖如下。ITradeMapper負責資料格式的對映轉換,ITradeValidator負責資料的驗證。

mark

public class TradeParser : ITradeParser
{
    private readonly ITradeValidator tradeValidator;
    private readonly ITradeMapper tradeMapper;
    public TradeParser(ITradeValidator tradeValidator, ITradeMapper tradeMapper)
    {
        this.tradeValidator = tradeValidator;
        this.tradeMapper = tradeMapper;
    }

    public IEnumerable<TradeRecord> Parse(IEnumerable<string> tradeData)
    {
        var trades = new List<TradeRecord>();
        var lineCount = 1;
        foreach (var line in tradeData)
        {
            var fields = line.Split(new char[] { ',' });

            if (!tradeValidator.Validate(fields, lineCount))
            {
                continue;
            }

            var tradeRecord = tradeMapper.MapTradeDataToTradeRecord(fields);
            trades.Add(tradeRecord);

            lineCount++;
        }
        return trades;
    }
}
複製程式碼

類似於上面將職責抽象為介面(及其實現)的過程是遞迴的。在檢視每個類時,你需要判斷它是否具備多重職責。如果是,提取抽象直到該類只具備單個職責。

重構抽象完成後的整個UML圖如下:

mark

需要注意的是,記錄日誌等一般需要依賴第三方程式集。對於第三方引用,應該通過包裝的方式轉換為第一方引用。這樣對於第三方的依賴可以被有效控制,在可預見的將來,替換第三方引用將會變得十分容易(只需要替換一處),否則專案中可能到處是對第三方引用的直接依賴。包裝一般是通過介面卡模式,此處使用的是物件介面卡模式。

mark

注意,示例中的程式碼實現對於依賴的抽象(介面),都是通過建構函式傳入的,也就是說物件依賴的具體實現在物件建立時就已經確定了。有兩種選擇,一是客戶端傳入手動建立的依賴物件(窮人版的依賴注入),二是使用IOC容器(依賴注入)。

需求變更

重構抽象後的新版本能在無需改變任何現有類的情況下實現以下的需求增強功能。我們可以模擬需求變更來體驗以下程式碼的自適應能力。

  • 當輸入資料的驗證規則變化時

    修改ITradeValidator介面的實現以反映最新的規則。

  • 當更改日誌記錄方式時,由視窗列印方式改為檔案記錄方式

    建立一個檔案記錄的FileLogger類實現檔案記錄日誌的功能,替換ILogger的具體實現。

  • 當資料庫發生了變化,例如使用文件資料庫替換關係型資料庫

    建立MongoTradeStorage類使用MongoDB儲存交易資料,替換ITradeStorage的具體實現。

最後

我們發現,符合單一職責原則的程式碼會由更多的小規模但目標更明確的類組成,然後通過介面抽象以及在執行時將無關功能的責任委託給相應的介面來達成目標的。更多的小規模但目標更明確的類通過自由組合的形式配合完成任務,每個類都可以看做是一個小零件,而介面就是生產這些零件的模具。當這個零件不再適合完成此任務時,就可以考慮替換掉這個零件,前提是替換前後的零件都是通過同一個模具生產出來的。

聰明的人從來不會把雞蛋放到同一個籃子裡,但是更聰明的人會考慮把這些籃子放到不同的車上。我們應該做更聰明的人,而不是每次系統出現問題時,在義大利麵條式的程式碼裡一遍又一遍的DeBug。

參考

《C#敏捷開發實踐》

作者:CoderFocus
微信公眾號:

編碼最佳實踐——單一職責原則


相關文章