Об интерфейсах и композиции


Об интерфейсах и композиции

Абстракции — это прекрасно, когда они делаются умеренно, с учетом поставленной цели и с использованием возможностей языка, предназначенных для получения преимуществ. Мы работаем над созданием хранилища данных, и я думал о том, как реализовать его таким образом, чтобы работа с кодом была понятной и сводилась к небольшим блокам, которые можно менять местами по мере необходимости.

Необходимость хранения данных

В большинстве приложений вам понадобится способ хранения данных, чтобы их можно было извлечь позже. Будь то скомпилированные артефакты сборки, метрики, журналы, комментарии, сообщения в блоге, изображения — неважно. У вас есть кусок данных, и вы хотите поместить его куда-то, где вы сможете найти его и сделать с ним что-то спустя 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)
}
Вход в полноэкранный режим Выход из полноэкранного режима

Обзор

Использование интерфейсов позволяет легко компоновать различные части сервисов, которые мы создаем. Таким образом, каждый отдельный слой может быть изолирован, протестирован, проверен, а вся функциональность может быть сымитирована, чтобы мы могли протестировать общую логику приложения с учетом известных результатов.

В конечном итоге это приводит к упрощению требований к тестированию, повышению уверенности в коде приложения, улучшению тестового покрытия, ускорению выполнения тестов и упрощению рассуждений о коде.

Оцените статью
devanswers.ru
Добавить комментарий