golang

Кастомные uuid на базе дженерика

  • среда, 4 февраля 2026 г. в 00:00:08
https://habr.com/ru/articles/992396/

Уже более 6 лет я использую кастомные идентификаторы - одна из классных штук, за которые обожаю go. Они незаменимы в описании бизнес-логики - невозможно перепутать порядок идентификаторов. Все вызовы становятся типобезопасными и самодокументируемыми. И писать такой легкочитаемый код очень приятно. Применяю кастомные id очень широко - от парсинга http-запроса до слоя данных.
В статье подробно рассказываю:

  • "как было" раньше (и осталось для кадастровых номеров, например),

  • переход от кастомизации строк к скрепным uuid.UUID и варианты типизации,

  • немного запутаемся в важных условиях кастомизации,

  • скопипастим компактное и готовое решение

  • и бонусом посмотрим лаконичный синоним в коде.

UUID - наше всё для идентификаторов

UUID весьма длинный для идентификатора - 16 байт в массиве или 36 символов в строке. И если таблица маленькая, то выглядит как оверхед. Но популярность высока и не собирается снижаться. Вспомним некоторые моменты, чем хороши UUID для бизнеса:

  • в первую очередь становится невозможным перебор сущностей. Если в запросе видно "пользователь #5432", то есть обоснованные подозрения на наличие других 5431 пользователей.

  • идентификаторы относительно легко различаются при рандомной генерации вместо, например, натуральных 98674564743 и 98674664743,

  • еще можно придумывать красивые зарезервированные значения вида 00000000-0000-4000-8000-face2222face,

  • даже в малых таблицах редкоизменяемых справочников uuid-идентификатор рулит, потому что сложнее накосячить при синхронизации.

  • и с рандомайзером почти невозможно сгенерировать одинаковый идентификатор и т.д. и т.п.

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

Кастомизация, "как было" и почему всё работало

По кастомизацией я подразумеваю новый тип, который не сравним с родителем и родственниками, но "совместим" с ними через приведение типа. Очень важно различать alias и новый тип:

type OrgID string // новый кастомный тип
type RoleID = string // alias = синоним

s := "text"
var org OrgID
var role RoleID

role = s // синоним можно присваивать без приведения, это один тип!
org = s  // ошибка типа
org = OrgID(s) // ok и это важно!

Еще пример, потому различие важное:

func doNothing (org OrgID, role RoleID) error {
	return nil
}

doNothing(org, role) // ожидаем

// цель типобезопасности - получить ошибку
doNothing(role, org) // ошибка при нарушении порядка
doNothing(org, doc) // ошибка другой сущности

// и в коде могут быть нюансы с константами
doNothing(org, s)      // ok, но жду роль!
doNothing(org, "role") // ok, потому что текст!

// кастомизация это решает
doNothing(s, role)     // ошибка! и сразу видно
doNothing("org", role) // ошибка! потому что ждем НЕ текст!

И еще один пример, потому что это ОЧЕНЬ важное различие:

if role == org {}    // ожидаемая ошибка типов
if role == "role" {} // нет ошибки, но сравнение со строкой
if role == s {}      // но хочу ошибку для кастомного типа

// правильное поведение
if org == "org" {}        // ошибка
if org == OrgID("org") {} // ожидаемое решение
if org == role {}         // хочу ошибку здесь
if role == RoleID(org) {} // но иногда хочу сравнивать с приведением

Обобщу в пунктах:

  • я хочу видеть ошибку при сравнении и подстановке идентификаторов различных сущностей,

  • но хочу иметь возможность прямого приведения родственных типов, так как в своей сути они одной природы,

  • и очень хочется нативной работы всех модулей без костылей!

Последнее замечание требует пояснения. "Из коробки" приведение типов работает только при кастомизации простых типов:

type DocID string
type NoteID string
var (  
    n NoteID = "face"  
    x DocID = Doc(n)
)

// но когда попытаетесь сделать так
type DocID uuid.UUID
type NoteID uuid.UUID
var (  
    n NoteID = NoteID(uuid.MustParse("eba3c7d5-64a5-4e71-9fc3-13e756df2b99"))  
    x DocID = Doc(n)
)

// то ошибки компиляции нет и даже можно сравнить через приведение,
// но новый тип не наследует методы uuid, и будет ошибка сериализации 
// в http-парсинге или в каком-то другом непредсказуемом месте

То есть парсинг и сериализация сложного значения требует индивидуальных методов сериализации MarshalText()/UnmarshalText(). Если методы не определены, то json-ответ будет совсем не таким правильным как вы этого ожидаете! Плюс важны методы IsZero(), Parse(), Equals() для логики и Scan()/Value() для бд-запросов. Прямым решением является переопределение всех этих модулей ДЛЯ КАЖДОГО нового кастомного типа. Что как минимум некрасиво - у меня в коде 16 кастомных идентификаторов только в глобальном models плюс локальные. И я не люблю кодогенерацию.

Моё решение было удобным и несовершенным - я несколько лет использовал кастомные string-идентификаторы! Это решало вопросы типобезопасности. И, если честно, то в начале мне не хотелось понимать подкапотную работу голанга - были более интересные задачи, и устроило "строки работают!". Но маленький червячок грыз, что это не оптимальное решение по памяти - 16-байтный идентификатор весит полных 36 байтов в памяти на много штук на каждый запрос.

Альтернативное решение - юзать некастомизированные uuid.UUID. Так как множество дубликатов переопределяемых методов для множества идентификаторов - это места для копипасты и тупых ошибок. И я не был готов отказаться от типобезопасности и самодокументируемого кода. Да и разумеется, вернуться "когда-нибудь" от стандартного типа к кастомным будет намного труднее чем сразу.

Важное замечание, что решение было принято еще ДО ПОЯВЛЕНИЯ ДЖЕНЕРИКОВ. Я долго смирялся с несовершенным и удобным, и вот нам завезли дженерики! Задача перехода на кастомные uuid.UUID была внесена в список важных несрочных дел. И вот несколько месяцев назад нашел время вяло поиздеваться над ии-кой, и я наконец реализовал хороший вариант с кастомными uuid-идентификаторами. И делюсь своим простым и красивым решением.

Если "кастомность" всё равно осталась непонятной, то визуально кастомные идентификаторы против обычных выглядят так:

// обычные можно обобщить
func RolesByMember(org, user, proxy uuid.UUID) ([]*mod.Role, error)

// а с кастомными получаем подсказку и проверку типа
func RolesByMember(org models.OrgID, user models.UserID, proxy models.ProxyID) ([]*mod.Role, error)

// но можно писать более красиво, и m-синоним описан в бонусной части 
// ближе к концу статьи
func RolesByMember(org m.OrgID, user m.UserID, proxy m.ProxyID) ([]*mod.Role, error)

"Как стало" и вообще о решении

Итак, ЗАДАЧА:

  • Под капотом должен быть uuid.UUID aka [16]byte!

  • Работающая сериализация в http-запросах и запросах в бд - в обе стороны или туда-и-обратно!

  • Uuid-родственники не могут быть сравнимы напрямую - хочу ошибку компиляции!

  • И хочу прямое приведение uuid-родственников в обе стороны! И способ через методы x.ToY(), когда у всех под капотом [16]byte непримемлем!

На входе у меня есть модуль models для глобальных идентификаторов (какие-то сущности участвуют во многих модулях, и это даже не обычный пользователь):

type (
	UUID string
	UserID UUID // пользователь  
	ProxyID UUID // доверенность  
	OrgID UUID // сообщество  
	RoleID UUID // роль в сообществе  
	WgID UUID // рабочая группа  
	...
)

// и напомню что не пишем = (так все типы будут равны одному типу)
type (
	UserID = uuid.UUID // пользователь  
	ProxyID = uuid.UUID // доверенность  
	...
)

// и после хочу писать все методы от uuid.UUID
type (
	UserID uuid.UUID // пользователь  
	ProxyID uuid.UUID // доверенность  
	...
)

Проблема - голанг считает ИЛИ ОДНИМ ТИПОМ, ИЛИ РАЗНЫМИ! Я потратил приличное количество времени мучая ИИшку, чтобы выработать красивое решение. В основном ии-решения сводились к некрасивым предложениям вида:

// 1. Структура с уникальным полем - это чемодан с оторванной ручкой!
// Структуры уже не сравнимы, но и невозможно прямое приведение
type DocID struct {
	uuid.UUID
	x struct{}
}

// 2. Так выглядить старое проверенное решение, которое не наследует 
// методы родителя, но в виде решения ии-шка настойчиво предлагает 
// кодогенерацию недостающих методов
type NewID uuid.UUID

// 3. Начинаешь требовать больше - ии-шка пробует обмануть. Такое работает, 
// но синоним и сравнивается, из-за чего теряется весь смысл
type NewID[T any] = uuid.UUID

// 4... когда начинаешь приставать к ии-шке с чем-то умным, то она сливается 
// и пишет откровенную хрень с методами, которых вообще нет в природе

// 5... особо доставляет, когда в ии-снерерированных методах 
// сериализации видишь нарушение логики из-за влияния других языков. 
// Например, UuidNil пытается сериализовать в "null" вместо нормального 
// "00000000-0000-0000-0000-000000000000"

Далее я уже понял, что иишка тупит, и начал изучать и прорабатывать нативный и перспективный вариант через пакет reflect, вдумчивое прочтение go-блога и познание мира через древний метод "гугление":

type ID[T any] uuid.UUID

Выводы по итогам исследования:

  1. Если присутствует "=", то разрешено сравнение - comparable. Что мне не надо категорически, и вариант alias я вычеркиваю.

  2. Если новый тип (без "="), то в любом случае НЕ НАСЛЕДУЕТ МЕТОДЫ, и ИХ ПРИДЕТСЯ добавить. Но все еще не хочется кодогенерацию!

  3. РЕТРОСПЕКТИВА: с type ID string работало, потому что это простой тип! и даже type ID [16]byte сработало бы. Но uuid.UUID - это уже тип с методами!

  4. Если alias, то методы наследуются. И я снова дружу с синонимами!

  5. Но я не хочу сравнивать, то есть мне нужно несравнимое поле, а поле - это структура.

  6. Важно! Если под капотом структура, то никак не будет явного приведения типов...

Жесть, да?! Не буду пересказывать все мытарства, перелистнем к моменту, где я наткнулся на красивую штуку, которую ии-шка назвал "маркерным типом":

// базовый дженерик-тип ожидает методы оригинала!
type ID[T any] uuid.UUID

// теперь alias, чтобы наследовать методы, и уже работает UUID<-->DocID
type DocID = ID[string]
// но если другой тип
type RoleID = ID[string]
// то это уже синоним
if doc == role {} // нет ошибки

// а если добавить другой тип, то уже нельзя сравнить
type OrgID = ID[int]
OrgID == RoleID // ошибка!

// важно! функционал уже работает! но вот неприятность: простых типов 
// сильно меньше чем кастомных типов!

// это работающий вариант, где магия в том, что USER не comparable с PROXY
type (
	User string
	UserID = ID[User] // пользователь  
  
	Proxy string
	ProxyID = ID[Proxy] // доверенность
)

// ФИНАЛЬНЫЙ маркерный тип - не импортируемый и вообще не string
type (  
    UserID   = ID[markUser] // пользователь  
    markUser struct{}       // используется только строкой выше!
  
    ProxyID   = ID[markProxy] // доверенность  
    markProxy struct{}
)

МОЕ РЕШЕНИЕ:

  1. Базовый дженерик-тип в модуле /models, в котором полсотни строк наследованных методов.

  2. Кастомные uuid-идентификаторы в глобальном /models и локальных /mod, которые будучи синонимом наследуют методы базового дженерик-типа, но при этом они не сравнимы между собой из-за разного маркера.

  3. Под капотом остается обычный uuid.UUID, и доступно приведение в любом направлении. И в каких-то случаях я могу сравнить с явным приведением.

  4. Легко расширяемо - не нужны методы с явными приведениями структуры к другим структурам.

  5. ВАЖНО! Можно парсить и генерировать оригинальными uuid.Parse() и uuid.New() в виде простого DocID(uuid.New()).

ВЕСЬ БАЗОВЫЙ ДЖЕНЕРИК-ТИП /models/typed-uuid.go

package models  
  
import (  
    "database/sql/driver"  
    "github.com/google/uuid"
)
  
// базовый дженерик-тип  
type ID[T any] uuid.UUID  
  
// обычный uuid.UUID  
func (id ID[T]) UUID() uuid.UUID {  
    return uuid.UUID(id)  
}  
  
// сравнение с нулевым значением uuid.Nil  
func (id ID[T]) IsZero() bool {  
    return uuid.UUID(id) == uuid.Nil  
}  
  
// реализует Stringer
func (id ID[T]) String() string {  
    return uuid.UUID(id).String()  
}  
  
// текстовая сериализация - encoding.TextMarshaler
// json.Marshal(), xml.Marshal(), и т.д.
func (id ID[T]) MarshalText() ([]byte, error) {  
    return uuid.UUID(id).MarshalText()  
}  
  
// текстовая десериализация - encoding.TextUnmarshaler
// pointer! json.Unmarshal(), xml.Unmarshal(), и т.д.
func (id *ID[T]) UnmarshalText(text []byte) error {  
    return (*uuid.UUID)(id).UnmarshalText(text)  
}  
  
// запись в БД - driver.Valuer
// db.Exec(), подготовленные выражения
func (id ID[T]) Value() (driver.Value, error) {  
    return uuid.UUID(id).Value()  
}  
  
// чтение из БД - sql.Scanner 
// pointer! rows.Scan(), db.QueryRow().Scan()
func (id *ID[T]) Scan(value any) error {  
    return (*uuid.UUID)(id).Scan(value)  
}  
  
// реализует encoding.BinaryMarshaler  
func (id ID[T]) MarshalBinary() ([]byte, error) {  
    return uuid.UUID(id).MarshalBinary()  
}  
  
// реализует encoding.BinaryUnmarshaler /pointer!/
func (id *ID[T]) UnmarshalBinary(data []byte) error {  
    return (*uuid.UUID)(id).UnmarshalBinary(data)  
}

// список идентификаторов  
type IDs[T any] uuid.UUIDs  
  
func (id IDs[T]) Strings() []string {  
    return uuid.UUIDs(id).Strings()  
}

КАСТОМНЫЕ ТИПЫ в /models/uuid.go

package models  
  
// Конкретные + Маркерные типы  
type (  
    UUID = uuid.UUID // синоним базового типа (uuid-пакет только в models!)
  
    UserID   = ID[markUser] // пользователь  
    markUser struct{}  
  
    ProxyID   = ID[markProxy] // доверенность  
    markProxy struct{}  
  
    OrgID   = ID[markOrg] // сообщество  
    markOrg struct{}  
  
    RoleID   = ID[markRole] // роль в сообществе  
    markRole struct{}
    ...    
)  
  
// VAR=CONST
var (
	// так резервирую константы для логики
    UserVK = UserID(uuid.MustParse("eba3c7d5-64a5-4e71-9fc3-13e756df2b99"))
    ProxySudo = ProxyID(uuid.MustParse("00000000-0000-4000-8000-face4444face"))  
    
    // для проверки есть IsZero(), но бывает, что отправляю в бд как значение
    UuidNil = uuid.Nil  
    User0   = UserID(uuid.Nil)  
    Proxy0  = ProxyID(uuid.Nil)  
    Org0    = OrgID(uuid.Nil)  
)  
  
// синонимы функций оригинального пакета
var (  
    ParseUUID = uuid.Parse  
    NewUUID   = uuid.New  
)  

ПРИМЕРЫ

// в парсинге с валидацией
type formOrgWg struct {  
    Org m.OrgID `json:"org" validate:"required,uuid"`  
    Wg  m.WgID  `json:"wg" validate:"required,uuid"`  
}

// проверка в логике
if f.Org == m.Org0 {...}
// или метод
if f.Org.IsZero() {...}
// или с приведением
if f.Doc == m.DocID(f.Org) {...}

// и новый идентификатор для новой сущности
org := mod.Organization{
	ID: m.OrgID(m.NewUUID()),
	...
}

// вызовы в слой данных c m-синонимом models
func RolesMembersDelete(ctx m.Ctx, r m.Repo, org m.OrgID, role m.RoleID, user m.UserID, proxy m.ProxyID) error {}

M-модуль или комфортная лаконичность! (бонус)

Про кастомизацию всё. Здесь добавлю про синоним модуля в повсеместных импортах в golang-проекте. Тема нетороплива выросла из запросов в слой данных. Сначала и до какого-то момента я писал "как учили" в виде repository-структуры с методами:

// уже давно так не пишу, пардоньте за ошибки
type Dbase struct{
	db *sqlx.DВ
}

// и дробил на подразделы с дублированием в глобальном models
func (db *Dbase) Documents() models.DocumentsRepository {
	return &DocRep{db: db.db}
}

// для которых множество методов вида
func (r *DocumentRep) ReadDocument(id models.DocID) (data, err) {...}

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

  1. Когда слой данных перерастает 50 штук, то уже боишься думать о тестах, потому что супер-объект! А когда вырастает в 200-300 запросов в бд, то про тесты и не вспоминаешь, тяжело просто синхронизировать интерфейс.

  2. Хочется один модуль на одну таблицу, но в логике это невозможно. Появляются грязные модули, где в одном методе запросы в несколько таблиц. И это больно!

  3. Когда дорастаешь до транзакций, то становится по-настоящему больно. Я видел решение, где транзакция поднимается из контекста, но это очень некрасивое решение, и я не тащил такое в свой код принципиально. Не надо пихать аргументы в контекст!

  4. Между любыми базами есть различия, и, внезапно, базы не особо взаимозаменяемые. Если используешь jsonb в postgres, то менять базу данных как-то и не на что.

  5. И есть уверенность, что в postgres я буду торчать очень долго.

То есть я перестал планировать менять базы данных как перчатки. И выдохнув начал писать спокойное:

func ReadDocument(r *sql.DB, id models.DocID) (data, err) {...}

Так гораздо удобнее, потому что работает кодекс го-разработчика "все бизнес-аргументы надо получать явно" и "нельзя зависеть от скрытой логики контекста". Да и через поля и обертки объекта можно получить неявные ошибки.
Но в такой красивой строчной формой С КОНТЕКСТОМ И КАСТОМНЫМИ идентификаторами можно получить очень длинные конструкции вида вплоть до:

func RolesMembersDelete(
	ctx context.Context,
	r *sql.DB,
	org models.OrgID,
	role models.RoleID,
	user models.UserID,
	proxy models.ProxyID) error {

На каждый вызов! И местами становится больно - я долгое время не пробрасывал контекст в слой данных из-за "лишних" 20 символов в строке! И нельзя обойтись без models.*. И годами думал, как же сделать все красиво! И задача не решалась покупкой 27-дюймового монитора! Только где-то полтора года назад (и вроде здесь на Хабре, но уже не уверен) увидел красивый описательный способ, который дарю вам в этот холодный февральский день.

Состоит из частей:

  1. Внезапно оказалось, что от *sql.DB нам нужны только несколько методов, и все их мы можем перечислить в models. Теперь в слое данных вызываем не метод объекта, а получаем метод интерфейса, под которым может быть как база данных, так и транзакция. И теперь все запросы нескольких таблиц красиво разделены и лежат в правильных модулях, и даже могут быть вызваны из своего internal-окружения в другом модуле через публичную обертку! А транзакция красиво лежит в синхронном коде обработчика.

  2. В models добавляем короткие синонимы Ctx для контекста и Repo для репозитория. И другие синонимы по любимым хотелкам.

  3. И режем буквы импорта - повсеместные models сокращаем за синонимом "m"!

ИЛИ подробнее в коде:

package models

type Ctx = context.Context // синонимом режем буквы оригинального наименования

// красивое выравнивание в полях структур, и решаем навязчивый выбор json-модуля
type RAW = json.RawMessage

// sqlx - интересная библиотека, которая всё упрощает до одиночной структуры 
// или массива! рекомендую, но будет тяжело соскочить в pgx из-за удобства
type Tx = *sqlx.Tx

// внезапно! оказывается, что в слое данных у нас конечное число вызываемых 
// методов. И в значении интерфейса может быть не только сама база, но и транзакция
type Repo interface {  
    GetContext(ctx context.Context, dest any, query string, args ...any) error  
    SelectContext(ctx context.Context, dest any, query string, args ...any) error  
    NamedExecContext(ctx context.Context, query string, arg any) (sql.Result, error)  
    ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)  
    QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)  
}

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

import (  
    "core/internal/dmn2org/internal/mod"  
    m "core/internal/models"  
)

// где m - глобальй models
// а mod - локальный mod(els) internal-модуля
func RolesRead(ctx m.Ctx, r m.Repo, org m.OrgID, role m.RoleID) (*mod.Role, error) {  
    const op = "d2.store.RolesRead"  
  
    query := `  
       SELECT org, role, named, manifest, journal
       FROM org_roles
       WHERE org = $1 AND role = $2
    `  
    var data mod.Role
    // в r-интерфейсе может быть как база данных, так и транзакция!
    err := r.GetContext(ctx, &data, query, org, role)  
    return &data, m.CatchErrDB(op, "ошибка роли сообщества", err)  
}

И МАГИЯ ТРАНЗАКЦИИ делается в одном месте синхронного кода обработчика:

// возможны отдельные запросы через интерфейс в реплику srv.ReadDB() или 
// мастер-базу srv.MasterDB()
data, err := store.WorkgroupsRead(ctx, srv.ReadDB(), f.Wg, f.Org)
err = store.WorkgroupsUpdate(ctx, srv.MasterDB(), data)

// или запись в несколько таблиц мастер-базы в транзакции с контекстом
tx := srv.StartTxx(ctx) // старт транзации с контекстом!
defer tx.Rollback()

// в аргументе tx-транзакция вместо бд
err = store.WorkgroupsDelete(ctx, tx, data.Wg)
err = tx.Commit()

Резюмирую:

  1. Кастомизация типов вкупе с типобезопасностью голанг - мощный инструмент разработчика для написания самодокументируемого кода.

  2. Кастомизация типов отлично работает даже на строках. Из коробки уже всё работает: типобезопасность, приведение и v10-валидация. Проверено годами разработки. И буду и дальше кастомизировать строки: кадастровый номер, например.

  3. Когда хочется кастомизировать более интересный тип uuid.UUID, то возникают сложности с наследованием методов и явным приведением. Обычное решение - кодогенерация наследуемых методов. Но найдено более интересное решение на дженерике и маркерных типах.

  4. Рефакторинг кодовой базы бэкенда занял 1,5 часа. Помогала типобезопасность голанга. Массово это были правки сравнений вида if DocID != "" {} на нулевое uuid-значение if DocID != m.Doc0 {}.

  5. Рефакторинг кодовой базы фронтенда занял несколько дней, так как в нем была неявная логика на пустой идентификатор вида !user.ID ? x : y, и здесь потребовалось полное ручное тестирование.

  6. Кастомизация типов заметно удлиняет строку аргументов функции, так как пропадают обобщения вида func (org, user, proxy, role, ctc uuid.UUID), и требуется полностью перечислять типы. Тут предложено решение в виде 'm'-синонима глобального models, которое проверено полутора годами активной разработки, и отлично поддерживается средами разработки.

P.S. Рефачу код давно и с удовольствием! В поиске команды и проекта для @victor_kupriyanov.