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

文章讲述了作者在接手一个复杂的大数据SQL引擎项目时,如何通过单一职责原则进行代码重构,将一个庞大且难以维护的类拆分成多个职责清晰的小类,提高了代码质量和维护性。
摘要由CSDN通过智能技术生成

工作期间,曾经接手过一个大数据 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 就可以了,不需要对原来的类再进行修改,甚至不需要调用原来的类。程序运行期,在语法处理的时候遇到对应的语法节点,交给相关的类处理就好了。

重构后虽然类的数量扩展了几百倍,但是代码总行数却少了很多。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值