lenec ru

← все посты

Интерфейсы в Go: маленькие vs большие, accept interfaces, return structs

11K

Когда я пришёл в Go из Java, первой реакцией на интерфейсы было «где аннотации implements». Через пару проектов привык к утиной типизации, но потом долго не мог определиться: делать интерфейсы маленькими или собирать всё в большой UserRepository. Сейчас, после девяти лет, ответ для меня устоялся, но видно, что в команде у каждого свой подход.

Рассмотрю, чем отличаются разные стили описания интерфейсов в Go, почему «accept interfaces, return structs» — это не догма, и как обычно получается компромисс на практике.

Маленькие интерфейсы: один-два метода

Самые известные примеры — io.Reader, io.Writer, fmt.Stringer. По одному методу, и весь stdlib строится вокруг них.

type Reader interface {
	Read(p []byte) (n int, err error)
}

Удобство: любой тип, у которого есть Read([]byte) (int, error), автоматически удовлетворяет интерфейсу. Можно писать функции вроде io.Copy(dst io.Writer, src io.Reader), и они работают с файлами, сетевыми соединениями, gzip-стримами, вообще со всем.

Плюсы:

  • Легко тестировать — мокается одной функцией.
  • Композируется. io.ReadCloser = Reader + Closer.
  • Не зависит от деталей реализации. Реализатор не знает про интерфейс.

Большие интерфейсы: как у бизнес-репозиториев

В реальных сервисах часто видишь такое:

type UserRepository interface {
	Create(ctx context.Context, u User) error
	Get(ctx context.Context, id string) (User, error)
	Update(ctx context.Context, u User) error
	Delete(ctx context.Context, id string) error
	List(ctx context.Context, filter UserFilter) ([]User, error)
	CountByStatus(ctx context.Context, s Status) (int, error)
}

Это нормально, и не надо за это ругать архитектора. Большой интерфейс — это явный контракт сервисного слоя с хранилищем. Тут другая логика: интерфейс описывает не «способность Read», а «всю поверхность работы с пользователями».

Минусы:

  • Мокать дорого — нужно подделывать все методы, даже если тест использует один.
  • При добавлении метода обновляешь все моки.
  • Часть методов добавляется «на будущее» и никогда не используется.

Решение мокинга — использовать moq, mockery, или генерить моки автоматически. Это не идеально, но снимает большую часть боли.

«Accept interfaces, return structs»

Эта фраза часто приводится без объяснений. Что она реально значит:

// плохо
func NewService(db *postgres.Client) *Service

// хорошо
func NewService(db UserStore) *Service
func (s *Service) GetUser(ctx context.Context, id string) (*User, error)

Принимаем UserStore — узкий интерфейс с теми методами, что реально нужны сервису. Возвращаем конкретный *Service, без интерфейса.

Зачем так:

  • Принимая интерфейс, мы делаем сервис тестируемым: подсунем мок.
  • Возвращая структуру, не плодим лишние интерфейсы. Если кто-то захочет интерфейс — сделает его на своей стороне с теми методами, которые ему нужны.

Это работает потому, что в Go интерфейсы implicit: тебе не надо менять код реализации, чтобы он соответствовал новому интерфейсу.

Где правило не работает

Есть случаи, когда возврат интерфейса оправдан.

Фабрики разных реализаций

type Cache interface {
	Get(key string) ([]byte, bool)
	Set(key string, val []byte, ttl time.Duration)
}

func NewCache(cfg CacheConfig) Cache {
	switch cfg.Type {
	case "redis":
		return newRedisCache(cfg)
	case "memory":
		return newMemoryCache(cfg)
	}
	return nil
}

Тут нет одной конкретной структуры — есть несколько реализаций под общий контракт. Возвращать что-то одно бессмысленно.

Возврат через pkg-private реализацию

// внутри пакета
type tokenStore struct { /* ... */ }

// наружу даём только интерфейс
type TokenStore interface {
	Issue(...) (Token, error)
	Revoke(...) error
}

func NewTokenStore() TokenStore {
	return &tokenStore{}
}

Иногда хочется, чтобы наружу пакета не утекали внутренние поля. Тогда возвращаем интерфейс, а реализация остаётся private. На большом коде это даёт чистый API пакета.

Куда положить определение интерфейса

Самый частый спор в команде. Два варианта:

В пакете-провайдере

// repository/users.go
type Store interface { /* ... */ }
type postgresStore struct { /* ... */ }
func New(db *pgx.Pool) Store { /* ... */ }

Интерфейс рядом с реализацией. Просто, но не очень идиоматично для Go.

В пакете-потребителе

// service/users.go
type userStore interface {
	Get(ctx context.Context, id string) (User, error)
	Save(ctx context.Context, u User) error
}

type Service struct {
	store userStore
}

Это «accept interfaces» в чистом виде. Сервис описывает, что ему нужно — узкий интерфейс. Реализация в другом пакете не знает про этот интерфейс. Когда метод в реализации меняется, проверка на соответствие случается при инъекции в NewService.

Я почти всегда выбираю второй вариант. Минус — если интерфейс используется в трёх сервисах, его описание дублируется. Но дубликаты эти короткие, и каждый сервис описывает только нужные ему методы.

Эмбеддинг интерфейсов

type ReadWriter interface {
	io.Reader
	io.Writer
}

Это удобно для составления контрактов. Используется в stdlib (io.ReadCloser, io.ReadWriteCloser). В пользовательском коде применяю осторожно: иногда хочется записать «X = A + B», но потом X начинает жить своей жизнью, а композиция остаётся неявной.

Дженерики и интерфейсы

С Go 1.18 у нас есть type parameters. Часть случаев, где раньше бы написали интерфейс с одним методом, можно теперь решить дженериками:

// Раньше
type Less interface {
	Less(other any) bool
}
func Min(a, b Less) Less { /* ... */ }

// Сейчас
func Min[T cmp.Ordered](a, b T) T {
	if a < b {
		return a
	}
	return b
}

Где интерфейс работает со значениями разных типов через type assertion и reflection — там дженерики чище. Но интерфейсы по-прежнему нужны для описания поведения, а не структуры данных.

Антипаттерны, которые до сих пор встречаются

Интерфейс ради интерфейса

type UserService interface {
	CreateUser(ctx context.Context, u User) error
}

type userService struct { /* ... */ }
func (s *userService) CreateUser(ctx context.Context, u User) error { /* ... */ }

Интерфейс с одной реализацией, который никем не подменяется. Если завтра появится вторая реализация — добавишь интерфейс тогда. Сейчас он просто шум.

Интерфейсы из чужого пакета

// my_service/db.go
type DBClient = postgres.Client // type alias

Иногда вижу: вместо своего интерфейса делают alias на конкретный тип из библиотеки. Тестируемость пропадает, абстракция нулевая. Если уж делаешь свой alias — пусть это будет интерфейс.

Слишком абстрактные имена

Manager, Service, Handler, Provider — самые бесполезные имена интерфейсов. По ним нельзя понять, что внутри. Лучше UserStore, PaymentRouter, TokenIssuer. Имя интерфейса должно намекать на конкретный контракт.

Что в итоге

Несколько правил, которыми руководствуюсь сам:

  • Маленькие интерфейсы — лучше. Если не получается, делай большой, но осознанно.
  • Описывай интерфейс там, где он используется, а не там, где реализуется.
  • Возвращай конкретный тип, если нет нескольких реализаций.
  • Не делай интерфейс, пока не видишь второй реализации или не нужно для тестов.
  • Не плоди абстрактных названий: Manager — плохо, UserCounter — хорошо.

Со временем интерфейсы перестают быть инструментом «про ООП» и становятся способом описать минимальный контракт между двумя кусками кода. Чем меньше этот контракт — тем легче с ним жить, проще тестировать и проще менять обе стороны независимо.

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

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.