如何优雅地管理你的定时任务?

在日常开发中,我们经常会使用一些定时任务来辅助完成某些事情。对此,绝大多数人都会选择使用 crontab 来配置定时任务。

不可否认,crontab 的确是管理定时任务的经典利器,但是你是否和我一样,踩过不少 crontab 的坑呢?

下面,我将介绍一下个人认为 crontab 的一些痛点和坑。最终,给出另一种优化的解决方案。

crontab

提到 crontab,这里必须要介绍一下它的配置规则,如下所示。

1
2
3
4
5
6
7
.---------------- 分 (0 - 59)
| .------------- 时 (0 - 23)
| | .---------- 日 (1 - 31)
| | | .------- 月 (1 - 12)
| | | | .---- 星期 (0 - 6) (星期日可为0或7)
| | | | |
* * * * * 执行的命令
crontab 的配置规则可以分为 5 列,其作用分别是:

  • 第一列单位为分,表示每时第几分钟,范围为 0-59
  • 第二列单位为时,表示每天第几小时,范围为 0-23
  • 第三列单位为日,表示每月第几天,范围为 1-31
  • 第四列单位为月,表示每年第几月,范围为 1-12
  • 第五列单位为星期,表示每星期第几天,范围 0-7,0 与 7 表示星期日,其他分别为星期 1-6

整体而言,crontab 对于不同的单位(除了星期),均支持了三种配置规则:

  • 指定时间
  • 指定范围
  • 指定步长

通过组合这些配置规则,crontab 可以实现非常多的定时配置。

在使用 crontab 很长时间之后,我发现 crontab 还是存在着一些使用痛点的,主要有以下几点,下面分别进行介绍。

输出重定向

默认为情况下,crontab 会将任务输出默认写入到执行用户的邮件中。如果任务有大量输出,则会大量占用磁盘资源,甚至导致系统宕机。

如下所示,我们配置一个输出当前日志的定时任务。

1
* * * * * date

我们可以查看当前用户的邮件,如下所示。

1
2
3
$ cat /var/mail/$USER
...
Tue Aug 1 22:11:22 CST 2023

关于这个问题,实践经验都是建议采用如下的方式对任务的输出进行重定向。很显然,这对于新手是非常不友好的。

1
* * * * * date >> /dev/null/ 2>&1

环境变量

在实践中,我们可以发现 crontab 的环境变量与控制台的环境变量是存在差异的。因此,经常会出现这样的情景:在控制台中调试完成的任务,在 cron 中执行时,其结果会与预期不相符。事实上,产生这种差异的根本原因就是环境变量。

此外,crontab 中环境变量不会全局共享。因此,当我们配置多个任务时,可能需要为每个任务单独配置环境变量。很显然,这是一个重复而又繁琐的问题。

规则语法

关于 crontab 的规则语法,这是个仁者见仁智者见智的问题。对于老手来说,可能比较简单;对于新人来说,在使用时得去查询各个位置的单位以及不同规则的写法。我觉得 crontab 的规则语法不容易理解的根本原因是缺少语义。如果能优化其规则语法的语义,那就更好不过了。

另一方面,对于某些极客来说,crontab 的规则可能还不够完备。比如:预期一个定时任务从某个时刻开始或停止执行,或者,预期一个任务循环执行 n 次后结束。对于这种规则,crontab 无法一次性满足,只能通过配置多个任务来辅助完成。

运行日志

在实际应用中,我们经常需要借助任务的运行日志来排查问题。此时,我们就需要修改 crontab,将任务的输出重定向至某个文件,从而方便后续进行查看。当任务非常多的时候,我们很难记住每个任务对应的日志文件是哪个。这也是 crontab 的一个痛点。

taskloop

为了解决 crontab 的诸多痛点,我在业余时间开发了一款优化版的定时任务管理器——taskloop

taskloop 底层运行在 cron 守护进程之上,基于 crontab 配置了最小粒度的调度规则,实现了一个中间层,从而解决了 crontab 的诸多痛点。

命令

taskloop 提供了一系列的命令,实现了一个相对完整(如有缺失,补充实现)的工作流,其主要包含以下这些特性。

环境变量

taskloop env 命令提供了查看、导入、删除环境变量的功能。

如下所示,为环境变量查看的使用示例。

1
2
3
4
5
6
7
8
9
10
11
12
$ taskloop env 

PATH=/Users/baochuquan/.rvm/gems/ruby-2.6.5/bin:/Users/baochuquan/.rvm/gems/ruby-2.6.5@global/bin:/Users/baochuquan/.rvm/rubies/ruby-2.6.5/bin:/usr/local/texlive/2023basic/bin/universal-darwin:/Users/baochuquan/.nvm/versions/node/v18.16.0/bin:/usr/local/opt/sqlite/bin:/usr/local/sbin:/usr/local/opt/gettext/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/Users/baochuquan/.rvm/bin:/Users/baochuquan/Flutter/bin:/Users/baochuquan/Library/Android/sdk/tools
RUBY_VERSION=ruby-2.6.5
GEM_PATH=/Users/baochuquan/.rvm/gems/ruby-2.6.5:/Users/baochuquan/.rvm/gems/ruby-2.6.5@global
GEM_HOME=/Users/baochuquan/.rvm/gems/ruby-2.6.5
IRBRC=/Users/baochuquan/.rvm/rubies/ruby-2.6.5/.irbrc
NOX_ROOT=/Users/baochuquan/Develop/nox
NOX_NAME=nox
NOX_COMMON=/Users/baochuquan/Develop/nox/common
NOX_CONFIG=/Users/baochuquan/Develop/nox/config
NOX_SCRIPTS=/Users/baochuquan/Develop/nox/scripts

如下所示,为环境变量导入的使用示例。示例中,我导入了两个环境变量 JAVA_HOMEGROOVY_HOME

1
2
3
4
5
6
7
8
$ taskloop env --import=JAVA_HOME,GROOVY_HOME

importing JAVA_HOME ...
JAVA_HOME=/Library/Internet Plug-Ins/JavaAppletPlugin.plugin/Contents/Home
importing GROOVY_HOME ...
GROOVY_HOME=/usr/local/opt/groovy/libexec

import global environment variables complete.

如下所示,为环境变量删除的使用示例。示例中,我删除了 GROOVY_HOME 环境变量。

1
2
3
$ taskloop env --remove=GROOVY_HOME

remove global environment variables complete.

经过一系列导入、删除操作之后,我们可以通过 taskloop env 命令来查看导入结果是否正确。

启动/关闭

taskloop 具有一个全局的开关,即启动和关闭的能力。前面我们提到 taskloop 底层是运行在 cron 守护进程之上,对此,启动功能的本质就是将 taskloop 注册至 crontab;关闭功能的本质就是将 taskloop 从 crontab 注销。

如下所示,为启动 taskloop 的使用示例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$  taskloop launch

@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@ @@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@ @@@@@@ @@@@@ @@@ @@ @@ @@@@@ @@@@ @@@ @@@@@@@@@
@@@@@@@@@@@ @@@@ @@ @@ @@@ @@ @ @@@ @@@@ @@ @@ @@ @@ @@ @@@@@@@@
@@@@@@@@@@@ @@@@@@@@@ @@@ @@@@@@ @@@@ @@@@ @@ @@ @@ @@ @@ @@@@@@@@
@@@@@@@@@@@ @@@@ @@@@@@ @@@ @@@@ @@@@ @@ @@ @@ @@ @@ @@@@@@@@
@@@@@@@@@@@ @@@ @@@ @@ @@@ @@ @ @@@ @@@@ @@ @@ @@ @@ @@ @@@@@@@@
@@@@@@@@@@@ @@@@ @@@ @@@ @@ @@ @@@ @@@@ @@@ @@@@@@@@@
@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@
@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@

taskloop has launched successfully.

如下所示,为关闭 taskloop 的使用示例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ taskloop shutdown

@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@ @@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@ @@@@@@ @@@@@ @@@ @@ @@ @@@@@ @@@@ @@@ @@@@@@@@@
@@@@@@@@@@@ @@@@ @@ @@ @@@ @@ @ @@@ @@@@ @@ @@ @@ @@ @@ @@@@@@@@
@@@@@@@@@@@ @@@@@@@@@ @@@ @@@@@@ @@@@ @@@@ @@ @@ @@ @@ @@ @@@@@@@@
@@@@@@@@@@@ @@@@ @@@@@@ @@@ @@@@ @@@@ @@ @@ @@ @@ @@ @@@@@@@@
@@@@@@@@@@@ @@@ @@@ @@ @@@ @@ @ @@@ @@@@ @@ @@ @@ @@ @@ @@@@@@@@
@@@@@@@@@@@ @@@@ @@@ @@@ @@ @@ @@@ @@@@ @@@ @@@@@@@@@
@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@
@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@

taskloop has shutdown successfully.

byeeeeeeeeeeeeeeeee !

初始化

taskloop 通过读取注册的 Taskfile 来执行所有的任务,Taskfile 中可以定义一系列用户自定义的任务。为了便于使用,taskloop 提供了一个初始化命令,可以自动创建一个 Taskfile 模板,从而供用户进行修改和定制。

如下所示,为初始化的使用示例。taskloop init 方法创建了一个 Taskfile 模板,并定义了所有支持的属性,我们可以自定义任务,包括任务的路径、名称、执行规则等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ cd my-job-project
$ taskloop init
$ cat Taskfile

# env to set environment variables which are shared by all tasks defined in the Taskfile. <Optional>
# env "ENV_NAME", "ENV_VALUE"

TaskLoop::Task.new do |t|
t.name = 'TODO: task name. <Required>'
t.path = 'TODO: task job path. For example, t.path = "./Job.sh". <Required>'
t.week = 'TODO: week rule. <Optional>'
t.year = "TODO: year rule. <Optional>"
t.month = "TODO: month rule. <Optional>"
t.day = "TODO: day rule. <Optional>"
t.hour = "TODO: hour rule. <Optional>"
t.minute = "TODO: minute rule. <Optional>"
t.time = "TODO: time list rule. <Optional>"
t.date = "TODO: date list rule. <Optional>"
t.loop = "TODO: loop count. <Optional>"
t.start_point = "TODO: start point boundary rule. <Optional>"
t.end_point = "TODO: end point boundary rule. <Optional>"
end

发布/撤销

当我们完成了对 Taskfile 的定义之后,可以进行发布。发布过程中,taskloop 会检查 Taskfile 中的语法规则,如果不符合将抛出异常,并提示错误;如果符合规则,则完成发布。Taskfile 将正式生效,后续的任务执行将以此为准。

如下所示,为发布的使用示例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
$ cd my-job-project
$ taskloop deploy

(@&/////%@@@@@@@@@@@@@#
@@&@&////////////////(@@#
/@(///////////////////////%@/
*@////////////////////////////#@,
@&///////////////////////////////@%
@&//////////////(@////@&#/////////(@
@////////////@@ @////@ ,.@////@@
/@//////////&@ ,@@////@@@&. *///@@
,@/////////(@@@ @///&@ /@@///@@
@%////////@@ @@@& @@/@#
@#///////@ ,
@@//////@& @(//@
@@/////%@ @////@
@@/////#@@@////@
&@@@&#((&@@
/&# @///@@
,///,*. @////@
&# %/,@ @@///@
(, .*/&*%/*%///& (@@@ ,*/////*.
%/#@, ////& @ % .& /@%/(/ /#/#@( #@#/&
(/& . .////, &//
&(//@ &//////& @///@
(@%//////#&@@@@@@@@@@%///////@@ @@///////%@@@@@@@@@@&#//////#@#
%@@@@&@@@@@&&@@@@/ /@@@@&&@@@@@&@@@@&

Taskfile deploy success!

当然,在某些情况下,我们需要撤销已经发布的 Taskfile。此时,我们可以执行如下命令进行撤销。

1
2
3
4
$ cd my-job-project
$ taskloop undeploy

Taskfile in </Users/baochuquan/Github/taskloop> has been undeployed successfully.

任务查看

为了便于查看当前已发布的任务,taskloop 提供了一个命令方便用户进行查询。如下所示,为任务查看的使用示例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
$ taskloop list

=============================
Tasks above are defined in Taskfile of </Users/baochuquan/Github/taskloop>
<Task.name: haha, sha1: 637d1f5c6e6d1be22ed907eb3d223d858ca396d8>
t.name = haha
t.path = ./test/Test01.rb
t.year = unit: year; specific: 2023
t.month = unit: month; specific: Aug
t.day = unit: day; default rule
t.hour = unit: hour; default rule
t.minute = unit: minute; scope: between 25, 30
t.loop = unit: loop; default rule
t.start_point = unit: full; default rule
t.end_point = unit: full; default rule
<Task.name: baocq, sha1: 7cc14c1bffcd559180d9906377bfaa41a4f9a980>
t.name = baocq
t.path = ./test/Test02.rb
t.year = unit: year; default rule
t.month = unit: month; default rule
t.day = unit: day; default rule
t.hour = unit: hour; default rule
t.minute = unit: minute; interval: 1
t.loop = unit: loop; loop: 3
t.start_point = unit: full; boundary: start from 2023-7-31 22:31:00
t.end_point = unit: full; boundary: end to 2023-7-31 22:35:00
<Task.name: chuquan, sha1: d461e86c07d232ceebcd2d024ea4b4c33d0f7b4b>
t.name = chuquan
t.path = ./test/Test03.rb
t.year = unit: year; specific: 2023
t.month = unit: month; default rule
t.day = unit: day; default rule
t.hour = unit: hour; scope: after 22
t.minute = unit: minute; interval: 10
t.loop = unit: loop; loop: 1
t.start_point = unit: full; default rule
t.end_point = unit: full; default rule

日志查看

为了解决 crontab 的日志查询问题,taskloop 同样提供了一个命令支持查询不同维度的日志,包括:系统日志(即 taskloop 运行日志)、任务日志。

如下所示,为查看系统日志的使用示例。

1
2
3
4
5
6
7
8
9
10
$ taskloop log --cron

=============================
Log of cron:

Trigger Time: <2023-08-03 08:24:00 +0800>
Checking: <Task.name: haha, sha1: 637d1f5c6e6d1be22ed907eb3d223d858ca396d8> does not meet the execution rules, taskloop will skip its execution.
Checking: <Task.name: baocq, sha1: 7cc14c1bffcd559180d9906377bfaa41a4f9a980> does not meet the execution rules, taskloop will skip its execution.
Checking: <Task.name: chuquan, sha1: d461e86c07d232ceebcd2d024ea4b4c33d0f7b4b> does not meet the execution rules, taskloop will skip its execution.
=============================

如下所示,为查看任务日志的使用示例。

1
2
3
4
5
6
7
8
$ taskloop log --task-name=baocq
=============================
Project of </Users/baochuquan/Github/taskloop>
Log of <Task.name: haha> above:
<Trigger Time: 2023-08-03 08:27:16 +0800>
Test0101

=============================

语法规则

taskloop init 命令会创建一个 Taskfile 文件,我们可以在 Taskfile 文件中自定义不同的任务与规则。这里,taskloop 定义了一套语法规则,我们将基于如下所示的 Taskfile 模板进行介绍。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
TaskLoop::Task.new do |t|
t.name = 'TODO: task name. <Required>'
t.path = 'TODO: task job path. For example, t.path = "./Job.sh". <Required>'
t.week = 'TODO: week rule. <Optional>'
t.year = "TODO: year rule. <Optional>"
t.month = "TODO: month rule. <Optional>"
t.day = "TODO: day rule. <Optional>"
t.hour = "TODO: hour rule. <Optional>"
t.minute = "TODO: minute rule. <Optional>"
t.time = "TODO: time list rule. <Optional>"
t.date = "TODO: date list rule. <Optional>"
t.loop = "TODO: loop count. <Optional>"
t.start_point = "TODO: start point boundary rule. <Optional>"
t.end_point = "TODO: end point boundary rule. <Optional>"
end

模板中列出了任务支持的所有属性,首先有两个必要属性 namepath

  • name:用于指出任务的名称,同一个 Taskfile 中不能有同名的任务,主要用于日志查询时指定名称。
  • path 用于指出任务的路径,taskloop 会根据此路径加载并执行任务脚本。

模板中的其他属性均为非必要属性,用于描述执行规则。关于执行规则,taskloop 中主要定义如下几种规则。

  • 指定时间规则(Specific Rule)
    • 指定时间规则用于指定特定的时间值,对应的语法是 at
    • 支持指定时间规则的属性有 weekyearmonthdayhourminute,其中 weekmonthday 属性需要使用预定义的符号,其余属性可以直接使用数值。
      • 对于 week,需要使用星期符号,如::Sun:Mon 等。
      • 对于 month,需要使用月份符号,如::Jan:Feb 等。
      • 对于 day,需要使用表示月份中第几天的符号,如::day1:day2 等。
    • 示例
      • t.week = at :Mon, :Sub, :Tue
      • t.month = at :Feb, :Aug
      • t.day = at :day2, :day8, :day30, day:31
      • t.year = at 2023, 2024
      • t.hour = at 10, 11
      • t.minute = at 59
  • 时间范围规则(Scope Rule)
    • 时间范围规则包含三种子规则,对应的语法分别是:beforebetweenafter
      • before 语法表示在小于等于某个值时执行。
      • between 语法表示在大于等于某个值,且小于等于另一个值时执行。
      • after 语法表示在大于等于某个值时执行。
    • 支持时间范围规则的属性有 weekyearmonthdayhourminute
    • 示例
      • t.year = before 2026
      • t.week = between :Mon, :Fri
      • t.hour = after 12
  • 时间间隔规则(Interval Rule)
    • 时间间隔规则用于指定两次任务之间的时间间隔,对应的语法是 interval
    • 支持时间间隔规则的属性有 yearmonthdayhourminute
    • 示例
      • t.minute = interval 5
      • t.day = interval 1
  • 循环次数规则(Loop Rule)
    • 循环次数规则用于指定任务循环的次数,对应的语法是 loop
    • 支持循环次数规则的属性只有 loop
    • 示例
      • t.loop = loop 10
  • 时间列表规则(Time List Rule)
    • 时间列表规则用于指定任务执行的时间列表,对应的语法是 time。其与 hourminute 属性冲突,不能同时使用。
    • 支持时间列表规则的属性只有 time
    • 示例
      • t.time = time "10:00:00", "7:00:00"
  • 日期列表规则(Date List Rule)
    • 日期列表规则用于指定执行任务的日期列表,对应的语法是 date。其与 yearmonthday 属性冲突,不能同时使用。
    • 支持日期列表规则的属性只有 date
    • 示例
      • t.date = date "2023-10-1, "2023-5-1
  • 执行边界规则(Boundary Rule)
    • 执行边界规则包含两种子规则,对应的语法分别是 fromto
      • from 语法表示任务从某一个时刻开始执行,支持的属性只有 start_point
      • to 语法表示任务在某一时刻之后不在执行,支持的属性只有 end_point
    • 示例
      • t.start_point = "2023-10-1 10:00:00"
      • t.end_point = "2023-10-30 23:59:00

工作流程

taskloop 的工作流程可以分为三个步骤:

  • 启动/关闭
  • 初始化
  • 发布/撤销

启动/关闭步骤是一个全局开关,对应分别有两个命令,如上所述。关于启动,一般只在最开始使用 taskloop 的时候使用启动命令。如果希望停止所有已注册任务的执行,则可以执行关闭命令。

taskloop 建议用户能够使用一个目录统一管理所有的定时任务,当希望为这些定时任务创建定时规则时,可以在目录下执行初始化命令,从而生成一个 Taskfile 文件。之后,即可自定义定时规则。如果用户本地维护了多个目录管理定时任务,则需要在不同的目录下分别执行一次初始化命令,从而完成任务规则自定义。

发布/撤销步骤相对而言会比较频繁,当初始化并自定义 Taskfile 之后,我们就可以执行发布命令,使得 Taskfile 真正在 taskloop 中生效。当然,有时候我们会在发布后发现一些错误,我们可以修改后重新发布,或者为了避免产生副作用,可以执行撤销命令。注意,发布/撤销命令必须在 Taskfile 的同级目录下执行。

总结

本文简单介绍了一下我最近业余时间写的一个定时任务管理工具——taskloop。同时,解释了为什么做这个工具的原因(即解决 crontab 的痛点)。

关于软件 logo,我花了两晚设计了这样一个形象。两个圈组成一个莫比乌斯环,象征着循环。任务执行抽象为海豚跳圈,海豚在两个圈中循环穿越则象征着 taskloop 在永不停止地运行任务。

忘了说,其实写这篇文章的另一个重要目的是为了推广一下我的作品,也希望有兴趣的朋友能够给一些意见,甚至可以一起参与软件的开发和完善。

参考

  1. taskloop