В статье показано, как в Go реализовать обработку ошибок и логирование по принципу "Сделал и забыл". Способ расчитан на микросервисы на Go, работающие в Docker-контейнере и построенные с соблюдением принципов Clean Architecture. Эта статья является развёрнутой версией доклада с недавно прошедшего митапа по Go в Казани. Если вас интересует язык Go и вы живёте в Казани, Иннополисе, прекрасной Йошкар-Оле или в другом городе неподалёку, вам стоит посетить страницу сообщества: golangkazan.github.io. На митапе наша команда в двух докладах показала, как мы разрабатываем микросервисы на Go — какие принципы соблюдаем и как упрощаем себе жизнь. Эта статья посвящена нашей концепции обработки ошибок, которую мы теперь распространяем на все наши новые микросервисы. Прежде чем коснуться правил обработки ошибок, стоит решить, какие ограничения мы соблюдаем при проектировании и кодировании. Для этого стоит рассказать, как выглядят наши микросервисы. Прежде всего, мы соблюдаем чистую архитектуру. Код разделяем на три уровня и соблюдаем правило зависимостей: пакеты на более глубоком уровне не зависят от внешних пакетов и нет циклических зависимостей. К счастью, в Go прямые циклические зависимости пакетов запрещены. Косвенные зависимости через заимствование терминологии, предположения о поведении или приведение к типу всё ещё могут появиться, их следует избегать. Так выглядят наши уровни: Так выглядит дерево каталогов микросервиса (та часть, где лежит код на Go):  Для каждого из контекстов (модулей) приложения структура пакетов выглядит так: Всё это показано на диаграмме:  Тут всё просто: Мы не будем заострять внимание на паниках: просто не забывайте обрабатывать панику в каждой горутине и при обработке каждого запроса, каждого сообщения, каждой запущенной запросом асинхронной задачи. Почти всегда панику можно превратить в ошибку, чтобы не дать завершить всё приложение. На уровне бизнес-логики обрабатываются только ожидаемые ошибки, определённые бизнес-правилами. Определить такие ошибки вам помогут Sentinel Errors — мы используем именно эту идиому вместо написания собственных типов данных для ошибок. Пример: Здесь объявляется глобальная переменная, которую по нашему джентельменскому соглашению мы нигде не должны изменять. Если вам не нравятся глобальные переменные и вы используете линтер для их обнаружения, то вы можете обойтись одними константами, как предлагает Dave Cheney в посте Constant errors: Главное преимущество Sentinel Errors — возможность легко выполнять композицию ошибок. В частности, при создании ошибки или при получении ошибки извне хорошо бы добавлять к ней stacktrace. Для таких целей есть два популярных решения Наша команда пока ещё использует github.com/pkg/errors и функции errors.WithStack (когда нам нечего добавить, кроме stacktrace) либо errors.Wrap (когда нам есть что сказать об этой ошибке). Обе функции принимают на вход ошибку и возвращают новую ошибку, но уже со stacktrace. Пример из инфраструктурного слоя: Мы рекомендуем каждую ошибку оборачивать только один раз. Это легко сделать, если следовать правилам: Все ошибки ожидаемо делятся на ожидаемые и неожиданные. Чтобы обработать ожидаемую ошибку, вам нужно избавиться от последствий композиции. В пакетах xerrors и github.com/pkg/errors есть всё необходимое: в частности, в пакете errors есть функция errors.Cause, которая возвращает первопричину ошибки. Эта функция в цикле одну за другой извлекает более ранние ошибки, пока очередная извлечённая ошибка имеет метод Cause() error. Пример, к котором мы извлекаем первопричину и прямо сравниваем её с sentinel error: Возможно, вы используете linter, который заставляет вас маниакально проверять все ошибки. В этом случае вас наверняка бесит, когда linter просит проверять ошибки методах .Close() и других методах, которые вы вызываете только в defer. Вы когда нибудь пробовали корректно обработать ошибку в defer, особенно если до этого была ещё одна ошибка? А мы — пробовали и спешим поделиться рецептом. Представим, что у нас вся работа с БД происходит строго через транзакции. Согласно правилу зависимостей, уровни app и domain не должны прямо или косвенно зависеть от infrastructure и технологии SQL. Это означает, что на уровнях app и domain нет слова "транзакция". Самое простое решение — заменить слово "транзакция" на что-то абстрактное; так рождается паттерн Unit of Work. В нашей реализации сервис в пакете app получает фабрику по интерфейсу UnitOfWorkFactory, и при выполнении каждой операции создаёт объект UnitOfWork, скрывающий за собой транзакцию. Объект UnitOfWork позволяет получить Repository. Чтоб лучше понять использование Unit of Work, взгляните на диаграмму:  Не является ли чрезмерным создание транзакции на каждую операцию, даже изначально атомарную? Вам решать; мы считаем, что сохранение независимости бизнес-логики важнее, чем экономия на создании транзакции. Можно ли объединить UnitOfWork и Repository? Можно, но мы считаем, что это нарушит принцип Single Responsibility. Так выглядт интерфейс: Интефейс UnitOfWork предоставляет метод Complete, принимающий один in-out параметр: указатель на интерфейс error. Да, именно указатель, и именно in-out параметр — в любых других вариантах код на вызывающей стороне окажется гораздо более сложным. Пример операции с unitOfWork: Так реализуется завершение транзакции UnitOfWork: Функция mergeErrors выполняет слияние двух ошибок, но без проблем обработает nil вместо одной или обоих ошибок. При этом мы считаем, что обе ошибки случились при выполнении одной операции на разных этапах, и первая ошибка является более важной — поэтому, когда обе ошибки не nil, мы сохраняем первую, а от второй ошибки сохраняем только сообщение: Статья Чек-лист: что нужно было делать до того, как запускать микросервисы в prod советует: Мы предпочитаем к сообщениям об ошибке добавлять ещё два поля: "error" и "stacktrace". Для языка Golang есть много качественных библиотек логирования, например, sirupsen/logrus, которую мы используем. Но мы не применяем библиотеку напрямую. В первую очередь, мы в своём пакете log сокращаем череcчур обширный интерфейс библиотеки до одного интерфейса Logger: Если программист хочет писать логи, он должен получать извне интерфейс Logger, причём делать это следует на уровне инфраструктуры, а не app или domain. Интерфейс логгера лаконичен: Такая строгость позволяет направить программистов в правильное русло: если кто-то хочет внести улучшение в саму систему ведения логов, он должен сделать это с учётом всей инфраструктуры их сбора и обработки, которая в микросервисе только начинается (а заканчивается обычно где-нибудь в Kibana и Zabbix). Впрочем, в пакете log есть ещё один интерфейс, который позволяет прервать работу программы при фатальной ошибке и потому может использоваться только в пакете main: Реализует интерфейс Logger наш пакет jsonlog, выполняющий настройку библиотеки logrus и абстрагирующий работу с ней. Схематично выглядит так:  Собственный пакет позволяет связать потребности микросервиса (выраженные интерфейсом log.Logger), возможности библиотеки logrus и особенности вашей инфраструктуры сборка логов. Например, мы используем ELK (Elastic Search, Logstash, Kibana), и поэтому в пакете jsonlog мы: Микросервис инициализирует логгер в функции main: Мы переходим на GRPC в своих микросервисах на Go. Но даже если вы используете HTTP API, общие принципы вам подойдут. Прежде всего, обработка ошибок и запись логов должны происходить на уровне infrastructure в пакете, отвечающем за транспорт, потому что именно он сочетает в себе знание правил транспортного протокола и знание методов интерфейса app.Service. Напомним, как выглядит взаимосвязь пакетов:  Обрабатывать ошибки и вести логи удобно с помощью паттерна Middleware (Middleware — это название паттерна Decorator в мире Golang и Node.js): Куда следует добавлять Middleware? Сколько их должно быть? Есть разные варианты добавления Middleware, выбирать вам: Схематично выглядит так:  Вы можете создать разные Middleware для обработки ошибок (и panic) и для логирования. Можете скрестить всё в один. Мы рассмотрим пример, в котором всё скрещивается в один Middleware, который создаётся так: Мы получаем на вход интерфейс api.BackendService и декорируем его, возвращая на выходе свою реализацию интерфейса api.BackendService. Произвольный метод API в Middleware реализуется так: Здесь мы выполняем три задачи: Трансляцию ошибок обсудим позже. А запись лога выполняет метод logCall, который просто вызывает правильный метод интерейса Logger: Мы должны получить первопричину ошибки и превратить её в ошибку, понятную на транспортном уровне и задокументированную в API вашего сервиса. В GRPC это просто — используйте функцию status.Errorf для создания ошибки с кодом статуса. Если у вас HTTP API (REST API), вы можете создать свой тип ошибки, о котором не должны знать уровни app и domain В первом приближении трансляция ошибки выглядит так: Декорируемый интерфейс при валидации входных аргументов может вернуть ошибку типа status.Status с кодом статуса, и первая версия translateError этот код статуса потеряет. Смастерим улучшенную версию с помощью приведения к типу интерфейса (да здравствует утиная типизация!): Функция translateError создаётся индивидуально для каждого контекста (независимого модуля) в вашем микросервисе и транслирует ошибки бизнес-логики в ошибки транспортного уровня. Мы предлагаем вам несколько правил обработки ошибок и работы с логами. Следовать им или нет, решать вам. Здесь мы ничего не говорили о технологиях трассировки запросов (например, OpenTracing), мониторинга метрик (например, производительности запросов к БД) и других вещах, подобных логированию. Вы и сами с этим разберётесь, мы в вас верим .