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

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

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

同時應用這些最佳實踐,能夠提高代碼適應變動的能力。可是凡事要有度,過分使用雖然可讓代碼有很高的自適應能力,可是會致使層次粒度太小而難以理解或使用,還會影響代碼的可讀性。數據庫

mark

單一職責原則

單一職責原則(Single Responsibility principle)要求開發人員編寫的代碼有且只有一個變動理由。若是一個類有多個變動理由,那麼它就具備多個職責。這個時候就要進行重構,將多職責類拆解爲多個單職責類。經過委託和抽象,包含多個變動理由的類應該把一個或多個職責委託給其餘的單職責類c#

以前看過一篇文章,講爲何面向對象比面向過程更能適應業務變化?從其中也能夠看出單一職責原則帶來的好處,職責明確,只須要修改局部,不會對外部形成影響,影響能夠控制在足以掌控的範圍內。微信

對象將需求用類一個個隔開,就像用儲物箱把東西一個個封裝起來同樣,需求變了,分幾種狀況,最嚴重的是大變,那麼每一個儲物箱都要打開改,這種方法就不見得有好處;可是這種狀況發生機率比較小,大部分需求變化都是侷限在一兩個儲物箱中,那麼咱們只要打開這兩個儲物箱修改就能夠,不會影響其餘儲物櫃了。app

而面向過程是把全部東西都放在一個大儲物箱中,修改某個部分之後,會引發其餘部分不穩定,一個BUG修復,引起新的無數BUG,最後程序員陷入焦頭爛額。ide

咱們一段代碼爲例,經過重構的過程,體會一下單一職責原則的好處。函數

面向過程編碼

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實例、將交易數據持久化到永久存儲。post

單一職責原則能夠表如今類和方法層面上。從方法的層面上,一個方法只能作一件事情;從類的層面上,一個類只能有一個職責。不然,就要對類和方法進行拆分重構。對於方法的拆分重構,目標是清晰度,能提高代碼的可讀性,可是不能提高代碼的自適應能力。要提高代碼的自適應能力,就要作抽象,將每一個職責劃分到不一樣的類中。學習

重構清晰度

上面咱們分析過ProcessTrades方法代碼能夠分爲三個部分,咱們能夠將每一個部分提取爲一個方法,將工做委託給這些方法,這樣ProcessTrades方法就變成了:this

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

微信公衆號:

聲明:本文爲博主學習感悟總結,水平有限,若是不當,歡迎指正。若是您認爲還不錯,不妨點擊一下下方的推薦按鈕,謝謝支持。轉載與引用請註明做者及出處。

相關文章
相關標籤/搜索