Патерн репозиторію¶
Розкидання SQL по всіх ваших обробниках прив'язує всю програму до однієї бази даних. Виправлення — це межа: інтерфейс, що описує те, що вам потрібно, і один пакет, який його реалізує.
type UserStore interface {
ByID(ctx context.Context, id int64) (User, error)
Create(ctx context.Context, name string) (User, error)
}
Усе, що вище цієї межі, працює в термінах User і ErrUserNotFound.
Ніщо вище неї не імпортує database/sql.
Два пакети, один контракт¶
Інтерфейс, доменні типи й сигнальні помилки (sentinel errors) лежать у пакеті-контракті. Він взагалі не має імпортів бази даних:
type User struct {
ID int64
Name string
}
var ErrUserNotFound = errors.New("user not found")
type UserStore interface {
ByID(ctx context.Context, id int64) (User, error)
Create(ctx context.Context, name string) (User, error)
}
SQL живе в окремому пакеті-реалізації, у неекспортованій структурі, що тримає пул:
type userStore struct{ db *sql.DB }
func (s userStore) ByID(ctx context.Context, id int64) (User, error) {
var u User
err := s.db.QueryRowContext(ctx,
`SELECT id, name FROM users WHERE id=$1`, id).Scan(&u.ID, &u.Name)
if errors.Is(err, sql.ErrNoRows) {
return User{}, fmt.Errorf("id %d: %w", id, ErrUserNotFound)
}
if err != nil {
return User{}, fmt.Errorf("selecting user %d: %w", id, err)
}
return u, nil
}
Неекспортована, бо виклики (callers) мають тримати саме інтерфейс. Конструктор повертає його:
Доведіть, що реалізація задовольняє контракт, на етапі компіляції:
Перетворюйте помилки сховища на межі¶
Це та частина, що несе на собі весь патерн. sql.ErrNoRows — це
концепція database/sql; якщо дати їй просочитись нагору, кожен
виклик буде імпортувати пакет SQL, і зміна сховища зламає їх усі.
Сховище перетворює її:
_, err := store.ByID(ctx, 99999)
fmt.Println(errors.Is(err, ErrUserNotFound)) // output: true
fmt.Println(errors.Is(err, sql.ErrNoRows)) // output: false
fmt.Println(err) // output: id 99999: user not found
Обгортання через %w лишає errors.Is робочим, водночас додаючи,
якого саме id бракувало. Неочікувані помилки теж обгортаються, але
проходять як самі собою — виклик не може сплутати обірване з'єднання з
відсутнім рядком.
Тепер бізнес-логіка читається в доменних термінах:
func (g Greeter) Greet(ctx context.Context, id int64) (string, error) {
u, err := g.users.ByID(ctx, id)
if errors.Is(err, ErrUserNotFound) {
return "hello, stranger", nil
}
if err != nil {
return "", fmt.Errorf("greeting %d: %w", id, err)
}
return "hello, " + u.Name, nil
}
Залежте від найвужчого інтерфейсу¶
Єдиний інтерфейс Store із сорока методами змушує кожного споживача
знати про всі вони, а кожен тестовий дублікат — реалізовувати їх усі.
Оголошуйте той невеликий набір, який справді використовує кожен
споживач:
Greeter потребує два методи. Дайте йому два. Це принцип «приймайте
інтерфейси, повертайте конкретні типи» зі статті про
інтерфейси, застосований
до сховищ.
Структура-контейнер — розумний спосіб зібрати їх усіх при старті — просто не передавайте весь контейнер у кожен компонент:
type Stores struct {
Users UserStore
Posts PostStore
}
g := Greeter{users: stores.Users} // не так: Greeter{stores}
Тестування без бази даних¶
Оскільки споживач тримає інтерфейс, достатньо написаного вручну дубліката. Поля-функції дозволяють кожному тесту сказати точно те, що йому потрібно:
type stubUsers struct {
ByIDFn func(ctx context.Context, id int64) (User, error)
}
func (s stubUsers) ByID(ctx context.Context, id int64) (User, error) {
return s.ByIDFn(ctx, id)
}
func (s stubUsers) Create(context.Context, string) (User, error) { return User{}, nil }
Та сама логіка тепер працює взагалі без бази даних:
g := Greeter{users: stubUsers{ByIDFn: func(_ context.Context, id int64) (User, error) {
return User{ID: id, Name: "Stub"}, nil
}}}
fmt.Println(g.Greet(ctx, 1)) // output: hello, Stub <nil>
А гілки, які незручно відтворити на справжній базі даних, тепер стають одним рядком кожна:
// не знайдено
return User{}, ErrUserNotFound
// → hello, stranger <nil>
// збій з'єднання
return User{}, errors.New("connection reset")
// → greeting 7: connection reset
Другий випадок — це аргумент на користь усього патерну. Симулювати обірване з'єднання проти живої бази даних складно; проти інтерфейсу — тривіально.
Де це перестає бути виправданим¶
- Не додавайте метод на кожен запит. Сховище з методами
ByName,ByNameAndStatus,ByNameAndStatusAndCreatedAfter— це погано написаний конструктор запитів. Замість цього візьміть структуру-фільтр. - Не мапте геть усе. Звітний запит, що живить один ендпоінт, може бути єдиним методом, що повертає спеціально створений тип результату. Проштовхування його крізь доменну модель нікому не допомагає.
- Не абстрагуйте заради заміни, якої не станеться. Справжня користь — це тестованість і те, що SQL зібраний в одному місці. «Можливо, ми змінимо базу даних» майже ніколи не збувається, а проєктування під це лише погіршує інтерфейс.
- Транзакції перетинають сховища. Двом сховищам в одній транзакції
потрібно її ділити — і це саме транзакція, перенесена через
контекст, з попередньої статті, саме тому, що
сигнатури методів сховища не згадують
*sql.Tx.
З досвіду Python: це той самий патерн репозиторію, який ви б побудували над SQLAlchemy, але тут інтерфейс визначає споживач, і задовольняється він неявно, тож немає ні базового класу, ні реєстрації. Тестовий дублікат — це будь-яка структура з потрібними методами.
Швидка довідка¶
| Питання | Що робити |
|---|---|
| де живе інтерфейс | у пакеті-контракті, разом із доменними типами |
| де живе SQL | в окремому пакеті, у неекспортованій структурі |
| конструктор | func NewUserStore(db *sql.DB) UserStore |
| довести, що задовольняє | var _ UserStore = userStore{} |
| відсутність рядків | перетворити на власний ErrNotFound, обгорнутий через %w |
| інші помилки | обгорнути з контекстом, пропустити далі |
| що приймають споживачі | найвужчий інтерфейс, який вони використовують |
| тестові дублікати | структура з полями-функціями |
| транзакції між сховищами | нести їх у контексті |
| гранулярність | структури-фільтри, а не метод на кожен запит |