이 섹션에서는 요청 헤더에 따라 애플리케이션이 HTML, JSON 또는 XML 형식으로 응답 할 수 있도록 애플리케이션을 약간 리팩터링합니다.
1. 재사용 가능한 함수 만들기
Route Handler에서 지금까지는 Gin의 컨텍스트 중 HTML을 사용했습니다. 항상 HTML페이지를 보여줄 때는 괜찮지만, 요청에 따라 응답 형식을 변경하고 싶을 때에는 렌더링을 처리하는 단일 함수로 리팩토링해야합니다(?). 이렇게 함으로써 Route Handler는 유효성 검사(validation) 및 데이터 추출(data fetching)에 집중하도록 할 수 있습니다.
Route Handler는 응답 형식에 관계없이 동일한 유효성 검사, 데이터 추출 및 처리를 수행해야합니다. 이 부분이 완료되면 데이터를 사용하여 원하는 형식의 응답을 생성 할 수 있습니다. HTML 응답이 필요한 경우 데이터를 HTML 템플릿에 전달하고, JSON 응답이 필요한 경우이 데이터를 JSON으로 변환, XML에서도 마찬로 데이터를 보내줄 수 있습니다.
main.go 파일 내부에 모든 Route Handler가 사용할 render 함수를 만들겠습니다. 이 함수는 request의 Accept header를 참조하여 rendering을 처리할 것입니다.
Gin에서는 라우트 핸들러에 전달 된 Context 객체에 Request라는 필드가 포함됩니다. This field contains the header field which contains all the request headers. Get 메소드를 를 이용하여 Header로부터 Accept header를 다음과 같이 추출할 수 있습니다.
// c is the Gin Context
c.Request.Header.Get("Accept")
application/json 으로 설정하면 함수가 JSON을 렌더링합니다.
application/xml 으로 설정하면 함수가 XML을 렌더링하고
이것이 다른 것으로 설정되거나 비어 있으면 함수는 HTML을 렌더링합니다.
main.go에 render 함수를 아래와 같이 추가합니다.
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
var router *gin.Engine
func main() {
router := gin.Default()
router.LoadHTMLGlob("templates/*")
router.GET("/", showIndexPage)
router.GET("/article/view/:article_id", getArticle)
router.Run()
}
func render(c *gin.Context, data gin.H, templateName string) {
switch c.Request.Header.Get("Accept") {
case "application/json":
// Respond with JSON
c.JSON(http.StatusOK, data["payload"])
case "application/xml":
// Respond with XML
c.XML(http.StatusOK, data["payload"])
default:
// Respond with HTML
c.HTML(http.StatusOK, templateName, data)
}
}
2. 유닛 테스트를 통한 Route Handler 요구사항 정의 =>생략...
3. Route Handler 업데이트
기존의 c.HTML방법을 render 함수로 바꿔주기만 하면 됩니다.
<기존>
func showIndexPage(c *gin.Context) {
articles := getAllArticles()
// Call the HTML method of the Context to render a template
c.HTML(
// Set the HTTP status to 200 (OK)
http.StatusOK,
// Use the index.html template
"index.html",
// Pass the data that the page uses
gin.H{
"title": "Home Page",
"payload": articles,
},
)
}
<변경>
func showIndexPage(c *gin.Context) {
articles := getAllArticles()
// Call the render function with the name of the template to render
render(c, gin.H{
"title": "Home Page",
"payload": articles}, "index.html")
}
JSON 형식으로 Article List 추출
JSON테스트를 위해새로 어플리케이션을 빌드한 후 아래를 실행합니다.
curl -X GET -H "Accept: application/json" http://localhost:8080/
개별 Article Contents를 표현할 새 템플릿 templates/article.html 을 생성하겠습니다.
<!--article.html-->
<!--Embed the header.html template at this location-->
{{ template "header.html" .}}
<!--Display the title of the article-->
<h1>{{.payload.Title}}</h1>
<!--Display the content of the article-->
<p>{{.payload.Content}}</p>
<!--Embed the footer.html template at this location-->
{{ template "footer.html" .}}
유닛 테스트....가 필요한데 Pass...
3. Route Handler 생성
Article 추출
models.article.go파일에 getArticleByID() 함수 정의하여 article을 가져오는 기능을 추가합니다.
package main
import "errors"
type article struct {
ID int `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
}
// For this demo, we're storing the article list in memory
// In a real application, this list will most likely be fetched
// from a database or from static files
var articleList = []article{
article{ID: 1, Title: "Article 1", Content: "Article 1 body"},
article{ID: 2, Title: "Article 2", Content: "Article 2 body"},
}
// Return a list of all the articles
func getAllArticles() []article {
return articleList
}
func getArticleByID(id int) (*article, error) {
for _, a := range articleList {
if a.ID == id {
return &a, nil
}
}
return nil, errors.New("Article not found")
}
getArticleByID 함수는 Article List를 반복하다가 ID가 일치할 경우 해당 Article을 반환합니다. 일치하는 기사가 없으면 오류를 반환합니다.
handlers.article.go파일 수정
Article을 article.html 템플릿에 전달하여 렌더링하도록 getArticle 함수를 추가합니다.
// handlers.article.go
package main
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
func showIndexPage(c *gin.Context) {
articles := getAllArticles()
// Call the HTML method of the Context to render a template
c.HTML(
// Set the HTTP status to 200 (OK)
http.StatusOK,
// Use the index.html template
"index.html",
// Pass the data that the page uses
gin.H{
"title": "Home Page",
"payload": articles,
},
)
}
func getArticle(c *gin.Context) {
// Check if the article ID is valid
if articleID, err := strconv.Atoi(c.Param("article_id")); err == nil {
// Check if the article exists
if article, err := getArticleByID(articleID); err == nil {
// Call the HTML method of the Context to render a template
c.HTML(
// Set the HTTP status to 200 (OK)
http.StatusOK,
// Use the index.html template
"article.html",
// Pass the data that the page uses
gin.H{
"title": article.Title,
"payload": article,
},
)
} else {
// If the article is not found, abort with an error
c.AbortWithError(http.StatusNotFound, err)
}
} else {
// If an invalid article ID is specified in the URL, abort with an error
c.AbortWithStatus(http.StatusNotFound)
}
}
이 섹션에서는 index 페이지에 모든 article list를 표시하는 기능을 추가합니다.
1.Router 설정
원문에 따르면 응용 프로그램이 커질 것을 대비하여 별도의 Router파일에서 경로를 정의하는 방식으로 구성하였는데, 무슨 문제인지 제 실습 중에는 routes.go파일에 따로 코드를 분리하니 에러가 발생했습니다. 그래서 route 를 main() 함수 내부에 구성하도록 하겠습니다. 단, route handler 함수만 별도로 분리해 내도록 하겠습니다.(handlers.article.go)
main.go파일은 아래와 같이 코딩합니다.
package main
import (
//"net/http""github.com/gin-gonic/gin"
)
var router *gin.Engine
funcmain() {
router := gin.Default()
router.LoadHTMLGlob("templates/*")
router.GET("/", showIndexPage)
router.Run()
}
2. Article Model 디자인
router를 마치기 전에 우선 Model을 구성하도록 합니다. 우리가 구성할 Article 모델은 Id, Title, Content의 세 개의 필드 만 사용하여 구성합니다. 일반적인 어플리케이션이라면 당연히 데이터베이스를 사용하지만, 예제에서는 이를 단순화하기 위해 아래와 같이 하드코딩된 List변수를 사용하도록 하겠습니다. 그리고 모든 Article List을 반환하는 함수가 필요합니다. 이 함수의 이름을 getAllArticles()로 하고 models.article.go 파일에 함께 구성합니다.
models.article.go
// models.article.gopackage main
type article struct {
ID int json:"id"
Title string json:"title"
Content string json:"content"
}
// For this demo, we're storing the article list in memory// In a real application, this list will most likely be fetched// from a database or from static filesvar articleList = []article{
article{ID: 1, Title: "Article 1", Content: "Article 1 body"},
article{ID: 2, Title: "Article 2", Content: "Article 2 body"},
}
// Return a list of all the articlesfuncgetAllArticles() []article {
return articleList
}
또한 단위 테스트를 위한 models.article_test.go 파일을 생성하고 내부에 TestGetAllArticles 함수를 작성하겠습니다.
models.article_test.go
// models.article_test.gopackage main
import"testing"// Test the function that fetches all articlesfuncTestGetAllArticles(t *testing.T) {
alist := getAllArticles()
// Check that the length of the list of articles returned is the// same as the length of the global variable holding the listiflen(alist) != len(articleList) {
t.Fail()
}
// Check that each member is identicalfor i, v := range alist {
if v.Content != articleList[i].Content ||
v.ID != articleList[i].ID ||
v.Title != articleList[i].Title {
t.Fail()
break
}
}
}
위 단위 테스트에서는 getAllArticles() 함수를 이용해 모든 Article List를 가져오는 테스트 수행합니다. 이 테스트는 먼저getAllArticles() 함수로 가져온 Article List와 전역 변수 articleList의 요소 개수가 동일한지 확인합니다. 그런 다음 Article List들을 반복하여 각각의 Article이 동일한 지 확인합니다. 이 두 검사 중 하나가 실패하면 테스트는 Fail이 됩니다.
3. View Template 만들기
index.html에서 모든 Article들의 List를 표시해야합니다. Article List는 payload라는 이름의 변수로 전달하도록 하겠습니다. 아래는 모든 article list를 보여주게 됩니다. 모든 Article List에 대해 Title, Content를 표시해주게 됩니다만, 아직 라우터를 완성하지 않았으므로 표시되는 건 없습니다.
업데이트 된 index.html파일은 아래와 같습니다.
<!--index.html--><!--Embed the header.html template at this location-->
{{ template "header.html" .}}
<!--Loop over the payload variable, which is the list of articles-->
{{range .payload }}
<!--Create the link for the article based on its ID--><ahref="/article/view/{{.ID}}"><!--Display the title of the article --><h2>{{.Title}}</h2></a><!--Display the content of the article--><p>{{.Content}}</p>
{{end}}
<!--Embed the footer.html template at this location-->
{{ template "footer.html" .}}
(주의) header에는 웹페이지의 타이틀을 표시하는 {{.title}}변수가 있고, index에서 호출하는 {{.Title}}변수는 Article의 타이틀을 의미합니다.
4. 유닛 테스트를 통한 Route Handler 요구사항 정의
Index 라우트 생성 전에 이 route handler의 동작을 미리 예상한 테스트를 정의하겠습니다. 이 테스트에서 확인할 조건은 다음과 같습니다.
핸들러는 HTTP 상태 코드 200으로 응답한다.
반환 된 HTML에는 "Home Page"라는 텍스트가 포함 된 title tag가 포함된다.
handlers.article_test.go파일을 생성하고, 내부에 TestShowIndexPageUnauthenticated 라는 테스트 함수를 생성합니다. 그리고 이 함수에서 사용할 helper 함수를 common_test.go파일에 작성합니다.
handlers.article_test.go다음과 같습니다.
// handlers.article_test.gopackage main
import (
"io/ioutil""net/http""net/http/httptest""strings""testing"
)
// Test that a GET request to the home page returns the home page with// the HTTP code 200 for an unauthenticated userfuncTestShowIndexPageUnauthenticated(t *testing.T) {
r := getRouter(true)
r.GET("/", showIndexPage)
// Create a request to send to the above route
req, _ := http.NewRequest("GET", "/", nil)
testHTTPResponse(t, r, req, func(w *httptest.ResponseRecorder)bool {
// Test that the http status code is 200
statusOK := w.Code == http.StatusOK
// Test that the page title is "Home Page"// You can carry out a lot more detailed tests using libraries that can// parse and process HTML pages
p, err := ioutil.ReadAll(w.Body)
pageOK := err == nil && strings.Index(string(p), "<title>Home Page</title>") > 0return statusOK && pageOK
})
}
common_test.go다음과 같습니다.
package main
import (
"net/http""net/http/httptest""os""testing""github.com/gin-gonic/gin"
)
var tmpArticleList []article
// This function is used for setup before executing the test functionsfuncTestMain(m *testing.M) {
//Set Gin to Test Mode
gin.SetMode(gin.TestMode)
// Run the other tests
os.Exit(m.Run())
}
// Helper function to create a router during testingfuncgetRouter(withTemplates bool) *gin.Engine {
r := gin.Default()
if withTemplates {
r.LoadHTMLGlob("templates/*")
}
return r
}
// Helper function to process a request and test its responsefunctestHTTPResponse(t *testing.T, r *gin.Engine, req *http.Request, f func(w *httptest.ResponseRecorder)bool) {
// Create a response recorder
w := httptest.NewRecorder()
// Create the service and process the above request.
r.ServeHTTP(w, req)
if !f(w) {
t.Fail()
}
}
// This function is used to store the main lists into the temporary one// for testingfuncsaveLists() {
tmpArticleList = articleList
}
// This function is used to restore the main lists from the temporary onefuncrestoreLists() {
articleList = tmpArticleList
}
테스트를 도와주는 helper 함수 몇개를 작성했습니다. 이 함수들은 유사한 기능 테스트를 위한 추가 테스트 코드를 작성할 때 상용구 코드를 줄이는 데에도 도움이됩니다.
TestMain함수는 Gin이 테스트 모드를 사용하도록 설정하고 나머지 테스트 함수를 호출합니다. getRouter함수는 기본 애플리케이션과 유사한 방식으로 라우터를 생성/반환합니다. saveLists() 함수는 (original) Article List를 임시 변수에 저장합니다. 임시 변수는 restoreLists() 함수에서 사용되어, 단위 테스트가 실행 된 후 Article List를 테스트 수행 전 초기 상태로 복원하는 역할을 합니다.
마지막으로 testHTTPResponse함수는 인자로 전달 된 함수를 실행하여 true/false를 반환하는지 확인합니다.(테스트 성공/실패). 이 함수는 HTTP 요청에 대한 응답을 테스트하는 데 필요한 중복 코드를 방지해줍니다.
HTTP 코드와 반환 된 HTML을 확인하기 위해 다음을 수행합니다.
새 router 생성.
메인 앱에서 사용하는 것과 동일한 handler를 사용하는 route 정의( showIndexPage),
이 route에 접근하기 위한 새 request 생성
HTTP 코드와 HTML을 테스트하기 위한 응답처리 함수 생성
testHTTPResponse() 호출. 단, 새로 생성한 응답처리 함수와 연결.
5. Route Handler 생성
handlers.article.go파일에 Article 관련 기능들에 대한 모든 route handler 함수인 showIndexPage를 정의합니다. payload라는 변수를 통해 Article List를 템플릿에 전달하도록 수정하였습니다.
handlers.article.go파일
// handlers.article.gopackage main
import (
"net/http""github.com/gin-gonic/gin"
)
funcshowIndexPage(c *gin.Context) {
articles := getAllArticles() // 기존에 정의한 getAllArticles 함수를 이용하여 article list 가져오기// Call the HTML method of the Context to render a template
c.HTML(
// Set the HTTP status to 200 (OK)
http.StatusOK,
// Use the index.html template"index.html",
// Pass the data that the page uses
gin.H{
"title": "Home Page",
"payload": articles,
},
)
}
<!--index.html-->
<!--Embed the header.html template at this location-->
{{ template "header.html" .}}
<h1>Hello Gin!</h1>
<!--Embed the footer.html template at this location-->
{{ template "footer.html" .}}
3. header.html
<!--header.html-->
<!doctype html>
<html>
<head>
<!--Use the title variable to set the title of the page-->
<title>{{ .title }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta charset="UTF-8">
<!--Use bootstrap to make the application look nice-->
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css" integrity="sha384-1q8mTJOASx8j1Au+a5WDVnPi2lkFfwwEAa8hDDdjZlpLegxhjVME1fgjWPGmkzs7" crossorigin="anonymous">
<script async src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/js/bootstrap.min.js" integrity="sha384-0mSbJDEHialfmuBBQP6A4Qrprq5OVfW37PRR3j5ELqxss1yVqOtnepnHVP9aJ7xS" crossorigin="anonymous"></script>
</head>
<body class="container">
<!--Embed the menu.html template at this location-->
{{ template "menu.html" . }}
이번에는 모듈 구분을 하여 코드를 좀 다듬어보려고 합니다. 아직 Lorca는 예제가 많지도 않고, 튜토리얼이 있는 상태도 아니어서 혼자 여러가지 방식을 시도해 보는 중입니다. 어쨌든 목적은 Control, View와 Function을 다른 파일에 나누어 구성하는 것입니다.
1. lorca_ex.go(Control파일)
앱의 시작부분으로 Control에 해당하는 역할을 합니다. 함수 바인딩을 이곳에서 정의합니다. 그리고 index.html을 불러와서 data에 저장한 뒤 url.PathEscape메서드에 string형태의 인자로 넘겨주는 방식으로 수정해 주었습니다.
아직 로컬 CSS를 인식하는 방법이 있는지는 잘 모르겠습니다. 사용 예제가 있는데 복잡하고 잘 이해가 안되네요. 그래서 생각해낸 방법이 Bootstrap을 CDN방식으로 불러오기. 이렇게 하면 잘 인식이 됩니다.
<!doctype html><htmllang="ko"><head><title>Lorca Sample</title><metacharset="UTF-8"><linkhref="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta1/dist/css/bootstrap.min.css"rel="stylesheet"integrity="sha384-giJF6kkoqNQ00vy+HMDP7azOuL0xtbfIcaT9wjKHr8RbDVddVHyTfAAsrekwKmP1"crossorigin="anonymous"><scriptsrc="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script><scriptsrc="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.16.0/umd/popper.min.js"></script><scriptsrc="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script><script>functionhelloFromJavaScript() {
return'Hello from JavaScript!'
}
</script></head><body><!-- 헤 섹션 --><divclass="box-padding-big"><navclass="navbar navbar-expand-sm navbar-dark bg-dark"><divclass="collapse navbar-collapse"><ulclass="navbar-nav mr-auto"><liclass="nav-item active"><aclass="nav-link"href="#"id="navbardrop"data-toggle="dropdown">파일</a><divclass="dropdown-menu"><aclass="dropdown-item"href="#">서브1</a><aclass="dropdown-item"href="#">서브2</a><aclass="dropdown-item"href="#">서브3</a></div></li><liclass="nav-item"><aclass="nav-link"href="#">편집</a></li><liclass="nav-item"><aclass="nav-link"href="#">뷰</a></li><liclass="nav-item"><aclass="nav-link"href="#">정보</a></li></ul></div></nav></div><!-- 첫번째 섹션 --><divclass="box-padding-big bg-info"><divclass="btn-secondary"align="center"><textareaid="content"style="width:98%"rows="18"></textarea></div></div><!-- 두번째 섹션 --><divclass="box-padding-big btn-secondary"align="center"><buttononclick="helloFromGo()"class="btn btn-info btn-sm"> Click! </button><buttononclick="saveFromGo()"class="btn btn-warning btn-sm"> Save </button></div><!-- 푸터 섹션 --><divclass="btn-secondary"style="text-align:center vertical-align:bottom"><divclass="container text-center">
The site owner is not responsible for uploaded images. </br>
You can only upload images for which you own the copyright.
</div></div></body></html>
기능은 Click을 누르면 Hello From Go를 출력하고, Save를 누르면 같은 폴더에 1.txt를 생성하며 텍스트를 저장합니다.
추가로, 배포할 때에는 html파일도 함께 배포해야 정상 작동하는 것도 주의하세요. (lorca_ex.go 파일 내부에 html을 모두 코딩하면 하나의 exe파일이 생성되므로 상관없지만, 위 예제처럼 불러오기식으로 처리하면 함께 배포해야합니다. 상황에 따라 적절히 활용하시기 바랍니다.)
Go 언어로 구현된 라이브러리들도 상당히 많습니다. (https://github.com/avelino/awesome-go#gui). 이전에 Fyne, Gotk3, andlabs/ui, sciter 에 대해서 포스팅한 적이 있는데요...최대한 설치할 것들이 적고 쉽게 구축 가능한 라이브러리들을 찾아보고 있었습니다. 그러던 중 Lorca라는 것을 발견했는데요. 오늘은 이 Lorca 를 한번 사용해보겠습니다. Lorca는 HTML 문법을 사용하여 매우 쉽게 UI를 구성할 수 있다는 장점이 있습니다. 비슷하게는 Webview가 있는데, 가볍고 build된 파일도 용량이 매우 작지만 설치시 조금 애로가 있을 수 있고, 세부설정도 Lorca보다는 조금 어렵다고 합니다. 반대로 얘기하면, Lorca는 설정할 수 있는 항목이 제한적이지만 심플하게 만들어보기에는 적합하다고 합니다.
1. 설치
여타 다른 go 라이브러리들과 마찬가지로 go get으로 lorca를 설치해줍시다.
go get github.com/zserge/lorca
2. Hello World
우선 아래의 소스를 그대로 복붙하여 실행이 되는지 봅시다.
package main
import (
"log""net/url""github.com/zserge/lorca"
)
funcmain() {
// Create UI with basic HTML passed via data URI
ui, err := lorca.New("data:text/html,"+url.PathEscape(`
<html>
<head><title>Hello</title></head>
<body><h1>Hello, world!</h1></body>
</html>
`), "", 480, 320)
if err != nil {
log.Fatal(err)
}
defer ui.Close()
// Wait until UI window is closed
<-ui.Done()
}
Golang에는 웹 프레임워크가 여러가지 있습니다. 제가 공부했던 책에서는 Beego가 추천되었는데, 그 외에도 Revel, Martini, Buffalo, echo, iris 등 여러가지가 있습니다. 요즘은 gin이라는 프레임워크가 대세인 것 같아서 설치해볼까 합니다. https://github.com/gin-gonic/gin(Gin 소스 페이지) 우선 아래 명령어로 Gin을 설치합니다.
go get -u github.com/gin-gonic/gin
그리고 프로젝트를 생성해보겠습니다. 적당한 폴더를 하나 만들어 주고 Go 파일을 하나 작성해줍니다. 저는 그냥 main.go로 만들었습니다.
package main
import"github.com/gin-gonic/gin"funcmain() {
r := gin.Default()
r.GET("/ping", func(c *gin.Context) {
c.JSON(200, gin.H{
"message": "pong",
})
})
r.Run() // listen and serve on 0.0.0.0:8080 (for windows "localhost:8080")
}
이제 웹 브라우저에서 localhost:8080/ping을 입력하면 메시지가 응답되는 것을 볼 수 있습니다.