理解 Spring Reactive 中的 switchIfEmpty()
1. 概述
在本文中,我們將重點放在了解 Spring Reactive 中的switchIfEmpty()
運算子及其在使用和不使用defer()
運算子的情況下的行為。我們將探討這些運算子如何在不同場景中交互,並提供實際範例來說明它們對反應流的影響。
2. switchIfEmpty()
和Defer()
的使用
switchIfEmpty()
是[Mono](https://projectreactor.io/docs/core/release/api/reactor/core/publisher/Mono.html)
和[Flux](https://projectreactor.io/docs/core/release/api/reactor/core/publisher/Flux.html)
中的一個運算符,如果來源生產者為空,它會執行備用生產者流。如果主要來源發布者未發出任何數據,則此操作符會切換到來自備用來源的資料發出。
讓我們考慮一個透過 ID 從大檔案中檢索使用者詳細資訊的端點。每次有人從文件中請求用戶詳細資訊時,迭代該文件都會消耗大量時間。因此,對於經常訪問的 ID 來說,快取其詳細資訊更有意義。
當端點收到請求時,我們將首先搜尋快取。如果用戶詳細資訊可用,我們將回傳回應。如果沒有,我們將從文件中獲取資料並將其緩存以供後續請求。
在這種情況下,主要資料提供者是檢查快取中密鑰是否存在的流,而替代資料提供者是檢查檔案中密鑰並更新快取的流。 switchIfEmpty()
運算子可以根據快取中資料的可用性有效地切換來源提供者。
了解[defer()](https://projectreactor.io/docs/core/release/api/reactor/core/publisher/Mono.html#defer-java.util.function.Supplier-)
運算子的使用也很重要,該運算子會延後或延遲函數的計算,直到發生訂閱為止。當我們不將defer()
運算子與switchIfEmpty()
一起使用時,表達式會立即(急切地)求值,可能會導致意外的副作用。
3. 設定
讓我們進一步探討這個範例,以了解switchIfEmpty()
運算子在不同情況下的行為。
我們將實作必要的程式碼並分析系統日誌以確定是從快取還是檔案中取得使用者。
3.1.資料模型
首先,讓我們定義一個使用者模型,其中包含一些詳細信息,例如id, name, email, roles
:
public class User {
@JsonProperty("id")
private String id;
@JsonProperty("email")
private String email;
@JsonProperty("username")
private String username;
@JsonProperty("roles")
private String roles;
// standard getters and setters...
}
3.2.用戶資料設定
隨後,我們在類別路徑 ( users.json
) 中維護一個文件,其中包含 JSON 格式的所有使用者詳細資訊:
[
{
"id": "66b296723881ea345705baf1",
"email": "[email protected]",
"username": "reid90",
"roles": "member"
},
{
"id": "66b29672e6f99a7156cc4ada",
"email": "[email protected]",
"username": "boyle94",
"roles": "admin"
},
...
]
3.3.控制器和服務實現
在下面的步驟中,我們新增一個透過 ID 檢索使用者詳細資料的控制器。它將接受一個可選的布林參數withDefer
,並根據此查詢參數涉及不同的實作:
@GetMapping("/user/{id}")
public Mono<ResponseEntity<User>> findUserDetails(@PathVariable("id") String id,
@RequestParam("withDefer") boolean withDefer) {
return (withDefer ? userService.findByUserIdWithDefer(id) :
userService.findByUserIdWithoutDefer(id)).map(ResponseEntity::ok);
}
然後,讓我們在UserService
中定義這兩個實作(有和沒有defer()
,以了解switchIfEmpty():
public Mono<User> findByUserIdWithDefer(String id) {
return fetchFromCache(id).switchIfEmpty(Mono.defer(() -> fetchFromFile(id)));
}
public Mono<User> findByUserIdWithoutDefer(String id) {
return fetchFromCache(id).switchIfEmpty(fetchFromFile(id));
}
為了簡單起見,我們實作一個記憶體快取來保留使用者資訊作為請求的主要資料提供者。我們還將記錄每次訪問,這使我們能夠確定快取是否檢索到資料:
private final Map<String, User> usersCache;
private Mono<User> fetchFromCache(String id) {
User user = usersCache.get(id);
if (user != null) {
LOG.info("Fetched user {} from cache", id);
return Mono.just(user);
}
return Mono.empty();
}
接下來,當 ID 不在快取中時,讓我們從文件中獲取用戶詳細信息,如果找到數據則更新快取:
private Mono<User> fetchFromFile(String id) {
try {
File file = new ClassPathResource("users.json").getFile();
String usersData = new String(Files.readAllBytes(file.toPath()));
List<User> users = objectMapper.readValue(usersData, new TypeReference<List<User>>() {
});
User user = users.stream()
.filter(u -> u.getId()
.equalsIgnoreCase(id))
.findFirst()
.get();
usersCache.put(user.getId(), user);
LOG.info("Fetched user {} from file", id);
return Mono.just(user);
} catch (IOException e) {
return Mono.error(e);
}
}
請注意日誌記錄詳細信息,以斷言是否從文件中檢索了用戶資料。
4. 測試
讓我們在BeforeEach
測試方法中加入一個ListAppender
來追蹤日誌。我們將使用它來確定快取或檔案是否針對不同的請求執行該函數:
protected ListAppender<ILoggingEvent> listAppender;
@BeforeEach
void setLogger() {
Logger logger = (Logger) LoggerFactory.getLogger(UserService.class);
logger.setLevel(Level.DEBUG);
listAppender = new ListAppender<>();
logger.addAppender(listAppender);
listAppender.start();
}
我們可以在以下部分中添加一些測試來驗證各種條件。
4.1. switchIfEmpty()
在非空源上使用defer()
只有當請求的withDefer
參數設為true
時,我們才會驗證實作是否從快取中檢索使用者數據,並且我們將相應地斷言記錄器輸出:
@Test
void givenUserDataIsAvailableInCache_whenUserByIdIsRequestedWithDeferParameter_thenCachedResponseShouldBeRetrieved() {
usersCache = new HashMap<>();
User cachedUser = new User("66b29672e6f99a7156cc4ada", "[email protected]", "boyle94", "admin");
usersCache.put("66b29672e6f99a7156cc4ada", cachedUser);
userService.getUsers()
.putAll(usersCache);
webTestClient.get()
.uri("/api/v1/user/66b29672e6f99a7156cc4ada?withDefer=true")
.exchange()
.expectStatus()
.isOk()
.expectBody(String.class)
.isEqualTo("{\"id\":\"66b29672e6f99a7156cc4ada\"," +
"\"email\":\"[email protected]\",\"username\":\"boyle94\",\"roles\":\"admin\"}");
assertTrue(listAppender.list.stream()
.anyMatch(e -> e.toString()
.contains("Fetched user 66b29672e6f99a7156cc4ada from cache")));
assertTrue(listAppender.list.stream()
.noneMatch(e -> e.toString()
.contains("Fetched user 66b29672e6f99a7156cc4ada from file")));
}
當我們將switchIfEmpty()
與defer()
運算子一起使用時,替代來源提供者不會立即被評估。
4.2. switchIfEmpty()
在非空源上沒有defer()
讓我們新增另一個測試來檢查在不使用defer()
運算子的情況下使用switchIfEmpty()
的行為:
@Test
void givenUserDataIsAvailableInCache_whenUserByIdIsRequestedWithoutDeferParameter_thenUserIsFetchedFromFileInAdditionToCache() {
usersCache = new HashMap<>();
User cachedUser1 = new User("66b29672e6f99a7156cc4ada", "[email protected]", "boyle94", "admin");
usersCache.put("66b29672e6f99a7156cc4ada", cachedUser1);
userService.getUsers().putAll(usersCache);
webTestClient.get()
.uri("/api/v1/user/66b29672e6f99a7156cc4ada?withDefer=false")
.exchange()
.expectStatus()
.isOk()
.expectBody(String.class)
.isEqualTo("{\"id\":\"66b29672e6f99a7156cc4ada\"," +
"\"email\":\"[email protected]\",\"username\":\"boyle94\",\"roles\":\"admin\"}");
assertTrue(listAppender.list.stream()
.anyMatch(e -> e.toString()
.contains("Fetched user 66b29672e6f99a7156cc4ada from file")));
assertTrue(listAppender.list.stream()
.anyMatch(e -> e.toString()
.contains("Fetched user 66b29672e6f99a7156cc4ada from cache")));
}
正如我們所看到的,該實現從快取和文件中獲取了用戶詳細信息,但它最終提供了來自快取的回應。儘管來自主來源(快取)的排放,但備用來源中的程式碼區塊被不必要地觸發。
4.3. switchIfEmpty()
在空源上使用defer()
接下來,讓我們新增一個測試,以驗證當快取中沒有資料時,特別是在使用defer()
運算子時,是否從檔案中檢索使用者詳細資訊:
@Test
void givenUserDataIsNotAvailableInCache_whenUserByIdIsRequestedWithDeferParameter_thenFileResponseShouldBeRetrieved() {
webTestClient.get()
.uri("/api/v1/user/66b29672e6f99a7156cc4ada?withDefer=true")
.exchange()
.expectStatus()
.isOk()
.expectBody(String.class)
.isEqualTo("{\"id\":\"66b29672e6f99a7156cc4ada\"
,\"email\":\"[email protected]\",\"username\":\"boyle94\",\"roles\":\"admin\"}");
assertTrue(listAppender.list.stream()
.anyMatch(e -> e.toString()
.contains("Fetched user 66b29672e6f99a7156cc4ada from file")));
assertTrue(listAppender.list.stream()
.noneMatch(e -> e.toString()
.contains("Fetched user 66b29672e6f99a7156cc4ada from cache")));
}
API 按預期從文件中獲取用戶詳細信息,而不是嘗試從快取中檢索它們。
4.4. switchIfEmpty()
在空源上沒有defer()
最後,讓我們新增一個測試來驗證實作在不使用defer()
情況下是否從檔案中檢索使用者詳細資訊(即快取中沒有資料):
@Test
void givenUserDataIsNotAvailableInCache_whenUserByIdIsRequestedWithoutDeferParameter_thenFileResponseShouldBeRetrieved() {
webTestClient.get()
.uri("/api/v1/user/66b29672e6f99a7156cc4ada?withDefer=false")
.exchange()
.expectStatus()
.isOk()
.expectBody(String.class)
.isEqualTo("{\"id\":\"66b29672e6f99a7156cc4ada\"," + "\"email\":\"[email protected]\",\"username\":\"boyle94\",\"roles\":\"admin\"}");
assertTrue(listAppender.list.stream()
.anyMatch(e -> e.toString()
.contains("Fetched user 66b29672e6f99a7156cc4ada from file")));
assertTrue(listAppender.list.stream()
.noneMatch(e -> e.toString()
.contains("Fetched user 66b29672e6f99a7156cc4ada from cache")));
}
由於快取中沒有數據,因此不會從快取中獲取用戶詳細信息,而是會嘗試從快取中獲取用戶詳細信息,但 API 仍會按預期從文件中獲取用戶詳細信息。
5. 結論
在本文中,我們將重點放在測試來了解 Spring Reactive 中的switchIfEmpty()
運算子及其各種行為。
將switchIfEmpty()
與defer()
結合使用可確保僅在必要時存取備用資料來源。這可以防止不必要的計算和潛在的副作用。
與往常一樣,範例的原始程式碼可在 GitHub 上取得。