gRPC Часть №2 — .NET C# и Google Protobuff. Пишем свой proto-файл.
Несколько дней назад я написал вводную статью, открывающую небольшой сборник записей про связь между вселенной .NET и фреймворком Google Protobuff gRPC. Это вторая часть цикла, по сути также являющаяся вступительной. Сегодня мы научимся создавать свои собственные протофайлы (обычные текстовые файлы с расширением .proto) и изучим синтаксис proto3 (думаю, что версии ниже можно уже не рассматривать).
Итак, как я уже говорил, протофайл — это обычный текстовый формат с определённым строгим синтаксисом, определяющий интерфейс взаимодействия модулей вашего программного обеспечения. Внутри этого файла мы описываем все наши модели данных и методы, оперирующие этими данными. Также стоит помнить, что Google Protobuff является строготипизированным протоколом, а это значит, что нам не придётся беспокоиться за приведение и сохранность данных. Мы будем работать именно с тем, что описали в протофайле.
Необходимо определиться с примитивами синтаксиса протофайла. Это очень простой формат данных и он оперирует всего несколькими сущностями: мета данные, сами методы, сообщения (аналог классов, моделей и т.д.) и списки перечислений (enums). Данного весьма скромного, но мощного набора инструментов хватает, чтобы реализовать, по сути, любую задачу. Начать, конечно, имеет смысл с базовых «головных» настроек протофайла. Под мета данными мы пониманием явное указание синтаксиса, названия сервиса, используемых пакетов (в протофайл можно импортить другие пакеты и протофайлы) и т.д.:
syntax = "proto3";
С этой строки должен начинаться наш протофайл. Таким образом мы сообщаем генератору коду о версии синтаксиса нашего .proto. Следующим после этого обязательного поля идут опциональные директивы import и package. Первая позволяет импортировать внешние протофайлы (аналог include), а вторая директива задаёт namespace, в котором будет сгенерирован код. В нашем примере import мы использовать не будем, воспользуемся только определением пространства имён:
package UsersManagement;
Теперь о реализуемой задаче. Для нашего примера я выбрал простенький пример микросервиса для менеджмента пользователей в какой-то абстрактной системе: здесь будут функции добавления, редактирования, удаление пользователей, а также поиск среди них. Это маленький проект, созданный исключительно для демонстрационных целей. Нам предстоит создать методы CreateUser
, DeleteUser
, UpdateUser
, Search
, Count
(для получения количества пользователей в системе) и два метода RegOnline
, RegOffline
для соответствующей регистрации наших пользователей. Давайте для начала опишем модель пользователя — пусть это будет некий класс User с полями id, ФИО, датами создания, обновления, дня рождения, булевые типами активный\не активный и онлайн\не онлайн. Также у этой модели будет два enum списка Sex
(пол) и Role
(роль пользователя в системе). Вполне себе стандартный набор. Давайте определим всё это в нашем протофайле:
enum Sex { SEX_UNKNOWN=0; SEX_MALE=1; SEX_FEMALE=2; } enum Role{ ROLE_UNKNOWN=0; ROLE_GUEST=1; ROLE_DEVELOPER=2; ROLE_MANAGER=3; ROLE_ADMIN=4; } message User { string id=1; string name_first = 2; string name_last = 3; double created_at = 4; double updated_at = 5; double birthday = 6; bool active = 7; bool online = 8; Sex sex = 9; Role role = 10; }
Стоит оговорить, что gRPC не умеет возвращать пустые ответы. Мы не сможем создать классическую процедуру с возвращаемым типом void. Также, к сожалению, нельзя сделать метод без аттрибутов. Ваши функции всегда должны что-либо принимать к себе. Поэтому хорошим тоном и best practice именования функций является следующий формат: Foo
(сам метод) плюс FooRequest
для запроса и FooResponse
для ответа. Даже если ничего не нужно передавать — нам необходимо создать пустой экземпляр класса FooRequest
. К слову, во второй версии протобуфа была возможность создавать extend message, который транслировались в статические поля, что давало возможность делать подобные вызовы: Foo(FooRequest.Empty);
, но в последствии от такого подхода отказались (как и от required и optional полей, почему-то; в целом прото3 стал на порядок меньше и лёгковеснее, чем свои предшественники). Часто создают пустые запросы и ответы: EmptyRequest
и EmptyResponse
, с помощью которых можно решить наше маленькое неудобство.
Таким образом давайте создадим наш собственный вариант ответа к тем методам, которые мы расцениваем как void. Только мы сделаем сразу хорошо 🙂 Добавим некое подобие обработчика ошибок, статус и сообщение на случай ошибки:
message Response { enum ResponseType{ RESPONSE_OK=0; RESPONSE_AUTH_ERROR=1; RESPONSE_SERVER_ERROR=2; RESPONSE_BAD_REQUEST=3; } string message = 1; ResponseType type = 2; double timestamp = 3; }
Здесь мы создали вложенный enum с статусами-ответами, а также сообщение на случай ошибки и double timestamp
для передачи серверного времени. Ну а почему бы и нет! 🙂
Мы описали главную сущность нашего сервиса. Давайте добавим ещё несколько моделей: коллекцию пользователей, определить тип, в котором мы сможем передавать UserID на сторону микросервиса, заложить возможность поиска и определения общего количества юзеров. Итак, допишем наши proto message:
message Users { repeated User list = 1; CountResponse count = 2; } message UserID { string id=1; } message SearchQuery { string query = 1; oneof select { Role role = 2; Sex sex = 3; } } message CountRequest { } message CountResponse { int32 count = 1; }
Ключевое слово repeated позволяет нам создать однотипный список (аналог List в .NET Framework). Кроме списка можно также создать объект Map (аналог Dictionary), в котором хранить пару ключ-значение. Что касается тела сообщения SearchQuery — в нём есть ключевое слово oneof, которое позволяет передавать исключительно либо Role, либо Sex. Таким образом мы можем сделать поиск либо по текстовому содержанию + роль, либо по тексту + пол. Исключительное или.
Когда все модели описаны, самое время перейти к самому вкусному — сигнатуры методов. Каждая функция нашего сервиса должна быть описана внутри конструкции service — название, которое последует после будет интерпретировано генератором в название соответствующего класса, на основе которого мы сможем сделать либо клиент, либо сервер:
service UsersManagementService { rpc CreateUser (User) returns (Response) {} rpc DeleteUser (User) returns (Response) {} rpc UpdateUser (User) returns (Response) {} rpc Search(SearchQuery) returns (Users) {} rpc Count(CountRequest) returns (CountResponse) {} rpc RegOnline(UserID) returns (Response) {} rpc RegOffline(UserID) returns (Response) {} }
Здесь всё очень просто. Это напоминает всем знакомый interface: мы описываем название, приёмный аттрибут и возвращаемое значение. После генерации (об этом я писал в первой статье) на выходе мы будем иметь два абстрактных класса с виртуальными методами: один с постфиксом Base
для сервера (в нашем случае: UsersManagementServiceBase
) и один с постфиксом Client
для клиента (UsersManagementServiceClient
).
Подведём итог. Сегодня нам удалось описать протокол взаимодействия с нашим новым микросервисом, описать основные его сущности и модели, ввести списки и определить сигнатуры наших методов\функций. Данный протофайл успешно генерируется и экспортируется в наш проект, а значит мы полностью готовы к дальнейшей работе и уже в следующей статье мы реализуем серверную и клиентскую стороны нашего микросервиса.