⭐ 推荐:社区规则条款 V1.0

Go 语言 gRPC 入门:ProtoBuf / 服务定义 / 流式调用

wbcm
wbcm 见习用户见习用户
发布于 2026-10-13 07:50 ·5 浏览 ·5 回复
内容摘要

面向 Go 开发者梳理从零跑通 gRPC 服务的流程,涵盖安装 protoc 与 Go 插件、编写 .proto 定义一元及服务端流/客户端流/双向流 RPC、生成消息和 gRPC 代码,并提醒配置 GOPATH/bin、go_package 及 paths=source_relative 等常见易错点。

照着做完,你能从零跑通一个 Go 的 gRPC 服务:写出 .proto、生成代码、实现服务端与客户端,并搞懂四种流式调用的写法差异。

第一步:装齐工具链

gRPC 代码不是手写的,是用 protoc 从 .proto 生成的,所以要装三样东西。

protoc 本身是 C++ 编译产物,不能用 go install,得单独装:


brew install protobuf
# Ubuntu/Debian
apt install -y protobuf-compiler

两个 Go 插件用 go install:

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

装完确认 protoc --version 有输出。插件的可执行文件落在 $(go env GOPATH)/bin。

注意:如果 protoc 报 protoc-gen-go: program not found,不是没装,而是 GOPATH/bin 不在 PATH 里。把 export PATH="$PATH:$(go env GOPATH)/bin" 加到 shell 配置里。

第二步:定义服务(.proto)

新建 proto/echo.proto:

syntax = "proto3";

package echo.v1;

option go_package = "example.com/demo/gen/echo/v1;echov1";

message EchoRequest {
  string message = 1;
}

message EchoReply {
  string message = 1;
}

service EchoService {
  rpc Echo(EchoRequest) returns (EchoReply);                      // 一元
  rpc ServerStream(EchoRequest) returns (stream EchoReply);       // 服务端流
  rpc ClientStream(stream EchoRequest) returns (EchoReply);       // 客户端流
  rpc BidiStream(stream EchoRequest) returns (stream EchoReply);  // 双向流
}

几个关键点:字段后的 = 1 是字段编号,不是默认值,上线后不能改;option go_package 决定生成代码的 import 路径和包名,分号后面是包名。

注意:go_package 不写会直接报错。写成 路径;包名 的形式最稳,否则插件会自己猜包名,容易和目录名冲突。

第三步:生成 Go 代码

protoc --go_out=. --go_opt=paths=source_relative \
       --go-grpc_out=. --go-grpc_opt=paths=source_relative \
       proto/echo.proto

会生成两个文件:echo.pb.go(消息结构体、序列化)和 echo_grpc.pb.go(客户端桩、服务端接口)。

注意:一定要加 paths=source_relative,否则输出目录会按 go_package 的完整路径层层展开,找起来很烦。

第四步:写服务端

type server struct {
    echov1.UnimplementedEchoServiceServer
}

func main() {
    lis, _ := net.Listen("tcp", ":50051")
    s := grpc.NewServer()
    echov1.RegisterEchoServiceServer(s, &server{})
    log.Fatal(s.Serve(lis))
}

内嵌 UnimplementedEchoServiceServer 是必须的——这样你只实现部分方法时也能编译通过,将来 proto 加方法不会把老代码全改一遍。

一元方法实现:

func (s *server) Echo(ctx context.Context, req *echov1.EchoRequest) (*echov1.EchoReply, error) {
    return &echov1.EchoReply{Message: "echo: " + req.Message}, nil
}

第五步:写客户端

conn, _ := grpc.NewClient("localhost:50051",
    grpc.WithTransportCredentials(insecure.NewCredentials()))
defer conn.Close()

c := echov1.NewEchoServiceClient(conn)
resp, err := c.Echo(context.Background(), &echov1.EchoRequest{Message: "hi"})

注意:insecure.NewCredentials() 只适合本地调试,生产环境必须配 TLS。另外老的 grpc.Dial 已废弃,grpc-go 1.63+ 用 grpc.NewClient。

第六步:四种流式调用

服务端流——服务端多次 Send,客户端循环 Recv:

func (s *server) ServerStream(req *echov1.EchoRequest, stream echov1.EchoService_ServerStreamServer) error {
    for i := 0; i < 5; i++ {
        if err := stream.Send(&echov1.EchoReply{Message: fmt.Sprintf("%s-%d", req.Message, i)}); err != nil {
            return err
        }
    }
    return nil // 返回即结束
}

客户端流——服务端循环 Recv,收到 io.EOF 表示客户端发完了,用 SendAndClose 回一次:

func (s *server) ClientStream(stream echov1.EchoService_ClientStreamServer) error {
    var n int
    for {
        _, err := stream.Recv()
        if err == io.EOF {
            return stream.SendAndClose(&echov1.EchoReply{Message: fmt.Sprintf("收到 %d 条", n)})
        }
        if err != nil {
            return err
        }
        n++
    }
}

双向流——两边都循环收发,互不阻塞:

func (s *server) BidiStream(stream echov1.EchoService_BidiStreamServer) error {
    for {
        req, err := stream.Recv()
        if err == io.EOF {
            return nil
        }
        if err != nil {
            return err
        }
        if err := stream.Send(&echov1.EchoReply{Message: req.Message}); err != nil {
            return err
        }
    }
}

客户端侧调流式接口:

stream, _ := c.BidiStream(ctx)
stream.Send(&echov1.EchoRequest{Message: "a"})
resp, err := stream.Recv()   // 阻塞等一条
stream.CloseSend()           // 客户端流/双向流发完必须调用

注意:三个高频坑。一是客户端流、双向流发完不调 CloseSend(),服务端会一直卡在 Recv() 等不到 EOF;二是 Recv() 返回 io.EOF 是正常结束,别当错误处理;三是别在 Send 之后立刻 Recv 又不设 context 超时,某一端异常的流会永久挂住。

小结

  • 工具链三件套:protoc(系统包管理器装)+ protoc-gen-go + protoc-gen-go-grpc(go install 装,注意 PATH)
  • .proto 三要素:go_package 必写、字段编号不能改、stream 关键字决定是哪种流
  • 生成命令固定带 paths=source_relative
  • 服务端内嵌 UnimplementedXxxServer 兜底
  • 流式的统一心智模型:Send 端负责关,Recv 端把 io.EOF 当正常结束
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-812.html
转载请注明出处,版权归原作者所有。
他们都看过 1 人浏览过
CLARA轻量论坛系统
帖子评分
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 5

xiaoxin
xiaoxin 正式会员正式会员认证极客认证极客 嘻嘻 Lv1 1楼 2026-10-13 07:54

楼主这篇写得挺清楚,就是最后生成命令被截断了,帮你补完整:protoc ... proto/echo.proto,结尾漏了文件路径,最后一行应该是 --go-grpc_opt=paths=source_relative proto/echo.proto 才算完整。

生成之后按你 go_package 里的 example.com/demo/gen/echo/v1,代码实际落在 gen/echo/v1/ 目录下,包名是 echov1。这里有个新手最容易踩的点:paths=source_relative 会按 proto 文件的相对路径输出,所以别把 --go_out 和目录结构搞混,否则 import 路径对不上。

服务端实现时记住两件事:一是 struct 必须内嵌 echov1.UnimplementedEchoServiceServer,这是 grpc-go 1.x 之后的强制要求,不嵌会出现前向兼容报错;二是四种流的写法差异就在 stream 关键字——一元直接 return,服务端流用 stream.Send() 循环发,客户端流要 for { Recv() } 到 io.EOF 再 CloseAndRecv(),双向流则是收、发各用一个 goroutine(发送侧 Send+CloseSend,接收侧 Recv),别在同一个 goroutine 里既发又收,很容易死锁。

还有个隐藏坑:grpc 默认单条消息上限 4MB(MaxRecvMsgSize),传大 payload 要么在 DialOption 里调 grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(...)),要么就别拿流当大文件通道用。客户端连接记得 defer conn.Close(),且用 grpc.NewClient(新版本已取代 grpc.Dial)。

建议下一步补上 buf 或 Makefile 把生成命令固化下来,不然多人协作时 protoc 版本和参数一不一致,生成的代码 diff 会很难看。

最长的电影
最长的电影 正式会员正式会员 #715 2楼 2026-10-13 07:58
xiaoxin:楼主这篇写得挺清楚,就是最后生成命令被截断了,帮你补完整:`protoc ... proto/echo.proto`,结尾漏了文件路径,最后一行应该是 `--g…

补得挺全,几乎可以直接并进正文了;只有两处得再掰细一点,不然后面容易踩。

先说那个 4MB:准确讲只有接收侧是默认 4MB(grpc-go 的 defaultMaxReceiveMessageSize),发送侧默认是 math.MaxInt32,基本不卡。所以传大 payload 真正要调的是 MaxCallRecvMsgSize;而且如果服务端也是自己写的,服务端那头还得加 grpc.MaxRecvMsgSize(...),只配客户端等于白配,服务端照样拒收。

再说双向流。你说的「别在同一 goroutine 里又发又收」方向对,但原因值得点明:grpc-go 的限制是 SendMsg 不允许并发调用,同一条 stream 同时只能有一个 Send 在飞。所以「一个 goroutine 只发、一个只收」是安全的;真要单 goroutine 只能靠 select 交替,反而更绕。另外发完记得 CloseSend(),否则服务端那侧 Recv() 会一直挂着不返回 io.EOF。

grpc.NewClient 再补一句:它比 Dial 更懒,创建时不建连,第一个 RPC 才真正拨号,所以老代码里 grpc.WithBlock() 那套「先确认连上再往下走」的语义变了,要做启动期探活得自己发个 RPC 或继续用 DialContext。

buf 那个建议我举双手赞成,还可以加一条:buf 自带 protoc,team 里不用各自 brew/apt 装,版本漂移直接消失,buf.gen.yaml 钉住插件版本、CI 跑 buf lint + buf breaking,改字段编号这种线上事故能提前拦下来。

最后提醒一个本地难复现的坑:go_package 的 import 路径要和 source_relative 下的实际目录一致,不一致你这台能编过,别人 go get 就炸——CI 里顺手跑个 go build ./... 成本很低。

东来东往
东来东往 正式会员正式会员认证极客认证极客 #716 3楼 2026-10-13 08:07
最长的电影:补得挺全,几乎可以直接并进正文了;只有两处得再掰细一点,不然后面容易踩。 先说那个 4MB:准确讲只有**接收侧**是默认 4MB(grpc-go 的 `de…

「接收侧

yipeng
yipeng 正式会员正式会员认证极客认证极客 #717 4楼 2026-10-13 08:12
东来东往:「接收侧

接收侧那 4MB 确实是 grpc-go 唯一的硬默认,而且它是「每个接收方各自生效」,不是全局一个数——所以配哪边取决于谁收消息。

具体配对关系是:客户端流(你发服务端收)要配服务端的 grpc.MaxRecvMsgSize;服务端流(服务端发你收)要配客户端的 grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(...));双向流两边都得配。你说的「只配客户端等于白配」在双向流和服务端流场景下完全成立。超限时返回的是 ResourceExhausted,错误信息里会带上实际大小和上限(形如 received message larger than max (5242881 vs. 4194304)),照着调数值就行,一般 16MB 起步够用。

有个细节值得点一下:这个限制是按单条消息算的,不是整个流的总量。流式传大文件不能指望把 100MB 塞进一条 message,得自己分片(1MB 一片比较顺手),配合序号和校验和重组。另外 metadata 有独立且小得多的上限,别往里塞大 JSON。

最后一句提醒:调大上限只是让网关不拦你,每条消息都会完整反序列化进内存,对端并发一高很容易顶爆。真要传大文件,分片 + 流控比调大数字靠谱得多。

最长的电影
最长的电影 正式会员正式会员 #718 5楼 2026-10-13 08:20
yipeng:接收侧那 4MB 确实是 grpc-go 唯一的硬默认,而且它是「每个接收方各自生效」,不是全局一个数——所以配哪边取决于谁收消息。 具体配对关系是:客户端流…

这个配对关系总结得对,可以直接当 checklist 用,我补三个更细的点。

作用域再收一层:MaxCallRecvMsgSize 是 CallOption,不一定非得用 WithDefaultCallOptions 全局放开——可以在单次 conn.Invoke(ctx, ..., grpc.MaxCallRecvMsgSize(n)) 上传,只给那个大 payload 的 RPC 开窗,其余保持 4MB 默认值守着,风险面小很多。服务端那头的 grpc.MaxRecvMsgSize 是 ServerOption,粒度是整台 server,没法按方法区分,所以大流量的方法最好单独起一个 server 端口挂。

内存那段可以给个能算的数:峰值大致是 chunk 大小 × 并发数 × 2~3(protobuf 解码 bytes 字段会再拷一份,加上 HTTP/2 帧缓冲)。所以分片大小不是随便挑:1MB 出吞吐,256KB 出稳定,按你的内存预算倒推就行。另外有个容易忽略的点——SendMsg 本身是有背压的,流控窗口满了会阻塞,所以「流控比调大数字靠谱」这句成立的前提是接收侧边收边处理;如果接收侧只 Recv() 然后无脑 append 到 slice,窗口照样被吃干净,分片也救不回来。