완성 코드는 맨 아래에 있습니다.
multipart/form-data
multipart/form-data가 무엇인지는 여기에 잘 소개되어 있습니다(혹은 RFC7578)
쉽게 말하자면 multipart/form-data는 여러 파트를 boundary로 구분하여 한 번에 전송하는 HTTP요청의 한 방식입니다.
이 방식은 여러 종류의 content type을 함께 전송할 때 유용합니다. (username, image를 같이 전송하는 등)
각 파트는 헤더와 데이터를 가집니다.
POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=boundary123
--boundary123
Content-Disposition: form-data; name="field1"
value1
--boundary123
Content-Disposition: form-data; name="file1"; filename="example.txt"
Content-Type: text/plain
(file contents here)
--boundary123--
헤더에서 바운더리를 정하고
(--바운더리)로 파트를 구분합니다.
마지막엔 (--바운더리--)로 메시지의 끝을 명시합니다.
전송
key, value
func TestMultipartSelfTest(t *testing.T) {
reqBody := new(bytes.Buffer)
w := multipart.NewWriter(reqBody)
for k, v := range map[string]string{
"date": time.Now().Format(time.RFC3339),
"Description": "form values with attached files",
} {
err := w.WriteField(k, v)
if err != nil {
t.Fatal(err)
}
}
}
mime/multipart 패키지의 NewWriter 함수는 io.Writer 인터페이스를 인자로 받아 해당 버퍼를 사용해 multipart Writer를 만들어줍니다.
사용한 Writer의 메서드는 다음과 같습니다.
// CreateFormField calls CreatePart with a header using the
// given field name.
func (w *Writer) CreateFormField(fieldname string) (io.Writer, error) {
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition",
fmt.Sprintf(`form-data; name="%s"`, escapeQuotes(fieldname)))
return w.CreatePart(h)
}
// WriteField calls CreateFormField and then writes the given value.
func (w *Writer) WriteField(fieldname, value string) error {
p, err := w.CreateFormField(fieldname)
if err != nil {
return err
}
_, err = p.Write([]byte(value))
return err
}
Writer의 WriteField메서드에 key, value를 전달하면 함수 내부에서 CreateFormField함수를 호출해 파트 헤더를 만들고 파트 데이터를 입력합니다
파일
파일의 경우 Writer의 CreateFormFile메서드를 사용할 수 있습니다.
// CreateFormFile is a convenience wrapper around CreatePart. It creates
// a new form-data header with the provided field name and file name.
func (w *Writer) CreateFormFile(fieldname, filename string) (io.Writer, error) {
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition",
fmt.Sprintf(`form-data; name="%s"; filename="%s"`,
escapeQuotes(fieldname), escapeQuotes(filename)))
h.Set("Content-Type", "application/octet-stream")
return w.CreatePart(h)
}
하지만 CreateFormFile메서드는 파트의 Content-Type헤더를 application/octet-stream으로 고정합니다.
파일의 종류에 따라 Content-Type을 다르게 하고 싶기 때문에 CreateFormFile메서드를 사용하지 않고 따로 작성해 주었습니다.
for i, file := range []string{
"./files/hello.txt",
"./files/ham.jpg",
} {
f, err := os.Open(file)
if err != nil {
t.Fatal(err)
}
defer f.Close()
mimeType := mime.TypeByExtension(filepath.Ext(f.Name()))
if mimeType == "" {
mimeType = "application/octet-stream"
}
filename := filepath.Base(f.Name())
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition",
fmt.Sprintf(`form-data; name="%s"; filename="%s"`,
escapeQuotes(fmt.Sprintf("file%d", i)),
escapeQuotes(filename)))
h.Set("Content-Type", mimeType)
part, err := w.CreatePart(h)
if err != nil {
t.Fatal(err)
}
_, err = io.Copy(part, f)
if err != nil {
t.Fatal(err)
}
}
hello.txt내용은 UTF-8로 인코딩 된 Hello,world!가 있고 ham.jpg는 이미지입니다.
메시지의 끝
err := w.Close()
if err != nil {
t.Fatal(err)
}
넣고 싶은 정보들을 다 넣었다면 Writer를 닫아주어야 합니다.
// Close finishes the multipart message and writes the trailing
// boundary end line to the output.
func (w *Writer) Close() error {
if w.lastpart != nil {
if err := w.lastpart.close(); err != nil {
return err
}
w.lastpart = nil
}
_, err := fmt.Fprintf(w.w, "\r\n--%s--\r\n", w.boundary)
return err
}
Close() 메서드는 메시지의 끝을 처리해 줍니다.
요청
ts := httptest.NewServer(http.HandlerFunc(processMultipart(t)))
defer ts.Close()
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := client.Post(ts.URL, w.FormDataContentType(), reqBody)
if err != nil {
fmt.Println("Request failed:", err)
return
}
defer resp.Body.Close()
httptest서버를 만들고 post요청을 진행합니다.
요청헤더에 w.FormDataContentType()를 넣어줌으로써
multipart/form-data와 Writer가 http요청 바디를 만들 때 사용했던 boundary를 추가해 줍니다.
// FormDataContentType returns the Content-Type for an HTTP
// multipart/form-data with this Writer's Boundary.
func (w *Writer) FormDataContentType() string {
b := w.boundary
// We must quote the boundary if it contains any of the
// tspecials characters defined by RFC 2045, or space.
if strings.ContainsAny(b, `()<>@,;:\"/[]?= `) {
b = `"` + b + `"`
}
return "multipart/form-data; boundary=" + b
}
수신
전체 바디 읽기
func processMultipart(t *testing.T) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatal(err)
}
t.Logf("\n %s", string(body))
}
}
전송을 처리할 테스트 서버의 핸들러 함수를 작성해 줍니다.
r.Body를 읽어서 전체 바디를 볼 수 있습니다.
파트 읽기
reader, err := r.MultipartReader()
if err != nil {
t.Fatal(err)
}
for {
part, err := reader.NextPart()
if err != nil {
if err == io.EOF {
break
}
t.Fatal(err)
}
for name, values := range part.Header {
t.Logf("%s: %s", name, values)
}
if part.FormName() != "" && part.FileName() == "" {
data, err := io.ReadAll(part)
if err != nil {
t.Fatal(err)
}
t.Logf("Form field: %s = %s\n", part.FormName(), string(data))
}
// 파일 byte가 100 넘어가면 truncate해서 string으로 출력
// 아니면 파일 처리 코드를 작성해줄 수 있음
if part.FileName() != "" {
data, err := io.ReadAll(part)
if err != nil {
t.Fatal(err)
}
t.Logf("File name: %s\n", part.FileName())
if len(data) >= 100 {
t.Logf("File content(truncate): %s\n", string(data[:100]))
} else {
t.Logf("File content: %s\n", string(data))
}
}
t.Log()
}
}
request의 MultipartReader() 메서드의 반환값인 multipart.Reader를 사용하여 각 파트를 확인할 수 있습니다.
출력
~/Downloads/gonetwork/http (main) $ go test -v multipart_test.go
=== RUN TestMultipartSelfTest
multipart_test.go:54: Content-Disposition: [form-data; name="Description"]
multipart_test.go:62: Form field: Description = form values with attached files
multipart_test.go:78:
multipart_test.go:54: Content-Disposition: [form-data; name="date"]
multipart_test.go:62: Form field: date = 2024-06-10T18:14:18+09:00
multipart_test.go:78:
multipart_test.go:54: Content-Disposition: [form-data; name="file0"; filename="hello.txt"]
multipart_test.go:54: Content-Type: [text/plain; charset=utf-8]
multipart_test.go:71: File name: hello.txt
multipart_test.go:75: File content: Hello, world!
multipart_test.go:78:
multipart_test.go:54: Content-Disposition: [form-data; name="file1"; filename="ham.jpg"]
multipart_test.go:54: Content-Type: [image/jpeg]
multipart_test.go:71: File name: ham.jpg
multipart_test.go:73: File content(truncate): ����JFIFHH��C
%-(0%()(��C
multipart_test.go:78:
--- PASS: TestMultipartSelfTest (0.01s)
PASS
ok command-line-arguments 0.373s
전체 코드
package main
import (
"bytes"
"fmt"
"io"
"mime"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/textproto"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// httptest에 넘겨줄 핸들러 함수(클로저?)
func processMultipart(t *testing.T) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
// body, err := io.ReadAll(r.Body)
// if err != nil {
// t.Fatal(err)
// }
// t.Logf("\n %s", string(body))
// t.Log()
if r.Method != http.MethodPost {
t.Fatalf("expected method %s; actual status %s",
http.MethodPost, r.Method)
}
// 요청 헤더 출력
// t.Logf("Request Headers\n")
// for name, values := range r.Header {
// t.Logf("%s: %v\n", name, values)
// }
// t.Log("\n")
reader, err := r.MultipartReader()
if err != nil {
t.Fatal(err)
}
for {
part, err := reader.NextPart()
if err != nil {
if err == io.EOF {
break
}
t.Fatal(err)
}
for name, values := range part.Header {
t.Logf("%s: %s", name, values)
}
if part.FormName() != "" && part.FileName() == "" {
data, err := io.ReadAll(part)
if err != nil {
t.Fatal(err)
}
t.Logf("Form field: %s = %s\n", part.FormName(), string(data))
}
// 파일 byte가 100 넘어가면 truncate해서 출력
if part.FileName() != "" {
data, err := io.ReadAll(part)
if err != nil {
t.Fatal(err)
}
t.Logf("File name: %s\n", part.FileName())
if len(data) >= 100 {
t.Logf("File content(truncate): %s\n", string(data[:100]))
} else {
t.Logf("File content: %s\n", string(data))
}
}
t.Log()
}
w.WriteHeader(http.StatusOK)
}
}
func TestMultipartSelfTest(t *testing.T) {
reqBody := new(bytes.Buffer)
w := multipart.NewWriter(reqBody)
for k, v := range map[string]string{
"date": time.Now().Format(time.RFC3339),
"Description": "form values with attached files",
} {
err := w.WriteField(k, v)
if err != nil {
t.Fatal(err)
}
}
for i, file := range []string{
"./files/hello.txt",
"./files/ham.jpg",
} {
f, err := os.Open(file)
if err != nil {
t.Fatal(err)
}
defer f.Close()
mimeType := mime.TypeByExtension(filepath.Ext(f.Name()))
if mimeType == "" {
mimeType = "application/octet-stream"
}
filename := filepath.Base(f.Name())
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition",
fmt.Sprintf(`form-data; name="%s"; filename="%s"`,
escapeQuotes(fmt.Sprintf("file%d", i)),
escapeQuotes(filename)))
h.Set("Content-Type", mimeType)
part, err := w.CreatePart(h)
if err != nil {
t.Fatal(err)
}
_, err = io.Copy(part, f)
if err != nil {
t.Fatal(err)
}
}
err := w.Close()
if err != nil {
t.Fatal(err)
}
ts := httptest.NewServer(http.HandlerFunc(processMultipart(t)))
defer ts.Close()
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := client.Post(ts.URL, w.FormDataContentType(), reqBody)
if err != nil {
fmt.Println("Request failed:", err)
return
}
defer resp.Body.Close()
}
func escapeQuotes(s string) string {
return strings.ReplaceAll(s, `"`, `\"`)
}
'Go' 카테고리의 다른 글
[Go] 클로저 (0) | 2024.08.22 |
---|---|
[Go] 컨트리뷰트 도전 실패 (0) | 2024.06.10 |
[Go] 테스트가능한 예시 (Testable Examples) (0) | 2024.06.05 |
[Go] 빈 구조체의 의미와 활용 struct{} (0) | 2024.06.04 |