LoginSignup
2
2

More than 3 years have passed since last update.

MultiPart のファイルアップロード実装のCLIクライアント・サーバーコードを理解する

Posted at

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

挙動が理解できました。

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2