Найти в Дзене
Worst Programming

Как я написал обертку для работы с базой данных... на каналах

Оглавление

Сломать конкурентность при выполнении запросов к базе данных? Получать данные из базы с помощью каналов, которые необходимо вычитывать до конца? Наворотить такого, что потом никто не разберется? Да... Знаем, практикуем, умеем.

Стоп! Что?

Ни для кого не секрет, что в go нужно делать так:

Офишальная документация отсюда: https://pkg.go.dev/database/sql#DB.Query
Офишальная документация отсюда: https://pkg.go.dev/database/sql#DB.Query

Обращаю внимание на `defer rows.Close()` сразу после корректного выполнения `db.Query` и `rows.Next()` перед каждой `rows.Scan`. Ну традиция. Вернее, теперь это, наверно, классика, т.к. так поступать уже не модно.

Долгое время я был ретроградом и делал именно так, как велела документация go, но вот в один прекрасный день я увидел код, в котором не обнаружил `rows.Close`... и я завис.

Всем понятно, что `rows.Close` выполняет освобождение занятых ресурсов? В том числе и коннекта через который получает данные. Т.е. я хочу сказать, что если не выполнять `rows.Close` то рано или поздно достигнешь ограничения на число коннектов и приложение зависнет. Однако с кодом, который я увидел, такого не происходило. И я погрузился в его исследование.

Когда стрелять? В кого стрелять?

Ну, окей. Давайте разберемся, когда нам нужно освободить ресурсы? Все правильно, когда закончились данные, которые мы читаем. А еще? А еще, когда `rows.Scan` вернул ошибку. Ну еще есть ситуация, когда программисту надоело читать данные и он посередине остановился и решил досрочно выйти из процедуры... Последнее бывает крайне редко, но не исключение, если у вас есть контекст с дэдлайном. Кстати, почитать про контексты с сарказмом можно тут.

Первый случай. Закончились данные, а это значит, что `rows.Next` вернет FALSE. Хорошо, значит rows знает, когда данным наступил конец, тогда чего же освобождение ресурсов не делается внутри реализации `Next`? Давайте делать...

Второй случай, когда `rows.Scan` возвращает ошибку, а это значит, что опять самому интерфейсу `rows` известно, когда наступит этот момент, так давайте же и тут делать освобождение ресурсов?

Третий случай редкий, но меткий - тут интерфейсу ничего не известно о планах программиста, значит все таки придется делать `rows.Close`, но только если мы действительно планируем выходить из цикла чтения досрочно.

Поймал? Че молчишь то?

Не раздумывая долго, пишем вот такой интерфейс:

Само закрывает, когда надо
Само закрывает, когда надо

Ну а `Close()` у этого чуда просто будет `return r.row.Close()`. Ну, если не учитывать, что все, что я нарисовал на картинке, не тестировалось, из настоящего кода я многое повырезал (потому, что оффтоп), а поле `row` по смыслу должно называться `rows`, то что мы имеем?

Теперь писать в коде `defer rows.Close()` не требуется, а значит мы не будем накладывать оверхед `defer` на время выполнения функции, не будем накладывать обязанности по освобождению ресурсов на программиста (за исключением таймаута контекста) и выглядеть это будет не по канонам GO, тоесть прямо, как баг!

Чем еще удивить?

И вот я подумал, а чем бы я мог удивить автора такого интерфейса? А можно ли вообще сделать это на каналах? И да, можно, но есть нюансы...

Вот такая функция может родиться у кого угодно... завтра
Вот такая функция может родиться у кого угодно... завтра

Сори, если мелко, но смысл в том, что функция возвращает канал интерфейсов Row, который на самом деле содержит только это:

И как с таким работать?
И как с таким работать?

Только скан! Да-да, когда вы выполняете процедуру `QueryRows`, то в ней сразу выполняется `rows.Next`, а если результат будет отрицательным - то процедура вернет `err = io.EOF` и так будет понятно, что никаких данных нет. Ну а если данные есть, то вернется канал в котором будет лежать интерфейс `Row`, который останется только прочитать... А потом снова прочитать, если чтение из канала не закончилось... И опять... И опять...

А кое-что не тонет

Так где же подводный камень? Да вот же он, как всегда на виду и в то же время не заметен, когда мы пишем "дурацкий код". Дело в том, что Когда мы вычитали из канала, то место в канале освободилось и его тут же хочется заполнить новыми данными. И если не знать, что ресурс для чтения у `rows` всегда один и тот же, то очень хочется сделать что-то такое:

var ch = make(chan Rows)
go func(){
defer rows.Close()
for rows.Next() {
// как только ты вычитаешь из канала
// я сразу же выполню rows.Next опять
ch <- rows
}
}
return ch

И получается, что мы, не успевая выполнить `rows.Scan`, выполняем `rows.New`, а это уже самый настоящий race condition.

Ну и пофиг, давайте просто наполним канал не тогда, когда из него вычитают, а сразу после того, как выполнится `Scan`, для этого просто сохраним канал в структуру `rowWithNext`.

А че так можно было?
А че так можно было?

Ой, а что там за go-рутина на 65 строке? А-а-а-ах эта?! Да это все потому, что мой канал не буферизованый, а это значит, что когда в него пишут, выполняется ожидание, когда данные с той стороны прочитают, но если канал буферизовать (вот так: make(chan Rows, 1)), то от горутины в этом месте можно избавиться...

Отказ от ответственности

Код, который я тут представил, не является полным рабочим кодом, там требуется первичное заполнение канала и вообще я не показал имплементацию `QueryRows`. А зачем? Не надо это копировать, если не можете сами написать - только ошибок наделаете. А если можете это написать, то и подавно не надо выкладывать весь код тут...

Ок, я это использовал в одном проекте и это работает, что это дало? Код каждой процедуры, которая вычитывает данные из базы, уменьшился на одну строчку... сократили вызов `rows.Close` и теперь вообще не похоже, что это вызов из БД. Но зато теперь никто не забудет вызвать этот пресловутый `rows.Close`, достаточно просто дочитать канал до конца.

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

Спасибо, до свидания!