1. 前言
machinery 是使用 go 语言开发的一个异步任务框架,开源项目地址:
GitHub - RichardKnop/machinery: Machinery is an asynchronous task queue/job queue based on distributed message passing.
2. 初衷
什么场景会用到异步任务队列?
举个例子,用 12306 买票,付款后,12306 和银行都会发送一条通知短信,告知用户买票成功、银行账户扣款成功。但如果短信发送失败呢?我们的车票一样能够买到,车票钱还会从银行账户扣除,这个逻辑很合理。如果让我实现这样一段逻辑,很可能会把发送短信通知这段逻辑放在买票的主流程中顺序执行。可发短信一旦阻塞或者失败,会影响主逻辑的后续流程或返回结果,如果不处理短信的发送结果,就无法实现错误重试。那好,我单开一个协程专门处理短信通知逻辑,增加错误重试。可如果发生意外,程序崩溃,发短信的任务就会丢失,为此我还要增加一个持久化的逻辑,把未发送的和正在重试中的短信保存在文件中。
如此一来,为了保证程序的健壮性,我们额外的任务量增加了很多。那有没有一个现成的代码库,既能支持异步任务,又能支持错误重试,还能支持任务持久化呢?没错你猜对了,接下来就轮到 machinery 登场了。
3. 基本架构
先不讨论具体的底层实现,从一个使用者的视角,machinery 主要由三部分组成,一个发送任务的生产者 server,一个执行任务的消费者 worker,一个保存任务和执行结果的消息代理 broker。
发送任务的数据流:server --> broker --> worker
获取结果的数据流:server <-- broker <-- worker
4. 实际案例
4.1 发送短信通知...给显示器
项目目录结构
/my_workspace/my_machinery
config.yaml # 配置文件,配置 redis 连接信息
go.mod
go.sum
server.go # 生产者
worker.go # 消费者
创建消费者
流程:加载配置文件 --> 注册任务 --> 绑定消息队列 --> 接收、执行任务
首先,调用 config.NewFromYaml 加载咱们的配置文件 config.yaml,得到一个配置对象实例,然后调用 machinery.NewServer 绑定配置项,生成一个 server 实例,通过调用 server 的 RegisterTask 方法来注册我们事先定义好的任务 SayHello 函数,然后调用 server.NewWorker 生成一个消费者实例 worker,并绑定名为 sms 的消息队列,最后调用 worker.Launch 启动消费者程序,并一直监听 sms 消息队列。
具体代码如下所示:
package main
import (
"fmt"
"log"
"github.com/RichardKnop/machinery/v1"
"github.com/RichardKnop/machinery/v1/config"
)
// 定义任务
func SayHello(args []string) (string, error) {
for _, arg := range args {
fmt.Println(arg)
}
return "ok", nil
}
func main() {
// 将配置文件实例化
cnf, err := config.NewFromYaml("./config.yaml", false)
if err != nil {
log.Println("config.NewFromYaml failed, err:", err)
return
}
// 根据实例化的配置文件创建 server 实例
server, err := machinery.NewServer(cnf)
if err != nil {
log.Println("machinery.NewServer failed, err:", err)
return
}
// 为消费者程序注册任务
err = server.RegisterTask("SayHello", SayHello)
if err != nil {
log.Println("server.RegisterTask failed, err:", err)
return
}
// 创建 worker 实例并绑定任务队列名
worker := server.NewWorker("sms", 1)
// 运行 worker 监听逻辑,监听消息队列中的任务
err = worker.Launch()
if err != nil {
log.Println("start worker error", err)
return
}
}
创建生产者
生产者程序的流程比消费者简单,具体的不同之处在于生产者程序不需要创建消费者,不需要绑定任务函数。只在需要的时候新建任务,再将任务下发给消费者即可。
创建任务通过签名结构体 tasks.Signature 来完成,指定任务名 Name,指定任务参数 Args,最后调用 SendTask 下发这个任务签名就可以了。
package main
import (
"log"
"github.com/RichardKnop/machinery/v1"
"github.com/RichardKnop/machinery/v1/config"
"github.com/RichardKnop/machinery/v1/tasks"
)
func NewSumTaskSignature(val []string) *tasks.Signature {
// 新建任务签名,执行任务名Name,指定传递给任务的参数Args
signature := &tasks.Signature{
Name: "SayHello",
Args: []tasks.Arg{
{
Type: "[]string",
Value: val,
},
},
}
return signature
}
func main() {
// 加载配置文件
cnf, err := config.NewFromYaml("./config.yaml", false)
if err != nil {
log.Println("config failed", err)
return
}
// 新建 server 实例
server, err := machinery.NewServer(cnf)
if err != nil {
log.Println("start server failed", err)
return
}
// 下发任务给消费者
signature := NewSumTaskSignature([]string{"1", "2", "3", "4", "5"})
asyncResult, err := server.SendTask(signature)
if err != nil {
log.Fatal(err)
}
// 每秒获取一次消息队列中的结果
res, err := asyncResult.Get(1)
if err != nil {
log.Fatal(err)
}
log.Printf("get res is %v\n", tasks.HumanReadableResults(res))
}
运行程序
成功启动 redis 后,先运行 worker 程序,再运行 server 程序。worker 会阻塞在监听 redis 任务这一步,等 server 发送任务后,worker 收到任务,执行任务,通过消息队列返回执行结果给生产者。
最终,控制台输出如下所示:
PS D:\my_workspace\golang\src\study_machinery\my_machinery> go run worker.go
INFO: 2023/10/22 11:12:49 file.go:19 Successfully loaded config from file ./config.yaml
INFO: 2023/10/22 11:12:49 worker.go:58 Launching a worker with the following settings:
INFO: 2023/10/22 11:12:49 worker.go:59 - Broker: redis://172.27.57.102:6379
INFO: 2023/10/22 11:12:49 worker.go:61 - DefaultQueue: sms
INFO: 2023/10/22 11:12:49 worker.go:65 - ResultBackend: redis://172.27.57.102:6379
INFO: 2023/10/22 11:12:49 redis.go:105 [*] Waiting for messages. To exit press CTRL+C
##### server 下发任务后 #####
DEBUG: 2023/10/22 11:14:20 redis.go:347 Received new message: {"UUID":"task_02196b06-25ed-475d-a134-14af5b9f6c28","Name":"SayHello","RoutingKey":"sms","ETA":null,"GroupUUID":"","GroupTaskCount":0,"Args":[{"Name":"","Type":"[]string","Value":["1","2","3","4","5"]}],"Headers":{},"Priority":0,"Immutable":false,"RetryCount":0,"RetryTimeout":0,"OnSuccess":null,"OnError":null,"ChordCallback":null,"BrokerMessageGroupId":"","SQSReceiptHandle":"","StopTaskDeletionOnError":false,"IgnoreWhenTaskNotRegistered":false}
DEBUG: 2023/10/22 11:14:20 worker.go:261 Processed task task_02196b06-25ed-475d-a134-14af5b9f6c28. Results = ok
##### server 下发任务,并获取到任务执行结果 #####
PS D:\my_workspace\golang\src\study_machinery\my_machinery> go run server.go
INFO: 2023/10/22 11:14:20 file.go:19 Successfully loaded config from file ./config.yaml
2023/10/22 11:14:20 get res is ok
4.2 给发送短信添加延迟时间
修改上例生产者程序中的 NewSumTaskSignature 函数,为任务签名添加延迟时间。设置任务签名的 ETA 成员变量,ETA 用于控制任务执行的时间。该值为 time.Time 类型,表示未来的一个时间点。
在下面这个例子中,我们让发送短信延迟三秒执行。
func NewSumTaskSignature(val []string) *tasks.Signature {
// 新建任务签名,执行任务名Name,指定传递给任务的参数Args
signature := &tasks.Signature{
Name: "SayHello",
Args: []tasks.Arg{
{
Type: "[]string",
Value: val,
},
},
}
// ETA 为 Estimated Time of Arrival 的缩写,意指预计到达时间,
// 在这里表示任务预计执行的时间。
eta := time.Now().Add(time.Second * time.Duration(3))
signature.ETA = &eta
return signature
}
再次执行 server 程序,观察程序打印,可以看到三秒后才得到返回值。
4.3 批量发送短信
为避免调用短信接口过于频繁,我们可以先积累一定量的待发送的信息,比如3条,当待发送的短信数量达到三条后,一次性下发一个组任务,将这三条短信一次性发送出去。
紧接前面的代码,在新建三个任务签名后,调用 task.NewGroup 将三个任务打包成一个任务组,然后调用 server.SendGroup 下发这个任务组,最后遍历任务组的结果对象,直到所有任务完成执行并返回结果。
// SendSayHelloGroupTask 下发任务组,该组的任务并发执行
func SendSayHelloGroupTask(server *machinery.Server) {
// 创建三个任务
sign1 := NewSumTaskSingature([]string{"01", "02", "03", "04", "05"})
sign2 := NewSumTaskSingature([]string{"11", "12", "13", "14", "15"})
sign3 := NewSumTaskSingature([]string{"21", "22", "23", "24", "25"})
// 把上面创建的三个任务加入任务组
group, err := tasks.NewGroup(sign1, sign2, sign3)
if err != nil {
log.Println("add group failed", err)
}
// 下发任务组,并设置任务执行的最大并发数为 10
asyncResults, err := server.SendGroup(group, 10)
if err != nil {
log.Println(err)
}
// 接收组内所有任务的结果,直到所有任务都执行完毕返回成功或者失败
for _, asyncResult := range asyncResults {
results, err := asyncResult.Get(1)
if err != nil {
log.Println(err)
continue
}
log.Printf(
"args: %v, result: %v\n",
asyncResult.Signature.Args[0].Value,
tasks.HumanReadableResults(results),
)
}
}
从server程序的控制台打印可以看到,组内每个任务的参数和结果如下所示:
worker程序的控制台打印如下,可以看到三个SayHello任务都得到了执行,而且最后任务组执行结果为 ok
4.4 统计短信批量发送结果
为避免调用短信接口过于频繁,我们可以先积累一定量的待发送的信息,比如3条,当待发送的短信数量达到三条后,一次性下发一个组任务,将这三条短信一次性发送出去。
紧接前面的代码,在新建三个任务签名后,调用 task.NewGroup 将三个任务打包成一个任务组,然后调用 server.SendGroup 下发这个任务组,最后遍历任务组的结果对象,直到所有任务完成执行并返回结果。
5. 环境准备
必要组件
下载安装 go:All releases - The Go Programming Language (google.cn)
machinery 兼容多种消息代理,比如 redis、rabbitmq、memcache、mongodb 等。
本文使用 redis 作为消息代理。下载安装 redis:Download | Redis
项目配置文件
按照上面展示的项目路径,配置文件为:/my_workspace/my_machinery/config.yaml
在配置文件中配置消息代理,先复制到项目中,需要分别修改下面的四个变量:
${user}、${password}、${ip}、${user} 分别为 redis 的用户名、密码、IP 地址、端口
# 消息代理的地址,这里使用 redis 作为代理
broker: redis://${user}:${password}@${ip}:${port}
# 消息代理的默认队列名
default_queue: "sms"
# 消费者处理的结果代理
result_backend: redis://${user}:${password}@${ip}:${port}
# redis的配置
redis:
max_idle: 3
max_active: 3
max_idle_timeout: 240
wait: true
read_timeout: 15
write_timeout: 15
connect_timeout: 15
normal_tasks_poll_period: 1000
delayed_tasks_poll_period: 500
delayed_tasks_key: "sms"