仓库源文

.. Kenneth Lee 版权所有 2021

:Authors: Kenneth Lee :Version: 1.0

确定逻辑的根


在前一个总结(\ :doc:一个逻辑链断裂引起架构设计方向错误的实例\ )中,我们讨论 了抓不同的设计目标怎样导致设计结果不同。

那么顺利成章地,我们就有必要接着讨论一下我们怎么抓设计目标了。

很多工程师希望用“客户的意见”和“领导的要求”来作为逻辑的根。这是某种稳定。这种稳 定的逻辑是:“如果后面变更了,(道理上)它不是我的错。”,这种稳定在更高的层面上 说也是不稳定的,因为这并没有满足客户或者领导的原始目标,他们确实批评不了你了, 但他们可以在行动上不再使用你的方案。所以,实际上,我们如果查看整个人类历史的时 间线,通常不是拍马屁的人左右了世界,而是解决了问题的人左右了世界。因为“客户”和“ 领导”也有他们逻辑的根,你解决不了这个根,他们本身就不稳定。客户和领导有他们的目 标,无论这个目标是否高尚,你满足不了这个目标,这个问题就没有解决,最终他们还是 要另想办法解决他们那个目标的。

这个推演告诉了我们两个结论:

第一,我们解决问题中的每个人,都是被整个系统的利益所左右的,这是个离散的关系, 不是一个一层一层向下控制的关系,你不能指望找到一个单一的控制点去控制整个系统。 并非一个问题是某个人说的,这个问题就可以万事大吉。所以,寻找一个逻辑最稳固的根, 必须从系统上找,而不是从人身上找。

第二,什么东西是稳定的,是个相对的概念,你的目标不同,我们看到的稳定的要素也不 一样。“我没错”和“解决客户问题”就是不同的目标。

相对稳定的逻辑的根怎么找呢?我们还是用例子来类比讨论。

比如说,我们还是做一个设备驱动,假设就是个网卡驱动吧。网卡驱动有很多逻辑,比如 把一个数据报文拼成一个硬件能够认识的格式,然后写到硬件指定的位置上。这个逻辑怎 么写,这些有依赖的。比如说,你需要基于协议栈是怎么把数据报文给你的,你才能设计 这个逻辑。而且正如我们前面索引的那个文档中的例子一样,这种逻辑也看角度,说在高 层逻辑中,协议栈这个概念是否存在,具有什么要的内涵,都是个问题。

我们就假设我们已经决定我们确实有协议栈,而且协议栈就是会把一个数据报文提供给驱 动了。那我们设计这个地方的逻辑,稳定的东西是什么呢?你可以说,这个数据报文的格 式,应该就是稳定的。但真的是这样吗?我们难道不能修改数据报文的格式?当然可以修 改了。只是修改这个报文的格式又需要付出成本修改协议栈的逻辑,你本来只是投资了实 现“驱动”的成本,现在你是否投得起修改协议栈的成本呢?

这就是我们判断“稳定”的原理。在这个例子中,协议栈对我们来说很稳定,因为我们投不 出修改它的成本。把这个原理推广到整个系统,你就会发现,这里的控制要素是“基于目标 的变更的成本”。比如协议栈,在整个系统中,它就比驱动难修改得多。因为驱动的逻辑 只依赖单独的协议栈,而协议栈的逻辑,依赖“很多的驱动”。修改协议栈,破坏了“其他驱 动”的目标。所以修改协议栈的外部接口,有更高的“基于目标的变更成本”。所以,被依赖 更多的实体,有更高的稳定度。用《道德经》的概念来说,它具有更高的“德”。所以持而 盈之,不如其已;揣而锐之,不可长保;金玉满堂,莫之能守;富贵而骄,自遗其咎。功 遂身退,天之道。一旦“功成”,就没有被依赖的机会了,要维持这个德,就必须选另一个 逻辑链,让自己重新在这个逻辑链中被依赖,或者让自己不出现在逻辑链中(无名),否 则就怎么都守不住。

.. note::

这也是为什么大部分时候我们设计构架,总是把逻辑一层层叠加上去,每个逻辑 对应一个目标,有优先级的是那个逻辑,而不是那个模块。我们在遇到逻辑障碍 的时候,首先切掉的是整个逻辑,而不是一个模块。

当然,反过来,我们尽量把相同的逻辑封闭在一个模块内部。但模块相对一个个 独立的逻辑,实在太粗燥了,精度和维度都不够。

所以,构架一个很重要的工作就是怎么对这些独立逻辑进行优先级排序,决定谁 的逻辑要“让”谁的逻辑。比如驱动的逻辑就需要让协议栈的逻辑,因为协议栈更 靠近用户,而用户的期望才是更稳固的逻辑。而协议栈的修改必须“让”用户的期 望,而用户的“表层需要”必须“让”它决定这个需要的背后动力。这样一个过程, 就叫“食母”。

上面是原理,我们再看点更接近现实的东西。当然,按照惯例,为了保密和突出问题,我 说的是一个根据实际情况修改过的版本,只是用来突出问题,请知道细节的人不要直接对 号入座,我们只是谈原理。

我们有一个设备,假设叫H吧,做了一个驱动D,为了让它在无数的应用场合中能稳定演进 下去,我们把D合入Linux的主线。这样它会被迫随着Linux主线的升级而升级。另一个产品 看中了这个设备的功能,他们找H的设计团队,基于这个H的IP代码,实现了另一个硬件, 我们姑且称为H2。H2和H几乎是一样的,但针对那个产品特定的场合做了部分的修改,他们 懒得和社区沟通那么多细节。所以他们没有使用D这个驱动,他们直接基于D另外写了一个 D2的驱动,单独用于H2。但他们还想用D的维护成果(比如修正一些Bug什么的),所以D2 的写法是D的一个外挂,H2和H不同的逻辑都从D中外调一个函数到D2p中完成那部分逻辑。

.. note:

请注意,我们这里的D和D2是软件,都是会持续升级的,除非它们拉了分支, 否则我们都用同一个名字表示这个持续升级的存在。

我们来Review一下这个设计:针对H做了一个H2,用于特定的市场,只要这个市场卖出足够 的钱,这个选择是没有问题的。但离开主线,不和别人兼容,这个分支就必须一路自己维护 下去,不能熊,或者只要这个产品的生命周期足够短,钱反正已经赚回来了,我们的策略都 可以有效。

但明显作者自己都不是这样认为的,否则他就不会写成D2=D+D2p这种方式了。但这件事本 身是个失败的选择。因为它没有构成稳定的逻辑根。我们这样说:D的维护者并不知道D2p 的存在,那么D在保证它的逻辑自洽的时候,是不考虑D2p的。那么你把D2p独立出来有什么 用呢?

我们前面就说过,模块一大目的就是封闭一组独立的逻辑。现在D2p封闭的是什么呢?它封闭 的是“所有D和D2的不同”,这不是个完整的逻辑,不需要独立维护的。而且我要分离两者的 区别,只要做一个diff就可以啦,用“模块分离”这种高成本的手段,毫无意义。

关键在于,这样的维护策略,会制造一种非常不好的架构暗示:D修改了,只要能编译得过 ,不修改D2p,D2就是好的。但从逻辑的角度来说,这根本不成立。D2的质量就没法让人相 信了。

实际的发展是,D2的维护人员自己都不想继续维护这个版本了,因为飞线实在太多了。

然后我们接着看后面的故事。D和D2都在向前发展,IP开发组不希望继续维护两个IP了,所 以在后面的版本中,他们合并了H和H2的特性,做成一个新的IP,H3。我们代表D提了一个 最基本的要求:H3必须完全兼容H。因为我们已经有很大的存量市场了,这些市场不会再升 级D,如果H3改变这一点,存量市场就没法用H3去销售了。

而D2的开发组也注意到原来的维护方式成本太高,他们希望从H3开始,直接使用D的驱动。 为了实现这个目的,他们在D的驱动数据结构中放了一个d2_private的数据结构,当遇到在 D3的实现中,原来D和D2不一样的地方,就走不同的路径。

好了,我们再来Review一下这个方案。D2现在是个现实的存在,但我们应该清楚D2从一开 始存在的原因:D2是因为不肯标准化所以才不去解决那些和别人共存的问题,从而拉了一 个独立的分支的。这个问题不会因为时间的推移而改变。如果我们把d2原来的逻辑再接进 来,原来留下来的锅,一个都不会少。现在不去解决这些问题,而直接创建一个 d2_private,走另外一个分支,就觉得可以解决问题?这个选择从方向上就错了,不面对 问题。

这个判断上,D的逻辑,大于D2的逻辑,D2必须绕着D的逻辑走,如果走不过去,只能是D2 接着拉分支。因为D在整个系统中有更高的德。

从另一方面来说,既然D和D2的逻辑可放在同一个实现中,它们的分别就应该不以硬件版本 来区分,而应该是以功能来区分。比如,D选择用Latency优先的算法,D2选择用Bandwith 优先的算法,那么,如果要把D2的逻辑合并到D中,就应该让D有一个配置参数,当用于H2 的硬件的时候,配置为Bandwith优先的算法。而不是根据当前硬件是H2,直接走另一个分 支。当两个逻辑能被合并在一起,他们就必须有共同的逻辑根,而这个根,必须是在我们 研究的范围内德高的东西。对于这个例子中的情形,H和H2的逻辑已经被合并处理了,除非 你能从用户的角度定义出两个应用场景,否则,用H一方的需求和H2一方的需求这样来区分 逻辑,整个一体的代码就被分解成两个部分,这还不如不合并呢。

我们再说得清楚一点:如果我们在一个逻辑上下文中,这样写代码:

.. code-block: python

if global_options.latency_first: serve_with_latency_first_algorithm() else: serve_with_bandwith_first_algorithm()

这个逻辑可以相对封闭。但如果你这样写:

.. code-block: python

if global_options.device_type != D2 serve_with_latency_first_algorithm() else: serve_with_bandwith_first_algorithm

这我要看懂整个逻辑,就必须看完整个上下文了,谁知道在D2的时候,你前面还做了什么 处理?当然这个例子简单,现在还是好分辨的,但后面合并越来越多模块和逻辑的时候, 就难说得很了。latency_first只有一个意思。“是不是D2”这个意思,可包含了很多东西。 这个根本就是强行把代码写到一起了,逻辑并没有在一起。我们辛辛苦苦保证独立的逻辑 在独立的位置,结果这样一合并,本来就是独立开的逻辑,还被努力合并到一起了。

这个例子同样给我们展示了我们进行架构设计的时候,抓“不变”抓得不一样,到底会产生 什么效果。我们如果不去深入思考,我们很容易天然地认为,我们现在做成的什么样子, 或者我们现在部门什么样子,这些东西是不变的。所以我做D2,就应该把D看做是不变的, 我做D/D2,就应该把Linux主线看做是不变的。但只要我们观察一下,你就会发现,这些 其实都在变,它们都不是控制要素,控制要素是性能收益。我们错误判断这些优先级, 我们高层逻辑的控制错了,后面的细节设计就会一层层错下去。在时间面前,国家,组织 ,公司,部门,团队,模块,都不是逻辑控制的根。

附录

另一个抓不同的逻辑根的问题

我们曾经做过一个操作系统的“封装层”,这个封装层封装了线程接口,因为当时我们用的 是vxworks接口,这个封装就跟vxworks很像,vxworks有的接口它都有。在这个设计中,我 们认为vxworks的接口是个“不变”。但显然这个不变是不靠谱的(如果靠谱何必用封装层?)

只要你选择了线程这个名字作为封装方向,这个问题就不可避免,因为如果你抽象所有操 作系统的“共性”作为你的依赖,你这个线程库就拒绝了所有的创新(因为创新的功能不在 你的共性范围内),这对于商业产品是不可接受的。

你现在让我做这样的封装层,我就不会抓线程这个封装,我会抓用户需求作为封装中不变 的依赖。比如你需要在一个多核系统中调度多个任务,那我做一个业务任务调度器就可以 了,这不依赖线程的细节功能。