有状态服务,即服务端需要记录每次会话的客户端信息,从而识别客户端身份,根据用户身份进行请求的处理,典型的设计如 Tomcat 中的 Session。例如登录:用户登录后,我们把用户的信息保存在服务端 session 中,并且给用户一个 cookie 值,记录对应的 session,然后下次请求,用户携带 cookie 值来(这一步有浏览器自动完成),我们就能识别到对应 session,从而找到用户的信息。这种方式目前来看最方便,但是也有一些缺陷,如下:
微服务集群中的每个服务,对外提供的都使用 RESTful 风格的接口。而 RESTful 风格的一个最重要的规范就是:服务的无状态性,即:
那么这种无状态性有哪些好处呢?
无状态登录的流程:
JWT,全称是 Json Web Token, 是一种 JSON 风格的轻量级的授权和身份认证规范,可实现无状态、分布式的 Web 应用授权:
JWT 作为一种规范,并没有和某一种语言绑定在一起,常用的 Java 实现是 GitHub 上的开源项目 jjwt,地址如下:https://github.com/jwtk/jjwt
JWT 包含三部分数据:
Header:头部,通常头部有两部分信息:
我们会对头部进行 Base64Url 编码(可解码),得到第一部分数据。
Payload:载荷,就是有效数据,在官方文档中(RFC7519),这里给了7个示例信息:
这部分也会采用 Base64Url 编码,得到第二部分数据。
生成的数据格式如下图:
注意,这里的数据通过 .
隔开成了三部分,分别对应前面提到的三部分,另外,这里数据是不换行的,图片换行只是为了展示方便而已。
流程图:
步骤翻译:
因为 JWT 签发的 token 中已经包含了用户的身份信息,并且每次请求都会携带,这样服务的就无需保存用户信息,甚至无需去数据库查询,这样就完全符合了 RESTful 的无状态规范。
说了这么多,JWT 也不是天衣无缝,由客户端维护登录状态带来的一些问题在这里依然存在,举例如下:
当然,为了解决 JWT 存在的问题,也可以将 JWT 结合 Redis 来用,服务端生成的 JWT 字符串存入到 Redis 中并设置过期时间,每次校验的时候,先看 Redis 中是否存在该 JWT 字符串,如果存在就进行后续的校验。但是这种方式有点不伦不类(又成了有状态了)。
我们来看下 gRPC 如何结合 JWT。
首先我先给大家看下我的项目结构:
├── grpc_api
│ ├── pom.xml
│ └── src
├── grpc_client
│ ├── pom.xml
│ └── src
├── grpc_server
│ ├── pom.xml
│ └── src
└── pom.xml
还是跟之前文章中的一样,三个模块,grpc_api 用来存放一些公共的代码。
grpc_server 用来放服务端的代码,我这里服务端主要提供了两个接口:
grpc_client 则是我的客户端代码。
我将 protocol buffers 和一些依赖都放在 grpc_api 模块中,因为将来我的 grpc_server 和 grpc_client 都将依赖 grpc_api。
我们来看下这里需要的依赖和插件:
io.jsonwebtoken jjwt-api 0.11.5 io.jsonwebtoken jjwt-impl 0.11.5 runtime io.jsonwebtoken jjwt-jackson 0.11.5 runtime io.grpc grpc-netty-shaded 1.52.1 io.grpc grpc-protobuf 1.52.1 io.grpc grpc-stub 1.52.1 org.apache.tomcat annotations-api 6.0.53 provided
kr.motd.maven os-maven-plugin 1.6.2 org.xolstice.maven.plugins protobuf-maven-plugin 0.6.1 com.google.protobuf:protoc:3.21.7:exe:${os.detected.classifier} grpc-java io.grpc:protoc-gen-grpc-java:1.51.0:exe:${os.detected.classifier} compile compile-custom
这里的依赖和插件松哥在本系列的第一篇文章中都已经介绍过了,唯一不同的是,这里引入了 JWT 插件,JWT 我使用了比较流行的 JJWT 这个工具。JJWT 松哥在之前的文章和视频中也都有介绍过,这里就不再啰嗦了。
先来看看我的 Protocol Buffers 文件:
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;
}
经过前面几篇文章的介绍,这里我就不多说啦,就是定义了两个服务:
定义完成之后,生成对应的代码即可。
接下来再定义一个常量类供 grpc_server 和 grcp_client 使用,如下:
public interface AuthConstant {SecretKey JWT_KEY = Keys.hmacShaKeyFor("hello_javaboy_hello_javaboy_hello_javaboy_hello_javaboy_".getBytes());Context.Key AUTH_CLIENT_ID = Context.key("clientId");String AUTH_HEADER = "Authorization";String AUTH_TOKEN_TYPE = "Bearer";
}
这里的每个常量我都给大家解释下:
如此,我们的 gRPC_api 就定义好了。
接下来我们来定义 gRPC_server。
首先来定义登录服务:
public class LoginServiceImpl extends LoginServiceGrpc.LoginServiceImplBase {@Overridepublic void login(LoginBody request, StreamObserver responseObserver) {String username = request.getUsername();String password = request.getPassword();if ("javaboy".equals(username) && "123".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();}}
}
省事起见,我这里没有连接数据库,用户名和密码固定为 javaboy 和 123。
登录成功之后,就生成一个 JWT 字符串返回。
登录失败,就返回一个 login error 字符串。
再来看我们的 HelloService 服务,如下:
public class HelloServiceImpl extends HelloServiceGrpc.HelloServiceImplBase {@Overridepublic void sayHello(StringValue request, StreamObserver 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);@Overridepublic ServerCall.Listener interceptCall(ServerCall serverCall, Metadata metadata, ServerCallHandler 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 = 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() {};}
}
这段代码逻辑应该好理解:
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 = 50051;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 服务的时候,多加了一个拦截器,换言之,登录的时候,请求是不会被这个认证拦截器拦截的。
好啦,这样我们的 grpc_server 就开发完成了。
接下来我们来看 grpc_client。
先来看登录:
public class LoginClient {public static void main(String[] args) throws InterruptedException {ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 50051).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("javaboy").setPassword("123").build(), new StreamObserver() {@Overridepublic void onNext(LoginResponse loginResponse) {System.out.println("loginResponse.getToken() = " + loginResponse.getToken());}@Overridepublic void onError(Throwable throwable) {}@Overridepublic void onCompleted() {countDownLatch.countDown();}});countDownLatch.await();}
}
这个方法直接调用就行了,看过前面几篇 gRPC 文章的话,这里都很好理解。
再来看 hello 接口的调用,这个接口调用需要携带 JWT 字符串,而携带 JWT 字符串,则需要我们构建一个 CallCredentials 对象,如下:
public class JwtCredential extends CallCredentials {private String subject;public JwtCredential(String subject) {this.subject = subject;}@Overridepublic 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));}});}@Overridepublic void thisUsesUnstableApi() {}
}
这里就是将请求的 JWT 令牌放入到请求头中即可。
最后来看看调用:
public class LoginClient {public static void main(String[] args) throws InterruptedException {ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 50051).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("eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJqYXZhYm95In0.IMMp7oh1dl_trUn7sn8qiv9GtO-COQyCGDz_Yy8VI4fIqUcRfwQddP45IoxNovxL")).sayHello(StringValue.newBuilder().setValue("wangwu").build(), new StreamObserver() {@Overridepublic void onNext(StringValue stringValue) {System.out.println("stringValue.getValue() = " + stringValue.getValue());}@Overridepublic void onError(Throwable throwable) {System.out.println("throwable.getMessage() = " + throwable.getMessage());}@Overridepublic void onCompleted() {countDownLatch.countDown();}});countDownLatch.await();}
}
这里的登录令牌就是前面调用 login 方法时获取到的令牌。
好啦,大功告成。
上面的登录与校验只是松哥给小伙伴们展示的一个具体案例而已,在此案例基础之上,我们还可以扩展出来更多写法,但是万变不离其宗,其他玩法就需要小伙伴们自行探索啦~