1. 概述

本文将深入探讨Spring Reactive中的switchIfEmpty()操作符,重点分析其在配合defer()操作符使用时的行为差异。我们将通过实际案例演示这些操作符在不同场景下的交互方式,帮助读者理解它们对响应式流的影响。

2. switchIfEmpty()和defer()的使用场景

switchIfEmpty()MonoFlux中的核心操作符,当原始数据源为空时,它会切换到备用数据源执行。若主数据源未发出任何数据,该操作符会自动切换到备用数据源获取数据。

考虑一个从大文件中按ID获取用户详情的接口:每次请求都遍历文件会消耗大量时间。对于高频访问的ID,使用缓存显然更高效。

当接口收到请求时:

  1. 首先查询缓存
  2. 若缓存命中,直接返回数据
  3. 若缓存未命中,从文件获取数据并更新缓存

在这个场景中:

  • 主数据源:缓存查询流程
  • 备用数据源:文件查询并更新缓存的流程
  • switchIfEmpty():根据缓存数据可用性智能切换数据源

⚠️ 关键点defer()操作符会延迟函数求值直到订阅发生。当switchIfEmpty()不使用defer()时,表达式会立即求值(急切求值),可能导致意外副作用。

3. 环境搭建

通过具体实现来分析switchIfEmpty()在不同场景下的行为。我们将编写代码并通过系统日志判断数据来源(缓存/文件)。

3.1 数据模型

定义包含idnameemailroles等字段的用户模型:

public class User {

    @JsonProperty("id")
    private String id;

    @JsonProperty("email")
    private String email;

    @JsonProperty("username")
    private String username;

    @JsonProperty("roles")
    private String roles;

    // 标准getter和setter...
}

3.2 用户数据准备

在类路径下创建users.json文件存储所有用户数据(JSON格式):

[  
  {
    "id": "66b296723881ea345705baf1",
    "email": "reid@example.com",
    "username": "reid90",
    "roles": "member"
  },
  {
    "id": "66b29672e6f99a7156cc4ada",
    "email": "boyle@example.com",
    "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()):

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();
}

当缓存未命中时,从文件获取数据并更新缓存:

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. 测试验证

使用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", "boyle@example.com", "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\":\"boyle@example.com\",\"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")));
}

关键结论:使用defer()时,备用数据源不会急切求值。

4.2 非空源使用switchIfEmpty()(无defer)

测试不使用defer()时的行为:

@Test
void givenUserDataIsAvailableInCache_whenUserByIdIsRequestedWithoutDeferParameter_thenUserIsFetchedFromFileInAdditionToCache() {
    usersCache = new HashMap<>();
    User cachedUser1 = new User("66b29672e6f99a7156cc4ada", "boyle@example.com", "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\":\"boyle@example.com\",\"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()

验证缓存无数据时,从文件获取数据:

@Test
void givenUserDataIsNotAvailableInCache_whenUserByIdIsRequestedWithDeferParameter_thenFileResponseShouldBeRetrieved() {
    webTestClient.get()
      .uri("/api/v1/user/66b29672e6f99a7156cc4ada?withDefer=true")
      .exchange()
      .expectStatus()
      .isOk()
      .expectBody(String.class)
      .isEqualTo("{\"id\":\"66b29672e6f99a7156cc4ada\"
        ,\"email\":\"boyle@example.com\",\"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\":\"boyle@example.com\",\"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()操作符的行为特性:

  1. 最佳实践:将switchIfEmpty()defer()结合使用,确保仅在必要时访问备用数据源
  2. 常见陷阱:不使用defer()会导致备用数据源急切求值,产生不必要的计算和副作用
  3. ⚠️ 性能影响:在缓存命中场景下,不正确的实现会导致文件查询被不必要执行

简单粗暴地说:**永远在switchIfEmpty()中使用defer()**,这是避免性能陷阱的关键!

本文示例代码已上传至GitHub


原始标题:Understanding switchIfEmpty() in Spring Reactive | Baeldung