依賴注入(dependency injection)最通俗的講解

這篇文章解釋了什麼是依賴注入(又稱控制反轉),以及它如何改善定義業務邏輯的代碼。

服務和依賴

服務可以是您編寫的類,也可以是來自導入庫的類。例如,它可以是一個 logger 或一個 database connection。因此,您可以編寫一個無需任何外部幫助即可單獨運行的服務,但也可能您會很快您會達到一個點,即其中一個服務將不得不使用另一個服務的代碼的地步。

讓我們看一個小的例子

我們將創建一個EmailSender。此類將用於發送電子郵件。它必須在數據庫中寫入已發送電子郵件的信息,並記錄可能發生的錯誤。

EmailSender 將依賴於其他三項服務:用於發送電子郵件的 SmtpClient,用於與數據庫交互的 EmailRepository 以及用於記錄錯誤的 Logger。

通常情況下我們會怎麼實現呢?

1.EmailSender 的三個依賴關係被聲明爲屬性。依賴項注入的思想是,EmailSender不應該負責創建其依賴項,它們應該從外部注入。對EmailSender來說,其配置的詳細信息應該是未知的。

interface SmtpClientInterface {
    send(toName: string, toEmail: string, subject: string, message: string)
}

interface EmailRepositoryInterface {
    insertEmail(address: string, email: Email, status: string)
    updateEmailStatus(id: number, status: string)
}

interface LoggerInterface {
    error(message: string)
}

class EmailSender {
    client: SmtpClientInterface
    repo: EmailRepositoryInterface
    logger: LoggerInterface
    
    send(user: User, email: Email) {
        try {
            this.repo.insertEmail(user.email, email, "sending")
            this.client.send(user.email, user.name, email.subject, email.message)
            this.repo.updateEmailStatus(email.id, "sent")
        } catch(e) {
            this.logger.error(e.toString())
        }
    }
}

2.使用 setter,您可以在EmailSender上添加setSmtpClient(),setEmailRepository()和setLogger()方法。

// inject dependencies with setters
sender = new EmailSender()
sender.setSmtpClient(client)
sender.setEmailRepository(repo)
sender.setLogger(logger)

3.在構造函數中設置依賴項。它確保一旦創建對象,它就會按預期工作並且不會遺忘任何依賴關係。

class EmailSender {
    // ... dependencies and other attributes ...
    
    constructor(client: SmtpClientInterface, repo: EmailRepositoryInterface, logger: LoggerInterface) {
        this.client = client
        this.repo = repo
        this.logger = logger
    }
    
    // ... methods ...
}

依賴注入和解耦

依賴項注入的關鍵概念是解耦,它們應該從外部注入。服務不應將其依賴項綁定到特定的實現,他們將失去可重用性。因此,這將使它們更難維護和測試。

例如,如果將依賴像這樣寫入構造函數。

constructor() {
    this.client = new SmtpClient()
}

這麼做很不好,你的服務將只能使用特定的Smtp客戶端,它的配置不能被更改(一旦要更換一個Smtp客戶端你需要修改業務代碼,這樣不好)。

應該怎麼做:

constructor(client: SmtpClientInterface) {
    this.client = client
}

這樣,您就可以自由使用你想要的實現。

話雖如此,構造函數的參數不一定必須是接口:

constructor(client: SmtpClient) {
    this.smtp = smtp
}

一般情況下這種方式足夠了,接口很棒,但是它們會使您的代碼難以閱讀。如果要避免過度設計的代碼,那麼從類(classes)開始可能是一個很好的方法。然後在必要時將其替換爲接口(interfaces)。

總結

依賴項注入的主要優點是解耦。它可以極大地提高代碼的可重用性和可測試性。缺點是,服務的創建將會遠離您的業務邏輯,這會使您的代碼更難理解。

如何用DI編寫Go中的REST API

爲了進一步說明DI的優點,我們將使用DI編寫Go中的REST API。

現在假設我們要開發一個汽車管理的項目,需要提供幾個API,對cars進行增刪改查(CRUD)操作。

API description

api的作用是管理汽車列表。該api實現以下基本的CRUD操作:

clipboard.png

請求和響應主體用json編碼。api處理以下錯誤代碼:

  • 400 - Bad Request : the parameters of the request are not valid
  • 404 - Not Found : the car does not exist
  • 500 - Internal Error : an unexpected error occurred (eg: the database connection failed)

Project structure

項目結構非常簡單:

├─ app
│   ├─ handlers
│   ├─ middlewares
│   └─ models
│       ├─ garage
│       └─ helpers
│
├─ config
│   ├─ logging
│   └─ services
│
└─ main.go 
  • main.go文件是應用程序的入口點。它的作用是創建一個可以處理api路由的Web服務器。
  • app/handler 和 app/middlewares 就像他們的名字所說的,是定義應用程序的處理程序和中間件的位置。它們代表了MVC應用程序的控制器部分,僅此而已。
  • app/models/garage 包含業務邏輯。換句話說,它定義了什麼是汽車以及如何管理它們。
  • app/models/helpers由可以協助處理程序的功能組成。 ReadJSONBody函數可以解碼http請求的正文,而JSONResponse函數可以編寫json響應。該軟件包還包括兩個錯誤類型:ErrValidation和ErrNotFound。它們用於促進http處理程序中的錯誤處理。
  • 在config/logging目錄中,logger 定義爲全局變量。記錄器是一個特殊的對象。那是因爲您需要儘快在應用程序中安裝一個記錄器。而且您還希望保留它直到應用程序停止。
  • 在config/services中,您可以找到依賴注入容器的服務定義。它們描述了服務的創建方式以及應如何關閉服務。

Model

我們先在model中定義好car的數據結構。

// Car is the structure representing a car.
type Car struct {
    ID    string `json:"id" bson:"_id"`
    Brand string `json:"brand" bson:"brand"`
    Color string `json:"color" bson:"color"`
}

它代表一輛非常簡單的汽車,只有兩個字段,一個品牌和一個顏色。Car 是保存在數據庫中的結構。該結構也用於請求和響應中。

CarManager 的結構體及業務邏輯層的CRUD操作。

type CarManager struct {
    Repo   *CarRepository
    Logger *zap.Logger
}

// GetAll returns the list of cars.
func (m *CarManager) GetAll() ([]*Car, error) {
    cars, err := m.Repo.FindAll()

    if cars == nil {
        cars = []*Car{}
    }

    if err != nil {
        m.Logger.Error(err.Error())
    }

    return cars, err
}

// Get returns the car with the given id.
// If the car does not exist an helpers.ErrNotFound is returned.
func (m *CarManager) Get(id string) (*Car, error) {
    car, err := m.Repo.FindByID(id)

    if m.Repo.IsNotFoundErr(err) {
        return nil, helpers.NewErrNotFound("Car `" + id + "` does not exist.")
    }

    if err != nil {
        m.Logger.Error(err.Error())
    }

    return car, err
}

// Create inserts the given car in the database.
// It returns the inserted car.
func (m *CarManager) Create(car *Car) (*Car, error) {
    if err := ValidateCar(car); err != nil {
        return nil, err
    }

    car.ID = bson.NewObjectId().Hex()

    err := m.Repo.Insert(car)

    if m.Repo.IsAlreadyExistErr(err) {
        return m.Create(car)
    }

    if err != nil {
        m.Logger.Error(err.Error())
        return nil, err
    }

    return car, nil
}

// Update updates the car with the given id.
// It uses the values contained in the given car fields.
// It returns the updated car.
func (m *CarManager) Update(id string, car *Car) (*Car, error) {
    if err := ValidateCar(car); err != nil {
        return nil, err
    }

    car.ID = id

    err := m.Repo.Update(car)

    if m.Repo.IsNotFoundErr(err) {
        return nil, helpers.NewErrNotFound("Car `" + id + "` does not exist.")
    }

    if err != nil {
        m.Logger.Error(err.Error())
        return nil, err
    }

    return car, err
}

// Delete removes the car with the given id.
func (m *CarManager) Delete(id string) error {
    err := m.Repo.Delete(id)

    if m.Repo.IsNotFoundErr(err) {
        return nil
    }

    if err != nil {
        m.Logger.Error(err.Error())
    }

    return err
}

CarManager 是一個數據結構體,被handlers用來處理執行CRUD操作。每個方法對應一個http handle 。 CarManager需要一個CarRepository來執行mongo查詢。

CarRepository 的結構體及具體DB層的操作

package garage

import mgo "gopkg.in/mgo.v2"

// CarRepository contains all the interactions
// with the car collection stored in mongo.
type CarRepository struct {
    Session *mgo.Session
}

// collection returns the car collection.
func (repo *CarRepository) collection() *mgo.Collection {
    return repo.Session.DB("dingo_car_api").C("cars")
}

// FindAll returns all the cars stored in the database.
func (repo *CarRepository) FindAll() ([]*Car, error) {
    var cars []*Car
    err := repo.collection().Find(nil).All(&cars)
    return cars, err
}

// FindByID retrieves the car with the given id from the database.
func (repo *CarRepository) FindByID(id string) (*Car, error) {
    var car *Car
    err := repo.collection().FindId(id).One(&car)
    return car, err
}

// Insert inserts a car in the database.
func (repo *CarRepository) Insert(car *Car) error {
    return repo.collection().Insert(car)
}

// Update updates all the caracteristics of a car.
func (repo *CarRepository) Update(car *Car) error {
    return repo.collection().UpdateId(car.ID, car)
}

// Delete removes the car with the given id.
func (repo *CarRepository) Delete(id string) error {
    return repo.collection().RemoveId(id)
}

// IsNotFoundErr returns true if the error concerns a not found document.
func (repo *CarRepository) IsNotFoundErr(err error) bool {
    return err == mgo.ErrNotFound
}

// IsAlreadyExistErr returns true if the error is related
// to the insertion of an already existing document.
func (repo *CarRepository) IsAlreadyExistErr(err error) bool {
    return mgo.IsDup(err)
}

CarRepository只是mongo查詢的包裝器。這裏的CarRepository可以是具體的CarMongoRepository或CarPsgRepository等。

在Repository中分離數據庫查詢可以輕鬆列出與數據庫的所有交互。在這種情況下,很容易替換數據庫。例如,您可以使用postgres代替mongo創建另一個存儲庫。它還爲您提供了爲測試創建模擬存儲庫的機會。

服務依賴配置

以下配置了每個服務的依賴,比如car-manager依賴car-repository和logger

The logger is in the App scope. It means it is only created once for the whole application. The Build function is called the first time to retrieve the service. After that, the same object is returned when the service is requested again.

logger是在App範圍內。這意味着它只爲整個應用程序創建一次。第一次調用Build函數來檢索服務。之後,當再次請求服務時,將返回相同的對象。

聲明如下:

var Services = []di.Def{
    {
        Name:  "logger",
        Scope: di.App,
        Build: func(ctn di.Container) (interface{}, error) {
            return logging.Logger, nil
        },
    },
    // other services
}

現在我們需要一個mongo連接。我們首先要的是連接池。然後,每個http請求將使用該池來檢索其自己的連接。

因此,我們將創建兩個服務。在App範圍內爲mongo-pool,在Request範圍內爲mongo:

 {
        Name:  "mongo-pool",
        Scope: di.App,
        Build: func(ctn di.Container) (interface{}, error) {
            // create a *mgo.Session
            return mgo.DialWithTimeout(os.Getenv("MONGO_URL"), 5*time.Second)
        },
        Close: func(obj interface{}) error {
            // close the *mgo.Session, it should be cast first
            obj.(*mgo.Session).Close()
            return nil
        },
    },
    {
        Name:  "mongo",
        Scope: di.Request,
        Build: func(ctn di.Container) (interface{}, error) {
            // get the pool of connections (*mgo.Session) from the container
            // and retrieve a connection thanks to the Copy method
            return ctn.Get("mongo-pool").(*mgo.Session).Copy(), nil
        },
        Close: func(obj interface{}) error {
            // close the *mgo.Session, it should be cast first
            obj.(*mgo.Session).Close()
            return nil
        },
    },

mongo服務在每個請求中被創建,它使用mongo-pool服務取得數據庫連接。mongo服務可以在Build函數中使用mongo-pool服務,多虧了容器的Get方法。

請注意,在兩種情況下關閉mongo連接也很重要。這可以使用定義的“關閉”字段來完成。刪除容器時將調用Close函數。它發生在針對請求容器的每個http請求的末尾,以及針對App容器的程序停止時。

接下來是CarRepository。這依賴於mongo服務。由於mongo連接在Request範圍內,因此CarRepository不能在App範圍內。它也應該在Request範圍內。

 {
        Name:  "car-repository",
        Scope: di.Request,
        Build: func(ctn di.Container) (interface{}, error) {
            return &garage.CarRepository{
                Session: ctn.Get("mongo").(*mgo.Session),
            }, nil
        },
    },

最後,我們可以編寫CarManager定義。與CarRepository相同,由於其依賴性,CarManager應該位於Request範圍內。

{
        Name:  "car-manager",
        Scope: di.Request,
        Build: func(ctn di.Container) (interface{}, error) {
            return &garage.CarManager{
                Repo:   ctn.Get("car-repository").(*garage.CarRepository),
                Logger: ctn.Get("logger").(*zap.Logger),
            }, nil
        },
    },

基於這些定義,可以在main.go文件中創建依賴項注入容器。

Handlers

http處理程序的作用很簡單。它必須解析傳入的請求,檢索並調用適當的服務並編寫格式化的響應。所有處理程序大致相同。例如,GetCarHandler看起來像這樣:

func GetCarHandler(w http.ResponseWriter, r *http.Request) {
    id := mux.Vars(r)["carId"]

    car, err := di.Get(r, "car-manager").(*garage.CarManager).Get(id)

    if err == nil {
        helpers.JSONResponse(w, 200, car)
        return
    }

    switch e := err.(type) {
    case *helpers.ErrNotFound:
        helpers.JSONResponse(w, 404, map[string]interface{}{
            "error": e.Error(),
        })
    default:
        helpers.JSONResponse(w, 500, map[string]interface{}{
            "error": "Internal Error",
        })
    }
}

mux.Vars只是使用gorilla/mux路由庫,從URL中檢索carId參數的方法。

mux.Vars只是使用大猩猩/ mux(用於該項目的路由庫)從URL中檢索carId參數的方法。

處理程序有趣的部分是如何從依賴項注入容器中檢索CarManager。這是通過di.Get(r,“car-manager”)完成的。爲此,容器應包含在http.Request中。您必須使用中間件來實現。

Middlewares

該api使用兩個中間件。

第一個是PanicRecoveryMiddleware。它用於從處理程序中可能發生的緊急情況中恢復並記錄錯誤。這一點非常重要,因爲如果無法從容器中檢索CarManager,di.Get(r,“ car-manager”)可能會慌亂。

// PanicRecoveryMiddleware handles the panic in the handlers.
func PanicRecoveryMiddleware(h http.HandlerFunc, logger *zap.Logger) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                // log the error
                logger.Error(fmt.Sprint(rec))

                // write the error response
                helpers.JSONResponse(w, 500, map[string]interface{}{
                    "error": "Internal Error",
                })
            }
        }()

        h(w, r)
    }
}

第二個中間件通過將di.Container注入http.Request來允許di.Get(r, "car-manager").(*garage.CarManager)工作。

package di

import (
    "context"
    "net/http"
)

// ContainerKey is a type that can be used to store a container
// in the context.Context of an http.Request.
// By default, it is used in the C function and the HTTPMiddleware.
type ContainerKey string

// HTTPMiddleware adds a container in the request context.
//
// The container injected in each request, is a new sub-container
// of the app container given as parameter.
//
// It can panic, so it should be used with another middleware
// to recover from the panic, and to log the error.
//
// It uses logFunc, a function that can log an error.
// logFunc is used to log the errors during the container deletion.
func HTTPMiddleware(h http.HandlerFunc, app Container, logFunc func(msg string)) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        // create a request container from tha app container
        ctn, err := app.SubContainer()
        if err != nil {
            panic(err)
        }
        defer func() {
            if err := ctn.Delete(); err != nil && logFunc != nil {
                logFunc(err.Error())
            }
        }()

        // call the handler with a new request
        // containing the container in its context
        h(w, r.WithContext(
            context.WithValue(r.Context(), ContainerKey("di"), ctn),
        ))
    }
}

// C retrieves a Container from an interface.
// The function panics if the Container can not be retrieved.
//
// The interface can be :
// - a Container
// - an *http.Request containing a Container in its context.Context
//   for the ContainerKey("di") key.
//
// The function can be changed to match the needs of your application.
var C = func(i interface{}) Container {
    if c, ok := i.(Container); ok {
        return c
    }

    r, ok := i.(*http.Request)
    if !ok {
        panic("could not get the container with C()")
    }

    c, ok := r.Context().Value(ContainerKey("di")).(Container)
    if !ok {
        panic("could not get the container from the given *http.Request")
    }

    return c
}

// Get is a shortcut for C(i).Get(name).
func Get(i interface{}, name string) interface{} {
    return C(i).Get(name)
}

對於每個http請求。將創建給定應用程序容器的子容器。它被注入到http.Request的context.Context中,因此可以使用di.Get進行檢索。在每個請求結束時,將刪除子容器。 logFunc函數用於記錄刪除子容器期間可能發生的錯誤。

Main

main.go文件是應用程序的入口點。

首先確保 logger 在程序結束之前能夠寫入任何內容。

defer logging.Logger.Sync()

然後依賴注入容器可以被創建:

// create a builder
builder, err := di.NewBuilder()
if err != nil {
    logging.Logger.Fatal(err.Error())
}

// add the service definitions
err = builder.Add(services.Services...)
if err != nil {
    logging.Logger.Fatal(err.Error())
}

// create the app container, delete it just before the program stops
app := builder.Build()
defer app.Delete()

最後一件有趣的事情是這部分:

m := func(h http.HandlerFunc) http.HandlerFunc {
    return middlewares.PanicRecoveryMiddleware(
        di.HTTPMiddleware(h, app, func(msg string) {
            logging.Logger.Error(msg)
        }),
        logging.Logger,
    )
}

m 函數結合了兩個中間件。它可以用於將中間件應用於處理程序。

主文件的其餘部分只是 gorilla mux router(多路複用器路由器)的配置和Web服務器的創建。

下面給出完成的Main.go的全部代碼:

package main

import (
    "context"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    "github.com/gorilla/mux"
    "github.com/sarulabs/di"
    "github.com/sarulabs/di-example/app/handlers"
    "github.com/sarulabs/di-example/app/middlewares"
    "github.com/sarulabs/di-example/config/logging"
    "github.com/sarulabs/di-example/config/services"
)

func main() {
    // Use a single logger in the whole application.
    // Need to close it at the end.
    defer logging.Logger.Sync()

    // Create the app container.
    // Do not forget to delete it at the end.
    builder, err := di.NewBuilder()
    if err != nil {
        logging.Logger.Fatal(err.Error())
    }

    err = builder.Add(services.Services...)
    if err != nil {
        logging.Logger.Fatal(err.Error())
    }

    app := builder.Build()
    defer app.Delete()

    // Create the http server.
    r := mux.NewRouter()

    // Function to apply the middlewares:
    // - recover from panic
    // - add the container in the http requests
    m := func(h http.HandlerFunc) http.HandlerFunc {
        return middlewares.PanicRecoveryMiddleware(
            di.HTTPMiddleware(h, app, func(msg string) {
                logging.Logger.Error(msg)
            }),
            logging.Logger,
        )
    }

    r.HandleFunc("/cars", m(handlers.GetCarListHandler)).Methods("GET")
    r.HandleFunc("/cars", m(handlers.PostCarHandler)).Methods("POST")
    r.HandleFunc("/cars/{carId}", m(handlers.GetCarHandler)).Methods("GET")
    r.HandleFunc("/cars/{carId}", m(handlers.PutCarHandler)).Methods("PUT")
    r.HandleFunc("/cars/{carId}", m(handlers.DeleteCarHandler)).Methods("DELETE")

    srv := &http.Server{
        Handler:      r,
        Addr:         "0.0.0.0:" + os.Getenv("SERVER_PORT"),
        WriteTimeout: 15 * time.Second,
        ReadTimeout:  15 * time.Second,
    }

    logging.Logger.Info("Listening on port " + os.Getenv("SERVER_PORT"))

    go func() {
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            logging.Logger.Error(err.Error())
        }
    }()

    // graceful shutdown
    stop := make(chan os.Signal, 1)

    signal.Notify(stop, os.Interrupt, syscall.SIGTERM)

    <-stop

    ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
    defer cancel()

    logging.Logger.Info("Stopping the http server")

    if err := srv.Shutdown(ctx); err != nil {
        logging.Logger.Error(err.Error())
    }
}

Conclusion

通過上面的例子可以看到,業務層代碼和其依賴是解耦的,如果要更換依賴不需要更改業務層的代碼,而只需要修改服務的依賴配置文件就可以了。

依賴注入將有助於使這個項目變得更容易維護。使用sarulabs/di框架可讓您將服務的定義與業務邏輯分開。聲明發生在單一的地方,這是應用程序配置的一部分。這些服務可以被獲取在handles中通過使用在請求中的容器存儲(container stored)。

參考

How to write a REST API in Go with DI
關於設計模式:使用依賴注入有什麼缺點?

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章