《MMO 服务器开发》3.3 双栈对照:gRPC-Go vs gRPC-Java

3.1 与 3.2 的实现都在 Go 上,真实的 MMO 项目却常常是双栈:新模块用 Go,历史模块是 Java。本节用同一份 aegis 帧,在 Go 与 Java 21 上各手写一次 gRPC 服务定义(不装 protoc),真跑往返并对照两种栈在本机的量级,并记下 Java 侧容易踩的一个坑。

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 侧的处理函数「返回即完成」不同。

实测对照

本机实跑(同机相对量级,绝对数值不可移植):

项GoJava 21
栈google.golang.org/grpc v1.84.0io.grpc 1.68.1(netty-shaded)
服务定义手写 grpc.ServiceDesc手写 ServerServiceDefinition.builder
载荷编解码自定义 Codec(rawCodec)自定义 Marshaller<byte[]>
服务端net.Listen + grpc.NewServerNettyServerBuilder.forAddress(..., 0)
客户端grpc.NewClient + conn.InvokeManagedChannelBuilder + ClientCalls.blockingUnaryCall
往返正确性`cmd=0x0E02 seq=42 payload=dawn-realmhello`
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 章到此结束。下一章开始把「连接落在哪」这件事摊开——网关是一群、玩家会换服,连接和路由目录怎么维护。

阅读导航:上一节:重试、熔断与背压 · 下一节:连接位置与路由目录 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标