Optimize form and multipart support with streaming (by AI)
This commit is contained in:
parent
ad02b380c2
commit
aa43171c4a
19
CHANGELOG.md
19
CHANGELOG.md
@ -1,5 +1,24 @@
|
|||||||
# CHANGELOG
|
# CHANGELOG
|
||||||
|
|
||||||
|
## v1.0.8 (2026-05-08)
|
||||||
|
- **性能优化 (Memory Efficiency)**:
|
||||||
|
- 重构 `Multipart` 处理逻辑,采用 `io.Pipe` 实现流式上传,彻底解决大文件上传时的内存撑爆问题。
|
||||||
|
- 引入 `writeMultipartPart` 与 `writeMultipartFile` 辅助函数,优化分块写入逻辑。
|
||||||
|
- **功能增强 (Unified Type Handling)**:
|
||||||
|
- 统一 `do` 方法内部逻辑,通过类型分组消除冗余代码。
|
||||||
|
- 将 `map[string][]any` 提升为 `Multipart` 处理,确保复杂/混合数据类型的正确流式发送。
|
||||||
|
- 完善对 `url.Values`, `map[string][]string`, `Form` 的统一表单编码支持。
|
||||||
|
- 优化 `Multipart` 对多值字段的支持,允许在 `Multipart` 映射中使用 `[]string` 或 `[]any` 发送同名多参数。
|
||||||
|
- **基础设施对齐**:
|
||||||
|
- 更新 `go/encoding` 至 v1.0.6。
|
||||||
|
- 移除 `bufferPool` 在 `Multipart` 中的使用,转向更高效的流式 IO。
|
||||||
|
|
||||||
|
## v1.0.6 (2026-05-07)
|
||||||
|
- 内部版本,优化 Header 自动透传逻辑。
|
||||||
|
|
||||||
|
## v1.0.5 (2026-05-06)
|
||||||
|
- 内部版本,增强对 H2C 场景下的连接池管理。
|
||||||
|
|
||||||
## v1.0.4 (2026-05-05)
|
## v1.0.4 (2026-05-05)
|
||||||
- **基础设施对齐**:
|
- **基础设施对齐**:
|
||||||
- 更新 `go/file` 至 v1.0.5, `go/log` 至 v1.1.1, `go/config` 至 v1.0.5。
|
- 更新 `go/file` 至 v1.0.5, `go/log` 至 v1.1.1, `go/config` 至 v1.0.5。
|
||||||
|
|||||||
16
README.md
16
README.md
@ -36,8 +36,12 @@ user, err := http.To[User](r)
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
fmt.Println(user.Name)
|
fmt.Println(user.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 支持 map[string][]string 或 map[string][]any 作为表单 (application/x-www-form-urlencoded)
|
||||||
|
c.Post("https://api.example.com/update", map[string][]string{"tags": {"go", "http"}})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### 2. Header 透传 (Discover Relay)
|
### 2. Header 透传 (Discover Relay)
|
||||||
在处理外部请求时,自动从原请求中提取并续传关键 Header。
|
在处理外部请求时,自动从原请求中提取并续传关键 Header。
|
||||||
```go
|
```go
|
||||||
@ -73,11 +77,13 @@ c.Download("local_file.zip", "https://example.com/large_file.zip", func(start, e
|
|||||||
|
|
||||||
### 特殊类型
|
### 特殊类型
|
||||||
- `type Form map[string]string`: 用于 `Post/Put` 等方法,显式指定为 `application/x-www-form-urlencoded` 格式。
|
- `type Form map[string]string`: 用于 `Post/Put` 等方法,显式指定为 `application/x-www-form-urlencoded` 格式。
|
||||||
- 注意:直接传入 `map[string]string` 会被默认识别为 `application/json`。
|
- 注意:直接传入 `map[string]string` 会被默认识别为 `application/json`;传入 `map[string][]string` 或 `map[string][]any` 会被识别为 `application/x-www-form-urlencoded`。
|
||||||
- `type Multipart map[string]any`: 用于 `Post/Put` 等方法,支持混合表单字段与文件上传。
|
- `type Multipart map[string]any`: 用于 `Post/Put` 等方法,支持混合表单字段与文件流式上传。
|
||||||
- 如果值为 `string` 且指向有效文件路径,则作为文件上传。
|
- **流式上传**: 内部使用 `io.Pipe` 结合 `multipart.Writer` 实现,支持超大文件而不会占用过多内存。
|
||||||
- 如果值为 `[]byte` 或 `io.Reader`,则作为文件上传。
|
- **文件识别**: 如果值为 `string` 且指向有效文件路径,则作为文件上传。
|
||||||
- 其他类型将作为普通表单字段(复杂类型会自动转为 JSON)。
|
- **流/字节**: 如果值为 `[]byte` 或 `io.Reader`,则作为文件上传。
|
||||||
|
- **多值支持**: 如果值为 `[]string` 或 `[]any`,将产生多个同名的表单字段或文件。
|
||||||
|
- **其他类型**: 将作为普通表单字段(复杂类型会自动转为 JSON)。
|
||||||
|
|
||||||
### 响应处理 (Result)
|
### 响应处理 (Result)
|
||||||
- `func (rs *Result) String() string`: 返回响应体字符串。
|
- `func (rs *Result) String() string`: 返回响应体字符串。
|
||||||
|
|||||||
186
client.go
186
client.go
@ -45,12 +45,6 @@ type Result struct {
|
|||||||
type Form map[string]string
|
type Form map[string]string
|
||||||
type Multipart map[string]any
|
type Multipart map[string]any
|
||||||
|
|
||||||
var bufferPool = sync.Pool{
|
|
||||||
New: func() any {
|
|
||||||
return new(bytes.Buffer)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewClient(timeout time.Duration) *Client {
|
func NewClient(timeout time.Duration) *Client {
|
||||||
if timeout < time.Millisecond && timeout > 0 {
|
if timeout < time.Millisecond && timeout > 0 {
|
||||||
timeout *= time.Millisecond
|
timeout *= time.Millisecond
|
||||||
@ -324,72 +318,95 @@ func (client *Client) Download(filename, url string, callback func(start, end in
|
|||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (client *Client) buildMultipart(writer *multipart.Writer, data map[string]any) []error {
|
func (client *Client) buildMultipart(writer *multipart.Writer, data map[string]any) error {
|
||||||
errs := make([]error, 0)
|
|
||||||
for key, value := range data {
|
for key, value := range data {
|
||||||
|
if err := client.writeMultipartPart(writer, key, value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) writeMultipartPart(writer *multipart.Writer, key string, value any) error {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否是文件
|
||||||
if filename, ok := value.(string); ok && file.Exists(filename) {
|
if filename, ok := value.(string); ok && file.Exists(filename) {
|
||||||
|
return client.writeMultipartFile(writer, key, filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch t := value.(type) {
|
||||||
|
case []string:
|
||||||
|
for _, v := range t {
|
||||||
|
if err := client.writeMultipartPart(writer, key, v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case []any:
|
||||||
|
for _, v := range t {
|
||||||
|
if err := client.writeMultipartPart(writer, key, v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case io.Reader:
|
||||||
|
h := make(textproto.MIMEHeader)
|
||||||
|
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, key, key))
|
||||||
|
h.Set("Content-Type", "application/octet-stream")
|
||||||
|
part, err := writer.CreatePart(h)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = io.Copy(part, t)
|
||||||
|
return err
|
||||||
|
case []byte:
|
||||||
|
h := make(textproto.MIMEHeader)
|
||||||
|
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, key, key))
|
||||||
|
h.Set("Content-Type", "application/octet-stream")
|
||||||
|
part, err := writer.CreatePart(h)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = part.Write(t)
|
||||||
|
return err
|
||||||
|
case string:
|
||||||
|
return writer.WriteField(key, t)
|
||||||
|
default:
|
||||||
|
// 其他复杂类型序列化为 JSON
|
||||||
|
bytesData, err := cast.ToJSONBytes(value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writer.WriteField(key, string(bytesData))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) writeMultipartFile(writer *multipart.Writer, key, filename string) error {
|
||||||
var r io.Reader
|
var r io.Reader
|
||||||
var closer io.Closer
|
var closer io.Closer
|
||||||
if mf := file.ReadFileFromMemory(filename); mf != nil {
|
if mf := file.ReadFileFromMemory(filename); mf != nil {
|
||||||
r = bytes.NewReader(mf.GetData())
|
r = bytes.NewReader(mf.GetData())
|
||||||
} else {
|
} else {
|
||||||
if fp, err := os.Open(filename); err == nil {
|
fp, err := os.Open(filename)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
r = fp
|
r = fp
|
||||||
closer = fp
|
closer = fp
|
||||||
} else {
|
|
||||||
errs = append(errs, err)
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if part, err := writer.CreateFormFile(key, filepath.Base(filename)); err == nil {
|
|
||||||
if _, err = io.Copy(part, r); err != nil {
|
|
||||||
errs = append(errs, err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
errs = append(errs, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if closer != nil {
|
if closer != nil {
|
||||||
_ = closer.Close()
|
defer closer.Close()
|
||||||
}
|
|
||||||
} else {
|
|
||||||
var dataBytes []byte
|
|
||||||
h := make(textproto.MIMEHeader)
|
|
||||||
isField := false
|
|
||||||
switch t := value.(type) {
|
|
||||||
case io.Reader:
|
|
||||||
dataBytes, _ = io.ReadAll(t)
|
|
||||||
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, key, key))
|
|
||||||
h.Set("Content-Type", "application/octet-stream")
|
|
||||||
case []byte:
|
|
||||||
dataBytes = t
|
|
||||||
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, key, key))
|
|
||||||
h.Set("Content-Type", "application/octet-stream")
|
|
||||||
case string:
|
|
||||||
isField = true
|
|
||||||
dataBytes = []byte(t)
|
|
||||||
default:
|
|
||||||
isField = true
|
|
||||||
dataBytes, _ = cast.ToJSONBytes(value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if isField {
|
part, err := writer.CreateFormFile(key, filepath.Base(filename))
|
||||||
if err := writer.WriteField(key, string(dataBytes)); err != nil {
|
if err != nil {
|
||||||
errs = append(errs, err)
|
return err
|
||||||
}
|
}
|
||||||
} else {
|
_, err = io.Copy(part, r)
|
||||||
if part, err := writer.CreatePart(h); err == nil {
|
return err
|
||||||
if _, err = part.Write(dataBytes); err != nil {
|
|
||||||
errs = append(errs, err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
errs = append(errs, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return errs
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (client *Client) do(fetchBody bool, method, url string, data any, headers ...string) *Result {
|
func (client *Client) do(fetchBody bool, method, url string, data any, headers ...string) *Result {
|
||||||
@ -409,36 +426,49 @@ func (client *Client) do(fetchBody bool, method, url string, data any, headers .
|
|||||||
case string:
|
case string:
|
||||||
reader = strings.NewReader(t)
|
reader = strings.NewReader(t)
|
||||||
contentLength = len(t)
|
contentLength = len(t)
|
||||||
|
case url2.Values, map[string][]string, Form:
|
||||||
|
var values url2.Values
|
||||||
|
switch v := t.(type) {
|
||||||
case url2.Values:
|
case url2.Values:
|
||||||
encoded := t.Encode()
|
values = v
|
||||||
reader = strings.NewReader(encoded)
|
case map[string][]string:
|
||||||
contentType = "application/x-www-form-urlencoded"
|
values = v
|
||||||
contentLength = len(encoded)
|
|
||||||
case Form:
|
case Form:
|
||||||
values := url2.Values{}
|
values = url2.Values{}
|
||||||
for k, v := range t {
|
for k, v1 := range v {
|
||||||
values.Set(k, v)
|
values.Set(k, v1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
encoded := values.Encode()
|
encoded := values.Encode()
|
||||||
reader = strings.NewReader(encoded)
|
reader = strings.NewReader(encoded)
|
||||||
contentType = "application/x-www-form-urlencoded"
|
contentType = "application/x-www-form-urlencoded"
|
||||||
contentLength = len(encoded)
|
contentLength = len(encoded)
|
||||||
case Multipart:
|
case Multipart, map[string][]any:
|
||||||
buf := bufferPool.Get().(*bytes.Buffer)
|
var mData map[string]any
|
||||||
buf.Reset()
|
if m, ok := t.(Multipart); ok {
|
||||||
defer bufferPool.Put(buf)
|
mData = m
|
||||||
writer := multipart.NewWriter(buf)
|
} else {
|
||||||
errs := client.buildMultipart(writer, t)
|
m := t.(map[string][]any)
|
||||||
if err := writer.Close(); err != nil {
|
mData = make(map[string]any, len(m))
|
||||||
errs = append(errs, err)
|
for k, v := range m {
|
||||||
|
mData[k] = v
|
||||||
}
|
}
|
||||||
if len(errs) > 0 {
|
|
||||||
return &Result{Error: errors.Join(errs...)}
|
|
||||||
}
|
}
|
||||||
bytesData := buf.Bytes()
|
pr, pw := io.Pipe()
|
||||||
reader = bytes.NewReader(bytesData)
|
writer := multipart.NewWriter(pw)
|
||||||
contentType = writer.FormDataContentType()
|
contentType = writer.FormDataContentType()
|
||||||
contentLength = len(bytesData)
|
reader = pr
|
||||||
|
go func() {
|
||||||
|
err := client.buildMultipart(writer, mData)
|
||||||
|
if err == nil {
|
||||||
|
err = writer.Close()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
_ = pw.CloseWithError(err)
|
||||||
|
} else {
|
||||||
|
_ = pw.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
default:
|
default:
|
||||||
bytesData, _ := cast.ToJSONBytes(data)
|
bytesData, _ := cast.ToJSONBytes(data)
|
||||||
if len(bytesData) > 0 && string(bytesData) != "null" {
|
if len(bytesData) > 0 && string(bytesData) != "null" {
|
||||||
|
|||||||
2
go.mod
2
go.mod
@ -4,7 +4,7 @@ go 1.25.0
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
apigo.cc/go/cast v1.2.8
|
apigo.cc/go/cast v1.2.8
|
||||||
apigo.cc/go/encoding v1.0.5
|
apigo.cc/go/encoding v1.0.6
|
||||||
apigo.cc/go/file v1.0.7
|
apigo.cc/go/file v1.0.7
|
||||||
apigo.cc/go/log v1.1.9
|
apigo.cc/go/log v1.1.9
|
||||||
apigo.cc/go/rand v1.0.5
|
apigo.cc/go/rand v1.0.5
|
||||||
|
|||||||
104
optimization_test.go
Normal file
104
optimization_test.go
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
package http_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
ah "apigo.cc/go/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOptimizationForms(t *testing.T) {
|
||||||
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") {
|
||||||
|
_ = r.ParseMultipartForm(10 << 20)
|
||||||
|
} else {
|
||||||
|
_ = r.ParseForm()
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "foo=%v", r.Form["foo"])
|
||||||
|
})
|
||||||
|
server := &http.Server{Addr: ":18088", Handler: handler}
|
||||||
|
go func() { _ = server.ListenAndServe() }()
|
||||||
|
defer server.Close()
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
c := ah.NewClient(time.Second)
|
||||||
|
|
||||||
|
// Test map[string][]string
|
||||||
|
r1 := c.Post("http://127.0.0.1:18088/", map[string][]string{"foo": {"bar", "baz"}})
|
||||||
|
if r1.Error != nil {
|
||||||
|
t.Fatalf("map[string][]string failed: %v", r1.Error)
|
||||||
|
}
|
||||||
|
if r1.String() != "foo=[bar baz]" {
|
||||||
|
t.Errorf("expected foo=[bar baz], got %s", r1.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test map[string][]any (now Multipart)
|
||||||
|
r2 := c.Post("http://127.0.0.1:18088/", map[string][]any{"foo": {"bar", 123}})
|
||||||
|
if r2.Error != nil {
|
||||||
|
t.Fatalf("map[string][]any failed: %v", r2.Error)
|
||||||
|
}
|
||||||
|
// Multipart output might look different depending on how the server parses it,
|
||||||
|
// but r.Form["foo"] should still work if it's multipart.
|
||||||
|
if r2.String() != "foo=[bar 123]" {
|
||||||
|
t.Errorf("expected foo=[bar 123], got %s", r2.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultipartStreaming(t *testing.T) {
|
||||||
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
err := r.ParseMultipartForm(10 << 20)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ParseMultipartForm failed: %v", err)
|
||||||
|
}
|
||||||
|
f1 := r.FormValue("foo")
|
||||||
|
file, _, err := r.FormFile("file")
|
||||||
|
var fileContent []byte
|
||||||
|
if file != nil {
|
||||||
|
fileContent, _ = io.ReadAll(file)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "foo=%s,file=%s", f1, string(fileContent))
|
||||||
|
})
|
||||||
|
server := &http.Server{Addr: ":18089", Handler: handler}
|
||||||
|
go func() { _ = server.ListenAndServe() }()
|
||||||
|
defer server.Close()
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
c := ah.NewClient(time.Second)
|
||||||
|
// Test streaming Multipart
|
||||||
|
r := c.Post("http://127.0.0.1:18089/", ah.Multipart{
|
||||||
|
"foo": "bar",
|
||||||
|
"file": io.NopCloser(strings.NewReader("baz")), // use Reader to force streaming
|
||||||
|
})
|
||||||
|
if r.Error != nil {
|
||||||
|
t.Fatalf("Post with Multipart streaming failed: %v", r.Error)
|
||||||
|
}
|
||||||
|
if r.String() != "foo=bar,file=baz" {
|
||||||
|
t.Errorf("expected foo=bar,file=baz, got %s", r.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultipartMultipleParts(t *testing.T) {
|
||||||
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = r.ParseMultipartForm(10 << 20)
|
||||||
|
fmt.Fprintf(w, "foo=%v", r.MultipartForm.Value["foo"])
|
||||||
|
})
|
||||||
|
server := &http.Server{Addr: ":18090", Handler: handler}
|
||||||
|
go func() { _ = server.ListenAndServe() }()
|
||||||
|
defer server.Close()
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
c := ah.NewClient(time.Second)
|
||||||
|
r := c.Post("http://127.0.0.1:18090/", ah.Multipart{
|
||||||
|
"foo": []string{"bar", "baz"},
|
||||||
|
})
|
||||||
|
if r.Error != nil {
|
||||||
|
t.Fatalf("Post with Multipart multiple parts failed: %v", r.Error)
|
||||||
|
}
|
||||||
|
if r.String() != "foo=[bar baz]" {
|
||||||
|
t.Errorf("expected foo=[bar baz], got %s", r.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user