BackendBit

К другим SOLID принципам

Принцип разделения интерфейсов (ISP) – клиенты не должны зависеть от методов интерфейса, которые они не используют. Клиент – это любой код, который принимает интерфейс как зависимость: например, функция, модуль или пакет.

Пример

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

Офисная машина представлена интерфейсом:

package office

type FaxNumber string

type Machine interface {
	Print(document Document) error
	Scan() (ScannedDocument, error)
	SendFax(faxNumber FaxNumber, document Document) error
}
go

Пока что в офисе есть единственная машина, которая реализует этот интерфейс:

package office

import (
	"fmt"
	"time"
)

type MultiFunctionMachine struct{}

func (m *MultiFunctionMachine) Print(document Document) error {
	fmt.Printf("printing %s\n", document.Name)

	return nil
}

func (m *MultiFunctionMachine) Scan() (ScannedDocument, error) {
	fmt.Println("scanning document")

	return ScannedDocument{
		Data:      []byte("%PDF-1.4 fake scan content"),
		Format:    "pdf",
		ScannedAt: time.Now(),
	}, nil
}

func (m *MultiFunctionMachine) SendFax(faxNumber FaxNumber, document Document) error {
	fmt.Printf("faxing %s to %s\n", document.Name, faxNumber)

	return nil
}
go

Задачи на сканирование, печать и отправку факса обрабатываются функциями ProcessScanQueue, ProcessPrintQueue и ProcessFaxQueue:

package scan

import (
	"fmt"
	"os"

	"example.com/office/office"
)

type Job struct {
	Destination string
}

func ProcessScanQueue(scanner office.Machine, jobs []Job) error {
	for _, job := range jobs {
		doc, err := scanner.Scan()
		if err != nil {
			return fmt.Errorf("scan to %s failed: %w", job.Destination, err)
		}

		filename := fmt.Sprintf("%s/scan.%s", job.Destination, doc.Format)
		if err := os.WriteFile(filename, doc.Data, 0644); err != nil {
			return fmt.Errorf("saving scan to %s failed: %w", filename, err)
		}
	}

	return nil
}
go
package print

import (
	"fmt"

	"example.com/office/office"
)

type Job struct {
	Document office.Document
	Copies   int
}

func ProcessPrintQueue(printer office.Machine, jobs []Job) error {
	for _, job := range jobs {
		for i := range job.Copies {
			err := printer.Print(job.Document)
			if err != nil {
				return fmt.Errorf("print queue failed on %s (copy %d/%d): %w", job.Document.Name, i+1, job.Copies, err)
			}
		}
	}

	return nil
}
go
package fax

import (
	"fmt"

	"example.com/office/office"
)

type Job struct {
	Document    office.Document
	Destination office.FaxNumber
}

func ProcessFaxQueue(faxSender office.Machine, jobs []Job) error {
	for _, job := range jobs {
		err := faxSender.SendFax(job.Destination, job.Document)
		if err != nil {
			return fmt.Errorf("fax to %s failed: %w", job.Destination, err)
		}
	}

	return nil
}
go

Обратим внимание, что все три функции зависят от «толстого» интерфейса office.Machine, несмотря на то, что каждой функции нужен только один метод этого интерфейса.

Диаграмма зависимостей до применения ISPТри функции ProcessPrintQueue, ProcessScanQueue и ProcessFaxQueue зависят от интерфейса office.Machine, хотя каждая использует только один его метод.<<interface>>Machine+Print(Document) error+Scan() (ScannedDocument, error)+SendFax(FaxNumber, Document) errorProcessPrintQueueProcessScanQueueProcessFaxQueuePrint()Scan()SendFax()

Проблемы «толстого» интерфейса

Нарушение Liskov substitution principle

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

package office

import "fmt"

type BasicPrinter struct{}

func (p *BasicPrinter) Print(document Document) error {
	fmt.Printf("printing %s\n", document.Name)

	return nil
}
go

К сожалению, в таком виде мы не сможем использовать этот принтер для обработки задач. ProcessPrintQueue принимает интерфейс office.Machine. BasicPrinter не соответствует интерфейсу, поскольку не хватает методов Scan и SendFax.

Попробуем их добавить:

package office

import (
	"errors"
	"fmt"
)

type BasicPrinter struct{}

func (p *BasicPrinter) Print(document Document) error {
	fmt.Printf("printing %s\n", document.Name)

	return nil
}

func (p *BasicPrinter) Scan() (ScannedDocument, error) {
	return ScannedDocument{}, errors.New("базовый принтер не умеет сканировать")
}

func (p *BasicPrinter) SendFax(faxNumber FaxNumber, document Document) error {
	return errors.New("базовый принтер не умеет отправлять факс")
}
go

Теперь объекты BasicPrinter можно передать в ProcessPrintQueue. Однако нам пришлось добавить два метода, которые всегда возвращают ошибку (поскольку базовый принтер не умеет ни сканировать, ни отправлять факс).

BasicPrinter реализует Machine с заглушкамиBasicPrinter вынужден реализовать все три метода интерфейса Machine, хотя реально использует только Print. Методы Scan и SendFax – заглушки, которые всегда возвращают ошибку.<<interface>>Machine+Print(Document) error+Scan() (ScannedDocument, error)+SendFax(FaxNumber, Document) errorBasicPrinter+Print(Document) error+Scan() (ScannedDocument, error)+SendFax(FaxNumber, Document) errorScan и SendFax – заглушки,всегда возвращают ошибку

Это нарушение Liskov substitution principle: проблема не в том, что методы возвращают ошибку – ошибки ожидаемы. Проблема в том, что Scan и SendFax не могут выполнить операцию в принципе – они безусловно завершаются неудачей. Контракт метода Scan() подразумевает, что вызов может завершиться успешно и вернуть отсканированный документ. BasicPrinter.Scan() безусловно возвращает ошибку – постусловие ослаблено до невозможности успешного выполнения. Это делает BasicPrinter непригодным для подстановки вместо любой реализации Machine.

Если интерфейс продолжит расширяться, BasicPrinter будет вынужден добавлять всё новые заглушки.

package office

type Machine interface {
	Print(document Document) error
	Scan() (ScannedDocument, error)
	SendFax(faxNumber FaxNumber, document Document) error
	// Добавляем метод 3D-печати
	Print3DModel(model Model) error
}
go
package office

import (
	"errors"
	"fmt"
)

type BasicPrinter struct{}

func (p *BasicPrinter) Print(document Document) error {
	fmt.Printf("printing %s\n", document.Name)

	return nil
}

func (p *BasicPrinter) Scan() (ScannedDocument, error) {
	return ScannedDocument{}, errors.New("базовый принтер не умеет сканировать")
}

func (p *BasicPrinter) SendFax(faxNumber FaxNumber, document Document) error {
	return errors.New("базовый принтер не умеет отправлять факс")
}

func (p *BasicPrinter) Print3DModel(model Model) error {
	return errors.New("базовый принтер не умеет печатать 3D-модели")
}
go

Непрозрачность зависимостей

Сигнатура функции – это её контракт. Когда функция принимает «толстый» интерфейс, контракт размыт:

package print

import "example.com/office/office"

func ProcessPrintQueue(printer office.Machine, jobs []Job) error
go

По имени функции и параметра можно догадаться, что она печатает. Но догадка – не гарантия. Вызывает ли ProcessPrintQueue метод Scan, чтобы проверить результат печати? Использует ли SendFax для уведомления? Имена подсказывают основное назначение, но не исключают побочных вызовов – чтобы узнать наверняка, придётся читать тело функции.

Каскадные изменения

Когда «толстый» интерфейс меняется, последствия затрагивают всех – даже тех, кого изменение не касается.

Мы уже видели это выше: добавление метода Print3DModel в Machine заставило BasicPrinter реализовать заглушку. Но BasicPrinter – не единственный пострадавший. Обновить придётся каждую реализацию интерфейса и каждый test double. В большой кодовой базе одно изменение в интерфейсе превращается в правки десятков файлов в разных пакетах.

Сложность тестирования

Чтобы протестировать ProcessScanQueue, нужно создать стаб (stub), который удовлетворяет интерфейсу Machine – а значит, реализовать все его методы, включая те, которые тест не вызывает:

package scan_test

import (
	"testing"
	"time"

	"example.com/office/office"
	"example.com/office/scan"
)

// stubMachine реализует office.Machine целиком,
// хотя тесту нужен только Scan
type stubMachine struct{}

func (m *stubMachine) Print(office.Document) error {
	panic("не используется в тесте")
}

func (m *stubMachine) SendFax(office.FaxNumber, office.Document) error {
	panic("не используется в тесте")
}

func (m *stubMachine) Scan() (office.ScannedDocument, error) {
	return office.ScannedDocument{
		Data:      []byte("%PDF-1.4 test scan"),
		Format:    "pdf",
		ScannedAt: time.Now(),
	}, nil
}

func TestProcessScanQueue(t *testing.T) {
	jobs := []scan.Job{{Destination: t.TempDir()}}
	err := scan.ProcessScanQueue(&stubMachine{}, jobs)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
}
go

Методы Print и SendFax – мёртвый код: они существуют только ради компилятора.

Инструменты кодогенерации (например, mockgen из go.uber.org/mock) снимают проблему ручного написания заглушек – все методы генерируются автоматически. Однако даже с кодогенерацией «толстый» интерфейс ухудшает читаемость тестов: по тестовому коду невозможно быстро понять, какие методы действительно используются, а какие присутствуют только ради компилятора. Когда функция принимает узкий интерфейс с единственным методом, стаб содержит ровно тот метод, от которого зависит тестируемая функция, – тест становится самодокументирующимся.

Разделение интерфейса

Решение – пусть каждый клиент сам определяет интерфейс, который ему нужен. В Go интерфейсы удовлетворяются неявно, поэтому клиент может объявить нужный ему контракт, не затрагивая пакет с реализацией:

package scan

import "example.com/office/office"

type Scanner interface {
	Scan() (office.ScannedDocument, error)
}

func ProcessScanQueue(scanner Scanner, jobs []Job) error
go
package print

import "example.com/office/office"

type Printer interface {
	Print(document office.Document) error
}

func ProcessPrintQueue(printer Printer, jobs []Job) error
go
package fax

import "example.com/office/office"

type FaxSender interface {
	SendFax(faxNumber office.FaxNumber, document office.Document) error
}

func ProcessFaxQueue(faxSender FaxSender, jobs []Job) error
go

Тела функций не меняются – меняется только тип параметра. Но теперь сигнатура каждой функции точно описывает её контракт: ProcessPrintQueue принимает Printer, и компилятор гарантирует, что она не вызовет Scan или SendFax.

Интерфейс определён клиентом и содержит ровно то, что клиенту нужно. Пакет office ничего не знает об этих интерфейсах и не зависит от них.

Диаграмма зависимостей после применения ISPКаждая функция зависит только от своего гранулярного интерфейса: ProcessPrintQueue от Printer, ProcessScanQueue от Scanner, ProcessFaxQueue от FaxSender.<<interface>>Printer+Print(Document) error<<interface>>Scanner+Scan() (ScannedDocument, error)<<interface>>FaxSender+SendFax(FaxNumber, Document) errorProcessPrintQueueProcessScanQueueProcessFaxQueue

BasicPrinter больше не нуждается в заглушках. Он реализует единственный метод Print – и этого достаточно, чтобы неявно удовлетворить интерфейсу print.Printer:

package office

import "fmt"

type BasicPrinter struct{}

func (p *BasicPrinter) Print(document Document) error {
	fmt.Printf("printing %s\n", document.Name)

	return nil
}
go
BasicPrinter после ISP – заглушки не нужныBasicPrinter реализует только интерфейс Printer с единственным методом Print. Интерфейсы Scanner и FaxSender существуют отдельно, но BasicPrinter к ним не подключён.<<interface>>Printer+Print(Document) error<<interface>>Scanner+Scan() (ScannedDocument, error)<<interface>>FaxSender+SendFax(FaxNumber, Document) errorBasicPrinter+Print(Document) error

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

mfm := &office.MultiFunctionMachine{}

// MultiFunctionMachine удовлетворяет print.Printer
print.ProcessPrintQueue(mfm, printJobs)

// MultiFunctionMachine удовлетворяет scan.Scanner
scan.ProcessScanQueue(mfm, scanJobs)

// MultiFunctionMachine удовлетворяет fax.FaxSender
fax.ProcessFaxQueue(mfm, faxJobs)
go

В будущем в системе может появиться принтер, который умеет печатать и сканировать, но не отправлять факс. Гранулярные интерфейсы позволяют создавать различные комбинации поведения – это делает систему более гибкой и избавляет код от зависимости на ненужные ему вещи. А если клиенту нужна комбинация возможностей, Go позволяет составить интерфейс через встраивание – как это сделано в стандартной библиотеке с io.ReadCloser:

package main

import (
	"example.com/office/print"
	"example.com/office/scan"
)

type PrinterScanner interface {
	print.Printer
	scan.Scanner
}
go
Композиция интерфейсов через встраиваниеИнтерфейс PrinterScanner встраивает (embedding) интерфейсы Printer и Scanner, демонстрируя композицию гранулярных интерфейсов.<<interface>>Printer+Print(Document) error<<interface>>Scanner+Scan() (ScannedDocument, error)<<interface>>PrinterScanner+Printer+Scanner

Реальный пример

io

Стандартная библиотека Go – каноничный пример ISP. Интерфейсы io.Reader, io.Writer и io.Closer определяют по одному методу:

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

type Writer interface {
	Write(p []byte) (n int, err error)
}

type Closer interface {
	Close() error
}
go

Когда нужна комбинация, Go компонует интерфейсы:

type ReadCloser interface {
	Reader
	Closer
}
go

Это прямая аналогия с нашим MultiFunctionMachine, который реализует Printer, Scanner и FaxSender. В Go композиция интерфейсов – встроенный механизм языка.

Конкретный пример: http.Response.Body имеет тип io.ReadCloser – тело HTTP-ответа нужно и читать, и закрывать. Но функция, которая только обрабатывает данные, может принимать io.Reader – ей не важно, можно ли закрыть источник.

package main

import (
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
)

type User struct {
	Name  string `json:"name"`
	Email string `json:"email"`
}

// Зависим только от io.Reader – не важно, откуда приходят данные
func decodeUsers(r io.Reader) ([]User, error) {
	var users []User
	if err := json.NewDecoder(r).Decode(&users); err != nil {
		return nil, err
	}
	return users, nil
}

func main() {
	resp, err := http.Get("https://api.example.com/users")
	if err != nil {
		log.Fatal(err)
	}
	defer resp.Body.Close() // resp.Body – io.ReadCloser

	// decodeUsers принимает io.Reader, resp.Body удовлетворяет этому интерфейсу
	users, err := decodeUsers(resp.Body)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(users)
}
go

Заключение

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