The Go Blog

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

Джин Бархузен и Джонатан Амстердам
7 июля 2020

Введение

Эта запись является пятой в серии.

Примечание: Для документации по разработке модулей см. Разработка и публикация модулей.

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

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

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

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

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

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

<code>func Run(name string)
</code>

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

<code>func Run(name string, size ...int)
</code>

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

<code>package mypkg
var runner func(string) = yourpkg.Run
</code>

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

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

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

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

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

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

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

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

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

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

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

<code>func Dial(network, addr string, config *Config) (*Conn, error)
</code>

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

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

<code>func Listen(network, address string) (Listener, error)
</code>

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

<code>type ListenConfig struct {
  Control func(network, address string, c syscall.RawConn) error
}
func (*ListenConfig) Listen(ctx context.Context, network, address string) (Listener, error)
</code>

Еще один способ предоставления новых опций в будущем — это паттерн «Типы опций», при котором опции передаются как вариадические аргументы, а каждая опция представляет собой функцию, изменяющую состояние создаваемого значения. Описание этого паттерна можно найти в статье Роба Пайка Self-referential functions and the design of options. Одним из широко используемых примеров является google.golang.org/grpc и его тип DialOption.

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

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

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

<code>notgrpc.Dial("some-target", &notgrpc.Options{
  Authority: "some-authority",
  MaxDelay:  time.Second,
  Block:     true,
})
</code>

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

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

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

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

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

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

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

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

<code>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.
}
</code>

(См. reader.go для реального кода.)

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

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

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

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

<code>// TB — это интерфейс, общий для T и B.
type TB interface {
  Error(args ...interface{})
  Errorf(format string, args ...interface{})
  // ...
  // Приватный метод, чтобы предотвратить реализацию интерфейса
  // пользователями, и тем самым избежать нарушения совместимости Go 1
  // при будущих изменениях.
  private()
}
</code>

Эта тема также подробно рассматривается в выступлении Джонатана Амстердама «Detecting Incompatible API Changes» (видео, слайды).

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

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

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

Обеспечение совместимости структур

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

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

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

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

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

<code>type Point struct {
  _ [0]func()
  X int
  Y int
}
</code>

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

<code>type doNotCompare [0]func()
type Point struct {
  doNotCompare
  X int
  Y int
}
</code>

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

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

Заключение

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

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

Следующая статья: Выпущена Go 1.15
Предыдущая статья: Следующий шаг для дженериков
Индекс блога

GoRu.dev Golang на русском

На сайте представлена адаптированная под русский язык документация языка программирования Golang