You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

13 KiB

14 | 软件设计的单一职责原则:为什么说一个类文件打开最好不要超过一屏?

我在Intel工作期间曾经接手过一个大数据SQL引擎的开发工作[如何自己开发一个大数据SQL引擎](https://time.geekbang.org/column/article/71634)。我接手的时候这个项目已经完成了早期的技术验证和架构设计能够处理较为简单的标准SQL语句。后续公司打算成立一个专门的小组开发支持完整的标准SQL语法的大数据引擎然后进一步将这个产品商业化。

我接手后打开项目一看吓出一身冷汗这个项目只有几个类组成其中最大的一个类负责SQL语法的处理有近万行代码。代码中充斥着大量的switch/caseif/else代码而且方法之间互相调用各种全局变量传递。

只有输入测试SQL语句的时候在debug状态下才能理解每一行代码的意思。而这样的代码有1万行现在只实现了不到10%的SQL语法特性。如果将SQL的全部语法特性都实现了那么这个类该有多么大逻辑有多么复杂维护有多么困难而且还要准备一个团队来合作开发想想看几个人在这样一个大文件里提交代码想想都酸爽。

这是当时这个SQL语法处理类中的一个方法而这样的方法有上百个。

  /**
   * Digest all Not Op and merge into subq or normal filter semantics
   * After this process there should not be any NOT FB in the FB tree.
   */
  private void digestNotOp(FilterBlockBase fb, FBPrepContext ctx) {
    // recursively digest the not op in a top down manner
    if (fb.getType() == FilterBlockBase.Type.LOGIC_NOT) {
      FilterBlockBase child = fb.getOnlyChild();
      FilterBlockBase newOp = null;
      switch (child.getType()) {
      case LOGIC_AND:
      case LOGIC_OR: {
        // not (a and b) -> (not a) or (not b)
        newOp = (child.getType() == Type.LOGIC_AND) ? new OpORFilterBlock()
            : new OpANDFilterBlock();
        FilterBlockBase lhsNot = new OpNOTFilterBlock();
        FilterBlockBase rhsNot = new OpNOTFilterBlock();
        lhsNot.setOnlyChild(child.getLeftChild());
        rhsNot.setOnlyChild(child.getRightChild());
        newOp.setLeftChild(lhsNot);
        newOp.setRightChild(rhsNot);
        break;
      }
      case LOGIC_NOT:
        newOp = child.getOnlyChild();
        break;
      case SUBQ: {
        switch (((SubQFilterBlock) child).getOpType()) {
        case ALL: {
          ((SubQFilterBlock) child).setOpType(OPType.SOMEANY);
          SqlASTNode op = ((SubQFilterBlock) child).getOp();
          // Note: here we directly change the original SqlASTNode
          revertRelationalOp(op);
          break;
        }
        case SOMEANY: {
          ((SubQFilterBlock) child).setOpType(OPType.ALL);
          SqlASTNode op = ((SubQFilterBlock) child).getOp();
          // Note: here we directly change the original SqlASTNode
          revertRelationalOp(op);
          break;
        }
        case RELATIONAL: {
          SqlASTNode op = ((SubQFilterBlock) child).getOp();
          // Note: here we directly change the original SqlASTNode
          revertRelationalOp(op);
          break;
        }
        case EXISTS:
          ((SubQFilterBlock) child).setOpType(OPType.NOTEXISTS);
          break;
        case NOTEXISTS:
          ((SubQFilterBlock) child).setOpType(OPType.EXISTS);
          break;
        case IN:
          ((SubQFilterBlock) child).setOpType(OPType.NOTIN);
          break;
        case NOTIN:
          ((SubQFilterBlock) child).setOpType(OPType.IN);
          break;
        case ISNULL:
          ((SubQFilterBlock) child).setOpType(OPType.ISNOTNULL);
          break;
        case ISNOTNULL:
          ((SubQFilterBlock) child).setOpType(OPType.ISNULL);
          break;
        default:
          // should not come here
          assert (false);
        }
        newOp = child;
        break;
      }
      case NORMAL:
        // we know all normal filters are either UnCorrelated or
        // correlated, don't have both case at present
        NormalFilterBlock nf = (NormalFilterBlock) child;
        assert (nf.getCorrelatedFilter() == null || nf.getUnCorrelatedFilter() == null);
        CorrelatedFilter cf = nf.getCorrelatedFilter();
        UnCorrelatedFilter ucf = nf.getUnCorrelatedFilter();
        // It's not likely to result in chaining SqlASTNode
        // as any chaining NOT FB has been collapsed from top down
        if (cf != null) {
          cf.setRawFilterExpr(
              SqlXlateUtil.revertFilter(cf.getRawFilterExpr(), false));
        }
        if (ucf != null) {
          ucf.setRawFilterExpr(
              SqlXlateUtil.revertFilter(ucf.getRawFilterExpr(), false));
        }
        newOp = child;
        break;
      default:
      }
      fb.getParent().replaceChildTree(fb, newOp);
    }
    if (fb.hasLeftChild()) {
      digestNotOp(fb.getLeftChild(), ctx);
    }
    if (fb.hasRightChild()) {
      digestNotOp(fb.getRightChild(), ctx);
    }
  }

我当时就觉得,我太难了。

单一职责原则

软件设计有两个基本准则:低耦合和高内聚。我在前面讲到过的设计原则和后面将要讲的设计模式大多数都是关于如何进行低耦合设计的。而内聚性主要研究组成一个模块或者类的内部元素的功能相关性。

设计类的时候,我们应该把强相关的元素放在一个类里,而弱相关性的元素放在类的外边。保持类的高内聚性。具体设计时应该遵循这样一个设计原则:

一个类,应该只有一个引起它变化的原因

这就是软件设计的单一职责原则。如果一个类承担的职责太多就等于把这些职责都耦合在一起。这种耦合会导致类很脆弱当变化发生的时候会引起类不必要的修改进而导致bug出现。

职责太多,还会导致类的代码太多。一个类太大,它就很难保证满足开闭原则,如果不得不打开类文件进行修改,大堆大堆的代码呈现在屏幕上,一不小心就会引出不必要的错误。

所以关于编程有这样一个最佳实践:一个类文件打开后,最好不要超过屏幕的一屏。这样做的好处是,一方面代码少,职责单一,可以更容易地进行复用和扩展,更符合开闭原则。另一方面,阅读简单,维护方便。

一个违反单一职责原则的例子

如何判断一个类的职责是否单一,就是看这个类是否只有一个引起它变化的原因。

我们看这样一个设计:

正方形类Rectangle有两个方法一个是绘图方法draw()一个是计算面积方法area()。有两个应用需要依赖这个Rectangle类一个是几何计算应用一个是图形界面应用。

绘图的时候程序需要计算面积但是计算面积的时候呢程序又不需要绘图。而在计算机屏幕上绘图又是一件非常麻烦的事情所以需要依赖一个专门的GUI组件包。

这样就会出现一个尴尬的情形当我需要开发一个几何计算应用程序的时候我需要依赖Rectangle类而Rectangle类又依赖了GUI包一个GUI包可能有几十M甚至数百M。本来几何计算程序作为一个纯科学计算程序主要是一些数学计算代码现在程序打包完却不得不把一个不相关的GUI包也打包进来。本来程序包可能只有几百K现在变成了几百M。

Rectangle类的设计就违反了单一职责原则。Rectangle承担了两个职责一个是几何形状的计算一个是在屏幕上绘制图形。也就是说Rectangle类有两个引起它变化的原因这种不必要的耦合不仅会导致科学计算应用程序庞大而且当图形界面应用程序不得不修改Rectangle类的时候还得重新编译几何计算应用程序。

比较好的设计是将这两个职责分离开来将Rectangle类拆分成两个类

将几何面积计算方法拆分到一个独立的类GeometricRectangle这个类负责图形面积计算area()。Rectangle只保留单一绘图职责draw()现在绘制长方形的时候可以使用计算面积的方法而几何计算应用程序则不需要依赖一个不相关的绘图方法以及一大堆的GUI组件。

从Web应用架构演进看单一职责原则

事实上Web应用技术的发展、演化过程也是一个不断进行职责分离实现单一职责原则的过程。在十几年前互联网应用早期的时候业务简单技术落后通常是一个类负责处理一个请求处理。

以Java为例就是一个Servlet完成一个请求处理。

这种技术方案有一个比较大的问题是请求处理以及响应的全部操作都在Servlet里Servlet获取请求数据进行逻辑处理访问数据库得到处理结果根据处理结果构造返回的HTML。这些职责全部都在一个类里完成特别是输出HTML需要在Servlet中一行一行输出HTML字符串类似这样

response.getWriter().println("<html> <head> <title>servlet程序</title> </head>");

这就比较痛苦了一个HMTL文件可能会很大在代码中一点一点拼字符串编程困难、维护困难总之就是各种困难。

于是后来就有了JSP如果说Servlet是在程序中输出HTML那么JSP就是在HTML调用程序。使用JSP开发Web程序大概是这样的

用户请求提交给JSP而JSP会依赖业务模型进行逻辑处理并将模型的处理结果包装在HTML里面构造成一个动态页面返回给用户。

使用JSP技术比Servlet更容易开发一点至少不用再痛苦地进行HTML字符串拼接了通常基于JSP开发的Web程序在职责上也会进行了一些最基本的分离构造页面的JSP和处理逻辑的业务模型分离。但是这种分离藕断丝连JSP中依然存在大量的业务逻辑代码代码和HTML标签耦合在一起职责分离得并不彻底。

真正将视图和模型分离的是后来出现的各种MVC框架MVC框架通过控制器将视图与模型彻底分离。视图中只包含HTML标签和模板引擎的占位符业务模型则专门负责进行业务处理。正是这种分离使得前后端开发成为两个不同的工种前端工程师只做视图模板开发后端工程师只做业务开发彼此之间没有直接的依赖和耦合各自独立开发、维护自己的代码。

有了MVC就可以顺理成章地将复杂的业务模型进行分层了。通过分层方式将业务模型分为业务层、服务层、数据持久层使各层职责进一步分离更符合单一职责原则。

小结

让我们回到文章的标题类的职责应该是单一的也就是引起类变化的原因应该只有一个这样类的代码通常也是比较少的。在开发实践中一个类文件在IDE打开最好不要超过一屏。

文章开头那个大数据SQL引擎的例子中SQL语法处理类的主要问题是太多功能职责被放在一个类里了。我在研读了原型代码并和开发原型的同事讨论后把这个类的职责从两个维度进行切分。一个维度是处理过程整个处理过程可以分为语法定义、语法变形和语法生成这三个环节每个SQL语句都需要依赖这三个环节。此外我在第一个模块的第6篇文章中讲到每个SQL语句在处理的时候都要生成一个SQL语法树而树是由很多节点组成的。从这个角度讲每个语法树节点都应该由一个单一职责的类处理。

我从这两个维度将原来有着近万行代码的类进行职责拆分拆分出几百个类每个类的职责都比较单一只负责一个语法树节点的一个处理过程。很多小的类只有几行代码打开后只占IDE中一小部分在显示器上一目了然阅读、维护都很轻松。类之间没有耦合而是在运行期根据SQL语法树将将这些代表语法节点的类构造成一颗树然后用设计模式中的组合模式进行遍历即可。

后续参与进来开发的同事只需要针对还不支持的SQL语法功能点开发相对应的语法转换器Transformer和语法树生成器Generator就可以了不需要对原来的类再进行修改甚至不需要调用原来的类。程序运行期在语法处理的时候遇到对应的语法节点交给相关的类处理就好了。

重构后虽然类的数量扩展了几百倍,但是代码总行数却少了很多,这是重构后的部分代码截图:

思考题

你在软件开发中有哪些可以用单一职责原则改进的设计呢?

欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。