📜 ⬆️ ⬇️

Embedding Dependencies in Go


Recently, I created a small project in the Go language. After several years of working with Java, I was very surprised at how sluggishly dependency injection (DI) is applied in the Go ecosystem. For my project, I decided to use the dig library from Uber, and it really impressed me.

I found that dependency injection allows you to solve many problems I encountered while working on Go-applications: abuse of the init function and global variables, excessive complexity of setting up applications, etc.

In this article I will talk about the basics of dependency injection, as well as show an example of the application before and after applying this mechanism (via the dig library).
')

A quick overview of the dependency injection mechanism


The DI mechanism assumes that dependencies are provided to components (a struct in Go) when they are created externally. This contrasts with the anti-pattern of components that themselves form their dependencies during initialization. Let's turn to an example.

Suppose you have a Server structure that requires Config to implement its behavior. As one of the options, the Server can create its own Config structure during initialization.

 type Server struct { config *Config } func New() *Server { return &Server{ config: buildMyConfigSomehow(), } } 

It looks comfortable. The calling operator does not need to be aware that the Server requires access to the Config . Such information is hidden from the user function.

However, there are drawbacks. First of all, if we decide to change the function of creating Config , then along with it we will have to change all those places that cause the corresponding code. Suppose the buildMyConfigSomehow function now requests an argument. This means that access to this argument is now needed for any call to this function.

In addition, in such a situation it will be difficult to simulate the Config structure for its testing without dependencies. To test the creation of Config using arbitrary data (monkey testing), we will have to somehow get into the bowels of the New function.

But how to solve this problem using DI:

 type Server struct { config *Config } func New(config *Config) *Server { return &Server{ config: config, } } 

Now the Server and Config structures are created separately from each other. We can use any suitable logic to create a Config , and then pass the data to the New function.

In addition, if Config is an interface, then we can easily conduct mock testing for it. Any argument that allows us to implement our interface, we can pass to the New function. This simplifies testing the Server structure with the help of Config mock objects.

The main disadvantage of this approach is the need to manually create a Config structure before we can create a Server . It is very uncomfortable. Here we have a dependency graph: first you need to create a Config structure, because Server depends on it. In real-world applications, such graphs can grow too much, which complicates the logic of creating all the components necessary for the application to work properly.

The situation can be corrected by DI due to the following two mechanisms:

  1. The mechanism of "providing" new components. In short, it tells the DI framework what components you need to create an object (your dependencies), as well as how to create this object after getting all the necessary components.
  2. The mechanism of "extraction" of the created components.

The DI framework builds a dependency graph based on the “providers” that you report to it, and then determines how to create your objects. This is difficult to explain theoretically, so let's consider one relatively small practical example.

Sample application


As an example, let's use an HTTP server code that returns a JSON response when a client makes a GET request to /people . We will consider it in parts. To simplify this example, all our code will be in one package ( main ). In real Go applications, this should not be done. You can find the full code from this example here .
First, let's turn to the Person structure. It does not implement any behavior, only a few JSON tags are declared.

 type Person struct { Id int `json:"id"` Name string `json:"name"` Age int `json:"age"` } 

In the Person structure there are tags Id , Name and Age . And that's all.

Now look at the Config . Like Person , this structure has no dependencies. However, unlike Person , it has a constructor.

 type Config struct { Enabled bool DatabasePath string Port string } func NewConfig() *Config { return &Config{ Enabled: true, DatabasePath: "./example.db", Port: "8000", } } 

The Enabled field determines whether our application will return real data. The DatabasePath field indicates the path to the database (we use SQlite). The Port field specifies the port on which our server will run.

To connect to the database, we will use the following function. It works with Config and returns *sql.D B.

 func ConnectDatabase(config *Config) (*sql.DB, error) { return sql.Open("sqlite3", config.DatabasePath) } 

Now look at the PersonRepository structure. She will be responsible for retrieving information about people from our database and deserializing it into appropriate Person structures.

 type PersonRepository struct { database *sql.DB } func (repository *PersonRepository) FindAll() []*Person { rows, _ := repository.database.Query( `SELECT id, name, age FROM people;` ) defer rows.Close() people := []*Person{} for rows.Next() { var ( id int name string age int ) rows.Scan(&id, &name, &age) people = append(people, &Person{ Id: id, Name: name, Age: age, }) } return people } func NewPersonRepository(database *sql.DB) *PersonRepository { return &PersonRepository{database: database} } 

The PersonRepository structure requires a database connection. It provides only one function — FindAll , which uses this connection to return a list of Person structures that correspond to information in the database.

We need the PersonService structure to create a layer between the HTTP server and PersonRepository .

 type PersonService struct { config *Config repository *PersonRepository } func (service *PersonService) FindAll() []*Person { if service.config.Enabled { return service.repository.FindAll() } return []*Person{} } func NewPersonService(config *Config, repository *PersonRepository) *PersonService { return &PersonService{config: config, repository: repository} } 

PersonService depends not only on Config , but also on PersonRepository . It contains the FindAll function, which conditionally calls the PersonRepository if the application is enabled.

Finally, the Server structure. It is responsible for executing the HTTP server and sending the corresponding requests to the PersonService .

 type Server struct { config *Config personService *PersonService } func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/people", s.people) return mux } func (s *Server) Run() { httpServer := &http.Server{ Addr: ":" + s.config.Port, Handler: s.Handler(), } httpServer.ListenAndServe() } func (s *Server) people(w http.ResponseWriter, r *http.Request) { people := s.personService.FindAll() bytes, _ := json.Marshal(people) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) w.Write(bytes) } func NewServer(config *Config, service *PersonService) *Server { return &Server{ config: config, personService: service, } } 

Server depends on the PersonService and Config structures.
So, we know all the components. So how do you now initialize them and start our system?

Great and terrible main ()


First, let's write the main() function in the traditional way.

 func main() { config := NewConfig() db, err := ConnectDatabase(config) if err != nil { panic(err) } personRepository := NewPersonRepository(db) personService := NewPersonService(config, personRepository) server := NewServer(config, personService) server.Run() } 

First, we set the Config structure. Then with its help we create a connection to the database. After that, you can create a PersonRepository structure, and on its basis, a PersonService structure. Finally, we use all of this to create and launch a Server .

Pretty complicated process. And what's even worse, as our application becomes more complex, the main function will become more and more complicated. Every time we add dependencies to any of our components, we will have to add logic and re-revise the main function.

As you might have guessed, this problem can be solved using the dependency injection mechanism. Let's find out how to achieve this.

Container creation


Within the framework of the DI framework, “containers” is the place where you add “suppliers” and from where you are requesting complete objects. The dig library provides us with Provide and Invoke functions. The first one is used to add suppliers, the second one is to retrieve completely finished objects from the container.

First create a new container.

container := dig.New()

Now we can add suppliers. To do this, call the Provide container function. It has one argument: a function that can have any number of arguments (they reflect the dependencies of the component being created), as well as one or two return values ​​(the component provided by the function and, if necessary, an error).

 container.Provide(func() *Config { return NewConfig() }) 

This code states: “I give the container the type Config . I don't need anything else to create it. ” Now that our container knows how to create a Config type, we can use it to create other types.

 container.Provide(func(config *Config) (*sql.DB, error) { return ConnectDatabase(config) }) 

The code says: “I give the container the type *sql.DB To create it, I need Config . In addition, if necessary, I can return the error. "
In both cases, we are too verbose. Since we already have the NewConfig and ConnectDatabase functions NewConfig , we can directly use them as providers for the container.

 container.Provide(NewConfig) container.Provide(ConnectDatabase) 

Now you can ask the container to provide us with a fully prepared component of any of the proposed types. For this we use the Invoke function. The function argument Invoke is a function with any number of arguments. They are the types that we create our container for.

 container.Invoke(func(database *sql.DB) { }) 

The container performs truly unbanal actions. Here is what happens:


The container does a whole lot of work for us, and in fact even does more. He is smart enough to create only one instance of each type provided. And this means that we will never accidentally create an extra connection to the database if we use it in several places (for example, in several repositories).

Improved main () version


Now that we know how the dig container works, let's use it to optimize the main function.

 func BuildContainer() *dig.Container { container := dig.New() container.Provide(NewConfig) container.Provide(ConnectDatabase) container.Provide(NewPersonRepository) container.Provide(NewPersonService) container.Provide(NewServer) return container } func main() { container := BuildContainer() err := container.Invoke(func(server *Server) { server.Run() }) if err != nil { panic(err) } } 

The only thing we haven't encountered yet is the error value returned by the Invoke function. If any of the providers used by the Invoke function returns an error, the function will be suspended and returned to the caller.

Despite the small size of this example, it is easy to note the advantages of this approach compared to the standard main . The more our application becomes, the more obvious these advantages will be.

One of the most important positive moments is the separation of the processes of creating components and their dependencies. Suppose that our PersonRepository now needs access to the Config . All we have to do is add Config as an argument to the NewPersonRepository constructor. No additional code changes are required.

Other important advantages include the reduction in the number of global variables and objects used, as well as calls to the init function (dependencies are created only once, when necessary, so you no longer need to use the error-prone init mechanisms). In addition, this approach allows us to simplify testing of individual components. Imagine that during testing you create a container and request a complete object. Or that you need to create an object with fictitious implementations of all its dependencies (mock-object). All this is much easier to do with the mechanism of dependency injection.

Idea worth spreading


I’m sure that the dependency injection mechanism allows you to create more robust applications that are also easier to test. The larger the application, the more pronounced this is. The Go language is great for building large applications, and the dig library is a great tool for dependency injection. I think that the Go programmers community should pay more attention to DI and more often use this mechanism in applications.

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


All Articles