let's go를 배워보자 8일차 - Processing Forms

0

lets-go

목록 보기
8/15

Processing Forms

이제 우리의 web application이 user로부터 입력을 받도록 하자.

form형식으로 만들어서 Post-Redirect-Get pattern을 적용시키도록 해보는 것이다.

  1. user가 /snippet/createGET요청을 만들면, user에게 빈 form을 보이도록 한다.
  2. user가 form 입력을 완성하고 server에 제출하면 /snippet/create에 대한 POST요청을 통해 서버에 전송된다.
  3. form data는 우리의 createSnippet 핸들러에 의해서 검증되어 질 것이다. 만약 해당 form에 대한 어떠한 검증 실패라도 있으면, 적절한 form 필드를 하이라이팅해주면서 다시 화면을 보여주도록 한다. 만약 우리의 검증 검사가 성공하면 새로운 snippet에 대한 date가 database에 추가되고 user를 /snippet/:id로 리다이렉트 해준다.

1. Setting Up a Form

ui/html/create.page.tmpl 파일을 만들어 form을 하나 만들어보자.

touch ui/html/create.page.tmpl

그리고 다음의 form 데이터 html 파일을 추가하도록 하자.

  • ui/html/create.page.tmpl
{{template "base" . }}

{{define "title"}} Create a New Snippet {{end}}

{{define "main"}}
<form action='/snippet/create' method='POST'>
    <div>
        <label>Title:</label>
        <input type='text' name='title'>
    </div>
    <div>
        <label>Content:</label>
        <textarea name='content'></textarea>
    </div>
    <div>
        <label>Delete in:</label>
        <input type='radio' name='expires' value='365' checked> One Year
        <input type='radio' name='expires' value='7'> One Week
        <input type='radio' name='expires' value='1'> One Day
    </div>
    <div>
        <input type='submit' value='Publish snippet'>
    </div>
</form>
{{end}}

해당 form은 3개의 데이터를 전송하는데 title, content, expires가 있다. 집중해야할 것은 actionmethod attributes부분인데 action/snippet/create로 만들고, methodPOST로 두면 submit버튼을 누를 경우 /snippet/createPOST요청이 전송된다.

이제 Create snippet이라는 새로운 link를 만들어 우리의 application을 위한 navigation bar를 마련하도록 하자.

  • ui/html/base.layout.tmpl
{{define "base"}}
<!doctype html>
<html lang='en'>
    <head>
        <meta charset='utf-8'>
        <title>{{template "title" .}} - Snippetbox</title>
        <!-- Link to the CSS stylesheet and favicon -->
        <link rel='stylesheet' href='/static/css/main.css'>
        <link rel='shortcut icon' href='/static/img/favicon.ico' type='image/x-icon'>
        <!-- Also link to some fonts hosted by Google -->
        <link rel='stylesheet' href='https://fonts.googleapis.com/css?family=Ubuntu+Mono:400,700'>
    </head>
    <body>
        <header>
            <h1><a href='/'>Snippetbox</a></h1>
        </header>
        <nav>
            <a href='/'>Home</a>
            <!-- Add a link to the new form -->
            <a href='/snippet/create'>Create snippet</a>
        </nav>
        <main>
            {{template "main" .}}
        </main>
        {{template "footer" .}}
        <script src="/static/js/main.js" type="text/javascript"></script>
    </body>
</html>
{{end}}

마지막으로 createSnippetForm 핸들러를 업데이트하여 우리의 새로운 page를 렌더링하도록 하자.

  • cmd/web/handlers.go
...
func (app *application) createSnippetForm(w http.ResponseWriter, r *http.Request) {
	app.render(w, r, "create.page.tmpl", nil)
}

이제 server를 실행하고 /snippet/create의 화면이 어떻게 나오는 지 확인해보도록 하자.

우리가 만든 form이 잘나온다면 성공한 것이다.

2. Parsing Form Data

이제 form data가 전달되면 이를 처리하는 핸들러를 수정해보도록 하자. 우리는 이미 이전에 POST /snippet/create 요청이 오면 우리의 createSnippet 핸들러로 dispatch되도록 하였다. 우리는 이 핸들러를 수정하여 form data를 처리하고 사용하도록 하자.

이 과정을 다음의 두 과정으로 나눌 수 있는데

  1. r.ParseForm() 메서드를 사용하여 request body를 parse할 필요가 있다. 이는 request body가 잘 형성되어 있는 지를 확인하고 request의 r.PostForm map에 form date을 저장하도록 한다. 만약 request body를 parsing하는 도중에 여기에 발견된 에러가 있다면(가령, body가 없거나 너무 커서 process하기 어렵다거나), error를 반환할 것이다. r.ParseForm() 메서드는 또한 idempotent(멱등성)하기 때문에, 몇번이고 같은 request에 대해서 안전하게 호출(사용)되며 어떠한 side-effect도 없다.

  2. 우리는 r.PostForm에 포함된 form 데이터를 가져오기 위해서 r.PostForm.Get() 메서드를 사용할 수 있다. 가령 우리는 title 필드에 있는 값을 가져오기 위해서 r.PostForm.Get("title")을 사용할 수 있다. 만약 여기에 어떠한 매칭되는 필드가 없다면 empty string인 ""을 반환한다. 이는 이전에 우리가 query string을 다루었을 때와 같다.

cmd/web/handlers.go 파일을 열어서 다음의 코드를 업데이트 하도록 하자.

  • cmd/web/handlers.go
func (app *application) createSnippet(w http.ResponseWriter, r *http.Request) {
	// First we call r.ParseForm() which adds any data in POST request bodies
	// to the r.PostForm map. This also works in the same way for PUT and PATCH
	// requests. If there are any errors, we use our app.ClientError helper to send
	// a 400 Bad Request response to the user.
	err := r.ParseForm()
	if err != nil {
		app.clientError(w, http.StatusBadRequest)
		return
	}
	// Use the r.PostForm.Get() method to retrieve the relevant data fields
	// from the r.PostForm map.
	title := r.PostForm.Get("title")
	content := r.PostForm.Get("content")
	expires := r.PostForm.Get("expires")

	// Create a new snippet record in the database using the form data.
	id, err := app.snippets.Insert(title, content, expires)
	if err != nil {
		app.serverError(w, err)
		return
	}

	http.Redirect(w, r, fmt.Sprintf("/snippet/%d", id), http.StatusSeeOther)
}

먼저 err := r.ParseForm()으로 reuqest body가 문제가 없는 지를 확인한다. 가령, 비어있거나 너무 큰 사이즈인가를 확인하는 것이다. 이후 r.ParseForm에서 reqeust body를 파싱한 결과를 map에 저장한다. 이 다음 부터는 r.PostForm.Get("title")을 통해서 request body에 저장된 map으로 데이터를 가져오는 것이다. 만약 값이 없다면 ""으로 나올 것이다.

이제 application을 다시 시작하고 sinppet의 title과 content를 채워 요청을 보내어 결과를 확인하도록 하자.

추가적으로 우리는 r.PostForm map을 통해서 값들을 가져왔다. 그러나 다른 방법이 하나 있는데 약간 미묘하지만 r.Form map을 사용하는 것이다.

r.PostForm map은 오직 POST, PATCH 그리고 PUT 요청들에 대해서만 사용되고, request body로부터 form data를 포함한다.

반면에 r.Form map은 모든 요청들(http 요청들에 관련없이)에 대해서 동작한다. 그리고 어떠한 request body와 어떠한 query string 파라미터로 부터 form data를 포함한다. 그래서 만약 우리의 form이 /snippet/create?foo=bar로 제출된다면 우리는 foo파라미터를 r.Form.Get("foo")를 사용하여 foo에 접근이 가능하다. 만약 충돌이 발생할 경우(query string과 reuqest body가 같은 field명을 가지는 경우) request body값이 더 우선한다.

만약 우리의 application이 HTML form과 url에 데이터를 전송한다면, 또는 얼마나 많은 parameter들이 전달되는 지 상관없는 application을 가진다면, r.Form map을 사용하는 것은 매우 효울적이다. 그러나 우리의 case에서는 이러한 것들이 적용가능하진 않다. 우리는 우리의 form data가 오직 request body에 전송되므로 우리는 r.PostForm으로만 접근하는 것이 맞다.

net/http 패키지에서는 또한 r.FormValue()r.PostFormValue() 메서드들을 제공하는데 이것들은 본질적으로 r.ParseForm 함수를 호출해주고 적절한 필드 값을 r.Form 또는 r.PostForm 으로부터 각각 가져오는 기능을 단축해주는 것과 같다.

r.FormValue()r.PostFromValue()를 사용하는 것을 피하는 것을 추천하는데, 이유는 이들이 약간씩 r.ParseForm() 메서드로 인해 반환되는 특정 error들을 무시하기 때문이다. 이는 이상적이지 않다. 이는 우리의 application이 유저에게 error과 실패를 맞닥들이게 할 수 있다는 것이고, 이에 대한 어떠한 피드백이 우리에게 제공되지 않기 때문이다.

우리가 사용한 r.PostForm.Get() 메서드는 특정 form 필드의 첫번째 값만을 반환해준다. 이는 r.PostForm.Get 메서드를 여러 개의 값들을 전송하는 form 필드와 함께 사용할 수 없다는 것이다. 가령 checkboxes의 group들이 있다.

<input type="checkbox" name="items" value="foo"> Foo
<input type="checkbox" name="items" value="bar"> Bar
<input type="checkbox" name="items" value="baz"> Baz

이 경우, r.PostForm map을 직접 동작해야할 필요가 있다. r.PostForm map의 타입은 url.Values이다. 이는 map[string][]string 타입을 가진다. 그래서 여러 개의 값들을 가지는 fields에 대해서 map에 loop를 사용하여 데이터에 접근할 수 있다.

for i, item := range r.PostForm["items"] {
fmt.Fprintf(w, "%d: Item %s\n", i, item)
}

만약 multipart data를 전송하는 경우(가령, enctype="multipart/form-data" attribute)라면 POST, PUT,그리고 PATCH request body들은 10MB로 제한된다. 만약 이를 넘기게 되면 r.ParseForm()에서 에러를 반환한다.

만약, 해당 limit을 바꾸고 싶다면 http.MaxBytesReader함수를 사용하여 변경하면 된다.

r.Body = http.MaxBytesReader(w, r.Body, 4096) // 4096byte
err := r.ParseForm()
if err != nil {
    http.Error(w, "Bad Request", http.StatusBadRequest)
    return
}

위의 코드는 오직 r.ParseFrom()에서 request body의 첫번째 4096 bytes만들을 읽는다. 이 limit 이상으로 읽기 위한 시도는 MaxBytesReader가 error를 반환하고 이를 r.ParseForm()에서 직면하게 된다.

추가적으로 만약 limit이 설정되면, MaxBytesReaderhttp.ResponseWriter`에 flag를 설정하여 server가 기저의 TCP 연결을 끊도록 지시한다.

3. Data Validation

우리의 코드에 눈에 띄는 문제점이 하나 있는데, 그건 바로 form으로 부터오는 user input을 어떠한 방식으로든 검증하고 있지 않다는 것이다. 주어진 form 데이터가 정확하고 우리의 business logic에 매칭되는 지를 보장하기 위해 우리는 검증을 할 필요가 있다.

구체적으로 form data에 관해 우리가 해야할 것은 다음과 같다.

  • title, contentexpires 필드들은 empty여서는 안된다.
  • title 필드는 100 글자 이상이 넘어가면 안된다.
  • expires의 값은 우리가 허용한 값인 1, 7, 365 값이어야 한다.

이러한 모든 check들은 if 문과 여러 go의 strings 패키지, unicode/utf8 패키지를 통해서 사용가능하다.

handlers.go 파일을 열고 createSnippet 핸들러에 적절한 validation 코드를 추가해보자.

  • cmd/web/handlers.go
func (app *application) createSnippet(w http.ResponseWriter, r *http.Request) {
	err := r.ParseForm()
	if err != nil {
		app.clientError(w, http.StatusBadRequest)
		return
	}

	title := r.PostForm.Get("title")
	content := r.PostForm.Get("content")
	expires := r.PostForm.Get("expires")
	// Initialize a map to hold any validation errors.
	errors := make(map[string]string)
	// Check that the title field is not blank and is not more than 100 characters
	// long. If it fails either of those checks, add a message to the errors
	// long. If it fails either of those checks, add a message to the errors
	// map using the field name as the key.
	if strings.TrimSpace(title) == "" {
		errors["title"] = "This field cannot be blank"
	} else if utf8.RuneCountInString(title) > 100 {
		errors["title"] = "This field is too long (maximum is 100 characters"
	}
	// Check that the Content field isn't blank.
	if strings.TrimSpace(content) == "" {
		errors["content"] = "This field cannot be blank"
	}
	// Check the expires field isn't blank and matches one of the permitted
	// values ("1", "7" or "365").
	if strings.TrimSpace(expires) == "" {
		errors["expires"] = "This field cannot be blank"
	} else if expires != "365" && expires != "7" && expires != "1" {
		errors["expires"] = "This field is invalid"
	}
	// If there are any errors, dump them in a plain text HTTP response and return
	// from the handler.
	if len(errors) > 0 {
		fmt.Fprint(w, errors)
		return
	}

	id, err := app.snippets.Insert(title, content, expires)
	if err != nil {
		app.serverError(w, err)
		return
	}

	http.Redirect(w, r, fmt.Sprintf("/snippet/%d", id), http.StatusSeeOther)
}

각각의 필드를 validation하는 코드를 추가하였다. 여기서 눈여겨볼 것은 다음과 같다.

우리가 title 필드의 길이를 검사할 때 len() 함수가 아닌 utf8.RuneCountInString() 함수를 사용하였다. 이는 우리가 byte의 개수를 세기보다는 문자들의 개수를 세기를 원하기 때문이다. 차이를 말하자면 "Zoë"은 3개의 문자들로 이루어져 있지만 바이트로는 4byte이다. 왜냐하면 ë가 2byte이기 때문이다.

이제 data validation이 제대로 동작하는 지를 확인해보자.

가령 content 필드에는 빈 문자열을 넣고, title은 100글자가 넘도록 써보자.

map[content:This field cannot be blank title:This field is too long (maximum is 100 characters]

화면에 다음과 같은 에러가 전송될 것이다.

만약 더 많은 validation 방법들이 필요하다면 다음의 사이트에서 확인해보도록 하자.
https://www.alexedwards.net/blog/validation-snippets-for-go

4. Displaying Validation Errors and Repopulating Fields

이제 createSnippet 핸들러가 data를 검증하고 있다. 다음에 해야할 단계는 이 검증 에러를 어떻게 gracefully하게 관리할 것이냐이다.

만약 어떠한 validation error라도 있다면 우리는 form을 다시 보여주고 싶고 어떤 필드에 문제가 발생했는 지를 보여주어 실패한 검증 부분을 나타내고, 다시 이전에 제출한 값들을 form 형식에 맞게 설정해주고 싶다.

이렇게 하기 위해서 우리의 templateData 구조체에 두 개의 새로운 필드로 어떠한 검증 에러들이 있는 지 알고있는 FormErrors필드와 이전에 제출한 데이터 값이 무엇인 지를 잡고 있는 FormData 필드를 추가하도록 하자.

  • cmd/web/templates.go
type templateData struct {
	CurrentYear int
	FormData    url.Values
	FormErrors  map[string]string
	Snippet     *models.Snippet
	Snippets    []*models.Snippet
}

FormData 필드는 url.Values 타입을 가지는데 이는 r.PostForm map과 같은 타입으로 request body에서 전달한 데이터를 가지고 있다.

이제 createSnippet 핸들러를 다시 업데이트해보자. 만약 어떠한 validation errors가 발생하면 form을 관련된 에러들과 함께 다시 보여주고, template로 form data가 전달된다. 이를 위해 handlers.go를 변경하도록 하자.

  • cmd/ web/handlers.go
...
func (app *application) createSnippet(w http.ResponseWriter, r *http.Request) {
	err := r.ParseForm()
	if err != nil {
		app.clientError(w, http.StatusBadRequest)
		return
	}

	title := r.PostForm.Get("title")
	content := r.PostForm.Get("content")
	expires := r.PostForm.Get("expires")

	errors := make(map[string]string)

	if strings.TrimSpace(title) == "" {
		errors["title"] = "This field cannot be blank"
	} else if utf8.RuneCountInString(title) > 100 {
		errors["title"] = "This field is too long (maximum is 100 characters"
	}

	if strings.TrimSpace(content) == "" {
		errors["content"] = "This field cannot be blank"
	}

	if strings.TrimSpace(expires) == "" {
		errors["expires"] = "This field cannot be blank"
	} else if expires != "365" && expires != "7" && expires != "1" {
		errors["expires"] = "This field is invalid"
	}
	// If there are any validation errors, re-display the create.page.tmpl
	// template passing in the validation errors and previously submitted
	// r.PostForm data.
	if len(errors) > 0 {
		app.render(w, r, "create.page.tmpl", &templateData{
			FormErrors: errors,
			FormData:   r.PostForm,
		})
		return
	}

	id, err := app.snippets.Insert(title, content, expires)
	if err != nil {
		app.serverError(w, err)
		return
	}

	http.Redirect(w, r, fmt.Sprintf("/snippet/%d", id), http.StatusSeeOther)
}

그래서 현재 어떠한 validation error가 있다면 우리는 create.page.tmpl template 파일을 다시 보여주고 template data의 FormErrors 필드안에 error들의 map을 전달할 것이다. 그리고 이전에 제출된 데이터를 FormData 필드에 넣어 전달한다.

그래서 이 데이터를 어떻게 template에서 render하는가?

FormErrors 필드의 타입은 map[string]string이다. 이를 위해서는 key로 value를 접근해야하는 데, template에서 할 수 있는 방법은 {{.FormErrors.title}}title 필드에 접근할 수 있다. 매우 중요한 점은 구조체 필드와는 달리 map key 이름은 template에서 접근하려면 반드시 대문자가 아니어야 한다.

FormData의 타입은 url.Values이다. 그리고 우리가 사용하는 것은 Get 메서드로 필드에 대한 값을 가져올 수 있다. 가령 우리는 title의 값을 가져오고 싶다면 template에서 {{.FormData.Get "title"}}로 가져오면 된다.

이를 명심해두고 create.page.tmpl 파일을 수정하여 data와 각 필드에 대한 validation error message를 화면에 표시해주도록 하자.

  • ui/html/create.page.tmpl
{{template "base" .}}

{{define "title"}}Create a New Snippet{{end}}

{{define "main"}}
<form action='/snippet/create' method='POST'>
    <div>
        <label>Title:</label>
        {{with .FormErrors.title}}
            <label class='error'>{{.}}</label>
        {{end}}
        <input type='text' name='title' value='{{.FormData.Get "title"}}'>
    </div>
    <div>
        <label>Content:</label>
        {{with .FormErrors.content}}
            <label class='error'>{{.}}</label>
        {{end}}
        <textarea name='content'>{{.FormData.Get "content"}}</textarea>
    </div>
    <div>
        <label>Delete in:</label>
        {{with .FormErrors.expires}}
            <label class='error'>{{.}}</label>
        {{end}}
        {{$exp := or (.FormData.Get "expires") "365"}}
        <input type='radio' name='expires' value='365' {{if (eq $exp "365")}}checked{{end}}> One Year
        <input type='radio' name='expires' value='7' {{if (eq $exp "7")}}checked{{end}}> One Week
        <input type='radio' name='expires' value='1' {{if (eq $exp "1")}}checked{{end}}> One Day
    </div>
    <div>
        <input type='submit' value='Publish snippet'>
    </div>
</form>
{{end}}

with을 통해서 FormErrors에서 key에 해당하는 값이 있다면 에러를 나타내도록 하였다. 또한, 해당 값은 child에서 {{.}}로 접근이 가능하다.

한 가지 좀 어려운 라인이 있는데,

{{$exp := or (.FormData.Get "expires") "365"}}

이는 본질적으로 새로운 변수인 $exp를 만들고, or template 함수로 .FormData.Getexpires값이 있다면 해당 값을 넣고 없다면 365를 넣어준다.

한 가지 알아두어야 할 것인 ()으로 그룹화를 했다는 것인데 이는 .FormData.Get 메서드를 그룹화한 것으로, 그 결과 값이 or action에 들어가게 하기 위한 것이다.

우리는 그리고 나서 eq 함수를 사용하여 checked attribute를 적절한 radio button에 넣어주었다. 다음과 같이 말이다.

{{if (eq $exp "365")}}checked{{end}}

서버를 다시 작동시키고, /snippet/create에서 이전과 같이 에러가 발생하는 내용을 넣도록 하자.

빨간색으로 잘 표시되었다면 성공한 것이다.

5. Scaling Data Validation

form 데이터 검증은 매우 자주 있는 일이고, 반복적인 일이다. form 데이터를 검증는 코드를 만드는 일은 시간을 많이 소모하고 흥미로운 일도 아니다.

forms 패키지를 만들어서 우리의 behavior를 추상화하고 핸들러에 있는 boilerplate code를 감소시키자. 우리는 user입장에서 application 어떻게 동작하는 지, 전혀 변화없이 우리의 코드 base를 리팩토링하도록 하자.

새로운 패키지인 pkg/forms 폴더를 프로젝트 directory에 만들고 두 개의 파일은 forms.goerrors.go파일을 추가하도록 하자.

mkdir pkg/forms
touch pkg/forms/errors.go
touch pkg/forms/form.go

파일이 만들어졌다면 errors.go 파일을 열어서 errors 타입을 만들어주도록 하자. 이는 form에 대한 validation error를 담아내는 필드이다.

  • pkg/forms/errors.go
package forms

// Define a new errors type, which we will use to hold the validation error
// messages for forms. The name of the form field will be used as the key in
// this map.
type errors map[string][]string

// Implement an Add() method to add error messages for a given field to the map.
func (e errors) Add(field, message string) {
	e[field] = append(e[field], message)
}

// Implement a Get() method to retrieve the first error message for a given
// field from the map.
func (e errors) Get(field string) string {
	es := e[field]
	if len(es) == 0 {
		return ""
	}
	return es[0]
}

forms 패키지의 errors타입은 map으로 구성되어 검증에 실패한 field에 대한 에러를 담아낼 것이다. Add를 통해서 field에 맞는 에러 메시지를 추가하고, Get으로 field에 맞는 에러 메시지를 가져오는 것이다.

이제 form 데이터를 검증하는 form.go 파일을 만들어보자.

  • pkg/forms/form.go
package forms

import (
	"fmt"
	"net/url"
	"strings"
	"unicode/utf8"
)

// Create a custom Form struct, which anonymously embeds a url.Values object
// (to hold the form data) and an Errors field to hold any validation errors
// for the form data.
type Form struct {
	url.Values
	Errors errors
}

// Define a New function to initialize a custom Form struct. Notice that
// this takes the form data as the parameter?
func New(data url.Values) *Form {
	return &Form{
		data,
		make(errors),
	}
}

// Implement a Required method to check that specific fields in the form
// data are present and not blank. If any fields fail this check, add the
// appropriate message to the form errors.
func (f *Form) Required(fields ...string) {
	for _, field := range fields {
		value := f.Get(field)
		if strings.TrimSpace(value) == "" {
			f.Errors.Add(field, "This field cannot be blank")
		}
	}
}

// Implement a MaxLength method to check that a specific field in the form
// contains a maximum number of characters. If the check fails then add the
// appropriate message to the form errors.
func (f *Form) MaxLength(field string, d int) {
	value := f.Get(field)
	if value == "" {
		return
	}
	if utf8.RuneCountInString(value) > d {
		f.Errors.Add(field, fmt.Sprintf("This field is too long (maximum is %d characters", d))
	}
}

// Implement a PermittedValues method to check that a specific field in the form
// matches one of a set of specific permitted values. If the check fails
// then add the appropriate message to the form errors.
func (f *Form) PermittedValues(field string, opts ...string) {
	value := f.Get(field)
	if value == "" {
		return
	}

	for _, opt := range opts {
		if value == opt {
			return
		}
	}
	f.Errors.Add(field, "This field is invalid")
}

// Implement a Valid method which returns true if there are no errors.
func (f *Form) Valid() bool {
	return len(f.Errors) == 0
}

Form 구조체는 url.Values를 embed하고 있어 form data를 가지고 있다. 그리고 validation에서 실패한 error들을 Errors안에 넣고 있다.

Required은 특정 form data가 비어있는 지 없는 지를 검증하는 메서드이다. MaxLength은 정해진 사이즈인 d만큼의 문자 개수를 갖고 있는 지 아닌 지를 검사한다. PermittedValues은 허용되는 특정한 opt 값 중에 허용되는 것인지 아닌 지를 확인한다.

Form 구조체 안에 Errors를 갖고 있기 때문에 에러가 발생하면 f.Errors.Add를 통해 검증에 실패한 에러를 넣을 수 있다.

다음 단계는 templateData 구조체를 변경하여 새로운 forms.Form 구조체를 우리의 template에 넣어주는 것이다.

  • cmd/web/templates.go
...
// Update the templateData fields, removing the individual FormData and
// FormErrors fields and replacing them with a single Form field.
type templateData struct {
	CurrentYear int
	Form        *forms.Form
	Snippet     *models.Snippet
	Snippets    []*models.Snippet
}
...

이전의 두 데이터인 FormDataFormErrorsforms.Form안에 있으므로 삭제해주고 이를 추가해주도록 하자.

hanlders.go 파일을 열고 forms.Form 구조체를 사용하고 우리가 만든 검증 메서드를 사용하기 위해 handlers.go 파일의 코드를 수정해주도록 하자.

  • cmd/web/handlers.go
...
func (app *application) createSnippetForm(w http.ResponseWriter, r *http.Request) {
	app.render(w, r, "create.page.tmpl", &templateData{
		// Pass a new empty forms.Form object to the template
		Form: forms.New(nil),
	})
}

func (app *application) createSnippet(w http.ResponseWriter, r *http.Request) {
	err := r.ParseForm()
	if err != nil {
		app.clientError(w, http.StatusBadRequest)
		return
	}
	// Create a new forms.Form struct containing the POSTed data from the
	// form, then use the validation methods to check the content.
	form := forms.New(r.PostForm)
	form.Required("title", "content", "expires")
	form.MaxLength("title", 100)
	form.PermittedValues("expires", "365", "7", "1")
	// If the form isn't valid, redisplay the template passing in the
	// form.Form object as the data.
	if !form.Valid() {
		app.render(w, r, "create.page.tmpl", &templateData{Form: form})
		return
	}
	// Because the form data (with type url.Values) has been anonymously embedded
	// in the form.Form struct, we can use the Get() method to retrieve
	// the validated value for a particular form field.
	id, err := app.snippets.Insert(form.Get("title"), form.Get("content"), form.Get("expires"))
	if err != nil {
		app.serverError(w, err)
		return
	}

	http.Redirect(w, r, fmt.Sprintf("/snippet/%d", id), http.StatusSeeOther)
}

form을 사용하여 데이터를 검증하고 form.Valid를 통해서 검증에 성공했는 지 실패했는 지를 받도록 하였다. createSnippetFormForm을 넘겨야 하므로 form.New(nil)을 통해 인스턴스를 생성해주었다.

이제 create.page.tmpl 파일에서 데이터를 잘 가져오도록 코드를 수정해주도록 하자.

  • ui/html/create.page.tmpl
{{template "base" .}}

{{define "title"}}Create a New Snippet{{end}}

{{define "main"}}
<form action='/snippet/create' method='POST'>
    {{with .Form}}
        <div>
            <label>Title:</label>
            {{with .Errors.Get "title"}}
                <label class='error'>{{.}}</label>
            {{end}}
            <input type='text' name='title' value='{{.Get "title"}}'>
        </div>
        <div>
            <label>Content:</label>
            {{with .Errors.Get "content"}}
                <label class='error'>{{.}}</label>
            {{end}}
            <textarea name='content'>{{.Get "content"}}</textarea>
        </div>
        <div>
            <label>Delete in:</label>
            {{with .Errors.Get "expires"}}
                <label class='error'>{{.}}</label>
            {{end}}
            {{$exp := or (.Get "expires") "365"}}
            <input type='radio' name='expires' value='365' {{if (eq $exp "365")}}checked{{end}}> One Year
            <input type='radio' name='expires' value='7' {{if (eq $exp "7")}}checked{{end}}> One Week
            <input type='radio' name='expires' value='1' {{if (eq $exp "1")}}checked{{end}}> One Day
        </div>
        <div>
            <input type='submit' value='Publish snippet'>
        </div>
    {{end}}
</form>
{{end}}

딱히 크게 달라진 것은 없고 {{with .Form}} ... {{end}}을 넣어서 .Form에 더 접근하기 쉽도록 한다음 .Errors.Get.Get으로 메시지를 받아오도록 수정한 것 밖에 없다.

이제 서버를 다시 실행해서 테스트해보자. 아마 같은 결과가 나올 것이다.

우리는 이전에 handlers에서 검증의 역할을 같이 하였지만 이제는 forms 패키지에서 validation 규칙들과 로직을 담당하고 있기 어 application 전반적으로 이를 다시 사용할 수 있게 되었다. 이는 또한 추가적인 규칙을 미래에 추가하기에 매우 효율적이게 되었고, form data와 error들이 깔끔하게 하나의 forms.Form 객체에 캡슐화되었다. 이는 template 파일에 쉽게 전달할 수 있다는 장점이 있다. 그리고 이는 form data와 어떠한 error message들이든 이 둘을 가져오고 화면에 보여주는 간단하고 consistent한 API를 제공한다는 장점이 있다.

0개의 댓글