суббота, 11 июля 2020 г.

Совместимость ваших модулей

Ваши модули будут развиваться с течением времени по мере добавления новых функций, изменения поведения и пересмотра частей общедоступной поверхности модуля. Как обсуждалось в Go модули: v2 и далее, критические изменения в модуле v1+ должны произойти как часть основного изменения версии (или путем принятия нового пути к модулю).

Однако выпуск новой основной версии затруднен для ваших пользователей. Они должны найти новую версию, изучить новый API и изменить свой код. И некоторые пользователи могут никогда не обновлять, то есть вы должны поддерживать две версии для своего кода навсегда. Поэтому обычно лучше изменить существующий пакет совместимым способом.

В этом посте мы рассмотрим некоторые методы для внесения неразрывных изменений. Общая тема: добавить, не изменять или удалять. Мы также поговорим о том, как с самого начала разработать свой API для совместимости.

Добавление к функции

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

При добавлении новых аргументов с разумными значениями по умолчанию заманчиво добавить их в качестве параметра с переменными (variadic) параметрами. Расширить функцию

func Run(name string)

с дополнительным аргументом size, который по умолчанию равен нулю, можно предложить

func Run(name string, size ...int)

на том основании, что все существующие места вызовов будут продолжать работать. Хотя это и так, другие варианты использования Run могут прекратиться, например:

package mypkg
var runner func(string) = yourpkg.Run

Оригинальная функция Run работает здесь, потому что ее тип - func(string), а тип новой функции Run - func(string, ...int), поэтому во время компиляции назначение не выполняется.

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

Вместо изменения сигнатуры функции добавьте новую функцию. Например, после введения пакета context стало обычной практикой передавать context.Context в качестве первого аргумента функции. Однако стабильные API не могут изменить экспортированную функцию для принятия context.Context, потому что это нарушит все виды использования этой функции.

Вместо этого были добавлены новые функции. Например, сигнатура метода Query для пакета database/sql была (и остается)

func (db *DB) Query(query string, args ...interface{}) (*Rows, error)

Когда пакет контекста был создан, команда Go добавила новый метод в database/sql:

func (db *DB) QueryContext(ctx context.Context, query string, args ...interface{}) (*Rows, error)

Чтобы избежать копирования кода, старый метод вызывает новый:

func (db *DB) Query(query string, args ...interface{}) (*Rows, error) {
    return db.QueryContext(context.Background(), query, args...)
}

Добавление метода позволяет пользователям переходить на новый API в своем собственном темпе. Поскольку методы читаются одинаково и сортируются вместе, а Context - во имя нового метода, это расширение API database/sql не ухудшило читаемость или понимание пакета.

Если вы ожидаете, что функция может нуждаться в большем количестве аргументов в будущем, вы можете планировать заранее, сделав необязательные аргументы частью сигнатуры функции. Самый простой способ сделать это - добавить один аргумент структуры, как это делает функция crypto/tls.Dial:

func Dial(network, addr string, config *Config) (*Conn, error)

Подтверждение связи TLS (TLS handshake), проводимое Dial, требует сети и адреса, но имеет много других параметров с приемлемыми значениями по умолчанию. Передача nil для config использует эти значения по умолчанию; передача структуры Config с некоторыми установленными полями переопределит значения по умолчанию для этих полей. В будущем для добавления нового параметра конфигурации TLS требуется только новое поле в структуре Config - изменение, обратно совместимое (почти всегда).

Иногда техники добавления новой функции и добавления параметров можно объединить, сделав структуру параметров приемником метода. Рассмотрим эволюцию способности пакета net прослушивать сетевой адрес. До перехода к версии 1.11 пакет net предоставлял только функцию Listen с сигнатурой

func Listen(network, address string) (Listener, error)

Для Go 1.11 в сетевое прослушивание были добавлены две функции: передача контекста и разрешение вызывающей стороне предоставлять "функцию управления" для настройки необработанного соединения после создания, но до привязки. Результатом могла быть новая функция, которая принимала контекст, сеть, адрес и функцию управления. Вместо этого авторы пакета добавили структуру ListenConfig в ожидании, что когда-нибудь понадобятся дополнительные параметры. И вместо того, чтобы определять новую функцию верхнего уровня с громоздким именем, они добавили метод Listen к ListenConfig:

type ListenConfig struct {
    Control func(network, address string, c syscall.RawConn) error
}

func (*ListenConfig) Listen(ctx context.Context, network, address string) (Listener, error)

Другим способом предоставления новых параметров в будущем является шаблон "Типы параметров", в котором опции передаются как аргументы с переменным числом аргументов, а каждая опция - это функция, которая изменяет состояние создаваемого значения. Одним из широко используемых примеров является DialOption google.golang.org/grpc.

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

grpc.Dial("some-target",
  grpc.WithAuthority("some-authority"),
  grpc.WithMaxDelay(time.Second),
  grpc.WithBlock())

Это также может быть реализовано как структура параметров:

notgrpc.Dial("some-target", ¬grpc.Options{
  Authority: "some-authority",
  MaxDelay:  time.Minute,
  Block:     true,
})

Функциональные опции имеют свои недостатки: они требуют написания имени пакета перед опцией для каждого вызова; они увеличивают размер пространства имен пакета; и неясно, каким должно быть поведение, если один и тот же параметр предоставляется дважды. С другой стороны, функциям, которые принимают структуру параметров, требуется параметр, который почти всегда может быть равен нулю, что некоторые считают непривлекательным. И когда нулевое значение типа имеет допустимое значение, неуклюже указывать, что у опции должно быть значение по умолчанию, обычно для этого требуется указатель или дополнительное логическое поле.

Любой из вышеперечисленных способов является разумным выбором для обеспечения в будущем расширяемости общедоступного API вашего модуля.

Работа с интерфейсами

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

Основная идея заключается в том, чтобы определить новый интерфейс с новым методом, а затем, где бы ни использовался старый интерфейс, динамически проверять, является ли предоставленный тип более старым или более новым типом.

Давайте проиллюстрируем это на примере из пакета archive/tar. tar.NewReader принимает io.Reader, но со временем команда Go поняла, что было бы более эффективно переходить от одного заголовка файла к другому, если бы вы могли вызвать Seek. Но они не смогли добавить метод Seek в io.Reader: это сломало бы все реализации io.Reader.

Другим исключенным вариантом было изменение tar.NewReader для принятия io.ReadSeeker, а не io.Reader, поскольку он поддерживает как методы io.Reader, так и Seek (посредством io.Seeker). Но, как мы видели выше, изменение сигнатуры функции также является критическим изменением.

Поэтому они решили оставить сигнатуру tar.NewReader неизменной, но проверяют тип для (и поддерживают) io.Seeker в методах tar.Reader:

package tar

type Reader struct {
  r io.Reader
}

func NewReader(r io.Reader) *Reader {
  return &Reader{r: r}
}

func (r *Reader) Read(b []byte) (int, error) {
  if rs, ok := r.r.(io.Seeker); ok {
    // Используем более эффективный rs.Seek.
  }
  // Используем менее эффективный r.r.Read.
}

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

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

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

Совет: если вам нужно использовать интерфейс, но пользователи не собираются его реализовывать, вы можете добавить неэкспортированный метод. Это не позволяет типам, определенным вне вашего пакета, удовлетворять ваш интерфейс без встраивания, позволяя вам добавлять новые методы позже, не нарушая пользовательских реализаций. Например, функция private() в testing.TB.

type TB interface {
    Error(args ...interface{})
    Errorf(format string, args ...interface{})
    // ...

    // private метод для предотвращения 
    // реализации интерфейса пользователями 
    // и поэтому будущие дополнения к нему 
    // не будут нарушать совместимость с Go 1.
    private()
}

Добавить методы конфигурации

До сих пор мы говорили о явных нарушениях, когда изменение типа или функции может привести к тому, что пользовательский код перестанет компилироваться. Однако изменения поведения также могут нарушить работу пользователей, даже если код пользователя продолжает компилироваться. Например, многие пользователи ожидают, что json.Decoder будет игнорировать поля в JSON, которые не входят в структуру аргумента. Когда команда Go хотела вернуть ошибку в этом случае, они должны были быть осторожными. Выполнение этого без механизма согласия означало бы, что многие пользователи, использующие эти методы, могут начать получать ошибки там, где их раньше не было.

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

Поддержание совместимости структур

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

Напомним, что авторы пакета net добавили ListenConfig в Go 1.11, потому что они думали, что могут появиться дополнительные опции. Оказывается, они были правы. В Go 1.13 было добавлено поле KeepAlive, чтобы отключить поддержку активности или изменить его период. Значение по умолчанию, равное нулю, сохраняет исходное поведение включения поддержки с периодом по умолчанию.

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

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

Во-первых, чтобы предотвратить сравнение, убедитесь, что структура имеет несопоставимое поле. У него уже может быть один - ни один срез, ни карта, ни тип функции не сопоставимы - но если нет, его можно добавить следующим образом:

type Point struct {
        _ [0]func()
        X int
        Y int
}

Тип func() несопоставим, и массив нулевой длины не занимает места. Мы можем определить тип, чтобы прояснить наше намерение:

type doNotCompare [0]func()

type Point struct {
        doNotCompare
        X int
        Y int
}

Вы должны использовать doNotCompare в своих структурах? Если вы определили структуру, которая будет использоваться в качестве указателя, то есть у нее есть методы указателя и, возможно, функция конструктора NewXXX, которая возвращает указатель, то добавление поля doNotCompare, вероятно, является излишним. Пользователи типа указателя понимают, что каждое значение типа отличается: что если они хотят сравнить два значения, они должны сравнить указатели.

Если вы определяете структуру, предназначенную для непосредственного использования в качестве значения, как в нашем примере Point, то довольно часто вы хотите, чтобы она была сопоставимой. В редком случае, когда у вас есть структура значений, которую вы не хотите сравнивать, добавление поля doNotCompare даст вам свободу изменять структуру позже, не беспокоясь о том, чтобы нарушить сравнение. С другой стороны, тип не будет использоваться в качестве ключа карты.

Заключение

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

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


Читайте также:


Комментариев нет:

Отправить комментарий