Gin使用NoRoute实现默认路由踩坑总结
gin路由注册要求是互斥的,engine.NoRoute
可以提供默认路由的能力,
使用gin NoRoute对grpc gateway进行路由时却发生了正常返回结果但伴有404
status code的问题。
背景
某存储服务实现了两组grpc-gateway分别对外提供artifact服务与package服务,并使用Gin框架进行路由。
|
|
需求
现需要为此服务新增openapi路由,并且同样具有/v1 prefix,路由表如下所示:
实现方案
最初的想法是在最开头加上相应的route。
|
|
运行时报如下错误:
|
|
gin的router在匹配路由时并不像三层路由表按定义的顺序一层一层匹配,而注册一组互斥的(method, pattern)用以全局匹配。而上述两个pattern是存在包含关系的,因此报错path conflict。由于mux内的二级路由比较多样,因此我们希望通过默认路由对mux进行路由改造。gin提供了NoRoute
方法去处理匹配不上的路由,这正好可以满足此需求。
|
|
问题描述
经过本地测试,服务可以正常返回,但上线时UI并不能正常工作。进一步测试时发现虽然api返回了正确结果,但是status code返回404
,所以client无法正常工作。
|
|
问题排查
上述请求返回了正确结果,因此可以确定其已经被artifact handler的逻辑正确处理,显然status code被gin置成了404
。NoRoute
的doc如此描述:
|
|
一个合理的猜测是gin默认使用200
作为response status code,NoRoute
在处理匹配不上的路由同时,会将默认status code置为404
。
为了更深入探索此问题,做了如下三个实验
- Case1:将
/v1/artifacts
返回结果改造成internal error,观察status code。 - Case2:给mux添加一个自定义路由
/v1/artifacts/success
(裸handler),总是返回200
。 - Case3:给mux添加一个自定义路由
/v1/artifacts/fail
,总是返回500
。
|
|
上述请求的resp status code都是符合预期的。
|
|
上述现象基本表明在grpc ServiceHanlder中,如果处理正确,并不会显式向resp写200
,但出错时会显式写特定的status code。
源码追溯
gin侧
查看
NoRoute
配置的handler。1 2 3
func (engine *Engine) rebuild404Handlers() { engine.allNoRoute = engine.combineHandlers(engine.noRoute) }
从名字来看,
allNoRoute
像是当匹配不到路由时会执行的handler,我们搜一下其引用位置。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
func (engine *Engine) handleHTTPRequest(c *Context) { // 省略匹配路由的逻辑 // 省略HandleMethodNotAllowed处理逻辑 // ... c.handlers = engine.allNoRoute serveError(c, http.StatusNotFound, default404Body) } // ServeHTTP conforms to the http.Handler interface. func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) { c := engine.pool.Get().(*Context) c.writermem.reset(w) c.Request = req c.reset() // 置默认status code engine.handleHTTPRequest(c) engine.pool.Put(c) } func (w *responseWriter) reset(writer http.ResponseWriter) { w.ResponseWriter = writer w.size = noWritten w.status = defaultStatus // 200 } const ( noWritten = -1 defaultStatus = http.StatusOK )
ServeHTTP
调用handleHTTPRequest
,并且将默认status code设置为200
,路由匹配失败会进入到serveError
函数里。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
func serveError(c *Context, code int, defaultMessage []byte) { // 默认status改成了404 c.writermem.status = code // 执行handler c.Next() if c.writermem.Written() { return } // ... } // Next should be used only inside middleware. // It executes the pending handlers in the chain inside the calling handler. // See example in GitHub. func (c *Context) Next() { c.index++ for c.index < int8(len(c.handlers)) { // 调用注册的handler c.handlers[c.index](c) c.index++ } }
很明显这里将默认status code改成
404
,并去执行相应的handler,正常情况下handler会覆写resp里的status code,而grpc ServiceHandler看上去在执行成功后没有写200
。
Grpc-gateway侧
查看注册handler的逻辑,可以看到当handler返回错误后,会去调用
runtime.HTTPError
,而正确处理时调用runtime.ForwardResponseMessage
。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
func RegisterArtifactServiceHandlerServer(ctx context.Context, mux *runtime.ServeMux, server ArtifactServiceServer) error { mux.Handle("GET", pattern_ArtifactService_ListArtifacts_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { // ... if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return } // 调用ListArtifactHandler resp, md, err := local_request_ArtifactService_ListArtifacts_0(ctx, inboundMarshaler, server, req, pathParams) // 省略处理metadata逻辑 if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return } // 封装成http resp forward_ArtifactService_ListArtifacts_0(ctx, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) }) } forward_ArtifactService_ListArtifacts_0 = runtime.ForwardResponseMessage
HTTPError
将grpc code与http status code做了转换后,直接写到resp里,这解释了为何错误的状态码可以正常返回。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
// DefaultHTTPErrorHandler is the default error handler. // If "err" is a gRPC Status, the function replies with the status code mapped by HTTPStatusFromCode. // If "err" is a HTTPStatusError, the function replies with the status code provide by that struct. This is // intended to allow passing through of specific statuses via the function set via WithRoutingErrorHandler // for the ServeMux constructor to handle edge cases which the standard mappings in HTTPStatusFromCode // are insufficient for. // If otherwise, it replies with http.StatusInternalServerError. // // The response body written by this function is a Status message marshaled by the Marshaler. func DefaultHTTPErrorHandler(ctx context.Context, mux *ServeMux, marshaler Marshaler, w http.ResponseWriter, r *http.Request, err error) { // ... // grpc code向http code转换 st := HTTPStatusFromCode(s.Code()) if customStatus != nil { st = customStatus.HTTPStatus } // 显式写入status code w.WriteHeader(st) if _, err := w.Write(buf); err != nil { grpclog.Infof("Failed to write response: %v", err) } // ... }
最后看一看handler正确处理后,封装resp相关逻辑,只将handler返回的结果序列化写到
resp.Body
中,并没有显式写status code。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
// ForwardResponseMessage forwards the message "resp" from gRPC server to REST client. func ForwardResponseMessage(ctx context.Context, mux *ServeMux, marshaler Marshaler, w http.ResponseWriter, req *http.Request, resp proto.Message, opts ...func(context.Context, http.ResponseWriter, proto.Message) error) { // ... if err := handleForwardResponseOptions(ctx, w, resp, opts); err != nil { HTTPError(ctx, mux, marshaler, w, req, err) return } var buf []byte var err error if rb, ok := resp.(responseBody); ok { buf, err = marshaler.Marshal(rb.XXX_ResponseBody()) } else { buf, err = marshaler.Marshal(resp) } if err != nil { grpclog.Infof("Marshal error: %v", err) HTTPError(ctx, mux, marshaler, w, req, err) return } // 只marshal了结果写入了resp body,没有显式写status code if _, err = w.Write(buf); err != nil { grpclog.Infof("Failed to write response: %v", err) } // ... }
这时需要考虑是否有地方可以通过options来做一些trick,从上述代码我们可以看到
handleForwardResponseOptions
传入了resp,猜测这里面有文章。1 2 3 4 5 6 7 8 9 10 11 12 13 14
func handleForwardResponseOptions(ctx context.Context, w http.ResponseWriter, resp proto.Message, opts []func(context.Context, http.ResponseWriter, proto.Message) error) error { if len(opts) == 0 { return nil } // 这里的Opts实际上是mux.GetForwardResponseOptions()... // 可以在创建mux时通过runtime.WithForwardResponseOption自定义 for _, opt := range opts { if err := opt(ctx, w, resp); err != nil { grpclog.Infof("Error handling ForwardResponseOptions: %v", err) return err } } return nil }
因为执行到
ForwardResponseMessage
一定意味着service handler执行成功了,显然可以通过ForwardResoponseOptions
将200写到resp status code来解决此问题。
解决方法
创建mux时指定WithForwardResponseOption
向resp中写入200
status code。
|
|
测试一切正常。
后记
ResponseWriter标准
有小伙伴指出在http.ResposeWriter
接口中的Write
方法里有个重要说明:If WriteHeader has not yet been called, Write calls WriteHeader(http.StatusOK) before writing the data。这表明在没有显式写状态码时,调用Write
前会写个200。
|
|
这看似与上述排查结论相悖。但进一步看,这个结构是个nterface,在interface内声明这个规则并不能强制约束实现方去如此做。恰好gin Context
中的相关接口实现恰好打破了这个规则。
|
|
gin 对Write
装饰了一层写内部状态码的行为,用以对默认状态码的支持。
其他方案
后续跟web经验丰富的同事互动了一下,学到了两种替代方案:
方案一:预写默认状态码发生在router层,避免掀开引擎盖子。
1 2 3 4 5
mux := newMux() r.NoRoute(gin.WrapF(func(writer http.ResponseWriter, request *http.Request) { writer.WriteHeader(200) mux.ServeHTTP(writer, request) }))
方案二:自定义转发规则,将部分路由从gin框架内提到外部。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
r := gin.Default() r.GET("/v1/swagger/", func(c *gin.Context) { c.JSON(200, "swagger") }) mux := newMux() h := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { path := request.URL.Path if strings.HasPrefix(path, "/v1") && !strings.HasPrefix(path, "/v1/swagger") { // 匹配 v1/!swagger pattern的,由mux serve mux.ServeHTTP(writer, request) } else { // 匹配 v1/swagger pattern的,由gin router serve r.ServeHTTP(writer, request) } }) server := httptest.NewServer(h) defer server.Close()