甲方:之前版本挺好的,用起来挺舒服的。不过我这边需要将日志写道邮箱里、通过Http写到指定网站里,写到日志数据库里,我也不要求你都给我写出来,你把接口预留给我,具体的写法我自己来处理吧。
看,我设定的甲方还是很仁慈的,没有要求你去写邮件、网络和数据库部分,实际上这也不是这个Demo的重点。这次需求的重点在于,日志记录库需要有扩展性,理论上来说日志库不可能提供向所有目的地记录日志的功能,因此需要预留接口供他人二次开发。
怎么做呢?可能最先想到的利用继承来解决,通过继承Logger类,向其内部注入新的函数LogToEmail()、LogToHttp()和LogToSqlite()等等。然而,这里有个问题,因为所有的日志记录API都是通过LogToTarget完成的,如果我们添加新目的地的日志写入功能,需要重新编写LogToTarget方法。然而,对该方法的重写是很危险的,就像下面这样,如果不清楚内部逻辑,一旦稍有不慎,就会丢掉原先的逻辑。
public class AdvancedLogger : Logger { ... public void LogToTarget(LogLevel level, string message) { LogToEmail(level, message); LogToHttp(level, message); LogToSqlite(level, message); } private void LogToEmail(LogLevel level, string message) { // 邮件记录 } private void LogToHttp(LogLevel level, string message) { // Http记录 } private void LogToSqlite(LogLevel level, string message) { // Sqlite记录 } }这个继承类有以下几个问题。
它覆盖掉了原有的Logger内中LogToTarget函数的逻辑。为了添加向邮件、Http 以及 sqlite 的支持,则必须要重写这个函数,这很正常,但问题是稍不注意则会丢弃原有的运行逻辑。并且,这种错误并不会在编译期报错,而会在运行期间以不符合预料的行为现象表现出来,增加了软件调试的难度。按照软件设计的理论,我们应该尽可能地让错误尽早暴露出来,将错误由最后运行期暴露不是一个好的方案。
对于后续新增的LogToXXX函数,没有固定好其函数的调用形式。在v5版本中,我们将LogData类对象作为唯一的输入参数输入,由LogData对象内的ToString方法自行将其转换成字符串并交给LogToXXX方法写入对应目的地中,也就是说,日志字符串的生成工作由LogData负责,而具体的LogToXXX则只负责将生成好的字符串写道对应目的地中。但在AdvancedLogger中对其他写入放法的规则是自己编写的,可以使用LogData,也可以使用其他任意值。换句话来说,函数没有一个约束输入参数的规则。
可以看出,继承的做法并不是比较好的方法。那么,有办法做到本轮需求且不引入这两个缺点么?有的。我们先回顾v5版本中的Logger类,我们发现Logger类担任了太多职责了。对于Logger类来说,它既负责了提供调用的 API(LogToTarget、LogXXX)又提供了具体的写入操作(LogToConsole和LogToFiles)。从单一职责的角度来看,这种设计会严重加大类设计的复杂性。如果一个类有太多的职责的话,常规的做法是将其切分成多个类,每个职责一个类,减少类设计的复杂度。从我们的使用场景来看,Logger类应该是提供调用的API而不需要负责具体的写入。Logger类只需要保有对写入对象的引用,在LogToTarget中对其一一引发即可。
那么,负责具体写入的应该是什么呢?通过观察LogToConsole以及LogToFiles可以发现,其函数名均带有一个LogData的输入以及无返回值的输出(void LogToXXX(LogData logData))。那么我们只需要认为负责具体写入的类包含这样的函数即可。考虑到该函数没有默认的方法实现,我们使用接口来描述。
public interface ILogTarget { void WriteLog(LogData logData); }通过继承ILogTarget,我们实现了负责输出到控制台以及文件的类设计:
public class ConsoleTarget : ILogTarget { public void WriteLog(LogData logData) { Console.WriteLine(logData); } } public class FileTarget : ILogTarget { private string _filePath; public FileTarget(string filePath) { _filePath = filePath; } public void WriteLog(LogData logData) { using var fs = new FileStream(_filePath, FileMode.Append); using var sw = new StreamWriter(fs); sw.WriteLine(logData); } }注意一点的是这里的文件写入对象只负责一个文件的写入,而非像以前那样通过提供路径数组写入多个,这样写的好处在于每个FileTarget类对象负责一个文件的写入,不同的文件写入方法有不同的配置,更加灵活。
这样,如果有人需要做二次开发,提供更多写入目的地的实现,只需要继承ILogTarget接口,并编写接口内需要提供的逻辑即可。不仅如此,在继承中可以塞入更多的配置信息,来灵活处理不同的日志写入。例如FileTarget中,通过在构造函数内提供路径来保存每次写入哪个文件。