Всем привет! Меня зовут Ефим Воробьёв, я учусь на программиста в университете, создаю свои проекты и работаю в стартапе. Я с девятого класса разрабатывал веб-приложения на PHP и Python, зарабатывая на фрилансе. Со временем я понял, что хочу развиваться дальше, и поступил на курс «Go-разработчик» в Практикуме.
С новыми знаниями я пришёл в стартап «умных дверей». Для него я написал плагин для Protobuf, который собирает служебную информацию из proto-файлов, сопоставляет её с информацией от контроллера и преобразует в понятный человеку формат. Это происходит автоматически и без использования рефлексии.
В этой статье я расскажу, зачем такой плагин понадобился и как он облегчил жизнь разработчикам. Я покажу примеры кода и поделюсь инструментами, которые использовал. В конце я сделаю вывод: какими преимуществами обладает язык Go, по моему мнению.
Хочу добавить немного контекста и рассказать про проект, над которым сейчас работаю, — «Умные двери Dora». Основная его составляющая – контроллер, который представляет из себя хаб для объединения системы контроля доступа (СКУД) и системы умного дома. Контроллер управляет смарт-замками, приводом, считывателями и модулями расширения: камерой и аудиовыходом для озвучивания уведомлений.
Достаточно встроить контроллер в любую существующую дверь, чтобы она стала умной. Умная дверь поддерживает интеграцию с умным домом и многофакторную авторизацию. Пользователь может получить доступ по Face ID, распознаванию голоса, скану пальца или с помощью Bluetooth, RFID/NFC, аутентификации через мессенджер.
На языке Go реализовано большинство сервисов для хранения и обработки данных СКУД, в том числе панель управления. Благодаря ей пользователь видит, что происходит в текущий момент с помещением, самой дверью, замками и приводами. Он может настроить доступы, посмотреть, кто куда входил, какие ошибки происходили, если они были.

Именно для отображения различных состояний и уведомлений я написал плагин, о котором буду рассказывать дальше. Сначала я расскажу, какое решение мы выбрали в ранних итерациях, и объясню, почему решили его доработать.
Принцип работы контроллера и ограничения 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 строк. В результате работы были убраны обработчики — теперь все сервисы синхронизируются с последней версией единого формата сообщений. В том числе фронтенд и сервис для рассылки уведомлений теперь в автоматическом режиме транслируют сырое сообщение контроллера в понятный для человека формат.
У плагина уже широкий функционал, но мы планируем расширять его дальше. В будущем появится возможность автоматически генерировать внутреннюю документацию протокола общения контроллера.
Подведу итоги: раньше мы вручную создавали мэпы, где ключ — название темы MQTT, а значение — структура сообщения, для их сопоставления и дальнейшей десериализации. Для спокойствия всё автоматически проверялось специальным unit-тестом.
Теперь все фильтры, настройки и мониторинг на фронтенде, а также сервис рассылки автоматически синхронизируются с proto-файлами описания сообщений и возможностей контроллера. Благодаря работе плагина мы намного быстрее реализуем новые интеграции.
Если вы решите написать свой плагин и сделать его общедоступным, оформите его в специальный реестр. Важно, чтобы ваш плагин не использовал уникальные идентификаторы опций из уже существующих плагинов. В реестре вы сможете найти их список, краткое описание и зарезервированные номера.
Преимущества языка Go
В процессе разработки мне приходилось писать код, который конкурирует за ресурсы в системе. Обычно непросто написать такие решения, которые работали бы ещё и безопасно. Из-за особенностей Go и тех паттернов проектирования, которые я изучил в рамках курса, мне удалось сделать всё проще, чем оно бывает обычно.
Язык довольно простой, в нём не много управляющих конструкций, при этом он подходит для написания систем любой сложности. Go позволяет писать в объектно-ориентированном стиле, используя более простые конструкции, чем в Java или C#. По этим причинам новые специалисты довольно быстро включаются в процесс разработки. В целом принципы языка идеально отвечают великому принципу программистов — Keep it simple, stupid (KISS).
Плюсы Go:
-
Простой синтаксис
-
Низкий порог входа
-
Скорость разработки
-
Статическая линковка
-
Скорость компиляции
-
Большое комьюнити
-
Большой выбор пакетов
-
Схожесть с языком C
-
Нет традиционного ООП
-
Хороший FFI через cgo
-
Модель CSP для простой реализации конкурентных систем
-
От создателя Plan9 🙂
Поэтому язык Go подходит в качестве первого языка программирования: его можно изучать, даже если нет технического образования и опыта в разработке. На онлайн-курсе по бэкенд-разработке на Go можно поработать с реальными заказчиками и наполнить портфолио крутыми кейсами.
ссылка на оригинал статьи https://habr.com/ru/articles/765568/
Добавить комментарий