📜 ⬆️ ⬇️

Interface Composition in Go

One of the most enjoyable Go concepts for me is the ability to compose interfaces. In this article we will discuss a small example of using this feature of the language. To do this, we present a hypothetical scenario in which two structures process user data and execute http requests.

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 } 

The Sync and Store structures are responsible for operations with users in our system. In order for them to perform http requests, they need to be passed a structure that satisfies the HTTPClient interface. This is what it is:

 type ( //   http    HTTPClient interface { //  POST- Post(url, contentType string, body io.Reader) (*http.Response, error) //  GET- Get(url string) (*http.Response, error) } ) 

So, we have two structures, each doing one thing and doing it well, and both of them depend on only one interface argument. It looks like easy-to-test code, because all we need is to create a stub for the HTTPClient interface. The unit test for Sync can be implemented as follows:

 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} 

Such a test works fine, but you should pay attention to the fact that Sync does not use the Get method from the HTTPClient interface .
Clients should not depend on methods that they do not use. Robert Martin
Also, if you want to add a new method to the HTTPClient , you will also have to add it to the HTTPClientMock stub, which degrades the readability of the code and complicates its testing. Even if you just change the signature of the Get method, it still affects the test for the Sync structure, despite the fact that this method is not used. From such dependencies should get rid of.
')
In our example, we need to implement only two methods for the HTTPClient interface stub . But imagine that your hypothetical handler should receive messages from the queue and save them to the database:

 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 } 

To save user data to the AMQPHandler database, you only need the Add method, but, as you probably guessed, the Repository interface stub for testing will look threatening:

 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 } 

Due to a similar error in the design of the application, we have no other choice how to implement all the methods of the Repository interface every time. But according to Go philosophy, interfaces, as a rule, should be small, consist of one or two methods. In this light, the implementation of the Repository looks completely redundant.
The larger the interface, the weaker the abstraction. Rob Pike
Returning to the user management code, both the Post and Get methods are needed only to store data ( Store ), and only the Post method is enough for synchronization. Let's fix the sync implementation with this fact:

 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) } 

Now we do not need to deal with the redundant HTTPClient interface, this approach simplifies testing and avoids unnecessary dependencies. Also , the purpose of the argument for the NewSync designer has become much clearer.

Now let's see what a test for Store can look like, using both methods from the 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) } 

Frankly, I did not invent this approach. This can be seen in the standard Go library, io.ReadWriter well illustrates the principle of interface composition:

 type ReadWriter interface { Reader Writer } 

This way of organizing interfaces makes dependencies more explicit in code.

An astute reader probably caught a hint of TDD in my example. Indeed, without unit tests, it is difficult to achieve such a design from the first attempt. It is also worth noting the lack of external dependencies on the tests, this approach I spied from Ben Johnson .

Are you curious about how the HTTPClient implementation will look like?

 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) } 

This is easy - just implement the methods for Post and Get . Note that the constructor does not return an interface and a specific type; this approach is recommended in Go. And the interface must be declared in the consumer packet that will use the HTTPClient . In our case, the user package can be called:

 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) } ) 

And, finally, let's put it all together in main.go

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

I hope this example will help you start using the principle of separation of interfaces in order to write a more idiomatic Go code that is easy to test and with explicit dependencies. In the next article, I will add in the HTTPClient logic for processing failures and re-sending, stay connected.

The complete source code for the example implementation .

Special thanks to my friends Bastian and Felipe for reviewing this article.

Source: https://habr.com/ru/post/350856/


All Articles