xgfone / ship

A flexible, powerful, high performance and minimalist Go Web HTTP router framework.
https://github.com/xgfone/ship
Apache License 2.0
48 stars 5 forks source link

HTTP状态码一旦写入后,HandleError无法修改状态码 #5

Closed xmx closed 2 years ago

xmx commented 2 years ago

ship 版本

v5.1.1

代码

package main

import (
    "encoding/json"
    "net/http"

    "github.com/xgfone/ship/v5"
)

func main() {
    s := ship.New()
    s.HandleError = func(c *ship.Context, err error) {
        // 如果出现错误:http状态码为400
        _ = c.Text(http.StatusBadRequest, err.Error())
    }

    s.Route("/demo").GET(func(c *ship.Context) error {
        wrong := json.RawMessage("a:b")     // 错误的JSON数据
        return c.JSON(http.StatusOK, wrong) // 如果执行正常:http状态码200
    })

    ship.StartServer(":8080", s)
}

curl 测试输出(问题在响应报文的状态码)

$ curl -v 'http://127.0.0.1:8080/demo' 
*   Trying 127.0.0.1:8080...
* Connected to 127.0.0.1 (127.0.0.1) port 8080 (#0)
> GET /demo HTTP/1.1
> Host: 127.0.0.1:8080
> User-Agent: curl/7.80.0
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Content-Type: application/json; charset=UTF-8
< Date: Wed, 29 Dec 2021 02:41:51 GMT
< Content-Length: 110
< 
* Connection #0 to host 127.0.0.1 left intact
json: error calling MarshalJSON for type json.RawMessage: invalid character 'a' looking for beginning of value% 

期望

// 当正常执行的时候输出HTTP状态码为200
// 当出现错误的时候输出的HTTP状态码由HandleError控制
return c.JSON(http.StatusOK, wrong)
xgfone commented 2 years ago

这里有两个问题:

  1. Go 标准库 net/http 的限制:http.ResponseWriter.WriteHeader(statusCode int) 原则上只能调用一次;虽然可以调用多次,但只有第一次生效,对于后续的调用,http.Server 仅会打印一条错误日志。所以 HandleError 的正确实现是:要检查 WriteHeader 是否已经调用(ship.Context.IsResponded()),只有未调用的情况下,才可以向客户端返回新的状态码。
  2. ship.Context.JSON(statusCode int, data interface{}) 当前是假定 data 必须可以被 JSON 序列化,否则可能会出现问题。这个可以稍后会修复一下,以便支持 data 不能被 JSON 序列化的情况。
xmx commented 2 years ago

标准库*http.response确实是:只允许状态码设置一次,所以我也简单观察了以下常见的几种 go web 框架针对该种类型错误的处理方式:

  1. gofiber 能够处理该种错误,但是底层用的 fasthttp,没有基于标准库的 net/http 实现,超出三界之外不在五行之中🤪,没继续看实现。
  2. gin 能够处理该种错误,但是gin框架错误机制稍微和ship不太一样,这个地方会panic,依靠recovery中间件兜底,但是由此可见也是做了特殊处理。专门针对状态码不可重复写做了处理:对标准库的 http.ResponseWriter进行了包装
  3. echo 能够处理该种错误,框架错误机制也和ship类似,同样也是对对标准库的 http.ResponseWriter进行了包装

大概逻辑都是包装了以下标准库的http.ResponseWriter,加入status字段用于多次修改状态码,等到到业务代码处理完,最后调用标准库http.Write的时候才写入最终的状态码

xgfone commented 2 years ago

针对 ship.Context.JSON(statusCode int, data interface{}) 假定 data 必须可以被 JSON 序列化 的情况,已经优化,当前版本 v5.1.2 已经可以正确处理 不能被 JSON 序列化的 data 了。

// go.mod
module myapp

require github.com/xgfone/ship/v5 v5.1.2
// main.go
package main

import (
    "encoding/json"
    "log"
    "net/http"

    "github.com/xgfone/ship/v5"
)

func main() {
    s := ship.New()
    s.HandleError = func(c *ship.Context, err error) {
        // 只有在未应答(也即是没有调用 WriteHeader)的情况下,
        // 我们才能继续使用类似 Text、JSON、XML 等应答方法。
        if !c.IsResponded() {
            // 如果出现错误:http状态码为400
            c.Text(http.StatusBadRequest, err.Error())
        }

        // 记录错误信息到日志中
        log.Printf("method=%s, path=%s, code=%d, err=%s",
            c.Method(), c.Path(), c.StatusCode(), err)
    }

    s.Route("/demo").GET(func(c *ship.Context) error {
        wrong := json.RawMessage("a:b")     // 错误的JSON数据
        return c.JSON(http.StatusOK, wrong) // 如果执行正常:http状态码200
    })

    ship.StartServer(":8080", s)
}
$ curl -i http://127.0.0.1:8080/demo
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=UTF-8
Date: Wed, 29 Dec 2021 13:49:58 GMT
Content-Length: 110

json: error calling MarshalJSON for type json.RawMessage: invalid character 'a' looking for beginning of value
xgfone commented 2 years ago

Go 标准库实现的 HTTP Server,对于 ResponseWriter 仅支持且实现了 应答行(即状态码)应答头(即 Header)应答体(即 Body)发送 (分别对应 WriteHeaderWrite 两个方法),未提供发送的状态。而对于框架而言,有两个状态几乎是很重要的:

由于标准库未提供,因此,几乎大部分基本于标准库的HTTP框架都会捕获 ResponseWriter,然后提供上述发送状态查询,echoship 都是如此。换句话说就是,只要是基于 Go 标准库 net/http 的 HTTP 框架,都逃不出这个。

所以,在上面的 HandleError 样例中,要先检查 WriteHeader 是否已经调用过了;只有在没有调用的情况下,执行类似 Text 的应答方法才有意义。