Одна из наиболее приятных для меня концепций 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 за рецензирование этой статьи.

Комментарии (10)


  1. VulvarisMagistralis
    13.03.2018 16:54

    такой подход рекомендован в Go


    Там есть еще разумная рекомендация — объявлять интерфейсы в тех пакетах, где они используются.

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

    А если мне в интерфейсе нужны параметры сложного типа? Тогда как? Где объявлять эти параметры? Объявляю вместе с интерфейсами — и все, получаем полную зависимость от пакета с интерфейсов в пакете реализации.


    1. tmvrus Автор
      13.03.2018 18:50

      Можно выделить интерфейсы с такими параметрами в отдельный пакет.


  1. Ghost_Russia
    13.03.2018 18:50

    Правильно ли я понимаю, что в случае примера с Repository

    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
    		// и еще
    		// и еще
    		// и еще
    		// ...
    	}
    )
    

    Я должен буду преобразовать интерфейс в такие интерфейсы?
    type (
    	AMQPHandler struct {
    		repository Repository
    	}
    
    	Repository interface {
    		RepositoryAdder
    		RepositoryFinderByID
    		RepositoryFinderByEmail
    		RepositoryFinderByCountry
    		RepositoryFinderByEmailAndCountry
    		RepositorySearcher
    		RepositoryRemover
    		// и еще
    		// и еще
    		// и еще
    		// ...
    	}
    
    	RepositoryAdder interface {
    		Add(user *User) error
    	}
    	
    	RepositoryFinderByID interface {
    		FindByID(ID string) (*User, error)
    	}
    	
    	RepositoryFinderByEmail interface {
    		FindByEmail(email string) (*User, error)
    	}
    	
    	RepositoryFinderByCountry interface {
    		FindByCountry(country string) (*User, error)
    	}
    	
    	RepositoryFinderByEmailAndCountry interface {
    		FindByEmailAndCountry(country string) (*User, error)
    	}
    	
    	RepositorySearcher interface {
    		Search(...CriteriaOption) ([]*User, error)
    	}
    	
    	RepositoryRemover interface {
    		Remove(ID string) error
    	}
    )
    


    1. tmvrus Автор
      13.03.2018 18:57

      Да, такой вариант возможен.
      Но для начала можно попробовать вот так:

      Repository interface {
                      Adder
      		Finder		
                      Searcher
      		Remover		
      	}
      

      Основная идея в том, что можно выделить методы или группы методов, которые можно использовать отдельно друг от друга.


      1. Ghost_Russia
        13.03.2018 19:28

        А как стоит поступить, если, к примеру, необходимо одновременно найти по Email и удалить?
        Сделать дополнительный интерфейс, который будет это делать?

        type RepositoryFindRemover interface {
        	RepositoryFinderByEmail
        	RepositoryRemover
        }
        


        1. evocatus
          14.03.2018 00:10

          А как стоит поступить, если, к примеру, необходимо одновременно найти по Email и удалить?

          ИМХО, лучше сделать type cast.

          Вообще композиция в Go лично мне не нравится (я использую Go в домашних проектах года два). Во многих случаях интерфейсы просто не работают — основная проблема с интерфейсом в том, что если он возвращается из функции или передаётся как параметр, то всё, что мы можем с ним сделать — это вызывать функции, из которых состоит этот интерфейс. Этот подход неплохо работает, когда нужно заменить простую утилиту на bash или С из пары сотен строк, или когда самый фундаментальный тип данных всего один и простой (как float64 в этой статье), но на более сложных примерах это очень неудобно.

          Например, если у вас есть несколько структур с полем ID типа int64, то вы никак не можете создать функцию, которая бы принимала на вход любой из этих типов и работала с полем ID. Создавать id_getter и id_setter для каждой структуры? Можно, конечно передавать указатель на это поле, но тогда о какой композиции может идти речь?

          Примечательно, что в сообществе Go массово используют библиотеки, которые пропитаны рефлексией (которая в Go тоже некрасивая) как бисквит коньяком, типа encoding/json, sqlx, database/sql, html/template и т.д. Потому что иначе это чистый Pascal. К слову, все эти красивые идеи, которые реализованы в Go, Дейкстра сотоварищи сформулировал ещё в 70-е, о чём прекрасно рассказывается в этой лекции.

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


          1. VulvarisMagistralis
            14.03.2018 05:30

            Например, если у вас есть несколько структур с полем ID типа int64, то вы никак не можете создать функцию, которая бы принимала на вход любой из этих типов и работала с полем ID. Создавать id_getter и id_setter для каждой структуры?


            В общем либо я не умею мыслить как настоящий программист на Go, либо я испорчен той свободой, которую даёт Python.


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

            И да, тогда два варианта или рефлексия (что медленно) или создавать интерфейс типа:
            type IDer interface {
               IDSetter(id string)
               IDGetter() string
            }
            


            и реализовывать его для всех структур.

            Но я бы рассмотрел ситуацию под другим углом. Возможно, что архитектура просто реализована некооректно, без учетов особенностей языка со статической типизацией.

            Ну, например, ситуация, что структура имеет ID и должна записываться/читаться в/из файл/по сети — то достаточно реализовать IDer или ReaderWriter и больше ничего знать конкретного о структуре не понадобится.

            В случае, если нужен доступ к полям структуры, но хочется это сделать без рефлексии, то понадобится нечто подобное:

            type CustomPropsExample struct {
                I, J int
                // Sum is not stored, but should always be equal to I + J.
                Sum int `datastore:"-"`
            }
            
            func (x *CustomPropsExample) Load(ps []datastore.Property) error {
                // Load I and J as usual.
                if err := datastore.LoadStruct(x, ps); err != nil {
                    return err
                }
                // Derive the Sum field.
                x.Sum = x.I + x.J
                return nil
            }
            
            func (x *CustomPropsExample) Save() ([]datastore.Property, error) {
                // Validate the Sum field.
                if x.Sum != x.I + x.J {
                    return errors.New("CustomPropsExample has inconsistent sum")
                }
                // Save I and J as usual. The code below is equivalent to calling
                // "return datastore.SaveStruct(x)", but is done manually for
                // demonstration purposes.
                return []datastore.Property{
                    {
                        Name:  "I",
                        Value: int64(x.I),
                    },
                    {
                        Name:  "J",
                        Value: int64(x.J),
                    },
                }
            }


            И я уже десять раз пожалел, что начал нынешний домашний проект на Go, тем более, что вся эта шикарная производительность, которую даёт Go, мне нафиг не нужна и не будет.


            У языков со статической типизацией не только производительность.

            У них еще дополнительные проверки по типам, что уменьшает количество потенциальных ошибок, так как часть ошибок ловиться компилятором еще до запуска программы, но заставляет тщательнее прописывать программу.

            Для реализации подобного на языках с динамической типизацией приходится писать больше тестов.

            Однако на маленьких проектах и на прототипах, которые нужно реализовать максимально быстро и не очень качественно как черновик, у языков с динамической типизацией — преимущество по простоте написания.


            1. VulvarisMagistralis
              14.03.2018 05:44

              Возможны еще кривовастенькие варианты:

              type BaseStruct struct {
                 ID string
                 data interface{}
              }
              

              или

              type BaseStruct struct {
                 ID string
                 StructA *StructTypeA
                 StructB *StructTypeB
              }


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

              Если же вам недостаточно доступа только к ID, а нужно иметь доступ к любому полю произвольной структуры — то это значит или написано без учета особенностей языков со статической типизацией.

              Или тот самый специфический случай.
              Например, БД, которая должна расскладывать данные в файл по полям отдельно, но заранее не имеет понятия какая структура БД в конкретном случае.


          1. Zanak
            14.03.2018 12:24

            всё, что мы можем с ним сделать — это вызывать функции, из которых состоит этот интерфейс

            Интерфейс для того и нужен, чтобы скрыть конкретную реализацию, запретив потребительскому коду зависеть от ее деталей, разве не в этом была идея их появления?


            Например, если у вас есть несколько структур с полем ID типа int64, то вы никак не можете создать функцию, которая бы принимала на вход любой из этих типов и работала с полем ID.

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


            В общем либо я не умею мыслить как настоящий программист на Go, либо я испорчен той свободой, которую даёт Python.

            Каюсь, я тоже не проникся духом Go, и поэтому пока остаюсь на питоне, подтягивая плюсы, где нужна скорость. Пробую на вкус раст, но пока еще не готов делиться впечатлениями.


        1. VulvarisMagistralis
          14.03.2018 05:36

          А как стоит поступить, если, к примеру, необходимо одновременно найти по Email и удалить?


          Если это не настолько типовая ситуация, чтобы имело смысл реализовывать сдвоенный интерфейс, то можно обойтись с использованием:

          data.(FinderByEMail)
          data.(Remover)


          Если же операция частая, то удобнее будет сделать сборный интерфейс:

          type FinderAndRemover interface {
             FinderByEMail
             Remover
          }