读源码学架构系列:单一职责原则

0x01 定义

单一职责原则(SRP):就一个类而言,应该仅有一个引起它变化的原因

这里强调了仅有一个引起变化的原因,但是为什么只能有一个原因呢?

如果一个类承担的职责过多,一方面就相当于把这些职责耦合在了一起(面向对象设计的目标就是高内聚低耦合),另一方面,当某一个职责发生变化时,由于类承担了多个职责,所以一个职责的变化是有可能会抑制这个类对其他职责的实现能力的;同时,当变化发生时,可能会破坏我们的设计。

这条原则本质上强调内聚性的,内聚性本身的定义为:一个模块的组成元素之间的功能相关性。单一职责原则对内聚性的理解可以扩展为:把内聚性和引起一个模块或者类改变的作用力联系起来。

那到底什么是职责?

SRP中,职责被定义为:变化的原因(a reason for change)。如果你能够想到多于一个的动机去改变一个类,那么这个类就具有多于一个的职责。但往往,我们很难注意到这一点,因为我们习惯于以组的形式去考虑职责。

Uncle Bob对职责的描述有两句原话:

So a responsibility is a family of functions that serves one particular actor.(职责就是为某一个特定角色服务的一系列功能。)

An actor for a responsibility is the single source of change for that responsibility.(职责的参与者是该职责发生变化的唯一来源。)

SpringApplicationContext接口为例:

上图是Spring框架中ApplicationContext接口的继承层级关系。

从图中可以看到,ApplicationContext继承了六个接口,其中有两个又是继承自BeanFactory,一个继承自ResourceLoader。这些不同的接口各自承担着不同的职责:

  • ApplicationEventPublisher:事件派发;
  • BeanFactoryIoC的核心接口;
  • MessageSource:国际化消息;
  • ResourceLoader:资源加载;
  • EnvironmentCapable:关联运行环境;

结合Uncle Bob的那两句话看ApplicationContext的继承关系:上面的接口各自定义了一组组不同的功能(每一组功能对应于一个角色),而ApplicationContext就是这些职责的参与者(同时会充当多个角色),ApplicationContext作为某一种角色使用某一组功能时,如果功能发生了变化时,这种变化就会反映到对应定义功能的接口上去,让对应的接口也做出相应的改变。

0x02 示例

假设有一个Book类,它封装了自己的一些属性和功能,如标题、作者、总页数,以及翻页功能,同时还能打印当前页:

public class Book {

    public Book(String title, String author, Integer totalPage) {
        this.title = title;
        this.author = author;
        this.totalPage = totalPage;
    }

    private String title;
    private String author;
    private Integer totalPage;
    private Integer currPage = 0;

    public String getTitle() {
        return title;
    }

    public String getAuthor() {
        return author;
    }

    public void prevPage() {
        Integer prevPage = --currPage;
        if (prevPage <= 0) {
            prevPage = 0;
        }
        turnToPage(prevPage);
    }

    public void nextPage() {
        Integer nextPage = ++currPage;
        if (nextPage >= totalPage) {
            nextPage = totalPage;
        }
        turnToPage(nextPage);
    }

    public void turnToPage(Integer toPage) {
        currPage = toPage;
    }

    public String getCurrentPage() {
        return "current page " + currPage + " content";
    }
    
    public void printCurrentPage() {
        System.out.println(getCurrentPage());
    }
}

上面的代码将所有的属性和功能全部定义在了Book类中,我们按上面的定义来诊断这个类的封装,作为Book,属性和翻页相关的方法都没有问题,这些都属于Book本身的职责,也就是说,这些属性和相关的功能发生变化时,理所当然的需要修改Book;但是打印这个功能有没有问题呢?

比如,如果用户希望使用不同的格式打印时,我们不得不修改Book类,将Book类与打印的一些细节信息耦合在一起,由此可知除了Book本身的属性和功能变化会导致Book类的修改时,打印细节的调整也会导致Book类的修改,显然,这就不符合单一职责原则了。

我们应当将打印相关的操作抽取出来,封装成一个Printable接口:

public interface Printable {
    
    void print(String content);
    
}

该接口只有一个print方法,参数就是要打印的内容。对于不同的格式,定义不同的实现类即可:

public class HtmlPrinter implements Printable {
    @Override
    public void print(String content) {
        System.out.println(String.format("<html> %s </html>", content));
    }
}
public class PlainTextPrinter implements Printable {
    @Override
    public void print(String content) {
        System.out.println(content);
    }
}

通过抽取Printable接口,我们将打印相关的职责从Book类中分离了出来。

public class Book {

    public Book(String title, String author, Integer totalPage) {
        this.title = title;
        this.author = author;
        this.totalPage = totalPage;
    }

    private String title;
    private String author;
    private Integer totalPage;
    private Integer currPage = 0;

    public String getTitle() {
        return title;
    }

    public String getAuthor() {
        return author;
    }

    public void prevPage() {
        Integer prevPage = --currPage;
        if (prevPage <= 0) {
            prevPage = 0;
        }
        turnToPage(prevPage);
    }

    public void nextPage() {
        Integer nextPage = ++currPage;
        if (nextPage >= totalPage) {
            nextPage = totalPage;
        }
        turnToPage(nextPage);
    }

    public void turnToPage(Integer toPage) {
        currPage = toPage;
    }

    public String getCurrentPage() {
        return "current page " + currPage + " content";
    }
}

Book类简单的调整一下。这样调整之后,打印角色的参与者自行决定要使用什么格式进行打印,它只关注使用什么格式打印传入的内容;Book类角色的参与者只需要翻到需要打印的页面,再返回要页面的内容即可,它只关注你要翻到哪一页。各自职责清晰明了。

更进一步,假如,我们希望能支持用户以不同的格式来将Book保存到本地。同样的,如果我们将保存的功能放到Book类中,又会引起同样的问题,Book类需要关注到保存的细节了,保存的细节发生变化时,又会导致Book类需要做出修改。因此,我们需要单独建立一个保存的接口:

public interface Downloadable {
    
    void download(Book book);
    
}

具体的格式以不同的实现类来实现:

public class EpubFormatDownloader implements Downloadable {
    @Override
    public void download(Book book) {
        System.out.println(String.format("Book %s download with format EPUB", book.getTitle()));
    }
}
public class MobiFormatDownloader implements Downloadable {
    @Override
    public void download(Book book) {
        System.out.println(String.format("Book %s download with format MOBI", book.getTitle()));
    }
}

我们用同样的方式新建了一个Downloadable接口来将下载功能分离出来。其实,这个下载功能和我们平时使用的持久化功能很相似,我们在开发时,对于持久化功能,始终会使用一层接口将具体持久化的细节隔离开,这样做最大的好处在于开发测试时,我们可以使用内存数据库来进行开发测试,实际上线时使用关系数据库或是NoSQL等其它类型的数据库进行数据的持久化。

Book类更像是DDD中的领域对象了,将自己的属性和功能封装在一起,让自己保持高度的内聚性。

0x03 总结

SRP是所有原则中最简单的原则之一,但也最难用好。我们早已习惯于把职责耦合在一起,用Uncle Bob的话说:软件设计真正要做的许多内容就是发现职责并把那些职责相互分离

其实,分离也会带来其它的问题,一个典型的问题就是代码的复杂性,看看上面的ApplicationContext的继承关系图,还算比较清晰,如果下探到更具体的一些子类,如XmlWebApplicationContext的继承关系:

这个继承关系已经有二十多个对象了,复杂性可想而知。

在前一篇,我们已经知道这些设计原则是用来诊断我们的设计的,所以,对于SRP,我们要学习去判断类的职责;职责的定义是指引起类变化的原因。有时候,我们可能会碰到一种情况:我们通过职责分离的方式分离出了两个接口,而应用程序的变化方式总是会导致这两个职责同时发生变化,这种情况下,Uncle Bob的建议是不必分离,因为分离后会带来不必要的复杂性。我个人的理解就是结合具体的使用场景来权衡,不做过度设计,不做超前设计。

欢迎各种交流与反馈!

References:
  1. 敏捷软件开发
  2. Single Responsibility Principle
  3. The Single Responsibility Principle
  4. 示例代码仓库