Разработка веб-приложения на Golang

в 14:40, , рубрики: Go, golang

В этой статье я рассмотрю разработку веб-приложения на 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 для обработки текста по некоторым типографическим правилам.

Автор: dblokhin

Источник

Поделиться новостью

* - обязательные к заполнению поля