Источник: Nuances of Programming
REST и gRPC: идеальное сочетание
Микросервисы обычно работают на фреймворках HTTP и RPC, таких как REST и gRPC.
REST построен на основе объектно-ориентированного проектирования — подхода, который представляет собой строительный блок HTTP-протокола. Операции CRUD (Create Read Update Delete — Создание, чтение, обновление, удаление) определяют набор поведений объекта. API REST задействуют поднабор методов HTTP, чтобы выполнять операции CRUD над элементом, который обычно представлен/сериализован в формате JSON.
gRPC — высокопроизводительный фреймворк RPC. С помощью API RPC можно получить доступ к распределенным процедурам или методам, которые синтаксически неотличимы от централизованных. Это позволяет скрыть сложность сериализации и передачи данных по сети. gRPC предлагает клиентскую, серверную и двунаправленную потоковую передачу.
Под капотом gRPC использует HTTP/2 для передачи и буферы протокола для эффективной сериализации, чтобы достичь невероятной производительности уровня REST+JSON. Он предлагает первоклассную поддержку генерации кода. Компилятор protobuf создает код как для клиента, так и для сервера, что ускоряет разработку приложений и сокращает уровень усилий, необходимый для доставки нового сервиса.
Сочетание REST и gRPC позволяет создавать распределенные высокопроизводительные сервисы с двухканальным режимом доступа, а также сохранить преимущества объектно-ориентированного проектирования.
Рассмотрим на примере. Определим сервис gRPC, который будет размещать заказы (order ) объектно-ориентированным образом с помощью спецификации protobuf . Поскольку order является объектом, определенные методы RPC должны соответствовать операциям CRUD, которые будет поддерживать сервис. Мы также добавим List — дополнительный метод RPC, чтобы поддерживать списки и фильтрацию существующих заказов.
Затем компилируем order.proto с помощью protoc с необходимыми опциями Go.
Команда выше создаст два файла: order.pb.go и order_grpc.pb.go . Первый содержит структуру для каждого типа protobuf message , определенного в order.proto .
Файл order_grpc.pb.go предоставляет клиентский и серверный код для взаимодействия с сервисом заказов. Он содержит OrderServiceServer — перевод интерфейса OrderService .
Чтобы запустить сервер gRPC, нужно реализовать интерфейс OrderServiceServer . Для этого можно применить UnimplementedOrderServiceServer (поверхностная реализация, представленная в сгенерированном коде).
Метод RegisterOrderServiceServer принимает grpc.Server и интерфейс OrderServiceServer . Он оборачивает grpc.Server вокруг реализации интерфейса сервиса заказов и должен вызываться перед серверным методом Serve() . Пример представлен ниже.
На этом этапе сервис заказов gRPC запускается всего несколькими строками кода. Осталось только разработать сервер REST. Внедрив в него интерфейс OrderServiceServer , мы полностью объединим REST и gRPC.
Обновляем метод main , и сочетание REST и gRPC будет готово.
Теперь сервера gRPC и REST запущены и работают с одной реализацией сервиса заказов. Обратите внимание, что в вышеизложенные фрагменты кода можно внести несколько оптимизаций относительно обработки ошибок, параллелизма, удобочитаемости и так далее.
Как было сказано выше, фреймворк gRPC предоставляет обширный набор инструментов для работы с protobuf . Он ускоряет разработку приложений и позволяет генерировать клиентский и серверный код, а также интерфейс сервиса, который можно использовать для объединения gRPC с REST и другими API HTTP.
Параллелизм — горутины и каналы
Goroutine — это функция, которая выполняется одновременно с другими функциями. Это некий фоновый процесс, который не блокирует текущий поток выполнения. За кадром эти легкие потоки мультиплексируются с один или несколькими (много:1) потоками ОС. Это позволяет программе Go справляться с миллионами горутин, где количество futures , которые может обрабатывать Java, будет ограничено число доступных потоком ОС (так как потоки Java соотносятся 1:1 с потоками ОС).
Оговорка такого повышения производительности состоит в том, что потоки Go совместно используют пространство памяти, доступ к которому должен быть синхронизирован. От состояния гонки и попадания в тупик могут спасти каналы.
Channel — это канал с примитивными типами, который позволяет горутинам безопасно обмениваться данными без мьютексов (блокировок). Процесс чтения и записи канала блокирует текущий поток выполнения до тех пор, пока отправитель и получатель не будут готовы.
Вот несколько задач, в которых могут пригодиться горутины.
- Задачи приложения : запуск веб-серверов, пулы соединения с базами данных, демоны, извлечение API, очереди обработки данных.
- Запросы/события : обработка входящих HTTP-запросов, выполнение дорогостоящих подзадач (например, несколько сетевых вызовов), публикация новых сообщений в Kafka.
- Задачи Fire & Forget : логирование, оповещение, метрики.
Веб-сервер — это процесс уровня приложения, который обычно включает метод start / serve , блокирующий текущий поток выполнения до тех пор, пока сервер не завершит обслуживание запросов. Если вам интересно, как HTTP-сервер Go обрабатывает запросы, загляните в исходный код (для каждого входящего HTTP-запроса создается горутина).
Так как grpcServer.Serve() и restServer.Start() — блокирующие вызовы, лишь один из них может быть запущен в основном (main ) потоке выполнения. Другой должен работать в фоновом режиме. Методы start / serve серверов REST & gRPC также возвращают ошибки, требующие аккуратной обработки.
Совет : оберните каждый сервер в структуру, которая представляет канал ошибки. Вызовите метод start /serve в горутину, записывающую ошибку в канал. Это позволит использовать select , чтобы активировать ожидание завершения операций нескольких каналов.
Ниже показано, как оптимизировать серверы REST и gRPC для фоновой обработки и распространения ошибок по каналам.
RestServer:
GrpcServer:
Не забывайте о том, что приложение Go стоит рассматривать как единую структуру. Разработчики часто пишут надежный код на уровне сервиса, а затем засоряют методы main множеством условных выражений log.Fatal() и другой сложной логикой.
Постарайтесь создать структуру приложения, которая включает конфигурации, серверы и прочие зависимости на уровне приложения. И хотя Go предоставляет возможность создавать несколько функций init , по возможности старайтесь их избегать по причине некоторых недостатков. К примеру, они возвращают пустые значения. Среда выполнения Go ищет функции уровня пакета со следующей сигнатурой.
Соответственно возвращать значения из функции init не получится. Если в процессе инициализации переменной возникает ошибка, возможно, вам потребуется выйти из приложения или написать логику recover .
Функции init могут усложнить код. Поэтому лучше попробуйте создать собственную функцию, подобную конструктору, которая создает новое приложение, выполняет все необходимые для него инициализации и затем возвращает его. Если на втором этапе возникнут сбои, просто измените сигнатуру return с возврата функции на экземпляр приложения и ошибки.
Ниже представлена оптимизированная версия main , которая создает структуру приложения, использует select для прослушивания ошибок серверов REST & gRPC, а также обрабатывает запуск и остановку, включая сигналы завершения работы ОС.
Прежде чем создать или обновить заказ (order ), нужно предварительное одобрить метод оплаты, а также подтвердить, что товары есть в наличии. Предположим, что эти подзадачи могут вызвать ошибку (сбой или таймаут) и выполняться независимо. Есть несколько способов обработки параллелизма на уровне запросов. Можно использовать стандартные горутины и каналы, но есть и более подходящие варианты.
Группы ожидания (Waitgroup ) позволяют запускать набор горутин и ожидать завершения их работы. Однако при их использовании также нужно управлять счетчиком waitGroup . ErrGroup отлично подходит для выполнения набора подзадач. Этот элемент состоит из коллекции горутин, которые реализуют подзадачи и обрабатывают распространение ошибок. errGroup ожидает (блокирует) до тех пор, пока все подзадачи не будут завершены.
Применяйте Context для входящих и исходящих серверных запросов. Он позволяет распространять ограниченные запросом значения, сроки и сигналы отмены между клиентами и серверами. В Context есть канал Done() , с помощью которого горутины могут получать уведомления о его отмене. Так они могут раньше выполнить выход и освободить ресурсы системы. Когда используется errgroup.WithContext() , полученный контекст отменяется при первой обнаруженной ошибке подзадачи или при возвращении wait() .
В примере ниже validateOrder создает errGroup , которая порождает две параллельные подзадачи: preAuthorizePayment для авторизации способа оплаты и checkInventory для проверки наличия товаров. Функции, вызываемые в обеих подзадачах, принимают Context и могут вернуться раньше в случае его отмены (или задержки запроса).
В большинстве складов и хранилищ есть системы управления заказами, что обеспечивает их эффективное и экономичное выполнение. Аналогичным образом, управление параллелизмом также важно для поддержания качества приложения. В примере ниже применяется waitgroup и каналы, чтобы ограничить количество заказов, которое склад может обрабатывать одновременно.
Эффективное модульное тестирование
Несколько советов по модульному тестированию в Go.
- Используйте чистые функции, а не методы. Это одна из самых простых единиц кода в плане тестирования. Чистая функция детерминирована и не требует инициализации для проверки. Метод — это функция, определенная по типу (type ), например структуре. Чтобы его протестировать, нужно инициализировать его родительский тип. Пример показан ниже.
- Создавайте функциональные зависимости. Любую внешнюю зависимость (база данных, вызов веб-сервиса, производитель событий и прочие), которая позволяет функции выполнить задачу, можно внедрить в нее в качестве параметра. Но такие функции сложно тестировать. Обычно это обходится путем использования фреймворка тестирования, который способен изменять (имитировать) значения внешних зависимостей во время выполнения (с помощью отражения). Посмотрите еще раз на функцию validateOrder в одном из фрагментов кода выше и вы обнаружите, что внешние зависимости preAuthorizePayment и verifyInventory встроены. Протестировать ее будет непросто. Поскольку Go поддерживает функции первого класса, исправить это можно, превратив validateOrder в функцию более высокого порядка.
Ниже показан пример тестового случая, в котором объединены все эти детали.
Мок-фреймворки полезны, если использовать их как инструмент, а не основу. Несмотря на то, что внешние зависимости можно имитировать без сторонних библиотек, эти фреймворки все же значительно помогают в процессе модульного тестирования, например при выполнении тестовых утверждений.
- Пишите чистый код. Учитывайте, что его будут читать другие члены команды. Чистый код легко писать, понимать и тестировать. Как говорил Роб Пайк: “Чистый лучше, чем умный”.
Читайте также:
Перевод статьи George Francis Jr : The Go Microservice Toolkit