广义的代码可维护设计有丰富的层次,它可以包含易理解性设计可测试性设计可移植性设计和可修改性设计。本章节可维护设计指的是可修改(程序容易被修改的程度)设计范畴的内容。

一个可修改的程序往往是可理解的、通用的、灵活的和简明的。所谓通用是指不需要修改程序就可使程序改变功能;所谓灵活是指程序容易被分解和组合。

要度量一个程序的可修改性,可以通过对该程序做少量简单的改变来估算对这个程序改变的困难程度,例如对程序增加新类型的作业、改变输入输出设备、取消输出报告等。如果对于一个简单的改变,程序中必须修改的模块超过30%,则该程序属难于修改之列。

模块设计的内聚、耦合和局部化等因素都会影响软件的可修改性。模块抽象和信息隐蔽越好,模块的独立性越高,则修改时出错的机会也就越少。

编写短小的代码单元

动机:

1、短小的代码单元易于测试

2、短小的代码单元易于拆分

3、短小的代码单元易于重用

代码单元是可独立维护和执行的最小代码集合,短小的代码单元易于理解、测试及重用。你应该编写不超过15行代码的单元,或者将长的单元分解成多个单元。

假设你正在编写一个“JPacman(吃豆人)”项目,起初你的start方法会检查游戏是否开始。目前我们这个代码单元只包含了4行代码,我们可以很轻松的为它添加一个单元测试。

public void Start(){
    if(inProgress){
        return;
    }
    inProgress = true;
}

当你向系统添加新功能时,就会发现代码单元开始变得越来越长。

public void Start(){
    if(inProgress){
        return;
    }
    inProgress = true;
    //如果玩家死亡则更新观察者
    if(!IsAnyPlayerAlive()){
        for(LevelObserver o : observers){
            o.LevelLost();
        }
    }
    //如果所有的豆都被吃光则更形观察者
    if(RemainingPellets()==0){
        for(LevelObserver o : ovservers){
            o.LevelWon();
        }
    }
}

添加了新功能的代码后,我们的代码增加到21行。在测试新代码的功能后,你可能已经在想下一个要实现的功能了。别急,现在你首先需要重构代码,本例中我们使用“提取方法”这个重构技巧,重构后的代码如下:

public void Start(){
    if(inProgress){
        return;
    }
    inProgress = true;
    UpdateObservers();
}

private void UpdateObservers(){
    UpdateObserversPlayerDied();
    UpdateObserversPelletsEaten();
}

private void UpdateObserversPlayerDied(){
    //如果玩家死亡则更新观察者
    if(!IsAnyPlayerAlive()){
        for(LevelObserver o : observers){
            o.LevelLost();
        }
    }
}

private void UpdateObserversPelletsEaten(){
    //如果所有的豆都被吃光则更形观察者
    if(RemainingPellets()==0){
        for(LevelObserver o : ovservers){
            o.LevelWon();
        }
    }
}

短小的代码单元能够带来不少的好处,同时也会增加代码的总行数。我们需要在编写可维护代码时不断做出权衡。比如上述的UpdateObserversPlayerDied和UpdateObserversPelletsEaten可以合并成一个方法UpdateObservers,虽然合并后的方法超过了15行我们依然可以这么做。

编写简单的代码单元

动机:

1、简答的代码单元易于修改

2、简单的代码单元易于测试

原则:

1、限制每个代码单元分支点的数量不超过4个

2、应该将复杂的代码单元拆分成多个更简单的单元。避免多个复杂的单元在一起‘

该原则能提供可维护性的原因在于,分支点越少,代码单元越容易被修改和测试

为了保持代码的可维护性,我们必须对复杂度加以限制。测试复杂度的另一个原因,是为了能够预先知道系统所需的最小测试量。

客观评价复杂度的一个常用方式,是计算一段代码中可能路径的数量。我们可以通过计算分支点数量清晰地确定出路径数量。分支点是指根据不同条件会得到不同执行结果的语句。一个代码单元分支点的数量,就是覆盖所有分支点产生的分支路径的最小数量,我们将其称为分支覆盖率。我们采用圈复杂度(分支点数量加1)来作为一段代码复杂度的衡量标准.按照上述原则,我们需要将分支点的数量限制在4以下既可以。

现在我们以如下代码为例。对于一个国家来说,GetFlagColors方法会返回正确的国旗颜色。

public Ilist<Color> GetFlagColors(Nationality nationality){
    List<Color> result;
    switch(nationality)
    {
        case Nationality.DUTCH:
            result = new list<color> {Color.Red,Color.White,Color.Blue};
            break;
        case Nationality.GERMAN:
            result = new list<color> {Color.Black,Color.Red,Color.Yellow};
            break;
        case Nationality.BELGIAN:
            result = new list<color> {Color.Black,Color.Yellow,Color.Red};
            break;
        case Nationality.FRENCH:
            result = new list<color> {Color.Blue,Color.White,Color.Red};
            break;
       case Nationality.ITALIAN:
        result = new list<color> {Color.Green,Color.White,Color.Red};
        break;         
       case Nationality.UNCLASSIFIED:
       default:
        result = new list<color> {Color.Gray};
        break;
    }
    return result;
}

从上述代码来看,需要测试的独立路径数量为6。这段代码可读性很强,但是如果我们需要对整个方法进行测试,就需要六个单独的测试用例。假设某个开发人员又添加了卢森堡的国旗:

...
    case Nationality.DUTCH:
        result = new List<Color> {Color.Red, Color.White, Color.Blue};
    case Nationality.LUXEMBOURGER:
        result = new List<Color> {Color.Red, Color.White, Color.LightBlue};
        break;
    case Nationality.GERMAN:
...

这个开发人员忘记了break语句,导致所有荷兰用户在个人页面上看到的都是卢森堡国旗了!

那我们如何才能限制分支点的数量呢?我们主要是要理解高复杂度如如何产生的,一种情况是许多耦合在一起的代码块构成了这段代码,另一种可能是代码中使用了非常长的链式(连续)if-else语句或者switch语句。这几种情况都有各自的解决办法。

对于第一种情况,我们适合采用“提取方法”的技巧来进行重构。但是遇到另一种复杂度更高的情况应该怎么办呢,如何降低它的复杂度?

实际上,对于这种复杂度较高的代码,我们需要根据不同的情况来选择最佳的解决方案。对于GetFlagColor方法,我们有两种方法降低它的复杂度。

1、引入一个Map数据结构,将国家映射到指定的Flag对象上。

2、将不同国旗的功能拆分到不同的国旗类型中,你可以使用“使用多态来代替条件判断”的重构技巧,让每个国旗都拥有一个自己的类型,并实现同一个接口。

通过上述的方法,我们就将方法更容易理解,并且更易于测试了。

不写重复代码

复制已有代码是大家在编码中都不可避免做过的事情,这似乎可以最快速达成目标。但是重复代码却会对系统可维护性带来很大的影响:

1、重复代码更加难以分析——重复代码可能在系统到处存在,我们不知道共存在多少处以及各自的位置

2、重复代码更加难以修改——如果一个重复代码中含有一个BUG,那么就需要多次重复修改BUG的过程

我们以下面这段代码为例。这是一个管理银行账户的系统,我们通过Transfer类的对象来表示钱在不同账户之间的流转流程。类CheckingAccount表示银行提供的支票账户:

public class CheckingAccount
{
    private int transferLimit = 100;
    public Transfer MakeTransfer(String counterAccount, Money amount){
        //1.检查提款限额 
        if(amount.GreaterThan(this.transferLimt)){
            throw new BusinessException("Limt exceed!");
        }
        //2.假设结果是一个9位数字的银行号码,使用11-test进行验证
        int sum = 0;
        for(int i = 0; i<counterAccount.Length; i++){
            sum = sum + (9 - i) * (int)Char.GetNumericValue(counterAccount[i]);
        }
        if(sum % 11 == 0){
            //3.查找对方账户并创建transfer对象:
            CheckingAccount acct = Accounts.FindAcctByNumber(counterAccount);
            Transfer result = new Transfer(this, acct, amount);
            return result;
        }
        else{
            throw new BusinessException("Invalid account number!");
        }
    }
}

现在我们假设银行新增了一种账户类型,储蓄账户没有转账限额,但是它有另一个限制:钱只能转给一个指定的(固定的)支票账户。这样做的目的是,账户所有者有一次机会可以将指定的支票账户与某个储蓄账户关联到一起。

我们需要一个新的类来表示这个新的账户类型。为了创建这个类,通常我们会复制一个已有的类,对它进行重命名和一些调整。最终代码如下所示:

public class SavingsAccount
{
    Checking Account registeredCounterAccount;
    public Transfer MakeTransfer(String counterAccount, Money amount) throws BusinessException{
        //1.假设结果是一个9位数字的银行号码,使用11-test进行验证
        int sum = 0;
        for(int i = 0; i<counterAccount.Length; i++){
            sum = sum + (9 - i) * Character.GetNumericValue(counterAccount.charAt(i));
        }
        if(sum % 11 == 0){
            //2.查找对方账户并创建transfer对象:
            CheckingAccount acct = Accounts.FindAcctByNumber(counterAccount);
            Transfer result = new Transfer(this, acct, amount);
                return result;
            //3.检查提款方是否是已注册的对方账号
            if (result.getCounterAccount().equals(registeredCounterAccount)){
                return result;
            }
            else{
                throw new BusinessException("Counter-account not registered!");
            }
        }
        else{
            throw new BusinessException("Invalid account number!");
        }
    }
}

假设我们在11-test中发现了一个bug,那么需要在这两处重复代码中同时修改这个bug,这样不仅增加了额外的工作,也让维护工作变得低效。

对于这点,我们应该遵从以下的原则:

  • 不要复制代码
  • 编写可重用的、通用的代码,并且/或者调用已有的代码

我们对“重复代码”的定义是,一段至少6行都相同的代码,不包含空行或者注释行。这几行代码必须完全一样,才会被认为是重复代码,这样的代码我们称为“1类克隆”,它与出现的位置无关。如下这段代码,若出现了两次,我们会认为是一段6行的重复代码,而不是两段3行的重复代码。

public void SetGivenName(string givenName)
{
    this.givenName = givenName;
}对
public void SetFamilyName(string familyName)
{
    this.familyName = familyName;
}

这里对重复代码的认定并不是唯一的标准,我们只要理解什么是重复代码即可。从本节所阐述的内容来看,移除1类克隆对可维护性的提升最大。

如何贯彻该原则呢?我们有一些有效的方法:

  1. 使用“提取方法”的重构技巧能解决大量重复代码的问题。例如上述的CheckingAccount和SavingsAccount类,此时的做法是将提取的方法放到一个工具类中。在这个例子中,我们已经有了一个合适的类(Accounts),因此可以在该类中添加一个新的静态方法IsValid:
public static bool IsValid(string number)
{
    int sum = 0;
    for (int i = 0; i<number.length; i++)
    {
        sum = sum + (9 - i) * (int)Char.GetNumericValue(number[i]);
    }
    return sum % 11 == 0;
}

然后我们在CheckingAccount类和SavingAccount类中分别调用该方法就消除了这段重复代码。

2.提取父类的重构技巧 。我们对于这两个类是通过复制来生成的,从而造成了重复代码,而且两者之间并无关联。我们可以使用面向对象的高级特性从原类提取到一个公共父类中。我们创建一个新的Account类,如下所示:

public class Account
{
    public virtual Transfer MakeTransfer(string counterAccount, Money amount)
    {
        //1.假设结果是一个9位数字的银行号码,使用11-test进行验证
        int sum = 0;
        for(int i = 0; i<counterAccount.Length; i++){
            sum = sum + (9 - i) * Character.GetNumericValue(counterAccount.charAt(i));
        }
        if(sum % 11 == 0){
            //2.查找对方账户并创建transfer对象:
            CheckingAccount acct = Accounts.FindAcctByNumber(counterAccount);
            Transfer result = new Transfer(this, acct, amount);
            return result;
        }
        else{
            throw new BusinessException("Counter-account not registered!");
        }
    }
}

现在,我们可以将CheckingAccount类和SavingAccount类改成这个父类的子类,并对这个方法进行覆盖。这样,我们就完成对这两个类提取父类的任务,代码结构更清晰,也更易于维护了。

不写死代码

我曾经见我的同事将系统要读取的一个日志文件指定在C盘的一个固定目录下,如果系统部署时没有这个目录以及这个文件就会出错。如果他将这个决定路径下的目录改为相对路径,或者通过一个属性文件可以修改,代码岂不就写活了。一般来说,我在设计中需要使用日志文件、属性文件、配置文件,通常都是以下几个方式:将文件放到与类相同的目录,使用ClassLoader.getResource()来读取;将文件放到classpath目录下,用File的相对路径来读取;使用web.xml或另一个属性文件来制定读取路径。

我也曾见另一家公司的软件要求,在部署的时候必须在C:/bea目录下,如果换成其它目录则不能正常运行。这样的设定常常为软件部署时带来许多的麻烦。如果服务器在该目录下已经没有多余空间,或者已经有其它软件,将是很挠头的事情。

保持代码单元的接口简单

保持较少的接口参数并引入合适的参数对象,能够带来很多的好处。

  • 短接口更易于理解和重用
  • 短接口的方法更易于修改

对于这点,我们应该遵从以下的原则:

  • 限制每个代码单元的参数不能超过4个

  • 将多个参数提取成对象

在JPacman项目中,有一个方法会在一个x、y、w、h四个参数表示的矩形中,绘制一个方块及方块的占有者,代码如下

//在一个指定的长方形上绘制一个方块
//<param name = "square">要绘制的方块</param>
//<param name = "g">需要进行绘制的图形上下文</param>
//<param name = "x">开始绘制的x位置</param>
//<param name = "y">开始绘制的y位置</param>
//<param name = "w">方块的宽度(以像素为单位)</param>
//<param name = "h">方块的高度(以像素为单位)</param>
private void Render(Square square, Graphics g, int x, int y, int w, int h)
{
    square.Sprite.Draw(g,x,y,w,h);
    foreach(Unit unit in square Occupants){
        unit.Sprite.Draw(g,x,y,w,h);
    }
}

这个方法超过了4个参数的长度限制,方法难以理解,并且容易出错,我们需要对它进行重构。

接口本身过长并不是主要问题, 这可能意味着更深层次的可维护性问题。那么,我们如何才能保持接口足够短呢?

  • 将具有关联关系的参数包装成对象
  • 使用方法对象替换方法

保持架构组件之间的平衡

我们先来看一下什么是组件平衡。一个平衡良好的软件结构拥有的组件,数量不会太多也不会太少,各个组件体积大小也几乎都一样。这样的架构就拥有一个好的组件平衡性。

下展示了组件平衡以及其最佳情形。左上角的是最差的情形,因为我们无法对一个独立的组件进行修改。最理想的情形是位于右下角拥有九个组件的图案,它意味着我们可以对一两个范围有限的组件进行独立的维护。第二种情形(右上角)在组件体积分布上是极其不平衡的,当一个组件的体积远大于其他组件时,架构就变成了一个单独的整体,代码不仅变得难以浏览,也难以独立维护。而在第三种情形(左下角)中,当架构被分散在许多组件之中时,我们难以保持对代码的记忆,也难以了解组件之间是如何交互的。

组件平衡能带来以下的好处:

  • 好的组件平衡能让查找和分析代码变得更加容易
  • 好的组件平衡能隔离维护所带来的影响
  • 好的组件平衡能够分离维护职责

对于这点,我们应该遵从以下的原则:

  • 顶层系统组件个数在理想状态下应为9,通常来说位于6到12之间

  • 各个组件的代码量应该大致相当

如何使用上述原则呢?

一、确定将功能合成组件的合适原则

为了实现一个合适的系统划分,方便开发人员浏览代码,你需要选择组合功能的合适原则。通常,软件系统会根据高层的功能领域(描述了系统为用户提供了何种操作的功能)来进行组织,而另一种则是按照技术专长来进行划分的。

例如,基于功能领域划分的系统,可能会拥有像资料检索、发票管理、报表、管理员等组件。每个组件都包含了提供端到端功能的代码,涵盖从数据库到前端的范围。基于功能的划分好处是可以在设计时就进行,而不用等到开发阶段。对于开发人员来说,这样的好处是他们可以从高层功能的角度来分析代码。不好的是,开发人员可能需要熟悉多个技术领域,才能对某个组件进行修改。

二、明确系统的领域并坚持下去

一旦决定了系统组件的划分类型,你需要一直坚持下去。一个不能保持一致的架构不是一个好架构。因此,组件划分的方式应当被明确规定下来,并始终保持在控制中。

分离模块之间的关注点

之前我们更多地关注系统中带个代码单元的可维护性,这里我们从代码单元层提升到模块层面,关注类之间的耦合度。松耦合意味着对类的设计可以更灵活地适应将来的变化。这里的“灵活性”指的是你可以在变化的同时,降低变化所带来的预料之外的影响。

  • 小型、松耦合的模块允许开发人员独立进行
  • 小型、松耦合的模块降低了浏览代码库的难度
  • 小型、松耦合的模块避免了让新人感到难以接手

我们会用一个真实的案例,来说明类之间的紧耦合是什么样子,以及为什么它会导致可维护性问题。这个案例是关于一个名为UserService的类(位于某web应用程序的服务层)如何在开发过程中变得越来越庞大,以至于它最终违反了本章的原则。

在第一次开发迭代中,UserService类只是一个含有三个方法的类,各个方法的名称和职责如下所示:

public class UserService
{
    public User LoadUser(string userId)
    {
        // ...
    }

    public bool DoesUserExist(string userId)
    {
        // ...
    }

    public User ChangeUserInfo(UserInfo userInfo)
    {
        // ...
    }
}
// end::UserSerice[]

在本例中,web应用程序的后台为前端代码和其他系统提供了一个REST接口。

REST接口是一种以简单形式提供web服务的方法。REST通常用于对外暴露系统的功能。如下所示,REST层的类通过UserService类来实现用户操作:

public class UserController : System.Web.Http.ApiController
{

    private readonly UserService userService = new UserService();

    // ...

    public System.Web.Http.IHttpActionResult GetUserById(string id)
    {
        User user = userService.LoadUser(id);
        if (user == null)
        {
            return NotFound();
        }
        return Ok(user);
    }
}

在第二次开发迭代中,没有对UserService类进行任何修改。在第三次开发迭代中,实现了一个新的需求,允许用户注册,以便能够接收特定的通知。为了实现该需求,三个新方法被添加到UserService类中:

public class UserService
{
    public User LoadUser(string userId)
    {
        // ...
    }

    public bool DoesUserExist(string userId)
    {
        // ...
    }

    public User ChangeUserInfo(UserInfo userInfo)
    {
        // ...
    }

    public List<NotificationType> GetNotificationTypes(User user)
    {
        // ...
    }

    public void RegisterForNotifications(User user, NotificationType type)
    {
        // ...
    }

    public void UnregisterForNotifications(User user, NotificationType type)
    {
        // ...
    }
}
// end::UserSerice[]

这几个新方法同样通过独立的REST API类对外暴露:

public class NotificationController : System.Web.Http.ApiController
{
    private readonly UserService userService = new UserService();

    // ...

    public System.Web.Http.IHttpActionResult Register(string id,
        string notificationType)
    {
        User user = userService.LoadUser(id);
        userService.RegisterForNotifications(user,
            NotificationType.FromString(notificationType));
        return Ok();

    }

    [System.Web.Http.HttpPost]
    [System.Web.Http.ActionName("unregister")]
    public System.Web.Http.IHttpActionResult Unregister(string id,string notificationType)
    {
        User user = userService.LoadUser(id);
        userService.UnregisterForNotifications(user,
        NotificationType.FromString(notificationType));
        return Ok();
    }
}

在第四次开发迭代中,又增加了新的需求,包括搜索用户、锁定用户,以及列举所有被锁定的用户(应管理要求,最后一个需求用于报表展示)。所有这些需求都导致UserService类又添加了新的方法:

public class UserService
{
    public User LoadUser(string userId)
    {
        // ...
    }

    public bool DoesUserExist(string userId)
    {
        // ...
    }

    public User ChangeUserInfo(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()
    {
        // ...
    }
}
// end::UserSerice[]

在开发的最后迭代中,这个类已经增长到了一个可观的体积。此时UserService类已经成为了系统服务层中最常用的一个服务。3个前端视图(个人档案、通知和搜索页面)分别通过3个REST API来使用UserService类,来自其他类的调用数量也增长到了50个以上。此时,这个类的体积已经膨胀到超过300行代码。该类中代码包含了太多的功能,并且知道其他代码的实现细节。这样做的结果,就是该类与其他类紧紧地耦合在一起。代码中随处都存在对该类的调用,并且该类自己也知道其他部分的细节。

对于这点,我们应该遵从以下的原则:

  • 避免形成大型模块、以便能达到模块之间的松耦合

  • 将不同的职责分给不同的木块,并且隐藏接口内部的实现细节

一般来说,本原则会要求保持的类的体积尽可能小,并限制外部对该类的调用数量。以下是三个帮助避免类之间紧耦合的开发实践:

一、根据不同关注点拆分类

为了展示如何做到这一点,我们以之前的UserService类为例,将它拆分为三个独立的类。以下是两个新创建的类和修改后的UserService类:

public class UserNotificationService
{
    public IList<NotificationType> GetNotificationTypes(User user)
    {
        // ...
    }
    public void Register(User user, NotificationType type)
    {
        // ...
    }

    public void Unregister(User user, NotificationType type)
    {
        // ...
    }

}
public class UserBlockService
{

    public void BlockUser(User user)
    {
        // ...
    }

    public IList<User> GetAllBlockedUsers()
    {
        // ...
    }
}

public class UserService
{
    public User LoadUser(string userId)
    {
        // ...
    }

    public bool DoesUserExist(string userId)
    {
        // ...
    }

    public User ChangeUserInfo(UserInfo userInfo)
    {
        // ...
    }

    public IList<User> SearchUsers(UserInfo userInfo)
    {
        // ...
    }
}

当我们再从REST API调用这些类时,现在的系统已经有了更加松耦合的实现。例如,UserService类并不知道通知系统,也不知道锁定用户的逻辑。开发人员现在可以将新的功能放在独立的类中,而不是像原来一样只能放在UserService类中。

二、隐藏接口背后的特定实现

我们可以通过隐藏高层接口背后特定、具体的实现细节,来达到松耦合的目的。为了降低耦合程度,我们使用一个接口仅定义几个基础相机和高级相机都需要实现的功能列表。

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类只访问SimpleDigitalCamera接口。这保证了SmartphoneApp不使用任何高级相机的方法。

同时,通过这种方式系统也变得更加模块化:对一个类的修改会对其他类造成最小的影响。换句话说,增加了可修改性。现在对系统的修改不仅更加容易、工作量更少,而且由于修改可能导致缺陷的风险也降低了。

三、可以使用第三方库、框架来替换自定义代码

第三种通常会导致模块紧耦合的情况,出现在提供通用功能或工具方法的类中。经典的例子包括StringUtils和FileUtils。由于这些类提供的是通用功能,很显然会被许多其他代码调用。在很多情况下,这种紧耦合很难避免。不过,一个最佳的实践是保持这些类的体积限制在一个有限范围内。

架构组件松耦合

当你在构建或者维护软件时,拥有对软件架构的清晰视野是必不可少的。一个好的软件架构可以让你洞悉系统要做什么、系统如何做到,以及功能之间是如何组织的(即如何进行组件划分)。它能够向你展示系统的高层结构,即系统的“骨架”。拥有一个好的架构使得你更容易查找源代码,以及理解(高层级)组件之间是如何互相作用的。

动机

当一个组件内发生的变化只在这个组件内部有效果时,系统更加容易维护。为了阐释松耦合组件的好处,让我们详细说明下中不同类型依赖的后果。

左图显示了一个轻度的组件依赖。绝大多数模块间的调用都是内部的(在该组件之中)。让我们先来解释内部和非内部的依赖。

能够提升可维护性的调用分为两种:

  • 内部调用。模块调用的是相同组件内部的其它模块,他们应该实现了紧密相关的功能。它们的内部逻辑对于外部是隐藏起来的。

  • 传出调用。因为它们把要做的任务代理给其他组件,所以创建了一个向外的依赖。

对于这点,我们应该遵从以下的原则:

  • 顶层组件之间应该做到松耦合

  • 尽可能减少当前模块中需要暴露给其它组件中模块的代码

本原则的目的是让组件之间达到松耦合,你需要坚持并遵循以下几个规范。

  • 限制作为组件接口的模块的大小
  • 在更高的抽象层次上来定义组件接口
  • 避免使用透传调用

如何使用本原则

抽象工厂模式

使用抽象工厂设计模式,简单的讲就是类的实例不能直接被创建(new一个),而是通过工厂类的方法返回。这种通用的工厂接口背后,隐藏了具体产品的创建过程。在这个环境下,产品通常都不止有一种类型。如果要使用其中的逻辑,需要通过创建通用的工厂对象调用类方法成员。
为了能够启动、停止云平台的服务器,以及预定存储空间,我们为一个云主机平台实现了以下接口:

public interface ICloudServerFactory
{
    ICloudServer LaunchComputeServer();

    ICloudServer LaunchDatabaseServer();

    ICloudStorage CreateCloudStorage(long sizeGb);
}

为了能够启动、停止云平台的服务器,以及预定存储空间,我们为一个云主机平台实现了以下接口:

public class AWSCloudServerFactory : ICloudServerFactory
{
    public ICloudServer LaunchComputeServer()
    {
        return new AWSComputeServer();
    }

    public ICloudServer LaunchDatabaseServer()
    {
        return new AWSDatabaseServer();
    }

    public ICloudStorage CreateCloudStorage(long sizeGb)
    {
        return new AWSCloudStorage(sizeGb);
    }
    }
    public class AzureCloudServerFactory : ICloudServerFactory {
        public ICloudServer LaunchComputeServer() {
            return new AzureComputeServer();
        }

        public ICloudServer LaunchDatabaseServer() {
            return new AzureDatabaseServer();
        }
        public ICloudStorage CreateCloudStorage(long sizeGb) {
            return new AzureCloudStorage(sizeGb);
        }
}

注意,这些工厂类会调用特定的AWS和Azure实现类(它们会再调用具体的AWS和Azure API),但是对服务器和存储返回各自通用的接口类型。

PlatformServices组件之外的代码,现在可以按照如下方式来使用接口模块CloudServerFactory:

public class ApplicationLauncher
{

    public static void Main(string[] args)
    {

        ICloudServerFactory factory;
        if (args[1].Equals("-azure"))
        {
            factory = new AzureCloudServerFactory();
        }
        else
        {
            factory = new AWSCloudServerFactory();
        }
        ICloudServer computeServer = factory.LaunchComputeServer();
        ICloudServer databaseServer = factory.LaunchDatabaseServer();

PlatformServices的CloudServerFactory接口为其他组件提供了一个小接口。这样,其他组件与它之间就可以形成松耦合的关系。

预测可能发生的变化

除此之外,在设计的时候,如果将一些关键参数放到配置文件中,可以为软件部署和使用带来更多的灵活性。要做到这一点,要求我们在软件设计时,应当有更多的意识,考虑到软件应用中可能发生的变化。比如,有一次我在设计财务软件的时候,考虑到一些单据在制作时的前置条件,在不同企业使用的时候,可能要求不一样,有些企业可能要求严格些而有些要求松散些。考虑到这种可能的变化,我将前置条件设计为可配置的,就可能方便部署人员在实际部署中进行灵活变化。然而这样的配置,必要的注释说明是非常必要的。

软件可维护性的另一层意思就是软件的设计便于日后的变更。这一层意思与软件的可变更性是重合的。所有的软件设计理论的发展,都是从软件的可变更性这一要求逐渐展开的,它成为了软件设计理论的核心。

results matching ""

    No results matching ""