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)
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 } )
tmvrus Автор
13.03.2018 18:57Да, такой вариант возможен.
Но для начала можно попробовать вот так:
Repository interface { Adder Finder Searcher Remover }
Основная идея в том, что можно выделить методы или группы методов, которые можно использовать отдельно друг от друга.Ghost_Russia
13.03.2018 19:28А как стоит поступить, если, к примеру, необходимо одновременно найти по Email и удалить?
Сделать дополнительный интерфейс, который будет это делать?
type RepositoryFindRemover interface { RepositoryFinderByEmail RepositoryRemover }
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, мне нафиг не нужна и не будет.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, мне нафиг не нужна и не будет.
У языков со статической типизацией не только производительность.
У них еще дополнительные проверки по типам, что уменьшает количество потенциальных ошибок, так как часть ошибок ловиться компилятором еще до запуска программы, но заставляет тщательнее прописывать программу.
Для реализации подобного на языках с динамической типизацией приходится писать больше тестов.
Однако на маленьких проектах и на прототипах, которые нужно реализовать максимально быстро и не очень качественно как черновик, у языков с динамической типизацией — преимущество по простоте написания.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, а нужно иметь доступ к любому полю произвольной структуры — то это значит или написано без учета особенностей языков со статической типизацией.
Или тот самый специфический случай.
Например, БД, которая должна расскладывать данные в файл по полям отдельно, но заранее не имеет понятия какая структура БД в конкретном случае.
Zanak
14.03.2018 12:24всё, что мы можем с ним сделать — это вызывать функции, из которых состоит этот интерфейс
Интерфейс для того и нужен, чтобы скрыть конкретную реализацию, запретив потребительскому коду зависеть от ее деталей, разве не в этом была идея их появления?
Например, если у вас есть несколько структур с полем ID типа int64, то вы никак не можете создать функцию, которая бы принимала на вход любой из этих типов и работала с полем ID.
Мне кажется, вы смешали композицию интерфейсов для работы с данными и сами структуры данных. Правда вопрос с совпадением имен методов в 2 интерфейсах требует уточнения.
В общем либо я не умею мыслить как настоящий программист на Go, либо я испорчен той свободой, которую даёт Python.
Каюсь, я тоже не проникся духом Go, и поэтому пока остаюсь на питоне, подтягивая плюсы, где нужна скорость. Пробую на вкус раст, но пока еще не готов делиться впечатлениями.
VulvarisMagistralis
14.03.2018 05:36А как стоит поступить, если, к примеру, необходимо одновременно найти по Email и удалить?
Если это не настолько типовая ситуация, чтобы имело смысл реализовывать сдвоенный интерфейс, то можно обойтись с использованием:
data.(FinderByEMail) data.(Remover)
Если же операция частая, то удобнее будет сделать сборный интерфейс:
type FinderAndRemover interface { FinderByEMail Remover }
VulvarisMagistralis
Там есть еще разумная рекомендация — объявлять интерфейсы в тех пакетах, где они используются.
Таким образом реализация как бы даже и не подозревает, что она реализует интерфейс, ибо нет ссылок на пакет, где объявлен интерфейс.
А если мне в интерфейсе нужны параметры сложного типа? Тогда как? Где объявлять эти параметры? Объявляю вместе с интерфейсами — и все, получаем полную зависимость от пакета с интерфейсов в пакете реализации.
tmvrus Автор
Можно выделить интерфейсы с такими параметрами в отдельный пакет.