上一篇链接: 记一次gRPC的调用实战(二)
接着我们来完善 gRPC 错误处理机制,通过搜罗网上 gRPC 的文章我们知道,除了可以直接返回错误之外,Google 还提供了 google.golang.org/grpc/status 来表示错误,这个结构包含了 code 和 message 两个字段,并且还可以携带 details 字段用来附加自己扩展的错误类型。
话不多说,我们先从 Kratos 项目开始改造,首先先安装 protoc-gen-go-errors 工具,用来生成错误处理公共 pb.go 文件。
docker ps
docker exec -it Golang容器ID /bin/bash
go install github.com/go-kratos/kratos/cmd/protoc-gen-go-errors/v2@latest
接着在 Makefile 中的 api 定义里增加一行生成错误的配置:
.PHONY: api
# generate api proto
api:
protoc --proto_path=./api \
--proto_path=./third_party \
--go_out=paths=source_relative:./api \
--go-errors_out=paths=source_relative:./api \
--go-http_out=paths=source_relative:./api \
--go-grpc_out=paths=source_relative:./api \
--openapi_out=fq_schema_naming=true,default_response=false:. \
$(API_PROTO_FILES)
我们先定义一个错误用来返回业务异常的状态,打开 api/helloworld/v1/error_reason.proto 文件,引入 errors/errors.proto,并增加一个 BIZ_ERROR:
syntax = "proto3";
package helloworld.v1;
import "errors/errors.proto";
option go_package = "demo/api/helloworld/v1;v1";
option java_multiple_files = true;
option java_package = "helloworld.v1";
option objc_class_prefix = "APIHelloworldV1";
enum ErrorReason {
GREETER_UNSPECIFIED = 0;
USER_NOT_FOUND = 1;
BIZ_ERROR = 2 [(errors.code) = 500];
}
message ErrorReply {
int32 code = 1;
string msg = 2;
}
记得在项目目录中执行:
make api
然后在业务逻辑中,也就是 internal/biz/greeter.go 中去掉正常返回,并直接返回错误。
func (uc *GreeterUsecase) CreateGreeter(ctx context.Context, g *Greeter) (*Greeter, error) {
uc.log.WithContext(ctx).Infof("CreateGreeter: %v", g.Hello)
uc.log.WithContext(ctx).Infof("jwt uid: %v", ctx.Value("uid"))
uc.log.WithContext(ctx).Infof("jwt nickname: %v", ctx.Value("nickname"))
return nil, v1.ErrorBizError("biz error")
//return uc.repo.Save(ctx, g)
}
这样直接返回错误,其实 gRPC-client 也就是 Hyperf 中是可以通过 $reply->getMessage() 来获取到错误的消息,但是通常返回错误的时候会携带更多信息便于追踪,所以需要在 gRPC 拦截器,也就是 Kratos 的返回中间件来处理。
我们新建一个 error_handle.go 的中间件,并加上错误信息组装的代码:
package middleware
import (
"context"
v1 "demo/api/helloworld/v1"
"github.com/go-kratos/kratos/v2/errors"
"github.com/go-kratos/kratos/v2/middleware"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func ErrorHandleMiddleware() middleware.Middleware {
return func(handler middleware.Handler) middleware.Handler {
return func(ctx context.Context, req interface{}) (interface{}, error) {
reply, err := handler(ctx, req)
if err != nil && v1.IsBizError(err) {
errInfo := errors.FromError(err)
st, _ := status.New(codes.Unknown, "error").WithDetails(&v1.ErrorReply{Code: errInfo.Code, Msg: errInfo.Message})
return nil, st.Err()
}
return reply, err
}
}
}
同时将中间件加入 internal/server/grpc.go 中 NewGRPCServer 的 grpc.Middleware 列表中:
package server
import (
v1 "demo/api/helloworld/v1"
"demo/internal/conf"
"demo/internal/service"
"demo/middleware"
"github.com/go-kratos/kratos/v2/log"
"github.com/go-kratos/kratos/v2/middleware/metadata"
"github.com/go-kratos/kratos/v2/middleware/recovery"
"github.com/go-kratos/kratos/v2/transport/grpc"
)
// NewGRPCServer new a gRPC server.
func NewGRPCServer(c *conf.Server, greeter *service.GreeterService, logger log.Logger) *grpc.Server {
var opts = []grpc.ServerOption{
grpc.Middleware(
recovery.Recovery(),
metadata.Server(),
middleware.AuthMiddleware(),
middleware.ErrorHandleMiddleware(),
),
}
if c.Grpc.Network != "" {
opts = append(opts, grpc.Network(c.Grpc.Network))
}
if c.Grpc.Addr != "" {
opts = append(opts, grpc.Address(c.Grpc.Addr))
}
if c.Grpc.Timeout != nil {
opts = append(opts, grpc.Timeout(c.Grpc.Timeout.AsDuration()))
}
srv := grpc.NewServer(opts...)
v1.RegisterGreeterServer(srv, greeter)
return srv
}
到此 Kratos 的错误返回已经处理完毕,接下来开始改造 Hyperf 的接收逻辑。我们打开 Hyperf 项目目录下的 app/Controller/IndexController.php,加入错误处理逻辑:
declare(strict_types=1);
namespace App\Controller;
use App\Exception\BusinessException;
use App\Grpc\GreeterClient;
use App\Helper\Result;
use App\Middleware\AuthMiddleware;
use Helloworld\V1\HelloRequest;
use Hyperf\Grpc\Parser;
use Hyperf\Grpc\StatusCode;
use Hyperf\HttpServer\Annotation\Controller;
use Hyperf\HttpServer\Annotation\GetMapping;
use Hyperf\HttpServer\Annotation\Middleware;
#[Middleware(AuthMiddleware::class)]
#[Controller]
class IndexController extends AbstractController
{
#[GetMapping(path: "/")]
public function index()
{
// 这个client是协程安全的,可以复用
// 注意这里的IP地址需要填写为刚刚通过 ipconfig 记录下的局域网IP地址
// 端口为 Kratos 指定的 gRPC 服务 9000
$client = new GreeterClient('192.168.1.20:9000', [
'credentials' => null,
]);
$client->authUser = $this->request->getAttribute('AuthUser');
$user = $this->request->input('user', 'Hyperf');
$method = $this->request->getMethod();
$request = new HelloRequest();
$request->setName($user);
[$reply, $code, $response] = $client->SayHello($request);
if ($code !== StatusCode::OK) {
// 通过 response 获取到 status
$status = Parser::statusFromResponse($response);
// 注意最后需调用 Google\Protobuf\Any 类的解包操作,不然会是个乱码字符串
$detail = $status?->getDetails()->offsetGet(0)->unpack();
throw new BusinessException($detail->getCode(), $detail->getMsg());
}
$message = $reply->getMessage();
return Result::result([
'method' => $method,
'memory_get_usage' => memory_get_usage(true),
'message' => $message,
]);
}
}
通过查看 Parser::statusFromResponse() 方法的源码我们知道,status 中错误信息 details 字段其实是放到 metadata 中的,而 metadata 是放到 HTTP 的 header 中的,并且是个固定值:grpc-status-details-bin,经过了 base64 编码处理。
/**
* @param Response
* @param mixed $response
*/
public static function statusFromResponse($response): ?Status
{
$detailsEncoded = $response->headers['grpc-status-details-bin'] ?? '';
if (! $detailsEncoded || ! $detailsBin = base64_decode($detailsEncoded, true)) {
return null;
}
return self::deserializeUnpackedMessage([Status::class, ''], $detailsBin);
}
最后我们启动 Hyperf 的服务,经过鉴权认证访问 http://127.0.0.1:9501 ,已可查看到返回:
{
"code": 500,
"data": [],
"msg": "biz error"
}
gRPC 错误处理大功告成!