上一篇链接: 记一次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 错误处理大功告成!