最常見的 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 核心主題。