开篇
00、软件设计,应对需求规模的“算法”
作为一个能把基本功能实现出来的程序员,偶尔仰望天空时,你是否这样问过自己,“我写过的代码还有没有更好的写法呢?如果有一天,我能够把它重写一遍,我该怎么做呢?”这就是我当年问过自己的问题,因为我对自己的代码不满意:
- 我厌倦了,把各种代码堆砌在一起,然后,在出现 Bug 时,犹如“大家来找茬”一样在其中定位问题
- 我厌倦了,仅仅为了一个小需求,要在无数的地方小心翼翼地做着各种微调,还被产品经理嫌弃改得慢;
- 我厌倦了,自己辛辛苦苦写好的代码,被别人在其他地方不经意地修改,给弄崩溃了;……
我四处寻找答案,直到后来,我找到了一个东西,叫做“软件设计”。在如饥似渴地学习了软件设计之后,我对做软件这件事有了全新的认识:
- 我知道了,写软件不仅要追求如何实现功能,还要考虑未来的维护和扩展;
- 我知道了,代码不应该毫无目的地堆在那里,而是要考虑如何组织更为恰当;
- 我知道了,原来后期遇到很多问题,只是因为前期缺乏设计而留下了隐患;
- ……
如果你也曾有同样的迷茫,想破茧成蝶,欢迎你与我一起探索“软件设计”!
关注长期变化的软件设计
设计是为了让软件在长期更容易适应变化。
Design is there to enable you to keep changing the software easily in the long term.
—— Kent Beck
软件设计,是一门关注长期变化的学问。它并不是程序员的入门第一课,因为初窥编程门径的程序员首先追求的是把一个功能实现出来,他无法看到一个软件长期的变化。或许未曾学过软件设计,但这并不妨碍你凭借一股蛮力把软件写出来。但如果你在做的是一个有生命力的软件,长期意味着什么呢?意味着会有源源不断的需求扑面而来。面对一拨又一拨的需求,你该如何应对呢?
你可以想象一下,小米加步枪的解决方案,在敌人不多的时候,你还可以应付得游刃有余,但当敌人已经漫山遍野时,你需要的是大规模杀伤性武器。而软件设计就是这个大规模杀伤性武器。
用程序员们更熟悉的排序算法为例,快速排序的平均复杂度是 O(nlogn),而插入排序是 O(n^2)。所以,一般我们说快速排序比插入排序有优势。但是,这种优势只有在一定规模下才能体现出来。当数据规模很小的时候,二者差别并不明显,更有甚者,插入排序在某些情况下表现得会更好。但当数据规模很大时,快速排序的优势就非常明显了。对比两个算法的优劣,关键在于数据规模。所以你会发现,算法和软件设计其实是一样的,二者对抗的都是规模问题,只不过,算法对抗的是数据的规模,而软件设计对抗的是需求的规模。
你现在应该理解了,为什么软件设计是一门关注长期的学问了,因为只有长期的积累,需求才会累积,规模问题才会凸显出来。软件设计,实际上就是应对需求的“算法”。
如何学习软件设计?
软件设计的相关知识有很多,你可能听说过一些,比如,设计模式、领域驱动设计等等。但是,分别学习这些知识时,总有一些令人困惑的地方。比方说,学了那么多设计模式,你能用上的却没有几个;领域驱动设计中的概念那么多,你都不知道该从哪学起。
你的困惑我也有过,我花了很长时间才知道,我们困惑的,并不是这些知识本身,而是在于缺乏一个整体结构将它们贯穿起来。比如,多态,这个词在你看来只是一个普通的语法规则,但只有你懂得,它是把变的部分和不变的部分隔离开来,你才能理解开放封闭、接口隔离和依赖倒置这些设计原则的价值所在,理解了这些原则,才能知道某些设计模式只是这些原则在具体场景下的应用。
也就是说,软件设计学习的难度,不在于一招一式,而在于融会贯通。
所以,在这个课程中,我会尝试将软件设计的相关知识贯通起来,帮你建立起对软件设计的整体认知。那具体要从哪里入手呢?其实对于“软件设计”,我们可以将其划分为两个维度:“了解现有软件的设计”和“自己设计一个软件”。
了解现有软件的设计,是能够在这个软件上继续添砖加瓦的前提。事实上,无论是初入职场,还是加入一个新公司,在工作初期,我们能做的往往也只是添砖加瓦,这时候如果能快速了解现有软件的设计,就可以尽快投入工作中去。此外,当你想从一个开源项目上汲取养分时,了解其背后的设计,也是一种不可或缺的能力。
大多数人在理解一个软件时,总会出现一个问题,就是眉毛胡子一把抓,直奔代码细节而去。这样不仅增加了我们的时间成本,还会迷失在细节之中,只见树木不见森林。所以在这个课程中,我会教你一个快速了解现有软件设计的方法,那就是抓住这个软件最核心的三个部分:模型、接口和实现。
同时我会以一些开源项目为案例,教你如何用这个方法去解读它们的设计,比如:
- 我们怎样理解 Spring DI 容器模型,它的模型怎样有效解决了其面对的问题;
- 如何理解 Ruby on Rails 的接口,我们可以从其接口设计中借鉴哪些内容;
- Kafka 的实现有哪些独特之处,实现的诸多细节中,我们应该关注哪些内容。
通过对这些案例的解读,你会切实地感受到融会贯通的好处,真正做到快速了解一个软件的设计。慢慢地,当你在业务和技能上有了一定的积累,你将有机会做属于自己的设计。你负责的工作内容也将会从一个小功能到一个完整的小业务,从到一个模块到一个系统。随着你的能力不断提升,你负责的内容会逐渐增多,复杂度逐步升级,对你设计能力的要求也会随之攀升。
这时,你就需要掌握一些软件设计的基础知识。我会把软件设计中最重要的部分交付给你,包括:
- 程序设计语言;
- 编程范式;
- 设计原则;
- 设计模式;
- 设计方法。
程序设计语言,是软件设计落地的基础。任何设计都依赖程序设计语言来实现。但任何语言也都有自己的局限,我将带领你横跨语言学语言,让你不再局限于某一种语言,而是择其善者而从之,更好地落地你的设计。
编程范式,是代码编写的风格,决定着你在设计时可以用到哪些元素:是模块、是对象,还是函数。在不同层次的设计中,选择不同的编程范式已经成为今天开发的主流。在这个主题下,我选择了几个最主流的编程范式,包括结构化编程、面向对象和函数式编程,帮你建立起软件设计的根基。
设计原则,是你在进入到具体设计的层面时,可以用来评判自己工作结果的一个衡量标准。我会给你介绍面向对象的主流设计原则:SOLID 原则。一来面向对象是当今的主流开发方式,二来 SOLID 原则也是比较成体系的设计原则,它本身也在不断发展。
设计模式,是设计原则在具体场景下的应用。不过,这个话题展开之后,内容会非常多,而且有很多书和课程都讲到了,所以,我并不准备把它当作重点。但我会和你分享一些学习设计模式的心得,帮助你将设计模式的相关知识贯穿起来。
当你手里有了诸多工具之后,接下来就需要用这些工具去做自己的设计了。这就轮到设计方法登场了。我会用领域驱动设计(也就是 DDD,Domain Driven Design)进行讲解,这是目前最为完整、有效的应对复杂业务场景的设计方法,包括了从如何识别概念到如何建立模型。在这个课程中,我准备将 DDD 的基础知识贯穿起来,做一个结构性的介绍。你会发现,有了前面知识的铺垫,DDD 理解起来一点都不困难。
有了基础知识,在课程最后,我们还会在巩固篇中操练一下,将学到的软件知识应用起来。在这个模块中,我会结合自己的开源项目 Moco,来讲讲如何设计一个程序库;还会借着一个数据采集的项目,谈谈如何构建起一个可扩展的模型。另外,因为大多数人在实际工作中面对的都是一个既有的项目,所以,我还会讲讲,如何对既有项目做设计上的改进。
写在最后
最后,再自我介绍一下。我叫郑晔,一个从业近二十年的程序员,《10x 程序员工作法》专栏作者。很高兴又回到极客时间,和你分享我对软件设计的理解。如果说《10x 程序员工作法》这门课是在告诉你要做正确的事,做有价值的需求,别把时间浪费在不该做的事情上,那《软件设计之美》这门课就是告诉你如何把事做对,如何建立有效的模型,划清模块之间的边界,所以,二者可谓一脉相承。
不想当将军的士兵不是一个好士兵,不想做设计的程序员不是一个好程序员。写程序的人谁不想操刀一个大型的系统,但不懂软件设计的人能摆弄的代码规模是有限的,而这也限定了一个人的成长高度。学习软件设计,是让你的把控能力从一段代码扩展到一个模块,再扩展到一个子系统,再扩展到一个大系统的必备手艺,是程序员从“家常菜厨师”到“大厨”的进阶之路。
不过,你也不必把软件设计想象得过于高大上,很多设计理念既可以用来设计一个系统,也可以运用于日常开发之中,它就在你的身边。今天多学习一点设计,明天就能多发现一个问题。
如果你曾与我一样,走入过软件开发的迷途;如果你希望自己的软件开发能力再上层楼;或者你只是对软件设计充满好奇,那么,欢迎加入我的课程。也欢迎你把自己的现状和预期写在留言区,当课程结束时,让我们共同见证你的成长!你准备好了吗?让我们正式开启软件设计之旅,一起领略软件设计的美妙!
01、软件设计到底是什么
一个软件需要设计,这是你一定认同的。但软件设计到底是什么,不同的人却有着不同的理解:
- 有人认为,设计就是讨论要用什么技术实现功能;
- 有人认为,设计就是要考虑选择哪些框架和中间件;
- 有人认为,设计就是设计模式;
- 有人认为,设计就是 Controller、Service 加 Model;……
你会发现,如果我们按照这些方式去了解“软件设计”,不仅软件设计的知识会很零散,而且你会像站在流沙之上一般:
- 今天你刚学会用 Java,明天 JavaScript 成了新宠,还没等你下定决心转向,Rust 又成了一批大公司吹捧的目标;
- 你终于知道了消息队列在解决什么问题,准备学习强大的 Kafka,这时候有人告诉你 Pulsar 在某些地方表现得更好;
- 你总算理解了 Observer 模式,却有人告诉你 JDK 中早就提供了原生的支持,但更好的做法应该是用 Guava 的 EventBus;
- 你好不容易弄清楚 MVC 是怎样回事,却发现后端开发现在的主要工作是写 RESTful 服务,Controller 还没有用,就应该改名成 Resource 了;
- ……
我们说,软件设计要关注长期变化,需要应对需求规模的膨胀。这些在不断流变的东西可能还没你的软件生命周期长,又怎能支撑起长期的变化呢!
那么回到一开始的问题,软件设计到底是什么呢?
核心的模型
在回答这个问题之前,我们先来思考这样一件事:软件的开发目的是什么?一个直白的答案就是,软件开发是为了解决由需求带来的各种问题,而解决的结果是一个可以运行的交付物。比如,我们在线购物的需求,是通过电商平台这个方案解决的。那软件设计在这个过程中做的事情是什么呢?就是在需求和解决方案之间架设一个桥梁。
\t区别于解决简单的问题,软件的开发往往是一项长期的工作,会有许多人参与其中。在这种情况下,就需要建立起一个统一的结构,以便于所有人都能有一个共同的理解。这就如同建筑中的图纸,懂建筑的人看了之后,就会产生一个统一的认识。
而在软件的开发过程中,这种统一的结构就是模型,而软件设计就是要构建出一套模型。这里所说的模型,不仅包括用来描述业务的各种实体,也包括完成业务功能的各种组件。人们写代码中常常会用到的服务(Service)、调度器(Scheduler)等概念就是一个一个的模型。
**模型,是一个软件的骨架,是一个软件之所以是这个软件的核心。**一个电商平台,它不用关系型数据库,还可以用 NoSQL,但如果没有产品信息,没有订单,它就不再是电商平台了。
可能有不少人一听到模型,就会情不自禁地要打退堂鼓,认为这些内容过于高大上,其实大可不必,模型的粒度可大可小。如果把模型理解为一个一个的类,是不是你就会熟悉很多了,这就是小的模型。你也可以把一整个系统当作一个整体来理解,这就是大的模型。
关于设计,你一定听说过一个说法,“高内聚、低耦合”,(模块的内聚程度越高越好,模块间的耦合程度越低越好),这其实就是对模型的要求。一个“高内聚、低耦合”的模型能够有效地隐藏细节,让人理解起来也更容易,甚至还可以在上面继续扩展。比如,我们后面课程会讲到的程序设计语言,就是提供了一个又一个的编程模型,让我们今天写程序不用再面对各种硬件的差异,还能够在此基础上继续提供新功能。
你在日常工作中用到的各种框架和技术,也是提供了一个又一个的模型,它们大幅度降低了我们的开发门槛。所以你看,整个计算机世界就是在这样一个又一个模型的叠加中,一点一点构建出来的。用一个程序员所熟悉的说法就是:模型是分层的。这就像乐高一样,由一个个小块构建出一个个大一些的部件,再用这些部件组成最终的成品。
这与一些人常规理解的 Controller、Service 那种分层略有差异。但实际上,这才是在计算机行业中普遍存在的分层。我们熟悉的网络模型就是一个典型的分层模型。按照 TCP/IP 的分层方法,网络层要构建在网络接口层之上,应用层则要依赖传输层,而我们平时使用的大多数协议则属于应用层。
\t即便是在一个软件内部,模型也可以是分层的。我们可以先从最核心的模型开始构建,有了这个核心模型之后,可以通过组合这些基础的模型,构建出上面一层的模型。
我曾经做过一个交易系统的设计。在分析了主要的交易动作之后,我提出了一个交易原语的概念,包括资产冻结、解冻、出金、入金等少数几个动作。然后,把原先的交易动作变成了原语的组合。比如,下单是资产冻结,成交是不同账户的出金和入金,撤单则是资产解冻。
\t在这个结构下,由交易原语保证每个业务的准确性,由交易动作保证整个操作的事务性。从上面这个图中,你可以看出,这就是一种分层,一种模型上的分层。好,到这里我们已经对软件设计中的模型有了一个初步的认识。总结一下就是,模型是一个软件的核心;模型的粒度可大可小;好的模型应该“高内聚、低耦合”;模型可以分层,由底层的模型提供接口,构建出上层的模型。
后续我们这个课程的大部分内容都会围绕着模型来讲:怎样理解模型、建立模型、评判模型的优劣等等。学会这些知识之后,能在多大的粒度上应用它们,你就能掌控多大的模块。不过,仅仅是把软件设计理解成构建模型,这个理解还不够。模型设计也不能任意妄为,需要有一定的约束,而这个约束,就是软件设计要构建的另一个部分:规范。
约束的规范
如果说,软件设计要构建出一套模型,这还是比较直观好理解的。因为模型通常可以直接体现在代码中。但软件设计的另一部分——规范,就常常会被忽略。规范,就是限定了什么样的需求应该以怎样的方式去完成。比如:
- 与业务处理相关的代码,应该体现在领域模型中;
- 与网络连接相关的代码,应该写在网关里;
- 与外部系统集成的代码,需要有防腐层;
- ……
其实,每个项目都会有自己的规范。比如,你总会遇到一些项目里的老人,他们会告诉你,这个代码应该写在这,而不应该写在那,这就是某种意义上的规范。虽然规范通常都有,但问题常常也在。
一种常见的问题就是缺乏显式的、统一的规范。
规范的一个重要作用,就是维系软件长期的演化。如果没有显式的规范,项目的维系只能依靠团队成员个人的发挥,老成员一个没留神,新成员就可能创造出一种诡异的新写法,项目就朝着失控又迈出了一步。不知道你是否接触过这样的项目,多种不同的做法并存其中:
- 数据库访问,有用 MyBatis 的,有用 JDBC 的,也有用 Hibernate 的;
- 外部接口设计,有用 REST 风格的,有用 URL 表示各种动作的;
- 文件组织,有的按照业务功能划分(比如,产品、订单等),有的按照代码结构划分(比如,\bResource、Service 等);
- ……
没有一个统一的规范,每一个项目上的新成员都会痛斥一番前人的不负责任。然后,新的人准备另起炉灶,增加一些新东西。这种场景你是不是很熟悉呢?混乱通常就是这样开始的。如果存在一个显式的、统一的规范,项目会按照一个统一的方向行进。即便未来设计要演化、规范要调整,有一个统一的规范也要比散弹打鸟来得可控得多。
关于规范,还有一种常见问题就是,规范不符合软件设计原则。我给你讲一个让我印象深刻的故事。
我曾经遇到一个网关出现了 OOM(Out of Memory,内存溢出)。这个网关日常的内存消耗高达 150G,一次流量暴增它就扛不住了。后来经过优化,把内存消耗降到了 8G。
如果单看数字,这是一个接近 20 倍的优化,大手笔啊,但这里面究竟发生了什么呢?实际上,这次优化最核心的内容就是构建了一个防腐层,将请求过来的 JSON 转换成了普通的内存对象。而原来的做法是把 JSON 解析器解析出来的对象到处使用,因为这些对象上附加很多额外的信息,导致占用了大量的内存。
很遗憾,这不是大牛战天斗地的故事,只是因为旧的规范不符合软件设计原则而导致的错误:外部请求的对象需要在防腐层转换为内部对象。
模型与规范
有了模型,有了规范,那模型与规范是什么关系呢?**模型与规范,二者相辅相成。**一个项目最初建立起的模型,往往是要符合一定规范的,而规范的制定也有赖于模型。这就像讨论户型设计时,你可以按照各种方式组合不同的空间(模型),却不会把厨房与卫生间放在一起(规范)。
至此,我们已经知道了,软件设计既包含构建出一套模型,也包括制定出相应的规范。再回过头来看这节课开头的问题,你是不是对软件设计有了重新的认识呢?特定技术、框架和中间件,只是支撑我们模型的实现,而设计模式、Controller、Service、Model 这些东西也只是一个特定的实现结果,是某些特定场景下的模型。
总结时刻
今天,我们学习了软件设计到底是什么,它应该包括“模型”和“规范”两部分:
- 模型,是一个软件的骨架,是一个软件之所以是这个软件的核心。模型的粒度可大可小。我们所说的“高内聚、低耦合”指的就是对模型的要求,一个好的模型可以有效地隐藏细节,让开发者易于理解。模型是分层的,可以不断地叠加,基于一个基础的模型去构建上一层的模型,计算机世界就是这样一点点构建出来的。
- 规范,就是限定了什么样的需求应该以怎样的方式去完成。它对于维系软件长期演化至关重要。关于规范,常见的两种问题是:一个项目缺乏显式的、统一的规范;规范不符合软件设计原则。
- 模型与规范,二者相辅相成,一个项目最初建立起的模型,往往是要符合一定规范的,而规范的制定也有赖于模型。
有了对软件设计的初步了解,我们就准备开始做设计了,但该从哪入手呢?这就是我们下一讲的内容。如果今天的内容你只能记住一件事,那请记住:软件设计,应该包括模型和规范。
02、分离关注点:软件设计至关重要的第一步
上一讲我们讲了软件开发就是在解决问题。那问题一般是如何解决的呢?最常见的解决问题思路是分而治之,也就是说,我们要先把问题拆分开。在每个问题都得到解决之后,再把这些解决好的子问题以恰当的方式组装起来。如何分解与组合,就是我们要在软件设计中考虑的问题。
然而,在软件设计这个环节中,大部分人都把焦点放在了如何组合上,却忽略了至关重要的第一步:分解。你可能会觉得:“分解?我会啊,不就是把一个大系统拆成若干个子系统,再把子系统再拆成若干个模块,一层一层拆下去嘛。”
然而,在我看来,这种程度的分解远远不够,因为分解出来的粒度太大了。粒度太大会造成什么影响呢?这会导致我们把不同的东西混淆在一起,为日后埋下许多隐患。
为什么这么说呢?我来给你举个例子。
一个失败的分解案例
我曾经见过一个故障频出的清结算系统,它的主要职责是执行清结算。一开始我觉得,清结算系统是一个业务规则比较多的系统,偶尔出点故障,也是情有可原。
但是在分析了这个系统的故障报告后,我们发现这个系统设计得极其复杂。其中有一处是这样的:上游系统以推送的方式向这个系统发消息。在原本的实现中,开发人员发现这个过程可能会丢消息,于是,他们设计了一个补偿机制。
因为推送过来的数据是之前由这个系统发出去的,它本身有这些数据的初始信息,于是,开发人员就在数据库里增加了一个状态,记录消息返回的情况。一旦发现丢消息了,这个系统就会访问上游系统的接口,将丢失的数据请求回来。
正是这个补偿机制的设计,带来了一系列的后续问题。比如,当系统业务量增加的时候,数据库访问的压力本身就很大,但在这种场景下,丢数据的概率也增加了,用于补偿的线程也会频繁访问数据库,因为它要找出丢失的数据,还要把请求回来的数据写回到数据库里。
也就是说,一旦业务量上升,本来就已经很吃力的系统,它的负担就更重了,系统出现卡顿也就在所难免了。这个补偿机制的设计是有问题的,问题的点在于,上游系统向下游推送消息,这应该是一个通信层面的问题。而在原有的设计中,因为那个状态的添加,这个问题被带到了业务层面。
这就是一个典型的分解没有做好的例子,是分解粒度太大造成的。开发人员只考虑了业务功能,忽视其他维度。技术和业务被混在了一起,随之而来的就是无尽的后患。
一旦理解了这一点,我们就可以想办法解决了。既然是否丢消息是通信层面的事,我们就争取在通信层面解决它。我们当时的解决方案是,选择了一个吞吐量更大的消息队列。在未来可见的业务量下,消息都不会丢。**通信层面的问题在通信层面解决了,业务层面也就不会受到影响了。**果不其然,这样改造之后,系统的稳定性得到了大幅度的提升。
上面我只讲了这个故事的主线,其实,相关的事情还有一些。比如,上游系统专门为补偿而开发的接口,现在也不需要了,于是上游系统得到了简化;这个系统里那个表示状态的字段,其实还被用在了业务处理中,也引发过其他问题,现在它只用在业务处理中,角色单一了,与此相关的问题也少了。
分离关注点
至此,我们已经对分解粒度太大所造成的影响,有了一个初步的了解。那在做设计时,该如何考虑分解呢?传统上,我们习惯的分解问题的方式是树型的。比如,按功能分解,可分为:功能 1、功能 2、功能 3,等等,然后,每个功能再分成功能 1.1、功能 1.2、功能 2.1、功能 3.1 等等,以此类推。
如果只从业务上看,这似乎没什么问题。但我们要实现一个真实的系统,就不仅仅要考虑功能性的需求,还要考虑非功能性的需求。比如,前面提到的数据不能丢失、有的系统还要求处理速度要快,等等。
这与业务并不是一个维度的事情,我们在做设计时,要能够发现这些非功能性的需求。也就是说,我们在分解问题的时候,会有很多维度,每一个维度都代表着一个关注点,这就是设计中一个常见的说法,“分离关注点(Separation of concerns)”。
可以分离的关注点有非常多,你只要稍微注意一下,就能识别出来。但还有一些你可能注意不到,结果导致了混淆。最常见的一类问题就是把业务处理和技术实现两个关注点混在了一起,前面举的那个例子就是一个典型。
对于“把业务处理和技术实现混在一起”的问题,我再给你举个例子。如果现在业务的处理性能跟不上,你有什么办法解决吗?大多数程序员的第一反应是,多线程啊!
没错,多线程的确是一种解决办法。但如果不加限制地让人去把这段代码改成多线程的,一些多线程相关的问题也会随之而来。比如,让人头疼的资源竞争、数据同步等等。
写好业务规则和正确地处理多线程,这是两个不同的关注点。如果我们把二者放到同一段代码里去写,彼此影响也就在所难免了。问题说明白了,解决方案才能清楚,那就是把业务处理和多线程处理的代码分开。
按照我的理解,大部分程序员都不应该编写多线程程序。由专门的程序员把并发处理的部分封装成框架,大家在里面写业务代码就好了。
把业务处理和技术实现混在一起,类似问题还有很多。比如我们经常问怎么处理分布式事务,怎么做分库分表等。其实,你更应该问的是,我的业务需要分布式事务吗?我是不是业务划分没有做清楚,才造成了数据库的压力?
在真实项目中,程序员最常犯的错误就是认为所有问题都是技术问题,总是试图用技术解决所有问题。任何试图用技术去解决其他关注点的问题,只能是陷入焦油坑之中,越挣扎,陷得越深。
另外一个常见的容易产生混淆的关注点是不同的数据变动方向。
有人问过我这样一个问题:在 Java 应用里,做数据库访问用 Spring Data JPA 好,还是 MyBatis 好。Spring Data JPA 简化了数据库访问,自动生成对应的 SQL 语句,而 MyBatis 则要自己手写 SQL。
普通的增删改查用 Spring Data JPA 非常省事,但对于一些复杂场景,他会担心自动生成 SQL 的性能有问题,还是手写 SQL 优化来得直接。是不是挺纠结的?随即我又问了他一个问题,为什么需要复杂查询呢?他告诉我,有一些统计报表需要。
不知道你是否发现了其中混淆关注点的地方?普通的增删改查需要经常改动数据库,而复杂查询的使用频率其实是很低的。从本质上说,之所以出现工具选择的困难,是因为他把两种数据使用频率不同的场景混在一起所造成的。如果将前台访问(处理增删改查)和后台访问(统计报表)分开,纠结也就不复存在了。
不同的数据变动方向还有很多,比如:
- 动静分离,就是把变和不变的内容分开;
- 读写分离,就是把读和写分开;
- 前面提到的高频和低频,也可以分解开;
- ……
不同的数据变动方向,就是一个潜在的、可以分离的关注点。
在实际的项目中,可以分离的关注点远不止这些。做设计时,你需要一直有一根弦去发现不同的关注点。分离关注点,不只适用于宏观的层面。
在微观的代码层面,你用同样的思维方式,也可以帮助你识别出一些混在一起的代码。比如,很多程序员很喜欢写 setter,但你真的有那么多要改变的东西吗?实际上可能就是封装没做好而已。
分离关注点之所以重要,有两方面原因。一方面,不同的关注点混在一起会带来一系列的问题,正如前面提到的各种问题;另一方面,当分解得足够细小,你就会发现不同模块的共性,才有机会把同样的信息聚合在一起。这会为软件设计的后续过程,也就是组合,做好准备。
总结时刻
今天,我们学习了软件设计中至关重要的第一步:分解。大多数系统的设计做得不够好,问题常常出现在分解这步就没做好。常见的分解问题就是分解的粒度太大,把各种维度混淆在一起。在设计中,将一个模块的不同维度分开,有一个专门的说法,叫分离关注点。
分离关注点很重要,一方面,不同的关注点混在一起会带来许多问题;另一方面,分离关注点有助于我们发现不同模块的共性,更好地进行设计。分离关注点,是我们在做设计的时候,需要时时绷起的一根弦。今天,我还给你举了两种常见的关注点混淆的情况。一种是技术和业务的混淆,另一种是不同数据变动方向的混淆。希望你在日常开发中,引以为戒。
好,我们已经迈出了软件设计的第一步。接下来,就该考虑如何组合了。在组合的过程中,会有很多因素影响到组合的方式。下一讲我们就来看一个非常重要却不受重视的因素:可测试性。如果今天的内容你只能记住一件事,那请记住:分离关注点,发现的关注点越多越好,粒度越小越好。
最后我想请你去了解一下 CQRS(Command Query Responsibility Segregation),看看它分离了哪些关注点,以及在什么样的场景下使用这种架构是合理的。欢迎在留言区写下你的想法。
-
cqrs,命令与查询分离,最早是在ddd实战里面看到。其分离啦增删改与查询这两个关注点。
-
静态上,拆分了这两块的代码。使各自可以采用不同的技术栈,做针对性的调优。动态上,切分了流量,能够更灵活的做资源分配。
-
查询服务的实现。可以走从库,这有利于降低主库压力,也可以做到水平扩展。但需要注意数据延迟的问题。在异步同步和同步多写上要做好权衡。
-
可以怎样去练习做分离呢?
-
有一种从小事练起的方法,就是写代码时,把自己写的函数行数限定在一定的规模之下,比如,10行。超过10行的代码,你就要去仔细想想是否是有东西混在了一起。 这种方法锻炼的就是找出不同关注点的思维习惯,一旦你具备了这种思维习惯,再去看大的设计,自然也会发现不同的关注点。
03、可测试性: 一个影响软件设计的重要因素
上一讲,我们讲了软件设计的第一步:分离关注点。作为至关重要的第一步,分离关注点常常被人忽略,严重影响了设计的有效性。这一讲,我们再来看另一个经常被很多人忽视的因素:可测试性。在讨论可测试性之前,我们不妨先来思考一个问题:你觉得软件开发中最浪费时间的环节是什么?答案肯定不是写代码,因为写代码是一个建设的过程,谈不上是在浪费时间。在我接触过的诸多项目里,集成测试可以说是一个浪费时间的大户。
那你的项目是怎么做集成测试的呢?一个常见的测试场景是这样的:你先花了一些时间打包部署一个服务端应用,然后开始测试。测着测着,你发现一个 Bug,然后调查半天,最后发现是一个简单的错误。你就在心里暗恨,为啥写代码的时候没发现呢!
这还只是一个简单的场景,也有稍微复杂一点的。比如,有多个不同项目组的人一起联合测试。当你测出一个 Bug,然后辛辛苦苦调查半天,发现是另外一个模块出了问题,你唯一能做的就是等着那个组的同事把 Bug 改好,测试才能进行下去。更可恨的是,他们查了半天,结果也是一个简单的错误。你会在心里嘀咕,为啥写代码的时候不仔细一点呢?
在实际工作中,我们经常遇到类似的场景。你觉得这种状态正常吗?可能很多人对此习以为常。虽然难受,却不得不忍受。但我想说的是,这样的问题原本有机会得到优化。而出现这样的问题,主要原因就在于前期设计时就埋下了隐患,你根本没有考虑“可测试性”。
软件设计要考虑“可测试性”
我们知道,软件开发要解决的问题是从需求而来。需求包括两大类,第一类是功能性需求,也就是要完成怎样的业务功能;第二类是非功能性需求,是业务功能之外的一些需求。
非功能性需求也被分为两大类,一类称为执行质量(Execution qualities),你所熟悉的吞吐、延迟、安全就属于这一类,它们都是可以在运行时通过运维手段被观察到的;而另一类称为演化质量(Evolution qualities),它们内含于一个软件的结构之中,包括可测试性、可维护性、可扩展性等。
做设计的时候,功能性需求自不必说,你肯定会考虑到。在非功能性需求中,执行质量是很多程序员的心头爱,一般也不会被忽略。但演化质量的地位却很低,常常为人忽略,尤其是其中的“可测试性”。我们在开发过程中欠下的很多技术债,本质上都是因为忽略了“可测试性”这个需求。
可测试性为什么如此重要?因为我们做设计,其实就是把一个软件拆分成一个一个的小模块。如果不尽可能地保证每个小模块的正确性,而只是从最外围的系统角度去验证系统的正确性,这将会是一个非常困难的过程。就和盖楼是一个道理,不保证钢筋、水泥、砖土质量合格,却想要盖出合格的大楼来,很荒唐吧!然而,很多团队的软件开发就是这么做的。
我们要保证每个小模块的正确性,就要保证每个模块在开发阶段能够测试,而想要每个模块能够测试,在设计过程中,就要保证每个模块是可以测试的,而这就是可测试性。
一旦我们在可测试性上考虑不足,就会引发一系列的后续问题。比如,复杂的系统不仅仅在测试上有难度,在集成、部署等各个环节,都有其复杂性,完成一次部署往往也需要很长时间。
这也就意味着,即便是一个简单的验证工作,部署的时间成本也非常昂贵。这还不包括在出问题时,我们在一个复杂系统中定位问题的成本。我们只有把每个小模块尽可能做好,才能尽量降低对集成环境的依赖程度,从而节省后期的成本。这就相当于在前面多花了 1 块钱,却省下了后期的 10 块钱。
我们回过头思考一下这节课刚开始提到的那个问题,为什么我们在集成测试场景中,会浪费那么多时间呢?因为这个系统只能在集成测试环境中进行测试,所以,即使是一些非常简单的问题,也只能在这阶段暴露。这些问题原本可以在更前面的阶段解决,比如,单元测试。
可为什么这些问题会遗留到集成测试环境呢?很多程序员给你的回答都会是,不好测。而这不好测的背后,往往就是因为在设计中没有考虑“可测试性”这个因素。那么如何在设计中考虑可测试性呢?其实就是要在设计时想一下,这个函数 / 模块 / 系统要怎么测。
当你用这个标准衡量一些系统时,可能就会发现一种典型的错误,就是设计根本没有考虑过测试。这样的系统常常只有最外层的接口可以测试,也就是说,整个系统必须集成起来才能测试。前面提到的集成测试的问题犯下的就是这种错误。
在实际工作中,很多公司为了做集成测试,要把所有的子系统全部都搭建出来,也就是一套完整的环境。这种环境要占用大量的资源,一般来说,公司不会准备很多套。这样造成的结果就是各个团队对于环境的竞争,再叠加上各个系统配合的问题,测试的效率还会进一步降低。所以,我们在设计一个函数 / 模块 / 系统时,必须将可测试性纳入考量,以便于能够完成不同层次的测试,减少对集成环境的依赖。
那么,具体该如何做呢?一方面,尽可能地给每个模块更多的测试,使构成系统的每个模块尽可能稳定,把集成测试环境更多地留作公共的验收资源。另一方面,尽可能搭建本地的集成测试环境,周边的系统可以采用模拟服务的方案。
在软件开发过程中考虑测试,实际上是思考软件的质量问题,而把质量的思考前移到开发,甚至是设计阶段,是软件开发从传统进入到现代的重要一步。
当你有了可测试性的视角
现在你已经对软件设计中的可测试性有了一个初步的认识。其实,在了解可测试性之后,我们还可以把它作为一个衡量标准来考察已有的设计。比如,有一个设计模式叫 Singleton,通常的做法是把构造函数做成私有的。如果这个 Singleton 的类与其他组件配合,由于这个私有函数的存在,这个类无法继承,也就不能用一个子类对象去模拟它。所以,从可测试性的角度来看,Singleton 就不是一个好的设计模式。
再比如,TDD(Test-Driven Development,测试驱动开发)对于很多人来说都非常困难,主要有两方面原因。一方面,这些人不习惯先写测试的工作方式,但另外一方面,也是更重要的原因,是他们不知道怎么测试。
因为很多模块的设计根本没有考虑过如何做测试,要把它们单独拿出来测试,必然会遇到很多问题。举个例子,在通常的架构中,服务会调用数据库访问的代码。如果是不考虑测试的做法,代码可能写成这样:
在这里,我们要直接创建数据库访问的对象,然而,要创建数据库访问对象,就要同时把数据库连接起来,你要准备一大堆相关的东西,所以,测试的复杂度就会非常大。可是,测试这个服务目的是,关心这个服务的逻辑是不是写正确了,这与是不是用数据库没关系啊!所以,如果我考虑了可测试性,服务的依赖就变成了一个数据访问的接口:
在这种代码里,我们只需要将数据访问的接口模拟出来,而用来模拟接口的 Mock 框架在各种程序语言里几乎都可以找到。我们唯一要保证的,就是模拟出来的对象要与接口定义的行为保持一致,不过,这可比准备数据库,难度系数要低多了。
真正懂得了可测试性,还可以帮助我们理解软件开发的趋势。有些 Java 工作经验的同学可能听说过 EJB(Enterprise Java Beans),它是 2000 年左右的开发主流。当时一个 Java 系统如果没用到 EJB,你都不好意思和人打招呼。但是,今天你很难听说有谁还在用 EJB 做新系统了。
在每次测试时,EJB 都需要部署到专门的应用服务器上。站在可测试性的角度看,它的测试成本就是极其高昂的,相应的开发成本也就变得很高。
当年与 EJB 竞争的正是当今如日中天的 Spring,Spring 胜出的一个重要原因就是它简化了开发。它当年的口号正是 without EJB。这是一种重要的开发趋势:轻量级开发。而这背后,重要的思维基础,就是可测试性。后面在第五讲中,我们会讲到 SpringDI 容器的设计,你会进一步看到可测试性在其中发挥的作用。
实际上,Spring 在简化开发的道路上从未停下脚步。今天的 Java 程序员使用 Spring Boot 的时候,启动它就像启动一个普通的 Java 应用,在 IDE 里做各种调试,甚至都没有注意到它启动时,下面有一个 Tomcat。要知道,当年可是要打出一个 WAR 包,部署到 Tomcat 上。所以,曾几何时,能够连接远程的 Web 服务器是 IDE 一项重要的功能,而这项功能在今天来看,已经非常鸡肋了。
总结时刻
今天,我们学习了一个影响软件设计的重要因素:可测试性。在软件设计中,可测试性常常被人忽视,结果造成了很多模块的不可测,由此引发了很多技术债。所以,在设计中就要充分考虑可测试性。
在设计中考虑可测试性,就是在设计时问一下,这个函数 / 模块 / 系统怎么测。在软件开发中,只有把一个一个的小模块做了足够的测试,我们才会有稳定的构造块,才可以在集成测试的时候,只关注最终的结果。而有了可测试性的视角,我们可以把它当作一个衡量标准去看待其他的设计或实践,也可以用它帮助我们理解软件的发展趋势。
经过前几讲基础知识的铺垫,你对软件设计已经有了一个初步的了解。下一讲,我们将进入到实际的工作环节中,去了解一个软件的设计。如果今天的内容你只能记住一件事,那请记住:做软件设计,请考虑可测试性。
最后,我想请你回想一下,如果以可测试性衡量一下你开发过的系统,它的可测试性如何?有哪些问题是由于最初没有考虑可测试性造成的呢?欢迎在留言区分享你的经历。
