Haskell Yesod의 템플릿 언어 세익스피어를 활용하여 웹페이지 만들기 (1)
앞으로 2개의 포스팅을 통해 Yesod로 웹페이지를 구현해보겠습니다.
먼저, Yesod로 Web 페이지를 구현하기 위한 템플릿 언어에는 다음이 있습니다.
- Hamlet
- 들어쓰기를 활용한 네스트 표현
- Cassius (Lucius)
- Lucius 는 CSS 의 슈퍼세트로、CSS 기술
- Lucius 는
{}
를 통해 네스트 표현
- Julius
- 들어쓰기로 네스트를 표현
이상을 합쳐서 Shakespeare라고 부르고, 각각은 HTML
, CSS
, Javascript
에 대응합니다. HTML 이 Hamlet 에,
Cassius (Lucius) 이 CSS 에, Javascript 에 Julius 가 대응하는 등 첫 글자가 동일한 것은 뭐 우연일 것입니다.
Web페이지 작성해보기
이번에는 Bootstrap3でさくっとWebサイトを作ってみよう의 내용을 Yesod 를 이용하여 기술해보겠습니다.
Wrapper 작성
<html>
, <head>
, <body>
을 정의하는 것부터 시작합니다. 그리고 이를 templates/default-layout-wrapper.hamlet
에 기술합니다.
templates/default-layout-wrapper.hamlet
1 2 3 4 5 6 7 | $doctype 5 <html> <head> <title>#{pageTitle pc} ^{pageHead pc} <body> ^{pageBody pc} | cs |
Hamlet
의 구문에는 닫는 태그는 기본적으로 사용하지 않습니다. 닫는 태그를 들어쓰기로 대신합니다.
Doctype
$doctype
은 Doctype 선언을 위한 키워드입니다. 아래와 같이 종류가 있습니다.
구문 | 생성되는 Doctype 선언 |
---|---|
$doctype 5 | <!DOCTYPE html> |
$doctype html | <!DOCTYPE html> |
$doctype 1.1 | <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "" class="autolink">http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd"> |
$doctype strict | <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "" class="autolink">http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> |
!!! (非推奨 비추장) | <!DOCTYPE html> |
변수전개
Hamlet
은 변수의 전개를 지원합니다. 다만, 전개되는 대상에 따라 아래와 같이 약간의 차이가 있습니다.
기법 | 전개되는 대상 |
---|---|
#{...} | 변수 |
@{...} | 형안전URL |
@?{...} | 쿼리 파라메터에 붙는 형안전URL |
^{...} | 동형 템플릿 |
*{...} | 속성 또는 속성의 리스트 |
_{...} | 메시지 |
이번 예에서는 변수전개와 템플릿의 전개를 다뤄봅니다.
Header 구역 작성
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | <div #header> <div .logo> <h1>LOGO <nav .navbar .navbar-default role="navigation"> <div .container> <div .navbar-header> <button .navbar-toggle data-toggle="collapse" data-target="#bs-example-navbar-collapse-1"> <span .sr-only>Toggle navigation <span .icon-bar> <span .icon-bar> <span .icon-bar> <span .navbar-brand .visible-xs href="@{HomeR}">Menu <div .collapse .navbar-collapse #bs-example-navbar-collapse-1> <ul .nav .navbar-nav> $forall n <- [1,2,3,4,5] $if n == 1 <li .first> <a href=@{HomeR}>MENU#{show n} $else <li> <a href=@{HomeR}>MENU#{show n} | cs |
@{HomeR}
로 링크를 지정합니다. 이것은 config/routes
파일에 기술된 내용과 대응관계로 실제의 URL 을 교환합니다. 태그의 속성으로 .
와 #
을 사용하는바, 이로부터 자동적으로 id
와 class
에 각각 치환됩니다. 생성되는 HTML
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | <!DOCTYPE html> <html><head><title></title> <link rel="stylesheet" href="http://***.**.**.***:3000/static/css/bootstrap.css?etag=PDMJ7RwX"><link rel="stylesheet" href="http://***.**.**.***:3000/static/tmp/autogen-dyDfk8nC.css"></head> <body><div id="header"><div class="logo"><h1>LOGO</h1> </div> <nav class="navbar navbar-default" role="navigation"><div class="container"><div class="navbar-header"><button class="navbar-toggle" data-toggle="collapse" data-target="#bs-example-navbar-collapse-1"><span class="sr-only">Toggle navigation</span> <span class="icon-bar"></span> <span class="icon-bar"></span> <span class="icon-bar"></span> </button> <span class="navbar-brand visible-xs" href="http://***.**.**.***:3000/">Menu</span> </div> <div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1"><ul class="nav navbar-nav"><li class="first"><a href="http://***.**.**.***:3000/">MENU1</a> </li> <li><a href="http://***.**.**.***:3000/">MENU2</a> </li> <li><a href="http:/***.**.**.***:3000/">MENU3</a> </li> <li><a href="http://***.**.**.***:3000/">MENU4</a> </li> <li><a href="http://***.**.**.***:3000/">MENU5</a> </li> </ul> </div> </div> </nav> </div> </body> </html> | cs |
Header 영역 CSS 정의
Cassius
도 Hamlet
과 같은 들여쓰기를 이용하여 기술합니다. 다만, 세미클론은 필요없습니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 | #header padding-top: 20px background-color: #eee .logo text-align: center width: 100px margin-left: auto margin-right: auto margin-bottom: 20px .logo h1 margin: 0 padding-top: 38px width: 100px height: 100px -webkit-border-radius: 50% -moz-border-radius: 50% border-radius: 50% font-size: 26px color: #7E7E7E background-color: #fff .navbar border-radius: 0 .navbar-nav float: none width: 400px margin: 10px auto .navbar-nav > li text-align: center float: left width: 80px border-right: 1px solid #555 .navbar-nav > li.first border-left: 1px solid #555 .navbar-nav > li > a padding-top: 5px padding-bottom: 5px | cs |
생성되는 CSS
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 | #header { padding-top: 20px; background-color: #eee; } .logo { text-align: center; width: 100px; margin-left: auto; margin-right: auto; margin-bottom: 20px; } .logo h1 { margin: 0; padding-top: 38px; width: 100px; height: 100px; -webkit-border-radius: 50%; -moz-border-radius: 50%; border-radius: 50%; font-size: 26px; color: #7E7E7E; background-color: #fff; } .navbar { border-radius: 0; } .navbar-nav { float: none; width: 400px; margin: 10px auto; } .navbar-nav > li { text-align: center; float: left; width: 80px; border-right: 1px solid #555; } .navbar-nav > li.first { border-left: 1px solid #555; } .navbar-nav > li > a { padding-top: 5px; padding-bottom: 5px; } | cs |
이번에 새롭게 등장한 구문이 $if $else
구문과 $forall
구문입니다.
Forall
이것은、리스트의 값을 반복해서 보여줄 때 사용됩니다.
1 2 3 | <ul> $forall person <- people <li>#{person} | cs |
if elseif else
Hamlet
에는 조건분기로 if, elseif, else
구문이 사용됩니다.
1 2 3 4 5 6 | $if isAdmin <p>管理者ページへようこそ。 $elseif isLoggedIn <p>管理者ではありません。 $else <p>ログインしてください。 | cs |
Footer 영역의 작성
헤더 영역과 관련 방식으로 templates/default-layout.hamlet
에 기술한다.
templates/default-layout.hamlet
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | <div #footer> <div .logo> <p>LOGO <ul .navbar-nav .list-inline> $forall n <- [1,2,3,4,5] $if n == 1 <li .first> <a href=@{HomeR}>MENU#{show n} $else <li> <a href=@{HomeR}>MENU#{show n} <div .clearfix> <ul .sns-icon .list-inline> <li><i .fa .fa-twitter .fa-2x> <li><i .fa .fa-facebook .fa-2x> <div .copy> <span>#{appCopyright $ appSettings master} | cs |
Scaffolded 사이트의 카피라이트는 #{appCopyright $ appSettings master}
에서 기술하거나、config/Settings
에서 카피라이트의 문자열을 설정할 수 있습니다.
config/settings.yaml
1 2 3 4 5 6 7 8 9 10 | # Values formatted like "_env:ENV_VAR_NAME:default_value" can be overridden by the specified environment variable. # See https://github.com/yesodweb/yesod/wiki/Configuration#overriding-configuration-values-with-environment-variables static-dir: "_env:STATIC_DIR:static" host: "_env:HOST:*4" # any IPv4 host port: "_env:PORT:3000" # NB: The port `yesod devel` uses is distinct from this value. Set the `yesod devel` port from the command line. ip-from-header: "_env:IP_FROM_HEADER:false" copyright: Insert copyright statement here #analytics: UA-YOURCODE | cs |
生成されるHTML
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | <div id="footer"><div class="logo"><p>LOGO</p> </div> <ul class="navbar-nav list-inline"><li class="first"><a href="http://***.**.**.***:3000/">MENU1</a> </li> <li><a href="http://***.**.**.***:3000/">MENU2</a> </li> <li><a href="http://***.**.**.***:3000/">MENU3</a> </li> <li><a href="http://***.**.**.***:3000/">MENU4</a> </li> <li><a href="http://***.**.**.***:3000/">MENU5</a> </li> </ul> <div class="clearfix"></div> <ul class="sns-icon list-inline"><li><i .fa .fa-twitter .fa-2x></li> <li><i .fa .fa-facebook .fa-2x></li> </ul> <div class="copy"><span>Insert copyright statement here</span> </div> </div> </body> </html> | cs |
여기에서 HTML이 기대한대로 생성되지 않은 점에 주의하세요. 이는 Hamlet
의 들여쓰기 태그가 통상의 텍스트 값대로 처리되지 않았기 때문에 이런 결과가 생기게 된 것입니다. 들여쓰기 태그를 기술하는 실제에서 id
와 class
가 포함되는 경우에는 적절히 들여쓰기를 할 필요가 있습니다. 마지막으로
font-awesome
을 사용하기 위해 Foundation.hs
파일을 아래와 같이 약간 수정합니다.
1 2 3 4 5 | pc <- widgetToPageContent $ do addStylesheet $ StaticR css_bootstrap_css addStylesheetRemote "https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" $(widgetFile "default-layout") withUrlRenderer $(hamletFile "templates/default-layout-wrapper.hamlet") | cs |
Footer 영역의 CSS 정의
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 | #footer text-align: center padding: 40px 0 background-color: #7E7E7E #footer .logo margin-top: 0 .logo p margin: 0 padding-top: 34px width: 100px height: 100px -webkit-border-radius: 50% -moz-border-radius: 50% border-radius: 50% font-size: 26px color: #7E7E7E font-weight: bold background-color: #fff #footer .navbar-nav > li border-right: 1px solid #000 #footer .navbar-nav > li.first border-left: 1px solid #000 #footer .navbar-nav li a color: #fff .sns-icon margin-top: 30px .sns-icon li padding-left: 20px .sns-icon li:hover color: #fff .copy margin-top: 20px .copy span color: #333 | cs |
지금까지의 소스코드
1 2 3 4 5 6 7 | $doctype 5 <html> <head> <title>#{pageTitle pc} ^{pageHead pc} <body> ^{pageBody pc} | cs |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 | <div #header> <div .logo> <h1>LOGO <nav .navbar .navbar-default role="navigation"> <div .container> <div .navbar-header> <button .navbar-toggle data-toggle="collapse" data-target="#bs-example-navbar-collapse-1"> <span .sr-only>Toggle navigation <span .icon-bar> <span .icon-bar> <span .icon-bar> <span .navbar-brand .visible-xs href="@{HomeR}">Menu <div .collapse .navbar-collapse #bs-example-navbar-collapse-1> <ul .nav .navbar-nav> $forall n <- [1,2,3,4,5] $if n == 1 <li .first> <a href=@{HomeR}>MENU#{show n} $else <li> <a href=@{HomeR}>MENU#{show n} <div #footer> <div .logo> <p>LOGO <ul .navbar-nav .list-inline> $forall n <- [1,2,3,4,5] $if n == 1 <li .first> <a href=@{HomeR}>MENU#{show n} $else <li> <a href=@{HomeR}>MENU#{show n} <div .clearfix> <ul .sns-icon .list-inline> <li> <i .fa .fa-twitter .fa-2x> <li> <i .fa .fa-facebook .fa-2x> <div .copy> <span>#{appCopyright $ appSettings master} | cs |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 | #header padding-top: 20px background-color: #eee .logo text-align: center width: 100px margin-left: auto margin-right: auto margin-bottom: 20px .logo h1 margin: 0 padding-top: 38px width: 100px height: 100px -webkit-border-radius: 50% -moz-border-radius: 50% border-radius: 50% font-size: 26px color: #7E7E7E background-color: #fff .navbar border-radius: 0 .navbar-nav float: none width: 400px margin: 10px auto .navbar-nav > li text-align: center float: left width: 80px border-right: 1px solid #555 .navbar-nav > li.first border-left: 1px solid #555 .navbar-nav > li > a padding-top: 5px padding-bottom: 5px #footer text-align: center padding: 40px 0 background-color: #7E7E7E #footer .logo margin-top: 0 .logo p margin: 0 padding-top: 34px width: 100px height: 100px -webkit-border-radius: 50% -moz-border-radius: 50% border-radius: 50% font-size: 26px color: #7E7E7E font-weight: bold background-color: #fff #footer .navbar-nav > li border-right: 1px solid #000 #footer .navbar-nav > li.first border-left: 1px solid #000 #footer .navbar-nav li a color: #fff .sns-icon margin-top: 30px .sns-icon li padding-left: 20px .sns-icon li:hover color: #fff .copy margin-top: 20px .copy span color: #333 | cs |
1 | -- 빈 파일 | cs |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | # Values formatted like "_env:ENV_VAR_NAME:default_value" can be overridden by the specified environment variable. # See https://github.com/yesodweb/yesod/wiki/Configuration#overriding-configuration-values-with-environment-variables static-dir: "_env:STATIC_DIR:static" host: "_env:HOST:*4" # any IPv4 host port: "_env:PORT:3000" # NB: The port `yesod devel` uses is distinct from this value. Set the `yesod devel` port from the command line. ip-from-header: "_env:IP_FROM_HEADER:false" # Default behavior: determine the application root from the request headers. # Uncomment to set an explicit approot #approot: "_env:APPROOT:http://localhost:3000" # Optional values with the following production defaults. # In development, they default to the inverse. # # detailed-logging: false # should-log-all: false # reload-templates: false # mutable-static: false # skip-combining: false # auth-dummy-login : false # NB: If you need a numeric value (e.g. 123) to parse as a String, wrap it in single quotes (e.g. "_env:PGPASS:'123'") # See https://github.com/yesodweb/yesod/wiki/Configuration#parsing-numeric-values-as-strings copyright: Copyright © 2018 Haskell Core Developer Group.com All Rights Reserved. #analytics: UA-YOURCODE | cs |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 | {-# LANGUAGE NoImplicitPrelude #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE MultiParamTypeClasses #-} {-# LANGUAGE TypeFamilies #-} {-# LANGUAGE ViewPatterns #-} {-# LANGUAGE ExplicitForAll #-} {-# LANGUAGE RankNTypes #-} module Foundation where import Import.NoFoundation import Text.Hamlet (hamletFile) import Text.Jasmine (minifym) import Yesod.Core.Types (Logger) import Yesod.Default.Util (addStaticContentExternal) import qualified Yesod.Core.Unsafe as Unsafe import qualified Data.CaseInsensitive as CI import qualified Data.Text.Encoding as TE -- | The foundation datatype for your application. This can be a good place to -- keep settings and values requiring initialization before your application -- starts running, such as database connections. Every handler will have -- access to the data present here. data App = App { appSettings :: AppSettings , appStatic :: Static -- ^ Settings for static file serving. , appHttpManager :: Manager , appLogger :: Logger } data MenuItem = MenuItem { menuItemLabel :: Text , menuItemRoute :: Route App , menuItemAccessCallback :: Bool } data MenuTypes = NavbarLeft MenuItem | NavbarRight MenuItem -- This is where we define all of the routes in our application. For a full -- explanation of the syntax, please see: -- http://www.yesodweb.com/book/routing-and-handlers -- -- Note that this is really half the story; in Application.hs, mkYesodDispatch -- generates the rest of the code. Please see the following documentation -- for an explanation for this split: -- http://www.yesodweb.com/book/scaffolding-and-the-site-template#scaffolding-and-the-site-template_foundation_and_application_modules -- -- This function also generates the following type synonyms: -- type Handler = HandlerT App IO -- type Widget = WidgetT App IO () mkYesodData "App" $(parseRoutesFile "config/routes") -- | A convenient synonym for creating forms. type Form x = Html -> MForm (HandlerT App IO) (FormResult x, Widget) -- Please see the documentation for the Yesod typeclass. There are a number -- of settings which can be configured by overriding methods here. instance Yesod App where -- Controls the base of generated URLs. For more information on modifying, -- see: https://github.com/yesodweb/yesod/wiki/Overriding-approot approot = ApprootRequest $ \app req -> case appRoot $ appSettings app of Nothing -> getApprootText guessApproot app req Just root -> root -- Store session data on the client in encrypted cookies, -- default session idle timeout is 120 minutes makeSessionBackend _ = Just <$> defaultClientSessionBackend 120 -- timeout in minutes "config/client_session_key.aes" -- Yesod Middleware allows you to run code before and after each handler function. -- The defaultYesodMiddleware adds the response header "Vary: Accept, Accept-Language" and performs authorization checks. -- Some users may also want to add the defaultCsrfMiddleware, which: -- a) Sets a cookie with a CSRF token in it. -- b) Validates that incoming write requests include that token in either a header or POST parameter. -- To add it, chain it together with the defaultMiddleware: yesodMiddleware = defaultYesodMiddleware . defaultCsrfMiddleware -- For details, see the CSRF documentation in the Yesod.Core.Handler module of the yesod-core package. yesodMiddleware = defaultYesodMiddleware defaultLayout widget = do master <- getYesod mmsg <- getMessage mcurrentRoute <- getCurrentRoute -- Get the breadcrumbs, as defined in the YesodBreadcrumbs instance. (title, parents) <- breadcrumbs -- Define the menu items of the header. let menuItems = [ NavbarLeft $ MenuItem { menuItemLabel = "Home" , menuItemRoute = HomeR , menuItemAccessCallback = True } ] let navbarLeftMenuItems = [x | NavbarLeft x <- menuItems] let navbarRightMenuItems = [x | NavbarRight x <- menuItems] let navbarLeftFilteredMenuItems = [x | x <- navbarLeftMenuItems, menuItemAccessCallback x] let navbarRightFilteredMenuItems = [x | x <- navbarRightMenuItems, menuItemAccessCallback x] -- We break up the default layout into two components: -- default-layout is the contents of the body tag, and -- default-layout-wrapper is the entire page. Since the final -- value passed to hamletToRepHtml cannot be a widget, this allows -- you to use normal widget features in default-layout. pc <- widgetToPageContent $ do addStylesheet $ StaticR css_bootstrap_css addStylesheetRemote "https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" $(widgetFile "default-layout") withUrlRenderer $(hamletFile "templates/default-layout-wrapper.hamlet") -- Routes not requiring authenitcation. isAuthorized FaviconR _ = return Authorized isAuthorized RobotsR _ = return Authorized -- Default to Authorized for now. isAuthorized _ _ = return Authorized -- This function creates static content files in the static folder -- and names them based on a hash of their content. This allows -- expiration dates to be set far in the future without worry of -- users receiving stale content. addStaticContent ext mime content = do master <- getYesod let staticDir = appStaticDir $ appSettings master addStaticContentExternal minifym genFileName staticDir (StaticR . flip StaticRoute []) ext mime content where -- Generate a unique filename based on the content itself genFileName lbs = "autogen-" ++ base64md5 lbs -- What messages should be logged. The following includes all messages when -- in development, and warnings and errors in production. shouldLog app _source level = appShouldLogAll (appSettings app) || level == LevelWarn || level == LevelError makeLogger = return . appLogger -- Define breadcrumbs. instance YesodBreadcrumbs App where breadcrumb HomeR = return ("Home", Nothing) breadcrumb _ = return ("home", Nothing) -- This instance is required to use forms. You can modify renderMessage to -- achieve customized and internationalized form validation messages. instance RenderMessage App FormMessage where renderMessage _ _ = defaultFormMessage -- Useful when writing code that is re-usable outside of the Handler context. -- An example is background jobs that send email. -- This can also be useful for writing code that works across multiple Yesod applications. instance HasHttpManager App where getHttpManager = appHttpManager unsafeHandler :: App -> Handler a -> IO a unsafeHandler = Unsafe.fakeHandlerGetLogger appLogger -- Note: Some functionality previously present in the scaffolding has been -- moved to documentation in the Wiki. Following are some hopefully helpful -- links: -- -- https://github.com/yesodweb/yesod/wiki/Sending-email -- https://github.com/yesodweb/yesod/wiki/Serve-static-files-from-a-separate-domain -- https://github.com/yesodweb/yesod/wiki/i18n-messages-in-the-scaffolding | cs |
'프로그래밍 Programming' 카테고리의 다른 글
cabal install 사용방법 (1) How to cabal install (1) (0) | 2018.02.06 |
---|---|
Haskell Yesod의 템플릿 언어 세익스피어를 활용하여 웹페이지 만들기 (2) (0) | 2018.02.03 |
Yesod의 위젯에 대해서 (1) (0) | 2018.01.27 |
하스켈 익스텐션 사용법 How to Enable Extensions (0) | 2018.01.26 |
하스켈 Yesod 튜토리얼 - 페이지 추가하기 Minimal echo application (0) | 2018.01.19 |