感兴趣的小伙伴可以支持一下点个 Star。
项目地址:https://gitcode.net/qq_42875345/grpcdemo
最近感到有点子迷茫,天天写业务代码有点麻木,趁着有点空闲时间去了解了下 Grpc 这个框架,一方面是听说他很火,支持多种语言。另一方面也是为了将来可能需要用到他,未雨绸缪一下,当然了本文只是基于使用上来带大家入门 Grpc,应付基本的日常开发足够了,后续有时间给大家分析一波源码,帮助大家更好的理解 net.devh.boot.grpc 这个包里面关于自动装配、Grpc服务是如何注册、@GrpcService以及内置注解是如何生效的。
Grpc 是基于 Protobuf 序列化传输的,为啥用 Protobuf 那就是传输效率高,Grpc 的基础代码是根据我们编写的 Protobuf 文件,通过 Protobuf 插件逆向生成的,有点类似于 Mybatis 的逆向生成,所以在学习 Grpc 前,我们要先了解下 Protobuf 的基础语法。
就着本文使用到的 proto 文件来介绍吧,如下几个参数类似于 java 中的 import、papackage 这些关键字,对应的作用已用注释说明。
//是否生成多个文件
option java_multiple_files = false;
//生成的代码放在所指定的包下面
option java_package = "com.zzh.grpcapi.rpc";
//生成文件名称
option java_outer_classname = "UserServiceProto";
当我们通过 Protobuf 插件编译完成后,项目对应的 com.zzh.grpcapi.rpc 目录下就会出现如下图的这些文件。我们日后的开发用的就是这些逆向生成的代码。
但是生成文件到指定目录需要一个前提就是我们做过相关的配置,也就是引入我们的 Protobuf 插件。
在 bulid 下面的 plugins 标签中添加一个 如下的一个 plugin 节点即可,plugin 代码在文章末尾的附录里有。
插件代码一般都是拿来即用的,唯一需要大家做出一点改动的地方就是如下这俩个节点,outputDirectory 改成你项目中,对应的生成文件需要放的位置,clearOutputDirectory 节点用 false 就行,开发中类中的方法会出现增增减减的情况,用 false 表示对应的逆向文件只更新我们 proto 文件中更新的内容,其他内容不变。而用 true 表示,先删除目录下的所有文件,然后重新生成我们的代码。
<!--指定生成文件目录位置-->
<outputDirectory>${basedir}/src/main/java</outputDirectory>
<!--false:追加,true:清除指定目录下的文件然后覆盖-->
<clearOutputDirectory>false</clearOutputDirectory>
plugin 配置都加好了后 ,依次点击 protobuf: compile , protobuf: compile-custom 即可。(我们的 proto 文件夹必须是在 main 文件下面,不然 proto 文件找不到)
除了需要配置一下 Grpc 服务的端口号,其他配置均为 Nacos 的大众配置,为什么 Grpc 需要额外的配置一下端口号呢?原因也很简单 Grpc 是基于 Netty 的二次开发,不管是 Grpc 还是 Netty 最终实现远程通信都离不开 Scoket 这个东西,而 server port 指定的是我们 tomcat 的端口号,grpc server port 指定的则是 Scoket 的端口号。这俩个端口号是俩码事(后续推出一遍源码博客帮助大家了解吧~)
server:
port: 8089
grpc:
server:
port: 8090
spring:
application:
name: grpcservice
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
config:
server-addr: 127.0.0.1:8848
配置好了后记得引入我们的 api 模块,加入到 dependencies 中即可。
<dependency>
<groupId>com.zzhgroupId>
<artifactId>grpcapiartifactId>
<version>0.0.1-SNAPSHOTversion>
<scope>compilescope>
dependency>
我们实现 UserServiceGrpc 中的以 Base 结尾的那个类即可,这个类也是大家业务开发中需要实现的类,然后客户端调用的也是我们实现类中的方法逻辑。指的注意的是:Grpc 的返回数据、异常返回和我们 Java 中接口的返回有点不太一样。
这些代码都是固定的,为了加深理解,读者自行去测试看效果将会事半功倍。本文只阐述最基本的代码通用模版。常用的开发中也就是这些个东西了。最后我们用 @GrpcService 注解标注此类为 Grpc 服务类即可。
里面就是一些 Nacos 注册的配置还有 Grpc 探测服务的配置,值得一提的是,如项目整合了 Nacos,那么 address: static://127.0.0.1:8090 下文配置中的这一行代码将无需配置,原因很简单,客户端和服务端注册在同一个 Nacos 里面,当客户端需要用到服务端时,根据服务名称寻找即可找到对应的 Grpc 服务,但是当项目没整合 Nacos 时,则需要我们手动配置 Grpc 服务地址。
server:
port: 8088
grpc:
client:
grpc-service-test:
#address: static://127.0.0.1:8090 #整合nacos后无需设置,grrpc服务端地址,根据服务名寻找对应的服务
negotiation-type: plaintext
enableKeepAlive: true
keepAliveWithoutCalls: true
spring:
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
config:
server-addr: 127.0.0.1:8848
application:
name: grpcclient
注意我们的 grpc-service-test 不是乱配的,需要和 Nacos 中的服务名对应。
客户端都配置好了之后,我们利用 @GrpcClient 注解,注入我们的 Grpc 服务端实现类,即可在客户端完成调用。日常开发用下图中的阻塞客户端即可,意思就是这次 Grpc 远程调用必须等待服务端处理好请求,客户端收到请求后接着处理客户端后续的逻辑(日常开发最常用的也是这种模式),为阻塞模式。那有的读者就会问了,有没有非阻塞的调用呀,有的请看下文。
大致代码和阻塞式差不多,但是区别在于 Grpc 的调用是靠 UserServiceStub 完成的,也就是非阻塞客户端(使用场景:聊天室、股票代码、实时天气),非阻塞模式的调用,在不阻塞客户端逻辑的情况下,先出给接口响应,后续的 Grpc 推送过来的消息采用监听的模式接收,整个过程是异步的。因此 Grpc 贴心的为我们提供了 onNext、onError、onCompleted 这三大方法分别代表如下意思。
当然非阻塞式方法需要对应的 proto 文件支持,需要哪边(客户端、服务端)具备主动推送消息的能力就在对应的参数(请求参数、返回参数)前面添加 stream 。
rpc loginStream (LoginVo) returns (stream LoginDto) {}
客户端改造好了后,服务端用 for 循环每隔 2 秒,依次调用 onNext 方法即可,客户端的 onNext 监听方法里面也会每隔 2 秒收到来自 Grpc 服务端的消息。
演示效果:每隔 2 秒收到来自服务端推送过来的消息
Grpc 还有一个功能就是可以实现,客户端与服务端互推消息。 有点类似于 WebScoket 。使用起来也很简单,客户端直接调用双向流的方法,里面还是那三个监听方法,监听 Grpc 服务端推送过来的消息,同时客户端利用调用方法返回的 StreamObserver 可以去主动推送给 Grpc 服务端。完成了客户端的主动推送。
而服务端也是利用 StreamObserver 完成去监听客户端发来的消息和推送给客户端消息。下图的代码我是客户端发过来一条,服务端就处理一条推送回给客户端的,各位可以根据自己的业务逻辑来。
到此 Grpc 几种常用的模式就介绍完了,接下来在说一下 Grpc 中的拦截器吧,这个大家也有可能会用到。
有的时候为了保证 Grpc 服务的安全,一般都需要做认证才能进行调用,利用客户端拦截器我们可以对 Grpc 调用进行日志记录,也可以对所有的 Grpc 调用添加统一的请求头,做 Jwt 校验啥的。我们只需实现 ClientInterceptor 接口,然后在所在类上面加上 @GrpcGlobalClientInterceptor 注解即可。标注这个是全局客户端拦截器。下面的代码我会在所有 Grpc 请求调用前为当期请求设置一个 jwt 请求头,这个 jwt 是服务端给我们颁发的,如果客户端没有检测到 jwt 的存在会携带 ak、sk 参数主动去请求服务端的 jwt 获取接口,完成后存储于客户端,可能有人会说了,jwt 不是无状态的吗?你这样不就成了有状态的了。答:jwt 存储在客户端,相当于我们的前端页面,对于服务端依旧是无状态的。
/**
* 全局设置 jwt 请求头
*/
@Slf4j
@GrpcGlobalClientInterceptor
public class ClientInterceptor implements io.grpc.ClientInterceptor {
@Value("${grpc.auth.ak}")
private String ak;
@Value("${grpc.auth.sk}")
private String sk;
@Value("${grpc.auth.host}")
private String host;
@Value("${grpc.auth.port}")
private int port;
@Value("${grpc.auth.name}")
private String serviceName;
@Autowired
private RedisTemplate redisTemplate;
@Override
public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(MethodDescriptor<ReqT, RespT> method, CallOptions callOptions, Channel next) {
CallOptions myCallOptions = callOptions
//.withDeadlineAfter(2, TimeUnit.SECONDS) //设置超时
.withCallCredentials(new CallCredentials() {
@Override
public void applyRequestMetadata(RequestInfo requestInfo, Executor appExecutor, MetadataApplier applier) {
Metadata metadata = new Metadata();
Object token = redisTemplate.opsForValue().get(serviceName);
String jwt = "";
if (null == token) {
//根据服务名进行寻找服务,利用 ak、sk 自动登录,将 token 存储于 redis
ManagedChannel managedChannel = ManagedChannelBuilder.forTarget(serviceName).usePlaintext().build();
UserServiceGrpc.UserServiceBlockingStub userServiceBlockingStub = UserServiceGrpc.newBlockingStub(managedChannel);
UserServiceProto.LoginDto login = userServiceBlockingStub
.login(UserServiceProto.LoginVo.newBuilder()
.setName(ak)
.setPassword(sk)
.build());
jwt = login.getToken();
redisTemplate.opsForValue().set(serviceName, jwt);
} else {
jwt = String.valueOf(token);
}
//设置 jwt 请求头
metadata.put(Metadata.Key.of("Authorization", Metadata.ASCII_STRING_MARSHALLER), "Bearer " + jwt);
applier.apply(metadata);
}
@Override
public void thisUsesUnstableApi() {
}
});
return new ForwardingClientCall.SimpleForwardingClientCall<ReqT, RespT>(next.newCall(method, myCallOptions)) {
@Override
public void sendMessage(ReqT message) {
//日志记录
log.info("ClientInterceptor#interceptCall#SimpleForwardingClientCall#sendMessage## request method: {} , param: {} ", method.getFullMethodName(), message.toString());
super.sendMessage(message);
}
};
}
}
但是上面的代码有一个问题,如果当前项目需要调用多个不同的 Grpc 服务,且每个 Grpc 服务都有各自的认证方式的话,那么这种全局添加请求头这种方式就不太适合了,那么这个时候我们根据调用的 Grpc 服务名(通过 requestInfo.getAuthority()获取),去请求对应的认证接口,然后把拿到的 token 放到请求头或者是哪,这个属于业务问题了,读者根据自己的需求来即可。
大致逻辑就是如果没带 jwt 请求头提示客户端没权限访问,带了 jwt 请求头去校验 jwt 是否合法,合法了放行。放行和阻塞的代码都是固定的,大家根据自己需要设置对应的 Status、还有上下文参数即可。
@GrpcGlobalServerInterceptor
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 ("UserService/login".equals(serverCall.getMethodDescriptor().getFullMethodName()))
//放行
return Contexts.interceptCall(Context.current(), serverCall, metadata, serverCallHandler);
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) {
//设置全局上下文属性,下游通过 AuthConstant.AUTH_CLIENT_ID.get(Context.current()) 获取设置的值
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>() {
};
}
}
public interface AuthConstant {
SecretKey JWT_KEY = Keys.hmacShaKeyFor("zzhhaoshuaizzhhaoshuaizzhhaoshuaizzhhaoshuai".getBytes());
Context.Key<String> AUTH_CLIENT_ID = Context.key("userId");
String AUTH_HEADER = "Authorization";
String AUTH_TOKEN_TYPE = "Bearer";
}
到此你已经可以利用 Grpc 进行日常业务开发了,剩下的就是搬砖操作了~~~~~~~~~~~~
本文使用到的 Grpc 插件
<extensions>
<extension>
<groupId>kr.motd.mavengroupId>
<artifactId>os-maven-pluginartifactId>
<version>1.6.2version>
extension>
extensions>
<plugins>
<plugin>
<groupId>org.xolstice.maven.pluginsgroupId>
<artifactId>protobuf-maven-pluginartifactId>
<version>0.6.1version>
<configuration>
<protocArtifact>com.google.protobuf:protoc:${protoc.version}:exe:${os.detected.classifier}protocArtifact>
<pluginId>grpc-javapluginId>
<pluginArtifact>io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier}pluginArtifact>
<outputDirectory>${basedir}/src/main/javaoutputDirectory>
<clearOutputDirectory>falseclearOutputDirectory>
configuration>
<executions>
<execution>
<goals>
<goal>compilegoal>
<goal>compile-customgoal>
goals>
execution>
executions>
plugin>
本文用到的 Nacos、Grpc、Jwt Maven 依赖
<properties>
<project.build.sourceEncoding>UTF-8project.build.sourceEncoding>
<maven.compiler.source>1.8maven.compiler.source>
<maven.compiler.target>1.8maven.compiler.target>
<grpc.version>1.44.0grpc.version>
<protobuf.version>3.19.2protobuf.version>
<protoc.version>3.19.2protoc.version>
<gson.version>2.8.9gson.version>
<spring-boot.version>2.3.9.RELEASEspring-boot.version>
properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-dependenciesartifactId>
<version>${spring-boot.version}version>
<type>pomtype>
<scope>importscope>
dependency>
<dependency>
<groupId>io.grpcgroupId>
<artifactId>grpc-bomartifactId>
<version>${grpc.version}version>
<type>pomtype>
<scope>importscope>
dependency>
<dependency>
<groupId>com.google.protobufgroupId>
<artifactId>protobuf-java-utilartifactId>
<version>${protobuf.version}version>
dependency>
<dependency>
<groupId>com.google.code.gsongroupId>
<artifactId>gsonartifactId>
<version>${gson.version}version>
dependency>
dependencies>
dependencyManagement>
<dependencies>
<dependency>
<groupId>com.alibaba.cloudgroupId>
<artifactId>spring-cloud-alibaba-nacos-configartifactId>
<version>2.2.0.RELEASEversion>
dependency>
<dependency>
<groupId>com.alibaba.cloudgroupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discoveryartifactId>
<version>2.2.5.RELEASEversion>
dependency>
<dependency>
<groupId>io.jsonwebtokengroupId>
<artifactId>jjwt-apiartifactId>
<version>0.11.5version>
dependency>
<dependency>
<groupId>io.jsonwebtokengroupId>
<artifactId>jjwt-implartifactId>
<version>0.11.5version>
<scope>runtimescope>
dependency>
<dependency>
<groupId>io.jsonwebtokengroupId>
<artifactId>jjwt-jacksonartifactId>
<version>0.11.5version>
<scope>runtimescope>
dependency>
<dependency>
<groupId>io.grpcgroupId>
<artifactId>grpc-netty-shadedartifactId>
<scope>runtimescope>
dependency>
<dependency>
<groupId>io.grpcgroupId>
<artifactId>grpc-protobufartifactId>
dependency>
<dependency>
<groupId>io.grpcgroupId>
<artifactId>grpc-stubartifactId>
dependency>
<dependency>
<groupId>com.google.protobufgroupId>
<artifactId>protobuf-java-utilartifactId>
dependency>
<dependency>
<groupId>com.google.code.gsongroupId>
<artifactId>gsonartifactId>
dependency>
<dependency>
<groupId>net.devhgroupId>
<artifactId>grpc-spring-boot-starterartifactId>
<version>2.14.0.RELEASEversion>
dependency>
<dependency>
<groupId>org.projectlombokgroupId>
<artifactId>lombokartifactId>
<version>1.18.8version>
<scope>providedscope>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-testartifactId>
<scope>testscope>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>com.zzhgroupId>
<artifactId>grpcapiartifactId>
<version>0.0.1-SNAPSHOTversion>
<scope>compilescope>
dependency>
dependencies>
本文测试用到的 proto 文件
syntax = "proto3";
//是否生成多个文件
option java_multiple_files = false;
//生成的代码放在所指定的包下面
option java_package = "com.zzh.grpcapi.rpc";
//生成文件名称
option java_outer_classname = "UserServiceProto";
service UserService {
rpc login (LoginVo) returns (LoginDto) {}
rpc loginStream (LoginVo) returns (stream LoginDto) {}
rpc loginDoubleStream (stream LoginVo) returns (stream LoginDto) {}
}
message LoginVo {
string name = 1;
string password = 2;
}
message LoginDto {
string name = 1;
repeated string ids = 3;
string password = 2;
string token=4;
}