在之前的文章《设计杂谈(0x01)——配置文件的起源与矛盾》中,笔者对配置文件的起源,以及存在的矛盾进行了探讨。而在这一系列的第二篇中,将对上述矛盾提供进一步的分析,并尝试通过引入“约定大于配置”的设计思想来解决这一问题。
话不多说,开始。
约定大于配置
对于提升配置文件系统的易用性和可操作性,笔者认为其中一种较好的优化思路是 “约定大于配置”(Convention over Configuration,简称为CoC),一般采用如下的定义(见Convention over configuration - Wikipedia)
Convention over configuration (also known as coding by convention) is a software design paradigm used by software frameworks that attempts to decrease the number of decisions that a developer using the framework is required to make without necessarily losing flexibility and don’t repeat yourself (DRY) principles.
简单来说,就是如下几点:
- 约定一个具备明确实际意义的【常规态】
- 对【常规态】要有完整的描述,并将其作为默认值
- 撰写配置时,只需要写明“非常规”的部分,剩下的部分交给【常规态】
- 这样的方式可以极大减少撰写配置时的决策与重复,满足“不要自我重复(Don’t Repeat Yourself,简称DRY)”的设计原则
而这其中最为关键的,则是要去设定一个“常规态”。出于方便易用、表意清晰的考虑,需要对实际代码逻辑的最常用形态有足够的了解,才能定义和抽象出所谓的“常规态”。并且,为了支持更多定制化的功能,也应该提供一系列的附加配置。同时,附加配置依然需要基于“常规态”赋予其默认值,以简化使用。换句话说,不要让复杂的需求影响最常用的体验,也不要让编写者进行一系列重复且不必要的选择。
例如,对于写过Java代码的开发者而言,如果使用了Maven框架创建项目的话,一般将会呈现如下图所示的项目结构:
(基于Maven的Java项目结构,源码、单元测试、编译产物、项目配置均在固定的位置)
你可能会疑惑——为什么一定要组织成这样呢?或者说,是否可以换个方式来组织(比如源代码目录改作 code
,或者编译产物目录改作 build
等),并且一样可以正常地编译、运行与测试呢?答案其实很简单——并不一定非要这样。实际上,具体的组织形式,例如项目结构、各个文件/目录的命名等,都是可以修改的。然而,在实际使用的情况下,这样的修改并无必要性,作为代码的编写者,只需要按照这样一个既定的模式来编写项目代码即可解决上述全部问题,那么当然就没有手动进行额外配置的必要了。而针对极端特殊的情况,只需要在项目配置中进行少量的修改,一样可以达成所需的效果。
这样隐含的“约定”,实际上广泛存在于各种语言的各种框架中,其中在Web应用框架中尤其普遍存在。例如,将约定大于配置设计原则发挥到极致的Ruby on Rails框架中,数据模型部分在“关系”(即数据表之间的关系,例如一对一的“has one”,一对多的“has many”,代表从属的“belong to”等,这在数据库与数据模型的设计中极为常见)上的设计就极好的遵循了这一原则。下面就让让我们来看一看其中 has_many
,也就是一对多关系具体的参数设计(原地址)
(has_many的部分参数及使用说明,全部参数共计18个)
上述内容仅仅只是参数文档的一部分,实际上参数总共有18个之多,看起来会非常复杂难用,但是,实际使用起来的时候则非常简单,这里我们举一个建模帖子和评论数据库关系的例子:
(帖子和评论的关系,一个帖子可以包含多个评论,一个评论只属于一个帖子)
而上述复杂的数据关系,在Rails的数据模型中,可以极为简单的进行表达
class Post < ApplicationRecord
has_many :comments
end
class Comment < ApplicationRecord
end
出人意料吧?就是如此简短。想必你对此一定有疑问,毕竟从代码中所展现的内容来看,信息确实太少了,无非只有“Post”、“comments”以及“Comment”、“post”,而做过数据库设计的都知道,一个完整的关系需要包含的内容有很多,包括但不限于:
- 连接方式——是内连接(Inner Join),还是完全外连接(Full Outer Join)、左外连接(Left Outer Join)?
- 表名——是作
Post
,还是post_table
,还是POST
? - 主键名——是作
id
,还是ID
,还是UUID
? - 外键名——是
post_id
,还是postId
? - 依存性——如果一个
Post
不复存在,那么其下属的Comment
是否也应被移除?是否允许Comment
的post_id
字段指向并不存在的Post
? - 主键性质——主键是使用自增数值(auto increment),还是通用唯一标识符(即UUID,详见:Wikipedia - UUID)?主键是否要建立相应的主索引,并确保其唯一性?
- 外键性质——外键经常会涉及查询,出于性能考虑,是否应该为外键添加索引?
如上所示,看似简单的一个组合关系,若想实际运转起来,需要考虑一系列复杂的配置。
那么相应的,Rails中用这样简陋的配置表达如此复杂的逻辑当真是严谨的?有这样的疑问很正常,笔者当年初学这里的时候也倍感纠结,少了这些手动的配置项容易让人产生一种不可控感。然而,当我们换个角度,用“约定”的视角来看待这一问题,那么就是另一个故事了。
首先,Rails的 ActiveRecord
对表、主键、字段、外键做了如下的约定
- 表名一律使用小写下划线命名法,并使用复数形式,例如
posts
、comments
等 - 主键名一律使用
id
,类型为自增数值,并配备主键约束 - 字段名一律使用小写下划线命名法,例如
name
、title
、double_word
等 - 外键使用小写下划线命名法,命名为对应的
表名_id
,例如post_id
、sample_table_id
等;并配备外键约束,自带索引 - 对每个表额外配备
created_at
和updated_at
字段,用于记录该条记录的创建时间与最后一次更改时间(由Rails框架提供实现)
我们对比一下就可以发现上述例子的命名方式则完全符合这一约定,因此是无需额外配置的。
然后,回到数据模型,以及has_many
这条语句本身,实际也暗含了一系列约定,其中包括:
- 数据模型命名一律使用大写开头的驼峰命名法,使用单数形式,并且和表名严格对应,例如
Post
、Comment
等,分别对应表posts
、表comments
等 - 对于
has_many
关系而言- 传入的英文词,转为单数形式,再转为大写开头的驼峰命名法,即作为对应的子级数据模型,例如
comments
转为Comment
- 当前类名,转为小写下划线形式,转为单数形式,加上
_id
,即作为子级表中的外键,例如Post
转为post_id
- 除非特殊指定,当前表上的主键一概使用
id
- 如果涉及多表联合查询,那么默认使用内连接方式(Inner Join)
- 在数据模型层面,直接使用
comments
,即可访问当前帖子所属的评论,且可以自动构造SQL查询语句
- 传入的英文词,转为单数形式,再转为大写开头的驼峰命名法,即作为对应的子级数据模型,例如
通过上述两方面的约定,就可以基于此而使用简单的语法来处理复杂的数据关系。而这一系列的约定也十分符合一般的数据模型实际开发需求,做到了相当完美的契合,以至于实际开发中确实几乎不需要做多少的额外配置。而针对比较特殊的情况,例如当 comments
表中的外键被命名为 pid
时,则只需要做一点小小的配置即可解决问题:
class Post < ApplicationRecord
has_many :comments, foreign_key: :pid
end
class Comment < ApplicationRecord
end
从这样的视角来看,我们不难发现“约定大于配置”设计思想实际能实现的效果:
- 对于大部分常用情况,极简短的配置即可使用
- 对于少部分的略特殊情况,少量的配置即可使用
- 对于极为特殊的定制化需求,可以通过各选项手动配置实现
这样一来,配置的灵活性和易用性便可以得到很好的兼顾。
配置的形态
在上面的例子中,涉及到了Rails框架的代码。可能你会感到困惑——这还是否能算是配置呢?
先说结论——算!然后让我们来看看说到“配置”二字,比较常见的都有哪些形态。按照笔者的理解,基本上可以分为三种类型:
- 基于框架和库的配置,即使用高级语言搭建,并依赖于该非原生框架和库的配置。比如上文中Rails的数据模型(使用了Rails提供的
ActiveRecord
基类),以及类似如下的配置
# 'myframework' is a third party library
from myframework import configure
configure.rotate(120).flip('lr').crop(0, 0, 100, 100)
- 基于高级语言的配置,即使用高级语言搭建,并且不依赖于非原生框架和库的配置。例如如下的配置(注意:原生库由于不存在依赖问题,所以仅使用了原生库的配置也属于该类型,例如下面配置中引入
os
库并获取环境变量的行为)
import os
config = [
dict(
type='rotate',
# load from environment variable
angle=os.environ.get('ROTATE_ANGLE', 120),
),
dict(
type='flip',
direction='lr',
),
dict(
type='crop',
x1=0,
y1=0,
x2=100,
y2=100,
)
]
- 无依赖的配置,即不依赖于任何高级语言,且格式拥有一套相对的固定标准的配置。例如之前文章《设计杂谈(0x01)——配置文件的起源与矛盾》中所展示过的YAML格式的配置,以及其他诸如JSON、INI、TOML等常见格式的配置。
对于上述三类的配置,实际上不难发现它们之间的等价性——无论是哪一类配置,从语义的角度来看,都可以直接或间接地转为基于序列、映射与值的树状结构,即类似JSON那样的结构,且转化过程中不会造成语义的丢失。而这些类型的配置,因其各自的设计特点,所以各有优劣之处,如下表所示
优势 | 劣势 | |
---|---|---|
基于框架和库 | 依托于框架和库,可以充分利用语法来蕴涵大量约定和语义,理论上语义密度(语义信息量对文本长度的比值)很高,大幅简化配置撰写;可以设计出复杂的机制,且实现配置与业务层的隔离,便于长期维护和迭代。 | 对框架和库有较强的依赖,甚至可能依赖于特定的版本或亚种,使潜在的兼容性问题更加复杂化,不利于通用环境下的使用。 |
基于高级语言 | 依托于高级语言,可以利用原生库进行简单的动态操作,较之无依赖配置灵活性更高;不依赖特定的非原生框架和库,较之基于框架和库的配置而言,通用性更高。 | 对高级语言本身有依赖,仍然不利于全通用环境下的使用;由于无框架和库支持,因而表意能力受限,本质上更接近于无依赖配置。 |
无依赖 | 依托于统一标准,设计上充分考虑了通用性,理论上全语言、全平台兼容。 | 因为通用性优先,因而不利于针对特定场景做特殊优化,与之相关的功能迭代也较为困难;通用性语法本身表意能力受限,简化潜力不足,语义密度较低。 |
基于上述分析,可以得出一些使用上的建议:
- 当配置且无跨语言、跨框架使用需求,且格式语义较为复杂时,推荐使用【基于框架和库的配置】。
- 当配置有大量跨语言使用需求、需要用于传输或需要支持持久化存储时,推荐使用【无依赖的配置】。
- 当配置无跨语言使用需求,但需要支持跨框架使用时,推荐使用【基于高级语言的配置】;当语义格式较为复杂时,可以考虑进行以兼容为目的的额外封装,并基于封装层使用【基于框架和库的配置】。
在本篇内容中,笔者针对“约定大于配置”原则进行基本的介绍与讨论,并就配置的形态问题进行了归纳与分析。那么,这些设计原则该如何应用于具体的配置设计呢?关于这部分,我们将在下一篇中进行详细的讨论,敬请期待。
文章原地址:《设计杂谈(0x02)——约定大于配置》