[Pitaya Demo解读笔记]2.cluster demo

2023年 9月 26日 88.5k 0

项目目录: examples/demo/cluster

吐槽一下,pitaya 这个框架虽然非常好,但是文档确实太少了,chat demo 好歹还有个 README,到了 cluster 这个复杂的例子反而什么文档也没有了……

吐槽完毕,让我们先看一下,这个例子跑起来是什么样子的

运行

代码里有关于 Jaeger,但这不是我们的主线,而且配置起来比较麻烦,这里先略过,只研究我们的主线逻辑,之后有时间再回过头来看吧

先 cd 到项目目录下

运行前端、后端服务器

  • 运行前端服务(不加 flag 默认就是前端服务,待会看代码的时候再说)

     go run main.go
    
  • 运行后端服务,type 为后端服务名,也就是待会客户端路由到的服务名,这里设定为 room (main.go:51 说明了后端服务名为 room)

     go run main.go -type=room -frontend=false
    
  • 运行 pitaya-cli 客户端

    pitaya-cli 安装

    这个命令行客户端是 pitaya 内置的,我们这里已经把源码拉下来了,可以直接 cd 到项目的 pitaya-cli 目录下,使用 go install 直接安装

    image-20230921163304031.png

    然后在命令行界面就可以使用 pitaya-cli 直接使用了

    PS:如果报错找不到这个命令,一般 go install 是安装到了 GOPATH 目录下。看一下 go env 的 GOPATH 配置在哪里,设置一下系统的环境变量即可

    客户端CLI测试

    两个客户端大概的输入输出如下

    客户端1:

     > pitaya-cli
     Pitaya REPL Client
     >>> connect 127.0.0.1:3250
     Using json client
     connected!
     >>> request room.room.entry
     >>> sv->{"result":"ok"}
     >>> request room.room.join
     >>> sv->{"Members":["e34fbbbf-9d86-412d-924e-b2994a32efa3"]}
     sv->{"result":"success"}
     sv->{"content":"New user: 2"}
     sv->{"content":"New user: 4"}
     >>> notify room.room.message {"name":"test", "content":"hello world"}
     >>> sv->{"name":"test","content":"helloworld"}
    

    客户端2:

     > pitaya-cli
     Pitaya REPL Client
     >>> connect 127.0.0.1:3250
     Using json client
     connected!
     >>> request room.room.entry
     >>> sv->{"result":"ok"}
     >>> request room.room.join
     >>> sv->{"result":"success"}
     sv->{"Members":["e34fbbbf-9d86-412d-924e-b2994a32efa3","791d0a3a-0929-46bc-82c4-7c501fa8db8a"]}
     sv->{"content":"New user: 4"}
     sv->{"name":"test","content":"helloworld"}
    
  • connect 127.0.0.1:3250 连接到前端服务
  • room.room.entry 绑定会话,为该会话赋值了一个 UID
  • room.room.join 加入到聊天房间,并收到房间当前所有玩家的 UID 列表
  • room.room.message 推送消息到服务器,服务器将消息广播到所有人
  • 代码分析

    前3行就是命令行的解析,这里就顺便学习一下 flag 的用法

    flag

     port := flag.Int("port", 3250, "the port to listen")
     svType := flag.String("type", "connector", "the server type")
     isFrontend := flag.Bool("frontend", true, "if server is frontend")
     flag.Parse()
    

    flag包提供了对命令行的解析方法,以上三个方法的参数列表都是一样的,分别表示:命令行参数名、默认值和用法描述。我们可以使用 -help 来输出这些内容,比如我们使用 go -help 可以打印出 go 支持的参数及其用法:

     > go -help
     Go is a tool for managing Go source code.
     
     Usage:
     
             go  [arguments]
     
     The commands are:
     
             bug         start a bug report
             build       compile packages and dependencies
             clean       remove object files and cached files
             doc         show documentation for package or symbol
             env         print Go environment information
             fix         update packages to use new APIs
             fmt         gofmt (reformat) package sources
             generate    generate Go files by processing source
             get         add dependencies to current module and install them
             install     compile and install packages and dependencies
             list        list packages or modules
             mod         module maintenance
             work        workspace maintenance
             run         compile and run Go program
             test        test packages
             tool        run specified go tool
             version     print Go version
             vet         report likely mistakes in packages
     
     Use "go help " for more information about a command
    

    这里我们试一下打印该项目支持的命令行参数

  • 把项目编译成 .exe

     go build -o cluster.exe .
    
  • 输出命令行参数

     ./cluster.exe -help
    
  • image-20230921170758271.png

    frontend 默认值为 true,所有刚才我们运行前端服务时,不需要附加任何参数

    frontend

     if *isFrontend {
         tcp := acceptor.NewTCPAcceptor(fmt.Sprintf(":%d", *port))
         builder.AddAcceptor(tcp)
     }
    

    isFrontend 为 true 时表明了这是一个前端服务,前端服务需要接收客户端连接,所以这里创建了一个 acceptor,而且只有前端服务可以创建 acceptor,在前面一个 demo 里我们也提到过,Builder AddAcceptor 时会判断服务是否为前端服务。

    关于前后端服务,我们这里再理解一下含义

    pitaya 官方文档有相关描述:

    Frontend and backend servers

    In cluster mode servers can either be a frontend or backend server.

    在集群模式下,服务器可以是前端服务器,也可以是后端服务器。

    Frontend servers must specify listeners for receiving incoming client connections. They are capable of forwarding received messages to the appropriate servers according to the routing logic.

    前端服务器必须指定接收传入客户端连接的侦听器。它们能够根据路由逻辑将收到的消息转发到适当的服务器。

    Backend servers don’t listen for connections, they only receive RPCs, either forwarded client messages (sys rpc) or RPCs from other servers (user rpc).

    后端服务器不侦听连接,它们接受 RPC,要么是转发的客户端消息(sys rpc) ,要么是来自其他服务器(user rpc)的 RPC。

    前端服务、后端服务,应该是 Pitaya 框架给出的概念,可以将前端服务理解为 Proxy 或者 Gate,它是面向客户端连接的,具有转发消息到后端服务的功能和职责。

    假设有一个简单的游戏服务器架构如下:

    游戏服务器架构example.png

    登录服务器 LoginServer、转发服务器 ProxyServer 和 战斗服务器 BattleServer 作为前端服务器,与客户端直接建立连接,而大厅服务器(或者说逻辑服务器)LobbyServer 和 聊天服务器 ChatServer 是后端服务器,由 Proxy 进行消息转发。

    Register 和 RegisterRemote

    在前后端服务器中,都有两种类型的注册:RegisterRegisterRemote

    Register 我们在前文已经说过,最终是将消息处理函数整理到了 HandlerServiceHandlerPool 结构体里,当 acceptor 收到消息时,根据消息路由key,获取到对应的 handler 进行处理。再看 RegisterRemote,其实最终落地也是在 HandlerPool,不同的只是 Remote 是在 RemoteService 的结构体中。

    我们看一下这两个结构体的定义:

    image-20230925200433967.png

    可以发现差异很大,RemoteService 既包含 RPCServer 也包含 RPCClient,也就是说,这个 Service 是具有 RPC 收发能力的,既可以接受 RPC,也可以发起 RPC。

    再看 HandlerService 里还包含有一个 RemoteService,查找引用发现有这个调用链:

     // handler.go:319
     // processMessage
     if r.SvType == h.server.Type {
             h.chLocalProcess  size || (offset+2) > size {
                     return nil, ErrInvalidMessage
                 }
     ​
                 m.compressed = true
                 code := binary.BigEndian.Uint16(data[offset:(offset + 2)])
                 routesCodesMutex.RLock()
                 route, ok := codes[code]
                 routesCodesMutex.RUnlock()
                 if !ok {
                     return nil, ErrRouteInfoNotFound
                 }
                 m.Route = route
                 offset += 2
             } else {
                 m.compressed = false
                 ...
             }
         }
         ...
     }
    
    

    但是压缩路由表是设置在服务器上的,对端是如何获取这个路由表的?

    这些在 Pitaya 底层已经做了保证,当客户端连接上来的时候,底层会自动 Handshake 握手,HandshakeResponse 即握手回复包里,服务器就附带上自己的压缩路由表了。

     // client.go:138
     // sendHandshakeRequest
     func (c *Client) sendHandshakeRequest() error {
         enc, err := json.Marshal(c.clientHandshakeData)
         if err != nil {
             return err
         }
    
         p, err := c.packetEncoder.Encode(packet.Handshake, enc)
         if err != nil {
             return err
         }
    
         _, err = c.conn.Write(p)
         return err
     }
    
     func (c *Client) handleHandshakeResponse() error {
         ...
         if compression.IsCompressed(handshakePacket.Data) {
             handshakePacket.Data, err = compression.InflateData(handshakePacket.Data)
             if err != nil {
                 return err
             }
         }
         ...
         if handshake.Sys.Dict != nil {
             message.SetDictionary(handshake.Sys.Dict)
         }
         ...
     }
    

    GetSessionData / SetSessionData

    前端服务器的 Connector 和后端服务器的 Room,都实现了对 GetSessionData 与 SetSessionData 的消息处理。跑个命令行测试一下:

     pitaya-cli
     Pitaya REPL Client
     >>> connect 127.0.0.1:3250
     Using json client
     connected!
     >>> request room.room.entry
     >>> sv->{"result":"ok"}
     >>>
     >>> request connector.setsessiondata {"data":{"key1":"value1"}}
     >>> sv->{"code":200,"msg":"success"}
     >>>
     >>> request connector.getsessiondata
     >>> sv->{"Data":{"key1":"value1"}}
     >>>
     >>> request room.room.setsessiondata {"data":{"key1":"value1","key2":"value2"}}
     >>> sv->success
     >>>
     >>> request connector.getsessiondata
     >>> sv->{"Data":{"key1":"value1","key2":"value2"}}
     >>>
     >>> request room.room.getsessiondata
     >>> sv->{"Data":{"key1":"value1","key2":"value2"}}
    

    前后端服务器都可以 get/set 这个 sessiondata,其中的区别在哪里?上代码!

    image-20230926140349424.png

    可以看到前后端的实现都差不多,但是 Room.SetSessionData 里多了一个 API 调用 PushToFront

    PushToFront

    后端服务器如果想修改会话数据,必须要推送到前端服务器,这个在官方文档也有说明:

    Backend sessions have access to the sessions through the handler’s methods, but they have some limitations and special characteristics. Changes to session variables must be pushed to the frontend server by calling s.PushToFront (this is not needed for s.Bind operations), setting callbacks to session lifecycle operations is also not allowed. One can also not retrieve a session by user ID from a backend server.

    后端会话可以通过处理方法来访问,但是它们有一些限制和特殊的特征。会话变量的更改必须通过调用 s.PushToFront 推送到前端服务器(s.Bind操作不需要这样做) ,也不允许为会话生命周期设置回调。也不能通过用户 ID 从后端服务器检索会话。

    把推送的代码注释掉,再重新测试,会发现在 Room 侧的 sessiondata 修改并没有生效:

     >>> request room.room.setsessiondata {"data":{"key1":"value1","key2":"value2"}}
     >>> request room.room.getsessiondata
     >>> sv->{"Data":{"ipversion":"ipv4"}}
     >>> request connector.getsessiondata
     >>> sv->{"Data":{"ipversion":"ipv4"}
    

    SendRPC

    后端服务器 Room 可以使用 RPCTo 远程调用前端服务器的开放接口,我们之前说过,RegisterRemote 就是注册了被远程调用的API,本例中,Room 后端服务器就有这样的接口,而前端服务器的 ConnectorRemote 也被注册为了远程服务。

     func (r *Room) SendRPC(ctx context.Context, msg *protos.SendRPCMsg) (*protos.RPCRes, error) {
         logger := pitaya.GetDefaultLoggerFromCtx(ctx)
         ret := &protos.RPCRes{}
         err := r.app.RPCTo(ctx, msg.ServerId, msg.Route, ret, &protos.RPCMsg{Msg: msg.Msg})
         if err != nil {
             logger.Errorf("Failed to execute RPCTo %s - %s", msg.ServerId, msg.Route)
             logger.Error(err)
             return nil, pitaya.Error(err, "RPC-000")
         }
         return ret, nil
     }
    

    我们迂回一下,通过命令行调用 Room.SendRPC,通过远程调用来访问 ConnectorRemote.RemoteFunc,至于 RPCTo 需要传入的参数 ServerId,就在服务器每次开启时会在后台打印的信息里:

    image-20230926144155366.png

    需要远程调用 Connector 前端服务器,这里的 ServerId 就是 type 为 connector 的数据

    接下来看看命令行的请求与回复,还有服务器里的日志打印:

     >>> request room.room.entry
     >>> request room.room.sendrpc {"server_id":"b19b69d0-4def-4766-aa9f-b8395a2fbcd0", "route":"connector.connectorremote.remotefunc", "msg":"this is a remote call"}
     >>> sv->{"Msg":"thisisaremotecall"}
    
     # 前端服务器打印出了日志
     received a remote call with this message: thisisaremotecall
    

    需要注意一点,ServerId 每次开启服务器都会不一样,实际生产环境不是这样去写死调用的,这里只是做测试

    Doc / Descriptor

    connector 中还有 Doc 和 Descriptor 处理函数,在 pitaya-cli README 的解释是:

    For connecting to a server that uses protobuf as serializer the server must implement two routes:

    • Docs: responsible for returning all handlers and the protos used on input and output;
    • Descriptors: The list of protos descriptions, this will be used by the CLI to encode/decode the messages.

    也就是说,如果服务器是使用 protobuf 来序列化(默认使用 json),那么命令行要连接服务器,就需要服务器实现这两个接口

    但是本例中,我一直没有跑通这个代码,而且要使用 protobuf,是需要在 Build App 之前重新设置序列化器,即:

     builder.Serializer = protobuf.NewSerializer()
    

    这个在本例中也没有调用,感觉这个例子是有点问题的,暂时先不研究这个了,之后再自己写 demo 测一下 protobuf 作为序列化器的功能

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论