🐥

gocsvとmime/multipartを利用してmultipart/formdata形式のCSVをPOSTする

2023/01/31に公開

こんにちは!CastingONE の岩井です。

はじめに

わたしは普段、フロントエンドとバックエンドの両方を担当しています。どっちもめちゃ楽しいのですが、今回はバックエンドで外部 API に CSV データを送る実装をした際に、結構つまづいてしまったので備忘録としてその方法を残しておこうと思います。

net/http を利用して multipart/formdata 形式の POST リクエストを送る必要があったのですが、Go でリクエスト組み立てるのが、難しかったです。

同じように悩まれている方がいらしたら、ぜひ参考になれば嬉しいです 😊

multipart/formdata データ形式とは

実際に実装する前に、どういったリクエストを組み立てるべきか想像できた方が良いので、multipart/formdataの形式について確認しておきます。わたしはここがかなり危うい理解だったということもあり、根本的な理解も含めて調べながらだったので、実装が難航しました。

既に知っているよという方は、読み飛ばしていただいてかまいません。

マルチでないリクエストとの違い

フロントエンドの実装をしていると、フォームを利用したファイルアップロード等でmultipart/formdataという形式をよく聞くと思います。multipart/formdataは「一度の HTTP リクエストで、複数のデータ形式(MIMEType)を扱いたい」ときに利用します。

multiple/formdataの図説

普通のリクエストの場合、形式が json データであればContent-type: application/jsonを指定し、body に json をぎゅっと詰めてあげるだけです。しかし、複数のデータ形式の場合は Body の中に仕切りをつくり、「ここは json、ここからはテキスト!」リクエストを受け取った人がわかりやすい形にする必要があります。

そういった処理を行う必要があるので、multipart/formdataには他のデータ形式にはないいくつかの概念があります。

  • part
    • Body の中に「ここの中は json!」と指定した入れ物を指します。
  • boundary
    • 「ここまでが json!」の仕切り部分のことを指します。

また、「ここの中は json!」をはじめとした中の情報を伝えるために、Content-TypeContent-Dispositionといったヘッダーぽい指定も、通常のヘッダーとは別で必要になります。

実装

以上のmultipart/formdata形式についての理解を踏まえて、go でリクエストを組み立てていきます。

利用パッケージ(gocsv mime/multipart)

今回利用したパッケージは、以下の通りです。

  • gocsv
    • csv へ変換するためのツール
  • mime/multipart
    • 複数のパート(part)を作るためのツール
  • net/http
    • HTTP で POST するためのツール

CSV の作成

CSV の作成にはgocsvを利用しました。
構造体を定義してデータを流し込み、メソッドを実行することで、とっても簡単に CSV 形式に沿ったデータを生成してくれます。

package main

import (
	"fmt"

	"github.com/gocarina/gocsv"
)


func main() {
	type Person struct {
		Id   string `csv:"id"`
		Name string `csv:"name"`
		Age  string `csv:"client_age"`
	}

	var payload = []*Person{
		{"a1", "たろう", "エンジニア"},
		{"a2", "はなこ", "デザイナー"},
		{"a3", "じろう", "ディレクター"},
	}

	// CSVを簡単に生成
	cs, err := gocsv.MarshalString(&payload)
	if err != nil {
		panic(err)
	}

	// ヘッダーを消したければこのように指定するなど
	// たくさんの便利なメソッドが用意されています
	cswh, err = gocsv.MarshalStringWithoutHeaders(&payload)
	if err != nil {
		panic(err)
	}
}

multipart 部分を作成

次に肝心の multipart の中身を作成していきます。
先に説明した通り、この形式で送るためにはいくつかのヘッダー情報や boundary を書き込まなくてはいけないのですが、それを作成してくれるのがmime/multipartです。

以下のようにして、1 つの part が出来上がります。

func main() {
	// CSV作成部分は省略

	cs, err := gocsv.MarshalString(&payload)
	if err != nil {
		panic(err)
	}

	payloadBuffer := bytes.NewBufferString(cs)

	var b bytes.Buffer

	// boundaryを持つWriter
	mw := multipart.NewWriter(&b)
	part := make(textproto.MIMEHeader)
	part.Set("Content-Type", "text/csv")
	part.Set("Content-Disposition", `form-data; name="file"; filename="file"`)

	// 引数に渡されたheader情報(上でセットしたContent-Type等)を持つpartを作成
	pw, err := mw.CreatePart(part)
	if err != nil {
		panic(err)
	}

	// 作成したpartに情報を書き込む
	if _, err := io.Copy(pw, payloadBuffer); err != nil {
		panic(err)
	}

	mw.Close()

	// 出力してみるとヘッダー情報やboundaryが追加されたpartが出来上がっていることがわかります
	// fmt.Println(b.String());
}

ヘッダー情報や boundary がきちんと入っていることがわかります。

fmt.Println結果

その他 HTTP リクエスエト部分を作成

あとは net/http のシンプルな通信でできました。

Content-Typeに指定したmw.FormDataContentType()を出力してみると、multipart/form-data; boundary=xxxxxxというようにきちんと boundary が指定されているのがわかると思います。リクエストを受け取った人はこのヘッダー情報に指示されている boundary を見て、どこまでがどういうデータ形式のリクエストかをきちんと判定できるようになりました。

func main() {
	// multipart作成部分は省略

	req, err := http.NewRequest("POST", "URL", &b)
	if err != nil {
		panic(err)
	}
	// boundary付きのmultiple/formdata形式のヘッダー情報をセット
	req.Header.Set("Content-Type", mw.FormDataContentType())

	// 出力してみるとリクエスト本体の方のContent-Typeがどう指定されているのかわかります
	// fmt.Println(mw.FormDataContentType())

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()
}

以上で無事に、gocsvmime/multipart を利用して multipart/formdata 形式の HTTP を POST することができました!

おわりに

multipart/formdataをはじめとした根本の理解も進んで、とっても良い経験になりました。

何か、もっとこうした方が良いというのがあれば、ぜひ教えてください!

CastingONE について

https://www.wantedly.com/projects/1130967
https://www.wantedly.com/projects/768663

Discussion