Хабрахабр

[Перевод] Методика разработки высоконадёжных серверов на Go

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

Какой уровень надёжности нужен вашему проекту?

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

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

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

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

Возможно, решение — это TDD?

Разработку через тестирование (Test Driven Development, TDD), часто считают лучшим лекарством от сбойного кода. TDD — это пуристическая методология разработки, при применении которой сначала пишут тесты, а уже потом — код, который добавляется в проект лишь тогда, когда тесты, которые его проверяют, перестают выдавать ошибки. Этот процесс гарантирует 100% покрытие кода тестами и часто даёт иллюзию того, что код протестирован во всех возможных вариантах его использования.

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

Тесты — ключ к надёжности

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

Наша уверенность в качестве кода никоим образом не должна основываться на его ручных проверках. Так как мы собираемся запускать наши тесты очень часто, в идеале — после добавления в код каждой новой строки, необходимо, чтобы тесты были бы автоматизированными. Внимание человека к деталям ослабевает после того, как он выполнит одну и ту же однообразную задачу много раз подряд. Всё дело в том, что людям свойственно ошибаться.

Очень быстрыми. Тесты должны быть быстрыми.

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

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

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

Цель достижения 100% покрытия кода тестами может показаться чрезмерной, но, если об этом поразмыслить, окажется, что при неполном покрытии кода тестами некоторая часть кода отправляется в продакшн непроверенной, ранее никогда не выполнявшейся. Полное покрытие кодовой базы тестами — это обманчивая, но важная метрика.

С уверенностью можно говорить лишь о том, что если проект покрыт тестами не на 100%, у разработчика не может быть уверенности в абсолютной надёжности кода, так как некоторые части кода никогда не тестируются. Полное покрытие кода тестами не обязательно означает, что в проекте имеется достаточно тестов, и не означает, что это — тесты, которые предусматривают абсолютно все варианты использования кода.

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

Почему Go — это отличный выбор для высоконадёжных проектов?

Go является языком со статической типизацией. Типы — это контракт между различными фрагментами кода, выполняющимися совместно. Без автоматической проверки типов в процессе сборки проекта, если нужно придерживаться строгих правил покрытия кода тестами, нам пришлось бы реализовывать тесты, проверяющие эти «контракты» самостоятельно. Так, например, происходит в серверных и клиентских проектах, основанных на JavaScript. Написание сложных тестов, направленных лишь на то, чтобы проверить типы, означает массу дополнительной работы, которой, в случае с Go, можно избежать.

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

Читабельный код легче проанализировать и подвергнуть аудиту. Go лаконичен, но выразителен. Если же код слишком лаконичен, программы на нём могут оказаться сложными для чтения и понимания. Если код слишком многословен, его основная цель может утонуть в «шуме» вспомогательных конструкций. Например, в нём не так много вспомогательных конструкций, как в таких языках, как Java или C++. Go поддерживает баланс между лаконичностью и выразительностью. При этом конструкции Go, относящиеся, например, к таким областям, как обработка ошибок, являются весьма ясными и достаточно подробными, что упрощает работу программиста, помогая ему убедиться, например, в том, что он проверил всё, что можно.

Хорошо отлаженные механизмы обработки ошибок времени выполнения — это краеугольный камень высоконадёжного кода. В Go имеются чёткие механизмы обработки ошибок и восстановления работоспособности программ после сбоев. В средах вроде Node.js смешение таких подходов к управлению потоком выполнения программы, как коллбэки, промисы и асинхронные функции, часто ведёт к появлению необработанных ошибок, вроде необработанного отклонения промиса. В Go имеются строгие правила, определяющие возврат и распространение ошибок. Восстановление работы программы после подобных событий практически невозможно.

Зависимости — это риск, особенно, когда их источником являются проекты, в которых недостаточно внимания уделяется надёжности кода. В Go имеется обширная стандартная библиотека. При этом, если что-то пойдёт не так, отвечать за это будет разработчик готового приложения, а не тот, кто создал одну из используемых им библиотек. Серверное приложение, которое уходит в продакшн, содержит в себе и все зависимости. Как результат, в средах, проекты, написанные для которых, переполнены мелкими зависимостями, сложнее создавать надёжные приложения.

Обширная стандартная библиотека Go поддерживается её разработчиками в очень хорошем состоянии, её существование снижает потребность во внешних зависимостях. Зависимости — это ещё и риск с точки зрения безопасности, так как уровень уязвимости проекта соответствует уровню уязвимости его самой небезопасной зависимости.

Основная привлекательная черта окружений вроде Node.js — это чрезвычайно короткий цикл разработки. Высокая скорость разработки. Написание кода занимает меньше времени, в результате программист становится более продуктивным.

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

Практический эксперимент

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

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

▍Разбиение сложного проекта на части, которыми удобно управлять

Сложность — это худший враг надёжности. Один из лучших подходов при работе со сложными системами заключается в применении давно известного принципа «разделяй и властвуй». Задачу нужно разбить на маленькие подзадачи и решать каждую из них по отдельности. С какой стороны подойти к разбиению нашей задачи? Мы будем следовать принципу разделения ответственности. У каждой части нашего проекта должна быть собственная сфера ответственности.

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

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

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

Наш сервер, пожалуй, слишком прост для того, чтобы делить его на части, но, в учебных целях, мы его, всё же, его разделим. Итак, какие сервисы нам нужны? Один из сервисов может работать с HTTP-интерфейсом для клиентов. Нам нужно отвечать на HTTP-запросы клиентов, направленные на проверку балансов и выполнение транзакций. Ещё один сервис будет владеть информацией о состоянии системы — журналом балансов. Назовём его PublicApi. Третий сервис будет соединять два вышеописанных и реализовывать логику «контрактов», направленную на изменение балансов. Назовём его StateStorage. Назовём его VirtualMachine. Задачей третьего сервиса будет выполнение контрактов.

Архитектура серверной части приложения

Разместим код этих сервисов в папках проекта /services/publicapi, /services/virtualmachine и /services/statestorage.

▍Чёткое определение границ ответственности сервисов

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

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

Это — простой протокол описания сообщений и конечных точек сервисов, разработанный Google и не зависящий от языка. Один из вариантов описания интерфейсов — воспользоваться protobuf.

Состояние приложения мы представим в виде структуры вида ключ-значение. Начнём с интерфейса для сервиса StateStorage. Вот код файла statestorage.proto:

syntax = "proto3";
package statestorage; service StateStorage { rpc WriteKey (WriteKeyInput) returns (WriteKeyOutput); rpc ReadKey (ReadKeyInput) returns (ReadKeyOutput);
} message WriteKeyInput { string key = 1; int32 value = 2;
} message WriteKeyOutput {
} message ReadKeyInput { string key = 1;
} message ReadKeyOutput { int32 value = 1;
}

Хотя с сервисом PublicApi клиенты работают по HTTP, ему тоже не помешает чёткий интерфейс, описанный теми же средствами, что и выше (файл publicapi.proto):

syntax = "proto3";
package publicapi; import "protocol/transactions.proto"; service PublicApi { rpc Transfer (TransferInput) returns (TransferOutput); rpc GetBalance (GetBalanceInput) returns (GetBalanceOutput);
} message TransferInput { protocol.Transaction transaction = 1;
} message TransferOutput { string success = 1; int32 result = 2;
} message GetBalanceInput { protocol.Address from = 1;
} message GetBalanceOutput { string success = 1; int32 result = 2;
}

Теперь нам понадобится описать структуры данных Transaction и Address (файл transactions.proto):

syntax = "proto3";
package protocol; message Address { string username = 1;
} message Transaction { Address from = 1; Address to = 2; int32 amount = 3;
}

В проекте proto-описания для сервисов помещены в папке /types/services, а описания структур данных общего назначения — в папке /types/protocol.

После того, как описания интерфейсов будут готовы, их можно скомпилировать в код на Go.

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

Это возможно благодаря Square Engineering и их разработке goprotowrap. Полные определения, сгенерированные Go-файлы и инструкции компиляции можно найти здесь.

Когда мы будем готовы к тому, чтобы разнести сервисы по разным серверам, мы можем добавить в систему транспортный уровень вроде gRPC. Обратите внимание на то, что в нашем проекте не реализован транспортный уровень RPC и обмен данными между сервисами выглядит как обычные вызовы библиотек.

▍Типы тестов, используемых в проекте

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

Модульные тесты

Модульные тесты — это основа пирамиды тестирования. Мы будем тестировать каждый модуль в изоляции. Что такое модуль? В Go мы можем воспринимать модули в виде отдельных файлов в пакете. Например, если у нас имеется файл /services/publicapi/handlers.go, то модульный тест для него мы разместим в том же пакете по адресу /services/publicapi/handlers_test.go.

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

Сервисные тесты

Следующий тип тестов известен под различными названиями. Это — так называемые сервисные, интеграционные или компонентные тесты. Их сущность заключается в том, чтобы взять несколько модулей и протестировать их совместную работу. Эти тесты находятся на уровень выше модульных в пирамиде тестирования. В нашем случае мы будем применять интеграционные тесты для проверки всего сервиса. Эти тесты определяют спецификации для сервиса. Например, тесты для сервиса StateStorage будут помещены в папку /services/statestorage/spec.

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

Сквозные тесты

Эти тесты находятся на вершине пирамиды тестирования, с их помощью выполняется проверка всей системы и всех её сервисов. Такие тесты описывают сквозную (end-to-end, e2e) спецификацию для системы, поэтому мы разместим их в папке /e2e/spec.

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

Начать с фундамента «пирамиды» и двигаться вверх? Какие тесты надо написать первыми? Любой из этих подходов имеет право на жизнь. Или начать сверху и спускаться вниз? Обычно легче всего в самом начале работы рассуждать об особенностях системы как единого целого. Преимущества подхода «сверху вниз» заключаются в первоочередном создании спецификации для всей системы. Это, кроме того, поможет нам понять, что что-то, на более низком уровне, сделано неверно. Даже если мы разделим систему на отдельные сервисы неправильно, системные спецификации останутся неизменными.

Это означает, что они будут выдавать ошибки в течение длительного времени. Минус подхода «сверху вниз» заключается в том, что сквозные тесты — это те тесты, которые используются после всех остальных, тогда, когда будет создана вся разрабатываемая система. Мы, при написании тестов для нашего проекта, будем пользоваться именно этим подходом.

▍Разработка тестов

Разработка сквозных тестов

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

Среди них — GoConvey и Ginkgo. Существует немало отличных фреймворков, дающих то, что нам надо.

Лично мне нравится использовать сочетание Ginkgo и Gomega (ужасные названия, но что поделать), которые используют синтаксические конструкции вроде Describe() и It().

Например — вот тест для механизма проверки баланса пользователя (файл sanity.go): Как же будут выглядеть наши тесты?

package spec import ... var _ = Describe("Sanity", func() ) AfterEach(func() { node.Stop() }) It("should show balances with GET /api/balance", func() { resp, err := http.Get("http://localhost:8080/api/balance?from=user1") Expect(err).ToNot(HaveOccurred()) Expect(resp.StatusCode).To(Equal(http.StatusOK)) Expect(ResponseBodyAsString(resp)).To(Equal("0")) }) })

Так как сервер доступен из внешнего мира по HTTP, мы будем работать с его веб-API с использование http.Get. А как насчёт тестирования выполнения транзакций? Вот код соответствующего теста:

It("should transfer funds with POST /api/transfer", func() { resp, err := http.Get("http://localhost:8080/api/transfer?from=user1&to=user2&amount=17") Expect(err).ToNot(HaveOccurred()) Expect(resp.StatusCode).To(Equal(http.StatusOK)) Expect(ResponseBodyAsString(resp)).To(Equal("-17")) resp, err = http.Post("http://localhost:8080/api/balance?from=user2", "text/plain", nil) Expect(err).ToNot(HaveOccurred()) Expect(resp.StatusCode).To(Equal(http.StatusOK)) Expect(ResponseBodyAsString(resp)).To(Equal("17"))
})

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

→ Вот полный код теста

Разработка сервисных тестов

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

Здесь можно найти интерфейс с proto-описаниями для этого сервиса. Начнём с сервиса VirtualMachine. Объект-заглушка позволит нам управлять ответами StateStorage во время тестирования. Так как сервис VirtualMachine полагается на сервис StateStorage и выполняет обращения к нему, нам надо будет создать объект-заглушку (mock object) для сервиса StateStorage для того, чтобы протестировать сервис VirtualMachine в изоляции.

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

Лучше всего размещать объекты-заглушки там же, где находятся сущности, которые они имитируют для того, чтобы предоставить им доступ к неэкспортируемым переменным и функциям. Код заглушки разместим в файле /services/statestorage/mock.go. Вот код объекта-заглушки (файл mock.go): Заглушка на данном этапе представляет собой схематичную реализацию сервиса, но, по мере развития сервиса, нам может понадобится развивать и реализацию заглушки.

package statestorage import ... type MockService struct { mock.Mock
} func (s *MockService) Start() { s.Called()
} func (s *MockService) Stop() { s.Called()
} func (s *MockService) IsStarted() bool { return s.Called().Bool(0)
} func (s *MockService) WriteKey(input *statestorage.WriteKeyInput) (*statestorage.WriteKeyOutput, error) { ret := s.Called(input) return ret.Get(0).(*statestorage.WriteKeyOutput), ret.Error(1)
} func (s *MockService) ReadKey(input *statestorage.ReadKeyInput) (*statestorage.ReadKeyOutput, error) { ret := s.Called(input) return ret.Get(0).(*statestorage.ReadKeyOutput), ret.Error(1)
}

Если вы отдаёте разработку отдельных сервисов разным программистам, имеет смысл сначала создавать заглушки и передавать их команде.

Какой сценарий нужно здесь проверить? Вернёмся к разработке сервисного теста для VirtualMachine. Мы реализуем тест для конечной точки CallContract() с аргументом, представляющим метод "GetBalance". Лучше всего ориентироваться на интерфейс сервиса и разрабатывать тесты для каждой конечной точки. Вот соответствующий код (файл contracts.go):

package spec import ... var _ = Describe("Contracts", func() { var ( service uut.Service stateStorage *_statestorage.MockService ) BeforeEach(func() { service = uut.NewService() stateStorage = &_statestorage.MockService{} service.Start(stateStorage) }) AfterEach(func() { service.Stop() }) It("should support 'GetBalance' contract method", func() { stateStorage.When("ReadKey", &statestorage.ReadKeyInput{Key: "user1"}).Return(&statestorage.ReadKeyOutput{Value: 100}, nil).Times(1) addr := protocol.Address{Username: "user1"} out, err := service.CallContract(&virtualmachine.CallContractInput{Method: "GetBalance", Arg: &addr}) Expect(err).ToNot(HaveOccurred()) Expect(out.Result).To(BeEquivalentTo(100)) Expect(stateStorage).To(ExecuteAsPlanned()) }) })

Обратите внимание на то, что сервис, который мы тестируем, VirtualMachine, получает указатель на свою зависимость, StateStorage, в методе Start() посредством простого механизма внедрения зависимостей. Именно здесь мы передаём экземпляр объекта-заглушки. Кроме того, обратите внимание на строку stateStorage.When("ReadKey", &statestorage.ReadKeyInput{Key…, где мы сообщаем объекту-заглушке о том, как он должен вести себя при обращении к нему. Когда вызывается метод ReadKey, он должен возвратить значение 100. Затем мы, в строке Expect(stateStorage).To(ExecuteAsPlanned()), проверяем, чтобы эта команда была вызвана ровно один раз.

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

Разработка модульных тестов

Пожалуй, реализация контракта для метода "GetBalance" слишком проста, поэтому поговорим о реализации несколько более сложного метода "Transfer". Контракт перевода средств со счёта на счёт, представленный этим методом, нуждается в чтении данных о балансах отправителя и получателя средств, в вычислении новых балансов и в записи того, что получилось, в состояние приложения. Сервисный тест для всего этого очень похож на тот, который мы только что реализовали (файл transactions.go):

It("should support 'Transfer' transaction method", func() { stateStorage.When("ReadKey", &statestorage.ReadKeyInput{Key: "user1"}).Return(&statestorage.ReadKeyOutput{Value: 100}, nil).Times(1) stateStorage.When("ReadKey", &statestorage.ReadKeyInput{Key: "user2"}).Return(&statestorage.ReadKeyOutput{Value: 50}, nil).Times(1) stateStorage.When("WriteKey", &statestorage.WriteKeyInput{Key: "user1", Value: 90}).Return(&statestorage.WriteKeyOutput{}, nil).Times(1) stateStorage.When("WriteKey", &statestorage.WriteKeyInput{Key: "user2", Value: 60}).Return(&statestorage.WriteKeyOutput{}, nil).Times(1) t := protocol.Transaction{From: &protocol.Address{Username: "user1"}, To: &protocol.Address{Username: "user2"}, Amount: 10} out, err := service.ProcessTransaction(&virtualmachine.ProcessTransactionInput{Method: "Transfer", Arg: &t}) Expect(err).ToNot(HaveOccurred()) Expect(out.Result).To(BeEquivalentTo(90)) Expect(stateStorage).To(ExecuteAsPlanned())
})

В процессе работы над проектом мы, наконец доходим до создания его внутренних механизмов и создаём модуль, размещаемый в файле processor.go, который содержит реализацию контракта. Вот как будет выглядеть его исходный вариант (файл processor.go):

package virtualmachine import ... func (s *service) processTransfer(fromUsername string, toUsername string, amount int32) (int32, error) { fromBalance, err := s.stateStorage.ReadKey(&statestorage.ReadKeyInput{Key: fromUsername}) if err != nil { return 0, err } toBalance, err := s.stateStorage.ReadKey(&statestorage.ReadKeyInput{Key: toUsername}) if err != nil { return 0, err } _, err = s.stateStorage.WriteKey(&statestorage.WriteKeyInput{Key: fromUsername, Value: fromBalance.Value - amount}) if err != nil { return 0, err } _, err = s.stateStorage.WriteKey(&statestorage.WriteKeyInput{Key: toUsername, Value: toBalance.Value + amount}) if err != nil { return 0, err } return fromBalance.Value - amount, nil
}

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

В Go принято избегать экзотических фреймворков в модульных тестах. Так как мы собираемся вызывать функцию несколько раз с различными входными данными и имитировать параметры для достижения всех ветвей кода, мы, для того, чтобы сделать этот процесс эффективнее, можем прибегнуть к тестам, основанным на таблице (table driven test). В результате проверки, выполняемые здесь, будут похожи на те, которые мы выполняли в предыдущих тестах. Мы можем отказаться от Ginkgo, но, вероятно, нам стоит оставить Gomega. Вот код теста (файл processor_test.go):

package virtualmachine import ... var transferTable = []struct{ to string // имя пользователя, которому переводят средства read1Err error // требуется ли отказ первой операции чтения read2Err error // требуется ли отказ второй операции чтения write1Err error // требуется ли отказ первой операции записи write2Err error // требуется ли отказ второй операции записи output int32 // ожидаемый выход errs bool // ожидаем ли мы возврат ошибки из функции
}{ {"user2", errors.New("a"), nil, nil, nil, 0, true}, {"user2", nil, errors.New("a"), nil, nil, 0, true}, {"user2", nil, nil, errors.New("a"), nil, 0, true}, {"user2", nil, nil, nil, errors.New("a"), 0, true}, {"user2", nil, nil, nil, nil, 90, false},
} func TestTransfer(t *testing.T) { Ω := NewGomegaWithT(t) for _, tt := range transferTable { s := NewService() ss := &_statestorage.MockService{} s.Start(ss) ss.When("ReadKey", &statestorage.ReadKeyInput{Key: "user1"}).Return(&statestorage.ReadKeyOutput{Value: 100}, tt.read1Err) ss.When("ReadKey", &statestorage.ReadKeyInput{Key: "user2"}).Return(&statestorage.ReadKeyOutput{Value: 50}, tt.read2Err) ss.When("WriteKey", &statestorage.WriteKeyInput{Key: "user1", Value: 90}).Return(&statestorage.WriteKeyOutput{}, tt.write1Err) ss.When("WriteKey", &statestorage.WriteKeyInput{Key: "user2", Value: 60}).Return(&statestorage.WriteKeyOutput{}, tt.write2Err) output, err := s.(*service).processTransfer("user1", tt.to, 10) if tt.errs { Ω.Expect(err).To(HaveOccurred()) } else { Ω.Expect(err).ToNot(HaveOccurred()) Ω.Expect(output).To(BeEquivalentTo(tt.output)) } }
}

Если вы озадачены символом «Ω» — не беспокойтесь, так как это — обычное имя переменной (эта переменная хранит указатель на Gomega). Вы можете использовать для подобной переменной любое понравившееся вам имя.

При использовании этой методологии реализация метода processTransfer() и модульный тест для него создаются за несколько итераций. Для того чтобы не тратить слишком много времени, мы не будем рассматривать здесь строгую методологию TDD, при применении которой новую строку кода пишут лишь для того, чтобы заставить правильно заработать тест, который до этого выдавал ошибку.

Модульные тесты для других сервисов находятся здесь и здесь. Полный набор модульных тестов для сервиса VirtualMachine можно найти здесь.

Система успешно проходит сквозные, сервисные и модульные тесты. Теперь мы достигли 100% покрытия кода тестами. Код совершенно точно реализует предъявляемые к нему требования и всё это отлично протестировано.

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

▍О важности стресс-тестов

До сих пор наши тесты проверяли работоспособность системы при обработке одного запроса за раз. А как насчёт проверки её на возможные проблемы синхронизации? Каждый HTTP-запрос в Go обрабатывается в собственной горутине (goroutine). Так как горутины выполняются параллельно, потенциально — в разных потоках ОС на разных ядрах процессора, перед нами встают проблемы синхронизации. Ошибки, которые могут проявляться в подобных ситуациях, весьма неприятны, их сложно отслеживать.

Тест, выполняющий подобную проверку, должен быть сквозным, так как он направлен на проверку синхронизации во всей системе, на проверку слаженной работы всех составляющих её сервисов. Один из подходов к поиску проблем с синхронизацией заключается в стресс-тестировании системы путём выполнения множества параллельных запросов к ней и проверки её работоспособности в таких условиях. Вот код стресс-теста (файл stress.go): Код стресс-тестов мы разместим в папке /e2e/stress.

package stress import ... const NUM_TRANSACTIONS = 20000
const NUM_USERS = 100
const TRANSACTIONS_PER_BATCH = 200
const BATCHES_PER_SEC = 40 var _ = Describe("Transaction Stress Test", func() { var ( node services.Node ) BeforeEach(func() { node = services.NewNode() node.Start() }) AfterEach(func() { node.Stop() }) It("should handle lots and lots of transactions", func() { // подготовим HTTP-клиент к выполнению множества соединений transport := http.Transport{ IdleConnTimeout: time.Second*20, MaxIdleConns: TRANSACTIONS_PER_BATCH*10, MaxIdleConnsPerHost: TRANSACTIONS_PER_BATCH*10, } client := &http.Client{Transport: &transport} // создадим локальный журнал для верификации ledger := map[string]int32{} for i := 0; i < NUM_USERS; i++ { ledger[fmt.Sprintf("user%d", i+1)] = 0 } // отправим все транзакции по HTTP в пакетах rand.Seed(42) done := make(chan error, TRANSACTIONS_PER_BATCH) for i := 0; i < NUM_TRANSACTIONS / TRANSACTIONS_PER_BATCH; i++ { log.Printf("Sending %d transactions... (batch %d out of %d)", TRANSACTIONS_PER_BATCH, i+1, NUM_TRANSACTIONS / TRANSACTIONS_PER_BATCH) time.Sleep(time.Second / BATCHES_PER_SEC) for j := 0; j < TRANSACTIONS_PER_BATCH; j++ { from := randomizeUser() to := randomizeUser() amount := randomizeAmount() ledger[from] -= amount ledger[to] += amount go sendTransaction(client, from, to, amount, &done) } for j := 0; j < TRANSACTIONS_PER_BATCH; j++ { err := <- done Expect(err).ToNot(HaveOccurred()) } } // проверим журнал for i := 0; i < NUM_USERS; i++ { user := fmt.Sprintf("user%d", i+1) resp, err := client.Get(fmt.Sprintf("http://localhost:8080/api/balance?from=%s", user)) Expect(err).ToNot(HaveOccurred()) Expect(resp.StatusCode).To(Equal(http.StatusOK)) Expect(ResponseBodyAsString(resp)).To(Equal(fmt.Sprintf("%d", ledger[user]))) } }) }) func randomizeUser() string { return fmt.Sprintf("user%d", rand.Intn(NUM_USERS)+1)
} func randomizeAmount() int32 { return rand.Int31n(1000)+1
} func sendTransaction(client *http.Client, from string, to string, amount int32, done *chan error) { url := fmt.Sprintf("http://localhost:8080/api/transfer?from=%s&to=%s&amount=%d", from, to, amount) resp, err := client.Post(url, "text/plain", nil) if err == nil { ioutil.ReadAll(resp.Body) resp.Body.Close() } *done <- err
}

Обратите внимание на то, что стресс-тест использует случайные данные. В подобных ситуациях рекомендуется инициализировать генератор случайных чисел явным образом (в нашем случае мы инициализируем его командой rand.Seed(42)) для того, чтобы тесты были детерминированы. Не рекомендуется каждый раз запускать тест с разными данными. Это может привести к тому, что некоторые запуски теста на одном и том же коде выдают ошибки, некоторые — нет, что сбивает с толку разработчика.

В коде, представленном выше, сделана попытка предусмотреть возникновение подобных системных сбоев путём ограничения одновременных соединений пакетами по 200 штук и применения параметра IdleConnection для повторного использования TCP-сессий в различных пакетах. Одна из особенностей стресс-тестирования по HTTP заключается в том, что большинству компьютеров тяжело симулировать работу с системой тысяч пользователей и открывать тысячи TCP-соединений (тут можно столкнуться со странными сбоями, в ходе которых появляются сообщения, оповещающие о достижении максимального количества дескрипторов файлов или о сбросе соединений). Если на вашем компьютере этот тест нестабилен, попробуйте уменьшить размер пакета до 100.

Запустим тест… Он дал сбой:

fatal error: concurrent map writes goroutine 539 [running]:
runtime.throw(0x147bf60, 0x15) /usr/local/go/src/runtime/panic.go:616 +0x81 fp=0xc4207159d8 sp=0xc4207159b8 pc=0x102ca01
runtime.mapassign_faststr(0x13f5140, 0xc4201ca0c0, 0xc4203a8097, 0x6, 0x1012001) /usr/local/go/src/runtime/hashmap_fast.go:703 +0x3e9 fp=0xc420715a48 sp=0xc4207159d8 pc=0x100d879
services/statestorage.(*service).WriteKey(0xc42000c060, 0xc4209e6800, 0xc4206491a0, 0x0, 0x0) services/statestorage/methods.go:15 +0x10c fp=0xc420715a88 sp=0xc420715a48 pc=0x138339c
services/virtualmachine.(*service).processTransfer(0xc4201ca090, 0xc4203a8097, 0x6, 0xc4203a80a1, 0x6, 0x2a4, 0xc420715b30, 0x1012928, 0x40) services/virtualmachine/processor.go:19 +0x16e fp=0xc420715ad0 sp=0xc420715a88 pc=0x13840ee
services/virtualmachine.(*service).ProcessTransaction(0xc4201ca090, 0xc4209e67c0, 0x30, 0x1433660, 0x12a1d01) Ginkgo ran 1 suite in 1.288879763s
Test Suite Failed

Что случилось? Структура данных StateStorage реализована в виде обычного ассоциативного массива (map), хранящегося в памяти. Кажется, что проблема заключается в том, что мы пытаемся писать данные в этот массив параллельно из разных потоков. На первый взгляд может показаться, что для исправления ситуации нужно просто заменить обычную структуру данных map на её потокобезопасный вариант sync.map. Однако наша проблема кроется глубже.

Он дважды выполняет операцию чтения данных из состояния приложения, а затем — дважды выполняет запись данных в состояние. Взглянем на реализацию метода processTransfer(). Решение проблемы заключается в том, чтобы при обработке параллельных запросов использовался лишь один экземпляр processTransfer(). Набор операций чтения и записи не является атомарной транзакцией, поэтому, если один поток прочёл данные и считает их неизменными, а в это время другой поток их изменил, мы столкнёмся с повреждением данных. Вот соответствующий код.

Однако, к нашему великому сожалению, мы снова сталкиваемся с ошибкой. Внеся исправления в проект, запустим тест снова.

e2e/stress/transactions.go:44 Expected <string>: -7498
to equal <string>: -7551 e2e/stress/transactions.go:82
------------------------------
Ginkgo ran 1 suite in 5.251593179s
Test Suite Failed

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

Мы следовали правилам TDD и в базовой логике нашего кода имеется недопустимый изъян. Это — пренеприятнейшая ошибка. Разве мы не проверили код в каждом из возможных сценариев работы, разве не покрыли его тестами на 100%?! Как это возможно? В требованиях к processTransfer() должно присутствовать чёткое указание на то, что если пользователь переводит средства сам себе, ничего происходить не должно. Всё это так, но данная ошибка — результат плохо продуманных требований к проекту.

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

Итоги

Итак, теперь, после того, как проект успешно проходит стресс-тесты, можем ли мы говорить о том, что наша система работает именно так, как ожидается? Можно ли считать её высоконадёжной? К сожалению — нет.

В частности, наша «простая» функция processTransfer() всё ещё находится в группе риска. В ней всё ещё присутствуют неприятные ошибки, которые не выявляет даже стресс-тестирование. Первая операция записи данных в состояние приложения окажется успешной, а вторая — нет. Представьте себе, что произойдёт, если мы окажемся здесь. Если мы намереваемся вернуть ошибку, сначала нам надо восстановить систему до того состояния, в котором она была до первой операции записи. Мы собираемся вернуть ошибку, но состояние приложения уже повреждено из-за записи в него не вполне готовых данных.

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

Работа со сложными ошибками требует от разработчиков навыков критического мышления и «параноидальной креативности». На самом деле, из всего этого можно извлечь один важный урок: даже хорошо проработанные наборы тестов не обеспечивают полной надёжности решения. Ещё лучше — переводить код в разряд опенсорсного. В этой связи рекомендуется показывать код кому-то, кто не участвовал в его создании для того, чтобы он, взглянув на программу свежим взглядом, мог бы увидеть то, чего разработчики этого кода не заметили. Так его смогут проверить все, кому он интересен, а подобные проверки — это лучший способ повышения надёжности кода.

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

Уважаемые читатели! Как вы подходите к разработке высоконадёжных серверных систем?

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть