gRPC 实战指南:常见误区与 Java 开发实践

89 阅读9分钟

写在前面

最近看了不少开源项目的源码,发现到处都是 gRPC:SkyWalking 用它上报数据,Nacos 2.0 用它做服务注册,Dubbo 也支持 gRPC 协议。

既然这么多项目在用,我就花了点时间把 gRPC 这个东西搞明白。结果发现,之前对 gRPC 的理解有不少误区。

这篇文章就是把我搞明白的东西写下来,顺便帮自己理清思路。

我之前的几个误解

误解1:gRPC 是 Go 专用的

我一开始真这么想,毕竟名字里有个 "g"。而且看了几个 Go 写的微服务项目,发现它们服务间通信基本都用 gRPC,就更坚定了我的想法。

后来才知道这个 g 是 Google,不是 Go。gRPC 确实跟 Go 很搭(都是 Google 出品,生态契合度高),但不是 Go 专用的。

gRPC 支持一大堆语言:Java、Python、C++、Go、C#、Node.js、PHP、Ruby... 基本上主流语言都支持。Java 生态里 Dubbo、Spring Cloud 也都支持 gRPC。这篇文章用的就是 Java。

误解2:gRPC 直接基于 TCP

我以为 gRPC 是直接在 TCP 上搞的,后来发现不对。gRPC 的层次是这样的:

应用代码 → gRPC → HTTP/2 → TCP

中间有层 HTTP/2。为啥不直接用 TCP?后来我想明白了:

  1. HTTP/2 已经帮你搞定了流控制、多路复用、头部压缩
  2. 防火墙和负载均衡器都认识 HTTP,不会被拦
  3. TLS 加密也是现成的
  4. 标准协议,生态成熟

误解3:用 gRPC 要自己写 Netty

这个误解最离谱。我看到 gRPC 底层用 Netty,就以为要自己写 Netty 代码。

实际上 gRPC 官方库已经把 Netty 封装好了:

Server server = ServerBuilder.forPort(50051)
    .addService(new GreeterServiceImpl())
    .build()
    .start();

就这么简单。底层的网络 I/O、连接池、线程管理全帮你搞定了。你完全不用碰 Netty。

误解4:Spring Boot 集成 gRPC 性能很差

我之前担心 Spring Boot 框架重,集成 gRPC 会有性能问题。

后来测试发现,确实有损耗,但也就 5-15%。而实际项目里,你的瓶颈往往是数据库、Redis、外部接口,不是框架本身。

除非你 QPS 上几万,否则 Spring Boot 集成完全够用。

gRPC 是个啥

说白了,gRPC 就是让不同机器上的程序互相调用,像调本地方法一样。比 REST API 快,比自己写 Socket 省事。

核心就三点:

  1. 用 HTTP/2 传输(快、支持流)
  2. 用 Protobuf 序列化(比 JSON 省空间)
  3. 自动生成代码(写个 .proto 文件,代码自动生成)

实战:写个 gRPC 服务

别光说不练,咱们写个真实的项目。

第一步:Maven 配置

这是重点,很多人卡在这一步。

<properties>
    <!-- Java 17,别用 8 了,新特性真香 -->
    <maven.compiler.source>17</maven.compiler.source>
    <maven.compiler.target>17</maven.compiler.target>
    <grpc.version>1.60.0</grpc.version>
    <protobuf.version>3.25.1</protobuf.version>
</properties>

<dependencies>
    <!-- gRPC 核心三件套 -->
    <dependency>
        <groupId>io.grpc</groupId>
        <artifactId>grpc-netty-shaded</artifactId>
        <version>${grpc.version}</version>
    </dependency>
    
    <dependency>
        <groupId>io.grpc</groupId>
        <artifactId>grpc-protobuf</artifactId>
        <version>${grpc.version}</version>
    </dependency>
    
    <dependency>
        <groupId>io.grpc</groupId>
        <artifactId>grpc-stub</artifactId>
        <version>${grpc.version}</version>
    </dependency>
</dependencies>

<build>
    <!-- 这个扩展很重要,不加会报错 -->
    <extensions>
        <extension>
            <groupId>kr.motd.maven</groupId>
            <artifactId>os-maven-plugin</artifactId>
            <version>1.7.1</version>
        </extension>
    </extensions>
    
    <plugins>
        <!-- 这个插件自动生成代码 -->
        <plugin>
            <groupId>org.xolstice.maven.plugins</groupId>
            <artifactId>protobuf-maven-plugin</artifactId>
            <version>0.6.1</version>
            <configuration>
                <!-- protoc 编译器 -->
                <protocArtifact>
                    com.google.protobuf:protoc:${protobuf.version}:exe:${os.detected.classifier}
                </protocArtifact>
                <!-- gRPC 插件 -->
                <pluginId>grpc-java</pluginId>
                <pluginArtifact>
                    io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier}
                </pluginArtifact>
            </configuration>
            <executions>
                <execution>
                    <goals>
                        <goal>compile</goal>
                        <goal>compile-custom</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

重点说明

  1. grpc-netty-shaded 是推荐版本,避免 Netty 版本冲突
  2. os-maven-plugin 必须加,不然 protoc 下载会出错
  3. protobuf-maven-plugin 会自动生成代码到 target/generated-sources/

关键:生成的代码不用 copy 到 src/main/java

很多人会纠结要不要把生成的代码复制到 src 目录。答案是:不用!

Maven 会自动识别 target/generated-sources/ 目录,IDE 也会自动识别。而且生成的代码不应该提交到 Git,每次 mvn compile 都会重新生成。

第二步:定义协议

src/main/proto/greeter.proto 写协议定义:

syntax = "proto3";

option java_package = "com.example.grpc";

service Greeter {
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}

message HelloRequest {
  string name = 1;  // 数字是字段编号,不能改
  int32 age = 2;
}

message HelloReply {
  string message = 1;
}

注意

  • 字段编号一旦定了就别改,影响向后兼容
  • 1-15 的编号 Tag 只占 1 字节,常用字段用这些编号
  • 16-2047 的编号 Tag 占 2 字节

第三步:生成代码

mvn clean compile

这一步会生成三个关键文件:

  • HelloRequest.java - 请求对象
  • HelloReply.java - 响应对象
  • GreeterGrpc.java - 服务类和客户端 Stub

你会在 target/generated-sources/protobuf/java/target/generated-sources/protobuf/grpc-java/ 看到它们。

IDEA 会把这些目录标记成蓝色(Generated Sources Root),可以直接用。

第四步:写服务端

// 服务实现
public class GreeterServiceImpl extends GreeterGrpc.GreeterImplBase {
    
    @Override
    public void sayHello(HelloRequest request, 
                        StreamObserver<HelloReply> responseObserver) {
        
        String message = "Hello " + request.getName() + 
                        ", you are " + request.getAge() + " years old";
        
        HelloReply reply = HelloReply.newBuilder()
            .setMessage(message)
            .build();
        
        responseObserver.onNext(reply);
        responseObserver.onCompleted();
    }
}

// 启动服务器
public class GreeterServer {
    
    public static void main(String[] args) throws Exception {
        Server server = ServerBuilder.forPort(50051)
            .addService(new GreeterServiceImpl())
            .build()
            .start();
        
        System.out.println("服务器启动,监听 50051");
        
        // 优雅关闭
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            server.shutdown();
        }));
        
        server.awaitTermination();
    }
}

就这么简单。不需要写任何 Netty 代码,不需要处理网络连接,不需要管理线程池。

第五步:写客户端

public class GreeterClient {
    
    public static void main(String[] args) {
        // 创建连接
        ManagedChannel channel = ManagedChannelBuilder
            .forAddress("localhost", 50051)
            .usePlaintext()  // 不用 TLS,测试环境
            .build();
        
        try {
            // 创建客户端
            GreeterBlockingStub stub = GreeterGrpc.newBlockingStub(channel);
            
            // 发请求
            HelloRequest request = HelloRequest.newBuilder()
                .setName("张三")
                .setAge(25)
                .build();
            
            HelloReply response = stub.sayHello(request);
            
            System.out.println("响应: " + response.getMessage());
            
        } finally {
            channel.shutdown();
        }
    }
}

运行

# 终端1:启动服务端
mvn exec:java -Dexec.mainClass="com.example.grpc.server.GreeterServer"

# 终端2:运行客户端
mvn exec:java -Dexec.mainClass="com.example.grpc.client.GreeterClient"

gRPC 消息是怎么传的

很多人好奇 gRPC 底层到底传了啥。简单说就是:5 字节头 + Protobuf 数据

完整消息:
00 00 00 00 09 0A 05 41 6C 69 63 65 10 19
│           │
│           └─ Protobuf 数据(9字节)
└─ 5字节头部

5 字节头部

  • 第 1 字节:压缩标志(0x00 = 不压缩)
  • 第 2-5 字节:消息长度(大端序)

Protobuf 数据

0A 05 41 6C 69 63 65 10 19

解释:
0A = Tag (字段1,类型是字符串)
05 = 长度 5
41 6C 69 63 65 = "Alice" 的 UTF-8
10 = Tag (字段2,类型是整数)
19 = 值 25

Protobuf 用的是 Tag-Length-Value 编码:

  • Tag 包含字段编号和类型
  • Length 对于字符串类型才有
  • Value 就是实际值

比 JSON 省空间多了:

JSON: {"name":"Alice","age":25}  // 26 字节
Protobuf: 0A 05 41 6C 69 63 65 10 19  // 9 字节

拦截器:看清楚每个字节

我写了个拦截器,可以打印出每个请求的详细字节分析。这对理解 gRPC 很有帮助。

public class LoggingServerInterceptor implements ServerInterceptor {
    
    @Override
    public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
            ServerCall<ReqT, RespT> call,
            Metadata headers,
            ServerCallHandler<ReqT, RespT> next) {
        
        System.out.println("收到请求: " + call.getMethodDescriptor().getFullMethodName());
        
        // 这里可以拦截请求和响应
        // 打印字节、记录日志、做权限校验等
        
        return next.startCall(call, headers);
    }
}

加到服务器:

Server server = ServerBuilder.forPort(50051)
    .addService(new GreeterServiceImpl())
    .intercept(new LoggingServerInterceptor())  // 加这一行
    .build()
    .start();

我项目里的拦截器会打印详细的字节分析,运行后输出是这样的:

========== 收到 gRPC 请求 ==========
方法: greeter.Greeter/SayHello

请求消息:
  name: "Alice"
  age: 25

Protobuf 字节数: 9
十六进制: 0A 05 41 6C 69 63 65 10 19

详细解析:
  [0] 0x0A = Tag (字段1,字符串)
  [1] 0x05 = 长度 5
  [2-6] "Alice"
  [7] 0x10 = Tag (字段2,整数)
  [8] 0x19 = 值 25

这对调试和学习 gRPC 协议非常有用。

纯 gRPC vs Spring Boot

纯 gRPC(我这个项目的方式)

代码

Server server = ServerBuilder.forPort(50051)
    .addService(new GreeterServiceImpl())
    .build()
    .start();

优点

  • 完全掌控,想怎么配置就怎么配置
  • 轻量,启动快(毫秒级)
  • 性能最好
  • 适合学习和理解原理

缺点

  • 要自己管理服务器生命周期
  • 没有依赖注入
  • 不能用 Spring 那一套(事务、缓存、安全等)

适合

  • 学习 gRPC
  • 独立的微服务
  • 性能要求极高的场景
  • 不需要 Spring 生态

Spring Boot 集成

依赖

<dependency>
    <groupId>net.devh</groupId>
    <artifactId>grpc-spring-boot-starter</artifactId>
    <version>2.15.0.RELEASE</version>
</dependency>

代码

@GrpcService  // 一个注解搞定
public class GreeterServiceImpl extends GreeterGrpc.GreeterImplBase {
    
    @Autowired
    private UserService userService;  // 依赖注入
    
    @Override
    public void sayHello(HelloRequest request, 
                        StreamObserver<HelloReply> responseObserver) {
        // 可以用 Spring 的所有东西
    }
}

配置(application.yml):

grpc:
  server:
    port: 9090

优点

  • 自动配置,省事
  • 依赖注入,代码简洁
  • 可以用 Spring 全家桶
  • 开发效率高

缺点

  • 启动慢(Spring 容器初始化)
  • 内存占用多
  • 性能有 5-15% 的损耗

适合

  • 企业级应用
  • 团队熟悉 Spring
  • 需要 Spring 生态
  • 对性能要求不是特别极致

怎么选

QPS < 10K,团队用 Spring → Spring Boot 集成
QPS > 50K,性能敏感 → 纯 gRPC
学习和研究 → 纯 gRPC

说实话,大部分项目用 Spring Boot 集成完全够用。那点性能损耗真的不是瓶颈。

实际使用场景

SkyWalking:Agent 上报数据

SkyWalking 的 Agent 用 gRPC 向 OAP 服务器上报追踪数据。为啥用 gRPC?

  • Agent 要高频上报(每秒几千次)
  • gRPC 二进制协议省带宽
  • 支持流式上报
  • 对应用性能影响小

Nacos 2.0:服务注册

Nacos 2.0 把 HTTP 轮询改成了 gRPC 长连接。好处:

  • 配置变更实时推送(不用轮询了)
  • 心跳用双向流(省连接)
  • 减少服务器压力

微服务内部调用

订单服务调用库存服务、支付服务,用 gRPC:

  • 类型安全(.proto 定义接口)
  • 比 REST 快
  • 自动生成客户端代码
  • 支持流式(批量操作)

常见问题

Q:如何调试 gRPC?

方法1:用拦截器(我项目里就是这么干的)

方法2:用 grpcurl

grpcurl -plaintext localhost:50051 list
grpcurl -plaintext -d '{"name":"张三","age":25}' \
  localhost:50051 greeter.Greeter/SayHello

方法3:用 Postman(新版本支持 gRPC)

Q:生产环境怎么办?

  • 开启 TLS
  • 加认证(Metadata 里传 Token)
  • 限流(拦截器里做)
  • 监控(集成 Prometheus)

Q:能用于对外 API 吗?

不太推荐。gRPC 更适合内部微服务。对外 API 还是用 REST,理由:

  • REST 文档友好(Swagger)
  • 浏览器直接支持
  • 调试方便
  • 生态成熟

如果非要用,可以加个网关,外面 REST,里面 gRPC。

Q:性能到底怎么样?

跟 REST 比,gRPC 一般快 3-5 倍。但说实话,你的瓶颈往往不在这:

  • 数据库查询:10-50ms
  • Redis 读取:1-5ms
  • 外部接口:50-500ms
  • gRPC vs REST:差 1-2ms

所以别过度优化。除非你 QPS 真的很高。

总结

gRPC 其实没那么复杂:

  1. 不是 Go 专用的,Java 也能用
  2. 不用自己写 Netty,官方库帮你封装好了
  3. Maven 自动生成代码,不用手动 copy
  4. 用 HTTP/2 + Protobuf,比 REST 快
  5. Spring Boot 集成有损耗,但大部分场景够用

我的建议

  • 先用纯 gRPC 学习,理解原理
  • 生产环境看情况,能用 Spring Boot 就用
  • 别过度优化,先跑起来再说