Разработка Web-приложений и микросервисов на Go с Gin

February 2017

Разработка: Разработка Web-приложений и микросервисов на Golang с Gin

Вве­де­ние

Се­год­ня мы по­ка­жем, как со­зда­вать веб-при­ло­же­ния и мик­ро­сер­ви­сы в Go с по­мо­щью фрейм­вор­ка Gin. Gin это фрейм­ворк, поз­во­ля­ю­щий умень­шить объ­ём ко­да, необ­хо­ди­мо­го для по­стро­е­ния та­ких при­ло­же­ний. Он по­ощ­ря­ет со­зда­ние мно­го­крат­но-ис­поль­зу­е­мо­го и рас­ши­ря­е­мо­го ко­да.

Мы рас­смот­рим со­зда­ние про­ек­та и сбор­ку неслож­но­го при­ло­же­ния с Gin, ко­то­рое бу­дет вы­во­дить спи­сок то­пи­ков и от­дель­ный то­пик.

Под­го­тов­ка

Пе­ред на­ча­лом ра­бо­ты убе­ди­тесь, что у вас уста­нов­ле­ны Go и ути­ли­та curl. Ес­ли curl не уста­нов­ле­на и вы не хо­ти­те ра­бо­тать с ней, ис­поль­зуй­те лю­бую дру­гую ути­ли­ту те­сти­ро­ва­ния API.

Что та­кое Gin?

Gin это вы­со­ко­про­из­во­ди­тель­ный мик­рофрейм­ворк, ко­то­рый ис­поль­зу­ет­ся для со­зда­ния веб-при­ло­же­ний и мик­ро­сер­ви­сов. С ним очень удоб­но де­лать ком­плекс­ную кон­вей­ер­ную об­ра­бот­ку за­про­сов из мо­ду­лей — мно­го­крат­но ис­поль­зу­е­мых ку­соч­ков ко­да. Вы пи­ше­те про­ме­жу­точ­ный слой при­ло­же­ния, ко­то­рый за­тем под­клю­ча­ет­ся в один или бо­лее об­ра­бот­чик за­про­сов или в груп­пу об­ра­бот­чи­ков.

По­че­му имен­но Gin?

Од­но из луч­ших ка­честв Go — его встро­ен­ная биб­лио­те­ка net/http, поз­во­ля­ю­щая с лёг­ко­стью со­зда­вать HTTP сер­вер. Од­на­ко, она не на­столь­ко гиб­кая, как бы хо­те­лось, и ко­ли­че­ство ко­да, тре­бу­е­мое при ра­бо­те с ней, до­воль­но боль­шое.

В Go нет встро­ен­ной под­держ­ки об­ра­бот­чи­ка ро­у­тов на ба­зе ре­гу­ляр­ных вы­ра­же­ний. Вам нуж­но пи­сать код для по­лу­че­ния это­го функ­ци­о­на­ла. Од­на­ко, с ро­стом ко­ли­че­ства ва­ших при­ло­же­ний, вы бу­де­те вы­нуж­де­ны ко­пи­ро­вать один и тот же код вез­де или всё-та­ки со­зда­ди­те биб­лио­те­ку.

В этом и есть за­да­ча Gin. Он со­дер­жит на­бор ча­сто упо­треб­ля­е­мых функ­ций, та­ких как ро­утинг, под­держ­ка middleware, об­ра­бот­ка шаб­ло­нов. Вдо­ба­вок к это­му, он поз­во­ля­ет умень­шить ко­ли­че­ство ко­да в при­ло­же­ни­ях и со­зда­ние веб-при­ло­же­ний с ним на­мно­го про­ще.

Про­ек­ти­ро­ва­ние при­ло­же­ния

По­смот­рим, как Gin об­ра­ба­ты­ва­ет за­про­сы:

Request -> Route Parser -> [Optional Middleware] -> Route Handler -> [Optional Middleware] -> Response

Ко­гда при­хо­дит за­прос, Gin сна­ча­ла про­ве­ря­ет, есть ли под­хо­дя­щий ро­ут (марш­рут). Ес­ли со­от­вет­ству­ю­щий ро­ут най­ден, Gin за­пус­ка­ет об­ра­бот­чик это­го ро­у­та и про­ме­жу­точ­ные зве­нья в за­дан­ном по­ряд­ке. Мы уви­дим как это про­ис­хо­дит, ко­гда пе­рей­дём к ко­ду в сле­ду­ю­щем раз­де­ле.

Функ­ци­о­нал при­ло­же­ния

На­ше при­ло­же­ние — это про­стой ме­не­джер то­пи­ков. Оно долж­но:

  • поз­во­лять поль­зо­ва­те­лям ре­ги­стри­ро­вать­ся с ло­ги­ном и па­ро­лем (для неав­то­ри­зо­ван­ных поль­зо­ва­те­лей),
  • поз­во­лять поль­зо­ва­те­лям ав­то­ри­зо­вать­ся (для неав­то­ри­зо­ван­ных поль­зо­ва­те­лей),
  • поз­во­лять поль­зо­ва­те­лям за­вер­шать се­анс (для ав­то­ри­зо­ван­ных поль­зо­ва­те­лей),
  • поз­во­лять поль­зо­ва­те­лям со­зда­вать то­пи­ки (для ав­то­ри­зо­ван­ных поль­зо­ва­те­лей),
  • Вы­во­дить спи­сок всех то­пи­ков на глав­ной стра­ни­це (для всех поль­зо­ва­те­лей), и
  • Вы­во­дить то­пик на его соб­ствен­ной стра­ни­це (для всех поль­зо­ва­те­лей).
    Вдо­ба­вок к это­му мы сде­ла­ем, что­бы спи­сок то­пи­ков и от­дель­ные то­пи­ки бы­ли до­ступ­ны в фор­ма­тах HTML, JSON и XML.

Это поз­во­лит нам про­ил­лю­стри­ро­вать, как мож­но ис­поль­зо­вать Gin для про­ек­ти­ро­ва­ния веб-при­ло­же­ний, API сер­ве­ров и мик­ро­сер­ви­сов.

Для это­го мы ис­поль­зу­ем сле­ду­ю­щий функ­ци­о­нал Gin:

  • Routing — для об­ра­бот­ки раз­лич­ных URL ад­ре­сов,
  • Custom rendering — для об­ра­бот­ки фор­ма­та от­ве­та, и
  • Middleware — для ре­а­ли­за­ции ав­то­ри­за­ции.
    Та­к­же мы на­пи­шем те­сты для про­вер­ки ра­бо­то­спо­соб­но­сти на­ше­го функ­ци­о­на­ла.
Routing

Ро­утинг (марш­ру­ти­за­ция) это од­на из важ­ней­ших функ­ций, име­ю­щих­ся во всех со­вре­мен­ных веб-фрейм­вор­ках. Лю­бая веб-стра­ни­ца или вы­зов API до­сту­пен по URL. Фрейм­вор­ки ис­поль­зу­ют ро­уты для об­ра­бот­ки за­про­сов к этим URL-ад­ре­сам. Ес­ли URL та­кой: httр://​www.​example.​com/​some/​random/​route, то ро­ут бу­дет: /some/random/route.

У Gin очень быст­рый ро­у­тер, удоб­ный в кон­фи­гу­ри­ро­ва­нии и ра­бо­те. Вме­сте с об­ра­бот­кой опре­де­лен­ных URL-ад­ре­сов, ро­у­тер в Gin мо­жет об­ра­ба­ты­вать шаб­ло­ны ад­ре­сов и груп­пы URL.

В на­шем при­ло­же­нии мы бу­дем:

  • Хра­нить глав­ную стра­ни­цу в ро­уте / (за­прос HTTP GET),
  • Груп­пи­ро­вать ро­уты, от­но­ся­щи­е­ся к поль­зо­ва­те­лям, в ро­уте /u ,

  • Хра­нить стра­ни­цу ав­то­ри­за­ции в /u/login (за­прос HTTP GET),

  • Пе­ре­да­вать дан­ные ав­то­ри­за­ции в /u/login (за­прос HTTP POST),

  • За­вер­ше­ние се­ан­са в /u/logout (за­прос HTTP GET),

  • Хра­нить стра­ни­цу ре­ги­стра­ции в /u/register (за­прос HTTP GET),

  • Пе­ре­да­вать ре­ги­стра­ци­он­ную ин­фор­ма­цию в /u/register (за­прос HTTP POST) ,

  • Груп­пи­ро­вать ро­уты, от­но­ся­щи­е­ся к то­пи­кам, в ро­уте /article,

  • Хра­нить стра­ни­цу со­зда­ния то­пи­ка в /article/create (за­прос HTTP GET),

  • Пе­ре­да­вать утвер­ждён­ный то­пик в /article/create (за­прос HTTP POST), и

  • Хра­нить стра­ни­цу то­пи­ка в /article/view/:article_id (за­прос HTTP GET). Об­ра­ти­те вни­ма­ние на часть :article_id в этом ро­уте. Двое­то­чие : в на­ча­ле ука­зы­ва­ет на то, что это ди­на­ми­че­ский ро­ут. Это зна­чит, что :article_id мо­жет со­дер­жать лю­бое зна­че­ние и Gin сде­ла­ет это зна­че­ние до­ступ­ным в об­ра­бот­чи­ке за­про­са.

Rendering

Веб-при­ло­же­ние мо­жет вы­ве­сти от­вет в раз­лич­ных фор­ма­тах, та­ких как HTML, текст, JSON, XML или дру­гие фор­ма­ты. API и мик­ро­сер­ви­сы обыч­но от­да­ют дан­ные в фор­ма­те JSON, но здесь та­к­же нет огра­ни­че­ний.

В сле­ду­ю­щем раз­де­ле мы уви­дим, как мож­но об­ра­бо­тать раз­ные ти­пы от­ве­тов без дуб­ли­ро­ва­ния функ­ци­о­на­ла. По-умол­ча­нию мы бу­дем от­ве­чать на за­прос шаб­ло­ном HTML. Од­на­ко, мы со­зда­дим ещё два ви­да за­про­са, ко­то­рые бу­дут от­ве­чать в фор­ма­те JSON или XML.

Middleware

В кон­тек­сте веб-при­ло­же­ний на Go, middleware это часть ко­да, ко­то­рую мож­но вы­пол­нить на лю­бом эта­пе об­ра­бот­ки HTTP-за­про­са. Обыч­но их ис­поль­зу­ют для ин­кап­су­ля­ции ти­по­во­го функ­ци­о­на­ла, ко­то­рый вам нуж­но вы­зы­вать из раз­лич­ных ро­у­тов. Мы мо­жем ис­поль­зо­вать middleware пе­ред и/или по­сле об­ра­бо­тан­но­го HTTP-за­про­са. К ти­по­вым при­ме­рам при­ме­не­ния middleware от­но­сят­ся ав­то­ри­за­ция, ва­ли­да­ция и т.п.

Ес­ли middleware ис­поль­зу­ет­ся пе­ред об­ра­бот­кой ро­у­та, лю­бые из­ме­не­ния, сде­лан­ные им, бу­дут до­ступ­ны в глав­ном об­ра­бот­чи­ке за­про­сов. Это удоб­но, ес­ли мы хо­тим ре­а­ли­зо­вать про­вер­ку опре­де­лён­ных за­про­сов. С дру­гой сто­ро­ны, ес­ли middleware ис­поль­зу­ет­ся по­сле об­ра­бот­чи­ка, он по­лу­чит от­вет из об­ра­бот­чи­ка ро­у­тов. Это мож­но ис­поль­зо­вать для мо­ди­фи­ка­ции от­ве­та из об­ра­бот­чи­ка ро­у­та.

Мы долж­ны быть уве­ре­ны, что неко­то­рые стра­ни­цы и дей­ствия, к при­ме­ру, со­зда­ние то­пи­ка, за­вер­ше­ние се­ан­са, до­ступ­ны толь­ко ав­то­ри­зо­ван­ным поль­зо­ва­те­лям. И та­к­же необ­хо­ди­мо, что­бы неко­то­рые стра­ни­цы и дей­ствия, к при­ме­ру, ре­ги­стра­ция, ав­то­ри­за­ция, бы­ли до­ступ­ны толь­ко неав­то­ри­зо­ван­ным поль­зо­ва­те­лям.

Ес­ли мы вклю­чим со­от­вет­ству­ю­щую ло­ги­ку в каж­дый ро­ут, это бу­дет слож­но, из­лишне по­вто­ря­е­мо и склон­но к ошиб­кам. К сча­стью, мы мо­жем со­здать middleware для каж­дой из этих за­дач и мно­го­крат­но ис­поль­зо­вать их в со­от­вет­ству­ю­щих ро­у­тах.

Мы со­зда­дим middleware, ко­то­рое бу­дет при­ме­ни­мо ко всем ро­у­там. На­ше middleware (setUserStatus) бу­дет про­ве­рять — от ав­то­ри­зо­ван­но­го поль­зо­ва­те­ля при­шёл за­прос или от неав­то­ри­зо­ван­но­го. За­тем оно уста­но­вит флаг, ко­то­рый мож­но бу­дет ис­поль­зо­вать в шаб­ло­нах для на­строй­ки ви­ди­мо­сти опре­де­лён­ных ссы­лок в ме­ню при­ло­же­ния.

Уста­нов­ка за­ви­си­мо­стей

На­ше при­ло­же­ние бу­дет ис­поль­зо­вать толь­ко од­ну внеш­нюю за­ви­си­мость — сам фрейм­ворк Gin. Уста­но­вим ак­ту­аль­ную вер­сию та­кой ко­ман­дой:

go get -u github.com/gin-gonic/gin
Со­зда­ние мно­го­крат­но-ис­поль­зу­е­мых шаб­ло­нов

На­ше при­ло­же­ние бу­дет отоб­ра­жать веб-стра­ни­цу, ис­поль­зуя её шаб­лон. Од­на­ко, в ней бу­дет несколь­ко ча­стей, та­ких как шап­ка (header), ме­ню, бо­ко­вая па­нель и под­вал (footer), ко­то­рые бу­дут пред­став­ле­ны на всех стра­ни­цах. В Go мож­но со­зда­вать шаб­лон­ные сни­пе­ты, ко­то­рые мож­но бу­дет за­гру­жать в лю­бые шаб­ло­ны.

Мы со­зда­дим сни­пе­ты для шап­ки и под­ва­ла, та­к­же со­зда­дим ме­ню в со­от­вет­ству­ю­щем фай­ле-шаб­лоне, ко­то­рое за­тем вы­зо­вем из шап­ки. Ну и на­ко­нец, мы со­зда­дим шаб­лон глав­ной стра­ни­цы, с ко­то­рой вы­зо­вем шап­ку и под­вал. Все фай­лы шаб­ло­нов бу­дут раз­ме­щать­ся в пап­ке templates на­ше­го про­ек­та.

Сна­ча­ла со­здай­те шаб­лон ме­ню в фай­ле templates/menu.html как опи­са­но ни­же:

<!--menu.html-->

<nav class="navbar navbar-default">
  <div class="container">
    <div class="navbar-header">
      <a class="navbar-brand" href="/">
        Home
      </a>
    </div>
  </div>
</nav>

По­ка в на­шем ме­ню есть толь­ко од­на ссыл­ка на глав­ную стра­ни­цу. Поз­же мы до­ба­вим осталь­ные ссыл­ки по ме­ре ро­ста функ­ци­о­на­ла при­ло­же­ния. Шаб­лон шап­ки бу­дет в фай­ле templates/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" . }}

Как вы ви­ди­те, мы ис­поль­зу­ем здесь фрейм­ворк с от­кры­тым ис­ход­ным ко­дом Bootstrap. Боль­шая часть фай­ла это стан­дарт­ный HTML. Од­на­ко, по­смот­рим вни­ма­тель­но на па­ру строк. В стро­ке с {{ .title }} ди­на­ми­че­ски за­да­ёт­ся за­го­ло­вок стра­ни­цы с по­мо­щью пе­ре­мен­ной .title, ко­то­рая долж­на быть опре­де­ле­на в при­ло­же­нии. А в стро­ке {{ template «menu.html». }} мы за­гру­жа­ем шаб­лон ме­ню из фай­ла menu.html. Вот так в Go мож­но вы­зы­вать один шаб­лон из дру­го­го.

Шаб­лон под­ва­ла со­дер­жит толь­ко ста­ти­че­ский HTML. Шаб­лон глав­ной стра­ни­цы вы­зы­ва­ет шап­ку и под­вал и вы­во­дит со­об­ще­ние Hello Gin:

<!--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" .}}

По ана­ло­гии с шаб­ло­ном глав­ной, в шаб­ло­нах дру­гих стра­ниц мы та­к­же ис­поль­зу­ем эти шап­ку и под­вал.

За­вер­ше­ние и про­вер­ка уста­нов­ки

Со­здав шаб­ло­ны, те­перь са­мое вре­мя со­здать глав­ный файл при­ло­же­ния. Мы со­зда­дим файл main.go, в нём бу­дет про­стое веб-при­ло­же­ние, за­гру­жа­ю­щее глав­ную стра­ни­цу. С Gin это де­ла­ет­ся в че­ты­ре ша­га:

1. Со­зда­ём ро­у­тер

Ро­у­тер в Gin со­зда­ёт­ся так:

router := gin.Default()

2. За­гру­жа­ем шаб­ло­ны

По­сле со­зда­ния ро­у­те­ра, за­гру­зим все шаб­ло­ны:

router.LoadHTMLGlob("templates/*")

Это за­гру­зит все шаб­ло­ны из пап­ки templates. За­гру­зив один раз шаб­ло­ны, боль­ше не бу­дет необ­хо­ди­мо­сти пе­ре­чи­ты­вать их, что де­ла­ет веб-при­ло­же­ния с Gin очень быст­ры­ми.

3. За­да­ём об­ра­бот­чик ро­у­тов

Очень важ­но пра­виль­но спро­ек­ти­ро­вать при­ло­же­ние, раз­де­лив на со­от­вет­ству­ю­щие ро­уты и за­дав об­ра­бот­чи­ки для каж­до­го из них. Мы со­зда­дим ро­ут для глав­ной стра­ни­цы и его об­ра­бот­чик.

router.GET("/", func(c *gin.Context) {

  // 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 (in this case, 'title')
      gin.H{
          "title": "Home Page",
      },
  )

})

С по­мо­щью ме­то­да router.GET мы за­да­ём об­ра­бот­чик ро­у­та для GET-за­про­сов. Он при­ни­ма­ет в ка­че­стве па­ра­мет­ров сам ро­ут (/) и один или несколь­ко об­ра­бот­чи­ков, ко­то­рые все­го лишь функ­ции.

Об­ра­бот­чик ро­у­та име­ет ука­за­тель на Кон­текст (gin.​Context) в па­ра­мет­рах. В этом кон­тек­сте со­дер­жит­ся вся ин­фор­ма­ция о за­про­се, ко­то­рая мо­жет по­на­до­бит­ся об­ра­бот­чи­ку в даль­ней­шем. К при­ме­ру, в нём есть ин­фор­ма­ция о за­го­лов­ках, cookies и т.д.

В Кон­тек­сте та­к­же есть ме­то­ды для вы­во­да от­ве­та в фор­ма­тах HTML, тек­сте, JSON и XML. В на­шем слу­чае мы взя­ли ме­тод context.HTML для об­ра­бот­ки HTML шаб­ло­на (index.html). Вы­зов это­го ме­то­да вклю­ча­ет до­пол­ни­тель­ные дан­ные, в ко­то­рых зна­че­ние title уста­нов­ле­но Home Page. Это зна­че­ние, ко­то­рое мо­жет быть об­ра­бо­та­но в HTML шаб­лоне. Мы ис­поль­зу­ем это зна­че­ние в те­ге в шаб­лоне шап­ки.

4. За­пуск при­ло­же­ния

Для за­пус­ка при­ло­же­ния вос­поль­зу­ем­ся ме­то­дом Run на­ше­го ро­у­те­ра:

router.Run()

При­ло­же­ние за­пу­стит­ся на localhost и 8080 пор­те, по-умол­ча­нию.

Фи­наль­ный файл main.go бу­дет та­ким:

// main.go

package main

import (
  "net/http"

  "github.com/gin-gonic/gin"
)

var router *gin.Engine

func main() {

  // Set the router as the default one provided by Gin
  router = gin.Default()

  // Process the templates at the start so that they don't have to be loaded
  // from the disk again. This makes serving HTML pages very fast.
  router.LoadHTMLGlob("templates/*")

  // Define the route for the index page and display the index.html template
  // To start with, we'll use an inline route handler. Later on, we'll create
  // standalone functions that will be used as route handlers.
  router.GET("/", func(c *gin.Context) {

    // 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 (in this case, 'title')
      gin.H{
        "title": "Home Page",
      },
    )

  })

  // Start serving the application
  router.Run()

}

Для за­пус­ка при­ло­же­ния из ко­манд­ной стро­ки, пе­рей­ди­те в пап­ку при­ло­же­ния и вы­пол­ни­те ко­ман­ду:

go build -o app

Бу­дет со­бра­но при­ло­же­ние и со­здан ис­пол­ня­е­мый файл с име­нем app, ко­то­рый мож­но за­пу­стить так:

./app

Ес­ли всё про­шло успеш­но, вы долж­ны уви­деть при­ло­же­ние по ад­ре­су http://localhost:8080 и оно бу­дет вы­гля­деть при­мер­но так:
Разработка: Разработка Web-приложений и микросервисов на Golang с Gin

На этом эта­пе иерар­хия па­пок при­ло­же­ния бу­дет та­кой:

├── main.go
└── templates
    ├── footer.html
    ├── header.html
    ├── index.html
    └── menu.html
Выводим список топиков

Сейчас мы добавим функционал для показа списка всех топиков на главной странице.

Настройка роута

В предыдущем разделе мы создали роут и его описание в файле main.go. С ростом размера приложения будет лучше перенести описания роутов в отдельный файл. Мы создадим функцию initializeRoutes() в файле routes.go и будем вызывать её из функции main() для настройки всех роутов. Вместо создания линейного обработчика роутов, мы сделаем роуты отдельными функциями.

После всего этого файл routes.go будет таким:

// routes.go

package main

func initializeRoutes() {

  // определение роута главной страницы
  router.GET("/", showIndexPage)
}

Так как мы выводим список топиков на главной странице, нам не нужно будет создавать больше никаких других роутов.

Файл main.go должен быть сейчас таким:

// main.go

package main

import "github.com/gin-gonic/gin"

var router *gin.Engine

func main() {

  // роутер по-умолчанию в Gin
  router = gin.Default()

  // Обработаем шаблоны вначале, так что их не нужно будет перечитывать
  // ещё раз. Из-за этого вывод HTML-страниц такой быстрый.
  router.LoadHTMLGlob("templates/*")

  // Инициализируем роуты
  initializeRoutes()

  // Запускаем приложение
  router.Run()

}
Проектирование модели топика

Сделаем структуру топика простой, всего с тремя полями — Id, Title (название) и Content (содержание). Её можно описать так:

type article struct {
  ID      int    `json:"id"`
  Title   string `json:"title"`
  Content string `json:"content"`
}

Большинство приложений используют базу данных для хранения данных. Чтобы не усложнять, мы будем хранить список топиков в памяти и заполнять его при создании двумя следующими топиками:

var articleList = []article{
  article{ID: 1, Title: "Article 1", Content: "Article 1 body"},
  article{ID: 2, Title: "Article 2", Content: "Article 2 body"},
}

Мы вставим этот кусок кода в новый файл models.article.go. Сейчас нам понадобится функция, возвращающая список всех топиков. Мы её назовём getAllArticles() и положим её в этот же файл. И создадим тест для неё. Мы назовём этот тест TestGetAllArticles и вставим его в файл models.article_test.go.

Создадим тест (TestGetAllArticles) для функции getAllArticles(). В результате файл models.article_test.go будет таким:

// models.article_test.go

package main

import "testing"

// Test the function that fetches all articles
func TestGetAllArticles(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 list
  if len(alist) != len(articleList) {
    t.Fail()
  }

  // Check that each member is identical
  for i, v := range alist {
    if v.Content != articleList[i].Content ||
      v.ID != articleList[i].ID ||
      v.Title != articleList[i].Title {

      t.Fail()
      break
    }
  }
}

В этом тесте используется функция getAllArticles() для получения списка всех топиков. Сперва этот тест проверяет, что эта функция получает список топиков и этот список идентичен списку, загруженному в глобальную переменную articleList. Затем он проходит в цикле по списку топиков для проверки уникальности каждого. Если хотя бы одна из этих проверок не удалась, тест возвращает неудачу.

После написания теста приступим к написанию кода модуля. Файл models.article.go будет содержать такой код:

// models.article.go

package 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 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
}
Создание шаблона представления

Так как список топиков будет выводится на главной странице, нам не нужно создавать новый шаблон. Однако, нам нужно изменить шаблон index.html для вывода в него списка топиков.

Предположим, что список топиков будет передан в шаблон в переменной payload. Тогда следующий снипет выведет список всех топиков:

{{range .payload }}
    <!--Create the link for the article based on its ID-->
    <a href="/article/view/{{.ID}}">
      <!--Display the title of the article -->
      <h2>{{.Title}}</h2>
    </a>
    <!--Display the content of the article-->
    <p>{{.Content}}</p>
  {{end}}

Этот снипет пройдётся по всем элементам переменной payload и выведет название и текст каждого топика. Также этот снипет добавит ссылку в каждый топик. Однако, пока мы ещё не создали обработчик соответствующего роута, и эти ссылки на топики не будут работать.

Обновлённый 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-->
    <a href="/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" .}}
Определяем требования к обработчику роута с помощью юнит-теста

Перед созданием обработчика роута главной страницы, мы создадим тест, чтобы определить поведение этого обработчика. Этот тест проверит следующие условия:

  1. Обработчик отвечает статус-кодом HTTP 200,
  2. Возвращаемый HTML содержит тег title с текстом «Home Page».
    Код теста поместим в функцию TestShowIndexPageUnauthenticated в файл handlers.article_test.go. Вспомогательные функции, используемые в этом тесте, мы разместим в файле common_test.go.

Вот содержимое файла handlers.article_test.go:

// handlers.article_test.go

package 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 user
func TestShowIndexPageUnauthenticated(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>") > 0

    return 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 functions
func TestMain(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 testing
func getRouter(withTemplates bool) *gin.Engine {
  r := gin.Default()
  if withTemplates {
    r.LoadHTMLGlob("templates/*")
  }
  return r
}

// Helper function to process a request and test its response
func testHTTPResponse(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 testing
func saveLists() {
  tmpArticleList = articleList
}

// This function is used to restore the main lists from the temporary one
func restoreLists() {
  articleList = tmpArticleList
}

Для написания теста мы написали несколько вспомогательных функций. Они в дальнейшем помогут нам уменьшить количество кода при написании похожих тестов.

Функция TestMain переводит Gin в тестовый режим и вызывает функции тестирования. Функция getRouter создаёт и возвращает роутер. Функция saveLists() помещает список топиков во временную переменную. Она используется в функции restoreLists() для восстановления списка топиков до первоначального состояния после выполнения юнит-теста.

И, наконец, функция testHTTPResponse выполняет переданную ей функцию для проверки — возвращает ли она булево значение true — показывая успешность теста, или нет. Эта функция помогает нам избежать дублирования кода, необходимого для тестирования ответа на HTTP-запрос.

Для проверки HTTP-кода и возвращаемого HTML, сделаем следующее:

  1. Создадим новый роутер,
  2. Определим роуту тот же обработчик, что используется в главном приложении (showIndexPage),
  3. Создадим новый запрос для доступа к роуту,
  4. Создадим функцию, обрабатывающую ответ, для тестирования HTTP-кода и HTML, и
  5. Вызовем testHTTPResponse() из новой функции для завершения теста.
Создание обработчика роута

Мы будет создавать все обработчики роутов, относящихся к топикам, в файле handlers.article.go. Обработчик главной страницы, showIndexPage выполняет следующие задачи:

1. Получает список топиков

Это делается с помощью функции getAllArticles, созданной ранее:

articles := getAllArticles()

2. Обрабатывает шаблон index.html, передавая ему список топиков

Это делается с помощью кода ниже:

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,
    },
)

Разница с кодом из предыдущего раздела только в том, что мы передаём список топиков, который можно прочитать в шаблоне в переменной payload.

Файл handlers.article.go должен быть таким:

// handlers.article.go

package main

import (
  "net/http"

  "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,
    },
  )

}

Если сейчас собрать и запустить приложение, открыть в браузере http://localhost:8080, оно будет выглядеть так:

Разработка: Разработка Web-приложений и микросервисов на Golang с Gin

Новые файлы, добавленные в этом разделе:

├── common_test.go
├── handlers.article.go
├── handlers.article_test.go
├── models.article.go
├── models.article_test.go
└── routes.go
Вывод топика

У нас пока не работают ссылки на топики из общего списка. Сейчас мы добавим обработчики клика и шаблон для вывода топика.

Настройка роутов

Мы можем создать роут для обработки запросов для топика подобно роуту из предыдущей части. Однако, мы должны учитывать, что хотя обработчик для всех топиков будет один, URL каждого топика должен быть уникальным. Gin позволяет это сделать с помощью передачи параметров в роут:

router.GET("/article/view/:article_id", getArticle)

Этот роут будет обрабатывать соответствующие указанному пути запросы, а также хранить значение параметра, переданного в роут — article_id, который мы обработаем в обработчике роута. Обработчиком нашего роута будет функция getArticle.

Изменённый файл routes.go:

// routes.go

package main

func initializeRoutes() {

  // обработчик главного роута
  router.GET("/", showIndexPage)

  // Обработчик GET-запросов на /article/view/некоторый_article_id
  router.GET("/article/view/:article_id", getArticle)

}
Шаблоны

Для вывода топика нам нужно создать новый шаблон templates/article.html. Он будет создан так же, как шаблон index.html, но с небольшим отличием: вместо передачи в него переменной со списком топиков, мы будем передавать в него только один топик.

Посмотреть код шаблона article.html можно на Github.

Определяем требования к обработчику роутов юнит-тестами

Тест обработчика будет проверять выполнение следующих условий:

  1. Обработчик отвечает статус-кодом HTTP 200,
  2. Возвращаемый HTML содержит тег title, содержащий название полученного топика.
    Код теста будет в функции TestArticleUnauthenticated в файле handlers.article_test.go. Вспомогательные функции мы разместим в файле common_test.go.
Создаём обработчик роута

Итак, что должен делать обработчик роута для топика — getArticle:

1. Получить ID топика для вывода

Для вывода нужного топика, мы должны получить его ID из контекста. Примерно так:

c.Param("article_id")

где c — это Контекст Gin, который передаётся параметром в любой обработчик при разработке с Gin.

2. Получить сам топик

Это можно сделать с помощью функции getArticleByID() из файла models.article.go:

article, err := getArticleByID(articleID)

Функция getArticleByID (в models.article.go) выглядит так:

func getArticleByID(id int) (*article, error) {
  for _, a := range articleList {
    if a.ID == id {
      return &a, nil
    }
  }
  return nil, errors.New("Article not found")
}

Эта функция считывает список топиков в цикле и возвращает топик, ID которого соответствует переданному ID. Если такого топика нет, она возвращает ошибку.

3. Обработать шаблон article.html, передав в него топик

Код ниже как раз делает это:

c.HTML(
    // Зададим HTTP статус 200 (OK)
    http.StatusOK,
    // Используем шаблон article.html
    "article.html",
    // Передадим данные в шаблон
    gin.H{
        "title":   article.Title,
        "payload": article,
    },
)

Обновлённый файл handlers.article.go будет таким:

// handlers.article.go

package main

import (
  "net/http"
  "strconv"

  "github.com/gin-gonic/gin"
)

func showIndexPage(c *gin.Context) {
  articles := getAllArticles()

  // Вызовем метод HTML из Контекста Gin для обработки шаблона
  c.HTML(
    // Зададим HTTP статус 200 (OK)
    http.StatusOK,
    // Используем шаблон index.html
    "index.html",
    // Передадим данные в шаблон
    gin.H{
      "title":   "Home Page",
      "payload": articles,
    },
  )

}

func getArticle(c *gin.Context) {
  // Проверим валидность ID
  if articleID, err := strconv.Atoi(c.Param("article_id")); err == nil {
    // Проверим существование топика
    if article, err := getArticleByID(articleID); err == nil {
      // Вызовем метод HTML из Контекста Gin для обработки шаблона
      c.HTML(
        // Зададим HTTP статус 200 (OK)
        http.StatusOK,
        // Используем шаблон index.html
        "article.html",
        // Передадим данные в шаблон
        gin.H{
          "title":   article.Title,
          "payload": article,
        },
      )

    } else {
      // Если топика нет, прервём с ошибкой
      c.AbortWithError(http.StatusNotFound, err)
    }

  } else {
    // При некорректном ID в URL, прервём с ошибкой
    c.AbortWithStatus(http.StatusNotFound)
  }
}

Если сейчас собрать и запустить наше приложение, при открытии localhost:8080/article/view/1 в браузере оно будет выглядеть так:

Разработка: Разработка Web-приложений и микросервисов на Golang с Gin

Новые файлы, добавленные в этом разделе:

└── templates
    └── article.html
Ответ в JSON/XML

В этом разделе мы немного перепишем приложение так, что оно, в зависимости от заголовков запроса, будет отвечать в формате HTML, JSON или XML.

Повторно используемые функции

До сих пор мы использовали метод HTML Контекста Gin для обработки шаблонов прямо из обработчика. Этот способ хорошо если мы всегда будем выводить только в формате HTML. Однако, если мы хотим менять формат ответа, к примеру, на основе какого-то параметра, мы должны переписать эту часть функции, чтобы она делала только валидацию данных и их получение, а выводом в шаблон будет заниматься другая функция в зависимости от формата вывода на основе заголовка Accept. Мы создадим эту функция в файле main.go и она будет общая для всех обработчиков.

В Gin в Контексте, переданном обработчику роута, есть поле Request. В этом поле есть Header, в котором содержатся все заголовки запроса. Для получения заголовка Accept мы можем использовать метод Get в Header, вот так:

// c - это Gin Context
c.Request.Header.Get("Accept")
  • Если заголовок: application/json, функция выводит JSON,
  • Если заголовок: application/xml, функция выводит XML, и
  • Если заголовок любой другой или вообще пустой, функция выводит HTML.
    Полный код функции:
// Render one of HTML, JSON or CSV based on the 'Accept' header of the request
// If the header doesn't specify this, HTML is rendered, provided that
// the template name is present
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)
  }

}
Изменяем требования к обработчику роутов

Так как мы теперь должны проверить ответ в JSON и XML если задан специальный заголовок, нам нужно добавить тесты в файл handlers.article_test.go для проверки этих условий:

  1. Проверить, что приложение вернёт список топиков в формате JSON если заголовок Accept равен application/json
  2. Проверить, что приложение вернёт список топиков в формате XML если заголовок Accept равен application/xml
    Мы добавим соответствующие функции TestArticleListJSON и TestArticleXML.
Обновляем обработчики

Обработчик у нас уже полностью готов, нам нужно только изменить метод обработки c.HTML на соответствующий требуемому формату метод вывода.

К примеру, обработчик роута showIndexPage будет изменён с такого:

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

Чтобы увидеть приложение в работе, соберём его и запустим. Затем выполним следующую команду:

curl -X GET -H "Accept: application/json" http://localhost:8080/

Она должна вернуть следующее:

[{"id":1,"title":"Article 1","content":"Article 1 body"},{"id":2,"title":"Article 2","content":"Article 2 body"}]

Как вы видите, мы получили ответ в формате JSON, передав заголовок Accept как application/json.

Список топиков в формате XML

Теперь запросим детали конкретной статьи в формате XML. Для этого запустите приложение как написано выше и затем выполните команду:

curl -X GET -H "Accept: application/xml" http://localhost:8080/article/view/1

В ответ должно прийти следующее:

<article><ID>1</ID><Title>Article 1</Title><Content>Article 1 body</Content></article>
Тестирование приложения

Мы использовали тесты для определения требований к обработчикам роутов и моделям, поэтому можем теперь запустить их и проверить, что всё работает как предполагалось. В директории проекта запустите следующую команду:

go test -v

Результат должен быть примерно таким:

=== RUN   TestShowIndexPageUnauthenticated
[GIN] 2016/06/14 - 19:07:26 | 200 |     183.315µs |  |   GET     /
--- PASS: TestShowIndexPageUnauthenticated (0.00s)
=== RUN   TestArticleUnauthenticated
[GIN] 2016/06/14 - 19:07:26 | 200 |     143.789µs |  |   GET     /article/view/1
--- PASS: TestArticleUnauthenticated (0.00s)
=== RUN   TestArticleListJSON
[GIN] 2016/06/14 - 19:07:26 | 200 |      51.087µs |  |   GET     /
--- PASS: TestArticleListJSON (0.00s)
=== RUN   TestArticleXML
[GIN] 2016/06/14 - 19:07:26 | 200 |      38.656µs |  |   GET     /article/view/1
--- PASS: TestArticleXML (0.00s)
=== RUN   TestGetAllArticles
--- PASS: TestGetAllArticles (0.00s)
=== RUN   TestGetArticleByID
--- PASS: TestGetArticleByID (0.00s)
PASS
ok    github.com/demo-apps/go-gin-app 0.084s

Как мы видим, эта команда запускает все написанные нами тесты и, в нашем случае, сообщает, что всё работает как положено. Если вы присмотритесь к выводу, то увидите, что Go также сделал и HTTP запросы для нас, проверив обработчики роутов.

Заключение

В этом цикле статей мы сделали приложение с помощью фреймворка Gin и постепенно добавили в него функционал. Мы написали тесты, чтобы наше приложение было надёжным, а также использовали методологию повторно используемого кода, чтобы сделать вывод в различные форматы без больших затрат времени.

Код приложения можно скачать в этом Github репозитории.

Gin очень прост для того, чтобы начать писать веб-приложения — вкупе со встроенной функциональностью Go, он легко позволяет строить высококачественные, хорошо покрытые тестами веб-приложения и микросервисы.

По материалам Building Go Web Applications and Microservices Using Gin

comments powered by Disqus