Раз в месяц в CompoZed нам посчастливилось проводить дни обучения в масштабе всей лаборатории. В нашем расписании обучения чередуются беседы с коллегами, командные мероприятия и индивидуальные дни исследований / обучения. Сегодняшний день был для индивидуального обучения, поэтому я решил изучить проблему, с которой сталкивается моя команда.

Фон

Когда наш продукт только зарождался, мы решили создать пакет базы данных, который обрабатывал бы все наши запросы. Наша схема в то время была небольшой, и это облегчало нам единообразное подключение других наших пакетов к базе данных. Чтобы имитировать вызовы нашей базы данных при модульном тестировании других наших пакетов, мы создали интерфейс для нашей базы данных, чтобы нашу фактическую реализацию можно было легко заменить фиктивной реализацией. Результат выглядел примерно так:

// database package
type DBInterface interface {
    CreateItem(Item) (*Item, error)
} 
type DB struct {
    *sql.DB
} 
func (db *DB) CreateItem(item Item) (*Item, error) {
    query := `INSERT INTO Table_Items(Name) VALUES(@itemName)`
    db.Query(query, sql.Named("itemName", item.Name))
    
    return &item, nil
} 
type MockDB struct {} 
func (db *MockDB) CreateItem(item Item) (*Item, error) {
    i := Item{Name: "Foo"}
    return &i, nil
}  
// shopping cart package - import database package
type Cart struct {
    DB db.DBInterface
} 
// Live implementation
db := db.DB{*sql.DB}
cartService := Cart{DB: &db} 
// Mock out db when testing the cart service
db := db.MockDB{}
cartService := Cart{DB: &mockDB}

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

Наша проблема

Шаблон интерфейса оказывается наиболее эффективным и читаемым, когда интерфейсы остаются относительно небольшими. По мере роста нашего приложения интерфейс нашей базы данных рос вместе с ним (примерно 65 методов). Функционально все еще работало нормально. То, что превратилось в кошмар, - это поддержание моков и рассуждение с помощью модульных тестов, в которых использовались эти моки. У нас было две отдельные фиктивные реализации нашего интерфейса, которые использовались для различных тестовых случаев, что означало, что нам нужно было поддерживать 130 фиктивных функций. Это также стало ограничением для случаев, когда мы могли бы извлечь выгоду из создания третьей или четвертой фиктивной реализации. Если бы у нас была одна функция, для которой требовалось три разных ложных приемника, нам пришлось бы создать третью копию из 65 функций, чтобы полностью удовлетворить наш огромный интерфейс. Таким образом, у нас не только были трудности с рассуждением с помощью кода, который мы уже написали, но мы также ограничивали себя, когда дело доходило до написания хороших тестов в будущем. Не идеально.

Возможное решение: дополнительная упаковка

Один из способов обойти эту проблему - разбить нашу базу данных на подпакеты по сущностям. Это позволит нам сделать следующее преобразование:

До:

// package db
type MyDB interface {
    CreateThingOne(ThingOne) (ThingOne, error)
    FindThingOne(string) (ThingOne, error)
    UpdateThingOne(ThingOne) (ThingOne, error)
    DeleteThingOne(string) error
    CreateThingTwo(ThingTwo) (ThingTwo, error)
    FindThingTwo(string) (ThingTwo, error)
    UpdateThingTwo(ThingTwo) (ThingTwo, error)
    DeleteThingTwo(string) error
}
// package foo
type FooService struct {
    DB db.MyDB
}

После:

// package db/thing_one_db
type ThingOneDB interface {
    CreateThingOne(ThingOne) (ThingOne, error)
    FindThingOne(string) (ThingOne, error)
    UpdateThingOne(ThingOne) (ThingOne, error)
    DeleteThingOne(string) error
}
// package db/thing_two_db
type ThingTwoDB interface {
    CreateThingTwo(ThingTwo) (ThingTwo, error)
    FindThingTwo(string) (ThingTwo, error)
    UpdateThingTwo(ThingTwo) (ThingTwo, error)
    DeleteThingTwo(string) error
}
// package foo
type FooService struct {
    ThingOneDB thing_one_db.ThingOneDB
    ThingTwoDB thing_two_db.ThingTwoDB
}

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

Улучшенное решение: встраивание

Это подводит нас к сегодняшнему дню обучения. Я начал свое утро с того места, где остановился, в серии видео, которые я смотрел: Ultimate Go Programming. Я уже был в середине раздела Методы, интерфейсы и встроенные типы (для видео требуется учетная запись Safari Books, но связанное сообщение в блоге отличное, и его можно найти здесь), который в итоге оказался идеальным для решения проблемы. моя команда и я столкнулись в то время. Подраздел о встроенных типах, в частности, щелкнул переключателем на метафорическую лампочку в моей голове.

Короче говоря о встроенных типах, вы можете встроить внутренний тип в другой внешний тип, объявив его как безымянный атрибут в определении внешнего типа следующим образом:

type Car struct {}
type Truck struct {}
type Vehicle struct {
    Car
    Truck
}

Прелесть здесь в том, что это позволяет нам ссылаться на методы, определенные во внутреннем типе, из внешнего типа. Другими словами, методы, определенные для типов Car и Truck, получают повышенный для типа Vehicle, так что набор методов, доступных для экземпляра Vehicle, включает исходные методы, определенные для Vehicle, а также все методы. определено как для автомобилей, так и для грузовиков.

Это здорово, потому что тип интерфейса Go может быть абсолютно встроенным. Применение этой концепции к нашей проблеме выглядит примерно так:

До:

type MyDBInterface interface {
    CreateThingOne(ThingOne) (ThingOne, error)
    FindThingOne(string) (ThingOne, error)
    UpdateThingOne(ThingOne) (ThingOne, error)
    DeleteThingOne(string) error
    CreateThingTwo(ThingTwo) (ThingTwo, error)
    FindThingTwo(string) (ThingTwo, error)
    UpdateThingTwo(ThingTwo) (ThingTwo, error)
    DeleteThingTwo(string) error
}

После:

type MyDB struct {
    ThingOneDB
    ThingTwoDB
}
type ThingOneDB interface {
    CreateThingOne(ThingOne) (ThingOne, error)
    FindThingOne(string) (ThingOne, error)
    UpdateThingOne(ThingOne) (ThingOne, error)
    DeleteThingOne(string) error
}
type ThingTwoDB interface {
    CreateThingTwo(ThingTwo) (ThingTwo, error)
    FindThingTwo(string) (ThingTwo, error)
    UpdateThingTwo(ThingTwo) (ThingTwo, error)
    DeleteThingTwo(string) error
}

Я загорелся. Это очень много для нас делает. Как и в случае с субпакетным решением, мы можем разбить наш гигантский интерфейс на набор более мелких интерфейсов, что значительно повысит нашу способность писать надежные макеты. Но это также облегчает бремя, связанное с импортом дополнительных пакетов. Мы можем продолжать поддерживать наш пакет базы данных в действии, и с точки зрения импортированных атрибутов нашим службам не нужно будет подключать какие-либо дополнительные атрибуты базы данных.

До:

type FooService struct {
    DB db.MyDBInterface
}

После:

type FooService struct {
    DB db.MyDB
}

Разница здесь в том, что теперь мы можем импортировать тип структуры базы данных, который состоит из встроенных интерфейсов, вместо импорта одного гигантского интерфейса.

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

// package db
type MyDB struct {
    ThingOneDB
    ThingTwoDB
}
type ThingOneDB interface {
    CreateThingOne(ThingOne) (ThingOne, error)
    FindThingOne(string) (ThingOne, error)
    UpdateThingOne(ThingOne) (ThingOne, error)
    DeleteThingOne(string) error
}
type ThingTwoDB interface {
    CreateThingTwo(ThingTwo) (ThingTwo, error)
    FindThingTwo(string) (ThingTwo, error)
    UpdateThingTwo(ThingTwo) (ThingTwo, error)
    DeleteThingTwo(string) error
}
// package foo
type FooService struct {
    DB db.MyDB
}
// package main
func main() {
    database := db.MyDB{ThingOneDB: db.ThingOneDBImpl{}}
    fooService := FooService{
                      DB: database,
                  }
}

В main мы создали экземпляр типа FooService, который требует подключения к базе данных. Мы определили, что FooService не заботится о методах, объявленных в ThingTwoDB, и поэтому может не учитывать их. По мере роста количества встроенных интерфейсов это станет большим преимуществом с точки зрения удобочитаемости. Это дает нам возможность импортировать только то, что нам нужно, в каждую службу, продолжая при этом увеличивать нашу базу данных в одном пакете.

В заключение, используя интерфейсы и встроенные типы вместе, мы смогли повысить читаемость нашего кода и сделать наш набор тестов более масштабируемым, не вызывая серьезных сбоев в восходящем потоке. Это лишь один из множества интересных паттернов, которые Go позволяет нам реализовать. Для дальнейшего обсуждения вы можете найти меня в Twitter (Charles Kaminer) или Gophers Slack (@charlie).