ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

深入理解回调函数与观察者模式:事件驱动编程的核心机制与实践

深入理解回调函数与观察者模式:事件驱动编程的核心机制与实践 1. 回调函数先搞懂事件驱动里那个最核心的“电话”1.1 回调函数是什么从一次“你忙完再联系我”开始做后端开发这些年我发现一个挺有意思的现象很多新人对同步代码非常熟但一碰到回调函数、观察者模式、监听器这些概念就容易发怵。其实它们背后都是同一件事——这段代码不该自己主动往下走而是在等某个事件发生后再被调用。理解了这一点回调、监听、单观察者、多观察者这些词就不会再绕晕了。我一般会给新同事打一个比方回调函数就像你给维修师傅留了一个电话号码。你跟师傅说“修好了给我打个电话”然后你就去忙别的事了。师傅不会在你面前盯着你看也不会等你一直坐在那儿他只是在修完之后、某个关键时间点主动拨通你留的那个号码把结果告诉你。这里的“电话号码”就是回调函数师傅“修完之后拨电话”这个动作就叫触发回调。放到代码里回调用一句话概括把一段可执行代码函数、方法、lambda作为参数传给另一个函数或对象让它在合适的时机反过来调用这段代码。最常见的一段代码就是数组排序Java里Arrays.sort可以传一个ComparatorPython里sorted可以传一个key函数C里std::sort可以传一个比较函数。排序函数本身并不知道你的业务规则它只负责“排序”这件事而具体两个元素谁大谁小它是通过调用你传入的那个函数来确定的。这就是标准的控制权反转不再是你的代码主动去调用工具类而是工具类在特定节点上反过来调用你的逻辑。明白了这一点下面所有内容都好办了。回调不是一种高级黑魔法它只是一条“在合适时机打给你”的约定。1.2 同步回调与异步回调这两个一定要分开记回调函数根据触发时机可以分成两类同步回调和异步回调。这两者的差异非常重要因为它直接影响你后面排查问题的思路。同步回调比较好理解回调在函数返回之前就会被调用调用方会一直等在那里。你给sort传入的Comparator就是一个同步回调排序过程中每比较两个元素都会立刻执行你的compare方法sort没跑完后面的代码就执行不到。同步回调的优点是非常好排查代码一步一步走异常也能正常抛出来栈信息完整。缺点是如果回调里写了耗时操作整个调用链都会被拖住。异步回调则不同。函数发起某个操作后立刻返回不会等结果当事件真正发生时回调在未来的某个时间点由另一个线程或事件循环来调用。比如你发一个网络请求代码发起请求后马上继续执行下面的逻辑等网络响应回来了那个处理响应的函数才会被调用。这就是网上很多人说的“两段式回调”第一段发起异步操作第二段在回调里继续处理结果。其实它和普通回调的区别就是时机不同一个在同一个调用栈里等你一个放在事件循环里喊你。我见过不少刚入行的同事在异步回调里直接抛异常想当然认为能被外层try-catch接住。这基本是不成立的因为异步回调执行的时候外层那些代码早就执行完了栈早就不在了。说白了就是同步回调可以当普通函数调用看待异常可以往上传异步回调要学会自己接管异常处理不能指望有“外部调用者”帮你兜底。对比项同步回调异步回调调用时机函数返回前触发事件发生后、未来某个时刻触发所在线程与调用方相同可能不同线程或事件循环异常传播能被外层捕获通常不能被发起方捕获典型场景排序比较器、遍历访问器网络请求、定时器、支付回调1.3 不同语言里的回调写法其实都是在做同一件事有些同学的语言基础不深一看到“C中注册回调机制”这类问题就发懵。其实不管哪种语言写回调的核心动作只有两个注册和触发。注册是把你的回调交给别人触发是别人在合适的时候调用它。C里的传统写法是函数指针但它有两个局限一是它只能指向普通函数或静态函数不方便捕获上下文二是语法看起来比较劝退。现在C项目里基本都会用std::function加lambda表达式代码干净很多。下面是一个很典型的使用场景#include functional #include iostream class Button { public: // 注册回调把外部传入的可调用对象保存起来 void setOnClick(std::functionvoid() cb) { onClick cb; } // 模拟按钮被点击触发回调 void click() { if (onClick) onClick(); } private: std::functionvoid() onClick; }; int main() { Button btn; btn.setOnClick([]() { std::cout 按钮被点击了 std::endl; }); btn.click(); // 触发回调 return 0; }这段代码里std::function就是那个“号码”它可以存放普通函数、lambda、成员函数等各种可调用对象。注册回调的本质就是把执行逻辑从Button内部拆出去Button根本不需要关心点击之后具体要干什么它只在click的时候把保存的动作执行一遍。Python写回调更直接函数本身就是一等公民直接传函数对象就可以了def send_after_finish(result): print(f任务完成处理结果: {result}) def do_something(callback): # 模拟一个耗时任务 result 10 20 callback(result) # 任务结束后回调 do_something(send_after_finish)JavaScript就更不用说了从早期的setTimeout到现在的Promise整个语言都是建立在回调之上的。C、C、Python、Java、JavaScript这些语言语法差得很远但回调的思想完全一致。只要你能在自己熟悉的语言里把“注册”和“触发”这两个动作写清楚回调这关就算过了。2. 观察者模式从单观察者到多观察者的关键一步2.1 什么时候单个回调不够用回调机制解决的是“某件事发生后通知某个人去做某事”。但实际业务里一件事发生后往往要通知一堆人去做事。这时候如果只靠单独的回调代码很快就会变丑。举个例子用户下单成功后系统要发短信、更新库存、把订单数据推送给财务系统、给用户加积分。如果采用简单回调的思路你可能会在下单成功的地方这么写orderService.createOrder(order); sendSms(order); inventoryService.reduceStock(order); financeSync(order); userService.addPoints(order);下单接口每新增一个通知需求就要改一次这段代码。三个月后这段代码会从5行变成50行而且每个模块之间全是硬编码依赖。更麻烦的是如果你今天想给这个订单加一个“发放优惠券”的动作你就得动下单这部分的代码这违背了“开闭原则”对扩展开放、对修改关闭。观察者模式就是专门来解决“一对多通知”的问题。它背后的核心思想仍然基于回调只是把“一个回调函数”升级成“一组回调接口”把“直接调用某个具体模块”升级成“向所有登记过的观察者广播事件”。这也是为什么标题里写“单-多观察者模式”——单观察者的时候一个回调就够了当观察者数量不确定、随时可能增加时就要切换到观察者模式。2.2 观察者模式里那四个角色怎么分工观察者模式不是玄学它就四个角色理解之后会非常清晰。第一个角色叫主题Subject也叫被观察者。主题负责维护观察者列表并提供注册、移除、通知三个基本操作。第二个角色是观察者Observer它是一个抽象接口里面通常只有一个方法比如update或者onEvent代表“收到事件后要做的事”。第三个角色是具体主题比如订单服务就是具体主题它知道订单什么时候创建成功。第四个角色是具体观察者短信服务、财务系统、积分服务都是具体观察者它们各自实现update方法收到事件后各干各的。这里要特别注意观察者接口里面那个update方法本质上就是一个回调方法。主题在事件发生时逐个调用观察者的update方法其实就是循环触发回调。所以完全可以这样理解观察者模式 一组回调函数的集合化管理。2.3 手写一个支持多观察者的最小实现很多框架里都内置了观察者模式但为了搞清楚内部逻辑咱们自己手写一个最小版本。我用Python写代码短看起来直观。假设这里是订单模块事件是订单创建成功。from abc import ABC, abstractmethod class Observer(ABC): abstractmethod def update(self, order_id: int) - None: pass class SmsObserver(Observer): def update(self, order_id: int) - None: print(f发送下单成功短信订单号: {order_id}) class FinanceObserver(Observer): def update(self, order_id: int) - None: print(f同步订单数据到财务系统订单号: {order_id}) class PointsObserver(Observer): def update(self, order_id: int) - None: print(f用户加积分订单号: {order_id}) class OrderSubject: def __init__(self): self._observers [] def attach(self, observer: Observer): self._observers.append(observer) def detach(self, observer: Observer): self._observers.remove(observer) def notify(self, order_id: int): for observer in self._observers: observer.update(order_id)使用时的流程是这样的先创建主题然后把观察者依次attach进去在下单成功后调用notifyorder_subject OrderSubject() order_subject.attach(SmsObserver()) order_subject.attach(FinanceObserver()) order_subject.attach(PointsObserver()) # 订单创建成功后一行代码通知所有观察者 order_subject.notify(1001)当输出三行日志就说明三个观察者都收到了通知。以后如果产品说还要加一个“赠送优惠券”不用再动下单核心逻辑只需要新建一个CouponObserver并attach进去就行。这正是观察者模式的价值主流程稳定不动新功能通过扩展实现。需要提醒的是上面的简单实现里notify是顺序执行的要是某个观察者的update方法抛了异常后面的观察者就会全部中断。真实项目里往往要加异常隔离、线程池、事件总线这些机制后面第4章会详细说。3. 真实项目中的观察者模式与回调机制怎么落地的3.1 Spring事件机制后端项目最实用的观察者模式Java后端开发里观察者模式最常见的落地形态就是Spring的事件机制。很多同学虽然听过ApplicationEvent、EventListener这些名词但一直没把它们和观察者模式联系起来。其实Spring事件机制的底层就是一个标准观察者模式ApplicationEventPublisher负责发布事件对应主题EventListener注解标注的方法对应观察者。举个例子用户注册成功后想触发一堆后续动作发欢迎邮件、送新人券、初始化用户配置。先定义一个事件类public class UserRegisteredEvent extends ApplicationEvent { private final User user; public UserRegisteredEvent(Object source, User user) { super(source); this.user user; } public User getUser() { return user; } }然后在注册服务里发布事件Service public class UserService { Autowired private ApplicationEventPublisher publisher; public void register(User user) { // 执行注册逻辑 saveUser(user); // 发布事件通知所有观察者 publisher.publishEvent(new UserRegisteredEvent(this, user)); } }在各业务模块里写监听器Component public class WelcomeMailListener { EventListener public void onUserRegistered(UserRegisteredEvent event) { sendMail(event.getUser().getEmail()); } }这样写最大的好处是什么注册的核心流程完全不需要知道“注册之后有哪些动作”每新加一个动作只需要增加一个监听器。我见过很多项目里把监听器写得极其克制一个监听器就只干一件业务事邮件、积分、报表、消息推送全部拆开主流程代码短得吓人。改动的时候也很安全不会出现“为了加个积分把注册接口改出线上故障”的情况。要注意一个细节Spring默认的事件监听是同步执行的publishEvent发布之后主线程会一直等监听器执行完才返回。如果一个监听器里做了慢查询或者调用外部接口用户注册接口就会变慢。这时候可以考虑给监听方法加Async让监听器异步执行。不过异步之后事务边界就变了主流程的事务提交前异步线程可能已经执行了监听器导致读不到未提交的数据。这个坑我在项目里踩过不止一次后面排查那章会单独说。3.2 支付回调这种业务回调本质就是一次异步回调支付宝回调、微信支付回调这些词在热搜里很常见其实它们对应的是支付平台主动往你服务器发请求熟悉这块的朋友都知道第三方支付平台在你发起付款后会在用户支付完成时通过服务器异步通知你的接口。这个机制本质上是一条非常典型的外部异步回调。支付回调之所以让很多新手头疼不是回调本身多难而是它的可靠性要求很高第三方支付平台不信任你的服务一定会正确处理通知所以它会不断重试直到你明确告诉它“我处理好了”。以微信支付为例回调接口要做以下几件事第一验签。微信支付的回调通知报文里带了签名信息你必须用平台证书验签确认这条通知确实是微信支付发来的防止别人伪造回调。第二解密报文。微信支付部分场景的回调数据是加密的需要按照规范解密。第三处理业务判断订单状态如果是“支付成功”就把本地订单状态改掉、发货、加积分。第四幂等处理。同一个支付通知可能会发送多次你的业务代码必须能识别出“这个订单已经处理过了”不能出现重复发货、重复加积分。第五返回通知结果。微信支付要求你在收到通知后处理成功要返回JSON报文里“code”为“SUCCESS”处理失败就返回“FAIL”它会继续重试。有些同学处理支付回调时只写了正常流程忘记做幂等结果线上收到一次重试就重复发货。这个问题的根源在于把外部回调当成普通同步请求来写——同步请求没有网络重试的顾虑而异步回调天然带有“随意重试、乱序到达”的特性。记住一句话凡是外部系统主动调用你接口的场景默认都不信任你的处理结果你必须在代码里自己保证“处理一次”和“处理十次”的结果完全一样。3.3 Python里的回调与监听日志框架就是一个现成例子Python开发者平时接触回调的机会也很多。最简单的就是logging模块它实现了一个很标准的观察者模式Logger相当于主题Handler相当于观察者。你可以给同一个Logger挂多个Handler比如一个FileHandler把日志写到文件里一个StreamHandler把日志输出到控制台一个自定义Handler把日志发给报警平台。每产生一条日志Logger就会把日志事件分发给所有Handler去处理这不就是一对多通知吗import logging logger logging.getLogger(my_app) logger.setLevel(logging.INFO) file_handler logging.FileHandler(app.log) console_handler logging.StreamHandler() logger.addHandler(file_handler) logger.addHandler(console_handler) logger.info(用户下单成功)这段代码里addHandler对应的就是观察者模式里的attach里面每个handler的emit方法就是观察者的update。后续想增加日志推送只需要再加一个Handler业务日志那一行代码完全不用动。Python的asyncio里也有非常典型的回调使用方式比如给Future添加done回调import asyncio async def worker(): await asyncio.sleep(1) return 42 async def main(): task asyncio.create_task(worker()) task.add_done_callback(lambda t: print(f任务结果: {t.result()})) await taskadd_done_callback就是注册回调等任务结束之后事件循环会调用这个函数。这是Python异步编程里特别常用的套路。PyQt、Tkinter这些GUI框架里更是把回调、监听发挥到了极致按钮点击、文本框变化、窗口关闭全是信号槽或者事件回调。可以说“监听”这个概念无处不在区别只在于有人把回调封装成接口观察者模式有人保持函数形态直接传参回调函数。4. 回调与监听模式最容易踩的坑我帮你列全了4.1 回调地狱嵌套层级深了怎么拆回调本身不复杂但人一旦贪方便就容易把回调一层套一层。最典型的就是早期JavaScript写异步请求A请求完成后要请求BB完成后要请求C代码写出来像这样request(urlA, function (resA) { request(urlB, function (resB) { request(urlC, function (resC) { // 处理最终结果 }); }); });三层还算能忍到了八层十层代码的可读性、可维护性就彻底崩了。这就是网上经常说的回调地狱。处理思路也很成熟把嵌套的代码压平做成链式调用。JavaScript现代方案用Promise和async/awaitJava里有CompletableFuturePython有asyncio和curioC#有async/await机制。但我要特意说一句回调地狱的根源不是代码长得难看而是你试图用回调去表达一套顺序流程。回调适合表达“某个事件触发某个动作”不太适合表达“做完这一步再做下一步”。当你发现自己需要把两个回调串联起来时可以先停下来想一想这段逻辑真的是事件驱动的吗还是只是两个有先后顺序的业务步骤如果是后者那应该用异步编排工具而不是继续堆回调。4.2 监听器不注销内存泄漏和诡异Bug的头号来源观察者模式有一个非常隐蔽的坑注册监听器的时候很顺手但移除的时候老是忘记。这个问题在GUI开发和Android开发里特别典型。举个场景一个Activity销毁了但你在onCreate里把监听器注册到了一个全局的事件总线上结果Activity销毁时没有在onDestroy里移除这个监听器。那么当事件发生时事件总线还是会调用已销毁Activity里的回调轻则产生内存泄漏重则触发空指针异常App直接崩溃。在Java里这属于“监听器引用持有了外部类对象”导致Activity无法被垃圾回收。解决这个问题有几条路子。第一条永远让注册和注销成对出现。凡是register就一定有个对应的unregister凡是attach就一定有个对应的detach。写代码的时候把这两个保护性注册/注销放在同一个代码块里或者紧挨着写注释提醒自己。第二条事件总线、观察者模式下尽量避免让监听器持有生命期比它更长的对象引用。如果必须持有考虑用WeakReference让垃圾回收器能回收无用的监听器。第三条Java里推荐使用监听器管理类比如事件总线的自动释放机制或者像Spring那样由容器管理Bean生命周期。自己手写的观察者列表一定要看看remove方法是不是忘了写。4.3 异步回调里异常处理和时间顺序问题异步回调最让人崩溃的就是异常处理。同步代码出错异常会沿着调用栈传播你可以在外层try-catch里拦住。异步回调出错外层早就返回了你即使用了try-catch包住发起异步调用的那段代码也接不住回调里抛出的异常。比如JavaScript里发起一个setTimeout里面抛异常外层try-catch完全没反应因为回调是在事件循环的下一个tick执行的。Java里如果回调用线程池执行异常会被线程池吞噬掉你没做UncaughtExceptionHandler的话连日志都看不到排查靠猜。我习惯的做法是进入回调函数第一件事就是包一个异常处理逻辑。回调里所有代码要么自己try-catch要么往外传一个“回调结果对象”里面包含成功标志、数据、错误信息。哪怕只打印一条日志也一定要让异常在日志里显性出现绝不静默吞掉。时间顺序问题也要注意。假设一个订单状态可能从“待支付”变成“已支付”然后又变成“已退款”如果这三个状态变化都发异步事件监听器执行顺序可能并不是发布顺序。比如退款事件先到支付事件后到监听器一处理订单被错误地改回“已支付”。这种问题尤其在消息队列消费场景里特别常见。解决办法是给事件带上版本号或者时间戳在监听器里做状态机校验只有合法状态流转才执行否则忽略或告警。问题现象可能原因排查思路回调里报的异常在日志里看不到异步线程异常被吞掉检查线程池的UncaughtExceptionHandler回调代码统一加try-catch监听器被调用了多次注册时重复attach检查主题里的observer列表是否存在重复元素打印列表长度辅助判断事件发布后监听器没执行事件类型不匹配或监听器未扫描到确认事件类继承关系确认监听器Bean被Spring加载确认EventListener方法参数类型Activity销毁后事件还触发崩溃监听器持有Activity引用onDestroy里移除监听器或使用弱引用包装支付回调重试导致重复发货缺少幂等处理在回调处理前先查询订单状态已经处理过的直接返回成功同步回调耗时太长拖慢主流程回调里做了慢操作观察者模式里用线程池异步执行或者拆成消息队列5. 到底该用回调还是观察者模式我的选型心得5.1 一张判断表帮你快速决策做了这么多项目我带过的新人总会问一个问题什么时候用回调什么时候用观察者模式我这里总结了一套非常简单的判断方法。回调是“1对1”的通知观察者模式是“1对多”的通知。如果你确定一个事件只会有一个接收者、并且这个接收者短期内不会变那么直接用回调最简洁。比如某个按钮点击后只更新一个状态没必要大动干戈搞事件总线。如果事件可能有多个接收者或者接收者数量不确定、以后可能会增加那就要用观察者模式。另一个判断维度是“要不要运行时增删”。回调一旦注册就是固定的除非你自己再写一套注册管理机制。观察者模式天生支持动态订阅和退订。如果你的业务需要用户手动开启/关闭某个通知那观察者模式更合适。还有一层是解耦程度。回调通常要求调用方知道“我在调用谁”因为你要直接创建回调对象。观察者模式里主题只依赖Observer接口具体是短信SmsObserver还是积分PointsObserver主题完全不关心。所以模块之间要还是不要强依赖这是一个明确的分界线。我的建议是如果你们的团队还在频繁改业务主流程那大部分情况下你应该把通知机制抽象成观察者模式省得后面每个需求都要动核心代码。判断维度用回调用观察者模式通知关系1对11对多接收者数量固定、明确动态、可能增加解耦程度双方有直接引用只依赖抽象接口实现成本低传个函数即可略高需要接口和注册管理适合场景排序、简单事件、一次性处理业务事件广播、跨模块通知、插件化扩展5.2 一个真实的重构案例从回调改成观察者模式之后我去年参与维护过一个注册中心类的老项目里面的逻辑就是用回调写死的。原始代码大概是这样的public class RegisterService { public void register(User user) { // 保存用户 userDao.save(user); // 发短信 smsService.sendWelcome(user.getPhone()); // 送积分 userService.addPoints(user.getId(), 100); } }第一次加需求产品说注册完要送几张优惠券好办在register方法里加一行。第二次说要发站内信通知。第三次说要推送给CRM。我亲眼看着register方法从三四行膨胀到二十多行各种服务在注册逻辑里串成一串改一个地方要担心另外几个地方受影响。后来我花了一个下午把它重构成Spring事件模式。定义一个UserRegisteredEvent类register方法里只做两件事保存用户、发布事件。短信、积分、站内信、CRM监听全部拆到各自的Listener类里。重构完register方法短了很多而且新增通知需求的时候直接新建Listener不需要碰register方法。更舒服的是测试也变好写了每个监听器都能单独写单元测试不会再为了测一条短信逻辑去拉起整个RegisterService。这个案例带来的不是代码量减少而是职责边界变清晰了。注册流程关心“用户注册了”其他模块关心“用户注册后我要做什么”。后来项目里陆续加了营销、推荐、审计这些模块主流程的注册代码一行都没改全是新加Listener来扩展的。我个人在实际操作中体会特别深的一点是改代码之前先想清楚这个通知是1对1还是1对多。如果是1对1老老实实写回调代码短、跑得快如果心里有一丝丝“以后可能要加新的接收方”的预感就直接上观察者模式。判断错了也别怕重构的成本其实比想象中低关键是要把主题和观察者的接口定义干净。只要Observer接口稳定后面无论加多少个观察者核心代码都不会乱。
返回列表