Skip to content

Wire

wire là một công cụ dependency injection được Google mã nguồn mở, khái niệm dependency injection này khá phổ biến trong framework Spring của Java, Go cũng có một số thư viện dependency injection như dig được Uber mã nguồn mở. Tuy nhiên triết lý dependency injection của wire không dựa trên cơ chế reflection của ngôn ngữ, nói một cách nghiêm ngặt, wire thực ra là một trình sinh mã, triết lý dependency injection chỉ thể hiện ở cách sử dụng, nếu có vấn đề thì có thể phát hiện ra trong quá trình sinh mã.

Địa chỉ kho lưu trữ: google/wire: Compile-time Dependency Injection for Go (github.com)

Địa chỉ tài liệu: wire/docs/guide.md at main · google/wire (github.com)

Cài đặt

Cài đặt công cụ sinh mã

go
go install github.com/google/wire/cmd/wire@latest

Cài đặt phụ thuộc mã nguồn

go get github.com/google/wire

Nhập môn

Dependency injection trong wire dựa trên hai phần tử, providerinjector.

provider có thể là một constructor do nhà phát triển cung cấp, như dưới đây, Provider phải được công khai.

go
package foobarbaz

type Foo struct {
    X int
}

// Construct Foo
func ProvideFoo() Foo {
    return Foo{X: 42}
}

Với tham số

go
package foobarbaz

// ...

type Bar struct {
    X int
}

// ProvideBar returns a Bar: a negative Foo.
func ProvideBar(foo Foo) Bar {
    return Bar{X: -foo.X}
}

Cũng có thể có tham số và giá trị trả về

go
package foobarbaz

import (
    "context"
    "errors"
)

type Baz struct {
    X int
}

// ProvideBaz returns a value if Bar is not zero.
func ProvideBaz(ctx context.Context, bar Bar) (Baz, error) {
    if bar.X == 0 {
        return Baz{}, errors.New("cannot provide baz when bar is zero")
    }
    return Baz{X: bar.X}, nil
}

Cũng có thể kết hợp các provider

go
package foobarbaz

import (
    // ...
    "github.com/google/wire"
)

// ...

var SuperSet = wire.NewSet(ProvideFoo, ProvideBar, ProvideBaz)

TIP

wire có quy định sau đối với giá trị trả về của provider

  • Giá trị trả về đầu tiên là giá trị do provider cung cấp
  • Giá trị trả về thứ hai phải là func() | error
  • Giá trị trả về thứ ba, nếu giá trị trả về thứ hai là func, thì giá trị trả về thứ ba phải là error

injector là một hàm do wire sinh ra, nó chịu trách nhiệm gọi provider theo thứ tự chỉ định, chữ ký của injector do nhà phát triển định nghĩa, wire sinh phần thân hàm cụ thể, khai báo thông qua việc gọi wire.Build, khai báo này không nên được gọi, càng không nên được biên dịch.

go
func Build(...interface{}) string {
  return "implementation not generated, run wire"
}
go
// +build wireinject
// The build tag makes sure the stub is not built in the final build.

package main

import (
    "context"

    "github.com/google/wire"
    "example.com/foobarbaz"
)

// injector được định nghĩa
func initializeBaz(ctx context.Context) (foobarbaz.Baz, error) {
    wire.Build(foobarbaz.MegaSet)
    return foobarbaz.Baz{}, nil
}

Sau đó thực thi

wire

sẽ sinh ra wire_gen.go, nội dung như sau

go
// Code generated by Wire. DO NOT EDIT.

//go:generate go run -mod=mod github.com/google/wire/cmd/wire
//+build !wireinject

package main

import (
    "example.com/foobarbaz"
)

// injector thực tế được sinh ra
func initializeBaz(ctx context.Context) (foobarbaz.Baz, error) {
    foo := foobarbaz.ProvideFoo()
    bar := foobarbaz.ProvideBar(foo)
    baz, err := foobarbaz.ProvideBaz(ctx, bar)
    if err != nil {
        return foobarbaz.Baz{}, err
    }
    return baz, nil
}

Mã được sinh ra hầu như không có phụ thuộc nào vào wire, có thể làm việc bình thường mà không cần wire, và có thể sinh lại sau đó bằng cách thực thi go generate, sau đó, nhà phát triển gọi injector thực tế được sinh ra và truyền tham số tương ứng để hoàn thành dependency injection. Có phải toàn bộ quá trình code khá đơn giản, cảm giác như chỉ cần cung cấp vài constructor, sau đó sinh ra một hàm gọi constructor, cuối cùng gọi hàm này truyền tham số, dường như cũng không làm việc gì đặc biệt phức tạp, viết tay cũng có thể được, đúng vậy, wire chính là làm một việc đơn giản như vậy, chỉ là từ viết tay biến thành tự động sinh. Theo triết lý của wire, dependency injection vốn dĩ nên là một việc đơn giản như vậy, không nên phức tạp hóa.

Ví dụ

Dưới đây là một ví dụ để hiểu sâu hơn, đây là một ví dụ khởi tạo app.

HttpServer provider nhận một tham số net.Addr, trả về con trỏ và error

go
var ServerProviderSet = wire.NewSet(NewHttpserver)

type HttpServer struct {
  net.Addr
}

func NewHttpserver(addr net.Addr) (*HttpServer, error) {
  return &HttpServer{addr}, nil
}

MysqlClientSystem provider dưới đây tương tự

go
var DataBaseProviderSet = wire.NewSet(NewMysqlClient)

type MysqlClient struct {
}

var SystemSet = wire.NewSet(NewApp)

type System struct {
  server *HttpServer
  data   *MysqlClient
}

func (s *System) Run() {
  log.Printf("app run on %s", s.server.String())
}

func NewApp(server *HttpServer, data *MysqlClient) (System, error) {
  return System{server: server, data: data}, nil
}

Sau khi định nghĩa provider, cần định nghĩa injector, tốt nhất nên tạo một file wire.go để định nghĩa

go
//go:build wireinject
// +build wireinject

package main

import (
  "github.com/google/wire"
  "net"
)

// Định nghĩa injector
func initSystemServer(serverAddr net.Addr, dataAddr string) (System, error) {
  // Gọi provider theo thứ tự
  panic(wire.Build(DataBaseProviderSet, ServerProviderSet, SystemSet))
}

+build wireinject để bỏ qua injector này khi biên dịch. Sau đó thực thi lệnh sau, nếu có đầu ra như sau nghĩa là đã sinh thành công.

sh
$ wire
$ wire: golearn: wrote /golearn/wire_gen.go

Code sau khi sinh như sau

go
// Code generated by Wire. DO NOT EDIT.

//go:generate go run github.com/google/wire/cmd/wire
//go:build !wireinject
// +build !wireinject

package main

import (
  "net"
)

// Injectors from wire.go:

// Định nghĩa injector
func initSystemServer(serverAddr net.Addr, dataAddr string) (System, error) {
  httpServer, err := NewHttpserver(serverAddr)
  if err != nil {
    return System{}, err
  }
  mysqlClient, err := NewMysqlClient(dataAddr)
  if err != nil {
    return System{}, err
  }
  system, err := NewApp(httpServer, mysqlClient)
  if err != nil {
    return System{}, err
  }
  return system, nil
}

Có thể thấy logic rất rõ ràng, thứ tự gọi cũng chính xác, cuối cùng thông qua injector được sinh ra để khởi động app.

go
package main

import (
  "github.com/google/wire"
  "log"
  "net"
  "net/netip"
)

func main() {
  server, err := initSystemServer(
    net.TCPAddrFromAddrPort(netip.MustParseAddrPort("127.0.0.1:8080")),
    "mysql:localhost:3306/test")
  if err != nil {
    panic(err)
  }
  server.Run()
}

Đầu ra cuối cùng như sau

2023/08/01 19:20:48 app run on 127.0.0.1:8080

Đây là một ví dụ sử dụng rất đơn giản.

Cách sử dụng nâng cao

Ràng buộc interface

Đôi khi, dependency injection sẽ inject một implementation cụ thể vào interface. wire khi dependency injection dựa trên khớp kiểu.

go
type Fooer interface {
    Foo() string
}

type MyFooer string

func (b *MyFooer) Foo() string {
    return string(*b)
}

func provideMyFooer() *MyFooer {
    b := new(MyFooer)
    *b = "Hello, World!"
    return b
}

type Bar string

func provideBar(f Fooer) string {
    // f will be a *MyFooer.
    return f.Foo()
}

Tham số của provider provideBar là một kiểu interface, thực tế nó là *MyFooer, để code khi sinh ra provider có thể khớp chính xác, chúng ta có thể ràng buộc hai kiểu, như sau

Tham số đầu tiên là kiểu con trỏ interface cụ thể, tham số thứ hai là kiểu con trỏ implementation cụ thể.

go
func Bind(iface, to interface{}) Binding
go
var Set = wire.NewSet(
    provideMyFooer,
    wire.Bind(new(Fooer), new(*MyFooer)),
    provideBar)

Ràng buộc giá trị

Khi sử dụng wire.Build, có thể không cần provider cung cấp giá trị, cũng có thể sử dụng wire.Value để cung cấp một giá trị cụ thể. wire.Value hỗ trợ biểu thức để构造 giá trị, biểu thức này sẽ được sao chép vào injector khi sinh code, như sau.

go
type Foo struct {
    X int
}

func injectFoo() Foo {
    wire.Build(wire.Value(Foo{X: 42}))
    return Foo{}
}

injector được sinh ra

func injectFoo() Foo {
    foo := _wireFooValue
    return foo
}

var (
    _wireFooValue = Foo{X: 42}
)

Nếu muốn ràng buộc một giá trị kiểu interface, có thể sử dụng wire.InterfaceValue

go
func injectReader() io.Reader {
    wire.Build(wire.InterfaceValue(new(io.Reader), os.Stdin))
    return nil
}

Construct struct

Trong providerset, có thể sử dụng wire.Struct để sử dụng giá trị trả về của provider khác构建 một struct kiểu chỉ định.

Tham số đầu tiên nên truyền kiểu con trỏ struct, sau đó là tên các trường.

go
func Struct(structType interface{}, fieldNames ...string) StructProvider

Ví dụ như sau

go
type Foo int
type Bar int

func ProvideFoo() Foo {/* ... */}

func ProvideBar() Bar {/* ... */}

type FooBar struct {
    MyFoo Foo
    MyBar Bar
}

var Set = wire.NewSet(
    ProvideFoo,
    ProvideBar,
    wire.Struct(new(FooBar), "MyFoo", "MyBar"))

func injectFooBar() FoodBar {
    wire.Build(Set)
}

injector được sinh ra có thể như sau

go
func injectFooBar() FooBar {
    foo := ProvideFoo()
    bar := ProvideBar()
    fooBar := FooBar{
        MyFoo: foo,
        MyBar: bar,
    }
    return fooBar
}

Nếu muốn điền tất cả các trường, có thể sử dụng *, ví dụ

go
wire.Struct(new(FooBar), "*")

Mặc định là construct kiểu struct, nếu muốn construct kiểu con trỏ, có thể sửa giá trị trả về của chữ ký injector

func injectFooBar() *FoodBar {
    wire.Build(Set)
}

Nếu muốn bỏ qua trường, có thể thêm tag, như sau

go
type Foo struct {
    mu sync.Mutex `wire:"-"`
    Bar Bar
}

Cleanup

Nếu provider construct một giá trị cần thực hiện công việc收尾 sau khi sử dụng (ví dụ đóng một file), provider có thể trả về một closure để thực hiện thao tác như vậy, injector sẽ không gọi hàm cleanup này, cụ thể khi nào gọi do người gọi injector quyết định, như sau.

go
type Data struct {
  // TODO wrapped database client
}

// NewData .
func NewData(c *conf.Data, logger log.Logger) (*Data, func(), error) {
  cleanup := func() {
    log.NewHelper(logger).Info("closing the data resources")
  }
  return &Data{}, cleanup, nil
}

Code thực tế được sinh ra có thể như sau

go
func wireApp(confData *conf.Data, logger log.Logger) (func(), error) {
    dataData, cleanup, err := data.NewData(confData, logger)
    if err != nil {
       return nil, nil, err
    }
    // inject data
    // ...
    return app, func() {
       cleanup()
    }, nil
}

Trùng kiểu

Tham số đầu vào của provider tốt nhất không nên trùng kiểu, đặc biệt là đối với một số kiểu cơ bản

go
type FooBar struct {
  foo string
  bar string
}

func NewFooBar(foo string, bar string) FooBar {
  return FooBar{
      foo: foo,
      bar: bar,
  }
}

func InitializeFooBar(a string, b string) FooBar {
    panic(wire.Build(NewFooBar))
}

Trường hợp này khi sinh code sẽ báo lỗi

provider has multiple parameters of type string

wire sẽ không thể phân biệt các tham số này nên được inject như thế nào, để tránh xung đột, có thể sử dụng bí danh kiểu.

Best Practices

  1. Tổ chức code rõ ràng: Tách biệt provider và injector vào các file riêng
  2. Sử dụng wireinject tag: Luôn sử dụng //go:build wireinject cho file định nghĩa injector
  3. Kiểm tra lỗi: Wire sẽ báo lỗi tại thời điểm biên dịch nếu có vấn đề về dependency
  4. Tránh trùng kiểu: Không sử dụng nhiều tham số cùng kiểu cơ bản trong provider
  5. Sử dụng interface binding: Sử dụng wire.Bind khi cần inject vào interface

Kết luận

Wire là một công cụ dependency injection độc đáo cho Go, với phương pháp sinh code tại thời điểm biên dịch thay vì sử dụng reflection. Điều này giúp phát hiện lỗi sớm và tạo ra code hiệu quả hơn. Mặc dù có vẻ phức tạp ban đầu, wire thực sự đơn giản hóa việc quản lý dependency trong các dự án Go lớn.

Golang by www.golangdev.cn edit