跳转至

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

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

我接手后打开项目一看,吓出一身冷汗,这个项目只有几个类组成,其中最大的一个类,负责SQL语法的处理,有近万行代码。代码中充斥着大量的switch/case,if/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就可以了,不需要对原来的类再进行修改,甚至不需要调用原来的类。程序运行期,在语法处理的时候遇到对应的语法节点,交给相关的类处理就好了。

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

思考题

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

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

精选留言(15)
  • 张希音 👍(12) 💬(2)

    以前编码的时候喜欢写一大段逻辑,然后出现bug要进行debug的时候发现真的很痛苦。后来,把特定功能的逻辑抽出一个方法,每个方法只干一件事,刚开始感觉很麻烦,后来发现看代码的时候比以前清晰多了,有时候不用debug都能大概判断问题出在哪个具体方法中。

    2019-12-23

  • learn more 👍(6) 💬(1)

    即使一个类职责单一,也不一定就好维护吧,个人觉得还要考虑他所依赖和他被依赖的关系,如果你看到的方法被很多类依赖使用,还敢这么轻松修改吗?

    2020-03-26

  • Jinyun 👍(6) 💬(1)

    在维护前同事写的代码的时候,之前总是在其方法内部做增量,吃了几次亏后,果断写个方法把需要的业务处理好,然后勾到需要的地方,不光调试贼爽,还很清晰。老师讲的比较宏大,不知道我这个算不算😂

    2019-12-23

  • geek_arong2048 👍(0) 💬(1)

    智慧老师当年还需要亲手写代码吗hhh,一直以为这是交给下属做的事

    2021-07-17

  • escray 👍(3) 💬(0)

    很努力的看了一下那个 103 行的方法(包括注释),其实看不太懂,上来就是一个 if,然后有两层的 switch 嵌套,第二层 case 里面还有 if ,很奇怪,居然没有 else。 可能自己以前也是这么写代码的,感觉就像一篇流水账,好在还没有到面条(Spaghetti code)的程度。 有点好奇,李老师当年是如何重构这段代码的,似乎可以再开一个专栏了。 如果是我的话,可能会把 Rectangle 类拆分成 GeometricRectangle 和 GUIRectangle,这样似乎更明确一些,否则总感觉 GeometricRectangle 是 Rectangle 的子类。 看到 Web 应用架构演进那一段,还真是亲切,我在学编程的时候,似乎还是 Servlet 的时代(暴露年龄),后来写代码的时候已经是 JSP 了(其实调试起来也很痛苦),再后来就是 MVC 了。 看到文末的截图,不过似乎 project-panthera-ase 已经停止更新了。 感觉单一职责原则其实有点考察程序员对于业务的掌握情况,就是能否想清楚引起类变化的原因。 最后,讲个段子。 因为要求一个类不超过一屏,越来越多的程序员买宽屏甚至带鱼屏,然后竖着放。

    2020-09-25

  • 丁乐洪 👍(3) 💬(0)

    看到这,我要重构我的代码

    2020-03-18

  • 俊伟 👍(3) 💬(0)

    我觉得从测试的角度写代码也有助于代码逻辑结构的模块化划分。每次写之前想想写完了这段代码能不能测试。

    2019-12-23

  • Seven.Lin澤耿 👍(1) 💬(0)

    老师,从我工作以来,Java工程基本都是Controller->Service->DAO,以表来区分,这样子算违背单一责任原则吗?还是要看所谓的"责任"的界限?

    2020-06-08

  • 蔡伟祥 👍(1) 💬(0)

    如此众多的类,它们之间的关系就相对复杂了,请问这如何管理呢?

    2020-01-28

  • Paul Shan 👍(1) 💬(0)

    职责单一原则其实挺难定义,有些资源文件虽然很大,但是其职责是单一的,这个文件就是维护一个映射。我个人认为,一个类与其包含的所有方法和属性,如果只有包含关系,可认为单一职责,例如用户和用户的名字。如果用户名字还有其他用途,例如和密码一道提供认证功能而用户类中还有其他属性和认证无关,就不再是单一职责,可以让用户名和密码单独成类。

    2019-12-29

  • prader26 👍(0) 💬(0)

    单一职责原则:一个类最好只有一个引起它变化的点。

    2023-06-27

  • Tank 👍(0) 💬(0)

    增强代码可读性。

    2023-04-23

  • java小霸王 👍(0) 💬(0)

    什么时候该拆分一个类,老师给出的是一个屏幕,这里补充下,通过构造器注入的方式,看类的依赖服务有多少,一般多于3就要开始重视了

    2022-07-13

  • 进化菌 👍(0) 💬(0)

    代码越积越多,特别是在不愿意改动别人的代码基础上,因为很多时候改动的成本比较高。这个时候,抽离部分逻辑出来,应该也算是逐步完善了吧~

    2022-04-18

  • meijing0114 👍(0) 💬(0)

    开发web程序的时候,针对redis的访问类的包装,很多时候就没做到单一职责。逻辑堆叠的很多,场景又不太一样,很多时候会有不少重复的代码。

    2020-12-02