浅谈响应式编程及Spring WebClient(1/2)

一、引言

在处理高并发 HTTP 调用时,传统的同步阻塞式 HTTP 客户端(如 RestTemplate)往往依赖多线程来提升并发性能:一个线程对应一条连接,请求发出后线程阻塞等待响应。尽管可以通过增加线程数来提高并发量,但这通常会带来上下文切换开销巨大、内存占用的大幅上升,严重时甚至可能导致 OOM(内存溢出)问题。

这个问题本质上与我们在学习 Java BIO 和 NIO 时遇到的阻塞模型痛点如出一辙。那么,是否存在一个成熟的、基于非阻塞模型的 HTTP 客户端,用以更高效地应对高并发场景呢?

答案是肯定的 ------ Spring 提供了 WebClient,一个基于响应式编程模型、构建于 Project Reactor 之上的非阻塞 HTTP 客户端,作为 RestTemplate 的现代替代者,已经在众多实际项目中得到了验证和广泛应用。

本文将从响应式编程模型的基本概念出发,介绍Reactive Streams规范、Project Reactor 的核心机制。

下一篇文章讲介绍其在 WebClient 中的应用;再深入讲解 WebClient 的基本用法与核心特性,并通过对比 WebClientRestTemplate 在压力测试下的性能表现,直观感受 WebClient 在高并发场景下的优势。

二、响应式编程

WebClient 是基于 Project Reactor 实现的,而 Project Reactor 又是建立在响应式编程模型之上的。因此,在深入理解 WebClient 之前,我们有必要先了解什么是响应式编程。

根据维基百科的定义,响应式编程(Reactive Programming)是一种声明式编程范式,关注数据流及其变化的传播:

"响应式编程是一种基于异步数据流和变化传播的声明式编程范式。借助这一范式,程序可以更自然地表达静态(如数组)或动态(如事件源)的数据流,并通过执行模型中推断出的依赖关系,实现数据变化的自动传播。"

通俗地讲,响应式编程可以理解为一种"事件驱动 + 数据流处理"的编程范式。程序不再显式地控制流程,而是通过"响应"外部事件或数据的变化来驱动计算流程。例如,当某个数据源产生新的数据时,程序会自动触发相关的处理逻辑,无需人为干预。

这一模型非常适合处理高并发、异步、IO 密集型的场景,因为它避免了线程阻塞,能够以更少的资源处理更多的任务。

响应式编程的核心特性

响应式编程具有以下几个显著特性:

  • 异步非阻塞:数据的产生与消费彼此独立,通过回调或事件通知机制解耦,从而避免线程阻塞。
  • 数据流驱动:程序中的数据以流的形式存在,开发者通过操作流(如 mapfilterflatMap 等)来定义处理逻辑。
  • 背压机制(Backpressure):在数据生产者与消费者速率不匹配时,背压机制可以有效协调两者之间的节奏,避免内存溢出或消息堆积。
  • 声明式编程风格:开发者更多地关注"做什么"而不是"怎么做",代码更简洁、可读性更强。

三、Reactive Streams规范

为了统一响应式编程中的数据流处理标准,在 2013 年,Lightbend(当时是 Typesafe)、Netflix、Pivotal、Red Hat 等公司联合提出了 Reactive Streams 规范,其核心目标是为异步流处理建立一个 非阻塞、具备背压机制的通用标准。

Reactive Streams 的核心组成

Reactive Streams 定义了四个核心接口(Java 9 之后被正式纳入 JDK 标准包 java.util.concurrent.Flow):

接口 说明
Publisher 表示一个可以发布数据的源(生产者),它可以发出多个元素或信号(包括错误或完成)。
Subscriber 表示数据的消费者,用于接收来自 Publisher 的数据流。
Subscription 连接 Publisher 和 Subscriber 的桥梁,允许 Subscriber 向 Publisher 请求数据(背压控制)。
Processor<T, R> 既是 Publisher 又是 Subscriber,用于数据的中间处理。

基本的交互流程如下:

  1. SubscriberPublisher 订阅。
  2. Publisher 返回一个 SubscriptionSubscriber
  3. Subscriber 通过 Subscription.request(n) 请求数据。
  4. Publisher 根据请求量 n 发送最多 n 个数据项。
  5. 若过程中发生异常,Publisher 会调用 onError;正常结束时调用 onComplete

这种 请求-响应(pull-based)+ 事件驱动(push-based) 的混合模型,是 Reactive Streams 背压机制的关键所在。

接口定义

可能说概念还是比较模糊,直接看Reactive Streams是怎么定义这几个接口的

2.1 Publiser(发布者)

从接口定义及注释信息,可以知道:

  1. Publisher提供了subscribe方法,通过该方法可以将Subscriber传入
  2. 调用subscribe方法之后,就意味着订阅开始,Publisher就向Subscriber开始数据传输了
  3. subscribe可以被多次调用,也就是说一个Publisher可以被多个Subscriber订阅。每次订阅就会产生一个Subscription
  4. Publisher不能被同一个Subscriber订阅多次
java 复制代码
package org.reactivestreams;

/**
 * A {@link Publisher} is a provider of a potentially unbounded number of sequenced elements, publishing them according to
 * the demand received from its {@link Subscriber}(s).
 * <p>
 * A {@link Publisher} can serve multiple {@link Subscriber}s subscribed {@link Publisher#subscribe(Subscriber)} dynamically
 * at various points in time.
 *
 * @param <T> the type of element signaled
 */
public interface Publisher<T> {

    /**
     * Request {@link Publisher} to start streaming data.
     * <p>
     * This is a "factory method" and can be called multiple times, each time starting a new {@link Subscription}.
     * <p>
     * Each {@link Subscription} will work for only a single {@link Subscriber}.
     * <p>
     * A {@link Subscriber} should only subscribe once to a single {@link Publisher}.
     * <p>
     * If the {@link Publisher} rejects the subscription attempt or otherwise fails it will
     * signal the error via {@link Subscriber#onError(Throwable)}.
     *
     * @param s the {@link Subscriber} that will consume signals from this {@link Publisher}
     */
    public void subscribe(Subscriber<? super T> s);
}

2.2 Subscriber(订阅者)

从Subscriber的接口定义,我们可以得出以下信息:

  1. Subscriber包含四个回调方法,Publisher会分别在不同状态下调用
  2. onSubscribe:当调用Publisher的subscribe方法之后,Publisher会将Subscription通过方法onSubscribe返还给Subscriber,类似于一个回执的东西。
  3. onNext:通过该方法,Publisher向Subscriber发送单个数据
  4. onError:当处理出现异常之后,Publisher会调用该方法,并且流就此中断,不会继续了。
  5. onComplete:当成功结束了,Publisher会调用该方法。
  6. 背压:Subscriber需要根据自己的处理情况,通过Subscription.request向Publisher索取数据
java 复制代码
package org.reactivestreams;

/**
 * Will receive call to {@link #onSubscribe(Subscription)} once after passing an instance of {@link Subscriber} to {@link Publisher#subscribe(Subscriber)}.
 * <p>
 * No further notifications will be received until {@link Subscription#request(long)} is called.
 * <p>
 * After signaling demand:
 * <ul>
 * <li>One or more invocations of {@link #onNext(Object)} up to the maximum number defined by {@link Subscription#request(long)}</li>
 * <li>Single invocation of {@link #onError(Throwable)} or {@link Subscriber#onComplete()} which signals a terminal state after which no further events will be sent.
 * </ul>
 * <p>
 * Demand can be signaled via {@link Subscription#request(long)} whenever the {@link Subscriber} instance is capable of handling more.
 *
 * @param <T> the type of element signaled
 */
public interface Subscriber<T> {

    /**
     * Invoked after calling {@link Publisher#subscribe(Subscriber)}.
     * <p>
     * No data will start flowing until {@link Subscription#request(long)} is invoked.
     * <p>
     * It is the responsibility of this {@link Subscriber} instance to call {@link Subscription#request(long)} whenever more data is wanted.
     * <p>
     * The {@link Publisher} will send notifications only in response to {@link Subscription#request(long)}.
     * 
     * @param s the {@link Subscription} that allows requesting data via {@link Subscription#request(long)}
     */
    public void onSubscribe(Subscription s);

    /**
     * Data notification sent by the {@link Publisher} in response to requests to {@link Subscription#request(long)}.
     * 
     * @param t the element signaled
     */
    public void onNext(T t);

    /**
     * Failed terminal state.
     * <p>
     * No further events will be sent even if {@link Subscription#request(long)} is invoked again.
     *
     * @param t the throwable signaled
     */
    public void onError(Throwable t);

    /**
     * Successful terminal state.
     * <p>
     * No further events will be sent even if {@link Subscription#request(long)} is invoked again.
     */
    public void onComplete();
}

2.3 Subscription(订阅)

从Subscription的接口定义,我们可以得出以下信息:

  1. Subscription代表了订阅整个生命周期,与Subscriber是一对一的关系
  2. Subscription是用于Subscriber向Publisher发送信号的
  3. 通过request方法,Subscriber可以向Publisher请求指定数量的数据
  4. 通过cancel方法,Subscriber可以向Publisher停止发送数据并且清理资源
java 复制代码
package org.reactivestreams;

/**
 * A {@link Subscription} represents a one-to-one lifecycle of a {@link Subscriber} subscribing to a {@link Publisher}.
 * <p>
 * It can only be used once by a single {@link Subscriber}.
 * <p>
 * It is used to both signal desire for data and cancel demand (and allow resource cleanup).
 */
public interface Subscription {

    /**
     * No events will be sent by a {@link Publisher} until demand is signaled via this method.
     * <p>
     *  It can be called however often and whenever needed---but if the outstanding cumulative demand ever becomes Long.MAX_VALUE or more,
     *  it may be treated by the {@link Publisher} as "effectively unbounded".
     * <p>
     * Whatever has been requested can be sent by the {@link Publisher} so only signal demand for what can be safely handled.
     * <p>
     * A {@link Publisher} can send less than is requested if the stream ends but
     * then must emit either {@link Subscriber#onError(Throwable)} or {@link Subscriber#onComplete()}.
     * 
     * @param n the strictly positive number of elements to requests to the upstream {@link Publisher}
     */
    public void request(long n);

    /**
     * Request the {@link Publisher} to stop sending data and clean up resources.
     * <p>
     * Data may still be sent to meet previously signalled demand after calling cancel.
     */
    public void cancel();
}

四、Project Reactor 基础与核心用法

Project Reactor 就是基于响应式编程特性构建的响应式库,它实现了 Reactive Streams 规范,提供了两个核心类型:Mono(表示 0 或 1 个元素)和 Flux(表示 0 到 N 个元素)。这两个类型构成了响应式编程的基础抽象,贯穿整个 WebClient 的使用过程。

接下来,我们将介绍 Project Reactor 的核心机制,并通过一些简单示例来帮助理解 Mono 和 Flux 是如何工作的。

1. Mono/Flux

1.1 概述

Mono和Flux是Publisher的两个实现,主要用于生产数据序列,下面将介绍这两者。

  • 作用:数据流的源头,负责生产数据。
  • 核心实现:
    • Flux<T>:发射 0-N个元素 的流(例如集合、流式数据)。
    • Mono<T>:发射 0或1个元素 的流(例如异步任务、Optional结果)。
  • 示例:
java 复制代码
Flux<String> flux = Flux.just("A", "B", "C"); // 多元素
Mono<String> mono = Mono.just("Hello");        // 单元素

1.2 创建方式

1.2.1 Flux 创建方式
Flux.just

用于创建一个包含固定元素的 Flux。

java 复制代码
Flux<String> flux = Flux.just("A", "B", "C");

这个方法适合在已知数据元素的情况下快速创建一个数据流。元素按顺序发出,然后完成。

Flux.fromArray

从数组创建 Flux。

java 复制代码
Flux<String> flux = Flux.fromArray(new String[]{"A", "B"});

常用于已有数组数据的场景,将其转换为响应式流进行处理。

Flux.fromIterable

ListSet 等集合类型创建 Flux。

java 复制代码
Flux<String> flux = Flux.fromIterable(Arrays.asList("A", "B"));

适合处理 Java 集合类,将其转化为流式处理形式。

Flux.fromStream

从 Java 8 Stream 创建 Flux。

java 复制代码
Flux<String> flux = Flux.fromStream(Stream.of("A", "B"));

注意:Stream 只能消费一次,不能重复订阅该 Flux。

Flux.range

生成一个整数范围内的序列。

java 复制代码
Flux<Integer> flux = Flux.range(1, 5); // 输出 1 到 5

适用于需要生成定长数字序列的场景,常用于测试或批量操作。

Flux.interval

以固定时间间隔生成递增数字(异步)。

java 复制代码
Flux<Long> flux = Flux.interval(Duration.ofSeconds(1));

每隔一秒发出一个值,适用于定时任务、轮询等场景。

Flux.empty

创建一个空的 Flux,订阅后立即完成。

java 复制代码
Flux<Object> flux = Flux.empty();

可以用于占位或无数据时的默认返回。

Flux.error

创建一个立即以错误终止的 Flux。

java 复制代码
Flux<Object> flux = Flux.error(new RuntimeException("Something went wrong"));

常用于异常测试或显式地向下游传递错误信号。

Flux.never

创建一个永不发送数据和完成信号的 Flux。

java 复制代码
Flux<Object> flux = Flux.never();

主要用于测试或特定逻辑阻塞场景。

Flux.generates

通过同步状态生成数据,按需发出元素。

java 复制代码
Flux<Integer> flux = Flux.generate(() -> 0, (state, sink) -> {
    sink.next(state);
    return state + 1;
});

适合基于状态的单线程数据生成流程。

Flux.create

支持异步、回调式数据流生成,同时支持背压。

java 复制代码
Flux<String> flux = Flux.create(sink -> {
    asyncEventSource.register(e -> sink.next(e));
});

适用于需要手动推送数据的异步场景,如事件监听。

Flux.concat

连接多个 Publisher,顺序发出各序列中的元素。

java 复制代码
Flux<String> flux = Flux.concat(Flux.just("A"), Flux.just("B"));

适用于按顺序合并多个流。

Flux.merge

合并多个异步 Publisher,元素可能交错。

java 复制代码
Flux<Long> flux = Flux.merge(
    Flux.interval(Duration.ofMillis(100)),
    Flux.interval(Duration.ofMillis(150))
);

适合多个异步源并发发出数据的场景。

Flux.defer

延迟创建 Flux,每次订阅时生成一个新的序列。

java 复制代码
Flux<Long> flux = Flux.defer(() -> Flux.just(System.currentTimeMillis()));

用于每次订阅都需要最新数据的场景。

1.2.2 Mono 创建方式
Mono.just

创建一个包含单个元素的 Mono。

java 复制代码
Mono<String> mono = Mono.just("A");

用于返回单一结果的场景,如数据库单条查询。

Mono.justOrEmpty

根据输入是否为 null 创建 Mono,null 会变成 Mono.empty()。

java 复制代码
Mono<String> mono = Mono.justOrEmpty(null);

安全地处理可能为 null 的值。

Mono.fromCallable

Callable 延迟生成 Mono,自动捕获异常。

java 复制代码
Mono<String> mono = Mono.fromCallable(() -> "Result");

适用于需要延迟执行、且可能抛出异常的操作。

Mono.fromSupplier

Supplier 延迟生成 Mono,不捕获异常。

java 复制代码
Mono<String> mono = Mono.fromSupplier(() -> "Result");

类似 fromCallable,但不处理异常,适合无异常风险的延迟执行。

Mono.fromFuture

CompletableFuture 转为 Mono。

java 复制代码
Mono<String> mono = Mono.fromFuture(CompletableFuture.completedFuture("Result"));

常用于与异步方法或非响应式 API 集成。

Mono.fromRunnable

执行 Runnable 后返回空 Mono。

java 复制代码
Mono<Void> mono = Mono.fromRunnable(() -> System.out.println("Done"));

用于触发副作用动作(如日志、清理)但不返回数据。

Mono.empty()

创建一个空的 Mono,立即完成。

java 复制代码
Mono<Object> mono = Mono.empty();

适用于无返回值的场景,如删除操作等。

Mono.error

创建一个立即错误终止的 Mono。

java 复制代码
Mono<Object> mono = Mono.error(new RuntimeException("Error occurred"));

用于错误流转,或测试异常处理。

Mono.never

创建一个永不发出任何信号的 Mono。

java 复制代码
Mono<Object> mono = Mono.never();

适合测试超时等场景。

Mono.create

通过回调方式手动生成 Mono,适用于异步或自定义场景。

java 复制代码
Mono<String> mono = Mono.create(sink -> {
    asyncTask(result -> {
        if (success) sink.success(result);
        else sink.error(e);
    });
});

可以精细控制完成、失败等状态。

Mono.defer

每次订阅时重新生成 Mono,确保数据时效性。

java 复制代码
Mono<Long> mono = Mono.defer(() -> Mono.just(System.currentTimeMillis()));

适用于动态计算或需要实时数据的情况。

Mono.zip

组合多个 Mono,等待所有 Mono 完成后合并结果。

java 复制代码
Mono<String> mono = Mono.zip(monoA, monoB, (a, b) -> a + b);

用于聚合多个异步结果。

Mono.when

等待多个 Mono 完成,但忽略返回值。

java 复制代码
Mono<Void> mono = Mono.when(monoA, monoB);

常用于多个任务同步执行的场景。

1.3 关键区别说明

  1. Flux:
    • 适用于 0..N 个元素的异步序列。
    • 支持复杂流操作(过滤/转换/背压等)。
    • 生成方式更侧重序列的连续性(如 range/interval)。
  2. Mono:
    • 适用于 0..1 个元素的异步结果。
    • 常用于单次异步操作(如 HTTP 请求、Future 封装)。
    • 延迟创建(fromSupplier/defer)避免立即执行逻辑。

1.4 使用场景

  • Flux:流式数据处理(文件读取、消息队列消费)。
  • Mono:单值异步操作(数据库查询、结果聚合)。

2. 订阅者

2.1 概述

在 Project Reactor 中,订阅者是响应式流中处理数据的核心组件,负责接收和处理 Publisher(Mono/Flux)发出的数据流。

2.2 订阅方式

下面将介绍订阅者的几种实现方式

2.1 基础订阅(Lambda 表达式)
2.1.1 最简单的 subscribe(什么都不处理)

最简单的方式,只触发流,不处理任何数据或错误

方法为:

java 复制代码
public final Disposable subscribe()

出参:Disposable可用于取消订阅、判断是否已取消订阅

示例:

java 复制代码
Flux.just(1, 2, 3).subscribe();
2.1.2 只处理 onNext

只处理 onNext,不处理错误或完成

方法为:

java 复制代码
public final Disposable subscribe(Consumer<? super T> consumer)

入参:consumer用于处理onNext,也就是消费每一个数据

出参:Disposable可用于取消订阅、判断是否已取消订阅

示例:

java 复制代码
Flux.just(1, 2, 3).subscribe(System.out::println);
2.1.3 处理 onNext 和 onError

方法为:

java 复制代码
public final Disposable subscribe(@Nullable Consumer<? super T> consumer, Consumer<? super Throwable> errorConsumer)

入参:consumer用于处理onNext,也就是消费每一个数据;errorConsumer用于处理异常信号

出参:Disposable可用于取消订阅、判断是否已取消订阅

示例:

java 复制代码
Flux.just(1, 2, 3)
    .map(i -> 10 / (i - 2))
    .subscribe(
        System.out::println,
        error -> System.err.println("Error: " + error)
    );
2.1.4 处理 onNext、onError 和 onComplete

方法为:

java 复制代码
public final Disposable subscribe(
			@Nullable Consumer<? super T> consumer,
			@Nullable Consumer<? super Throwable> errorConsumer,
			@Nullable Runnable completeConsumer)

入参:consumer用于处理onNext,也就是消费每一个数据;errorConsumer用于处理异常信号;completeConsumer用于处理完成信号

出参:Disposable可用于取消订阅、判断是否已取消订阅

示例:

java 复制代码
Flux.just(1, 2, 3).subscribe(
    System.out::println,
    System.err::println,
    () -> System.out.println("Stream completed.")
);
2.2 自定义订阅者

如果想完全控制流的行为,BaseSubscriber 是一个非常强大的选择。通过继承 BaseSubscriber,可以在 hookOnSubscribe 中控制请求的数量,在 hookOnNext 中处理每个元素,并灵活响应背压。

示例:

java 复制代码
Flux.range(1, 10).subscribe(new BaseSubscriber<Integer>() {
    @Override
    protected void hookOnSubscribe(Subscription subscription) {
        System.out.println("Subscribed");
        request(2); // 控制每次请求数量
    }

    @Override
    protected void hookOnNext(Integer value) {
        System.out.println("Value: " + value);
        request(1); // 每处理一个再请求下一个
    }
});

BaseSubscriber包含以下钩子方法,可通过重写以下方法在指定状态下执行指定操作:

hookOnSubscribe

当订阅刚建立时调用。你可以在这里决定是否立即请求数据、请求多少数据,或保存 Subscription 以备后用。

用途:

  • 初始化请求(如 request(1)request(Long.MAX_VALUE)
  • 延迟请求
  • 打印日志

示例:

java 复制代码
@Override
protected void hookOnSubscribe(Subscription subscription) {
    System.out.println("订阅已建立");
    request(1); // 主动请求一个数据项
}
hookOnNext(T value)

每当有一个元素被成功发送下来时调用。你可以在这里处理元素,并决定是否继续请求下一个。

用途:

  • 消费数据
  • 动态调整请求数量(如手动调用 request(1)
  • 过滤或变换数据(一般不建议在这做变换,推荐用上游操作符)

示例:

java 复制代码
@Override
protected void hookOnNext(String value) {
    System.out.println("收到数据: " + value);
    request(1); // 每处理完一个,再请求下一个
}
hookOnComplete

当所有数据发送完成时调用(即流正常终止)。在这里你可以做一些清理或结束操作。

用途:

  • 日志记录
  • 释放资源
  • 通知业务方完成状态

示例:

java 复制代码
@Override
protected void hookOnComplete() {
    System.out.println("数据发送完毕,流结束");
}
hookOnError(Throwable throwable)

当流在处理过程中发生错误时调用。可以记录错误、发送告警等。

用途:

  • 错误日志
  • 尝试恢复(通常不在这里做)
  • 报警、通知

示例:

java 复制代码
@Override
protected void hookOnError(Throwable throwable) {
System.err.println("发生错误: " + throwable.getMessage());
}
hookOnCancel

当流被取消时调用(如调用了 cancel()dispose())。适合进行取消后的清理逻辑。

用途:

  • 释放资源
  • 中止连接或任务
  • 记录取消原因

示例:

java 复制代码
@Override
protected void hookOnCancel() {
    System.out.println("订阅被取消");
}
hookFinally

无论是正常结束、出错、取消,最终都会调用该方法,类似于 try-finally 中的 finally。

用途:

  • 最终清理逻辑
  • 统一处理所有终止状态(完成、出错、取消)
  • 日志记录

示例:

java 复制代码
@Override
protected void hookFinally(SignalType type) {
System.out.println("流终止,终止类型:" + type);
}

3. Subscription

当你订阅一个 FluxMono 时,底层就会建立一个 Subscription,并调用订阅者的 onSubscribe(Subscription s) 方法,将这个 Subscription 实例传递下去。

对于外部调用者来说,对Subscription的感知不强。它的作用一般体现在以下几个方面

3.1 订阅的使用
Disposable

调用发布者的subscribe方法之后,会返回一个Disposable。主要包含以下方法

  • dispose:通过Disposable可以对订阅进行取消
  • isDisposed:判断订阅是否已取消
java 复制代码
@FunctionalInterface
public interface Disposable {

	void dispose();

	default boolean isDisposed() {
		return false;
	}
BaseSubscriber

从上面看到BaseSubscriber的继承关系可以看出,该类实现了Subscription。因此,只要能拿到BaseSubscriber,就能拿到Subscription;BaseSubscriber内部也可以直接调用Subscription的request方法

java 复制代码
BaseSubscriber<Integer> baseSubscriber = Flux.range(0, 100)
        .delayElements(Duration.ofMillis(100))
        .subscribeWith(new BaseSubscriber<Integer>() {
            @Override
            protected void hookOnSubscribe(Subscription subscription) {
                log.info("subscribe");
                request(10); // 请求数据
            }

            @Override
            protected void hookOnNext(Integer value) {
                log.info("receive: {}", value);
                request(1);
            }

            @Override
            protected void hookOnCancel() {
                log.info("cancel");
            }
        });
TimeUnit.SECONDS.sleep(1);
baseSubscriber.cancel(); // 取消订阅

4. Operators(操作符)

在响应式编程中,操作符(Operator)是构建数据流的核心工具。它们负责在数据从上游流向下游的过程中,进行各种变换、过滤、合并、拆分、分组、切换线程等处理。对响应式流而言,操作符就像传统集合中的 map()filter()reduce() 等函数式编程工具,但它们是以 异步、非阻塞、流式 的方式工作。

Project Reactor 中的 FluxMono 提供了大量内置操作符,使得我们可以使用链式 API 来描述数据流的处理逻辑,从而构建出清晰、简洁且强大的异步流程。

在响应式编程中,操作符是构建数据流处理管道的核心工具。Reactor 提供了丰富的操作符,可归类为以下几类:

1. 转换类操作符(Transformation Operators)

map操作符

是最基础的元素转换工具。它对流中的每个元素应用给定的转换函数,将输入元素转换为另一种形式。当您需要简单地对元素进行一对一转换时(如类型转换、字段提取或简单计算),map是理想选择。

java 复制代码
Flux.just(1, 2, 3)
        .map(String::valueOf)
        .subscribe(log::info); //  "1", "2", "3"
flatMap操作符

用于处理一对多的转换场景。当转换函数返回的是一个新的Publisher(Mono或Flux)时,flatMap会将这些Publisher展平为单个数据流。这在需要异步操作(如调用其他服务)时特别有用:

java 复制代码
Flux<Integer> flux = Flux.range(1, 100)
        .flatMap(new Function<Integer, Flux<Integer>>() {
            @Override
            public Flux<Integer> apply(Integer integer) {
                return Flux.just(integer)
                        .delayElements(Duration.ofSeconds(1))
                        .doOnNext(integer1 -> log.info("return value {}", integer1));
            }
        }, 10); // 可以同时并行执行10个Publisher
flux.subscribe(integer -> log.info("subscribe: {}", integer));

因此,效果就是每隔1秒,会有十个Flux执行完毕将数据交给订阅者

concatMap操作符

与flatMap功能相似,但严格保持元素顺序。它确保前一个元素的流完全处理完毕后再处理下一个元素,适用于需要严格顺序保证的场景:

java 复制代码
Flux<Integer> flux = Flux.range(1, 100)
        .concatMap(new Function<Integer, Flux<Integer>>() {
            @Override
            public Flux<Integer> apply(Integer integer) {
                return Flux.just(integer)
                        .delayElements(Duration.ofSeconds(1))
                        .doOnNext(integer1 -> log.info("return value {}", integer1));
            }
        }, 10); // 向发布者request(10)个数据元素
flux.subscribe(integer -> log.info("subscribe: {}", integer));

因此,效果就是每隔1秒,会有一个Flux执行完毕将数据交给订阅者

switchMap操作符

在接收到新元素时会取消前一个元素的处理。这特别适合实现实时搜索功能,当用户连续输入时,只处理最新的搜索请求:

java 复制代码
   @Test
    public void testSwitchMap() throws InterruptedException {
        Flux.just(1, 2, 3)
                .delayElements(Duration.ofSeconds(1))
                .switchMap(new Function<Integer, Publisher<?>>() {
                    @Override
                    public Publisher<?> apply(Integer integer) {
                        return Flux.just(integer)
                                .doOnCancel(() -> log.info("cancel"))
                                .delayElements(Duration.ofSeconds(2));
                    }
                })
                .subscribe(o -> log.info("subscribe: {}", o));
        TimeUnit.SECONDS.sleep(10);
    }

因此,效果就是前两个元素都被取消了,最后一个元素能被订阅者拿到

2. 过滤类操作符(Filtering Operators)

filter操作符

根据条件筛选元素,只允许满足条件的元素通过:

java 复制代码
Flux.range(1,10)
        .filter(n -> n % 2 == 0)
        .subscribe(System.out::println); // 2,4,6,8,10
take操作符

限制从流中获取的元素数量:

java 复制代码
// 只取前三个元素
Flux.range(1,10)
        .take(3)
        .subscribe(System.out::println); //1,2,3
skip操作符

跳过指定数量的元素:

java 复制代码
// 跳过前7个元素
Flux.range(1,10)
        .skip(7)
        .subscribe(System.out::println); // 8,9,10
distinct操作符

去除重复元素:

java 复制代码
Flux.just(1, 1, 2, 3, 4, 5, 6, 3, 4)
        .distinct()
        .subscribe(System.out::println); // 1,2,3,4,5,6

3. 组合类操作符(Combination Operators)

merge操作符

将多个流合并为一个流,元素按实际发出时间交错出现:

java 复制代码
Flux<String> flux1 = Flux.interval(Duration.ofMillis(100)).map(i -> "A" + i);
Flux<String> flux2 = Flux.interval(Duration.ofMillis(150)).map(i -> "B" + i);
Flux.merge(flux1, flux2).subscribe(System.out::println); // A0, B0, A1, A2, B1...
zip操作符

将多个流的元素按顺序一一配对组合:

java 复制代码
Flux<String> names = Flux.just("Alice", "Bob", "Charlie");
Flux<Integer> ages = Flux.just(28, 32, 45);
Flux.zip(names, ages, (name, age) -> name + ":" + age)
    .subscribe(System.out::println);
// Alice:28, Bob:32, Charlie:45
concat操作符

按顺序连接多个流,先完整消费前一个流,再处理下一个:

java 复制代码
Flux<Integer> first = Flux.just(1, 2, 3);
Flux<Integer> second = Flux.just(4, 5, 6);
Flux.concat(first, second).subscribe(System.out::println); // 1,2,3,4,5,6

4. 错误处理类操作符(Error Handling Operators)

onErrorResume操作符

在发生错误时提供备用数据流:

java 复制代码
Flux.just(1, 2, 3, 4, 5)
        .map(i -> {
            if (i == 3) {
                throw new RuntimeException("error");
            }
            return i;
        })
        .onErrorResume(e -> Flux.just(6, 7, 8))
        .subscribe(System.out::println); // 1,2,6,7,8
onErrorReturn操作符

在错误时返回默认值:

java 复制代码
Flux.just(1, 2, 3, 4, 5)
        .map(i -> {
            if (i == 3) {
                throw new RuntimeException("error");
            }
            return i;
        })
        .onErrorReturn(-1)
        .subscribe(System.out::println); // 1,2,-1
retry操作符

在失败时自动重试,并且可以配置不同的策略进行重试(比如说立即重试、定时重试、指数退避重试):

重试若干次:

java 复制代码
externalService.call()
    .retry(3) // 最多重试3次
java 复制代码
externalService.call()
    .retryWhen(Retry.max(3)) // 最多重试3次

固定频率重试:

java 复制代码
externalService.call()
    .retryWhen(Retry.fixedDelay(3, Duration.ofSeconds(1))) // 最多重试3次,时间间隔1秒

无限次重试:

java 复制代码
externalService.call()
    .retryWhen(Retry.indefinitely()) // 无限次重试

指数退避重试:

java 复制代码
externalService.call()
    .retryWhen(Retry.backoff(3, Duration.ofSeconds(1))
                    .maxBackoff(Duration.ofSeconds(3))
                    .jitter(0.2))

当外部服务调用失败时,会进行最多 3 次重试,初始延迟 1 秒,之后每次重试间隔按指数增长(但最大不超过 3 秒),并添加 ±20% 的随机抖动以避免重试风暴

并且除此之外,还可以配置在出现指定异常的情况下,才进行重试:

java 复制代码
externalService.call()
    .retryWhen(Retry.max(3)
                    .filter(throwable -> throwable instanceof WebClientResponseException.InternalServerError))

5. 工具类操作符(Utility Operators)

doOnNext操作符

在不改变流的情况下执行副作用操作:

java 复制代码
Flux.just("a", "b", "c")
    .doOnNext(s -> System.out.println("Processing: " + s))
    .subscribe();
log操作符

提供详细的流处理日志:

java 复制代码
Flux.range(1, 3)
    .log("range-logger")
    .subscribe();
// 输出:onSubscribe, request(unbounded), onNext(1), onNext(2), onNext(3), onComplete
elapsed操作符

记录每个元素与前一个元素的时间间隔:

java 复制代码
Flux.range(1, 10)
        .delayElements(Duration.ofMillis(100))
        .elapsed()
        .subscribe(new Consumer<Tuple2<Long, Integer>>() {
            @Override
            public void accept(Tuple2<Long, Integer> objects) {
                log.info("{}ms: {}", objects.get(0), objects.get(1));
            }
        });
// 输出:112ms: 1,108ms: 2,...

7. 条件类操作符(Conditional Operators)

takeUntil操作符

在条件满足时停止流:

java 复制代码
Flux.range(1, 10)
        .takeUntil(i -> i == 5) // 取到5(包含5)
        .subscribe(System.out::println); //1,2,3,4,5
skipUntil操作符

在条件满足前跳过元素:

java 复制代码
Flux.range(1, 10)
        .skipUntil(i -> i > 5) // 跳过直到i>5(不包含触发条件的元素)
        .subscribe(System.out::println); //6,7,8,9,10

8. 时间相关操作符(Time-based Operators)

delayElements操作符

延迟每个元素的发射:

java 复制代码
Flux.just("A", "B", "C")
    .delayElements(Duration.ofSeconds(1))
    .subscribe();
/*
13:39:57.018 [parallel-1] INFO  - A
13:39:58.021 [parallel-2] INFO  - B
13:39:59.025 [parallel-3] INFO  - C
 */
timeout操作符

设置元素发出的超时时间:

java 复制代码
Flux.range(1, 5)
        .concatMap(integer -> Flux.just(integer).delayElements(Duration.ofSeconds(integer)))
        .timeout(Duration.ofSeconds(3))
        .subscribe(System.out::println, Throwable::printStackTrace);
/*
1
2
java.util.concurrent.TimeoutException: Did not observe any item or terminal signal within 3000ms in 'concatMapNoPrefetch' (and no fallback has been configured)
*/

9. 聚合操作符(Aggregation Operators)

reduce操作符

通过二元函数对数据流进行累积计算,最终输出单个结果。

java 复制代码
Mono<Integer> reduce = Flux.range(1, 5) // 1,2,3,4,5
        .reduce(0, Integer::sum);
reduce.subscribe(sum -> System.out.println("总和: " + sum));
// 输出: 总和: 15
scan操作符

reduce 类似,但输出每次累积的中间结果。

java 复制代码
Flux<Integer> scan = Flux.range(1, 5)
        .scan(0, Integer::sum);
scan.subscribe(System.out::println);
// 输出:0 1 3 6 10 15
collect操作符

作用:将流元素收集到集合容器中(List, Set, Map等)。

java 复制代码
// 收集到List
Flux.just("Apple", "Banana", "Orange")
.collectList()
.subscribe(list -> System.out.println("水果列表: " + list));
// 输出: 水果列表: [Apple, Banana, Orange]

// 自定义收集器
Flux.range(1, 10)
.collect(Collectors.partitioningBy(n -> n % 2 == 0))
.subscribe(map -> System.out.println("奇偶分组: " + map));
// 输出: 奇偶分组: {false=[1,3,5,7,9], true=[2,4,6,8,10]}

常用变体:

  • collectList()Mono<List<T>>
  • collectMap(keyMapper)Mono<Map<K, T>>
  • collectMultimap(keyMapper)Mono<Map<K, Collection<T>>>
  • collect(Collector)
count操作符

作用:统计流中元素数量。

java 复制代码
Flux.range(1,5)
.count()
.subscribe(c -> System.out.println("元素数量: " + c));
// 输出: 元素数量: 5
all/any操作符

作用:检查流是否满足条件

java 复制代码
// 所有元素都满足条件?
Flux.just(2, 4, 6, 8)
.all(n -> n % 2 == 0)
.subscribe(result -> System.out.println("全是偶数? " + result));
// 输出: 全是偶数? true

// 至少一个元素满足条件?
Flux.just(1, 3, 5, 6)
.any(n -> n % 2 == 0)
.subscribe(result -> System.out.println("包含偶数? " + result));
// 输出: 包含偶数? true
hasElements操作符

作用:检查流是否包含任何元素。

java 复制代码
Flux.empty()
.hasElements()
.subscribe(has -> System.out.println("有元素? " + has));
// 输出: 有元素? false
groupBy操作符

作用:按key分组后对每组进行聚合处理。

java 复制代码
Flux.just(
    new Order("A", 100),
    new Order("B", 200),
    new Order("A", 150),
    new Order("C", 50)
)
.groupBy(Order::getCustomerId)  // 按客户ID分组
.flatMap(groupedFlux -> 
         groupedFlux
         .map(Order::getAmount)
         .reduce(0, Integer::sum)
         .map(total -> new CustomerTotal(groupedFlux.key(), total))
        )
.subscribe(System.out::println);
// 输出: 
//   CustomerTotal{customerId='A', total=250}
//   CustomerTotal{customerId='B', total=200}
//   CustomerTotal{customerId='C', total=50}
window操作符

将流划分为多个窗口(子流):支持按数量分窗口、按时间分窗口、动态窗口(基于另一个Publisher)等

java 复制代码
Flux<Flux<Integer>> windowFLux = Flux.range(1, 10)
        .window(3);// 每3个元素一个窗口
windowFLux.flatMap(window -> window.reduce(Integer::sum))
        .subscribe(System.out::println);
// 输出:6, 15, 24, 10
java 复制代码
Flux.interval(Duration.ofMillis(100))  // 每100ms生成一个数字: 0,1,2...
    .window(Duration.ofSeconds(1))     // 每1秒分一个窗口
    .flatMap(window -> 
        window.count()                 // 统计每秒内的元素数量
    )
    .subscribe(count -> 
        System.out.println("1秒内元素数: " + count)
    );
java 复制代码
// 边界控制器:每500ms发出信号
Flux<Long> boundary = Flux.interval(Duration.ofMillis(500)); 

Flux.interval(Duration.ofMillis(100))  // 数据流: 0,1,2,3...
    .window(boundary)                  // 按boundary信号分窗口
    .flatMap(window -> window.collectList())
    .subscribe(System.out::println);
buffer操作符

和window操作符基本一致,也支持各种窗口划分方式。将元素收集到集合中:

java 复制代码
Flux<List<Integer>> listFlux = Flux.range(1, 10)
        .buffer(3);
listFlux.subscribe(System.out::println);
// 输出:[1,2,3], [4,5,6], [7,8,9], [10]

5. 调度器(Schedulers)和线程模型

在 Reactor 中,调度器(Schedulers) 和 线程模型 是非常重要的部分,它们决定了数据流的执行线程和调度策略。下面从几个方面为你介绍:

5.1 调度器

Reactor 提供了几种常见的调度器(Scheduler),用于控制操作符执行的线程。

1. Schedulers.immediate()
  • 执行策略:在当前调用线程上立即执行任务。
  • 使用场景:调试或不需要线程切换的简单场景。
  • 线程开销:没有额外线程开销。
2. Schedulers.single()
  • 执行策略:使用一个单独的线程串行执行任务。
  • 线程数:只有一个线程。
  • 使用场景:需要顺序执行、避免并发的任务。
4. Schedulers.boundedElastic()
  • 执行策略 :类似 elastic(),但线程数量有上限(默认最大线程数:CPU 核心数 × 10)。
  • 使用场景:IO 阻塞任务,数据库、网络、文件等操作。
5. Schedulers.parallel()
  • 执行策略:使用固定数量的线程池(默认与 CPU 核心数相等)。
  • 使用场景:计算密集型任务,适合并行处理数据。
6. Schedulers.fromExecutor(Executor) / Schedulers.fromExecutorService(...)
  • 执行策略:自定义线程池。
  • 使用场景:适用于需要对线程进行完全控制的情况。

5.2 线程模型

在 Reactor 中,默认所有操作都在当前线程(调用链所在线程)中执行,除非显式指定调度器(Scheduler)。

1. subscribeOn
  • 它影响的是源(Publisher)创建与数据发射的线程
  • 只影响调用链中的最上游部分(只生效一次)。
java 复制代码
Flux<Integer> flux = Flux.range(1, 5)
.map(i -> {
    System.out.println("map1 thread: " + Thread.currentThread().getName());
    return i;
})
.subscribeOn(Schedulers.boundedElastic())
.map(i -> {
    System.out.println("map2 thread: " + Thread.currentThread().getName());
    return i;
});

flux.subscribe();

输出中 map1map2 都在 boundedElastic 线程池中执行。

2. publishOn ------ 切换后续操作的执行线程
  • 它影响的是publishOn 之后的操作符所在的线程。
  • 可以多次使用,在链中多次切换线程。
java 复制代码
Flux.range(1, 5)
.map(i -> {
    System.out.println("before publishOn: " + Thread.currentThread().getName());
    return i;
})
.publishOn(Schedulers.parallel())
.map(i -> {
    System.out.println("after publishOn: " + Thread.currentThread().getName());
    return i;
})
.subscribe();

before publishOn 在主线程,after publishOn 在 parallel 线程池中。

5.3 并行执行

上述 publishOnsubscribeOn 可以指定某一段操作在指定线程池中执行,但它们本质上是串行执行 :一个元素一个元素地顺序处理。在需要对大量元素进行真正并行处理 (多线程同时处理多个元素)时,应使用 parallel() + runOn(...) 提供的并发能力。

parallel() + runOn(...)
java 复制代码
Flux.range(1, 10)
.parallel(4)                        // 拆成 4 条轨道
.runOn(Schedulers.parallel())      // 每条轨道运行在 parallel 调度器的不同线程上
.map(i -> {
    System.out.println("Thread: " + Thread.currentThread().getName() + ", value: " + i);
    return i;
})
.sequential()                      // 合并为单一 Flux(可选)
.subscribe();
  • parallel(n):将一个 Flux 拆分成 n 个并行轨道(子流),默认数量为 CPU 核心数。
  • runOn(Scheduler):将这些轨道的操作符执行绑定到指定的调度器线程池。
  • sequential():用于并行处理完成后再合并回一个普通的 Flux(不是必须的,但常见)。
flatMap

也可以通过flatMap将每个元素转为异步执行,之前已介绍过,在此不再赘述。

6. 背压(Backpressure)

背压是一种调节数据生产速率的机制,旨在防止下游消费者因处理不过来而导致**内存溢出(OOM)**或系统性能下降。在响应式流中,背压是保障系统稳定性和弹性的重要手段。

6.1 推模式 vs 拉模式

在响应式编程中,数据流的传递通常有两种方式:

  • 推模式(Push):生产者主动将数据推送给消费者。这种方式在消费者处理能力不足时,可能造成内存堆积或应用崩溃。
  • 拉模式(Pull):消费者根据自己的能力请求数据。背压机制本质上就是让"推模式"具备"拉模式"的能力,由消费者控制节奏。

因此,对于拉模式,是自带背压的;对于推模式,需要通过设置一些背压策略避免出现OOM的情况。

属于推模式的几个方法分别为:Flux.interval()、Flux.create()、Flux.push()

值得说明的是,如果发布者和订阅者使用同一个线程,推模式是天然背压的。因为发布者需要等待订阅者处理完之后,才能继续发布下一个数据。

6.2 背压策略

Reactor 提供了多种背压策略,用于在数据下游处理不过来时,决定如何处理溢出的数据。常见策略如下:

策略名称 说明
IGNORE 忽略背压信号,可能导致 OOM
ERROR 抛出异常(IllegalStateException ),终止数据流
DROP 丢弃溢出的数据
LATEST 保留最新数据,丢弃旧数据
BUFFER 缓存所有数据,直到消费者准备好。可设置缓冲区大小和溢出处理策略

这些策略通常通过 onBackpressureXXX 系列方法配置,如:

java 复制代码
Flux.create(sink -> {
    // 产生大量数据
}).onBackpressureBuffer(1000, BufferOverflowStrategy.DROP_OLDEST);

6.3 背压操作符

onBackpressureError
java 复制代码
Flux<Integer> flux = Flux.create(new Consumer<FluxSink<Integer>>() {
            @SneakyThrows
            @Override
            public void accept(FluxSink<Integer> sink) {
                for (int i = 0; i < 1000; i++) {
                    for (int j = 0; j < 10; j++) {
                        log.info("send {}", j);
                        sink.next(j);
                    }
                    TimeUnit.SECONDS.sleep(2);
                }
                sink.complete();
            }
        })
        .onBackpressureError()
        .publishOn(Schedulers.single(), 1);

flux.subscribe(new Consumer<Integer>() {
    @SneakyThrows
    @Override
    public void accept(Integer num) {
        log.info("receive {}", num);
        TimeUnit.SECONDS.sleep(1);
    }
});
onBackpressureDrop
java 复制代码
Flux<Integer> flux = Flux.create(new Consumer<FluxSink<Integer>>() {
    @SneakyThrows
    @Override
    public void accept(FluxSink<Integer> sink) {
        for (int i = 0; i < 1000; i++) {
            for (int j = 0; j < 5; j++) {
                log.info("send {}", j);
                sink.next(j);
            }
            TimeUnit.SECONDS.sleep(2);
        }
        sink.complete();
    }
})
.onBackpressureDrop(integer -> log.warn("drop {}", integer))
.publishOn(Schedulers.single(), 1);

flux.subscribe(new Consumer<Integer>() {
    @SneakyThrows
    @Override
    public void accept(Integer num) {
        log.info("receive {}", num);
        TimeUnit.SECONDS.sleep(1);
    }
});
onBackpressureLatest
java 复制代码
Flux<Integer> flux = Flux.create(new Consumer<FluxSink<Integer>>() {
            @SneakyThrows
            @Override
            public void accept(FluxSink<Integer> sink) {
                for (int i = 0; i < 1000; i++) {
                    for (int j = 0; j < 5; j++) {
                        log.info("send {}", j);
                        sink.next(j);
                    }
                    TimeUnit.SECONDS.sleep(2);
                }
                sink.complete();
            }
        })
        .onBackpressureLatest()
        .publishOn(Schedulers.single(), 1);

flux.subscribe(new Consumer<Integer>() {
    @SneakyThrows
    @Override
    public void accept(Integer num) {
        log.info("receive {}", num);
        TimeUnit.SECONDS.sleep(1);
    }
});
onBackpressureBuffer
java 复制代码
Flux<Integer> flux = Flux.create(new Consumer<FluxSink<Integer>>() {
            @SneakyThrows
            @Override
            public void accept(FluxSink<Integer> sink) {
                for (int i = 0; i < 1000; i++) {
                    for (int j = 0; j < 5; j++) {
                        log.info("send {}", j);
                        sink.next(j);
                    }
                    TimeUnit.SECONDS.sleep(1);
                }
                sink.complete();
            }
        })
        .onBackpressureBuffer(3, integer -> log.warn("drop {}", integer), BufferOverflowStrategy.DROP_OLDEST)
        .publishOn(Schedulers.single(), 1);

flux.subscribe(new Consumer<Integer>() {
    @SneakyThrows
    @Override
    public void accept(Integer num) {
        log.info("receive {}", num);
        TimeUnit.SECONDS.sleep(1);
    }
});

6.4 哪些场景需要特别关注背压

  • 自定义发布者:如使用 Flux.create()Flux.push() 等手动发射数据的方法。
  • 高速生产、慢速消费:如读取高速数据源(消息队列、数据库流)时未加以限制。
  • 多线程环境:生产者在独立线程中快速产生数据,容易压垮单线程消费者。
  • 网络 IO、文件流处理等慢速资源场景。

相比之下,一些操作符(如 Flux.range()Flux.just())天然是背压感知的,无需额外干预。

7. 冷流 vs 热流

  • 冷流(Cold Sequence):每个订阅者独立消费完整数据(如HTTP请求)。
java 复制代码
Flux<String> cold = Flux.fromStream(() -> Stream.of("A", "B"));
cold.subscribe(); // 触发数据生成
cold.subscribe(); // 再次触发新数据流
  • 热流(Hot Sequence):多个订阅者共享实时数据(如消息队列)。
java 复制代码
ConnectableFlux<String> hot = Flux.just("X", "Y", "Z").publish();
hot.connect(); // 手动启动发布
hot.subscribe(System.out::println); // 所有订阅者接收相同数据

9. 上下文(Context)

  • 作用:在响应式链中传递状态(类似ThreadLocal)。
  • 示例:
java 复制代码
Mono<String> result = Mono.deferContextual(ctx -> 
    Mono.just("User: " + ctx.get("user"))
).contextWrite(ctx -> ctx.put("user", "Alice"));

总结

本文介绍了响应式编程、Reactive Streams规范、Project Reactor的核心概念及使用。后续将基于本文的介绍的内容,介绍Spring WebClient。

相关推荐
王中阳Go26 分钟前
从超市收银到航空调度:贪心算法如何破解生活中的最优决策谜题?
java·后端·算法
shepherd11126 分钟前
谈谈TransmittableThreadLocal实现原理和在日志收集记录系统上下文实战应用
java·后端·开源
关山月1 小时前
使用 Ollama 和 Next.js 构建 AI 助手
后端
倚栏听风雨1 小时前
SwingWorker详解
后端
深栈解码1 小时前
OpenIM 源码深度解析系列(二):双Token认证机制与接入流程
后端
考虑考虑1 小时前
feign异常处理
spring boot·后端·spring
mCell2 小时前
你可能在用错密码:服务端密码安全的真相与陷阱
后端·安全·go
掘金狂热勇士2 小时前
Faster LIO建图过程
后端
树獭叔叔2 小时前
从零开始Node之旅——装饰器
后端·node.js
摆烂工程师2 小时前
Google One AI Pro 的教育学生优惠即将在六月底结束了!教你如何认证Gemini学生优惠!
前端·人工智能·后端