let's go를 배워보자 5일차 - Displaying Dynamic Data

0

lets-go

목록 보기
5/15

Displaying Dynamic Data

이제 database에 있는 데이터를 바탕으로 화면을 꾸며보도록 하자.

showSnippet 핸들러에 다음의 코드를 추가하여 show.page.tmpl template file을 랜더링하도록 하자.

  • cmd/web/handles.go
...
func (app *application) showSnippet(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.Atoi(r.URL.Query().Get("id"))
	if err != nil || id < 1 {
		app.notFound(w)
		return
	}

	s, err := app.snippets.Get(id)
	if err != nil {
		if errors.Is(err, models.ErrNoRecord) {
			app.notFound(w)
		} else {
			app.serverError(w, err)
		}
		return
	}
    // Initialize a slice containing the paths to the show.page.tmpl file,
    // plus the base layout and footer partial that we made earlier.
	files := []string{
		"./ui/html/show.page.tmpl",
		"./ui/html/base.layout.tmpl",
		"./ui/html/footer.partial.tmpl",
	}
    // Parse the template files...
	ts, err := template.ParseFiles(files...)
	if err != nil {
		app.serverError(w, err)
		return
	}
    // And then execute them. Notice how we are passing in the snippet
    // data (a models.Snippet struct) as the final parameter.
	err = ts.Execute(w, s)
	if err != nil {
		app.serverError(w, err)
	}
}
...

아직 show.page.tmpl 페이지가 없으니 이제 만들어주도록 하자.

HTML templates에 넘어간 어떠한 dynamic data들 모두 .(dot)으로 접근가능하다.

우리는 err = ts.Execute(w, s)을 통해서 models.Snippet 구조체를 넣어주었다. 구조체의 fieldvalue{{.field}}이런 식으로 접근이 가능하다. 따라서 model.Snippet 구조체의 Title field는 {{.Title}}로 우리의 template에서 접근이 가능하다.

ui/html/show.page.tmpl를 만들고 다음의 코드를 추가하자.

touch ui/html/show.page.tmpl
  • ui/html/show.page.tmpl
{{template "base" .}}
{{define "title"}}Snippet #{{.ID}}{{end}}
{{define "main"}}
<div class='snippet'>
    <div class='metadata'>
        <strong>{{.Title}}</strong>
        <span>#{{.ID}}</span>
    </div>
    <pre><code>{{.Content}}</code></pre>
    <div class='metadata'>
        <time>Created: {{.Created}}</time>
        <time>Expires: {{.Expires}}</time>
    </div>
</div>
{{end}}

application을 다시 시작하고 다음의 url로 접속하면 datebase로 부터 데이터를 가져와 template에 담아 표현해줄 것이다.

http://localhost:4000/snippet?id=1

1. Rendering Multiple Pieces of Data

err = ts.Execute(w, s)에서 Execute 메서드를 확인해보자.

func (t *Template) Execute(wr io.Writer, data any) error {
	if err := t.escape(); err != nil {
		return err
	}
	return t.text.Execute(wr, data)
}

오직 하나의 data만을 매개변수로 받는 것을 확인할 수 있다. 여러 개의 data를 보내어 template를 꾸미고 싶다면 어떻게 해야할까??

가장 좋은 방법은 하나의 template type를 만들어 렌더링하고 싶은 여러 개의 타입을 넣는 방식이다.

cmd/web/templates.go 파일을 새로만들어 templateData 구조체가 위의 일을 하도록 하자.

touch cmd/web/templates.go

다음의 코드를 넣어주도록 하자.

  • cmd/web/templates.go
package main

import "github.com/gyu-young-park/snippetbox/pkg/models"

type templateData struct {
	Snippet *models.Snippet
}

handlers.goshowSnippet에 해당 구조체를 사용해보도록 하자.

  • handlers.go
func (app *application) showSnippet(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.Atoi(r.URL.Query().Get("id"))
	if err != nil || id < 1 {
		app.notFound(w)
		return
	}

	s, err := app.snippets.Get(id)
	if err != nil {
		if errors.Is(err, models.ErrNoRecord) {
			app.notFound(w)
		} else {
			app.serverError(w, err)
		}
		return
	}
	// Create an instance of a templateData struct holding the snippet data.
	data := &templateData{Snippet: s}

	files := []string{
		"./ui/html/show.page.tmpl",
		"./ui/html/base.layout.tmpl",
		"./ui/html/footer.partial.tmpl",
	}

	ts, err := template.ParseFiles(files...)
	if err != nil {
		app.serverError(w, err)
		return
	}
	// Pass in the templateData struct when executing the template.
	err = ts.Execute(w, data)
	if err != nil {
		app.serverError(w, err)
	}
}

우리의 데이터인 models.Snippets 구조체가 templateData 구조체 안에 포함되게 되었다. 이 데이터를 template에서 불러오기 위해서 다음과 같이 하면 된다.

  • ui/html/show.page.tmpl
{{template "base" .}}
{{define "title"}}Snippet #{{.Snippet.ID}}{{end}}
{{define "main"}}
<div class='snippet'>
    <div class='metadata'>
        <strong>{{.Snippet.Title}}</strong>
        <span>#{{.Snippet.ID}}</span>
    </div>
    <pre><code>{{.Snippet.Content}}</code></pre>
    <div class='metadata'>
        <time>Created: {{.Snippet.Created}}</time>
        <time>Expires: {{.Snippet.Expires}}</time>
    </div>
</div>
{{end}}

다시 해당 url에 접속해보도록 하자.

http://localhost:4000/snippet?id=1

이전과 동일한 페이지가 나왔다면 성공이다.

참고로 html/template 패키지는 자동적으로 {{}} tags 사이에 생성되는 어떠한 데이터든 escape한다. 이러한 행동은 XSS(cross-site scripting)를 피하는데 큰 도움이 된다. 때문에 text/template 대신에 html/template 패키지를 사용해야하는 이유이기도 하다.

가령 다음과 같은 동적 데이터는

<span>{{"<script>alert('xss attack')</script>"}}</span>

다음과 같이 harmlessly하게 변경되어 렌더링된다.

<span>&lt;script&gt;alert(&#39;xss attack&#39;)&lt;/script&gt;</span>

html/template 패키지는 굉장히 스마트하여 어떠한 데이터가 html, css, js, uri 등에서 렌더링되냐에 따라 sequence들을 적절하게 escape한다.

Nested template를 하고 싶다면, {{template}} 또는 {{block}} action을 사용하면 된다. 이때 {{}}태그 안에 마지막은 .로 적어주어야 한다.

{{template "base" .}}
{{template "main" .}}
{{template "footer" .}}
{{block "sidebar" .}}{{end}}

또한, template에서 method를 호출하고 싶다면 다음과 같이 사용이 가능하다. 가령, .Snippet.Created에서 time.Time 타입이 가진 Weekday() 메서드를 호출하고 싶다면 Weekday() 메서드를 다음과 같이 호출하면 된다.

<span>{{.Snippet.Created.Weekday}}</span>

만약, 매개변수가 있다고 한다면 다음과 같이 써주면 된다. AddDate() 메서드로 6개월을 더하고 싶다면 다음과 같이 하자.

<span>{{.Snippet.Created.AddDate 0 6 0}}</span>

단, 이렇게 template안에서 method를 호출하는 것은 single return value일 때만 가능하다.

2. Template Actions and Functions

이전부터 {{define}}, {{template}}, {{block}} 과 같은 action들을 사용해왔는데, 이것 말고도 {{if}}, {{with}}, {{range}} 등의 action들로 dynamic data를 control할 수 있다.

{{if .Foo}} C1 {{else}} C2 {{end}}.Foo가 있다면 C1이 렌더링되고, 그렇지않다면 C2가 렌더링된다.

{{with .Foo}} C1 {{else}} C2 {{end}}.Foo가 있다면 .Foo 값을 설정하고 C1을 렌더링한다. 그렇지않으면 C2를 렌더링한다.

{{range .Foo}} C1 {{else}} C2 {{end}} .Foo의 length가 zero보다 크면 loop를 돌면서 각 값들을 값으로 설정하고 C1의 내용을 렌더링한다. 만약 .Foo의 길이가 0이면 C2 내용이 렌더링된다. 따라서 .Foo는 반드시 배열, 슬라이스, 맵 또는 채널이 되어야 한다.

여기에는 몇가지 요점이 있는데,

  1. 3개의 action모두 {{else}} 절은 optional하다. 가령, 만약 C2내용으로 렌더링할 것이 없다면 {{if .Foo}} C1 {{end}}로 써도 된다.

  2. 값이 없는 empty value는 false이다. 또한, 0, nil, interface와 길이가 0인 array, slice, map, string 또한 false이다.

  3. with, range action들은 dot의 값을 변경하는데, template에서 개발자가 의도한 대로 변경된다.

html/template 패키지는 또한 여러 template 함수를 제공하는데, 이는 런타임에 렌더링되는 것을 제어하고 개발자가 만든 template에 추가적인 로직을 추가하는 데 사용된다.

  1. {{eq .Foo .Bar}}.Foo.Bar과 같으면 true이다.
  2. {{ne .Foo .Bar}}.Foo.Bar과 다르면 true이다.
  3. {{not .Foo}}boolean값인 .Foo 의 반대를 만들어낸다.
  4. {{or .Foo .Bar}}.Foo가 empty가 아니면 .Foo를 yields하고 아니라면 .Bar를 yields한다.
  5. {{index .Foo i}}i번째 .Foo값을 yields한다. 따라서 .Foo의 타입은 map, slice, array이어야 한다.
  6. {{printf "%s-%s" .Foo .Bar}}.Foo, .Bar 값을 포함하는 formatted string를 만들어낸다. fmt.Sprintf와 같다.
  7. {{len .Foo}}: .Foo의 길이를 integer 로 반환한다.
  8. {{$bar := len .Foo}}.Foo의 길이를 $bar 변수에 할당한다.

이제 다음의 action들을 사용하여 template를 꾸며보자.

  • ui/html/show.page.tmpl
{{template "base" .}}
{{define "title"}}Snippet #{{.Snippet.ID}}{{end}}
{{define "main"}}
<div class='snippet'>
    {{with .Snippet}}
    <div class='metadata'>
        <strong>{{.Title}}</strong>
        <span>#{{.ID}}</span>
    </div>
    <pre><code>{{.Content}}</code></pre>
    <div class='metadata'>
        <time>Created: {{.Created}}</time>
        <time>Expires: {{.Expires}}</time>
    </div>
    {{end}}
</div>
{{end}}

이전과 달라진 것은 {{with .Snippet}}과 이를 감싸는 {{end}} tag이다. dot값은 .Snippet에 설정된다. 즉 위에서는 .Snippet.ID으로 templateData struct를 가져와 사용하였는데, with를 통해서 .Snippet 구조체를 빼온 것이다. 즉, with안에서는 models.Snippets 구조체가 대신 사용된다는 것이다.

이제 {{if}}{{range}} action을 사용해보자.

먼저 templateData 구조체에 Snippets 필드를 slice로 잡게끔 하도록 하자.

  • cmd/web/templates.go
package main

import "github.com/gyu-young-park/snippetbox/pkg/models"

type templateData struct {
	Snippet  *models.Snippet
	Snippets []*models.Snippet
}

그리고 home 핸들러를 수정하여 우리의 database model로부터 최근의 snippet들을 불러와보자, 그리고 이 값을 home.page.tmpl template에 넣어주도록 하자.

  • cmd/web/handlers.go
func (app *application) home(w http.ResponseWriter, r *http.Request) {
	if r.URL.Path != "/" {
		app.notFound(w)
		return
	}

	s, err := app.snippets.Latest()
	if err != nil {
		app.serverError(w, err)
		return
	}

	// Create an instance of a templateData struct holding the slice of
	// snippets.
	data := &templateData{Snippets: s}

	files := []string{
		"./ui/html/home.page.tmpl",
		"./ui/html/base.layout.tmpl",
		"./ui/html/footer.partial.tmpl",
	}

	ts, err := template.ParseFiles(files...)
	if err != nil {
		app.serverError(w, err)
		return
	}
	// Pass in the templateData struct when executing the template.
	err = ts.Execute(w, data)
	if err != nil {
		app.serverError(w, err)
	}
}

s, err := app.snippets.Latest()으로 데이터를 받아와서, data := &templateData{Snippets: s}에 설정해주고, err = ts.Execute(w, data)로 넘겨주는 것 말고는 딱히 수정된 것이 없다.

이제 ui/html/home.page.tmpl 파일로 가서 snippet들을 {{if}}{{range}} action을 사용하여 테이블로 보여주도록 하자.

  1. {{if}} action을 사용하여 snippet들의 slice가 empty인지 아닌지를 확인하고 싶다. 만약 empty라면 우리는 "There's nothing to see here yet!"이라는 메시지를 화면에 보여줄 것이고, 그렇지 않으면 우리는 snippet 정보가 담긴 table을 렌더링할 것이다.

  2. 우리는 {{range}} action을 사용하여 slice안의 모든 snippet들을 순회하도록 하고, 각 snippet들을 table row로 렌더링하게 할 것이다.

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

{{define "title"}}Home{{end}}

{{define "main"}}
    <h2>Latest Snippets</h2>
    {{if .Snippets}}
    <table>
        <tr>
            <th>Title</th>
            <th>Created</th>
            <th>ID</th>
        </tr>
        {{range .Snippets}}
        <tr>
            <td><a href='/snippet?id={{.ID}}'>{{.Title}}</a></td>
            <td>{{.Created}}</td>
            <td>#{{.ID}}</td>
        </tr>
        {{end}}
    </table>
    {{else}}
        <p>There's nothing to see here... yet!</p>
    {{end}}
{{end}}

이제 서버를 구동하여 화면이 어떻게 만들어졌는 지 확인해보자.

3. Caching Templates

우리의 코드를 최적화해보도록 하자.

  1. 매번 web page를 렌더링하고 우리의 application은 관련된 template 파일을들을 template.ParseFiles() 함수를 사용하여 parse한다. 우리는 file들을 한 번만 parsing함으로서 이러한 duplicated된 작업을 피하도록 하고, parsed된 template들을 in-memory cache에 저장하도록 하자.

  2. homeshowSnippet 핸들러에는 duplicated된 코드들이 있다. 우리는 새로운 helper function을 만들어 이를 해결하도록 하자.

in-memory 캐시를 위해 map을 하나 만들도록 하자. map[string]*template.Template은 pared template를 캐싱하도록 한다. cmd/web/templates.go 파일을 열어서 다음의 코드를 넣어보자.

  • /cmd/web/templates.go
func newTemplateCache(dir string) (map[string]*template.Template, error) {
	// Initialize a new map to act as the cache.
	cache := map[string]*template.Template{}

	// Use the filepath.Glob function to get a slice of all filepaths with
	// the extension '.page.tmpl'. This essentially gives us a slice of all the
	// 'page' templates for the application.
	pages, err := filepath.Glob(filepath.Join(dir, "*.page.tmpl"))
	if err != nil {
		return nil, err
	}
	// Loop through the pages one-by-one.
	for _, page := range pages {
		// Extract the file name (like 'home.page.tmpl') from the full file path
		// and assign it to the name variable.
		name := filepath.Base(page)

		// Parse the page template file in to a template set.
		ts, err := template.ParseFiles(page)
		if err != nil {
			return nil, err
		}
		// Use the ParseGlob method to add any 'layout' templates to the
		// template set (in our case, it's just the 'base' layout at the
		// moment).
		ts, err = ts.ParseGlob(filepath.Join(dir, "*.layout.tmpl"))
		if err != nil {
			return nil, err
		}

		// Use the ParseGlob method to add any 'partial' templates to the
		// template set (in our case, it's just the 'footer' partial at the
		// moment).
		ts, err = ts.ParseGlob(filepath.Join(dir, "*.partial.tmpl"))
		if err != nil {
			return nil, err
		}
		// Add the template set to the cache, using the name of the page
		// (like 'home.page.tmpl') as the key.
		cache[name] = ts
	}
	// Return the map.
	return cache, nil
}

filepath.Join을 사용하면 여러 개의 file들을 하나의 file path로 만들어준다. *.page.tmpl 가령, 디렉터리 path인 dir*.page,tmpljoin해주면 ui/html/*.page.tmpl과 같이 만들어준다. 이후 filepath.Glob을 사용해주면 *.page.tmpl이름을 가진 모든 파일들을 slice로 가져온다.

이후, template.PaeseFiles를 통해서 page를 template로 만들고 ts.ParseGlob()을 통해서 패턴에 매칭된 템플릿들을 파싱해준다.

이후, main함수에 cache를 초기화해주기 위한 코드를 넣어주고, 핸들러에 dependency injection을 하기위해 application struct에 다음과 같이 써주도록 하자.

  • cmd/web/main.go
func main() {
	addr := flag.String("addr", ":4000", "HTTP network address")
	dsn := flag.String("dsn", "web:pass@/snippetbox?parseTime=true", "MySQL data source name")
	flag.Parse()

	infoLog := log.New(os.Stdout, "INFO\t", log.Ldate|log.Ltime)
	errorLog := log.New(os.Stderr, "ERROR\t", log.Ldate|log.Ltime|log.Lshortfile)

	db, err := openDB(*dsn)
	if err != nil {
		errorLog.Fatal(err)
	}
	defer db.Close()

	templateCache, err := newTemplateCache("./ui/html/")
	if err != nil {
		errorLog.Fatal(err)
	}

	app := &application{
		errorLog:      errorLog,
		infoLog:       infoLog,
		snippets:      &mysql.SnippetModel{DB: db},
		templateCache: templateCache,
	}

	srv := &http.Server{
		Addr:     *addr,
		ErrorLog: errorLog,
		Handler:  app.routes(),
	}

	infoLog.Printf("Starting server on %s", *addr)
	err = srv.ListenAndServe()
	errorLog.Fatal(err)
}

templateCache, err := newTemplateCache("./ui/html/")templateCache 인스턴스를 만들고 이를 application 인스턴스에 할당해주었다.

이제 우리는 우리의 페이지에 대한 template cache를 갖게 되었다. 이제 두번째 문제인 duplicated code 문제를 해결해보자 그리고 helper메서드를 만들어 쉽게 cache로 부터 templates를 렌더링하도록 하자.

  • cmd/web/helpers.go
func (app *application) render(w http.ResponseWriter, r *http.Request, name string, td *templateData) {
	// Retrieve the appropriate template set from the cache based on the page name
	// (like 'home.page.tmpl'). If no entry exists in the cache with the
	// provided name, call the serverError helper method that we made earlier.
	ts, ok := app.templateCache[name]
	if !ok {
		app.serverError(w, fmt.Errorf("The template %s does not exist", name))
		return
	}
	// Execute the template set, passing in any dynamic data.
	err := ts.Execute(w, td)
	if err != nil {
		app.serverError(w, err)
	}
}

현재 render에는 http.Request를 사용하는 부분이 없다. 이는 추후에 사용되기 때문에 따로 빼두었다.

이제 helpers의 render메서드를 사용하여 handler 코드를 간결하게 만들어보자.

  • cmd/web/handlers.go
func (app *application) home(w http.ResponseWriter, r *http.Request) {
	if r.URL.Path != "/" {
		app.notFound(w)
		return
	}

	s, err := app.snippets.Latest()
	if err != nil {
		app.serverError(w, err)
		return
	}

	app.render(w, r, "home.page.tmpl", &templateData{
		Snippets: s,
	})
}

func (app *application) showSnippet(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.Atoi(r.URL.Query().Get("id"))
	if err != nil || id < 1 {
		app.notFound(w)
		return
	}

	s, err := app.snippets.Get(id)
	if err != nil {
		if errors.Is(err, models.ErrNoRecord) {
			app.notFound(w)
		} else {
			app.serverError(w, err)
		}
		return
	}
	app.render(w, r, "show.page.tmpl", &templateData{
		Snippet: s,
	})
}

렌더링하는 코드 부분을 helper로 두어서 훨씬 더 코드가 간결해졌다.

4. Catching Runtime Errors

html templates에 dynamic behavior를 추가하는 것은 runtime error를 발생시키는 위험이 있다.

일부러 show.page.tmpl template에 에러를 만들어보자, 그리고 무슨 일이 발생하는 지 알아보자.

  • ui/html/show.page.tmpl
{{template "base" .}}
{{define "title"}}Snippet #{{.Snippet.ID}}{{end}}
{{define "main"}}
<div class='snippet'>
    {{with .Snippet}}
    <div class='metadata'>
        <strong>{{.Title}}</strong>
        <span>#{{.ID}}</span>
    </div>
    {{len nil}} <!-- Deliberate error -->
    <pre><code>{{.Content}}</code></pre>
    <div class='metadata'>
        <time>Created: {{.Created}}</time>
        <time>Expires: {{.Expires}}</time>
    </div>
    {{end}}
</div>
{{end}}

{{len nil}} <!-- Deliberate error --> 부분이 일부러 에러를 발생시킨 부분이다. nil의 length를 가져오려고 하니 에러가 발생할 것이다.

이제 서버를 실행시키고 일부러 만든 에러를 확인해보자.

curl -i http://localhost:4000/snippet?id=1

HTTP/1.1 200 OK
Date: Mon, 26 Dec 2022 05:22:51 GMT
Content-Length: 741
Content-Type: text/html; charset=utf-8

<!doctype html>
<html lang='en'>
    <head>
        <meta charset='utf-8'>
        <title>Snippet #1 - Snippetbox</title>

        <link rel='stylesheet' href='/static/css/main.css'>
        <link rel='shortcut icon' href='/static/img/favicon.ico' type='image/x-icon'>

        <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>
        </nav>
        <main>

<div class='snippet'>

    <div class='metadata'>
        <strong>An old silent pond</strong>
        <span>#1</span>
    </div>
    Internal Server Error

분명히 에러가 발생했지만 응답은 HTTP/1.1 200 OK으로 나온다.

이는 굉장히 안좋은 상태이다. 제대로 된 html page도 안나오고 상태코드도 엉망이기 때문이다. 이러한 일이 발생하는 이유는 render 함수에서 제대로 코드를 작성하지 않았기 때문이다.

func (app *application) render(w http.ResponseWriter, r *http.Request, name string, td *templateData) {
	ts, ok := app.templateCache[name]

	if !ok {
		app.serverError(w, fmt.Errorf("The template %s does not exist", name))
		return
	}

	err := ts.Execute(w, td)
	if err != nil {
		app.serverError(w, err)
	}
}

이미 w에 값을 먼저 쓰기 때문에 헤더가 200 ok로 쓰이고, error가 발견되어 app.serverError가 동작하면 Internal Server Error가 추가되지만 header는 이미 쓰여있기 때문에 변경되지 않는다. 이러한 문제를 해결하기 위해서는 ts.Execute 메서드를 임시로 try해보고 결과를 반환받아 보는 것이 좋다.

  • cmd/web/helpers.go
func (app *application) render(w http.ResponseWriter, r *http.Request, name string, td *templateData) {
	ts, ok := app.templateCache[name]

	if !ok {
		app.serverError(w, fmt.Errorf("The template %s does not exist", name))
		return
	}
	// Initialize a new buffer.
	buf := new(bytes.Buffer)
	// Write the template to the buffer, instead of straight to the
	// http.ResponseWriter. If there's an error, call our serverError helper and then
	// return.
	err := ts.Execute(buf, td)
	if err != nil {
		app.serverError(w, err)
		return
	}
	// Write the contents of the buffer to the http.ResponseWriter. Again, this
	// is another time where we pass our http.ResponseWriter to a function that
	// takes an io.Writer.
	buf.WriteTo(w)
}

다음과 같이 buf := new(bytes.Buffer)를 먼저 생성하여 buf를 가지고 err := ts.Execute(buf, td)에 먼저 try해본다. 이후 결과에 따라 에러가 발생하면 app.serverError를 넣어주고, error가 발생하지 않았다면 buf.WriteTohttp.ResponseWriter에 값을 넘겨주면 된다.

다시 서버를 실행하여 결과를 확인해보도록 하자.

curl -i http://localhost:4000/snippet?id=1

HTTP/1.1 500 Internal Server Error
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Date: Mon, 26 Dec 2022 05:40:13 GMT
Content-Length: 22

Internal Server Error

재대로 된 결과가 나온 것을 확인할 수 있다.

이제 show.page.tmpl에서 {{len nil}} <!-- Deliberate error -->인 의도한 에러를 삭제해주도록 하자.

5. Common Dynamic Data

일부 web application에서 하나 이상 또는 심지어 모든 webpage에 포함하고 싶은 common dynamic data가 있을 수 있다. 가령 우리는 user의 name과 profile picture 또는 CSRF 토큰을 모든 페이지의 form형식으로 포함하고 싶을 수 있다.

우리는 간단하게, 현재 년도를 모든 page에 추가하고싶다고 하자.

이를 위해서 templateData 구조체에 CurrentYear 필드를 추가하도록 하자.

  • cmd/web/templates.go
type templateData struct {
	CurrentYear int
	Snippet     *models.Snippet
	Snippets    []*models.Snippet
}

이제 다음 단계는 addDefaultData() helper 메서드를 우리의 application에 추가할 차례이다. 이는 templateData 구조체의 인스턴스에 CurrentYear를 inject하게 해줄 것이다.

그리고 나서 우리는 render() helper 함수를 호출하여 default data를 각 페이지에 자동적으로 추가할 수 있도록 할 수 있다.

  • cmd/web/helpers.go

func (app *application) addDefaultData(td *templateData, r *http.Request) *templateData {
	if td == nil {
		td = &templateData{}
	}
	td.CurrentYear = time.Now().Year()
	return td
}

func (app *application) render(w http.ResponseWriter, r *http.Request, name string, td *templateData) {
	ts, ok := app.templateCache[name]

	if !ok {
		app.serverError(w, fmt.Errorf("The template %s does not exist", name))
		return
	}

	buf := new(bytes.Buffer)

	err := ts.Execute(buf, app.addDefaultData(td, r))
	if err != nil {
		app.serverError(w, err)
		return
	}

	buf.WriteTo(w)
}

addDefaultDatatemplateData를 포인터로 받아서 데이터가 없다면 만들어주고, 있다면 기존 데이터에 현재년도를 추가를 해주도록 한다.

해당 메서드를 render 메서드 안에 err := ts.Execute(buf, app.addDefaultData(td, r))으로 추가해주면 된다.

이제 년도를 그려주기 위해서 footer.partial.tmpl에 현재년도를 추가해주도록 하자.

  • ui/html/footer.partial.tmpl
{{define "footer"}}
<footer>Powered by <a href='https://golang.org/'>Go</a> in {{.CurrentYear}}</footer>
{{end}}

다시 서버를 시작하고 접속해보면, 하단 footer에 현재 년도가 있는 것을 확인할 수 있을 것이다.

6. Custom Template Functions

go template에 사용하기위해 custom한 function을 만들 수 있다. 즉, .tmpl 파일에 {{define}}과 같은 action을 만들 수 있다는 것이다.

humanDate() 함수를 만들어 datetime을 인간이 보기 좋은 형식으로 보이게 하자. 가령 2019-01-02 15:04:00 +0000 UTC02 Jan 2019 at 15:04와 같이 보이도록 하는 것이다.

이를 위해서 두 가지 주요한 과정이 있다.

  1. custom한 humanData()함수를 포함하는 template.FuncMap 객체를 만들도록 하자.
  2. template를 parse하기 이전에 이를 등록하기 위해서 template.Funcs() 메서드를 사용해야 한다.

이를 template.go 파일에 적용시켜보자.

  • cmd/web/templates.go
// Create a humanDate function which returns a nicely formatted string
// representation of a time.Time object.
func humanDate(t time.Time) string {
	return t.Format("02 Jan 2006 at 15:04")
}

// Initialize a template.FuncMap object and store it in a global variable. This is
// essentially a string-keyed map which acts as a lookup between the names of our
// custom template functions and the functions themselves.
var functions = template.FuncMap{
	"humanDate": humanDate,
}

func newTemplateCache(dir string) (map[string]*template.Template, error) {
	cache := map[string]*template.Template{}
	pages, err := filepath.Glob(filepath.Join(dir, "*.page.tmpl"))
	if err != nil {
		return nil, err
	}

	for _, page := range pages {
		name := filepath.Base(page)

		// The template.FuncMap must be registered with the template set before you
		// call the ParseFiles() method. This means we have to use template.New() to
		// create an empty template set, use the Funcs() method to register the
		// template.FuncMap, and then parse the file as normal.
		ts, err := template.New(name).Funcs(functions).ParseFiles(page)
		if err != nil {
			return nil, err
		}

		// ts, err = template.ParseFiles(page)
		// if err != nil {
		// 	return nil, err
		// }

		ts, err = ts.ParseGlob(filepath.Join(dir, "*.layout.tmpl"))
		if err != nil {
			return nil, err
		}

		ts, err = ts.ParseGlob(filepath.Join(dir, "*.partial.tmpl"))
		if err != nil {
			return nil, err
		}

		cache[name] = ts
	}

	return cache, nil
}

template.FuncMapcustom template function을 string-function(key-value)로 넣어준다. 이를 통해서 humanData라는 문자열로 humanDate 함수에 접근할 수 있게 되는 것이다.

마지막으로 ts, err := template.New(name).Funcs(functions).ParseFiles(page)에 우리가 만든 template.FuncMap을 넘겨주어야 하는데, 이는 template set에 우리가 만든 함수 mapping을 등록해주는 것이다. 이는 반드시 ParseFiles() 메서드 이전에 호출되어야 한다. 이는 template.New()를 사용하기 위해서 empty template set을 만들어야 한다는 것이며 Func() 메서드를 사용하여 template.FuncMap을 등록한다음 file을 정상적으로 파싱하는 것이다.

humanData()와 같은 custom template function은 여러 개의 파라미터를 받을 수 있지만, 반드시 한 개의 값을 반환해야 한다. 단 한 가지의 예외 케이스가 있는데, 두 번째 값으로 error를 반환하는 경우말고는 없다.

이제 우리의 humanData()함수를 build-in template function들과 같은 방법으로 사용해보도록 하자.

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

{{define "title"}}Home{{end}}

{{define "main"}}
    <h2>Latest Snippets</h2>
    {{if .Snippets}}
    <table>
        <tr>
            <th>Title</th>
            <th>Created</th>
            <th>ID</th>
        </tr>
        {{range .Snippets}}
        <tr>
            <td><a href='/snippet?id={{.ID}}'>{{.Title}}</a></td>
            <td>{{humanDate .Created}}</td>
            <td>#{{.ID}}</td>
        </tr>
        {{end}}
    </table>
    {{else}}
        <p>There's nothing to see here... yet!</p>
    {{end}}
{{end}}

<td>{{humanDate .Created}}</td>을 넣으면 자동으로 template에서 우리가 등록한 FuncMap을 통해서 humanDate의 함수를 찾아주어 실행해준다.

  • ui/html/show.page.tmpl
{{template "base" .}}
{{define "title"}}Snippet #{{.Snippet.ID}}{{end}}
{{define "main"}}
<div class='snippet'>
    {{with .Snippet}}
    <div class='metadata'>
        <strong>{{.Title}}</strong>
        <span>#{{.ID}}</span>
    </div>
    <pre><code>{{.Content}}</code></pre>
    <div class='metadata'>
        <time>Created: {{humanDate .Created}}</time>
        <time>Expires: {{humanDate .Expires}}</time>
    </div>
    {{end}}
</div>
{{end}}

show.page.tmpl에도 우리가 만든 humanDate를 사용해주고 렌더링을 해주면

go run ./...
24 Jan 2022 at 04:38

다음과 같이 사람이 알아보기 좋은 형식으로 값이 출력될 것이다.

추가적으로 재밌게도 template에서는 pipeline을 제공하는데 pipeline은 unix like system에서 제공하는 |가 맞다. 그래서 다음은 동일한 결과를 반환한다.

<time>Created: {{humanDate .Created}}</time>

<time>Created: {{.Created | humanDate}}</time>

이를 이용하여 다음과 같은 구문도 만들 수 있다.

<time>{{.Created | humanDate | printf "Created: %s"}}</time>

0개의 댓글