從實踐到原理,帶你參透 gRPC

image

gRPC 在 Go 語言中大放異彩,愈來愈多的小夥伴在使用,最近也在公司安利了一波,但願這一篇文章能帶你一覽 gRPC 的巧妙之處,本文篇幅比較長,請作好閱讀準備。本文目錄以下:html

image

簡述

gRPC 是一個高性能、開源和通用的 RPC 框架,面向移動和 HTTP/2 設計。目前提供 C、Java 和 Go 語言版本,分別是:grpc, grpc-java, grpc-go. 其中 C 版本支持 C, C++, Node.js, Python, Ruby, Objective-C, PHP 和 C# 支持。java

gRPC 基於 HTTP/2 標準設計,帶來諸如雙向流、流控、頭部壓縮、單 TCP 鏈接上的多複用請求等特性。這些特性使得其在移動設備上表現更好,更省電和節省空間佔用。git

調用模型

image

一、客戶端(gRPC Stub)調用 A 方法,發起 RPC 調用。github

二、對請求信息使用 Protobuf 進行對象序列化壓縮(IDL)。golang

三、服務端(gRPC Server)接收到請求後,解碼請求體,進行業務邏輯處理並返回。web

四、對響應結果使用 Protobuf 進行對象序列化壓縮(IDL)。算法

五、客戶端接受到服務端響應,解碼請求體。回調被調用的 A 方法,喚醒正在等待響應(阻塞)的客戶端調用並返回響應結果。api

調用方式

1、Unary RPC:一元 RPC

image

Server

type SearchService struct{}

func (s *SearchService) Search(ctx context.Context, r *pb.SearchRequest) (*pb.SearchResponse, error) {
    return &pb.SearchResponse{Response: r.GetRequest() + " Server"}, nil
}

const PORT = "9001"

func main() {
    server := grpc.NewServer()
    pb.RegisterSearchServiceServer(server, &SearchService{})

    lis, err := net.Listen("tcp", ":"+PORT)
    ...

    server.Serve(lis)
}
  • 建立 gRPC Server 對象,你能夠理解爲它是 Server 端的抽象對象。
  • 將 SearchService(其包含須要被調用的服務端接口)註冊到 gRPC Server。 的內部註冊中心。這樣能夠在接受到請求時,經過內部的 「服務發現」,發現該服務端接口並轉接進行邏輯處理。
  • 建立 Listen,監聽 TCP 端口。
  • gRPC Server 開始 lis.Accept,直到 Stop 或 GracefulStop。

Client

func main() {
    conn, err := grpc.Dial(":"+PORT, grpc.WithInsecure())
    ...
    defer conn.Close()

    client := pb.NewSearchServiceClient(conn)
    resp, err := client.Search(context.Background(), &pb.SearchRequest{
        Request: "gRPC",
    })
    ...
}
  • 建立與給定目標(服務端)的鏈接句柄。
  • 建立 SearchService 的客戶端對象。
  • 發送 RPC 請求,等待同步響應,獲得回調後返回響應結果。

2、Server-side streaming RPC:服務端流式 RPC

image

Server

func (s *StreamService) List(r *pb.StreamRequest, stream pb.StreamService_ListServer) error {
    for n := 0; n <= 6; n++ {
        stream.Send(&pb.StreamResponse{
            Pt: &pb.StreamPoint{
                ...
            },
        })
    }

    return nil
}

Client

func printLists(client pb.StreamServiceClient, r *pb.StreamRequest) error {
    stream, err := client.List(context.Background(), r)
    ...
    
    for {
        resp, err := stream.Recv()
        if err == io.EOF {
            break
        }
        ...
    }

    return nil
}

3、Client-side streaming RPC:客戶端流式 RPC

image

Server

func (s *StreamService) Record(stream pb.StreamService_RecordServer) error {
    for {
        r, err := stream.Recv()
        if err == io.EOF {
            return stream.SendAndClose(&pb.StreamResponse{Pt: &pb.StreamPoint{...}})
        }
        ...

    }

    return nil
}

Client

func printRecord(client pb.StreamServiceClient, r *pb.StreamRequest) error {
    stream, err := client.Record(context.Background())
    ...
    
    for n := 0; n < 6; n++ {
        stream.Send(r)
    }

    resp, err := stream.CloseAndRecv()
    ...

    return nil
}

4、Bidirectional streaming RPC:雙向流式 RPC

image

Server

func (s *StreamService) Route(stream pb.StreamService_RouteServer) error {
    for {
        stream.Send(&pb.StreamResponse{...})
        r, err := stream.Recv()
        if err == io.EOF {
            return nil
        }
        ...
    }

    return nil
}

Client

func printRoute(client pb.StreamServiceClient, r *pb.StreamRequest) error {
    stream, err := client.Route(context.Background())
    ...

    for n := 0; n <= 6; n++ {
        stream.Send(r)
        resp, err := stream.Recv()
        if err == io.EOF {
            break
        }
        ...
    }

    stream.CloseSend()

    return nil
}

客戶端與服務端是如何交互的

在開始分析以前,咱們要先 gRPC 的調用有一個初始印象。那麼最簡單的就是對 Client 端調用 Server 端進行抓包去剖析,看看整個過程當中它都作了些什麼事。以下圖:服務器

image

  • Magic
  • SETTINGS
  • HEADERS
  • DATA
  • SETTINGS
  • WINDOW_UPDATE
  • PING
  • HEADERS
  • DATA
  • HEADERS
  • WINDOW_UPDATE
  • PING

咱們略加整理髮現共有十二個行爲,是比較重要的。在開始分析以前,建議你本身先想一下,它們的做用都是什麼?大膽猜想一下,帶着疑問去學習效果更佳。網絡

行爲分析

Magic

image

Magic 幀的主要做用是創建 HTTP/2 請求的前言。在 HTTP/2 中,要求兩端都要發送一個鏈接前言,做爲對所使用協議的最終確認,並肯定 HTTP/2 鏈接的初始設置,客戶端和服務端各自發送不一樣的鏈接前言。

而上圖中的 Magic 幀是客戶端的前言之一,內容爲 PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n,以肯定啓用 HTTP/2 鏈接。

SETTINGS

image

image

SETTINGS 幀的主要做用是設置這一個鏈接的參數,做用域是整個鏈接而並不是單一的流。

而上圖的 SETTINGS 幀都是空 SETTINGS 幀,圖一是客戶端鏈接的前言(Magic 和 SETTINGS 幀分別組成鏈接前言)。圖二是服務端的。另外咱們從圖中能夠看到多個 SETTINGS 幀,這是爲何呢?是由於發送完鏈接前言後,客戶端和服務端還須要有一步互動確認的動做。對應的就是帶有 ACK 標識 SETTINGS 幀。

HEADERS

image

HEADERS 幀的主要做用是存儲和傳播 HTTP 的標頭信息。咱們關注到 HEADERS 裏有一些眼熟的信息,分別以下:

  • method:POST
  • scheme:http
  • path:/proto.SearchService/Search
  • authority::10001
  • content-type:application/grpc
  • user-agent:grpc-go/1.20.0-dev

你會發現這些東西很是眼熟,其實都是 gRPC 的基礎屬性,實際上遠遠不止這些,只是設置了多少展現多少。例如像平時常見的 grpc-timeoutgrpc-encoding 也是在這裏設置的。

DATA

image

DATA 幀的主要做用是裝填主體信息,是數據幀。而在上圖中,能夠很明顯看到咱們的請求參數 gRPC 存儲在裏面。只須要了解到這一點就能夠了。

HEADERS, DATA, HEADERS

image

在上圖中 HEADERS 幀比較簡單,就是告訴咱們 HTTP 響應狀態和響應的內容格式。

imgae

在上圖中 DATA 幀主要承載了響應結果的數據集,圖中的 gRPC Server 就是咱們 RPC 方法的響應結果。

image

在上圖中 HEADERS 幀主要承載了 gRPC 狀態 和 gRPC 狀態消息,圖中的 grpc-status 和 grpc-message 就是咱們的 gRPC 調用狀態的結果。

其它步驟

WINDOW_UPDATE

主要做用是管理和流的窗口控制。一般狀況下打開一個鏈接後,服務器和客戶端會當即交換 SETTINGS 幀來肯定流控制窗口的大小。默認狀況下,該大小設置爲約 65 KB,但可經過發出一個 WINDOW_UPDATE 幀爲流控制設置不一樣的大小。

image

PING/PONG

主要做用是判斷當前鏈接是否仍然可用,也經常使用於計算往返時間。其實也就是 PING/PONG,你們對此應該很熟。

小結

image

  • 在創建鏈接以前,客戶端/服務端都會發送鏈接前言(Magic+SETTINGS),確立協議和配置項。
  • 在傳輸數據時,是會涉及滑動窗口(WINDOW_UPDATE)等流控策略的。
  • 傳播 gRPC 附加信息時,是基於 HEADERS 幀進行傳播和設置;而具體的請求/響應數據是存儲的 DATA 幀中的。
  • 請求/響應結果會分爲 HTTP 和 gRPC 狀態響應兩種類型。
  • 客戶端發起 PING,服務端就會迴應 PONG,反之亦可。

這塊 gRPC 的基礎使用,你能夠看看我另外的 《gRPC 入門系列》,相信對你必定有幫助。

淺談理解

服務端

image

爲何四行代碼,就可以起一個 gRPC Server,內部作了什麼邏輯。你有想過嗎?接下來咱們一步步剖析,看看裏面究竟是何方神聖。

1、初始化

// grpc.NewServer()
func NewServer(opt ...ServerOption) *Server {
	opts := defaultServerOptions
	for _, o := range opt {
		o(&opts)
	}
	s := &Server{
		lis:    make(map[net.Listener]bool),
		opts:   opts,
		conns:  make(map[io.Closer]bool),
		m:      make(map[string]*service),
		quit:   make(chan struct{}),
		done:   make(chan struct{}),
		czData: new(channelzData),
	}
	s.cv = sync.NewCond(&s.mu)
	...

	return s
}

這塊比較簡單,主要是實例 grpc.Server 並進行初始化動做。涉及以下:

  • lis:監聽地址列表。
  • opts:服務選項,這塊包含 Credentials、Interceptor 以及一些基礎配置。
  • conns:客戶端鏈接句柄列表。
  • m:服務信息映射。
  • quit:退出信號。
  • done:完成信號。
  • czData:用於存儲 ClientConn,addrConn 和 Server 的channelz 相關數據。
  • cv:當優雅退出時,會等待這個信號量,直到全部 RPC 請求都處理並斷開纔會繼續處理。

2、註冊

pb.RegisterSearchServiceServer(server, &SearchService{})

步驟一:Service API interface

// search.pb.go
type SearchServiceServer interface {
	Search(context.Context, *SearchRequest) (*SearchResponse, error)
}

func RegisterSearchServiceServer(s *grpc.Server, srv SearchServiceServer) {
	s.RegisterService(&_SearchService_serviceDesc, srv)
}

還記得咱們平時編寫的 Protobuf 嗎?在生成出來的 .pb.go 文件中,會定義出 Service APIs interface 的具體實現約束。而咱們在 gRPC Server 進行註冊時,會傳入應用 Service 的功能接口實現,此時生成的 RegisterServer 方法就會保證二者之間的一致性。

步驟二:Service API IDL

你想亂傳糊弄一下?不可能的,請乖乖定義與 Protobuf 一致的接口方法。可是那個 &_SearchService_serviceDesc 又有什麼做用呢?代碼以下:

// search.pb.go
var _SearchService_serviceDesc = grpc.ServiceDesc{
	ServiceName: "proto.SearchService",
	HandlerType: (*SearchServiceServer)(nil),
	Methods: []grpc.MethodDesc{
		{
			MethodName: "Search",
			Handler:    _SearchService_Search_Handler,
		},
	},
	Streams:  []grpc.StreamDesc{},
	Metadata: "search.proto",
}

這看上去像服務的描述代碼,用來向內部表述 「我」 都有什麼。涉及以下:

  • ServiceName:服務名稱
  • HandlerType:服務接口,用於檢查用戶提供的實現是否知足接口要求
  • Methods:一元方法集,注意結構內的 Handler 方法,其對應最終的 RPC 處理方法,在執行 RPC 方法的階段會使用。
  • Streams:流式方法集
  • Metadata:元數據,是一個描述數據屬性的東西。在這裏主要是描述 SearchServiceServer 服務

步驟三:Register Service

func (s *Server) register(sd *ServiceDesc, ss interface{}) {
    ...
	srv := &service{
		server: ss,
		md:     make(map[string]*MethodDesc),
		sd:     make(map[string]*StreamDesc),
		mdata:  sd.Metadata,
	}
	for i := range sd.Methods {
		d := &sd.Methods[i]
		srv.md[d.MethodName] = d
	}
	for i := range sd.Streams {
		...
	}
	s.m[sd.ServiceName] = srv
}

在最後一步中,咱們會將先前的服務接口信息、服務描述信息給註冊到內部 service 去,以便於後續實際調用的使用。涉及以下:

  • server:服務的接口信息
  • md:一元服務的 RPC 方法集
  • sd:流式服務的 RPC 方法集
  • mdata:metadata,元數據

小結

在這一章節中,主要介紹的是 gRPC Server 在啓動前的整理和註冊行爲,看上去很簡單,但其實一切都是爲了後續的實際運行的預先準備。所以咱們整理一下思路,將其串聯起來看看,以下:

image

3、監聽

接下來到了整個流程中,最重要也是你們最關注的監聽/處理階段,核心代碼以下:

func (s *Server) Serve(lis net.Listener) error {
	...
	var tempDelay time.Duration 
	for {
		rawConn, err := lis.Accept()
		if err != nil {
			if ne, ok := err.(interface {
				Temporary() bool
			}); ok && ne.Temporary() {
				if tempDelay == 0 {
					tempDelay = 5 * time.Millisecond
				} else {
					tempDelay *= 2
				}
				if max := 1 * time.Second; tempDelay > max {
					tempDelay = max
				}
				...
				timer := time.NewTimer(tempDelay)
				select {
				case <-timer.C:
				case <-s.quit:
					timer.Stop()
					return nil
				}
				continue
			}
			...
			return err
		}
		tempDelay = 0

		s.serveWG.Add(1)
		go func() {
			s.handleRawConn(rawConn)
			s.serveWG.Done()
		}()
	}
}

Serve 會根據外部傳入的 Listener 不一樣而調用不一樣的監聽模式,這也是 net.Listener 的魅力,靈活性和擴展性會比較高。而在 gRPC Server 中最經常使用的就是 TCPConn,基於 TCP Listener 去作。接下來咱們一塊兒看看具體的處理邏輯,以下:

image

  • 循環處理鏈接,經過 lis.Accept 取出鏈接,若是隊列中沒有需處理的鏈接時,會造成阻塞等待。
  • 若 lis.Accept 失敗,則觸發休眠機制,若爲第一次失敗那麼休眠 5ms,不然翻倍,再次失敗則不斷翻倍直至上限休眠時間 1s,而休眠完畢後就會嘗試去取下一個 「它」。
  • 若 lis.Accept 成功,則重置休眠的時間計數和啓動一個新的 goroutine 調用 handleRawConn 方法去執行/處理新的請求,也就是你們很喜歡說的 「每個請求都是不一樣的 goroutine 在處理」。
  • 在循環過程當中,包含了 「退出」 服務的場景,主要是硬關閉和優雅重啓服務兩種狀況。

客戶端

image

1、建立撥號鏈接

// grpc.Dial(":"+PORT, grpc.WithInsecure())
func DialContext(ctx context.Context, target string, opts ...DialOption) (conn *ClientConn, err error) {
	cc := &ClientConn{
		target:            target,
		csMgr:             &connectivityStateManager{},
		conns:             make(map[*addrConn]struct{}),
		dopts:             defaultDialOptions(),
		blockingpicker:    newPickerWrapper(),
		czData:            new(channelzData),
		firstResolveEvent: grpcsync.NewEvent(),
	}
	...
	chainUnaryClientInterceptors(cc)
	chainStreamClientInterceptors(cc)

	...
}

grpc.Dial 方法其實是對於 grpc.DialContext 的封裝,區別在於 ctx 是直接傳入 context.Background。其主要功能是建立與給定目標的客戶端鏈接,其承擔瞭如下職責:

  • 初始化 ClientConn
  • 初始化(基於進程 LB)負載均衡配置
  • 初始化 channelz
  • 初始化重試規則和客戶端一元/流式攔截器
  • 初始化協議棧上的基礎信息
  • 相關 context 的超時控制
  • 初始化並解析地址信息
  • 建立與服務端之間的鏈接

連沒連

以前聽到有的人說調用 grpc.Dial 後客戶端就已經與服務端創建起了鏈接,但這對不對呢?咱們先鳥瞰全貌,看看正在跑的 goroutine。以下:

image

咱們能夠有幾個核心方法一直在等待/處理信號,經過分析底層源碼可得知。涉及以下:

func (ac *addrConn) connect()
func (ac *addrConn) resetTransport()
func (ac *addrConn) createTransport(addr resolver.Address, copts transport.ConnectOptions, connectDeadline time.Time)
func (ac *addrConn) getReadyTransport()

在這裏主要分析 goroutine 提示的 resetTransport 方法,看看都作了啥。核心代碼以下:

func (ac *addrConn) resetTransport() {
	for i := 0; ; i++ {
		if ac.state == connectivity.Shutdown {
			return
		}
		...
		connectDeadline := time.Now().Add(dialDuration)
		ac.updateConnectivityState(connectivity.Connecting)
		newTr, addr, reconnect, err := ac.tryAllAddrs(addrs, connectDeadline)
		if err != nil {
			if ac.state == connectivity.Shutdown {
				return
			}
			ac.updateConnectivityState(connectivity.TransientFailure)
			timer := time.NewTimer(backoffFor)
			select {
			case <-timer.C:
				...
			}
			continue
		}

		if ac.state == connectivity.Shutdown {
			newTr.Close()
			return
		}
		...
		if !healthcheckManagingState {
			ac.updateConnectivityState(connectivity.Ready)
		}
		...

		if ac.state == connectivity.Shutdown {
			return
		}
		ac.updateConnectivityState(connectivity.TransientFailure)
	}
}

在該方法中會不斷地去嘗試建立鏈接,若成功則結束。不然不斷地根據 Backoff 算法的重試機制去嘗試建立鏈接,直到成功爲止。從結論上來說,單純調用 DialContext 是異步創建鏈接的,也就是並非立刻生效,處於 Connecting 狀態,而正式下要到達 Ready 狀態纔可用。

真的連了嗎

image

在抓包工具上提示一個包都沒有,那麼這算真正鏈接了嗎?我認爲這是一個表述問題,咱們應該儘量的嚴謹。若是你真的想經過 DialContext 方法就打通與服務端的鏈接,則須要調用 WithBlock 方法,雖然會致使阻塞等待,但最終鏈接會到達 Ready 狀態(握手成功)。以下圖:

image

2、實例化 Service API

type SearchServiceClient interface {
	Search(ctx context.Context, in *SearchRequest, opts ...grpc.CallOption) (*SearchResponse, error)
}

type searchServiceClient struct {
	cc *grpc.ClientConn
}

func NewSearchServiceClient(cc *grpc.ClientConn) SearchServiceClient {
	return &searchServiceClient{cc}
}

這塊就是實例 Service API interface,比較簡單。

3、調用

// search.pb.go
func (c *searchServiceClient) Search(ctx context.Context, in *SearchRequest, opts ...grpc.CallOption) (*SearchResponse, error) {
	out := new(SearchResponse)
	err := c.cc.Invoke(ctx, "/proto.SearchService/Search", in, out, opts...)
	if err != nil {
		return nil, err
	}
	return out, nil
}

proto 生成的 RPC 方法更像是一個包裝盒,把須要的東西放進去,而實際上調用的仍是 grpc.invoke 方法。以下:

func invoke(ctx context.Context, method string, req, reply interface{}, cc *ClientConn, opts ...CallOption) error {
	cs, err := newClientStream(ctx, unaryStreamDesc, cc, method, opts...)
	if err != nil {
		return err
	}
	if err := cs.SendMsg(req); err != nil {
		return err
	}
	return cs.RecvMsg(reply)
}

經過概覽,能夠關注到三塊調用。以下:

  • newClientStream:獲取傳輸層 Trasport 並組合封裝到 ClientStream 中返回,在這塊會涉及負載均衡、超時控制、 Encoding、 Stream 的動做,與服務端基本一致的行爲。
  • cs.SendMsg:發送 RPC 請求出去,但其並不承擔等待響應的功能。
  • cs.RecvMsg:阻塞等待接受到的 RPC 方法響應結果。

鏈接

// clientconn.go
func (cc *ClientConn) getTransport(ctx context.Context, failfast bool, method string) (transport.ClientTransport, func(balancer.DoneInfo), error) {
	t, done, err := cc.blockingpicker.pick(ctx, failfast, balancer.PickOptions{
		FullMethodName: method,
	})
	if err != nil {
		return nil, nil, toRPCErr(err)
	}
	return t, done, nil
}

在 newClientStream 方法中,咱們經過 getTransport 方法獲取了 Transport 層中抽象出來的 ClientTransport 和 ServerTransport,實際上就是獲取一個鏈接給後續 RPC 調用傳輸使用。

4、關閉鏈接

// conn.Close()
func (cc *ClientConn) Close() error {
	defer cc.cancel()
    ...
	cc.csMgr.updateState(connectivity.Shutdown)
    ...
	cc.blockingpicker.close()
	if rWrapper != nil {
		rWrapper.close()
	}
	if bWrapper != nil {
		bWrapper.close()
	}

	for ac := range conns {
		ac.tearDown(ErrClientConnClosing)
	}
	if channelz.IsOn() {
		...
		channelz.AddTraceEvent(cc.channelzID, ted)
		channelz.RemoveEntry(cc.channelzID)
	}
	return nil
}

該方法會取消 ClientConn 上下文,同時關閉全部底層傳輸。涉及以下:

  • Context Cancel
  • 清空並關閉客戶端鏈接
  • 清空並關閉解析器鏈接
  • 清空並關閉負載均衡鏈接
  • 添加跟蹤引用
  • 移除當前通道信息

Q&A

1. gRPC Metadata 是經過什麼傳輸?

image

2. 調用 grpc.Dial 會真正的去鏈接服務端嗎?

會,可是是異步鏈接的,鏈接狀態爲正在鏈接。但若是你設置了 grpc.WithBlock 選項,就會阻塞等待(等待握手成功)。另外你須要注意,當未設置 grpc.WithBlock 時,ctx 超時控制對其無任何效果。

3. 調用 ClientConn 不 Close 會致使泄露嗎?

會,除非你的客戶端不是常駐進程,那麼在應用結束時會被動地回收資源。但若是是常駐進程,你又真的忘記執行 Close語句,會形成的泄露。以下圖:

3.1. 客戶端

image

3.2. 服務端

image

3.3. TCP

image

4. 不控制超時調用的話,會出現什麼問題?

短期內不會出現問題,可是會不斷積蓄泄露,積蓄到最後固然就是服務沒法提供響應了。以下圖:

image

5. 爲何默認的攔截器不能夠傳多個?

func chainUnaryClientInterceptors(cc *ClientConn) {
	interceptors := cc.dopts.chainUnaryInts
	if cc.dopts.unaryInt != nil {
		interceptors = append([]UnaryClientInterceptor{cc.dopts.unaryInt}, interceptors...)
	}
	var chainedInt UnaryClientInterceptor
	if len(interceptors) == 0 {
		chainedInt = nil
	} else if len(interceptors) == 1 {
		chainedInt = interceptors[0]
	} else {
		chainedInt = func(ctx context.Context, method string, req, reply interface{}, cc *ClientConn, invoker UnaryInvoker, opts ...CallOption) error {
			return interceptors[0](ctx, method, req, reply, cc, getChainUnaryInvoker(interceptors, 0, invoker), opts...)
		}
	}
	cc.dopts.unaryInt = chainedInt
}

當存在多個攔截器時,取的就是第一個攔截器。所以結論是容許傳多個,但並無用。

6. 真的須要用到多個攔截器的話,怎麼辦?

可使用 go-grpc-middleware 提供的 grpc.UnaryInterceptor 和 grpc.StreamInterceptor 鏈式方法,方便快捷省心。

單單會用還不行,咱們再深剖一下,看看它是怎麼實現的。核心代碼以下:

func ChainUnaryClient(interceptors ...grpc.UnaryClientInterceptor) grpc.UnaryClientInterceptor {
	n := len(interceptors)
	if n > 1 {
		lastI := n - 1
		return func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
			var (
				chainHandler grpc.UnaryInvoker
				curI         int
			)

			chainHandler = func(currentCtx context.Context, currentMethod string, currentReq, currentRepl interface{}, currentConn *grpc.ClientConn, currentOpts ...grpc.CallOption) error {
				if curI == lastI {
					return invoker(currentCtx, currentMethod, currentReq, currentRepl, currentConn, currentOpts...)
				}
				curI++
				err := interceptors[curI](currentCtx, currentMethod, currentReq, currentRepl, currentConn, chainHandler, currentOpts...)
				curI--
				return err
			}

			return interceptors[0](ctx, method, req, reply, cc, chainHandler, opts...)
		}
	}
    ...
}

當攔截器數量大於 1 時,從 interceptors[1] 開始遞歸,每個遞歸的攔截器 interceptors[i] 會不斷地執行,最後才真正的去執行 handler 方法。同時也常常有人會問攔截器的執行順序是什麼,經過這段代碼你得出結論了嗎?

7. 頻繁建立 ClientConn 有什麼問題?

這個問題咱們能夠反向驗證一下,假設不公用 ClientConn 看看會怎麼樣?以下:

func BenchmarkSearch(b *testing.B) {
	for i := 0; i < b.N; i++ {
		conn, err := GetClientConn()
		if err != nil {
			b.Errorf("GetClientConn err: %v", err)
		}
		_, err = Search(context.Background(), conn)
		if err != nil {
			b.Errorf("Search err: %v", err)
		}
	}
}

輸出結果:

... connection error: desc = "transport: Error while dialing dial tcp :10001: socket: too many open files"
    ... connection error: desc = "transport: Error while dialing dial tcp :10001: socket: too many open files"
    ... connection error: desc = "transport: Error while dialing dial tcp :10001: socket: too many open files"
    ... connection error: desc = "transport: Error while dialing dial tcp :10001: socket: too many open files"
FAIL
exit status 1

當你的應用場景是存在高頻次同時生成/調用 ClientConn 時,可能會致使系統的文件句柄佔用過多。這種狀況下你能夠變動應用程序生成/調用 ClientConn 的模式,又或是池化它,這塊能夠參考 grpc-go-pool 項目。

8. 客戶端請求失敗後會默認重試嗎?

會不斷地進行重試,直到上下文取消。而重試時間方面採用 backoff 算法做爲的重連機制,默認的最大重試時間間隔是 120s。

9. 爲何要用 HTTP/2 做爲傳輸協議?

許多客戶端要經過 HTTP 代理來訪問網絡,gRPC 所有用 HTTP/2 實現,等到代理開始支持 HTTP/2 就能透明轉發 gRPC 的數據。不光如此,負責負載均衡、訪問控制等等的反向代理都能無縫兼容 gRPC,比起本身設計 wire protocol 的 Thrift,這樣作科學很多。@ctiller @滕亦飛

10. 在 Kubernetes 中 gRPC 負載均衡有問題?

gRPC 的 RPC 協議是基於 HTTP/2 標準實現的,HTTP/2 的一大特性就是不須要像 HTTP/1.1 同樣,每次發出請求都要從新創建一個新鏈接,而是會複用原有的鏈接。

因此這將致使 kube-proxy 只有在鏈接創建時纔會作負載均衡,而在這以後的每一次 RPC 請求都會利用本來的鏈接,那麼實際上後續的每一次的 RPC 請求都跑到了同一個地方。

注:使用 k8s service 作負載均衡的狀況下

總結

  • gRPC 基於 HTTP/2 + Protobuf。
  • gRPC 有四種調用方式,分別是一元、服務端/客戶端流式、雙向流式。
  • gRPC 的附加信息都會體如今 HEADERS 幀,數據在 DATA 幀上。
  • Client 請求若使用 grpc.Dial 默認是異步創建鏈接,當時狀態爲 Connecting。
  • Client 請求若須要同步則調用 WithBlock(),完成狀態爲 Ready。
  • Server 監聽是循環等待鏈接,若沒有則休眠,最大休眠時間 1s;若接收到新請求則起一個新的 goroutine 去處理。
  • grpc.ClientConn 不關閉鏈接,會致使 goroutine 和 Memory 等泄露。
  • 任何內/外調用若是不加超時控制,會出現泄漏和客戶端不斷重試。
  • 特定場景下,若是不對 grpc.ClientConn 加以調控,會影響調用。
  • 攔截器若是不用 go-grpc-middleware 鏈式處理,會覆蓋。
  • 在選擇 gRPC 的負載均衡模式時,須要謹慎。

參考

相關文章
相關標籤/搜索