在 Golang 中玩轉依賴注入 - dig 篇

什麼是依賴注入?有時候一個結構體非常複雜,包含了非常多各種類型的屬性,這些屬性又包含了更多的屬性,當我們創建這樣一個結構體時需要編寫大量的代碼。面向接口編程可以讓我們的代碼避免耦合更具擴展性,但統一更換接口實現時需要大範圍的修改代碼。

依賴注入幫助我們解決類似的問題,依賴注入框架能夠自動解析依賴關係,幫助我們自動構建結構體實例。依賴注入可以對接口注入實例,讓整個代碼系統不用關注具體的接口實現。

由於 Go 語言靜態的特性,依賴注入在 Go 中應用並不廣泛,主要有兩種實現方式:代碼生成和反射。

上一篇已經介紹了 Go 依賴注入的中代碼生成實現的典型代表wire,如果還沒閱讀可以點擊👉 在 Golang 中玩轉依賴注入 - wire 篇

本篇來介紹下反射實現的代表dig

安裝

因爲不需要命令行操作,因此只單獨引入 dig 庫即可

go get 'go.uber.org/dig@v1'

快速入門

dig 中也有一些概念需要理解

容器

依賴注入容器就是用來解析各種結構體依賴關係的對象,或者簡單點理解,他就是一個 dig 實例

c := dig.New()

Provide

wire中的provider概念類似(沒看過wire也沒關係),它就是一個普通的工廠函數,函數返回值是要被創建的類型,函數入參可以有多個,用來描述創建返回值需要依賴的其他類型。

type UserGateway struct {
 conn *sql.DB
}

func (g UserGateway) GetUserName(id string) (name string, err error) {
 row := g.conn.QueryRow("select name from users")
 err = row.Scan(&name)
 return
}

err := c.Provide(func(conn *sql.DB) (*UserGateway, error) {
  return &UserGateway{conn}, nil
})
if err != nil {
  // ...
}

上面代碼的意思是,創建UserGateway依賴一個conn *sql.DB

那怎麼創建conn *sql.DB呢?當然是提供一個conn *sql.DBprovider

err = c.Provide(func(opt *Option) (*sql.DB, error) {
  return sql.Open(opt.driver, opt.dsn)
})
if err != nil {
  // ...
}

以此類推,我們還得提供一個opt *Optionprovider

err = c.Provide(func() *Option {
  return &Option{
    driver: "mysql",
    dsn:    "user:password@/dbname",
  }
})
if err != nil {
  // ...
}

於是構建UserGateway條件就全部滿足了

Invoke

現在我們在依賴注入容器描述瞭如何創建每一個類型,以及創建每一個類型依賴的其他類型了。如果想要使用容器中的實例就變得非常簡單了

err := c.Invoke(func(g *UserGateway) {
  name, err := g.GetUserName("liang")
  if err != nil {
    panic(err)
  }

  fmt.Println(name)
})

if err != nil {
  panic(err)
}

當需要使用g *UserGateway時,只需要像示例代碼一樣,放入Invoker入參中,dig會根據依賴關係正確創建g *UserGateway實例並完成函數的執行

應用場景

官方文檔中提到了 dig 的推薦場景:

下面示例是一個使用dig依賴注入的 web 服務器,並且在啓動階段就完成了全部的依賴注入

完整代碼可以參考:https://github.com/liangwt/note_example/tree/main/dig

package main

import (
 "github.com/gin-gonic/gin"
 "github.com/liangwt/note/golang/demo/dig/internal"
)

func main() {
  // provider代碼簡略
 c := internal.Init()

 r := gin.Default()

 err := c.Invoke(func(g *internal.UserGateway) {
  r.GET("/get_username", func(c *gin.Context) {
   name, err := g.GetUserName(c.Query("id"))
   if err != nil {
    c.JSON(500, err.Error())
    return
   }

   c.JSON(200, gin.H{
    "name": name,
   })
  })
 })
 if err != nil {
  panic(err)
 }

 r.Run()
}

進階用法

參數對象和返回值對象

前面提到provider函數的入參和返回值可以是多個,但當參數或返回值太多時,可讀性較差

// internal.go
type PostGateway struct {
 conn *sql.DB
}

type CommentGateway struct {
 conn *sql.DB
}

type UserGateway struct {
 conn *sql.DB
}

err = c.Provide(func(Logger *log.Logger, DB *sql.DB) (
 Comments *CommentGateway,
 Posts *PostGateway,
 Users *UserGateway,
 err error,
) {
 return &CommentGateway{conn: DB},
  &PostGateway{conn: DB},
  &UserGateway{conn: DB},
  nil
})
if err != nil {
 // ...
}
// main.go
func main() {
 c := internal.Init()

 err := c.Invoke(func(g *internal.UserGateway) {
  name, err := g.GetUserName("id")
  if err != nil {
   fmt.Println(err)
   return
  }

  fmt.Println(name)
 })
 if err != nil {
  panic(err)
 }
}

我們可以傳遞結構體來增強可讀性。和普通結構體參數 / 返回值不一樣,dig要求的結構體必須內嵌dig.In或者dig.Out,下面的示例和上面的代碼是等價的

type Gateways struct {
 dig.Out

 Comments *CommentGateway
 Posts    *PostGateway
 Users    *UserGateway
}

type Connection struct {
 dig.In

 Logger *log.Logger
 DB     *sql.DB
}

err = c.Provide(func(conn Connection) (Gateways, error) {
 return Gateways{
  Comments: &CommentGateway{conn: conn.DB},
  Posts:    &PostGateway{conn: conn.DB},
  Users:    &UserGateway{conn: conn.DB},
 }, nil
})
if err != nil {
 // ...
}

在 Invoke 時也沒有區別,依舊可以直接使用UserGateway而不是Gateways

// main.go
func main() {
 c := internal.Init()

 err := c.Invoke(func(g *internal.UserGateway) {
  name, err := g.GetUserName("id")
  if err != nil {
   fmt.Println(err)
   return
  }

  fmt.Println(name)
 })
 if err != nil {
  panic(err)
 }
}

注意參數對象 / 返回值對象與普通結構體的區別

🌲 參數對象 / 返回值對象包含dig.Indig.Out,雖然是一個結構體,但代表的是多個參數 / 返回值,結構體的每一個字段都代表着參數 / 返回值

對於上面的例子,Gateways因爲包含dig.Out,所以Invoke時可以注入*CommentGateway*PostGateway*UserGateway

而下面例子Gateways不包含dig.Out只是普通的結構體,Invoke時只能注入Gateways,注入*UserGateway將會報錯

type Gateways struct {

 Comments *CommentGateway
 Posts    *PostGateway
 Users    *UserGateway
}

type Connection struct {
 dig.In
  
 Logger *log.Logger
 DB     *sql.DB
}

err = c.Provide(func(conn Connection) (Gateways, error) {
 return Gateways{
  Comments: &CommentGateway{conn: conn.DB},
  Posts:    &PostGateway{conn: conn.DB},
  Users:    &UserGateway{conn: conn.DB},
 }, nil
})
if err != nil {
 // ...
}

func main() {
 c := internal.Init()

 err := c.Invoke(func(g internal.Gateways) {
  name, err := g.Users.GetUserName("id")
  if err != nil {
   fmt.Println(err)
   return
  }

  fmt.Println(name)
 })
 if err != nil {
  panic(err)
 }
}

🌲 參數對象 / 返回值對象不能是指針類型,普通類型是可以是指針類型的

可選依賴

有些依賴並不是必須的,因此可以通過結構體 tag:optional:"true"把某些依賴標記成可選的。可選依賴只能在參數對象上使用

如下的示例中,即使沒有提供*redis.Client的 provide 函數,Gateways依舊可以被創建成功

type UserGateway struct {
 conn  *sql.DB
 cache *redis.Client
}

func (g *UserGateway) GetUserName(id string) (name string, err error) {
 if g.cache != nil {
  name := g.cache.Get(id).Val()
  if name != "" {
   return name, nil
  }
 }

 row := g.conn.QueryRow("select name from users")
 err = row.Scan(&name)
 if err != nil && g.cache != nil {
  g.cache.Set(id, name, -1)
 }
 return
}

type Connection struct {
 dig.In

 Logger *log.Logger
 Cache  *redis.Client `optional:"true"`
 DB     *sql.DB
}

err = c.Provide(func(conn Connection) (Gateways, error) {
 return Gateways{
  Comments: &CommentGateway{conn: conn.DB},
  Posts:    &PostGateway{conn: conn.DB},
  Users:    &UserGateway{conn: conn.DB, cache: conn.Cache},
 }, nil
})
if err != nil {
 // ...
}

命名值

有時候我們可能同時依賴兩個相同類型的資源,例如讀寫分離的庫

type UserGateway struct {
 roDB  *sql.DB
 rwDB  *sql.DB
 cache *redis.Client
}

func (g *UserGateway) GetUserName(id string) (name string, err error) {
 if g.cache != nil {
  name := g.cache.Get(id).Val()
  if name != "" {
   return name, nil
  }
 }

 row := g.roDB.QueryRow("select name from users")
 err = row.Scan(&name)
 if err != nil && g.cache != nil {
  g.cache.Set(id, name, -1)
 }
 return
}

以上場景無論我們怎麼提供兩個*sql.DB的 provider,都無法區分出來哪一個是隻讀庫,哪一個是讀寫庫

err = c.Provide(func(opt *Option) (*sql.DB, *sql.DB, error) {
  // 讀寫庫
 rw, err := sql.Open(opt.driver, opt.dsn)
 if err != nil {
  return nil, nil, err
 }
 
  // 只讀庫
 ro, err := sql.Open("mysql""user:password@/ro_dbname")
 if err != nil {
  return nil, nil, err
 }

 return rw, ro, nil
})
if err != nil {
 // ...
}

////////////////以下和上文代碼等價////////////////

// 讀寫庫
err = c.Provide(func(opt *Option) (*sql.DB, error) {
 return sql.Open(opt.driver, opt.dsn)
})
if err != nil {
 // ...
}

// 只讀庫
err = c.Provide(func() (*sql.DB, error) {
 return sql.Open("mysql""user:password@/ro_dbname")
})
if err != nil {
 // ...
}

在構建*UserGateway時,rwDB, roDB也無法對應以上兩個*sql.DB

err = c.Provide(func(Logger *log.Logger, rwDB, roDB *sql.DB) (
 Comments *CommentGateway,
 Posts *PostGateway,
 Users *UserGateway,
 err error,
) {
 return &CommentGateway{rwDB: rwDB},
  &PostGateway{rwDB: rwDB},
  &UserGateway{rwDB: rwDB, roDB: roDB},
  nil
})
if err != nil {
 // ...
}

解決方案就是命名

首先我們要做的是分別給兩個*sql.DB一個名字,用來區分兩個*sql.DB

如果各自使用獨立的函數,則可以使用dig.Name

// 讀寫庫
err = c.Provide(func(opt *Option) (*sql.DB, error) {
 return sql.Open(opt.driver, opt.dsn)
}, dig.Name("rw"))
if err != nil {
 // ...
}

// 只讀庫
err = c.Provide(func() (*sql.DB, error) {
 return sql.Open("mysql""user:password@/ro_dbname")
}, dig.Name("ro"))
if err != nil {
 // ...
}

如果使用一個函數構建兩個*sql.DB,則可以使用返回值對象的 tag

type DBResult struct {
 dig.Out

 RWDB *sql.DB `name:"rw"`
 RODB *sql.DB `name:"ro"`
}

err = c.Provide(func(opt *Option) (DBResult, error) {
 rw, err := sql.Open(opt.driver, opt.dsn)
 if err != nil {
  return DBResult{}, err
 }

 ro, err := sql.Open("mysql""user:password@/ro_dbname")
 if err != nil {
  return DBResult{}, err
 }

 return DBResult{RWDB: rw, RODB: ro}, nil
})
if err != nil {
 // ...
}

通過以上兩種方式,我們聲明瞭兩個*sql.DB,哪一個是隻讀庫,哪一個是讀寫庫。下面要做的就是使用這兩個庫是指定對應關係。於是RODB *sql.DB便會被注入只讀庫。

type Connection struct {
 dig.In

 Logger *log.Logger
 Cache  *redis.Client `optional:"true"`
 RODB   *sql.DB       `name:"ro"`
 RWDB   *sql.DB       `name:"rw"`
}

type Gateways struct {
 dig.Out

 Comments *CommentGateway
 Posts    *PostGateway
 Users    *UserGateway
}
err = c.Provide(func(conn Connection) (Gateways, error) {
 return Gateways{
  Comments: &CommentGateway{rwDB: conn.RWDB},
  Posts:    &PostGateway{rwDB: conn.RWDB},
  Users:    &UserGateway{rwDB: conn.RWDB, roDB: conn.RODB, cache: conn.Cache},
 }, nil
})
if err != nil {
 // ...
}

總結

本篇介紹了利用反射實現的依賴注入框架dig

可以看到dig在使用被注入的依賴時,需要放入Invoke的函數中,如果在代碼中任意使用,必然大幅影響代碼的可讀性。和wire不同,如果缺少某項依賴dig在編譯階段不會報錯,只有在運行時的 panic

基於以上兩點,我更推薦在服務啓動階段就利用dig完成全部依賴關係解析,不推薦在服務啓動後再使用dig

dig也被集成到了 https://github.com/uber-go/fx 的框架中,有機會我也會給大家介紹下 fx 框架

以上所有的示例的代碼都可以在 https://github.com/liangwt/note_example/tree/main/dig 找到

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/0DRrMNkQUm3bXt5d3wMAbg