gRPC 快速入门
gRPC 是 Google 开发的一个高性能、开源的 RPC(远程过程调用)框架, 广泛用于微服务架构中。
它基于 HTTP/2 和 Protocol Buffers(protobuf),
支持多语言、双向流式传输、负载均衡等功能,非常适合分布式系统。
核心概念
- RPC:远程过程调用,即客户端可以像调用本地函数一样,调用远程服务器提供的函数。
- Proto Buffers:gRPC 使用 protobuf 来定义消息格式和服务。它是一种高效的二进制序列化协议。
- HTTP/2:gRPC 使用 HTTP/2 协议,支持多路复用、流控制、头部压缩和更低的延迟。
安装 gRPC 和 Protocol Buffers
在你的 Go 环境中安装 gRPC 和 protobuf 插件:
- 安装 gRPC-Go:
go get google.golang.org/grpc
- 安装 Protocol Buffers 编译器(protoc)
macos的如下:
brew install protoc # 其他环境请自查哈哈
如果在win环境中,需要将路径添加到环境变量Path中,方便实用protoc进行代码生成。
- 安装 Go 的代码生成插件
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
下文代码目录结构(可跳过)
tree -L 9 --noreport --charset ascii | sed 's/^/ /' > directory_structure.md
-L: 目录深度。
.
|-- common
| |-- app
| | `-- grpc_client.go
| `-- service
| `-- grpc
| |-- impl
| | `-- user.go
| |-- pb
| | `-- user
| | |-- user.pb.go
| | `-- user_grpc.pb.go
| |-- proto
| | `-- user.proto
| `-- register.go
|-- directory_structure.md
|-- grpc_client
| |-- core
| | `-- core.go
| |-- main.go
| `-- service
| `-- grpc
| `-- client
| `-- user
| `-- user.go
|-- grpc_server
| |-- core
| | `-- core.go
| |-- main.go
| `-- service
目录介绍:
impl: 对pb目录下的xxx_grpc.pb.go中未实现方法的实现的目录。pb: 使用protoc指令,生成的代码文件目录proto: 存放xxx.proto目录。
编写Proto文件
.proto 文件用来描述一个 gRPC 存在的服务和消息格式。
以下为定义一个rpc服务 UserService 并提供 GetUserInfo 方法,并定义该方法的请求参数、响应的消息。
文件:/common/service/grpc/proto/user.proto
syntax = "proto3"; // 指定使用 Protocol Buffers 的 proto3 语法
package user; // 定义包名,类似于命名空间
option go_package ="./user"; // 指定生成的 Go 代码所在的包名以及该代码的导入路径。
import "google/protobuf/timestamp.proto"; // 导入其他 .proto 文件(如有需要)
// 定义服务
service UserService {
// 定义获取用户信息的 RPC 方法
rpc GetUserInfo (GetUserRequest) returns (GetUserResponse);
}
// ---------------------------- GetUserInfo
// 定义 GetUserRequest 请求消息
message GetUserRequest {
int32 user_id = 1; // 用户 ID
}
// 定义 GetUserResponse 响应消息
message GetUserResponse {
User user = 1; // 用户信息
string remark = 2; // 备注信息
google.protobuf.Timestamp resp_at = 3;// 消息回复时间
}
// ---------------------------- 内嵌类型
// 定义用户信息
message User {
int32 id = 1; // 用户 ID
string name = 2; // 用户名
int32 age = 3; // 年龄
Gender gender = 4; // 性别
Address address = 5; // 地址信息
google.protobuf.Timestamp created_at = 6; // 账户创建时间
Status status = 7; // 使用枚举表示用户状态
}
生成代码
同时生成 .pb.go 和 _grpc.pb.go 文件
protoc --go_out=. --go-grpc_out=. --proto_path=. user.proto
--go_out=.: 生成 xxx.pb.go 文件到当前目录下。此处为当前目录下。--go-grpc_out=.: 生成 xxx_grpc.pb.go 文件到当前目录下。此处为当前目录下。--proto_path=.: 用于生成的的 .proto 文件所处的目录。此处为当前目录下。
如此便可生成以下两种文件:
-
xxx.pb.go文件:生成 Protobuf 消息的结构体和序列化/反序列化的代码,专注于数据传输。
-
xxx_grpc.pb.go文件:主要提供与 gRPC 服务相关的代码,包括客户端的调用接口和服务端的注册与实现。它封装了 gRPC 的底层通信逻辑,开发者只需实现接口即可。
基础用法
服务端
服务端方法(默认)
在 xxx_grpc.pb.go 文件中,可以看到下面这种代码:
// UnimplementedUserServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedUserServiceServer struct{}
func (UnimplementedUserServiceServer) GetUserInfo(context.Context, *GetUserRequest) (*GetUserResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetUserInfo not implemented")
}
方法 GetUserInfo 是我们在 .proto 文件中,为 UserService 定义的方法。
需要重写该方法。
服务端方法(重写)
或者说是实现该方法,
- 定义结构体
UserSys,嵌入Unimplemented*,以继承其方法。 - 复制
xxx_grpc.pb.go中原有方法,修改其内部逻辑。
package impl
import (
"context"
"fmt"
pbUser "go-developer/apps/common/service/grpc/pb/user"
"google.golang.org/protobuf/types/known/timestamppb"
"time"
)
type UserSys struct {
pbUser.UnimplementedUserServiceServer
}
func NewUserSys() *UserSys {
return &UserSys{}
}
// GetUserInfo 重写 xxx_grpc.pb.go 的 GetUserInfo
func (sys *UserSys) GetUserInfo(ctx context.Context, req *pbUser.GetUserRequest) (*pbUser.GetUserResponse, error) {
time.Sleep(time.Millisecond) // 模拟查询
return &pbUser.GetUserResponse{
User: &pbUser.User{
Id: req.GetUserId(),
},
Remark: fmt.Sprintf("你好,%d", req.GetUserId()),
RespAt: timestamppb.Now(),
}, nil
}
如此,Grpc 服务端的 UserService 的 GetUserInfo 方法就实现了。
接下来要启动服务,使其可以被外部调用。
启动服务
// 提供一个不安全的GRPC服务。
func ServeGrpcInsecure() {
// 初始化对端口的监听
listener, err := net.Listen("tcp", ":8800")
if err != nil {
panic("rpc port listen failed :" + err.Error())
}
// 初始化grpc服务器
gServer := grpc.NewServer()
// 注册服务
grpc2.RegisterServices(gServer)
// 开始监听并提供 gRPC 服务
err = gServer.Serve(listener)
if err != nil {
panic(err)
}
}
客户端
就像核心概念中说的,客户端可以像调用本地函数一样,调用远程服务器提供的函数。
连接服务
文件:src/common/app/grpc_client.go
import (
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
var (
GrpcClientConn *grpc.ClientConn
err error
)
// 初始化一个不安全的客户端连接
func InitClientInsecure() {
GrpcClientConn, err = grpc.NewClient("127.0.0.1:8800", grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
panic("failed to conn grpc server:" + err.Error())
}
}
调用远程方法
调用的方法是 xxx_grpc.pb.go 中的方法,
- New其服务对应的Client,传入上文中的连接。此处的服务是
UserService,所以为NewUserServiceClient() - 调用需要的方法,传入参数获得响应。
import (
"context"
"go-developer/apps/common/apps"
pbUser "go-developer/apps/common/service/grpc/pb/user"
)
type UserSys struct {
ctx context.Context
}
func NewUserSys(ctx context.Context) *UserSys {
return &UserSys{
ctx: ctx,
}
}
func (sys *UserSys) GetUserInfo(userId int32) interface{} {
req := &pbUser.GetUserRequest{
UserId: userId,
}
// 此处调用的方式是 xxx_grpc.pb.go 中的 New*Client 方法
userClient := pbUser.NewUserServiceClient(apps.Client)
resp, err := userClient.GetUserInfo(sys.ctx, req)
if err != nil {
panic(err)
}
return resp
}
TLS 加密
你可能已经发现,上文中的服务端和客户端之间,其tcp连接并没有使用任何加密方式,是不安全的。
通常用于 TCP 加密的方式是通过 TLS (Transport Layer Security),它提供了通信的安全性。
注册证书
- ca.pem
- ca.key
- server.pem
- server.key
- client.pem
- client.key
单向加密
理论
- TLS 单向加密(One-Way TLS)
在单向 TLS 中,只有服务器需要提供证书,而客户端则不需要。客户端通过验证服务器的证书来确认它与预期的服务器通信,并建立加密连接。这种模式下,只有服务器被验证,客户端未进行验证。
工作流程:
- 客户端发起请求:客户端向服务器发起连接请求。
- 服务器发送证书:服务器向客户端提供其数字证书,证书包含公钥。
- 客户端验证证书:客户端使用 CA(证书颁发机构)签发的根证书,验证服务器提供的证书是否有效。
- 建立加密通道:验证成功后,客户端生成一个对称密钥,并用服务器的公钥加密该密钥,发送给服务器。双方用该对称密钥加密通信数据。
应用场景:
- 大多数普通 HTTPS 连接使用的都是单向 TLS,如浏览器访问网站。
- 单向 TLS 保证了服务器的真实性和通信的加密,但不验证客户端的身份。
安全性:
- 数据在传输过程中加密,防止窃听和篡改。
- 客户端能够确认服务器的身份,但服务器无法确认客户端的身份。
实操
服务端
- 初始化 Credentials
// 服务端 TLS 单向凭证
func ServerTLSOneWayCred() credentials.TransportCredentials {
creds, err := credentials.NewServerTLSFromFile(iconst.ServerPemPath, iconst.ServerKeyPath)
if err != nil {
log.Fatalln("初始化服务端TLS凭证失败" + err.Error())
}
return creds
}
其中:
iconst.ServerPemPath: server.pem 的路径iconst.ServerKeyPath: server.key 的路径
- 使用该凭证
gServer := grpc.NewServer(grpc.Creds(ServerTLSOneWayCred())) // tls 单向认证的服务
总结:
与无校验的对比,只在 grpc.NewServer 增加了 grpc.Creds 这个option
客户端
- 获取认证
// 客户端 获取 tls 单向认证
func ClientTlsOneWayCred() credentials.TransportCredentials {
cred, err := credentials.NewClientTLSFromFile(iconst.ServerPemPath, `*.fanfine.cn`)
if err != nil {
panic("new client tls cred failed : " + err.Error())
}
return cred
}
iconst.ServerPemPath: server.pem 的路径*.fanfine.cn: server.conf 中配置的合法域名。- 使用凭证
GrpcClientConn, err = grpc.NewClient("127.0.0.1:8800",
grpc.WithTransportCredentials(ClientTlsOneWayCred())) // 单向的客户端连接
总结:
与无校验对比,更换了凭证
双向加密
理论
在双向 TLS 中,客户端和服务器都需要提供并验证各自的证书。服务器不仅要向客户端证明自己的身份,客户端也要向服务器提供证书,证明它的合法性。
工作流程:
- 客户端发起请求:客户端向服务器发起连接请求。
- 服务器发送证书:服务器向客户端提供其数字证书。
- 客户端验证服务器证书:客户端验证服务器的证书是否由可信的 CA 签发。
- 客户端发送证书:服务器请求客户端提供其证书,客户端将自己的证书发送给服务器。
- 服务器验证客户端证书:服务器验证客户端证书的合法性。
- 建立加密通道:验证成功后,双方协商对称密钥,并用各自的公钥加密传输数据。
应用场景:
- 高度敏感的通信场景,如金融交易、企业内部服务间通信等。
- 双向 TLS 可确保服务器和客户端互相验证身份,提供更高的安全性。
安全性:
- 双向认证确保了通信双方的身份都被验证,从而防止了恶意客户端的访问。
- 在对安全性要求极高的应用中,双向 TLS 是一种常见选择。
实操
服务端
- 初始化凭证
// 服务端 TLS 双向凭证
func ServerTLSTwoWayAuth() credentials.TransportCredentials {
// 读服务端证书
cert, err := tls.LoadX509KeyPair(iconst.ServerPemPath, iconst.ServerKeyPath)
if err != nil {
log.Fatal("证书读取错误", err)
}
// 创建一个新的、空的 CertPool
certPool := x509.NewCertPool()
ca, err := os.ReadFile(iconst.CAPemPath)
if err != nil {
log.Fatalln("ca证书os读取失败", err)
}
// 尝试解析所传入的 PEM 编码的证书。如果解析成功会将其加到 CertPool 中,便于后面的使用
certPool.AppendCertsFromPEM(ca)
// 构建基于 TLS 的 TransportCredentials 选项
creds := credentials.NewTLS(&tls.Config{
// 设置证书链,允许包含一个或多个
Certificates: []tls.Certificate{cert},
// 要求必须校验客户端的证书。可以根据实际情况选用以下参数
ClientAuth: tls.RequireAndVerifyClientCert,
// 设置根证书的集合,校验方式使用 ClientAuth 中设定的模式
ClientCAs: certPool,
})
return creds
}
- 使用双向认证
gServer := grpc.NewServer(grpc.Creds(ServerTLSTwoWayAuth())) // tls 双向认证服务
客户端
此时客户端若仍为单向认证的方式,会出现报错:
panic: rpc error: code = Unavailable desc = connection error: desc = "error reading server preface: remote error: tls: certificate required"
修改:
- 获取双向认证的 cred
// 客户端 获取 tls 双向认证
func ClientTlsTwoWayCred() credentials.TransportCredentials {
// 1. 读取客户端密钥对
cert, _ := tls.LoadX509KeyPair(iconst.ClientPemPath, iconst.ClientKeyPath)
// 2. 创建一个新的、空的 CertPool
certPool := x509.NewCertPool()
// 3. 读取CA
ca, err := os.ReadFile(iconst.CAPemPath)
if err != nil {
log.Fatalln("ca证书os读取失败", err)
}
// 4. 将ca加到 CertPool 中
ok := certPool.AppendCertsFromPEM(ca)
if !ok {
log.Fatalln("添加ca证书失败!")
}
// 5. 构建基于 TLS 的 TransportCredentials 选项
creds := credentials.NewTLS(&tls.Config{
Certificates: []tls.Certificate{cert},
// 此ServerName需为注册证书时配置的DNS
ServerName: `fanfine.cn`,
RootCAs: certPool,
})
return creds
}
- 修改启动服务时使用的 cred
相关报错
panic: rpc error: code = Unavailable desc = connection error: desc = "transport: authentication handshake failed: tls: first record does not look like a TLS handshake"
表示在 gRPC 连接时,TLS 握手失败。通常是由于客户端和服务器的配置不匹配,或者服务器并未使用 TLS 协议,而客户端尝试使用 TLS 进行连接。
具体原因可能有以下几种情况:
- 服务器未启用 TLS,但客户端启用了 TLS
- 证书配置错误
- 端口配置问题
- 防火墙或代理的影响
出现:
panic: rpc error: code = Unavailable desc = connection error: desc = "transport: authentication handshake failed: tls: failed to verify certificate: x509: certificate signed by unknown authority (possibly because of \"x509: invalid signature: parent certificate cannot sign this kind of certificate\" while trying to verify candidate authority certificate \"fanfine\")"
这个错误提示表示 gRPC 客户端在与服务器建立 TLS 连接时,无法验证服务器的证书,检查证书是否正确。
Auth认证
服务端开启拦截器
拦截器拦截 ctx 中的 authorization 键,校验正确性。
拦截器也可以拦截以验证其他数据,类似于Gin中的中间件的。
拦截器示例:
- 支持豁免部分方法,使其不参与相关验证
- 验证上下文的元数据中的 authorization ,验证登录情况
// 豁免的方法
var ExemptMethods = map[string]struct{}{
//pbUser.UserService_GetUserInfo_FullMethodName: {},
}
// auth 拦截器,用于登录验证之类,从metadata中取key
func AuthInterceptor(
ctx context.Context,
req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler) (resp any, err error) {
// 若方法设置了豁免,则无需验证
if _, ok := ExemptMethods[info.FullMethod]; ok {
log.Println("被豁免")
return handler(ctx, req)
}
// 提取metadata信息
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, errors.New("missing metadata")
}
fmt.Println("收到的metadata是:", md)
// 提取 authorization
if val, ok := md["access_token"]; ok {
token := val[0]
// 在redis或其他缓存中找到token
time.Sleep(time.Millisecond) // 模拟查询
// 校验token的正确性
if token != "the_true_access_token" {
return nil, errors.New("invalid access_token")
}
} else {
return nil, errors.New("access_token missing")
}
// 校验通过,执行具体逻辑
return handler(ctx, req)
}
客户端
有两种向ctx的metadata中写入键值对的方式
-
WithPerRPCCredentials一种动态Token的方式。 -
随用随写的方式,调用前写入ctx的metadata中。
方法一
WithPerRPCCredentials
在 NewClient 时,作为凭证option使用,
GrpcClientConn, err = grpc.NewClient("127.0.0.1:8800",
grpc.WithTransportCredentials(cred),
grpc.WithPerRPCCredentials( // 动态变化Token认证方式
OAuthToken{
AccessToken: "the_true_access_token",
}),
)
PerRPCCredentials 是在grpc库的credentials中的接口,
token 应当在必要时刷新,是的,就比如 Oauth
需要实现该接口:
// PerRpcCred
type OAuthToken struct {
AccessToken string
RefreshToken string
AppId string
Secret string
}
func (oauthToken OAuthToken) getOrRefresh() {
// 获取或刷新access_token的逻辑
}
func (oauthToken OAuthToken) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
// 以 oauth 为例,
// access_token 需要在必要时刷新
oauthToken.getOrRefresh()
return map[string]string{
"access_token": oauthToken.AccessToken,
}, nil
}
// RequireTransportSecurity indicates whether the credentials requires
// transport security.
func (cred OAuthToken) RequireTransportSecurity() bool {
return true
}
方式二
md := metadata.New(map[string]string{
"access_token": "the_true_access_token",
})
ctx := metadata.NewOutgoingContext(context.Background(), md)
// 初始化 User 服务的客户端连接
userClient := pbUser.NewUserServiceClient(app.GrpcClientConn)
resp, err := userClient.GetUserInfo(ctx, req)
if err != nil {
panic(err)
}
fmt.Println("得到响应", resp)
链式拦截器
错误示范
在进行auth校验之后,我还想要在handler之后,进行日志记录,或者向日志服务器提交日志。
我尝试再写一个 logger 拦截器
// 日志拦截器,在handler之后记录日志
func LoggerInterceptor(
ctx context.Context,
req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler) (resp any, err error) {
resp, err = handler(ctx, req)
// 记录日志
log.Println("这是logger拦截器,在handler之后的操作,FullMethod = ", info.FullMethod)
return
}
我将该拦截器加入到 grpc.NewServer(),
gServer := grpc.NewServer(
grpc.Creds(ServerTLSTwoWayAuth()),
grpc.UnaryInterceptor(AuthInterceptor),
) // 使用 <单/双向> 认证,且使用 auth 拦截器验证登录
此时出现报错:
panic: The unary server interceptor was already set and may not be reset.
看起来只能用一个 UnaryInterceptor
正确方式
- 单个拦截器,在 handler 的前后,调用auth校验、logger的函数
也行,不过在业务越来越复杂,引入更多的操作,会越来越臃肿。 - 链式拦截器
gServer := grpc.NewServer(
grpc.Creds(ServerTLSTwoWayAuth()),
grpc.ChainUnaryInterceptor(
AuthInterceptor,
LoggerInterceptor,
)) // 使用 <单/双> 向认证,且使用链式拦截器
链式拦截器的执行顺序
简单来说,拦截器链按照声明的顺序依次执行,在每个拦截器中调用 handler,最后再依次返回。
假设有三个拦截器 A、B 和 C,它们的调用链设置如下:
grpc.ChainUnaryInterceptor(A, B, C)
-
进入顺序(请求链路):
• 拦截器 A → B → C → handler
• 先执行 A 的前置逻辑,传递给 B。
• 然后执行 B 的前置逻辑,传递给 C。
• 最后执行 C 的前置逻辑,再调用实际的 handler。 -
返回顺序(响应链路):
• handler 执行完毕后,按 逆序 返回结果:C → B → A。
• 首先返回到 C,执行 C 的后置逻辑。
• 然后返回到 B,执行 B 的后置逻辑。
• 最后返回到 A,执行 A 的后置逻辑,最终将结果返回给客户端。
func A(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler) (interface{}, error) {
// 前置逻辑
log.Println("A前")
// 调用下一个拦截器或 handler
resp, err := handler(ctx, req)
// 后置逻辑
log.Println("A后")
return resp, err
}
func B(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler) (interface{}, error) {
log.Println("B前")
resp, err := handler(ctx, req)
log.Println("B后")
return resp, err
}
func C(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler) (interface{}, error) {
log.Println("C前")
resp, err := handler(ctx, req)
log.Println("C后")
return resp, err
}
server := grpc.NewServer(
grpc.ChainUnaryInterceptor(A, B, C),
)


