你贴出的这段代码正是上面我们讨论的 事件监听是如何"常驻运行"的底层机制 。
让我们一步步剖析它,看看:
🔍 "是谁一直在调用 listener?"
🔄 "goroutine 是在哪个环节启动的?"
🧩 "eventBus / Dapr / listenerRegistry 三者之间是怎样协作的?"
🧩 一、核心方法:SubscribeWithHandler
我们重点看这一段:
go
go func() {
defer s.Close()
for {
msg, err := s.Receive()
if err != nil {
...
return
}
go func() {
if err := msg.respondStatus(handler(msg.TopicEvent)); err != nil {
...
}
}()
}
}()
这几行代码做的事情可以总结为👇:
| 动作 | 说明 |
|---|---|
go func() { ... }() |
启动一个新的 goroutine,专门负责监听消息流。 |
for { msg, _ := s.Receive() } |
持续阻塞等待(long polling),直到从 pub/sub 收到新消息。 |
handler(msg.TopicEvent) |
收到消息后调用你注册的业务处理函数(例如 processCreated)。 |
msg.respondStatus(...) |
将处理结果(成功/失败)回传给 Dapr runtime。 |
也就是说:
每个订阅(Subscription)在启动时都会自动启动一个 goroutine,永久监听该 topic。
这就是为什么即使你不主动"轮询"调用,事件监听器也能一直"活着"的原因。
🧠 二、事件监听的运行机制图(从 Dapr 到你的 listener)
scss
[Dapr sidecar]
│ ← 消息发布 (RabbitMQ / Redis Streams / Kafka / etc.)
▼
[Dapr gRPC server]
│
▼
client.SubscribeWithHandler(...) ← 你调用的函数
│
├─ s := c.Subscribe(ctx, opts) ← 创建底层订阅连接
│
└─ go func() { ← 启动 goroutine
for {
msg, _ := s.Receive() ← 阻塞等待新事件
go handler(msg.TopicEvent) ← 调用业务处理函数
}
}()
💡 当 Dapr sidecar 收到新消息,会通过 gRPC 推送给客户端,
Receive() 负责接收并触发你的 handler。
⚙️ 三、DaprClient 的生命周期管理
再看:
go
func (c *DaprClient) SubscribeWithHandler(...) error {
stop, err := c.client.SubscribeWithHandler(...)
...
c.stopHandlers = append(c.stopHandlers, stop)
}
- 每个订阅(topic)都会返回一个
stop函数; - 这个函数内部调用
s.Close(); DaprClient把它们都存到stopHandlers里,以便在应用关闭时统一释放。
👉 所以:
- 启动时:每个订阅会起一个 goroutine;
- 关闭时:调用
for _, stop := range c.stopHandlers { stop() }来安全退出。
🔁 四、关联到你上面的 ListenerRegistry 流程
整个流程串起来是这样的:
css
main()
└─ wireApp()
├─ eventBus := data.NewEventBus()
│ └─ 包装 DaprClient.SubscribeWithHandler()
│ └─ 启动 goroutine 循环 Receive()
│
├─ workflowEventListener := NewWorkflowEventListener(eventBus, ...)
│ └─ 调用 eventBus.Subscribe(pubsub="pubsub", topic="workflow_event")
│ └─ 启动 goroutine 等待消息
│
└─ listenerRegistry := &ListenerRegistry{WorkflowEventListener: ...}
(只是保存引用)
结果:
listenerRegistry是一个引用容器;WorkflowEventListener.Subscribe()通过 Dapr client 启动 goroutine;- Dapr runtime 持续通过 gRPC 推消息;
- 你的
processCreated、processStop等处理函数在 goroutine 中被自动调用。
✅ 五、总结要点
| 部分 | 是否常驻 | 说明 |
|---|---|---|
listenerRegistry |
❌ 否,仅保存指针引用 | 不执行任何逻辑 |
WorkflowEventListener.Subscribe() |
✅ 启动 Dapr 订阅 goroutine | 实现事件监听 |
SubscribeWithHandler |
✅ 内部 go func() { for { s.Receive() } }() |
永久循环接收消息 |
handler(msg.TopicEvent) |
✅ 每条消息触发一次 | 执行业务逻辑 |
stopHandlers |
✅ 管理所有订阅 | 在应用退出时关闭 goroutine |
💬 一句话总结
监听器之所以能"一直运行",
是因为
SubscribeWithHandler在内部启动了一个 goroutine,这个 goroutine 会阻塞等待
s.Receive()不停从 Dapr pub/sub 拉取消息。而
listenerRegistry只是保存所有这些监听器的引用,真正"跑"的是 Dapr 客户端的消息循环。
是否希望我帮你画出这个完整的 "事件监听常驻机制时序图" (Dapr → gRPCClient → WorkflowEventListener → handler 调用链)?
这样你能一眼看清整个 goroutine 调用流。
You said:
Dapr runtime 持续通过 gRPC 推消息;
ChatGPT said:
非常对 👍,你抓到了整个机制的关键一句话:
💡 "Dapr runtime 持续通过 gRPC 推消息。"
下面我帮你完整解释这句话背后的 运行原理(包括 Dapr sidecar 的行为、gRPC 通道、消息流动方向) ,并结合你贴的代码,让你真正明白 listener 为什么会"一直被调用"。
🧩 一、整体结构:Dapr 的 pub/sub 机制分成两部分
scss
+-----------------------------+
| Your App (Go) |
|-----------------------------|
| Dapr SDK / Client (gRPC) | ← 你用的 dapr.NewClient()
|-----------------------------|
| Listener goroutines |
| - SubscribeWithHandler() | ← 启动 for { Receive() }
+-------------▲---------------+
│ gRPC (推送)
▼
+-----------------------------+
| Dapr Sidecar |
|-----------------------------|
| Pub/Sub component plugin | ← Redis / RabbitMQ / Kafka / etc.
| Subscription registry |
+-----------------------------+
Dapr runtime(sidecar)是一个 本地进程 (通常与应用容器同 pod),
它负责:
- 从外部消息系统订阅消息;
- 把消息 推送(push) 到你的应用;
- 等待你的应用返回 ack(成功/失败)。
⚙️ 二、gRPC 推送机制(双向流)
你看到的代码:
css
msg, err := s.Receive()
其实是封装在一个 gRPC 双向流(Bidirectional Stream) 之上的。
Dapr runtime 在 gRPC 层面的协议大致如下(伪代码):
scss
service AppCallback {
rpc OnTopicEventStream(stream TopicEventRequest) returns (stream TopicEventResponse);
}
运行时流程:
scss
Dapr Sidecar (server) ←→ Your App (client)
↑ ↓
[Push message] [Receive() blocks]
↑ ↓
[Wait ack] [RespondStatus()]
所以当 Dapr 从外部 pub/sub 系统(如 Redis)收到消息时:
- 它通过 gRPC 流式发送(Send) 一条
TopicEventRequest; - 你的应用那边
Receive()就被唤醒; - Dapr SDK 执行你注册的
handler(msg.TopicEvent); - 你业务处理完后,SDK 调用
respondStatus(); - Dapr runtime 收到 ack,决定是否从 pub/sub 确认(acknowledge)该消息。
🧵 三、为什么是"推送(push)",而不是"拉取(poll)"
区别如下👇
| 模式 | 谁主动发起 | SDK 里表现 | 性能 |
|---|---|---|---|
| 拉取 (polling) | 应用主动调用 API 去取消息 | 手动调用 Receive() | 延迟高 |
| 推送 (stream push) | Dapr runtime 主动发消息 | Receive() 被唤醒 | 延迟低、实时性高 ✅ |
所以即使你在 Go 代码里看到
for { s.Receive() },实际上是一个被 远端 gRPC 推送唤醒的阻塞等待。
这就是为什么 listener 不需要轮询或定时器:
它在一个长连接的 gRPC 流上,Dapr 一推就收到。
🧠 四、时序图展示整个推送过程
scss
外部消息源 (RabbitMQ)
│
│ 发布事件
▼
[Dapr Sidecar]
│ 从 Pub/Sub 插件接收消息
│
│ 通过 gRPC stream 推送 TopicEvent
▼
[Dapr SDK in your app]
│ s.Receive() 被唤醒
│ 调用 handler(msg.TopicEvent)
│ handler 完成 → respondStatus()
▼
[Dapr Sidecar]
│ 收到 ack → 向 Pub/Sub 发确认
▼
[消息系统]
│ 从队列移除消息
🧩 五、结合你贴的 SubscribeWithHandler
现在你可以更清楚地理解这几行的意义了:
scss
for {
msg, err := s.Receive() // ← 阻塞等待 gRPC stream 推送
go func() {
handler(msg.TopicEvent) // ← 调用你的业务逻辑
msg.respondStatus(...) // ← 发送 ack 给 Dapr runtime
}()
}
Receive()实际是等 Dapr runtime 通过 gRPC stream 送过来的事件;handler()是你注册的回调(例如processCreated);respondStatus()是响应结果给 Dapr runtime;- 整个过程由 sidecar 主动推送消息 触发。
✅ 六、总结
| 环节 | 作用 |
|---|---|
| Dapr runtime (sidecar) | 从 Pub/Sub 拉取消息,通过 gRPC 推送到你的应用 |
| gRPC 双向流 | 保持一个长连接通道,用于持续推送消息和接收 ack |
SDK SubscribeWithHandler |
启动 goroutine,阻塞 Receive(),自动处理每条消息 |
| handler() | 你的业务逻辑,执行完后返回状态 |
| listenerRegistry | 只负责收集 listener,不参与推送逻辑 |
💬 一句话总结
Dapr runtime 主动通过 gRPC 长连接推送消息到你的应用,
你的应用通过
SubscribeWithHandler启动 goroutine 阻塞等待Receive(),当 Dapr 发送新消息时,它被唤醒并调用你的回调函数处理。