实战系列:(八)Go语言中使用gRPC

写在前面

       首先声明这篇文章只是介绍gRPC在Go语言中的使用入门级的文章,不包含多少深入的内容,读者对象是gRPC的初学者,如果你是一位gRPC和Go的资深开发者,请绕行,没必要浪费时间!

正文

       没用过RPC都不好意思说自己从事过互联网开发,今天就来讲讲gRPC在Go中的应用。
       所谓RPC(remote procedure call远程过程调用),实际上是提供了一套框架机制,使得位于网络中的不同机器上的应用程序之间可以进行通信相互调用,而且也遵从server/client模型。使用的时候客户端调用server端提供的接口就像是调用本地的函数一样。通常RPC都是通过反射机制来实现的,本文不做深入分析,待后续文章再深入分析RPC的实现原理。
       与其他的RPC框架类似,gRPC在服务端提供一个gRPC Server,客户端的库是gRPC Stub。典型的场景是客户端发送请求,调用服务端的接口,客户端和服务端之间的通信协议是基于HTTP2的,支持双工的流式保序消息,性能比较好,同时也很轻量级。

gRPC架构图

       既然是server/client模型,那么我们直接用restful api不是也可以的吗,为什么还需要RPC(或者gRPC)呢?下面我们就来看看gRPC相对于Restful API到底有哪些优势?gRPC和restful API都提供了一套通信机制,用于server/client模型通信,而且它们都使用http作为底层的传输协议。不过gRPC还是有些特有的优势的,如下:

  1. gRPC可以通过protobuf来定义接口,从而可以有更加严格的接口约束条件;
  2. 另外,通过protobuf可以将数据序列化为二进制编码,这会减少需要传输的数据量,从而提高性能;
  3. gRPC可以方便地支持流式通信(理论上通过http2.0就可以使用streaming模式);

接下来我们看看gRPC官网上的描述

A high performance, open-source universal RPC framework.

1. Simple service definition
2. Works across languages and platforms
3. Start quickly and scale
4. Bi-directional streaming and integrated auth

       简单易学,快速开始,能够支持多种语言和平台,双向流式通讯、集成认证模块。有谁在使用gRPC呢?还是有不少知名公司在用的。

Who’s using it?  Square, NETFLIX, Core OS, carbon3D, CISCO, JUNIPER 

更详细的描述和使用场景:

gRPC is a modern open source high performance RPC framework that can run 
in any environment. It can efficiently connect services in and across data 
centers with pluggable support for load balancing, tracing, health checking 
and authentication. It is also applicable in last mile of distributed computing 
to connect devices, mobile applications and browsers to backend services.

The main usage scenarios:
1. Efficiently connecting polyglot services in microservices style architecture
2. Connecting mobile devices, browser clients to backend services
3. Generating efficient client libraries
4. Core Features that make it awesome:
    a. Idiomatic client libraries in 10 languages
    b. Highly efficient on wire and with a simple service definition framework
    c. Bi-directional streaming with http/2 based transport
    d. Pluggable auth, tracing, load balancing and health checking

支持的开发语言和平台:

Language Platform Compiler
C/C++ Linux/Mac GCC 4.8+ Clang 3.3+
C/C++ Windows 7+ Visual Studio 2015+
C# Linux/Mac .NET Core, Mono 4+
C# Windows 7+ .NET Core, .NET 4.5+
Dart Windows/Linux/Mac Dart 2.0+
Go Windows/Linux/Mac Go 1.6+
Java Windows/Linux/Mac JDK 8 recommended. Gingerbread+ for Android
Node.js Windows/Linux/Mac Node v4+
Objective-C Mac OS X 10.11+/iOS 7.0+ Xcode 7.2+
PHP (Beta) Linux/Mac PHP 5.5+ and PHP 7.0+
Python Windows/Linux/Mac Python 2.7 and Python 3.4+
Ruby Windows/Linux/Mac Ruby 2.3+

主流的开发语言和平台基本都支持了。

       官网上写的还是不够详细,我们自己来总结一下吧!首先聊聊使用使用场景:

  1. 需要对接口进行严格约束的情况,我们不希望客户端给我们传递任意的数据,尤其是考虑到安全性的因素,我们通常需要对接口进行更加严格的约束。这时gRPC就可以通过protobuf来提供严格的接口约束;
  2. 对于性能有更高的要求时。有时我们的服务需要传递大量的数据,而又希望不影响我们的性能,这个时候也可以考虑gRPC服务,因为通过protobuf我们可以将数据压缩编码转化为二进制格式,通常传递的数据量要小得多,而且通过http2我们可以实现异步的请求,从而大大提高了通信效率;
  3. 但是,通常我们不会去单独使用gRPC,而是将gRPC作为一个部件进行使用,这是因为在生产环境,我们面对大并发的情况下,需要使用分布式系统来去处理,而gRPC并没有提供分布式系统相关的一些必要组件。而且,真正的线上服务还需要提供包括负载均衡,限流熔断,监控报警,服务注册和发现等必要的组件;
           接下来还得简单介绍一下Protobuf,因为gRPC使用protobuf来定义接口。Protobuf是什么?Protobuf实际是一套类似于Json或者XML的数据传输格式和规范,用于不同应用或进程之间进行通信时使用。通信时所传递的信息是通过Protobuf定义的message数据结构进行打包,然后编译成二进制的码流再进行传输或者存储。

Protobuf有如下优点:

  1. 足够简单;
  2. 序列化后体积很小,消息大小只需要XML的1/10 ~ 1/3;
  3. 解析速度快,解析速度比XML快20 ~ 100倍;
  4. 多语言支持;
  5. 更好的兼容性,Protobuf设计的一个原则就是要能够很好的支持向下或向上兼容;

使用Protobuf有如下几个步骤:

  1. 定义消息;
  2. 初始化消息以及存储传输消息;
  3. 读取消息并解析;

       Protobuf的消息结构是通过一种叫做Protocol Buffer Language的语言进行定义和描述的,实际上Protocol Buffer Language分为两个版本,版本2和版本3,默认不声明的情况下使用的是版本2,目前推荐使用的是版本3。
       采用ProtoBuf作为IDL(Interface Definition Language接口定义语言),需要定义service和message,生成客户端和服务端代码。用户自己实现服务端代码中的调用接口,并且利用客户端代码来发起请求到服务端。service代表RPC接口,message代表数据结构(里面可以包括不同类型的成员变量,包括字符串、数字、数组、字典等)。message中成员变量后面的数字代表进行二进制编码时候的提示信息,1~15表示热变量,会用较少的字节来编码。默认所有变量都是可选的(optional),repeated则表示数组。service rpc接口只能接受单个message 参数,返回单个message。

       到此,我们已经把gRPC相关的基础知识介绍完毕,接下来进入实践环节,以Go语言为例。

       在Go中使用gRPC之前,要先做一些基础性的准备工作。gRPC需要运行在Go的1.6版本以上的环境中。先查看一下你的Go版本:

$ go version

安装gRPC组件

$ go get -u google.golang.org/grpc

安装Protocol Buffer v3及编译器,最简单的方法就是直接下载已经编译好的二进制文件包,下载地址为:https://github.com/google/protobuf/releases

  1. 下载完成后,解压文件;
  2. 更新环境变量,把protoc程序所在的路径加入到环境变量PATH之中;

安装protoc的插件protoc-gen-go

$ go get -u github.com/golang/protobuf/protoc-gen-go

protoc-gen-go将被安装到GOBIN,默认为GOPATH/bin。必须把$GOPATH/bin路径放到PATH环境变量中。

$ export PATH=$PATH:$GOPATH/bin

gRPC服务和数据结构定义在.proto文件中的,使用protoc来将其编译为Go的代码(.pb.go)。

protoc --go_out=plugins=grpc:./ xxx.proto

       基础工作完成就可以实践了,gRPC官方网站有个简单的入门例子,我这里不再重复,有兴趣可以参考https://www.grpc.io/docs/quickstart/go/
       gRPC官网的例子作为入门教程是可以的,但是生产环境下这么简单是不能满足要求的。下面讲述一下生产环境中是如何使用gRPC的,分布式环境,使用consul作为注册中心来注册和发现服务。为了保证数据的安全性,数据需加密传输,使用非对称加密算法,如何生成公钥密钥对以及CA证书不在本文的讨论范围之内,请参考其他相关文档。
       简单说一下Consul,Consul是一个服务网格(微服务间的 TCP/IP,负责服务之间的网络调用、限流、熔断和监控)解决方案,它是一个一个分布式的,高度可用的系统,而且开发使用都很简便。它提供了一个功能齐全的控制平面,主要特点是:服务发现、健康检查、键值存储、安全服务通信、多数据中心。Consul 的主要功能有服务发现、健康检查、KV存储、安全服务沟通和多数据中心。我们这里使用它的服务注册和服务发现的功能。
       闲言少叙,直接上代码来讲解,直观明了!在项目当中,写了一个叫做优惠券(coupon)的模块来提供相应的功能。

Server端代码

先来定义gRPC的接口和数据结构(coupon.proto):

syntax = "proto3";

package coupon;

service coupon {
    rpc GetUserCouponByUserAndOrder (GetUserCouponByUserAndOrderRequest) returns (GetUserCouponByUserAndOrderResponse);
}

message GetUserCouponByUserAndOrderRequest {
    string userId = 1;
    string orderId = 2;
}

message GetUserCouponByUserAndOrderResponse {
    int32 userCouponId = 1;
}

使用protoc编译protobuf文件coupon.proto来生成coupon.pb.go代码。

cd <coupon.proto所在目录>
protoc --go_out=plugins=grpc:./ coupon.proto

coupon.pb.go(部分代码)

// Code generated by protoc-gen-go. DO NOT EDIT.
// source: coupon.proto

package coupon

import (
    context "context"
    fmt "fmt"
    proto "github.com/golang/protobuf/proto"
    grpc "google.golang.org/grpc"
    codes "google.golang.org/grpc/codes"
    status "google.golang.org/grpc/status"
    math "math"
)

// ...... 其他省略

server.go中的相关代码(gRPC服务初始化,使用非对称加密算法实现数据的安全传输,并且把gRPC服务注册到Consul中):

package server

import (
    "crypto/tls"
    "crypto/x509"
    "fmt"
    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials"
    "google.golang.org/grpc/grpclog"
    "google.golang.org/grpc/reflection"
    "io/ioutil"
    "net"
    "time"
    "xxxxx/coupon/grpc/coupon"
    "git.xxxx.net/xxxx/go-common/logger"
    // 其他省略
)

func ServerInit() error {
    logger.Info("gRPC server init")

    // 读取并解析公钥私钥对
    cert, err := tls.LoadX509KeyPair("server.pem", "server.key")
    if err != nil {
        logger.Fatalf("tls.LoadX509KeyPair err: %v", err)
    }

    certPool := x509.NewCertPool()
    ca, err := ioutil.ReadFile("ca.pem")
    if err != nil {
        logger.Fatalf("ioutil.ReadFile err: %v", err)
    }

    if ok := certPool.AppendCertsFromPEM(ca); !ok {
        log.Fatalf("error occurred when certPool.AppendCertsFromPEM")
    }

    c := credentials.NewTLS(&tls.Config{
        Certificates: []tls.Certificate{cert},
        ClientAuth:   tls.RequireAndVerifyClientCert,
        ClientCAs:    certPool,
    })

    // 日志
    grpclog.SetLoggerV2(logger.GetGrpcLogger())

    s := grpc.NewServer(
        grpc.Creds(c),
    )

    coupon.RegisterCouponServer(s, &couponService.Service{})

    reflection.Register(s)

    listener, err := net.Listen("tcp", fmt.Sprintf(":%d", 9503))
    if err != nil {
        logger.Fatal("fail to listen on port 9503")
    }

    go func() {
        logger.Info("gRPC server running in goroutine")

        // register into consul
        err = consul.Register("coupon", "127.0.0.1", 9503, "127.0.0.1:8500", time.Second * 10, 15)
        if err != nil {
            panic(err)
        }
        if err := s.Serve(listener); err != nil {
            logger.Panic("gRPC server init failed,", err)
        }
    }()

    return err
}

coupon_grpc.go中实现远程服务。

package coupon

import (
    "context"
    "xxxxxx/coupon/grpc/coupon"
    "xxxxxx/coupon/models/admin"
)

type Service struct{}

func (s *Service) GetUserCouponByUserAndOrder(ctx context.Context, req *coupon.GetUserCouponByUserAndOrderRequest) (res *coupon.GetUserCouponByUserAndOrderResponse, err error) {
    re := coupon.GetUserCouponByUserAndOrderResponse{}
    userCoupon, err := admin.GetUserCouponByUserAndOrder(req.OrderId, req.UserId)
    if err != nil {
        return res, err
    }

    re.UserCouponId = int32(userCoupon.Id)

    return &re, err
}

main.go中的相关代码:

func init() {
    
    // init gRPC server
    err := server.ServerInit()
    if err != nil {
        logger.Fatal("init gRPC server failed", err)
    }
}

       插一段来讲讲Go语言中的init函数。go语言中init函数用于包(package)的初始化,该函数是go语言的一个重要特性。

  1. init函数是用于程序执行前做包的初始化的函数;
  2. 每个包可以拥有多个init函数;
  3. 包的每个源文件也可以拥有多个init函数;
  4. 同一个包中多个init函数的执行顺序go语言没有明确的定义;
  5. 不同包的init函数按照包导入的依赖关系决定该初始化函数的执行顺序;
  6. init函数不能被其他函数调用,而是在main函数执行之前,自动被调用。
Client端代码

       首先是coupon.proto和coupon.pb.go文件,可以直接从server端copy,也可以重新生成。
coupon.proto,与server端一致。

syntax = "proto3";

package coupon;

service coupon {
    rpc GetUserCouponByUserAndOrder (GetUserCouponByUserAndOrderRequest) returns (GetUserCouponByUserAndOrderResponse);
}

message GetUserCouponByUserAndOrderRequest {
    string userId = 1;
    string orderId = 2;
}

message GetUserCouponByUserAndOrderResponse {
    int32 userCouponId = 1;
}

coupon.pb.go(部分代码)

// Code generated by protoc-gen-go. DO NOT EDIT.
// source: coupon.proto

package coupon

import (
    context "context"
    fmt "fmt"
    proto "github.com/golang/protobuf/proto"
    grpc "google.golang.org/grpc"
    codes "google.golang.org/grpc/codes"
    status "google.golang.org/grpc/status"
    math "math"
)

// ...... 其他省略

client.go代码,初始化gRPC client,使用非对称加密算法实现数据的安全传输(与server端一致),并且连接注册中心。

package client

import (
    "crypto/tls"
    "crypto/x509"
    "errors"
    "xxxxxx/order/grpc/coupon"
    "xxxxxx/order/logger"
    "io/ioutil"
    "google.golang.org/grpc"
    "google.golang.org/grpc/balancer/roundrobin"
    "google.golang.org/grpc/connectivity"
    "google.golang.org/grpc/credentials"
    "google.golang.org/grpc/grpclog"
)

var couponServerConn *grpc.ClientConn
var couponClient coupon.CouponClient

func GetNewCouponClient() (coupon.CouponClient, error) {
    if !(couponServerConn.GetState() == connectivity.Idle || couponServerConn.GetState() == connectivity.Ready) {
        return nil, errors.New("the couponServerConn state is not idle or ready: " + couponServerConn.GetState().String())
    }

    newClient := coupon.NewCouponClient(couponServerConn)
    return newClient, nil
}

func CouponClientInit() error {
    var err error

    // 读取并解析公钥私钥对
    cert, err := tls.LoadX509KeyPair("client.pem", "client.key")
    if err != nil {
        logger.Fatalf("Load tls.LoadX509KeyPair error: %v", err)
    }

    certPool := x509.NewCertPool()
    ca, err := ioutil.ReadFile("ca.pem")
    if err != nil {
        logger.Fatalf("ioutil.ReadFile error: %v", err)
    }

    if ok := certPool.AppendCertsFromPEM(ca); !ok {
        logger.Fatalf("certPool.AppendCertsFromPEM error")
    }

    c := credentials.NewTLS(&tls.Config{
        Certificates: []tls.Certificate{cert},
        ServerName:   "coupon",
        RootCAs:      certPool,
    })

    // set log
    grpclog.SetLoggerV2(logger.GetGrpcLogger())
    target := "consul://127.0.0.1:8500/coupon?healthy=true&wait=5s"

    // 连接到注册中心
    couponServerConn, err = grpc.Dial(
        target,
        grpc.WithBalancerName(roundrobin.Name),
        grpc.WithTransportCredentials(c))
    if err != nil {
        logger.Fatalf("coupon grpc failed to connect to the given target: %v", err)
    }

    couponClient = coupon.NewCouponClient(couponServerConn)
    return err
}

main.go中的相关代码:

import (
    grpcClient "xxxxxx/order/grpc/client"
)

func init() {
    // ...... 其他代码省略

    // init coupon gRPC client
    err := grpcClient.CouponClientInit()
    if err != nil {
        logger.Fatal("init coupon gRPC client failed", err)
    }

    // ...... 其他代码省略
}

客户端调用远程gRPC服务:

package coupon

import (
    "context"
    "xxxxxx/order/grpc/client"
    "xxxxxx/order/grpc/coupon"
    "time"
)

func GetUserCouponByUserAndOrder(userId string, orderId string) (userCouponId int, err error) {
    couponClient, err := client.GetNewCouponClient()
    if err != nil {
        return userCouponId, err
    }

    ctx := context.Background()

    res, err := couponClient.GetUserCouponByUserAndOrder(ctx, &coupon.GetUserCouponByUserAndOrderRequest{
        UserId:  userId,
        OrderId: orderId,
    })

    if err != nil {
        return userCouponId, err
    }

    return int(res.UserCouponId), nil
}

    2019年12月1日星期天 于北京通州家中

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 207,113评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,644评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,340评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,449评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,445评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,166评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,442评论 3 401
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,105评论 0 261
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,601评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,066评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,161评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,792评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,351评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,352评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,584评论 1 261
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,618评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,916评论 2 344

推荐阅读更多精彩内容