Куда складывать функции-утилиты в Go

Почти в каждом Go-проекте рано или поздно заводится пакет utils. Сначала туда кладут пару безобидных хелперов. Через год это свалка, в которой никто ничего не находит. У меня есть простое правило, которое решает проблему: вспомогательные функции надо резать по доменам, а не сваливать в один общий пакет.

Расскажу, почему utils плохо масштабируется и как я раскладываю хелперы у себя.

Почему utils превращается в свалку

Корень проблемы в самом слове. utils ничего не говорит о содержимом. Имя пакета не сообщает вам, что внутри. Поэтому со временем туда попадает всё подряд.

Дальше включается простая динамика. Нужна функция перевести строку в число? Кладём в utils. Нужно обрезать пробелы по-особому? Туда же. Через полгода у вас файлы helpers.go, functions.go и какой-нибудь misc.go. Где именно лежит нужная функция, вы уже не помните.

И чем больше в utils функций, тем сильнее соблазн закинуть туда ещё одну. Пакет растёт без ограничений. Это junk drawer проекта. Ящик, куда складывают то, чему не нашли места.

Правило: режьте по домену

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

Каждый пакет делает что-то одно. Это совпадает с философией Go: маленькие пакеты с понятной зоной ответственности.

Главный вопрос здесь такой: нет ли у функции естественного дома. Отдельный util-пакет это не первый выбор, а последний. Сначала проверьте, не относится ли функция к уже существующему пакету по смыслу. Если относится, ей место там, а не в общей куче.

Возьмём конкретный пример. Куда положить функцию StrToInt? Если она нужна только для одного домена (скажем, разбирает поля модели), её место в этом домене. Если это общий хелпер по строкам, ему место в stringutil. В utils он попадает только тогда, когда вы не подумали, куда его положить.

Было:

// Пакет-свалка: по имени непонятно, что внутри.
package utils

func StrToInt(s string) (int, error) { /* ... */ }
func ParseJSON(data []byte, v any) error { /* ... */ }
func BuildQuery(filters map[string]string) string { /* ... */ }

Стало:

// stringutil/strconv.go
package stringutil

func ToInt(s string) (int, error) { /* ... */ }
// jsonutil/json.go
package jsonutil

func Parse(data []byte, v any) error { /* ... */ }

Чтение кода сразу меняется. stringutil.ToInt и jsonutil.Parse объясняют сами себя. По месту вызова видно, из какой области функция.

Про конвертацию строк я писал в статье про работу со строками в Go. Про разбор JSON есть отдельная статья. Это как раз кандидаты в stringutil и jsonutil.

Мой паттерн: utils/<домен>util

В своих проектах я держу такие пакеты под общей директорией utils. Получается ./utils/jsonutils, ./utils/searchutils и так далее.

Здесь важный нюанс, из-за которого многие спорят. utils в этом случае не пакет. Это просто директория, неймспейс. В самой папке utils/ нет ни одного .go-файла. Пакетом является уже jsonutils.

utils/
  jsonutils/
    json.go      // package jsonutils
  searchutils/
    search.go    // package searchutils
  stringutils/
    strconv.go   // package stringutils

Антипаттерн здесь один: единый пакет utils со всем подряд. Папка utils/ с подпакетами антипаттерном не является. Импорт выглядит как myproject/utils/jsonutils, в коде вы пишете jsonutils.Parse. Группировка в одной директории помогает не растерять эти пакеты по дереву, но каждый из них остаётся узким и самостоятельным.

Идеально ли это? Нет. Чище было бы держать stringutils и jsonutils на верхнем уровне, без обёртки utils. Но для проектных хелперов, которых много, общий неймспейс удобнее. Главное, что граница режется по домену, а не по принципу «удобно свалить в кучу».

Код живёт там, к чему относится

На эти грабли наступила сама стандартная библиотека Go. Долгое время в ней жил пакет io/ioutil. Внутри лежала пёстрая смесь: чтение файла целиком, запись файла, чтение директории, временные файлы, NopCloser, Discard. Объединяло их только то, что все они «утилиты для ввода и вывода».

В Go 1.16 пакет признали устаревшим (deprecated). Russ Cox в предложении golang/go#42026 сформулировал причину прямо:

io/ioutil, like most things with util in the name, has turned out to be a poorly defined and hard to understand collection of things.

То есть ioutil, как и почти всё со словом util в имени, оказался плохо определённой и трудной для понимания свалкой. Классика.

Функции не выкинули. Их растащили туда, где им место. ReadAll и NopCloser уехали в io по предложению golang/go#40025. Файловые ReadFile, WriteFile, ReadDir и работа с временными файлами уехали в os. Часть заодно переименовали: TempFile стал CreateTemp, TempDir стал MkdirTemp. Старые имена оставили обёртками, чтобы не ломать существующий код.

Урок простой. Функция читает файл? Её дом рядом с файловыми операциями, в os, а не в общем util. Если у разработчиков языка ушли годы, чтобы это исправить, то в своём проекте лучше класть код по местам сразу.

Нюансы Go, которые легко упустить

Сама идея простая. Но в Go есть несколько деталей, на которых спотыкаются.

Имя пакета. Стандартная библиотека пишет такие пакеты в единственном числе и без подчёркиваний: httputil в net/http/httputil, тот же ioutil. Я по привычке пишу во множественном: jsonutils. Это расхождение со стилем stdlib. Строгой ошибки тут нет, но если хотите следовать идиоме библиотеки, выбирайте jsonutil. Главное — держаться одного варианта по всему проекту.

Заикание имени (stutter). Не делайте jsonutils.JSONParse. Имя пакета уже несёт контекст, и JSON в имени функции повторяется. Линтеры вроде revive (наследник golint) на это ругаются. Правильно будет jsonutils.Parse. По месту вызова и так видно, что это JSON.

internal/ для проектных хелперов. Если пакет нужен только внутри проекта и не должен утечь наружу как библиотека, положите его под internal/. Например, internal/searchutils. Go гарантирует, что импортировать его смогут только пакеты из того же поддерева. Чужой модуль такой пакет не подключит.

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

Итог

Утилиты в проекте нужны. Но они не обязаны быть болью. Правило, которое держит код в порядке, помещается в одну строку: контекст важнее удобства.

Когда в следующий раз рука потянется создать utils, остановитесь и спросите: к какому домену относится эта функция? Ответ почти всегда подскажет имя пакета. stringutils, jsonutils, searchutils — и свалка не появляется.


Теги: