编码最佳实践——单一职责原则

栏目: 数据库 · 发布时间: 6年前

内容简介:SOLID是一组同时应用这些最佳实践,可以提升代码适应变更的能力。但是凡事要有度,过度使用虽然可以让代码有很高的自适应能力,但是会导致层次粒度过小而难以理解或使用,还会影响代码的可读性。单一职责原则(Single Responsibility principle)要求开发人员编写的代码

SOLID是一组 最佳编码实践 的首字母缩写

  • S 单一职责原则
  • O 开放与封闭原则
  • L Liskov(里式)替换原则
  • I 接口分离原则
  • D 依赖注入原则

同时应用这些最佳实践,可以提升代码适应变更的能力。但是凡事要有度,过度使用虽然可以让代码有很高的自适应能力,但是会导致层次粒度过小而难以理解或使用,还会影响代码的可读性。

编码最佳实践——单一职责原则

单一职责原则

单一职责原则(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抽象的第一步就是设计一个或一组接口来执行三个最高级别的任务:读取数据、处理数据和存储数据。

编码最佳实践——单一职责原则
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负责数据的验证。

编码最佳实践——单一职责原则
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图如下:

编码最佳实践——单一职责原则

需要注意的是,记录日志等一般需要依赖第三方程序集。 对于第三方引用,应该通过包装的方式转换为第一方引用 。这样对于第三方的依赖可以被有效控制,在可预见的将来,替换第三方引用将会变得十分容易(只需要替换一处),否则项目中可能到处是对第三方引用的直接依赖。包装一般是通过适配器模式,此处使用的是对象适配器模式。

编码最佳实践——单一职责原则

注意,示例中的代码实现对于依赖的抽象(接口),都是通过构造函数传入的,也就是说对象依赖的具体实现在对象创建时就已经确定了。有两种选择,一是客户端传入手动创建的依赖对象(穷人版的依赖注入),二是使用IOC容器(依赖注入)。

需求变更

重构抽象后的新版本能在无需改变任何现有类的情况下实现以下的需求增强功能。我们可以模拟需求变更来体验以下代码的自适应能力。

  • 当输入数据的验证规则变化时

    修改ITradeValidator接口的实现以反映最新的规则。

  • 当更改日志记录方式时,由窗口打印方式改为文件记录方式

    创建一个文件记录的FileLogger类实现文件记录日志的功能,替换ILogger的具体实现。

  • 当数据库发生了变化,例如使用文档数据库替换关系型数据库

    创建MongoTradeStorage类使用 MongoDB 存储交易数据,替换ITradeStorage的具体实现。

最后

我们发现,符合单一职责原则的代码会由 更多的小规模但目标更明确的类 组成,然后通过接口抽象以及在运行时将无关功能的责任委托给相应的接口来达成目标的。更多的小规模但目标更明确的类通过 自由组合 的形式配合完成任务,每个类都可以看做是一个小零件,而接口就是生产这些零件的模具。当这个零件不再适合完成此任务时,就可以考虑替换掉这个零件,前提是替换前后的零件都是通过同一个模具生产出来的。

聪明的人从来不会把鸡蛋放到同一个篮子里,但是更聪明的人会考虑把这些篮子放到不同的车上。我们应该做更聪明的人,而不是每次系统出现问题时,在意大利面条式的代码里一遍又一遍的DeBug。


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

冒号课堂

冒号课堂

郑晖 / 电子工业出版社 / 2009-10 / 65.00元

《冒号课堂》以课堂对话的形式,借六位师生之口讲述编程要义。上篇对编程范式作了入门性的介绍,并对一些流行的编程语言进行了简评;下篇侧重阐发软件设计思想,其中在范式上以OOP为主,在语言上以C++、Java和C#为主。全书寓庄于谐,深入浅出,既可开阔眼界,又能引发思考,值得编程爱好者品读。一起来看看 《冒号课堂》 这本书的介绍吧!

RGB转16进制工具
RGB转16进制工具

RGB HEX 互转工具

SHA 加密
SHA 加密

SHA 加密工具