代码仓库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中服务端和客户端的交互存在着四种不同的通信模式:
- 一元RPC(unary RPC)
- 服务端流 RPC(server-streaming RPC)
- 客户端流 RPC(client-streaming RPC)
- 双向流 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中比较核心的配置,我们简单介绍一下:
- option java_multiple_files = true;可选字段,如果设置为 true,表示每一个 message 文件都会对应一个单独的 class 文件;否则,message 将被全部定义在 outerclass 文件里。
- option java_package = "ustc.codemaker.grpc.ddemo1"; 可选字段,用于标识生成的 java 文件的 package。如果没有指定,则使用 proto 里定义的 package,如果package 也没有指定,那就会生成在根目录下。
- option java_outer_classname = "BookServiceProto"; 可选字段,用于指定 proto 文件生成的 java 类的 outerclass 类名。outerclass是一个包含所有message对应的Java类的class文件。如果没有指定,那么outerclass的名称将默认为proto文件的驼峰式名称。
- package book;这个属性用于定义message的包名。包名的含义与平台语言无关,它只在proto文件中用于区分同名的message类型。可以把它理解为message全名的前缀,和message名称一起,可以唯一标识一个message类型。当我们在proto文件中导入其他proto文件的message时,需要加上package前缀。
- sevice
:
我们在service中定义的方法都是跨平台的。在book.proto文件中,我们定义了五个方法,并给出了相应的注释。这里的定义相当于一个接口,我们将在Java代码中实现这个接口。 - 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 中的拦截器整体上来说可以分为两大类:
- 服务端拦截器
- 客户端拦截器
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开源项目jjwt
JJWT(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 交互流程
交互流程:
- 客户端使用账户名和密码登录
- 登录后,服务器会向客户端返回token访问令牌
- 客户端使用访问令牌来访问受保护资源(如 API)
因为 JWT 签发的 token 中已经包含了用户的身份信息,并且每次请求都会携带,这样服务的就无需保存用户信息,甚至无需去数据库查询。
1.3.4 JWT 存在的问题
JWT 也存在着一些问题和弊端:
- 续签问题,传统的 cookie+session 的方案天然的支持续签,但是 JWT 由于服务端不保存用户状态,因此很难完美解决续签问题。
- 注销问题,由于服务端不再保存用户信息,所以一般可以通过修改 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则是客户端。
服务端主要提供了两个接口:
- 登录接口,登录成功之后返回 JWT 令牌。
- 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";
}
各个常量的含义:
- JWT_KEY:一个SecretKey对象,该对象用于JWT的签名和验证。这里使用了HMAC SHA256算法,密钥是"ustc_sse_codemaker_5079_VJKNKLsada_"
- AUTH_CLIENT_ID:用于存储和获取客户端ID,即客户端发送来的请求携带了 JWT令牌,通过 JWT 确认了用户身份,就存在这个变量中。
- AUTH_HEADER:该常量表示HTTP请求头中的"Authorization"字段,这个字段通常用于携带身份验证信息。
- 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>() {
};
}
}
拦截器的代码逻辑是:
- 首先从 Metadata 中提取出当前请求所携带的 JWT 字符串(相当于从请求头中提取出来),放入Authorization字段中,然后,根据Authorization字段值的不同情况,进行不同的处理。
- 如果Authorization字段为 null 或者这个值不是以指定字符 Bearer 开始的,说明这个令牌是一个非法令牌,设置对应的响应 status 。
- 否则,尝试解析Authorization字段的值(即JWT)。如果解析失败,则将状态设置为UNAUTHENTICATED,并添加异常信息。如果解析成功,就会获取到一个 Jws 对象,从这个对象中可以提取出来用户名,并存入到 Context 中,,将来在 HelloServiceImpl 中就可以获取到这里的用户名了。
- 最后,登录成功的话,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有了更深入的理解和应用,这对我来说是非常宝贵的经验。总的来说,网络程序设计这门课使我受益匪浅,期待将来能有更多这样的学习机会,感谢孟宁老师的教育和指导!