分离模块之间的关注点
原则:
避免形成大型模块,以便能达到模块之间的松耦合。
不同的职责分给不同的模块,隐藏接口的内部实现细节。
之前说的是代码单元层面,这里开始说模块层面。对应C#的类的概念。
一个真实的案例,先说明类之间的紧耦合是什么样子,为何对导致可维护性问题。
一个UserService类,位于某web应用程序的服务层,在开发过程中变得越来越庞大,最终违法了本章的原则。
第一次迭代中,该类只有三个方法,如下
public class UserSerivce
{
public User LoadUser(string userID)
{
//...
}
public bool DoesUserExist(string userID)
{
//...
}
public User CHnageUserInfo(UserInfo userInfo)
{
//...
}
}
该案例的web应用程序为前端和其他系统提供了一个REST接口。REST层的类通过UserService类来实现对用户的操作。
public class UserController : System.Web.Http.ApiController
{
private readonly UserSerivce userSerivce = new UserSerivce();
public System.Web.Http.IHttpActionResult GetUserByID(string id)
{
User user = userSerivce.LoadUser(id);
if(user==null)
{
return NotFound();
}
return Ok(user);
}
}
第二次迭代中,没有对UserService进行修改。在第三次迭代中,实现新需求,允许用户注册,以便能够接收特定的通知。此时要加三个新方法在UserService类。
public class UserSerivce
{
public User LoadUser(string userID)
{
//...
}
public bool DoesUserExist(string userID)
{
//...
}
public User CHnageUserInfo(UserInfo userInfo)
{
//...
}
//第三次迭代新增
public List<NotificationType> GetNotificationTypes(User user)
{
//...
}
public void RegisterForNotifications(User user, NotificationType type)
{
//...
}
public void UnregisterForNotifications(User user, NotificationType type)
{
//...
}
}
这几个方法同样通过独立的rest api对外暴露。
public class NotificationController : System.Web.Http.ApiController
{
private readonly UserSerivce userSerivce = new UserSerivce();
public System.Web.Http.IHttpActionResult Register(string id, string notificationtype)
{
User user = userSerivce.LoadUser(id);
userSerivce.RegisterForNotifications(user, notificationtype);
return Ok();
}
public System.Web.Http.IHttpActionResult Unregister(string id, string notificationtype)
{
User user = userSerivce.LoadUser(id);
userSerivce.UnregisterForNotifications(user, notificationtype);
return Ok();
}
}
第四次迭代中,增加新需求,搜索用户,锁定用户,列举所有被锁定的用户,这些都要在UserSerivce类里增加方法。
public class UserSerivce
{
public User LoadUser(string userID)
{
//...
}
public bool DoesUserExist(string userID)
{
//...
}
public User CHnageUserInfo(UserInfo userInfo)
{
//...
}
//第三次迭代新增
public List<NotificationType> GetNotificationTypes(User user)
{
//...
}
public void RegisterForNotifications(User user, NotificationType type)
{
//...
}
public void UnregisterForNotifications(User user, NotificationType type)
{
//...
}
//第四次迭代新增
public List<User> SearchUsers(UserInfo userInfo)
{
//...
}
public void BlockUser(User user)
{
//...
}
public List<User> GetAllBlockedUsers()
{
//...
}
}
此时最后的迭代已经让这个类增长到了一个客观的体积,称为了系统服务层中的最常用的一个服务,3个前端视图(个人档案,通知,搜索页面),分别通过3个rest api来使用UserService,来自其他类的调用数量也增长到了50个以上。该类中代码包含了太多的功能,知道实现的细节,结果是该类与其他类紧紧耦合,它使用了不同的数据层类,来完成用户的个人档案管理,通知系统和搜索,锁定其他用户等。
要理解类之间的耦合关系的重要性,必须理解两个原则:
耦合是一个源代码类层面的问题。
紧耦合还是松耦合是一个程度上的事情,紧耦合对可维护性造成的影响取决于调用该类的数量以及该类的体积,对一个紧耦合类的调用次数越多,这个类的体积应该越小。
松耦合的模块允许开发人员独立进行工作;降低了浏览代码库的难度;避免了让新人感到手足无措。
帮助避免类之间紧耦合的三个最佳实践:
根据不同关注点拆分类
之前的UserService类拆成两个新类:
public class UserNotificationService
{
public List<NotificationType> GetNotificationTypes(User user)
{
//...
}
public void RegisterForNotifications(User user, NotificationType type)
{
//...
}
public void UnregisterForNotifications(User user, NotificationType type)
{
//...
}
}
public class UserBlockService
{
public void BlockUser(User user)
{
//...
}
public List<User> GetAllBlockedUsers()
{
//...
}
}
public class UserSerivce
{
public User LoadUser(string userID)
{
//...
}
public bool DoesUserExist(string userID)
{
//...
}
public User CHnageUserInfo(UserInfo userInfo)
{
//...
}
//第四次迭代新增
public List<User> SearchUsers(UserInfo userInfo)
{
//...
}
}
当我们从REST API调用这些类时,现在的系统有了更加松耦合的实现。UserService类并不知道通知系统,也不知道锁定用户的逻辑,开发人员现在可以将新的功能放在独立的类中。
隐藏接口背后的特定实现
假设有一个如下类,实现了一个数码相机的功能,可以通过打开或关闭闪光灯来拍摄照片。
public class DigitalCamera
{
public Image TakeSnapshot()
{
//...
}
public void FlashLightOn()
{
//...
}
public void FlashLightOff()
{
//...
}
}
假设这些代码运行在智能移送设备的某个应用中,
public class SmartphoneApp
{
private static DigitalCamera camera = new DigitalCamera();
public static void Main(string[] args)
{
//...
Image image = camera.TakeSnapshot();
//...
}
}
现在有了一个更高级的数码相机,除了能拍照之外,还可以录制视频,定时器,并可以缩放,那么对DigitalCamera类扩展。
public class DigitalCamera
{
public Image TakeSnapshot()
{
//...
}
public void FlashLightOn()
{
//...
}
public void FlashLightOff()
{
//...
}
public Image TakePanoramaSnapshot()
{
//...
}
public Video Record()
{
//...
}
public void ZoomIn()
{
//...
}
public void ZoomOut()
{
//...
}
}
为了降低耦合程度,使用一个接口定义几个基础相机和高级相机都需要实现的功能列表。
public interface ISimpleDigitalCamera
{
Image TakeSnapshot();
void FlashLightOn();
void FlashLightOff();
}
public class DigitalCamera : ISimpleDigitalCamera
{
//...
}
public class SmartphoneApp
{
private static ISimpleDigitalCamera camera = SDK.GetCamera();
public static void Main(string[] args)
{
//...
Image image = camera.TakeSnapshot();
//...
}
}
这种方式只使用基本数码相机功能的类,不再需要知道任何高级数码相机所具有的功能。SmartphoneApp只访问ISimpleDigitalCamera接口,保证了SmartphoneApp不适用任何高级相机的方法。
对一个类的修改会对其他类造成最小的影响。增加可可修改性。
可以使用第三方库、框架来替代自定义代码,这通常会导致模块紧耦合,比如StringUtils和FileUtils,这些类提供通用功能,被许多其他代码调用,很多情况下,这种紧耦合很难避免,
一个最佳的实践是保持这些类的体积限制在一个有限范围内,经常地检查其他开源库和框架是否可以替换掉这些自定义的实现。
常见反对意见
松耦合与代码重用相冲突
代码重用并不一定会导致方法被尽可能多地调用。好的软件设计——使用继承和接口,可以在既达到代码重用的同时,又保证代码实现之间的松耦合,因为接口隐藏掉了实现细节。
为了让代码更加通用,用更少的代码来解决更多的问题,并不意味着就应该变得紧耦合,显然相比业务方法来说,工具方法被调用的地方会更多。工具方法应该包含更少的代码,虽然可能有许多来自外部的调用,但是都会指向到一小段的代码中。
C#接口并不适合松耦合
使用接口来隐藏实现是一个提高封装性的有效方式,不应该为每一个类都提供一个接口,根据经验,一个接口至少要被两个类实现,如果为某个类添加接口的唯一原因是为了限制其他类能看到的代码数量,那么应该考虑拆分这个类。
对工具类的高度使用是不可避免的
没错,在实践中,即便是可维护性非常好的代码库,也一样包含一些非常通用,被各处使用的代码,比如日志或IO代码。高度通用,可重用的代码应该尽可能小,其中一些可能也真的无法避免,但是i如果某个功能真的很常用,可能某个框架或者库已经实现了这个功能,应该尽量去选择它们。
不是所有的松耦合都会增加可维护性
控制反转是一个实现松耦合的设计原则,IoC让一个系统的扩展更加灵活,并且降低了代码之间相互了解的程度。 对于一些缺乏经验的维护人员,当某些IoC框架增加了代码复杂度时,他们便会产生这样的反对意见,这个问题的根源不在IoC本身,而在于实现它的框架。面对这样的情况,可以谨慎选择实现IoC的框架,很难满足所有情况。是否应当使用这些框架来仅仅达到松耦合,是一个几乎永远无法去评判的选择。
SIG如何评估模块耦合度
要评估模块耦合度,需要将每次方法调用都计算在内。每个模块会根据类中所有方法的总调用次数,被划分4个风险分类。
模块被调用的次数 | 4星评级允许的百分比 |
51+ | 6.6 |
21-50 | 13.8 |
11-20 | 21.6 |
1-10 | 无限制 |