Golang中如何重复读取HTTP客户端响应

Golang中如何重复读取HTTP客户端响应 你好,

这里我需要读取客户端响应两次,以便能够:

  1. Get 处理程序返回它。
  2. 使用 Log 函数将其转储到文件中。

请问最节省内存的方法是什么?我查看了 TeeReader 但有些困惑。另外,也不确定在何处以及如何关闭响应体。

谢谢

package api

func Get(w http.ResponseWriter, r *http.Request) {
	req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, "https://api.fake.io/id-1", nil)
	if err != nil {
		w.WriteHeader(500)
		return
	}

	res, err := http.DefaultTransport.RoundTrip(req)
	if err != nil {
		w.WriteHeader(500)
		return
	}

	defer audit.Log(req, res)

	dat, err := io.ReadAll(res.Body)
	if err != nil {
		w.WriteHeader(500)
		return
	}
	defer res.Body.Close()

	w.Write(dat)
}
package audit

func Log(req *http.Request, res *http.Response) error {
	dump, err := httputil.DumpResponse(res, true)
	if err != nil {
		return err
	}

	buf := &bytes.Buffer{}
	buf.WriteString(fmt.Sprintf("%s %s HTTP/%d.%d\n", req.Method, req.URL.RequestURI(), req.ProtoMajor, req.ProtoMinor))
	buf.Write(dump)

	file, err := os.OpenFile(uuid.NewString() + ".log", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, os.FileMode(0600))
	if err != nil {
		return err
	}
	defer file.Close()

	if _, err := file.Write(buf.Bytes()); err != nil {
		return err
	}

	return nil
}

更多关于Golang中如何重复读取HTTP客户端响应的实战教程也可以访问 https://www.itying.com/category-94-b0.html

17 回复

两个答案都与问题相去甚远。

更多关于Golang中如何重复读取HTTP客户端响应的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


不,因为你使用了 io.Copy,第一次 buf 复制数据时,它就已经返回了 http.OK,所以再返回 500 状态码是没有意义的。

正如我在回答中所说,它已经解决了你的两个问题。如果你不明白我为什么这样写,那么你应该阅读其中的逻辑。 最大限度地节省内存意味着尽可能广泛地共享底层数据。

根据你的代码,只需在这里移除 defer

defer audit.Log(req, res)

DumpResponse 在内部会复制响应体。

我考虑了大家所说的,最终得到了这个方案,如果你愿意审阅一下以防我理解有误的话。

确实如此。我复制/粘贴了那段代码,但你说得对,在 io.Copy 之后返回 500 是不正确的。我想,除非它在第一次写入之前就失败了,因此没有设置 http.OK。

func main() {
    fmt.Println("hello world")
}

抱歉,我还是不明白,当我说不能移除 defer 时,这个回答是如何解决问题的。

最大程度的内存节省意味着尽可能共享底层数据。

让我们重新开始。请问,在不移除 defer 的情况下,如何实现这个目标?如果无法实现,请给我展示另一个仍然保留 defer 的示例。

audit.Log 的设计目的就是要在后台运行而不阻塞流程,因此它被 defer 了。我本可以自己移除它,但这并不适用。

此外,我问的是“请问最节省内存的方式是什么?”,而不是“我如何在不创建响应副本的情况下实现这一点?”。

由于这两个特定原因,我认为这些回答是多余的。

如果你将整个请求解析到一个结构体中,无论如何它都会被完全读入内存。

说得好。我在原问题中忽略了这一点。

最好直接使用你已经创建的字节数组,并传递一个指向它的指针给你的审计方法。

是的——不过由于它是切片,如果你不传递指针,你传递的只是切片头,而不是底层数组。所以我认为在分配/内存占用方面,这两者之间不会有太大区别:

// 传递一个指向我们切片头的指针。
func DoSomething(b *[]byte)
// 传递切片头;但切片头包含指向底层数组的指针,所以数组不会被复制。
func DoSomething(b []byte)

无论如何,我完全同意你的观点。修改代码,只读取请求体一次。然后使用一个 goroutine 来处理耗时的部分(上传到 S3)。提取 DumpRequest 的相关部分,避免创建响应体的多个副本。就这样搞定。

1- 读取它,将其解析为结构体,并在处理程序中交给我的客户端。

如果你将整个请求解析到一个结构体中,无论如何它都会被完全读入内存。正如 @Dean_Davidson 所说——在你的情况下,流式处理不会带来任何可衡量的好处。最好直接使用你已经创建的字节数组,并将其指针传递给你的审计方法。

最好的方法可能是同步读取请求的URL、方法等信息(如果你想的话,可以使用 defer),但实际的写入S3操作在一个单独的 goroutine 中执行。这个 goroutine 不会有数据竞争,因为它只读取常量数据(字节数组以及你从请求中创建的字符串)。

将审计数据的实际写入操作放到一个单独的 goroutine 中是有益的,因为当原始的 HTTP 处理程序返回时,HTTP 连接就会被释放。如果只使用 defer(而不使用额外的 goroutine),它仍然会阻塞,直到延迟的方法执行完毕才会从调用中返回。这样,你的 HTTP 请求和底层连接将会一直被阻塞,直到审计日志写入完成。

以下是我在后台实际执行的操作:

  1. 向远程服务器发送一个 HTTP 请求。
  2. 对来自远程服务器的 HTTP 响应做两件事:
    1. 读取它,将其解析为结构体,并在处理程序中交给我的客户端。
    2. 整个响应(URI、头部和正文……)转储到一个地方(因为我正在审计所有响应): a) 如果应用程序在本地环境中运行,则转储到 TXT 文件。 b) 如果应用程序不在本地环境中运行,则转储到 S3 存储桶。

我只是不希望 a 和 b 这两项操作延迟我向客户端的响应,因为它们有时会花费很长时间。这就是我使用 defer 的原因。

所以我想你的意思是:

  1. 不要使用 defer,而是像 go audit.Log(...) 这样使用,并管理当前发生的 DATA RACE
  2. 使用流式处理,而不是 io.ReadAll(res.Body)。我猜你的意思是 var buf bytes.Buffer - io.Copy(&buf, resp.Body)

顺便说一句,如果你能想到任何更好的解决方案,我很乐意听听。谢谢。

audit.Log 的设计目的就是要在后台运行而不阻塞流程,因此它被 defer 了。我本也可以自己移除它,但这并不适用。

好吧,使用 defer 并不是实现调试任务不阻塞 Web 请求的唯一方法。你可以直接启动一个 goroutine,在那里执行 audit.Log 相关操作。我经常出于这个原因,将 Web 处理程序产生的副作用操作放在它们自己的 goroutine 中执行。

另外,我问的是“请问最节省内存的方式是什么?”,而不是“我如何在不创建响应副本的情况下实现这个?”。

你原来的代码使用了 io.ReadAll(res.Body),这会将整个响应读入内存。如果你希望处理大型请求,就需要将 res.Body 流式传输到缓冲区并进行处理。但一般来说,在 Web 服务器中,我们只是使用 http.MaxBytesReader 将请求大小限制在合理的默认值,除非是在我们预期会收到大响应的处理程序中,否则我们不必担心这个问题。而在那种情况下,是的,你必须进行流式处理。

你是否预期会收到大响应,因此需要进行流式处理?如果是,请查看这个答案:

如何接收用于流式传输的 HTTP 响应

你可以实现类似的功能,但要与 io.MultiWriter 结合使用。

综上所述,如果你不预期会收到大响应,那么现在这样做可能得不偿失。你分析过你的应用吗?它是否使用了太多内存?你确定这是有问题的代码区域吗?

func Get(w http.ResponseWriter, r *http.Request) {
	req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, "https://api.fake.io/id-1", nil)
	if err != nil {
		w.WriteHeader(500)
		return
	}

	res, err := http.DefaultTransport.RoundTrip(req)
	if err != nil {
		w.WriteHeader(500)
		return
	}
	defer res.Body.Close()
	dat, err := io.ReadAll(res.Body)
	if err != nil {
		w.WriteHeader(500) //Because you have a logic here to determine whether the read is normal, you cannot achieve the minimum memory overhead of stream iocopy.
		_ = Log(req, res, dat)
		return
	}
	_ = Log(req, res, dat)
	_, _ = w.Write(dat)
}

func Log(req *http.Request, res *http.Response, data []byte) error {
	dump, err := httputil.DumpResponse(res, false)
	if err != nil {
		return err
	}
	file, err := os.OpenFile(uuid.NewString()+".log", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, os.FileMode(0600))
	if err != nil {
		return err
	}
	defer file.Close()
	_, err = file.WriteString(fmt.Sprintf("%s %s HTTP/%d.%d\n", req.Method, req.URL.RequestURI(), req.ProtoMajor, req.ProtoMinor))
	if err != nil {
		return err
	}
	_, err = file.Write(dump)
	if err != nil {
		return err
	}
	_, err = file.WriteString("\r\n\r\n")
	if err != nil {
		return err
	}
	_, err = file.Write(data)
	if err != nil {
		return err
	}
	return nil
}

由于你在这里有一段逻辑来判断读取是否正常,因此无法实现流式 io.Copy 的最低内存开销。

在查阅了这里所有的示例和建议后,我不确定如何将其应用到下面的代码中。请求体的大小可以是任意的。

package xhttp

import (
	"context"
	"io"
	"net/http"
)

type Client struct {
	Client http.RoundTripper
}

func (c Client) Request(ctx context.Context, met, url string, bdy io.Reader, hdrs map[string]string) (*http.Response, error) {
	req, err := http.NewRequestWithContext(ctx, met, url, bdy)
	if err != nil {
		return nil, err
	}

	for k, v := range hdrs {
		req.Header.Add(k, v)
	}

	// go LogRequest()

	res, err := c.Client.RoundTrip(req)
	if err != nil {
		return nil, err
	}

	// go LogResponse()

	return res, nil
}

// LogRequest writes whole HTTP request to a file.
func LogRequest() {
	// GET /api/v1/postcodes?active=false HTTP/1.1
	// Host: 0.0.0.0:8080
	// Accept: */*
	// Accept-Encoding: gzip, deflate, br
	// Cache-Control: no-cache
	// Connection: keep-alive
	// Content-Length: 29
	// Content-Type: application/json
	// User-Agent: PostmanRuntime/7.42.0
	
	// {
	// 	"postcode": "SW1A0AA"
	// }
}

// LogResponse writes whole HTTP response to a file.
func LogResponse() {
	// GET /api/v1/postcodes?active=false HTTP/1.1
	// X-Request-Id: 1f532ad9-4062-46d3-9e2f-9fd5f3c74d9a
	// Content-Type: application/json
	//
	// Status: 200 OK
	// 
	// {
	//  "exists": true
	// }
}

我不这么认为。我认为 @GonzaSaya 建议你在那里避免使用 defer 的原因是:它会创建响应体的副本,这样你就不会遇到第二次读取时响应体为空的问题。相关代码在这里:

https://cs.opensource.google/go/go/+/refs/tags/go1.23.3:src/net/http/httputil/dump.go;l=281

因此,你可以不延迟执行它,并且为了简化将 resp.Body 复制到 w 的操作,你可以使用 io.Copy 而不是 io.ReadAll,就像这样:

_, err = io.Copy(w, resp.Body)

所以你的伪代码会变成类似这样(可能无法编译;我在这里的文本编辑器中写的,请根据需要调整):

func Get(w http.ResponseWriter, r *http.Request) {
	req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, "https://api.fake.io/id-1", nil)
	if err != nil {
		w.WriteHeader(500) // 你可能应该对 err 做些什么
		return
	}
	res, err := http.DefaultTransport.RoundTrip(req)
	if err != nil {
		w.WriteHeader(500) // 这里也应该对 err 做些什么
		return
	}
	// 记录请求;从 res 读取但会创建 res.Body 的副本
	// 以便我们稍后可以再次读取它。
	audit.Log(req, res)
	// 将我们复制的 resp.Body 复制到 w
	_, err = io.Copy(w, resp.Body)
	if err != nil {
		w.WriteHeader(500) // 这里也应该对 err 做些什么
	}
	// 在成功路径上,你可能还应该在这里向 w 写入头部信息等。
	res.Body.Close()
}

如果让我来实现这个功能,我会这样做:

func Get(w http.ResponseWriter, r *http.Request) {
	req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, "https://api.fake.io/id-1", nil)
	if err != nil {
		w.WriteHeader(500)
		return
	}

	res, err := http.DefaultTransport.RoundTrip(req)
	if err != nil {
		w.WriteHeader(500)
		return
	}
	defer res.Body.Close()

	bs := new(bytes.Buffer)
	writer := io.MultiWriter(w, bs)
	_, _ = io.Copy(writer, res.Body) // will 200 Code
	// 方案a		// 推荐此方案
	flush, ok := writer.(http.Flusher)
	if ok {
		flush.Flush()
	}
	_ = Log(req, res, bs)
	// 方案b
	go func() { // 或使用goroutine池;如果只是简单的并发,当短时间内有大量请求时,可能会产生大量的内存开销。
		_ = Log(req, res, bs)
	}()
}

func Log(req *http.Request, res *http.Response, bs *bytes.Buffer) error {
	dump, err := httputil.DumpResponse(res, false)
	if err != nil {
		return err
	}
	file, err := os.OpenFile(uuid.NewString()+".log", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, os.FileMode(0600))
	if err != nil {
		return err
	}
	defer file.Close()
	_, err = file.WriteString(fmt.Sprintf("%s %s HTTP/%d.%d\n", req.Method, req.URL.RequestURI(), req.ProtoMajor, req.ProtoMinor))
	if err != nil {
		return err
	}
	_, err = file.Write(dump)
	if err != nil {
		return err
	}
	_, err = file.WriteString("\r\n\r\n")
	if err != nil {
		return err
	}
	_, err = io.Copy(file, bs)
	if err != nil {
		return err
	}
	return nil
}

在Golang中重复读取HTTP响应体,最节省内存的方法是使用io.TeeReader配合缓冲区。以下是修改后的代码:

package api

import (
    "bytes"
    "io"
    "net/http"
)

func Get(w http.ResponseWriter, r *http.Request) {
    req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, "https://api.fake.io/id-1", nil)
    if err != nil {
        w.WriteHeader(500)
        return
    }

    res, err := http.DefaultTransport.RoundTrip(req)
    if err != nil {
        w.WriteHeader(500)
        return
    }
    defer res.Body.Close()

    // 创建缓冲区来存储响应体副本
    var buf bytes.Buffer
    teeReader := io.TeeReader(res.Body, &buf)
    
    // 先读取到缓冲区(用于日志)
    bodyForLog, err := io.ReadAll(teeReader)
    if err != nil {
        w.WriteHeader(500)
        return
    }

    // 创建新的响应体用于日志函数
    resForLog := *res
    resForLog.Body = io.NopCloser(bytes.NewReader(bodyForLog))
    
    // 调用日志函数
    if err := audit.Log(req, &resForLog); err != nil {
        w.WriteHeader(500)
        return
    }

    // 将缓冲区内容写回响应
    w.Write(buf.Bytes())
}
package audit

import (
    "bytes"
    "fmt"
    "net/http"
    "net/http/httputil"
    "os"
    
    "github.com/google/uuid"
)

func Log(req *http.Request, res *http.Response) error {
    dump, err := httputil.DumpResponse(res, true)
    if err != nil {
        return err
    }

    buf := &bytes.Buffer{}
    buf.WriteString(fmt.Sprintf("%s %s HTTP/%d.%d\n", 
        req.Method, req.URL.RequestURI(), req.ProtoMajor, req.ProtoMinor))
    buf.Write(dump)

    file, err := os.OpenFile(uuid.NewString()+".log", 
        os.O_WRONLY|os.O_CREATE|os.O_TRUNC, os.FileMode(0600))
    if err != nil {
        return err
    }
    defer file.Close()

    if _, err := file.Write(buf.Bytes()); err != nil {
        return err
    }

    return nil
}

或者使用更高效的方法,直接复用缓冲区:

package api

import (
    "bytes"
    "io"
    "net/http"
)

func Get(w http.ResponseWriter, r *http.Request) {
    req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, "https://api.fake.io/id-1", nil)
    if err != nil {
        w.WriteHeader(500)
        return
    }

    res, err := http.DefaultTransport.RoundTrip(req)
    if err != nil {
        w.WriteHeader(500)
        return
    }
    defer res.Body.Close()

    // 一次性读取响应体
    bodyBytes, err := io.ReadAll(res.Body)
    if err != nil {
        w.WriteHeader(500)
        return
    }

    // 创建响应副本用于日志
    resForLog := *res
    resForLog.Body = io.NopCloser(bytes.NewReader(bodyBytes))
    
    // 异步记录日志
    go func() {
        _ = audit.Log(req, &resForLog)
    }()

    // 返回响应
    w.Write(bodyBytes)
}

关于响应体关闭:只需要在Get函数中调用一次defer res.Body.Close()即可。当使用io.NopCloser包装的reader不需要单独关闭,因为它只是一个包装器,不持有实际资源。

回到顶部