Skip to content

Commit b9096f8

Browse files
authored
Merge pull request #17 from golang-io/dev
feat: 添加 multipart/form-data 请求体摘要解析并补充测试覆盖
2 parents 11ff3d5 + f9815c0 commit b9096f8

2 files changed

Lines changed: 290 additions & 9 deletions

File tree

stat.go

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,18 @@ import (
66
"bytes"
77
"encoding/json"
88
"fmt"
9+
"io"
10+
"mime"
11+
"mime/multipart"
912
"net/http"
13+
"strings"
1014
"time"
1115
)
1216

1317
// RequestId 是用于跟踪请求的HTTP头字段名称
1418
// RequestId is the HTTP header field name used for request tracking
1519
const RequestId = "Request-Id"
1620

17-
// dateTime 是统计信息中使用的时间格式
18-
// dateTime is the time format used in statistics
19-
const dateTime = "2006-01-02 15:04:05.000"
20-
2121
// Stat 是HTTP请求统计信息结构,记录了请求和响应的完整信息
2222
// 该结构在客户端和服务器端都可以使用,但某些字段的含义略有不同
2323
//
@@ -198,7 +198,7 @@ func (stat *Stat) Print() string {
198198
// - *Stat: 统计信息对象 / Statistics object
199199
func responseLoad(resp *Response) *Stat {
200200
stat := &Stat{
201-
StartAt: resp.StartAt.Format(dateTime),
201+
StartAt: resp.StartAt.Format(time.RFC3339),
202202
Cost: time.Since(resp.StartAt).Milliseconds(),
203203
}
204204
if resp.Response != nil {
@@ -263,6 +263,39 @@ func responseLoad(resp *Response) *Stat {
263263
return stat
264264
}
265265

266+
// multipartBodySummary 将 multipart/form-data 请求体表示为 "name=@filename" 或 "name=value",不记录文件内容
267+
// multipartBodySummary represents multipart/form-data body as name=@filename or name=value, without recording file content
268+
func multipartBodySummary(buf *bytes.Buffer, contentType string) string {
269+
_, params, err := mime.ParseMediaType(contentType)
270+
if err != nil || params["boundary"] == "" {
271+
return ""
272+
}
273+
mr := multipart.NewReader(bytes.NewReader(buf.Bytes()), params["boundary"])
274+
var parts []string
275+
for {
276+
p, err := mr.NextPart()
277+
if err == io.EOF {
278+
break
279+
}
280+
if err != nil {
281+
return ""
282+
}
283+
name := p.FormName()
284+
if name == "" {
285+
continue
286+
}
287+
if filename := p.FileName(); filename != "" {
288+
parts = append(parts, name+"=@"+filename)
289+
} else {
290+
var sb strings.Builder
291+
if _, err := io.Copy(&sb, p); err == nil {
292+
parts = append(parts, name+"="+sb.String())
293+
}
294+
}
295+
}
296+
return strings.Join(parts, "&")
297+
}
298+
266299
// serveLoad 从HTTP请求和响应中提取并构建统计信息(服务器端使用)
267300
// 该函数会记录服务器端的请求处理统计信息
268301
//
@@ -291,11 +324,20 @@ func serveLoad(w *ResponseWriter, r *http.Request, start time.Time, buf *bytes.B
291324
stat.Request.URL = r.URL.String()
292325

293326
if buf != nil {
294-
m := make(map[string]any)
295-
if err := json.Unmarshal(buf.Bytes(), &m); err != nil {
296-
stat.Request.Body = buf.String()
327+
contentType := r.Header.Get("Content-Type")
328+
if strings.HasPrefix(contentType, "multipart/form-data") {
329+
if summary := multipartBodySummary(buf, contentType); summary != "" {
330+
stat.Request.Body = summary
331+
} else {
332+
stat.Request.Body = "(multipart)"
333+
}
297334
} else {
298-
stat.Request.Body = m
335+
m := make(map[string]any)
336+
if err := json.Unmarshal(buf.Bytes(), &m); err != nil {
337+
stat.Request.Body = buf.String()
338+
} else {
339+
stat.Request.Body = m
340+
}
299341
}
300342
}
301343
scheme := "http://"

stat_test.go

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,192 @@ import (
66
"encoding/json"
77
"fmt"
88
"io"
9+
"mime/multipart"
910
"net/http"
1011
"strings"
1112
"sync"
1213
"testing"
1314
"time"
1415
)
1516

17+
// TestMultipartBodySummary 测试 multipartBodySummary 函数
18+
func TestMultipartBodySummary(t *testing.T) {
19+
// buildMultipart 辅助函数:构造 multipart body 和 Content-Type
20+
buildMultipart := func(fields map[string]string, files map[string]string) (*bytes.Buffer, string) {
21+
var buf bytes.Buffer
22+
writer := multipart.NewWriter(&buf)
23+
for name, value := range fields {
24+
_ = writer.WriteField(name, value)
25+
}
26+
for fieldName, fileName := range files {
27+
part, _ := writer.CreateFormFile(fieldName, fileName)
28+
_, _ = part.Write([]byte("fake file content"))
29+
}
30+
_ = writer.Close()
31+
return &buf, writer.FormDataContentType()
32+
}
33+
34+
tests := []struct {
35+
name string
36+
buildInput func() (*bytes.Buffer, string)
37+
checkResult func(t *testing.T, result string)
38+
}{
39+
{
40+
name: "单个文本字段",
41+
buildInput: func() (*bytes.Buffer, string) {
42+
return buildMultipart(map[string]string{"username": "alice"}, nil)
43+
},
44+
checkResult: func(t *testing.T, result string) {
45+
if result != "username=alice" {
46+
t.Errorf("期望 'username=alice',实际 '%s'", result)
47+
}
48+
},
49+
},
50+
{
51+
name: "多个文本字段",
52+
buildInput: func() (*bytes.Buffer, string) {
53+
var buf bytes.Buffer
54+
writer := multipart.NewWriter(&buf)
55+
// 按顺序写入以保证顺序一致
56+
_ = writer.WriteField("name", "bob")
57+
_ = writer.WriteField("age", "30")
58+
_ = writer.Close()
59+
return &buf, writer.FormDataContentType()
60+
},
61+
checkResult: func(t *testing.T, result string) {
62+
if result != "name=bob&age=30" {
63+
t.Errorf("期望 'name=bob&age=30',实际 '%s'", result)
64+
}
65+
},
66+
},
67+
{
68+
name: "单个文件上传",
69+
buildInput: func() (*bytes.Buffer, string) {
70+
return buildMultipart(nil, map[string]string{"avatar": "photo.png"})
71+
},
72+
checkResult: func(t *testing.T, result string) {
73+
if result != "avatar=@photo.png" {
74+
t.Errorf("期望 'avatar=@photo.png',实际 '%s'", result)
75+
}
76+
},
77+
},
78+
{
79+
name: "混合字段和文件",
80+
buildInput: func() (*bytes.Buffer, string) {
81+
var buf bytes.Buffer
82+
writer := multipart.NewWriter(&buf)
83+
_ = writer.WriteField("title", "my doc")
84+
part, _ := writer.CreateFormFile("file", "document.pdf")
85+
_, _ = part.Write([]byte("pdf content"))
86+
_ = writer.Close()
87+
return &buf, writer.FormDataContentType()
88+
},
89+
checkResult: func(t *testing.T, result string) {
90+
if result != "title=my doc&file=@document.pdf" {
91+
t.Errorf("期望 'title=my doc&file=@document.pdf',实际 '%s'", result)
92+
}
93+
},
94+
},
95+
{
96+
name: "无效的Content-Type",
97+
buildInput: func() (*bytes.Buffer, string) {
98+
return bytes.NewBufferString("some data"), "text/plain"
99+
},
100+
checkResult: func(t *testing.T, result string) {
101+
if result != "" {
102+
t.Errorf("无效 Content-Type 应返回空字符串,实际 '%s'", result)
103+
}
104+
},
105+
},
106+
{
107+
name: "无boundary的Content-Type",
108+
buildInput: func() (*bytes.Buffer, string) {
109+
return bytes.NewBufferString("some data"), "multipart/form-data"
110+
},
111+
checkResult: func(t *testing.T, result string) {
112+
if result != "" {
113+
t.Errorf("无 boundary 应返回空字符串,实际 '%s'", result)
114+
}
115+
},
116+
},
117+
{
118+
name: "无法解析的Content-Type",
119+
buildInput: func() (*bytes.Buffer, string) {
120+
return bytes.NewBufferString("data"), ";;;invalid;;;"
121+
},
122+
checkResult: func(t *testing.T, result string) {
123+
if result != "" {
124+
t.Errorf("无法解析的 Content-Type 应返回空字符串,实际 '%s'", result)
125+
}
126+
},
127+
},
128+
{
129+
name: "boundary不匹配的无效body",
130+
buildInput: func() (*bytes.Buffer, string) {
131+
return bytes.NewBufferString("not a valid multipart body"),
132+
"multipart/form-data; boundary=nonexistent"
133+
},
134+
checkResult: func(t *testing.T, result string) {
135+
// 没有有效的 part,应返回空字符串(parts 为空,Join 后为 "")
136+
if result != "" {
137+
t.Errorf("无效 body 应返回空字符串,实际 '%s'", result)
138+
}
139+
},
140+
},
141+
{
142+
name: "空的multipart body",
143+
buildInput: func() (*bytes.Buffer, string) {
144+
var buf bytes.Buffer
145+
writer := multipart.NewWriter(&buf)
146+
_ = writer.Close() // 关闭但不写入任何 part
147+
return &buf, writer.FormDataContentType()
148+
},
149+
checkResult: func(t *testing.T, result string) {
150+
if result != "" {
151+
t.Errorf("空 multipart body 应返回空字符串,实际 '%s'", result)
152+
}
153+
},
154+
},
155+
{
156+
name: "多个文件上传",
157+
buildInput: func() (*bytes.Buffer, string) {
158+
var buf bytes.Buffer
159+
writer := multipart.NewWriter(&buf)
160+
part1, _ := writer.CreateFormFile("file1", "a.txt")
161+
_, _ = part1.Write([]byte("aaa"))
162+
part2, _ := writer.CreateFormFile("file2", "b.jpg")
163+
_, _ = part2.Write([]byte("bbb"))
164+
_ = writer.Close()
165+
return &buf, writer.FormDataContentType()
166+
},
167+
checkResult: func(t *testing.T, result string) {
168+
if result != "file1=@a.txt&file2=@b.jpg" {
169+
t.Errorf("期望 'file1=@a.txt&file2=@b.jpg',实际 '%s'", result)
170+
}
171+
},
172+
},
173+
{
174+
name: "字段值为空字符串",
175+
buildInput: func() (*bytes.Buffer, string) {
176+
return buildMultipart(map[string]string{"empty": ""}, nil)
177+
},
178+
checkResult: func(t *testing.T, result string) {
179+
if result != "empty=" {
180+
t.Errorf("期望 'empty=',实际 '%s'", result)
181+
}
182+
},
183+
},
184+
}
185+
186+
for _, tt := range tests {
187+
t.Run(tt.name, func(t *testing.T) {
188+
buf, contentType := tt.buildInput()
189+
result := multipartBodySummary(buf, contentType)
190+
tt.checkResult(t, result)
191+
})
192+
}
193+
}
194+
16195
// TestStat_Methods 测试 Stat 的基础方法:String、Print、RequestBody、ResponseBody
17196
func TestStat_Methods(t *testing.T) {
18197
stat := &Stat{
@@ -381,6 +560,66 @@ func TestServeLoad(t *testing.T) {
381560
}
382561
},
383562
},
563+
{
564+
name: "multipart/form-data请求_summary成功",
565+
buildReq: func() (*http.Request, *ResponseWriter, *bytes.Buffer) {
566+
// 构造有效的 multipart body
567+
var body bytes.Buffer
568+
writer := multipart.NewWriter(&body)
569+
_ = writer.WriteField("username", "alice")
570+
part, _ := writer.CreateFormFile("avatar", "photo.png")
571+
_, _ = part.Write([]byte("fake image data"))
572+
_ = writer.Close()
573+
574+
contentType := writer.FormDataContentType()
575+
req, _ := http.NewRequest("POST", "/upload", nil)
576+
req.Header.Set("Content-Type", contentType)
577+
req.RemoteAddr = "10.0.0.1:9090"
578+
w := &ResponseWriter{
579+
StatusCode: 200,
580+
Content: bytes.NewBufferString(`{"ok":true}`),
581+
}
582+
buf := bytes.NewBuffer(body.Bytes())
583+
return req, w, buf
584+
},
585+
checkFunc: func(t *testing.T, stat *Stat) {
586+
bodyStr, ok := stat.Request.Body.(string)
587+
if !ok {
588+
t.Fatalf("multipart 请求体应为字符串,实际类型 %T", stat.Request.Body)
589+
}
590+
if !strings.Contains(bodyStr, "username=alice") {
591+
t.Errorf("应包含 'username=alice',实际 '%s'", bodyStr)
592+
}
593+
if !strings.Contains(bodyStr, "avatar=@photo.png") {
594+
t.Errorf("应包含 'avatar=@photo.png',实际 '%s'", bodyStr)
595+
}
596+
},
597+
},
598+
{
599+
name: "multipart/form-data请求_summary失败回退",
600+
buildReq: func() (*http.Request, *ResponseWriter, *bytes.Buffer) {
601+
// 构造 Content-Type 是 multipart/form-data 但 body 内容无效的情况
602+
contentType := "multipart/form-data; boundary=invalidboundary"
603+
req, _ := http.NewRequest("POST", "/upload", nil)
604+
req.Header.Set("Content-Type", contentType)
605+
req.RemoteAddr = "10.0.0.1:9090"
606+
w := &ResponseWriter{
607+
StatusCode: 400,
608+
Content: bytes.NewBufferString("bad request"),
609+
}
610+
buf := bytes.NewBufferString("this is not valid multipart data")
611+
return req, w, buf
612+
},
613+
checkFunc: func(t *testing.T, stat *Stat) {
614+
bodyStr, ok := stat.Request.Body.(string)
615+
if !ok {
616+
t.Fatalf("无效 multipart 请求体应为字符串,实际类型 %T", stat.Request.Body)
617+
}
618+
if bodyStr != "(multipart)" {
619+
t.Errorf("期望 '(multipart)',实际 '%s'", bodyStr)
620+
}
621+
},
622+
},
384623
}
385624

386625
for _, tt := range tests {

0 commit comments

Comments
 (0)