对比Web开发与游戏开发中的RPC

799 阅读9分钟

什么是RPC

RPC众所周知是远程过程调用,如何理解呢?首先什么是本地过程调用:就是任何代码触发了内存中(或者磁盘里)其他一段代码的执行,就是所谓了过程调用;而远程过程调用就是设备1上试图运行代码A,但是代码部署在设备2上,尝试在2上运行A的过程就是RPC。(我们最常见的前后端分离场景下,前端发Ajax触发后端crud就是一个RPC的实现)

Web开发中的RPC

笔者是Java初学者,以下web开发视角局限于Java

RPC的首次概念出现在:在微服务架构中,不同微服务间的相互调用需要一种比REST更轻的方式 —— 传统通过发HTTP来完成服务间的调用虽然可以实现,但是HTTP协议的冗余部分较多(或者说为了适配各种场景,设计就需要更普适一点)。

RPC的思路就是从自身微服务的业务角度出发,定义一套能够在方法调用方和方法提供方间明确语义的协议。

以下是一个标准定义的RPC过程

image.png

角色与数据流转如下:

  1. 客户端(client)以本地调用方式(即以接口的方式)调用服务;
  2. 客户端存根(client stub)接收到调用后,负责将方法、参数等组装成能够进行网络传输的消息体(将消息体对象序列化为二进制 byte[]);
  3. 客户端通过sockets将消息发送到服务端;
  4. 服务端存根( server stub)收到消息后进行解码(将消息对象反序列化);
  5. 服务端存根( server stub)根据解码结果调用本地的服务;
  6. 本地服务执行并将结果返回给服务端存根( server stub);
  7. 服务端存根( server stub)将返回结果打包成消息(将结果消息对象序列化);
  8. 服务端(server)通过sockets将消息发送到客户端;
  9. 客户端存根(client stub)接收到结果消息,并进行解码(将结果消息反序列化);
  10. 客户端(client)得到最终结果。

市面上许多RPC框架的作业,都是将2,3,4,7,8封装起来(包括基于HTTP协议的REST);

反向思考:RPC框架的作用是:给网络上传输的二进制流 在冗余信息量最小的情况下,赋予明确的意义

上述数据流转过程使用到如下技术 —— 动态代理、序列号、NIO、注册中心... 详情参考书籍

image.png

问题

  1. 市面有哪些RPC框架?
对比项DubbogRPCbrpcThrift
公司AliGoogleBaiduFaceBook
通讯协议tcp/httphttp2多种协议tcp/http
序列化协议可扩展protobufprotobuf/json/mcpack可扩展
开发语言Java跨语言C++ / Java跨语言
主要特点服务治理、扩展性跨语言、性能高性能、扩展性跨语言
github star36.9K33.5K12.9K8.9K

着重介绍下一位学长参与的trpc

trpc.io/

  1. 架构

    image.png

    主要特点:

    1. 明晰的分层 & 分模块

      • 架构由“框架” & “插件”两部分组成。虚线框内为tRPC,中间红色实线框为框架核心部分,蓝色实线框为插件部分

        将很多功能以插件的形式提供有很多好处:其实仔细一想这和Java世界依赖倒置是一样的,都是将上层和下层统一依赖于抽象。

        举例而言:在之前高耦合性下的架构下,序列化方案想由JSON变成ProtoBuf,那可能会在代码中每个消息处理的位置都手动修改。

        而TRPC的实现方式大致为: 将插件功能串在一条过滤器链filter chain中,通过AOP的方式给每个消息都赋予过滤器链上的功能/任务;详情后面细聊

        插件能提供哪些功能?博客上将基本分为两类 —— 个性化需求(比如 校验,请求回访,故障注入)& 服务治理(比如 性能监控,鉴权,日志,调用链跟踪...)

        详情见博客 cloud.tencent.com/developer/a…

    2. 性能据说很好

      image.png (测试机型与环境:测试机型:腾讯云标准型 SA2 CVM虚拟机,CPU处理器型号 AMD EPYC™ Rome,8核,2.60GHz,内存16G。测试场景和数据:吞吐测试:调用方的 P99 延时在 10ms 左右时,测量服务的 QPS。)

    3. TODO

  2. 业务数据流转

    大佬箴言:学习一个框架,最有效的方法就是先掌握它最基本的业务过程,其次再从各个流程分叉点延伸到各个角落,这样才不会在框架大量的代码中迷失了方向。

    参考博客:blog.hackerpie.com/posts/archi…

image.png

流程如下:

  1. 客户端通过tRPC框架 完成请求内容的内存结构化;示例代码:

    image.png

  2. 客户端通过服务发现机制获取目标服务,也就是被调用方的网络地址和端口 示例代码如下:

    image.png

    SayHello中,调用c.client.Invoke()完成RPC调用,接下来看看Invoke

    image.png 看到倒数第三行c.fixFilters(),是过滤器相关内容,接着进去

    image.png 代码功能在过滤器链上添加了selectorFilter,猜测应该是负责服务发现的拦截器

    回到2中的图 —— 倒数第一行callFunc是核心的调用逻辑

    image.png

    具体方法及功能就是RPC的基本数据流转模式

    • prepareRequestbuf,方法完成整个请求的序列化和编码
    • opts.Transport.RoundTrip,完成传输层的调用
    • opts.Codec.Decode(msg, rspbuf),完成响应消息的解码
    • processResponseBuf 函数,完成的是响应消息的反序列化等

完整功能:

image.png

细节:

  1. 序列化 & 压缩

    image.png tRPC-Go 框架早期内置支持 pb、json 和 jce 三种序列化格式,以及 gzip、snappy 压缩格式,但是以现在的版本看的话,已经在框架内部又增加了更多的格式。tRPC 生成的桩代码里,默认会使用 pb 序列化格式,并且不使用任何压缩算法:

    image.png

    要了解框架对于序列化和压缩的实现逻辑,我们需要在前面解读 tRPC-Go 请求过程源码中提到的 prepareRequestBuf 函数着眼:

    image.png

    tRPC-Go 框架实现将序列化和压缩逻辑放在了一个独立的函数 serializeAndCompress 里

    image.png

    //TODO

游戏开发中的RPC

游戏开发比传统Web开发似乎更需要高效的RPC通信方式,因为游戏的客户端和服务器交互更加频繁,不同业务的消息结构天差地别。

历史发展:

  • 最早期的游戏框架,多数都是client—>server—>db的模式,但随着玩家数量的增长,一台server一个进程就会扛不住;

  • 合理的想法是:根据游戏业务进行纵向拆分,比如说把聊天,场景,战斗,支付等部署到不同服务器上(微服务)这种思路直观看来又好 又好;

    image.png

  • 但是问题在于——游戏开发不同于Web开发,分service的开发十分困难,将传统基于进程的开发转变成基于service的开发更是困难。解决方案是:RPC

    image.png

    具体思路是:网关gateway中注册有各个service所提供的方法,客户端只需对gateway提出对某个方法的调用,即由底层完成对具体service的远程过程调用

参考博客:www.cnblogs.com/cr1719/p/13…

某游戏项目中 RPC的具体使用

由于游戏业务和web业务有所不同,其架构和RPC调用模式也不同;同时由于游戏公司开源程度不高,各个公司或各个游戏的架构还有区别。以下是在不触及公司不可泄漏信息情况下的正常技术交流。

  • 对上游而言,如何准确的调用想要的服务

    在NPC场景下,例如Java的Spring项目,Spring的IOC容器中管理所有bean对象,由bean对象完成对内部方法的调用;RPC场景下的需求也是这样:需要一个功能来找到这些接口(或者是能找到接口的具体位置)

    和Spring的思路一致,在项目启动时扫描全包,将有@Bean注解标识的类加载成bean,我们这里将声明RPC的方法加载成lua-table的一个元素。

    1. 玩家登录时,初始化Player的所有Component,过程中会收集Component的所有RPC接口

      伪代码:

        local ComponentManager = {}
        function ComponentManager:Init()
            self.modules = {}
            local modList = ServerUtil.GetModuleListByDir({'Component',"apps/GameServer/Game"})
            -- 类似反射,获取目录下所有Component
            for i,modName in pairs(modList) do
                local mod = require(modName)
                self.modules[mod._name] = mod
                System.ImportRpcModule(modName)
                -- 手机具有RPC标记的接口位置
            end
            self:InitComponentList()
            -- 根据组件列表,加载依赖
            end
      
    2. 在importRpcModule中,将接口的元数据加载如table

        function System.ImportRpcModule(requirePath, modName)
            local module = require(requirePath)
            -- 注册RPC
            System.Register(requirePath, modName or module.__name, module)
        return module
        end
      
    3. 将所有RPC接口平铺 / 按模块 / 某种高效查找的数据结构如搜索树 加载在内存 / 发给网关,调用方即可高效查询 + 调用

      除此之外,RPC的使用往往会外加装饰器模式;换句话说,就是将普通的NPC代码通过装饰器模式赋予RPC的功能。(装饰器模式感觉天生适合NPC->RPC的升级)

    • 对于装饰器模式,面向对象有面向对象的实现模板,面向函数也有面向函数的实现模板;但其基本思想都是用盔甲武装真身,但本质还是真身行动
    • 在RPC中,装饰器模式的使用很优雅
      RPC()
      function Test:TestFunc()
          -- 本地代码
      end
      
      @RPC
      public void test(){
          // 本地代码
      }
      
      对于业务代码而言:只需要在本地方法上添加标记即可将方法注册到RPC服务; 之后对RPC接口的管理即可如上方所述
  • 游戏行业分层有所不同

    1. 在Web开发中 服务端的服务又是无状态的,区分不同用户的方式是传入的Uid不同。

    2. 在游戏开发中 首先要明确以下几点:

      • skynet框架底层客户端与服务器间通信使用UDP
      • 服务端的服务是由具体的Player对象/线程调用的(这也就是在各种方法里不传入用户ID的原因);这和Java开发中 ThreadLocal的用法相近

      那么问题来了:使用UDP没有维护连接,失去session存储信息的能力,那是怎么找到对应player线程的呢?

      思路有点暴力,就是在UDP中携带用户信息,并在服务端抽象出一层来专门做Player映射。

      所以这样一来,请求的调用层次就成了:客户端将Call的消息按照Sprotobuf协议发往Player层,Player层找到对应玩家的对象/线程,并识别出Call的参数,之后由rpc_dispatch模块寻找到具体服务位置,完成函数执行。

综上所述,RPC在Web开发和游戏开发中,解决的痛点有所不同;Web中是为了减少微服务间通信的成本,游戏中是为了统一各个服务中 能力的提供。但话又说回来,这只是我片面的理解,希望大佬多多指点