原创

Spring WebFlux 核心操作符详解:map、flatMap 与 Mono 常用方法

为什么这个主题容易学混

Spring WebFlux 本身并不难,真正让人困惑的,往往不是 API 数量,而是几个名字很像、行为却不一样的方法混在一起:mapflatMapMonoFluxswitchIfEmptydefaultIfEmptythenzipWithonErrorResume

很多人第一次接触 WebFlux 时,会下意识沿用同步编程的理解:

  • map 就是“转换一下值”
  • flatMap 就是“也能转换”
  • Mono 就是“一个对象的包装”
  • 调用链写完就等于执行完

这套理解不完全错,但一旦进入真实业务,比如查库、调用远程接口、做条件分支、错误恢复、空值处理,就会很快出问题。WebFlux 的核心不是“写成链式调用”,而是理解数据流如何在发布、转换、组合、终止和错误恢复中传播

这篇文章就围绕最常用、最容易混淆的一组操作来讲清楚两个问题:

  1. mapflatMap 到底该怎么区分
  2. Mono 在业务代码里最常用的方法应该怎么用

WebFlux 中的 Mono 和 Flux 到底是什么

在 Spring WebFlux 里,最核心的响应式类型来自 Reactor:

  • Mono<T>:表示 0 个或 1 个元素
  • Flux<T>:表示 0 个到多个元素

这不是普通集合,也不是 Future 的简单替代品,而是异步数据流的描述

可以先用最直接的方式理解:

  • 查询一个用户详情,通常是 Mono<User>
  • 查询用户列表,通常是 Flux<User>
  • 只关心操作是否完成,不关心具体值,通常仍然用 Mono<Void>

例如:

Mono<User> userMono = userRepository.findById(1L);
Flux<User> userFlux = userRepository.findAll();

这里的 userMonouserFlux 不是结果本身,而是未来如何产生结果的过程描述

这一点非常关键。因为后面所有操作符,本质上都不是“立刻执行”,而是在定义处理流程


map:同步一对一转换

map 是 WebFlux 中最基础也最常用的操作符之一。它的作用是:

把上游发出的元素,按同步方式转换成另一个元素。

特点有两个:

  1. 同步
  2. 一对一

看一个最简单的例子:

Mono<String> mono = Mono.just("webflux")
        .map(String::toUpperCase);

上游发出 "webflux",经过 map 后变成 "WEBFLUX"

再看一个业务场景:

Mono<UserDTO> result = userRepository.findById(1L)
        .map(user -> new UserDTO(user.getId(), user.getName()));

这里适合用 map,因为:

  • findById 返回的是 Mono<User>
  • 转换逻辑只是把 User 转成 UserDTO
  • 转换过程没有新的异步操作

所以这个动作属于普通对象转换,用 map 最合适。


map 的典型使用场景

1. 对象字段转换

Mono<String> nameMono = userRepository.findById(1L)
        .map(User::getName);

2. DTO 映射

Mono<UserVO> userVOMono = userRepository.findById(1L)
        .map(user -> new UserVO(user.getId(), user.getName(), user.getEmail()));

3. 结果格式化

Mono<String> msgMono = Mono.just(100)
        .map(score -> "当前分数为:" + score);

4. 简单计算

Mono<Integer> result = Mono.just(5)
        .map(num -> num * 2);

这些场景有一个共同点:转换函数返回的是普通值,而不是新的 Mono 或 Flux。


flatMap:异步展开转换

flatMap 也是“转换”,但它和 map 的根本区别在于:

flatMap 用于把一个元素转换成另一个响应式类型,然后自动拍平。

也就是说,flatMap 适用于这样的函数:

  • 输入:T
  • 输出:Mono<R>Flux<R>

例如:

Mono<UserDTO> result = userRepository.findById(1L)
        .flatMap(user -> orderService.getLatestOrder(user.getId())
                .map(order -> new UserDTO(user.getId(), user.getName(), order.getOrderNo())));

这里为什么不能只用 map

因为 orderService.getLatestOrder(user.getId()) 返回的是 Mono<Order>,它本身就是一个异步流。如果你写成 map

Mono<Mono<Order>> wrong = userRepository.findById(1L)
        .map(user -> orderService.getLatestOrder(user.getId()));

得到的就会是 Mono<Mono<Order>>。这通常不是你想要的结果。

flatMap 的价值就在这里: 它会把嵌套的响应式结构自动展开成一层。


一句话区分 map 和 flatMap

可以直接记这个判断标准:

  • 返回普通对象,用 map
  • 返回 MonoFlux,用 flatMap

再直白一点:

  • map:值变值
  • flatMap:值变流,再把流摊平

map 和 flatMap 的对比例子

使用 map

Mono<String> result = Mono.just("spring")
        .map(s -> s + " webflux");

最终类型:

Mono<String>

使用 flatMap

Mono<String> result = Mono.just("spring")
        .flatMap(s -> Mono.just(s + " webflux"));

最终类型仍然是:

Mono<String>

错误使用 map 导致嵌套

Mono<Mono<String>> result = Mono.just("spring")
        .map(s -> Mono.just(s + " webflux"));

最终类型变成了:

Mono<Mono<String>>

这类嵌套类型在业务中几乎总是错误信号。


实战里什么时候必须用 flatMap

场景一:查完用户再查订单

public Mono<OrderDTO> getUserLatestOrder(Long userId) {
    return userRepository.findById(userId)
            .flatMap(user -> orderRepository.findLatestByUserId(user.getId())
                    .map(order -> new OrderDTO(user.getName(), order.getOrderNo(), order.getAmount())));
}

原因很明确:第二步 findLatestByUserId 是异步查询,返回 Mono<Order>


场景二:先校验,再保存

public Mono<User> createUser(User user) {
    return userRepository.findByEmail(user.getEmail())
            .flatMap(existing -> Mono.<User>error(new IllegalArgumentException("邮箱已存在")))
            .switchIfEmpty(userRepository.save(user));
}

这里虽然核心逻辑在 switchIfEmpty,但前面的分支返回的是 Mono.error(...),本质上也是响应式返回值。


场景三:调用远程服务

public Mono<ProductDetailVO> getProductDetail(Long productId) {
    return productRepository.findById(productId)
            .flatMap(product -> inventoryClient.getStock(product.getId())
                    .map(stock -> new ProductDetailVO(product.getId(), product.getName(), stock)));
}

远程调用通常是异步的,因此这里必须用 flatMap


map 能不能替代 flatMap

不能。

更准确地说,在返回响应式对象的场景里,map 语义不对,结果类型也不对。

错误示例:

Mono<Mono<User>> result = Mono.just(1L)
        .map(userRepository::findById);

正确写法:

Mono<User> result = Mono.just(1L)
        .flatMap(userRepository::findById);

flatMap 能不能替代 map

技术上很多场景可以写,但通常不推荐。

比如下面本该用 map

Mono<String> result = Mono.just("java")
        .map(String::toUpperCase);

你硬要写成 flatMap 也能跑:

Mono<String> result = Mono.just("java")
        .flatMap(s -> Mono.just(s.toUpperCase()));

但这样做的问题是:

  1. 代码更啰嗦
  2. 语义不清晰
  3. 本来是同步转换,却伪装成异步流程

所以规范做法仍然是: 同步转换用 map,异步转换用 flatMap


Mono 为什么比你想的更重要

很多人把 Mono 理解成“单个值容器”,这太浅了。Mono 在 WebFlux 里承担的是完整的响应式语义:

  • 可能有值
  • 可能没有值
  • 可能完成
  • 可能失败

所以 Mono 不只是“装一个对象”,而是表示一次异步操作的完整生命周期。

例如:

Mono<User> mono1 = Mono.just(new User());
Mono<User> mono2 = Mono.empty();
Mono<User> mono3 = Mono.error(new RuntimeException("查询失败"));

它们的区别不是对象内容,而是信号类型不同:

  • just:发出一个值,然后完成
  • empty:不发值,直接完成
  • error:直接发送错误信号

理解这一点,后面很多方法才不会用错。


Mono 最常用的创建方法

Mono.just

用于包装一个确定存在的非空值。

Mono<String> mono = Mono.just("hello");

注意:Mono.just(null) 会抛异常,因为它不允许包装 null


Mono.justOrEmpty

用于包装一个可能为 null 的值。

String name = getNameFromCache();
Mono<String> mono = Mono.justOrEmpty(name);

如果 namenull,就会得到 Mono.empty()

这是处理老代码、缓存接口、传统同步方法时非常实用的入口。


Mono.empty

表示没有数据,但流程正常结束。

Mono<User> mono = Mono.empty();

这个状态和报错不同。它代表的是“没查到”“无需返回”“正常无结果”。


Mono.error

用于构造失败信号。

Mono<String> mono = Mono.error(new IllegalStateException("状态异常"));

适合在参数校验失败、业务规则不满足时中断响应式链路。


Mono.fromCallable

用于把可能抛异常的同步逻辑包装成 Mono

Mono<String> mono = Mono.fromCallable(() -> {
    return "result-" + System.currentTimeMillis();
});

这比直接 Mono.just(method()) 更安全。因为:

  • Mono.just(method()) 会在创建时立即执行
  • Mono.fromCallable(() -> method()) 会延迟到订阅时执行,并且能把异常转成响应式错误信号

这一点在桥接旧系统代码时非常重要。


Mono 常用转换方法

map

前面已经讲过,用于同步转换。

Mono<Integer> result = Mono.just("123")
        .map(Integer::parseInt);

flatMap

用于异步转换。

Mono<UserDTO> result = userRepository.findById(1L)
        .flatMap(user -> addressRepository.findByUserId(user.getId())
                .map(address -> new UserDTO(user.getName(), address.getCity())));

cast

用于类型转换。

Mono<Object> source = Mono.just("hello");
Mono<String> result = source.cast(String.class);

如果类型不匹配,会抛出 ClassCastException


Mono 常用过滤方法

filter

满足条件才继续向下游传递,不满足则变成 Mono.empty()

Mono<User> result = userRepository.findById(1L)
        .filter(user -> user.getAge() >= 18);

如果用户年龄小于 18,结果就不是报错,而是空。

这意味着后面通常要配合 switchIfEmpty 使用。


ofType

按类型过滤并转换。

Mono<Object> source = Mono.just("hello");
Mono<String> result = source.ofType(String.class);

只有元素是 String 时才会通过。


Mono 常用空值处理方法

defaultIfEmpty

当上游为空时,给一个默认值。

Mono<String> result = Mono.<String>empty()
        .defaultIfEmpty("默认值");

适合简单兜底。


switchIfEmpty

当上游为空时,切换到另一个 Mono

Mono<User> result = cacheService.getUserFromCache(1L)
        .switchIfEmpty(userRepository.findById(1L));

这是 WebFlux 里非常高频的方法,尤其适合:

  • 先查缓存,查不到再查数据库
  • 先查本地,查不到再调远程
  • 没数据时走另一套逻辑

defaultIfEmptyswitchIfEmpty 的区别很清楚:

  • defaultIfEmpty:补一个普通值
  • switchIfEmpty:切换到另一个响应式流程

Mono 常用错误处理方法

onErrorReturn

发生错误时直接返回默认值。

Mono<String> result = Mono.<String>error(new RuntimeException("fail"))
        .onErrorReturn("fallback");

适合错误兜底逻辑很简单的场景。


onErrorResume

发生错误时切换到新的响应式流程。

Mono<User> result = userRepository.findById(1L)
        .onErrorResume(ex -> {
            log.error("查询失败", ex);
            return Mono.empty();
        });

这是实际开发里最常用的错误恢复方式,因为它比 onErrorReturn 更灵活。

例如按异常类型处理:

Mono<User> result = userRepository.findById(1L)
        .onErrorResume(TimeoutException.class, ex -> cacheService.getBackupUser(1L));

onErrorMap

把一种异常转换成另一种异常。

Mono<User> result = userRepository.findById(1L)
        .onErrorMap(ex -> new BusinessException("用户查询失败", ex));

适合统一异常语义,而不是吞掉错误。


Mono 常用副作用方法

这类方法不改变主数据,只是在特定阶段做一些附加动作,比如日志记录、监控、埋点。

doOnNext

拿到值时执行副作用逻辑。

Mono<User> result = userRepository.findById(1L)
        .doOnNext(user -> log.info("查询到用户:{}", user.getName()));

doOnSuccess

成功结束时触发。即使是空 Mono,也可能触发。

Mono<User> result = userRepository.findById(1L)
        .doOnSuccess(user -> log.info("处理完成:{}", user));

这里要注意:如果 Mono 为空,user 可能是 null。所以不要把它当成 doOnNext 的等价替代。


doOnError

出现错误时执行副作用逻辑。

Mono<User> result = userRepository.findById(1L)
        .doOnError(ex -> log.error("查询失败", ex));

doFinally

无论成功、失败还是取消,最终都会执行。

Mono<User> result = userRepository.findById(1L)
        .doFinally(signalType -> log.info("结束信号:{}", signalType));

适合做清理、统计、统一收尾。


Mono 常用终止与衔接方法

then

忽略上游结果,只关心完成信号,然后继续下一个流程。

Mono<Void> result = userRepository.deleteById(1L)
        .then();

或者:

Mono<String> result = userRepository.deleteById(1L)
        .then(Mono.just("删除成功"));

这个方法经常出现在“前一个操作只负责执行,后一个操作才需要返回值”的场景中。


thenReturn

上游完成后直接返回固定值。

Mono<String> result = userRepository.deleteById(1L)
        .thenReturn("OK");

它本质上是 then(Mono.just(value)) 的简化写法。


thenEmpty

上游完成后,再执行另一个 Publisher,最终返回空结果。

Mono<Void> result = userRepository.save(user)
        .thenEmpty(auditService.record("user_created"));

适合串联两个只关心完成、不关心值的流程。


Mono 常用组合方法

zipWith

把两个 Mono 的结果组合起来。

Mono<UserOrderVO> result = userRepository.findById(1L)
        .zipWith(orderRepository.findLatestByUserId(1L))
        .map(tuple -> new UserOrderVO(
                tuple.getT1().getName(),
                tuple.getT2().getOrderNo()
        ));

这里会等待两个 Mono 都完成,然后再组合结果。


zip

如果需要组合多个 Mono,可以用 Mono.zip(...)

Mono<UserProfileVO> result = Mono.zip(
        userRepository.findById(1L),
        addressRepository.findByUserId(1L),
        orderRepository.findLatestByUserId(1L)
).map(tuple -> new UserProfileVO(
        tuple.getT1().getName(),
        tuple.getT2().getCity(),
        tuple.getT3().getOrderNo()
));

这类写法非常适合聚合接口,比如用户首页、详情页、控制台总览数据。


Mono 常用判断与获取方法

hasElement

判断是否有元素。

Mono<Boolean> result = userRepository.findById(1L)
        .hasElement();

适合判断“数据是否存在”,而不是取具体值。


block

同步等待结果。

User user = userRepository.findById(1L).block();

这个方法存在,但在 WebFlux 应用里通常不建议在业务链路中使用。因为它会把异步流程重新阻塞化,破坏响应式模型。

block() 更适合:

  • 单元测试
  • 启动初始化中的特殊场景
  • 非响应式边界代码

在 WebFlux Controller、Service 主链路里,不应把它当常规手段。


一个完整示例:把常用操作符串起来

下面用一个稍微接近真实业务的例子,展示 mapflatMapswitchIfEmptyonErrorResumethenReturn 的组合方式。

需求

根据用户 ID 查询用户,如果用户不存在则报错; 再查询该用户最近订单; 如果订单不存在,返回默认订单说明; 如果出现远程异常,则做兜底处理。

代码

public Mono<UserOrderResponse> getUserLatestOrder(Long userId) {
    return userRepository.findById(userId)
            .switchIfEmpty(Mono.error(new IllegalArgumentException("用户不存在")))
            .flatMap(user -> orderRepository.findLatestByUserId(user.getId())
                    .map(order -> new UserOrderResponse(
                            user.getId(),
                            user.getName(),
                            order.getOrderNo(),
                            order.getAmount(),
                            false
                    ))
                    .switchIfEmpty(Mono.just(new UserOrderResponse(
                            user.getId(),
                            user.getName(),
                            "NO_ORDER",
                            BigDecimal.ZERO,
                            true
                    )))
            )
            .onErrorResume(TimeoutException.class, ex -> Mono.just(
                    new UserOrderResponse(userId, "unknown", "TIMEOUT", BigDecimal.ZERO, true)
            ));
}

这个链路里各操作符分别做了什么

  • findById:查询用户,得到 Mono<User>
  • switchIfEmpty:用户不存在时转为错误信号
  • flatMap:因为后面还要继续异步查询订单
  • 内层 map:把 Order 转成响应对象
  • 内层 switchIfEmpty:订单不存在时返回默认响应
  • onErrorResume:指定异常发生时兜底

这个例子基本覆盖了 WebFlux 日常业务里的典型写法。


在 Controller 中怎么落地这些操作符

Spring WebFlux Controller 通常直接返回 MonoFlux

@RestController
@RequestMapping("/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping("/{id}")
    public Mono<UserDTO> getUser(@PathVariable Long id) {
        return userService.getUserById(id)
                .map(user -> new UserDTO(user.getId(), user.getName(), user.getEmail()));
    }
}

如果 Service 已经返回 Mono<User>,Controller 层只做 DTO 转换,用 map 就够了。

如果 Controller 里还要继续调别的异步服务,就要用 flatMap

@GetMapping("/{id}/detail")
public Mono<UserDetailVO> getUserDetail(@PathVariable Long id) {
    return userService.getUserById(id)
            .flatMap(user -> profileService.getProfile(user.getId())
                    .map(profile -> new UserDetailVO(user.getId(), user.getName(), profile.getAvatar())));
}

初学者最容易犯的几个错误

1. 把 map 当成所有转换的万能方法

最常见表现就是写出 Mono<Mono<T>>

错误示例:

Mono<Mono<User>> result = Mono.just(1L)
        .map(userService::getUserById);

正确写法:

Mono<User> result = Mono.just(1L)
        .flatMap(userService::getUserById);

2. 对空结果和异常不做区分

Mono.empty()Mono.error() 语义完全不同:

  • empty:没有数据,但流程正常
  • error:流程失败

例如“用户不存在”到底该返回空,还是抛业务异常,要在业务层明确设计,而不是混着写。


3. 在响应式链里随意 block

public Mono<UserDTO> getUser(Long id) {
    User user = userRepository.findById(id).block();
    return Mono.just(new UserDTO(user.getId(), user.getName()));
}

这类代码虽然“能跑”,但已经把响应式优势破坏掉了。

正确思路应该是继续沿着链路往下写:

public Mono<UserDTO> getUser(Long id) {
    return userRepository.findById(id)
            .map(user -> new UserDTO(user.getId(), user.getName()));
}

4. 滥用 doOnNext 执行业务逻辑

doOnNext 设计目的不是做主业务处理,而是做副作用。

错误思路:

Mono<User> result = userRepository.findById(1L)
        .doOnNext(user -> user.setName("newName"));

这会让逻辑边界变得混乱。 数据转换应该放在 mapflatMap 中,doOnNext 更适合日志、监控、审计。


5. 看见 flatMap 就一直套 flatMap

有些代码每一步都写成 flatMap,即使只是普通字段转换,也不例外。这会让代码变得冗余且难读。

例如:

Mono<String> result = userRepository.findById(1L)
        .flatMap(user -> Mono.just(user.getName()))
        .flatMap(name -> Mono.just(name.toUpperCase()));

更合理的写法应该是:

Mono<String> result = userRepository.findById(1L)
        .map(User::getName)
        .map(String::toUpperCase);

map 的地方就用 map,不要把所有东西都异步化。


一套实用判断规则

写 WebFlux 链路时,可以按下面这套顺序判断:

第一步:当前是普通值转换,还是新的异步流程

  • 普通值转换:map
  • 新的 Mono/FluxflatMap

第二步:上游可能为空吗

  • 给默认值:defaultIfEmpty
  • 切换新流程:switchIfEmpty

第三步:失败后要怎么处理

  • 固定兜底值:onErrorReturn
  • 动态恢复流程:onErrorResume
  • 统一异常语义:onErrorMap

第四步:是否只关心完成,不关心上游结果

  • 忽略结果继续:then
  • 返回固定值:thenReturn

这套判断规则足够覆盖大部分 Controller 和 Service 场景。


Spring Framework 5 与 6 下这些操作符有没有本质区别

如果你关注的是本文讲的这些核心操作符:mapflatMapswitchIfEmptydefaultIfEmptythenzipWithonErrorResume 等,在 Spring Framework 5 / 6 对应的 WebFlux 开发中,核心语义没有本质变化

需要明确的是:

  • 这些操作符主要来自 Project Reactor
  • Spring WebFlux 只是基于 Reactor 构建响应式 Web 编程模型
  • 因此这些操作符的核心行为,在常见版本中是一致的

真正需要关注的版本差异,更多在这些方面:

  • Spring 5 常见于 Spring Boot 2.x 体系
  • Spring 6 常见于 Spring Boot 3.x 体系
  • Spring 6 / Boot 3 迁移时涉及 Jakarta 命名空间变化
  • JDK 基线要求提升
  • 部分周边 API、配置方式、兼容性要求变化

但就本文讨论的 Mono 常用方法和 map / flatMap 的使用逻辑来说,学习方式和判断标准可以直接延续


结论

Spring WebFlux 的操作符很多,但真正最常用、最需要先吃透的,就是 mapflatMap 和一批围绕 Mono 的核心方法。

可以把整篇文章压缩成四条最重要的结论:

  1. map 处理同步一对一转换
  2. flatMap 处理返回 Mono/Flux 的异步转换
  3. Mono 表示的不只是一个值,而是一整套异步信号语义
  4. 空值处理、错误恢复、流程衔接,才是 WebFlux 业务代码可读性的关键

真正写 WebFlux,不是把代码改成链式调用,而是明确每一步到底属于:

  • 值转换
  • 异步展开
  • 空结果兜底
  • 异常恢复
  • 流程衔接
  • 多源组合

当你把这些边界分清楚之后,WebFlux 代码就不会再显得“玄学”,而会变得非常有规律。

正文到此结束
评论插件初始化中...
Loading...