gRPC Часть №2 — .NET C# и Google Protobuff. Пишем свой proto-файл.

2 июня, 2018DEV

Несколько дней назад я написал вводную статью, открывающую небольшой сборник записей про связь между вселенной .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).

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

Leave a comment

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

Prev Post Next Post