[Pitaya Demo解读笔记]7.worker demo

项目目录: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
}

好了,服务器,启动~!

  1. 开启 metagame 服务器
sh 复制代码
go run main.go
  1. 开启 room 服务器
sh 复制代码
go run main.go -port=3251 -type=room
  1. 开启 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 部分都截掉了,不然太长了):

  1. 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
  1. metagame
sh 复制代码
level=info msg="argument msg:\"hello\" \n" source=pitaya
  1. 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

根据日志我们大概能看出几点:

  1. cli 端只输出了 code 而没有 msg,所以这个 ReliableRPC 虽然传递了 reply,但并没有赋值,这里是拿不到回复的。如果你断点看的话,会发现 ReliableRPC 其实是一个异步调用,直接就返回了;
  2. metagame 输出了 hello,与预期一致,这个没啥好解释的;
  3. 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
}

串下来的逻辑流是这样的:

  1. room 写 job 到 redis
  2. worker 从 redis 拉去到待执行的 job
  3. 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 都大概过完了,不过我们还有很多底层代码并没有涉及到,不急,接下来,我们先试着实现自己的简单服务器,下一篇,进入实战环节,共勉!

相关推荐
代码小鑫7 分钟前
A031-基于SpringBoot的健身房管理系统设计与实现
java·开发语言·数据库·spring boot·后端
Json____12 分钟前
学法减分交管12123模拟练习小程序源码前端和后端和搭建教程
前端·后端·学习·小程序·uni-app·学法减分·驾考题库
monkey_meng32 分钟前
【Rust类型驱动开发 Type Driven Development】
开发语言·后端·rust
落落落sss40 分钟前
MQ集群
java·服务器·开发语言·后端·elasticsearch·adb·ruby
大鲤余1 小时前
Rust,删除cargo安装的可执行文件
开发语言·后端·rust
她说彩礼65万1 小时前
Asp.NET Core Mvc中一个视图怎么设置多个强数据类型
后端·asp.net·mvc
陈随易1 小时前
农村程序员-关于小孩教育的思考
前端·后端·程序员
_江南一点雨1 小时前
SpringBoot 3.3.5 试用CRaC,启动速度提升3到10倍
java·spring boot·后端
转转技术团队2 小时前
空间换时间-将查询数据性能提升100倍的计数系统实践
java·后端·架构
r0ad2 小时前
SpringCloud2023实战之接口服务测试工具SpringBootTest
spring boot·后端·spring cloud