照着做完,你能从零跑通一个 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 当正常结束