读源码学架构系列:开放封闭原则

0x01 定义

开放封闭原则(OCP):软件实体(类、模块、函数等)应该是可以扩展的,但是不可修改的。

这条原则强调的是设计在面对需求的改变时可以保持相对的稳定性。

遵循开闭原则设计出的模块具有两个主要的特征:

  1. 对于扩展是开放的:即意味着模块的行为是可以扩展的。
  2. 对于修改是封闭的:对模块行为进行扩展时,不必发动模块的源代码或者二进制代码。

那如何做到这两点呢?核心的关键就是抽象

在模块设计层面,前面SPI篇的文章已经明确的分析了模块设计时如何操持良好的扩展性,因此SPI模式是遵循开闭原则的。尤其是Dubbo框架的设计,每一层的实现都暴露出扩展点来,让用户可以自行扩展。

在代码设计层面,有两种方法来实现开闭原则:

  • 一种是将直接依赖修改为接口,在未来进行扩展时只需要新实现一个实现类即可;
  • 另一种是使用模版方法模式,用抽象来封装好通用的逻辑部分,将实现细节清晰的分离出来供不同的子类实现;

基于此,也可以看到,OCP强调通过新增代码来实现扩展,反对通过修改来实现扩展。这也很容易理解,谁也不能保证修改不会破坏之前封装好的正确的行为或逻辑。

0x02 示例

OCP在模块层面的实现我们这里就不做示例了,框架的设计中,SPI已经完美的诠释了OCP在模块设计时的应用,我们这里以代码层面的OCP来做示例。

代码层面的OCP有两种实现,一种是基于接口的实现,另一种是基于抽象类的实现。

在前一篇SRP中,关于Book类的打印功能和下载功能的设计,我们在保证Book类的单一职责时,分别使用了PrintableDownloadable来实现打印与下载的功能,以支持不同方式的打印和不同格式的下载,其实这就是基于接口的OCP的实现了。

试想一下,现在如果想增加一种html的下载方式,我们需要怎么做?

我们只需要基于Downloadable接口,重新实现这种下载的逻辑即可,不需要对Book或是其它的下载实现类做任何的修改,这不正好就是OCP所强调的:通过新增来实现扩展吗?

因此,这里就不再重复介绍基于接口方式来实现OCP了,下面介绍一下以抽象类的方式来实现OCP

假设有这样一个需求,我们要实现下面这样一个窗口程序:

窗口组件的树形结构如下图:

程序最后的输出要是这样:

print WinForm(WINDOW窗口)
print Picture(LOGO图片)
print Button(登录)
print Button(注册)
print Frame(FRAME1)
print Label(用户名)
print TextBox(用户名文本框)
print Label(密码)
print PasswordBox(密码框)
print CheckBox(复选框)
print TextBox(记住用户名)
print LinkLabel(忘记密码)

我们应当怎样来设计这个程序呢?

通过上面的需求,我们能看到,每个控制都能输出自己的信息,有些控件还能容纳一些其它的控件,控件可以组装成一个整体的程序。

作为控件来说,它有自己的一些属性,如name等,还有一些自己的行为,如获取自己的名称信息等、输出显示信息等。

再看上面的两张图片,控件分很多种不同的类型,不同的控制既有相同的行为又有不同的行为,因此,我们需要使用抽象将控件的公共行为进行封装。如果只是一个抽象类,那么某些能容纳其它控件的控件在维护它的子控件时,就需要关联一抽象类本身,而本质上,它只关注自己容纳的子控件的行为(如下面的Frame控件,WinForm控件),所以,我们可以用一个Component接口将组件的行为分离出来,并且由抽象类来实现这个接口。

具体的代码如下:

public interface Component {

    /**
     * Gui component's name
     * @return name
     */
    String getName();

    /**
     * Display detail info
     */
    void display();

}
public abstract class AbstractComponent implements Component {

    protected String name;

    public AbstractComponent(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

}

AbstractComponent抽象类封装了控件的公共属性name和公共行为getName()方法以及display()方法,getName()方法的逻辑是公共的,而display()方法的逻辑是与具体的不同控件相关的,所以是抽象方法。

以下为各具体控件的代码:

public class Button extends AbstractComponent {

    public Button(String name) {
        super(name);
    }

    @Override
    public void display() {
        System.out.println("print Button(" + getName() + ")");
    }
}
public class CheckBox extends AbstractComponent {

    public CheckBox(String name) {
        super(name);
    }

    @Override
    public void display() {
        System.out.println("print CheckBox(" + getName() + ")");
    }
}
public class Frame extends AbstractComponent {

    private List<Component> subComponents = new ArrayList<>();

    public Frame(String name) {
        super(name);
    }

    @Override
    public void display() {
        System.out.println("print Frame(" + getName() + ")");
        for (Component sub : subComponents) {
            sub.display();
        }
    }

    public Frame addSubComponent(Component component) {
        this.subComponents.add(component);
        return this;
    }
}
public class Label extends AbstractComponent {

    public Label(String name) {
        super(name);
    }

    @Override
    public void display() {
        System.out.println("print Label(" + getName() + ")");
    }
}
public class LinkLabel extends AbstractComponent {

    public LinkLabel(String name) {
        super(name);
    }

    @Override
    public void display() {
        System.out.println("print LinkLabel(" + getName() + ")");
    }
}
public class PasswordBox extends AbstractComponent {

    public PasswordBox(String name) {
        super(name);
    }

    @Override
    public void display() {
        System.out.println("print PasswordBox(" + getName() + ")");
    }
}
public class Picture extends AbstractComponent {

    public Picture(String name) {
        super(name);
    }

    @Override
    public void display() {
        System.out.println("print Picture(" + getName() + ")");
    }
}
public class TextBox extends AbstractComponent {

    public TextBox(String name) {
        super(name);
    }

    @Override
    public void display() {
        System.out.println("print TextBox(" + getName() + ")");
    }
}
public class WinForm extends AbstractComponent {

    private List<Component> subComponents = new ArrayList<>();

    public WinForm(String name) {
        super(name);
    }

    @Override
    public void display() {
        System.out.println("print WinForm(" + getName() + ")");
        for (Component sub : subComponents) {
            sub.display();
        }
    }

    public WinForm addSubComponent(Component component) {
        this.subComponents.add(component);
        return this;
    }
}

以上为具体的控件的代码,下面是main()方法:

public class App {

    public static void main(String[] args) {
        // build WinForm
        WinForm winForm = new WinForm("WINDOW窗口");
        winForm
            .addSubComponent(new Picture("LOGO图片")) // build Picture
            .addSubComponent(new Button("登录"))      // build Login Button
            .addSubComponent(new Button("注册"));     // build Register Button
        // build Frame
        Frame frame = new Frame("FRAME1");
        winForm.addSubComponent(frame);
        frame
            .addSubComponent(new Label("用户名"))           // build username Label
            .addSubComponent(new TextBox("用户名文本框"))    // build username TextBox
            .addSubComponent(new Label("密码"))             // build password Label
            .addSubComponent(new PasswordBox("密码框"))     // build PasswordBox
            .addSubComponent(new CheckBox("复选框"))        // build CheckBox
            .addSubComponent(new TextBox("记住用户名"))      // build remember username CheckBox
            .addSubComponent(new LinkLabel("忘记密码"));     // build forget password LinkLabel
        // display window
        winForm.display();
    }
}

运行后的输出如下:

3:10:26 下午: Executing task 'App.main()'...

> Task :compileJava
> Task :processResources UP-TO-DATE
> Task :classes

> Task :App.main()
print WinForm(WINDOW窗口)
print Picture(LOGO图片)
print Button(登录)
print Button(注册)
print Frame(FRAME1)
print Label(用户名)
print TextBox(用户名文本框)
print Label(密码)
print PasswordBox(密码框)
print CheckBox(复选框)
print TextBox(记住用户名)
print LinkLabel(忘记密码)

BUILD SUCCESSFUL in 1s
3 actionable tasks: 2 executed, 1 up-to-date
3:10:27 下午: Task execution finished 'App.main()'.

上面的示例,我们用AbstractComponent来封装了不同控件的公共行为和属性,分离出Component接口是因为对于FrameWinForm这种能容纳其它子控件的控件而言,它们关注的仅仅是子控件的行为,并不关注它们的具体实现,所以,通过Component接口将控件的行为单独分离出来,这样在FrameWinForm中就不用耦合AbstractComponent类,而只需要依赖于Component接口即可。(分离Component接口纯属我个人的思考)

0x03 总结

本篇介绍的是开闭原则(OCP),它可以应用于不同的维度,如模块、类、函数等。

基于模块维度的实现在前面的SPI篇中已经完美的诠释了OCP的应用。

基于类的实现也有两种方式:基于接口和基于抽象类。

基于接口的实现在SRP篇中的示例也已经演示过了,我们这里主要是演示了基于抽象类的实现。

OCP是面向对象设计的核心,遵循这个原则可以带来更好的灵活性、可重用性及可维护性。但是,我们也不能滥用抽象,对程序中的每个部分者肆意的进行抽象是不行的;正确的做法是应该仅仅对程序中呈现出频繁变化的那些部分做出抽象。

另外,关于使用接口进行抽象还是使用抽象类使用抽象的问题:

接口定义的是一组相关的行为契约,用来在不同的类、模块或是系统间交互;而抽象类更像是一个模块。接口的不同实现类更松散,只需要满足接口中方法定义的契约即可,调用方只按契约调用;而抽象类一般会定义好类的主要逻辑或是流程,只是将一些更细致的细节部分定义为抽象方法给子类实现,调用方在调用时还是要遵循抽象类定义的逻辑或流程,甚至很多时候,调用方根本就不会直接调用子类所实现的抽象方法,而是通过抽象类定义好的业务方法来回调到子类的实现。

最后,我个人认为在实际开发中,抽象类封装的可能还有一些public方法,我们通过接口来屏蔽掉抽象类中不希望被错误调用的方法,这样更能提升内聚性,这仅仅只是我个人的想法。

欢迎各种交流与反馈!

References:
  1. 敏捷软件开发
  2. Open Closed Principle
  3. Choosing Between an Interface and an Abstract Class
  4. 示例代码仓库