![程序员的底层思维](https://wfqqreader-1252317822.image.myqcloud.com/cover/270/46841270/b_46841270.jpg)
1.4.3 抽象缺失之基础类型偏执
基础类型偏执(Primitive Obsession)是Martin Fowler在《重构:改善既有代码的设计》一书中提到的一种典型的代码“坏味道”,意思是我们使用了太多的基础类型,导致有些应该被抽象成实体类的概念,却以基础类的形式散落在代码各处,这是一种典型的抽象缺失。
由于抽象缺失,相关的数据和行为将分散在其他抽象概念中,这将导致两个问题。
(1)暴露太多的实现细节,从而违反封装原则。
(2)数据和行为分散在代码的多个地方,导致代码重复、类之间耦合度变高、代码难以维护和理解等问题。
比如在一个图书馆信息管理应用程序中,国际标准书号(International Standard Book Number,ISBN)的存储和处理非常重要。一种自然的做法是将ISBN设计成字符串,毕竟它在数据库中的确也是以字符串形式存储的。然而,这并不是一个好的选择,为什么呢?
ISBN有两种表示方式,分别是10位和13位的,这两种形式之间可以转换。ISBN的各位都有其含义。例如,13位的ISBN由商品编号(图书产品代码978或979)、地区代码、出版社代码、书序码和校验码组成。
比如我写的第一本书《代码精进之路》,它的ISBN是978-7-115-52102-6,如图1-3所示。
![](https://epubservercos.yuewen.com/1F31CC/26126228301725106/epubprivate/OEBPS/Images/42977_25_1.jpg?sign=1739666537-8Diwj70Vlelj1hdiOvBGmA037TH6vNLp-0-b2d8e4db0a6cbceb9e05011f567e69a3)
图1-3 ISBN示例
ISBN的最后一位是校验码,其计算方式如下:从第一位开始,奇数位的值保持不变,而偶数位的值乘以3,将所有这些值相加再除以10,用10减去得到的余数就是最后一位的值。因此,给定一个ISBN,我们可以通过这种方式校验它是否有效。
对于图书馆管理系统来说,ISBN并不是一个简单的字符串,它本身就是业务核心,包含了一系列业务逻辑,比如关于ISBN的创建、验证、处理和转换,以及通过ISBN获取地区信息、出版社信息、书号等。如果将ISBN设计为基础类型字符串,那么这些处理逻辑将重复分散在很多地方。这种不将ISBN封装为类的行为,将带来因为抽象缺失导致的一系列不良后果。
因此正确的做法是,我们要对ISBN建立合理的抽象(类)概念,创建一个ISBN的接口,其中包含通用的抽象操作:
![](https://epubservercos.yuewen.com/1F31CC/26126228301725106/epubprivate/OEBPS/Images/42977_26_1.jpg?sign=1739666537-fsjeS1vtaTaxRYJjrfOEQO3VfUuPTneX-0-d92f12e5113bb96ed082a84d5e9ef6e0)
并创建子类ISBN-10和ISBN-13,它们都扩展了超类ISBN,如图1-4所示。
![](https://epubservercos.yuewen.com/1F31CC/26126228301725106/epubprivate/OEBPS/Images/42977_26_2.jpg?sign=1739666537-FUMK4LyFUYCRA5uZTYxsazr0UIdaboqW-0-3f3be39e79a5d4d31b9c769da96aa472)
图1-4 ISBN设计类图
再比如,假设现在要实现一个功能,让A用户可以给用户B支付x元,可能的实现如下:
![](https://epubservercos.yuewen.com/1F31CC/26126228301725106/epubprivate/OEBPS/Images/42977_26_3.jpg?sign=1739666537-7EiBahqLE4mRT0TlRK32fXcMPecM5SUb-0-dbd7fe86c8a6b942b4e928f6c045cce6)
如果这是境内转账,并且境内的货币永远不变,该方法似乎没什么问题。但如果有一天货币变更了(比如欧元区曾经出现的问题),或者我们需要做跨境转账,该方法有明显的bug,因为money对应的货币不一定是CNY。
在这里,当我们说“支付x元”时,除了x本身的数字,实际上还有一个隐含的概念,那就是货币“元”。但是在原始的入参里,之所以只用了BigDecimal,是因为我们认为CNY货币是默认的,是一个隐含的条件。然而在我们写代码时,需要把所有隐性的条件显性化。
所以当我们实现支付功能时,实际上需要的一个入参是“支付金额+支付货币”。我们可以把这两个概念组合成为一个独立的完整概念——Money。
![](https://epubservercos.yuewen.com/1F31CC/26126228301725106/epubprivate/OEBPS/Images/42977_27_1.jpg?sign=1739666537-tzXbFmHnhxDhydN6z4fJ4dv8VHS6ggyT-0-4e72aedda5e3bb8edd11cbc53e4814b8)
而原有的代码则变为:
![](https://epubservercos.yuewen.com/1F31CC/26126228301725106/epubprivate/OEBPS/Images/42977_27_2.jpg?sign=1739666537-X9K1vNzG7bZsMJeUmFpdIeIQvrrqzFtB-0-e1a4e450fc283da9cfe599ce7a211d27)
通过将默认货币这个隐性的概念显性化,并且和金额合并为Money这个抽象概念,我们可以避免很多当前看不出来但未来可能会“爆雷”的bug。
将前面的案例升级一下,假设用户可能要做跨境转账(从CNY到USD),并且货币汇率随时在波动:
![](https://epubservercos.yuewen.com/1F31CC/26126228301725106/epubprivate/OEBPS/Images/42977_27_3.jpg?sign=1739666537-a761KdGUpk2HrEoZhEJSAcPutGAvm7Qv-0-9cc94731eb8fdc64f25fd7153b9a74ba)
现在最大的问题在于,金额的计算被包含在了支付的服务中,涉及的对象也有2个Currency、2个Money、1个BigDecimal,总共5个对象。这种涉及多个对象的业务逻辑,需要一个新的抽象概念进行封装。
我们可以考虑将转换汇率的功能封装到一个叫作ExchangeRate的DP(Domain Primitive)[2]里:
![](https://epubservercos.yuewen.com/1F31CC/26126228301725106/epubprivate/OEBPS/Images/42977_28_1.jpg?sign=1739666537-4aMVO1scfKweiHfFaCFFKAEMETp0JDF5-0-6e5c18bd5e8540c1eb3f3062133ce77b)
ExchangeRate汇率对象通过封装金额计算逻辑及各种校验逻辑,使原始代码变得极其简单:
![](https://epubservercos.yuewen.com/1F31CC/26126228301725106/epubprivate/OEBPS/Images/42977_28_2.jpg?sign=1739666537-qwA2L8Lwy98cHkR3E6pcBYo6Xc0YIjoL-0-37c04c2f22f16fce22053edb3e024144)