今天的应用程序越来越分布式。我们需要编写更多的代码来连接其他服务,并尽量保持简单。
为了使用来自外部服务的数据,我们通常将JSON有效负载转换为数据传输对象(DTO)。处理DTO的代码很快变得复杂,但是有一些提示可以帮助。我们可以编写更易于交互的DTO,使客户端代码更容易编写和阅读。这些提示一起使用有助于保持简单。
从手册中获取DTO序列化
让我们从处理JSON的典型方法开始。这是一个JSON结构。它代表一个Regina比萨饼。
要在我的应用程序中使用这些数据,我创建了一个名为PizzaDto
的简单DTO。
PizzaDto
是一个纯旧Java对象:具有属性,getter,setter,而且就是这样。它镜像了JSON结构,因此对象和JSON之间的转换只是一个一行代码。这是使用Jackson库的示例:
转换很简单。那么问题在哪里?
在现实生活中,DTO可能非常复杂。创建和初始化DTO的代码可能很大:有时候有几十行代码。有时更多。这是一个问题,因为复杂的代码包含更多的错误,并且不太能响应变化。
我简化DTO创建的第一次尝试是使用不可变DTO:一个在创建后不能修改的DTO。
如果你不熟悉这个想法,那么它可能听起来很奇怪,因此让我们专注于这个主题。
创建不可变DTO
简单地说,当一个对象的状态在构造后不能改变时,它是不可变的。
让我们重写PizzaDto
以使其不可变。
不可变版本没有setter。所有属性都是final并且必须在构造时初始化。
正如你所看到的,成分列表没有按原样存储。相反,我使用List.copyOf()
来保持输入的不可修改副本。这可以防止客户端修改存储在DTO中的成分。
这很重要,因为没有蘑菇的Regina比萨绝对不是Regina比萨。
更严重的是,Effective Java的作者Joshua Bloch给出了创建不可变类的建议:
“如果你的类具有引用可变对象的任何字段,请确保类的客户端无法获取对这些对象的引用。” Joshua Bloch
如果DTO的任何属性是可变的,则需要进行防御性复制。使用防御性复制,可以保护DTO免受外部修改。
发布后编辑:从Java 16开始,记录提供了一种更简洁的创建不可变类的方法。
好了。现在我们有一个不可变DTO。但是它如何简化代码呢?
不可变性的好处
不可变性带来许多好处,但这是我最喜欢的:不可变变量是无副作用的。
让我们通过一个示例来看看这一点。此片段中有一个错误:
运行此代码后,pizza
没有预期的状态。哪一行导致了问题?
我们将考虑两个答案:第一个是使用可变变量,第二个是使用不可变变量。
第一个答案,使用可变比萨。pizza
由make()
创建,但可以在verify()
和serve()
中进行修改。因此,错误可能来自任何3行中的任何一行。
现在,第二个答案,使用不可变比萨。make()
返回一个比萨,但是verify()
和serve()
不能修改它。问题只能来自make()
。在这里,投资的范围要小得多。这个错误更容易找到。当我们使用不可变变量时,调试变得更容易。但这还不是全部。
当披萨无效时,verify()
可能会抛出异常来中断进程。让我们进行一些改变。我们希望 verify()
可以修复无效的披萨。
由于披萨是不可变的,verify()
不能只是修复它。它必须创建并返回一个修改后的披萨,客户端代码必须进行适应:
在这个新版本中,很明显 verify()
返回了一个新的、修复过的披萨。不可变性使你的代码更加明确。它变得更容易阅读和演进。
你可能不知道,但我们每天都在使用不可变的对象。java.lang.String
,java.math.BigDecimal
,java.io.File
都是不可变的。不可变性提供了许多其他优势。在他的《Effective Java》一书中,Joshua Bloch只是建议“最小化可变性”。
不可变类比可变类更易于设计、实现和使用。它们更不容易出错,更安全。Joshua Bloch
现在,有趣的问题是:我们能在 DTO 中使用它吗?
不可变的 DTO……有意义吗?
DTO 的目的是在进程之间传递数据。它被初始化,然后它的状态不应该发生变化。它要么会被序列化为 JSON,要么会被客户端使用。这使得不可变性非常合适。一个不可变的 DTO 将在进程之间传递数据,并保证它没有被改变。
那么,我为什么要先编写一个可变的 PizzaDto
,而不是一个不可变的呢?问题是,我相信我的 JSON 库需要 DTO 上的 getter 和 setter。
结果我错了!
使用 Jackson 创建不可变的 DTO
Jackson 是 Java 中最常见的 JSON 库。
当你的 DTO 有 getter 和 setter 时,Jackson 可以映射对象到 JSON而不需要任何额外的配置。但对于不可变对象,Jackson 需要一些帮助。它需要知道如何构造对象。
对象的构造函数必须用 @JsonCreator
进行注释,每个参数都用 @JsonProperty
进行注释。让我们在 DTO 的构造函数上添加这些注释。
就是这样。我们有了一个不可变的 DTO,Jackson 可以将其转换为 JSON,然后再转换回对象。
使用 Gson 和 Moshi 创建不可变的 DTO
Gson 和 Moshi 是 Jackson 的两个替代品。
使用这些库,将 JSON 转换为不可变的 DTO 更加简单,因为它们不需要任何额外的注释。
但是,为什么 Jackson 需要注释,而 Gson 和 Moshi 不需要呢?
这不是魔法。实际上,当Gson和 Moshi 从 JSON 生成对象时,它们通过反射创建并初始化它。最后,它们只是不使用构造函数。
我不是这种方法的忠实粉丝。这是误导性的,因为开发人员可能会在构造函数中放置一些逻辑,而永远不知道它没有被调用。相比之下,我认为 Jackson 更加安全。
避免 null 值
使用 Jackson 还有另一个好处。如果我们在构造函数中放置一些逻辑,无论 DTO 是由应用程序代码创建还是从 JSON 生成,它都会被调用。
我们可以利用这一点,避免空值。我们可以改进构造函数,使用非空值初始化字段。
在下面的代码片段中,当输入为空时,字段将被初始化为空值。
大多数情况下,空和null
没有区别。如果我们将 null 值替换为空值,客户端可以在不先检查它是否为 null 的情况下使用 DTO 属性。此外,它降低了获取 NullPointerExceptions的机会。
通过这个技巧,你可以编写更少的代码,增加程序的鲁棒性。我们还能做得更好吗?
最后,使用 Builder 创建 DTO
我使用另一个技巧来简化 DTO 的初始化。对于每个 DTO,我创建一个 Builder 辅助类。Builder 提供了一个流畅的 API 来简化 DTO 的初始化。
以下是使用 Builder 创建 PizzaDto 的示例:
对于复杂的 DTO,Builder 使代码更加清晰明了。这个模式如此出色,以至于 Joshua Bloch 几乎用它来开始他的《Effective Java》一书。
这个客户端代码易于编写,更重要的是易于阅读。Joshua Bloch
它是如何工作的?建造者对象简单地存储值,直到我们调用 build()
,该方法使用存储的值实际创建所需的对象。
这是 PizzaDto
的示例。
有些人使用 Lombok 在编译时创建建造者,以保持 DTOs 简单。
我更喜欢使用 Builder generator IntelliJ plugin 生成建造者代码。然后,我可以添加方法重载,就像在之前的片段中所做的那样。建造者更加灵活,客户端代码更加简洁。
结论
这些是我用来编写 DTO 的主要技巧。一起使用,它们真正提高了你的代码质量。代码库更易于阅读、易于维护,最终也更易于与团队共享。
感谢你的阅读。我希望你学到了一些技巧。如果你有任何反馈或与 DTO 相关的其他技巧,请留下评论。我很乐意阅读。
还要感谢贡献者:Héloïse Hembert 进行早期审阅,Julien Sobczak 提供宝贵的反馈。最后,感谢 Javarevisited。这篇文章因他们的发表而得到更多人的关注。
资源
书籍
- Effective Java,Joshua Bloch,2017
会议
- The Power and Practicality of Immutability,Venkat Subramaniam,2018
译自:https://medium.com/javarevisited/not-so-obvious-tips-to-write-better-dtos-in-java-c6116895b180
评论(0)