搞Go微服务的,迟早会撞上gRPC这个东西。我第一次认真接触它是在给一个内部网关做改造的时候,当时REST接口越堆越臃肿,接口文档靠人肉维护,客户端和服务端各写一套DTO,字段对不齐是常事。后来核心链路全部换成了gRPC,接口定义收敛成一个proto文件,两端代码直接生成,Payload体积降了六成以上。这篇文章算是我的入门复盘,把从零搭一个gRPC服务的过程、思路和踩过的坑都整理一遍,适合刚入门Go、准备搞微服务,或者只是想搞明白“gRPC到底比REST好在哪”的读者。
1. 先搞清楚:gRPC到底是什么,值得学吗
1.1 一句话定义,以及它选型的底层逻辑
gRPC是Google开源的一个高性能RPC框架,跑在HTTP/2之上,默认用Protocol Buffers(简称protobuf)做序列化协议。所谓RPC,就是远程过程调用——你写代码的时候像调本地函数一样调用一个远程服务,网络细节全被框架藏起来了。但gRPC不是简单的“把函数名和参数传过去”,它有一套完整的契约体系:先用proto文件把接口、字段、枚举、错误码全部定义清楚,再通过工具生成客户端和服务端代码,两端拿到的是同一份契约,天然不会出现字段错位。
之所以选择protobuf而不是JSON,本质上是在“可读性”和“效率”之间做取舍。JSON是纯文本,人类能直接读懂,调试方便,但同样的数据结构在传输时要重复携带大量字段名,比如{"name": "world"}这一段,光键名就占了近一半的字节。protobuf走的是二进制编码,字段名编译期就映射成了数字编号,线上传输的基本只有值和编号,体积小、编解码纯CPU操作,不加压缩都比JSON快不少。在微服务这种高频调用的场景里,积少成多,带宽和延迟的收益会非常明显。
我个人的理解是,gRPC和REST不是简单的“替代”关系,而是不同层级的产品。REST更偏向“资源”和“无状态”,适合对外暴露API、被浏览器或第三方直接调用;gRPC更偏向“服务间通信”,适合内部系统之间高密度、低延迟的调用。你在网上搜“golang八股文”的时候会发现,微服务相关的问题基本绕不开这两种协议的对比,这也是面试官最爱问的点之一。
1.2 和REST/JSON比,到底差在哪
为了让你直观感受差异,我拿一个真实的业务请求举例。假设我们要创建一个用户,请求带name、email、age三个字段。用REST+JSON大概长这样:
POST /api/v1/users Content-Type: application/json { "name": "Alice", "email": "alice@example.com", "age": 28 }换成gRPC的话,请求参数是proto里定义好的CreateUserRequest,三个字段都有编号也有类型,编码成二进制后发送。网络传输上,同样的内容,JSON可能几百字节,protobuf只有几十字节。当然,单次请求这点差距不算什么,但一天几十亿次调用,差距就变成实打实的带宽账单和响应时间了。
再从工程规范的角度看,REST接口很容易出现“文档和代码不一致”的问题。接口文档写一个样,服务端实现另一个样,客户端再猜一个样。gRPC的proto文件本身就是唯一的真相来源,字段加不加、类型改不改,编译期就能发现,省掉了大量对接口的扯皮。
下面这个表格是我整理的两者关键差异,也是面试常考的点:
| 对比维度 | REST + JSON | gRPC + protobuf |
|---|---|---|
| 传输协议 | HTTP/1.1,文本为主 | HTTP/2,二进制 |
| 数据格式 | JSON,可读性好 | protobuf,不可直接读 |
| 接口契约 | 无强制,靠文档约定 | proto文件强约束 |
| 性能表现 | 编解码慢,体积大 | 编解码快,体积小 |
| 浏览器兼容 | 天然支持,curl就能调 | 需要额外工具(grpcurl等) |
| 流式支持 | 有限,SSE算半流式 | Unary、Server/Client/Bidirectional流 |
| 多语言互通 | 任何语言都能解析JSON | 需要protobuf支持,但生态覆盖很广 |
表格列出来之后你会发现,REST强在“通用和简单”,gRPC强在“性能和契约”。没有绝对的谁更优,只有你当前场景更适合谁。
1.3 什么时候不应该用gRPC
接触新技术的时候,学会“什么时候别用”比学会“怎么用”更重要。gRPC并不是万能药,我见过不少项目强行上了gRPC,最后维护成本反而更高。
第一种场景是浏览器或移动端直接调用。浏览器虽然现在支持HTTP/2,但要直接调gRPC还是需要一个叫gRPC-Web的代理层,网络中间还要处理CORS、鉴权这些,折腾一圈,体验反而不如REST。如果你的服务是要暴露给App或网页前台,老老实实写REST。
第二种场景是接口频率极低、数据结构极不稳定的时候。比如一个内部管理后台,一天调用几百次,JSON的可读性优势就远大于那点性能差异。这种情况下押注proto的版本演化,可能会把自己锁死。
第三种场景是团队里没人熟悉protobuf,而且短期内没有学习计划。proto语法虽然不难,但引入之后会带来一层额外的“代码生成”心智负担,新人上手成本比直接写REST要高。工具链选型不是技术问题,是团队成本问题。
我通常的建议是:核心链路、高频调用、强契约需求,上gRPC;低频对外接口、调试频繁、团队规模小,先用REST把业务跑起来,等量级上来了再局部改造。
2. 动手前的准备:工具链和版本匹配
2.1 三样必备工具:Go、protoc、两个插件
gRPC的Go开发环境准备其实非常轻量。前提是你的Go版本在1.21以上,当前Go 1.24的稳定版也已经发布了,直接用新版本不会有兼容性问题。你的机器上需要装三样东西:
第一是protoc编译器,它负责把.proto文件解析成目标语言代码。protoc是用C++写的,官方在各平台都提供了预编译的二进制包,直接下载解压就能用,不需要自己编译。这里顺便提一个很多人遇到的困惑:热搜里那个“gRPC在Windows下Visual Studio编译”的帖子,其实是C++场景下的问题,Go用户完全不需要走源码编译这条路,下载release版二进制就行。
第二是protoc-gen-go插件,它解析proto里的message定义,生成结构体、字段访问方法、序列化代码。安装命令很直接:
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest第三是protoc-gen-go-grpc插件,它解析proto里的service定义,生成客户端和服务端的接口代码。安装命令:
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest装完之后把$(go env GOPATH)/bin加到你的PATH里。验证方式是执行protoc-gen-go --version,如果能正常打印版本号,说明插件已经就位。这步如果跳过,后面的生成命令会报protoc-gen-go: program not found or is not executable,这是新手最常见的问题之一。
2.2 版本匹配是第一个大坑
我强调一下版本匹配,因为网上大量教程和图谱里的代码对不上,十有八九都出在这。gRPC的Go库这几年API变动比较多,最典型的就是新旧两套protobuf API并存的历史问题。
老的API在github.com/golang/protobuf下,新的API在google.golang.org/protobuf下。从v1.20开始,官方已经全面迁移到新API,代码生成也是新风格。如果你看到教程里还在导入github.com/golang/protobuf/proto,那多半是两三年前的旧文,跑起来可能会遇到类型不兼容的问题。
另一个大坑是grpc.Dial和grpc.NewClient的区别。在grpc-go v1.64之前,大家写客户端连接都用grpc.Dial;从v1.64开始,推荐使用grpc.NewClient,grpc.Dial被标记为废弃。如果你用的新版本库,还按老教程写grpc.Dial,代码能跑但会有Deprecated警告,而且连鉴权方式也变了,老写法grpc.WithInsecure()在新版本里干脆被移除了,必须用credentials/insecure包装一下。
我的建议是:插件直接装最新版,grpc库也用最新版,写代码时以官方文档的示例为准,不要拿两年前的博客当标准。版本兼容性这种问题,踩一次坑就够了,没必要反复踩。
3. HelloWorld全流程:proto、Server、Client一次跑通
3.1 项目结构和proto定义
先建立项目目录,我习惯叫grpc-helloworld,整体结构长这样:
grpc-helloworld/ ├── go.mod # module example.com/grpc-helloworld ├── proto/ │ └── helloworld.proto ├── server/ │ └── main.go └── client/ └── main.go先初始化go module:
go mod init example.com/grpc-helloworld然后写proto/helloworld.proto,这个文件就是我们接口契约的核心。
syntax = "proto3"; package helloworld; option go_package = "example.com/grpc-helloworld/proto"; service Greeter { rpc SayHello(HelloRequest) returns (HelloReply); } message HelloRequest { string name = 1; } message HelloReply { string message = 1; }解释几个关键点。syntax = "proto3"声明用的是proto3语法,proto2现在基本不推荐新项目使用。package helloworld是proto内部的命名空间,用来避免不同proto文件之间的类型冲突。最容易被忽略的是option go_package,它决定了生成的Go代码放在哪个目录、import的时候是什么路径,必须和go.mod里的module前缀一致,否则生成代码后import会直接失败。
字段后面的= 1、= 2是字段编号,在protobuf二进制编码里,字段名不会出现在线上,只有编号和值,这就是它体积小的原因。编号一旦确定,就不要随意修改,否则老的客户端会解析错字段,这是兼容性的核心。
3.2 用protoc生成Go代码
在项目根目录执行:
protoc --go_out=paths=source_relative:. --go-grpc_out=paths=source_relative:. proto/helloworld.proto--go_out负责生成message相关的代码,--go-grpc_out负责生成service相关的代码,这两类代码是分开的,都要指定。paths=source_relative的意思是生成文件跟随proto文件所在目录,也就是直接在proto/目录下生成helloworld.pb.go和helloworld_grpc.pb.go。
生成完之后,跑一下go mod tidy把依赖拉下来。我实际跑的时候 import 的是example.com/grpc-helloworld/proto,包内公开的符号是GreeterServer、HelloRequest这些结构体。
这里有个小提醒:如果执行生成命令前忘记设置go_package,protoc会直接报错,提示无法确定Go的import路径。所以每次新建proto文件,第一件事就是把go_package写好。
3.3 实现Server端
项目有了proto代码,接下来就是写业务逻辑了。服务端要做三件事:实现proto生成的接口、启动TCP监听、用grpc.Server把监听和实现绑在一起。
package main import ( "context" "log" "net" "google.golang.org/grpc" pb "example.com/grpc-helloworld/proto" ) type server struct { pb.UnimplementedGreeterServer } func (s *server) SayHello(ctx context.Context, req *pb.HelloRequest) (*pb.HelloReply, error) { log.Printf("Receive name: %s", req.GetName()) return &pb.HelloReply{Message: "Hello " + req.GetName()}, nil } func main() { lis, err := net.Listen("tcp", ":50051") if err != nil { log.Fatalf("failed to listen: %v", err) } s := grpc.NewServer() pb.RegisterGreeterServer(s, &server{}) log.Printf("server listening at %s", lis.Addr()) if err := s.Serve(lis); err != nil { log.Fatalf("failed to serve: %v", err) } }重点说两个地方。第一,server结构体必须嵌入pb.UnimplementedGreeterServer。这是proto生成代码里的一个“默认实现”,作用是当新版本proto增加了方法而你还没实现时,服务器不会直接崩溃,而是返回一个未实现错误。新版生成的代码强制要求嵌入这个字段,不写的话RegisterGreeterServer可能编译不过。
第二,SayHello的方法签名要和生成代码里定义的一致,参数里有context.Context,返回值必须是(*pb.HelloReply, error)。新增其他方法、用别的返回值组合,编译都会失败。这就是强类型的好处,接口契约错了根本跑不起来。
3.4 实现Client端
客户端代码的核心是建立连接、生成客户端stub、调用远程方法。我在代码里加了用户名参数的支持,方便你传不同名字测试。
package main import ( "context" "log" "os" "time" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" pb "example.com/grpc-helloworld/proto" ) func main() { conn, err := grpc.NewClient( "localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials()), ) if err != nil { log.Fatalf("did not connect: %v", err) } defer conn.Close() client := pb.NewGreeterClient(conn) ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() name := "world" if len(os.Args) > 1 { name = os.Args[1] } resp, err := client.SayHello(ctx, &pb.HelloRequest{Name: name}) if err != nil { log.Fatalf("could not greet: %v", err) } log.Printf("Greeting: %s", resp.GetMessage()) }这段代码对应新版本grpc-go的标准写法:grpc.NewClient新建连接,WithTransportCredentials(insecure.NewCredentials())表示不加密通信,本地测试用。如果服务端配置了TLS,这里就要替换成对应的证书credentials。
给每个请求加context.WithTimeout是一个非常值得养成的习惯。gRPC调用默认没有超时限制,如果服务端卡死,客户端连接会一直挂着,在高并发场景下就是一堆“僵尸goroutine”,内存和连接数都会被拖垮。这是一行代码就能避免的事故。
3.5 跑起来验证
打开两个终端,一个跑服务端,一个跑客户端:
# 终端1 go run server/main.go # 终端2 go run client/main.go Alice正常情况下看到服务端输出Receive name: Alice,客户端输出Greeting: Hello Alice,整个链路就通了。
我还建议你试一下流式调用,不过那是后面的事。先把这个最朴素的Unary调用跑通,再进阶就不慌了。
4. 入门后必须搞懂的几个设计点
4.1 四种服务类型,不只是“一问一答”
很多新手以为gRPC只能像普通HTTP接口那样请求一次返回一次,其实那是四种类型里最简单的一种。我见过一些系统在需要推送数据时,还是用轮询硬撑着,其实gRPC原生支持流式通信。四种类型分别是:
Unary(一元调用)就是我们刚刚写的SayHello,客户端发一个请求,服务端返回一个响应。适合普通查询、小规模写操作。
Server streaming(服务端流)是客户端发一个请求,服务端可以持续返回多个响应。适合服务端主动推送、大结果集分批返回。比如你订阅一个实时价格信息,服务端每次价格变动就推一条消息过来,客户端像听广播一样持续接收。
Client streaming(客户端流)反过来,客户端持续发多个请求,服务端收完后统一返回一个最终响应。适合上传批量数据、日志聚合分析。比如你上报一万条行为日志,不用等一条一条的应答,全部发完服务端最后告诉你“这批数据我入库了”。
Bidirectional streaming(双向流)是两端同时持续收发,服务端和客户端都是流式的。适合做实时聊天、协同编辑、AI对话这种交互密集的场景。
四种类型的proto定义区别只在stream关键字上,比如rpc Chat(stream ChatRequest) returns (stream ChatReply)。但代码生成和实现复杂度会高一个台阶,建议把Unary和Server streaming先练熟,再碰双向流。
4.2 proto里的字段规则和风格建议
proto文件写起来不难,但坑在细节。一句话总结:字段编号、字段类型、字段个数,这三个东西是线上稳定性的大半。
字段编号一旦上线,就别改。改编号意味着老版本客户端还在按老编号解析数据,新服务端按新编号编码,两边根本对不上。我在生产环境见过一次失误,直接把一个接口的兼容性打崩了,排查了半小时才定位到是字段编号被人改了。新增字段只能增加新的编号,永远不要复用。
再说oneof和repeated。repeated相当于Go里的切片,适合表达列表;oneof表示多选一,适合表达“手机号和邮箱二选一”这种互斥场景。这两个语法都值得提前掌握,写接口的时候比自造一堆is_additional字段优雅得多。
命名风格上,proto官方推荐message和字段统一用lower_snake_case,生成到Go代码里会自动转成大驼峰,看着很别扭但这是规范。你如果非要用大驼峰写字段,编译虽然能过,生成代码的样式会很怪,而且社区里绝大多数工具链都假设你遵循snake_case。
4.3 错误处理、超时和拦截器
gRPC的错误处理和普通HTTP完全不同,它不依赖HTTP状态码,而是用google.golang.org/grpc/status和codes包。服务端可以这样返回业务错误:
import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) func (s *server) SayHello(ctx context.Context, req *pb.HelloRequest) (*pb.HelloReply, error) { if req.GetName() == "" { return nil, status.Error(codes.InvalidArgument, "name cannot be empty") } return &pb.HelloReply{Message: "Hello " + req.GetName()}, nil }客户端在另一端通过status.FromError拿到错误码做分支判断。这里的关键好处是,错误码是一等公民,不像REST那样要在业务状态码和HTTP状态码之间来回映射,微服务间对错误类型的识别干净很多。
拦截器(Interceptor)是gRPC里特别重要的扩展点,有点类似于中间件。你可以为服务端和客户端分别注册UnaryInterceptor和StreamInterceptor,用来统一做日志、鉴权、限流和耗时统计。一个最简的日志拦截器大概长这样:
func loggingInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { start := time.Now() resp, err := handler(ctx, req) log.Printf("%s duration=%s error=%v", info.FullMethod, time.Since(start), err) return resp, err }然后用grpc.NewServer(grpc.UnaryInterceptor(loggingInterceptor))初始化。很多公司把全链路trace、统一鉴权这些能力都做在拦截器里,业务代码里完全不掺和这些横切逻辑,这是gRPC项目结构化程度普遍高于REST项目的原因之一。
5. 常见问题与排查实录
5.1 问题速查表:我踩过的坑,你直接避开
从刚入门到现在,我把最常见的几个问题整理成一张速查表,基本覆盖90%的HelloWorld阶段问题:
| 现象 | 根因 | 解决方案 |
|---|---|---|
protoc: command not found | protoc没有安装或不在PATH | 下载release版二进制,加入PATH后重开终端 |
protoc-gen-go: program not found | Go插件没装或GOPATH/bin不在PATH | 执行go install,检查$(go env GOPATH)/bin |
unable to determine Go import path | proto文件缺go_package | 在proto里添加option go_package |
connection refused | 服务端没启动,或端口、监听地址写错 | 确认服务端在跑,netstat -ano看端口 |
nilresponse和error同时出现 | handler里返回了nil error但resp为nil | 检查方法返回值,resp和error必须二选一 |
context deadline exceeded | 调用超时时间过短,或服务端响应慢 | 调大超时时间,排查服务端瓶颈 |
| 运行老教程代码报Deprecated | grpc-go版本过新 | 改用grpc.NewClient+credentials/insecure |
| 生成的代码引入旧版proto包 | 教程和当前生态版本不匹配 | 统一用google.golang.org/protobuf |
这里面最坑的是“nil response和error同时出现”这个,我在帮同事查一个灰度问题时找了半天,最后发现是handler的某个分支里直接把resp留成nil就return了。gRPC的客户端看到resp=nil, err=nil时,会以为调用成功了但没拿到数据,表现非常隐蔽。
5.2 我排查gRPC问题的习惯
排查gRPC问题,我的习惯是先分清是“契约层”问题还是“传输层”问题。
契约层问题通常发生在序列化和反序列化阶段,报错信息里能看到proto相关的描述。这时候我会拿proto文件重新生成代码,然后对比生成后的结构体是否和预期一致。经常出现的一种情况是,代码没重新生成,本地还在用老结构体,新字段传过去直接被丢弃。
传输层问题集中在连接建立、超时和流式传输上。我一般会先用grpcurl这个工具直接访问服务端,绕过自己的客户端代码,判断问题出在服务端还是客户端侧。比如连接不上时,先用grpcurl -plaintext localhost:50051 list看看服务端暴露了哪些方法,如果这里能列出来,说明服务端是正常的,问题多半在客户端配置上。
还有一个习惯是打日志不要懒。拦截器里把FullMethod、耗时、错误码打出来,比任何追踪工具都直观。微服务环境下,调用链长了之后,每个节点的日志质量直接决定你能不能在十分钟内定位问题。
5.3 Windows环境下的特别说明
热搜里那个“grpc在Windows下Visual Studio编译”的问题,我多说一句。如果是C++项目想用gRPC,在Windows下用Visual Studio编译确实要折腾CMake、依赖库、vcpkg这一套,比较痛苦。但Go项目不需要碰这些,Go的grpc-go是纯Go实现的,go get直接拉源码编译,和操作系统无关。
所以如果你是Go用户,看到那条热搜不用慌。Windows下要提醒的只有两件事:一是PATH分隔符和Linux不同,设置GOPATH/bin时注意别漏了分号;二是下载protoc的zip包后如果是“裸目录”解压,记得把bin/protoc.exe所在目录加进PATH,而不是把zip整体解压到某个不起眼的角落就忘了。
6. 写在最后的实操体会
6.1 顺手工具与日常调优
开发效率这块,我只推荐两个工具,都是我自己一直在用的。
第一个是grpcurl,典型的命令行gRPC客户端。你可以不写任何代码就调用gRPC接口,接口记得加-plaintext跳过TLS校验。查看服务端有哪些方法可以用list,直接调方法用invoke,还支持-d参数传JSON格式的请求体。日常调试、验证环境是否有问题,一个命令就搞定。
第二个是evans,交互式的gRPC客户端,比grpcurl更顺手一点。支持REPL风格的交互,你可以像连数据库一样进入交互模式,有补全提示,反复调同一个接口的时候效率很高。团队里有女生或者新人上手时,evans对降低命令记忆成本帮助很大。
另外想聊一下连接池这件事。grpc-go的客户端连接本质上是支持多路复用的,一条TCP连接上能并发跑很多请求,所以千万不要在每次RPC调用前都新建一个连接、调用完就Close。正确的做法是复用连接,连接由grpc.ClientConn内部管理连接池和状态检测。我在生产环境见过一些项目把NewClient写在业务函数里,一次请求就建一个连接,性能直接被打穿,这是最典型的gRPC滥用场景。
6.2 两条学习路线建议,以及我踩坑后的总结
很多人在热词里搜“golang学习路线”和“golang八股文”,关于gRPC的部分,我的建议是分两步走。
第一步,把今天的HelloWorld完全吃透。手写proto、生成代码、跑通Server和Client,然后尝试再加一个方法、加一个字段、改成服务端流,体会一下代码生成对接口演化的影响。这个过程能训练出“先定义契约,再写代码”的肌肉记忆。
第二步,去读grpc-go官方仓库里的examples。这比看任何二手中文教程都准确,NewClient、拦截器、流式、metadata验证这些进阶用法,官方示例都是完整可运行的。面试聊gRPC时你不会只停留在“我知道有四种类型”,而是能聊出连接复用怎么实现、拦截器怎么注册、错误码怎么映射,这是八股文里不会细写的部分。
说到底,gRPC不只是把你常用的“HTTP调用”换成“RPC调用”,它逼着你重新思考接口设计这件事。你把契约定义清楚了,工具生成代码,两端强制对齐,剩下大部分精力都能集中在业务逻辑上。这套工作流一旦跑顺,你再回去写REST接口,会觉得处处都是需要人工维护的坑。我个人体会最深的一点是:先把proto说清楚,再动手写业务,这个顺序直接决定了一个项目后面要踩多少雷。