最常见的 10 个 Spring 框架错误

已发表: 2022-03-11

Spring 可以说是最流行的 Java 框架之一,也是一头难以驯服的野兽。 虽然它的基本概念相当容易掌握,但成为一名优秀的 Spring 开发人员需要一些时间和精力。

在本文中,我们将介绍 Spring 中一些更常见的错误,特别是针对 Web 应用程序和 Spring Boot。 正如 Spring Boot 的网站所述,Spring Boot 对应该如何构建生产就绪应用程序持固执己见的观点,因此本文将尝试模仿这种观点并提供一些技巧的概述,这些技巧将很好地融入标准的 Spring Boot Web 应用程序开发中。

如果您对 Spring Boot 不是很熟悉,但仍想尝试其中提到的一些内容,我已经创建了一个与本文配套的 GitHub 存储库。 如果您在本文中的任何时候感到迷茫,我建议您克隆存储库并在本地计算机上使用代码。

常见错误 #1:级别太低

我们正在解决这个常见的错误,因为“不是这里发明的”综合症在软件开发世界中很常见。 包括定期重写常用代码片段在内的症状和许多开发人员似乎都深受其害。

虽然了解特定库的内部结构及其实现在很大程度上是好的和必要的(并且也可能是一个很好的学习过程),但不断处理相同的低级实现对您作为软件工程师的发展是有害的细节。 存在诸如 Spring 之类的抽象和框架是有原因的,这正是为了将您从重复的手动工作中分离出来,让您专注于更高级别的细节——您的领域对象和业务逻辑。

所以拥抱抽象——下次遇到特定问题时,先快速搜索并确定解决该问题的库是否已集成到 Spring 中; 如今,您很可能会找到合适的现有解决方案。 作为一个有用库的示例,我将在本文其余部分的示例中使用 Project Lombok 注释。 Lombok 被用作样板代码生成器,希望您内部的懒惰开发人员在熟悉该库时不会遇到问题。 举个例子,看看 Lombok 的“标准 Java bean”是什么样子的:

 @Getter @Setter @NoArgsConstructor public class Bean implements Serializable { int firstBeanProperty; String secondBeanProperty; }

正如您可能想象的那样,上面的代码编译为:

 public class Bean implements Serializable { private int firstBeanProperty; private String secondBeanProperty; public int getFirstBeanProperty() { return this.firstBeanProperty; } public String getSecondBeanProperty() { return this.secondBeanProperty; } public void setFirstBeanProperty(int firstBeanProperty) { this.firstBeanProperty = firstBeanProperty; } public void setSecondBeanProperty(String secondBeanProperty) { this.secondBeanProperty = secondBeanProperty; } public Bean() { } }

但是请注意,如果您打算在 IDE 中使用 Lombok,您很可能必须安装一个插件。 IntelliJ IDEA 的插件版本可以在这里找到。

常见错误 #2:“泄漏”内部结构

暴露你的内部结构从来都不是一个好主意,因为它会在服务设计中造成不灵活,从而促进糟糕的编码实践。 “泄漏”内部通过使数据库结构可以从某些 API 端点访问来表现出来。 例如,假设以下 POJO(“Plain Old Java Object”)表示数据库中的一个表:

 @Entity @NoArgsConstructor @Getter public class TopTalentEntity { @Id @GeneratedValue private Integer id; @Column private String name; public TopTalentEntity(String name) { this.name = name; } }

假设存在一个需要访问TopTalentEntity数据的端点。 返回TopTalentEntity实例可能很诱人,但更灵活的解决方案是创建一个新类来表示 API 端点上的TopTalentEntity数据:

 @AllArgsConstructor @NoArgsConstructor @Getter public class TopTalentData { private String name; }

这样,对数据库后端进行更改将不需要对服务层进行任何额外的更改。 考虑在向TopTalentEntity添加“密码”字段以将用户的密码哈希存储在数据库中的情况下会发生什么 - 如果没有诸如TopTalentData类的连接器,忘记更改服务前端会意外暴露一些非常不受欢迎的秘密信息!

常见错误 #3:缺乏关注点分离

随着您的应用程序的增长,代码组织越来越开始成为一个越来越重要的问题。 具有讽刺意味的是,大多数优秀的软件工程原则开始大规模崩溃——尤其是在没有太多考虑应用程序架构设计的情况下。 开发人员最容易犯的错误之一就是混合代码问题,而且非常容易做到!

通常打破关注点分离的只是将新功能“转储”到现有类中。 当然,这是一个很好的短期解决方案(对于初学者来说,它需要更少的输入),但它不可避免地会成为一个进一步的问题,无论是在测试、维护期间还是介于两者之间。 考虑以下控制器,它从其存储库返回TopTalentData

 @RestController public class TopTalentController { private final TopTalentRepository topTalentRepository; @RequestMapping("/toptal/get") public List<TopTalentData> getTopTalent() { return topTalentRepository.findAll() .stream() .map(this::entityToData) .collect(Collectors.toList()); } private TopTalentData entityToData(TopTalentEntity topTalentEntity) { return new TopTalentData(topTalentEntity.getName()); } }

起初,这段代码似乎没有什么特别的问题。 它提供了从TopTalentEntity实例中检索的TopTalentData列表。 然而,仔细观察,我们可以看到TopTalentController实际上在这里执行了一些操作; 即,它将请求映射到特定端点,从存储库中检索数据,并将从TopTalentRepository接收到的实体转换为不同的格式。 一个“更清洁”的解决方案是将这些关注点分离到它们自己的类中。 它可能看起来像这样:

 @RestController @RequestMapping("/toptal") @AllArgsConstructor public class TopTalentController { private final TopTalentService topTalentService; @RequestMapping("/get") public List<TopTalentData> getTopTalent() { return topTalentService.getTopTalent(); } } @AllArgsConstructor @Service public class TopTalentService { private final TopTalentRepository topTalentRepository; private final TopTalentEntityConverter topTalentEntityConverter; public List<TopTalentData> getTopTalent() { return topTalentRepository.findAll() .stream() .map(topTalentEntityConverter::toResponse) .collect(Collectors.toList()); } } @Component public class TopTalentEntityConverter { public TopTalentData toResponse(TopTalentEntity topTalentEntity) { return new TopTalentData(topTalentEntity.getName()); } }

这种层次结构的另一个优点是它允许我们仅通过检查类名来确定功能所在的位置。 此外,在测试期间,如果需要,我们可以轻松地用模拟实现替换任何类。

常见错误 #4:不一致和糟糕的错误处理

一致性主题不一定是 Spring(或 Java,就此而言)所独有的,但仍然是处理 Spring 项目时需要考虑的一个重要方面。 虽然编码风格可能会引起争论(通常是团队或整个公司内部达成一致的问题),但有一个共同的标准被证明是一个很好的生产力帮助。 对于多人团队尤其如此; 一致性允许切换发生,而无需花费大量资源来处理或提供有关不同类职责的冗长解释

考虑一个带有各种配置文件、服务和控制器的 Spring 项目。 在命名它们时在语义上保持一致会创建一个易于搜索的结构,任何新开发人员都可以在其中管理自己的代码方式; 例如,将 Config 后缀附加到您的配置类,将 Service 后缀附加到您的服务,并将 Controller 后缀附加到您的控制器。

与一致性主题密切相关的是,服务器端的错误处理值得特别强调。 如果您曾经不得不处理来自编写不佳的 API 的异常响应,您可能知道原因——正确解析异常可能会很痛苦,而首先确定这些异常发生的原因则更痛苦。

作为 API 开发人员,理想情况下,您希望涵盖所有面向用户的端点并将它们转换为常见的错误格式。 这通常意味着有一个通用的错误代码和描述,而不是 a) 返回“500 Internal Server Error”消息,或 b) 只是将堆栈跟踪转储给用户(实际上应该不惜一切代价避免这种情况)因为除了难以处理客户端之外,它还暴露了您的内部结构)。

常见错误响应格式的示例可能是:

 @Value public class ErrorResponse { private Integer errorCode; private String errorMessage; }

在大多数流行的 API 中经常会遇到类似的情况,并且往往工作良好,因为它可以轻松且系统地记录下来。 可以通过向方法提供@ExceptionHandler注释来将异常转换为这种格式(注释的示例在常见错误 #6 中)。

常见错误 #5:不正确地处理多线程

无论是在桌面应用程序还是 Web 应用程序中遇到 Spring 或没有 Spring,多线程都可能是一个难以破解的难题。 由程序并行执行引起的问题难以捉摸,而且常常极难调试——事实上,由于问题的性质,一旦你意识到你正在处理一个并行执行问题,你可能会去必须完全放弃调试器并“手动”检查您的代码,直到找到根本错误原因。 不幸的是,不存在解决此类问题的千篇一律的解决方案。 根据您的具体情况,您将不得不评估情况,然后从您认为最好的角度解决问题。

当然,理想情况下,您希望完全避免多线程错误。 同样,不存在一刀切的方法,但这里有一些调试和防止多线程错误的实际注意事项:

避免全局状态

首先,永远记住“全球状态”问题。 如果您正在创建一个多线程应用程序,那么绝对应密切监视全局可修改的任何内容,并在可能的情况下将其完全删除。 如果全局变量必须保持可修改是有原因的,请谨慎使用同步并跟踪应用程序的性能,以确认它不会因为新引入的等待期而变得迟缓。

避免可变性

这个直接来自函数式编程,并适应 OOP,指出应该避免类的可变性和状态变化。 简而言之,这意味着前面的 setter 方法和所有模型类上的私有 final 字段。 它们的值唯一发生变化的时间是在构造过程中。 通过这种方式,您可以确定不会出现争用问题,并且访问对象属性将始终提供正确的值。

记录关键数据

评估您的应用程序可能导致问题的位置并抢先记录所有关键数据。 如果发生错误,您将很高兴获得说明已收到哪些请求的信息,并更好地了解您的应用程序行为不端的原因。 再次需要注意的是,日志记录引入了额外的文件 I/O,因此不应被滥用,因为它会严重影响应用程序的性能。

重用现有的实现

每当您需要生成自己的线程时(例如,为了向不同的服务发出异步请求),重用现有的安全实现,而不是创建自己的解决方案。 这在很大程度上意味着利用 ExecutorServices 和 Java 8 简洁的函数式 CompletableFutures 来创建线程。 Spring 还允许通过 DeferredResult 类进行异步请求处理。

常见错误 #6:不使用基于注释的验证

假设我们之前的 TopTalent 服务需要一个端点来添加新的 Top Talent。 此外,假设出于某种真正正当的原因,每个新名称都需要正好有 10 个字符长。 进行此操作的一种方法可能如下:

 @RequestMapping("/put") public void addTopTalent(@RequestBody TopTalentData topTalentData) { boolean nameNonExistentOrHasInvalidLength = Optional.ofNullable(topTalentData) .map(TopTalentData::getName) .map(name -> name.length() == 10) .orElse(true); if (nameNonExistentOrInvalidLength) { // throw some exception } topTalentService.addTopTalent(topTalentData); }

但是,上述(除了构造不佳之外)并不是真正的“干净”解决方案。 我们正在检查不止一种类型的有效性(即TopTalentData不为 null TopTalentData.name不为 null TopTalentData.name的长度为 10 个字符),如果数据无效则抛出异常.

这可以通过在 Spring 中使用 Hibernate 验证器来更干净地执行。 让我们首先重构addTopTalent方法以支持验证:

 @RequestMapping("/put") public void addTopTalent(@Valid @NotNull @RequestBody TopTalentData topTalentData) { topTalentService.addTopTalent(topTalentData); } @ExceptionHandler @ResponseStatus(HttpStatus.BAD_REQUEST) public ErrorResponse handleInvalidTopTalentDataException(MethodArgumentNotValidException methodArgumentNotValidException) { // handle validation exception }

此外,我们还必须在TopTalentData类中指明我们想要验证的属性:

 public class TopTalentData { @Length(min = 10, max = 10) @NotNull private String name; }

现在 Spring 将在调用方法之前拦截请求并对其进行验证——无需使用额外的手动测试。

我们可以实现相同目的的另一种方法是创建我们自己的注释。 虽然您通常只会在您的需求超过 Hibernate 的内置约束集时才使用自定义注解,但对于这个示例,我们假设 @Length 不存在。 您将创建一个验证器,通过创建两个附加类来检查字符串长度,一个用于验证,另一个用于注释属性:

 @Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) @Documented @Constraint(validatedBy = { MyAnnotationValidator.class }) public @interface MyAnnotation { String message() default "String length does not match expected"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; int value(); } @Component public class MyAnnotationValidator implements ConstraintValidator<MyAnnotation, String> { private int expectedLength; @Override public void initialize(MyAnnotation myAnnotation) { this.expectedLength = myAnnotation.value(); } @Override public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) { return s == null || s.length() == this.expectedLength; } }

请注意,在这些情况下,关注点分离的最佳实践要求您将属性标记为有效,如果它为 null(在isValid方法中s == null @NotNull ,然后如果这是对财产:

 public class TopTalentData { @MyAnnotation(value = 10) @NotNull private String name; }

常见错误 #7:(仍然)使用基于 XML 的配置

虽然 XML 是 Spring 早期版本的必需品,但现在大多数配置都可以通过 Java 代码/注释来完成; XML 配置只是作为额外的和不必要的样板代码。

本文(及其随附的 GitHub 存储库)使用注释来配置 Spring,并且 Spring 知道它应该连接哪些 bean,因为根包已使用@SpringBootApplication复合注释进行注释,如下所示:

 @SpringBootApplication public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }

复合注释(您可以在 Spring 文档中了解更多关于它的信息,它只是向 Spring 提示应该扫描哪些包以检索 bean。在我们的具体案例中,这意味着将使用顶部 (co.kukurin) 包下的以下内容接线:

  • @Component ( TopTalentConverter , MyAnnotationValidator )
  • @RestController ( TopTalentController )
  • @Repository ( TopTalentRepository )
  • @Service ( TopTalentService ) 类

如果我们有任何额外的@Configuration注释类,它们也将被检查基于 Java 的配置。

常见错误 #8:忘记个人资料

服务器开发中经常遇到的一个问题是区分不同的配置类型,通常是您的生产和开发配置。 无需在每次从测试切换到部署应用程序时手动替换各种配置条目,更有效的方法是使用配置文件。

考虑使用内存数据库进行本地开发的情况,以及生产中的 MySQL 数据库。 从本质上讲,这意味着您将使用不同的 URL 和(希望)不同的凭据来访问两者中的每一个。 让我们看看如何在两个不同的配置文件中做到这一点:

应用程序.yaml 文件

# set default profile to 'dev' spring.profiles.active: dev # production database details spring.datasource.url: 'jdbc:mysql://localhost:3306/toptal' spring.datasource.username: root spring.datasource.password:

应用程序-dev.yaml 文件

spring.datasource.url: 'jdbc:h2:mem:' spring.datasource.platform: h2

大概您不想在修改代码时不小心对生产数据库执行任何操作,因此将默认配置文件设置为 dev 是有意义的。 在服务器上,您可以通过向 JVM 提供-Dspring.profiles.active=prod参数来手动覆盖配置文件。 或者,您也可以将操作系统的环境变量设置为所需的默认配置文件。

常见错误 #9:未能接受依赖注入

正确使用 Spring 的依赖注入意味着允许它通过扫描所有需要的配置类来将所有对象连接在一起; 事实证明,这对于解耦关系很有用,也使测试变得更加容易。 通过执行以下操作而不是紧密耦合类:

 public class TopTalentController { private final TopTalentService topTalentService; public TopTalentController() { this.topTalentService = new TopTalentService(); } }

我们允许 Spring 为我们进行接线:

 public class TopTalentController { private final TopTalentService topTalentService; public TopTalentController(TopTalentService topTalentService) { this.topTalentService = topTalentService; } }

Misko Hevery 的 Google 演讲深入解释了依赖注入的“原因”,所以让我们看看它是如何在实践中使用的。 在关注点分离(常见错误 #3)部分,我们创建了一个服务和控制器类。 假设我们要在TopTalentService行为正确的假设下测试控制器。 我们可以通过提供一个单独的配置类来插入一个模拟对象来代替实际的服务实现:

 @Configuration public class SampleUnitTestConfig { @Bean public TopTalentService topTalentService() { TopTalentService topTalentService = Mockito.mock(TopTalentService.class); Mockito.when(topTalentService.getTopTalent()).thenReturn( Stream.of("Mary", "Joel").map(TopTalentData::new).collect(Collectors.toList())); return topTalentService; } }

然后我们可以通过告诉 Spring 使用SampleUnitTestConfig作为其配置提供者来注入模拟对象:

 @ContextConfiguration(classes = { SampleUnitTestConfig.class })

然后,这允许我们使用上下文配置将自定义 bean 注入到单元测试中。

常见错误 #10:缺乏测试或测试不当

尽管单元测试的想法已经存在很长时间了,但许多开发人员似乎要么“忘记”这样做(尤其是在不需要的情况下),要么只是在事后才添加它。 这显然是不可取的,因为测试不仅应该验证代码的正确性,还应该作为应用程序在不同情况下应该如何表现的文档。

在测试 Web 服务时,您很少专门进行“纯”单元测试,因为通过 HTTP 的通信通常需要您调用 Spring 的DispatcherServlet并查看接收到实际HttpServletRequest时会发生什么(使其成为集成测试、处理验证、序列化, 等等)。 REST Assured 是一种用于轻松测试 REST 服务的 Java DSL,它位于 MockMVC 之上,已被证明提供了一个非常优雅的解决方案。 考虑以下带有依赖注入的代码片段:

 @RunWith(SpringJUnit4ClassRunner.class) @ContextConfiguration(classes = { Application.class, SampleUnitTestConfig.class }) public class RestAssuredTestDemonstration { @Autowired private TopTalentController topTalentController; @Test public void shouldGetMaryAndJoel() throws Exception { // given MockMvcRequestSpecification givenRestAssuredSpecification = RestAssuredMockMvc.given() .standaloneSetup(topTalentController); // when MockMvcResponse response = givenRestAssuredSpecification.when().get("/toptal/get"); // then response.then().statusCode(200); response.then().body("name", hasItems("Mary", "Joel")); } }

SampleUnitTestConfig将 TopTalentService 的模拟实现TopTalentServiceTopTalentController ,而所有其他类都使用从根植于 Application 类的包的扫描包推断的标准配置进行连接。 RestAssuredMockMvc仅用于设置轻量级环境并向/toptal/get端点发送GET请求。

成为春季大师

Spring 是一个功能强大的框架,很容易上手,但需要一些奉献精神和时间才能完全掌握。 从长远来看,花时间熟悉框架肯定会提高您的生产力,并最终帮助您编写更简洁的代码并成为更好的开发人员。

如果您正在寻找更多资源,Spring In Action 是一本很好的实践书籍,涵盖了许多 Spring 核心主题。