编码最佳实践——单一职责原则
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。
参考
《C#敏捷开发实践》