Ускоряем разработку новых фич: как я написал плагин кодогенерации для Protobuf
- среда, 11 октября 2023 г. в 00:00:17
Всем привет! Меня зовут Ефим Воробьёв, я учусь на программиста в университете, создаю свои проекты и работаю в стартапе. Я с девятого класса разрабатывал веб-приложения на PHP и Python, зарабатывая на фрилансе. Со временем я понял, что хочу развиваться дальше, и поступил на курс «Go-разработчик» в Практикуме.
С новыми знаниями я пришёл в стартап «умных дверей». Для него я написал плагин для Protobuf, который собирает служебную информацию из proto-файлов, сопоставляет её с информацией от контроллера и преобразует в понятный человеку формат. Это происходит автоматически и без использования рефлексии.
В этой статье я расскажу, зачем такой плагин понадобился и как он облегчил жизнь разработчикам. Я покажу примеры кода и поделюсь инструментами, которые использовал. В конце я сделаю вывод: какими преимуществами обладает язык Go, по моему мнению.
Хочу добавить немного контекста и рассказать про проект, над которым сейчас работаю, — «Умные двери Dora». Основная его составляющая – контроллер, который представляет из себя хаб для объединения системы контроля доступа (СКУД) и системы умного дома. Контроллер управляет смарт-замками, приводом, считывателями и модулями расширения: камерой и аудиовыходом для озвучивания уведомлений.
Достаточно встроить контроллер в любую существующую дверь, чтобы она стала умной. Умная дверь поддерживает интеграцию с умным домом и многофакторную авторизацию. Пользователь может получить доступ по Face ID, распознаванию голоса, скану пальца или с помощью Bluetooth, RFID/NFC, аутентификации через мессенджер.
На языке Go реализовано большинство сервисов для хранения и обработки данных СКУД, в том числе панель управления. Благодаря ей пользователь видит, что происходит в текущий момент с помещением, самой дверью, замками и приводами. Он может настроить доступы, посмотреть, кто куда входил, какие ошибки происходили, если они были.
Именно для отображения различных состояний и уведомлений я написал плагин, о котором буду рассказывать дальше. Сначала я расскажу, какое решение мы выбрали в ранних итерациях, и объясню, почему решили его доработать.
Для взаимодействия контроллера с серверной частью мы используем MQTT, а для описания сообщений — Protocol Buffers, или Protobuf. Это удобно, потому что можно один раз описать формат сообщений в protobuf-формате и использовать их в различных сервисах, даже если они написаны с использованием разных языков программирования. Это возможно благодаря кодогенерации — у каждого языка есть свой плагин для её запуска.
Protobuf-формат позволяет без особых трудностей изменять сообщения, сохраняя обратную совместимость, при этом сами данные сериализуются эффективнее по сравнению с такими форматами сериализации данных, как XML или JSON. Но есть и трудность: из-за особенностей формата его нельзя однозначно десериализовать без знания структуры сообщения.
Контроллер отправляет на сервер информацию о событиях, которые с ним происходят, в различных форматах сообщений в режиме реального времени: один топик — один формат сообщения. Изначально мы реализовали обработчик сообщений, в котором мы вручную однозначно указывали тип сообщения для каждого MQTT-топика.
Мы написали unit-тест, который анализировал абстрактное синтаксическое дерево обработчика и проверял, что тип сообщения соответствует MQTT-теме. Тему он брал из комментария для каждого сообщения в proto-файле — это было реализовано при помощи пакета protoreflect. На тот момент это было хорошим решением.
Команда для запуска кодогенерации из proto-файлов:
protoc --plugin-protoc-gen-notification-description=./plugin/notification-description/cmd/main --notification-description_out=. --notification-description_opt=paths=source_relative --go_out=. --go_opt=paths=source_relative <путь_до_proto_файла>/*.proto
С появлением новых интеграций стало появляться всё больше уведомлений контроллера. Их обработка должна происходить не только на стороне сервера, но и в читаемом виде отображаться в интерфейсе пользователя, рассылать уведомления для мессенджеров. Так мы поняли, что нужен плагин, который синхронизирует все сервисы со служебной информацией.
Плагин собирает служебную информацию из proto-файлов при помощи атрибутов MessageOptions и FieldOptions и генерирует исходный код для использования в различных сервисах:
путь темы MQTT,
тип события,
название события,
название группы событий,
описание,
название каждого поля.
Пример сообщения с описанной служебной информацией
message AcsUserSyncErrorEvent {
option (notification_description.path_mqtt) =
".../sync/error";
option (notification_description.event_type) = "ACS_USER_SYNC_ERROR";
option (notification_description.name) =
"Синхронизация доступов завершилась с ошибкой";
option (notification_description.group) = "Доступы";
option (notification_description.description) =
"Ошибка при синхронизации доступов пользователей";
EventGeneral general = 1;
string error = 2
[ (notification_description.field_name) = "Ошибка синхронизации" ];
}
Я реализовал плагин при помощи языка Go и пакета protogen. Он даёт возможность читать proto-файлы, которые были вызваны при сборке с этим плагином, и создавать новые файлы, в которых и будет сгенерирован код. Давайте рассмотрим, как это всё работает.
Сначала программа итерируется по списку всех сообщений и собирает служебную информацию о них при помощи пакета protowire и номеров опций, которые мы заранее обозначаем. Пример опций:
extend google.protobuf.MessageOptions {
string path_mqtt = 27001;
string event_type = 27002;
string name = 27003;
string group = 27004;
string description = 27005;
}
extend google.protobuf.FieldOptions {
string field_name = 27006;
bool field_photo = 27007;
}
После того как вся необходимая информация собрана, можно приступать к генерации кода при помощи шаблонов на основе пакета text/template.
package generator_go
const switchPathMqttToTypeTemplate = `
var (
UnknownNotificationPathErr = errors.New("unknown notification path")
)
// NewNotificationByPath — функция для получения protobuf типа структуры уведомления по теме MQTT
func NewNotificationByPath(notificationPath string) (notification NotificationProto, err error) {
switch notificationPath {
{{range .NotificationDescriptionList}}
case "{{.ShortPathMQTT}}":
notification = new({{.MessageName}})
return
{{end}}
default:
err = UnknownNotificationPathErr
return
}
}
`
const switchShortPathMqttToNameAndGroupAndDescriptionTemplate = `
// NameAndGroupByShortPathMqtt — функция для получения названия, группы и описания уведомления по сокращенной теме MQTT
func NameAndGroupAndDescriptionByShortPathMqtt(notificationPath string) (name string, group string, description string, err error) {
switch notificationPath {
{{range .NotificationDescriptionList}}
case "{{.ShortPathMQTT}}":
return "{{.Name}}", "{{.Group}}", "{{.Description}}", nil
{{end}}
default:
err = UnknownNotificationPathErr
return
}
}
`
const switchShortPathMqttToEnumIdTemplate = `
// NameAndGroupByShortPathMqtt — функция для получения названия, группы и описания уведомления по сокращенной теме MQTT
func EnumIdByShortPathMqtt(notificationPath string) (enumId int32, err error) {
switch notificationPath {
{{range .NotificationDescriptionList}}
case "{{.ShortPathMQTT}}":
return {{.EventTypeId}}, nil
{{end}}
default:
err = UnknownNotificationPathErr
return
}
}
`
const allNotificationsNamesAndGroupsTemplate = `
type NotificationNameShortPath struct {
Name string ` + "`" + `json:"name"` + "`" + `
ShortPath string ` + "`" + `json:"short_path"` + "`" + `
EventTypeId int32 ` + "`" + `json:"event_type_id"` + "`" + `
}
var NotificationsTypeList = []NotificationNameShortPath{ {{range .NotificationDescriptionList}}{ "{{.Name}}", "{{.ShortPathMQTT}}", {{.EventTypeId}} }, {{end}} }
var NotificationsGroupList = []string{ {{range .NotificationGroupSet}}"{{.}}", {{end}} }
var NotificationsShortPathList = []string{ {{range .NotificationDescriptionList}} "{{.ShortPathMQTT}}", {{end}} }
var NotificationsWithGroupMap = map[string][]NotificationNameShortPath{
{{range $key, $value := .NotificationsWithGroupMap}}
"{{ $key }}": []NotificationNameShortPath{ {{range $value}}{ "{{.Name}}", "{{.ShortPath}}", {{.EventTypeId}} }, {{end}} },{{end}}
}
`
package notifications
import (
"github.com/pkg/errors"
"google.golang.org/protobuf/reflect/protoreflect"
)
type NotificationProto interface {
GetGeneral() *EventGeneral
ProtoReflect() protoreflect.Message
}
type NotificationNameShortPath struct {
Name string `json:"name"`
ShortPath string `json:"short_path"`
EventTypeId int32 `json:"event_type_id"`
}
// NotificationsTypeList — cписок всех уведомлений (название, сокращенный путь топика MQTT и номер типа события контроллера)
var NotificationsTypeList = []NotificationNameShortPath{{"Фото", "********", 0}, {"Пользователь рядом (BLE)", "********", 0}, {"Пользователь ушел (BLE)", "********", 0}, {"Авторизация началась", "********", 0}, {"Авторизация завершилась успешно", "********", 0}, {"Авторизация не удалась", "********", 0}, {"Ошибка авторизации", "********", 0}, {"Синхронизация пользователей началась", "********", 0}, {"Синхронизация доступов завершилась успешно", "********", 0}, {"Синхронизация доступов завершилась с ошибкой", "********", 0}, {"Добавлен пользователь", "********", 0}, {"Обновлён пользователь", "********", 0}, {"Удалён пользователь", "********", 0}, {"Дверь открыта", "********", 0}, {"Дверь закрыта", "********", 0}, {"Контроллер включился", "********", 0}, {"Контроллер подключился", "********", 0}, {"Контроллер отключился", "********", 0}, {"Изменены настройки", "********", 0}, {"Ошибка при изменении", "********", 0}, {"Изменение состояния закрытости замка", "********", 0}, {"Изменение состояния ригеля", "********", 0}, {"Изменение состояния язычка", "********", 0}, {"Изменение состояния ручки", "********", 0}, {"Изменение состояния цилиндра", "********", 0}, {"Ошибка замка", "********", 0}}
// NotificationsGroupList — cписок всех названий групп уведомлений
var NotificationsGroupList = []string{"Фото", "Пользователь рядом", "Авторизация", "Доступы", "Дверь", "Подключение", "Настройки контроллера", "Замок"}
// NotificationsShortPathList — cписок всех сокращенных путей топиков MQTT для уведомлений
var NotificationsShortPathList = []string{"********", "********", "********", "********", "********", "********", "********", "********", "********", "********", "********", "********", "********", "********", "********", "********", "********", "********", "********", "********", "********", "********", "********", "********", "********", "********"}
// NotificationsWithGroupMap — мапа с ключом в виде названия группы уведомлений, значением структуры (название события, сокращенный путь топика MQTT)
var NotificationsWithGroupMap = map[string][]NotificationNameShortPath{
"Авторизация": []NotificationNameShortPath{{"Авторизация началась", "********", 0}, {"Авторизация завершилась успешно", "********", 0}, {"Авторизация не удалась", "********", 0}, {"Ошибка авторизации", "********", 0}},
"Дверь": []NotificationNameShortPath{{"Дверь открыта", "********", 0}, {"Дверь закрыта", "********", 0}},
"Доступы": []NotificationNameShortPath{{"Синхронизация пользователей началась", "********", 0}, {"Синхронизация доступов завершилась успешно", "********", 0}, {"Синхронизация доступов завершилась с ошибкой", "********", 0}, {"Добавлен пользователь", "********", 0}, {"Обновлён пользователь", "********", 0}, {"Удалён пользователь", "********", 0}},
"Замок": []NotificationNameShortPath{{"Изменение состояния закрытости замка", "********", 0}, {"Изменение состояния ригеля", "********", 0}, {"Изменение состояния язычка", "********", 0}, {"Изменение состояния ручки", "********", 0}, {"Изменение состояния цилиндра", "********", 0}, {"Ошибка замка", "********", 0}},
"Настройки контроллера": []NotificationNameShortPath{{"Изменены настройки", "********", 0}, {"Ошибка при изменении", "********", 0}},
"Подключение": []NotificationNameShortPath{{"Контроллер включился", "********", 0}, {"Контроллер подключился", "********", 0}, {"Контроллер отключился", "********", 0}},
"Пользователь рядом": []NotificationNameShortPath{{"Пользователь рядом (BLE)", "********", 0}, {"Пользователь ушел (BLE)", "********", 0}},
"Фото": []NotificationNameShortPath{{"Фото", "********", 0}},
}
var (
UnknownNotificationPathErr = errors.New("unknown notification path")
)
// NewNotificationByPath — функция для получения protobuf типа структуры уведомления по теме MQTT
func NewNotificationByPath(notificationPath string) (notification NotificationProto, err error) {
switch notificationPath {
case "********":
notification = new(PhotoEvent)
return
case "********":
notification = new(BleDeviceNearEvent)
return
case "********":
notification = new(BleDeviceGoneEvent)
return
case "********":
notification = new(AcsAuthProcessingEvent)
return
case "********":
notification = new(AcsAuthSuccessEvent)
return
case "********":
notification = new(AcsAuthErrorEvent)
return
case "********":
notification = new(AcsAuthFailedEvent)
return
case "********":
notification = new(AcsUserSyncProcessingEvent)
return
case "********":
notification = new(AcsUserSyncSuccessEvent)
return
case "********":
notification = new(AcsUserSyncErrorEvent)
return
case "********":
notification = new(AcsUserCreateEvent)
return
case "********":
notification = new(AcsUserUpdateEvent)
return
case "********":
notification = new(AcsUserDeleteEvent)
return
case "********":
notification = new(DoorOpenedEvent)
return
case "********":
notification = new(DoorClosedEvent)
return
case "********":
notification = new(StateStartedEvent)
return
case "********":
notification = new(StateConnectedEvent)
return
case "********":
notification = new(StateDisconnectedEvent)
return
case "********":
notification = new(ConfigSuccessEvent)
return
case "********":
notification = new(ConfigErrorEvent)
return
case "********":
notification = new(ModuleLockClosed)
return
case "********":
notification = new(ModuleLockLock)
return
case "********":
notification = new(ModuleLockTrigger)
return
case "********":
notification = new(ModuleLockHandle)
return
case "********":
notification = new(ModuleLockCylinder)
return
case "********":
notification = new(ModuleLockError)
return
default:
err = UnknownNotificationPathErr
return
}
}
// NameAndGroupByShortPathMqtt — функция для получения названия, группы и описания уведомления по сокращенной теме MQTT
// Используется для отображения списка уведомлений контроллера в панели управления и генерации уведомлений в мессенджерах
func NameAndGroupAndDescriptionByShortPathMqtt(notificationPath string) (name string, group string, description string, err error) {
switch notificationPath {
case "********":
return "Фото", "Фото", "Фото, инициированное каким-то другим событием", nil
case "********":
return "Пользователь рядом (BLE)", "Пользователь рядом", "Событие обнаружения пользователя при помощи BLE", nil
case "********":
return "Пользователь ушел (BLE)", "Пользователь рядом", "Событие потери пользователя из области видимости при помощи BLE", nil
case "********":
return "Авторизация началась", "Авторизация", "Попытка авторизации", nil
case "********":
return "Авторизация завершилась успешно", "Авторизация", "Успешная попытка авторизации", nil
case "********":
return "Авторизация не удалась", "Авторизация", "Ошибка при попытке авторизации (авторизация может быть не завершена), уведомляет о любых ошибках в процессе авторизации", nil
case "********":
return "Ошибка авторизации", "Авторизация", "Авторизация провалена (завершена)", nil
case "********":
return "Синхронизация пользователей началась", "Доступы", "Начало процесса синхронизации доступов пользователей", nil
case "********":
return "Синхронизация доступов завершилась успешно", "Доступы", "Cобытие успешной синхронизации доступов пользователей", nil
case "********":
return "Синхронизация доступов завершилась с ошибкой", "Доступы", "Ошибка при синхронизации доступов пользователей", nil
case "********":
return "Добавлен пользователь", "Доступы", "Cобытие добавления доступа пользователя", nil
case "********":
return "Обновлён пользователь", "Доступы", "Cобытие обновления данных доступа пользователя", nil
case "********":
return "Удалён пользователь", "Доступы", "Событие удаления доступа пользователя", nil
case "********":
return "Дверь открыта", "Дверь", "Cобытие открытия двери (геркон разомкнулся)", nil
case "********":
return "Дверь закрыта", "Дверь", "Событие закрытия двери (геркон замкнулся)", nil
case "********":
return "Контроллер включился", "Подключение", "Событие включения контроллера", nil
case "********":
return "Контроллер подключился", "Подключение", "Событие подключения контроллера к серверу (в том числе и после разрыва соединения)", nil
case "********":
return "Контроллер отключился", "Подключение", "Событие разрыва соединения контроллера с сервером (не отправляется контроллером)", nil
case "********":
return "Изменены настройки", "Настройки контроллера", "Событие успешного изменения настроек", nil
case "********":
return "Ошибка при изменении", "Настройки контроллера", "Событие ошибки при изменении настроек", nil
case "********":
return "Изменение состояния закрытости замка", "Замок", "Изменение состояния замка (закрыт/не закрыт)", nil
case "********":
return "Изменение состояния ригеля", "Замок", "Изменение состояния ригеля (выдвинулся/не выдвинулся)", nil
case "********":
return "Изменение состояния язычка", "Замок", "Изменение состояния язычка (нажат/не нажат)", nil
case "********":
return "Изменение состояния ручки", "Замок", "Изменение состояния ручки (нажата/не нажата)", nil
case "********":
return "Изменение состояния цилиндра", "Замок", "Изменение состояния цилиндра", nil
case "********":
return "Ошибка замка", "Замок", "Любая ошибка модуля", nil
default:
err = UnknownNotificationPathErr
return
}
}
На написание и интеграцию плагина ушло 3 рабочих дня, объём исходного кода — менее 500 строк. В результате работы были убраны обработчики — теперь все сервисы синхронизируются с последней версией единого формата сообщений. В том числе фронтенд и сервис для рассылки уведомлений теперь в автоматическом режиме транслируют сырое сообщение контроллера в понятный для человека формат.
У плагина уже широкий функционал, но мы планируем расширять его дальше. В будущем появится возможность автоматически генерировать внутреннюю документацию протокола общения контроллера.
Подведу итоги: раньше мы вручную создавали мэпы, где ключ — название темы MQTT, а значение — структура сообщения, для их сопоставления и дальнейшей десериализации. Для спокойствия всё автоматически проверялось специальным unit-тестом.
Теперь все фильтры, настройки и мониторинг на фронтенде, а также сервис рассылки автоматически синхронизируются с proto-файлами описания сообщений и возможностей контроллера. Благодаря работе плагина мы намного быстрее реализуем новые интеграции.
Если вы решите написать свой плагин и сделать его общедоступным, оформите его в специальный реестр. Важно, чтобы ваш плагин не использовал уникальные идентификаторы опций из уже существующих плагинов. В реестре вы сможете найти их список, краткое описание и зарезервированные номера.
В процессе разработки мне приходилось писать код, который конкурирует за ресурсы в системе. Обычно непросто написать такие решения, которые работали бы ещё и безопасно. Из-за особенностей Go и тех паттернов проектирования, которые я изучил в рамках курса, мне удалось сделать всё проще, чем оно бывает обычно.
Язык довольно простой, в нём не много управляющих конструкций, при этом он подходит для написания систем любой сложности. Go позволяет писать в объектно-ориентированном стиле, используя более простые конструкции, чем в Java или C#. По этим причинам новые специалисты довольно быстро включаются в процесс разработки. В целом принципы языка идеально отвечают великому принципу программистов — Keep it simple, stupid (KISS).
Плюсы Go:
Простой синтаксис
Низкий порог входа
Скорость разработки
Статическая линковка
Скорость компиляции
Большое комьюнити
Большой выбор пакетов
Схожесть с языком C
Нет традиционного ООП
Хороший FFI через cgo
Модель CSP для простой реализации конкурентных систем
От создателя Plan9 :-)
Поэтому язык Go подходит в качестве первого языка программирования: его можно изучать, даже если нет технического образования и опыта в разработке. На онлайн-курсе по бэкенд-разработке на Go можно поработать с реальными заказчиками и наполнить портфолио крутыми кейсами.