专栏名称: 狗厂
目录
相关文章推荐
51好读  ›  专栏  ›  狗厂

【英】Go上实现依赖注入

狗厂  · 掘金  ·  · 2018-05-18 05:54

正文

I recently built a small project in Go. I've been working with Java for the past few years and was immediately struck by the lack of momentum behind Dependency Injection (DI) in the Go ecosystem. I decided to try building my project using Uber's dig library and was very impressed.

I found that DI helped solve a lot of problems I had encountered in my previous Go applications -- overuse of the init function, abuse of globals and complicated application setup.

In this post I'll give an introduction to DI and then show an example application before and after using a DI framework (via the dig library).

A Brief Overview of DI

Dependency Injection is the idea that your components (usually structs in go) should receive their dependencies when being created. This runs counter to the associated anti-pattern of components building their own dependencies during initialization. Let's look at an example.

Suppose you have a Server struct that requires a Config struct to implement its behavior. One way to do this would be for the Server to build its own Config during initialization.

type Server struct {
  config *Config
}

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

This seems convenient. Our caller doesn't have to be aware that our Server even needs access to Config . This is all hidden from the user of our function.

However, there are some disadvantages. First of all, if we want to change the way our Config is built, we'll have to change all the places that call the building code. Suppose, for example, our buildMyConfigSomehow function now needs an argument. Every call site would need access to that argument and would need to pass it into the building function.

Also, it gets really tricky to mock the behavior of our Config . We'll somehow have to reach inside of our New function to monkey with the creation of Config .

Here's the DI way to do it:

type Server struct {
  config *Config
}

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

Now the creation of our Server is decoupled from the creation of the Config . We can use whatever logic we want to create the Config and then pass the resulting data to our New function.

Furthermore, if Config is an interface, this gives us an easy route to mocking. We can pass anything we want into New as long as it implements our interface. This makes testing our Server with mock implementations of Config simple.

The main downside is that it's a pain to have to manually create the Config before we can create the Server . We've created a dependency graph here -- we must create our Config first because of Server depends on it. In real applications these dependency graphs can become very large and this leads to complicated logic for building all of the components your application needs to do its job.

This is where DI frameworks can help. A DI framework generally provides two pieces of functionality:

  1. A mechanism for "providing" new components. In a nutshell, this tells the DI framework what other components you need to build yourself (your dependencies) and how to build yourself once you have those components.
  2. A mechanism for "retrieving" built components.

A DI framework generally builds a graph based on the "providers" you tell it about and determines how to build your objects. This is very hard to understand in the abstract, so let's walk through a moderately-sized example.

An Example Application

We're going to be reviewing the code for an HTTP server that delivers a JSON response when a client makes a GET request to /people . We'll review the code piece by piece. For simplicity sake, it all lives in the same package ( main ). Please don't do this in real Go applications. Full code for this example can be found here .

First, let's look at our Person struct. It has no behavior save for some JSON tags.

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

A Person has an Id , Name and Age . That's it.

Next let's look at our Config . Similar to Person , it has no dependencies. Unlike Person , we will provide a constructor.

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

Enabled tells us if our application should return real data. DatabasePath tells us where our database lives (we're using sqlite). Port tells us the port on which we'll be running our server.

Here's the function we'll use to open our database connection. It relies on our Config and returns a *sql.DB .

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

Next we'll look at our PersonRepository . This struct will be responsible for fetching people from our database and deserializing those database results into proper Person structs.

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

PersonRepository requires a database connection to be built. It exposes a single function called FindAll that uses our database connection to return a list of Person structs representing the data in our database.

To provide a layer between our HTTP server and the PersonRepository , we'll create a PersonService .

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

Our PersonService relies on both the Config and the PersonRepository . It exposes a function called FindAll that conditionally calls the PersonRepository if the application is enabled.







请到「今天看啥」查看全文