06.google grpc

一、介绍:

gRPC (gRPC Remote Procedure Calls[1]) 是Google发起的一个开源远程过程调用 (Remote procedure call) 系统。该系统基于 HTTP/2 协议传输,使用Protocol Buffers 作为接口描述语言

支持语言:Java, C++, Python, Objective-C, C#, a lite-runtime (Android Java), Ruby, and JavaScript、Go、Node.js

grpc底层传输层是使用netty实现的。

二、相关资料

  1. gRpc文档阅读:https://www.grpc.io/docs/guides/
  2. gRpc核心概念:https://www.grpc.io/docs/guides/concepts/
  3. gRpc安全认证:https://www.grpc.io/docs/guides/auth/
  4. gRpc的quickstart:https://www.grpc.io/docs/quickstart/
  5. gRpc使用手册(gRPC Basics - Java):https://www.grpc.io/docs/tutorials/basic/java/
  6. gRpc在github地址:https://github.com/grpc针对java语言的源码及手册:https://github.com/grpc/grpc-java

三、特殊性说明

  • 虽然可以在使用grpc时使用proto2,grpc也默认使用proto2,但是推荐使用proto3,以在GRPC中可以使用全部特性的支持
  • 避免客户端与服务端使用的proto版本不一致造成一些兼容性问题

四、GRPC核心概念

以下内容来自:https://www.grpc.io/docs/guides/concepts/

1、可以使用proto2中message,同时增加了service

2、提供了4种类型的服务方法

  • 客户端发送的是普通请求,服务端返回的是普通响应
  • 客户端发送的是普通请求,服务端返回的是stream(流:可以理解未一些列对象),客户端会读取这个stream
  • 客户端发送的是stream,服务端返回的是普通响应
  • 客户端发送的是stream,服务端返回的是stream

什么时候用单向流(服务端向客户端/客户端向服务端)、什么时候用双向流、什么时候用流,什么时候不用流,是取决于业务逻辑的,实际上流只是分批多次将数据进行发送或者接收。将数据一次性发送还是以流式一个个发送,最终效果是相同的,只是看什么场景下需要什么形式而已

第四种:双向流方式,客户端的stream与服务端的stream互不影响,彼此独立。

stream式数据理解:编码层面上可以理解为一个集合或者迭代器

3、在protoc基础上又多出一个plugin

不仅能生成message文件,还能生成客户端与服务端相关的代码

插件的意义:因为grpc是基于protobuf的,而protobuf'提供的protoc工具只能生成特定语言使用的消息代码,而grpc除了此部分代码外,还有包括服务器端、客户端通信代码,所以grpc提供了此插件生成message、客户端、服务端相关代码。

4、grpc提供了3种传输层实现

  • 基于netty的传输层:主要的传输层实现,他是基于netty的,包括客户端、服务端
  • 基于okhttp的传输层:主要是面向Android开发使用,轻量级传输层实现,只有客户端
  • InProcess传输层:主要是面向测试环境,客户端端与服务端在一个进程。

5、grpc规范要求

  • rpc方法的参数与返回值类型都是IDL中定义的message类型,而不能是string、int32等变量类型,这一点跟thrift不同,即使只有一个属性,也得定义成message
  • grpc生成得即使有返回值得方法,也是void类型的返回值,方法的返回值通过调用rpc方法参数StreamObserver对象的onNext方法实现向下一个处理器流转(发送给对端)

6、grpc即提供了rpc的同步调用也提供了异步调用

  • 通过创建不同的Stub体现:
    • 同步:newBlockingStub
    • 异步:newStub
    • newFutureStub??
  • 对于客户端请求参数是stream类型,客户端调用rpc时必须使用异步的stub进行调用,同步的blockingstub是无法使用的

 

五、GRPC的使用

1、运行官网示例1

本示例仅是执行官方可运行的示例,并不包括直接编写及编译IDL文件、编写业务代码等。

以下内容来自:https://www.grpc.io/docs/quickstart/

1)Download the example

git clone -b v1.26.0 https://github.com/grpc/grpc-java

2)通过gradlew编译

cd grpc-java/examples
./gradlew installDist

首次编译会下载gradlew相关依赖

3)启动服务端

./build/install/examples/bin/hello-world-server

输出:

4)启动客户端

./build/install/examples/bin/hello-world-client

2、官网grpc手册-java

以下内容来自:https://www.grpc.io/docs/tutorials/basic/java/

本示例包括如下部分,可以用于系统的学习grpc整个使用流程:

1)定义IDL

2)编译生成源码

3)编写相关业务代码

4)启动服务端、客户端

3、实践:使用grpc

以下内容基本来自:https://github.com/grpc/grpc-java

开发grpc应用(使用java)流程如下:

3.1 添加依赖

maven:

<dependency>
  <groupId>io.grpc</groupId>
  <artifactId>grpc-netty-shaded</artifactId>
  <version>1.27.1</version>
</dependency>
<dependency>
  <groupId>io.grpc</groupId>
  <artifactId>grpc-protobuf</artifactId>
  <version>1.27.1</version>
</dependency>
<dependency>
  <groupId>io.grpc</groupId>
  <artifactId>grpc-stub</artifactId>
  <version>1.27.1</version>
</dependency>

gradle:

implementation 'io.grpc:grpc-netty-shaded:1.27.1'
implementation 'io.grpc:grpc-protobuf:1.27.1'
implementation 'io.grpc:grpc-stub:1.27.1'

3.2 配置grpc编译插件

插件的意义:因为grpc是基于protobuf的,而protobuf'提供的protoc工具只能生成特定语言使用的消息代码,而grpc除了此部分代码外,还有包括服务器端、客户端通信代码,所以grpc提供了此插件生成message、客户端、服务端相关代码。

grpc提供了针对maven与gradle的插件集成方式

maven:如果使用maven作为构建工具,可以使用 protobuf-maven-plugin 插件,如果IDE是eclipse或者netbeans,还应该看看对应的IDE文档:https://github.com/trustin/os-maven-plugin#issues-with-eclipse-m2e-or-other-ides

<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.11.0:exe:${os.detected.classifier}</protocArtifact>
        <pluginId>grpc-java</pluginId>
        <pluginArtifact>io.grpc:protoc-gen-grpc-java:1.27.1:exe:${os.detected.classifier}</pluginArtifact>
      </configuration>
      <executions>
        <execution>
          <goals>
            <goal>compile</goal>
            <goal>compile-custom</goal>
          </goals>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>

其中os-maven-plugin用于识别你的操作系统的版本,提供${os.detected.classifier}参数供后面使用,没有其他的作用

gradle:如果使用的gradle可以使用protobuf-gradle-plugin插件:配置如下:

plugins {
    id 'com.google.protobuf' version '0.8.8'
}

protobuf {
  protoc {
    artifact = "com.google.protobuf:protoc:3.11.0"
  }
  plugins {
    grpc {
      artifact = 'io.grpc:protoc-gen-grpc-java:1.27.1'
    }
  }
  generateProtoTasks {
    all()*.plugins {
      grpc {}
    }
  }
}

本文以maven为例,配置好后,会出现下图的一些task:

3.3 编写proto文件

因为grpc也是基于protobuf的,所以需要创建的IDL为.proto

IDL放置在src/main/proto and src/test/proto 下可以被grpc插件自动识别

syntax = "proto3";

package com.mzj.netty.ssy._08_grpc;

option java_package = "com.mzj.netty.ssy._08_grpc";
option java_outer_classname = "StudentProto";
option java_multiple_files = true;

service StudentService{
    //gRpc支持的四种调用形式示例:
    rpc GetRealNameByUsername(MyRequest) returns (MyResponse){}//种类1:普通输入参数与返回值
    rpc GetStudentsByAge(StudentRequest) returns (stream StudentResponse){}//种类2:服务端rpc方法返回值是stream形式,参数是普通对象
    rpc GetStudentsWrapperByAges(stream StudentRequest) returns (StudentResponseList){}//种类3:客户端输入参数是stream形式,返回是一个普通对象
    rpc BiTalk(stream StreamRequest) returns (stream StreamResponse){}//种类4:双向的流式的数据传递(客户端发送请求/服务端返回结果都是流式)

    //从IDL的定义上,四种调用形式区别体现在rpc定义时方法参数、返回值的message前面是否有stream关键字
    //rpc方法的参数与返回值类型都是IDL中定义的message类型,而不能是string、int32等变量类型,这一点跟thrift不同,即使只有一个属性,也得定义成message
}

message MyRequest{
    string username = 1;
}

message MyResponse{
    string realname = 2;
}

message StudentRequest{
    int32 age = 1;
}

message StudentResponse{
    string name = 1;
    int32 age = 2;
    string city = 3;
}

message StudentResponseList{
    //protobuf中集合用repeated表示
    repeated StudentResponse studentResponse = 1;//repeated表示集合类型,这里表示服务器端向客户端返回的是一个集合类型,集合中元素是StudentResponse
}

message StreamRequest{
    string request_info = 1;
}

message StreamResponse{
    string response_info = 1;
}

其中包括了4种类型的服务方法。

3.4 编译IDL文件

1、运行插件(protobuf:compile):

生成一堆java文件和一个.exe文件:message相关代码

可以看到生成的exe文件名中有3.11.0,这个是在pom.xml配置protobuf-maven-plugin插件的时候在<configuration></configuration>中配置的。后缀windows-x86_64是当前操作系统版本标识,是由os-maven-plugin插件自动获得的。

2、运行第二个插件(protobuf:compile-custom):

会生成一个exe文件和一个java文件:grpc操作相关代码

3、将这两次生成的java文件拷贝到src对应源码路径下。

3.5 编写服务实现类、服务端、客户端代码

1、服务实现类

1)继承生成的grpc类中public static abstract class类,并Override其中我们定义的、默认实现的业务方法(生成代码中方法的实现为占位实现)

方法参数说明:通常参数包括两部分:

  • 客户端发送的请求对象
  • 用于向客户端返回结果的对象
package com.mzj.netty.ssy._08_grpc.mycode;

import com.mzj.netty.ssy._08_grpc.*;
import io.grpc.stub.StreamObserver;

import java.util.UUID;

public class StudentServiceImpl extends StudentServiceGrpc.StudentServiceImplBase {

    /**
     * 方式1:输入参数与返回值都是普通的java对象
     *
     * grpc生成得即使有返回值得方法,也是void,而方法得返回值通过调用StreamObserver对象的onNext方法实现向下一个处理器流转
     *
     * rpc业务方法
     * @param request:客户端发送的请求对象
     * @param responseObserver:用于向客户端返回结果的对象
     */
    @Override
    public void getRealNameByUsername(MyRequest request, StreamObserver<MyResponse> responseObserver) {
        System.out.println("[服务端] 接收到客户端信息:"+request.getUsername());

        //返回客户端MyResponse对象
        responseObserver.onNext(MyResponse.newBuilder().setRealname("mazhongjia").build());
        //标识这次方法调用结束
        responseObserver.onCompleted();
    }

    /**
     * 方式2:输入参数是普通的java对象,返回值是流类型
     */
    @Override
    public void getStudentsByAge(StudentRequest request, StreamObserver<StudentResponse> responseObserver) {
        System.out.println("接收到客户端信息:" + request.getAge());

        responseObserver.onNext(StudentResponse.newBuilder().setName("马仲佳").setAge(20).setCity("北京").build());
        responseObserver.onNext(StudentResponse.newBuilder().setName("呼娜").setAge(21).setCity("北京").build());
        responseObserver.onNext(StudentResponse.newBuilder().setName("maxiaotang").setAge(22).setCity("北京").build());
        responseObserver.onNext(StudentResponse.newBuilder().setName("mawenge").setAge(23).setCity("北京").build());
        responseObserver.onNext(StudentResponse.newBuilder().setName("lvying").setAge(20).setCity("北京").build());

        responseObserver.onCompleted();
    }


    /**
     * 方式3:输入参数是流类型,返回值是普通的java对象
     *
     * 此种方式,rpc方法参数仅有一个StreamObserver用来返回时调用,而请求参数是作为rpc方法的返回值存在(比较怪异)
     *
     * 实现此rpc方法时,需要返回一个StreamObserver<T>的实现,通过实现其中onNext、onError、onCompleted三个回调方法完成接收请求-->处理请求--->返回响应的三个流程
     */
    @Override
    public StreamObserver<StudentRequest> getStudentsWrapperByAges(StreamObserver<StudentResponseList> responseObserver){

        return new StreamObserver<StudentRequest>() {

            /**
             * 请求到来时,会调用这里的onNext方法,因为是流式请求参数,所以会调用多次
             * @param value
             */
            @Override
            public void onNext(StudentRequest value) {
                //客户端请求到来了一次,会调用onNext方法一次,发送输入参数为StudentRequest类型,整体一次请求为多个StudentRequest对象组成的流
                System.out.println("onNext:"+value.getAge());
            }

            /**
             * 出错时调用onError
             * @param t
             */
            @Override
            public void onError(Throwable t) {
                System.out.println(t.getMessage());
            }

            /**
             * 客户端流式数据全部发送完毕后调用onCompleted,此方法中需要将请求处理结果(响应)返回给客户端
             */
            @Override
            public void onCompleted() {
                //客户端以流的方式发送完一次请求所有StudentRequest对象后,会调用此方法表示本次请求发送完毕
                //服务端被调用此方法后,需要将处理结果返回给客户端,此时返回的是一次普通java对象返回结果
                //模拟数据库查询后封装多个StudentResponse
                StudentResponse studentResponse = StudentResponse.newBuilder().setName("mazhongjia").setAge(34).setCity("北京").build();
                StudentResponse studentResponse2 = StudentResponse.newBuilder().setName("huna").setAge(31).setCity("北京").build();

                //本服务接口返回数据类型为StudentResponseList(见IDL定义)
                StudentResponseList studentResponseList = StudentResponseList.newBuilder().addStudentResponse(studentResponse).addStudentResponse(studentResponse2).build();

                //服务端将结果返回给客户端
                responseObserver.onNext(studentResponseList);
                responseObserver.onCompleted();

            }
        };

        //mzj分析:
        //客户端与服务端分别持有对方的StreamObserver对象:服务端持有调用getStudentsWrapperByAges的方法中参数StreamObserver对象,客户端持有getStudentsWrapperByAges方法返回的new的StreamObserver对象
        //建立好这种对象持有关系后,客户端通过调用自己持有StreamObserver的onNext方法将数据发送服务端
        //发送完所有数据后,客户端调用自己持有StreamObserver的onConpleted方法通知客户端
        //服务端发送给客户端返回结果时,操作的是自己的StreamObserver对象(通过getStudentsWrapperByAges方法参数拿到的)
    }

    /**
     * 方式4:输入参数是流类型,返回值也是流类型
     *
     * 请求参数泛型StreamRequest与返回结果泛型StreamRequest都是IDL中自定义的类型
     *
     * 说明:双向的流式数据传递原理:在完全不同的两个流中传递,两个流是互相独立的,一般情况从逻辑上来说,一方流关闭,另一方开着也没啥用,所以逻辑上来说也应该关闭
     *
     * @param responseObserver
     * @return
     */
    @Override
    public StreamObserver<StreamRequest> biTalk(StreamObserver<StreamResponse> responseObserver) {
        return new StreamObserver<StreamRequest>() {
            @Override
            public void onNext(StreamRequest value) {
                //打印客户端发送来的数据
                System.out.println("###"+value.getRequestInfo());
                //每收到客户端发送一条数据,也向客户端回复一条数据
                //这里的实现是在在收到客户端数据时也向客户端返回一条数据
                responseObserver.onNext(StreamResponse.newBuilder().setResponseInfo(UUID.randomUUID().toString()).build());
            }

            @Override
            public void onError(Throwable t) {
                System.out.println(t.getMessage());
            }

            @Override
            public void onCompleted() {
                //这里实现是:客户端发送完毕通知服务端时,服务端也发送完毕通知客户端
                //虽然客户端与服务端如果都是流式传递时,双方是在不同流上
                //但是从逻辑上来说,一方流关闭,另一方开着也没啥用,所以逻辑上来说也应该关闭
                responseObserver.onCompleted();
            }
        };
    }


}

2、服务端代码

package com.mzj.netty.ssy._08_grpc.mycode;

import io.grpc.Server;
import io.grpc.ServerBuilder;

import java.io.IOException;

/**
 * @Auther: mazhongjia
 * @Description:
 */
public class GrpcServer {

    private Server server;

    /**
     * 服务端启动
     * @throws IOException
     */
    private void start() throws IOException {
        //以下编写方式参考的官方示例
        this.server = ServerBuilder.forPort(8080).addService(new StudentServiceImpl()).build().start();

        System.out.println("server started....");

        //官方示例:grpc server退出的方式:JVM退出之前,关闭grpc server
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            System.out.println("进行关闭");
            GrpcServer.this.stop();
        }));

        System.out.println("server start invork end....");
    }

    /**
     * 服务端退出
     */
    private void stop(){
        if (null != server){
            this.server.shutdown();
        }
    }

    /**
     * 阻塞等待服务器关闭没,需要自己调用(与thrift不同,需要自己阻塞等待服务器关闭,而thrift调用serve后会阻塞当前线程)
     * @throws InterruptedException
     */
    private void awaitTermination() throws InterruptedException {
        if (null != server){
            this.server.awaitTermination();
        }
    }

    public static void main(String[] args) throws InterruptedException, IOException {
        GrpcServer grpcServer = new GrpcServer();
        grpcServer.start();
        grpcServer.awaitTermination();
    }
}

3、客户端代码

package com.mzj.netty.ssy._08_grpc.mycode;

import com.mzj.netty.ssy._08_grpc.*;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import io.grpc.stub.StreamObserver;

import java.time.LocalDateTime;
import java.util.Iterator;

/**
 * @Auther: mazhongjia
 * @Description:
 */
public class GrpcClient {

    /**
     * 方式1:输入参数与返回值都是普通的java对象
     */
    public static void client1(){
        //其中usePlaintext(true):创建的是一个不安全的、不是用ssl证书加密的通道
        ManagedChannel managedChannel = ManagedChannelBuilder.forAddress("localhost",8080).usePlaintext().build();

        //通过grpc生成的服务stub对象调用rpc请求,这里获取的是阻塞的Stub,也就是同步调用并等待RPC调用返回结果,grpc同样也支持异步调用
        StudentServiceGrpc.StudentServiceBlockingStub blockingStub = StudentServiceGrpc.newBlockingStub(managedChannel);

        MyResponse myResponse = blockingStub.getRealNameByUsername(MyRequest.newBuilder().setUsername("huna").build());

        System.out.println(myResponse.getRealname());
    }

    /**
     * 方式2:输入参数是普通的java对象,返回值是流类型
     */
    public static void client2(){
        ManagedChannel managedChannel = ManagedChannelBuilder.forAddress("localhost",8080).usePlaintext().build();

        StudentServiceGrpc.StudentServiceBlockingStub blockingStub = StudentServiceGrpc.newBlockingStub(managedChannel);

        /**
         * 流式返回值,API编码时返回的是一个迭代器
         */
        Iterator<StudentResponse> iterator = blockingStub.getStudentsByAge(StudentRequest.newBuilder().setAge(31).build());

        while(iterator.hasNext()){
            StudentResponse studentResponse = iterator.next();
            System.out.println(studentResponse.getName()+studentResponse.getAge()+studentResponse.getCity());
        }

    }

    /**
     * 方式3:输入参数是流类型,返回值是普通的java对象
     *
     * 客户端
     */
    public static void client3(){
        //1.客户端首选创建StreamObserver对象(客户端创建的listener对象,用于传给服务端,监听服务端发送消息的回调listener)
        //这里创建的StreamObserver中回调方法是在服务端返回response后,调用,用于客户端处理服务端返回数据(关注回调方法参数类型)
        StreamObserver<StudentResponseList> studentResponseListStreamObserver = new StreamObserver<StudentResponseList>() {
            @Override
            public void onNext(StudentResponseList value) {
                value.getStudentResponseList().forEach(studentResponse -> {
                    System.out.println(studentResponse.getName());
                    System.out.println(studentResponse.getAge());
                    System.out.println(studentResponse.getCity());
                    System.out.println("*********");
                });
            }

            @Override
            public void onError(Throwable t) {
                System.out.println(t.getMessage());
            }

            @Override
            public void onCompleted() {
                System.out.println("completed!");
            }
        };

        //2、创建客户端向服务端发送数据
        //****只要客户端是以流式的方式向服务端发送请求,那么交互肯定是异步的****=>典型的异步调用---回调方式的接口设计
        //而之前两种方式使用的xxxxServiceBlockingStub是同步使用的,因此需要构造可以用于异步的stub(xxxxServiceStub)
        ManagedChannel managedChannel = ManagedChannelBuilder.forAddress("localhost",8080).usePlaintext().build();
        StudentServiceGrpc.StudentServiceStub asyncStub = StudentServiceGrpc.newStub(managedChannel);
        //通过asyncStub可以调用IDL中声明的所有rpc service接口(异步的方式,mzj:可以对比两种方式,使用API接口--传递参数、返回值有什么不同,体会同步/异步接口的设计感),但是客户端以流行色向服务端发送的rpc请求接口,必须得是通过这种异步的方式
        StreamObserver<StudentRequest> studentRequestStreamObserver = asyncStub.getStudentsWrapperByAges(studentResponseListStreamObserver);
        //-------------异步接口--------------:
//        void asyncStub.getRealNameByUsername(MyRequest request,
//                io.grpc.stub.StreamObserver<MyResponse> responseObserver);
        //-------------同步接口--------------:
//        MyResponse myResponse = blockingStub.getRealNameByUsername(MyRequest request)

        studentRequestStreamObserver.onNext(StudentRequest.newBuilder().setAge(20).build());
        studentRequestStreamObserver.onNext(StudentRequest.newBuilder().setAge(30).build());
        studentRequestStreamObserver.onNext(StudentRequest.newBuilder().setAge(40).build());
        studentRequestStreamObserver.onNext(StudentRequest.newBuilder().setAge(50).build());

        studentRequestStreamObserver.onCompleted();
        //mzj:这里设计的服务端/客户端交互接口,跟我水电GDI设计的异步接口区别在(注意,这里写的区别仅是API接口设计):
        //grpc异步接口设计,接口返回值设计成StreamObserver<StudentRequest>,而我设计成public GDIFuture<GDIResponse>  method(GDLListener listener)
        //区别在于,我是①通过返回值让客户端持有并可以同步获取请求结果②通过参数listener被动通知(回调传递返回结果)
        //而grpc是通过返回值让客户端可以调用onNext发送流式request请求
        //设计的目的不同,初衷不同。。。。
    }

    /**
     * 方式4:输入参数是流类型,返回值也是流类型
     */
    public static void client4(){
        //1.客户端首选创建StreamObserver对象(用于传给服务端监听服务端发送消息的listener)
        StreamObserver<StreamResponse> responseStreamObserver = new StreamObserver<StreamResponse>() {
            @Override
            public void onNext(StreamResponse value) {
                System.out.println(value.getResponseInfo());
            }

            @Override
            public void onError(Throwable t) {
                System.out.println(t.getMessage());
            }

            @Override
            public void onCompleted() {
                System.out.println("completed!");
            }
        };
        //2、客户端向服务端发送数据
        ManagedChannel managedChannel = ManagedChannelBuilder.forAddress("localhost",8080).usePlaintext().build();
        StudentServiceGrpc.StudentServiceStub asyncStub = StudentServiceGrpc.newStub(managedChannel);

        StreamObserver<StreamRequest> requestStreamObserver = asyncStub.biTalk(responseStreamObserver);

        for(int i=0;i<10;i++){
            requestStreamObserver.onNext(StreamRequest.newBuilder().setRequestInfo(LocalDateTime.now().toString()).build());
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
//        client1();
//        client2();

//        client3();

        client4();
        //测试client3、client4时(客户端以流式向服务端发送数据),由于通信是异步的,所以要保证主线程不退出,才能完成调用,测试出效果
        Thread.sleep(10000);
    }
}

然后依次运行服务端和客户端就可以了。通过main函数4种类型rpc方法测试观察效果

完整工程代码:

https://github.com/mazhongjia/nettyssynetty02/tree/master/src/main/java/com/mzj/netty/ssy/_08_grpc

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值