我和在Slack上的几个人讨论过这个问题。出现的一个想法(还有很多,我可能还会有第二个答案描述另一种方法)是以下内容
- 在共享命名空间中将值规范放在一起,即
specs.clj
- 将结构规格放入依赖于该结构的命名空间中,例如在命名空间内部,像在
ships.clj
和 stars.clj
里面,或在它们对应的规格命名空间中,比如 ships_specs.clj
和 stars_specs.clj
。
- 将类型规格放在一个共享的命名空间中,可能甚至是一个共享库,因为你可能希望在不同甚至不相关的项目中使用它,例如
type_specs.clj
或 common_specs.clj
。
这个方法中棘手的部分是决定哪些规格适合哪种类别。我在这方面还有一些挣扎,但以下说明非常有帮助。
什么是值规格?
值规格是对不可分割的数据的规格。
任何不是集合的东西都是值规格,如字符串、数字、关键字等。
有时,它也可以是集合,但在你的应用程序的上下文中,你永远不会分解这个集合。这让你稍微有些模糊,我建议可能从没有集合作为值规格开始,例如一个 point
: {:x 0-255 :y 0-255}
。如果你永远不会以不同的方式表示一个点,比如 never having it be: [:x 0-255 :y 0-255]
或 never have a function that only takes :x
coordinates without their corresponding :y
for example, then it qualifies as a value spec, otherwise it doesn't。
值规格对你应用程序也是有意义的。这意味着值规格的名称在你应用程序中具有意义。例如,你可以有 (s/def ::first-name string?)
和 (s/def ::last-name string?)
。当你看到它们时,你会看到它们都是 string?
规格,但在你的应用程序中,区分这些作为名字的 string?
与作为姓氏的 string?
是有意义的。因此,你所有的值规格都应该有像这样有意义的名称,它们是你讨论应用程序领域时所使用的通用语言的一部分。如果你向我解释你的应用程序做什么,你不会说它只是取字符串并将其存储在数据库中,你会说它取客户的名字和姓氏。
因此,由于它们是领域概念,具有广泛的意义,它们将放在你的共享规格命名空间中。
这里有一个澄清,如果你意识到你有多于一种的 first-name 类型,例如,你意识到你经常谈论 customer-first-name 和 animal-first-name。这意味着你需要有两个值规格,一个对应每一个。基本上,如果你应用程序的上下文中存在子上下文,在那里同一个名称以不同的隐含意义使用,你需要使其明确。
什么是结构规格?
结构规格是将值规格临时组合到某些集合或结构中的安排。Rich Hickey 称之为:“信息一起旅行”。有时,你有一个需要三个输入的函数,并决定将这些输入作为一个地图一起获取,这是一个结构规格,它是特定的函数和它希望在执行期间如何安排数据,因此它随其命名空间一起。
许多事物都是结构规格,你一开始可能不会想到。例如,你可能认为用户是一个值规格。但稍作深入,你总是在各个使用用户的上下文中,完全相同的信息以完全相同的结构组合在一起吗?很快你就会意识到,有时候用户没有出生日期或没有相关的购物列表。有时候,用户有一个密码散列,而有时候没有。
这就是为什么我建议你首先尝试这个规则:所有收集都是结构规格,在各自命名空间(或伴随规范命名空间)中定义。只有值规格不是集合。这实际上训练你进入这个规格模型,一旦你掌握得很好,那些很好的候选值规格将更容易为你所识别。
因此,像 s/keys, s/map-of, s/tuple, s/coll-of 等等这样的东西最有可能是结构规格。
通常情况下,您不需要详细描述您应用的所有结构。从边界处的输入和输出开始。例如,用户提供的数据、保存到文件或数据库中的数据、发送和接收的API数据等。在这里,数据的验证最为关键。如果您想提高代码安全性,或进行文档记录,您可以逐步扩展到更多内部结构。
什么是类型规范?
最后,名称对您的应用没有意义的规范被称为类型规范。我记得我提到过,值规范必须有有意义的名称,如果值规范没有有意义的名称,它就是一个类型规范。例如,我们曾经有 ::first-name
和 ::last-name
值规范,它们都是 string?
类型。设想您需要一个仅是空白字符串的某种东西,并且希望 ::first-name
和 ::last-name
都具备这种类型。您可以创建一个类型规范:(s/def ::non-blank-string (complement str/blank?))
。
有时在 type_specs
命名空间中,除了放置规范外,还可以放置生成规范函数/宏以及帮助您定义类型规范的实用工具。例如,一个 (string-of-length len)
将返回一个验证字符串是特定长度的规范。
这就是类型规范及其命名空间的目的。由于这些规范基本上是领域无关的,因此您可以将它们放入库中,跨项目重用。
所有这些是如何结合在一起的?
好吧,我不会遍历整个 spacewars 代码库,但例如,使用这种风格的规范建模,您可能有
- value_specs.clj
(s/def ::x number?)
(s/def ::y number?)
(s/def ::velocity-1d number?)
(s/def ::velocity-2d ::type/num2tuple)
(s/def ::age number?)
(s/def ::direction number?)
(s/def ::romulan-state #{:invisible :appearing :visible :firing :fading :disappeared})
(s/def ::battle-state #{:no-battle :flank-right :flank-left :retreating :advancing})
(s/def ::battle-state-age number?)
基本来说,您会把所有值规范放在 value_specs 中。如果某个名称在不同上下文中有不同的含义,例如对于 state
和 velocity
,您会明确在名称中体现上下文。
- klingons.clj
(s/def ::klingon (s/keys :req-un [::value/x ::value/y ::value/velocity2d ::value/battle-state-age ::value/battle-state ...] ...)
等等
- type_specs.clj
(s/def ::num2tuple (s/tuple number? number?))
优势和劣势
优势
- 迫使您理解领域中的不同名称及其上下文
- 重用值规范,而不是在需要使用它们的命名空间中重复定义它们
- 可能避免了为同一事物使用两个不同的名称
- 防止您意外地将代码耦合到某个特定结构的实例
- 允许函数自由定义它们希望操作的结构
- 跨项目重用公共类型规范和规范构造器/实用工具
劣势
- 您可能会意外地将相同的值规范用于两个实际上是不同的事物,有一天您会发现需要区分它们,因此需要提取值规范并将其拆分为两部分。
- 您可能最终会有重复的结构规范,但我们故意这样做,因为我们假设那些在命名空间中可能需要更多或更少的键或需要在不同集合中。