27 мая в 10:34

Создание веб-приложения на Go в 2017 году. Часть 2 перевод tutorial

Go*
Содержание

Итак, наше приложение будет иметь две основные части: клиентская и серверная. (Какой сейчас год?). Серверная часть будет на Go, а клиентская — на JS. Давайте сначала поговорим о серверной части.


Go (сервер)


Серверная часть нашего приложения будет ответственной за первоначальное обслуживание всего необходимого для JavaScript и всего остального, типа статических файлов и данных в формате JSON. Это все, всего две функциональности: (1) статика и (2) JSON.


Стоит отметить, что обслуживание статики опционально: статику можно обслуживать в CDN, например. Но важно то, что это не проблема для нашего Go приложения — в отличие от Python/Ruby приложения, оно может работать наравне с Ngnix и Apache, обслуживающими статику. Делегирование раздачи статических файлов какому-то другому приложению для облегчения нагрузки особо не требуется, хотя и имеет смысл в некоторых ситуациях.


Для упрощения давайте представим, что мы создаем приложение, которое обслуживает список людей (только имена и фамилии), хранящийся в таблицы базы данных, и все. Код находится здесь — https://github.com/grisha/gowebapp.


Структура каталогов


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


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


# github.com/user/foo

foo/            # package main
  |
  +--daemon/    # package daemon
  |
  +--model/     # package model
  |
  +--ui/        # package ui
  |
  +--db/        # package db
  |
  +--assets/    # здесь хранятся файлы JS и остальная статика

Верхний уровень: пакет main


На верхнем уровне у нас расположен пакет main, а его код — в файле main.go. Главный плюс в том, что при таком раскладе go get github.com/user/foo — это единственная команда, требуемая для установки всего приложения в $GOPATH/bin.


Пакет main должен быть минимальным, насколько это возможно. Единственный код, который тут находится, — это анализ аргументов команды. Если у приложения был бы конфигурационный файл, я поместил бы парсинг и проверку этого файла в еще один пакет, который, скорее всего, назвал бы config. После этого main должен передать управление пакету daemon.


Вот основа main.go:


package main

import (
    "github.com/user/foo/daemon"
)

var assetsPath string

func processFlags() *daemon.Config {
    cfg := &daemon.Config{}

    flag.StringVar(&cfg.ListenSpec, "listen", "localhost:3000", "HTTP listen spec")
    flag.StringVar(&cfg.Db.ConnectString, "db-connect", "host=/var/run/postgresql dbname=gowebapp sslmode=disable", "DB Connect String")
    flag.StringVar(&assetsPath, "assets-path", "assets", "Path to assets dir")

    flag.Parse()
    return cfg
}

func setupHttpAssets(cfg *daemon.Config) {
    log.Printf("Assets served from %q.", assetsPath)
    cfg.UI.Assets = http.Dir(assetsPath)
}

func main() {
    cfg := processFlags()

    setupHttpAssets(cfg)

    if err := daemon.Run(cfg); err != nil {
        log.Printf("Error in main(): %v", err)
    }
}

Приведенный код принимает три параметра: -listen, -db-connect и -assets-path, ничего особенного.


Использование структур для ясности


В строке cfg := &daemon.Config{} мы создаем объект daemon.Config. Его основная цель — представить конфигурацию в структурированном и понятном формате. Каждый из наших пакетов определяет свой собственный тип Config, описывающий необходимые ему параметры, и который может включать в себя настройки других пакетов. Мы видим пример этого в processFlags() выше: flag.StringVar(&cfg.Db.ConnectString, .... Здесь db.Config включен в daemon.Config. На мой взгляд, это очень полезный прием. Использование структур также оставляет возможность сериализации настроек в виде JSON, TOML или еще чего-нибудь.


Использование http.FileSystem для обслуживания статики


http.Dir(assetsPath) в setupHttpAssets — это подготовка к тому, как мы будем обслуживать статику в пакете ui. Сделано это именно так, чтобы оставить возможность для другой реализации cfg.UI.Assets (который является интерфейсом http.FileSystem), например, отдавать этот контент из оперативной памяти. Я расскажу об этом более детально позже, в отдельном посте.


В конце концов, main вызывает daemon.Run(cfg), который фактически запускает наше приложение и блокируется до момента завершения работы.


Пакет daemon


Пакет daemon содержит все, что связано с запуском процесса. Сюда относится, например, какой порт будет прослушиваться, здесь будет определен пользовательский журнал, а также все, что связано с вежливым перезапуском и т.д.


Поскольку задачей пакета daemon является инициализация подключения к базе данных, ему нужно импортировать пакет db. Он также отвечает за прослушивание TCP порта и запуск пользовательского интерфейса для этого слушателя, поэтому ему необходимо импортировать пакет ui, а поскольку пакету ui необходимо иметь доступ к данным, который обеспечивается пакетом model, ему также необходимо импортировать пакет model.


Скелет модуля daemon выглядит примерно так:


package daemon

import (
    "log"
    "net"
    "os"
    "os/signal"
    "syscall"

    "github.com/grisha/gowebapp/db"
    "github.com/grisha/gowebapp/model"
    "github.com/grisha/gowebapp/ui"
)

type Config struct {
    ListenSpec string

    Db db.Config
    UI ui.Config
}

func Run(cfg *Config) error {
    log.Printf("Starting, HTTP on: %s\n", cfg.ListenSpec)

    db, err := db.InitDb(cfg.Db)
    if err != nil {
        log.Printf("Error initializing database: %v\n", err)
        return err
    }

    m := model.New(db)

    l, err := net.Listen("tcp", cfg.ListenSpec)
    if err != nil {
        log.Printf("Error creating listener: %v\n", err)
        return err
    }

    ui.Start(cfg.UI, m, l)

    waitForSignal()

    return nil
}

func waitForSignal() {
    ch := make(chan os.Signal)
    signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
    s := <-ch
    log.Printf("Got signal: %v, exiting.", s)
}

Обратите внимание, Config включает db.Config и ui.Config, как я уже упоминал.


Все действие происходит в Run(*Config). Мы инициализируем соединение с базой данных, создаем экземпляр model.Model и запускаем ui, передавая ему настройки, указатели на модель и слушателя.


Пакет model


Назначение model заключается в отделении того, как данные хранятся в базе данных от ui, а также в обеспечении бизнес-логики, которую может иметь приложение. Это мозг вашего приложения, если угодно.


Пакет model должен определить структуру (Model выглядит подходящим названием), а указатель на экземпляр этой структуры должен быть передан всем функциям и методам ui. В нашем приложении должен быть только один такой экземпляр — для дополнительной уверенности вы можете реализовать это программно с помощью синглтона, но я не думаю, что это так уж необходимо.


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


Модель также должна определять структуры для сущностей данных, с которыми мы имеем дело. В нашем примере это будет структура Person. Его члены должны быть экспортированы (именованы с заглавной буквы), потому что другие пакеты будут к ним обращаться. Если вы используете sqlx, здесь же необходимо указать тэги, которые привязывают элементы структуры к названиям колонок в БД, например db:"first_name".


Наш тип Person:


type Person struct {
    Id          int64
    First, Last string
}

Тут нам не нужны тэги, потому что имена колонок соответствуют именам элементов структуры, а sqlx позаботится о регистре так, что Last соответствует колонке с именем last.


Пакет model НЕ должен импортировать db


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


type db interface {
    SelectPeople() ([]*Person, error)
}

Наше приложение делает не очень много, но мы знаем, что в нем перечислены люди, поэтому наша модель, скорее всего, должна иметь метод People() ([]*Person, error):


func (m *Model) People() ([]*Person, error) {
    return m.SelectPeople()
}

Чтобы все было аккуратно, код лучше размещать в разных файлах, например структура Person должна быть определена в person.go, и т.д. Но для удобочитаемости здесь представлена однофайловая версия нашего пакета model:


package model

type db interface {
    SelectPeople() ([]*Person, error)
}

type Model struct {
    db
}

func New(db db) *Model {
    return &Model{
        db: db,
    }
}

func (m *Model) People() ([]*Person, error) {
    return m.SelectPeople()
}

type Person struct {
    Id          int64
    First, Last string
}

Пакет db


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


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


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


Мы используем PostgreSQL, что означает, что нам необходимо импортировать драйвер pq. Мы также будем полагаться на sqlx и нам нужна наша model. Вот начало реализации нашего db:


package db

import (
    "database/sql"

    "github.com/grisha/gowebapp/model"
    "github.com/jmoiron/sqlx"
    _ "github.com/lib/pq"
)

type Config struct {
    ConnectString string
}

func InitDb(cfg Config) (*pgDb, error) {
    if dbConn, err := sqlx.Connect("postgres", cfg.ConnectString); err != nil {
        return nil, err
    } else {
        p := &pgDb{dbConn: dbConn}
        if err := p.dbConn.Ping(); err != nil {
            return nil, err
        }
        if err := p.createTablesIfNotExist(); err != nil {
            return nil, err
        }
        if err := p.prepareSqlStatements(); err != nil {
            return nil, err
        }
        return p, nil
    }
}

Экспортируемая функция InitDb() создает экземпляр pgDb, который является Postgres-реализацией нашего интерфейса model.db. Он содержит все необходимое для связи с базой данных, включая подготовленные запросы, и реализует необходимые интерфейсу методы.


type pgDb struct {
    dbConn *sqlx.DB

    sqlSelectPeople *sqlx.Stmt
}

Ниже приведен код для создания таблиц и подготовки запросов. С точки зрения SQL тут все довольно упрощенно и, конечно, есть куда совершенствовать:


func (p *pgDb) createTablesIfNotExist() error {
    create_sql := `

       CREATE TABLE IF NOT EXISTS people (
       id SERIAL NOT NULL PRIMARY KEY,
       first TEXT NOT NULL,
       last TEXT NOT NULL);

    `
    if rows, err := p.dbConn.Query(create_sql); err != nil {
        return err
    } else {
        rows.Close()
    }
    return nil
}

func (p *pgDb) prepareSqlStatements() (err error) {

    if p.sqlSelectPeople, err = p.dbConn.Preparex(
        "SELECT id, first, last FROM people",
    ); err != nil {
        return err
    }

    return nil
}

Наконец, нам нужно предоставить метод, реализующий интерфейс:


func (p *pgDb) SelectPeople() ([]*model.Person, error) {
    people := make([]*model.Person, 0)
    if err := p.sqlSelectPeople.Select(&people); err != nil {
        return nil, err
    }
    return people, nil
}

Здесь мы используем преимущество sqlx для выполнения запроса и построения слайса из результатов, просто вызывая Select() (Обратите внимание: p.sqlSelectPeople имеет тип *sqlx.Stmt). Без sqlx нам надо было бы итерироваться по строкам результата, обрабатывая каждую с помощью Scan, что получилось бы более многословно.


Остерегайтесь одного очень тонкого момента. people можно было бы определить как var people []*model.Person и метод работал бы точно так же. Однако, если база данных вернет пустой набор строк, метод вернет nil, а не пустой слайс. Если результат данного метода позднее будет закодирован в JSON, то он станет null, а не []. Это может вызвать проблемы, если клиентская сторона не знает, как обращаться с null.


Вот и все для db.


Пакет ui


В конце концов, нам нужно обслуживать все это через HTTP, и это именно то, что делает пакет ui.


Вот очень упрощенный вариант:


package ui

import (
    "fmt"
    "net"
    "net/http"
    "time"

    "github.com/grisha/gowebapp/model"
)

type Config struct {
    Assets http.FileSystem
}

func Start(cfg Config, m *model.Model, listener net.Listener) {

    server := &http.Server{
        ReadTimeout:    60 * time.Second,
        WriteTimeout:   60 * time.Second,
        MaxHeaderBytes: 1 << 16}

    http.Handle("/", indexHandler(m))

    go server.Serve(listener)
}

const indexHTML = `
<!DOCTYPE HTML>
<html>
  <head>
    <meta charset="utf-8">
    <title>Simple Go Web App</title>
  </head>
  <body>
    <div id='root'></div>
  </body>
</html>
`

func indexHandler(m *model.Model) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, indexHTML)
    })
}

Обратите внимание, что indexHTML почти ничего не содержит. Это почти на 100% весь HTML, который будет использовать наше приложение. Он немного изменится, когда мы приступим к клиентской части приложения, буквально на несколько строк.


Также следует отметить как определяется обработчик. Если эта идиома вам не знакома, стоит потратить несколько минут (или день), чтобы усвоить ее полностью, поскольку она очень распространена в Go. indexHandler() — это не сам обработчик, он возвращает функцию-обработчик. Это делается таким образом для того, чтобы мы могли передать *model.Model через замыкание, так как сигнатура функции-обработчика HTTP фиксирована и указатель на модель не является одним из ее параметров.


Пока мы в indexHandler() ничего не делаем с указателем на модель, но когда дойдем до фактической реализации списка людей, он нам понадобится.


Заключение


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


Продолжение

Автор оригинала: Grisha Trubetskoy
Сергей Соломеин @kilgur
карма
8,0
рейтинг 51,2
Системный администратор
Похожие публикации

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

  • 0
    Спасибо, достаточно информативно. Сам подумываю в последнее время заняться Go вплотную, поскольку судя по всему, достаточно интересная экосистема складывается вокруг него.
  • 0
    Надеюсь что в дальнейшем будут раскрыты еще подходы к тестированию, в данный момент в коде на Github тестов нет.
    • +1
      Нет, тема тестов автором не раскрыта
      • +2
        Очень жаль, потому что при написании более менее рабочих вещей, а не вариаций hello world.
        Возникает множество вопросов к тестированию, разделению по пакетам, хранению конфигураций, работой с БД. И потом приходится пробираться по граблям и переписывать несколько раз одно и то же.

        В тех же Rails подобные вещи уже учтены и есть эталонные примерны.
    • 0

      я бы не сказал, что это расово верный путь от автора, а по тестам смотрите https://onsi.github.io/ginkgo/

      • +1
        А встроенными в go средствами тестирования не обойтись?
        • 0

          Последнее время я привык задавать вопрос — "А зачем?"...


          Скорее разница подходов между TDD и BDD. Просто кому что ближе.

  • 0
    Это все хорошо подходит для очень простого приложения. Как только усложниться БЛ, потребуется декомпозировать «модель» и как-то стыковать одни элемент БЛ с другими. Здесь про дальнейшее расширение ни слова. Жаль, очень хотелось почитать про что-нибудь сложнее чем «hello world».
    • 0
      На Го это принято делать независимыми микросервисами, и потом связывать через тот же rest api. Плюсы и минусы такого решения как бы очевидны.
      • 0
        А можно поподробнее про микросервисы? Как этот всё стыковать? Какие интерфейсы делать? Интересует именно вариант с Go
  • +2
    import github.com/grisha/gowebapp/model

    А потом grisha выпускает следующую версию gowebapp, ломает обратную совместимость — норма для Go библиотек, — и при очередном деплое все благополучно наворачивается.

    Либо использовать какой-нибудь godep, либо вручную клонировать по хешу, либо gopkg.in.

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