В коде на Go всё чаще встречается одна и та же картина, почти перед каждым обращением к указателю стоит
if x != nil. Выглядит как разумная подстраховка, но часто это сигнал, что в системе уже не понятно, какие значения действительно могут быть nil, а какие нет, и проверяется всё подряд на всякий случай.➡️ Проверка на зависимость
Структура
RateLimiter хранит клиент Redis:func (r *RateLimiter) Allow(ctx context.Context, req *Request) (bool, error) {
userID := GetUserID(req)
if r.redis != nil {
return r.checkLimit(ctx, userID)
}
return false, nil
}Проверка
r.redis != nil ничего не лечит. Если клиент равен nil, ошибка произошла раньше, при инициализации, и проверка здесь просто позволяет коду работать дальше в уже сломанном состоянии. В Go лучше падать сразу и громко, чем тащить невалидное состояние по системе.
Перенос проверки в конструктор
NewRateLimiter выглядит лучше, но тоже не решает задачу до конца, потому что nil всё равно успевает дойти до конструктора. Правильное место для обработки ошибки это сама точка создания клиента:redisClient, err := NewRedisClient(addr)
if err != nil {
return nil, err
}
limiter := NewRateLimiter(redisClient)
Если клиент создан успешно, дальше по системе он уже не может быть
nil, и проверки внутри RateLimiter просто не нужны. Если же системе важно переживать временную недоступность Redis, это стоит явно смоделировать отдельным типом, который сам внутри себя справляется с повторами, а наружу всегда отдаёт нечто гарантированно рабочее.➡️ Громкий отказ
Частый аргумент в пользу лишних проверок звучит так, не хочу ронять программу из-за своего изменения, лучше залогировать и продолжить. Только выбор здесь не между падением и продолжением работы, а между громким отказом и тихим.
Явная ошибка заметна сразу, привязана к месту, где она произошла, и понятно, какая операция не удалась. Проглоченная ошибка устроена наоборот, она всплывает позже, после того как уже отработал другой код, и к моменту, когда виден симптом, найти причину сложнее.
На тихие отказы потом приходится тратить силы отдельно, выстраивая метрики и алерты, чтобы заметить то, что сами же и спрятали.
➡️ Где проводить границу
Полезно думать о коде как о внешнем и внутреннем слое. Внешний слой это там, где данные входят в программу, например HTTP-обработчик. Внутренний слой это код, до которого эти данные доходят дальше по стеку вызовов:
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
req, err := DecodeRequest(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
allowed, err := h.limiter.Allow(r.Context(), req)
// ...
}Проверять запрос на
nil нужно один раз, на границе, в момент декодирования. После этого Allow уже может работать с запросом как с доверенными данными, без единой проверки на nil внутри.Проверка на
nil уместна, когда она охраняет границу системы или моделирует осознанно опциональное состояние. Если же она тихо обрабатывает состояние, которое по замыслу вообще не должно быть возможным, это уже сигнал проблемы в дизайне. Решение тогда не в том, чтобы добавить ещё проверок, а в том, чтобы постепенно установить инварианты, на которые сможет опираться остальной код.➡️ Оригинал
📍 Навигация: Вакансии • Задачи • Собесы
🐸 Библиотека Go-разработчика
#GoDeep