SELECT-запросов. В этом уроке расскажем, как можно вносить изменения в базу данных. Сначала разберём SQL-команды UPDATE, INSERT и DELETE. Затем, используя разные подходы, рассмотрим как на Go cконвертировать csv-файл с видеороликами в базу данных. Вы познакомитесь с транзакциями и подготовленными SQL-запросами. Всё, что вы изучите в этом уроке, поможет вам в приложениях на Go создавать таблицы, а также вставлять, удалять и изменять записи в базе данных.SELECT вы уже знакомы. Теперь рассмотрим команды, которые в отличие от SELECT, могут изменять данные.CREATE TABLE используется для создание таблицы. Эта команда вам встречалась, когда вы создавали таблицу videos из sqlite3.-- создаём таблицу movies
CREATE TABLE movies (
"id" INTEGER PRIMARY KEY,
"title" VARCHAR(250) NOT NULL DEFAULT '',
"created" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
"tags" TEXT,
"views" INTEGER NOT NULL DEFAULT 0
) PRIMARY KEY определяет первичный ключ для которого автоматически создаётся индекс;VARCHAR(250) означает строковый тип переменного размера (до 250 байт);NOT NULL указывает на то, что значение колонки не может принимать NULL;DEFAULT определяет значение по умолчанию, если при добавлении записи значение колонки не указано;CURRENT_TIMESTAMP означает подстановку текущего времени.DROP TABLE удаляет таблицу.-- удаляем таблицу videos
DROP TABLE videos DROP TABLE полностью удаляет таблицу из БД. Если нужно удалить все записи, но оставить структуру таблицы без изменений, то нужно использовать TRUNCATE TABLE.-- удаляем все записи в таблице videos
TRUNCATE TABLE videos INSERT INTO. -- вставляем запись, у которой указаны значения только четырёх колонок
INSERT INTO videos (video_id, title, views, likes)
VALUES ('1', 'Something about', 100, 20) -- вставляем три записи
INSERT INTO videos (video_id, title, views, likes)
VALUES ('1', 'Something about', 100, 20),
('2', 'Happy birthday', 150, 31),
('3', 'To be or not to be', 97, 12) UPDATE ... SET. Как правило, она применяется с условием WHERE. Условие указывается так же, как в команде SELECT.-- изменяем заголовок, количество просмотров и лайков у роликов
-- с video-id равным 2kyS6SvSYSE
UPDATE videos SET title = 'The best movie', views = 10001, likes = 578
WHERE video_id = '2kyS6SvSYSE'
-- обнуляем количество лайков, если их меньше 100
UPDATE videos SET likes = 0 WHERE likes < 100 DELETE FROM служит для удаления записей. Её нужно применять с условием WHERE. Иначе все записи в таблице будут удалены.-- удаляем записи, у которых количество просмотров меньше 100000
DELETE FROM videos WHERE views < 100000 USvideos.csv файла в SQLite базу данных с помощью утилиты sqlite3 и представляете, как можно импортировать данные. В реальной практике часто возникает потребность в импорте и экспорте данных. Представьте себе такие ситуации:csv-файла. Так как количество записей небольшое, можно написать функцию, которая читает данные и возвращает слайс структур со всеми записями. После — добавим данные в таблицу.type Video struct {
Id string // video_id
Title string // title
PublishTime time.Time // publish_time
Tags []string // tags
Views int // views
} csv-файла будем использовать пакет encoding/csv. Приведём готовую функцию, которая будет использоваться в программе конвертации. Если вы чувствуете, что способны написать функцию func readVideoCSV(csvFile string) ([]Video, error) самостоятельно, то попробуйте. Возможно, вы реализуете её элегантнее предложенного варианта. func readVideoCSV(csvFile string) ([]Video, error) {
// открываем csv файл
file, err := os.Open(csvFile)
if err != nil {
return nil, err
}
defer file.Close()
var videos []Video
// определим индексы нужных полей
const (
Id = 0 // video_id
Title = 2 // title
PublishTime = 5 // publish_time
Tags = 6 // tags
Views = 7 // views
)
// конструируем Reader из пакета encoding/csv
// он умеет читать строки csv-файла
r := csv.NewReader(file)
// пропустим первую строку с именами полей
if _, err := r.Read(); err != nil {
return nil, err
}
for {
// csv.Reader за одну операцию Read() считывает одну csv-запись
l, err := r.Read()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
// инициализируем целевую структуру,
// в которую будем делать разбор csv-записи
v := Video{
Id: l[Id],
Title: l[Title],
}
// парсинг строковых записей в типизированные поля структуры
if v.PublishTime, err = time.Parse(time.RFC3339, l[PublishTime]); err != nil {
return nil, err
}
tags := strings.Split(l[Tags], "|")
for i, v := range tags {
tags[i] = strings.Trim(v, `"`)
}
v.Tags = tags
if v.Views, err = strconv.Atoi(l[Views]); err != nil {
return nil, err
}
// добавляем полученную структуру в слайс
videos = append(videos, v)
}
return videos, nil
} func insertVideos(ctx context.Context, db *sql.DB, videos []Video) error {
// здесь будем вставлять записи в базу данных
// ...
return nil
}
func main() {
// открываем соединение с БД
db, err := sql.Open("sqlite", "newvideo.db")
if err != nil {
log.Fatal(err)
}
defer db.Close()
// читаем записи из файла в слайс []Video вспомогательной функцией
videos, err := readVideoCSV("USvideos.csv")
if err != nil {
log.Fatal(err)
}
// записываем []Video в базу данных
// тоже вспомогательной функцией
err = insertVideos(context.Background(), db, videos)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Всего csv-записей %v\n", len(videos))
} $ go run main.go
Всего csv-записей 40949 SELECT-запросов, а вторые - для остальных SQL-команд. Используем метод ExecContext() для вставки элементов в таблицу с помощью INSERT INTO.(db *DB) ExecContext(ctx context.Context, query string, args ...any) (Result, error) Result:type Result interface {
LastInsertId() (int64, error)
RowsAffected() (int64, error)
} LastInsertId() возвращает целое число, сгенерированное базой данных. Обычно это происходит у атрибутов со свойством «автоинкремент» (например, id записи). RowsAffected() возвращает количество строк, на которые повлияли вставка, обновление или удаление.[]Video можно реализовать таким образом:for _, v := range videos {
// в этом случае возвращаемое значение не несёт полезной информации,
// поэтому его можно игнорировать
_, err := db.ExecContext(ctx,
"INSERT INTO videos (video_id, title, publish_time, tags, views)"+
" VALUES(?,?,?,?,?)", v.Id, v.Title, v.PublishTime,
strings.Join(v.Tags, `|`), v.Views)
if err != nil {
return err
}
} github.com/mattn/go-sqlite3 будет выполнять эту работу на 20-25% быстрее, чем modernc.org/sqlite.start := time.Now()
err = insertVideos(context.Background(), db, videos)
if err != nil {
log.Fatal(err)
}
fmt.Println(time.Since(start)) sqlite3 создавала БД очень быстро, то, видимо, решение существует. Может, можно как-то накопить все вставки в таблицу и записать их за одну операцию?Begin, Commit и Rollback. Рассмотрим каждую из них: Begin указывает на начало транзакции.Commit сохраняет изменения, которые внесли в транзакцию.Rollback откатывает изменения. Например, Rollback поможет вернуть данные пользователя, если их удаление вызывало системную ошибку в работе сервера.*sql.Tx:func (db *DB) Begin() (*Tx, error)
func (db *DB) BeginTx(ctx context.Context, opts *TxOptions) (*Tx, error) BeginTx передаётся контекст — транзакция будет отменена при отмене контекста. Параметр opts позволяет настраивать транзакцию, нам это не требуется, так что будем передавать nil в этом параметре.(tx *Tx) Commit() error сохраняет все изменения сделанные в рамках транзакции, а (tx *Tx) Rollback() error отменяет все изменения и откатывает транзакцию.*sql.Tx есть методы tx.Query...() и tx.Exec...(), аналоличные тем, которые мы использовали.func insertVideos(ctx context.Context, db *sql.DB, videos []Video) error {
// начинаем транзакцию
tx, err := db.Begin()
if err != nil {
return err
}
for _, v := range videos {
// все изменения записываются в транзакцию
_, err := tx.ExecContext(ctx,
"INSERT INTO videos (video_id, title, publish_time, tags, views)"+
" VALUES(?,?,?,?,?)", v.Id, v.Title, v.PublishTime,
strings.Join(v.Tags, `|`), v.Views)
if err != nil {
// если ошибка, то откатываем изменения
tx.Rollback()
return err
}
}
// завершаем транзакцию
return tx.Commit()
} newvideo.db должен быть быстрее в сотню раз.PrepareContext(...) или Prepare(...).func (db *DB) PrepareContext(ctx context.Context, query string) (*Stmt, error)
func (db *DB) Prepare(query string) (*Stmt, error) *sql.Stmt. У этого типа, как и у *sql.Tx, есть методы, аналогичные Query...() и Exec...(). Например, ниже показан код с QueryRowContext из прошлого урока и подобный код с использованием подготовленной SQL-инструкции:row := db.QueryRowContext(ctx,
"SELECT description FROM videos WHERE video_id = ?", id)
// пример с использованием PrepareContext
stmt, err := db.PrepareContext(ctx, "SELECT description FROM videos WHERE video_id = ?")
// не забывайте закрывать запрос
defer stmt.Close()
// сам запрос не указывается, передаются только параметры
row := stmt.QueryRowContext(ctx, id) *sql.Tx также имеет методы PrepareContext(...) или Prepare(...), которые создают скомпилированные запросы для использования в рамках конкретной транзакции.csv-файле — 40 000 записей. А что, если их будет миллион? И размер файла будет 10, 200, 500 гигабайт? Тогда нужно будет записывать строки в базу данных пачками. Как это сделать:csv-файл построчно,csv-файла.INSERT можно реализовать с помощью SQL. Например, INSERT (id, name) INTO table VALUES (1, "video1"), (2, "video2");. А если надо вставлять по 1000 записей? Удобно ли будет перечислять их в одном огромном запросе? Чтобы научиться использовать транзакции, рассмотрим вариант с буферизацией. Хотя для этого примера он избыточен.insertVideos() можно оставить без изменений, она будет получать очередной слайс по мере заполнения буфера. Изменения придётся внести в функцию чтения записей readVideoCSV(). video_id. В примере для краткости не используются скомпилированные запросы, но вы можете их добавить.// query_test.go
package main
import (
"database/sql"
"log"
"testing"
_ "modernc.org/sqlite"
)
func BenchmarkQuery(b *testing.B) {
db, err := sql.Open("sqlite", "newvideo.db")
if err != nil {
log.Fatal(err)
}
defer db.Close()
b.ResetTimer()
for i := 0; i < b.N; i++ {
db.QueryRow("SELECT title, views FROM videos WHERE video_id = ?", "R39-E3uG5J0")
}
} go test -bench=. query_test.go, то видно, что скорость оставляет желать лучшего, так как движку базы данных приходится перебирать все записи в таблице videos. И чем больше количество записей, тем медленнее будет проходить выборка данных. cpu: Intel(R) Core(TM) i5-6260U CPU @ 1.80GHz
BenchmarkQuery-4 216 5551661 ns/op video_id, то поиск будет значительно быстрее. Но всего должно быть в меру. Не нужно увлекаться индексами: каждый индекс занимает дополнительное место на диске или в памяти, а ещё замедляет операции по изменению таблицы. Например, при добавлении записи будут обновлены все индексы таблицы.CREATE INDEX name_idx ON table (field1, field2, ...), где name_idx — имя индекса, table — имя таблицы, а fieldN — имена полей по которым создаётся индекс.-- создание индекса для таблицы videos по полю video_id
CREATE INDEX video_id ON videos (video_id) video_id и заново сконвертируем данные._, err = db.ExecContext(ctx, "CREATE INDEX video_id ON videos (video_id)")
if err != nil {
log.Fatal(err)
} cpu: Intel(R) Core(TM) i5-6260U CPU @ 1.80GHz
BenchmarkQuery-4 3272 329454 ns/op UPDATE. Она изменяет значения указанных в ней полей (колонок). С точки зрения Go разработчика, её использование ничем не отличается от INSERT, CREATE и прочих команд, которые вызываются методами Exec...(). Нужно обращать внимание на корректность самого SQL-выражения. Если, например, выполнить такой запрос: db.Exec("UPDATE videos SET title = ?", "Marshmello - Blocks"), новое название будет установлено для всех роликов и таблица потеряет свою ценность.csv-файла базу данных newvideo.db и отдали её руководителю. Через некоторое время было решено избавиться от всех тегов, содержащих слово best. И эта задача попадает снова к вам. Если бы достаточно было очистить все теги у ролика и если бы там встречалось слово best, то это можно сделать одним запросом типа UPDATE videos SET tags='' WHERE tags LIKE '%best%'. Но перед вами задача — убрать конкретные теги. Для этого нужно получить все ролики, которые содержат такой тег и удалить его у каждой записи.video_id не уникальны, но при этом у записей с одинаковым video_id нельзя гарантировать совпадание тегов. Для того, чтобы не проверять каждую запись вручную воспользуемся GROUP BY. Сгруппируем записи с одинаковым значением указанного поля. GROUP BY tags позволит нам получить только набор различающихся значений tags, без дублирующихся записей.package main
import (
"context"
"database/sql"
"fmt"
"log"
"strings"
_ "modernc.org/sqlite"
)
type TagVideo struct {
Tags string
}
func getList(ctx context.Context, db *sql.DB) (videos []TagVideo, err error) {
/* 1. Выберите список роликов, у которых в тегах есть слово best
2. Получите теги в структуру TagVideo
3. Добавьте TagVideo в videos
4. Повторите пункты 2-3 для каждого найденного элемента
*/
}
func main() {
// открываем соединение с БД
db, err := sql.Open("sqlite", "newvideo.db")
if err != nil {
log.Fatal(err)
}
defer db.Close()
ctx := context.Background()
videos, err := getList(ctx, db)
if err != nil {
log.Fatal(err)
}
var updates int64 // подсчитаем количество изменённых записей
for _, v := range videos {
var tags []string
// удаляем лишние теги
for _, tag := range strings.Split(v.Tags, `|`) {
if !strings.Contains(strings.ToLower(tag), "best") {
tags = append(tags, tag)
}
}
res, err := db.ExecContext(ctx,
"UPDATE videos SET tags = ? WHERE tags = ?",
strings.Join(tags, "|"), v.Tags)
if err != nil {
log.Fatal(err)
}
// посмотрим, сколько записей было обновлено
if upd, err := res.RowsAffected(); err == nil {
updates += upd
}
}
fmt.Println(updates)
} DELETE-запрос: удалить опубликованные в определённый день видео, у которых меньше 1000 просмотров.DELETE FROM videos
WHERE publish_time BETWEEN '2018-01-01 00:00:00' AND '2018-01-31 23:59:59'
AND views < 1000 _, err := db.ExecContext(сtx, "DELETE FROM videos WHERE publish_time" +
" BETWEEN ? AND ? AND views < ?", fromDate,
toDate, viewsLimit) ? плохо читается, особенно, если в нём больше трёх аргументов. Чтобы запрос читался легче, можно использовать именованные аргументы, которые определяются функцией Named():func Named(name string, value interface{}) NamedArg _, err := db.ExecContext(сtx, "DELETE FROM videos WHERE publish_time" +
" BETWEEN @start AND @end AND views < @views",
sql.Named("end", toDate),
sql.Named("start", fromDate),
sql.Named("views", viewsLimit),
) database/sql поддерживает пул соединений. Часто у СУБД есть ограничение на количество подключений, которые она может обслуживать. Чтобы поддерживать баланс между скоростью обработки запросов и количеством одновременно работающих клиентов, пакет database/sql позволяет настраивать параметры пула подключений. Это можно сделать с помощью методов:func (db *DB) SetMaxOpenConns(n int)
func (db *DB) SetMaxIdleConns(n int)
func (db *DB) SetConnMaxIdleTime(d time.Duration)
func (db *DB) SetConnMaxLifetime(d time.Duration) db.SetMaxOpenConns(n int) служит для ограничения количества соединений, используемых приложением. Рекомендуемого числа нет: оно зависит от задач и особенностей работы. Можете поиграться с настройками.db.SetMaxIdleConns(n int) служит для ограничения количества простаивающих соединений. Устанавливайте его с тем же значением, что и db.SetMaxOpenConns(...). Если значение меньше, чем SetMaxOpenConns(), соединения могут открываться и закрываться гораздо чаще.db.SetConnMaxIdleTime(d time.Duration) задаёт максимальное время простаивания одного соединения. Поскольку некоторые промежуточные инфраструктурные приложения закрывают простаивающие соединения после пяти минут, рекомендуется устанавливать значение меньше.db.SetConnMaxLifetime(d time.Duration) задаёт максимальное время работы одного соединения с момента его создания.database/sql — простота. Но есть у него и свои неудобства. В рабочих проектах, в больших таблицах использование функции Scan() может быть утомительным. Нужно следить за порядком столбцов, обязательно делать итерацию по результатам.database/sql. Вернёмся к примеру и удалим теги, содержащие слово worst. Используем sqlx.package main
import (
"context"
"fmt"
"log"
"strings"
"github.com/jmoiron/sqlx"
_ "modernc.org/sqlite"
)
type TagVideo struct {
Tags string
}
func getList(ctx context.Context, db *sqlx.DB) (videos []TagVideo, err error) {
sqlSelect := `SELECT tags FROM videos
WHERE tags LIKE '%worst%' GROUP BY tags`
err = db.SelectContext(ctx, &videos, sqlSelect)
return
}
func main() {
db := sqlx.MustOpen("sqlite", "newvideo.db")
defer db.Close()
ctx := context.Background()
videos, err := getList(ctx, db)
if err != nil {
log.Fatal(err)
}
var updates int64
sqlUpdate := "UPDATE videos SET tags = ? WHERE tags = ?"
for _, v := range videos {
var tags []string
// удаляем лишние теги
for _, tag := range strings.Split(v.Tags, `|`) {
if !strings.Contains(strings.ToLower(tag), "worst") {
tags = append(tags, tag)
}
}
res := db.MustExecContext(ctx, sqlUpdate, strings.Join(tags, `|`), v.Tags)
if upd, err := res.RowsAffected(); err == nil {
updates += upd
}
}
fmt.Println(updates)
} MustOpen, SelectContext и MustExecContext — но при этом количество строк кода уменьшилось на 20%. Уже этот пример показывает, насколько может быть эффективно применение пакета sqlx. Create, Read, Update и Delete). В этом случае ORM сэкономит много времени на разработку.store.Store с запросами к СУБД PostgreSQL.internal/store/pg и поместим туда файл store.go: > ~/dev/alice-skill
|
|--- cmd
| |--- skill
| |--- flags.go
| |--- gzip.go
| |--- main.go
| |--- main_test.go
|--- internal
| |--- logger
| | |--- logger.go
| |--- models
| | |--- models.go
| |--- store
| |--- mock
| | |--- store.go
| |--- pg
| | |--- store.go
| |--- store.go
|--- go.mod
|--- go.sum
package pg
import (
"context"
"database/sql"
"time"
"github.com/bluegopher/alice-skill/internal/store"
)
// Store реализует интерфейс store.Store и позволяет взаимодействовать с СУБД PostgreSQL
type Store struct {
// Поле conn содержит объект соединения с СУБД
conn *sql.DB
}
// NewStore возвращает новый экземпляр PostgreSQL хранилища
func NewStore(conn *sql.DB) *Store {
return &Store{conn: conn}
}
// Bootstrap подготавливает БД к работе, создавая необходимые таблицы и индексы
func (s Store) Bootstrap(ctx context.Context) error {
// запускаем транзакцию
tx, err := s.conn.BeginTx(ctx, nil)
if err != nil {
return err
}
// в случае неуспешного коммита все изменения транзакции будут отменены
defer tx.Rollback()
// создаём таблицу пользователей и необходимые индексы
tx.ExecContext(ctx, `
CREATE TABLE users (
id varchar(128) PRIMARY KEY,
username varchar(128)
)
`)
tx.ExecContext(ctx, `CREATE UNIQUE INDEX sender_idx ON users (username)`)
// создаём таблицу сообщений и необходимые индексы
tx.ExecContext(ctx, `
CREATE TABLE messages (
id serial PRIMARY KEY,
sender varchar(128),
recepient varchar(128),
payload text,
sent_at timestamp with time zone,
read_at timestamp with time zone DEFAULT NULL
)
`)
tx.ExecContext(ctx, `CREATE INDEX recepient_idx ON messages (recepient)`)
// коммитим транзакцию
return tx.Commit()
}
func (s Store) FindRecepient(ctx context.Context, username string) (userID string, err error) {
// запрашиваем внутренний идентификатор пользователя по его имени
row := s.conn.QueryRowContext(ctx, `SELECT id FROM users WHERE username = $1`, username)
err = row.Scan(&userID)
return
}
func (s Store) ListMessages(ctx context.Context, userID string) ([]store.Message, error) {
// запрашиваем данные обо всех сообщениях пользователя, без самого текста
rows, err := s.conn.QueryContext(ctx, `
SELECT
m.id,
u.username AS sender,
m.sent_at
FROM messages m
JOIN users u ON m.sender = u.id
WHERE
m.recepient = $1
`, userID)
if err != nil {
return nil, err
}
// не забываем закрыть курсор после завершения работы с данными
defer rows.Close()
// считываем записи в слайс сообщений
var messages []store.Message
for rows.Next() {
var m store.Message
if err := rows.Scan(&m.ID, &m.Sender, &m.Time); err != nil {
return nil, err
}
messages = append(messages, m)
}
// необходимо проверить ошибки уровня курсора
if err := rows.Err(); err != nil {
return nil, err
}
return messages, nil
}
func (s Store) GetMessage(ctx context.Context, id int64) (*store.Message, error) {
// запрашиваем сообщение по внутреннему идентификатору
row := s.conn.QueryRowContext(ctx, `
SELECT
m.id,
u.username AS sender,
m.payload,
m.sent_at
FROM messages m
JOIN users u ON m.sender = u.id
WHERE
m.id = $1
`,
id,
)
// считываем значения из записи БД в соответствующие поля структуры
var msg store.Message
err := row.Scan(&msg.ID, &msg.Sender, &msg.Payload, &msg.Time)
if err != nil {
return nil, err
}
return &msg, nil
}
func (s Store) SaveMessage(ctx context.Context, userID string, msg store.Message) error {
// добавляем новое сообщение в БД
_, err := s.conn.ExecContext(ctx, `
INSERT INTO messages
(sender, recepient, payload, sent_at)
VALUES
($1, $2, $3, $4);
`, msg.Sender, userID, msg.Payload, time.Now())
return err
}
cmd/skill/flags.go:package main
import (
"flag"
"os"
)
var (
flagRunAddr string
flagLogLevel string
// переменная будет содержать параметры соединения с СУБД
flagDatabaseURI string
)
func parseFlags() {
flag.StringVar(&flagRunAddr, "a", ":8080", "address and port to run server")
flag.StringVar(&flagLogLevel, "l", "info", "log level")
// обрабатываем аргумент -d
flag.StringVar(&flagDatabaseURI, "d", "", "database URI")
flag.Parse()
if envRunAddr := os.Getenv("RUN_ADDR"); envRunAddr != "" {
flagRunAddr = envRunAddr
}
if envLogLevel := os.Getenv("LOG_LEVEL"); envLogLevel != "" {
flagLogLevel = envLogLevel
}
// обрабатываем переменную окружения DATABASE_URI
if envDatabaseURI := os.Getenv("DATABASE_URI"); envDatabaseURI != "" {
flagDatabaseURI = envDatabaseURI
}
}
flagDatabaseURI для подключения к СУБД PostgreSQL с помощью драйвера github.com/jackc/pgx/v5.
Нам необходимо добавить код в файл cmd/skill/main.go:package main
import (
"database/sql"
"net/http"
"strings"
"github.com/bluegopher/alice-skill/internal/logger"
"github.com/bluegopher/alice-skill/internal/store/pg"
_ "github.com/jackc/pgx/v5/stdlib"
"go.uber.org/zap"
)
func main() {
parseFlags()
if err := run(); err != nil {
panic(err)
}
}
func run() error {
if err := logger.Initialize(flagLogLevel); err != nil {
return err
}
// создаём соединение к СУБД PostgreSQL с помощью аргумента командной строки
conn, err := sql.Open("pgx", flagDatabaseURI)
if err != nil {
return err
}
// создаём экземпляр приложения, передавая реализацию хранилища pg в качестве внешней зависимости
appInstance := newApp(pg.NewStore(conn))
logger.Log.Info("Running server", zap.String("address", flagRunAddr))
// обернём хендлер webhook в middleware с логгированием и поддержкой gzip
return http.ListenAndServe(flagRunAddr, logger.RequestLogger(gzipMiddleware(appInstance.webhook)))
}
...
cmd/skill/app.go:func (a *app) webhook(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if r.Method != http.MethodPost {
logger.Log.Debug("got request with bad method", zap.String("method", r.Method))
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
logger.Log.Debug("decoding request")
var req models.Request
dec := json.NewDecoder(r.Body)
if err := dec.Decode(&req); err != nil {
logger.Log.Debug("cannot decode request JSON body", zap.Error(err))
w.WriteHeader(http.StatusInternalServerError)
return
}
if req.Request.Type != models.TypeSimpleUtterance {
logger.Log.Debug("unsupported request type", zap.String("type", req.Request.Type))
w.WriteHeader(http.StatusUnprocessableEntity)
return
}
// текст ответа навыка
var text string
switch true {
// пользователь попросил отправить сообщение
case strings.HasPrefix(req.Request.Command, "Отправь"):
// гипотетическая функция parseSendCommand вычленит из запроса логин адресата и текст сообщения
username, message := parseSendCommand(req.Request.Command)
// найдём внутренний идентификатор адресата по его логину
recepientID, err := a.store.FindRecepient(ctx, username)
if err != nil {
logger.Log.Debug("cannot find recepient by username", zap.String("username", username), zap.Error(err))
w.WriteHeader(http.StatusInternalServerError)
return
}
// сохраняем новое сообщение в СУБД, после успешного сохранения оно станет доступно для прослушивания получателем
err = a.store.SaveMessage(ctx, recepientID, store.Message{
Sender: req.Session.User.UserID,
Time: time.Now(),
Payload: message,
})
if err != nil {
logger.Log.Debug("cannot save message", zap.String("recepient", recepientID), zap.Error(err))
w.WriteHeader(http.StatusInternalServerError)
return
}
// Оповестим отправителя об успешности операции
text = "Сообщение успешно отправлено"
// пользователь попросил прочитать сообщение
case strings.HasPrefix(req.Request.Command, "Прочитай"):
// гипотетическая функция parseSendCommand вычленит из запроса порядковый номер сообщения в списке доступных
messageIndex := parseReadCommand(req.Request.Command)
// получим список непрослушанных сообщений пользователя
messages, err := a.store.ListMessages(ctx, req.Session.User.UserID)
if err != nil {
logger.Log.Debug("cannot load messages for user", zap.Error(err))
w.WriteHeader(http.StatusInternalServerError)
return
}
text = "Для вас нет новых сообщений."
if len(messages) < messageIndex {
// пользователь попросил прочитать сообщение, которого нет
text = "Такого сообщения не существует."
} else {
// получим сообщение по идентификатору
messageID := messages[messageIndex].ID
message, err := a.store.GetMessage(ctx, messageID)
if err != nil {
logger.Log.Debug("cannot load message", zap.Int64("id", messageID), zap.Error(err))
w.WriteHeader(http.StatusInternalServerError)
return
}
// передадим текст сообщения в ответе
text = fmt.Sprintf("Сообщение от %s, отправлено %s: %s", message.Sender, message.Time, message.Payload)
}
// если не поняли команду, просто скажем пользовутелю сколько у него новых сообщений
default:
messages, err := a.store.ListMessages(ctx, req.Session.User.UserID)
if err != nil {
logger.Log.Debug("cannot load messages for user", zap.Error(err))
w.WriteHeader(http.StatusInternalServerError)
return
}
text = "Для вас нет новых сообщений."
if len(messages) > 0 {
text = fmt.Sprintf("Для вас %d новых сообщений.", len(messages))
}
// первый запрос новой сессии
if req.Session.New {
// обработаем поле Timezone запроса
tz, err := time.LoadLocation(req.Timezone)
if err != nil {
logger.Log.Debug("cannot parse timezone")
w.WriteHeader(http.StatusBadRequest)
return
}
// получим текущее время в часовом поясе пользователя
now := time.Now().In(tz)
hour, minute, _ := now.Clock()
// формируем новый текст приветствия
text = fmt.Sprintf("Точное время %d часов, %d минут. %s", hour, minute, text)
}
}
// заполним модель ответа
resp := models.Response{
Response: models.ResponsePayload{
Text: text, // Алиса проговорит текст
},
Version: "1.0",
}
w.Header().Set("Content-Type", "application/json")
// сериализуем ответ сервера
enc := json.NewEncoder(w)
if err := enc.Encode(resp); err != nil {
logger.Log.Debug("error encoding response", zap.Error(err))
return
}
logger.Log.Debug("sending HTTP 200 response")
}