今川館

都内勤務の地味OLです

はじめてのwebアプリ④ アップロードファイルの扱い方

この記事のソースコードこちら

ファイルを読み込んで文字数を数える機能を追加する

文字数カウントするwebアプリも入力テキストを処理するだけなら出来上がったので、今度はファイルをアップロードしてカウントする機能を追加することにした。

(画面イメージ)
f:id:imagawa_yakata:20161220110636p:plain

ファイルアップロードの結果として

  • 文字数(不正な文字は除外した数)
  • バイト数
  • 不正なバイト数

これらを画面に表示する。

ここまでは比較的悩むことなく簡単に進めてこられたが、アップロードファイルを扱う必要が生じて、途端に進みが遅くなった。
色々ハマって一応完成したので、学んだことを以下記す。

ファイルアップロードに対応

リクエストメソッドはPOST, enctype="multipart/form-data"を指定

取り敢えず画面からファイルをアップロードしなければならないので、

<form action="/count/file" method="POST" enctype="multipart/form-data">
  <input type="file" name="file">
  <input type="submit">
</form>

このように、リクエストメソッドはPOST、enctypeは"multipart/form-data"にしておく。

http.Request.FormFileからアップロードされたファイルを取得

次に、サーバー側の処理でアップロードされたファイルを取得、利用する方法だが、リクエストがFormFileというメソッドを持っているのでこれを使うことにした。

FormFileはキーを指定するとファイルを返してくれるが、その際、データをhttp.defaultMaxMemoryまで自動的にメモリに読み込んで、残りを一時ファイルに保存する。

FormFileの該当箇所
https://golang.org/src/net/http/request.go?s=34318:34403#L1118

func (r *Request) FormFile(key string) (multipart.File, *multipart.FileHeader, error) {
	if r.MultipartForm == multipartByReader {
		return nil, nil, errors.New("http: multipart handled by MultipartReader")
	}
	if r.MultipartForm == nil {
		err := r.ParseMultipartForm(defaultMaxMemory)
		if err != nil {
			return nil, nil, err
		}
	}
	(後略)
ファイルの後始末

FormFileの処理がわかったのは良いとして、一時ファイルが作られたときは後で削除する必要があるが、それはどう対処するのだろうか。
これには、RequestにMutipartForm.RemoveAll()というお誂え向きのメソッドがあった。
ソースを見ると、一時ファイルがあれば消す、と賢く処理してくれるようだ。

RemoveAllの該当箇所
https://golang.org/src/mime/multipart/formdata.go?s=2602:2634#L102

func (f *Form) RemoveAll() error {
	var err error
	for _, fhs := range f.File {
		for _, fh := range fhs {
			if fh.tmpfile != "" {
				e := os.Remove(fh.tmpfile)
				if e != nil && err == nil {
					err = e
				}
			}
		}
	}
	return err
}


そこで、deferに無名関数を指定し、

  1. file.Close() → アップロードファイルを閉じる
  2. r.MultipartForm.RemoveAll() → 一時ファイルが作成されていれば、削除

を呼ぶことにした(以下)。

file, _, err := r.FormFile("file")
if err != nil {
	http.Error(w, fmt.Sprintf("%v", err), http.StatusBadRequest)
}

defer func() {
	file.Close()
	r.MultipartForm.RemoveAll()
}()

ファイルから正常なruneの個数を数えたい

今までは文字列の中の文字数やバイト数を数えれば良いので簡単だったのだが、今度はファイルを見なければならない。
せっかくファイルを処理するのだから、全データを[]byteの変数に取り出してカウントするといった無駄なことはしたくないものだ。

最初、unicode/utf8を使って何とかしようと試みたがうまくいかず、色々調べた結果、実はbufioのReaderがReadRuneというメソッドを持っているのでこれを使えば良いことが判った。

ReadRuneの該当箇所
https://golang.org/src/bufio/bufio.go?s=6027:6084#L250

func (b *Reader) ReadRune() (r rune, size int, err error) {
	for b.r+utf8.UTFMax > b.w && !utf8.FullRune(b.buf[b.r:b.w]) && b.err == nil && b.w-b.r < len(b.buf) {
		b.fill() // b.w-b.r < len(buf) => buffer is not full
	}
(後略)

このように、正常なUTF-8の最大バイト数UTFMax(=4)まで読み取って正常なUnicodeコードポイント(rune)であるか調べて文字を返却してくれる。

なお、utf8にはruneを扱う上で必要な定数がちゃんと定義されているので覚えておくと良いかもしれない。

https://golang.org/pkg/unicode/utf8/#pkg-constants

const (
        RuneError = '\uFFFD'     // the "error" Rune or "Unicode replacement character"
        RuneSelf  = 0x80         // characters below Runeself are represented as themselves in a single byte.
        MaxRune   = '\U0010FFFF' // Maximum valid Unicode code point.
        UTFMax    = 4            // maximum number of bytes of a UTF-8 encoded Unicode character.
)

サンプルコード

最終的に、プログラムは以下のようになった。

type Context map[string]interface{}

func FileWordCountHandler(w http.ResponseWriter, r *http.Request) {
	if r.Method != "POST" {
		http.Error(w, "POSTでアクセスしてください", http.StatusMethodNotAllowed)
		return
	}

	file, _, err := r.FormFile("file")
	if err != nil {
		http.Error(w, fmt.Sprintf("%v", err), http.StatusBadRequest)
	}

	defer func() {
		file.Close()
		r.MultipartForm.RemoveAll()
	}()

	in := bufio.NewReader(file)
	count := 0
	bc := 0
	invalid := 0

	for {
		r, size, err := in.ReadRune()
		if err == io.EOF {
			break
		}
		if r == utf8.RuneError {
			invalid += size
		} else {
			bc += size
			// 正常な文字だけカウントする。
			count++
		}
	}

	wc, _ := htmlTemplate.New("file_wc").Parse(`
	<html>
		<head>
			<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
			<title>文字数カウント結果</title>
			<style type="text/css">
				.err { border: solid 1px red; }
			</style>
		</head>

		<body>
			<h1>文字数カウント結果</h1>

			文字数は: {{.count}}<br>
			バイト数は: {{.bc}}<br>
			不正なバイト数は: {{.invalid}}<br>

			でした。<br><br>

			文字を入力してください。
			<form action="/count" method="GET">
			<input type="text" name="text" size="32">
			<input type="submit">
			</form>

			ファイルを調べたい場合はこちら。
			<form action="/count/file" method="POST" enctype="multipart/form-data">
			<input type="file" name="file">
			<input type="submit">
			</form>

		</body>
	</html>`)

	data := Context{
		"count":   count,
		"bc":      bc,
		"invalid": invalid,
	}
	if err := wc.Execute(w, data); err != nil {
		log.Panic(err)
	}
}