Команда Go for Devs подготовила материал о том, почему попытка тащить в Go привычные ОО-паттерны часто заканчивается печально, а вот разделение интерфейсов — наоборот, работает почти магически. Разберём, как маленькие интерфейсы избавляют от «интерфейсного ожирения», упрощают тесты и делают код гибче, даже если вы никогда не читали SOLID. А заодно посмотрим, почему огромный S3Client — это архитектурный антипаттерн, замаскированный под благо.
Объектно-ориентированные (ОО) паттерны часто критикуют в сообществе Go — и во многих случаях небезосновательно.
Тем не менее, я убеждён, что такие принципы, как SOLID, несмотря на своё ОО-происхождение, могут служить полезными ориентирами при проектировании кода на Go.
Недавно, общаясь с несколькими коллегами, которые только начинают работать с Go, я заметил, что некоторые из них сами по себе заново «открыли» принцип разделения интерфейсов (Interface Segregation Principle — та самая буква “I” в SOLID), даже не осознавая этого. Польза была очевидна, но без общего понятийного аппарата обсуждать и обобщать идею было куда сложнее.
Поэтому я решил ещё раз взглянуть на ISP в контексте Go и показать, как небольшие интерфейсы, неявная реализация и контракты, определяемые потребителем, делают разделение интерфейсов естественным и приводят к коду, который проще тестировать и поддерживать.
Клиенты не должны быть вынуждены зависеть от методов, которыми они не пользуются.
— Роберт Мартин (SOLID, принцип разделения интерфейсов)
Или, проще говоря: вашему коду не стоит принимать то, что он не использует.
Рассмотрим такой пример:
type FileStorage struct{}
func (FileStorage) Save(data []byte) error {
fmt.Println("Saving data to disk...")
return nil
}
func (FileStorage) Load(id string) ([]byte, error) {
fmt.Println("Loading data from disk...")
return []byte("data"), nil
} У FileStorage есть два метода: Save и Load. Теперь представим, что вы пишете функцию, которой нужно только сохранять данные:
func Backup(fs FileStorage, data []byte) error {
return fs.Save(data)
}Это работает, но здесь скрывается несколько проблем.
Backup принимает именно FileStorage, поэтому она работает только с этим типом. Если позже вы захотите делать резервную копию в память, по сети или в зашифрованное хранилище, функцию придётся переписывать. Поскольку она зависит от конкретного типа, в тестах вам тоже придётся использовать FileStorage, что может означать доступ к диску или другие побочные эффекты, которые не нужны в модульных тестах. И по сигнатуре функции вообще не видно, какую часть FileStorage она реально использует.
Вместо того чтобы зависеть от конкретного типа, мы можем зависеть от абстракции. В Go это достигается с помощью интерфейса. Давайте его определим:
type Storage interface {
Save(data []byte) error
Load(id string) ([]byte, error)
}Теперь Backup может принимать Storage:
func Backup(store Storage, data []byte) error {
return store.Save(data)
}Backup теперь зависит от поведения, а не от конкретной реализации. Вы можете передать ей всё, что удовлетворяет интерфейсу Storage: что-то, что пишет на диск, в память или даже в удалённый сервис. И FileStorage по-прежнему подходит без каких-либо изменений.
Кроме того, функцию можно протестировать с помощью фейка:
type FakeStorage struct{}
func (FakeStorage) Save(data []byte) error { return nil }
func (FakeStorage) Load(id string) ([]byte, error) { return nil, nil }
func TestBackup(t *testing.T) {
fake := FakeStorage{}
err := Backup(fake, []byte("test-data"))
if err != nil {
t.Fatal(err)
}
}Это шаг вперёд. Мы убрали жёсткую связь с конкретным типом и избавили тесты от побочных эффектов. Однако проблема всё ещё есть: Backup вызывает только Save, но интерфейс Storage включает и Save, и Load. Если позже в Storage добавятся новые методы, все фейки тоже придётся расширять, даже если эти методы не используются. Ровно от этого и предостерегает принцип разделения интерфейсов.
Интерфейс выше слишком широкий. Сузим его так, чтобы он соответствовал реальным потребностям функции:
type Saver interface {
Save(data []byte) error
}Теперь обновим функцию:
func Backup(s Saver, data []byte) error {
return s.Save(data)
}Теперь намерение очевидно: Backup зависит только от Save. Тестовый дубль может реализовывать всего один метод:
type FakeSaver struct{}
func (FakeSaver) Save(data []byte) error { return nil }
func TestBackup(t *testing.T) {
fake := FakeSaver{}
err := Backup(fake, []byte("test-data"))
if err != nil {
t.Fatal(err)
}
}Исходный FileStorage при этом продолжает прекрасно работать:
fs := FileStorage{}
_ = Backup(fs, []byte("backup-data"))Неявная реализация интерфейсов в Go делает всё это гораздо менее бюрократичным. Любой тип с методом Save автоматически удовлетворяет интерфейсу Saver.
Этот приём отражает более широкий подход в Go: маленькие интерфейсы определяют на стороне потребителя, рядом с кодом, который их использует. Потребитель знает, какое подмножество поведения ему нужно, и может задать минимальный контракт под свои задачи. Если же вы определяете интерфейс на стороне поставщика, каждый потребитель вынужден зависеть от этого определения. Одно изменение ин��ерфейса поставщика может ненужной волной прокатиться по всей кодовой базе.
Из комментариев к ревью кода Go:
Интерфейсы в Go обычно принадлежат пакету, который использует значения интерфейсного типа, а не пакету, который эти значения реализует. Пакет-реализатор должен возвращать конкретные типы (обычно указатели или структуры): так можно добавлять новые методы к реализациям, не требуя масштабного рефакторинга.
Это не жёсткое правило. В стандартной библиотеке есть интерфейсы, определённые на стороне поставщика, вроде io.Reader и io.Writer — и это нормально, потому что они стабильные и универсальные. Но в прикладном коде интерфейсы обычно существуют всего в двух местах: в боевом коде и в тестах. Размещая их рядом с потребителем, вы снижаете связность между пакетами и упрощаете развитие проекта.
Эта идея всплывает снова и снова. Возьмём, например, AWS SDK. Легко поддаться соблазну определить большой интерфейс клиента S3 и использовать его повсюду:
type S3Client interface {
PutObject(
ctx context.Context,
input *s3.PutObjectInput,
opts ...func(*s3.Options)) (*s3.PutObjectOutput, error)
GetObject(
ctx context.Context,
input *s3.GetObjectInput,
opts ...func(*s3.Options)) (*s3.GetObjectOutput, error)
ListObjectsV2(
ctx context.Context,
input *s3.ListObjectsV2Input,
opts ...func(*s3.Options)) (*s3.ListObjectsV2Output, error)
// ...and many more
}Зависимость от такого большого интерфейса привязывает ваш код ко множеству вещей, которые ему вовсе не нужны. Любое изменение или расширение этого интерфейса может без особой причины затронуть ваш код и тесты.
Например, если ваш код загружает файлы, ему нужен только метод PutObject:
func UploadReport(ctx context.Context, client S3Client, data []byte) error {
_, err := client.PutObject(
ctx,
&s3.PutObjectInput{
Bucket: aws.String("reports"),
Key: aws.String("daily.csv"),
Body: bytes.NewReader(data),
},
)
return err
}Но принимая здесь полный S3Client, вы привязываете UploadReport к интерфейсу, который слишком широк. Фейку придётся реализовывать все методы, просто чтобы его удовлетворить.
Гораздо лучше определить небольшой интерфейс на стороне потребителя, который описывает только те действия, что вам действительно нужны. Ровно это и рекомендует документация AWS SDK для тестирования.
Чтобы поддержать использование моков, применяйте интерфейсы Go вместо конкретных типов сервисных клиентов, пагинаторов и «ожидателей» (waiters), таких как
s3.Client. Это позволяет вашему приложению использовать такие подходы, как внедрение зависимостей, и тестировать логику приложения.
Аналогично тому, что мы уже видели, можно определить интерфейс с одним методом:
type Uploader interface {
PutObject(
ctx context.Context, input *s3.PutObjectInput, opts ...func(*s3.Options),
) (*s3.PutObjectOutput, error)
}А затем использовать его в функции:
func UploadReport(ctx context.Context, u Uploader, data []byte) error {
_, err := u.PutObject(
ctx,
&s3.PutObjectInput{
Bucket: aws.String("reports"),
Key: aws.String("daily.csv"),
Body: bytes.NewReader(data),
},
)
return err
}Замысел прозрачен: эта функция загружает данные и зависит только от PutObject. Фейк для тестов теперь минимальный:
type FakeUploader struct{}
func (FakeUploader) PutObject(
_ context.Context,
_ *s3.PutObjectInput,
_ ...func(*s3.Options)) (*s3.PutObjectOutput, error) {
return &s3.PutObjectOutput{}, nil
}Если свести этот подход к общему практическому правилу, получится примерно следующее:
Вставляйте «шов» между двумя тесно связанными компонентами, определяя интерфейс на стороне потребителя, который включает только те методы, которые реально вызываются.
Готово!
Русскоязычное Go сообщество

Друзья! Эту статью подготовила команда «Go for Devs» — сообщества, где мы делимся практическими кейсами, инструментами для разработчиков и свежими новостями из мира Go. Подписывайтесь, чтобы быть в курсе и ничего не упустить!