Przejście z Spring Boot na Go i YDB Serverless: doświadczenie migracji trenera pisania głuchym
Rozwój aplikacji typu TypeStep, trenera pisania głuchym, został przeprowadzony przez programistę z doświadczeniem w Java i Spring Boot. Aplikacja została przeniesiona do chmury Yandex Cloud Serverless. Zrezygnowanie z płatnych maszyn wirtualnych i rozwiązań podobnych do ECS doprowadziło do wyboru języka Go dla backendu oraz YDB zamiast PostgreSQL. Efektem było osiągnięcie bezpłatnego limitu przy bardzo szybkich startach kontenerów (milisekundy) i 1 milion jednostek żądań miesięcznie.
Go kompiluje się do statycznego pliku binarnego bez JVM i potrzeby ogrzewania JIT. Kontener uruchamia się natychmiast — kluczowe dla Serverless Containers, gdzie dla każdego żądania może zostać utworzony nowy instancja. Spring Boot z GraalVM wymaga dodatkowej konfiguracji, co nie mieściło się w ograniczonym budżecie.
YDB Serverless zastąpił PostgreSQL ze względu na koszty. Dostępne są 1 milion jednostek RU miesięcznie za darmo, ale obliczanie jednostek nadal pozostaje częściowo niejasne bez szczegółowego przejrzenia dokumentacji.
Architektura bez zbędnych warstw
Frontend oparty na Next.js z generacją statyczną przechowywany jest w Object Storage. Backend to Go w Dockerze na Serverless Containers. Baza danych to YDB Serverless. Żądania płyną bezpośrednio z przeglądarki: statyka z magazynu, API do kontenerów. Brak API Gateway minimalizuje opóźnienia i koszty.
// Przykład enum dla kolumn YDB
type StageType string
const (
Lowercase StageType = "lowercase"
Uppercase StageType = "uppercase"
Punctuation StageType = "punctuation"
Numbers StageType = "numbers"
)
func (s *StageType) Scan(value interface{}) error {
return ScanStringEnum(s, value, "StageType")
}
func (s *StageType) Value() (driver.Value, error) {
return string(*s), nil
}
Po przejściu z JPA do ręcznego projektowania schematów i zapytań YQL odczuwa się regresję. Każde pole wymaga DECLARE, typizowanych parametrów i ręcznego skanowania.
Repozytoria i zapytania YQL
Repozytoria wykorzystują YQL z transakcjami. Przykład tworzenia rekordu:
func (r *ItemRepository) CreateItem(ctx context.Context, item Item) (*Item, error) {
var result *Item
err := r.txManager.DoTx(ctx, func(ctx context.Context, tx table.TransactionActor) error {
res, err := tx.Execute(ctx, `
DECLARE $id AS Utf8;
DECLARE $name AS Utf8;
DECLARE $status AS Utf8;
DECLARE $created_at AS Datetime;
INSERT INTO items (id, name, status, created_at)
VALUES ($id, $name, $status, $created_at)
RETURNING id, name, status, created_at;
`,
table.NewQueryParameters(
table.ValueParam("$id", types.UTF8Value(item.Id)),
table.ValueParam("$name", types.UTF8Value(item.Name)),
table.ValueParam("$status", types.UTF8Value(string(item.Status))),
table.ValueParam("$created_at", types.DatetimeValueFromTime(item.CreatedAt)),
),
)
if err != nil {
return fmt.Errorf("failed to execute CreateItem: %w", err)
}
defer res.Close()
// ... skanowanie wyniku do &result
return res.Err()
})
return result, err
}
Dodanie kolumny wymaga zmian we wszystkich operacjach: INSERT, UPDATE, SELECT. W przeciwieństwie do JPA, gdzie encja aktualizuje się automatycznie.
- Zalety ręcznego SQL: pełna kontrola nad zapytaniami, brak problemu N+1.
- Wady: zbyt dużo kodu, ręczne mapowanie typów i enumów.
- Porównanie z JPA: JPA jest prostsze dla prostych schematów, ale ukrywa szczegóły wykonania.
Transakcje w rozproszonej YDB
YDB nie obsługuje kluczy obcych, ale oferuje transakcje. Bez @Transactional wymagane jest ręczne zarządzanie.
TransactionManager sprawdza kontekst na obecność tx:
func (t *TransactionManager) DoTx(ctx context.Context, fn func(ctx context.Context, tx table.TransactionActor) error) error {
if tx, ok := TxFromContext(ctx); ok {
return fn(ctx, *tx)
}
return t.ydb.NativeDriver.Table().DoTx(ctx, func(ctx context.Context, tx table.TransactionActor) error {
ctx = context.WithValue(ctx, txKey{}, &tx)
return fn(ctx, tx)
})
}
To symuluje propagację Spring: nowa transakcja lub ponowne użycie istniejącej. Usuwa powtarzanie metod (Create vs CreateTx).
Usługi i repozytoria wstrzykują menedżera. Wiele operacji w jednej transakcji działa bez problemów.
DI i ręczna kompozycja zależności
Bez Spring IoC zależności są składane w main.go w kolejności: DB → repozytoria → usługi → handlerzy. Brak refleksji, proxy czy cykli czasu wykonania.
Zalety:
- Jasny graf zależności.
- Brak magii i niespodziewanych błędów.
- Łatwy refaktoryzacja.
Wady: ręczny boilerplate dla każdego komponentu.
Obsługa błędów z kontekstem
Błędy w Go to wartości bez śladów stosu. Rozwiązanie: unikalne prefiksy w fmt.Errorf.
func (s *ItemService) ProcessItem(ctx context.Context, id string) error {
item, err := s.repo.FindById(ctx, id)
if err != nil {
return fmt.Errorf("ProcessItem: failed to find item %s: %w", id, err)
}
// ...
}
W logach: ProcessItem: failed to find item abc123: ItemRepository.FindById: query failed: connection refused. Łańcuch wskazuje dokładne miejsce awarii.
Optymalizacja jednostek RU w YDB
Początkowa struktura kopiuje PostgreSQL: statystyki liter w osobnych wierszach, INSERT/UPDATE w pętli po klawiszach. To zużywa duże ilości jednostek RU.
Optymalizacje:
- Agregacja danych po stronie klienta przed wstawieniem.
- Batchowanie operacji.
- Przegląd indeksów i partycjonowania.
Po poprawkach mieszczą się w bezpłatnym limicie.
Co jest ważne
- Go zapewnia chłodne starty <1 sekundy dla Serverless Containers, w porównaniu do sekund w Spring Boot.
- YDB wymaga ręcznego YQL i DECLARE-parametrów; upraszczaj schematy, aby oszczędzić jednostki RU.
- TransactionManager symuluje @Transactional bez duplikowania kodu.
- Ręczna DI jest bardziej przejrzysta niż Spring, ale wymaga dyscypliny w main.go.
- Błędy otaczaj prefiksami dla lepszej czytelności logów.
— Editorial Team
Brak komentarzy.