Java 转 go 学习 - GRPC(3)

文章目录

  • [1. gRPC 拦截器](#1. gRPC 拦截器)
    • [1.1 简单示例](#1.1 简单示例)
    • [1.2 多个拦截器](#1.2 多个拦截器)
  • [2. gRPC 错误处理](#2. gRPC 错误处理)
    • [2.1 错误码 codes](#2.1 错误码 codes)
    • [2.2 服务端返回的格式](#2.2 服务端返回的格式)
    • [2.3 示例](#2.3 示例)
    • [2.4 错误添加细节](#2.4 错误添加细节)
    • [2.5 自定义错误返回](#2.5 自定义错误返回)
  • [3. 小结](#3. 小结)

本系列文章:


1. gRPC 拦截器

1.1 简单示例

grpc 提供了 grpc.UnaryInterceptor(serverUnaryInterceptor) 方法,可以注册方法拦截器,这个拦截器的类型是:

go 复制代码
type UnaryServerInterceptor func(ctx context.Context, req any, info *UnaryServerInfo, handler UnaryHandler) (resp any, err error)

下面来看下用法,首先我们看下服务端的拦截器。

go 复制代码
// serverUnaryInterceptor 会在真正进入 SayHello 前后执行
// 常见用途是记录日志、鉴权、统计耗时、统一处理错误
func serverUnaryInterceptor(
	ctx context.Context,
	req any, // 客户端请求
	info *grpc.UnaryServerInfo,
	handler grpc.UnaryHandler, // 业务函数
) (any, error) {
	start := time.Now()

	// 从客户端传来的 metadata 里取 request-id
	if md, ok := metadata.FromIncomingContext(ctx); ok {
		fmt.Printf("[server interceptor] method=%s request-id=%s\n", info.FullMethod, first(md.Get("request-id")))
	}

	// 调用真正的业务处理函数
	resp, err := handler(ctx, req)
	fmt.Printf("[server interceptor] cost=%s err=%v\n", time.Since(start), err)
	return resp, err
}

服务端的拦截器会从客户端传过来的 metadata 里面取出 request-id,也就是客户端的请求 id,然后将 id 打印出来。

http 里面有 RequestHeader 请求头,gRPC 也有, metadata 就可以理解成 gRPC 里的 请求头/响应头 。而客户端可以通过 metadata.AppendToOutgoingContext(ctx, "request-id", "req-20260416-001") 将 request-id 设置到 metadata 中,服务端再通过 metadata.FromIncomingContext(ctx) 取出来。

gRPC 的 metadata 本质上是 mapstring\[\]string,也就是一个 key 可以对应多个 value。

下面是客户端的拦截器函数。

go 复制代码
type UnaryClientInterceptor func(ctx context.Context, method string, req, reply any, cc *ClientConn, invoker UnaryInvoker, opts ...CallOption) error

具体实现如下。

go 复制代码
// clientUnaryInterceptor 会包住客户端的每次一元 RPC 调用。
// 这里演示给请求统一追加 request-id,并在调用前后打印日志。
func clientUnaryInterceptor(
	ctx context.Context, // 本次 RPC 调用的上下文
	method string, // 当前调用的方法名全名
	req any, // 也就是发给服务端的 protobuf 消息
	reply any, // 响应对象,用来接收服务端返回结果
	cc *grpc.ClientConn, // 当前客户端连接对象, 一般业务用不到
	invoker grpc.UnaryInvoker, // 执行器, 调用下一个环节,最终把请求发到服务端
	opts ...grpc.CallOption, // 附加参数
) error {
	// 将 kv 设置到 context 中
	ctx = metadata.AppendToOutgoingContext(ctx, "request-id", "req-20260416-001")
	fmt.Printf("[client interceptor] before method=%s\n", method)

	err := invoker(ctx, method, req, reply, cc, opts...)
	fmt.Printf("[client interceptor] after method=%s err=%v\n", method, err)
	return err
}

拦截器的各个参数我也写到注释上了,AppendToOutgoingContext 方法中可以把 k-v 设置到 context 中。

同样的在 NewClient 创建客户端的时候可以使用 grpc.WithUnaryInterceptor(clientUnaryInterceptor) 将拦截器作为 opts 附加参数传进去。

最后我们来看下主函数的示例,主函数我们要用到 gRPC 提供的一个工具 bufconn,bufconn 是 gRPC 提供的一个 内存里的假连接,不走真实的 TCP 端口,不经过网络栈,直接在同一个进程里让 client 和 server 通信,比较适合下面这些场景。

  • 写示例
  • 写单元测试
  • 验证拦截器、middleware、序列化逻辑
  • 避免占用本地端口

下面看下 proto 文件和生成的 grpc 接口。

go 复制代码
syntax = "proto3";

package greeter.v1;

option go_package = "example.com/grpc-05/api/proto/greeter/v1;greeterv1";

message HelloRequest {
  string name = 1;
}

message HelloReply {
  string message = 1;
}

service Greeter {
  rpc SayHello(HelloRequest) returns (HelloReply);
}

文件结构如下,生成的 go 代码就不看了。

然后来看下 main 方法。

go 复制代码
package main

import (
	"context"
	"fmt"
	"log"
	"net"
	"time"

	greeterv1 "example.com/grpc-05/api/proto/greeter/v1"
	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
	"google.golang.org/grpc/metadata"
	"google.golang.org/grpc/test/bufconn"
)

const bufSize = 1024 * 1024

type greeterServer struct {
	greeterv1.UnimplementedGreeterServer
}

// SayHello 是业务处理函数,本身只负责处理请求,不关心日志、耗时这类横切逻辑。
func (s *greeterServer) SayHello(ctx context.Context, req *greeterv1.HelloRequest) (*greeterv1.HelloReply, error) {
	return &greeterv1.HelloReply{
		Message: "Hello, " + req.GetName(),
	}, nil
}

// serverUnaryInterceptor 会在真正进入 SayHello 前后执行
// 常见用途是记录日志、鉴权、统计耗时、统一处理错误
func serverUnaryInterceptor(
	ctx context.Context,
	req any, // 客户端请求
	info *grpc.UnaryServerInfo,
	handler grpc.UnaryHandler, // 业务函数
) (any, error) {
	...
}

// clientUnaryInterceptor 会包住客户端的每次一元 RPC 调用。
// 这里演示给请求统一追加 request-id,并在调用前后打印日志。
func clientUnaryInterceptor(
	ctx context.Context, // 本次 RPC 调用的上下文
	method string, // 当前调用的方法名全名
	req any, // 也就是发给服务端的 protobuf 消息
	reply any, // 响应对象,用来接收服务端返回结果
	cc *grpc.ClientConn, // 当前客户端连接对象, 一般业务用不到
	invoker grpc.UnaryInvoker, // 执行器, 调用下一个环节,最终把请求发到服务端
	opts ...grpc.CallOption, // 附加参数
) error {
	...
}

func first(values []string) string {
	if len(values) == 0 {
		return ""
	}
	return values[0]
}

func main() {
	// bufconn 提供内存中的连接,适合示例和测试,不需要真实监听 TCP 端口。
	lis := bufconn.Listen(bufSize)

	// 给服务端挂上一元拦截器。
	server := grpc.NewServer(grpc.UnaryInterceptor(serverUnaryInterceptor))
	greeterv1.RegisterGreeterServer(server, &greeterServer{})

	go func() {
		if err := server.Serve(lis); err != nil {
			log.Fatalf("server exited: %v", err)
		}
	}()
	defer server.Stop()

	// 创建客户端连接,并给客户端挂上一元拦截器。
	conn, err := grpc.NewClient(
		"passthrough:///bufnet",
		grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
			return lis.Dial()
		}),
		grpc.WithTransportCredentials(insecure.NewCredentials()),
		grpc.WithUnaryInterceptor(clientUnaryInterceptor),
	)
	if err != nil {
		log.Fatalf("create client failed: %v", err)
	}
	defer conn.Close()

	client := greeterv1.NewGreeterClient(conn)
	// 调用顺序是:
	// client interceptor -> server interceptor -> SayHello -> server interceptor -> client interceptor
	resp, err := client.SayHello(context.Background(), &greeterv1.HelloRequest{Name: "Tom"})
	if err != nil {
		log.Fatalf("say hello failed: %v", err)
	}

	fmt.Printf("rpc response: %s\n", resp.GetMessage())
}

SayHello 就是核心的 grpc 业务接口,在 main 方法中创建出服务端拦截器之后通过 RegisterGreeterServer 将这个拦截器注册到 greeterv1 这个 grpc 服务中,客户端也同理,而下面是客户端其他参数的意思。

  • lis.Dial():这个向这个 bufconn 监听器申请一个客户端连接,并把这个连接返回给 gRPC 客户端使用,也就是内存里面的连接而不是真正的 tcp 连接。
  • passthrough:不要做额外名字解析,目标字符串基本原样往下传(后面的 bufnet),比如说如果我们 NewClient 方法第一个目标参数填的是 localhost:8080 ,那么 gRPC 会把它当成一个目标地址,交给内部的 resolver 去处理,然后再决定怎么拨号连接,这里的 passthrough 就是在告诉 gRPC 别帮我做复杂解析,后面的目标值直接往下传就行 ,正常来说连接建立的逻辑是第一步先看目标字符串怎么解析,第二步再决定怎么建立连接,passthrough 就是让第一步简单点,而第二部建立连接由于我们用了 lis.Dial(),所以 passthrough:///bufnet 后面的 bufnet 实际上也没有用到,这里只是为了规范。
  • bufnet:目标名,占位用的,不是必须,改成其他也行,一般来说生产环境有可能会接入北极星,然后写法就变成了 polaris://namespace/service-name,当然不同项目写法不同,这种情况下就会去解析后面的 namespace 和 service-name,再调用具体的服务。
  • grpc.WithTransportCredentials(insecure.NewCredentials()):告诉 gRPC,这条连接不使用 TLS,走明文连接,也就是说,它是客户端的传输层安全配置。这里就等于直接告诉服务端我知道这次连接不做证书校验,不做 TLS 握手,直接用非加密连接。因为 gRPC Go 新版客户端创建连接时,必须明确指定 用什么传输凭证,要么就是 TLS,要么就是 insecure,我们本地测试自然不需要加密。

最终来看下 main 方法输出结果。

go 复制代码
[client interceptor] before method=/greeter.v1.Greeter/SayHello
[server interceptor] method=/greeter.v1.Greeter/SayHello request-id=req-20260416-001
[server interceptor] cost=0s err=<nil>
[client interceptor] after method=/greeter.v1.Greeter/SayHello err=<nil>
rpc response: Hello, Tom

这些拦截器的执行顺序是:

  • client interceptor -> server interceptor -> SayHello -> server interceptor -> client interceptor

1.2 多个拦截器

上面是单个拦截器的示例,实际上我们可以通过 grpc.ChainUnaryInterceptor 去添加多个服务端拦截器,也就是拦截器链,也可以通过 WithChainUnaryInterceptor 添加客户端拦截器。

go 复制代码
server := grpc.NewServer(
	// 配置拦截器链
	grpc.ChainUnaryInterceptor(
		serverLogInterceptor,
		serverAuthInterceptor,
	),
)

conn, err := grpc.NewClient(
	"passthrough:///bufnet",
	grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
		return lis.Dial()
	}),
	grpc.WithTransportCredentials(insecure.NewCredentials()),
	grpc.WithChainUnaryInterceptor(
		clientLogInterceptor,
		clientMetadataInterceptor,
	),
)

下面直接看 main 方法。

go 复制代码
package main

import (
	"context"
	"fmt"
	"log"
	"net"
	"time"

	greeterv1 "example.com/grpc-05/api/proto/greeter/v1"
	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
	"google.golang.org/grpc/metadata"
	"google.golang.org/grpc/test/bufconn"
)

const bufSize = 1024 * 1024

type greeterServer struct {
	greeterv1.UnimplementedGreeterServer
}

// SayHello 是真正的业务处理函数。
// 拦截器会在它的外层包一层或多层,用来处理日志、鉴权、埋点等通用逻辑。
func (s *greeterServer) SayHello(ctx context.Context, req *greeterv1.HelloRequest) (*greeterv1.HelloReply, error) {
	return &greeterv1.HelloReply{
		Message: "Hello, " + req.GetName(),
	}, nil
}

// serverLogInterceptor 是服务端日志拦截器。
// 它在进入业务函数前打印方法名,在业务返回后统计耗时和错误。
func serverLogInterceptor(
	ctx context.Context,
	req any,
	info *grpc.UnaryServerInfo,
	handler grpc.UnaryHandler,
) (any, error) {
	start := time.Now()
	fmt.Printf("[server log] before method=%s\n", info.FullMethod)

	resp, err := handler(ctx, req)

	fmt.Printf("[server log] after method=%s cost=%s err=%v\n", info.FullMethod, time.Since(start), err)
	return resp, err
}

// serverAuthInterceptor 模拟服务端从 metadata 里读取请求附加信息
// 真实项目里这里常放鉴权、租户校验、灰度标识读取等逻辑
func serverAuthInterceptor(
	ctx context.Context,
	req any,
	info *grpc.UnaryServerInfo,
	handler grpc.UnaryHandler,
) (any, error) {
	if md, ok := metadata.FromIncomingContext(ctx); ok {
		fmt.Printf("[server auth] request-id=%s\n", first(md.Get("request-id")))
	}

	res, err := handler(ctx, req)
	fmt.Println("[server auth] serverAuthInterceptor end")
	return res, err
}

// clientLogInterceptor 是客户端日志拦截器。
// 它会在 RPC 发出前后分别打印一次日志。
func clientLogInterceptor(
	ctx context.Context,
	method string,
	req any,
	reply any,
	cc *grpc.ClientConn,
	invoker grpc.UnaryInvoker,
	opts ...grpc.CallOption,
) error {
	fmt.Printf("[client log] before method=%s\n", method)
	err := invoker(ctx, method, req, reply, cc, opts...)
	fmt.Printf("[client log] after method=%s err=%v\n", method, err)
	return err
}

// clientMetadataInterceptor 给每次发出的 RPC 统一追加 metadata
// 这里追加的是 request-id,服务端可以从 context 里取出来
func clientMetadataInterceptor(
	ctx context.Context,
	method string,
	req any,
	reply any,
	cc *grpc.ClientConn,
	invoker grpc.UnaryInvoker,
	opts ...grpc.CallOption,
) error {
	ctx = metadata.AppendToOutgoingContext(ctx, "request-id", "req-20260418-001")
	fmt.Printf("[client metadata] append request-id for method=%s\n", method)
	return invoker(ctx, method, req, reply, cc, opts...)
}

func first(values []string) string {
	if len(values) == 0 {
		return ""
	}
	return values[0]
}

func main() {
	// bufconn 提供内存中的连接,适合示例和测试。
	// 它不需要真实 TCP 端口,但客户端和服务端的调用链仍然是完整的 gRPC 调用链。
	lis := bufconn.Listen(bufSize)

	// 服务端多个拦截器按注册顺序组成一条链:
	// server log -> server auth -> SayHello -> server auth 返回 -> server log 返回
	server := grpc.NewServer(
		grpc.ChainUnaryInterceptor(
			serverLogInterceptor,
			serverAuthInterceptor),
	)
	greeterv1.RegisterGreeterServer(server, &greeterServer{})

	go func() {
		if err := server.Serve(lis); err != nil {
			log.Fatalf("server exited: %v", err)
		}
	}()
	defer server.Stop()

	// 客户端多个拦截器也是按注册顺序组成一条链:
	// client log -> client metadata -> 真正发请求 -> client metadata 返回 -> client log 返回
	conn, err := grpc.NewClient(
		"passthrough:///bufnet",
		grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
			return lis.Dial()
		}),
		grpc.WithTransportCredentials(insecure.NewCredentials()),
		grpc.WithChainUnaryInterceptor(
			clientLogInterceptor,
			clientMetadataInterceptor,
		),
	)
	if err != nil {
		log.Fatalf("create client failed: %v", err)
	}
	defer conn.Close()

	client := greeterv1.NewGreeterClient(conn)
	// 这里触发一次普通的一元 RPC。
	// 调用时会先经过客户端拦截器,再进入服务端拦截器,最后才到业务函数 SayHello。
	resp, err := client.SayHello(context.Background(), &greeterv1.HelloRequest{Name: "Tom"})
	if err != nil {
		log.Fatalf("say hello failed: %v", err)
	}

	fmt.Printf("rpc response: %s\n", resp.GetMessage())
}

服务端的拦截器顺序是:

  • serverLogInterceptor
  • serverAuthInterceptor

客户端的拦截器顺序是:

  • clientLogInterceptor
  • clientMetadataInterceptor

所以整体的拦截器执行顺序应该是:

go 复制代码
  clientLogInterceptor
  -> clientMetadataInterceptor
  -> 发起 RPC
  -> serverLogInterceptor
  -> serverAuthInterceptor
  -> SayHello
  -> serverAuthInterceptor 返回
  -> serverLogInterceptor 返回
  -> clientMetadataInterceptor 返回
  -> clientLogInterceptor 返回

事实也是,main 方法的输出结果如下。

go 复制代码
[client log] before method=/greeter.v1.Greeter/SayHello
[client metadata] append request-id for method=/greeter.v1.Greeter/SayHello
[server log] before method=/greeter.v1.Greeter/SayHello
[server auth] request-id=req-20260418-001
[server auth] serverAuthInterceptor end
[server log] after method=/greeter.v1.Greeter/SayHello cost=0s err=<nil>
[client log] after method=/greeter.v1.Greeter/SayHello err=<nil>
rpc response: Hello, Tom

2. gRPC 错误处理

gRPC 不推荐直接返回 error,有一套官方的标准:

  • status.Error + codes 错误码

2.1 错误码 codes

错误码 codes 常用的如下。

错误码 含义 常见场景
codes.OK 请求成功,没有错误 正常返回业务结果时使用
codes.Canceled 请求被取消 客户端主动取消请求,或上游 context 被取消
codes.Unknown 未知错误 服务端返回了无法明确分类的错误,通常不建议业务主动使用
codes.InvalidArgument 请求参数不合法 参数缺失、格式错误、范围错误,比如 user_id <= 0
codes.DeadlineExceeded 请求超时 客户端设置了超时,服务端没在截止时间前处理完
codes.NotFound 资源不存在 用户不存在、订单不存在、配置不存在
codes.AlreadyExists 资源已存在 创建用户时用户名已存在,创建订单号重复
codes.PermissionDenied 没有权限 用户身份没问题,但没有操作当前资源的权限
codes.Unauthenticated 未认证 没登录、token 缺失、token 无效、签名失败
codes.ResourceExhausted 资源不足或超限 限流、配额超限、连接池满、磁盘/内存资源不足
codes.FailedPrecondition 当前状态不满足执行条件 订单未支付不能发货,账户未实名认证不能提现吗
codes.Aborted 操作被中止,通常和并发冲突有关 乐观锁冲突、事务冲突、重复提交需要回滚
codes.OutOfRange 请求超出合法范围 分页越界、读取偏移量越界、索引超范围
codes.Unimplemented 方法或能力未实现 RPC 还没开发、某功能暂不支持
codes.Internal 服务端内部错误 代码 bug、内部状态异常、依赖结果不符合预期
codes.Unavailable 服务暂时不可用 服务实例挂了、网络抖动、下游不可达、服务正在重启
codes.DataLoss 数据损坏或不可恢复 数据校验失败、存储内容损坏、关键数据丢失

2.2 服务端返回的格式

go 复制代码
return nil, status.Error(错误码, "错误描述信息")

2.3 示例

同样的,我们可以来看下示例,首先就是 proto 文件。

go 复制代码
syntax = "proto3";

package user.v1;

option go_package = "example.com/grpc-06/api/proto/user/v1;userv1";

message GetUserRequest {
  uint64 user_id = 1;
}

message GetUserReply {
  uint64 user_id = 1;
  string name = 2;
}

service UserService {
  rpc GetUser(GetUserRequest) returns (GetUserReply);
}

生成的 go 文件就不看了,整个文件的结构如下。

gRPC 接口是 GetUser,下面是服务端的接口,通过 gRPC 的 status.Error 直接返回 Error 结构体错误,也就是下面这个结构体。

go 复制代码
type Error struct {
	s *Status
}

下面看服务端代码。

go 复制代码
type userServer struct {
	userv1.UnimplementedUserServiceServer
}

// GetUser demonstrates typical gRPC error handling on the server side.
func (s *userServer) GetUser(ctx context.Context, req *userv1.GetUserRequest) (*userv1.GetUserReply, error) {
	switch req.GetUserId() {
	case 1:
		return &userv1.GetUserReply{
			UserId: 1,
			Name:   "Tom",
		}, nil
	case 0:
		return nil, status.Error(codes.InvalidArgument, "user_id must be greater than 0")
	case 404:
		return nil, status.Error(codes.NotFound, "user not found")
	case 500:
		return nil, status.Error(codes.Internal, "internal server error")
	default:
		return nil, status.Error(codes.PermissionDenied, "no permission to access this user")
	}
}

然后通过下面的方法去处理错误,首先通过 status.FromError 解析这个 Error 结构体获取 Status,再对 Code 属性分别讨论。

go 复制代码
func callAndPrint(client userv1.UserServiceClient, userID uint64) {
	fmt.Printf("call GetUser user_id=%d\n", userID)

	resp, err := client.GetUser(context.Background(), &userv1.GetUserRequest{
		UserId: userID,
	})
	if err != nil {
		st, ok := status.FromError(err)
		if !ok {
			fmt.Printf("non-gRPC error: %v\n\n", err)
			return
		}

		fmt.Printf("grpc error code=%s message=%s\n", st.Code(), st.Message())
		switch st.Code() {
		case codes.InvalidArgument:
			fmt.Println("client action: fix request parameters")
		case codes.NotFound:
			fmt.Println("client action: show user not found")
		case codes.Internal:
			fmt.Println("client action: retry later or alert")
		case codes.PermissionDenied:
			fmt.Println("client action: deny operation")
		default:
			fmt.Println("client action: generic fallback")
		}
		fmt.Println()
		return
	}

	fmt.Printf("success user_id=%d name=%s\n\n", resp.GetUserId(), resp.GetName())
}

2.4 错误添加细节

上面例子中的 status.Error(...) 是比较常见的错误返回方式,但是这种方式返回的字段只有两个。

  • code
  • message

但是有时候客户端不单单想要知道一些错误码和消息,也想知道下面的信息。

  • 到底哪个字段错了
  • 这是哪种稳定错误原因
  • 关联的是哪个资源
  • 是否可以重试
  • 是否有本地化提示文案

这些信息可以放到 details 返回,就可以用到 status.WithDetails,这个方法的签名是。

go 复制代码
func (s *Status) WithDetails(details ...protoadapt.MessageV1) (*Status, error) 

用法如下。

go 复制代码
// invalidUserIDError 返回一个带详情的 InvalidArgument 错误。
// 顶层的 code/message 适合通用处理;
// details 部分适合客户端做更细粒度的解析。
func invalidUserIDError() error {
	// 创建 Status
	st := status.New(codes.InvalidArgument, "invalid request parameters")

	// 加上细节
	withDetails, err := st.WithDetails(
		&errdetails.BadRequest{
			FieldViolations: []*errdetails.BadRequest_FieldViolation{
				{
				  // 字段
					Field:       "user_id",
					// 字段错误原因
					Description: "user_id must be greater than 0",
				},
			},
		},
		&errdetails.LocalizedMessage{
		  // 语言
			Locale:  "zh-CN",
			// 信息
			Message: "user_id 必须大于 0",
		},
	)
	if err != nil {
		// 如果附加详情失败,则退化成普通 gRPC 错误。
		return status.Error(codes.Internal, "build invalid argument details failed")
	}

	// 返回错误码, 将上面的内容组合在一起
	return withDetails.Err()
}

BadRequest 和 LocalizedMessage 都是 gRPC 官方提供的 错误详情类型,一般配合 WithDetails(...) 使用,但它们用途不一样。

  • BadRequest 用来表示请求参数哪里不合法。
  • LocalizedMessage 用来表达 给用户看的本地化错误提示,告诉前端该怎么向用户展示。

同样的还有 ErrorInfo 和 ResourceInfo ,也都是 结构化错误详情 ,但它们关注的不是 字段校验 ,而是 错误原因和资源上下文。

  • ErrorInfo 用来表示 这个错误的稳定业务原因是什么,比如权限校验失败,错误原因是非管理员身份,还有比如 Token 校验失败等固定的原因,这个跟 message 的区别就是这个要求返回的原因尽量文档,也就是一种错误码的造成方式要稳定。
  • ResourceInfo 用来表示 这个错误和哪个资源有关。

下面来看下具体的返回例子。

go 复制代码
// permissionDeniedError 返回一个带 ErrorInfo 详情的 PermissionDenied 错误。
// 当客户端需要根据稳定的 reason 或 metadata 做分支处理时,这种写法更合适。
func permissionDeniedError(userID uint64) error {
	st := status.New(codes.PermissionDenied, "no permission to access this user")

	withDetails, err := st.WithDetails(
		&errdetails.ErrorInfo{
		  // 稳定原因
			Reason:   "USER_READ_FORBIDDEN",
			// 业务
			Domain:   "grpc-06.user",
			// 附加 k-v
			Metadata: map[string]string{"user_id": fmt.Sprintf("%d", userID)},
		},
		&errdetails.ResourceInfo{
			// 资源类型
			ResourceType: "user",
			// 资源名称
			ResourceName: fmt.Sprintf("users/%d", userID),
			// 错误描述
			Description:  "the caller is not allowed to read this user",
		},
	)
	if err != nil {
		return status.Error(codes.Internal, "build permission denied details failed")
	}

	return withDetails.Err()
}

上面这两个 Error 最终返回的都是两部分组成。

  1. status.New 创建出来的基础信息(code,message)
  2. 添加的细节信息

比如上面第一个参数错误的就如下所示。

服务端测试,根据不同 userId 发送不同的结果。

go 复制代码
// GetUser 演示几种常见的 gRPC 错误处理方式:
// 1. 正常返回业务结果
// 2. 直接返回 status.Error(...)
// 3. 通过 status.New(...).WithDetails(...) 返回带结构化详情的错误
func (s *userServer) GetUser(ctx context.Context, req *userv1.GetUserRequest) (*userv1.GetUserReply, error) {
	switch req.GetUserId() {
	case 1:
		return &userv1.GetUserReply{
			UserId: 1,
			Name:   "Tom",
		}, nil
	case 0:
		// InvalidArgument,附带字段级别的校验详情。
		return nil, invalidUserIDError()
	case 404:
		return nil, status.Error(codes.NotFound, "user not found")
	case 500:
		return nil, status.Error(codes.Internal, "internal server error")
	default:
		// PermissionDenied,附带便于客户端识别的结构化上下文。
		return nil, permissionDeniedError(req.GetUserId())
	}
}

客户端下面可以用这个方法来解析 Status。

go 复制代码
// printDetails 解析并打印通过 WithDetails 附加的结构化错误详情。
func printDetails(st *status.Status) {
	for _, detail := range st.Details() {
		switch d := detail.(type) {
		case *errdetails.BadRequest:
			for _, violation := range d.FieldViolations {
				fmt.Printf("detail BadRequest field=%s description=%s\n", violation.Field, violation.Description)
			}
		case *errdetails.LocalizedMessage:
			fmt.Printf("detail LocalizedMessage locale=%s message=%s\n", d.Locale, d.Message)
		case *errdetails.ErrorInfo:
			fmt.Printf("detail ErrorInfo reason=%s domain=%s metadata=%v\n", d.Reason, d.Domain, d.Metadata)
		case *errdetails.ResourceInfo:
			fmt.Printf(
				"detail ResourceInfo type=%s name=%s description=%s\n",
				d.ResourceType,
				d.ResourceName,
				d.Description,
			)
		default:
			fmt.Printf("detail %T\n", d)
		}
	}
}

最后看下 main 方法。

go 复制代码
func main() {
	lis := bufconn.Listen(bufSize)
	server := grpc.NewServer()
	userv1.RegisterUserServiceServer(server, &userServer{})

	go func() {
		if err := server.Serve(lis); err != nil {
			log.Fatalf("server exited: %v", err)
		}
	}()
	defer server.Stop()

	conn, err := grpc.NewClient(
		"passthrough:///bufnet",
		grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
			return lis.Dial()
		}),
		grpc.WithTransportCredentials(insecure.NewCredentials()),
	)
	if err != nil {
		log.Fatalf("create client failed: %v", err)
	}
	defer conn.Close()

	client := userv1.NewUserServiceClient(conn)

	callAndPrint(client, 1)
	callAndPrint(client, 0)
	callAndPrint(client, 404)
	callAndPrint(client, 500)
	callAndPrint(client, 999)
}

func callAndPrint(client userv1.UserServiceClient, userID uint64) {
	fmt.Printf("call GetUser user_id=%d\n", userID)

	resp, err := client.GetUser(context.Background(), &userv1.GetUserRequest{
		UserId: userID,
	})
	if err != nil {
		st, ok := status.FromError(err)
		if !ok {
			fmt.Printf("non-gRPC error: %v\n\n", err)
			return
		}

		fmt.Printf("grpc error code=%s message=%s\n", st.Code(), st.Message())
		printDetails(st)

		switch st.Code() {
		case codes.InvalidArgument:
			fmt.Println("client action: fix request parameters")
		case codes.NotFound:
			fmt.Println("client action: show user not found")
		case codes.Internal:
			fmt.Println("client action: retry later or alert")
		case codes.PermissionDenied:
			fmt.Println("client action: deny operation")
		default:
			fmt.Println("client action: generic fallback")
		}
		fmt.Println()
		return
	}

	fmt.Printf("success user_id=%d name=%s\n\n", resp.GetUserId(), resp.GetName())
}

打印结果如下。

go 复制代码
call GetUser user_id=1
success user_id=1 name=Tom

call GetUser user_id=0
grpc error code=InvalidArgument message=invalid request parameters
detail BadRequest field=user_id description=user_id must be greater than 0
detail LocalizedMessage locale=zh-CN message=user_id 必须大于 0
client action: fix request parameters

call GetUser user_id=404
grpc error code=NotFound message=user not found
client action: show user not found

call GetUser user_id=500
grpc error code=Internal message=internal server error
client action: retry later or alert

call GetUser user_id=999
grpc error code=PermissionDenied message=no permission to access this user
detail ErrorInfo reason=USER_READ_FORBIDDEN domain=grpc-06.user metadata=map[user_id:999]
detail ResourceInfo type=user name=users/999 description=the caller is not allowed to read this user
client action: deny operation

2.5 自定义错误返回

上面我们在 WithDetails 中用的都是 gRPC 提供的结构体,我们也可以用自定义的结构体,但是有一个问题,这个方法传入的是 MessageV1 接口的实现类。

go 复制代码
type MessageV1 interface {
	Reset()
	String() string
	ProtoMessage()
}

所以如果我们要用自定义结构体就也要实现这几个方法,但是我们可以把结构体定义到 proto 文件中,然后用 proto 生成 go 文件,这样定义出来并生成的 protobuf 类型就默认实现了这三个接口,可以直接使用。

go 复制代码
message UserErrorDetail {
  string reason = 1;
  uint32 biz_code = 2;
  string biz_message = 3;
  string help = 4;
}

在代码中就可以直接用。

go 复制代码
func quotaExceededError(userID uint64) error {
	st := status.New(codes.ResourceExhausted, "user quota exceeded")

	withDetails, err := st.WithDetails(&userv1.UserErrorDetail{
		Reason:     "USER_QUOTA_EXCEEDED",
		BizCode:    10001,
		BizMessage: "current caller has exceeded the user read quota",
		Help:       fmt.Sprintf("reduce request rate and retry later, user_id=%d", userID),
	})
	if err != nil {
		return status.Error(codes.Internal, "build custom details failed")
	}

	return withDetails.Err()
}

客户端解析就加上这个类型。

go 复制代码
// printDetails 解析并打印通过 WithDetails 附加的结构化错误详情。
func printDetails(st *status.Status) {
	for _, detail := range st.Details() {
		switch d := detail.(type) {
		...
		case *userv1.UserErrorDetail:
			fmt.Printf(
				"detail UserErrorDetail reason=%s biz_code=%d biz_message=%s help=%s\n",
				d.Reason,
				d.BizCode,
				d.BizMessage,
				d.Help,
			)
		default:
			fmt.Printf("detail %T\n", d)
		}
	}
}

3. 小结

这篇文章就先到这里,下一篇继续学习 gRPC 如何生成 http 接口。

相关推荐
知识分享小能手1 小时前
C学习教程,从入门到精通,C语言概述 —— 知识点详解(1)
c语言·开发语言·学习
想做小南娘,发现自己是女生喵1 小时前
i.MX6ULL嵌入式Linux入门学习文档
linux·运维·学习
谢亮_vipxieliang2 小时前
Go 并发安全性保障
服务器·网络·golang
汤米粥2 小时前
后端开发主流技术方案
java·python·golang·php·nodejs·后端开发
JWASX2 小时前
Java 转 go 学习 - Redis(1)
学习·golang
JWASX2 小时前
Java 转 go 学习 - Redis(2)
学习·golang
Misnearch3 小时前
agent架构学习
学习·架构
li星野3 小时前
【学习记录】USB连接与枚举全解析:从D+/D-上拉到pyOCD烧录
学习
我命由我123453 小时前
Photoshop - Photoshop 使用魔棒工具选择单独的区域
学习·ui·职场和发展·求职招聘·职场发展·学习方法·photoshop