软件设计中的正交
数学中的正交
最早听到正交这个词还是高中数学课上,当时并没有特别的印象,无非是把它当成是一个数学公式,主要还是为了做题。
数学上的正交非常直观,表示的是两个相互垂直的向量,再进一步,它们的内积为 0。比如,在直角坐标系中,假设有向量 $\vec{A} = [x_A, y_A]$ 和向量 $\vec{B} = [x_B, y_B]$,这两个向量的 内积 就是对应位置相乘再求和:
\[\vec{A} \cdot \vec{B} = x_A * x_B + y_A * y_B\]如果 $ \vec{A} \cdot \vec{B} $ 等于 0,那么这两个向量就是垂直的,是正交的,比如 $[0,1]$,$[1,0]$ 就是两个正交的向量。当然,我们还可把向量扩展到更高的维度去,内积的计算方式依然是对位相乘再求和,正交的定义依然不变:
\[\vec{A} \cdot \vec{B} = x_A * x_B + y_A * y_B + z_A * z_B + ...\]正交的延伸
还是上面的式子,还是向量 $\vec{A}$ 和 $\vec{B}$,不过我们把向量的表示替换一下:
\[\vec{A} \cdot \vec{B} = fun1_A * fun1_B + fun2_A * fun2_B + fun3_A * fun3_B + ...\]如果把一个向量看成是一个整体,$funN$ 表示的是它的成分,如果它不含有某个成分,那么对应位置的数值就是 0。当我们把两个向量(整体)放在一起比较,如果两个向量(整体)的成分完全不重叠,那他们就是正交的。用大白话说就是,你有的不管多少我都没有,我有的不管多少你都没有,我们两个就是正交的。
上面说的整体可以是一个系统、一个模块、一个组织、一个产品、一个理论、一个设计、一个框架等等。可这有什么意义呢?两个东西正交了又能说明什么呢?正交可以让相关的两个整体效率最大化。举个例子,一个公司里,如果一个部门在开发一个产品,而另一个部门在开发另外一个产品,这两个产品在功能上有很大的重叠,那么这个公司所产生的效益就没有最大化,资源也没有得到合理的分配。
试想一下,如果你发现你和你的同事干了同样的事,两个人同时花时间解决了相同的问题,你们会不会对自己的职责感到困惑?你负责系统 A,我也负责系统 A,他也负责系统 A,那么系统 A 出问题了该由谁来负责?谁来解决?所以,非正交也会带来职责的不清晰,沟通成本的增加,整体效率的下降。
软件设计中的正交
现在回到正题,正交对我们平时设计软件、写代码有何借鉴意义?什么方法可以帮助我们设计出正交的软件?
系统中的正交
当下,微服务架构盛行,每个服务都可以看成是一个整体。公司里的某个人或者团队负责一个服务,这样职责划分清晰,而且也不需要去关注整体复杂度,相信不用我说你也能感受到微服务的好处。可切换到微服务就不需要考虑设计了吗?依然要考虑,只不过这个考虑是系统层面的,而不是模块或者代码层面的,一个服务要做的事情,以及它如何与其他服务之间的关系就是我们设计的重点。
首先是要保证新服务的功能不会与现有服务重叠,如有重叠则需要考虑合并。其次,要知道,服务之间通信所带来的延迟要比你引用一个模块,或者调用一个函数大多了,因此这个请求链或数据链就不宜过长。
要做到服务之间最大程度的正交,还有一个技巧——对服务进行层级划分,如下图所示:
这里 “层级” 就变成了一个服务之上的抽象,来辅助我们设计出正交的系统,每一层的服务只依赖于其下一层的服务。比如这里,展示层(Presentation Layer)只会调用业务层(Business Layer)的服务接口,业务层只会调用持久层(Persistence Layer)的服务接口,持久层只会调用数据层(Database Layer)的服务接口。在分层过后,你的请求链的长度通常来说就会由层数决定,只要设计合理,都不会太长。
这就完了吗?并没有,如果说知道了几个技巧,使用一下就可以做好,那么这世界上就不存在糟糕的设计了。这里的诸多细节我们均没有考虑,比如一个服务可不可以依赖于下一层的多个服务?它们之间的 API 是怎么定义的?数据是怎么传输的?会不会有阻塞?这些问题的回答均会给整个系统带来影响。
每当你觉得系统设计好了的时候,就问自己一个问题——如果一个特定需求发生变化,需要更改多少服务? 如果答案是 1 个,那么你的系统就是正交的,至少说对这个需求来说是正交的,如果是多个,那么这个设计仍然有改进的空间。
另外,不管是做设计,还是写代码,都不要依赖那些容易变化或者你没办法掌控的东西,比如用用户的电话号码作为 ID,用其他服务或系统的参数来定义你自己服务的设计等等,这样的设计都会存在隐患。
代码中的正交
落到具体实现上,我们依然要考虑正交,不过这里的单位变成了模块,而非之前的服务。如何写好代码,讨论来讨论去都不会有最佳答案,但是很多原则性的东西还是有借鉴意义的,比如设计模式、SOLID 原则、KISS 原则等等,这些原则给我们写好代码提供了方向。我在 之前的文章 提到了如何衡量代码的好坏,其中有两点最为关键——去重 和 解耦。想要做到正交,解决重复代码,减少模块之间的耦合是关键。
如果模块之间存在有重复的代码,一是说明模块之间的功能有可能重叠,二是当这部分业务逻辑变化时,所有包含重复代码的模块都需要更改,这两点显然就违背了正交的定义。关于耦合,也是类似的,模块和模块相互依赖,与正交背道而驰。
你可能想知道,要让模块正交,写代码的时候我们该注意些什么呢?这里有几个建议:
- 尽量避免全局变量:注意,我这里说的是变量,不是常量。因为是全局的,这个变量可能被任何的模块修改,也可能被任何的模块访问,这里面的复杂关系就不言而喻了,如果再考虑多线程的话,复杂度又上升了,关系就更加混乱了。更为恰当的方式是显示地向模块中传入需要的变量,这样代码和数据更容易理解,也更容易维护。
- 避免相似的功能:每当我们往模块中添加功能时,都可以思考是否这个功能已经被实现或部分实现,如果是这样的话,我们就可以考虑代码重用了。这里有一个设计模式——策略模式——可以辅助我们更好地写出优雅的代码。
- 尽可能不制造依赖:举个例子,假如你要在一个模块中改变一个对象的状态,那么最好是让这个对象自己来完成,而非你手动更改,这二者有何区别呢?注意,要分清楚依赖和正常的关联。这里的对象可以看作是个模块,一个整体,它的内部状态应该由它自己来管理和维护,而非外界,它可以对外界提供公开的 API,并在其内部实现相关的应答、记录以及错误机制等等。这样,其他模块通过公开且支持的渠道来访问就是正常的关联。试想一下,如果一个对象内部的状态任何模块都可以直接访问并更改,那这个对象出了问题谁来负责?如何定位错误?一个对象的状态的维护和管理均取决于其他模块的操作,这样的关系就是依赖。
当然,也不止这些建议。要写出好的代码,正交的代码,最终还是需要自己多写、多练、多做不同的项目、多思考,只要我们反复回头审视自己写的代码,把不满意的部分想办法优化,慢慢地,你写出来的代码就会越来越正交。
文档中的正交
正交这个概念,不仅可以用在编程上,还可以用在其他地方,比如说写文档。文档主要包括 展示 和 内容 两部分,展示是文档的结构和呈现,内容是细节,是大家想要知道的东西。这两部分要尽量正交。理想情况下,对于一个真正正交的文档,不管你如何改变展示,内容都不需要进行任何的改动。
从正交看软件设计
在软件设计中,正交是一个被经常提起的概念,类似的还有 “高内聚,低耦合”,它们虽然没法非常准确地评估一个软件,但给了我们一个努力的方向。
软件设计能力的提升是循序渐进的。想要做好软件设计,需要心中有追求,不能带着完成任务的心态去设计,写程序。要时时刻刻都想着怎样才能把自己负责的部分做到不会出错,又怎样才能让他人非常容易地就理解自己设计出来的东西。并且还要时不时地回过头去想想那些前人总结出来的最佳实践,比如设计模式、编程范式、设计原则等等,不断尝试新的领域,不断经历,不断踩坑,不断犯错,又不断回过头去反思这些方法,你的软件设计能力就会逐步提高了。
Linus 说过,这世界程序员之所有高下之分,最大的区别就是程序员的 “品味” 不一样。是的,软件设计就是程序员的品味,争取做一个有品味的程序员:)