Принцип разделения интерфейсов (ISP)
Принцип разделения интерфейсов (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
}gopackage 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
}gopackage 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, несмотря на то, что каждой функции нужен только один метод этого интерфейса.
Проблемы «толстого» интерфейса
Нарушение 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. Однако нам пришлось добавить два метода, которые всегда возвращают ошибку (поскольку базовый принтер не умеет ни сканировать, ни отправлять факс).
Это нарушение 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
}gopackage 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) errorgoПо имени функции и параметра можно догадаться, что она печатает. Но догадка – не гарантия. Вызывает ли 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) errorgopackage print
import "example.com/office/office"
type Printer interface {
Print(document office.Document) error
}
func ProcessPrintQueue(printer Printer, jobs []Job) errorgopackage fax
import "example.com/office/office"
type FaxSender interface {
SendFax(faxNumber office.FaxNumber, document office.Document) error
}
func ProcessFaxQueue(faxSender FaxSender, jobs []Job) errorgoТела функций не меняются – меняется только тип параметра. Но теперь сигнатура каждой функции точно описывает её контракт: ProcessPrintQueue принимает Printer, и компилятор гарантирует, что она не вызовет Scan или SendFax.
Интерфейс определён клиентом и содержит ровно то, что клиенту нужно. Пакет office ничего не знает об этих интерфейсах и не зависит от них.
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
}goMultiFunctionMachine по-прежнему реализует все три метода – а значит, неявно удовлетворяет всем трём интерфейсам. Мы можем передать её в любую из трёх функций:
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Реальный пример
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Заключение
Оценивайте интерфейс с точки зрения каждого клиента. Если разные клиенты используют один и тот же интерфейс, но обращаются к разным подмножествам его методов – это сигнал к разделению. Гранулярные интерфейсы позволят каждому клиенту зависеть только от того, что ему действительно нужно, – и изолируют его от изменений в методах, которые его не касаются.