gRPC学习
单体架构的缺点
- 一旦某个服务宕机,会引起整个应用不可用,隔离性差
- 只能整体应用进行伸缩,浪费资源,可伸缩性差
- 代码耦合在一起,可维护性差
不同的服务访问量不一样
微服务架构
负载均衡 -> 网关 -> 各个拆分的服务(隔离性比较好,有单独的对应服务的数据库)
上述架构,解决了单体架构的弊端
但是引入了新的问题:
- 代码冗余
- 服务和服务之间存在调用关系
代码冗余问题:
服务未拆分之前,公共的功能有统一的实现,比如认证、授权、限流等,但是服务拆分之后,每一个服务可能都需要实现一遍
解决方案:
- 由于为了保持对外提供服务的一致性,引入了网关的概念,由网关根据不同的请求,将其转发到不同的服务(路由功能),由于入口的一致性,可以在网关上实现公共的一些功能
- 可以将公共的功能抽取出来,形成一个新的服务,比如统一认证中心
服务之间的调用:
服务拆分后,服务和服务之间发生的是进程和进程之间的调用,服务器和服务器之间的调用
那么就需要发起网络调用,网络调用我们立马想到的就是http,但是在微服务架构中,http虽然便捷方便,但性能较低 ,这时候就需要引入RPC(远程过程调用),通过自定义协议发起TCP调用,来加快传输效率
每个服务由于可能分布在成千上百台机器上,服务和服务之间的调用,会出现一些问题,比如如何知道应该调用哪台机器上的服务,调用方式可能需要维护被调用方的地址,这个地址可很多,增加了额外的负担,这时候就需要引入服务治理
服务治理中有一个重要的概念服务发现,服务发现中有个重要概念叫做注册中心
服务和服务之间调用会发生一些问题,为了避免产生连锁的雪崩反应,引入了服务容错,为了追踪一个调用所经过的服务,引入了链路追踪,等等这些就构建了一个微服务的生态
gRPC
gRPC是一款语言中立、平台中立,开源的远程过程调用系统,gRPC客户端和服务端可以在多种环境中运行和交互,例如用java写一个服务端,可以用go语言写客户端调用
数据在进行网络传输的时候,需要进行序列化,序列化协议有多种比如xml、json、protobuf等gRPC默认使用protocol buffers,这是google开源的一套成熟的结构数据序列化机制
在学习gRPC之前,需要先了解protocol buffers
序列化:将数据结构或对象转换成二进制串的过程
反序列化:将在序列化过程中产生的二进制串转换成数据结构或对象的过程
protobuf
google开源的一种数据格式,适合高性能,对响应速度有要求的数据传输场景。因为profobuf是二进制数据格式,需要编码和解码,数据本身不具有可读性,因为只能反序列化之后得到真正可读的数据
优势:
- 序列化后体积想对Json和XML很小,适合网络传输
- 支持跨平台多语言
- 消息格式升级和兼容性还不错
- 序列化反序列化速度很快
安装:
-
根据不同的操作系统下载不同的包
-
配置环境变量,解压之后的bin目录
-
安装go专业的protoc的生成器
go get github.com/golang/protobuf/protoc-gen-go #这个好像不对 go install github.com/golang/protobuf/protoc-gen-go
安装后会在GOPATH目录下生成可执行文件,protobuf的编译器插件protoc-gen-go,执行protoc命令会自动调用这个插件
如何使用protobuf呢?
- 定义一种源文件,扩展名为.proto,使用这种源文件,可以定义存储类的内容(消息类型)
- protobuf有自己的编译器protoc,可以将.proto编译成对应语言的文件,就可以进行使用了
编写文件
// 指定当前的proto语法的版本,有2和3
syntax = "proto3";
// option go_package = "path;name"; ath表示生成的go文件的存放地址,会自动生成目录
// name 表示生成的go文件所属的包名
option go_package = "../service";
// 指定等会文件生出来的package
// 消息 传输的对象
message User{
string username = 1;
int32 age = 2;
}
终端使用命令
protoc --go_out=./ .\user.proto
proto文件介绍
message介绍
message:protobuf
中定义一个消息类型是通过关键字message
字段指定的
消息就是需要传输的数据格式的定义
message关键字类似于C++中的class,Java中的class,go中的struct
例如:
// 消息 传输的对象
message User{
string username = 1;
int32 age = 2;
}
在消息中承载的数据分别对应的于每一个字段
其中每个字段都有一个名字和一种类型
字段规则:
- required:消息体中必填字段,不设置会导致编解码异常
- optional:消息体中可选字段
- repeated:消息体中可重复字段,重复的值的顺序会被保留,在go中重复的会被定义为切片
message User {
string username = 1;
int32 age = 2;
optional string password = 3;
repeated string address = 4;
}
字段映射:
.protoType | Notes | C++ Type | Python Type | Go Type |
---|---|---|---|---|
double | double | float | float64 | |
float | float | float | float32 | |
int32 | 使用变长编码,对于负值的效率很低,如果你的域有可能负值,请使用sint64替代 | int32 | int | int32 |
uint32 | 使用边长编码 | uint32 | int/long | uint32 |
uint64 | 使用边长编码 | uint64 | int/long | uint64 |
sint32 | 使用边长编码,这些编码在负值时比int32高效的多 | int32 | int | int32 |
sint64 | 使用边长编码,有符号的整型值。编码时比通常的int64高效 | int64 | int/long | int64 |
fixed32 | 总是4个字节,如果数值总是比228大的话,这个类型会比uint32高效 | uint32 | int | uint32 |
fixed64 | 总是8个字节,如果数值总是比256大的话,这个类型会比uint64高效 | uint64 | int/long | uint64 |
sfixed32 | 总是4个字节 | int32 | int | int32 |
sfiexed64 | 总是8个字节 | int64 | int/lng | int64 |
bool | bool | bool | bool | |
string | 一个字符串必须是UTF-8编码或者7-bit ASCII编码的文本 | string | str/unicode | string |
bytes | 可能包含任意顺序的字节数据 | string | str | []byte |
默认值
protobuf3删除了protobuf2中用来设置默认值的default关键字,取而代之的是protobuf3为各类型定义的默认值,也就是约定的默认值,如下表所示:
类型 | 默认值 |
---|---|
bool | false |
整型 | 0 |
string | 空字符串"" |
枚举enum | 第一个枚举元素的值,因为Protobuf3强制要求第一个枚举元素的值必须是0,所以枚举的默认值就是0 |
message | 不是null,而是DEFAULT_INSTANCE |
标识号:
在消息体的定义中,每个字段都必须要有一个唯一的标识号,标识号是[0,2^29-1]范围内的一个整数
message User {
string username = 1; // 位置1
int32 age = 2;
optional string password = 3;
repeated string address = 4; // 位置4
}
以Person为例,name=1,id=2,email=3,phones=4中的1-4就是标识号
定义多个消息类型:
一个proto文件中可以定义多个消息类型
message UserRequest {
string username = 1; // 位置1
int32 age = 2;
optional string password = 3;
repeated string address = 4; // 位置4
}
message UserResponse {
string username = 1;
int32 age = 2;
optional string password = 3;
repeated string address = 4;
}
嵌套消息:
可以在其他消息类型中定义,使用消息类型,在下面的例子中,Person消息就定义在PersonInfo消息内
如:
message PersonInfo {
message Person {
string name = 1;
int32 height = 2;
repeated int32 weight = 3;
}
repeated Person info = 1;
}
如果你想在它的父消息类型的外部重用这个消息类型,你需要以PersonInfo.Person的形式使用它,如:
message PersonMessage {
PersonInfo.Person info = 1;
}
也可以将消息嵌套任意多层
message Grandpa {
message Father {
message son {
string name = 1;
int32 age = 2;
}
}
message Uncle {
message Son {
string name = 1;
int32 age = 2;
}
}
}
定义服务:
如果想要消息类型用在PRC系统中,可以在.proto文件中定义一个RPC服务接口,protocol buffer编译器将会根据所选择的不同语言生成服务接口代码及存根
service SearchService {
// rpc 服务的函数名 (传入参数) 返回 (返回参数)
rpc Search (SearchRequest) returns (SearchResponse);
}
上述代表表示,定义了一个RPC服务,该方法接收SearchRequest返回SearchResponse
RPC和gRPC:
RPC(Remote Procedure Call) 远程过程调用协议,一种通过网络从远程计算机上请求服务,而不需要了解底层网络技术的协议。RPC假定某些协议的存在,例如TCP/UDP等,为通信程序之间携带信息数据。在OSI七层模型中,RPC跨越了传输层和应用层,RPC使得开发,包括网络分布式多程序在内的应用程序更加容易。
过程是什么?过程就是业务处理、计算任务、更直白的说,就是程序,就像调用本地方法一样调用远程的过程
RPC采用客户端/服务端的模式,通过request-response消息模式实现
gRPC 里客户端应用可以像调用本地对象一样直接调用另一台不同的机器上服务端应用的方法,使得您能够更容易地创建分布式应用和服务。与许多 RPC 系统类似,gRPC 也是基于以下理念: 定义一个服务,指定其能够被远程调用的方法(包含参数和返回类型)。在服务端实现这个接口,并运行一个 gRPC 服务器来处理客户端调用。在客户端拥有一个存根能够像服务端一样的方法。
gRPC基于http2
- HTTP/1里的header对应HTTP/2里的HEADERS frame
- HTTP/1里的payload对应HTTP/2里的DATA frame
gRPC把元数据放到HTTP/2 Headers里,请求参数序列化之后放到DATA frame里
基于HTTP/2协议的优点:
- 公开标准
- HTTP/2前身是Google的SPDY,有经过实践检验
- HTTP/2天然支持物联网、手机、浏览器
- 基于HTTP/2多语言客户端实现容易
- 每个流行的编程语言都会有成熟的HTTP/2 Client
- HTTP/2 Client是经过充分测试,可靠的
- 用Client发送HTTP/2请求的难度远低于socket发送报数据/解析数据包
- HTTP/2支持Stream和流控
- 基于HTTP/2在Gateway/Proxy很容易支持
- nginx和envoy都有支持
- HTTP/2安全性有保证
- HTTP/2天然支持SSL,当然gRPC可以跑在clear text协议(即不加密)上
- 很多私有协议的rpc可能自己包装了一层TLS支持,使用起来也非常复杂。开发者是否有足够的安全知识?使用者是否配置对了?运维者是否能正确理解?
- HTTP/2在共有网络上传输有保证,比如CRIME攻击,私有协议很难保证没有这样子的漏洞
- HTTP/2鉴权成熟
- 从HTTP/1发展起来的鉴权系统已经很成熟,可以无缝用在HTTP/2上
- 可以从前端到后端完全打通的鉴权,不需要组做任何转换适配
基于HTTP/2协议的缺点:
-
rpc的元数据的传输不够高效
尽管HPAC可以压缩HTTP Header,但是对于rpc来说,确定一个函数调用,,可以简化为一个int,只要两端去协商过一次,后面直接查表就可以了,不需要像HPAC那样编码解码可以考虑专门对gRPC做一个优化过的HTTP/2解析器,减少一些通用的处理,感觉可以提升性能
-
HTTP/2 里一次gRRC调用需要解码两次
一次是HEADERS frame,次是DATA frame
-
HTTP/2 标准本身是只有一个TCP连接,但是实际在gRPC里是会有多个TCP连接,使用时需要注意
gRPC选择基于HTTP/2,那么它的性能肯定不会是最顶尖的。但是对于rpc来说中庸的qps可以接受,通用和兼容性才是最重要的事情。
gRPC目前是k8s生态里的事实标准,而Kubernetes又是容器编排的事实标准。gRPC已经广泛应用于lstio体系,包括:
- Envoy与Pilot(现在叫istiod)间的XDS协议
- mixer的handler扩展协议
- MCP(控制面的配置分发协议)
在Cloud Native的潮流下,开放互通的需求必然会产生基于HTTP/2的RPC
gRPC调用实例:
// 这个就是protobuf的中间文件
// 指定的当前proto语法的版本,有2有3
syntax = "proto3";
option go_package="../service";
// 指定等会文件生成出来的package
package service;
// 定义request model
message ProductRequest{
int32 prod_id = 1; //1代表顺序
}
// 定义response model
message ProductResponse{
int32 prod_stock = 1; //1代表顺序
}
// 定义服务主体
service ProdService{
// 定义方法
rpc GetProductStock(ProductRequest) returns(ProductResponse);
}
终端输入:
protoc --go_out=plugins=grpc:./ .\product.proto
认证:
客户端和服务端之间调用,我们可以通过加入证书的方式,实现调用的安全性
TLS(Transport Layer Security安全传输层)TLS是建立在传输层TCP协议之上的协议,服务于应用层,它的前身是SSL(Secure Socket Layer安全套接字),它实现了将应用层的报文进行加密后再交由TCP进行传输的功能
TLS协议主要解决如下三个网络安全问题
- 保密:保密通过加密encryption实现,所有信息都加密传输,第三方无法嗅探
- 完整性:通过MAC校验机制,一旦被篡改,通信双方会立刻发现
- 认证:双方认证,双方都可以配备证书,防止身份被冒充
生成自签证书:
生产环境可以购买整数或者使用一些平台发放的免费证书
-
安装openssl
-
生成私钥文件
# 需要输入密码 openssl genrsa -des3 -out server.key 2048
-
创建证书请求
openssl req -new -key server.key -out server.csr
-
生成ca.crt
openssl x509 -req -days 365 -in ca.csr -signkey server.key -out ca.crt
找到openssl.cnf文件
-
打开copy_extensions = copy // 原来有注释,放开
-
打开 req_extensions = v3_req
-
找到[v3_req]添加subjectAltName = @alt_names
-
添加新的标签[alt_names],和标签字段
DNS.1 = *.mszlu.com # 可以添加多个
-
生成整数私钥server.key
openssl genpkey -algorithm RSA -out server.key
-
通过私钥server.key生成证书请求文件server.csr
openssl req -new -nodes -key server.key -out server.csr -days 3650 -config ./openssl.cnf -extensions v3_req
-
生成SAN证书
openssl x509 -req -days 365 -in server.csr -out server.pem -CA ca.crt -CAkey server.key -CAcreateserial -extfile ./openssl.cnf -extensions v3_req
- key:服务器上的私钥文件,用于对发送给客户端数据的加密,以及对从客户端接收到数据的解密
- csr:证书签名请求文件,用于提交给证书颁发机构 (CA) 对证书签名
- crt:由证书颁发机构(CA)签名后的证书,或者是开发者自签名的证书,包含证书持有人的信息,持有人的公钥,以及签署者的签名等信息
- pem:是基于Base64编码的证书格式,扩展名包括PEM、CRT和CER
什么是 SAN?
SAN (Subject Alternative Name) 是 SSL 标准 509 中定义的一个扩展。使用了 SAN 字段的 SSL 证书,可以扩展此证书支持的域名,使得一个证书可以支持多个不同域名的解析
服务端应用证书
单项认证:
将server.key和server.pem 复制到程序中
// 添加整数 grpc_server.go
creds, err2 := credentials.NewServerTLSFromFile("cert/server.pem","cert/server.key")
if err2 != nil {
log.Fatal("证书生成错误",err2)
}
// grpc_client.go
creds, err2 := credentials.NewClientTLSFromFile("cert/server.pem","*.mszlu.com") // 第二个参数是上面第四步配置的DNS1那个内容
if err2 != nil {
log.Fatal("证书错误",err2)
}
双向认证:
上面的server.pem和server.key是服务端的公钥和私钥
如果双向认证,客户端也需要生成对应的公钥和私钥
私钥:
openssl genpkey -algorithm RSA -out client.key
证书:
openssl req -new -nodes -key client.key -out client.csr -days 3650 -config ./openssl.cnf -extensions v3_req
SAN证书:
openssl x509 -req -days 365 -in client.csr -out client.pem -CA ca.crt -CAkey ca.key -CAcreateserial -extfile ./openssl.cnf -extensions v3_req
服务端:
func main() {
//证书认证-双向认证
// 从证书相关文件中读取和解析信息,得到证书公钥、密钥对
cert, err := tls.LoadX509KeyPair("cert/server.pem","cert/server.key")
if err != nil {
log.Fatal("证书读取错误",err)
}
// 创建一个新的、空的CertPool
certPool := x509.NewCertPool()
ca, err := ioutil.ReadFile("cert/ca.crt")
if err != nil {
log.Fatal("ca证书读取错误",err)
}
// 尝试解析传入的PEM编码的证书,如果解析成果将会将其加到CertPool中,便于后面的使用
certPool.AppendCertsFromPEM(a)
// 构建基于TLS的TransportCredentials选项
creds := credentials.NewTLS(&tls.Config{
// 设置证书时,允许包含一个或多个
Certificates:[]tls.Certificate{cert},
// 要求必须校验客户端的证书,可以根据实际情况选用以下参数
ClientAuth: tls.RequireAndVerifyClientCert,
// 设置根证书的集合,校验方式使用ClientAuth 中设定的模式
ClientCAs:certPool,
})
rpcServer := grpc.NewServer(grpc.Creds(creds))
// 注册了接口
service.RegisterProdServiceServer(rpcServer, service.ProductService)
}
Token认证:
服务端添加用户名密码的校验
// 实现token认证,需要合法的用户名和密码
// 实现一个拦截器
var authInterceptor grpc.UnaryServerInterceptor
authInterceptor = func(
ctx content.Context
req interface{}
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
)(resp interface{}, err error){
// 拦截普通方法请求,验证Token
err = Auth(ctx)
if err != nil {
return
}
// 继续处理请求
return handler(ctx, req)
}
func Auth(ctx context.Context) error {
// 实际上 就是拿到传输的用户名和密码
mb,ok := metadata.FromIncomingContext(ctx)
if !ok {
return fmt.Errorf("missing credentials")
}
var user string
var password string
if val,ok := md["user"]; ok {
user = val[0]
}
if val, ok := md["password"];ok {
password = val[0]
}
if user != "admin" || password != "admin" {
return status.Errorf(codes.Unauthenticated, "token不合法")
}
return nil
}
客户端实现
客户端实现需要实现PerRPCcredentials接口
type PerRPCCredentials interface {
GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error)
RequireTransportSecurity() bool
}
GetRequestMetadata方法返回认证需要的必要信息,RequireTransportSecurity 方法表示是否启用安全连接,在生产环境中,一般都是启用的,但为了测试方便,暂时这里不启用了
实现接口:
type Authentication struct {
User string
Password string
}
func (* Authentication) GetRequestMetadata(context.Context,...string) (
map[string]string , error
) {
return map[string][string]{"user":a.User,"password":a.Password}, nil
}
func (a *Authentication) RequireTransportSecurity() bool {
return false
}
更换protoc-gen-go生成器
gPRC官方文档 https://grpc.io/
官方使用的和上面写的不一致
-
Install the protocol compiler plugins for Go using the following commands:
$ go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.28 $ go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.2
-
Update your
PATH
so that theprotoc
compiler can find the plugins:$ export PATH="$PATH:$(go env GOPATH)/bin"
github的方式需要使用 --go_out=plugins=grpc来去进行生成,而在golang.org方式中,弃用了这种方式,使用protoc-gen-go将不再支持gRPC service的定义,需要使用新的插件protoc-gen-go-grpc
所以这里需要重新实现一下服务的和客户端
-
安装完成后在gopath的bin目录下生成
-
protoc-gen-go.exe
-
protoc-gen-go-grpc.exe
-
-
重新生成proto文件重新生成go文件:
protoc --go_out=./service --go-grpc_out=./service pbfile\product.proto
-
修改之前的service实现
import使用
用于导入其他的proto文件
import的路径是从当前终端的路径开始,保持一致
任意类型Any
// 使用any类型,需要导入这个
import "google/protobuf/any.proto"
// 定义入参消息
message HelloParam{
// any,带不可以是任何类型
google.protobuf.Any data = 1;
}
stream
在 HTTP/1.1 的时代,同一个时刻只能对一个请求进行处理或者响应,换句话说,下一个请求必须要等当前请求处理完才能继续进行.
HTTP/1.1需要注意的是,在服务端没有response的时候,客户端是可以发起多个request的,但服务端依旧是顺序对请求进行处理,并按照收到请求的次序予以返回
HTTP/2 的时代,多路复用的特性让一次同时处理多个请求成为了现实,并且同一个 TCP 通道中的请求不分先后、不会阳塞,HTTP/2 中引入了流(Stream)和 (Frame) 的概念,当 TCP 道建立以后,后续的所有操作都是以流的方式发送的,而二进制帧则是组成流的最小单位,属于协议层上的流式传输
HTTP/2 在一个 TCP 连接的基础上虚拟出多个 Stream, Stream 之间可以并发的请求和处理, 并且HTTP/2 以二进制帧 (frame) 的方式进行数据传送,并引入了头部压缩(HPACK),大大提升了交互效率
定义:
// 普通 RPC
rpc SimplePing(PingRequest) returns (PingReply);
// 客户端流式 RPC
rpc ClientStreamPing(stream PingRequest) returns (PingReply);
// 服务器端流式 RPC
rpc ServerStreamPing(PingRequest) returns (stream PingReply);
// 双向流式 RPC
rpc BothStreamPing(stream PingRequest) returns (stream PingReply);
stream 关键字,当该关键字修饰参数时,表示这是一个客户端流式的 gRPC 接口;当该参数修饰返回值时,表示这是一个服务器端流式的 gRPC 接口;当该关键字同时修饰参数和返回值时,表示这是一个双向流式的 gRPC 接口。
客户端流:
客户端可以源源不断的发数据
rpc UpdateStockclientStream(stream ProductRequest) returns(ProductResponse);
服务端流:
rpc GetMultiStockServerStream(ProductRequest) returns(stream ProductResponse);
双向流:
rpc SayHelloStream(stream ProductRequest) returns(stream ProductResponse);