设计哲学

Datomic的设计哲学

数据库世界的产品不胜枚举,Datomic算得上是独树一帜。它吸引我的并非它强大的功能,也并非Rick Hickey[1]的非凡影响力。Datomic的官方网站将它定义为完全事务的、云端的分布式数据库(The fully transacational, cloud-ready, distributed database),这意味着它在互联网时代或许能扮演更多关键角色。但它真正打动我的是它建立的数据模型观点。Rick Hickey在谈论Datomic的架构时,有这么一段描述精到的论述:

任何数据库系统都需要有支持它的数据模型观点。传统的关系型数据库支持与某种世界更新语义(world-update semantic)关联的关系模型。与该领域相对的另一端,一些新的NoSQL系统对它们自身包含的信息却知之甚少,仅仅在保证最终一致性的情况下以简单的键值方式存储blobs。Datomic将数据库视为信息系统,而信息是一组事实(facts),事实是指一些已经发生的事情。鉴于任何人都无法改变过去,这也意味着数据库将累积这些事实,而非原地进行更新。虽然过去可以遗忘,但却是不能改变的。因此,如果某些人“修改了”他们的地址,Datomic会存储他们拥有新地址这个事实,而非替换掉老的事实(它只是在这个时间点被简单的回收了)。这个不变性(immutability)带来了很多重要的架构优势和机会。

或许很多人会关注到“不变性”,这也是Datomic最大的特色。Rick提到的架构优势和机会就包括:数据一致性、数据读的高可伸缩、内建缓存、高并发支持[2]。但我认为,这里呈现出来的关键信息还在于Datomic提倡的数据模型观点背后隐藏的设计哲学,即“将数据库视为信息系统,而信息是一组事实(facts)”。显然,它将观察世界的观点融入到了数据库的设计中。这何尝不是数据世界的哲学观呢?

"虽然过去可以遗忘,但却是不能改变的。"好像是诗人的美丽诗句,却又揭露了一个有趣的事实。这个事实引申出的“不变性”构成了Datomic架构的基石,它是Datomic数据世界的最高法则——这是一个不变的世界,万事万物亘古不变,唯有新增。每当我们遭遇历史数据不可变更的需求时,我们会想到Datomic;当并发控制数据遭遇棘手问题时,我们会想到Datomic;当读与写成为两种泾渭分明的场景时,我们会想到Datomic。

现在还不是深入探讨设计的时候,让我们先来看看什么是哲学?

设计与哲学

亚里士多德说过:“当所有的科学都只关注各自特定的领域时,哲学关注的却是一切存在,或者说普遍意义上的存在。”而罗素则认为:“哲学是介于神学与科学之间的东西。它与神学的共同之处在于,都包含着人类对未知事物的思考;它与科学也有共同之处,那就是理性地看待事物,而不是一切都遵循权威,无论是哪种权威。”[3]

显然,我们提到哲学,就会与知识、智慧、思考、逻辑、分析关联起来,至于人生、世界、空间、时间乃至于宇宙,都是哲学思考的目标。加入我们将软件产品看作一个独立的世界,我们为其建立模型、分析需求、捕捉内在运转的规律、设计合理的架构,不就是在做哲学家每天在做的事情吗?规律和本质,在软件世界中被我们创造,反过来又掌控者我们,规定着设计的方向。

文章《如果哲学家是程序员》从这些本质特征中看到了哲学与编程的关系[4]:

软件代码无非是反应了开发者看问题的视角和解决方案。在开始编码之前,开发人员会花时间反复思考待解决的问题,明确该问题的要点以及它们之间关系,这种过程正好反应了他们看待这个世界的哲学。同样地,哲学家们都在不停地琢磨他们所关心问题的重要特征,比如生命、良知或者上帝。

编程如此,设计更其如此,哲学对软件设计的指导价值在于它隐含地定义了软件的运行规律,就好像康德所说的头顶璀璨的星空与心中的道德定律。

Ken Thompson在设计Unix操作系统时,提出了所谓的“Unix哲学(Unix Philosophy)”。Unix管道的创造者Doug McIlroy用言简意赅的语言总结了他所认为的Unix哲学:“编写的程序只做一件事情,并尽力做好。编写的程序放在一起工作。程序应处理文本流,因为它是通用的接口”[5]。Unix的另一个发明者,同时也是C语言之父Dennis Ritchie则将Unix的设计原则归纳为“保持简单和直接(Keep It Simple Stupid)”,即著名的KISS原则。

遵循KISS原则,Unix被设计为多个小程序,每个小程序只完成一个功能,任何复杂的操作都必须分解成一些基本步骤,由这些小程序逐一完成,再组合起来得到最终结果。由于小程序之间可以像积木一样自由组合,非常灵活,能够轻易完成大量意想不到的任务。把大程序分解成单一目的的小程序,使得开发变得容易,Unix最初版本在短短几个月内就问世。这样的开发效率对于一个庞大的操作系统而言,是不可想象的。

我们可以认为,KISS原则就是Dennis Ritchie等人对于Unix的哲学观。这种哲学观可以解释为:在Unix世界中,万事万物都可以分解为最基本的原子,它们可以自由组合,并表现为不同的形态。巧合的是,将哲学带到希腊的哲学家阿那克萨哥拉也认为:“每一件事物都是由更小的事物组成的,表现在我们面前的是它包含其他事物最多时的状态。”

“柏拉图将不懂哲学的人比喻为被关在洞穴中的囚犯,这些囚犯因为被锁着,所以只能看着眼前的墙壁,不能转头。他们的背后生着一堆火,他们只能看到墙上自己和其他东西的影子。他们无法回头,不知道有火,便以为墙上的影子是实物。某一天,一位囚犯逃离了洞穴,并发现了真相,发现自己以前被影子骗了。[3]”我们也需要去探索和理解软件设计中的哲学,否则,就会像那些不明真相的洞穴囚犯一样,误以为影子就是我们要设计的软件。

img

图:柏拉图的洞穴寓言

设计态度

人生态度即为一种处事哲学。第欧根尼对世上的一切风俗说不,像苦行僧一样乞讨为生,住在安葬死人用的瓮中。他创造的犬儒学派唯一信仰的是爱,这种友爱不仅是人之间的,也包括人与动物之间。这种处事哲学之所以诞生,是因为哲学家所处的时代发生了变化,因而改变了他与社会的关系。正如罗素所说:

幸运的时代里,人们重视他们的提议;混乱的时代里,他们可能是改革者;还有一种时代,他们生活在其中看不到未来,知道理想绝不可能实现,于是变得绝望。最后只能将精力转移到一些宗教和迷信之上,期待着产生奇迹。

是以设计态度表现的同样是一种设计哲学,并与设计所处的场景,设计者的经历见识,软件类型与需求紧密相关。在语言与框架的设计时,我们常常能看到设计者表达的态度。旗帜鲜明的态度意味着设计者在践行和引导一种最佳实践,甚至可以理解为是一种约束。在软件设计中,如果没有任何约束,相反会带来更大的问题。约束是一种驱动力,例如我们需要可伸缩性的约束,就需要我们设计的服务不应该是有状态的。设计的态度大意如此。例如Go语言的依赖处理就施加了一个看似比较独裁的约束,即“不允许其依赖图中有循环性的包含关系,编译器和链接器都会对此进行检查以确保不存在循环依赖”。设计者并不否认循环依赖存在一定的价值,然而在大规模程序的前提下,它带来的问题远远超过了可能存在的价值。Rob Pike在《Go在谷歌:以软件工程为目的的语言设计》文中[6]提到:

循环依赖要求编译器同时处理大量源文件,从而会减慢增量式build的速度。更重要的是,如果允许循环依赖,我们的经验告诉我们,这种依赖最后会形成大片互相纠缠不清的源代码树,从而让树中各部分也变得很大,难以进行独立管理,最后二进制文件会膨胀,使得软件开发中的初始化、测试、重构、发布以及其它一些任务变得过于复杂。

不支持循环import偶尔会让人感到苦恼,但却能让依赖树保持清晰明了,对package的清晰划分也提了个更高的要求。就象Go中其它许多设计决策一样,这会迫使程序员早早地就对一些大规模程序里的问题提前进行思考(在这种情况下,指的是package的边界),而这些问题一旦留给以后解决往往就会永远得不到满意的解决。

我在设计框架时,坚持一个态度就是降低框架对软件项目的侵入性。没有侵入性,就使得项目的代码更加干净清爽,并能够脱离框架容器而单独存在,有利于代码的可测试性、可扩展性与良好的可读性。设计态度决定设计方案,例如我和同事在设计IoC框架(容器)Melt时,为了降低甚至避免框架的侵入性,遵循了惯例优于配置(Convention over Configure)的原则,对项目施加约束。只要遵循框架制定的惯例,框架的使用者可以定义POJO,而感觉不到框架的存在。唯一与框架有关的代码仅仅是定义自己的Module以及创建Melt容器:

public class MyInjectionModule extends InjectionModule {
    @Override
    public void configure() {
        register(DefaultBankService.class)
               .withConstructorParameter(CustomerFiller.class);
        register(DefaultBankDao.class)
            .withProperty("message", "melt");
    }
}

Container container = Melt.createContainer(new MyInjectionModule);
DefaultBankService bankService = container.resolve(BankService.class);
assertThat(bankService.getBankDao(), instanceOf(BankDao.class));

Martin Fowler曾经定义“架构是以后很难更改的内容”,若依此定义,则设计者的态度似乎就是在定义架构。Linus在他的自传Just For Fun中提到了当年Minix与Linux之争,这主要在于二者基于的内核模式的不同。Linus首先分析了微内核理论的原则[7]:

微内核理论的原则是:内核作为操作系统的基本核心,本身的功能越少越好,它的主要功能应该只有通信。电脑所提供的各种不同服务,都要通过微内核之间的通信渠道来实现。所以,采用微内核方法,就得尽量分割操作系统的问题空间,让每个问题空间都尽量简单。

而Linux采用的却是宏内核模式,Linus的设计态度是虽然合理的分解可以使得每个组成部分都更简单,但之间的交互却会变得非常复杂。他认为:“各独立模块之间的联系比原来系统要复杂得多,更何况,每个模块也并不是那么简单。”

宏内核与微内核之争并不能以Linux后来的成功决定胜者,关键还在于设计者观察的视角,以及产品适用的场景。

设计的因与果

古希腊哲学家留基伯有一句名言说:“世界上没有无缘无故的事情,万事万物总有一个因,那是必然的。”他认为世界自从建立之日起,就在遵循规律发展。因果分析是几千年来的哲学家一直在深思的问题。因与果之间的相关性具有本质上的联系,甚至不因外界的变化而变化。这种不变的因果关系就是我们需要去寻找的事物发展的规律。

图灵奖得主Judea Pearl在著作Causality-Models, Reasoning and Inference中定义了三条重要的规则。根据这三条规则,在因果关系结构给定的情况下,可以将观察数据中得来的相关关系的度量,转换成因果关系(causality)中的度量。

如果我们没有洞察事物的真相,不过是因为我们没有发现事物的因罢了。例如在上个世纪初期之前,人们都认为空间和时间并不会受到在其中发生的事件的影响。“即便在狭义相对论中,这也是对的。物体运动,力吸引并排斥,但时间和空间则完全不受影响地延伸着。空间和时间很自然地被认为无限地向前延伸。”

但是,广义相对论找到了时间与空间的“因”,爱因斯坦认为它们都属于一种动力量,所以在物体运动或产生力作用时,会影响空间和时间的曲率。正是建立了这样的假设,才会对所谓“宇宙是不变的”这种观点产生冲击,从而建立一种新的宇宙观,即宇宙膨胀理论。

general relativity

图:爱因斯坦的广义相对论认为,这样一个质量巨大的天体自转时的速度和角动量会造成其周遭时空的扭曲

我们在软件设计中,若能洞察这种因果关系,就能为整个设计规划出合理的路线图,并定义合理的设计原则。要做到这一点,则需要大胆提出假设,然后小心求证,就像福尔摩斯探案一般,善于观察,严谨缜密不放过任何蛛丝马迹,又要敢于假设,然后通过严丝合缝地逻辑推理来导出结论。这种“假设”实质上就是识别风险,并推断可能的应用场景,利用场景驱动设计思路,进而做出决策。之后,我们需要对需求进行深入分析和观察,辨别散落在需求中的各种相关性,再通过早期实现进行验证,从而寻找到设计软件的“因”。

参考文献

[1]Rick Hickey,Clojure语言之父,又一手打造了Datomic数据库。

[2]在后面章节,我会结合Datomic和CQRS,尤其是Event Store与Event Sourcing进行深入讲解。

[3]罗素:西方哲学史

[4]博客《如果哲学家是程序员

[5]Basics of the Unix Philosophy

[6]Rob Pike,Go在谷歌:以软件工程为目的的语言设计

[7]Linus Torvalds, David Diamond, Just for Fun

[8]史蒂芬.霍金,《时间简史》