🐭

goでWebサービス No.5(form)

2020/10/11に公開

今回は、フォーム処理についてまとめます。
今回のデータはgithubにあげています。必要なファイルはクローンしてお使いください。

注意

コマンドラインを使った操作が出て来ることがあります。cd, ls, mkdir, touchといった基本的なコマンドを知っていることを前提に進めさせていただきます。
環境の違いで操作や実行結果に差異が出てくる可能性があります。私の実行環境は以下になります。

MacBook Pro (Early 2015)
macOS Catalina ver.10.15.6
go version go1.14.7 darwin/amd64
エディタはVScode

基本的な処理

まずは、ログインフォームという体でクライアントから送られていくる文字列の処理を見てみましょう。
ワーキングディレクトリに05formというディレクトリを作成し、以下のような構成でファイルを作成します。

:~/go/src/web_server_learning/form (master *)
$ tree
.
├── main.go
└── public
    └── login.html

login.htmlは以下のように記述します。

<!-- code:web5-1 -->
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>HTML Form</title>
</head>
<body>
    <form action="/login" method="POST">
        名前:<input type="text" name="username">
        <br />
        パスワード:<input type="password" name="password">
        <br />
        <input type="submit" value="送信">
        <br />
    </form>
</body>
</html>

フォームを作成し、「名前」と「パスワード」を入力する欄を作ります。
続いてサーバとなるmain.goです。

// code:web5-2
package main

import (
	"fmt"
	"html/template"
	"log"
	"net/http"
)

func main() {
	port := "8080"
	http.HandleFunc("/login", handleLogin)
	log.Printf("server is running on http://localhost:%s/login", port)
	log.Print(http.ListenAndServe(":"+port, nil))
}

func handleLogin(w http.ResponseWriter, r *http.Request) {
	if r.Method == "GET" {
		// 入力フォームを返す
		t, _ := template.ParseFiles("public/login.html")
		t.Execute(w, nil)
	}
	if r.Method == "POST" {
        // Requestを解析し入力情報を出力する。
		r.ParseForm()
		fmt.Println("name:", r.Form)
		http.Redirect(w, r, "/login", 301)
	}
}
/*実行結果
$ go run main.go
2020/10/07 11:21:06 server is running on http://localhost:8080/login
name: map[password:[1234] username:[ハム]]

// curlコマンドで複数のクエリパラメータを送った場合
$ curl -X POST -d "username=abc&password=123&username=xyz&password=789" http://localhost:8080/login
$ go run main.go
2020/10/10 10:13:12 server is running on http://localhost:8080/login
query: map[password:[123 789] username:[abc xyz]]
*/

ここではhandlerとしてhandleLogin関数を指定し処理します。リクエストのメソッドによって条件分岐をします。"GET"の場合は先ほどのフォームを作ったlogin.htmlを返します。"POST"の場合は、r.ParseForm関数でまずリクエストを解析します。その後Println関数で解析したFormの内容を表示し、もう一度/loginにリダイレクトします。
ParseFormで解析したあとでないとr.FormでPOSTの内容は確認できないので注意してください。
実行結果のようにParseFormで解析されたリクエストの内容はmapに格納されます。

入力内容の検証

ウェブ開発ではユーザのいかなる入力内容も信用してはいけません。ここでは入力内容の検証方法をみていきましょう。

これ以降は共通のフォームを拡張していくことでそれぞれの項目について確認していきましょう。サーバサイドで入力をチェックし良ければ入力内容を埋め込んだユーザページに遷移します。そうでない場合はログインページにリダイレクトします。各項目については関係ある部分をピックアップして書きます。
最後に全体のコードを載せますので全体のコードを先に見たい方は最後を確認してください。

必須フィールド

ここではユーザネームを通して必ず入力してもらいたい項目についてのチェックを確認しましょう。ユーザネームは文字列ですので文字数で確認すればいいですね。

<!-- code:web5-3 -->
<!-- login.html -->
<form action="/login" method="POST">
	名前(必須):<input type="text" name="username">
	<br />
	<input type="submit" value="送信">
	<br />
	<p>{{ .Message }}</p>
</form>
<!-- user.html -->
<body>
    <h1>ようこそ{{.Username}}さん</h1>
</body>

login.htmlはログイン(というより登録)を想定したフォームです。このページを使って入力内容を検証します。user.htmlログイン後のユーザページを想定しています。ログインで入力した内容を表示します。

// code:web5-4
// Emb は入力内容をuser.htmlに埋め込むためのもの
type Emb struct {
	Username string
	Age      int
	Gender   string
	Fruit    string
	Interest []string
}

// Msg は入力内容に誤りがある場合にlogin.htmlに埋め込むもの
type Msg struct {
	Message string
}

var emb Emb = Emb{}
var msg Msg = Msg{}

func handleLogin(w http.ResponseWriter, r *http.Request) {
	if r.Method == "GET" {
		// 入力フォームを返す
		t, _ := template.ParseFiles("public/login.html")
		t.Execute(w, msg)
	}
	if r.Method == "POST" {
		msg.Message = ""
		r.ParseForm()
		// 必須入力のチェック
		if len(r.Form.Get("username")) == 0 {
			msg.Message = msg.Message + "usernameが入力されていません。"
		}

		// 入力内容によってページ遷移
		if msg.Message == "" {
			emb = Emb{
				Username: r.Form.Get("username"),
			}
			http.Redirect(w, r, "/user", 301)
		} else {
			http.Redirect(w, r, "/login", 301)
		}
	}
}

Emb, Msgはhtmlテンプレートに埋め込むための構造体です。入力ミスを知らせるメッセージ、入力した内容をuser.htmlに埋め込むのにはこれらの変数を使います。
必須入力には組み込み関数lenでフォームの内容の内usernameの文字列の長さを求めます。その値が0ならmsg.Messageに"usernameが入力されていません。"を代入して"/login"にリダイレクトします。
msg.Message==""なら問題はなかったのでuser.htmlに遷移します。そうでなければ(何らかのメッセージがあれば)入力にミスがあるのでリダイレクトしlogin.htmlに戻ります。

数値

今度は年齢入力欄を作り年齢の値として妥当かどうかを確認しましょう。code:web5-3とwe5-4に以下を追加します。

<!-- code:web5-5 -->
<!-- login.html -->
年齢:<input type="text" name="age">
<!-- user.html -->
<p>年齢:{{.Age}}</p>

main.goも同様に以下の処理を追加します。

// code:web5-6
// 値のチェック
getint, interr := strconv.Atoi(r.Form.Get("age")) // 文字列の変換
if interr != nil || getint < 0 || getint > 100 {
	msg.Message = msg.Message + "年齢がおかしいです。"
}

今回は年齢は0~100までと想定し、その範囲で入力値が正しいかどうかを見ます。
code:web5-6ではstrconvパッケージ内のAtoi関数で送られてきたリクエストのクエリの内の年齢の部分をint型に変換します(この時クエリの値はstring型で送られてきます。)。
アルファベットや少数など整数に変換できないような値の場合はエラーが返ってきます。もし正しい値が入ればint型に変換してgetintに代入します。
それを2つ目のケースでチェックしています。

ここまでで実行して試してみてください。こちらが想定した値しか受け付けないことが確認できると思います。

数値以外でもメールアドレスなどある一定のパターンに一致するかをチェックしたいことはあるでしょう。その時は正規表現を使うことができます。ここではメールアドレスについての例を下に書きます。

m, err := regexp.MatchString(`^([\w\.\_]{2,10})@(\w{1,}).([a-z]{2,4})$`, r.Form.Get("email"))

これは第1引数に正規表現、第2引数に文字列を指定し、文字列が正規表現に一致すればtrueそうでなければfalseを返します。これでメールアドレスの文字列パターンになっているかをチェックすることができます。

プルダウン、ラジオボタン、チェックボックス

選択肢の中から選ぶタイプの入力では選択肢以外の値が送られてくることを想定しなくてはいけません。それはクライアントのミスかもしれませんし、悪意のある故意の行為かもしれません。いずれにせよサーバ側で対策する必要があります。

ラジオボタン

まずはラジオボタンからみてみましょう。以下のようにhtmlファイルに追加しましょう。

<!-- code:web5-7 -->
<!-- login.html -->
<input type="radio" name="gender" value="1"><input type="radio" name="gender" value="2"><!-- user.html -->
<p>性別:{{.Gender}}</p>
// ラジオボタンのチェック
// ①
slice := []string{"1", "2"}
exist := false
// ②
for _, s := range slice {
	if r.Form.Get("gender") == s {
		exist = true
	}
}
// ③
if !exist {
	msg.Message = msg.Message + "性別がおかしいです。"
}
  1. 選択肢がある場合は選択肢と同じ要素を持つリストの配列(ここではslice変数)を用意し、それに一致するものがあるかどうかで判断します。ここでもリクエストのクエリが文字列で取得されることに注意してください。なのでsliceの要素も文字列にするか型変換をする必要があります。
    チェック用の変数existを用意し初期値でfalseにしておきます。
  2. ループ文の中で配列内に一致する要素があればexist変数をtrueに書き換えます。
  3. 一致する要素がなければfalseのままなのでメッセージを追加します。

このようにして入力内容のチェックをします。

プルダウンメニュー

次にプルダウンメニューです。これもチェックの方法はラジオボタンと変わりません。

<!-- code:web5-8 -->
<!-- login.html -->
好きなフルーツ
<select name="fruit">
	<option value="apple">apple</option>
	<option value="pear">pear</option>
	<option value="banane">banane</option>
</select>
<br/>
<!-- user.html -->
<p>好きなフルーツ:{{.Like}}</p>
// code:web5-9
// プルダウンメニュー
slice2 := []string{"apple", "pear", "banane"}
exist2 := false
for _, s := range slice2 {
	if r.Form.Get("fruit") == s {
		exist2 = true
	}
}
if !exist2 {
	msg.Message = msg.Message + "選択肢の中から選んでください。"
}

ラジオボタンと同じなので特にいうことはありません。

チェックボックス

チェックボックスは複数選択できるので少し処理を変える必要があります。
まずはhtmlファイルに以下を追加しましょう。

<!-- code:web5-10 -->
<!-- login.html -->
好きなスポーツ(複数選択)
<br/>
<input type="checkbox" name="interest" value="football">サッカー
<input type="checkbox" name="interest" value="baseball">野球
<input type="checkbox" name="interest" value="basketball">バスケットボール
<input type="checkbox" name="interest" value="tennis">テニス
<br/>
<!-- user.html -->
<p>好きなスポーツ</p>
<ul>
	{{range $var := .Interest}}
		<li>{{$var}}</li>
	{{end}}
</ul>

user.htmlに渡されるInterest変数は配列です。phpのようにループを回すことができるので上のようにrangeでループを回して要素を埋め込んでいきます。
main.goには以下を追加しましょう。

// code:web5-11
// チェックボックス
// ①
slice3 := []string{"football", "baseball", "basketball", "tennis"}
exist3 := 0
// ②slice3とinterestの要素が一致する数をカウントする
interest := r.Form["interest"]
for _, i := range interest {
	for _, s := range slice3 {
		if i == s {
			exist3++
		}
	}
}
// ③interestの要素が全てslice3の要素ならば数が一致する
if len(interest) != exist3 {
	msg.Message = msg.Message + "選択肢の中から選んでください。"
}
  1. 選択肢と同じ配列(slice3)を作成します。ここではクライアントから送られてきた結果も配列で取得することになります。なのでslice3の要素と一致した数をカウントします。カウント用の変数としてexist3を作成します。
  2. r.Form.Get()では一つの要素しか取り出せません。なので上のようにr.Form["interest"]で配列として取り出しinterest変数に格納します。
    その後、interest変数でループを回し、全ての要素に対してslice3変数でループを回して一致するかをチェックします。これで一致するごとにexist変数がインクリメントされるので、interest変数の要素全てがslice3に含まれるならばexistの値はinterest変数の要素の数と一致します。
  3. interestの数とexistの数が一致しなければ、不正な要素が含まれていることになるのでメッセージを追加します。

これにより複数の要素もくまなくチェックすることができます。

これまでのディレクトリ構成とコード

最後にここでディレクトリ構成と要素を載せます。

ディレクトリ構成

$ tree
.
├── main.go
└── public
    ├── login.html
    └── user.html

コード

<!-- login.html -->
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>HTML Form</title>
</head>
<body>
    <form action="/login" method="POST">
        名前(必須):<input type="text" name="username">
        <br />
        年齢    :<input type="text" name="age">
        <br />
        <input type="radio" name="gender" value="1"><input type="radio" name="gender" value="2"><br />
        好きなフルーツ
        <select name="fruit">
            <option value="apple">apple</option>
            <option value="pear">pear</option>
            <option value="banane">banane</option>
        </select>
        <br/>
        好きなスポーツ(複数選択)
        <br/>
        <input type="checkbox" name="interest" value="football">サッカー
        <input type="checkbox" name="interest" value="baseball">野球
        <input type="checkbox" name="interest" value="basketball">バスケットボール
        <input type="checkbox" name="interest" value="tennis">テニス
        <br/>
        <input type="submit" value="送信">
        <br />
        <p>{{ .Message }}</p>
    </form>
</body>
</html>
<!-- user.html -->
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{{.Username}}</title>
</head>
<body>
    <h1>ようこそ{{.Username}}さん</h1>
    <p>年齢:{{.Age}}</p>
    <p>性別:{{.Gender}}</p>
    <p>好きなフルーツ:{{.Fruit}}</p>
    <p>好きなスポーツ</p>
    <ul>
        {{range $var := .Interest}}
            <li>{{$var}}</li>
        {{end}}
    </ul>
</body>
</html>
package main

import (
	"html/template"
	"log"
	"net/http"
	"strconv"
)

// Emb は入力内容をuser.htmlに埋め込むためのもの
type Emb struct {
	Username string
	Age      int
	Gender   string
	Fruit    string
	Interest []string
}

// Msg は入力内容に誤りがある場合にlogin.htmlに埋め込むもの
type Msg struct {
	Message string
}

var emb Emb = Emb{}
var msg Msg = Msg{""}

func main() {
	port := "8080"
	http.HandleFunc("/login", handleLogin)
	http.HandleFunc("/user", handleUser)
	log.Printf("server is running on http://localhost:%s/login", port)
	log.Print(http.ListenAndServe(":"+port, nil))
}

func handleLogin(w http.ResponseWriter, r *http.Request) {
	if r.Method == "GET" {
		// 入力フォームを返す
		t, _ := template.ParseFiles("public/login.html")
		t.Execute(w, msg)
	}
	if r.Method == "POST" {
		msg.Message = ""
		r.ParseForm()
		// 必須入力のチェック
		if len(r.Form.Get("username")) == 0 {
			msg.Message = msg.Message + "usernameが入力されていません。"
		}
		// 値のチェック
		getint, interr := strconv.Atoi(r.Form.Get("age")) // 文字列の変換
		if interr != nil || getint < 0 || getint > 100 {
			msg.Message = msg.Message + "年齢がおかしいです。"
		}
		// ラジオボタンのチェック
		slice := []string{"1", "2"}
		exist := false
		for _, s := range slice {
			if r.Form.Get("gender") == s {
				exist = true
			}
		}
		if !exist {
			msg.Message = msg.Message + "性別がおかしいです。"
		}
		// プルダウンメニュー
		slice2 := []string{"apple", "pear", "banane"}
		exist2 := false
		for _, s := range slice2 {
			if r.Form.Get("fruit") == s {
				exist2 = true
			}
		}
		if !exist2 {
			msg.Message = msg.Message + "選択肢の中から選んでください。"
		}
		// チェックボックス
		slice3 := []string{"football", "baseball", "basketball", "tennis"}
		exist3 := 0
		interest := r.Form["interest"]
		for _, i := range interest {
			for _, s := range slice3 {
				if i == s {
					exist3++
				}
			}
		}
		if len(interest) != exist3 {
			msg.Message = msg.Message + "選択肢の中から選んでください。"
		}

		// 入力内容によってページ遷移
		if msg.Message == "" {
			emb = Emb{
				Username: r.Form.Get("username"),
				Age:      getint,
				Gender:   r.Form.Get("gender"),
				Fruit:    r.Form.Get("fruit"),
				Interest: r.Form["interest"],
			}
			http.Redirect(w, r, "/user", 301)
		} else {
			http.Redirect(w, r, "/login", 301)
		}
	}
}

func handleUser(w http.ResponseWriter, r *http.Request) {
	t, _ := template.ParseFiles("public/user.html")
	t.Execute(w, emb)
}

最後になりますが、ここであげたのは例にすぎません。想定する入力ミスや攻撃、状況や扱う内容によって処理のロジックを最適化することは高いパフォーマンスを実現する上で大事なことです。
自分でも入力ミスや攻撃を想像してみて、そのためにどのような処理を加えるのかを考えてみてください。

Discussion