学习gRPC的基本用法,并在 gRPC 中实现客户端用户核验的两个demo

代码仓库1(实现grpc四种通信模式和拦截器的demo):代码仓库1

代码仓库2(在grpc中实现客户端用户核验,使用JWT):代码仓库2

一、gRPC的基本用法

1.什么是RPC

在学习gRPC之前,我们首先要了解什么是RPC。

RPC(Remote Procedure Call)是远程过程调用协议,它是一种通过网络从远程计算机上请求服务,而不需要了解底层网络技术的协议。RPC的主要功能目标是让构建分布式计算(应用)更容易,在提供强大的远程调用能力时不损失本地调用的语义简洁性。为实现该目标,RPC框架需提供一种透明调用机制,让使用者不必显式的区分本地调用和远程调用。

通俗地讲,使用RPC进行通信,调用远程函数就像调用本地函数一样,RPC底层会做好数据的序列化与传输,从而能使我们更轻松地创建分布式应用和服务。

2.什么是gRPC

gRPC是一种用于实现RPC的新式的、开源的高性能框架。gRPC源自Google,他基于定义服务的思想,指定可以远程调用的接口及其参数和返回类型。服务端实现这个接口并运行一个 gRPC 服务器来处理客户端调用。而客户端有一个stub(存根,在某些语言中称为客户端client),它提供与服务器相同的方法。客户端通过调用stub的方法来与服务端进行通信,获取响应结果。

gRPC的典型特征就是使用protobuf(全称protocol buffers)作为其接口定义语言(Interface Definition Language,缩写IDL),同时底层的消息交换格式也是使用protobuf。开发人员使用IDL为每个微服务定义服务协定。该协定作为基于文本的 .proto 文件实现,描述了每个服务的方法、输入和输出。同一文件可用于基于不同开发平台不同语言构建的gRPC客户端和服务。

3.gRPC的四种通信模式

gRPC中服务端和客户端的交互存在着四种不同的通信模式:

  1. 一元RPC(unary RPC)
  2. 服务端流 RPC(server-streaming RPC)
  3. 客户端流 RPC(client-streaming RPC)
  4. 双向流 RPC(bidirectional-streaming RPC)

下面我们将通过一个小的demo来了解一下这四种通信模式(对应代码仓库1的内容)

3.1 准备工作

我的整体项目结构如下

grpc-communicationMode
│ 
├───bidirectional-streamingRPC-client//双向流RPC客户端
│   ├───pom.xml
│   ├───src
├───bidirectional-streamingRPC-server//双向流RPC服务端
│   ├───pom.xml
│   ├───src
├───client-streamingRPC-client//客户端流RPC客户端
│   ├───pom.xml
│   ├───src
├───client-streamingRPC-server//客户端流RPC服务端
│   ├───pom.xml
│   ├───src
├───server-streamingRPC-client//服务端流RPC客户端
│   ├───pom.xml
│   ├───src
├───server-streamingRPC-server//服务端流RPC服务端
│   ├───pom.xml
│   ├───src
├───src//主项目源码
├───UnaryRPC-client//一元RPC客户端,内部实现了客户端拦截器
│   ├───pom.xml
│   ├───src
├───UnaryRPC-server//一元RPC服务端,内部实现了服务端拦截器
│   ├───pom.xml
│   ├───src
└───pom.xml//主项目配置文件

首先在idea中创建maven项目 grpc-communicationMode ,作为父项目,引入项目依赖和所需插件

    <dependencies>
        <dependency>
            <groupId>io.grpc</groupId>
            <artifactId>grpc-netty-shaded</artifactId>
            <version>1.52.1</version>
        </dependency>
        <dependency>
            <groupId>io.grpc</groupId>
            <artifactId>grpc-protobuf</artifactId>
            <version>1.52.1</version>
        </dependency>
        <dependency>
            <groupId>io.grpc</groupId>
            <artifactId>grpc-stub</artifactId>
            <version>1.52.1</version>
        </dependency>
        <dependency> 
            <groupId>org.apache.tomcat</groupId>
            <artifactId>annotations-api</artifactId>
            <version>6.0.53</version>
            <scope>provided</scope>
        </dependency>
    </dependencies>

   <build>
        <extensions>
            <extension>
                <groupId>kr.motd.maven</groupId>
                <artifactId>os-maven-plugin</artifactId>
                <version>1.6.2</version>
            </extension>
        </extensions>
        <plugins>
            <plugin>
                <groupId>org.xolstice.maven.plugins</groupId>
                <artifactId>protobuf-maven-plugin</artifactId>
                <version>0.6.1</version>
                <configuration>
                    <protocArtifact>com.google.protobuf:protoc:3.21.7:exe:${os.detected.classifier}</protocArtifact>
                    <pluginId>grpc-java</pluginId>
                    <pluginArtifact>io.grpc:protoc-gen-grpc-java:1.51.0:exe:${os.detected.classifier}</pluginArtifact>
                </configuration>
                <executions>
                    <execution>
                        <goals>
                            <goal>compile</goal>
                            <goal>compile-custom</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

默认情况下,gRPC 使用 Protocol Buffers,这是 Google 提供的一个成熟的开源的跨平台的序列化数据结构的协议,我们编写对应的 proto 文件,通过上面这个插件可以将我们编写的 proto 文件自动转为对应的 Java 类。

接着在src/main/proto 的位置,我们新建一个 book.proto 文件,用于定义数据结构和服务接口。

数据结构即消息类型,我们可以在.proto文件中定义各种消息类型,每种消息类型都可以包含多个字段,每个字段都有一个名称和类型。这些数据结构会在客户端和服务端之间进行交换。
服务接口则定义了服务端可以提供哪些RPC方法,以及这些方法的输入和输出参数。

一旦定义了数据结构和服务接口,就可以使用gRPC的工具来自动生成客户端和服务器的代码。这些代码可以在gRPC支持的任何语言中运行,包括但不限于C++, Java, Python, Go, Ruby,等。
book.proto内容如下,主要定义了一些图书相关的方法:

syntax = "proto3";

option java_multiple_files = true;
option java_package = "ustc.codemaker.grpc.demo1";
option java_outer_classname = "BookServiceProto";
import "google/protobuf/wrappers.proto";

package book;

service BookService {
   rpc addBook(Book) returns (google.protobuf.StringValue);//添加图书,使用图书对象插入,返回插入图书的id
   rpc getBook(google.protobuf.StringValue) returns (Book);//获取图书,使用id查找,返回图书对象
   rpc searchBooks(google.protobuf.StringValue) returns (stream Book);//查找图书,使用tag查找,返回所有包含tag的图书对象
   rpc updateBooks(stream Book) returns (google.protobuf.StringValue);//更新图书,给出一系列更新操作,返回所有被更改的图书id
   rpc processBooks(stream google.protobuf.StringValue) returns (stream BookSet);//模拟双向流,没有实际意义
}

message Book {
  string id = 1;
  repeated string tags = 2;
  string name = 3;
  float price = 4;
  string author = 5;
}

message BookSet {
  string id = 1;
  repeated Book bookList = 3;
}

注意如果不在src/main/proto这个默认位置创建.proto文件的话,那么在配置插件的时候需要指定 proto 文件的位置。

.proto文件是grpc中比较核心的配置,我们简单介绍一下:

  1. option java_multiple_files = true;可选字段,如果设置为 true,表示每一个 message 文件都会对应一个单独的 class 文件;否则,message 将被全部定义在 outerclass 文件里。
  2. option java_package = "ustc.codemaker.grpc.ddemo1"; 可选字段,用于标识生成的 java 文件的 package。如果没有指定,则使用 proto 里定义的 package,如果package 也没有指定,那就会生成在根目录下。
  3. option java_outer_classname = "BookServiceProto"; 可选字段,用于指定 proto 文件生成的 java 类的 outerclass 类名。outerclass是一个包含所有message对应的Java类的class文件。如果没有指定,那么outerclass的名称将默认为proto文件的驼峰式名称。
  4. package book;这个属性用于定义message的包名。包名的含义与平台语言无关,它只在proto文件中用于区分同名的message类型。可以把它理解为message全名的前缀,和message名称一起,可以唯一标识一个message类型。当我们在proto文件中导入其他proto文件的message时,需要加上package前缀。
  5. sevice我们在service中定义的方法都是跨平台的。在book.proto文件中,我们定义了五个方法,并给出了相应的注释。这里的定义相当于一个接口,我们将在Java代码中实现这个接口。
  6. message:这里类似于我们在Java中定义类。在上文中,我们定义了两个类,分别是Book和BookSet,这两个类在service中被使用。

在 message 中定义的属性的时候,都会给一个数字,这个数字称为字段编号。这些数字在序列化消息时用于标识各个字段。这些数字在消息类型的整个生命周期内必须保持不变。

接下来需要使用插件来生成对应的 Java 代码,执行compile 和 compile-custom 两个指令,其中 compile 用来编译消息对象,compile-custom 则依赖消息对象,生成接口服务。执行界面如下图所示。

接着就会在对应的路径下生成相应的java代码

至此我们的准备工作就完成了。

3.2 一元RPC(unary RPC)

一元RPC中,每个请求相互独立,客户端发起一个请求,服务端给出一个响应,然后请求结束。上面我们定义的五个方法中,addBook 和 getBook 都是一元 RPC。我实现了getBook的服务端和客户端(对应代码仓库1中的UnaryRPC,由于篇幅原因,此处代码不做详细解释),getBook 根据客户端传来的 id,从 Map 中查询到一个 Book 并返回,运行效果如下图。

3.3 服务端流 RPC(server-streaming RPC)

服务端流 RPC 模式中,客户端发起一个 RPC 请求,服务端会返回一个响应序列,组成一个流,发送完所有的响应后,服务端在流结尾标记服务状态详情作为结束的元数据。
上面我们给出的 searchBook 就是这样一个例子,客户端给出所要查询图书的 tag 参数,然后在服务端查询哪些书的 tags 满足条件,将满足条件的书全部返回。 (对应代码仓库1中的server-streamingRPC,由于篇幅原因,此处代码不做详细解释),效果如下图。

搜索的关键字是明清小说,每当服务端返回一次数据的时候,客户端回调的 onNext 方法就会被触发一次,当服务端执行了responseObserver.onCompleted(); 之后,客户端的 onCompleted 方法也会被触发,结束此次调用。

3.4 客户端流 RPC(client-streaming RPC)

与服务端流模式类似,客户端流RPC会发送连续的请求给服务端,服务端在收到请求后不会立马给到客户端响应结果,直到请求结束了,才会返回一个单独的响应。
我们上面的 updateBooks 就是一个客户端流的案例,客户端想要修改图书,可以发起多个请求修改图书,服务端则收集多次修改的结果,将之汇总然后一次性返回给客户端。 (对应代码仓库1中的client-streamingRPC,由于篇幅原因,此处代码不做详细解释),效果如下图。

两次修改id为1的图书,可以直接得到最后一次(最新的)修改的结果。

3.5 双向流 RPC(bidirectional-streaming RPC)

双向流其实就是 3.3、3.4 小节的合体。即客户端多次发送数据,服务端也多次响应数据。

代码仓库1的bidirectional-streamingRPC中我们实现了双向流RPC,我的操作逻辑是客户端传递多个 ID 到服务端,然后服务端根据这些 ID 构建对应的 Book 对象,然后三个三个一组,再返回给客户端。
服务端操作逻辑是:客户端每次发送一个请求,都会触发服务端的 onNext 方法,在这个方法中实现了对请求分组的返回。最后如果还有剩余的请求,我们在 onCompleted() 方法中返回。

4. gRPC上的拦截器

在gRPC中有请求的发送、处理,也就会有拦截器的需求。grpc服务端和客户端都提供了拦截器interceptor功能,功能类似中间件middleware,很适合在这里处理验证、日志等流程。gRPC 中的拦截器整体上来说可以分为两大类:

  1. 服务端拦截器
  2. 客户端拦截器

4.1 服务端拦截器

服务端拦截器又可以继续细分为一元拦截器流拦截器。其作用有点像 Java 中的 Filter
一元拦截器对应一元 RPC,流拦截器则对应服务端流 RPC、客户端流 RPC 以及双向流 RPC。两者的基本概念相同,仅有的区别是在处理流式RPC时,可能需要处理多个消息。所以,我们不再区分一元拦截器和流拦截器,直接进行服务端拦截器demo的简单实现。

服务端拦截器的工作原理如下图所示,可以在服务端处理请求之前将请求拦截下来,统一进行权限校验等操作,也可以在服务端将请求处理完毕之后,准备响应的时候将响应拦截下来,可以对响应进行二次处理。

我们在上述一元RPC的代码基础上( 对应代码仓库1中的UnaryRPC)来添加拦截器,首先来看请求拦截器:

public class BookServiceCallListener<R> extends ForwardingServerCallListener<R> {
    private final ServerCall.Listener<R> delegate;
    private final ServerCall<R, ?> call;

    public BookServiceCallListener(ServerCall.Listener<R> delegate, ServerCall<R, ?> call) {
        this.delegate = delegate;
        this.call = call;
    }

    @Override
    protected ServerCall.Listener<R> delegate() {
        return delegate;
    }

    @Override
    public void onMessage(R message) {
        String authority = call.getAuthority();
        System.out.println("这是客户端用户 "+authority+" 发来的消息,请求资源id为:"+((StringValue)message).getValue());
        super.onMessage(message);
    }
}

这里我们定义一个继承自 ForwardingServerCallListener 的类,重写 onMessage 方法,当有请求到达的时候,就会经过这里的 onMessage 方法,打印出客户端用户的权限和请求的资源id。最后,它调用了父类的 onMessage方法来处理消息。

响应拦截器代码:

public class BookServiceCall<ReqT,RespT> extends ForwardingServerCall.SimpleForwardingServerCall<ReqT,RespT> {
    protected BookServiceCall(ServerCall<ReqT, RespT> delegate) {
        super(delegate);
    }

    @Override
    protected ServerCall<ReqT, RespT> delegate() {
        return super.delegate();
    }

    @Override
    public MethodDescriptor<ReqT, RespT> getMethodDescriptor() {
        return super.getMethodDescriptor();
    }

    @Override
    public void sendMessage(RespT message) {
        System.out.println("这是服务端返回给客户端的消息:");
        Book returnbook=(Book) message;
        System.out.println("id: " + returnbook.getId());
        System.out.println("name: " + returnbook.getName());
        System.out.println("author: " + returnbook.getAuthor());
        System.out.println("price: " + returnbook.getPrice());
        for (String tag : returnbook.getTagsList()) {
            System.out.println("tag: " + tag);
        }
        super.sendMessage(message);
    }
}

这里是重写 sendMessage 方法,它在发送响应给客户端之前被调用,在这个方法中我们可以对服务端准备返回给客户端的消息进行处理。所以这个位置就相当于响应拦截器

这里用到了很多泛型,因为实际应用中,拦截器可能会拦截到多种类型的请求,请求参数和响应的数据类型都不一定一样。

最后,我们需要在启动服务的时候,将这两个拦截器配置进去,代码如下:

public class GetBookServer {
    Server server;

    public static void main(String[] args) throws IOException, InterruptedException {
        GetBookServer server = new GetBookServer();
        server.start();
        server.blockUntilShutdown();
    }

//    public void start() throws IOException {
//        int port = 50790;
//        server = ServerBuilder.forPort(port)
//                .addService(new BookServiceImpl())
//                .build()
//                .start();
//        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
//            GetBookServer.this.stop();
//        }));
//    }

    /***拦截器版本****/
    public void start() throws IOException {
        int port = 50790;
        server = ServerBuilder.forPort(port)
                .addService(ServerInterceptors.intercept(new BookServiceImpl(), new ServerInterceptor() {
                    @Override
                    public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(ServerCall<ReqT, RespT> call, Metadata headers, ServerCallHandler<ReqT, RespT> next) {
                        String fullMethodName = call.getMethodDescriptor().getFullMethodName();
                        System.out.println(fullMethodName + ":pre");
                        Set<String> keys = headers.keys();
                        for (String key : keys) {
                            System.out.println(key + ">>>" + headers.get(Metadata.Key.of(key, ASCII_STRING_MARSHALLER)));
                        }
                        return new BookServiceCallListener<>(next.startCall(new BookServiceCall(call), headers), call);
                    }
                }))
                .build()
                .start();
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            GetBookServer.this.stop();
        }));
    }


    private void stop() {
        if (server != null) {
            server.shutdown();
        }
    }

    private void blockUntilShutdown() throws InterruptedException {
        if (server != null) {
            server.awaitTermination();
        }
    }
}

以前调用 addService 方法的时候,直接添加对应的服务就可以了,现在,我们添加服务时使用了ServerInterceptors.intercept()方法来添加拦截器。这个方法接收两个参数:一个是要被拦截的服务,另一个是拦截器。

每当请求到达的时候,就会经过拦截器的 interceptCall 方法,这个方法有三个参数:

  • 第一个参数 call, 它代表一个服务器端的调用,可以从这个对象中获取到关于这个调用的各种信息,比如方法名、方法类型等。
  • 第二个参数 headers 则是请求的消息头,如果我们通过 JWT 进行请求校验,那么就从这个 headers 中提取出请求的 JWT 令牌然后进行校验。
  • 第三个参数 next,可以把它理解为Java过滤器中的filterChain。当拦截器完成了所有的工作后后,调用next.startCall()方法来让请求到达实际的服务实现。

在我们的例子中,interceptCall()方法的返回值构建了我们刚刚写的请求拦截器和响应拦截器。他会在请求到达BookServiceImpl之前先处理请求。然后,BookServiceCall会在响应离开BookServiceImpl之前处理响应。

4.2 客户端拦截器

客户端拦截器的实现较为简单,他可以将请求拦截下来修改一些参数,如为所有的请求添加统一的令牌 Token等,方式如下:

public class GetBookServiceClient {
    public static void main(String[] args) throws InterruptedException {
//        ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 50790)
//                .usePlaintext()
//                .build();
        /***拦截器版本****/
        ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 50790)
                .usePlaintext()
                .intercept(new ClientInterceptor() {
                    @Override
                    public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(MethodDescriptor<ReqT, RespT> method, CallOptions callOptions, Channel next) {
                        System.out.println("!!!!!!!!!!!!!!!!");
                        callOptions = callOptions.withAuthority("ustc_codemaker");
                        return next.newCall(method,callOptions);
                    }
                })
                .build();

        BookServiceGrpc.BookServiceStub stub = BookServiceGrpc.newStub(channel);
        getBook(stub);
    }

    private static void getBook(BookServiceGrpc.BookServiceStub stub) throws InterruptedException {
        CountDownLatch countDownLatch = new CountDownLatch(1);
        stub.getBook(StringValue.newBuilder().setValue("2").build(), new StreamObserver<Book>() {
            @Override
            public void onNext(Book book) {
                System.out.println("id: " + book.getId());
                System.out.println("name: " + book.getName());
                System.out.println("author: " + book.getAuthor());
                System.out.println("price: " + book.getPrice());
                for (String tag : book.getTagsList()) {
                    System.out.println("tag: " + tag);
                }
            }

            @Override
            public void onError(Throwable throwable) {

            }

            @Override
            public void onCompleted() {
                countDownLatch.countDown();
                System.out.println("查询完毕");
            }
        });
        countDownLatch.await();
    }
}

当我们的请求执行的时候,这个客户端拦截器就会被触发,打印一条提示,然后修改了callOptions的权限为"ustc_codemaker",最后通过next.newCall()方法创建了一个新的ClientCall并返回。

添加拦截器之后的,gRPC运行效果如下:

到这里,们会实现一个在 gRPC 中使用gRPC的基本用法就算讲清楚了,接下来我 JWT 完成身份校验的案例。

二、在 gRPC 中使用 JWT 完成身份校验

拦截器的一个重要使用场景就是进行身份的校验。当客户端发起请求的时候,服务端通过拦截器进行身份校验,就知道这个请求是谁发起的了。

1.JWT介绍

1.1 无状态登录

1.1.1 什么是有状态服务

有状态服务是指服务端需要记录每次会话的客户端信息,以识别客户端身份并根据用户身份处理请求。例如,在用户登录后,我们将用户的信息保存在服务端的Session中,并给用户一个Cookie值来记录对应的Session。然后,当用户下次请求时,他们会携带这个Cookie值(这一步由浏览器自动完成),我们就能识别到对应的Session,从而找到用户的信息。这种方式虽然方便,但也存在一些缺点:

  • 服务端需要保存大量数据,这会增加服务端的压力。
  • 服务端保存用户状态,不支持集群化部署
1.1.2 什么是无状态服务

无状态服务即服务端不保存任何客户端请求者信息,客户端的每次请求必须具备自描述信息,通过这些信息识别客户端身份。微服务集群中的每个服务,对外提供的都使用 RESTful 风格的接口。而 RESTful 风格的一个最重要的规范就是:服务的无状态性

那么这种无状态服务有哪些好处呢?

  • 客户端请求不依赖服务端的信息,多次请求不需要必须访问到同一台服务器
  • 服务端的集群和状态对客户端透明
  • 服务端可以任意迁移和伸缩,便于集群化部署。
  • 减小服务端存储压力

1.2 如何实现无状态服务

无状态登录的流程:

  • 客户端发送账户名/密码到服务端进行认证
  • 认证通过后,服务端将用户信息加密并且编码成一个令牌 token,返回给客户端
  • 以后客户端每次发送请求,只需要携带认证的 token,无需账户和密码
  • 服务端对客户端发送来的 token 进行解密,判断是否有效,并且获取用户登录信息

1.3 JWT

1.3.1 JWT简介

JWT,全称是 Json Web Token,是一种 JSON 风格的轻量级的授权和身份认证规范,可实现无状态、分布式的 Web 应用授权。
简单来说, 就是通过一些算法对加密字符串和JSON对象之间进行加解密,用来实现端到端安全验证。JWT加密JSON,保存在客户端,不需要在服务端保存会话信息。

JWT 作为一种规范,并没有和某一种语言绑定在一起,常用的 Java 实现是 GitHub开源项目jjwtJJWT(Java JSON Web Token)是一个开放源代码的Java库,用于创建和验证JSON Web Tokens (JWTs),它提供了一种端到端的JWT创建和验证的方式。

1.3.2 JWT数据结构

JSON Web Token由三部分组成,它们之间用圆点(.)连接。这三部分分别是:

  • Header
  • Payload
  • Signature

第一部分是Header,它通常由两部分组成:token的类型(这里是JWT)和使用的签名算法(例如HMAC SHA256或RSA)。使用Base64对这个JSON编码就得到JWT的第一部分

第二部分是Payload,它包含声明(要求)。声明是关于实体(通常是用户)和其他数据的声明,如签发人、主题、受众等。使用Base64对这个JSON编码就得到JWT的第二部分

第三部分是Signature(签名),是整个数据的认证信息。一般根据前两步的编码数据,再加上服务的的密钥secret(密钥保存在服务端,不能泄露),通过 Header 中配置的签名算法生成。用于验证整个数据完整和可靠性。

官网的数据结构示意图如下:

1.3.3 JWT 交互流程

交互流程:

  1. 客户端使用账户名和密码登录
  2. 登录后,服务器会向客户端返回token访问令牌
  3. 客户端使用访问令牌来访问受保护资源(如 API)

因为 JWT 签发的 token 中已经包含了用户的身份信息,并且每次请求都会携带,这样服务的就无需保存用户信息,甚至无需去数据库查询。

1.3.4 JWT 存在的问题

JWT 也存在着一些问题和弊端:

  1. 续签问题,传统的 cookie+session 的方案天然的支持续签,但是 JWT 由于服务端不保存用户状态,因此很难完美解决续签问题。
  2. 注销问题,由于服务端不再保存用户信息,所以一般可以通过修改 secret 来实现注销,服务端 secret 修改后,已经颁发的未过期的 token 就会认证失败,进而实现注销,但没有传统的注销方便。

当然,为了解决 JWT 存在的问题,也可以将 JWT 结合 Redis 来用,服务端生成的 JWT 字符串存入到 Redis 中并设置过期时间,每次校验的时候,先看 Redis 中是否存在该 JWT 字符串,如果存在就进行后续的校验,但是这种方式又成了有状态服务了。

2. 实践在gRPC中结合JWT

我们的项目结构如下

grpc-jwt
├── grpc-jwt-client
│   ├── pom.xml
│   └── src
├── grpc-jwt-server
│   ├── pom.xml
│   └── src
├── src
└── pom.xml

在主项目grpc-jwt下,放置公共代码和依赖,grpc-jwt-server用来放服务端的代码,grpc-jwt-client则是客户端。

服务端主要提供了两个接口:

  1. 登录接口,登录成功之后返回 JWT 令牌。
  2. hello 接口,客户端使用 JWT 令牌来访问 hello 接口

2.1 主项目中的准备工作

将 protocol buffers 和一些依赖放在grpc-jwt模块中,便于grpc-jwt-server 和 grpc-jwt-client 的使用

导入JWT 依赖,在这里使用了比较流行的 JJWT 工具,其他依赖和插件配置与前面3.1中的依赖配置完全相同,在此不做赘述。

    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.11.5</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <version>0.11.5</version>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
        <version>0.11.5</version>
        <scope>runtime</scope>
    </dependency>

创建 login.proto文件

syntax = "proto3";

option java_multiple_files = true;
option java_package = "org.javaboy.grpc.api";
option java_outer_classname = "LoginProto";
import "google/protobuf/wrappers.proto";

package login;

service LoginService {
  rpc login (LoginBody) returns (LoginResponse);
}

service HelloService{
  rpc sayHello(google.protobuf.StringValue) returns (google.protobuf.StringValue);
}

message LoginBody {
  string username = 1;
  string password = 2;
}

message LoginResponse {
  string token = 1;
}

定义了两个服务:

  • LoginService:登录服务,传入用户名密码,返回登录成功之后的令牌。
  • HelloService:一个验证JWT的简单服务,传入字符串,返回也是字符串。

然后使用执行compile 和 compile-custom 两个指令,生成对应代码

接下来再定义一个常量类提供一些用于身份验证的常量,这些常量在处理身份验证请求时会被使用,如下:

public interface AuthConstant {
    SecretKey JWT_KEY = Keys.hmacShaKeyFor("ustc_sse_codemaker_5079_VJKNKLsada_".getBytes());
    Context.Key<String> AUTH_CLIENT_ID = Context.key("clientId");
    String AUTH_HEADER = "Authorization";
    String AUTH_TOKEN_TYPE = "Bearer";
}

各个常量的含义:

  1. JWT_KEY:一个SecretKey对象,该对象用于JWT的签名和验证。这里使用了HMAC SHA256算法,密钥是"ustc_sse_codemaker_5079_VJKNKLsada_"
  2. AUTH_CLIENT_ID:用于存储和获取客户端ID,即客户端发送来的请求携带了 JWT令牌,通过 JWT 确认了用户身份,就存在这个变量中。
  3. AUTH_HEADER:该常量表示HTTP请求头中的"Authorization"字段,这个字段通常用于携带身份验证信息。
  4. AUTH_TOKEN_TYPE:该常量表示token的类型,常见取值有 Bearer 和 Basic。

至此,grpc-jwt就定义好了。

2.2 服务端代码

接下来定义服务端,首先定义登录服务:

public class LoginServiceImpl extends LoginServiceGrpc.LoginServiceImplBase {
    @Override
    public void login(LoginBody request, StreamObserver<LoginResponse> responseObserver) {
        String username = request.getUsername();
        String password = request.getPassword();
        if ("ustc_codemaker".equals(username) && "5079".equals(password)) {
            System.out.println("login success");
            //登录成功
            String jwtToken = Jwts.builder().setSubject(username).signWith(AuthConstant.JWT_KEY).compact();
            responseObserver.onNext(LoginResponse.newBuilder().setToken(jwtToken).build());
            responseObserver.onCompleted();
        }else{
            System.out.println("login error");
            //登录失败
            responseObserver.onNext(LoginResponse.newBuilder().setToken("login error").build());
            responseObserver.onCompleted();
        }
    }
}

为了简化操作,我们暂时没有连接数据库,而是将用户名和密码设定为固定值,分别为"ustc_codemaker"和"5079"。一旦用户成功登录,系统将生成一个JWT字符串并返回。在未来,我们可以进一步扩展和丰富这个功能,设计更加符合应用实际的服务器。
如果登录失败,则返回一个 login error 字符串。

再来是HelloService 服务,如下:

public class HelloServiceImpl extends HelloServiceGrpc.HelloServiceImplBase {
    @Override
    public void sayHello(StringValue request, StreamObserver<StringValue> responseObserver) {
        String clientId = AuthConstant.AUTH_CLIENT_ID.get();
        responseObserver.onNext(StringValue.newBuilder().setValue(clientId + " say hello:" + request.getValue()).build());
        responseObserver.onCompleted();
    }
}

该服务较为简单,AuthConstant.AUTH_CLIENT_ID.get(); 表示获取当前访问用户的 ID,这个用户 ID 是在拦截器中传入进来的。

最后是服务端比较重要的拦截器,我们要在拦截器中从请求头中获取到 JWT 令牌并解析,如下:

public class AuthInterceptor implements ServerInterceptor {
    private JwtParser parser = Jwts.parser().setSigningKey(AuthConstant.JWT_KEY);

    @Override
    public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(ServerCall<ReqT, RespT> serverCall, Metadata metadata, ServerCallHandler<ReqT, RespT> serverCallHandler) {
        String authorization = metadata.get(Metadata.Key.of(AuthConstant.AUTH_HEADER, Metadata.ASCII_STRING_MARSHALLER));
        Status status = Status.OK;
        if (authorization == null) {
            status = Status.UNAUTHENTICATED.withDescription("miss authentication token");
        } else if (!authorization.startsWith(AuthConstant.AUTH_TOKEN_TYPE)) {
            status = Status.UNAUTHENTICATED.withDescription("unknown token type");
        } else {
            Jws<Claims> claims = null;
            String token = authorization.substring(AuthConstant.AUTH_TOKEN_TYPE.length()).trim();
            try {
                claims = parser.parseClaimsJws(token);
            } catch (JwtException e) {
                status = Status.UNAUTHENTICATED.withDescription(e.getMessage()).withCause(e);
            }
            if (claims != null) {
                Context ctx = Context.current()
                        .withValue(AuthConstant.AUTH_CLIENT_ID, claims.getBody().getSubject());
                return Contexts.interceptCall(ctx, serverCall, metadata, serverCallHandler);
            }
        }
        serverCall.close(status, new Metadata());
        return new ServerCall.Listener<ReqT>() {
        };
    }
}

拦截器的代码逻辑是:

  1. 首先从 Metadata 中提取出当前请求所携带的 JWT 字符串(相当于从请求头中提取出来),放入Authorization字段中,然后,根据Authorization字段值的不同情况,进行不同的处理。
  2. 如果Authorization字段为 null 或者这个值不是以指定字符 Bearer 开始的,说明这个令牌是一个非法令牌,设置对应的响应 status 。
  3. 否则,尝试解析Authorization字段的值(即JWT)。如果解析失败,则将状态设置为UNAUTHENTICATED,并添加异常信息。如果解析成功,就会获取到一个 Jws 对象,从这个对象中可以提取出来用户名,并存入到 Context 中,,将来在 HelloServiceImpl 中就可以获取到这里的用户名了。
  4. 最后,登录成功的话,Contexts.interceptCall 方法构建监听器并返回;登录失败,则构建一个空的监听器返回。

最后是服务端启动器:

public class LoginServer {
    Server server;

    public static void main(String[] args) throws IOException, InterruptedException {
        LoginServer server = new LoginServer();
        server.start();
        server.blockUntilShutdown();
    }

    public void start() throws IOException {
        int port = 50790;
        server = ServerBuilder.forPort(port)
                .addService(new LoginServiceImpl())
                .addService(ServerInterceptors.intercept(new HelloServiceImpl(), new AuthInterceptor()))
                .build()
                .start();
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            LoginServer.this.stop();
        }));
    }

    private void stop() {
        if (server != null) {
            server.shutdown();
        }
    }

    private void blockUntilShutdown() throws InterruptedException {
        if (server != null) {
            server.awaitTermination();
        }
    }
}

与之前的启动器相比多增加了一个 Service,添加 HelloServiceImpl 服务的时候,加入了一个拦截器,这样可以实现只在调用HelloServiceImpl 服务,jwt认证拦截器才会工作。

grpc-jwt-server 至此开发完毕。

2.3 客户端代码

客户端构建了两个请求服务,分别请求调用LoginService和HelloService

首先看登录:

public class LoginClient {
    public static void main(String[] args) throws InterruptedException {
        ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 50790)
                .usePlaintext()
                .build();
        LoginServiceGrpc.LoginServiceStub stub = LoginServiceGrpc.newStub(channel);
        login(stub);
    }

    private static void login(LoginServiceGrpc.LoginServiceStub stub) throws InterruptedException {
        CountDownLatch countDownLatch = new CountDownLatch(1);
        stub.login(LoginBody.newBuilder().setUsername("ustc_codemaker").setPassword("5079").build(), new StreamObserver<LoginResponse>() {
            @Override
            public void onNext(LoginResponse loginResponse) {
                System.out.println("loginResponse.getToken() = " + loginResponse.getToken());
            }

            @Override
            public void onError(Throwable throwable) {

            }

            @Override
            public void onCompleted() {
                countDownLatch.countDown();
            }
        });
        countDownLatch.await();
    }
}

这个客户端接收到服务器的响应时会打印出服务器返回的Token。

再来看 hello 接口的调用,这个接口调用需要携带 JWT 字符串,而携带 JWT 字符串,则需要我们构建一个 CallCredentials 对象,如下:

public class JwtCredential extends CallCredentials {
    private String subject;

    public JwtCredential(String subject) {
        this.subject = subject;
    }

    @Override
    public void applyRequestMetadata(RequestInfo requestInfo, Executor executor, MetadataApplier metadataApplier) {
        executor.execute(() -> {
            try {
                Metadata headers = new Metadata();
                headers.put(Metadata.Key.of(AuthConstant.AUTH_HEADER, Metadata.ASCII_STRING_MARSHALLER),
                        String.format("%s %s", AuthConstant.AUTH_TOKEN_TYPE, subject));
                metadataApplier.apply(headers);
            } catch (Throwable e) {
                metadataApplier.fail(Status.UNAUTHENTICATED.withCause(e));
            }
        });
    }

    @Override
    public void thisUsesUnstableApi() {

    }
}

这里就是将请求的 JWT 令牌放入到请求头中即可。

实现hello功能的调用:

public class LoginClient2 {
    public static void main(String[] args) throws InterruptedException {
        ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 50790)
                .usePlaintext()
                .build();
        LoginServiceGrpc.LoginServiceStub stub = LoginServiceGrpc.newStub(channel);
        sayHello(channel);
    }

    private static void sayHello(ManagedChannel channel) throws InterruptedException {
        CountDownLatch countDownLatch = new CountDownLatch(1);
        HelloServiceGrpc.HelloServiceStub helloServiceStub = HelloServiceGrpc.newStub(channel);
        helloServiceStub
                .withCallCredentials(new JwtCredential("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c3RjX2NvZGVtYWtlciJ9.S6VmqExhFWCKpj-KDE3z0GvpqMEBIBTMWVxr-KaehL8"))
                .sayHello(StringValue.newBuilder().setValue("shb").build(), new StreamObserver<StringValue>() {
                    @Override
                    public void onNext(StringValue stringValue) {
                        System.out.println("stringValue.getValue() = " + stringValue.getValue());
                    }

                    @Override
                    public void onError(Throwable throwable) {
                        System.out.println("throwable.getMessage() = " + throwable.getMessage());
                    }

                    @Override
                    public void onCompleted() {
                        countDownLatch.countDown();
                    }
                });
        countDownLatch.await();
    }
}

使用这个客户端向服务器发送一个带有JWT令牌的请求,这里的令牌就是前面使用LoginClient时从服务器获取到的令牌。

grpc-jwt-client 至此开发完毕。

2.4 功能测试

调用登录服务

使用获得的令牌调用hello服务

测试成功!

至此,我们实现了在gRPC中使用JWT完成身份校验的项目。

三、总结

网络程序设计是一门难得的好课。在这门课程中,我不仅学习到了许多新的、有趣的知识,如websocket、epoll和grpc等,而且还开拓了我的眼界,让我看到了计算机科学的无限可能。

在课程的最后阶段,我完成了两个关于grpc的学习项目,通过这些项目,我对grpc有了更深入的理解和应用,这对我来说是非常宝贵的经验。总的来说,网络程序设计这门课使我受益匪浅,期待将来能有更多这样的学习机会,感谢孟宁老师的教育和指导!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值