Как интерфейсы Go могут облегчить переключение внешних сервисов

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

Зачем вообще менять почтовые сервисы

Примерно в октябре 2017 года мы начали проект в Manifold под названием dogfooding. Для тех, кто не знаком с этим термином, собачье кормление - также известное как съесть свой собственный корм для собак - это когда компания начинает использовать свой собственный продукт или услугу. Основная идея заключается в том, что если у вас есть хороший продукт и вы являетесь частью демографической группы своего продукта, вам, вероятно, в первую очередь следует использовать свой собственный продукт!

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

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

Краткое описание интерфейсов Go

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

type Floater interface {
  Float()
}

Это означает, что любой тип, реализующий метод Float без аргументов и без возврата, считается «Floater». Теперь мы определяем два других типа, реализующих тот же метод:

type Duck struct {}
func (Duck) Float() {}
type Witch struct {}
func (Witch) Float() {}

В любом месте нашего кода, где требуется Floater, вместо него можно использовать как Duck, так и Witch. Обратите внимание, что мы не указываем явно, что эти типы реализуют интерфейс Floater, но компилятор будет обеспечивать это, если мы попытаемся использовать другой тип, у которого нет такой же сигнатуры метода.

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

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

package other
type Wood struct {}
func (Wood) Float() {}

Wood также можно использовать как Floater, даже если пакет other не знает о вашем интерфейсе.

Замена старого API

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

Основная причина заключается в том, чтобы отделить нашу систему от единой реализации сторонней службы, то есть: система должна знать, как отправлять электронные письма, но ее не должно волновать, используем ли мы SendGrid, Mailgun или Amazon SES. Это упрощает переключение между реализациями в будущем, а также подделку реализации для тестов, когда нам не нужно отправлять настоящие электронные письма.

Библиотека SendGrid Go:

type Client struct { … }
func (*Client) Send(email *mail.SGMailV3) (*rest.Response, error)

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

Наш новый интерфейс можно определить как:

type Mailer interface {
  Mail(Email) error
}

Поскольку нас не интересует ответ, мы можем удалить его из интерфейса только в случае ошибки. Везде, где мы использовали SendGrid * Client, теперь мы можем заменить его интерфейсом Mailer.

Например, если бы у нас было:

Мы можем заменить на:

Конечно, это нарушит наш код, потому что клиент SendGrid еще не реализует интерфейс почтовой программы.

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

Наш собственный тип SendGridClient реализует интерфейс Mailer, и код снова компилируется. Мы можем использовать тот же подход для Mailgun:

И Mailgun, и SendGrid реализуют интерфейс Mailer и могут использоваться как взаимозаменяемые!

Использование резервной реализации

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

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

Как интерфейсы облегчают тестирование

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

Предположим, мы пишем тесты для функции UserActivation, которую мы показали ранее:

func UserActivation(m Mailer, e Email) error {
  return m.Mail(e)
}

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

Во время тестов мы можем утверждать, что сообщение, которое мы планируем отправить, было добавлено в очередь TestingMailer или была возвращена ошибка.

Заключение

Я надеюсь, что этот пост продемонстрировал, насколько полезны интерфейсы в Go, как их использовать для отделения API от его реализации и как имитировать интерфейс во время тестов.

Как правило, стремитесь к маленьким интерфейсам: если ваш интерфейс имеет слишком много методов, вероятно, из него можно было бы извлечь меньшие интерфейсы. Интерфейс также может состоять из нескольких интерфейсов, io package Go является отличным примером этого, а тип может реализовывать несколько интерфейсов одновременно.