Получаем данные в формате JSON из PostgreSQL на Go
Из поста вы узнаете, как конвертировать результаты запроса в PostgreSQL из обычных строк в формат JSON на уровне базы данных, и как работать с полученной информацией в коде на Go.
В одном из проектов, у меня возникла необходимость получить разные варианты сводной статистики по одной из таблиц в формате JSON одним запросом. Да, именно так: не прочитать данные из поля типа JSON/JSONB, а конвертировать обычные строки в JSON. Изучив раздел документации PostgreSQL про работу с JSON, я нашёл все необходимые инструменты.
Пример 1
Запрос к БД
Задача: хотим получить количество записей с группировкой по датам (на основе поля created_at
типа TIMESTAMP
) в формате JSON, например вот так:
{
"visits": {
"2024-11-13": "250",
"2024-11-14": "54",
"2024-11-15": "263",
"2024-11-16": "63",
"2024-11-17": "26",
"2024-11-18": "30",
"2024-11-19": "14",
"2024-11-20": "9",
"2024-11-21": "4",
"2024-11-22": "4"
}
}
Пример запроса на SQL:
SELECT
json_build_object(
'visits', visits
)
FROM (
select json_object(
array_agg(v1.created_date::text),
array_agg(v1.visits::text)
) as visits
from (
select
date(created_at) as created_date,
COUNT(*) as visits
from visits
where link_id = 372
group by created_date
order by created_date asc
) v1
)
В данном случае, json_object()
принимает два массива и попарно соединяет их элементы в качестве ключа и значения в объекте JSON. json_build_object()
размещает полученный объект во внешнем объекте под ключом visits
. В результате запроса, из БД придёт одна строка с одним столбцом json_build_object
с результатом, похожим на приведённый выше образец JSON.
Работа из кода на Go
Далее мы рассмотрим, как выполнить написанный нами SQL-запрос из программы на Go, и декодировать полученный результат в соответствующую данным структуру. Я приведу только самые важные моменты кода. Полный пример аналогичной программы вы можете увидеть в замечательном посте Алекса Эдвардса.
// Определим тип данных, соответствующий формату, в котором мы получим JSON из БД:
type Visits struct {
Visits map[string]string `json:"visits"`
}
// Наша структура должна реализовывать метод Scan(), чтобы декодировать
// данные при получении из БД
func (s *Visits) Scan(value interface{}) error {
b, ok := value.([]byte)
if !ok {
return errors.New("type assertion to []byte failed")
}
return json.Unmarshal(b, &s)
}
// ...
func GetVisits() (*Visits, error) {
query := fmt.Sprintf(`
SELECT
json_build_object(
'visits', visits
)
FROM (
SELECT json_object(
array_agg(v1.created_date::text),
array_agg(v1.visits::text)
) AS visits
FROM (
SELECT
date(created_at) AS created_date,
COUNT(*) AS visits
FROM visits
GROUP BY created_date
ORDER BY created_date ASC
) v1
)`,
)
ctx, cancel := context.WithTimeout(context.Background(), defaultQueryTimeout)
defer cancel()
rows, err := m.db.QueryContext(ctx, query)
if err != nil {
return nil, err
}
rows.Next()
visits := Visits{}
// Вот тут и понадобится написанный нами метод Scan(): полученные из БД
// данные будут декодированы в переменную visits.
err = rows.Scan(&visits)
if err != nil {
return nil, err
}
return &visits, nil
}
Пример 2
Запрос к БД
Задача: хотим получить из БД список записей в формате JSON в виде массива объектов, в таком виде:
{
"oses": [
{
"os": "iOS",
"visit_count": 343
},
{
"os": "Android",
"visit_count": 290
},
{
"os": "Windows",
"visit_count": 32
},
{
"os": "MacOS",
"visit_count": 19
},
{
"os": "",
"visit_count": 18
},
{
"os": "Linux",
"visit_count": 15
}
]
}
Запрос для этого будет даже проще, чем в первом примере:
SELECT
json_build_object(
'oses', oses
)
FROM (
select json_agg(v1) as oses
from (
select os, count(os) as visit_count
from visits
where link_id = 372
and (date(created_at) between '2024-11-13' and '2024-11-24')
group by os
order by visit_count desc
) v1
)
json_agg()
превратит строки в массив объектов, где названия колонок – ключи. json_build_object()
разместит этот массив в объекте по ключу oses
. В результате запроса, из БД придёт одна строка с одним столбцом json_build_object
с приведённым выше JSON.
Работа из кода на Go
Код будет аналогичен первому примеру, но нужно по другому определить структуру, которую мы будем декодировать JSON.
// Определяет объекты в массиве
type OsStatsItem struct {
OS string `json:"os"`
VisitCount int64 `json:"visit_count"`
}
// Определяет общий формат JSON
type OsStats struct {
Oses []*OsStatsItem `json:"oses"`
}
// Полностью аналогично первому примеру
func (s *OsStats) Scan(value interface{}) error {
b, ok := value.([]byte)
if !ok {
return errors.New("type assertion to []byte failed")
}
return json.Unmarshal(b, &s)
}
Недостатки подхода
Заметка получила конструктивную критику от @Max@lor.sh, поэтому решил отметить здесь недостатки рассмотренного подхода.
- Двойное преобразование в JSON. Сначала данные конвертируются в JSON на уровне БД, потом парсятся в коде приложения, и, в конечном итоге, снова переводятся в JSON, чтобы отдать их из эндпоинта. Если в вашем случае это неприемлемо, не делайте так!
- Громоздкие запросы на SQL и код для обработки результатов на Go. Тут нечего добавить – действительно, могут возникнуть затруднения в поддержке такого кода.
Как обычно, нужно тщательно взвесить все "за" и "против", прежде чем отдать предпочтение в пользу этого или иного способа взаимодействия с БД.
Заключение
Любопытно, что мы можем объединить несколько подобных запросов в один. Так как в результате из БД возвращается одна строка с одной колонкой формата JSON, мы можем воспользоваться UNION ALL
и одним запросом получить большое количество разнородной информации, сразу в JSON.
Мне очень интересно узнать, пригодился ли вам такой подход на практике и как он себя показал в вашем проекте. Пожалуйста, поделитесь своим опытом через форму ниже, а лучше – напишите в Мастодоне или в Телеграм!
Справочные материалы
Отправить сообщение
С помощью формы ниже, вы можете связаться с автором сайта. Пожалуйста, укажите ваш ник в Телеграме или e-mail, чтобы я смог вам ответить!