go通过grpc-gateway同时提供grpc/http服务的示例

新项目开发决定使用proto来定义api,服务同时提供grpc/http接口
然后通过proto生成swagger文件,导入到yapi中实现自动接口维护
由于之前项目未应用,于是写了个demo以作内部演示用,主要是介绍http rest接口的定义与请求的传参和 message 定义之间的关联

目录结构

1
2
3
4
5
6
7
8
├── gotest
│   └── api
│   ├── main.go
│   └── proto
│   ├── api.pb.go
│   ├── api.pb.gw.go
│   ├── api.proto
│   └── api_grpc.pb.go

proto文件

api.proto
api的定义,参考 http.proto
为直观演示,放飞自我定义的接口,勿喷

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
syntax = "proto3";

package api;

import "google/api/annotations.proto";

option go_package = "./;api";

service ApiService {
rpc Query(QueryRequest) returns (QueryResponse) {
option (google.api.http) = {
get: "/api/query/{id}",
additional_bindings {
get: "/api/query/{name=twofield/*}/{age}",
},
additional_bindings {
get: "/api/query/{name=manyfield/*/*}/{age}",
}
};
}
rpc Create(CreateRequest) returns (CreateResponse) {
option (google.api.http) = {
post: "/api/create/{foo}",
body: "*",
// 演示body的使用,同时说明additional_bindings可以定义多个,及一个rpc定义可以对应任意多个http方法
additional_bindings {
post: "/api/createV2/{foo}/{data.nick}",
body: "data",
},
additional_bindings {
post: "/api/createV3",
body: "baz",
},
additional_bindings {
post: "/api/createV4/{bar}",
body: "baz",
}
};
}
}

message Persion {
int64 id = 1;
int32 age = 2 [json_name = "myage"];
string name = 3;
string nick = 4;
}

message QueryRequest {
int64 id = 1 [json_name = "myid"];
int32 age = 2;
string name = 3 [json_name = "myname"];
}

message QueryResponse {
Persion data = 1;
}

message CreateRequest {
bool foo = 1;
int64 bar = 2;
string baz = 3 [json_name = "mybaz"];
Persion data = 4;
}

message CreateResponse {
Persion data = 1;
}

代码生成

annotations.proto 等依赖的 proto 文件如果没有放在同一文件夹,需要使用 -I 指定其路径,注意import是否已包含了部分路径

1
2
# 同时生成 pb,grpc,gateway 文件
gotest/apiprotoc -I=. -I={path to proto} --go_out=paths=source_relative:. --go-grpc_out=paths=source_relative:. --grpc-gateway_out=paths=source_relative:. *.proto

demo

main.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
package main

import (
"context"
"fmt"
"net"
"net/http"

api "gotest/grpc/api/proto"

"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"google.golang.org/grpc"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
)

type server struct {
api.UnimplementedApiServiceServer
}

func (s *server) Query(ctx context.Context, request *api.QueryRequest) (*api.QueryResponse, error) {
fmt.Printf("query %s \n", request)
return &api.QueryResponse{
Data: &api.Persion{
Id: request.Id + 10000,
Age: request.Age + 10000,
Name: request.Name + " response",
Nick: "",
},
}, nil
}

func (s *server) Create(ctx context.Context, request *api.CreateRequest) (*api.CreateResponse, error) {
fmt.Printf("create %s \n", request)
return &api.CreateResponse{}, nil
}

func main() {
// 8081
go startGrpcServer()

// 8080
startHttpServer()
}

func startGrpcServer() {
l, err := net.Listen("tcp", ":8081")
if err != nil {
panic(err)
}

// grpc server
grpcServer := grpc.NewServer()
api.RegisterApiServiceServer(grpcServer, &server{})

go func() {
err2 := grpcServer.Serve(l)
if err2 != nil {
fmt.Println(err2)
}
}()
}

func startHttpServer() {
f1 := func(ctx context.Context, mux *runtime.ServeMux, m runtime.Marshaler, writer http.ResponseWriter, request *http.Request, status int) {
fmt.Println("route err:", request.URL, status)
writer.WriteHeader(http.StatusNotFound)
_, _ = writer.Write([]byte(`{"code":10404,"msg":"wow"}`))
}
mux := runtime.NewServeMux(
runtime.WithRoutingErrorHandler(f1),
// runtime.WithErrorHandler(),
runtime.WithForwardResponseOption(func(ctx context.Context, writer http.ResponseWriter, message proto.Message) error {
fmt.Println("forward response:", message)
// 只能追加,不能修改
_, _ = writer.Write([]byte(`hook append `))
return nil
}),
// 任意mime类型都处理
runtime.WithMarshalerOption(runtime.MIMEWildcard, &runtime.JSONPb{
MarshalOptions: protojson.MarshalOptions{
// 设置为false,可使用json_name指定,否则只能使用message定义的字段(驼峰化)
UseProtoNames: false,
// EmitUnpopulated specifies whether to emit unpopulated fields. It does not
// emit unpopulated oneof fields or unpopulated extension fields.
// The JSON value emitted for unpopulated fields are as follows:
// ╔═══════╤════════════════════════════╗
// ║ JSON │ Protobuf field ║
// ╠═══════╪════════════════════════════╣
// ║ false │ proto3 boolean fields ║
// ║ 0 │ proto3 numeric fields ║
// ║ "" │ proto3 string/bytes fields ║
// ║ null │ proto2 scalar fields ║
// ║ null │ message fields ║
// ║ [] │ list fields ║
// ║ {} │ map fields ║
// ╚═══════╧════════════════════════════╝
// 设置为 true,即便字段为零值,也会输出,零值字段输出格式对应上表
EmitUnpopulated: true,
},
UnmarshalOptions: protojson.UnmarshalOptions{},
}),
)

opts := []grpc.DialOption{
grpc.WithInsecure(),
}

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

err := api.RegisterApiServiceHandlerFromEndpoint(ctx, mux, ":8081", opts)
if err != nil {
panic(err)
}

err = http.ListenAndServe(":8080", mux)
if err != nil {
panic(err)
}
}

验证

看代码,在接口处理方法中,打印了请求参数(调用String方法,零值将被忽略不打印),来演示入参的获取情况
结合使用了 json_name,不了解的可以参考上一篇 在go的protobuf中进行自定义json tag标记及使用
go run main.go

GET

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 除路径外无任何传参
# request对象只接收到了path中的参数 id
curl "localhost:8080/api/query/1" # 打印 query id:1

# 通过query string传参
# query string中的参数也被接收了
curl "localhost:8080/api/query/1?name=who&age=42" # 打印 query id:1 age:42 name:"who"

# 通过query string传参,同时尝试覆盖path中的参数
# path 中的参数值并未被覆盖
curl "localhost:8080/api/query/1?name=who&age=42&id=2" # 打印 query id:1 age:42 name:"who"

# 尝试使用 json_name 指定的名称传参
# json_name 指定的 myname 也可正常使用
curl "localhost:8080/api/query/1?myname=who&age=42" # 打印 query id:1 age:42 name:"who"

# 尝试使用 json_name 指定的名称传参的同时,也使用原始字段名传参
# json_name 指定的参数名和原始字段名同时使用没有报错,但多次调用后输出结果不一致,所以不要骚操作
curl "localhost:8080/api/query/1?myname=who&age=42&name=origin" # 打印 query id:1 age:42 name:"origin" / query id:1 age:42 name:"who"

# 一个字段的值包含path中的多个分段时
# name字段被指定等于两个字段,结果就是表现为 name = twofield/two,而 age = 18
curl "localhost:8080/api/query/twofield/two/18" # 打印 query age:18 name:"twofield/two"

curl "localhost:8080/api/query/manyfield/two/18" # 打印 {"code":5, "message":"Not Found", "details":[]}

curl "localhost:8080/api/query/manyfield/two/three/18" # 打印 query age:18 name:"manyfield/two/three"

POST

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# body:"*" 但不传body,尝试 query string 传参
# query string 传参无效,path中的foo(布尔值)被接收
curl -X POST "localhost:8080/api/create/true?bar=1" # 打印 create foo:true

# body:"*" 传body,只使用原始名称
# body中的传参未覆盖path中的值(foo);
curl -X POST "localhost:8080/api/create/true" -d '{"foo":false,"baz":"zzz","data":{"id":1,"nick":"nick"}}' # 打印 create foo:true baz:"zzz" data:{id:1 nick:"nick"}

# body:"*" 传body,使用 json_name 名
# json_name也可正常使用
curl -X POST "localhost:8080/api/create/true" -d '{"foo":false,"mybaz":"zzz","data":{"id":1,"nick":"nick"}}' # 打印 create foo:true baz:"zzz" data:{id:1 nick:"nick"}

# body:"*" 传body,使用 json_name 名的同时,也是用原始字段名传参
# 报错了,body中不能同时存在
curl -X POST "localhost:8080/api/create/true" -d '{"foo":false,"mybaz":"zzz","baz":"origin"}' # 打印 {"code":3,"message":"proto: (line 1:28): duplicate field \"baz\"","details":[]}%

# body:"data" 尝试query string 传参
# 同时接收了body/path/query string 中的值
curl -X POST "localhost:8080/api/createV2/true/nick?bar=1" -d '{"name":"me"}' # 打印 create foo:true bar:1 data:{name:"me" nick:"nick"}

# body:"data" 使用嵌套对象字段,以及body指定接收对象而非 *
# 只接收了data参数的值,但传参并非 data 对象,所以只接收了path中的参数
curl -X POST "localhost:8080/api/createV2/true/nick" -d '{"bar":42,"data":{"name":"who","myid":110,"age":18}}' # 打印 create foo:true data:{nick:"nick"}

# body是data对象,同时使用了json_name的名称,接收到了path以及body中的值
curl -X POST "localhost:8080/api/createV2/true/nick" -d '{"name":"who","id":1,"myage":18}' # 打印 create foo:true data:{id:1 age:18 name:"who" nick:"nick"}

其它骚操作

1
2
3
4
5
6
7
8
9
10
# body:"baz" 传递了json对象
# 报错了,因为 baz 字段的定义是 string
curl -X POST "localhost:8080/api/createV3" -d '{"id":1,"baz":"zzz"}' # 打印 {"code":3, "message":"json: cannot unmarshal object into Go value of type string", "details":[]}

# body:"baz" 传递字符串和query string
# 接收到了指定的字段信息以及query string值
curl -X POST "localhost:8080/api/createV3?bar=11" -d '"aaa"' # 打印 create bar:11 baz:"aaa"

# body:"baz" 同时指定了path,接收到了path及body中的信息
curl -X POST "localhost:8080/api/createV4/42" -d '"zzz"' # 打印 create bar:42 baz:"zzz"

总结

通过以上案例,结合proto定义,可直观的掌握如何应用grpc-gateway来同时提供http/grpc接口(grpc未测试)
简单总结如下:

  • method为get时,path及queryString中的参数都会被映射到 reques 对应的 message 字段
    • 传参字段名可以使用json_name指定名称,但不能与原字段名同时使用
  • method为post时,会忽略queryString中的传参,只接收 path 及 body 中的传参
    • option中省略了 body,则表示没有http请求体,参数只能通过 path 及 query string 传递,body 被忽略
    • option中指定body为 * 时,参数只能通过 path 及 body 传递,query string 被忽略
      • 所以如果使用 * ,要注意不能再通过 query string 传参了
    • option中指定body指定为指定字段时,path/query string/body中的值都可以被接收
    • json body中字段名可以使用json_name指定名称,但不能与原字段名同时使用

当然,也可以用用一个端口来监听,但需要根据http协议版本来区分是哪一类请求

1
2
3
4
5
if r.ProtoMajor == 2 && strings.HasPrefix(r.Header.Get("Content-Type"), "application/grpc") {
grpcServer.ServeHTTP(w, r)
} else {
mux.ServeHTTP(w, r)
}

挑战

自定义响应格式

大多数公司会定义rest api时,指定一堆错误码,并随响应返回,通常类似

1
2
3
4
5
6
7
{
"code": 0,
"msg":"OK",
"data": {
// 消息体
}
}

但直接使用grpc-gateway面临的挑战就是它直接返回了消息体,而未提供响应的hook来自定义响应体,错误时有方法,但正确响应时没有
同时开发者建议在response中定义相关的字段,是一个closed的issue #1610,目前没有其它好的方案直接使用
但是,如果这个接口不是直接对公网终端用户提供服务,前方有网关,或其它应用层处理,那么就可以在上层进行数据格式组织,即只对错误的情况预处理后返回,正确的留给上层包装
哪怕是通过runtime.WithForwardResponseOptionheader中将错误写入自定义 header这种骚操作呢,总是有办法处理的
比如 自定义错误

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
type httpError struct {
Code int32 `json:"code,omitempty"`
Message string `json:"message,omitempty"`
}
func grpcGatewayError(ctx context.Context, _ *runtime.ServeMux, marshaler runtime.Marshaler, w http.ResponseWriter, _ *http.Request, err error) {
s, ok := status.FromError(err)
if !ok {
s = status.New(codes.Unknown, err.Error())
}
httpError := httpError{Code: int32(s.Code()), Message: s.Message()}
details := s.Details()
for _, detail := range details {
if v, ok := detail.(*pb.Error); ok {
httpError.Code = v.Code
httpError.Message = v.Message
}
}
resp, _ := json.Marshal(httpError)
w.Header().Set("Content-type", marshaler.ContentType())
w.WriteHeader(runtime.HTTPStatusFromCode(s.Code()))
_, _ = w.Write(resp)
}

// 覆盖runtime的默认方法
// runtime.HTTPError = grpcGatewayError

其它的自定义函数可参考 Customizing your gateway

跨域

grpc也是http,http/2,所以可以在拦截器(interceptors)中进行跨域设置

参考链接

原文链接
http/grpc同一个端口
http.proto
grpc-gateway 自定义错误