3.3 双栈对照:gRPC-Go vs gRPC-Java
3.1 和 3.2 的实现都落在 Go 上。但真实的 MMO 项目常常是双栈的:新写的网关、场景服、匹配器用 Go,历史悠久的账号、计费、运营后台是 Java。跨服 RPC 要在两种栈之间互通,就必须回到最底层的两个问题——载荷格式和服务定义。
载荷格式已经有答案:aegis 帧是字节流,与语言无关,Go 能编,Java 也能编。剩下的就是服务定义。常规做法是写 .proto 再用 protoc 生成两侧代码,但本书的环境里没有 protoc,也不打算装。好消息是,gRPC 的服务定义本来就只是「方法名 + 两个编解码器」,两种栈都允许手写——Go 用 grpc.ServiceDesc + 自定义 Codec,Java 用 MethodDescriptor + 自定义 Marshaller。
这一节就用同一份 aegis 帧,在 Go 和 Java 21 上各实现一次 gRPC 往返,真跑并对照。
同一份帧,两种栈:手写服务定义
先看 Go 侧的服务定义胶水。它做两件事:一个把 []byte 原样收发的 Codec,和一个手写的 ServiceDesc(方法 /aegis.Ping/Call)。载荷就是 aegis 帧,帧里的 cmd / seq / flags 由业务自己解释,gRPC 只负责把字节搬过去。
下面这段是 L1:
// file: internal/rpc/grpc.go
// tier: L1
// verify: go test ./internal/rpc/
package rpc
import (
"context"
"fmt"
"google.golang.org/grpc"
"google.golang.org/grpc/encoding"
)
// rawCodec 把 aegis 帧当作 []byte 原样收发,不引入任何 IDL。
// 这样 Go 与 Java 两侧都能用同一份 aegis 帧做载荷,对照才公平。
type rawCodec struct{}
func (rawCodec) Name() string { return "aegis" }
func (rawCodec) Marshal(v any) ([]byte, error) {
switch b := v.(type) {
case []byte:
return b, nil
case *[]byte:
return *b, nil
default:
return nil, fmt.Errorf("rawCodec: cannot marshal %T", v)
}
}
func (rawCodec) Unmarshal(data []byte, v any) error {
if b, ok := v.(*[]byte); ok {
*b = data
return nil
}
return fmt.Errorf("rawCodec: cannot unmarshal into %T", v)
}
// PingHandler 是服务端处理函数的签名:收到一帧,回一帧。
type PingHandler func(context.Context, []byte) ([]byte, error)
// PingServiceDesc 手写服务描述:/aegis.Ping/Call,收发都是原始帧。
// 不依赖 protoc —— 这正是 3.3 双栈对照要用的最小服务定义。
var PingServiceDesc = grpc.ServiceDesc{
ServiceName: "aegis.Ping",
HandlerType: (*any)(nil),
Methods: []grpc.MethodDesc{{
MethodName: "Call",
Handler: func(srv any, ctx context.Context, dec func(any) error, _ grpc.UnaryServerInterceptor) (any, error) {
var in []byte
if err := dec(&in); err != nil {
return nil, err
}
h, ok := srv.(PingHandler)
if !ok {
return nil, fmt.Errorf("rpc: bad handler %T", srv)
}
return h(ctx, in)
},
}},
}
func init() { encoding.RegisterCodec(rawCodec{}) }
注意 HandlerType: (*any)(nil) 这个写法:gRPC 要求服务实现「实现某个接口」,这里把接口声明成空接口 any,于是任何处理函数都满足,注册就不会被拦。
Go 侧:grpc.NewServer + grpc.NewClient
Go 侧的往返测试起一个真服务端(net.Listen 到 127.0.0.1 随机端口 + grpc.NewServer),客户端用 grpc.NewClient + conn.Invoke 发一帧 aegis,断言响应帧的 cmd 由 0x0E01 变成 0x0E02、seq 原样返回。
下面这段是 L2:
// file: internal/rpc/grpc_roundtrip_test.go
// tier: L2
// verify: go test ./internal/rpc/ -run TestGRPCRoundTrip -v
package rpc
import (
"context"
"testing"
"time"
)
// TestGRPCRoundTrip 是 3.3 的 Go 侧:用同一份 aegis 帧做 gRPC 往返,
// 与 Java 侧(L2J)实现完全相同的服务端变换,供对照。
func TestGRPCRoundTrip(t *testing.T) {
addr, stop := startPing(t, func(ctx context.Context, in []byte) ([]byte, error) {
f, err := Decode(in)
if err != nil {
return nil, err
}
// 服务端变换:cmd 加一(0x0E01 -> 0x0E02),seq/flags/payload 原样回
return Frame{Cmd: f.Cmd + 1, Seq: f.Seq, Flags: f.Flags, Payload: f.Payload}.Encode()
})
defer stop()
cc := dialPing(t, addr)
defer cc.Close()
req, _ := Frame{Cmd: 0x0E01, Seq: 42, Payload: []byte("dawn-realm|hello")}.Encode()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var out []byte
if err := cc.Invoke(ctx, "/aegis.Ping/Call", req, &out); err != nil {
t.Fatal(err)
}
resp, err := Decode(out)
if err != nil {
t.Fatal(err)
}
if resp.Cmd != 0x0E02 || resp.Seq != 42 || string(resp.Payload) != "dawn-realm|hello" {
t.Fatalf("bad resp %+v", resp)
}
t.Logf("go roundtrip ok: cmd=0x%04X seq=%d payload=%s", resp.Cmd, resp.Seq, resp.Payload)
const n = 2000
for i := 0; i < 200; i++ {
var o []byte
_ = cc.Invoke(ctx, "/aegis.Ping/Call", req, &o)
}
t0 := time.Now()
for i := 0; i < n; i++ {
var o []byte
if err := cc.Invoke(ctx, "/aegis.Ping/Call", req, &o); err != nil {
t.Fatal(err)
}
}
dt := time.Since(t0)
t.Logf("go %d roundtrips in %s -> %.1f us/call", n, dt, float64(dt.Microseconds())/float64(n))
}
startPing / dialPing 是 3.1 里已经给出的测试胶水,用 grpc.ForceServerCodec 和 grpc.ForceCodec 把 rawCodec 强制装上,避免走 content-subtype 协商。
Java 侧:NettyServerBuilder + 自定义 Marshaller(L2J)
Java 侧做一模一样的事,只是 API 换了一套。因为本机 JDK 只有 8,Java 代码经 Docker(maven:3.9-eclipse-temurin-21,JDK 21)真跑;代码放在 $HOME/.aegis-java/dist-ch03/(Colima 只挂 $HOME,/tmp 不挂)。服务定义用 MethodDescriptor + 自定义 Marshaller<byte[]> 手写,不装 protoc。
下面这段是 L2J:
// file: src/main/java/dev/aegis/dist/AegisGrpc.java
// tier: L2J
// verify: docker run --rm -v "$HOME/.m2-mmo:/root/.m2" -v "$HOME/.aegis-java/dist-ch03:/w" -w /w maven:3.9-eclipse-temurin-21 sh -c "mvn -B compile exec:java"
package dev.aegis.dist;
import io.grpc.*;
import io.grpc.stub.*;
import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder;
import java.io.*;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Arrays;
public final class AegisGrpc {
// aegis wire: length(4,BE)|cmd(2,BE)|seq(4,BE)|flags(1) + payload
static final int HEADER = 11;
static final int CMD_CROSS_CHAT_SEND = 0x0E01;
static final int CMD_CROSS_CHAT_RECV = 0x0E02;
static byte[] encode(int cmd, int seq, int flags, byte[] payload) {
ByteBuffer b = ByteBuffer.allocate(HEADER + payload.length).order(ByteOrder.BIG_ENDIAN);
b.putInt(7 + payload.length);
b.putShort((short) cmd);
b.putInt(seq);
b.put((byte) flags);
b.put(payload);
return b.array();
}
record Frame(int cmd, int seq, int flags, byte[] payload) {}
static Frame decode(byte[] raw) {
ByteBuffer b = ByteBuffer.wrap(raw).order(ByteOrder.BIG_ENDIAN);
b.getInt(); // length
int cmd = b.getShort() & 0xFFFF;
int seq = b.getInt();
int flags = b.get() & 0xFF;
byte[] p = new byte[raw.length - HEADER];
b.get(p);
return new Frame(cmd, seq, flags, p);
}
static final MethodDescriptor.Marshaller<byte[]> BYTES = new MethodDescriptor.Marshaller<>() {
public InputStream stream(byte[] v) { return new ByteArrayInputStream(v); }
public byte[] parse(InputStream in) {
try { return in.readAllBytes(); } catch (IOException e) { throw new RuntimeException(e); }
}
};
// server transform: 0x0E01 -> 0x0E02, echo seq/flags/payload
static byte[] serve(byte[] raw) {
Frame f = decode(raw);
return encode(f.cmd() + 1, f.seq(), f.flags(), f.payload());
}
public static void main(String[] args) throws Exception {
MethodDescriptor<byte[], byte[]> md = MethodDescriptor.<byte[], byte[]>newBuilder()
.setType(MethodDescriptor.MethodType.UNARY)
.setFullMethodName("aegis.Ping/Call")
.setRequestMarshaller(BYTES).setResponseMarshaller(BYTES).build();
ServerServiceDefinition def = ServerServiceDefinition.builder("aegis.Ping")
.addMethod(md, ServerCalls.asyncUnaryCall((req, obs) -> {
obs.onNext(serve(req));
obs.onCompleted(); // unary 响应必须显式完成,否则客户端一直等
}))
.build();
Server server = NettyServerBuilder.forAddress(new InetSocketAddress("127.0.0.1", 0))
.addService(def).build().start();
int port = server.getPort();
ManagedChannel ch = ManagedChannelBuilder.forAddress("127.0.0.1", port).usePlaintext().build();
byte[] payload = "dawn-realm|hello".getBytes("UTF-8");
byte[] req = encode(CMD_CROSS_CHAT_SEND, 42, 0, payload);
byte[] resp = ClientCalls.blockingUnaryCall(ch, md, CallOptions.DEFAULT, req);
Frame rf = decode(resp);
boolean ok = rf.cmd() == CMD_CROSS_CHAT_RECV && rf.seq() == 42
&& Arrays.equals(rf.payload(), payload);
System.out.println("java roundtrip ok=" + ok + " respCmd=0x" + Integer.toHexString(rf.cmd())
+ " seq=" + rf.seq() + " payload=" + new String(rf.payload(), "UTF-8"));
int n = 2000;
for (int i = 0; i < 200; i++) ClientCalls.blockingUnaryCall(ch, md, CallOptions.DEFAULT, req);
long t0 = System.nanoTime();
for (int i = 0; i < n; i++) ClientCalls.blockingUnaryCall(ch, md, CallOptions.DEFAULT, req);
long dt = System.nanoTime() - t0;
System.out.printf("java %d roundtrips in %.1f ms -> %.1f us/call%n", n, dt / 1e6, dt / 1e3 / n);
ch.shutdownNow(); server.shutdownNow();
System.out.flush();
System.exit(ok ? 0 : 1);
}
}
依赖只有两个,版本 1.68.1,NettyServerBuilder 的 import 是 io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder:
<!-- file: pom.xml tier: L3 -->
<dependencies>
<dependency><groupId>io.grpc</groupId><artifactId>grpc-netty-shaded</artifactId><version>1.68.1</version></dependency>
<dependency><groupId>io.grpc</groupId><artifactId>grpc-stub</artifactId><version>1.68.1</version></dependency>
</dependencies>
<build><plugins>
<plugin><groupId>org.codehaus.mojo</groupId><artifactId>exec-maven-plugin</artifactId><version>3.5.0</version></plugin>
</plugins></build>
这里有一个真实踩过的坑:服务端 ServerCalls.asyncUnaryCall 的处理函数里,obs.onNext(...) 之后必须再调 obs.onCompleted()。只发 onNext 不结束,gRPC 认为响应还没发完,客户端会一直阻塞到超时——现象是「服务端明明返回了,客户端却挂住」。Java 的 unary 语义要求显式关闭调用,这一点和 Go 侧的处理函数「返回即完成」不同。
实测对照
本机实跑(同机相对量级,绝对数值不可移植):
| 项 | Go | Java 21 |
|---|---|---|
| 栈 | google.golang.org/grpc v1.84.0 | io.grpc 1.68.1(netty-shaded) |
| 服务定义 | 手写 grpc.ServiceDesc | 手写 ServerServiceDefinition.builder |
| 载荷编解码 | 自定义 Codec(rawCodec) | 自定义 Marshaller<byte[]> |
| 服务端 | net.Listen + grpc.NewServer | NettyServerBuilder.forAddress(..., 0) |
| 客户端 | grpc.NewClient + conn.Invoke | ManagedChannelBuilder + ClientCalls.blockingUnaryCall |
| 往返正确性 | `cmd=0x0E02 seq=42 payload=dawn-realm | hello` |
| 2000 次往返 | 约 0.60s,约 299 µs/次 | 约 0.97s,约 483 µs/次 |
两种栈都能用同一份 aegis 帧完成往返,服务端变换(cmd 加一、seq 原样回)结果完全一致。吞吐量级上 Go 侧在本机更省——这是同机相对量级,换机器、换 JDK、换 GC 参数都会变,不要把它当成「Go 比 Java 快」的结论。
本节小结 / 实测结论
- 跨服 RPC 的载荷复用 aegis 帧,与语言无关;服务定义可以手写,不必依赖
protoc。 - Go 侧靠
grpc.ServiceDesc+ 自定义Codec,Java 侧靠MethodDescriptor+ 自定义Marshaller,两边都能直接收发原始帧。 - Java 的 unary 服务端必须显式
obs.onCompleted(),否则客户端永久阻塞——这是双栈联调时最容易被误判成「网络问题」的坑。 - 实测:同一份帧、同一个变换,Go 与 Java 21 的往返结果一致;本机量级 Go 约 299 µs/次、Java 约 483 µs/次(同机相对量级,不可移植)。
第 3 章到此结束。下一章开始把「连接落在哪」这件事摊开——网关是一群、玩家会换服,连接和路由目录怎么维护。
阅读导航:上一节:重试、熔断与背压 · 下一节:连接位置与路由目录 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。