做机器人项目最怕的从来不是电机不转,而是事件没到。我印象最深的一次调试,是一条本该在 20 毫秒内触发的碰撞回调,硬生生推迟了 400 毫秒才执行,排查了整整两天,最后发现是某个日志处理函数里塞了一次同步文件写入,把整条事件通道堵死了。那次之后我才真正把 Gobot 的事件驱动架构当回事,而不是把它当成一个“回调注册工具”。这篇内容围绕 Gobot 事件驱动架构展开,讲清楚响应式机器人应用该怎么搭、事件从硬件一路流到业务逻辑中间要经过哪些关卡、哪些参数必须算着定而不是拍脑袋,以及踩过的坑。适合已经在用 Go 写机器人或物联网应用、被时延和并发问题折磨过的人,也适合刚接触 Gobot、想知道“为什么不能直接在回调里干活”的入门者。
1. 从轮询到响应式:机器人应用为什么绕不开事件驱动
1.1 轮询模型的三个隐性成本
绝大多数人写第一版机器人控制程序时,用的都是轮询:主循环里读一次传感器、算一次控制量、驱动一次执行器,然后sleep。这套写法在单任务、低频场景下没毛病,但一旦设备数量上去、逻辑分支变多,成本会以你意想不到的方式冒出来。
第一个成本是时延不可控。你在循环里读五个传感器,每个读一次花 2 毫秒,加上中间的计算和输出,一圈下来 15 毫秒。看起来还行,但只要加一个需要 50 毫秒的通信动作,整圈就被拖到 65 毫秒,所有事件的响应精度都跟着劣化。更麻烦的是这种劣化是“连带”的——你没有碰的那个传感器,响应也变慢了。
第二个成本是逻辑耦合。轮询循环天然把所有设备的处理逻辑挤在同一个函数里,加一个设备就要改主循环,删一个设备也要改主循环。半年之后你回头看那段代码,会发现它已经变成一棵巨大的switch,谁也不敢动。
第三个成本是空转浪费。绝大多数时刻传感器状态没有变化,但循环照样在跑,照样在读寄存器、照样在算。在电池供电的移动平台上,这部分浪费直接换算成续航。
事件驱动把这三件事一次性反转:设备状态变化时主动“喊一声”,业务逻辑只在被喊到时才干活,没变化就没有计算。
1.2 Gobot 的事件模型到底长什么样
用一句话概括 Gobot 的事件模型:任何实现了Eventer接口的对象,都能发布事件,也都能订阅事件。这个接口里最关键的是三个方法——On(name, handler)注册处理函数,Publish(name, data)触发事件,AddEvent(name)声明一个事件名。Robot本身就是一个Eventer,每个Device内部也持有一个Eventer,所以你可以选择在设备层订阅,也可以选择在机器人层订阅。
这个设计的巧妙之处在于它把“谁产生事件”和“谁消费事件”解耦了。按钮驱动不需要知道谁在听它,它只管在检测到电平变化时Publish一个事件;业务层也不需要知道按钮接在哪个引脚上,它只管关心“push 事件来了我该做什么”。中间这层解耦,就是响应式架构能长成参天大树的地基。
但这里有个必须提前说清楚的细节:Gobot 的事件分发走的是同步调用。Publish被调用的那一刻,它会按注册顺序依次执行所有绑定在该事件上的处理函数,全部执行完之后Publish才返回。这意味着处理函数里的任何阻塞操作,都会原样传导回事件源所在的 goroutine。这一点后面第 2.3 节还会展开,因为它是九成“事件延迟”问题的根源。
1.3 什么项目适合事件驱动,什么项目别硬上
事件驱动不是银弹。我见过有人把一个只有两个舵机、逻辑就是定时摆动的玩具项目也套上完整的事件总线,结果代码量翻了三倍,调试难度还上去了。判断标准其实很朴素:如果“状态变化的时刻”比“状态的当前值”更重要,就该用事件驱动;如果业务只关心“现在是什么值”,轮询反而更直接。
| 场景特征 | 推荐模型 | 原因 |
|---|---|---|
| 多设备异步输入、逻辑分支多 | 事件驱动 | 解耦彻底,加设备不改主循环 |
| 需要毫秒级响应外部触发 | 事件驱动 | 中断驱动,无需等待轮询周期 |
| 低频、单一传感器的简单控制 | 轮询 | 代码量小,行为可预测 |
| 强周期性控制回路(如 PID 定频) | 定时器 + 事件混合 | 控制律需要固定步长,事件负责异常 |
| 大量设备但只有少数频繁变化 | 事件驱动 + 状态缓存 | 避免无效计算 |
注意最后一行:事件驱动不等于不缓存状态。响应式应用里通常会维护一份“最新状态快照”,新订阅者接入时先拿快照,再接收增量事件,否则你会丢失接入之前的所有信息。这套“快照 + 增量”的组合,是响应式系统里最基本的一致性保障手段。
2. Gobot 事件驱动架构的四个角色与数据流向
2.1 Adaptor、Driver、Device、Robot 各管什么
把 Gobot 的架构拆开看,其实就四个角色在接力。搞清楚每个角色的职责边界,后面写代码才不会把逻辑放错层。
Adaptor是平台抽象层,负责“怎么和这块板子说话”。它管连接、管端口、管最终的数据读写,屏蔽掉底层是串口、是 I2C、还是网络套接字。Driver是设备抽象层,负责“这个具体设备的行为语义”,比如一个按钮驱动知道去轮询某个引脚,检测到电平从高到低就发布 push 事件。Device是把 Driver 和 Adaptor 绑在一起的胶水,一个 Device 必定持有一个 Connection。Robot是编排层,负责启动和停止所有设备、维护事件总线、承载业务逻辑。
| 角色 | 核心职责 | 不该做的事 |
|---|---|---|
| Adaptor | 连接管理、底层读写 | 不做业务判断、不做数据解释 |
| Driver | 设备语义、状态检测、发布事件 | 不做跨设备协调、不写数据库 |
| Device | 驱动与连接的绑定 | 不放业务逻辑 |
| Robot | 生命周期编排、事件路由 | 不直接操作寄存器 |
这张表值得贴在显示器边上。我接手过一个项目,有人在 Driver 里直接调用了业务侧的告警服务,结果这个驱动完全没法在测试环境里跑,因为它硬依赖一个只在生产环境存在的告警端点。职责一旦越界,可测试性第一个阵亡。
2.2 一条事件的完整旅程
拿一个最典型的例子:按钮被按下。整个链路是这样的——Adaptor 层的数字输入读取函数被周期性调用,Driver 拿到电平值,和自己记录的上一次状态比对,发现从高变低,于是Publish("push", 时间信息)。这次Publish会遍历 Robot 上所有注册了push的处理函数,按注册顺序同步执行。如果某个处理函数是往一个通道里塞数据,那么业务侧的 worker 会在另一个 goroutine 里取到这条消息并处理。
这条链路上有三个可以插入“缓冲”的位置:驱动层的事件合并、Robot 层的通道转发、业务层的工作队列。绝大多数性能问题,都是因为这三个位置一个都没设,事件从硬件一路裸奔到业务逻辑,中间没有任何削峰填谷的余地。
还有一个容易被忽略的细节:事件携带的数据。Gobot 的事件数据是interface{},你可以塞任何东西进去,但建议永远塞一个结构体而不是裸值,结构体里至少包含三样东西——触发时间戳、来源标识、以及一个自增序号。这三样东西在排查乱序和重复问题时价值连城,后面第 3.3 节会专门讲。
2.3 goroutine 边界:事件循环里能做什么、不能做什么
这是整篇内容里我最想强调的一点。Gobot 的Publish是同步分发,处理函数跑在事件源所在的 goroutine 上。也就是说,如果你在按钮的 push 处理函数里做了一次 100 毫秒的网络请求,那么按钮驱动在这 100 毫秒里没法继续检测电平变化——它被你自己堵住了。
正确的做法是在处理函数里只做“转手”:把事件丢进一个有缓冲的通道,立刻返回。真正的处理交给独立的 worker goroutine。这样事件源永远不被业务阻塞,事件源和处理逻辑彻底异步。
注意:通道必须有缓冲,且缓冲深度要算。无缓冲通道在 worker 繁忙时同样会阻塞生产者,等于把同步调用换了个马甲。缓冲深度的计算方法在第 4.3 节给出。
判断一个处理函数是否“太重”,我一般用一个土办法:在函数首尾各打一个时间戳,超过 1 毫秒就标红。1 毫秒对于事件分发循环来说已经是很宽松的阈值了,因为一个 200Hz 的传感器驱动留给每次分发的预算只有 5 毫秒,你不能让一个处理函数吃掉五分之一。
3. 构建响应式机器人应用的 10 个核心技巧
3.1 技巧一至三:先把事件源管住
技巧一:高频信号必须做去抖,而且要去在驱动侧。机械按钮的抖动会在一两毫秒内产生十几个边沿,如果每个边沿都发布一次事件,业务侧会被瞬间淹没。Gobot 的按钮驱动本身提供了去抖间隔的配置能力,常见取值在 10 到 50 毫秒之间。选值的依据很简单:人手最快的一次连击间隔大约 100 毫秒,取 50 毫秒以内的值不会吞掉真实操作,同时足以滤掉绝大多数抖动。这个参数不要照抄,如果你的按钮是工业级微动开关,抖动时间可能短到 1 毫秒,10 毫秒就够;如果是廉价的薄膜按键,可能要 30 毫秒。
技巧二:语义相同的事件要合并,不是排队。距离传感器在障碍物靠近时会连续输出一连串距离变化事件,业务侧真正关心的可能只是“最近 50 毫秒内的最小距离”。这时候应该做一个“合流窗口”:窗口内收到多条同类事件时,只保留最极端的那条(最小值或最大值),窗口结束时发布一条聚合事件。这能把事件量降一到两个数量级,而且不损失决策所需的全部信息。
技巧三:给事件源加一层“健康心跳”。事件驱动有个天然盲区——如果设备静默了,你分不清它是“状态没变化”还是“驱动挂了”。解决办法是让每个驱动定期发布一条心跳事件,业务侧维护一个“最后活跃时间”表,超过阈值没收到就告警。阈值一般取心跳周期的 3 倍,这个 3 倍是经验值,对应的是允许连续丢 2 次心跳仍不误报。
提示:心跳事件本身也会占用分发带宽,所以周期不要设太密。对于 20Hz 以上的高频驱动,心跳用 1Hz 就足够,因为它要证明的是“我还活着”,不是“我很忙”。
3.2 技巧四至六:让处理函数抗住真实环境的冲击
技巧四:每个处理函数都要有超时。事件处理超时的杀伤力在于它会拖垮整个分发链路。给处理加超时最省事的做法是用context.WithTimeout包一层,超时后立刻返回并记录一次超时计数。超时值怎么定?先跑一周采集 P99 耗时,然后把超时设成 P99 的 3 倍。不要设成平均值的倍数,平均值没有参考价值,一个 P50 是 2 毫秒、P99 是 200 毫秒的处理函数,超时设 10 毫秒会把 1% 的正常请求误杀。
技巧五:处理逻辑必须幂等。网络重传、驱动重启、事件重放,任何一个环节都可能让同一条事件被执行两次。幂等的判定依据就用第 2.2 节说的那个自增序号——维护一个“已处理序号”的滑动窗口,窗口内见过的序号直接丢弃。窗口大小取并发处理数的 4 倍,太小会漏判,太大浪费内存。
技巧六:用显式状态机收拢分支。当你发现同一个设备的事件处理里出现了五六个互相嵌套的if,判断“当前是待机还是运行还是故障”,那就是该上状态机了。状态机的好处是把“在什么状态下忽略什么事件”这条规则集中成一张表,而不是散落在各个分支里。
| 当前状态 | 收到事件 | 动作 | 新状态 |
|---|---|---|---|
| 待机 | 启动指令 | 初始化执行器 | 运行 |
| 运行 | 障碍告警 | 减速并记录 | 避障 |
| 避障 | 障碍解除 | 恢复速度 | 运行 |
| 任意 | 急停 | 断电并锁存 | 故障 |
这张表一旦写出来,代码基本就是照抄。而且它能直接拿去和产品经理对齐需求,比讲代码有效得多。
3.3 技巧七至九:编排、背压与可观测性
技巧七:给事件分级,不要让急事排队等闲事。事件通道如果只有一个,那么日志上报和急停信号会挤在同一条队列里,这在任何场景下都不可接受。我的做法是分三条通道:控制类(最高优先级,深度小但响应快)、状态类(中等,可合并)、遥测类(最低,可丢弃)。三条通道各自配 worker 池,互不干扰。
技巧八:明确背压策略,别让系统悄无声息地死掉。背压就是“下游处理不过来时上游怎么办”。只有三个选项:丢(丢最旧的还是最新的)、降采样(合并成聚合值)、阻塞(反压到事件源)。选择依据是业务能否容忍信息损失。遥测通道丢最新的没问题;状态通道必须降采样而不是丢,因为丢中间状态会导致最终状态判断错误;控制通道只能阻塞,因为丢掉一条控制指令的后果比延迟严重得多。
技巧九:把可观测性做进事件层,而不是事后补。每条事件在进入业务队列时记录三个指标——入队计数、出队计数、处理耗时直方图。这三个数放在一起能立刻分辨出问题类型:入队远大于出队说明消费能力不足;出队计数追平入队但耗时直方图右移说明单次处理变慢了;两者都正常但业务表现异常说明事件根本没产生,问题在驱动侧。这种“三类问题一眼分清”的诊断能力,是事后加日志永远做不到的。
3.4 技巧十:优雅退出比优雅启动更重要
最后一个技巧,也是最容易被跳过的一个:设计退出路径。机器人应用通常是长期运行的,但一定会遇到重启、升级、故障停机。如果退出时不处理,正在队列里的事件会丢,正在执行的动作会中断在半路,最坏的情况是执行器停在了一个危险姿态。
退出流程我一般写成三步:第一步,停止所有设备的事件发布(调用各设备的Halt),保证不再有新事件进队;第二步,等待队列排空,设置一个最长等待时间,比如 5 秒,超时就放弃剩余事件并记录数量;第三步,把执行器归位到安全状态,然后才真正退出。这三步的顺序不能颠倒,先归位再排空队列,队列里的事件会在归位之后继续操作执行器,等于白归位。
注意:等待队列排空时,worker 不能再向队列里投递新事件,否则永远排不空。实践中要给每个 worker 加一个“正在关闭”的原子标志,关闭期间产生的衍生事件直接丢弃并计数。
4. 手把手:一个事件驱动机器人应用的完整落地过程
4.1 项目骨架与依赖分层
代码组织上我坚持一个原则:驱动层和业务层必须放在不同的包里,业务层的包不能 import 任何具体的平台适配器。这样业务逻辑可以在没有硬件的情况下编译、运行、测试,而这件事对迭代速度的影响是决定性的。
目录结构大致是这样:
robot-app/ cmd/ main.go // 只负责装配和启动 internal/ platform/ // 只有这里 import gobot 的平台适配器 adaptor.go // 适配器构造,含仿真适配器 devices.go // 设备注册表 events/ // 事件信封、事件总线、优先级通道 envelope.go bus.go dispatcher.go logic/ // 纯业务逻辑,不依赖任何硬件 statemachine.go workers.go telemetry/ // 指标与结构化日志main.go里的活儿应该少到一眼看完:构造适配器、构造设备、构造总线、注册订阅、启动机器人、等待退出信号、优雅关闭。任何超过 80 行的main都说明有逻辑放错了位置。
4.2 驱动注册与事件订阅的完整代码
先看事件信封。所有事件在进入总线之前都要包一层,这是保证后面所有排查手段可用的前提。
package events import ( "sync/atomic" "time" ) // Envelope 是所有事件在总线中的统一载体。 // Seq 由总线内部原子递增生成,用于幂等判断和乱序检测。 type Envelope struct { Topic string Source string Seq uint64 Timestamp time.Time Payload interface{} } var seqCounter uint64 func NewEnvelope(topic, source string, payload interface{}) Envelope { return Envelope{ Topic: topic, Source: source, Seq: atomic.AddUint64(&seqCounter, 1), Timestamp: time.Now(), Payload: payload, } }总线部分用三条不同深度的通道,各自有独立的 worker 池。深度参数先给个保守值,后面第 4.3 节讲怎么算。
package events import "sync" type Bus struct { Control chan Envelope State chan Envelope Telemetry chan Envelope wg sync.WaitGroup closing int32 } func NewBus(controlDepth, stateDepth, telemetryDepth int) *Bus { return &Bus{ Control: make(chan Envelope, controlDepth), State: make(chan Envelope, stateDepth), Telemetry: make(chan Envelope, telemetryDepth), } } // Publish 永不阻塞调用方超过一个极小的时间片。 // 满了就走降级策略,绝不反压到驱动层。 func (b *Bus) Publish(e Envelope) { switch e.Topic { case "control", "estop": // 控制类:宁可阻塞也要送达 b.Control <- e case "sensor", "state": select { case b.State <- e: default: // 状态类:满了丢弃最旧的一条再塞新的 select { case <-b.State: default: } select { case b.State <- e: default: } } default: select { case b.Telemetry <- e: default: // 遥测类:直接丢,只记计数 } } }订阅侧的写法要点在于“订阅函数里只做转手”。下面这段把按钮事件从驱动层转到总线,全程不阻塞:
func wireButton(btn *gpio.ButtonDriver, bus *events.Bus, source string) { btn.On(gpio.ButtonPush, func(data interface{}) { bus.Publish(events.NewEnvelope("control", source, "pressed")) }) btn.On(gpio.ButtonRelease, func(data interface{}) { bus.Publish(events.NewEnvelope("state", source, "released")) }) }装配阶段注意订阅顺序:先订阅,再启动。Gobot 的Start会启动所有设备的轮询和事件发布,如果先启动再订阅,启动瞬间产生的事件会因为没有处理函数而被静默丢弃。这个坑我踩过一次,表现是“第一次上电后 1 秒内的指令全部无效”,查了很久才定位到是启动顺序问题。
func main() { adaptor := platform.NewAdaptor("") // 传空字符串走仿真适配器 btn := gpio.NewButtonDriver(adaptor, "gpio2") bus := events.NewBus(64, 256, 1024) wireButton(btn, bus, "btn-a") robot := gobot.NewRobot("app", []gobot.Connection{adaptor}, []gobot.Device{btn}, ) dispatcher := logic.NewDispatcher(bus, 4) // 4 个控制 worker if err := robot.Start(); err != nil { panic(err) } dispatcher.Run() // 内部起 worker 池 platform.WaitForSignal() // 阻塞等待退出信号 robot.Halt() // 第一步:停止事件源 dispatcher.Drain(5 * time.Second) // 第二步:排空 platform.SafePose() // 第三步:执行器归位 }4.3 关键参数怎么算:采样率、缓冲深度、并发度
这三个参数几乎决定了整个系统的行为,但很多人是靠“先写个 100 试试”定下来的。给一套能算的方法。
缓冲深度。用利特尔法则的简化版:深度 = 峰值事件速率 × 可容忍延迟。假设传感器峰值 500 事件/秒,允许事件在队列里最多待 200 毫秒,那深度就是 100。再往上留 30% 余量,取 128。这个数不是越大越好——队列越长,事件在里面积压的时间越久,等你发现积压的时候,系统已经延迟了半秒以上。
并发度。并发度 = 单通道峰值吞吐 ÷ 单 worker 处理能力。如果峰值是 500 事件/秒,单个 worker 每秒能处理 200 条,那至少需要 3 个,取 4 个留余量。这里要注意 worker 数量不能超过 CPU 核心数的两倍,因为 worker 之间会争抢调度,开太多反而整体吞吐下降。
采样率。这个不该由你拍,应该由控制回路的带宽需求决定。按采样定理,采样率至少要高于信号最高频率成分的两倍,工程上取 5 到 10 倍更稳。一个典型的手势识别场景,人手动作的最高频率成分大约 10Hz,那采样 50 到 100Hz 就足够,再高只是徒增事件量。
| 参数 | 计算依据 | 典型值 | 过大/过小的后果 |
|---|---|---|---|
| 控制通道深度 | 峰值速率 × 可容忍延迟 × 1.3 | 64 | 过小反压驱动;过大延迟不可控 |
| 状态通道深度 | 同上,可放宽 | 256 | 过小频繁丢事件;过大内存浪费 |
| 遥测通道深度 | 允许大量丢弃,取最大 | 1024 | 过小丢数据影响分析 |
| 控制 worker 数 | 峰值吞吐 / 单 worker 能力 | 4 | 过少积压;过多调度抖动 |
| 采样率 | 信号最高频率 × 5~10 | 100Hz | 过低混叠;过高噪声放大 |
4.4 用仿真适配器搭一条本地调试回路
事件驱动的应用在没硬件的时候极难调试,因为你没法手动制造“设备在第 300 毫秒突然掉线”这种场景。解决办法是自己写一个仿真适配器,实现和真机一样的接口,但内部由脚本驱动。
type SimAdaptor struct { name string events chan platform.SimCommand readings map[string]int } func (s *SimAdaptor) DigitalRead(pin string) (int, error) { return s.readings[pin], nil } // Inject 用于在测试中人为制造输入变化 func (s *SimAdaptor) Inject(pin string, value int) { s.readings[pin] = value }有了它,你可以写一个测试,注入一串“按下、抖动三下、保持、释放”的电平序列,然后断言总线最终只收到了 2 条语义事件而不是 8 条。这种测试跑一次 10 毫秒,能在 CI 里天天跑,比手动按按钮靠谱一万倍。仿真适配器还有一个附加价值:它能精确复现“事件风暴”场景,比如 1 秒内注入 5000 条事件,看看你的背压策略是不是真的生效了。
5. 常见问题与排查实录
5.1 事件丢失、重复与乱序的三条定位路径
事件丢失的第一嫌疑人永远是“没订阅就启动”。第二嫌疑人是缓冲通道满了之后的静默丢弃。定位方法很直接:在总线入口和出口各打一个计数器,两个数的差值就是“在途事件数”,如果这个数长期贴着通道深度上限,说明消费能力不足,丢事件是必然的。
重复事件的根源通常在驱动侧。有些轮询型驱动在两次读取之间状态抖动,会连续发布两次相同语义的事件。如果你确认底层电平没有变化,那就是驱动的去抖逻辑没覆盖到。对策就是在业务侧用第 3.2 节的序号窗口兜底,别指望驱动侧百分之百干净。
乱序只在多 worker 并发消费同一个通道时出现。如果你用 4 个 worker 从同一个通道取事件,处理时间有长有短,后面的先完成、前面的后完成,业务看到的顺序就乱了。解决办法是按来源做分区,同一个设备的事件永远交给同一个 worker 处理,这样设备内部的事件顺序就有保证,设备之间的乱序通常不影响业务。
提示:分区 worker 的实现很简单,用
hash(source) % workerCount选 worker,给每个 worker 配一个独立通道。代价是可能出现某个 worker 忙死、另一个闲死,所以分区键要选分布均匀的字段。
5.2 常见问题速查表
| 现象 | 最可能原因 | 快速验证 | 处理方式 |
|---|---|---|---|
| 事件延迟明显增大 | 处理函数阻塞分发循环 | 在处理函数首尾打时间戳 | 改为只投递到通道,重活交给 worker |
| 事件偶尔整批丢失 | 通道满后静默丢弃 | 对比入队/出队计数 | 加大深度或提高消费并发 |
| 首条指令无效 | 先启动后订阅 | 调整装配顺序 | 先On再Start |
| CPU 持续高占用 | 采样率过高或事件风暴 | 看事件速率指标 | 降采样或加合流窗口 |
| 退出时执行器停在半路 | 关闭顺序错误 | 检查是否先归位再排空 | 改为停源、排空、归位 |
| 同一条指令执行两次 | 事件重放或驱动重发 | 查序号是否重复 | 加序号窗口做幂等 |
| 状态最终判断错误 | 状态通道丢了中间事件 | 检查是否丢弃了状态事件 | 状态类只做降采样不做丢弃 |
5.3 上线前的十项自检
在把应用放到真实设备上跑之前,我会过一遍这张清单,每一条都对应一次真实的翻车经历。
- 所有订阅是否都在
Start之前完成。 - 每个处理函数是否都有耗时统计,并能拿到 P99。
- 控制通道是否有可能被遥测事件污染。
- 通道满时的降级行为是否被单元测试覆盖过。
- 是否存在无缓冲通道,且生产者可能被阻塞。
- 每个设备的最后活跃时间是否被监控。
- 关机流程是否按“停源、排空、归位”的顺序执行。
- 仿真适配器能否复现最常见的三种异常输入。
- 事件速率突增十倍时,系统是降级还是崩溃。
- 日志里是否包含来源、序号、时间戳三要素。
这十条里我最看重第八条。能复现的异常才是可修复的异常,不能复现的问题只能靠猜,而猜测在机器人这种软硬耦合的系统里往往是错的。花一天时间把仿真回路搭好,后面每次改逻辑都能省下几小时的现场调试。
最后分享一个我个人在实际操作中的体会:事件驱动的代码写起来很爽,但它的爽感来自“解耦”,而解耦的代价是“看不见”。同步调用你能顺着栈一路看下去,事件驱动你只能看到“事件进去了”和“结果出来了”,中间发生了什么全靠你埋的指标和日志。所以每加一条订阅,我都会顺手加一个计数器和一个耗时直方图,加的时候觉得啰嗦,出问题的时候会感谢当时的自己。这套习惯我保持了几年,现在接手别人写的响应式应用,第一件事也是去数它的埋点密度,密度不够的,基本都能预见接下来会遇到什么问题。