Subscriber 的职责
Subscriber 看起来只是「收数据」,但它承担四条独立责任:
- 表达需求:通过
request(n)告诉上游能处理多少(§2.1)。 - 串行调用:对 request/cancel 的调用不能并发(§2.7)。
- 容忍边界:cancel 后可能还有迟到元素(§2.8),未 request 也可能收到终止信号(§2.9、§2.10)。
- 正确收尾:终止回调里不得再操作 Subscription 或 Publisher(§2.3)。
本节用三个实现分别覆盖这些点。
| Subscriber | 用途 | 主要规则 |
|---|---|---|
LoggingSubscriber | 调试输出 | §1.9、§2.1 |
CountingSubscriber | 逐元素请求、达到目标即取消 | §2.1、§2.7、§2.8 |
BatchingSubscriber | 批量缓冲后处理 | §2.3、§2.4、§2.7 |
LoggingSubscriber:最小可用实现
package com.plume.eventd.core;
import org.reactivestreams.Subscriber;
import org.reactivestreams.Subscription;
public final class LoggingSubscriber<T> implements Subscriber<T> {
private final String name;
private Subscription subscription;
public LoggingSubscriber(String name) {
this.name = name;
}
@Override
public void onSubscribe(Subscription subscription) {
// §2.5:已有活动订阅时,cancel 新来的这个并返回;
// 旧订阅保持活动,不能反过来把旧的取消掉
if (this.subscription != null) {
subscription.cancel();
return;
}
this.subscription = subscription;
log("onSubscribe");
subscription.request(Long.MAX_VALUE); // §2.1:表达需求
}
@Override
public void onNext(T item) {
log("onNext: " + item);
}
@Override
public void onError(Throwable throwable) {
// §2.3:终止回调里不调用 subscription.cancel()
log("onError: " + throwable);
}
@Override
public void onComplete() {
log("onComplete");
}
public void cancel() {
if (subscription != null) {
subscription.cancel();
}
}
private void log(String message) {
System.out.printf("[%s][%s] %s%n",
Thread.currentThread().getName(), name, message);
}
}
onSubscribe 里调用 request 是合法的
很多资料说「request 必须在 onSubscribe 返回后调用」,这是误解。§3.2 明确要求 Subscription 必须允许 Subscriber 从 onSubscribe 或 onNext 内部同步调用 request。同步 Publisher 正是靠这一点工作的:request 调用栈里直接发射元素。
真正需要担心的是无界递归(§3.3):onNext -> request -> onNext -> ... 如果每一层都直接递归,栈会爆。规范建议把递归深度限制为 1,实现方式就是 2.2 里的「记账 + 循环泵」:request 只增加额度并尝试进入发射循环,如果已有线程在循环中就直接返回。
终止回调里什么不能做
§2.3 禁止在 onComplete/onError 中调用 Subscription 或 Publisher 的任何方法。下面两种写法都违规:
@Override
public void onComplete() {
subscription.cancel(); // 违规:终止回调里操作 Subscription
}
@Override
public void onError(Throwable t) {
publisher.subscribe(new RecoverySubscriber<>()); // 违规:操作 Publisher
}
要在终止时释放资源(关闭文件、归还连接、latch.countDown()),直接在回调里做即可,只要不碰 Subscription/Publisher。如果用的是 Reactor,doFinally 是更合适的钩子:它在终止信号之后执行,且不会再回调 Subscription。
CountingSubscriber:逐元素请求
public final class CountingSubscriber<T> implements Subscriber<T> {
private Subscription subscription;
private int count;
private final int target;
public CountingSubscriber(int target) {
this.target = target;
}
@Override
public void onSubscribe(Subscription subscription) {
this.subscription = subscription;
subscription.request(1); // 先要一个
}
@Override
public void onNext(T item) {
count++;
if (count >= target) {
subscription.cancel(); // 够了就取消(§2.6)
return;
}
subscription.request(1); // 处理完再要下一个
}
@Override
public void onError(Throwable t) {
System.out.println("Error: " + t);
}
@Override
public void onComplete() {
System.out.println("Complete, total: " + count);
}
}
request(1) 放在 onNext 的哪个位置
| 位置 | 效果 | 风险 |
|---|---|---|
| 处理前 | 提前要下一个,吞吐高 | 处理慢时元素在内存里排队 |
| 处理后(本例) | 处理完才要,天然背压 | 同步 Publisher 下有重入风险 |
| 另一个线程 | 完全避开重入 | 多一次调度,顺序需要额外保证 |
「处理后」版本的背压最直观:处理多慢,请求就多慢。重入风险由 Publisher 侧的 §3.3 兜底——合规的 Publisher 必须限制同步递归深度,不能因为 Subscriber 在 onNext 末尾调 request 就无限递归。
cancel 之后仍可能收到 onNext
cancel() 不是「立即静音」。§2.8 要求 Subscriber 准备好接收 cancel 之后、已经请求过的迟到元素;§3.12 也只要求 Publisher「最终」停止。所以 onNext 里 cancel 之后,计数器、缓冲区、状态机仍然要能安全处理后续回调,不能把字段置 null 后直接 dereference。
本节的 AsyncRangePublisher 在发射循环的每个元素前检查 cancelled,所以实际观察到的是「cancel 后立刻停止」。这是实现的优化,不是规范保证——换一个异步源(网络、消息队列)就可能出现迟到元素。
BatchingSubscriber:批量消费
import java.util.ArrayList;
import java.util.List;
import org.reactivestreams.Subscriber;
import org.reactivestreams.Subscription;
public final class BatchingSubscriber<T> implements Subscriber<T> {
private final List<T> buffer = new ArrayList<>();
private final int batchSize;
private Subscription subscription;
private boolean done;
public BatchingSubscriber(int batchSize) {
this.batchSize = batchSize;
}
@Override
public void onSubscribe(Subscription subscription) {
this.subscription = subscription;
subscription.request(batchSize); // 只请求一批
}
@Override
public void onNext(T item) {
// synchronized 保护 buffer 状态,但不负责「信号串行」
synchronized (this) {
if (done) return;
buffer.add(item);
if (buffer.size() < batchSize) {
return;
}
List<T> batch = drain();
process(batch);
}
// 在本批处理完之后再请求下一批
subscription.request(batchSize);
}
@Override
public void onError(Throwable t) {
synchronized (this) {
done = true;
buffer.clear();
}
System.out.println("Error: " + t);
}
@Override
public void onComplete() {
List<T> tail;
synchronized (this) {
done = true; // §2.4:终止后订阅视为取消
tail = drain();
}
if (!tail.isEmpty()) {
process(tail); // 只处理残余,不再 request
}
System.out.println("Complete");
}
private List<T> drain() {
List<T> copy = new ArrayList<>(buffer);
buffer.clear();
return copy;
}
private void process(List<T> batch) {
System.out.println("Processing batch of " + batch.size());
// 真实场景在这里写库/发消息;演示中不 sleep
}
}
为什么 onComplete 里不能再 request
终止信号到达后,Subscription 在语义上已经取消(§1.6、§2.4),§2.3 干脆禁止在 onComplete/onError 中调用它的任何方法。所以收尾逻辑必须是「把缓冲区里剩下的处理掉」,而不是「再要一批」。
早期版本常写成:
@Override
public void onComplete() {
processBatch(); // 内部又 subscription.request(batchSize)
}
在同步 Publisher 上这会触发重入,在异步 Publisher 上会得到一个无意义的 NOP(或某些实现直接抛异常)。正确做法是把「处理」和「请求」拆开:process 只处理数据,request 只发生在 onNext 的正常路径。
synchronized 不等于信号串行
§2.7 要求 Subscriber 对 request/cancel 的调用串行;§1.3 要求 Publisher 对 Subscriber 的信号串行。这两条责任不同:
- 如果 Publisher 并发调用
onNext,它已经违反 §1.3,Subscriber 加synchronized只能防止状态被破坏,不能把违规变成合规。 synchronized在onNext里是有用的:它保证 buffer 的读写不被并发破坏,也让「判断满批 + 取出 + 清空」成为一个原子步骤。
真正的串行化应该由 Publisher 侧的 WIP 计数器(2.2 的 emitting)或调度器保证,而不是让每个 Subscriber 自己加锁。
三个边界测试
测试一:cancel 后 request 是 no-op
@Override
public void onSubscribe(Subscription s) {
s.cancel();
s.request(10); // §3.6:NOP
}
预期没有任何 onNext,也没有终止信号。如果实现仍然发射,说明 request 没有检查取消状态。
测试二:非法 request 触发 onError
@Override
public void onSubscribe(Subscription s) {
s.request(-1);
}
@Override
public void onError(Throwable t) {
System.out.println("Expected: " + t.getMessage());
}
预期打印 Expected: §3.9: non-positive request: -1,且 request 本身没有抛异常。
测试三:迟到的 onNext
用一个「cancel 后仍会发完当前批次」的 Publisher(把 2.2 的检查从每个元素前移到每批前即可复现):
@Override
public void onNext(Integer i) {
System.out.println("onNext: " + i);
if (i == 5) subscription.cancel();
}
预期看到 1–5,之后可能还会出现 6–10(取决于实现)。Subscriber 不能因为「已经 cancel」就假定状态可以随意销毁。
实现检查清单
| 检查项 | 规则 | 说明 |
|---|---|---|
| 保存 Subscription | §2.12 的前提 | 不保存就无法 request/cancel |
| 已有订阅时 cancel 新来的 | §2.5 | 保留旧订阅,拒绝第二个 Publisher 驱动 |
| 用 request 表达需求 | §2.1 | 不 request 就不该收到 onNext |
| request/cancel 串行调用 | §2.7 | 多线程时加锁或收敛到单线程 |
| 终止回调不碰 Subscription | §2.3 | 包括不调用 cancel/request |
| 容忍迟到元素 | §2.8 | cancel 后状态仍要可用 |
| 参数 null 抛 NPE | §2.13 | 四个信号方法都适用 |
小结
Subscriber 的实现重点不在「收数据」,而在边界:
- 需求由自己表达,不要指望 Publisher 猜。
- 调用要串行,并发调用 request/cancel 的责任在 Subscriber。
- 终止回调是禁区,不能在里面操作 Subscription/Publisher。
- cancel 不是立即静音,状态清理要能容忍迟到信号。
下一节实现 Processor:它同时扮演 Subscriber 和 Publisher,两侧契约会在这里正面相遇。
阅读导航
下一篇:3.1 Processor:Subscriber 与 Publisher 的桥接
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。