Wstrzykiwanie zależności w Go: wzorce i najlepsze praktyki

Opanuj wzorce wstrzykiwania zależności w celu uzyskania testowalnego kodu w Go

Page content

Wstrzykiwanie zależności (DI) to fundamentalny wzorzec projektowy, który promuje czysty, łatwy do testowania i utrzymania kod w aplikacjach Go.

Niezależnie od tego, czy budujesz REST API, wdrażasz wzorce baz danych wielodostępnych, czy pracujesz z bibliotekami ORM, zrozumienie wstrzykiwania zależności znacząco poprawi jakość Twojego kodu.

go-dependency-injection

Czym jest wstrzykiwanie zależności?

Wstrzykiwanie zależności to wzorzec projektowy, w którym komponenty otrzymują swoje zależności ze źródeł zewnętrznych, zamiast tworzyć je wewnętrznie. Podejście to odseparowuje komponenty, czyniąc kod bardziej modułowym, łatwiejszym do testowania i utrzymania.

W języku Go wstrzykiwanie zależności jest szczególnie potężne ze względu na filozofię opartą na interfejsach. Implicitne zadowolenie interfejsów w Go oznacza, że możesz łatwo wymieniać implementacje bez modyfikowania istniejącego kodu.

Dlaczego używać wstrzykiwania zależności w Go?

Poprawiona testowalność: Dzięki wstrzykiwaniu zależności możesz łatwo zastąpić prawdziwe implementacje mockami lub podwójnikami testowymi. Pozwala to pisać testy jednostkowe, które są szybkie, izolowane i nie wymagają zewnętrznych usług, takich jak bazy danych czy API.

Lepsza podatność na utrzymanie (maintainability): Zależności stają się jawne w Twoim kodzie. Gdy spojrzysz na funkcję konstruktora, od razu widzisz, czego komponent wymaga. Ułatwia to zrozumienie i modyfikację kodu.

Luźne powiązania: Komponenty polegają na abstrakcjach (interfejsach), a nie na konkretnych implementacjach. Oznacza to, że możesz zmieniać implementacje bez wpływu na zależny kod.

Elastyczność: Możesz konfigurować różne implementacje dla różnych środowisk (rozwój, testy, produkcja) bez zmiany logiki biznesowej.

Wstrzykiwanie przez konstruktor: Sposób Go

Najczęstszym i najbardziej idiomatycznym sposobem wdrożenia wstrzykiwania zależności w Go są funkcje konstrukcyjne. Zazwyczaj nazywane są NewXxx i przyjmują zależności jako parametry.

Podstawowy przykład

Oto prosty przykład demonstrujący wstrzykiwanie przez konstruktor:

// Zdefiniuj interfejs dla repozytorium
type UserRepository interface {
    FindByID(id int) (*User, error)
    Save(user *User) error
}

// Usługa zależy od interfejsu repozytorium
type UserService struct {
    repo UserRepository
}

// Konstruktor wstrzykuje zależność
func NewUserService(repo UserRepository) *UserService {
    return &UserService{repo: repo}
}

// Metody używają wstrzykniętej zależności
func (s *UserService) GetUser(id int) (*User, error) {
    return s.repo.FindByID(id)
}

Ten wzorzec jasno wskazuje, że UserService wymaga UserRepository. Nie można utworzyć UserService bez dostarczenia repozytorium, co zapobiega błędom czasu wykonania wynikającym z brakujących zależności.

Wiele zależności

Gdy komponent ma wiele zależności, po prostu dodaj je jako parametry konstruktora:

type EmailService interface {
    Send(to, subject, body string) error
}

type Logger interface {
    Info(msg string)
    Error(msg string, err error)
}

type OrderService struct {
    repo        OrderRepository
    emailSvc    EmailService
    logger      Logger
    paymentSvc  PaymentService
}

func NewOrderService(
    repo OrderRepository,
    emailSvc EmailService,
    logger Logger,
    paymentSvc PaymentService,
) *OrderService {
    return &OrderService{
        repo:       repo,
        emailSvc:   emailSvc,
        logger:     logger,
        paymentSvc: paymentSvc,
    }
}

Projektowanie interfejsów dla wstrzykiwania zależności

Jedną z kluczowych zasad wdrażania wstrzykiwania zależności jest Zasada Inwersji Zależności (DIP): moduły wysokiego poziomu nie powinny zależeć od modułów niskiego poziomu; oba powinny zależeć od abstrakcji.

W Go oznacza to definiowanie małych, ukierunkowanych interfejsów, które reprezentują tylko to, czego potrzebuje Twój komponent:

// Dobrze: Mały, ukierunkowany interfejs
type PaymentProcessor interface {
    ProcessPayment(amount float64) error
}

// Źle: Duży interfejs z niepotrzebnymi metodami
type PaymentService interface {
    ProcessPayment(amount float64) error
    RefundPayment(id string) error
    GetPaymentHistory(userID int) ([]Payment, error)
    UpdatePaymentMethod(userID int, method PaymentMethod) error
    // ... wiele więcej metod
}

Mniejszy interfejs stosuje Zasodę Segmentacji Interfejsów—klienci nie powinni zależeć od metod, których nie używają. Uczyń Twój kod bardziej elastycznym i łatwiejszym do testowania.

Praktyczny przykład: Abstrakcja bazy danych

Pracując z bazami danych w aplikacjach Go, często będziesz potrzebować abstrahować operacje bazodanowe. Oto jak wstrzykiwanie zależności pomaga:

// Interfejs bazy danych - abstrakcja wysokiego poziomu
type DB interface {
    Query(ctx context.Context, query string, args ...interface{}) (Rows, error)
    Exec(ctx context.Context, query string, args ...interface{}) (Result, error)
    BeginTx(ctx context.Context) (Tx, error)
}

// Repozytorium zależy od abstrakcji
type UserRepository struct {
    db DB
}

func NewUserRepository(db DB) *UserRepository {
    return &UserRepository{db: db}
}

func (r *UserRepository) FindByID(ctx context.Context, id int) (*User, error) {
    query := "SELECT id, name, email FROM users WHERE id = $1"
    rows, err := r.db.Query(ctx, query, id)
    if err != nil {
        return nil, err
    }
    defer rows.Close()
    
    // ... parsowanie wierszy
}

Ten wzorzec jest szczególnie przydatny przy wdrażaniu wzorów baz danych wielodostępnych, gdzie możesz potrzebować przełączania się między różnymi implementacjami baz danych lub strategiami połączeń.

Wzorzec Korzenia Kompozycji (Composition Root)

Korzeń Kompozycji to miejsce, w którym zestawiasz wszystkie swoje zależności w punkcie wejściowym aplikacji (zazwyczaj main). Centralizuje to konfigurację zależności i czyni graf zależności jawny.

func main() {
    // Zainicjuj zależności infrastrukturalne
    db := initDatabase()
    logger := initLogger()
    
    // Zainicjuj repozytoria
    userRepo := NewUserRepository(db)
    orderRepo := NewOrderRepository(db)
    
    // Zainicjuj usługi z zależnościami
    emailSvc := NewEmailService(logger)
    paymentSvc := NewPaymentService(logger)
    userSvc := NewUserService(userRepo, logger)
    orderSvc := NewOrderService(orderRepo, emailSvc, logger, paymentSvc)
    
    // Zainicjuj obsługujące żądania HTTP (handlers)
    userHandler := NewUserHandler(userSvc)
    orderHandler := NewOrderHandler(orderSvc)
    
    // Podłącz trasy
    router := setupRouter(userHandler, orderHandler)
    
    // Uruchom serwer
    log.Fatal(http.ListenAndServe(":8080", router))
}

To podejście jasno pokazuje, jak jest zbudowana Twoja aplikacja i skąd pochodzą zależności. Jest szczególnie wartościowe przy budowaniu REST API w Go, gdzie musisz koordynować wiele warstw zależności.

Narzędzia do wstrzykiwania zależności

W większych aplikacjach ze złożonymi grafami zależności ręczne zarządzanie zależnościami może stać się uciążliwe. Go posiada kilka frameworków DI, które mogą pomóc:

Google Wire (DI w czasie kompilacji)

Wire to narzędzie do wstrzykiwania zależności w czasie kompilacji, które generuje kod. Jest bezpieczne typowo i nie ma narzutu czasu wykonania.

Instalacja:

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

Przykład:

// wire.go
//go:build wireinject
// +build wireinject

package main

import "github.com/google/wire"

func InitializeApp() (*App, error) {
    wire.Build(
        NewDB,
        NewUserRepository,
        NewUserService,
        NewUserHandler,
        NewApp,
    )
    return &App{}, nil
}

Wire generuje kod wstrzykiwania zależności w czasie kompilacji, zapewniając bezpieczeństwo typów i eliminując narzut refleksji w czasie wykonania.

Uber Dig (DI w czasie wykonania)

Dig to framework wstrzykiwania zależności w czasie wykonania, który używa refleksji. Jest bardziej elastyczny, ale ma pewne koszty związane z czasem wykonania.

Przykład:

import "go.uber.org/dig"

func main() {
    container := dig.New()
    
    // Zarejestruj dostawców
    container.Provide(NewDB)
    container.Provide(NewUserRepository)
    container.Provide(NewUserService)
    container.Provide(NewUserHandler)
    
    // Wywołaj funkcję wymagającą zależności
    err := container.Invoke(func(handler *UserHandler) {
        // Użyj handlera
    })
    if err != nil {
        log.Fatal(err)
    }
}

Kiedy używać frameworków

Użyj frameworku, gdy:

  • Twój graf zależności jest złożony z wieloma wzajemnie zależnymi komponentami
  • Masz wiele implementacji tego samego interfejsu, które muszą być wybierane na podstawie konfiguracji
  • Chcesz automatyczne rozwiązywanie zależności
  • Budujesz dużą aplikację, gdzie ręczne podłączanie staje się podatne na błędy

Pozostań przy ręcznym DI, gdy:

  • Twoja aplikacja jest mała lub średniej wielkości
  • Graf zależności jest prosty i łatwy do śledzenia
  • Chcesz zachować zależności minimalne i jawne
  • Wolisz jawny kod przed kodem generowanym

Testowanie z wstrzykiwaniem zależności

Jedną z głównych zalet wstrzykiwania zależności jest poprawiona testowalność. Oto jak DI ułatwia testowanie:

Przykład testu jednostkowego

// Implementacja mocka do testów
type mockUserRepository struct {
    users map[int]*User
    err   error
}

func (m *mockUserRepository) FindByID(id int) (*User, error) {
    if m.err != nil {
        return nil, m.err
    }
    return m.users[id], nil
}

func (m *mockUserRepository) Save(user *User) error {
    if m.err != nil {
        return m.err
    }
    m.users[user.ID] = user
    return nil
}

// Test używający mocka
func TestUserService_GetUser(t *testing.T) {
    mockRepo := &mockUserRepository{
        users: map[int]*User{
            1: {ID: 1, Name: "John", Email: "john@example.com"},
        },
    }
    
    service := NewUserService(mockRepo)
    
    user, err := service.GetUser(1)
    assert.NoError(t, err)
    assert.Equal(t, "John", user.Name)
}

Ten test wykonuje się szybko, nie wymaga bazy danych i testuje Twoją logikę biznesową w izolacji. Pracując z bibiotekami ORM w Go, możesz wstrzyknąć mocki repozytoriów, aby testować logikę usług bez konfiguracji bazy danych.

Częste wzorce i najlepsze praktyki

1. Stosuj segmentację interfejsów

Trzymaj interfejsy małe i ukierunkowane na to, czego klient faktycznie potrzebuje:

// Dobrze: Klient potrzebuje tylko odczytu użytkowników
type UserReader interface {
    FindByID(id int) (*User, error)
    FindByEmail(email string) (*User, error)
}

// Oddzielny interfejs do zapisu
type UserWriter interface {
    Save(user *User) error
    Delete(id int) error
}

2. Zwracaj błędy z konstruktorów

Konstruktory powinny weryfikować zależności i zwracać błędy, jeśli inicjalizacja się nie powiedzie:

func NewUserService(repo UserRepository) (*UserService, error) {
    if repo == nil {
        return nil, errors.New("user repository cannot be nil")
    }
    return &UserService{repo: repo}, nil
}

3. Używaj Context dla zależności o zakresie żądania

Dla zależności specyficznych dla żądania (takich jak transakcje baz danych), przesyłaj je przez kontekst:

type ctxKey string

const dbKey ctxKey = "db"

func WithDB(ctx context.Context, db DB) context.Context {
    return context.WithValue(ctx, dbKey, db)
}

func DBFromContext(ctx context.Context) (DB, bool) {
    db, ok := ctx.Value(dbKey).(DB)
    return db, ok
}

4. Unikaj nadmiernego wstrzykiwania

Nie wstrzykuj zależności, które są naprawdę szczegółami wewnętrznymi implementacji. Jeśli komponent tworzy i zarządza własnymi obiektami pomocniczymi, jest to w porządku:

// Dobrze: Wewnętrzny pomocnik nie wymaga wstrzykiwania
type UserService struct {
    repo UserRepository
    // Wewnętrzna pamięć podręczna - nie wymaga wstrzykiwania
    cache map[int]*User
}

func NewUserService(repo UserRepository) *UserService {
    return &UserService{
        repo:  repo,
        cache: make(map[int]*User),
    }
}

5. Dokumentuj zależności

Używaj komentarzy, aby udokumentować, dlaczego zależności są potrzebne i jakie są ograniczenia:

// UserService obsługuje logikę biznesową związaną z użytkownikami.
// Wymaga UserRepository do dostępu do danych oraz Logger do
// śledzenia błędów. Repozytorium musi być bezpieczne wątkowo, jeśli jest używane
// w kontekstach współbieżnych.
func NewUserService(repo UserRepository, logger Logger) *UserService {
    // ...
}

Kiedy NIE używać wstrzykiwania zależności

Wstrzykiwanie zależności to potężne narzędzie, ale nie zawsze jest konieczne:

Pomiń DI dla:

  • Prosty obiektów wartości lub struktur danych
  • Wewnętrznych funkcji pomocniczych lub narzędzi
  • Skryptów jednorazowych lub małych narzędzi
  • Gdy bezpośrednia instancjacja jest klarowniejsza i prostsza

Przykład, kiedy NIE używać DI:

// Prosta struktura - brak potrzeby wstrzykiwania
type Point struct {
    X, Y float64
}

func NewPoint(x, y float64) Point {
    return Point{X: x, Y: y}
}

// Proste narzędzie - brak potrzeby wstrzykiwania
func FormatCurrency(amount float64) string {
    return fmt.Sprintf("$%.2f", amount)
}

Integracja z ekosystemem Go

Wstrzykiwanie zależności płynnie współpracuje z innymi wzorcami i narzędziami Go. Przy budowaniu aplikacji używających standardowej biblioteki Go do scrapingu sieci lub generowania raportów PDF, możesz wstrzyknąć te usługi do swojej logiki biznesowej:

type PDFGenerator interface {
    GenerateReport(data ReportData) ([]byte, error)
}

type ReportService struct {
    pdfGen PDFGenerator
    repo   ReportRepository
}

func NewReportService(pdfGen PDFGenerator, repo ReportRepository) *ReportService {
    return &ReportService{
        pdfGen: pdfGen,
        repo:   repo,
    }
}

Pozwala to na wymianę implementacji generowania PDF lub używanie mocków podczas testów.

Podsumowanie

Wstrzykiwanie zależności jest kamieniem węgielnym pisania utrzymywalnego i testowalnego kodu Go. Postępując zgodnie ze wzorcami opisanymi w tym artykule—wstrzykiwaniem przez konstruktor, projektowaniem opartym na interfejsach i wzorcem korzenia kompozycji—tworzyć będziesz aplikacje, które są łatwiejsze do zrozumienia, testowania i modyfikacji.

Zacznij od ręcznego wstrzykiwania przez konstruktor w małych i średnich aplikacjach, a rozważ frameworki takie jak Wire lub Dig, gdy Twój graf zależności rośnie. Pamiętaj, że celem jest klarowność i testowalność, a nie złożoność dla jej samego. Jeśli strukturyzujesz swoją aplikację wokół obsługujących polecenia i zapytania, Wdrażanie CQRS w Go pokazuje, jak ten sam podejście do wstrzykiwania przez konstruktor łączy struktury Application, Commands i Queries w czysty i bezceremonialny sposób.

Aby uzyskać więcej zasobów dotyczących rozwoju w Go, sprawdź naszą Ściągnik Go dla szybkiego odniesienia do składni Go i powszechnych wzorców.

Przydatne linki

Zasoby zewnętrzne

Subskrybuj

Otrzymuj nowe wpisy o systemach, infrastrukturze i inżynierii AI.