Dobrze skonstruowana logika: samouczek Golang OOP
Opublikowany: 2022-03-11Czy 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.
Jako Partner Google Cloud, certyfikowani przez Google eksperci Toptal są dostępni dla firm na żądanie w ich najważniejszych projektach.