项目目录:examples/demo/worker
这个 demo 我是看了很久,也跟朋友、同行、群友讨论了很久,最后一致认定,worker 主要用来实现一个异步调用,并不能直接在调用侧获得回复,相当于是发送一个 job 到 worker,由 worker 来保证可靠调用,失败重试。
我们还是先把代码跑起来,待会再详细说说这个异步调用的坑。
运行
哦对了,这个 demo 需要连接 redis,demo 是直接连接到 "localhost:6379",请在本地开启 redis,或者修改 url 到正确的地址:
go
// main.go:32
conf.SetDefault("pitaya.worker.redis.url", "localhost:6379")
修改 response msg
由于 demo 里的 Msg 输出都是 "OK",不太方便看数据源头,所以我略做了一些修改:
go
// room.go
func (r *Room) CallLog(ctx context.Context, arg *protos.Arg) (*protos.WorkerResponse, error) {
...
return &protos.WorkerResponse{Code: 200, Msg: reply.Msg}, nil // 改成 WorkerResponse 的原因见后文的填坑部分
}
// metagame.go
func (m *Metagame) LogRemote(ctx context.Context, arg *protos.Arg) (*protos.WorkerResponse, error) {
logger.Log.Infof("argument %+v\n", arg)
return &protos.WorkerResponse{Code: 200, Msg: "msg from metagame"}, nil
}
// worker.go
func (r *RPCJob) RPC(ctx context.Context, serverID, routeStr string, reply, arg proto.Message) error {
err := r.app.RPCTo(ctx, serverID, routeStr, reply, arg)
logrus.Infof("rpc to %s, route: %s, reply: %+v, arg: %+v", serverID, routeStr, reply, arg)
return err
}
好了,服务器,启动~!
- 开启 metagame 服务器
sh
go run main.go
- 开启 room 服务器
sh
go run main.go -port=3251 -type=room
- 开启 worker 服务器
sh
go run main.go -port=3252 -type=worker
如果你能成功开启、且连接上 redis,恭喜,你可以跳过下面的填坑部分~
解决服务器启动失败的错误(填坑)
redis 是带密码的
我本地之前部署过 redis 是带密码的,懒得改了,所以开起来的时候会报错:
sh
time="2023-12-07T11:30:39+08:00" level=error msg="failed to fetch message NOAUTH Authentication required." source=go-workers
worker 从 redis 拉数据的时候报权限错误了,我们需要在代码里配置一下访问密码。 这个 demo 中使用 viper
来配置,在配置字典里只将 url 和 pool 做了映射,没有映射 password 到实际的 config里,那我们加一下:
go
// viper_config.go:139 加一行映射
pitaya.worker.redis.password": workerConfig.Redis.Password,
main 里配置一下:
go
// main.go:34
conf.SetDefault("pitaya.worker.redis.password", "your_password")
protos.Response 注册冲突
我本地的代码已经改掉了,没有保留之前的报错提示,这里就不给出原提示了(就是懒得 revert 了哈~) 这个错误是因为 worker demo 的 pb 生成文件 response.pb.go 里有一个包 init
函数,会注册 type "protos.Response",这个名字与 pitaya 框架里的 response.pb.go init
中的冲突了,两个同名了。 所以把 demo 中的文件改名为 worker_response.pb,再重新用 protoc 生成一下就好了(具体如何生成,网上很多文章,此处不扩展了)。
invalid memory address or nil pointer
worker 服务有概率会报这个错误,这个我没有细看,感觉 worker 这边的问题挺多的,pitaya 的 worker 也是在别人开源的一个 worker 基础上做了封装,不多余花时间去 debug 了,如果报这个错误的话,多尝试几次就可以正常开启。如果你的项目需要用到这个功能,还得注意这个坑,自己解决一下吧~(我是觉得这东西挺鸡肋的哈哈)。
测试 demo
按照以上一波操作,服务器总算是开启来了(不知道你们还会不会遇到别的坑......),让我们开启 pitaya-cli 测试一下:
sh
pitaya-cli
Pitaya REPL Client
>>> connect 127.0.0.1:3251
Using json client
connected!
>>> request room.room.calllog {"msg":"hello"}
>>> sv->{"code":200}
依次看看几个服务器的日志(把 time 部分都截掉了,不然太长了):
- room
sh
level=debug msg="SID=4, Data={\"msg\":\"hello\"}" requestId=L0eYEIdBTVSZuPQNk1uHFD route=room.room.calllog source=pitaya userId=
level=info msg="enqueue rpc job: %!d(string=3191b5e2de7968051b21bac7)" source=pitaya
level=debug msg="Type=Response, ID=4, UID=, MID=1, Data=12bytes" source=pitaya
- metagame
sh
level=info msg="argument msg:\"hello\" \n" source=pitaya
- worker
sh
level=debug msg="executing rpc job" source=pitaya
level=debug msg="getting route arg and reply" source=pitaya
level=debug msg="unmarshalling rpc info" source=pitaya
level=debug msg="choosing server to make rpc" source=pitaya
level=debug msg="executing rpc func to metagame.metagame.logremote" source=pitaya
level=debug msg="[rpc_client] sending remote nats request for route metagame.metagame.logremote with timeout of 5s" source=pitaya
level=info msg="rpc to , route: metagame.metagame.logremote, reply: code:200 msg:\"msg from metagame\", arg: msg:\"hello\" "
level=debug msg="finished executing rpc job" source=pitaya
根据日志我们大概能看出几点:
- cli 端只输出了 code 而没有 msg,所以这个
ReliableRPC
虽然传递了reply
,但并没有赋值,这里是拿不到回复的。如果你断点看的话,会发现ReliableRPC
其实是一个异步调用,直接就返回了; - metagame 输出了 hello,与预期一致,这个没啥好解释的;
- worker 那一行 info 日志注意看,在 worker 既能拿到 cli 端的请求参数 hello ,也能拿到 metagame 返回的消息数据 msg from metagame。
接下来,我们从代码层面追踪一下整个调用链。
代码分析
先看看调用链的头部,room CallLog,我们刚才使用 pitaya-cli 发送的 request 就是 room.room.calllog。
起点:room.CallLog
go
// CallLog makes ReliableRPC to metagame LogRemote
func (r *Room) CallLog(ctx context.Context, arg *protos.Arg) (*protos.WorkerResponse, error) {
route := "metagame.metagame.logremote"
reply := &protos.WorkerResponse{}
jid, err := r.app.ReliableRPC(route, nil, reply, arg)
if err != nil {
logger.Log.Infof("failed to enqueue rpc: %q", err)
return nil, err
}
logger.Log.Infof("enqueue rpc job: %d", jid)
return &protos.WorkerResponse{Code: 200, Msg: reply.Msg}, nil
}
很明显,重点是这行代码 r.app.ReliableRPC(router, nil, reply, arg)
,根据命名,猜测是发起了一个远程调用,调用的目的地为 metagame.metagame.logremote
,请求消息为 arg
,回复数据填充到 reply
。我是这么猜的,可惜,就猜对了一部分......前面也提到过,这里的 reply
其实并无回复数据填充,ReliableRPC
只是一个异步调用。 老规矩,跟进源码看一下。
room ReliableRPC
go
// rpc.go
// ReliableRPC enqueues RPC to worker so it's executed asynchronously
// Default enqueue options are used
func (app *App) ReliableRPC(routeStr string, metadata map[string]interface{}, reply, arg proto.Message) (jid string, err error) {
return app.worker.EnqueueRPC(routeStr, metadata, reply, arg)
看注释也知道,这是一个异步调用,入队一个请求到 worker,让他去异步执行,与其说这是一个 rpc,我更愿意理解成 job 或者 task,扔过去就不用管了,由 worker 来保证可靠执行。 app.worker
是在框架层 NewBuilder
里默认创建的,也就是说,即使你压根不想用这个什么 worker,它依然会在底层给你创建这么个东西,但不生效,如果想要用上 worker 的功能,还需要手动调用 app.StartWorker()
,里面的逻辑不深究,继续看回我们的 demo。 EnqueueRPC
做了什么呢?
go
// worker/worker.go
func (w *Worker) EnqueueRPC(routeStr string, metadata map[string]interface{}, reply, arg proto.Message) (jid string, err error) {
opts := w.enqueueOptions(w.opts)
return workers.EnqueueWithOptions(rpcQueue, class, &rpcInfo{
Route: routeStr,
Metadata: metadata,
Arg: arg,
Reply: reply,
}, opts)
}
// go-workers包 -> enqueue.go
func EnqueueWithOptions(queue, class string, args interface{}, opts EnqueueOptions) (string, error) {
now := nowToSecondsWithNanoPrecision()
data := EnqueueData{
Queue: queue,
Class: class,
Args: args,
Jid: generateJid(),
EnqueuedAt: now,
EnqueueOptions: opts,
}
bytes, err := json.Marshal(data)
if err != nil {
return "", err
}
if now < opts.At {
err := enqueueAt(data.At, bytes)
return data.Jid, err
}
var conn redis.Conn
if len(opts.ConnectionOptions) == 0 {
conn = Config.Pool.Get()
} else {
conn = GetConnectionPool(opts.ConnectionOptions).Get()
}
defer conn.Close()
_, err = conn.Do("sadd", Config.Namespace+"queues", queue)
if err != nil {
return "", err
}
queue = Config.Namespace + "queue:" + queue
_, err = conn.Do("lpush", queue, bytes)
if err != nil {
return "", err
}
return data.Jid, nil
}
代码比较简单,只看核心逻辑,就是把请求数据打包、序列化后写入到 redis(具体这两个 redis key 是怎么拼的,内容是什么,这些细节就不用太在意)。 另外,它会返回一个随机生成的 jid,理论上可以理解为一段时间内的唯一 id,标识这一次的 rpc。所以看到这里的时候,我以为还会有什么 api 可以通过这个 jid 来获取这次请求的返回消息,然而并没有......其实 pitaya 用的第三方开源库 go-workers 也提供了对 jid 的获取:
go
// go-workers库 -> msg.go
func (m *Msg) Jid() string {
return m.Get("jid").MustString()
}
但是 pitaya 并没有使用过,猜测 pitaya 并没有完全用上 go-workers 库,做了部分简化,或者有一些功能是留给开发者自己在业务层去实现的,pitaya 在设计上只把 worker 当作一个异步可靠的 job scheduler 来用。
看过了一个 job 的投递,我们再看看逻辑链条的第二部,这个 job 是如何被获取并执行的呢,让我们进入 worker 流程~
worker Configure
go
// Configure starts workers and register rpc job
func (w *Worker) Configure(app pitaya.Pitaya) {
app.StartWorker()
app.RegisterRPCJob(&RPCJob{app: app})
}
前面说到,worker 功能必须要调用 StartWorker
才可以使用,这里主要是开启几个 goroutine,从 redis 中获取待处理的 job 队列(即前面通过EnqueueWithOptions
写进去的)并执行。这部分已经是第三方库 go-workers 的内容了,不去管这些细节了(没错,内容太多了,就是我懒得看了......)。
还是回到 pitaya 的代码上来,RegisterRPCJob
用来将 job 的处理方式注册到程序里:
go
// RegisterRPCJob registers a RPC job
func (w *Worker) RegisterRPCJob(rpcJob RPCJob) error {
if w.registered {
return constants.ErrRPCJobAlreadyRegistered
}
job := w.parsedRPCJob(rpcJob)
workers.Process(rpcQueue, job, w.concurrency)
w.registered = true
return nil
}
那我们看看如何定义一个 RPCJob
。
worker RPCJob
go
// rpc_job.go
// RPCJob has infos to execute a rpc on worker
type RPCJob interface {
// ServerDiscovery returns a serverID based on the route
// and any metadata that is necessary to decide
ServerDiscovery(route string, rpcMetadata map[string]interface{}) (serverID string, err error)
// RPC executes the RPC
// It is expected that if serverID is "" the RPC
// happens to any destiny server
RPC(ctx context.Context, serverID, routeStr string, reply, arg proto.Message) error
// GetArgReply returns the arg and reply of the
// method
GetArgReply(route string) (arg, reply proto.Message, err error)
}
ServerDiscovery
用来实现路由策略,与我们之前在 cluster demo 里说的 AddRouter
功能类似。 RPC
在本例中,就是调用了 pitaya 的 RPC:
go
// RPC calls pitaya's rpc
func (r *RPCJob) RPC(ctx context.Context,serverID, routeStr string, reply, arg proto.Message) error {
err := r.app.RPCTo(ctx, serverID, routeStr, reply, arg)
logrus.Infof("rpc to %s, route: %s, reply: %+v, arg: %+v", serverID, routeStr, reply, arg)
return err
}
串下来的逻辑流是这样的:
- room 写 job 到 redis
- worker 从 redis 拉去到待执行的 job
- worker 在 RPC 中执行 job
GetArgReply
就是返回空的请求和回复结构,应该是帮助解析从 redis 中取出的数据,否则在 worker 这里无法确定如何反序列化。所以 GetArgReply
是为了提供反序列化数据的模板。
回到 pitaya 底层,看一下这三个方法是如何依次被调用的。 前面我们提到了 RegisterRPCJob
,其中调用了 parsedRPCJob
,就是这个方法实现了 worker 解析数据、执行的流程串联:
go
// worker/worker.go
func (w *Worker) parsedRPCJob(rpcJob RPCJob) func(*workers.Msg) {
return func(jobArg *workers.Msg) {
logger.Log.Debug("executing rpc job")
bts, rpcRoute, err := w.unmarshalRouteMetadata(jobArg)
if err != nil {
logger.Log.Errorf("failed to get job arg: %q", err)
panic(err)
}
logger.Log.Debug("getting route arg and reply")
arg, reply, err := rpcJob.GetArgReply(rpcRoute.Route)
if err != nil {
logger.Log.Errorf("failed to get methods arg and reply: %q", err)
panic(err)
}
rpcInfo := &rpcInfo{
Arg: arg,
Reply: reply,
}
logger.Log.Debug("unmarshalling rpc info")
err = json.Unmarshal(bts, rpcInfo)
if err != nil {
logger.Log.Errorf("failed to unmarshal rpc info: %q", err)
panic(err)
}
logger.Log.Debug("choosing server to make rpc")
serverID, err := rpcJob.ServerDiscovery(rpcInfo.Route, rpcInfo.Metadata)
if err != nil {
logger.Log.Errorf("failed get server: %q", err)
panic(err)
}
ctx := context.Background()
logger.Log.Debugf("executing rpc func to %s", rpcInfo.Route)
err = rpcJob.RPC(ctx, serverID, rpcInfo.Route, reply, arg)
if err != nil {
logger.Log.Errorf("failed make rpc: %q", err)
panic(err)
}
logger.Log.Debug("finished executing rpc job")
}
}
前文我们提到了,worker 的 RPC 是可以获取到 metagame 返回的消息,就在 reply
里,但是他并没有做处理,也就是说并没有写入 redis 缓存,或者返回给调用方 room。这里就是佐证,rpcJob.RPC(ctx, serverID, rpcInfo.Route, reply, arg)
调用后,对于 reply
回复数据没有作进一步处理。 这里的代码是当然是可以改造的,再来一个 RPC 将 reply 返回给调用端,或者写入 redis 让调用端拿着 jid 去获取回复。我们这里就不讨论了,就像前文提到的,pitaya 的本意可能只是把 worker 当做一个 job scheduler 来使用,而并不是我们平时所理解的 RPC。
由于这个 demo 写得比较糙,不详细(pitaya 的所有 demo 都这样......),还涉及到了第三方库及其封装,背后的代码还挺多的,时间有限,能力有限,我大概也就断断续续看了一天写了一天,难免有纰漏,如果你发现疏漏、错误,或者有不同理解,欢迎留言~
至此,pitaya 框架的几个 demo 都大概过完了,不过我们还有很多底层代码并没有涉及到,不急,接下来,我们先试着实现自己的简单服务器,下一篇,进入实战环节,共勉!