背景
我们公司有一款社交 App,随着业务不断扩展,功能逐渐增多,早期的代码发展至今写的很乱很臃肿。为此,我们对部分业务模块进行了重构,比如里面的推送相关的服务被单独拆分了出来。
像「用户资料审核通过」「任务领奖通知」「用户送礼物大厅广播」等系统消息,最初都是由各模块各自接入第三方 SDK(如网易云信、融云等)实现推送,导致推送逻辑分散、参数不统一、维护混乱。
为了解决这些问题,我们将推送能力抽离,重构为一个独立的 Java 推送服务,统一封装各类第三方推送通道,并通过 gRPC 提供统一接口,供各业务方调用,从而实现了推送逻辑的解耦、调用方式的统一以及整体效率的提升。
架构部分示例
我们目前的服务架构采用多语言协作的形式:
- Go 负责核心业务逻辑(如社交模块核心服务、任务引擎)
- PHP 承担后台管理系统、运营控制台
- Java 则专注在通用能力服务的封装,例如推送服务、推荐服务等
可以说是“集各家所长”,各服务通过高效的通信协议完成协作。 推送单独剥离出去后 我们不同服务直接调用大概就是这样的模式:
┌────────────┐ ┌──────────────┐
│ 后台服务 ├─────────▶│ │
└────────────┘ │ │
│ │
┌────────────┐ gRPC │ 推送服务模块 │
│ 游戏服务 ├─────────▶│(Java 实现) │
└────────────┘ │ │
│ │
┌────────────┐ │ │
│ 任务服务 ├─────────▶│ │
└────────────┘ └─────┬────────┘
│
┌─────────────────────────────────────┐
│ 第三方推送厂商(网易/融云/极光/Firebase)│
└─────────────────────────────────────┘
grpc方案选定
在推送服务刚拆分出去的时候,我们一开始还是采用了 HTTP 接口(REST API)的方式和推送服务模块进行对接。但是由于各业务服务由不同的语言实现、不同的团队负责,接口对接变得格外麻烦。字段一变、结构一改,就要拉一轮会同步,前后端和不同团队之间经常为了一个参数名反复确认,开发效率大打折扣。
特别是当接口稍微复杂一点,比如要传附加字段、走不同推送策略的时候,不同团队的理解经常出现偏差,频繁的改动导致维护成本越来越高。
后来我们技术老大一锤定音:服务间直接走 gRPC。接口统一由 .proto 文件定义,双方各自生成代码,字段变动直接对齐,省去来回确认的过程,开发效率也大大提升。
那为什么不是 JSON-RPC?
其实在选型初期,我们也讨论过是否使用 JSON-RPC,毕竟它结构简单、上手快、数据可读性强。但考虑到我们项目的实际情况,最终还是没有采用,主要原因有三点:
- 团队多语言协作:gRPC 原生支持 Go、Java、PHP 等主流语言,能自动生成接口代码,而 JSON-RPC 通常需要我们手动维护请求结构,协作成本高。
- 接口强约束不够:JSON-RPC 接口没有统一的“强类型定义”,字段变动容易出错,不像 gRPC 有
.proto文件统一规范。 - 性能不够优:推送场景请求频繁,我们更需要一个轻量、高效的通信方式。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(消息结构)
步骤三:实现 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();
}
}
大概的代码目录如下:
3. 启动服务进行验证
运行 GrpcPushServer.java,我们就可以看到控制台输出:
gRPC 推送服务已启动,监听端口 50051...
此时 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.gopush_grpc.pb.go
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
Java 控制台也会显示:
接收到推送请求:uid=10086, title=系统通知, ...
到此,我们就完成了一次完整的 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 来进行服务间调用。
这个时候,我们其实还没明确地说要做“微服务”——只是觉得代码太乱、耦合太深,需要拆一下,后来每个模块都独立了,再后来就接了注册中心、做了链路追踪、统一了限流认证... 然后就变成了微服务架构模式。
所以,我们不是「决定做微服务」才去拆服务哈,而是在不断优化项目结构、解决实际问题的过程中,一步步把服务变“微”了。