Język programowania Go: samouczek wprowadzający do Golanga

Opublikowany: 2022-03-11

Co to jest język programowania Go?

Stosunkowo nowy język programowania Go znajduje się w samym środku krajobrazu, zapewniając wiele dobrych funkcji i celowo pomijając wiele złych. Kompiluje się szybko, działa szybko, zawiera środowisko uruchomieniowe i wyrzucanie śmieci, ma prosty statyczny system typów i dynamiczne interfejsy oraz doskonałą standardową bibliotekę. Dlatego tak wielu programistów chętnie uczy się programowania w Go.

Samouczek Golanga: ilustracja logo

Idź i OOP

OOP to jedna z tych funkcji, które Go celowo pomija. Nie ma podklas, a więc nie ma diamentów dziedziczenia, super wywołań ani wirtualnych metod, które mogłyby cię potknąć. Mimo to wiele przydatnych części OOP jest dostępnych na inne sposoby.

*Miksyny* są dostępne poprzez anonimowe osadzanie struktur, co pozwala na wywoływanie ich metod bezpośrednio na strukturze zawierającej (patrz osadzanie). Promowanie metod w ten sposób nazywa się *przekazywaniem* i nie jest to to samo, co tworzenie podklas: metoda nadal będzie wywoływana na wewnętrznej, osadzonej strukturze.

Osadzanie również nie implikuje polimorfizmu. Chociaż „A” może mieć „B”, nie oznacza to, że jest to „B” -- funkcje, które przyjmują „B”, nie przyjmą zamiast tego „A”. Do tego potrzebujemy interfejsów , z którymi spotkamy się pokrótce później.

Tymczasem Golang zajmuje silną pozycję w kwestii funkcji, które mogą prowadzić do zamieszania i błędów. Pomija idiomy OOP, takie jak dziedziczenie i polimorfizm, na rzecz kompozycji i prostych interfejsów. Pomija obsługę wyjątków na rzecz jawnych błędów w zwracanych wartościach. Jest dokładnie jeden poprawny sposób ułożenia kodu Go, wymuszony przez narzędzie gofmt . I tak dalej.

Dlaczego warto uczyć się Golanga?

Go to także świetny język do pisania programów współbieżnych : programów z wieloma niezależnie działającymi częściami. Oczywistym przykładem jest serwer WWW: każde żądanie działa osobno, ale żądania często muszą współdzielić zasoby, takie jak sesje, pamięci podręczne lub kolejki powiadomień. Oznacza to, że wykwalifikowani programiści Go muszą radzić sobie z jednoczesnym dostępem do tych zasobów.

Chociaż Golang ma doskonały zestaw niskopoziomowych funkcji do obsługi współbieżności, ich bezpośrednie używanie może stać się skomplikowane. W wielu przypadkach garść abstrakcji wielokrotnego użytku nad tymi niskopoziomowymi mechanizmami znacznie ułatwia życie.

W dzisiejszym samouczku programowania Go przyjrzymy się jednej z takich abstrakcji: wrapperowi, który może przekształcić dowolną strukturę danych w usługę transakcyjną . Jako przykład posłużymy się typem Fund – prostym sklepem z pozostałymi środkami naszego startupu, w którym możemy sprawdzić saldo i dokonać wypłat.

Aby zademonstrować to w praktyce, zbudujemy usługę małymi krokami, robiąc po drodze bałagan, a następnie ponownie go sprzątając. W miarę postępów w naszym samouczku Go napotkamy wiele fajnych funkcji języka Go, w tym:

  • Rodzaje i metody struktur
  • Testy jednostkowe i benchmarki
  • Gorutyny i kanały
  • Interfejsy i dynamiczne pisanie

Budowanie prostego funduszu

Napiszmy trochę kodu, aby śledzić finansowanie naszego startupu. Fundusz zaczyna z określonym saldem, a pieniądze można wypłacić tylko (później dowiemy się o przychodach).

Ta grafika przedstawia prosty przykład gorutyny przy użyciu języka programowania Go.

Go celowo nie jest językiem zorientowanym obiektowo: nie ma klas, obiektów ani dziedziczenia. Zamiast tego zadeklarujemy typ struktury o nazwie Fund , z prostą funkcją do tworzenia nowych struktur funduszy i dwiema metodami publicznymi.

fundusz.go

 package funding type Fund struct { // balance is unexported (private), because it's lowercase balance int } // A regular function returning a pointer to a fund func NewFund(initialBalance int) *Fund { // We can return a pointer to a new struct without worrying about // whether it's on the stack or heap: Go figures that out for us. return &Fund{ balance: initialBalance, } } // Methods start with a *receiver*, in this case a Fund pointer func (f *Fund) Balance() int { return f.balance } func (f *Fund) Withdraw(amount int) { f.balance -= amount }

Testowanie z benchmarkami

Następnie potrzebujemy sposobu na przetestowanie Fund . Zamiast pisać oddzielny program, użyjemy pakietu testowego Go, który zapewnia ramy zarówno dla testów jednostkowych, jak i benchmarków. Prosta logika naszego Fund nie jest tak naprawdę warta pisania testów jednostkowych, ale ponieważ będziemy później dużo mówić o równoczesnym dostępie do funduszu, napisanie benchmarku ma sens.

Benchmarki są jak testy jednostkowe, ale zawierają pętlę, która wielokrotnie uruchamia ten sam kod (w naszym przypadku fund.Withdraw(1) ). Dzięki temu platforma może określić czas trwania każdej iteracji, uśredniając przejściowe różnice w wynikach wyszukiwania dysków, chybień w pamięci podręcznej, planowania procesów i innych nieprzewidywalnych czynników.

Platforma testowa chce, aby każdy test porównawczy działał przez co najmniej 1 sekundę (domyślnie). Aby to zapewnić, będzie on wielokrotnie wywoływał test porównawczy, przekazując za każdym razem rosnącą wartość „liczby iteracji” (pole bN ), aż przebieg zajmie co najmniej sekundę.

Na razie nasz benchmark po prostu wpłaci trochę pieniędzy, a następnie wypłaci je po jednym dolarze.

fundusz_test.go

 package funding import "testing" func BenchmarkFund(b *testing.B) { // Add as many dollars as we have iterations this run fund := NewFund(bN) // Burn through them one at a time until they are all gone for i := 0; i < bN; i++ { fund.Withdraw(1) } if fund.Balance() != 0 { b.Error("Balance wasn't zero:", fund.Balance()) } }

Teraz uruchommy to:

 $ go test -bench . funding testing: warning: no tests to run PASS BenchmarkWithdrawals 2000000000 1.69 ns/op ok funding 3.576s

To poszło dobrze. Przeprowadziliśmy dwa miliardy (!) iteracji i ostateczna kontrola salda była poprawna. Możemy zignorować ostrzeżenie „brak testów do uruchomienia”, które odnosi się do testów jednostkowych, których nie napisaliśmy (w późniejszych przykładach programowania Go w tym samouczku ostrzeżenie jest wycinane).

Równoczesny dostęp w Go

Teraz zróbmy zbieżny test porównawczy, aby modelować różnych użytkowników dokonujących wypłat w tym samym czasie. Aby to zrobić, stworzymy dziesięć gorutyn i każdy z nich wypłaci jedną dziesiątą pieniędzy.

Jak ustrukturyzowalibyśmy wiele współbieżnych gorutyn w języku Go?

Gorutyny są podstawowym budulcem współbieżności w języku Go. Są to zielone wątki — lekkie wątki zarządzane przez środowisko uruchomieniowe Go, a nie przez system operacyjny. Oznacza to, że możesz uruchomić ich tysiące (lub miliony) bez żadnych znaczących kosztów ogólnych. Gorutyny są tworzone za pomocą słowa kluczowego go i zawsze zaczynają się od funkcji (lub wywołania metody):

 // Returns immediately, without waiting for `DoSomething()` to complete go DoSomething()

Często chcemy stworzyć krótką jednorazową funkcję za pomocą zaledwie kilku linijek kodu. W tym przypadku zamiast nazwy funkcji możemy użyć domknięcia:

 go func() { // ... do stuff ... }() // Must be a function *call*, so remember the ()

Gdy wszystkie nasze gorutyny się pojawią, potrzebujemy sposobu, aby poczekać, aż skończą. Moglibyśmy sami zbudować jeden za pomocą kanałów , ale jeszcze się z nimi nie spotkaliśmy, więc byłoby to przeskakiwaniem do przodu.

Na razie możemy po prostu użyć typu WaitGroup w standardowej bibliotece Go, która w tym celu istnieje. Stworzymy jeden (zwany „ wg ”) i wg.Add(1) przed odrodzeniem każdego robotnika, aby śledzić ich liczbę. Następnie pracownicy zgłoszą się ponownie za pomocą wg.Done() . Tymczasem w głównej gorutynie możemy po prostu powiedzieć wg.Wait() , aby zablokować, aż każdy pracownik skończy.

Wewnątrz gorutyn roboczych w następnym przykładzie użyjemy defer , aby wywołać wg.Done() .

defer przyjmuje wywołanie funkcji (lub metody) i uruchamia je bezpośrednio przed powrotem bieżącej funkcji, po wykonaniu wszystkich innych czynności. Jest to przydatne do czyszczenia:

 func() { resource.Lock() defer resource.Unlock() // Do stuff with resource }()

W ten sposób możemy łatwo dopasować Unlock z jego Lock , aby uzyskać czytelność. Co ważniejsze, odroczona funkcja będzie działać, nawet jeśli wystąpi panika w głównej funkcji (coś, co możemy obsłużyć za pomocą try-finally w innych językach).

Na koniec, odroczone funkcje będą wykonywane w odwrotnej kolejności, w jakiej zostały wywołane, co oznacza, że ​​możemy ładnie przeprowadzić zagnieżdżone czyszczenie (podobnie do idiomu C zagnieżdżonych goto i label , ale znacznie ładniej):

 func() { db.Connect() defer db.Disconnect() // If Begin panics, only db.Disconnect() will execute transaction.Begin() defer transaction.Close() // From here on, transaction.Close() will run first, // and then db.Disconnect() // ... }()

OK, więc po tym wszystkim, co powiedziałem, oto nowa wersja:

fundusz_test.go

 package funding import ( "sync" "testing" ) const WORKERS = 10 func BenchmarkWithdrawals(b *testing.B) { // Skip N = 1 if bN < WORKERS { return } // Add as many dollars as we have iterations this run fund := NewFund(bN) // Casually assume bN divides cleanly dollarsPerFounder := bN / WORKERS // WaitGroup structs don't need to be initialized // (their "zero value" is ready to use). // So, we just declare one and then use it. var wg sync.WaitGroup for i := 0; i < WORKERS; i++ { // Let the waitgroup know we're adding a goroutine wg.Add(1) // Spawn off a founder worker, as a closure go func() { // Mark this worker done when the function finishes defer wg.Done() for i := 0; i < dollarsPerFounder; i++ { fund.Withdraw(1) } }() // Remember to call the closure! } // Wait for all the workers to finish wg.Wait() if fund.Balance() != 0 { b.Error("Balance wasn't zero:", fund.Balance()) } }

Możemy przewidzieć, co się tutaj wydarzy. Wszyscy pracownicy wykonają Withdraw jeden na drugim. Wewnątrz niego f.balance -= amount odczyta saldo, odejmie jedno, a następnie zapisze go z powrotem. Ale czasami dwóch lub więcej pracowników odczyta to samo saldo i wykona to samo odejmowanie, i otrzymamy niewłaściwą sumę. Prawidłowy?

 $ go test -bench . funding BenchmarkWithdrawals 2000000000 2.01 ns/op ok funding 4.220s

Nie, nadal mija. Co tu się stało?

Pamiętaj, że gorutyny to zielone wątki — są zarządzane przez środowisko uruchomieniowe Go, a nie przez system operacyjny. Środowisko wykonawcze planuje harmonogramy dla dowolnej liczby dostępnych wątków systemu operacyjnego. W momencie pisania tego samouczka języka Go, Go nie próbuje zgadywać, ile wątków systemu operacyjnego powinien użyć, a jeśli chcemy więcej niż jednego, musimy to powiedzieć. Wreszcie, obecne środowisko wykonawcze nie wyklucza gorutyn — gorutyna będzie działać, dopóki nie zrobi czegoś, co sugeruje, że jest gotowa na przerwę (np. interakcję z kanałem).

Wszystko to oznacza, że ​​chociaż nasz test porównawczy jest teraz współbieżny, nie jest równoległy . Tylko jeden z naszych pracowników będzie działał na raz i będzie działać, dopóki nie zostanie ukończony. Możemy to zmienić, mówiąc Go, aby używał większej liczby wątków za pomocą zmiennej środowiskowej GOMAXPROCS .

 $ GOMAXPROCS=4 go test -bench . funding BenchmarkWithdrawals-4 --- FAIL: BenchmarkWithdrawals-4 account_test.go:39: Balance wasn't zero: 4238 ok funding 0.007s

Tak jest lepiej. Teraz oczywiście tracimy część naszych wypłat, tak jak się spodziewaliśmy.

W tym przykładzie programowania Go wynik wielu równoległych gorutyn nie jest korzystny.

Zrób z tego serwer

W tym momencie mamy różne opcje. Moglibyśmy dodać wyraźną blokadę muteksu lub blokady odczytu i zapisu wokół funduszu. Moglibyśmy użyć funkcji porównania i zamiany z numerem wersji. Moglibyśmy pójść na całość i użyć schematu CRDT (być może zastępując pole balance listami transakcji dla każdego klienta i obliczając saldo z nich).

Ale nie zrobimy teraz żadnej z tych rzeczy, ponieważ są brudne, przerażające lub jedno i drugie. Zamiast tego zdecydujemy, że fundusz powinien być serwerem . Co to jest serwer? To coś, z czym rozmawiasz. W Go wszystko mówi się przez kanały.

Kanały to podstawowy mechanizm komunikacji między gorutynami. Wartości są przesyłane do kanału (o channel <- value ) i mogą być odbierane po drugiej stronie (o value = <- channel ). Kanały są „bezpieczne dla gorutyn”, co oznacza, że ​​dowolna liczba gorutyn może wysyłać do nich i odbierać od nich w tym samym czasie.

Buforowanie

Buforowanie kanałów komunikacji może być w pewnych okolicznościach optymalizacją wydajności, ale powinno być używane z dużą ostrożnością (i benchmarkingiem!).

Istnieją jednak zastosowania dla kanałów buforowanych, które nie dotyczą bezpośrednio komunikacji.

Na przykład, popularny idiom ograniczania przepustowości tworzy kanał z (na przykład) rozmiarem bufora `10`, a następnie natychmiast wysyła do niego dziesięć tokenów. Następnie pojawia się dowolna liczba robotniczych gorutyn, z których każda otrzymuje token z kanału przed rozpoczęciem pracy, a następnie odsyła go z powrotem. Wtedy, bez względu na liczbę pracowników, tylko dziesięciu będzie pracowało w tym samym czasie.

Domyślnie kanały Go nie są buforowane . Oznacza to, że wysłanie wartości do kanału będzie blokowane, dopóki inna gorutyna nie będzie gotowa do jej natychmiastowego odbioru. Go obsługuje również stałe rozmiary buforów dla kanałów (używając make(chan someType, bufferSize) ). Jednak przy normalnym użytkowaniu jest to zwykle zły pomysł .

Wyobraź sobie serwer WWW dla naszego funduszu, na którym każde żądanie powoduje wypłatę. Gdy sytuacja jest bardzo zajęta, FundServer nie będzie w stanie nadążyć, a żądania wysyłane do jego kanału poleceń zaczną się blokować i czekać. W tym momencie możemy wymusić na serwerze maksymalną liczbę żądań i zwrócić klientom rozsądny kod błędu (np. 503 Service Unavailable ) powyżej tego limitu. Jest to najlepsze możliwe zachowanie, gdy serwer jest przeciążony.

Dodanie buforowania do naszych kanałów uczyniłoby to zachowanie mniej deterministycznym. Moglibyśmy łatwo skończyć z długimi kolejkami nieprzetworzonych poleceń w oparciu o informacje, które klient widział znacznie wcześniej (a być może w przypadku żądań, które od tego czasu przekroczyły limit czasu). To samo dotyczy wielu innych sytuacji, takich jak stosowanie przeciwciśnienia przez TCP, gdy odbiorca nie może nadążyć za nadawcą.

W każdym razie dla naszego przykładu Go pozostaniemy przy domyślnym zachowaniu niebuforowanym.

Użyjemy kanału do wysyłania poleceń do naszego FundServer . Każdy pracownik testu porównawczego wyśle ​​polecenia do kanału, ale otrzyma je tylko serwer.

Moglibyśmy przekształcić nasz typ funduszu bezpośrednio w implementację serwerową, ale byłoby to kłopotliwe — połączylibyśmy obsługę współbieżności i logikę biznesową. Zamiast tego pozostawimy typ funduszu dokładnie taki, jaki jest, i FundServer oddzielnym opakowaniem wokół niego.

Jak każdy serwer, wrapper będzie miał główną pętlę, w której czeka na polecenia i odpowiada na każde z nich po kolei. Jest jeszcze jeden szczegół, którym musimy się zająć: typ poleceń.

Schemat funduszu używanego jako serwer w tym samouczku programowania Go.

Wskaźniki

Mogliśmy sprawić, by nasz kanał poleceń pobierał *wskaźniki* do poleceń (`chan *TransactionCommand`). Dlaczego nie my?

Przekazywanie wskaźników między gorutynami jest ryzykowne, ponieważ każda z gorutyn może je zmodyfikować. Często jest również mniej wydajny, ponieważ druga gorutyna może działać na innym rdzeniu procesora (co oznacza więcej unieważniania pamięci podręcznej).

Jeśli to możliwe, wolę przekazywać zwykłe wartości.

W następnej sekcji poniżej wyślemy kilka różnych poleceń, każde z własnym typem struktury. Chcemy, aby kanał poleceń serwera akceptował którykolwiek z nich. W języku obiektowym możemy to zrobić za pomocą polimorfizmu: niech kanał przyjmuje nadklasę, której podklasami były poszczególne typy poleceń. W Go zamiast tego używamy interfejsów .

Interfejs to zestaw sygnatur metod. Każdy typ, który implementuje wszystkie te metody, może być traktowany jako ten interfejs (bez zadeklarowania tego). Podczas pierwszego uruchomienia nasze struktury poleceń nie będą w rzeczywistości ujawniać żadnych metod, więc użyjemy pustego interfejsu interface{} . Ponieważ nie ma żadnych wymagań, każda wartość (w tym wartości pierwotne, takie jak liczby całkowite) spełnia pusty interfejs. To nie jest idealne — chcemy tylko akceptować struktury poleceń — ale wrócimy do tego później.

Na razie zacznijmy od rusztowania dla naszego serwera Go:

serwer.go

 package funding type FundServer struct { Commands chan interface{} fund Fund } func NewFundServer(initialBalance int) *FundServer { server := &FundServer{ // make() creates builtins like channels, maps, and slices Commands: make(chan interface{}), fund: NewFund(initialBalance), } // Spawn off the server's main loop immediately go server.loop() return server } func (s *FundServer) loop() { // The built-in "range" clause can iterate over channels, // amongst other things for command := range s.Commands { // Handle the command } }

Teraz dodajmy kilka typów struktur Golanga dla poleceń:

 type WithdrawCommand struct { Amount int } type BalanceCommand struct { Response chan int }

WithdrawCommand zawiera tylko kwotę do wypłaty. Nie ma odpowiedzi. BalanceCommand ma odpowiedź, więc zawiera kanał do wysłania. Gwarantuje to, że odpowiedzi zawsze trafią we właściwe miejsce, nawet jeśli nasz fundusz później zdecyduje się odpowiedzieć w złej kolejności.

Teraz możemy napisać główną pętlę serwera:

 func (s *FundServer) loop() { for command := range s.Commands { // command is just an interface{}, but we can check its real type switch command.(type) { case WithdrawCommand: // And then use a "type assertion" to convert it withdrawal := command.(WithdrawCommand) s.fund.Withdraw(withdrawal.Amount) case BalanceCommand: getBalance := command.(BalanceCommand) balance := s.fund.Balance() getBalance.Response <- balance default: panic(fmt.Sprintf("Unrecognized command: %v", command)) } } }

Hmm. To trochę brzydkie. Włączamy typ polecenia, używamy asercji typu i prawdopodobnie się zawieszamy. Idźmy jednak dalej i zaktualizujmy benchmark, aby korzystać z serwera.

 func BenchmarkWithdrawals(b *testing.B) { // ... server := NewFundServer(bN) // ... // Spawn off the workers for i := 0; i < WORKERS; i++ { wg.Add(1) go func() { defer wg.Done() for i := 0; i < dollarsPerFounder; i++ { server.Commands <- WithdrawCommand{ Amount: 1 } } }() } // ... balanceResponseChan := make(chan int) server.Commands <- BalanceCommand{ Response: balanceResponseChan } balance := <- balanceResponseChan if balance != 0 { b.Error("Balance wasn't zero:", balance) } }

To też było trochę brzydkie, zwłaszcza gdy sprawdziliśmy równowagę. Uwaga. Spróbujmy:

 $ GOMAXPROCS=4 go test -bench . funding BenchmarkWithdrawals-4 5000000 465 ns/op ok funding 2.822s

Dużo lepiej, nie tracimy już wypłat. Ale kod staje się trudny do odczytania i pojawiają się poważniejsze problemy. Jeśli kiedykolwiek BalanceCommand , a następnie zapomnimy przeczytać odpowiedź, nasz serwer funduszy zablokuje na zawsze próby wysłania go. Posprzątajmy trochę.

Uczyń z tego usługę

Serwer to coś, z czym rozmawiasz. Co to jest usługa? Usługa to coś, z czym rozmawiasz za pomocą API . Zamiast tego, aby kod klienta działał bezpośrednio z kanałem poleceń, uczynimy kanał nieeksportowanym (prywatnym) i zawiniemy dostępne polecenia w funkcje.

 type FundServer struct { commands chan interface{} // Lowercase name, unexported // ... } func (s *FundServer) Balance() int { responseChan := make(chan int) s.commands <- BalanceCommand{ Response: responseChan } return <- responseChan } func (s *FundServer) Withdraw(amount int) { s.commands <- WithdrawCommand{ Amount: amount } }

Teraz nasz test porównawczy może po prostu powiedzieć server.Withdraw(1) i balance := server.Balance() , a jest mniejsza szansa, że ​​przypadkowo wyślesz mu nieprawidłowe polecenia lub zapomnisz przeczytać odpowiedzi.

Oto, jak może wyglądać korzystanie z funduszu jako usługi w tym przykładowym programie w języku Go.

Nadal istnieje wiele dodatkowych szablonów poleceń, ale wrócimy do tego później.

Transakcje

W końcu pieniądze zawsze się kończą. Umówmy się, że przestaniemy się wycofywać, gdy nasz fundusz wyczerpie się do ostatnich dziesięciu dolarów, i wydamy te pieniądze na wspólną pizzę, aby świętować lub współczuć. Nasz benchmark to odzwierciedli:

 // Spawn off the workers for i := 0; i < WORKERS; i++ { wg.Add(1) go func() { defer wg.Done() for i := 0; i < dollarsPerFounder; i++ { // Stop when we're down to pizza money if server.Balance() <= 10 { break } server.Withdraw(1) } }() } // ... balance := server.Balance() if balance != 10 { b.Error("Balance wasn't ten dollars:", balance) }

Tym razem naprawdę możemy przewidzieć wynik.

 $ GOMAXPROCS=4 go test -bench . funding BenchmarkWithdrawals-4 --- FAIL: BenchmarkWithdrawals-4 fund_test.go:43: Balance wasn't ten dollars: 6 ok funding 0.009s

Wróciliśmy tam, gdzie zaczęliśmy – kilku pracowników może jednocześnie odczytać saldo, a następnie wszyscy go zaktualizować. Aby sobie z tym poradzić, moglibyśmy dodać trochę logiki w samym funduszu, na przykład właściwość minimumBalance lub dodać kolejne polecenie o nazwie WithdrawIfOverXDollars . To oba straszne pomysły. Nasza umowa jest między nami, a nie własnością funduszu. Powinniśmy zachować to w logice aplikacji.

To, czego naprawdę potrzebujemy, to transakcje , w tym samym sensie, co transakcje bazodanowe. Ponieważ nasza usługa wykonuje tylko jedno polecenie na raz, jest to bardzo łatwe. Dodamy polecenie Transact , które zawiera wywołanie zwrotne (zamknięcie). Serwer wykona wywołanie zwrotne wewnątrz własnej gorutyny, przekazując surowy Fund . Oddzwanianie może wtedy bezpiecznie zrobić z Fund , co tylko chce.

Semafory i błędy

W następnym przykładzie robimy dwie małe rzeczy źle.

Po pierwsze, używamy kanału „Gotowe” jako semafora, aby poinformować kod wywołujący o zakończeniu transakcji. W porządku, ale dlaczego typ kanału to `bool`? Wyślemy w nim tylko słowo „prawda” w znaczeniu „gotowe” (co w ogóle oznaczałoby wysłanie „fałsz”?). To, czego naprawdę chcemy, to wartość jednostanowa (wartość, która nie ma wartości?). W Go możemy to zrobić za pomocą pustego typu struktury: `struct{}`. Ma to również tę zaletę, że zużywa mniej pamięci. W przykładzie pozostaniemy przy `bool`, aby nie wyglądać zbyt strasznie.

Po drugie, nasze wywołanie zwrotne transakcji niczego nie zwraca. Jak zobaczymy za chwilę, możemy uzyskać wartości z wywołania zwrotnego do kodu wywołującego za pomocą sztuczek z zakresem. Jednak transakcje w rzeczywistym systemie przypuszczalnie czasami kończyłyby się niepowodzeniem, więc zgodnie z konwencją Go transakcja zwracałaby „błąd” (a następnie sprawdzała, czy w kodzie wywołującym było „zerowe”).

Na razie też tego nie robimy, ponieważ nie mamy żadnych błędów do wygenerowania.
 // Typedef the callback for readability type Transactor func(fund *Fund) // Add a new command type with a callback and a semaphore channel type TransactionCommand struct { Transactor Transactor Done chan bool } // ... // Wrap it up neatly in an API method, like the other commands func (s *FundServer) Transact(transactor Transactor) { command := TransactionCommand{ Transactor: transactor, Done: make(chan bool), } s.commands <- command <- command.Done } // ... func (s *FundServer) loop() { for command := range s.commands { switch command.(type) { // ... case TransactionCommand: transaction := command.(TransactionCommand) transaction.Transactor(s.fund) transaction.Done <- true // ... } } }

Nasze wywołania zwrotne transakcji nie zwracają niczego bezpośrednio, ale język Go ułatwia uzyskiwanie wartości bezpośrednio z zamknięcia, więc zrobimy to w benchmarku, aby ustawić flagę pizzaTime , gdy pieniądze się wyczerpią:

 pizzaTime := false for i := 0; i < dollarsPerFounder; i++ { server.Transact(func(fund *Fund) { if fund.Balance() <= 10 { // Set it in the outside scope pizzaTime = true return } fund.Withdraw(1) }) if pizzaTime { break } }

I sprawdź, czy działa:

 $ GOMAXPROCS=4 go test -bench . funding BenchmarkWithdrawals-4 5000000 775 ns/op ok funding 4.637s

Nic oprócz transakcji

Być może zauważyłeś okazję, aby teraz trochę uporządkować. Ponieważ mamy ogólne polecenie Transact , nie potrzebujemy już WithdrawCommand ani BalanceCommand . Przepiszemy je pod kątem transakcji:

 func (s *FundServer) Balance() int { var balance int s.Transact(func(f *Fund) { balance = f.Balance() }) return balance } func (s *FundServer) Withdraw(amount int) { s.Transact(func (f *Fund) { f.Withdraw(amount) }) }

Teraz jedynym poleceniem, jakie przyjmuje serwer, jest TransactionCommand , więc możemy usunąć cały bałagan w interface{} w jego implementacji i sprawić, by akceptował tylko polecenia transakcyjne:

 type FundServer struct { commands chan TransactionCommand fund *Fund } func (s *FundServer) loop() { for transaction := range s.commands { // Now we don't need any type-switch mess transaction.Transactor(s.fund) transaction.Done <- true } }

Dużo lepiej.

Tutaj możemy zrobić ostatni krok. Poza wygodnymi funkcjami Balance i Withdraw , wdrożenie usługi nie jest już powiązane z Fund . Zamiast zarządzać Fund , może zarządzać interface{} i być używany do pakowania czegokolwiek . Jednak każde wywołanie zwrotne transakcji musiałoby wtedy przekonwertować interface{} z powrotem na rzeczywistą wartość:

 type Transactor func(interface{}) server.Transact(func(managedValue interface{}) { fund := managedValue.(*Fund) // Do stuff with fund ... })

To jest brzydkie i podatne na błędy. To, czego naprawdę chcemy, to generyki kompilowane w czasie kompilacji, więc możemy „zaprojektować” serwer dla konkretnego typu (takiego jak *Fund ).

Niestety Go nie obsługuje leków generycznych – jeszcze. Oczekuje się, że w końcu nadejdzie, gdy ktoś wymyśli dla niego rozsądną składnię i semantykę. W międzyczasie, ostrożne projektowanie interfejsu często eliminuje potrzebę stosowania generyków, a jeśli tak nie jest, możemy poradzić sobie z asercjami typu (które są sprawdzane w czasie wykonywania).

Skończyliśmy?

TAk.

No dobrze, nie.

Na przykład:

  • Panika w transakcji zabije cały serwis.

  • Nie ma limitów czasu. Transakcja, która nigdy nie wraca, zablokuje usługę na zawsze.

  • Jeśli nasz Fundusz powiększy nowe pola i transakcja ulegnie awarii w połowie ich aktualizacji, będziemy mieli niespójny stan.

  • Transakcje mogą wyciekać z obiektu zarządzanego Fund , co nie jest dobre.

  • Nie ma rozsądnego sposobu przeprowadzania transakcji na wielu funduszach (takich jak wypłata z jednego i wpłata w innym). Nie możemy po prostu zagnieździć naszych transakcji, ponieważ pozwoliłoby to na zakleszczenie.

  • Prowadzenie transakcji asynchronicznie wymaga teraz nowej gorutyny i wielu bałaganu. W związku z tym prawdopodobnie chcemy mieć możliwość odczytywania najnowszego stanu Fund z innego miejsca, gdy trwa długotrwała transakcja.

W naszym następnym samouczku dotyczącym języka programowania Go przyjrzymy się kilku sposobom rozwiązania tych problemów.

Powiązane: Dobrze skonstruowana logika: samouczek Golang OOP