gRPC 转 RESTful JSON API
Table of Contents
- 1. 安装
- 2. 书写规则
- 3. 生成 stub
- 4. http 服务
- 5. 和 gRPC 服务整合
- 6. 关联的资料汇总
- 7. FAQ
- 7.1. 编译报错
cannot use myFilter (type func(context.Context, http.ResponseWriter, protoreflect.ProtoMessage) error) as type func(context.Context, http.ResponseWriter, protoiface.MessageV1) error in argument to "github.com/grpc-ecosystem/grpc-gateway/runtime".WithForwardResponseOption
- 7.2. 自定义返回 body 格式
- 7.3. 跨域问题
- 7.4. 解决返回数据时空字段不返回的问题
- 7.1. 编译报错
gRPC 很诱人,它兼具了易用性和高效,同时支持多重编程语言。但是老的业务还是要兼容到传统的 RESTful JSON API。
grpc-gateway 的出现是为了解决这个问题(gRPC 服务可以提供 HTTP + JSON 的接口),在 service 中附加少量的 HTTP 语义,生成反向代理。
本文档偏向于实操和最佳实践,而且假定读者了解了 protobuf、gRPC、gRPC-go 相关的知识(如果不知道可以查看 gRPC 专题)。
1. 安装
go get -u github.com/grpc-ecosystem/grpc-gateway/protoc-gen-grpc-gateway
在已有的 service proto 上附加 http 转换规则,规范在这里:https://github.com/googleapis/googleapis/blob/master/google/api/http.proto#L46
2. 书写规则
service Messaging { rpc GetMessage(GetMessageRequest) returns (Message) { option (google.api.http) = { get: "/v1/{name=messages/*}" }; } } message GetMessageRequest { string name = 1; // Mapped to URL path. } message Message { string text = 1; // The resource content. }
这样会将 HTTP API GET /v1/messages/123456
转换为 gRPC 的 GetMessage(name: "messages/123456")
请求。
- 如果没有 HTTP 请求 body,query params 会自动转换为 gRPC req
- 使用
additional_bindings
option 可以将多个请求可以转发到同一个 gRPC
还有很多规则,文档里说的比较清楚。
gateway 依赖于 google.api.annotations.proto,需要在 proto 头部引入,才可以使用 http option:
import "google/api/annotations.proto";
也就是说依赖于 googleapi,你需要把 googleapi 置于项目中。
git clone https://github.com/googleapis/googleapis.git
3. 生成 stub
以上全部操作完毕之后,生成 gRPC 和 gateway stub,比如:
protoc -I=libproto -I=googleapis --go_out=plugins=grpc:libproto --go_opt=paths=source_relative libproto/*.proto protoc -I=libproto -I=googleapis --grpc-gateway_out=logtostderr=true,paths=source_relative:gwproto libproto/*.proto
注意额外链接了 googleapis。
4. http 服务
需要另外起一个 http:
import ( "context" "log" "net/http" "github.com/grpc-ecosystem/grpc-gateway/runtime" "google.golang.org/grpc" gw "/gateway/code/path" ) func main() { ctx := context.Background() ctx, cancel := context.WithCancel(ctx) defer cancel() mux := runtime.NewServeMux() opts := []grpc.DialOption{grpc.WithInsecure()} err := gw.RegisterStaffRPCHandlerFromEndpoint(ctx, mux, "localhost:50051", opts) if err != nil { log.Fatal(err) } http.ListenAndServe(":8080", mux) }
5. 和 gRPC 服务整合
上面的方法虽然可行,但部署的时候是两个进程,一个 gateway 服务进程,一个 rpc 服务进程。这样在生产化部署时,会有一定的部署成本(容器化之后,一个容器只能有一起服务在前台运行)。
所以,要一个进程同时起 rpc 服务和 gateway 服务。解决办法是起一个 http/2 的服务,然后根据协议的不同分发给不同的服务,类似:
https://github.com/grpc/grpc-go/blob/master/server.go#L871
if r.ProtoMajor == 2 && strings.HasPrefix( r.Header.Get("Content-Type"), "application/grpc") { grpcServer.ServeHTTP(w, r) } else { yourMux.ServeHTTP(w, r) }
范例:https://github.com/ntons/libra/blob/master/librad/main.go
6. 关联的资料汇总
- grcp-gateway https://github.com/grpc-ecosystem/grpc-gateway
- metadata https://github.com/grpc/grpc-go/blob/master/Documentation/grpc-metadata.md
- customizing your gateway https://grpc-ecosystem.github.io/grpc-gateway/docs/customizingyourgateway.html
- gRPC Transcoding https://github.com/googleapis/googleapis/blob/master/google/api/http.proto#L44 service option 规则
7. FAQ
7.1. 编译报错 cannot use myFilter (type func(context.Context, http.ResponseWriter, protoreflect.ProtoMessage) error) as type func(context.Context, http.ResponseWriter, protoiface.MessageV1) error in argument to "github.com/grpc-ecosystem/grpc-gateway/runtime".WithForwardResponseOption
因为 gateway 用的是 github.com/golang/protobuf/proto
,而 proto 一般默认引入的是 google.golang.org/protobuf/proto
.
两个需要统一一下。
7.2. 自定义返回 body 格式
gRPC gateway 默认情况下返回的是 Resp Struct 对应的 JSON,出错时返回 code,error,message
三个字段。如果想要统一返回值,则需要自定义返回 body。
runtime.WithProtoErrorHandler
可以修改错误返回时的返回格式。但是正常情况下的返回值并没有一个直接修改的地方。
runtime.WithForwardResponseOption
会在正常返回时回,它可以修改 resp body,但是他只是正常返回的一部分,你可以成追加,而非覆写。
比如在 WithForwardResponseOption
中调用 w.Write
会导致返回两份数据。按照官方的设计,这个回调似乎是来追加 Header 的,并不是修改 resp body。
但是我发现一个现象,WithProtoErrorHandler 只要出错就会被调用,即便是在 WithForwardResponseOption
中返回错误也是一样的。
既然这样,就有一种 hack 办法来解决这个问题:正常的返回抛出错误,然后统一在 WithProtoErrorHandler
中区分处理,也就是说把 WithProtoErrorHandler 作为一个 proxy。
代码如下:
type StandardResp struct { Code int `json:"code"` Data interface{} `json:"data"` Error string `json:"error"` } const ( proxyFlag = "__succ__" ) func HttpSuccHandler(ctx context.Context, w http.ResponseWriter, p proto.Message) error { resp := StandardResp{ Code: 0, Data: p, Error: "", } bs, _ := json.Marshal(&resp) return errors.New(proxyFlag + string(bs)) } func HttpErrorHandler(ctx context.Context, mux *runtime.ServeMux, m runtime.Marshaler, w http.ResponseWriter, r *http.Request, err error) { w.Header().Set("Content-Type", "application/json") // success proxy raw := err.Error() if strings.HasPrefix(raw, proxyFlag) { raw = raw[len(proxyFlag):] w.Write([]byte(raw)) return } // normal error s, ok := status.FromError(err) if !ok { s = status.New(codes.Unknown, err.Error()) } resp := StandardResp{ Code: 1, Data: nil, Error: s.Message(), } bs, _ := json.Marshal(&resp) w.Write(bs) }
Mux 代码:
gwMux := runtime.NewServeMux( runtime.WithForwardResponseOption(HttpSuccHandler), runtime.WithProtoErrorHandler(HttpErrorHandler), )
按照这个思路验证是可行的,那么会不会有什么副作用呢?比如说改变了 WithForwardResponseOption 的行为。
在 gateway 中 ForwardResponseMessage
是转换 gRPC 到 REST 的处理函数。WithForwardResponseOption 也是在这个函数中调用的,当出错时会调用 HTTPError
httpError 也就是 WithProtoErrorHandler
。看源码可以知道,大部分行为是在 WithForwardResponseOption 之前执行完了的,除了 handleForwardResponseTrailer
。
而且在如果有多个 WithForwardResponseOption
时,其中的一个报错,其他的将不会被执行。
总结下来这种方法可行,但是你要充分了解这么做带来的副作用,以免行为不符合预期。
按说 grpc-gateway 这应该是一个很常规的需求,不知道为什么没有预留出 hook 供开发者自定义回包。我提了 issue,看后面会不会有更加优雅的就解决办法:
7.3. 跨域问题
gateway 的 server 本质上还是标准的 http.Server
,在 ServeHTTP
之前加一个跨域头即可:
w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Request-Method", "GET, POST, PUT, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
7.4. 解决返回数据时空字段不返回的问题
protobuf 生成的 pb.go 中 struct 字段都是用 json:",omitempty"
修饰,这会导致在 gateway 转发返回时 json marshal 空的字段(初始值,0,空 slice 等)不返回。
解决的办法是使用 jsonpb marshal,jsonpb 提供了 EmitDefaults
选项来控制是否解析 omitempty
字段。具体如下:
func sendProtoMessage(resp proto.Message, w http.ResponseWriter) { w.Header().Set("Content-Type", "application/json; charset=utf-8") m := jsonpb.Marshaler{EmitDefaults: true} m.Marshal(w, resp) // You should check for errors here }
如果使用 gateway 的 WithMarshalerOption
会更简单一些:
gwmux := runtime.NewServeMux(runtime.WithMarshalerOption(runtime.MIMEWildcard, &runtime.JSONPb{OrigName: true, EmitDefaults: true}))