上一篇链接: 记一次gRPC的调用实战(一)

这次我们在已搭建好的 Hyperf 的 gRPC-client 进行 jwt 认证通信开发,再将已经认证好的用户信息通过 Metadata 的形式传递给 Kratos 的 gRPC-server,来达到一个完整的内部服务调用的目的。

我们使用 phper666/jwt-auth 这个 PHP 扩展来实现 Hyperf 的 jwt 认证核心功能,首先在 Hyperf 容器中进入项目目录执行

composer require phper666/jwt-auth

等待执行完毕后,通过 Hyperf 的扩展发布命令生成 jwt 的配置

php bin/hyperf.php vendor:publish phper666/jwt-auth

生成好的配置,路径在项目目录下(后续如无说明,路径统统为项目目录下)的 config/autoload/jwt.php,可以按照项目需求进行适当调整,这里只做默认的处理。

首先需要注释掉 no_check_route 中默认配置的 ["", "/"],因为这一段不去掉的话,所有的路由都会直接放行。再就是 blacklist_enabled 这一项需要设置为 false,因为需要用到 redis 做缓存,我们直接省去 redis 环境搭建的步骤。

接下来编写 jwt 的中间件,应用于需鉴权的模块中。首先我们在 app/Constants 中打开 ErrorCode.php 文件定义一个错误常量,用来 jwt 错误时返回。

declare(strict_types=1);

namespace App\Constants;

use Hyperf\Constants\AbstractConstants;
use Hyperf\Constants\Annotation\Constants;

#[Constants]
class ErrorCode extends AbstractConstants
{
    /**
     * @Message("Server Error!")
     */
    public const SERVER_ERROR = 500;

    /**
     * @Message("Something went wrong!")
     */
    public const COMMON_ERROR = 400;

     /**
     * @Message("Authorization failed")
     */
    public const CODE_TO_AUTH_FAIL = 401;
}

Hyperf 的中间件存放在 app/Middleware 中,我们新建一个 AuthMiddleware.php

declare(strict_types=1);

namespace App\Middleware;

use App\Constants\ErrorCode;
use Hyperf\Context\Context;
use Hyperf\HttpServer\Contract\ResponseInterface as HttpResponse;
use Phper666\JWTAuth\Exception\JWTException;
use Phper666\JWTAuth\Util\JWTUtil;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Phper666\JWTAuth\JWT;
use Phper666\JWTAuth\Exception\TokenValidException;

/**
 * jwt token 校验的中间件,校验场景是否一致
 */
class AuthMiddleware implements MiddlewareInterface
{
    public function __construct(protected HttpResponse $response, protected JWT $jwt)
    {
    }

    /**
     * @param ServerRequestInterface  $request
     * @param RequestHandlerInterface $handler
     * @return ResponseInterface
     * @throws \Throwable
     */
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $token = $request->getHeaderLine('Authorization') ?? '';
        if ($token === "") {
            throw new JWTException('Missing token', ErrorCode::COMMON_ERROR);
        }

        $token = JWTUtil::handleToken($token);
        if ($token !== false && $this->jwt->verifyTokenAndScene('application2', $token)) {
            // 封装认证用户信息
            $request = $request->withAttribute('AuthUser', JWTUtil::getParserData($request));
            Context::set(ServerRequestInterface::class, $request);

            return $handler->handle($request);
        }

        throw new TokenValidException('Token authentication does not pass', ErrorCode::COMMON_ERROR);
    }
}

实现鉴权中间件的代码中我们是直接通过抛异常的形式进行错误返回,如果不自行定义一下异常处理方式,则会直接将异常信息输出在运行控制台上,页面上无法显示具体的错误原因和错误码,所以我们需要调整 app/Exception/Handler/AppExceptionHandler.php 默认的处理方式

declare(strict_types=1);

namespace App\Exception\Handler;

use App\Constants\ErrorCode;
use App\Helper\Result;
use Hyperf\Contract\StdoutLoggerInterface;
use Hyperf\ExceptionHandler\ExceptionHandler;
use Hyperf\HttpMessage\Stream\SwooleStream;
use Psr\Http\Message\ResponseInterface;
use Throwable;

class AppExceptionHandler extends ExceptionHandler
{
    public function __construct(protected StdoutLoggerInterface $logger)
    {
    }

    public function handle(Throwable $throwable, ResponseInterface $response)
    {
        $message = $throwable->getMessage();
        $error = explode('|', $message, 2);
        $code = $error[1] ?? $throwable->getCode() ?: ErrorCode::COMMON_ERROR;
        $data = Result::error($code, $error[0]);
        $responseStr = json_encode($data, JSON_UNESCAPED_UNICODE);

        return $response->withHeader('Content-Type', 'application/json')->withBody(new SwooleStream($responseStr));
    }

    public function isValid(Throwable $throwable): bool
    {
        return true;
    }
}

再将异常处理代码中未定义的错误返回助手函数实现一下,我们在 app 中新增一个 Helper 目录,在里面再建一个 Result.php。

declare(strict_types=1);

namespace App\Helper;

use App\Constants\ErrorCode;

/**
 * 返回处理公共类
 */
class Result
{
    /**
     * 返回数据格式化
     *
     * @param array|null $data
     * @param int $code
     * @param string $message
     * @return array
     */
    public static function result(?array $data = null, int $code = 200, string $message = 'success') : array
    {
        return [
            'code' => $code,
            'data' => $data ?? [],
            'msg' => $message
        ];
    }

    /**
     * 错误返回
     *
     * @param int $code
     * @param string|null $message
     * @param array|null $data
     * @return array
     */
    public static function error(int $code = ErrorCode::SERVER_ERROR, ?string $message = null, ?array $data = null) : array
    {
        if ($message === null) {
            $message = ErrorCode::getMessage($code);
        }

        return self::result($data, $code, $message);
    }
}

然后在 app/Controller 中增加 UserController.php,用于增加获取 JWT token 的方法便于调试。

declare(strict_types=1);

namespace App\Controller;

use App\Constants\ErrorCode;
use App\Exception\BusinessException;
use App\Helper\Result;
use Hyperf\Di\Annotation\Inject;
use Hyperf\HttpServer\Annotation\AutoController;
use Phper666\JWTAuth\JWT;
use Psr\SimpleCache\InvalidArgumentException;

#[AutoController]
class UserController extends AbstractController
{
    /**
     * JWT认证组件
     *
     * @var JWT
     */
    #[Inject]
    protected JWT $jwt;

    /**
     * 获取JWT认证token
     *
     * @return array
     */
    public function getToken()
    {
        $uid = $this->request->input('uid');
        $nickname = $this->request->input('nickname');
        
        try {
            $token = $this->jwt->getToken('application2', [
                'uid' => $uid,
                'nickname' => $nickname,
            ]);
        } catch (InvalidArgumentException) {
            // 这里可以再记录一下日志
            throw new BusinessException(ErrorCode::CODE_TO_AUTH_FAIL);
        }

        return Result::result([
            'token' => $token->toString(),
            'exp' => $this->jwt->getTTL($token->toString())
        ]);
    }
}

此时运行 Hyperf 项目,访问 http://127.0.0.1:9501/user/getToken?uid=1&nickname=fantasticbin 已可获取到 token 及过期时间。

如果要在 Kratos 的 gRPC-server 中获取到 JWT 认证后的信息,则需要通过 gRPC 的 Metadata 元信息特性来传递。我们先调整好 Hyperf 的调用过程定义。

declare(strict_types=1);

namespace App\Grpc;

use Helloworld\V1\HelloReply;
use Helloworld\V1\HelloRequest;
use Hyperf\GrpcClient\BaseClient;

class GreeterClient extends BaseClient
{
    /**
     * JWT认证信息
     * 
     * @var array
     */
    public array $authUser;

    public function SayHello(HelloRequest $request)
    {
        $metadata = [];

        if (!empty($this->authUser)) {
            $metadata = [
                'x-md-global-uid' => $this->authUser['uid'],
                'x-md-global-nickname' => $this->authUser['nickname']
            ];
        }

        return $this->_simpleRequest(
            '/helloworld.v1.Greeter/SayHello',
            $request,
            [HelloReply::class, 'decode'],
            $metadata
        );
    }
}

继续在 IndexController 中将上面新增的鉴权中间件通过注解的方式引用进去,并去除 config/routes.php 中自带的主页路由,改由 Controller 注解及 GetMapping 注解来实现路由定义,同时将返回格式调整为结果返回助手函数统一处理。

declare(strict_types=1);

namespace App\Controller;

use App\Grpc\GreeterClient;
use App\Helper\Result;
use App\Middleware\AuthMiddleware;
use Helloworld\V1\HelloRequest;
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, $status] = $client->SayHello($request);

        $message = $reply->getMessage();

        return Result::result([
            'method' => $method,
            'memory_get_usage' => memory_get_usage(true),
            'message' => $message,
        ]);
    }
}
declare(strict_types=1);

use Hyperf\HttpServer\Router\Router;

Router::get('/favicon.ico', function () {
    return '';
});

此时重启 Hyperf 并访问 http://127.0.0.1:9501 已能查看到 "Missing token" 的返回,再通过 getToken 方法获取到 token 后带入到请求 Header 中,已能正常走通 gRPC 流程。

我们再来到 Kratos 中,定义一个中间件,将 Metadata 元信息获取并存储到 ctx 中。

package middleware

import (
    "context"
    "github.com/go-kratos/kratos/v2/middleware"
    "github.com/go-kratos/kratos/v2/transport"
)

func AuthMiddleware() middleware.Middleware {
    return func(handler middleware.Handler) middleware.Handler {
        return func(ctx context.Context, req interface{}) (reply interface{}, err error) {
            if tr, ok := transport.FromServerContext(ctx); ok {
                userId := tr.RequestHeader().Get("x-md-global-uid")
                nickname := tr.RequestHeader().Get("x-md-global-nickname")

                ctx = context.WithValue(ctx, "uid", userId)
                ctx = context.WithValue(ctx, "nickname", nickname)
            }

            return handler(ctx, req)
        }
    }
}

再将新增的中间件放入 Kratos 项目所在目录下 internal/server/grpc.go 中 NewGRPCServer 的 grpc.Middleware 列表中,同时在前面加上 metadata.Server() 进行 Metadata 数据加载。

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(),
        ),
    }
    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
}

最后在业务逻辑中,也就是 internal/biz/greeter.go 中加入日志查看 ctx 是否包含 jwt 信息。

// CreateGreeter creates a Greeter, and returns the new Greeter.
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 uc.repo.Save(ctx, g)
}

开启 Kratos 服务,再开启 Hyperf 服务,最后调取一下接口,Kratos 日志上已显示出打印的 uid 和 nickname:

INFO ts=2023-08-27T15:30:29+08:00 caller=biz/greeter.go:45 service.id=Fantasticbin-PC service.name= service.version= trace.id= span.id= msg=jwt uid: 1
INFO ts=2023-08-27T15:30:29+08:00 caller=biz/greeter.go:46 service.id=Fantasticbin-PC service.name= service.version= trace.id= span.id= msg=jwt nickname: fantasticbin

内部服务调用大功告成!