Создание простого списка контактов на Go и PostgreSQL

В этом мануале мы с вами соберем простую веб-страницу, содержащую список контактов, извлекаемых из БД PostgreSQL. Эта простая страница Go будет использовать поддержку столбцов JSON в PostgreSQL.

Следуя этому мануалу, вы научитесь подключаться к БД PostgreSQL в Go с помощью команд sqlx и pgx, динамически обрабатывать данные при помощи шаблона и обслуживать полученную страницу на HTTP-сервере.

Требования

  • Установите Go на свою машину.
  • Проверьте свой GOPATH (обычно это ~/go).
  • Установите PostgreSQL. Мы будем использовать управляемую БД.

Запуск HTTP-сервера

В новом пустом каталоге внутри $GOPATH создайте файл main.go. Вы можете назвать каталог как угодно, а здесь он будет называться go-contacts. Начнем с настройки HTTP-сервера с помощью встроенного пакета Go, net/http.

package main
import (
"flag"
"log"
"net/http"
"os"
)
var (
listenAddr = flag.String("addr", getenvWithDefault("LISTENADDR", ":8080"), "HTTP address to listen on")
)
func getenvWithDefault(name, defaultValue string) string {
val := os.Getenv(name)
if val == "" {
val = defaultValue
}
return val
}
func main() {
flag.Parse()
log.Printf("listening on %s\n", *listenAddr)
http.ListenAndServe(*listenAddr, nil)
}

Серверу нужен хост и порт, которые он будет прослушивать, поэтому мы используем флаг addr. Также можно настроить передачу параметра в переменную среды, поэтому значение флага по умолчанию будет взято из переменной среды LISTENADDR. Это означает, что при передаче флага CLI будет использовано значение переменной среды. Если ни то, ни другое не установлено, сервер будет использовать порт :8080.

Если сейчас вы сохраните файл и запустите его, вы сможете перейти по адресу http://localhost:8080.

go run main.go

И вы получите ошибку 404. Все в порядке! Это потому, что мы еще не настроили никаких маршрутов или страниц, поэтому сервер не знает, как ответить на ваш запрос.

Создание страницы контактов

Давайте создадим страницу со списком контактов и разместим ее в корневом пути /. Мы будем использовать пакет template/html , чтобы быстро передавать динамические данные (контакты), которые будут отображаться на странице.

Создайте каталог templates там же, где и main.go, и внутри него создайте файл index.html со следующим содержимым:

<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Contacts</title>
<link rel="stylesheet" href="https://unpkg.com/tachyons@4.10.0/css/tachyons.min.css"/>
</head>
<body>
<div class="mw6 center pa3 sans-serif">
<h1 class="mb4">Contacts</h1>
</div>
</body>
</html>

Это страница с базовым стилем, которая послужит основой для списка контактов.

Теперь нужно прочитать шаблон index.html в нашей программе. Импортируйте html/template  и добавьте глобальную переменную для хранения шаблонов сразу после listenAddr:

import (
"flag"
"log"
"html/template"
"net/http"
)
var (
listenAddr       = flag.String("addr", getenvWithDefault("LISTENADDR", ":8080"), "HTTP address to listen on")
tmpl             = template.New("")
)

Внутри main() после строки flag.Parse() добавьте следующий код. Для совместимости со всеми операционными системами импортируйте пакет path/filepath, который мы будем использовать для построения пути к файлам шаблонов.

var err error
_, err = tmpl.ParseGlob(filepath.Join(".", "templates", "*.html"))
if err != nil {
log.Fatalf("Unable to parse templates: %v\n", err)
}

Этот код прочитает каждый файл HTML в каталоге templates и подготовит его к визуализации. Теперь можно настроить шаблон для отображения в корневом каталоге /. Добавьте новую функцию для обслуживания страницы в конец файла:

func handler(w http.ResponseWriter, r *http.Request) {
tmpl.ExecuteTemplate(w, "index.html", nil)
}

А теперь настройте сервер для использования этой функции обработчика. Над строкой log.Printf() в main() добавьте эту строку:

http.HandleFunc("/", handler)

Все готово! Вот так должен выглядеть файл:

package main
import (
"flag"
"log"
"html/template"
"net/http"
)
var (
listenAddr = flag.String("addr", getenvWithDefault("LISTENADDR", ":8080"), "HTTP address to listen on")
tmpl       = template.New("")
)
func getenvWithDefault(name, defaultValue string) string {
val := os.Getenv(name)
if val == "" {
val = defaultValue
}
return val
}
func main() {
flag.Parse()
var err error
_, err = tmpl.ParseGlob(filepath.Join(".", "templates", "*.html"))
if err != nil {
log.Fatalf("Unable to parse templates: %v\n", err)
}
http.HandleFunc("/", handler)
log.Printf("listening on %s\n", *listenAddr)
http.ListenAndServe(*listenAddr, nil)
}
func handler(w http.ResponseWriter, r *http.Request) {
tmpl.ExecuteTemplate(w, "index.html", nil)
}

Снова запустите:

go run main.go

На этот раз вы увидите базовый шаблон, что вы только что настроили.

Добавление контактов в базу данных

Сейчас на страницу нужно добавить наши контакты.

Для этого вам нужна БД. Мы используем управляемый экземпляр PostgreSQL.

Скопируйте строку подключения к вашей БД в панели управления. Эта строка содержит все детали, необходимые для подключения к вашей базе данных (включая пароль), потому ее нужно хранить в секрете.

Инициализация базы данных

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

В macOS для работы с базами данных можно использовать TablePlus. В целом вы можете использовать любой клиент, который вам нравится, или импортировать данные с помощью команды CLI psql:

psql 'your connection string here' < contacts.sql

Извлечение контактов

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

Существует много способов подключения к базе данных PostgreSQL в Go. В этом случае также нужен удобный способ доступа к полям JSONB, поскольку база контактов использует их. Для этого можно использовать комбинацию команд github.com/jmoiron/sqlx и github.com/jackc/pgx .

Для начала импортируем пакеты:

go get -u -v github.com/jackc/pgx github.com/jmoiron/sqlx

А затем добавим их в начало main.go:

import (
...
_ "github.com/jackc/pgx/stdlib"
"github.com/jmoiron/sqlx"
"github.com/jmoiron/sqlx/types"
)

Теперь нам нужно определить тип контакта на основе структуры таблицы базы данных и подключиться к базе данных PostgreSQL. При обслуживании страницы мы будем запрашивать контакты в базе данных и передавать их в шаблон для обработки.

Тип контакта

Добавьте следующие типы в main.go. Они соответствуют структуре экспортированной базы данных и подготавливают поддержку для поля JSONB favorites:

// ContactFavorites is a field that contains a contact's favorites
type ContactFavorites struct {
Colors []string `json:"colors"`
}
// Contact represents a Contact model in the database
type Contact struct {
ID                   int
Name, Address, Phone string
FavoritesJSON types.JSONText    `db:"favorites"`
Favorites     *ContactFavorites `db:"-"`
CreatedAt string `db:"created_at"`
UpdatedAt string `db:"updated_at"`
}

Подключение к базе данных

Обратите внимание, что мы еще не подключились к базе данных. Давайте сделаем это сейчас. Для этого нужно передать строку соединения PostgreSQL в качестве флага CLI и добавить глобальную переменную базы данных. В начало файла main.go вставьте:

var (
connectionString = flag.String("conn", getenvWithDefault("DATABASE_URL", ""), "PostgreSQL connection string")
listenAddr       = flag.String("addr", ":8080", "HTTP address to listen on")
db               *sqlx.DB
tmpl             = template.New("")
)

Обратите внимание, мы используем функцию getenvWithDefault, как и в случае с прослушиваемым адресом, чтобы разрешить передачу строки соединения с помощью переменной среды (DATABASE_URL) в дополнение к флагу CLI (-conn).

После логики шаблонов в main(), прямо над http.HandleFunc(), вставьте следующее:

if *connectionString == "" {
log.Fatalln("Please pass the connection string using the -conn option")
}
db, err = sqlx.Connect("pgx", *connectionString)
if err != nil {
log.Fatalf("Unable to establish connection: %v\n", err)
}

Подключение с БД PostgreSQL установлено.

Запрос контактов в базе данных

Добавьте в конец файла новую функцию, чтобы получить все контакты из базы данных. Для более удобной обработки ошибок мы будем использовать другой пакет, github.com/pkg/errors. Загрузите его и импортируйте как обычно, в начале файла main.go.

go get -u -v github.com/pkg/errors
import (
...
"github.com/pkg/errors"
...
)

func fetchContacts() ([]*Contact, error) {
contacts := []*Contact{}
err := db.Select(&contacts, "select * from contacts")
if err != nil {
return nil, errors.Wrap(err, "Unable to fetch contacts")
}
return contacts, nil
}

Единственное, чего сейчас не хватает, это столбца favorites. Если вы посмотрите на тип контакта, мы определили это поле:

FavoritesJSON types.JSONText `db:"favorites"`

Эта строка связывает столбец favorites в базе данных с полем FavoritesJSON в структуре Contact, делая его доступным в виде объекта JSON, сериализованного как текст.

Это означает, что нам нужно вручную разобрать и разархивировать объекты JSON в реальные структуры Go. Для этого можно использовать пакет Go encoding/json. Импортируйте его в начале файла main.go и добавьте в fetchContacts():

import (
...
"encoding/json"
...
)
...
func fetchContacts() ([]*Contact, error) {
...
for _, contact := range contacts {
err := json.Unmarshal(contact.FavoritesJSON, &contact.Favorites)
if err != nil {
return nil, errors.Wrap(err, "Unable to parse JSON favorites")
}
}
return contacts, nil
}

Полученные структуры будут сохранены в поле Favorites в структуре Contact.

Обработка контактов

Итак, теперь у нас есть данные. Давайте попробуем применить их. Внутри функции handler() мы будем использовать fetchContacts (), чтобы получить контакты, а затем передать их шаблону:

func handler(w http.ResponseWriter, r *http.Request) {
contacts, err := fetchContacts()
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}
tmpl.ExecuteTemplate(w, "index.html", struct{ Contacts []*Contact }{contacts})
}

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

Теперь нам нужно изменить шаблон, чтобы сделать что-то с контактами, которые мы ему передаем. Чтобы отобразить в виде списка через запятую любимые цвета людей из списка, мы будем использовать функцию strings.Join. Прежде чем мы сможем использовать ее внутри шаблона, нам нужно определить ее как функцию шаблона внутри файла main() над строкой tmpl.ParseGlob. Не забудьте импортировать пакет strings в начале файла.

import (
...
"strings"
...
)

tmpl.Funcs(template.FuncMap{"StringsJoin": strings.Join})
_, err = tmpl.ParseGlob(filepath.Join(".", "templates", "*.html"))
...

Затем под строкой <h1> в шаблоне HTML добавьте следующие строки:

{{range .Contacts}}
<div class="pa2 mb3 striped--near-white">
<header class="b mb2">{{.Name}}</header>
<div class="pl2">
<p class="mb2">{{.Phone }}</p>
<p class="pre mb3">{{.Address}}</p>
<p class="mb2"><span class="fw5">Favorite colors:</span> {{StringsJoin .Favorites.Colors ", "}}</p>
</div>
</div>
{{end}}

Все готово! Итоговый файл main.go будет выглядеть так:

package main
import (
"encoding/json"
"flag"
"log"
"html/template"
"net/http"
"path/filepath"
"strings"
_ "github.com/jackc/pgx/stdlib"
"github.com/jmoiron/sqlx"
"github.com/jmoiron/sqlx/types"
"github.com/pkg/errors"
)
// ContactFavorites is a field that contains a contact's favorites
type ContactFavorites struct {
Colors []string `json:"colors"`
}
// Contact represents a Contact model in the database
type Contact struct {
ID                   int
Name, Address, Phone string
FavoritesJSON types.JSONText    `db:"favorites"`
Favorites     *ContactFavorites `db:"-"`
CreatedAt string `db:"created_at"`
UpdatedAt string `db:"updated_at"`
}
var (
connectionString = flag.String("conn", getenvWithDefault("DATABASE_URL", ""), "PostgreSQL connection string")
listenAddr       = flag.String("addr", getenvWithDefault("LISTENADDR", ":8080"), "HTTP address to listen on")
db               *sqlx.DB
tmpl             = template.New("")
)
func getenvWithDefault(name, defaultValue string) string {
val := os.Getenv(name)
if val == "" {
val = defaultValue
}
return val
}
func main() {
flag.Parse()
var err error
// templating
tmpl.Funcs(template.FuncMap{"StringsJoin": strings.Join})
_, err = tmpl.ParseGlob(filepath.Join(".", "templates", "*.html"))
if err != nil {
log.Fatalf("Unable to parse templates: %v\n", err)
}
// postgres connection
if *connectionString == "" {
log.Fatalln("Please pass the connection string using the -conn option")
}
db, err = sqlx.Connect("pgx", *connectionString)
if err != nil {
log.Fatalf("Unable to establish connection: %v\n", err)
}
// http server
http.HandleFunc("/", handler)
log.Printf("listening on %s\n", *listenAddr)
http.ListenAndServe(*listenAddr, nil)
}
func fetchContacts() ([]*Contact, error) {
contacts := []*Contact{}
err := db.Select(&contacts, "select * from contacts")
if err != nil {
return nil, errors.Wrap(err, "Unable to fetch contacts")
}
for _, contact := range contacts {
err := json.Unmarshal(contact.FavoritesJSON, &contact.Favorites)
if err != nil {
return nil, errors.Wrap(err, "Unable to parse JSON favorites")
}
}
return contacts, nil
}
func handler(w http.ResponseWriter, r *http.Request) {
contacts, err := fetchContacts()
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}
tmpl.ExecuteTemplate(w, "index.html", struct{ Contacts []*Contact }{contacts})
}

Попробуйте снова запустить программу, передав строку подключения к вашей БД, и сейчас вы должны увидеть на экране список контактов:

go run main.go -conn "connection string here"
# alternatively:
DATABASE_URL="connection string here" go run main.go

Заключение

Итак, теперь у вас есть простая страница контактов, которую мы создали пошагово: начиная с пустой страницы, обслуживаемой веб-сервером HTTP, и заканчивая страницей, которая отображает список контактов, извлеченных из базы данных PostgreSQL. Попутно вы познакомились с использованием пакета html/template для обработки веб-страницы с динамическими данными, научились подключаться к базе данных PostgreSQL и взаимодействовать с объектами JSONB, хранящимися в базе данных.

Tags: , ,