Как стать автором
Обновить

Комментарии 9

Интересная статья, спасибо!
Возникает вопрос: если конструктор возвращает среди прочего ещё и деструктор, то как решается указанная в статье проблема локальности этого объекта? То есть я, к примеру, создал ресурс в одной функции и передал его как зависимость в службу (отдельный поток), который и должен вызвать освобождение ресурсов. Получается нужно передать туда ещё и функцию-деструктор отдельной зависимостью?
И вторая очевидная проблема такого подхода (характерная для всех фреймворков в Go): вы предлагаете свою идиоматику, что в общем случае весьма плохо.
Ещё вопрос: приведите пример конкретных ресурсов и сценариев, на которые рассчитана библиотека.
Спасибо за отзыв!

В качестве примеров ресурсов можно привести соединение с БД, брокером, логгер, или хотя бы тот же файл. Память в Go обычно в качестве явного ресурса не выступает, но всё же могут быть отдельные случаи.

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

Относительно вашего примера мне нужно больше контекста. Без конкретики могу сказать только, что если исходить из определения ресурса как зависимости, то здесь освобождение ресурса является частью его интерфейса, от которого зависит горутина, и допускаю, что типовой шаблон new — error — defer в данном случае не применим.

Но кто в данном случае владелец ресурса, а кто его просто использует (обычно зависимость трактуется как использование, а не владение)? Почему ресурсы создаются и освобождаются в разных горутинах (в некоторых случаях это может создать проблемы)? Есть ли другие горутины, зависящие от ресурса? Как осуществляется передача зависимости (вызов или канал)? Кто управляет самой горутиной (в частности, дожидается её окончания)? Как горутина декларирует то, что она будет освобождать переданный ресурс? Подход заставляет как минимум задуматься, где должен быть вызван деструктор по аналогии с тем, как явный возврат ошибки заставляет задуматься об её обработке в отличие от исключений :)

Что касается идиоматики, то это сложная тема)

Согласен, беспричинно нарушать идиомы языка — это плохая практика, поскольку код сразу же становится сложнее для понимания и плохо совместимым с уже существующей кодовой базой. Но суть любого фреймворка (не только для Go) как раз и состоит во введении правил организации кодовой базы. Кроме того, фреймворки обычно реализуют IoC. Так что без создания новых идиом и замены существующих им часто не обойтись — если бы эти идиомы уже существовали, то фреймворк был бы не нужен. В конце концов, даже сами языки меняют сложившиеся практики, объявляют их устаревшими вплоть до реальных ошибок и предупреждений при компиляции/интерпретации. Мне бы, например, очень хотелось, чтобы в Go появился более удобный механизм обработки ошибок, нежели существует сейчас. Если изменения оправданы — то почему они плохи?

У чисто утилитарных пакетов (таких, как драйвера баз данных, парсеры форматов, математические движки и т.д.) просто другая задача. Если, например, драйвер БД не будет совместим с database/sql/driver, то неминуемо потеряет в функциональности вплоть до того, что его применимость в реальных проектах окажется под вопросом.
Ясно, но не вполне понятно :) Файлы обычно закрываются с помощью defer, соединения с БД завершаются при завершении работы системы (если это долгоживущий сервер) или с помощью того же defer (если небольшая утилита).
Запуск потока с зависимостью также не обязательно может означать наличие явной точки присоединения (возврата). Например при старте системы я запускаю расчёт статистики в отдельном потоке, который завершится как закончит считать. Такому потоку я могу передать пул соединений с БД или ещё какие-то зависисмости. Точки присоединения (Waitgroup и пр.) просто нет.
В целом у нас с вами просто разные взгляды на программирование на Go. На мой взгляд Go не нуждается в тех фреймворках, которые сейчас есть. И все попытки «улучшить» язык и его идиоматику пока что явно провальные.
Кстати говоря, про драйверы БД. Как раз совместимость с database/sql и ограничивает функциональность драйверов в обмен на некоторую переносимость кода. Поэтому, например, самый популярный драйвер pgx для самой популярной БД PostgreSQL обычно используют без database/sql.
Относительно горутины, освобождающей зависимости — тут, если я правильно понял идею, могут возникнуть проблемы.

Из документации:
Program execution begins by initializing the main package and then invoking the function main. When that function invocation returns, the program exits. It does not wait for other (non-main) goroutines to complete.

То есть, если вся остальная программа не знает про горутину, которая рассчитывает статистику, то при досрочном выходе из main до освобождения ресурсов дело просто не дойдёт (даже у GC не будет шанса вызвать финализаторы). Конечно, в тривиальных случаях Go сам позаботится об освобождении системных ресурсов (хотя это fallback и необходимости корректной финализации с записями в лог и т.д. он не отменяет). Но если финализация ресурса предполагает сложную логику, то есть риски — например, тут локальная база, не будучи корректно закрытой, окажется повреждена. Даже банальная синхронизация файла может не состояться, а СУБД со своей стороны может записать в лог ошибку неожиданного закрытия соединения.

Говоря про совместимость драйверов БД с database/sql, я имел в виду скорее то, что нарушится связность экосистемы — различные утилиты вроде golang-migrate полагаются на стандартный интерфейс и при всём желании не могут предусмотреть все возможные частные случаи. В таких ситуациях сохранение идиоматичности кода необходимо по вполне практическим соображениям. Однако многие драйверы расширяют стандартную функциональность, предлагая при этом частные подходы к написанию кода — и тут я полностью согласен, часто расширения намного удобнее стандартной реализации (т. к. максимально используют возможности протокола конкретной СУБД) и используются вместо неё. Это хороший пример того, что расширение возможностей языка не противоречит совместимости и оказывается полезным :)
Интересный подход, спасибо за статью
Попробую немножко покритиковать — имхо, описанный подход слабо жизнеспособен

Дело в чем — если жизненный цикл объекта ограничивается одной областью, то шанс забыть вызвать .Close() стремится к нулю (наплодили объектов, поработали, закрыли).

Если же объект у нас долгоживущий и, как бывает почти всегда, закрывается в какой-нибудь дежурной горутине, тогда, при наличии отвязанной от структуры функции освобождения ресурсов, начинается свистопляска с передачей деструктора (а то и целой пачки).

Привязанная к структуре функция, как ни крути, выходит более простым и не менее надёжным решением.
Плюс, как отметили выше, имплементация Closer идиоматична, значит, такой объект можно использовать и закрывать где угодно, просто обратившись по интерфейсу

Впрочем, это только имхо, буду рад подискутировать!..
Спасибо за отзыв!

Согласен, для простых случаев предложенный подход избыточен. Хотя он даёт гарантию времени компиляции, но всё же в трёх строчках кода это можно трактовать как ненужную сложность.

Но подход предназначен в первую очередь для сведения кода сложных конструкторов к привычному шаблону new — error — defer. Кроме того, деструкторы легко поддаются расширению и компоновке, в то время как io.Closer требует оборачивания для расширения и опять же финализации в другой функции (имплементации метода Close), что усложняет код.

Тут, как мне кажется, лучше привести пример кода.

// Без использования выделенных деструкторов.

type Resource struct {
  closed bool
  r1 *Resource1
  r2 *Resource2
}

func NewResource() (*Resource, error) {

  r1, err := NewResource1() // Опускаю обработку ошибок.

  r2, err := NewResource2(r1)
  if err != nil { // А тут для примера нужно написать бойлерпрейт.
    r1.Close() // Тут обработка ошибки не пропущена, так нередко и пишут.
    return err
  }

  return &Resource{r1, r2}, nil
}

func (r *Resource) Close() error {
  if r.closed { /* ошибка */ }
  defer r.r1.Close() // Либо использовать multierror (гарантии для паники опциональны).
  defer r.r2.Close()
  return nil
}

type ResourceWithLog struct {
  closed bool
  *Resource
  log *Logger
}

func NewResourceWithLog(log *Logger) (*ResourceWithLog, error) {

  r, err := NewResource() // Опускаю обработку ошибок.

  return ResourceWithLog{r, log}, nil
}

func (r *ResourceWithLog) Close() error {
  if r.closed { /* ошибка */ }
  defer r.log.Print("Конец освобождения ресурса")
  defer r.r1.Close() // Либо использовать multierror, но нет гарантий для случая паники.
  defer r.r2.Close()
  defer r.log.Print("Начало освобождения ресурса")
  return nil
}


// С использованием деструкторов.

type Resource struct {
  r2 *Resource2
}

func NewResource() (*Resource, kdone.Destructor, error) {
  reaper := kdone.NewReaper()
  defer reaper.MustFinalize()

  r1, err := NewResource1() // Опускаю обработку ошибок.
  reaper.MustAssume(r1)
  
  r2, err := NewResource2(r1) // Опускаю обработку ошибок.
  reaper.MustAssume(r2)

  return &Resource{r2}, reaper.MustRelease(), nil
}

func NewResourceWithLog(log *Logger) (*Resource, kdone.Destructor, error) {

  r, dtor, err := NewResource() // Опускаю обработку ошибок.

  return r, kdone.DestructorFunc(func() error {
    defer log.Print("Конец освобождения ресурса")
    log.Print("Начало освобождения ресурса")
    return dtor.Destroy()
  }), nil
}


Во втором сниппете меньше кода, меньше сущностей и больше гарантий успешной финализации. При этом можно без труда обернуть ресурс в io.Closer там, где требуется его закрытие (что-то вроде type DestructibleResource struct { *Resource; kdone.Destructor }) — а обратное с сохранением возможности отделить интерфейс финализации весьма трудоёмко. При этом само обозначение DestructibleResource (или ClosableResource) даёт понять, что ответственность за освобождение этого ресурса ложится на принимающую сторону.

Относительно освобождающей горутины я должен задать те же вопросы, что и в ответе на комментарий выше. Это всё же не совсем стандартный случай — или, по крайней мере, я неправильно понимаю, что вы имеете в виду.

Благодарю за пример, идея стала яснее


Относительно закрытия в другом потоке — кейс из реальности: поднимается хттп-сервер, к которому цепляются хендлеры с состоянием.
Состояние читается из конфигурации, количество хендлеров зависит только от того, что в конфиге наворотит юзер.
Далее, запускается дежурная горутина, которая по сигналу перебирает срез указателей на структуры (через Closer) и закрывает их, чтобы выполнить gracefull shutdown.


Как в этом случае красиво передать пачку деструкторов?
Можно сделать срез с функциями, конечно, но если дежурная горутина берется из другой либы и ожидает Closer, придется делать обёртку

Насколько я понимаю, в целом тут речь идёт об обычном конструкторе — просто способ «возврата» у него нестандартный. Соответственно можно воспользоваться тем же Reaper, собрав деструкторы через Assume и передав их в функцию горутины в форме единого деструктора через Release.

Для случая, если функция принимает именно io.Closer и её сигнатура не может быть изменена (случай с библиотекой), действительно придётся сделать обёртку типа

type Closable struct { kdone.Destructor }

func (c Closable) Close() error { return c.Destroy() }

// При передаче:
go Cleanup(Closable{reaper.MustRelease()})

Чего-то типа io.CloserFunc в Go к сожалению нет :)

Таким образом мы просто делегируем освобождение ресурсов (в данном случае закрытие обработчиков) не вышестоящему коду, а вызываемой горутине.

Если же закрывающая функция имеет какое-то более специфическое поведение (например, применяет стратегию MRU для определения порядка), то Close — это уже часть более широкого интерфейса. Его можно скомпоновать (см. пример с DestructibleResource), но серебряной пули всё же не существует — возможно, освобождение ресурсов обработчика со временем станет лишь деталью реализации, а не публичным интерфейсом, и правильнее будет сформулировать зависимость конкретнее чем io.Closer или kdone.Destructor.
Описанные вами проблемы решаются выносом всего «опасного» функционала в маленькие легко тестируемые\читаемые функции. Я лично за 2 года постоянной работы с Go всего пару раз наблюдал проблему которую вы решаете.
Зарегистрируйтесь на Хабре , чтобы оставить комментарий

Публикации

Истории