Upgrade to Pro — share decks privately, control downloads, hide ads and more …

[SnowOne 2023] Александр Нозик: Асинхронная система сбора данных — сделай сам!

jugnsk
March 17, 2023

[SnowOne 2023] Александр Нозик: Асинхронная система сбора данных — сделай сам!

Системы сбора данных и управления оборудованием (SCADA) давно уже не являются какой-то экзотикой. Любое крупное производство использует их. Не говоря уже о всяких "умных" домах. Но интересный факт заключается в том, что большинство таких систем (как минимум, открытых) разработаны лет 20 назад, и на данный момент "идейно отсталые".

В докладе мы разберем архитектуру работы систем сбора данных разной степени устарелости и обсудим, как мы сделали полностью асинхронную систему сбора данных (Controls-kt) на реактивных потоках (корутинах), и какие в этом есть плюсы и минус.

jugnsk

March 17, 2023
Tweet

More Decks by jugnsk

Other Decks in Programming

Transcript

  1. Обо мне • Директор Центра Научного Программирования. • К. ф.–м.

    н. по физике частиц. • Преподаватель МФТИ. • (Со-)руководитель московского KUG. • https://sciprog.center/people/Nozik • https://twitter.com/noraltavir • https://t.me/noraltavir 2
  2. Кто все эти люди? • SCADA - Supervisory Control And

    Data Acquisition • АСУ ТП - Автоматизированная система управления технологическим процессом • САУ - Системы автоматического управления • DCS - Distributed control system • HMI – Human-Machine Interface • PLC - Programmable logic controller (что оно тут делает?) 3/16/2023 Controls-kt 4
  3. Какие они (не)бывают Шина Асинхронная Клиент-сервер Синхронная 3/16/2023 Controls-kt 5

    DOOCS TANGO controls Монолитная Распределенная EPICS LabView Много закрытых HMI WinCC
  4. Задачи, которые они (не)решают • Чтение и запись свойств устройства

    • Обнаружение устройств в сети • Распределение прав доступа к устройствам • Централизованное хранение данных • Локальное хранение данных • Панели управления • База данных конфигураций • Интеграция с другими системами Все озабочены этим 3/16/2023 Controls-kt 6 И никто этим
  5. И в чем проблема? • Системы строятся вокруг протоколов. Как

    только протоколы перестают поддерживаться, все становится плохо. • Протоколы имеют ограниченную реализацию на разных языках программирования. • Системы обнаружения сервисов и распределенные базы данных требуют сложной настройки. • Инструментарий для создания серверов устройств очень сложный (часто система поддерживает только «свое» железо). • В результате запуск простенького эксперимента с десятком датчиков требует профессиональной команды и года работы! 3/16/2023 Controls-kt 7
  6. Что такое сервер устройства Device property Device property Device command

    Read from port Synchronization Device property • Устройство состоит из свойств (типизированных). • Свойство может быть доступно на чтение и запись. • Свойство может быть связано с физическим состоянием прибора. • Возможно наличие команд. 3/16/2023 Controls-kt 10
  7. Интерфейс устройства public interface Device : Closeable, ContextAware, CoroutineScope {

    public val meta: Meta public val propertyDescriptors: Collection<PropertyDescriptor> public val actionDescriptors: Collection<ActionDescriptor> public suspend fun readProperty(propertyName: String): Meta public fun getProperty(propertyName: String): Meta? public suspend fun invalidate(propertyName: String) public suspend fun writeProperty(propertyName: String, value: Meta) public val messageFlow: Flow<DeviceMessage> public suspend fun execute(action: String, argument: Meta? = null): Meta? public suspend fun open(): Unit override fun close(): Unit } Параметры устройства Дескрипторы свойств Дескрипторы действий Асинхронное чтение (физического) свойства Взять текущее (логическое) значение свойства Сброс логического значения Запись (физического) значения Подписка (многоразовая) на события 3/16/2023 Controls-kt 11
  8. Стоп, стоп… назад Какие такие физические и логические свойства? Что

    такое Meta? Почему там один и тот же тип. 3/16/2023 Controls-kt 12
  9. Логический уровень свойства • Делаем запросы с той частотой, с

    которой удобно прибору. • Храним последнее состояние в виде логического значения. • Посылаем сигнал только когда логическое значение поменялось (экономим события). 3/16/2023 Controls-kt 13
  10. Что за Meta? Meta - дерево значений. • Q: Почему

    не типизированный объект? A: Все равно придется обезтипливать при сериализазции. • Q: Почему не JSON? A: JSON для текстового представления. А тут в памяти. Meta root A B Value C List value D[ index1 D[ index2 Value Value 3/16/2023 Controls-kt 14
  11. Добавим уюта https://github.com/SciProgCentre/controls.kt/blob/dev/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoDevice.kt class DemoDevice(context: Context, meta: Meta) : DeviceBySpec<DemoDevice>(DemoDevice,

    context, meta) { private var timeScaleState = 5000.0 private var sinScaleState = 1.0 private var cosScaleState = 1.0 companion object : DeviceSpec<DemoDevice>() { // register virtual properties based on actual object state val timeScale by mutableProperty(MetaConverter.double, DemoDevice::timeScaleState) { metaDescriptor { type(ValueType.NUMBER) } info = "Real to virtual time scale" } val sinScale by mutableProperty(MetaConverter.double, DemoDevice::sinScaleState) val cosScale by mutableProperty(MetaConverter.double, DemoDevice::cosScaleState) Состояние виртуального прибора или обращение к физическому состоянию 3/16/2023 Controls-kt 15
  12. Добавим уюта Спецификация устройства, общая для всех устройств этого типа.

    Не хранит состояния. 3/16/2023 Controls-kt 16 https://github.com/SciProgCentre/controls.kt/blob/dev/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoDevice.kt class DemoDevice(context: Context, meta: Meta) : DeviceBySpec<DemoDevice>(DemoDevice, context, meta) { private var timeScaleState = 5000.0 private var sinScaleState = 1.0 private var cosScaleState = 1.0 companion object : DeviceSpec<DemoDevice>() { // register virtual properties based on actual object state val timeScale by mutableProperty(MetaConverter.double, DemoDevice::timeScaleState) { metaDescriptor { type(ValueType.NUMBER) } info = "Real to virtual time scale" } val sinScale by mutableProperty(MetaConverter.double, DemoDevice::sinScaleState) val cosScale by mutableProperty(MetaConverter.double, DemoDevice::cosScaleState)
  13. Добавим уюта https://github.com/SciProgCentre/controls.kt/blob/dev/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoDevice.kt class DemoDevice(context: Context, meta: Meta) : DeviceBySpec<DemoDevice>(DemoDevice,

    context, meta) { private var timeScaleState = 5000.0 private var sinScaleState = 1.0 private var cosScaleState = 1.0 companion object : DeviceSpec<DemoDevice>() { // register virtual properties based on actual object state val timeScale by mutableProperty(MetaConverter.double, DemoDevice::timeScaleState) { metaDescriptor { type(ValueType.NUMBER) } info = "Real to virtual time scale" } val sinScale by mutableProperty(MetaConverter.double, DemoDevice::sinScaleState) val cosScale by mutableProperty(MetaConverter.double, DemoDevice::cosScaleState) Регистрация изменяемых свойств. Хранение состояния в экземпляре 3/16/2023 Controls-kt 17
  14. Добавим уюта val sin by doubleProperty { val time =

    Instant.now() kotlin.math.sin(time.toEpochMilli().toDouble() / timeScaleState) * sinScaleState } val cos by doubleProperty { val time = Instant.now() kotlin.math.cos(time.toEpochMilli().toDouble() / timeScaleState) * sinScaleState } override suspend fun DemoDevice.onOpen() { doRecurring(50.milliseconds) { sin.read() cos.read() } } Чтение «физического» свойства 3/16/2023 Controls-kt 18 https://github.com/SciProgCentre/controls.kt/blob/dev/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoDevice.kt Похожая концепция используется в Plotly.kt: https://www.youtube.com/live/8F0e_JaoUBU
  15. Добавим уюта val sin by doubleProperty { val time =

    Instant.now() kotlin.math.sin(time.toEpochMilli().toDouble() / timeScaleState) * sinScaleState } val cos by doubleProperty { val time = Instant.now() kotlin.math.cos(time.toEpochMilli().toDouble() / timeScaleState) * sinScaleState } override suspend fun DemoDevice.onOpen() { doRecurring(50.milliseconds) { sin.read() cos.read() } } Автоматически читаем свойство с той скоростью, с которой комфортно устройству. 3/16/2023 Controls-kt 19 https://github.com/SciProgCentre/controls.kt/blob/dev/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoDevice.kt
  16. Запись свойств button("Submit") { useMaxWidth = true action { controller.device?.run

    { launch { timeScale.write(timeScaleSlider.value) sinScale.write(xScaleSlider.value) cosScale.write(yScaleSlider.value) } } } } Входим в контекст устройства В контексте устройства используем типо-безопасный дескриптор для записи значения. public suspend fun <T> WritableDevicePropertySpec<D, T>.write(value: T) { invalidate(name) write(self, value) //perform asynchronous read and update after write launch { read() } } 3/16/2023 Controls-kt 20
  17. Работа с поротом 3/16/2023 Controls-kt 21 private val portDelegate =

    lazy { val ports = context.request(Ports) ports.buildPort(meta["port"] ?: error("Port is not defined in device configuration")).synchronous() } private val port: SynchronousPort by portDelegate private val responsePattern: Regex by lazy { ("@${address}ACK(.*);FF").toRegex() } private suspend fun talk(requestContent: String): String? = withTimeoutOrNull(5000) { val answer = port.respondStringWithDelimiter(String.format("@%s%s;FF", address, requestContent), ";FF") responsePattern.matchEntire(answer)?.groups?.get(1)?.value ?: error("Message $answer does not match $responsePattern") }
  18. Моделирование приборов • Создание модели прибора – важный этап разработки

    прибора, его отладки и поддержки. • Встраивание модели прибора в рабочую систему – важный этап отладки всей системы. 3/16/2023 Controls-kt 22 Photo by Atish Sewmangel on Unsplash
  19. Сервера устройств: выводы • Сервер устройства состоит из двух частей:

    спецификация и экземпляр. • Спецификация содержит описания свойств. • Экземпляр хранит логические свойства. • Используем делегаты в Kotlin для того, чтобы объявлять и сразу регистрировать свойства. • Делаем сервер устройства в 50 строк. 3/16/2023 Controls-kt 23
  20. Шина? Какая шина? • Большинство SCADA систем не используют шину

    данных. • Используются синхронные P2P запросы. • Клиент отличается от сервера. • SCADA система предоставляет сервисы для обнаружения устройств. Deadlock Bottleneck 3/16/2023 Controls-kt 25
  21. Асинхронная шина • Есть единый «сервер» - (распределенная) шина и

    множество «клиентов» с двусторонней коммуникацией. • Сообщения отправляются когда хочет клиент, а не когда спросили. Event bus send send send send send subscribe 3/16/2023 Controls-kt 26
  22. Что лучше? P2P • Синхронные запросы с гарантией доставки и

    гарантией времени отклика. • Плохо масштабируется. • Нужен сервис обнаружения устройств. • Каждое сообщение доставляется только адресату. Шина • Асинхронные события. Нет (в общем случае) гарантий доставки. • Хорошо масштабируется. • Не нужен сервис обнаружения устройств. • Сообщения доставляются всем, кто подписан. 3/16/2023 Controls-kt 27
  23. It’s Magix public interface MagixEndpoint { public fun subscribe( filter:

    MagixMessageFilter= MagixMessageFilter.ALL, ): Flow<MagixMessage> public suspend fun broadcast( message: MagixMessage, ) public fun close() } Подписка на события Отправка событий 3/16/2023 Controls-kt 28
  24. Magix server val magixFlow = MutableSharedFlow<MagixMessage>( replay = buffer, extraBufferCapacity

    = buffer, onBufferOverflow = BufferOverflow.DROP_OLDEST ) Волшебная штучка: { "id": 1235, "origin": "waltz", "format": "dataforge", "target": "192.168.111.132:8882", "payload":{ "type": "property.set", "targetDevice":"my-device", "property": "a", "value": 11, "comment": "pretty please!" } } Пример сообщения: https://github.com/waltz-controls/rfc Спецификация тут: 3/16/2023 Controls-kt 29
  25. Реализация для RSocket RSocketRequestHandler(coroutineContext) { //handler for request/stream requestStream {

    request: Payload -> val filter = magixJson.decodeFromString( MagixMessageFilter.serializer(), request.data.readText() ) magixFlow.filter(filter).map { message -> val string = magixJson.encodeToString(MagixMessage.serializer(), message) buildPayload { data(string) } } } //single send fireAndForget { request: Payload -> val message = magixJson.decodeFromString(MagixMessage.serializer(), request.data.readText()) magixFlow.emit(message) } } 3/16/2023 Controls-kt 30
  26. Реализация для RSocket RSocketRequestHandler(coroutineContext) { //handler for request/stream requestStream {

    request: Payload -> val filter = magixJson.decodeFromString( MagixMessageFilter.serializer(), request.data.readText() ) magixFlow.filter(filter).map { message -> val string = magixJson.encodeToString(MagixMessage.serializer(), message) buildPayload { data(string) } } } //single send fireAndForget { request: Payload -> val message = magixJson.decodeFromString(MagixMessage.serializer(), request.data.readText()) magixFlow.emit(message) } } 3/16/2023 Controls-kt 31 public fun subscribe( filter: MagixMessageFilter = MagixMessageFilter.ALL, ): Flow<MagixMessage>
  27. Реализация для RSocket RSocketRequestHandler(coroutineContext) { //handler for request/stream requestStream {

    request: Payload -> val filter = magixJson.decodeFromString( MagixMessageFilter.serializer(), request.data.readText() ) magixFlow.filter(filter).map { message -> val string = magixJson.encodeToString(MagixMessage.serializer(), message) buildPayload { data(string) } } } //single send fireAndForget { request: Payload -> val message = magixJson.decodeFromString(MagixMessage.serializer(), request.data.readText()) magixFlow.emit(message) } } 3/16/2023 Controls-kt 32 public suspend fun broadcast( message: MagixMessage, )
  28. “Биологическое” разнообразие • EPICS • Sun ONC (DOOCS) • CORBA

    (TANGO controls) • OPC-UA • Protobuf • HTTP/SSE • WebSocket • ZMQ • … 3/16/2023 Controls-kt 34
  29. Почему протокол – это сложно? • RPC протокол с безопасным

    типом требует схемы. • Схему надо передать всем участникам коммуникации. • Протокол надо поддержать на всех языках программирования, на которых написаны сервера устройств. • Если библиотека, обеспечивающая протокол «протухла», надо поддерживать ее самостоятельно. 3/16/2023 Controls-kt 35
  30. А почему один протокол? Message flow ZMQ Endpoint RSocket Endpoint

    HTTP Endpoint • Общая шина позволяет реализовывать подключения по разным протоколам. • Использование не-типизированных наполнений сообщений позволяет не думать о схеме. • Конвертация протоколов не бесплатная, но очень дешевая. 3/16/2023 Controls-kt 36
  31. Система как конструктор context.launch { device = deviceManager.install("demo", DemoDevice) //starting

    magix event loop magixServer = startMagixServer( RSocketMagixFlowPlugin(), //TCP rsocket support ZmqMagixFlowPlugin() //ZMQ support ) //Launch device client and connect it to the server val deviceEndpoint = MagixEndpoint.rSocketWithTcp("localhost") deviceManager.connectToMagix(deviceEndpoint) //connect visualization to a magix endpoint val visualEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost") visualizer = visualEndpoint.startDemoDeviceServer() //serve devices as OPC-UA namespace opcUaServer.startup() opcUaServer.serveDevices(deviceManager) } https://ideas.lego.com/projects/b383b238- c159-41e4-b4b9-7354240a890e 3/16/2023 Controls-kt 38
  32. Система как конструктор context.launch { device = deviceManager.install("demo", DemoDevice) //starting

    magix event loop magixServer = startMagixServer( RSocketMagixFlowPlugin(), //TCP rsocket support ZmqMagixFlowPlugin() //ZMQ support ) //Launch device client and connect it to the server val deviceEndpoint = MagixEndpoint.rSocketWithTcp("localhost") deviceManager.connectToMagix(deviceEndpoint) //connect visualization to a magix endpoint val visualEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost") visualizer = visualEndpoint.startDemoDeviceServer() //serve devices as OPC-UA namespace opcUaServer.startup() opcUaServer.serveDevices(deviceManager) } https://ideas.lego.com/projects/b383b238- c159-41e4-b4b9-7354240a890e 3/16/2023 Controls-kt 39
  33. Система как конструктор context.launch { device = deviceManager.install("demo", DemoDevice) //starting

    magix event loop magixServer = startMagixServer( RSocketMagixFlowPlugin(), //TCP rsocket support ZmqMagixFlowPlugin() //ZMQ support ) //Launch device client and connect it to the server val deviceEndpoint = MagixEndpoint.rSocketWithTcp("localhost") deviceManager.connectToMagix(deviceEndpoint) //connect visualization to a magix endpoint val visualEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost") visualizer = visualEndpoint.startDemoDeviceServer() //serve devices as OPC-UA namespace opcUaServer.startup() opcUaServer.serveDevices(deviceManager) } https://ideas.lego.com/projects/b383b238- c159-41e4-b4b9-7354240a890e 3/16/2023 Controls-kt 40
  34. Система как конструктор context.launch { device = deviceManager.install("demo", DemoDevice) //starting

    magix event loop magixServer = startMagixServer( RSocketMagixFlowPlugin(), //TCP rsocket support ZmqMagixFlowPlugin() //ZMQ support ) //Launch device client and connect it to the server val deviceEndpoint = MagixEndpoint.rSocketWithTcp("localhost") deviceManager.connectToMagix(deviceEndpoint) //connect visualization to a magix endpoint val visualEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost") visualizer = visualEndpoint.startDemoDeviceServer() //serve devices as OPC-UA namespace opcUaServer.startup() opcUaServer.serveDevices(deviceManager) } https://ideas.lego.com/projects/b383b238- c159-41e4-b4b9-7354240a890e 3/16/2023 Controls-kt 41
  35. Система как конструктор context.launch { device = deviceManager.install("demo", DemoDevice) //starting

    magix event loop magixServer = startMagixServer( RSocketMagixFlowPlugin(), //TCP rsocket support ZmqMagixFlowPlugin() //ZMQ support ) //Launch device client and connect it to the server val deviceEndpoint = MagixEndpoint.rSocketWithTcp("localhost") deviceManager.connectToMagix(deviceEndpoint) //connect visualization to a magix endpoint val visualEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost") visualizer = visualEndpoint.startDemoDeviceServer() //serve devices as OPC-UA namespace opcUaServer.startup() opcUaServer.serveDevices(deviceManager) } https://ideas.lego.com/projects/b383b238- c159-41e4-b4b9-7354240a890e 3/16/2023 Controls-kt 42
  36. Система как конструктор context.launch { device = deviceManager.install("demo", DemoDevice) //starting

    magix event loop magixServer = startMagixServer( RSocketMagixFlowPlugin(), //TCP rsocket support ZmqMagixFlowPlugin() //ZMQ support ) //Launch device client and connect it to the server val deviceEndpoint = MagixEndpoint.rSocketWithTcp("localhost") deviceManager.connectToMagix(deviceEndpoint) //connect visualization to a magix endpoint val visualEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost") visualizer = visualEndpoint.startDemoDeviceServer() //serve devices as OPC-UA namespace opcUaServer.startup() opcUaServer.serveDevices(deviceManager) } https://ideas.lego.com/projects/b383b238- c159-41e4-b4b9-7354240a890e 3/16/2023 Controls-kt 43
  37. Конвертер форматов public fun <T, R> CoroutineScope.launchMagixConverter( endpoint: MagixEndpoint, filter:

    MagixMessageFilter, outputFormat: String, newOrigin: String? = null, transformer: suspend (JsonElement) -> JsonElement, ): Job = endpoint.subscribe(filter).onEach { message-> val newPayload = transformer(message.payload) val transformed: MagixMessage = MagixMessage( outputFormat, newPayload, newOrigin ?: message.origin, message.target, message.id, message.parentId, message.user ) endpoint.broadcast(transformed) }.launchIn(this) Можно встроить конвертер в передатчик. Можно встроить конвертер в потребитель. Можно прицепить конвертер к шине и просто переводить все сообщения. 3/16/2023 Controls-kt 45
  38. Гарантии доставки Проблема • Асинхронная система (в отличие от синхронной)

    не дает гарантий доставки сообщений. • Сервисы могут самопроизвольно подключаться и отключаться от шины. • Или вовсе не поддерживать определенные типы сообщений. Решения • Использовать шину с гарантиями доставки (например Apache Kafka). • Использовать сервис для проверки подключения (watchdog). • Регулярно подавать признаки жизни (heartbeat). 3/16/2023 Controls-kt 49
  39. Много сообщений Проблема • В худшем случае на одно сообщение

    от источника N пересылок (где N – количество сервисов). • То есть общее количество пересылаемых сообщений 𝑁2 Решения • Использовать фильтр на стороне шины. • Использовать распределенную шину (если не знаешь что делать, бери железо посильнее). • Можно сегментировать шину… 3/16/2023 Controls-kt 50
  40. Большие бинарные данные Проблема • Шина не годится для передачи

    больших бинарных данных. Решения • Передавать по шине только сигнал о появлении файла. • Запрос файла делать напрямую к серверу устройства по другому протоколу. 3/16/2023 Controls-kt 51
  41. Выводы • Мир систем сбора данных большой и дивный (но

    не новый). • Хороших общепринятых решений там нет. • Те, что есть в основном синхронные. • Мы сделали конструктор на основе асинхронной шины. • И вроде получилось (сейчас стадия MVP). • Есть еще хранение, про него не успел рассказать. • И OPC-UA. 3/16/2023 Controls-kt 53
  42. Ссылки 3/16/2023 Controls-kt 54 • Сам проект: https://github.com/SciProgCentre/controls.kt • TANGO:

    https://www.tango-controls.org/ • EPICS: https://epics.anl.gov/ • Визуализация на Walz: https://github.com/waltz-controls/waltz • Обсудить: https://t.me/SciProgCentre