谨以本文向我脑海中那些不成熟的想法致敬。
序言
受疫情影响呆在家中的这段时间里,我收尾了《Clean Architecture》。这本书给了我许多新知识和启发,包括本文的中心论点——数据库schema不是CRUD服务的一切,也是在读书过程中想到的。在书中,作者的原话是
But the database is not the data model
它出现在书中第六部分《Details》的第一个章节中。作者认为,从架构的角度来看,数据库不是一个实体而是一个细节,不足以成为架构中的一个元素。他甚至打了个比方:数据库对于架构而言,就好像门把手对于房子一般。并且,作者进一步澄清了他的观点:他口中所说的数据库,不是指的数据模型。应用内的数据结构对架构而言至关重要,但数据库并不是数据模型。
这不禁让我回忆起了自己早期写设计文档的套路。
在我的从业生涯早期(说得好像我从业很久了一样),每当需要开发一个新的Web服务时,必须先写一份简要的设计文档,向上级清楚地表达我的实现思路,包括:
- 如何与其它服务协作完成产品提出的需求;
- 服务的接口描述;
- 数据的存储结构;
- 关键的算法等。
那时候的我会先考虑数据的存储结构,然后定义接口,最后才是与其它服务的协作。这些早期设计文档的其中一个特点是:接口的响应格式,与数据的存储结构是相同的。
比方说我要设计一个网上商城的订单服务,可能会提供如下查询特定订单的接口
GET /order/:id
其响应格式可能如下
{
order: {
id: 'F122663A-A5DC-451A-9B79-92DCE2EE41F1',
price: '100.00',
products: [
{
id: 1,
name: 'MacBook Pro',
price: '19999.00'
},
{
id: 2,
name: 'iPhone',
price: '6999.00'
}
]
}
}
为了保存这种“不平坦”的对象,将会用MongoDB作为存储——除了文档的主键_id
之外,其它字段在接口和存储之间一一对应。
不仅仅是响应格式,在这个Web服务内所操作的也是同样结构的对象。用MongoDB Node.JS Driver获得的订单恰好是一个JS对象,它与collection的文档有着一模一样的结构。之后这些对象会在代码内到处流通,不加修饰地使用。定义了数据库schema(就算是用MongoDB也有一套脑内的schema)后,其它的一切也就跟着确定了。
业界甚至有工具可以直接从数据库得到API,比如postgrest。
很多时候,数据库schema成了一个应用内事实上的数据模型。但是,即便它们可以偶然一样,也不要认为它们总应该一样。
适合存储,不一定适合计算
以MySQL为例,在CREATE TABLE
语句中某一列的类型实际上决定的是存储时分配的空间的多少。但适合存储的类型,并不一定也适合业务逻辑的运算。
比如说,要在MySQL中存储“开关型”的数据,即诸如“是否启用”或“是否已支付”这样非此即彼的状态时,通常定义为TINYINT
类型,用0表示逻辑假(“未启用”和“未支付”),1表示逻辑真(“已启用”和“已支付”)。但对代码而言,比起用数值类型,布尔类型才是更恰当的选择。尤其是当所选择的语言并没有将0与false
、1与true
等价起来的时候——在Common Lisp中,(if 0 1 2)
的求值结果为1。
适合计算,不一定适合存储
通常数据结构在内存中比在磁盘上要容易表达得多,所以代码中使用的数据结构会比数据库中存储的要灵活不少,这同样造成了两者的不匹配。
以我自己开发的提醒工具cuckoo
为例,应用内有两种对象:任务和提醒。任务描述了要做的事情,提醒描述了在什么时候该告诉用户。显然,提醒是一个依赖于任务的弱实体。在cuckoo的代码中,任务是Task
类的实例对象,有一个名为remind
的成员变量存储着提醒。
但这样的结构不方便存储在MySQL中。遵照关系型数据库设计的第一范式,任务和提醒分别被存储在t_task
和t_remind
表中,两者通过t_task.remind_id
联系起来。
当然,也可以在一开始就用MongoDB来存储这些数据(甚至可以用对象数据库?不过我没玩过)。尤其是cuckoo只是一个小玩意儿,MySQL和MongoDB都足以胜任。但作为一名有理想的程序员,在做设计的时候,不应该让低层细节过分干预高层策略。(在《Clean Architecture》中,越是接近I/O的越是low-level,反之则是high-level。)
面向业务逻辑,而非存储结构
业务逻辑和规则才是一个服务的核心,应该把更多精力花在实现业务逻辑的数据结构和算法上。
以网上商城中常见的优惠券功能为例。优惠券服务所管理的优惠券往往有着各种效果、条件,以及限制。为了保持灵活性,优惠券类(下称Coupon
)的实例对象中会有三种接口类型的成员变量:
-
Effect
类型的变量effect
,负责实现优惠效果的计算逻辑; -
Condition
数组类型的变量conditions
,负责实现使用条件的检查逻辑; -
Restriction
数组类型的变量restrictions
,负责实现使用限制的检查逻辑。
三个接口可以有各种各样的实现——定额减免、折扣减免、某年月日前可用、不可用于电子产品,等等。如此,优惠券功能具备了极大的灵活性,业务可以随心所欲,产品可以为所欲为,老板数钱数到手软,公司业绩蒸蒸日上。
那么如何存储Effect
、Condition
、Restriction
、Coupon
类的实例对象呢?没有唯一的选择,既可以存储在MySQL中,也可以存储在MongoDB中,或者别的什么数据库中。不管这些数据最终如何持久化,都不会影响作为高层策略的优惠券业务逻辑。反过来,如果在代码中处理的不是类、接口,以及实例对象,而是直接从数据库中取出来的、贫血模型的行(或文档),处理起来就不是很优雅了——可以预见 ,代码中会充斥着许多的if-else
判断逻辑。
数据库只是帮忙从磁盘中读取数据的软件,它的schema不应该直接成为应用的数据模型。
Interface Segregation Principle
不应该在HTTP接口的响应中直接暴露数据库的schema。
不说别的,光是数据库schema与接口规格所使用的命名规则就足以造成差异了。也许在MySQL中用snake case
命名一列,却又在HTTP响应的JSON对象中用camel case
命名字段。
此外,除非这些接口仅仅实现增删查改、没有任何的业务逻辑或规则,否则一个服务更应当提供与业务需求恰好契合的接口。仍然以上文的优惠券服务为例,尽管内部可能Effect
、Condition
、Restriction
、Coupon
等诸多概念,但煮不在乎用户不在乎,他们只想看到用人话说出来的优惠券效果以及使用规则——用户甚至不关心条件和限制有何不同。
如果优惠券服务直接将数据库中的行(或文档)序列化成JSON返回给调用者,会导致封装的泄露。每一个查询优惠券的调用方,都必须了解优惠券的内部表示形式,必须知道效果由effect
描述、用券后的订单金额是多少、conditions
中有关于过期与否的信息,等等。每增加一个优惠券服务的使用者,就相应地增加一套描述这些内容的代码。甚至当优惠券服务自身重构的时候,也许牵连到众多的调用方。
如果直接将存储结构暴露给调用者的话,又何必再做一个Web服务呢。
切勿矫枉过正
的确存在这样的例子,数据库schema、数据模型,以及HTTP响应结构三者相同。这是因为比起维护数据库schema与数据模型的转换规则,以及DTO与数据模型的转换规则而言,在领域代码中直接使用数据库schema来表达数据模型的成本更低一点。尽管数据库schema不是Web服务的一切,但很多时候可以因地制宜地妥协一下。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。