В этой статье я рассмотрю разработку веб-приложения на Go. Материал не содержит принципиально новых знаний и рассчитан скорее для таких же новоиспеченных исследователей языка как и я. Хотя, надеюсь, какие-то свежие идеи вы все-таки для себя найдете.

У некоторых читателей могут возникнуть вопросы о «велосипедостроении» — это всё плоды любопытства и живого интереса при ознакомлении с языком Golang.

Администрирование системы и разработка проекта


Лишь мельком обозначу этот пункт, чтобы по кусочкам иметь представление о единой системе. В конечном счете CI-сервер собирает проект из git-репозитория и формирует полноценный rpm-пакет для нужной архитектуры, который устанавливается в систему как systemd-сервис.

[Unit]
Description=Description
After=network.target
Requires=mysqld.service

[Service]
Type=simple
User=nginx
Group=nginx

WorkingDirectory=/usr/share/project_name

StandardOutput=journal
StandardError=journal

ExecStart=/usr/share/project_name/project_name
Restart=always

[Install]
WantedBy=multi-user.target

Системный менеджер systemd занимается:
  1. Установлением зависимостей запуска веб-сервиса (как в вышеуказанном примере от mysqld);
  2. Respawn-ом на случай падения приложения;
  3. Благодаря опциям StandardOutput и StandardError, логированием службы. Чтобы из приложения писать в системный лог, достаточно вызвать:
    log.Println("Server is preparing to start")

Впереди устанавливается http-сервер для отдачи статики, например, nginx.

Установка, обновление и откат веб-приложения целиком ложатся на пакетный менеджер linux-системы (yum/dnf/rpm), в результате чего эта иногда нетривиальная задача становиться простой и надежной.

Основная логика


Для некоторых задач мы будем пользоваться готовым тулкитом Gorilla toolkit и на его основе, по сути, сделаем свой несколько расширенный тулкит.

Инициализация приложения

Приложение имеет объекты, которые изменяются лишь однажды при старте — это структуры конфигурации, роутеров, объекты доступа к базе данных и шаблонам. Для консолидации и удобного их применения, создадим структуру Application:


type MapRoutes map[string]Controller

type Application struct {
    Doc AbstractPage
    Config Config
    DB SQL

    routes MapRoutes
}

Методы Application

// Routes устанавливает обработчики запросов в соответствии с URL'ами
func (app *Application) Routes(r MapRoutes) {
    app.routes = r
}

func (app *Application) Run() {
    r := mux.NewRouter()
    r.StrictSlash(true)

    for url, ctrl := range app.routes {
        r.HandleFunc(url, obs(ctrl))
    }

    http.Handle("/", r)
    listen := fmt.Sprintf("%s:%d", app.Config.Net.Listen_host, app.Config.Net.Listen_port)

    log.Println("Server is started on", listen)
    if err := http.ListenAndServe(listen, nil); err != nil {
        log.Println(err)
    }
}


Объект Application в приложении конечно же должен быть один:


var appInstance *Application

// GetApplication возвращает экземпляр Application
func GetApplication() *Application {
    if appInstance == nil {
        appInstance = new(Application)

        // Init code
        appInstance.Config = loadConfig("config.ini")
        appInstance.Doc = make(AbstractPage)
        appInstance.routes = make(MapRoutes)
        // ...
    }

    return appInstance
}

Таким образом, использование нашего Application будет достаточно простым:

main.go

package main

import (
	"interfaces/app"
	"interfaces/handlers"
	"log"
)

func init() {
	log.SetFlags(log.LstdFlags | log.Lshortfile)
}

func main() {
	log.Println("Server is preparing to start")
	Application := app.GetApplication()

	if Application.Config.Site.Disabled {
		log.Println("Site is disabled")
		Application.Routes(app.MapRoutes{"/": handlers.HandleDisabled{}})
	} else {
		Application.Routes(app.MapRoutes{
			"/": handlers.HandleHome{},
			"/v1/ajax/": handlers.HandleAjax{},
			// другие контроллеры
			"/{url:.*}": handlers.Handle404{},
		})
	}

	Application.Run()
	log.Println("Exit")
}


httpHandler с контекстом *Context

Самое интересное здесь именно установление роутеров:


for url, ctrl := range app.routes {
    r.HandleFunc(url, obs(ctrl))
}

Дело в том, что в Router из тулкита Gorilla ровно как и в стандартной библиотеке «net/http» работа обработчика (контроллера) сводится к функции типа func(http.ResponseWriter, *http.Request). Нам же интересен другой вид контроллера, чтобы не дублировать код из контроллера в контроллер тривиальными операциями:


func ProductHandler(ctx *Context) {
    // ...
}

где *Context — удобный инструмент работы с куками, сессией и другими контекстно-зависимыми структурами. Если говорить более детально, то нас интересует не только контекст реквеста в контроллере, но и доступ к БД, к конфигурации, т.е. и к объекту Application. Для этого вводим функцию обертку obs(handler Controller) func(http.ResponseWriter, *http.Request), которая на вход получает нужный нам вид контроллера — интерфейс Controller, а возвращает нужный для r.HandleFunc() вид функции и при этом выполняет все надстроечные действия перед выполнением контроллера — создание *ContextApplication объекта.

Функция obs(), Controller и HTTPController

type Controller interface {

    GET(app *ContextApplication)
    POST(app *ContextApplication)
    PUT(app *ContextApplication)
    DELETE(app *ContextApplication)
    PATCH(app *ContextApplication)
    OPTIONS(app *ContextApplication)
    HEAD(app *ContextApplication)
    TRACE(app *ContextApplication)
    CONNECT(app *ContextApplication)
}

// obs инициализирует контекст для заданного клиента и вызывает контроллер
func obs(handler Controller) func(http.ResponseWriter, *http.Request) {
    return func(w http.ResponseWriter, req *http.Request) {


        ctx := context.New(w, req)
        app := GetApplication()
        doc := app.Doc.Clone("")
        doc["Ctx"] = ctx
        doc["User"] = ctx.User()

        contextApp := &ContextApplication{ctx, doc, app.Config, app.DB}

        switch ctx.Input.Method() {
            case "GET":     handler.GET(contextApp);
            case "POST":    handler.POST(contextApp);
            case "PUT":     handler.PUT(contextApp);
            case "DELETE":  handler.DELETE(contextApp);
            case "PATCH":   handler.PATCH(contextApp);
            case "OPTIONS": handler.OPTIONS(contextApp);
            case "HEAD":    handler.HEAD(contextApp);
            case "TRACE":   handler.TRACE(contextApp);
            case "CONNECT": handler.CONNECT(contextApp);

            default: http.Error(ctx.Response(), "Method not allowed", http.StatusMethodNotAllowed)
        }
    }
}

// HTTPController объект для встраивания в контроллеры, содержащие стандартные методы для контроллера
// Задача контроллеров переписать необходимые методы.
type HTTPController struct {}

func (h HTTPController) GET(app *ContextApplication) {
    http.Error(app.Ctx.Response(), "Method not allowed", http.StatusMethodNotAllowed)
}

func (h HTTPController) POST(app *ContextApplication) {
    http.Error(app.Ctx.Response(), "Method not allowed", http.StatusMethodNotAllowed)
}

func (h HTTPController) PUT(app *ContextApplication) {
    http.Error(app.Ctx.Response(), "Method not allowed", http.StatusMethodNotAllowed)
}

func (h HTTPController) DELETE(app *ContextApplication) {
    http.Error(app.Ctx.Response(), "Method not allowed", http.StatusMethodNotAllowed)
}

func (h HTTPController) PATCH(app *ContextApplication) {
    http.Error(app.Ctx.Response(), "Method not allowed", http.StatusMethodNotAllowed)
}

func (h HTTPController) OPTIONS(app *ContextApplication) {
    http.Error(app.Ctx.Response(), "Method not allowed", http.StatusMethodNotAllowed)
}

func (h HTTPController) HEAD(app *ContextApplication) {
    http.Error(app.Ctx.Response(), "Method not allowed", http.StatusMethodNotAllowed)
}

func (h HTTPController) TRACE(app *ContextApplication) {
    http.Error(app.Ctx.Response(), "Method not allowed", http.StatusMethodNotAllowed)
}

func (h HTTPController) CONNECT(app *ContextApplication) {
    http.Error(app.Ctx.Response(), "Method not allowed", http.StatusMethodNotAllowed)
}


*ContextApplication

type ContextApplication struct {
    Ctx *context.Context
    Doc AbstractPage
    Config Config
    DB SQL
}


Создание контроллера

Теперь все готово для создание контроллера:

HandleCustom

import (
    "interfaces/app"
)

type HandleCustom struct {
    app.HTTPController
}

func (h HandleCustom) GET(app *app.ContextApplication) {
    app.Ctx.SendHTML("html data here")
}

func (h HandleCustom) POST(app *app.ContextApplication) {
	// and so on...
}


Процесс создания нового контроллера заключается в переписывании методов встроенного app.HTTPController объекта (GET, POST и т.п.). Если не переписать метод, то вызовется встроенный, который возвращает клиенту «Method not allowed» (это поведение можно изменить на любое другое).

Контекст

Context по сути состоит из набора методов для упрощения работы с контекстно-зависимыми переменными. Не буду писать реализацию, вкратце перечислю некоторые методы, чтобы было ясно о чем идет речь:


func (c *Context) NotFound() // NotFound sends page with 404 http code from template tpls/404.tpl
func (c *Context) Redirect(url string) // Redirect sends http redirect with 301 code
func (c *Context) Redirect303(url string) // Redirect303 sends http redirect with 303 code
func (c *Context) SendJSON(data string) int // SendJSON sends json-content (data)
func (c *Context) SendXML(data string) // SendXML sends xml-content (data)
func (c *Context) GetCookie(key string) string // GetCookie return cookie from request by a given key.
func (c *Context) SetCookie(name string, value string, others ...interface{}) // SetCookie set cookie for response.
func (c *Context) CheckXsrfToken() bool // CheckXsrfToken проверяет token
func (c *Context) User() User // User возвращает текущего пользователя
func (c *Context) Session(name string) (*Session, error) // Session открывает сессию
func (s *Session) Clear() // Clear очищает открытую сессию

// и т.д.


Шаблонизатор

В составе стандартной библиотеки есть замечательный пакет «html/template». Его и будем использовать, немного расширив его функционал.


// loadTemplate load template from tpls/%s.tpl
func loadTemplate(Name string) *html.Template {
    funcMap := html.FuncMap{
        "html": func(val string) html.HTML {
            return html.HTML(val)
        },
        "typo": func(val string) string {
            return typo.Typo(val)
        },
        "mod": func(args ...interface{}) interface{} {
            if len(args) == 0 {
                return ""
            }

            name := args[0].(string)
            ctx := new(context.Context)

            if len(args) > 1 {
                ctx = args[1].(*context.Context)
            }

            modules := reflect.ValueOf(modules.Get())
            mod := modules.MethodByName(name)

            if (mod == reflect.Value{}) {
                return ""
            }

            inputs := make([]reflect.Value, 0)
            inputs = append(inputs, reflect.ValueOf(ctx))

            ret := mod.Call(inputs)
            return ret[0].Interface()
        },
    }

    return html.Must(html.New("*").Funcs(funcMap).Delims("{{%", "%}}").ParseFiles("tpls/" + Name + ".tpl"))
}

Для совместимости с AngularJS меняем разделители с "{{ }}" на "{{% %}}", хотя, признаюсь, не совсем удобно.
Более подробно о 3-х вышеуказанных pipeline-функций:
  1. html — меняет тип входного параметра на HTML, чтобы шаблон не экранировал HTML-строки. Иногда бывает полезно. Пример использования в шаблоне:
    <div>{{% .htmlString | html %}}</div>
  2. typo — обработка текста по некоторым типографическим правилам. Пример использования в шаблоне:
    <h1>{{% .title | typo %}}</h1>
  3. mod — запуск модулей прямо из тела шаблона. Пример использования:
    <div>{{% mod "InformMenu" %}}</div>


type AbstractPage map[string]interface{}

AbstractPage является контейнером входных данных для использования их в template'ах. Приведу пример:

Заполнение значений в коде

func (h HandleCustom) GET(app *app.ContextApplication) {
    doc := app.Doc.Clone("custom") // Создается новый AbstractPage, который будет использовать custom.tpl
    doc["V1"] = "V1"
    doc["V2"] = 555

    result := doc.Compile()
    app.Ctx.SendHTML(result)
}


custom.tpl

{{%define "*"%}}
<ul>
    <li>{{% .V1 %}}</li>
    <li>{{% .V2 %}}</li>
</ul>
{{%end%}}


AbstractPage имеет 2 метода:
  1. Метод Clone()
    
    // Clone возвращает новый экземпляр AbstractPage c наследованными полями и значениями
    func (page AbstractPage) Clone(tplName string) AbstractPage {
        doc := make(AbstractPage)
        for k, v := range page {
            doc[k] = v
        }
    
        doc["__tpl"] = tplName
        return doc
    }
    


    Создает новый контейнер AbstractPage, копируя все значения. Смысл этой операции заключается в наследовании значений с вышестоящих уровней AbstractPage.
  2. Метод Compile()
    
    // Compile return page formatted with template from tpls/%d.tpl
    func (page AbstractPage) Compile() string {
        var data bytes.Buffer
    
        for k, v := range page {
            switch val := v.(type) {
                case AbstractPage: {
                    page[k] = html.HTML(val.Compile())
                }
                case func()string: {
                    page[k] = val()
                }
            }
        }
    
        // Директива загрузки модулей динамичная (ctx записан в doc["Ctx"])
        getTpl(page["__tpl"].(string)).Execute(&data, page)
    
        return data.String()
    }
    


    Выполняет прогон шаблона и формирует результирующий HTML-код.


Резюме


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

Хотелось бы отметить, что Go не оставил меня равнодушным, также как и многих.

Ссылки


1. github.com/dblokhin/typo — golang package для обработки текста по некоторым типографическим правилам.

Комментарии (28)


  1. x88
    17.06.2015 18:32

    Пример реализации простых приложений это может и хорошо, но вот мы не нашли паттерна реализации или фреймворка для крупных веб приложений. У меня довольно большой опыт web разработки (с применением Yii, laravel, kohana, symphony), и буквально неделю назад начали писать под свой проект (с дальнейшей opensource публикацией) вменяемый fullstack фреймворк со всеми плюшками ORM, валидаторами, i18n и т. д. Если есть желающие присоединиться к разработке, пишите в лс.


    1. neolink
      17.06.2015 18:47
      +2

      только он symfony


      1. shoomyst
        17.06.2015 19:21
        -2

        Symfony — для малоопытных, symphony — для многоопытных


    1. vsb
      17.06.2015 19:25
      +3

      Revel это не оно?


    1. denis_g
      17.06.2015 19:27
      +3

      Может быть именно в этом и состоит дзен Go: вместо больших фреймворков использовать небольшие, но модульные решения (один инструмент выполняет одну задачу). И, черт побери, после жутко монолитной Джанги, мне это как бальзам на душу.


      1. Pryada
        17.06.2015 19:29
        +4

        Микросервисы? REST Microservices in Go with Gin


        1. denis_g
          17.06.2015 19:31

          Типа того :)


    1. VasilioRuzanni
      17.06.2015 19:39
      +2

      А нужно ли оно вообще, свой фреймворк-то, в котором все-все-все?

      Связка Gin + Gorm (или beego + beego ORM) + govalidator + go-i18n + whatever не подходит по объективным причинам или обладает фатальным недостатком?


      1. alehano
        18.06.2015 18:07

        +1 можно легко собрать свой фреймворк и заменять детали на ходу.


        1. VasilioRuzanni
          18.06.2015 18:51

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


    1. Alexeyco
      18.06.2015 10:30

      beego.me не понравился? martini — для приложений поменьше (за отсутствие ссылок прошу прощения — не позволяет карма)


      1. alehano
        18.06.2015 18:08
        +1

        Вместо Martini лучше на Gin посмотреть.


  1. taliban
    17.06.2015 19:50
    +4

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


  1. paco
    17.06.2015 20:10
    +4

    Статья совсем не для новичков. И даже для тех, кто прошел что-нибудь на подобии Go Tour статья вызывает больше вопросов, чем ответов.


    1. dblokhin Автор
      18.06.2015 01:45

      Задавайте вопросы, я постараюсь ответить и раскрыть все что смогу.


  1. ZoomLS
    18.06.2015 00:03

    >>Впереди устанавливается http-сервер для отдачи статики, например, nginx

    А совсем недавно статья была, там говорилось что Go ненужны никакие nginx и прочие апачи, якобы он настолько самостоятельный, что это всё сам обрабатывать может легко. И где правду искать? Круто конечно, если действительно он настолько самодостаточный.


    1. frol
      18.06.2015 01:25

      Атак на web-серверы превеликое множество, я бы не рисковал выставлять Go в интернет. Несмотря на то, что он скорее всего будет справляться с определённой нагрузкой, его вероятнее всего не составит труда свадить банальной slowloris. Это не говоря о том, что реализовывать HTTPS самостоятельно — ещё более плохая идея. Подводя итог, использовать Go как самостоятельный веб-сервер хоть и можно, но не рекомендуется уж точно.


      1. neolink
        18.06.2015 01:43
        +3

        что значит https самостоятельно? в go в стандартной библиотеке есть реализация tls.
        вот slowloris написанный на go для nginx github.com/valyala/goloris
        веб сервер на go дает ~ 20000-25000 rps на ядро, это примерно на 20-30% медленее nginx на тойже машине в задаче отдачи статических файлов
        подводя итог ваши аргументы достаточно странные как и рекомендации.
        если не нужен reverse-proxy то смысла его использовать нет — получится двойной парсинг запроса, ещё больше занятых сокетов и т.п, что в итоге даст меньшую производительность.


        1. frol
          18.06.2015 09:24
          +1

          How quickly it can take down unprotected nginx with default settings?

          In a few minutes with default config options.

          Which versions of nginx are vulnerable?

          All up to 1.5.9 if unprotected as described below (i.e. with default config).
          Это ещё раз подтверждает, что не стоит делать свой велосипед, если даже специально заточенный проект борется с этими атаками годами. Ну и, как видно, в Nginx 1.6, который вышел больше года назад, уже приняты меры, но даже без обновления до 1.6 можно было залатать уязвимость при помощи конфигурирования.

          Если вы хотите велосипед — мне вас не остановить. Не доверяете граблям, на которые наступили другие, — наступите на них сами.


    1. dblokhin Автор
      18.06.2015 01:54
      +3

      Дело не просто в отдачи статики. Nginx имеет целый багаж полезных фич и настроек — кэш, сжатие, limit_rate, limit_req, геомодуль, все не перечесть — вы и так все знаете. Не делать же этот функционал заново средствами Go.


      1. Alexeyco
        18.06.2015 11:02
        +3

        Не все понимают, что между фразами «go умеет самостоятельно» и «лучше над go ставить веб-сервер» нет противоречия.


  1. Crandel
    18.06.2015 09:44
    +3

    Скиньте пожалуйста все это в гитхаб, хочется всю структуру проекта оценить


    1. dblokhin Автор
      18.06.2015 12:16

      1. dblokhin Автор
        18.06.2015 12:17

        Задавайте свои вопросы и/или оставляйте свои рекомендации.


        1. Crandel
          18.06.2015 16:42

          не очень понятно зачем в каждом темплейте
          {{%define "*"%}}


          1. dblokhin Автор
            19.06.2015 03:53

            Это именование шаблона, требование системы шаблонов «text/template» (и соответственно «html/template»), т.к. шаблоны могут быть множественно представлены в одном файле: golang.org/pkg/text/template/#hdr-Nested_template_definitions

            Т.е. определять имя шаблона необходимо в любом случае, даже если вы не обращаете на это внимание. В моем примере все шаблоны именуются одинаково "*". Можно дать любое другое именование.


          1. Pryada
            20.06.2015 08:45

            Можно использовать другой шаблонизатор, например Понго (шаблонизатор пришедший из Джанго). В нём другой подход.

            Хочу сказать, что это не «фишка языка Го», а просто одна из реализаций шаблонизатора. Может быть и по-другому, без дефайнов.


  1. xlin
    20.06.2015 14:49

    Статья явно не для новичков. Не понял ничего. Хотя не много (совсем не много) пытаюсь писать мини программки на go.