ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Go를 이용한 web application 작성
    Tech 2021. 3. 14. 21:16

    다루는 기술

    • data structure를 생성하고, 저장과 불러오기.
    • net/http package를 이용해서 web application 만들기
    • html/template package를 이용해서 HTML template 처리하기
    • regexp package를 이용해서 input validation 하기
    • closure를 이용하기

    기본 준비

    gowiki라는 폴더를 생성하고, 그 안에 wiki.go 파일을 작성합니다.

    package main
    
    import (
        "fmt"
        "io/ioutil"
    )

    data structure 정의

    page가 서로 연결되는 wiki는 각 각 title과 body(본문)라는 정보를 포함합니다.

    type Page struct {
        Title string
        Body []byte
    }
    • Body를 string이 아닌 []byte를 쓰는 이유는 contents를 담기에 byte를 이용하는 것은 후에 사용할 io library 들의 사용을 편리하게 하기 위함입니다.
    • 이 구조는 page data를 우리 memory에 어떻게 저장할지를 나타냅니다. (즉, 잠시 동안 담아놓을 경우에만 사용하는 구조) 하지만, 영구적으로 데이터를 저장하기 위해서는 다음과 같은 저장 함수가 필요합니다.
      • 특정 struct의 멤버 함수를 지정하고 싶을 때, 다음과 같이 func와 함수 이름 사이에 해당 structure의 이름의 pointer를 넣어주면 된다.
      • 해당 함수는 error를 return한다. 만약, 제대로 저장된다면, nil을 return 한다.
      • writeFile은 파일을 작성하는 함수로 현재에는 Page.Title에 .txt를 붙인 파일명에 Page.Body를 저장한다. 이때, 권한을 0600으로 지정한다는 뜻으로 마지막 인자가 사용된다. (권한을 잘 모르겠다면, "linux file 권한"을 google에 쳐보시길 권합니다.)
    func (p *Page) save() error {
        filename := p.Title + ".txt"
        return ioutil.WriteFile(filename, p.Body, 0600)
    }
    • 파일을 읽어오고 싶을 때는 다음과 같은 방식을 사용합니다.\
      • title을 입력받아서, file을 불러와 Page에 담아 리턴하는 함수
    func loadPage(title string) (*Page, error) {
        filename := title + ".txt"
        body, err := ioutil.ReadFile(filename)
        if err != nil {
            return nil, err
        }
        return &Page{ Title: title, Body: body }, nil
    }

    이후에 프로젝트를 빌드하고 실행시켜보면 파일을 write하고, 이를 다시 읽어오는 것을 볼 수 있다.

    $ go build wiki.go
    $ ./wiki

    go에서는 이렇게 쉽게 파일 입출력이 가능한 것 같다. (굉장히 편리하네용)

    net/http package

    해당 package를 이용해서 간단하게 webpage를 구동해보는 것이 가능합니다.

    package main
    
    import (
        "fmt"
        "log"
        "net/http"
    )
    
    // handler는 responseWriter와 request를 받아서, 
    // url 요청에서 root 주소를 제외한 값 중 가장 첫글자인 '/'를 제외한 값을 불러와 string에 끼워넣어 출력합니다.
    func handler(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Hi there, I love %s!", r.URL.Path[1:])
    }
    
    func main() {
        http.HandleFunc("/", handler) // url에 /를 붙이는 요청이 왔을 때, 수행할 동작은 hanlder임을 명시합니다.
        log.Fatal(http.ListenAndServe(":8080", nil)) // :8080으로 요청이 들어오면 위에 정의한 규칙에 따라 동작하도록 명시합니다. 이 과정에서 에러가 나는 경우 이를 기록할 수 있도록 logging하기 위해 한 번 감싸 줍니다. 
    }

    이를 위에서 수행했던, wiki.go 파일에 적용해볼 것이다.

    func viewHandler(w http.ResponseWriter, r *http.Request) {
        title := r.URL.Path[len("/view/"):]
        p, _ := loadPage(title)
        fmt.Fprintf(w, "<h1>%s</h1><div>%s</div>", p.Title, p.Body)
    }
    
    func main() {
        http.HandleFunc("/view/", viewHandler)
        log.Fatal(http.ListenAndServe(":8080", nil))
    }

    다음 두 코드를 삽입하여 요청이 들어오면 이에 알맞게 파일을 읽어서 값을 리턴하는 형식으로 표현한 것이다.

    이를 이용해서 이전에 만들었던, TestPage를 열어볼 수 있다.

    먼저, 해당 파일을 실행한다.

    $ go build wiki.go
    $ ./wiki

    그 후, http://localhost:8080/view/TestPage를 들어가면 다음과 같이 뜨는 것을 확인할 수 있다.

    수정 페이지

    현재 조회를 할 수 있는 페이지를 만들었지만, 수정하거나 저장을 수행할 url은 아직 정의가 되지 않았다. 따라서, 각 각의 url을 만들어서 해당 동작을 수행할 수 있도록 하겠습니다.

    아직, 수정 동작이 구현이 된 것은 아니므로 단지 input창으로 바뀌는 효과밖에는 없습니다.

    editHandler라는 함수를 새로 선언하고, 이를 활용하는 내용을 main에 추가합니다.

    func editHandler(w http.ResponseWriter, r *http.Request) {
        title := r.URL.Path[len("/edit/"):]
        p, err := loadPage(title)
        if err != nil {
            p = &Page{ Title: title }
        }
        fmt.Fprintf(w, "<h1>Editing %s</h1>"+
        "<form action=\"/save/%s\" method=\"POST\">"+
        "<textarea name=\"body\">%s</textarea><br>"+
        "<input type=\"submit\" value=\"Save\">"+
        "</form>",
        p.Title, p.Title, p.Body)
    }
    
    func main() {
        http.HandleFunc("/view/", viewHandler)
        http.HandleFunc("/edit/", editHandler)
        log.Fatal(http.ListenAndServe(":8080", nil))
    }

    code 좀 더 예쁘게 하기 (html/tempate package)

    위에 코드를 보면 html을 선언하는 부분이 다소 지저분하게 보일 수 있다.

    따라서, 이를 지원하는 package가 go에 존재한다.

    먼저, 위에 작성한 html 코드를 별도의 edit.html이라는 파일로 분리합니다.

    <h1>Editing {{.Title}}</h1>
    
    <form action="/save/{{.Title}}" method="POST">
    <div><textarea name="body" rows="20" cols="80">{{printf "%s" .Body}}</textarea></div>
    <div><input type="submit" value="Save"></div>
    </form>

    그 후에, "html/template"를 import에 선언한 후 다음과 같이 코드를 변경한다.

    import (
        "fmt"
        "io/ioutil"
        "log"
        "net/http"
        "html/template"
    )
    
    ...
    
    
    func editHandler(w http.ResponseWriter, r *http.Request) {
        title := r.URL.Path[len("/edit/"):]
        p, err := loadPage(title)
        if err != nil {
            p = &Page{Title: title}
        }
        t, _ := template.ParseFiles("edit.html")
        t.Execute(w, p)
    }
    

    이를 통해서 코드를 좀 더 간소화시킬 수 있습니다.

    마찬가지로, view 관련 부분도 수정한다면 다음과 같아집니다.

    view.html

    <h1>{{.Title}}</h1>
    
    <p>[<a href="/edit/{{.Title}}">edit</a>]</p>
    
    <div>{{printf "%s" .Body}}</div>

    wiki.go

    func viewHandler(w http.ResponseWriter, r *http.Request) {
        title := r.URL.Path[len("/view/"):]
        p, _ := loadPage(title)
        t, _ := template.ParseFiles("view.html")
        t.Execute(w, p)
    }

    또한, 중복되는 코드 역시 걸러내는 것이 좋기 때문에 다음과 같은 코드는 별도의 함수로 묶어둡니다.

    이에 따라서 다음과 같은 코드가 됩니다.

    func viewHandler(w http.ResponseWriter, r *http.Request) {
        title := r.URL.Path[len("/view/"):]
        p, _ := loadPage(title)
        renderTemplate(w, "view", p)
    }
    
    func editHandler(w http.ResponseWriter, r *http.Request) {
        title := r.URL.Path[len("/edit/"):]
        p, err := loadPage(title)
        if err != nil {
            p = &Page{ Title: title }
        }
        renderTemplate(w, "edit", p)
    }
    
    func renderTemplate(w http.ResponseWriter, tmpl string, p *Page) {
        t, _ := template.ParseFiles(tmpl + ".html")
        t.Execute(w, p)
    }
    

    404 not found (Handling non-existent pages)

    존재하지 않는 페이지일 경우 처리하는 방법은 여러 가지가 있다. 존재하지 않는다는 뜻에서 404 에러를 보내줄 수도 있고, 메인 페이지로 다시 redirect 해버리는 방법도 있다. 여기서는 edit page로 보내주는 방법을 선택하였다.

    파일로 저장하는 로직 구현

    edit.html 문서를 보면 form에서 요청을 /save/:title로 보내지만 아직까지는 이를 수행하는 함수가 있지 않았다. 이제 여기에 이를 구현해주면 된다.

    예외처리

    예외가 발생할 거 같은 부분에서 이를 무시하는 것은 좋지 않은 습관입니다. 알 수 없는 에러에 의해서 프로그램이 잘못된 행위를 취하게 될 경우 이를 복구하는 것이 매우 어려워질 수 있기 때문입니다. 따라서, 최소한 error message를 보여줄 수 있는 처리는 필수적입니다.

    따라서, 우리는 renderTemplate, saveHandler 함수를 다음과 같이 변경할 수 있습니다.

    func renderTemplate(w http.ResponseWriter, tmpl string, p *Page) {
        t, err := template.ParseFiles(tmpl + ".html")
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return 
        }
        err = t.Execute(w, p)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
        }
    }
    
    func saveHandler(w http.ResponseWriter, r *http.Request) {
        title := r.URL.Path[len("/save/"):]
        body := r.FormValue("body")
        p := &Page{ Title: title, Body: []byte(body) }
        err := p.save()
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
        http.Redirect(w, r, "/view/" + title, http.StatusFound)
    }

    Template caching

    매번 view를 호출할 때마다, template를 조회하는 것은 많은 비용을 발생시킵니다. 따라서, 해당 데이터를 memory에 올려놓고, 이를 이용하는 것이 더욱 효율적인 사용을 가능케 합니다. 따라서, 다음과 같은 방식으로 동작을 수행합니다.

    var templates = template.Must(template.ParseFiles("edit.html", "view.html"))
    
    func renderTemplate(w http.ResponseWriter, tmpl string, p *Page) {
        err := templates.ExecuteTemplate(w, tmpl + ".html", p)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return 
        }
    }
    

    URL Validation

    현재 만든 web application의 가장 큰 보안 취약점을 고르라면, 그것은 url에 무슨 이름이든 입력이 가능하고, 이를 통해서 파일을 생성하는 것이 가능하다는 것입니다. 따라서, 우리는 해당 데이터를 제한할 필요가 있고, 이는 regexp package를 이용해서 구현이 가능합니다.

    var validPath = regexp.MustCompile("^/(edit|save|view)/([a-zA-Z0-9]+)$")
    
    func getTitle(w http.ResponseWriter, r *http.Request) (string, error) {
        m := validPath.FindStringSubmatch(r.URL.Path)
        if m == nil {
            http.NotFound(w, r)
            return "", errors.New("invalid Page Title")
        }
        return m[2], nil 
    }
    
    func viewHandler(w http.ResponseWriter, r *http.Request) {
        title, err := getTitle(w, r)
        if err != nil {
            return
        }
        p, err := loadPage(title)
        if err != nil {
            http.Redirect(w, r, "/edit/" + title, http.StatusFound)
            return
        }
        renderTemplate(w, "view", p)
    }
    
    func editHandler(w http.ResponseWriter, r *http.Request) {
        title, err := getTitle(w, r)
        if err != nil {
            return
        }
        p, err := loadPage(title)
        if err != nil {
            p = &Page{ Title: title }
        }
        renderTemplate(w, "edit", p)
    }
    
    func saveHandler(w http.ResponseWriter, r *http.Request) {
        title, err := getTitle(w, r)
        if err != nil {
            return
        }
        body := r.FormValue("body")
        p := &Page{ Title: title, Body: []byte(body) }
        err = p.save()
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
        http.Redirect(w, r, "/view/" + title, http.StatusFound)
    }

    Function Literals and Closures

    위의 코드처럼 getTitle을 반복해서 호출하고, error를 확인하는 중복 코드는 굉장히 안 좋은 패턴입니다. 이러한 validation과 error checking을 포함해서 함수를 한 번 포장해주는 방법을 고려해볼 필요가 있습니다. 이때, Go의 function literal은 굉장한 수단이 되어줍니다.

    func makeHandler(fn func (http.ResponseWriter, *http.Request, string)) http.HandlerFunc {
        return func(w http.ResponseWriter, r *http.Request) {
            m := validPath.FindStringSubmatch(r.URL.Path)
            if m == nil {
                http.NotFound(w, r)
                return
            }
            fn(w, r, m[2])
        }
    }
    • 다음과 같이 makeHandler를 통해서 함수를 인자로 받아 함수를 리턴하는 함수를 생성할 수 있습니다.
    • 해당 함수는 기존의 getTitle의 기능을 포함하고 있습니다.
    • 여기서 return 되는 함수를 closure이라고 하고, closure는 인자로 받은 function을 호출하여 이를 사용하는 것이 가능하다.

    최종 코드는 상당 부분 바뀌기 때문에 github link를 올리겠습니다.

     

     

    euidong/go_web_tutorial

    Contribute to euidong/go_web_tutorial development by creating an account on GitHub.

    github.com

     

    'Tech' 카테고리의 다른 글

    Go with GraphQL  (0) 2021.04.04
    chrome 확장 앱  (0) 2021.04.01
    RDF Turtle  (0) 2021.03.23
    A Tour of Go  (0) 2021.03.16
    Golang 찍어먹어보기  (0) 2021.03.14

    댓글

Copyright © euidong