JDK17 |java17学习 第 16 章 Java 微基准线束
Chapter 17: Best Practices for Writing High-Quality Code
程序员相互交谈时,经常会使用非程序员无法理解的行话,或者是不同编程语言的程序员都听不懂的行话。但是那些使用相同编程语言的人彼此理解得很好。有时,它也可能取决于程序员的知识渊博。一个新手可能不明白一个有经验的程序员在说什么,而一个经验丰富的同事点头并以同样的方式回应。本章旨在填补这一空白,增进不同层次程序员之间的理解。在本章中,我们将讨论一些 Java 编程术语——描述某些特性、功能、设计解决方案等的 Java 习语。您还将了解设计和编写应用程序代码的最流行和最有用的做法。
本章将涵盖以下主题:
- Java 习语、它们的实现和用法
equals()
、hashCode()
、compareTo()
和clone()
方法StringBuffer
和StringBuilder
类try
、catch
和finally
子句- 最佳设计实践
- 代码是为人编写的
- 使用完善的框架和库
- 测试是获得高质量代码的最短途径
到本章结束时,您将深入了解其他 Java 程序员在讨论他们的设计决策和他们使用的功能时所谈论的内容。
Technical requirements
要执行本章提供的代码示例,您将需要以下内容:
- 装有 Microsoft Windows、Apple macOS 或 Linux 的计算机
- Java SE 版本 17 或更高版本
- 您选择的 IDE 或代码编辑器
第 1 章, Java 17 入门。包含本章代码示例的文件可在 GitHub 上的 https://github .com/PacktPublishing/Learn-Java-17-Programming.git 在 examples/src/main/java/com/packt/learnjava/ch17_bestpractices 文件夹和 spring code> 和
reactive
文件夹。
Java idioms, their implementation, and their usage
除了作为专业人士之间的一种交流方式外,编程习语也是经过验证的编程解决方案和常见做法,它们并非直接源自语言规范,而是源于编程经验。在本节中,我们将讨论最常用的那些。您可以在 官方 Java 文档(https://docs.oracle.com/javase/tutorial)。
The equals() and hashCode() methods
equals()
和 hashCode()
方法的默认实现 java.lang.Object
类如下所示:
正如您所见, 方法的默认实现仅比较指向对象所在地址的内存引用被存储。同样,从注释中可以看到(引用自源代码), hashCode()
方法 返回相同的整数相同的对象和不同的对象不同的整数。让我们使用 Person
类来演示:
这是一个 示例,说明默认 equals()
和 hashCode()
方法 行为:
您系统中的输出可能略有不同。
person1
和 person2
引用和它们的哈希码是相等的,因为它们指向同一个对象(相同的内存区域和相同的地址),而 person3
引用指向另一个对象。
但在实践中,正如我们在 第 6 章
、数据结构、泛型和流行实用程序,我们希望对象的相等性基于所有或部分对象属性的值.因此,这是 equals()
和 hashCode()
方法的典型实现:
它曾经涉及更多,但使用 java.util.Objects
实用程序会更容易,特别是如果您注意到 Objects.equals( )
方法也处理 null
。
在这里,我们 添加了 描述的equals()
的实现和hashCode()
方法到 Person1
类并执行相同的比较:
如您所见,我们所做的更改不仅使相同的对象相等,而且使两个具有相同属性值的不同对象也相等。此外,哈希码值现在也基于相同属性的值。
在第 6 章中,< em class="italic">数据结构、泛型和流行实用程序,我们解释了为什么在实现 hasCode()
方法很重要class="literal">equals() 方法。
在 equals()
方法 和 hashCode()
方法中的哈希计算。
在这些方法前面添加 @Override
注释可确保它们覆盖 Object
类中的默认实现。否则,方法名称中的拼写错误可能会产生一种错觉,即新实现正在被使用,而实际上并没有被使用。事实证明,调试此类情况比仅添加 @Override
注释要困难得多且成本高,如果该方法未覆盖任何内容,则会生成错误。
The compareTo() method
在第 6 章中,< em class="italic">Data Structures, Generics, and Popular Utilities,我们使用了compareTo()
方法(Comparable
接口)广泛地指出基于这个方法建立的顺序(其集合的元素实现)称为自然顺序。
为了证明这一点,我们创建了 Person2
类:
然后,我们 组成了一个 Person2
对象列表并对其进行排序:
结果如下所示:
有三点值得注意:
- 根据
Comparable
接口,compareTo()
方法必须返回负整数、零或正整数,如果对象小于、等于或大于另一个对象。在我们的实现中,如果两个对象的相同属性的值不同,我们会立即返回结果。我们已经知道这个对象是更大或更小,不管其他属性是什么。但是比较两个对象的属性的顺序会影响最终结果。它定义了属性值影响顺序的优先级。 - 我们已将
List.of()
的结果放入new ArrayList()
对象中。我们这样做是因为,正如我们在 第 6 章 ,Data Structures, Generics, and Popular Utilities,由of()
工厂创建的集合方法是不可修改的。不能从中添加或删除元素,也不能更改元素的顺序,而我们需要对创建的集合进行排序。我们只使用了of()
方法,因为它更方便并且提供了更短的符号。 - 最后,使用
java.util.Objects
来比较属性使得实现比自定义编码更容易和更可靠。
在 实现 compareTo()
方法时,确保不违反以下规则很重要:
obj1.compareTo(obj2)
返回与obj2.compareTo(obj1)
相同的值,但仅当返回值为 <代码类="literal">0。- 如果返回值不是
0
,则obj1.compareTo(obj2)
与obj2.compareTo(obj1)
。 - 如果
obj1.compareTo(obj2) > 0
和obj2.compareTo(obj3) > 0
,然后obj1.compareTo(obj3) > 0 。
- 如果
obj1.compareTo(obj2)
obj1.compareTo(obj2) < 0
和obj2.compareTo(obj3) < 0
,然后obj1.compareTo(obj3)
obj1.compareTo(obj3) < 0 。
- 如果
obj1.compareTo(obj2) == 0
,那么obj2.compareTo(obj3)
和obj1.compareTo(obj3) > 0
具有相同的符号。 obj1.compareTo(obj2)
和obj2.compareTo(obj1)
都会抛出相同的异常,如果有的话。
建议但并不总是要求,如果 obj1.equals(obj2)
,则 obj1.compareTo(obj2) == 0
,同时如果obj1.compareTo(obj2) == 0
,那么obj1.equals(obj2)
。
The clone() method
clone()
方法的 在 java.lang.Object
类中的实现看起来像这样:
上述代码中显示的注释说明如下:
此方法的默认结果按原样返回对象字段的副本,如果值是原始类型,这很好。但是,如果一个对象属性持有对另一个对象的引用,则只会复制引用本身,而不是引用的对象。这就是为什么这样的副本被称为浅副本。要获得 深拷贝,您必须重写 clone()
方法并克隆每个对象 引用对象的属性。
在任何情况下,为了能够克隆一个对象,它必须实现 Cloneable
接口并确保继承树上的所有对象(以及作为对象的属性)都实现Cloneable
接口(除了 java.lang.Object
类)。 Cloneable
接口只是一个标记接口,它告诉编译器程序员有意识地决定允许克隆这个对象(无论是因为浅拷贝足够好还是因为clone()
方法被覆盖)。尝试在未实现 Cloneable
接口的对象上调用 clone()
将导致 CloneNotSupportedException
。
它看起来已经很复杂,但在实践中,还有更多的陷阱。例如,假设 Person
类具有 Address
address
属性代码>类型。 Person
对象 p1
的浅拷贝 p2
将引用 Address
的同一对象,以便 p1.address == p2.address
。这是一个例子。 Address
类如下所示:
请注意,clone()
方法 进行了浅拷贝,因为它不会克隆 地址
属性。下面是使用这种 clone()
方法实现的结果:
可以看到,克隆完成后,对源对象的address
属性所做的更改就体现出来了在克隆的相同属性中。这不是很直观,是吗?克隆时,我们期待一个独立的副本,不是吗?
为避免共享 Address
对象,您也必须显式克隆它。为此,您必须使 Address
对象可克隆,如下所示:
有了这个实现,我们现在可以添加 address
属性进行克隆:
现在,如果我们运行 相同的测试,结果将与我们最初预期的一样:
因此,如果应用程序希望所有属性都被深度复制,那么所有涉及的对象都必须是可克隆的。只要没有任何相关对象,无论是当前对象中的属性还是父类(以及它们的属性和父类),都没有获取新的对象属性而不使它们可克隆并且在 容器对象的 clone()
方法。最后这句话很复杂。其复杂性的原因在于克隆过程的潜在复杂性。这就是为什么程序员经常远离使对象可克隆的原因。
相反,如果需要,他们更喜欢手动克隆对象,如以下代码所示:
如果将另一个属性添加到任何相关对象,这种方法仍然需要更改代码。但是,它提供了对结果的更多控制,并且出现意外后果的可能性较小。
幸运的是,clone()
方法并不经常使用。您可能永远不会遇到使用它的需要。
The StringBuffer and StringBuilder classes
我们谈到了StringBuffer
和第 6 章中的 literal">StringBuilder
类< /em>,数据结构、泛型和流行实用程序。我们不打算在这里重复这一点。相反,我们只会提到,在单线程进程中(绝大多数情况下),StringBuilder
类是首选,因为它更快。
The try, catch, and finally clauses
第 4 章,异常处理,是专用于使用try
,catch
和 finally
子句,所以 我们这里不再赘述。我们希望 重申使用 try-with-resources
语句是释放资源的首选方式(传统上在finally
块)。延迟库使代码更简单、更可靠。
Best design practices
best 一词通常是 主观的和上下文相关的。这就是为什么我们要披露以下建议是基于主流编程中的绝大多数案例。但是,不应盲目和无条件地遵循它们,因为在某些情况下,其中一些做法在某些情况下是无用的,甚至是错误的。在关注他们之前,请尝试了解他们背后的动机,并将其作为您决策的指南。例如,大小很重要。如果应用程序的代码量不会超过几千行,那么一个带有洗衣清单式代码的简单单体就足够了。但是,如果有复杂的代码块并且有几个人在处理它,那么如果一个特定的代码区域比其他代码区域需要更多的资源,那么将代码分解成专门的部分将有利于代码的理解、维护甚至扩展。
我们将从没有特定顺序的更高级别的设计决策开始。
Identifying loosely coupled functional areas
这些设计 决策可以在很早的时候做出,仅基于对未来系统的主要部分、它们的功能以及它们产生和交换的数据的一般理解。这样做有几个好处:
Breaking the functional area into traditional tiers
有了每个 功能区域,就可以使用基于技术方面和技术的专业化。传统的技术专业分离如下:
- 前端(用户图形或 Web 界面)
- 具有广泛业务逻辑的中间层
- 后端(数据存储或数据源)
这样做的好处包括:
- 您可以按层部署和扩展
- 您可以根据自己的专业知识获得程序员专业化
- 您可以并行开发零件
Coding to an interface
专门的部分,基于前两小节中描述的决定,必须在隐藏实现细节的接口中描述。这种设计的好处在于面向对象编程(OOP)的基础,并在第 2 章,Java 面向对象编程 (OOP),所以这里不再赘述。
Using factories
我们在 "italic">第 2 章,Java 面向对象编程 (OOP)。根据定义,接口没有也不能描述实现该接口的类的构造函数。使用工厂可以让你缩小这个差距,只向客户端公开一个接口。
Preferring composition over inheritance
最初,OOP 专注于将继承作为在对象之间共享通用功能的一种方式。继承甚至是四个 OOP 原则之一,正如我们在 第 2 章,Java 面向对象编程 (OOP)。然而,在实践中,这种功能共享方法会在同一继承行中包含的类之间产生过多的依赖关系。应用程序功能的演变往往是不可预测的,继承链中的一些类开始获得与类链的原始目的无关的功能。我们可以争辩说,有一些设计解决方案允许我们不这样做并保持原始类的完整性。但是,在实践中,这样的事情一直在发生,子类可能会因为通过继承获得新的功能而突然改变行为。我们不能选择我们的父母,不是吗?此外,它以这种方式打破了封装,这是 OOP 的另一个基本原则。
另一方面,组合允许我们选择和控制要使用类的哪些功能以及忽略哪些功能。它还允许对象保持轻量级并且不受继承的负担。这样的设计更加灵活、可扩展和可预测。
Using libraries
在整个本书中,我们已经提到使用Java类库(JCL) 和 外部(Java 开发工具包 (JDK)) Java 库使编程变得更加容易并生成更高质量的代码。 第 7 章,Java 标准库和外部库,包含最流行的 Java 库的概述。创建图书馆的人投入了大量的时间和精力,因此您应该随时利用它们。
在第 13 章中,< em class="italic">函数式编程,我们描述了驻留在 JCL java.util.function
包中的标准函数式接口。这是利用库的另一种方式——通过使用它的一组众所周知的共享接口,而不是定义你自己的接口。
这最后一个陈述很好地延续了下一个关于编写其他人容易理解的代码的主题。
Code is written for people
最初几十年的编程需要编写机器命令,以便电子设备可以执行它们。这不仅是一项乏味且容易出错的工作,而且还要求您以能够产生最佳性能的方式编写指令。这是因为计算机速度很慢,并且没有做太多的代码优化,如果有的话。
从那时起,我们在硬件和编程方面都取得了很大的进步。现代编译器在使提交的代码尽可能快地工作方面走了很长一段路,即使程序员没有考虑它。我们在上一章中通过具体的例子讨论了这一点。
它允许程序员编写更多代码行,而无需过多考虑优化。但传统和许多关于编程的书籍继续呼吁它,一些程序员仍然担心他们的代码性能——比它产生的结果更担心。遵循传统比打破传统更容易。这就是为什么程序员往往更关注他们编写代码的方式而不是他们自动化的业务,尽管实现错误业务逻辑的好代码是无用的。
不过,回到主题。使用现代 JVM,程序员对代码优化的需求不像以前那么紧迫。如今,程序员必须主要关注全局,以避免导致代码性能不佳的结构错误以及多次使用的代码。随着 JVM 变得更加复杂,后者变得不那么紧迫,实时观察代码,并在使用相同输入多次调用相同代码块时返回结果(不执行)。
这给我们留下了唯一可能的结论——在编写代码时,您必须确保它易于人类阅读和理解,而不是计算机。那些在这个行业工作了一段时间的人对他们几年前写的代码感到困惑。您可以通过其意图的清晰度和透明度 来改进您的代码编写风格。
现在,让我们讨论评论的必要性。我们不需要注释来回应代码所做的事情,如以下示例所示:
注释代码可能非常复杂。好的注释解释了意图并提供了帮助我们理解代码的指导。然而,程序员通常懒得写注释。反对写评论的论点通常包括两个陈述:
- 注释必须与代码一起维护和发展;否则,它们可能会产生误导。但是,没有任何工具可以提示程序员在更改代码的同时调整注释。因此,评论是危险的。
- 必须编写代码本身(包括变量和方法的名称选择),这样就不需要额外的解释。
两种说法 都是正确的,但评论也很有帮助,尤其是那些抓住意图的评论。此外,此类注释往往需要较少的调整,因为代码意图不会经常更改(如果有的话)。
Use well-established frameworks and libraries
程序员 并不总是有机会选择框架和库来开发软件。通常,公司更愿意保留他们已经用于其他项目的软件和开发工具集。但是,当您有这样的选择可能性时,可用产品的种类可能会不堪重负。选择编程社区中流行的最新报价也可能很诱人。然而,经验一次又一次地证明,最好的行动方案是选择一些成熟且被证明具有生产能力的产品。此外,使用历史悠久的可靠软件通常需要编写更少的样板代码。
为了证明这一点,我们创建了两个项目:
- 使用 Spring Boot 框架
- 使用 Vert.x 工具包
我们从 Spring Boot 开始。它是一个基于 Java 的开源框架,由 Pivotal 团队开发,用于构建独立的生产型应用程序。默认情况下,它不需要外部 Web 服务器,因为它嵌入了 Web 服务器(Tomcat 或 Netty)。因此,Spring Boot 用户不需要编写任何非业务代码。您甚至不需要像在 Spring 中那样创建配置。您只需使用属性文件定义您需要的非业务功能(例如健康检查、指标或 swagger 文档),并使用注释对其进行调整。
自然,因为背后有这么多的实现,Spring Boot 是很有主见的。但是,当它不能用于生成可靠有效的应用程序时,您将很难找到一个案例。 Spring Boot 的局限性很可能会在大型项目中体现出来。使用 Spring Boot 的最佳方法是采用它的做事方式,因为这样做可以节省大量时间,并获得一个健壮且优化良好的解决方案。
为了简化依赖管理,Spring Boot 在所谓的 starter
JAR 中为每种类型的应用程序提供了所需的第三方依赖文件。例如,spring-boot-starter-web 将 Spring MVC (Model-View-Controller) 和 Tomcat Web 服务器所需的所有库引入项目。 Spring Boot 根据选择的启动包自动配置应用程序。
您可以在 https://spring.io/projects/spring-boot。如果您打算在您的工作中使用 Spring Boot,我们鼓励您阅读它。
为了展示 Spring Boot 的功能和优势,我们在 spring
文件夹中创建了一个项目。要运行此示例应用程序,您需要在 中创建的数据库本书的第 10 章,管理数据库中的数据,开始运行。示例应用程序管理(创建、读取、更新、删除)数据库中的人员记录。此功能可通过以人为本的 UI(HTML 页面)访问。此外,我们通过 RESTful 服务实现了对相同功能的访问,其他应用程序可以使用这些服务。
您可以通过执行 Application
类从 IDE 运行 应用程序。或者,您可以从命令行启动应用程序。 spring
文件夹中有两个命令文件:mvnw
(适用于 Unix/Linux/Mac 系统)和 mvnw.cmd
(适用于 Windows)。它们可用于启动应用程序,如下所示:
- 对于 Unix/Linux/Mac 系统:
- 对于 Windows:
当你第一次这样做时,你可能会得到一个错误:
如果发生这种情况,请通过执行以下命令安装 Maven 包装器:
或者,您可以构建可执行的 .jar
文件:
- 对于 Unix/Linux/Mac 系统:
- 对于 Windows:
然后,您可以使用以下命令将创建的 .jar
文件放在任何安装了 Java 17 的计算机上并运行它:
应用程序运行后,执行以下命令:
curl
命令要求应用程序创建新的人员记录。预期的响应如下所示(每次运行此命令时 id
值都会不同):
要查看响应中的 HTTP 代码,请添加 选项-v
到命令。 HTTP 代码 200 表示请求处理成功。
现在让我们执行 update
命令:
应用程序对此命令的响应如下:
请注意,并非负载中的所有字段都必须填充。只有 id
值是必需的,并且必须与现有记录之一匹配。应用程序通过提供的 id
值检索当前的 Person
记录,并仅更新提供的那些属性。
delete
端点的构造类似。不同之处在于数据(Person
记录标识号id
)作为 URL 的一部分传递。现在让我们执行以下命令:
应用程序使用以下消息响应此命令:
上述所有功能都可以通过 UI 访问。在浏览器中输入 URL http://localhost:8083/ui/list
并单击相应的链接。
您也可以在浏览器 URL 中输入 http://localhost:8083
并访问以下页面:
然后再次单击任何可用的链接。 Home
页面提供有关当前应用程序版本及其健康状况的信息。 http://localhost:8083/swagger-ui.html
URL 会显示所有应用程序端点的列表。
我们强烈建议您学习应用程序代码并阅读 https://spring.io/ 上的 Spring Boot 文档项目/spring-boot 网站。
现在让我们查看 reactive
文件夹中的项目。它使用 Vert.x 演示了反应式通信方法,Vert.x 是一个事件驱动的非阻塞轻量级多语言工具包。它允许您使用 Java、JavaScript、Groovy、Ruby、Scala、Kotlin 和 Ceylon 编写组件。它支持异步编程模型和到达 JavaScript 浏览器的分布式事件总线,从而允许创建实时 Web 应用程序。但是,由于本书的重点,我们将只使用 Java。
为了演示使用 Vert.x 工具包实现的微服务的反应式系统的外观,我们 创建了一个 HTTP 服务器,它可以接受基于 REST 的系统请求,将基于 EventBus 的消息发送到另一个 verticle(在Vert.x 工具包),接收回复,并将回复发送回原始请求。
我们创建了两个verticle:
现在我们可以按如下方式部署它们:
要执行此代码,请运行 ReactiveSystemDemo
类。预计结果如下:
现在让我们开始向我们的系统发送 HTTP 请求。首先,让我们发送相同的 HTTP GET
请求 3 次:
如果有几个verticles注册了相同的地址(在我们的例子中:我们注册了两个verticles 一个
地址),系统使用循环算法来选择应该接收下一条消息的verticle。
第一个请求以 ID="1"
发送给接收者,第二个请求以 ID="2"
发送给接收者>,第三个请求再次以 ID="1"
发送给接收者。
我们对 /some/path/send
路径使用 HTTP POST
请求得到相同的结果:
同样,消息的接收者使用循环算法进行轮换。现在,让我们将消息发布到我们的系统两次。
由于接收者的回复无法传播回系统用户,我们需要查看后端记录的消息:
可以看到,publish()
方法将消息发送到所有注册到 指定地址的verticles。并注意 verticle 带有 ID="3"
(注册了 两个
地址)从未收到消息。
在我们结束这个反应式系统演示之前,值得一提的是,Vert.x 工具包允许您 轻松地集群 Verticle。您可以在 Vert.x 文档中了解此功能">https://vertx.io/docs/vertx-core/java。
这两个示例展示了如果您使用完善的框架,您只需编写很少的代码即可创建完整的 Web 应用程序。这并不意味着您不能探索最新最好的框架。无论如何,您可以而且应该这样做以跟上您所在行业的进步。请记住,新产品需要一些时间才能成熟并变得足够可靠和有用,以创建强大的生产软件解决方案。
Testing is the shortest path to quality code
我们将讨论的最后一个最佳实践是这样的陈述:测试不是开销或负担;它是程序员的成功指南。唯一的问题是何时编写测试。
有一个令人信服的论点要求在编写任何代码行之前编写测试。如果你能做到,那就太好了。我们不会试图说服你放弃它。但是,如果您不这样做,请尝试在您编写完一个或所有您被要求编写的代码行之后开始编写测试。
在实践中,许多有经验的程序员发现在一些新功能实现后开始编写测试代码很有帮助。这是因为那是程序员更好地理解新代码如何适应现有上下文的时候。他们甚至可能会尝试对一些值进行硬编码,以查看新代码与调用新方法的代码的集成程度。在确保新代码很好地集成后,程序员可以继续实现和调整它,同时根据调用代码上下文中的要求测试新实现。
必须添加一个重要的条件 - 在编写 测试时,输入数据和测试标准最好不是由您设置,而是由分配任务的人设置或测试仪。根据代码产生的结果设置测试是一个众所周知的程序员陷阱。如果可能的话,客观的自我评估并不容易。
Quiz
回答以下问题以测试您对本章的了解:
- 选择所有正确的陈述:
- 习语可用于传达代码的意图。
- 成语可以用来解释代码的作用。
- 习语可能会被误用并掩盖谈话的主题。
- 应避免使用成语以清楚地表达想法。
- 是不是每次实现
equals()
都要实现hasCode()
? - 如果
obj1.compareTo(obj2)
返回负值,这是什么意思? - 深拷贝概念是否适用于克隆期间的原始值?
StringBuffer
或StringBuilder
哪个更快?- 对接口进行编码有什么好处?
- 使用组合与继承有什么好处?
- 使用库与编写自己的代码相比有什么优势?
- 谁是您的代码的目标受众?
- 是否需要测试?