Dobrze skonstruowana logika: samouczek Golang OOP

Opublikowany: 2022-03-11

Czy Go jest zorientowany obiektowo? Może być? Go (lub „Golang”) to język programowania post-OOP, który zapożycza swoją strukturę (pakiety, typy, funkcje) z rodziny języków Algol/Pascal/Modula. Niemniej jednak w Go wzorce zorientowane obiektowo są nadal przydatne do konstruowania programu w jasny i zrozumiały sposób. Ten samouczek Golanga na prostym przykładzie zademonstruje, jak zastosować koncepcje funkcji wiążących do typów (czyli klas), konstruktorów, podtypów, polimorfizmu, wstrzykiwania zależności i testowania za pomocą mocków.

Studium przypadku w Golang OOP: odczytywanie kodu producenta z numeru identyfikacyjnego pojazdu (VIN)

Niepowtarzalny numer identyfikacyjny pojazdu każdego samochodu zawiera – oprócz numeru „bieżącego” (tj. seryjnego) – informacje o samochodzie, takie jak producent, fabryka, model samochodu i czy jest on prowadzony z lewej lub prawa strona.

Funkcja do określenia kodu producenta może wyglądać tak:

 package vin func Manufacturer(vin string) string { manufacturer := vin[: 3] // if the last digit of the manufacturer ID is a 9 // the digits 12 to 14 are the second part of the ID if manufacturer[2] == '9' { manufacturer += vin[11: 14] } return manufacturer }

A oto test, który udowadnia, że ​​przykładowy VIN działa:

 package vin_test import ( "vin-stages/1" "testing" ) const testVIN = "W09000051T2123456" func TestVIN_Manufacturer(t *testing.T) { manufacturer := vin.Manufacturer(testVIN) if manufacturer != "W09123" { t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN) } }

Tak więc ta funkcja działa poprawnie po podaniu odpowiednich danych wejściowych, ale ma pewne problemy:

  • Nie ma gwarancji, że ciąg wejściowy to numer VIN.
  • W przypadku łańcuchów krótszych niż trzy znaki funkcja powoduje panic .
  • Opcjonalna druga część identyfikatora jest cechą wyłącznie europejskich numerów VIN. Funkcja zwróciłaby nieprawidłowe identyfikatory dla samochodów amerykańskich, w których 9 jako trzecia cyfra kodu producenta.

Aby rozwiązać te problemy, dokonamy refaktoryzacji za pomocą wzorców zorientowanych obiektowo.

Idź OOP: powiązanie funkcji z typem

Pierwsza refaktoryzacja polega na uczynieniu VIN własnym typem i powiązaniu z nim funkcji Manufacturer() . Dzięki temu cel funkcji jest jaśniejszy i zapobiega bezmyślnemu użytkowaniu.

 package vin type VIN string func (v VIN) Manufacturer() string { manufacturer := v[: 3] if manufacturer[2] == '9' { manufacturer += v[11: 14] } return string(manufacturer) }

Następnie dostosowujemy test i wprowadzamy problem nieprawidłowych numerów VIN:

 package vin_test import( "vin-stages/2" "testing" ) const ( validVIN = vin.VIN("W0L000051T2123456") invalidVIN = vin.VIN("W0") ) func TestVIN_Manufacturer(t * testing.T) { manufacturer := validVIN.Manufacturer() if manufacturer != "W0L" { t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, validVIN) } invalidVIN.Manufacturer() // panic! }

Ostatni wiersz został wstawiony, aby zademonstrować, jak wywołać panic podczas korzystania z funkcji Manufacturer() . Poza testem spowodowałoby to awarię działającego programu.

OOP w Golangu: korzystanie z konstruktorów

Aby uniknąć panic podczas obsługi nieprawidłowego numeru VIN, można dodać kontrolę ważności do samej funkcji Manufacturer() . Wadą jest to, że sprawdzanie odbywałoby się przy każdym wywołaniu funkcji Manufacturer() i że musiałby zostać wprowadzony błąd zwracanej wartości, co uniemożliwiłoby bezpośrednie użycie zwracanej wartości bez zmiennej pośredniej (np. klucz mapy).

Bardziej eleganckim sposobem jest umieszczenie kontroli poprawności w konstruktorze dla typu VIN , tak aby funkcja Manufacturer() była wywoływana tylko dla poprawnych numerów VIN i nie wymagała sprawdzania i obsługi błędów:

 package vin import "fmt" type VIN string // it is debatable if this func should be named New or NewVIN // but NewVIN is better for greping and leaves room for other // NewXY funcs in the same package func NewVIN(code string)(VIN, error) { if len(code) != 17 { return "", fmt.Errorf("invalid VIN %s: more or less than 17 characters", code) } // ... check for disallowed characters ... return VIN(code), nil } func (v VIN) Manufacturer() string { manufacturer := v[: 3] if manufacturer[2] == '9' { manufacturer += v[11: 14] } return string(manufacturer) }

Oczywiście dodajemy test funkcji NewVIN . Nieprawidłowe numery VIN są teraz odrzucane przez konstruktora:

 package vin_test import ( "vin-stages/3" "testing" ) const ( validVIN = "W0L000051T2123456" invalidVIN = "W0" ) func TestVIN_New(t *testing.T) { _, err := vin.NewVIN(validVIN) if err != nil { t.Errorf("creating valid VIN returned an error: %s", err.Error()) } _, err = vin.NewVIN(invalidVIN) if err == nil { t.Error("creating invalid VIN did not return an error") } } func TestVIN_Manufacturer(t *testing.T) { testVIN, _ := vin.NewVIN(validVIN) manufacturer := testVIN.Manufacturer() if manufacturer != "W0L" { t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN) } }

Test funkcji Manufacturer() może teraz pominąć testowanie nieprawidłowego numeru VIN, ponieważ zostałby on już odrzucony przez konstruktor NewVIN .

Idź OOP Pułapka: Niewłaściwy polimorfizm

Następnie chcemy rozróżnić VIN europejskie i pozaeuropejskie. Jednym z podejść byłoby rozszerzenie type VIN na struct i przechowywanie, czy VIN jest europejski, czy nie, odpowiednio zwiększając konstruktor:

 type VIN struct { code string european bool } func NewVIN(code string, european bool)(*VIN, error) { // ... checks ... return &VIN { code, european }, nil }

Bardziej eleganckim rozwiązaniem jest stworzenie podtypu VIN dla europejskich VIN. Tutaj flaga jest niejawnie przechowywana w informacji o typie, a funkcja Manufacturer() dla pozaeuropejskich numerów VIN staje się ładna i zwięzła:

 package vin import "fmt" type VIN string func NewVIN(code string)(VIN, error) { if len(code) != 17 { return "", fmt.Errorf("invalid VIN %s: more or less than 17 characters", code) } // ... check for disallowed characters ... return VIN(code), nil } func (v VIN) Manufacturer() string { return string(v[: 3]) } type EUVIN VIN func NewEUVIN(code string)(EUVIN, error) { // call super constructor v, err := NewVIN(code) // and cast to subtype return EUVIN(v), err } func (v EUVIN) Manufacturer() string { // call manufacturer on supertype manufacturer := VIN(v).Manufacturer() // add EU specific postfix if appropriate if manufacturer[2] == '9' { manufacturer += string(v[11: 14]) } return manufacturer }

W językach OOP, takich jak Java, spodziewalibyśmy się, że podtyp EUVIN będzie użyteczny w każdym miejscu, w którym określony jest typ VIN . Niestety to nie działa w Golang OOP.

 package vin_test import ( "vin-stages/4" "testing" ) const euSmallVIN = "W09000051T2123456" // this works! func TestVIN_EU_SmallManufacturer(t *testing.T) { testVIN, _ := vin.NewEUVIN(euSmallVIN) manufacturer := testVIN.Manufacturer() if manufacturer != "W09123" { t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN) } } // this fails with an error func TestVIN_EU_SmallManufacturer_Polymorphism(t *testing.T) { var testVINs[] vin.VIN testVIN, _ := vin.NewEUVIN(euSmallVIN) // having to cast testVIN already hints something is odd testVINs = append(testVINs, vin.VIN(testVIN)) for _, vin := range testVINs { manufacturer := vin.Manufacturer() if manufacturer != "W09123" { t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN) } } }

To zachowanie można wytłumaczyć świadomym wyborem zespołu programistów Go, aby nie obsługiwał dynamicznego wiązania dla typów bez interfejsu. Pozwala to kompilatorowi wiedzieć, która funkcja zostanie wywołana w czasie kompilacji i pozwala uniknąć obciążenia związanego z dynamicznym wysyłaniem metod. Wybór ten zniechęca również do stosowania dziedziczenia jako ogólnego wzorca kompozycji. Zamiast tego najlepszym rozwiązaniem są interfejsy (przepraszam za kalambur).

Sukces Golang OOP: właściwy polimorfizm

Kompilator Go traktuje typ jako implementację interfejsu, gdy implementuje zadeklarowane funkcje (pisanie kaczki). Dlatego, aby wykorzystać polimorfizm, typ VIN jest konwertowany na interfejs, który jest implementowany przez ogólny i europejski typ VIN. Należy zauważyć, że europejski typ VIN nie musi być podtypem ogólnego.

 package vin import "fmt" type VIN interface { Manufacturer() string } type vin string func NewVIN(code string)(vin, error) { if len(code) != 17 { return "", fmt.Errorf("invalid VIN %s: more or less than 17 characters", code) } // ... check for disallowed characters ... return vin(code), nil } func (v vin) Manufacturer() string { return string(v[: 3]) } type vinEU vin func NewEUVIN(code string)(vinEU, error) { // call super constructor v, err := NewVIN(code) // and cast to own type return vinEU(v), err } func (v vinEU) Manufacturer() string { // call manufacturer on supertype manufacturer := vin(v).Manufacturer() // add EU specific postfix if appropriate if manufacturer[2] == '9' { manufacturer += string(v[11: 14]) } return manufacturer }

Test polimorfizmu przechodzi teraz z niewielką modyfikacją:

 // this works! func TestVIN_EU_SmallManufacturer_Polymorphism(t *testing.T) { var testVINs[] vin.VIN testVIN, _ := vin.NewEUVIN(euSmallVIN) // now there is no need to cast! testVINs = append(testVINs, testVIN) for _, vin := range testVINs { manufacturer := vin.Manufacturer() if manufacturer != "W09123" { t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN) } } }

W rzeczywistości oba typy VIN mogą być teraz używane w każdym miejscu, które określa interfejs VIN , ponieważ oba typy są zgodne z definicją interfejsu VIN .

Golang zorientowany obiektowo: jak korzystać z wstrzykiwania zależności

Na koniec musimy zdecydować, czy VIN jest europejski, czy nie. Załóżmy, że znaleźliśmy zewnętrzny interfejs API, który dostarcza nam te informacje, i zbudowaliśmy dla niego klienta:

 package vin type VINAPIClient struct { apiURL string apiKey string // ... internals go here ... } func NewVINAPIClient(apiURL, apiKey string) *VINAPIClient { return &VINAPIClient {apiURL, apiKey} } func (client *VINAPIClient) IsEuropean(code string) bool { // calls external API and returns correct value return true }

Skonstruowaliśmy również usługę, która obsługuje VIN, a przede wszystkim potrafi je tworzyć:

 package vin type VINService struct { client *VINAPIClient } type VINServiceConfig struct { APIURL string APIKey string // more configuration values } func NewVINService(config *VINServiceConfig) *VINService { // use config to create the API client apiClient := NewVINAPIClient(config.APIURL, config.APIKey) return &VINService {apiClient} } func (s *VINService) CreateFromCode(code string)(VIN, error) { if s.client.IsEuropean(code) { return NewEUVIN(code) } return NewVIN(code) }

Działa to dobrze, jak pokazuje zmodyfikowany test:

 func TestVIN_EU_SmallManufacturer(t *testing.T) { service := vin.NewVINService( & vin.VINServiceConfig {}) testVIN, _ := service.CreateFromCode(euSmallVIN) manufacturer := testVIN.Manufacturer() if manufacturer != "W09123" { t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN) } }

Jedynym problemem jest to, że test wymaga połączenia na żywo z zewnętrznym API. To niefortunne, ponieważ interfejs API może być offline lub po prostu nieosiągalny. Ponadto wywoływanie zewnętrznego interfejsu API zajmuje czas i może kosztować.

Ponieważ wynik wywołania API jest znany, powinno być możliwe zastąpienie go makietą. Niestety w powyższym kodzie VINService sam tworzy klienta API, więc nie ma łatwego sposobu na jego podmianę. Aby było to możliwe, zależność klienta API należy wstrzyknąć do VINService . Oznacza to, że należy go utworzyć przed wywołaniem konstruktora VINService .

Wytyczna Golang OOP mówi, że żaden konstruktor nie powinien wywoływać innego konstruktora . Jeśli zostanie to dokładnie zastosowane, każdy singleton użyty w aplikacji zostanie stworzony na najwyższym poziomie. Zazwyczaj będzie to funkcja ładowania początkowego, która tworzy wszystkie potrzebne obiekty, wywołując ich konstruktory w odpowiedniej kolejności, wybierając odpowiednią implementację dla zamierzonej funkcjonalności programu.

Pierwszym krokiem jest uczynienie VINAPIClient interfejsem:

 package vin type VINAPIClient interface { IsEuropean(code string) bool } type vinAPIClient struct { apiURL string apiKey string // .. internals go here ... } func NewVINAPIClient(apiURL, apiKey string) *VINAPIClient { return &vinAPIClient {apiURL, apiKey} } func (client *VINAPIClient) IsEuropean(code string) bool { // calls external API and returns something more useful return true }

Następnie nowy klient może zostać wstrzyknięty do VINService :

 package vin type VINService struct { client VINAPIClient } type VINServiceConfig struct { // more configuration values } func NewVINService(config *VINServiceConfig, apiClient VINAPIClient) *VINService { // apiClient is created elsewhere and injected here return &VINService {apiClient} } func (s *VINService) CreateFromCode(code string)(VIN, error) { if s.client.IsEuropean(code) { return NewEUVIN(code) } return NewVIN(code) }

Dzięki temu możliwe jest teraz użycie do testu makiety klienta API. Oprócz unikania wywołań zewnętrznego API podczas testów, makieta może również działać jako sonda do zbierania danych o wykorzystaniu API. W poniższym przykładzie po prostu sprawdzamy, czy funkcja IsEuropean jest rzeczywiście wywoływana.

 package vin_test import ( "vin-stages/5" "testing" ) const euSmallVIN = "W09000051T2123456" type mockAPIClient struct { apiCalls int } func NewMockAPIClient() *mockAPIClient { return &mockAPIClient {} } func (client *mockAPIClient) IsEuropean(code string) bool { client.apiCalls++ return true } func TestVIN_EU_SmallManufacturer(t *testing.T) { apiClient := NewMockAPIClient() service := vin.NewVINService( & vin.VINServiceConfig {}, apiClient) testVIN, _ := service.CreateFromCode(euSmallVIN) manufacturer := testVIN.Manufacturer() if manufacturer != "W09123" { t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN) } if apiClient.apiCalls != 1 { t.Errorf("unexpected number of API calls: %d", apiClient.apiCalls) } }

Ten test przechodzi pomyślnie, ponieważ nasza sonda IsEuropean uruchamia się raz podczas wywołania CreateFromCode .

Programowanie obiektowe w ruchu: zwycięska kombinacja (po prawidłowym wykonaniu)

Krytycy mogą powiedzieć: „Dlaczego nie użyć Javy, jeśli i tak robisz OOP?” Cóż, ponieważ otrzymujesz wszystkie inne fajne zalety Go, unikając zasobożernych maszyn wirtualnych/JIT, przeklętych frameworków z adnotacjami voodoo, obsługą wyjątków i przerwami na kawę podczas uruchamiania testów (te ostatnie mogą być problemem dla niektórych).

Powyższy przykład pokazuje, w jaki sposób programowanie obiektowe w Go może generować lepiej zrozumiały i szybciej działający kod w porównaniu z prostą, imperatywną implementacją. Chociaż Go nie ma być językiem OOP, zapewnia narzędzia niezbędne do strukturyzowania aplikacji w sposób obiektowy. Wraz z funkcjami grupowania w pakietach, OOP w Golang może być wykorzystany do zapewnienia modułów wielokrotnego użytku jako bloków konstrukcyjnych dla dużych aplikacji.


Status Partnera Google Cloud.

Jako Partner Google Cloud, certyfikowani przez Google eksperci Toptal są dostępni dla firm na żądanie w ich najważniejszych projektach.