Об интерфейсах и композиции
Абстракции — это прекрасно, когда они делаются умеренно, с учетом поставленной цели и с использованием возможностей языка, предназначенных для получения преимуществ. Мы работаем над созданием хранилища данных, и я думал о том, как реализовать его таким образом, чтобы работа с кодом была понятной и сводилась к небольшим блокам, которые можно менять местами по мере необходимости.
Необходимость хранения данных
В большинстве приложений вам понадобится способ хранения данных, чтобы их можно было извлечь позже. Будь то скомпилированные артефакты сборки, метрики, журналы, комментарии, сообщения в блоге, изображения — неважно. У вас есть кусок данных, и вы хотите поместить его куда-то, где вы сможете найти его и сделать с ним что-то спустя 4 недели.
Самая первая итерация приложения, скорее всего, будет содержать базу данных, например MySQL, поскольку именно ее вы решили использовать, это просто, и нет причин использовать непрямые и абстракции.
Проблемы возникают, когда вам нужно заменить MySQL на что-то другое по какой-либо причине. Теперь вам нужно вырвать половину вашей кодовой базы, потому что MySQL был жестко закодирован.
Вводим интерфейсы
Допустим, ваше приложение действительно заботят только две вещи: пользователи и продукты. Единственное, что нужно сделать вашему приложению в отношении этих вещей, это
- хранение пользователя
- поиск пользователя по адресу электронной почты или ID
- хранение продукта
- поиск продукта по его ID
Вот и все.
Таким образом, учитывая следующий псевдокод, приложение заботят только 4 вышеуказанные операции, но ему не нужно знать, как они выполняются.
package app
type User struct {
ID int
Name string,
Email string,
Password string,
Confirmed bool
}
type Product struct {
ID int
Title string,
Author User,
Description string,
Price int
}
func (a *App) ProductsForUser(user User) ([]Product, error) {
// Get products for the user
}
Отсюда и интерфейсы. Они описывают набор моделей поведения, которые должны быть реализованы. В этом гипотетическом примере использования будет такой интерфейс.
package app
type Storage interface {
StoreUser(ctx context.Context, user User) (User, error)
GetUser(ctx context.Context, id int) (User, error)
ListUsers(ctx context.Context, ids []int) ([]User, error)
StoreProduct(ctx context.Context, product Product) (Product, error)
GetProduct(ctx context.Context, id int) (Product, error)
ListProducts(ctx context.Context, ids []int) ([]Product, error)
ListProductsForUser(ctx context.Context, user User) ([]Product, error)
}
Привязка этого интерфейса к приложению будет выглядеть следующим образом, без реализации или конструктора.
package app
type App struct {
storage Storage // the type is the Storage interface from above
}
func New(storage Storage) App {
return App{
storage: storage,
}
}
func (a *App) ProductsForUser(user User) ([]Product, error) {
products, err := a.storage.ListProductsForUser(user)
if err != nil {
return nil, errors.Wrap(err, "storage.ListProductsForUser for user %d", user.ID)
}
return products, nil
}
Замечательно то, что реализация Storage не имеет значения. Давайте подключим сюда нашу реализацию MySQL:
package mysql
import (
"database/sql"
"app"
_ "github.com/go-sql-driver/mysql"
)
// This is the mysql.DB, the implementation of the Storage interface
type DB struct {
db dbConnection
}
func New(username, password, host, port, databasename) (DB, error) {
db, err := sql.Open("mysql", fmt.Sprintf(
"%s:%s@%s:%s/%s",
username, password, host, port, databasename,
))
if err != nil {
return Storage{}, errors.Wrapf(err, "sql.Open: %s:***@%s:%s/%s",
username, host, port, databasename)
}
db.SetConnMaxLifetime(time.Minute * 3)
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(10)
return DB{
db: db,
}, nil
}
func (d *DB) ProductsForUser(ctx context.Context, user app.User) ([]app.Product, error) {
d.db.Select(query, user.ID)
}
func (d *DB) StoreUser(ctx context.Context, user app.User) (User, error) { ... }
// rest of the interface implementation here
Это даст нам прямой доступ к базе данных, здесь нет ничего сложного или умного.
Однако по соображениям производительности мы можем захотеть кэшировать некоторые из этих данных, особенно если данные имеют тенденцию быть статичными. Детали определенных продуктов не будут часто меняться, также как и данные о пользователях, и обычно быстрее обращаться к бэкенду Redis, чем к реальной базе данных.
Тем не менее, нам также нужно подумать о том, как справиться с ситуациями, когда некоторые данные не кэшируются. Один из обычных способов справиться с этим — проверить наличие данных, и если их там нет, проверить базу данных. Псевдокод выглядит следующим образом:
cachedData, found, err := redis.GetSomeData("key")
if err != nil {
// there was an error, handle it
}
if found {
// superb, cache had the data
return cachedData
}
// cache didn't have the data, let's ask MySQL
data, err := database.GetSomeData("key")
if err != nil {
// even database didn't have it, so 404?
}
// found the data in the database, cache it on Redis
// and return it
redis.SetSomeData("key", data)
return data
Это работает, но не очень расширяемо, сложно и похоже на спагетти. Есть лучший способ.
Обертывания!
Оказывается, я могу обернуть реализацию mysql в реализацию Redis. Также псевдокод:
package redis
import "github.com/go-redis/redis/v8"
type Cache struct {
fallback Storage // the interface
client *redis.Client
}
func New(host, port, password string, db int, fallback Storage) Cache {
rdb := redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%s:%s", host, port),
Password: password,
DB: db,
})
return Cache{
fallback: fallback,
client: rdb,
}
}
func (c Cache) ProductsForUser(user app.User) ([]app.Product, error) {
data, err := c.client.Get(ctx, user.ID).Result()
if err == nil {
// Redis had the data
return data, nil
}
if err == redis.Nil {
// Redis did not have the data, ask fallback
products, err := c.fallback.ProductsForUser(user)
if err != nil {
return nil, errors.Wrap(err, "redis fallback sent back an error")
}
err = c.client.Set(ctx, user.ID, products, 0).Err()
if err != nil {
// setting the value to Redis failed, but we do have the data
// log and move on
log.Warn("could not set data to redis", err)
}
return products, nil
}
// err is not nil, but also not not found from Redis
return nil, errors.Wrap(err, "something went wrong in redis")
}
Самое замечательное в этом то, что вызывающему коду, нашему приложению, не нужно ничего знать о внутреннем устройстве этого приложения. Код настройки будет выглядеть следующим образом:
package main
func main() {
// create mysql connection
mysqlStorage, err := mysql.New("username", "password", "host", "port", "databasename")
if err != nil {
log.Fatal("could not get mysql")
os.Exit(1)
}
// create Redis connection and add the mysql connection as a fallback
// for all the data that's not cached yet
redisStorage := redis.New("host", "port", "password", 0, mysqlStorage)
// spin up our app with the Redis storage
service := app.New(redisStorage)
// service will first check Redis, then mysql
products, err := service.ProductsForUser(43)
// rest of the owl
}
Таким образом, API сервиса остается плоским, простым и понятным. Более того, каждый уровень хранилища остается простым, потому что есть только два уровня, о которых им нужно беспокоиться: их собственный (Redis делает вещи Redis), и если это не дало никакого эффекта, попросите fallback.
Однако запасные варианты — это интерфейсы, поэтому им не нужно заботиться о том, что такое запасной вариант, просто запрос одних и тех же данных выглядит одинаково. Пусть об этом беспокоится пакет.
Это позволяет очень легко добавлять дополнительные уровни. Хотите ли вы иметь кэш в памяти перед Redis? Конечно:
package main
func main() {
// create MySQL connection
mysqlStorage, err := mysql.New("username", "password", "host", "port", "databasename")
if err != nil {
log.Fatal("could not get mysql")
os.Exit(1)
}
// create Redis connection and add the mysql connection as a fallback
// for all the data that's not cached yet
redisStorage := redis.New("host", "port", "password", 0, mysqlStorage)
// wrap the Redis into the in–memory
inmemStorage := inmemory.New(redisStorage)
// spin up our app with the in–memory storage
service := app.New(inmemStorage)
// service will first check in–memory, then Redis, then MySQL
products, err := service.ProductsForUser(43)
// rest of the owl
}
Тестирование / мокинг
Более важным, чем вышеперечисленное, является то, что модульное тестирование кода становится простым, потому что нам больше не нужно запускать реальный сервер Redis, или базу данных mysql, или использовать кэш в памяти, потому что мы тестируем не эти решения хранения, а скорее то, что сервис делает с данными / ошибками, возвращаемыми из хранилища.
Mockery — отличный инструмент для генерации моков на основе интерфейсов, таким образом, наши модульные тесты могут выглядеть следующим образом (псевдокод):
func TestGetProduct(t *testing.T) {
// new mock storage with expecter on it.
storage := new(mocks.Storage)
// tell the mock that if the argument for the ProductsForUser method is 43,
// then return that list of products, nil err,
// and expect it to be called 1 times.
storage.EXPECT().ProductsForUser(43).Return([]Product{p1, p2}, nil).Times(1)
// use the mocked storage implementation with expectations on it
service := app.New(storage)
// query the code
products, err := service.ProductsForUser(43)
// make the assertions
storage.AssertExpectations(t)
}
Обзор
Использование интерфейсов позволяет легко компоновать различные части сервисов, которые мы создаем. Таким образом, каждый отдельный слой может быть изолирован, протестирован, проверен, а вся функциональность может быть сымитирована, чтобы мы могли протестировать общую логику приложения с учетом известных результатов.
В конечном итоге это приводит к упрощению требований к тестированию, повышению уверенности в коде приложения, улучшению тестового покрытия, ускорению выполнения тестов и упрощению рассуждений о коде.