golang

Ускоряем разработку новых фич: как я написал плагин кодогенерации для Protobuf

  • среда, 11 октября 2023 г. в 00:00:17
https://habr.com/ru/companies/yandex_praktikum/articles/765568/

Всем привет! Меня зовут Ефим Воробьёв, я учусь на программиста в университете, создаю свои проекты и работаю в стартапе. Я с девятого класса разрабатывал веб-приложения на PHP и Python, зарабатывая на фрилансе. Со временем я понял, что хочу развиваться дальше, и поступил на курс «Go-разработчик» в Практикуме

С новыми знаниями я пришёл в стартап «умных дверей». Для него я написал плагин для Protobuf, который собирает служебную информацию из proto-файлов, сопоставляет её с информацией от контроллера и преобразует в понятный человеку формат. Это происходит автоматически и без использования рефлексии.

В этой статье я расскажу, зачем такой плагин понадобился и как он облегчил жизнь разработчикам. Я покажу примеры кода и поделюсь инструментами, которые использовал. В конце я сделаю вывод: какими преимуществами обладает язык Go, по моему мнению.

Хочу добавить немного контекста и рассказать про проект, над которым сейчас работаю, — «Умные двери Dora». Основная его составляющая – контроллер, который представляет из себя хаб для объединения системы контроля доступа (СКУД) и системы умного дома. Контроллер управляет смарт-замками, приводом, считывателями и модулями расширения: камерой и аудиовыходом для озвучивания уведомлений.

Внешний вид устройства
Внешний вид устройства

Достаточно встроить контроллер в любую существующую дверь, чтобы она стала умной. Умная дверь поддерживает интеграцию с умным домом и многофакторную авторизацию. Пользователь может получить доступ по Face ID, распознаванию голоса, скану пальца или с помощью Bluetooth, RFID/NFC, аутентификации через мессенджер.

На языке Go реализовано большинство сервисов для хранения и обработки данных СКУД, в том числе панель управления. Благодаря ей пользователь видит, что происходит в текущий момент с помещением, самой дверью, замками и приводами. Он может настроить доступы, посмотреть, кто куда входил, какие ошибки происходили, если они были. 

Панель управления системой, состояния системы отображаются во всплывающем окне
Панель управления системой, состояния системы отображаются во всплывающем окне
Уведомления в Telegram для специалистов из команды сервиса
Уведомления в Telegram для специалистов из команды сервиса

Именно для отображения различных состояний и уведомлений я написал плагин, о котором буду рассказывать дальше. Сначала я расскажу, какое решение мы выбрали в ранних итерациях, и объясню, почему решили его доработать. 

Принцип работы контроллера и ограничения Protobuf’а

Для взаимодействия контроллера с серверной частью мы используем 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 строк. В результате работы были убраны обработчики — теперь все сервисы синхронизируются с последней версией единого формата сообщений. В том числе фронтенд и сервис для рассылки уведомлений теперь в автоматическом режиме транслируют сырое сообщение контроллера в понятный для человека формат.

Кейсы применения Protobuf плагина
Кейсы применения Protobuf плагина

У плагина уже широкий функционал, но мы планируем расширять его дальше. В будущем появится возможность автоматически генерировать внутреннюю документацию протокола общения контроллера.

Подведу итоги: раньше мы вручную создавали мэпы, где ключ — название темы MQTT, а значение — структура сообщения, для их сопоставления и дальнейшей десериализации. Для спокойствия всё автоматически проверялось специальным unit-тестом. 

Теперь все фильтры, настройки и мониторинг на фронтенде, а также сервис рассылки автоматически синхронизируются с proto-файлами описания сообщений и возможностей контроллера. Благодаря работе плагина мы намного быстрее реализуем новые интеграции. 

Если вы решите написать свой плагин и сделать его общедоступным, оформите его в специальный реестр. Важно, чтобы ваш плагин не использовал уникальные идентификаторы опций из уже существующих плагинов. В реестре вы сможете найти их список, краткое описание и зарезервированные номера.

Преимущества языка Go

В процессе разработки мне приходилось писать код, который конкурирует за ресурсы в системе. Обычно непросто написать такие решения, которые работали бы ещё и безопасно. Из-за особенностей Go и тех паттернов проектирования, которые я изучил в рамках курса, мне удалось сделать всё проще, чем оно бывает обычно.

Язык довольно простой, в нём не много управляющих конструкций, при этом он подходит для написания систем любой сложности. Go позволяет писать в объектно-ориентированном стиле, используя более простые конструкции, чем в Java или C#. По этим причинам новые специалисты довольно быстро включаются в процесс разработки. В целом принципы языка идеально отвечают великому принципу программистов — Keep it simple, stupid (KISS).

Плюсы Go:

  • Простой синтаксис

  • Низкий порог входа

  • Скорость разработки

  • Статическая линковка

  • Скорость компиляции

  • Большое комьюнити

  • Большой выбор пакетов

  • Схожесть с языком C

  • Нет традиционного ООП

  • Хороший FFI через cgo

  • Модель CSP для простой реализации конкурентных систем

  • От создателя Plan9 :-)

Поэтому язык Go подходит в качестве первого языка программирования: его можно изучать, даже если нет технического образования и опыта в разработке. На онлайн-курсе по бэкенд-разработке на Go можно поработать с реальными заказчиками и наполнить портфолио крутыми кейсами.