Go の シンプルで動作の早いWebフレームワークであるginと、multipartのライブラリを使って、サーバーとクライアントの複数ファイルアップロードの仕組みを記述してみたいと思います。
サーバー側
ginはサンプルが充実しているので書くのがとても簡単でした。ルーターを作って、そこにルーティングを設定するだけです。
func main() {
router := gin.Default()
router.POST("/csv", ReceiveFiles)
srv := &http.Server{
Addr: ":38081",
Handler: router,
}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %s\n", err)
}
}()
// send request
err := SendFiles()
log.Printf("error: %v\n", err)
}
POST の登録箇所の定義を見てみると、相対パスとハンドラがあります。
// POST is a shortcut for router.Handle("POST", path, handle).
func (group *RouterGroup) POST(relativePath string, handlers ...HandlerFunc) IRoutes {
return group.handle("POST", relativePath, handlers)
}
ハンドラの条件はコンテキストを持った関数であればよさそうです。ここでは、ハンドラとして、ReceiveFiles という後で出てくるメソッドを渡しています。もちろん、他に,GET, PUT, DELETE 等の一通りのメソッドが定義されています。
ちなみに、go routine を使って server を起動しているのは、サーバーが上がった後の処理を書きたいからです。具体的には、このままでは、ctrl+c を押下しても停止しませんので、以前書いたブログのような作戦を使って、シグナルを受け取ったら終了するようにコードを書きます。
// HandlerFunc defines the handler used by gin middleware as return value.
type HandlerFunc func(*Context)
さて、実際のファイルを受け取る部分も簡単です。gin
が便利なユーティリティ関数を用意してくれていいます。
func ReceiveFiles(c *gin.Context) {
// Save file
form, _ := c.MultipartForm()
files := form.File["file"]
configPath := filepath.Join(".", "volley", "csv")
if _, err := os.Stat(configPath); os.IsNotExist(err) {
err = os.MkdirAll(configPath, os.ModePerm)
}
for _, file := range files {
log.Println(file.Filename)
dist := filepath.Join(configPath, file.Filename)
c.SaveUploadedFile(file, dist)
}
}
マルチパートの受け取りはこの関数で
// MultipartForm is the parsed multipart form, including file uploads.
func (c *Context) MultipartForm() (*multipart.Form, error) {
このFormストラクトは下記の定義です。ValueとFileのmapを持っています。Fileからは、ファイルヘッダの構造体が返ります。
// Form is a parsed multipart form.
// Its File parts are stored either in memory or on disk,
// and are accessible via the *FileHeader's Open method.
// Its Value parts are stored as strings.
// Both are keyed by field name.
type Form struct {
Value map[string][]string
File map[string][]*FileHeader
}
ファイルヘッダは、その中身やファイル名を持っていますので、
// A FileHeader describes a file part of a multipart request.
type FileHeader struct {
Filename string
Header textproto.MIMEHeader
Size int64
content []byte
tmpfile string
}
:
// Open opens and returns the FileHeader's associated File.
func (fh *FileHeader) Open() (File, error) {
if b := fh.content; b != nil {
r := io.NewSectionReader(bytes.NewReader(b), 0, int64(len(b)))
return sectionReadCloser{r}, nil
}
return os.Open(fh.tmpfile)
}
SaveUploadedFile 関数を使えば、場所さえ指定したらファイルを書いてくれます。これは、multipart.FileHeader構造体に、Open()メソッドが生えているのですね。
// SaveUploadedFile uploads the form file to specific dst.
func (c *Context) SaveUploadedFile(file *multipart.FileHeader, dst string) error {
src, err := file.Open()
if err != nil {
return err
}
defer src.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, src)
return err
}
こんな感じでとても簡単にサーバー側のコードが書けました。
Multipartアップロードのクライアント側実装
ローカルのファイル探索
この例では、csv
の拡張子を持つファイルを対象に、アップロードしています。アップロードするために、アップロード対象のファイルの検索を行うのは、filepath.Walk
メソッドが便利そうです。ルートディレクトリを指定すると、そのルートディレクトリにあるファイル、ディレクトリ、サブディレクトリを探索してくれます。ただし、あんまり深いディレクトリだと、効率が悪いそうです。(ソースコードのコメントより) WalkFunc
はファイルが見つかったときのコールバックで、ファイルのパスと、os.FileInfo
が返ります。最後のエラーは、探索の最中でエラーがあった場合の振る舞いを、WalkFunc側で決めるためのものです。
func Walk(root string, walkFn WalkFunc) error {
:
type WalkFunc func(path string, info os.FileInfo, err error) error
os.FileInfo
はそのまま「ファイルの情報」ですね。
// A FileInfo describes a file and is returned by Stat and Lstat.
type FileInfo interface {
Name() string // base name of the file
Size() int64 // length in bytes for regular files; system-dependent for others
Mode() FileMode // file mode bits
ModTime() time.Time // modification time
IsDir() bool // abbreviation for Mode().IsDir()
Sys() interface{} // underlying data source (can return nil)
}
マルチパートの作成
Go言語のmime/multipartパッケージでファイルをアップロードしましょうというブログが素晴らしい内容でした。MultiPartは本来、生では次のようになります。具体的なデータは上記のブログから転載しています。
POST /upload HTTP/1.1
Host: localhost:3000
Accept-Encoding: gzip
Content-Length: 254
Content-Type: multipart/form-data; boundary=c7245ee369df31f524686275eb89381b30581b1ca5557de2453f9f8cf66c
User-Agent: Go-http-client/1.1
--c7245ee369df31f524686275eb89381b30581b1ca5557de2453f9f8cf66c
Content-Disposition: form-data; name="file"; filename="hello.txt"
Content-Type: application/octet-stream
hello
--c7245ee369df31f524686275eb89381b30581b1ca5557de2453f9f8cf66c--
マルチパートは、上記のように、boundary
が設定されており、これを境界としていくつものファイルを一度に送ることができます。Content-Disposition
というヘッダの定義を見てみましょう。
本文が multipart/form-data である場合、Content-Disposition ヘッダーは、マルチパートを構成する各サブパートに付与され、そのフィールドに関する情報を示します。サブパートはContent-Type ヘッダーで定義された boundary によって区切られます。マルチパートの本文体に付与した場合、Content-Disposition は何の意味も持ちません。上記の例ではフィールド名を
file
として、1つのファイル分を、boundary
の中で表現しています。複数のファイルをboundary
で区切って一回で送信することもできます。サーバーのところで出てきたfile
はこのフィールド名ですね。例
Content-Disposition: form-data
Content-Disposition: form-data; name="fieldName"
Content-Disposition: form-data; name="fieldName"; filename="filename.jpg"
コードの本文
multipart.NewWriter
さて、具体的にマルチパートのアップロードの部分を見てみると、multipart.NewWriter
関数がまずあります。ここで、適当にバウンダリをランダムに作って保持しています。Writer を持っていますね。
// NewWriter returns a new multipart Writer with a random boundary,
// writing to w.
func NewWriter(w io.Writer) *Writer {
return &Writer{
w: w,
boundary: randomBoundary(),
}
}
multipart.CreateFromFile
こちらのメソッド見てみると、面倒なContent-Disposition やContent-Typeをファイルから作ってくれるようです。ただし、ヘッダのみなので、ご注意。
// 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)
}
そのあとで、func Copy(dst Writer, src Reader) (written int64, err error)
によって、CreateFromFile
で返ってきたWriterにファイルの本体をコピーします。
multipart.FromDataContentType
こちらで、ContentType を返します。このヘッダに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 SendFiles() error {
body := &bytes.Buffer{}
mw := multipart.NewWriter(body)
fieldName := "file"
matchCsv := regexp.MustCompile(`.*\.csv`)
err := filepath.Walk(".", func(path string, info os.FileInfo, err error) error {
if matchCsv.MatchString(path) {
file, err := os.Open(path)
if err != nil {
return err
}
fw, err := mw.CreateFormFile(fieldName, path)
if err != nil {
return err
}
_, err = io.Copy(fw, file)
if err != nil {
return err
}
file.Close()
}
return nil
})
if err != nil {
return err
}
contentType := mw.FormDataContentType()
err = mw.Close()
if err != nil {
return err
}
resp, err := http.Post("http://localhost:38081/csv", contentType, body)
if err != nil {
return err
}
defer resp.Body.Close()
responseBody, err := ioutil.ReadAll(resp.Body)
if err != nil {
return err
}
fmt.Println(string(responseBody))
return nil
}
最後にPOSTリクエストを送信します。内部のマルチパートの組み立てはmultipart
パッケージの便利メソッドが程よくやってくれるので、楽ちんですね!
resp, err := http.Post("http://localhost:38081/csv", contentType, body)
実行結果
これら2つのファイルをアップロード
$ ls
DocumentApi.csv DocumentApi2.csv go.mod go.sum main.go
プログラムを動かすとサーバーが起動して、アップロードしてファイルを書いたら終了します。Windowsだと、ファイアーウォールのポート公開の警告がでますが、その頃には処理は終わっています。
$ go run main.go
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.
[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
- using env: export GIN_MODE=release
- using code: gin.SetMode(gin.ReleaseMode)
[GIN-debug] POST /csv --> main.ReceiveFiles (3 handlers)
2019/12/23 08:48:58 DocumentApi.csv
2019/12/23 08:48:58 DocumentApi2.csv
[GIN] 2019/12/23 - 08:48:58 | 200 | 16.1636ms | 127.0.0.1 | POST /csv
2019/12/23 08:48:58 error: <nil>
ディレクトリを見ると、2つのファイルが作成されています。ちなみに、ファイルが存在してもエラーは出ず、上書きされるようですね。
$ cd volley/csv
$ ls
DocumentApi.csv DocumentApi2.csv
挙動が理解できました。