gRPC-Gateway 实战——让 gRPC 服务同时提供 RESTful API
gRPC-Gateway 实战——让 gRPC 服务同时提供 RESTful API
适读人群:在用 gRPC 但也需要对外提供 HTTP 接口的 Go 工程师 | 阅读时长:约18分钟 | 核心价值:用一套代码同时暴露 gRPC 和 REST 接口,彻底解决内外部协议割裂的问题
一次让我意识到"两套接口"有多痛的架构评审
2023年底,我们公司开始推 gRPC 改造,内部服务全面切 gRPC。推进了几个月,遇到了一个棘手的问题:
手机 App 和 H5 前端没法直接调 gRPC。浏览器环境对 HTTP/2 的支持有限,移动端也不想引 gRPC 客户端库。最重要的是,外部合作方的系统是 REST,不可能要求他们全改 gRPC。
我们面临三个选择:
- 维护两套代码:gRPC 服务 + 独立的 REST API 服务
- 在 gRPC 服务前加一个 BFF(Backend For Frontend)做协议转换
- 用 gRPC-Gateway,让 gRPC 服务自动生成 REST 接口
我选了第三种,试用了一周后爱不释手。
今天把这套方案完整讲清楚。
gRPC-Gateway 是什么
gRPC-Gateway 是一个 protoc 插件,通过在 proto 文件里添加 HTTP 路由注解,自动生成一个 REST → gRPC 的反向代理代码。
工作原理如下:
Client (HTTP/JSON)
→ gRPC-Gateway (自动生成的 HTTP server)
→ 转换为 Protobuf
→ gRPC Server最大的好处: 你只需要写一套 gRPC 业务逻辑,REST 接口是自动生成的,不需要额外维护。
环境安装
# 安装 gateway 插件
go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@latest
go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@latest
# 下载 Google API 注解定义(需要这些文件才能用 http 注解)
# 建议直接从 grpc-gateway 仓库复制 googleapis 目录项目目录结构:
gateway-demo/
├── proto/
│ ├── googleapis/ # Google API 注解文件
│ │ └── google/api/
│ │ ├── annotations.proto
│ │ └── http.proto
│ └── user/
│ └── user.proto
├── pb/
├── server/
│ └── main.go
├── gateway/
│ └── main.go
└── go.modProto 文件:加上 HTTP 注解
这是关键步骤,在 proto 文件里声明每个 RPC 方法对应的 HTTP 路由:
syntax = "proto3";
option go_package = "gateway-demo/pb/user;user";
package user;
import "google/api/annotations.proto";
message GetUserRequest {
int64 user_id = 1;
}
message UserInfo {
int64 user_id = 1;
string username = 2;
string email = 3;
int32 age = 4;
}
message CreateUserRequest {
string username = 1;
string email = 2;
int32 age = 3;
}
message CreateUserResponse {
int64 user_id = 1;
string message = 2;
}
message ListUsersRequest {
int32 page = 1;
int32 page_size = 2;
string keyword = 3;
}
message ListUsersResponse {
repeated UserInfo users = 1;
int32 total = 2;
}
message DeleteUserRequest {
int64 user_id = 1;
}
message DeleteUserResponse {
string message = 1;
}
service UserService {
// 获取用户 - 映射为 GET /v1/users/{user_id}
rpc GetUser(GetUserRequest) returns (UserInfo) {
option (google.api.http) = {
get: "/v1/users/{user_id}"
};
}
// 创建用户 - 映射为 POST /v1/users
rpc CreateUser(CreateUserRequest) returns (CreateUserResponse) {
option (google.api.http) = {
post: "/v1/users"
body: "*" // * 表示 request body 映射到整个 CreateUserRequest
};
}
// 查询用户列表 - 映射为 GET /v1/users,支持 query string
rpc ListUsers(ListUsersRequest) returns (ListUsersResponse) {
option (google.api.http) = {
get: "/v1/users"
// page, page_size, keyword 会自动从 query string 读取
};
}
// 删除用户 - 映射为 DELETE /v1/users/{user_id}
rpc DeleteUser(DeleteUserRequest) returns (DeleteUserResponse) {
option (google.api.http) = {
delete: "/v1/users/{user_id}"
};
}
}生成代码(需要同时生成 gRPC 代码和 Gateway 代码):
protoc \
-I ./proto \
-I ./proto/googleapis \
--go_out=./pb --go_opt=paths=source_relative \
--go-grpc_out=./pb --go-grpc_opt=paths=source_relative \
--grpc-gateway_out=./pb --grpc-gateway_opt=paths=source_relative \
proto/user/user.proto执行后会额外生成 user.pb.gw.go 文件,里面包含 HTTP handler 的注册代码。
gRPC 服务端(正常实现,不需要改动)
package main
import (
"context"
"fmt"
"log"
"net"
"sync"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "gateway-demo/pb/user"
)
type UserServer struct {
pb.UnimplementedUserServiceServer
mu sync.RWMutex
users map[int64]*pb.UserInfo
nextID int64
}
func NewUserServer() *UserServer {
return &UserServer{
users: make(map[int64]*pb.UserInfo),
nextID: 1,
}
}
func (s *UserServer) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.UserInfo, error) {
s.mu.RLock()
defer s.mu.RUnlock()
user, ok := s.users[req.UserId]
if !ok {
return nil, status.Errorf(codes.NotFound, "用户 %d 不存在", req.UserId)
}
return user, nil
}
func (s *UserServer) CreateUser(ctx context.Context, req *pb.CreateUserRequest) (*pb.CreateUserResponse, error) {
if req.Username == "" {
return nil, status.Error(codes.InvalidArgument, "用户名不能为空")
}
s.mu.Lock()
defer s.mu.Unlock()
id := s.nextID
s.nextID++
s.users[id] = &pb.UserInfo{
UserId: id, Username: req.Username, Email: req.Email, Age: req.Age,
}
return &pb.CreateUserResponse{UserId: id, Message: "创建成功"}, nil
}
func (s *UserServer) ListUsers(ctx context.Context, req *pb.ListUsersRequest) (*pb.ListUsersResponse, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var users []*pb.UserInfo
for _, u := range s.users {
users = append(users, u)
}
return &pb.ListUsersResponse{Users: users, Total: int32(len(users))}, nil
}
func (s *UserServer) DeleteUser(ctx context.Context, req *pb.DeleteUserRequest) (*pb.DeleteUserResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.users[req.UserId]; !ok {
return nil, status.Errorf(codes.NotFound, "用户 %d 不存在", req.UserId)
}
delete(s.users, req.UserId)
return &pb.DeleteUserResponse{Message: "删除成功"}, nil
}
func main() {
lis, _ := net.Listen("tcp", ":50051")
s := grpc.NewServer()
pb.RegisterUserServiceServer(s, NewUserServer())
fmt.Println("gRPC 服务启动,监听 :50051")
s.Serve(lis)
}Gateway 服务(核心代码)
package main
import (
"context"
"fmt"
"log"
"net/http"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
pb "gateway-demo/pb/user"
)
func main() {
ctx := context.Background()
ctx, cancel := context.WithCancel(ctx)
defer cancel()
// 创建 Gateway Mux
// runtime.WithErrorHandler 自定义错误响应格式
mux := runtime.NewServeMux(
// 自定义 HTTP header → gRPC metadata 的映射
runtime.WithIncomingHeaderMatcher(customHeaderMatcher),
// 自定义错误处理
runtime.WithErrorHandler(customErrorHandler),
)
opts := []grpc.DialOption{
grpc.WithTransportCredentials(insecure.NewCredentials()),
}
// 注册 Gateway handler,连接到 gRPC 服务
if err := pb.RegisterUserServiceHandlerFromEndpoint(
ctx, mux, "localhost:50051", opts,
); err != nil {
log.Fatalf("注册 Gateway handler 失败: %v", err)
}
// 启动 HTTP 服务
fmt.Println("Gateway 服务启动,监听 :8080")
if err := http.ListenAndServe(":8080", mux); err != nil {
log.Fatalf("HTTP 服务启动失败: %v", err)
}
}
// customHeaderMatcher 把自定义 HTTP header 传递到 gRPC metadata
// 默认只传递以 "Grpc-Metadata-" 开头的 header
func customHeaderMatcher(key string) (string, bool) {
switch key {
case "Authorization", "X-Request-Id", "X-Trace-Id":
return key, true
default:
return runtime.DefaultHeaderMatcher(key)
}
}
// customErrorHandler 自定义错误响应格式
func customErrorHandler(
ctx context.Context,
mux *runtime.ServeMux,
marshaler runtime.Marshaler,
w http.ResponseWriter,
r *http.Request,
err error,
) {
// 把 gRPC 错误码转换为 HTTP 状态码并自定义响应体
// 默认已经做了转换,这里只是示例如何自定义
runtime.DefaultHTTPErrorHandler(ctx, mux, marshaler, w, r, err)
}测试(先启动 gRPC 服务,再启动 Gateway):
# 终端1
go run server/main.go
# 终端2
go run gateway/main.go
# 终端3:用 curl 测试 REST 接口
# 创建用户
curl -X POST http://localhost:8080/v1/users \
-H "Content-Type: application/json" \
-d '{"username":"张三","email":"zs@example.com","age":28}'
# 获取用户
curl http://localhost:8080/v1/users/1
# 查询列表
curl "http://localhost:8080/v1/users?page=1&page_size=10"
# 删除用户
curl -X DELETE http://localhost:8080/v1/users/1进阶:同一个端口同时服务 gRPC 和 HTTP
很多场景下,你希望只开一个端口,根据请求协议(HTTP/1.1 vs HTTP/2)分发:
package main
import (
"context"
"crypto/tls"
"log"
"net"
"net/http"
"strings"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
"google.golang.org/grpc"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
pb "gateway-demo/pb/user"
)
func main() {
grpcServer := grpc.NewServer()
pb.RegisterUserServiceServer(grpcServer, NewUserServer())
ctx := context.Background()
gwMux := runtime.NewServeMux()
pb.RegisterUserServiceHandlerServer(ctx, gwMux, NewUserServer())
// 混合 handler:根据 Content-Type 分发
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.ProtoMajor == 2 && strings.HasPrefix(r.Header.Get("Content-Type"), "application/grpc") {
grpcServer.ServeHTTP(w, r)
} else {
gwMux.ServeHTTP(w, r)
}
})
// 用 h2c 支持明文 HTTP/2(生产环境应该用 TLS)
h2Server := &http2.Server{}
lis, _ := net.Listen("tcp", ":8080")
log.Println("gRPC + REST 混合服务启动,监听 :8080")
http.Serve(lis, h2c.NewHandler(handler, h2Server))
}踩坑实录
坑1:路由参数名必须和 proto 字段名完全一致
现象: proto 里字段是 user_id,路由里写成 {userId}(驼峰),请求进来后 user_id 始终是0。
原因: gRPC-Gateway 路由参数名必须和 proto 字段名一字不差,包括下划线风格。
解法: 统一用 snake_case:get: "/v1/users/{user_id}",不要用驼峰。
坑2:gRPC 错误码到 HTTP 状态码的默认映射有坑
现象: gRPC 返回 codes.InvalidArgument,客户端收到 HTTP 400,这是对的。但 codes.NotFound 返回的是 404,内部错误 codes.Internal 返回 500,这也对。但 codes.Unauthenticated 返回的是 401,codes.PermissionDenied 返回 403——这两个经常搞混。
原因: gRPC-Gateway 内置的映射遵循 Google API 规范:
codes.Unauthenticated→ 401(没登录/token 无效)codes.PermissionDenied→ 403(登录了但没权限)
解法: 服务端严格按语义返回对应的 gRPC 错误码,不要随便用 codes.Unknown 或 codes.Internal 兜底。
坑3:body: "*" 导致 URL 路径参数无法传入
现象: proto 是这样的:
rpc UpdateUser(UpdateUserRequest) returns (UserInfo) {
option (google.api.http) = {
put: "/v1/users/{user_id}"
body: "*"
};
}结果 user_id 从路径里取不到,始终是0。
原因: body: "*" 表示整个 request body 映射到请求 message,但路径参数({user_id})会覆盖 body 里同名字段。问题在于:如果请求 body 里也有 user_id,Gateway 不知道该用哪个。
解法: 更新接口时,把路径参数单独列出来,不用 *:
rpc UpdateUser(UpdateUserRequest) returns (UserInfo) {
option (google.api.http) = {
put: "/v1/users/{user_id}"
body: "user_info" // 只把 user_info 子消息映射到 body
};
}gRPC-Gateway vs 独立 REST 服务
| 对比维度 | gRPC-Gateway | 独立 REST 服务 |
|---|---|---|
| 代码维护 | 一套代码 | 两套代码 |
| 灵活性 | 受限于 proto 注解 | 完全自由 |
| 适合场景 | 接口结构简单、以资源为中心 | 需要复杂 HTTP 行为(文件上传、特殊 header) |
| 性能 | 多一层转换,略有损耗 | 直接处理,更高效 |
我的建议: 80%的场景用 gRPC-Gateway 完全够用,节省大量重复代码。只有需要文件上传、WebSocket、或者 HTTP 协议特性深度定制的场景,才考虑独立 REST 服务。
