CodeGym /Kursy /Go SELF /Kontrakty pakietów — minimalne API

Kontrakty pakietów — minimalne API

Go SELF
Poziom 37, Lekcja 4
Dostępny

1. Kontrakt pakietu: co dokładnie obiecujesz kodowi z zewnątrz

Kiedy piszesz kod w jednym pliku, wydaje się, że wszystko jest pod kontrolą: „no przecież wiem, co tu się dzieje”. Ale gdy tylko pojawia się pakiet, kod zamienia się w małą bibliotekę. I nagle okazuje się, że twoim przyszłym „użytkownikiem” jesteś ty sam za miesiąc (i już bez świeżych wspomnień), twój kolega, testy, a czasem nawet inny projekt.

Kontrakt pakietu to zestaw obietnic, które pakiet składa światu zewnętrznemu. W Go najbardziej bezpośrednim językiem kontraktu jest eksportowane API: eksportowane typy, funkcje, metody, zmienne, stałe. Zmienisz kontrakt — możesz złamać użytkowników. To dosłownie jeden z podstawowych sensów kompatybilności: nie można „po prostu wziąć i usunąć API”, bo ktoś mógł już się na nim oprzeć.

Myślmy praktycznie: dobry kontrakt sprawia, że pakiet jest przewidywalny. Zły kontrakt sprawia, że pakiet staje się „pudełkiem niespodzianek”, gdzie każda próba „ulepszenia” implementacji grozi wysadzeniem kodu zewnętrznego.

Minimalne API: eksport

Istnieje prosta (i trochę smutna) prawda: eksportowanej nazwy prawie nigdy nie da się „bezboleśnie zabrać z powrotem”. Oczywiście można ogłosić „przestarzałe” i trzymać w kodzie wieczne muzeum, ale to już osobny rodzaj sztuki.

Dlatego jednym z najcenniejszych nawyków w Go jest zaczynanie od małego eksportu. Logika jest taka: to, co nie zostało wyeksportowane, można zmieniać nawet codziennie, dopóki testy przechodzą i zachowanie pozostaje takie samo. To, co zostało wyeksportowane, zaczyna żyć według zasad „to obiecaliśmy”.

Wyobraź sobie, że pakiet to kawiarnia. Eksportowane API to menu. Jeśli codziennie drukujesz 80 stron menu, goście tracą czas nie na jedzenie, ale na czytanie. Minimalne API to krótkie menu, w którym naprawdę zamawia się każde danie.

Tabelka, która pomaga „otrzeźwieć”

Element Wyeksportowano? Co to oznacza w praktyce
type Task struct { ... }
Tak/Nie Z zewnątrz można tworzyć/porównywać/kopiować wartości i oczekiwać stabilności
Pola struktury Tak/Nie Eksport pola = kod zewnętrzny może zmieniać je bezpośrednio (niezmienniki wyciekają)
func NewX(...) X
Tak Obiecujesz sposób poprawnego tworzenia
var ErrSomething
Tak Obiecujesz rozpoznawalną przyczynę błędu (będzie sprawdzana przez errors.Is)
func (x X) Method()
Tak Obiecujesz zachowanie i sygnaturę; zmiana nazwy to zmiana łamiąca

Główna idea: eksportujemy nie dlatego, że „jest wygodnie teraz”, ale dlatego, że „to naprawdę jest część kontraktu”.

2. Niezmienniki modelu: dlaczego lepiej ukrywać pola

Bardzo często początkujący (a czasem nie tylko początkujący) robią tak: tworzą strukturę, eksportują pola — i cieszą się, że „teraz wszystko jest proste”. Ale to prostota na poziomie „zdjąłem drzwi z lodówki — produkty teraz łatwiej wyjmować”. Tak, łatwiej. Ale jest haczyk: temperatura też stała się „wygodniejsza”, bo już jej nie ma.

Niezmienniki to zasady poprawności danych. Na przykład dla zadania w aplikacji TODO łatwo wymyślić niezmienniki: id musi być dodatnie, title nie może być puste, done to stan logiczny, a nie ciąg znaków "yes pls".

Jeśli pola są eksportowane, dowolny kod zewnętrzny może zrobić:

t := todo.Task{}
t.ID = -100
t.Title = ""

I to wszystko — twój pakiet przestaje kontrolować własny model.

Praktyka: robimy model bezpieczny

Poniżej mały, ale bardzo typowy wzorzec: pola ukrywamy, tworzenie robimy przez konstruktor, a na zewnątrz dajemy metody dostępu.

package todo

import (
	"errors"
	"strings"
)

type Task struct {
	id    int
	title string
	done  bool
}

func NewTask(id int, title string) (Task, error) {
	title = strings.TrimSpace(title)
	if id <= 0 {
		return Task{}, errors.New("id must be positive")
	}
	if title == "" {
		return Task{}, errors.New("title is empty")
	}
	return Task{id: id, title: title}, nil
}

Zwróć uwagę: Task{} nie da się poprawnie utworzyć z zewnątrz, bo pola są niewidoczne. Za to można utworzyć przez NewTask, a tam utrzymasz niezmienniki.

Dalej dodajmy metody (i tak, krótki receiver jest całkowicie normalny):

package todo

func (t Task) ID() int       { return t.id }
func (t Task) Title() string { return t.title }
func (t Task) Done() bool    { return t.done }

I osobno metoda, która zmienia stan — ale już pod twoją kontrolą:

package todo

func (t *Task) MarkDone() {
	t.done = true
}

Teraz kod zewnętrzny nie może „przez przypadek” zrobić zadania niepoprawnym, omijając twój pakiet.

4. Błędy jako część kontraktu: co można obiecać na zewnątrz

Błędy w Go to wartości, z którymi pracuje kod, a nie tylko tekst na ekranie. To ważne właśnie dla kontraktów pakietów: w momencie, gdy kod zewnętrzny zaczyna rozróżniać przyczyny błędów, nie zwracasz już tylko „ciągu znaków”, ale przekazujesz sygnał, na którym można się oprzeć.

„Wrap czy nie wrap” — decyzja na poziomie API

Oto kluczowa myśl, którą warto zapamiętać: jeśli owijasz błąd przez %w, czynisz oryginalny błąd częścią API, bo kod zewnętrzny będzie mógł napisać errors.Is(...) i zacząć na nim polegać.

Dlaczego to ważne? Bo czasem to jest właściwe (na przykład, jeśli źródło błędu dostarczył sam użytkownik pakietu), a czasem jest to wyciek szczegółów implementacji.

W Go‑blogu o błędach jest bardzo praktyczny przykład: jeśli twój pakiet wewnętrznie używa database/sql i zwraca na zewnątrz sql.ErrNoRows przez wrapping, to kod zewnętrzny zacznie sprawdzać właśnie sql.ErrNoRows. A potem będziesz chciał zmienić implementację przechowywania — i nagle okaże się, że nie możesz, nie łamiąc użytkowników.

Czyli wrapping to nie „ładniej”, ale „otwieram wnętrze na zewnątrz”.

Stabilne przyczyny błędów dla własnego pakietu

Dla pakietu TODO zazwyczaj chcemy, aby kod zewnętrzny mógł rozróżniać kilka przyczyn: „nie znaleziono”, „walidacja”, „błąd wewnętrzny”. Zacznijmy od najprostszego: sentinel error.

package todo

import "errors"

var ErrNotFound = errors.New("not found")

Teraz w kodzie pakietu możemy zagwarantować: jeśli zadanie nie zostanie znalezione, na zewnątrz wyjdzie błąd rozpoznawany jako ErrNotFound.

Przykład funkcji, która dodaje kontekst i zachowuje przyczynę:

package todo

import "fmt"

func (s *Service) Get(id int) (Task, error) {
	t, err := s.store.Get(id)
	if err != nil {
		return Task{}, fmt.Errorf("get task %d: %w", id, err)
	}
	return t, nil
}

Ale tu jest ważne zastrzeżenie: co dokładnie leży w err? Jeśli s.store to wewnętrzny szczegół, a jego błędów nie chcesz eksportować na zewnątrz, to %w może być zbędne. I to już jest decyzja kontraktowa.

Często robi się tak: na dole (wewnątrz pakietu) rozróżnia się szczegóły, a na zewnątrz oddaje się błędy własnej domeny, nie ujawniając „jaki tam dokładnie jest sterownik/pliki/format”.

5. Jasne zależności: „jawnie przekaż to, czego potrzebujesz”

W złym kodzie zależności pojawiają się jak grzyby po deszczu: wewnątrz funkcji nagle tworzony jest logger, otwierany jest plik, pobierany jest bieżący czas, łączymy się z siecią, czytamy konfigurację z env. Czytelnik otwiera funkcję — i widzi nie logikę biznesową, tylko szamanizm i efekty uboczne.

W dobrym kodzie Go zależności stara się robić jawnie. To nie jest kwestia „modne”, ale przewidywalności. Gdy zależność jest jawna, łatwiej zrozumieć, od czego zależy zachowanie funkcji, łatwiej podmienić zależność w testach i łatwiej nie ukrywać stanu globalnego.

Mini‑zasada: pakiet nie powinien sam decydować, „skąd wziąć świat”

Pakiet powinien umieć pracować z tym, co dostał. Jeśli potrzebuje „teraz”, niech dostanie „zegary”. Jeśli potrzebuje przechowywania, niech dostanie „magazyn”. I nie trzeba od razu zamieniać tego w kosmiczny kontener DI: w Go najczęściej wystarcza konstruktor.

Zróbmy mały interfejs Store. Ważne, żeby nie popaść w interface pollution: interfejs powinien odzwierciedlać realne potrzeby serwisu, a nie „wszystko, co potrafi baza”.

package todo

type Store interface {
	Get(id int) (Task, error)
	Save(t Task) error
}

A zegar — osobny mały kontrakt (w Go bardzo lubi się tak robić, bo jest to proste i testowalne):

package todo

import "time"

type Clock interface {
	Now() time.Time
}

Teraz Service zależy jawnie od Store i Clock:

package todo

type Service struct {
	store Store
	clock Clock
}

func NewService(store Store, clock Clock) *Service {
	return &Service{store: store, clock: clock}
}

Zwróć uwagę: NewService jest częścią kontraktu pakietu. Obiecujesz: serwis tworzy się tak, a zależności przekazuje się tak.

Dlaczego „stworzę wewnątrz” jest prawie zawsze gorsze

Gdybyśmy zrobili tak:

func NewService() *Service {
	return &Service{
		store: NewPostgresStoreFromEnv(),
		clock: RealClock{},
	}
}

to serwis stałby się „magiczny”. Zależałby od środowiska, od konkretnego Postgresa, od konkretnego sposobu odczytu konfiguracji — i wszystko to byłoby ukryte. Kod zewnętrzny nie rozumie, dlaczego serwis działa właśnie tak, i nie może łatwo podmienić zależności.

A to tworzy nieprzyjemny kontrakt: żeby korzystać z pakietu, trzeba „trafić w prawidłowy stan świata”. Zwykle psuje się to w nieoczekiwanym momencie — na przykład w testach albo w innym środowisku.

6. Przykład: mała aplikacja todo

Teraz złożymy to w miniaturowy obraz. Mamy pakiet todo, który udostępnia minimalne API, pilnuje niezmienników i wymaga zależności jawnie. Dla zrozumienia warto widzieć strukturę plików (to nie jest komenda ani „zadanie”, tylko po prostu orientacja dla oka):

todo/
  task.go        // model + niezmienniki
  errors.go      // domenowe przyczyny błędów
  service.go     // warstwa use-case pakietu
  store_mem.go   // wewnętrzna implementacja store (typ może być nieeksportowany)
main.go          // budowa aplikacji (w innym pakiecie)

Wewnętrzny magazyn w pamięci: typ ukrywamy, konstruktor wybieramy zgodnie z kontraktem

Zróbmy in-memory store. Ważne: typ można nie eksportować, jeśli nie chcesz obiecywać go na zewnątrz. Jednocześnie konstruktor można eksportować, jeśli jesteś gotów wspierać go jako część API.

Poniżej wariant: typ nie jest eksportowany, a konstruktor jest eksportowany i zwraca interfejs Store (w ten sposób mniej przywiązujesz kod zewnętrzny do konkretnej struktury).

package todo

import "fmt"

type memStore struct {
	items map[int]Task
}

func NewInMemoryStore() Store {
	return &memStore{items: make(map[int]Task)}
}

func (m *memStore) Get(id int) (Task, error) {
	t, ok := m.items[id]
	if !ok {
		return Task{}, fmt.Errorf("%w", ErrNotFound)
	}
	return t, nil
}

Zapis:

package todo

func (m *memStore) Save(t Task) error {
	m.items[t.ID()] = t
	return nil
}

Tutaj zwracamy ErrNotFound jako przyczynę. Można też bez fmt.Errorf, po prostu return Task{}, ErrNotFound. „Pusty wrap” zwykle nie jest potrzebny, ale samą technikę warto rozumieć: wrapping wpływa na kontrakt.

Serwis: „wąskie” API i jasne błędy

Dodajmy tworzenie zadania przez serwis, gdzie serwis sam wybiera ID (tak, to uproszczenie, ale do przykładu pasuje). Zwróć uwagę na happy path: na dole.

package todo

import "fmt"

func (s *Service) Create(title string) (Task, error) {
	id := int(s.clock.Now().Unix()) // najprostszy przykład, nie do produkcji
	t, err := NewTask(id, title)
	if err != nil {
		return Task{}, fmt.Errorf("create task: %w", err)
	}
	if err := s.store.Save(t); err != nil {
		return Task{}, fmt.Errorf("save task: %w", err)
	}
	return t, nil
}

Teraz patrz oczami „użytkownika pakietu”: Create albo zwraca zadanie, albo błąd. Błąd niesie kontekst. Kod zewnętrzny może rozróżniać ErrNotFound (jeśli to ważne), ale nie musi wiedzieć, że wewnątrz działa memStore albo jaka tam jest baza.

Złożenie w main: zależności tworzone są na granicy aplikacji

Na granicy aplikacji wybieramy implementację zależności. We wnętrzu pakietu todo nie „zgadujemy”, czego potrzebuje świat.

package main

import (
	"fmt"
	"time"

	"example.com/app/todo"
)

type realClock struct{}

func (realClock) Now() time.Time { return time.Now() }

func main() {
	store := todo.NewInMemoryStore()
	svc := todo.NewService(store, realClock{})

	t, err := svc.Create("kupić mleko")
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println("created:", t.Title()) // created: kupić mleko
}

Jeśli nie chcesz obiecywać na zewnątrz nawet konstruktora NewInMemoryStore, to jest już decyzja na poziomie architektury aplikacji: można tworzyć memStore po stronie main (na przykład przez wewnętrzny pakiet albo fabrykę na poziomie aplikacji), a pakiet todo pozostawić maksymalnie „domenowym”.

Kompatybilność i zmiany API: dlaczego „po prostu zmienię nazwę” nie jest takie proste

Czasem wydaje się: „zmienię nazwę Task.Title() na Task.Name() — i po sprawie”. Ale jeśli to jest eksportowane, twój pakiet mógł już być używany gdzieś indziej. Zespół Go w kontekście kompatybilności wyraźnie podkreśla: usunięcie lub zmiana eksportowanych elementów łamie programy, które są od nich zależne.

Dobra praktyka: zanim coś wyeksportujesz, zadaj sobie pytanie: „czy jestem gotów długo wspierać tę nazwę i tę sygnaturę?” Jeśli masz wątpliwości — lepiej nie eksportować.

7. Typowe błędy

Błąd nr 1: eksportowanie pól struktury „żeby było wygodnie”.
Zwykle prowadzi to do tego, że niezmienniki przestają istnieć, a pakiet zaczyna przyjmować niepoprawne dane „z zewnątrz”. Potem dodajesz sprawdzanie w każdej funkcji i kod zamienia się w tor przeszkód. Dużo spokojniej jest ukryć pola i dać jedną poprawną ścieżkę tworzenia.

Błąd nr 2: zamienianie pakietu w „składowisko” wszystkiego naraz.
Gdy w jednym pakiecie żyją model, parsowanie, drukowanie, praca z plikami i sieć, kontrakt staje się nieczytelny. Kodowi zewnętrznemu trudno zrozumieć, „co ten pakiet w ogóle obiecuje”. Jeden pakiet — jedna jasna odpowiedzialność.

Błąd nr 3: ukrywanie zależności wewnątrz pakietu.
Jeśli pakiet sam czyta env, sam otwiera pliki, sam tworzy klientów — kod staje się magiczny. Trudno go testować i trudno zrozumieć. Jasne zależności przez konstruktor czynią zachowanie przejrzystym.

Błąd nr 4: bezmyślne używanie %w na wszystkich poziomach.
Wrapping przez %w to nie tylko „dodanie kontekstu”, ale decyzja dotycząca API: pozwalasz kodowi zewnętrznemu rozpoznawać wewnętrzną przyczynę przez errors.Is/errors.As. To przydatne, gdy przyczyna rzeczywiście powinna być widoczna, ale szkodliwe, gdy ujawniasz szczegóły implementacji (na przykład konkretny sterownik bazy danych).

Błąd nr 5: wystawianie na zewnątrz „losowych ciągów” zamiast stabilnych przyczyn.
Jeśli kod zewnętrzny ma rozróżniać „nie znaleziono”, „walidacja”, „brak dostępu”, to łańcuch znaków jest złym kontraktem. Dzisiaj tekst jest jeden, jutro inny, a logika kodu zewnętrznego się psuje. Lepiej dać stabilne przyczyny (sentinel/typed errors), a już na granicy aplikacji tłumaczyć je na komunikaty dla użytkownika.

1
Zadanie
Go SELF, poziom 37, lekcja 4
Niedostępne
Pakiet powitań
Pakiet powitań
1
Zadanie
Go SELF, poziom 37, lekcja 4
Niedostępne
Użytkownik konta
Użytkownik konta
1
Zadanie
Go SELF, poziom 37, lekcja 4
Niedostępne
Katalog produktów
Katalog produktów
1
Zadanie
Go SELF, poziom 37, lekcja 4
Niedostępne
Serwis notatek
Serwis notatek
1
Ankieta/quiz
Czysty kod w Go, poziom 37, lekcja 4
Niedostępny
Czysty kod w Go
Czysty kod w Go
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION