gRPC实现原理1

前面的文章讲了架构,从这篇文章开始将详细分析实现架构的各种技术。首先从RPC开始,为了明白rpc做了什么工作,解决了什么问题,这里选取gRPC框架作为分析对象。

目录

HTTP2

整体概念

启动http2

规范定义的其它能力

gRPC

整体概念

代码分析


HTTP2

整体概念

grpc是在http2上实现的,所以我们要先了解它。而http2是在tcp上实现的,先回忆一下tcp的知识吧。

先介绍概念,http2的一个连接对应一个tcp连接,一个http2连接中又划分出很多流stream,流是并行的,意味着应用程序可以并行地发送接受数据。一个流中有很多帧frame,如果一些应用数据message很小,它们会放在同一个帧上,如果一个message很大,它会被拆分到多个帧上。下面画个图

http2连接就像cpu,流就像进程,在只有一个cpu的情况下,通过调度算法让多个进程交替运行,类比一下,在只有一个http2连接的情况下,通过调度算法让多个流交替发送消息。也就是说,流提供了并发能力,允许应用程序在同一个连接上同时发送多个消息,如下图:有r1、r2、r3、r4四个请求,因为请求数据大小不同,r1被切分为5个帧、r2一个帧、r3和r4同为两个帧,它们在三个流上并发地被发送出去

http2连接是长期存在的,而流通常是短暂的,流可以看作是一个帧序列,帧控制着流的生命周期。也就是说发送一个特定的帧可以改变对应流的状态。生命值周期如下:

这里的状态图先有个印象,等介绍完帧再回过头来看

  • send:端点发送一个帧
  • recv:端点接收一个帧
  • H:HEADERS类型的帧(紧接着会介绍帧)
  • ES:标志位的END_STREAM位被设置为1
  • R: RST_STREAM类型的帧
  • PP: PUSH_PROMISE类型的帧

流中包含了许多帧,帧有固定的格式

HTTP Frame {
  Length (24), // Payload的长度
  Type (8), // 帧的类型

  Flags (8), // 标志位,不同类型的帧有不同的值

  Reserved (1), // 保留,无含义
  Stream Identifier (31), // 流标识符,是个数字

  Frame Payload (..), // 具体的数据,根据帧类型不同而不同
}

帧的类型有:

1、DATA帧

该类型的帧用于携带请求或响应数据

DATA Frame {
  Length (24),
  Type (8) = 0x00, // 值为0

  // 这是8位的Flags
  Unused Flags (4),
  PADDED Flag (1), // 是否填充
  Unused Flags (2),
  END_STREAM Flag (1), // 是否关闭流

  Reserved (1),
  Stream Identifier (31),

  // 这是Length字段指示的长度
  [Pad Length (8)], // 当PADDED=1时该字段有含义,指示了Padding的长度
  Data (..), // 真正的数据,长度=Length-PadLength
  Padding (..2040), // 全部为0的填充
}

pad填充用于安全目的,掩盖data的真正长度;

如果END_STREAM为1,该流进入半关闭或关闭状态(见上面的流生命周期)

2、HEADERS帧

该类型的帧用于打开一个流

HEADERS Frame {
  Length (24),
  Type (8) = 0x01, // 职值为1

  // 这是8位的Flags
  Unused Flags (2),
  PRIORITY Flag (1), // 优先级相关,等于1时Exclusive、Stream Dependency、Weight生效
  Unused Flag (1),
  PADDED Flag (1),
  END_HEADERS Flag (1),
  Unused Flag (1),
  END_STREAM Flag (1),

  Reserved (1),
  Stream Identifier (31),

  [Pad Length (8)],
  [Exclusive (1)],
  [Stream Dependency (31)],
  [Weight (8)],
  Field Block Fragment (..), // 字段块
  Padding (..2040),
}

3、SETTINGS帧

用于客户端控制服务端的行为(也可以反过来),始终作用于整个连接

SETTINGS Frame {
  Length (24),
  Type (8) = 0x04,

  Unused Flags (7),
  ACK Flag (1), // 表示该帧是一个确认帧,发出者确认接收到并使用前面接收到的设置

  Reserved (1),
  Stream Identifier (31) = 0,

  // 具体的配置项
  Setting (48) ...,
}

Setting {
  Identifier (16),
  Value (32),
}

(1)规范中已经定义了几个设置项

  • SETTINGS_HEADER_TABLE_SIZE:字段块相关
  • SETTINGS_ENABLE_PUSH :启用或禁用服务器推送。如果服务器收到此参数设置为0,则不得发送PUSH_PROMISE帧
  • SETTINGS_ENABLE_PUSH :指示发送方将允许的最大并发流数。这个限制是有方向的:它适用于发送方允许接收方创建的流的数量。最初,此值没有限制。建议该值不小于100,以免不必要地限制并行度。
  • SETTINGS_INITIAL_WINDOW_SIZE: 指定流量控制的初始窗口大小,作用于所有流
  • SETTINGS_MAX_FRAME_SIZE :指示发送方愿意接收的最大帧有效负载的大小,也就是Frame Payload的长度
  • SETTINGS_MAX_HEADER_LIST_SIZE: 

(2)SETTINGS帧如何交互

未设置ACK标志的SETTINGS帧的接收者必须在收到后尽快应用更新的设置。SETTINGS帧按照它们被接收的顺序被确认。

SETTINGS 帧中的值必须按照它们出现的顺序进行处理,一旦处理完所有值,接收方必须立即发出一个设置了ACK标志的SETTINGS帧。在接收到设置了ACK标志的SETTINGS帧后,更改设置的发送方可以依赖已应用的最旧的未确认SETTINGS帧中的值。

如果SETTINGS帧的发送者在合理的时间内没有收到确认,它可能会发出一个SETTINGS_TIMEOUT类型的连接错误 。在设置超时时,需要为对等方的处理延迟留出一些余地,仅基于端点之间的往返时间的超时可能会导致虚假错误。

4、PUSH_PROMISE帧

推送相关的,暂不介绍

5、PING帧

是一种用于测量来自发送方的最小往返时间以及确定空闲连接是否仍然有效的机制,它是作用于连接的,可以从任何端点发送。

PING Frame {
  Length (24) = 0x08,
  Type (8) = 0x06,

  Unused Flags (7),
  ACK Flag (1),

  Reserved (1),
  Stream Identifier (31) = 0,

  Opaque Data (64), // 必须包含一个64位的数据
}

发送方必须在PING帧中包含一个64位的Opaque Data。接收方收到后,必须发送确认帧,ack置为1,并携带相同的Opaque Data。

6、GOAWAY帧

用于关闭连接,优雅关闭流。暂时不做介绍

7、WINDOW_UPDATE帧

用于流量控制

启动http2

规定了如何建立一个http2连接

s1、握手

客户发送一个特定字符串:PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n,后面跟着一个空SETTINGS帧。服务端接收到并校验成功后,需要发送一个空SETTINGS帧。每个端都需要对接收到的空SETTINGS帧发送一个ack SETTINGS帧。如果一切顺利,一条http2连接就建立成功了。

package main

import (
	"encoding/hex"
	"errors"
	"fmt"
	"net"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	go server()
	time.Sleep(time.Second)
	go client()
	c := make(chan os.Signal, 1)
	signal.Notify(c, syscall.SIGHUP, syscall.SIGQUIT, syscall.SIGTERM, syscall.SIGINT)
	for {
		s := <-c
		switch s {
		case syscall.SIGQUIT, syscall.SIGTERM, syscall.SIGINT:
			return
		case syscall.SIGHUP:
		default:
			return
		}
	}
}

const (
	address = "127.0.0.1:8000"
	tcp     = "tcp"
	magic   = "505249202a20485454502f322e300d0a0d0a534d0d0a0d0a" // PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n
)

var (
	emptySettingFrame = []byte{0, 0, 0, 4, 0, 0, 0, 0, 0}
	ackSettingFrame   = []byte{0, 0, 0, 4, 1, 0, 0, 0, 0}
)

// 服务端
func server() {
	listener, err := net.Listen(tcp, address)
	if err != nil {
		fmt.Println(err)
		return
	}
	defer listener.Close()
	for {
		conn, err := listener.Accept()
		if err != nil {
			fmt.Println(err)
			return
		}
		err = shakeHandsServer(conn)
		if err != nil {
			fmt.Println(err)
			return
		}

	}
}

// 服务端握手
func shakeHandsServer(conn net.Conn) error {
	// 读取连接前言magic
	var buf [128]byte
	n, err := conn.Read(buf[:])
	if err != nil {
		fmt.Println(err)
		return err
	}
	recvStr := string(buf[:n])
	fmt.Println(recvStr)
	recvMagic := hex.EncodeToString(buf[:n])
	if recvMagic != magic {
		return errors.New(fmt.Sprintf("bad magic:=%s", recvMagic))
	}
	// 读取连接前言emptySettingFrame
	n, err = conn.Read(buf[:])
	if buf[:n][3] != 4 {
		return errors.New(fmt.Sprintf("bad emptySettingFrame:=%v", buf[:n]))
	}
	// 发送自己的连接前言emptySettingFrame
	_, err = conn.Write(emptySettingFrame)
	if err != nil {
		fmt.Println(err)
		return err
	}
	// 确认刚刚收到的客户端前言emptySettingFrame
	_, err = conn.Write(ackSettingFrame)
	if err != nil {
		fmt.Println(err)
		return err
	}
	// 等待获取客户端对自己的ack
	n, err = conn.Read(buf[:])
	if buf[:n][3] != 4 && buf[:n][4] != 1 {
		return errors.New(fmt.Sprintf("bad clent ack:=%v", buf[:n]))
	}
	return nil
}

// 客户端
func client() {
	conn, err := net.Dial(tcp, address)
	if err != nil {
		fmt.Println(err)
		return
	}
	defer conn.Close()
	err = shakeHandsClient(conn)
	if err != nil {
		fmt.Println(err)
		return
	}

}

// 客户端握手
func shakeHandsClient(conn net.Conn) error {
	// 发送连接前言:magic、空setting帧
	bs0, _ := hex.DecodeString(magic)
	_, err := conn.Write(bs0)
	if err != nil {
		fmt.Println(err)
		return err
	}
	_, err = conn.Write(emptySettingFrame)
	if err != nil {
		fmt.Println(err)
		return err
	}
	// 等待服务端前言:空setting帧
	var buf [128]byte
	var n int
	n, err = conn.Read(buf[:])
	if err != nil {
		fmt.Println(err)
		return err
	}
	if buf[:n][3] != 4 {
		return errors.New(fmt.Sprintf("bad emptySettingFrame:=%v", buf[:n]))
	}
	// 针对服务端返回的空setting帧,写ack setting帧
	_, err = conn.Write(ackSettingFrame)
	if err != nil {
		fmt.Println(err)
		return err
	}
	// 针对自己刚刚发送的空setting帧,等待获取服务端的ack
	n, err = conn.Read(buf[:])
	if err != nil {
		fmt.Println(err)
		return err
	}
	if buf[:n][3] != 4 && buf[:n][4] != 1 {
		return errors.New(fmt.Sprintf("bad server ack:=%v", buf[:n]))
	}
	return nil
}

校验我们的代码是否正确,打开wireshark抓包,选择本地回环,并在过滤框中输入 tcp.port==8000 回车

 可以看到某些条目已经被wireshark识别为http2了。选择一个条目,在下面的详情框中可以看到HyperText Transfer Protocol 2(http2)的帧字节。

 如果你那里不显示http2,请在任意条目上右键,选择decode as...,在弹出框中选择http2即可

s2、打开流

完成握手后,发送一个HEADERS帧用于打开流。回忆一下流的生命周期图。

这里不再代码实现了,大家试试自己完成吧!送上h2的规范文档,祝君顺利RFC9113https://httpwg.org/specs/rfc9113.html

s3、

打开流后,就可以发送DATA帧进行数据传输。这里不再代码实现了,大家试试自己完成吧!

规范定义的其它能力

流控:功能上类似与tcp流控,这里的流控借助WINDOW_UPDATE帧实现,不仅能控制单个流的发送接受窗口,还能控制整个连接的

安全:http2要求必须使用TLS1.2及以上用来确保传输安全,也就是常说的https。并在此基础上提出许多安全问题

错误:定义了常见的错误类型

......

如果你想更加全面地了解http2,请阅读以下官方规范

HTTP Documentationhttps://httpwg.org/specs/

学习h2最好的办法是一边阅读规范,一边参考博客,一边动手抓包,一边编写代码,这样才可以有更深入的理解

gRPC

整体概念

这里侧重讲解grpc是如何构建在h2之上的。grpc引入以下概念:通道、远程过程调用(RPC) 和消息。三者的关系很简单:每个通道可能有很多RPC,而每个RPC可能有很多消息,如下图

再看一下grpc语义与h2的关系,通道channel中包含多个连接conn,conn是实际的h2连接。流stream(rpcs)在conn上调度。在go中,grpc通道的概念对应ClientConn对象,而其中包含的连接对应SubConn。RPCS对应Stream对象

在工程实现上,grpc分为下层和上层。上层指grpc协议实现与api,下层指解析器和负载均衡器。

为了对上层保持透明和便捷,引入了解析器负载均衡器,它们的作用是保持连接活跃、健康、可重复使用。解析器负责从一个名称地址得到ip地址,然后交给负载均衡器。负载均衡器负责从这些地址中创建出n个连接,并维护一个连接池。

这里总结一下grpc中的概念,通道channel是grpc中一个‘健康灵活’的连接,它包含许多subConn,subConn代表h2连接,用于实际的数据传输。Stream(rpcs)与h2流的生命周期对应,但功能上不仅是对其的包装,更是提供了一个消息的传输层,包括解压缩、底层连接、元数据等等。

代码分析

首先编写一个grpc服务端和客户端。完整代码在这里

服务端:

package main

import (
	protos "awesomeProject/api"
	"context"
	"fmt"
	"google.golang.org/grpc"
	"net"
)

func main() {
	rpcs := grpc.NewServer()
	protos.RegisterGreetsServer(rpcs, &Server{})
	lis, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", 8000))
	if err != nil {
		panic(err)
	}
	defer lis.Close()
	if err = rpcs.Serve(lis); err != nil {
		panic(err)
	}
}

type Server struct {
	*protos.UnimplementedGreetsServer
}

func (s Server) SayHello(ctx context.Context, request *protos.HelloRequest) (*protos.HelloReply, error) {
	return &protos.HelloReply{
		Name: "aaa1",
	}, nil
}

客户端:注意一下注释

package main

import (
	protos "awesomeProject1/api"
	"context"
	"fmt"
	"google.golang.org/grpc"
	"os"
	"os/signal"
	"syscall"
)

func main() {
    // 点进grpc.Dial方法
	conn, err := grpc.Dial("127.0.0.1:8000", grpc.WithInsecure())
	if err != nil {
		panic(err)
	}
	defer conn.Close()
	cc := protos.NewGreetsClient(conn)
	for i := 0; i < 3; i++ {
		hello, err1 := cc.SayHello(context.Background(), &protos.HelloRequest{
			Name: "xx1",
		})
		if err != nil {
			fmt.Println(err1)
		}
		fmt.Println(hello)
	}
	
	c := make(chan os.Signal, 1)
	signal.Notify(c, syscall.SIGHUP, syscall.SIGQUIT, syscall.SIGTERM, syscall.SIGINT)
	for {
		s := <-c
		switch s {
		case syscall.SIGQUIT, syscall.SIGTERM, syscall.SIGINT:
			return
		case syscall.SIGHUP:
		default:
			return
		}
	}
}

先看客户端,按照以下顺序点击下去。main—>grpc.Dial—>DialContext(这是入口代码)

1、创建一个解析器builder

2、创建一个负载均衡器,启动事件监听

3、根据解析器builder创建解析器,并解析地址,将获得的地址传递给负载均衡器,负载均衡器接收到消息后,开始创建连接等操作

点进2——newCCBalancerWrapper(创建负载均衡器包装者)方法

1、启动事件监听

2、创建实际的负载均衡器

点进1——watcher(事件监听)方法

解析器如何将地址传递给负载均衡器呢,答案就是放到ccb.updateCh中,利用go语言chan的特性,一旦放进去,负载均衡器就会马上接收到,颇有些事件驱动的感觉。

解析器放进去的类型是ccStateUpdate,那么逻辑就走到2,一路点下去:

balancer_conn_wrappers.go watcher->ccb.handleClientConnStateChange->ccb.balancer.UpdateClientConnState->balToUpdate.UpdateClientConnState->
pickfirst.go UpdateClientConnState->b.cc.NewSubConn->b.subConn.Connect
balancer_conn_wrappers.go Connect->go acbw.ac.connect->ac.resetTransport->ac.tryAllAddrs->ac.createTransport->transport.NewClientTransport->newHTTP2Client->
t.conn.Write

经过层层包装,到这里就见到了熟悉的h2:发送连接前言PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n,发送seeting帧,启动子协程发送ping帧保活连接等等,如下图

1、发送连接前言

2、发送seeting帧

3、保活我没有圈起来,大家自己找找吧~

在上面层层调用中,我只列举了1和2两点与h2相关的。与grpc状态相关的如下:(按照调用链的顺序列举)

(1)在pickfirst.go UpdateClientConnState方法中,创建h2连接之前更新了通道的状态为连接中

 注意下这里的blockingpicker。

(2)在ac.resetTransport(创建h2连接)方法之前,更新了subConn的状态为连接中

 

 注意这里的事件对象用的是scStateUpdate,是针对updateUp进行操作的。那么负载均衡器的事件监听就会执行对应的操作3

顺着ccb.handleSubConnStateChange方法一路点下去,发现最终也是对blockingpicker进行了更新。注意下,这里包含了一个特殊的err,balancer.ErrNoSubConnAvailable。

(3)进入ac.resetTransport方法,在调用ac.tryAllAddrs之前又更新了一次subConn状态,但这次没有了err

 (4)进入ac.tryAllAddrs->ac.createTransport->ac.startHealthCheck,可以看到defer函数最终会把该subConn状态设置为READY,前面说过,它会针对updateCh塞进去一个scStateUpdate类型的对象,负载均衡器会收到该事件,最终将blockingpicker状态更新为READY。

除此以外,还会启用一个协程做健康检查,更新该连接的状态。

 

到这里为止,客户端已经创建一个通道、一条实际的h2连接,并更新了各自的状态。

我们回到自己编写的客户端代码那里,接着一步步点下去

 cc.SayHello->SayHello->c.cc.Invoke->Invoke->invoke->newClientStream->newClientStreamWithParams->op闭包->a.getTransport->cs.cc.getTransport->cc.blockingpicker.pick->pick->p.Pick

pick方法用于获取一个h2连接

 1、p.Pick调用的就是blockingpicker中的方法

2、而上面我们创建conn中的时候,会往blockingpicker塞进去一个状态:连接中、err=balancer.ErrNoSubConnAvailable,就与这里相对应的。如果检测到此错误,说明连接尚未建立好,便continue循环等待一会

3、一旦连接建立好,就塞进去READY状态,这里可以走到3的逻辑,获取到连接返回

回到调用链的op闭包方法,a.getTransport上面已经讲了,获取到可用的h2连接后,再new一个Stream就返回了。a.newStream中其实就处理了解压缩器、header字段等等。

再往前回到调用链的invoke方法

1、前面讲了,获取一个Stream

2、利用得到的Stream发送消息(Stream中已经包含了h2连接、h2流id、解压缩、header等等基础信息)

 

grpc客户端到这里就算结束了,只讲了主要的流程,其中有很多细节都略过了(所以墙裂推荐自己看一看源码)。下一篇将讲服务端是如何实现的,并总结grpc的特点,升华主题,得到RPC的一些技术共性、特点。

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值