CORDISDSH 的心脏第 04 章 · 思想一 · 回执全书目录术语表读论文原文 ↗
第四章 · 思想一 · 可逆效应

每个改动,
当场交出撤销办法

幽灵任务不是因为作者粗心,而是因为「装」和「撤」被写在了两个地方。论文的第一个思想很简单:做一件事,当场留一张回执。

约 8 分钟 · 上一章 03 一页纸 · 下一章 05 思想二 · 插座

题图每个改动,都留下一张回执。

幽灵从哪里来

回到小周的第一个坑。旧天气插件卸掉以后,它挂的定时任务还在每小时跑一次。不是小周不认真,是写法本身在给人挖坑。

大多数插件系统让你写两个函数:装的时候调一个,卸的时候调另一个。装的时候开了三样东西,卸的时候就得记得关三样。两份清单分开写、分开改,全靠人脑对齐。今天加了一个定时器,忘了在另一个函数里补一行,幽灵就诞生了。论文把这叫作「创建和清理被拆开,违反了关注点的就近原则」。

论文的办法:做一件事,交一张回执

论文的第一个思想叫可逆效应:每一个对外部的改动,在发生的当场就附带一个撤销办法,交给运行时保管。

注意两个词。当场:撤销办法和改动写在同一处,不可能只写一半。运行时保管:撤销办法不交给作者自己记,而是由框架收着,到时候框架来执行。

老写法装和撤分两处
// 装的时候function activate() {  db = openDatabase()  bot.on('message', onMessage)  timer = setInterval(check, 60000)}// 卸的时候,另写一份function deactivate() {  bot.off('message', onMessage)  db.close()// 少了 clearInterval(timer),幽灵诞生}
漏掉哪一行,编译器不会提醒你,测试也很难测出来。
Cordis 写法(示意)当场交回执
export function apply(ctx) {ctx.effect(() => {  const db = openDatabase()  return () => db.close()   // 回执})  ctx.on('message', onMessage)  ctx.setInterval(check, 60000)}// 没有卸载函数。// on、setInterval 都走 ctx,// 回执由框架自动登记。
想漏也没地方漏:不交回执,这件事就没法通过 ctx 做。

看不懂代码没关系,看两边的形状就够了:左边有两个函数,右边只有一个。

回执串:倒着撕

一个插件装上时往往要做好几件事。运行时把它们的回执按顺序串成一串,论文叫累加器,本书叫回执串。卸载时只做一件事:从最新的那张开始,倒着一张张执行。

演示 4.1 · 可以点回执串:倒着撕,一张不落
    插件
    提醒插件未装
    按时发出提醒
    天气插件在岗
    邻居,和提醒无关
    系统里多出来的东西
    (空)
    回执串 · 新的压在上面
    (空)
    旁白这次换成提醒插件。它装上时要做三件事,而且后两件都要用到第一件打开的数据库连接。旁边还有个天气插件,看它会不会被连累。
    按主按钮一拍一拍往下走

    倒着撕不是随便定的。后做的事经常要用先做的事:先开连接,再在连接上挂监听。如果先关连接,后面那些还没撤的东西就会撞上一个已经关掉的连接。倒序,正好让每件事在撤销时,它依赖的东西都还在。

    半路失败也一样好办。装到第三步出了错,回执串里只有前两张,运行时就只撤这两张。不会多撤,也不会漏撤。

    拼起来,还是可撤销的

    回执串有一个很省心的性质:两个可撤销的东西拼在一起,还是可撤销的。

    图 4.1 · 套娃也不怕

    一整串,可以当一张用

    一个插件可以在自己里面再装子插件。子插件有自己的回执串;从父插件的角度看,这一整串只算一张回执。

    卸掉父插件时,撕到这一张,就把子插件整串倒着撕完,再接着撕父插件自己的。嵌套多少层都一样,没有一层需要额外写清理代码。

    这也是 Koishi 的新手作者不写卸载代码也能干净退场的原因 p.69:只要事情是经过 ctx 做的,回执就自动在串上。

    它不保证什么

    可逆效应很强,但有两个边界,论文自己写得很清楚。

    第一,撤销办法写得对不对,框架不检查。你交了一张回执,框架就照着执行;回执本身写错了,框架发现不了。这是作者的义务 p.59。

    第二,只有经过 ctx 的事才有回执。插件绕开 ctx,自己偷偷改了一个全局变量,框架根本不知道这件事发生过,自然也撤不了。这正是第 06 章要讲的「一扇门」。至于已经发给用户的消息、写到磁盘上的文件,那是另一种撤不回,第 10 章再说。

    带走这一句

    撤销办法不许另写:做事的当场交出来,由运行时保管,卸载时倒着执行。

    请检查并重构这个项目里所有「注册类」操作(事件监听、定时器、命令、路由、全局状态写入):
    1. 每个注册操作都在同一处返回一个撤销函数,不要另写集中式的卸载函数;
    2. 用一个列表按顺序收集这些撤销函数,卸载时按相反顺序依次调用;
    3. 如果安装进行到一半失败,只调用已经收集到的撤销函数;
    4. 改完后,列出原来漏掉撤销的地方。