之前一改参数就开会,现在 gRPC + proto 谁都不吵架了!✌️

878 阅读10分钟

背景

我们公司有一款社交 App,随着业务不断扩展,功能逐渐增多,早期的代码发展至今写的很乱很臃肿。为此,我们对部分业务模块进行了重构,比如里面的推送相关的服务被单独拆分了出来。

像「用户资料审核通过」「任务领奖通知」「用户送礼物大厅广播」等系统消息,最初都是由各模块各自接入第三方 SDK(如网易云信、融云等)实现推送,导致推送逻辑分散、参数不统一、维护混乱。

为了解决这些问题,我们将推送能力抽离,重构为一个独立的 Java 推送服务,统一封装各类第三方推送通道,并通过 gRPC 提供统一接口,供各业务方调用,从而实现了推送逻辑的解耦、调用方式的统一以及整体效率的提升。

架构部分示例

我们目前的服务架构采用多语言协作的形式:

  • Go 负责核心业务逻辑(如社交模块核心服务、任务引擎)
  • PHP 承担后台管理系统、运营控制台
  • Java 则专注在通用能力服务的封装,例如推送服务、推荐服务等

可以说是“集各家所长”,各服务通过高效的通信协议完成协作。 推送单独剥离出去后 我们不同服务直接调用大概就是这样的模式:

    ┌────────────┐          ┌──────────────┐
    │  后台服务   ├─────────▶│              │
    └────────────┘          │              │
                            │              │
    ┌────────────┐   gRPC   │ 推送服务模块   │
    │  游戏服务   ├─────────▶│(Java 实现)   │
    └────────────┘          │              │
                            │              │
    ┌────────────┐          │              │
    │  任务服务   ├─────────▶│              │
    └────────────┘          └─────┬────────┘
                                  │
                 ┌─────────────────────────────────────┐
                 │ 第三方推送厂商(网易/融云/极光/Firebase)│
                 └─────────────────────────────────────┘
                 
                 

grpc方案选定

在推送服务刚拆分出去的时候,我们一开始还是采用了 HTTP 接口(REST API)的方式和推送服务模块进行对接。但是由于各业务服务由不同的语言实现、不同的团队负责,接口对接变得格外麻烦。字段一变、结构一改,就要拉一轮会同步,前后端和不同团队之间经常为了一个参数名反复确认,开发效率大打折扣。

特别是当接口稍微复杂一点,比如要传附加字段、走不同推送策略的时候,不同团队的理解经常出现偏差,频繁的改动导致维护成本越来越高。

后来我们技术老大一锤定音:服务间直接走 gRPC。接口统一由 .proto 文件定义,双方各自生成代码,字段变动直接对齐,省去来回确认的过程,开发效率也大大提升。

那为什么不是 JSON-RPC?

其实在选型初期,我们也讨论过是否使用 JSON-RPC,毕竟它结构简单、上手快、数据可读性强。但考虑到我们项目的实际情况,最终还是没有采用,主要原因有三点:

  1. 团队多语言协作:gRPC 原生支持 Go、Java、PHP 等主流语言,能自动生成接口代码,而 JSON-RPC 通常需要我们手动维护请求结构,协作成本高。
  2. 接口强约束不够:JSON-RPC 接口没有统一的“强类型定义”,字段变动容易出错,不像 gRPC 有 .proto 文件统一规范。
  3. 性能不够优:推送场景请求频繁,我们更需要一个轻量、高效的通信方式。gRPC 使用 Protobuf 序列化,在传输体积和编解码效率上都比 JSON 更适合。

所以最终我们放弃了 JSON-RPC,选择了更稳定、工程化更强的 gRPC 来作为服务间通信协议。

gRPC 跨语言通信示例:Go 调用 Java 服务

接下来我将通过一个简单的 demo,演示我们是如何使用 gRPC 实现 Go 与 Java 两个服务之间的接口互调。

这个 demo 模拟的是我们项目中一个常见的场景:

由 Go 编写的社交接口服务,调用 Java 实现的推送服务,向用户发送一条通知消息。

我们会从定义 .proto 文件开始,分别用 Go 和 Java 实现客户端与服务端,完成一次完整的 gRPC 调用链路。

步骤一:定义 proto 文件

我们首先创建了一个 push.proto 文件,描述推送服务的通信结构。它定义了一个 PushService 服务,包含一个 SendNotification 方法,用于向指定用户发送系统消息。

SendRequest 请求体包含用户 ID、标题、内容、来源模块、以及附加参数(如跳转页等);SendResponse 响应体包含是否推送成功及返回的 messageId。

下面是 proto 文件的完整内容:

syntax = "proto3";

package push;

service PushService {
  rpc SendNotification (SendRequest) returns (SendResponse);
}

message SendRequest {
  string uid = 1;                  // 接收用户 ID
  string title = 2;                // 消息标题
  string content = 3;              // 消息内容
  string source = 4;               // 来源模块,例如 game / task / admin
  map<string, string> metadata = 5; // 附加字段,如跳转页参数等
}

message SendResponse {
  bool success = 1;               // 推送是否成功
  string messageId = 2;           // 推送消息 ID
}

这个接口定义非常简单清晰,便于跨语言统一调用。

我们接下来会基于这个定义,在 Java 中实现服务端,在 Go 中实现客户端,完成一次完整的跨语言服务调用流程。

步骤二:Java 服务端准备工作

我们使用的是一个基于 Maven 的 Spring Boot 项目,用于实现推送服务的 gRPC 服务端。

在正式实现服务逻辑之前,需要先将 proto 文件加入到 Java 项目中,并配置相关插件以便自动生成 Java 类。

1. 创建 proto 文件夹

在项目结构中,我们在 src/main 目录下新建一个 proto/ 文件夹,并将之前写好的 push.proto 文件放入该目录:

push-service/
├── src/
│   └── main/
│       └── proto/
│           └── push.proto

建议:保持 proto 文件单独集中管理,便于我们维护和复用哈。

2. 引入依赖和插件

pom.xml 中我们已经提前引入了 gRPC 所需的依赖和插件,包括:

  • grpc-netty-shaded:gRPC 运行时(Netty 实现)
  • grpc-stub / grpc-protobuf:gRPC 通信和消息结构支持
  • protobuf-maven-plugin:用于编译 .proto 文件并生成 Java 源码
  • os-maven-plugin:自动识别操作系统架构,兼容 protoc 工具

依赖部分如下(略):

<dependency>
  <groupId>io.grpc</groupId>
  <artifactId>grpc-netty-shaded</artifactId>
  <version>${grpc.version}</version>
</dependency>
...

插件配置如下(略):

<plugin>
  <groupId>org.xolstice.maven.plugins</groupId>
  <artifactId>protobuf-maven-plugin</artifactId>
  <version>0.6.1</version>
  ...
</plugin>

配置完成后,执行以下命令即可自动生成 Java 代码:

mvn clean compile

生成后的文件会位于:

target/generated-sources/protobuf/

其中包含:

  • PushServiceGrpc.java(服务接口)
  • Push.SendRequest.java / SendResponse.java(消息结构)

image.png

步骤三:实现 Java 服务端逻辑

在执行完 mvn compile 之后,我们已经生成好了 gRPC 所需的 Java 类。接下来我们来实现具体的业务逻辑,并启动一个 gRPC 服务监听请求。

1. 实现 PushServiceImpl

我们创建一个类 PushServiceImpl,继承自动生成的 PushServiceGrpc.PushServiceImplBase,并重写其中的 sendNotification 方法:

package org.example.application.pushservice;

import io.grpc.stub.StreamObserver;
import push.Push;
import push.PushServiceGrpc;

import java.util.UUID;

public class PushServiceImpl extends PushServiceGrpc.PushServiceImplBase {

    @Override
    public void sendNotification(Push.SendRequest request, StreamObserver<Push.SendResponse> responseObserver) {
        String uid = request.getUid();
        String title = request.getTitle();
        String content = request.getContent();
        String source = request.getSource();

        System.out.printf("接收到推送请求:uid=%s, title=%s, content=%s, source=%s%n",
                uid, title, content, source);

        // 模拟返回一个消息 ID
        String messageId = UUID.randomUUID().toString();

        Push.SendResponse response = Push.SendResponse.newBuilder()
                .setSuccess(true)
                .setMessageId(messageId)
                .build();

        responseObserver.onNext(response);
        responseObserver.onCompleted();
    }
}

这里我们只是简单地打印请求内容,并返回一个模拟的 messageId,真实项目中可以集成推送通道、入库等操作。


2. 启动 gRPC 服务端

我们再创建一个启动类 GrpcPushServer,用于启动一个 gRPC 服务端监听端口(比如 50051):

package org.example.application.pushservice;

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

public class GrpcPushServer {

    public static void main(String[] args) throws Exception {
        Server server = ServerBuilder
                .forPort(50051)
                .addService(new PushServiceImpl())
                .build();

        System.out.println("gRPC 推送服务已启动,监听端口 50051");
        server.start();
        server.awaitTermination();
    }
}

大概的代码目录如下:

image.png


3. 启动服务进行验证

运行 GrpcPushServer.java,我们就可以看到控制台输出:

gRPC 推送服务已启动,监听端口 50051...

image.png 此时 gRPC 服务端已经准备就绪,可以等待客户端调用。


步骤四:Go 客户端调用 Java 推送服务

在 Java 服务端启动完成后,我们使用 Go 实现客户端,模拟社交服务向推送服务发起调用。

1. 初始化 Go 项目

我们在本地创建了一个新的 Go 项目目录:

mkdir push-client-go && cd push-client-go
go mod init push-client-go

2. 准备 proto 文件

将之前写好的 push.proto 文件复制到项目的 proto/ 目录下:

push-client-go/
├── go.mod
├── main.go
└── proto/
    └── push.proto

为了让 Go 正常生成 .pb.go 文件,我们需要在 proto 文件中加入:

option go_package = "/proto;pushpb";

该配置用于指定 Go 的生成包路径,Java 不需要,但 Go 是必须的,否则 protoc 编译时会报错。

3. 安装依赖与编译插件

首先安装生成 Go 代码所需的插件:

go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

将 GOPATH 加入环境变量:

export PATH="$PATH:$(go env GOPATH)/bin"

然后安装 gRPC 依赖包:

go get google.golang.org/grpc

这一步是为了支持 grpc.Dial() 等客户端调用函数。

4. 生成 Go 代码

在项目根目录执行:

protoc --go_out=. --go-grpc_out=. --proto_path=proto proto/push.proto

这会在 proto/ 目录下生成两个文件:

  • push.pb.go
  • push_grpc.pb.go

image.png


5. 编写客户端代码

在项目根目录下创建 main.go,实现调用逻辑:

package main

import (
	"context"
	"fmt"
	"log"
	"time"

	pushpb "push-client-go/proto"

	"google.golang.org/grpc"
)

func main() {
	conn, err := grpc.Dial("localhost:50051", grpc.WithInsecure())
	if err != nil {
		log.Fatalf("连接失败: %v", err)
	}
	defer conn.Close()

	client := pushpb.NewPushServiceClient(conn)

	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()

	req := &pushpb.SendRequest{
		Uid:     "10086",
		Title:   "系统通知",
		Content: "你中了 888 金币大奖!",
		Source:  "task",
		Metadata: map[string]string{
			"jump": "task_page",
		},
	}

	resp, err := client.SendNotification(ctx, req)
	if err != nil {
		log.Fatalf("调用失败: %v", err)
	}

	fmt.Printf("推送成功:messageId = %s\n", resp.GetMessageId())
}

6. 运行客户端

确保 Java 服务端正在运行,然后执行:

go mod tidy
go run main.go

我们就会看到终端输出:

推送成功:messageId = 87c0d8a4-xxxx-xxxx-xxxx

image.png

Java 控制台也会显示:

接收到推送请求:uid=10086, title=系统通知, ...

image.png


到此,我们就完成了一次完整的 gRPC 跨语言调用:Go 客户端成功调用了 Java 推送服务,并完成了消息发送请求。

补充说明:实际项目中的安全通信配置

在本次 Demo 中,为了快速演示 gRPC 的跨语言调用,我们使用了最基础的配置(明文传输、Insecure 模式、IP + 明文端口)。但在实际项目中,为了保障服务安全性和可信度,有以下几点需要特别注意:


1. 不应直接暴露内网 IP + 端口

Demo 中我们使用了:

grpc.Dial("localhost:50051", grpc.WithInsecure())

但在生产环境中 服务端口通常不会直接暴露给公网,比如我们项目会通过服务注册与发现(如 Consul、Nacos)+ 内部负载均衡调用 或者接入统一网关等。


2. gRPC 通信应开启 TLS 认证

gRPC 支持类似 HTTPS 的 TLS 传输加密 + 双向认证机制,可以防止中间人攻击、伪造请求等安全问题。

服务端配置 TLS 示例(Java):

Server server = NettyServerBuilder
    .forPort(50051)
    .useTransportSecurity(
        new File("server.crt"),  // 证书
        new File("server.key")   // 私钥
    )
    .addService(new PushServiceImpl())
    .build();

客户端(Go)则需要传入 credentials.NewClientTLSFromFile(...)

creds, _ := credentials.NewClientTLSFromFile("ca.crt", "")
conn, _ := grpc.Dial("your-service.com:443", grpc.WithTransportCredentials(creds))

3. 可通过 metadata 传递 Token,实现服务鉴权

gRPC 支持通过 Metadata 机制向服务端传输认证信息(如 JWT Token、签名头):

md := metadata.Pairs("authorization", "Bearer xxx-token")
ctx := metadata.NewOutgoingContext(context.Background(), md)

client.SendNotification(ctx, req)

服务端可通过拦截器统一处理鉴权逻辑,详细的实现细节这里我就不再多写相关代码了 主要本篇写的太多了已经。

附:如何生成服务端 TLS 证书(开发环境用)

在实际部署 gRPC 服务时,我们推荐启用 TLS 来加密通信内容,防止中间人攻击与数据泄露。这里我们使用 OpenSSL 工具来生成一套本地自签名证书(仅适用于开发测试环境)。

1. 生成 CA 根证书(用于签发服务端证书)

openssl genrsa -out ca.key 4096
openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 -out ca.crt \
  -subj "/C=CN/ST=Guangdong/L=Shenzhen/O=Example/OU=Dev/CN=example.com"

这一步会生成:

  • ca.key:根证书私钥
  • ca.crt:根证书(客户端会用它来验证服务端证书)

2. 生成服务端证书和私钥

# 生成私钥
openssl genrsa -out server.key 2048

# 生成 CSR(证书签名请求)
openssl req -new -key server.key -out server.csr \
  -subj "/C=CN/ST=Guangdong/L=Shenzhen/O=Example/OU=Server/CN=localhost"

# 用 CA 证书签发服务端证书
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key \
  -CAcreateserial -out server.crt -days 365 -sha256

最终会生成:

  • server.key:服务端私钥
  • server.crt:服务端证书
  • server.csr:中间签发文件(可忽略)
  • ca.srl:序列号记录(自动生成)

3. 启动 gRPC 服务时配置证书(Java 示例)

Server server = NettyServerBuilder
    .forPort(50051)
    .useTransportSecurity(
        new File("server.crt"),
        new File("server.key")
    )
    .addService(new PushServiceImpl())
    .build();

4. 客户端连接时验证 CA 证书(Go 示例)

creds, err := credentials.NewClientTLSFromFile("ca.crt", "")
conn, err := grpc.Dial("localhost:50051", grpc.WithTransportCredentials(creds))

生产环境强烈建议使用企业 CA 签发的证书,或使用 Let's Encrypt、阿里云、腾讯云等提供的正式证书。


思考

我们上面通过一个简单的 demo 展示了如何使用 gRPC 实现 Go 与 Java 的跨语言服务调用。

看起来是挺简单的,但放在我们实际的项目架构背景下,其实正是架构演进的缩影。随着项目规模越来越大,代码结构越来越臃肿,我们开始拆分一些通用能力出来,比如推送服务。因为其他模块可能用的是 Go、PHP、Node,推送模块却是 Java 写的,为了让它们之间能高效互通,我们又引入了 gRPC 来进行服务间调用。

这个时候,我们其实还没明确地说要做“微服务”——只是觉得代码太乱、耦合太深,需要拆一下,后来每个模块都独立了,再后来就接了注册中心、做了链路追踪、统一了限流认证... 然后就变成了微服务架构模式。

所以,我们不是「决定做微服务」才去拆服务哈,而是在不断优化项目结构、解决实际问题的过程中,一步步把服务变“微”了