Композиция интерфейсов в Go

перевод
tmvrus 12 марта в 22:49 7,2k
Оригинал: Rafael Jesus
Одна из наиболее приятных для меня концепций Go — это возможность композиции интерфейсов. В этой статье мы разберем небольшой пример использования такой возможности языка. Для этого представим гипотетический сценарий, в котором две структуры обрабатывают пользовательские данные и выполняют http-запросы.

type (
        // Структура отвечает за синхронизацию пользователей
	Sync struct {
		client HTTPClient
	}
)

// возвращает сконфигурированный экземпляр Sync
func NewSync(hc HTTPClient) *Sync {
	return &Sync{hc}
}

// Упрощенный код синхронизации пользователей со сторонней системой
func (s *Sync) Sync(user *User) error {
        res, err := s.client.Post(syncURL, "application/json", body)
        // обработка с res и err
        return err
}

type (
        // Структура отвечает за хранение пользовательских данных
        Store struct {
		client HTTPClient
	}
)

// возвращает сконфигурированный экземпляр Store
func NewStore(hc HTTPClient) *Store {
	return &Store{hc}
}

// Упрощенный код работы с данными пользователя
func (s *Store) Store(user *User) error {
	res, err := s.client.Get(userResource)
        // обработка с res и err
	res, err = s.client.Post(usersURL, "application/json", body)
        // обработка с res и err
	return err
}

Структуры Sync и Store отвечают за операции с пользователями в нашей системе. Для того чтобы они могли выполнять http-запросы, им необходимо передать структуру, удовлетворяющую интерфейсу HTTPClient. Вот что он из себя представляет:

type (
        // обертка вокруг http для всего приложения
        HTTPClient interface {
                // выполняет POST-запрос
	        Post(url, contentType string, body io.Reader) (*http.Response, error)
                // выполняет GET-запрос
                Get(url string) (*http.Response, error)
	}
)

Итак, у нас есть две структуры, каждая делает что-то одно и делает это хорошо, и обе они зависят только от одного аргумента-интерфейса. Выглядит как легко тестируемый код, ведь все, что нам нужно, это создать заглушку для интерфейса HTTPClient. Юнит-тест для Sync можно реализовать следующим образом:

func TestUserSync(t *testing.T) {
	client := new(HTTPClientMock)
	client.PostFunc = func(url, contentType string, body io.Reader) (*http.Response, error) {
		// check if args are the expected
		return &http.Response{StatusCode: http.StatusOK}, nil
	}
	syncer := NewSync(client)
	u := NewUser("foo@mail.com", "de")
	if err := syncer.Sync(u); err != nil {
		t.Fatalf("failed to sync user: %v", err)
	}
	if !client.PostInvoked {
		t.Fatal("expected client.Post() to be invoked")
	}
}

type (
	HTTPClientMock struct {
		PostInvoked bool
		PostFunc    func(url, contentType string, body io.Reader) (*http.Response, error)
    
    		GetInvoked bool
		GetFunc    func(url string) (*http.Response, error)
	}
)

func (m *HTTPClientMock) Post(url, contentType string, body io.Reader) (*http.Response, error) {
	m.PostInvoked = true
	return m.PostFunc(url, contentType, body)
}

func (m *HTTPClientMock) Get(url string) (*http.Response, error) { return nil, nil}

Такой тест прекрасно работает, но стоит обратить внимание на то, что Sync не использует метод Get из интерфейса HTTPClient
Клиенты не должны зависеть от методов, которые они не используют. Роберт Мартин
Еще, если вы захотите добавить новый метод к HTTPClient, вам также придется добавить его в заглушку HTTPClientMock, что ухудшает читаемость кода и усложняет его тестирование. Даже если просто изменить сигнатуру метода Get, это все равно повлияет на тест для структуры Sync, не смотря на то, что этот метод не используется. От таких зависимостей следует избавиться.

В нашем примере нужно реализовать всего два метода для заглушки интерфейса HTTPClient. Но представьте, что ваш гипотетический обработчик должен получать сообщения из очереди и сохранять их в базу данных:

type (
	AMQPHandler struct {
		repository Repository
	}

	Repository interface {
		Add(user *User) error
		FindByID(ID string) (*User, error)
		FindByEmail(email string) (*User, error)
		FindByCountry(country string) (*User, error)
		FindByEmailAndCountry(country string) (*User, error)
		Search(...CriteriaOption) ([]*User, error)
		Remove(ID string) error
		// и еще
		// и еще
		// и еще
		// ...
	}
)

func NewAMQPHandler(r Repository) *AMQPHandler {
	return &AMQPHandler{r}
}

func (h *AMQPHandler) Handle(body []byte) error {
	// сохранение пользователя
	if err := h.repository.Add(user); err != nil {
		return err
	}
	
	return nil
}

Для сохранения пользовательских данных в базу AMQPHandler нужен только метод Add, но, как вы наверное догадались, заглушка интерфейса Repository для тестирования будет выглядеть угрожающе:

type (
        RepositoryMock struct {
                AddInvoked bool
	}
)

func (r *Repository) Add(u *User) error {
        r.AddInvoked = true
        return nil
}

func (r *Repository) FindByID(ID string) (*User, error) { return nil }
func (r *Repository) FindByEmail(email string) (*User, error) { return nil }
func (r *Repository) FindByCountry(country string) (*User, error) { return nil }
func (r *Repository) FindByEmailAndCountry(email, country string) (*User, error) { return nil }
func (r *Repository) Search(...CriteriaOption) ([]*User, error) { return nil, nil }
func (r *Repository) Remove(ID string) error { return nil }

Из-за подобной ошибки в дизайне приложения у нас нет другого выбора, как каждый раз реализовывать все методы интерфейса Repository. Но согласно философии Go интерфейсы, как правило, должны быть небольшими, состоять из одного или двух методов. В этом свете реализация Repository выглядит абсолютно избыточной.
Чем больше интерфейс, тем слабее абстракция. Роб Пайк
Вернемся к коду управления пользователями, оба метода Post и Get нужны только для сохранения данных(Store), а для синхронизации достаточно только метода Post. Давайте исправим реализацию Sync с учетом этого факта:

type (
	 // Структура отвечает за синхронизацию пользователей
	Sync struct {
		client HTTPPoster
	}
)

// возвращает сконфигурированный экземпляр Sync
func NewSync(hc HTTPPoster) *Sync {
	return &Sync{hc}
}

// Упрощенный код синхронизации пользователей со сторонней системой
func (s *Sync) Sync(user *User) error {
	res, err := s.client.Post(syncURL, "application/json", body)
        // обработка с res и err
        return err
}

func TestUserSync(t *testing.T) {
	client := new(HTTPPosterMock)
	client.PostFunc = func(url, contentType string, body io.Reader) (*http.Response, error) {
		// assert the arguments are the expected
		return &http.Response{StatusCode: http.StatusOK}, nil
	}
	syncer := NewSync(client)
	u := NewUser("foo@mail.com", "de")
	if err := syncer.Sync(u); err != nil {
		t.Fatalf("failed to sync user: %v", err)
	}
	if !client.PostInvoked {
		t.Fatal("expected client.Post() to be invoked")
	}
}

type (
	HTTPPosterMock struct {
		PostInvoked bool
		PostFunc    func(url, contentType string, body io.Reader) (*http.Response, error)
	}
)

func (m *HTTPPosterMock) Post(url, contentType string, body io.Reader) (*http.Response, error) {
	m.PostInvoked = true
	return m.PostFunc(url, contentType, body)
}

Теперь нам не нужно иметь дело с избыточным интерфейсом HTTPClient, такой подход упрощает тестирование и позволяет избежать лишних зависимостей. А еще, назначение аргумента для конструктора NewSync стало гораздо яснее.

Теперь посмотрим, как может выглядеть тест для Store, использующей оба метода из HTTPClient:

func TestUserStore(t *testing.T) {
	client := new(HTTPClientMock)
	client.PostFunc = func(url, contentType string, body io.Reader) (*http.Response, error) {
		// assertion omitted
		return &http.Response{StatusCode: http.StatusOK}, nil
	}
	client.GetFunc = func(url string) (*http.Response, error) {
		// assertion omitted
		return &http.Response{StatusCode: http.StatusOK}, nil
	}
	storer := NewStore(client)
	u := NewUser("foo@mail.com", "de")
	if err := storer.Store(u); err != nil {
		t.Fatalf("failed to store user: %v", err)
	}
	if !client.PostInvoked {
		t.Fatal("expected client.Post() to be invoked")
	}
	if !client.GetInvoked {
		t.Fatal("expected client.Get() to be invoked")
	}
}

type (
	HTTPClientMock struct {
		HTTPPosterMock
		HTTPGetterMock
	}

	HTTPPosterMock struct {
		PostInvoked bool
		PostFunc    func(url, contentType string, body io.Reader) (*http.Response, error)
	}

	HTTPGetterMock struct {
		GetInvoked bool
		GetFunc    func(url string) (*http.Response, error)
	}
)

func (m *HTTPPosterMock) Post(url, contentType string, body io.Reader) (*http.Response, error) {
	m.PostInvoked = true
	return m.PostFunc(url, contentType, body)
}

func (m *HTTPGetterMock) Get(url string) (*http.Response, error) {
	m.GetInvoked = true
	return m.GetFunc(url)
}

Честно говоря, такой подход изобрел не я. Подобное можно увидеть в стандартной библиотеке Go, io.ReadWriter хорошо иллюстрирует принцип композиции интерфейсов:

type ReadWriter interface {
        Reader
        Writer
}

Такой способ организации интерфейсов делает зависимости в коде более явными.

Проницательный читатель, наверное, уловил намек на TDD в моем примере. Действительно, без юнит-тестов добиться такого дизайна с первой попытки затруднительно. Также стоит отметить отсутствие внешних зависимостей у тестов, такой подход я подсмотрел у Ben Johnson.

Возможно вам любопытно, как будет выглядеть реализация HTTPClient?

type (
	// обертка для http-запросов
	HTTPClient struct {
		req *Request
	}
	
	// структура для представления http-запроса
	Request struct{}
)

// возвращает сконфигурированный HTTPClient
func New(r *Request) *HTTPClient {
	return &HTTPClient{r}
}

// выполняет Get-запрос
func (c *HTTPClient) Get(url string) (*http.Response, error) {
	return c.req.Do(http.MethodGet, url, "application/json", nil)
}

// выполняет Post-запрос
func (c *HTTPClient) Post(url, contentType string, body io.Reader) (*http.Response, error) {
	return c.req.Do(http.MethodPost, url, contentType, body)
}

// выполняет http-запрос
func (r *Request) Do(method, url, contentType string, body io.Reader) (*http.Response, error) {
	req, err := http.NewRequest(method, url, body)
	if err != nil {
		return nil, fmt.Errorf("failed to create request %v: ", err)
	}

	req.Header.Set("Content-Type", contentType)

	return http.DefaultClient.Do(req)
}

Это проще простого — достаточно реализовать методы для Post и Get. Обратите внимание, что конструктор возвращает не интерфейс и конкретный тип, такой подход рекомендован в Go. А интерфейс должен быть объявлен в пакете-потребителе, который будет использовать HTTPClient. В нашем случае можно называть пакет user:

type (
	// Структура для представления данных пользователя
	User struct {
		Email   string `json:"email"`
		Country string `json:"country"`
	}

	// композиция интерфейсов
	HTTPClient interface {
		HTTPGetter
		HTTPPoster
	}

	// Интерфейс для Post-запросов
	HTTPPoster interface {
		Post(url, contentType string, body io.Reader) (*http.Response, error)
	}

	// Интерфейс для Get-запросов
	HTTPGetter interface {
		Get(url string) (*http.Response, error)
	}
)

И, в конце концов, соберем все вместе в main.go

func main() {
	req := new(httpclient.Request)
	client := httpclient.New(req)
	
	_ = user.NewSync(client)
	_ = user.NewStore(client)

	// работа с Sync и Store
}

Надеюсь, этот пример поможет вам начать использовать принцип разделения интерфейсов, чтобы писать более идиоматичный Go-код, легко тестируемый и с явными зависимостями. В следующей статье я добавлю в HTTPClient логику обработки отказов и повторную отправку, оставайтесь на связи.

Полный исходный код реализации примера.

Отдельная благодарность моим друзьям Bastian и Felipe за рецензирование этой статьи.
Проголосовать:
+21
Сохранить: