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).
Подведём итог. Сегодня нам удалось описать протокол взаимодействия с нашим новым микросервисом, описать основные его сущности и модели, ввести списки и определить сигнатуры наших методов\функций. Данный протофайл успешно генерируется и экспортируется в наш проект, а значит мы полностью готовы к дальнейшей работе и уже в следующей статье мы реализуем серверную и клиентскую стороны нашего микросервиса.