代码的执行路径和入口
代码并不是从开始一直运行到结束,经历顺序、分支、循环的控制结构,经历函数、类和对象等各种封装就可以了,工具脚本是这样的,只执行一次,传入参数或者修改配置,然后就会执行或计算出你想要的结果,客户端和服务端的代码都是长期运行的,有阻塞、并发等概念,涉及到多线程甚至多进程。服务端的代码逻辑可能是在接收到请求之后再执行,或者某个时间自动执行,客户端也是一样,有的代码是用户操作触发了什么事件才会执行,而有的是定时的自动执行的。
定时任务的周期与复杂度
定时执行的任务根据有没有周期概念和周期的长短复杂度也不同,有的任务是周期性的以天为单位的定时执行,比如每天的数据和日志备份,有的任务只执行一次或者只是很短的周期,比如我们云课堂中纪律树的成长,下课的提醒。很多后端应用的框架都会提供定时任务的功能,比如eggjs的schedule,比如java生态的quartz。这些是周期比较长的定时任务,因为服务器是不会停止的。客户端用到的定时任务一般周期都较短,框架或者运行平台提供的也都比较简单,比如浏览器自带的setInterval、setTimeout,egret的Timer等。
我们封装的定时任务框架TimeReminder
我们产品所面向的场景是教室中的一次上课过程,其中有一些需要定时任务的地方,比如下课的提醒,比如纪律树的自动生长。这些任务可以单独去定时,但是这样太过散乱,不易于管理和维护,所以我们封装了一个定时任务队列的库,叫做TimeRemider,功能是添加一个时间和该时间要执行的任务,到时间后会自动执行,这和直接用setInterval或者setTimout的区别有两个:
- 把所有定时任务集中到了一起来管理
- 定时器部分通过子进程的方式来提升性能
这部分功能比较独立,因此我们把他抽取成了一个单独的项目,以一个node module的形式来被我们业务的项目所使用,通过版本号的更新来迭代升级。
TimeReminder中存在的问题
最近我在读这部分的代码,对定时功能实现的方式、代码职责和结构的划分、暴露出去的api还有配置方面都有一些自己的看法。
定时功能的实现方式
time-reminder的定时器是用setInterval来实现的,通过定时轮询,每次轮询取出任务队列中最近的一个任务,判断是否需要执行,如果需要执行,则通知主进程执行这个任务,如果任务过期则删掉该任务。
因为轮询是有一定间隔的,所以这里需要判断当前时间是否在这个轮询周期的时间段内。另外,这里的offsetTime是和服务器时间的差值,项目中请求都会在接收到响应之后根据服务器的时间来更新这个offset,我觉得这里只要在其中一个接口校准一次就好了,因为服务器的时间也不会手动调整。
这里的实现方式是基于setInterval,所以才会有定时的轮询和时间段的计算。其实这里用setTimeout也可以,唯一有问题的是offset更新的问题,setInterval在下一个轮询的计算时就能感知到更新,而se'tTimeout感知不到,需要在offset更新后,手动的去clear掉所有的timer,然后基于新offfset算出的时间重新setTimeout。两种方式各有优缺点。
代码的职责和结构的划分
定时任务涉及到定时器和任务队列两个方面,
现在的版本中定时器是基于setInterval来实现的,考虑到性能,把他放到了子进程中去(我们是基于electron做的客户端,可以使用node),而主进程负责任务队列的维护和任务的执行。现在的目录划分很简单:
index.js是主进程的代码,主要是任务队列的维护和定时器的启动、停止等。core/adjust-timer.js是子进程的代码,里面是定时器的轮询和在检测到有任务要执行时通知主进程执行的逻辑。
代码职责方面我觉得是有问题的,如上面的架构图,我觉得定时器应该是纯粹的,只有基于setInterval的根据设定的时间不断轮询,或者基于setTimeout的定时通知的逻辑,而不应该包含判断任务队列是否有要执行任务的逻辑。而现在这部分逻辑是直接写在定时器里的。这样不但使得不能透明的从setInterval替换成setTimeout,也使得将来如果要适应更多平台(不支持node的进程)的成本增加。
合理的架构应该是多层次多模块的,层与层之间单项依赖,模块与模块之间职责明确,基于抽象的约定来通信,这样才能做到可以灵活的替换实现方案,比如把setInterval替换成setTimeout,比如把进程的方式去掉。
如图,至少应该有2个层次,定时器和任务队列是底层实现功能的部分,主进程和子进程的代码是node环境下的适配方式,然后再提供一个index.js暴露全局api。
这样的架构和对应的目录结构是易于扩展和替换实现方案的,vue在3.0中把observer独立成顶层文件夹,就是为了替换成proxy更方便,这里也是一样。
暴露出去的api
实现功能之后要暴露出一些api去,供外部使用,暴露出去的api对应着定时器和任务队列,也有两部分,一部分是添加、删除、清空定时任务的,一部分是启动、停止定时器以及修改计时offset的。
现在暴露出去的api如下:
addTimeListener
removeTimeListener
hasOwnId
clear
start
stop
getCurrentServiceTime
updateOffset
复制代码
8个api前3个是定时任务的,后5个是定时器相关的,但是从名字上不能明确的区分出各自的功能,我觉得如下的命名会更好一些;
addTimedTask
removeTimedTask
clearTimedTask
startTimer
stopTimer
setTimeOffset
复制代码
getCurrentServiceTime是获取当前服务器时间的,虽然在请求响应的时候设置到了这里,但是这并不是定时任务的功能,不应该放到这里面,可以在响应的时候再保存一份到别的地方。
配置
定时任务中有很多可以配置的地方,比如扩展成多平台之后的平台选择,比如定时器setInterval和setTimeout两种实现的选择,比如是否打印日志等。
可以像eslint、babel等提供一个配置文件放在项目下,支持json等配置方式,可以叫timerTaskQueue.config.js。
module.exports = {
log: false,//是否打印日志
platform: 'node',//使用定时任务的平台
timer: 'interval'//定时器的实现方式
}
复制代码
甚至可以提供插件扩展的机制或者一系列内置的功能供用户自己去选择。
其他的问题
代码中还有很多命名和实现的具体问题:
比如分了handlerList和taskList两部分,本意是handler可以复用,但是却没有提供复用handler的合理机制,像提供handler的name注册机制等。
比如taskList中的task如果一个time有多个任务,会组织成如下的结构,我觉得这个也是没有必要的,扁平化的放多份就可以,这样组织还有维护成本。
{
time: 2323232
ids: [id1,id2]
}
复制代码
总结
请求、定时任务、事件都是代码触发的方式,或者说执行的入口,定时任务根据周期的长短复杂度也不同,后端或者客户端的框架都提供了定时任务的功能(eggjs、quartz、egret、web等)。
我们的项目为了集中管理定时任务,封装了一个定时任务框架叫time-reminder,提供定时器和任务队列两方面的功能。因为是node平台,考虑到性能使用了子进程的方式,并且定时器的实现是setInterval。我提出了一些重构的思路,包括代码架构和目录结构的调整、支持配置、改进暴露出去的api,以及一些代码的细节问题。
真正做一个通用的东西,和做只能适应一种业务场景的东西是完全不一样的,我们既然把他抽取了出来,就要使得它更加的通用,完善的差不多之后可以考虑开源,到时候一定要支持多平台、支持配置、暴露的顶层api更加优化,甚至提供插件功能。同时书写文档、demo和测试用例。会继续完善下去。