From 4a236a45bef33c10d9f4b3bdc1dbae6e5c69ac6e Mon Sep 17 00:00:00 2001 From: rstein Date: Fri, 4 Sep 2020 21:36:19 +0200 Subject: [PATCH] Initial OpenCMW commit --- .clang-format | 221 +++ .github/actions/get-version/action.yml | 25 + .github/actions/get-version/version.sh | 32 + .github/dependabot.yml | 8 + .github/workflows/maven.yml | 109 ++ .gitignore | 164 ++ .remarkrc.yaml | 5 + .restyled.yaml | 204 ++ .stylelintrc.json | 160 ++ CODE_OF_CONDUCT.md | 46 + CONTRIBUTING.md | 73 + Development.md | 9 + LICENSE | 165 ++ README.md | 0 assets/OpenCMW_logo.odg | Bin 0 -> 13518 bytes client/pom.xml | 50 + .../java/io/opencmw/client/DataSource.java | 118 ++ .../io/opencmw/client/DataSourceFilter.java | 63 + .../opencmw/client/DataSourcePublisher.java | 463 +++++ .../main/java/io/opencmw/client/Endpoint.java | 137 ++ .../client/cmwlight/CmwLightDataSource.java | 545 ++++++ .../client/cmwlight/CmwLightMessage.java | 427 +++++ .../client/cmwlight/CmwLightProtocol.java | 621 ++++++ .../client/cmwlight/DirectoryLightClient.java | 203 ++ .../opencmw/client/rest/RestDataSource.java | 458 +++++ client/src/main/java/module-info.java | 19 + .../io/opencmw/client/DataSourceExample.java | 45 + .../client/DataSourcePublisherTest.java | 299 +++ .../java/io/opencmw/client/EndpointTest.java | 21 + .../cmwlight/CmwLightDataSourceTest.java | 117 ++ .../client/cmwlight/CmwLightExample.java | 119 ++ .../client/cmwlight/CmwLightProtocolTest.java | 210 +++ .../java/io/opencmw/client/rest/Event.java | 41 + .../client/rest/EventSourceRecorder.java | 142 ++ .../client/rest/RestDataSourceTest.java | 251 +++ codecov.yml | 2 + concepts/pom.xml | 35 + .../aggregate/DemuxEventDispatcher.java | 130 ++ .../concepts/aggregate/TestEventSource.java | 155 ++ .../concepts/majordomo/MajordomoBroker.java | 647 +++++++ .../concepts/majordomo/MajordomoClientV1.java | 163 ++ .../concepts/majordomo/MajordomoClientV2.java | 118 ++ .../concepts/majordomo/MajordomoProtocol.java | 423 +++++ .../concepts/majordomo/MajordomoWorker.java | 299 +++ concepts/src/main/java/module-info.java | 9 + .../opencmw/concepts/BlockingQueueTests.java | 95 + .../io/opencmw/concepts/DisruptorTests.java | 115 ++ .../java/io/opencmw/concepts/FutureTests.java | 216 +++ .../concepts/ManyVsLargeFrameEvaluation.java | 281 +++ .../io/opencmw/concepts/PushPullTests.java | 121 ++ .../concepts/RestBehindRouterEvaluation.java | 210 +++ .../RoundTripAndNotifyEvaluation.java | 449 +++++ .../aggregate/DemuxEventDispatcherTest.java | 97 + .../concepts/majordomo/ClientSampleV1.java | 48 + .../concepts/majordomo/ClientSampleV2.java | 50 + .../majordomo/MajordomoBrokerTests.java | 253 +++ .../majordomo/SimpleEchoServiceWorker.java | 21 + .../test/resources/simplelogger.properties | 50 + config/hooks/pre-commit | 59 + config/hooks/pre-commit-stub | 15 + core/pom.xml | 51 + .../io/opencmw/AggregateEventHandler.java | 359 ++++ core/src/main/java/io/opencmw/EventStore.java | 450 +++++ core/src/main/java/io/opencmw/Filter.java | 31 + .../main/java/io/opencmw/FilterPredicate.java | 40 + .../java/io/opencmw/HistoryEventHandler.java | 23 + core/src/main/java/io/opencmw/MimeType.java | 242 +++ .../main/java/io/opencmw/OpenCmwProtocol.java | 526 ++++++ .../java/io/opencmw/QueryParameterParser.java | 296 +++ .../main/java/io/opencmw/RingBufferEvent.java | 222 +++ .../java/io/opencmw/domain/BinaryData.java | 130 ++ .../main/java/io/opencmw/domain/NoData.java | 10 + .../java/io/opencmw/filter/EvtTypeFilter.java | 96 + .../java/io/opencmw/filter/TimingCtx.java | 206 ++ .../java/io/opencmw/rbac/BasicRbacRole.java | 45 + .../java/io/opencmw/rbac/RbacProvider.java | 7 + .../main/java/io/opencmw/rbac/RbacRole.java | 70 + .../main/java/io/opencmw/rbac/RbacToken.java | 66 + .../main/java/io/opencmw/utils/AnsiDefs.java | 17 + .../src/main/java/io/opencmw/utils/Cache.java | 374 ++++ .../java/io/opencmw/utils/CustomFuture.java | 118 ++ .../io/opencmw/utils/LimitedArrayList.java | 54 + .../io/opencmw/utils/NoDuplicatesList.java | 42 + .../java/io/opencmw/utils/SharedPointer.java | 82 + .../io/opencmw/utils/SystemProperties.java | 62 + .../io/opencmw/utils/WorkerThreadFactory.java | 74 + core/src/main/java/module-info.java | 15 + .../AggregateEventHandlerTestSource.java | 153 ++ .../opencmw/AggregateEventHandlerTests.java | 179 ++ .../test/java/io/opencmw/EventStoreTest.java | 352 ++++ .../test/java/io/opencmw/MimeTypeTests.java | 85 + .../java/io/opencmw/OpenCmwProtocolTests.java | 95 + .../io/opencmw/QueryParameterParserTest.java | 226 +++ .../java/io/opencmw/RingBufferEventTests.java | 135 ++ .../io/opencmw/domain/BinaryDataTest.java | 110 ++ .../io/opencmw/filter/EvtTypeFilterTests.java | 77 + .../io/opencmw/filter/TimingCtxTests.java | 163 ++ .../io/opencmw/rbac/BasicRbacRoleTest.java | 9 + .../java/io/opencmw/utils/CacheTests.java | 226 +++ .../io/opencmw/utils/CustomFutureTests.java | 157 ++ .../opencmw/utils/LimitedArrayListTests.java | 48 + .../io/opencmw/utils/SharedPointerTests.java | 41 + .../test/resources/simplelogger.properties | 50 + docs/CmwLight.md | 172 ++ docs/IoSerialiser.md | 303 +++ docs/MajordomoProtocol.md | 11 + docs/Majordomo_protocol_comparison.ods | Bin 0 -> 23520 bytes docs/Majordomo_protocol_comparison.pdf | Bin 0 -> 76379 bytes docs/Majordomo_protocol_comparison.png | Bin 0 -> 426112 bytes formatFiles.sh | 58 + formatLastCommit.sh | 62 + pom.xml | 368 ++++ ruleset.xml | 103 + serialiser/pom.xml | 71 + .../main/java/io/opencmw/serialiser/Cat.java | 12 + .../java/io/opencmw/serialiser/DataType.java | 218 +++ .../opencmw/serialiser/FieldDescription.java | 94 + .../opencmw/serialiser/FieldSerialiser.java | 172 ++ .../java/io/opencmw/serialiser/IoBuffer.java | 209 ++ .../io/opencmw/serialiser/IoBufferHeader.java | 160 ++ .../opencmw/serialiser/IoClassSerialiser.java | 627 ++++++ .../io/opencmw/serialiser/IoSerialiser.java | 382 ++++ .../serialiser/annotations/Description.java | 12 + .../serialiser/annotations/Direction.java | 12 + .../serialiser/annotations/Groups.java | 12 + .../serialiser/annotations/MetaInfo.java | 15 + .../opencmw/serialiser/annotations/Unit.java | 12 + .../serialiser/spi/BinarySerialiser.java | 1679 +++++++++++++++++ .../io/opencmw/serialiser/spi/ByteBuffer.java | 599 ++++++ .../serialiser/spi/ClassFieldDescription.java | 862 +++++++++ .../serialiser/spi/CmwLightSerialiser.java | 1135 +++++++++++ .../serialiser/spi/FastByteBuffer.java | 1088 +++++++++++ .../serialiser/spi/JsonSerialiser.java | 933 +++++++++ .../opencmw/serialiser/spi/ProtocolInfo.java | 65 + .../spi/WireDataFieldDescription.java | 314 +++ .../spi/iobuffer/DataSetSerialiser.java | 518 +++++ .../iobuffer/FieldBoxedValueArrayHelper.java | 75 + .../spi/iobuffer/FieldBoxedValueHelper.java | 68 + .../spi/iobuffer/FieldCollectionsHelper.java | 63 + .../spi/iobuffer/FieldDataSetHelper.java | 47 + .../iobuffer/FieldListAxisDescription.java | 76 + .../spi/iobuffer/FieldMapHelper.java | 32 + .../spi/iobuffer/FieldMultiArrayHelper.java | 189 ++ .../iobuffer/FieldPrimitiveValueHelper.java | 78 + .../FieldPrimitveValueArrayHelper.java | 80 + .../opencmw/serialiser/utils/AssertUtils.java | 393 ++++ .../serialiser/utils/ByteArrayCache.java | 85 + .../serialiser/utils/CacheCollection.java | 153 ++ .../opencmw/serialiser/utils/ClassUtils.java | 271 +++ .../serialiser/utils/GenericsHelper.java | 325 ++++ serialiser/src/main/java/module-info.java | 14 + .../IoClassSerialiserSimpleTest.java | 42 + .../serialiser/IoClassSerialiserTests.java | 399 ++++ .../opencmw/serialiser/IoSerialiserTests.java | 345 ++++ .../SerialiserAnnotationTests.java | 105 ++ .../benchmark/DataSetSerialiserBenchmark.java | 114 ++ .../benchmark/FastByteBufferBenchmark.java | 37 + .../benchmark/JsonSelectionBenchmark.java | 128 ++ .../benchmark/ReflectionBenchmark.java | 192 ++ .../SerialiserAssumptionsBenchmark.java | 271 +++ .../benchmark/SerialiserBenchmark.java | 196 ++ .../benchmark/SerialiserQuickBenchmark.java | 113 ++ .../serialiser/spi/BinarySerialiserTests.java | 714 +++++++ .../spi/CmwLightSerialiserTests.java | 26 + .../opencmw/serialiser/spi/IoBufferTests.java | 561 ++++++ .../serialiser/spi/JsonSerialiserTests.java | 305 +++ .../serialiser/spi/helper/MyGenericClass.java | 547 ++++++ .../spi/iobuffer/DataSetSerialiserTests.java | 433 +++++ .../opencmw/serialiser/utils/CmwHelper.java | 181 ++ .../serialiser/utils/CmwLightHelper.java | 461 +++++ .../serialiser/utils/FlatBuffersHelper.java | 355 ++++ .../serialiser/utils/GenericsHelperTests.java | 76 + .../opencmw/serialiser/utils/JsonHelper.java | 278 +++ .../serialiser/utils/SerialiserHelper.java | 355 ++++ .../serialiser/utils/TestDataClass.java | 488 +++++ .../test/resources/simplelogger.properties | 50 + server-rest/pom.xml | 122 ++ .../server/rest/MajordomoRestPlugin.java | 491 +++++ .../server/rest/RestCommonThreadPool.java | 69 + .../java/io/opencmw/server/rest/RestRole.java | 23 + .../io/opencmw/server/rest/RestServer.java | 497 +++++ .../server/rest/admin/RestServerAdmin.java | 93 + .../server/rest/login/LoginController.java | 305 +++ .../io/opencmw/server/rest/user/RestUser.java | 29 + .../server/rest/user/RestUserHandler.java | 43 + .../server/rest/user/RestUserHandlerImpl.java | 203 ++ .../server/rest/util/CombinedHandler.java | 111 ++ .../server/rest/util/MessageBundle.java | 38 + .../resources/DefaultRestUserPasswords.pwd | 6 + server-rest/src/main/resources/keystore.jks | Bin 0 -> 2619 bytes server-rest/src/main/resources/keystore.pwd | 1 + .../localisation/messages_de.properties | 36 + .../localisation/messages_en.properties | 36 + .../src/main/resources/public/img/english.png | Bin 0 -> 56203 bytes .../src/main/resources/public/img/german.png | Bin 0 -> 52123 bytes .../src/main/resources/public/img/logo_b.png | Bin 0 -> 19370 bytes .../src/main/resources/public/img/logo_w.png | Bin 0 -> 16137 bytes .../src/main/resources/public/main.css | 397 ++++ .../main/resources/velocity/admin/admin.vm | 14 + .../main/resources/velocity/clipboard/all.vm | 25 + .../resources/velocity/clipboard/one_long.vm | 86 + .../resources/velocity/clipboard/one_sse.vm | 111 ++ .../resources/velocity/clipboard/upload.vm | 17 + .../resources/velocity/errors/accessDenied.vm | 4 + .../resources/velocity/errors/badRequest.vm | 8 + .../resources/velocity/errors/notFound.vm | 4 + .../resources/velocity/errors/unauthorised.vm | 4 + .../src/main/resources/velocity/layout.vm | 56 + .../main/resources/velocity/layoutNoFrame.vm | 18 + .../velocity/login/changePassword.vm | 28 + .../main/resources/velocity/login/login.vm | 22 + .../property/defaultTextPropertyLayout.vm | 11 + .../velocityconfig/velocity_implicit.vm | 9 + .../server/rest/MajordomoRestPluginTests.java | 228 +++ .../opencmw/server/rest/RestServerTests.java | 25 + .../server/rest/helper/ReplyDataType.java | 35 + .../server/rest/helper/ReplyOption.java | 11 + .../server/rest/helper/RequestDataType.java | 48 + .../server/rest/helper/TestContext.java | 23 + .../samples/MajordomoRestPluginSample.java | 43 + .../server/rest/test/HelloWorldService.java | 55 + .../server/rest/test/ImageService.java | 76 + .../test/testimages/PM5544_test_signal.png | Bin 0 -> 15118 bytes .../rest/test/testimages/SMPTE_Color_Bars.png | Bin 0 -> 1611 bytes .../test/resources/simplelogger.properties | 50 + server/pom.xml | 40 + .../io/opencmw/server/BasicMdpWorker.java | 377 ++++ .../io/opencmw/server/DefaultHtmlHandler.java | 119 ++ .../io/opencmw/server/MajordomoBroker.java | 916 +++++++++ .../io/opencmw/server/MajordomoWorker.java | 203 ++ .../io/opencmw/server/MmiServiceHelper.java | 89 + server/src/main/java/module-info.java | 12 + .../property/defaultPropertyLayout.vm | 218 +++ .../io/opencmw/server/BasicMdpWorkerTest.java | 111 ++ .../opencmw/server/MajordomoBrokerTests.java | 368 ++++ .../server/MajordomoTestClientAsync.java | 120 ++ .../server/MajordomoTestClientSync.java | 134 ++ .../opencmw/server/MajordomoWorkerTests.java | 409 ++++ .../opencmw/server/MmiServiceHelperTests.java | 151 ++ .../test/resources/simplelogger.properties | 50 + setupGitHooks.sh | 4 + 241 files changed, 41276 insertions(+) create mode 100644 .clang-format create mode 100644 .github/actions/get-version/action.yml create mode 100755 .github/actions/get-version/version.sh create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/maven.yml create mode 100644 .gitignore create mode 100644 .remarkrc.yaml create mode 100644 .restyled.yaml create mode 100644 .stylelintrc.json create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 Development.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 assets/OpenCMW_logo.odg create mode 100644 client/pom.xml create mode 100644 client/src/main/java/io/opencmw/client/DataSource.java create mode 100644 client/src/main/java/io/opencmw/client/DataSourceFilter.java create mode 100644 client/src/main/java/io/opencmw/client/DataSourcePublisher.java create mode 100644 client/src/main/java/io/opencmw/client/Endpoint.java create mode 100644 client/src/main/java/io/opencmw/client/cmwlight/CmwLightDataSource.java create mode 100644 client/src/main/java/io/opencmw/client/cmwlight/CmwLightMessage.java create mode 100644 client/src/main/java/io/opencmw/client/cmwlight/CmwLightProtocol.java create mode 100644 client/src/main/java/io/opencmw/client/cmwlight/DirectoryLightClient.java create mode 100644 client/src/main/java/io/opencmw/client/rest/RestDataSource.java create mode 100644 client/src/main/java/module-info.java create mode 100644 client/src/test/java/io/opencmw/client/DataSourceExample.java create mode 100644 client/src/test/java/io/opencmw/client/DataSourcePublisherTest.java create mode 100644 client/src/test/java/io/opencmw/client/EndpointTest.java create mode 100644 client/src/test/java/io/opencmw/client/cmwlight/CmwLightDataSourceTest.java create mode 100644 client/src/test/java/io/opencmw/client/cmwlight/CmwLightExample.java create mode 100644 client/src/test/java/io/opencmw/client/cmwlight/CmwLightProtocolTest.java create mode 100644 client/src/test/java/io/opencmw/client/rest/Event.java create mode 100644 client/src/test/java/io/opencmw/client/rest/EventSourceRecorder.java create mode 100644 client/src/test/java/io/opencmw/client/rest/RestDataSourceTest.java create mode 100644 codecov.yml create mode 100644 concepts/pom.xml create mode 100644 concepts/src/main/java/io/opencmw/concepts/aggregate/DemuxEventDispatcher.java create mode 100644 concepts/src/main/java/io/opencmw/concepts/aggregate/TestEventSource.java create mode 100644 concepts/src/main/java/io/opencmw/concepts/majordomo/MajordomoBroker.java create mode 100644 concepts/src/main/java/io/opencmw/concepts/majordomo/MajordomoClientV1.java create mode 100644 concepts/src/main/java/io/opencmw/concepts/majordomo/MajordomoClientV2.java create mode 100644 concepts/src/main/java/io/opencmw/concepts/majordomo/MajordomoProtocol.java create mode 100644 concepts/src/main/java/io/opencmw/concepts/majordomo/MajordomoWorker.java create mode 100644 concepts/src/main/java/module-info.java create mode 100644 concepts/src/test/java/io/opencmw/concepts/BlockingQueueTests.java create mode 100644 concepts/src/test/java/io/opencmw/concepts/DisruptorTests.java create mode 100644 concepts/src/test/java/io/opencmw/concepts/FutureTests.java create mode 100644 concepts/src/test/java/io/opencmw/concepts/ManyVsLargeFrameEvaluation.java create mode 100644 concepts/src/test/java/io/opencmw/concepts/PushPullTests.java create mode 100644 concepts/src/test/java/io/opencmw/concepts/RestBehindRouterEvaluation.java create mode 100644 concepts/src/test/java/io/opencmw/concepts/RoundTripAndNotifyEvaluation.java create mode 100644 concepts/src/test/java/io/opencmw/concepts/aggregate/DemuxEventDispatcherTest.java create mode 100644 concepts/src/test/java/io/opencmw/concepts/majordomo/ClientSampleV1.java create mode 100644 concepts/src/test/java/io/opencmw/concepts/majordomo/ClientSampleV2.java create mode 100644 concepts/src/test/java/io/opencmw/concepts/majordomo/MajordomoBrokerTests.java create mode 100644 concepts/src/test/java/io/opencmw/concepts/majordomo/SimpleEchoServiceWorker.java create mode 100644 concepts/src/test/resources/simplelogger.properties create mode 100755 config/hooks/pre-commit create mode 100755 config/hooks/pre-commit-stub create mode 100644 core/pom.xml create mode 100644 core/src/main/java/io/opencmw/AggregateEventHandler.java create mode 100644 core/src/main/java/io/opencmw/EventStore.java create mode 100644 core/src/main/java/io/opencmw/Filter.java create mode 100644 core/src/main/java/io/opencmw/FilterPredicate.java create mode 100644 core/src/main/java/io/opencmw/HistoryEventHandler.java create mode 100644 core/src/main/java/io/opencmw/MimeType.java create mode 100644 core/src/main/java/io/opencmw/OpenCmwProtocol.java create mode 100644 core/src/main/java/io/opencmw/QueryParameterParser.java create mode 100644 core/src/main/java/io/opencmw/RingBufferEvent.java create mode 100644 core/src/main/java/io/opencmw/domain/BinaryData.java create mode 100644 core/src/main/java/io/opencmw/domain/NoData.java create mode 100644 core/src/main/java/io/opencmw/filter/EvtTypeFilter.java create mode 100644 core/src/main/java/io/opencmw/filter/TimingCtx.java create mode 100644 core/src/main/java/io/opencmw/rbac/BasicRbacRole.java create mode 100644 core/src/main/java/io/opencmw/rbac/RbacProvider.java create mode 100644 core/src/main/java/io/opencmw/rbac/RbacRole.java create mode 100644 core/src/main/java/io/opencmw/rbac/RbacToken.java create mode 100644 core/src/main/java/io/opencmw/utils/AnsiDefs.java create mode 100644 core/src/main/java/io/opencmw/utils/Cache.java create mode 100644 core/src/main/java/io/opencmw/utils/CustomFuture.java create mode 100644 core/src/main/java/io/opencmw/utils/LimitedArrayList.java create mode 100644 core/src/main/java/io/opencmw/utils/NoDuplicatesList.java create mode 100644 core/src/main/java/io/opencmw/utils/SharedPointer.java create mode 100644 core/src/main/java/io/opencmw/utils/SystemProperties.java create mode 100644 core/src/main/java/io/opencmw/utils/WorkerThreadFactory.java create mode 100644 core/src/main/java/module-info.java create mode 100644 core/src/test/java/io/opencmw/AggregateEventHandlerTestSource.java create mode 100644 core/src/test/java/io/opencmw/AggregateEventHandlerTests.java create mode 100644 core/src/test/java/io/opencmw/EventStoreTest.java create mode 100644 core/src/test/java/io/opencmw/MimeTypeTests.java create mode 100644 core/src/test/java/io/opencmw/OpenCmwProtocolTests.java create mode 100644 core/src/test/java/io/opencmw/QueryParameterParserTest.java create mode 100644 core/src/test/java/io/opencmw/RingBufferEventTests.java create mode 100644 core/src/test/java/io/opencmw/domain/BinaryDataTest.java create mode 100644 core/src/test/java/io/opencmw/filter/EvtTypeFilterTests.java create mode 100644 core/src/test/java/io/opencmw/filter/TimingCtxTests.java create mode 100644 core/src/test/java/io/opencmw/rbac/BasicRbacRoleTest.java create mode 100644 core/src/test/java/io/opencmw/utils/CacheTests.java create mode 100644 core/src/test/java/io/opencmw/utils/CustomFutureTests.java create mode 100644 core/src/test/java/io/opencmw/utils/LimitedArrayListTests.java create mode 100644 core/src/test/java/io/opencmw/utils/SharedPointerTests.java create mode 100644 core/src/test/resources/simplelogger.properties create mode 100644 docs/CmwLight.md create mode 100644 docs/IoSerialiser.md create mode 100644 docs/MajordomoProtocol.md create mode 100644 docs/Majordomo_protocol_comparison.ods create mode 100644 docs/Majordomo_protocol_comparison.pdf create mode 100644 docs/Majordomo_protocol_comparison.png create mode 100755 formatFiles.sh create mode 100755 formatLastCommit.sh create mode 100644 pom.xml create mode 100644 ruleset.xml create mode 100644 serialiser/pom.xml create mode 100644 serialiser/src/main/java/io/opencmw/serialiser/Cat.java create mode 100644 serialiser/src/main/java/io/opencmw/serialiser/DataType.java create mode 100644 serialiser/src/main/java/io/opencmw/serialiser/FieldDescription.java create mode 100644 serialiser/src/main/java/io/opencmw/serialiser/FieldSerialiser.java create mode 100644 serialiser/src/main/java/io/opencmw/serialiser/IoBuffer.java create mode 100644 serialiser/src/main/java/io/opencmw/serialiser/IoBufferHeader.java create mode 100644 serialiser/src/main/java/io/opencmw/serialiser/IoClassSerialiser.java create mode 100644 serialiser/src/main/java/io/opencmw/serialiser/IoSerialiser.java create mode 100644 serialiser/src/main/java/io/opencmw/serialiser/annotations/Description.java create mode 100644 serialiser/src/main/java/io/opencmw/serialiser/annotations/Direction.java create mode 100644 serialiser/src/main/java/io/opencmw/serialiser/annotations/Groups.java create mode 100644 serialiser/src/main/java/io/opencmw/serialiser/annotations/MetaInfo.java create mode 100644 serialiser/src/main/java/io/opencmw/serialiser/annotations/Unit.java create mode 100644 serialiser/src/main/java/io/opencmw/serialiser/spi/BinarySerialiser.java create mode 100644 serialiser/src/main/java/io/opencmw/serialiser/spi/ByteBuffer.java create mode 100644 serialiser/src/main/java/io/opencmw/serialiser/spi/ClassFieldDescription.java create mode 100644 serialiser/src/main/java/io/opencmw/serialiser/spi/CmwLightSerialiser.java create mode 100644 serialiser/src/main/java/io/opencmw/serialiser/spi/FastByteBuffer.java create mode 100644 serialiser/src/main/java/io/opencmw/serialiser/spi/JsonSerialiser.java create mode 100644 serialiser/src/main/java/io/opencmw/serialiser/spi/ProtocolInfo.java create mode 100644 serialiser/src/main/java/io/opencmw/serialiser/spi/WireDataFieldDescription.java create mode 100644 serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/DataSetSerialiser.java create mode 100644 serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/FieldBoxedValueArrayHelper.java create mode 100644 serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/FieldBoxedValueHelper.java create mode 100644 serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/FieldCollectionsHelper.java create mode 100644 serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/FieldDataSetHelper.java create mode 100644 serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/FieldListAxisDescription.java create mode 100644 serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/FieldMapHelper.java create mode 100644 serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/FieldMultiArrayHelper.java create mode 100644 serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/FieldPrimitiveValueHelper.java create mode 100644 serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/FieldPrimitveValueArrayHelper.java create mode 100644 serialiser/src/main/java/io/opencmw/serialiser/utils/AssertUtils.java create mode 100644 serialiser/src/main/java/io/opencmw/serialiser/utils/ByteArrayCache.java create mode 100644 serialiser/src/main/java/io/opencmw/serialiser/utils/CacheCollection.java create mode 100644 serialiser/src/main/java/io/opencmw/serialiser/utils/ClassUtils.java create mode 100644 serialiser/src/main/java/io/opencmw/serialiser/utils/GenericsHelper.java create mode 100644 serialiser/src/main/java/module-info.java create mode 100644 serialiser/src/test/java/io/opencmw/serialiser/IoClassSerialiserSimpleTest.java create mode 100644 serialiser/src/test/java/io/opencmw/serialiser/IoClassSerialiserTests.java create mode 100644 serialiser/src/test/java/io/opencmw/serialiser/IoSerialiserTests.java create mode 100644 serialiser/src/test/java/io/opencmw/serialiser/annotations/SerialiserAnnotationTests.java create mode 100644 serialiser/src/test/java/io/opencmw/serialiser/benchmark/DataSetSerialiserBenchmark.java create mode 100644 serialiser/src/test/java/io/opencmw/serialiser/benchmark/FastByteBufferBenchmark.java create mode 100644 serialiser/src/test/java/io/opencmw/serialiser/benchmark/JsonSelectionBenchmark.java create mode 100644 serialiser/src/test/java/io/opencmw/serialiser/benchmark/ReflectionBenchmark.java create mode 100644 serialiser/src/test/java/io/opencmw/serialiser/benchmark/SerialiserAssumptionsBenchmark.java create mode 100644 serialiser/src/test/java/io/opencmw/serialiser/benchmark/SerialiserBenchmark.java create mode 100644 serialiser/src/test/java/io/opencmw/serialiser/benchmark/SerialiserQuickBenchmark.java create mode 100644 serialiser/src/test/java/io/opencmw/serialiser/spi/BinarySerialiserTests.java create mode 100644 serialiser/src/test/java/io/opencmw/serialiser/spi/CmwLightSerialiserTests.java create mode 100644 serialiser/src/test/java/io/opencmw/serialiser/spi/IoBufferTests.java create mode 100644 serialiser/src/test/java/io/opencmw/serialiser/spi/JsonSerialiserTests.java create mode 100644 serialiser/src/test/java/io/opencmw/serialiser/spi/helper/MyGenericClass.java create mode 100644 serialiser/src/test/java/io/opencmw/serialiser/spi/iobuffer/DataSetSerialiserTests.java create mode 100644 serialiser/src/test/java/io/opencmw/serialiser/utils/CmwHelper.java create mode 100644 serialiser/src/test/java/io/opencmw/serialiser/utils/CmwLightHelper.java create mode 100644 serialiser/src/test/java/io/opencmw/serialiser/utils/FlatBuffersHelper.java create mode 100644 serialiser/src/test/java/io/opencmw/serialiser/utils/GenericsHelperTests.java create mode 100644 serialiser/src/test/java/io/opencmw/serialiser/utils/JsonHelper.java create mode 100644 serialiser/src/test/java/io/opencmw/serialiser/utils/SerialiserHelper.java create mode 100644 serialiser/src/test/java/io/opencmw/serialiser/utils/TestDataClass.java create mode 100644 serialiser/src/test/resources/simplelogger.properties create mode 100644 server-rest/pom.xml create mode 100644 server-rest/src/main/java/io/opencmw/server/rest/MajordomoRestPlugin.java create mode 100644 server-rest/src/main/java/io/opencmw/server/rest/RestCommonThreadPool.java create mode 100644 server-rest/src/main/java/io/opencmw/server/rest/RestRole.java create mode 100644 server-rest/src/main/java/io/opencmw/server/rest/RestServer.java create mode 100644 server-rest/src/main/java/io/opencmw/server/rest/admin/RestServerAdmin.java create mode 100644 server-rest/src/main/java/io/opencmw/server/rest/login/LoginController.java create mode 100644 server-rest/src/main/java/io/opencmw/server/rest/user/RestUser.java create mode 100644 server-rest/src/main/java/io/opencmw/server/rest/user/RestUserHandler.java create mode 100644 server-rest/src/main/java/io/opencmw/server/rest/user/RestUserHandlerImpl.java create mode 100644 server-rest/src/main/java/io/opencmw/server/rest/util/CombinedHandler.java create mode 100644 server-rest/src/main/java/io/opencmw/server/rest/util/MessageBundle.java create mode 100644 server-rest/src/main/resources/DefaultRestUserPasswords.pwd create mode 100644 server-rest/src/main/resources/keystore.jks create mode 100644 server-rest/src/main/resources/keystore.pwd create mode 100644 server-rest/src/main/resources/localisation/messages_de.properties create mode 100644 server-rest/src/main/resources/localisation/messages_en.properties create mode 100644 server-rest/src/main/resources/public/img/english.png create mode 100644 server-rest/src/main/resources/public/img/german.png create mode 100644 server-rest/src/main/resources/public/img/logo_b.png create mode 100644 server-rest/src/main/resources/public/img/logo_w.png create mode 100644 server-rest/src/main/resources/public/main.css create mode 100644 server-rest/src/main/resources/velocity/admin/admin.vm create mode 100644 server-rest/src/main/resources/velocity/clipboard/all.vm create mode 100644 server-rest/src/main/resources/velocity/clipboard/one_long.vm create mode 100644 server-rest/src/main/resources/velocity/clipboard/one_sse.vm create mode 100644 server-rest/src/main/resources/velocity/clipboard/upload.vm create mode 100644 server-rest/src/main/resources/velocity/errors/accessDenied.vm create mode 100644 server-rest/src/main/resources/velocity/errors/badRequest.vm create mode 100644 server-rest/src/main/resources/velocity/errors/notFound.vm create mode 100644 server-rest/src/main/resources/velocity/errors/unauthorised.vm create mode 100644 server-rest/src/main/resources/velocity/layout.vm create mode 100644 server-rest/src/main/resources/velocity/layoutNoFrame.vm create mode 100644 server-rest/src/main/resources/velocity/login/changePassword.vm create mode 100644 server-rest/src/main/resources/velocity/login/login.vm create mode 100644 server-rest/src/main/resources/velocity/property/defaultTextPropertyLayout.vm create mode 100644 server-rest/src/main/resources/velocityconfig/velocity_implicit.vm create mode 100644 server-rest/src/test/java/io/opencmw/server/rest/MajordomoRestPluginTests.java create mode 100644 server-rest/src/test/java/io/opencmw/server/rest/RestServerTests.java create mode 100644 server-rest/src/test/java/io/opencmw/server/rest/helper/ReplyDataType.java create mode 100644 server-rest/src/test/java/io/opencmw/server/rest/helper/ReplyOption.java create mode 100644 server-rest/src/test/java/io/opencmw/server/rest/helper/RequestDataType.java create mode 100644 server-rest/src/test/java/io/opencmw/server/rest/helper/TestContext.java create mode 100644 server-rest/src/test/java/io/opencmw/server/rest/samples/MajordomoRestPluginSample.java create mode 100644 server-rest/src/test/java/io/opencmw/server/rest/test/HelloWorldService.java create mode 100644 server-rest/src/test/java/io/opencmw/server/rest/test/ImageService.java create mode 100644 server-rest/src/test/resources/io/opencmw/server/rest/test/testimages/PM5544_test_signal.png create mode 100644 server-rest/src/test/resources/io/opencmw/server/rest/test/testimages/SMPTE_Color_Bars.png create mode 100644 server-rest/src/test/resources/simplelogger.properties create mode 100644 server/pom.xml create mode 100644 server/src/main/java/io/opencmw/server/BasicMdpWorker.java create mode 100644 server/src/main/java/io/opencmw/server/DefaultHtmlHandler.java create mode 100644 server/src/main/java/io/opencmw/server/MajordomoBroker.java create mode 100644 server/src/main/java/io/opencmw/server/MajordomoWorker.java create mode 100644 server/src/main/java/io/opencmw/server/MmiServiceHelper.java create mode 100644 server/src/main/java/module-info.java create mode 100644 server/src/main/resources/velocity/property/defaultPropertyLayout.vm create mode 100644 server/src/test/java/io/opencmw/server/BasicMdpWorkerTest.java create mode 100644 server/src/test/java/io/opencmw/server/MajordomoBrokerTests.java create mode 100644 server/src/test/java/io/opencmw/server/MajordomoTestClientAsync.java create mode 100644 server/src/test/java/io/opencmw/server/MajordomoTestClientSync.java create mode 100644 server/src/test/java/io/opencmw/server/MajordomoWorkerTests.java create mode 100644 server/src/test/java/io/opencmw/server/MmiServiceHelperTests.java create mode 100644 server/src/test/resources/simplelogger.properties create mode 100755 setupGitHooks.sh diff --git a/.clang-format b/.clang-format new file mode 100644 index 00000000..f41fe296 --- /dev/null +++ b/.clang-format @@ -0,0 +1,221 @@ +# .clang-format for Qt Creator +# +# This is for clang-format >= 5.0. +# +# The configuration below follows the Qt Creator Coding Rules [1] as closely as +# possible. For documentation of the options, see [2]. +# +# Use ../../tests/manual/clang-format-for-qtc/test.cpp for documenting problems +# or testing changes. +# +# In case you update this configuration please also update the qtcStyle() in src\plugins\clangformat\clangformatutils.cpp +# +# [1] https://doc-snapshots.qt.io/qtcreator-extending/coding-style.html +# [2] https://clang.llvm.org/docs/ClangFormatStyleOptions.html +# +--- +Language: Cpp +AccessModifierOffset: -4 +AlignAfterOpenBracket: DontAlign +AlignConsecutiveAssignments: false +AlignConsecutiveDeclarations: false +AlignEscapedNewlines: DontAlign +AlignOperands: true +AlignTrailingComments: false +AllowAllParametersOfDeclarationOnNextLine: true +AllowShortLambdasOnASingleLine: None +AllowShortCaseLabelsOnASingleLine: false +AllowShortFunctionsOnASingleLine: None +AllowShortIfStatementsOnASingleLine: Never +AllowShortLoopsOnASingleLine: false +AlwaysBreakAfterReturnType: None +AlwaysBreakBeforeMultilineStrings: false +AlwaysBreakTemplateDeclarations: true +BinPackArguments: true +BinPackParameters: true +BraceWrapping: + AfterClass: false + AfterControlStatement: false + AfterEnum: false + AfterFunction: false + AfterNamespace: false + AfterObjCDeclaration: false + AfterStruct: false + AfterUnion: false + BeforeCatch: false + BeforeElse: false + IndentBraces: false + SplitEmptyFunction: true + SplitEmptyRecord: true + SplitEmptyNamespace: true +BreakBeforeBinaryOperators: All +BreakBeforeBraces: Custom +BreakBeforeInheritanceComma: false +BreakBeforeTernaryOperators: true +BreakConstructorInitializersBeforeComma: false +BreakConstructorInitializers: BeforeComma +BreakAfterJavaFieldAnnotations: true +BreakStringLiterals: true +ColumnLimit: 0 +CommentPragmas: '^ IWYU pragma:' +CompactNamespaces: false +ConstructorInitializerAllOnOneLineOrOnePerLine: false +ConstructorInitializerIndentWidth: 4 +ContinuationIndentWidth: 8 +Cpp11BracedListStyle: false +DerivePointerAlignment: false +DisableFormat: false +ExperimentalAutoDetectBinPacking: false +FixNamespaceComments: true +ForEachMacros: + - forever # avoids { wrapped to next line + - foreach + - Q_FOREACH + - BOOST_FOREACH +IncludeCategories: + - Regex: '^ + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..e69de29b diff --git a/assets/OpenCMW_logo.odg b/assets/OpenCMW_logo.odg new file mode 100644 index 0000000000000000000000000000000000000000..566d37064f156c275c14d0c88a15e65b38c2ebbf GIT binary patch literal 13518 zcmb7r1y~$OxAqX+U4y$rfS^HxySuv&?(Qyu48b9Ia1S0lKyY^p?(WP_cAvew``z7p z|9j@?uIlP~-&57yQ+ke~3?vj5000L7z`MvQXG1dgFaQ96U&l)lz|P9f%*E5e%*esP z*2=`l#R_Q8;%0Bk3^a1Ka%KiPnAw{GO|K~GoQxbStxTL1|3>)2@n3@X5_=D{ zH@C8Ib^49UnT^E-2(&dea$@ORk-eGi|9~U>Cw)#p3nw#Y=l^?2on4GvT>lpw$v>Vi(j$*PAb z^n6q{4=K~78ra?3`sMj1i-;mZC@CpBA%urUvUg3u2L4ix4rIcS`8hJlQ?BNRXgdJz z9X?z%*aZEj$7Jdi3Ulb(`C&s39F%t%VjR@-8|J4s3MWscouifD%czkZ%C5;z;$~6o z$GDF?ouM?m7QL$IbOl_&R6BPZaF%iG(_Z59(JCNWQ?xn4TTI~Lbgd3!Ua>ARj+)vCvAN8h^ zRkK{(qO^JnJn0MI$ljzO#}Ega?RUH>l#4U+_^v;ztqH$H2RbhpO~r zn&~4?0PiUW=}h!DkJE*+duMv} zN1muEl1*AV{SIXa#kb!3_&v?|k5NeyKi~;Gj9ppZ89(_pND70|fOx`@H|TG~HJG{| zp||R8ONaBdVf0ND4X<&zF&nTCgsaKLs_l%K45uhs^NF~Jnb6@Sn%iyyYCfyFF z!k1H;S^EwjA5tnn4uKo;i1Im-Y)8HZVv{c9L6YZ4qHKdZl)GarS=5R1s=fPKo9E za%}2nj;x2C@3C74A~m?NFwbVz251Vtdw-zhCN8FKdxPYeat`|yU=< z_^RIW!=QrB$F|jhSeA|GufwsG65V9a$6uZ?Y2aI7+-(-u9F(~Gc4nCnMyXRY)_?NR zJywbGFsDhg5<0#yPE}14Rp1J~ux{9$_p`R1mlSCL#n!ndfS#laBYZo5sGrHTWd{zQ z>Ch*`w~f6HUEx+eGLH7*!m^7L4C&jVebiX*-DMO^Ig-2xz&ndb$HK(D(u{<0wEv&YP|`EUIJ-gfIvkG18dty z)k*s&ob-FjKGo{alm_Y##jQWc^#{vC2zAQbDTi};b9dJUpJR+J#T374qLrqY6jvnL zt`~8@q*w@Kzr~UI(2M8xU9k$37eHE5#V;Wh$WX&f4!hIG*q*&=g% z#P|8=E!$sBA3&F+OtzCf%_h0Xg??MlJvpC-gH54|#k}($;q&eG>02iyt3*$vAk(MKoA2axe18 zIC#@rB}16w!p3gJHk>`*`RH3_weu2)B;lqNRmMk*4gHwxKD9*41Out8qs!cFg=hOp z8%IdDaauB=Y0KQuy%2L>52JjZ|KdhB++ZF2Io+(@FjCE5?GC*hgNOV8KFXD$utorN z3Ezy4aPyNAH|A?cR|e8Nd^eHi+6YpKd@9(I(%~$z4-2PMd0WPN2McNZ2h^DUBeNd7 z2-xR2z~$if<-M!4M&wEtDbafT;WWi`{>`wyf+|WCSF+cb>`T(=t1(6IF`TOlu6t4V zl0H(JrB!^EIT#;<0i=0@DahGHW`z@TXSv<50`%lk9Kf(3m4VxOJEHmk?J3p=MAE`N>dzDC|Cx|eGO?_$9QI~aPh4Mes!_b3>}Kk z+uX9Obl79dMM{~p+@U@T-=1oznAgEE^6l*OMSt$Rb{=6(W1whRxay8O5r182vHcts z+irF7x~7S`JhCnR?dwr|Ib(N@A4hF^<6|G1c`}2ywd07kDp(HdLmf8zRw@vz19h=% zXA3@*kER{WM$bgOtqH4xrM_R#N2NuM!_51{IkNiVS4;(7%W+1Gm!8rgaL!P3&jd;lT3?fhq!M-c;+@sN>xE}OToi4Oh4|Vn{Hv;`7_V}zOdnJog?72p425+2 zDN7EErSq^*oH+DAa-z%%AG{oq9{Z&#)Qmu8 zJRgmK6_(O?9d0P=F)KznNu^FR$#=8av6#}rn7XRP*+-~OwB@A=UbbU4o_ZcrZ*<0o z2_k(F@}y8DqLh@njEoAcQ97H65wOY`4VS7W_*}i)IFQ$hMA2$ zX9`z>5fx=%;kL%qbs-U72F8*A!0+>)e;PW!WB`}|?OlG&v0lc`M|wKWo8n%+qm@Ed zTqV_da-V@h@6Tcw24>WW-!W^nrb}-jLk3~Ykaj}TTJ?1Z>~Kg%Q34|IhN%v^Kyneg zL+*UO_mF2-_YWj1m4j;nmFb_gJK8_gp3;Ax)vBkR;i{~BzE2TEbie9O_rGt~W{zg; z&{v&brZ)hJsh2l?g~4mHuUMS<@`^wH|Ki ztcLs5eY14>=O+E4($=OG^I5{?6z8Wou4R#Kn@U6PS}p9Y0?aBF7kxX{p%ym5EOV6| zWZwHdmds<1A7{E+leuM}$5p9k1 zgMRopJMUF2$mYR2aO=}q$|0vg!Kb|W!h?R=+4LjKPgNJZtEyLStKNx3yujoA33naS zi`E|{w(KeFo~Qb5=`)KmklTFO0Xxsz#)nI*NJ17$MW`twUG17G{oU|kGU2r+XPRa8 zhw%Mro*>YC=iE^^TgRNPn*iq3X?}5dGr-|`S>ukgO%>-M%dm`brUPX&y*&Q5UG@7z zA=Z#JX^4U|h-b6xda)qL-R2mI)yt8`@Nxr+X|H7{c$bePUVpWGW$UW9rt_U1AtMVH z;pUW4?yJan$x0wf`uCb<9Tshrqjf5j7J<~<&Db4I!qq*GY5^XhFz4&}o0kt<(F^xH z4`05^pd5xSnKENFX$u98<=n{;KUl+fI%AScBK!Pi!}D2yal^%%uL@=Pk%{1`e~~2K zJzZwpvAvo@OV5^>x~t(P(kP<}ze;X+al6!Trz1~Q!k}$_=j~D1@S}rCZ4mB!%%>E^A&6obAjz z-1OstW6|}9ngbghUv#<;vaO-JC2>@tr~IjW=OO%-?;>LFqh^5LV-38Y)a-Chq{S0+ zRZWHZ-hF<5;hee9i7$eCB0#)2j4lOtuP@F(q$&H0-Bn#b3V(YDFIcHO>W2*C$}?N> zgjA(Wz`n++%;h;lo<#Q3)6Uq#t8xE9FzESadGZbKzVlhLZ=iICs{32}JHVr^MrVBQ z@R6Hh)5?m0h#U#wouksjnC-Yv?s4f&_jE+Z2#SueOpWRiXFqVLjt_usL|`2Y#JOU&T~@RqUYmCIopYJig1_k zm8r~JF4yKJrkbZCIqeHBOt4s8@NIId>@ev6+OVy^@;(etkiYo z{%acraTK(39ji{}(#V$@kt}fd)`S_E6Q$q)eYyXz4Q)WtTKBiqaYFAUh0Tgqt$HG;7kQ$pWBKi(@!fjsCcke2 zznczrG_0fS`tUmoJETw*~{WT`? zQ#>YeI_3PyJA&`)IG>jH}gyJt`S^Ve}r5=o`;M7TUfXNG1K!^!DTtvDhwBnh9VhoNc7z*LsQ%&W>>Pz`MeJca zwYapixYQ!A!klepImd<}KB&SRu2*Sl`bVDTuz-@ys(6=DxMjfJ5uob5N7p2 zlvoGmR|078P=li4mZ4}(*Iy_tCgJ&sHE$I+=sl6Cx?&{pIrv`Vxww5L>MkYt%AVF*ZOg$yIo_XwB_Qab6 z(?>Okh}_x1PPZJXy?iBAoa=Lf-Z|V5m!5<%067P2Ux)+~I*jIP2Gtmpo*V=*B(;)e zjuis^J-6L-QQ;Uf^Y?;ptK`~9KdhuY6WBa)2nfP5Z~dU14VJNz|4yrPhR%E?8`_W^ z8`DKUGeNxhVf6$ebZ(j6<{15u5h0X40>9NVLf!*wH|XZOppxuDuFil+9|a@ZTCNb} z1l!N9ILPNc^hnDfKNq~WfP$zz0B)YEI4Q*^2i8}R@9?0gUjYL!^K(J+w^n9z-@xe9 zt^wFuBW687$v6zW(lRVgvg4{xXnaN7oo$HIKp^(3y^VzLNmz(XL`SdqjXOpCh@{|B zgqp=s^@Xci#DY5)@*wKOt4!1q*z)EKVj+bBNy?BI@jb+X#N?3f`?59XKHGE-hxC7nGz3Tj#ks97d>N%UEq(B>#hH5Tyi-48(r5LzOa8wdtpIf9Uq8r?;;% zJG)8eG!Yr~`mO@s6=FlA%=)$FamOHg){e%9ins-v9yxRn96U7! z!jdauXf%mxAlRe#l&Be=ma7E1_ zch3-SO07O-6SX?}dXbt$^!A(%+6|pGrB!3W^n>ilSDv8#>U1r$M{FHh$j;(BIXDPs z&E=W!=$3+LegINfz}{CQvB1?-VT=rzF3Idx!**}9@b=yx@IS+IH^h6MFuO3vlCKw$ zR6A>6Q=D30FQJ=aLvo(XO;ba({1YQBU?^qq2)h)Iz0l!jD^cbvF!H*+Qz%JrW%%)A ze1+Yx!hBm2_z?<_5=BaC4{47_b%Fm&+v1wmbWOV4pokRT(f?ariCM}qS9SB zb_<>| zoj##bRy~*D&#<=S5PqF7s3Yd*GT%T6qoyLu=5wl{Or(bQMBheq2%SK44iL6 zhE^^%ZEiV~&b6}1uVgRH1msQ`z4s^KM$1|ub?yj-{dZaE?t0+k!&0|{g|SF+0J;HG z!4l?-ES)|WrMResJ|Uv=1R#$~(+PWEA{j_KvDWUBBMGU)01>GVvqd;1N@+|&{08J~ zT2Hbpd4M@Q(XQ|G{x*fGF^`dK8Fmd$M-h1`z`|14Jo2js?9!J+2AOxV5=#drNE_`z z!oi*C+3#k}-G>{rBeyx1fBKGd1dns1B{Vlii!~TFz1so~QkYk*!Y%bp|!y@b0=X4(q^U&n94Y zC8b$4-%HMo(~<43o?wiPI>!g2?i70W1K%BMCC2F1Fd^JDOB`{byhuCpn+`%EpQ^Ku zX+YG%N4QL+XV(@Rj7Q+9$|;V&(apE;OMc3$lqrK6OwRE4l@TP4i_u8|f&QBGanPG5 z-gI%Wobxh{Fq9RyYnq2b;rfK}F@bFy7Gobs2urVWQ?@Z3(4@C?p1vc}Qf0m^KPy}7xf0?_3y$hc{dEawFqwF5hdVySPY zb}={yG_Z?ma#`nE^?8Vz=P7*HZYk8vep~OfunlatiQKaaB>EAlv21mt5>>&rN#!=S zU_yRpIVa6FLz_sHe}nHyS=&D{&c=uMlj4VLU*us~dHfsa{2wFDm*&*#q=pLVeceU1 zARUSas^9UJIsH8sl39&$PzvEf_c8C{e-4>`@!Q9@*ycVhhB$u-B~za+DXv(1v+84E z5(sj#AyPj}WS_A;?CsbtTsXL;Nr+em1cVh_&+}?q3qr@fb)}4fv9v5Jp(wakK{tO5 zQ|XQWPy#_ukU-~kiO-~RqcT2Uml=`86(5{POqyp@;@c1h;uVshkRyVn;1`vA3F+#y zKO_^Fuhcf22@#Oy|Chqu7ljco3cD-=;{H%b^t(bhtFn^K{4_u5uJ_lh9-ArEIrx7E z%8QPs_ji6r8XK>g63~&5A?5HA=ByMGT(@!Ak&IusF~xX~>T2OXEcmcMC{(36=M$I&jZmM>|)Q#32XV;7Wi7 zd`Wi`t(|VS#hDwoDdUSSZM5z{?S+osLhGn&aNKTu5s=*N$hEnBpx3*aex`Td{_1`Z z%)GQp<-6*3CODu^wDRb(l6&|%&H(H)azEpN?@`>pJ#hVbZ-|V)wZ#hWi8a zShWnX!699`XcG}(uM3(MGu?UVh(Nyq4JdwE}x z789B`gnCR_$e|gIzM2nIAjZ-u#{3$T z272zV<+ssHWg;A!k~x~7{ax{y*0ZB}^AaR&OppN_2&2mS12nutAf`*<^@(tOPNf*R zrVOLzTN*Uc8Qm&f`D$jFsz>EoLKs$|9(;v%^DRF0B~jQJ2zvP<%)~K5@CK+lYS&HO zN?Lp#m*f>%M2>qCOS~CAV*q~78wp8#*s{SFoG#8(O@m6#@*SLF-5E4@S}0eU0t{`g zux!D2XBA1{Vd=Fo0~X?|+$7HAzAmOs8fSP!ZRk~XhD{qI9e>ECg>JNpdL;7b5o1tp z&3XTr%dT}v1WJ?ou3?A(TUjY-2Ievx5!YBU!w%YJR-PDV>u+HZY8u`IWzEc<*9<0YEyrM+bk?xr2pVJH(AGRMcF~)9e3s-s zC{4!`@Ao&3VP%v?Ka5UbZw?v}w?T`ZyW4jH!8*aMzfad5bZw<;gT^0;@o% zHwVn091q>w?TU_Pp6e`Lw8#@6()$$0>{F44$kMsx;$pR_S`Fo#6o;y}YnBG$bO-wB*2~SleT#nsEZtC%jaoo~)Qu}LjvGL$F zH0!9k2s%v!n^6^flBD8oPUtGGz4H%hb=uqHk!vw6{x+WHNTuh~!|q;V$JSyU@H9cJ z`1FdNVDsYqNn=LaJafeB}k{M1JMPQ5=Mj3G5EQpJ4R=7o3(2irIlqJv)!3Us{8L!Vrhx|+v zd-)FasqT8+#B;GSaAdn_W_CXo_2j0vvWK!nk+>66bWykJy8$B~45S3}O*J0QljV?X z0hPGO7^kC>^F@z^R9^ewV>PrOdJx3Au+9+H;J2InwMAw>m~4zf!tn>|9w|1Qu&vXE z%JFbh7=y?RrHaSIPuY-zxd|Hp`{v9?$lPP8*4<1qS_jm-)E@~2`)Q{Uf%{d^TJ1P( z1>egWnW<7^9g335;9_tk<&9Gqe%d5uTf6%fxAnEs zw#pYf5mw`JxKK)x={$1D%O+$jp+lvp3@ue<||g*bQ1*jCf8M=}8Z{Kk_vW zPzN5r?L#eq^A40!3sxXop+)V`iO6m`7)2D$4-z@Bp{cmKguO*sl*zqey}}_lPIH|7 z)+?SO%!%Ed_Be~26-6~JLcGEvD9L#xbPj|{uIm+ zI$6cn){`tKuNRyo-=~DvSz$;>5l!m$ZjyN`?_#@g{A{n7L%iCWDfw;`Uo2Dly5h^W zsK{F~q#}rp*%shRq2*f3kAN=|x2%cd)fkvurHW~iLHBa{5pymSa?bWK)I_Qj+ERXV zsiUqx9AR=myFh$P<5`*f*N_2Q3#9z#@c!_=wP;!K1woto0#+{8Qr+m$Oc) z&FA<2@Sex?xE%pMIc-o>i1%G~_S$qwX6sIyMc%EMwqm+S-LBg-Q{XuvDXZHW41P{V6xxqmlXI0-CKsM!W=by$Ufl-SVAhP*=O9y^V_jMpN0N_LYN7Ma>W!;B+ zn!Eg$JHL)!hH$Euu6D-uMpm}YEG~aUnH}scA{FH&kP!%e8B8HdNs1~101z)xfH558 z%Q<4_srn^|Rg_f~1IGZs@o?ZoBych&7=#D@Lh)dBeS1blY^etv!_4j2q>{aU~T06)#Xe_boLFdQH^gr2(H379|)LCQ1U4)RPk=g7AigXBhEn6 z?q)3oKp4I}6`21XJTjMN69GK+0XnOj&&__~0CJ_-)P$5ezd<_1b-6~jh2sk*{|UT= z`N5}5T<`*3RY@bL;N%JQM6kYp)%_iF^Y$!MXG29ahn%k!vnmYCa8Sb0@ofT0ZFUwk z=i9u<6Fx{UBKMU=oy%UF3O-;nIkMsu*&S6SqGN)(Ac~L<-STaz?NBBe9czku3Xby;L(%iYZJxy1_tO->pu`(RN{T5(KhOKE6L; zGM9~L;m`c8TovjB=R>ipIGlS_Eon*L9cSydnN!Dhu3n^ z8v~SUiME~E4)MkL9A-3mlz>UY_6uHZ(_XyFcPJ0nRG%=uDYd+&7Gr51BufVY03a7_+gX!|P2V0FM9Pc;uyjIR# zFjj|PpWUCB|_6p*1KISX&>9^4e6X*?nK2NXQY1f3oMMB7R^ULRdr)I3*p z4Gl6oup6i9@e!5F|D4NaV#$ne1SmREL9?#im*6r?+ipCb^Hso9<;LZ4T$8>c<~GojD;D_RII6F^=%5FYvp3D>Ql59O2wG(|oL%*L6F{Eajo6NTUjk%D z^1eD^ayNj<(Hdl%?Mn5#@M}}PSV5U9_g|m_IOQ87PBSlaktF6(o9RAxzi)>gQp?oE&rMw|WUKx>Wz&vI7R7?vjB#E7SA z{Z5#t%7CJahM5y~G6&Vr$sz@A1n$hd!VgN zz0eh@{FUm!i-S_2%!xP8{(3TOQ(x5BImn=YUt%8lf z_)xgCv7?T@wwjGDwc^W{eZNn7ZEt7Py-QnG4Yv&}kO7e20IUF{|1Iz!cz_fxRj|{f zv^#xi=KxY-@}dR_=hA2r;w z0`r0ua^q3uMnj54#=Q;}JjvSh;z8v2Rg1n0wj{x z&(6uA@x9@?9j%fmvX(1vb9AQy7=j_xK=m+(X!Mz*WB9!;Rt08XY`?Mfr`iiMk+ztB zV&UyAV$(j*yPa$Tk#TgXaLg2nU~Mv`jUcMvlkL~GpFDh3z_AOWroFrL{V(?lZpI|aP+mR z%+&+vy=lubxxe=_1i0SsYUF;;1;}-QbVt2?65T1&Wt>_wUPFG<=Los-WO*B{s3pV z=nP#OBX~mm`W{cvXWx{GoEP^l1vu!xqRMBV>iPjzpdy|^5J|0Qi8-G`R4i`gHqQRY z>kRkTER0G{vbyD^@1=wKqbvU>>kRCd&q7X2RhUUiUYy0w$ll7_%=tGvjq)Tp>uy%G z&>No6@fBr%PO+YW&tsVRu!>uW?4SFup>%jretGeQSm+LXstu`@!d<+`vyJQH1ijkI_M|4+H6f#pw+g#TZbwtryx(?j9kS$_E% z{3#bN(f_Q`Up*H7e|Z>Q-oStI{MCcu-+6xdF#IVd|G?w$H;;yYXZm~1GXH_;uO1Hn z&hyL1;ZK?V2cG}t@$m0tf3F_KtN;F({_X+sJJTOc#a~UlehumW6xkQ1KRqJ;_=Nw! z{dEcGmjT$H5{~>gbFlve{&fQKYlY}f$tL^zdeI-qf19BEiPWa~_vy+XEPoXIudU{< z&hSs+c$px)I1~NWHU1~+uWi7u7WGe|V)>W$RZ#{S=2tefmmkAR6CKO?>+1gjKiUBM literal 0 HcmV?d00001 diff --git a/client/pom.xml b/client/pom.xml new file mode 100644 index 00000000..ae3a1d3b --- /dev/null +++ b/client/pom.xml @@ -0,0 +1,50 @@ + + + 4.0.0 + + + io.opencmw + opencmw + ${revision}${sha1}${changelist} + ../pom.xml + + + client + + + OpenCmw, CmwLight, and RESTful client implementations. + + + + + io.opencmw + core + ${revision}${sha1}${changelist} + + + de.gsi.dataset + chartfx-dataset + ${version.chartfx} + + + + com.squareup.okhttp3 + okhttp + ${version.okHttp3} + + + com.squareup.okhttp3 + okhttp-sse + ${version.okHttp3} + + + com.squareup.okhttp3 + mockwebserver + ${version.okHttp3} + test + + + + + diff --git a/client/src/main/java/io/opencmw/client/DataSource.java b/client/src/main/java/io/opencmw/client/DataSource.java new file mode 100644 index 00000000..822ea7b7 --- /dev/null +++ b/client/src/main/java/io/opencmw/client/DataSource.java @@ -0,0 +1,118 @@ +package io.opencmw.client; + +import java.time.Duration; +import java.util.List; + +import org.zeromq.ZContext; +import org.zeromq.ZMQ.Socket; +import org.zeromq.ZMsg; + +import io.opencmw.serialiser.IoSerialiser; +import io.opencmw.utils.NoDuplicatesList; + +/** + * Interface for DataSources to be added to an EventStore by a single event loop. + * Should provide a static boolean matches(String address) function to determine whether + * it is eligible for a given address. + */ +public abstract class DataSource { + private static final List IMPLEMENTATIONS = new NoDuplicatesList<>(); + + private DataSource() { + // prevent implementers from implementing default constructor + } + + /** + * Constructor + * @param endpoint Endpoint to subscribe to + */ + public DataSource(final String endpoint) { + if (endpoint == null || endpoint.isBlank() || !getFactory().matches(endpoint)) { + throw new UnsupportedOperationException(this.getClass().getName() + " DataSource Implementation does not support endpoint: " + endpoint); + } + } + + /** + * Factory method to get a DataSource for a given endpoint + * @param endpoint endpoint address + * @return if there is a DataSource implementation for the protocol of the endpoint return a Factory to create a new + * Instance of this DataSource + * @throws UnsupportedOperationException in case there is no valid implementation + */ + public static Factory getFactory(final String endpoint) { + for (Factory factory : IMPLEMENTATIONS) { + if (factory.matches(endpoint)) { + return factory; + } + } + throw new UnsupportedOperationException("No DataSource implementation available for endpoint: " + endpoint); + } + + public static void register(final Factory factory) { + IMPLEMENTATIONS.add(0, factory); // custom added implementations are added in front to be discovered first + } + + /** + * Get Socket to wait for in the event loop. + * The main event thread will wait for data becoming available on this socket. + * The socket might be used to receive the actual data or it might just be used to notify the main thread. + * @return a Socket for the event loop to wait upon + */ + public abstract Socket getSocket(); + + protected abstract Factory getFactory(); + + /** + * Gets called whenever data is available on the DataSoure's socket. + * Should then try to receive data and return any results back to the calling event loop. + * @return null if there is no more data available, a Zero length Zmsg if there was data which was only used internally + * or a ZMsg with [reqId, endpoint, byte[] data, [byte[] optional RBAC token]] + */ + public abstract ZMsg getMessage(); + + /** + * Perform housekeeping tasks like connection management, heartbeats, subscriptions, etc + * @return next time housekeeping duties should be performed + */ + public abstract long housekeeping(); + + /** + * Subscribe to this endpoint + * @param reqId the id to join the result of this subscribe with + * @param rbacToken byte array containing signed body hash-key and corresponding RBAC role + */ + public abstract void subscribe(final String reqId, final String endpoint, final byte[] rbacToken); + + /** + * Unsubscribe from the endpoint of this DataSource. + */ + public abstract void unsubscribe(final String reqId); + + /** + * Perform a get request on this endpoint. + * @param requestId request id which later allows to match the returned value to this query. + * This is the only mandatory parameter, all the following may be null. + * @param endpoint extend the filters originally supplied to the endpoint e.g. "ctx=selector&channel=chanA" + * @param filters The serialised filters which will determine which data to update + * @param data The serialised data which can be used by the get call + * @param rbacToken byte array containing signed body hash-key and corresponding RBAC role + */ + public abstract void get(final String requestId, final String endpoint, final byte[] filters, final byte[] data, final byte[] rbacToken); + + /** + * Perform a set request on this endpoint using additional filters + * @param requestId request id which later allows to match the returned value to this query. + * This is the only mandatory parameter, all the following may be null. + * @param endpoint extend the filters originally supplied to the endpoint e.g. "ctx=selector&channel=chanA" + * @param filters The serialised filters which will determine which data to update + * @param data The serialised data which can be used by the get call + * @param rbacToken byte array containing signed body hash-key and corresponding RBAC role + */ + public abstract void set(final String requestId, final String endpoint, final byte[] filters, final byte[] data, final byte[] rbacToken); + + protected interface Factory { + boolean matches(final String endpoint); + Class getMatchingSerialiserType(final String endpoint); + DataSource newInstance(final ZContext context, final String endpoint, final Duration timeout, final String clientId); + } +} diff --git a/client/src/main/java/io/opencmw/client/DataSourceFilter.java b/client/src/main/java/io/opencmw/client/DataSourceFilter.java new file mode 100644 index 00000000..7ba7d582 --- /dev/null +++ b/client/src/main/java/io/opencmw/client/DataSourceFilter.java @@ -0,0 +1,63 @@ +package io.opencmw.client; + +import io.opencmw.Filter; +import io.opencmw.serialiser.IoSerialiser; + +public class DataSourceFilter implements Filter { + public ReplyType eventType = ReplyType.UNKNOWN; + public Class protocolType; + public String device; + public String property; + public DataSourcePublisher.ThePromisedFuture future; + public String context; + + @Override + public void clear() { + eventType = ReplyType.UNKNOWN; + device = "UNKNOWN"; + property = "UNKNOWN"; + future = null; // NOPMD - have to clear the future because the events are reused + context = ""; + } + + @Override + public void copyTo(final Filter other) { + if (other instanceof DataSourceFilter) { + final DataSourceFilter otherDSF = (DataSourceFilter) other; + otherDSF.eventType = eventType; + otherDSF.device = device; + otherDSF.property = property; + otherDSF.future = future; + otherDSF.context = context; + } + } + + /** + * internal enum to track different get/set/subscribe/... transactions + */ + public enum ReplyType { + SUBSCRIBE(0), + GET(1), + SET(2), + UNSUBSCRIBE(3), + UNKNOWN(-1); + + private final byte id; + ReplyType(int id) { + this.id = (byte) id; + } + + public byte getID() { + return id; + } + + public static ReplyType valueOf(final int id) { + for (ReplyType mode : ReplyType.values()) { + if (mode.getID() == id) { + return mode; + } + } + return UNKNOWN; + } + } +} diff --git a/client/src/main/java/io/opencmw/client/DataSourcePublisher.java b/client/src/main/java/io/opencmw/client/DataSourcePublisher.java new file mode 100644 index 00000000..55d14d37 --- /dev/null +++ b/client/src/main/java/io/opencmw/client/DataSourcePublisher.java @@ -0,0 +1,463 @@ +package io.opencmw.client; + +import static java.util.Objects.requireNonNull; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Arrays; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.zeromq.SocketType; +import org.zeromq.ZContext; +import org.zeromq.ZFrame; +import org.zeromq.ZMQ; +import org.zeromq.ZMsg; + +import io.opencmw.EventStore; +import io.opencmw.RingBufferEvent; +import io.opencmw.filter.EvtTypeFilter; +import io.opencmw.filter.TimingCtx; +import io.opencmw.rbac.RbacProvider; +import io.opencmw.serialiser.IoBuffer; +import io.opencmw.serialiser.IoClassSerialiser; +import io.opencmw.serialiser.IoSerialiser; +import io.opencmw.serialiser.spi.FastByteBuffer; +import io.opencmw.utils.CustomFuture; +import io.opencmw.utils.SharedPointer; + +import com.lmax.disruptor.EventHandler; + +/** + * Publishes events from different sources into a common {@link EventStore} and takes care of setting the appropriate + * filters and deserialisation of the domain objects. + * + * The subscribe/unsubscribe/set/get methods can be called from any thread and are decoupled from the actual + * + * @author Alexander Krimmm + * @author rstein + */ +public class DataSourcePublisher implements Runnable { + private static final Logger LOGGER = LoggerFactory.getLogger(DataSourcePublisher.class); + private static final AtomicInteger INSTANCE_COUNT = new AtomicInteger(); + private static final byte[] EMPTY_BYTE_ARRAY = new byte[0]; + private static final ZFrame EMPTY_FRAME = new ZFrame(EMPTY_BYTE_ARRAY); + public static final int MIN_FRAMES_INTERNAL_MSG = 3; + private final String inprocCtrl = "inproc://dsPublisher#" + INSTANCE_COUNT.incrementAndGet(); + protected final Map> requestFutureMap = new ConcurrentHashMap<>(); // + protected final Map clientMap = new ConcurrentHashMap<>(); // + + private final AtomicBoolean running = new AtomicBoolean(false); + private final AtomicInteger internalReqIdGenerator = new AtomicInteger(0); + private final EventStore eventStore; + private final ZMQ.Poller poller; + private final ZContext context = new ZContext(1); + private final ZMQ.Socket controlSocket; + private final IoBuffer byteBuffer = new FastByteBuffer(2000); // zero length buffer to use when there is no data to deserialise + private final IoClassSerialiser ioClassSerialiser = new IoClassSerialiser(byteBuffer); + + private final ThreadLocal perThreadControlSocket = new ThreadLocal<>() { // creates a client control socket for each calling thread + private ZMQ.Socket result; + + @Override + public void remove() { + if (result != null) { + result.disconnect(inprocCtrl); + } + super.remove(); + } + + @Override + protected ZMQ.Socket initialValue() { + result = context.createSocket(SocketType.DEALER); + result.connect(inprocCtrl); + return result; + } + }; + private final String clientId; + private final RbacProvider rbacProvider; + + public DataSourcePublisher(final RbacProvider rbacProvider, final EventStore publicationTarget, final String... clientId) { + this(rbacProvider, clientId); + eventStore.register((event, sequence, endOfBatch) -> { + final DataSourceFilter dataSourceFilter = event.getFilter(DataSourceFilter.class); + final ThePromisedFuture future = dataSourceFilter.future; + if (future.replyType == DataSourceFilter.ReplyType.SUBSCRIBE) { + final Class domainClass = future.getRequestedDomainObjType(); + final ZMsg cmwMsg = event.payload.get(ZMsg.class); + requireNonNull(cmwMsg.poll()).getString(Charset.defaultCharset()); // ignore header + final byte[] body = requireNonNull(cmwMsg.poll()).getData(); + final String exc = requireNonNull(cmwMsg.poll()).getString(Charset.defaultCharset()); + Object domainObj = null; + if (body != null && body.length != 0) { + ioClassSerialiser.setDataBuffer(FastByteBuffer.wrap(body)); + domainObj = ioClassSerialiser.deserialiseObject(domainClass); + ioClassSerialiser.setDataBuffer(byteBuffer); // allow received byte array to be released + } + publicationTarget.getRingBuffer().publishEvent((publishEvent, seq, obj) -> { + final TimingCtx contextFilter = publishEvent.getFilter(TimingCtx.class); + final EvtTypeFilter evtTypeFilter = publishEvent.getFilter(EvtTypeFilter.class); + publishEvent.arrivalTimeStamp = event.arrivalTimeStamp; + publishEvent.payload = new SharedPointer<>(); + publishEvent.payload.set(obj); + if (exc != null && !exc.isBlank()) { + publishEvent.throwables.add(new Exception(exc)); + } + try { + contextFilter.setSelector(dataSourceFilter.context, 0); + } catch (IllegalArgumentException e) { + LOGGER.atError().setCause(e).addArgument(dataSourceFilter.context).log("No valid context: {}"); + } + // contextFilter.acqts = msg.dataContext.acqStamp; // needs to be added? + // contextFilter.ctxName = // what should go here? + evtTypeFilter.evtType = EvtTypeFilter.DataType.DEVICE_DATA; + evtTypeFilter.typeName = dataSourceFilter.device + '/' + dataSourceFilter.property; + evtTypeFilter.updateType = EvtTypeFilter.UpdateType.COMPLETE; + }, domainObj); + } else if (future.replyType == DataSourceFilter.ReplyType.GET) { + // get data from socket + final ZMsg cmwMsg = event.payload.get(ZMsg.class); + requireNonNull(cmwMsg.poll()).getString(StandardCharsets.UTF_8); + final byte[] body = requireNonNull(cmwMsg.poll()).getData(); + final String exc = requireNonNull(cmwMsg.poll()).getString(Charset.defaultCharset()); + // deserialise + Object obj = null; + if (body != null && body.length != 0) { + ioClassSerialiser.setDataBuffer(FastByteBuffer.wrap(body)); + obj = ioClassSerialiser.deserialiseObject(future.getRequestedDomainObjType()); + ioClassSerialiser.setDataBuffer(byteBuffer); // allow received byte array to be released + } + // notify future + if (exc == null || exc.isBlank()) { + future.castAndSetReply(obj); + } else { + future.setException(new Exception(exc)); + } + // publish to ring buffer + publicationTarget.getRingBuffer().publishEvent((publishEvent, seq, o) -> { + final TimingCtx contextFilter = publishEvent.getFilter(TimingCtx.class); + final EvtTypeFilter evtTypeFilter = publishEvent.getFilter(EvtTypeFilter.class); + publishEvent.arrivalTimeStamp = event.arrivalTimeStamp; + publishEvent.payload = new SharedPointer<>(); + publishEvent.payload.set(o); + if (exc != null && !exc.isBlank()) { + publishEvent.throwables.add(new Exception(exc)); + } + try { + contextFilter.setSelector(dataSourceFilter.context, 0); + } catch (IllegalArgumentException e) { + LOGGER.atError().setCause(e).addArgument(dataSourceFilter.context).log("No valid context: {}"); + } + // contextFilter.acqts = msg.dataContext.acqStamp; // needs to be added? + // contextFilter.ctxName = // what should go here? + evtTypeFilter.evtType = EvtTypeFilter.DataType.DEVICE_DATA; + evtTypeFilter.typeName = dataSourceFilter.device + '/' + dataSourceFilter.property; + evtTypeFilter.updateType = EvtTypeFilter.UpdateType.COMPLETE; + }, obj); + } else { + // ignore other reply types for now + // todo: publish statistics, connection state and getRequests + LOGGER.atInfo().addArgument(event.payload.get()).log("{}"); + } + }); + } + + public DataSourcePublisher(final RbacProvider rbacProvider, final EventHandler eventHandler, final String... clientId) { + this(rbacProvider, clientId); + eventStore.register(eventHandler); + } + + public DataSourcePublisher(final RbacProvider rbacProvider, final String... clientId) { + poller = context.createPoller(1); + // control socket for adding subscriptions / triggering requests from other threads + controlSocket = context.createSocket(SocketType.DEALER); + controlSocket.bind(inprocCtrl); + poller.register(controlSocket, ZMQ.Poller.POLLIN); + // instantiate event store + eventStore = EventStore.getFactory().setSingleProducer(true).setFilterConfig(DataSourceFilter.class).build(); + // register default handlers // TODO: find out how to do this without having to reference them directly + // DataSource.register(CmwLightClient.FACTORY); + // DataSource.register(RestDataSource.FACTORY); + this.clientId = clientId.length == 1 ? clientId[0] : DataSourcePublisher.class.getName(); + this.rbacProvider = rbacProvider; + } + + public ZContext getContext() { + return context; + } + + public EventStore getEventStore() { + return eventStore; + } + + public Future set(String endpoint, final Class requestedDomainObjType, final Object requestBody, final RbacProvider... rbacProvider) { + return set(endpoint, null, requestBody, requestedDomainObjType, rbacProvider); + } + + /** + * Perform an asynchronous set request on the given device/property. + * Checks if a client for this service already exists and if it does performs the asynchronous get on it, otherwise + * it starts a new client and performs it there. + * + * @param endpoint endpoint address for the property e.g. 'rda3://hostname:port/property?selector&filter', + * file:///path/to/directory, mdp://host:port + * @param requestFilter optional map of optional filters e.g. Map.of("channelName", "VoltageChannel") + * @param requestBody optional domain object payload to be send with the request + * @param requestedDomainObjType the requested result domain object type + * @param The type of the deserialised requested result domain object + * @return A future which will be able to retrieve the deserialised result + */ + public Future set(String endpoint, final Map requestFilter, final Object requestBody, final Class requestedDomainObjType, final RbacProvider... rbacProvider) { + return request(DataSourceFilter.ReplyType.SET, endpoint, requestFilter, requestBody, requestedDomainObjType, rbacProvider); + } + + public Future get(String endpoint, final Class requestedDomainObjType, final RbacProvider... rbacProvider) { + return get(endpoint, null, null, requestedDomainObjType, rbacProvider); + } + + /** + * Perform an asynchronous get request on the given device/property. + * Checks if a client for this service already exists and if it does performs the asynchronous get on it, otherwise + * it starts a new client and performs it there. + * + * @param endpoint endpoint address for the property e.g. 'rda3://hostname:port/property?selector&filter', + * file:///path/to/directory, mdp://host:port + * @param requestFilter optional map of optional filters e.g. Map.of("channelName", "VoltageChannel") + * @param requestBody optional domain object payload to be send with the request + * @param requestedDomainObjType the requested result domain object type + * @param The type of the deserialised requested result domain object + * @return A future which will be able to retrieve the deserialised result + */ + public Future get(String endpoint, final Map requestFilter, final Object requestBody, final Class requestedDomainObjType, final RbacProvider... rbacProvider) { + return request(DataSourceFilter.ReplyType.GET, endpoint, requestFilter, requestBody, requestedDomainObjType, rbacProvider); + } + + private ThePromisedFuture request(final DataSourceFilter.ReplyType replyType, final String endpoint, final Map requestFilter, final Object requestBody, final Class requestedDomainObjType, final RbacProvider... rbacProvider) { + final String requestId = clientId + internalReqIdGenerator.incrementAndGet(); + final ThePromisedFuture requestFuture = newFuture(endpoint, requestFilter, requestBody, requestedDomainObjType, replyType, requestId); + final Class matchingSerialiser = DataSource.getFactory(endpoint).getMatchingSerialiserType(endpoint); + + // signal socket for get with endpoint and request id + final ZMsg msg = new ZMsg(); + msg.add(new byte[] { replyType.getID() }); + msg.add(requestId); + msg.add(endpoint); + if (requestFilter == null) { + msg.add(EMPTY_FRAME); + } else { + ioClassSerialiser.getDataBuffer().reset(); + ioClassSerialiser.setMatchedIoSerialiser(matchingSerialiser); // needs to be converted in DataSource impl + ioClassSerialiser.serialiseObject(requestFilter); + msg.add(Arrays.copyOfRange(ioClassSerialiser.getDataBuffer().elements(), 0, ioClassSerialiser.getDataBuffer().position())); + } + if (requestBody == null) { + msg.add(EMPTY_FRAME); + } else { + ioClassSerialiser.getDataBuffer().reset(); + ioClassSerialiser.setMatchedIoSerialiser(matchingSerialiser); // needs to be converted in DataSource impl + ioClassSerialiser.serialiseObject(requestBody); + msg.add(Arrays.copyOfRange(ioClassSerialiser.getDataBuffer().elements(), 0, ioClassSerialiser.getDataBuffer().position())); + } + // RBAC + if (rbacProvider.length > 0 || this.rbacProvider != null) { + final RbacProvider rbac = rbacProvider.length > 0 ? rbacProvider[0] : this.rbacProvider; // NOPMD - future use + // rbac.sign(msg); // todo: sign message and add rbac token and signature + } else { + msg.add(EMPTY_FRAME); + } + + msg.send(perThreadControlSocket.get()); + //TODO: do we need the following 'remove()' + perThreadControlSocket.remove(); + return requestFuture; + } + + public void subscribe(final String endpoint, final Class requestedDomainObjType) { + subscribe(endpoint, requestedDomainObjType, null, null); + } + + public String subscribe(final String endpoint, final Class requestedDomainObjType, final Map requestFilter, final Object requestBody, final RbacProvider... rbacProvider) { + ThePromisedFuture future = request(DataSourceFilter.ReplyType.SUBSCRIBE, endpoint, requestFilter, requestBody, requestedDomainObjType, rbacProvider); + return future.internalRequestID; + } + + public void unsubscribe(String requestId) { + // signal socket for get with endpoint and request id + final ZMsg msg = new ZMsg(); + msg.add(new byte[] { DataSourceFilter.ReplyType.UNSUBSCRIBE.getID() }); + msg.add(requestId); + msg.add(requestFutureMap.get(requestId).endpoint); + msg.send(perThreadControlSocket.get()); + //TODO: do we need the following 'remove()' + perThreadControlSocket.remove(); + } + + @Override + public void run() { + // start the ring buffer and its processors + eventStore.start(); + // event loop polling all data sources and performing regular housekeeping jobs + running.set(true); + long nextHousekeeping = System.currentTimeMillis(); // immediately perform first housekeeping + long tout = 0L; + while (!Thread.interrupted() && running.get() && (tout <= 0 || -1 != poller.poll(tout))) { + // get data from clients + boolean dataAvailable = true; + while (dataAvailable && System.currentTimeMillis() < nextHousekeeping && running.get()) { + dataAvailable = handleDataSourceSockets(); + // check specificaly for control socket + dataAvailable |= handleControlSocket(); + } + + nextHousekeeping = clientMap.values().stream().mapToLong(DataSource::housekeeping).min().orElse(System.currentTimeMillis() + 1000); + tout = nextHousekeeping - System.currentTimeMillis(); + } + LOGGER.atDebug().addArgument(clientMap.values()).log("poller returned negative value - abort run() - clients = {}"); + } + + public void start() { + new Thread(this).start(); // NOPMD - not a webapp + } + + protected boolean handleControlSocket() { + final ZMsg controlMsg = ZMsg.recvMsg(controlSocket, false); + if (controlMsg == null) { + return false; // no more data available on control socket + } + if (controlMsg.size() < MIN_FRAMES_INTERNAL_MSG) { // msgType, requestId and endpoint have to be always present + LOGGER.atDebug().log("ignoring invalid message"); + return true; // ignore invalid partial message + } + final DataSourceFilter.ReplyType msgType = DataSourceFilter.ReplyType.valueOf(controlMsg.pollFirst().getData()[0]); + final String requestId = requireNonNull(controlMsg.pollFirst()).getString(Charset.defaultCharset()); + final String endpoint = requireNonNull(controlMsg.pollFirst()).getString(Charset.defaultCharset()); + final byte[] filters = controlMsg.isEmpty() ? EMPTY_BYTE_ARRAY : controlMsg.pollFirst().getData(); + final byte[] data = controlMsg.isEmpty() ? EMPTY_BYTE_ARRAY : controlMsg.pollFirst().getData(); + final byte[] rbacToken = controlMsg.isEmpty() ? EMPTY_BYTE_ARRAY : controlMsg.pollFirst().getData(); + + final DataSource client = getClient(endpoint); // get client for endpoint + switch (msgType) { + case SUBSCRIBE: // subscribe: 0b, requestId, addr/dev/prop?sel&filters, [filter] + client.subscribe(requestId, endpoint, rbacToken); // issue get request + break; + case GET: // get: 1b, reqId, addr/dev/prop?sel&filters, [filter] + client.get(requestId, endpoint, filters, data, rbacToken); // issue get request + break; + case SET: // set: 2b, reqId, addr/dev/prop?sel&filters, data, add data to blocking queue instead? + client.set(requestId, endpoint, filters, data, rbacToken); + break; + case UNSUBSCRIBE: //unsub: 3b, reqId, endpoint + client.unsubscribe(requestId); + requestFutureMap.remove(requestId); + break; + case UNKNOWN: + default: + throw new UnsupportedOperationException("Illegal operation type"); + } + return true; + } + + protected boolean handleDataSourceSockets() { + boolean dataAvailable = false; + for (DataSource entry : clientMap.values()) { + final ZMsg reply = entry.getMessage(); + if (reply == null) { + continue; // no data received, queue empty + } + dataAvailable = true; + if (reply.isEmpty()) { + continue; // there was data received, but only used for internal state of the client + } + // the received data consists of the following frames: replyType(byte), reqId(string), endpoint(string), dataBody(byte[]) + eventStore.getRingBuffer().publishEvent((event, sequence) -> { + final String reqId = requireNonNull(reply.pollFirst()).getString(Charset.defaultCharset()); + final ThePromisedFuture returnFuture = requestFutureMap.get(reqId); + if (returnFuture.getReplyType() != DataSourceFilter.ReplyType.SUBSCRIBE) { // remove entries for one time replies + assert returnFuture.getInternalRequestID().equals(reqId) + : "requestID mismatch"; + requestFutureMap.remove(reqId); + } + final Endpoint endpoint = new Endpoint(requireNonNull(reply.pollFirst()).getString(Charset.defaultCharset())); // NOPMD - need to create new Endpoint + event.arrivalTimeStamp = System.currentTimeMillis(); + event.payload = new SharedPointer<>(); // NOPMD - need to create new shared pointer instance + event.payload.set(reply); // ZMsg containing header, body and exception frame + final DataSourceFilter dataSourceFilter = event.getFilter(DataSourceFilter.class); + dataSourceFilter.future = returnFuture; + dataSourceFilter.eventType = DataSourceFilter.ReplyType.SUBSCRIBE; + dataSourceFilter.device = endpoint.getDevice(); + dataSourceFilter.property = endpoint.getProperty(); + dataSourceFilter.context = endpoint.getSelector(); + }); + } + return dataAvailable; + } + + protected ThePromisedFuture newFuture(final String endpoint, final Map requestFilter, final Object requestBody, final Class requestedDomainObjType, final DataSourceFilter.ReplyType replyType, final String requestId) { + final ThePromisedFuture requestFuture = new ThePromisedFuture<>(endpoint, requestFilter, requestBody, requestedDomainObjType, replyType, requestId); + final Object oldEntry = requestFutureMap.put(requestId, requestFuture); + assert oldEntry == null : "requestID '" + requestId + "' already present in requestFutureMap"; + return requestFuture; + } + + private DataSource getClient(final String endpoint) { + return clientMap.computeIfAbsent(new Endpoint(endpoint).getAddress(), requestedEndPoint -> { + final DataSource dataSource = DataSource.getFactory(requestedEndPoint).newInstance(context, endpoint, Duration.ofMillis(100), Long.toString(internalReqIdGenerator.incrementAndGet())); + poller.register(dataSource.getSocket(), ZMQ.Poller.POLLIN); + return dataSource; + }); + } + + public static class ThePromisedFuture extends CustomFuture { // NOPMD - no need for setters/getters here + private final String endpoint; + private final Map requestFilter; + private final Object requestBody; + private final Class requestedDomainObjType; + private final DataSourceFilter.ReplyType replyType; + private final String internalRequestID; + + public ThePromisedFuture(final String endpoint, final Map requestFilter, final Object requestBody, final Class requestedDomainObjType, final DataSourceFilter.ReplyType replyType, final String internalRequestID) { + super(); + this.endpoint = endpoint; + this.requestFilter = requestFilter; + this.requestBody = requestBody; + this.requestedDomainObjType = requestedDomainObjType; + this.replyType = replyType; + this.internalRequestID = internalRequestID; + } + + public String getEndpoint() { + return endpoint; + } + + public DataSourceFilter.ReplyType getReplyType() { + return replyType; + } + + public Object getRequestBody() { + return requestBody; + } + + public Map getRequestFilter() { + return requestFilter; + } + + public Class getRequestedDomainObjType() { + return requestedDomainObjType; + } + + @SuppressWarnings("unchecked") + protected void castAndSetReply(final Object newValue) { + this.setReply((R) newValue); + } + + public String getInternalRequestID() { + return internalRequestID; + } + } +} diff --git a/client/src/main/java/io/opencmw/client/Endpoint.java b/client/src/main/java/io/opencmw/client/Endpoint.java new file mode 100644 index 00000000..74b97db6 --- /dev/null +++ b/client/src/main/java/io/opencmw/client/Endpoint.java @@ -0,0 +1,137 @@ +package io.opencmw.client; + +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Endpoint helper class to deserialise endpoint strings. + * Uses lazy initialisation to prevent doing unnecessary work or doing the same thing twice. + */ +public class Endpoint { // NOPMD data class + private static final String DEFAULT_SELECTOR = ""; + public static final String FILTER_TYPE_LONG = "long:"; + public static final String FILTER_TYPE_INT = "int:"; + public static final String FILTER_TYPE_BOOL = "bool:"; + public static final String FILTER_TYPE_DOUBLE = "double:"; + public static final String FILTER_TYPE_FLOAT = "float:"; + private final String value; + private String protocol; + private String address; + private String device; + private String property; + private String selector; + private Map filters; + + public Endpoint(final String endpoint) { + this.value = endpoint; + } + + public String getProtocol() { + if (protocol == null) { + parse(); + } + return protocol; + } + + @Override + public String toString() { + return value; + } + + public String getAddress() { + if (protocol == null) { + parse(); + } + return address; + } + + public String getDevice() { + if (protocol == null) { + parse(); + } + return device; + } + + public String getSelector() { + if (protocol == null) { + parse(); + } + return selector; + } + + public String getProperty() { + return property; + } + + public Map getFilters() { + return filters; + } + + public String getEndpointForContext(final String context) { + if (context == null || context.equals("")) { + return value; + } + parse(); + final String filterString = filters.entrySet().stream() // + .map(e -> { + String val; + if (e.getValue() instanceof String) { + val = (String) e.getValue(); + } else if (e.getValue() instanceof Integer) { + val = FILTER_TYPE_INT + e.getValue(); + } else if (e.getValue() instanceof Long) { + val = FILTER_TYPE_LONG + e.getValue(); + } else if (e.getValue() instanceof Boolean) { + val = FILTER_TYPE_BOOL + e.getValue(); + } else if (e.getValue() instanceof Double) { + val = FILTER_TYPE_DOUBLE + e.getValue(); + } else if (e.getValue() instanceof Float) { + val = FILTER_TYPE_FLOAT + e.getValue(); + } else { + throw new UnsupportedOperationException("Data type not supported in endpoint filters"); + } + return e.getKey() + '=' + val; + }) // + .collect(Collectors.joining("&")); + return address + '/' + device + '/' + property + "?ctx=" + context + '&' + filterString; + } + + private void parse() { + final String[] tmp = value.split("\\?", 2); // split into address/dev/prop and sel+filters part + final String[] adp = tmp[0].split("/"); // split access point into parts + device = adp[adp.length - 2]; // get device name from access point + property = adp[adp.length - 1]; // get property name from access point + address = tmp[0].substring(0, tmp[0].length() - device.length() - property.length() - 2); + protocol = address.substring(0, address.indexOf("://") + 3); + filters = new HashMap<>(); + selector = DEFAULT_SELECTOR; + filters = new HashMap<>(); + + final String paramString = tmp[1]; + final String[] kvpairs = paramString.split("&"); // split into individual key/value pairs + for (final String pair : kvpairs) { + String[] splitpair = pair.split("=", 2); // split at first equal sign + if (splitpair.length != 2) { + continue; + } + if ("ctx".equals(splitpair[0])) { + selector = splitpair[1]; + } else { + if (splitpair[1].startsWith(FILTER_TYPE_INT)) { + filters.put(splitpair[0], Integer.valueOf(splitpair[1].substring(FILTER_TYPE_INT.length()))); + } else if (splitpair[1].startsWith(FILTER_TYPE_LONG)) { + filters.put(splitpair[0], Long.valueOf(splitpair[1].substring(FILTER_TYPE_LONG.length()))); + } else if (splitpair[1].startsWith(FILTER_TYPE_BOOL)) { + filters.put(splitpair[0], Boolean.valueOf(splitpair[1].substring(FILTER_TYPE_BOOL.length()))); + } else if (splitpair[1].startsWith(FILTER_TYPE_DOUBLE)) { + filters.put(splitpair[0], Double.valueOf(splitpair[1].substring(FILTER_TYPE_DOUBLE.length()))); + } else if (splitpair[1].startsWith(FILTER_TYPE_FLOAT)) { + filters.put(splitpair[0], Float.valueOf(splitpair[1].substring(FILTER_TYPE_FLOAT.length()))); + } else { + filters.put(splitpair[0], splitpair[1]); + } + } + } + } +} diff --git a/client/src/main/java/io/opencmw/client/cmwlight/CmwLightDataSource.java b/client/src/main/java/io/opencmw/client/cmwlight/CmwLightDataSource.java new file mode 100644 index 00000000..d92ff899 --- /dev/null +++ b/client/src/main/java/io/opencmw/client/cmwlight/CmwLightDataSource.java @@ -0,0 +1,545 @@ +package io.opencmw.client.cmwlight; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.time.Duration; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.Queue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.zeromq.SocketType; +import org.zeromq.ZContext; +import org.zeromq.ZFrame; +import org.zeromq.ZMQ; +import org.zeromq.ZMQException; +import org.zeromq.ZMsg; + +import io.opencmw.client.DataSource; +import io.opencmw.client.Endpoint; +import io.opencmw.serialiser.IoSerialiser; +import io.opencmw.serialiser.spi.CmwLightSerialiser; + +/** + * A lightweight implementation of the CMW RDA3 client part. + * Reads all sockets from a single Thread, which can also be embedded into other event loops. + * Manages connection state and automatically reconnects broken connections and subscriptions. + */ +public class CmwLightDataSource extends DataSource { // NOPMD - class should probably be smaller + private static final Logger LOGGER = LoggerFactory.getLogger(CmwLightDataSource.class); + private static final AtomicLong CONNECTION_ID_GENERATOR = new AtomicLong(0); // global counter incremented for each connection + private static final AtomicInteger REQUEST_ID_GENERATOR = new AtomicInteger(0); + public static final String RDA_3_PROTOCOL = "rda3://"; + public static final Factory FACTORY = new Factory() { + @Override + public boolean matches(final String endpoint) { + return endpoint.startsWith(RDA_3_PROTOCOL); + } + + @Override + public Class getMatchingSerialiserType(final String endpoint) { + return CmwLightSerialiser.class; + } + + @Override + public DataSource newInstance(final ZContext context, final String endpoint, final Duration timeout, final String clientId) { + return new CmwLightDataSource(context, endpoint, clientId); + } + }; + protected static final int HEARTBEAT_INTERVAL = 1000; // time between to heartbeats in ms + protected static final int HEARTBEAT_ALLOWED_MISSES = 3; // number of heartbeats which can be missed before resetting the conection + protected static final long SUBSCRIPTION_TIMEOUT = 1000; // maximum time after which a connection should be reconnected + private static DirectoryLightClient directoryLightClient; + protected final AtomicInteger channelId = new AtomicInteger(0); // connection local counter incremented for each channel + protected final ZContext context; + protected final ZMQ.Socket socket; + protected final AtomicReference connectionState = new AtomicReference<>(ConnectionState.DISCONNECTED); + private final String address; + protected final String sessionId; + protected long connectionId; + protected final Map subscriptions = new HashMap<>(); // all subscriptions added to the server // NOPMD - only accessed from main thread + protected final Map subscriptionsByReqId = new HashMap<>(); // all subscriptions added to the server // NOPMD - only accessed from main thread + protected final Map replyIdMap = new HashMap<>(); // all acknowledged subscriptions by their reply id // NOPMD - only accessed from main thread + protected long lastHbReceived = -1; + protected long lastHbSent = -1; + protected int backOff = 20; + private final Queue queuedRequests = new LinkedBlockingQueue<>(); + private final Map pendingRequests = new HashMap<>(); // NOPMD - only accessed from main thread + private String connectedAddress = ""; + + public CmwLightDataSource(final ZContext context, final String endpoint, final String clientId) { + super(endpoint); + LOGGER.atTrace().addArgument(endpoint).log("connecting to: {}"); + this.context = context; + this.socket = context.createSocket(SocketType.DEALER); + this.sessionId = getSessionId(clientId); + this.address = new Endpoint(endpoint).getAddress(); + } + + public static DirectoryLightClient getDirectoryLightClient() { + return directoryLightClient; + } + + public static void setDirectoryLightClient(final DirectoryLightClient directoryLightClient) { + CmwLightDataSource.directoryLightClient = directoryLightClient; + } + + public CmwLightMessage receiveData() { + // receive data + try { + final ZMsg data = ZMsg.recvMsg(socket, ZMQ.DONTWAIT); + if (data == null) { + return null; + } + return CmwLightProtocol.parseMsg(data); + } catch (CmwLightProtocol.RdaLightException e) { + LOGGER.atDebug().setCause(e).log("error parsing cmw light reply: "); + return null; + } + } + @Override + public ZMsg getMessage() { // return maintenance objects instead of replies + final long currentTime = System.currentTimeMillis(); // NOPMD + CmwLightMessage reply = receiveData(); + if (reply == null) { + return null; + } + switch (reply.messageType) { + case SERVER_CONNECT_ACK: + if (connectionState.get().equals(ConnectionState.CONNECTING)) { + LOGGER.atTrace().addArgument(connectedAddress).log("Connected to server: {}"); + connectionState.set(ConnectionState.CONNECTED); + lastHbReceived = currentTime; + backOff = 20; // reset back-off time + } else { + LOGGER.atWarn().addArgument(reply).log("ignoring unsolicited connection acknowledgement: {}"); + } + return new ZMsg(); + case SERVER_HB: + if (connectionState.get() != ConnectionState.CONNECTED) { + LOGGER.atWarn().addArgument(reply).log("ignoring heartbeat received before connection established: {}"); + return new ZMsg(); + } + lastHbReceived = currentTime; + return new ZMsg(); + case SERVER_REP: + if (connectionState.get() != ConnectionState.CONNECTED) { + LOGGER.atWarn().addArgument(reply).log("ignoring data received before connection established: {}"); + return new ZMsg(); + } + lastHbReceived = currentTime; + return handleServerReply(reply, currentTime); + case CLIENT_CONNECT: + case CLIENT_REQ: + case CLIENT_HB: + default: + LOGGER.atWarn().addArgument(reply).log("ignoring client message from server: {}"); + return new ZMsg(); + } + } + + private ZMsg handleServerReply(final CmwLightMessage reply, final long currentTime) { //NOPMD + final ZMsg result = new ZMsg(); + switch (reply.requestType) { + case REPLY: + Request requestForReply = pendingRequests.remove(reply.id); + result.add(requestForReply.requestId); + result.add(new Endpoint(requestForReply.endpoint).getEndpointForContext(reply.dataContext.cycleName)); + result.add(new ZFrame(new byte[0])); // header + result.add(reply.bodyData); // body + result.add(new ZFrame(new byte[0])); // exception + return result; + case EXCEPTION: + final Request requestForException = pendingRequests.remove(reply.id); + result.add(requestForException.requestId); + result.add(requestForException.endpoint); + result.add(new ZFrame(new byte[0])); // header + result.add(new ZFrame(new byte[0])); // body + result.add(reply.exceptionMessage.message); // exception + return result; + case SUBSCRIBE: + final long id = reply.id; + final Subscription sub = subscriptions.get(id); + sub.updateId = (long) reply.options.get(CmwLightProtocol.FieldName.SOURCE_ID_TAG.value()); + replyIdMap.put(sub.updateId, sub); + sub.subscriptionState = SubscriptionState.SUBSCRIBED; + LOGGER.atDebug().addArgument(sub.device).addArgument(sub.property).log("subscription successful: {}/{}"); + sub.backOff = 20; + return result; + case UNSUBSCRIBE: + // successfully removed subscription + final Subscription subscriptionForUnsub = subscriptions.remove(reply.id); + subscriptionsByReqId.remove(subscriptionForUnsub.idString); + replyIdMap.remove(subscriptionForUnsub.updateId); + return result; + case NOTIFICATION_DATA: + final Subscription subscriptionForNotification = replyIdMap.get(reply.id); + if (subscriptionForNotification == null) { + LOGGER.atInfo().addArgument(reply.toString()).log("Got unsolicited subscription data: {}"); + return result; + } + result.add(subscriptionForNotification.idString); + result.add(new Endpoint(subscriptionForNotification.endpoint).getEndpointForContext(reply.dataContext.cycleName)); + result.add(new ZFrame(new byte[0])); // header + result.add(reply.bodyData); // body + result.add(new ZFrame(new byte[0])); // exception + return result; + case NOTIFICATION_EXC: + final Subscription subscriptionForNotifyExc = replyIdMap.get(reply.id); + if (subscriptionForNotifyExc == null) { + LOGGER.atInfo().addArgument(reply.toString()).log("Got unsolicited subscription notification error: {}"); + return result; + } + result.add(subscriptionForNotifyExc.idString); + result.add(subscriptionForNotifyExc.endpoint); + result.add(new ZFrame(new byte[0])); // header + result.add(new ZFrame(new byte[0])); // body + result.add(reply.exceptionMessage.message); // exception + return result; + case SUBSCRIBE_EXCEPTION: + final Subscription subForSubExc = subscriptions.get(reply.id); + subForSubExc.subscriptionState = SubscriptionState.UNSUBSCRIBED; + subForSubExc.timeoutValue = currentTime + subForSubExc.backOff; + subForSubExc.backOff *= 2; + LOGGER.atDebug().addArgument(subForSubExc.device).addArgument(subForSubExc.property).log("exception during subscription, retrying: {}/{}"); + result.add(subForSubExc.idString); + result.add(subForSubExc.endpoint); + result.add(new ZFrame(new byte[0])); // header + result.add(new ZFrame(new byte[0])); // body + result.add(reply.exceptionMessage.message); // exception + return result; + // unsupported or non-actionable replies + case GET: + case SET: + case CONNECT: + case EVENT: + case SESSION_CONFIRM: + default: + return result; + } + } + + public enum ConnectionState { + DISCONNECTED, + CONNECTING, + CONNECTED + } + + @Override + public void get(final String requestId, final String endpoint, final byte[] filters, final byte[] data, final byte[] rbacToken) { + final Request request = new Request(CmwLightProtocol.RequestType.GET, requestId, endpoint, filters, data, rbacToken); + queuedRequests.add(request); + } + + @Override + public void set(final String requestId, final String endpoint, final byte[] filters, final byte[] data, final byte[] rbacToken) { + final Request request = new Request(CmwLightProtocol.RequestType.SET, requestId, endpoint, filters, data, rbacToken); + queuedRequests.add(request); + } + + @Override + public void subscribe(final String reqId, final String endpoint, final byte[] rbacToken) { + final Endpoint ep = new Endpoint(endpoint); + final Subscription sub = new Subscription(endpoint, ep.getDevice(), ep.getProperty(), ep.getSelector(), ep.getFilters()); + sub.idString = reqId; + subscriptions.put(sub.id, sub); + subscriptionsByReqId.put(reqId, sub); + } + + @Override + public void unsubscribe(final String reqId) { + subscriptionsByReqId.get(reqId).subscriptionState = SubscriptionState.CANCELED; + } + + public ConnectionState getConnectionState() { + return connectionState.get(); + } + + public ZContext getContext() { + return context; + } + + @Override + public ZMQ.Socket getSocket() { + return socket; + } + + @Override + protected Factory getFactory() { + return FACTORY; + } + + private String getIdentity() { + String hostname; + try { + hostname = InetAddress.getLocalHost().getHostName(); + } catch (UnknownHostException e) { + hostname = "localhost"; + } + final long processId = ProcessHandle.current().pid(); + connectionId = CONNECTION_ID_GENERATOR.incrementAndGet(); + final int chId = this.channelId.incrementAndGet(); + return hostname + '/' + processId + '/' + connectionId + '/' + chId; + } + + private String getSessionId(final String clientId) { + return "cmwLightClient{pid=" + ProcessHandle.current().pid() + ", conn=" + connectionId + ", clientId=" + clientId + '}'; // Todo: create identification string, cmw uses string with user/app name, pid, etc + } + + public void connect() { + if (connectionState.getAndSet(ConnectionState.CONNECTING) != ConnectionState.DISCONNECTED) { + return; // already connected + } + String address = this.address.startsWith(RDA_3_PROTOCOL) ? this.address.substring(RDA_3_PROTOCOL.length()) : this.address; + if (!address.contains(":")) { + try { + DirectoryLightClient.Device device = directoryLightClient.getDeviceInfo(Collections.singletonList(address)).get(0); + LOGGER.atTrace().addArgument(address).addArgument(device).log("resolved address for device {}: {}"); + address = device.servers.stream().findFirst().orElseThrow().get("Address:"); + } catch (NullPointerException | NoSuchElementException | DirectoryLightClient.DirectoryClientException e) { // NOPMD - directory client must be refactored anyway + LOGGER.atDebug().addArgument(e.getMessage()).log("Error resolving device from nameserver, using address from endpoint. Error was: {}"); + backOff = backOff * 2; + connectionState.set(ConnectionState.DISCONNECTED); + return; + } + } + lastHbSent = System.currentTimeMillis(); + try { + final String identity = getIdentity(); + connectedAddress = "tcp://" + address; + LOGGER.atDebug().addArgument(connectedAddress).addArgument(identity).log("connecting to: {} with identity {}"); + socket.setIdentity(identity.getBytes()); // hostname/process/id/channel + socket.connect(connectedAddress); + CmwLightProtocol.sendMsg(socket, CmwLightMessage.connect(CmwLightProtocol.VERSION)); + } catch (ZMQException | CmwLightProtocol.RdaLightException e) { + LOGGER.atDebug().setCause(e).log("failed to connect: "); + backOff = backOff * 2; + connectionState.set(ConnectionState.DISCONNECTED); + } + } + + private void disconnect() { + LOGGER.atDebug().addArgument(connectedAddress).log("disconnecting {}"); + connectionState.set(ConnectionState.DISCONNECTED); + try { + socket.disconnect(connectedAddress); + } catch (ZMQException e) { + LOGGER.atError().setCause(e).log("Failed to disconnect socket"); + } + // disconnect/reset subscriptions + for (Subscription sub : subscriptions.values()) { + sub.subscriptionState = SubscriptionState.UNSUBSCRIBED; + } + } + + @Override + public long housekeeping() { + final long currentTime = System.currentTimeMillis(); + switch (connectionState.get()) { + case DISCONNECTED: // reconnect after adequate back off + if (currentTime > lastHbSent + backOff) { + LOGGER.atTrace().addArgument(address).log("Connecting to {}"); + connect(); + } + return lastHbSent + backOff; + case CONNECTING: + if (currentTime > lastHbSent + HEARTBEAT_INTERVAL * HEARTBEAT_ALLOWED_MISSES) { // connect timed out -> increase back of and retry + backOff = backOff * 2; + lastHbSent = currentTime; + LOGGER.atTrace().addArgument(connectedAddress).addArgument(backOff).log("Connection timed out for {}, retrying in {} ms"); + disconnect(); + } + return lastHbSent + HEARTBEAT_INTERVAL * HEARTBEAT_ALLOWED_MISSES; + case CONNECTED: + Request request; + while ((request = queuedRequests.poll()) != null) { + pendingRequests.put(request.id, request); + sendRequest(request); + } + if (currentTime > lastHbSent + HEARTBEAT_INTERVAL) { // check for heartbeat interval + // send Heartbeats + sendHeartBeat(); + lastHbSent = currentTime; + // check if heartbeat was received + if (lastHbReceived + HEARTBEAT_INTERVAL * HEARTBEAT_ALLOWED_MISSES < currentTime) { + LOGGER.atDebug().addArgument(backOff).log("Connection timed out, reconnecting in {} ms"); + disconnect(); + return HEARTBEAT_INTERVAL; + } + // check timeouts of connection/subscription requests + for (Subscription sub : subscriptions.values()) { + updateSubscription(currentTime, sub); + } + } + return lastHbSent + HEARTBEAT_INTERVAL; + default: + throw new IllegalStateException("unexpected connection state: " + connectionState.get()); + } + } + + private void sendRequest(final Request request) { + // Filters and data are already serialised but the protocol saves them deserialised :/ + // final ZFrame data = request.data == null ? new ZFrame(new byte[0]) : new ZFrame(request.data); + // final ZFrame filters = request.filters == null ? new ZFrame(new byte[0]) : new ZFrame(request.filters); + final Endpoint requestEndpoint = new Endpoint(request.endpoint); + + try { + switch (request.requestType) { + case GET: + CmwLightProtocol.sendMsg(socket, CmwLightMessage.getRequest( + sessionId, request.id, requestEndpoint.getDevice(), requestEndpoint.getProperty(), + new CmwLightMessage.RequestContext(requestEndpoint.getSelector(), requestEndpoint.getFilters(), null))); + break; + case SET: + Objects.requireNonNull(request.data, "Data for set cannot be null"); + CmwLightProtocol.sendMsg(socket, CmwLightMessage.setRequest( + sessionId, request.id, requestEndpoint.getDevice(), requestEndpoint.getProperty(), + new ZFrame(request.data), + new CmwLightMessage.RequestContext(requestEndpoint.getSelector(), requestEndpoint.getFilters(), null))); + break; + default: + throw new CmwLightProtocol.RdaLightException("Message of unknown type"); + } + } catch (CmwLightProtocol.RdaLightException e) { + LOGGER.atDebug().setCause(e).log("Error sending get request:"); + } + } + + private void updateSubscription(final long currentTime, final Subscription sub) { + switch (sub.subscriptionState) { + case SUBSCRIBING: + // check timeout + if (currentTime > sub.timeoutValue) { + sub.subscriptionState = SubscriptionState.UNSUBSCRIBED; + sub.timeoutValue = currentTime + sub.backOff; + sub.backOff = sub.backOff * 2; // exponential back of + LOGGER.atDebug().addArgument(sub.device).addArgument(sub.property).log("subscription timed out, retrying: {}/{}"); + } + break; + case UNSUBSCRIBED: + if (currentTime > sub.timeoutValue) { + LOGGER.atDebug().addArgument(sub.device).addArgument(sub.property).log("subscribing {}/{}"); + sendSubscribe(sub); + } + break; + case SUBSCRIBED: + case UNSUBSCRIBE_SENT: + // do nothing + break; + case CANCELED: + sendUnsubscribe(sub); + break; + default: + throw new IllegalStateException("unexpected subscription state: " + sub.subscriptionState); + } + } + + public void sendHeartBeat() { + try { + CmwLightProtocol.sendMsg(socket, CmwLightMessage.CLIENT_HB); + } catch (CmwLightProtocol.RdaLightException e) { + LOGGER.atDebug().setCause(e).log("Error sending heartbeat"); + } + } + + private void sendSubscribe(final Subscription sub) { + if (!sub.subscriptionState.equals(SubscriptionState.UNSUBSCRIBED)) { + return; // already subscribed/subscription in progress + } + try { + CmwLightProtocol.sendMsg(socket, CmwLightMessage.subscribeRequest( + sessionId, sub.id, sub.device, sub.property, + Map.of(CmwLightProtocol.FieldName.SESSION_BODY_TAG.value(), Collections.emptyMap()), + new CmwLightMessage.RequestContext(sub.selector, sub.filters, null), + CmwLightProtocol.UpdateType.IMMEDIATE_UPDATE)); + sub.subscriptionState = SubscriptionState.SUBSCRIBING; + sub.timeoutValue = System.currentTimeMillis() + SUBSCRIPTION_TIMEOUT; + } catch (CmwLightProtocol.RdaLightException e) { + LOGGER.atDebug().setCause(e).log("Error subscribing to property:"); + sub.timeoutValue = System.currentTimeMillis() + sub.backOff; + sub.backOff *= 2; + } + } + + private void sendUnsubscribe(final Subscription sub) { + try { + CmwLightProtocol.sendMsg(socket, CmwLightMessage.unsubscribeRequest( + sessionId, sub.updateId, sub.device, sub.property, + Map.of(CmwLightProtocol.FieldName.SESSION_BODY_TAG.value(), Collections.emptyMap()), + CmwLightProtocol.UpdateType.IMMEDIATE_UPDATE)); + sub.subscriptionState = SubscriptionState.UNSUBSCRIBE_SENT; + } catch (CmwLightProtocol.RdaLightException e) { + LOGGER.atError().addArgument(sub.property).log("failed to unsubscribe "); + } + } + + public static class Subscription { + private final long id = REQUEST_ID_GENERATOR.incrementAndGet(); + public final String property; + public final String device; + public final String selector; + public final Map filters; + public final String endpoint; + public SubscriptionState subscriptionState = SubscriptionState.UNSUBSCRIBED; + public int backOff = 20; + public long updateId = -1; + public long timeoutValue = -1; + public String idString = ""; + + public Subscription(final String endpoint, final String device, final String property, final String selector, final Map filters) { + this.endpoint = endpoint; + this.property = property; + this.device = device; + this.selector = selector; + this.filters = filters; + } + + @Override + public String toString() { + return "Subscription{" + + "property='" + property + '\'' + ", device='" + device + '\'' + ", selector='" + selector + '\'' + ", filters=" + filters + ", subscriptionState=" + subscriptionState + ", backOff=" + backOff + ", id=" + id + ", updateId=" + updateId + ", timeoutValue=" + timeoutValue + '}'; + } + } + + public static class Request { // NOPMD - data class + public final byte[] filters; + public final byte[] data; + public final long id; + private final String requestId; + private final String endpoint; + private final byte[] rbacToken; + public final CmwLightProtocol.RequestType requestType; + + public Request(final CmwLightProtocol.RequestType requestType, + final String requestId, + final String endpoint, + final byte[] filters, // NOPMD - zero copy contract + final byte[] data, // NOPMD - zero copy contract + final byte[] rbacToken // NOPMD - zero copy contract + ) { + this.requestType = requestType; + this.id = REQUEST_ID_GENERATOR.incrementAndGet(); + this.requestId = requestId; + this.endpoint = endpoint; + this.filters = filters; + this.data = data; + this.rbacToken = rbacToken; + } + } + + public enum SubscriptionState { + UNSUBSCRIBED, + SUBSCRIBING, + SUBSCRIBED, + CANCELED, + UNSUBSCRIBE_SENT + } +} diff --git a/client/src/main/java/io/opencmw/client/cmwlight/CmwLightMessage.java b/client/src/main/java/io/opencmw/client/cmwlight/CmwLightMessage.java new file mode 100644 index 00000000..640d498b --- /dev/null +++ b/client/src/main/java/io/opencmw/client/cmwlight/CmwLightMessage.java @@ -0,0 +1,427 @@ +package io.opencmw.client.cmwlight; + +import java.util.Map; +import java.util.Objects; + +import org.zeromq.ZFrame; + +/** + * Data representation for all Messages exchanged between CMW client and server + */ +@SuppressWarnings({ "PMD.ExcessivePublicCount", "PMD.TooManyMethods", "PMD.TooManyFields" }) // - the nature of this class definition +public class CmwLightMessage { + // general fields + public CmwLightProtocol.MessageType messageType; + + // Connection Req/Ack + public String version; + + // header data + public CmwLightProtocol.RequestType requestType; + public long id; + public String deviceName; + public CmwLightProtocol.UpdateType updateType; + public String sessionId; + public String propertyName; + public Map options; + public Map data; + + // additional data + public ZFrame bodyData; + public ExceptionMessage exceptionMessage; + public RequestContext requestContext; + public DataContext dataContext; + + // Subscription Update + public long notificationId; + + // subscription established + public long sourceId; + public Map sessionBody; + + // static instances for low level message types + public static final CmwLightMessage SERVER_HB = new CmwLightMessage(CmwLightProtocol.MessageType.SERVER_HB); + public static final CmwLightMessage CLIENT_HB = new CmwLightMessage(CmwLightProtocol.MessageType.CLIENT_HB); + // static functions to get certain message types + public static CmwLightMessage connectAck(final String version) { + final CmwLightMessage msg = new CmwLightMessage(CmwLightProtocol.MessageType.SERVER_CONNECT_ACK); + msg.version = version; + return msg; + } + + public static CmwLightMessage connect(final String version) { + final CmwLightMessage msg = new CmwLightMessage(CmwLightProtocol.MessageType.CLIENT_CONNECT); + msg.version = version; + return msg; + } + public static CmwLightMessage subscribeRequest(String sessionId, long id, String device, String property, final Map options, RequestContext requestContext, CmwLightProtocol.UpdateType updateType) { + final CmwLightMessage msg = new CmwLightMessage(CmwLightProtocol.MessageType.CLIENT_REQ); + msg.requestType = CmwLightProtocol.RequestType.SUBSCRIBE; + msg.id = id; + msg.options = options; + msg.sessionId = sessionId; + msg.deviceName = device; + msg.propertyName = property; + msg.requestContext = requestContext; + msg.updateType = updateType; + return msg; + } + public static CmwLightMessage subscribeReply(String sessionId, long id, String device, String property, final Map options) { + final CmwLightMessage msg = new CmwLightMessage(CmwLightProtocol.MessageType.SERVER_REP); + msg.requestType = CmwLightProtocol.RequestType.SUBSCRIBE; + msg.id = id; + msg.options = options; + msg.sessionId = sessionId; + msg.deviceName = device; + msg.propertyName = property; + return msg; + } + public static CmwLightMessage unsubscribeRequest(String sessionId, long id, String device, String property, final Map options, CmwLightProtocol.UpdateType updateType) { + final CmwLightMessage msg = new CmwLightMessage(CmwLightProtocol.MessageType.CLIENT_REQ); + msg.requestType = CmwLightProtocol.RequestType.UNSUBSCRIBE; + msg.id = id; + msg.options = options; + msg.sessionId = sessionId; + msg.deviceName = device; + msg.propertyName = property; + msg.updateType = updateType; + return msg; + } + public static CmwLightMessage getRequest(String sessionId, long id, String device, String property, RequestContext requestContext) { + final CmwLightMessage msg = new CmwLightMessage(); + msg.messageType = CmwLightProtocol.MessageType.CLIENT_REQ; + msg.requestType = CmwLightProtocol.RequestType.GET; + msg.id = id; + msg.sessionId = sessionId; + msg.deviceName = device; + msg.propertyName = property; + msg.requestContext = requestContext; + msg.updateType = CmwLightProtocol.UpdateType.NORMAL; + return msg; + } + + public static CmwLightMessage setRequest(final String sessionId, final long id, final String device, final String property, final ZFrame data, final RequestContext requestContext) { + final CmwLightMessage msg = new CmwLightMessage(); + msg.messageType = CmwLightProtocol.MessageType.CLIENT_REQ; + msg.requestType = CmwLightProtocol.RequestType.SET; + msg.id = id; + msg.sessionId = sessionId; + msg.deviceName = device; + msg.propertyName = property; + msg.requestContext = requestContext; + msg.updateType = CmwLightProtocol.UpdateType.NORMAL; + msg.bodyData = data; + return msg; + } + + public static CmwLightMessage exceptionReply(final String sessionId, final long id, final String device, final String property, final String message, final long contextAcqStamp, final long contextCycleStamp, final byte type) { + final CmwLightMessage msg = new CmwLightMessage(CmwLightProtocol.MessageType.SERVER_REP); + msg.requestType = CmwLightProtocol.RequestType.EXCEPTION; + msg.id = id; + msg.sessionId = sessionId; + msg.deviceName = device; + msg.propertyName = property; + msg.updateType = CmwLightProtocol.UpdateType.NORMAL; + msg.exceptionMessage = new ExceptionMessage(contextAcqStamp, contextCycleStamp, message, type); + return msg; + } + + public static CmwLightMessage subscribeExceptionReply(final String sessionId, final long id, final String device, final String property, final String message, final long contextAcqStamp, final long contextCycleStamp, final byte type) { + final CmwLightMessage msg = new CmwLightMessage(CmwLightProtocol.MessageType.SERVER_REP); + msg.requestType = CmwLightProtocol.RequestType.SUBSCRIBE_EXCEPTION; + msg.id = id; + msg.sessionId = sessionId; + msg.deviceName = device; + msg.propertyName = property; + msg.updateType = CmwLightProtocol.UpdateType.NORMAL; + msg.exceptionMessage = new ExceptionMessage(contextAcqStamp, contextCycleStamp, message, type); + return msg; + } + + public static CmwLightMessage notificationExceptionReply(final String sessionId, final long id, final String device, final String property, final String message, final long contextAcqStamp, final long contextCycleStamp, final byte type) { + final CmwLightMessage msg = new CmwLightMessage(CmwLightProtocol.MessageType.SERVER_REP); + msg.requestType = CmwLightProtocol.RequestType.NOTIFICATION_EXC; + msg.id = id; + msg.sessionId = sessionId; + msg.deviceName = device; + msg.propertyName = property; + msg.updateType = CmwLightProtocol.UpdateType.NORMAL; + msg.exceptionMessage = new ExceptionMessage(contextAcqStamp, contextCycleStamp, message, type); + return msg; + } + + public static CmwLightMessage notificationReply(final String sessionId, final long id, final String device, final String property, final ZFrame data, final long notificationId, final DataContext requestContext, final CmwLightProtocol.UpdateType updateType) { + final CmwLightMessage msg = new CmwLightMessage(CmwLightProtocol.MessageType.SERVER_REP); + msg.requestType = CmwLightProtocol.RequestType.NOTIFICATION_DATA; + msg.id = id; + msg.sessionId = sessionId; + msg.deviceName = device; + msg.propertyName = property; + msg.notificationId = notificationId; + msg.options = Map.of(CmwLightProtocol.FieldName.NOTIFICATION_ID_TAG.value(), notificationId); + msg.dataContext = requestContext; + msg.updateType = updateType; + msg.bodyData = data; + return msg; + } + + public static CmwLightMessage getReply(final String sessionId, final long id, final String device, final String property, final ZFrame data, final DataContext requestContext) { + final CmwLightMessage msg = new CmwLightMessage(CmwLightProtocol.MessageType.SERVER_REP); + msg.requestType = CmwLightProtocol.RequestType.REPLY; + msg.id = id; + msg.sessionId = sessionId; + msg.deviceName = device; + msg.propertyName = property; + msg.dataContext = requestContext; + msg.bodyData = data; + return msg; + } + + public static CmwLightMessage sessionConfirmReply(final String sessionId, final long id, final String device, final String property, final Map options) { + final CmwLightMessage msg = new CmwLightMessage(); + msg.messageType = CmwLightProtocol.MessageType.SERVER_REP; + msg.requestType = CmwLightProtocol.RequestType.SESSION_CONFIRM; + msg.id = id; + msg.options = options; + msg.sessionId = sessionId; + msg.deviceName = device; + msg.propertyName = property; + msg.updateType = CmwLightProtocol.UpdateType.NORMAL; + return msg; + } + + public static CmwLightMessage eventReply(final String sessionId, final long id, final String device, final String property) { + final CmwLightMessage msg = new CmwLightMessage(); + msg.messageType = CmwLightProtocol.MessageType.SERVER_REP; + msg.requestType = CmwLightProtocol.RequestType.EVENT; + msg.id = id; + msg.sessionId = sessionId; + msg.deviceName = device; + msg.propertyName = property; + msg.updateType = CmwLightProtocol.UpdateType.NORMAL; + return msg; + } + + public static CmwLightMessage eventRequest(final String sessionId, final long id, final String device, final String property) { + final CmwLightMessage msg = new CmwLightMessage(); + msg.messageType = CmwLightProtocol.MessageType.CLIENT_REQ; + msg.requestType = CmwLightProtocol.RequestType.EVENT; + msg.id = id; + msg.sessionId = sessionId; + msg.deviceName = device; + msg.propertyName = property; + msg.updateType = CmwLightProtocol.UpdateType.NORMAL; + return msg; + } + + public static CmwLightMessage connectRequest(final String sessionId, final long id, final String device, final String property) { + final CmwLightMessage msg = new CmwLightMessage(); + msg.messageType = CmwLightProtocol.MessageType.CLIENT_REQ; + msg.requestType = CmwLightProtocol.RequestType.CONNECT; + msg.id = id; + msg.sessionId = sessionId; + msg.deviceName = device; + msg.propertyName = property; + msg.updateType = CmwLightProtocol.UpdateType.NORMAL; + return msg; + } + + protected CmwLightMessage() { + // Constructor only accessible from within serialiser and factory methods to only allow valid messages + } + + protected CmwLightMessage(final CmwLightProtocol.MessageType messageType) { + this.messageType = messageType; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (!(o instanceof CmwLightMessage)) { + return false; + } + final CmwLightMessage that = (CmwLightMessage) o; + return id == that.id && notificationId == that.notificationId && sourceId == that.sourceId && messageType == that.messageType && Objects.equals(version, that.version) && requestType == that.requestType && Objects.equals(deviceName, that.deviceName) && updateType == that.updateType && Objects.equals(sessionId, that.sessionId) && Objects.equals(propertyName, that.propertyName) && Objects.equals(options, that.options) && Objects.equals(data, that.data) && Objects.equals(bodyData, that.bodyData) && Objects.equals(exceptionMessage, that.exceptionMessage) && Objects.equals(requestContext, that.requestContext) && Objects.equals(dataContext, that.dataContext) && Objects.equals(sessionBody, that.sessionBody); + } + + @Override + public int hashCode() { + return Objects.hash(messageType, version, requestType, id, deviceName, updateType, sessionId, propertyName, options, data, bodyData, exceptionMessage, requestContext, dataContext, notificationId, sourceId, sessionBody); + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("CmwMessage: "); + switch (messageType) { + case CLIENT_CONNECT: + sb.append("Connection request, client version='").append(version).append('\''); + break; + case SERVER_CONNECT_ACK: + sb.append("Connection ack, server version='").append(version).append('\''); + break; + case CLIENT_HB: + sb.append("client heartbeat"); + break; + case SERVER_HB: + sb.append("server heartbeat"); + break; + case SERVER_REP: + sb.append("server reply: ").append(requestType.name()); + case CLIENT_REQ: + if (messageType == CmwLightProtocol.MessageType.CLIENT_REQ) { + sb.append("client request: ").append(requestType.name()); + } + sb.append(" id: ").append(id).append(" deviceName=").append(deviceName).append(", updateType=").append(updateType).append(", sessionId='").append(sessionId).append("', propertyName='").append(propertyName).append("', options=").append(options).append(", data=").append(data).append(", sourceId=").append(sourceId); + switch (requestType) { + case GET: + case SET: + case SUBSCRIBE: + case UNSUBSCRIBE: + sb.append("\n requestContext=").append(requestContext); + break; + case REPLY: + case NOTIFICATION_DATA: + sb.append(", notificationId=").append(notificationId).append("\n bodyData=").append(bodyData).append("\n dataContext=").append(dataContext); + break; + case EXCEPTION: + case NOTIFICATION_EXC: + case SUBSCRIBE_EXCEPTION: + sb.append("\n exceptionMessage=").append(exceptionMessage); + break; + case SESSION_CONFIRM: + sb.append(", sessionBody='").append(sessionBody).append('\''); + break; + case CONNECT: + case EVENT: + break; + default: + throw new IllegalStateException("unknown client request message type: " + messageType); + } + break; + default: + throw new IllegalStateException("unknown message type: " + messageType); + } + return sb.toString(); + } + + public static class RequestContext { + public String selector; + public Map data; + public Map filters; + + public RequestContext(final String selector, final Map filters, final Map data) { + this.selector = selector; + this.filters = filters; + this.data = data; + } + + protected RequestContext() { + // default constructor only available to protocol (de)serialisers + } + + @Override + public String toString() { + return "RequestContext{" + + "selector='" + selector + '\'' + ", data=" + data + ", filters=" + filters + '}'; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (!(o instanceof RequestContext)) { + return false; + } + final RequestContext that = (RequestContext) o; + return selector.equals(that.selector) && Objects.equals(data, that.data) && Objects.equals(filters, that.filters); + } + + @Override + public int hashCode() { + return Objects.hash(selector, data, filters); + } + } + + public static class DataContext { + public String cycleName; + public long cycleStamp; + public long acqStamp; + public Map data; + + public DataContext(final String cycleName, final long cycleStamp, final long acqStamp, final Map data) { + this.cycleName = cycleName; + this.cycleStamp = cycleStamp; + this.acqStamp = acqStamp; + this.data = data; + } + + protected DataContext() { + // allow only protocol serialiser to create empty object + } + + @Override + public String toString() { + return "DataContext{cycleName='" + cycleName + '\'' + ", cycleStamp=" + cycleStamp + ", acqStamp=" + acqStamp + ", data=" + data + '}'; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (!(o instanceof DataContext)) { + return false; + } + final DataContext that = (DataContext) o; + return cycleStamp == that.cycleStamp && acqStamp == that.acqStamp && cycleName.equals(that.cycleName) && Objects.equals(data, that.data); + } + + @Override + public int hashCode() { + return Objects.hash(cycleName, cycleStamp, acqStamp, data); + } + } + + public static class ExceptionMessage { + public long contextAcqStamp; + public long contextCycleStamp; + public String message; + public byte type; + + public ExceptionMessage(final long contextAcqStamp, final long contextCycleStamp, final String message, final byte type) { + this.contextAcqStamp = contextAcqStamp; + this.contextCycleStamp = contextCycleStamp; + this.message = message; + this.type = type; + } + + protected ExceptionMessage() { + // allow only protocol serialiser to create empty object + } + + @Override + public String toString() { + return "ExceptionMessage{contextAcqStamp=" + contextAcqStamp + ", contextCycleStamp=" + contextCycleStamp + ", message='" + message + '\'' + ", type=" + type + '}'; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ExceptionMessage)) { + return false; + } + final ExceptionMessage that = (ExceptionMessage) o; + return contextAcqStamp == that.contextAcqStamp && contextCycleStamp == that.contextCycleStamp && type == that.type && message.equals(that.message); + } + + @Override + public int hashCode() { + return Objects.hash(contextAcqStamp, contextCycleStamp, message, type); + } + } +} diff --git a/client/src/main/java/io/opencmw/client/cmwlight/CmwLightProtocol.java b/client/src/main/java/io/opencmw/client/cmwlight/CmwLightProtocol.java new file mode 100644 index 00000000..e42ba502 --- /dev/null +++ b/client/src/main/java/io/opencmw/client/cmwlight/CmwLightProtocol.java @@ -0,0 +1,621 @@ +package io.opencmw.client.cmwlight; + +import java.nio.charset.Charset; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +import org.jetbrains.annotations.NotNull; +import org.zeromq.ZFrame; +import org.zeromq.ZMQ; +import org.zeromq.ZMsg; + +import io.opencmw.serialiser.DataType; +import io.opencmw.serialiser.FieldDescription; +import io.opencmw.serialiser.IoBuffer; +import io.opencmw.serialiser.IoClassSerialiser; +import io.opencmw.serialiser.spi.CmwLightSerialiser; +import io.opencmw.serialiser.spi.FastByteBuffer; +import io.opencmw.serialiser.spi.WireDataFieldDescription; + +/** + * A lightweight implementation of the CMW RDA client protocol part. + * Serializes CmwLightMessage to ZeroMQ messages and vice versa. + */ +@SuppressWarnings("PMD.UnusedLocalVariable") // Unused variables are taken from the protocol and should be available for reference +public class CmwLightProtocol { //NOPMD -- nomen est omen + private static final String CONTEXT_ACQ_STAMP = "ContextAcqStamp"; + private static final String CONTEXT_CYCLE_STAMP = "ContextCycleStamp"; + private static final String MESSAGE = "Message"; + private static final String TYPE = "Type"; + private static final String EMPTY_CONTEXT = "empty context data for request type: "; + private static final int MAX_MSG_SIZE = 4096 * 1024; + private static final IoBuffer IO_BUFFER = new FastByteBuffer(MAX_MSG_SIZE); + private static final CmwLightSerialiser SERIALISER = new CmwLightSerialiser(IO_BUFFER); + private static final IoClassSerialiser IO_CLASS_SERIALISER = new IoClassSerialiser(IO_BUFFER); + public static final String VERSION = "1.0.0"; // Protocol version used if msg.version is null or empty + private static final int SERIALISER_QUIRK = 100; // there seems to be a bug in the serialiser which does not update the buffer position correctly, so send more + + private CmwLightProtocol() { + // utility class + } + + /** + * The message specified by the byte contained in the first frame of a message defines what type of message is present + */ + public enum MessageType { + SERVER_CONNECT_ACK(0x01), + SERVER_REP(0x02), + SERVER_HB(0x03), + CLIENT_CONNECT(0x20), + CLIENT_REQ(0x21), + CLIENT_HB(0x22); + + private static final int CLIENT_API_RANGE = 0x4; + private static final int SERVER_API_RANGE = 0x20; + private final byte value; + + MessageType(int value) { + this.value = (byte) value; + } + + public byte value() { + return value; + } + + public static MessageType of(int value) { // NOPMD -- nomen est omen + if (value < CLIENT_API_RANGE) { + return values()[value - 1]; + } else { + return values()[value - SERVER_API_RANGE + CLIENT_CONNECT.ordinal()]; + } + } + } + + /** + * Frame Types in the descriptor (Last frame of a message containing the type of each sub message) + */ + public enum FrameType { + HEADER(0), + BODY(1), + BODY_DATA_CONTEXT(2), + BODY_REQUEST_CONTEXT(3), + BODY_EXCEPTION(4); + + private final byte value; + + FrameType(int value) { + this.value = (byte) value; + } + + public byte value() { + return value; + } + } + + /** + * Field names for the Request Header + */ + public enum FieldName { + EVENT_TYPE_TAG("eventType"), + MESSAGE_TAG("message"), + ID_TAG("0"), + DEVICE_NAME_TAG("1"), + REQ_TYPE_TAG("2"), + OPTIONS_TAG("3"), + CYCLE_NAME_TAG("4"), + ACQ_STAMP_TAG("5"), + CYCLE_STAMP_TAG("6"), + UPDATE_TYPE_TAG("7"), + SELECTOR_TAG("8"), + CLIENT_INFO_TAG("9"), + NOTIFICATION_ID_TAG("a"), + SOURCE_ID_TAG("b"), + FILTERS_TAG("c"), + DATA_TAG("x"), + SESSION_ID_TAG("d"), + SESSION_BODY_TAG("e"), + PROPERTY_NAME_TAG("f"); + + private final String name; + + FieldName(String name) { + this.name = name; + } + + public String value() { + return name; + } + } + + /** + * request type used in request header REQ_TYPE_TAG + */ + public enum RequestType { + GET(0), + SET(1), + CONNECT(2), + REPLY(3), + EXCEPTION(4), + SUBSCRIBE(5), + UNSUBSCRIBE(6), + NOTIFICATION_DATA(7), + NOTIFICATION_EXC(8), + SUBSCRIBE_EXCEPTION(9), + EVENT(10), + SESSION_CONFIRM(11); + + private final byte value; + + RequestType(int value) { + this.value = (byte) value; + } + + public static RequestType of(int value) { // NOPMD - nomen est omen + return values()[value]; + } + + public byte value() { + return value; + } + } + + /** + * UpdateType + */ + public enum UpdateType { + NORMAL(0), + FIRST_UPDATE(1), // Initial update sent when the subscription is created. + IMMEDIATE_UPDATE(2); // Update sent after the value has been modified by a set call. + + private final byte value; + + UpdateType(int value) { + this.value = (byte) value; + } + + public static UpdateType of(int value) { // NOPMD - nomen est omen + return values()[value]; + } + + public byte value() { + return value; + } + } + + public static CmwLightMessage recvMsg(final ZMQ.Socket socket, int tout) throws RdaLightException { + return parseMsg(ZMsg.recvMsg(socket, tout)); + } + + public static CmwLightMessage parseMsg(final @NotNull ZMsg data) throws RdaLightException { // NOPMD - NPath complexity acceptable (complex protocol) + assert data != null : "data"; + final ZFrame firstFrame = data.pollFirst(); + if (firstFrame != null && Arrays.equals(firstFrame.getData(), new byte[] { MessageType.SERVER_CONNECT_ACK.value() })) { + final CmwLightMessage reply = new CmwLightMessage(MessageType.SERVER_CONNECT_ACK); + final ZFrame versionData = data.pollFirst(); + assert versionData != null : "version data in connection acknowledgement frame"; + reply.version = versionData.getString(Charset.defaultCharset()); + return reply; + } + if (firstFrame != null && Arrays.equals(firstFrame.getData(), new byte[] { MessageType.CLIENT_CONNECT.value() })) { + final CmwLightMessage reply = new CmwLightMessage(MessageType.CLIENT_CONNECT); + final ZFrame versionData = data.pollFirst(); + assert versionData != null : "version data in connection acknowledgement frame"; + reply.version = versionData.getString(Charset.defaultCharset()); + return reply; + } + if (firstFrame != null && Arrays.equals(firstFrame.getData(), new byte[] { MessageType.SERVER_HB.value() })) { + return CmwLightMessage.SERVER_HB; + } + if (firstFrame != null && Arrays.equals(firstFrame.getData(), new byte[] { MessageType.CLIENT_HB.value() })) { + return CmwLightMessage.CLIENT_HB; + } + byte[] descriptor = checkDescriptor(data.pollLast(), firstFrame); + final ZFrame headerMsg = data.poll(); + assert headerMsg != null : "message header"; + CmwLightMessage reply = getReplyFromHeader(firstFrame, headerMsg); + switch (reply.requestType) { + case REPLY: + assertDescriptor(descriptor, FrameType.HEADER, FrameType.BODY, FrameType.BODY_DATA_CONTEXT); + reply.bodyData = data.pollFirst(); + if (data.isEmpty()) { + throw new RdaLightException(EMPTY_CONTEXT + reply.requestType); + } + reply.dataContext = parseContextData(data.pollFirst()); + return reply; + case NOTIFICATION_DATA: // notification update + assertDescriptor(descriptor, FrameType.HEADER, FrameType.BODY, FrameType.BODY_DATA_CONTEXT); + if (reply.options != null && reply.options.containsKey(FieldName.NOTIFICATION_ID_TAG.value())) { + reply.notificationId = (long) reply.options.get(FieldName.NOTIFICATION_ID_TAG.value()); + } + reply.bodyData = data.pollFirst(); + if (data.isEmpty()) { + throw new RdaLightException(EMPTY_CONTEXT + reply.requestType); + } + reply.dataContext = parseContextData(data.pollFirst()); + return reply; + case EXCEPTION: // exception on get/set request + case NOTIFICATION_EXC: // exception on notification, e.g null pointer in server notify code + case SUBSCRIBE_EXCEPTION: // exception on subscribe e.g. nonexistent property, wrong filters + assertDescriptor(descriptor, FrameType.HEADER, FrameType.BODY_EXCEPTION); + reply.exceptionMessage = parseExceptionMessage(data.pollFirst()); + return reply; + case GET: + assertDescriptor(descriptor, FrameType.HEADER, FrameType.BODY_REQUEST_CONTEXT); + if (data.isEmpty()) { + throw new RdaLightException(EMPTY_CONTEXT + reply.requestType); + } + reply.requestContext = parseRequestContext(data.pollFirst()); + return reply; + case SUBSCRIBE: // descriptor: [0] options: SOURCE_ID_TAG // seems to be sent after subscription is accepted + if (reply.messageType == MessageType.SERVER_REP) { + assertDescriptor(descriptor, FrameType.HEADER); + if (reply.options != null && reply.options.containsKey(FieldName.SOURCE_ID_TAG.value())) { + reply.sourceId = (long) reply.options.get(FieldName.SOURCE_ID_TAG.value()); + } + } else { + assertDescriptor(descriptor, FrameType.HEADER, FrameType.BODY_REQUEST_CONTEXT); + if (data.isEmpty()) { + throw new RdaLightException(EMPTY_CONTEXT + reply.requestType); + } + reply.requestContext = parseRequestContext(data.pollFirst()); + } + return reply; + case SESSION_CONFIRM: // descriptor: [0] options: SESSION_BODY_TAG + assertDescriptor(descriptor, FrameType.HEADER); + if (reply.options != null && reply.options.containsKey(FieldName.SESSION_BODY_TAG.value())) { + final Object subMap = reply.options.get(FieldName.SESSION_BODY_TAG.value()); + final String fieldName = FieldName.SESSION_BODY_TAG.value(); + if (subMap instanceof Map) { + @SuppressWarnings("unchecked") + final Map castMap = (Map) reply.options.get(fieldName); + reply.sessionBody = castMap; + } else { + throw new RdaLightException("field member '" + fieldName + "' not assignable to Map: " + subMap); + } + } + return reply; + case EVENT: + case UNSUBSCRIBE: + case CONNECT: + assertDescriptor(descriptor, FrameType.HEADER); + return reply; + case SET: + assertDescriptor(descriptor, FrameType.HEADER, FrameType.BODY, FrameType.BODY_REQUEST_CONTEXT); + reply.bodyData = data.pollFirst(); + if (data.isEmpty()) { + throw new RdaLightException(EMPTY_CONTEXT + reply.requestType); + } + reply.requestContext = parseRequestContext(data.pollFirst()); + return reply; + default: + throw new RdaLightException("received unknown or non-client request type: " + reply.requestType); + } + } + + public static void sendMsg(final ZMQ.Socket socket, final CmwLightMessage msg) throws RdaLightException { + serialiseMsg(msg).send(socket); + } + + public static ZMsg serialiseMsg(final CmwLightMessage msg) throws RdaLightException { + final ZMsg result = new ZMsg(); + switch (msg.messageType) { + case SERVER_CONNECT_ACK: + case CLIENT_CONNECT: + result.add(new ZFrame(new byte[] { msg.messageType.value() })); + result.add(new ZFrame(msg.version == null || msg.version.isEmpty() ? VERSION : msg.version)); + return result; + case CLIENT_HB: + case SERVER_HB: + result.add(new ZFrame(new byte[] { msg.messageType.value() })); + return result; + case SERVER_REP: + case CLIENT_REQ: + result.add(new byte[] { msg.messageType.value() }); + result.add(serialiseHeader(msg)); + switch (msg.requestType) { + case CONNECT: + case EVENT: + case SESSION_CONFIRM: + case UNSUBSCRIBE: + addDescriptor(result, FrameType.HEADER); + break; + case GET: + case SUBSCRIBE: + if (msg.messageType == MessageType.CLIENT_REQ) { + assert msg.requestContext != null : "requestContext"; + result.add(serialiseRequestContext(msg.requestContext)); + addDescriptor(result, FrameType.HEADER, FrameType.BODY_REQUEST_CONTEXT); + } else { + addDescriptor(result, FrameType.HEADER); + } + break; + case SET: + assert msg.bodyData != null : "bodyData"; + assert msg.requestContext != null : "requestContext"; + result.add(msg.bodyData); + result.add(serialiseRequestContext(msg.requestContext)); + addDescriptor(result, FrameType.HEADER, FrameType.BODY, FrameType.BODY_REQUEST_CONTEXT); + break; + case REPLY: + case NOTIFICATION_DATA: + assert msg.bodyData != null : "bodyData"; + result.add(msg.bodyData); + result.add(serialiseDataContext(msg.dataContext)); + addDescriptor(result, FrameType.HEADER, FrameType.BODY, FrameType.BODY_DATA_CONTEXT); + break; + case NOTIFICATION_EXC: + case EXCEPTION: + case SUBSCRIBE_EXCEPTION: + assert msg.exceptionMessage != null : "exceptionMessage"; + result.add(serialiseExceptionMessage(msg.exceptionMessage)); + addDescriptor(result, FrameType.HEADER, FrameType.BODY_EXCEPTION); + break; + default: + } + return result; + default: + } + + throw new RdaLightException("Invalid cmwMessage: " + msg); + } + + private static ZFrame serialiseExceptionMessage(final CmwLightMessage.ExceptionMessage exceptionMessage) { + IO_BUFFER.reset(); + SERIALISER.setBuffer(IO_BUFFER); + SERIALISER.putHeaderInfo(); + SERIALISER.put(CONTEXT_ACQ_STAMP, exceptionMessage.contextAcqStamp); + SERIALISER.put(CONTEXT_CYCLE_STAMP, exceptionMessage.contextCycleStamp); + SERIALISER.put(MESSAGE, exceptionMessage.message); + SERIALISER.put(TYPE, exceptionMessage.type); + IO_BUFFER.flip(); + return new ZFrame(Arrays.copyOfRange(IO_BUFFER.elements(), 0, IO_BUFFER.limit() + SERIALISER_QUIRK)); + } + + private static void addDescriptor(final ZMsg result, final FrameType... frametypes) { + byte[] descriptor = new byte[frametypes.length]; + for (int i = 0; i < descriptor.length; i++) { + descriptor[i] = frametypes[i].value(); + } + result.add(new ZFrame(descriptor)); + } + + private static ZFrame serialiseHeader(final CmwLightMessage msg) throws RdaLightException { + IO_BUFFER.reset(); + SERIALISER.setBuffer(IO_BUFFER); + SERIALISER.putHeaderInfo(); + SERIALISER.put(FieldName.REQ_TYPE_TAG.value(), msg.requestType.value()); + SERIALISER.put(FieldName.ID_TAG.value(), msg.id); + SERIALISER.put(FieldName.DEVICE_NAME_TAG.value(), msg.deviceName); + SERIALISER.put(FieldName.PROPERTY_NAME_TAG.value(), msg.propertyName); + if (msg.updateType != null) { + SERIALISER.put(FieldName.UPDATE_TYPE_TAG.value(), msg.updateType.value()); + } + SERIALISER.put(FieldName.SESSION_ID_TAG.value(), msg.sessionId); + // StartMarker marks start of Data Object + putMap(SERIALISER, FieldName.OPTIONS_TAG.value(), msg.options); + IO_BUFFER.flip(); + return new ZFrame(Arrays.copyOfRange(IO_BUFFER.elements(), 0, IO_BUFFER.limit() + SERIALISER_QUIRK)); + } + + private static ZFrame serialiseRequestContext(final CmwLightMessage.RequestContext requestContext) throws RdaLightException { + IO_BUFFER.reset(); + SERIALISER.putHeaderInfo(); + SERIALISER.put(FieldName.SELECTOR_TAG.value(), requestContext.selector); + putMap(SERIALISER, FieldName.FILTERS_TAG.value(), requestContext.filters); + putMap(SERIALISER, FieldName.DATA_TAG.value(), requestContext.data); + IO_BUFFER.flip(); + return new ZFrame(Arrays.copyOfRange(IO_BUFFER.elements(), 0, IO_BUFFER.limit() + SERIALISER_QUIRK)); + } + + private static ZFrame serialiseDataContext(final CmwLightMessage.DataContext dataContext) throws RdaLightException { + IO_BUFFER.reset(); + SERIALISER.putHeaderInfo(); + SERIALISER.put(FieldName.CYCLE_NAME_TAG.value(), dataContext.cycleName); + SERIALISER.put(FieldName.CYCLE_STAMP_TAG.value(), dataContext.cycleStamp); + SERIALISER.put(FieldName.ACQ_STAMP_TAG.value(), dataContext.acqStamp); + putMap(SERIALISER, FieldName.DATA_TAG.value(), dataContext.data); + IO_BUFFER.flip(); + return new ZFrame(Arrays.copyOfRange(IO_BUFFER.elements(), 0, IO_BUFFER.limit() + SERIALISER_QUIRK)); + } + + private static void putMap(final CmwLightSerialiser serialiser, final String fieldName, final Map map) throws RdaLightException { + if (map != null) { + final WireDataFieldDescription dataFieldMarker = new WireDataFieldDescription(serialiser, serialiser.getParent(), -1, + fieldName, DataType.START_MARKER, -1, -1, -1); + serialiser.putStartMarker(dataFieldMarker); + for (final Map.Entry entry : map.entrySet()) { + if (entry.getValue() instanceof String) { + serialiser.put(entry.getKey(), (String) entry.getValue()); + } else if (entry.getValue() instanceof Integer) { + serialiser.put(entry.getKey(), (Integer) entry.getValue()); + } else if (entry.getValue() instanceof Long) { + serialiser.put(entry.getKey(), (Long) entry.getValue()); + } else if (entry.getValue() instanceof Boolean) { + serialiser.put(entry.getKey(), (Boolean) entry.getValue()); + } else if (entry.getValue() instanceof Map) { + @SuppressWarnings("unchecked") + final Map subMap = (Map) entry.getValue(); + putMap(serialiser, entry.getKey(), subMap); + } else { + throw new RdaLightException("unsupported map entry type: " + entry.getValue().getClass().getCanonicalName()); + } + } + serialiser.putEndMarker(dataFieldMarker); + } + } + + private static CmwLightMessage getReplyFromHeader(final ZFrame firstFrame, final ZFrame header) throws RdaLightException { + CmwLightMessage reply = new CmwLightMessage(MessageType.of(firstFrame.getData()[0])); + IO_CLASS_SERIALISER.setDataBuffer(FastByteBuffer.wrap(header.getData())); + final FieldDescription headerMap; + try { + headerMap = IO_CLASS_SERIALISER.parseWireFormat().getChildren().get(0); + for (FieldDescription field : headerMap.getChildren()) { + if (field.getFieldName().equals(FieldName.REQ_TYPE_TAG.value()) && field.getType() == byte.class) { + reply.requestType = RequestType.of((byte) (((WireDataFieldDescription) field).data())); + } else if (field.getFieldName().equals(FieldName.ID_TAG.value()) && field.getType() == long.class) { + reply.id = (long) ((WireDataFieldDescription) field).data(); + } else if (field.getFieldName().equals(FieldName.DEVICE_NAME_TAG.value()) && field.getType() == String.class) { + reply.deviceName = (String) ((WireDataFieldDescription) field).data(); + } else if (field.getFieldName().equals(FieldName.OPTIONS_TAG.value())) { + reply.options = readMap(field); + } else if (field.getFieldName().equals(FieldName.UPDATE_TYPE_TAG.value()) && field.getType() == byte.class) { + reply.updateType = UpdateType.of((byte) ((WireDataFieldDescription) field).data()); + } else if (field.getFieldName().equals(FieldName.SESSION_ID_TAG.value()) && field.getType() == String.class) { + reply.sessionId = (String) ((WireDataFieldDescription) field).data(); + } else if (field.getFieldName().equals(FieldName.PROPERTY_NAME_TAG.value()) && field.getType() == String.class) { + reply.propertyName = (String) ((WireDataFieldDescription) field).data(); + } else { + throw new RdaLightException("Unknown CMW header field: " + field.getFieldName()); + } + } + } catch (IllegalStateException e) { + throw new RdaLightException("unparsable header: " + Arrays.toString(header.getData()) + "(" + header.toString() + ")", e); + } + if (reply.requestType == null) { + throw new RdaLightException("Header does not contain request type field"); + } + return reply; + } + + private static Map readMap(final FieldDescription field) { + Map result = null; + for (FieldDescription dataField : field.getChildren()) { + if (result == null) { + result = new HashMap<>(); // NOPMD - necessary to allocate inside loop + } + //if ( 'condition' ) { + // find out how to see if the field is itself a map + // result.put(dataField.getFieldName(), readMap(dataField)) + // } else { + result.put(dataField.getFieldName(), ((WireDataFieldDescription) dataField).data()); + //} + } + return result; + } + + private static CmwLightMessage.ExceptionMessage parseExceptionMessage(final ZFrame exceptionBody) throws RdaLightException { + if (exceptionBody == null) { + throw new RdaLightException("malformed subscription exception"); + } + final CmwLightMessage.ExceptionMessage exceptionMessage = new CmwLightMessage.ExceptionMessage(); + IO_CLASS_SERIALISER.setDataBuffer(FastByteBuffer.wrap(exceptionBody.getData())); + final FieldDescription exceptionFields = IO_CLASS_SERIALISER.parseWireFormat().getChildren().get(0); + for (FieldDescription field : exceptionFields.getChildren()) { + if (CONTEXT_ACQ_STAMP.equals(field.getFieldName()) && field.getType() == long.class) { + exceptionMessage.contextAcqStamp = (long) ((WireDataFieldDescription) field).data(); + } else if (CONTEXT_CYCLE_STAMP.equals(field.getFieldName()) && field.getType() == long.class) { + exceptionMessage.contextCycleStamp = (long) ((WireDataFieldDescription) field).data(); + } else if (MESSAGE.equals(field.getFieldName()) && field.getType() == String.class) { + exceptionMessage.message = (String) ((WireDataFieldDescription) field).data(); + } else if (TYPE.equals(field.getFieldName()) && field.getType() == byte.class) { + exceptionMessage.type = (byte) ((WireDataFieldDescription) field).data(); + } else { + throw new RdaLightException("Unsupported field in exception body: " + field.getFieldName()); + } + } + return exceptionMessage; + } + + private static CmwLightMessage.RequestContext parseRequestContext(final @NotNull ZFrame contextData) throws RdaLightException { + assert contextData != null : "contextData"; + CmwLightMessage.RequestContext requestContext = new CmwLightMessage.RequestContext(); + IO_CLASS_SERIALISER.setDataBuffer(FastByteBuffer.wrap(contextData.getData())); + final FieldDescription contextMap; + try { + contextMap = IO_CLASS_SERIALISER.parseWireFormat().getChildren().get(0); + for (FieldDescription field : contextMap.getChildren()) { + if (field.getFieldName().equals(FieldName.SELECTOR_TAG.value()) && field.getType() == String.class) { + requestContext.selector = (String) ((WireDataFieldDescription) field).data(); + } else if (field.getFieldName().equals(FieldName.FILTERS_TAG.value())) { + for (FieldDescription dataField : field.getChildren()) { + if (requestContext.filters == null) { + requestContext.filters = new HashMap<>(); // NOPMD - necessary to allocate inside loop + } + requestContext.filters.put(dataField.getFieldName(), ((WireDataFieldDescription) dataField).data()); + } + } else if (field.getFieldName().equals(FieldName.DATA_TAG.value())) { + for (FieldDescription dataField : field.getChildren()) { + if (requestContext.data == null) { + requestContext.data = new HashMap<>(); // NOPMD - necessary to allocate inside loop + } + requestContext.data.put(dataField.getFieldName(), ((WireDataFieldDescription) dataField).data()); + } + } else { + throw new UnsupportedOperationException("Unknown field: " + field.getFieldName()); + } + } + } catch (IllegalStateException e) { + throw new RdaLightException("unparsable context data: " + Arrays.toString(contextData.getData()) + "(" + new String(contextData.getData()) + ")", e); + } + return requestContext; + } + + private static CmwLightMessage.DataContext parseContextData(final @NotNull ZFrame contextData) throws RdaLightException { + assert contextData != null : "contextData"; + CmwLightMessage.DataContext dataContext = new CmwLightMessage.DataContext(); + IO_CLASS_SERIALISER.setDataBuffer(FastByteBuffer.wrap(contextData.getData())); + final FieldDescription contextMap; + try { + contextMap = IO_CLASS_SERIALISER.parseWireFormat().getChildren().get(0); + for (FieldDescription field : contextMap.getChildren()) { + if (field.getFieldName().equals(FieldName.CYCLE_NAME_TAG.value()) && field.getType() == String.class) { + dataContext.cycleName = (String) ((WireDataFieldDescription) field).data(); + } else if (field.getFieldName().equals(FieldName.ACQ_STAMP_TAG.value()) && field.getType() == long.class) { + dataContext.acqStamp = (long) ((WireDataFieldDescription) field).data(); + } else if (field.getFieldName().equals(FieldName.CYCLE_STAMP_TAG.value()) && field.getType() == long.class) { + dataContext.cycleStamp = (long) ((WireDataFieldDescription) field).data(); + } else if (field.getFieldName().equals(FieldName.DATA_TAG.value())) { + for (FieldDescription dataField : field.getChildren()) { + if (dataContext.data == null) { + dataContext.data = new HashMap<>(); // NOPMD - necessary to allocate inside loop + } + dataContext.data.put(dataField.getFieldName(), ((WireDataFieldDescription) dataField).data()); + } + } else { + throw new UnsupportedOperationException("Unknown field: " + field.getFieldName()); + } + } + } catch (IllegalStateException e) { + throw new RdaLightException("unparsable context data: " + Arrays.toString(contextData.getData()) + "(" + new String(contextData.getData()) + ")", e); + } + return dataContext; + } + + private static void assertDescriptor(final byte[] descriptor, final FrameType... frameTypes) throws RdaLightException { + if (descriptor.length != frameTypes.length) { + throw new RdaLightException("descriptor does not match message type: \n " + Arrays.toString(descriptor) + "\n " + Arrays.toString(frameTypes)); + } + for (int i = 1; i < descriptor.length; i++) { + if (descriptor[i] != frameTypes[i].value()) { + throw new RdaLightException("descriptor does not match message type: \n " + Arrays.toString(descriptor) + "\n " + Arrays.toString(frameTypes)); + } + } + } + + private static byte[] checkDescriptor(final ZFrame descriptorMsg, final ZFrame firstFrame) throws RdaLightException { + if (firstFrame == null || !(Arrays.equals(firstFrame.getData(), new byte[] { MessageType.SERVER_REP.value() }) || Arrays.equals(firstFrame.getData(), new byte[] { MessageType.CLIENT_REQ.value() }))) { + throw new RdaLightException("Expecting only messages of type Heartbeat or Reply but got: " + firstFrame); + } + if (descriptorMsg == null) { + throw new RdaLightException("Message does not contain descriptor"); + } + final byte[] descriptor = descriptorMsg.getData(); + if (descriptor[0] != FrameType.HEADER.value()) { + throw new RdaLightException("First message of SERVER_REP has to be of type MT_HEADER but is: " + descriptor[0]); + } + return descriptor; + } + + public static class RdaLightException extends Exception { + private static final long serialVersionUID = 5197623305559702319L; + public RdaLightException(final String msg) { + super(msg); + } + + public RdaLightException(final String msg, final Throwable e) { + super(msg, e); + } + } +} diff --git a/client/src/main/java/io/opencmw/client/cmwlight/DirectoryLightClient.java b/client/src/main/java/io/opencmw/client/cmwlight/DirectoryLightClient.java new file mode 100644 index 00000000..5d8e0f12 --- /dev/null +++ b/client/src/main/java/io/opencmw/client/cmwlight/DirectoryLightClient.java @@ -0,0 +1,203 @@ +package io.opencmw.client.cmwlight; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.PrintWriter; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.URLDecoder; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Obtain device info from the directory server + */ +public class DirectoryLightClient { + public static final String GET_DEVICE_INFO = "get-device-info"; + // public static final String GET_SERVER_INFO = "get-server-info"; + // private static final String SUPPORTED_CHARACTERS = "\\.\\-\\+_a-zA-Z0-9"; + // private static final String NAME_REGEX = "[a-zA-Z0-9][" + SUPPORTED_CHARACTERS + "]*"; + // private static final String CLIENT_INFO_SUPPORTED_CHARACTERS = "\\x20-\\x7E"; // ASCII := {32-126} + private static final String ERROR_STRING = "ERROR"; + private static final String HOST_PORT_SEPARATOR = ":"; + + private static final String NOT_BOUND_LOCATION = "*NOT_BOUND*"; + // static final String UNKNOWN_SERVER = "*UNKNOWN*"; + private static final String CLIENT_INFO = "DirectoryLightClient"; + private static final String VERSION = "2.0.0"; + private final String nameserver; + private final int nameserverPort; + + public DirectoryLightClient(final String... nameservers) throws DirectoryClientException { + if (nameservers.length != 1) { + throw new DirectoryClientException("only one nameserver supported at the moment"); + } + final String[] hostport = nameservers[0].split(HOST_PORT_SEPARATOR); + if (hostport.length != 2) { + throw new DirectoryClientException("nameserver address has wrong format: " + nameservers[0]); + } + nameserver = hostport[0]; + nameserverPort = Integer.parseInt(hostport[1]); + } + + /** + * Build the request message to query a number of devices + * + * @param devices The devices to query information for + * @return The request message to send to the server + **/ + private String getDeviceMsg(final List devices) { + final StringBuilder sb = new StringBuilder(); + sb.append(GET_DEVICE_INFO).append("\n@client-info ").append(CLIENT_INFO).append("\n@version ").append(VERSION).append('\n'); + // msg.append("@prefer-proxy\n"); + // msg.append("@direct ").append(this.properties.directServers.getValue()).append("\n"); + // msg.append("@domain "); + // for (Domain domain : domains) { + // msg.append(domain.getName()); + // msg.append(","); + // } + // msg.deleteCharAt(msg.length()-1); + // msg.append("\n"); + for (final String dev : devices) { + sb.append(dev).append('\n'); + } + sb.append('\n'); + return sb.toString(); + } + + // /** + // * Build the request message to query a number of servers + // * + // * @param servers The servers to query information for + // * @return The request message to send to the server + // **/ + // private String getServerMsg(final List servers) { + // final StringBuilder sb = new StringBuilder(); + // sb.append(GET_SERVER_INFO).append("\n"); + // sb.append("@client-info ").append(CLIENT_INFO).append("\n"); + // sb.append("@version ").append(VERSION).append("\n"); + // // msg.append("@prefer-proxy\n"); + // // msg.append("@direct ").append(this.properties.directServers.getValue()).append("\n"); + // // msg.append("@domain "); + // // for (Domain domain : domains) { + // // msg.append(domain.getName()); + // // msg.append(","); + // // } + // // msg.deleteCharAt(msg.length()-1); + // // msg.append("\n"); + // for (final String dev : servers) { + // sb.append(dev).append('\n'); + // } + // sb.append('\n'); + // return sb.toString(); + // } + + /** + * Query Server information for a given list of devices. + * + * @param devices The devices to query information for + * @return a list of device information for the queried devices + **/ + public List getDeviceInfo(final List devices) throws DirectoryClientException { + final ArrayList result = new ArrayList<>(); + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress(nameserver, nameserverPort)); + try (PrintWriter writer = new PrintWriter(socket.getOutputStream()); + BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(socket.getInputStream()))) { + writer.write(getDeviceMsg(devices)); + writer.flush(); + // read query result, one line per requested device or ERROR followed by error message + while (true) { + final String line = bufferedReader.readLine(); + if (line == null) { + break; + } + if (line.equals(ERROR_STRING)) { + final String errorMsg = bufferedReader.lines().collect(Collectors.joining("\n")).strip(); + throw new DirectoryClientException(errorMsg); + } + result.add(parseDeviceInfo(line)); + } + } + } catch (IOException e) { + throw new DirectoryClientException("Nameserver error: ", e); + } + return result; + } + + private Device parseDeviceInfo(final String line) throws DirectoryClientException { + String[] tokens = line.split(" "); + if (tokens.length < 2) { + throw new DirectoryClientException("Malformed reply line: " + line); + } + if (tokens[1].equals(NOT_BOUND_LOCATION)) { + throw new DirectoryClientException("Requested device not bound: " + tokens[0]); + } + final ArrayList> servers = new ArrayList<>(); + for (int j = 2; j < tokens.length; j++) { + final HashMap server = new HashMap<>(); // NOPMD - necessary to allocate inside loop + servers.add(server); + final String[] servertokens = tokens[j].split("#"); + server.put("protocol", servertokens[0]); + int k = 1; + while (k + 3 < servertokens.length) { + if ("string".equals(servertokens[k + 1])) { + final int length = Integer.parseInt(servertokens[k + 2]); + final String value = URLDecoder.decode(servertokens[k + 3], Charset.defaultCharset()); + if (length == value.length()) { + server.put(servertokens[k], value); + } else { + throw new DirectoryClientException("Error parsing string: " + servertokens[k] + "(" + length + ") = " + value); + } + k += 4; + } else if ("int".equals(servertokens[k + 1]) || "long".equals(servertokens[k + 1])) { // NOPMD + server.put(servertokens[k], servertokens[k + 2]); + k += 3; + } else { + throw new DirectoryClientException("Error parsing argument: " + k + ": " + Arrays.toString(servertokens)); + } + } + } + return new Device(tokens[0], tokens[1], servers); + } + + public static class Device { + public final String name; + private final String deviceClass; + public final List> servers; + + public Device(final String name, final String deviceClass, final List> servers) { + this.name = name; + this.deviceClass = deviceClass; + this.servers = servers; + } + + @Override + public String toString() { + return "Device{name='" + name + '\'' + ", deviceClass='" + deviceClass + '\'' + ", servers=" + servers + '}'; + } + + public String getAddress() { + // N.B. here the '9' in 'rda3://9' is an indicator that the entry has 9 fields + // useful snippet for manual queries: + // echo -e "get-device-info\nGSCD025\n\n" | nc cmwpro00a.acc.gsi.de 5021 | sed -e "s%#%\n#%g" + return servers.stream().filter(s -> "rda3://9".equals(s.get("protocol"))).map(s -> s.get("Address:")).findFirst().orElseThrow(); + } + } + + public static class DirectoryClientException extends Exception { + private static final long serialVersionUID = -4452775634393421952L; + public DirectoryClientException(final String errorMsg) { + super(errorMsg); + } + public DirectoryClientException(final String errorMsg, final Exception cause) { + super(errorMsg, cause); + } + } +} diff --git a/client/src/main/java/io/opencmw/client/rest/RestDataSource.java b/client/src/main/java/io/opencmw/client/rest/RestDataSource.java new file mode 100644 index 00000000..c1815b25 --- /dev/null +++ b/client/src/main/java/io/opencmw/client/rest/RestDataSource.java @@ -0,0 +1,458 @@ + +package io.opencmw.client.rest; + +import static io.opencmw.OpenCmwProtocol.EMPTY_FRAME; + +import java.io.IOException; +import java.lang.management.ManagementFactory; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Timer; +import java.util.TimerTask; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingDeque; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.LockSupport; +import java.util.concurrent.locks.ReentrantLock; + +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.spi.LoggingEventBuilder; +import org.zeromq.SocketType; +import org.zeromq.ZContext; +import org.zeromq.ZMQ; +import org.zeromq.ZMQ.Socket; +import org.zeromq.ZMsg; + +import io.opencmw.MimeType; +import io.opencmw.client.DataSource; +import io.opencmw.serialiser.IoSerialiser; +import io.opencmw.serialiser.spi.JsonSerialiser; + +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.sse.EventSource; +import okhttp3.sse.EventSourceListener; +import okhttp3.sse.EventSources; + +@SuppressWarnings({ "PMD.TooManyFields", "PMD.ExcessiveImports" }) +public class RestDataSource extends DataSource implements Runnable { + public static final Factory FACTORY = new Factory() { + @Override + public boolean matches(final String endpoint) { + return endpoint != null && !endpoint.isBlank() && endpoint.toLowerCase(Locale.UK).startsWith("http"); + } + + @Override + public Class getMatchingSerialiserType(final String endpoint) { + return JsonSerialiser.class; + } + + @Override + public DataSource newInstance(final ZContext context, final String endpoint, final Duration timeout, final String clientId) { + return new RestDataSource(context, endpoint, timeout, clientId); + } + }; + private static final Logger LOGGER = LoggerFactory.getLogger(RestDataSource.class); + private static final int WAIT_TIMEOUT_MILLIS = 1000; + private static final AtomicInteger REST_DATA_SOURCE_INSTANCE = new AtomicInteger(); + private static final int MAX_RETRIES = 3; + private static final AtomicLong PUBLICATION_COUNTER = new AtomicLong(); + protected static OkHttpClient okClient; + protected static EventSource.Factory eventSourceFactory; + protected final AtomicBoolean run = new AtomicBoolean(true); // NOPMD + protected final String uniqueID; + protected final byte[] uniqueIdBytes; + protected final String endpoint; + protected final Duration timeOut; + protected final String clientID; + protected int cancelLastCall; // needed for unit-testing only + protected final ZContext ctxCopy; + protected final Object newData = new Object(); // to notify event loop that new data has arrived + protected final Timer timer = new Timer(); + protected final List pendingCallbacks = Collections.synchronizedList(new ArrayList<>()); + protected final List completedCallbacks = Collections.synchronizedList(new ArrayList<>()); + protected final BlockingQueue requestQueue = new LinkedBlockingDeque<>(); + protected Map sseSource = new HashMap<>(); // NOPMD - only accessed from main thread + protected Socket internalSocket; // facing towards the internal REST client API + protected Socket externalSocket; // facing towards the DataSource manager + protected final TimerTask wakeupTask = new TimerTask() { + @Override + public void run() { + synchronized (newData) { + newData.notifyAll(); + } + } + }; + + protected RestDataSource(final ZContext ctx, final String endpoint) { + this(ctx, endpoint, Duration.ofMillis(0), RestDataSource.class.getName()); + } + + /** + * Constructor + * @param ctx ZeroMQ context to use + * @param endpoint Endpoint to subscribe to + * @param timeOut after which the request defaults to a time-out exception (no data) + * @param clientID subscription id to be able to process the notification updates. + */ + public RestDataSource(final ZContext ctx, final String endpoint, final Duration timeOut, final String clientID) { + super(endpoint); + synchronized (LOGGER) { // prevent race condition between multiple constructor invocations + // initialised only when needed, ie. when RestDataSource is actually instantiated + if (okClient == null) { + okClient = new OkHttpClient(); // NOPMD + eventSourceFactory = EventSources.createFactory(okClient); // NOPMD + } + } + + if (timeOut == null) { + throw new IllegalArgumentException("timeOut is null"); + } + this.ctxCopy = ctx == null ? new ZContext() : ctx; + this.endpoint = endpoint; + this.timeOut = timeOut; + this.clientID = clientID; + + uniqueID = clientID + "PID=" + ManagementFactory.getRuntimeMXBean().getName() + "-InstanceID=" + REST_DATA_SOURCE_INSTANCE.getAndIncrement(); + uniqueIdBytes = uniqueID.getBytes(ZMQ.CHARSET); + if (timeOut.toMillis() > 0) { + timer.scheduleAtFixedRate(wakeupTask, 0, timeOut.toMillis()); + } + + start(); // NOPMD - starts on initialisation + } + + /** + * Connect or reconnect to broker + */ + private void createPair() { + if (internalSocket != null) { + internalSocket.close(); + } + if (externalSocket != null) { + externalSocket.close(); + } + + internalSocket = ctxCopy.createSocket(SocketType.PAIR); + assert internalSocket != null : "internalSocket being initialised"; + if (!internalSocket.setHWM(0)) { + throw new IllegalStateException("could not set HWM on internalSocket"); + } + if (!internalSocket.setIdentity(uniqueIdBytes)) { + throw new IllegalStateException("could not set identity on internalSocket"); + } + if (!internalSocket.bind("inproc://" + uniqueID)) { + throw new IllegalStateException("could not bind internalSocket to: inproc://" + uniqueID); + } + + externalSocket = ctxCopy.createSocket(SocketType.PAIR); + assert externalSocket != null : "externalSocket being initialised"; + if (!externalSocket.setHWM(0)) { + throw new IllegalStateException("could not set HWM on externalSocket"); + } + if (!externalSocket.connect("inproc://" + uniqueID)) { + throw new IllegalStateException("could not bind externalSocket to: inproc://" + uniqueID); + } + + LOGGER.atTrace().addArgument(endpoint).log("(re-)connecting to REST endpoint: '{}'"); + } + /** + * Perform a get request on this endpoint. + * @param requestId request id which later allows to match the returned value to this query. + * This is the only mandatory parameter, all the following may be null. + * @param filterPattern extend the filters originally supplied to the endpoint e.g. "ctx=selector&channel=chanA" + * @param filters The serialised filters which will determine which data to update + * @param data The serialised data which can be used by the get call + * @param rbacToken byte array containing signed body hash-key and corresponding RBAC role + */ + @Override + public void get(final String requestId, final String filterPattern, final byte[] filters, final byte[] data, final byte[] rbacToken) { + enqueueRequest(requestId); //TODO: refactor interface + } + + /** + * Perform a set request on this endpoint using additional filters + * @param requestId request id which later allows to match the returned value to this query. + * This is the only mandatory parameter, all the following may be null. + * @param filterPattern extend the filters originally supplied to the endpoint e.g. "ctx=selector&channel=chanA" + * @param filters The serialised filters which will determine which data to update + * @param data The serialised data which can be used by the get call + * @param rbacToken byte array containing signed body hash-key and corresponding RBAC role + */ + @Override + public void set(final String requestId, final String filterPattern, final byte[] filters, final byte[] data, final byte[] rbacToken) { + throw new UnsupportedOperationException("set not (yet) implemented"); + } + + public void enqueueRequest(final String hashKey) { + if (!requestQueue.offer(hashKey)) { + throw new IllegalStateException("could not add hashKey " + hashKey + " to request queue of endpoint " + endpoint); + } + synchronized (newData) { + newData.notifyAll(); + } + } + + @Override + public void subscribe(final String reqId, final String endpoint, final byte[] rbacToken) { + final Request request = new Request.Builder().url(endpoint).build(); + sseSource.put(reqId, eventSourceFactory.newEventSource(request, new EventSourceListener() { + @Override + public void onEvent(final @NotNull EventSource eventSource, final String id, final String type, final @NotNull String data) { + final String pubKey = clientID + "#" + PUBLICATION_COUNTER.getAndIncrement(); + getRequest(pubKey, endpoint, MimeType.TEXT); // poll actual endpoint + } + })); + } + + @Override + public void unsubscribe(final String reqId) { + final EventSource source = sseSource.remove(reqId); + if (source != null) { + source.cancel(); + } + } + + public ZContext getCtx() { + return ctxCopy; + } + + @Override + public ZMQ.Socket getSocket() { + return externalSocket; + } + + @Override + protected Factory getFactory() { + return FACTORY; + } + + /** + * Gets called whenever data is available on the DataSoure's socket. + * Should then try to receive data and return any results back to the calling event loop. + * @return null if there is no more data available, a Zero length Zmsg if there was data which was only used internally + * or a ZMsg with [reqId, endpoint, byte[] data, [byte[] optional RBAC token]] + */ + @Override + public ZMsg getMessage() { + return ZMsg.recvMsg(externalSocket, false); + } + + @Override + public long housekeeping() { + synchronized (newData) { + ArrayList temp = new ArrayList<>(pendingCallbacks); + for (RestCallBack callBack : temp) { + callBack.checkTimeOut(); + } + + try { + while (!requestQueue.isEmpty()) { + final String hash = requestQueue.take(); + if (LOGGER.isTraceEnabled()) { + LOGGER.atTrace().addArgument(hash).log("external request with hashKey = '{}'"); + } + getRequest(hash, endpoint, MimeType.TEXT); + } + } catch (InterruptedException e) { // NOSONAR NOPMD + LOGGER.atError().setCause(e).addArgument(endpoint).log("error in retrieving requestQueue items for endpoint: {}"); + } + } + return System.currentTimeMillis() + timeOut.toMillis(); + } + + public void run() { // NOPMD NOSONAR - complexity + run.set(true); + try { + while (run.get() && !Thread.interrupted()) { + synchronized (newData) { + if (completedCallbacks.isEmpty() && requestQueue.isEmpty()) { + // nothing to do, wait for signals + final long waitMax; + if (timeOut.toMillis() <= 0) { + waitMax = TimeUnit.MILLISECONDS.toMillis(WAIT_TIMEOUT_MILLIS); + } else { + waitMax = timeOut.toMillis(); + } + // N.B. is automatically updated in case of time-out and/or new arriving data/exceptions + newData.wait(waitMax); + } + + for (RestCallBack callBack : completedCallbacks) { + // notify data + + final byte[] header; + final byte[] data; + if (callBack.response == null) { + // exception branch + header = EMPTY_FRAME; + data = EMPTY_FRAME; + } else { + header = callBack.response.headers().toString().getBytes(StandardCharsets.UTF_8); + data = callBack.response.peekBody(Long.MAX_VALUE).bytes(); + callBack.response.close(); + } + final byte[] exception = callBack.exception == null ? EMPTY_FRAME : callBack.exception.getMessage().getBytes(StandardCharsets.UTF_8); + + final ZMsg msg = new ZMsg(); // NOPMD - instantiation in loop + msg.add(callBack.hashKey); + msg.add(callBack.endPointName); + msg.add(header); + msg.add(data); + msg.add(exception); + + if (!msg.send(internalSocket)) { + throw new IllegalStateException("internalSocket could not send message - error code: " + internalSocket.errno()); + } + } + completedCallbacks.clear(); + + housekeeping(); + } + } + } catch (final Exception e) { // NOPMD NOSONAR -- terminate normally beyond this point + LOGGER.atError().setCause(e).log("data acquisition loop abnormally terminated"); + } finally { + externalSocket.close(); + internalSocket.close(); + } + LOGGER.atTrace().addArgument(uniqueID).addArgument(run.get()).log("stop poller thread for uniqueID={} - run={}"); + } + + public void start() { + createPair(); + new Thread(this).start(); // NOPMD + } + + public void stop() { + for (final String reqId : sseSource.keySet()) { + unsubscribe(reqId); + } + run.set(false); + } + + protected void getRequest(final String hashKey, final String path, final MimeType mimeType) { + Request request = new Request.Builder().url(path).get().addHeader("Accept", mimeType.toString()).build(); + if (LOGGER.isTraceEnabled()) { + LOGGER.atTrace().addArgument(endpoint).addArgument(path).addArgument(request).log("new request for {} - {} : request{}"); + } + final RestCallBack callBack = new RestCallBack(hashKey, path, mimeType); + pendingCallbacks.add(callBack); + final Call call = okClient.newCall(request); + call.enqueue(callBack); + if (cancelLastCall > 0) { + call.cancel(); // needed only for unit-testing + cancelLastCall--; + } + } + + public class RestCallBack implements Callback { + private final String hashKey; + private final String endPointName; + private final MimeType mimeType; + private final long requestTimeStamp = System.currentTimeMillis(); + private boolean active = true; + private final AtomicInteger retryCount = new AtomicInteger(); + private final Lock lock = new ReentrantLock(); + private Response response; + private Exception exception; + + public RestCallBack(final String hashKey, final String endPointName, final MimeType mimeType) { + this.hashKey = hashKey; + this.endPointName = endPointName; + this.mimeType = mimeType; + } + + @Override + public String toString() { + return "RestCallBack{hashKey='" + hashKey + '\'' + ", endPointName='" + endPointName + '\'' + ", requestTimeStamp=" + requestTimeStamp + ", active=" + active + ", retryCount=" + retryCount + ", result=" + response + ", exception=" + exception + '}'; + } + + public void checkTimeOut() { + if (!active || timeOut.toMillis() <= 0) { + return; + } + final long now = System.currentTimeMillis(); + if (requestTimeStamp + timeOut.toMillis() < now) { + // mark failed and notify + lock.lock(); + exception = new TimeoutException("ts=" + now + " - time-out of REST request for endpoint: " + endpoint); + notifyResult(); + lock.unlock(); + } + } + + @Override + public void onFailure(@NotNull final Call call, @NotNull final IOException e) { + if (!active) { + return; + } + + if (retryCount.incrementAndGet() <= MAX_RETRIES) { + lock.lock(); + exception = e; + lock.unlock(); + final LoggingEventBuilder logger = LOGGER.atWarn(); + if (LOGGER.isTraceEnabled()) { + logger.setCause(e); + } + logger.addArgument(retryCount.get()).addArgument(MAX_RETRIES).addArgument(endpoint).log("retry {} of {}: could not connect/receive from endpoint {}"); + // TODO: add more sophisticated exponential back-off + LockSupport.parkNanos(timeOut.toMillis() * (1L << (2 * (retryCount.get() - 1)))); + Request request = new Request.Builder().url(endPointName).get().addHeader("Accept", mimeType.toString()).build(); + final Call repeatedCall = okClient.newCall(request); + repeatedCall.enqueue(this); + if (cancelLastCall > 0) { + repeatedCall.cancel(); // needed only for unit-testing + cancelLastCall--; + } + return; + } + LOGGER.atWarn().setCause(e).addArgument(MAX_RETRIES).addArgument(endpoint).log("failed after {} connect/receive retries - abort"); + lock.lock(); + exception = e; + notifyResult(); + lock.unlock(); + LOGGER.atWarn().addArgument(e.getLocalizedMessage()).log("RestCallBack-Failure: '{}'"); + } + + @Override + public void onResponse(@NotNull final Call call, @NotNull final Response response) { + if (!active) { + return; + } + lock.lock(); + this.response = response; + notifyResult(); + lock.unlock(); + if (LOGGER.isTraceEnabled()) { + LOGGER.atTrace().addArgument(response).log("RestCallBack: '{}'"); + } + } + + private void notifyResult() { + synchronized (newData) { + active = false; + pendingCallbacks.remove(this); + completedCallbacks.add(this); + newData.notifyAll(); + } + } + } +} diff --git a/client/src/main/java/module-info.java b/client/src/main/java/module-info.java new file mode 100644 index 00000000..5e2e5b1e --- /dev/null +++ b/client/src/main/java/module-info.java @@ -0,0 +1,19 @@ +module io.opencmw.client { + requires java.management; + requires io.opencmw; + requires io.opencmw.serialiser; + requires org.slf4j; + requires jeromq; + requires disruptor; + requires org.jetbrains.annotations; + requires okhttp3; + requires okhttp3.sse; + requires kotlin.stdlib; + requires it.unimi.dsi.fastutil; + + exports io.opencmw.client.cmwlight; + exports io.opencmw.client.rest; + exports io.opencmw.client; + + opens io.opencmw.client to io.opencmw.serialiser; +} \ No newline at end of file diff --git a/client/src/test/java/io/opencmw/client/DataSourceExample.java b/client/src/test/java/io/opencmw/client/DataSourceExample.java new file mode 100644 index 00000000..90e4fd13 --- /dev/null +++ b/client/src/test/java/io/opencmw/client/DataSourceExample.java @@ -0,0 +1,45 @@ +package io.opencmw.client; + +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.opencmw.EventStore; +import io.opencmw.client.cmwlight.CmwLightExample; +import io.opencmw.client.cmwlight.DirectoryLightClient; +import io.opencmw.filter.EvtTypeFilter; +import io.opencmw.filter.TimingCtx; + +public class DataSourceExample { + private final static Logger LOGGER = LoggerFactory.getLogger(DataSourceExample.class); + private final static String DEV_NAME = "GSCD002"; + private final static String DEV2_NAME = "GSCD001"; + private final static String PROP = "AcquisitionDAQ"; + private final static String SELECTOR = "FAIR.SELECTOR.ALL"; + + public static void main(String[] args) throws DirectoryLightClient.DirectoryClientException { + // create and start a simple event store which just prints everything written to it to stdout + final EventStore eventStore = EventStore.getFactory().setFilterConfig(TimingCtx.class, EvtTypeFilter.class).build(); + eventStore.register((e, s, last) -> { + System.out.println(e); + System.out.println(e.payload.get()); + }); + eventStore.start(); + // create a data source publisher and add a subscription + final DataSourcePublisher dataSourcePublisher = new DataSourcePublisher(null, eventStore); + if (args.length == 0) { + LOGGER.atError().log("no directory server supplied"); + return; + } + LOGGER.atInfo().addArgument(args[0]).log("directory server: {}"); + final DirectoryLightClient directoryLightClient = new DirectoryLightClient(args[0]); + final List devInfo = directoryLightClient.getDeviceInfo(List.of(DEV_NAME, DEV2_NAME)); + final String address = devInfo.get(0).getAddress().replace("tcp://", "rda3://"); + final String address2 = devInfo.get(1).getAddress().replace("tcp://", "rda3://"); + // run the publisher's main loop + new Thread(dataSourcePublisher).start(); + dataSourcePublisher.subscribe(address + '/' + DEV_NAME + '/' + PROP + "?ctx=" + SELECTOR + '&' + "acquisitionModeFilter=int:0" + '&' + "channelNameFilter=GS11MU2:Current_1@10Hz", CmwLightExample.AcquisitionDAQ.class); + dataSourcePublisher.subscribe(address2 + '/' + DEV2_NAME + '/' + PROP + "?ctx=FAIR.SELECTOR.ALL" + '&' + "acquisitionModeFilter=int:4&channelNameFilter=GS02P:SumY:Triggered@25MHz", CmwLightExample.AcquisitionDAQ.class); + } +} diff --git a/client/src/test/java/io/opencmw/client/DataSourcePublisherTest.java b/client/src/test/java/io/opencmw/client/DataSourcePublisherTest.java new file mode 100644 index 00000000..0415d43b --- /dev/null +++ b/client/src/test/java/io/opencmw/client/DataSourcePublisherTest.java @@ -0,0 +1,299 @@ +package io.opencmw.client; + +import static org.junit.jupiter.api.Assertions.*; + +import java.time.Duration; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Timer; +import java.util.TimerTask; +import java.util.concurrent.CancellationException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Test; +import org.zeromq.SocketType; +import org.zeromq.ZContext; +import org.zeromq.ZMQ; +import org.zeromq.ZMsg; + +import io.opencmw.EventStore; +import io.opencmw.filter.EvtTypeFilter; +import io.opencmw.filter.TimingCtx; +import io.opencmw.serialiser.IoClassSerialiser; +import io.opencmw.serialiser.IoSerialiser; +import io.opencmw.serialiser.spi.BinarySerialiser; +import io.opencmw.serialiser.spi.FastByteBuffer; + +class DataSourcePublisherTest { + private static final AtomicReference testObject = new AtomicReference<>(); + private final Integer requestBody = 10; + private final Map requestFilter = new HashMap<>(); + + private DataSourcePublisher.ThePromisedFuture getTestFuture() { + return new DataSourcePublisher.ThePromisedFuture<>("endPoint", requestFilter, requestBody, Float.class, DataSourceFilter.ReplyType.GET, "TestClientID"); + } + + private static class TestDataSource extends DataSource { + public static final Factory FACTORY = new Factory() { + @Override + public boolean matches(final String endpoint) { + return endpoint.startsWith("test://"); + } + + @Override + public Class getMatchingSerialiserType(final String endpoint) { + return BinarySerialiser.class; + } + + @Override + public DataSource newInstance(final ZContext context, final String endpoint, final Duration timeout, final String clientId) { + return new TestDataSource(context, endpoint, timeout, clientId); + } + }; + private final static String INPROC = "inproc://testDataSource"; + private final ZContext context; + private final ZMQ.Socket socket; + private ZMQ.Socket internalSocket; + private long nextHousekeeping = 0; + private final IoClassSerialiser ioClassSerialiser = new IoClassSerialiser(new FastByteBuffer(2000)); + private final Map subscriptions = new HashMap<>(); + private final Map requests = new HashMap<>(); + private long nextNotification = 0L; + + public TestDataSource(final ZContext context, final String endpoint, final Duration timeOut, final String clientId) { + super(endpoint); + this.context = context; + this.socket = context.createSocket(SocketType.DEALER); + this.socket.bind(INPROC); + } + + @Override + public long housekeeping() { + final long currentTime = System.currentTimeMillis(); + if (currentTime > nextHousekeeping) { + if (currentTime > nextNotification) { + subscriptions.forEach((subscriptionId, endpoint) -> { + if (internalSocket == null) { + internalSocket = context.createSocket(SocketType.DEALER); + internalSocket.connect(INPROC); + } + final ZMsg msg = new ZMsg(); + msg.add(subscriptionId); + msg.add(endpoint); + msg.add(new byte[0]); // header + ioClassSerialiser.getDataBuffer().reset(); + ioClassSerialiser.serialiseObject(testObject.get()); + // todo: the serialiser does not report the correct position after serialisation + msg.add(Arrays.copyOfRange(ioClassSerialiser.getDataBuffer().elements(), 0, ioClassSerialiser.getDataBuffer().position() + 4)); + msg.add(new byte[0]); // exception + msg.send(internalSocket); + }); + nextNotification = currentTime + 3000; + } + requests.forEach((requestId, endpoint) -> { + if (internalSocket == null) { + internalSocket = context.createSocket(SocketType.DEALER); + internalSocket.connect(INPROC); + } + final ZMsg msg = new ZMsg(); + msg.add(requestId); + msg.add(endpoint); + msg.add(new byte[0]); // header + ioClassSerialiser.getDataBuffer().reset(); + ioClassSerialiser.serialiseObject(testObject.get()); + // todo: the serialiser does not report the correct position after serialisation + msg.add(Arrays.copyOfRange(ioClassSerialiser.getDataBuffer().elements(), 0, ioClassSerialiser.getDataBuffer().position() + 4)); + msg.add(new byte[0]); // exception + msg.send(internalSocket); + }); + requests.clear(); + nextHousekeeping = currentTime + 200; + } + return nextHousekeeping; + } + + @Override + public void get(final String requestId, final String endpoint, final byte[] filters, final byte[] data, final byte[] rbacToken) { + requests.put(requestId, endpoint); + } + + @Override + public void set(final String requestId, final String endpoint, final byte[] filters, final byte[] data, final byte[] rbacToken) { + throw new UnsupportedOperationException("cannot perform set"); + } + + @Override + public ZMQ.Socket getSocket() { + return socket; + } + + @Override + protected Factory getFactory() { + return FACTORY; + } + + @Override + public ZMsg getMessage() { + return ZMsg.recvMsg(socket, ZMQ.DONTWAIT); + } + + @Override + public void subscribe(final String reqId, final String endpoint, final byte[] rbacToken) { + subscriptions.put(reqId, endpoint); + } + + @Override + public void unsubscribe(final String reqId) { + subscriptions.remove(reqId); + } + } + + public static class TestObject { + private final String foo; + private final double bar; + + public TestObject(final String foo, final double bar) { + this.foo = foo; + this.bar = bar; + } + + public TestObject() { + this.foo = ""; + this.bar = Double.NaN; + } + + @Override + public boolean equals(final Object o) { + if (this == o) + return true; + if (!(o instanceof TestObject)) + return false; + final TestObject that = (TestObject) o; + return bar == that.bar && Objects.equals(foo, that.foo); + } + + @Override + public int hashCode() { + return Objects.hash(foo, bar); + } + + @Override + public String + toString() { + return "TestObject{" + + "foo='" + foo + '\'' + ", bar=" + bar + '}'; + } + } + + @Test + void testSubscribe() { + final AtomicBoolean eventReceived = new AtomicBoolean(false); + final TestObject referenceObject = new TestObject("foo", 1.337); + testObject.set(referenceObject); + + DataSource.register(TestDataSource.FACTORY); + + final EventStore eventStore = EventStore.getFactory().setFilterConfig(TimingCtx.class, EvtTypeFilter.class).build(); + eventStore.register((event, sequence, endOfBatch) -> { + assertEquals(testObject.get(), event.payload.get(TestObject.class)); + eventReceived.set(true); + }); + + final DataSourcePublisher dataSourcePublisher = new DataSourcePublisher(null, eventStore); + + eventStore.start(); + new Thread(dataSourcePublisher).start(); + + dataSourcePublisher.subscribe("test://foobar/testdev/prop?ctx=FAIR.SELECTOR.ALL&filter=foobar", TestObject.class); + + Awaitility.waitAtMost(Duration.ofSeconds(1)).until(eventReceived::get); + } + + @Test + void testGet() throws InterruptedException, ExecutionException, TimeoutException { + final TestObject referenceObject = new TestObject("foo", 1.337); + testObject.set(referenceObject); + + DataSource.register(TestDataSource.FACTORY); + + final EventStore eventStore = EventStore.getFactory().setFilterConfig(TimingCtx.class, EvtTypeFilter.class).build(); + + final DataSourcePublisher dataSourcePublisher = new DataSourcePublisher(null, eventStore); + + eventStore.start(); + new Thread(dataSourcePublisher).start(); + + final Future future = dataSourcePublisher.get("test://foobar/testdev/prop?ctx=FAIR.SELECTOR.ALL&filter=foobar", TestObject.class); + + final TestObject result = future.get(1000, TimeUnit.MILLISECONDS); + assertEquals(referenceObject, result); + } + + @Test + void testGetTimeout() { + testObject.set(null); // makes the test event source not answer the get request + + DataSource.register(TestDataSource.FACTORY); + + final EventStore eventStore = EventStore.getFactory().setFilterConfig(TimingCtx.class, EvtTypeFilter.class).build(); + + final DataSourcePublisher dataSourcePublisher = new DataSourcePublisher(null, eventStore); + + eventStore.start(); + new Thread(dataSourcePublisher).start(); + + final Future future = dataSourcePublisher.get("test://foobar/testdev/prop?ctx=FAIR.SELECTOR.ALL&filter=foobar", TestObject.class); + + assertThrows(TimeoutException.class, () -> future.get(1, TimeUnit.MILLISECONDS)); + } + + @Test + void testFuture() throws InterruptedException, ExecutionException { + final Float replyObject = (float) Math.PI; + + { + final DataSourcePublisher.ThePromisedFuture future = getTestFuture(); + + assertNotNull(future); + assertEquals("endPoint", future.getEndpoint()); + assertEquals(requestFilter, future.getRequestFilter()); + assertEquals(requestBody, future.getRequestBody()); + assertEquals(Float.class, future.getRequestedDomainObjType()); + assertEquals(DataSourceFilter.ReplyType.GET, future.getReplyType()); + assertEquals("TestClientID", future.getInternalRequestID()); + + future.setReply(replyObject); + assertEquals(replyObject, future.get()); + assertFalse(future.cancel(true)); + } + + { + // delayed reply + final DataSourcePublisher.ThePromisedFuture future = getTestFuture(); + new Timer().schedule(new TimerTask() { + @Override + public void run() { + future.setReply(replyObject); + } + }, 300); + Awaitility.waitAtMost(Duration.ofSeconds(1)).until(() -> replyObject.equals(future.get())); + } + + { + // cancelled reply + final DataSourcePublisher.ThePromisedFuture future = getTestFuture(); + assertFalse(future.isCancelled()); + future.cancel(true); + assertTrue(future.isCancelled()); + assertThrows(CancellationException.class, () -> future.get(1, TimeUnit.SECONDS)); + } + } +} diff --git a/client/src/test/java/io/opencmw/client/EndpointTest.java b/client/src/test/java/io/opencmw/client/EndpointTest.java new file mode 100644 index 00000000..8a6ac542 --- /dev/null +++ b/client/src/test/java/io/opencmw/client/EndpointTest.java @@ -0,0 +1,21 @@ +package io.opencmw.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +public class EndpointTest { + @Test + void testEndpointParsing() { + final Endpoint ep = new Endpoint("rda3://server:port/device/property?ctx=test.sel:t=100&filter=asdf&amount=int:1"); + assertEquals("rda3://", ep.getProtocol()); + assertEquals("rda3://server:port", ep.getAddress()); + assertEquals("device", ep.getDevice()); + assertEquals("property", ep.getProperty()); + assertEquals("test.sel:t=100", ep.getSelector()); + assertEquals(Map.of("filter", "asdf", "amount", 1), ep.getFilters()); + assertEquals("rda3://server:port/device/property?ctx=test.sel:t=101:id=1&filter=asdf&amount=int:1", ep.getEndpointForContext("test.sel:t=101:id=1")); + } +} diff --git a/client/src/test/java/io/opencmw/client/cmwlight/CmwLightDataSourceTest.java b/client/src/test/java/io/opencmw/client/cmwlight/CmwLightDataSourceTest.java new file mode 100644 index 00000000..5bebd870 --- /dev/null +++ b/client/src/test/java/io/opencmw/client/cmwlight/CmwLightDataSourceTest.java @@ -0,0 +1,117 @@ +package io.opencmw.client.cmwlight; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.charset.Charset; +import java.time.Duration; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.locks.LockSupport; + +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Test; +import org.zeromq.*; + +import io.opencmw.client.Endpoint; + +class CmwLightDataSourceTest { + @Test + void testCmwLightSubscription() throws CmwLightProtocol.RdaLightException { + // setup zero mq socket to mock cmw server + ZContext context = new ZContext(1); + ZMQ.Socket socket = context.createSocket(SocketType.DEALER); + socket.bind("tcp://localhost:7777"); + + final CmwLightDataSource client = new CmwLightDataSource(context, "rda3://localhost:7777/testdevice/testprop?ctx=test.selector&nFilter=int:1", "testClientId"); + + client.connect(); + client.housekeeping(); + + // check connection request was received + final CmwLightMessage connectMsg = CmwLightProtocol.parseMsg(ZMsg.recvMsg(socket)); + assertEquals(CmwLightProtocol.MessageType.CLIENT_CONNECT, connectMsg.messageType); + assertEquals(CmwLightProtocol.VERSION, connectMsg.version); + client.housekeeping(); // allow the subscription to be sent out + + // send connection ack + CmwLightProtocol.sendMsg(socket, CmwLightMessage.connectAck("1.3.7")); + CmwLightProtocol.sendMsg(socket, CmwLightMessage.SERVER_HB); + client.getMessage(); // Make client receive ack and update connection status + client.housekeeping(); // allow the subscription to be sent out + + // assert that the client has connected + Awaitility.await().atMost(Duration.ofSeconds(2)).until(() -> { + client.getMessage(); // Make client receive ack and update connection status + client.housekeeping(); // allow the subscription to be sent out + return client.connectionState.get().equals(CmwLightDataSource.ConnectionState.CONNECTED); + }); + + // request subscription + final String reqId = "testId"; + final String endpoint = "rda3://localhost:7777/testdevice/testprop?ctx=FAIR.SELECTOR.ALL&nFilter=int:1"; + client.subscribe(reqId, endpoint, null); + + final CmwLightMessage subMsg = getNextNonHeartbeatMsg(socket, client, false); + assertEquals(CmwLightProtocol.MessageType.CLIENT_REQ, subMsg.messageType); + assertEquals(CmwLightProtocol.RequestType.SUBSCRIBE, subMsg.requestType); + assertEquals(Map.of("nFilter", 1), subMsg.requestContext.filters); + + // acknowledge subscription + final long sourceId = 1337L; + CmwLightProtocol.sendMsg(socket, CmwLightMessage.subscribeReply(subMsg.sessionId, subMsg.id, subMsg.deviceName, subMsg.propertyName, Map.of(CmwLightProtocol.FieldName.SOURCE_ID_TAG.value(), sourceId))); + + // assert that the subscription was established + Awaitility.await().atMost(Duration.ofSeconds(2)).until(() -> { + client.getMessage(); // Make client receive ack and update connection status + client.housekeeping(); // allow the subscription to be sent out + return client.replyIdMap.containsKey(sourceId); + }); + + // send 10 updates + for (int i = 0; i < 10; i++) { + final String cycleName = "FAIR.SELECTOR.C=" + (i + 1); + CmwLightProtocol.sendMsg(socket, CmwLightMessage.notificationReply(subMsg.sessionId, sourceId, "", "", new ZFrame("data"), i, + new CmwLightMessage.DataContext(cycleName, 123456789, 123456788, null), CmwLightProtocol.UpdateType.NORMAL)); + + // assert that the subscription update was received + Awaitility.await().atMost(Duration.ofSeconds(2)).until(() -> { + final ZMsg reply = client.getMessage(); // Make client receive ack and update connection status + client.housekeeping(); // allow the subscription to be sent out + + return reply.size() == 5 && reply.pollFirst().getString(Charset.defaultCharset()).equals("testId") + && Objects.requireNonNull(reply.pollFirst()).getString(Charset.defaultCharset()).equals(new Endpoint(endpoint).getEndpointForContext(cycleName)) + && Objects.requireNonNull(reply.pollFirst()).getData().length == 0 + && Objects.requireNonNull(reply.pollFirst()).getString(Charset.defaultCharset()).equals("data") + && Objects.requireNonNull(reply.pollFirst()).getData().length == 0; + }); + } + } + + /* + / get next message sent from client to server ignoring heartbeats, periodically send heartbeat and perform housekeeping + */ + private CmwLightMessage getNextNonHeartbeatMsg(final ZMQ.Socket socket, final CmwLightDataSource client, boolean debug) throws CmwLightProtocol.RdaLightException { + int i = 0; + while (true) { + final ZMsg msg = ZMsg.recvMsg(socket, false); + final CmwLightMessage result = msg == null ? null : CmwLightProtocol.parseMsg(msg); + if (debug) { + if (result == null) { + System.out.print('.'); + } else { + System.out.println(result); + } + } + if (result != null && result.messageType != CmwLightProtocol.MessageType.CLIENT_HB) { + return result; + } + if (i % 10 == 0) { // send server heartbeat every second + CmwLightProtocol.sendMsg(socket, CmwLightMessage.SERVER_HB); + } + client.housekeeping(); + client.getMessage(); + LockSupport.parkNanos(100000); + i++; + } + } +} diff --git a/client/src/test/java/io/opencmw/client/cmwlight/CmwLightExample.java b/client/src/test/java/io/opencmw/client/cmwlight/CmwLightExample.java new file mode 100644 index 00000000..faf5aa15 --- /dev/null +++ b/client/src/test/java/io/opencmw/client/cmwlight/CmwLightExample.java @@ -0,0 +1,119 @@ +package io.opencmw.client.cmwlight; + +import java.util.Arrays; +import java.util.Collections; + +import org.zeromq.ZContext; +import org.zeromq.ZMQ; + +import io.opencmw.serialiser.IoClassSerialiser; +import io.opencmw.serialiser.spi.CmwLightSerialiser; +import io.opencmw.serialiser.spi.FastByteBuffer; + +public class CmwLightExample { // NOPMD is not a utility class but a sample + private final static String CMW_NAMESERVER = "cmwpro00a.acc.gsi.de:5021"; + private final static String DEVICE = "GSCD002"; + // private final static String PROPERTY = "SnoopTriggerEvents"; + private final static String PROPERTY = "AcquisitionDAQ"; + private final static String SELECTOR = "FAIR.SELECTOR.ALL"; + + public static void main(String[] args) throws DirectoryLightClient.DirectoryClientException { + subscribeAcqFromDigitizer(); + } + + public static void subscribeAcqFromDigitizer() throws DirectoryLightClient.DirectoryClientException { + final DirectoryLightClient directoryClient = new DirectoryLightClient(CMW_NAMESERVER); + DirectoryLightClient.Device device = directoryClient.getDeviceInfo(Collections.singletonList(DEVICE)).get(0); + System.out.println(device); + final String address = device.servers.stream().findFirst().orElseThrow().get("Address:"); + System.out.println("connect client to " + address); + final CmwLightDataSource client = new CmwLightDataSource(new ZContext(1), address, "testclient"); + final ZMQ.Poller poller = client.getContext().createPoller(1); + poller.register(client.getSocket(), ZMQ.Poller.POLLIN); + client.connect(); + + System.out.println("starting subscription"); + // 4 = Triggered Acquisition Mode; 0 = Continuous Acquisition mode + String filtersString = "acquisitionModeFilter=int:0&channelNameFilter=GS11MU2:Current_1@10Hz"; + String filters2String = "acquisitionModeFilter=int:0&channelNameFilter=GS11MU2:Voltage_1@10Hz"; + client.subscribe("r1", "rda3://" + DEVICE + '/' + DEVICE + '/' + PROPERTY + "?ctx=" + SELECTOR + "&" + filtersString, null); + client.subscribe("r1", "rda3://" + DEVICE + '/' + DEVICE + '/' + PROPERTY + "?ctx=" + SELECTOR + "&" + filters2String, null); + client.subscriptions.forEach((id, c) -> System.out.println(id + " -> " + c)); + + int i = 0; + while (i < 45) { + client.housekeeping(); + poller.poll(); + final CmwLightMessage result = client.receiveData(); + if (result != null && result.requestType == CmwLightProtocol.RequestType.NOTIFICATION_DATA) { + System.out.println(result); + final byte[] bytes = result.bodyData.getData(); + final IoClassSerialiser classSerialiser = new IoClassSerialiser(FastByteBuffer.wrap(bytes), CmwLightSerialiser.class); + final AcquisitionDAQ acq = classSerialiser.deserialiseObject(AcquisitionDAQ.class); + System.out.println("body: " + acq); + } else { + if (result != null) + System.out.println(result); + } + if (i == 15) { + client.subscriptions.forEach((id, c) -> System.out.println(id + " -> " + c)); + System.out.println("unsubscribe"); + client.unsubscribe("r1"); + client.unsubscribe("r2"); + } + i++; + } + } + + public static class AcquisitionDAQ { + public String refTriggerName; + public long refTriggerStamp; + public float[] channelTimeSinceRefTrigger; + public float channelUserDelay; + public float channelActualDelay; + public String channelName; + public float[] channelValue; + public float[] channelError; + public String channelUnit; + public int status; + public float channelRangeMin; + public float channelRangeMax; + public float temperature; + public int processIndex; + public int sequenceIndex; + public int chainIndex; + public int eventNumber; + public int timingGroupId; + public long acquisitionStamp; + public long eventStamp; + public long processStartStamp; + public long sequenceStartStamp; + public long chainStartStamp; + + @Override + public String toString() { + return "AcquisitionDAQ{" + + "refTriggerName='" + refTriggerName + '\'' + ", refTriggerStamp=" + refTriggerStamp + ", channelTimeSinceRefTrigger(n=" + channelTimeSinceRefTrigger.length + ")=" + Arrays.toString(Arrays.copyOfRange(channelTimeSinceRefTrigger, 0, 3)) + ", channelUserDelay=" + channelUserDelay + ", channelActualDelay=" + channelActualDelay + ", channelName='" + channelName + '\'' + ", channelValue(n=" + channelValue.length + ")=" + Arrays.toString(Arrays.copyOfRange(channelValue, 0, 3)) + ", channelError(n=" + channelError.length + ")=" + Arrays.toString(Arrays.copyOfRange(channelError, 0, 3)) + ", channelUnit='" + channelUnit + '\'' + ", status=" + status + ", channelRangeMin=" + channelRangeMin + ", channelRangeMax=" + channelRangeMax + ", temperature=" + temperature + ", processIndex=" + processIndex + ", sequenceIndex=" + sequenceIndex + ", chainIndex=" + chainIndex + ", eventNumber=" + eventNumber + ", timingGroupId=" + timingGroupId + ", acquisitionStamp=" + acquisitionStamp + ", eventStamp=" + eventStamp + ", processStartStamp=" + processStartStamp + ", sequenceStartStamp=" + sequenceStartStamp + ", chainStartStamp=" + chainStartStamp + '}'; + } + } + + public static class SnoopAcquisition { + public String TriggerEventName; + public long acquisitionStamp; + public int chainIndex; + public long chainStartStamp; + public int eventNumber; + public long eventStamp; + public int processIndex; + public long processStartStamp; + public int sequenceIndex; + public long sequenceStartStamp; + public int timingGroupID; + + @Override + public String toString() { + return "SnoopAcquisition{" + + "TriggerEventName='" + TriggerEventName + '\'' + ", acquisitionStamp=" + acquisitionStamp + ", chainIndex=" + chainIndex + ", chainStartStamp=" + chainStartStamp + ", eventNumber=" + eventNumber + ", eventStamp=" + eventStamp + ", processIndex=" + processIndex + ", processStartStamp=" + processStartStamp + ", sequenceIndex=" + sequenceIndex + ", sequenceStartStamp=" + sequenceStartStamp + ", timingGroupID=" + timingGroupID + '}'; + } + } +} diff --git a/client/src/test/java/io/opencmw/client/cmwlight/CmwLightProtocolTest.java b/client/src/test/java/io/opencmw/client/cmwlight/CmwLightProtocolTest.java new file mode 100644 index 00000000..22dffdcc --- /dev/null +++ b/client/src/test/java/io/opencmw/client/cmwlight/CmwLightProtocolTest.java @@ -0,0 +1,210 @@ +package io.opencmw.client.cmwlight; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.charset.Charset; +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.zeromq.ZFrame; +import org.zeromq.ZMsg; + +/** + * Test serialisation and deserialisation of cmw protocol messages. + */ +class CmwLightProtocolTest { + @Test + void testConnectRequest() throws CmwLightProtocol.RdaLightException { + final CmwLightMessage subscriptionMsg = CmwLightMessage.connectRequest("testsession", + 1337L, + "testdev", + "testprop"); + ZMsg serialised = CmwLightProtocol.serialiseMsg(subscriptionMsg); + final CmwLightMessage restored = CmwLightProtocol.parseMsg(serialised); + assertEquals(subscriptionMsg, restored); + } + + @Test + void testEventRequest() throws CmwLightProtocol.RdaLightException { + final CmwLightMessage subscriptionMsg = CmwLightMessage.eventRequest("testsession", + 1337L, + "testdev", + "testprop"); + ZMsg serialised = CmwLightProtocol.serialiseMsg(subscriptionMsg); + final CmwLightMessage restored = CmwLightProtocol.parseMsg(serialised); + assertEquals(subscriptionMsg, restored); + } + + @Test + void testEventReply() throws CmwLightProtocol.RdaLightException { + final CmwLightMessage subscriptionMsg = CmwLightMessage.eventReply("testsession", + 1337L, + "testdev", + "testprop"); + ZMsg serialised = CmwLightProtocol.serialiseMsg(subscriptionMsg); + final CmwLightMessage restored = CmwLightProtocol.parseMsg(serialised); + assertEquals(subscriptionMsg, restored); + } + + @Test + @Disabled // issues with the empty map in options in the cmw light serialiser + void testSessionConfirmReply() throws CmwLightProtocol.RdaLightException { + final CmwLightMessage subscriptionMsg = CmwLightMessage.sessionConfirmReply("testsession", + 1337L, + "testdev", + "testprop", + Map.of(CmwLightProtocol.FieldName.SESSION_BODY_TAG.value(), Collections.emptyMap())); + ZMsg serialised = CmwLightProtocol.serialiseMsg(subscriptionMsg); + serialised.forEach(frame -> System.out.println(frame.getString(Charset.defaultCharset()))); + serialised.forEach(frame -> System.out.println(Arrays.toString(frame.getData()))); + final CmwLightMessage restored = CmwLightProtocol.parseMsg(serialised); + assertEquals(subscriptionMsg, restored); + } + + @Test + void testSessionGetReply() throws CmwLightProtocol.RdaLightException { + final ZFrame data = new ZFrame(new byte[] { 0, 1, 4, 7, 8 }); + final CmwLightMessage subscriptionMsg = CmwLightMessage.getReply("testsession", + 1338L, + "testdev", + "testprop", + data, + new CmwLightMessage.DataContext("testCycleName", 4242, 2323, null)); + ZMsg serialised = CmwLightProtocol.serialiseMsg(subscriptionMsg); + final CmwLightMessage restored = CmwLightProtocol.parseMsg(serialised); + assertEquals(subscriptionMsg, restored); + } + + @Test + void testNotificationReply() throws CmwLightProtocol.RdaLightException { + final ZFrame data = new ZFrame(new byte[] { 0, 1, 4, 7, 8 }); + final CmwLightMessage subscriptionMsg = CmwLightMessage.notificationReply("testsession", + 1337L, + "testdev", + "testprop", + data, + 7, + new CmwLightMessage.DataContext("testCycleName", 4242, 2323, null), + CmwLightProtocol.UpdateType.IMMEDIATE_UPDATE); + ZMsg serialised = CmwLightProtocol.serialiseMsg(subscriptionMsg); + final CmwLightMessage restored = CmwLightProtocol.parseMsg(serialised); + assertEquals(subscriptionMsg, restored); + } + + @Test + void testExceptionReply() throws CmwLightProtocol.RdaLightException { + final CmwLightMessage subscriptionMsg = CmwLightMessage.exceptionReply( + "testsession", 1337L, "testdev", "testprop", + "test exception message", 314, 981, (byte) 3); + ZMsg serialised = CmwLightProtocol.serialiseMsg(subscriptionMsg); + final CmwLightMessage restored = CmwLightProtocol.parseMsg(serialised); + assertEquals(subscriptionMsg, restored); + } + + @Test + void testNotificationExceptionReply() throws CmwLightProtocol.RdaLightException { + final CmwLightMessage subscriptionMsg = CmwLightMessage.notificationExceptionReply( + "testsession", 1337L, "testdev", "testprop", + "test exception message", 314, 981, (byte) 3); + ZMsg serialised = CmwLightProtocol.serialiseMsg(subscriptionMsg); + final CmwLightMessage restored = CmwLightProtocol.parseMsg(serialised); + assertEquals(subscriptionMsg, restored); + } + + @Test + void testSubscribeExceptionReply() throws CmwLightProtocol.RdaLightException { + final CmwLightMessage subscriptionMsg = CmwLightMessage.subscribeExceptionReply( + "testsession", 1337L, "testdev", "testprop", + "test exception message", 314, 981, (byte) 3); + ZMsg serialised = CmwLightProtocol.serialiseMsg(subscriptionMsg); + final CmwLightMessage restored = CmwLightProtocol.parseMsg(serialised); + assertEquals(subscriptionMsg, restored); + } + + @Test + void testSubscribeRequest() throws CmwLightProtocol.RdaLightException { + final CmwLightMessage subscriptionMsg = CmwLightMessage.subscribeRequest( + "testsession", 1337L, "testdev", "testprop", + Map.of("b", 1337L), + new CmwLightMessage.RequestContext("testselector", Map.of("testfilter", 5L), null), + CmwLightProtocol.UpdateType.NORMAL); + ZMsg serialised = CmwLightProtocol.serialiseMsg(subscriptionMsg); + // System.out.println(serialised); + final CmwLightMessage restored = CmwLightProtocol.parseMsg(serialised); + assertEquals(subscriptionMsg, restored); + } + + @Test + void testUnsubscribeRequest() throws CmwLightProtocol.RdaLightException { + final CmwLightMessage subscriptionMsg = CmwLightMessage.unsubscribeRequest("testsession", + 1337L, + "testdev", + "testprop", + Map.of("b", 1337L), + CmwLightProtocol.UpdateType.NORMAL); + ZMsg serialised = CmwLightProtocol.serialiseMsg(subscriptionMsg); + final CmwLightMessage restored = CmwLightProtocol.parseMsg(serialised); + assertEquals(subscriptionMsg, restored); + } + + @Test + void testGetRequest() throws CmwLightProtocol.RdaLightException { + final CmwLightMessage subscriptionMsg = CmwLightMessage.getRequest("testsession", + 1337L, + "testdev", + "testprop", + new CmwLightMessage.RequestContext("testselector", Map.of("testfilter", 5L), null)); + ZMsg serialised = CmwLightProtocol.serialiseMsg(subscriptionMsg); + final CmwLightMessage restored = CmwLightProtocol.parseMsg(serialised); + assertEquals(subscriptionMsg, restored); + } + + @Test + void testSetRequest() throws CmwLightProtocol.RdaLightException { + final ZFrame data = new ZFrame(new byte[] { 0, 1, 4, 7, 8 }); + final CmwLightMessage subscriptionMsg = CmwLightMessage.setRequest("testsession", + 1337L, + "testdev", + "testprop", + data, + new CmwLightMessage.RequestContext("testselector", Map.of("testfilter", 5L), null)); + ZMsg serialised = CmwLightProtocol.serialiseMsg(subscriptionMsg); + final CmwLightMessage restored = CmwLightProtocol.parseMsg(serialised); + assertEquals(subscriptionMsg, restored); + } + + @Test + void testHbServerMsg() throws CmwLightProtocol.RdaLightException { + final CmwLightMessage msg = CmwLightMessage.SERVER_HB; + ZMsg serialised = CmwLightProtocol.serialiseMsg(msg); + final CmwLightMessage restored = CmwLightProtocol.parseMsg(serialised); + assertEquals(msg, restored); + } + + @Test + void testHbClientMsg() throws CmwLightProtocol.RdaLightException { + final CmwLightMessage msg = CmwLightMessage.CLIENT_HB; + ZMsg serialised = CmwLightProtocol.serialiseMsg(msg); + final CmwLightMessage restored = CmwLightProtocol.parseMsg(serialised); + assertEquals(msg, restored); + } + + @Test + void testConnectMsg() throws CmwLightProtocol.RdaLightException { + final CmwLightMessage msg = CmwLightMessage.connect("1.3.7"); + ZMsg serialised = CmwLightProtocol.serialiseMsg(msg); + final CmwLightMessage restored = CmwLightProtocol.parseMsg(serialised); + assertEquals(msg, restored); + } + + @Test + void testConnectAckMsg() throws CmwLightProtocol.RdaLightException { + final CmwLightMessage msg = CmwLightMessage.connectAck("1.3.7"); + ZMsg serialised = CmwLightProtocol.serialiseMsg(msg); + final CmwLightMessage restored = CmwLightProtocol.parseMsg(serialised); + assertEquals(msg, restored); + } +} diff --git a/client/src/test/java/io/opencmw/client/rest/Event.java b/client/src/test/java/io/opencmw/client/rest/Event.java new file mode 100644 index 00000000..7a25c33d --- /dev/null +++ b/client/src/test/java/io/opencmw/client/rest/Event.java @@ -0,0 +1,41 @@ +package io.opencmw.client.rest; + +import java.util.Objects; + +public class Event { + private final String id; + private final String type; + private final String data; + + public Event(final String id, final String type, final String data) { + if (data == null) { + throw new IllegalArgumentException("data == null"); + } + this.id = id; + this.type = type; + this.data = data; + } + + @Override + public String toString() { + return "Event{id='" + id + "', type='" + type + "', data='" + data + "'}"; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (!(o instanceof Event)) + return false; + Event other = (Event) o; + return Objects.equals(id, other.id) && Objects.equals(type, other.type) && data.equals(other.data); + } + + @Override + public int hashCode() { + int result = Objects.hashCode(id); + result = 31 * result + Objects.hashCode(type); + result = 31 * result + data.hashCode(); + return result; + } +} diff --git a/client/src/test/java/io/opencmw/client/rest/EventSourceRecorder.java b/client/src/test/java/io/opencmw/client/rest/EventSourceRecorder.java new file mode 100644 index 00000000..65f055b0 --- /dev/null +++ b/client/src/test/java/io/opencmw/client/rest/EventSourceRecorder.java @@ -0,0 +1,142 @@ +package io.opencmw.client.rest; + +import static java.util.concurrent.TimeUnit.SECONDS; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.util.Objects; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingDeque; + +import org.jetbrains.annotations.NotNull; + +import okhttp3.Response; +import okhttp3.internal.platform.Platform; +import okhttp3.sse.EventSource; +import okhttp3.sse.EventSourceListener; + +class EventSourceRecorder extends EventSourceListener { + private final BlockingQueue events = new LinkedBlockingDeque<>(); + + @Override + public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) { + Platform.get().log("[ES] onOpen", Platform.INFO, null); + events.add(new Open(eventSource, response)); + } + + @Override + public void onEvent(@NotNull EventSource eventSource, String id, String type, @NotNull String data) { + Platform.get().log("[ES] onEvent", Platform.INFO, null); + events.add(new Event(id, type, data)); + } + + @Override + public void onClosed(@NotNull EventSource eventSource) { + Platform.get().log("[ES] onClosed", Platform.INFO, null); + events.add(new Closed()); + } + + @Override + public void onFailure(@NotNull EventSource eventSource, Throwable t, Response response) { + Platform.get().log("[ES] onFailure", Platform.INFO, t); + events.add(new Failure(t, response)); + } + + private Object nextEvent() { + try { + Object event = events.poll(10, SECONDS); + if (event == null) { + throw new AssertionError("Timed out waiting for event."); + } + return event; + } catch (InterruptedException e) { + throw new AssertionError(e); + } + } + + public void assertExhausted() { + assertTrue(events.isEmpty()); + } + + public void assertEvent(String id, String type, String data) { + Object actual = nextEvent(); + assertEquals(new Event(id, type, data), actual); + } + + public EventSource assertOpen() { + Object event = nextEvent(); + if (!(event instanceof Open)) { + throw new AssertionError("Expected Open but was " + event); + } + return ((Open) event).eventSource; + } + + public void assertClose() { + Object event = nextEvent(); + if (!(event instanceof Closed)) { + throw new AssertionError("Expected Open but was " + event); + } + } + + public void assertFailure(String message) { + Object event = nextEvent(); + if (!(event instanceof Failure)) { + throw new AssertionError("Expected Failure but was " + event); + } + if (message != null) { + assertEquals(message, ((Failure) event).t.getMessage()); + } else { + assertNull(((Failure) event).t); + } + } + + static final class Open { + final EventSource eventSource; + final Response response; + + Open(EventSource eventSource, Response response) { + this.eventSource = eventSource; + this.response = response; + } + + @Override + public String toString() { + return "Open[" + response + ']'; + } + } + + static final class Failure { + final Throwable t; + final Response response; + final String responseBody; + + Failure(Throwable t, Response response) { + this.t = t; + this.response = response; + String responseBody = null; + if (response != null) { + try { + responseBody = Objects.requireNonNull(response.body()).string(); + } catch (IOException ignored) { + } + } + this.responseBody = responseBody; + } + + @Override + public String toString() { + if (response == null) { + return "Failure[" + t + "]"; + } + return "Failure[" + response + "]"; + } + } + + static final class Closed { + @Override + public String toString() { + return "Closed[]"; + } + } +} diff --git a/client/src/test/java/io/opencmw/client/rest/RestDataSourceTest.java b/client/src/test/java/io/opencmw/client/rest/RestDataSourceTest.java new file mode 100644 index 00000000..39693ae7 --- /dev/null +++ b/client/src/test/java/io/opencmw/client/rest/RestDataSourceTest.java @@ -0,0 +1,251 @@ +package io.opencmw.client.rest; + +import static java.util.Objects.requireNonNull; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.time.Duration; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.LockSupport; + +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.zeromq.ZContext; +import org.zeromq.ZMQ; +import org.zeromq.ZMQException; +import org.zeromq.ZMsg; + +import io.opencmw.MimeType; + +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import okhttp3.sse.EventSource; +import okhttp3.sse.EventSources; +import zmq.ZError; + +class RestDataSourceTest { + private static final Logger LOGGER = LoggerFactory.getLogger(RestDataSourceTest.class); + private static final String TEST_DATA = "Hello World!"; + private static final int DEFAULT_TIMEOUT_MILLIS = 1000; + private static final int DEFAULT_WAIT_MILLIS = 10; + private MockWebServer server; + private OkHttpClient client; + private EventSourceRecorder listener; + + @BeforeEach + void before() throws IOException { + this.server = new MockWebServer(); + server.setDispatcher(new CustomDispatcher()); + server.start(); + + client = new OkHttpClient(); + listener = new EventSourceRecorder(); + } + + @AfterEach + void after() throws IOException { + server.close(); + } + + @Test + void basicEvent() { + enqueue(new MockResponse().setBody("data: hey\n\n").setHeader("content-type", "text/event-stream")); + + EventSource source = newEventSource(); + + assertEquals("/", source.request().url().encodedPath()); + + listener.assertOpen(); + listener.assertEvent(null, null, "hey"); + listener.assertClose(); + } + + @Test + void basicRestDataSourceTests() { + assertThrows(UnsupportedOperationException.class, () -> new RestDataSource(null, null)); + assertThrows(UnsupportedOperationException.class, () -> new RestDataSource(null, "")); + assertThrows(IllegalArgumentException.class, () -> new RestDataSource(null, server.url("/sse").toString(), null, "clientName")); // NOSONAR + RestDataSource dataSource = new RestDataSource(null, server.url("/sse").toString()); + assertNotNull(dataSource); + assertDoesNotThrow(dataSource::housekeeping); + } + + @Test + void testRestDataSource() { + try (final ZContext ctx = new ZContext()) { + final RestDataSource dataSource = new RestDataSource(ctx, server.url("/sse").toString()); + assertNotNull(dataSource); + + dataSource.subscribe("1", server.url("/sse").toString(), new byte[0]); + receiveAndCheckData(dataSource, "io.opencmw.client.rest.RestDataSource#*", true); + + // test asynchronuous get + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(DEFAULT_WAIT_MILLIS)); + dataSource.enqueueRequest("testHashKey#1"); + final ZMsg returnMessage = receiveAndCheckData(dataSource, "testHashKey#1", true); + assertEquals(0, returnMessage.getLast().getData().length); + + dataSource.stop(); + } + } + + @Test + void testRestDataSourceTimeOut() { + try (final ZContext ctx = new ZContext()) { + final RestDataSource dataSource = new RestDataSource(ctx, server.url("/testDelayed").toString(), Duration.ofMillis(10), "testClient"); + assertNotNull(dataSource); + + // test asynchronuous with time-out + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(DEFAULT_WAIT_MILLIS)); + dataSource.enqueueRequest("testHashKey#1"); + final ZMsg returnMessage = receiveAndCheckData(dataSource, "testHashKey#1", true); + assertNotEquals(0, returnMessage.getLast().getData().length); + + dataSource.stop(); + } + } + + @Test + void testRestDataSourceConnectionError() { + try (final ZContext ctx = new ZContext()) { + final RestDataSource dataSource = new RestDataSource(ctx, server.url("/testError").toString()); + assertNotNull(dataSource); + + // three retries and a successful response + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(DEFAULT_WAIT_MILLIS)); + dataSource.cancelLastCall = 3; // required for unit-testing + dataSource.enqueueRequest("testHashKey#1"); + ZMsg returnMessage = receiveAndCheckData(dataSource, "testHashKey#1", true); + System.err.println("returnMessage = " + returnMessage); + assertNotEquals(0, requireNonNull(requireNonNull(returnMessage.pollLast())).getData().length); + assertNotEquals(0, requireNonNull(requireNonNull(returnMessage.pollLast())).getData().length); + + // four retries without successful response + dataSource.cancelLastCall = 4; // required for unit-testing + dataSource.enqueueRequest("testHashKey#1"); + returnMessage = receiveAndCheckData(dataSource, "testHashKey#1", true); + assertNotEquals(0, requireNonNull(requireNonNull(returnMessage.pollLast())).getData().length); + assertEquals(0, requireNonNull(requireNonNull(returnMessage.pollLast())).getData().length); + + dataSource.stop(); + } + } + + @Test + @Disabled("not to be used in CI/CD environment") + void testLsaRestDataSource() { + try (final ZContext ctx = new ZContext()) { + final String endPoint = "?msg=HalloRaphael;mytime=" + System.currentTimeMillis(); + final RestDataSource dataSource = new RestDataSource(ctx, endPoint); + assertNotNull(dataSource); + dataSource.enqueueRequest("lsaHashKey#1"); + receiveAndCheckData(dataSource, "lsaHashKey#1", false); + + dataSource.stop(); + } + } + + private void enqueue(MockResponse response) { + final Dispatcher dispatcher = server.getDispatcher(); + if (!(dispatcher instanceof CustomDispatcher)) { + throw new IllegalStateException("wrong dispatcher type: " + dispatcher); + } + CustomDispatcher customDispatcher = (CustomDispatcher) dispatcher; + customDispatcher.enquedEvents.offer(response); + } + + private EventSource newEventSource() { + Request.Builder builder = new Request.Builder().url(server.url("/")); + + builder.header("Accept", "event-stream"); + + Request request = builder.build(); + EventSource.Factory factory = EventSources.createFactory(client); + return factory.newEventSource(request, listener); + } + + private ZMsg receiveAndCheckData(final RestDataSource dataSource, final String hashKey, final boolean verbose) { + final ZMQ.Poller poller = dataSource.getCtx().createPoller(1); + final ZMQ.Socket socket = dataSource.getSocket(); + final int socketID = poller.register(socket, ZMQ.Poller.POLLIN); + + final int n = poller.poll(TimeUnit.MILLISECONDS.toMillis(DEFAULT_TIMEOUT_MILLIS)); + assertEquals(1, n, "external socket did not receive the expected number of message frames for hashKey = " + hashKey); + ZMsg msg; + if (poller.pollin(socketID)) { + try { + msg = dataSource.getMessage(); + if (verbose) { + LOGGER.atDebug().addArgument(msg).log("received reply via external socket: '{}'"); + } + + if (msg == null) { + throw new IllegalStateException("no data received"); + } + final String text = msg.getFirst().toString(); + assertTrue(text.matches(hashKey.replace("?", ".?").replace("*", ".*?")), "mesage " + text + " did not match hashKey template " + hashKey); + } catch (ZMQException e) { + final int errorCode = socket.errno(); + LOGGER.atError().setCause(e).addArgument(errorCode).addArgument(ZError.toString(errorCode)).log("recvMsg error {} - {}"); + throw e; + } + } else { + throw new IllegalStateException("no data received - pollin"); + } + + poller.close(); + return msg; + } + + private static class CustomDispatcher extends Dispatcher { + public BlockingQueue enquedEvents = new LinkedBlockingQueue<>(); + @Override + public @NotNull MockResponse dispatch(@NotNull RecordedRequest request) { + if (!enquedEvents.isEmpty()) { + // dispatch enqued events + return enquedEvents.poll(); + } + final String acceptHeader = request.getHeader("Accept"); + final String contentType = request.getHeader("content-type"); + LOGGER.atTrace().addArgument(request).addArgument(request.getPath()).addArgument(contentType).addArgument(acceptHeader) // + .log("server-request: {} path = {} contentType={} accept={}"); + + final String path; + try { + path = request.getPath(); + } catch (NullPointerException e) { + LOGGER.atError().setCause(e).log("server-request exception"); + return new MockResponse().setResponseCode(404); + } + switch (requireNonNull(path)) { + case "/sse": + if ("text/event-stream".equals(acceptHeader)) { + return new MockResponse().setBody("data: event-stream init\n\n").setHeader("content-type", "text/event-stream"); + } + return new MockResponse().setBody(TEST_DATA).setHeader("content-type", MimeType.TEXT.toString()); + case "/test": + return new MockResponse().setResponseCode(200).setBody("special test data"); + case "/testDelayed": + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1000)); + return new MockResponse().setResponseCode(200).setBody("special delayed test data"); + case "/testError": + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(2 * DEFAULT_WAIT_MILLIS)); + return new MockResponse().setResponseCode(200).setBody("special error test data"); + default: + } + return new MockResponse().setResponseCode(404); + } + } +} diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..7f243cf9 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,2 @@ +ignore: + - "*/src/test/**/*" diff --git a/concepts/pom.xml b/concepts/pom.xml new file mode 100644 index 00000000..159f4006 --- /dev/null +++ b/concepts/pom.xml @@ -0,0 +1,35 @@ + + + + opencmw + io.opencmw + ${revision}${sha1}${changelist} + ../pom.xml + + 4.0.0 + + concepts + + Module for experimental features which will be moved to more appropriate modules once they become stable. + + + + + io.opencmw + core + ${revision}${sha1}${changelist} + + + de.gsi.dataset + chartfx-dataset + ${version.chartfx} + + + it.unimi.dsi + fastutil + ${version.fastutil} + + + \ No newline at end of file diff --git a/concepts/src/main/java/io/opencmw/concepts/aggregate/DemuxEventDispatcher.java b/concepts/src/main/java/io/opencmw/concepts/aggregate/DemuxEventDispatcher.java new file mode 100644 index 00000000..da7126ee --- /dev/null +++ b/concepts/src/main/java/io/opencmw/concepts/aggregate/DemuxEventDispatcher.java @@ -0,0 +1,130 @@ +package io.opencmw.concepts.aggregate; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.locks.LockSupport; + +import io.opencmw.utils.Cache; + +import com.lmax.disruptor.EventHandler; +import com.lmax.disruptor.RingBuffer; +import com.lmax.disruptor.Sequence; +import com.lmax.disruptor.SequenceReportingEventHandler; +import com.lmax.disruptor.TimeoutHandler; + +/** + * Dispatches aggregation workers upon seeing new values for a specified event field. + * Each aggregation worker then assembles all events for this value and optionally publishes back an aggregated events. + * If the aggregation is not completed within a configurable timeout, a partial AggregationEvent is published. + * + * For now events are aggregated into a list of Objects until a certain number of events is reached. + * The final api should allow to specify different Objects to be placed into a result domain object. + * + * @author Alexander Krimm + */ +public class DemuxEventDispatcher implements SequenceReportingEventHandler { + private static final int N_WORKERS = 4; // number of workers defines the maximum number of aggregate events groups which can be overlapping + private static final long TIMEOUT = 400; + private static final int RETENTION_SIZE = 10; + private static final int N_AGG_ELEMENTS = 3; + private final AggregationHandler[] aggregationHandler; + private final List freeWorkers = Collections.synchronizedList(new ArrayList<>(N_WORKERS)); + private final RingBuffer rb; + // private Map aggregatedBpcts = new SoftHashMap<>(RETENTION_SIZE); + private final Cache aggregatedBpcts = new Cache<>(RETENTION_SIZE); + private Sequence seq; + + public DemuxEventDispatcher(final RingBuffer ringBuffer) { + rb = ringBuffer; + aggregationHandler = new AggregationHandler[N_WORKERS]; + for (int i = 0; i < N_WORKERS; i++) { + aggregationHandler[i] = new AggregationHandler(); + freeWorkers.add(aggregationHandler[i]); + } + } + + public AggregationHandler[] getAggregationHander() { + return aggregationHandler; + } + + @Override + public void onEvent(final TestEventSource.IngestedEvent event, final long nextSequence, final boolean b) { + if (!(event.payload instanceof TestEventSource.Event)) { + return; + } + final long eventBpcts = ((TestEventSource.Event) event.payload).bpcts; + // final boolean alreadyScheduled = Arrays.stream(workers).filter(w -> w.bpcts == eventBpcts).findFirst().isPresent(); + final boolean alreadyScheduled = aggregatedBpcts.containsKey(eventBpcts); + if (alreadyScheduled) { + return; + } + while (true) { + if (!freeWorkers.isEmpty()) { + final AggregationHandler freeWorker = freeWorkers.remove(0); + freeWorker.bpcts = eventBpcts; + freeWorker.aggStart = event.ingestionTime; + aggregatedBpcts.put(eventBpcts, new Object()); // NOPMD - necessary to allocate inside loop + seq.set(nextSequence); // advance sequence to let workers process events up to here + return; + } + // no free worker available + long waitTime = Long.MAX_VALUE; + for (AggregationHandler w : aggregationHandler) { + final long currentTime = System.currentTimeMillis(); + final long diff = currentTime - w.aggStart; + waitTime = Math.min(waitTime, diff * 1_000_000); + if (w.bpcts != -1 && diff < TIMEOUT) { + w.publishAndFreeWorker(true); // timeout reached, publish partial result and free worker + break; + } + } + LockSupport.parkNanos(waitTime); + } + } + + @Override + public void setSequenceCallback(final Sequence sequence) { + this.seq = sequence; + } + + @SuppressWarnings("PMD.AvoidUsingVolatile") // necessary for desired CPU caching behaviour + public class AggregationHandler implements EventHandler, TimeoutHandler { + protected volatile long bpcts = -1; // [ms] + protected volatile long aggStart = -1; // [ns] + private List payloads = new ArrayList<>(); + + @Override + public void onEvent(final TestEventSource.IngestedEvent event, final long sequence, final boolean endOfBatch) { + if (bpcts != -1 && event.ingestionTime > aggStart + TIMEOUT) { + publishAndFreeWorker(true); + return; + } + if (bpcts == -1 || !(event.payload instanceof TestEventSource.Event) || ((TestEventSource.Event) event.payload).bpcts != bpcts) { + return; // skip irrelevant events + } + this.payloads.add(event); + if (payloads.size() == N_AGG_ELEMENTS) { + publishAndFreeWorker(false); + } + } + + protected void publishAndFreeWorker(final boolean partial) { + rb.publishEvent(((event1, sequence1, arg0) -> { + event1.ingestionTime = System.currentTimeMillis(); + event1.payload = partial ? ("aggregation timed out for bpcts: " + bpcts + " -> ") + payloads : payloads; + }), + payloads); + bpcts = -1; + payloads = new ArrayList<>(); + freeWorkers.add(this); + } + + @Override + public void onTimeout(final long sequence) { + if (bpcts != -1 && System.currentTimeMillis() > aggStart + TIMEOUT) { + publishAndFreeWorker(true); + } + } + } +} diff --git a/concepts/src/main/java/io/opencmw/concepts/aggregate/TestEventSource.java b/concepts/src/main/java/io/opencmw/concepts/aggregate/TestEventSource.java new file mode 100644 index 00000000..3aed18b5 --- /dev/null +++ b/concepts/src/main/java/io/opencmw/concepts/aggregate/TestEventSource.java @@ -0,0 +1,155 @@ +package io.opencmw.concepts.aggregate; + +import java.util.Arrays; +import java.util.Iterator; +import java.util.concurrent.locks.LockSupport; + +import com.lmax.disruptor.RingBuffer; + +/** + * An event Source to generate Events with different timing characteristics/orderings. + * + * @author Alexander Krimm + */ +public class TestEventSource implements Runnable { + private static final int DEFAULT_CHAIN = 3; + private static final long DEFAULT_DELTA = 20; + private static final long DEFAULT_PAUSE = 400; + // state for the event source + public final int repeat; + public final String[] eventList; + private final RingBuffer ringBuffer; + + /** + * Generate an event source which plays back the given sequence of events + * + * @param events A string containing a space separated list of events. first letter is type/bpcts, second is number + * Optionally you can add semicolon delimited key=value pairs to assign values in of the events + * @param repeat How often to repeat the given sequence (use zero value for infinite repetition) + * @param rb The ring buffer to publish the event into + */ + public TestEventSource(final String events, final int repeat, final RingBuffer rb) { + eventList = events.split(" "); + this.repeat = repeat; + this.ringBuffer = rb; + } + + @Override + public void run() { + long lastEvent = System.currentTimeMillis(); + long timeOffset = 0; + int repetitionCount = 0; + while (repeat == 0 || repeat > repetitionCount) { + final Iterator eventIterator = Arrays.stream(eventList).iterator(); + while (!Thread.interrupted() && eventIterator.hasNext()) { + final String eventToken = eventIterator.next(); + final String[] tokens = eventToken.split(";"); + if (tokens.length == 0 || tokens[0].isEmpty()) { + continue; + } + if ("pause".equals(tokens[0])) { + lastEvent += DEFAULT_PAUSE; + continue; + } + Event currentEvent = generateEventFromToken(tokens, timeOffset, lastEvent, repetitionCount); + lastEvent = currentEvent.publishTime; + long diff = currentEvent.publishTime - System.currentTimeMillis(); + if (diff > 0) { + LockSupport.parkNanos(1_000_000L * diff); + } + ringBuffer.publishEvent((event, sequence, arg0) -> { + event.ingestionTime = System.currentTimeMillis(); + event.payload = arg0; + }, currentEvent); + } + repetitionCount++; + } + } + + private Event generateEventFromToken(final String[] tokens, final long timeOffset, final long lastEvent, final int repetitionCount) { + String device = tokens[0].substring(0, 1); + long bpcts = Long.parseLong(tokens[0].substring(1)) + repetitionCount * 1000L; + int type = device.charAt(0); + String payload = device + bpcts; + long sourceTime = lastEvent + DEFAULT_DELTA; + long publishTime = sourceTime; + int chain = DEFAULT_CHAIN; + for (int i = 1; i < tokens.length; i++) { + String[] keyvalue = tokens[i].split("="); + if (keyvalue.length != 2) { + continue; + } + switch (keyvalue[0]) { + case "time": + sourceTime = Long.parseLong(keyvalue[1]) + timeOffset; + publishTime = sourceTime; + break; + case "sourceTime": + sourceTime = Long.parseLong(keyvalue[1]) + timeOffset; + break; + case "publishTime": + publishTime = Long.parseLong(keyvalue[1]) + timeOffset; + break; + case "bpcts": + bpcts = Long.parseLong(keyvalue[1]) + repetitionCount * 1000L; + break; + case "chain": + chain = Integer.parseInt(keyvalue[1]); + break; + case "type": + type = Integer.parseInt(keyvalue[1]); + break; + case "device": + device = keyvalue[1]; + break; + case "payload": + payload = keyvalue[1] + "(repetition count: " + repetitionCount + ")"; + break; + default: + throw new IllegalArgumentException("unable to process event keyvalue pair: " + Arrays.toString(keyvalue)); + } + } + return new Event(sourceTime, publishTime, bpcts, chain, type, device, payload); + } + + /** + * Mock event entry. + */ + public static class Event { + public final long sourceTime; + public final long publishTime; + public final long bpcts; + public final int chain; + public final int type; + public final String device; + public final Object payload; + + public Event(final long sourceTime, final long publishTime, final long bpcts, final int chain, final int type, final String device, final Object payload) { + this.sourceTime = sourceTime; + this.publishTime = publishTime; + this.bpcts = bpcts; + this.chain = chain; + this.type = type; + this.device = device; + this.payload = payload; + } + + @Override + public String toString() { + return "Event{sourceTime=" + sourceTime + ", publishTime=" + publishTime + ", bpcts=" + bpcts + ", chain=" + chain + ", type=" + type + ", device='" + device + '\'' + ", payload=" + payload + '}'; + } + } + + /** + * Basic ring buffer event + */ + public static class IngestedEvent { + public long ingestionTime; + public Object payload; + + @Override + public String toString() { + return "IngestedEvent{ingestionTime=" + ingestionTime + ", payload=" + payload + '}'; + } + } +} diff --git a/concepts/src/main/java/io/opencmw/concepts/majordomo/MajordomoBroker.java b/concepts/src/main/java/io/opencmw/concepts/majordomo/MajordomoBroker.java new file mode 100644 index 00000000..56324f37 --- /dev/null +++ b/concepts/src/main/java/io/opencmw/concepts/majordomo/MajordomoBroker.java @@ -0,0 +1,647 @@ +package io.opencmw.concepts.majordomo; + +import static org.zeromq.ZMQ.Socket; + +import static io.opencmw.concepts.majordomo.MajordomoProtocol.*; +import static io.opencmw.concepts.majordomo.MajordomoProtocol.MdpSubProtocol.*; +import static io.opencmw.concepts.majordomo.MajordomoProtocol.MdpWorkerCommand.*; + +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.zeromq.SocketType; +import org.zeromq.ZContext; +import org.zeromq.ZMQ; +import org.zeromq.ZMQException; + +import io.opencmw.rbac.BasicRbacRole; +import io.opencmw.rbac.RbacRole; +import io.opencmw.rbac.RbacToken; +import io.opencmw.utils.SystemProperties; + +/** + * Majordomo Protocol broker -- a minimal implementation of http://rfc.zeromq.org/spec:7 and spec:8 + * + * default heart-beat time-out [ms] is set by system property: 'OpenCMW.heartBeat' // default: 2500 [ms] + * default heart-beat liveness is set by system property: 'OpenCMW.heartBeatLiveness' // [counts] 3-5 is reasonable + * N.B. heartbeat expires when last heartbeat message is more than HEARTBEAT_INTERVAL * HEARTBEAT_LIVENESS ms ago. + * this implies also, that worker must either return their message within 'HEARTBEAT_INTERVAL * HEARTBEAT_LIVENESS ms' or decouple their secondary handler interface into another thread. + * + * default client time-out [s] is set by system property: 'OpenCMW.clientTimeOut' // default: 3600 [s] -- after which unanswered client messages and infos are being deleted + * + */ +@SuppressWarnings({ "PMD.DoNotUseThreads", "PMD.TooManyMethods", "PMD.GodClass", "PMD.UseConcurrentHashMap" }) // this is a concept, HashMap invoked in single-threaded context +public class MajordomoBroker extends Thread { + private static final Logger LOGGER = LoggerFactory.getLogger(MajordomoBroker.class); + private static final byte[] INTERNAL_SENDER_ID = null; + private static final String INTERNAL_SERVICE_PREFIX = "mmi."; + private static final byte[] INTERNAL_SERVICE_PREFIX_BYTES = INTERNAL_SERVICE_PREFIX.getBytes(StandardCharsets.UTF_8); + private static final long HEARTBEAT_LIVENESS = SystemProperties.getValueIgnoreCase("OpenCMW.heartBeatLiveness", 3); // [counts] 3-5 is reasonable + private static final long HEARTBEAT_INTERVAL = SystemProperties.getValueIgnoreCase("OpenCMW.heartBeat", 2500); // [ms] + private static final long HEARTBEAT_EXPIRY = HEARTBEAT_INTERVAL * HEARTBEAT_LIVENESS; + private static final int CLIENT_TIMEOUT = SystemProperties.getValueIgnoreCase("OpenCMW.clientTimeOut", 0); // [s] + private static final AtomicInteger BROKER_COUNTER = new AtomicInteger(); + private static final AtomicInteger WORKER_COUNTER = new AtomicInteger(); + + // --------------------------------------------------------------------- + private final ZContext ctx; + private final Socket internalRouterSocket; + private final Socket internalServiceSocket; + private final List routerSockets = new ArrayList<>(); // Sockets for clients & public external workers + private final AtomicBoolean run = new AtomicBoolean(false); // NOPMD + private final SortedSet> rbacRoles; + private final Map services = new HashMap<>(); // known services Map<'service name', Service> + private final Map workers = new HashMap<>(); // known workers Map + private final Map clients = new HashMap<>(); + + private final Deque waiting = new ArrayDeque<>(); // idle workers + private long heartbeatAt = System.currentTimeMillis() + HEARTBEAT_INTERVAL; // When to send HEARTBEAT + + /** + * Initialize broker state. + * @param ioThreads number of threads dedicated to network IO (recommendation 1 thread per 1 GBit/s) + * @param rbacRoles RBAC-based roles (used for IO prioritisation and service access control + */ + public MajordomoBroker(final int ioThreads, final RbacRole... rbacRoles) { + super(); + this.setName(MajordomoBroker.class.getSimpleName() + "#" + BROKER_COUNTER.getAndIncrement()); + + ctx = new ZContext(ioThreads); + + // initialise RBAC role-based priority queues + this.rbacRoles = Collections.unmodifiableSortedSet(new TreeSet<>(Set.of(rbacRoles))); + + // generate and register internal default inproc socket + this.internalRouterSocket = bind("inproc://broker"); // NOPMD + this.internalServiceSocket = bind("inproc://intService"); // NOPMD + //this.internalServiceSocket.setRouterMandatory(true); + + registerDefaultServices(rbacRoles); // NOPMD + } + + public void addInternalService(final MajordomoWorker worker, final int nServiceThreads) { + assert worker != null : "worker must not be null"; + final Service oldWorker = services.put(worker.getServiceName(), new Service(worker.getServiceName(), worker.getServiceName().getBytes(StandardCharsets.UTF_8), worker, nServiceThreads)); + if (oldWorker != null) { + LOGGER.atWarn().addArgument(worker.getServiceName()).log("overwriting existing internal service definition for '{}'"); + } + } + + /** + * Bind broker to endpoint, can call this multiple times. We use a single + * socket for both clients and workers. + */ + public Socket bind(String endpoint) { + final Socket routerSocket = ctx.createSocket(SocketType.ROUTER); + routerSocket.setHWM(0); + routerSocket.bind(endpoint); + routerSockets.add(routerSocket); + if (LOGGER.isDebugEnabled()) { + LOGGER.atDebug().addArgument(endpoint).log("Majordomo broker/0.1 is active at '{}'"); + } + return routerSocket; + } + + public ZContext getContext() { + return ctx; + } + + public Socket getInternalRouterSocket() { + return internalRouterSocket; + } + + /** + * @return unmodifiable list of registered external sockets + */ + public List getRouterSockets() { + return Collections.unmodifiableList(routerSockets); + } + + public Collection getServices() { + return services.values(); + } + + public boolean isRunning() { + return run.get(); + } + + public void removeService(final String serviceName) { + services.remove(serviceName); + } + + /** + * main broker work happens here + */ + @Override + public void run() { + try (ZMQ.Poller items = ctx.createPoller(routerSockets.size())) { + for (Socket routerSocket : routerSockets) { // NOPMD - closed below in finally + items.register(routerSocket, ZMQ.Poller.POLLIN); + } + while (run.get() && !Thread.currentThread().isInterrupted()) { + if (items.poll(HEARTBEAT_INTERVAL) == -1) { + break; // interrupted + } + + int loopCount = 0; + while (run.get()) { + boolean processData = false; + for (Socket routerSocket : routerSockets) { // NOPMD - closed below in finally + final MdpMessage msg = receiveMdpMessage(routerSocket, false); + if (msg != null) { + if (LOGGER.isDebugEnabled()) { + LOGGER.atDebug().addArgument(msg).log("Majordomo broker received new message: '{}'"); + } + processData |= handleReceivedMessage(routerSocket, msg); + } + } + + processClients(); + if (loopCount % 10 == 0) { + // perform maintenance tasks during the first and every tenth iteration + purgeWorkers(); + purgeClients(); + sendHeartbeats(); + } + loopCount++; + if (!processData) { + break; + } + } + } + + } finally { + routerSockets.forEach(Socket::close); + } + destroy(); // interrupted + } + + @Override + public synchronized void start() { // NOPMD - need to be synchronised on class level due to super definition + run.set(true); + services.forEach((serviceName, service) -> service.internalWorkers.forEach(Thread::start)); + super.start(); + } + + public void stopBroker() { + run.set(false); + } + + /** + * Deletes worker from all data structures, and destroys worker. + */ + protected void deleteWorker(Worker worker, boolean disconnect) { + assert (worker != null); + if (disconnect) { + sendWorkerMessage(worker.socket, W_DISCONNECT, worker.address, null); + } + if (worker.service != null) { + worker.service.waiting.remove(worker); + } + workers.remove(worker.addressHex); + } + + /** + * Disconnect all workers, destroy context. + */ + protected void destroy() { + Worker[] deleteList = workers.values().toArray(new Worker[0]); + for (Worker worker : deleteList) { + deleteWorker(worker, true); + } + ctx.destroy(); + } + + /** + * Dispatch requests to waiting workers as possible + */ + protected void dispatch(Service service) { + assert (service != null); + purgeWorkers(); + while (!service.waiting.isEmpty() && service.requestsPending()) { + final MdpMessage msg = service.getNextPrioritisedMessage(); + if (msg == null) { + // should be thrown only with VM '-ea' enabled -- assert noisily since this a (rare|design) library error + assert false : "getNextPrioritisedMessage should not be null"; + continue; + } + Worker worker = service.waiting.pop(); + waiting.remove(worker); + sendWorkerMessage(worker.socket, W_REQUEST, worker.address, msg.senderID, msg.payload); + } + } + + protected boolean handleReceivedMessage(final Socket receiveSocket, final MdpMessage msg) { + switch (msg.protocol) { + case C_CLIENT: + // Set reply return address to client sender + final Client client = clients.computeIfAbsent(msg.senderName, s -> new Client(receiveSocket, msg.protocol, msg.senderName, msg.senderID)); + client.offerToQueue((MdpClientMessage) msg); + return true; + case W_WORKER: + processWorker(receiveSocket, (MdpWorkerMessage) msg); + return true; + default: + // N.B. not too verbose logging since we do not want that sloppy clients can bring down the broker through warning or info messages + if (LOGGER.isDebugEnabled()) { + LOGGER.atDebug().addArgument(msg).log("Majordomo broker invalid message: '{}'"); + } + return false; + } + } + + /** + * Process a request coming from a client. + */ + protected void processClients() { + // round-robbin + clients.forEach((name, client) -> { + final MdpClientMessage clientMessage = client.pop(); + if (clientMessage == null) { + return; + } + // dispatch client message to worker queue + final Service service = services.get(clientMessage.serviceName); + if (service == null) { + // not implemented -- according to Majordomo Management Interface (MMI) as defined in http://rfc.zeromq.org/spec:8 + sendClientMessage(client.socket, MdpClientCommand.C_UNKNOWN, clientMessage.senderID, clientMessage.serviceNameBytes, "501".getBytes(StandardCharsets.UTF_8)); + return; + } + // queue new client message RBAC-priority-based + service.putPrioritisedMessage(clientMessage); + + // dispatch service + if (service.isInternal) { + final MdpClientMessage msg = service.getNextPrioritisedMessage(); + if (msg == null) { + // should be thrown only with VM '-ea' enabled -- assert noisily since this a (rare|design) library error + assert false : "getNextPrioritisedMessage should not be null"; + return; + } + sendWorkerMessage(service.internalDispatchSocket, W_REQUEST, null, msg.senderID, msg.payload); + } else { + //dispatch(requireService(clientMessage.serviceName, clientMessage.serviceNameBytes)); + dispatch(service); + } + }); + } + + /** + * Process message sent to us by a worker. + */ + protected void processWorker(final Socket receiveSocket, final MdpWorkerMessage msg) { + final boolean isInternal = internalServiceSocket.equals(receiveSocket); + final boolean workerReady = isInternal || workers.containsKey(msg.senderIdHex); + final Worker worker; // = requireWorker(receiveSocket, msg.senderID, msg.senderIdHex); + switch (msg.command) { + case W_READY: + worker = requireWorker(receiveSocket, msg.senderID, msg.senderIdHex); + // Not first mdpWorkerCommand in session || Reserved service name + if (workerReady || Arrays.equals(INTERNAL_SERVICE_PREFIX_BYTES, 0, 3, msg.senderID, 0, 3)) { + deleteWorker(worker, true); + } else { + // Attach worker to service and mark as idle + worker.service = requireService(msg.serviceName, msg.serviceNameBytes); + workerWaiting(worker); + } + break; + case W_REPLY: + if (workerReady) { + worker = isInternal ? null : requireWorker(receiveSocket, msg.senderID, msg.senderIdHex); + final byte[] serviceName = isInternal ? msg.senderID : worker.service.nameBytes; + final Client client = clients.get(msg.clientSourceName); + if (client == null || client.socket == null) { + break; + } + + if (client.protocol == C_CLIENT) { // OpenCMW + sendClientMessage(client.socket, MdpClientCommand.C_UNKNOWN, msg.clientSourceID, serviceName, msg.payload); + } else { + // TODO: add other branches for: + // * CmwLight + // * REST/JSON + // * REST/HTML + throw new IllegalStateException("Unexpected value: " + client.protocol); + } + if (!isInternal) { + workerWaiting(worker); + } + } else { + worker = requireWorker(receiveSocket, msg.senderID, msg.senderIdHex); + deleteWorker(worker, true); + } + break; + case W_HEARTBEAT: + worker = requireWorker(receiveSocket, msg.senderID, msg.senderIdHex); + if (workerReady) { + worker.expiry = System.currentTimeMillis() + HEARTBEAT_EXPIRY; + } else { + deleteWorker(worker, true); + } + break; + case W_DISCONNECT: + worker = requireWorker(receiveSocket, msg.senderID, msg.senderIdHex); + deleteWorker(worker, false); + break; + default: + // N.B. not too verbose logging since we do not want that sloppy clients can bring down the broker through warning or info messages + if (LOGGER.isDebugEnabled()) { + LOGGER.atDebug().addArgument(msg).log("Majordomo broker invalid message: '{}'"); + } + break; + } + } + + /** + * Look for & kill expired clients. + */ + protected void purgeClients() { + if (CLIENT_TIMEOUT <= 0) { + return; + } + for (String clientName : clients.keySet()) { // copy because we are going to remove keys + Client client = clients.get(clientName); + if (client == null || client.expiry < System.currentTimeMillis()) { + clients.remove(clientName); + if (LOGGER.isDebugEnabled()) { + LOGGER.atDebug().addArgument(client).log("Majordomo broker deleting expired client: '{}'"); + } + } + } + } + + /** + * Look for & kill expired workers. Workers are oldest to most recent, so we stop at the first alive worker. + */ + protected /*synchronized*/ void purgeWorkers() { + for (Worker w = waiting.peekFirst(); w != null && w.expiry < System.currentTimeMillis(); w = waiting.peekFirst()) { + if (LOGGER.isInfoEnabled()) { + LOGGER.atInfo().addArgument(w.addressHex).addArgument(w.service == null ? "(unknown)" : w.service.name).log("Majordomo broker deleting expired worker: '{}' - service: '{}'"); + } + deleteWorker(waiting.pollFirst(), false); + } + } + + protected void registerDefaultServices(final RbacRole[] rbacRoles) { + // add simple internal Majordomo worker + + // Majordomo Management Interface (MMI) as defined in http://rfc.zeromq.org/spec:8 + MajordomoWorker mmiService = new MajordomoWorker(ctx, "mmi.service", rbacRoles); + mmiService.registerHandler(payload -> { + final String serviceName = new String(payload[0], StandardCharsets.UTF_8); + final String returnCode = services.containsKey(serviceName) ? "200" : "400"; + return new byte[][] { returnCode.getBytes(StandardCharsets.UTF_8) }; + }); + addInternalService(mmiService, 1); + + // echo service + MajordomoWorker echoService = new MajordomoWorker(ctx, "mmi.echo", rbacRoles); + echoService.registerHandler(input -> input); // output = input : echo service is complex :-) + addInternalService(echoService, 1); + } + + /** + * Locates the service (creates if necessary). + * + * @param serviceName service name + * @param serviceNameBytes UTF-8 encoded service name + */ + protected Service requireService(final String serviceName, final byte[] serviceNameBytes) { + assert (serviceNameBytes != null); + return services.computeIfAbsent(serviceName, s -> new Service(serviceName, serviceNameBytes, null, 0)); + } + + /** + * Finds the worker (creates if necessary). + */ + protected Worker requireWorker(final Socket socket, final byte[] address, final String addressHex) { + assert (addressHex != null); + return workers.computeIfAbsent(addressHex, identity -> { + if (LOGGER.isInfoEnabled()) { + LOGGER.atInfo().addArgument(addressHex).log("Majordomo broker registering new worker: '{}'"); + } + return new Worker(socket, address, addressHex); + }); + } + + /** + * Send heartbeats to idle workers if it's time + */ + protected /*synchronized*/ void sendHeartbeats() { + // Send heartbeats to idle workers if it's time + if (System.currentTimeMillis() >= heartbeatAt) { + for (Worker worker : waiting) { + sendWorkerMessage(worker.socket, W_HEARTBEAT, worker.address, null); + } + heartbeatAt = System.currentTimeMillis() + HEARTBEAT_INTERVAL; + } + } + + /** + * This worker is now waiting for work. + */ + protected /*synchronized*/ void workerWaiting(Worker worker) { + // Queue to broker and service waiting lists + waiting.addLast(worker); + //TODO: evaluate addLast vs. push (addFirst) - latter should be more beneficial w.r.t. CPU context switches (reuses the same thready/context frequently + // do not know why original implementation wanted to spread across different workers (load balancing across different machines perhaps?!=) + //worker.service.waiting.addLast(worker); + worker.service.waiting.push(worker); + worker.expiry = System.currentTimeMillis() + HEARTBEAT_EXPIRY; + dispatch(worker.service); + } + + /** + * Main method - create and start new broker. + * + * @param args use '-v' for putting worker in verbose mode + */ + public static void main(String[] args) { + MajordomoBroker broker = new MajordomoBroker(1, BasicRbacRole.values()); + // broker.setDaemon(true); // use this if running in another app that controls threads + // Can be called multiple times with different endpoints + broker.bind("tcp://*:5555"); + broker.bind("tcp://*:5556"); + + for (int i = 0; i < 10; i++) { + // simple internalSock echo + MajordomoWorker workerSession = new MajordomoWorker(broker.getContext(), "inproc.echo", BasicRbacRole.ADMIN); // NOPMD - necessary to allocate inside loop + workerSession.registerHandler(input -> input); // output = input : echo service is complex :-) + workerSession.start(); + } + + broker.start(); + } + + /** + * This defines a client service. + */ + protected static class Client { + protected final Socket socket; // Socket worker is connected to + protected final MdpSubProtocol protocol; + protected final String name; // Service name + protected final byte[] nameBytes; // Service name as byte array + protected final String nameHex; // Service name as hex String + private final Deque requests = new ArrayDeque<>(); // List of client requests + protected long expiry = System.currentTimeMillis() + CLIENT_TIMEOUT * 1000L; // Expires at unless heartbeat + + public Client(final Socket socket, final MdpSubProtocol protocol, final String name, final byte[] nameBytes) { + this.socket = socket; + this.protocol = protocol; + this.name = name; + this.nameBytes = nameBytes == null ? name.getBytes(StandardCharsets.UTF_8) : nameBytes; + this.nameHex = strhex(nameBytes); + } + + public void offerToQueue(final MdpClientMessage msg) { + expiry = System.currentTimeMillis() + CLIENT_TIMEOUT * 1000L; + requests.offer(msg); + } + + public MdpClientMessage pop() { + return requests.isEmpty() ? null : requests.poll(); + } + } + + /** + * This defines a single service. + */ + protected class Service { + protected final String name; // Service name + protected final byte[] nameBytes; // Service name as byte array + protected final MajordomoWorker mdpWorker; + protected final boolean isInternal; + protected final Map, Queue> requests = new HashMap<>(); // NOPMD RBAC-based queuing - thread-safe use of HashMap + protected final Deque waiting = new ArrayDeque<>(); // List of waiting workers + protected final List internalWorkers = new ArrayList<>(); + protected final Socket internalDispatchSocket; + + public Service(final String name, final byte[] nameBytes, final MajordomoWorker mdpWorker, final int nInternalThreads) { + this.name = name; + this.nameBytes = nameBytes == null ? name.getBytes(StandardCharsets.UTF_8) : nameBytes; + this.mdpWorker = mdpWorker; + this.isInternal = mdpWorker != null; + if (isInternal) { + this.internalDispatchSocket = ctx.createSocket(SocketType.PUSH); + this.internalDispatchSocket.setHWM(0); + this.internalDispatchSocket.bind("inproc://" + mdpWorker.getServiceName() + "push"); + for (int i = 0; i < nInternalThreads; i++) { + internalWorkers.add(new InternalWorkerThread(this)); // NOPMD - necessary to allocate inside loop + } + } else { + this.internalDispatchSocket = null; + } + rbacRoles.forEach(role -> requests.put(role, new ArrayDeque<>())); + requests.put(BasicRbacRole.NULL, new ArrayDeque<>()); // add default queue + } + + public boolean requestsPending() { + return requests.entrySet().stream().anyMatch(map -> !map.getValue().isEmpty()); + } + + /** + * @return next RBAC prioritised message or 'null' if there aren't any + */ + protected final MdpClientMessage getNextPrioritisedMessage() { + for (RbacRole role : rbacRoles) { + final Queue queue = requests.get(role); // matched non-empty queue + if (!queue.isEmpty()) { + return queue.poll(); + } + } + final Queue queue = requests.get(BasicRbacRole.NULL); // default queue + return queue.isEmpty() ? null : queue.poll(); + } + + protected void putPrioritisedMessage(final MdpClientMessage queuedMessage) { + if (queuedMessage.hasRbackToken()) { + // find proper RBAC queue + final RbacToken rbacToken = RbacToken.from(queuedMessage.getRbacFrame()); + final Queue roleBasedQueue = requests.get(rbacToken.getRole()); + if (roleBasedQueue != null) { + roleBasedQueue.offer(queuedMessage); + } + } else { + requests.get(BasicRbacRole.NULL).offer(queuedMessage); + } + } + } + + /** + * This defines one worker, idle or active. + */ + protected class Worker { + protected final Socket socket; // Socket worker is connected to + protected final byte[] address; // Address ID frame to route to + protected final String addressHex; // Address ID frame of worker expressed as hex-String + + protected final boolean external; + protected Service service; // Owning service, if known + protected long expiry = System.currentTimeMillis() + HEARTBEAT_INTERVAL * HEARTBEAT_LIVENESS; // Expires at unless heartbeat + + @SuppressWarnings("PMD.ArrayIsStoredDirectly") + public Worker(final Socket socket, final byte[] address, final String addressHex) { + this.socket = socket; + this.external = true; + this.address = address; + this.addressHex = addressHex; + } + } + + protected class InternalWorkerThread extends Thread { + private final Service service; + + public InternalWorkerThread(final Service service) { + super(); + assert service != null && service.name != null && !service.name.isBlank(); + final String serviceName = service.name + "#" + WORKER_COUNTER.getAndIncrement(); + this.setName(MajordomoBroker.class.getSimpleName() + "-" + InternalWorkerThread.class.getSimpleName() + ":" + serviceName); + this.setDaemon(true); + this.service = service; + } + + @Override + public void run() { + try (Socket sendSocket = ctx.createSocket(SocketType.DEALER); + Socket receiveSocket = ctx.createSocket(SocketType.PULL); + ZMQ.Poller items = ctx.createPoller(1)) { + // register worker with broker + sendSocket.setSndHWM(0); + sendSocket.setIdentity(service.name.getBytes(StandardCharsets.UTF_8)); + sendSocket.connect("inproc://intService"); + + receiveSocket.connect("inproc://" + service.mdpWorker.getServiceName() + "push"); + receiveSocket.setRcvHWM(0); + + // register poller + items.register(receiveSocket, ZMQ.Poller.POLLIN); + while (run.get() && !this.isInterrupted()) { + if (items.poll(HEARTBEAT_INTERVAL) == -1) { + break; // interrupted + } + + while (run.get()) { + final MdpMessage mdpMessage = receiveMdpMessage(receiveSocket, false); + if (mdpMessage == null || mdpMessage.isClient) { + break; + } + MdpWorkerMessage msg = (MdpWorkerMessage) mdpMessage; + + // execute the user-provided call-back function + final MdpMessage reply = service.mdpWorker.processRequest(msg, msg.clientSourceID); + + if (reply != null) { + sendWorkerMessage(sendSocket, W_REPLY, INTERNAL_SENDER_ID, msg.clientSourceID, reply.payload); + } + } + } + } catch (ZMQException e) { + // process should abort + } + } + } +} diff --git a/concepts/src/main/java/io/opencmw/concepts/majordomo/MajordomoClientV1.java b/concepts/src/main/java/io/opencmw/concepts/majordomo/MajordomoClientV1.java new file mode 100644 index 00000000..30ecfe1a --- /dev/null +++ b/concepts/src/main/java/io/opencmw/concepts/majordomo/MajordomoClientV1.java @@ -0,0 +1,163 @@ +package io.opencmw.concepts.majordomo; + +import java.lang.management.ManagementFactory; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Formatter; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.zeromq.SocketType; +import org.zeromq.ZContext; +import org.zeromq.ZFrame; +import org.zeromq.ZMQ; +import org.zeromq.ZMsg; +import org.zeromq.util.ZData; + +/** +* Majordomo Protocol Client API, Java version Implements the OpenCmwProtocol/Worker spec at +* http://rfc.zeromq.org/spec:7. +* +*/ +public class MajordomoClientV1 { + private static final Logger LOGGER = LoggerFactory.getLogger(MajordomoClientV1.class); + private static final AtomicInteger CLIENT_V1_INSTANCE = new AtomicInteger(); + private final String uniqueID; + private final byte[] uniqueIdBytes; + private final String broker; + private final ZContext ctx; + private ZMQ.Socket clientSocket; + private long timeout = 2500; + private int retries = 3; + private final Formatter log = new Formatter(System.out); + private ZMQ.Poller poller; + + public MajordomoClientV1(String broker, String clientName) { + this.broker = broker; + ctx = new ZContext(); + + uniqueID = clientName + "PID=" + ManagementFactory.getRuntimeMXBean().getName() + "-InstanceID=" + CLIENT_V1_INSTANCE.getAndIncrement(); + uniqueIdBytes = uniqueID.getBytes(ZMQ.CHARSET); + + reconnectToBroker(); + } + + /** + * Connect or reconnect to broker + */ + private void reconnectToBroker() { + if (clientSocket != null) { + clientSocket.close(); + } + clientSocket = ctx.createSocket(SocketType.DEALER); + clientSocket.setHWM(0); + clientSocket.setIdentity(uniqueIdBytes); + clientSocket.connect(broker); + + if (poller != null) { + poller.unregister(clientSocket); + poller.close(); + } + poller = ctx.createPoller(1); + poller.register(clientSocket, ZMQ.Poller.POLLIN); + LOGGER.atDebug().addArgument(broker).log("connecting to broker at: '{}'"); + } + + public void destroy() { + ctx.destroy(); + } + + public int getRetries() { + return retries; + } + + public void setRetries(int retries) { + this.retries = retries; + } + + public long getTimeout() { + return timeout; + } + + public void setTimeout(long timeout) { + this.timeout = timeout; + } + + public String getUniqueID() { + return uniqueID; + } + + /** + * Send request to broker and get reply by hook or crook takes ownership of + * request message and destroys it when sent. Returns the reply message or + * NULL if there was no reply. + * + * @param service service name + * @param msgs message(s) to be sent to OpenCmwProtocol broker (if more than one, than the last is assumed to be a RBAC-token + * @return reply message or NULL if there was no reply + */ + public ZMsg send(final String service, final byte[]... msgs) { + return send(service.getBytes(StandardCharsets.UTF_8), msgs); + } + + /** + * Send request to broker and get reply by hook or crook takes ownership of + * request message and destroys it when sent. Returns the reply message or + * NULL if there was no reply. + * + * @param service UTF-8 encoded service name byte array + * @param msgs message(s) to be sent to OpenCmwProtocol broker (if more than one, than the last is assumed to be a RBAC-token + * @return reply message or NULL if there was no reply + */ + public ZMsg send(final byte[] service, final byte[]... msgs) { + ZMsg reply = null; + + int retriesLeft = retries; + while (retriesLeft > 0 && !Thread.currentThread().isInterrupted()) { + if (!MajordomoProtocol.sendClientMessage(clientSocket, MajordomoProtocol.MdpClientCommand.C_UNKNOWN, null, service, msgs)) { + throw new IllegalStateException("could not send request " + Arrays.stream(msgs).map(ZData::toString).collect(Collectors.joining(",", "[", "]"))); + } + + // Poll socket for a reply, with timeout + if (poller.poll(timeout) == -1) { + break; // Interrupted + } + + if (poller.pollin(0)) { + ZMsg msg = ZMsg.recvMsg(clientSocket, false); + LOGGER.atDebug().addArgument(msg).log("received reply: '{}'"); + + if (msg == null) { + break; + } + // Don't try to handle errors, just assert noisily + assert (msg.size() >= 3); + + ZFrame emptyFrame = msg.pop(); + assert emptyFrame.size() == 0; + + ZFrame header = msg.pop(); + assert (MajordomoProtocol.MdpSubProtocol.C_CLIENT.isEquals(header.getData())); + header.destroy(); + + ZFrame replyService = msg.pop(); + //noinspection AssertWithSideEffects + assert Arrays.equals(service, replyService.getData()); // NOSONAR NOPMD only read, not mutation of service + replyService.destroy(); + + reply = msg; + break; + } else { + if (--retriesLeft == 0) { + log.format("W: permanent error, abandoning\n"); + break; + } + log.format("W: no reply, reconnecting\n"); + reconnectToBroker(); + } + } + return reply; + } +} diff --git a/concepts/src/main/java/io/opencmw/concepts/majordomo/MajordomoClientV2.java b/concepts/src/main/java/io/opencmw/concepts/majordomo/MajordomoClientV2.java new file mode 100644 index 00000000..a5c18bf1 --- /dev/null +++ b/concepts/src/main/java/io/opencmw/concepts/majordomo/MajordomoClientV2.java @@ -0,0 +1,118 @@ +package io.opencmw.concepts.majordomo; + +import static io.opencmw.concepts.majordomo.MajordomoProtocol.*; + +import java.nio.charset.StandardCharsets; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.zeromq.SocketType; +import org.zeromq.ZContext; +import org.zeromq.ZFrame; +import org.zeromq.ZMQ; +import org.zeromq.ZMsg; + +/** + * Majordomo Protocol Client API, asynchronous Java version. Implements the + * OpenCmwProtocol/Worker spec at http://rfc.zeromq.org/spec:7. + */ +public class MajordomoClientV2 { + private static final Logger LOGGER = LoggerFactory.getLogger(MajordomoClientV2.class); + private final String broker; + private final ZContext ctx; + private ZMQ.Socket clientSocket; + private long timeout = 2500; + private ZMQ.Poller poller; + + public MajordomoClientV2(final String broker) { + this.broker = broker; + ctx = new ZContext(); + reconnectToBroker(); + } + + public void destroy() { + ctx.destroy(); + } + + public long getTimeout() { + return timeout; + } + + /** + * Returns the reply message or NULL if there was no reply. Does not attempt + * to recover from a broker failure, this is not possible without storing + * all unanswered requests and resending them all… + */ + public ZMsg recv() { + // Poll socket for a reply, with timeout + if (poller.poll(timeout * 1000) == -1) { + return null; // Interrupted + } + + if (poller.pollin(0)) { + ZMsg msg = ZMsg.recvMsg(clientSocket); + LOGGER.atDebug().addArgument(msg).log("received reply: '{}'"); + + // Don't try to handle errors, just assert noisily + assert (msg.size() >= 4); + + ZFrame empty = msg.pop(); + assert (empty.getData().length == 0); + empty.destroy(); + + ZFrame header = msg.pop(); + assert (MdpSubProtocol.C_CLIENT.isEquals(header.getData())); + header.destroy(); + + ZFrame replyService = msg.pop(); + replyService.destroy(); + + return msg; + } + return null; + } + + /** + * Send request to broker and get reply by hook or crook Takes ownership of request message and destroys it when sent. + * + * @param service UTF-8 encoded service name byte array + * @param msgs message(s) to be sent to OpenCmwProtocol broker (if more than one, than the last is assumed to be a RBAC-token + */ + public boolean send(final byte[] service, final byte[]... msgs) { + return sendClientMessage(clientSocket, MdpClientCommand.C_UNKNOWN, null, service, msgs); + } + + /** + * Send request to broker and get reply by hook or crook Takes ownership of request message and destroys it when sent. + * + * @param service UTF-8 encoded service name byte array + * @param msgs message(s) to be sent to OpenCmwProtocol broker (if more than one, than the last is assumed to be a RBAC-token + */ + public boolean send(final String service, final byte[]... msgs) { + return send(service.getBytes(StandardCharsets.UTF_8), msgs); + } + + public void setTimeout(long timeout) { + this.timeout = timeout; + } + + /** + * Connect or reconnect to broker + */ + private void reconnectToBroker() { + if (clientSocket != null) { + clientSocket.close(); + } + clientSocket = ctx.createSocket(SocketType.DEALER); + clientSocket.setHWM(0); + clientSocket.setIdentity("clientV2".getBytes(StandardCharsets.UTF_8)); + clientSocket.connect(broker); + if (poller != null) { + poller.unregister(clientSocket); + poller.close(); + } + poller = ctx.createPoller(1); + poller.register(clientSocket, ZMQ.Poller.POLLIN); + LOGGER.atDebug().addArgument(broker).log("connecting to broker at: '{}'"); + } +} diff --git a/concepts/src/main/java/io/opencmw/concepts/majordomo/MajordomoProtocol.java b/concepts/src/main/java/io/opencmw/concepts/majordomo/MajordomoProtocol.java new file mode 100644 index 00000000..3a4933f8 --- /dev/null +++ b/concepts/src/main/java/io/opencmw/concepts/majordomo/MajordomoProtocol.java @@ -0,0 +1,423 @@ +package io.opencmw.concepts.majordomo; + +import static org.zeromq.ZMQ.Socket; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +import org.zeromq.SocketType; +import org.zeromq.ZFrame; +import org.zeromq.ZMQ; +import org.zeromq.ZMsg; +import org.zeromq.util.ZData; + +import zmq.SocketBase; + +/** + * Majordomo Protocol (MDP) definitions and implementations according to https://rfc.zeromq.org/spec/7/ + * + * @author rstein + */ +@SuppressWarnings({ "PMD.ArrayIsStoredDirectly", "PMD.MethodReturnsInternalArray" }) +public class MajordomoProtocol { // NOPMD nomen-est-omen + public static final byte[] EMPTY_FRAME = {}; + private static final String HEX_CHAR = "0123456789ABCDEF"; + + public static MdpMessage receiveMdpMessage(final Socket socket) { + return receiveMdpMessage(socket, true); + } + + public static MdpMessage receiveMdpMessage(final Socket socket, final boolean wait) { //NOPMD + assert socket != null : "socket must not be null"; + final int flags = wait ? 0 : ZMQ.DONTWAIT; + + final ZMsg msg = ZMsg.recvMsg(socket, flags); + if (msg == null) { + return null; + } + assert msg.size() >= 3; + + final byte[] senderId; + + if (socket.getSocketType() == SocketType.ROUTER) { + senderId = msg.pop().getData(); + assert senderId != null : "first sender frame is empty"; + } else { + senderId = null; + } + + final ZFrame emptyFrame = msg.pop(); + assert emptyFrame.hasData() && emptyFrame.getData().length == 0 : "nominally empty message has data: " + emptyFrame.getData().length + " - '" + emptyFrame.toString() + "'"; + + final ZFrame protocolFrame = msg.pop(); + assert protocolFrame.hasData(); + final MdpSubProtocol protocol = MdpSubProtocol.getProtocol(protocolFrame); + + switch (protocol) { + case C_CLIENT: + assert msg.size() >= 2; + final MdpClientCommand clientCommand = MdpClientCommand.getCommand(MdpClientCommand.C_UNKNOWN.newFrame()); + final ZFrame serviceName = msg.pop(); + assert serviceName.getData() != null : "empty serviceName"; + + final byte[][] clientMessages = new byte[msg.size()][]; + for (int i = 0; i < clientMessages.length; i++) { + final ZFrame dataFrame = msg.pop(); + clientMessages[i] = dataFrame.hasData() ? dataFrame.getData() : EMPTY_FRAME; + dataFrame.destroy(); + } + return new MdpClientMessage(senderId, clientCommand, serviceName.getData(), clientMessages); + case W_WORKER: + final ZFrame commandFrame = msg.pop(); + assert protocolFrame.hasData(); + final MdpWorkerCommand workerCommand = MdpWorkerCommand.getCommand(commandFrame); + switch (workerCommand) { + case W_HEARTBEAT: + case W_DISCONNECT: + assert msg.isEmpty() + : "MDP V0.1 does not support further frames for W_HEARTBEAT or W_DISCONNECT"; + return new MdpWorkerMessage(senderId, workerCommand, null, null); + case W_READY: + // service-name is optional + return new MdpWorkerMessage(senderId, workerCommand, msg.isEmpty() ? null : msg.pop().getData(), null); + case W_REQUEST: + case W_REPLY: + final byte[] clientSourceId = msg.pop().getData(); + assert clientSourceId != null : "clientSourceID must not be null"; + final ZFrame emptyFrame2 = msg.pop(); + assert emptyFrame2.hasData() && emptyFrame2.getData().length == 0 : "nominally empty message has data: " + emptyFrame2.getData().length + " - '" + emptyFrame2.toString() + "'"; + + final byte[][] workerMessages = new byte[msg.size()][]; + for (int i = 0; i < workerMessages.length; i++) { + final ZFrame dataFrame = msg.pop(); + workerMessages[i] = dataFrame.hasData() ? dataFrame.getData() : EMPTY_FRAME; + dataFrame.destroy(); + } + return new MdpWorkerMessage(senderId, workerCommand, null, clientSourceId, workerMessages); + + case W_UNKNOWN: + default: + assert false : "should not reach here for production code"; + return null; + } + case UNKNOWN: + default: + assert false : "should not reach here for production code"; + return null; + } + } + + /** + * Send worker message according to the MDP 'client' sub-protocol + * + * @param socket ZeroMQ socket to send the message on + * @param mdpClientCommand the OpenCmwProtocol mdpWorkerCommand + * @param sourceID the unique source ID of the peer client (usually 5 bytes, can be overwritten, BROKER sockets need this to be non-null) + * @param serviceName the unique, original source ID the broker shall forward this message to + * @param msg message(s) to be sent to OpenCmwProtocol broker (if more than one, than the last is assumed to be a RBAC-token + * + * @return {@code true} if successful + */ + public static boolean sendClientMessage(final Socket socket, final MdpClientCommand mdpClientCommand, final byte[] sourceID, final byte[] serviceName, final byte[]... msg) { + assert socket != null : "socket must not be null"; + assert mdpClientCommand != null : "mdpClientCommand must not be null"; + assert serviceName != null : "serviceName must not be null"; + + final SocketBase socketBase = socket.base(); + boolean status = true; + if (socket.getSocketType() == SocketType.ROUTER) { + assert sourceID != null : "sourceID must be non-null when using ROUTER sockets"; + status = socketBase.send(new zmq.Msg(sourceID), ZMQ.SNDMORE); // frame 0: source ID (optional, only needed for broker sockets) + } + status &= socketBase.send(new zmq.Msg(EMPTY_FRAME), ZMQ.SNDMORE); // frame 1: empty frame (0 bytes) + status &= socketBase.send(new zmq.Msg(MdpSubProtocol.C_CLIENT.getFrameData()), ZMQ.SNDMORE); // frame 2: 'MDPCxx' client sub-protocol version + status &= socketBase.send(new zmq.Msg(serviceName), ZMQ.SNDMORE); // frame 3: service name (UTF-8 string) + // frame 3++: msg frames (last one being usually the RBAC token) + for (int i = 0; i < msg.length; i++) { + status &= socketBase.send(new zmq.Msg(msg[i]), i < msg.length - 1 ? ZMQ.SNDMORE : 0); // NOPMD - necessary to allocate inside loop + } + + return status; + } + + /** + * Send worker message according to the MDP 'worker' sub-protocol + * + * @param socket ZeroMQ socket to send the message on + * @param mdpWorkerCommand the OpenCmwProtocol mdpWorkerCommand + * @param sourceID the unique source ID of the peer client (usually 5 bytes, can be overwritten, BROKER sockets need this to be non-null) + * @param clientID the unique, original source ID the broker shall forward this message to + * @param msg message(s) to be sent to OpenCmwProtocol broker (if more than one, than the last is assumed to be a RBAC-token + * + * @return {@code true} if successful + */ + public static boolean sendWorkerMessage(final Socket socket, MdpWorkerCommand mdpWorkerCommand, final byte[] sourceID, final byte[] clientID, final byte[]... msg) { + assert socket != null : "socket must not be null"; + assert mdpWorkerCommand != null : "mdpWorkerCommand must not be null"; + + final SocketBase socketBase = socket.base(); + boolean status = true; + if (socket.getSocketType() == SocketType.ROUTER) { + assert sourceID != null : "sourceID must be non-null when using ROUTER sockets"; + status = socketBase.send(new zmq.Msg(sourceID), ZMQ.SNDMORE); // frame 0: source ID (optional, only needed for broker sockets) + } + socketBase.send(new zmq.Msg(EMPTY_FRAME), ZMQ.SNDMORE); // frame 1: empty frame (0 bytes) + socketBase.send(new zmq.Msg(MdpSubProtocol.W_WORKER.getFrameData()), ZMQ.SNDMORE); // frame 2: 'MDPWxx' worker sub-protocol version + + switch (mdpWorkerCommand) { + case W_HEARTBEAT: + case W_DISCONNECT: + assert msg.length == 0 : "byte[]... msg must be empty for W_HEARTBEAT and W_DISCONNECT commands"; + status &= socketBase.send(new zmq.Msg(mdpWorkerCommand.getFrameData()), 0); // frame 3: mdpWorkerCommand (1-byte: W_HEARTBEAT, W_DISCONNECT) + return status; + case W_REQUEST: + case W_REPLY: + case W_READY: + socketBase.send(new zmq.Msg(mdpWorkerCommand.getFrameData()), ZMQ.SNDMORE); // frame 3: mdpWorkerCommand (1-byte: W_READY, W_REQUEST, W_REPLY) + assert clientID != null; + if (msg.length == 0 && mdpWorkerCommand == MdpWorkerCommand.W_READY) { + status &= socketBase.send(new zmq.Msg(clientID), 0); // frame 4: client ID (i.e. sourceID of the client that is known to the broker + } else { + assert msg.length != 0 : "byte[]... msg must not be empty"; + status &= socketBase.send(new zmq.Msg(clientID), ZMQ.SNDMORE); // frame 4: client ID (i.e. sourceID of the client that is known to the broker + + // optional additional payload after ready (e.g. service uniqueID, input/output property layout etc.) + status &= socketBase.send(new zmq.Msg(EMPTY_FRAME), ZMQ.SNDMORE); // frame 5: empty frame (0 bytes) + for (int i = 0; i < msg.length; i++) { + status &= socketBase.send(new zmq.Msg(msg[i]), i < msg.length - 1 ? ZMQ.SNDMORE : 0); // NOPMD - necessary to allocate inside loop + } + } + return status; + case W_UNKNOWN: + default: + throw new IllegalArgumentException("should not reach here/unknown command: " + mdpWorkerCommand); + } + } + + public static String strhex(byte[] data) { + if (data == null) { + return ""; + } + StringBuilder b = new StringBuilder(); + for (byte aData : data) { + int b1 = aData >>> 4 & 0xf; + int b2 = aData & 0xf; + b.append(HEX_CHAR.charAt(b1)); + b.append(HEX_CHAR.charAt(b2)); + } + return b.toString(); + } + + /** + * MDP sub-protocol V0.1 + */ + public enum MdpSubProtocol { + C_CLIENT("MDPC01"), // OpenCmwProtocol/Client implementation version + W_WORKER("MDPW01"), // OpenCmwProtocol/Worker implementation version + UNKNOWN("XXXXXX"); + + private final byte[] data; + MdpSubProtocol(final String value) { + this.data = value.getBytes(StandardCharsets.UTF_8); + } + + public boolean frameEquals(ZFrame frame) { + return Arrays.equals(data, frame.getData()); + } + + public byte[] getFrameData() { + return data; + } + + public boolean isEquals(final byte[] other) { + return Arrays.equals(this.data, other); + } + + public ZFrame newFrame() { + return new ZFrame(data); + } + + public static MdpSubProtocol getProtocol(ZFrame frame) { + for (MdpSubProtocol knownProtocol : MdpSubProtocol.values()) { + if (knownProtocol.frameEquals(frame)) { + if (knownProtocol == UNKNOWN) { + continue; + } + return knownProtocol; + } + } + return UNKNOWN; + } + } + + /** + * OpenCmwProtocol/Server commands, as byte values + */ + public enum MdpWorkerCommand { + W_READY(0x01), + W_REQUEST(0x02), + W_REPLY(0x03), + W_HEARTBEAT(0x04), + W_DISCONNECT(0x05), + W_UNKNOWN(-1); + + private final byte[] data; + MdpWorkerCommand(final int value) { //watch for ints>255, will be truncated + this.data = new byte[] { (byte) (value & 0xFF) }; + } + + public boolean frameEquals(ZFrame frame) { + return Arrays.equals(data, frame.getData()); + } + + public byte[] getFrameData() { + return data; + } + + public ZFrame newFrame() { + return new ZFrame(data); + } + + public static MdpWorkerCommand getCommand(ZFrame frame) { + for (MdpWorkerCommand knownMdpCommand : MdpWorkerCommand.values()) { + if (knownMdpCommand.frameEquals(frame)) { + if (knownMdpCommand == W_UNKNOWN) { + continue; + } + return knownMdpCommand; + } + } + return W_UNKNOWN; + } + } + + /** + * OpenCmwProtocol/Client commands, as byte values + */ + public enum MdpClientCommand { + C_UNKNOWN(-1); // N.B. Majordomo V0.1 does not provide dedicated client commands + + private final byte[] data; + MdpClientCommand(final int value) { //watch for ints>255, will be truncated + this.data = new byte[] { (byte) (value & 0xFF) }; + } + + public boolean frameEquals(ZFrame frame) { + return Arrays.equals(data, frame.getData()); + } + + public byte[] getFrameData() { + return data; + } + + public ZFrame newFrame() { + return new ZFrame(data); + } + + public static MdpClientCommand getCommand(ZFrame frame) { + for (MdpClientCommand knownMdpCommand : MdpClientCommand.values()) { + if (knownMdpCommand.frameEquals(frame)) { + if (knownMdpCommand == C_UNKNOWN) { + continue; + } + return knownMdpCommand; + } + } + return C_UNKNOWN; + } + } + + public static class MdpMessage { + public final MdpSubProtocol protocol; + public final boolean isClient; + public final byte[] senderID; + public final String senderIdHex; + public final String senderName; + public final byte[][] payload; + + public MdpMessage(final byte[] senderID, final MdpSubProtocol protocol, final byte[]... payload) { + this.isClient = protocol == MdpSubProtocol.C_CLIENT; + this.senderID = senderID; + this.senderIdHex = strhex(senderID); + this.senderName = senderID == null ? null : new String(senderID, StandardCharsets.UTF_8); + this.protocol = protocol; + this.payload = payload; + } + + public byte[] getRbacFrame() { + if (hasRbackToken()) { + final byte[] rbacFrame = payload[payload.length - 1]; + return Arrays.copyOf(rbacFrame, rbacFrame.length); + } + return new byte[0]; + } + + public boolean hasRbackToken() { + return payload.length >= 2; + } + + @Override + public String toString() { + return "MdpMessage{isClient=" + isClient + ", senderID=" + ZData.toString(senderID) + ", payload=" + toString(payload) + '}'; + } + + protected static String toString(byte[][] byteValue) { + if (byteValue == null) { + return "(null)"; + } + if (byteValue.length == 0) { + return "[]"; + } + if (byteValue.length == 1) { + return "[" + ZData.toString(byteValue[0]) + "]"; + } + StringBuilder b = new StringBuilder(); + b.append('[').append(ZData.toString(byteValue[0])); + + for (int i = 1; i < byteValue.length; i++) { + b.append(", ").append(ZData.toString(byteValue[i])); + } + + b.append(']'); + return b.toString(); + } + } + + public static class MdpClientMessage extends MdpMessage { + public final MdpClientCommand command; + public final byte[] serviceNameBytes; // UTF-8 encoded service name + public final String serviceName; + public MdpClientMessage(final byte[] senderID, final MdpClientCommand clientCommand, final byte[] serviceNameBytes, final byte[]... clientMessages) { + super(senderID, MdpSubProtocol.C_CLIENT, clientMessages); + this.command = clientCommand; + this.serviceNameBytes = serviceNameBytes; + this.serviceName = new String(serviceNameBytes, StandardCharsets.UTF_8); + } + + @Override + public String toString() { + return "MdpClientMessage{senderID=" + ZData.toString(senderID) + ", serviceName='" + serviceName + "', payload=" + toString(payload) + '}'; + } + } + + public static class MdpWorkerMessage extends MdpMessage { + public final MdpWorkerCommand command; + public final byte[] serviceNameBytes; // UTF-8 encoded service name (optional - only for W_READY) + public final String serviceName; + public final byte[] clientSourceID; + public final String clientSourceName; + public MdpWorkerMessage(final byte[] senderID, final MdpWorkerCommand workerCommand, final byte[] serviceName, final byte[] clientSourceID, final byte[]... workerMessages) { + super(senderID, MdpSubProtocol.W_WORKER, workerMessages); + this.command = workerCommand; + this.serviceNameBytes = serviceName; + this.serviceName = serviceName == null ? null : new String(serviceName, StandardCharsets.UTF_8); + this.clientSourceID = clientSourceID; + this.clientSourceName = clientSourceID == null ? null : new String(clientSourceID, StandardCharsets.UTF_8); + } + + @Override + public String toString() { + return "MdpWorkerMessage{senderID=" + ZData.toString(senderID) + ", command=" + command + ", serviceName='" + serviceName + "', clientSourceID='" + ZData.toString(clientSourceID) + "', payload=" + toString(payload) + '}'; + } + } +} diff --git a/concepts/src/main/java/io/opencmw/concepts/majordomo/MajordomoWorker.java b/concepts/src/main/java/io/opencmw/concepts/majordomo/MajordomoWorker.java new file mode 100644 index 00000000..70e6f7b5 --- /dev/null +++ b/concepts/src/main/java/io/opencmw/concepts/majordomo/MajordomoWorker.java @@ -0,0 +1,299 @@ +package io.opencmw.concepts.majordomo; + +import static io.opencmw.concepts.majordomo.MajordomoProtocol.*; +import static io.opencmw.concepts.majordomo.MajordomoProtocol.MdpWorkerCommand.*; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.lang.management.ManagementFactory; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Objects; +import java.util.Set; +import java.util.SortedSet; +import java.util.TreeSet; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.zeromq.SocketType; +import org.zeromq.ZContext; +import org.zeromq.ZMQ; + +import io.opencmw.rbac.RbacRole; +import io.opencmw.utils.SystemProperties; + +/** + * Majordomo Protocol Client API, Java version Implements the OpenCmwProtocol/Worker spec at + * http://rfc.zeromq.org/spec:7. + * + * default heart-beat time-out [ms] is set by system property: 'OpenCMW.heartBeat' // default: 2500 [ms] + * default heart-beat liveness is set by system property: 'OpenCMW.heartBeatLiveness' // [counts] 3-5 is reasonable + * N.B. heartbeat expires when last heartbeat message is more than HEARTBEAT_INTERVAL * HEARTBEAT_LIVENESS ms ago. + * this implies also, that worker must either return their message within 'HEARTBEAT_INTERVAL * HEARTBEAT_LIVENESS ms' or decouple their secondary handler interface into another thread. + * + */ +@SuppressWarnings("PMD.DoNotUseThreads") +public class MajordomoWorker extends Thread { + private static final Logger LOGGER = LoggerFactory.getLogger(MajordomoWorker.class); + private static final int HEARTBEAT_LIVENESS = SystemProperties.getValueIgnoreCase("OpenCMW.heartBeatLiveness", 3); // [counts] 3-5 is reasonable + private static final int HEARTBEAT_INTERVAL = SystemProperties.getValueIgnoreCase("OpenCMW.heartBeat", 2500); // [ms] + private static final AtomicInteger WORKER_COUNTER = new AtomicInteger(); + + // --------------------------------------------------------------------- + protected final String uniqueID; + protected final ZContext ctx; + private final String brokerAddress; + private final String serviceName; + private final byte[] serviceBytes; + + private final AtomicBoolean run = new AtomicBoolean(true); // NOPMD + private final SortedSet> rbacRoles; + private ZMQ.Socket workerSocket; // Socket to broker + private long heartbeatAt; // When to send HEARTBEAT + private int liveness; // How many attempts left + private int reconnect = 2500; // Reconnect delay, msecs + private RequestHandler requestHandler; + private ZMQ.Poller poller; + + public MajordomoWorker(String brokerAddress, String serviceName, final RbacRole... rbacRoles) { + this(null, brokerAddress, serviceName, rbacRoles); + } + + public MajordomoWorker(ZContext ctx, String serviceName, final RbacRole... rbacRoles) { + this(ctx, "inproc://broker", serviceName, rbacRoles); + } + + protected MajordomoWorker(ZContext ctx, String brokerAddress, String serviceName, final RbacRole... rbacRoles) { + super(); + assert (brokerAddress != null); + assert (serviceName != null); + this.brokerAddress = brokerAddress; + this.serviceName = serviceName; + this.serviceBytes = serviceName.getBytes(StandardCharsets.UTF_8); + + // initialise RBAC role-based priority queues + this.rbacRoles = Collections.unmodifiableSortedSet(new TreeSet<>(Set.of(rbacRoles))); + + this.ctx = Objects.requireNonNullElseGet(ctx, ZContext::new); + if (ctx != null) { + this.setDaemon(true); + } + this.setName(MajordomoWorker.class.getSimpleName() + "#" + WORKER_COUNTER.getAndIncrement()); + this.uniqueID = this.serviceName + "-PID=" + ManagementFactory.getRuntimeMXBean().getName() + "-TID=" + this.getId(); + + this.setName(this.getClass().getSimpleName() + "(\"" + this.serviceName + "\")-" + uniqueID); + + LOGGER.atDebug().addArgument(serviceName).addArgument(uniqueID).log("created new service '{}' worker - uniqueID: {}"); + } + + public void destroy() { + ctx.destroy(); + } + + public int getHeartbeat() { + return HEARTBEAT_INTERVAL; + } + + public SortedSet> getRbacRoles() { + return rbacRoles; + } + + public int getReconnect() { + return reconnect; + } + + public RequestHandler getRequestHandler() { + return requestHandler; + } + + public String getServiceName() { + return serviceName; + } + + public String getUniqueID() { + return uniqueID; + } + + public MdpMessage handleRequestsFromBoker(final MdpWorkerMessage request) { + if (request == null) { + return null; + } + + switch (request.command) { + case W_REQUEST: + return processRequest(request, request.clientSourceID); + case W_HEARTBEAT: + // Do nothing for heartbeats + break; + case W_DISCONNECT: + reconnectToBroker(); + break; + case W_UNKNOWN: + default: + // N.B. not too verbose logging since we do not want that sloppy clients can bring down the broker through warning or info messages + if (LOGGER.isDebugEnabled()) { + LOGGER.atDebug().addArgument(uniqueID).addArgument(request).log("worer '{}' received invalid message: '{}'"); + } + break; + } + return null; + } + + public MdpMessage processRequest(final MdpMessage request, final byte[] clientSourceID) { + if (requestHandler != null) { + // de-serialise + // byte[] -> PropertyMap() (+ getObject(Class)) + try { + final byte[][] payload = requestHandler.handle(request.payload); + // serialise + return new MdpWorkerMessage(request.senderID, W_REPLY, serviceBytes, clientSourceID, payload); + } catch (Throwable e) { // NOPMD on purpose since we want to catch exceptions and courteously return this to the user + final StringWriter sw = new StringWriter(); + final PrintWriter pw = new PrintWriter(sw); + e.printStackTrace(pw); + + //noinspection StringBufferReplaceableByString + final StringBuilder builder = new StringBuilder(); // NOPMD NOSONAR -- easier to read !?!? + builder.append(getClass().getName()) + .append(" caught exception in user-provided call-back function for service '") + .append(getServiceName()) + .append("'\nrequest msg: ") + .append(request) + .append("\nexception: ") + .append(sw.toString()); + final String exceptionMsg = builder.toString(); + return new MdpWorkerMessage(request.senderID, W_REPLY, serviceBytes, clientSourceID, exceptionMsg.getBytes(StandardCharsets.UTF_8)); + } + } + return null; + } + + public void registerHandler(final RequestHandler handler) { + this.requestHandler = handler; + } + + @Override + public void run() { + while (!Thread.currentThread().isInterrupted()) { + handleReceive(); + } + destroy(); + } + + public void setReconnect(int reconnect) { + this.reconnect = reconnect; + } + + @Override + public synchronized void start() { // NOPMD - need to be synchronised on class level due to super definition + run.set(true); + reconnectToBroker(); + super.start(); + } + + public void stopWorker() { + run.set(false); + } + + /** + * Send reply, if any, to broker and wait for next request. + */ + protected void handleReceive() { // NOPMD -- single serial function .. easier to read + while (run.get() && !Thread.currentThread().isInterrupted()) { + // Poll socket for a reply, with timeout + if (poller.poll(HEARTBEAT_INTERVAL) == -1) { + break; // Interrupted + } + + if (poller.pollin(0)) { + final MdpMessage msg = receiveMdpMessage(workerSocket); + if (msg == null) { + continue; + // break; // Interrupted + } + if (LOGGER.isDebugEnabled()) { + LOGGER.atDebug().addArgument(uniqueID).addArgument(msg).log("worker '{}' received new message from broker: '{}'"); + } + liveness = HEARTBEAT_LIVENESS; + // Don't try to handle errors, just assert noisily + assert msg.payload != null : "MdpWorkerMessage payload is null"; + if (!(msg instanceof MdpWorkerMessage)) { + assert false : "msg is not instance of MdpWorkerMessage"; + continue; + } + final MdpWorkerMessage workerMessage = (MdpWorkerMessage) msg; + + if (LOGGER.isDebugEnabled()) { + LOGGER.atDebug().addArgument(uniqueID).addArgument(workerMessage).log("worker '{}' received request: '{}'"); + } + + final MdpMessage reply = handleRequestsFromBoker(workerMessage); + + if (LOGGER.isDebugEnabled()) { + LOGGER.atDebug().addArgument(uniqueID).addArgument(reply).log("worker '{}' received reply: '{}'"); + } + + if (reply != null) { + sendWorkerMessage(workerSocket, W_REPLY, reply.senderID, workerMessage.clientSourceID, reply.payload); + } + + } else if (--liveness == 0) { + if (LOGGER.isDebugEnabled()) { + LOGGER.atDebug().addArgument(uniqueID).log("worker '{}' disconnected from broker - retrying"); + } + try { + //noinspection BusyWait + Thread.sleep(reconnect); // NOSONAR NOPMD -- need to wait until retry + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); // Restore the interrupted status + break; + } + reconnectToBroker(); + } + + // Send HEARTBEAT if it's time + if (System.currentTimeMillis() > heartbeatAt) { + sendWorkerMessage(workerSocket, W_HEARTBEAT, null, null); + heartbeatAt = System.currentTimeMillis() + HEARTBEAT_INTERVAL; + } + } + if (Thread.currentThread().isInterrupted()) { + LOGGER.atInfo().addArgument(uniqueID).log("worker '{}' interrupt received, killing worker"); + } + } + + /** + * Connect or reconnect to broker + */ + protected void reconnectToBroker() { + if (workerSocket != null) { + workerSocket.close(); + } + workerSocket = ctx.createSocket(SocketType.DEALER); + assert workerSocket != null : "worker socket is null"; + assert workerSocket.getSocketType() == SocketType.DEALER : "worker socket type is " + workerSocket.getSocketType() + " instead of " + SocketType.DEALER; + workerSocket.setHWM(0); + workerSocket.connect(brokerAddress); + LOGGER.atDebug().addArgument(uniqueID).addArgument(brokerAddress).log("worker '{}' connecting to broker at '{}'"); + + // Register service with broker + sendWorkerMessage(workerSocket, W_READY, null, serviceBytes, getUniqueID().getBytes(StandardCharsets.UTF_8)); + + if (poller != null) { + poller.unregister(workerSocket); + poller.close(); + } + poller = ctx.createPoller(1); + poller.register(workerSocket, ZMQ.Poller.POLLIN); + + // If liveness hits zero, queue is considered disconnected + liveness = HEARTBEAT_LIVENESS; + heartbeatAt = System.currentTimeMillis() + HEARTBEAT_INTERVAL; + } + + public interface RequestHandler { + byte[][] handle(byte[][] payload) throws Throwable; + } +} diff --git a/concepts/src/main/java/module-info.java b/concepts/src/main/java/module-info.java new file mode 100644 index 00000000..1b31b8ec --- /dev/null +++ b/concepts/src/main/java/module-info.java @@ -0,0 +1,9 @@ +module io.opencmw.concepts { + requires org.slf4j; + requires disruptor; + requires jeromq; + requires java.management; + requires io.opencmw; + + exports io.opencmw.concepts.aggregate; +} \ No newline at end of file diff --git a/concepts/src/test/java/io/opencmw/concepts/BlockingQueueTests.java b/concepts/src/test/java/io/opencmw/concepts/BlockingQueueTests.java new file mode 100644 index 00000000..ddc3d1cd --- /dev/null +++ b/concepts/src/test/java/io/opencmw/concepts/BlockingQueueTests.java @@ -0,0 +1,95 @@ +package io.opencmw.concepts; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; + +public class BlockingQueueTests { // NOPMD NOSONAR -- nomen est omen + private static final int N_ITERATIONS = 100_000; + private final BlockingQueue outputQueue = new ArrayBlockingQueue<>(N_ITERATIONS); + private final BlockingQueue inputQueue = new ArrayBlockingQueue<>(N_ITERATIONS); + private final List workerList = new ArrayList<>(); + + public BlockingQueueTests(int nWorkers) { + for (int i = 0; i < nWorkers; i++) { + final int nWorker = i; + final Thread worker = new Thread() { + public void run() { + this.setName("worker#" + nWorker); + while (!this.isInterrupted()) { + final byte[] msg = outputQueue.poll(); + if (msg != null) { + inputQueue.offer(msg); + } + } + } + }; + + workerList.add(worker); + worker.start(); + } + } + + public void sendMessage(final byte[] msg) { + outputQueue.offer(msg); + } + + public byte[] receiveMessage() { + try { + return inputQueue.take(); + } catch (InterruptedException e) { + return null; + } + } + + public void stopWorker() { + workerList.forEach(Thread::interrupt); + } + + public static void main(String[] argv) { + for (int nThreads : new int[] { 1, 2, 4, 8, 10 }) { + final BlockingQueueTests test = new BlockingQueueTests(nThreads); + System.out.println("running: " + test.getClass().getName() + " with nThreads = " + nThreads); + + measure("nThreads=" + nThreads + " sync loop - simple", 3, () -> { + test.sendMessage("testString".getBytes(StandardCharsets.ISO_8859_1)); + final byte[] msg = test.receiveMessage(); + assert msg != null : "message must be non-null"; + System.out.println("received = " + (new String(msg, StandardCharsets.ISO_8859_1))); + }); + + for (int i = 0; i < 3; i++) { + measure("nThreads=" + nThreads + " sync loop#" + i, N_ITERATIONS, () -> { + test.sendMessage("testString".getBytes(StandardCharsets.ISO_8859_1)); + final byte[] msg = test.receiveMessage(); + assert msg != null : "message must be non-null"; + }); + } + + for (int i = 0; i < 3; i++) { + measure("nThreads=" + nThreads + " async loop#" + i, N_ITERATIONS, () -> test.sendMessage("testString".getBytes(StandardCharsets.ISO_8859_1)), // + () -> { + final byte[] msg = test.receiveMessage(); + assert msg != null : "message must be non-null"; + }); + } + test.stopWorker(); + } + } + + private static void measure(final String topic, final int nExec, final Runnable... runnable) { + final long start = System.nanoTime(); + + for (Runnable run : runnable) { + for (int i = 0; i < nExec; i++) { + run.run(); + } + } + + final long stop = System.nanoTime(); + final double diff = (stop - start) * 1e-9; + System.out.printf("%-40s: %10d calls/second\n", topic, diff > 0 ? (int) (nExec / diff) : -1); + } +} diff --git a/concepts/src/test/java/io/opencmw/concepts/DisruptorTests.java b/concepts/src/test/java/io/opencmw/concepts/DisruptorTests.java new file mode 100644 index 00000000..ac385f4c --- /dev/null +++ b/concepts/src/test/java/io/opencmw/concepts/DisruptorTests.java @@ -0,0 +1,115 @@ +package io.opencmw.concepts; + +import java.nio.charset.StandardCharsets; + +import com.lmax.disruptor.BlockingWaitStrategy; +import com.lmax.disruptor.RingBuffer; +import com.lmax.disruptor.dsl.Disruptor; +import com.lmax.disruptor.dsl.ProducerType; +import com.lmax.disruptor.util.DaemonThreadFactory; +import com.lmax.disruptor.util.Util; + +public class DisruptorTests { // NOPMD NOSONAR -- nomen est omen + // https://github.com/LMAX-Exchange/disruptor/wiki/Getting-Started + private static final int N_ITERATIONS = 100_000; + private static final int BUFFER_SIZE = Util.ceilingNextPowerOfTwo(N_ITERATIONS); // specify the size of the ring buffer, must be power of 2. + private final Disruptor disruptorOut; + private final RingBuffer outputBuffer; + private final Disruptor disruptorIn; + private final RingBuffer inputBuffer; + private long readPosition = 0; + + public DisruptorTests(int nWorkers) { + disruptorOut = new Disruptor<>(ByteArrayEvent::new, BUFFER_SIZE, DaemonThreadFactory.INSTANCE, ProducerType.SINGLE, new BlockingWaitStrategy()); + outputBuffer = disruptorOut.getRingBuffer(); + disruptorIn = new Disruptor<>(ByteArrayEvent::new, BUFFER_SIZE, DaemonThreadFactory.INSTANCE, nWorkers <= 1 ? ProducerType.SINGLE : ProducerType.MULTI, new BlockingWaitStrategy()); + inputBuffer = disruptorIn.getRingBuffer(); + + // Connect the parallel handler + for (int i = 0; i < nWorkers; i++) { + final int threadWorkerID = i; + disruptorOut.handleEventsWith((inputEvent, sequence, endOfBatch) -> { + if (sequence % nWorkers != threadWorkerID) { + return; + } + inputBuffer.publishEvent((returnEvent, returnSequence, returnBuffer) -> returnEvent.array = inputEvent.array); + }); + } + // Start the Disruptor, starts all threads running + disruptorOut.start(); + disruptorIn.start(); + } + + public void sendMessage(final byte[] msg) { + outputBuffer.publishEvent((event, sequence, buffer) -> event.array = msg); + } + + public byte[] receiveMessage() { + //System.err.println("inputBuffer.getCursor() = " + inputBuffer.getCursor()); + long cursor; + //noinspection StatementWithEmptyBody + while ((cursor = inputBuffer.getCursor()) < 0 || readPosition > cursor) { // NOPMD NOSONAR -- busy loop + // empty block on purpose - busy loop optimises latency + } + if (readPosition <= cursor) { + final ByteArrayEvent value = inputBuffer.get(readPosition); + readPosition++; + return value.array; + } else { + return null; + } + } + + public void stopWorker() { + disruptorOut.shutdown(); + disruptorIn.shutdown(); + } + + public static void main(String[] argv) { + for (int nThreads : new int[] { 1, 2, 4, 8, 10 }) { + final DisruptorTests test = new DisruptorTests(nThreads); + System.out.println("running: " + test.getClass().getName() + " with nThreads = " + nThreads); + + measure("nThreads=" + nThreads + " sync loop - simple", 3, () -> { + test.sendMessage("testString".getBytes(StandardCharsets.ISO_8859_1)); + final byte[] msg = test.receiveMessage(); + assert msg != null : "message must be non-null"; + }); + + for (int i = 0; i < 3; i++) { + measure("nThreads=" + nThreads + " sync loop#" + i, N_ITERATIONS, () -> { + test.sendMessage("testString".getBytes(StandardCharsets.ISO_8859_1)); + final byte[] msg = test.receiveMessage(); + assert msg != null : "message must be non-null"; + }); + } + + for (int i = 0; i < 3; i++) { + measure("nThreads=" + nThreads + " async loop#" + i, N_ITERATIONS, () -> test.sendMessage("testString".getBytes(StandardCharsets.ISO_8859_1)), // + () -> { + final byte[] msg = test.receiveMessage(); + assert msg != null : "message must be non-null"; + }); + } + test.stopWorker(); + } + } + + private static void measure(final String topic, final int nExec, final Runnable... runnable) { + final long start = System.nanoTime(); + + for (Runnable run : runnable) { + for (int i = 0; i < nExec; i++) { + run.run(); + } + } + + final long stop = System.nanoTime(); + final double diff = (stop - start) * 1e-9; + System.out.printf("%-40s: %10d calls/second\n", topic, diff > 0 ? (int) (nExec / diff) : -1); + } + + private static class ByteArrayEvent { + public byte[] array; + } +} diff --git a/concepts/src/test/java/io/opencmw/concepts/FutureTests.java b/concepts/src/test/java/io/opencmw/concepts/FutureTests.java new file mode 100644 index 00000000..9be71558 --- /dev/null +++ b/concepts/src/test/java/io/opencmw/concepts/FutureTests.java @@ -0,0 +1,216 @@ +package io.opencmw.concepts; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +import org.jetbrains.annotations.NotNull; + +public class FutureTests { // NOPMD NOSONAR -- nomen est omen + private static final int N_ITERATIONS = 100_000; + private final BlockingQueue> outputQueue = new ArrayBlockingQueue<>(N_ITERATIONS); + private final List workerList = new ArrayList<>(); + + public FutureTests(int nWorkers) { + for (int i = 0; i < nWorkers; i++) { + final int nWorker = i; + final Thread worker = new Thread() { + public void run() { + this.setName("worker#" + nWorker); + try { + while (!this.isInterrupted()) { + final CustomFuture msgFuture; + if (outputQueue.isEmpty()) { + msgFuture = outputQueue.take(); + } else { + msgFuture = outputQueue.poll(); + } + if (msgFuture == null) { + continue; + } + msgFuture.running.set(true); + if (msgFuture.payload != null) { + msgFuture.setReply(msgFuture.payload); + continue; + } + msgFuture.cancelled.set(true); + } + } catch (InterruptedException e) { + if (!outputQueue.isEmpty()) { + e.printStackTrace(); + } + } + } + }; + + workerList.add(worker); + worker.start(); + } + } + + public Future sendMessage(final byte[] msg) { + CustomFuture msgFuture = new CustomFuture<>(msg); + outputQueue.offer(msgFuture); + return msgFuture; + } + + public void stopWorker() { + workerList.forEach(Thread::interrupt); + } + + public static void main(String[] argv) { + for (int nThreads : new int[] { 1, 2, 4, 8, 10 }) { + final FutureTests test = new FutureTests(nThreads); + System.out.println("running: " + test.getClass().getName() + " with nThreads = " + nThreads); + + measure("nThreads=" + nThreads + " sync loop - simple", 3, () -> { + final Future reply = test.sendMessage("testString".getBytes(StandardCharsets.ISO_8859_1)); + try { + final byte[] msg = reply.get(); + assert msg != null : "message must be non-null"; + System.out.println("received = " + (new String(msg, StandardCharsets.ISO_8859_1))); + } catch (InterruptedException | ExecutionException e) { + e.printStackTrace(); + } + }); + + for (int i = 0; i < 3; i++) { + measure("nThreads=" + nThreads + " sync loop#" + i, N_ITERATIONS, () -> { + final Future reply = test.sendMessage("testString".getBytes(StandardCharsets.ISO_8859_1)); + try { + final byte[] msg = reply.get(); + assert msg != null : "message must be non-null"; + } catch (InterruptedException | ExecutionException e) { + e.printStackTrace(); + } + }); + } + + for (int i = 0; i < 3; i++) { + measureAsync("nThreads=" + nThreads + " async loop#" + i, N_ITERATIONS, + () -> { + final List> replies = new ArrayList<>(N_ITERATIONS); + for (int k = 0; k < N_ITERATIONS; k++) { + replies.add(test.sendMessage("testString".getBytes(StandardCharsets.ISO_8859_1))); + } + assert replies.size() == N_ITERATIONS : "did not receive sufficient events"; + replies.forEach(reply -> { + try { + final byte[] msg = reply.get(); + assert msg != null : "message must be non-null"; + } catch (InterruptedException | ExecutionException e) { + e.printStackTrace(); + } + }); + }); + } + test.stopWorker(); + } + } + + private static void measure(final String topic, final int nExec, final Runnable... runnable) { + final long start = System.nanoTime(); + + for (Runnable run : runnable) { + for (int i = 0; i < nExec; i++) { + run.run(); + } + } + + final long stop = System.nanoTime(); + final double diff = (stop - start) * 1e-9; + System.out.printf("%-40s: %10d calls/second\n", topic, diff > 0 ? (int) (nExec / diff) : -1); + } + + private static void measureAsync(final String topic, final int nExec, final Runnable... runnable) { + final long start = System.nanoTime(); + + for (Runnable run : runnable) { + run.run(); + } + + final long stop = System.nanoTime(); + final double diff = (stop - start) * 1e-9; + System.out.printf("%-40s: %10d calls/second\n", topic, diff > 0 ? (int) (nExec / diff) : -1); + } + + private static class CustomFuture implements Future { + private final Lock lock = new ReentrantLock(); + private final Condition processorNotifyCondition = lock.newCondition(); + private final AtomicBoolean running = new AtomicBoolean(false); + private final AtomicBoolean requestCancel = new AtomicBoolean(false); + private final AtomicBoolean cancelled = new AtomicBoolean(false); + private final T payload; + private T reply = null; + + private CustomFuture(final T input) { + this.payload = input; + } + + @Override + public boolean cancel(final boolean mayInterruptIfRunning) { + if (!running.get()) { + cancelled.set(true); + return !requestCancel.getAndSet(true); + } + return false; + } + + @Override + public T get() throws InterruptedException { + return get(0, TimeUnit.NANOSECONDS); + } + + @Override + public T get(final long timeout, @NotNull final TimeUnit unit) throws InterruptedException { + if (isDone()) { + return reply; + } + lock.lock(); + try { + while (!isDone()) { + //noinspection ResultOfMethodCallIgnored + processorNotifyCondition.await(timeout, TimeUnit.NANOSECONDS); + } + } finally { + lock.unlock(); + } + return reply; + } + + @Override + public boolean isCancelled() { + return cancelled.get(); + } + + @Override + public boolean isDone() { + return (reply != null && !running.get()) || cancelled.get(); + } + + public void setReply(final T newValue) { + if (running.getAndSet(false)) { + this.reply = newValue; + } + notifyListener(); + } + + private void notifyListener() { + lock.lock(); + try { + processorNotifyCondition.signalAll(); + } finally { + lock.unlock(); + } + } + } +} diff --git a/concepts/src/test/java/io/opencmw/concepts/ManyVsLargeFrameEvaluation.java b/concepts/src/test/java/io/opencmw/concepts/ManyVsLargeFrameEvaluation.java new file mode 100644 index 00000000..545ba8c1 --- /dev/null +++ b/concepts/src/test/java/io/opencmw/concepts/ManyVsLargeFrameEvaluation.java @@ -0,0 +1,281 @@ +package io.opencmw.concepts; + +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.LockSupport; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.zeromq.SocketType; +import org.zeromq.ZContext; +import org.zeromq.ZFrame; +import org.zeromq.ZMQ; +import org.zeromq.ZMQ.Socket; +import org.zeromq.ZMQException; +import org.zeromq.ZMsg; + +/** + * Quick performance evaluation to see the impact of single large w.r.t. many small frames. + */ +@SuppressWarnings({ "PMD.DoNotUseThreads", "PMD.AvoidInstantiatingObjectsInLoops" }) +public class ManyVsLargeFrameEvaluation { + private static final Logger LOGGER = LoggerFactory.getLogger(ManyVsLargeFrameEvaluation.class); + private static final AtomicBoolean RUN = new AtomicBoolean(true); + private static final byte[] CLIENT_ID = "C".getBytes(StandardCharsets.UTF_8); // client name + private static final byte[] WORKER_ID = "W".getBytes(StandardCharsets.UTF_8); // worker-service name + public static final char TAG_INTERNAL = 'I'; + public static final char TAG_EXTERNAL = 'E'; + public static final String TAG_EXTERNAL_STRING = "E"; + public static final String TAG_EXTERNAL_INTERNAL = "I"; + public static final String ENDPOINT_ROUTER = "tcp://localhost:5555"; + public static final String ENDPOINT_TCP = "tcp://localhost:5556"; + public static final String ENDPOINT_PUBSUB = "tcp://localhost:5557"; + private static int sampleSize = 100_000; + private static final int N_BUFFER_SIZE = 8; + private static final int N_FRAMES = 10; + public static final byte[] smallMessage = new byte[N_BUFFER_SIZE * N_FRAMES]; // NOPMD - volatile on purpose + public static final byte[] largeMessage = new byte[N_BUFFER_SIZE]; // NOPMD - volatile on purpose + + private static final int N_LOOPS = 5; + + private ManyVsLargeFrameEvaluation() { + // utility class + } + + public static void main(String[] args) { + if (args.length == 1) { + sampleSize = Integer.parseInt(args[0]); + } + Thread brokerThread = new Thread(new Broker()); + Thread workerThread = new Thread(new RoundTripAndNotifyEvaluation.Worker()); + brokerThread.start(); + workerThread.start(); + + Thread clientThread = new Thread(new Client()); + clientThread.start(); + + try { + clientThread.join(); + RUN.set(false); + workerThread.interrupt(); + brokerThread.interrupt(); + + // wait for threads to finish + workerThread.join(); + brokerThread.join(); + } catch (InterruptedException e) { + // finishes tests + assert false : "should not reach here if properly executed"; + } + + LOGGER.atDebug().log("finished tests"); + } + + private static void measure(final String topic, final int nExec, final Runnable... runnable) { + final long start = System.currentTimeMillis(); + + for (Runnable run : runnable) { + for (int i = 0; i < nExec; i++) { + run.run(); + } + } + + final long stop = System.currentTimeMillis(); + LOGGER.atDebug().addArgument(String.format("%-40s: %10d calls/second", topic, (1000L * nExec) / (stop - start))).log("{}"); + } + + private static class Broker implements Runnable { + private static final int TIMEOUT = 1000; + + @Override + public void run() { // NOPMD single-loop broker ... simplifies reading + try (ZContext ctx = new ZContext(); + Socket tcpFrontend = ctx.createSocket(SocketType.ROUTER); + Socket tcpBackend = ctx.createSocket(SocketType.ROUTER); + Socket inprocBackend = ctx.createSocket(SocketType.ROUTER); + ZMQ.Poller items = ctx.createPoller(3)) { + tcpFrontend.setHWM(0); + tcpBackend.setHWM(0); + inprocBackend.setHWM(0); + tcpFrontend.bind(ENDPOINT_ROUTER); + tcpBackend.bind(ENDPOINT_TCP); + inprocBackend.bind("inproc://broker"); + + Thread internalWorkerThread = new Thread(new InternalWorker(ctx)); + internalWorkerThread.setDaemon(true); + internalWorkerThread.start(); + items.register(tcpFrontend, ZMQ.Poller.POLLIN); + items.register(tcpBackend, ZMQ.Poller.POLLIN); + items.register(inprocBackend, ZMQ.Poller.POLLIN); + + while (RUN.get() && !Thread.currentThread().isInterrupted()) { + if (items.poll(TIMEOUT) == -1) { + break; // Interrupted + } + + if (items.pollin(0)) { + ZMsg msg = ZMsg.recvMsg(tcpFrontend); + if (msg == null) { + break; // Interrupted + } + + final ZFrame address = msg.pop(); + if (address.getData()[0] != CLIENT_ID[0]) { + address.destroy(); + break; + } + final ZFrame internal = msg.pop(); + if (TAG_EXTERNAL == internal.getData()[0]) { + msg.addFirst(new ZFrame(WORKER_ID)); // NOPMD - necessary to allocate inside loop + msg.send(tcpBackend); + } else if (TAG_INTERNAL == internal.getData()[0]) { + msg.addFirst(new ZFrame(WORKER_ID)); + msg.send(inprocBackend); + } + address.destroy(); + } + + if (items.pollin(1)) { + ZMsg msg = ZMsg.recvMsg(tcpBackend); + if (msg == null) { + break; // Interrupted + } + ZFrame address = msg.pop(); + + if (address.getData()[0] == WORKER_ID[0]) { + msg.addFirst(new ZFrame(CLIENT_ID)); + } + msg.send(tcpFrontend); + address.destroy(); + } + + if (items.pollin(2)) { + final ZMsg msg = ZMsg.recvMsg(inprocBackend); + if (msg == null) { + break; // Interrupted + } + final ZFrame address = msg.pop(); + + if (address.getData()[0] == WORKER_ID[0]) { + msg.addFirst(new ZFrame(CLIENT_ID)); + } + address.destroy(); + msg.send(tcpFrontend); + } + } + + internalWorkerThread.interrupt(); + if (!internalWorkerThread.isInterrupted()) { + internalWorkerThread.join(); + } + } catch (InterruptedException | IllegalStateException e) { + // terminated broker via interrupt + } + } + + private static class InternalWorker implements Runnable { + private final ZContext ctx; + + private InternalWorker(ZContext ctx) { + this.ctx = ctx; + } + + @Override + public void run() { + try (Socket worker = ctx.createSocket(SocketType.DEALER)) { + worker.setHWM(0); + worker.setIdentity(WORKER_ID); + worker.connect("inproc://broker"); + while (RUN.get() && !Thread.currentThread().isInterrupted()) { + ZMsg msg = ZMsg.recvMsg(worker); + msg.send(worker); + } + } catch (ZMQException e) { + // terminate internal worker + } + } + } + } + + private static class Client implements Runnable { + @Override + public void run() { // NOPMD -- complexity + try (ZContext ctx = new ZContext(); + Socket client = ctx.createSocket(SocketType.DEALER); + Socket subClient = ctx.createSocket(SocketType.SUB)) { + client.setHWM(0); + client.setIdentity(CLIENT_ID); + client.connect(ENDPOINT_ROUTER); + subClient.setHWM(0); + subClient.connect(ENDPOINT_PUBSUB); + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(100)); + + LOGGER.atDebug().log("Setting up test"); + + for (int l = 0; l < N_LOOPS; l++) { + for (final boolean external : new boolean[] { true, false }) { + final String inOut = external ? "TCP " : "InProc"; + measure(" Synchronous round-trip test (" + inOut + ", large frames)", sampleSize, () -> { + ZMsg req = new ZMsg(); + req.addString(external ? TAG_EXTERNAL_STRING : TAG_EXTERNAL_INTERNAL); + for (int i = 0; i < N_FRAMES; i++) { + req.add(smallMessage); + } + req.send(client); + ZMsg.recvMsg(client).destroy(); + }); + + measure("Asynchronous round-trip test (" + inOut + ", large frames)", sampleSize, () -> { + // send messages + ZMsg req = new ZMsg(); + req.addString(external? TAG_EXTERNAL_STRING : TAG_EXTERNAL_INTERNAL); + for (int i = 0; i < N_FRAMES; i++) { + req.add(smallMessage); + } + req.send(client); }, () -> { + // receive messages + ZMsg.recvMsg(client).destroy(); }); + + measure(" Synchronous round-trip test (" + inOut + ", many frames)", sampleSize, () -> { + ZMsg req = new ZMsg(); + req.addString(external ? TAG_EXTERNAL_STRING : TAG_EXTERNAL_INTERNAL); + req.add(largeMessage); + req.send(client); + ZMsg.recvMsg(client).destroy(); + }); + + measure("Asynchronous round-trip test (" + inOut + ", many frames)", sampleSize, () -> { + // send messages + ZMsg req = new ZMsg(); + req.addString(external? TAG_EXTERNAL_STRING : TAG_EXTERNAL_INTERNAL); + req.add(largeMessage); + req.send(client); }, () -> { + // receive messages + ZMsg.recvMsg(client).destroy(); }); + } + } + } catch (ZMQException e) { + LOGGER.atDebug().log("terminate client"); + } + } + } + + private static class Worker implements Runnable { + @Override + public void run() { + try (ZContext ctx = new ZContext(); + Socket worker = ctx.createSocket(SocketType.DEALER)) { + worker.setHWM(0); + worker.setIdentity(WORKER_ID); + worker.connect(ENDPOINT_TCP); + while (RUN.get() && !Thread.currentThread().isInterrupted()) { + ZMsg msg = ZMsg.recvMsg(worker); + msg.send(worker); + } + } catch (ZMQException e) { + // terminate worker + } + } + } +} diff --git a/concepts/src/test/java/io/opencmw/concepts/PushPullTests.java b/concepts/src/test/java/io/opencmw/concepts/PushPullTests.java new file mode 100644 index 00000000..63161e76 --- /dev/null +++ b/concepts/src/test/java/io/opencmw/concepts/PushPullTests.java @@ -0,0 +1,121 @@ +package io.opencmw.concepts; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import org.zeromq.SocketType; +import org.zeromq.ZContext; +import org.zeromq.ZMQ; +import org.zeromq.ZMQException; + +public class PushPullTests { // NOPMD NOSONAR -- nomen est omen + private static final int N_ITERATIONS = 100_000; + private final List workerList = new ArrayList<>(); + private final ZContext ctx = new ZContext(); + private final ZMQ.Socket sendSocket; + private final ZMQ.Socket receiveSocket; + + public PushPullTests(int nWorkers) { + sendSocket = ctx.createSocket(SocketType.PUSH); + sendSocket.setHWM(0); + sendSocket.bind("inproc://broker_push"); + receiveSocket = ctx.createSocket(SocketType.PULL); + receiveSocket.setHWM(0); + receiveSocket.bind("inproc://broker_pull"); + receiveSocket.setReceiveTimeOut(-1); + + for (int i = 0; i < nWorkers; i++) { + final int nWorker = i; + final Thread worker = new Thread() { + private final ZMQ.Socket sendSocket = ctx.createSocket(SocketType.PUSH); + private final ZMQ.Socket receiveSocket = ctx.createSocket(SocketType.PULL); + public void run() { + this.setName("worker#" + nWorker); + receiveSocket.connect("inproc://broker_push"); + receiveSocket.setHWM(0); + receiveSocket.setReceiveTimeOut(-1); + sendSocket.connect("inproc://broker_pull"); + try { + while (!this.isInterrupted()) { + final byte[] msg = receiveSocket.recv(0); + if (msg != null) { + sendSocket.send(msg); + } + } + } catch (ZMQException e) { + // process should abort + receiveSocket.close(); + sendSocket.close(); + } + } + }; + + workerList.add(worker); + worker.start(); + } + } + + public void sendMessage(final byte[] msg) { + sendSocket.send(msg); + } + + public byte[] receiveMessage() { + return receiveSocket.recv(); + } + + public void stopWorker() { + try { + Thread.sleep(1000); // NOSONAR + } catch (InterruptedException e) { + // do nothing + } + workerList.forEach(Thread::interrupt); + } + + public static void main(String[] argv) { + for (int nThreads : new int[] { 1, 2, 4, 8, 10 }) { + final PushPullTests test = new PushPullTests(nThreads); + System.out.println("running: " + test.getClass().getName() + " with nThreads = " + nThreads); + + measure("nThreads=" + nThreads + " sync loop - simple", 3, () -> { + test.sendMessage("testString".getBytes(StandardCharsets.ISO_8859_1)); + final byte[] msg = test.receiveMessage(); + assert msg != null : "message must be non-null"; + System.out.println("received = " + (new String(msg, StandardCharsets.ISO_8859_1))); + }); + + for (int i = 0; i < 3; i++) { + measure("nThreads=" + nThreads + " sync loop#" + i, N_ITERATIONS, () -> { + test.sendMessage("testString".getBytes(StandardCharsets.ISO_8859_1)); + final byte[] msg = test.receiveMessage(); + assert msg != null : "message must be non-null"; + }); + } + + for (int i = 0; i < 3; i++) { + measure("nThreads=" + nThreads + " async loop#" + i, N_ITERATIONS, () -> test.sendMessage("testStringA".getBytes(StandardCharsets.ISO_8859_1)), // + () -> { + final byte[] msg = test.receiveMessage(); + assert msg != null : "message must be non-null"; + }); + } + + test.stopWorker(); + } + } + + private static void measure(final String topic, final int nExec, final Runnable... runnable) { + final long start = System.nanoTime(); + + for (Runnable run : runnable) { + for (int i = 0; i < nExec; i++) { + run.run(); + } + } + + final long stop = System.nanoTime(); + final double diff = (stop - start) * 1e-9; + System.out.printf("%-40s: %10d calls/second\n", topic, diff > 0 ? (int) (nExec / diff) : -1); + } +} diff --git a/concepts/src/test/java/io/opencmw/concepts/RestBehindRouterEvaluation.java b/concepts/src/test/java/io/opencmw/concepts/RestBehindRouterEvaluation.java new file mode 100644 index 00000000..1af8da58 --- /dev/null +++ b/concepts/src/test/java/io/opencmw/concepts/RestBehindRouterEvaluation.java @@ -0,0 +1,210 @@ +package io.opencmw.concepts; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Timer; +import java.util.TimerTask; + +import org.zeromq.SocketType; +import org.zeromq.ZContext; +import org.zeromq.ZFrame; +import org.zeromq.ZMQ.Poller; +import org.zeromq.ZMQ.Socket; + +public class RestBehindRouterEvaluation { // NOPMD -- nomen est omen + private static final byte[] ZERO_MQ_HEADER = { -1, 0, 0, 0, 0, 0, 0, 0, 7, 127 }; + + private RestBehindRouterEvaluation() { + // utility class + } + + public static void main(final String[] argv) { + try (ZContext context = new ZContext(); + Socket tcpProxy = context.createSocket(SocketType.ROUTER); + Socket router = context.createSocket(SocketType.ROUTER); + Socket stream = context.createSocket(SocketType.STREAM); + Poller poller = context.createPoller(2)) { + tcpProxy.setRouterRaw(true); + if (!tcpProxy.bind("tcp://*:8080")) { + throw new IllegalStateException("could not bind socket"); + } + if (!router.bind("tcp://*:8081")) { + throw new IllegalStateException("could not bind socket"); + } + if (!stream.bind("tcp://*:8082")) { + throw new IllegalStateException("could not bind socket"); + } + poller.register(tcpProxy, Poller.POLLIN); + poller.register(stream, Poller.POLLIN); + + final TimerTask timerTask = new TimerTask() { + @Override + public void run() { + try (Socket dealer = context.createSocket(SocketType.DEALER)) { + dealer.setIdentity("dealer".getBytes(zmq.ZMQ.CHARSET)); + + System.err.println("clients sends request"); + dealer.connect("tcp://localhost:8080"); + dealer.send("Hello World"); + } + } + }; + new Timer().schedule(timerTask, 2000); + + while (!Thread.interrupted()) { + if (poller.poll(1000) == -1) { + break; // Interrupted + } + + if (poller.pollin(0)) { + handleRouterSocket(tcpProxy); + } + if (poller.pollin(1)) { + handleStreamHttpSocket(stream, null); + } + } + } + } + + private static long bytesToLong(byte[] bytes) { + long value = 0; + for (final byte aByte : bytes) { + value = (value << 8) + (aByte & 0xff); + } + return value; + } + + private static ZFrame getConnectionID(final Socket socket) { + // TODO: add further safe-guards if called for a socket with no data pending + + // Get [id, ] message on client connection. + final ZFrame handle = ZFrame.recvFrame(socket); + if (handle == null || bytesToLong(handle.getData()) == 0) { + return null; + } + + System.err.println("received ID = " + handle.toString()); // Professional Logging(TM) + if (!handle.hasMore()) { + // Close erroneous connection to browser + handle.send(socket, ZFrame.MORE | ZFrame.REUSE); + socket.send((byte[]) null, 0); + return null; + } + + // receive empty payload. + final ZFrame emptyFrame = ZFrame.recvFrame(socket); + if (!emptyFrame.hasMore() || emptyFrame.size() == 0) { + // received null frame + //System.err.println("nothing received"); + return handle; + } + if (emptyFrame.hasMore() || emptyFrame.size() != 0) { + System.err.println("did receive more " + emptyFrame); + return null; + } + + return handle; + } + + private static ZFrame getRequest(final Socket socket) { + // TODO: add further safe-guards if called for a socket with no data pending + + // Get [id, ] message on client connection. + final ZFrame handle = ZFrame.recvFrame(socket); + if (handle == null || bytesToLong(handle.getData()) == 0) { + return null; + } + + System.err.println("received Request ID = " + handle.toString() + " - more = " + handle.hasMore()); // Professional Logging(TM) + if (!handle.hasMore()) { + // Close erroneous connection to browser + handle.send(socket, ZFrame.MORE | ZFrame.REUSE); + socket.send((byte[]) null, 0); + return null; + } + + // receive request + return ZFrame.recvFrame(socket); + } + + private static void handleRouterSocket(final Socket router) { + System.err.println("### called handleRouterSocket"); + // Get [id, ] message on client connection. + ZFrame handle = getConnectionID(router); + if (handle == null) { + // did not receive proper [ID, null msg] frames + return; + } + + final ZFrame request = getRequest(router); + if (request == null) { + return; + } + if (Arrays.equals(ZERO_MQ_HEADER, request.getData())) { + router.sendMore(handle.getData()); + router.send(ZERO_MQ_HEADER); + System.err.println("received ZeroMQ message more = " + request.hasMore()); + + } else { + System.err.println("received other (HTTP) message"); + System.err.println("received request = " + request + " more? " + request.hasMore()); + return; + } + + // handleStreamHttpSocket(router, handle); + // received router request + while (request.hasMore()) { + // receive message + final byte[] message = router.recv(0); + final boolean more = router.hasReceiveMore(); + System.err.println("router msg (" + (more ? "more" : "all ") + "): " + Arrays.toString(message) + "\n - string: '" + new String(message) + "'"); // NOPMD + + //handleStreamHttpSocket(router); + // Broker it -- throws an exception (too naive implementation?) + //stream.send(message, more ? ZMQ.SNDMORE : 0); + if (!more) { + break; + } + } + } + + private static void handleStreamHttpSocket(Socket httpSocket, ZFrame handlerExt) { + // Get [id, ] message on client connection. + ZFrame handler = handlerExt; + if (handler == null && (handler = getConnectionID(httpSocket)) == null) { // NOPMD + // did not receive proper [ID, null msg] frames + return; + } + + // Get [id, playload] message. + final ZFrame clientRequest = ZFrame.recvFrame(httpSocket); + if (clientRequest == null || bytesToLong(clientRequest.getData()) == 0) { + return; + } + if (!Arrays.equals(handler.getData(), clientRequest.getData())) { + // header ID mismatch + return; + } + if (!clientRequest.hasMore()) { + // Close erroneous connection to browser + clientRequest.send(httpSocket, ZFrame.MORE | ZFrame.REUSE); + httpSocket.send((byte[]) null, 0); + return; + } + + // receive playload message. + ZFrame request = ZFrame.recvFrame(httpSocket); + String header = new String(request.getData(), 0, request.size(), + StandardCharsets.UTF_8); + System.err.println("received client request header : '" + header); // Professional Logging(TM) + + // Send Hello World response + final String uri = (header.length() == 0) ? "null" : header.split("\n")[0]; + clientRequest.send(httpSocket, ZFrame.MORE | ZFrame.REUSE); + httpSocket.send("HTTP/1.0 200 OK\r\nContent-Type: text/plain\r\n\r\nHello, World!\nyou requested URI: " + uri); + + // Close connection to browser -- normally exit + clientRequest.send(httpSocket, ZFrame.MORE | ZFrame.REUSE); + httpSocket.send((byte[]) null, 0); + } +} diff --git a/concepts/src/test/java/io/opencmw/concepts/RoundTripAndNotifyEvaluation.java b/concepts/src/test/java/io/opencmw/concepts/RoundTripAndNotifyEvaluation.java new file mode 100644 index 00000000..e30514bf --- /dev/null +++ b/concepts/src/test/java/io/opencmw/concepts/RoundTripAndNotifyEvaluation.java @@ -0,0 +1,449 @@ +package io.opencmw.concepts; + +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.LockSupport; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.zeromq.SocketType; +import org.zeromq.ZContext; +import org.zeromq.ZFrame; +import org.zeromq.ZMQ; +import org.zeromq.ZMQ.Socket; +import org.zeromq.ZMQException; +import org.zeromq.ZMsg; + +/** + * Quick Router-Dealer Round-trip demonstrator. + * Broker, Worker and Client are mocked as separate threads. + * + * Example output: + * Setting up test + * Synchronous round-trip test (TCP) : 3838 calls/second + * Synchronous round-trip test (InProc) : 12224 calls/second + * Asynchronous round-trip test (TCP) : 33444 calls/second + * Asynchronous round-trip test (InProc) : 35587 calls/second + * Subscription (SUB) test : 632911 calls/second + * Subscription (DEALER) test (TCP) : 43821 calls/second + * Subscription (DEALER) test (InProc) : 49900 calls/second + * Subscription (direct DEALER) test : 1351351 calls/second + * finished tests + * + * N.B. for >200000 calls/second the code seems to depend largely on the broker/parameters + * (ie. JIT, whether services are identified by single characters etc.) + */ +@SuppressWarnings({ "PMD.DoNotUseThreads", "PMD.AvoidInstantiatingObjectsInLoops" }) +class RoundTripAndNotifyEvaluation { + private static final Logger LOGGER = LoggerFactory.getLogger(RoundTripAndNotifyEvaluation.class); + // private static final String SUB_TOPIC = "x"; + private static final String SUB_TOPIC = "/?# - a very long topic to test the dependence of pub/sub pairs on topic lengths"; + private static final byte[] SUB_DATA = "D".getBytes(StandardCharsets.UTF_8); // custom minimal data + private static final byte[] CLIENT_ID = "C".getBytes(StandardCharsets.UTF_8); // client name + private static final byte[] WORKER_ID = "W".getBytes(StandardCharsets.UTF_8); // worker-service name + private static final byte[] PUBLISH_ID = "P".getBytes(StandardCharsets.UTF_8); // publish-service name + private static final byte[] SUBSCRIBER_ID = "S".getBytes(StandardCharsets.UTF_8); // subscriber name + public static final char TAG_INTERNAL = 'I'; + public static final char TAG_EXTERNAL = 'E'; + public static final String TAG_EXTERNAL_STRING = "E"; + public static final String TAG_EXTERNAL_INTERNAL = "I"; + public static final String START = "start"; + public static final String DUMMY_DATA = "hello"; + public static final String ENDPOINT_ROUTER = "tcp://localhost:5555"; + public static final String ENDPOINT_TCP = "tcp://localhost:5556"; + public static final String ENDPOINT_PUBSUB = "tcp://localhost:5557"; + private static final AtomicBoolean RUN = new AtomicBoolean(true); + private static int sampleSize = 10_000; + private static int sampleSizePub = 100_000; + + private RoundTripAndNotifyEvaluation() { + // utility class + } + + public static void main(String[] args) { + if (args.length == 1) { + sampleSize = Integer.parseInt(args[0]); + sampleSizePub = 10 * sampleSize; + } + Thread brokerThread = new Thread(new Broker()); + Thread workerThread = new Thread(new Worker()); + Thread publishThread = new Thread(new PublishWorker()); + Thread pubDealerThread = new Thread(new PublishDealerWorker()); + Thread directDealerThread = new Thread(new PublishDirectDealerWorker()); + + brokerThread.start(); + workerThread.start(); + publishThread.start(); + pubDealerThread.start(); + directDealerThread.start(); + + Thread clientThread = new Thread(new Client()); + clientThread.start(); + + try { + clientThread.join(); + RUN.set(false); + workerThread.interrupt(); + brokerThread.interrupt(); + publishThread.interrupt(); + pubDealerThread.interrupt(); + directDealerThread.interrupt(); + + // wait for threads to finish + workerThread.join(); + publishThread.join(); + pubDealerThread.join(); + directDealerThread.join(); + brokerThread.join(); + } catch (InterruptedException e) { + // finishes tests + assert false : "should not reach here if properly executed"; + } + + LOGGER.atDebug().log("finished tests"); + } + + private static void measure(final String topic, final int nExec, final Runnable... runnable) { + final long start = System.currentTimeMillis(); + + for (Runnable run : runnable) { + for (int i = 0; i < nExec; i++) { + run.run(); + } + } + + final long stop = System.currentTimeMillis(); + LOGGER.atDebug().addArgument(String.format("%-40s: %10d calls/second", topic, (1000L * nExec) / (stop - start))).log("{}"); + } + + private static class Broker implements Runnable { + private static final int TIMEOUT = 1000; + @Override + public void run() { // NOPMD single-loop broker ... simplifies reading + try (ZContext ctx = new ZContext(); + Socket tcpFrontend = ctx.createSocket(SocketType.ROUTER); + Socket tcpBackend = ctx.createSocket(SocketType.ROUTER); + Socket inprocBackend = ctx.createSocket(SocketType.ROUTER); + ZMQ.Poller items = ctx.createPoller(3)) { + tcpFrontend.setHWM(0); + tcpBackend.setHWM(0); + inprocBackend.setHWM(0); + final boolean a = tcpFrontend.bind(ENDPOINT_ROUTER); + tcpBackend.bind(ENDPOINT_TCP); + inprocBackend.bind("inproc://broker"); + items.register(tcpFrontend, ZMQ.Poller.POLLIN); + items.register(tcpBackend, ZMQ.Poller.POLLIN); + items.register(inprocBackend, ZMQ.Poller.POLLIN); + + Thread internalWorkerThread = new Thread(new InternalWorker(ctx)); + internalWorkerThread.setDaemon(true); + internalWorkerThread.start(); + Thread internalPublishDealerWorkerThread = new Thread(new InternalPublishDealerWorker(ctx)); + internalPublishDealerWorkerThread.setDaemon(true); + internalPublishDealerWorkerThread.start(); + + while (RUN.get() && !Thread.currentThread().isInterrupted()) { + if (items.poll(TIMEOUT) == -1) { + break; // Interrupted + } + + if (items.pollin(0)) { + final ZMsg msg = ZMsg.recvMsg(tcpFrontend); + if (msg == null) { + break; // Interrupted + } + final ZFrame address = msg.pop(); + final ZFrame internal = msg.pop(); + if (address.getData()[0] == CLIENT_ID[0]) { + if (TAG_EXTERNAL == internal.getData()[0]) { + msg.addFirst(new ZFrame(WORKER_ID)); + msg.send(tcpBackend); + } else if (TAG_INTERNAL == internal.getData()[0]) { + msg.addFirst(new ZFrame(WORKER_ID)); + msg.send(inprocBackend); + } + } else { + if (TAG_EXTERNAL == internal.getData()[0]) { + msg.addFirst(new ZFrame(PUBLISH_ID)); + msg.send(tcpBackend); + } else if (TAG_INTERNAL == internal.getData()[0]) { + msg.addFirst(new ZFrame(PUBLISH_ID)); + msg.send(inprocBackend); + } + } + address.destroy(); + } + + if (items.pollin(1)) { + ZMsg msg = ZMsg.recvMsg(tcpBackend); + if (msg == null) { + break; // Interrupted + } + ZFrame address = msg.pop(); + + if (address.getData()[0] == WORKER_ID[0]) { + msg.addFirst(new ZFrame(CLIENT_ID)); + } else { + msg.addFirst(new ZFrame(SUBSCRIBER_ID)); + } + msg.send(tcpFrontend); + address.destroy(); + } + + if (items.pollin(2)) { + final ZMsg msg = ZMsg.recvMsg(inprocBackend); + if (msg == null) { + break; // Interrupted + } + ZFrame address = msg.pop(); + + if (address.getData()[0] == WORKER_ID[0]) { + msg.addFirst(new ZFrame(CLIENT_ID)); + } else { + msg.addFirst(new ZFrame(SUBSCRIBER_ID)); + } + address.destroy(); + msg.send(tcpFrontend); + } + } + + internalWorkerThread.interrupt(); + internalPublishDealerWorkerThread.interrupt(); + if (!internalWorkerThread.isInterrupted()) { + internalWorkerThread.join(); + } + if (!internalPublishDealerWorkerThread.isInterrupted()) { + internalPublishDealerWorkerThread.join(); + } + } catch (InterruptedException | IllegalStateException e) { + // terminated broker via interrupt + } + } + + private static class InternalWorker implements Runnable { + private final ZContext ctx; + public InternalWorker(ZContext ctx) { + this.ctx = ctx; + } + + @Override + public void run() { + try (Socket worker = ctx.createSocket(SocketType.DEALER)) { + worker.setHWM(0); + worker.setIdentity(WORKER_ID); + worker.connect("inproc://broker"); + while (RUN.get() && !Thread.currentThread().isInterrupted()) { + ZMsg msg = ZMsg.recvMsg(worker); + msg.send(worker); + } + } catch (ZMQException e) { + // terminate internal worker + } + } + } + + private static class InternalPublishDealerWorker implements Runnable { + private final ZContext ctx; + public InternalPublishDealerWorker(ZContext ctx) { + this.ctx = ctx; + } + + @Override + public void run() { + try (Socket worker = ctx.createSocket(SocketType.DEALER)) { + worker.setHWM(0); + worker.setIdentity(PUBLISH_ID); + worker.connect("inproc://broker"); + while (RUN.get() && !Thread.currentThread().isInterrupted()) { + ZMsg msg = ZMsg.recvMsg(worker); + if (START.equals(msg.getFirst().getString(ZMQ.CHARSET))) { + // System.err.println("dealer (indirect): start pushing"); + for (int requests = 0; requests < sampleSizePub; requests++) { + worker.send(SUB_TOPIC, ZMQ.SNDMORE); + worker.send(SUB_DATA); + } + } + } + } catch (ZMQException | IllegalStateException e) { + // terminate internal publish worker + } + } + } + } + + protected static class Worker implements Runnable { + @Override + public void run() { + try (ZContext ctx = new ZContext(); Socket worker = ctx.createSocket(SocketType.DEALER)) { + worker.setHWM(0); + worker.setIdentity(WORKER_ID); + worker.connect(ENDPOINT_TCP); + while (RUN.get() && !Thread.currentThread().isInterrupted()) { + ZMsg msg = ZMsg.recvMsg(worker); + msg.send(worker); + } + } catch (ZMQException e) { + // terminate worker + } + } + } + + private static class PublishWorker implements Runnable { + @Override + public void run() { + try (ZContext ctx = new ZContext(); Socket worker = ctx.createSocket(SocketType.PUB)) { + worker.setHWM(0); + worker.bind(ENDPOINT_PUBSUB); + // System.err.println("PublishWorker: start publishing"); + while (RUN.get() && !Thread.currentThread().isInterrupted()) { + worker.send(SUB_TOPIC, ZMQ.SNDMORE); + worker.send(SUB_DATA); + } + } catch (ZMQException | IllegalStateException e) { + // terminate pub-Dealer worker + } + } + } + + private static class PublishDealerWorker implements Runnable { + @Override + public void run() { + try (ZContext ctx = new ZContext(); Socket worker = ctx.createSocket(SocketType.DEALER)) { + worker.setHWM(0); + worker.setIdentity(PUBLISH_ID); + //worker.bind("tcp://localhost:5558"); + worker.connect("tcp://localhost:5556"); + while (RUN.get() && !Thread.currentThread().isInterrupted()) { + ZMsg msg = ZMsg.recvMsg(worker); + if (START.equals(msg.getFirst().getString(ZMQ.CHARSET))) { + // System.err.println("dealer (indirect): start pushing"); + for (int requests = 0; requests < sampleSizePub; requests++) { + worker.send(SUB_TOPIC, ZMQ.SNDMORE); + worker.send(SUB_DATA); + } + } + } + } catch (ZMQException | IllegalStateException e) { + // terminate publish worker + } + } + } + + private static class PublishDirectDealerWorker implements Runnable { + @Override + public void run() { + try (ZContext ctx = new ZContext(); Socket worker = ctx.createSocket(SocketType.DEALER)) { + worker.setHWM(0); + worker.setIdentity(PUBLISH_ID); + worker.bind("tcp://localhost:5558"); + while (RUN.get() && !Thread.currentThread().isInterrupted()) { + ZMsg msg = ZMsg.recvMsg(worker); + if (START.equals(msg.getFirst().getString(ZMQ.CHARSET))) { + // System.err.println("dealer (direct): start pushing"); + for (int requests = 0; requests < sampleSizePub; requests++) { + worker.send(SUB_TOPIC, ZMQ.SNDMORE); + worker.send(SUB_DATA); + } + } + } + } catch (ZMQException | IllegalStateException e) { + // terminate publish worker + } + } + } + + private static class Client implements Runnable { + @Override + public void run() { + try (ZContext ctx = new ZContext(); + Socket client = ctx.createSocket(SocketType.DEALER); + Socket subClient = ctx.createSocket(SocketType.SUB)) { + client.setHWM(0); + client.setIdentity(CLIENT_ID); + client.connect(ENDPOINT_ROUTER); + subClient.setHWM(0); + subClient.connect(ENDPOINT_PUBSUB); + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(100)); + + LOGGER.atDebug().log("Setting up test"); + // check and wait until broker is up and running + ZMsg.newStringMsg(TAG_EXTERNAL_STRING).addString(DUMMY_DATA).send(client); + ZMsg.recvMsg(client).destroy(); + + measure("Synchronous round-trip test (TCP)", sampleSize, () -> { + ZMsg req = new ZMsg(); + req.addString(TAG_EXTERNAL_STRING); + req.addString(DUMMY_DATA); + req.send(client); + ZMsg.recvMsg(client).destroy(); + }); + + measure("Synchronous round-trip test (InProc)", sampleSize, () -> { + ZMsg req = new ZMsg(); + req.addString(TAG_EXTERNAL_INTERNAL); + req.addString(DUMMY_DATA); + req.send(client); + ZMsg.recvMsg(client).destroy(); + }); + + measure("Asynchronous round-trip test (TCP)", sampleSize, () -> { + // send messages + ZMsg req = new ZMsg(); + req.addString(TAG_EXTERNAL_STRING); + req.addString(DUMMY_DATA); + req.send(client); }, () -> { + // receive messages + ZMsg.recvMsg(client).destroy(); }); + + measure("Asynchronous round-trip test (InProc)", sampleSize, () -> { + // send messages + ZMsg req = new ZMsg(); + req.addString(TAG_EXTERNAL_INTERNAL); + req.addString(DUMMY_DATA); + req.send(client); }, () -> { + // receive messages + ZMsg.recvMsg(client).destroy(); }); + + subClient.subscribe(SUB_TOPIC.getBytes(ZMQ.CHARSET)); + // first loop to empty potential queues/HWM + for (int requests = 0; requests < sampleSizePub; requests++) { + ZMsg req = ZMsg.recvMsg(subClient); + req.destroy(); + } + // start actual subscription loop + measure("Subscription (SUB) test", sampleSizePub, () -> { + ZMsg req = ZMsg.recvMsg(subClient); + req.destroy(); + }); + subClient.unsubscribe(SUB_TOPIC.getBytes(ZMQ.CHARSET)); + + client.disconnect(ENDPOINT_ROUTER); + client.setIdentity(SUBSCRIBER_ID); + client.connect(ENDPOINT_ROUTER); + ZMsg.newStringMsg(START).addFirst(TAG_EXTERNAL_STRING).send(client); + measure("Subscription (DEALER) test (TCP)", sampleSizePub, () -> { + ZMsg req = ZMsg.recvMsg(client); + req.destroy(); + }); + + ZMsg.newStringMsg(START).addFirst(TAG_EXTERNAL_INTERNAL).send(client); + measure("Subscription (DEALER) test (InProc)", sampleSizePub, () -> { + ZMsg req = ZMsg.recvMsg(client); + req.destroy(); + }); + + client.disconnect(ENDPOINT_ROUTER); + client.connect("tcp://localhost:5558"); + ZMsg.newStringMsg(START).send(client); + measure("Subscription (direct DEALER) test", sampleSizePub, () -> { + ZMsg req = ZMsg.recvMsg(client); + req.destroy(); + }); + + } catch (ZMQException e) { + LOGGER.atError().setCause(e).log("terminate client"); + } + } + } +} diff --git a/concepts/src/test/java/io/opencmw/concepts/aggregate/DemuxEventDispatcherTest.java b/concepts/src/test/java/io/opencmw/concepts/aggregate/DemuxEventDispatcherTest.java new file mode 100644 index 00000000..4fcc2bcf --- /dev/null +++ b/concepts/src/test/java/io/opencmw/concepts/aggregate/DemuxEventDispatcherTest.java @@ -0,0 +1,97 @@ +package io.opencmw.concepts.aggregate; + +import static org.junit.jupiter.params.provider.Arguments.arguments; + +import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.awaitility.Awaitility; +import org.hamcrest.Matcher; +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import com.lmax.disruptor.EventHandler; +import com.lmax.disruptor.RingBuffer; +import com.lmax.disruptor.TimeoutBlockingWaitStrategy; +import com.lmax.disruptor.dsl.Disruptor; +import com.lmax.disruptor.dsl.EventHandlerGroup; +import com.lmax.disruptor.dsl.ProducerType; +import com.lmax.disruptor.util.DaemonThreadFactory; + +/** + * + * @author Alexander Krimm + */ +@SuppressWarnings("unchecked") +class DemuxEventDispatcherTest { + static Stream workingEventSamplesProvider() { + return Stream.of( + arguments("ordinary", "a1 b1 c1 a2 b2 c2 a3 b3 c3", "a1 b1 c1; a2 b2 c2; a3 b3 c3", "", 1), + arguments("duplicate events", "a1 b1 c1 b1 a2 b2 c2 a2 a3 b3 c3 c3", "a1 b1 c1; a2 b2 c2; a3 b3 c3", "", 1), + arguments("reordered", "a1 c1 b1 a2 b2 c2 a3 b3 c3", "a1 b1 c1; a2 b2 c2; a3 b3 c3", "", 1), + arguments("interleaved", "a1 b1 a2 b2 c1 a3 b3 c2 c3", "a1 b1 c1; a2 b2 c2; a3 b3 c3", "", 1), + arguments("missing event", "a1 b1 a2 b2 c2 a3 b3 c3", "a2 b2 c2; a3 b3 c3", "1", 1), + arguments("missing device", "a1 b1 a2 b2 a3 b3", "", "1 2 3", 1), + arguments("late", "a1 b1 a2 b2 c2 a3 b3 c3 c1", "a1 b1 c1; a2 b2 c2; a3 b3 c3", "", 1), + arguments("timeout without event", "a1 b1 c1 a2 b2", "a1 b1 c1", "2", 1), + arguments("long queue", "a1 b1 c1 a2 b2", "a1 b1 c1; a1001 b1001 c1001; a2001 b2001 c2001; a3001 b3001 c3001; a4001 b4001 c4001", "2 1002 2002 3002 4002", 5), + arguments("simple broken long queue", "a1 b1", "", "1 1001 2001 3001 4001", 5), + arguments("single event timeout", "a1 b1 pause pause c1", "", "1", 1)); + } + + @ParameterizedTest + @MethodSource("workingEventSamplesProvider") + void testSimpleEvents(final String eventSetupName, final String events, final String aggregatesAll, final String timeoutsAll, final int repeat) { + // handler which collects all aggregate events which are republished to the buffer + final Set> aggResults = ConcurrentHashMap.newKeySet(); + final Set aggTimeouts = ConcurrentHashMap.newKeySet(); + EventHandler testHandler = (ev, seq, eob) -> { + System.out.println(ev); + if (ev.payload instanceof List) { + @SuppressWarnings("unchecked") + final List agg = (List) ev.payload; + final Set payloads = agg.stream().map(e -> (String) ((TestEventSource.Event) e.payload).payload).collect(Collectors.toSet()); + aggResults.add(payloads); + } + if (ev.payload instanceof String && ((String) ev.payload).startsWith("aggregation timed out for bpcts: ")) { + final String payload = ((String) ev.payload); + aggTimeouts.add(Integer.parseInt(payload.substring(33, payload.indexOf(' ', 34)))); + } + }; + + // create event ring buffer and add de-multiplexing processors + final Disruptor disruptor = new Disruptor<>( + TestEventSource.IngestedEvent::new, + 256, + DaemonThreadFactory.INSTANCE, + ProducerType.MULTI, + new TimeoutBlockingWaitStrategy(200, TimeUnit.MILLISECONDS)); + final DemuxEventDispatcher aggProc = new DemuxEventDispatcher(disruptor.getRingBuffer()); + final EventHandlerGroup endBarrier = disruptor.handleEventsWith(testHandler).handleEventsWith(aggProc).then(aggProc.getAggregationHander()); + RingBuffer rb = disruptor.start(); + + // Use event source to publish demo events to the buffer. + TestEventSource testEventSource = new TestEventSource(events, repeat, rb); + Assertions.assertDoesNotThrow(testEventSource::run); + + // wait for all events to be played and processed + Awaitility.await().atMost(Duration.ofSeconds(repeat)).until(() -> endBarrier.asSequenceBarrier().getCursor() == rb.getCursor() && Arrays.stream(aggProc.getAggregationHander()).allMatch(w -> w.bpcts == -1)); + // compare aggregated results and timeouts + MatcherAssert.assertThat(aggResults, Matchers.containsInAnyOrder(Arrays.stream(aggregatesAll.split(";")) + .filter(s -> !s.isEmpty()) + .map(s -> Matchers.containsInAnyOrder(Arrays.stream(s.split(" ")).map(String::trim).filter(e -> !e.isEmpty()).toArray())) + .toArray(Matcher[] ::new))); + System.out.println(aggTimeouts); + MatcherAssert.assertThat(aggTimeouts, Matchers.containsInAnyOrder(Arrays.stream(timeoutsAll.split(" ")).filter(s -> !s.isEmpty()).map(Integer::parseInt).toArray(Integer[] ::new))); + } +} diff --git a/concepts/src/test/java/io/opencmw/concepts/majordomo/ClientSampleV1.java b/concepts/src/test/java/io/opencmw/concepts/majordomo/ClientSampleV1.java new file mode 100644 index 00000000..f097a5f2 --- /dev/null +++ b/concepts/src/test/java/io/opencmw/concepts/majordomo/ClientSampleV1.java @@ -0,0 +1,48 @@ +package io.opencmw.concepts.majordomo; + +import java.nio.charset.StandardCharsets; + +import org.zeromq.ZMsg; + +import io.opencmw.rbac.BasicRbacRole; +import io.opencmw.rbac.RbacToken; + +/** +* Majordomo Protocol client example. Uses the mdcli API to hide all OpenCmwProtocol aspects +*/ +public final class ClientSampleV1 { // nomen est omen + private static final int N_SAMPLES = 50_000; + + private ClientSampleV1() { + // requires only static methods for testing + } + + public static void main(String[] args) { + MajordomoClientV1 clientSession = new MajordomoClientV1("tcp://localhost:5555", "customClientName"); + final byte[] serviceBytes = "mmi.echo".getBytes(StandardCharsets.UTF_8); + // final byte[] serviceBytes = "inproc.echo".getBytes(StandardCharsets.UTF_8) + // final byte[] serviceBytes = "echo".getBytes(StandardCharsets.UTF_8) + + int count; + long start = System.currentTimeMillis(); + for (count = 0; count < N_SAMPLES; count++) { + final String requestMsg = "Hello world - sync - " + count; + final byte[] request = requestMsg.getBytes(StandardCharsets.UTF_8); + final byte[] rbacToken = new RbacToken(BasicRbacRole.ADMIN, "HASHCODE").getBytes(); // NOPMD + final ZMsg reply = clientSession.send(serviceBytes, request, rbacToken); // with RBAC + // final ZMsg reply = clientSession.send(serviceBytes, request); // w/o RBAC + if (count < 10 || count % 10_000 == 0 || count >= (N_SAMPLES - 10)) { + System.err.println("client iteration = " + count + " - received: " + reply); + } + if (reply == null) { + break; // Interrupt or failure + } + reply.destroy(); + } + + long mark1 = System.currentTimeMillis(); + double diff2 = 1e-3 * (mark1 - start); + System.err.printf("%d requests/replies processed in %d ms -> %f op/s\n", count, mark1 - start, count / diff2); + clientSession.destroy(); + } +} diff --git a/concepts/src/test/java/io/opencmw/concepts/majordomo/ClientSampleV2.java b/concepts/src/test/java/io/opencmw/concepts/majordomo/ClientSampleV2.java new file mode 100644 index 00000000..b08c98b4 --- /dev/null +++ b/concepts/src/test/java/io/opencmw/concepts/majordomo/ClientSampleV2.java @@ -0,0 +1,50 @@ +package io.opencmw.concepts.majordomo; + +import java.nio.charset.StandardCharsets; + +import org.zeromq.ZMsg; + +/** + * Majordomo Protocol client example, asynchronous. Uses the mdcli API to hide + * all OpenCmwProtocol aspects + */ + +public final class ClientSampleV2 { // NOPMD -- nomen est omen + private static final int N_SAMPLES = 1_000_000; + + private ClientSampleV2() { + // requires only static methods for testing + } + + public static void main(String[] args) { + MajordomoClientV2 clientSession = new MajordomoClientV2("tcp://localhost:5555"); + final byte[] serviceBytes = "mmi.echo".getBytes(StandardCharsets.UTF_8); + // final byte[] serviceBytes = "inproc.echo".getBytes(StandardCharsets.UTF_8) + // final byte[] serviceBytes = "echo".getBytes(StandardCharsets.UTF_8) + + int count; + long start = System.currentTimeMillis(); + for (count = 0; count < N_SAMPLES; count++) { + final String requestMsg = "Hello world - async - " + count; + clientSession.send(serviceBytes, requestMsg.getBytes(StandardCharsets.UTF_8)); + } + long mark1 = System.currentTimeMillis(); + double diff1 = 1e-3 * (mark1 - start); + System.err.printf("%d requests processed in %d ms -> %f op/s\n", count, mark1 - start, count / diff1); + + for (count = 0; count < N_SAMPLES; count++) { + ZMsg reply = clientSession.recv(); + if (count < 10 || count % 100_000 == 0 || count >= (N_SAMPLES - 10)) { + System.err.println("client iteration = " + count + " - received: " + reply); + } + if (reply == null) { + break; // Interrupt or failure + } + reply.destroy(); + } + long mark2 = System.currentTimeMillis(); + double diff2 = 1e-3 * (mark2 - start); + System.err.printf("%d requests/replies processed in %d ms -> %f op/s\n", count, mark2 - start, count / diff2); + clientSession.destroy(); + } +} diff --git a/concepts/src/test/java/io/opencmw/concepts/majordomo/MajordomoBrokerTests.java b/concepts/src/test/java/io/opencmw/concepts/majordomo/MajordomoBrokerTests.java new file mode 100644 index 00000000..bee46941 --- /dev/null +++ b/concepts/src/test/java/io/opencmw/concepts/majordomo/MajordomoBrokerTests.java @@ -0,0 +1,253 @@ +package io.opencmw.concepts.majordomo; + +import static org.junit.jupiter.api.Assertions.*; + +import static io.opencmw.concepts.majordomo.MajordomoProtocol.*; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import org.awaitility.Awaitility; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Test; +import org.zeromq.SocketType; +import org.zeromq.Utils; +import org.zeromq.ZMQ; +import org.zeromq.ZMsg; + +import io.opencmw.rbac.BasicRbacRole; +import io.opencmw.rbac.RbacToken; + +public class MajordomoBrokerTests { + private static final byte[] DEFAULT_RBAC_TOKEN = new RbacToken(BasicRbacRole.ADMIN, "HASHCODE").getBytes(); + private static final byte[] DEFAULT_MMI_SERVICE = "mmi.service".getBytes(StandardCharsets.UTF_8); + private static final byte[] DEFAULT_ECHO_SERVICE = "mmi.echo".getBytes(StandardCharsets.UTF_8); + private static final String DEFAULT_REQUEST_MESSAGE = "Hello World!"; + private static final byte[] DEFAULT_REQUEST_MESSAGE_BYTES = DEFAULT_REQUEST_MESSAGE.getBytes(StandardCharsets.UTF_8); + + @Test + public void basicLowLevelRequestReplyTest() throws InterruptedException, IOException { + MajordomoBroker broker = new MajordomoBroker(1, BasicRbacRole.values()); + // broker.setDaemon(true); // use this if running in another app that controls threads + final int openPort = Utils.findOpenPort(); + broker.bind("tcp://*:" + openPort); + assertFalse(broker.isRunning(), "broker not running"); + broker.start(); + assertTrue(broker.isRunning(), "broker running"); + // test interfaces + assertNotNull(broker.getContext()); + assertNotNull(broker.getInternalRouterSocket()); + assertNotNull(broker.getServices()); + assertEquals(2, broker.getServices().size()); + assertDoesNotThrow(() -> broker.addInternalService(new MajordomoWorker(broker.getContext(), "demoService"), 10)); + assertEquals(3, broker.getServices().size()); + assertDoesNotThrow(() -> broker.removeService("demoService")); + assertEquals(2, broker.getServices().size()); + + // wait until all services are initialised + Thread.sleep(200); + + final ZMQ.Socket clientSocket = broker.getContext().createSocket(SocketType.DEALER); + clientSocket.setIdentity("demoClient".getBytes(StandardCharsets.UTF_8)); + clientSocket.connect("tcp://localhost:" + openPort); + + // wait until client is connected + Thread.sleep(200); + + sendClientMessage(clientSocket, MdpClientCommand.C_UNKNOWN, null, DEFAULT_ECHO_SERVICE, DEFAULT_REQUEST_MESSAGE_BYTES); + + final MdpMessage reply = receiveMdpMessage(clientSocket); + assertNotNull(reply.toString()); + assertNotNull(reply, "reply message w/o RBAC token not being null"); + assertTrue(reply instanceof MdpClientMessage); + MdpClientMessage clientMessage = (MdpClientMessage) reply; + assertNull(clientMessage.senderID); // default dealer socket does not export sender ID (only ROUTER and/or enabled sockets) + assertEquals(MdpSubProtocol.C_CLIENT, clientMessage.protocol, "equal protocol"); + assertEquals(MdpClientCommand.C_UNKNOWN, clientMessage.command, "matching command"); + assertArrayEquals(DEFAULT_ECHO_SERVICE, clientMessage.serviceNameBytes, "equal service name"); + assertNotNull(clientMessage.payload, "user-data not being null"); + assertArrayEquals(DEFAULT_REQUEST_MESSAGE_BYTES, clientMessage.payload[0], "equal data"); + assertFalse(clientMessage.hasRbackToken()); + assertNotNull(clientMessage.getRbacFrame()); + assertArrayEquals(new byte[0], clientMessage.getRbacFrame()); + + broker.stopBroker(); + } + + @Test + public void basicSynchronousRequestReplyTest() throws InterruptedException, IOException { + MajordomoBroker broker = new MajordomoBroker(1, BasicRbacRole.values()); + // broker.setDaemon(true); // use this if running in another app that controls threads + final int openPort = Utils.findOpenPort(); + broker.bind("tcp://*:" + openPort); + broker.start(); + assertEquals(2, broker.getServices().size()); + + // add external (albeit inproc) Majordomo worker to the broker + MajordomoWorker internal = new MajordomoWorker(broker.getContext(), "inproc.echo", BasicRbacRole.ADMIN); + internal.registerHandler(input -> input); // output = input : echo service is complex :-) + internal.start(); + + // add external Majordomo worker to the broker + MajordomoWorker external = new MajordomoWorker(broker.getContext(), "ext.echo", BasicRbacRole.ADMIN); + external.registerHandler(input -> input); // output = input : echo service is complex :-) + external.start(); + + // add external (albeit inproc) Majordomo worker to the broker + MajordomoWorker exceptionService = new MajordomoWorker(broker.getContext(), "inproc.exception", BasicRbacRole.ADMIN); + exceptionService.registerHandler(input -> { throw new IllegalAccessError("autsch"); }); // allways throw an exception + exceptionService.start(); + + // wait until all services are initialised + Thread.sleep(200); + assertEquals(5, broker.getServices().size()); + + // using simple synchronous client + MajordomoClientV1 clientSession = new MajordomoClientV1("tcp://localhost:" + openPort, "customClientName"); + assertEquals(3, clientSession.getRetries()); + assertDoesNotThrow(() -> clientSession.setRetries(4)); + assertEquals(4, clientSession.getRetries()); + assertEquals(2500, clientSession.getTimeout()); + assertDoesNotThrow(() -> clientSession.setTimeout(2000)); + assertEquals(2000, clientSession.getTimeout()); + assertNotNull(clientSession.getUniqueID()); + + { + final byte[] serviceBytes = "mmi.echo".getBytes(StandardCharsets.UTF_8); + final ZMsg replyWithoutRbac = clientSession.send(serviceBytes, DEFAULT_REQUEST_MESSAGE_BYTES); // w/o RBAC + assertNotNull(replyWithoutRbac, "reply message w/o RBAC token not being null"); + assertNotNull(replyWithoutRbac.peekLast(), "user-data not being null"); + assertArrayEquals(DEFAULT_REQUEST_MESSAGE_BYTES, replyWithoutRbac.pollLast().getData(), "equal data"); + } + + { + final byte[] serviceBytes = "inproc.echo".getBytes(StandardCharsets.UTF_8); + final ZMsg replyWithoutRbac = clientSession.send(serviceBytes, DEFAULT_REQUEST_MESSAGE_BYTES); // w/o RBAC + assertNotNull(replyWithoutRbac, "reply message w/o RBAC token not being null"); + assertNotNull(replyWithoutRbac.peekLast(), "user-data not being null"); + assertArrayEquals(DEFAULT_REQUEST_MESSAGE_BYTES, replyWithoutRbac.pollLast().getData(), "equal data"); + } + + { + final byte[] serviceBytes = "ext.echo".getBytes(StandardCharsets.UTF_8); + final ZMsg replyWithoutRbac = clientSession.send(serviceBytes, DEFAULT_REQUEST_MESSAGE_BYTES); // w/o RBAC + assertNotNull(replyWithoutRbac, "reply message w/o RBAC token not being null"); + assertNotNull(replyWithoutRbac.peekLast(), "user-data not being null"); + assertArrayEquals(DEFAULT_REQUEST_MESSAGE_BYTES, replyWithoutRbac.pollLast().getData(), "equal data"); + } + + { + final byte[] serviceBytes = "inproc.exception".getBytes(StandardCharsets.UTF_8); + final ZMsg replyWithoutRbac = clientSession.send(serviceBytes, DEFAULT_REQUEST_MESSAGE_BYTES); // w/o RBAC + assertNotNull(replyWithoutRbac, "reply message w/o RBAC token not being null"); + assertNotNull(replyWithoutRbac.peekLast(), "user-data not being null"); + // future: check exception type + } + + { + final byte[] serviceBytes = "mmi.echo".getBytes(StandardCharsets.UTF_8); + final ZMsg replyWithRbac = clientSession.send(serviceBytes, DEFAULT_REQUEST_MESSAGE_BYTES, DEFAULT_RBAC_TOKEN); // with RBAC + assertNotNull(replyWithRbac, "reply message with RBAC token not being null"); + assertNotNull(replyWithRbac.peekLast(), "RBAC token not being null"); + assertArrayEquals(DEFAULT_RBAC_TOKEN, replyWithRbac.pollLast().getData(), "equal RBAC token"); + assertNotNull(replyWithRbac.peekLast(), "user-data not being null"); + assertArrayEquals(DEFAULT_REQUEST_MESSAGE_BYTES, replyWithRbac.pollLast().getData(), "equal data"); + } + + internal.stopWorker(); + external.stopWorker(); + exceptionService.stopWorker(); + broker.stopBroker(); + } + + @Test + public void basicMmiTests() throws IOException { + MajordomoBroker broker = new MajordomoBroker(1, BasicRbacRole.values()); + // broker.setDaemon(true); // use this if running in another app that controls threads + final int openPort = Utils.findOpenPort(); + broker.bind("tcp://*:" + openPort); + broker.start(); + + // using simple synchronous client + MajordomoClientV1 clientSession = new MajordomoClientV1("tcp://localhost:" + openPort, "customClientName"); + + { + final ZMsg replyWithoutRbac = clientSession.send("mmi.echo", DEFAULT_REQUEST_MESSAGE_BYTES); // w/o RBAC + assertNotNull(replyWithoutRbac, "reply message w/o RBAC token not being null"); + assertNotNull(replyWithoutRbac.peekLast(), "user-data not being null"); + assertArrayEquals(DEFAULT_REQUEST_MESSAGE_BYTES, replyWithoutRbac.pollLast().getData(), "MMI echo service request"); + } + + { + final ZMsg replyWithoutRbac = clientSession.send(DEFAULT_MMI_SERVICE, DEFAULT_MMI_SERVICE); // w/o RBAC + assertNotNull(replyWithoutRbac, "reply message w/o RBAC token not being null"); + assertNotNull(replyWithoutRbac.peekLast(), "user-data not being null"); + assertEquals("200", new String(replyWithoutRbac.pollLast().getData(), StandardCharsets.UTF_8), "known MMI service request"); + } + + { + final ZMsg replyWithoutRbac = clientSession.send(DEFAULT_MMI_SERVICE, DEFAULT_ECHO_SERVICE); // w/o RBAC + assertNotNull(replyWithoutRbac, "reply message w/o RBAC token not being null"); + assertNotNull(replyWithoutRbac.peekLast(), "user-data not being null"); + assertEquals("200", new String(replyWithoutRbac.pollLast().getData(), StandardCharsets.UTF_8), "known MMI service request"); + } + + { + // MMI service request: service should not exist + final ZMsg replyWithoutRbac = clientSession.send(DEFAULT_MMI_SERVICE, DEFAULT_REQUEST_MESSAGE_BYTES); // w/o RBAC + assertNotNull(replyWithoutRbac, "reply message w/o RBAC token not being null"); + assertNotNull(replyWithoutRbac.peekLast(), "user-data not being null"); + assertEquals("400", new String(replyWithoutRbac.pollLast().getData(), StandardCharsets.UTF_8), "unknown MMI service request"); + } + + { + // unknown service name + final ZMsg replyWithoutRbac = clientSession.send("unknownService".getBytes(StandardCharsets.UTF_8), DEFAULT_REQUEST_MESSAGE_BYTES); // w/o RBAC + assertNotNull(replyWithoutRbac, "reply message w/o RBAC token not being null"); + assertNotNull(replyWithoutRbac.peekLast(), "user-data not being null"); + assertEquals("501", new String(replyWithoutRbac.pollLast().getData()), "unknown service"); + } + + broker.stopBroker(); + } + + @Test + public void basicASynchronousRequestReplyTest() throws IOException { + MajordomoBroker broker = new MajordomoBroker(1, BasicRbacRole.values()); + // broker.setDaemon(true); // use this if running in another app that controls threads + final int openPort = Utils.findOpenPort(); + broker.bind("tcp://*:" + openPort); + broker.start(); + + final AtomicInteger counter = new AtomicInteger(0); + new Thread(() -> { + // using simple synchronous client + MajordomoClientV2 clientSession = new MajordomoClientV2("tcp://localhost:" + openPort); + assertEquals(2500, clientSession.getTimeout()); + assertDoesNotThrow(() -> clientSession.setTimeout(2000)); + assertEquals(2000, clientSession.getTimeout()); + + // send bursts of 10 messages + for (int i = 0; i < 5; i++) { + clientSession.send("mmi.echo", DEFAULT_REQUEST_MESSAGE_BYTES); + clientSession.send(DEFAULT_ECHO_SERVICE, DEFAULT_REQUEST_MESSAGE_BYTES); + } + + // send bursts of 10 messages + for (int i = 0; i < 10; i++) { + final ZMsg reply = clientSession.recv(); + assertNotNull(reply, "reply message w/o RBAC token not being null"); + assertNotNull(reply.peekLast(), "user-data not being null"); + assertArrayEquals(DEFAULT_REQUEST_MESSAGE_BYTES, reply.getLast().getData()); + counter.getAndIncrement(); + } + }).start(); + + Awaitility.await().alias("wait for reply messages").atMost(1, TimeUnit.SECONDS).until(counter::get, Matchers.equalTo(10)); + assertEquals(10, counter.get(), "received expected number of replies"); + + broker.stopBroker(); + } +} diff --git a/concepts/src/test/java/io/opencmw/concepts/majordomo/SimpleEchoServiceWorker.java b/concepts/src/test/java/io/opencmw/concepts/majordomo/SimpleEchoServiceWorker.java new file mode 100644 index 00000000..3e82007f --- /dev/null +++ b/concepts/src/test/java/io/opencmw/concepts/majordomo/SimpleEchoServiceWorker.java @@ -0,0 +1,21 @@ +package io.opencmw.concepts.majordomo; + +import io.opencmw.rbac.BasicRbacRole; + +/** +* Majordomo Protocol worker example. Uses the mdwrk API to hide all OpenCmwProtocol aspects +* +*/ +public class SimpleEchoServiceWorker { // NOPMD - nomen est omen + + private SimpleEchoServiceWorker() { + // private helper/test class + } + + public static void main(String[] args) { + MajordomoWorker workerSession = new MajordomoWorker("tcp://localhost:5556", "echo", BasicRbacRole.ADMIN); + // workerSession.setDaemon(true); // use this if running in another app that controls threads + workerSession.registerHandler(input -> input); // output = input : echo service is complex :-) + workerSession.start(); + } +} diff --git a/concepts/src/test/resources/simplelogger.properties b/concepts/src/test/resources/simplelogger.properties new file mode 100644 index 00000000..a01ef764 --- /dev/null +++ b/concepts/src/test/resources/simplelogger.properties @@ -0,0 +1,50 @@ +# SLF4J's SimpleLogger configuration file +# Simple implementation of Logger that sends all enabled log messages, for all defined loggers, to System.err. + +# Default logging detail level for all instances of SimpleLogger. +# Must be one of ("trace", "debug", "info", "warn", or "error"). +# If not specified, defaults to "info". +org.slf4j.simpleLogger.defaultLogLevel=debug + +# The output target which can be the path to a file, or the special values "System.out" and "System.err". +# Default is "System.err". +org.slf4j.simpleLogger.logFile=System.out + +# If the output target is set to "System.out" or "System.err" (see preceding entry), by default, +# logs will be output to the latest value referenced by System.out/err variables. +# By setting this parameter to true, the output stream will be cached, i.e. assigned once at initialization +# time and re-used independently of the current value referenced by System.out/err. +org.slf4j.simpleLogger.cacheOutputStream=true + +# Logging detail level for a SimpleLogger instance named "a.b.c". Right-side value must be one of +# "trace", "debug", "info", "warn", "error" or "off". When a SimpleLogger named "a.b.c" is initialized, +# its level is assigned from this property. If unspecified, the level of nearest parent logger will be used, +# and if none is set, then the value specified by org.slf4j.simpleLogger.defaultLogLevel will be used. +org.slf4j.simpleLogger.log.de.gsi.* + +# Logging detail level for a SimpleLogger instance named "xxxxx". +# Must be one of ("trace", "debug", "info", "warn", or "error"). +# If not specified, the default logging detail level is used. +#org.slf4j.simpleLogger.log.xxxxx= + +# Set to true if you want the current date and time to be included in output messages. +# Default is false, and will output the number of milliseconds elapsed since startup. +#org.slf4j.simpleLogger.showDateTime=false + +# The date and time format to be used in the output messages. +# The pattern describing the date and time format is the same that is used in java.text.SimpleDateFormat. +# If the format is not specified or is invalid, the default format is used. +# The default format is yyyy-MM-dd HH:mm:ss:SSS Z. +#org.slf4j.simpleLogger.dateTimeFormat=yyyy-MM-dd HH:mm:ss:SSS Z + +# Set to true if you want to output the current thread name. +# Defaults to true. +# org.slf4j.simpleLogger.showThreadName=false + +# Set to true if you want the Logger instance name to be included in output messages. +# Defaults to true. +#org.slf4j.simpleLogger.showLogName=true + +# Set to true if you want the last component of the name to be included in output messages. +# Defaults to false. +# org.slf4j.simpleLogger.showShortLogName=true \ No newline at end of file diff --git a/config/hooks/pre-commit b/config/hooks/pre-commit new file mode 100755 index 00000000..4201c858 --- /dev/null +++ b/config/hooks/pre-commit @@ -0,0 +1,59 @@ +#!/bin/bash +#enforces .clang-format style guide prior to committing to the git repository + +CLANG_MIN_VERSION="9.0.0" + +set -e + +REPO_ROOT_DIR="$(git rev-parse --show-toplevel)" +CLANG_FORMAT="$(command -v clang-format)" +CLANG_VERSION="$(${CLANG_FORMAT} --version | sed '/^clang-format version /!d;s///;s/-.*//;s///g')" + +compare_version () { + echo " " + if [[ $1 == $2 ]] + then + CLANG_MIN_VERSION_MATCH="=" + return + fi + local IFS=. + local i ver1=($1) ver2=($2) + # fill empty fields in ver1 with zeros + for ((i=${#ver1[@]}; i<${#ver2[@]}; i++)) + do + ver1[i]=0 + done + for ((i=0; i<${#ver1[@]}; i++)) + do + if [[ -z ${ver2[i]} ]] + then + # fill empty fields in ver2 with zeros + ver2[i]=0 + fi + if ((10#${ver1[i]} > 10#${ver2[i]})) + then + CLANG_MIN_VERSION_MATCH="<" + return + fi + if ((10#${ver1[i]} < 10#${ver2[i]})) + then + CLANG_MIN_VERSION_MATCH=">" + return + fi + done + CLANG_MIN_VERSION_MATCH="=" + return +} + +compare_version ${CLANG_MIN_VERSION} ${CLANG_VERSION} + +files=$((git diff --cached --name-only --diff-filter=ACMR | grep -Ei "\.(c|cc|cpp|cxx|c\+\+|h|hh|hpp|hxx|h\+\+|java)$") || true) +if [ -n "${files}" ]; then + + if [ -n "${CLANG_FORMAT}" ] && [ "$CLANG_MIN_VERSION_MATCH" != "<" ]; then + spaced_files=$(echo "$files" | paste -s -d " " -) + # echo "reformatting ${spaced_files}" + "${CLANG_FORMAT}" -style=file -i $spaced_files >/dev/null + fi + git add $(echo "$files" | paste -s -d " " -) +fi \ No newline at end of file diff --git a/config/hooks/pre-commit-stub b/config/hooks/pre-commit-stub new file mode 100755 index 00000000..d168dc2f --- /dev/null +++ b/config/hooks/pre-commit-stub @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +# stub pre-commit hook +# just a runner for the real pre-commit script +# if script cannot be found, exit without error +# (to not block local commits) + +set -e + +REPO_ROOT_DIR="$(git rev-parse --show-toplevel)" +PRE_COMMIT_SCRIPT="${REPO_ROOT_DIR}/config/hooks/pre-commit" + +if [ -f $PRE_COMMIT_SCRIPT ]; then + source $PRE_COMMIT_SCRIPT +fi \ No newline at end of file diff --git a/core/pom.xml b/core/pom.xml new file mode 100644 index 00000000..aaa10e58 --- /dev/null +++ b/core/pom.xml @@ -0,0 +1,51 @@ + + + 4.0.0 + + + io.opencmw + opencmw + ${revision}${sha1}${changelist} + ../pom.xml + + + core + + + ZeroMQ and REST-based micro-service implementation. + + + + + io.opencmw + serialiser + ${revision}${sha1}${changelist} + + + de.gsi.dataset + chartfx-dataset + ${version.chartfx} + true + test + + + org.zeromq + jeromq + ${version.jeromq} + + + com.lmax + disruptor + ${versions.lmax.disruptor} + + + + org.apache.commons + commons-lang3 + ${version.commons-lang3} + + + + + diff --git a/core/src/main/java/io/opencmw/AggregateEventHandler.java b/core/src/main/java/io/opencmw/AggregateEventHandler.java new file mode 100644 index 00000000..29acdd75 --- /dev/null +++ b/core/src/main/java/io/opencmw/AggregateEventHandler.java @@ -0,0 +1,359 @@ +package io.opencmw; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.LockSupport; +import java.util.function.Predicate; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.opencmw.filter.EvtTypeFilter; +import io.opencmw.filter.TimingCtx; +import io.opencmw.utils.Cache; +import io.opencmw.utils.NoDuplicatesList; +import io.opencmw.utils.SharedPointer; + +import com.lmax.disruptor.EventHandler; +import com.lmax.disruptor.RingBuffer; +import com.lmax.disruptor.Sequence; +import com.lmax.disruptor.SequenceReportingEventHandler; +import com.lmax.disruptor.TimeoutHandler; + +/** + * Dispatches aggregation workers upon seeing new values for a specified event field. + * Each aggregation worker then assembles all events for this value and optionally publishes back an aggregated events. + * If the aggregation is not completed within a configurable timeout, a partial AggregationEvent is published. + * + * For now events are aggregated into a list of Objects until a certain number of events is reached. + * The final api should allow to specify different Objects to be placed into a result domain object. + * + * @author Alexander Krimm + */ +@SuppressWarnings("PMD.LinguisticNaming") // fluent-style API with setter returning factory +public class AggregateEventHandler implements SequenceReportingEventHandler { + private static final Logger LOGGER = LoggerFactory.getLogger(AggregateEventHandler.class); + // private Map aggregatedBpcts = new SoftHashMap<>(RETENTION_SIZE); + protected final Map aggregatedBpcts; + private final RingBuffer ringBuffer; + private final long timeOut; + private final TimeUnit timeOutUnit; + private final int numberOfEventsToAggregate; + private final List deviceList; + private final List> evtTypeFilter; + private final InternalAggregationHandler[] aggregationHandler; + private final List freeWorkers; + private final String aggregateName; + private Sequence seq; + + private AggregateEventHandler(final RingBuffer ringBuffer, final String aggregateName, final long timeOut, final TimeUnit timeOutUnit, final int nWorkers, final int retentionSize, final List deviceList, List> evtTypeFilter) { // NOPMD NOSONAR -- number of arguments acceptable/ complexity handled by factory + this.ringBuffer = ringBuffer; + this.aggregateName = aggregateName; + this.timeOut = timeOut; + this.timeOutUnit = timeOutUnit; + + freeWorkers = Collections.synchronizedList(new ArrayList<>(nWorkers)); + aggregationHandler = new InternalAggregationHandler[nWorkers]; + for (int i = 0; i < nWorkers; i++) { + aggregationHandler[i] = new InternalAggregationHandler(); + freeWorkers.add(aggregationHandler[i]); + } + aggregatedBpcts = new Cache<>(retentionSize); + this.deviceList = deviceList; + this.evtTypeFilter = evtTypeFilter; + numberOfEventsToAggregate = deviceList.size() + evtTypeFilter.size(); + } + + public InternalAggregationHandler[] getAggregationHander() { + return aggregationHandler; + } + + @Override + public void onEvent(final RingBufferEvent event, final long nextSequence, final boolean b) { + final TimingCtx ctx = event.getFilter(TimingCtx.class); + if (ctx == null) { + return; + } + + // final boolean alreadyScheduled = Arrays.stream(workers).filter(w -> w.bpcts == eventBpcts).findFirst().isPresent(); + final boolean alreadyScheduled = aggregatedBpcts.containsKey(ctx.bpcts); + if (alreadyScheduled) { + return; + } + while (true) { + if (!freeWorkers.isEmpty()) { + final InternalAggregationHandler freeWorker = freeWorkers.remove(0); + freeWorker.bpcts = ctx.bpcts; + freeWorker.aggStart = event.arrivalTimeStamp; + aggregatedBpcts.put(ctx.bpcts, new Object()); // NOPMD - necessary to allocate inside loop + seq.set(nextSequence); // advance sequence to let workers process events up to here + return; + } + // no free worker available + long waitTimeNanos = Long.MAX_VALUE; + for (InternalAggregationHandler w : aggregationHandler) { + final long currentTime = System.currentTimeMillis(); + final long diffMillis = currentTime - w.aggStart; + waitTimeNanos = Math.min(waitTimeNanos, TimeUnit.MILLISECONDS.toNanos(diffMillis)); + if (w.bpcts != -1 && diffMillis < timeOutUnit.toMillis(timeOut)) { + w.publishAndFreeWorker(EvtTypeFilter.UpdateType.PARTIAL); // timeout reached, publish partial result and free worker + break; + } + } + LockSupport.parkNanos(waitTimeNanos); + } + } + + @Override + public void setSequenceCallback(final Sequence sequence) { + this.seq = sequence; + } + + public static AggregateEventHandlerFactory getFactory() { + return new AggregateEventHandlerFactory(); + } + + public static class AggregateEventHandlerFactory { + private final List deviceList = new NoDuplicatesList<>(); + private final List> evtTypeFilter = new NoDuplicatesList<>(); + private RingBuffer ringBuffer; + private int numberWorkers = 4; // number of workers defines the maximum number of aggregate events groups which can be overlapping + private long timeOut = 400; + private TimeUnit timeOutUnit = TimeUnit.MILLISECONDS; + private int retentionSize = 12; + private String aggregateName; + + public AggregateEventHandler build() { + if (aggregateName == null || aggregateName.isBlank()) { + throw new IllegalArgumentException("aggregateName must not be null or blank"); + } + if (ringBuffer == null) { + throw new IllegalArgumentException("ringBuffer must not be null"); + } + final int actualRetentionSize = Math.min(retentionSize, 3 * numberWorkers); + return new AggregateEventHandler(ringBuffer, aggregateName, timeOut, timeOutUnit, numberWorkers, actualRetentionSize, deviceList, evtTypeFilter); + } + + public String getAggregateName() { + return aggregateName; + } + + public AggregateEventHandlerFactory setAggregateName(final String aggregateName) { + this.aggregateName = aggregateName; + return this; + } + + public List getDeviceList() { + return deviceList; + } + + /** + * + * @param deviceList lists of devices, event names, etc. that shall be aggregated + * @return itself (fluent design) + */ + public AggregateEventHandlerFactory setDeviceList(final List deviceList) { + this.deviceList.addAll(deviceList); + return this; + } + + /** + * + * @param devices single or lists of devices, event names, etc. that shall be aggregated + * @return itself (fluent design) + */ + public AggregateEventHandlerFactory setDeviceList(final String... devices) { + this.deviceList.addAll(Arrays.asList(devices)); + return this; + } + + public List> getEvtTypeFilter() { + return evtTypeFilter; + } + + /** + * + * @param evtTypeFilter single or lists of predicate filters of events that shall be aggregated + * @return itself (fluent design) + */ + @SafeVarargs + public final AggregateEventHandlerFactory setEvtTypeFilter(final Predicate... evtTypeFilter) { + this.evtTypeFilter.addAll(Arrays.asList(evtTypeFilter)); + return this; + } + + /** + * + * @param evtTypeFilter single or lists of predicate filters of events that shall be aggregated + * @return itself (fluent design) + */ + public AggregateEventHandlerFactory setEvtTypeFilter(final List> evtTypeFilter) { + this.evtTypeFilter.addAll(evtTypeFilter); + return this; + } + + /** + * @return number of workers defines the maximum number of aggregate events groups which can be overlapping + */ + public int getNumberWorkers() { + return numberWorkers; + } + + /** + * + * @param numberWorkers number of workers defines the maximum number of aggregate events groups which can be overlapping + * @return itself (fluent design) + */ + public AggregateEventHandlerFactory setNumberWorkers(final int numberWorkers) { + if (numberWorkers < 1) { + throw new IllegalArgumentException("numberWorkers must not be < 1: " + numberWorkers); + } + this.numberWorkers = numberWorkers; + return this; + } + + public int getRetentionSize() { + return retentionSize; + } + + public AggregateEventHandlerFactory setRetentionSize(final int retentionSize) { + if (retentionSize < 1) { + throw new IllegalArgumentException("timeOut must not be < 1: " + retentionSize); + } + this.retentionSize = retentionSize; + return this; + } + + public RingBuffer getRingBuffer() { + return ringBuffer; + } + + public AggregateEventHandlerFactory setRingBuffer(final RingBuffer ringBuffer) { + if (ringBuffer == null) { + throw new IllegalArgumentException("ringBuffer must not null"); + } + this.ringBuffer = ringBuffer; + return this; + } + + public long getTimeOut() { + return timeOut; + } + + public TimeUnit getTimeOutUnit() { + return timeOutUnit; + } + + public AggregateEventHandlerFactory setTimeOut(final long timeOut, final TimeUnit timeOutUnit) { + if (timeOut <= 0) { + throw new IllegalArgumentException("timeOut must not be <=0: " + timeOut); + } + if (timeOutUnit == null) { + throw new IllegalArgumentException("timeOutUnit must not null"); + } + this.timeOut = timeOut; + this.timeOutUnit = timeOutUnit; + return this; + } + } + + @SuppressWarnings("PMD.AvoidUsingVolatile") // cache-specific usage here + protected class InternalAggregationHandler implements EventHandler, TimeoutHandler { + protected volatile long bpcts = -1; // [ms] + protected volatile long aggStart = -1; // [ns] + protected List aggregatedEventsStash = new ArrayList<>(); + + @Override + public void onEvent(final RingBufferEvent event, final long sequence, final boolean endOfBatch) { + if (bpcts != -1 && event.arrivalTimeStamp > aggStart + timeOutUnit.toMillis(timeOut)) { + publishAndFreeWorker(EvtTypeFilter.UpdateType.PARTIAL); + return; + } + final TimingCtx ctx = event.getFilter(TimingCtx.class); + if (bpcts == -1 || ctx == null || ctx.bpcts != bpcts) { + return; // skip irrelevant events + } + final EvtTypeFilter evtType = event.getFilter(EvtTypeFilter.class); + if (evtType == null) { + throw new IllegalArgumentException("cannot aggregate events without ring buffer containing EvtTypeFilter"); + } + if ((!deviceList.isEmpty() && !deviceList.contains(evtType.typeName)) || (!evtTypeFilter.isEmpty() && evtTypeFilter.stream().noneMatch(filter -> filter.test(event)))) { + return; + } + + aggregatedEventsStash.add(event); + if (aggregatedEventsStash.size() == numberOfEventsToAggregate) { + publishAndFreeWorker(EvtTypeFilter.UpdateType.COMPLETE); + } + } + + @Override + public void onTimeout(final long sequence) { + if (bpcts != -1 && System.currentTimeMillis() > aggStart + timeOut) { + publishAndFreeWorker(EvtTypeFilter.UpdateType.PARTIAL); + } + } + + protected void publishAndFreeWorker(final EvtTypeFilter.UpdateType updateType) { + final long now = System.currentTimeMillis(); + ringBuffer.publishEvent(((event, sequence, arg0) -> { + final TimingCtx ctx = event.getFilter(TimingCtx.class); + if (ctx == null) { + throw new IllegalStateException("RingBufferEvent has not TimingCtx definition"); + } + final EvtTypeFilter evtType = event.getFilter(EvtTypeFilter.class); + if (evtType == null) { + throw new IllegalArgumentException("cannot aggregate events without ring buffer containing EvtTypeFilter"); + } + + // write header/meta-type data + event.arrivalTimeStamp = now; + event.payload = new SharedPointer<>(); + final Map> aggregatedItems = new HashMap<>(); // + event.payload.set(aggregatedItems); + if (updateType == EvtTypeFilter.UpdateType.PARTIAL) { + LOGGER.atInfo().log("aggregation timed out for bpcts: " + bpcts); + } + + if (aggregatedEventsStash.isEmpty()) { + // notify empty aggregate + event.parentSequenceNumber = sequence; + evtType.typeName = aggregateName; + evtType.updateType = EvtTypeFilter.UpdateType.EMPTY; + evtType.evtType = EvtTypeFilter.DataType.AGGREGATE_DATA; + return; + } + + // handle non-empty aggregate + final RingBufferEvent firstItem = aggregatedEventsStash.get(0); + for (int i = 0; i < firstItem.filters.length; i++) { + firstItem.filters[i].copyTo(event.filters[i]); + } + if (updateType == EvtTypeFilter.UpdateType.PARTIAL) { + LOGGER.atInfo().log("aggregation timed out for 2:bpcts: " + event.getFilter(TimingCtx.class).bpcts); + } + evtType.typeName = aggregateName; + evtType.updateType = updateType; + evtType.evtType = EvtTypeFilter.DataType.AGGREGATE_DATA; + event.parentSequenceNumber = sequence; + + // add new events to payload + for (RingBufferEvent rbEvent : aggregatedEventsStash) { + final EvtTypeFilter type = rbEvent.getFilter(EvtTypeFilter.class); + aggregatedItems.put(type.typeName, rbEvent.payload.getCopy()); + } + }), + aggregatedEventsStash); + + // init worker for next aggregation iteration + bpcts = -1; + aggregatedEventsStash = new ArrayList<>(); + freeWorkers.add(this); + } + } +} diff --git a/core/src/main/java/io/opencmw/EventStore.java b/core/src/main/java/io/opencmw/EventStore.java new file mode 100644 index 00000000..bf7b93bb --- /dev/null +++ b/core/src/main/java/io/opencmw/EventStore.java @@ -0,0 +1,450 @@ +package io.opencmw; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; +import java.util.function.Function; +import java.util.function.Predicate; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.opencmw.utils.Cache; +import io.opencmw.utils.LimitedArrayList; +import io.opencmw.utils.NoDuplicatesList; +import io.opencmw.utils.WorkerThreadFactory; + +import com.lmax.disruptor.BlockingWaitStrategy; +import com.lmax.disruptor.EventHandler; +import com.lmax.disruptor.RingBuffer; +import com.lmax.disruptor.TimeoutBlockingWaitStrategy; +import com.lmax.disruptor.WaitStrategy; +import com.lmax.disruptor.dsl.Disruptor; +import com.lmax.disruptor.dsl.EventHandlerGroup; +import com.lmax.disruptor.dsl.ProducerType; +import com.lmax.disruptor.util.Util; + +/** + * Initial event-source concept with one primary event-stream and arbitrary number of secondary context-multiplexed event-streams. + * + * Each event-stream is implemented using LMAX's disruptor ring-buffer using the default {@link RingBufferEvent}. + * + * The multiplexing-context for the secondary ring buffers is controlled via the 'Function<RingBufferEvent, String> muxCtxFunction' + * function that produces a unique string hash for a given ring buffer event, e.g.: + * {@code Function muxCtx = evt -> "cid=" + evt.getFilter(TimingCtx.class).cid;} + * + * See EventStoreTest in the teest directory for usage and API examples. + * + * @author rstein + */ +public class EventStore { + private static final Logger LOGGER = LoggerFactory.getLogger(EventStore.class); + private static final String NOT_FOUND_FOR_MULTIPLEXING_CONTEXT = "disruptor not found for multiplexing context = "; + protected final WorkerThreadFactory threadFactory; + protected final List listener = new NoDuplicatesList<>(); + protected final List> allEventHandlers = new NoDuplicatesList<>(); + protected final List> muxCtxFunctions = new NoDuplicatesList<>(); + protected final Cache> eventStreams; + protected final Disruptor disruptor; + protected final int lengthHistoryBuffer; + protected Function> ctxMappingFunction; + + /** + * + * @param filterConfig static filter configuration + */ + @SafeVarargs + protected EventStore(final Cache.CacheBuilder> muxBuilder, final Function muxCtxFunction, final int ringBufferSize, final int lengthHistoryBuffer, final int maxThreadNumber, final boolean isSingleProducer, final WaitStrategy waitStrategy, final Class... filterConfig) { // NOPMD NOSONAR - handled by factory + assert filterConfig != null; + if (muxCtxFunction != null) { + this.muxCtxFunctions.add(muxCtxFunction); + } + this.lengthHistoryBuffer = lengthHistoryBuffer; + this.threadFactory = new WorkerThreadFactory(EventStore.class.getSimpleName() + "Worker", maxThreadNumber); + this.disruptor = new Disruptor<>(() -> new RingBufferEvent(filterConfig), ringBufferSize, threadFactory, isSingleProducer ? ProducerType.SINGLE : ProducerType.MULTI, waitStrategy); + final BiConsumer> clearCacheElement = (muxCtx, d) -> { + d.shutdown(); + final RingBuffer rb = d.getRingBuffer(); + for (long i = rb.getMinimumGatingSequence(); i < rb.getCursor(); i++) { + rb.get(i).clear(); + } + }; + this.eventStreams = muxBuilder == null ? Cache.>builder().withPostListener(clearCacheElement).build() : muxBuilder.build(); + + this.ctxMappingFunction = ctx -> { + // mux contexts -> create copy into separate disruptor/ringbuffer if necessary + // N.B. only single writer ... no further post-processors (all done in main eventStream) + final Disruptor ld = new Disruptor<>(() -> new RingBufferEvent(filterConfig), ringBufferSize, threadFactory, ProducerType.SINGLE, new BlockingWaitStrategy()); + ld.start(); + return ld; + }; + } + + public Disruptor getDisruptor() { + return disruptor; + } + + public List getHistory(final String muxCtx, final Predicate predicate, final int nHistory) { + return getHistory(muxCtx, predicate, Long.MAX_VALUE, nHistory); + } + + public List getHistory(final String muxCtx, final Predicate predicate, final long sequence, final int nHistory) { + assert muxCtx != null && !muxCtx.isBlank(); + assert sequence >= 0 : "sequence = " + sequence; + assert nHistory > 0 : "nHistory = " + nHistory; + final Disruptor localDisruptor = eventStreams.computeIfAbsent(muxCtx, ctxMappingFunction); + assert localDisruptor != null : NOT_FOUND_FOR_MULTIPLEXING_CONTEXT + muxCtx; + final RingBuffer ringBuffer = localDisruptor.getRingBuffer(); + + // simple consistency checks + final long cursor = ringBuffer.getCursor(); + assert cursor >= 0 : "uninitialised cursor: " + cursor; + assert nHistory < ringBuffer.getBufferSize() + : (" nHistory == " + nHistory + " history = new ArrayList<>(nHistory); + long seqStart = Math.max(cursor - ringBuffer.getBufferSize() - 1, 0); + for (long seq = cursor; history.size() < nHistory && seqStart <= seq; seq--) { + final RingBufferEvent evt = ringBuffer.get(seq); + if (evt.parentSequenceNumber <= sequence && predicate.test(evt)) { + history.add(evt); + } + } + return history; + } + + public Optional getLast(final String muxCtx, final Predicate predicate) { + return getLast(muxCtx, predicate, Long.MAX_VALUE); + } + + public Optional getLast(final String muxCtx, final Predicate predicate, final long sequence) { + assert muxCtx != null && !muxCtx.isBlank(); + final Disruptor localDisruptor = eventStreams.computeIfAbsent(muxCtx, ctxMappingFunction); + assert localDisruptor != null : NOT_FOUND_FOR_MULTIPLEXING_CONTEXT + muxCtx; + final RingBuffer ringBuffer = localDisruptor.getRingBuffer(); + assert ringBuffer.getCursor() > 0 : "uninitialised cursor: " + ringBuffer.getCursor(); + + // search for the most recent element that matches the provided predicate + long seqStart = Math.max(ringBuffer.getCursor() - ringBuffer.getBufferSize() - 1, 0); + for (long seq = ringBuffer.getCursor(); seqStart <= seq; seq--) { + final RingBufferEvent evt = ringBuffer.get(seq); + if (evt.parentSequenceNumber <= sequence && predicate.test(evt)) { + return Optional.of(evt.clone()); + } + } + return Optional.empty(); + } + + public RingBuffer getRingBuffer() { + return disruptor.getRingBuffer(); + } + + @SafeVarargs + public final LocalEventHandlerGroup register(final EventHandler... eventHandler) { + final LocalEventHandlerGroup group = new LocalEventHandlerGroup(lengthHistoryBuffer, eventHandler); + listener.add(group); + return group; + } + + public final LocalEventHandlerGroup register(final Predicate filter, Function muxCtxFunction, final HistoryEventHandler... eventHandler) { + final LocalEventHandlerGroup group = new LocalEventHandlerGroup(lengthHistoryBuffer, filter, muxCtxFunction, eventHandler); + listener.add(group); + return group; + } + + public void start(final boolean startReaper) { + // create single writer that is always executed first + EventHandler muxCtxWriter = (evt, seq, batch) -> { + for (Function muxCtxFunc : muxCtxFunctions) { + final String muxCtx = muxCtxFunc.apply(evt); + // only single writer ... no further post-processors (all done in main eventStream) + final Disruptor localDisruptor = eventStreams.computeIfAbsent(muxCtx, ctxMappingFunction); + assert localDisruptor != null : NOT_FOUND_FOR_MULTIPLEXING_CONTEXT + muxCtx; + + if (!localDisruptor.getRingBuffer().tryPublishEvent((event, sequence) -> { + if (event.payload != null && event.payload.getReferenceCount() > 0) { + event.payload.release(); + } + evt.copyTo(event); + })) { + throw new IllegalStateException("could not write event, sequence = " + seq + " muxCtx = " + muxCtx); + } + } + }; + allEventHandlers.add(muxCtxWriter); + EventHandlerGroup handlerGroup = disruptor.handleEventsWith(muxCtxWriter); + + // add other handler + for (LocalEventHandlerGroup localHandlerGroup : listener) { + attachHandler(disruptor, handlerGroup, localHandlerGroup); + } + + assert handlerGroup != null; + @SuppressWarnings("unchecked") + EventHandler[] eventHanders = allEventHandlers.toArray(new EventHandler[0]); + if (startReaper) { + // start the reaper thread for this given ring buffer + disruptor.after(eventHanders).then(new RingBufferEvent.ClearEventHandler()); + } + + // register this event store to all DefaultHistoryEventHandler + for (EventHandler handler : allEventHandlers) { + if (handler instanceof DefaultHistoryEventHandler) { + ((DefaultHistoryEventHandler) handler).setEventStore(this); + } + } + + disruptor.start(); + } + + public void start() { + this.start(true); + } + + public void stop() { + disruptor.shutdown(); + } + + protected EventHandlerGroup attachHandler(final Disruptor disruptor, final EventHandlerGroup parentGroup, final LocalEventHandlerGroup localHandlerGroup) { + EventHandlerGroup handlerGroup; + @SuppressWarnings("unchecked") + EventHandler[] eventHanders = localHandlerGroup.handler.toArray(new EventHandler[0]); + allEventHandlers.addAll(localHandlerGroup.handler); + if (parentGroup == null) { + handlerGroup = disruptor.handleEventsWith(eventHanders); + } else { + handlerGroup = parentGroup.then(eventHanders); + } + + if (localHandlerGroup.dependent != null && !localHandlerGroup.handler.isEmpty()) { + handlerGroup = attachHandler(disruptor, handlerGroup, localHandlerGroup.dependent); + } + + return handlerGroup; + } + + public static EventStoreFactory getFactory() { + return new EventStoreFactory(); + } + + public static class LocalEventHandlerGroup { + protected final List> handler = new NoDuplicatesList<>(); + protected final int lengthHistoryBuffer; + protected LocalEventHandlerGroup dependent; + + @SafeVarargs + private LocalEventHandlerGroup(final int lengthHistoryBuffer, final EventHandler... eventHandler) { + assert eventHandler != null; + this.lengthHistoryBuffer = lengthHistoryBuffer; + handler.addAll(Arrays.asList(eventHandler)); + } + + private LocalEventHandlerGroup(final int lengthHistoryBuffer, final Predicate filter, Function muxCtxFunction, final HistoryEventHandler... eventHandlerCallbacks) { + assert eventHandlerCallbacks != null; + this.lengthHistoryBuffer = lengthHistoryBuffer; + for (final HistoryEventHandler callback : eventHandlerCallbacks) { + handler.add(new DefaultHistoryEventHandler(null, filter, muxCtxFunction, lengthHistoryBuffer, callback)); // NOPMD - necessary to allocate inside loop + } + } + + @SafeVarargs + public final LocalEventHandlerGroup and(final EventHandler... eventHandler) { + assert eventHandler != null; + handler.addAll(Arrays.asList(eventHandler)); + return this; + } + + public final LocalEventHandlerGroup and(final Predicate filter, Function muxCtxFunction, final HistoryEventHandler... eventHandlerCallbacks) { + assert eventHandlerCallbacks != null; + for (final HistoryEventHandler callback : eventHandlerCallbacks) { + handler.add(new DefaultHistoryEventHandler(null, filter, muxCtxFunction, lengthHistoryBuffer, callback)); // NOPMD - necessary to allocate inside loop + } + return this; + } + + @SafeVarargs + public final LocalEventHandlerGroup then(final EventHandler... eventHandler) { + return (dependent = new LocalEventHandlerGroup(lengthHistoryBuffer, eventHandler)); // NOPMD NOSONAR + } + + public final LocalEventHandlerGroup then(final Predicate filter, Function muxCtxFunction, final HistoryEventHandler... eventHandlerCallbacks) { + return (dependent = new LocalEventHandlerGroup(lengthHistoryBuffer, filter, muxCtxFunction, eventHandlerCallbacks)); // NOPMD NOSONAR + } + } + + public static class EventStoreFactory { + private boolean singleProducer; + private int maxThreadNumber = 4; + private int ringbufferSize = 64; + private int lengthHistoryBuffer = 10; + private Cache.CacheBuilder> muxBuilder; + private Function muxCtxFunction; + private WaitStrategy waitStrategy = new TimeoutBlockingWaitStrategy(100, TimeUnit.MILLISECONDS); + @SuppressWarnings("unchecked") + private Class[] filterConfig = new Class[0]; + + public EventStore build() { + if (muxBuilder == null) { + muxBuilder = Cache.>builder().withLimit(lengthHistoryBuffer); + } + return new EventStore(muxBuilder, muxCtxFunction, ringbufferSize, lengthHistoryBuffer, maxThreadNumber, singleProducer, waitStrategy, filterConfig); + } + + public Class[] getFilterConfig() { + return filterConfig; + } + + @SafeVarargs + @SuppressWarnings("PMD.ArrayIsStoredDirectly") + public final EventStoreFactory setFilterConfig(final Class... filterConfig) { + if (filterConfig == null) { + throw new IllegalArgumentException("filterConfig is null"); + } + this.filterConfig = filterConfig; + return this; + } + + public int getLengthHistoryBuffer() { + return lengthHistoryBuffer; + } + + public EventStoreFactory setLengthHistoryBuffer(final int lengthHistoryBuffer) { + if (lengthHistoryBuffer < 0) { + throw new IllegalArgumentException("lengthHistoryBuffer < 0: " + lengthHistoryBuffer); + } + + this.lengthHistoryBuffer = lengthHistoryBuffer; + return this; + } + + public int getMaxThreadNumber() { + return maxThreadNumber; + } + + public EventStoreFactory setMaxThreadNumber(final int maxThreadNumber) { + this.maxThreadNumber = maxThreadNumber; + return this; + } + + public Cache.CacheBuilder> getMuxBuilder() { + return muxBuilder; + } + + public EventStoreFactory setMuxBuilder(final Cache.CacheBuilder> muxBuilder) { + this.muxBuilder = muxBuilder; + return this; + } + + public Function getMuxCtxFunction() { + return muxCtxFunction; + } + + public EventStoreFactory setMuxCtxFunction(final Function muxCtxFunction) { + this.muxCtxFunction = muxCtxFunction; + return this; + } + + public int getRingbufferSize() { + return ringbufferSize; + } + + public EventStoreFactory setRingbufferSize(final int ringbufferSize) { + if (ringbufferSize < 0) { + throw new IllegalArgumentException("lengthHistoryBuffer < 0: " + ringbufferSize); + } + final int rounded = Util.ceilingNextPowerOfTwo(ringbufferSize - 1); + if (ringbufferSize != rounded) { + LOGGER.atWarn().addArgument(ringbufferSize).addArgument(rounded).log("setRingbufferSize({}) is not a power of two setting to next power of two: {}"); + this.ringbufferSize = rounded; + return this; + } + + this.ringbufferSize = ringbufferSize; + return this; + } + + public WaitStrategy getWaitStrategy() { + return waitStrategy; + } + + public EventStoreFactory setWaitStrategy(final WaitStrategy waitStrategy) { + this.waitStrategy = waitStrategy; + return this; + } + + public boolean isSingleProducer() { + return singleProducer; + } + + public EventStoreFactory setSingleProducer(final boolean singleProducer) { + this.singleProducer = singleProducer; + return this; + } + } + + protected static class DefaultHistoryEventHandler implements EventHandler { + private final Predicate filter; + private final Function muxCtxFunction; + private final HistoryEventHandler callback; + private final int lengthHistoryBuffer; + private EventStore eventStore; + private Cache> historyCache; + + protected DefaultHistoryEventHandler(final EventStore eventStore, final Predicate filter, Function muxCtxFunction, final int lengthHistoryBuffer, final HistoryEventHandler callback) { + assert filter != null : "filter predicate is null"; + assert muxCtxFunction != null : "muxCtxFunction hash function is null"; + assert callback != null : "callback function must not be null"; + + this.eventStore = eventStore; + this.filter = filter; + this.muxCtxFunction = muxCtxFunction; + this.lengthHistoryBuffer = lengthHistoryBuffer; + this.callback = callback; + } + + @Override + public void onEvent(final RingBufferEvent event, final long sequence, final boolean endOfBatch) { + if (!filter.test(event)) { + return; + } + final String muxCtx = muxCtxFunction.apply(event); + final LimitedArrayList history = historyCache.computeIfAbsent(muxCtx, ctx -> new LimitedArrayList<>(lengthHistoryBuffer)); + + final RingBufferEvent eventCopy = event.clone(); + if (history.size() == history.getLimit()) { + final RingBufferEvent removedEvent = history.remove(history.size() - 1); + removedEvent.clear(); + } + history.add(0, eventCopy); + + final RingBufferEvent result; + try { + result = callback.onEvent(history, eventStore, sequence, endOfBatch); + } catch (Exception e) { // NOPMD - part of exception handling/forwarding scheme + LOGGER.atError().setCause(e).addArgument(history.size()).addArgument(sequence).addArgument(endOfBatch) // + .log("caught error for arguments (history={}, eventStore, sequence={}, endOfBatch={})"); + event.throwables.add(e); + return; + } + if (result == null) { + return; + } + eventStore.getRingBuffer().publishEvent((newEvent, newSequence) -> { + result.copyTo(newEvent); + newEvent.parentSequenceNumber = newSequence; + }); + } + + private void setEventStore(final EventStore eventStore) { + this.eventStore = eventStore; + // allocate cache + final BiConsumer> clearCacheElement = (muxCtx, history) -> history.forEach(RingBufferEvent::clear); + final Cache c = eventStore.eventStreams; // re-use existing config limits + historyCache = Cache.>builder().withLimit((int) c.getLimit()).withTimeout(c.getTimeout(), c.getTimeUnit()).withPostListener(clearCacheElement).build(); + } + } +} diff --git a/core/src/main/java/io/opencmw/Filter.java b/core/src/main/java/io/opencmw/Filter.java new file mode 100644 index 00000000..89c96216 --- /dev/null +++ b/core/src/main/java/io/opencmw/Filter.java @@ -0,0 +1,31 @@ +package io.opencmw; + +/** + * Basic filter interface description + * + * @author rstein + * N.B. while 'toString()', 'hashCode()' and 'equals()' is ubiquously defined via the Java 'Object' class, these definition are kept for symmetry with the C++ implementation + */ +public interface Filter { + /** + * reinitialises the filter to safe default values + */ + void clear(); + + /** + * @param other filter this filter should copy its data to + */ + void copyTo(Filter other); + + @Override + boolean equals(Object other); + + @Override + int hashCode(); + + /** + * @return filter description including internal state (if any). + */ + @Override + String toString(); +} diff --git a/core/src/main/java/io/opencmw/FilterPredicate.java b/core/src/main/java/io/opencmw/FilterPredicate.java new file mode 100644 index 00000000..c9dee56c --- /dev/null +++ b/core/src/main/java/io/opencmw/FilterPredicate.java @@ -0,0 +1,40 @@ +package io.opencmw; + +import java.util.function.Predicate; + +//@FunctionalInterface +public interface FilterPredicate { + /** + * Evaluates this predicate on the given arguments. + * + * @param filterClass the filter class + * @param filterPredicate the filter predicate object + * @return {@code true} if the input arguments match the predicate, otherwise {@code false} + */ + boolean test(Class filterClass, Predicate filterPredicate); + + // /** + // * @param other a filter predicate that will be logically-ANDed with this predicate + // * @return a composed predicate that represents the short-circuiting logical AND of this predicate and the {@code other} predicate + // */ + // FilterPredicate and(FilterPredicate other); + + // /** + // * Returns a predicate that represents the logical negation of this + // * predicate. + // * + // * @return a predicate that represents the logical negation of this predicate + // */ + // FilterPredicate negate(); + + // + // /** + // * @param other a predicate that will be logically-ORed with this predicate + // * @return a composed predicate that represents the short-circuiting logical OR of this predicate and the {@code other} predicate + // * @throws NullPointerException if other is null + // */ + // default FilterPredicate or(FilterPredicate other) { + // Objects.requireNonNull(other); + // return (t, u) -> test(t, u) || other.test(t, u); + // } +} \ No newline at end of file diff --git a/core/src/main/java/io/opencmw/HistoryEventHandler.java b/core/src/main/java/io/opencmw/HistoryEventHandler.java new file mode 100644 index 00000000..0fb440f7 --- /dev/null +++ b/core/src/main/java/io/opencmw/HistoryEventHandler.java @@ -0,0 +1,23 @@ +package io.opencmw; + +import java.util.List; + +import com.lmax.disruptor.RingBuffer; + +@SuppressWarnings("PMD.SignatureDeclareThrowsException") // nature of this interface that it may and can throw any exception that needs to be dealt with upstream +public interface HistoryEventHandler { + /** + * Called when a publisher has published a new event to the {@link EventStore}. + * + * N.B. this is a delegate handler based on the {@link com.lmax.disruptor.EventHandler}. + * + * @param events RingBufferEvent history published to the {@link EventStore}. Newest element is stored in '0' + * @param eventStore handler to superordinate {@link EventStore} and {@link RingBuffer} + * @param sequence of the event being processed + * @param endOfBatch flag to indicate if this is the last event in a batch from the {@link EventStore} + * @return optional return element that publishes (if non-null) the new processed event in to the primary event stream + * + * @throws Exception if the EventHandler would like the exception handled further up the chain. (N.B. no further event is being published) + */ + RingBufferEvent onEvent(final List events, final EventStore eventStore, final long sequence, final boolean endOfBatch) throws Exception; +} diff --git a/core/src/main/java/io/opencmw/MimeType.java b/core/src/main/java/io/opencmw/MimeType.java new file mode 100644 index 00000000..00571a8d --- /dev/null +++ b/core/src/main/java/io/opencmw/MimeType.java @@ -0,0 +1,242 @@ +package io.opencmw; + +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +import com.jsoniter.spi.JsoniterSpi; + +/** + * Definition and convenience methods for common MIME types according to RFC6838 and RFC4855 + *

+ * Since the official list is rather long, contains types we likely never encounter, and also does not contain all + * unofficial but nevertheless commonly used MIME types, we chose the specific sub-selection from: + * https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types + * + * @author rstein + * + */ +public enum MimeType { + /* text MIME types */ + CSS("text/css", "Cascading Style Sheets (CSS)", ".css"), + CSV("text/csv", "Comma-separated values (CSV)", ".csv"), + EVENT_STREAM("text/event-stream", "SSE stream"), + HTML("text/html", "HyperText Markup Language (HTML)", ".htm", ".html"), + ICS("text/calendar", "iCalendar format", ".ics"), + JAVASCRIPT("text/javascript", "JavaScript", ".js", ".mjs"), + JSON("application/json", "JSON format", ".json"), + JSON_LD("application/ld+json", "JSON-LD format", ".jsonld"), + TEXT("text/plain", "Text, (generally ASCII or ISO 8859-n)", ".txt"), + XML("text/xml", "XML", ".xml"), // if readable from casual users (RFC 3023, section 3) + YAML("text/yaml", "YAML Ain't Markup Language File", ".yml", ".yaml"), // not yet an IANA standard + + /* audio MIME types */ + AAC("audio/aac", "AAC audio", ".aac"), + MIDI("audio/midi", "Musical Instrument Digital Interface (MIDI)", ".mid", ".midi"), + MP3("audio/mpeg", "MP3 audio", ".mp3"), + OTF("audio/opus", "Opus audio", ".opus"), + WAV("audio/wav", "Waveform Audio Format", ".wav"), + WEBM_AUDIO("audio/webm", "WEBM audio", ".weba"), + + /* image MIME types */ + BMP("image/bmp", "Windows OS/2 Bitmap Graphics", ".bmp"), + GIF("image/gif", "Graphics Interchange Format (GIF)", ".gif"), + ICO("image/vnd.microsoft.icon", "Icon format", ".ico"), + JPEG("image/jpeg", "JPEG images", ".jpg", ".jpeg"), + PNG("image/png", "Portable Network Graphics", ".png"), + APNG("image/apng", "Portable Network Graphics", ".png", ".apng"), + SVG("image/svg+xml", "Scalable Vector Graphics (SVG)", ".svg"), + TIFF("image/tiff", "Tagged Image File Format (TIFF)", ".tif", ".tiff"), + WEBP("image/webp", "WEBP image", ".webp"), + + /* video MIME types */ + AVI("video/x-msvideo", "AVI: Audio Video Interleave", ".avi"), + MP2T("video/mp2t", "MPEG transport stream", ".ts"), + MPEG("video/mpeg", "MPEG Video", ".mpeg"), + WEBM_VIDEO("video/webm", "WEBM video", ".webm"), + + /* application-specific audio MIME types -- mostly binary-type formats */ + BINARY("application/octet-stream", "Any kind of binary data", ".bin"), + CMWLIGHT("application/cmwlight", "proprietary CERN serialiser binary format", ".cmwlight"), // deprecated: do not use for new projects + // BZIP("application/x-bzip", "BZip archive", ".bz"), // affected by patent + BZIP2("application/x-bzip2", "BZip2 archive", ".bz2"), + DOC("application/msword", "Microsoft Word", ".doc"), + DOCX("application/vnd.openxmlformats-officedocument.wordprocessingml.document", "Microsoft Word (OpenXML)", ".docx"), + GZIP("application/gzip", "GZip Compressed Archive", ".gz"), + JAR("application/java-archive", "Java Archive (JAR)", ".jar"), + ODP("application/vnd.oasis.opendocument.presentation", "OpenDocument presentation document", ".odp"), + ODS("application/vnd.oasis.opendocument.spreadsheet", "OpenDocument spreadsheet document", ".ods"), + ODT("application/vnd.oasis.opendocument.text", "OpenDocument text document", ".odt"), + OGG("application/ogg", "OGG Audio/Video File", ".ogx", ".ogv", ".oga"), + PDF("application/pdf", "Adobe Portable Document Format (PDF)", ".pdf"), + PHP("application/x-httpd-php", "Hypertext Preprocessor (Personal Home Page)", ".php"), + PPT("application/vnd.ms-powerpoint", "Microsoft PowerPoint", ".ppt"), + PPTX("application/vnd.openxmlformats-officedocument.presentationml.presentation", "Microsoft PowerPoint (OpenXML)", ".pptx"), + RAR("application/vnd.rar", "RAR archive", ".rar"), + RTF("application/rtf", "Rich Text Format (RTF)", ".rtf"), + TAR("application/x-tar", "Tape Archive (TAR)", ".tar"), + VSD("application/vnd.visio", "Microsoft Visio", ".vsd"), + XHTML("application/xhtml+xml", "XHTML", ".xhtml"), + XLS("application/vnd.ms-excel", "Microsoft Excel", ".xls"), + XLSX("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "Microsoft Excel (OpenXML)", ".xlsx"), + ZIP("application/zip", "ZIP archive", ".zip"), + + /* fall-back */ + UNKNOWN("unknown/unknown", "unknown data format"); + + static { + // custom decoder to bypass Jsoniter's Javaassist usage that uses + // 'toString()' rather than 'name()' to instantiate specific enum values + JsoniterSpi.registerTypeDecoder(MimeType.class, iter -> MimeType.getEnum(iter.readString())); + } + + private final String mediaType; + private final String description; + private final Type type; + private final String subType; + private final List fileEndings; + + MimeType(final String definition, final String description, final String... endings) { + mediaType = definition; + this.description = description; + type = Type.getEnum(definition); + subType = definition.split("/")[1]; + fileEndings = Arrays.asList(endings); + } + + /** + * @return the commonly defined file-endings for the given MIME type + */ + public List getFileEndings() { + return fileEndings; + } + + /** + * @return the specific media sub-type, such as "plain" or "png", "mpeg", "mp4" + * or "xml". + */ + public String getSubType() { + return subType; + } + + /** + * @return the high-level media type, such as "text", "image", "audio", "video", + * or "application". + */ + public Type getType() { + return type; + } + + public boolean isImageData() { + return Type.IMAGE.equals(this.getType()); + } + + public boolean isNonDisplayableData() { + return !isImageData() && !isVideoData(); + } + + public boolean isTextData() { + return Type.TEXT.equals(this.getType()); + } + + public boolean isVideoData() { + return Type.VIDEO.equals(this.getType()); + } + + @Override + public String toString() { + return mediaType; + } + + public String getMediaType() { + return mediaType; + } + + /** + * @return human-readable description of the format + */ + public String getDescription() { + return description; + } + + /** + * Case-insensitive mapping between MIME-type string and enumumeration value. + * + * @param mimeType the string equivalent mime-type, e.g. "image/png" + * @return the enumeration equivalent first matching mime-type, e.g. MimeType.PNG or MimeType.UNKNOWN as fall-back + */ + public static MimeType getEnum(final String mimeType) { + if (mimeType == null || mimeType.isBlank()) { + return UNKNOWN; + } + + final String trimmed = mimeType.toLowerCase(Locale.UK).trim(); + for (MimeType mType : MimeType.values()) { + // N.B.trimmed can contain several MIME types, e.g "image/webp,image/apng,image/*" + if (trimmed.contains(mType.mediaType)) { + return mType; + } + // second fall-back - raw enum type name + if (trimmed.equalsIgnoreCase(mType.name())) { + return mType; + } + } + return UNKNOWN; + } + + /** + * Case-insensitive mapping between MIME-type string and enumeration value. + * + * @param fileName the string equivalent mime-type, e.g. "image/png" + * @return the enumeration equivalent mime-type, e.g. MimeType.PNG or MimeType.UNKNOWN as fall-back + */ + public static MimeType getEnumByFileName(final String fileName) { + if (fileName == null || fileName.isBlank()) { + return UNKNOWN; + } + + final String trimmed = fileName.toLowerCase(Locale.UK).trim(); + for (MimeType mType : MimeType.values()) { + for (String ending : mType.getFileEndings()) { + if (trimmed.endsWith(ending)) { + return mType; + } + } + } + + return UNKNOWN; + } + + public enum Type { + AUDIO("audio"), + IMAGE("image"), + VIDEO("video"), + TEXT("text"), + APPLICATION("application"), + UNKNOWN("unknown"); + + private final String typeDef; + + Type(final String subType) { + typeDef = subType; + } + + @Override + public String toString() { + return typeDef; + } + + public static Type getEnum(final String type) { + if (type == null || type.isBlank()) { + return UNKNOWN; + } + final String stripped = type.split("/")[0]; + for (Type mSubType : Type.values()) { + if (mSubType.typeDef.equalsIgnoreCase(stripped)) { + return mSubType; + } + } + return UNKNOWN; + } + } +} diff --git a/core/src/main/java/io/opencmw/OpenCmwProtocol.java b/core/src/main/java/io/opencmw/OpenCmwProtocol.java new file mode 100644 index 00000000..6ae5dbd0 --- /dev/null +++ b/core/src/main/java/io/opencmw/OpenCmwProtocol.java @@ -0,0 +1,526 @@ +package io.opencmw; + +import static org.zeromq.ZMQ.Socket; + +import static io.opencmw.OpenCmwProtocol.Command.*; +import static io.opencmw.utils.AnsiDefs.ANSI_RED; +import static io.opencmw.utils.AnsiDefs.ANSI_RESET; + +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.zeromq.SocketType; +import org.zeromq.ZFrame; +import org.zeromq.ZMQ; +import org.zeromq.ZMsg; +import org.zeromq.util.ZData; + +import io.opencmw.utils.AnsiDefs; + +/** + * Open Common Middle-Ware Protocol + * + * extended and based upon: + * Majordomo Protocol (MDP) definitions and implementations according to https://rfc.zeromq.org/spec/7/ + * + * For a non-programmatic protocol description see: + * https://github.com/GSI-CS-CO/chart-fx/blob/master/microservice/docs/MajordomoProtocol.md + * + * @author rstein + * @author Alexander Krimm + */ +@SuppressWarnings({ "PMD.TooManyMethods", "PMD.ArrayIsStoredDirectly", "PMD.CommentSize", "PMD.MethodReturnsInternalArray" }) +public final class OpenCmwProtocol { // NOPMD - nomen est omen + public static final String COMMAND_MUST_NOT_BE_NULL = "command must not be null"; + public static final byte[] EMPTY_FRAME = {}; + public static final URI EMPTY_URI = URI.create(""); + private static final byte[] PROTOCOL_NAME_CLIENT = "MDPC03".getBytes(StandardCharsets.UTF_8); + private static final byte[] PROTOCOL_NAME_CLIENT_HTTP = "MDPH03".getBytes(StandardCharsets.UTF_8); + private static final byte[] PROTOCOL_NAME_WORKER = "MDPW03".getBytes(StandardCharsets.UTF_8); + private static final byte[] PROTOCOL_NAME_UNKNOWN = "UNKNOWN_PROTOCOL".getBytes(StandardCharsets.UTF_8); + private static final int MAX_PRINT_LENGTH = 200; // unique client id, see ROUTER socket docs for info + private static final int FRAME0_SOURCE_ID = 0; // unique client id, see ROUTER socket docs for info + private static final int FRAME1_PROTOCOL_ID = 1; // 'MDPC0' or 'MDPW0' + private static final int FRAME2_COMMAND_ID = 2; + private static final int FRAME3_SERVICE_ID = 3; + private static final int FRAME4_CLIENT_REQUEST_ID = 4; + private static final int FRAME5_TOPIC = 5; + private static final int FRAME6_DATA = 6; + private static final int FRAME7_ERROR = 7; + private static final int FRAME8_RBAC_TOKEN = 8; + private static final Logger LOGGER = LoggerFactory.getLogger(OpenCmwProtocol.class); + private static final String SOCKET_MUST_NOT_BE_NULL = "socket must not be null"; + public static final int N_PROTOCOL_FRAMES = 8; + + /** + * MDP sub-protocol V0.1 + */ + public enum MdpSubProtocol { + PROT_CLIENT(PROTOCOL_NAME_CLIENT), // OpenCmwProtocol/Client protocol implementation version + PROT_CLIENT_HTTP(PROTOCOL_NAME_CLIENT_HTTP), // OpenCmwProtocol/HTTP(REST) protocol implementation version + PROT_WORKER(PROTOCOL_NAME_WORKER), // OpenCmwProtocol/Worker protocol implementation version + UNKNOWN(PROTOCOL_NAME_UNKNOWN); + + private final byte[] data; + private final String protocolName; + MdpSubProtocol(final byte[] value) { + this.data = value; + protocolName = new String(data, StandardCharsets.UTF_8); + } + + public byte[] getData() { + return data; + } + + @Override + public String toString() { + return "MdpSubProtocol{'" + protocolName + "'}"; + } + + public static MdpSubProtocol getProtocol(byte[] frame) { + for (MdpSubProtocol knownProtocol : MdpSubProtocol.values()) { + if (Arrays.equals(knownProtocol.data, frame)) { + if (knownProtocol == UNKNOWN) { + continue; + } + return knownProtocol; + } + } + return UNKNOWN; + } + } + + /** + * OpenCmwProtocol commands, as byte values + */ + public enum Command { + GET_REQUEST(0x01, true, true), + SET_REQUEST(0x02, true, true), + PARTIAL(0x03, true, true), + FINAL(0x04, true, true), + READY(0x05, true, true), // mandatory for worker, optional for client (ie. for optional initial RBAC authentication) + DISCONNECT(0x06, true, true), // mandatory for worker, optional for client + SUBSCRIBE(0x07, true, true), // client specific command + UNSUBSCRIBE(0x08, true, true), // client specific command + W_NOTIFY(0x09, false, true), // worker specific command + W_HEARTBEAT(0x10, true, true), // worker specific command, optional for client + UNKNOWN(-1, false, false); + + private final byte[] data; + private final boolean isForClients; + private final boolean isForWorkers; + Command(final int value, boolean client, final boolean worker) { //watch for ints>255, will be truncated + this.data = new byte[] { (byte) (value & 0xFF) }; + this.isForClients = client; + this.isForWorkers = worker; + } + + public byte[] getData() { + return data; + } + + public boolean isClientCompatible() { + return isForClients; + } + + public boolean isWorkerCompatible() { + return isForWorkers; + } + + public static Command getCommand(byte[] frame) { + for (Command knownMdpCommand : values()) { + if (Arrays.equals(knownMdpCommand.data, frame)) { + if (knownMdpCommand == UNKNOWN) { + continue; + } + return knownMdpCommand; + } + } + return UNKNOWN; + } + } + + /** + * MDP data object to store OpenCMW frames description + * + * For a non-programmatic protocol description see: + * https://github.com/GSI-CS-CO/chart-fx/blob/master/microservice/docs/MajordomoProtocol.md + */ + public static class MdpMessage { + /** OpenCMW frame 0: sender source ID - usually the ID from the MDP broker ROUTER socket for the given connection */ + public byte[] senderID; + /** OpenCMW frame 1: unique protocol identifier */ + public MdpSubProtocol protocol; + /** OpenCMW frame 2: MDP command */ + public Command command; + /** OpenCMW frame 3: service name (for client sub-protocols) or client source ID (for worker sub-protocol) */ + public byte[] serviceNameBytes; // UTF-8 encoded service name and/or clientID + /** OpenCMW frame 4: custom client request ID (N.B. client-generated and transparently passed through broker and worker) */ + public byte[] clientRequestID; + /** OpenCMW frame 5: request/reply topic -- follows URI syntax, ie. '

scheme:[//authority]path[?query][#fragment]
' see documentation */ + public URI topic; // request/reply topic - follows URI syntax, ie. '
scheme:[//authority]path[?query][#fragment]
' + /** OpenCMW frame 6: data (may be null if error stack is not blank) */ + public byte[] data; + /** OpenCMW frame 7: error stack -- UTF-8 string (may be blank if data is not null) */ + public String errors; + /** OpenCMW frame 8 (optional): RBAC token */ + public byte[] rbacToken; + + private MdpMessage() { + // private constructor + } + + /** + * generate new (immutable) MdpMessage representation + * @param senderID OpenCMW frame 0: sender source ID - usually the ID from the MDP broker ROUTER socket for the given connection + * @param protocol OpenCMW frame 1: unique protocol identifier (see: MdpSubProtocol) + * @param command OpenCMW frame 2: command (see: Command) + * @param serviceID OpenCMW frame 3: service name (for client sub-protocols) or client source ID (for worker sub-protocol) + * @param clientRequestID OpenCMW frame 4: custom client request ID (N.B. client-generated and transparently passed through broker and worker) + * @param topic openCMW frame 5: the request/reply topic - follows URI syntax, ie. '
scheme:[//authority]path[?query][#fragment]
' see documentation + * @param data OpenCMW frame 6: data - may be null in case errors is not null + * @param errors OpenCMW frame 7: error stack -- UTF-8 string may be blank only if data is not null + * @param rbacToken OpenCMW frame 8 (optional): RBAC token + */ + public MdpMessage(final byte[] senderID, @NotNull final MdpSubProtocol protocol, @NotNull final Command command, + @NotNull final byte[] serviceID, @NotNull final byte[] clientRequestID, @NotNull final URI topic, + final byte[] data, @NotNull final String errors, final byte[] rbacToken) { + this.senderID = senderID == null ? EMPTY_FRAME : senderID; + this.protocol = protocol; + this.command = command; + this.serviceNameBytes = serviceID; + this.clientRequestID = clientRequestID; + this.topic = topic; + this.data = data == null ? EMPTY_FRAME : data; + this.errors = errors; + if (data == null && errors.isBlank()) { + throw new IllegalArgumentException("data must not be null if errors are blank"); + } + this.rbacToken = rbacToken == null ? EMPTY_FRAME : rbacToken; + } + + /** + * Copy constructor cloning other MdpMessage + * @param other MdpMessage + */ + public MdpMessage(@NotNull final MdpMessage other) { + this(other, UNKNOWN); + } + + /** + * Copy constructor cloning other MdpMessage + * @param other MdpMessage + * @param fullCopy is UNKNOWN then a clone is generated, for all other cases a MdpMessage with + * the specified command, mirrored frames, except 'data', 'errors' and 'rbacToken' is generated. + */ + public MdpMessage(@NotNull final MdpMessage other, @NotNull final Command fullCopy) { + this(copyOf(other.senderID), other.protocol, fullCopy == UNKNOWN ? other.command : fullCopy, copyOf(other.serviceNameBytes), copyOf(other.clientRequestID), other.topic, + fullCopy == UNKNOWN ? copyOf(other.data) : EMPTY_FRAME, fullCopy == UNKNOWN ? other.errors : "", fullCopy == UNKNOWN ? copyOf(other.rbacToken) : EMPTY_FRAME); + } + + @Override + public boolean equals(final Object obj) { + if (this == obj) { + return true; + } else if (!(obj instanceof MdpMessage)) { + return false; + } + final MdpMessage other = (MdpMessage) obj; + // ignore senderID from comparison since not all socket provide/require this information + if (/*!Arrays.equals(senderID, other.senderID) ||*/ (protocol != other.protocol) || (command != other.command) + || !Arrays.equals(serviceNameBytes, other.serviceNameBytes) || !Arrays.equals(clientRequestID, other.clientRequestID) + || (!Objects.equals(topic, other.topic)) + || !Arrays.equals(data, other.data) + || (!Objects.equals(errors, other.errors))) { + return false; + } + return Arrays.equals(rbacToken, other.rbacToken); + } + + public String getSenderName() { + return senderID == null ? "" : ZData.toString(senderID); + } + + public String getServiceName() { + return serviceNameBytes == null ? "" : ZData.toString(serviceNameBytes); + } + + public boolean hasRbackToken() { + return rbacToken.length != 0; + } + + @Override + public int hashCode() { + int result = (protocol == null ? 0 : protocol.hashCode()); + result = 31 * result + Objects.hashCode(command); + result = 31 * result + Arrays.hashCode(serviceNameBytes); + result = 31 * result + Arrays.hashCode(clientRequestID); + result = 31 * result + (topic == null ? 0 : topic.hashCode()); + result = 31 * result + Arrays.hashCode(data); + result = 31 * result + (errors == null ? 0 : errors.hashCode()); + result = 31 * result + Arrays.hashCode(rbacToken); + return result; + } + + /** + * Send MDP message to Socket + * + * @param socket ZeroMQ socket to send the message on + * @return {@code true} if successful + */ + public boolean send(final Socket socket) { + // some assertions for debugging purposes - N.B. these should occur only when developing/refactoring the frame-work + // N.B. to be enabled with '-ea' VM argument + assert socket != null : SOCKET_MUST_NOT_BE_NULL; + assert protocol != null : "protocol must not be null"; + assert !protocol.equals(MdpSubProtocol.UNKNOWN) + : "protocol must not be UNKNOWN"; + assert command != null : COMMAND_MUST_NOT_BE_NULL; + assert (protocol.equals(MdpSubProtocol.PROT_CLIENT) && command.isClientCompatible()) + || (protocol.equals(MdpSubProtocol.PROT_WORKER) && command.isWorkerCompatible()) + : "command is client/worker compatible"; + assert serviceNameBytes != null : "serviceName must not be null"; + assert clientRequestID != null : "clientRequestID must not be null"; + assert topic != null : "topic must not be null"; + assert !(data == null && (errors == null || errors.isBlank())) + : "data must not be null and errors be blank"; + + ZMsg msg = new ZMsg(); + if (socket.getSocketType() == SocketType.ROUTER) { + if (senderID == null) { + throw new IllegalArgumentException("senderID must be non-null when using ROUTER sockets"); + } + msg.add(new ZFrame(senderID)); // frame 0: source ID (optional, only needed for broker sockets) + } + msg.add(new ZFrame(protocol.data)); // frame: 1 + msg.add(new ZFrame(command.data)); // frame: 2 + msg.add(new ZFrame(serviceNameBytes)); // frame: 3 + msg.add(new ZFrame(clientRequestID)); // frame: 4 + msg.addString(topic.toString()); // frame: 5 + msg.add(new ZFrame(data == null ? EMPTY_FRAME : data)); // frame: 6 + msg.addString(errors == null ? "" : errors); // frame: 7 + msg.add(new ZFrame(rbacToken)); // frame: 8 - rbac token + + if (LOGGER.isTraceEnabled()) { + LOGGER.atTrace().addArgument(msg.toString()).log("sending message {}"); + } + return msg.send(socket); + } + + @Override + public String toString() { + final String errStr = errors == null || errors.isBlank() ? "no-exception" : ANSI_RED + " exception thrown: " + errors + ANSI_RESET; + return "MdpMessage{senderID='" + ZData.toString(senderID) + "', " + protocol + ", " + command + ", serviceName='" + getServiceName() + + "', clientRequestID='" + ZData.toString(clientRequestID) + "', topic='" + topic + + "', data='" + dataToString(data) + "', " + errStr + ", rbac='" + ZData.toString(rbacToken) + "'}"; + } + + protected static String dataToString(byte[] data) { + if (data == null) { + return ""; + } + // Dump message as text or hex-encoded string + boolean isText = true; + for (byte aData : data) { + if (aData < AnsiDefs.MIN_PRINTABLE_CHAR) { + isText = false; + break; + } + } + if (isText) { + // always make full-print when there are only printable characters + return new String(data, ZMQ.CHARSET); + } + if (data.length < MAX_PRINT_LENGTH) { + return ZData.strhex(data); + } else { + return ZData.strhex(Arrays.copyOf(data, MAX_PRINT_LENGTH)) + "[" + (data.length - MAX_PRINT_LENGTH) + " more bytes]"; + } + } + + /** + * @param socket the socket to receive from (performs blocking call) + * @return MdpMessage if valid, or {@code null} otherwise + */ + public static MdpMessage receive(final Socket socket) { + return receive(socket, true); + } + + /** + * @param socket the socket to receive from + * @param wait setting the flag to ZMQ.DONTWAIT does a non-blocking recv. + * @return MdpMessage if valid, or {@code null} otherwise + */ + @SuppressWarnings("PMD.NPathComplexity") + public static MdpMessage receive(@NotNull final Socket socket, final boolean wait) { + final int flags = wait ? 0 : ZMQ.DONTWAIT; + final ZMsg msg = ZMsg.recvMsg(socket, flags); + if (msg == null) { + return null; + } + if (socket.getSocketType() != SocketType.ROUTER) { + msg.push(EMPTY_FRAME); // push empty client frame + } + + if (socket.getSocketType() == SocketType.SUB || socket.getSocketType() == SocketType.XSUB) { + msg.pollFirst(); // remove first subscription topic message -- not needed here since this is also encoded in the service/topic frame + } + + final List rawFrames = msg.stream().map(ZFrame::getData).collect(Collectors.toUnmodifiableList()); + if (rawFrames.size() <= N_PROTOCOL_FRAMES) { + if (LOGGER.isDebugEnabled()) { + LOGGER.atWarn().addArgument(rawFrames.size()).addArgument(dataToString(rawFrames)).log("received message size is < " + N_PROTOCOL_FRAMES + ": {} rawMessage: {}"); + } + return null; + } + + // OpenCMW frame 0: sender source ID - usually the ID from the MDP broker ROUTER socket for the given connection + final byte[] senderID = socket.getSocketType() == SocketType.ROUTER ? rawFrames.get(FRAME0_SOURCE_ID) : EMPTY_FRAME; // NOPMD + // OpenCMW frame 1: unique protocol identifier + final MdpSubProtocol protocol = MdpSubProtocol.getProtocol(rawFrames.get(FRAME1_PROTOCOL_ID)); + if (protocol.equals(MdpSubProtocol.UNKNOWN)) { + if (LOGGER.isDebugEnabled()) { + LOGGER.atWarn().addArgument(ZData.toString(rawFrames.get(FRAME1_PROTOCOL_ID))).addArgument(dataToString(rawFrames)).log("unknown protocol: '{}' rawMessage: {}"); + } + return null; + } + + // OpenCMW frame 2: command + final Command command = getCommand(rawFrames.get(FRAME2_COMMAND_ID)); + if (command.equals(UNKNOWN)) { + if (LOGGER.isDebugEnabled()) { + LOGGER.atWarn().addArgument(ZData.toString(rawFrames.get(FRAME2_COMMAND_ID))).addArgument(dataToString(rawFrames)).log("unknown command: '{}' rawMessage: {}"); + } + return null; + } + + // OpenCMW frame 3: service name or client source ID + final byte[] serviceNameBytes = rawFrames.get(FRAME3_SERVICE_ID); + if (serviceNameBytes == null) { + if (LOGGER.isDebugEnabled()) { + LOGGER.atDebug().addKeyValue("a", "dd").log(""); + LOGGER.atWarn().addArgument(dataToString(rawFrames)).log("serviceNameBytes is null, rawMessage: {}"); + } + return null; + } + + // OpenCMW frame 4: service name or client source ID + final byte[] clientRequestID = rawFrames.get(FRAME4_CLIENT_REQUEST_ID); // NOPMD + + // OpenCMW frame 5: request/reply topic -- UTF-8 string + final byte[] topicBytes = rawFrames.get(FRAME5_TOPIC); + if (topicBytes == null) { + if (LOGGER.isDebugEnabled()) { + LOGGER.atWarn().addArgument(dataToString(rawFrames)).log("topic is null, rawMessage: {}"); + } + return null; + } + + final String topicString = new String(topicBytes, StandardCharsets.UTF_8); + final URI topic; + try { + topic = new URI(topicString); + } catch (URISyntaxException e) { + if (LOGGER.isDebugEnabled()) { + LOGGER.atWarn().setCause(e).addArgument(topicString).addArgument(topicString).log("topic URI cannot be parsed {} - {}"); + } + return null; + } + + // OpenCMW frame 6: data + final byte[] data = rawFrames.get(FRAME6_DATA); + // OpenCMW frame 7: error stack -- UTF-8 string + final byte[] errorBytes = rawFrames.get(FRAME7_ERROR); + final String errors = errorBytes == null || errorBytes.length == 0 ? "" : new String(errorBytes, StandardCharsets.UTF_8); + if (data == null && errors.isBlank()) { + if (LOGGER.isDebugEnabled()) { + LOGGER.atWarn().addArgument(dataToString(rawFrames)).log("data is null and errors is blank - {}"); + } + return null; + } + + // OpenCMW frame 8 (optional): RBAC token + final byte[] rbacTokenByte = rawFrames.size() == 9 ? rawFrames.get(FRAME8_RBAC_TOKEN) : null; + final byte[] rbacToken = rawFrames.size() == 9 && rbacTokenByte != null ? rbacTokenByte : EMPTY_FRAME; + + return new MdpMessage(senderID, protocol, command, serviceNameBytes, clientRequestID, topic, data, errors, rbacToken); // OpenCMW frame 8 (optional): RBAC token + } + + public static boolean send(final Socket socket, final List replies) { + assert socket != null : SOCKET_MUST_NOT_BE_NULL; + assert replies != null; + if (replies.isEmpty()) { + return false; + } + boolean sendState = false; + for (Iterator iter = replies.iterator(); iter.hasNext();) { + MdpMessage reply = iter.next(); + reply.command = iter.hasNext() ? PARTIAL : FINAL; + sendState |= reply.send(socket); + } + return sendState; + } + + protected static byte[] copyOf(byte[] original) { + return original == null ? EMPTY_FRAME : Arrays.copyOf(original, original.length); + } + + protected static String dataToString(List data) { + return data.stream().map(ZData::toString).collect(Collectors.joining(", ", "[#frames= " + data.size() + ": ", "]")); + } + } + + /** + * MDP reply/request context + */ + public static class Context { + public final MdpMessage req; // input request + public MdpMessage rep; // return request + + private Context() { + req = new MdpMessage(); + } + + public Context(@NotNull MdpMessage requestMsg) { + req = requestMsg; + rep = new MdpMessage(req, FINAL); + } + + @Override + public String toString() { + return "OpenCmwProtocol.Context{req=" + req + ", rep=" + rep + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Context)) { + return false; + } + Context context = (Context) o; + + if (!Objects.equals(req, context.req)) { + return false; + } + return Objects.equals(rep, context.rep); + } + + @Override + public int hashCode() { + int result = req == null ? 0 : req.hashCode(); + result = 31 * result + (rep == null ? 0 : rep.hashCode()); + return result; + } + } +} diff --git a/core/src/main/java/io/opencmw/QueryParameterParser.java b/core/src/main/java/io/opencmw/QueryParameterParser.java new file mode 100644 index 00000000..3fc486f7 --- /dev/null +++ b/core/src/main/java/io/opencmw/QueryParameterParser.java @@ -0,0 +1,296 @@ +package io.opencmw; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.AbstractMap.SimpleImmutableEntry; +import static java.util.stream.Collectors.mapping; +import static java.util.stream.Collectors.toList; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Type; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.function.BiFunction; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.NotNull; + +import io.opencmw.filter.TimingCtx; +import io.opencmw.serialiser.FieldDescription; +import io.opencmw.serialiser.spi.ClassFieldDescription; +import io.opencmw.serialiser.utils.ClassUtils; + +/** + * Parses query parameters into PoJo structure. + * + * Follows URI syntax, ie. '
scheme:[//authority]path[?query][#fragment]
' + * see documentation + * + * @author rstein + */ +public final class QueryParameterParser { // NOPMD - nomen est omen + public static final String MIME_TYPE_TAG = "contentType"; + public static final ConcurrentMap STRING_TO_CLASS_CONVERTER = new ConcurrentHashMap<>(); // NOSONAR NOPMD + public static final ConcurrentMap> CLASS_TO_STRING_CONVERTER = new ConcurrentHashMap<>(); // NOSONAR NOPMD + public static final ConcurrentMap> CLASS_TO_OBJECT_CONVERTER = new ConcurrentHashMap<>(); // NOSONAR NOPMD + static { + STRING_TO_CLASS_CONVERTER.put(boolean.class, (str, obj, field) -> field.getField().setBoolean(obj, Boolean.parseBoolean(str))); + STRING_TO_CLASS_CONVERTER.put(byte.class, (str, obj, field) -> field.getField().setByte(obj, Byte.parseByte(str))); + STRING_TO_CLASS_CONVERTER.put(short.class, (str, obj, field) -> field.getField().setShort(obj, Short.parseShort(str))); + STRING_TO_CLASS_CONVERTER.put(int.class, (str, obj, field) -> field.getField().setInt(obj, Integer.parseInt(str))); + STRING_TO_CLASS_CONVERTER.put(long.class, (str, obj, field) -> field.getField().setLong(obj, Long.parseLong(str))); + STRING_TO_CLASS_CONVERTER.put(float.class, (str, obj, field) -> field.getField().setFloat(obj, Float.parseFloat(str))); + STRING_TO_CLASS_CONVERTER.put(double.class, (str, obj, field) -> field.getField().setDouble(obj, Double.parseDouble(str))); + STRING_TO_CLASS_CONVERTER.put(Boolean.class, (str, obj, field) -> field.getField().set(obj, Boolean.parseBoolean(str))); + STRING_TO_CLASS_CONVERTER.put(Byte.class, (str, obj, field) -> field.getField().set(obj, Byte.parseByte(str))); + STRING_TO_CLASS_CONVERTER.put(Short.class, (str, obj, field) -> field.getField().set(obj, Short.parseShort(str))); + STRING_TO_CLASS_CONVERTER.put(Integer.class, (str, obj, field) -> field.getField().set(obj, Integer.parseInt(str))); + STRING_TO_CLASS_CONVERTER.put(Long.class, (str, obj, field) -> field.getField().set(obj, Long.parseLong(str))); + STRING_TO_CLASS_CONVERTER.put(Float.class, (str, obj, field) -> field.getField().set(obj, Float.parseFloat(str))); + STRING_TO_CLASS_CONVERTER.put(Double.class, (str, obj, field) -> field.getField().set(obj, Double.parseDouble(str))); + STRING_TO_CLASS_CONVERTER.put(String.class, (str, obj, field) -> field.getField().set(obj, str)); + + final BiFunction objToString = (obj, field) -> { + final Object ret = field.getField().get(obj); + return ret == null || ret.getClass().equals(Object.class) ? "" : ret.toString(); + }; + CLASS_TO_STRING_CONVERTER.put(boolean.class, (obj, field) -> Boolean.toString(field.getField().getBoolean(obj))); + CLASS_TO_STRING_CONVERTER.put(byte.class, (obj, field) -> Byte.toString(field.getField().getByte(obj))); + CLASS_TO_STRING_CONVERTER.put(short.class, (obj, field) -> Short.toString(field.getField().getShort(obj))); + CLASS_TO_STRING_CONVERTER.put(int.class, (obj, field) -> Integer.toString(field.getField().getInt(obj))); + CLASS_TO_STRING_CONVERTER.put(long.class, (obj, field) -> Long.toString(field.getField().getLong(obj))); + CLASS_TO_STRING_CONVERTER.put(float.class, (obj, field) -> Float.toString(field.getField().getFloat(obj))); + CLASS_TO_STRING_CONVERTER.put(double.class, (obj, field) -> Double.toString(field.getField().getDouble(obj))); + CLASS_TO_STRING_CONVERTER.put(boolean[].class, (obj, field) -> Arrays.toString((boolean[]) field.getField().get(obj))); + CLASS_TO_STRING_CONVERTER.put(byte[].class, (obj, field) -> Arrays.toString((byte[]) field.getField().get(obj))); + CLASS_TO_STRING_CONVERTER.put(short[].class, (obj, field) -> Arrays.toString((short[]) field.getField().get(obj))); + CLASS_TO_STRING_CONVERTER.put(int[].class, (obj, field) -> Arrays.toString((int[]) field.getField().get(obj))); + CLASS_TO_STRING_CONVERTER.put(long[].class, (obj, field) -> Arrays.toString((long[]) field.getField().get(obj))); + CLASS_TO_STRING_CONVERTER.put(float[].class, (obj, field) -> Arrays.toString((float[]) field.getField().get(obj))); + CLASS_TO_STRING_CONVERTER.put(double[].class, (obj, field) -> Arrays.toString((double[]) field.getField().get(obj))); + CLASS_TO_STRING_CONVERTER.put(Boolean.class, objToString); + CLASS_TO_STRING_CONVERTER.put(Byte.class, objToString); + CLASS_TO_STRING_CONVERTER.put(Short.class, objToString); + CLASS_TO_STRING_CONVERTER.put(Integer.class, objToString); + CLASS_TO_STRING_CONVERTER.put(Long.class, objToString); + CLASS_TO_STRING_CONVERTER.put(Float.class, objToString); + CLASS_TO_STRING_CONVERTER.put(Double.class, objToString); + CLASS_TO_STRING_CONVERTER.put(String.class, (obj, field) -> Objects.requireNonNullElse(field.getField().get(obj), "").toString()); + + CLASS_TO_OBJECT_CONVERTER.put(boolean.class, (obj, field) -> field.getField().getBoolean(obj)); + CLASS_TO_OBJECT_CONVERTER.put(byte.class, (obj, field) -> field.getField().getByte(obj)); + CLASS_TO_OBJECT_CONVERTER.put(short.class, (obj, field) -> field.getField().getShort(obj)); + CLASS_TO_OBJECT_CONVERTER.put(int.class, (obj, field) -> field.getField().getInt(obj)); + CLASS_TO_OBJECT_CONVERTER.put(long.class, (obj, field) -> field.getField().getLong(obj)); + CLASS_TO_OBJECT_CONVERTER.put(float.class, (obj, field) -> field.getField().getFloat(obj)); + CLASS_TO_OBJECT_CONVERTER.put(double.class, (obj, field) -> field.getField().getDouble(obj)); + CLASS_TO_OBJECT_CONVERTER.put(Object.class, (obj, field) -> field.getField().get(obj)); + + // special known objects + STRING_TO_CLASS_CONVERTER.put(Object.class, (str, obj, field) -> field.getField().set(obj, new Object())); + STRING_TO_CLASS_CONVERTER.put(MimeType.class, (str, obj, field) -> field.getField().set(obj, MimeType.getEnum(str))); + STRING_TO_CLASS_CONVERTER.put(TimingCtx.class, (str, obj, field) -> field.getField().set(obj, TimingCtx.get(str))); + + CLASS_TO_STRING_CONVERTER.put(Object.class, objToString); + CLASS_TO_STRING_CONVERTER.put(MimeType.class, (obj, field) -> { + final Object ret = field.getField().get(obj); + return ret == null || ret.getClass().equals(Object.class) ? "" : ((MimeType) ret).name(); + }); + CLASS_TO_STRING_CONVERTER.put(TimingCtx.class, (obj, field) -> { + final Object ctx = field.getField().get(obj); + return ctx instanceof TimingCtx ? ((TimingCtx) ctx).selector : ""; + }); + } + + private QueryParameterParser() { + // this is a utility class + } + + public static URI appendQueryParameter(URI oldUri, String appendQuery) throws URISyntaxException { + if (appendQuery == null || appendQuery.isBlank()) { + return oldUri; + } + return new URI(oldUri.getScheme(), oldUri.getAuthority(), oldUri.getPath(), oldUri.getQuery() == null ? appendQuery : (oldUri.getQuery() + "&" + appendQuery), oldUri.getFragment()); + } + + /** + * + * @param queryParameterMap query parameter map + * @return queryString a rfc3986 query parameter string + */ + @SuppressWarnings("PMD") + public static String generateQueryParameter(final Map queryParameterMap) { //NOSONAR - complexity justified + final StringBuilder builder = new StringBuilder(); + + final Set> entrySet = queryParameterMap.entrySet(); + final Iterator> iterator = entrySet.iterator(); + boolean first = true; + while (iterator.hasNext()) { + Map.Entry item = iterator.next(); + String key = item.getKey(); + Object values = item.getValue(); + if (!first) { + builder.append('&'); + } + if (values == null) { + builder.append(key); + } else if (List.class.isAssignableFrom(values.getClass())) { + @SuppressWarnings("unchecked") // checked with above isAssignableFrom + List list = (List) values; + for (Object val : list) { + if (!first) { + builder.append('&'); + } + if (val == null) { + builder.append(key); + } else { + builder.append(key).append('=').append(val); + } + first = false; + } + } else { + // non list object + builder.append(key).append('=').append(values); + } + first = false; + } + return builder.toString(); + } + + /** + * + * @param obj storage class + * @return queryString a rfc3986 query parameter string + */ + public static String generateQueryParameter(Object obj) { + final ClassFieldDescription fieldDescription = ClassUtils.getFieldDescription(obj.getClass()); + final StringBuilder builder = new StringBuilder(); + final List children = fieldDescription.getChildren(); + for (int index = 0; index < children.size(); index++) { + ClassFieldDescription field = (ClassFieldDescription) children.get(index); + final BiFunction mapFunction = CLASS_TO_STRING_CONVERTER.get(field.getType()); + final String str; + if (mapFunction == null) { + str = CLASS_TO_STRING_CONVERTER.get(Object.class).apply(obj, field); + } else { + str = mapFunction.apply(obj, field); + } + builder.append(field.getFieldName()).append('=').append(str == null ? "" : URLEncoder.encode(str, UTF_8)); + if (index != children.size() - 1) { + builder.append('&'); + } + } + return builder.toString(); + } + + public static Map> getMap(final String queryParam) { + if (queryParam == null || queryParam.isBlank()) { + return Collections.emptyMap(); + } + + return Arrays.stream(StringUtils.split(queryParam, "&;")) + .map(QueryParameterParser::splitQueryParameter) + .collect(Collectors.groupingBy(SimpleImmutableEntry::getKey, HashMap::new, mapping(Map.Entry::getValue, toList()))); + } + + public static @NotNull MimeType getMimeType(final String queryString) { + final List mimeTypeList = QueryParameterParser.getMap(queryString).get(MIME_TYPE_TAG); + return mimeTypeList == null || mimeTypeList.isEmpty() ? MimeType.UNKNOWN : MimeType.getEnum(mimeTypeList.get(mimeTypeList.size() - 1)); + } + + /** + * Parse query parameter t. + * + * @param generic storage class type to be returned + * @param clazz storage class type + * @param queryString a rfc3986 query parameter string + * @return PoJo with those parameters that could be matched (N.B. flat map only) + * @throws NoSuchMethodException in case the class does not have a accessible constructor + * @throws IllegalAccessException in case the class cannot be instantiated + * @throws InvocationTargetException in case the class cannot be instantiated + * @throws InstantiationException in case the class cannot be instantiated + */ + public static T parseQueryParameter(Class clazz, final String queryString) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException { + final ClassFieldDescription fieldDescription = ClassUtils.getFieldDescription(clazz); + final Constructor constructor = clazz.getDeclaredConstructor(); + constructor.setAccessible(true); // NOSONAR NOPMD + final T obj = constructor.newInstance(); + final Map> queryMap = getMap(queryString); + for (FieldDescription f : fieldDescription.getChildren()) { + ClassFieldDescription field = (ClassFieldDescription) f; + final List values = queryMap.get(field.getFieldName()); + final TriConsumer mapFunction = STRING_TO_CLASS_CONVERTER.get(field.getType()); + if (mapFunction == null || values == null || values.isEmpty()) { + // skip field + continue; + } + final String value = values.get(values.size() - 1); + try { + mapFunction.accept(value, obj, field); + } catch (final Exception e) { // NOPMD exception is being rethrown + throw new IllegalArgumentException("error parsing value '" + value + "' for field: '" + clazz.getName() + "::" + field.getFieldName() + "'", e); + } + } + return obj; + } + + public static URI removeQueryParameter(URI oldUri, String removeQuery) throws URISyntaxException { + if (removeQuery == null || removeQuery.isBlank() || oldUri.getQuery() == null) { + return oldUri; + } + final Map> query = getMap(oldUri.getQuery()); + final int idx = removeQuery.indexOf('='); + if (idx >= 0) { + final String key = idx > 0 ? removeQuery.substring(0, idx) : removeQuery; + final String value = idx > 0 && removeQuery.length() > idx + 1 ? removeQuery.substring(idx + 1) : null; + final List entry = query.get(key); + if (entry != null) { + entry.remove(value); + if (entry.isEmpty()) { + query.remove(value); + } + } + } else { + query.remove(removeQuery); + } + final String newQueryParameter = QueryParameterParser.generateQueryParameter(query); + return new URI(oldUri.getScheme(), oldUri.getAuthority(), oldUri.getPath(), newQueryParameter, oldUri.getFragment()); + } + + /** + * used as lambda expression for user-level code to read/write data into the query pojo + * + * @author rstein + */ + public interface TriConsumer { + /** + * Performs this operation on the given arguments. + * + * @param str the reference string + * @param rootObj the specific root object reference the given field is part of + * @param field the description for the given class member, if null then rootObj is written/read directly + */ + void accept(String str, Object rootObj, ClassFieldDescription field); + } + + @SuppressWarnings("PMD.DefaultPackage") + static SimpleImmutableEntry splitQueryParameter(String queryParameter) { // NOPMD package private for unit-testing purposes + final int idx = queryParameter.indexOf('='); + final String key = idx > 0 ? queryParameter.substring(0, idx) : queryParameter; + final String value = idx > 0 && queryParameter.length() > idx + 1 ? queryParameter.substring(idx + 1) : null; + return new SimpleImmutableEntry<>(URLDecoder.decode(key, UTF_8), value == null ? null : URLDecoder.decode(value, UTF_8)); + } +} diff --git a/core/src/main/java/io/opencmw/RingBufferEvent.java b/core/src/main/java/io/opencmw/RingBufferEvent.java new file mode 100644 index 00000000..f33b045f --- /dev/null +++ b/core/src/main/java/io/opencmw/RingBufferEvent.java @@ -0,0 +1,222 @@ +package io.opencmw; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.lang.reflect.InvocationTargetException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.function.Predicate; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.opencmw.utils.SharedPointer; + +import com.lmax.disruptor.EventHandler; + +@SuppressWarnings("PMD.TooManyMethods") +public class RingBufferEvent implements FilterPredicate, Cloneable { + private final static Logger LOGGER = LoggerFactory.getLogger(RingBufferEvent.class); + /** + * local UTC event arrival time-stamp [ms] + */ + public long arrivalTimeStamp; + + /** + * reference to the parent's disruptor sequence ID number + */ + public long parentSequenceNumber; + + /** + * list of known filters. N.B. this + */ + public final Filter[] filters; + + /** + * domain object carried by this ring buffer event + */ + public SharedPointer payload; + + /** + * collection of exceptions that have been issued while handling this RingBuffer event + */ + public final List throwables = new ArrayList<>(); + + /** + * + * @param filterConfig static filter configuration + */ + @SafeVarargs + public RingBufferEvent(final Class... filterConfig) { + assert filterConfig != null; + this.filters = new Filter[filterConfig.length]; + for (int i = 0; i < filters.length; i++) { + try { + filters[i] = filterConfig[i].getConstructor().newInstance(); + } catch (InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException e) { + throw new IllegalArgumentException("filter initialisations error - could not instantiate class:" + filterConfig[i], e); + } + } + clear(); + } + + @Override + @SuppressWarnings({ "unchecked", "MethodDoesntCallSuperMethod" }) + public RingBufferEvent clone() { // NOSONAR NOPMD we do not want to call super (would be kind of stupid) + final RingBufferEvent retVal = new RingBufferEvent(Arrays.stream(filters).map(Filter::getClass).toArray(Class[] ::new)); + this.copyTo(retVal); + return retVal; + } + + public void copyTo(RingBufferEvent other) { + other.arrivalTimeStamp = arrivalTimeStamp; + other.parentSequenceNumber = parentSequenceNumber; + for (int i = 0; i < other.filters.length; i++) { + filters[i].copyTo(other.filters[i]); + } + other.payload = payload == null ? null : payload.getCopy(); + other.throwables.clear(); + other.throwables.addAll(throwables); + } + + public T getFilter(final Class filterType) { + for (Filter filter : filters) { + if (filter.getClass().isAssignableFrom(filterType)) { + return filterType.cast(filter); + } + } + final StringBuilder builder = new StringBuilder(); + builder.append("requested filter type '").append(filterType.getSimpleName()).append(" not part of ").append(RingBufferEvent.class.getSimpleName()).append(" definition: "); + printToStringArrayList(builder, "[", "]", (Object[]) filters); + throw new IllegalArgumentException(builder.toString()); + } + + public boolean matches(final Predicate predicate) { + return predicate.test(this); + } + + public boolean matches(Class filterType, final Predicate predicate) { + return predicate.test(getFilter(filterType)); + } + + /** + * @param payloadType required payload class-type + * @return {@code true} if payload is defined and matches type + */ + public boolean matches(Class payloadType) { + return payload != null && payload.getType() != null && payloadType.isAssignableFrom(payload.getType()); + } + + public final void clear() { + arrivalTimeStamp = 0L; + parentSequenceNumber = -1L; + for (Filter filter : filters) { + filter.clear(); + } + throwables.clear(); + if (payload != null) { + payload.release(); + } + payload = null; // NOPMD - null use on purpose (faster/easier than an Optional) + } + + @Override + public boolean test(final Class filterClass, final Predicate filterPredicate) { + return filterPredicate.test(filterClass.cast(getFilter(filterClass))); + } + + @Override + public String toString() { + final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.UK); + final StringBuilder builder = new StringBuilder(); + builder.append(RingBufferEvent.class.getSimpleName()).append(": arrivalTimeStamp ").append(arrivalTimeStamp).append(" (").append(sdf.format(arrivalTimeStamp)).append(") parent sequence number: ").append(parentSequenceNumber).append(" - filter: "); + printToStringArrayList(builder, "[", "]", (Object[]) filters); + if (!throwables.isEmpty()) { + builder.append(" - exceptions (n=").append(throwables.size()).append("):\n"); + for (Throwable t : throwables) { + builder.append(getPrintableStackTrace(t)).append('\n'); + } + } + return builder.toString(); + } + + public static void printToStringArrayList(final StringBuilder builder, final String prefix, final String postFix, final Object... items) { + if (prefix != null && !prefix.isBlank()) { + builder.append(prefix); + } + boolean more = false; + for (Object o : items) { + if (more) { + builder.append(", "); + } + builder.append(o.getClass().getSimpleName()).append(':').append(o.toString()); + more = true; + } + if (postFix != null && !postFix.isBlank()) { + builder.append(postFix); + } + //TODO: refactor into a common utility class + } + + public static String getPrintableStackTrace(final Throwable t) { + if (t == null) { + return ""; + } + final StringWriter sw = new StringWriter(); + t.printStackTrace(new PrintWriter(sw)); + return sw.toString(); + //TODO: refactor into a common utility class + } + + /** + * default buffer element clearing handler + */ + public static class ClearEventHandler implements EventHandler { + @Override + public void onEvent(RingBufferEvent event, long sequence, boolean endOfBatch) { + LOGGER.atTrace().addArgument(sequence).addArgument(endOfBatch).log("clearing RingBufferEvent sequence = {} endOfBatch = {}"); + event.clear(); + } + } + + @Override + public boolean equals(final Object obj) { // NOSONAR NOPMD - npath complexity unavoidable for performance reasons + if (this == obj) { + return true; + } + if (!(obj instanceof RingBufferEvent)) { + return false; + } + final RingBufferEvent other = (RingBufferEvent) obj; + if (hashCode() != other.hashCode()) { + return false; + } + if (arrivalTimeStamp != other.arrivalTimeStamp) { + return false; + } + if (parentSequenceNumber != other.parentSequenceNumber) { + return false; + } + if (!Arrays.equals(filters, other.filters)) { + return false; + } + if (!Objects.equals(payload, other.payload)) { + return false; + } + return throwables.equals(other.throwables); + } + + @Override + public int hashCode() { + int result = (int) (arrivalTimeStamp ^ (arrivalTimeStamp >>> 32)); + result = 31 * result + (int) (parentSequenceNumber ^ (parentSequenceNumber >>> 32)); + result = 31 * result + Arrays.hashCode(filters); + result = 31 * result + (payload == null ? 0 : payload.hashCode()); + result = 31 * result + throwables.hashCode(); + return result; + } +} diff --git a/core/src/main/java/io/opencmw/domain/BinaryData.java b/core/src/main/java/io/opencmw/domain/BinaryData.java new file mode 100644 index 00000000..9ebb7372 --- /dev/null +++ b/core/src/main/java/io/opencmw/domain/BinaryData.java @@ -0,0 +1,130 @@ +package io.opencmw.domain; + +import java.util.Arrays; +import java.util.Objects; + +import org.zeromq.util.ZData; + +import io.opencmw.MimeType; +import io.opencmw.serialiser.annotations.MetaInfo; + +/** + * basic domain object definition for receiving or sending generic binary data + */ +@MetaInfo(description = "domain object definition for receiving/sending generic binary data") +public class BinaryData { + public String resourceName = "default"; + public MimeType contentType = MimeType.BINARY; + public byte[] data = {}; + public int dataSize = -1; + + public BinaryData() { + // default constructor + } + + public BinaryData(final String resourceName, final MimeType contentType, final byte[] data) { + this(resourceName, contentType, data, -1); + } + + @SuppressWarnings("PMD.ArrayIsStoredDirectly") + public BinaryData(final String resourceName, final MimeType contentType, final byte[] data, final int dataSize) { + this.resourceName = resourceName; + this.contentType = contentType; + this.data = data; + this.dataSize = dataSize; + checkConsistency(); // NOPMD + } + + public void checkConsistency() { + if (resourceName == null || resourceName.isBlank()) { + throw new IllegalArgumentException("resourceName must not be blank"); + } + if (contentType == null) { + throw new IllegalArgumentException("mimeType must not be blank"); + } + if (data == null || (dataSize >= 0 && data.length < dataSize)) { + throw new IllegalArgumentException("data[" + (data == null ? "null" : data.length) + "] must be larger than dataSize=" + dataSize); + } + } + + @Override + public String toString() { + return "BinaryData{resourceName='" + resourceName + "', contentType=" + contentType.name() + "(\"" + contentType + "\"), dataSize=" + dataSize + ", data=" + ZData.toString(data) + '}'; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (!(o instanceof BinaryData)) { + return false; + } + final BinaryData that = (BinaryData) o; + + if (!Objects.equals(resourceName, that.resourceName) || contentType != that.contentType + || (data == null && that.data != null) || (data != null && that.data == null)) { + return false; + } + if (data == null) { + return true; + } + final int minSize = dataSize >= 0 ? Math.min(data.length, dataSize) : data.length; + return Arrays.equals(data, 0, minSize, that.data, 0, that.data.length); + } + + @Override + public int hashCode() { + int result = resourceName == null ? 0 : resourceName.hashCode(); + result = 31 * result + (contentType == null ? 0 : contentType.hashCode()); + result = 31 * result + Arrays.hashCode(data); + return result; + } + + protected static String fixPreAndPost(final String name) { + final String nonNullName = name == null ? "/" : name.trim(); + final String fixedPrefix = (nonNullName.startsWith("/") ? nonNullName : '/' + nonNullName); + return fixedPrefix.endsWith("/") ? fixedPrefix : fixedPrefix + '/'; + } + + protected static String genExportName(final String name) { + checkField("genExportName(name)", name); + int p = name.lastIndexOf('/'); + if (p < 0) { + p = 0; + } + int e = name.lastIndexOf('.'); + if (e < 0) { + e = name.length(); + } + return name.substring(p, e).replace("/", ""); + } + + protected static String genExportNameData(final String name) { + checkField("genExportNameData(name)", name); + int p = name.lastIndexOf('/'); + if (p < 0) { + p = 0; + } + return name.substring(p).replace("/", ""); + } + + protected static String getCategory(final String name) { + checkField("getCategory(name)", name); + final int p = name.lastIndexOf('/'); + if (p < 0) { + return fixPreAndPost(""); + } + return fixPreAndPost(name.substring(0, p + 1)); + } + + private static String checkField(final String field, final String category) { + if (category == null) { + throw new IllegalArgumentException(field + "category not be null"); + } + if (category.isBlank()) { + throw new IllegalArgumentException(field + "must not be blank"); + } + return category; + } +} diff --git a/core/src/main/java/io/opencmw/domain/NoData.java b/core/src/main/java/io/opencmw/domain/NoData.java new file mode 100644 index 00000000..2d8e7af0 --- /dev/null +++ b/core/src/main/java/io/opencmw/domain/NoData.java @@ -0,0 +1,10 @@ +package io.opencmw.domain; + +import io.opencmw.serialiser.annotations.MetaInfo; + +/** + * dummy domain object definition to indicate that no input/output data is requested + */ +@MetaInfo(description = "dummy domain object definition to indicate that no input/output data is requested") +public class NoData { +} diff --git a/core/src/main/java/io/opencmw/filter/EvtTypeFilter.java b/core/src/main/java/io/opencmw/filter/EvtTypeFilter.java new file mode 100644 index 00000000..c0d3f57f --- /dev/null +++ b/core/src/main/java/io/opencmw/filter/EvtTypeFilter.java @@ -0,0 +1,96 @@ +package io.opencmw.filter; + +import java.util.Objects; +import java.util.function.Predicate; + +import io.opencmw.Filter; + +public class EvtTypeFilter implements Filter { + public DataType evtType = DataType.UNKNOWN; + public UpdateType updateType = UpdateType.UNKNOWN; + public String typeName = ""; + protected int hashCode = 0; // NOPMD + + @Override + public void clear() { + hashCode = 0; + evtType = DataType.UNKNOWN; + updateType = UpdateType.UNKNOWN; + typeName = ""; + } + + @Override + public void copyTo(final Filter other) { + if (!(other instanceof EvtTypeFilter)) { + return; + } + ((EvtTypeFilter) other).hashCode = this.hashCode; + ((EvtTypeFilter) other).evtType = this.evtType; + ((EvtTypeFilter) other).typeName = this.typeName; + ((EvtTypeFilter) other).updateType = this.updateType; + } + + @Override + public boolean equals(final Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof EvtTypeFilter)) { + return false; + } + final EvtTypeFilter other = (EvtTypeFilter) obj; + return evtType == other.evtType && updateType == other.updateType && Objects.equals(typeName, other.typeName); + } + + @Override + public int hashCode() { + return hashCode == 0 ? hashCode = Objects.hash(evtType, updateType, typeName) : hashCode; + } + + @Override + public String toString() { + return '[' + EvtTypeFilter.class.getSimpleName() + ": evtType=" + evtType + " typeName='" + typeName + "']"; + } + + public enum DataType { + TIMING_EVENT, + AGGREGATE_DATA, + DEVICE_DATA, + SETTING_SUPPLY_DATA, + PROCESSED_DATA, + OTHER, + UNKNOWN + } + + public enum UpdateType { + EMPTY, + PARTIAL, + COMPLETE, + OTHER, + UNKNOWN + } + + public static Predicate isTimingData() { + return t -> t.evtType == DataType.TIMING_EVENT; + } + + public static Predicate isTimingData(final String typeName) { + return t -> t.evtType == DataType.TIMING_EVENT && Objects.equals(t.typeName, typeName); + } + + public static Predicate isDeviceData() { + return t -> t.evtType == DataType.DEVICE_DATA; + } + + public static Predicate isDeviceData(final String typeName) { + return t -> t.evtType == DataType.DEVICE_DATA && Objects.equals(t.typeName, typeName); + } + + public static Predicate isSettingsData() { + return t -> t.evtType == DataType.SETTING_SUPPLY_DATA; + } + + public static Predicate isSettingsData(final String typeName) { + return t -> t.evtType == DataType.SETTING_SUPPLY_DATA && Objects.equals(t.typeName, typeName); + } +} diff --git a/core/src/main/java/io/opencmw/filter/TimingCtx.java b/core/src/main/java/io/opencmw/filter/TimingCtx.java new file mode 100644 index 00000000..47d824a0 --- /dev/null +++ b/core/src/main/java/io/opencmw/filter/TimingCtx.java @@ -0,0 +1,206 @@ +package io.opencmw.filter; + +import java.text.SimpleDateFormat; +import java.util.Locale; +import java.util.Objects; +import java.util.function.Predicate; + +import org.apache.commons.lang3.StringUtils; + +import io.opencmw.Filter; + +import com.jsoniter.spi.JsoniterSpi; + +@SuppressWarnings({ "PMD.TooManyMethods" }) // - the nature of this class definition +public class TimingCtx implements Filter { + public static final String WILD_CARD = "ALL"; + public static final int WILD_CARD_VALUE = -1; + public static final String SELECTOR_PREFIX = "FAIR.SELECTOR."; + /** selector string, e.g.: 'FAIR.SELECTOR.C=0:S=1:P=3:T=101' */ + public String selector = ""; + /** Beam-Production-Chain (BPC) ID - uninitialised/wildcard value = -1 */ + public int cid; + /** Sequence ID -- N.B. this is the timing sequence number not the disruptor sequence ID */ + public int sid; + /** Beam-Process ID (PID) - uninitialised/wildcard value = -1 */ + public int pid; + /** timing group ID - uninitialised/wildcard value = -1 */ + public int gid; + /** Beam-Production-Chain-Time-Stamp - UTC in [us] since 1.1.1980 */ + public long bpcts; + /** stores the settings-supply related ctx name */ + public String ctxName; + protected int hashCode = 0; // NOPMD cached hash code + static { + // custom JsonIter decoder + JsoniterSpi.registerTypeDecoder(TimingCtx.class, iter -> TimingCtx.get(iter.readString())); + } + public TimingCtx() { + clear(); // NOPMD -- called during initialisation + } + + @Override + public void clear() { + hashCode = 0; + selector = ""; + cid = -1; + sid = -1; + pid = -1; + gid = -1; + bpcts = -1; + ctxName = ""; + } + + @Override + public void copyTo(final Filter other) { + if (!(other instanceof TimingCtx)) { + return; + } + final TimingCtx otherCtx = (TimingCtx) other; + otherCtx.selector = this.selector; + otherCtx.cid = this.cid; + otherCtx.sid = this.sid; + otherCtx.pid = this.pid; + otherCtx.gid = this.gid; + otherCtx.bpcts = this.bpcts; + otherCtx.ctxName = this.ctxName; + otherCtx.hashCode = this.hashCode; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + final TimingCtx otherCtx = (TimingCtx) o; + if (hashCode != otherCtx.hashCode() || cid != otherCtx.cid || sid != otherCtx.sid || pid != otherCtx.pid || gid != otherCtx.gid || bpcts != otherCtx.bpcts) { + return false; + } + + return Objects.equals(selector, otherCtx.selector); + } + + @Override + public int hashCode() { + if (hashCode != 0) { + return hashCode; + } + hashCode = selector == null ? 0 : selector.hashCode(); + hashCode = 31 * hashCode + cid; + hashCode = 31 * hashCode + sid; + hashCode = 31 * hashCode + pid; + hashCode = 31 * hashCode + gid; + hashCode = 31 * hashCode + Long.hashCode(bpcts); + return hashCode; + } + + public Predicate matches(final TimingCtx other) { + return t -> this.equals(other); + } + + /** + * TODO: add more thorough documentation or reference + * + * @param selector new selector to be parsed, e.g. 'FAIR.SELECTOR.ALL', 'FAIR.SELECTOR.C=1:S=3:P:3:T:103' + * @param bpcts beam-production-chain time-stamp [us] + */ + @SuppressWarnings("PMD.NPathComplexity") // -- parser/format has intrinsically large number of possible combinations + public void setSelector(final String selector, final long bpcts) { + if (bpcts < 0) { + throw new IllegalArgumentException("BPCTS time stamp < 0 :" + bpcts); + } + try { + clear(); + this.selector = Objects.requireNonNull(selector, "selector string must not be null"); + this.bpcts = bpcts; + + final String selectorUpper = selector.toUpperCase(Locale.UK); + if (selector.isBlank() || WILD_CARD.equals(selectorUpper)) { + return; + } + + final String[] identifiers = StringUtils.replace(selectorUpper, SELECTOR_PREFIX, "", 1).split(":"); + if (identifiers.length == 1 && WILD_CARD.equals(identifiers[0])) { + return; + } + + for (String tag : identifiers) { + final String[] splitSubComponent = tag.split("="); + assert splitSubComponent.length == 2 : "invalid selector: " + selector; // NOPMD NOSONAR assert only while debugging + final int value = splitSubComponent[1].equals(WILD_CARD) ? -1 : Integer.parseInt(splitSubComponent[1]); + switch (splitSubComponent[0]) { + case "C": + this.cid = value; + break; + case "S": + this.sid = value; + break; + case "P": + this.pid = value; + break; + case "T": + this.gid = value; + break; + default: + clear(); + throw new IllegalArgumentException("cannot parse selector: '" + selector + "' sub-tag: " + tag); + } + } + } catch (Throwable t) { // NOPMD NOSONAR should catch Throwable + clear(); + throw new IllegalArgumentException("Invalid selector or bpcts: " + selector, t); + } + } + + public static TimingCtx get(final String ctxString) { + final TimingCtx ctx = new TimingCtx(); + ctx.setSelector(ctxString, 0); + return ctx; + } + + @Override + public String toString() { + final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.UK); + return '[' + TimingCtx.class.getSimpleName() + ": bpcts=" + bpcts + " (\"" + sdf.format(bpcts / 1_000_000) + "\"), selector='" + selector + "', cid=" + cid + ", sid=" + sid + ", pid=" + pid + ", gid=" + gid + ']'; + } + + public static Predicate matches(final int cid, final int sid, final int pid, final long bpcts) { + return t -> t.bpcts == bpcts && t.cid == cid && wildCardMatch(t.sid, sid) && wildCardMatch(t.pid, pid); + } + + public static Predicate matches(final int cid, final int sid, final long bpcts) { + return t -> t.bpcts == bpcts && wildCardMatch(t.cid, cid) && wildCardMatch(t.sid, sid); + } + + public static Predicate matches(final int cid, final long bpcts) { + return t -> t.bpcts == bpcts && wildCardMatch(t.cid, cid); + } + + public static Predicate matches(final int cid, final int sid, final int pid) { + return t -> wildCardMatch(t.cid, cid) && wildCardMatch(t.sid, sid) && wildCardMatch(t.pid, pid); + } + + public static Predicate matches(final int cid, final int sid) { + return t -> wildCardMatch(t.cid, cid) && wildCardMatch(t.sid, sid); + } + + public static Predicate matchesBpcts(final long bpcts) { + return t -> t.bpcts == bpcts; + } + + public static Predicate isOlderBpcts(final long bpcts) { + return t -> t.bpcts < bpcts; + } + + public static Predicate isNewerBpcts(final long bpcts) { + return t -> t.bpcts > bpcts; + } + + protected static boolean wildCardMatch(final int a, final int b) { + return a == b || a == WILD_CARD_VALUE || b == WILD_CARD_VALUE; + } +} diff --git a/core/src/main/java/io/opencmw/rbac/BasicRbacRole.java b/core/src/main/java/io/opencmw/rbac/BasicRbacRole.java new file mode 100644 index 00000000..0235575b --- /dev/null +++ b/core/src/main/java/io/opencmw/rbac/BasicRbacRole.java @@ -0,0 +1,45 @@ +package io.opencmw.rbac; + +import java.util.Locale; + +/** + * basic definition of common Role-Based-Access-Control (RBAC) roles + * + * original RBAC concept: + *
    + *
  • Ferraiolo, D.F. & Kuhn, D.R. (October 1992). "Role-Based Access Control". 15th National Computer Security Conference: 554–563. + * https://csrc.nist.gov/CSRC/media/Publications/conference-paper/1992/10/13/role-based-access-controls/documents/ferraiolo-kuhn-92.pdf + *
  • + *
  • Sandhu, R., Coyne, E.J., Feinstein, H.L. and Youman, C.E. (August 1996). "Role-Based Access Control Models". IEEE Computer. 29 (2): 38–47. CiteSeerX 10.1.1.50.7649. doi:10.1109/2.485845 + * https://csrc.nist.gov/projects/role-based-access-control + *
  • + *
+ */ +public enum BasicRbacRole implements RbacRole { + ADMIN(0), // highest priority in queues + READ_WRITE(100), + READ_ONLY(200), + ANYONE(300), + NULL(300); // lowest priority in queues + + private final int priority; + + BasicRbacRole(final int priority) { + this.priority = priority; + } + + @Override + public String getName() { + return this.toString(); + } + + @Override + public int getPriority() { + return this.priority; + } + + @Override + public BasicRbacRole getRole(final String roleName) { + return valueOf(roleName.toUpperCase(Locale.UK)); + } +} \ No newline at end of file diff --git a/core/src/main/java/io/opencmw/rbac/RbacProvider.java b/core/src/main/java/io/opencmw/rbac/RbacProvider.java new file mode 100644 index 00000000..a193644b --- /dev/null +++ b/core/src/main/java/io/opencmw/rbac/RbacProvider.java @@ -0,0 +1,7 @@ +package io.opencmw.rbac; + +/** + * Interface for RBAC implementations to sign messages with public key cryptography + */ +public interface RbacProvider { +} diff --git a/core/src/main/java/io/opencmw/rbac/RbacRole.java b/core/src/main/java/io/opencmw/rbac/RbacRole.java new file mode 100644 index 00000000..7288c5a4 --- /dev/null +++ b/core/src/main/java/io/opencmw/rbac/RbacRole.java @@ -0,0 +1,70 @@ +package io.opencmw.rbac; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; + +import org.jetbrains.annotations.NotNull; + +/** + * Interface for Role-Based-Access-Control (RBAC) roles + * + * original RBAC concept: + *
    + *
  • Ferraiolo, D.F. & Kuhn, D.R. (October 1992). "Role-Based Access Control". 15th National Computer Security Conference: 554–563. + * https://csrc.nist.gov/CSRC/media/Publications/conference-paper/1992/10/13/role-based-access-controls/documents/ferraiolo-kuhn-92.pdf + *
  • + *
  • Sandhu, R., Coyne, E.J., Feinstein, H.L. and Youman, C.E. (August 1996). "Role-Based Access Control Models". IEEE Computer. 29 (2): 38–47. CiteSeerX 10.1.1.50.7649. doi:10.1109/2.485845 + * https://csrc.nist.gov/projects/role-based-access-control + *
  • + *
+ */ +public interface RbacRole> extends Comparable { + default String getRoles(final Set roleSet) { + return roleSet.stream().map(RbacRole::toString).collect(Collectors.joining(", ")); + } + + default Set getRoles(final String roleString) { + if (roleString.contains(":")) { + throw new IllegalArgumentException("roleString must not contain [:]"); + } + + final HashSet roles = new HashSet<>(); + for (final String role : roleString.replaceAll("\\s", "").split(",")) { + if (role == null || role.isEmpty() || "*".equals(role)) { // NOPMD + continue; + } + roles.add(getRole(role.toUpperCase(Locale.UK))); + } + + return Collections.unmodifiableSet(roles); + } + + T getRole(String roleName); + + /** + * + * @return role name + */ + String getName(); + + /** + * + * @return role priority used to schedule tasks or position in queues ( smaller numbers == higher importance) + */ + int getPriority(); + + @Override + default int compareTo(@NotNull RbacRole otherRole) { + System.err.println("T = " + otherRole); + if (getPriority() > otherRole.getPriority()) { + return 1; + } + if (getPriority() == otherRole.getPriority()) { + return 0; + } + return 1; + } +} diff --git a/core/src/main/java/io/opencmw/rbac/RbacToken.java b/core/src/main/java/io/opencmw/rbac/RbacToken.java new file mode 100644 index 00000000..ebc85801 --- /dev/null +++ b/core/src/main/java/io/opencmw/rbac/RbacToken.java @@ -0,0 +1,66 @@ +package io.opencmw.rbac; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +import org.zeromq.ZMQ; + +public class RbacToken { + private final static String RBAC_TOKEN_PREFIX = "RBAC"; + private final String signedHashCode; + private final RbacRole> rbacRole; + private final String stringRepresentation; + private final byte[] byteRepresentation; + + public RbacToken(final RbacRole> rbacRole, final String signedHashCode) { + if (rbacRole == null) { + throw new IllegalArgumentException("rbacRole must not be null: " + null); + } + if (signedHashCode == null) { + throw new IllegalArgumentException("signedHashCode must not be null: " + null); + } + this.rbacRole = rbacRole; + this.signedHashCode = signedHashCode; + this.stringRepresentation = RBAC_TOKEN_PREFIX + "=" + this.rbacRole.getName() + "," + signedHashCode; + this.byteRepresentation = stringRepresentation.getBytes(StandardCharsets.UTF_8); + + // BCrypt.hashpw() + } + + public RbacRole> getRole() { + return rbacRole; + } + + public String getSignedHashCode() { + return signedHashCode; + } + + @Override + public String toString() { + return stringRepresentation; + } + + public byte[] getBytes() { + return Arrays.copyOf(byteRepresentation, byteRepresentation.length); + } + + public static RbacToken from(final byte[] rbacToken) { + return from(rbacToken, rbacToken.length); + } + + public static RbacToken from(final byte[] rbacToken, final int length) { + return from(new String(rbacToken, 0, length, ZMQ.CHARSET)); + } + + public static RbacToken from(final String rbacToken) { + if (rbacToken == null || rbacToken.isBlank()) { + return new RbacToken(BasicRbacRole.ANYONE, ""); + } + final String[] component = rbacToken.split("[,=]"); + if (component.length != 3 || !RBAC_TOKEN_PREFIX.equals(component[0])) { + // protocol error: sent token with less or more than two commas + return new RbacToken(BasicRbacRole.NULL, ""); + } + return new RbacToken(BasicRbacRole.NULL.getRole(component[1]), component[2]); + } +} diff --git a/core/src/main/java/io/opencmw/utils/AnsiDefs.java b/core/src/main/java/io/opencmw/utils/AnsiDefs.java new file mode 100644 index 00000000..e71b4897 --- /dev/null +++ b/core/src/main/java/io/opencmw/utils/AnsiDefs.java @@ -0,0 +1,17 @@ +package io.opencmw.utils; + +/** + * basic ANSI colour code and other escape character definitions @see https://en.wikipedia.org/wiki/ANSI_escape_code + */ +public class AnsiDefs { // NOPMD - nomen est omen + public static final int MIN_PRINTABLE_CHAR = 32; + public static final String ANSI_RESET = "\u001B[0m"; + public static final String ANSI_BLACK = "\u001B[30m"; + public static final String ANSI_RED = "\u001B[31m"; + public static final String ANSI_GREEN = "\u001B[32m"; + public static final String ANSI_YELLOW = "\u001B[33m"; + public static final String ANSI_BLUE = "\u001B[34m"; + public static final String ANSI_PURPLE = "\u001B[35m"; + public static final String ANSI_CYAN = "\u001B[36m"; + public static final String ANSI_WHITE = "\u001B[37m"; +} diff --git a/core/src/main/java/io/opencmw/utils/Cache.java b/core/src/main/java/io/opencmw/utils/Cache.java new file mode 100644 index 00000000..beea949d --- /dev/null +++ b/core/src/main/java/io/opencmw/utils/Cache.java @@ -0,0 +1,374 @@ +package io.opencmw.utils; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; +import java.util.stream.Collectors; + +import org.jetbrains.annotations.NotNull; + +/** + * A simple map based cache with timeOut and limit + * + * usage example: + * + *
+ *  {@code
+ *     public class Demo {
+ *         private Cache cache;
+ *
+ *         public Demo() {
+ *             cache = final Cache cache = Cache.builder().withLimit(10)
+ *                  .withTimeout(100, TimeUnit.MILLISECONDS).build();
+ *             // alternatively:
+ *             // cache = new Cache(100, TimeUnit.MILLISECONDS, 10);
+ *
+ *             String name1 = "Han Solo";
+ *
+ *             cache.put(name1, 10);
+ *
+ *             System.out.println(name1 + " is cached: " + isCached(name1));
+ *
+ *             // Wait 1 second
+ *             try {
+ *                 Thread.sleep(1000);
+ *             } catch (InterruptedException e) {
+ *                 e.printStackTrace();
+ *             }
+ *
+ *             System.out.println(name1 + " is cached: " + isCached(name1));
+ *
+ *             // Wait another second
+ *             try {
+ *                 Thread.sleep(1000);
+ *             } catch (InterruptedException e) {
+ *                 e.printStackTrace();
+ *             }
+ *
+ *             System.out.println(name1 + " is cached: " + isCached(name1));
+ *         }
+ *
+ *         private boolean isCached(final String KEY) {
+ *             return cache.get(KEY).isPresent();
+ *         }
+ *
+ *         public static void main(String[] args) {
+ *             new Demo();
+ *         }
+ *     }
+ * }
+ * 
+ * + * + * Original code courtesy from: https://github.com/HanSolo/cache + * + * @author Gerrit Grunwald (aka. HanSolo, original concept) + * @author rstein + * + * @param search key + * @param cached value + */ +@SuppressWarnings({ "PMD.DoNotUseThreads", "PMD.TooManyMethods" }) // thread use necessary for maintenance tasks, methods due to Map interface +public class Cache implements Map { + private final ConcurrentHashMap dataCache; + private final ConcurrentHashMap timeOutMap; + private final ChronoUnit chronoUnit; + private final TimeUnit timeUnit; + private final long timeOut; + private final int limit; + private final BiConsumer preListener; + private final BiConsumer postListener; + + public Cache(final int limit) { + this(0, TimeUnit.MILLISECONDS, limit, null, null); + } + + public Cache(final long timeOut, final TimeUnit timeUnit) { + this(timeOut, timeUnit, Integer.MAX_VALUE, null, null); + } + + public Cache(final long timeOut, final TimeUnit timeUnit, final int limit) { + this(timeOut, timeUnit, limit, null, null); + } + + private Cache(final long timeOut, final TimeUnit timeUnit, final int limit, final BiConsumer preListener, final BiConsumer postListener) { + dataCache = new ConcurrentHashMap<>(); + timeOutMap = new ConcurrentHashMap<>(); + + if (timeOut < 0) { + throw new IllegalArgumentException("Timeout cannot be negative"); + } + if (timeOut > 0 && null == timeUnit) { + throw new IllegalArgumentException("TimeUnit cannot be null if timeOut is > 0"); + } + if (limit < 1) { + throw new IllegalArgumentException("Limit cannot be smaller than 1"); + } + + this.timeOut = timeOut; + this.timeUnit = timeUnit; + chronoUnit = convertToChronoUnit(timeUnit); + this.limit = limit; + + this.preListener = preListener; + this.postListener = postListener; + + final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(r -> { + final Thread t = Executors.defaultThreadFactory().newThread(r); + t.setName(Cache.class.getCanonicalName() + "-Thread"); + t.setDaemon(true); + return t; + }); // Daemon Service + + if (timeOut != 0) { + executor.scheduleAtFixedRate(this::checkTime, 0, timeOut, timeUnit); + } + } + + @Override + public void clear() { + dataCache.clear(); + timeOutMap.clear(); + } + + @Override + public boolean containsKey(final Object key) { + return dataCache.containsKey(key); + } + + @Override + public boolean containsValue(final Object value) { + return dataCache.containsValue(value); + } + + @Override + public @NotNull Set> entrySet() { + return dataCache.entrySet(); + } + + @Override + @SuppressWarnings("unchecked") + public V get(final Object key) { + return getIfPresent((K) key); + } + + public V getIfPresent(final K key) { + timeOutMap.put(key, Instant.now()); + return dataCache.getOrDefault(key, null); + } + + public long getLimit() { + return limit; + } + + public Optional getOptional(final K key) { + return Optional.ofNullable(getIfPresent(key)); + } + + public int getSize() { + return dataCache.size(); + } + + public long getTimeout() { + return timeOut; + } + + public TimeUnit getTimeUnit() { + return timeUnit; + } + + @Override + public boolean isEmpty() { + return dataCache.isEmpty(); + } + + @Override + public @NotNull Set keySet() { + return dataCache.keySet(); + } + + @Override + public V put(final K key, final V value) { + checkSize(); + final V val = dataCache.put(key, value); + timeOutMap.put(key, Instant.now()); + return val; + } + + @Override + public V putIfAbsent(final K key, final V value) { + checkSize(); + final V val = dataCache.putIfAbsent(key, value); + timeOutMap.putIfAbsent(key, Instant.now()); + return val; + } + + @Override + public void putAll(final Map m) { + checkSize(m.size()); + dataCache.putAll(m); + final Instant now = Instant.now(); + m.keySet().forEach(key -> timeOutMap.putIfAbsent(key, now)); + } + + @Override + public V remove(final Object key) { + final V val = dataCache.remove(key); + timeOutMap.remove(key); + return val; + } + + @Override + public int size() { + return dataCache.size(); + } + + @Override + public @NotNull Collection values() { + return dataCache.values(); + } + + protected void checkSize() { + checkSize(1); + } + + protected void checkSize(final int nNewElements) { + if (dataCache.size() < limit) { + return; + } + final int surplusEntries = Math.max(dataCache.size() - limit + nNewElements, 0); + final List toBeRemoved = timeOutMap.entrySet().stream().sorted(Entry.comparingByValue().reversed()).limit(surplusEntries).map(Entry::getKey).collect(Collectors.toList()); + removeEntries(toBeRemoved); + } + + protected void checkTime() { + final Instant cutoffTime = Instant.now().minus(timeOut, chronoUnit); + final List toBeRemoved = timeOutMap.entrySet().stream().filter(entry -> entry.getValue().isBefore(cutoffTime)).map(Entry::getKey).collect(Collectors.toList()); + removeEntries(toBeRemoved); + } + + private void removeEntries(final List toBeRemoved) { + final HashMap removalMap; + if (preListener == null && postListener == null) { + removalMap = null; + } else { + removalMap = new HashMap<>(); + toBeRemoved.forEach(key -> removalMap.put(key, dataCache.get(key))); + } + + // call registered pre-listener + if (preListener != null) { + removalMap.forEach(preListener); + } + + toBeRemoved.forEach(key -> { + timeOutMap.remove(key); + dataCache.remove(key); + }); + + // call registered post-listener + if (postListener != null) { + removalMap.forEach(postListener); + } + } + + public static CacheBuilder builder() { + return new CacheBuilder<>(); + } + + protected static int clamp(final int min, final int max, final int value) { + if (value < min) { + return min; + } + return Math.min(value, max); + } + + protected static long clamp(final long min, final long max, final long value) { + if (value < min) { + return min; + } + return Math.min(value, max); + } + + protected static ChronoUnit convertToChronoUnit(final TimeUnit timeUnit) { + switch (timeUnit) { + case NANOSECONDS: + return ChronoUnit.NANOS; + case MICROSECONDS: + return ChronoUnit.MICROS; + case SECONDS: + return ChronoUnit.SECONDS; + case MINUTES: + return ChronoUnit.MINUTES; + case HOURS: + return ChronoUnit.HOURS; + case DAYS: + return ChronoUnit.DAYS; + case MILLISECONDS: + default: + return ChronoUnit.MILLIS; + } + } + + public static class CacheBuilder { + private int limit = Integer.MAX_VALUE; + private long timeOut; + private TimeUnit timeUnit = TimeUnit.MILLISECONDS; + private BiConsumer preListener; + private BiConsumer postListener; + + private CacheBuilder() { + // only called via builderCacheRemovalListener + } + + public Cache build() { + return new Cache<>(timeOut, timeUnit, limit, preListener, postListener); + } + + public CacheBuilder withLimit(final int limit) { + if (limit < 1) { + throw new IllegalArgumentException("Limit cannot be smaller than 1"); + } + this.limit = limit; + return this; + } + + public CacheBuilder withPostListener(final BiConsumer listener) { + if (listener == null) { + throw new IllegalArgumentException("listener cannot be null"); + } + this.postListener = listener; + return this; + } + + public CacheBuilder withPreListener(final BiConsumer listener) { + if (listener == null) { + throw new IllegalArgumentException("listener cannot be null"); + } + this.preListener = listener; + return this; + } + + public CacheBuilder withTimeout(final long timeOut, final TimeUnit timeUnit) { + if (timeOut < 0) { + throw new IllegalArgumentException("Timeout cannot be negative"); + } + if (null == timeUnit) { + throw new IllegalArgumentException("TimeUnit cannot be null"); + } + this.timeOut = clamp(0, Integer.MAX_VALUE, timeOut); + this.timeUnit = timeUnit; + return this; + } + } +} diff --git a/core/src/main/java/io/opencmw/utils/CustomFuture.java b/core/src/main/java/io/opencmw/utils/CustomFuture.java new file mode 100644 index 00000000..a57ebb40 --- /dev/null +++ b/core/src/main/java/io/opencmw/utils/CustomFuture.java @@ -0,0 +1,118 @@ +package io.opencmw.utils; + +import java.util.concurrent.CancellationException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +public class CustomFuture implements Future { + private static final String FUTURE_HAS_BEEN_CANCELLED = "future has been cancelled"; + protected final Lock lock = new ReentrantLock(); + protected final Condition processorNotifyCondition = lock.newCondition(); + protected final AtomicBoolean done = new AtomicBoolean(false); + private final AtomicBoolean requestCancel = new AtomicBoolean(false); + private final AtomicBoolean cancelled = new AtomicBoolean(false); + private T reply; + private Throwable exception; + + @Override + public boolean cancel(final boolean mayInterruptIfRunning) { + if (done.getAndSet(true)) { + return false; + } + cancelled.set(true); + notifyListener(); + return !requestCancel.getAndSet(true); + } + + @Override + public T get() throws ExecutionException, InterruptedException { + try { + return get(0, TimeUnit.NANOSECONDS); + } catch (TimeoutException e) { + // cannot normally occur -- need this because we re-use 'get(...)' to avoid code duplication + throw new ExecutionException("TimeoutException should not occur here", e); + } + } + + @SuppressWarnings("NullableProblems") + @Override + public T get(final long timeout, final TimeUnit unit) throws ExecutionException, InterruptedException, TimeoutException { + if (cancelled.get()) { + throw new CancellationException(FUTURE_HAS_BEEN_CANCELLED); + } + + if (isDone()) { + if (exception == null) { + return reply; + } + throw new ExecutionException(exception); + } + lock.lock(); + try { + while (!isDone()) { + if (timeout > 0) { + if (!processorNotifyCondition.await(timeout, unit)) { + throw new TimeoutException(); + } + } else { + processorNotifyCondition.await(); + } + if (cancelled.get()) { + throw new CancellationException(FUTURE_HAS_BEEN_CANCELLED); + } + } + } finally { + lock.unlock(); + } + if (exception != null) { + throw new ExecutionException(exception); + } + return reply; + } + + @Override + public boolean isCancelled() { + return cancelled.get(); + } + + @Override + public boolean isDone() { + return done.get(); + } + + /** + * set reply and notify potential listeners + * @param newValue the new value to be notified + * @throws IllegalStateException in case this method has been already called or Future has been cancelled + */ + public void setReply(final T newValue) { + if (done.getAndSet(true)) { + throw new IllegalStateException("future is not running anymore (either cancelled or already notified)"); + } + this.reply = newValue; + notifyListener(); + } + + public void setException(final Throwable exception) { + if (done.getAndSet(true)) { + throw new IllegalStateException("future is not running anymore (either cancelled or already notified)"); + } + this.exception = exception; + notifyListener(); + } + + private void notifyListener() { + lock.lock(); + try { + processorNotifyCondition.signalAll(); + } finally { + lock.unlock(); + } + } +} \ No newline at end of file diff --git a/core/src/main/java/io/opencmw/utils/LimitedArrayList.java b/core/src/main/java/io/opencmw/utils/LimitedArrayList.java new file mode 100644 index 00000000..abf8e286 --- /dev/null +++ b/core/src/main/java/io/opencmw/utils/LimitedArrayList.java @@ -0,0 +1,54 @@ +package io.opencmw.utils; + +import java.util.ArrayList; + +/** + * @author rstein + * @param generic list element type + */ +public class LimitedArrayList extends ArrayList { + private static final long serialVersionUID = 7158175707385120597L; + private int limit; + + /** + * + * @param limit length of queue in terms of number of elements + */ + public LimitedArrayList(final int limit) { + super(); + if (limit < 1) { + throw new IllegalArgumentException("limit = '" + limit + "'must be >=1 "); + } + this.limit = limit; + } + + @Override + public boolean add(final E o) { + final boolean added = super.add(o); + while (added && size() > limit) { + super.remove(0); + } + return added; + } + + /** + * + * @return length of queue in terms of number of elements + */ + public int getLimit() { + return limit; + } + + /** + * + * @param newLimit length of queue in terms of number of elements + * @return newly set limit (if valid) + */ + public int setLimit(final int newLimit) { + if (newLimit < 1) { + throw new IllegalArgumentException("limit = '" + limit + "'must be >=1 "); + } + limit = newLimit; + return limit; + } +} diff --git a/core/src/main/java/io/opencmw/utils/NoDuplicatesList.java b/core/src/main/java/io/opencmw/utils/NoDuplicatesList.java new file mode 100644 index 00000000..b94faa71 --- /dev/null +++ b/core/src/main/java/io/opencmw/utils/NoDuplicatesList.java @@ -0,0 +1,42 @@ +package io.opencmw.utils; + +import java.util.Collection; +import java.util.LinkedList; + +/** + * @author unknown + * @param generics + */ +public class NoDuplicatesList extends LinkedList { + private static final long serialVersionUID = -8547667608571765668L; + + @Override + public boolean add(E e) { + if (this.contains(e)) { + return false; + } + return super.add(e); + } + + @Override + public void add(int index, E element) { + if (this.contains(element)) { + return; + } + super.add(index, element); + } + + @Override + public boolean addAll(Collection collection) { + Collection copy = new LinkedList<>(collection); + copy.removeAll(this); + return super.addAll(copy); + } + + @Override + public boolean addAll(int index, Collection collection) { + Collection copy = new LinkedList<>(collection); + copy.removeAll(this); + return super.addAll(index, copy); + } +} diff --git a/core/src/main/java/io/opencmw/utils/SharedPointer.java b/core/src/main/java/io/opencmw/utils/SharedPointer.java new file mode 100644 index 00000000..4f2eef3e --- /dev/null +++ b/core/src/main/java/io/opencmw/utils/SharedPointer.java @@ -0,0 +1,82 @@ +package io.opencmw.utils; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +public class SharedPointer { + private static final String CLASS_NAME = SharedPointer.class.getSimpleName().intern(); + private T payload; + private Consumer destroyFunction; + private final AtomicInteger payloadUseCount = new AtomicInteger(0); + + /** + * + * @param payload the raw object to be held by this object + * @return this + */ + public SharedPointer set(final T payload) { + return set(payload, null); + } + + /** + * + * @param payload the raw object to be held by this object + * @param destroyFunction function executed when the last reference is destroyed + * @return this + */ + public SharedPointer set(final T payload, final Consumer destroyFunction) { + assert payload != null : "object must not be null"; + final int usageCount = payloadUseCount.get(); + if (usageCount > 0) { + throw new IllegalStateException("cannot set new variable - object not yet released - usageCount: " + usageCount); + } + this.payload = payload; + this.destroyFunction = destroyFunction; + payloadUseCount.getAndIncrement(); + return this; + } + + public T get() { + return payload; + } + + public R get(Class classType) { + return classType.cast(payload); + } + + public int getReferenceCount() { + return payloadUseCount.get(); + } + + public Class getType() { + return payload == null ? null : payload.getClass(); + } + + /** + * + * @return reference copy of this shared-pointer while increasing the usage count + */ + public SharedPointer getCopy() { + payloadUseCount.getAndIncrement(); + return this; + } + + public void release() { + if (payload == null || payloadUseCount.decrementAndGet() > 0) { + return; + } + if (destroyFunction != null) { + destroyFunction.accept(payload); + } + payload = null; // NOPMD + } + + @Override + public String toString() { + if (payload == null) { + return CLASS_NAME + "[useCount= " + payloadUseCount.get() + ", has destructor=" + (destroyFunction != null) + ", .class, null]"; + } + return CLASS_NAME + "[useCount= " + payloadUseCount.get() + ", has destructor=" + (destroyFunction != null) + ", " // + + payload.getClass().getSimpleName() + ".class, '" + payload.toString() + "']"; + } +} diff --git a/core/src/main/java/io/opencmw/utils/SystemProperties.java b/core/src/main/java/io/opencmw/utils/SystemProperties.java new file mode 100644 index 00000000..a08ec499 --- /dev/null +++ b/core/src/main/java/io/opencmw/utils/SystemProperties.java @@ -0,0 +1,62 @@ +package io.opencmw.utils; + +import static java.util.Map.Entry; + +import java.util.Properties; +import java.util.Set; + +public final class SystemProperties { // NOPMD -- nomen est omen + private static final Properties SYSTEM_PROPERTIES = System.getProperties(); + + private SystemProperties() { + // utility class + } + + public static String getProperty(final String key) { + return SYSTEM_PROPERTIES.getProperty(key); + } + + public static String getPropertyIgnoreCase(String key, String defaultValue) { + String value = SYSTEM_PROPERTIES.getProperty(key); + if (null != value) { + return value; + } + + // Not matching with the actual key then + Set> systemProperties = SYSTEM_PROPERTIES.entrySet(); + for (final Entry entry : systemProperties) { + if (key.equalsIgnoreCase((String) entry.getKey())) { + return (String) entry.getValue(); + } + } + return defaultValue; + } + + public static String getPropertyIgnoreCase(String key) { + return getPropertyIgnoreCase(key, null); + } + + public static double getValue(String key, double defaultValue) { + final String value = getProperty(key); + return value == null ? defaultValue : Double.parseDouble(value); + } + + public static int getValue(String key, int defaultValue) { + final String value = getProperty(key); + return value == null ? defaultValue : Integer.parseInt(value); + } + + public static double getValueIgnoreCase(String key, double defaultValue) { + final String value = getPropertyIgnoreCase(key); + return value == null ? defaultValue : Double.parseDouble(value); + } + + public static int getValueIgnoreCase(String key, int defaultValue) { + final String value = getPropertyIgnoreCase(key); + return value == null ? defaultValue : Integer.parseInt(value); + } + + public static Object put(final Object key, final Object value) { + return SYSTEM_PROPERTIES.put(key, value); + } +} \ No newline at end of file diff --git a/core/src/main/java/io/opencmw/utils/WorkerThreadFactory.java b/core/src/main/java/io/opencmw/utils/WorkerThreadFactory.java new file mode 100644 index 00000000..14de524e --- /dev/null +++ b/core/src/main/java/io/opencmw/utils/WorkerThreadFactory.java @@ -0,0 +1,74 @@ +package io.opencmw.utils; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.atomic.AtomicInteger; + +import org.jetbrains.annotations.NotNull; + +/** + * OpenCMW thread pool factory and default definitions + * + * default minimum thread pool size is set by system property: 'OpenCMW.defaultPoolSize' + * + * @author rstein + */ +@SuppressWarnings("PMD.DoNotUseThreads") +public class WorkerThreadFactory implements ThreadFactory { + private static final int MAX_THREADS = Math.max(Math.max(4, Runtime.getRuntime().availableProcessors()), // + Integer.parseInt(System.getProperties().getProperty("OpenCMW.defaultPoolSize", "10"))); + private static final AtomicInteger TREAD_COUNTER = new AtomicInteger(); + private static final ThreadFactory DEFAULT_FACTORY = Executors.defaultThreadFactory(); + private static final WorkerThreadFactory SELF = new WorkerThreadFactory("DefaultOpenCmwWorker"); + + private final String poolName; + private final int nThreads; + private final ExecutorService pool; + + public WorkerThreadFactory(final String poolName) { + this(poolName, -1); + } + + public WorkerThreadFactory(final String poolName, final int nThreads) { + this.poolName = poolName; + this.nThreads = nThreads <= 0 ? MAX_THREADS : nThreads; + this.pool = Executors.newFixedThreadPool(this.nThreads, this); + if (this.pool instanceof ThreadPoolExecutor) { + ((ThreadPoolExecutor) pool).setRejectedExecutionHandler((runnable, executor) -> { + try { + // work queue is full -> make the thread calling pool.execute() to wait + executor.getQueue().put(runnable); + } catch (InterruptedException e) { // NOPMD + // silently ignore + } + }); + } + } + + @Override + public Thread newThread(final @NotNull Runnable r) { + final Thread thread = DEFAULT_FACTORY.newThread(r); + TREAD_COUNTER.incrementAndGet(); + thread.setName(poolName + "#" + TREAD_COUNTER.intValue()); + thread.setDaemon(true); + return thread; + } + + public ExecutorService getPool() { + return pool; + } + + public String getPoolName() { + return poolName; + } + + public static WorkerThreadFactory getInstance() { + return SELF; + } + + public int getNumbersOfThreads() { + return nThreads; + } +} diff --git a/core/src/main/java/module-info.java b/core/src/main/java/module-info.java new file mode 100644 index 00000000..95d106d1 --- /dev/null +++ b/core/src/main/java/module-info.java @@ -0,0 +1,15 @@ +open module io.opencmw { + requires disruptor; + requires org.slf4j; + requires jeromq; + requires io.opencmw.serialiser; + requires org.apache.commons.lang3; + requires org.jetbrains.annotations; + requires jsoniter; + + exports io.opencmw; + exports io.opencmw.domain; + exports io.opencmw.filter; + exports io.opencmw.rbac; + exports io.opencmw.utils; +} \ No newline at end of file diff --git a/core/src/test/java/io/opencmw/AggregateEventHandlerTestSource.java b/core/src/test/java/io/opencmw/AggregateEventHandlerTestSource.java new file mode 100644 index 00000000..fd4b4e32 --- /dev/null +++ b/core/src/test/java/io/opencmw/AggregateEventHandlerTestSource.java @@ -0,0 +1,153 @@ +package io.opencmw; + +import java.util.Arrays; +import java.util.Iterator; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.LockSupport; + +import io.opencmw.filter.EvtTypeFilter; +import io.opencmw.filter.TimingCtx; +import io.opencmw.utils.SharedPointer; + +import com.lmax.disruptor.RingBuffer; + +/** + * An event Source to generate Events with different timing characteristics/orderings. + * + * @author Alexander Krimm + */ +public class AggregateEventHandlerTestSource implements Runnable { + private static final int DEFAULT_CHAIN = 3; + private static final long DEFAULT_DELTA = 20; + private static final long DEFAULT_PAUSE = 400; + // state for the event source + public final int repeat; + public final String[] eventList; + private final RingBuffer ringBuffer; + + /** + * Generate an event source which plays back the given sequence of events + * + * @param events A string containing a space separated list of events. first letter is type/bpcts, second is number + * Optionally you can add semicolon delimited key=value pairs to assign values in of the events + * @param repeat How often to repeat the given sequence (use zero value for infinite repetition) + * @param rb The ring buffer to publish the event into + */ + public AggregateEventHandlerTestSource(final String events, final int repeat, final RingBuffer rb) { + eventList = events.split(" "); + this.repeat = repeat; + this.ringBuffer = rb; + } + + @Override + public void run() { + long lastEvent = System.currentTimeMillis(); + long timeOffset = 0; + int repetitionCount = 0; + while (repeat == 0 || repeat > repetitionCount) { + final Iterator eventIterator = Arrays.stream(eventList).iterator(); + while (!Thread.interrupted() && eventIterator.hasNext()) { + final String eventToken = eventIterator.next(); + final String[] tokens = eventToken.split(";"); + if (tokens.length == 0 || tokens[0].isEmpty()) + continue; + if (tokens[0].equals("pause")) { + lastEvent += DEFAULT_PAUSE; + continue; + } + final Event currentEvent = generateEventFromToken(tokens, timeOffset, lastEvent, repetitionCount); + lastEvent = currentEvent.publishTime; + final long diffMilis = currentEvent.publishTime - System.currentTimeMillis(); + if (diffMilis > 0) { + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(diffMilis)); + } + ringBuffer.publishEvent((event, sequence, userPayload) -> { + final TimingCtx ctx = event.getFilter(TimingCtx.class); + if (ctx == null) { + throw new IllegalStateException("RingBufferEvent has not TimingCtx definition"); + } + final EvtTypeFilter evtType = event.getFilter(EvtTypeFilter.class); + if (evtType == null) { + throw new IllegalStateException("RingBufferEvent has not EvtTypeFilter definition"); + } + + event.arrivalTimeStamp = System.currentTimeMillis(); + event.payload = new SharedPointer<>(); + event.payload.set(userPayload.payload); + ctx.setSelector("FAIR.SELECTOR.c=" + userPayload.chain + "", userPayload.bpcts); + evtType.typeName = userPayload.device; + evtType.evtType = EvtTypeFilter.DataType.DEVICE_DATA; + evtType.updateType = EvtTypeFilter.UpdateType.COMPLETE; + }, currentEvent); + } + repetitionCount++; + } + } + + private Event generateEventFromToken(final String[] tokens, final long timeOffset, final long lastEvent, final int repetitionCount) { + String device = tokens[0].substring(0, 1); + long bpcts = Long.parseLong(tokens[0].substring(1)) + repetitionCount * 1000L; + String payload = device + bpcts; + long sourceTime = lastEvent + DEFAULT_DELTA; + long publishTime = sourceTime; + int chain = DEFAULT_CHAIN; + for (int i = 1; i < tokens.length; i++) { + String[] keyvalue = tokens[i].split("="); + if (keyvalue.length != 2) + continue; + switch (keyvalue[0]) { + case "time": + sourceTime = Long.parseLong(keyvalue[1]) + timeOffset; + publishTime = sourceTime; + break; + case "sourceTime": + sourceTime = Long.parseLong(keyvalue[1]) + timeOffset; + break; + case "publishTime": + publishTime = Long.parseLong(keyvalue[1]) + timeOffset; + break; + case "bpcts": + bpcts = Long.parseLong(keyvalue[1]) + repetitionCount * 1000L; + break; + case "chain": + chain = Integer.parseInt(keyvalue[1]); + break; + case "device": + device = keyvalue[1]; + break; + case "payload": + payload = keyvalue[1] + "(repetition count: " + repetitionCount + ")"; + break; + default: + throw new IllegalArgumentException("unable to process event keyvalue pair: " + Arrays.toString(keyvalue)); + } + } + return new Event(sourceTime, publishTime, bpcts, chain, device, payload); + } + + /** + * Mock event entry. + */ + public static class Event { + public final long sourceTime; + public final long publishTime; + public final long bpcts; + public final int chain; + public final String device; + public final Object payload; + + public Event(final long sourceTime, final long publishTime, final long bpcts, final int chain, final String device, final Object payload) { + this.sourceTime = sourceTime; + this.publishTime = publishTime; + this.bpcts = bpcts; + this.chain = chain; + this.device = device; + this.payload = payload; + } + + @Override + public String toString() { + return "Event{sourceTime=" + sourceTime + ", publishTime=" + publishTime + ", bpcts=" + bpcts + ", chain=" + chain + ", device='" + device + '\'' + ", payload=" + payload + '}'; + } + } +} diff --git a/core/src/test/java/io/opencmw/AggregateEventHandlerTests.java b/core/src/test/java/io/opencmw/AggregateEventHandlerTests.java new file mode 100644 index 00000000..97591244 --- /dev/null +++ b/core/src/test/java/io/opencmw/AggregateEventHandlerTests.java @@ -0,0 +1,179 @@ +package io.opencmw; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.awaitility.Awaitility; +import org.hamcrest.Matcher; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.opencmw.filter.EvtTypeFilter; +import io.opencmw.filter.TimingCtx; +import io.opencmw.utils.SharedPointer; + +import com.lmax.disruptor.BlockingWaitStrategy; +import com.lmax.disruptor.EventHandler; +import com.lmax.disruptor.RingBuffer; +import com.lmax.disruptor.TimeoutBlockingWaitStrategy; +import com.lmax.disruptor.dsl.Disruptor; +import com.lmax.disruptor.dsl.EventHandlerGroup; +import com.lmax.disruptor.dsl.ProducerType; +import com.lmax.disruptor.util.DaemonThreadFactory; + +/** + * Unit-Test for {@link AggregateEventHandler} + * + * @author Alexander Krimm + * @author rstein + */ +class AggregateEventHandlerTests { + private static final Logger LOGGER = LoggerFactory.getLogger(AggregateEventHandlerTests.class); + private static final String[] DEVICES = { "a", "b", "c" }; + private static Stream workingEventSamplesProvider() { // NOPMD NOSONAR - false-positive PMD error (unused function), function is used via reflection + return Stream.of( + arguments("ordinary", "a1 b1 c1 a2 b2 c2 a3 b3 c3", "a1 b1 c1; a2 b2 c2; a3 b3 c3", "", 1), + arguments("duplicate events", "a1 b1 c1 b1 a2 b2 c2 a2 a3 b3 c3 c3", "a1 b1 c1; a2 b2 c2; a3 b3 c3", "", 1), + arguments("reordered", "a1 c1 b1 a2 b2 c2 a3 b3 c3", "a1 b1 c1; a2 b2 c2; a3 b3 c3", "", 1), + arguments("interleaved", "a1 b1 a2 b2 c1 a3 b3 c2 c3", "a1 b1 c1; a2 b2 c2; a3 b3 c3", "", 1), + arguments("missing event", "a1 b1 a2 b2 c2 a3 b3 c3", "a1 b1; a2 b2 c2; a3 b3 c3", "1", 1), + arguments("missing device", "a1 b1 a2 b2 a3 b3", "a1 b1; a2 b2; a3 b3", "1 2 3", 1), + arguments("late", "a1 b1 a2 b2 c2 a3 b3 c3 c1", "a1 b1 c1; a2 b2 c2; a3 b3 c3", "", 1), + arguments("timeout without event", "a1 b1 c1 a2 b2", "a1 b1 c1; a2 b2", "2", 1), + arguments("long queue", "a1 b1 c1 a2 b2", "a1 b1 c1; a2 b2; a1001 b1001 c1001; a1002 b1002; a2001 b2001 c2001; a2002 b2002; a3001 b3001 c3001; a3002 b3002; a4001 b4001 c4001; a4002 b4002", "2 1002 2002 3002 4002", 5), + arguments("simple broken long queue", "a1 b1", "a1 b1; a1001 b1001; a2001 b2001; a3001 b3001; a4001 b4001", "1 1001 2001 3001 4001", 5), + arguments("single event timeout", "a1 b1 pause pause c1", "a1 b1", "1", 1)); + } + + @Test + void testFactoryMethods() { + assertDoesNotThrow(AggregateEventHandler::getFactory); + + final AggregateEventHandler.AggregateEventHandlerFactory factory = AggregateEventHandler.getFactory(); + assertThrows(IllegalArgumentException.class, factory::build); // missing aggregate name + + factory.setAggregateName("testAggregate"); + assertEquals("testAggregate", factory.getAggregateName()); + + assertTrue(factory.getDeviceList().isEmpty()); + factory.setDeviceList("testDevice1", "testDevice2"); + assertEquals(List.of("testDevice1", "testDevice2"), factory.getDeviceList()); + factory.getDeviceList().clear(); + assertTrue(factory.getDeviceList().isEmpty()); + factory.setDeviceList(List.of("testDevice1", "testDevice2")); + assertEquals(List.of("testDevice1", "testDevice2"), factory.getDeviceList()); + factory.getDeviceList().clear(); + + Predicate filter1 = rbEvt -> true; + Predicate filter2 = rbEvt -> false; + assertTrue(factory.getEvtTypeFilter().isEmpty()); + factory.setEvtTypeFilter(filter1, filter2); + assertEquals(List.of(filter1, filter2), factory.getEvtTypeFilter()); + factory.getEvtTypeFilter().clear(); + assertTrue(factory.getEvtTypeFilter().isEmpty()); + factory.setEvtTypeFilter(List.of(filter1, filter2)); + assertEquals(List.of(filter1, filter2), factory.getEvtTypeFilter()); + factory.getEvtTypeFilter().clear(); + + factory.setNumberWorkers(42); + assertEquals(42, factory.getNumberWorkers()); + assertThrows(IllegalArgumentException.class, () -> factory.setNumberWorkers(0)); + + factory.setRetentionSize(13); + assertEquals(13, factory.getRetentionSize()); + assertThrows(IllegalArgumentException.class, () -> factory.setRetentionSize(0)); + + factory.setTimeOut(2, TimeUnit.SECONDS); + assertEquals(2, factory.getTimeOut()); + assertEquals(TimeUnit.SECONDS, factory.getTimeOutUnit()); + assertThrows(IllegalArgumentException.class, () -> factory.setTimeOut(0, TimeUnit.SECONDS)); + assertThrows(IllegalArgumentException.class, () -> factory.setTimeOut(1, null)); + + assertThrows(IllegalArgumentException.class, factory::build); // missing ring buffer assignment + RingBuffer ringBuffer = RingBuffer.createSingleProducer(RingBufferEvent::new, 8, new BlockingWaitStrategy()); + assertThrows(IllegalArgumentException.class, () -> factory.setRingBuffer(null)); + factory.setRingBuffer(ringBuffer); + assertEquals(ringBuffer, factory.getRingBuffer()); + + assertDoesNotThrow(factory::build); + } + + @ParameterizedTest + @MethodSource("workingEventSamplesProvider") + void testSimpleEvents(final String eventSetupName, final String events, final String aggregatesAll, final String timeoutsAll, final int repeat) { + // handler which collects all aggregate events which are republished to the buffer + final Set> aggResults = ConcurrentHashMap.newKeySet(); + final Set aggTimeouts = ConcurrentHashMap.newKeySet(); + + EventHandler testHandler = (evt, seq, eob) -> { + LOGGER.atDebug().addArgument(evt).log("testHandler: {}"); + if (evt.payload == null) { + throw new IllegalStateException("RingBufferEvent payload is null."); + } + + final EvtTypeFilter evtType = evt.getFilter(EvtTypeFilter.class); + if (evtType == null) { + throw new IllegalStateException("RingBufferEvent has no EvtTypeFilter definition"); + } + final TimingCtx timingCtx = evt.getFilter(TimingCtx.class); + if (timingCtx == null) { + throw new IllegalStateException("RingBufferEvent has no TimingCtx definition"); + } + + if (evtType.evtType == EvtTypeFilter.DataType.AGGREGATE_DATA) { + if (!(evt.payload.get() instanceof Map)) { + throw new IllegalStateException("RingBufferEvent payload is not a Map: " + evt.payload.get()); + } + @SuppressWarnings("unchecked") + final Map> agg = (Map>) evt.payload.get(); + final Set payloads = agg.values().stream().map(SharedPointer::get).collect(Collectors.toSet()); + // add aggregate events to result vector + aggResults.add(payloads); + } + + if (evtType.evtType == EvtTypeFilter.DataType.AGGREGATE_DATA && evtType.updateType != EvtTypeFilter.UpdateType.COMPLETE) { + // time-out occured -- add failed aggregate event BPCTS to result vector + aggTimeouts.add(timingCtx.bpcts); + } + }; + + // create event ring buffer and add de-multiplexing processors + final Disruptor disruptor = new Disruptor<>(() -> new RingBufferEvent(TimingCtx.class, EvtTypeFilter.class), 256, DaemonThreadFactory.INSTANCE, ProducerType.MULTI, new TimeoutBlockingWaitStrategy(200, TimeUnit.MILLISECONDS)); + final AggregateEventHandler aggProc = AggregateEventHandler.getFactory().setRingBuffer(disruptor.getRingBuffer()).setAggregateName("testAggregate").setDeviceList(DEVICES).build(); + final EventHandlerGroup endBarrier = disruptor.handleEventsWith(testHandler).handleEventsWith(aggProc).then(aggProc.getAggregationHander()); + RingBuffer rb = disruptor.start(); + + // Use event source to publish demo events to the buffer. + AggregateEventHandlerTestSource testEventSource = new AggregateEventHandlerTestSource(events, repeat, rb); + assertDoesNotThrow(testEventSource::run); + + // wait for all events to be played and processed + Awaitility.await().atMost(Duration.ofSeconds(repeat)).until(() -> endBarrier.asSequenceBarrier().getCursor() == rb.getCursor() && Arrays.stream(aggProc.getAggregationHander()).allMatch(w -> w.bpcts == -1)); + // compare aggregated results and timeouts + assertFalse(aggResults.isEmpty()); + //noinspection unchecked + assertThat(aggResults, containsInAnyOrder(Arrays.stream(aggregatesAll.split(";")) + .filter(s -> !s.isEmpty()) + .map(s -> containsInAnyOrder(Arrays.stream(s.split(" ")).map(String::trim).filter(e -> !e.isEmpty()).toArray())) + .toArray(Matcher[] ::new))); + LOGGER.atDebug().addArgument(aggTimeouts).log("aggTimeouts: {}"); + assertThat(aggTimeouts, containsInAnyOrder(Arrays.stream(timeoutsAll.split(" ")).filter(s -> !s.isEmpty()).map(Long::parseLong).toArray(Long[] ::new))); + } +} diff --git a/core/src/test/java/io/opencmw/EventStoreTest.java b/core/src/test/java/io/opencmw/EventStoreTest.java new file mode 100644 index 00000000..34be287f --- /dev/null +++ b/core/src/test/java/io/opencmw/EventStoreTest.java @@ -0,0 +1,352 @@ +package io.opencmw; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.locks.LockSupport; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.spi.LoggingEventBuilder; + +import io.opencmw.filter.EvtTypeFilter; +import io.opencmw.filter.TimingCtx; +import io.opencmw.utils.Cache; +import io.opencmw.utils.SharedPointer; + +import com.lmax.disruptor.EventHandler; +import com.lmax.disruptor.LifecycleAware; +import com.lmax.disruptor.RingBuffer; +import com.lmax.disruptor.TimeoutHandler; +import com.lmax.disruptor.WaitStrategy; +import com.lmax.disruptor.YieldingWaitStrategy; +import com.lmax.disruptor.dsl.Disruptor; + +class EventStoreTest { + private final static Logger LOGGER = LoggerFactory.getLogger(EventStoreTest.class); + private final static boolean IN_ORDER = true; + + @Test + void testFactory() { + assertDoesNotThrow(EventStore::getFactory); + + final EventStore.EventStoreFactory factory = EventStore.getFactory(); + assertDoesNotThrow(factory::build); + + assertEquals(0, factory.getFilterConfig().length); + factory.setFilterConfig(TimingCtx.class, EvtTypeFilter.class); + assertArrayEquals(new Class[] { TimingCtx.class, EvtTypeFilter.class }, factory.getFilterConfig()); + + factory.setLengthHistoryBuffer(42); + assertEquals(42, factory.getLengthHistoryBuffer()); + + factory.setMaxThreadNumber(7); + assertEquals(7, factory.getMaxThreadNumber()); + + factory.setRingbufferSize(128); + assertEquals(128, factory.getRingbufferSize()); + factory.setRingbufferSize(42); + assertEquals(64, factory.getRingbufferSize()); + + factory.setSingleProducer(true); + assertTrue(factory.isSingleProducer()); + factory.setSingleProducer(false); + assertFalse(factory.isSingleProducer()); + + final Function muxCtx = evt -> "cid=" + evt.getFilter(TimingCtx.class).cid; + assertNotEquals(muxCtx, factory.getMuxCtxFunction()); + factory.setMuxCtxFunction(muxCtx); + assertEquals(muxCtx, factory.getMuxCtxFunction()); + + final Cache.CacheBuilder> ctxCacheBuilder = Cache.>builder().withLimit(100); + final WaitStrategy customWaitStrategy = new YieldingWaitStrategy(); + assertNotEquals(ctxCacheBuilder, factory.getMuxBuilder()); + assertNotEquals(customWaitStrategy, factory.getWaitStrategy()); + factory.setWaitStrategy(customWaitStrategy).setMuxBuilder(ctxCacheBuilder); + assertEquals(customWaitStrategy, factory.getWaitStrategy()); + assertEquals(ctxCacheBuilder, factory.getMuxBuilder()); + } + + @Test + void basicTest() { + assertDoesNotThrow(() -> EventStore.getFactory().setFilterConfig(TimingCtx.class, EvtTypeFilter.class).build()); + + // global multiplexing context function -> generate new EventStream per detected context, here: multiplexed on {@see TimingCtx#cid} + final Function muxCtx = evt -> "cid=" + evt.getFilter(TimingCtx.class).cid; + final Cache.CacheBuilder> ctxCacheBuilder = Cache.>builder().withLimit(100); + final EventStore es = EventStore.getFactory().setMuxCtxFunction(muxCtx).setMuxBuilder(ctxCacheBuilder).setFilterConfig(TimingCtx.class, EvtTypeFilter.class).build(); + + Predicate filterBp1 = evt -> evt.test(TimingCtx.class, TimingCtx.matches(-1, -1, 1)); + + es.register(filterBp1, muxCtx, (evts, evtStore, seq, eob) -> { + LOGGER.atTrace().addArgument(evts.get(0).payload.get()).log("SequencedFilteredTask 1.0: received cid == 1 : payload = {}"); + return null; + }).and(filterBp1, muxCtx, (evts, evtStore, seq, eob) -> { + LOGGER.atTrace().addArgument(evts.get(0).payload.get()).log("SequencedFilteredTask 1.1: received cid == 1 : payload = {}"); + return null; + }).and(filterBp1, muxCtx, (evts, evtStore, seq, eob) -> { + LOGGER.atTrace().addArgument(evts.get(0).payload.get()).log("SequencedFilteredTask 1.2: received cid == 1 : payload = {}"); + return null; + }).then(filterBp1, muxCtx, (evts, evtStore, seq, eob) -> { + LOGGER.atTrace().addArgument(evts.get(0).payload.get()).log("SequencedFilteredTask 2.0: received cid == 1 : payload = {}"); + return null; + }).then(filterBp1, muxCtx, (evts, evtStore, seq, eob) -> { + LOGGER.atTrace().addArgument(evts.get(0).payload.get()).log("SequencedFilteredTask 3.0: received cid == 1 : payload = {}"); + return null; + }).and(filterBp1, muxCtx, (evts, evtStore, seq, eob) -> { + LOGGER.atTrace().addArgument(evts.get(0).payload.get()).log("SequencedFilteredTask 3.1: received cid == 1 : payload = {}"); + return null; + }).and(filterBp1, muxCtx, (evts, evtStore, seq, eob) -> { + LOGGER.atTrace().addArgument(evts.get(0).payload.get()).log("SequencedFilteredTask 3.2: received cid == 1 : payload = {}"); + return null; + }); + + Predicate filterBp0 = evt -> evt.test(TimingCtx.class, TimingCtx.matches(-1, -1, 0)); + es.register(filterBp0, muxCtx, (evts, evtStore, seq, eob) -> { + final String history = evts.stream().map(b -> (String) b.payload.get()).collect(Collectors.joining(", ", "(", ")")); + final String historyAlt = es.getHistory(muxCtx.apply(evts.get(0)), filterBp0, seq, 30).stream().map(b -> (String) b.payload.get()).collect(Collectors.joining(", ", "(", ")")); + LOGGER.atTrace().addArgument(history).log("@@@EventHandler with history: {}"); + + // check identity between the two reference implementations + assertEquals(evts.size(), es.getHistory(muxCtx.apply(evts.get(0)), filterBp0, seq, 30).size()); + assertEquals(history, historyAlt); + return null; + }); + + assertNotNull(es.getRingBuffer()); + + es.start(); + testPublish(es, LOGGER.atTrace(), "message A", 0); + testPublish(es, LOGGER.atTrace(), "message B", 0); + testPublish(es, LOGGER.atTrace(), "message C", 0); + testPublish(es, LOGGER.atTrace(), "message A", 1); + testPublish(es, LOGGER.atTrace(), "message D", 0); + testPublish(es, LOGGER.atTrace(), "message E", 0); + + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(100)); // give a bit of time until all workers are finished + es.stop(); + } + + @Test + void attachEventHandlerTest() { + final Cache.CacheBuilder> ctxCacheBuilder = Cache.>builder().withLimit(100); + final Function muxCtx = evt -> "cid=" + evt.getFilter(TimingCtx.class).cid; + final EventStore es = EventStore.getFactory().setMuxCtxFunction(muxCtx).setMuxBuilder(ctxCacheBuilder).setFilterConfig(TimingCtx.class, EvtTypeFilter.class).build(); + + final AtomicInteger handlerCount1 = new AtomicInteger(); + final AtomicInteger handlerCount2 = new AtomicInteger(); + final AtomicInteger handlerCount3 = new AtomicInteger(); + es.register((evt, seq, eob) -> handlerCount1.incrementAndGet()) + .and((evt, seq, eob) -> handlerCount2.incrementAndGet()) + .then((evt, seq, eob) -> { + assertTrue(handlerCount1.get() >= handlerCount3.get()); + assertTrue(handlerCount2.get() >= handlerCount3.get()); + handlerCount3.incrementAndGet(); + }); + + es.start(); + testPublish(es, LOGGER.atTrace(), "A", 0); + testPublish(es, LOGGER.atTrace(), "B", 0); + testPublish(es, LOGGER.atTrace(), "C", 0); + testPublish(es, LOGGER.atTrace(), "A", 1); + testPublish(es, LOGGER.atTrace(), "D", 0); + testPublish(es, LOGGER.atTrace(), "E", 0); + Awaitility.await().atMost(200, TimeUnit.MILLISECONDS).until(() -> { + es.stop(); + return true; }); + + assertEquals(6, handlerCount1.get()); + assertEquals(6, handlerCount2.get()); + assertEquals(6, handlerCount3.get()); + } + + @Test + void attachHistoryEventHandlerTest() { + final Cache.CacheBuilder> ctxCacheBuilder = Cache.>builder().withLimit(100); + final Function muxCtx = evt -> "cid=" + evt.getFilter(TimingCtx.class).cid; + final EventStore es = EventStore.getFactory().setMuxCtxFunction(muxCtx).setMuxBuilder(ctxCacheBuilder).setFilterConfig(TimingCtx.class, EvtTypeFilter.class).build(); + + final AtomicInteger handlerCount1 = new AtomicInteger(); + final AtomicInteger handlerCount2 = new AtomicInteger(); + final AtomicInteger handlerCount3 = new AtomicInteger(); + Predicate filterBp1 = evt -> evt.test(TimingCtx.class, TimingCtx.matches(-1, -1, 0)); + es.register(filterBp1, muxCtx, (evts, evtStore, seq, eob) -> { + handlerCount1.incrementAndGet(); + return null; + }).and(filterBp1, muxCtx, (evts, evtStore, seq, eob) -> { + handlerCount2.incrementAndGet(); + return null; + }).then(filterBp1, muxCtx, (evts, evtStore, seq, eob) -> { + assertTrue(handlerCount1.get() >= handlerCount3.get()); + assertTrue(handlerCount2.get() >= handlerCount3.get()); + handlerCount3.incrementAndGet(); + return null; + }); + + es.start(); + testPublish(es, LOGGER.atTrace(), "A", 0); + testPublish(es, LOGGER.atTrace(), "B", 0); + testPublish(es, LOGGER.atTrace(), "C", 0); + testPublish(es, LOGGER.atTrace(), "A", 1); + testPublish(es, LOGGER.atTrace(), "D", 0); + testPublish(es, LOGGER.atTrace(), "E", 0); + Awaitility.await().atMost(200, TimeUnit.MILLISECONDS).until(() -> { + es.stop(); + return true; }); + + assertEquals(5, handlerCount1.get()); + assertEquals(5, handlerCount2.get()); + assertEquals(5, handlerCount3.get()); + } + + @Test + void historyTest() { + final Cache.CacheBuilder> ctxCacheBuilder = Cache.>builder().withLimit(100); + final Function muxCtx = evt -> "cid=" + evt.getFilter(TimingCtx.class).cid; + final EventStore es = EventStore.getFactory().setMuxCtxFunction(muxCtx).setMuxBuilder(ctxCacheBuilder).setFilterConfig(TimingCtx.class, EvtTypeFilter.class).build(); + + es.start(); + testPublish(es, LOGGER.atTrace(), "A", 0); + testPublish(es, LOGGER.atTrace(), "B", 0); + testPublish(es, LOGGER.atTrace(), "C", 0); + testPublish(es, LOGGER.atTrace(), "A", 1); + testPublish(es, LOGGER.atTrace(), "D", 0); + testPublish(es, LOGGER.atTrace(), "E", 0); + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(100)); // give a bit of time until all workers are finished + + final Optional lastEvent = es.getLast("cid=0", evt -> true); + assertTrue(lastEvent.isPresent()); + assertFalse(es.getLast("cid=0", evt -> evt.matches(TimingCtx.class, TimingCtx.matches(-1, -1, 2))).isPresent()); + LOGGER.atTrace().addArgument(lastEvent.get().payload.get(String.class)).log("retrieved last event = {}"); + + final List eventHistory = es.getHistory("cid=0", evt -> evt.matches(String.class) && evt.matches(TimingCtx.class, TimingCtx.matches(-1, -1, 0)), 4); + final String history = eventHistory.stream().map(b -> b.payload.get(String.class)).collect(Collectors.joining(", ", "(", ")")); + LOGGER.atTrace().addArgument(history).log("retrieved last events = {}"); + + es.stop(); + } + + public static void main(final String[] args) { + // global multiplexing context function -> generate new EventStream per detected context, here: multiplexed on {@see TimingCtx#cid} + final Function muxCtx = evt -> "cid=" + evt.getFilter(TimingCtx.class).cid; + final Cache.CacheBuilder> ctxCacheBuilder = Cache.>builder().withLimit(100); + final EventStore es = EventStore.getFactory().setMuxCtxFunction(muxCtx).setMuxBuilder(ctxCacheBuilder).setFilterConfig(TimingCtx.class, EvtTypeFilter.class).build(); + + final MyHandler handler1 = new MyHandler("Handler1", es.getRingBuffer()); + MyHandler handler2 = new MyHandler("Handler2", es.getRingBuffer()); + EventHandler lambdaEventHandler = (evt, seq, buffer) -> // + LOGGER.atInfo().addArgument(seq).addArgument(evt.payload.get()).addArgument(es.getRingBuffer().getMinimumGatingSequence()).log("Lambda-Handler3 seq:{} - '{}' - gate ='{}'"); + + if (IN_ORDER) { + // execute in order + es.register(handler1).then(handler2).then(lambdaEventHandler); + } else { + //execute out-of-order + es.register(handler1).and(handler2).and(lambdaEventHandler); + } + + Predicate filterBp1 = evt -> evt.test(TimingCtx.class, TimingCtx.matches(-1, -1, 1)); + es.register(filterBp1, muxCtx, (evts, evtStore, seq, eob) -> { + LOGGER.atInfo().addArgument(evts.get(0).payload.get()).log("SequencedFilteredTask 1.0: received cid == 1 : payload = {}"); + return null; + }).and(filterBp1, muxCtx, (evts, evtStore, seq, eob) -> { + LOGGER.atInfo().addArgument(evts.get(0).payload.get()).log("SequencedFilteredTask 1.1: received cid == 1 : payload = {}"); + return null; + }).and(filterBp1, muxCtx, (evts, evtStore, seq, eob) -> { + LOGGER.atInfo().addArgument(evts.get(0).payload.get()).log("SequencedFilteredTask 1.2: received cid == 1 : payload = {}"); + return null; + }).then(filterBp1, muxCtx, (evts, evtStore, seq, eob) -> { + LOGGER.atInfo().addArgument(evts.get(0).payload.get()).log("SequencedFilteredTask 2.0: received cid == 1 : payload = {}"); + return null; + }).then(filterBp1, muxCtx, (evts, evtStore, seq, eob) -> { + LOGGER.atInfo().addArgument(evts.get(0).payload.get()).log("SequencedFilteredTask 3.0: received cid == 1 : payload = {}"); + return null; + }).and(filterBp1, muxCtx, (evts, evtStore, seq, eob) -> { + LOGGER.atInfo().addArgument(evts.get(0).payload.get()).log("SequencedFilteredTask 3.1: received cid == 1 : payload = {}"); + return null; + }).and(filterBp1, muxCtx, (evts, evtStore, seq, eob) -> { + LOGGER.atInfo().addArgument(evts.get(0).payload.get()).log("SequencedFilteredTask 3.2: received cid == 1 : payload = {}"); + return null; + }); + + EventHandler printEndHandler = (evt, seq, buffer) -> // + LOGGER.atInfo().addArgument(es.getRingBuffer().getMinimumGatingSequence()) // + .addArgument(es.disruptor.getSequenceValueFor(handler1)) // + .addArgument(es.disruptor.getSequenceValueFor(handler2)) // + .addArgument(es.disruptor.getSequenceValueFor(lambdaEventHandler)) // + .addArgument(seq) // + .log("### gating position = {} sequences for handler 1: {} 2: {} 3:{} ph: {}"); + + es.register(printEndHandler); + + Predicate filterBp0 = evt -> evt.test(TimingCtx.class, TimingCtx.matches(-1, -1, 0)); + es.register(filterBp0, muxCtx, (evts, evtStore, seq, eob) -> { + final String history = evts.stream().map(b -> (String) b.payload.get()).collect(Collectors.joining(", ", "(", ")")); + final String historyAlt = es.getHistory(muxCtx.apply(evts.get(0)), filterBp0, seq, 30).stream().map(b -> (String) b.payload.get()).collect(Collectors.joining(", ", "(", ")")); + LOGGER.atInfo().addArgument(history).log("@@@EventHandlerA with history: {}"); + LOGGER.atInfo().addArgument(historyAlt).log("@@@EventHandlerB with history: {}"); + return null; + }); + + es.start(); + + testPublish(es, LOGGER.atInfo(), "message A", 0); + testPublish(es, LOGGER.atInfo(), "message B", 0); + testPublish(es, LOGGER.atInfo(), "message C", 0); + testPublish(es, LOGGER.atInfo(), "message A", 1); + testPublish(es, LOGGER.atInfo(), "message D", 0); + testPublish(es, LOGGER.atInfo(), "message E", 0); + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(100)); // give a bit of time until all workers are finished + es.stop(); + } + + protected static void testPublish(EventStore eventStore, final LoggingEventBuilder logger, final String payLoad, final int beamProcess) { + eventStore.getRingBuffer().publishEvent((event, sequence, buffer) -> { + event.arrivalTimeStamp = System.currentTimeMillis() * 1000; + event.parentSequenceNumber = sequence; + event.getFilter(TimingCtx.class).setSelector("FAIR.SELECTOR.C=0:S=0:P=" + beamProcess, event.arrivalTimeStamp); + event.payload = new SharedPointer<>(); + event.payload.set("pid=" + beamProcess + ": " + payLoad); + logger.addArgument(sequence).addArgument(event.payload.get()).addArgument(buffer).log("publish Seq:{} - event:'{}' buffer:'{}'"); + }); + } + + public static class MyHandler implements EventHandler, TimeoutHandler, LifecycleAware { + private final RingBuffer ringBuffer; + + private final String handlerName; + + public MyHandler(final String handlerName, final RingBuffer ringBuffer) { + this.handlerName = handlerName; + this.ringBuffer = ringBuffer; + } + + @Override + public void onEvent(final RingBufferEvent event, final long sequence, final boolean endOfBatch) { + LOGGER.atInfo().addArgument(handlerName).addArgument(sequence).addArgument(event.payload.get()).log("'{}'- process sequence ID: {} event = {}"); + } + + @Override + public void onShutdown() { + LOGGER.atInfo().addArgument(MyHandler.class).addArgument(handlerName).log("stopped '{}'-name:'{}'"); + } + + @Override + public void onStart() { + LOGGER.atInfo().addArgument(MyHandler.class).addArgument(handlerName).log("started '{}'-name:'{}'"); + } + + @Override + public void onTimeout(final long sequence) { + LOGGER.atInfo().addArgument(handlerName).addArgument(sequence).addArgument(ringBuffer.getMinimumGatingSequence()).log("onTimeout '{}'-sequence:'{}' - gate:'{}'"); + } + } +} diff --git a/core/src/test/java/io/opencmw/MimeTypeTests.java b/core/src/test/java/io/opencmw/MimeTypeTests.java new file mode 100644 index 00000000..09dd32d4 --- /dev/null +++ b/core/src/test/java/io/opencmw/MimeTypeTests.java @@ -0,0 +1,85 @@ +package io.opencmw; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +/** + * Tests of {@link io.opencmw.MimeType} + * + * @author rstein + */ +class MimeTypeTests { + @ParameterizedTest + @EnumSource(MimeType.class) + void genericTests(final MimeType mType) { + assertNotNull(mType.toString()); + final String mimeTypeName = "'" + mType + "'"; + + assertEquals(mType, MimeType.getEnum(mType.toString()), "getEnum: mType = " + mimeTypeName); + assertEquals(mType, MimeType.getEnum(mType.name()), "getEnum: mType = " + mimeTypeName); + + assertNotNull(mType.getDescription(), "description: mType = " + mimeTypeName); + assertNotNull(mType.getType(), "type: mType = " + mimeTypeName); + assertNotNull(mType.getSubType(), "subType: mType = " + mimeTypeName); + assertNotNull(mType.getFileEndings(), "fileEndings: mType = " + mimeTypeName); + + if (!mType.getFileEndings().isEmpty()) { + for (String fileName : mType.getFileEndings()) { + if (mType.equals(MimeType.APNG)) { + // skip file-ending tests since PNG and APNG have same/very similar ending .png (and the more rare .apng) + continue; + } + assertEquals(mType, MimeType.getEnumByFileName(fileName), "fileEndings - match: mType = " + mimeTypeName); + } + } + + final String typeRaw = mType.toString().split("/")[0]; + assertTrue(typeRaw.contentEquals(mType.getType().toString()), "mType = " + mimeTypeName); + + final String description = mType.toString(); + if (mType.isImageData()) { + assertTrue(description.startsWith("image"), "image?: mType = " + mimeTypeName); + } else { + assertFalse(description.startsWith("image"), "image?: mType = " + mimeTypeName); + } + + if (mType.isVideoData()) { + assertTrue(description.startsWith("video"), "video?: mType = " + mimeTypeName); + } else { + assertFalse(description.startsWith("video"), "video?: mType = " + mimeTypeName); + } + + if (mType.isTextData()) { + assertTrue(description.startsWith("text"), "text?: mType = " + mimeTypeName); + } else { + assertFalse(description.startsWith("text"), "text?: mType = " + mimeTypeName); + } + + if (mType.isNonDisplayableData()) { + assertFalse(mType.isImageData(), "nonDisplayableData?: mType = " + mimeTypeName); + assertFalse(mType.isVideoData(), "nonDisplayableData?: mType = " + mimeTypeName); + } + } + + @Test + void cornerCaseTests() { + assertEquals(MimeType.UNKNOWN, MimeType.getEnum(null)); + assertEquals(MimeType.UNKNOWN, MimeType.getEnum("")); + assertEquals(MimeType.UNKNOWN, MimeType.getEnum(" ")); + assertEquals(MimeType.UNKNOWN, MimeType.getEnum("video/made-up-format")); + assertEquals(MimeType.UNKNOWN, MimeType.getEnum("wormhole/made-up-format")); + + assertEquals(MimeType.UNKNOWN, MimeType.getEnumByFileName(null)); + assertEquals(MimeType.UNKNOWN, MimeType.getEnumByFileName("")); + assertEquals(MimeType.UNKNOWN, MimeType.getEnumByFileName(" ")); + assertEquals(MimeType.UNKNOWN, MimeType.getEnumByFileName(".xyz42")); + + assertEquals(MimeType.Type.UNKNOWN, MimeType.Type.getEnum(null)); + assertEquals(MimeType.Type.UNKNOWN, MimeType.Type.getEnum("")); + assertEquals(MimeType.Type.UNKNOWN, MimeType.Type.getEnum(" ")); + assertEquals(MimeType.Type.UNKNOWN, MimeType.Type.getEnum("made-up-type")); + } +} diff --git a/core/src/test/java/io/opencmw/OpenCmwProtocolTests.java b/core/src/test/java/io/opencmw/OpenCmwProtocolTests.java new file mode 100644 index 00000000..7b5dd379 --- /dev/null +++ b/core/src/test/java/io/opencmw/OpenCmwProtocolTests.java @@ -0,0 +1,95 @@ +package io.opencmw; + +import static org.junit.jupiter.api.Assertions.*; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.LockSupport; + +import org.junit.jupiter.api.Test; +import org.zeromq.SocketType; +import org.zeromq.ZContext; +import org.zeromq.ZMQ; + +/** + * basic MDP OpenCMW protocol consistency tests + */ +class OpenCmwProtocolTests { + private static final OpenCmwProtocol.MdpMessage TEST_MESSAGE = new OpenCmwProtocol.MdpMessage("senderName".getBytes(StandardCharsets.UTF_8), // + OpenCmwProtocol.MdpSubProtocol.PROT_CLIENT, OpenCmwProtocol.Command.GET_REQUEST, // + "serviceName".getBytes(StandardCharsets.UTF_8), new byte[] { (byte) 3, (byte) 2, (byte) 1 }, + URI.create("test/topic"), "test data - Hello World!".getBytes(StandardCharsets.UTF_8), "error message", "rbacToken".getBytes(StandardCharsets.UTF_8)); + + @Test + void testCommandEnum() { + for (OpenCmwProtocol.Command cmd : OpenCmwProtocol.Command.values()) { + assertEquals(cmd, OpenCmwProtocol.Command.getCommand(cmd.getData())); + assertNotNull(cmd.toString()); + if (!cmd.equals(OpenCmwProtocol.Command.UNKNOWN)) { + assertTrue(cmd.isClientCompatible() || cmd.isWorkerCompatible()); + } + } + assertFalse(OpenCmwProtocol.Command.UNKNOWN.isWorkerCompatible()); + assertFalse(OpenCmwProtocol.Command.UNKNOWN.isClientCompatible()); + } + + @Test + void testMdpSubProtocolEnum() { + for (OpenCmwProtocol.MdpSubProtocol cmd : OpenCmwProtocol.MdpSubProtocol.values()) { + assertEquals(cmd, OpenCmwProtocol.MdpSubProtocol.getProtocol(cmd.getData())); + assertNotNull(cmd.toString()); + } + } + + @Test + void testMdpIdentity() { + final OpenCmwProtocol.MdpMessage test = TEST_MESSAGE; + assertEquals(OpenCmwProtocol.Command.GET_REQUEST, test.command); + assertEquals(TEST_MESSAGE, test, "object identity"); + assertNotEquals(TEST_MESSAGE, new Object(), "inequality if different class type"); + final OpenCmwProtocol.MdpMessage clone = new OpenCmwProtocol.MdpMessage(TEST_MESSAGE); + assertEquals(TEST_MESSAGE, clone, "copy constructor"); + assertEquals(TEST_MESSAGE.hashCode(), clone.hashCode(), "hashCode equality"); + final OpenCmwProtocol.MdpMessage modified = new OpenCmwProtocol.MdpMessage(TEST_MESSAGE); + modified.protocol = OpenCmwProtocol.MdpSubProtocol.PROT_WORKER; + assertNotEquals(TEST_MESSAGE, modified, "copy constructor"); + assertTrue(TEST_MESSAGE.hasRbackToken(), "non-empty rbac token"); + assertEquals("senderName", TEST_MESSAGE.getSenderName(), "sender name string"); + assertEquals("serviceName", TEST_MESSAGE.getServiceName(), "service name string"); + assertNotNull(TEST_MESSAGE.toString()); + } + + @Test + void testMdpSendReceiveIdentity() { + try (ZContext ctx = new ZContext()) { + { + final ZMQ.Socket receiveSocket1 = ctx.createSocket(SocketType.ROUTER); + receiveSocket1.bind("inproc://pair1"); + final ZMQ.Socket receiveSocket2 = ctx.createSocket(SocketType.DEALER); + receiveSocket2.bind("inproc://pair2"); + final ZMQ.Socket sendSocket = ctx.createSocket(SocketType.DEALER); + sendSocket.setIdentity(TEST_MESSAGE.senderID); + sendSocket.connect("inproc://pair1"); + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(200)); + + TEST_MESSAGE.send(sendSocket); + final OpenCmwProtocol.MdpMessage reply = OpenCmwProtocol.MdpMessage.receive(receiveSocket1); + assertEquals(TEST_MESSAGE, reply, "serialisation identity via router"); + + sendSocket.disconnect("inproc://pair1"); + sendSocket.connect("inproc://pair2"); + TEST_MESSAGE.send(sendSocket); + final OpenCmwProtocol.MdpMessage reply2 = OpenCmwProtocol.MdpMessage.receive(receiveSocket2); + assertEquals(TEST_MESSAGE, reply2, "serialisation identity via dealer"); + + OpenCmwProtocol.MdpMessage.send(sendSocket, List.of(TEST_MESSAGE)); + final OpenCmwProtocol.MdpMessage reply3 = OpenCmwProtocol.MdpMessage.receive(receiveSocket2); + final OpenCmwProtocol.MdpMessage clone = new OpenCmwProtocol.MdpMessage(TEST_MESSAGE); + clone.command = OpenCmwProtocol.Command.FINAL; // N.B. multiple message exist only for reply type either FINAL, or (PARTIAL, PARTIAL, ..., FINAL) + assertEquals(clone, reply3, "serialisation identity via dealer"); + } + } + } +} diff --git a/core/src/test/java/io/opencmw/QueryParameterParserTest.java b/core/src/test/java/io/opencmw/QueryParameterParserTest.java new file mode 100644 index 00000000..3d87af15 --- /dev/null +++ b/core/src/test/java/io/opencmw/QueryParameterParserTest.java @@ -0,0 +1,226 @@ +package io.opencmw; + +import static org.junit.jupiter.api.Assertions.*; + +import java.lang.reflect.InvocationTargetException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import io.opencmw.filter.TimingCtx; + +class QueryParameterParserTest { + @SuppressWarnings("CanBeFinal") + private static class TestQueryClass { + public String param1; + public int param2; + public MimeType mimeType; + public Object specialClass; + public UnknownClass unknownClass; + public TimingCtx ctx; + } + + private static class UnknownClass { + // empty unknown class + } + + @SuppressWarnings("CanBeFinal") + private static class TestQueryClass2 { + protected boolean dummyBoolean; + protected byte dummyByte; + protected short dummyShort; + protected int dummyInt; + protected long dummyLong; + protected float dummyFloat; + protected double dummyDouble; + protected Boolean dummyBoxedBoolean = Boolean.FALSE; + protected Byte dummyBoxedByte = (byte) 0; + protected Short dummyBoxedShort = (short) 0; + protected Integer dummyBoxedInt = 0; + protected Long dummyBoxedLong = 0L; + protected Float dummyBoxedFloat = 0f; + protected Double dummyBoxedDouble = 0.0; + protected String dummyString1 = "nope"; + protected String dummyString2 = "nope"; + protected String dummyString3; + protected String dummyString4; + } + + @Test + void testMapStringToClass() throws InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException { + URI uri = URI.create("https://opencmw.io?param1=Hello¶m2=42&mimeType=text/html&specialClass"); + final TestQueryClass ctx = QueryParameterParser.parseQueryParameter(TestQueryClass.class, uri.getQuery()); + assertEquals("Hello", ctx.param1); + assertEquals(42, ctx.param2); + assertEquals(MimeType.HTML, ctx.mimeType); + assertNotNull(ctx.specialClass); + + assertThrows(IllegalArgumentException.class, () -> QueryParameterParser.parseQueryParameter(TestQueryClass.class, "param1=b;param2=c")); + } + + @Test + void testMapStringToClassWithMissingParameter() throws InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException { + URI uri = URI.create("https://opencmw.io?param1=Hello¶m2=42&specialClass"); + final TestQueryClass ctx = QueryParameterParser.parseQueryParameter(TestQueryClass.class, uri.getQuery()); + assertEquals("Hello", ctx.param1); + assertEquals(42, ctx.param2); + assertNull(ctx.mimeType); // was missing in parameters + assertNotNull(ctx.specialClass); + + assertThrows(IllegalArgumentException.class, () -> QueryParameterParser.parseQueryParameter(TestQueryClass.class, "param1=b;param2=c")); + } + + @Test + void testMapClassToString() { + TestQueryClass ctx = new TestQueryClass(); + ctx.param1 = "Hello"; + ctx.param2 = 42; + ctx.mimeType = MimeType.HTML; + ctx.ctx = TimingCtx.get("FAIR.SELECTOR.C=2"); + ctx.specialClass = new Object(); + + String result = QueryParameterParser.generateQueryParameter(ctx); + // System.err.println("result = " + result); + assertNotNull(result); + assertTrue(result.contains(ctx.param1)); + assertTrue(result.contains("" + ctx.param2)); + assertEquals("param1=Hello¶m2=42&mimeType=HTML&specialClass=&unknownClass=&ctx=FAIR.SELECTOR.C%3D2", result); + } + + @Test + void testClassToStringFunctions() throws InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException { + final String testString = "dummyBoolean=true;dummyBoxedBoolean=true;" + + "dummyByte=2&dummyBoxedByte=2;" + + "dummyShort=3&dummyBoxedShort=3;" + + "dummyInt=4&dummyBoxedInt=4;" + + "dummyLong=5&dummyBoxedLong=5;" + + "dummyFloat=6.&dummyBoxedFloat=6.0;" + + "dummyDouble=7.0&dummyBoxedDouble=7.0;" + + "dummyString1=TestA;dummyString2=\"TestA \";dummyString=;dummyString4=\"equation =%3D5\""; + final TestQueryClass2 ctx = QueryParameterParser.parseQueryParameter(TestQueryClass2.class, testString); + assertTrue(ctx.dummyBoolean); + assertTrue(ctx.dummyBoxedBoolean); + assertEquals((byte) 2, ctx.dummyByte); + assertEquals((byte) 2, ctx.dummyBoxedByte); + assertEquals((short) 3, ctx.dummyShort); + assertEquals((short) 3, ctx.dummyBoxedShort); + assertEquals(4, ctx.dummyInt); + assertEquals(4, ctx.dummyBoxedInt); + assertEquals(5L, ctx.dummyLong); + assertEquals(5L, ctx.dummyBoxedLong); + assertEquals(6.f, ctx.dummyFloat); + assertEquals(6.f, ctx.dummyBoxedFloat); + assertEquals(7., ctx.dummyDouble); + assertEquals(7., ctx.dummyBoxedDouble); + assertEquals("TestA", ctx.dummyString1); + assertEquals("\"TestA \"", ctx.dummyString2); + assertNull(ctx.dummyString3); + assertEquals("\"equation ==5\"", ctx.dummyString4); + } + + @Test + void testMapFunction() { + URI uri = URI.create("https://opencmw.io?param1=value1¶m2=¶m3=value3¶m3"); + final Map> map = QueryParameterParser.getMap(uri.getQuery()); + + assertNotNull(map.get("param1")); + assertEquals(1, map.get("param1").size()); + assertEquals("value1", map.get("param1").get(0)); + + assertNotNull(map.get("param2")); + assertEquals(1, map.get("param2").size()); + assertNull(map.get("param2").get(0)); + + assertNotNull(map.get("param3")); + assertEquals(2, map.get("param3").size()); + assertEquals("value3", map.get("param3").get(0)); + assertNull(map.get("param3").get(1)); + + assertNull(map.get("param4"), "null for non-existent parameter"); + + assertEquals(0, QueryParameterParser.getMap("").size(), "empty map for empty query string"); + } + + @Test + void testMimetype() { + assertEquals(MimeType.HTML, QueryParameterParser.getMimeType("contentType=text/html")); + assertEquals(MimeType.HTML, QueryParameterParser.getMimeType("contentType=HTML")); + assertEquals(MimeType.HTML, QueryParameterParser.getMimeType("contentType=html")); + assertEquals(MimeType.HTML, QueryParameterParser.getMimeType("contentType=text/HtmL")); + } + + @Test + void testIdentity() { + final String queryString = "param1=value1a¶m2=value2¶m3=value3¶m1=value1b"; + final Map> parsedMap = QueryParameterParser.getMap(queryString); + assertEquals(3, parsedMap.size(), "number of unique parameter"); + assertEquals(2, parsedMap.get("param1").size()); + assertEquals(1, parsedMap.get("param2").size()); + assertEquals(1, parsedMap.get("param3").size()); + assertEquals(List.of("value1a", "value1b"), parsedMap.get("param1")); + assertEquals(List.of("value2"), parsedMap.get("param2")); + assertEquals(List.of("value3"), parsedMap.get("param3")); + final Map returnMap = new HashMap<>(parsedMap); + returnMap.put("param4", "value4"); + final String returnString = QueryParameterParser.generateQueryParameter(returnMap); + + // test second generated map + final Map> parsedMap2 = QueryParameterParser.getMap(returnString); + assertEquals(4, parsedMap2.size(), "number of unique parameter"); + assertEquals(2, parsedMap2.get("param1").size()); + assertEquals(1, parsedMap2.get("param2").size()); + assertEquals(1, parsedMap2.get("param3").size()); + assertEquals(1, parsedMap2.get("param4").size()); + assertEquals(List.of("value1a", "value1b"), parsedMap2.get("param1")); + assertEquals(List.of("value2"), parsedMap2.get("param2")); + assertEquals(List.of("value3"), parsedMap2.get("param3")); + assertEquals(List.of("value4"), parsedMap2.get("param4")); + + // generate special cases + final Map testMap1 = new HashMap<>(); + testMap1.put("param1", null); + assertEquals("param1", QueryParameterParser.generateQueryParameter(testMap1)); + testMap1.put("param2", null); + assertEquals("param1¶m2", QueryParameterParser.generateQueryParameter(testMap1)); + testMap1.put("param3", Arrays.asList(null, null)); + assertEquals("param3¶m3¶m1¶m2", QueryParameterParser.generateQueryParameter(testMap1)); + } + + @Test + void testAddQueryParameter() throws URISyntaxException { + final URI baseUri = URI.create("basePath"); + final URI extUri = URI.create("basePath?param1"); + assertEquals(baseUri, QueryParameterParser.appendQueryParameter(baseUri, null)); + assertEquals(baseUri, QueryParameterParser.appendQueryParameter(baseUri, "")); + assertEquals(URI.create("basePath?test"), QueryParameterParser.appendQueryParameter(baseUri, "test")); + assertEquals(URI.create("basePath?param1&test"), QueryParameterParser.appendQueryParameter(extUri, "test")); + } + + @Test + void testRemoveQueryParameter() throws URISyntaxException { + final URI origURI = URI.create("basePath?param1=value1a¶m2=value2¶m3=value3¶m1=value1b"); + + assertEquals(origURI, QueryParameterParser.removeQueryParameter(origURI, null)); + assertEquals(origURI, QueryParameterParser.removeQueryParameter(origURI, "")); + assertEquals(URI.create("basePath"), QueryParameterParser.removeQueryParameter(URI.create("basePath"), "bla")); + + // remove 'param1' with specific value 'value1a' - > value1b should remain + final Map> reference1 = QueryParameterParser.getMap("param2=value2¶m3=value3¶m1=value1b"); + final Map> result1 = QueryParameterParser.getMap(QueryParameterParser.removeQueryParameter(origURI, "param1=value1a").getQuery()); + assertEquals(reference1, result1); + + // remove all 'param1' by key, only 'param2' and 'param3' should remain + final Map> reference2 = QueryParameterParser.getMap("param2=value2¶m3=value3"); + final Map> result2 = QueryParameterParser.getMap(QueryParameterParser.removeQueryParameter(origURI, "param1").getQuery()); + assertEquals(reference2, result2); + + // remove 'param1' with specific value 'value1a' and then 'value1b' - > only 'param2' and 'param3' should remain + final Map> result3 = QueryParameterParser.getMap(QueryParameterParser.removeQueryParameter(QueryParameterParser.removeQueryParameter(origURI, "param1=value1a"), "param1=value1b").getQuery()); + assertEquals(reference2, result3); + } +} diff --git a/core/src/test/java/io/opencmw/RingBufferEventTests.java b/core/src/test/java/io/opencmw/RingBufferEventTests.java new file mode 100644 index 00000000..9b3e5fdf --- /dev/null +++ b/core/src/test/java/io/opencmw/RingBufferEventTests.java @@ -0,0 +1,135 @@ +package io.opencmw; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +import io.opencmw.filter.EvtTypeFilter; +import io.opencmw.filter.TimingCtx; +import io.opencmw.utils.SharedPointer; + +class RingBufferEventTests { + @Test + void basicTests() { + assertDoesNotThrow(() -> new RingBufferEvent(TimingCtx.class)); + assertThrows(IllegalArgumentException.class, () -> new RingBufferEvent(TimingCtx.class, BogusFilter.class)); + + final RingBufferEvent evt = new RingBufferEvent(TimingCtx.class); + assertFalse(evt.matches(String.class)); + evt.payload = new SharedPointer<>(); + assertFalse(evt.matches(String.class)); + evt.payload.set("Hello World"); + assertTrue(evt.matches(String.class)); + evt.throwables.add(new Throwable("test")); + assertNotNull(evt.toString()); + + // assert copy/clone interfaces + assertEquals(evt, evt.clone()); + final RingBufferEvent evt2 = new RingBufferEvent(TimingCtx.class); + evt.copyTo(evt2); + assertEquals(evt, evt2); + + assertDoesNotThrow(evt::clear); + assertEquals(0, evt.throwables.size()); + assertEquals(0, evt.arrivalTimeStamp); + + final long timeNowMicros = System.currentTimeMillis() * 1000; + final TimingCtx ctxFilter = evt.getFilter(TimingCtx.class); + assertNotNull(ctxFilter); + assertThrows(IllegalArgumentException.class, () -> evt.getFilter(BogusFilter.class)); + + ctxFilter.setSelector("FAIR.SELECTOR.C=3:S=2", timeNowMicros); + + // assert copy/clone interfaces for cleared evt + evt.clear(); + assertEquals(evt, evt.clone()); + final RingBufferEvent evt3 = new RingBufferEvent(TimingCtx.class); + evt.copyTo(evt3); + assertEquals(evt, evt3); + } + + @Test + void basicUsageTests() { + final RingBufferEvent evt = new RingBufferEvent(TimingCtx.class, EvtTypeFilter.class); + assertNotNull(evt); + final long timeNowMicros = System.currentTimeMillis() * 1000; + evt.arrivalTimeStamp = timeNowMicros; + evt.getFilter(EvtTypeFilter.class).evtType = EvtTypeFilter.DataType.DEVICE_DATA; + evt.getFilter(EvtTypeFilter.class).typeName = "MyDevice"; + evt.getFilter(TimingCtx.class).setSelector("FAIR.SELECTOR.C=3:S=2", timeNowMicros); + + evt.matches(TimingCtx.class, ctx -> { + System.err.println("received ctx = " + ctx); + return true; + }); + + // fall-back filter: the whole RingBufferEvent, all Filters etc are accessible + assertTrue(evt.matches(e -> e.arrivalTimeStamp == timeNowMicros)); + + // filter only on given filter trait - here TimingCtx + assertTrue(evt.matches(TimingCtx.class, TimingCtx.matches(3, 2))); + evt.test(TimingCtx.class, TimingCtx.matches(3, 2)); + + // combination of filter traits + assertTrue(evt.test(TimingCtx.class, TimingCtx.matches(3, 2)) && evt.test(EvtTypeFilter.class, dataType -> dataType.evtType == EvtTypeFilter.DataType.DEVICE_DATA)); + assertTrue(evt.test(TimingCtx.class, TimingCtx.matches(3, 2)) && evt.test(EvtTypeFilter.class, EvtTypeFilter.isDeviceData("MyDevice"))); + assertTrue(evt.test(TimingCtx.class, TimingCtx.matches(3, 2).and(TimingCtx.isNewerBpcts(timeNowMicros - 1L)))); + } + + @Test + void equalsTests() { + final RingBufferEvent evt1 = new RingBufferEvent(TimingCtx.class); + final RingBufferEvent evt2 = new RingBufferEvent(TimingCtx.class); + + assertEquals(evt1, evt1, "equals identity"); + assertNotEquals(null, evt1, "equals null"); + evt1.parentSequenceNumber = 42; + assertNotEquals(evt1.hashCode(), evt2.hashCode(), "equals hashCode"); + assertNotEquals(evt1, evt2, "equals hashCode"); + } + + @Test + void testClearEventHandler() { + final RingBufferEvent evt = new RingBufferEvent(TimingCtx.class, EvtTypeFilter.class); + assertNotNull(evt); + final long timeNowMicros = System.currentTimeMillis() * 1000; + evt.arrivalTimeStamp = timeNowMicros; + + assertEquals(timeNowMicros, evt.arrivalTimeStamp); + assertDoesNotThrow(RingBufferEvent.ClearEventHandler::new); + + final RingBufferEvent.ClearEventHandler clearHandler = new RingBufferEvent.ClearEventHandler(); + assertNotNull(clearHandler); + + clearHandler.onEvent(evt, 0, false); + assertEquals(0, evt.arrivalTimeStamp); + } + + @Test + void testHelper() { + assertNotNull(RingBufferEvent.getPrintableStackTrace(new Throwable("pretty print"))); + assertNotNull(RingBufferEvent.getPrintableStackTrace(null)); + StringBuilder builder = new StringBuilder(); + assertDoesNotThrow(() -> RingBufferEvent.printToStringArrayList(builder, "[", "]", 1, 2, 3, 4)); + assertDoesNotThrow(() -> RingBufferEvent.printToStringArrayList(builder, null, "]", 1, 2, 3, 4)); + assertDoesNotThrow(() -> RingBufferEvent.printToStringArrayList(builder, "[", null, 1, 2, 3, 4)); + assertDoesNotThrow(() -> RingBufferEvent.printToStringArrayList(builder, "", "]", 1, 2, 3, 4)); + assertDoesNotThrow(() -> RingBufferEvent.printToStringArrayList(builder, "[", "", 1, 2, 3, 4)); + } + + private static class BogusFilter implements Filter { + public BogusFilter() { + throw new IllegalStateException("should not call/use this filter"); + } + + @Override + public void clear() { + // never called + } + + @Override + public void copyTo(final Filter other) { + // never called + } + } +} diff --git a/core/src/test/java/io/opencmw/domain/BinaryDataTest.java b/core/src/test/java/io/opencmw/domain/BinaryDataTest.java new file mode 100644 index 00000000..7690b47e --- /dev/null +++ b/core/src/test/java/io/opencmw/domain/BinaryDataTest.java @@ -0,0 +1,110 @@ +package io.opencmw.domain; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.ThrowingSupplier; + +import io.opencmw.MimeType; + +class BinaryDataTest { + @Test + void testFixPreAndPost() { + Assertions.assertDoesNotThrow(() -> BinaryData.fixPreAndPost("test")); + assertEquals("/", BinaryData.fixPreAndPost(null)); + assertEquals("/", BinaryData.fixPreAndPost("")); + assertEquals("/", BinaryData.fixPreAndPost("/")); + assertEquals("/test/", BinaryData.fixPreAndPost("test")); + assertEquals("/test/", BinaryData.fixPreAndPost("/test")); + assertEquals("/test/", BinaryData.fixPreAndPost("test/")); + assertEquals("/test/", BinaryData.fixPreAndPost("/test/")); + } + + @Test + void testGenExportName() { + Assertions.assertDoesNotThrow(() -> BinaryData.genExportName("test")); + assertThrows(IllegalArgumentException.class, () -> BinaryData.genExportName(null)); + assertThrows(IllegalArgumentException.class, () -> BinaryData.genExportName("")); + assertEquals("test", BinaryData.genExportName("test.png")); + assertEquals("test", BinaryData.genExportName("test/test.png")); + assertEquals("test", BinaryData.genExportName("/test/test.png")); + assertEquals("test", BinaryData.genExportName("testA/testB/test.png")); + assertEquals("test", BinaryData.genExportName("testA/testB/test")); + + Assertions.assertDoesNotThrow(() -> BinaryData.genExportNameData("test.png")); + assertThrows(IllegalArgumentException.class, () -> BinaryData.genExportNameData(null)); + assertThrows(IllegalArgumentException.class, () -> BinaryData.genExportNameData("")); + assertEquals("test.png", BinaryData.genExportNameData("test.png")); + assertEquals("test.png", BinaryData.genExportNameData("test/test.png")); + assertEquals("test.png", BinaryData.genExportNameData("/test/test.png")); + assertEquals("test.png", BinaryData.genExportNameData("testA/testB/test.png")); + assertEquals("test", BinaryData.genExportNameData("testA/testB/test")); + } + + @Test + void testConstructor() { + assertDoesNotThrow((ThrowingSupplier) BinaryData::new); + assertDoesNotThrow(() -> new BinaryData("name2", MimeType.BINARY, new byte[0])); + assertThrows(IllegalArgumentException.class, () -> new BinaryData(null, MimeType.BINARY, new byte[0])); + assertThrows(IllegalArgumentException.class, () -> new BinaryData("name2", null, new byte[0])); + assertThrows(IllegalArgumentException.class, () -> new BinaryData("name2", MimeType.BINARY, null)); + assertThrows(IllegalArgumentException.class, () -> new BinaryData("name2", MimeType.BINARY, new byte[0], 1)); + } + + @Test + void testToString() { + final BinaryData testData = new BinaryData("name1", MimeType.TEXT, "testText".getBytes(StandardCharsets.UTF_8)); + assertNotNull(testData.toString(), "not null"); + // assert that sub-components are part of the description message + assertThat(testData.toString(), containsString(BinaryData.class.getSimpleName())); + assertThat(testData.toString(), containsString("name1")); + assertThat(testData.toString(), containsString("text/plain")); + assertThat(testData.toString(), containsString("testText")); + } + + @Test + void testEquals() { + final BinaryData reference = new BinaryData("name1", MimeType.TEXT, "testText".getBytes(StandardCharsets.UTF_8)); + final BinaryData test = new BinaryData("name2", MimeType.BINARY, "otherText".getBytes(StandardCharsets.UTF_8)); + assertThat("equality of identity", reference, is(equalTo(reference))); + assertThat("inequality for different object", reference, not(equalTo(new Object()))); + assertThat("inequality for resourceName differences", test, not(equalTo(reference))); + test.resourceName = reference.resourceName; + assertThat("inequality for MimeType differences", test, not(equalTo(reference))); + test.contentType = reference.contentType; + assertThat("inequality for data differences", test, not(equalTo(reference))); + test.data = reference.data; + assertThat("equality for content-effective copy", test, is(equalTo(reference))); + } + + @Test + void testHashCode() { + final BinaryData reference = new BinaryData("name1", MimeType.TEXT, "testText".getBytes(StandardCharsets.UTF_8)); + final BinaryData test = new BinaryData("name2", MimeType.BINARY, "otherText".getBytes(StandardCharsets.UTF_8)); + assertThat("uninitialised hashCode()", reference.hashCode(), is(not(equalTo(0)))); + assertThat("simple hashCode inequality", reference.hashCode(), is(not(equalTo(test.hashCode())))); + } + + @Test + void testGetCategory() { + Assertions.assertDoesNotThrow(() -> BinaryData.getCategory("test")); + assertThrows(IllegalArgumentException.class, () -> BinaryData.getCategory(null)); + assertThrows(IllegalArgumentException.class, () -> BinaryData.getCategory("")); + assertEquals("/", BinaryData.getCategory("test.png")); + assertEquals("/test/", BinaryData.getCategory("test/test.png")); + assertEquals("/test/", BinaryData.getCategory("/test/test.png")); + assertEquals("/testA/testB/", BinaryData.getCategory("testA/testB/test.png")); + assertEquals("/testA/testB/", BinaryData.getCategory("testA/testB/test")); + } +} \ No newline at end of file diff --git a/core/src/test/java/io/opencmw/filter/EvtTypeFilterTests.java b/core/src/test/java/io/opencmw/filter/EvtTypeFilterTests.java new file mode 100644 index 00000000..28c00f56 --- /dev/null +++ b/core/src/test/java/io/opencmw/filter/EvtTypeFilterTests.java @@ -0,0 +1,77 @@ +package io.opencmw.filter; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +class EvtTypeFilterTests { + @Test + void basicTests() { + assertDoesNotThrow(EvtTypeFilter::new); + + final EvtTypeFilter evtTypeFilter = new EvtTypeFilter(); + assertInitialised(evtTypeFilter); + + evtTypeFilter.clear(); + assertInitialised(evtTypeFilter); + + assertNotNull(evtTypeFilter.toString()); + } + + @Test + void testEqualsAndHash() { + final EvtTypeFilter evtTypeFilter1 = new EvtTypeFilter(); + evtTypeFilter1.evtType = EvtTypeFilter.DataType.DEVICE_DATA; + evtTypeFilter1.typeName = "DeviceName"; + // check identity + assertEquals(evtTypeFilter1, evtTypeFilter1); + assertEquals(evtTypeFilter1.hashCode(), evtTypeFilter1.hashCode()); + assertTrue(EvtTypeFilter.isDeviceData().test(evtTypeFilter1)); + assertTrue(EvtTypeFilter.isDeviceData("DeviceName").test(evtTypeFilter1)); + + assertNotEquals(evtTypeFilter1, new Object()); + + final EvtTypeFilter evtTypeFilter2 = new EvtTypeFilter(); + evtTypeFilter2.evtType = EvtTypeFilter.DataType.DEVICE_DATA; + evtTypeFilter2.typeName = "DeviceName"; + assertEquals(evtTypeFilter1, evtTypeFilter2); + assertEquals(evtTypeFilter1.hashCode(), evtTypeFilter2.hashCode()); + + evtTypeFilter2.typeName = "DeviceName2"; + assertNotEquals(evtTypeFilter1, evtTypeFilter2); + evtTypeFilter2.evtType = EvtTypeFilter.DataType.PROCESSED_DATA; + + final EvtTypeFilter evtTypeFilter3 = new EvtTypeFilter(); + assertNotEquals(evtTypeFilter1, evtTypeFilter3); + assertDoesNotThrow(() -> evtTypeFilter1.copyTo(null)); + assertDoesNotThrow(() -> evtTypeFilter1.copyTo(evtTypeFilter3)); + assertEquals(evtTypeFilter1, evtTypeFilter3); + } + + @Test + void predicateTsts() { + final EvtTypeFilter evtTypeFilter = new EvtTypeFilter(); + + evtTypeFilter.evtType = EvtTypeFilter.DataType.TIMING_EVENT; + evtTypeFilter.typeName = "TimingEventName"; + assertTrue(EvtTypeFilter.isTimingData().test(evtTypeFilter)); + assertTrue(EvtTypeFilter.isTimingData("TimingEventName").test(evtTypeFilter)); + + evtTypeFilter.evtType = EvtTypeFilter.DataType.DEVICE_DATA; + evtTypeFilter.typeName = "DeviceName"; + assertTrue(EvtTypeFilter.isDeviceData().test(evtTypeFilter)); + assertTrue(EvtTypeFilter.isDeviceData("DeviceName").test(evtTypeFilter)); + + evtTypeFilter.evtType = EvtTypeFilter.DataType.SETTING_SUPPLY_DATA; + evtTypeFilter.typeName = "SettingName"; + assertTrue(EvtTypeFilter.isSettingsData().test(evtTypeFilter)); + assertTrue(EvtTypeFilter.isSettingsData("SettingName").test(evtTypeFilter)); + } + + private static void assertInitialised(final EvtTypeFilter evtTypeFilter) { + assertNotNull(evtTypeFilter.typeName); + assertTrue(evtTypeFilter.typeName.isBlank()); + assertEquals(EvtTypeFilter.DataType.UNKNOWN, evtTypeFilter.evtType); + assertEquals(0, evtTypeFilter.hashCode); + } +} diff --git a/core/src/test/java/io/opencmw/filter/TimingCtxTests.java b/core/src/test/java/io/opencmw/filter/TimingCtxTests.java new file mode 100644 index 00000000..42bc810b --- /dev/null +++ b/core/src/test/java/io/opencmw/filter/TimingCtxTests.java @@ -0,0 +1,163 @@ +package io.opencmw.filter; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +class TimingCtxTests { + @Test + void basicTests() { + assertDoesNotThrow(TimingCtx::new); + + final long timeNowMicros = System.currentTimeMillis() * 1000; + final TimingCtx ctx = new TimingCtx(); + assertInitialised(ctx); + + assertDoesNotThrow(() -> ctx.setSelector("FAIR.SELECTOR.C=0:S=1:P=2:T=3", timeNowMicros)); + assertEquals(0, ctx.cid); + assertEquals(1, ctx.sid); + assertEquals(2, ctx.pid); + assertEquals(3, ctx.gid); + assertEquals(timeNowMicros, ctx.bpcts); + assertNotNull(ctx.toString()); + ctx.clear(); + + assertThrows(IllegalArgumentException.class, () -> ctx.setSelector("FAIR.SELECTOR.C=0:S=1:P=2:T=3", -1)); + assertInitialised(ctx); + + // add unknown/erroneous tag + assertThrows(IllegalArgumentException.class, () -> ctx.setSelector("FAIR.SELECTOR.C0:S=1:P=2:T=3", timeNowMicros)); + assertInitialised(ctx); + assertThrows(IllegalArgumentException.class, () -> ctx.setSelector("FAIR.SELECTOR.X=1", timeNowMicros)); + assertInitialised(ctx); + + assertThrows(IllegalArgumentException.class, () -> ctx.setSelector(null, timeNowMicros)); + assertInitialised(ctx); + } + + @Test + void basicAllSelectorTests() { + final long timeNowMicros = System.currentTimeMillis() * 1000; + final TimingCtx ctx = new TimingCtx(); + + // empty selector + assertDoesNotThrow(() -> ctx.setSelector("", timeNowMicros)); + assertEquals("", ctx.selector.toUpperCase()); + assertAllWildCard(ctx); + assertEquals(timeNowMicros, ctx.bpcts); + + // "ALL" selector + assertDoesNotThrow(() -> ctx.setSelector(TimingCtx.WILD_CARD, timeNowMicros)); + assertEquals(TimingCtx.WILD_CARD, ctx.selector.toUpperCase()); + assertAllWildCard(ctx); + assertEquals(timeNowMicros, ctx.bpcts); + + // "FAIR.SELECTOR.ALL" selector + assertDoesNotThrow(() -> ctx.setSelector("FAIR.SELECTOR.ALL", timeNowMicros)); + assertEquals(TimingCtx.SELECTOR_PREFIX + TimingCtx.WILD_CARD, ctx.selector.toUpperCase()); + assertAllWildCard(ctx); + assertEquals(timeNowMicros, ctx.bpcts); + } + + @Test + void testHelper() { + assertTrue(TimingCtx.wildCardMatch(TimingCtx.WILD_CARD_VALUE, 2)); + assertTrue(TimingCtx.wildCardMatch(TimingCtx.WILD_CARD_VALUE, -1)); + assertTrue(TimingCtx.wildCardMatch(1, TimingCtx.WILD_CARD_VALUE)); + assertTrue(TimingCtx.wildCardMatch(-1, TimingCtx.WILD_CARD_VALUE)); + assertFalse(TimingCtx.wildCardMatch(3, 2)); + } + + @Test + void testEqualsAndHash() { + final long timeNowMicros = System.currentTimeMillis() * 1000; + final TimingCtx ctx1 = new TimingCtx(); + assertDoesNotThrow(() -> ctx1.setSelector("FAIR.SELECTOR.C=0:S=1:P=2:T=3", timeNowMicros)); + // check identity + assertEquals(ctx1, ctx1); + assertEquals(ctx1.hashCode(), ctx1.hashCode()); + + assertNotEquals(ctx1, new Object()); + + final TimingCtx ctx2 = new TimingCtx(); + assertDoesNotThrow(() -> ctx2.setSelector("FAIR.SELECTOR.C=0:S=1:P=2:T=3", timeNowMicros)); + + assertEquals(ctx1, ctx2); + assertEquals(ctx1.hashCode(), ctx2.hashCode()); + + ctx2.bpcts++; + assertNotEquals(ctx1, ctx2); + ctx2.gid = -1; + assertNotEquals(ctx1, ctx2); + ctx2.pid = -1; + assertNotEquals(ctx1, ctx2); + ctx2.sid = -1; + assertNotEquals(ctx1, ctx2); + ctx2.cid = -1; + assertNotEquals(ctx1, ctx2); + + final TimingCtx ctx3 = new TimingCtx(); + assertNotEquals(ctx1, ctx3); + assertDoesNotThrow(() -> ctx1.copyTo(null)); + assertDoesNotThrow(() -> ctx1.copyTo(ctx3)); + assertEquals(ctx1, ctx3); + } + + @Test + void basicSelectorTests() { + final long timeNowMicros = System.currentTimeMillis() * 1000; + final TimingCtx ctx = new TimingCtx(); + assertInitialised(ctx); + + // "FAIR.SELECTOR.C=2" selector + assertDoesNotThrow(() -> ctx.setSelector("FAIR.SELECTOR.C=2", timeNowMicros)); + assertEquals(2, ctx.cid); + assertEquals(-1, ctx.sid); + assertEquals(-1, ctx.pid); + assertEquals(-1, ctx.gid); + assertEquals(timeNowMicros, ctx.bpcts); + } + + @Test + void matchingTests() { // NOPMD NOSONAR -- number of assertions is OK ... it's a simple unit-test + final long timeNowMicros = System.currentTimeMillis() * 1000; + final TimingCtx ctx = new TimingCtx(); + ctx.setSelector("FAIR.SELECTOR.C=0:S=1:P=2:T=3", timeNowMicros); + + assertTrue(ctx.matches(ctx).test(ctx)); + assertTrue(TimingCtx.matches(0, timeNowMicros).test(ctx)); + assertTrue(TimingCtx.matches(0, 1, timeNowMicros).test(ctx)); + assertTrue(TimingCtx.matches(0, 1, 2, timeNowMicros).test(ctx)); + assertTrue(TimingCtx.matches(0, 1, 2).test(ctx)); + assertTrue(TimingCtx.matches(-1, 1, 2).test(ctx)); + assertFalse(TimingCtx.matches(0, 0, 2).test(ctx)); + assertFalse(TimingCtx.matches(0, 1, 0).test(ctx)); + + assertTrue(TimingCtx.matchesBpcts(timeNowMicros).test(ctx)); + assertTrue(TimingCtx.isOlderBpcts(timeNowMicros + 1L).test(ctx)); + assertTrue(TimingCtx.isNewerBpcts(timeNowMicros - 1L).test(ctx)); + + // test wildcard + ctx.setSelector("FAIR.SELECTOR.C=0:S=1", timeNowMicros); + assertEquals(0, ctx.cid); + assertEquals(1, ctx.sid); + assertEquals(-1, ctx.pid); + assertTrue(TimingCtx.matches(0, 1).test(ctx)); + assertTrue(TimingCtx.matches(0, 1, -1).test(ctx)); + assertTrue(TimingCtx.matches(0, timeNowMicros).test(ctx)); + } + + private static void assertAllWildCard(final TimingCtx ctx) { + assertEquals(-1, ctx.cid); + assertEquals(-1, ctx.sid); + assertEquals(-1, ctx.pid); + assertEquals(-1, ctx.gid); + } + + private static void assertInitialised(final TimingCtx ctx) { + assertEquals("", ctx.selector); + assertAllWildCard(ctx); + assertEquals(-1, ctx.bpcts); + assertEquals(0, ctx.hashCode); + } +} diff --git a/core/src/test/java/io/opencmw/rbac/BasicRbacRoleTest.java b/core/src/test/java/io/opencmw/rbac/BasicRbacRoleTest.java new file mode 100644 index 00000000..502fc694 --- /dev/null +++ b/core/src/test/java/io/opencmw/rbac/BasicRbacRoleTest.java @@ -0,0 +1,9 @@ +package io.opencmw.rbac; + +class BasicRbacRoleTest { + // @Test + // void testBasicRbac() { + // assertTrue(BasicRbacRole.NULL.compareTo(BasicRbacRole.ADMIN) > 0, "ADMIN higher than NULL"); + // assertTrue(BasicRbacRole.ADMIN.compareTo(BasicRbacRole.NULL) < 0, "ADMIN higher than NULL"); + // } +} \ No newline at end of file diff --git a/core/src/test/java/io/opencmw/utils/CacheTests.java b/core/src/test/java/io/opencmw/utils/CacheTests.java new file mode 100644 index 00000000..4c3c1512 --- /dev/null +++ b/core/src/test/java/io/opencmw/utils/CacheTests.java @@ -0,0 +1,226 @@ +package io.opencmw.utils; + +import static org.junit.jupiter.api.Assertions.*; + +import java.time.temporal.ChronoUnit; +import java.util.Collection; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Regression testing for @see Cache + * + * @author rstein + */ +@Execution(ExecutionMode.SAME_THREAD) +public class CacheTests { + private static final Logger LOGGER = LoggerFactory.getLogger(CacheTests.class); + + @Test + public void demoTestCase() { + AtomicBoolean preListenerCalled = new AtomicBoolean(false); + AtomicBoolean postListenerCalled = new AtomicBoolean(false); + final Cache cache = Cache.builder().withLimit(10).withTimeout(100, TimeUnit.MILLISECONDS) // + .withPreListener((k, v) -> preListenerCalled.set(true)) + .withPostListener((k, v) -> postListenerCalled.set(true)) + .build(); + + String name1 = "Han Solo"; + + cache.put(name1, 10); + assertTrue(isCached(cache, name1), "initial push"); + + // wait 1 second + try { + Thread.sleep(50); + } catch (InterruptedException e) { + LOGGER.atError().setCause(e).log("sleep"); + } + + assertTrue(isCached(cache, name1), "check after 500 ms"); + assertFalse(preListenerCalled.get()); + assertFalse(postListenerCalled.get()); + + // wait another second + try { + Thread.sleep(200); + } catch (InterruptedException e) { + LOGGER.atError().setCause(e).log("sleep"); + } + assertFalse(isCached(cache, name1), "check after 500 ms"); + assertTrue(preListenerCalled.get()); + assertTrue(postListenerCalled.get()); + } + + @Test + public void testCacheSizeLimit() { + Cache cache = Cache.builder().withLimit(3).build(); + + assertEquals(3, cache.getLimit()); + + for (int i = 0; i < 10; i++) { + cache.put("test" + i, 10); + if (i < cache.getLimit()) { + assertEquals(i + 1, cache.getSize()); + } + assertTrue(cache.getSize() <= 3, "cache size during iteration " + i); + } + assertEquals(3, cache.getLimit()); + + final String testString = "testString"; + cache.put(testString, 42); + assertTrue(isCached(cache, testString), testString + " being cached"); + assertEquals(42, cache.get(testString), testString + " being cached"); + cache.remove(testString); + assertFalse(isCached(cache, testString), testString + " being removed from cache"); + + cache.clear(); + assertEquals(0, cache.size(), "cache size"); + cache.put(testString, 42); + assertTrue(cache.containsKey(testString), "containsKey"); + assertTrue(cache.containsValue(42), "containsValue"); + Set> entrySet = cache.entrySet(); + + assertEquals(1, entrySet.size(), "entrySet size"); + for (Entry entry : entrySet) { + assertEquals(testString, entry.getKey(), "entrySet - key"); + assertEquals(42, entry.getValue(), "entrySet - value"); + } + + Set keySet = cache.keySet(); + assertEquals(1, keySet.size(), "keySet size"); + for (String key : keySet) { + assertEquals(testString, key, "keySet - key"); + } + + Collection values = cache.values(); + assertEquals(1, values.size(), "values size"); + for (Integer value : values) { + assertEquals(42, value, "values - value"); + } + + assertEquals(1, cache.size(), "cache size"); + cache.clear(); + assertEquals(0, cache.size(), "cache size"); + assertFalse(isCached(cache, testString), testString + " being removed from cache"); + assertTrue(cache.isEmpty(), " cache being empty after clear"); + + Map mapToAdd = new ConcurrentHashMap<>(); + mapToAdd.put("Test1", 1); + mapToAdd.put("Test2", 2); + mapToAdd.put("Test3", 3); + cache.putAll(mapToAdd); + assertEquals(3, cache.size(), "cache size"); + } + + @Test + public void testConstructors() { + Cache cache1 = new Cache<>(20); // limit + assertEquals(20, cache1.getLimit(), "limit"); + assertDoesNotThrow(() -> cache1.put("testKey", "testValue")); + Cache cache2 = new Cache<>(1000, TimeUnit.MILLISECONDS); // time-out + assertEquals(1000, cache2.getTimeout(), "time out"); + assertEquals(TimeUnit.MILLISECONDS, cache2.getTimeUnit(), "time unit"); + assertDoesNotThrow(() -> cache2.put("testKey", "testValue")); + Cache cache3 = new Cache<>(1000, TimeUnit.MILLISECONDS, 20); // time-out && limit + assertEquals(20, cache3.getLimit(), "limit"); + assertEquals(TimeUnit.MILLISECONDS, cache3.getTimeUnit(), "limit"); + assertEquals(1000, cache3.getTimeout(), "limit"); + assertDoesNotThrow(() -> cache3.put("testKey", "testValue")); + + // check exceptions + + assertThrows(IllegalArgumentException.class, () -> { + // negative time out check + new Cache(-1, TimeUnit.MILLISECONDS, 20); + }); + + assertThrows(IllegalArgumentException.class, () -> { + // null TimeUnit check + new Cache(1, null, 20); + }); + + assertThrows(IllegalArgumentException.class, () -> { + // limit < 1 check + new Cache(2, TimeUnit.MICROSECONDS, 0); + }); + + // check builder exceptions + + assertThrows(IllegalArgumentException.class, () -> { + // negative time out check + Cache.builder().withTimeout(-1, TimeUnit.MILLISECONDS).build(); + }); + + assertThrows(IllegalArgumentException.class, () -> { + // null TimeUnit check + Cache.builder().withTimeout(1, null).build(); + }); + + assertThrows(IllegalArgumentException.class, () -> { + // limit < 1 check + Cache.builder().withLimit(0).build(); + }); + + assertThrows(IllegalArgumentException.class, () -> { + // null pre-listener + Cache.builder().withPreListener(null).build(); + }); + + assertThrows(IllegalArgumentException.class, () -> { + // null post-listener + Cache.builder().withPostListener(null).build(); + }); + + // Cache cache4 = Cache.builder().withLimit(20).withTimeout(100, TimeUnit.MILLISECONDS).build(); + } + + @Test + public void testHelperMethods() { + // TimeUnit to ChronoUnit conversions + for (TimeUnit timeUnit : TimeUnit.values()) { + ChronoUnit chronoUnit = Cache.convertToChronoUnit(timeUnit); + // timeUnit.toChronoUnit() would be faster but exists only since Java 9 + + long nanoTimeUnit = timeUnit.toNanos(1); + long nanoChrono = chronoUnit.getDuration().getNano() + 1000000000 * chronoUnit.getDuration().getSeconds(); + assertEquals(nanoTimeUnit, nanoChrono, "ChronoUnit =" + chronoUnit); + } + + // test clamp(int ... ) routine + assertEquals(1, Cache.clamp(1, 3, 0)); + assertEquals(2, Cache.clamp(1, 3, 2)); + assertEquals(3, Cache.clamp(1, 3, 4)); + + // test clamp(long ... ) routine + assertEquals(1L, Cache.clamp(1L, 3L, 0L)); + assertEquals(2L, Cache.clamp(1L, 3L, 2L)); + assertEquals(3L, Cache.clamp(1L, 3L, 4L)); + } + + @Test + public void testPutVariants() { + Cache cache = Cache.builder().withLimit(3).build(); + + assertNull(cache.put("key", 2)); + assertEquals(2, cache.put("key", 3)); + assertEquals(3, cache.putIfAbsent("key", 4)); + cache.clear(); + assertNull(cache.putIfAbsent("key", 4)); + assertEquals(4, cache.putIfAbsent("key", 5)); + } + + private static boolean isCached(Cache cache, final String key) { + return cache.getOptional(key).isPresent(); + } +} diff --git a/core/src/test/java/io/opencmw/utils/CustomFutureTests.java b/core/src/test/java/io/opencmw/utils/CustomFutureTests.java new file mode 100644 index 00000000..59e832df --- /dev/null +++ b/core/src/test/java/io/opencmw/utils/CustomFutureTests.java @@ -0,0 +1,157 @@ +package io.opencmw.utils; + +import static org.awaitility.Awaitility.await; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.jupiter.api.Assertions.*; + +import java.net.ProtocolException; +import java.util.concurrent.CancellationException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.LockSupport; + +import org.junit.jupiter.api.Test; + +class CustomFutureTests { + @Test + void testWithoutWaiting() throws ExecutionException, InterruptedException { + final CustomFuture future = new CustomFuture<>(); + + assertFalse(future.done.get(), "future is active"); + assertFalse(future.isCancelled()); + future.setReply("TestString"); + + assertEquals("TestString", future.get()); + assertFalse(future.isCancelled()); + } + + @Test + void testWithWaiting() { + final CustomFuture future = new CustomFuture<>(); + assertFalse(future.done.get(), "future is active"); + assertFalse(future.isCancelled()); + + final AtomicReference result = new AtomicReference<>(); + final AtomicBoolean run = new AtomicBoolean(false); + new Thread(() -> { + run.set(true); + try { + result.set(future.get()); + assertEquals("TestString", future.get()); + assertEquals("TestString", result.get()); + } catch (InterruptedException | ExecutionException e) { + throw new IllegalStateException("unexpected exception", e); + } + run.set(false); + }).start(); + await().alias("wait for thread to start").atMost(1, TimeUnit.SECONDS).until(run::get, equalTo(true)); + future.setReply("TestString"); + await().alias("wait for thread to finish").atMost(1, TimeUnit.SECONDS).until(run::get, equalTo(false)); + + assertEquals("TestString", result.get()); + assertFalse(future.isCancelled()); + } + + @Test + void testWithExecutionException() { + final CustomFuture future = new CustomFuture<>(); + assertFalse(future.done.get(), "future is active"); + assertFalse(future.isCancelled()); + future.setException(new ProtocolException("specific exception")); + + assertThrows(ExecutionException.class, future::get); + + assertThrows(IllegalStateException.class, () -> future.setException(new ProtocolException("specific exception"))); + } + + @Test + void testWithExecutionExceptionWhileWaiting() { + final CustomFuture future = new CustomFuture<>(); + assertFalse(future.done.get(), "future is active"); + + new Thread(() -> { + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(100)); + future.setException(new ProtocolException("specific exception")); + }).start(); + assertThrows(ExecutionException.class, () -> future.get(1000, TimeUnit.MILLISECONDS)); + } + + @Test + void testWithCancelWhileWaiting() { + final CustomFuture future = new CustomFuture<>(); + assertFalse(future.done.get(), "future is active"); + assertFalse(future.isCancelled()); + + final AtomicReference result = new AtomicReference<>(); + final AtomicBoolean run = new AtomicBoolean(false); + new Thread(() -> { + run.set(true); + assertThrows(CancellationException.class, () -> result.set(future.get())); + run.set(false); + }).start(); + await().alias("wait for thread to start").atMost(1, TimeUnit.SECONDS).until(run::get, equalTo(true)); + assertFalse(future.isCancelled()); + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(200)); + assertTrue(future.cancel(true)); + assertFalse(future.cancel(true)); + assertTrue(future.isCancelled()); + await().alias("wait for thread to finish").atMost(1, TimeUnit.SECONDS).until(run::get, equalTo(false)); + assertThrows(IllegalStateException.class, () -> future.setReply("TestString")); + + assertNull(result.get()); + } + + @Test + void testWithTimeout() { + final CustomFuture future = new CustomFuture<>(); + assertFalse(future.done.get(), "future is active"); + assertThrows(TimeoutException.class, () -> future.get(100, TimeUnit.MILLISECONDS)); + } + + @Test + void testWithTimeoutAndCancel() { + final CustomFuture future = new CustomFuture<>(); + final AtomicBoolean run = new AtomicBoolean(false); + final Thread testThread = new Thread(() -> { + run.set(true); + assertThrows(CancellationException.class, () -> future.get(1, TimeUnit.SECONDS)); + run.set(false); + }); + testThread.start(); + await().alias("wait for thread to start").atMost(1, TimeUnit.SECONDS).until(run::get, equalTo(true)); + future.cancel(false); + await().alias("wait for thread to finish").atMost(10, TimeUnit.SECONDS).until(run::get, equalTo(false)); + } + + @Test + void testWithCancelBeforeWaiting() { + final CustomFuture future = new CustomFuture<>(); + assertFalse(future.done.get(), "future is active"); + assertFalse(future.isCancelled()); + assertTrue(future.cancel(true)); + assertFalse(future.cancel(true)); + + final AtomicReference result = new AtomicReference<>(); + final AtomicBoolean run = new AtomicBoolean(true); + new Thread(() -> { + assertThrows(CancellationException.class, () -> result.set(future.get())); + run.set(false); + }).start(); + assertTrue(future.isCancelled()); + assertThrows(IllegalStateException.class, () -> future.setReply("TestString")); + await().alias("wait for thread to finish").atMost(1, TimeUnit.SECONDS).until(run::get, equalTo(false)); + + assertNull(result.get()); + } + + @Test + void testWithNullReply() throws ExecutionException, InterruptedException { + final CustomFuture future = new CustomFuture<>(); + assertFalse(future.done.get(), "future is active"); + future.setReply(null); + assertNull(future.get()); + } +} diff --git a/core/src/test/java/io/opencmw/utils/LimitedArrayListTests.java b/core/src/test/java/io/opencmw/utils/LimitedArrayListTests.java new file mode 100644 index 00000000..1e02512d --- /dev/null +++ b/core/src/test/java/io/opencmw/utils/LimitedArrayListTests.java @@ -0,0 +1,48 @@ +package io.opencmw.utils; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Unit testing for {@link LimitedArrayListTests} implementation. + * + * @author rstein + */ +class LimitedArrayListTests { + @Test + void testConstructors() { + Assertions.assertDoesNotThrow(() -> new LimitedArrayList<>(10)); + + assertThrows(IllegalArgumentException.class, () -> new LimitedArrayList<>(0)); + assertThrows(IllegalArgumentException.class, () -> new LimitedArrayList<>(-1)); + + LimitedArrayList testList = new LimitedArrayList<>(1); + assertEquals(1, testList.getLimit()); + assertEquals(0, testList.size()); + + assertThrows(IllegalArgumentException.class, () -> testList.setLimit(0)); + testList.setLimit(3); + assertEquals(3, testList.getLimit()); + + assertEquals(0, testList.size()); + assertTrue(testList.add(1.0)); + assertEquals(1, testList.size()); + assertTrue(testList.add(2.0)); + assertEquals(2, testList.size()); + assertTrue(testList.add(3.0)); + assertEquals(3, testList.size()); + assertTrue(testList.add(4.0)); + assertEquals(3, testList.size()); + + testList.clear(); + for (int i = 0; i <= 13; i++) { + testList.add((double) i); + } + assertEquals(3, testList.size()); + assertEquals(11.0, testList.get(0)); + assertEquals(12.0, testList.get(1)); + assertEquals(13.0, testList.get(2)); + } +} diff --git a/core/src/test/java/io/opencmw/utils/SharedPointerTests.java b/core/src/test/java/io/opencmw/utils/SharedPointerTests.java new file mode 100644 index 00000000..9a3b975d --- /dev/null +++ b/core/src/test/java/io/opencmw/utils/SharedPointerTests.java @@ -0,0 +1,41 @@ +package io.opencmw.utils; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.ThrowingSupplier; + +class SharedPointerTests { + @Test + void basicTests() { + assertDoesNotThrow((ThrowingSupplier>) SharedPointer::new); + + final SharedPointer sp = new SharedPointer<>(); + final Integer testObject = 2; + AtomicInteger destroyed = new AtomicInteger(0); + // set and get first ownership + sp.set(testObject, obj -> { + // destroyed called + assertEquals(obj, testObject, "lambda object equality"); + destroyed.getAndIncrement(); + }); + assertThrows(IllegalStateException.class, () -> sp.set(testObject)); + assertEquals(Integer.class, sp.getType()); + assertEquals(1, sp.getReferenceCount()); + + // get second ownership + final SharedPointer ref = sp.getCopy(); + assertEquals(testObject, ref.get(), "object identity copy"); + assertEquals(testObject, ref.get(Integer.class), "object identity copy"); + assertThrows(ClassCastException.class, () -> ref.get(Long.class)); + assertEquals(2, sp.getReferenceCount()); + assertDoesNotThrow(ref::release); // nothing should happen + assertEquals(1, ref.getReferenceCount()); + assertEquals(0, destroyed.get(), "erroneous destructor call"); + + assertDoesNotThrow(sp::release); // nothing should happen + assertEquals(1, destroyed.get(), "destructor not called"); + } +} diff --git a/core/src/test/resources/simplelogger.properties b/core/src/test/resources/simplelogger.properties new file mode 100644 index 00000000..a01ef764 --- /dev/null +++ b/core/src/test/resources/simplelogger.properties @@ -0,0 +1,50 @@ +# SLF4J's SimpleLogger configuration file +# Simple implementation of Logger that sends all enabled log messages, for all defined loggers, to System.err. + +# Default logging detail level for all instances of SimpleLogger. +# Must be one of ("trace", "debug", "info", "warn", or "error"). +# If not specified, defaults to "info". +org.slf4j.simpleLogger.defaultLogLevel=debug + +# The output target which can be the path to a file, or the special values "System.out" and "System.err". +# Default is "System.err". +org.slf4j.simpleLogger.logFile=System.out + +# If the output target is set to "System.out" or "System.err" (see preceding entry), by default, +# logs will be output to the latest value referenced by System.out/err variables. +# By setting this parameter to true, the output stream will be cached, i.e. assigned once at initialization +# time and re-used independently of the current value referenced by System.out/err. +org.slf4j.simpleLogger.cacheOutputStream=true + +# Logging detail level for a SimpleLogger instance named "a.b.c". Right-side value must be one of +# "trace", "debug", "info", "warn", "error" or "off". When a SimpleLogger named "a.b.c" is initialized, +# its level is assigned from this property. If unspecified, the level of nearest parent logger will be used, +# and if none is set, then the value specified by org.slf4j.simpleLogger.defaultLogLevel will be used. +org.slf4j.simpleLogger.log.de.gsi.* + +# Logging detail level for a SimpleLogger instance named "xxxxx". +# Must be one of ("trace", "debug", "info", "warn", or "error"). +# If not specified, the default logging detail level is used. +#org.slf4j.simpleLogger.log.xxxxx= + +# Set to true if you want the current date and time to be included in output messages. +# Default is false, and will output the number of milliseconds elapsed since startup. +#org.slf4j.simpleLogger.showDateTime=false + +# The date and time format to be used in the output messages. +# The pattern describing the date and time format is the same that is used in java.text.SimpleDateFormat. +# If the format is not specified or is invalid, the default format is used. +# The default format is yyyy-MM-dd HH:mm:ss:SSS Z. +#org.slf4j.simpleLogger.dateTimeFormat=yyyy-MM-dd HH:mm:ss:SSS Z + +# Set to true if you want to output the current thread name. +# Defaults to true. +# org.slf4j.simpleLogger.showThreadName=false + +# Set to true if you want the Logger instance name to be included in output messages. +# Defaults to true. +#org.slf4j.simpleLogger.showLogName=true + +# Set to true if you want the last component of the name to be included in output messages. +# Defaults to false. +# org.slf4j.simpleLogger.showShortLogName=true \ No newline at end of file diff --git a/docs/CmwLight.md b/docs/CmwLight.md new file mode 100644 index 00000000..f75f0c29 --- /dev/null +++ b/docs/CmwLight.md @@ -0,0 +1,172 @@ +# Unofficial CMW client protocol implementation + +This is an effort of documenting and reimplementing the CMW (Controls Middleware) +binary protocol. + +CMW is a project developed at CERN, for more information see its +[online documentation](https://cmwdoc.web.cern.ch/cmwdoc/) or the [paper](https://cds.cern.ch/record/2305650/files/mobpl05.pdf). + +## CMW protocol description + +The CMW rda3 protocol consists of individual [ZeroMQ](https://zeromq.org/) messages with +one to four frames. +The frames either contain simple enum-type bytes, a byte encoded string or CMW data encoded binary data. +The CMW data format is reimplemented in the `CmwLightSerialiser`, see the [serialiser documentation](IoSerialiser.md) +for more information. +The following sections describe the byte values and cmw-data field names used by the protocol +and their use for establishing a connection, performing subscribe/get/... actions and receiving the replies. + +The first ZFrame of each message contains a single byte defining the type of the Message: + +| byte value | message name | direction | contents | +|------------|--------------------|-----------|----------| +| 0x01 | SERVER_CONNECT_ACK | s -> c | MessageType, VersionString +| 0x02 | SERVER_REP | s -> c | MessageType, Frames(1-3), Descriptor +| 0x03 | SERVER_HB | s -> c | MessageType +| 0x20 | CLIENT_CONNECT | c -> s | MessageType, VersionString +| 0x21 | CLIENT_REQ | c -> s | MessageType, Frames(1-3), Descriptor +| 0x22 | CLIENT_HB | c -> s | MessageType + +### Establishing the connection + +The connection is established by the client sending a ZMsg with the first frame containing +the message type CLIENT_CONNECT, followed a ZFrame containing the version string +"1.0.0" (the version string is unused). +The server acknowledges by sending SERVER_CONNECT_ACK, followed by the version +string ("1.0.0", not used). + +### Heartbeats + +The rda3 protocol uses heartbeats for connection management. +Client as well as server periodically send messages only consisting of a single one-byte frame containing SERVER_HB/ CLIENT_HB. +If client or server do not receive a heartbeat or any other message for some time, the connection is reset. +By default, both sides send a heartbeat every second and reset the connection if 3 consecutive heartbeats are missed. +Heartbeats are only sent if there is otherwise no communication, so every received package must also reset this timeout. + +### Requests/Replies + +The client can send requests and the server sends replies, indicated by the types CLIENT_REQ and SERVER_REP. +The message type frame is followed by an arbitrary number of frames, where the last one is the so called descriptor, +which contains one byte for each previous frame, containing the type of the frame contents. + +| message name | byte value | +|-------------------------|------------| +| MT_HEADER | 0 | +| MT_BODY | 1 | +| MT_BODY_DATA_CONTEXT | 2 | +| MT_BODY_REQUEST_CONTEXT | 3 | +| MT_BODY_EXCEPTION | 4 | + +The second frame is always of type MT_HEADER and its field reqType defines the type of the +request/reply and also the frames present in the data: + +| byte | message type | message | direction | comment | +|------|-----------------------|---------|-----------|---------| +| 0 |RT_GET | H, RC | C->S | | +| 1 |RT_SET | H,B,RC | C->S | | +| 2 |RT_CONNECT | H, B | C->S | | +| 3 |RT_REPLY | H,B | S->C | response to connect | +| 3 |RT_REPLY | H,B,DC | S->C | | +| 4 |RT_EXCEPTION | H,B | S->C | | +| 5 |RT_SUBSCRIBE | H, RC | C->S | | +| 5 |RT_SUBSCRIBE | H | S->C | "ack" | +| 6 |RT_UNSUBSCRIBE | H, RC | C->S | | +| 7 |RT_NOTIFICATION_DATA | H,B,DC | S->C | | +| 8 |RT_NOTIFICATION_EXC | H,B | S->C | | +| 9 |RT_SUBSCRIBE_EXCEPTION | H,B | S->C | | +| 10 |RT_EVENT | H | C->S | | +| 10 |RT_EVENT | H | S->C | close | +| 11 |RT_SESSION_CONFIRM | H | S->C | | + +#### Header fields: +The header frame is sent as the second frame of each message, but depending on the message and request type, +not all fields are populated and different option fields are present. + +| fieldname | enum tag | type | description | +|-----------|-------------------|------|-------------| +| "2" | REQ_TYPE_TAG | byte | see table above +| "0" | ID_TAG | long | id to map back to the request from the reply, for subscription replies set by source id from subscription reply +| "1" | DEVICE_NAME_TAG |string| empty for subscription notifications +| "7" | UPDATE_TYPE_TAG | byte | +| "d" | SESSION_ID_TAG |string| empty for subscription notifications +| "f" | PROPERTY_NAME_TAG |string| empty for subscription notifications +| "3" | OPTIONS_TAG | data | + +The Session Id tag contains the session id which identifies the client: + - `RemoteHostInfoImpl[name=; userName=; appId=[app=;uid=;host=;pid=;]` + +The options tag can contain the following fields + - optional NOTIFICATION_ID_TAG = "a" type: long; for notification data, counts the notifications + - optional SOURCE_ID_TAG = "b"; for subscription requests to propagate the id + - optional SESSION_BODY_TAG = "e" type: cmw-data; for session context/RBAC + +#### Request Context Fields +The request context frame is sent for get, set and subscribe requests and specifies on which data the request should act. + +| fieldname | enum tag | type | description | +|-----------|--------------|------|-------------| +| "8" | SELECTOR_TAG |string| | +| "c" | FILTERS_TAG | data | | +| "x" | DATA_TAG | data | | + +#### Data Context Fields +The data context frame is sent for reply and notification replies and specifies, what context the data belongs to. + +| fieldname | enum tag | type | description | +|-----------|-----------------|------|-------------| +| "4" | CYCLE_NAME_TAG |string| | +| "5" | ACQ_STAMP_TAG | data | | +| "6" | CYCLE_STAMP_TAG | data | | +| "x" | DATA_TAG | data | fields: "acqStamp", "cycleStamp", "cycleName", "version", "type"| + +#### connect body +The connection step seems to be optional, it will be established implicitly when opening a subscription. +The server responds with an empty (fields as well as data frame) `REPLY` message. + +| fieldname | enum tag | type | description | +|-----------|-----------------|------|-------------| +| "9" | CLIENT_INFO_TAG |string| | + +The field contains a string of `#` separated `key#type[#length]#value` entries. Strings are URL encoded. +- `Address:#string#16#tcp:%2F%2FSYSPC004:0` +- `ApplicationId:#string#54#app=fesa%2Dexplorer2;uid=akrimm;host=SYSPC004;pid=17442;` +- `UserName:#string#6#akrimm` +- `ProcessName:#string#14#fesa%2Dexplorer2` +- `Language:#string#4#Java` +- `StartTime:#long#1605172397732` +- `Name:#string#14#fesa%2Dexplorer2` +- `Pid:#int#17442` +- `Version:#string#5#2%2E8%2E1` + +#### Exception body field +If there is an exception, the server sends a reply of type exception, subscribe exception or notification exception. +The enclosed exception body frame contains the following fields. + +| fieldname | enum tag | type | description | +|---------------------|------------------------------------|------|-------------| +| "Message" |EXCEPTION_MESSAGE_FIELD |string| | +| "Type" |EXCEPTION_TYPE_FIELD |string| | +| "Backtrace" |EXCEPTION_BACKTRACE_FIELD |string| | +| "ContextCycleName" |EXCEPTION_CONTEXT_CYCLE_NAME_FIELD |string| | +| "ContextCycleStamp" |EXCEPTION_CONTEXT_CYCLE_STAMP_FIELD | long | | +| "ContextAcqStamp" |EXCEPTION_CONTEXT_ACQ_STAMP_FIELD | long | | +| "ContextData" |EXCEPTION_CONTEXT_DATA_FIELD |string| | + +#### currently unused field names +- MESSAGE_TAG = "message"; + + +## Client implementation + +The implementation only cares about the client part and for now only supports property subscriptions. +RBAC is also not supported. + +The `CmwLightMessage` implements a generic message which can represent all the messages exchanged between client and server. +It provides methods for generating consistent messages and static instances for the heartbeat messages. +The `CmwLightProtocol` takes care of translating these messages to and from ZeroMQ's `ZMsg` format. + +One `CmwLightClient` takes care of one cmw server. It manages connection state, subscriptions and their state. +It is supposed to be embedded into an event loop, which should call the receiveMessage call at least once every `heartbeatInterval`. +This can be facilitated efficiently by registering a ZeroMQ poller to the client's socket. + +See `CmwLightPoller` for an example which publishes the subscription notifications into an LMAX disruptor ring buffer. \ No newline at end of file diff --git a/docs/IoSerialiser.md b/docs/IoSerialiser.md new file mode 100644 index 00000000..26264b50 --- /dev/null +++ b/docs/IoSerialiser.md @@ -0,0 +1,303 @@ +# Yet-another-Serialiser (YaS) +### or: why we are not reusing ... and opted to write yet another custom data serialiser + +Data serialisation is a basic core functionality when information has to be transmitted, stored and later retrieved by (often quite) +different sub-systems. With a multitude of different serialiser libraries, a non-negligible subset of these claim to be the fastest, +most efficient, easiest-to-use or *<add your favourite superlative here>*. +While this may be certainly true for the libraries' original design use-case, this often breaks down for other applications +that are often quite diverse and may focus on different aspects depending on the application. Hence, a fair comparison of +their performance is usually rather complex and highly non-trivial because the underlying assumptions of 'what counts as important' +being quite different between specific domains, boundary constraints, application goals and resulting (de-)serialisation strategies. +Rather than claiming any superlative, or needlessly bashing solutions that are well suited for their design use-cases, we wanted +to document the considerations, constraints and application goals of our specific use-case and that guided our +[multi-protocol serialiser developments](https://github.com/GSI-CS-CO/chart-fx/microservice). +This also in the hope that it might find interest, perhaps adoption, inspires new ideas, or any other form of improvements. +Thus, if you find something missing, unclear, or things that could be improved, please feel encouraged to post a PR. + +DISCLAIMER: This specific implementation while not necessarily a direct one-to-one source-code copy is at least conceptually +based upon a combination of other open-sourced implementations, long-term experience with internal-proprietary wire-formats, +and new serialiser design ideas expressed in the references [below](#references) which were adopted, adapted and optimised +for our specific use-case. + +### [Our](https://fair-center.eu/) [Use-Case](https://fair-wiki.gsi.de/FC2WG) +We use [this](../../microservice) and [Chart-Fx](https://github.com/GSI-CS-CO/chart-fx) in order to aid the development +of functional microservices that monitor and control a large variety of device- and beam-based parameters that are necessary +for the operation of our [FAIR particle accelerators](https://www.youtube.com/watch?v=zy4b0ZQnsck). +These microservices cover in particular those that require the aggregation of measurement data from different sub-systems, +or that require domain-specific logic or real-time signal-processing algorithms that cannot be efficiently implemented +in any other single device or sub-system. + +### Quick Overview +This serialiser implementation defines three levels of interface abstractions: + * [IoBuffer](../../microservice/src/main/java/de/gsi/serializer/IoBuffer.java) which defines the low-level byte-array format + of how data primitives (ie. `boolean`, `byte`, ...,`float`, `double`), `String`, and their array counter-part (ie. + `boolean[]`, `byte[]`, ...,`float[]', 'double[]`, `String[]`) are stored. There are two default implementations: + - [ByteBuffer](../../microservice/src/main/java/de/gsi/serializer/spi/ByteBuffer.java) which basically wraps around and + extends `java.nio.ByteBuffer` to also support `String` and primitive arrays, and + - [FastByteBuffer](../../microservice/src/main/java/de/gsi/serializer/spi/FastByteBuffer.java) which is the recommended + (~ 25% faster) reimplementation using direct byte-array and cached field accesses. + * [IoSerialiser](../../microservice/src/main/java/de/gsi/serializer/IoSerialiser.java) which defines the compound wire-format + for more complex objects (e.g. `List`, `Map`, multi-dimensional arrays etc), including field headers, and annotations. + There are three default implementations: + *(N.B. `IoSerialiser` allows further extensions to any other structurally similar protocol. A robust implementation of + [IoSerialiser::checkHeaderInfo()](../../microservice/src/main/java/de/gsi/serializer/IoSerialiser.java#L20) + is critical in order to distinguish new protocols from existing ones.)* + - [BinarySerialiser](../../microservice/src/main/java/de/gsi/serializer/spi/BinarySerialiser.java) which is the primary + binary-based transport protocol used by this library, + - [CmwLightSerialiser](../../microservice/src/main/java/de/gsi/serializer/spi/CmwLightSerialiser.java) which is the backward + compatible re-implementation of an existing proprietary protocol internally used in our facility, and + - [JsonSerialiser](../../microservice/src/main/java/de/gsi/serializer/spi/JsonSerialiser.java) which implements the + [JSON](https://www.json.org/) protocol commonly used in RESTful HTTP-based services. + * [IoClassSerialiser](../../microservice/src/main/java/de/gsi/serializer/IoClassSerialiser.java) which deals with the automatic + mapping and (de-)serialisation between the class field structure and specific wire-format. This class defines default strategies + for generic and nested classes and can be further extended by custom serialiser prototypes for more complex classes, + other custom nested protocols or interfaces using the + [FieldSerialiser](../../microservice/src/main/java/de/gsi/serializer/FieldSerialiser.java) interface. + +A short working example of how these can be used is shown in [IoClassSerialiserSimpleTest](../../microservice/src/test/java/de/gsi/serializer/IoClassSerialiserSimpleTest.java): +```Java +@Test +void simpleTest() { + final IoBuffer byteBuffer = new FastByteBuffer(10_000); // alt: new ByteBuffer(10_000); + final IoClassSerialiser ioClassSerialiser = new IoClassSerialiser(byteBuffer, BinarySerialiser.class); + TestDataClass data = new TestDataClass(); // object to be serialised + + byteBuffer.reset(); + ioClassSerialiser.serialiseObject(data); // pojo -> serialised data + // [..] stream/write serialised byteBuffer content [..] + + // [..] stream/read serialised byteBuffer content + byteBuffer.flip(); // mark byte-buffer for reading + TestDataClass received = ioClassSerialiser.deserialiseObject(TestDataClass.class); + + // check data equality, etc... + assertEquals(data, received); +} +``` +The specific wire-format that the [IoClassSerialiser](../../microservice/src/main/java/de/gsi/serializer/IoClassSerialiser.java) uses can be set either programmatically or dynamically (auto-detection based on serialised data content header) via: +```Java + ioClassSerialiser.setMatchedIoSerialiser(BinarySerialiser.class); + ioClassSerialiser.setMatchedIoSerialiser(CmwLightSerialiser.class); + ioClassSerialiser.setMatchedIoSerialiser(JsonSerialiser.class); + // to auto-detect the suitable serialiser based on serialised data header: + ioClassSerialiser.setAutoMatchSerialiser(true); +``` +The extension for arbitrary custom classes or interfaces can be achieved through (here for the `DoubleArrayList` class) via: +```Java + serialiser.addClassDefinition(new FieldSerialiser<>( + (io, obj, field) -> field.getField().set(obj, DoubleArrayList.wrap(io.getDoubleArray())), // IoBuffer → class field reader function + (io, obj, field) -> DoubleArrayList.wrap(io.getDoubleArray()), // return function - generates new object based on IoBuffer content + (io, obj, field) -> { // class field → IoBuffer writer function + final DoubleArrayList retVal = (DoubleArrayList) field.getField().get(obj); + io.put(field, retVal.elements(), retVal.size()); + }, + DoubleArrayList.class)); +``` +The [DataSetSerialiser](../../microservice/src/main/java/de/gsi/serializer/spi/iobuffer/DataSetSerialiser.java) serialiser +implementation is a representative example and serialises the [DataSet](../../chartfx-dataset/src/main/java/de/gsi/dataset/DataSet.java) +interface into an abstract implementation-independet wire-format using the [FieldDataSetHelper](../../microservice/src/main/java/de/gsi/serializer/spi/iobuffer/FieldDataSetHelper.java) +function. This is also the most prominent common domain object definition that is used within our MVC-pattern driven microservice-, +data-processing-, and UI-applications and one of the original primary motivations why we designed and built the `IoClassSerialiser` implementation. + +### Primary Serialiser Functionality Goals and Constraints +Some of the aspects that were incorporated into the design, loosely ordered according to their importance: + 1. performance: providing an optimised en-/decoding that minimises the effective total latency between the data object + content being ready to be serialised and sent by the server until the object is received, fully de-serialised and ready + for further processing on the client-side. + *N.B. some serialisers trade-off size for en-/decoding speed, which may be suitable for primarily network-io limited + systems. Since io-bandwidth is not a primary concern for our local network, we chose a rather simple encoder with no + explicit compression stage to save CPU clock cycles.* + 2. facilitate multi-protocol implementations, protocol evolution and loose coupling between data object definitions on + the server- and corresponding client-side, ie. services and clients may communicate with different protocol versions + and need to agree only on a mandatory small sub-set of information they both need to share. + *N.B. most micro-services develop naturally and grow their functionality with time. This decoupling is necessary to + provide a smooth and soft upgrade path for early adopters that require these new functionalities (ie. thus also being updated + during regular facility operation), and clients that may require a controlled maintenance period, e.g. safety related systems, + that need a formal qualification process prior to being deployed into regular operation with a modified data-model.* + 3. same client- and server-side API, decoupling the serialisers' wire-formats (ie. different binary formats, JSON, XML, YML, ...) + from the specific microservice APIs and low-level transport protocols that transmit the serialised data + *N.B. encapsulates domain-specific control as well as the generic microservice logic into reusable code blocks that + can be re-implemented if deemed necessary, and that are decoupled from the specific required io-formats, which are usually + either driven by technical necessity (e.g. device supporting only one data wire-format) and/or client-side preferences + (e.g. web-based clients typically favouring RESTful JSON-based protocols while high-throughput clients with real-time requirements + often favour more optimised binary data protocols over TCP/UDP-based sockets).* + 4. derive schemas for generic data directly from C++ or Java class structures and basic types rather than a 3rd-party IDL + definition (ie. using [Pocos](https://en.wikipedia.org/wiki/Plain_Old_C%2B%2B_Object) & [Pojos](https://en.wikipedia.org/wiki/Plain_old_Java_object) as IDL) + - aims at a high compatibility between C++, Java and other languages derived thereof and leverages existing experience + of developers with those languages + *N.B. this improves the productivity of new/occasional/less-experienced users who need to be ony vaguely familiar + with C++/Java and do not need to learn yet another new dedicated DSL. This also inverts the problem: rather than + 'here are the data structures you allowed to use to be serialised' to 'what can be done to serialise the structures + one already is using'.* + - enforces stronger type-safety + *N.B. some other serialisers encode only sub-sets of the possible data structures, and or reduce the specific type + to encompassing super types. For example, integer-types such as `byte`, `short`, `int` all being mapped to `long`, + or all floating-point-type numbers to `double` which due to the ambiguity causes unnecessary numerical decoding errors + on the deserialisation side.* + - support for simple data primitives, nested class objects or common data container, such as `Collection`, `List`, + `Set`, ..., `Map`, etc. + *N.B. We found, that due to the evolution of our microservices and data protocol definitions, we frequently had to + remap and rewrite adapters between our internal map-based data-formats and class objects which proved to be a frequent + and unnecessary source of coding errors.* + - efficient (first-class) support of large collections of numeric (floating-point) data + *N.B. many of the serialiser supporting binary wire-format seem to be optimised for simple data structure that are typically + much smaller than 1k Bytes rather than large numeric arrays that were eiter slow to encode and/or required custom serialiser + extensions.* + - usage of compile-time reflection ie. offline/one-time optimisation prior to running → run deterministic/optimally while + online w/o relying on dynamic parsing optimisations + *N.B. this particularly simplifies the evolution, modification of data structures, and removes one of the common source + of coding errors, since the synchronisation between class-structure, serialised-data-structure and formal-IDL-structure + is omitted.* + - optional: support run-time reflection as a fall-back solution for new data/users that haven't used the compile-time reflection + - optional support of UTF-8-based and fall-back to ISO8859-1-based String encoding if a faster or more efficient en-/decoding is needed. + 5. allow schema extensions through optional custom (de-)serialiser routines for known classes or interface that are either more optimised, + or that implement a specific data-exchange format for a given generic class interface. + 6. self-documented data-structures with optional data field annotations to document and communicate the data-exchange-API-intend to the client + - some examples: 'unit' and 'description' of specific data fields, read/write field access definitions, definition of field sub-sets, etc. + - allows on-the-fly data structure (full schema) documentation for users based on the transmitted wire-format structure + w/o the explicite need to have access to the exact service class domain object definition + *N.B. we keep the code public, this also facilitate automatic documentation updates whenever the code is being modified + and opens the possibility of [OpenAPI specification](https://swagger.io/specification/) -style extensions common for RESTful service.* + - optional: full schema information is transmitted only for the first and (optionally) suppressed in subsequent transmissions for improved performance. + *N.B. trade-off between optimise latency/throughput in high-volume paths vs. feature-rich/documented data storage protocol for + less critical low-volume 'get/set' operations.* + 7. minimise code-base and code-bloat -- for two reasons: + - smaller code usually leads to smaller compiled binary sizes that are more likely to fit into CPU cache, thus are less + likely to be evicted on context changes, and result into overall faster code. + *N.B. while readability is an important issue, we found that certain needless use of 'interface + impl pattern' + (ie. only one implementation for given per interface) are harder to read and harder to optimise for the (JIT) compiler too. + As an example, in-lining and keeping the code in one (albeit larger) source file proved to yield much faster results + for the `CmwLightSerialiser` reimplementation of an existing internally used wire-format.* + - maintenance: code should be able to be re-engineered or optimised within typically 2 weeks by one skilled developer. + *N.B. more code requires more time to read and to understand. While there are many skilled developer, having a simple + code base also implies that the code can be more easily be modified, tested, fixed or maintained by any internally + available developer. Also, be believe that this makes it possibly more likely to be adopted by external users that + want to understand, upgrade, or bug-fix of 'what is under the hood' and is of specific interest to them. Having too + many paradigms, patterns or library dependencies -- even with modern IDEs -- makes it unnecessarily hard for new or + occasional users for getting started.* + 8. unit-test driven development + *N.B. this to minimise errors, loop-holes, and to detect potential regression early-on as part of a general CI/CD strategy, + but also to continuously re-evaluate design choices and quantitative evolution of the performance (for both: potential + regressions and/or improvements, if possible).* + 9. free- and open-source code basis w/o strings-attached: + - it is important to us that this code can be re-used, built- and improved-upon by anybody and not limited by + unnecessary hurdles to due proprietary or IP-protected interfaces or licenses. + *N.B. we chose the [LGPLv3](https://www.gnu.org/licenses/lgpl-3.0.txt) license in order that this remains free for future use, + and to foster evolution of ideas and further developments that build upon this. See also [this](https://github.com/GSI-CS-CO/chart-fx/issues/221).* + +### Some Serialiser Performance Comparison Results +The following examples are qualitative and primarily used to verify that our implementation is not significantly slower than +another reference implementation and to document possible performance regression when refactoring the code base. +Example output of [SerialiserQuickBenchmark.java](../src/test/java/de/gsi/serializer/benchmark/SerialiserQuickBenchmark.java) which compares the +map-only, custom and full-pojo-to-pojo (de-)serialisation performance for the given low-level wire-format: + +```text +Example output - numbers should be compared relatively (nIterations = 100000): +(openjdk 11.0.7 2020-04-14, ASCII-only, nSizePrimitiveArrays = 10, nSizeString = 100, nestedClassRecursion = 1) +[..] more string-heavy TestDataClass +- run 1 +- JSON Serializer (Map only) throughput = 371.4 MB/s for 5.2 kB per test run (took 1413.0 ms) +- CMW Serializer (Map only) throughput = 220.2 MB/s for 6.3 kB per test run (took 2871.0 ms) +- CmwLight Serializer (Map only) throughput = 683.1 MB/s for 6.4 kB per test run (took 935.0 ms) +- IO Serializer (Map only) throughput = 810.0 MB/s for 7.4 kB per test run (took 908.0 ms) + +- FlatBuffers (custom FlexBuffers) throughput = 173.7 MB/s for 6.1 kB per test run (took 3536.0 ms) +- CmwLight Serializer (custom) throughput = 460.5 MB/s for 6.4 kB per test run (took 1387.0 ms) +- IO Serializer (custom) throughput = 545.0 MB/s for 7.3 kB per test run (took 1344.0 ms) + +- JSON Serializer (POJO) throughput = 53.8 MB/s for 5.2 kB per test run (took 9747.0 ms) +- CMW Serializer (POJO) throughput = 182.8 MB/s for 6.3 kB per test run (took 3458.0 ms) +- CmwLight Serializer (POJO) throughput = 329.2 MB/s for 6.3 kB per test run (took 1906.0 ms) +- IO Serializer (POJO) throughput = 374.9 MB/s for 7.2 kB per test run (took 1925.0 ms) + +[..] more primitive-array-heavy TestDataClass +(openjdk 11.0.7 2020-04-14, UTF8, nSizePrimitiveArrays = 1000, nSizeString = 0, nestedClassRecursion = 0) +- run 1 +- JSON Serializer (Map only) throughput = 350.7 MB/s for 34.3 kB per test run (took 9793.0 ms) +- CMW Serializer (Map only) throughput = 1.7 GB/s for 29.2 kB per test run (took 1755.0 ms) +- CmwLight Serializer (Map only) throughput = 6.7 GB/s for 29.2 kB per test run (took 437.0 ms) +- IO Serializer (Map only) throughput = 6.1 GB/s for 29.7 kB per test run (took 485.0 ms) + +- FlatBuffers (custom FlexBuffers) throughput = 123.1 MB/s for 30.1 kB per test run (took 24467.0 ms) +- CmwLight Serializer (custom) throughput = 3.9 GB/s for 29.2 kB per test run (took 751.0 ms) +- IO Serializer (custom) throughput = 3.8 GB/s for 29.7 kB per test run (took 782.0 ms) + +- JSON Serializer (POJO) throughput = 31.7 MB/s for 34.3 kB per test run (took 108415.0 ms) +- CMW Serializer (POJO) throughput = 1.5 GB/s for 29.2 kB per test run (took 1924.0 ms) +- CmwLight Serializer (POJO) throughput = 3.5 GB/s for 29.1 kB per test run (took 824.0 ms) +- IO Serializer (POJO) throughput = 3.4 GB/s for 29.7 kB per test run (took 870.0 ms) +``` + +A more thorough test using the Java micro-benchmark framework [JMH](https://openjdk.java.net/projects/code-tools/jmh/) output +of [SerialiserBenchmark.java](../src/test/java/de/gsi/serializer/benchmark/SerialiserBenchmark.java) for a string-heavy and +for a numeric-data-heavy test data class: + +```text +Benchmark (testClassId) Mode Cnt Score Error Units +SerialiserBenchmark.customCmwLight string-heavy thrpt 10 49954.479 ± 560.726 ops/s +SerialiserBenchmark.customCmwLight numeric-heavy thrpt 10 22433.828 ± 195.939 ops/s +SerialiserBenchmark.customFlatBuffer string-heavy thrpt 10 18446.085 ± 71.311 ops/s +SerialiserBenchmark.customFlatBuffer numeric-heavy thrpt 10 233.869 ± 7.314 ops/s +SerialiserBenchmark.customIoSerialiser string-heavy thrpt 10 53638.035 ± 367.122 ops/s +SerialiserBenchmark.customIoSerialiser numeric-heavy thrpt 10 24277.732 ± 200.380 ops/s +SerialiserBenchmark.customIoSerialiserOptim string-heavy thrpt 10 79759.984 ± 799.944 ops/s +SerialiserBenchmark.customIoSerialiserOptim numeric-heavy thrpt 10 24192.169 ± 419.019 ops/s +SerialiserBenchmark.customJson string-heavy thrpt 10 17619.026 ± 250.917 ops/s +SerialiserBenchmark.customJson numeric-heavy thrpt 10 138.461 ± 2.972 ops/s +SerialiserBenchmark.mapCmwLight string-heavy thrpt 10 79273.547 ± 2487.931 ops/s +SerialiserBenchmark.mapCmwLight numeric-heavy thrpt 10 67374.131 ± 954.149 ops/s +SerialiserBenchmark.mapIoSerialiser string-heavy thrpt 10 81295.197 ± 2391.616 ops/s +SerialiserBenchmark.mapIoSerialiser numeric-heavy thrpt 10 67701.564 ± 1062.641 ops/s +SerialiserBenchmark.mapIoSerialiserOptimized string-heavy thrpt 10 115008.285 ± 2390.426 ops/s +SerialiserBenchmark.mapIoSerialiserOptimized numeric-heavy thrpt 10 68879.735 ± 1403.197 ops/s +SerialiserBenchmark.mapJson string-heavy thrpt 10 14474.142 ± 1227.165 ops/s +SerialiserBenchmark.mapJson numeric-heavy thrpt 10 163.928 ± 0.968 ops/s +SerialiserBenchmark.pojoCmwLight string-heavy thrpt 10 41821.232 ± 217.594 ops/s +SerialiserBenchmark.pojoCmwLight numeric-heavy thrpt 10 33820.451 ± 568.264 ops/s +SerialiserBenchmark.pojoIoSerialiser string-heavy thrpt 10 41899.128 ± 940.030 ops/s +SerialiserBenchmark.pojoIoSerialiser numeric-heavy thrpt 10 33918.815 ± 376.551 ops/s +SerialiserBenchmark.pojoIoSerialiserOptim string-heavy thrpt 10 53811.486 ± 920.474 ops/s +SerialiserBenchmark.pojoIoSerialiserOptim numeric-heavy thrpt 10 32463.267 ± 635.326 ops/s +SerialiserBenchmark.pojoJson string-heavy thrpt 10 23327.701 ± 288.871 ops/s +SerialiserBenchmark.pojoJson numeric-heavy thrpt 10 161.396 ± 3.040 ops/s +SerialiserBenchmark.pojoJsonCodeGen string-heavy thrpt 10 23586.818 ± 470.233 ops/s +SerialiserBenchmark.pojoJsonCodeGen numeric-heavy thrpt 10 163.250 ± 1.254 ops/s +``` +*N.B. The 'FlatBuffer' implementation is bit of an outlier and uses internally FlatBuffer's `FlexBuffer` builder which does not +support or is optimised for large primitive arrays. `FlexBuffer` was chosen primarily for comparison since it supported flexible +compile/run-time map-type structures similar to the other implementations, whereas the faster Protobuf and Flatbuffer builder +require IDL-based desciptions that are used during compile-time to generate the necessary data-serialiser stubs.* + +JSON-compatible strings are easy to construct and write. Nevertheless, we chose the [Json-Itererator](https://github.com/json-iterator/java) +library as backend for implementing the [JsonSerialiser](../../microservice/src/main/java/de/gsi/serializer/spi/JsonSerialiser.java) +for purely pragmatic reasons and to initially avoid common pitfalls in implementing a robust JSON deserialiser. +The [JsonSelectionBenchmark.java](../src/test/java/de/gsi/serializer/benchmark/JsonSelectionBenchmark.java) compares the +choice with several other commonly used JSON serialisation libraries for a string-heavy and a numeric-data-heavy test data class: +```text +Benchmark (testClassId) Mode Cnt Score Error Units + JsonSelectionBenchmark.pojoFastJson string-heavy thrpt 10 12857.850 ± 109.050 ops/s + JsonSelectionBenchmark.pojoFastJson numeric-heavy thrpt 10 91.458 ± 0.437 ops/s + JsonSelectionBenchmark.pojoGson string-heavy thrpt 10 6253.698 ± 50.267 ops/s + JsonSelectionBenchmark.pojoGson numeric-heavy thrpt 10 48.215 ± 0.265 ops/s + JsonSelectionBenchmark.pojoJackson string-heavy thrpt 10 16563.604 ± 244.329 ops/s + JsonSelectionBenchmark.pojoJackson numeric-heavy thrpt 10 135.780 ± 1.074 ops/s + JsonSelectionBenchmark.pojoJsonIter string-heavy thrpt 10 10733.539 ± 35.605 ops/s + JsonSelectionBenchmark.pojoJsonIter numeric-heavy thrpt 10 86.629 ± 1.122 ops/s + JsonSelectionBenchmark.pojoJsonIterCodeGen string-heavy thrpt 10 41048.034 ± 396.628 ops/s + JsonSelectionBenchmark.pojoJsonIterCodeGen numeric-heavy thrpt 10 377.412 ± 9.755 ops/s +``` +Performance was not of primary concern for us, since JSON-based protocols are anyway slow. The heavy penalty for +numeric-heavy data is largely related to the inefficient string representation of double values. + +### References + + * Brian Goetz, "[Towards Better Serialization](https://cr.openjdk.java.net/~briangoetz/amber/serialization.html)", 2019 + * Pieter Hintjens et al., "[ZeroMQ's ZGuide on Serialisation](http://zguide.zeromq.org/page:chapter7#Serializing-Your-Data)" in 'ØMQ - The Guide', 2020 + * N. Trofimov et al., "[Remote Device Access in the new CERN accelerator controls middleware](https://accelconf.web.cern.ch/ica01/papers/THAP003.pdf)", ICALEPCS 2001, San Jose, USA, 2001, (proprietary, closed-source) + * J. Lauener, W. Sliwinski, "[How to Design & Implement a Modern Communication Middleware based on ZeroMQ](https://cds.cern.ch/record/2305650/files/mobpl05.pdf)", ICALEPS2017, Barcelona, Spain, 2017, (proprietary, closed-source) + * [Google's own Protobuf Serialiser](https://github.com/protocolbuffers/protobuf) + * [Google's own FlatBuffer Serialiser](https://github.com/google/flatbuffers) + * [Implementing High Performance Parsers in Java](https://www.infoq.com/articles/HIgh-Performance-Parsers-in-Java-V2/) + * [Is Protobuf 5x Faster Than JSON?](https://dzone.com/articles/is-protobuf-5x-faster-than-json-part-ii) ([Part 1](https://dzone.com/articles/is-protobuf-5x-faster-than-json), [Part 2](https://dzone.com/articles/is-protobuf-5x-faster-than-json-part-ii)) and reference therein + + diff --git a/docs/MajordomoProtocol.md b/docs/MajordomoProtocol.md new file mode 100644 index 00000000..b6e3f402 --- /dev/null +++ b/docs/MajordomoProtocol.md @@ -0,0 +1,11 @@ +# Majordomo Protocol (MDP) TL;DR; Comparison + +Brief summary of definitions in: + * MDP v 0.1: https://rfc.zeromq.org/spec/7/ + * MDP v 0.2: https://rfc.zeromq.org/spec/18/ + * Majordomo Management Interface (MMI): https://rfc.zeromq.org/spec/8/ + +and OpenCMW protocol extension proposal ([pdf](Majordomo_protocol_comparison.pdf), [spreadsheet source](Majordomo_protocol_comparison.ods)): +![Majordomo Comparison](Majordomo_protocol_comparison.png) + + \ No newline at end of file diff --git a/docs/Majordomo_protocol_comparison.ods b/docs/Majordomo_protocol_comparison.ods new file mode 100644 index 0000000000000000000000000000000000000000..88b4d143b48971da9a80d2e7f577104de791bca3 GIT binary patch literal 23520 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�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 literal 0 HcmV?d00001 diff --git a/docs/Majordomo_protocol_comparison.png b/docs/Majordomo_protocol_comparison.png new file mode 100644 index 0000000000000000000000000000000000000000..87396f4ec9e2b89349310b2641596a27f65bbf87 GIT binary patch literal 426112 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%0AYh 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 10#${ver2[i]})) + then + CLANG_MIN_VERSION_MATCH="<" + return + fi + if ((10#${ver1[i]} < 10#${ver2[i]})) + then + CLANG_MIN_VERSION_MATCH=">" + return + fi + done + CLANG_MIN_VERSION_MATCH="=" + return +} + +compare_version ${CLANG_MIN_VERSION} ${CLANG_VERSION} + +files=$((git status -uall --porcelain | awk 'match($1, "M??"){print $2}' | grep -Ei "\.(c|cc|cpp|cxx|c\+\+|h|hh|hpp|hxx|h\+\+|java)$") || true) +if [ -n "${files}" ]; then + + if [ -n "${CLANG_FORMAT}" ] && [ "$CLANG_MIN_VERSION_MATCH" != "<" ]; then + spaced_files=$(echo "$files" | paste -s -d " " -) + # echo "reformatting ${spaced_files}" + "${CLANG_FORMAT}" -style=file -i $spaced_files >/dev/null + fi +fi diff --git a/formatLastCommit.sh b/formatLastCommit.sh new file mode 100755 index 00000000..2d9948db --- /dev/null +++ b/formatLastCommit.sh @@ -0,0 +1,62 @@ +#/bin/bash + +#enforces .clang-format style guide prior to committing to the git repository + +CLANG_MIN_VERSION="9.0.0" + +set -e + +CLANG_FORMAT="$(command -v clang-format)" +CLANG_VERSION="$(${CLANG_FORMAT} --version | sed '/^clang-format version /!d;s///;s/-.*//;s///g')" + +compare_version () { + echo " " + if [[ $1 == $2 ]] + then + CLANG_MIN_VERSION_MATCH="=" + return + fi + local IFS=. + local i ver1=($1) ver2=($2) + # fill empty fields in ver1 with zeros + for ((i=${#ver1[@]}; i<${#ver2[@]}; i++)) + do + ver1[i]=0 + done + for ((i=0; i<${#ver1[@]}; i++)) + do + if [[ -z ${ver2[i]} ]] + then + # fill empty fields in ver2 with zeros + ver2[i]=0 + fi + if ((10#${ver1[i]} > 10#${ver2[i]})) + then + CLANG_MIN_VERSION_MATCH="<" + return + fi + if ((10#${ver1[i]} < 10#${ver2[i]})) + then + CLANG_MIN_VERSION_MATCH=">" + return + fi + done + CLANG_MIN_VERSION_MATCH="=" + return +} + +compare_version ${CLANG_MIN_VERSION} ${CLANG_VERSION} +git reset HEAD~1 --soft + +files=$((git diff --name-only --cached | grep -Ei "\.(c|cc|cpp|cxx|c\+\+|h|hh|hpp|hxx|h\+\+|java)$") || true) +if [ -n "${files}" ]; then + + if [ -n "${CLANG_FORMAT}" ] && [ "$CLANG_MIN_VERSION_MATCH" != "<" ]; then + spaced_files=$(echo "$files" | paste -s -d " " -) + echo "reformatting ${spaced_files}" + "${CLANG_FORMAT}" -style=file -i $spaced_files >/dev/null + git --no-pager diff + git add ${spaced_files} + fi +fi +git commit -C ORIG_HEAD diff --git a/pom.xml b/pom.xml new file mode 100644 index 00000000..cbd56cda --- /dev/null +++ b/pom.xml @@ -0,0 +1,368 @@ + + 4.0.0 + io.opencmw + opencmw + + ${revision}${sha1}${changelist} + pom + opencmw + + Microservice middleware framework for beam-based feedback systems at the FAIR particle accelerator. + + + + serialiser + core + server + server-rest + client + concepts + + + + 0.0.1 + -SNAPSHOT + + UTF-8 + 11 + 11 + 2.0.0-alpha0 + 20.1.0 + 0.5.2 + 3.4.2 + 3.13.3 + 9.4.35.v20201120 + 0.9.23 + 8.5.2 + 3.27.0-GA + 4.9.1 + 3.11 + 5.7.1 + 4.0.3 + 0.5.0 + 1.27 + 1.6.3 + 2.2 + 11.2.3 + + + + + LGPLv3 + https://www.gnu.org/licenses/lgpl-3.0.html + + + + + GSI Helmholtzzentrum für Schwerionenforschung GmbH + http://www.gsi.de + + + https://github.com/fair-acc/opencmw-java + + + + rstein + Ralph J. Steinhagen + R.Steinhagen@gsi.de + https://fair-wiki.gsi.de/FC2WG + +1 + + owner + architect + developer + + + + akrimm + Alexander Krimm + A.Krimm@gsi.de + +1 + + owner + architect + developer + + + + + + scm:git:https://github.com/fair-acc/opencmw-java + scm:git:git@github.com:fair-acc/opencmw-java + https://github.com/fair-acc/opencmw-java + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + 11 + 11 + + + + org.codehaus.mojo + flatten-maven-plugin + 1.2.5 + + true + resolveCiFriendliesOnly + + + + flatten + process-resources + + flatten + + + + flatten.clean + clean + + clean + + + + + + org.apache.maven.plugins + maven-source-plugin + 3.2.1 + + + attach-sources + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.2.0 + + + attach-javadocs + + jar + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M5 + + + + ${argLine} + --add-opens io.opencmw.serialiser/io.opencmw.serialiser=ALL-UNNAMED + --add-opens io.opencmw.serialiser/io.opencmw.serialiser.spi=ALL-UNNAMED + --add-opens io.opencmw.serialiser/io.opencmw.serialiser.spi.helper=ALL-UNNAMED + --add-opens io.opencmw.serialiser/io.opencmw.serialiser.spi.iobuffer=ALL-UNNAMED + --add-opens io.opencmw.serialiser/io.opencmw.serialiser.annotations=ALL-UNNAMED + --add-opens io.opencmw/io.opencmw=ALL-UNNAMED + --add-opens io.opencmw/io.opencmw.filter=ALL-UNNAMED + --add-opens io.opencmw/io.opencmw.utils=ALL-UNNAMED + --add-opens io.opencmw.client/io.opencmw.client=ALL-UNNAMED + --add-opens io.opencmw.client/io.opencmw.client.rest=ALL-UNNAMED + --add-opens io.opencmw.client/io.opencmw.client.cmwlight=ALL-UNNAMED + --add-opens io.opencmw.concepts/io.opencmw.concepts.cmwlight=ALL-UNNAMED + --add-opens io.opencmw.concepts/io.opencmw.concepts.aggregate=ALL-UNNAMED + -Duser.language=en -Duser.country=US + -Xms256m -Xmx2048m -XX:G1HeapRegionSize=32m + -Djava.awt.headless=true -Dtestfx.robot=glass -Dtestfx.headless=true -Dprism.order=sw + + 1 + 2 + random + + + + org.jacoco + jacoco-maven-plugin + 0.8.6 + + + + prepare-agent + + + + report + test + + report + + + + + + + + + + org.slf4j + slf4j-api + ${version.slf4j} + + + org.slf4j + slf4j-simple + ${version.slf4j} + test + + + org.jetbrains + annotations + ${version.jetbrains.annotations} + compile + + + org.junit.jupiter + junit-jupiter-api + ${version.jupiter} + test + + + org.junit.jupiter + junit-jupiter-engine + ${version.jupiter} + test + + + org.junit.jupiter + junit-jupiter-params + ${version.jupiter} + test + + + org.awaitility + awaitility + ${version.awaitility} + test + + + de.sandec + JMemoryBuddy + ${version.JMemoryBuddy} + test + + + + org.openjdk.jmh + jmh-core + ${version.jmh} + test + + + org.openjdk.jmh + jmh-generator-annprocess + ${version.jmh} + test + + + + + + releaseGithub + + + release + github + + + + + + org.apache.maven.plugins + maven-gpg-plugin + 1.6 + + + sign-artifacts + verify + + sign + + + + + + + --pinentry-mode + loopback + + + + + + + + github + GSI Github repository + https://maven.pkg.github.com/fair-acc/opencmw-java + + + + + releaseOSSRH + + + release + ossrh + + + + + + org.apache.maven.plugins + maven-gpg-plugin + 1.6 + + + sign-artifacts + verify + + sign + + + + + + + --pinentry-mode + loopback + + + + + org.sonatype.plugins + nexus-staging-maven-plugin + 1.6.8 + true + + ossrh + https://oss.sonatype.org/ + true + + + + + + + ossrh + https://oss.sonatype.org/content/repositories/snapshots + + + + + diff --git a/ruleset.xml b/ruleset.xml new file mode 100644 index 00000000..47febc43 --- /dev/null +++ b/ruleset.xml @@ -0,0 +1,103 @@ + + + + PMD rules for CSCOAP at GSI Java Applications + + + + + + .*\/generated-sources\/.* + + .*\/src\/test\/.* + + .*\/target\/.* + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 5 + + diff --git a/serialiser/pom.xml b/serialiser/pom.xml new file mode 100644 index 00000000..ad5e3b86 --- /dev/null +++ b/serialiser/pom.xml @@ -0,0 +1,71 @@ + + + 4.0.0 + + + io.opencmw + opencmw + ${revision}${sha1}${changelist} + ../pom.xml + + + serialiser + + + Efficient reflection based serialisers. + + + + + de.gsi.dataset + chartfx-dataset + ${version.chartfx} + true + + + + it.unimi.dsi + fastutil + ${version.fastutil} + + + com.jsoniter + jsoniter + ${version.jsoniter} + + + org.javassist + javassist + ${version.javassist} + + + + + + com.google.flatbuffers + flatbuffers-java + 1.12.0 + test + + + com.fasterxml.jackson.core + jackson-databind + 2.12.1 + test + + + com.google.code.gson + gson + 2.8.6 + test + + + com.alibaba + fastjson + 1.2.75 + test + + + + diff --git a/serialiser/src/main/java/io/opencmw/serialiser/Cat.java b/serialiser/src/main/java/io/opencmw/serialiser/Cat.java new file mode 100644 index 00000000..a5d272cc --- /dev/null +++ b/serialiser/src/main/java/io/opencmw/serialiser/Cat.java @@ -0,0 +1,12 @@ +package io.opencmw.serialiser; + +/** + * private type inner categories + * + * @author rstein + */ +enum Cat { + SINGLE_VALUE, + ARRAY, + COMPLEX_OBJECT +} diff --git a/serialiser/src/main/java/io/opencmw/serialiser/DataType.java b/serialiser/src/main/java/io/opencmw/serialiser/DataType.java new file mode 100644 index 00000000..cac68f8d --- /dev/null +++ b/serialiser/src/main/java/io/opencmw/serialiser/DataType.java @@ -0,0 +1,218 @@ +package io.opencmw.serialiser; + +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.Set; + +import io.opencmw.serialiser.spi.BinarySerialiser; + +import de.gsi.dataset.spi.utils.MultiArrayBoolean; +import de.gsi.dataset.spi.utils.MultiArrayByte; +import de.gsi.dataset.spi.utils.MultiArrayChar; +import de.gsi.dataset.spi.utils.MultiArrayDouble; +import de.gsi.dataset.spi.utils.MultiArrayFloat; +import de.gsi.dataset.spi.utils.MultiArrayInt; +import de.gsi.dataset.spi.utils.MultiArrayLong; +import de.gsi.dataset.spi.utils.MultiArrayObject; +import de.gsi.dataset.spi.utils.MultiArrayShort; + +/** + * Enum definition for data primitives in the context of serialisation and includes definitions for: + *
    + *
  • primitives (byte, short, ..., float, double, and String), and + *
  • arrays thereof (ie. byte[], short[], ..., float[], double[], and String[]), as well as + *
  • complex objects implementing Collections (ie. Set, List, Queues), Enums or Maps. + *
+ * Any other complex data objects can be stored/extended using the {@link DataType#OTHER OTHER} sub-type. + * + *

+ * A buffer is a linear, finite sequence of elements of a specific primitive type. Aside from its content, the essential + * properties of a buffer are its capacity, limit, and position: + *

+ *
+ *

+ * A buffer's capacity is the number of elements it contains. The capacity of a buffer is never negative and + * never changes. + *

+ *

+ * A buffer's limit is the index of the first element that should not be read or written. A buffer's limit is + * never negative and is never greater than its capacity. + *

+ *

+ * A buffer's position is the index of the next element to be read or written. A buffer's position is never + * negative and is never greater than its limit. + *

+ *
+ *

+ * The following invariant holds for the mark, position, limit, and capacity values:

{@code 0} {@code <=} + * position {@code <=} limit {@code <=} capacity
+ * + * @author rstein + */ +@SuppressWarnings("PMD.TooManyMethods") // NOPMD - these are short-hand convenience methods +public interface IoBufferHeader { + /** + * @return the capacity of this buffer + */ + int capacity(); + + /** + * Clears this buffer. The position is set to zero amd the limit is set to the capacity. + *

+ * Invoke this method before using a sequence of channel-read or put operations to fill this buffer. For + * example:

+ * + *
+     * buf.clear(); // Prepare buffer for reading
+     * in.read(buf); // Read data
+     * 
+ * + *
+ *

+ * This method does not actually erase the data in the buffer, but it is named as if it did because it will most + * often be used in situations in which that might as well be the case. + *

+ */ + void clear(); + + void ensureAdditionalCapacity(final int capacity); + + void ensureCapacity(final int capacity); + + /** + * Flips this buffer. The limit is set to the current position and then + * the position is set to zero. If the mark is defined then it is + * discarded. + * + *

After a sequence of channel-read or put operations, invoke + * this method to prepare for a sequence of channel-write or relative + * get operations. For example: + * + *

+     * buf.put(magic);    // Prepend header
+     * in.read(buf);      // Read data into rest of buffer
+     * buf.flip();        // Flip buffer
+     * out.write(buf);    // Write header + data to channel
+ */ + void flip(); + + /** + * Forces buffer to contain the given number of entries, preserving just a part of the array. + * + * @param length the new minimum length for this array. + * @param preserve the number of elements of the old buffer that shall be preserved in case a new allocation is + * necessary. + */ + void forceCapacity(final int length, final int preserve); + + /** + * @return {@code true} if, and only if, there is at least one element remaining in this buffer + */ + boolean hasRemaining(); + + /** + * @return {@code true} if, and only if, this buffer is read-only + */ + boolean isReadOnly(); + + /** + * @return the limit of this buffer + */ + int limit(); + + /** + * Sets this buffer's limit. If the position is larger than the new limit then it is set to the new limit. If the + * mark is defined and larger than the new limit then it is discarded. + * + * @param newLimit the new limit value; must be non-negative and no larger than this buffer's capacity + */ + void limit(final int newLimit); + + /** + * For efficiency/performance reasons the buffer implementation is not required to safe-guard each put/get method + * independently. Thus the user-code should acquire the given lock around a set of put/get appropriately. + * + * @return the read-write lock + */ + ReadWriteLock lock(); + + /** + * @return the position of this buffer + */ + int position(); + + /** + * Sets this buffer's position. If the mark is defined and larger than the new position then it is discarded. + * + * @param newPosition the new position value; must be non-negative and no larger than the current limit + */ + void position(final int newPosition); + + /** + * @return the number of elements remaining in this buffer + */ + int remaining(); + + /** + * resets the buffer read/write position to zero + */ + void reset(); + + /** + * Trims the internal buffer array so that the capacity is equal to the size. + * + * @see java.util.ArrayList#trimToSize() + */ + void trim(); + + /** + * Trims the internal buffer array if it is too large. If the current array length is smaller than or equal to + * {@code n}, this method does nothing. Otherwise, it trims the array length to the maximum between + * {@code requestedCapacity} and {@link #capacity()}. + *

+ * This method is useful when reusing FastBuffers. {@linkplain #reset() Clearing a list} leaves the array length + * untouched. If you are reusing a list many times, you can call this method with a typical size to avoid keeping + * around a very large array just because of a few large transient lists. + * + * @param requestedCapacity the threshold for the trimming. + */ + void trim(final int requestedCapacity); +} diff --git a/serialiser/src/main/java/io/opencmw/serialiser/IoClassSerialiser.java b/serialiser/src/main/java/io/opencmw/serialiser/IoClassSerialiser.java new file mode 100644 index 00000000..555aee36 --- /dev/null +++ b/serialiser/src/main/java/io/opencmw/serialiser/IoClassSerialiser.java @@ -0,0 +1,627 @@ +package io.opencmw.serialiser; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.opencmw.serialiser.spi.BinarySerialiser; +import io.opencmw.serialiser.spi.ClassFieldDescription; +import io.opencmw.serialiser.spi.CmwLightSerialiser; +import io.opencmw.serialiser.spi.JsonSerialiser; +import io.opencmw.serialiser.spi.WireDataFieldDescription; +import io.opencmw.serialiser.spi.iobuffer.FieldBoxedValueArrayHelper; +import io.opencmw.serialiser.spi.iobuffer.FieldBoxedValueHelper; +import io.opencmw.serialiser.spi.iobuffer.FieldCollectionsHelper; +import io.opencmw.serialiser.spi.iobuffer.FieldDataSetHelper; +import io.opencmw.serialiser.spi.iobuffer.FieldMapHelper; +import io.opencmw.serialiser.spi.iobuffer.FieldMultiArrayHelper; +import io.opencmw.serialiser.spi.iobuffer.FieldPrimitiveValueHelper; +import io.opencmw.serialiser.spi.iobuffer.FieldPrimitveValueArrayHelper; +import io.opencmw.serialiser.utils.ClassUtils; + +import de.gsi.dataset.utils.ByteArrayCache; + +/** + * reference implementation for streaming arbitrary object classes to and from a IoSerialiser- and IoBuffer-based buffers + * + * @author rstein + */ +@SuppressWarnings({ "PMD.TooManyMethods", "PMD.ExcessiveImports", "PMD.NPathComplexity" }) +public class IoClassSerialiser { + private static final Logger LOGGER = LoggerFactory.getLogger(IoClassSerialiser.class); + public static final String UNCHECKED_CAST_SUPPRESSION = "unchecked"; + private static final Map> CLASS_CONSTRUCTOR_MAP = new ConcurrentHashMap<>(); + protected final List ioSerialisers = new ArrayList<>(); + private final Map>> classMap = new ConcurrentHashMap<>(); + private final Map cachedFieldMatch = new ConcurrentHashMap<>(); + protected IoSerialiser matchedIoSerialiser; + protected IoBuffer dataBuffer; + protected Consumer startMarkerFunction; + protected Consumer endMarkerFunction; + private boolean autoMatchSerialiser = true; + private boolean useCustomJsonSerialiser; + + /** + * Initialises new IoBuffer-backed object serialiser + * + * @param ioBuffer the backing IoBuffer (see e.g. {@link IoBuffer} + * @param ioSerialiserTypeClass optional IoSerialiser type class this IoClassSerialiser should start with + * (see also e.g. {@link BinarySerialiser}, + * {@link CmwLightSerialiser}, or + * {@link JsonSerialiser} + */ + @SafeVarargs + public IoClassSerialiser(final IoBuffer ioBuffer, final Class... ioSerialiserTypeClass) { + dataBuffer = ioBuffer; + // add default IoSerialiser Implementations + ioSerialisers.add(new BinarySerialiser(dataBuffer)); + ioSerialisers.add(new JsonSerialiser(dataBuffer)); + ioSerialisers.add(new CmwLightSerialiser(dataBuffer)); + if (ioSerialiserTypeClass.length > 0) { + setMatchedIoSerialiser(ioSerialiserTypeClass[0]); // NOPMD + } else { + setMatchedIoSerialiser(ioSerialisers.get(0)); // NOPMD + } + + // register primitive and boxed data type handlers + FieldPrimitiveValueHelper.register(this); + FieldPrimitveValueArrayHelper.register(this); + FieldBoxedValueHelper.register(this); + FieldBoxedValueArrayHelper.register(this); + FieldCollectionsHelper.register(this); + + // Enum serialiser mapper to IoBuffer + addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().set(obj, io.getEnum((Enum) field.getField().get(obj))), // reader + (io, obj, field) -> io.getEnum((Enum) (field == null ? obj : field.getField().get(obj))), // return + (io, obj, field) -> io.put(field, (Enum) field.getField().get(obj)), // writer + Enum.class)); + + FieldMapHelper.register(this); + FieldDataSetHelper.register(this); + // MultiArray handlers + FieldMultiArrayHelper.register(this); + } + + public void addClassDefinition(FieldSerialiser serialiser) { + if (serialiser == null) { + throw new IllegalArgumentException("serialiser must not be null"); + } + if (serialiser.getClassPrototype() == null) { + throw new IllegalArgumentException("clazz must not be null"); + } + if (serialiser.getGenericsPrototypes() == null) { + throw new IllegalArgumentException("types must not be null"); + } + synchronized (knownClasses()) { + final List> list = knownClasses().computeIfAbsent(serialiser.getClassPrototype(), key -> new ArrayList<>()); + + if (list.isEmpty() || !list.contains(serialiser)) { + list.add(serialiser); + } + } + } + + /** + * if enabled ({@link #isAutoMatchSerialiser()} then set matching serialiser + */ + public void autoUpdateSerialiser() { + ioSerialisers.forEach(s -> s.setBuffer(dataBuffer)); + if (!isAutoMatchSerialiser()) { + return; + } + final int originalPosition = dataBuffer.position(); + for (IoSerialiser ioSerialiser : ioSerialisers) { + try { + ioSerialiser.checkHeaderInfo(); + this.setMatchedIoSerialiser(ioSerialiser); + LOGGER.atTrace().addArgument(matchedIoSerialiser).addArgument(matchedIoSerialiser.getBuffer().capacity()).log("set autoUpdateSerialiser() to {} - buffer capacity = {}"); + dataBuffer.position(originalPosition); + return; + } catch (Throwable e) { // NOPMD NOSONAR expected failures for protocol mismatch + LOGGER.atTrace().setCause(e).addArgument(ioSerialiser).log("could not match IoSerialiser '{}'"); + } + dataBuffer.position(originalPosition); + } + } + + @SuppressWarnings(UNCHECKED_CAST_SUPPRESSION) + public FieldSerialiser cacheFindFieldSerialiser(Type clazz, List classGenericArguments) { + // odd construction is needed since 'computeIfAbsent' cannot place 'null' element into the Map and since 'null' has a double interpretation of + // a) a non-initialiser map value + // b) a class for which no custom serialiser exist + return (FieldSerialiser) cachedFieldMatch.computeIfAbsent(new FieldSerialiserKey(clazz, classGenericArguments), key -> new FieldSerialiserValue(findFieldSerialiser(clazz, classGenericArguments))).get(); + } + + public T deserialiseObject(WireDataFieldDescription fieldRoot, final T obj) { + autoUpdateSerialiser(); + final int startPosition = matchedIoSerialiser.getBuffer().position(); + + // match field header with class field description + final ClassFieldDescription clazz = ClassUtils.getFieldDescription(obj.getClass()); + @SuppressWarnings(UNCHECKED_CAST_SUPPRESSION) + final FieldSerialiser existingSerialiser = (FieldSerialiser) clazz.getFieldSerialiser(); + final FieldSerialiser fieldSerialiser = existingSerialiser == null ? cacheFindFieldSerialiser(clazz.getType(), clazz.getActualTypeArguments()) : existingSerialiser; + + if (clazz.getFieldSerialiser() == null && fieldSerialiser != null) { + clazz.setFieldSerialiser(fieldSerialiser); + } + + matchedIoSerialiser.getBuffer().position(startPosition); + + if (fieldSerialiser != null) { + // return new object + final FieldDescription rawObjectFieldDescription = fieldRoot.getChildren().get(0).getChildren().get(0); + matchedIoSerialiser.getBuffer().position(rawObjectFieldDescription.getDataStartPosition()); + if (rawObjectFieldDescription.getDataType() == DataType.OTHER) { + return matchedIoSerialiser.getCustomData(fieldSerialiser); + } + return fieldSerialiser.getReturnObjectFunction().apply(matchedIoSerialiser, obj, clazz); + } + // deserialise into object + if (!fieldRoot.getChildren().isEmpty() && !fieldRoot.getChildren().get(0).getFieldName().isEmpty()) { + for (final FieldDescription child : fieldRoot.getChildren()) { + deserialise(obj, obj.getClass(), child, clazz, 0); + } + return obj; + } + + // class reference is not known by name (ie. was empty) parse directly dependent children + final List fieldRootChildren = fieldRoot.getChildren().get(0).getChildren(); + for (final FieldDescription fieldDescription : fieldRootChildren) { + final ClassFieldDescription subFieldDescription = (ClassFieldDescription) clazz.findChildField(fieldDescription.getFieldNameHashCode(), fieldDescription.getFieldName()); + + if (subFieldDescription != null) { + deserialise(obj, obj.getClass(), fieldDescription, subFieldDescription, 1); + } + } + return obj; + } + + public T deserialiseObject(final Class clazz) { + try { + final Constructor constructor = clazz.getDeclaredConstructor(); + constructor.setAccessible(true); + T obj = constructor.newInstance(); + return deserialiseObject(obj); + } catch (NoSuchMethodException | InstantiationException | IllegalAccessException | InvocationTargetException e) { + throw new IllegalStateException("no public constructor for class " + clazz.getCanonicalName(), e); + } + } + + public T deserialiseObject(final T obj) { + if (obj == null) { + throw new IllegalArgumentException("obj must not be null (yet)"); + } + autoUpdateSerialiser(); + // try to match buffer + if (matchedIoSerialiser instanceof JsonSerialiser) { + return ((JsonSerialiser) matchedIoSerialiser).deserialiseObject(obj); + } + final WireDataFieldDescription fieldRoot = parseWireFormat(); + return deserialiseObject(fieldRoot, obj); + } + + public void finaliseBuffer(ByteArrayCache arrayCache) { + try { + if (arrayCache == null) { + ByteArrayCache.getInstance().add(dataBuffer.elements()); + } else { + arrayCache.add(dataBuffer.elements()); + } + // return buffer to cache + dataBuffer = null; // NOPMD on purpose + for (IoSerialiser serialiser : ioSerialisers) { + serialiser.setBuffer(null); + } + } catch (Exception e) { // NOPMD + // do nothing + } + } + + /** + * find FieldSerialiser for known class, interface and corresponding generics + * @param type the class or interface + * @param classGenericArguments optional generics arguments + * @return FieldSerialiser matching the base class/interface and generics arguments + * @param The type of the Object to (de)serialise + */ + @SuppressWarnings({ UNCHECKED_CAST_SUPPRESSION }) + public FieldSerialiser findFieldSerialiser(Type type, List classGenericArguments) { + final Class clazz = ClassUtils.getRawType(type); + if (clazz == null) { + throw new IllegalArgumentException("clazz must not be null"); + } + final List> directClassMatchList = classMap.get(type); + if (directClassMatchList != null && !directClassMatchList.isEmpty()) { + if (directClassMatchList.size() == 1 || classGenericArguments == null || classGenericArguments.isEmpty()) { + return (FieldSerialiser) directClassMatchList.get(0); + } + // more than one possible serialiser implementation + for (final FieldSerialiser entry : directClassMatchList) { + if (checkClassCompatibility(classGenericArguments, entry.getGenericsPrototypes())) { + return (FieldSerialiser) entry; + } + } + // found FieldSerialiser entry but not matching required generic types + } + + // did not find FieldSerialiser entry by specific class -> search for assignable interface definitions + + final List> potentialMatchingKeys = new ArrayList<>(10); + for (Type key : knownClasses().keySet()) { + final Class testClass = ClassUtils.getRawType(key); + if (testClass.isAssignableFrom(clazz)) { + potentialMatchingKeys.add(testClass); + } + } + if (potentialMatchingKeys.isEmpty()) { + // did not find any matching clazz/interface FieldSerialiser entries + return null; + } + + final List> interfaceMatchList = new ArrayList<>(10); + for (Class testClass : potentialMatchingKeys) { + final List> fieldSerialisers = knownClasses().get(testClass); + if (fieldSerialisers.isEmpty()) { + continue; + } + interfaceMatchList.addAll(fieldSerialisers); + } + if (interfaceMatchList.size() == 1 || classGenericArguments == null || classGenericArguments.isEmpty()) { + // found single match FieldSerialiser entry type w/o specific generics requirements + return (FieldSerialiser) interfaceMatchList.get(0); + } + + // more than one possible serialiser implementation + for (final FieldSerialiser entry : interfaceMatchList) { + if (checkClassCompatibility(classGenericArguments, entry.getGenericsPrototypes())) { + // found generics matching or assignable entry + return (FieldSerialiser) entry; + } + } + // could not match with generics arguments + + // find generic serialiser entry w/o generics parameter requirements + return (FieldSerialiser) interfaceMatchList.stream().filter(entry -> entry.getGenericsPrototypes().isEmpty()).findFirst().orElse(null); + } + + public IoBuffer getDataBuffer() { + return dataBuffer; + } + + public IoSerialiser getMatchedIoSerialiser() { + return matchedIoSerialiser; + } + + @SuppressWarnings(UNCHECKED_CAST_SUPPRESSION) + public final BiFunction> getSerialiserLookupFunction() { + return (primaryType, secondaryType) -> { + if (primaryType == null) { + throw new IllegalArgumentException("no serialiser implementation found for classType = " + null); + } + return (FieldSerialiser) cacheFindFieldSerialiser(ClassUtils.getRawType(primaryType), secondaryType == null ? Collections.emptyList() : Arrays.asList(secondaryType)); + }; + } + + public boolean isAutoMatchSerialiser() { + return autoMatchSerialiser; + } + + public boolean isUseCustomJsonSerialiser() { + return useCustomJsonSerialiser; + } + + public Map>> knownClasses() { + return classMap; + } + + public WireDataFieldDescription parseWireFormat() { + autoUpdateSerialiser(); + final int startPosition = matchedIoSerialiser.getBuffer().position(); + matchedIoSerialiser.getBuffer().position(startPosition); + return matchedIoSerialiser.parseIoStream(true); + } + + public void serialiseObject(final Object rootObj, final ClassFieldDescription classField, final int recursionDepth) { + final FieldSerialiser existingSerialiser = classField.getFieldSerialiser(); + final FieldSerialiser fieldSerialiser = existingSerialiser == null ? cacheFindFieldSerialiser(classField.getType(), classField.getActualTypeArguments()) : existingSerialiser; + + if (fieldSerialiser != null && recursionDepth != 0) { + if (existingSerialiser == null) { + classField.setFieldSerialiser(fieldSerialiser); + } + // write field header + if (classField.getDataType() == DataType.OTHER) { + final WireDataFieldDescription header = matchedIoSerialiser.putFieldHeader(classField.getFieldName(), classField.getDataType()); + fieldSerialiser.getWriterFunction().accept(matchedIoSerialiser, rootObj, classField); + matchedIoSerialiser.updateDataEndMarker(header); + } else { + fieldSerialiser.getWriterFunction().accept(matchedIoSerialiser, rootObj, classField); + } + return; + } + // cannot serialise field check whether this is a container class and contains serialisable children + + if (classField.getChildren().isEmpty()) { + // no further children + return; + } + + // dive into it's children + if (recursionDepth != 0 && startMarkerFunction != null) { + startMarkerFunction.accept(classField); + } + + final Object newRoot = classField.getField() == null ? rootObj : classField.getField().get(rootObj); + for (final FieldDescription fieldDescription : classField.getChildren()) { + ClassFieldDescription field = (ClassFieldDescription) fieldDescription; + + if (!field.isPrimitive()) { + final Object reference = field.getField().get(newRoot); + if (!field.isPrimitive() && reference == null) { + // only follow and serialise non-null references of sub-classes + continue; + } + } + serialiseObject(newRoot, field, recursionDepth + 1); + } + + if (recursionDepth != 0 && endMarkerFunction != null) { + endMarkerFunction.accept(classField); + } + } + + public void serialiseObject(final Object obj) { + if (matchedIoSerialiser instanceof JsonSerialiser && useCustomJsonSerialiser) { + ((JsonSerialiser) matchedIoSerialiser).serialiseObject(obj); + return; + } + if (obj == null) { + // serialise null object + matchedIoSerialiser.putHeaderInfo(); + final String dataEndMarkerName = "OBJ_ROOT_END"; + final WireDataFieldDescription dataEndMarker = new WireDataFieldDescription(matchedIoSerialiser, null, dataEndMarkerName.hashCode(), dataEndMarkerName, DataType.START_MARKER, -1, -1, -1); + matchedIoSerialiser.putEndMarker(dataEndMarker); + return; + } + + final ClassFieldDescription classField = ClassUtils.getFieldDescription(obj.getClass()); + final FieldSerialiser existingSerialiser = classField.getFieldSerialiser(); + @SuppressWarnings("rawtypes") + final FieldSerialiser fieldSerialiser = existingSerialiser == null ? cacheFindFieldSerialiser(classField.getType(), classField.getActualTypeArguments()) : existingSerialiser; + + if (fieldSerialiser == null) { + matchedIoSerialiser.putHeaderInfo(classField); + serialiseObject(obj, classField, 0); + matchedIoSerialiser.putEndMarker(classField); + } else { + if (existingSerialiser == null) { + classField.setFieldSerialiser(fieldSerialiser); + } + matchedIoSerialiser.putHeaderInfo(); + @SuppressWarnings(UNCHECKED_CAST_SUPPRESSION) + FieldSerialiser castFieldSerialiser = fieldSerialiser; + matchedIoSerialiser.putCustomData(classField, obj, obj.getClass(), castFieldSerialiser); + final String dataEndMarkerName = "OBJ_ROOT_END"; + final WireDataFieldDescription dataEndMarker = new WireDataFieldDescription(matchedIoSerialiser, null, dataEndMarkerName.hashCode(), dataEndMarkerName, DataType.START_MARKER, -1, -1, -1); + matchedIoSerialiser.putEndMarker(dataEndMarker); + } + } + + public void setAutoMatchSerialiser(final boolean autoMatchSerialiser) { + this.autoMatchSerialiser = autoMatchSerialiser; + } + + public void setDataBuffer(final IoBuffer dataBuffer) { + this.dataBuffer = dataBuffer; + } + + public void setMatchedIoSerialiser(final Class serialiserTemplate) { + if (serialiserTemplate == null) { + throw new IllegalArgumentException("serialiserTemplate must not be null"); + } + + for (IoSerialiser ioSerialiser : ioSerialisers) { + if (ioSerialiser.getClass().equals(serialiserTemplate)) { + setMatchedIoSerialiser(ioSerialiser); + return; + } + } + throw new IllegalArgumentException("IoSerialiser '" + serialiserTemplate.getCanonicalName() + "' not registered with this = " + this); + } + + public void setUseCustomJsonSerialiser(final boolean useCustomJsonSerialiser) { + this.useCustomJsonSerialiser = useCustomJsonSerialiser; + } + + protected boolean checkClassCompatibility(final List ref1, final List ref2) { + if (ref1.size() != ref2.size()) { + return false; + } + if (ref1.isEmpty()) { + return true; + } + + for (int i = 0; i < ref1.size(); i++) { + final Class class1 = ClassUtils.getRawType(ref1.get(i)); + final Class class2 = ClassUtils.getRawType(ref2.get(i)); + if (!class1.equals(class2) && !(class2.isAssignableFrom(class1))) { + return false; + } + } + + return true; + } + + protected void deserialise(final Object obj, Class clazz, final FieldDescription fieldRoot, final ClassFieldDescription classField, final int recursionDepth) { + assert obj != null; + assert clazz != null; + @SuppressWarnings("rawtypes") + final FieldSerialiser existingSerialiser = classField.getFieldSerialiser(); + @SuppressWarnings(UNCHECKED_CAST_SUPPRESSION) + final FieldSerialiser fieldSerialiser = existingSerialiser == null ? cacheFindFieldSerialiser(classField.getType(), classField.getActualTypeArguments()) : existingSerialiser; + + if (fieldSerialiser != null) { + if (existingSerialiser == null) { + classField.setFieldSerialiser(fieldSerialiser); + } + matchedIoSerialiser.getBuffer().position(fieldRoot.getDataStartPosition()); + classField.getFieldSerialiser().getReaderFunction().accept(matchedIoSerialiser, obj, classField); + return; + } + + if (fieldRoot.getFieldNameHashCode() != classField.getFieldNameHashCode() /*|| !fieldRoot.getFieldName().equals(classField.getFieldName())*/) { + // did not find matching (sub-)field in class + if (fieldRoot.getChildren().isEmpty()) { + return; + } + // check for potential inner fields + for (final FieldDescription fieldDescription : fieldRoot.getChildren()) { + final ClassFieldDescription subFieldDescription = (ClassFieldDescription) classField.findChildField(fieldDescription.getFieldNameHashCode(), fieldDescription.getFieldName()); + + if (subFieldDescription != null) { + deserialise(obj, obj.getClass(), fieldDescription, subFieldDescription, recursionDepth + 1); + } + } + return; + } + + final Class fieldClass = ClassUtils.getRawType(classField.getType()); + if (classField.isFinal() && !fieldClass.isInterface()) { + // cannot set final variables + LOGGER.atWarn().addArgument(classField.getParent()).addArgument(classField.getFieldName()).log("cannot (read: better should not) set final field '{}-{}'"); + return; + } + + final Object ref = classField.getField() == null ? obj : classField.getField().get(obj); + final Object subRef; + if (ref == null) { + subRef = classField.allocateMemberClassField(obj); + } else { + subRef = ref; + } + + // no specific deserialiser present check for potential inner fields + for (final FieldDescription fieldDescription : fieldRoot.getChildren()) { + final ClassFieldDescription subFieldDescription = (ClassFieldDescription) classField.findChildField(fieldDescription.getFieldNameHashCode(), fieldDescription.getFieldName()); + + if (subFieldDescription != null) { + deserialise(subRef, subRef.getClass(), fieldDescription, subFieldDescription, recursionDepth + 1); + } + } + } + + private void setMatchedIoSerialiser(final IoSerialiser matchedIoSerialiser) { + this.matchedIoSerialiser = matchedIoSerialiser; + this.matchedIoSerialiser.setBuffer(dataBuffer); + this.matchedIoSerialiser.setFieldSerialiserLookupFunction(getSerialiserLookupFunction()); + assert this.matchedIoSerialiser.getBuffer() == dataBuffer; + startMarkerFunction = this.matchedIoSerialiser::putStartMarker; + endMarkerFunction = this.matchedIoSerialiser::putEndMarker; + LOGGER.atTrace().addArgument(matchedIoSerialiser).log("setMatchedIoSerialiser to {}"); + } + + public static int computeHashCode(final Class classPrototype, List classGenericArguments) { + final int prime = 31; + int result = 1; + result = prime * result + ((classPrototype == null) ? 0 : classPrototype.getName().hashCode()); + if (classGenericArguments == null || classGenericArguments.isEmpty()) { + return result; + } + for (final Type arg : classGenericArguments) { + result = prime * result + ((arg == null) ? 0 : arg.getTypeName().hashCode()); + } + + return result; + } + + @SuppressWarnings(UNCHECKED_CAST_SUPPRESSION) + public static Constructor getClassConstructorByName(final String name, Class... parameterTypes) { + return CLASS_CONSTRUCTOR_MAP.computeIfAbsent(name, key -> { + try { + return (Constructor) ClassUtils.getClassByName(key) + .getDeclaredConstructor(parameterTypes); + } catch (SecurityException | NoSuchMethodException e) { + LOGGER.atError().setCause(e).addArgument(Arrays.toString(parameterTypes)).addArgument(name).log("exception while getting constructor{} for class {}"); + return null; + } + }); + } + + public static String[] getClassNames(List> classGenericArguments) { + if (classGenericArguments == null) { + return new String[0]; + } + final String[] argStrings = new String[classGenericArguments.size()]; + for (int i = 0; i < argStrings.length; i++) { + argStrings[i] = classGenericArguments.get(i).getName(); + } + return argStrings; + } + + public static String getGenericFieldSimpleTypeString(List classArguments) { + if (classArguments == null || classArguments.isEmpty()) { + return ""; + } + return classArguments.stream().map(Type::getTypeName).collect(Collectors.joining(", ", "<", ">")); + } + + private static class FieldSerialiserKey { + private final Type clazz; + private final List classGenericArguments; + + private FieldSerialiserKey(Type clazz, List classGenericArguments) { + this.clazz = clazz; + this.classGenericArguments = classGenericArguments; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + final FieldSerialiserKey that = (FieldSerialiserKey) o; + return clazz.equals(that.clazz) && classGenericArguments.equals(that.classGenericArguments); + } + + @Override + public int hashCode() { + return Objects.hash(clazz, classGenericArguments); + } + + @Override + public String toString() { + return "FieldSerialiserKey{" + + "clazz=" + clazz + ", classGenericArguments=" + classGenericArguments + '}'; + } + } + + private static class FieldSerialiserValue { + private final FieldSerialiser fieldSerialiser; + + private FieldSerialiserValue(FieldSerialiser fieldSerialiser) { + this.fieldSerialiser = fieldSerialiser; + } + + private FieldSerialiser get() { + return fieldSerialiser; + } + } +} diff --git a/serialiser/src/main/java/io/opencmw/serialiser/IoSerialiser.java b/serialiser/src/main/java/io/opencmw/serialiser/IoSerialiser.java new file mode 100644 index 00000000..6c1d330d --- /dev/null +++ b/serialiser/src/main/java/io/opencmw/serialiser/IoSerialiser.java @@ -0,0 +1,382 @@ +package io.opencmw.serialiser; + +import java.lang.reflect.Type; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.Set; +import java.util.function.BiFunction; + +import io.opencmw.serialiser.spi.ProtocolInfo; +import io.opencmw.serialiser.spi.WireDataFieldDescription; + +@SuppressWarnings({ "PMD.TooManyMethods", "PMD.ExcessivePublicCount", "PMD.AvoidUsingShortType" }) // unavoidable since Java does not support templates (issue: primitive types) +public interface IoSerialiser { + /** + * Reads and checks protocol header information. + * @return ProtocolInfo info Object (extends FieldHeader) + * @throws IllegalStateException in case the format is incompatible with this serialiser + */ + ProtocolInfo checkHeaderInfo(); + + void setQueryFieldName(String fieldName, final int dataStartPosition); + + int[] getArraySizeDescriptor(); + + boolean getBoolean(); // NOPMD by rstein + + default boolean[] getBooleanArray() { + return getBooleanArray(null, 0); + } + + default boolean[] getBooleanArray(final boolean[] dst) { + return getBooleanArray(dst, dst == null ? -1 : dst.length); + } + + boolean[] getBooleanArray(final boolean[] dst, final int length); + + IoBuffer getBuffer(); + + void setBuffer(IoBuffer buffer); + + byte getByte(); + + default byte[] getByteArray() { + return getByteArray(null, 0); + } + + default byte[] getByteArray(final byte[] dst) { + return getByteArray(dst, dst == null ? -1 : dst.length); + } + + byte[] getByteArray(final byte[] dst, final int length); + + char getChar(); + + default char[] getCharArray() { + return getCharArray(null, 0); + } + + default char[] getCharArray(final char[] dst) { + return getCharArray(dst, dst == null ? -1 : dst.length); + } + + char[] getCharArray(final char[] dst, final int length); + + Collection getCollection(Collection collection); + + E getCustomData(FieldSerialiser serialiser); + + double getDouble(); + + default double[] getDoubleArray() { + return getDoubleArray(null, 0); + } + + default double[] getDoubleArray(final double[] dst) { + return getDoubleArray(dst, dst == null ? -1 : dst.length); + } + + double[] getDoubleArray(final double[] dst, final int length); + + > Enum getEnum(Enum enumeration); + + String getEnumTypeList(); + + WireDataFieldDescription getFieldHeader(); + + float getFloat(); + + default float[] getFloatArray() { + return getFloatArray(null, 0); + } + + default float[] getFloatArray(final float[] dst) { + return getFloatArray(dst, dst == null ? -1 : dst.length); + } + + float[] getFloatArray(final float[] dst, final int length); + + int getInt(); + + default int[] getIntArray() { + return getIntArray(null, 0); + } + + default int[] getIntArray(final int[] dst) { + return getIntArray(dst, dst == null ? -1 : dst.length); + } + + int[] getIntArray(final int[] dst, final int length); + + List getList(List collection); + + long getLong(); + + default long[] getLongArray() { + return getLongArray(null, 0); + } + + default long[] getLongArray(final long[] dst) { + return getLongArray(dst, dst == null ? -1 : dst.length); + } + + long[] getLongArray(final long[] dst, final int length); + + Map getMap(Map map); + + Queue getQueue(Queue collection); + + Set getSet(Set collection); + + short getShort(); // NOPMD by rstein + + default short[] getShortArray() { // NOPMD by rstein + return getShortArray(null, 0); + } + + default short[] getShortArray(final short[] dst) { // NOPMD by rstein + return getShortArray(dst, dst == null ? -1 : dst.length); + } + + short[] getShortArray(final short[] dst, final int length); // NOPMD by rstein + + String getString(); + + default String[] getStringArray() { + return getStringArray(null, 0); + } + + default String[] getStringArray(final String[] dst) { + return getStringArray(dst, dst == null ? -1 : dst.length); + } + + String[] getStringArray(final String[] dst, final int length); + + String getStringISO8859(); + + boolean isPutFieldMetaData(); + + void setPutFieldMetaData(boolean putFieldMetaData); + + WireDataFieldDescription parseIoStream(boolean readHeader); + + void put(FieldDescription fieldDescription, Collection collection, Type valueType); + + void put(FieldDescription fieldDescription, Enum enumeration); + + void put(FieldDescription fieldDescription, Map map, Type keyType, Type valueType); + + void put(String fieldName, Collection collection, Type valueType); + + void put(String fieldName, Enum enumeration); + + void put(String fieldName, Map map, Type keyType, Type valueType); + + default void put(FieldDescription fieldDescription, final boolean[] src) { + put(fieldDescription, src, -1); + } + + default void put(FieldDescription fieldDescription, final byte[] src) { + put(fieldDescription, src, -1); + } + + default void put(FieldDescription fieldDescription, final char[] src) { + put(fieldDescription, src, -1); + } + + default void put(FieldDescription fieldDescription, final double[] src) { + put(fieldDescription, src, -1); + } + + default void put(FieldDescription fieldDescription, final float[] src) { + put(fieldDescription, src, -1); + } + + default void put(FieldDescription fieldDescription, final int[] src) { + put(fieldDescription, src, -1); + } + + default void put(FieldDescription fieldDescription, final long[] src) { + put(fieldDescription, src, -1); + } + + default void put(FieldDescription fieldDescription, final short[] src) { // NOPMD + put(fieldDescription, src, -1); + } + + default void put(FieldDescription fieldDescription, final String[] src) { + put(fieldDescription, src, -1); + } + + void put(FieldDescription fieldDescription, boolean value); + + void put(FieldDescription fieldDescription, boolean[] values, int n); + + void put(FieldDescription fieldDescription, boolean[] values, int[] dims); + + void put(FieldDescription fieldDescription, byte value); + + void put(FieldDescription fieldDescription, byte[] values, int n); + + void put(FieldDescription fieldDescription, byte[] values, int[] dims); + + void put(FieldDescription fieldDescription, char value); + + void put(FieldDescription fieldDescription, char[] values, int n); + + void put(FieldDescription fieldDescription, char[] values, int[] dims); + + void put(FieldDescription fieldDescription, double value); + + void put(FieldDescription fieldDescription, double[] values, int n); + + void put(FieldDescription fieldDescription, double[] values, int[] dims); + + void put(FieldDescription fieldDescription, float value); + + void put(FieldDescription fieldDescription, float[] values, int n); + + void put(FieldDescription fieldDescription, float[] values, int[] dims); + + void put(FieldDescription fieldDescription, int value); + + void put(FieldDescription fieldDescription, int[] values, int n); + + void put(FieldDescription fieldDescription, int[] values, int[] dims); + + void put(FieldDescription fieldDescription, long value); + + void put(FieldDescription fieldDescription, long[] values, int n); + + void put(FieldDescription fieldDescription, long[] values, int[] dims); + + void put(FieldDescription fieldDescription, short value); + + void put(FieldDescription fieldDescription, short[] values, int n); + + void put(FieldDescription fieldDescription, short[] values, int[] dims); + + void put(FieldDescription fieldDescription, String string); + + void put(FieldDescription fieldDescription, String[] values, int n); + + void put(FieldDescription fieldDescription, String[] values, int[] dims); + + default void put(String fieldName, final boolean[] src) { + put(fieldName, src, -1); + } + + default void put(String fieldName, final byte[] src) { + put(fieldName, src, -1); + } + + default void put(String fieldName, final char[] src) { + put(fieldName, src, -1); + } + + default void put(String fieldName, final double[] src) { + put(fieldName, src, -1); + } + + default void put(String fieldName, final float[] src) { + put(fieldName, src, -1); + } + + default void put(String fieldName, final int[] src) { + put(fieldName, src, -1); + } + + default void put(String fieldName, final long[] src) { + put(fieldName, src, -1); + } + + default void put(String fieldName, final short[] src) { // NOPMD + put(fieldName, src, -1); + } + + default void put(String fieldName, final String[] src) { + put(fieldName, src, -1); + } + + void put(String fieldName, boolean value); + + void put(String fieldName, boolean[] values, int n); + + void put(String fieldName, boolean[] values, int[] dims); + + void put(String fieldName, byte value); + + void put(String fieldName, byte[] values, int n); + + void put(String fieldName, byte[] values, int[] dims); + + void put(String fieldName, char value); + + void put(String fieldName, char[] values, int n); + + void put(String fieldName, char[] values, int[] dims); + + void put(String fieldName, double value); + + void put(String fieldName, double[] values, int n); + + void put(String fieldName, double[] values, int[] dims); + + void put(String fieldName, float value); + + void put(String fieldName, float[] values, int n); + + void put(String fieldName, float[] values, int[] dims); + + void put(String fieldName, int value); + + void put(String fieldName, int[] values, int n); + + void put(String fieldName, int[] values, int[] dims); + + void put(String fieldName, long value); + + void put(String fieldName, long[] values, int n); + + void put(String fieldName, long[] values, int[] dims); + + void put(String fieldName, short value); + + void put(String fieldName, short[] values, int n); + + void put(String fieldName, short[] values, int[] dims); + + void put(String fieldName, String string); + + void put(String fieldName, String[] values, int n); + + void put(String fieldName, String[] values, int[] dims); + + int putArraySizeDescriptor(int n); + + int putArraySizeDescriptor(int[] dims); + + WireDataFieldDescription putCustomData(FieldDescription fieldDescription, E obj, Class type, FieldSerialiser serialiser); + + void putEndMarker(FieldDescription fieldDescription); + + WireDataFieldDescription putFieldHeader(FieldDescription fieldDescription); + + WireDataFieldDescription putFieldHeader(String fieldName, DataType dataType); + + /** + * Adds header and version information + * @param field optional FieldDescription (ie. to allow to attach MetaData to the start/stop marker) + */ + void putHeaderInfo(FieldDescription... field); + + void putStartMarker(FieldDescription fieldDescription); + + void updateDataEndMarker(WireDataFieldDescription fieldHeader); + + void setFieldSerialiserLookupFunction(BiFunction> serialiserLookupFunction); + + BiFunction> getSerialiserLookupFunction(); +} diff --git a/serialiser/src/main/java/io/opencmw/serialiser/annotations/Description.java b/serialiser/src/main/java/io/opencmw/serialiser/annotations/Description.java new file mode 100644 index 00000000..76c81b8d --- /dev/null +++ b/serialiser/src/main/java/io/opencmw/serialiser/annotations/Description.java @@ -0,0 +1,12 @@ +package io.opencmw.serialiser.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.FIELD, ElementType.TYPE }) +public @interface Description { + String value() default ""; +} diff --git a/serialiser/src/main/java/io/opencmw/serialiser/annotations/Direction.java b/serialiser/src/main/java/io/opencmw/serialiser/annotations/Direction.java new file mode 100644 index 00000000..815da5d7 --- /dev/null +++ b/serialiser/src/main/java/io/opencmw/serialiser/annotations/Direction.java @@ -0,0 +1,12 @@ +package io.opencmw.serialiser.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.FIELD, ElementType.TYPE }) +public @interface Direction { + String value() default ""; +} diff --git a/serialiser/src/main/java/io/opencmw/serialiser/annotations/Groups.java b/serialiser/src/main/java/io/opencmw/serialiser/annotations/Groups.java new file mode 100644 index 00000000..585c4e2c --- /dev/null +++ b/serialiser/src/main/java/io/opencmw/serialiser/annotations/Groups.java @@ -0,0 +1,12 @@ +package io.opencmw.serialiser.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.FIELD, ElementType.TYPE }) +public @interface Groups { + String[] value() default ""; +} diff --git a/serialiser/src/main/java/io/opencmw/serialiser/annotations/MetaInfo.java b/serialiser/src/main/java/io/opencmw/serialiser/annotations/MetaInfo.java new file mode 100644 index 00000000..8659a6f0 --- /dev/null +++ b/serialiser/src/main/java/io/opencmw/serialiser/annotations/MetaInfo.java @@ -0,0 +1,15 @@ +package io.opencmw.serialiser.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.FIELD, ElementType.TYPE }) +public @interface MetaInfo { + String unit() default ""; + String description() default ""; + String direction() default ""; + String[] groups() default ""; +} \ No newline at end of file diff --git a/serialiser/src/main/java/io/opencmw/serialiser/annotations/Unit.java b/serialiser/src/main/java/io/opencmw/serialiser/annotations/Unit.java new file mode 100644 index 00000000..61012c1c --- /dev/null +++ b/serialiser/src/main/java/io/opencmw/serialiser/annotations/Unit.java @@ -0,0 +1,12 @@ +package io.opencmw.serialiser.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.FIELD, ElementType.TYPE }) +public @interface Unit { + String value() default ""; +} diff --git a/serialiser/src/main/java/io/opencmw/serialiser/spi/BinarySerialiser.java b/serialiser/src/main/java/io/opencmw/serialiser/spi/BinarySerialiser.java new file mode 100644 index 00000000..27fb041b --- /dev/null +++ b/serialiser/src/main/java/io/opencmw/serialiser/spi/BinarySerialiser.java @@ -0,0 +1,1679 @@ +package io.opencmw.serialiser.spi; + +import java.lang.reflect.Method; +import java.lang.reflect.Type; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiFunction; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.opencmw.serialiser.DataType; +import io.opencmw.serialiser.FieldDescription; +import io.opencmw.serialiser.FieldSerialiser; +import io.opencmw.serialiser.IoBuffer; +import io.opencmw.serialiser.IoSerialiser; +import io.opencmw.serialiser.utils.AssertUtils; +import io.opencmw.serialiser.utils.ClassUtils; +import io.opencmw.serialiser.utils.GenericsHelper; + +/** + * YaS -- Yet another Serialiser implementation + * + * Generic binary serialiser aimed at efficiently transferring data between server/client and in particular between + * Java/C++/web-based programs. For rationale see IoSerialiser.md description. + * + *

+ * There are two default backing buffer implementations ({@link FastByteBuffer FastByteBuffer} and {@link ByteBuffer ByteBuffer}), + * but can be extended/replaced with any other buffer is also possible provided it implements the {@link IoBuffer IoBuffer} interface. + * + *

+ * The default serialisable data types are defined in {@link DataType DataType} and include definitions for + *

    + *
  • primitives (byte, short, ..., float, double, and String), and + *
  • arrays thereof (ie. byte[], short[], ..., float[], double[], and String[]), as well as + *
  • complex objects implementing Collections (ie. Set, List, Queues), Enums or Maps. + *
+ * Any other complex data objects can be stored/extended using the {@link DataType#OTHER OTHER} sub-type. + * + * N.B. Multi-dimensional arrays are handled through one-dimensional striding arrays with the additional + * infos on number of dimensions and size for each individual dimension. + * + *

+ * raw-byte level protocol: above data items are stored as follows: + *


+ * * header info:   [ 4 bytes (int) = 0x0000002A] // magic number used as coarse protocol identifier - precise protocol refined by further fields below
+ *                  [ clear text serialiser name: String ] + // ie. "YaS" for 'Yet another Serialiser'
+ *                  [ 1 byte - major protocol version ] +
+ *                  [ 1 byte - minor protocol version ] +
+ *                  [ 1 byte - micro protocol version ] // micro: non API-changing bug fixes in implementation
+ *                  [ field header for 'start marker' ] [ 1 byte - uniqueType (0x00) ]
+ * * String:        [ 4 bytes (int) - length (including termination) ][ n bytes based on ISO-8859 or UTF-8 encoding ]
+ * * field header:  # start field header 'p0'
+ *                  [ 1 byte - uniqueType ]
+ *                  [ 4 bytes - field name hash code] // enables faster field matching
+ *                  [ 4 bytes - dataStart = n bytes until data start] // counted w.r.t. field header start
+ *                  [ 4 bytes - dataSize = n bytes for data size]
+ *                  [ String (ISO-8859) - field name ]             // optional, if there are no field name hash code collisions
+ *                  N.B. following fields are optional (detectable if buffer position smaller than 'p0' + dataStart)
+ *                  [ String (UTF-8)    - field unit ]
+ *                  [ String (UTF-8)    - field in/out direction ]
+ *                  [ String (UTF-8)    - field groups ]
+ *                  # start data = 'p0' + dataStart
+ *                  ... type specific and/or custom data serialisation
+ *                  # end data = 'p0' + dataStart + dataSize
+ * * primitives:    [ field header for 'primitive type ID'] + [ 1-8 bytes depending on DataType ]
+ * * prim. arrays:  [ array header for 'prim. type array ID'] + [   ]=1-8 bytes x N_i or more - array data depending on variable DataType ]
+ * * boxed arrays:  as above but each element cast to corresponding primitive type
+ * * array header:  [ field header (as above) ] +
+ *                      [4 bytes - number of dimensions N_d ] +
+ *                      [4 bytes x N_d - vector sizes for each dimension N_i ]  
+ * * Collection[E]:
+ * * List[]:
+ * * Queue[E]:
+ * * Set[E]:        [ array header (uniqueType= one of the Collection type IDs) ] + 
+ *                      [ 1 byte - uniqueType of E ] + [  n bytes - array of E cast to primitive type and/or string ]
+ * * Map[K,V]:      [ array header (uniqueType=0xCB) ] + [ 1 byte - uniqueType of K ] +  [ 1 byte - uniqueType of V ] +
+ *                      [ n bytes - array of K cast to primitive type and/or string ] + 
+ *                      [ n bytes - array of V cast to primitive type and/or string ]
+ * * OTHER          [ field header - uniqueByte = 0xFD ] +
+ *                      [ 1 byte - uniqueType -- custom class type definition ]
+ *                      [ String (ISO-8859) - class type name ]
+ *                      [ n bytes - custom serialisation definition ]
+ * * start marker:  [ field header for '0x00' ] // dataSize == # bytes until the corresponding end-marker start
+ * * end marker:    [ field header for '0xFE' ]
+ * 
+ * * nesting or sub-structures (ie. POJOs with sub-classes) can be achieved via:
+ * [  start marker - field name == nesting context1 ] 
+ *   [  start marker - field name == nesting context2 ]
+ *    ... 
+ *   [  end marker - field name == nesting context2 (optional name) ]
+ * [  end marker - field name == nesting context1 (optional name) ]
+ * 
+ * with
+ * T: being a generic list parameter outlined in {@link DataType DataType}
+ * K: being a generic key parameter outlined in {@link DataType DataType}
+ * V: being a generic value parameter outlined in {@link DataType DataType}
+ * 
+ * + * @author rstein + */ +@SuppressWarnings({ "PMD.CommentSize", "PMD.TooManyMethods", "PMD.ExcessivePublicCount", "PMD.PrematureDeclaration", "PMD.ExcessiveClassLength", "PMD.NPathComplexity" }) // variables need to be read from stream +public class BinarySerialiser implements IoSerialiser { + public static final String UNCHECKED_CAST_SUPPRESSION = "unchecked"; + public static final int VERSION_MAGIC_NUMBER = -1; // '-1' since CmwLight cannot have a negative number of entries + public static final String PROTOCOL_NAME = "YaS"; // Yet another Serialiser implementation + public static final byte VERSION_MAJOR = 1; + public static final byte VERSION_MINOR = 0; + public static final byte VERSION_MICRO = 0; + public static final String PROTOCOL_ERROR_SERIALISER_LOOKUP_MUST_NOT_BE_NULL = "protocol error: serialiser lookup must not be null for DataType == OTHER"; + public static final String PROTOCOL_MISMATCH_N_ELEMENTS_HEADER = "protocol mismatch nElements header = "; + public static final String NO_SERIALISER_IMP_FOUND = "no serialiser implementation found for classType = "; + public static final String VS_ARRAY = " vs. array = "; + private static final Logger LOGGER = LoggerFactory.getLogger(BinarySerialiser.class); + private static final int ADDITIONAL_HEADER_INFO_SIZE = 1000; + private static final DataType[] BYTE_TO_DATA_TYPE = new DataType[256]; + private static final Byte[] DATA_TYPE_TO_BYTE = new Byte[256]; + public static final String VS_SHOULD_BE = "' vs. should be '"; + + static { + // static mapping of protocol bytes -- needed to be compatible with other wire protocols + BYTE_TO_DATA_TYPE[0] = DataType.START_MARKER; + + BYTE_TO_DATA_TYPE[1] = DataType.BOOL; + BYTE_TO_DATA_TYPE[2] = DataType.BYTE; + BYTE_TO_DATA_TYPE[3] = DataType.SHORT; + BYTE_TO_DATA_TYPE[4] = DataType.INT; + BYTE_TO_DATA_TYPE[5] = DataType.LONG; + BYTE_TO_DATA_TYPE[6] = DataType.FLOAT; + BYTE_TO_DATA_TYPE[7] = DataType.DOUBLE; + BYTE_TO_DATA_TYPE[8] = DataType.CHAR; + BYTE_TO_DATA_TYPE[9] = DataType.STRING; + + BYTE_TO_DATA_TYPE[101] = DataType.BOOL_ARRAY; + BYTE_TO_DATA_TYPE[102] = DataType.BYTE_ARRAY; + BYTE_TO_DATA_TYPE[103] = DataType.SHORT_ARRAY; + BYTE_TO_DATA_TYPE[104] = DataType.INT_ARRAY; + BYTE_TO_DATA_TYPE[105] = DataType.LONG_ARRAY; + BYTE_TO_DATA_TYPE[106] = DataType.FLOAT_ARRAY; + BYTE_TO_DATA_TYPE[107] = DataType.DOUBLE_ARRAY; + BYTE_TO_DATA_TYPE[108] = DataType.CHAR_ARRAY; + BYTE_TO_DATA_TYPE[109] = DataType.STRING_ARRAY; + + BYTE_TO_DATA_TYPE[200] = DataType.COLLECTION; + BYTE_TO_DATA_TYPE[201] = DataType.ENUM; + BYTE_TO_DATA_TYPE[202] = DataType.LIST; + BYTE_TO_DATA_TYPE[203] = DataType.MAP; + BYTE_TO_DATA_TYPE[204] = DataType.QUEUE; + BYTE_TO_DATA_TYPE[205] = DataType.SET; + + BYTE_TO_DATA_TYPE[0xFD] = DataType.OTHER; + BYTE_TO_DATA_TYPE[0xFE] = DataType.END_MARKER; + + for (int i = 0; i < BYTE_TO_DATA_TYPE.length; i++) { + if (BYTE_TO_DATA_TYPE[i] == null) { + continue; + } + final int id = BYTE_TO_DATA_TYPE[i].getID(); + DATA_TYPE_TO_BYTE[id] = (byte) i; + } + } + + private int bufferIncrements = ADDITIONAL_HEADER_INFO_SIZE; + private IoBuffer buffer; + private boolean putFieldMetaData = true; + private WireDataFieldDescription parent; + private WireDataFieldDescription lastFieldHeader; + private BiFunction> fieldSerialiserLookupFunction; + + /** + * @param buffer the backing IoBuffer (see e.g. {@link FastByteBuffer} or{@link ByteBuffer} + */ + public BinarySerialiser(final IoBuffer buffer) { + super(); + this.buffer = buffer; + } + + @Override + public ProtocolInfo checkHeaderInfo() { + final int magicNumber = buffer.getInt(); + if (magicNumber != VERSION_MAGIC_NUMBER) { + throw new IllegalStateException("byte buffer version magic byte incompatible: received '" + magicNumber + VS_SHOULD_BE + VERSION_MAGIC_NUMBER + "'"); + } + final String producer = buffer.getStringISO8859(); + if (!PROTOCOL_NAME.equals(producer)) { + throw new IllegalStateException("byte buffer producer name incompatible: received '" + producer + VS_SHOULD_BE + PROTOCOL_NAME + "'"); + } + final byte major = buffer.getByte(); + final byte minor = buffer.getByte(); + final byte micro = buffer.getByte(); + + final WireDataFieldDescription headerStartField = getFieldHeader(); + final ProtocolInfo header = new ProtocolInfo(this, headerStartField, producer, major, minor, micro); + + if (!header.isCompatible()) { + final String thisHeader = String.format(" serialiser: %s-v%d.%d.%d", PROTOCOL_NAME, VERSION_MAJOR, VERSION_MINOR, VERSION_MICRO); + throw new IllegalStateException("byte buffer version incompatible: received '" + header.toString() + VS_SHOULD_BE + thisHeader + "'"); + } + return header; + } + + @Override + public void setQueryFieldName(final String fieldName, final int dataStartPosition) { + if (fieldName == null || fieldName.isBlank()) { + throw new IllegalArgumentException("fieldName must not be null or blank: " + fieldName); + } + buffer.position(dataStartPosition); + } + + @Override + public int[] getArraySizeDescriptor() { + final int nDims = buffer.getInt(); // number of dimensions + final int[] ret = new int[nDims]; + for (int i = 0; i < nDims; i++) { + ret[i] = buffer.getInt(); // vector size for each dimension + } + return ret; + } + + @Override + public boolean getBoolean() { + return buffer.getBoolean(); + } + + @Override + public boolean[] getBooleanArray(final boolean[] dst, final int length) { + getArraySizeDescriptor(); + return buffer.getBooleanArray(dst, length); + } + + @Override + public IoBuffer getBuffer() { + return buffer; + } + + @Override + public void setBuffer(final IoBuffer buffer) { + this.buffer = buffer; + } + + public int getBufferIncrements() { + return bufferIncrements; + } + + public void setBufferIncrements(final int bufferIncrements) { + AssertUtils.gtEqThanZero("bufferIncrements", bufferIncrements); + this.bufferIncrements = bufferIncrements; + } + + @Override + public byte getByte() { + return buffer.getByte(); + } + + @Override + public byte[] getByteArray(final byte[] dst, final int length) { + getArraySizeDescriptor(); + return buffer.getByteArray(dst, length); + } + + @Override + public char getChar() { + return buffer.getChar(); + } + + @Override + public char[] getCharArray(final char[] dst, final int length) { + getArraySizeDescriptor(); + return buffer.getCharArray(dst, length); + } + + @Override + public Collection getCollection(final Collection collection) { + getArraySizeDescriptor(); + final int nElements = buffer.getInt(); + final DataType collectionType = getDataType(buffer.getByte()); + final DataType valueDataType = getDataType(buffer.getByte()); + + final Collection retCollection; + if (collection == null) { + switch (collectionType) { + case SET: + retCollection = new HashSet<>(nElements); + break; + case QUEUE: + retCollection = new ArrayDeque<>(nElements); + break; + case LIST: + case COLLECTION: + default: + retCollection = new ArrayList<>(nElements); + break; + } + } else { + retCollection = collection; + retCollection.clear(); + } + + if (DataType.OTHER.equals(valueDataType)) { + final String classTypeName = buffer.getStringISO8859(); + final String secondaryTypeName = buffer.getStringISO8859(); + final Type classType = ClassUtils.getClassByName(classTypeName); + final Type[] secondaryType = secondaryTypeName.isEmpty() ? new Type[0] : new Type[] { ClassUtils.getClassByName(secondaryTypeName) }; + final BiFunction> serialiserLookup = getSerialiserLookupFunction(); + if (serialiserLookup == null) { + throw new IllegalArgumentException(PROTOCOL_ERROR_SERIALISER_LOOKUP_MUST_NOT_BE_NULL); + } + @SuppressWarnings(UNCHECKED_CAST_SUPPRESSION) + final FieldSerialiser serialiser = (FieldSerialiser) serialiserLookup.apply(classType, secondaryType); + + if (serialiser == null) { + throw new IllegalArgumentException(NO_SERIALISER_IMP_FOUND + classTypeName); + } + for (int i = 0; i < nElements; i++) { + retCollection.add(serialiser.getReturnObjectFunction().apply(this, null, null)); + } + + return retCollection; + } + + // read primitive or String value vector + final E[] values = getGenericArrayAsBoxedPrimitive(valueDataType); + if (nElements != values.length) { + throw new IllegalStateException(PROTOCOL_MISMATCH_N_ELEMENTS_HEADER + nElements + VS_ARRAY + values.length); + } + retCollection.addAll(Arrays.asList(values)); + + return retCollection; + } + + @Override + @SuppressWarnings(UNCHECKED_CAST_SUPPRESSION) + public E getCustomData(final FieldSerialiser serialiser) { + String classType = null; + String classSecondaryType = null; + try { + classType = buffer.getStringISO8859(); + classSecondaryType = buffer.getStringISO8859(); + if (serialiser == null) { + final Type classTypeT = ClassUtils.getClassByName(classType); + final Type[] secondaryTypeT = classSecondaryType.isEmpty() ? new Type[0] : new Type[] { ClassUtils.getClassByName(classSecondaryType) }; + return (E) getSerialiserLookupFunction().apply(classTypeT, secondaryTypeT).getReturnObjectFunction().apply(this, null, null); + } else { + return serialiser.getReturnObjectFunction().apply(this, null, null); + } + } catch (Exception e) { // NOPMD + LOGGER.atError().setCause(e).addArgument(classType).addArgument(classSecondaryType).log("problems with generic classType: {} classSecondaryType: {}"); + throw e; + } + } + + @Override + public double getDouble() { + return buffer.getDouble(); + } + + @Override + public double[] getDoubleArray(final double[] dst, final int length) { + getArraySizeDescriptor(); + return buffer.getDoubleArray(dst, length); + } + + @Override + @SuppressWarnings(UNCHECKED_CAST_SUPPRESSION) + public > Enum getEnum(final Enum enumeration) { + // read value vector + final String enumSimpleName = buffer.getStringISO8859(); + final String enumName = buffer.getStringISO8859(); + buffer.getStringISO8859(); // enumTypeList + final String enumState = buffer.getStringISO8859(); + buffer.getInt(); // enumOrdinal + // N.B. for the time being package name + class name is required + Class enumClass = ClassUtils.getClassByName(enumName); + if (enumClass == null) { + enumClass = ClassUtils.getClassByName(enumSimpleName); + if (enumClass == null) { + throw new IllegalStateException( + "could not find enum class description '" + enumName + "' or '" + enumSimpleName + "'"); + } + } + + try { + final Method valueOf = enumClass.getMethod("valueOf", String.class); + return (Enum) valueOf.invoke(null, enumState); + } catch (final ReflectiveOperationException e) { + LOGGER.atError().setCause(e).addArgument(enumClass).log("could not match 'valueOf(String)' function for class/(supposedly) enum of {}"); + } + + return null; + } + + @Override + public String getEnumTypeList() { + // read value vector + buffer.getStringISO8859(); // enumSimpleName + buffer.getStringISO8859(); // enumName + final String enumTypeList = buffer.getStringISO8859(); + buffer.getStringISO8859(); // enumState + buffer.getInt(); // enumOrdinal + + return enumTypeList; + } + + @Override + public WireDataFieldDescription getFieldHeader() { + final int headerStart = buffer.position(); + final byte dataTypeByte = buffer.getByte(); + final int fieldNameHashCode = buffer.getInt(); + final int dataStartOffset = buffer.getInt(); + final int dataStartPosition = headerStart + dataStartOffset; + int dataSize = buffer.getInt(); + final String fieldName; + if (buffer.position() < dataStartPosition) { + fieldName = buffer.getStringISO8859(); + } else { + fieldName = null; + } + + final DataType dataType = getDataType(dataTypeByte); + if (dataType == DataType.END_MARKER) { + parent = (WireDataFieldDescription) parent.getParent(); + } + lastFieldHeader = new WireDataFieldDescription(this, parent, fieldNameHashCode, fieldName, dataType, headerStart, dataStartOffset, dataSize); + if (dataType == DataType.START_MARKER) { + parent = lastFieldHeader; + } + + if (this.isPutFieldMetaData()) { + // optional meta data + if (buffer.position() < dataStartPosition) { + lastFieldHeader.setFieldUnit(buffer.getString()); + } + if (buffer.position() < dataStartPosition) { + lastFieldHeader.setFieldDescription(buffer.getString()); + } + if (buffer.position() < dataStartPosition) { + lastFieldHeader.setFieldDirection(buffer.getString()); + } + if (buffer.position() < dataStartPosition) { + final String[] fieldGroups = buffer.getStringArray(); + lastFieldHeader.setFieldGroups(fieldGroups == null ? Collections.emptyList() : Arrays.asList(fieldGroups)); + } + } else { + buffer.position(dataStartPosition); + } + + // check for header-dataStart offset consistency + if (buffer.position() != dataStartPosition) { + final int diff = dataStartPosition - buffer.position(); + throw new IllegalStateException("could not parse FieldHeader: fieldName='" + dataType + ":" + fieldName + "' dataOffset = " + dataStartOffset + " bytes (read) -- " // + + " buffer position is " + buffer.position() + " vs. calculated " + dataStartPosition + " diff = " + diff); + } + + if (dataSize >= 0) { + return lastFieldHeader; + } + + // last-minute check in case dataSize hasn't been set correctly + if (dataType.isScalar()) { + dataSize = dataType.getPrimitiveSize(); + } else if (dataType == DataType.STRING) { + // sneak-peak look-ahead to get actual string size + // N.B. regarding jump size: <(>string size -1> + + dataSize = buffer.getInt(buffer.position() + FastByteBuffer.SIZE_OF_INT) + FastByteBuffer.SIZE_OF_INT; + } + lastFieldHeader.setDataSize(dataSize); + + return lastFieldHeader; + } + + @Override + public float getFloat() { + return buffer.getFloat(); + } + + @Override + public float[] getFloatArray(final float[] dst, final int length) { + getArraySizeDescriptor(); + return buffer.getFloatArray(dst, length); + } + + @Override + public int getInt() { + return buffer.getInt(); + } + + @Override + public int[] getIntArray(final int[] dst, final int length) { + getArraySizeDescriptor(); + return buffer.getIntArray(dst, length); + } + + @Override + public List getList(final List collection) { + getArraySizeDescriptor(); + final int nElements = buffer.getInt(); + final DataType listDataType = getDataType(buffer.getByte()); + final DataType valueDataType = getDataType(buffer.getByte()); + if (!listDataType.equals(DataType.LIST) && !listDataType.equals(DataType.COLLECTION)) { + throw new IllegalArgumentException("dataType incompatible with List = " + listDataType); + } + final List retCollection; + if (collection == null) { + retCollection = new ArrayList<>(); + } else { + retCollection = collection; + retCollection.clear(); + } + + if (DataType.OTHER.equals(valueDataType)) { + final String classTypeName = buffer.getStringISO8859(); + final String secondaryTypeName = buffer.getStringISO8859(); + final Type classType = ClassUtils.getClassByName(classTypeName); + final Type[] secondaryType = secondaryTypeName.isEmpty() ? new Type[0] : new Type[] { ClassUtils.getClassByName(secondaryTypeName) }; + final BiFunction> serialiserLookup = getSerialiserLookupFunction(); + if (serialiserLookup == null) { + throw new IllegalArgumentException(PROTOCOL_ERROR_SERIALISER_LOOKUP_MUST_NOT_BE_NULL); + } + @SuppressWarnings(UNCHECKED_CAST_SUPPRESSION) + final FieldSerialiser serialiser = (FieldSerialiser) serialiserLookup.apply(classType, secondaryType); + + if (serialiser == null) { + throw new IllegalArgumentException(NO_SERIALISER_IMP_FOUND + classTypeName); + } + for (int i = 0; i < nElements; i++) { + retCollection.add(serialiser.getReturnObjectFunction().apply(this, null, null)); + } + + return retCollection; + } + + // read primitive or String value vector + final E[] values = getGenericArrayAsBoxedPrimitive(valueDataType); + if (nElements != values.length) { + throw new IllegalStateException(PROTOCOL_MISMATCH_N_ELEMENTS_HEADER + nElements + VS_ARRAY + values.length); + } + retCollection.addAll(Arrays.asList(values)); + + return retCollection; + } + + @Override + public long getLong() { + return buffer.getLong(); + } + + @Override + public long[] getLongArray(final long[] dst, final int length) { + getArraySizeDescriptor(); + return buffer.getLongArray(dst, length); + } + + @Override + @SuppressWarnings({ UNCHECKED_CAST_SUPPRESSION }) + public Map getMap(final Map map) { // NOSONAR NOPMD + getArraySizeDescriptor(); + final int nElements = buffer.getInt(); + // convert into two linear arrays one of K and the other for V streamer encoding as + // <1 (int)> + + // read key type and key value vector + final K[] keys; + final DataType keyDataType = getDataType(buffer.getByte()); + final BiFunction> serialiserLookup = getSerialiserLookupFunction(); + if (keyDataType == DataType.OTHER) { + final String classTypeName = buffer.getStringISO8859(); + final String secondaryTypeName = buffer.getStringISO8859(); + final Type classType = ClassUtils.getClassByName(classTypeName); + final Type[] secondaryType = secondaryTypeName.isEmpty() ? new Type[0] : new Type[] { ClassUtils.getClassByName(secondaryTypeName) }; + if (serialiserLookup == null) { + throw new IllegalArgumentException(PROTOCOL_ERROR_SERIALISER_LOOKUP_MUST_NOT_BE_NULL); + } + final FieldSerialiser serialiser = serialiserLookup.apply(classType, secondaryType); + if (serialiser == null) { + throw new IllegalArgumentException(NO_SERIALISER_IMP_FOUND + classTypeName); + } + keys = (K[]) new Object[nElements]; + for (int i = 0; i < keys.length; i++) { + keys[i] = (K) serialiser.getReturnObjectFunction().apply(this, null, null); + } + } else { + keys = getGenericArrayAsBoxedPrimitive(keyDataType); + } + // read value type and value vector + final V[] values; + final DataType valueDataType = getDataType(buffer.getByte()); + if (valueDataType == DataType.OTHER) { + final String classTypeName = buffer.getStringISO8859(); + final String secondaryTypeName = buffer.getStringISO8859(); + final Type classType = ClassUtils.getClassByName(classTypeName); + final Type[] secondaryType = secondaryTypeName.isEmpty() ? new Type[0] : new Type[] { ClassUtils.getClassByName(secondaryTypeName) }; + if (serialiserLookup == null) { + throw new IllegalArgumentException(PROTOCOL_ERROR_SERIALISER_LOOKUP_MUST_NOT_BE_NULL); + } + final FieldSerialiser serialiser = serialiserLookup.apply(classType, secondaryType); + + if (serialiser == null) { + throw new IllegalArgumentException(NO_SERIALISER_IMP_FOUND + classTypeName); + } + values = (V[]) new Object[nElements]; + for (int i = 0; i < values.length; i++) { + values[i] = (V) serialiser.getReturnObjectFunction().apply(this, null, null); + } + } else { + values = getGenericArrayAsBoxedPrimitive(valueDataType); + } + + // generate new/write into existing Map + final Map retMap = map == null ? new ConcurrentHashMap<>() : map; + if (map != null) { + map.clear(); + } + for (int i = 0; i < keys.length; i++) { + retMap.put(keys[i], values[i]); + } + + return retMap; + } + + public WireDataFieldDescription getParent() { + return parent; + } + + @Override + public Queue getQueue(final Queue collection) { + getArraySizeDescriptor(); + final int nElements = buffer.getInt(); + final DataType listDataType = getDataType(buffer.getByte()); + final DataType valueDataType = getDataType(buffer.getByte()); + if (!listDataType.equals(DataType.QUEUE) && !listDataType.equals(DataType.COLLECTION)) { + throw new IllegalArgumentException("dataType incompatible with Queue = " + listDataType); + } + final Queue retCollection; + if (collection == null) { + retCollection = new ArrayDeque<>(); + } else { + retCollection = collection; + retCollection.clear(); + } + + if (DataType.OTHER.equals(valueDataType)) { + final String classTypeName = buffer.getStringISO8859(); + final String secondaryTypeName = buffer.getStringISO8859(); + final Type classType = ClassUtils.getClassByName(classTypeName); + final Type[] secondaryType = secondaryTypeName.isEmpty() ? new Type[0] : new Type[] { ClassUtils.getClassByName(secondaryTypeName) }; + final BiFunction> serialiserLookup = getSerialiserLookupFunction(); + if (serialiserLookup == null) { + throw new IllegalArgumentException(PROTOCOL_ERROR_SERIALISER_LOOKUP_MUST_NOT_BE_NULL); + } + @SuppressWarnings(UNCHECKED_CAST_SUPPRESSION) + final FieldSerialiser serialiser = (FieldSerialiser) serialiserLookup.apply(classType, secondaryType); + + if (serialiser == null) { + throw new IllegalArgumentException(NO_SERIALISER_IMP_FOUND + classTypeName); + } + for (int i = 0; i < nElements; i++) { + retCollection.add(serialiser.getReturnObjectFunction().apply(this, null, null)); + } + + return retCollection; + } + + // read primitive or String value vector + final E[] values = getGenericArrayAsBoxedPrimitive(valueDataType); + if (nElements != values.length) { + throw new IllegalStateException(PROTOCOL_MISMATCH_N_ELEMENTS_HEADER + nElements + VS_ARRAY + values.length); + } + retCollection.addAll(Arrays.asList(values)); + + return retCollection; + } + + @Override + public Set getSet(final Set collection) { + getArraySizeDescriptor(); + final int nElements = buffer.getInt(); + final DataType listDataType = getDataType(buffer.getByte()); + final DataType valueDataType = getDataType(buffer.getByte()); + if (!listDataType.equals(DataType.SET) && !listDataType.equals(DataType.COLLECTION)) { + throw new IllegalArgumentException("dataType incompatible with Set = " + listDataType); + } + final Set retCollection; + if (collection == null) { + retCollection = new HashSet<>(); + } else { + retCollection = collection; + retCollection.clear(); + } + + if (DataType.OTHER.equals(valueDataType)) { + final BiFunction> serialiserLookup = getSerialiserLookupFunction(); + if (serialiserLookup == null) { + throw new IllegalArgumentException(PROTOCOL_ERROR_SERIALISER_LOOKUP_MUST_NOT_BE_NULL); + } + final String classTypeName = buffer.getStringISO8859(); + final String secondaryTypeName = buffer.getStringISO8859(); + final Type classType = ClassUtils.getClassByName(classTypeName); + final Type[] secondaryType = secondaryTypeName.isEmpty() ? new Type[0] : new Type[] { ClassUtils.getClassByName(secondaryTypeName) }; + @SuppressWarnings(UNCHECKED_CAST_SUPPRESSION) + final FieldSerialiser serialiser = (FieldSerialiser) serialiserLookup.apply(classType, secondaryType); + + if (serialiser == null) { + throw new IllegalArgumentException(NO_SERIALISER_IMP_FOUND + classTypeName); + } + for (int i = 0; i < nElements; i++) { + retCollection.add(serialiser.getReturnObjectFunction().apply(this, null, null)); + } + + return retCollection; + } + + // read primitive or String value vector + final E[] values = getGenericArrayAsBoxedPrimitive(valueDataType); + if (nElements != values.length) { + throw new IllegalStateException(PROTOCOL_MISMATCH_N_ELEMENTS_HEADER + nElements + VS_ARRAY + values.length); + } + retCollection.addAll(Arrays.asList(values)); + + return retCollection; + } + + @Override + public short getShort() { + return buffer.getShort(); + } + + @Override + public short[] getShortArray(final short[] dst, final int length) { + getArraySizeDescriptor(); + return buffer.getShortArray(dst, length); + } + + @Override + public String getString() { + return buffer.getString(); + } + + @Override + public String[] getStringArray(final String[] dst, final int length) { + getArraySizeDescriptor(); + return buffer.getStringArray(dst, length); + } + + @Override + public String getStringISO8859() { + return buffer.getStringISO8859(); + } + + /** + * @return {@code true} the ISO-8859-1 character encoding is being enforced for data fields (better performance), otherwise UTF-8 is being used (more generic encoding) + */ + public boolean isEnforceSimpleStringEncoding() { + return buffer.isEnforceSimpleStringEncoding(); + } + + /** + * + * @param state {@code true} the ISO-8859-1 character encoding is being enforced for data fields (better performance), otherwise UTF-8 is being used (more generic encoding) + */ + public void setEnforceSimpleStringEncoding(final boolean state) { + buffer.setEnforceSimpleStringEncoding(state); + } + + @Override + public boolean isPutFieldMetaData() { + return putFieldMetaData; + } + + @Override + public void setPutFieldMetaData(final boolean putFieldMetaData) { + this.putFieldMetaData = putFieldMetaData; + } + + @Override + public WireDataFieldDescription parseIoStream(final boolean readHeader) { + final WireDataFieldDescription fieldRoot = getRootElement(); + parent = fieldRoot; + final WireDataFieldDescription headerRoot = readHeader ? checkHeaderInfo().getFieldHeader() : getFieldHeader(); + buffer.position(headerRoot.getDataStartPosition()); + parseIoStream(headerRoot, 0); + //updateDataEndMarker(fieldRoot) + return fieldRoot; + } + + public void parseIoStream(final WireDataFieldDescription fieldRoot, final int recursionDepth) { + if (fieldRoot.getParent() == null) { + parent = lastFieldHeader = fieldRoot; + } + WireDataFieldDescription field; + while ((field = getFieldHeader()) != null) { + final DataType dataType = field.getDataType(); + if (dataType == DataType.END_MARKER) { + // reached end of (sub-)class - close nested hierarchy + break; + } + + if (dataType == DataType.START_MARKER) { + // detected sub-class start marker + parseIoStream(field, recursionDepth + 1); + continue; + } + + final int dataSize = field.getDataSize(); + if (dataSize < 0) { + throw new IllegalStateException("FieldDescription for '" + field.getFieldName() + "' type '" + dataType + "' has negative dataSize = " + dataSize); + } + final int skipPosition = field.getDataStartPosition() + dataSize; + buffer.position(skipPosition); + } + } + + @Override + public void put(final FieldDescription fieldDescription, final Collection collection, final Type valueType) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription); + final Object[] values = collection.toArray(); + final int nElements = collection.size(); + final Class cleanedType = ClassUtils.getRawType(valueType); + final DataType valueDataType = DataType.fromClassType(cleanedType); + final int entrySize = 17; // as an initial estimate + putArraySizeDescriptor(nElements); + buffer.putInt(nElements); + + if (collection instanceof Queue) { + buffer.putByte(getDataType(DataType.QUEUE)); + } else if (collection instanceof Set) { + buffer.putByte(getDataType(DataType.SET)); + } else if (collection instanceof List) { + buffer.putByte(getDataType(DataType.LIST)); + } else { + buffer.putByte(getDataType(DataType.COLLECTION)); + } + + final BiFunction> serialiserLookup = getSerialiserLookupFunction(); + if (ClassUtils.isPrimitiveWrapperOrString(cleanedType) || serialiserLookup == null) { + buffer.ensureAdditionalCapacity((nElements * entrySize) + 9); + buffer.putByte(getDataType(valueDataType)); // write value element type + putGenericArrayAsPrimitive(valueDataType, values, nElements); + } else { + buffer.putByte(getDataType(DataType.OTHER)); // write value element type + final Type[] secondaryType = ClassUtils.getSecondaryType(valueType); + @SuppressWarnings(UNCHECKED_CAST_SUPPRESSION) + final FieldSerialiser serialiser = (FieldSerialiser) serialiserLookup.apply(valueType, secondaryType); + if (serialiser == null) { + throw new IllegalArgumentException("could not find serialiser for class type " + valueType); + } + buffer.putStringISO8859(serialiser.getClassPrototype().getCanonicalName()); // primary type + buffer.putStringISO8859(serialiser.getGenericsPrototypes().isEmpty() ? "" : serialiser.getGenericsPrototypes().get(0).getTypeName()); // secondary type if any + + final FieldSerialiser.TriConsumer writerFunction = serialiser.getWriterFunction(); + for (final Object value : values) { + writerFunction.accept(this, value, null); + } + } + + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final FieldDescription fieldDescription, final Enum enumeration) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription); + if (enumeration == null) { + return; + } + @SuppressWarnings(UNCHECKED_CAST_SUPPRESSION) + final Class> clazz = (Class>) enumeration.getClass(); + if (clazz == null) { + return; + } + final Enum[] enumConsts = clazz.getEnumConstants(); + if (enumConsts == null) { + return; + } + + final int nElements = 1; + final int entrySize = 17; // as an initial estimate + + buffer.ensureAdditionalCapacity((nElements * entrySize) + 9); + final String typeList = Arrays.stream(clazz.getEnumConstants()).map(Object::toString).collect(Collectors.joining(", ", "[", "]")); + buffer.putStringISO8859(clazz.getSimpleName()); + buffer.putStringISO8859(enumeration.getClass().getName()); + buffer.putStringISO8859(typeList); + buffer.putStringISO8859(enumeration.name()); + buffer.putInt(enumeration.ordinal()); + + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final FieldDescription fieldDescription, final Map map, Type keyType, Type valueType) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription); + final Object[] keySet = map.keySet().toArray(); + final int nElements = keySet.length; + putArraySizeDescriptor(nElements); + buffer.putInt(nElements); + + // convert into two linear arrays one of K and the other for V streamer encoding as + // <1 (int)> + + final Class cleanedKeyType = ClassUtils.getRawType(keyType); + final DataType keyDataType = DataType.fromClassType(cleanedKeyType); + final BiFunction> serialiserLookup = getSerialiserLookupFunction(); + if (serialiserLookup == null || ClassUtils.isPrimitiveWrapperOrString(cleanedKeyType)) { + final int entrySize = 17; // as an initial estimate + buffer.ensureAdditionalCapacity((nElements * entrySize) + 9); + buffer.putByte(getDataType(keyDataType)); // write key element type + putGenericArrayAsPrimitive(keyDataType, keySet, nElements); + } else { + // write key type + buffer.putByte(getDataType(DataType.OTHER)); // write key element type + final Type[] secondaryKeyType = ClassUtils.getSecondaryType(keyType); + final FieldSerialiser serialiserKey = serialiserLookup.apply(keyType, secondaryKeyType); + if (serialiserKey == null) { + throw new IllegalArgumentException("could not find serialiser for key class type " + keyType); + } + buffer.putStringISO8859(serialiserKey.getClassPrototype().getCanonicalName()); // primary type + buffer.putStringISO8859(serialiserKey.getGenericsPrototypes().isEmpty() ? "" : serialiserKey.getGenericsPrototypes().get(0).getTypeName()); // secondary key type if any + // write key data + final FieldSerialiser.TriConsumer writerFunctionKey = serialiserKey.getWriterFunction(); + for (final Object key : keySet) { + writerFunctionKey.accept(this, key, null); + } + } + + final Class cleanedValueType = ClassUtils.getRawType(valueType); + final Object[] valueSet = map.values().toArray(); + final DataType valueDataType = DataType.fromClassType(cleanedValueType); + if (serialiserLookup == null || ClassUtils.isPrimitiveWrapperOrString(cleanedValueType)) { + final int entrySize = 17; // as an initial estimate + buffer.ensureAdditionalCapacity((nElements * entrySize) + 9); + buffer.putByte(getDataType(valueDataType)); // write value element type + putGenericArrayAsPrimitive(valueDataType, valueSet, nElements); + } else { + // write value type + buffer.putByte(getDataType(DataType.OTHER)); // write key element type + final Type[] secondaryValueType = ClassUtils.getSecondaryType(valueType); + final FieldSerialiser serialiserValue = serialiserLookup.apply(valueType, secondaryValueType); + if (serialiserValue == null) { + throw new IllegalArgumentException("could not find serialiser for value class type " + valueType); + } + buffer.putStringISO8859(serialiserValue.getClassPrototype().getCanonicalName()); // primary type + buffer.putStringISO8859(serialiserValue.getGenericsPrototypes().isEmpty() ? "" : serialiserValue.getGenericsPrototypes().get(0).getTypeName()); // secondary key type if any + + // write key data + final FieldSerialiser.TriConsumer writerFunctionValue = serialiserValue.getWriterFunction(); + for (final Object value : valueSet) { + writerFunctionValue.accept(this, value, null); + } + } + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final Collection collection, final Type valueType) { + final DataType dataType; + if (collection instanceof Queue) { + dataType = DataType.QUEUE; + } else if (collection instanceof Set) { + dataType = DataType.SET; + } else if (collection instanceof List) { + dataType = DataType.LIST; + } else { + dataType = DataType.COLLECTION; + } + + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, dataType); + this.put((FieldDescription) null, collection, valueType); + this.updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final Enum enumeration) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.ENUM); + this.put((FieldDescription) null, enumeration); + this.updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final Map map, final Type keyType, final Type valueType) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.MAP); + this.put((FieldDescription) null, map, keyType, valueType); + this.updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final FieldDescription fieldDescription, final boolean value) { + this.putFieldHeader(fieldDescription); + buffer.putBoolean(value); + } + + @Override + public void put(final FieldDescription fieldDescription, final boolean[] values, final int n) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription); + final int valuesSize = values == null ? 0 : values.length; + final int bytesToCopy = putArraySizeDescriptor(n >= 0 ? Math.min(n, valuesSize) : valuesSize); + buffer.putBooleanArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final FieldDescription fieldDescription, final boolean[] values, final int[] dims) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription); + final int bytesToCopy = putArraySizeDescriptor(dims); + buffer.putBooleanArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final FieldDescription fieldDescription, final byte value) { + this.putFieldHeader(fieldDescription); + buffer.putByte(value); + } + + @Override + public void put(final FieldDescription fieldDescription, final byte[] values, final int n) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription); + final int valuesSize = values == null ? 0 : values.length; + final int bytesToCopy = putArraySizeDescriptor(n >= 0 ? Math.min(n, valuesSize) : valuesSize); + buffer.putByteArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final FieldDescription fieldDescription, final byte[] values, final int[] dims) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription); + final int bytesToCopy = putArraySizeDescriptor(dims); + buffer.putByteArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final FieldDescription fieldDescription, final char value) { + this.putFieldHeader(fieldDescription); + buffer.putChar(value); + } + + @Override + public void put(final FieldDescription fieldDescription, final char[] values, final int n) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription); + final int valuesSize = values == null ? 0 : values.length; + final int bytesToCopy = putArraySizeDescriptor(n >= 0 ? Math.min(n, valuesSize) : valuesSize); + buffer.putCharArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final FieldDescription fieldDescription, final char[] values, final int[] dims) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription); + final int bytesToCopy = putArraySizeDescriptor(dims); + buffer.putCharArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final FieldDescription fieldDescription, final double value) { + this.putFieldHeader(fieldDescription); + buffer.putDouble(value); + } + + @Override + public void put(final FieldDescription fieldDescription, final double[] values, final int n) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription); + final int valuesSize = values == null ? 0 : values.length; + final int bytesToCopy = putArraySizeDescriptor(n >= 0 ? Math.min(n, valuesSize) : valuesSize); + buffer.putDoubleArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final FieldDescription fieldDescription, final double[] values, final int[] dims) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription); + final int bytesToCopy = putArraySizeDescriptor(dims); + buffer.putDoubleArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final FieldDescription fieldDescription, final float value) { + this.putFieldHeader(fieldDescription); + buffer.putFloat(value); + } + + @Override + public void put(final FieldDescription fieldDescription, final float[] values, final int n) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription); + final int valuesSize = values == null ? 0 : values.length; + final int bytesToCopy = putArraySizeDescriptor(n >= 0 ? Math.min(n, valuesSize) : valuesSize); + buffer.putFloatArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final FieldDescription fieldDescription, final float[] values, final int[] dims) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription); + final int bytesToCopy = putArraySizeDescriptor(dims); + buffer.putFloatArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final FieldDescription fieldDescription, final int value) { + this.putFieldHeader(fieldDescription); + buffer.putInt(value); + } + + @Override + public void put(final FieldDescription fieldDescription, final int[] values, final int n) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription); + final int valuesSize = values == null ? 0 : values.length; + final int bytesToCopy = putArraySizeDescriptor(n >= 0 ? Math.min(n, valuesSize) : valuesSize); + buffer.putIntArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final FieldDescription fieldDescription, final int[] values, final int[] dims) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription); + final int bytesToCopy = putArraySizeDescriptor(dims); + buffer.putIntArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final FieldDescription fieldDescription, final long value) { + this.putFieldHeader(fieldDescription); + buffer.putLong(value); + } + + @Override + public void put(final FieldDescription fieldDescription, final long[] values, final int n) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription); + final int valuesSize = values == null ? 0 : values.length; + final int bytesToCopy = putArraySizeDescriptor(n >= 0 ? Math.min(n, valuesSize) : valuesSize); + buffer.putLongArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final FieldDescription fieldDescription, final long[] values, final int[] dims) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription); + final int bytesToCopy = putArraySizeDescriptor(dims); + buffer.putLongArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final FieldDescription fieldDescription, final short value) { // NOPMD by rstein + this.putFieldHeader(fieldDescription); + buffer.putShort(value); + } + + @Override + public void put(final FieldDescription fieldDescription, final short[] values, final int n) { // NOPMD by rstein + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription); + final int valuesSize = values == null ? 0 : values.length; + final int bytesToCopy = putArraySizeDescriptor(n >= 0 ? Math.min(n, valuesSize) : valuesSize); + buffer.putShortArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final FieldDescription fieldDescription, final short[] values, final int[] dims) { // NOPMD by rstein + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription); + final int bytesToCopy = putArraySizeDescriptor(dims); + buffer.putShortArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final FieldDescription fieldDescription, final String string) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription); + buffer.putString(string); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final FieldDescription fieldDescription, final String[] values, final int n) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription); + final int valuesSize = values == null ? 0 : values.length; + final int nElements = n >= 0 ? Math.min(n, valuesSize) : valuesSize; + putArraySizeDescriptor(nElements); + buffer.putStringArray(values, nElements); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final FieldDescription fieldDescription, final String[] values, final int[] dims) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription); + final int nElements = putArraySizeDescriptor(dims); + putArraySizeDescriptor(nElements); + buffer.putStringArray(values, nElements); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final boolean value) { + this.putFieldHeader(fieldName, DataType.BOOL); + buffer.putBoolean(value); + } + + @Override + public void put(final String fieldName, final boolean[] values, final int n) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.BOOL_ARRAY); + final int valuesSize = values == null ? 0 : values.length; + final int bytesToCopy = putArraySizeDescriptor(n >= 0 ? Math.min(n, valuesSize) : valuesSize); + buffer.putBooleanArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final boolean[] values, final int[] dims) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.BOOL_ARRAY); + final int bytesToCopy = putArraySizeDescriptor(dims); + buffer.putBooleanArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final byte value) { + this.putFieldHeader(fieldName, DataType.BYTE); + buffer.putByte(value); + } + + @Override + public void put(final String fieldName, final byte[] values, final int n) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.BYTE_ARRAY); + final int valuesSize = values == null ? 0 : values.length; + final int bytesToCopy = putArraySizeDescriptor(n >= 0 ? Math.min(n, valuesSize) : valuesSize); + buffer.putByteArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final byte[] values, final int[] dims) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.BYTE_ARRAY); + final int bytesToCopy = putArraySizeDescriptor(dims); + buffer.putByteArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final char value) { + this.putFieldHeader(fieldName, DataType.CHAR); + buffer.putChar(value); + } + + @Override + public void put(final String fieldName, final char[] values, final int n) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.CHAR_ARRAY); + final int valuesSize = values == null ? 0 : values.length; + final int bytesToCopy = putArraySizeDescriptor(n >= 0 ? Math.min(n, valuesSize) : valuesSize); + buffer.putCharArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final char[] values, final int[] dims) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.CHAR_ARRAY); + final int bytesToCopy = putArraySizeDescriptor(dims); + buffer.putCharArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final double value) { + this.putFieldHeader(fieldName, DataType.DOUBLE); + buffer.putDouble(value); + } + + @Override + public void put(final String fieldName, final double[] values, final int n) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.DOUBLE_ARRAY); + final int valuesSize = values == null ? 0 : values.length; + final int bytesToCopy = putArraySizeDescriptor(n >= 0 ? Math.min(n, valuesSize) : valuesSize); + buffer.putDoubleArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final double[] values, final int[] dims) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.DOUBLE_ARRAY); + final int bytesToCopy = putArraySizeDescriptor(dims); + buffer.putDoubleArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final float value) { + this.putFieldHeader(fieldName, DataType.FLOAT); + buffer.putFloat(value); + } + + @Override + public void put(final String fieldName, final float[] values, final int n) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.FLOAT_ARRAY); + final int valuesSize = values == null ? 0 : values.length; + final int bytesToCopy = putArraySizeDescriptor(n >= 0 ? Math.min(n, valuesSize) : valuesSize); + buffer.putFloatArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final float[] values, final int[] dims) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.FLOAT_ARRAY); + final int bytesToCopy = putArraySizeDescriptor(dims); + buffer.putFloatArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final int value) { + this.putFieldHeader(fieldName, DataType.INT); + buffer.putInt(value); + } + + @Override + public void put(final String fieldName, final int[] values, final int n) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.INT_ARRAY); + final int valuesSize = values == null ? 0 : values.length; + final int bytesToCopy = putArraySizeDescriptor(n >= 0 ? Math.min(n, valuesSize) : valuesSize); + buffer.putIntArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final int[] values, final int[] dims) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.INT_ARRAY); + final int bytesToCopy = putArraySizeDescriptor(dims); + buffer.putIntArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final long value) { + this.putFieldHeader(fieldName, DataType.LONG); + buffer.putLong(value); + } + + @Override + public void put(final String fieldName, final long[] values, final int n) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.LONG_ARRAY); + final int valuesSize = values == null ? 0 : values.length; + final int bytesToCopy = putArraySizeDescriptor(n >= 0 ? Math.min(n, valuesSize) : valuesSize); + buffer.putLongArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final long[] values, final int[] dims) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.LONG_ARRAY); + final int bytesToCopy = putArraySizeDescriptor(dims); + buffer.putLongArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final short value) { // NOPMD by rstein + this.putFieldHeader(fieldName, DataType.SHORT); + buffer.putShort(value); + } + + @Override + public void put(final String fieldName, final short[] values, final int n) { // NOPMD by rstein + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.SHORT_ARRAY); + final int valuesSize = values == null ? 0 : values.length; + final int bytesToCopy = putArraySizeDescriptor(n >= 0 ? Math.min(n, valuesSize) : valuesSize); + buffer.putShortArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final short[] values, final int[] dims) { // NOPMD by rstein + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.SHORT_ARRAY); + final int bytesToCopy = putArraySizeDescriptor(dims); + buffer.putShortArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final String string) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.STRING); + buffer.putString(string); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final String[] values, final int n) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.STRING_ARRAY); + final int valuesSize = values == null ? 0 : values.length; + final int nElements = n >= 0 ? Math.min(n, valuesSize) : valuesSize; + putArraySizeDescriptor(nElements); + buffer.putStringArray(values, nElements); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final String[] values, final int[] dims) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.STRING_ARRAY); + final int nElements = putArraySizeDescriptor(dims); + putArraySizeDescriptor(nElements); + buffer.putStringArray(values, nElements); + updateDataEndMarker(fieldHeader); + } + + @Override + public int putArraySizeDescriptor(final int n) { + buffer.putInt(1); // number of dimensions + buffer.putInt(n); // vector size for each dimension + return n; + } + + @Override + public int putArraySizeDescriptor(final int[] dims) { + buffer.putInt(dims.length); // number of dimensions + int nElements = 1; + for (final int dim : dims) { + nElements *= dim; + buffer.putInt(dim); // vector size for each dimension + } + return nElements; + } + + @Override + public WireDataFieldDescription putCustomData(final FieldDescription fieldDescription, final E rootObject, Class type, final FieldSerialiser serialiser) { + if (parent == null) { + parent = lastFieldHeader = getRootElement(); + } + final WireDataFieldDescription oldParent = parent; + final WireDataFieldDescription ret = putFieldHeader(fieldDescription); + buffer.putByte(ret.getFieldStart(), getDataType(DataType.OTHER)); + parent = lastFieldHeader; + // write generic class description and type arguments (if any) to aid reconstruction + buffer.putStringISO8859(serialiser.getClassPrototype().getCanonicalName()); // primary type + buffer.putStringISO8859(serialiser.getGenericsPrototypes().isEmpty() ? "" : serialiser.getGenericsPrototypes().get(0).getTypeName()); // secondary type if any + serialiser.getWriterFunction().accept(this, rootObject, fieldDescription instanceof ClassFieldDescription ? (ClassFieldDescription) fieldDescription : null); + putEndMarker(fieldDescription); + parent = oldParent; + return ret; + } + + @Override + public void putEndMarker(final FieldDescription fieldDescription) { + updateDataEndMarker(parent); + updateDataEndMarker(lastFieldHeader); + if (parent.getParent() != null) { + parent = (WireDataFieldDescription) parent.getParent(); + } + + putFieldHeader(fieldDescription); + buffer.putByte(lastFieldHeader.getFieldStart(), getDataType(DataType.END_MARKER)); + } + + @Override + public WireDataFieldDescription putFieldHeader(final FieldDescription fieldDescription) { + if (fieldDescription == null) { + // early return + return null; + } + final DataType dataType = fieldDescription.getDataType(); + if (isPutFieldMetaData()) { + buffer.ensureAdditionalCapacity(bufferIncrements); + } + final boolean isScalar = dataType.isScalar(); + + // -- offset 0 vs. field start + final int headerStart = buffer.position(); + buffer.putByte(getDataType(dataType)); // data type ID + buffer.putInt(fieldDescription.getFieldNameHashCode()); + buffer.putInt(-1); // dataStart offset + final int dataSize = isScalar ? dataType.getPrimitiveSize() : -1; + buffer.putInt(dataSize); // dataSize (N.B. 'headerStart' + 'dataStart + dataSize' == start of next field header + buffer.putStringISO8859(fieldDescription.getFieldName()); // full field name + + if (isPutFieldMetaData() && fieldDescription.isAnnotationPresent() && dataType != DataType.END_MARKER) { + buffer.putString(fieldDescription.getFieldUnit()); + buffer.putString(fieldDescription.getFieldDescription()); + buffer.putString(fieldDescription.getFieldDirection()); + final String[] groups = fieldDescription.getFieldGroups().toArray(new String[0]); + buffer.putStringArray(groups, groups.length); + } + + // -- offset dataStart calculations + final int dataStartOffset = buffer.position() - headerStart; + buffer.putInt(headerStart + 5, dataStartOffset); // write offset to dataStart + + // from hereon there are data specific structures + buffer.ensureAdditionalCapacity(16); // allocate 16 bytes to account for potential array header (safe-bet) + + lastFieldHeader = new WireDataFieldDescription(this, parent, fieldDescription.getFieldNameHashCode(), fieldDescription.getFieldName(), dataType, headerStart, dataStartOffset, dataSize); + if (isPutFieldMetaData() && fieldDescription.isAnnotationPresent()) { + lastFieldHeader.setFieldUnit(fieldDescription.getFieldUnit()); + lastFieldHeader.setFieldDescription(fieldDescription.getFieldDescription()); + lastFieldHeader.setFieldDirection(fieldDescription.getFieldDirection()); + lastFieldHeader.setFieldGroups(fieldDescription.getFieldGroups()); + } + return lastFieldHeader; + } + + @Override + public WireDataFieldDescription putFieldHeader(final String fieldName, final DataType dataType) { + final int addCapacity = ((fieldName.length() + 18) * FastByteBuffer.SIZE_OF_BYTE) + bufferIncrements + dataType.getPrimitiveSize(); + buffer.ensureAdditionalCapacity(addCapacity); + final boolean isScalar = dataType.isScalar(); + + // -- offset 0 vs. field start + final int headerStart = buffer.position(); + buffer.putByte(getDataType(dataType)); // data type ID + buffer.putInt(fieldName.hashCode()); // unique hashCode identifier -- TODO: unify across C++/Java & optimise performance + buffer.putInt(-1); // dataStart offset + final int dataSize = isScalar ? dataType.getPrimitiveSize() : -1; + buffer.putInt(dataSize); // dataSize (N.B. 'headerStart' + 'dataStart + dataSize' == start of next field header + buffer.putStringISO8859(fieldName); // full field name + + // this putField method cannot add meta-data use 'putFieldHeader(final FieldDescription fieldDescription)' instead + + // -- offset dataStart calculations + final int fieldHeaderDataStart = buffer.position(); + final int dataStartOffset = (fieldHeaderDataStart - headerStart); + buffer.putInt(headerStart + 5, dataStartOffset); // write offset to dataStart + + // from hereon there are data specific structures + buffer.ensureAdditionalCapacity(16); // allocate 16 bytes to account for potential array header (safe-bet) + + lastFieldHeader = new WireDataFieldDescription(this, parent, fieldName.hashCode(), fieldName, dataType, headerStart, dataStartOffset, dataSize); + return lastFieldHeader; + } + + public void putGenericArrayAsPrimitive(final DataType dataType, final Object[] data, final int nToCopy) { + putArraySizeDescriptor(nToCopy); + switch (dataType) { + case BOOL: + buffer.putBooleanArray(GenericsHelper.toBoolPrimitive(data), nToCopy); + break; + case BYTE: + buffer.putByteArray(GenericsHelper.toBytePrimitive(data), nToCopy); + break; + case CHAR: + buffer.putCharArray(GenericsHelper.toCharPrimitive(data), nToCopy); + break; + case SHORT: + buffer.putShortArray(GenericsHelper.toShortPrimitive(data), nToCopy); + break; + case INT: + buffer.putIntArray(GenericsHelper.toIntegerPrimitive(data), nToCopy); + break; + case LONG: + buffer.putLongArray(GenericsHelper.toLongPrimitive(data), nToCopy); + break; + case FLOAT: + buffer.putFloatArray(GenericsHelper.toFloatPrimitive(data), nToCopy); + break; + case DOUBLE: + buffer.putDoubleArray(GenericsHelper.toDoublePrimitive(data), nToCopy); + break; + case STRING: + buffer.putStringArray(GenericsHelper.toStringPrimitive(data), nToCopy); + break; + case OTHER: + break; + default: + throw new IllegalArgumentException("type not implemented - " + data[0].getClass().getSimpleName()); + } + } + + @Override + public void putHeaderInfo(final FieldDescription... field) { + parent = lastFieldHeader = getRootElement(); + + buffer.ensureAdditionalCapacity(ADDITIONAL_HEADER_INFO_SIZE); + buffer.putInt(VERSION_MAGIC_NUMBER); + buffer.putStringISO8859(PROTOCOL_NAME); + buffer.putByte(VERSION_MAJOR); + buffer.putByte(VERSION_MINOR); + buffer.putByte(VERSION_MICRO); + if (field.length == 0 || field[0] == null) { + putStartMarker(new WireDataFieldDescription(this, null, "OBJ_ROOT_START".hashCode(), "OBJ_ROOT_START", DataType.START_MARKER, -1, -1, -1)); + } else { + putStartMarker(field[0]); + } + } + + @Override + public void putStartMarker(final FieldDescription fieldDescription) { + putFieldHeader(fieldDescription); + buffer.putByte(lastFieldHeader.getFieldStart(), getDataType(DataType.START_MARKER)); + + parent = lastFieldHeader; + } + + @Override + public void updateDataEndMarker(final WireDataFieldDescription fieldHeader) { + if (fieldHeader == null) { + // N.B. early return in case field header hasn't been written + return; + } + final int sizeMarkerEnd = buffer.position(); + if (isPutFieldMetaData() && sizeMarkerEnd >= buffer.capacity()) { + throw new IllegalStateException("buffer position " + sizeMarkerEnd + " is beyond buffer capacity " + buffer.capacity()); + } + + final int dataSize = sizeMarkerEnd - fieldHeader.getDataStartPosition(); + if (fieldHeader.getDataSize() != dataSize) { + final int headerStart = fieldHeader.getFieldStart(); + fieldHeader.setDataSize(dataSize); + buffer.putInt(headerStart + 9, dataSize); // 9 bytes = 1 byte for dataType, 4 bytes for fieldNameHashCode, 4 bytes for dataOffset + } + } + + @Override + public void setFieldSerialiserLookupFunction(final BiFunction> serialiserLookupFunction) { + this.fieldSerialiserLookupFunction = serialiserLookupFunction; + } + + @Override + public BiFunction> getSerialiserLookupFunction() { + return fieldSerialiserLookupFunction; + } + + @SuppressWarnings(UNCHECKED_CAST_SUPPRESSION) + protected E[] getGenericArrayAsBoxedPrimitive(final DataType dataType) { + final Object[] retVal; + getArraySizeDescriptor(); + switch (dataType) { + case BOOL: + retVal = GenericsHelper.toObject(buffer.getBooleanArray()); + break; + case BYTE: + retVal = GenericsHelper.toObject(buffer.getByteArray()); + break; + case CHAR: + retVal = GenericsHelper.toObject(buffer.getCharArray()); + break; + case SHORT: + retVal = GenericsHelper.toObject(buffer.getShortArray()); + break; + case INT: + retVal = GenericsHelper.toObject(buffer.getIntArray()); + break; + case LONG: + retVal = GenericsHelper.toObject(buffer.getLongArray()); + break; + case FLOAT: + retVal = GenericsHelper.toObject(buffer.getFloatArray()); + break; + case DOUBLE: + retVal = GenericsHelper.toObject(buffer.getDoubleArray()); + break; + case STRING: + retVal = buffer.getStringArray(); + break; + default: + throw new IllegalArgumentException("type not implemented - " + dataType); + } + return (E[]) retVal; + } + + private WireDataFieldDescription getRootElement() { + final int headerOffset = 1 + PROTOCOL_NAME.length() + 3; // unique byte + protocol length + 3 x byte for version + return new WireDataFieldDescription(this, null, "ROOT".hashCode(), "ROOT", DataType.OTHER, buffer.position() + headerOffset, -1, -1); + } + + public static byte getDataType(final DataType dataType) { + final int id = dataType.getID(); + if (DATA_TYPE_TO_BYTE[id] != null) { + return DATA_TYPE_TO_BYTE[id]; + } + + throw new IllegalArgumentException("DataType " + dataType + " not mapped to specific byte"); + } + + public static DataType getDataType(final byte byteValue) { + final int id = byteValue & 0xFF; + if (DATA_TYPE_TO_BYTE[id] != null) { + return BYTE_TO_DATA_TYPE[id]; + } + + throw new IllegalArgumentException("DataType byteValue=" + byteValue + " rawByteValue=" + (byteValue & 0xFF) + " not mapped"); + } +} diff --git a/serialiser/src/main/java/io/opencmw/serialiser/spi/ByteBuffer.java b/serialiser/src/main/java/io/opencmw/serialiser/spi/ByteBuffer.java new file mode 100644 index 00000000..a9079be4 --- /dev/null +++ b/serialiser/src/main/java/io/opencmw/serialiser/spi/ByteBuffer.java @@ -0,0 +1,599 @@ +package io.opencmw.serialiser.spi; + +import java.nio.charset.StandardCharsets; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +import io.opencmw.serialiser.IoBuffer; + +/** + * @author rstein + */ +@SuppressWarnings({ "PMD.TooManyMethods", "PMD.ExcessivePublicCount" }) // unavoidable: each primitive type needs to handled individually (no templates) +public class ByteBuffer implements IoBuffer { + public static final int SIZE_OF_BOOLEAN = 1; + public static final int SIZE_OF_BYTE = 1; + public static final int SIZE_OF_SHORT = 2; + public static final int SIZE_OF_CHAR = 2; + public static final int SIZE_OF_INT = 4; + public static final int SIZE_OF_LONG = 8; + public static final int SIZE_OF_FLOAT = 4; + public static final int SIZE_OF_DOUBLE = 8; + private static final int DEFAULT_INITIAL_CAPACITY = 1000; + private final ReadWriteLock internalLock = new ReentrantReadWriteLock(); + private final java.nio.ByteBuffer nioByteBuffer; + private boolean enforceSimpleStringEncoding; + + /** + * construct new java.nio.ByteBuffer-based ByteBuffer with DEFAULT_INITIAL_CAPACITY + */ + public ByteBuffer() { + this(DEFAULT_INITIAL_CAPACITY); + } + + /** + * construct new java.nio.ByteBuffer-based ByteBuffer with DEFAULT_INITIAL_CAPACITY + * + * @param nCapacity initial capacity + */ + public ByteBuffer(final int nCapacity) { + nioByteBuffer = java.nio.ByteBuffer.wrap(new byte[nCapacity]); + nioByteBuffer.mark(); + } + + @Override + public int capacity() { + return nioByteBuffer.capacity(); + } + + @Override + public void clear() { + nioByteBuffer.clear(); + } + + @Override + public byte[] elements() { + return nioByteBuffer.array(); + } + + @Override + public void ensureAdditionalCapacity(final int capacity) { + /* not implemented */ + } + + @Override + public void ensureCapacity(final int capacity) { + /* not implemented */ + } + + @Override + public void flip() { + nioByteBuffer.flip(); + } + + @Override + public void forceCapacity(final int length, final int preserve) { + /* not implemented */ + } + + @Override + public boolean getBoolean() { + return nioByteBuffer.get() > 0; + } + + @Override + public boolean getBoolean(final int position) { + return nioByteBuffer.get(position) > 0; + } + + @Override + public boolean[] getBooleanArray(final boolean[] dst, final int length) { + final int arraySize = getInt(); // strided-array size + final boolean initNeeded = dst == null || length < 0 || dst.length != arraySize; + final boolean[] ret = initNeeded ? new boolean[arraySize] : dst; + + for (int i = 0; i < arraySize; i++) { + ret[i] = getBoolean(); + } + return ret; + } + + @Override + public byte getByte() { + return nioByteBuffer.get(); + } + + @Override + public byte getByte(final int position) { + return nioByteBuffer.get(position); + } + + @Override + public byte[] getByteArray(final byte[] dst, final int length) { + final int arraySize = getInt(); // strided-array size + final boolean initNeeded = dst == null || length < 0 || dst.length != arraySize; + final byte[] ret = initNeeded ? new byte[arraySize] : dst; + nioByteBuffer.get(ret, 0, arraySize); + return ret; + } + + @Override + public char getChar() { + return nioByteBuffer.getChar(); + } + + @Override + public char getChar(final int position) { + return nioByteBuffer.getChar(position); + } + + @Override + public char[] getCharArray(final char[] dst, final int length) { + final int arraySize = getInt(); // strided-array size + final boolean initNeeded = dst == null || length < 0 || dst.length != arraySize; + final char[] ret = initNeeded ? new char[arraySize] : dst; + for (int i = 0; i < arraySize; i++) { + ret[i] = getChar(); + } + return ret; + } + + @Override + public double getDouble() { + return nioByteBuffer.getDouble(); + } + + @Override + public double getDouble(final int position) { + return nioByteBuffer.getDouble(position); + } + + @Override + public double[] getDoubleArray(final double[] dst, final int length) { + final int arraySize = getInt(); // strided-array size + final boolean initNeeded = dst == null || length < 0 || dst.length != arraySize; + final double[] ret = initNeeded ? new double[arraySize] : dst; + for (int i = 0; i < arraySize; i++) { + ret[i] = getDouble(); + } + return ret; + } + + @Override + public float getFloat() { + return nioByteBuffer.getFloat(); + } + + @Override + public float getFloat(final int position) { + return nioByteBuffer.getFloat(position); + } + + @Override + public float[] getFloatArray(final float[] dst, final int length) { + final int arraySize = getInt(); // strided-array size + final boolean initNeeded = dst == null || length < 0 || dst.length != arraySize; + final float[] ret = initNeeded ? new float[arraySize] : dst; + for (int i = 0; i < arraySize; i++) { + ret[i] = getFloat(); + } + return ret; + } + + @Override + public int getInt() { + return nioByteBuffer.getInt(); + } + + @Override + public int getInt(final int position) { + return nioByteBuffer.getInt(position); + } + + @Override + public int[] getIntArray(final int[] dst, final int length) { + final int arraySize = getInt(); // strided-array size + final boolean initNeeded = dst == null || length < 0 || dst.length != arraySize; + final int[] ret = initNeeded ? new int[arraySize] : dst; + for (int i = 0; i < arraySize; i++) { + ret[i] = getInt(); + } + return ret; + } + + @Override + public long getLong() { + return nioByteBuffer.getLong(); + } + + @Override + public long getLong(final int position) { + return nioByteBuffer.getLong(position); + } + + @Override + public long[] getLongArray(final long[] dst, final int length) { + final int arraySize = getInt(); // strided-array size + final boolean initNeeded = dst == null || length < 0 || dst.length != arraySize; + final long[] ret = initNeeded ? new long[arraySize] : dst; + for (int i = 0; i < arraySize; i++) { + ret[i] = getLong(); + } + return ret; + } + + @Override + public short getShort() { // NOPMD + return nioByteBuffer.getShort(); + } + + @Override + public short getShort(final int position) { + return nioByteBuffer.getShort(position); + } + + @Override + public short[] getShortArray(final short[] dst, final int length) { // NOPMD + final int arraySize = getInt(); // strided-array size + final boolean initNeeded = dst == null || length < 0 || dst.length != arraySize; + final short[] ret = initNeeded ? new short[arraySize] : dst; // NOPMD + for (int i = 0; i < arraySize; i++) { + ret[i] = getShort(); + } + return ret; + } + + @Override + public String getString() { + final int arraySize = getInt() - 1; // for C++ zero terminated string + final byte[] values = new byte[arraySize]; + nioByteBuffer.get(values, 0, arraySize); + getByte(); // For C++ zero terminated string + return new String(values, 0, arraySize, StandardCharsets.UTF_8); + } + + @Override + public String getString(final int position) { + final int oldPosition = nioByteBuffer.position(); + nioByteBuffer.position(position); + final String ret = getString(); + nioByteBuffer.position(oldPosition); + return ret; + } + + @Override + public String[] getStringArray(final String[] dst, final int length) { + final int arraySize = getInt(); // strided-array size + final boolean initNeeded = dst == null || length < 0 || dst.length != arraySize; + final String[] ret = initNeeded ? new String[arraySize] : dst; + for (int k = 0; k < arraySize; k++) { + ret[k] = getString(); + } + return ret; + } + + @Override + public String getStringISO8859() { + final int arraySize = getInt() - 1; // for C++ zero terminated string + final byte[] values = new byte[arraySize]; + nioByteBuffer.get(values, 0, arraySize); + getByte(); // For C++ zero terminated string + return new String(values, 0, arraySize, StandardCharsets.ISO_8859_1); + } + + @Override + public boolean hasRemaining() { + return nioByteBuffer.hasRemaining(); + } + + @Override + public boolean isEnforceSimpleStringEncoding() { + return enforceSimpleStringEncoding; + } + + @Override + public void setEnforceSimpleStringEncoding(final boolean state) { + this.enforceSimpleStringEncoding = state; + } + + @Override + public boolean isReadOnly() { + return nioByteBuffer.isReadOnly(); + } + + @Override + public int limit() { + return nioByteBuffer.limit(); + } + + @Override + public void limit(final int newLimit) { + nioByteBuffer.limit(newLimit); + } + + @Override + public ReadWriteLock lock() { + return internalLock; + } + + @Override + public int position() { + return nioByteBuffer.position(); + } + + @Override + public void position(final int newPosition) { + nioByteBuffer.position(newPosition); + } + + @Override + public void putBoolean(final boolean value) { + putByte((byte) (value ? 1 : 0)); + } + + @Override + public void putBoolean(final int position, final boolean value) { + nioByteBuffer.put(position, (byte) (value ? 1 : 0)); + } + + @Override + public void putBooleanArray(final boolean[] src, final int n) { + final int srcSize = src == null ? 0 : src.length; + final int nElements = n >= 0 ? Math.min(n, srcSize) : srcSize; + ensureAdditionalCapacity(nElements); + putInt(nElements); + if (src == null) { + return; + } + for (int i = 0; i < nElements; i++) { + putBoolean(src[i]); + } + } + + @Override + public void putByte(final byte b) { + nioByteBuffer.put(b); + } + + @Override + public void putByte(final int position, final byte value) { + nioByteBuffer.put(position, value); + } + + @Override + public void putByteArray(final byte[] src, final int n) { + final int srcSize = src == null ? 0 : src.length; + final int nElements = (n >= 0 ? Math.min(n, srcSize) : srcSize); + ensureAdditionalCapacity(nElements); + putInt(nElements); // strided-array size + if (src == null) { + return; + } + nioByteBuffer.put(src, 0, nElements); + } + + @Override + public void putChar(final char value) { + nioByteBuffer.putChar(value); + } + + @Override + public void putChar(final int position, final char value) { + nioByteBuffer.putChar(position, value); + } + + @Override + public void putCharArray(final char[] src, final int n) { + final int srcSize = src == null ? 0 : src.length; + final int nElements = n >= 0 ? Math.min(n, srcSize) : srcSize; + ensureAdditionalCapacity(nElements * SIZE_OF_CHAR); + putInt(nElements); + if (src == null) { + return; + } + for (int i = 0; i < nElements; i++) { + nioByteBuffer.putChar(src[i]); + } + } + + @Override + public void putDouble(final double value) { + nioByteBuffer.putDouble(value); + } + + @Override + public void putDouble(final int position, final double value) { + nioByteBuffer.putDouble(position, value); + } + + @Override + public void putDoubleArray(final double[] src, final int n) { + final int srcSize = src == null ? 0 : src.length; + final int nElements = n >= 0 ? Math.min(n, srcSize) : srcSize; + ensureAdditionalCapacity(nElements * SIZE_OF_DOUBLE); + putInt(nElements); + if (src == null) { + return; + } + for (int i = 0; i < nElements; i++) { + nioByteBuffer.putDouble(src[i]); + } + } + + @Override + public void putFloat(final float value) { + nioByteBuffer.putFloat(value); + } + + @Override + public void putFloat(final int position, final float value) { + nioByteBuffer.putFloat(position, value); + } + + @Override + public void putFloatArray(final float[] src, final int n) { + final int srcSize = src == null ? 0 : src.length; + final int nElements = n >= 0 ? Math.min(n, srcSize) : srcSize; + ensureAdditionalCapacity(nElements * SIZE_OF_FLOAT); + putInt(nElements); + if (src == null) { + return; + } + for (int i = 0; i < nElements; i++) { + nioByteBuffer.putFloat(src[i]); + } + } + + @Override + public void putInt(final int value) { + nioByteBuffer.putInt(value); + } + + @Override + public void putInt(final int position, final int value) { + nioByteBuffer.putInt(position, value); + } + + @Override + public void putIntArray(final int[] src, final int n) { + final int srcSize = src == null ? 0 : src.length; + final int nElements = n >= 0 ? Math.min(n, srcSize) : srcSize; + ensureAdditionalCapacity(nElements * SIZE_OF_INT); + putInt(nElements); + if (src == null) { + return; + } + for (int i = 0; i < nElements; i++) { + nioByteBuffer.putInt(src[i]); + } + } + + @Override + public void putLong(final long value) { + nioByteBuffer.putLong(value); + } + + @Override + public void putLong(final int position, final long value) { + nioByteBuffer.putLong(position, value); + } + + @Override + public void putLongArray(final long[] src, final int n) { + final int srcSize = src == null ? 0 : src.length; + final int nElements = n >= 0 ? Math.min(n, srcSize) : srcSize; + ensureAdditionalCapacity(nElements * SIZE_OF_LONG); + putInt(nElements); + if (src == null) { + return; + } + for (int i = 0; i < nElements; i++) { + nioByteBuffer.putLong(src[i]); + } + } + + @Override + public void putShort(final short value) { // NOPMD + nioByteBuffer.putShort(value); + } + + @Override + public void putShort(final int position, final short value) { + nioByteBuffer.putShort(position, value); + } + + @Override + public void putShortArray(final short[] src, final int n) { // NOPMD + final int srcSize = src == null ? 0 : src.length; + final int nElements = n >= 0 ? Math.min(n, srcSize) : srcSize; + ensureAdditionalCapacity(nElements * SIZE_OF_SHORT); + putInt(nElements); + if (src == null) { + return; + } + for (int i = 0; i < nElements; i++) { + nioByteBuffer.putShort(src[i]); + } + } + + @Override + public void putString(final String string) { + if (isEnforceSimpleStringEncoding()) { + this.putStringISO8859(string); + return; + } + + if (string == null) { + putInt(1); // for C++ zero terminated string$ + putByte((byte) 0); // For C++ zero terminated string + return; + } + + final byte[] bytes = string.getBytes(StandardCharsets.UTF_8); + putInt(bytes.length + 1); // for C++ zero terminated string$ + ensureAdditionalCapacity(bytes.length + 1); + nioByteBuffer.put(bytes, 0, bytes.length); + putByte((byte) 0); // For C++ zero terminated string + } + + @Override + public void putString(final int position, final String value) { + final int oldPosition = nioByteBuffer.position(); + nioByteBuffer.position(position); + putString(value); + nioByteBuffer.position(oldPosition); + } + + @Override + public void putStringArray(final String[] src, final int n) { + final int srcSize = src == null ? 0 : src.length; + final int nElements = n >= 0 ? Math.min(n, srcSize) : srcSize; + putInt(nElements); + if (src == null) { + return; + } + if (isEnforceSimpleStringEncoding()) { + for (int k = 0; k < nElements; k++) { + putStringISO8859(src[k]); + } + return; + } + for (int k = 0; k < nElements; k++) { + putString(src[k]); + } + } + + @Override + public void putStringISO8859(final String string) { + final int strLength = string == null ? 0 : string.length(); + putInt(strLength + 1); // for C++ zero terminated string$ + for (int i = 0; i < strLength; ++i) { + putByte((byte) (string.charAt(i) & 0xFF)); // ISO-8859-1 encoding + } + putByte((byte) 0); // For C++ zero terminated string + } + + @Override + public int remaining() { + return nioByteBuffer.remaining(); + } + + @Override + public void reset() { + nioByteBuffer.position(0); + nioByteBuffer.mark(); + nioByteBuffer.limit(nioByteBuffer.capacity()); + nioByteBuffer.reset(); + nioByteBuffer.mark(); + } + + @Override + public void trim() { + /* not implemented */ + } + + @Override + public void trim(final int requestedCapacity) { + /* not implemented */ + } +} diff --git a/serialiser/src/main/java/io/opencmw/serialiser/spi/ClassFieldDescription.java b/serialiser/src/main/java/io/opencmw/serialiser/spi/ClassFieldDescription.java new file mode 100644 index 00000000..d74b7887 --- /dev/null +++ b/serialiser/src/main/java/io/opencmw/serialiser/spi/ClassFieldDescription.java @@ -0,0 +1,862 @@ +package io.opencmw.serialiser.spi; + +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Modifier; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.nio.CharBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.opencmw.serialiser.DataType; +import io.opencmw.serialiser.FieldDescription; +import io.opencmw.serialiser.FieldSerialiser; +import io.opencmw.serialiser.annotations.Description; +import io.opencmw.serialiser.annotations.Direction; +import io.opencmw.serialiser.annotations.Groups; +import io.opencmw.serialiser.annotations.MetaInfo; +import io.opencmw.serialiser.annotations.Unit; +import io.opencmw.serialiser.utils.ClassUtils; + +import sun.misc.Unsafe; // NOPMD - there is nothing more suitable under the Sun + +/** + * @author rstein + */ +@SuppressWarnings({ "PMD.ExcessivePublicCount", "PMD.TooManyFields" }) // utility class for safe reflection handling +public class ClassFieldDescription implements FieldDescription { + private static final Logger LOGGER = LoggerFactory.getLogger(ClassFieldDescription.class); + private final int hierarchyDepth; + private final FieldAccess fieldAccess; + private final String fieldName; + private final int fieldNameHashCode; + private final String fieldNameRelative; + private final String fieldUnit; + private final String fieldDescription; + private final String fieldDirection; + private final List fieldGroups; + private final boolean annotationPresent; + private final ClassFieldDescription parent; + private final List children = new ArrayList<>(); + private final Class classType; + private final DataType dataType; + private final String typeName; + private final String typeNameSimple; + private final int modifierID; + private final boolean modPublic; + private final boolean modProtected; + private final boolean modPrivate; + // field modifier in canonical order + private final boolean modAbstract; + private final boolean modStatic; + private final boolean modFinal; + private final boolean modTransient; + private final boolean modVolatile; + private final boolean modSynchronized; + private final boolean modNative; + private final boolean modStrict; + private final boolean modInterface; + // additional qualities + private final boolean isPrimitiveType; + private final boolean isClassType; + private final boolean isEnumType; + private final List enumDefinitions; + private final boolean serializable; + private String toStringName; // computed on demand and cached + private Type genericType; // computed on demand and cached + private List genericTypeList; // computed on demand and cached + private List genericTypeNameList; // computed on demand and cached + private String genericTypeNames; // computed on demand and cached + private String genericTypeNamesSimple; // computed on demand and cached + private String modifierStr; // computed on demand and cached + // serialiser info + private FieldSerialiser fieldSerialiser; + + /** + * This should be called only once with the root class as an argument + * + * @param referenceClass the root node containing further Field children + * @param fullScan {@code true} if the class field should be serialised according to {@link java.io.Serializable} + * (ie. object's non-static and non-transient fields); {@code false} otherwise. + */ + public ClassFieldDescription(final Class referenceClass, final boolean fullScan) { + this(referenceClass, null, null, 0); + if (referenceClass == null) { + throw new IllegalArgumentException("object must not be null"); + } + + genericType = classType.getGenericSuperclass(); + + // parse object + exploreClass(classType, this, 0, fullScan); + } + + protected ClassFieldDescription(final Class referenceClass, final Field field, final ClassFieldDescription parent, final int recursionLevel) { + super(); + hierarchyDepth = recursionLevel; + this.parent = parent; + + if (referenceClass == null) { + if (field == null) { + throw new IllegalArgumentException("field must not be null"); + } + fieldAccess = new FieldAccess(field); + classType = field.getType(); + fieldNameHashCode = field.getName().hashCode(); + fieldName = field.getName().intern(); + fieldNameRelative = this.parent == null ? fieldName : (this.parent.getFieldNameRelative() + "." + fieldName).intern(); + + modifierID = field.getModifiers(); + dataType = DataType.fromClassType(classType); + } else { + fieldAccess = null; // it's a root, no field definition available + classType = referenceClass; + fieldNameHashCode = classType.getName().hashCode(); + fieldName = classType.getName().intern(); + fieldNameRelative = fieldName; + + modifierID = classType.getModifiers(); + dataType = DataType.START_MARKER; + } + + // read annotation values + AnnotatedElement annotatedElement = field == null ? referenceClass : field; + fieldUnit = getFieldUnit(annotatedElement); + fieldDescription = getFieldDescription(annotatedElement); + fieldDirection = getFieldDirection(annotatedElement); + fieldGroups = getFieldGroups(annotatedElement); + + annotationPresent = fieldUnit != null || fieldDescription != null || fieldDirection != null || !fieldGroups.isEmpty(); + + typeName = ClassUtils.translateClassName(classType.getTypeName()).intern(); + final int lastDot = typeName.lastIndexOf('.'); + typeNameSimple = typeName.substring(lastDot < 0 ? 0 : lastDot + 1); + + modPublic = Modifier.isPublic(modifierID); + modProtected = Modifier.isProtected(modifierID); + modPrivate = Modifier.isPrivate(modifierID); + + modAbstract = Modifier.isAbstract(modifierID); + modStatic = Modifier.isStatic(modifierID); + modFinal = Modifier.isFinal(modifierID); + modTransient = Modifier.isTransient(modifierID); + modVolatile = Modifier.isVolatile(modifierID); + modSynchronized = Modifier.isSynchronized(modifierID); + modNative = Modifier.isNative(modifierID); + modStrict = Modifier.isStrict(modifierID); + modInterface = classType.isInterface(); + + // additional fields + isPrimitiveType = classType.isPrimitive(); + isClassType = !isPrimitiveType && !modInterface; + isEnumType = Enum.class.isAssignableFrom(classType); + if (isEnumType) { + enumDefinitions = List.of(classType.getEnumConstants()); + } else { + enumDefinitions = Collections.emptyList(); + } + serializable = !modTransient && !modStatic; + } + + /** + * This should be called for individual class field members + * + * @param field Field reference for the given class member + * @param parent pointer to the root/parent reference class field description + * @param recursionLevel hierarchy level (i.e. '0' being the root class, '1' the sub-class etc. + * @param fullScan {@code true} if the class field should be serialised according to {@link java.io.Serializable} + * (ie. object's non-static and non-transient fields); {@code false} otherwise. + */ + public ClassFieldDescription(final Field field, final ClassFieldDescription parent, final int recursionLevel, + final boolean fullScan) { + this(null, field, parent, recursionLevel); + if (field == null) { + throw new IllegalArgumentException("field must not be null"); + } + + if (serializable) { + // enable access by default (saves performance later on) + field.setAccessible(true); // NOSONAR NOPMD + } + + // add child to parent if it serializable or if a full scan is requested + if (this.parent != null && (serializable || fullScan)) { + this.parent.getChildren().add(this); + } + } + + public Object allocateMemberClassField(final Object fieldParent) { + try { + // need to allocate new class object + Class fieldParentClass = ClassUtils.getRawType(getParent(this, 1).getType()); + final Object newFieldObj; + if (fieldParentClass.getDeclaringClass() == null) { + final Constructor constr = fieldParentClass.getDeclaredConstructor(); + newFieldObj = constr.newInstance(); + } else { + final Constructor constr = fieldParentClass.getDeclaredConstructor(fieldParent.getClass()); + newFieldObj = constr.newInstance(fieldParent); + } + this.getField().set(fieldParent, newFieldObj); + + return newFieldObj; + } catch (InstantiationException | InvocationTargetException | SecurityException | NoSuchMethodException | IllegalAccessException e) { + LOGGER.atError().setCause(e).log("error initialising inner class object"); + } + return null; + } + + @Override + public boolean equals(final Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof FieldDescription)) { + return false; + } + final FieldDescription other = (FieldDescription) obj; + if (this.getFieldNameHashCode() != other.getFieldNameHashCode()) { + return false; + } + + if (this.getDataType() != other.getDataType()) { + return false; + } + + return this.getFieldName().equals(other.getFieldName()); + } + + @Override + public FieldDescription findChildField(final String fieldName) { + return findChildField(fieldName.hashCode(), fieldName); + } + + @Override + public FieldDescription findChildField(final int fieldNameHashCode, final String fieldName) { + for (final FieldDescription child : children) { + final String name = child.getFieldName(); + if (name == fieldName) { //NOSONAR NOPMD early return if the same String object reference + return child; + } + if (child.getFieldNameHashCode() == fieldNameHashCode && name.equals(fieldName)) { + return child; + } + } + return null; + } + + /** + * @return generic type argument name of the class (e.g. for List<String> this would return + * 'java.lang.String') + */ + public List getActualTypeArgumentNames() { + if (genericTypeNameList == null) { + genericTypeNameList = getActualTypeArguments().stream().map(Type::getTypeName).collect(Collectors.toList()); + } + + return genericTypeNameList; + } + + /** + * @return generic type argument objects of the class (e.g. for List<String> this would return 'String.class') + */ + public List getActualTypeArguments() { + if (genericTypeList == null) { + genericTypeList = new ArrayList<>(); + if ((fieldAccess == null) || (getGenericType() == null) || !(getGenericType() instanceof ParameterizedType)) { + return genericTypeList; + } + genericTypeList.addAll(Arrays.asList(((ParameterizedType) getGenericType()).getActualTypeArguments())); + } + + return genericTypeList; + } + + /** + * @return the children (if any) from the super classes + */ + @Override + public List getChildren() { + return children; + } + + @Override + public int getDataSize() { + return 0; + } + + @Override + public int getDataStartOffset() { + return 0; + } + + @Override + public int getDataStartPosition() { + return 0; + } + + /** + * @return the DataType (if known) for the detected Field, {@link DataType#OTHER} in all other cases + */ + @Override + public DataType getDataType() { + return dataType; + } + + /** + * @return the underlying Field type or {@code null} if it's a root node + */ + public FieldAccess getField() { + return fieldAccess; + } + + @Override + public String getFieldDescription() { + return fieldDescription; + } + + @Override + public String getFieldDirection() { + return fieldDirection; + } + + @Override + public List getFieldGroups() { + return fieldGroups; + } + + /** + * @return the underlying field name + */ + @Override + public String getFieldName() { + return fieldName; + } + + @Override + public int getFieldNameHashCode() { + return fieldNameHashCode; + } + + /** + * @return relative field name within class hierarchy (ie. field_level0.field_level1.variable_0) + */ + public String getFieldNameRelative() { + return fieldNameRelative; + } + + public FieldSerialiser getFieldSerialiser() { + return fieldSerialiser; + } + + @Override + public int getFieldStart() { + throw new UnsupportedOperationException("not implemented"); + } + + @Override + public String getFieldUnit() { + return fieldUnit; + } + + /** + * @return field type strings (e.g. for the class Map<Integer,String> this returns + * '<java.lang.Integer,java.lang.String>' + */ + public String getGenericFieldTypeString() { + if (genericTypeNames == null) { + if (getActualTypeArgumentNames().isEmpty()) { + genericTypeNames = ""; + } else { + genericTypeNames = getActualTypeArgumentNames().stream().collect(Collectors.joining(", ", "<", ">")).intern(); + } + } + return genericTypeNames; + } + + /** + * @return field type strings (e.g. for the class Map<Integer,String> this returns + * '<java.lang.Integer,java.lang.String>' + */ + public String getGenericFieldTypeStringSimple() { + if (genericTypeNamesSimple == null) { + if (getActualTypeArgumentNames().isEmpty()) { + genericTypeNamesSimple = ""; + } else { + genericTypeNamesSimple = getActualTypeArguments().stream() // + .map(t -> ClassUtils.translateClassName(t.getTypeName())) + .collect(Collectors.joining(", ", "<", ">")) + .intern(); + } + } + return genericTypeNamesSimple; + } + + public Type getGenericType() { + if (genericType == null) { + genericType = fieldAccess == null ? new Type() { + @Override + public String getTypeName() { + return "unknown type"; + } + } : fieldAccess.field.getGenericType(); + } + return genericType; + } + + /** + * @return hierarchy level depth w.r.t. root object (ie. '0' being a variable in the root object) + */ + public int getHierarchyDepth() { + return hierarchyDepth; + } + + /** + * @return the modifierID + */ + public int getModifierID() { + return modifierID; + } + + /** + * @return the full modifier string (cached) + */ + public String getModifierString() { + if (modifierStr == null) { + // initialise only on a need to basis + // for performance reasons + modifierStr = Modifier.toString(modifierID).intern(); + } + return modifierStr; + } + + /** + * @return the parent + */ + @Override + public FieldDescription getParent() { + return parent; + } + + /** + * @param field class Field description for which + * @param hierarchyLevel the recursion level of the parent (e.g. '1' yields the immediate parent, '2' the parent of + * the parent etc.) + * @return the parent field reference description for the provided field + */ + public FieldDescription getParent(final FieldDescription field, final int hierarchyLevel) { + if (field == null) { + throw new IllegalArgumentException("field is null at hierarchyLevel = " + hierarchyLevel); + } + if ((hierarchyLevel == 0) || field.getParent() == null) { + return field; + } + return getParent(field.getParent(), hierarchyLevel - 1); + } + + /** + * @return field class type + */ + @Override + public Type getType() { + return classType; + } + + /** + * @return field class type name + */ + public String getTypeName() { + return typeName; + } + + /** + * @return field class type name + */ + public String getTypeNameSimple() { + return typeNameSimple; + } + + @Override + public int hashCode() { + return fieldNameHashCode; + } + + /** + * @return the isAbstract + */ + public boolean isAbstract() { + return modAbstract; + } + + @Override + public boolean isAnnotationPresent() { + return annotationPresent; + } + + /** + * @return the isClass + */ + public boolean isClass() { + return isClassType; + } + + /** + * @return whether class is an Enum type + */ + public boolean isEnum() { + return isEnumType; + } + + /** + * @return possible Enum definitions, see also 'isEnum()' + */ + public List getEnumConstants() { + return enumDefinitions; + } + + /** + * @return {@code true} if the class field includes the {@code final} modifier; {@code false} otherwise. + */ + public boolean isFinal() { + return modFinal; + } + + /** + * @return {@code true} if the class field is an interface + */ + public boolean isInterface() { + return modInterface; + } + + /** + * @return the isNative + */ + public boolean isNative() { + return modNative; + } + + /** + * @return {@code true} if the class field is a primitive type (ie. boolean, byte, ..., int, float, double) + */ + public boolean isPrimitive() { + return isPrimitiveType; + } + + /** + * @return {@code true} if the class field includes the {@code private} modifier; {@code false} otherwise. + */ + public boolean isPrivate() { + return modPrivate; + } + + /** + * @return the isProtected + */ + public boolean isProtected() { + return modProtected; + } + + /** + * @return {@code true} if the class field includes the {@code public} modifier; {@code false} otherwise. + */ + public boolean isPublic() { + return modPublic; + } + + /** + * @return the isRoot + */ + public boolean isRoot() { + return hierarchyDepth == 0; + } + + /** + * @return {@code true} if the class field should be serialised according to {@link java.io.Serializable} (ie. + * object's non-static and non-transient fields); {@code false} otherwise. + */ + public boolean isSerializable() { + return serializable; + } + + /** + * @return {@code true} if the class field includes the {@code static} modifier; {@code false} otherwise. + */ + public boolean isStatic() { + return modStatic; + } + + /** + * @return {@code true} if the class field includes the {@code strictfp} modifier; {@code false} otherwise. + */ + public boolean isStrict() { + return modStrict; + } + + /** + * @return {@code true} if the class field includes the {@code synchronized} modifier; {@code false} otherwise. + */ + public boolean isSynchronized() { + return modSynchronized; + } + + /** + * @return {@code true} if the class field includes the {@code transient} modifier; {@code false} otherwise. + */ + public boolean isTransient() { + return modTransient; + } + + /** + * @return {@code true} if the class field includes the {@code volatile} modifier; {@code false} otherwise. + */ + public boolean isVolatile() { + return modVolatile; + } + + @Override + public void printFieldStructure() { + printClassStructure(this, true, 0); + } + + public void setFieldSerialiser(final FieldSerialiser fieldSerialiser) { + this.fieldSerialiser = fieldSerialiser; + } + + @Override + public String toString() { + if (toStringName == null) { + toStringName = (ClassFieldDescription.class.getSimpleName() + " for: " + getModifierString() + " " + + getTypeName() + getGenericFieldTypeStringSimple() + " " + getFieldName() + " (hierarchyDepth = " + getHierarchyDepth() + ")") + .intern(); + } + return toStringName; + } + + protected static void exploreClass(final Class classType, final ClassFieldDescription parent, final int recursionLevel, final boolean fullScan) { // NOSONAR NOPMD + if (ClassUtils.DO_NOT_PARSE_MAP.get(classType) != null) { + return; + } + if (recursionLevel > ClassUtils.getMaxRecursionDepth()) { + throw new IllegalStateException("recursion error while scanning object structure: recursionLevel = '" + + recursionLevel + "' > " + ClassFieldDescription.class.getSimpleName() + ".maxRecursionLevel ='" + + ClassUtils.getMaxRecursionDepth() + "'"); + } + + // call super types + if ((classType.getSuperclass() != null) && !classType.getSuperclass().equals(Object.class) && !classType.getSuperclass().equals(Enum.class)) { + // dive into parent hierarchy w/o parsing Object.class, -> meaningless and causes infinite recursion + exploreClass(classType.getSuperclass(), parent, recursionLevel + 1, fullScan); + } + + // loop over member fields and inner classes + for (final Field pfield : classType.getDeclaredFields()) { + final FieldDescription localParent = parent.getParent(); + if ((localParent != null && pfield.getType().equals(localParent.getType()) && recursionLevel >= ClassUtils.getMaxRecursionDepth()) || pfield.getName().startsWith("this$")) { + // inner classes contain parent as part of declared fields + continue; + } + final ClassFieldDescription field = new ClassFieldDescription(pfield, parent, recursionLevel + 1, fullScan); // NOPMD + // N.B. unavoidable in-loop object generation + + // N.B. omitting field.isSerializable() (static or transient modifier) is essential + // as they often indicate class dependencies that are prone to infinite dependency loops + // (e.g. for classes with static references to themselves or maps-of-maps-of-maps-....) + final boolean isClassAndNotObjectOrEnmum = field.isClass() && (!field.getType().equals(Object.class) || !field.getType().equals(Enum.class)); + if (field.isSerializable() && (isClassAndNotObjectOrEnmum || field.isInterface()) && field.getDataType().equals(DataType.OTHER)) { + // object is a (technically) Serializable, unknown (ie 'OTHER) compound object or interface than can be further parsed + exploreClass(ClassUtils.getRawType(field.getType()), field, recursionLevel + 1, fullScan); + } + } + } + + protected static void printClassStructure(final ClassFieldDescription field, final boolean fullView, final int recursionLevel) { + final String enumOrClass = field.isEnum() ? "Enum " : "class "; + final String typeCategorgy = (field.isInterface() ? "interface " : (field.isPrimitive() ? "" : enumOrClass)); //NOSONAR //NOPMD + final String typeName = field.getTypeName() + field.getGenericFieldTypeString(); + final String mspace = spaces(recursionLevel * ClassUtils.getIndentationNumberOfSpace()); + final boolean isSerialisable = field.isSerializable(); + + if (isSerialisable || fullView) { + LOGGER.atInfo().addArgument(mspace).addArgument(isSerialisable ? " " : "//") // + .addArgument(field.getModifierString()) + .addArgument(typeCategorgy) + .addArgument(typeName) + .addArgument(field.getFieldName()) + .log("{} {} {} {}{} {}"); + if (field.isAnnotationPresent()) { + LOGGER.atInfo().addArgument(mspace).addArgument(isSerialisable ? " " : "//") // + .addArgument(field.getFieldUnit()) + .addArgument(field.getFieldDescription()) + .addArgument(field.getFieldDirection()) + .addArgument(field.getFieldGroups()) + .log("{} {} "); + } + + field.getChildren().forEach(f -> printClassStructure((ClassFieldDescription) f, fullView, recursionLevel + 1)); + } + } + + private static String getFieldDescription(final AnnotatedElement annotatedElement) { + final MetaInfo[] annotationMeta = annotatedElement.getAnnotationsByType(MetaInfo.class); + if (annotationMeta != null && annotationMeta.length > 0) { + return annotationMeta[0].description().intern(); + } + final Description[] annotationDescription = annotatedElement.getAnnotationsByType(Description.class); + if (annotationDescription != null && annotationDescription.length > 0) { + return annotationDescription[0].value().intern(); + } + return null; + } + + private static String getFieldDirection(final AnnotatedElement annotatedElement) { + final MetaInfo[] annotationMeta = annotatedElement.getAnnotationsByType(MetaInfo.class); + if (annotationMeta != null && annotationMeta.length > 0) { + return annotationMeta[0].direction().intern(); + } + final Direction[] annotationDirection = annotatedElement.getAnnotationsByType(Direction.class); + if (annotationDirection != null && annotationDirection.length > 0) { + return annotationDirection[0].value().intern(); + } + return null; + } + + private static List getFieldGroups(final AnnotatedElement annotatedElement) { + final MetaInfo[] annotationMeta = annotatedElement.getAnnotationsByType(MetaInfo.class); + if (annotationMeta != null && annotationMeta.length > 0) { + return Arrays.asList(annotationMeta[0].groups()); + } + final Groups[] annotationGroups = annotatedElement.getAnnotationsByType(Groups.class); + if (annotationGroups != null && annotationGroups.length > 0) { + final List ret = new ArrayList<>(annotationGroups[0].value().length); + for (int i = 0; i < annotationGroups[0].value().length; i++) { + ret.add(annotationGroups[0].value()[i].intern()); + } + return ret; + } + return Collections.emptyList(); + } + + private static String getFieldUnit(final AnnotatedElement annotatedElement) { + final MetaInfo[] annotationMeta = annotatedElement.getAnnotationsByType(MetaInfo.class); + if (annotationMeta != null && annotationMeta.length > 0) { + return annotationMeta[0].unit().intern(); + } + final Unit[] annotationUnit = annotatedElement.getAnnotationsByType(Unit.class); + if (annotationUnit != null && annotationUnit.length > 0) { + return annotationUnit[0].value().intern(); + } + return null; + } + + private static String spaces(final int spaces) { + return CharBuffer.allocate(spaces).toString().replace('\0', ' '); + } + + public static class FieldAccess { + private static final Unsafe unsafe; // NOPMD + + static { + // get an instance of the otherwise private 'Unsafe' class + try { + final Field field = Unsafe.class.getDeclaredField("theUnsafe"); + field.setAccessible(true); // NOSONAR NOPMD + unsafe = (Unsafe) field.get(null); + } catch (NoSuchFieldException | SecurityException | IllegalAccessException e) { // NOPMD + throw new SecurityException(e); // NOPMD + } + } + + private final Field field; + private final long fieldByteOffset; + + private FieldAccess(final Field field) { + this.field = field; + field.setAccessible(true); //NOSONAR + + long offset = -1; + try { + offset = unsafe.objectFieldOffset(field); + } catch (IllegalArgumentException e) { + // fails for private static final fields + } + this.fieldByteOffset = offset; + } + + public Object get(final Object classReference) { + return unsafe.getObject(classReference, fieldByteOffset); + } + + public boolean getBoolean(final Object classReference) { + return unsafe.getBoolean(classReference, fieldByteOffset); + } + + public byte getByte(final Object classReference) { + return unsafe.getByte(classReference, fieldByteOffset); + } + + public char getChar(final Object classReference) { + return unsafe.getChar(classReference, fieldByteOffset); + } + + public double getDouble(final Object classReference) { + return unsafe.getDouble(classReference, fieldByteOffset); + } + + public Field getField() { + return field; + } + public float getFloat(final Object classReference) { + return unsafe.getFloat(classReference, fieldByteOffset); + } + public int getInt(final Object classReference) { + return unsafe.getInt(classReference, fieldByteOffset); + } + public long getLong(final Object classReference) { + return unsafe.getLong(classReference, fieldByteOffset); + } + public short getShort(final Object classReference) { // NOPMD + return unsafe.getShort(classReference, fieldByteOffset); + } + public void set(final Object classReference, final Object obj) { + unsafe.putObject(classReference, fieldByteOffset, obj); + } + public void setBoolean(final Object classReference, final boolean value) { + unsafe.putBoolean(classReference, fieldByteOffset, value); + } + public void setByte(final Object classReference, final byte value) { + unsafe.putByte(classReference, fieldByteOffset, value); + } + public void setChar(final Object classReference, final char value) { + unsafe.putChar(classReference, fieldByteOffset, value); + } + + public void setDouble(final Object classReference, final double value) { + unsafe.putDouble(classReference, fieldByteOffset, value); + } + + public void setFloat(final Object classReference, final float value) { + unsafe.putFloat(classReference, fieldByteOffset, value); + } + + public void setInt(final Object classReference, final int value) { + unsafe.putInt(classReference, fieldByteOffset, value); + } + + public void setLong(final Object classReference, final long value) { + unsafe.putLong(classReference, fieldByteOffset, value); + } + + public void setShort(final Object classReference, final short value) { // NOPMD + unsafe.putShort(classReference, fieldByteOffset, value); + } + } +} diff --git a/serialiser/src/main/java/io/opencmw/serialiser/spi/CmwLightSerialiser.java b/serialiser/src/main/java/io/opencmw/serialiser/spi/CmwLightSerialiser.java new file mode 100644 index 00000000..58400e97 --- /dev/null +++ b/serialiser/src/main/java/io/opencmw/serialiser/spi/CmwLightSerialiser.java @@ -0,0 +1,1135 @@ +package io.opencmw.serialiser.spi; + +import java.lang.reflect.Method; +import java.lang.reflect.Type; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.Set; +import java.util.function.BiFunction; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.opencmw.serialiser.DataType; +import io.opencmw.serialiser.FieldDescription; +import io.opencmw.serialiser.FieldSerialiser; +import io.opencmw.serialiser.IoBuffer; +import io.opencmw.serialiser.IoSerialiser; +import io.opencmw.serialiser.utils.ClassUtils; + +/** + * Light-weight open-source implementation of a (de-)serialiser that is binary-compatible to the serialiser used by CMW, + * a proprietary closed-source middle-ware used in some accelerator laboratories. + * + * N.B. this implementation is intended only for performance/functionality comparison and to enable a backward compatible + * transition to the {@link BinarySerialiser} implementation which is a bit more flexible, + * has some additional (optional) features, and a better IO performance. See the corresponding benchmarks for details; + * + * @author rstein + */ +@SuppressWarnings({ "PMD.ExcessiveClassLength", "PMD.ExcessivePublicCount", "PMD.TooManyMethods" }) +public class CmwLightSerialiser implements IoSerialiser { + public static final String NOT_IMPLEMENTED = "not implemented"; + private static final Logger LOGGER = LoggerFactory.getLogger(CmwLightSerialiser.class); + private static final int ADDITIONAL_HEADER_INFO_SIZE = 1000; + private static final DataType[] BYTE_TO_DATA_TYPE = new DataType[256]; + private static final Byte[] DATA_TYPE_TO_BYTE = new Byte[256]; + + static { + // static mapping of protocol bytes -- needed to be compatible with other wire protocols + // N.B. CmwLightSerialiser does not implement mappings for: + // * discreteFunction + // * discreteFunction_list + // * array of Data objects (N.B. 'Data' and nested 'Data' is being explicitely supported) + // 'Data' object is mapped to START_MARKER also used for nested data structures + + BYTE_TO_DATA_TYPE[0] = DataType.BOOL; + BYTE_TO_DATA_TYPE[1] = DataType.BYTE; + BYTE_TO_DATA_TYPE[2] = DataType.SHORT; + BYTE_TO_DATA_TYPE[3] = DataType.INT; + BYTE_TO_DATA_TYPE[4] = DataType.LONG; + BYTE_TO_DATA_TYPE[5] = DataType.FLOAT; + BYTE_TO_DATA_TYPE[6] = DataType.DOUBLE; + BYTE_TO_DATA_TYPE[201] = DataType.CHAR; // not actually implemented by CMW + BYTE_TO_DATA_TYPE[7] = DataType.STRING; + BYTE_TO_DATA_TYPE[8] = DataType.START_MARKER; // mapped to CMW 'Data' type + + // needs to be defined last + BYTE_TO_DATA_TYPE[9] = DataType.BOOL_ARRAY; + BYTE_TO_DATA_TYPE[10] = DataType.BYTE_ARRAY; + BYTE_TO_DATA_TYPE[11] = DataType.SHORT_ARRAY; + BYTE_TO_DATA_TYPE[12] = DataType.INT_ARRAY; + BYTE_TO_DATA_TYPE[13] = DataType.LONG_ARRAY; + BYTE_TO_DATA_TYPE[14] = DataType.FLOAT_ARRAY; + BYTE_TO_DATA_TYPE[15] = DataType.DOUBLE_ARRAY; + BYTE_TO_DATA_TYPE[202] = DataType.CHAR_ARRAY; // not actually implemented by CMW + BYTE_TO_DATA_TYPE[16] = DataType.STRING_ARRAY; + + // CMW 2D arrays -- also mapped internally to byte arrays + BYTE_TO_DATA_TYPE[17] = DataType.BOOL_ARRAY; + BYTE_TO_DATA_TYPE[18] = DataType.BYTE_ARRAY; + BYTE_TO_DATA_TYPE[19] = DataType.SHORT_ARRAY; + BYTE_TO_DATA_TYPE[20] = DataType.INT_ARRAY; + BYTE_TO_DATA_TYPE[21] = DataType.LONG_ARRAY; + BYTE_TO_DATA_TYPE[22] = DataType.FLOAT_ARRAY; + BYTE_TO_DATA_TYPE[23] = DataType.DOUBLE_ARRAY; + BYTE_TO_DATA_TYPE[203] = DataType.CHAR_ARRAY; // not actually implemented by CMW + BYTE_TO_DATA_TYPE[24] = DataType.STRING_ARRAY; + + // CMW multi-dim arrays -- also mapped internally to byte arrays + BYTE_TO_DATA_TYPE[25] = DataType.BOOL_ARRAY; + BYTE_TO_DATA_TYPE[26] = DataType.BYTE_ARRAY; + BYTE_TO_DATA_TYPE[27] = DataType.SHORT_ARRAY; + BYTE_TO_DATA_TYPE[28] = DataType.INT_ARRAY; + BYTE_TO_DATA_TYPE[29] = DataType.LONG_ARRAY; + BYTE_TO_DATA_TYPE[30] = DataType.FLOAT_ARRAY; + BYTE_TO_DATA_TYPE[31] = DataType.DOUBLE_ARRAY; + BYTE_TO_DATA_TYPE[204] = DataType.CHAR_ARRAY; // not actually implemented by CMW + BYTE_TO_DATA_TYPE[32] = DataType.STRING_ARRAY; + + for (int i = BYTE_TO_DATA_TYPE.length - 1; i >= 0; i--) { + if (BYTE_TO_DATA_TYPE[i] == null) { + continue; + } + final int id = BYTE_TO_DATA_TYPE[i].getID(); + DATA_TYPE_TO_BYTE[id] = (byte) i; + } + } + + private IoBuffer buffer; + private WireDataFieldDescription parent; + private WireDataFieldDescription lastFieldHeader; + private BiFunction> fieldSerialiserLookupFunction; + + public CmwLightSerialiser(final IoBuffer buffer) { + super(); + this.buffer = buffer; + } + + @Override + public ProtocolInfo checkHeaderInfo() { + final String fieldName = ""; + final int dataSize = FastByteBuffer.SIZE_OF_INT; + final WireDataFieldDescription headerStartField = new WireDataFieldDescription(this, parent, fieldName.hashCode(), fieldName, DataType.START_MARKER, buffer.position(), buffer.position(), dataSize); // NOPMD - needs to be read here + final int nEntries = buffer.getInt(); + if (nEntries <= 0) { + throw new IllegalStateException("nEntries = " + nEntries + " <= 0!"); + } + parent = lastFieldHeader = headerStartField; + return new ProtocolInfo(this, headerStartField, CmwLightSerialiser.class.getCanonicalName(), (byte) 1, (byte) 0, (byte) 1); + } + + @Override + public int[] getArraySizeDescriptor() { + final int nDims = buffer.getInt(); // number of dimensions + final int[] ret = new int[nDims]; + for (int i = 0; i < nDims; i++) { + ret[i] = buffer.getInt(); // vector size for each dimension + } + return ret; + } + + @Override + public boolean getBoolean() { + return buffer.getBoolean(); + } + + @Override + public boolean[] getBooleanArray(final boolean[] dst, final int length) { + getArraySizeDescriptor(); + return buffer.getBooleanArray(dst, length); + } + + @Override + public IoBuffer getBuffer() { + return buffer; + } + + @Override + public void setBuffer(final IoBuffer buffer) { + this.buffer = buffer; + } + + public int getBufferIncrements() { + return ADDITIONAL_HEADER_INFO_SIZE; + } + + @Override + public byte getByte() { + return buffer.getByte(); + } + + @Override + public byte[] getByteArray(final byte[] dst, final int length) { + getArraySizeDescriptor(); + return buffer.getByteArray(dst, length); + } + + @Override + public char getChar() { + return buffer.getChar(); + } + + @Override + public char[] getCharArray(final char[] dst, final int length) { + getArraySizeDescriptor(); + return buffer.getCharArray(dst, length); + } + + @Override + public Collection getCollection(final Collection collection) { + throw new UnsupportedOperationException(NOT_IMPLEMENTED); + } + + @Override + public E getCustomData(final FieldSerialiser serialiser) { + throw new UnsupportedOperationException(NOT_IMPLEMENTED); + } + + @Override + public double getDouble() { + return buffer.getDouble(); + } + + @Override + public double[] getDoubleArray(final double[] dst, final int length) { + getArraySizeDescriptor(); + return buffer.getDoubleArray(dst, length); + } + + @Override + public > Enum getEnum(final Enum enumeration) { + final int ordinal = buffer.getInt(); + assert ordinal >= 0 : "enum ordinal should be positive"; + + final String enumName = enumeration.getClass().getName(); + Class enumClass = ClassUtils.getClassByName(enumName); + if (enumClass == null) { + final String enumSimpleName = enumeration.getClass().getSimpleName(); + enumClass = ClassUtils.getClassByName(enumSimpleName); + if (enumClass == null) { + throw new IllegalStateException("could not find enum class description '" + enumName + "' or '" + enumSimpleName + "'"); + } + } + + try { + final Method values = enumClass.getMethod("values"); + final Object[] possibleEnumValues = (Object[]) values.invoke(null); + //noinspection unchecked + return (Enum) possibleEnumValues[ordinal]; // NOSONAR NOPMD + } catch (final ReflectiveOperationException e) { + LOGGER.atError().setCause(e).addArgument(enumClass).log("could not match 'valueOf(String)' function for class/(supposedly) enum of {}"); + } + throw new UnsupportedOperationException(NOT_IMPLEMENTED); + } + + @Override + public String getEnumTypeList() { + throw new UnsupportedOperationException(NOT_IMPLEMENTED); + } + + @Override + public WireDataFieldDescription getFieldHeader() { + // process CMW-like wire-format + final int headerStart = buffer.position(); // NOPMD - need to read the present buffer position + + final String fieldName = buffer.getStringISO8859(); // NOPMD - read advances position + final byte dataTypeByte = buffer.getByte(); + final DataType dataType = getDataType(dataTypeByte); + // process CMW-like wire-format - done + + final int dataStartOffset = buffer.position() - headerStart; // NOPMD - further reads advance the read position in the buffer + final int dataSize; + if (dataType == DataType.START_MARKER) { + dataSize = FastByteBuffer.SIZE_OF_INT; + } else if (dataType.isScalar()) { + dataSize = dataType.getPrimitiveSize(); + } else if (dataType == DataType.STRING) { + dataSize = FastByteBuffer.SIZE_OF_INT + buffer.getInt(); // <(>string size -1> + + } else if (dataType.isArray() && dataType != DataType.STRING_ARRAY) { + // read array descriptor + final int[] dims = getArraySizeDescriptor(); + final int arraySize = buffer.getInt(); // strided array size + dataSize = FastByteBuffer.SIZE_OF_INT * (dims.length + 2) + arraySize * dataType.getPrimitiveSize(); // + + } else if (dataType == DataType.STRING_ARRAY) { + // read array descriptor -- this case has a high-penalty since the size of all Strings needs to be read + final int[] dims = getArraySizeDescriptor(); + final int arraySize = buffer.getInt(); // strided array size + // String parsing, need to follow every single element + int totalSize = FastByteBuffer.SIZE_OF_INT * arraySize; + for (int i = 0; i < arraySize; i++) { + final int stringSize = buffer.getInt(); // <(>string size -1> + + totalSize += stringSize; + buffer.position(buffer.position() + stringSize); + } + dataSize = FastByteBuffer.SIZE_OF_INT * (dims.length + 2) + totalSize; + } else { + throw new IllegalStateException("should not reach here -- format is incompatible with CMW"); + } + + final int fieldNameHashCode = fieldName.hashCode(); //TODO: verify same hashcode function + + lastFieldHeader = new WireDataFieldDescription(this, parent, fieldNameHashCode, fieldName, dataType, headerStart, dataStartOffset, dataSize); + final int dataStartPosition = headerStart + dataStartOffset; + buffer.position(dataStartPosition); + + if (dataType == DataType.START_MARKER) { + parent = lastFieldHeader; + buffer.position(dataStartPosition); + buffer.position(dataStartPosition + dataSize); + } + + if (dataSize < 0) { + throw new IllegalStateException("should not reach here -- format is incompatible with CMW"); + } + return lastFieldHeader; + } + + @Override + public float getFloat() { + return buffer.getFloat(); + } + + @Override + public float[] getFloatArray(final float[] dst, final int length) { + getArraySizeDescriptor(); + return buffer.getFloatArray(dst, length); + } + + @Override + public int getInt() { + return buffer.getInt(); + } + + @Override + public int[] getIntArray(final int[] dst, final int length) { + getArraySizeDescriptor(); + return buffer.getIntArray(dst, length); + } + + @Override + public List getList(final List collection) { + throw new UnsupportedOperationException(NOT_IMPLEMENTED); + } + + @Override + public long getLong() { + return buffer.getLong(); + } + + @Override + public long[] getLongArray(final long[] dst, final int length) { + getArraySizeDescriptor(); + return buffer.getLongArray(dst, length); + } + + @Override + public Map getMap(final Map map) { + throw new UnsupportedOperationException(NOT_IMPLEMENTED); + } + + public WireDataFieldDescription getParent() { + return parent; + } + + @Override + public Queue getQueue(final Queue collection) { + throw new UnsupportedOperationException(NOT_IMPLEMENTED); + } + + @Override + public Set getSet(final Set collection) { + throw new UnsupportedOperationException(NOT_IMPLEMENTED); + } + + @Override + public short getShort() { + return buffer.getShort(); + } + + @Override + public short[] getShortArray(final short[] dst, final int length) { + getArraySizeDescriptor(); + return buffer.getShortArray(dst, length); + } + + @Override + public String getString() { + return buffer.getString(); + } + + @Override + public String[] getStringArray(final String[] dst, final int length) { + getArraySizeDescriptor(); + return buffer.getStringArray(dst, length); + } + + @Override + public String getStringISO8859() { + return buffer.getStringISO8859(); + } + + /** + * @return {@code true} the ISO-8859-1 character encoding is being enforced for data fields (better performance), otherwise UTF-8 is being used (more generic encoding) + */ + public boolean isEnforceSimpleStringEncoding() { + return buffer.isEnforceSimpleStringEncoding(); + } + + /** + * + * @param state {@code true} the ISO-8859-1 character encoding is being enforced for data fields (better performance), otherwise UTF-8 is being used (more generic encoding) + */ + public void setEnforceSimpleStringEncoding(final boolean state) { + buffer.setEnforceSimpleStringEncoding(state); + } + + @Override + public boolean isPutFieldMetaData() { + return false; + } + + @Override + public void setPutFieldMetaData(final boolean putFieldMetaData) { + // do nothing -- not implemented for this serialiser + } + + @Override + public WireDataFieldDescription parseIoStream(final boolean readHeader) { + final WireDataFieldDescription fieldRoot = getRootElement(); + parent = fieldRoot; + final WireDataFieldDescription headerRoot = readHeader ? checkHeaderInfo().getFieldHeader() : getFieldHeader(); + buffer.position(headerRoot.getDataStartPosition() + headerRoot.getDataSize()); + parseIoStream(headerRoot, 0); + return fieldRoot; + } + + public void parseIoStream(final WireDataFieldDescription fieldRoot, final int recursionDepth) { + if (fieldRoot == null || fieldRoot.getDataType() != DataType.START_MARKER) { + throw new IllegalStateException("fieldRoot not a START_MARKER but: " + fieldRoot); + } + buffer.position(fieldRoot.getDataStartPosition()); + final int nEntries = buffer.getInt(); + if (nEntries < 0) { + throw new IllegalStateException("nEntries = " + nEntries + " < 0!"); + } + parent = lastFieldHeader = fieldRoot; + for (int i = 0; i < nEntries; i++) { + final WireDataFieldDescription field = getFieldHeader(); // NOPMD - need to read the present buffer position + final int dataSize = field.getDataSize(); + final int skipPosition = field.getDataStartPosition() + dataSize; // NOPMD - read at this location necessary, further reads advance position pointer + + if (field.getDataType() == DataType.START_MARKER) { + // detected sub-class start marker + parent = lastFieldHeader = field; + parseIoStream(field, recursionDepth + 1); + parent = lastFieldHeader = fieldRoot; + continue; + } + + if (dataSize < 0) { + LOGGER.atWarn().addArgument(field.getFieldName()).addArgument(field.getDataType()).addArgument(dataSize).log("WireDataFieldDescription for '{}' type '{}' has bytesToSkip '{} <= 0'"); + // fall-back option in case of undefined dataSetSize -- usually indicated an internal serialiser error + throw new IllegalStateException(); + } + + if (skipPosition < buffer.capacity()) { + buffer.position(skipPosition); + } else { + // reached end of buffer + if (skipPosition == buffer.capacity()) { + return; + } + throw new IllegalStateException("reached beyond end of buffer at " + skipPosition + " vs. capacity" + buffer.capacity() + " " + field); + } + } + } + + @Override + public void put(final FieldDescription fieldDescription, final Collection collection, final Type valueType) { + throw new UnsupportedOperationException(NOT_IMPLEMENTED); + } + + @Override + public void put(final FieldDescription fieldDescription, final Enum enumeration) { + this.putFieldHeader(fieldDescription, DataType.INT); + buffer.putInt(enumeration.ordinal()); + } + + @Override + public void put(final FieldDescription fieldDescription, final Map map, Type keyType, Type valueType) { + throw new UnsupportedOperationException(NOT_IMPLEMENTED); + } + + @Override + public void put(final FieldDescription fieldDescription, final boolean value) { + this.putFieldHeader(fieldDescription, DataType.BOOL); + buffer.putBoolean(value); + } + + @Override + public void put(final FieldDescription fieldDescription, final boolean[] values, final int n) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription, DataType.BOOL_ARRAY); + final int valuesSize = values == null ? 0 : values.length; + final int bytesToCopy = putArraySizeDescriptor(n >= 0 ? Math.min(n, valuesSize) : valuesSize); + buffer.putBooleanArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final FieldDescription fieldDescription, final boolean[] values, final int[] dims) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription, DataType.BOOL_ARRAY); + final int bytesToCopy = putArraySizeDescriptor(dims); + buffer.putBooleanArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final FieldDescription fieldDescription, final byte value) { + this.putFieldHeader(fieldDescription, DataType.BYTE); + buffer.putByte(value); + } + + @Override + public void put(final FieldDescription fieldDescription, final byte[] values, final int n) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription, DataType.BYTE_ARRAY); + final int valuesSize = values == null ? 0 : values.length; + final int bytesToCopy = putArraySizeDescriptor(n >= 0 ? Math.min(n, valuesSize) : valuesSize); + buffer.putByteArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final FieldDescription fieldDescription, final byte[] values, final int[] dims) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription, DataType.BYTE_ARRAY); + final int bytesToCopy = putArraySizeDescriptor(dims); + buffer.putByteArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final FieldDescription fieldDescription, final char value) { + this.putFieldHeader(fieldDescription, DataType.CHAR); + buffer.putChar(value); + } + + @Override + public void put(final FieldDescription fieldDescription, final char[] values, final int n) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription, DataType.CHAR_ARRAY); + final int valuesSize = values == null ? 0 : values.length; + final int bytesToCopy = putArraySizeDescriptor(n >= 0 ? Math.min(n, valuesSize) : valuesSize); + buffer.putCharArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final FieldDescription fieldDescription, final char[] values, final int[] dims) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription, DataType.CHAR_ARRAY); + final int bytesToCopy = putArraySizeDescriptor(dims); + buffer.putCharArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final FieldDescription fieldDescription, final double value) { + this.putFieldHeader(fieldDescription, DataType.DOUBLE); + buffer.putDouble(value); + } + + @Override + public void put(final FieldDescription fieldDescription, final double[] values, final int n) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription, DataType.DOUBLE_ARRAY); + final int valuesSize = values == null ? 0 : values.length; + final int bytesToCopy = putArraySizeDescriptor(n >= 0 ? Math.min(n, valuesSize) : valuesSize); + buffer.putDoubleArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final FieldDescription fieldDescription, final double[] values, final int[] dims) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription, DataType.DOUBLE_ARRAY); + final int bytesToCopy = putArraySizeDescriptor(dims); + buffer.putDoubleArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final FieldDescription fieldDescription, final float value) { + this.putFieldHeader(fieldDescription, DataType.FLOAT); + buffer.putFloat(value); + } + + @Override + public void put(final FieldDescription fieldDescription, final float[] values, final int n) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription, DataType.FLOAT_ARRAY); + final int valuesSize = values == null ? 0 : values.length; + final int bytesToCopy = putArraySizeDescriptor(n >= 0 ? Math.min(n, valuesSize) : valuesSize); + buffer.putFloatArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final FieldDescription fieldDescription, final float[] values, final int[] dims) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription, DataType.FLOAT_ARRAY); + final int bytesToCopy = putArraySizeDescriptor(dims); + buffer.putFloatArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final FieldDescription fieldDescription, final int value) { + this.putFieldHeader(fieldDescription, DataType.INT); + buffer.putInt(value); + } + + @Override + public void put(final FieldDescription fieldDescription, final int[] values, final int n) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription, DataType.INT_ARRAY); + final int valuesSize = values == null ? 0 : values.length; + final int bytesToCopy = putArraySizeDescriptor(n >= 0 ? Math.min(n, valuesSize) : valuesSize); + buffer.putIntArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final FieldDescription fieldDescription, final int[] values, final int[] dims) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription, DataType.INT_ARRAY); + final int bytesToCopy = putArraySizeDescriptor(dims); + buffer.putIntArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final FieldDescription fieldDescription, final long value) { + this.putFieldHeader(fieldDescription, DataType.LONG); + buffer.putLong(value); + } + + @Override + public void put(final FieldDescription fieldDescription, final long[] values, final int n) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription, DataType.LONG_ARRAY); + final int valuesSize = values == null ? 0 : values.length; + final int bytesToCopy = putArraySizeDescriptor(n >= 0 ? Math.min(n, valuesSize) : valuesSize); + buffer.putLongArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final FieldDescription fieldDescription, final long[] values, final int[] dims) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription, DataType.LONG_ARRAY); + final int bytesToCopy = putArraySizeDescriptor(dims); + buffer.putLongArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final FieldDescription fieldDescription, final short value) { // NOPMD by rstein + this.putFieldHeader(fieldDescription, DataType.SHORT); + buffer.putShort(value); + } + + @Override + public void put(final FieldDescription fieldDescription, final short[] values, final int n) { // NOPMD by rstein + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription, DataType.SHORT_ARRAY); + final int valuesSize = values == null ? 0 : values.length; + final int bytesToCopy = putArraySizeDescriptor(n >= 0 ? Math.min(n, valuesSize) : valuesSize); + buffer.putShortArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final FieldDescription fieldDescription, final short[] values, final int[] dims) { // NOPMD by rstein + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription, DataType.SHORT_ARRAY); + final int bytesToCopy = putArraySizeDescriptor(dims); + buffer.putShortArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final FieldDescription fieldDescription, final String string) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription, DataType.STRING); + buffer.putString(string); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final FieldDescription fieldDescription, final String[] values, final int n) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription, DataType.STRING_ARRAY); + final int valuesSize = values == null ? 0 : values.length; + final int nElements = n >= 0 ? Math.min(n, valuesSize) : valuesSize; + putArraySizeDescriptor(nElements); + buffer.putStringArray(values, nElements); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final FieldDescription fieldDescription, final String[] values, final int[] dims) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldDescription, DataType.STRING_ARRAY); + final int nElements = putArraySizeDescriptor(dims); + putArraySizeDescriptor(nElements); + buffer.putStringArray(values, nElements); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final boolean value) { + this.putFieldHeader(fieldName, DataType.BOOL); + buffer.putBoolean(value); + } + + @Override + public void put(final String fieldName, final boolean[] values, final int n) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.BOOL_ARRAY); + final int valuesSize = values == null ? 0 : values.length; + final int bytesToCopy = putArraySizeDescriptor(n >= 0 ? Math.min(n, valuesSize) : valuesSize); + buffer.putBooleanArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final boolean[] values, final int[] dims) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.BOOL_ARRAY); + if (dims.length == 2) { + buffer.putByte(fieldHeader.getDataStartPosition() - 1, (byte) 17); + } else if (dims.length > 2) { + buffer.putByte(fieldHeader.getDataStartPosition() - 1, (byte) 25); + } + final int bytesToCopy = putArraySizeDescriptor(dims); + buffer.putBooleanArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final byte value) { + this.putFieldHeader(fieldName, DataType.BYTE); + buffer.putByte(value); + } + + @Override + public void put(final String fieldName, final byte[] values, final int n) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.BYTE_ARRAY); + final int valuesSize = values == null ? 0 : values.length; + final int bytesToCopy = putArraySizeDescriptor(n >= 0 ? Math.min(n, valuesSize) : valuesSize); + buffer.putByteArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final byte[] values, final int[] dims) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.BYTE_ARRAY); + if (dims.length == 2) { + buffer.putByte(fieldHeader.getDataStartPosition() - 1, (byte) 18); + } else if (dims.length > 2) { + buffer.putByte(fieldHeader.getDataStartPosition() - 1, (byte) 26); + } + final int bytesToCopy = putArraySizeDescriptor(dims); + buffer.putByteArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final char value) { + this.putFieldHeader(fieldName, DataType.CHAR); + buffer.putChar(value); + } + + @Override + public void put(final String fieldName, final char[] values, final int n) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.CHAR_ARRAY); + final int valuesSize = values == null ? 0 : values.length; + final int bytesToCopy = putArraySizeDescriptor(n >= 0 ? Math.min(n, valuesSize) : valuesSize); + buffer.putCharArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final char[] values, final int[] dims) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.CHAR_ARRAY); + if (dims.length == 2) { + buffer.putByte(fieldHeader.getDataStartPosition() - 1, (byte) 203); + } else if (dims.length > 2) { + buffer.putByte(fieldHeader.getDataStartPosition() - 1, (byte) 204); + } + final int bytesToCopy = putArraySizeDescriptor(dims); + buffer.putCharArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final double value) { + this.putFieldHeader(fieldName, DataType.DOUBLE); + buffer.putDouble(value); + } + + @Override + public void put(final String fieldName, final double[] values, final int n) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.DOUBLE_ARRAY); + final int valuesSize = values == null ? 0 : values.length; + final int bytesToCopy = putArraySizeDescriptor(n >= 0 ? Math.min(n, valuesSize) : valuesSize); + buffer.putDoubleArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final double[] values, final int[] dims) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.DOUBLE_ARRAY); + if (dims.length == 2) { + buffer.putByte(fieldHeader.getDataStartPosition() - 1, (byte) 23); + } else if (dims.length > 2) { + buffer.putByte(fieldHeader.getDataStartPosition() - 1, (byte) 31); + } + final int bytesToCopy = putArraySizeDescriptor(dims); + buffer.putDoubleArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final float value) { + this.putFieldHeader(fieldName, DataType.FLOAT); + buffer.putFloat(value); + } + + @Override + public void put(final String fieldName, final float[] values, final int n) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.FLOAT_ARRAY); + final int valuesSize = values == null ? 0 : values.length; + final int bytesToCopy = putArraySizeDescriptor(n >= 0 ? Math.min(n, valuesSize) : valuesSize); + buffer.putFloatArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final float[] values, final int[] dims) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.FLOAT_ARRAY); + if (dims.length == 2) { + buffer.putByte(fieldHeader.getDataStartPosition() - 1, (byte) 22); + } else if (dims.length > 2) { + buffer.putByte(fieldHeader.getDataStartPosition() - 1, (byte) 30); + } + final int bytesToCopy = putArraySizeDescriptor(dims); + buffer.putFloatArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final int value) { + this.putFieldHeader(fieldName, DataType.INT); + buffer.putInt(value); + } + + @Override + public void put(final String fieldName, final int[] values, final int n) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.INT_ARRAY); + final int valuesSize = values == null ? 0 : values.length; + final int bytesToCopy = putArraySizeDescriptor(n >= 0 ? Math.min(n, valuesSize) : valuesSize); + buffer.putIntArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final int[] values, final int[] dims) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.INT_ARRAY); + if (dims.length == 2) { + buffer.putByte(fieldHeader.getDataStartPosition() - 1, (byte) 20); + } else if (dims.length > 2) { + buffer.putByte(fieldHeader.getDataStartPosition() - 1, (byte) 28); + } + final int bytesToCopy = putArraySizeDescriptor(dims); + buffer.putIntArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final long value) { + this.putFieldHeader(fieldName, DataType.LONG); + buffer.putLong(value); + } + + @Override + public void put(final String fieldName, final long[] values, final int n) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.LONG_ARRAY); + final int valuesSize = values == null ? 0 : values.length; + final int bytesToCopy = putArraySizeDescriptor(n >= 0 ? Math.min(n, valuesSize) : valuesSize); + buffer.putLongArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final long[] values, final int[] dims) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.LONG_ARRAY); + if (dims.length == 2) { + buffer.putByte(fieldHeader.getDataStartPosition() - 1, (byte) 21); + } else if (dims.length > 2) { + buffer.putByte(fieldHeader.getDataStartPosition() - 1, (byte) 29); + } + final int bytesToCopy = putArraySizeDescriptor(dims); + buffer.putLongArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final short value) { // NOPMD by rstein + this.putFieldHeader(fieldName, DataType.SHORT); + buffer.putShort(value); + } + + @Override + public void put(final String fieldName, final short[] values, final int n) { // NOPMD by rstein + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.SHORT_ARRAY); + final int valuesSize = values == null ? 0 : values.length; + final int bytesToCopy = putArraySizeDescriptor(n >= 0 ? Math.min(n, valuesSize) : valuesSize); + buffer.putShortArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final short[] values, final int[] dims) { // NOPMD by rstein + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.SHORT_ARRAY); + if (dims.length == 2) { + buffer.putByte(fieldHeader.getDataStartPosition() - 1, (byte) 19); + } else if (dims.length > 2) { + buffer.putByte(fieldHeader.getDataStartPosition() - 1, (byte) 27); + } + final int bytesToCopy = putArraySizeDescriptor(dims); + buffer.putShortArray(values, bytesToCopy); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final String string) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.STRING); + buffer.putString(string); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final String[] values, final int n) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.STRING_ARRAY); + final int valuesSize = values == null ? 0 : values.length; + final int nElements = n >= 0 ? Math.min(n, valuesSize) : valuesSize; + putArraySizeDescriptor(nElements); + buffer.putStringArray(values, nElements); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final String[] values, final int[] dims) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.STRING_ARRAY); + if (dims.length == 2) { + buffer.putByte(fieldHeader.getDataStartPosition() - 1, (byte) 24); + } else if (dims.length > 2) { + buffer.putByte(fieldHeader.getDataStartPosition() - 1, (byte) 32); + } + final int nElements = putArraySizeDescriptor(dims); + buffer.putStringArray(values, nElements); + updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final Collection collection, final Type valueType) { + final DataType dataType; + if (collection instanceof Queue) { + dataType = DataType.QUEUE; + } else if (collection instanceof Set) { + dataType = DataType.SET; + } else if (collection instanceof List) { + dataType = DataType.LIST; + } else { + dataType = DataType.COLLECTION; + } + + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, dataType); + this.put((FieldDescription) null, collection, valueType); + this.updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final Enum enumeration) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.ENUM); + this.put((FieldDescription) null, enumeration); + this.updateDataEndMarker(fieldHeader); + } + + @Override + public void put(final String fieldName, final Map map, final Type keyType, final Type valueType) { + final WireDataFieldDescription fieldHeader = putFieldHeader(fieldName, DataType.MAP); + this.put((FieldDescription) null, map, keyType, valueType); + this.updateDataEndMarker(fieldHeader); + } + + @Override + public int putArraySizeDescriptor(final int n) { + buffer.putInt(1); // number of dimensions + buffer.putInt(n); // vector size for each dimension + return n; + } + + @Override + public int putArraySizeDescriptor(final int[] dims) { + buffer.putInt(dims.length); // number of dimensions + int nElements = 1; + for (final int dim : dims) { + nElements *= dim; + buffer.putInt(dim); // vector size for each dimension + } + return nElements; + } + + @Override + public WireDataFieldDescription putCustomData(final FieldDescription fieldDescription, final E rootObject, Class type, final FieldSerialiser serialiser) { + throw new UnsupportedOperationException(NOT_IMPLEMENTED); + } + + @Override + public void putEndMarker(final FieldDescription fieldDescription) { + if (parent.getParent() != null) { + parent = (WireDataFieldDescription) parent.getParent(); + } + } + + @Override + public WireDataFieldDescription putFieldHeader(final FieldDescription fieldDescription) { + return putFieldHeader(fieldDescription, fieldDescription.getDataType()); + } + + public WireDataFieldDescription putFieldHeader(final FieldDescription fieldDescription, DataType customDataType) { + final boolean isScalar = customDataType.isScalar(); + + final int headerStart = buffer.position(); + final String fieldName = fieldDescription.getFieldName(); + buffer.putStringISO8859(fieldName); // full field name + buffer.putByte(getDataType(customDataType)); // data type ID + + final int dataStart = buffer.position(); + final int dataStartOffset = dataStart - headerStart; + final int dataSize; + if (isScalar) { + dataSize = customDataType.getPrimitiveSize(); + } else if (customDataType == DataType.START_MARKER) { + dataSize = FastByteBuffer.SIZE_OF_INT; + buffer.ensureAdditionalCapacity(dataSize); + } else { + dataSize = -1; + // from hereon there are data specific structures + buffer.ensureAdditionalCapacity(16); // allocate 16+ bytes to account for potential array header (safe-bet) + } + lastFieldHeader = new WireDataFieldDescription(this, parent, fieldDescription.getFieldNameHashCode(), fieldDescription.getFieldName(), customDataType, headerStart, dataStartOffset, dataSize); + updateDataEntryCount(); + + return lastFieldHeader; + } + + @Override + public WireDataFieldDescription putFieldHeader(final String fieldName, final DataType dataType) { + final boolean isScalar = dataType.isScalar(); + + final int headerStart = buffer.position(); + buffer.putStringISO8859(fieldName); // full field name + buffer.putByte(getDataType(dataType)); // data type ID + + final int dataStart = buffer.position(); + final int dataStartOffset = dataStart - headerStart; + final int dataSize; + if (isScalar) { + dataSize = dataType.getPrimitiveSize(); + } else if (dataType == DataType.START_MARKER) { + dataSize = FastByteBuffer.SIZE_OF_INT; + buffer.ensureAdditionalCapacity(dataSize); + } else { + dataSize = -1; + // from hereon there are data specific structures + buffer.ensureAdditionalCapacity(16); // allocate 16+ bytes to account for potential array header (safe-bet) + } + + final int fieldNameHashCode = fieldName.hashCode(); // TODO: check hashCode function + lastFieldHeader = new WireDataFieldDescription(this, parent, fieldNameHashCode, fieldName, dataType, headerStart, dataStartOffset, dataSize); + updateDataEntryCount(); + + return lastFieldHeader; + } + + @Override + public void putHeaderInfo(final FieldDescription... field) { + parent = lastFieldHeader = getRootElement(); + final String fieldName = ""; + final int dataSize = FastByteBuffer.SIZE_OF_INT; + lastFieldHeader = new WireDataFieldDescription(this, parent, fieldName.hashCode(), fieldName, DataType.START_MARKER, buffer.position(), buffer.position(), dataSize); + buffer.putInt(0); + updateDataEntryCount(); + parent = lastFieldHeader; + } + + @Override + public void putStartMarker(final FieldDescription fieldDescription) { + putFieldHeader(fieldDescription, DataType.START_MARKER); + buffer.putInt(0); + updateDataEndMarker(lastFieldHeader); + parent = lastFieldHeader; + } + + @Override + public void setQueryFieldName(final String fieldName, final int dataStartPosition) { + if (fieldName == null || fieldName.isBlank()) { + throw new IllegalArgumentException("fieldName must not be null or blank: " + fieldName); + } + buffer.position(dataStartPosition); + } + + @Override + public void updateDataEndMarker(final WireDataFieldDescription fieldHeader) { + final int dataSize = buffer.position() - fieldHeader.getDataStartPosition(); + if (fieldHeader.getDataSize() != dataSize) { + fieldHeader.setDataSize(dataSize); + } + } + + private WireDataFieldDescription getRootElement() { + return new WireDataFieldDescription(this, null, "ROOT".hashCode(), "ROOT", DataType.OTHER, buffer.position(), -1, -1); + } + + private void updateDataEntryCount() { + // increment parent child count + if (parent == null) { + throw new IllegalStateException("no parent"); + } + + final int parentDataStart = parent.getDataStartPosition(); + if (parentDataStart >= 0) { // N.B. needs to be '>=' since CMW header is an incomplete field header containing only an 'nEntries' data field + buffer.position(parentDataStart); + final int nEntries = buffer.getInt(); + buffer.position(parentDataStart); + buffer.putInt(nEntries + 1); + buffer.position(lastFieldHeader.getDataStartPosition()); + } + } + + public static byte getDataType(final DataType dataType) { + final int id = dataType.getID(); + if (DATA_TYPE_TO_BYTE[id] != null) { + return DATA_TYPE_TO_BYTE[id]; + } + + throw new IllegalArgumentException("DataType " + dataType + " not mapped to specific byte"); + } + + public static DataType getDataType(final byte byteValue) { + final int id = byteValue & 0xFF; + if (BYTE_TO_DATA_TYPE[id] != null) { + return BYTE_TO_DATA_TYPE[id]; + } + + throw new IllegalArgumentException("DataType byteValue=" + byteValue + " rawByteValue=" + (byteValue & 0xFF) + " not mapped"); + } + + @Override + public void setFieldSerialiserLookupFunction(final BiFunction> serialiserLookupFunction) { + this.fieldSerialiserLookupFunction = serialiserLookupFunction; + } + + @Override + public BiFunction> getSerialiserLookupFunction() { + return fieldSerialiserLookupFunction; + } +} diff --git a/serialiser/src/main/java/io/opencmw/serialiser/spi/FastByteBuffer.java b/serialiser/src/main/java/io/opencmw/serialiser/spi/FastByteBuffer.java new file mode 100644 index 00000000..2365ab28 --- /dev/null +++ b/serialiser/src/main/java/io/opencmw/serialiser/spi/FastByteBuffer.java @@ -0,0 +1,1088 @@ +package io.opencmw.serialiser.spi; + +import static sun.misc.Unsafe.*; // NOSONAR NOPMD not an issue: contained and performance-related use + +import java.lang.reflect.Field; +import java.util.Objects; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +import io.opencmw.serialiser.IoBuffer; +import io.opencmw.serialiser.utils.AssertUtils; +import io.opencmw.serialiser.utils.ByteArrayCache; + +import sun.misc.Unsafe; // NOPMD - there is still nothing better under the Sun + +// import static jdk.internal.misc.Unsafe; // NOPMD by rstein TODO replaces sun in JDK11 + +/** + * FastByteBuffer implementation based on JVM 'Unsafe' Class. based on: + * https://mechanical-sympathy.blogspot.com/2012/07/native-cc-like-performance-for-java.html + * http://java-performance.info/various-methods-of-binary-serialization-in-java/ + * + * All accesses are range checked, because the performance impact was determined to be negligible. + * + * Read operations return "IndexOutOfBoundsException" if there are not enough bytes left in the buffer. + * For primitive types, the check can be done before, but for arrays and strings the size field has to be read first. + * Therefore, the position after a failed non-primitive read is not necessarily the position before the read attempt. + * + * When there is not enough space for a write operation, the behaviour depends on the autoRange and byteArrayCache + * variables. If autoRange is false, the operation returns an IndexOutOfBounds exception and the position is set to the + * position before the operation. For Strings there is a worst case space estimate being done, so an operation might + * fail with enough space left. If autoRange is true, the underlying byte array is replaced by a bigger one which is at + * least 1 KiB byte or 12,5% , but at max 100 KiB bigger than the requested size. When a byteArrayCache is provided, the + * next biggest array in that cache is used and the old buffer is returned, otherwise a new byte[] array is allocated. + * + * @author rstein + */ +@SuppressWarnings({ "restriction", "PMD.TooManyMethods", "PMD.ExcessivePublicCount", "PMD.ExcessiveClassLength" }) // unavoidable: each primitive type needs to handled individually (no templates) +public class FastByteBuffer implements IoBuffer { + public static final int SIZE_OF_BOOLEAN = 1; + public static final int SIZE_OF_BYTE = 1; + public static final int SIZE_OF_SHORT = 2; + public static final int SIZE_OF_CHAR = 2; + public static final int SIZE_OF_INT = 4; + public static final int SIZE_OF_LONG = 8; + public static final int SIZE_OF_FLOAT = 4; + public static final int SIZE_OF_DOUBLE = 8; + public static final String INVALID_UTF_8 = "Invalid UTF-8"; + private static final int DEFAULT_INITIAL_CAPACITY = 1 << 10; + private static final int DEFAULT_MIN_CAPACITY_INCREASE = 1 << 10; + private static final int DEFAULT_MAX_CAPACITY_INCREASE = 100 * (1 << 10); + private static final Unsafe unsafe; // NOPMD + static { + // get an instance of the otherwise private 'Unsafe' class + try { + @SuppressWarnings("Java9ReflectionClassVisibility") + Class cls = Class.forName("jdk.internal.module.IllegalAccessLogger"); // NOSONAR NOPMD + Field logger = cls.getDeclaredField("logger"); + + final Field field = Unsafe.class.getDeclaredField("theUnsafe"); + field.setAccessible(true); //NOSONAR + unsafe = (Unsafe) field.get(null); + unsafe.putObjectVolatile(cls, unsafe.staticFieldOffset(logger), null); + + } catch (NoSuchFieldException | SecurityException | IllegalAccessException | ClassNotFoundException e) { // NOPMD + throw new SecurityException(e); // NOPMD + } + } + + private final ReadWriteLock internalLock = new ReentrantReadWriteLock(); + private final StringBuilder builder = new StringBuilder(100); // NOPMD + private ByteArrayCache byteArrayCache; + private int intPos; + private int intLimit; + private byte[] buffer; + private boolean enforceSimpleStringEncoding; + private boolean autoResize; + + /** + * construct new FastByteBuffer backed by a default length array + */ + public FastByteBuffer() { + this(DEFAULT_INITIAL_CAPACITY); + } + /** + * construct new FastByteBuffer + * + * @param buffer buffer to initialise/re-use (stored directly) + * @param limit position until buffer is filled + */ + @SuppressWarnings("PMD.ArrayIsStoredDirectly") + public FastByteBuffer(final byte[] buffer, final int limit) { + Objects.requireNonNull(buffer, "buffer"); + if (buffer.length < limit) { + throw new IllegalArgumentException(String.format("limit %d >= capacity %d", limit, buffer.length)); + } + this.buffer = buffer; + this.intLimit = limit; + intPos = 0; + } + + /** + * construct new FastByteBuffer + * + * @param size initial capacity of the buffer + */ + public FastByteBuffer(final int size) { + this(size, false, null); + } + + /** + * construct new FastByteBuffer + * + * @param size initial capacity of the buffer + * @param autoResize whether the buffer should be resized automatically when trying to write past capacity + * @param byteArrayCache a ByteArrayCache from which arrays are obtained and returned to on resize + */ + public FastByteBuffer(final int size, final boolean autoResize, final ByteArrayCache byteArrayCache) { + AssertUtils.gtEqThanZero("size", size); + buffer = new byte[size]; + intPos = 0; + intLimit = buffer.length; + this.autoResize = autoResize; + this.byteArrayCache = byteArrayCache; + } + + @Override + public int capacity() { + return buffer.length; + } + + public void checkAvailable(final int bytes) { + if (intPos + bytes > intLimit) { + throw new IndexOutOfBoundsException("read unavailable " + bytes + " bytes at position " + intPos + " (limit: " + intLimit + ")"); + } + } + + public void checkAvailableAbsolute(final int position) { + if (position > intLimit) { + throw new IndexOutOfBoundsException("read unavailable bytes at end position " + position + " (limit: " + intLimit + ")"); + } + } + + @Override + public void clear() { + intPos = 0; + intLimit = capacity(); + } + + /** + * + * @return access to internal storage array (N.B. this is volatile and may be overwritten in case of auto-grow, or external sets) + */ + @Override + public byte[] elements() { + return buffer; // NOPMD -- allow public access to internal array + } + + @Override + public void ensureAdditionalCapacity(final int capacity) { + ensureCapacity(this.position() + capacity); + } + + @Override + public void ensureCapacity(final int newCapacity) { + if (newCapacity <= capacity()) { + return; + } + if (intPos > capacity()) { // invalid state, should never occur + throw new IllegalStateException("position " + intPos + " is beyond buffer capacity " + capacity()); + } + if (!autoResize) { + throw new IndexOutOfBoundsException("required capacity: " + newCapacity + " out of bounds: " + capacity() + "and autoResize is disabled"); + } + //TODO: add smarter enlarging algorithm (ie. increase fast for small arrays, + n% for medium sized arrays, byte-by-byte for large arrays) + final int addCapacity = Math.min(Math.max(DEFAULT_MIN_CAPACITY_INCREASE, newCapacity >> 3), DEFAULT_MAX_CAPACITY_INCREASE); // min, +12.5%, max + // if we are reading, limit() marks valid data, when writing, position() marks end of valid data, limit() is safe bet because position <= limit + forceCapacity(newCapacity + addCapacity, limit()); + } + + @Override + public void flip() { + intLimit = intPos; + intPos = 0; + } + + /** + * Forces FastByteBuffer to contain the given number of entries, preserving just a part of the array. + * + * @param length the new minimum length for this array. + * @param preserve the number of elements of the old buffer that shall be preserved in case a new allocation is necessary. + */ + @Override + public void forceCapacity(final int length, final int preserve) { + if (length == capacity()) { + intLimit = length; + return; + } + final byte[] newBuffer = byteArrayCache == null ? new byte[length] : byteArrayCache.getArray(length); + final int bytesToCopy = preserve * SIZE_OF_BYTE; + copyMemory(buffer, ARRAY_BYTE_BASE_OFFSET, newBuffer, ARRAY_BYTE_BASE_OFFSET, bytesToCopy); + intPos = Math.min(intPos, newBuffer.length); + if (byteArrayCache != null) { + byteArrayCache.add(buffer); + } + buffer = newBuffer; + intLimit = buffer.length; + } + + @Override + public boolean getBoolean() { // NOPMD by rstein + checkAvailable(SIZE_OF_BOOLEAN); + final boolean value = unsafe.getBoolean(buffer, (long) ARRAY_BYTE_BASE_OFFSET + intPos); + intPos += SIZE_OF_BOOLEAN; + + return value; + } + + @Override + public boolean getBoolean(final int position) { + checkAvailableAbsolute(position + SIZE_OF_BOOLEAN); + return unsafe.getBoolean(buffer, (long) ARRAY_BYTE_BASE_OFFSET + position); + } + + @Override + public boolean[] getBooleanArray(final boolean[] dst, final int length) { + final int arraySize = getInt(); // strided-array size + final boolean initNeeded = dst == null || length < 0 || dst.length != arraySize; + final boolean[] values = initNeeded ? new boolean[arraySize] : dst; + + checkAvailable(arraySize * SIZE_OF_BOOLEAN); + copyMemory(buffer, ARRAY_BYTE_BASE_OFFSET + intPos, values, ARRAY_BOOLEAN_BASE_OFFSET, arraySize); + intPos += arraySize; + + return values; + } + + @Override + public byte getByte() { + checkAvailable(SIZE_OF_BYTE); + final byte value = unsafe.getByte(buffer, (long) ARRAY_BYTE_BASE_OFFSET + intPos); + intPos += SIZE_OF_BYTE; + + return value; + } + + @Override + public byte getByte(final int position) { + checkAvailableAbsolute(position + SIZE_OF_BYTE); + return unsafe.getByte(buffer, (long) ARRAY_BYTE_BASE_OFFSET + position); + } + + @Override + public byte[] getByteArray(final byte[] dst, final int length) { + final int arraySize = getInt(); // strided-array size + final boolean initNeeded = dst == null || length < 0 || dst.length != arraySize; + final byte[] values = initNeeded ? new byte[arraySize] : dst; + + checkAvailable(arraySize); + copyMemory(buffer, ARRAY_BYTE_BASE_OFFSET + intPos, values, ARRAY_BYTE_BASE_OFFSET, arraySize); + intPos += arraySize; + + return values; + } + + public ByteArrayCache getByteArrayCache() { + return byteArrayCache; + } + + @Override + public char getChar() { + checkAvailable(SIZE_OF_CHAR); + final char value = unsafe.getChar(buffer, (long) ARRAY_CHAR_BASE_OFFSET + intPos); + intPos += SIZE_OF_CHAR; + + return value; + } + + @Override + public char getChar(final int position) { + checkAvailableAbsolute(position + SIZE_OF_CHAR); + return unsafe.getChar(buffer, (long) ARRAY_BYTE_BASE_OFFSET + position); + } + + @Override + public char[] getCharArray(final char[] dst, final int length) { + final int arraySize = getInt(); // strided-array size + final boolean initNeeded = dst == null || length < 0 || dst.length != arraySize; + final char[] values = initNeeded ? new char[arraySize] : dst; + + final int bytesToCopy = arraySize * SIZE_OF_CHAR; + checkAvailable(bytesToCopy); + copyMemory(buffer, ARRAY_BYTE_BASE_OFFSET + intPos, values, ARRAY_SHORT_BASE_OFFSET, bytesToCopy); + intPos += bytesToCopy; + + return values; + } + + @Override + public double getDouble() { + checkAvailable(SIZE_OF_DOUBLE); + final double value = unsafe.getDouble(buffer, (long) ARRAY_BYTE_BASE_OFFSET + intPos); + intPos += SIZE_OF_DOUBLE; + + return value; + } + + @Override + public double getDouble(final int position) { + checkAvailableAbsolute(position + SIZE_OF_DOUBLE); + return unsafe.getDouble(buffer, (long) ARRAY_BYTE_BASE_OFFSET + position); + } + + @Override + public double[] getDoubleArray(final double[] dst, final int length) { + final int arraySize = getInt(); // strided-array size + final boolean initNeeded = dst == null || length < 0 || dst.length != arraySize; + final double[] values = initNeeded ? new double[arraySize] : dst; + + final int bytesToCopy = arraySize * SIZE_OF_DOUBLE; + checkAvailable(bytesToCopy); + copyMemory(buffer, ARRAY_BYTE_BASE_OFFSET + intPos, values, ARRAY_DOUBLE_BASE_OFFSET, bytesToCopy); + intPos += bytesToCopy; + + return values; + } + + @Override + public float getFloat() { + checkAvailable(SIZE_OF_FLOAT); + final float value = unsafe.getFloat(buffer, (long) ARRAY_BYTE_BASE_OFFSET + intPos); + intPos += SIZE_OF_FLOAT; + + return value; + } + + @Override + public float getFloat(final int position) { + checkAvailableAbsolute(position + SIZE_OF_FLOAT); + return unsafe.getFloat(buffer, (long) ARRAY_BYTE_BASE_OFFSET + position); + } + + @Override + public float[] getFloatArray(final float[] dst, final int length) { + final int arraySize = getInt(); // strided-array size + final boolean initNeeded = dst == null || length < 0 || dst.length != arraySize; + final float[] values = initNeeded ? new float[arraySize] : dst; + + final int bytesToCopy = arraySize * SIZE_OF_FLOAT; + checkAvailable(bytesToCopy); + copyMemory(buffer, ARRAY_BYTE_BASE_OFFSET + intPos, values, ARRAY_FLOAT_BASE_OFFSET, bytesToCopy); + intPos += bytesToCopy; + + return values; + } + + @Override + public int getInt() { + checkAvailable(SIZE_OF_INT); + final int value = unsafe.getInt(buffer, (long) ARRAY_BYTE_BASE_OFFSET + intPos); + intPos += SIZE_OF_INT; + + return value; + } + + @Override + public int getInt(final int position) { + checkAvailableAbsolute(position + SIZE_OF_INT); + return unsafe.getInt(buffer, (long) ARRAY_BYTE_BASE_OFFSET + position); + } + + @Override + public int[] getIntArray(final int[] dst, final int length) { + final int arraySize = getInt(); // strided-array size + final boolean initNeeded = dst == null || length < 0 || dst.length != arraySize; + final int[] values = initNeeded ? new int[arraySize] : dst; + + final int bytesToCopy = arraySize * SIZE_OF_INT; + checkAvailable(bytesToCopy); + copyMemory(buffer, ARRAY_BYTE_BASE_OFFSET + intPos, values, ARRAY_INT_BASE_OFFSET, bytesToCopy); + intPos += bytesToCopy; + + return values; + } + + @Override + public long getLong() { + checkAvailable(SIZE_OF_LONG); + final long value = unsafe.getLong(buffer, (long) ARRAY_BYTE_BASE_OFFSET + intPos); + intPos += SIZE_OF_LONG; + + return value; + } + + @Override + public long getLong(final int position) { + checkAvailableAbsolute(position + SIZE_OF_LONG); + return unsafe.getLong(buffer, (long) ARRAY_BYTE_BASE_OFFSET + position); + } + + @Override + public long[] getLongArray(final long[] dst, final int length) { + final int arraySize = getInt(); // strided-array size + final boolean initNeeded = dst == null || length < 0 || dst.length != arraySize; + final long[] values = initNeeded ? new long[arraySize] : dst; + + final int bytesToCopy = arraySize * SIZE_OF_LONG; + checkAvailable(bytesToCopy); + copyMemory(buffer, ARRAY_BYTE_BASE_OFFSET + intPos, values, ARRAY_LONG_BASE_OFFSET, bytesToCopy); + intPos += bytesToCopy; + + return values; + } + + @Override + public short getShort() { // NOPMD by rstein + checkAvailable(SIZE_OF_SHORT); + final short value = unsafe.getShort(buffer, (long) ARRAY_BYTE_BASE_OFFSET + intPos); // NOPMD + intPos += SIZE_OF_SHORT; + + return value; + } + + @Override + public short getShort(final int position) { + checkAvailableAbsolute(position + SIZE_OF_SHORT); + return unsafe.getShort(buffer, (long) ARRAY_BYTE_BASE_OFFSET + position); + } + + @Override + public short[] getShortArray(final short[] dst, final int length) { // NOPMD by rstein + final int arraySize = getInt(); // strided-array size + final boolean initNeeded = dst == null || length < 0 || dst.length != arraySize; + final short[] values = initNeeded ? new short[arraySize] : dst; // NOPMD by rstein + + final int bytesToCopy = arraySize * SIZE_OF_SHORT; + checkAvailable(bytesToCopy); + copyMemory(buffer, ARRAY_BYTE_BASE_OFFSET + intPos, values, ARRAY_SHORT_BASE_OFFSET, bytesToCopy); + intPos += bytesToCopy; + + return values; + } + + @Override + public String getString() { + if (isEnforceSimpleStringEncoding()) { + return this.getStringISO8859(); + } + final int arraySize = getInt(); // for C++ zero terminated string + checkAvailable(arraySize); + // alt: final String str = new String(buffer, position, arraySize - 1, StandardCharsets.UTF_8) + decodeUTF8(buffer, intPos, arraySize - 1, builder); + + intPos += arraySize; // N.B. +1 larger to be compatible with C++ zero terminated string + // alt: return str + return builder.toString(); + } + + @Override + public String getString(final int position) { + final int oldPosition = position(); + position(position); + final String ret = getString(); + position(oldPosition); + return ret; + } + + @Override + public String[] getStringArray(final String[] dst, final int length) { + final int arraySize = getInt(); // strided-array size + checkAvailable(arraySize); + final boolean initNeeded = dst == null || length < 0 || dst.length != arraySize; + final String[] ret = initNeeded ? new String[arraySize] : dst; + for (int k = 0; k < arraySize; k++) { + ret[k] = getString(); + } + return ret; + } + + @Override + public String getStringISO8859() { + final int arraySize = getInt(); // for C++ zero terminated string + checkAvailable(arraySize); + //alt safe-fallback final String str = new String(buffer, position, arraySize - 1, StandardCharsets.ISO_8859_1) + @SuppressWarnings("deprecation") + final String str = new String(buffer, 0, intPos, arraySize - 1); // NOSONAR NOPMD fastest alternative that is public API + // final String str = FastStringBuilder.iso8859BytesToString(buffer, position, arraySize - 1) + intPos += arraySize; // N.B. +1 larger to be compatible with C++ zero terminated string + return str; + } + + @Override + public boolean hasRemaining() { + return (this.position() < limit()); + } + + /** + * @return True if the underlying byte array will be replaced by a bigger one if there is not enough space left + */ + public boolean isAutoResize() { + return autoResize; + } + + @Override + public boolean isEnforceSimpleStringEncoding() { + return enforceSimpleStringEncoding; + } + + @Override + public boolean isReadOnly() { + return false; + } + + @Override + public int limit() { + return intLimit; + } + + @Override + public void limit(final int newLimit) { + if ((newLimit > capacity()) || (newLimit < 0)) { + throw new IllegalArgumentException(String.format("invalid newLimit: [0, position: %d, newLimit:%d, %d]", intPos, newLimit, capacity())); + } + intLimit = newLimit; + if (intPos > intLimit) { + intPos = intLimit; + } + } + + @Override + public ReadWriteLock lock() { + return internalLock; + } + + @Override + public int position() { + return intPos; + } + + @Override + public void position(final int newPosition) { + if ((newPosition > intLimit) || (newPosition < 0) || (newPosition > capacity())) { + throw new IllegalArgumentException(String.format("invalid newPosition: %d vs. [0, position=%d, limit:%d, capacity:%d]", newPosition, intPos, intLimit, capacity())); + } + intPos = newPosition; + } + + @Override + public void putBoolean(final boolean value) { + ensureAdditionalCapacity(SIZE_OF_BOOLEAN); + unsafe.putBoolean(buffer, (long) ARRAY_BYTE_BASE_OFFSET + intPos, value); + intPos += SIZE_OF_BOOLEAN; + } + + @Override + public void putBoolean(final int position, final boolean value) { + ensureCapacity(position + SIZE_OF_BOOLEAN); + unsafe.putBoolean(buffer, (long) ARRAY_BYTE_BASE_OFFSET + position, value); + } + + @Override + public void putBooleanArray(final boolean[] values, final int n) { + final int valuesSize = values == null ? 0 : values.length; + final int nElements = n >= 0 ? Math.min(n, valuesSize) : valuesSize; + ensureAdditionalCapacity(nElements); + putInt(nElements); // strided-array size + copyMemory(values, ARRAY_BOOLEAN_BASE_OFFSET, buffer, ARRAY_BYTE_BASE_OFFSET + intPos, nElements); + intPos += nElements; + } + + @Override + public void putByte(final byte value) { + ensureAdditionalCapacity(SIZE_OF_BYTE); + unsafe.putByte(buffer, (long) ARRAY_BYTE_BASE_OFFSET + intPos, value); + intPos += SIZE_OF_BYTE; + } + + @Override + public void putByte(final int position, final byte value) { + ensureCapacity(position + SIZE_OF_BYTE); + unsafe.putByte(buffer, (long) ARRAY_BYTE_BASE_OFFSET + position, value); + } + + @Override + public void putByteArray(final byte[] values, final int n) { + final int valuesSize = values == null ? 0 : values.length; + final int nElements = (n >= 0 ? Math.min(n, valuesSize) : valuesSize); + ensureAdditionalCapacity(nElements + SIZE_OF_INT); + putInt(nElements); // strided-array size + copyMemory(values, ARRAY_BOOLEAN_BASE_OFFSET, buffer, ARRAY_BYTE_BASE_OFFSET + intPos, nElements); + intPos += nElements; + } + + @Override + public void putChar(final char value) { + ensureAdditionalCapacity(SIZE_OF_CHAR); + unsafe.putChar(buffer, (long) ARRAY_BYTE_BASE_OFFSET + intPos, value); + intPos += SIZE_OF_CHAR; + } + + @Override + public void putChar(final int position, final char value) { + ensureCapacity(position + SIZE_OF_CHAR); + unsafe.putChar(buffer, (long) ARRAY_BYTE_BASE_OFFSET + position, value); + } + + @Override + public void putCharArray(final char[] values, final int n) { + final int arrayOffset = ARRAY_CHAR_BASE_OFFSET; + final int valuesSize = values == null ? 0 : values.length; + final int nElements = (n >= 0 ? Math.min(n, valuesSize) : valuesSize); + final int bytesToCopy = nElements * SIZE_OF_CHAR; + ensureAdditionalCapacity(bytesToCopy + SIZE_OF_INT); + putInt(nElements); // strided-array size + copyMemory(values, arrayOffset, buffer, arrayOffset + intPos, bytesToCopy); + intPos += bytesToCopy; + } + + @Override + public void putDouble(final double value) { + ensureAdditionalCapacity(SIZE_OF_DOUBLE); + unsafe.putDouble(buffer, (long) ARRAY_BYTE_BASE_OFFSET + intPos, value); + intPos += SIZE_OF_DOUBLE; + } + + @Override + public void putDouble(final int position, final double value) { + ensureCapacity(position + SIZE_OF_DOUBLE); + unsafe.putDouble(buffer, (long) ARRAY_BYTE_BASE_OFFSET + position, value); + } + + @Override + public void putDoubleArray(final double[] values, final int n) { + final int arrayOffset = ARRAY_DOUBLE_BASE_OFFSET; + final int valuesSize = values == null ? 0 : values.length; + final int nElements = (n >= 0 ? Math.min(n, valuesSize) : valuesSize); + final int bytesToCopy = nElements * SIZE_OF_DOUBLE; + ensureAdditionalCapacity(bytesToCopy + SIZE_OF_INT); + putInt(nElements); // strided-array size + copyMemory(values, arrayOffset, buffer, arrayOffset + intPos, bytesToCopy); + intPos += bytesToCopy; + } + + @Override + public void putFloat(final float value) { + ensureAdditionalCapacity(SIZE_OF_FLOAT); + unsafe.putFloat(buffer, (long) ARRAY_BYTE_BASE_OFFSET + intPos, value); + intPos += SIZE_OF_FLOAT; + } + + @Override + public void putFloat(final int position, final float value) { + ensureCapacity(position + SIZE_OF_FLOAT); + unsafe.putFloat(buffer, (long) ARRAY_BYTE_BASE_OFFSET + position, value); + } + + @Override + public void putFloatArray(final float[] values, final int n) { + final int arrayOffset = ARRAY_FLOAT_BASE_OFFSET; + final int valuesSize = values == null ? 0 : values.length; + final int nElements = (n >= 0 ? Math.min(n, valuesSize) : valuesSize); + final int bytesToCopy = nElements * SIZE_OF_FLOAT; + ensureAdditionalCapacity(bytesToCopy + SIZE_OF_INT); + putInt(nElements); // strided-array size + copyMemory(values, arrayOffset, buffer, arrayOffset + intPos, bytesToCopy); + intPos += bytesToCopy; + } + + @Override + public void putInt(final int value) { + ensureAdditionalCapacity(SIZE_OF_INT); + unsafe.putInt(buffer, (long) ARRAY_BYTE_BASE_OFFSET + intPos, value); + intPos += SIZE_OF_INT; + } + + @Override + public void putInt(final int position, final int value) { + ensureCapacity(position + SIZE_OF_INT); + unsafe.putInt(buffer, (long) ARRAY_BYTE_BASE_OFFSET + position, value); + } + + @Override + public void putIntArray(final int[] values, final int n) { + final int arrayOffset = ARRAY_INT_BASE_OFFSET; + final int valuesSize = values == null ? 0 : values.length; + final int nElements = (n >= 0 ? Math.min(n, valuesSize) : valuesSize); + final int bytesToCopy = nElements * SIZE_OF_INT; + ensureAdditionalCapacity(bytesToCopy + SIZE_OF_INT); + putInt(nElements); // strided-array size + copyMemory(values, arrayOffset, buffer, arrayOffset + intPos, bytesToCopy); + intPos += bytesToCopy; + } + + @Override + public void putLong(final long value) { + ensureAdditionalCapacity(SIZE_OF_LONG); + unsafe.putLong(buffer, (long) ARRAY_BYTE_BASE_OFFSET + intPos, value); + intPos += SIZE_OF_LONG; + } + + @Override + public void putLong(final int position, final long value) { + ensureCapacity(position + SIZE_OF_LONG); + unsafe.putLong(buffer, (long) ARRAY_BYTE_BASE_OFFSET + position, value); + } + + @Override + public void putLongArray(final long[] values, final int n) { + final int arrayOffset = ARRAY_LONG_BASE_OFFSET; + final int valuesSize = values == null ? 0 : values.length; + final int nElements = (n >= 0 ? Math.min(n, valuesSize) : valuesSize); + final int bytesToCopy = nElements * SIZE_OF_LONG; + ensureAdditionalCapacity(bytesToCopy + SIZE_OF_INT); + putInt(nElements); // strided-array size + copyMemory(values, arrayOffset, buffer, arrayOffset + intPos, bytesToCopy); + intPos += bytesToCopy; + } + + @Override + public void putShort(final short value) { // NOPMD by rstein + ensureAdditionalCapacity(SIZE_OF_SHORT); + unsafe.putShort(buffer, (long) ARRAY_BYTE_BASE_OFFSET + intPos, value); + intPos += SIZE_OF_SHORT; + } + + @Override + public void putShort(final int position, final short value) { + ensureCapacity(position + SIZE_OF_SHORT); + unsafe.putShort(buffer, (long) ARRAY_BYTE_BASE_OFFSET + position, value); + } + + @Override + public void putShortArray(final short[] values, final int n) { // NOPMD by rstein + final int arrayOffset = ARRAY_SHORT_BASE_OFFSET; + final int valuesSize = values == null ? 0 : values.length; + final int nElements = (n >= 0 ? Math.min(n, valuesSize) : valuesSize); + final int bytesToCopy = nElements * SIZE_OF_SHORT; + ensureAdditionalCapacity(bytesToCopy + SIZE_OF_INT); + putInt(nElements); // strided-array size + copyMemory(values, arrayOffset, buffer, arrayOffset + intPos, bytesToCopy); + intPos += bytesToCopy; + } + + @Override + public void putString(final String string) { + if (string == null) { + putString(""); + return; + } + if (isEnforceSimpleStringEncoding()) { + putStringISO8859(string); + return; + } + final int utf16StringLength = string.length(); + final int initialPos = intPos; + intPos += SIZE_OF_INT; + // write string-to-byte (in-place) + ensureAdditionalCapacity(3 * utf16StringLength + SIZE_OF_INT); + final int strLength = encodeUTF8(string, buffer, intPos, 3 * utf16StringLength); + final int endPos = intPos + strLength; + + // write length of string byte representation + putInt(initialPos, strLength + 1); + intPos = endPos; + + putByte((byte) 0); // For C++ zero terminated string + } + + @Override + public void putString(final int position, final String value) { + final int oldPosition = position(); + position(position); + putString(value); + position(oldPosition); + } + + @Override + public void putStringArray(final String[] values, final int n) { + final int valuesSize = values == null ? 0 : values.length; + final int nElements = n >= 0 ? Math.min(n, valuesSize) : valuesSize; + final int originalPos = intPos; // NOPMD + putInt(nElements); // strided-array size + if (values == null) { + return; + } + try { + if (isEnforceSimpleStringEncoding()) { + for (int k = 0; k < nElements; k++) { + putStringISO8859(values[k]); + } + return; + } + for (int k = 0; k < nElements; k++) { + putString(values[k]); + } + } catch (IndexOutOfBoundsException e) { + intPos = originalPos; // reset the position to the original position before any strings where added + throw e; // rethrow the exception + } + } + + @Override + public void putStringISO8859(final String string) { + if (string == null) { + putStringISO8859(""); + return; + } + final int initialPos = intPos; + intPos += SIZE_OF_INT; + // write string-to-byte (in-place) + final int strLength = encodeISO8859(string, buffer, intPos, string.length()); + final int endPos = intPos + strLength; + + // write length of string byte representation + intPos = initialPos; + putInt(strLength + 1); + intPos = endPos; + + putByte((byte) 0); // For C++ zero terminated string + } + + @Override + public int remaining() { + return intLimit - intPos; + } + + @Override + public void reset() { + intPos = 0; + intLimit = buffer.length; + } + + /** + * @param autoResize {@code true} to automatically increase the size of the underlying buffer if necessary + */ + public void setAutoResize(final boolean autoResize) { + this.autoResize = autoResize; + } + + public void setByteArrayCache(final ByteArrayCache byteArrayCache) { + this.byteArrayCache = byteArrayCache; + } + + @Override + public void setEnforceSimpleStringEncoding(final boolean state) { + this.enforceSimpleStringEncoding = state; + } + + @Override + public String toString() { + return super.toString() + String.format(" - [0, position=%d, limit:%d, capacity:%d]", intPos, intLimit, capacity()); + } + + /** + * Trims the internal buffer array so that the capacity is equal to the size. + * + * @see java.util.ArrayList#trimToSize() + */ + @Override + public void trim() { + trim(position()); + } + + /** + * Trims the internal buffer array if it is too large. If the current array length is smaller than or equal to + * {@code requestedCapacity}, this method does nothing. Otherwise, it trims the array length to the maximum between + * {@code requestedCapacity} and {@link #capacity()}. + *

+ * This method is useful when reusing FastBuffers. {@linkplain #reset() Clearing a list} leaves the array length + * untouched. If you are reusing a list many times, you can call this method with a typical size to avoid keeping + * around a very large array just because of a few large transient lists. + * + * @param requestedCapacity the threshold for the trimming. + */ + @Override + public void trim(final int requestedCapacity) { + if ((requestedCapacity >= capacity()) || (this.position() > requestedCapacity)) { + return; + } + final int bytesToCopy = Math.min(Math.max(requestedCapacity, position()), capacity()) * SIZE_OF_BYTE; + final byte[] newBuffer = byteArrayCache == null ? new byte[requestedCapacity] : byteArrayCache.getArrayExact(requestedCapacity); + copyMemory(buffer, ARRAY_BYTE_BASE_OFFSET, newBuffer, ARRAY_BYTE_BASE_OFFSET, bytesToCopy); + if (byteArrayCache != null) { + byteArrayCache.add(buffer); + } + buffer = newBuffer; + intLimit = newBuffer.length; + } + + /** + * Wraps a given byte array into FastByteBuffer + *

+ * Note it is guaranteed that the type of the array returned by {@link #elements()} will be the same. + * + * @param byteArray an array to wrap. + * @return a new FastByteBuffer of the given size, wrapping the given array. + */ + public static FastByteBuffer wrap(final byte[] byteArray) { + return wrap(byteArray, byteArray.length); + } + + /** + * Wraps a given byte array into FastByteBuffer + *

+ * Note it is guaranteed that the type of the array returned by {@link #elements()} will be the same. + * + * @param byteArray an array to wrap. + * @param length the length of the resulting array list. + * @return a new FastByteBuffer of the given size, wrapping the given array. + */ + public static FastByteBuffer wrap(final byte[] byteArray, final int length) { + return new FastByteBuffer(byteArray, length); + } + + private static void copyMemory(final Object srcBase, final int srcOffset, final Object destBase, final int destOffset, final int nBytes) { + unsafe.copyMemory(srcBase, srcOffset, destBase, destOffset, nBytes); + } + + // Fast UTF-8 byte-array to String(Builder) decode - code originally based on Google's ProtoBuffer implementation and since modified + @SuppressWarnings("PMD") + private static void decodeUTF8(byte[] bytes, int offset, int size, StringBuilder result) { //NOSONAR + // Bitwise OR combines the sign bits so any negative value fails the check. + // N.B. many code snippets are in-lined for performance reasons (~10% performance improvement) ... this is a JIT hot spot. + if ((offset | size | bytes.length - offset - size) < 0) { + throw new ArrayIndexOutOfBoundsException(String.format("buffer length=%d, offset=%d, size=%d", bytes.length, offset, size)); + } + + // The longest possible resulting String is the same as the number of input bytes, when it is + // all ASCII. For other cases, this over-allocates and we will truncate in the end. + result.setLength(size); + + // keep separate int/long counters so we don't have to convert types at every call + int remaining = size; + long readPos = (long) ARRAY_BYTE_BASE_OFFSET + offset; + int resultPos = 0; + + // Optimize for 100% ASCII (Hotspot loves small simple top-level loops like this). + // This simple loop stops when we encounter a byte >= 0x80 (i.e. non-ASCII). + while (remaining > 0) { + final byte byte1 = unsafe.getByte(bytes, readPos); + if (byte1 < 0) { + // is more than one byte ie. non-ASCII (unsigned byte value larger > 127 <-> negative number for signed byte + break; + } + readPos++; + remaining--; + result.setCharAt(resultPos++, (char) byte1); + } + + while (remaining > 0) { + final byte byte1 = unsafe.getByte(bytes, readPos++); + remaining--; + if (byte1 >= 0) { // is one byte (ie. ASCII-type encoding) + result.setCharAt(resultPos++, (char) byte1); + // It's common for there to be multiple ASCII characters in a run mixed in, so add an extra optimized loop to take care of these runs. + while (remaining > 0) { + final byte b = unsafe.getByte(bytes, readPos); + if (b < 0) { // is not one byte + break; + } + readPos++; + remaining--; + result.setCharAt(resultPos++, (char) b); + } + } else if (byte1 < (byte) 0xE0) { // is two bytes + if (remaining < 1) { + throw new IllegalArgumentException(INVALID_UTF_8); + } + final byte byte2 = unsafe.getByte(bytes, readPos++); + remaining--; + final int resultPos1 = resultPos++; + // Simultaneously checks for illegal trailing-byte in leading position (<= '11000000') and overlong 2-byte, '11000001'. + if (byte1 < (byte) 0xC2) { + throw new IllegalArgumentException(INVALID_UTF_8 + ": Illegal leading byte in 2 bytes UTF"); + } + if (byte2 > (byte) 0xBF) { // is not trailing byte + throw new IllegalArgumentException(INVALID_UTF_8 + ": Illegal trailing byte in 2 bytes UTF"); + } + result.setCharAt(resultPos1, (char) (((byte1 & 0x1F) << 6) | byte2 & 0x3F)); + } else if (byte1 < (byte) 0xF0) { // is three bytes + if (remaining < 2) { + throw new IllegalArgumentException(INVALID_UTF_8); + } + /* byte2 */ + /* byte3 */ + final byte byte2 = unsafe.getByte(bytes, readPos++); + final byte byte3 = unsafe.getByte(bytes, readPos++); + final int resultPos1 = resultPos++; + if (byte2 > (byte) 0xBF // is not trailing byte + // overlong? 5 most significant bits must not all be zero + || (byte1 == (byte) 0xE0 && byte2 < (byte) 0xA0) + // check for illegal surrogate codepoints + || (byte1 == (byte) 0xED && byte2 >= (byte) 0xA0) + || byte3 > (byte) 0xBF) { // is not trailing byte + throw new IllegalArgumentException(INVALID_UTF_8); + } + result.setCharAt(resultPos1, (char) (((byte1 & 0x0F) << 12) | ((byte2 & 0x3F) << 6) | byte3 & 0x3F)); + remaining -= 2; + } else { // is four bytes + if (remaining < 3) { + throw new IllegalArgumentException(INVALID_UTF_8); + } + // handle four byte UTF + /* byte2 */ + /* byte3 */ + /* byte4 */ + final byte byte2 = unsafe.getByte(bytes, readPos++); + final byte byte3 = unsafe.getByte(bytes, readPos++); + final byte byte4 = unsafe.getByte(bytes, readPos++); + final int resultPos1 = resultPos++; + if (byte2 > (byte) 0xBF + // Check that 1 <= plane <= 16. Tricky optimized form of: + // valid 4-byte leading byte? + // if (byte1 > (byte) 0xF4 || + // overlong? 4 most significant bits must not all be zero + // byte1 == (byte) 0xF0 && byte2 < (byte) 0x90 || + // codepoint larger than the highest code point (U+10FFFF)? byte1 == (byte) 0xF4 && byte2 > (byte) 0x8F) + || (((byte1 << 28) + (byte2 - (byte) 0x90)) >> 30) != 0 + || byte3 > (byte) 0xBF + || byte4 > (byte) 0xBF) { + throw new IllegalArgumentException(INVALID_UTF_8); + } + final int codepoint = ((byte1 & 0x07) << 18) | ((byte2 & 0x3F) << 12) | ((byte3 & 0x3F) << 6) | byte4 & 0x3F; + result.setCharAt(resultPos1, (char) ((Character.MIN_HIGH_SURROGATE - (Character.MIN_SUPPLEMENTARY_CODE_POINT >>> 10)) + (codepoint >>> 10))); + result.setCharAt(resultPos1 + 1, (char) (Character.MIN_LOW_SURROGATE + (codepoint & 0x3ff))); + remaining -= 3; + // 4-byte case requires two chars. + resultPos++; + } + } + + result.setLength(resultPos); + } + + private static int encodeISO8859(final String sequence, final byte[] bytes, final int offset, final int length) { + // encode to ISO_8859_1 + final int base = ARRAY_BYTE_BASE_OFFSET + offset; + for (int i = 0; i < length; i++) { + unsafe.putByte(bytes, (long) base + i, (byte) (sequence.charAt(i) & 0xFF)); + } + return length; + } + + // Fast UTF-8 String (CharSequence) to byte-array encoder - code originally based on Google's ProtoBuffer implementation and since modified + @SuppressWarnings("PMD") + private static int encodeUTF8(final CharSequence sequence, final byte[] bytes, final int offset, final int length) { //NOSONAR + int utf16Length = sequence.length(); + int base = ARRAY_BYTE_BASE_OFFSET + offset; + int i = 0; + int limit = base + length; + // Designed to take advantage of https://wiki.openjdk.java.net/display/HotSpot/RangeCheckElimination + for (char c; i < utf16Length && base + i < limit && (c = sequence.charAt(i)) < 0x80; i++) { + unsafe.putByte(bytes, (long) base + i, (byte) c); + } + if (i == utf16Length) { + return utf16Length; + } + base += i; + for (; i < utf16Length; i++) { + final char c = sequence.charAt(i); + if (c < 0x80 && base < limit) { + unsafe.putByte(bytes, base++, (byte) c); + } else if (c < 0x800 && base <= limit - 2) { // 11 bits, two UTF-8 bytes + unsafe.putByte(bytes, base++, (byte) ((0xF << 6) | (c >>> 6))); + unsafe.putByte(bytes, base++, (byte) (0x80 | (0x3F & c))); + } else if ((c < Character.MIN_SURROGATE || Character.MAX_SURROGATE < c) && base <= limit - 3) { + // Maximum single-char code point is 0xFFFF, 16 bits, three UTF-8 bytes + unsafe.putByte(bytes, base++, (byte) ((0xF << 5) | (c >>> 12))); + unsafe.putByte(bytes, base++, (byte) (0x80 | (0x3F & (c >>> 6)))); + unsafe.putByte(bytes, base++, (byte) (0x80 | (0x3F & c))); + } else if (base <= limit - 4) { + // Minimum code point represented by a surrogate pair is 0x10000, 17 bits, four UTF-8 bytes + final char low; + if (i + 1 == sequence.length() || !Character.isSurrogatePair(c, (low = sequence.charAt(++i)))) { + throw new IllegalArgumentException("Unpaired surrogate at index " + (i - 1)); + } + final int codePoint = Character.toCodePoint(c, low); + unsafe.putByte(bytes, base++, (byte) ((0xF << 4) | (codePoint >>> 18))); + unsafe.putByte(bytes, base++, (byte) (0x80 | (0x3F & (codePoint >>> 12)))); + unsafe.putByte(bytes, base++, (byte) (0x80 | (0x3F & (codePoint >>> 6)))); + unsafe.putByte(bytes, base++, (byte) (0x80 | (0x3F & codePoint))); + } else { + throw new ArrayIndexOutOfBoundsException("Failed writing " + c + " at index " + base); + } + } + return base - ARRAY_BYTE_BASE_OFFSET - offset; + } +} diff --git a/serialiser/src/main/java/io/opencmw/serialiser/spi/JsonSerialiser.java b/serialiser/src/main/java/io/opencmw/serialiser/spi/JsonSerialiser.java new file mode 100644 index 00000000..ef7ac1ad --- /dev/null +++ b/serialiser/src/main/java/io/opencmw/serialiser/spi/JsonSerialiser.java @@ -0,0 +1,933 @@ +package io.opencmw.serialiser.spi; + +import java.io.IOException; +import java.lang.reflect.Type; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.function.BiFunction; + +import io.opencmw.serialiser.DataType; +import io.opencmw.serialiser.FieldDescription; +import io.opencmw.serialiser.FieldSerialiser; +import io.opencmw.serialiser.IoBuffer; +import io.opencmw.serialiser.IoSerialiser; + +import com.jsoniter.JsonIterator; +import com.jsoniter.JsonIteratorPool; +import com.jsoniter.any.Any; +import com.jsoniter.extra.PreciseFloatSupport; +import com.jsoniter.output.EncodingMode; +import com.jsoniter.output.JsonStream; +import com.jsoniter.spi.DecodingMode; +import com.jsoniter.spi.JsonException; + +import de.gsi.dataset.utils.ByteBufferOutputStream; + +@SuppressWarnings({ "PMD.ExcessivePublicCount", "PMD.TooManyMethods" }) // unavoidable: Java does not support templates and primitive types need to be handled one-by-one +public class JsonSerialiser implements IoSerialiser { + public static final String NOT_A_JSON_COMPATIBLE_PROTOCOL = "Not a JSON compatible protocol"; + public static final String JSON_ROOT = "JSON_ROOT"; + private static final int DEFAULT_INITIAL_CAPACITY = 10_000; + private static final int DEFAULT_INDENTATION = 2; + private static final char BRACKET_OPEN = '{'; + private static final char BRACKET_CLOSE = '}'; + public static final char QUOTE = '\"'; + private static final String NULL = "null"; + private static final String ASSIGN = ": "; + private static final String LINE_BREAK = System.getProperty("line.separator"); + public static final String UNCHECKED = "unchecked"; + private final StringBuilder builder = new StringBuilder(DEFAULT_INITIAL_CAPACITY); // NOPMD + private IoBuffer buffer; + private Any root; + private Any tempRoot; + private WireDataFieldDescription parent; + private WireDataFieldDescription lastFieldHeader; + private String queryFieldName; + private boolean hasFieldBefore; + private String indentation = ""; + private BiFunction> fieldSerialiserLookupFunction; + + /** + * @param buffer the backing IoBuffer (see e.g. {@link FastByteBuffer} or{@link ByteBuffer} + */ + public JsonSerialiser(final IoBuffer buffer) { + super(); + this.buffer = buffer; + + // JsonStream.setIndentionStep(DEFAULT_INDENTATION) + // JsonStream.setMode(EncodingMode.REFLECTION_MODE) -- enable as a fall back + // JsonIterator.setMode(DecodingMode.REFLECTION_MODE) -- enable as a fall back + JsonStream.setMode(EncodingMode.DYNAMIC_MODE); + JsonIterator.setMode(DecodingMode.DYNAMIC_MODE_AND_MATCH_FIELD_WITH_HASH); + + try { + PreciseFloatSupport.enable(); + } catch (JsonException e) { + // swallow subsequent enabling exceptions (function is guarded and supposed to be called only once) + } + } + + @Override + public ProtocolInfo checkHeaderInfo() { + // make coarse check (ie. check if first non-null character is a '{' bracket + int count = buffer.position(); + while (buffer.getByte(count) != BRACKET_OPEN && (buffer.getByte(count) == 0 || buffer.getByte(count) == ' ' || buffer.getByte(count) == '\t' || buffer.getByte(count) == '\n')) { + count++; + } + if (buffer.getByte(count) != BRACKET_OPEN) { + throw new IllegalStateException(NOT_A_JSON_COMPATIBLE_PROTOCOL); + } + + try (JsonIterator iter = JsonIteratorPool.borrowJsonIterator()) { + iter.reset(buffer.elements(), 0, buffer.limit()); + tempRoot = root = iter.readAny(); + } catch (IOException e) { + throw new IllegalStateException(NOT_A_JSON_COMPATIBLE_PROTOCOL, e); + } + + final WireDataFieldDescription headerStartField = new WireDataFieldDescription(this, null, JSON_ROOT.hashCode(), JSON_ROOT, DataType.OTHER, buffer.position(), count - 1, -1); + final ProtocolInfo header = new ProtocolInfo(this, headerStartField, JsonSerialiser.class.getCanonicalName(), (byte) 1, (byte) 0, (byte) 0); + parent = lastFieldHeader = headerStartField; + queryFieldName = JSON_ROOT; + return header; + } + + public T deserialiseObject(final T obj) { + try (JsonIterator iter = JsonIterator.parse(buffer.elements(), 0, buffer.limit())) { + return iter.read(obj); + } catch (IOException | JsonException e) { + throw new IllegalStateException(NOT_A_JSON_COMPATIBLE_PROTOCOL, e); + } + } + + @Override + public int[] getArraySizeDescriptor() { + return new int[0]; + } + + @Override + public boolean getBoolean() { + return tempRoot.get(queryFieldName).toBoolean(); + } + + @Override + public boolean[] getBooleanArray(final boolean[] dst, final int length) { + return tempRoot.get(queryFieldName).as(boolean[].class); + } + + @Override + public IoBuffer getBuffer() { + return buffer; + } + + @Override + public byte getByte() { + return (byte) tempRoot.get(queryFieldName).toInt(); + } + + @Override + public byte[] getByteArray(final byte[] dst, final int length) { + return tempRoot.get(queryFieldName).as(byte[].class); + } + + @Override + public char getChar() { + return (char) tempRoot.get(queryFieldName).toInt(); + } + + @Override + public char[] getCharArray(final char[] dst, final int length) { + return tempRoot.get(queryFieldName).as(char[].class); + } + + @Override + @SuppressWarnings(UNCHECKED) + public Collection getCollection(final Collection collection) { + return tempRoot.get(queryFieldName).as(ArrayList.class); + } + + @Override + @SuppressWarnings(UNCHECKED) + public E getCustomData(final FieldSerialiser serialiser) { + return (E) tempRoot.get(queryFieldName); + } + + @Override + public double getDouble() { + return tempRoot.get(queryFieldName).toDouble(); + } + + @Override + public double[] getDoubleArray(final double[] dst, final int length) { + return tempRoot.get(queryFieldName).as(double[].class); + } + + @Override + public > Enum getEnum(final Enum enumeration) { + return null; + } + + @Override + public String getEnumTypeList() { + return null; + } + + @Override + public WireDataFieldDescription getFieldHeader() { + return null; + } + + @Override + public float getFloat() { + return tempRoot.get(queryFieldName).toFloat(); + } + + @Override + public float[] getFloatArray(final float[] dst, final int length) { + return tempRoot.get(queryFieldName).as(float[].class); + } + + @Override + public int getInt() { + return tempRoot.get(queryFieldName).toInt(); + } + + @Override + public int[] getIntArray(final int[] dst, final int length) { + return tempRoot.get(queryFieldName).as(int[].class); + } + + @Override + @SuppressWarnings(UNCHECKED) + public List getList(final List collection) { + return tempRoot.get(queryFieldName).as(List.class); + } + + @Override + public long getLong() { + return tempRoot.get(queryFieldName).toLong(); + } + + @Override + public long[] getLongArray(final long[] dst, final int length) { + return tempRoot.get(queryFieldName).as(long[].class); + } + + @Override + public Map getMap(final Map map) { + return null; + } + + public WireDataFieldDescription getParent() { + return parent; + } + + @Override + @SuppressWarnings(UNCHECKED) + public Queue getQueue(final Queue collection) { + return tempRoot.get(queryFieldName).as(ArrayDeque.class); + } + + @Override + @SuppressWarnings(UNCHECKED) + public Set getSet(final Set collection) { + return tempRoot.get(queryFieldName).as(HashSet.class); + } + + @Override + public short getShort() { + return (short) tempRoot.get(queryFieldName).toLong(); + } + + @Override + public short[] getShortArray(final short[] dst, final int length) { + return tempRoot.get(queryFieldName).as(short[].class); + } + + @Override + public String getString() { + return tempRoot.get(queryFieldName).toString(); + } + + @Override + public String[] getStringArray(final String[] dst, final int length) { + return tempRoot.get(queryFieldName).as(String[].class); + } + + @Override + public String getStringISO8859() { + return tempRoot.get(queryFieldName).toString(); + } + + @Override + public boolean isPutFieldMetaData() { + return false; + } + + @Override + public WireDataFieldDescription parseIoStream(final boolean readHeader) { + try (JsonIterator iter = JsonIteratorPool.borrowJsonIterator()) { + iter.reset(buffer.elements(), 0, buffer.limit()); + tempRoot = root = iter.readAny(); + + final WireDataFieldDescription fieldRoot = new WireDataFieldDescription(this, null, "ROOT".hashCode(), "ROOT", DataType.OTHER, buffer.position(), -1, -1); + parseIoStream(fieldRoot, tempRoot, ""); + + return fieldRoot; + } catch (IOException e) { + throw new IllegalStateException(NOT_A_JSON_COMPATIBLE_PROTOCOL, e); + } + } + + @Override + public void put(final FieldDescription fieldDescription, final Collection collection, final Type valueType) { + put(fieldDescription.getFieldName(), collection, valueType); + } + + @Override + public void put(final FieldDescription fieldDescription, final Enum enumeration) { + put(fieldDescription.getFieldName(), enumeration); + } + + @Override + public void put(final FieldDescription fieldDescription, final Map map, final Type keyType, final Type valueType) { + put(fieldDescription.getFieldName(), map, keyType, valueType); + } + + @Override + public void put(final String fieldName, final Collection collection, final Type valueType) { + lineBreak(); + builder.append(QUOTE).append(fieldName).append(QUOTE).append(ASSIGN).append('['); + if (collection == null || collection.isEmpty()) { + builder.append(']'); + return; + } + final Iterator iter = collection.iterator(); + serialiseObject(iter.next()); + while (iter.hasNext()) { + builder.append(", "); + serialiseObject(iter.next()); + } + builder.append(']'); + hasFieldBefore = true; + } + + @Override + public void put(final String fieldName, final Enum enumeration) { + lineBreak(); + builder.append(QUOTE).append(fieldName).append(QUOTE + ASSIGN + QUOTE).append(enumeration).append(QUOTE); + hasFieldBefore = true; + } + + @Override + public void put(final String fieldName, final Map map, final Type keyType, final Type valueType) { + lineBreak(); + builder.append(QUOTE).append(fieldName).append(QUOTE + ASSIGN + '{'); + if (map == null || map.isEmpty()) { + builder.append('}'); + return; + } + final Set> entrySet = map.entrySet(); + boolean isFirst = true; + for (Map.Entry entry : entrySet) { + final V value = entry.getValue(); + if (isFirst) { + isFirst = false; + } else { + builder.append(", "); + } + builder.append(QUOTE).append(entry.getKey()).append(QUOTE + ASSIGN); + + switch (DataType.fromClassType(value.getClass())) { + case CHAR: + builder.append((int) value); + break; + case STRING: + builder.append(QUOTE).append(entry.getValue()).append(QUOTE); + break; + default: + builder.append(value); + break; + } + } + + builder.append('}'); + hasFieldBefore = true; + } + + @Override + public void put(final FieldDescription fieldDescription, final boolean value) { + put(fieldDescription.getFieldName(), value); + } + + @Override + public void put(final FieldDescription fieldDescription, final boolean[] values, final int n) { + put(fieldDescription.getFieldName(), values, n); + } + + @Override + public void put(final FieldDescription fieldDescription, final boolean[] values, final int[] dims) { + put(fieldDescription.getFieldName(), values, dims); + } + + @Override + public void put(final FieldDescription fieldDescription, final byte value) { + put(fieldDescription.getFieldName(), value); + } + + @Override + public void put(final FieldDescription fieldDescription, final byte[] values, final int n) { + put(fieldDescription.getFieldName(), values, n); + } + + @Override + public void put(final FieldDescription fieldDescription, final byte[] values, final int[] dims) { + put(fieldDescription.getFieldName(), values, dims); + } + + @Override + public void put(final FieldDescription fieldDescription, final char value) { + put(fieldDescription.getFieldName(), value); + } + + @Override + public void put(final FieldDescription fieldDescription, final char[] values, final int n) { + put(fieldDescription.getFieldName(), values, n); + } + + @Override + public void put(final FieldDescription fieldDescription, final char[] values, final int[] dims) { + put(fieldDescription.getFieldName(), values, dims); + } + + @Override + public void put(final FieldDescription fieldDescription, final double value) { + put(fieldDescription.getFieldName(), value); + } + + @Override + public void put(final FieldDescription fieldDescription, final double[] values, final int n) { + put(fieldDescription.getFieldName(), values, n); + } + + @Override + public void put(final FieldDescription fieldDescription, final double[] values, final int[] dims) { + put(fieldDescription.getFieldName(), values, dims); + } + + @Override + public void put(final FieldDescription fieldDescription, final float value) { + put(fieldDescription.getFieldName(), value); + } + + @Override + public void put(final FieldDescription fieldDescription, final float[] values, final int n) { + put(fieldDescription.getFieldName(), values, n); + } + + @Override + public void put(final FieldDescription fieldDescription, final float[] values, final int[] dims) { + put(fieldDescription.getFieldName(), values, dims); + } + + @Override + public void put(final FieldDescription fieldDescription, final int value) { + put(fieldDescription.getFieldName(), value); + } + + @Override + public void put(final FieldDescription fieldDescription, final int[] values, final int n) { + put(fieldDescription.getFieldName(), values, n); + } + + @Override + public void put(final FieldDescription fieldDescription, final int[] values, final int[] dims) { + put(fieldDescription.getFieldName(), values, dims); + } + + @Override + public void put(final FieldDescription fieldDescription, final long value) { + put(fieldDescription.getFieldName(), value); + } + + @Override + public void put(final FieldDescription fieldDescription, final long[] values, final int n) { + put(fieldDescription.getFieldName(), values, n); + } + + @Override + public void put(final FieldDescription fieldDescription, final long[] values, final int[] dims) { + put(fieldDescription.getFieldName(), values, dims); + } + + @Override + public void put(final FieldDescription fieldDescription, final short value) { + put(fieldDescription.getFieldName(), value); + } + + @Override + public void put(final FieldDescription fieldDescription, final short[] values, final int n) { + put(fieldDescription.getFieldName(), values, n); + } + + @Override + public void put(final FieldDescription fieldDescription, final short[] values, final int[] dims) { + put(fieldDescription.getFieldName(), values, dims); + } + + @Override + public void put(final FieldDescription fieldDescription, final String string) { + put(fieldDescription.getFieldName(), string); + } + + @Override + public void put(final FieldDescription fieldDescription, final String[] values, final int n) { + put(fieldDescription.getFieldName(), values, n); + } + + @Override + public void put(final FieldDescription fieldDescription, final String[] values, final int[] dims) { + put(fieldDescription.getFieldName(), values, dims); + } + + @Override + public void put(final String fieldName, final boolean value) { + lineBreak(); + builder.append(QUOTE).append(fieldName).append(QUOTE + ASSIGN).append(value); + hasFieldBefore = true; + } + + @Override + public void put(final String fieldName, final boolean[] values, final int n) { + lineBreak(); + builder.append(QUOTE).append(fieldName).append(QUOTE + ASSIGN + '['); + if (values == null || values.length <= 0) { + builder.append(']'); + return; + } + builder.append(values[0]); + final int valuesSize = values.length; + final int nElements = n >= 0 ? Math.min(n, valuesSize) : valuesSize; + for (int i = 1; i < nElements; i++) { + builder.append(", ").append(values[i]); + } + builder.append(']'); + hasFieldBefore = true; + } + + @Override + public void put(final String fieldName, final boolean[] values, final int[] dims) { + put(fieldName, values, getNumberElements(dims)); + } + + @Override + public void put(final String fieldName, final byte value) { + lineBreak(); + builder.append(QUOTE).append(fieldName).append(QUOTE + ASSIGN).append(value); + hasFieldBefore = true; + } + + @Override + public void put(final String fieldName, final byte[] values, final int n) { + lineBreak(); + builder.append(QUOTE).append(fieldName).append(QUOTE + ASSIGN + '['); + if (values == null || values.length <= 0) { + builder.append(']'); + return; + } + builder.append(values[0]); + final int valuesSize = values.length; + final int nElements = n >= 0 ? Math.min(n, valuesSize) : valuesSize; + for (int i = 1; i < nElements; i++) { + builder.append(", ").append(values[i]); + } + builder.append(']'); + hasFieldBefore = true; + } + + @Override + public void put(final String fieldName, final byte[] values, final int[] dims) { + put(fieldName, values, getNumberElements(dims)); + } + + @Override + public void put(final String fieldName, final char value) { + lineBreak(); + builder.append(QUOTE).append(fieldName).append(QUOTE + ASSIGN).append((int) value); + hasFieldBefore = true; + } + + @Override + public void put(final String fieldName, final char[] values, final int n) { + lineBreak(); + builder.append(QUOTE).append(fieldName).append(QUOTE + ASSIGN + '['); + if (values == null || values.length <= 0) { + builder.append(']'); + return; + } + builder.append((int) values[0]); + final int valuesSize = values.length; + final int nElements = n >= 0 ? Math.min(n, valuesSize) : valuesSize; + for (int i = 1; i < nElements; i++) { + builder.append(", ").append((int) values[i]); + } + builder.append(']'); + hasFieldBefore = true; + } + + @Override + public void put(final String fieldName, final char[] values, final int[] dims) { + put(fieldName, values, getNumberElements(dims)); + } + + @Override + public void put(final String fieldName, final double value) { + lineBreak(); + builder.append(QUOTE).append(fieldName).append(QUOTE + ASSIGN).append(value); + hasFieldBefore = true; + } + + @Override + public void put(final String fieldName, final double[] values, final int n) { + lineBreak(); + builder.append(QUOTE).append(fieldName).append(QUOTE + ASSIGN + '['); + if (values == null || values.length <= 0) { + builder.append(']'); + return; + } + builder.append(values[0]); + final int valuesSize = values.length; + final int nElements = n >= 0 ? Math.min(n, valuesSize) : valuesSize; + for (int i = 1; i < nElements; i++) { + builder.append(", ").append(values[i]); + } + builder.append(']'); + hasFieldBefore = true; + } + + @Override + public void put(final String fieldName, final double[] values, final int[] dims) { + put(fieldName, values, getNumberElements(dims)); + } + + @Override + public void put(final String fieldName, final float value) { + lineBreak(); + builder.append(QUOTE).append(fieldName).append(QUOTE + ASSIGN).append(value); + hasFieldBefore = true; + } + + @Override + public void put(final String fieldName, final float[] values, final int n) { + lineBreak(); + builder.append(QUOTE).append(fieldName).append(QUOTE).append(ASSIGN).append('['); + if (values == null || values.length <= 0) { + builder.append(']'); + return; + } + builder.append(values[0]); + final int valuesSize = values.length; + final int nElements = n >= 0 ? Math.min(n, valuesSize) : valuesSize; + for (int i = 1; i < nElements; i++) { + builder.append(", ").append(values[i]); + } + builder.append(']'); + hasFieldBefore = true; + } + + @Override + public void put(final String fieldName, final float[] values, final int[] dims) { + put(fieldName, values, getNumberElements(dims)); + } + + @Override + public void put(final String fieldName, final int value) { + lineBreak(); + builder.append(QUOTE).append(fieldName).append(QUOTE + ASSIGN).append(value); + hasFieldBefore = true; + } + + @Override + public void put(final String fieldName, final int[] values, final int n) { + lineBreak(); + builder.append(QUOTE).append(fieldName).append(QUOTE + ASSIGN).append("["); + if (values == null || values.length <= 0) { + builder.append(']'); + return; + } + builder.append(values[0]); + final int valuesSize = values.length; + final int nElements = n >= 0 ? Math.min(n, valuesSize) : valuesSize; + for (int i = 1; i < nElements; i++) { + builder.append(", ").append(values[i]); + } + builder.append(']'); + hasFieldBefore = true; + } + + @Override + public void put(final String fieldName, final int[] values, final int[] dims) { + put(fieldName, values, getNumberElements(dims)); + } + + @Override + public void put(final String fieldName, final long value) { + lineBreak(); + builder.append(QUOTE).append(fieldName).append(QUOTE + ASSIGN).append(value); + hasFieldBefore = true; + } + + @Override + public void put(final String fieldName, final long[] values, final int n) { + lineBreak(); + builder.append(QUOTE).append(fieldName).append(QUOTE + ASSIGN).append("["); + if (values == null || values.length <= 0) { + builder.append(']'); + return; + } + builder.append(values[0]); + final int valuesSize = values.length; + final int nElements = n >= 0 ? Math.min(n, valuesSize) : valuesSize; + for (int i = 1; i < nElements; i++) { + builder.append(", ").append(values[i]); + } + builder.append(']'); + hasFieldBefore = true; + } + + @Override + public void put(final String fieldName, final long[] values, final int[] dims) { + put(fieldName, values, getNumberElements(dims)); + } + + @Override + public void put(final String fieldName, final short value) { + lineBreak(); + builder.append(QUOTE).append(fieldName).append(QUOTE + ASSIGN).append(value); + hasFieldBefore = true; + } + + @Override + public void put(final String fieldName, final short[] values, final int n) { + lineBreak(); + builder.append(QUOTE).append(fieldName).append(QUOTE + ASSIGN).append("["); + if (values == null || values.length <= 0) { + builder.append(']'); + return; + } + builder.append(values[0]); + final int valuesSize = values.length; + final int nElements = n >= 0 ? Math.min(n, valuesSize) : valuesSize; + for (int i = 1; i < nElements; i++) { + builder.append(", ").append(values[i]); + } + builder.append(']'); + hasFieldBefore = true; + } + + @Override + public void put(final String fieldName, final short[] values, final int[] dims) { + put(fieldName, values, getNumberElements(dims)); + } + + @Override + public void put(final String fieldName, final String string) { + lineBreak(); + builder.append(QUOTE).append(fieldName).append("\": \"").append(string).append(QUOTE); + hasFieldBefore = true; + } + + @Override + public void put(final String fieldName, final String[] values, final int n) { + lineBreak(); + builder.append(QUOTE).append(fieldName).append(QUOTE + ASSIGN).append("["); + if (values == null || values.length <= 0) { + builder.append(']'); + return; + } + builder.append(QUOTE).append(values[0]).append(QUOTE); + final int valuesSize = values.length; + final int nElements = n >= 0 ? Math.min(n, valuesSize) : valuesSize; + for (int i = 1; i < nElements; i++) { + builder.append(", \"").append(values[i]).append(QUOTE); + } + builder.append(']'); + hasFieldBefore = true; + } + + @Override + public void put(final String fieldName, final String[] values, final int[] dims) { + put(fieldName, values, getNumberElements(dims)); + } + + @Override + public int putArraySizeDescriptor(final int n) { + return 0; + } + + @Override + public int putArraySizeDescriptor(final int[] dims) { + return 0; + } + + @Override + public WireDataFieldDescription putCustomData(final FieldDescription fieldDescription, final E obj, final Class type, final FieldSerialiser serialiser) { + return null; + } + + @Override + public void putEndMarker(final FieldDescription fieldDescription) { + indentation = indentation.substring(0, Math.max(indentation.length() - DEFAULT_INDENTATION, 0)); + builder.append(LINE_BREAK).append(indentation).append(BRACKET_CLOSE).append(LINE_BREAK); + hasFieldBefore = true; + final byte[] outputStrBytes = builder.toString().getBytes(StandardCharsets.UTF_8); + buffer.ensureAdditionalCapacity(outputStrBytes.length); + System.arraycopy(outputStrBytes, 0, buffer.elements(), buffer.position(), outputStrBytes.length); + buffer.position(buffer.position() + outputStrBytes.length); + builder.setLength(0); + } + + @Override + public WireDataFieldDescription putFieldHeader(final FieldDescription fieldDescription) { + return putFieldHeader(fieldDescription.getFieldName(), fieldDescription.getDataType()); + } + + @Override + public WireDataFieldDescription putFieldHeader(final String fieldName, final DataType dataType) { + lastFieldHeader = new WireDataFieldDescription(this, parent, fieldName.hashCode(), fieldName, dataType, -1, 1, -1); + queryFieldName = fieldName; + return lastFieldHeader; + } + + @Override + public void putHeaderInfo(final FieldDescription... field) { + if (builder.length() > 0) { + final byte[] outputStrBytes = builder.toString().getBytes(StandardCharsets.UTF_8); + buffer.ensureAdditionalCapacity(outputStrBytes.length); + System.arraycopy(outputStrBytes, 0, buffer.elements(), buffer.position(), outputStrBytes.length); + buffer.position(buffer.position() + outputStrBytes.length); + } else { + hasFieldBefore = false; + indentation = ""; + } + builder.setLength(0); + putStartMarker(null); + } + + @Override + public void putStartMarker(final FieldDescription fieldDescription) { + lineBreak(); + if (fieldDescription != null) { + builder.append(QUOTE).append(fieldDescription.getFieldName()).append(QUOTE + ASSIGN); + } + builder.append(BRACKET_OPEN); + indentation = indentation + " ".repeat(DEFAULT_INDENTATION); + builder.append(LINE_BREAK); + builder.append(indentation); + hasFieldBefore = false; + } + + public void serialiseObject(final Object obj) { + if (builder.length() > 0) { + final byte[] outputStrBytes = builder.toString().getBytes(StandardCharsets.UTF_8); + buffer.ensureAdditionalCapacity(outputStrBytes.length); + System.arraycopy(outputStrBytes, 0, buffer.elements(), buffer.position(), outputStrBytes.length); + buffer.position(buffer.position() + outputStrBytes.length); + builder.setLength(0); + } else { + hasFieldBefore = false; + indentation = ""; + } + if (obj == null) { + // serialise null object + builder.append(NULL); + byte[] bytes = builder.toString().getBytes(Charset.defaultCharset()); + System.arraycopy(bytes, 0, buffer.elements(), buffer.position(), bytes.length); + buffer.position(buffer.position() + bytes.length); + builder.setLength(0); + return; + } + try (ByteBufferOutputStream byteOutputStream = new ByteBufferOutputStream(java.nio.ByteBuffer.wrap(buffer.elements()), false)) { + byteOutputStream.position(buffer.position()); + JsonStream.serialize(obj, byteOutputStream); + buffer.position(byteOutputStream.position()); + } catch (IOException e) { + throw new IllegalStateException(NOT_A_JSON_COMPATIBLE_PROTOCOL, e); + } + } + + @Override + public void setBuffer(final IoBuffer buffer) { + this.buffer = buffer; + } + + @Override + public void setPutFieldMetaData(final boolean putFieldMetaData) { + // json does not support metadata + } + + @Override + public void setQueryFieldName(final String fieldName, final int dataStartPosition) { + if (fieldName == null || fieldName.isBlank()) { + throw new IllegalArgumentException("fieldName must not be null or blank: " + fieldName); + } + if (root == null) { + throw new IllegalArgumentException("JSON Any root hasn't been analysed/parsed yet"); + } + this.queryFieldName = fieldName; + // buffer.position(dataStartPosition); // N.B. not needed at this time + } + + @Override + public void updateDataEndMarker(final WireDataFieldDescription fieldHeader) { + // not needed + } + + private int getNumberElements(final int[] dims) { + int n = 1; + for (final int dim : dims) { + n *= dim; + } + return n; + } + + private void lineBreak() { + if (hasFieldBefore) { + builder.append(','); + builder.append(LINE_BREAK); + builder.append(indentation); + } + } + + private void parseIoStream(final WireDataFieldDescription fieldRoot, final Any any, final String fieldName) { + if (!(any.object() instanceof Map) || any.size() == 0) { + return; + } + + final Map map = any.asMap(); + final WireDataFieldDescription putStartMarker = new WireDataFieldDescription(this, fieldRoot, fieldName.hashCode(), fieldName, DataType.START_MARKER, 0, -1, -1); + for (Map.Entry child : map.entrySet()) { + final String childName = child.getKey(); + final Any childAny = map.get(childName); + final Object data = childAny.object(); + if (data instanceof Map) { + parseIoStream(putStartMarker, childAny, childName); + } else if (data != null) { + new WireDataFieldDescription(this, putStartMarker, childName.hashCode(), childName, DataType.fromClassType(data.getClass()), 0, -1, -1); // NOPMD - necessary to allocate inside loop + } + } + // add if necessary: + // new WireDataFieldDescription(this, fieldRoot, fieldName.hashCode(), fieldName, DataType.END_MARKER, 0, -1, -1) + } + + @Override + public void setFieldSerialiserLookupFunction(final BiFunction> serialiserLookupFunction) { + this.fieldSerialiserLookupFunction = serialiserLookupFunction; + } + + @Override + public BiFunction> getSerialiserLookupFunction() { + return fieldSerialiserLookupFunction; + } +} diff --git a/serialiser/src/main/java/io/opencmw/serialiser/spi/ProtocolInfo.java b/serialiser/src/main/java/io/opencmw/serialiser/spi/ProtocolInfo.java new file mode 100644 index 00000000..ef2515f1 --- /dev/null +++ b/serialiser/src/main/java/io/opencmw/serialiser/spi/ProtocolInfo.java @@ -0,0 +1,65 @@ +package io.opencmw.serialiser.spi; + +import io.opencmw.serialiser.IoSerialiser; + +public class ProtocolInfo extends WireDataFieldDescription { + private final WireDataFieldDescription fieldHeader; + private final String producerName; + private final byte versionMajor; + private final byte versionMinor; + private final byte versionMicro; + + public ProtocolInfo(final IoSerialiser source, final WireDataFieldDescription fieldDescription, final String producer, final byte major, final byte minor, final byte micro) { + super(source, null, fieldDescription.hashCode(), fieldDescription.getFieldName(), fieldDescription.getDataType(), fieldDescription.getFieldStart(), fieldDescription.getDataStartOffset(), fieldDescription.getDataSize()); + this.fieldHeader = fieldDescription; + producerName = producer; + versionMajor = major; + versionMinor = minor; + versionMicro = micro; + } + + @Override + public boolean equals(final Object obj) { + if (!(obj instanceof ProtocolInfo)) { + return false; + } + final ProtocolInfo other = (ProtocolInfo) obj; + return other.isCompatible(); + } + + public WireDataFieldDescription getFieldHeader() { + return fieldHeader; + } + + public String getProducerName() { + return producerName; + } + + public byte getVersionMajor() { + return versionMajor; + } + + public byte getVersionMicro() { + return versionMicro; + } + + public byte getVersionMinor() { + return versionMinor; + } + + @Override + public int hashCode() { + return producerName.hashCode(); + } + + public boolean isCompatible() { + // N.B. no API changes within the same 'major.minor'- version + // micro.version tracks possible benin additions & internal bug-fixes + return getVersionMajor() <= BinarySerialiser.VERSION_MAJOR && getVersionMinor() <= BinarySerialiser.VERSION_MINOR; + } + + @Override + public String toString() { + return super.toString() + String.format(" serialiser: %s-v%d.%d.%d", getProducerName(), getVersionMajor(), getVersionMinor(), getVersionMicro()); + } +} diff --git a/serialiser/src/main/java/io/opencmw/serialiser/spi/WireDataFieldDescription.java b/serialiser/src/main/java/io/opencmw/serialiser/spi/WireDataFieldDescription.java new file mode 100644 index 00000000..7cc8ee9f --- /dev/null +++ b/serialiser/src/main/java/io/opencmw/serialiser/spi/WireDataFieldDescription.java @@ -0,0 +1,314 @@ +package io.opencmw.serialiser.spi; + +import java.nio.CharBuffer; +import java.util.ArrayList; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.opencmw.serialiser.DataType; +import io.opencmw.serialiser.FieldDescription; +import io.opencmw.serialiser.IoSerialiser; +import io.opencmw.serialiser.utils.ClassUtils; + +/** + * Field header descriptor + * + * @author rstein + */ +public class WireDataFieldDescription implements FieldDescription { + private static final Logger LOGGER = LoggerFactory.getLogger(WireDataFieldDescription.class); + private final String fieldName; + private final int fieldNameHashCode; + private final DataType dataType; + private final List children = new ArrayList<>(); + private final FieldDescription parent; + private final int fieldStart; + private final int fieldDataStart; + private final int dataStartOffset; + // local references to source buffer needed for parsing + private final IoSerialiser ioSerialiser; + private String fieldUnit; + private String fieldDescription; + private String fieldDirection; + private List fieldGroups; + private int dataSize; + + /** + * Constructs new serializer field header + * + * @param source the referenced IoBuffer (if any) + * @param parent the optional parent field header (for cascaded objects) + * @param fieldNameHashCode the fairly-unique hash-code of the field name, + * N.B. checked during 1st iteration against fieldName, if no collisions are present then + * this check is being suppressed + * @param fieldName the clear text field name description + * @param dataType the data type of that field + * @param fieldStart the absolute buffer position from which the field header can be parsed + * @param dataStartOffset the position from which the actual data can be parsed onwards + * @param dataSize the expected number of bytes to skip the data block + */ + public WireDataFieldDescription(final IoSerialiser source, final FieldDescription parent, final int fieldNameHashCode, final String fieldName, final DataType dataType, // + final int fieldStart, final int dataStartOffset, final int dataSize) { + ioSerialiser = source; + this.parent = parent; + this.fieldNameHashCode = fieldNameHashCode; + this.fieldName = fieldName; + this.dataType = dataType; + this.fieldStart = fieldStart; + this.dataStartOffset = dataStartOffset; + this.dataSize = dataSize; + this.fieldDataStart = fieldStart + dataStartOffset; + + if (this.parent != null /*&& !this.parent.getChildren().contains(this)*/) { + this.parent.getChildren().add(this); + } + } + + @Override + public boolean equals(final Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof FieldDescription)) { + return false; + } + FieldDescription other = (FieldDescription) obj; + if (this.getFieldNameHashCode() != other.getFieldNameHashCode()) { + return false; + } + + if (this.getDataType() != other.getDataType()) { + return false; + } + + return this.getFieldName().equals(other.getFieldName()); + } + + @Override + public FieldDescription findChildField(final String fieldName) { + return findChildField(fieldName.hashCode(), fieldName); + } + + @Override + public FieldDescription findChildField(final int fieldNameHashCode, final String fieldName) { + for (final FieldDescription field : children) { //NOSONAR + final String name = field.getFieldName(); + if (name == fieldName) { // NOSONAR NOPMD early return if the same String object reference + return field; + } + if (field.hashCode() == fieldNameHashCode && name.equals(fieldName)) { + return field; + } + } + return null; + } + + @Override + public List getChildren() { + return children; + } + + @Override + public int getDataSize() { + return dataSize; + } + + public void setDataSize(final int size) { + dataSize = size; + } + + @Override + public int getDataStartOffset() { + return dataStartOffset; + } + + @Override + public int getDataStartPosition() { + return fieldDataStart; + } + + @Override + public DataType getDataType() { + return dataType; + } + + @Override + public String getFieldDescription() { + return fieldDescription; + } + + public void setFieldDescription(final String fieldDescription) { + this.fieldDescription = fieldDescription; + } + + @Override + public String getFieldDirection() { + return fieldDirection; + } + + public void setFieldDirection(final String fieldDirection) { + this.fieldDirection = fieldDirection; + } + + @Override + public List getFieldGroups() { + return fieldGroups; + } + + public void setFieldGroups(final List fieldGroups) { + this.fieldGroups = fieldGroups; + } + + @Override + public String getFieldName() { + return fieldName; + } + + @Override + public int getFieldNameHashCode() { + return fieldNameHashCode; + } + + @Override + public int getFieldStart() { + return fieldStart; + } + + @Override + public String getFieldUnit() { + return fieldUnit; + } + + public void setFieldUnit(final String fieldUnit) { + this.fieldUnit = fieldUnit; + } + + /** + * @return raw ioSerialiser reference this field was retrieved with the position in the underlying IoBuffer at the to be read field + * N.B. this is a safe convenience method and not performance optimised + * @param overwriteType optional DataType as which the data should be interpreted + */ + public Object data(DataType... overwriteType) { + ioSerialiser.setQueryFieldName(fieldName, fieldDataStart); + switch (overwriteType.length == 0 ? this.dataType : overwriteType[0]) { + case START_MARKER: + case END_MARKER: + return null; + case BOOL: + return ioSerialiser.getBoolean(); + case BYTE: + return ioSerialiser.getByte(); + case SHORT: + return ioSerialiser.getShort(); + case INT: + return ioSerialiser.getInt(); + case LONG: + return ioSerialiser.getLong(); + case FLOAT: + return ioSerialiser.getFloat(); + case DOUBLE: + return ioSerialiser.getDouble(); + case CHAR: + return ioSerialiser.getChar(); + case STRING: + return ioSerialiser.getString(); + case BOOL_ARRAY: + return ioSerialiser.getBooleanArray(); + case BYTE_ARRAY: + return ioSerialiser.getByteArray(); + case SHORT_ARRAY: + return ioSerialiser.getShortArray(); + case INT_ARRAY: + return ioSerialiser.getIntArray(); + case LONG_ARRAY: + return ioSerialiser.getLongArray(); + case FLOAT_ARRAY: + return ioSerialiser.getFloatArray(); + case DOUBLE_ARRAY: + return ioSerialiser.getDoubleArray(); + case CHAR_ARRAY: + return ioSerialiser.getCharArray(); + case STRING_ARRAY: + return ioSerialiser.getStringArray(); + case ENUM: + return ioSerialiser.getEnum(null); + case LIST: + return ioSerialiser.getList(null); + case MAP: + return ioSerialiser.getMap(null); + case QUEUE: + return ioSerialiser.getQueue(null); + case SET: + return ioSerialiser.getSet(null); + case COLLECTION: + return ioSerialiser.getCollection(null); + case OTHER: + return ioSerialiser.getCustomData(null); + default: + throw new IllegalStateException("unknown dataType = " + dataType); + } + } + + /** + * @return raw ioSerialiser reference this field was retrieved from w/o changing the position in the underlying IoBuffer + */ + public IoSerialiser getIoSerialiser() { + return ioSerialiser; + } + + @Override + public FieldDescription getParent() { + return parent; + } + + @Override + public Class getType() { + return dataType.getClassTypes().get(0); + } + + @Override + public int hashCode() { + return fieldNameHashCode; + } + + @Override + public boolean isAnnotationPresent() { + return fieldUnit != null || fieldDescription != null || fieldDirection != null || (fieldGroups != null && !fieldGroups.isEmpty()); + } + + @Override + public void printFieldStructure() { + if (parent == null) { + LOGGER.atInfo().log("FielHeader structure (no parent):"); + } else { + LOGGER.atInfo().addArgument(parent).log("FielHeader structure (parent: {}):"); + printFieldStructure(this, 0); + } + printFieldStructure(this, 0); + } + + @Override + public String toString() { + return String.format("[fieldName=%s, fieldType=%s]", fieldName, dataType.getAsString()); + } + + protected static void printFieldStructure(final FieldDescription field, final int recursionLevel) { + final String mspace = spaces((recursionLevel) *ClassUtils.getIndentationNumberOfSpace()); + LOGGER.atInfo().addArgument(mspace).addArgument(field.toString()).log("{}{}"); + if (field.isAnnotationPresent()) { + LOGGER.atInfo().addArgument(mspace) // + .addArgument(field.getFieldUnit()) + .addArgument(field.getFieldDescription()) + .addArgument(field.getFieldDirection()) + .addArgument(field.getFieldGroups()) + .log("{} "); + } + field.getChildren().forEach(f -> printFieldStructure(f, recursionLevel + 1)); + } + + private static String spaces(final int spaces) { + return CharBuffer.allocate(spaces).toString().replace('\0', ' '); + } +} \ No newline at end of file diff --git a/serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/DataSetSerialiser.java b/serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/DataSetSerialiser.java new file mode 100644 index 00000000..0768e371 --- /dev/null +++ b/serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/DataSetSerialiser.java @@ -0,0 +1,518 @@ +package io.opencmw.serialiser.spi.iobuffer; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.InputMismatchException; +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.opencmw.serialiser.DataType; +import io.opencmw.serialiser.FieldDescription; +import io.opencmw.serialiser.IoSerialiser; +import io.opencmw.serialiser.spi.WireDataFieldDescription; +import io.opencmw.serialiser.utils.AssertUtils; +import io.opencmw.serialiser.utils.GenericsHelper; + +import de.gsi.dataset.AxisDescription; +import de.gsi.dataset.DataSet; +import de.gsi.dataset.DataSetError; +import de.gsi.dataset.DataSetMetaData; +import de.gsi.dataset.GridDataSet; +import de.gsi.dataset.spi.AbstractDataSet; +import de.gsi.dataset.spi.DataSetBuilder; +import de.gsi.dataset.spi.utils.MathUtils; +import de.gsi.dataset.spi.utils.StringHashMapList; + +/** + * Class to efficiently serialise and de-serialise DataSet objects into binary byte arrays. The performance can be tuned + * through: + *

    + *
  • using floats (ie. memory-IO vs network-IO bound serialisation), or
  • + *
  • via {@link #setDataLablesSerialised(boolean)} (default: true) to control whether data labels and styles shall be processed
  • + *
  • via {@link #setMetaDataSerialised(boolean)} (default: true) to control whether meta data shall be processed
  • + *
+ * + * @author rstein + */ +public class DataSetSerialiser { // NOPMD + private static final Logger LOGGER = LoggerFactory.getLogger(DataSetSerialiser.class); + private static final String DATA_SET_NAME = "dataSetName"; + private static final String DIMENSIONS = "nDims"; + private static final String ARRAY_PREFIX = "array"; + private static final String EN_PREFIX = "en"; + private static final String EP_PREFIX = "ep"; + private static final String AXIS = "axis"; + private static final String NAME = "name"; + private static final String UNIT = "unit"; + private static final String MIN = "Min"; + private static final String MAX = "Max"; + private static final String META_INFO = "metaInfo"; + private static final String ERROR_LIST = "errorList"; + private static final String WARNING_LIST = "warningList"; + private static final String INFO_LIST = "infoList"; + private static final String DATA_STYLES = "dataStyles"; + private static final String DATA_LABELS = "dataLabels"; + private final IoSerialiser ioSerialiser; + private boolean transmitDataLabels = true; + private boolean transmitMetaData = true; + + private DataSetSerialiser(final IoSerialiser ioSerialiser) { + this.ioSerialiser = ioSerialiser; + } + + public boolean isDataLablesSerialised() { + return transmitDataLabels; + } + + public boolean isMetaDataSerialised() { + return transmitMetaData; + } + + /** + * Read a Dataset from a byte array containing comma separated values.
+ * The data format is a custom extension of csv with an additional #-commented Metadata Header and a $-commented + * column header. Expects the following columns in this order to be present: index, x, y, eyn, eyp. + * + * @return DataSet with the data and metadata read from the file + */ + public DataSet read() { // NOPMD + return read(null); + } + + /** + * Read a Dataset from a byte array containing comma separated values.
+ * The data format is a custom extension of csv with an additional #-commented Metadata Header and a $-commented + * column header. Expects the following columns in this order to be present: index, x, y, eyn, eyp. + * + * @param dataSet inplace DataSet that is being overwritten if non-null and {@link DataSet#set(DataSet, boolean)} is implemented + * @return DataSet with the data and metadata read from the file + */ + public DataSet read(final DataSet dataSet) { // NOPMD + final DataSetBuilder builder = new DataSetBuilder(); + FieldDescription root = ioSerialiser.parseIoStream(false); + final FieldDescription fieldRoot = root.getChildren().get(0); + // parsed until end of buffer + + parseHeaders(ioSerialiser, builder, fieldRoot); + + if (isMetaDataSerialised()) { + parseMetaData(ioSerialiser, builder, fieldRoot); + } + + if (isDataLablesSerialised()) { + parseDataLabels(builder, fieldRoot); + } + + parseNumericData(ioSerialiser, builder, dataSet, fieldRoot); + + if (root.getChildren().size() != 2) { + throw new IllegalArgumentException("fieldRoot children-count != 2: " + fieldRoot.getChildren().size()); + } + final FieldDescription endMarker = root.getChildren().get(1); + if (endMarker.getDataType() != DataType.END_MARKER) { + throw new IllegalArgumentException("fieldRoot END_MARKER expected but found: " + endMarker); + } + // move read position to after end marker + ioSerialiser.getBuffer().position(endMarker.getDataStartPosition()); + ioSerialiser.updateDataEndMarker((WireDataFieldDescription) endMarker); + if (dataSet == null) { + return builder.build(); + } + + // in-place update preserving existing listener, N.B: 'false' is important <-> inplace copy + return dataSet.set(builder.build(), false); + } + + public DataSetSerialiser setDataLablesSerialised(final boolean state) { + transmitDataLabels = state; + return this; + } + + public DataSetSerialiser setMetaDataSerialised(final boolean state) { + transmitMetaData = state; + return this; + } + + /** + * Write data set into byte buffer. + * + * @param dataSet The DataSet to export + * @param asFloat {@code true}: encode data as binary floats (smaller size, performance), or {@code false} as double + * (better precision) + */ + public void write(final DataSet dataSet, final boolean asFloat) { + AssertUtils.notNull("dataSet", dataSet); + AssertUtils.notNull("ioSerialiser", ioSerialiser); + final String dataStartMarkerName = "START_MARKER_DATASET:" + dataSet.getName(); + final WireDataFieldDescription dataStartMarker = new WireDataFieldDescription(ioSerialiser, null, dataStartMarkerName.hashCode(), dataStartMarkerName, DataType.OTHER, -1, -1, -1); + ioSerialiser.putStartMarker(dataStartMarker); + + writeHeaderDataToStream(dataSet); + + if (isMetaDataSerialised()) { + writeMetaDataToStream(dataSet); + } + + if (isDataLablesSerialised()) { + writeDataLabelsToStream(dataSet); + } + + if (asFloat) { + writeNumericBinaryDataToBufferFloat(dataSet); + + } else { + writeNumericBinaryDataToBufferDouble(dataSet); + } + + final String dataEndMarkerName = "END_MARKER_DATASET:" + dataSet.getName(); + final WireDataFieldDescription dataEndMarker = new WireDataFieldDescription(ioSerialiser, null, dataEndMarkerName.hashCode(), dataEndMarkerName, DataType.START_MARKER, -1, -1, -1); + ioSerialiser.putEndMarker(dataEndMarker); + } + + protected FieldDescription checkFieldCompatibility(final FieldDescription rootField, final int fieldNameHashCode, final String fieldName, final DataType... requireDataTypes) { + FieldDescription fieldHeader = rootField.findChildField(fieldNameHashCode, fieldName); + if (fieldHeader == null) { + return null; + } + + boolean foundMatchingDataType = false; + for (DataType dataType : requireDataTypes) { + if (fieldHeader.getDataType().equals(dataType)) { + foundMatchingDataType = true; + break; + } + } + if (!foundMatchingDataType) { + throw new InputMismatchException(fieldName + " is type " + fieldHeader.getDataType() + " vs. required type " + Arrays.asList(requireDataTypes).toString()); + } + + ioSerialiser.getBuffer().position(fieldHeader.getDataStartPosition()); + return fieldHeader; + } + + protected static int getDimIndex(String fieldName, String prefix) { + try { + return Integer.parseInt(fieldName.substring(prefix.length())); + } catch (NumberFormatException | IndexOutOfBoundsException e) { + LOGGER.atWarn().addArgument(fieldName).log("Invalid field name: {}"); + return -1; + } + } + + protected static double[] getDoubleArray(final IoSerialiser ioSerialiser, final double[] origArray, final DataType dataType) { + switch (dataType) { + case BOOL_ARRAY: + return GenericsHelper.toDoublePrimitive(ioSerialiser.getBooleanArray()); + case BYTE_ARRAY: + return GenericsHelper.toDoublePrimitive(ioSerialiser.getByteArray()); + case SHORT_ARRAY: + return GenericsHelper.toDoublePrimitive(ioSerialiser.getShortArray()); + case INT_ARRAY: + return GenericsHelper.toDoublePrimitive(ioSerialiser.getIntArray()); + case LONG_ARRAY: + return GenericsHelper.toDoublePrimitive(ioSerialiser.getLongArray()); + case FLOAT_ARRAY: + return GenericsHelper.toDoublePrimitive(ioSerialiser.getFloatArray()); + case DOUBLE_ARRAY: + return ioSerialiser.getDoubleArray(origArray); + case CHAR_ARRAY: + return GenericsHelper.toDoublePrimitive(ioSerialiser.getCharArray()); + case STRING_ARRAY: + return GenericsHelper.toDoublePrimitive(ioSerialiser.getStringArray()); + default: + throw new IllegalArgumentException("dataType '" + dataType + "' is not an array"); + } + } + + protected void parseDataLabels(final DataSetBuilder builder, final FieldDescription fieldRoot) { + if (checkFieldCompatibility(fieldRoot, DATA_LABELS.hashCode(), DATA_LABELS, DataType.MAP) != null) { + Map map = new HashMap<>(); // NOPMD - thread-safe usage + map = ioSerialiser.getMap(map); + builder.setDataLabelMap(map); + } + + if (checkFieldCompatibility(fieldRoot, DATA_STYLES.hashCode(), DATA_STYLES, DataType.MAP) != null) { + Map map = new HashMap<>(); // NOPMD - thread-safe usage + map = ioSerialiser.getMap(map); + builder.setDataStyleMap(map); + } + } + + protected void parseHeaders(final IoSerialiser ioSerialiser, final DataSetBuilder builder, final FieldDescription fieldRoot) { + // read strings + if (checkFieldCompatibility(fieldRoot, DATA_SET_NAME.hashCode(), DATA_SET_NAME, DataType.STRING) != null) { + builder.setName(ioSerialiser.getBuffer().getString()); + } + + if (checkFieldCompatibility(fieldRoot, DIMENSIONS.hashCode(), DIMENSIONS, DataType.INT) != null) { + builder.setDimension(ioSerialiser.getBuffer().getInt()); + } + + // check for axis descriptions (all fields starting with AXIS) + for (FieldDescription fieldDescription : fieldRoot.getChildren()) { + parseHeader(ioSerialiser, builder, fieldDescription); + } + } + + protected void parseMetaData(final IoSerialiser ioSerialiser, final DataSetBuilder builder, final FieldDescription rootField) { + if (checkFieldCompatibility(rootField, INFO_LIST.hashCode(), INFO_LIST, DataType.STRING_ARRAY) != null) { + builder.setMetaInfoList(ioSerialiser.getStringArray()); + } + + if (checkFieldCompatibility(rootField, WARNING_LIST.hashCode(), WARNING_LIST, DataType.STRING_ARRAY) != null) { + builder.setMetaWarningList(ioSerialiser.getStringArray()); + } + + if (checkFieldCompatibility(rootField, ERROR_LIST.hashCode(), ERROR_LIST, DataType.STRING_ARRAY) != null) { + builder.setMetaErrorList(ioSerialiser.getStringArray()); + } + + if (checkFieldCompatibility(rootField, META_INFO.hashCode(), META_INFO, DataType.MAP) != null) { + Map map = new HashMap<>(); // NOPMD - thread-safe usage + map = ioSerialiser.getMap(map); + builder.setMetaInfoMap(map); + } + } + + protected void parseNumericData(final IoSerialiser ioSerialiser, final DataSetBuilder builder, final DataSet origDataSet, final FieldDescription rootField) { + // check for numeric data + for (FieldDescription fieldDescription : rootField.getChildren()) { + final String fieldName = fieldDescription.getFieldName(); + if (fieldName == null || (fieldDescription.getDataType() != DataType.DOUBLE_ARRAY && fieldDescription.getDataType() != DataType.FLOAT_ARRAY)) { + continue; + } + if (fieldName.startsWith(ARRAY_PREFIX)) { + readValues(ioSerialiser, builder, origDataSet, fieldDescription, fieldName); + } else if (fieldName.startsWith(EP_PREFIX)) { + readPosError(ioSerialiser, builder, origDataSet, fieldDescription, fieldName); + } else if (fieldName.startsWith(EN_PREFIX)) { + readNegError(ioSerialiser, builder, origDataSet, fieldDescription, fieldName); + } + } + } + + @SuppressWarnings("PMD.NPathComplexity") + protected void writeDataLabelsToStream(final DataSet dataSet) { + if (dataSet instanceof AbstractDataSet) { + final StringHashMapList labelMap = ((AbstractDataSet) dataSet).getDataLabelMap(); + if (!labelMap.isEmpty()) { + ioSerialiser.put(DATA_LABELS, labelMap, Integer.class, String.class); + } + final StringHashMapList styleMap = ((AbstractDataSet) dataSet).getDataStyleMap(); + if (!styleMap.isEmpty()) { + ioSerialiser.put(DATA_STYLES, styleMap, Integer.class, String.class); + } + return; + } + + final int dataCount = dataSet.getDataCount(); + final Map labelMap = new HashMap<>(); // NOPMD - protected by lock and faster + for (int index = 0; index < dataCount; index++) { + final String label = dataSet.getDataLabel(index); + if ((label != null) && !label.isEmpty()) { + labelMap.put(index, label); + } + } + if (!labelMap.isEmpty()) { + ioSerialiser.put(DATA_LABELS, labelMap, Integer.class, String.class); + } + + final Map styleMap = new HashMap<>(); // NOPMD - protected by lock and faster + for (int index = 0; index < dataCount; index++) { + final String style = dataSet.getStyle(index); + if ((style != null) && !style.isEmpty()) { + styleMap.put(index, style); + } + } + if (!styleMap.isEmpty()) { + ioSerialiser.put(DATA_STYLES, styleMap, Integer.class, String.class); + } + } + + protected void writeHeaderDataToStream(final DataSet dataSet) { + // common header data + ioSerialiser.put(DATA_SET_NAME, dataSet.getName()); + ioSerialiser.put(DIMENSIONS, dataSet.getDimension()); + final List axisDescriptions = dataSet.getAxisDescriptions(); + StringBuilder builder = new StringBuilder(60); + for (int i = 0; i < axisDescriptions.size(); i++) { + builder.setLength(0); + final String prefix = builder.append(AXIS).append(i).append('.').toString(); + builder.setLength(0); + final String name = builder.append(prefix).append(NAME).toString(); + builder.setLength(0); + final String unit = builder.append(prefix).append(UNIT).toString(); + builder.setLength(0); + final String minName = builder.append(prefix).append(MIN).toString(); + builder.setLength(0); + final String maxName = builder.append(prefix).append(MAX).toString(); + + ioSerialiser.put(name, dataSet.getAxisDescription(i).getName()); + ioSerialiser.put(unit, dataSet.getAxisDescription(i).getUnit()); + ioSerialiser.put(minName, dataSet.getAxisDescription(i).getMin()); + ioSerialiser.put(maxName, dataSet.getAxisDescription(i).getMax()); + } + } + + protected void writeMetaDataToStream(final DataSet dataSet) { + if (!(dataSet instanceof DataSetMetaData)) { + return; + } + final DataSetMetaData metaDataSet = (DataSetMetaData) dataSet; + + ioSerialiser.put(INFO_LIST, metaDataSet.getInfoList().toArray(new String[0])); + ioSerialiser.put(WARNING_LIST, metaDataSet.getWarningList().toArray(new String[0])); + ioSerialiser.put(ERROR_LIST, metaDataSet.getErrorList().toArray(new String[0])); + ioSerialiser.put(META_INFO, metaDataSet.getMetaInfo(), String.class, String.class); + } + + /** + * @param dataSet to be exported + */ + protected void writeNumericBinaryDataToBufferDouble(final DataSet dataSet) { + final int nDim = dataSet.getDimension(); + if (dataSet instanceof GridDataSet) { + GridDataSet gridDataSet = (GridDataSet) dataSet; + for (int dimIndex = 0; dimIndex < nDim; dimIndex++) { + final boolean gridDimension = dimIndex < gridDataSet.getNGrid(); + final int nsamples = gridDimension ? gridDataSet.getShape(dimIndex) : dataSet.getDataCount(); + final double[] values = gridDimension ? gridDataSet.getGridValues(dimIndex) : dataSet.getValues(dimIndex); + ioSerialiser.put(ARRAY_PREFIX + dimIndex, values, nsamples); + } + return; // GridDataSet does not provide errors + } + for (int dimIndex = 0; dimIndex < nDim; dimIndex++) { + final int nsamples = dataSet.getDataCount(); + ioSerialiser.put(ARRAY_PREFIX + dimIndex, dataSet.getValues(dimIndex), nsamples); + } + if (!(dataSet instanceof DataSetError)) { + return; // data set does not have any error definition + } + final DataSetError ds = (DataSetError) dataSet; + for (int dimIndex = 0; dimIndex < nDim; dimIndex++) { + final int nsamples = dataSet.getDataCount(); + switch (ds.getErrorType(dimIndex)) { + case SYMMETRIC: + ioSerialiser.put(EP_PREFIX + dimIndex, ds.getErrorsPositive(dimIndex), nsamples); + break; + case ASYMMETRIC: + ioSerialiser.put(EN_PREFIX + dimIndex, ds.getErrorsNegative(dimIndex), nsamples); + ioSerialiser.put(EP_PREFIX + dimIndex, ds.getErrorsPositive(dimIndex), nsamples); + break; + case NO_ERROR: + default: + break; + } + } + } + + /** + * @param dataSet to be exported + */ + protected void writeNumericBinaryDataToBufferFloat(final DataSet dataSet) { + final int nDim = dataSet.getDimension(); + if (dataSet instanceof GridDataSet) { + GridDataSet gridDataSet = (GridDataSet) dataSet; + for (int dimIndex = 0; dimIndex < nDim; dimIndex++) { + final boolean gridDimension = dimIndex < gridDataSet.getNGrid(); + final int nsamples = gridDimension ? gridDataSet.getShape(dimIndex) : dataSet.getDataCount(); + final float[] values = MathUtils.toFloats(gridDimension ? gridDataSet.getGridValues(dimIndex) : dataSet.getValues(dimIndex)); + ioSerialiser.put(ARRAY_PREFIX + dimIndex, values, nsamples); + } + return; // GridDataSet does not provide errors + } + for (int dimIndex = 0; dimIndex < nDim; dimIndex++) { + final int nsamples = dataSet.getDataCount(); + ioSerialiser.put(ARRAY_PREFIX + dimIndex, MathUtils.toFloats(dataSet.getValues(dimIndex)), nsamples); + } + + if (!(dataSet instanceof DataSetError)) { + return; // data set does not have any error definition + } + + final DataSetError ds = (DataSetError) dataSet; + for (int dimIndex = 0; dimIndex < nDim; dimIndex++) { + final int nsamples = dataSet.getDataCount(); + switch (ds.getErrorType(dimIndex)) { + case SYMMETRIC: + ioSerialiser.put(EP_PREFIX + dimIndex, MathUtils.toFloats(ds.getErrorsPositive(dimIndex)), nsamples); + break; + case ASYMMETRIC: + ioSerialiser.put(EN_PREFIX + dimIndex, MathUtils.toFloats(ds.getErrorsNegative(dimIndex)), nsamples); + ioSerialiser.put(EP_PREFIX + dimIndex, MathUtils.toFloats(ds.getErrorsPositive(dimIndex)), nsamples); + break; + case NO_ERROR: + default: + break; + } + } + } + + private void parseHeader(final IoSerialiser ioSerialiser, final DataSetBuilder builder, FieldDescription fieldDescription) { + final String fieldName = fieldDescription.getFieldName(); + if (fieldName == null || !fieldName.startsWith(AXIS)) { + return; // not axis related field + } + final String[] parsed = fieldName.split("\\."); + if (parsed.length <= 1) { + return; // couldn't parse axis field + } + final int dimension = getDimIndex(parsed[0], AXIS); + if (dimension < 0) { + return; // couldn't parse dimIndex + } + ioSerialiser.getBuffer().position(fieldDescription.getDataStartPosition()); + switch (parsed[1]) { + case MIN: + builder.setAxisMin(dimension, ioSerialiser.getBuffer().getDouble()); + break; + case MAX: + builder.setAxisMax(dimension, ioSerialiser.getBuffer().getDouble()); + break; + case NAME: + builder.setAxisName(dimension, ioSerialiser.getBuffer().getString()); + break; + case UNIT: + builder.setAxisUnit(dimension, ioSerialiser.getBuffer().getString()); + break; + default: + LOGGER.atWarn().addArgument(parsed[1]).log("parseHeader(): encountered unknown tag {} - ignore"); + break; + } + } + + private void readNegError(final IoSerialiser ioSerialiser, final DataSetBuilder builder, final DataSet origDataSet, FieldDescription fieldDescription, final String fieldName) { + int dimIndex = getDimIndex(fieldName, EN_PREFIX); + if (dimIndex >= 0) { + ioSerialiser.getBuffer().position(fieldDescription.getDataStartPosition()); + final double[] origErrorArray = (origDataSet instanceof DataSetError) ? ((DataSetError) origDataSet).getErrorsNegative(dimIndex) : null; + builder.setNegErrorNoCopy(dimIndex, getDoubleArray(ioSerialiser, origErrorArray, fieldDescription.getDataType())); + } + } + + private void readPosError(final IoSerialiser ioSerialiser, final DataSetBuilder builder, final DataSet origDataSet, FieldDescription fieldDescription, + final String fieldName) { + int dimIndex = getDimIndex(fieldName, EP_PREFIX); + if (dimIndex >= 0) { + ioSerialiser.getBuffer().position(fieldDescription.getDataStartPosition()); + final double[] origErrorArray = (origDataSet instanceof DataSetError) ? ((DataSetError) origDataSet).getErrorsPositive(dimIndex) : null; + builder.setPosErrorNoCopy(dimIndex, getDoubleArray(ioSerialiser, origErrorArray, fieldDescription.getDataType())); + } + } + + private void readValues(final IoSerialiser ioSerialiser, final DataSetBuilder builder, final DataSet origDataSet, FieldDescription fieldDescription, + final String fieldName) { + int dimIndex = getDimIndex(fieldName, ARRAY_PREFIX); + if (dimIndex >= 0) { + ioSerialiser.getBuffer().position(fieldDescription.getDataStartPosition()); + builder.setValuesNoCopy(dimIndex, getDoubleArray(ioSerialiser, origDataSet == null ? null : origDataSet.getValues(dimIndex), fieldDescription.getDataType())); + } + } + + public static DataSetSerialiser withIoSerialiser(final IoSerialiser ioSerialiser) { + return new DataSetSerialiser(ioSerialiser); + } +} diff --git a/serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/FieldBoxedValueArrayHelper.java b/serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/FieldBoxedValueArrayHelper.java new file mode 100644 index 00000000..c7664724 --- /dev/null +++ b/serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/FieldBoxedValueArrayHelper.java @@ -0,0 +1,75 @@ +package io.opencmw.serialiser.spi.iobuffer; + +import io.opencmw.serialiser.FieldSerialiser; +import io.opencmw.serialiser.IoClassSerialiser; + +import de.gsi.dataset.utils.GenericsHelper; + +/** + * helper class to register default serialiser for boxed array primitive types (ie. Boolean[], Byte[], Short[], ..., + * double[]) w/o String[] (already part of the {@link FieldPrimitiveValueHelper} + * + * @author rstein + */ +public final class FieldBoxedValueArrayHelper { + public static final String NOT_SUPPORTED_FOR_PRIMITIVES = "return function not supported for primitive types"; + + private FieldBoxedValueArrayHelper() { + // utility class + } + + /** + * registers default serialiser for boxed array primitive types (ie. Boolean[], Byte[], Short[], ..., Double[]) + * + * @param serialiser for which the field serialisers should be registered + */ + public static void register(final IoClassSerialiser serialiser) { + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().set(obj, GenericsHelper.toObject(io.getBooleanArray())), // reader + (io, obj, field) -> { throw new UnsupportedOperationException(NOT_SUPPORTED_FOR_PRIMITIVES); }, // return + (io, obj, field) -> io.put(field, GenericsHelper.toBoolPrimitive((Boolean[]) field.getField().get(obj))), // writer + Boolean[].class)); + + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().set(obj, GenericsHelper.toObject(io.getByteArray())), // reader + (io, obj, field) -> { throw new UnsupportedOperationException(NOT_SUPPORTED_FOR_PRIMITIVES); }, // return + (io, obj, field) -> io.put(field, GenericsHelper.toBytePrimitive((Byte[]) field.getField().get(obj))), // writer + Byte[].class)); + + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().set(obj, GenericsHelper.toObject(io.getCharArray())), // reader + (io, obj, field) -> { throw new UnsupportedOperationException(NOT_SUPPORTED_FOR_PRIMITIVES); }, // return + (io, obj, field) -> io.put(field, GenericsHelper.toCharPrimitive((Character[]) field.getField().get(obj))), // writer + Character[].class)); + + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().set(obj, GenericsHelper.toObject(io.getShortArray())), // reader + (io, obj, field) -> { throw new UnsupportedOperationException(NOT_SUPPORTED_FOR_PRIMITIVES); }, // return + (io, obj, field) -> io.put(field, GenericsHelper.toShortPrimitive((Short[]) field.getField().get(obj))), // writer + Short[].class)); + + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().set(obj, GenericsHelper.toObject(io.getIntArray())), // reader + (io, obj, field) -> { throw new UnsupportedOperationException(NOT_SUPPORTED_FOR_PRIMITIVES); }, // return + (io, obj, field) -> io.put(field, GenericsHelper.toIntegerPrimitive((Integer[]) field.getField().get(obj))), // writer + Integer[].class)); + + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().set(obj, GenericsHelper.toObject(io.getLongArray())), // reader + (io, obj, field) -> { throw new UnsupportedOperationException(NOT_SUPPORTED_FOR_PRIMITIVES); }, // return + (io, obj, field) -> io.put(field, GenericsHelper.toLongPrimitive((Long[]) field.getField().get(obj))), // writer + Long[].class)); + + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().set(obj, GenericsHelper.toObject(io.getFloatArray())), // reader + (io, obj, field) -> { throw new UnsupportedOperationException(NOT_SUPPORTED_FOR_PRIMITIVES); }, // return + (io, obj, field) -> io.put(field, GenericsHelper.toFloatPrimitive((Float[]) field.getField().get(obj))), // writer + Float[].class)); + + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().set(obj, GenericsHelper.toObject(io.getDoubleArray())), // reader + (io, obj, field) -> { throw new UnsupportedOperationException(NOT_SUPPORTED_FOR_PRIMITIVES); }, // return + (io, obj, field) -> io.put(field, GenericsHelper.toDoublePrimitive((Double[]) field.getField().get(obj))), // writer + Double[].class)); + } +} diff --git a/serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/FieldBoxedValueHelper.java b/serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/FieldBoxedValueHelper.java new file mode 100644 index 00000000..47eab5b0 --- /dev/null +++ b/serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/FieldBoxedValueHelper.java @@ -0,0 +1,68 @@ +package io.opencmw.serialiser.spi.iobuffer; + +import io.opencmw.serialiser.FieldSerialiser; +import io.opencmw.serialiser.IoClassSerialiser; + +/** + * helper class to register default serialiser for boxed primitive types (ie. Boolean, Byte, Short, ..., double) w/o + * String (already part of the {@link FieldPrimitiveValueHelper} + * + * @author rstein + */ +public final class FieldBoxedValueHelper { + public static final String NOT_SUPPORTED_FOR_PRIMITIVES = "return function not supported for primitive types"; + + private FieldBoxedValueHelper() { + // utility class + } + + /** + * registers default serialiser for primitive array types (ie. boolean[], byte[], short[], ..., double[]) and + * String[] + * + * @param serialiser for which the field serialisers should be registered + */ + public static void register(final IoClassSerialiser serialiser) { + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().set(obj, io.getBoolean()), // reader + (io, obj, field) -> { throw new UnsupportedOperationException(NOT_SUPPORTED_FOR_PRIMITIVES); }, // return + (io, obj, field) -> io.put(field, (Boolean) field.getField().get(obj)), // writer + Boolean.class)); + + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().set(obj, io.getByte()), // reader + (io, obj, field) -> { throw new UnsupportedOperationException(NOT_SUPPORTED_FOR_PRIMITIVES); }, // return + (io, obj, field) -> io.put(field, (Byte) field.getField().get(obj)), // writer + Byte.class)); + + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().set(obj, io.getShort()), // reader + (io, obj, field) -> { throw new UnsupportedOperationException(NOT_SUPPORTED_FOR_PRIMITIVES); }, // return + (io, obj, field) -> io.put(field, (Short) field.getField().get(obj)), // writer + Short.class)); + + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().set(obj, io.getInt()), // reader + (io, obj, field) -> { throw new UnsupportedOperationException(NOT_SUPPORTED_FOR_PRIMITIVES); }, // return + (io, obj, field) -> io.put(field, (Integer) field.getField().get(obj)), // writer + Integer.class)); + + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().set(obj, io.getLong()), // reader + (io, obj, field) -> { throw new UnsupportedOperationException(NOT_SUPPORTED_FOR_PRIMITIVES); }, // return + (io, obj, field) -> io.put(field, (Long) field.getField().get(obj)), // writer + Long.class)); + + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().set(obj, io.getFloat()), // reader + (io, obj, field) -> { throw new UnsupportedOperationException(NOT_SUPPORTED_FOR_PRIMITIVES); }, // return + (io, obj, field) -> io.put(field, (Float) field.getField().get(obj)), // writer + Float.class)); + + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().set(obj, io.getDouble()), // reader + (io, obj, field) -> { throw new UnsupportedOperationException(NOT_SUPPORTED_FOR_PRIMITIVES); }, // return + (io, obj, field) -> io.put(field, (Double) field.getField().get(obj)), // writer + Double.class)); + } +} diff --git a/serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/FieldCollectionsHelper.java b/serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/FieldCollectionsHelper.java new file mode 100644 index 00000000..d117a340 --- /dev/null +++ b/serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/FieldCollectionsHelper.java @@ -0,0 +1,63 @@ +package io.opencmw.serialiser.spi.iobuffer; + +import java.util.Collection; +import java.util.List; +import java.util.Queue; +import java.util.Set; + +import io.opencmw.serialiser.FieldSerialiser; +import io.opencmw.serialiser.IoClassSerialiser; +import io.opencmw.serialiser.utils.ClassUtils; + +public final class FieldCollectionsHelper { + private FieldCollectionsHelper() { + // utility class + } + + /** + * registers default Collection, List, Set, and Queue interface and related helper methods + * + * @param serialiser for which the field serialisers should be registered + */ + @SuppressWarnings("PMD.NPathComplexity") + public static void register(final IoClassSerialiser serialiser) { + // Collection serialiser mapper to IoBuffer + final FieldSerialiser.TriFunction> returnCollection = (io, obj, field) -> // + io.getCollection(field == null ? null : (Collection) field.getField().get(obj)); // return function + final FieldSerialiser.TriFunction> returnList = (io, obj, field) -> // + io.getList(field == null ? null : (List) field.getField().get(obj)); // return function + final FieldSerialiser.TriFunction> returnQueue = (io, obj, field) -> // + io.getQueue(field == null ? null : (Queue) field.getField().get(obj)); // return function + final FieldSerialiser.TriFunction> returnSet = (io, obj, field) -> // + io.getSet(field == null ? null : (Set) field.getField().get(obj)); // return function + + final FieldSerialiser.TriConsumer collectionWriter = (io, obj, field) -> { + if (field != null && !field.getActualTypeArguments().isEmpty() && ClassUtils.isPrimitiveWrapperOrString(ClassUtils.getRawType(field.getActualTypeArguments().get(0)))) { + io.put(field, (Collection) field.getField().get(obj), field.getActualTypeArguments().get(0)); + return; + } + if (field != null) { + // Collection serialiser + io.put(field, (Collection) field.getField().get(obj), field.getActualTypeArguments().get(0)); + return; + } + throw new IllegalArgumentException("serialiser for obj = '" + obj + "' and type = '" + (obj == null ? "null" : obj.getClass()) + "' not yet implemented, field = null"); + }; // writer + + serialiser.addClassDefinition(new FieldSerialiser<>((io, obj, field) -> // + field.getField().set(obj, returnCollection.apply(io, obj, field)), // reader + returnCollection, collectionWriter, Collection.class)); + + serialiser.addClassDefinition(new FieldSerialiser<>((io, obj, field) -> // + field.getField().set(obj, returnList.apply(io, obj, field)), // reader + returnList, collectionWriter, List.class)); + + serialiser.addClassDefinition(new FieldSerialiser<>((io, obj, field) -> // + field.getField().set(obj, returnQueue.apply(io, obj, field)), // reader + returnQueue, collectionWriter, Queue.class)); + + serialiser.addClassDefinition(new FieldSerialiser<>((io, obj, field) -> // + field.getField().set(obj, returnSet.apply(io, obj, field)), // reader + returnSet, collectionWriter, Set.class)); + } +} diff --git a/serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/FieldDataSetHelper.java b/serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/FieldDataSetHelper.java new file mode 100644 index 00000000..e132d440 --- /dev/null +++ b/serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/FieldDataSetHelper.java @@ -0,0 +1,47 @@ +package io.opencmw.serialiser.spi.iobuffer; + +import io.opencmw.serialiser.FieldSerialiser; +import io.opencmw.serialiser.IoClassSerialiser; + +import de.gsi.dataset.DataSet; + +import it.unimi.dsi.fastutil.doubles.DoubleArrayList; + +public final class FieldDataSetHelper { + private FieldDataSetHelper() { + // utility class + } + + /** + * registers default DataSet interface and related helper methods + * + * @param serialiser for which the field serialisers should be registered + */ + public static void register(final IoClassSerialiser serialiser) { + // DoubleArrayList serialiser mapper to IoBuffer + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().set(obj, DoubleArrayList.wrap(io.getDoubleArray())), // reader + (io, obj, field) -> DoubleArrayList.wrap(io.getDoubleArray()), // return + (io, obj, field) -> { + final DoubleArrayList retVal = (DoubleArrayList) field.getField().get(obj); + io.put(field, retVal.elements(), retVal.size()); + }, // writer + DoubleArrayList.class)); + + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> { + // short form: FieldSerialiser.this.getReturnObjectFunction().andThen(io, obj, field) -- not possible inside a constructor + final DataSet origDataSet = (DataSet) field.getField().get(obj); + field.getField().set(obj, DataSetSerialiser.withIoSerialiser(io).read(origDataSet)); + }, // reader + (io, obj, field) -> DataSetSerialiser.withIoSerialiser(io).read(), // return object function + (io, obj, field) -> { + final DataSet origDataSet = (DataSet) (field == null || field.getField() == null ? obj : field.getField().get(obj)); + DataSetSerialiser.withIoSerialiser(io).write(origDataSet, false); + }, // writer + DataSet.class)); + + // List serialiser mapper to IoBuffer + serialiser.addClassDefinition(new FieldListAxisDescription()); + } +} diff --git a/serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/FieldListAxisDescription.java b/serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/FieldListAxisDescription.java new file mode 100644 index 00000000..c4ef6cd2 --- /dev/null +++ b/serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/FieldListAxisDescription.java @@ -0,0 +1,76 @@ +package io.opencmw.serialiser.spi.iobuffer; + +import java.util.ArrayList; +import java.util.List; + +import io.opencmw.serialiser.FieldSerialiser; +import io.opencmw.serialiser.IoSerialiser; +import io.opencmw.serialiser.spi.ClassFieldDescription; + +import de.gsi.dataset.AxisDescription; +import de.gsi.dataset.spi.DefaultAxisDescription; + +/** + * FieldSerialiser implementation for List<AxisDescription> to IoBuffer-backed byte-buffer + * + * @author rstein + */ +public class FieldListAxisDescription extends FieldSerialiser> { + /** + * FieldSerialiser implementation for List<AxisDescription> to IoBuffer-backed byte-ioSerialiser + * + */ + public FieldListAxisDescription() { + super((io, obj, field) -> {}, (io, obj, field) -> null, (io, obj, field) -> {}, List.class, AxisDescription.class); + readerFunction = this::execFieldReader; + returnFunction = this::execFieldReturn; + writerFunction = this::execFieldWriter; + } + + protected void execFieldReader(final IoSerialiser ioSerialiser, final Object obj, ClassFieldDescription field) { + field.getField().set(obj, execFieldReturn(ioSerialiser, obj, field)); + } + + protected List execFieldReturn(final IoSerialiser ioSerialiser, Object obj, ClassFieldDescription field) { + final Object oldObject = field == null ? null : field.getField().get(obj); + final boolean isListPresent = oldObject instanceof List; + + final int nElements = ioSerialiser.getBuffer().getInt(); // number of elements + // N.B. cast should fail at runtime (points to lib inconsistency) + @SuppressWarnings("unchecked") + List setVal = isListPresent ? (List) field.getField().get(obj) : new ArrayList<>(nElements); // NOPMD + if (isListPresent) { + setVal.clear(); + } + + for (int i = 0; i < nElements; i++) { + String axisName = ioSerialiser.getBuffer().getString(); + String axisUnit = ioSerialiser.getBuffer().getString(); + double min = ioSerialiser.getBuffer().getDouble(); + double max = ioSerialiser.getBuffer().getDouble(); + DefaultAxisDescription ad = new DefaultAxisDescription(i, axisName, axisUnit, min, max); // NOPMD + // N.B. PMD - unavoidable in-loop instantiation + setVal.add(ad); + } + + return setVal; + } + + protected void execFieldWriter(final IoSerialiser ioSerialiser, Object obj, ClassFieldDescription field) { + @SuppressWarnings("unchecked") + final List axisDescriptions = (List) field.getField().get(obj); // NOPMD + // N.B. cast should fail at runtime (points to lib inconsistency) + + final int nElements = axisDescriptions.size(); + final int entrySize = 50; // as an initial estimate + + ioSerialiser.getBuffer().ensureAdditionalCapacity((nElements * entrySize) + 9); + ioSerialiser.getBuffer().putInt(nElements); // number of elements + for (AxisDescription axis : axisDescriptions) { + ioSerialiser.getBuffer().putString(axis.getName()); + ioSerialiser.getBuffer().putString(axis.getUnit()); + ioSerialiser.getBuffer().putDouble(axis.getMin()); + ioSerialiser.getBuffer().putDouble(axis.getMax()); + } + } +} \ No newline at end of file diff --git a/serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/FieldMapHelper.java b/serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/FieldMapHelper.java new file mode 100644 index 00000000..062f6d41 --- /dev/null +++ b/serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/FieldMapHelper.java @@ -0,0 +1,32 @@ +package io.opencmw.serialiser.spi.iobuffer; + +import java.util.Map; + +import io.opencmw.serialiser.FieldSerialiser; +import io.opencmw.serialiser.IoClassSerialiser; + +public final class FieldMapHelper { + private FieldMapHelper() { + // utility class + } + + /** + * registers default Map interface and related helper methods + * + * @param serialiser for which the field serialisers should be registered + */ + public static void register(final IoClassSerialiser serialiser) { + // Map serialiser mapper to IoBuffer + + final FieldSerialiser.TriFunction> returnMapFunction = (io, obj, field) -> { + final Map origMap = field == null ? null : (Map) field.getField().get(obj); + return io.getMap(origMap); + }; + + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().set(obj, returnMapFunction.apply(io, obj, field)), // reader + returnMapFunction, // return + (io, obj, field) -> io.put(field, (Map) field.getField().get(obj), field.getActualTypeArguments().get(0), field.getActualTypeArguments().get(1)), // writer + Map.class)); + } +} diff --git a/serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/FieldMultiArrayHelper.java b/serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/FieldMultiArrayHelper.java new file mode 100644 index 00000000..2d18e327 --- /dev/null +++ b/serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/FieldMultiArrayHelper.java @@ -0,0 +1,189 @@ +package io.opencmw.serialiser.spi.iobuffer; + +import io.opencmw.serialiser.DataType; +import io.opencmw.serialiser.FieldDescription; +import io.opencmw.serialiser.FieldSerialiser; +import io.opencmw.serialiser.IoClassSerialiser; +import io.opencmw.serialiser.IoSerialiser; +import io.opencmw.serialiser.spi.WireDataFieldDescription; + +import de.gsi.dataset.spi.utils.*; + +/** + * helper class to register default serialiser for MultiArray types + * + * @author Alexander Krimm + */ +public final class FieldMultiArrayHelper { + private FieldMultiArrayHelper() { + // utility class + } + + @SuppressWarnings("unchecked") + public static MultiArray getMultiArray(final IoSerialiser serialiser, final MultiArray dst, final DataType type) { + final int[] dims = serialiser.getArraySizeDescriptor(); + int n = 1; + for (int ni : dims) { + n *= ni; + } + switch (type) { + case BOOL_ARRAY: + return (MultiArray) MultiArrayBoolean.wrap(serialiser.getBuffer().getBooleanArray(dst == null ? null : (boolean[]) dst.elements(), n), dims); + case BYTE_ARRAY: + return (MultiArray) MultiArrayByte.wrap(serialiser.getBuffer().getByteArray(dst == null ? null : (byte[]) dst.elements(), n), dims); + case SHORT_ARRAY: + return (MultiArray) MultiArrayShort.wrap(serialiser.getBuffer().getShortArray(dst == null ? null : (short[]) dst.elements(), n), dims); + case INT_ARRAY: + return (MultiArray) MultiArrayInt.wrap(serialiser.getBuffer().getIntArray(dst == null ? null : (int[]) dst.elements(), n), dims); + case LONG_ARRAY: + return (MultiArray) MultiArrayLong.wrap(serialiser.getBuffer().getLongArray(dst == null ? null : (long[]) dst.elements(), n), dims); + case FLOAT_ARRAY: + return (MultiArray) MultiArrayFloat.wrap(serialiser.getBuffer().getFloatArray(dst == null ? null : (float[]) dst.elements(), n), dims); + case DOUBLE_ARRAY: + return (MultiArray) MultiArrayDouble.wrap(serialiser.getBuffer().getDoubleArray(dst == null ? null : (double[]) dst.elements(), n), dims); + case CHAR_ARRAY: + return (MultiArray) MultiArrayChar.wrap(serialiser.getBuffer().getCharArray(dst == null ? null : (char[]) dst.elements(), n), dims); + case STRING_ARRAY: + return (MultiArray) MultiArrayObject.wrap(serialiser.getBuffer().getStringArray(dst == null ? null : (String[]) dst.elements(), n), dims); + default: + throw new IllegalStateException("Unexpected value: " + type); + } + } + + public static void put(final IoSerialiser serialiser, final String fieldName, final MultiArray value) { + if (value instanceof MultiArrayDouble) { + final WireDataFieldDescription fieldHeader = serialiser.putFieldHeader(fieldName, DataType.DOUBLE_ARRAY); + final int nElements = serialiser.putArraySizeDescriptor(value.getDimensions()); + serialiser.getBuffer().putDoubleArray(((MultiArrayDouble) value).elements(), nElements); + serialiser.updateDataEndMarker(fieldHeader); + } else if (value instanceof MultiArrayFloat) { + final WireDataFieldDescription fieldHeader = serialiser.putFieldHeader(fieldName, DataType.FLOAT_ARRAY); + final int nElements = serialiser.putArraySizeDescriptor(value.getDimensions()); + serialiser.getBuffer().putFloatArray(((MultiArrayFloat) value).elements(), nElements); + serialiser.updateDataEndMarker(fieldHeader); + } else if (value instanceof MultiArrayInt) { + final WireDataFieldDescription fieldHeader = serialiser.putFieldHeader(fieldName, DataType.INT_ARRAY); + final int nElements = serialiser.putArraySizeDescriptor(value.getDimensions()); + serialiser.getBuffer().putIntArray(((MultiArrayInt) value).elements(), nElements); + serialiser.updateDataEndMarker(fieldHeader); + } else if (value instanceof MultiArrayLong) { + final WireDataFieldDescription fieldHeader = serialiser.putFieldHeader(fieldName, DataType.LONG_ARRAY); + final int nElements = serialiser.putArraySizeDescriptor(value.getDimensions()); + serialiser.getBuffer().putLongArray(((MultiArrayLong) value).elements(), nElements); + serialiser.updateDataEndMarker(fieldHeader); + } else if (value instanceof MultiArrayShort) { + final WireDataFieldDescription fieldHeader = serialiser.putFieldHeader(fieldName, DataType.SHORT_ARRAY); + final int nElements = serialiser.putArraySizeDescriptor(value.getDimensions()); + serialiser.getBuffer().putShortArray(((MultiArrayShort) value).elements(), nElements); + serialiser.updateDataEndMarker(fieldHeader); + } else if (value instanceof MultiArrayChar) { + final WireDataFieldDescription fieldHeader = serialiser.putFieldHeader(fieldName, DataType.CHAR_ARRAY); + final int nElements = serialiser.putArraySizeDescriptor(value.getDimensions()); + serialiser.getBuffer().putCharArray(((MultiArrayChar) value).elements(), nElements); + serialiser.updateDataEndMarker(fieldHeader); + } else if (value instanceof MultiArrayByte) { + final WireDataFieldDescription fieldHeader = serialiser.putFieldHeader(fieldName, DataType.BYTE_ARRAY); + final int nElements = serialiser.putArraySizeDescriptor(value.getDimensions()); + serialiser.getBuffer().putByteArray(((MultiArrayByte) value).elements(), nElements); + serialiser.updateDataEndMarker(fieldHeader); + } else if (value instanceof MultiArrayObject) { + final WireDataFieldDescription fieldHeader = serialiser.putFieldHeader(fieldName, DataType.STRING_ARRAY); + final int nElements = serialiser.putArraySizeDescriptor(value.getDimensions()); + serialiser.getBuffer().putStringArray(((MultiArrayObject) value).elements(), nElements); + serialiser.updateDataEndMarker(fieldHeader); + } else { + throw new IllegalArgumentException("Illegal DataType for MultiArray"); + } + } + + public static void put(final IoSerialiser serialiser, final FieldDescription fieldDescription, final MultiArray value) { + final WireDataFieldDescription fieldHeader = serialiser.putFieldHeader(fieldDescription); // NOPMD + final int nElements = serialiser.putArraySizeDescriptor(value.getDimensions()); + switch (fieldDescription.getDataType()) { + case BOOL_ARRAY: + serialiser.getBuffer().putBooleanArray(((MultiArrayBoolean) value).elements(), nElements); + break; + case BYTE_ARRAY: + serialiser.getBuffer().putByteArray(((MultiArrayByte) value).elements(), nElements); + break; + case SHORT_ARRAY: + serialiser.getBuffer().putShortArray(((MultiArrayShort) value).elements(), nElements); + break; + case INT_ARRAY: + serialiser.getBuffer().putIntArray(((MultiArrayInt) value).elements(), nElements); + break; + case LONG_ARRAY: + serialiser.getBuffer().putLongArray(((MultiArrayLong) value).elements(), nElements); + break; + case FLOAT_ARRAY: + serialiser.getBuffer().putFloatArray(((MultiArrayFloat) value).elements(), nElements); + break; + case DOUBLE_ARRAY: + serialiser.getBuffer().putDoubleArray(((MultiArrayDouble) value).elements(), nElements); + break; + case CHAR_ARRAY: + serialiser.getBuffer().putCharArray(((MultiArrayChar) value).elements(), nElements); + break; + case STRING_ARRAY: + serialiser.getBuffer().putStringArray(((MultiArrayObject) value).elements(), nElements); + break; + default: + throw new IllegalStateException("Unexpected value: " + fieldDescription.getDataType()); + } + serialiser.updateDataEndMarker(fieldHeader); + } + + /** + * Registers default serialiser for MultiArray + * + * @param serialiser for which the field serialisers should be registered + */ + @SuppressWarnings("PMD.NPathComplexity") + public static void register(final IoClassSerialiser serialiser) { + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().set(obj, getMultiArray(io, (MultiArray) field.getField().get(obj), field.getDataType())), // reader + (io, obj, field) -> getMultiArray(io, (MultiArray) ((field == null) ? obj : field.getField().get(obj)), DataType.DOUBLE_ARRAY), // return + (io, obj, field) -> put(io, field, (MultiArray) field.getField().get(obj)), // writer + MultiArrayDouble.class)); + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().set(obj, getMultiArray(io, (MultiArray) field.getField().get(obj), field.getDataType())), // reader + (io, obj, field) -> getMultiArray(io, (MultiArray) ((field == null) ? obj : field.getField().get(obj)), DataType.FLOAT_ARRAY), // return + (io, obj, field) -> put(io, field, (MultiArray) field.getField().get(obj)), // writer + MultiArrayFloat.class)); + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().set(obj, getMultiArray(io, (MultiArray) field.getField().get(obj), field.getDataType())), // reader + (io, obj, field) -> getMultiArray(io, (MultiArray) ((field == null) ? obj : field.getField().get(obj)), DataType.INT_ARRAY), // return + (io, obj, field) -> put(io, field, (MultiArray) field.getField().get(obj)), // writer + MultiArrayInt.class)); + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().set(obj, getMultiArray(io, (MultiArray) field.getField().get(obj), field.getDataType())), // reader + (io, obj, field) -> getMultiArray(io, (MultiArray) ((field == null) ? obj : field.getField().get(obj)), DataType.LONG_ARRAY), // return + (io, obj, field) -> put(io, field, (MultiArray) field.getField().get(obj)), // writer + MultiArrayLong.class)); + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().set(obj, getMultiArray(io, (MultiArray) field.getField().get(obj), field.getDataType())), // reader + (io, obj, field) -> getMultiArray(io, (MultiArray) ((field == null) ? obj : field.getField().get(obj)), DataType.SHORT_ARRAY), // return + (io, obj, field) -> put(io, field, (MultiArray) field.getField().get(obj)), // writer + MultiArrayShort.class)); + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().set(obj, getMultiArray(io, (MultiArray) field.getField().get(obj), field.getDataType())), // reader + (io, obj, field) -> getMultiArray(io, (MultiArray) ((field == null) ? obj : field.getField().get(obj)), DataType.BYTE_ARRAY), // return + (io, obj, field) -> put(io, field, (MultiArray) field.getField().get(obj)), // writer + MultiArrayByte.class)); + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().set(obj, getMultiArray(io, (MultiArray) field.getField().get(obj), field.getDataType())), // reader + (io, obj, field) -> getMultiArray(io, (MultiArray) ((field == null) ? obj : field.getField().get(obj)), DataType.CHAR_ARRAY), // return + (io, obj, field) -> put(io, field, (MultiArray) field.getField().get(obj)), // writer + MultiArrayChar.class)); + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().set(obj, getMultiArray(io, (MultiArray) field.getField().get(obj), field.getDataType())), // reader + (io, obj, field) -> getMultiArray(io, (MultiArray) ((field == null) ? obj : field.getField().get(obj)), DataType.BOOL_ARRAY), // return + (io, obj, field) -> put(io, field, (MultiArray) field.getField().get(obj)), // writer + MultiArrayBoolean.class)); + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().set(obj, getMultiArray(io, (MultiArray) field.getField().get(obj), field.getDataType())), // reader + (io, obj, field) -> getMultiArray(io, (MultiArray) ((field == null) ? obj : field.getField().get(obj)), DataType.STRING_ARRAY), // return + (io, obj, field) -> put(io, field, (MultiArray) field.getField().get(obj)), // writer + MultiArrayObject.class)); + } +} diff --git a/serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/FieldPrimitiveValueHelper.java b/serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/FieldPrimitiveValueHelper.java new file mode 100644 index 00000000..a354f2f8 --- /dev/null +++ b/serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/FieldPrimitiveValueHelper.java @@ -0,0 +1,78 @@ +package io.opencmw.serialiser.spi.iobuffer; + +import io.opencmw.serialiser.FieldSerialiser; +import io.opencmw.serialiser.IoClassSerialiser; + +/** + * helper class to register default serialiser for primitive types (ie. boolean, byte, short, ..., double) and String + * + * @author rstein + */ +public final class FieldPrimitiveValueHelper { + public static final String UNSUPPORTED = "return function not supported for primitive types"; + + private FieldPrimitiveValueHelper() { + // utility class + } + + /** + * registers default serialiser for primitive types (ie. boolean, byte, short, ..., double) and String + * + * @param serialiser for which the field serialisers should be registered + */ + public static void register(final IoClassSerialiser serialiser) { + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().setBoolean(obj, io.getBoolean()), // reader + (io, obj, field) -> { throw new UnsupportedOperationException(UNSUPPORTED); }, // return + (io, obj, field) -> io.put(field, field.getField().getBoolean(obj)), // writer + boolean.class)); + + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().setByte(obj, io.getByte()), // reader + (io, obj, field) -> { throw new UnsupportedOperationException(UNSUPPORTED); }, // return + (io, obj, field) -> io.put(field, field.getField().getByte(obj)), // writer + byte.class)); + + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().setChar(obj, io.getChar()), // reader + (io, obj, field) -> { throw new UnsupportedOperationException(UNSUPPORTED); }, // return + (io, obj, field) -> io.put(field, field.getField().getChar(obj)), // writer + char.class)); + + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().setShort(obj, io.getShort()), // reader + (io, obj, field) -> { throw new UnsupportedOperationException(UNSUPPORTED); }, // return + (io, obj, field) -> io.put(field, field.getField().getShort(obj)), // writer + short.class)); + + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().setInt(obj, io.getInt()), // reader + (io, obj, field) -> { throw new UnsupportedOperationException(UNSUPPORTED); }, // return + (io, obj, field) -> io.put(field, field.getField().getInt(obj)), // writer + int.class)); + + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().setLong(obj, io.getLong()), // reader + (io, obj, field) -> { throw new UnsupportedOperationException(UNSUPPORTED); }, // return + (io, obj, field) -> io.put(field, field.getField().getLong(obj)), // writer + long.class)); + + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().setFloat(obj, io.getFloat()), // reader + (io, obj, field) -> { throw new UnsupportedOperationException(UNSUPPORTED); }, // return + (io, obj, field) -> io.put(field, field.getField().getFloat(obj)), // writer + float.class)); + + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().setDouble(obj, io.getDouble()), // reader + (io, obj, field) -> { throw new UnsupportedOperationException(UNSUPPORTED); }, // return + (io, obj, field) -> io.put(field, field.getField().getDouble(obj)), // writer + double.class)); + + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().set(obj, io.getString()), // reader + (io, obj, field) -> { throw new UnsupportedOperationException(UNSUPPORTED); }, // return + (io, obj, field) -> io.put(field, (String) field.getField().get(obj)), // writer + String.class)); + } +} diff --git a/serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/FieldPrimitveValueArrayHelper.java b/serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/FieldPrimitveValueArrayHelper.java new file mode 100644 index 00000000..27f10390 --- /dev/null +++ b/serialiser/src/main/java/io/opencmw/serialiser/spi/iobuffer/FieldPrimitveValueArrayHelper.java @@ -0,0 +1,80 @@ +package io.opencmw.serialiser.spi.iobuffer; + +import io.opencmw.serialiser.FieldSerialiser; +import io.opencmw.serialiser.IoClassSerialiser; + +/** + * helper class to register default serialiser for primitive array types (ie. boolean[], byte[], short[], ..., double[]) + * and String[] + * + * @author rstein + */ +public final class FieldPrimitveValueArrayHelper { + public static final String NOT_SUPPORTED_FOR_PRIMITIVES = "return function not supported for primitive types"; + + private FieldPrimitveValueArrayHelper() { + // utility class + } + + /** + * registers default serialiser for array primitive types (ie. boolean[], byte[], short[], ..., double[]) and + * String[] + * + * @param serialiser for which the field serialisers should be registered + */ + public static void register(final IoClassSerialiser serialiser) { + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().set(obj, io.getBooleanArray((boolean[]) field.getField().get(obj))), // reader + (io, obj, field) -> { throw new UnsupportedOperationException(NOT_SUPPORTED_FOR_PRIMITIVES); }, // return + (io, obj, field) -> io.put(field, (boolean[]) field.getField().get(obj)), // writer + boolean[].class)); + + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().set(obj, io.getByteArray((byte[]) field.getField().get(obj))), // reader + (io, obj, field) -> { throw new UnsupportedOperationException(NOT_SUPPORTED_FOR_PRIMITIVES); }, // return + (io, obj, field) -> io.put(field, (byte[]) field.getField().get(obj)), // writer + byte[].class)); + + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().set(obj, io.getCharArray((char[]) field.getField().get(obj))), // reader + (io, obj, field) -> { throw new UnsupportedOperationException(NOT_SUPPORTED_FOR_PRIMITIVES); }, // return + (io, obj, field) -> io.put(field, (char[]) field.getField().get(obj)), // writer + char[].class)); + + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().set(obj, io.getShortArray((short[]) field.getField().get(obj))), // reader + (io, obj, field) -> { throw new UnsupportedOperationException(NOT_SUPPORTED_FOR_PRIMITIVES); }, // return + (io, obj, field) -> io.put(field, (short[]) field.getField().get(obj)), // writer + short[].class)); + + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().set(obj, io.getIntArray((int[]) field.getField().get(obj))), // reader + (io, obj, field) -> { throw new UnsupportedOperationException(NOT_SUPPORTED_FOR_PRIMITIVES); }, // return + (io, obj, field) -> io.put(field, (int[]) field.getField().get(obj)), // writer + int[].class)); + + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().set(obj, io.getLongArray((long[]) field.getField().get(obj))), // reader + (io, obj, field) -> { throw new UnsupportedOperationException(NOT_SUPPORTED_FOR_PRIMITIVES); }, // return + (io, obj, field) -> io.put(field, (long[]) field.getField().get(obj)), // writer + long[].class)); + + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().set(obj, io.getFloatArray((float[]) field.getField().get(obj))), // reader + (io, obj, field) -> { throw new UnsupportedOperationException(NOT_SUPPORTED_FOR_PRIMITIVES); }, // return + (io, obj, field) -> io.put(field, (float[]) field.getField().get(obj)), // writer + float[].class)); + + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().set(obj, io.getDoubleArray((double[]) field.getField().get(obj))), // reader + (io, obj, field) -> { throw new UnsupportedOperationException(NOT_SUPPORTED_FOR_PRIMITIVES); }, // return + (io, obj, field) -> io.put(field, (double[]) field.getField().get(obj)), // writer + double[].class)); + + serialiser.addClassDefinition(new FieldSerialiser<>( // + (io, obj, field) -> field.getField().set(obj, io.getStringArray((String[]) field.getField().get(obj))), // reader + (io, obj, field) -> { throw new UnsupportedOperationException(NOT_SUPPORTED_FOR_PRIMITIVES); }, // return + (io, obj, field) -> io.put(field, (String[]) field.getField().get(obj)), // writer + String[].class)); + } +} diff --git a/serialiser/src/main/java/io/opencmw/serialiser/utils/AssertUtils.java b/serialiser/src/main/java/io/opencmw/serialiser/utils/AssertUtils.java new file mode 100644 index 00000000..4e6b1329 --- /dev/null +++ b/serialiser/src/main/java/io/opencmw/serialiser/utils/AssertUtils.java @@ -0,0 +1,393 @@ +package io.opencmw.serialiser.utils; + +/** + * Utility class used to examine function parameters. All the methods throw IllegalArgumentException if the + * argument doesn't fulfil constraints. + * + * @author rstein + */ +public final class AssertUtils { + private static final String MUST_BE_GREATER_THAN_OR_EQUAL_TO_0 = " must be greater than or equal to 0!"; + private static final String MUST_BE_NON_EMPTY = " must be non-empty!"; + + private AssertUtils() { + } + + /** + * The method returns true if both values area equal. The method differs from simple == compare because it takes + * into account that both values can be Double.NaN, in which case == operator returns false. + * + * @param v1 to be checked + * @param v2 to be checked + * + * @return true if v1 and v2 are Double.NaN or v1 == v2. + */ + public static boolean areEqual(final double v1, final double v2) { + return (Double.isNaN(v1) && Double.isNaN(v2)) || (v1 == v2); + } + + /** + * Asserts if the specified object is an instance of the specified type. + * + * @param obj to be checked + * @param type required class type + * + * @throws IllegalArgumentException in case of problems + */ + public static void assertType(final Object obj, final Class type) { + if (!type.isInstance(obj)) { + throw new IllegalArgumentException( + "The argument has incorrect type. The correct type is " + type.getName()); + } + } + + public static void belongsToEnum(final String name, final int[] allowedElements, final int value) { + for (final int allowedElement : allowedElements) { + if (value == allowedElement) { + return; + } + } + throw new IllegalArgumentException("The " + name + " has incorrect value!"); + } + + public static void checkArrayDimension(final String name, final boolean[] array, final int defaultLength) { + AssertUtils.notNull(name, array); + AssertUtils.nonEmptyArray(name, array); + if (array.length != defaultLength) { + throw new IllegalArgumentException("The " + name + " boolean array must have a length of " + defaultLength); + } + } + + public static void checkArrayDimension(final String name, final byte[] array, final int defaultLength) { + AssertUtils.notNull(name, array); + AssertUtils.nonEmptyArray(name, array); + if (array.length != defaultLength) { + throw new IllegalArgumentException("The " + name + " byte array must have a length of " + defaultLength); + } + } + + public static void checkArrayDimension(final String name, final double[] array, final int defaultLength) { + AssertUtils.notNull(name, array); + AssertUtils.nonEmptyArray(name, array); + if (array.length != defaultLength) { + throw new IllegalArgumentException("The " + name + " double array must have a length of " + defaultLength); + } + } + + public static void checkArrayDimension(final String name, final float[] array, final int defaultLength) { + AssertUtils.notNull(name, array); + AssertUtils.nonEmptyArray(name, array); + if (array.length != defaultLength) { + throw new IllegalArgumentException("The " + name + " float array must have a length of " + defaultLength); + } + } + + public static void checkArrayDimension(final String name, final int[] array, final int defaultLength) { + AssertUtils.notNull(name, array); + AssertUtils.nonEmptyArray(name, array); + if (array.length != defaultLength) { + throw new IllegalArgumentException("The " + name + " int array must have a length of " + defaultLength); + } + } + + /** + * Asserts that the specified arrays have the same length. + * + * @param generics object to be checked + * + * @param array1 to be checked + * @param array2 to be checked + */ + public static void equalArrays(final T[] array1, final T[] array2) { + if (array1.length != array2.length) { + throw new IllegalArgumentException("The double arrays must have the same length!"); + } + } + + /** + * Asserts that the specified arrays have the same length. + * + * @param array1 to be checked + * @param array2 to be checked + */ + public static void equalDoubleArrays(final double[] array1, final double[] array2) { + if (array1.length != array2.length) { + throw new IllegalArgumentException("The double arrays must have the same length! length1 = " + array1.length + + " vs. length2 = " + array2.length); + } + } + + /** + * Asserts that the specified arrays have the same length or are at least min size. + * + * @param array1 to be checked + * @param array2 to be checked + * @param nMinSize minimum required size + */ + public static void equalDoubleArrays(final double[] array1, final double[] array2, final int nMinSize) { + final int length1 = Math.min(nMinSize, array1.length); + final int length2 = Math.min(nMinSize, array2.length); + if (length1 != length2) { + throw new IllegalArgumentException("The double arrays must have the same length! length1 = " + array1.length + + " vs. length2 = " + array2.length + " (nMinSize = " + nMinSize + ")"); + } + } + + /** + * Asserts that the specified arrays have the same length. + * + * @param array1 to be checked + * @param array2 to be checked + */ + public static void equalFloatArrays(final float[] array1, final float[] array2) { + if (array1.length != array2.length) { + throw new IllegalArgumentException("The float arrays must have the same length! length1 = " + array1.length + + " vs. length2 = " + array2.length); + } + } + + /** + * Asserts that the specified arrays have the same length or are at least min size. + * + * @param array1 to be checked + * @param array2 to be checked + * @param nMinSize minimum required size + */ + public static void equalFloatArrays(final float[] array1, final float[] array2, final int nMinSize) { + final int length1 = Math.min(nMinSize, array1.length); + final int length2 = Math.min(nMinSize, array2.length); + if (length1 != length2) { + throw new IllegalArgumentException("The double arrays must have the same length! length1 = " + array1.length + + " vs. length2 = " + array2.length + " (nMinSize = " + nMinSize + ")"); + } + } + + /** + * Checks if the int value is >= 0 + * + * @param name name to be included in the exception message + * @param value to be checked + */ + public static void gtEqThanZero(final String name, final double value) { + if (value < 0) { + throw new IllegalArgumentException("The " + name + MUST_BE_GREATER_THAN_OR_EQUAL_TO_0); + } + } + + /** + * Checks if the int value is >= 0 + * + * @param name name to be included in the exception message + * @param value to be checked + */ + public static void gtEqThanZero(final String name, final int value) { + if (value < 0) { + throw new IllegalArgumentException("The " + name + MUST_BE_GREATER_THAN_OR_EQUAL_TO_0); + } + } + + /** + * Checks if the value is >= 0 + * + * @param generics object to be checked + * + * @param name name to be included in the exception message + * @param value to be checked + */ + public static void gtEqThanZero(final String name, final T value) { + if (value.doubleValue() < 0) { + throw new IllegalArgumentException("The " + name + MUST_BE_GREATER_THAN_OR_EQUAL_TO_0); + } + } + + /** + * Checks if the int value is >= 0 + * + * @param name name to be included in the exception message + * @param value to be checked + */ + public static void gtThanZero(final String name, final int value) { + if (value <= 0) { + throw new IllegalArgumentException("The " + name + " must be greater than 0!"); + } + } + + /** + * Checks if the value is >= 0 + * + * @param generics object to be checked + * + * @param name name to be included in the exception message + * @param value to be checked + */ + public static void gtThanZero(final String name, final T value) { + if (value.doubleValue() <= 0) { + throw new IllegalArgumentException("The " + name + " must be greater than 0!"); + } + } + + /** + * Checks if the index is >= 0 and < bounds + * + * @param index index to be checked + * @param bounds maximum bound + */ + public static void indexInBounds(final int index, final int bounds) { + AssertUtils.indexInBounds(index, bounds, "The index is out of bounds: 0 <= " + index + " < " + bounds); + } + + /** + * Checks if the index is >= 0 and < bounds + * + * @param index index to be checked + * @param bounds maximum bound + * @param message exception message + */ + public static void indexInBounds(final int index, final int bounds, final String message) { + if ((index < 0) || (index >= bounds)) { + throw new IndexOutOfBoundsException(message); + } + } + + /** + * Checks if the index1 <= index2 + * + * @param index1 index1 to be checked + * @param index2 index1 to be checked + * @param msg exception message + */ + public static void indexOrder(final int index1, final int index2, final String msg) { + if (index1 > index2) { + throw new IndexOutOfBoundsException(msg); + } + } + + /** + * Checks if the index1 <= index2 + * + * @param index1 index1 to be checked + * @param name1 name of index1 + * @param index2 index1 to be checked + * @param name2 name of index2 + */ + public static void indexOrder(final int index1, final String name1, final int index2, final String name2) { + if (index1 > index2) { + throw new IndexOutOfBoundsException( + "Index " + name1 + "(" + index1 + ") is greated than index " + name2 + "(" + index2 + ")"); + } + } + + /** + * Checks if the variable is less or equal than the reference + * + * @param name name to be included in exception message. + * @param ref reference + * @param len object to be checked + */ + public static void gtOrEqual(final String name, final double ref, final double len) { + if (len < ref) { + throw new IllegalArgumentException("The " + name + " len = '" + len + "' must be less or equal than " + ref); + } + } + + /** + * Checks if the variable is less or equal than the reference + * + * @param name name to be included in exception message. + * @param ref reference + * @param len object to be checked + */ + public static void gtOrEqual(final String name, final float ref, final float len) { + if (len < ref) { + throw new IllegalArgumentException("The " + name + " len = '" + len + "' must be less or equal than " + ref); + } + } + + /** + * Checks if the variable is greater or equal than the reference + * + * @param name name to be included in exception message. + * @param ref reference + * @param len object to be checked + */ + public static void gtOrEqual(final String name, final int ref, final int len) { + if (len < ref) { + throw new IllegalArgumentException("The " + name + " len = '" + len + "' must be greater or equal than " + ref); + } + } + + /** + * Checks if the variable is less or equal than the reference + * + * @param name name to be included in exception message. + * @param ref reference + * @param len object to be checked + */ + public static void gtOrEqual(final String name, final long ref, final long len) { + if (len < ref) { + throw new IllegalArgumentException("The " + name + " len = '" + len + "' must be less or equal than " + ref); + } + } + + public static void nonEmptyArray(final String name, final boolean[] array) { + AssertUtils.notNull(name, array); + if (array.length == 0) { + throw new IllegalArgumentException("The " + name + MUST_BE_NON_EMPTY); + } + } + + public static void nonEmptyArray(final String name, final byte[] array) { + AssertUtils.notNull(name, array); + if (array.length == 0) { + throw new IllegalArgumentException("The " + name + MUST_BE_NON_EMPTY); + } + } + + public static void nonEmptyArray(final String name, final double[] array) { + AssertUtils.notNull(name, array); + if (array.length == 0) { + throw new IllegalArgumentException("The " + name + MUST_BE_NON_EMPTY); + } + } + + public static void nonEmptyArray(final String name, final float[] array) { + AssertUtils.notNull(name, array); + if (array.length == 0) { + throw new IllegalArgumentException("The " + name + MUST_BE_NON_EMPTY); + } + } + + public static void nonEmptyArray(final String name, final int[] array) { + AssertUtils.notNull(name, array); + if (array.length == 0) { + throw new IllegalArgumentException("The " + name + MUST_BE_NON_EMPTY); + } + } + + public static void nonEmptyArray(final String name, final Object[] array) { + AssertUtils.notNull(name, array); + if (array.length == 0) { + throw new IllegalArgumentException("The " + name + MUST_BE_NON_EMPTY); + } + + for (final Object element : array) { + if (element == null) { + throw new NullPointerException("Elements of the " + name + " must be non-null!"); // #NOPMD + } + } + } + + /** + * Checks if the object is not null. + * + * @param generics object to be checked + * + * @param name name to be included in exception message. + * @param obj object to be checked + */ + public static void notNull(final String name, final T obj) { + if (obj == null) { + throw new IllegalArgumentException("The " + name + " must be non-null!"); + } + } +} \ No newline at end of file diff --git a/serialiser/src/main/java/io/opencmw/serialiser/utils/ByteArrayCache.java b/serialiser/src/main/java/io/opencmw/serialiser/utils/ByteArrayCache.java new file mode 100644 index 00000000..9888c090 --- /dev/null +++ b/serialiser/src/main/java/io/opencmw/serialiser/utils/ByteArrayCache.java @@ -0,0 +1,85 @@ +package io.opencmw.serialiser.utils; + +import java.lang.ref.Reference; + +/** + * Implements byte-array (byte[]) cache collection to minimise memory re-allocation. + * + *

+ * N.B. This is useful to re-use short-lived data storage container to minimise the amount of garbage to be collected. This is used in situation replacing e.g. + *

+ *  {@code
+ *      public returnValue frequentlyExecutedFunction(...) {
+ *          final byte[] storage = new byte[10000]; // allocate new memory block (costly)
+ *          // [...] do short-lived computation on storage
+ *          // storage is implicitly finalised by garbage collector (costly)
+ *      }
+ *  }
+ * 
+ * with + *
+ *  {@code
+ *      // ...
+ *      private final ByteArrayCache cache = new ByteArrayCache();
+ *      // ...
+ *      
+ *      public returnValue frequentlyExecutedFunction(...) {
+ *          final byte[] storage = cache.getArray(10000); // return previously allocated array (cheap) or allocated new if necessary 
+ *          // [...] do short-lived computation on storage
+ *          cache.add(storage); // return object to cache
+ *      }
+ *  }
+ * 
+ * + * @author rstein + * + */ +public class ByteArrayCache extends CacheCollection { + private static final ByteArrayCache SELF = new ByteArrayCache(); + + public byte[] getArray(final int requiredSize) { + return getArray(requiredSize, false); + } + + public byte[] getArrayExact(final int requiredSize) { + return getArray(requiredSize, true); + } + + private byte[] getArray(final int requiredSize, final boolean exact) { + synchronized (contents) { + byte[] bestFit = null; + int bestFitSize = Integer.MAX_VALUE; + + for (final Reference candidate : contents) { + final byte[] localRef = candidate.get(); + if (localRef == null) { + continue; + } + + final int sizeDiff = localRef.length - requiredSize; + if (sizeDiff == 0) { + bestFit = localRef; + break; + } + + if (sizeDiff > 0 && sizeDiff < bestFitSize && !exact) { + bestFitSize = sizeDiff; + bestFit = localRef; + } + } + + if (bestFit == null) { + // could not find any cached, return new byte[] + bestFit = new byte[requiredSize]; + return bestFit; + } + + remove(bestFit); + return bestFit; + } + } + + public static ByteArrayCache getInstance() { + return SELF; + } +} diff --git a/serialiser/src/main/java/io/opencmw/serialiser/utils/CacheCollection.java b/serialiser/src/main/java/io/opencmw/serialiser/utils/CacheCollection.java new file mode 100644 index 00000000..37dbd094 --- /dev/null +++ b/serialiser/src/main/java/io/opencmw/serialiser/utils/CacheCollection.java @@ -0,0 +1,153 @@ +package io.opencmw.serialiser.utils; + +import java.lang.ref.Reference; +import java.lang.ref.SoftReference; +import java.util.AbstractCollection; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; + +import org.jetbrains.annotations.NotNull; + +import de.gsi.dataset.utils.ByteArrayCache; + +/** + * Implements collection of cache-able objects that can be used to store recurring storage container. + *

+ * N.B. this implements only the backing cache of adding, removing, etc. elements. The cache object retrieval should be implemented in the derived class. + * See for example {@link ByteArrayCache}. + * + * @author rstein + * + * @param generic for object type to be cached. + */ +public class CacheCollection extends AbstractCollection { + protected final List> contents = Collections.synchronizedList(new LinkedList<>()); + + @Override + public boolean add(T recoveredObject) { + if (recoveredObject != null) { + synchronized (contents) { + if (contains(recoveredObject)) { + return false; + } + // N.B. here: specific choice of using 'SoftReference' + // derived classes may overwrite this function and replace this with e.g. WeakReference or similar + return contents.add(new SoftReference<>(recoveredObject)); + } + } + return false; + } + + @Override + public void clear() { + synchronized (contents) { + contents.clear(); + } + } + + @Override + public boolean contains(Object object) { + if (object != null) { + synchronized (contents) { + for (Reference weakReference : contents) { + if (object.equals(weakReference.get())) { + return true; + } + } + } + } + return false; + } + + @Override + public @NotNull Iterator iterator() { + synchronized (contents) { + return new CacheCollectionIterator<>(contents.iterator()); + } + } + + @Override + public boolean remove(Object o) { + if (o == null) { + return false; + } + synchronized (contents) { + Iterator> iter = contents.iterator(); + while (iter.hasNext()) { + final Reference candidate = iter.next(); + final T test = candidate.get(); + if (test == null) { + iter.remove(); + continue; + } + if (o.equals(test)) { + iter.remove(); + return true; + } + } + } + return false; + } + + @Override + public int size() { + synchronized (contents) { + cleanup(); + return contents.size(); + } + } + + protected void cleanup() { + synchronized (contents) { + List> toRemove = new LinkedList<>(); + for (Reference weakReference : contents) { + if (weakReference.get() == null) { + toRemove.add(weakReference); + } + } + contents.removeAll(toRemove); + } + } + + private static class CacheCollectionIterator implements Iterator { + private final Iterator> iterator; + private T nextElement; + + private CacheCollectionIterator(Iterator> iterator) { + this.iterator = iterator; + } + + @Override + public boolean hasNext() { + if (nextElement != null) { + return true; + } + while (iterator.hasNext()) { + T t = iterator.next().get(); + if (t != null) { + // to ensure next() can't throw after hasNext() returned true, we need to dereference this + nextElement = t; + return true; + } + } + return false; + } + + @Override + public T next() { + T result = nextElement; + nextElement = null; // NOPMD + while (result == null) { + result = iterator.next().get(); + } + return result; + } + + @Override + public void remove() { + iterator.remove(); + } + } +} diff --git a/serialiser/src/main/java/io/opencmw/serialiser/utils/ClassUtils.java b/serialiser/src/main/java/io/opencmw/serialiser/utils/ClassUtils.java new file mode 100644 index 00000000..8a8d32a6 --- /dev/null +++ b/serialiser/src/main/java/io/opencmw/serialiser/utils/ClassUtils.java @@ -0,0 +1,271 @@ +package io.opencmw.serialiser.utils; + +import java.lang.reflect.Array; +import java.lang.reflect.GenericArrayType; +import java.lang.reflect.Method; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.lang.reflect.TypeVariable; +import java.lang.reflect.WildcardType; +import java.nio.CharBuffer; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.opencmw.serialiser.spi.ClassFieldDescription; + +@SuppressWarnings("PMD.UseConcurrentHashMap") +public final class ClassUtils { //NOPMD nomen est omen + private static final Logger LOGGER = LoggerFactory.getLogger(ClassUtils.class); + // some helper declarations + private static final Map, Class> PRIMITIVE_WRAPPER_MAP = new HashMap<>(); + private static final Map, Class> WRAPPER_PRIMITIVE_MAP = new HashMap<>(); + private static final Map, Class> PRIMITIVE_ARRAY_BOXED_MAP = new HashMap<>(); + private static final Map, Class> BOXED_ARRAY_PRIMITIVE_MAP = new HashMap<>(); + public static final Map, String> DO_NOT_PARSE_MAP = new HashMap<>(); // NOPMD should not be a threading issue - static one-time/init write, multiple reads afterwards are safe + private static final Map CLASS_FIELD_DESCRIPTION_MAP = new ConcurrentHashMap<>(); + private static final Map> CLASS_STRING_MAP = new ConcurrentHashMap<>(); + private static final Map, Map> CLASS_METHOD_MAP = new ConcurrentHashMap<>(); + private static int indentationNumberOfSpace = 4; + private static int maxRecursionDepth = 10; + + static { + // primitive types + add(WRAPPER_PRIMITIVE_MAP, PRIMITIVE_WRAPPER_MAP, Boolean.class, boolean.class); + add(WRAPPER_PRIMITIVE_MAP, PRIMITIVE_WRAPPER_MAP, Byte.class, byte.class); + add(WRAPPER_PRIMITIVE_MAP, PRIMITIVE_WRAPPER_MAP, Character.class, char.class); + add(WRAPPER_PRIMITIVE_MAP, PRIMITIVE_WRAPPER_MAP, Short.class, short.class); + add(WRAPPER_PRIMITIVE_MAP, PRIMITIVE_WRAPPER_MAP, Integer.class, int.class); + add(WRAPPER_PRIMITIVE_MAP, PRIMITIVE_WRAPPER_MAP, Long.class, long.class); + add(WRAPPER_PRIMITIVE_MAP, PRIMITIVE_WRAPPER_MAP, Float.class, float.class); + add(WRAPPER_PRIMITIVE_MAP, PRIMITIVE_WRAPPER_MAP, Double.class, double.class); + add(WRAPPER_PRIMITIVE_MAP, PRIMITIVE_WRAPPER_MAP, Void.class, void.class); + add(WRAPPER_PRIMITIVE_MAP, PRIMITIVE_WRAPPER_MAP, String.class, String.class); + + // primitive arrays + add(PRIMITIVE_ARRAY_BOXED_MAP, BOXED_ARRAY_PRIMITIVE_MAP, boolean[].class, Boolean[].class); + add(PRIMITIVE_ARRAY_BOXED_MAP, BOXED_ARRAY_PRIMITIVE_MAP, byte[].class, Byte[].class); + add(PRIMITIVE_ARRAY_BOXED_MAP, BOXED_ARRAY_PRIMITIVE_MAP, char[].class, Character[].class); + add(PRIMITIVE_ARRAY_BOXED_MAP, BOXED_ARRAY_PRIMITIVE_MAP, short[].class, Short[].class); + add(PRIMITIVE_ARRAY_BOXED_MAP, BOXED_ARRAY_PRIMITIVE_MAP, int[].class, Integer[].class); + add(PRIMITIVE_ARRAY_BOXED_MAP, BOXED_ARRAY_PRIMITIVE_MAP, long[].class, Long[].class); + add(PRIMITIVE_ARRAY_BOXED_MAP, BOXED_ARRAY_PRIMITIVE_MAP, float[].class, Float[].class); + add(PRIMITIVE_ARRAY_BOXED_MAP, BOXED_ARRAY_PRIMITIVE_MAP, double[].class, Double[].class); + add(PRIMITIVE_ARRAY_BOXED_MAP, BOXED_ARRAY_PRIMITIVE_MAP, String[].class, String[].class); + + // boxed arrays + + // do not parse following classes + DO_NOT_PARSE_MAP.put(Class.class, "private java implementation"); + DO_NOT_PARSE_MAP.put(Thread.class, "recursive definitions"); // NOPMD - not an issue/not a use within a J2EE context + DO_NOT_PARSE_MAP.put(AtomicBoolean.class, "does not like to be parsed"); + } + private ClassUtils() { + // utility class + } + + public static void checkArgument(boolean condition) { + if (!condition) { + throw new IllegalArgumentException(); + } + } + + public static Class getClassByName(final String name) { + return CLASS_STRING_MAP.computeIfAbsent(name, key -> { + try { + return Class.forName(key); + } catch (ClassNotFoundException | SecurityException e) { + LOGGER.atError().setCause(e).addArgument(name).log("exception while getting class {}"); + return null; + } + }); + } + + public static Class getClassByNameNonVerboseError(final String name) { + return CLASS_STRING_MAP.computeIfAbsent(name, key -> { + try { + return Class.forName(key); + } catch (ClassNotFoundException | SecurityException e) { + return Object.class; + } + }); + } + + public static Map getClassDescriptions() { + return CLASS_FIELD_DESCRIPTION_MAP; + } + + public static ClassFieldDescription getFieldDescription(final Class clazz, final Class... classArguments) { + if (clazz == null) { + throw new IllegalArgumentException("object must not be null"); + } + return CLASS_FIELD_DESCRIPTION_MAP.computeIfAbsent(computeHashCode(clazz, classArguments), + key -> new ClassFieldDescription(clazz, false)); + } + + public static int getIndentationNumberOfSpace() { + return indentationNumberOfSpace; + } + + public static Collection getKnownClasses() { + return CLASS_FIELD_DESCRIPTION_MAP.values(); + } + + public static Map, Map> getKnownMethods() { + return CLASS_METHOD_MAP; + } + + public static int getMaxRecursionDepth() { + return maxRecursionDepth; + } + + public static Method getMethod(final Class clazz, final String methodName) { + return CLASS_METHOD_MAP.computeIfAbsent(clazz, c -> new ConcurrentHashMap<>()).computeIfAbsent(methodName, name -> { + try { + return clazz.getMethod(methodName); + } catch (NoSuchMethodException | SecurityException e) { + return null; + } + }); + } + + public static Class getRawType(Type type) { + if (type instanceof Class) { + // type is a normal class. + return (Class) type; + + } else if (type instanceof ParameterizedType) { + ParameterizedType parameterizedType = (ParameterizedType) type; + + // unsure why getRawType() returns Type instead of Class. + // possibly related to pathological case involving nested classes.... + Type rawType = parameterizedType.getRawType(); + checkArgument(rawType instanceof Class); + return (Class) rawType; + + } else if (type instanceof GenericArrayType) { + Type componentType = ((GenericArrayType) type).getGenericComponentType(); + return Array.newInstance(getRawType(componentType), 0).getClass(); + + } else if (type instanceof TypeVariable) { + // we could use the variable's bounds, but that won't work if there are multiple. + // having a raw type that's more general than necessary is okay + return Object.class; + + } else if (type instanceof WildcardType) { + return getRawType(((WildcardType) type).getUpperBounds()[0]); + + } else { + String className = type == null ? "null" : type.getClass().getName(); + throw new IllegalArgumentException("Expected a Class, ParameterizedType, or " + + "GenericArrayType, but <" + type + "> is of type " + className); + } + } + + public static Type[] getSecondaryType(final Type type) { + if (type instanceof ParameterizedType) { + return ((ParameterizedType) type).getActualTypeArguments(); + } + return new Type[0]; + } + + public static boolean isBoxedArray(final Class type) { + return BOXED_ARRAY_PRIMITIVE_MAP.containsKey(type); + } + + public static boolean isPrimitiveArray(final Class type) { + return PRIMITIVE_ARRAY_BOXED_MAP.containsKey(type); + } + + public static boolean isPrimitiveOrString(final Class type) { + if (type == null) { + return false; + } + return type.isPrimitive() || String.class.isAssignableFrom(type); + } + + public static boolean isPrimitiveOrWrapper(final Class type) { + if (type == null) { + return false; + } + return type.isPrimitive() || isPrimitiveWrapper(type); + } + + public static boolean isPrimitiveWrapper(final Class type) { + return WRAPPER_PRIMITIVE_MAP.containsKey(type); + } + + public static boolean isPrimitiveWrapperOrString(final Class type) { + if (type == null) { + return false; + } + return WRAPPER_PRIMITIVE_MAP.containsKey(type) || String.class.isAssignableFrom(type); + } + + public static Class primitiveToWrapper(final Class cls) { + Class convertedClass = cls; + if (cls != null && cls.isPrimitive()) { + convertedClass = PRIMITIVE_WRAPPER_MAP.get(cls); + } + return convertedClass; + } + + public static void setIndentationNumberOfSpace(final int indentationNumberOfSpace) { + ClassUtils.indentationNumberOfSpace = indentationNumberOfSpace; + } + + public static void setMaxRecursionDepth(final int maxRecursionDepth) { + ClassUtils.maxRecursionDepth = maxRecursionDepth; + } + + public static String spaces(final int spaces) { + return CharBuffer.allocate(spaces).toString().replace('\0', ' '); + } + + public static String translateClassName(final String name) { + if (name.startsWith("[Z")) { + return boolean[].class.getName(); + } else if (name.startsWith("[B")) { + return byte[].class.getName(); + } else if (name.startsWith("[S")) { + return short[].class.getName(); + } else if (name.startsWith("[I")) { + return int[].class.getName(); + } else if (name.startsWith("[J")) { + return long[].class.getName(); + } else if (name.startsWith("[F")) { + return float[].class.getName(); + } else if (name.startsWith("[D")) { + return double[].class.getName(); + } else if (name.startsWith("[L")) { + return name.substring(2, name.length() - 1) + "[]"; + } + + return name; + } + + private static void add(Map, Class> map1, Map, Class> map2, Class obj1, Class obj2) { + map1.put(obj1, obj2); + map2.put(obj2, obj1); + } + + private static int computeHashCode(final Class classPrototype, final Class... classArguments) { + final int prime = 31; + int result = 1; + result = (prime * result) + ((classPrototype == null) ? 0 : classPrototype.getName().hashCode()); + if ((classArguments == null) || (classArguments.length <= 0)) { + return result; + } + + for (final Class clazz : classArguments) { + result = (prime * result) + clazz.hashCode(); + } + + return result; + } +} diff --git a/serialiser/src/main/java/io/opencmw/serialiser/utils/GenericsHelper.java b/serialiser/src/main/java/io/opencmw/serialiser/utils/GenericsHelper.java new file mode 100644 index 00000000..a8b16a65 --- /dev/null +++ b/serialiser/src/main/java/io/opencmw/serialiser/utils/GenericsHelper.java @@ -0,0 +1,325 @@ +package io.opencmw.serialiser.utils; + +/** + * Helper class to convert between boxed and primitive data types. Lot's of boiler plate code because java generics + * cannot handle primitive types. + * + * @author rstein + */ +@SuppressWarnings({ "PMD.GodClass", "PMD.TooManyMethods", "PMD.ExcessivePublicCount", "PMD.AvoidArrayLoops" }) // unavoidable since Java does not support templates (issue: primitive types) +public final class GenericsHelper { + private GenericsHelper() { + // only static methods are used + } + + private static void checkForNonConformity(Object[] array, Class prototype) { + if (array == null) { + throw new IllegalArgumentException("null array pointer "); + } + + if (array.length == 0) { + return; + } + + for (int i = 0; i < array.length; i++) { + if (array[i] == null) { + throw new IllegalArgumentException( + "array class element " + i + " is null, should be of type'" + prototype.getName() + "'"); + } + } + + if (!prototype.isAssignableFrom(array[0].getClass())) { + throw new IllegalArgumentException("array class type '" + array[0].getClass().getName() + + "' mismatch with '" + prototype.getName() + "'"); + } + } + + public static boolean[] toBoolPrimitive(final Object[] array) { + checkForNonConformity(array, Boolean.class); + final boolean[] result = new boolean[array.length]; + for (int i = 0; i < array.length; i++) { + result[i] = array[i] != null && (Boolean) array[i]; + } + return result; + } + + public static byte[] toBytePrimitive(final Object[] array) { + checkForNonConformity(array, Byte.class); + final byte[] result = new byte[array.length]; + for (int i = 0; i < array.length; i++) { + result[i] = array[i] == null ? 0 : (Byte) array[i]; + } + return result; + } + + public static char[] toCharPrimitive(final Object[] array) { + checkForNonConformity(array, Character.class); + final char[] result = new char[array.length]; + for (int i = 0; i < array.length; i++) { + result[i] = array[i] == null ? 0 : (Character) array[i]; + } + return result; + } + + public static double[] toDoublePrimitive(final Object[] array) { + checkForNonConformity(array, Double.class); + final double[] result = new double[array.length]; + for (int i = 0; i < array.length; i++) { + result[i] = array[i] == null ? Double.NaN : (Double) array[i]; + } + return result; + } + + public static float[] toFloatPrimitive(final Object[] array) { + checkForNonConformity(array, Float.class); + final float[] result = new float[array.length]; + for (int i = 0; i < array.length; i++) { + result[i] = array[i] == null ? Float.NaN : (Float) array[i]; + } + return result; + } + + public static int[] toIntegerPrimitive(final Object[] array) { + checkForNonConformity(array, Integer.class); + final int[] result = new int[array.length]; + for (int i = 0; i < array.length; i++) { + result[i] = array[i] == null ? 0 : (Integer) array[i]; + } + return result; + } + + public static long[] toLongPrimitive(final Object[] array) { + checkForNonConformity(array, Long.class); + final long[] result = new long[array.length]; + for (int i = 0; i < array.length; i++) { + result[i] = array[i] == null ? 0L : (Long) array[i]; + } + return result; + } + + public static Boolean[] toObject(final boolean[] array) { + final Boolean[] result = new Boolean[array.length]; + for (int i = 0; i < array.length; i++) { + result[i] = array[i]; + } + return result; + } + + public static Byte[] toObject(final byte[] array) { + final Byte[] result = new Byte[array.length]; + for (int i = 0; i < array.length; i++) { + result[i] = array[i]; + } + return result; + } + + public static Character[] toObject(final char[] array) { + final Character[] result = new Character[array.length]; + for (int i = 0; i < array.length; i++) { + result[i] = array[i]; + } + return result; + } + + public static Double[] toObject(final double[] array) { + final Double[] result = new Double[array.length]; + for (int i = 0; i < array.length; i++) { + result[i] = array[i]; + } + return result; + } + + public static Float[] toObject(final float[] array) { + final Float[] result = new Float[array.length]; + for (int i = 0; i < array.length; i++) { + result[i] = array[i]; + } + return result; + } + + public static Integer[] toObject(final int[] array) { + final Integer[] result = new Integer[array.length]; + for (int i = 0; i < array.length; i++) { + result[i] = array[i]; + } + return result; + } + + public static Long[] toObject(final long[] array) { + final Long[] result = new Long[array.length]; + for (int i = 0; i < array.length; i++) { + result[i] = array[i]; + } + return result; + } + + public static Short[] toObject(final short[] array) { // NOPMD + + final Short[] result = new Short[array.length]; + for (int i = 0; i < array.length; i++) { + result[i] = array[i]; + } + return result; + } + + public static boolean[] toPrimitive(final Boolean[] array) { + final boolean[] result = new boolean[array.length]; + for (int i = 0; i < array.length; i++) { + result[i] = array[i]; + } + return result; + } + + public static byte[] toPrimitive(final Byte[] array) { + final byte[] result = new byte[array.length]; + for (int i = 0; i < array.length; i++) { + result[i] = array[i]; + } + return result; + } + + public static char[] toPrimitive(final Character[] array) { + final char[] result = new char[array.length]; + for (int i = 0; i < array.length; i++) { + result[i] = array[i]; + } + return result; + } + + public static double[] toPrimitive(final Double[] array) { + final double[] result = new double[array.length]; + for (int i = 0; i < array.length; i++) { + result[i] = array[i]; + } + return result; + } + + public static float[] toPrimitive(final Float[] array) { + final float[] result = new float[array.length]; + for (int i = 0; i < array.length; i++) { + result[i] = array[i]; + } + return result; + } + + public static int[] toPrimitive(final Integer[] array) { + final int[] result = new int[array.length]; + for (int i = 0; i < array.length; i++) { + result[i] = array[i]; + } + return result; + } + + public static long[] toPrimitive(final Long[] array) { + final long[] result = new long[array.length]; + for (int i = 0; i < array.length; i++) { + result[i] = array[i]; + } + return result; + } + + public static short[] toPrimitive(final Short[] array) { // NOPMD + final short[] result = new short[array.length]; // NOPMD + for (int i = 0; i < array.length; i++) { + result[i] = array[i]; + } + return result; + } + + public static short[] toShortPrimitive(final Object[] array) { // NOPMD + final short[] result = new short[array.length]; // NOPMD + for (int i = 0; i < array.length; i++) { + result[i] = (Short) array[i]; + } + return result; + } + + public static String[] toStringPrimitive(final Object[] array) { + final String[] result = new String[array.length]; + if (array.length == 0) { + return result; + } + if (array[0] instanceof String) { + for (int i = 0; i < array.length; i++) { + result[i] = (String) array[i]; + } + } else if (array[0] instanceof Boolean) { + for (int i = 0; i < array.length; i++) { + result[i] = ((Boolean) array[i]).toString(); + } + } else if (array[0] instanceof Character) { + for (int i = 0; i < array.length; i++) { + result[i] = array[i].toString(); + } + } else if (array[0] instanceof Number) { + for (int i = 0; i < array.length; i++) { + result[i] = array[i].toString(); + } + } + return result; + } + + public static double[] toDoublePrimitive(final boolean[] input) { + final double[] doubleArray = new double[input.length]; + for (int i = 0; i < input.length; i++) { // NOPMD + doubleArray[i] = input[i] ? 1.0 : 0.0; + } + return doubleArray; + } + + public static double[] toDoublePrimitive(final byte[] input) { + final double[] doubleArray = new double[input.length]; + for (int i = 0; i < input.length; i++) { // NOPMD + doubleArray[i] = input[i]; + } + return doubleArray; + } + + public static double[] toDoublePrimitive(final char[] input) { + final double[] doubleArray = new double[input.length]; + for (int i = 0; i < input.length; i++) { // NOPMD + doubleArray[i] = input[i]; + } + return doubleArray; + } + + public static double[] toDoublePrimitive(final float[] input) { + final double[] doubleArray = new double[input.length]; + for (int i = 0; i < input.length; i++) { // NOPMD + doubleArray[i] = input[i]; + } + return doubleArray; + } + + public static double[] toDoublePrimitive(final int[] input) { + final double[] doubleArray = new double[input.length]; + for (int i = 0; i < input.length; i++) { // NOPMD + doubleArray[i] = input[i]; + } + return doubleArray; + } + + public static double[] toDoublePrimitive(final long[] input) { + final double[] doubleArray = new double[input.length]; + for (int i = 0; i < input.length; i++) { // NOPMD + doubleArray[i] = input[i]; + } + return doubleArray; + } + + public static double[] toDoublePrimitive(final short[] input) { // NOPMD + final double[] doubleArray = new double[input.length]; + for (int i = 0; i < input.length; i++) { // NOPMD + doubleArray[i] = input[i]; + } + return doubleArray; + } + + public static double[] toDoublePrimitive(final String[] input) { + final double[] doubleArray = new double[input.length]; + for (int i = 0; i < input.length; i++) { // NOPMD + doubleArray[i] = input[i] == null ? Double.NaN : Double.parseDouble(input[i]); + } + return doubleArray; + } +} diff --git a/serialiser/src/main/java/module-info.java b/serialiser/src/main/java/module-info.java new file mode 100644 index 00000000..a5de31ea --- /dev/null +++ b/serialiser/src/main/java/module-info.java @@ -0,0 +1,14 @@ +module io.opencmw.serialiser { + requires static de.gsi.chartfx.dataset; + requires org.slf4j; + requires jsoniter; + requires jdk.unsupported; + requires it.unimi.dsi.fastutil; + requires org.jetbrains.annotations; + + exports io.opencmw.serialiser; + exports io.opencmw.serialiser.annotations; + exports io.opencmw.serialiser.spi; + exports io.opencmw.serialiser.spi.iobuffer; + exports io.opencmw.serialiser.utils; +} \ No newline at end of file diff --git a/serialiser/src/test/java/io/opencmw/serialiser/IoClassSerialiserSimpleTest.java b/serialiser/src/test/java/io/opencmw/serialiser/IoClassSerialiserSimpleTest.java new file mode 100644 index 00000000..034b3079 --- /dev/null +++ b/serialiser/src/test/java/io/opencmw/serialiser/IoClassSerialiserSimpleTest.java @@ -0,0 +1,42 @@ +package io.opencmw.serialiser; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +import io.opencmw.serialiser.spi.BinarySerialiser; +import io.opencmw.serialiser.spi.FastByteBuffer; +import io.opencmw.serialiser.utils.TestDataClass; + +/** + * This is a simple example illustrating the use of the IoClassSerialiser. + * + * @author rstein + */ +class IoClassSerialiserSimpleTest { + @Test + void simpleTest() { + final IoBuffer byteBuffer = new FastByteBuffer(10_000); // alt: new ByteBuffer(10_000); + final IoClassSerialiser ioClassSerialiser = new IoClassSerialiser(byteBuffer, BinarySerialiser.class); + // alt: + // ioClassSerialiser.setMatchedIoSerialiser(BinarySerialiser.class); + // ioClassSerialiser.setMatchedIoSerialiser(CmwLightSerialiser.class); + // ioClassSerialiser.setMatchedIoSerialiser(JsonSerialiser.class); + // ioClassSerialiser.setAutoMatchSerialiser(true); // to auto-detect the suitable serialiser based on serialised data header + + TestDataClass data = new TestDataClass(); // object to be serialised + + byteBuffer.reset(); + ioClassSerialiser.serialiseObject(data); // pojo -> serialised data + // [..] stream/write serialised byteBuffer content [..] + // final byte[] serialisedData = byteBuffer.elements(); + // final int serialisedDataSize = byteBuffer.limit(); + + // [..] stream/read serialised byteBuffer content + byteBuffer.flip(); // mark byte-buffer for reading + TestDataClass received = ioClassSerialiser.deserialiseObject(TestDataClass.class); + + // check data equality, etc... + assertEquals(data, received); + } +} diff --git a/serialiser/src/test/java/io/opencmw/serialiser/IoClassSerialiserTests.java b/serialiser/src/test/java/io/opencmw/serialiser/IoClassSerialiserTests.java new file mode 100644 index 00000000..82655080 --- /dev/null +++ b/serialiser/src/test/java/io/opencmw/serialiser/IoClassSerialiserTests.java @@ -0,0 +1,399 @@ +package io.opencmw.serialiser; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.parallel.ResourceAccessMode.READ_WRITE; + +import java.lang.reflect.InvocationTargetException; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Queue; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.parallel.ResourceLock; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import io.opencmw.serialiser.spi.BinarySerialiser; +import io.opencmw.serialiser.spi.ByteBuffer; +import io.opencmw.serialiser.spi.CmwLightSerialiser; +import io.opencmw.serialiser.spi.FastByteBuffer; +import io.opencmw.serialiser.spi.JsonSerialiser; +import io.opencmw.serialiser.spi.WireDataFieldDescription; + +import de.gsi.dataset.DataSet; +import de.gsi.dataset.spi.DefaultErrorDataSet; +import de.gsi.dataset.spi.utils.MultiArrayBoolean; +import de.gsi.dataset.spi.utils.MultiArrayByte; +import de.gsi.dataset.spi.utils.MultiArrayChar; +import de.gsi.dataset.spi.utils.MultiArrayDouble; +import de.gsi.dataset.spi.utils.MultiArrayFloat; +import de.gsi.dataset.spi.utils.MultiArrayInt; +import de.gsi.dataset.spi.utils.MultiArrayLong; +import de.gsi.dataset.spi.utils.MultiArrayObject; +import de.gsi.dataset.spi.utils.MultiArrayShort; + +/** + * @author rstein + */ +class IoClassSerialiserTests { + private static final int BUFFER_SIZE = 20000; + private static final String GLOBAL_LOCK = "lock"; + + @ParameterizedTest(name = "Serialiser class - {0}") + @ValueSource(classes = { CmwLightSerialiser.class, BinarySerialiser.class, JsonSerialiser.class }) + @ResourceLock(value = GLOBAL_LOCK, mode = READ_WRITE) + void testChangingBuffers(final Class serialiserClass) throws IllegalArgumentException, SecurityException { + final CustomClass2 classUnderTest = new CustomClass2(1.337, 42, "pi equals exactly three!"); + final CustomClass2 classAfterTest = new CustomClass2(); + + IoClassSerialiser serialiser = new IoClassSerialiser(new FastByteBuffer(2 * BUFFER_SIZE), serialiserClass); + serialiser.serialiseObject(classUnderTest); + final byte[] bytes = serialiser.getDataBuffer().elements(); + + final IoClassSerialiser deserialiser = new IoClassSerialiser(new FastByteBuffer(0)); + deserialiser.setDataBuffer(FastByteBuffer.wrap(bytes)); + final Object returnedClass = deserialiser.deserialiseObject(classAfterTest); + + assertSame(classAfterTest, returnedClass); // deserialisation should be in place + assertEquals(classUnderTest, classAfterTest); + } + + @ParameterizedTest(name = "IoBuffer class - {0}") + @ValueSource(classes = { ByteBuffer.class, FastByteBuffer.class }) + @ResourceLock(value = GLOBAL_LOCK, mode = READ_WRITE) + void testCustomSerialiserIdentity(final Class bufferClass) throws InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException, NoSuchMethodException, SecurityException { + assertNotNull(bufferClass, "bufferClass being not null"); + assertNotNull(bufferClass.getConstructor(int.class), "Constructor(Integer) present"); + final IoBuffer buffer = bufferClass.getConstructor(int.class).newInstance(2 * BUFFER_SIZE); + + IoClassSerialiser serialiser = new IoClassSerialiser(buffer, BinarySerialiser.class); + serialiser.setAutoMatchSerialiser(false); + + final AtomicInteger writerCalled = new AtomicInteger(0); + final AtomicInteger returnCalled = new AtomicInteger(0); + + // add custom serialiser - for more examples see classes in de.gsi.dataset.serializer.spi.iobuffer.*Helper + // provide a writer function + final FieldSerialiser.TriConsumer writeFunction = (io, obj, field) -> { + final Object localObj = field == null || field.getField() == null ? obj : field.getField().get(obj); + if (!(localObj instanceof CustomClass)) { + throw new IllegalArgumentException("object " + obj + " is not of type CustomClass"); + } + CustomClass customClass = (CustomClass) localObj; + // place custom elements/composites etc. here - N.B. ordering is of paramount importance since + // these raw fields are not preceded by field headers + io.getBuffer().putDouble(customClass.testDouble); + io.getBuffer().putInt(customClass.testInt); + io.getBuffer().putString(customClass.testString); + // [..] anything that can be generated with the IoSerialiser and/or IoBuffer interfaces + writerCalled.getAndIncrement(); + }; + + // provide a return function (can usually be re-used for the reader function) + final FieldSerialiser.TriFunction returnFunction = (io, obj, field) -> { + final Object sourceField = field == null ? null : field.getField().get(obj); // get raw class field content + + // place reverse custom elements/composites etc. here - N.B. ordering is of paramount importance since + final double doubleVal = io.getBuffer().getDouble(); + final int intVal = io.getBuffer().getInt(); + final String str = io.getBuffer().getString(); + // generate custom object or modify existing one + returnCalled.getAndIncrement(); + if (sourceField == null) { + return new CustomClass(doubleVal, intVal, str); + } else { + if (!(sourceField instanceof CustomClass)) { + throw new IllegalArgumentException("object " + obj + " is not of type CustomClass"); + } + CustomClass customClass = (CustomClass) sourceField; + customClass.testDouble = doubleVal; + customClass.testInt = intVal; + customClass.testString = str; + return customClass; + } + }; + + serialiser.addClassDefinition(new FieldSerialiser<>( // + /* reader */ (io, obj, field) -> field.getField().set(obj, returnFunction.apply(io, obj, field)), // + /* return */ returnFunction, // + /* write */ writeFunction, CustomClass.class)); + + final CustomClass sourceClass = new CustomClass(1.2, 2, "Hello World!"); + final CustomClass destinationClass = new CustomClass(); + + writerCalled.set(0); + returnCalled.set(0); + // serialise-deserialise DataSet + buffer.reset(); // '0' writing at start of buffer + serialiser.serialiseObject(sourceClass); + buffer.reset(); // reset to read position (==0) + final WireDataFieldDescription root = serialiser.getMatchedIoSerialiser().parseIoStream(true); + root.printFieldStructure(); + buffer.reset(); // reset to read position (==0) + final Object returnObject = serialiser.deserialiseObject(destinationClass); + + assertEquals(sourceClass, returnObject); + // TODO: future upgrade that this tests also passes: + // assertEquals(sourceClass, destinationClass) + } + + @ParameterizedTest(name = "IoBuffer class - {0}") + @ValueSource(classes = { ByteBuffer.class, FastByteBuffer.class }) + @SuppressWarnings("unchecked") + void testGenericSerialiserIdentity(final Class bufferClass) throws InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException, NoSuchMethodException, SecurityException { + assertNotNull(bufferClass, "bufferClass being not null"); + assertNotNull(bufferClass.getConstructor(int.class), "Constructor(Integer) present"); + final IoBuffer buffer = bufferClass.getConstructor(int.class).newInstance(2 * BUFFER_SIZE); + + IoClassSerialiser serialiser = new IoClassSerialiser(buffer, BinarySerialiser.class); + + TestClass sourceClass = new TestClass(); + sourceClass.integerBoxed = 1337; + + sourceClass.integerList = new ArrayList<>(); + sourceClass.integerList.add(1); + sourceClass.integerList.add(2); + sourceClass.integerList.add(3); + sourceClass.stringList = new ArrayList<>(); + sourceClass.stringList.add("String1"); + sourceClass.stringList.add("String2"); + sourceClass.dataSet = new DefaultErrorDataSet("test", // + new double[] { 1f, 2f, 3f }, new double[] { 6f, 7f, 8f }, // + new double[] { 0.7f, 0.8f, 0.9f }, new double[] { 7f, 8f, 9f }, 3, false); + sourceClass.dataSetList = new ArrayList<>(); + sourceClass.dataSetList.add(new DefaultErrorDataSet("ListDataSet#1")); + sourceClass.dataSetList.add(new DefaultErrorDataSet("ListDataSet#2")); + sourceClass.dataSetSet = new HashSet<>(); + sourceClass.dataSetSet.add(new DefaultErrorDataSet("SetDataSet#1")); + sourceClass.dataSetSet.add(new DefaultErrorDataSet("SetDataSet#2")); + sourceClass.dataSetQueue = new ArrayDeque<>(); + sourceClass.dataSetQueue.add(new DefaultErrorDataSet("QueueDataSet#1")); + sourceClass.dataSetQueue.add(new DefaultErrorDataSet("QueueDataSet#2")); + + sourceClass.dataSetMap = new HashMap<>(); + sourceClass.dataSetMap.put("dataSet1", new DefaultErrorDataSet("MapDataSet#1")); + sourceClass.dataSetMap.put("dataSet2", new DefaultErrorDataSet("MapDataSet#2")); + + final DefaultErrorDataSet keyDataSet1 = new DefaultErrorDataSet("KeyDataSet#1"); + final DefaultErrorDataSet keyDataSet2 = new DefaultErrorDataSet("KeyDataSet#2"); + sourceClass.dataSetStringMap = new HashMap<>(); + sourceClass.dataSetStringMap.put(keyDataSet1, "keyDataSet1"); + sourceClass.dataSetStringMap.put(keyDataSet2, "keyDataSet2"); + + sourceClass.multiArrayDouble = MultiArrayDouble.wrap(new double[] { 1, 2, 3, 4, 5, 6 }, new int[] { 2, 3 }); + sourceClass.multiArrayFloat = MultiArrayFloat.wrap(new float[] { 1, 2, 3, 4, 5, 6 }, new int[] { 2, 3 }); + sourceClass.multiArrayInt = MultiArrayInt.wrap(new int[] { 1, 2, 3, 4, 5, 6 }, new int[] { 2, 3 }); + sourceClass.multiArrayLong = MultiArrayLong.wrap(new long[] { 1, 2, 3, 4, 5, 6 }, new int[] { 2, 3 }); + sourceClass.multiArrayShort = MultiArrayShort.wrap(new short[] { 1, 2, 3, 4, 5, 6 }, new int[] { 2, 3 }); + sourceClass.multiArrayChar = MultiArrayChar.wrap(new char[] { 1, 2, 3, 4, 5, 6 }, new int[] { 2, 3 }); + sourceClass.multiArrayBoolean = MultiArrayBoolean.wrap(new boolean[] { true, false, false, true, true, false }, new int[] { 2, 3 }); + sourceClass.multiArrayByte = MultiArrayByte.wrap(new byte[] { 1, 2, 3, 4, 5, 6 }, new int[] { 2, 3 }); + sourceClass.multiArrayString = MultiArrayObject.wrap(new String[] { "aa", "ba", "ab", "bb", "ac", "bc" }, new int[] { 2, 3 }); // NOPMD NOSONAR -- String type is known + + TestClass destinationClass = new TestClass(); + destinationClass.nullIntegerList = new ArrayList<>(); + destinationClass.nullDataSet = new DefaultErrorDataSet("nullDataSet"); + assertNotEquals(sourceClass.nullIntegerList, destinationClass.nullIntegerList); + assertNotEquals(sourceClass.nullDataSet, destinationClass.nullDataSet); + + // serialise-deserialise DataSet + buffer.reset(); // '0' writing at start of buffer + serialiser.serialiseObject(sourceClass); + buffer.reset(); // reset to read position (==0) + + final WireDataFieldDescription root = serialiser.getMatchedIoSerialiser().parseIoStream(true); + root.printFieldStructure(); + assertEquals(sourceClass.integerBoxed, ((WireDataFieldDescription) root.findChildField("io.opencmw.serialiser.IoClassSerialiserTests$TestClass").findChildField("integerBoxed")).data()); + buffer.reset(); // reset to read position (==0) + serialiser.deserialiseObject(destinationClass); + + buffer.reset(); // reset to read position (==0) + final Object returnedClass = serialiser.deserialiseObject(destinationClass); + + assertSame(returnedClass, destinationClass); // deserialisation should be should be in-place + + assertEquals(sourceClass.integerBoxed, destinationClass.integerBoxed); + + assertEquals(sourceClass.integerList, destinationClass.integerList); + assertEquals(1, destinationClass.integerList.get(0)); + assertEquals(2, destinationClass.integerList.get(1)); + assertEquals(3, destinationClass.integerList.get(2)); + + assertEquals(sourceClass.stringList, destinationClass.stringList); + assertEquals("String1", destinationClass.stringList.get(0)); + assertEquals("String2", destinationClass.stringList.get(1)); + + // assertEquals(sourceClass.emptyIntegerList, destinationClass.emptyIntegerList); cannot assure that null is serialised will map to empty list + // buffer.reset(); // reset to read position (==0) + // final WireDataFieldDescription root = serialiser.getIoSerialiser().parseIoStream(true); + // root.printFieldStructure(); + + assertEquals(sourceClass.dataSet, destinationClass.dataSet); + // assertEquals(sourceClass.nullDataSet, destinationClass.nullDataSet); + + assertEquals(sourceClass.dataSetList, destinationClass.dataSetList); + assertEquals("ListDataSet#1", destinationClass.dataSetList.get(0).getName()); + assertEquals("ListDataSet#2", destinationClass.dataSetList.get(1).getName()); + + assertEquals(sourceClass.dataSetSet, destinationClass.dataSetSet); + assertTrue(destinationClass.dataSetSet.stream().anyMatch(ds -> ds.getName().equals("SetDataSet#1"))); + assertTrue(destinationClass.dataSetSet.stream().anyMatch(ds -> ds.getName().equals("SetDataSet#2"))); + + //assertEquals(sourceClass.dataSetQueue, destinationClass.dataSetQueue); + assertTrue(destinationClass.dataSetQueue.stream().anyMatch(ds -> ds.getName().equals("QueueDataSet#1"))); + assertTrue(destinationClass.dataSetQueue.stream().anyMatch(ds -> ds.getName().equals("QueueDataSet#2"))); + + assertEquals(sourceClass.dataSetMap, destinationClass.dataSetMap); + assertNotNull(destinationClass.dataSetMap.get("dataSet1")); + assertNotNull(destinationClass.dataSetMap.get("dataSet2")); + + assertEquals(sourceClass.dataSetStringMap, destinationClass.dataSetStringMap); + assertEquals("keyDataSet1", destinationClass.dataSetStringMap.get(keyDataSet1)); + assertEquals("keyDataSet2", destinationClass.dataSetStringMap.get(keyDataSet2)); + + assertEquals(sourceClass.multiArrayDouble, destinationClass.multiArrayDouble); + assertArrayEquals(sourceClass.multiArrayDouble.getDimensions(), destinationClass.multiArrayDouble.getDimensions()); + assertArrayEquals(sourceClass.multiArrayDouble.elements(), destinationClass.multiArrayDouble.elements()); + + assertEquals(sourceClass.multiArrayFloat, destinationClass.multiArrayFloat); + assertArrayEquals(sourceClass.multiArrayFloat.getDimensions(), destinationClass.multiArrayFloat.getDimensions()); + assertArrayEquals(sourceClass.multiArrayFloat.elements(), destinationClass.multiArrayFloat.elements()); + + assertEquals(sourceClass.multiArrayInt, destinationClass.multiArrayInt); + assertArrayEquals(sourceClass.multiArrayInt.getDimensions(), destinationClass.multiArrayInt.getDimensions()); + assertArrayEquals(sourceClass.multiArrayInt.elements(), destinationClass.multiArrayInt.elements()); + + assertEquals(sourceClass.multiArrayLong, destinationClass.multiArrayLong); + assertArrayEquals(sourceClass.multiArrayLong.getDimensions(), destinationClass.multiArrayLong.getDimensions()); + assertArrayEquals(sourceClass.multiArrayLong.elements(), destinationClass.multiArrayLong.elements()); + + assertEquals(sourceClass.multiArrayShort, destinationClass.multiArrayShort); + assertArrayEquals(sourceClass.multiArrayShort.getDimensions(), destinationClass.multiArrayShort.getDimensions()); + assertArrayEquals(sourceClass.multiArrayShort.elements(), destinationClass.multiArrayShort.elements()); + + assertEquals(sourceClass.multiArrayByte, destinationClass.multiArrayByte); + assertArrayEquals(sourceClass.multiArrayByte.getDimensions(), destinationClass.multiArrayByte.getDimensions()); + assertArrayEquals(sourceClass.multiArrayByte.elements(), destinationClass.multiArrayByte.elements()); + + assertEquals(sourceClass.multiArrayChar, destinationClass.multiArrayChar); + assertArrayEquals(sourceClass.multiArrayChar.getDimensions(), destinationClass.multiArrayChar.getDimensions()); + assertArrayEquals(sourceClass.multiArrayChar.elements(), destinationClass.multiArrayChar.elements()); + + assertEquals(sourceClass.multiArrayBoolean, destinationClass.multiArrayBoolean); + assertArrayEquals(sourceClass.multiArrayBoolean.getDimensions(), destinationClass.multiArrayBoolean.getDimensions()); + assertArrayEquals(sourceClass.multiArrayBoolean.elements(), destinationClass.multiArrayBoolean.elements()); + + assertEquals(sourceClass.multiArrayString, destinationClass.multiArrayString); + assertArrayEquals(sourceClass.multiArrayString.getDimensions(), destinationClass.multiArrayString.getDimensions()); + assertArrayEquals(sourceClass.multiArrayString.elements(), destinationClass.multiArrayString.elements()); + } + + static class CustomClass { + public double testDouble; + public int testInt; + public String testString; + + public CustomClass() { + this(-1.0, -1, "null string"); // null instantiation + } + public CustomClass(final double testDouble, final int testInt, final String testString) { + this.testDouble = testDouble; + this.testInt = testInt; + this.testString = testString; + } + + @Override + public boolean equals(final Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + final CustomClass that = (CustomClass) o; + return Double.compare(that.testDouble, testDouble) == 0 && testInt == that.testInt && Objects.equals(testString, that.testString); + } + + @Override + public int hashCode() { + return Objects.hash(testDouble, testInt, testString); + } + + @Override + public String toString() { + return "CustomClass(" + testDouble + ", " + testInt + ", " + testString + ")"; + } + } + + /** + * Duplicate of CustomClass, because Custom Class gets registered for a custom (de)serialiser and we don't want that for all tests. + */ + @SuppressWarnings("CanBeFinal") + public static class CustomClass2 { + public double testDouble; + public int testInt; + public String testString; + + public CustomClass2() { + this(-1.0, -1, "null string"); // null instantiation + } + public CustomClass2(final double testDouble, final int testInt, final String testString) { + this.testDouble = testDouble; + this.testInt = testInt; + this.testString = testString; + } + + @Override + public boolean equals(final Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + final CustomClass2 that = (CustomClass2) o; + return Double.compare(that.testDouble, testDouble) == 0 && testInt == that.testInt && Objects.equals(testString, that.testString); + } + + @Override + public int hashCode() { + return Objects.hash(testDouble, testInt, testString); + } + + @Override + public String toString() { + return "CustomClass2(" + testDouble + ", " + testInt + ", " + testString + ")"; + } + } + + /** + * small test class to test (de-)serialisation of wrapped and/or compound object types + */ + static class TestClass { + public Integer integerBoxed; + + public List integerList; + public List stringList; + public List nullIntegerList; + public DataSet dataSet; + public DataSet nullDataSet; + public List dataSetList; + public Set dataSetSet; + public Queue dataSetQueue; + + public Map dataSetMap; + public Map dataSetStringMap; + + public MultiArrayDouble multiArrayDouble; + public MultiArrayFloat multiArrayFloat; + public MultiArrayInt multiArrayInt; + public MultiArrayLong multiArrayLong; + public MultiArrayShort multiArrayShort; + public MultiArrayByte multiArrayByte; + public MultiArrayChar multiArrayChar; + public MultiArrayBoolean multiArrayBoolean; + public MultiArrayObject multiArrayString; + } +} diff --git a/serialiser/src/test/java/io/opencmw/serialiser/IoSerialiserTests.java b/serialiser/src/test/java/io/opencmw/serialiser/IoSerialiserTests.java new file mode 100644 index 00000000..6b706fcf --- /dev/null +++ b/serialiser/src/test/java/io/opencmw/serialiser/IoSerialiserTests.java @@ -0,0 +1,345 @@ +package io.opencmw.serialiser; + +import static org.junit.jupiter.api.Assertions.*; + +import static de.gsi.dataset.DataSet.DIM_X; + +import java.lang.reflect.InvocationTargetException; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.ArgumentsProvider; +import org.junit.jupiter.params.provider.ArgumentsSource; +import org.junit.jupiter.params.provider.ValueSource; + +import io.opencmw.serialiser.spi.BinarySerialiser; +import io.opencmw.serialiser.spi.ByteBuffer; +import io.opencmw.serialiser.spi.CmwLightSerialiser; +import io.opencmw.serialiser.spi.FastByteBuffer; +import io.opencmw.serialiser.spi.JsonSerialiser; +import io.opencmw.serialiser.spi.WireDataFieldDescription; +import io.opencmw.serialiser.spi.helper.MyGenericClass; +import io.opencmw.serialiser.utils.CmwLightHelper; +import io.opencmw.serialiser.utils.JsonHelper; +import io.opencmw.serialiser.utils.SerialiserHelper; +import io.opencmw.serialiser.utils.TestDataClass; + +import de.gsi.dataset.DataSet; +import de.gsi.dataset.spi.DoubleDataSet; + +class IoSerialiserTests { + private static final int BUFFER_SIZE = 50000; + + @ParameterizedTest + @ValueSource(classes = { ByteBuffer.class, FastByteBuffer.class }) + void simpleStreamerTest(final Class bufferClass) throws NoSuchMethodException, SecurityException, InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException { + assertNotNull(bufferClass, "bufferClass being not null"); + assertNotNull(bufferClass.getConstructor(int.class), "Constructor(Integer) present"); + final IoBuffer buffer = bufferClass.getConstructor(int.class).newInstance(1000000); + + // check reading/writing + final MyGenericClass inputObject = new MyGenericClass(); + MyGenericClass outputObject1 = new MyGenericClass(); + MyGenericClass.setVerboseChecks(true); + + // first test - check for equal initialisation -- this should be trivial + assertEquals(inputObject, outputObject1); + + //final IoBuffer buffer = new FastByteBuffer(1000000); + final IoClassSerialiser serialiser = new IoClassSerialiser(buffer, BinarySerialiser.class); + serialiser.serialiseObject(inputObject); + + buffer.flip(); + outputObject1 = serialiser.deserialiseObject(outputObject1); + + // second test - both vectors should have the same initial values + // after serialise/deserialise + assertEquals(inputObject, outputObject1); + + MyGenericClass outputObject2 = new MyGenericClass(); + buffer.reset(); + buffer.clear(); + // modify input object w.r.t. init values + inputObject.modifyValues(); + inputObject.boxedPrimitives.modifyValues(); + inputObject.arrays.modifyValues(); + inputObject.objArrays.modifyValues(); + + serialiser.serialiseObject(inputObject); + + buffer.flip(); + outputObject2 = serialiser.deserialiseObject(outputObject2); + + // third test - both vectors should have the same modified values + assertEquals(inputObject, outputObject2); + } + + @DisplayName("basic custom serialisation/deserialisation identity") + @ParameterizedTest(name = "IoBuffer class - {0} recursion level {1}") + @ArgumentsSource(IoBufferHierarchyArgumentProvider.class) + void testCustomSerialiserIdentity(final Class bufferClass, final int hierarchyLevel) throws NoSuchMethodException, SecurityException, InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException { + assertNotNull(bufferClass, "bufferClass being not null"); + assertNotNull(bufferClass.getConstructor(int.class), "Constructor(Integer) present"); + final IoBuffer buffer = bufferClass.getConstructor(int.class).newInstance(BUFFER_SIZE); + final BinarySerialiser ioSerialiser = new BinarySerialiser(buffer); // TODO: generalise to IoBuffer + + final TestDataClass inputObject = new TestDataClass(10, 100, hierarchyLevel); + final TestDataClass outputObject = new TestDataClass(-1, -1, 0); + + buffer.reset(); + SerialiserHelper.serialiseCustom(ioSerialiser, inputObject); + + buffer.flip(); + SerialiserHelper.deserialiseCustom(ioSerialiser, outputObject); + + // second test - both vectors should have the same initial values after serialise/deserialise + assertArrayEquals(inputObject.stringArray, outputObject.stringArray); + + assertEquals(inputObject, outputObject, "TestDataClass input-output equality"); + } + + @ParameterizedTest + @DisplayName("basic DataSet serialisation/deserialisation identity") + @ValueSource(classes = { ByteBuffer.class, FastByteBuffer.class }) + void testCustomDataSetSerialiserIdentity(final Class bufferClass) throws NoSuchMethodException, SecurityException, InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException { + assertNotNull(bufferClass, "bufferClass being not null"); + assertNotNull(bufferClass.getConstructor(int.class), "Constructor(Integer) present"); + final IoBuffer buffer = bufferClass.getConstructor(int.class).newInstance(BUFFER_SIZE); + + final IoClassSerialiser serialiser = new IoClassSerialiser(buffer, BinarySerialiser.class); + // disable auto serialiser selection because otherwise it will get detected as CMWLightSerialiser (strangely only for FastByteBuffer) + serialiser.setAutoMatchSerialiser(false); + assertEquals(bufferClass, buffer.getClass()); + assertEquals(bufferClass, serialiser.getDataBuffer().getClass()); + + final DoubleDataSet inputObject = new DoubleDataSet("inputObject"); + DataSet outputObject = new DoubleDataSet("outputObject"); + assertNotEquals(inputObject, outputObject); + + buffer.reset(); + serialiser.serialiseObject(inputObject); + buffer.flip(); + outputObject = serialiser.deserialiseObject(outputObject); + + assertEquals(inputObject, outputObject); + + inputObject.add(0.0, 1.0); + inputObject.getAxisDescription(DIM_X).set("time", "s"); + + buffer.reset(); + serialiser.serialiseObject(inputObject); + buffer.flip(); + outputObject = serialiser.deserialiseObject(outputObject); + + assertEquals(inputObject, outputObject); + + inputObject.addDataLabel(0, "MyCustomDataLabel"); + inputObject.addDataStyle(0, "MyCustomDataStyle"); + inputObject.setStyle("myDataSetStyle"); + + buffer.reset(); + serialiser.serialiseObject(inputObject); + buffer.flip(); + outputObject = serialiser.deserialiseObject(outputObject); + + assertEquals(inputObject, outputObject); + } + + @DisplayName("basic POJO serialisation/deserialisation identity") + @ParameterizedTest(name = "IoBuffer class - {0} recursion level {1}") + @ArgumentsSource(IoBufferHierarchyArgumentProvider.class) + void testIoBufferSerialiserIdentity(final Class bufferClass, final int hierarchyLevel) throws IllegalAccessException, InstantiationException, IllegalArgumentException, InvocationTargetException, NoSuchMethodException, SecurityException { + assertNotNull(bufferClass, "bufferClass being not null"); + assertNotNull(bufferClass.getConstructor(int.class), "Constructor(Integer) present"); + final IoBuffer buffer = bufferClass.getConstructor(int.class).newInstance(BUFFER_SIZE); + + final IoClassSerialiser ioClassSerialiser = new IoClassSerialiser(buffer, BinarySerialiser.class); + final TestDataClass inputObject = new TestDataClass(10, 100, hierarchyLevel); + final TestDataClass outputObject = new TestDataClass(-1, -1, 0); + + buffer.reset(); + ioClassSerialiser.serialiseObject(inputObject); + + buffer.flip(); + final Object returnedObject = ioClassSerialiser.deserialiseObject(outputObject); + + assertSame(outputObject, returnedObject, "Deserialisation should be in-place"); + + // second test - both vectors should have the same initial values after serialise/deserialise + assertArrayEquals(inputObject.stringArray, outputObject.stringArray); + + assertEquals(inputObject, outputObject, "TestDataClass input-output equality"); + } + + @DisplayName("basic POJO IoSerialiser identity - scan") + @ParameterizedTest(name = "IoSerialiser {0} - IoBuffer class {1} - recursion level {2}") + @ArgumentsSource(IoSerialiserHierarchyArgumentProvider.class) + void testParsingInterface(final Class ioSerialiserClass, final Class bufferClass, final int hierarchyLevel) throws IllegalAccessException, InstantiationException, IllegalArgumentException, InvocationTargetException, NoSuchMethodException, SecurityException { + assertNotNull(bufferClass, "bufferClass being not null"); + assertNotNull(bufferClass.getConstructor(int.class), "Constructor(Integer) present"); + assertNotNull(ioSerialiserClass, "ioSerialiserClass being not null"); + assertNotNull(ioSerialiserClass.getConstructor(IoBuffer.class), "Constructor(IoBuffer) present"); + final IoBuffer buffer = bufferClass.getConstructor(int.class).newInstance(BUFFER_SIZE); + + final IoClassSerialiser ioClassSerialiser = new IoClassSerialiser(buffer, ioSerialiserClass); + assertEquals(ioClassSerialiser.getMatchedIoSerialiser().getClass(), ioSerialiserClass, "matching class type"); + + final TestDataClass inputObject = new TestDataClass(10, 100, hierarchyLevel); + final TestDataClass outputObject = new TestDataClass(-1, -1, 0); + + buffer.reset(); + ioClassSerialiser.serialiseObject(inputObject); + // if (ioSerialiserClass.equals(JsonSerialiser.class)) { + // System.err.println("json output:="); + // final int pos = buffer.position(); + // System.err.println("data = " + new String(buffer.elements(), 0, pos)); + // } + + buffer.flip(); + final Object returnedObject = ioClassSerialiser.deserialiseObject(outputObject); + + assertSame(outputObject, returnedObject, "Deserialisation should be in-place"); + + assertEquals(inputObject, outputObject, "TestDataClass input-output equality"); + + buffer.reset(); + final WireDataFieldDescription rootField = ioClassSerialiser.parseWireFormat(); + //rootField.printFieldStructure(); + + assertEquals("ROOT", rootField.getFieldName()); + final WireDataFieldDescription classFields = (WireDataFieldDescription) (rootField.getChildren().get(0)); + for (FieldDescription field : classFields.getChildren()) { + final WireDataFieldDescription wireField = (WireDataFieldDescription) field; + final DataType dataType = wireField.getDataType(); + if ((dataType.isScalar() || dataType.isArray()) && dataType != DataType.START_MARKER && dataType != DataType.END_MARKER) { + final Object data = wireField.data(); + assertNotNull(data, "field non null for: " + wireField.getFieldName()); + assertEquals(dataType, DataType.fromClassType(data.getClass()), "field object type match for: " + wireField.getFieldName()); + } + } + + final WireDataFieldDescription boolField = (WireDataFieldDescription) (classFields.findChildField("bool1")); + assertNotNull(boolField); + assertEquals(inputObject.bool1, boolField.data(), "bool1 data field content"); + assertEquals(inputObject.bool2, ((WireDataFieldDescription) (classFields.findChildField("bool2"))).data(), "bool2 data field content"); + assertEquals(inputObject.byte1, ((WireDataFieldDescription) (classFields.findChildField("byte1"))).data(DataType.BYTE), "byte1 data field content"); + assertEquals(inputObject.byte2, ((WireDataFieldDescription) (classFields.findChildField("byte2"))).data(DataType.BYTE), "byte2 data field content"); + + if (!ioSerialiserClass.equals(JsonSerialiser.class)) { + assertArrayEquals(inputObject.boolArray, (boolean[]) ((WireDataFieldDescription) (classFields.findChildField("boolArray"))).data(), "intArray data field content"); + assertArrayEquals(inputObject.byteArray, (byte[]) ((WireDataFieldDescription) (classFields.findChildField("byteArray"))).data(), "byteArray data field content"); + assertArrayEquals(inputObject.shortArray, (short[]) ((WireDataFieldDescription) (classFields.findChildField("shortArray"))).data(), "shortArray data field content"); + assertArrayEquals(inputObject.intArray, (int[]) ((WireDataFieldDescription) (classFields.findChildField("intArray"))).data(), "intArray data field content"); + assertArrayEquals(inputObject.longArray, (long[]) ((WireDataFieldDescription) (classFields.findChildField("longArray"))).data(), "longArray data field content"); + assertArrayEquals(inputObject.floatArray, (float[]) ((WireDataFieldDescription) (classFields.findChildField("floatArray"))).data(), "floatArray data field content"); + assertArrayEquals(inputObject.doubleArray, (double[]) ((WireDataFieldDescription) (classFields.findChildField("doubleArray"))).data(), "doubleArray data field content"); + assertArrayEquals(inputObject.stringArray, (String[]) ((WireDataFieldDescription) (classFields.findChildField("stringArray"))).data(), "stringArray data field content"); + } + + assertArrayEquals(inputObject.boolArray, (boolean[]) ((WireDataFieldDescription) (classFields.findChildField("boolArray"))).data(DataType.BOOL_ARRAY), "intArray data field content"); + assertArrayEquals(inputObject.byteArray, (byte[]) ((WireDataFieldDescription) (classFields.findChildField("byteArray"))).data(DataType.BYTE_ARRAY), "byteArray data field content"); + assertArrayEquals(inputObject.shortArray, (short[]) ((WireDataFieldDescription) (classFields.findChildField("shortArray"))).data(DataType.SHORT_ARRAY), "shortArray data field content"); + assertArrayEquals(inputObject.intArray, (int[]) ((WireDataFieldDescription) (classFields.findChildField("intArray"))).data(DataType.INT_ARRAY), "intArray data field content"); + assertArrayEquals(inputObject.longArray, (long[]) ((WireDataFieldDescription) (classFields.findChildField("longArray"))).data(DataType.LONG_ARRAY), "longArray data field content"); + assertArrayEquals(inputObject.floatArray, (float[]) ((WireDataFieldDescription) (classFields.findChildField("floatArray"))).data(DataType.FLOAT_ARRAY), "floatArray data field content"); + assertArrayEquals(inputObject.doubleArray, (double[]) ((WireDataFieldDescription) (classFields.findChildField("doubleArray"))).data(DataType.DOUBLE_ARRAY), "doubleArray data field content"); + assertArrayEquals(inputObject.stringArray, (String[]) ((WireDataFieldDescription) (classFields.findChildField("stringArray"))).data(DataType.STRING_ARRAY), "stringArray data field content"); + } + + @DisplayName("benchmark identity tests") + @Test + void benchmarkIdentityTests() { + final TestDataClass inputObject = new TestDataClass(10, 100, 1); + final TestDataClass outputObject = new TestDataClass(-1, -1, 0); + + // execute thrice to ensure that buffer flipping/state is cleaned-up properly + for (int i = 0; i < 2; i++) { + assertDoesNotThrow(() -> {}); + // assertDoesNotThrow(() -> CmwHelper.checkSerialiserIdentity(inputObject, outputObject)); + Assertions.assertDoesNotThrow(() -> CmwLightHelper.checkSerialiserIdentity(inputObject, outputObject)); + assertDoesNotThrow(() -> CmwLightHelper.checkCustomSerialiserIdentity(inputObject, outputObject)); + Assertions.assertDoesNotThrow(() -> JsonHelper.checkSerialiserIdentity(inputObject, outputObject)); + assertDoesNotThrow(() -> JsonHelper.checkCustomSerialiserIdentity(inputObject, outputObject)); + + assertDoesNotThrow(() -> SerialiserHelper.checkSerialiserIdentity(inputObject, outputObject)); + assertDoesNotThrow(() -> SerialiserHelper.checkCustomSerialiserIdentity(inputObject, outputObject)); + // assertDoesNotThrow(() -> FlatBuffersHelper.checkCustomSerialiserIdentity(inputObject, outputObject)); + } + } + + @DisplayName("benchmark performance tests") + @Test + void benchmarkPerformanceTests() { + final TestDataClass inputObject = new TestDataClass(10, 100, 1); + final TestDataClass outputObject = new TestDataClass(-1, -1, 0); + final int nIterations = 1; + // execute thrice to ensure that buffer flipping/state is cleaned-up properly + for (int i = 0; i < 2; i++) { + assertDoesNotThrow(() -> {}); + + // map-only performance + assertDoesNotThrow(() -> JsonHelper.testSerialiserPerformanceMap(nIterations, inputObject)); + // assertDoesNotThrow(() -> CmwHelper.testSerialiserPerformanceMap(nIterations, inputObject, outputObject)); + assertDoesNotThrow(() -> CmwLightHelper.testSerialiserPerformanceMap(nIterations, inputObject)); + assertDoesNotThrow(() -> SerialiserHelper.testSerialiserPerformanceMap(nIterations, inputObject)); + + // custom serialiser performance + assertDoesNotThrow(() -> JsonHelper.testCustomSerialiserPerformance(nIterations, inputObject, outputObject)); + // assertDoesNotThrow(() -> FlatBuffersHelper.testCustomSerialiserPerformance(nIterations, inputObject, outputObject)); + assertDoesNotThrow(() -> CmwLightHelper.testCustomSerialiserPerformance(nIterations, inputObject, outputObject)); + assertDoesNotThrow(() -> SerialiserHelper.testCustomSerialiserPerformance(nIterations, inputObject, outputObject)); + + // POJO performance + assertDoesNotThrow(() -> JsonHelper.testPerformancePojo(nIterations, inputObject, outputObject)); + assertDoesNotThrow(() -> JsonHelper.testPerformancePojoCodeGen(nIterations, inputObject, outputObject)); + // assertDoesNotThrow(() -> CmwHelper.testPerformancePojo(nIterations, inputObject, outputObject)); + assertDoesNotThrow(() -> CmwLightHelper.testPerformancePojo(nIterations, inputObject, outputObject)); + assertDoesNotThrow(() -> SerialiserHelper.testPerformancePojo(nIterations, inputObject, outputObject)); + } + } + + private static class IoBufferHierarchyArgumentProvider implements ArgumentsProvider { + @Override + public Stream provideArguments(ExtensionContext context) { + return Stream.of( + Arguments.of(ByteBuffer.class, 0), + Arguments.of(ByteBuffer.class, 1), + Arguments.of(ByteBuffer.class, 2), + Arguments.of(ByteBuffer.class, 3), + Arguments.of(ByteBuffer.class, 4), + Arguments.of(ByteBuffer.class, 5), + Arguments.of(FastByteBuffer.class, 0), + Arguments.of(FastByteBuffer.class, 1), + Arguments.of(FastByteBuffer.class, 2), + Arguments.of(FastByteBuffer.class, 3), + Arguments.of(FastByteBuffer.class, 4), + Arguments.of(FastByteBuffer.class, 5)); + } + } + + private static class IoSerialiserHierarchyArgumentProvider implements ArgumentsProvider { + @Override + public Stream provideArguments(ExtensionContext context) { + return Stream.of( + Arguments.of(BinarySerialiser.class, ByteBuffer.class, 0), + Arguments.of(BinarySerialiser.class, ByteBuffer.class, 1), + Arguments.of(BinarySerialiser.class, FastByteBuffer.class, 0), + Arguments.of(BinarySerialiser.class, FastByteBuffer.class, 1), + + Arguments.of(CmwLightSerialiser.class, ByteBuffer.class, 0), + Arguments.of(CmwLightSerialiser.class, ByteBuffer.class, 1), + Arguments.of(CmwLightSerialiser.class, FastByteBuffer.class, 0), + Arguments.of(CmwLightSerialiser.class, FastByteBuffer.class, 1), + + Arguments.of(JsonSerialiser.class, ByteBuffer.class, 0), + Arguments.of(JsonSerialiser.class, ByteBuffer.class, 1), + Arguments.of(JsonSerialiser.class, FastByteBuffer.class, 0), + Arguments.of(JsonSerialiser.class, FastByteBuffer.class, 1)); + } + } +} diff --git a/serialiser/src/test/java/io/opencmw/serialiser/annotations/SerialiserAnnotationTests.java b/serialiser/src/test/java/io/opencmw/serialiser/annotations/SerialiserAnnotationTests.java new file mode 100644 index 00000000..3859e6b0 --- /dev/null +++ b/serialiser/src/test/java/io/opencmw/serialiser/annotations/SerialiserAnnotationTests.java @@ -0,0 +1,105 @@ +package io.opencmw.serialiser.annotations; + +import static org.junit.jupiter.api.Assertions.*; + +import java.lang.reflect.InvocationTargetException; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import io.opencmw.serialiser.FieldDescription; +import io.opencmw.serialiser.IoBuffer; +import io.opencmw.serialiser.IoClassSerialiser; +import io.opencmw.serialiser.spi.BinarySerialiser; +import io.opencmw.serialiser.spi.ByteBuffer; +import io.opencmw.serialiser.spi.ClassFieldDescription; +import io.opencmw.serialiser.spi.FastByteBuffer; +import io.opencmw.serialiser.spi.WireDataFieldDescription; +import io.opencmw.serialiser.utils.ClassUtils; + +class SerialiserAnnotationTests { + private static final int BUFFER_SIZE = 40000; + + @Test + void testAnnotationGeneration() { + // test annotation parsing on the generation side + final AnnotatedDataClass dataClass = new AnnotatedDataClass(); + final ClassFieldDescription classFieldDescription = ClassUtils.getFieldDescription(dataClass.getClass()); + // classFieldDescription.printFieldStructure(); + + final FieldDescription energyField = classFieldDescription.findChildField("energy".hashCode(), "energy"); + assertNotNull(energyField); + assertEquals("GeV/u", energyField.getFieldUnit()); + assertEquals("energy description", energyField.getFieldDescription()); + assertEquals("OUT", energyField.getFieldDirection()); + assertFalse(energyField.getFieldGroups().isEmpty()); + assertEquals("A", energyField.getFieldGroups().get(0)); + + final FieldDescription temperatureField = classFieldDescription.findChildField("temperature".hashCode(), "temperature"); + assertNotNull(temperatureField); + assertEquals("°C", temperatureField.getFieldUnit()); + assertEquals("important temperature reading", temperatureField.getFieldDescription()); + assertEquals("OUT", temperatureField.getFieldDirection()); + assertFalse(temperatureField.getFieldGroups().isEmpty()); + assertEquals(2, temperatureField.getFieldGroups().size()); + assertEquals("A", temperatureField.getFieldGroups().get(0)); + assertEquals("B", temperatureField.getFieldGroups().get(1)); + } + + @DisplayName("basic custom serialisation/deserialisation identity") + @ParameterizedTest(name = "IoBuffer class - {0} recursion level {1}") + @ValueSource(classes = { ByteBuffer.class, FastByteBuffer.class }) + void testCustomSerialiserIdentity(final Class bufferClass) throws NoSuchMethodException, SecurityException, InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException { + assertNotNull(bufferClass, "bufferClass being not null"); + assertNotNull(bufferClass.getConstructor(int.class), "Constructor(Integer) present"); + final IoBuffer buffer = bufferClass.getConstructor(int.class).newInstance(BUFFER_SIZE); + final BinarySerialiser ioSerialiser = new BinarySerialiser(buffer); + final IoClassSerialiser serialiser = new IoClassSerialiser(buffer, ioSerialiser.getClass()); + + final AnnotatedDataClass inputObject = new AnnotatedDataClass(); + + buffer.reset(); + serialiser.serialiseObject(inputObject); + + buffer.reset(); + final WireDataFieldDescription root = ioSerialiser.parseIoStream(true); + final FieldDescription serialiserFieldDescriptions = root.getChildren().get(0); + + final FieldDescription energyField = serialiserFieldDescriptions.findChildField("energy".hashCode(), "energy"); + assertNotNull(energyField); + assertEquals("GeV/u", energyField.getFieldUnit()); + assertEquals("energy description", energyField.getFieldDescription()); + assertEquals("OUT", energyField.getFieldDirection()); + assertFalse(energyField.getFieldGroups().isEmpty()); + assertEquals("A", energyField.getFieldGroups().get(0)); + + final FieldDescription temperatureField = serialiserFieldDescriptions.findChildField("temperature".hashCode(), "temperature"); + assertNotNull(temperatureField); + assertEquals("°C", temperatureField.getFieldUnit()); + assertEquals("important temperature reading", temperatureField.getFieldDescription()); + assertEquals("OUT", temperatureField.getFieldDirection()); + assertFalse(temperatureField.getFieldGroups().isEmpty()); + assertEquals(2, temperatureField.getFieldGroups().size()); + assertEquals("A", temperatureField.getFieldGroups().get(0)); + assertEquals("B", temperatureField.getFieldGroups().get(1)); + } + + @Description("this class is used to test field annotation") + public static class AnnotatedDataClass { + @MetaInfo(unit = "GeV/u", description = "energy description", direction = "OUT", groups = "A") + public double energy; + + @Unit("°C") + @Description("important temperature reading") + @Direction("OUT") + @Groups({ "A", "B" }) + public double temperature; + + @Unit("V") + @Description("control variable") + @Direction("IN/OUT") + public double controlVariable; + } +} diff --git a/serialiser/src/test/java/io/opencmw/serialiser/benchmark/DataSetSerialiserBenchmark.java b/serialiser/src/test/java/io/opencmw/serialiser/benchmark/DataSetSerialiserBenchmark.java new file mode 100644 index 00000000..ea49b772 --- /dev/null +++ b/serialiser/src/test/java/io/opencmw/serialiser/benchmark/DataSetSerialiserBenchmark.java @@ -0,0 +1,114 @@ +package io.opencmw.serialiser.benchmark; + +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +import io.opencmw.serialiser.IoClassSerialiser; +import io.opencmw.serialiser.spi.BinarySerialiser; +import io.opencmw.serialiser.spi.ByteBuffer; +import io.opencmw.serialiser.spi.FastByteBuffer; + +import de.gsi.dataset.DataSet; +import de.gsi.dataset.spi.DoubleDataSet; +import de.gsi.dataset.testdata.spi.GaussFunction; + +/** + * Simple benchmark to verify that the in-place DataSet (de-)serialiser is not significantly slower than creating a new DataSet + * + * Benchmark Mode Cnt Score Error Units + * DataSetSerialiserBenchmark.serialiserRoundTripByteBufferInplace thrpt 10 5971.023 ± 100.145 ops/s + * DataSetSerialiserBenchmark.serialiserRoundTripByteBufferNewDataSet thrpt 10 5652.462 ± 114.474 ops/s + * DataSetSerialiserBenchmark.serialiserRoundTripFastByteBufferInplace thrpt 10 7468.360 ± 126.494 ops/s + * DataSetSerialiserBenchmark.serialiserRoundTripFastByteBufferNewDataSet thrpt 10 7272.097 ± 170.991 ops/s + * + * @author rstein + */ +@State(Scope.Benchmark) +public class DataSetSerialiserBenchmark { + private static final IoClassSerialiser serialiserFastByteBuffer = new IoClassSerialiser(new FastByteBuffer(200_000), BinarySerialiser.class); + private static final IoClassSerialiser serialiserByteBuffer = new IoClassSerialiser(new ByteBuffer(200_000), BinarySerialiser.class); + private static final DataSet srcDataSet = new DoubleDataSet(new GaussFunction("Gauss-function", 10_000)); + private static final DataSet copyDataSet = new DoubleDataSet(srcDataSet); + private static final TestClass source = new TestClass(); + private static final TestClass copy = new TestClass(); + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public void serialiserRoundTripByteBufferInplace(Blackhole blackhole) { + final IoClassSerialiser classSerialiser = serialiserByteBuffer; + source.dataSet = srcDataSet; + copy.dataSet = copyDataSet; + + // serialise-deserialise DataSet + classSerialiser.getDataBuffer().reset(); // '0' writing at start of buffer + classSerialiser.serialiseObject(source); + + classSerialiser.getDataBuffer().flip(); // reset to read position (==0) + classSerialiser.deserialiseObject(copy); + + blackhole.consume(copy); + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public void serialiserRoundTripByteBufferNewDataSet(Blackhole blackhole) { + final IoClassSerialiser classSerialiser = serialiserByteBuffer; + source.dataSet = srcDataSet; + copy.dataSet = null; + + // serialise-deserialise DataSet + classSerialiser.getDataBuffer().reset(); // '0' writing at start of buffer + classSerialiser.serialiseObject(source); + + classSerialiser.getDataBuffer().flip(); // reset to read position (==0) + classSerialiser.deserialiseObject(copy); + + blackhole.consume(copy); + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public void serialiserRoundTripFastByteBufferInplace(Blackhole blackhole) { + final IoClassSerialiser classSerialiser = serialiserFastByteBuffer; + source.dataSet = srcDataSet; + copy.dataSet = copyDataSet; + + // serialise-deserialise DataSet + classSerialiser.getDataBuffer().reset(); // '0' writing at start of buffer + classSerialiser.serialiseObject(source); + + classSerialiser.getDataBuffer().flip(); // reset to read position (==0) + classSerialiser.deserialiseObject(copy); + + blackhole.consume(copy); + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public void serialiserRoundTripFastByteBufferNewDataSet(Blackhole blackhole) { + final IoClassSerialiser classSerialiser = serialiserFastByteBuffer; + source.dataSet = srcDataSet; + copy.dataSet = null; + + // serialise-deserialise DataSet + classSerialiser.getDataBuffer().reset(); // '0' writing at start of buffer + classSerialiser.serialiseObject(source); + + classSerialiser.getDataBuffer().flip(); // reset to read position (==0) + classSerialiser.deserialiseObject(copy); + + blackhole.consume(copy); + } + + static class TestClass { + public DataSet dataSet; + } +} diff --git a/serialiser/src/test/java/io/opencmw/serialiser/benchmark/FastByteBufferBenchmark.java b/serialiser/src/test/java/io/opencmw/serialiser/benchmark/FastByteBufferBenchmark.java new file mode 100644 index 00000000..70c06c08 --- /dev/null +++ b/serialiser/src/test/java/io/opencmw/serialiser/benchmark/FastByteBufferBenchmark.java @@ -0,0 +1,37 @@ +package io.opencmw.serialiser.benchmark; + +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +import io.opencmw.serialiser.spi.FastByteBuffer; + +/** + * Simple benchmark to evaluate the effect of range checks on FastByteBuffer performance + * + * Without range check: + * FastByteBufferBenchmark.putInts thrpt 10 40419,996 ± 3480,205 ops/s + * With range check: + * FastByteBufferBenchmark.putInts thrpt 10 40108,335 ± 912,071 ops/s + * + * @author akrimm + */ +@State(Scope.Benchmark) +public class FastByteBufferBenchmark { + private static final FastByteBuffer fastByteBuffer = new FastByteBuffer(200_000); + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public void putInts(Blackhole blackhole) { + fastByteBuffer.reset(); + for (int i = 0; i < 200_000 / 16; ++i) { + fastByteBuffer.putInt(blackhole.i1); + fastByteBuffer.putInt(blackhole.i2); + } + blackhole.consume(fastByteBuffer.elements()); + } +} diff --git a/serialiser/src/test/java/io/opencmw/serialiser/benchmark/JsonSelectionBenchmark.java b/serialiser/src/test/java/io/opencmw/serialiser/benchmark/JsonSelectionBenchmark.java new file mode 100644 index 00000000..38351a79 --- /dev/null +++ b/serialiser/src/test/java/io/opencmw/serialiser/benchmark/JsonSelectionBenchmark.java @@ -0,0 +1,128 @@ +package io.opencmw.serialiser.benchmark; + +import java.io.IOException; + +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +import io.opencmw.serialiser.utils.TestDataClass; + +import com.alibaba.fastjson.JSON; +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.PropertyAccessor; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.jsoniter.JsonIterator; +import com.jsoniter.output.EncodingMode; +import com.jsoniter.output.JsonStream; +import com.jsoniter.spi.DecodingMode; + +/** + * simple benchmark to evaluate various JSON libraries. + * N.B. This is not intended as a complete JSON serialiser evaluation but to indicate some rough trends. + * + * testClassId 1: being a string-heavy test data class + * testClassId 2: being a numeric-data-heavy test data class + * + * Benchmark (testClassId) Mode Cnt Score Error Units + * JsonSelectionBenchmark.pojoFastJson string-heavy thrpt 10 12857.850 ± 109.050 ops/s + * JsonSelectionBenchmark.pojoFastJson numeric-heavy thrpt 10 91.458 ± 0.437 ops/s + * JsonSelectionBenchmark.pojoGson string-heavy thrpt 10 6253.698 ± 50.267 ops/s + * JsonSelectionBenchmark.pojoGson numeric-heavy thrpt 10 48.215 ± 0.265 ops/s + * JsonSelectionBenchmark.pojoJackson string-heavy thrpt 10 16563.604 ± 244.329 ops/s + * JsonSelectionBenchmark.pojoJackson numeric-heavy thrpt 10 135.780 ± 1.074 ops/s + * JsonSelectionBenchmark.pojoJsonIter string-heavy thrpt 10 10733.539 ± 35.605 ops/s + * JsonSelectionBenchmark.pojoJsonIter numeric-heavy thrpt 10 86.629 ± 1.122 ops/s + * JsonSelectionBenchmark.pojoJsonIterCodeGen string-heavy thrpt 10 41048.034 ± 396.628 ops/s + * JsonSelectionBenchmark.pojoJsonIterCodeGen numeric-heavy thrpt 10 377.412 ± 9.755 ops/s + * + * Process finished with exit code 0 + */ +@State(Scope.Benchmark) +public class JsonSelectionBenchmark { + private static final String INPUT_OBJECT_NAME_1 = "string-heavy"; + private static final String INPUT_OBJECT_NAME_2 = "numeric-heavy"; + private static final TestDataClass inputObject1 = new TestDataClass(10, 100, 1); // string-heavy + private static final TestDataClass inputObject2 = new TestDataClass(10000, 0, 0); // numeric-heavy + private static final GsonBuilder builder = new GsonBuilder(); + private static final Gson gson = builder.create(); + private static final ObjectMapper objectMapper = new ObjectMapper(); + private static final boolean TEST_IDENTITY = true; + @Param({ INPUT_OBJECT_NAME_1, INPUT_OBJECT_NAME_2 }) + private String testClassId; + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public void pojoFastJson(Blackhole blackhole) { + final String serialisedData = JSON.toJSONString(getTestClass(testClassId)); // from object to JSON String + final TestDataClass outputPojo = JSON.parseObject(serialisedData, TestDataClass.class); // from JSON String to object + assert !TEST_IDENTITY || getTestClass(testClassId).equals(outputPojo); + blackhole.consume(serialisedData); + blackhole.consume(outputPojo); + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public void pojoGson(Blackhole blackhole) { + final String serialisedData = gson.toJson(getTestClass(testClassId)); // from object to JSON String + final TestDataClass outputPojo = gson.fromJson(serialisedData, TestDataClass.class); // from JSON String to object + assert !TEST_IDENTITY || getTestClass(testClassId).equals(outputPojo); + blackhole.consume(serialisedData); + blackhole.consume(outputPojo); + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public void pojoJackson(Blackhole blackhole) { + try { + // set this since the other libraries also (de-)serialise private fields (N.B. TestDataClass fields are all public) + objectMapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY); + final String serialisedData = objectMapper.writeValueAsString(getTestClass(testClassId)); // from object to JSON String + final TestDataClass outputPojo = objectMapper.readValue(serialisedData, TestDataClass.class); // from JSON String to object + assert !TEST_IDENTITY || getTestClass(testClassId).equals(outputPojo); + blackhole.consume(serialisedData); + blackhole.consume(outputPojo); + } catch (IOException e) { + e.printStackTrace(); + } + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public void pojoJsonIter(Blackhole blackhole) { + JsonStream.setMode(EncodingMode.REFLECTION_MODE); + JsonIterator.setMode(DecodingMode.REFLECTION_MODE); + final String serialisedData = JsonStream.serialize(getTestClass(testClassId)); // from object to JSON String + final TestDataClass outputPojo = JsonIterator.deserialize(serialisedData, TestDataClass.class); // from JSON String to object + assert !TEST_IDENTITY || getTestClass(testClassId).equals(outputPojo); + blackhole.consume(serialisedData); + blackhole.consume(outputPojo); + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public void pojoJsonIterCodeGen(Blackhole blackhole) { + JsonStream.setMode(EncodingMode.DYNAMIC_MODE); + JsonIterator.setMode(DecodingMode.DYNAMIC_MODE_AND_MATCH_FIELD_WITH_HASH); + final String serialisedData = JsonStream.serialize(getTestClass(testClassId)); // from object to JSON String + final TestDataClass outputPojo = JsonIterator.deserialize(serialisedData, TestDataClass.class); // from JSON String to object + assert !TEST_IDENTITY || getTestClass(testClassId).equals(outputPojo); + blackhole.consume(serialisedData); + blackhole.consume(outputPojo); + } + + private static TestDataClass getTestClass(final String arg) { + return INPUT_OBJECT_NAME_1.equals(arg) ? inputObject1 : inputObject2; + } +} diff --git a/serialiser/src/test/java/io/opencmw/serialiser/benchmark/ReflectionBenchmark.java b/serialiser/src/test/java/io/opencmw/serialiser/benchmark/ReflectionBenchmark.java new file mode 100644 index 00000000..b37286a1 --- /dev/null +++ b/serialiser/src/test/java/io/opencmw/serialiser/benchmark/ReflectionBenchmark.java @@ -0,0 +1,192 @@ +package io.opencmw.serialiser.benchmark; + +import java.lang.reflect.Field; +import java.util.Objects; + +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +import sun.misc.Unsafe; + +/** + * Benchmark to compare, test and rationalise some assumptions that went into the serialiser refactoring + * + * last test output (openjdk 11.0.7 2020-04-14, took 24 min): + * Benchmark Mode Cnt Score Error Units + * ReflectionBenchmark.fieldAccess1ViaMethod thrpt 10 368156046.779 ± 29954108.137 ops/s + * ReflectionBenchmark.fieldAccess2ViaField thrpt 10 160816173.982 ± 16004563.682 ops/s + * ReflectionBenchmark.fieldAccess3ViaFieldSetDouble thrpt 10 151854907.619 ± 10826489.476 ops/s + * ReflectionBenchmark.fieldAccess4ViaOptimisedField thrpt 10 195051859.819 ± 8803389.807 ops/s + * ReflectionBenchmark.fieldAccess5ViaOptimisedFieldSetDouble thrpt 10 201686198.694 ± 10422965.353 ops/s + * ReflectionBenchmark.fieldAccess6ViaDirectMemoryAccess thrpt 10 341641937.437 ± 53603148.397 ops/s + * + * @author rstein + */ +@State(Scope.Benchmark) +public class ReflectionBenchmark { + public static final int LOOP_COUNT = 100_000_000; + // static final Unsafe unsafe = jdk.internal.misc.Unsafe.getUnsafe(); + private static final Unsafe unsafe; // NOPMD + static { + // get an instance of the otherwise private 'Unsafe' class + try { + final Field field = Unsafe.class.getDeclaredField("theUnsafe"); + field.setAccessible(true); + unsafe = (Unsafe) field.get(null); + } catch (NoSuchFieldException | SecurityException | IllegalAccessException e) { + throw new SecurityException(e); // NOPMD + } + } + private final Field field = getField(); + private final Field fieldOptimised = getOptimisedField(); + private final long fieldOffset = getFieldOffset(); + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public void fieldAccess1ViaMethod(Blackhole blackhole, final MyData data) { + data.setValue(data.a); + blackhole.consume(data); + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public void fieldAccess2ViaField(Blackhole blackhole, final MyData data) { + try { + Objects.requireNonNull(field, "field is null").set(data, data.a); + } catch (IllegalAccessException e) { + e.printStackTrace(); + } + blackhole.consume(data); + } + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public void fieldAccess3ViaFieldSetDouble(Blackhole blackhole, final MyData data) { + try { + Objects.requireNonNull(field, "field is null").setDouble(data, data.a); + } catch (IllegalAccessException e) { + e.printStackTrace(); + } + blackhole.consume(data); + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public void fieldAccess4ViaOptimisedField(Blackhole blackhole, final MyData data) { + try { + Objects.requireNonNull(fieldOptimised, "field is null").set(data, data.a); + } catch (IllegalAccessException e) { + e.printStackTrace(); + } + blackhole.consume(data); + } + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public void fieldAccess5ViaOptimisedFieldSetDouble(Blackhole blackhole, final MyData data) { + try { + Objects.requireNonNull(fieldOptimised, "field is null").setDouble(data, data.a); + } catch (IllegalAccessException e) { + e.printStackTrace(); + } + blackhole.consume(data); + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public void fieldAccess6ViaDirectMemoryAccess(Blackhole blackhole, final MyData data) { + unsafe.putDouble(data, fieldOffset, data.a); + blackhole.consume(data); + } + + public static void main(String[] args) throws Throwable { + MyData data = new MyData(); + + long start = System.currentTimeMillis(); + for (int i = 0; i < LOOP_COUNT; ++i) { + data.setValue(i); + } + System.err.println("access via method: " + (System.currentTimeMillis() - start)); + + Field field = MyData.class.getDeclaredField("value"); + start = System.currentTimeMillis(); + for (int i = 0; i < LOOP_COUNT; ++i) { + field.set(data, (double) i); + } + System.err.println("access via reflection: " + (System.currentTimeMillis() - start)); + + field.setAccessible(true); // Optimization + start = System.currentTimeMillis(); + for (int i = 0; i < LOOP_COUNT; ++i) { + field.set(data, (double) i); + } + System.err.println("access via opt.-refl.: " + (System.currentTimeMillis() - start)); + + start = System.currentTimeMillis(); + for (int i = 0; i < LOOP_COUNT; ++i) { + field.setDouble(data, i); + } + System.err.println("access via setDouble: " + (System.currentTimeMillis() - start)); + + final long fieldOffset = unsafe.objectFieldOffset(field); + start = System.currentTimeMillis(); + for (int i = 0; i < LOOP_COUNT; ++i) { + unsafe.putDouble(data, fieldOffset, i); + } + System.err.println("access via unsafe: " + (System.currentTimeMillis() - start)); + } + + private static Field getField() { + try { + return MyData.class.getDeclaredField("value"); + } catch (NoSuchFieldException e) { + e.printStackTrace(); + } + return null; + } + + private static long getFieldOffset() { + try { + final Field field = MyData.class.getDeclaredField("value"); + field.setAccessible(true); + return unsafe.objectFieldOffset(field); + } catch (NoSuchFieldException e) { + e.printStackTrace(); + } + return -1; + } + + private static Field getOptimisedField() { + try { + final Field field = MyData.class.getDeclaredField("value"); + field.setAccessible(true); + return field; + } catch (NoSuchFieldException e) { + e.printStackTrace(); + } + return null; + } + + @State(Scope.Thread) + @SuppressWarnings("CanBeFinal") + public static class MyData { + public double a = 5.0; + public double value; + + public double getValue() { + return value; + } + + public void setValue(double value) { + this.value = value; + } + } +} \ No newline at end of file diff --git a/serialiser/src/test/java/io/opencmw/serialiser/benchmark/SerialiserAssumptionsBenchmark.java b/serialiser/src/test/java/io/opencmw/serialiser/benchmark/SerialiserAssumptionsBenchmark.java new file mode 100644 index 00000000..bf97e2aa --- /dev/null +++ b/serialiser/src/test/java/io/opencmw/serialiser/benchmark/SerialiserAssumptionsBenchmark.java @@ -0,0 +1,271 @@ +package io.opencmw.serialiser.benchmark; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.nio.charset.StandardCharsets; + +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import sun.misc.Unsafe; + +/** + * Benchmark to compare, test and rationalise some assumptions that went into the serialiser refactoring + * + * last test output (openjdk 11.0.7 2020-04-14, took ~1:15h): + Benchmark Mode Cnt Score Error Units + SerialiserAssumptionsBenchmark.fluentDesignVoid thrpt 10 471049302.874 ± 38950975.384 ops/s + SerialiserAssumptionsBenchmark.fluentDesignWithReturn thrpt 10 254339145.447 ± 5897376.654 ops/s + SerialiserAssumptionsBenchmark.fluentDesignWithoutReturn thrpt 10 476501604.954 ± 40973207.961 ops/s + SerialiserAssumptionsBenchmark.functionWithArray thrpt 10 221467425.933 ± 3002743.890 ops/s + SerialiserAssumptionsBenchmark.functionWithSingleArgument thrpt 10 287031569.929 ± 5614312.539 ops/s + SerialiserAssumptionsBenchmark.functionWithVarargsArrayArgument thrpt 10 218877961.349 ± 4692333.825 ops/s + SerialiserAssumptionsBenchmark.functionWithVarargsMultiArguments thrpt 10 142480433.294 ± 13146238.914 ops/s + SerialiserAssumptionsBenchmark.functionWithVarargsSingleArgument thrpt 10 165330552.790 ± 17698360.163 ops/s + SerialiserAssumptionsBenchmark.stringAllocationWithCharsetASCII thrpt 10 40173625.939 ± 735728.322 ops/s + SerialiserAssumptionsBenchmark.stringAllocationWithCharsetISO8859 thrpt 10 54217550.808 ± 1318340.645 ops/s + SerialiserAssumptionsBenchmark.stringAllocationWithCharsetUTF8 thrpt 10 41330615.597 ± 632412.774 ops/s + SerialiserAssumptionsBenchmark.stringAllocationWithCharsetUTF8_UTF8 thrpt 10 13048847.527 ± 66585.989 ops/s + SerialiserAssumptionsBenchmark.stringAllocationWithOutCharsetASCII thrpt 10 49542211.521 ± 2229389.418 ops/s + SerialiserAssumptionsBenchmark.stringAllocationWithOutCharsetISO8859 thrpt 10 49123986.088 ± 2657664.797 ops/s + SerialiserAssumptionsBenchmark.stringAllocationWithOutCharsetISO8859_V2 thrpt 10 100884194.645 ± 9426023.968 ops/s + SerialiserAssumptionsBenchmark.stringAllocationWithOutCharsetISO8859_V3 thrpt 10 71306482.793 ± 2909269.756 ops/s + SerialiserAssumptionsBenchmark.stringAllocationWithOutCharsetUTF8 thrpt 10 49503339.953 ± 2376939.023 ops/s + SerialiserAssumptionsBenchmark.stringAllocationWithOutCharsetUTF8_UTF8 thrpt 10 13860025.901 ± 244519.585 ops/s + * + * @author rstein + */ +@State(Scope.Benchmark) +public class SerialiserAssumptionsBenchmark { + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public void fluentDesignVoid(Blackhole blackhole, final MyData data) { + func1(blackhole, data.a); + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public SerialiserAssumptionsBenchmark fluentDesignWithReturn(Blackhole blackhole, final MyData data) { + return func2(blackhole, data.a); + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public void fluentDesignWithoutReturn(Blackhole blackhole, final MyData data) { + func2(blackhole, data.a); + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public int[] functionWithArray(Blackhole blackhole, final MyData data) { + return f2(blackhole, data.dim); + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public int functionWithSingleArgument(Blackhole blackhole, final MyData data) { + return f3(blackhole, data.a); + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public int[] functionWithVarargsArrayArgument(Blackhole blackhole, final MyData data) { + return f1(blackhole, data.dim); + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public int[] functionWithVarargsMultiArguments(Blackhole blackhole, final MyData data) { + return f1(blackhole, data.a, data.b, data.c); + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public int[] functionWithVarargsSingleArgument(Blackhole blackhole, final MyData data) { + return f1(blackhole, data.a); + } + + @Setup() + public void initialize() { + // add variables to initialise here + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public String stringAllocationWithCharsetASCII(final MyData data) { + return new String(data.byteASCII, 0, data.byteASCII.length, StandardCharsets.US_ASCII); + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public String stringAllocationWithCharsetISO8859(final MyData data) { + return new String(data.byteISO8859, 0, data.byteISO8859.length, StandardCharsets.ISO_8859_1); + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public String stringAllocationWithCharsetUTF8(final MyData data) { + return new String(data.byteISO8859, 0, data.byteISO8859.length, StandardCharsets.UTF_8); + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public String stringAllocationWithCharsetUTF8_UTF8(final MyData data) { + return new String(data.byteUTF8, 0, data.byteUTF8.length, StandardCharsets.UTF_8); + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public String stringAllocationWithOutCharsetASCII(final MyData data) { + return new String(data.byteASCII, 0, data.byteASCII.length); + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public String stringAllocationWithOutCharsetISO8859(final MyData data) { + return new String(data.byteISO8859, 0, data.byteISO8859.length); + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public String stringAllocationWithOutCharsetISO8859_V2(final MyData data) { + return new String(data.byteISO8859, 0, 0, data.byteISO8859.length); // NOPMD NOSONAR fast implementation + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public String stringAllocationWithOutCharsetISO8859_V3(final MyData data) { + return FastStringBuilder.iso8859BytesToString(data.byteISO8859, 0, data.byteISO8859.length); + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public String stringAllocationWithOutCharsetUTF8(final MyData data) { + return new String(data.byteISO8859, 0, data.byteISO8859.length); + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public String stringAllocationWithOutCharsetUTF8_UTF8(final MyData data) { + return new String(data.byteUTF8, 0, data.byteUTF8.length); + } + + private int[] f1(Blackhole blackhole, int... array) { + blackhole.consume(array); + return array; + } + + private int[] f2(Blackhole blackhole, int[] array) { + blackhole.consume(array); + return array; + } + + private int f3(Blackhole blackhole, int val) { + blackhole.consume(val); + return val; + } + + private void func1(Blackhole blackhole, final double val) { + blackhole.consume(val); + } + + private SerialiserAssumptionsBenchmark func2(Blackhole blackhole, final double val) { + blackhole.consume(val); + return this; + } + + @State(Scope.Thread) + public static class MyData { + private static final int ARRAY_SIZE = 10; + public int a = 1; + public int b = 2; + public int c = 2; + public int[] dim = { a, b, c }; + public String stringASCII = "Hello World!"; + public String stringISO8859 = "Hello World!"; + public String stringUTF8 = "Γειά σου Κόσμε!"; + public byte[] byteASCII = stringASCII.getBytes(StandardCharsets.US_ASCII); + public byte[] byteISO8859 = stringISO8859.getBytes(StandardCharsets.ISO_8859_1); + public byte[] byteUTF8 = stringUTF8.getBytes(StandardCharsets.UTF_8); + public String[] arrayISO8859 = new String[ARRAY_SIZE]; + public String[] arrayUTF8 = new String[ARRAY_SIZE]; + + public MyData() { + for (int i = 0; i < ARRAY_SIZE; i++) { + arrayISO8859[i] = stringISO8859; + arrayUTF8[i] = stringUTF8; + } + } + } + + /** + * Simple helper class to generate (a little bit) faster Strings from byte arrays ;-) + * N.B. bypassing some of the redundant (null-pointer, byte array size, etc.) safety checks gains up to about 80% performance. + */ + @SuppressWarnings("PMD") + public static class FastStringBuilder { + private static final Logger LOGGER = LoggerFactory.getLogger(FastStringBuilder.class); + private static final Field fieldValue; + private static final long FIELD_VALUE_OFFSET; + private static final Unsafe unsafe; // NOPMD + static { + try { + final Field field = Unsafe.class.getDeclaredField("theUnsafe"); + field.setAccessible(true); + unsafe = (Unsafe) field.get(null); + } catch (NoSuchFieldException | SecurityException | IllegalAccessException e) { + throw new SecurityException(e); // NOPMD + } + + Field tempVal = null; + long offset = 0; + try { + tempVal = String.class.getDeclaredField("value"); + tempVal.setAccessible(true); + + final Field modifiersField = Field.class.getDeclaredField("modifiers"); + modifiersField.setAccessible(true); + modifiersField.setInt(tempVal, tempVal.getModifiers() & ~Modifier.FINAL); + offset = unsafe.objectFieldOffset(tempVal); + } catch (NoSuchFieldException | IllegalAccessException e) { + LOGGER.atError().setCause(e).log("could not initialise String field references"); + } finally { + fieldValue = tempVal; + FIELD_VALUE_OFFSET = offset; + } + } + + public static String iso8859BytesToString(final byte[] ba, final int offset, final int length) { + final String retVal = ""; // NOPMD - on purpose allocating new object + final byte[] array = new byte[length]; + System.arraycopy(ba, offset, array, 0, length); + unsafe.putObject(retVal, FIELD_VALUE_OFFSET, array); + return retVal; + } + } +} diff --git a/serialiser/src/test/java/io/opencmw/serialiser/benchmark/SerialiserBenchmark.java b/serialiser/src/test/java/io/opencmw/serialiser/benchmark/SerialiserBenchmark.java new file mode 100644 index 00000000..b6975753 --- /dev/null +++ b/serialiser/src/test/java/io/opencmw/serialiser/benchmark/SerialiserBenchmark.java @@ -0,0 +1,196 @@ +package io.opencmw.serialiser.benchmark; + +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +import io.opencmw.serialiser.utils.CmwLightHelper; +import io.opencmw.serialiser.utils.FlatBuffersHelper; +import io.opencmw.serialiser.utils.JsonHelper; +import io.opencmw.serialiser.utils.SerialiserHelper; +import io.opencmw.serialiser.utils.TestDataClass; + +import com.jsoniter.JsonIterator; +import com.jsoniter.output.EncodingMode; +import com.jsoniter.output.JsonStream; +import com.jsoniter.spi.DecodingMode; + +/** + * More thorough (JMH-based)) benchmark of various internal and external serialiser protocols. + * Test consists of a simple repeated POJO->serialised->byte[] buffer -> de-serialisation -> POJO + comparison checks. + * N.B. this isn't as precise as the JMH tests but gives a rough idea whether the protocol degraded or needs to be improved. + * + * Benchmark (testClassId) Mode Cnt Score Error Units + * SerialiserBenchmark.customCmwLight string-heavy thrpt 10 49954.479 ± 560.726 ops/s + * SerialiserBenchmark.customCmwLight numeric-heavy thrpt 10 22433.828 ± 195.939 ops/s + * SerialiserBenchmark.customFlatBuffer string-heavy thrpt 10 18446.085 ± 71.311 ops/s + * SerialiserBenchmark.customFlatBuffer numeric-heavy thrpt 10 233.869 ± 7.314 ops/s + * SerialiserBenchmark.customIoSerialiser string-heavy thrpt 10 53638.035 ± 367.122 ops/s + * SerialiserBenchmark.customIoSerialiser numeric-heavy thrpt 10 24277.732 ± 200.380 ops/s + * SerialiserBenchmark.customIoSerialiserOptim string-heavy thrpt 10 79759.984 ± 799.944 ops/s + * SerialiserBenchmark.customIoSerialiserOptim numeric-heavy thrpt 10 24192.169 ± 419.019 ops/s + * SerialiserBenchmark.customJson string-heavy thrpt 10 17619.026 ± 250.917 ops/s + * SerialiserBenchmark.customJson numeric-heavy thrpt 10 138.461 ± 2.972 ops/s + * SerialiserBenchmark.mapCmwLight string-heavy thrpt 10 79273.547 ± 2487.931 ops/s + * SerialiserBenchmark.mapCmwLight numeric-heavy thrpt 10 67374.131 ± 954.149 ops/s + * SerialiserBenchmark.mapIoSerialiser string-heavy thrpt 10 81295.197 ± 2391.616 ops/s + * SerialiserBenchmark.mapIoSerialiser numeric-heavy thrpt 10 67701.564 ± 1062.641 ops/s + * SerialiserBenchmark.mapIoSerialiserOptimized string-heavy thrpt 10 115008.285 ± 2390.426 ops/s + * SerialiserBenchmark.mapIoSerialiserOptimized numeric-heavy thrpt 10 68879.735 ± 1403.197 ops/s + * SerialiserBenchmark.mapJson string-heavy thrpt 10 14474.142 ± 1227.165 ops/s + * SerialiserBenchmark.mapJson numeric-heavy thrpt 10 163.928 ± 0.968 ops/s + * SerialiserBenchmark.pojoCmwLight string-heavy thrpt 10 41821.232 ± 217.594 ops/s + * SerialiserBenchmark.pojoCmwLight numeric-heavy thrpt 10 33820.451 ± 568.264 ops/s + * SerialiserBenchmark.pojoIoSerialiser string-heavy thrpt 10 41899.128 ± 940.030 ops/s + * SerialiserBenchmark.pojoIoSerialiser numeric-heavy thrpt 10 33918.815 ± 376.551 ops/s + * SerialiserBenchmark.pojoIoSerialiserOptim string-heavy thrpt 10 53811.486 ± 920.474 ops/s + * SerialiserBenchmark.pojoIoSerialiserOptim numeric-heavy thrpt 10 32463.267 ± 635.326 ops/s + * SerialiserBenchmark.pojoJson string-heavy thrpt 10 23327.701 ± 288.871 ops/s + * SerialiserBenchmark.pojoJson numeric-heavy thrpt 10 161.396 ± 3.040 ops/s + * SerialiserBenchmark.pojoJsonCodeGen string-heavy thrpt 10 23586.818 ± 470.233 ops/s + * SerialiserBenchmark.pojoJsonCodeGen numeric-heavy thrpt 10 163.250 ± 1.254 ops/s + * + * @author rstein + */ +@State(Scope.Benchmark) +public class SerialiserBenchmark { + private static final String INPUT_OBJECT_NAME_1 = "string-heavy"; + private static final String INPUT_OBJECT_NAME_2 = "numeric-heavy"; + private static final TestDataClass inputObject1 = new TestDataClass(10, 100, 1); // string-heavy + private static final TestDataClass inputObject2 = new TestDataClass(10000, 0, 0); // numeric-heavy + private static final TestDataClass outputObject = new TestDataClass(-1, -1, 0); + @Param({ INPUT_OBJECT_NAME_1, INPUT_OBJECT_NAME_2 }) + private String testClassId; + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public void mapJson(Blackhole blackhole) { + JsonHelper.testSerialiserPerformanceMap(1, getTestClass(testClassId)); + blackhole.consume(getTestClass(testClassId)); + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public void mapCmwLight(Blackhole blackhole) { + CmwLightHelper.testSerialiserPerformanceMap(1, getTestClass(testClassId)); + blackhole.consume(getTestClass(testClassId)); + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public void mapIoSerialiser(Blackhole blackhole) { + SerialiserHelper.testSerialiserPerformanceMap(1, getTestClass(testClassId)); + blackhole.consume(getTestClass(testClassId)); + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public void mapIoSerialiserOptimized(Blackhole blackhole) { + SerialiserHelper.getBinarySerialiser().setEnforceSimpleStringEncoding(true); + SerialiserHelper.getBinarySerialiser().setPutFieldMetaData(false); + SerialiserHelper.testSerialiserPerformanceMap(1, getTestClass(testClassId)); + blackhole.consume(getTestClass(testClassId)); + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public void customJson(Blackhole blackhole) { + JsonHelper.testCustomSerialiserPerformance(1, getTestClass(testClassId), outputObject); + blackhole.consume(outputObject); + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public void customFlatBuffer(Blackhole blackhole) { + // N.B. internally FlatBuffer's FlexBuffer API is being used + // rationale: needed to compare libraries that allow loose coupling between server/client-side domain object definition + FlatBuffersHelper.testCustomSerialiserPerformance(1, getTestClass(testClassId), outputObject); + blackhole.consume(outputObject); + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public void customCmwLight(Blackhole blackhole) { + CmwLightHelper.testCustomSerialiserPerformance(1, getTestClass(testClassId), outputObject); + blackhole.consume(outputObject); + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public void customIoSerialiser(Blackhole blackhole) { + SerialiserHelper.testCustomSerialiserPerformance(1, getTestClass(testClassId), outputObject); + blackhole.consume(outputObject); + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public void customIoSerialiserOptim(Blackhole blackhole) { + SerialiserHelper.getBinarySerialiser().setEnforceSimpleStringEncoding(true); + SerialiserHelper.getBinarySerialiser().setPutFieldMetaData(false); + SerialiserHelper.testCustomSerialiserPerformance(1, getTestClass(testClassId), outputObject); + blackhole.consume(outputObject); + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public void pojoJson(Blackhole blackhole) { + JsonStream.setMode(EncodingMode.REFLECTION_MODE); + JsonIterator.setMode(DecodingMode.REFLECTION_MODE); + JsonHelper.testPerformancePojoCodeGen(1, getTestClass(testClassId), outputObject); + blackhole.consume(outputObject); + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public void pojoJsonCodeGen(Blackhole blackhole) { + JsonStream.setMode(EncodingMode.DYNAMIC_MODE); + JsonIterator.setMode(DecodingMode.DYNAMIC_MODE_AND_MATCH_FIELD_WITH_HASH); + JsonHelper.testPerformancePojoCodeGen(1, getTestClass(testClassId), outputObject); + blackhole.consume(outputObject); + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public void pojoCmwLight(Blackhole blackhole) { + CmwLightHelper.testPerformancePojo(1, getTestClass(testClassId), outputObject); + blackhole.consume(outputObject); + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public void pojoIoSerialiser(Blackhole blackhole) { + SerialiserHelper.testPerformancePojo(1, getTestClass(testClassId), outputObject); + blackhole.consume(outputObject); + } + + @Benchmark + @Warmup(iterations = 1) + @Fork(value = 2, warmups = 2) + public void pojoIoSerialiserOptim(Blackhole blackhole) { + SerialiserHelper.getBinarySerialiser().setEnforceSimpleStringEncoding(true); + SerialiserHelper.getBinarySerialiser().setPutFieldMetaData(false); + SerialiserHelper.testPerformancePojo(1, getTestClass(testClassId), outputObject); + blackhole.consume(outputObject); + } + + private static TestDataClass getTestClass(final String arg) { + return INPUT_OBJECT_NAME_1.equals(arg) ? inputObject1 : inputObject2; + } +} diff --git a/serialiser/src/test/java/io/opencmw/serialiser/benchmark/SerialiserQuickBenchmark.java b/serialiser/src/test/java/io/opencmw/serialiser/benchmark/SerialiserQuickBenchmark.java new file mode 100644 index 00000000..fd1f20a8 --- /dev/null +++ b/serialiser/src/test/java/io/opencmw/serialiser/benchmark/SerialiserQuickBenchmark.java @@ -0,0 +1,113 @@ +package io.opencmw.serialiser.benchmark; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.opencmw.serialiser.utils.CmwLightHelper; +import io.opencmw.serialiser.utils.FlatBuffersHelper; +import io.opencmw.serialiser.utils.JsonHelper; +import io.opencmw.serialiser.utils.SerialiserHelper; +import io.opencmw.serialiser.utils.TestDataClass; + +/** + * Simple (rough) benchmark of various internal and external serialiser protocols. + * Test consists of a simple repeated POJO->serialised->byte[] buffer -> de-serialisation -> POJO + comparison checks. + * N.B. this isn't as precise as the JMH tests but gives a rough idea whether the protocol degraded or needs to be improved. + * + * Example output - numbers should be compared relatively (nIterations = 100000): + * (openjdk 11.0.7 2020-04-14, ASCII-only, nSizePrimitiveArrays = 10, nSizeString = 100, nestedClassRecursion = 1) + * [..] more string-heavy TestDataClass + * - run 1 + * - JSON Serializer (Map only) throughput = 371.4 MB/s for 5.2 kB per test run (took 1413.0 ms) + * - CMW Serializer (Map only) throughput = 220.2 MB/s for 6.3 kB per test run (took 2871.0 ms) + * - CmwLight Serializer (Map only) throughput = 683.1 MB/s for 6.4 kB per test run (took 935.0 ms) + * - IO Serializer (Map only) throughput = 810.0 MB/s for 7.4 kB per test run (took 908.0 ms) + * + * - FlatBuffers (custom FlexBuffers) throughput = 173.7 MB/s for 6.1 kB per test run (took 3536.0 ms) + * - CmwLight Serializer (custom) throughput = 460.5 MB/s for 6.4 kB per test run (took 1387.0 ms) + * - IO Serializer (custom) throughput = 545.0 MB/s for 7.3 kB per test run (took 1344.0 ms) + * + * - JSON Serializer (POJO) throughput = 53.8 MB/s for 5.2 kB per test run (took 9747.0 ms) + * - CMW Serializer (POJO) throughput = 182.8 MB/s for 6.3 kB per test run (took 3458.0 ms) + * - CmwLight Serializer (POJO) throughput = 329.2 MB/s for 6.3 kB per test run (took 1906.0 ms) + * - IO Serializer (POJO) throughput = 374.9 MB/s for 7.2 kB per test run (took 1925.0 ms) + * + * [..] more primitive-array-heavy TestDataClass + * (openjdk 11.0.7 2020-04-14, UTF8, nSizePrimitiveArrays = 1000, nSizeString = 0, nestedClassRecursion = 0) + * - run 1 + * - JSON Serializer (Map only) throughput = 350.7 MB/s for 34.3 kB per test run (took 9793.0 ms) + * - CMW Serializer (Map only) throughput = 1.7 GB/s for 29.2 kB per test run (took 1755.0 ms) + * - CmwLight Serializer (Map only) throughput = 6.7 GB/s for 29.2 kB per test run (took 437.0 ms) + * - IO Serializer (Map only) throughput = 6.1 GB/s for 29.7 kB per test run (took 485.0 ms) + * + * - FlatBuffers (custom FlexBuffers) throughput = 123.1 MB/s for 30.1 kB per test run (took 24467.0 ms) + * - CmwLight Serializer (custom) throughput = 3.9 GB/s for 29.2 kB per test run (took 751.0 ms) + * - IO Serializer (custom) throughput = 3.8 GB/s for 29.7 kB per test run (took 782.0 ms) + * + * - JSON Serializer (POJO) throughput = 31.7 MB/s for 34.3 kB per test run (took 108415.0 ms) + * - CMW Serializer (POJO) throughput = 1.5 GB/s for 29.2 kB per test run (took 1924.0 ms) + * - CmwLight Serializer (POJO) throughput = 3.5 GB/s for 29.1 kB per test run (took 824.0 ms) + * - IO Serializer (POJO) throughput = 3.4 GB/s for 29.7 kB per test run (took 870.0 ms) + * + * @author rstein + */ +public class SerialiserQuickBenchmark { // NOPMD - nomen est omen + private static final Logger LOGGER = LoggerFactory.getLogger(SerialiserQuickBenchmark.class); + private static final TestDataClass inputObject = new TestDataClass(10, 100, 1); + private static final TestDataClass outputObject = new TestDataClass(-1, -1, 0); + + public static String humanReadableByteCount(final long bytes, final boolean si) { + final int unit = si ? 1000 : 1024; + if (bytes < unit) { + return bytes + " B"; + } + + final int exp = (int) (Math.log(bytes) / Math.log(unit)); + final String pre = (si ? "kMGTPE" : "KMGTPE").charAt(exp - 1) + (si ? "" : "i"); + return String.format("%.1f %sB", bytes / Math.pow(unit, exp), pre); + } + + public static void main(final String... argv) { + // CmwHelper.checkSerialiserIdentity(inputObject, outputObject); + LOGGER.atInfo().addArgument(CmwLightHelper.checkSerialiserIdentity(inputObject, outputObject)).log("CmwLight serialiser nBytes = {}"); + LOGGER.atInfo().addArgument(CmwLightHelper.checkCustomSerialiserIdentity(inputObject, outputObject)).log("CmwLight (custom) serialiser nBytes = {}"); + + LOGGER.atInfo().addArgument(JsonHelper.checkSerialiserIdentity(inputObject, outputObject)).log("JSON Serialiser serialiser nBytes = {}"); + LOGGER.atInfo().addArgument(JsonHelper.checkCustomSerialiserIdentity(inputObject, outputObject)).log("JSON Serialiser (custom) serialiser nBytes = {}"); + LOGGER.atInfo().addArgument(SerialiserHelper.checkSerialiserIdentity(inputObject, outputObject)).log("generic serialiser nBytes = {}"); + LOGGER.atInfo().addArgument(SerialiserHelper.checkCustomSerialiserIdentity(inputObject, outputObject)).log("generic serialiser (custom) nBytes = {}"); + LOGGER.atInfo().addArgument(FlatBuffersHelper.checkCustomSerialiserIdentity(inputObject, outputObject)).log("flatBuffers serialiser nBytes = {}"); + + // Cmw vs. CmwLight compatibility - requires CMW binary libs + // CmwLightHelper.checkCmwLightVsCmwIdentityForward(inputObject, outputObject); + // CmwLightHelper.checkCmwLightVsCmwIdentityBackward(inputObject, outputObject); + + // optimisation to be enabled if e.g. to protocols that do not support UTF-8 string encoding + // CmwLightHelper.getCmwLightSerialiser().setEnforceSimpleStringEncoding(true); + // SerialiserHelper.getBinarySerialiser().setEnforceSimpleStringEncoding(true); + // SerialiserHelper.getBinarySerialiser().setPutFieldMetaData(false); + + final int nIterations = 100000; + for (int i = 0; i < 10; i++) { + LOGGER.atInfo().addArgument(i).log("run {}"); + // map-only performance + JsonHelper.testSerialiserPerformanceMap(nIterations, inputObject); + // CmwHelper.testSerialiserPerformanceMap(nIterations, inputObject, outputObject); + CmwLightHelper.testSerialiserPerformanceMap(nIterations, inputObject); + SerialiserHelper.testSerialiserPerformanceMap(nIterations, inputObject); + + // custom serialiser performance + JsonHelper.testCustomSerialiserPerformance(nIterations, inputObject, outputObject); + FlatBuffersHelper.testCustomSerialiserPerformance(nIterations, inputObject, outputObject); + CmwLightHelper.testCustomSerialiserPerformance(nIterations, inputObject, outputObject); + SerialiserHelper.testCustomSerialiserPerformance(nIterations, inputObject, outputObject); + + // POJO performance + JsonHelper.testPerformancePojo(nIterations, inputObject, outputObject); + JsonHelper.testPerformancePojoCodeGen(nIterations, inputObject, outputObject); + // CmwHelper.testPerformancePojo(nIterations, inputObject, outputObject); + CmwLightHelper.testPerformancePojo(nIterations, inputObject, outputObject); + SerialiserHelper.testPerformancePojo(nIterations, inputObject, outputObject); + } + } +} diff --git a/serialiser/src/test/java/io/opencmw/serialiser/spi/BinarySerialiserTests.java b/serialiser/src/test/java/io/opencmw/serialiser/spi/BinarySerialiserTests.java new file mode 100644 index 00000000..ebc9c750 --- /dev/null +++ b/serialiser/src/test/java/io/opencmw/serialiser/spi/BinarySerialiserTests.java @@ -0,0 +1,714 @@ +package io.opencmw.serialiser.spi; + +import static org.junit.jupiter.api.Assertions.*; + +import java.lang.reflect.InvocationTargetException; +import java.util.Arrays; +import java.util.Collection; +import java.util.Deque; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.Set; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import io.opencmw.serialiser.DataType; +import io.opencmw.serialiser.IoBuffer; +import io.opencmw.serialiser.spi.helper.MyGenericClass; +import io.opencmw.serialiser.utils.AssertUtils; + +/** + * + * @author rstein + */ +@SuppressWarnings("PMD.ExcessiveMethodLength") +class BinarySerialiserTests { + private static final int BUFFER_SIZE = 2000; + private static final int ARRAY_DIM_1D = 1; // array dimension + + private void putGenericTestArrays(final BinarySerialiser ioSerialiser) { + ioSerialiser.putHeaderInfo(); + ioSerialiser.putGenericArrayAsPrimitive(DataType.BOOL, new Boolean[] { true, false, true }, 3); + ioSerialiser.putGenericArrayAsPrimitive(DataType.BYTE, new Byte[] { (byte) 1, (byte) 0, (byte) 2 }, 3); + ioSerialiser.putGenericArrayAsPrimitive(DataType.CHAR, new Character[] { (char) 1, (char) 0, (char) 2 }, 3); + ioSerialiser.putGenericArrayAsPrimitive(DataType.SHORT, new Short[] { (short) 1, (short) 0, (short) 2 }, 3); + ioSerialiser.putGenericArrayAsPrimitive(DataType.INT, new Integer[] { 1, 0, 2 }, 3); + ioSerialiser.putGenericArrayAsPrimitive(DataType.LONG, new Long[] { 1L, 0L, 2L }, 3); + ioSerialiser.putGenericArrayAsPrimitive(DataType.FLOAT, new Float[] { (float) 1, (float) 0, (float) 2 }, 3); + ioSerialiser.putGenericArrayAsPrimitive(DataType.DOUBLE, new Double[] { (double) 1, (double) 0, (double) 2 }, 3); + ioSerialiser.putGenericArrayAsPrimitive(DataType.STRING, new String[] { "1.0", "0.0", "2.0" }, 3); + } + + @DisplayName("basic primitive array writer tests") + @ParameterizedTest(name = "IoBuffer class - {0}") + @ValueSource(classes = { ByteBuffer.class, FastByteBuffer.class }) + void testBasicInterfacePrimitiveArrays(final Class bufferClass) throws InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException, NoSuchMethodException, SecurityException { + assertNotNull(bufferClass, "bufferClass being not null"); + assertNotNull(bufferClass.getConstructor(int.class), "Constructor(Integer) present"); + final IoBuffer buffer = bufferClass.getConstructor(int.class).newInstance(BUFFER_SIZE); + final BinarySerialiser ioSerialiser = new BinarySerialiser(buffer); // TODO: generalise to IoBuffer + + Deque positionBefore = new LinkedList<>(); + Deque positionAfter = new LinkedList<>(); + + // add primitive array types + positionBefore.add(buffer.position()); + ioSerialiser.put("boolean", new boolean[] { true, false }); + positionAfter.add(buffer.position()); + + positionBefore.add(buffer.position()); + ioSerialiser.put("byte", new byte[] { (byte) 42, (byte) 42 }); + positionAfter.add(buffer.position()); + + positionBefore.add(buffer.position()); + ioSerialiser.put("short", new short[] { (short) 43, (short) 43 }); + positionAfter.add(buffer.position()); + + positionBefore.add(buffer.position()); + ioSerialiser.put("int", new int[] { 44, 44 }); + positionAfter.add(buffer.position()); + + positionBefore.add(buffer.position()); + ioSerialiser.put("long", new long[] { (long) 45, (long) 45 }); + positionAfter.add(buffer.position()); + + positionBefore.add(buffer.position()); + ioSerialiser.put("float", new float[] { 1.0f, 1.0f }); + positionAfter.add(buffer.position()); + + positionBefore.add(buffer.position()); + ioSerialiser.put("double", new double[] { 3.0, 3.0 }); + positionAfter.add(buffer.position()); + + positionBefore.add(buffer.position()); + ioSerialiser.put("string", new String[] { "test", "test" }); + positionAfter.add(buffer.position()); + + WireDataFieldDescription header; + int positionAfterFieldHeader; + long skipNBytes; + int[] dims; + // check primitive types + buffer.flip(); + assertEquals(0, buffer.position(), "initial buffer position"); + + // boolean + assertEquals(positionBefore.removeFirst(), buffer.position()); + header = ioSerialiser.getFieldHeader(); + assertEquals("boolean", header.getFieldName(), "boolean field name retrieval"); + positionAfterFieldHeader = buffer.position(); // actual buffer position after having read the field header + assertEquals(positionAfterFieldHeader, header.getDataStartPosition(), "data start position"); + assertEquals(positionAfter.peekFirst(), header.getDataStartPosition() + header.getDataSize(), "data end skip address"); + dims = ioSerialiser.getArraySizeDescriptor(); + assertEquals(ARRAY_DIM_1D, dims.length, "dimension"); + assertEquals(2, dims[0], "array size"); + buffer.position(header.getDataStartPosition()); // return to original data start + assertArrayEquals(new int[] { 2 }, ioSerialiser.getArraySizeDescriptor()); // manual dimension check + assertArrayEquals(new boolean[] { true, false }, ioSerialiser.getBuffer().getBooleanArray()); // get data from IoBuffer + buffer.position(header.getDataStartPosition()); // return to original data start + assertArrayEquals(new boolean[] { true, false }, ioSerialiser.getBooleanArray()); // get data from IoSerialiser + skipNBytes = header.getDataSize(); // number of bytes to be skipped till end of this data chunk + assertEquals(skipNBytes, buffer.position() - positionAfterFieldHeader, "actual number of bytes skipped"); + assertEquals(positionAfter.removeFirst(), buffer.position()); + + // byte + assertEquals(positionBefore.removeFirst(), buffer.position()); + header = ioSerialiser.getFieldHeader(); + assertEquals("byte", header.getFieldName(), "byte field name retrieval"); + positionAfterFieldHeader = buffer.position(); // actual buffer position after having read the field header + assertEquals(positionAfterFieldHeader, header.getDataStartPosition(), "data start position"); + assertEquals(positionAfter.peekFirst(), header.getDataStartPosition() + header.getDataSize(), "data end skip address"); + dims = ioSerialiser.getArraySizeDescriptor(); + assertEquals(ARRAY_DIM_1D, dims.length, "dimension"); + assertEquals(2, dims[0], "array size"); + buffer.position(header.getDataStartPosition()); // return to original data start + assertArrayEquals(new int[] { 2 }, ioSerialiser.getArraySizeDescriptor()); // manual dimension check + assertArrayEquals(new byte[] { (byte) 42, (byte) 42 }, ioSerialiser.getBuffer().getByteArray()); // get data from IoBuffer + buffer.position(header.getDataStartPosition()); // return to original data start + assertArrayEquals(new byte[] { (byte) 42, (byte) 42 }, ioSerialiser.getByteArray()); // get data from IoSerialiser + skipNBytes = header.getDataSize(); // number of bytes to be skipped till end of this data chunk + assertEquals(skipNBytes, buffer.position() - positionAfterFieldHeader, "actual number of bytes skipped"); + assertEquals(positionAfter.removeFirst(), buffer.position()); + + // short + assertEquals(positionBefore.removeFirst(), buffer.position()); + header = ioSerialiser.getFieldHeader(); + assertEquals("short", header.getFieldName(), "short field name retrieval"); + positionAfterFieldHeader = buffer.position(); // actual buffer position after having read the field header + assertEquals(positionAfterFieldHeader, header.getDataStartPosition(), "data start position"); + assertEquals(positionAfter.peekFirst(), header.getDataStartPosition() + header.getDataSize(), "data end skip address"); + dims = ioSerialiser.getArraySizeDescriptor(); + assertEquals(ARRAY_DIM_1D, dims.length, "dimension"); + assertEquals(2, dims[0], "array size"); + buffer.position(header.getDataStartPosition()); // return to original data start + assertArrayEquals(new int[] { 2 }, ioSerialiser.getArraySizeDescriptor()); // manual dimension check + assertArrayEquals(new short[] { (short) 43, (short) 43 }, ioSerialiser.getBuffer().getShortArray()); // get data from IoBuffer + buffer.position(header.getDataStartPosition()); // return to original data start + assertArrayEquals(new short[] { (short) 43, (short) 43 }, ioSerialiser.getShortArray()); // get data from IoSerialiser + skipNBytes = header.getDataSize(); // number of bytes to be skipped till end of this data chunk + assertEquals(skipNBytes, buffer.position() - positionAfterFieldHeader, "actual number of bytes skipped"); + assertEquals(positionAfter.removeFirst(), buffer.position()); + + // int + assertEquals(positionBefore.removeFirst(), buffer.position()); + header = ioSerialiser.getFieldHeader(); + assertEquals("int", header.getFieldName(), "int field name retrieval"); + positionAfterFieldHeader = buffer.position(); // actual buffer position after having read the field header + assertEquals(positionAfterFieldHeader, header.getDataStartPosition(), "data start position"); + assertEquals(positionAfter.peekFirst(), header.getDataStartPosition() + header.getDataSize(), "data end skip address"); + dims = ioSerialiser.getArraySizeDescriptor(); + assertEquals(ARRAY_DIM_1D, dims.length, "dimension"); + assertEquals(2, dims[0], "array size"); + buffer.position(header.getDataStartPosition()); // return to original data start + assertArrayEquals(new int[] { 2 }, ioSerialiser.getArraySizeDescriptor()); // manual dimension check + assertArrayEquals(new int[] { 44, 44 }, ioSerialiser.getBuffer().getIntArray()); // get data from IoBuffer + buffer.position(header.getDataStartPosition()); // return to original data start + assertArrayEquals(new int[] { 44, 44 }, ioSerialiser.getIntArray()); // get data from IoSerialiser + skipNBytes = header.getDataSize(); // number of bytes to be skipped till end of this data chunk + assertEquals(skipNBytes, buffer.position() - positionAfterFieldHeader, "actual number of bytes skipped"); + assertEquals(positionAfter.removeFirst(), buffer.position()); + + // long + assertEquals(positionBefore.removeFirst(), buffer.position()); + header = ioSerialiser.getFieldHeader(); + assertEquals("long", header.getFieldName(), "long field name retrieval"); + positionAfterFieldHeader = buffer.position(); // actual buffer position after having read the field header + assertEquals(positionAfterFieldHeader, header.getDataStartPosition(), "data start position"); + assertEquals(positionAfter.peekFirst(), header.getDataStartPosition() + header.getDataSize(), "data end skip address"); + dims = ioSerialiser.getArraySizeDescriptor(); + assertEquals(ARRAY_DIM_1D, dims.length, "dimension"); + assertEquals(2, dims[0], "array size"); + buffer.position(header.getDataStartPosition()); // return to original data start + assertArrayEquals(new int[] { 2 }, ioSerialiser.getArraySizeDescriptor()); // manual dimension check + assertArrayEquals(new long[] { 45, 45 }, ioSerialiser.getBuffer().getLongArray()); // get data from IoBuffer + buffer.position(header.getDataStartPosition()); // return to original data start + assertArrayEquals(new long[] { 45, 45 }, ioSerialiser.getLongArray()); // get data from IoSerialiser + skipNBytes = header.getDataSize(); // number of bytes to be skipped till end of this data chunk + assertEquals(skipNBytes, buffer.position() - positionAfterFieldHeader, "actual number of bytes skipped"); + assertEquals(positionAfter.removeFirst(), buffer.position()); + + // float + assertEquals(positionBefore.removeFirst(), buffer.position()); + header = ioSerialiser.getFieldHeader(); + assertEquals("float", header.getFieldName(), "float field name retrieval"); + positionAfterFieldHeader = buffer.position(); // actual buffer position after having read the field header + assertEquals(positionAfterFieldHeader, header.getDataStartPosition(), "data start position"); + assertEquals(positionAfter.peekFirst(), header.getDataStartPosition() + header.getDataSize(), "data end skip address"); + dims = ioSerialiser.getArraySizeDescriptor(); + assertEquals(ARRAY_DIM_1D, dims.length, "dimension"); + assertEquals(2, dims[0], "array size"); + buffer.position(header.getDataStartPosition()); // return to original data start + assertArrayEquals(new int[] { 2 }, ioSerialiser.getArraySizeDescriptor()); // manual dimension check + assertArrayEquals(new float[] { 1.0f, 1.0f }, ioSerialiser.getBuffer().getFloatArray()); // get data from IoBuffer + buffer.position(header.getDataStartPosition()); // return to original data start + assertArrayEquals(new float[] { 1.0f, 1.0f }, ioSerialiser.getFloatArray()); // get data from IoSerialiser + skipNBytes = header.getDataSize(); // number of bytes to be skipped till end of this data chunk + assertEquals(skipNBytes, buffer.position() - positionAfterFieldHeader, "actual number of bytes skipped"); + assertEquals(positionAfter.removeFirst(), buffer.position()); + + // double + assertEquals(positionBefore.removeFirst(), buffer.position()); + header = ioSerialiser.getFieldHeader(); + assertEquals("double", header.getFieldName(), "double field name retrieval"); + positionAfterFieldHeader = buffer.position(); // actual buffer position after having read the field header + assertEquals(positionAfterFieldHeader, header.getDataStartPosition(), "data start position"); + assertEquals(positionAfter.peekFirst(), header.getDataStartPosition() + header.getDataSize(), "data end skip address"); + dims = ioSerialiser.getArraySizeDescriptor(); + assertEquals(ARRAY_DIM_1D, dims.length, "dimension"); + assertEquals(2, dims[0], "array size"); + buffer.position(header.getDataStartPosition()); // return to original data start + assertArrayEquals(new int[] { 2 }, ioSerialiser.getArraySizeDescriptor()); // manual dimension check + assertArrayEquals(new double[] { 3.0, 3.0 }, ioSerialiser.getBuffer().getDoubleArray()); // get data from IoBuffer + buffer.position(header.getDataStartPosition()); // return to original data start + assertArrayEquals(new double[] { 3.0, 3.0 }, ioSerialiser.getDoubleArray()); // get data from IoSerialiser + skipNBytes = header.getDataSize(); // number of bytes to be skipped till end of this data chunk + assertEquals(skipNBytes, buffer.position() - positionAfterFieldHeader, "actual number of bytes skipped"); + assertEquals(positionAfter.removeFirst(), buffer.position()); + + // string + assertEquals(positionBefore.removeFirst(), buffer.position()); + header = ioSerialiser.getFieldHeader(); + assertEquals("string", header.getFieldName(), "string field name retrieval"); + positionAfterFieldHeader = buffer.position(); // actual buffer position after having read the field header + assertEquals(positionAfterFieldHeader, header.getDataStartPosition(), "data start position"); + assertEquals(positionAfter.peekFirst(), header.getDataStartPosition() + header.getDataSize(), "data end skip address"); + dims = ioSerialiser.getArraySizeDescriptor(); + assertEquals(ARRAY_DIM_1D, dims.length, "dimension"); + assertEquals(2, dims[0], "array size"); + buffer.position(header.getDataStartPosition()); // return to original data start + assertArrayEquals(new int[] { 2 }, ioSerialiser.getArraySizeDescriptor()); // manual dimension check + assertArrayEquals(new String[] { "test", "test" }, ioSerialiser.getBuffer().getStringArray()); // get data from IoBuffer + buffer.position(header.getDataStartPosition()); // return to original data start + assertArrayEquals(new String[] { "test", "test" }, ioSerialiser.getStringArray()); // get data from IoSerialiser + skipNBytes = header.getDataSize(); // number of bytes to be skipped till end of this data chunk + assertEquals(skipNBytes, buffer.position() - positionAfterFieldHeader, "actual number of bytes skipped"); + assertEquals(positionAfter.removeFirst(), buffer.position()); + } + + @DisplayName("basic primitive writer tests") + @ParameterizedTest(name = "IoBuffer class - {0}") + @ValueSource(classes = { ByteBuffer.class, FastByteBuffer.class }) + void testBasicInterfacePrimitives(final Class bufferClass) throws InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException, NoSuchMethodException, SecurityException { + assertNotNull(bufferClass, "bufferClass being not null"); + assertNotNull(bufferClass.getConstructor(int.class), "Constructor(Integer) present"); + final IoBuffer buffer = bufferClass.getConstructor(int.class).newInstance(BUFFER_SIZE); + final BinarySerialiser ioSerialiser = new BinarySerialiser(buffer); // TODO: generalise to IoBuffer + + Deque positionBefore = new LinkedList<>(); + Deque positionAfter = new LinkedList<>(); + + // add primitive types + positionBefore.add(buffer.position()); + ioSerialiser.put("boolean", true); + positionAfter.add(buffer.position()); + positionBefore.add(buffer.position()); + ioSerialiser.put("boolean", false); + positionAfter.add(buffer.position()); + + positionBefore.add(buffer.position()); + ioSerialiser.put("byte", (byte) 42); + positionAfter.add(buffer.position()); + + positionBefore.add(buffer.position()); + ioSerialiser.put("short", (short) 43); + positionAfter.add(buffer.position()); + + positionBefore.add(buffer.position()); + ioSerialiser.put("int", 44); + positionAfter.add(buffer.position()); + + positionBefore.add(buffer.position()); + ioSerialiser.put("long", 45L); + positionAfter.add(buffer.position()); + + positionBefore.add(buffer.position()); + ioSerialiser.put("float", 1.0f); + positionAfter.add(buffer.position()); + + positionBefore.add(buffer.position()); + ioSerialiser.put("double", 3.0); + positionAfter.add(buffer.position()); + + positionBefore.add(buffer.position()); + ioSerialiser.put("string", "test"); + positionAfter.add(buffer.position()); + + positionBefore.add(buffer.position()); + ioSerialiser.put("string", ""); + positionAfter.add(buffer.position()); + + positionBefore.add(buffer.position()); + ioSerialiser.put("string", (String) null); + positionAfter.add(buffer.position()); + + WireDataFieldDescription header; + // check primitive types + buffer.flip(); + assertEquals(0, buffer.position(), "initial buffer position"); + + // boolean + assertEquals(positionBefore.removeFirst(), buffer.position()); + header = ioSerialiser.getFieldHeader(); + assertEquals("boolean", header.getFieldName(), "boolean field name retrieval"); + assertTrue(ioSerialiser.getBuffer().getBoolean(), "byte retrieval"); + assertEquals(positionAfter.removeFirst(), buffer.position()); + + assertEquals(positionBefore.removeFirst(), buffer.position()); + header = ioSerialiser.getFieldHeader(); + assertEquals("boolean", header.getFieldName(), "boolean field name retrieval"); + assertFalse(ioSerialiser.getBuffer().getBoolean(), "byte retrieval"); + assertEquals(positionAfter.removeFirst(), buffer.position()); + + // byte + assertEquals(positionBefore.removeFirst(), buffer.position()); + header = ioSerialiser.getFieldHeader(); + assertEquals("byte", header.getFieldName(), "boolean field name retrieval"); + assertEquals(42, ioSerialiser.getBuffer().getByte(), "byte retrieval"); + assertEquals(positionAfter.removeFirst(), buffer.position()); + + // short + assertEquals(positionBefore.removeFirst(), buffer.position()); + header = ioSerialiser.getFieldHeader(); + assertEquals("short", header.getFieldName(), "short field name retrieval"); + assertEquals(43, ioSerialiser.getBuffer().getShort(), "short retrieval"); + assertEquals(positionAfter.removeFirst(), buffer.position()); + + // int + assertEquals(positionBefore.removeFirst(), buffer.position()); + header = ioSerialiser.getFieldHeader(); + assertEquals("int", header.getFieldName(), "int field name retrieval"); + assertEquals(44, ioSerialiser.getBuffer().getInt(), "int retrieval"); + assertEquals(positionAfter.removeFirst(), buffer.position()); + + // long + assertEquals(positionBefore.removeFirst(), buffer.position()); + header = ioSerialiser.getFieldHeader(); + assertEquals("long", header.getFieldName(), "long field name retrieval"); + assertEquals(45, ioSerialiser.getBuffer().getLong(), "long retrieval"); + assertEquals(positionAfter.removeFirst(), buffer.position()); + + // float + assertEquals(positionBefore.removeFirst(), buffer.position()); + header = ioSerialiser.getFieldHeader(); + assertEquals("float", header.getFieldName(), "float field name retrieval"); + assertEquals(1.0f, ioSerialiser.getBuffer().getFloat(), "float retrieval"); + assertEquals(positionAfter.removeFirst(), buffer.position()); + + // double + assertEquals(positionBefore.removeFirst(), buffer.position()); + header = ioSerialiser.getFieldHeader(); + assertEquals("double", header.getFieldName(), "double field name retrieval"); + assertEquals(3.0, ioSerialiser.getBuffer().getDouble(), "double retrieval"); + assertEquals(positionAfter.removeFirst(), buffer.position()); + + // string + assertEquals(positionBefore.removeFirst(), buffer.position()); + header = ioSerialiser.getFieldHeader(); + assertEquals("string", header.getFieldName(), "string field name retrieval"); + assertEquals("test", ioSerialiser.getBuffer().getString(), "string retrieval"); + assertEquals(positionAfter.removeFirst(), buffer.position()); + + assertEquals(positionBefore.removeFirst(), buffer.position()); + header = ioSerialiser.getFieldHeader(); + assertEquals("string", header.getFieldName(), "string field name retrieval"); + assertEquals("", ioSerialiser.getBuffer().getString(), "string retrieval"); + assertEquals(positionAfter.removeFirst(), buffer.position()); + + assertEquals(positionBefore.removeFirst(), buffer.position()); + header = ioSerialiser.getFieldHeader(); + assertEquals("string", header.getFieldName(), "string field name retrieval"); + assertEquals("", ioSerialiser.getBuffer().getString(), "string retrieval"); + assertEquals(positionAfter.removeFirst(), buffer.position()); + } + + @DisplayName("basic tests") + @ParameterizedTest(name = "IoBuffer class - {0}") + @ValueSource(classes = { ByteBuffer.class, FastByteBuffer.class }) + void testHeaderAndSpecialItems(final Class bufferClass) throws InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException, NoSuchMethodException, SecurityException { + assertNotNull(bufferClass, "bufferClass being not null"); + assertNotNull(bufferClass.getConstructor(int.class), "Constructor(Integer) present"); + final IoBuffer buffer = bufferClass.getConstructor(int.class).newInstance(BUFFER_SIZE); + final BinarySerialiser ioSerialiser = new BinarySerialiser(buffer); + + Deque positionBefore = new LinkedList<>(); + Deque positionAfter = new LinkedList<>(); + + // add header info + positionBefore.add(buffer.position()); + ioSerialiser.putHeaderInfo(); + positionAfter.add(buffer.position()); + + // add start marker + positionBefore.add(buffer.position()); + final String dataStartMarkerName = "StartMarker"; + final WireDataFieldDescription dataStartMarker = new WireDataFieldDescription(ioSerialiser, null, dataStartMarkerName.hashCode(), dataStartMarkerName, DataType.START_MARKER, -1, -1, -1); + ioSerialiser.putStartMarker(dataStartMarker); + positionAfter.add(buffer.position()); + + // add Collection - List + final List list = Arrays.asList(1, 2, 3); + positionBefore.add(buffer.position()); + ioSerialiser.put("collection", list, Integer.class); + positionAfter.add(buffer.position()); + + // add Collection - Set + final Set set = Set.of(1, 2, 3); + positionBefore.add(buffer.position()); + ioSerialiser.put("set", set, Integer.class); + positionAfter.add(buffer.position()); + + // add Collection - Queue + final Queue queue = new LinkedList<>(Arrays.asList(1, 2, 3)); + positionBefore.add(buffer.position()); + ioSerialiser.put("queue", queue, Integer.class); + positionAfter.add(buffer.position()); + + // add Map + final Map map = new HashMap<>(); + list.forEach(item -> map.put(item, "Item#" + item.toString())); + positionBefore.add(buffer.position()); + ioSerialiser.put("map", map, Integer.class, String.class); + positionAfter.add(buffer.position()); + + // add Enum + positionBefore.add(buffer.position()); + ioSerialiser.put("enum", DataType.ENUM); + positionAfter.add(buffer.position()); + + // add end marker + positionBefore.add(buffer.position()); + final String dataEndMarkerName = "EndMarker"; + final WireDataFieldDescription dataEndMarker = new WireDataFieldDescription(ioSerialiser, null, dataEndMarkerName.hashCode(), dataEndMarkerName, DataType.START_MARKER, -1, -1, -1); + ioSerialiser.putEndMarker(dataEndMarker); + positionAfter.add(buffer.position()); + + buffer.flip(); + + WireDataFieldDescription header; + int positionAfterFieldHeader; + long skipNBytes; + // check types + assertEquals(0, buffer.position(), "initial buffer position"); + + // header info + assertEquals(positionBefore.removeFirst(), buffer.position()); + ProtocolInfo headerInfo = ioSerialiser.checkHeaderInfo(); + assertNotEquals(headerInfo, new Object()); // silly comparison for coverage reasons + assertNotNull(headerInfo); + assertEquals(BinarySerialiser.PROTOCOL_NAME, headerInfo.getProducerName()); + assertEquals(BinarySerialiser.VERSION_MAJOR, headerInfo.getVersionMajor()); + assertEquals(BinarySerialiser.VERSION_MINOR, headerInfo.getVersionMinor()); + assertEquals(BinarySerialiser.VERSION_MICRO, headerInfo.getVersionMicro()); + assertEquals(positionAfter.removeFirst(), buffer.position()); + + // start marker + assertEquals(positionBefore.removeFirst(), buffer.position()); + header = ioSerialiser.getFieldHeader(); + assertEquals("StartMarker", header.getFieldName(), "StartMarker type retrieval"); + assertEquals(positionAfter.removeFirst(), buffer.position()); + + // Collections - List + assertEquals(positionBefore.removeFirst(), buffer.position()); + header = ioSerialiser.getFieldHeader(); + assertEquals("collection", header.getFieldName(), "List field name"); + assertEquals(DataType.LIST, header.getDataType(), "List - type ID"); + positionAfterFieldHeader = buffer.position(); // actual buffer position after having read the field header + skipNBytes = header.getDataSize(); // number of bytes to be skipped till end of this data chunk + assertNotNull(positionAfter.peekFirst()); + assertEquals(positionAfter.peekFirst() - positionAfterFieldHeader, skipNBytes, "buffer skip address"); + assertFalse(header.getDataType().isScalar()); + assertEquals(ARRAY_DIM_1D, ioSerialiser.getBuffer().getInt(), "dimension"); + assertEquals(3, ioSerialiser.getBuffer().getInt(), "array size"); + buffer.position(header.getDataStartPosition()); + final int readPosition = buffer.position(); + Collection retrievedCollection = ioSerialiser.getCollection(null); + assertNotNull(retrievedCollection, "retrieved collection not null"); + assertEquals(list, retrievedCollection); + assertEquals(buffer.position(), header.getDataStartPosition() + header.getDataSize(), "buffer position data end"); + // check for specific List interface + buffer.position(readPosition); + List retrievedList = ioSerialiser.getList(null); + assertNotNull(retrievedList, "retrieved collection List not null"); + assertEquals(list, retrievedList); + assertEquals(positionAfter.removeFirst(), buffer.position()); + + // Collections - Set + assertEquals(positionBefore.removeFirst(), buffer.position()); + header = ioSerialiser.getFieldHeader(); + assertEquals("set", header.getFieldName(), "Set field name"); + assertEquals(DataType.SET, header.getDataType(), "Set - type ID"); + positionAfterFieldHeader = buffer.position(); // actual buffer position after having read the field header + skipNBytes = header.getDataSize(); // number of bytes to be skipped till end of this data chunk + assertNotNull(positionAfter.peekFirst()); + assertEquals(positionAfter.peekFirst() - positionAfterFieldHeader, skipNBytes, "buffer skip address"); + assertFalse(header.getDataType().isScalar()); + assertEquals(ARRAY_DIM_1D, ioSerialiser.getBuffer().getInt(), "dimension"); + assertEquals(3, ioSerialiser.getBuffer().getInt(), "array size"); + buffer.position(header.getDataStartPosition()); + Collection retrievedSet = ioSerialiser.getSet(null); + assertNotNull(retrievedSet, "retrieved set not null"); + assertEquals(set, retrievedSet); + assertEquals(positionAfter.removeFirst(), buffer.position()); + + // Collections - Queue + assertEquals(positionBefore.removeFirst(), buffer.position()); + header = ioSerialiser.getFieldHeader(); + assertEquals("queue", header.getFieldName(), "Queue field name"); + assertEquals(DataType.QUEUE, header.getDataType(), "Queue - type ID"); + positionAfterFieldHeader = buffer.position(); // actual buffer position after having read the field header + skipNBytes = header.getDataSize(); // number of bytes to be skipped till end of this data chunk + assertNotNull(positionAfter.peekFirst()); + assertEquals(positionAfter.peekFirst() - positionAfterFieldHeader, skipNBytes, "buffer skip address"); + assertFalse(header.getDataType().isScalar()); + assertEquals(ARRAY_DIM_1D, ioSerialiser.getBuffer().getInt(), "dimension"); + assertEquals(3, ioSerialiser.getBuffer().getInt(), "array size"); + buffer.position(header.getDataStartPosition()); + Queue retrievedQueue = ioSerialiser.getQueue(null); + assertNotNull(retrievedQueue, "retrieved set not null"); + // assertEquals(queue, retrievedQueue); // N.B. no direct comparison possible -> only partial Queue interface overlapp + while (!queue.isEmpty() && !retrievedQueue.isEmpty()) { + assertEquals(queue.poll(), retrievedQueue.poll()); + } + assertEquals(0, queue.size(), "reference queue empty"); + assertEquals(0, retrievedQueue.size(), "retrieved queue empty"); + assertEquals(positionAfter.removeFirst(), buffer.position()); + + // retrieve Map + assertEquals(positionBefore.removeFirst(), buffer.position()); + header = ioSerialiser.getFieldHeader(); + assertEquals("map", header.getFieldName(), "Map field name"); + assertEquals(DataType.MAP, header.getDataType(), "Map - type ID"); + positionAfterFieldHeader = buffer.position(); // actual buffer position after having read the field header + skipNBytes = header.getDataSize(); // number of bytes to be skipped till end of this data chunk + assertNotNull(positionAfter.peekFirst()); + assertEquals(positionAfter.peekFirst() - positionAfterFieldHeader, skipNBytes, "buffer skip address"); + assertFalse(header.getDataType().isScalar()); + assertEquals(ARRAY_DIM_1D, ioSerialiser.getBuffer().getInt(), "dimension"); + assertEquals(3, ioSerialiser.getBuffer().getInt(), "array size"); + buffer.position(header.getDataStartPosition()); + Map retrievedMap = ioSerialiser.getMap(null); + assertNotNull(retrievedMap, "retrieved set not null"); + assertEquals(map, retrievedMap); // N.B. no direct comparison possible -> only partial Queue interface overlapp + assertEquals(positionAfter.removeFirst(), buffer.position()); + + // enum + assertEquals(positionBefore.removeFirst(), buffer.position()); + header = ioSerialiser.getFieldHeader(); + assertEquals("enum", header.getFieldName(), "enum type retrieval"); + buffer.position(header.getDataStartPosition()); + assertDoesNotThrow(ioSerialiser::getEnumTypeList); //skips enum info + buffer.position(header.getDataStartPosition()); + assertEquals(DataType.ENUM, ioSerialiser.getEnum(DataType.OTHER), "enum retrieval"); + assertEquals(positionAfter.removeFirst(), buffer.position()); + + // end marker + assertEquals(positionBefore.removeFirst(), buffer.position()); + header = ioSerialiser.getFieldHeader(); + assertEquals("EndMarker", header.getFieldName(), "EndMarker type retrieval"); + assertEquals(positionAfter.removeFirst(), buffer.position()); + } + + @Test + void testIdentityGenericObject() { + // Simple tests to verify that the equals and hashCode functions of 'MyGenericClass' work as expected + final MyGenericClass rootObject1 = new MyGenericClass(); + final MyGenericClass rootObject2 = new MyGenericClass(); + MyGenericClass.setVerboseChecks(false); + + assertNotNull(rootObject1.toString()); + assertNotNull(rootObject1.boxedPrimitives.toString()); + + assertEquals(rootObject1, rootObject2); + + rootObject1.modifyValues(); + assertNotEquals(rootObject1, rootObject2); + rootObject2.modifyValues(); + assertEquals(rootObject1, rootObject2); + + rootObject1.boxedPrimitives.modifyValues(); + assertNotEquals(rootObject1, rootObject2); + rootObject2.boxedPrimitives.modifyValues(); + assertEquals(rootObject1, rootObject2); + + rootObject1.arrays.modifyValues(); + assertNotEquals(rootObject1, rootObject2); + rootObject2.arrays.modifyValues(); + assertEquals(rootObject1, rootObject2); + + rootObject1.objArrays.modifyValues(); + assertNotEquals(rootObject1, rootObject2); + rootObject2.objArrays.modifyValues(); + assertEquals(rootObject1, rootObject2); + } + + @DisplayName("basic primitive array writer tests") + @ParameterizedTest(name = "IoBuffer class - {0}") + @ValueSource(classes = { ByteBuffer.class, FastByteBuffer.class }) + void testParseIoStream(final Class bufferClass) throws InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException, NoSuchMethodException, SecurityException { + assertNotNull(bufferClass, "bufferClass being not null"); + assertNotNull(bufferClass.getConstructor(int.class), "Constructor(Integer) present"); + final IoBuffer buffer = bufferClass.getConstructor(int.class).newInstance(2 * BUFFER_SIZE); // a bit larger buffer since we test more cases at once + final BinarySerialiser ioSerialiser = new BinarySerialiser(buffer); + + ioSerialiser.putHeaderInfo(); // add header info + + // add some primitives + ioSerialiser.put("boolean", true); + ioSerialiser.put("byte", (byte) 42); + ioSerialiser.put("short", (short) 42); + ioSerialiser.put("int", 42); + ioSerialiser.put("long", 42L); + ioSerialiser.put("float", 42f); + ioSerialiser.put("double", 42); + ioSerialiser.put("string", "string"); + + ioSerialiser.put("boolean[]", new boolean[] { true }, 1); + ioSerialiser.put("byte[]", new byte[] { (byte) 42 }, 1); + ioSerialiser.put("short[]", new short[] { (short) 42 }, 1); + ioSerialiser.put("int[]", new int[] { 42 }, 1); + ioSerialiser.put("long[]", new long[] { 42L }, 1); + ioSerialiser.put("float[]", new float[] { (float) 42 }, 1); + ioSerialiser.put("double[]", new double[] { (double) 42 }, 1); + ioSerialiser.put("string[]", new String[] { "string" }, 1); + + final Collection collection = Arrays.asList(1, 2, 3); + ioSerialiser.put("collection", collection, Integer.class); // add Collection - List + + final List list = Arrays.asList(1, 2, 3); + ioSerialiser.put("list", list, Integer.class); // add Collection - List + + final Set set = Set.of(1, 2, 3); + ioSerialiser.put("set", set, Integer.class); // add Collection - Set + + final Queue queue = new LinkedList<>(Arrays.asList(1, 2, 3)); + ioSerialiser.put("queue", queue, Integer.class); // add Collection - Queue + + final Map map = new HashMap<>(); + list.forEach(item -> map.put(item, "Item#" + item.toString())); + ioSerialiser.put("map", map, Integer.class, String.class); // add Map + + ioSerialiser.put("enum", DataType.ENUM); // add Enum + + // start nested data + final String nestedContextName = "nested context"; + final WireDataFieldDescription nestedContextMarker = new WireDataFieldDescription(ioSerialiser, null, nestedContextName.hashCode(), nestedContextName, DataType.START_MARKER, -1, -1, -1); + ioSerialiser.putStartMarker(nestedContextMarker); // add start marker + ioSerialiser.put("booleanArray", new boolean[] { true }, 1); + ioSerialiser.put("byteArray", new byte[] { (byte) 0x42 }, 1); + + ioSerialiser.putEndMarker(nestedContextMarker); // add end marker + // end nested data + + final String dataEndMarkerName = "Life is good!"; + final WireDataFieldDescription dataEndMarker = new WireDataFieldDescription(ioSerialiser, null, dataEndMarkerName.hashCode(), dataEndMarkerName, DataType.START_MARKER, -1, -1, -1); + ioSerialiser.putEndMarker(dataEndMarker); // add end marker + + buffer.flip(); + + // and read back streamed items + final WireDataFieldDescription objectRoot = ioSerialiser.parseIoStream(true); + assertNotNull(objectRoot); + // objectRoot.printFieldStructure(); + } + + @DisplayName("test getGenericArrayAsBoxedPrimitive(...) helper method") + @ParameterizedTest(name = "IoBuffer class - {0}") + @ValueSource(classes = { ByteBuffer.class, FastByteBuffer.class }) + void testGetGenericArrayAsBoxedPrimitiveHelper(final Class bufferClass) throws InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException, NoSuchMethodException, SecurityException { + assertNotNull(bufferClass, "bufferClass being not null"); + assertNotNull(bufferClass.getConstructor(int.class), "Constructor(Integer) present"); + final IoBuffer buffer = bufferClass.getConstructor(int.class).newInstance(2 * BUFFER_SIZE); // a bit larger buffer since we test more cases at once + final BinarySerialiser ioSerialiser = new BinarySerialiser(buffer); + + putGenericTestArrays(ioSerialiser); + + buffer.flip(); + + // test conversion to double array + ioSerialiser.checkHeaderInfo(); + assertArrayEquals(new Boolean[] { true, false, true }, ioSerialiser.getGenericArrayAsBoxedPrimitive(DataType.BOOL)); + assertArrayEquals(new Byte[] { (byte) 1.0, (byte) 0.0, (byte) 2.0 }, ioSerialiser.getGenericArrayAsBoxedPrimitive(DataType.BYTE)); + assertArrayEquals(new Character[] { (char) 1.0, (char) 0.0, (char) 2.0 }, ioSerialiser.getGenericArrayAsBoxedPrimitive(DataType.CHAR)); + assertArrayEquals(new Short[] { (short) 1.0, (short) 0.0, (short) 2.0 }, ioSerialiser.getGenericArrayAsBoxedPrimitive(DataType.SHORT)); + assertArrayEquals(new Integer[] { 1, 0, 2 }, ioSerialiser.getGenericArrayAsBoxedPrimitive(DataType.INT)); + assertArrayEquals(new Long[] { (long) 1.0, (long) 0.0, (long) 2.0 }, ioSerialiser.getGenericArrayAsBoxedPrimitive(DataType.LONG)); + assertArrayEquals(new Float[] { 1.0f, 0.0f, 2.0f }, ioSerialiser.getGenericArrayAsBoxedPrimitive(DataType.FLOAT)); + assertArrayEquals(new Double[] { 1.0, 0.0, 2.0 }, ioSerialiser.getGenericArrayAsBoxedPrimitive(DataType.DOUBLE)); + assertArrayEquals(new String[] { "1.0", "0.0", "2.0" }, ioSerialiser.getGenericArrayAsBoxedPrimitive(DataType.STRING)); + } + + @Test + void testMisc() { + final BinarySerialiser ioSerialiser = new BinarySerialiser(new FastByteBuffer(1000)); // TODO: generalise to IoBuffer + final int bufferIncrements = ioSerialiser.getBufferIncrements(); + AssertUtils.gtEqThanZero("bufferIncrements", bufferIncrements); + ioSerialiser.setBufferIncrements(bufferIncrements + 1); + assertEquals(bufferIncrements + 1, ioSerialiser.getBufferIncrements()); + } +} diff --git a/serialiser/src/test/java/io/opencmw/serialiser/spi/CmwLightSerialiserTests.java b/serialiser/src/test/java/io/opencmw/serialiser/spi/CmwLightSerialiserTests.java new file mode 100644 index 00000000..b5d0af59 --- /dev/null +++ b/serialiser/src/test/java/io/opencmw/serialiser/spi/CmwLightSerialiserTests.java @@ -0,0 +1,26 @@ +package io.opencmw.serialiser.spi; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +import io.opencmw.serialiser.FieldDescription; + +public class CmwLightSerialiserTests { + @Test + public void testCmwData() { + final CmwLightSerialiser serialiser = new CmwLightSerialiser(FastByteBuffer.wrap(new byte[] { + 7, 0, 0, 0, 2, 0, 0, 0, 48, 0, 4, 1, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 49, 0, 7, 1, 0, 0, 0, 0, 2, 0, 0, + 0, 50, 0, 1, 5, 2, 0, 0, 0, 51, 0, 8, 1, 0, 0, 0, 2, 0, 0, 0, 98, 0, 4, 114, 0, 0, 0, 0, 0, 0, 0, 2, 0, + 0, 0, 55, 0, 1, 0, 2, 0, 0, 0, 100, 0, 7, 1, 0, 0, 0, 0, 2, 0, 0, 0, 102, 0, 7, 1, 0, 0, 0, 0 })); + final FieldDescription fieldDescription = serialiser.parseIoStream(true).getChildren().get(0); + // fieldDescription.printFieldStructure(); + assertEquals(1L, ((WireDataFieldDescription) fieldDescription.findChildField("0")).data()); + assertEquals("", ((WireDataFieldDescription) fieldDescription.findChildField("1")).data()); + assertEquals((byte) 5, ((WireDataFieldDescription) fieldDescription.findChildField("2")).data()); + assertEquals(114L, ((WireDataFieldDescription) fieldDescription.findChildField("3").findChildField("b")).data()); + assertEquals((byte) 0, ((WireDataFieldDescription) fieldDescription.findChildField("7")).data()); + assertEquals("", ((WireDataFieldDescription) fieldDescription.findChildField("d")).data()); + assertEquals("", ((WireDataFieldDescription) fieldDescription.findChildField("f")).data()); + } +} diff --git a/serialiser/src/test/java/io/opencmw/serialiser/spi/IoBufferTests.java b/serialiser/src/test/java/io/opencmw/serialiser/spi/IoBufferTests.java new file mode 100644 index 00000000..978d382c --- /dev/null +++ b/serialiser/src/test/java/io/opencmw/serialiser/spi/IoBufferTests.java @@ -0,0 +1,561 @@ +package io.opencmw.serialiser.spi; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +import java.lang.reflect.InvocationTargetException; +import java.util.Arrays; + +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import io.opencmw.serialiser.IoBuffer; +import io.opencmw.serialiser.utils.ByteArrayCache; + +/** + * @author rstein + */ +class IoBufferTests { + protected static final boolean[] booleanTestArray = { true, false, true, false }; + protected static final byte[] byteTestArray = { 100, 101, 102, 103, -100, -101, -102, -103 }; + protected static final short[] shortTestArray = { -5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5 }; // NOPMD by rstein + protected static final int[] intTestArray = { 5, 4, 3, 2, 1, 0, -1, -2, -3, -4, -5 }; + protected static final long[] longTestArray = { Integer.MAX_VALUE, Integer.MAX_VALUE - 1, -Integer.MAX_VALUE + 2 }; + protected static final float[] floatTestArray = { 1.1e9f, 1.2e9f, 1.3e9f, -1.1e9f, -1.2e9f, -1.3e9f }; + protected static final double[] doubleTestArray = { Float.MAX_VALUE + 1.1e9, Float.MAX_VALUE + 1.2e9, Float.MAX_VALUE + 1.3e9f, -Float.MAX_VALUE - 1.1e9f, -Float.MAX_VALUE - 1.2e9f, Float.MAX_VALUE - 1.3e9f }; + protected static final char[] charTestArray = { 'a', 'b', 'c', 'd' }; + protected static final String[] stringTestArray = { "Is", "this", "the", "real", "life?", "Is", "this", "just", "fantasy?", "", null }; + protected static final String[] stringTestArrayNullAsEmpty = Arrays.stream(stringTestArray).map(s -> s == null ? "" : s).toArray(String[]::new); + private static final int BUFFER_SIZE = 1000; + + @ParameterizedTest(name = "IoBuffer class - {0}") + @ValueSource(classes = { FastByteBuffer.class }) // trim is not implemented for ByteBuffer + @SuppressWarnings("PMD.ExcessiveMethodLength") + void trimTest(final Class bufferClass) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException { + assertNotNull(bufferClass, "bufferClass being not null"); + assertNotNull(bufferClass.getConstructor(int.class), "Constructor(Integer) present"); + final IoBuffer buffer = bufferClass.getConstructor(int.class).newInstance(10); + + buffer.position(5); + buffer.trim(12); + assertEquals(10, buffer.capacity()); + buffer.trim(7); + assertEquals(7, buffer.capacity()); + buffer.trim(); + assertEquals(5, buffer.capacity()); + buffer.trim(3); + assertEquals(5, buffer.capacity()); + } + + @ParameterizedTest(name = "IoBuffer class - {0}") + @ValueSource(classes = { ByteBuffer.class, FastByteBuffer.class }) + @SuppressWarnings("PMD.ExcessiveMethodLength") + void primitivesArrays(final Class bufferClass) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException { + assertNotNull(bufferClass, "bufferClass being not null"); + assertNotNull(bufferClass.getConstructor(int.class), "Constructor(Integer) present"); + final IoBuffer buffer = bufferClass.getConstructor(int.class).newInstance(BUFFER_SIZE); + + assertNotNull(buffer.toString()); + + { + buffer.reset(); + assertDoesNotThrow(() -> buffer.putBooleanArray(booleanTestArray, booleanTestArray.length)); + assertDoesNotThrow(() -> buffer.putBooleanArray(booleanTestArray, -1)); + assertDoesNotThrow(() -> buffer.putBooleanArray(null, 5)); + buffer.flip(); + assertArrayEquals(booleanTestArray, buffer.getBooleanArray()); + assertArrayEquals(booleanTestArray, buffer.getBooleanArray()); + assertEquals(0, buffer.getBooleanArray().length); + } + + { + buffer.reset(); + assertDoesNotThrow(() -> buffer.putByteArray(byteTestArray, byteTestArray.length)); + assertDoesNotThrow(() -> buffer.putByteArray(byteTestArray, -1)); + assertDoesNotThrow(() -> buffer.putByteArray(null, 5)); + buffer.flip(); + assertArrayEquals(byteTestArray, buffer.getByteArray()); + assertArrayEquals(byteTestArray, buffer.getByteArray()); + assertEquals(0, buffer.getByteArray().length); + } + + { + buffer.reset(); + assertDoesNotThrow(() -> buffer.putShortArray(shortTestArray, shortTestArray.length)); + assertDoesNotThrow(() -> buffer.putShortArray(shortTestArray, -1)); + assertDoesNotThrow(() -> buffer.putShortArray(null, 5)); + buffer.flip(); + assertArrayEquals(shortTestArray, buffer.getShortArray()); + assertArrayEquals(shortTestArray, buffer.getShortArray()); + assertEquals(0, buffer.getShortArray().length); + } + + { + buffer.reset(); + assertDoesNotThrow(() -> buffer.putIntArray(intTestArray, intTestArray.length)); + assertDoesNotThrow(() -> buffer.putIntArray(intTestArray, -1)); + assertDoesNotThrow(() -> buffer.putIntArray(null, 5)); + buffer.flip(); + assertArrayEquals(intTestArray, buffer.getIntArray()); + assertArrayEquals(intTestArray, buffer.getIntArray()); + assertEquals(0, buffer.getIntArray().length); + } + + { + buffer.reset(); + assertDoesNotThrow(() -> buffer.putLongArray(longTestArray, longTestArray.length)); + assertDoesNotThrow(() -> buffer.putLongArray(longTestArray, -1)); + assertDoesNotThrow(() -> buffer.putLongArray(null, 5)); + buffer.flip(); + assertArrayEquals(longTestArray, buffer.getLongArray()); + assertArrayEquals(longTestArray, buffer.getLongArray()); + assertEquals(0, buffer.getLongArray().length); + } + + { + buffer.reset(); + assertDoesNotThrow(() -> buffer.putFloatArray(floatTestArray, floatTestArray.length)); + assertDoesNotThrow(() -> buffer.putFloatArray(floatTestArray, -1)); + assertDoesNotThrow(() -> buffer.putFloatArray(null, 5)); + buffer.flip(); + assertArrayEquals(floatTestArray, buffer.getFloatArray()); + assertArrayEquals(floatTestArray, buffer.getFloatArray()); + assertEquals(0, buffer.getFloatArray().length); + } + + { + buffer.reset(); + assertDoesNotThrow(() -> buffer.putDoubleArray(doubleTestArray, doubleTestArray.length)); + assertDoesNotThrow(() -> buffer.putDoubleArray(doubleTestArray, -1)); + assertDoesNotThrow(() -> buffer.putDoubleArray(null, 5)); + buffer.flip(); + assertArrayEquals(doubleTestArray, buffer.getDoubleArray()); + assertArrayEquals(doubleTestArray, buffer.getDoubleArray()); + assertEquals(0, buffer.getDoubleArray().length); + } + + { + buffer.reset(); + assertDoesNotThrow(() -> buffer.putCharArray(charTestArray, charTestArray.length)); + assertDoesNotThrow(() -> buffer.putCharArray(charTestArray, -1)); + assertDoesNotThrow(() -> buffer.putCharArray(null, 5)); + buffer.flip(); + assertArrayEquals(charTestArray, buffer.getCharArray()); + assertArrayEquals(charTestArray, buffer.getCharArray()); + assertEquals(0, buffer.getCharArray().length); + } + + { + buffer.reset(); + assertDoesNotThrow(() -> buffer.putStringArray(stringTestArray, stringTestArray.length)); + assertDoesNotThrow(() -> buffer.putStringArray(stringTestArray, -1)); + assertDoesNotThrow(() -> buffer.putStringArray(null, 5)); + buffer.flip(); + assertArrayEquals(stringTestArrayNullAsEmpty, buffer.getStringArray()); + assertArrayEquals(stringTestArrayNullAsEmpty, buffer.getStringArray()); + assertEquals(0, buffer.getStringArray().length); + } + } + + @Test + void primitivesArraysASCII() { + FastByteBuffer buffer = new FastByteBuffer(BUFFER_SIZE); + + { + final char[] chars = Character.toChars(0x1F701); + final String fourByteCharacter = new String(chars); + String utf8TestString = "Γειά σου Κόσμε! - " + fourByteCharacter + " 語 \u00ea \u00f1 \u00fc + some normal ASCII character"; + buffer.reset(); + assertDoesNotThrow(() -> buffer.putStringArray(stringTestArray, stringTestArray.length)); + assertDoesNotThrow(() -> buffer.putStringArray(stringTestArray, -1)); + assertDoesNotThrow(() -> buffer.putStringArray(null, 5)); + buffer.putString(utf8TestString); + buffer.flip(); + assertArrayEquals(stringTestArrayNullAsEmpty, buffer.getStringArray()); + assertArrayEquals(stringTestArrayNullAsEmpty, buffer.getStringArray()); + assertEquals(0, buffer.getStringArray().length); + assertEquals(utf8TestString, buffer.getString()); + } + + buffer.setEnforceSimpleStringEncoding(true); + { + buffer.reset(); + assertDoesNotThrow(() -> buffer.putStringArray(stringTestArray, stringTestArray.length)); + assertDoesNotThrow(() -> buffer.putStringArray(stringTestArray, -1)); + assertDoesNotThrow(() -> buffer.putStringArray(null, 5)); + buffer.putString("Hello World!"); + buffer.flip(); + assertArrayEquals(stringTestArrayNullAsEmpty, buffer.getStringArray()); + assertArrayEquals(stringTestArrayNullAsEmpty, buffer.getStringArray()); + assertEquals(0, buffer.getStringArray().length); + assertEquals("Hello World!", buffer.getString()); + } + } + + @ParameterizedTest(name = "IoBuffer class - {0}") + @ValueSource(classes = { ByteBuffer.class, FastByteBuffer.class }) + void primitivesMixed(final Class bufferClass) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException { + assertNotNull(bufferClass, "bufferClass being not null"); + assertNotNull(bufferClass.getConstructor(int.class), "Constructor(Integer) present"); + final IoBuffer buffer = bufferClass.getConstructor(int.class).newInstance(BUFFER_SIZE); + final long largeLong = (long) Integer.MAX_VALUE + (long) 10; + + buffer.reset(); + buffer.putBoolean(true); + buffer.putBoolean(false); + buffer.putByte((byte) 0xFE); + buffer.putShort((short) 43); + buffer.putInt(1025); + buffer.putLong(largeLong); + buffer.putFloat(1.3e10f); + buffer.putDouble(1.3e10f); + buffer.putChar('@'); + buffer.putChar((char) 513); + buffer.putStringISO8859("Hello World!"); + buffer.putString("Γειά σου Κόσμε!"); + final long position = buffer.position(); + + // return to start position + buffer.flip(); + assertTrue(buffer.getBoolean()); + assertFalse(buffer.getBoolean()); + assertEquals(buffer.getByte(), (byte) 0xFE); + assertEquals(buffer.getShort(), (short) 43); + assertEquals(1025, buffer.getInt()); + assertEquals(buffer.getLong(), largeLong); + assertEquals(1.3e10f, buffer.getFloat()); + assertEquals(1.3e10f, buffer.getDouble()); + assertEquals('@', buffer.getChar()); + assertEquals((char) 513, buffer.getChar()); + assertEquals("Hello World!", buffer.getStringISO8859()); + assertEquals("Γειά σου Κόσμε!", buffer.getString()); + assertEquals(buffer.position(), position); + } + + @ParameterizedTest(name = "IoBuffer class - {0}") + @ValueSource(classes = { ByteBuffer.class, FastByteBuffer.class }) + void primitivesSimple(final Class bufferClass) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException { + assertNotNull(bufferClass, "bufferClass being not null"); + assertNotNull(bufferClass.getConstructor(int.class), "Constructor(Integer) present"); + final IoBuffer buffer = bufferClass.getConstructor(int.class).newInstance(BUFFER_SIZE); + buffer.reset(); + buffer.putBoolean(true); + buffer.flip(); + assertTrue(buffer.getBoolean()); + + buffer.reset(); + buffer.putBoolean(false); + buffer.flip(); + assertFalse(buffer.getBoolean()); + + buffer.reset(); + buffer.putByte((byte) 0xFE); + buffer.flip(); + assertEquals(buffer.getByte(), (byte) 0xFE); + + buffer.reset(); + buffer.putShort((short) 43); + buffer.flip(); + assertEquals(buffer.getShort(), (short) 43); + + buffer.reset(); + buffer.putInt(1025); + buffer.flip(); + assertEquals(1025, buffer.getInt()); + + buffer.reset(); + final long largeLong = (long) Integer.MAX_VALUE + (long) 10; + buffer.putLong(largeLong); + buffer.flip(); + assertEquals(buffer.getLong(), largeLong); + + buffer.reset(); + buffer.putFloat(1.3e10f); + buffer.flip(); + assertEquals(1.3e10f, buffer.getFloat()); + + buffer.reset(); + buffer.putDouble(1.3e10f); + buffer.flip(); + assertEquals(1.3e10f, buffer.getDouble()); + + buffer.reset(); + buffer.putChar('@'); + buffer.flip(); + assertEquals('@', buffer.getChar()); + + buffer.reset(); + buffer.putChar((char) 513); + buffer.flip(); + assertEquals((char) 513, buffer.getChar()); + + buffer.reset(); + buffer.putString("Hello World!"); + buffer.flip(); + assertEquals("Hello World!", buffer.getString()); + } + + @ParameterizedTest(name = "IoBuffer class - {0}") + @ValueSource(classes = { ByteBuffer.class, FastByteBuffer.class }) + void primitivesSimpleInPlace(final Class bufferClass) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException { + assertNotNull(bufferClass, "bufferClass being not null"); + assertNotNull(bufferClass.getConstructor(int.class), "Constructor(Integer) present"); + final IoBuffer buffer = bufferClass.getConstructor(int.class).newInstance(BUFFER_SIZE); + buffer.reset(); + + buffer.putBoolean(0, true); + assertTrue(buffer.getBoolean(0)); + + buffer.reset(); + buffer.putBoolean(0, false); + assertFalse(buffer.getBoolean(0)); + + buffer.putByte(1, (byte) 0xFE); + assertEquals(buffer.getByte(1), (byte) 0xFE); + + buffer.putShort(2, (short) 43); + assertEquals(buffer.getShort(2), (short) 43); + + buffer.putInt(3, 1025); + assertEquals(1025, buffer.getInt(3)); + + final long largeLong = (long) Integer.MAX_VALUE + (long) 10; + buffer.putLong(4, largeLong); + assertEquals(buffer.getLong(4), largeLong); + + buffer.putFloat(5, 1.3e10f); + assertEquals(1.3e10f, buffer.getFloat(5)); + + buffer.putDouble(6, 1.3e10f); + assertEquals(1.3e10f, buffer.getDouble(6)); + + buffer.putChar(7, '@'); + assertEquals('@', buffer.getChar(7)); + + buffer.putChar(7, (char) 513); + assertEquals((char) 513, buffer.getChar(7)); + + buffer.putString(8, "Hello World!"); + assertEquals("Hello World!", buffer.getString(8)); + } + + @ParameterizedTest(name = "IoBuffer class - {0}") + @ValueSource(classes = { ByteBuffer.class, FastByteBuffer.class }) + void indexManipulations(final Class bufferClass) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException { + assertNotNull(bufferClass, "bufferClass being not null"); + assertNotNull(bufferClass.getConstructor(int.class), "Constructor(Integer) present"); + final IoBuffer buffer = bufferClass.getConstructor(int.class).newInstance(10); + + assertEquals(0, buffer.position()); + assertEquals(10, buffer.limit()); + assertEquals(10, buffer.capacity()); + + assertThrows(IllegalArgumentException.class, () -> buffer.limit(11)); // limit > capacity + buffer.position(5); + buffer.limit(8); + assertEquals(5, buffer.position()); + assertEquals(8, buffer.limit()); + assertEquals(10, buffer.capacity()); + assertTrue(buffer.hasRemaining()); + assertEquals(3, buffer.remaining()); + + buffer.flip(); + assertEquals(0, buffer.position()); + assertEquals(5, buffer.limit()); + assertEquals(10, buffer.capacity()); + + assertThrows(IllegalArgumentException.class, () -> buffer.position(6)); // pos > limit + buffer.position(4); + buffer.limit(3); + assertEquals(3, buffer.position()); + assertEquals(3, buffer.limit()); + assertEquals(10, buffer.capacity()); + assertFalse(buffer.hasRemaining()); + + buffer.reset(); + } + + @Test + void testFastByteBufferAllocators() { + { + FastByteBuffer buffer = new FastByteBuffer(); + assertTrue(buffer.capacity() > 0); + assertEquals(0, buffer.position()); + assertEquals(buffer.limit(), buffer.capacity()); + buffer.limit(buffer.capacity() - 2); + assertEquals(buffer.limit(), (buffer.capacity() - 2)); + assertFalse(buffer.isReadOnly()); + } + + { + FastByteBuffer buffer = new FastByteBuffer(500); + assertEquals(500, buffer.capacity()); + } + + { + FastByteBuffer buffer = new FastByteBuffer(new byte[1000], 500); + assertEquals(1000, buffer.capacity()); + assertEquals(500, buffer.limit()); + assertThrows(IllegalArgumentException.class, () -> new FastByteBuffer(new byte[5], 10)); + } + + { + FastByteBuffer buffer = FastByteBuffer.wrap(byteTestArray); + assertArrayEquals(byteTestArray, buffer.elements()); + } + } + + @Test + void testFastByteBufferResizing() { + FastByteBuffer buffer = new FastByteBuffer(300, true, new ByteArrayCache()); + assertTrue(buffer.isAutoResize()); + assertNotNull(buffer.getByteArrayCache()); + assertEquals(0, buffer.getByteArrayCache().size()); + assertEquals(300, buffer.capacity()); + + buffer.limit(200); // shift limit to index 200 + assertEquals(200, buffer.remaining()); // N.B. == 200 - pos (0); + + buffer.ensureAdditionalCapacity(200); // should be NOP + assertEquals(200, buffer.remaining()); + assertEquals(300, buffer.capacity()); + + buffer.forceCapacity(300, 0); // does no reallocation but moves limit to end + assertEquals(300, buffer.remaining()); // N.B. == 200 - pos (0); + + buffer.ensureCapacity(400); + assertThat(buffer.capacity(), Matchers.greaterThan(400)); + + buffer.putByteArray(new byte[100], 100); + // N.B. int (4 bytes) for array size, n*4 Bytes for actual array + final long sizeArray = (FastByteBuffer.SIZE_OF_INT + 100 * FastByteBuffer.SIZE_OF_BYTE); + assertEquals(104, sizeArray); + assertEquals(sizeArray, buffer.position()); + + assertThat(buffer.capacity(), Matchers.greaterThan(400)); + buffer.trim(); + assertEquals(buffer.capacity(), buffer.position()); + + buffer.ensureCapacity(500); + buffer.trim(333); + assertEquals(333, buffer.capacity()); + + buffer.position(0); + assertEquals(0, buffer.position()); + + buffer.trim(); + assertFalse(buffer.hasRemaining()); + buffer.ensureAdditionalCapacity(100); + assertTrue(buffer.hasRemaining()); + assertThat(buffer.capacity(), Matchers.greaterThan(1124)); + + buffer.limit(50); + buffer.clear(); + assertEquals(0, buffer.position()); + assertEquals(buffer.limit(), buffer.capacity()); + + // test resize related getters and setters + buffer.setAutoResize(false); + assertFalse(buffer.isAutoResize()); + buffer.setByteArrayCache(null); + assertNull(buffer.getByteArrayCache()); + } + + @Test + void testFastByteBufferOutOfBounds() { + final FastByteBuffer buffer = FastByteBuffer.wrap(new byte[50]); + // test single getters + buffer.position(47); + assertThrows(IndexOutOfBoundsException.class, buffer::getInt); + assertThrows(IndexOutOfBoundsException.class, () -> buffer.getInt(47)); + assertThrows(IndexOutOfBoundsException.class, buffer::getLong); + assertThrows(IndexOutOfBoundsException.class, () -> buffer.getLong(44)); + assertThrows(IndexOutOfBoundsException.class, buffer::getDouble); + assertThrows(IndexOutOfBoundsException.class, () -> buffer.getDouble(47)); + assertThrows(IndexOutOfBoundsException.class, buffer::getFloat); + assertThrows(IndexOutOfBoundsException.class, () -> buffer.getFloat(47)); + buffer.position(49); + assertThrows(IndexOutOfBoundsException.class, buffer::getShort); + assertThrows(IndexOutOfBoundsException.class, () -> buffer.getShort(49)); + buffer.position(50); + assertThrows(IndexOutOfBoundsException.class, buffer::getBoolean); + assertThrows(IndexOutOfBoundsException.class, () -> buffer.getBoolean(50)); + assertThrows(IndexOutOfBoundsException.class, buffer::getByte); + assertThrows(IndexOutOfBoundsException.class, () -> buffer.getByte(50)); + // test array getters + // INT + buffer.reset(); + assertThrows(IndexOutOfBoundsException.class, () -> buffer.putIntArray(new int[50], 50)); + assertEquals(0, buffer.position()); + buffer.putIntArray(new int[5], 5); + buffer.flip(); + buffer.limit(FastByteBuffer.SIZE_OF_INT * 4); + assertThrows(IndexOutOfBoundsException.class, () -> buffer.getIntArray(null, 5)); + // LONG + buffer.reset(); + assertThrows(IndexOutOfBoundsException.class, () -> buffer.putLongArray(new long[50], 50)); + assertEquals(0, buffer.position()); + buffer.putLongArray(new long[5], 5); + buffer.flip(); + buffer.limit(FastByteBuffer.SIZE_OF_LONG * 4); + assertThrows(IndexOutOfBoundsException.class, () -> buffer.getLongArray(null, 5)); + // SHORT + buffer.reset(); + assertThrows(IndexOutOfBoundsException.class, () -> buffer.putShortArray(new short[50], 50)); + assertEquals(0, buffer.position()); + buffer.putShortArray(new short[5], 5); + buffer.flip(); + buffer.limit(FastByteBuffer.SIZE_OF_SHORT * 4); + assertThrows(IndexOutOfBoundsException.class, () -> buffer.getShortArray(null, 5)); + // CHAR + buffer.reset(); + assertThrows(IndexOutOfBoundsException.class, () -> buffer.putCharArray(new char[50], 50)); + assertEquals(0, buffer.position()); + buffer.putCharArray(new char[5], 5); + buffer.flip(); + buffer.limit(FastByteBuffer.SIZE_OF_CHAR * 4); + assertThrows(IndexOutOfBoundsException.class, () -> buffer.getCharArray(null, 5)); + // BOOLEAN + buffer.reset(); + assertThrows(IndexOutOfBoundsException.class, () -> buffer.putBooleanArray(new boolean[51], 51)); + assertEquals(0, buffer.position()); + buffer.putBooleanArray(new boolean[5], 5); + buffer.flip(); + buffer.limit(FastByteBuffer.SIZE_OF_BOOLEAN * 4); + assertThrows(IndexOutOfBoundsException.class, () -> buffer.getBooleanArray(null, 5)); + // BYTE + buffer.reset(); + assertThrows(IndexOutOfBoundsException.class, () -> buffer.putByteArray(new byte[51], 51)); + assertEquals(0, buffer.position()); + buffer.putByteArray(new byte[5], 5); + buffer.flip(); + buffer.limit(FastByteBuffer.SIZE_OF_BYTE * 4); + assertThrows(IndexOutOfBoundsException.class, () -> buffer.getByteArray(null, 5)); + // DOUBLE + buffer.reset(); + assertThrows(IndexOutOfBoundsException.class, () -> buffer.putDoubleArray(new double[50], 50)); + assertEquals(0, buffer.position()); + buffer.putDoubleArray(new double[5], 5); + buffer.flip(); + buffer.limit(FastByteBuffer.SIZE_OF_DOUBLE * 4); + assertThrows(IndexOutOfBoundsException.class, () -> buffer.getDoubleArray(null, 5)); + // FLOAT + buffer.reset(); + assertThrows(IndexOutOfBoundsException.class, () -> buffer.putFloatArray(new float[50], 50)); + assertEquals(0, buffer.position()); + buffer.putFloatArray(new float[5], 5); + buffer.flip(); + buffer.limit(FastByteBuffer.SIZE_OF_FLOAT * 4); + assertThrows(IndexOutOfBoundsException.class, () -> buffer.getFloatArray(null, 5)); + // STRING + buffer.reset(); + assertThrows(IndexOutOfBoundsException.class, () -> buffer.putStringArray(new String[50], 50)); + assertEquals(0, buffer.position()); + buffer.putStringArray(new String[5], 5); + buffer.flip(); + buffer.limit(10); + assertThrows(IndexOutOfBoundsException.class, () -> buffer.getStringArray(null, 4)); + } +} diff --git a/serialiser/src/test/java/io/opencmw/serialiser/spi/JsonSerialiserTests.java b/serialiser/src/test/java/io/opencmw/serialiser/spi/JsonSerialiserTests.java new file mode 100644 index 00000000..443fe9bc --- /dev/null +++ b/serialiser/src/test/java/io/opencmw/serialiser/spi/JsonSerialiserTests.java @@ -0,0 +1,305 @@ +package io.opencmw.serialiser.spi; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +import java.lang.reflect.InvocationTargetException; +import java.util.*; + +import org.hamcrest.Matchers; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.opencmw.serialiser.DataType; +import io.opencmw.serialiser.IoBuffer; + +/** + * + * @author rstein + */ +@SuppressWarnings("PMD.ExcessiveMethodLength") +class JsonSerialiserTests { + private static final Logger LOGGER = LoggerFactory.getLogger(JsonSerialiser.class); + private static final int BUFFER_SIZE = 2000; + + @DisplayName("basic tests") + @ParameterizedTest(name = "IoBuffer class - {0}") + @ValueSource(classes = { ByteBuffer.class, FastByteBuffer.class }) + void testHeaderAndSpecialItems(final Class bufferClass) throws InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException, NoSuchMethodException, SecurityException { + assertNotNull(bufferClass, "bufferClass being not null"); + assertNotNull(bufferClass.getConstructor(int.class), "Constructor(Integer) present"); + final IoBuffer buffer = bufferClass.getConstructor(int.class).newInstance(BUFFER_SIZE); + final JsonSerialiser ioSerialiser = new JsonSerialiser(buffer); + + // add header info + ioSerialiser.putHeaderInfo(); + // add start marker + final String dataStartMarkerName = "StartMarker"; + final WireDataFieldDescription dataStartMarker = new WireDataFieldDescription(ioSerialiser, null, dataStartMarkerName.hashCode(), dataStartMarkerName, DataType.START_MARKER, -1, -1, -1); + ioSerialiser.putStartMarker(dataStartMarker); + // add Collection - List + final List list = Arrays.asList(1, 2, 3); + ioSerialiser.put("collection", list, Integer.class); + // add Collection - Set + final Set set = Set.of(1, 2, 3); + ioSerialiser.put("set", set, Integer.class); + // add Collection - Queue + final Queue queue = new LinkedList<>(Arrays.asList(1, 2, 3)); + ioSerialiser.put("queue", queue, Integer.class); + // add Map + final Map map = new HashMap<>(); + list.forEach(item -> map.put(item, "Item#" + item.toString())); + ioSerialiser.put("map", map, Integer.class, String.class); + // add Enum + ioSerialiser.put("enum", DataType.ENUM); + // add end marker + final String dataEndMarkerName = "EndMarker"; + final WireDataFieldDescription dataEndMarker = new WireDataFieldDescription(ioSerialiser, null, dataEndMarkerName.hashCode(), dataEndMarkerName, DataType.START_MARKER, -1, -1, -1); + ioSerialiser.putEndMarker(dataEndMarker); // end start marker + ioSerialiser.putEndMarker(dataEndMarker); // end header info + + buffer.flip(); + + final String result = new String(Arrays.copyOfRange(buffer.elements(), 0, buffer.limit())); + LOGGER.atDebug().addArgument(result).log("serialised:\n{}"); + + final Iterator lines = result.lines().iterator(); + assertEquals("{", lines.next()); + assertEquals(" \"StartMarker\": {", lines.next()); + assertEquals(" \"collection\": [1, 2, 3],", lines.next()); + assertTrue(lines.next().matches(" {4}\"set\": \\[[123], [123], [123]],")); + assertEquals(" \"queue\": [1, 2, 3],", lines.next()); + assertEquals(" \"map\": {\"1\": \"Item#1\", \"2\": \"Item#2\", \"3\": \"Item#3\"},", lines.next()); + assertEquals(" \"enum\": \"ENUM\"", lines.next()); + assertEquals(" }", lines.next()); + assertEquals("", lines.next()); + assertEquals("}", lines.next()); + assertFalse(lines.hasNext()); + // check types + assertEquals(0, buffer.position(), "initial buffer position"); + + // header info + ProtocolInfo headerInfo = ioSerialiser.checkHeaderInfo(); + assertNotEquals(headerInfo, new Object()); // silly comparison for coverage reasons + assertNotNull(headerInfo); + assertEquals(JsonSerialiser.class.getCanonicalName(), headerInfo.getProducerName()); + assertEquals(1, headerInfo.getVersionMajor()); + assertEquals(0, headerInfo.getVersionMinor()); + assertEquals(0, headerInfo.getVersionMicro()); + } + + @DisplayName("basic primitive array writer tests") + @ParameterizedTest(name = "IoBuffer class - {0}") + @ValueSource(classes = { ByteBuffer.class, FastByteBuffer.class }) + void testParseIoStream(final Class bufferClass) throws InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException, NoSuchMethodException, SecurityException { //NOSONAR NOPMD + assertNotNull(bufferClass, "bufferClass being not null"); + assertNotNull(bufferClass.getConstructor(int.class), "Constructor(Integer) present"); + final IoBuffer buffer = bufferClass.getConstructor(int.class).newInstance(2 * BUFFER_SIZE); // a bit larger buffer since we test more cases at once + final JsonSerialiser ioSerialiser = new JsonSerialiser(buffer); + + ioSerialiser.putHeaderInfo(); // add header info + + // add some primitives + ioSerialiser.put("boolean", true); + ioSerialiser.put("byte", (byte) 42); + ioSerialiser.put("char", (char) 40d); + ioSerialiser.put("short", (short) 42); + ioSerialiser.put("int", 42); + ioSerialiser.put("long", 42L); + ioSerialiser.put("float", 42f); + ioSerialiser.put("double", 42); + ioSerialiser.put("string", "string"); + + ioSerialiser.put("boolean[]", new boolean[] { true }, 1); + ioSerialiser.put("byte[]", new byte[] { (byte) 42 }, 1); + ioSerialiser.put("char[]", new char[] { (char) 40 }, 1); + ioSerialiser.put("short[]", new short[] { (short) 42 }, 1); + ioSerialiser.put("int[]", new int[] { 42 }, 1); + ioSerialiser.put("long[]", new long[] { 42L }, 1); + ioSerialiser.put("float[]", new float[] { (float) 42 }, 1); + ioSerialiser.put("double[]", new double[] { (double) 42 }, 1); + ioSerialiser.put("string[]", new String[] { "string" }, 1); + + final Collection collection = Arrays.asList(1, 2, 3); + ioSerialiser.put("collection", collection, Integer.class); // add Collection - List + + final List list = Arrays.asList(1, 2, 3); + ioSerialiser.put("list", list, Integer.class); // add Collection - List + + final Set set = Set.of(1, 2, 3); + ioSerialiser.put("set", set, Integer.class); // add Collection - Set + + final Queue queue = new LinkedList<>(Arrays.asList(1, 2, 3)); + ioSerialiser.put("queue", queue, Integer.class); // add Collection - Queue + + final Map map = new HashMap<>(); + list.forEach(item -> map.put(item, "Item#" + item.toString())); + ioSerialiser.put("map", map, Integer.class, String.class); // add Map + + // ioSerialiser.put("enum", DataType.ENUM); // enums cannot be read back in, because there is not type information + + // start nested data + final String nestedContextName = "nested context"; + final WireDataFieldDescription nestedContextMarker = new WireDataFieldDescription(ioSerialiser, null, nestedContextName.hashCode(), nestedContextName, DataType.START_MARKER, -1, -1, -1); + ioSerialiser.putStartMarker(nestedContextMarker); // add start marker + ioSerialiser.put("booleanArray", new boolean[] { true }, 1); + ioSerialiser.put("byteArray", new byte[] { (byte) 0x42 }, 1); + + ioSerialiser.putEndMarker(nestedContextMarker); // add end marker + // end nested data + + final String dataEndMarkerName = "Life is good!"; + final WireDataFieldDescription dataEndMarker = new WireDataFieldDescription(ioSerialiser, null, dataEndMarkerName.hashCode(), dataEndMarkerName, DataType.START_MARKER, -1, -1, -1); + ioSerialiser.putEndMarker(dataEndMarker); // add end marker + + buffer.flip(); + + final String result = new String(Arrays.copyOfRange(buffer.elements(), 0, buffer.limit())); + LOGGER.atDebug().addArgument(result).log("serialised:\n{}"); + + final Iterator lines = result.lines().iterator(); + assertEquals("{", lines.next()); + assertEquals(" \"boolean\": true,", lines.next()); + assertEquals(" \"byte\": 42,", lines.next()); + assertEquals(" \"char\": 40,", lines.next()); + assertEquals(" \"short\": 42,", lines.next()); + assertEquals(" \"int\": 42,", lines.next()); + assertEquals(" \"long\": 42,", lines.next()); + assertEquals(" \"float\": 42.0,", lines.next()); + assertEquals(" \"double\": 42,", lines.next()); + assertEquals(" \"string\": \"string\",", lines.next()); + assertEquals(" \"boolean[]\": [true],", lines.next()); + assertEquals(" \"byte[]\": [42],", lines.next()); + assertEquals(" \"char[]\": [40],", lines.next()); + assertEquals(" \"short[]\": [42],", lines.next()); + assertEquals(" \"int[]\": [42],", lines.next()); + assertEquals(" \"long[]\": [42],", lines.next()); + assertEquals(" \"float[]\": [42.0],", lines.next()); + assertEquals(" \"double[]\": [42.0],", lines.next()); + assertEquals(" \"string[]\": [\"string\"],", lines.next()); + assertEquals(" \"collection\": [1, 2, 3],", lines.next()); + assertEquals(" \"list\": [1, 2, 3],", lines.next()); + assertTrue(lines.next().matches(" {2}\"set\": \\[[123], [123], [123]],")); + assertEquals(" \"queue\": [1, 2, 3],", lines.next()); + assertEquals(" \"map\": {\"1\": \"Item#1\", \"2\": \"Item#2\", \"3\": \"Item#3\"},", lines.next()); + // assertEquals(" \"enum\": ENUM,", lines.next()); + assertEquals(" \"nested context\": {", lines.next()); + assertEquals(" \"booleanArray\": [true],", lines.next()); + assertEquals(" \"byteArray\": [66]", lines.next()); + assertEquals(" }", lines.next()); + assertEquals("", lines.next()); + assertEquals("}", lines.next()); + assertFalse(lines.hasNext()); + + // and read back streamed items. Note that types get widened, arrays -> list etc due to type info lost + final WireDataFieldDescription objectRoot = ioSerialiser.parseIoStream(true); + assertNotNull(objectRoot); + objectRoot.printFieldStructure(); + } + @Test + @DisplayName("Simple Object in List") + void testObjectAlongPrimitives() { + final JsonSerialiser ioSerialiser = new JsonSerialiser(new FastByteBuffer(1000, true, null)); + + ioSerialiser.putHeaderInfo(); + + final SimpleClass simpleObj = new SimpleClass(); + simpleObj.setValues(); + final SimpleClass simpleObj2 = new SimpleClass(); + simpleObj2.foo = "baz"; + simpleObj2.integer = 42; + simpleObj2.switches = Collections.emptyList(); + ioSerialiser.put("SimpleObjects", List.of(simpleObj, simpleObj2), SimpleClass.class); + + ioSerialiser.putEndMarker(new WireDataFieldDescription(ioSerialiser, null, "end marker".hashCode(), "end marker", DataType.END_MARKER, -1, -1, -1)); + + ioSerialiser.getBuffer().flip(); + + final String result = new String(Arrays.copyOfRange(ioSerialiser.getBuffer().elements(), 0, ioSerialiser.getBuffer().limit())); + LOGGER.atDebug().addArgument(result).log("serialised:\n{}"); + + final Iterator lines = result.lines().iterator(); + assertEquals("{", lines.next()); + assertEquals(" \"SimpleObjects\": [{\"integer\":1337,\"foo\":\"bar\",\"switches\":[true,true,false]}, {\"integer\":42,\"foo\":\"baz\",\"switches\":[]}]", lines.next()); + assertEquals("}", lines.next()); + assertFalse(lines.hasNext()); + + // ensures that it is valid json + final WireDataFieldDescription header = ioSerialiser.parseIoStream(true); + + // reading back is not possible, because we cannot specify the type of the list and get a list of maps + ioSerialiser.setQueryFieldName("SimpleObjects", -1); + final List> recovered = ioSerialiser.getList(new ArrayList<>()); + assertThat(recovered, Matchers.contains(Matchers.aMapWithSize(3), Matchers.aMapWithSize(3))); + } + + @Test + @DisplayName("Simple object (de)serialisation") + void testSimpleObjectSerDe() { + final SimpleClass toSerialise = new SimpleClass(); + toSerialise.setValues(); + + final JsonSerialiser ioSerialiser = new JsonSerialiser(new FastByteBuffer(1000, true, null)); + + ioSerialiser.serialiseObject(toSerialise); + + ioSerialiser.getBuffer().flip(); + + final String result = new String(Arrays.copyOfRange(ioSerialiser.getBuffer().elements(), 0, ioSerialiser.getBuffer().limit())); + LOGGER.atDebug().addArgument(result).log("serialised:\n{}"); + + assertEquals("{\"integer\":1337,\"foo\":\"bar\",\"switches\":[true,true,false]}", result); + + final SimpleClass deserialized = ioSerialiser.deserialiseObject(new SimpleClass()); + assertEquals(toSerialise, deserialized); + } + + @Test + @DisplayName("Null object (de)serialisation") + void testNullObjectSerDe() { + final JsonSerialiser ioSerialiser = new JsonSerialiser(new FastByteBuffer(1000, true, null)); + + ioSerialiser.serialiseObject(null); + + ioSerialiser.getBuffer().flip(); + + final String result = new String(Arrays.copyOfRange(ioSerialiser.getBuffer().elements(), 0, ioSerialiser.getBuffer().limit())); + LOGGER.atDebug().addArgument(result).log("serialised:\n{}"); + + assertEquals("null", result); + + final SimpleClass deserialized = ioSerialiser.deserialiseObject(new SimpleClass()); + assertNull(deserialized); + } + + public static class SimpleClass { + public int integer = -1; + public String foo = null; + public List switches = null; + + public void setValues() { + integer = 1337; + foo = "bar"; + switches = List.of(true, true, false); + } + + @Override + public boolean equals(final Object o) { + if (this == o) + return true; + if (!(o instanceof SimpleClass)) + return false; + final SimpleClass that = (SimpleClass) o; + return integer == that.integer && Objects.equals(foo, that.foo) && Objects.equals(switches, that.switches); + } + + @Override + public int hashCode() { + return Objects.hash(integer, foo, switches); + } + } +} \ No newline at end of file diff --git a/serialiser/src/test/java/io/opencmw/serialiser/spi/helper/MyGenericClass.java b/serialiser/src/test/java/io/opencmw/serialiser/spi/helper/MyGenericClass.java new file mode 100644 index 00000000..337f3889 --- /dev/null +++ b/serialiser/src/test/java/io/opencmw/serialiser/spi/helper/MyGenericClass.java @@ -0,0 +1,547 @@ +package io.opencmw.serialiser.spi.helper; + +import java.util.ArrayList; +import java.util.Arrays; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.spi.LoggingEventBuilder; + +import io.opencmw.serialiser.spi.BinarySerialiser; + +/** + * Generic test class to verify serialiser forward/backward identities. + * + * @author rstein + * @see BinarySerialiser + */ +@SuppressWarnings("ALL") +public class MyGenericClass { + private static final Logger LOGGER = LoggerFactory.getLogger(MyGenericClass.class); + private static boolean verboseLogging = false; + private static boolean extendedTestCase = true; + private static final String MODIFIED = "Modified"; + // supported data types + protected boolean dummyBoolean; + protected byte dummyByte; + protected short dummyShort; + protected int dummyInt; + protected long dummyLong; + protected float dummyFloat; + protected double dummyDouble; + protected String dummyString = "Test"; + + protected TestEnum enumState = TestEnum.TEST_CASE_1; + + protected ArrayList arrayListInteger = new ArrayList<>(); + protected ArrayList arrayListString = new ArrayList<>(); + + protected BoxedPrimitivesSubClass boxedPrimitivesNull; + public BoxedPrimitivesSubClass boxedPrimitives = new BoxedPrimitivesSubClass(); + + public ArraySubClass arrays = new ArraySubClass(); + public BoxedObjectArraySubClass objArrays = new BoxedObjectArraySubClass(); + + public MyGenericClass() { + arrayListInteger.add(1); + arrayListInteger.add(2); + arrayListInteger.add(3); + arrayListString.add("String#1"); + arrayListString.add("String#2"); + arrayListString.add("String#3"); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof MyGenericClass)) { + return false; + } + // normally this value is immediately returned for 'false', + // here: state is latched to detect potential other violations + boolean state = true; + MyGenericClass other = (MyGenericClass) obj; + if (this.hashCode() != other.hashCode()) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(this.hashCode()).addArgument(other.hashCode()).log("{} - hashCode is not equal: this '{}' vs. other '{}'"); + state = false; + } + if (dummyBoolean != other.dummyBoolean) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(dummyBoolean).addArgument(other.dummyBoolean).log("{} - dummyBoolean is not equal: this '{}' vs. other '{}'"); + state = false; + } + if (dummyByte != other.dummyByte) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(dummyByte).addArgument(other.dummyByte).log("{} - dummyByte is not equal: this '{}' vs. other '{}'"); + state = false; + } + if (dummyShort != other.dummyShort) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(dummyShort).addArgument(other.dummyShort).log("{} - dummyShort is not equal: this '{}' vs. other '{}'"); + state = false; + } + if (dummyInt != other.dummyInt) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(dummyInt).addArgument(other.dummyInt).log("{} - dummyInt is not equal: this '{}' vs. other '{}'"); + state = false; + } + if (dummyLong != other.dummyLong) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(dummyLong).addArgument(other.dummyLong).log("{} - dummyLong is not equal: this '{}' vs. other '{}'"); + state = false; + } + if (dummyFloat != other.dummyFloat) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(dummyFloat).addArgument(other.dummyFloat).log("{} - dummyFloat is not equal: this '{}' vs. other '{}'"); + state = false; + } + if (dummyDouble != other.dummyDouble) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(dummyDouble).addArgument(other.dummyDouble).log("{} - dummyDouble is not equal: this '{}' vs. other '{}'"); + state = false; + } + if (dummyString == null || !dummyString.contentEquals(other.dummyString)) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(dummyString).addArgument(other.dummyString).addArgument(dummyString).addArgument(other.dummyString).log("{} - dummyString is not equal {} vs {}'"); + state = false; + } + + if (!boxedPrimitives.equals(other.boxedPrimitives)) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(boxedPrimitives).addArgument(other.boxedPrimitives).log("{} - boxedPrimitives are not equal: this '{}' vs. other '{}'"); + state = false; + } + + if (!extendedTestCase) { + // abort equals for more complex/extended data structures + return state; + } + + if (!enumState.equals(other.enumState)) { + state = false; + } + + if (!arrayListInteger.equals(other.arrayListInteger)) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(arrayListInteger).addArgument(other.arrayListInteger).log("{} - arrayListInteger is not equal: this '{}' vs. other '{}'"); + state = false; + } + if (!arrayListString.equals(other.arrayListString)) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(arrayListString).addArgument(other.arrayListString).log("{} - arrayListString is not equal: this '{}' vs. other '{}'"); + state = false; + } + if (!arrays.equals(other.arrays)) { + logBackEnd().addArgument(this.getClass().getSimpleName()).log("{} - arrays are not equal"); + state = false; + } + if (!objArrays.equals(other.objArrays)) { + logBackEnd().addArgument(this.getClass().getSimpleName()).log("{} - objArrays is not equal"); + state = false; + } + + return state; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + if (extendedTestCase) { + result = prime * result + ((enumState == null) ? 0 : enumState.hashCode()); + result = prime * result + ((arrayListInteger == null) ? 0 : arrayListInteger.hashCode()); + result = prime * result + ((arrayListString == null) ? 0 : arrayListString.hashCode()); + result = prime * result + ((arrays == null) ? 0 : arrays.hashCode()); + result = prime * result + ((boxedPrimitives == null) ? 0 : boxedPrimitives.hashCode()); + result = prime * result + ((boxedPrimitivesNull == null) ? 0 : boxedPrimitivesNull.hashCode()); + } + result = prime * result + (dummyBoolean ? 1231 : 1237); + result = prime * result + dummyByte; + long temp; + temp = Double.doubleToLongBits(dummyDouble); + result = prime * result + (int) (temp ^ (temp >>> 32)); + result = prime * result + Float.floatToIntBits(dummyFloat); + result = prime * result + dummyInt; + result = prime * result + (int) (dummyLong ^ (dummyLong >>> 32)); + result = prime * result + dummyShort; + result = prime * result + ((dummyString == null) ? 0 : dummyString.hashCode()); + // result = prime * result + ((objArrays == null) ? 0 : objArrays.hashCode()); + return result; + } + + public void modifyValues() { + dummyBoolean = !dummyBoolean; + dummyByte = (byte) (dummyByte + 1); + dummyShort = (short) (dummyShort + 2); + dummyInt = dummyInt + 1; + dummyLong = dummyLong + 1; + dummyFloat = dummyFloat + 0.5f; + dummyDouble = dummyDouble + 1.5; + dummyString = MODIFIED + dummyString; + arrayListInteger.set(0, arrayListInteger.get(0) + 1); + arrayListString.set(0, MODIFIED + arrayListString.get(0)); + enumState = TestEnum.values()[(enumState.ordinal() + 1) % TestEnum.values().length]; + } + + @Override + public String toString() { + return // + "[dummyBoolean=" + dummyBoolean + // + ", dummyByte=" + dummyByte + // + ", dummyShort=" + dummyShort + // + ", dummyInt=" + dummyInt + // + ", dummyFloat=" + dummyFloat + // + ", dummyDouble=" + dummyDouble + // + ", dummyString=" + dummyString + // + ", arrayListInteger=" + arrayListInteger + // + ", arrayListString=" + arrayListString + // + ", hash(boxedPrimitives)=" + boxedPrimitives.hashCode() + // + ", hash(arrays)=" + arrays.hashCode() + // + ", hash(objArrays)=" + objArrays.hashCode() + // + ']'; + } + + public static boolean isExtendedTestCase() { + return extendedTestCase; + } + + public static boolean isVerboseChecks() { + return verboseLogging; + } + + private static LoggingEventBuilder logBackEnd() { + return verboseLogging ? LOGGER.atError() : LOGGER.atTrace(); + } + + public static void setEnableExtendedTestCase(final boolean state) { + extendedTestCase = state; + } + + public static void setVerboseChecks(final boolean state) { + verboseLogging = state; + } + + public static class ArraySubClass { + protected boolean[] dummyBooleanArray = new boolean[2]; + protected byte[] dummyByteArray = new byte[2]; + protected short[] dummyShortArray = new short[2]; + protected char[] dummyCharArray = new char[2]; + protected int[] dummyIntArray = new int[2]; + protected long[] dummyLongArray = new long[2]; + protected float[] dummyFloatArray = new float[2]; + protected double[] dummyDoubleArray = new double[2]; + protected String[] dummyStringArray = { "Test 1", "Test 2" }; + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof ArraySubClass)) { + return false; + } + // normally this value is immediately returned for 'false', + // here: state is latched to detect potential other violations + boolean state = true; + ArraySubClass other = (ArraySubClass) obj; + if (this.hashCode() != other.hashCode()) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(this.hashCode()).addArgument(other.hashCode()).log("{} - hashCode is not equal: this '{}' vs. other '{}'"); + state = false; + } + + if (!Arrays.equals(dummyBooleanArray, other.dummyBooleanArray)) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(dummyBooleanArray).addArgument(other.dummyBooleanArray).log("{} - dummyBooleanArray is not equal: this '{}' vs. other '{}'"); + state = false; + } + if (!Arrays.equals(dummyByteArray, other.dummyByteArray)) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(dummyByteArray).addArgument(other.dummyByteArray).log("{} - dummyByteArray is not equal: this '{}' vs. other '{}'"); + state = false; + } + if (!Arrays.equals(dummyShortArray, other.dummyShortArray)) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(dummyShortArray).addArgument(other.dummyShortArray).log("{} - dummyShortArray is not equal: this '{}' vs. other '{}'"); + state = false; + } + if (!Arrays.equals(dummyCharArray, other.dummyCharArray)) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(dummyCharArray).addArgument(other.dummyCharArray).log("{} - dummyCharArray is not equal: this '{}' vs. other '{}'"); + state = false; + } + if (!Arrays.equals(dummyIntArray, other.dummyIntArray)) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(dummyIntArray).addArgument(other.dummyIntArray).log("{} - dummyIntArray is not equal: this '{}' vs. other '{}'"); + state = false; + } + if (!Arrays.equals(dummyLongArray, other.dummyLongArray)) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(dummyLongArray).addArgument(other.dummyLongArray).log("{} - dummyLongArray is not equal: this '{}' vs. other '{}'"); + state = false; + } + if (!Arrays.equals(dummyFloatArray, other.dummyFloatArray)) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(dummyFloatArray).addArgument(other.dummyFloatArray).log("{} - dummyFloatArray is not equal: this '{}' vs. other '{}'"); + state = false; + } + if (!Arrays.equals(dummyDoubleArray, other.dummyDoubleArray)) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(dummyDoubleArray).addArgument(other.dummyDoubleArray).log("{} - dummyDoubleArray is not equal: this '{}' vs. other '{}'"); + state = false; + } + if (!Arrays.equals(dummyStringArray, other.dummyStringArray)) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(dummyStringArray).addArgument(other.dummyStringArray).log("{} - dummyStringArray is not equal: this '{}' vs. other '{}'"); + state = false; + } + + return state; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + Arrays.hashCode(dummyBooleanArray); + result = prime * result + Arrays.hashCode(dummyByteArray); + result = prime * result + Arrays.hashCode(dummyDoubleArray); + result = prime * result + Arrays.hashCode(dummyFloatArray); + result = prime * result + Arrays.hashCode(dummyIntArray); + result = prime * result + Arrays.hashCode(dummyLongArray); + result = prime * result + Arrays.hashCode(dummyShortArray); + result = prime * result + Arrays.hashCode(dummyCharArray); + result = prime * result + Arrays.hashCode(dummyStringArray); + return result; + } + + public void modifyValues() { + dummyBooleanArray[0] = !dummyBooleanArray[0]; + dummyByteArray[0] = (byte) (dummyByteArray[0] + (byte) 1); + dummyShortArray[0] = (short) (dummyShortArray[0] + (short) 1); + dummyCharArray[0] = (char) (dummyCharArray[0] + (char) 1); + dummyIntArray[0] = dummyIntArray[0] + 1; + dummyLongArray[0] = dummyLongArray[0] + 1L; + dummyFloatArray[0] = dummyFloatArray[0] + 0.5f; + dummyDoubleArray[0] = dummyDoubleArray[0] + 1.5; + dummyStringArray[0] = MODIFIED + dummyStringArray[0]; + } + } + + public static class BoxedObjectArraySubClass { + protected Boolean[] dummyBoxedBooleanArray = { false, false }; + protected Byte[] dummyBoxedByteArray = { 0, 0 }; + protected Short[] dummyBoxedShortArray = { 0, 0 }; + protected Character[] dummyBoxedCharArray = { 0, 0 }; + protected Integer[] dummyBoxedIntArray = { 0, 0 }; + protected Long[] dummyBoxedLongArray = { 0L, 0L }; + protected Float[] dummyBoxedFloatArray = { 0.0f, 0.0f }; + protected Double[] dummyBoxedDoubleArray = { 0.0, 0.0 }; + protected String[] dummyBoxedStringArray = { "TestString#2", "TestString#2" }; + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof BoxedObjectArraySubClass)) { + return false; + } + // normally this value is immediately returned for 'false', + // here: state is latched to detect potential other violations + boolean state = true; + BoxedObjectArraySubClass other = (BoxedObjectArraySubClass) obj; + if (this.hashCode() != other.hashCode()) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(this.hashCode()).addArgument(other.hashCode()).log("{} - hashCode is not equal: this '{}' vs. other '{}'"); + state = false; + } + if (!Arrays.equals(dummyBoxedBooleanArray, other.dummyBoxedBooleanArray)) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(dummyBoxedBooleanArray).addArgument(other.dummyBoxedBooleanArray).log("{} - dummyBoxedBooleanArray is not equal: this '{}' vs. other '{}'"); + state = false; + } + if (!Arrays.equals(dummyBoxedByteArray, other.dummyBoxedByteArray)) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(dummyBoxedByteArray).addArgument(other.dummyBoxedByteArray).log("{} - dummyBoxedByteArray is not equal: this '{}' vs. other '{}'"); + state = false; + } + if (!Arrays.equals(dummyBoxedDoubleArray, other.dummyBoxedDoubleArray)) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(dummyBoxedDoubleArray).addArgument(other.dummyBoxedDoubleArray).log("{} - dummyBoxedDoubleArray is not equal: this '{}' vs. other '{}'"); + state = false; + } + if (!Arrays.equals(dummyBoxedFloatArray, other.dummyBoxedFloatArray)) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(dummyBoxedFloatArray).addArgument(other.dummyBoxedFloatArray).log("{} - dummyBoxedFloatArray is not equal: this '{}' vs. other '{}'"); + state = false; + } + if (!Arrays.equals(dummyBoxedIntArray, other.dummyBoxedIntArray)) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(dummyBoxedIntArray).addArgument(other.dummyBoxedIntArray).log("{} - dummyBoxedIntArray is not equal: this '{}' vs. other '{}'"); + state = false; + } + if (!Arrays.equals(dummyBoxedLongArray, other.dummyBoxedLongArray)) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(dummyBoxedLongArray).addArgument(other.dummyBoxedLongArray).log("{} - dummyBoxedLongArray is not equal: this '{}' vs. other '{}'"); + state = false; + } + if (!Arrays.equals(dummyBoxedShortArray, other.dummyBoxedShortArray)) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(dummyBoxedShortArray).addArgument(other.dummyBoxedShortArray).log("{} - dummyBoxedShortArray is not equal: this '{}' vs. other '{}'"); + state = false; + } + if (!Arrays.equals(dummyBoxedCharArray, other.dummyBoxedCharArray)) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(dummyBoxedCharArray).addArgument(other.dummyBoxedCharArray).log("{} - dummyBoxedCharArray is not equal: this '{}' vs. other '{}'"); + state = false; + } + if (!Arrays.equals(dummyBoxedStringArray, other.dummyBoxedStringArray)) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(dummyBoxedStringArray).addArgument(other.dummyBoxedStringArray).log("{} - dummyBoxedStringArray is not equal: this '{}' vs. other '{}'"); + state = false; + } + + return state; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + Arrays.hashCode(dummyBoxedBooleanArray); + result = prime * result + Arrays.hashCode(dummyBoxedByteArray); + result = prime * result + Arrays.hashCode(dummyBoxedDoubleArray); + result = prime * result + Arrays.hashCode(dummyBoxedFloatArray); + result = prime * result + Arrays.hashCode(dummyBoxedIntArray); + result = prime * result + Arrays.hashCode(dummyBoxedLongArray); + result = prime * result + Arrays.hashCode(dummyBoxedShortArray); + result = prime * result + Arrays.hashCode(dummyBoxedStringArray); + return result; + } + + public void modifyValues() { + dummyBoxedBooleanArray[0] = !dummyBoxedBooleanArray[0]; + dummyBoxedByteArray[0] = (byte) (dummyBoxedByteArray[0] + (byte) 1); + dummyBoxedShortArray[0] = (short) (dummyBoxedShortArray[0] + (short) 1); + dummyBoxedCharArray[0] = (char) (dummyBoxedCharArray[0] + (char) 1); + dummyBoxedIntArray[0] = dummyBoxedIntArray[0] + 1; + dummyBoxedLongArray[0] = dummyBoxedLongArray[0] + 1L; + dummyBoxedFloatArray[0] = dummyBoxedFloatArray[0] + 0.5f; + dummyBoxedDoubleArray[0] = dummyBoxedDoubleArray[0] + 1.5; + dummyBoxedStringArray[0] = MODIFIED + dummyBoxedStringArray[0]; + } + } + + public class BoxedPrimitivesSubClass { + protected Boolean dummyBoxedBoolean = Boolean.FALSE; + protected Byte dummyBoxedByte = (byte) 0; + protected Short dummyBoxedShort = (short) 0; + protected Integer dummyBoxedInt = 0; + protected Long dummyBoxedLong = 0L; + protected Float dummyBoxedFloat = 0f; + protected Double dummyBoxedDouble = 0.0; + protected String dummyBoxedString = "Test"; + + protected BoxedPrimitivesSubSubClass boxedPrimitivesSubSubClass = new BoxedPrimitivesSubSubClass(); + protected BoxedPrimitivesSubSubClass boxedPrimitivesSubSubClassNull; // to check instantiation + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof BoxedPrimitivesSubClass)) { + return false; + } + // normally this value is immediately returned for 'false', + // here: state is latched to detect potential other violations + boolean state = true; + BoxedPrimitivesSubClass other = (BoxedPrimitivesSubClass) obj; + if (this.hashCode() != other.hashCode()) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(this.hashCode()).addArgument(other.hashCode()).log("{} - hashCode is not equal: this '{}' vs. other '{}'"); + state = false; + } + if (!(dummyBoxedBoolean == null && other.dummyBoxedBoolean == null) && (dummyBoxedBoolean == null || other.dummyBoxedBoolean == null || !dummyBoxedBoolean.equals(other.dummyBoxedBoolean))) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(dummyBoxedBoolean).addArgument(other.dummyBoxedBoolean).log("{} - dummyBoxedBoolean is not equal: this '{}' vs. other '{}'"); + state = false; + } + if (!(dummyBoxedByte == null && other.dummyBoxedByte == null) && (dummyBoxedByte == null || other.dummyBoxedByte == null || !dummyBoxedByte.equals(other.dummyBoxedByte))) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(dummyBoxedByte).addArgument(other.dummyBoxedByte).log("{} - dummyBoxedByte is not equal: this '{}' vs. other '{}'"); + state = false; + } + if (!(dummyBoxedShort == null && other.dummyBoxedShort == null) && (dummyBoxedShort == null || other.dummyBoxedShort == null || !dummyBoxedShort.equals(other.dummyBoxedShort))) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(dummyBoxedShort).addArgument(other.dummyBoxedShort).log("{} - dummyBoxedShort is not equal: this '{}' vs. other '{}'"); + state = false; + } + if (!(dummyBoxedInt == null && other.dummyBoxedInt == null) && (dummyBoxedInt == null || other.dummyBoxedInt == null || !dummyBoxedInt.equals(other.dummyBoxedInt))) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(dummyBoxedInt).addArgument(other.dummyBoxedInt).log("{} - dummyBoxedInt is not equal: this '{}' vs. other '{}'"); + state = false; + } + if (!(dummyBoxedLong == null && other.dummyBoxedLong == null) && (dummyBoxedLong == null || other.dummyBoxedLong == null || !dummyBoxedLong.equals(other.dummyBoxedLong))) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(dummyBoxedLong).addArgument(other.dummyBoxedLong).log("{} - dummyBoxedLong is not equal: this '{}' vs. other '{}'"); + state = false; + } + if (!(dummyBoxedFloat == null && other.dummyBoxedFloat == null) && (dummyBoxedFloat == null || other.dummyBoxedFloat == null || !dummyBoxedFloat.equals(other.dummyBoxedFloat))) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(dummyBoxedFloat).addArgument(other.dummyBoxedFloat).log("{} - dummyBoxedFloat is not equal: this '{}' vs. other '{}'"); + state = false; + } + if (!(dummyBoxedDouble == null && other.dummyBoxedDouble == null) && (dummyBoxedDouble == null || other.dummyBoxedDouble == null || !dummyBoxedDouble.equals(other.dummyBoxedDouble))) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(dummyBoxedDouble).addArgument(other.dummyBoxedDouble).log("{} - dummyBoxedDouble is not equal: this '{}' vs. other '{}'"); + state = false; + } + if (!(dummyBoxedString == null && other.dummyBoxedString == null) && (dummyBoxedString == null || other.dummyBoxedString == null || !dummyBoxedString.equals(other.dummyBoxedString))) { + logBackEnd().addArgument(this.getClass().getSimpleName()).addArgument(dummyBoxedString).addArgument(other.dummyBoxedString).log("{} - dummyBoxedString is not equal: this '{}' vs. other '{}'"); + state = false; + } + + return state; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((dummyBoxedBoolean == null) ? 0 : dummyBoxedBoolean.hashCode()); + result = prime * result + ((dummyBoxedByte == null) ? 0 : dummyBoxedByte.hashCode()); + result = prime * result + ((dummyBoxedDouble == null) ? 0 : dummyBoxedDouble.hashCode()); + result = prime * result + ((dummyBoxedFloat == null) ? 0 : dummyBoxedFloat.hashCode()); + result = prime * result + ((dummyBoxedInt == null) ? 0 : dummyBoxedInt.hashCode()); + result = prime * result + ((dummyBoxedLong == null) ? 0 : dummyBoxedLong.hashCode()); + result = prime * result + ((dummyBoxedShort == null) ? 0 : dummyBoxedShort.hashCode()); + result = prime * result + ((dummyBoxedString == null) ? 0 : dummyBoxedString.hashCode()); + return result; + } + + public void modifyValues() { + dummyBoxedBoolean = !dummyBoxedBoolean; + dummyBoxedByte = (byte) (dummyBoxedByte + 1); + dummyBoxedShort = (short) (dummyBoxedShort + 2); + dummyBoxedInt = dummyBoxedInt + 1; + dummyBoxedLong = dummyBoxedLong + 1; + dummyBoxedFloat = dummyBoxedFloat + 0.5f; + dummyBoxedDouble = dummyBoxedDouble + 1.5; + dummyBoxedString = MODIFIED + dummyBoxedString; + arrayListInteger.set(0, arrayListInteger.get(0) + 1); + arrayListString.set(0, MODIFIED + arrayListString.get(0)); + } + + @Override + public String toString() { + return // + "[dummyBoxedBoolean=" + dummyBoxedBoolean + // + ", dummyBoxedByte=" + dummyBoxedByte + // + ", dummyBoxedShort=" + dummyBoxedShort + // + ", dummyBoxedInt=" + dummyBoxedInt + // + ", dummyBoxedFloat=" + dummyBoxedFloat + // + ", dummyBoxedDouble=" + dummyBoxedDouble + // + ", dummyBoxedString=" + dummyBoxedString + // + ']'; + } + + @SuppressWarnings("hiding") + public class BoxedPrimitivesSubSubClass { + protected Boolean dummyBoxedBooleanL2 = true; + protected Byte dummyBoxedByteL2 = (byte) 0; + protected Short dummyBoxedShortL2 = (short) 0; + protected Integer dummyBoxedIntL2 = 0; + protected Long dummyBoxedLongL2 = 0L; + protected Float dummyBoxedFloatL2 = 0f; + protected Double dummyBoxedDoubleL2 = 0.0; + protected String dummyBoxedStringL2 = "Test"; + + public BoxedPrimitivesSubSubSubClass boxedPrimitivesSubSubSubClass = new BoxedPrimitivesSubSubSubClass(); + public BoxedPrimitivesSubSubSubClass boxedPrimitivesSubSubSubClassNull; // to check instantiation + + @SuppressWarnings("hiding") + public class BoxedPrimitivesSubSubSubClass { + protected Boolean dummyBoxedBooleanL3 = false; + protected Byte dummyBoxedByteL3 = (byte) 0; + protected Short dummyBoxedShortL3 = (short) 0; + protected Integer dummyBoxedIntL3 = 0; + protected Long dummyBoxedLongL3 = 0L; + protected Float dummyBoxedFloatL3 = 0f; + protected Double dummyBoxedDoubleL3 = 0.0; + protected String dummyBoxedStringL3 = "Test"; + } + } + } + + public enum TestEnum { + TEST_CASE_1, + TEST_CASE_2, + TEST_CASE_3, + TEST_CASE_4 + } +} diff --git a/serialiser/src/test/java/io/opencmw/serialiser/spi/iobuffer/DataSetSerialiserTests.java b/serialiser/src/test/java/io/opencmw/serialiser/spi/iobuffer/DataSetSerialiserTests.java new file mode 100644 index 00000000..686f866d --- /dev/null +++ b/serialiser/src/test/java/io/opencmw/serialiser/spi/iobuffer/DataSetSerialiserTests.java @@ -0,0 +1,433 @@ +package io.opencmw.serialiser.spi.iobuffer; + +import static org.junit.jupiter.api.Assertions.*; + +import java.lang.reflect.InvocationTargetException; +import java.util.Arrays; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +import io.opencmw.serialiser.DataType; +import io.opencmw.serialiser.IoBuffer; +import io.opencmw.serialiser.IoClassSerialiser; +import io.opencmw.serialiser.spi.BinarySerialiser; +import io.opencmw.serialiser.spi.ByteBuffer; +import io.opencmw.serialiser.spi.FastByteBuffer; + +import de.gsi.dataset.DataSet; +import de.gsi.dataset.DataSet2D; +import de.gsi.dataset.DataSetError; +import de.gsi.dataset.DataSetMetaData; +import de.gsi.dataset.event.EventListener; +import de.gsi.dataset.spi.AbstractDataSet; +import de.gsi.dataset.spi.DefaultErrorDataSet; +import de.gsi.dataset.spi.DoubleDataSet; +import de.gsi.dataset.spi.DoubleErrorDataSet; +import de.gsi.dataset.spi.DoubleGridDataSet; +import de.gsi.dataset.testdata.spi.TriangleFunction; + +/** + * @author Alexander Krimm + * @author rstein + */ +class DataSetSerialiserTests { + private static final int BUFFER_SIZE = 10000; + private static final String[] DEFAULT_AXES_NAME = { "x", "y", "z" }; + private static final double DELTA = 1e-3; + + @ParameterizedTest(name = "IoBuffer class - {0}") + @ValueSource(classes = { ByteBuffer.class, FastByteBuffer.class }) + void testDataSet(final Class bufferClass) throws InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException, NoSuchMethodException, SecurityException { + assertNotNull(bufferClass, "bufferClass being not null"); + assertNotNull(bufferClass.getConstructor(int.class), "Constructor(Integer) present"); + final IoBuffer buffer = bufferClass.getConstructor(int.class).newInstance(2 * BUFFER_SIZE); + + boolean asFloat32 = false; + final DoubleDataSet original = new DoubleDataSet(new TriangleFunction("test", 1009)); + addMetaData(original, true); + + final DataSetSerialiser ioSerialiser = DataSetSerialiser.withIoSerialiser(new BinarySerialiser(buffer)); + + ioSerialiser.write(original, false); + buffer.reset(); // reset to read position (==0) + final DataSet restored = ioSerialiser.read(); + + assertEquals(original, restored); + } + + @ParameterizedTest(name = "IoBuffer class - {0}, asFloat - {1}") + @MethodSource("buffersAndFloatParameters") + void testGridDataSet(final Class bufferClass, final boolean asFloat32) throws InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException, NoSuchMethodException, SecurityException { + assertNotNull(bufferClass, "bufferClass being not null"); + assertNotNull(bufferClass.getConstructor(int.class), "Constructor(Integer) present"); + final IoBuffer buffer = bufferClass.getConstructor(int.class).newInstance(2 * BUFFER_SIZE); + + final DoubleGridDataSet original = asFloat32 ? new DoubleGridDataSet("test", false, + new double[][] { { 1f, 2f }, { 0.1f, 0.2f, 0.3f } }, new double[] { 9.9f, 8.8f, 7.7f, 6.6f, 5.5f, 4.4f }) + : new DoubleGridDataSet("test", false, + new double[][] { { 1.0, 2.0 }, { 0.1, 0.2, 0.3 } }, new double[] { 9.9, 8.8, 7.7, 6.6, 5.5, 4.4 }); + + final DataSetSerialiser ioSerialiser = DataSetSerialiser.withIoSerialiser(new BinarySerialiser(buffer)); + + ioSerialiser.write(original, asFloat32); + buffer.reset(); // reset to read position (==0) + final DataSet restored = ioSerialiser.read(); + + assertEquals(original, restored); + } + static public Stream buffersAndFloatParameters() { + return Stream.of( + Arguments.arguments(ByteBuffer.class, true), + Arguments.arguments(ByteBuffer.class, false), + Arguments.arguments(FastByteBuffer.class, true), + Arguments.arguments(FastByteBuffer.class, false)); + } + + @ParameterizedTest(name = "IoBuffer class - {0}") + @ValueSource(classes = { ByteBuffer.class, FastByteBuffer.class }) + void testDataSetErrorSymmetric(final Class bufferClass) throws InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException, NoSuchMethodException, SecurityException { + assertNotNull(bufferClass, "bufferClass being not null"); + assertNotNull(bufferClass.getConstructor(int.class), "Constructor(Integer) present"); + final IoBuffer buffer = bufferClass.getConstructor(int.class).newInstance(BUFFER_SIZE); + boolean asFloat32 = false; + + final DefaultErrorDataSet original = new DefaultErrorDataSet("test", new double[] { 1, 2, 3 }, + new double[] { 6, 7, 8 }, new double[] { 7, 8, 9 }, new double[] { 7, 8, 9 }, 3, false) { + private static final long serialVersionUID = 1L; + + @Override + public ErrorType getErrorType(int dimIndex) { + if (dimIndex == 1) { + return ErrorType.SYMMETRIC; + } + return super.getErrorType(dimIndex); + } + }; + addMetaData(original, true); + + final DataSetSerialiser ioSerialiser = DataSetSerialiser.withIoSerialiser(new BinarySerialiser(buffer)); + ioSerialiser.write(original, false); + buffer.reset(); // reset to read position (==0) + final DefaultErrorDataSet restored = (DefaultErrorDataSet) ioSerialiser.read(); + + assertEquals(new DefaultErrorDataSet(original), new DefaultErrorDataSet(restored)); + } + + @DisplayName("test getDoubleArray([boolean[], byte[], ..., String[]) helper method") + @ParameterizedTest(name = "IoBuffer class - {0}") + @ValueSource(classes = { ByteBuffer.class, FastByteBuffer.class }) + void testGetDoubleArrayHelper(final Class bufferClass) throws InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException, NoSuchMethodException, SecurityException { + assertNotNull(bufferClass, "bufferClass being not null"); + assertNotNull(bufferClass.getConstructor(int.class), "Constructor(Integer) present"); + final IoBuffer buffer = bufferClass.getConstructor(int.class).newInstance(2 * BUFFER_SIZE); // a bit larger buffer since we test more cases at once + final BinarySerialiser ioSerialiser = new BinarySerialiser(buffer); + + putGenericTestArrays(ioSerialiser); + + buffer.reset(); + + // test conversion to double array + ioSerialiser.checkHeaderInfo(); + assertThrows(IllegalArgumentException.class, () -> DataSetSerialiser.getDoubleArray(ioSerialiser, null, DataType.OTHER)); + assertArrayEquals(new double[] { 1.0, 0.0, 1.0 }, DataSetSerialiser.getDoubleArray(ioSerialiser, null, DataType.BOOL_ARRAY)); + assertArrayEquals(new double[] { 1.0, 0.0, 2.0 }, DataSetSerialiser.getDoubleArray(ioSerialiser, null, DataType.BYTE_ARRAY)); + assertArrayEquals(new double[] { 1.0, 0.0, 2.0 }, DataSetSerialiser.getDoubleArray(ioSerialiser, null, DataType.CHAR_ARRAY)); + assertArrayEquals(new double[] { 1.0, 0.0, 2.0 }, DataSetSerialiser.getDoubleArray(ioSerialiser, null, DataType.SHORT_ARRAY)); + assertArrayEquals(new double[] { 1.0, 0.0, 2.0 }, DataSetSerialiser.getDoubleArray(ioSerialiser, null, DataType.INT_ARRAY)); + assertArrayEquals(new double[] { 1.0, 0.0, 2.0 }, DataSetSerialiser.getDoubleArray(ioSerialiser, null, DataType.LONG_ARRAY)); + assertArrayEquals(new double[] { 1.0, 0.0, 2.0 }, DataSetSerialiser.getDoubleArray(ioSerialiser, null, DataType.FLOAT_ARRAY)); + assertArrayEquals(new double[] { 1.0, 0.0, 2.0 }, DataSetSerialiser.getDoubleArray(ioSerialiser, null, DataType.DOUBLE_ARRAY)); + assertArrayEquals(new double[] { 1.0, 0.0, 2.0 }, DataSetSerialiser.getDoubleArray(ioSerialiser, null, DataType.STRING_ARRAY)); + } + + private static void putGenericTestArrays(final BinarySerialiser ioSerialiser) { + ioSerialiser.putHeaderInfo(); + ioSerialiser.putGenericArrayAsPrimitive(DataType.BOOL, new Boolean[] { true, false, true }, 3); + ioSerialiser.putGenericArrayAsPrimitive(DataType.BYTE, new Byte[] { (byte) 1, (byte) 0, (byte) 2 }, 3); + ioSerialiser.putGenericArrayAsPrimitive(DataType.CHAR, new Character[] { (char) 1, (char) 0, (char) 2 }, 3); + ioSerialiser.putGenericArrayAsPrimitive(DataType.SHORT, new Short[] { (short) 1, (short) 0, (short) 2 }, 3); + ioSerialiser.putGenericArrayAsPrimitive(DataType.INT, new Integer[] { 1, 0, 2 }, 3); + ioSerialiser.putGenericArrayAsPrimitive(DataType.LONG, new Long[] { 1L, 0L, 2L }, 3); + ioSerialiser.putGenericArrayAsPrimitive(DataType.FLOAT, new Float[] { (float) 1, (float) 0, (float) 2 }, 3); + ioSerialiser.putGenericArrayAsPrimitive(DataType.DOUBLE, new Double[] { (double) 1, (double) 0, (double) 2 }, 3); + ioSerialiser.putGenericArrayAsPrimitive(DataType.STRING, new String[] { "1.0", "0.0", "2.0" }, 3); + } + + @ParameterizedTest(name = "IoBuffer class - {0}") + @ValueSource(classes = { ByteBuffer.class, FastByteBuffer.class }) + void testDataSetFloatError(final Class bufferClass) throws InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException, NoSuchMethodException, SecurityException { + assertNotNull(bufferClass, "bufferClass being not null"); + assertNotNull(bufferClass.getConstructor(int.class), "Constructor(Integer) present"); + final IoBuffer buffer = bufferClass.getConstructor(int.class).newInstance(2 * BUFFER_SIZE); + + boolean asFloat32 = true; + final DefaultErrorDataSet original = new DefaultErrorDataSet("test", new double[] { 1f, 2f, 3f }, + new double[] { 6f, 7f, 8f }, new double[] { 7f, 8f, 9f }, new double[] { 7f, 8f, 9f }, 3, false); + addMetaData(original, true); + + final DataSetSerialiser ioSerialiser = DataSetSerialiser.withIoSerialiser(new BinarySerialiser(buffer)); + + ioSerialiser.write(original, true); + buffer.reset(); // reset to read position (==0) + final DataSet restored = ioSerialiser.read(); + + assertEquals(original, restored); + } + + @ParameterizedTest(name = "IoBuffer class - {0}") + @ValueSource(classes = { ByteBuffer.class, FastByteBuffer.class }) + void testDataSetFloatErrorSymmetric(final Class bufferClass) throws InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException, NoSuchMethodException, SecurityException { + assertNotNull(bufferClass, "bufferClass being not null"); + assertNotNull(bufferClass.getConstructor(int.class), "Constructor(Integer) present"); + final IoBuffer buffer = bufferClass.getConstructor(int.class).newInstance(2 * BUFFER_SIZE); + boolean asFloat32 = true; + + final DefaultErrorDataSet original = new DefaultErrorDataSet("test", new double[] { 1f, 2f, 3f }, + new double[] { 6f, 7f, 8f }, new double[] { 7f, 8f, 9f }, new double[] { 7f, 8f, 9f }, 3, false) { + private static final long serialVersionUID = 1L; + + @Override + public ErrorType getErrorType(int dimIndex) { + if (dimIndex == 1) { + return ErrorType.SYMMETRIC; + } + return super.getErrorType(dimIndex); + } + }; + addMetaData(original, true); + + final DataSetSerialiser ioSerialiser = DataSetSerialiser.withIoSerialiser(new BinarySerialiser(buffer)); + + ioSerialiser.write(original, true); + buffer.reset(); // reset to read position (==0) + final DefaultErrorDataSet restored = (DefaultErrorDataSet) ioSerialiser.read(); + + assertEquals(new DefaultErrorDataSet(original), new DefaultErrorDataSet(restored)); + } + + @ParameterizedTest(name = "IoBuffer class - {0}") + @ValueSource(classes = { ByteBuffer.class, FastByteBuffer.class }) + void testErrorDataSet(final Class bufferClass) throws InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException, NoSuchMethodException, SecurityException { + assertNotNull(bufferClass, "bufferClass being not null"); + assertNotNull(bufferClass.getConstructor(int.class), "Constructor(Integer) present"); + final IoBuffer buffer = bufferClass.getConstructor(int.class).newInstance(10 * BUFFER_SIZE); + + boolean asFloat32 = false; + final DoubleErrorDataSet original = new DoubleErrorDataSet(new TriangleFunction("test", 1009)); + addMetaData(original, true); + + final DataSetSerialiser ioSerialiser = DataSetSerialiser.withIoSerialiser(new BinarySerialiser(buffer)); + + ioSerialiser.write(original, false); + buffer.reset(); // reset to read position (==0) + final DataSet restored = ioSerialiser.read(); + + assertEquals(original, restored); + } + + @ParameterizedTest(name = "IoBuffer class - {0}") + @ValueSource(classes = { ByteBuffer.class, FastByteBuffer.class }) + void testGenericSerialiserIdentity(final Class bufferClass) throws InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException, NoSuchMethodException, SecurityException { + assertNotNull(bufferClass, "bufferClass being not null"); + assertNotNull(bufferClass.getConstructor(int.class), "Constructor(Integer) present"); + final IoBuffer buffer = bufferClass.getConstructor(int.class).newInstance(2 * BUFFER_SIZE); + + IoClassSerialiser serialiser = new IoClassSerialiser(buffer, BinarySerialiser.class); + + final DefaultErrorDataSet original = new DefaultErrorDataSet("test", // + new double[] { 1f, 2f, 3f }, new double[] { 6f, 7f, 8f }, // + new double[] { 0.7f, 0.8f, 0.9f }, new double[] { 7f, 8f, 9f }, 3, false); + addMetaData(original, true); + final EventListener eventListener = evt -> { + // empty eventLister for counting + }; + original.addListener(eventListener); + assertEquals(1, original.updateEventListener().size()); + DataSetWrapper dsOrig = new DataSetWrapper(); + dsOrig.source = original; + DataSetWrapper cpOrig = new DataSetWrapper(); + + // serialise-deserialise DataSet + buffer.reset(); // '0' writing at start of buffer + serialiser.serialiseObject(dsOrig); + + // buffer.reset(); // reset to read position (==0) + // final WireDataFieldDescription root = serialiser.getIoSerialiser().parseIoStream(true); + // root.printFieldStructure(); + + buffer.reset(); // reset to read position (==0) + final Object retOrig = serialiser.deserialiseObject(cpOrig); + + assertSame(cpOrig, retOrig, "Deserialisation expected to be in-place"); + + // check DataSet for equality + if (!(cpOrig.source instanceof DataSetError)) { + throw new IllegalStateException("DataSet '" + cpOrig.source + "' is not not instanceof DataSetError"); + } + assertEquals(0, cpOrig.source.updateEventListener().size()); + DataSetError test = (DataSetError) (cpOrig.source); + + testIdentityCore(original, test); + testIdentityLabelsAndStyles(original, test, true); + if (test instanceof DataSetMetaData) { + testIdentityMetaData(original, (DataSetMetaData) test, true); + } + assertEquals(dsOrig.source, test); + } + + @ParameterizedTest(name = "IoBuffer class - {0}") + @ValueSource(classes = { ByteBuffer.class, FastByteBuffer.class }) + void testGenericSerialiserInplaceIdentity(final Class bufferClass) throws InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException, NoSuchMethodException, SecurityException { + assertNotNull(bufferClass, "bufferClass being not null"); + assertNotNull(bufferClass.getConstructor(int.class), "Constructor(Integer) present"); + final IoBuffer buffer = bufferClass.getConstructor(int.class).newInstance(2 * BUFFER_SIZE); + + IoClassSerialiser serialiser = new IoClassSerialiser(buffer, BinarySerialiser.class); + + final DefaultErrorDataSet original = new DefaultErrorDataSet("test", // + new double[] { 1f, 2f, 3f }, new double[] { 6f, 7f, 8f }, // + new double[] { 0.7f, 0.8f, 0.9f }, new double[] { 7f, 8f, 9f }, 3, false); + addMetaData(original, true); + final EventListener eventListener = evt -> { + // empty eventLister for counting + }; + original.addListener(eventListener); + assertEquals(1, original.updateEventListener().size()); + DataSetWrapper dsOrig = new DataSetWrapper(); + dsOrig.source = original; + DataSetWrapper cpOrig = new DataSetWrapper(); + cpOrig.source = new DefaultErrorDataSet("copyName - to be overwritten"); + final EventListener eventListener1 = evt -> { + // empty eventLister for counting + }; + cpOrig.source.addListener(eventListener1); + final EventListener eventListener2 = evt -> { + // empty eventLister for counting + }; + cpOrig.source.addListener(eventListener2); + assertEquals(2, cpOrig.source.updateEventListener().size()); + + // serialise-deserialise DataSet + buffer.reset(); // '0' writing at start of buffer + serialiser.serialiseObject(dsOrig); + + // buffer.reset(); // reset to read position (==0) + // final WireDataFieldDescription root = serialiser.getIoSerialiser().parseIoStream(true); + // root.printFieldStructure(); + + buffer.reset(); // reset to read position (==0) + final Object retOrig = serialiser.deserialiseObject(cpOrig); + + assertSame(cpOrig, retOrig, "Deserialisation expected to be in-place"); + + // check DataSet for equality + if (!(cpOrig.source instanceof DataSetError)) { + throw new IllegalStateException("DataSet '" + cpOrig.source + "' is not not instanceof DataSetError"); + } + assertEquals(2, cpOrig.source.updateEventListener().size()); + assertTrue(cpOrig.source.updateEventListener().contains(eventListener1)); + assertTrue(cpOrig.source.updateEventListener().contains(eventListener2)); + DataSetError test = (DataSetError) (cpOrig.source); + + testIdentityCore(original, test); + testIdentityLabelsAndStyles(original, test, true); + if (test instanceof DataSetMetaData) { + testIdentityMetaData(original, (DataSetMetaData) test, true); + } + + assertEquals(dsOrig.source, test); + } + + @Test + void testMiscellaneous() { + assertEquals(0, DataSetSerialiser.getDimIndex("axis0", "axis")); + assertDoesNotThrow(() -> DataSetSerialiser.getDimIndex("axi0", "axis")); + assertEquals(-1, DataSetSerialiser.getDimIndex("axi0", "axis")); + assertDoesNotThrow(() -> DataSetSerialiser.getDimIndex("axis0.1", "axis")); + assertEquals(-1, DataSetSerialiser.getDimIndex("axis0.1", "axis")); + } + + private static void addMetaData(final AbstractDataSet dataSet, final boolean addLabelsStyles) { + if (addLabelsStyles) { + dataSet.addDataLabel(1, "test"); + dataSet.addDataStyle(2, "color: red"); + } + dataSet.getMetaInfo().put("Test", "Value"); + dataSet.getErrorList().add("TestError"); + dataSet.getWarningList().add("TestWarning"); + dataSet.getInfoList().add("TestInfo"); + } + + private static String encodingBinary(final boolean isBinaryEncoding) { + return isBinaryEncoding ? "binary-based" : "string-based"; + } + + private static boolean floatInequality(double a, double b) { + // 32-bit float uses 23-bit for the mantissa + return Math.abs((float) a - (float) b) > 2 / Math.pow(2, 23); + } + + private static void testIdentityCore(final DataSetError original, final DataSetError test) { + // some checks + assertEquals(original.getName(), test.getName(), "name"); + assertEquals(original.getDimension(), test.getDimension(), "dimension"); + + assertEquals(original.getDataCount(), test.getDataCount(), "getDataCount()"); + + // check for numeric value + final int dataCount = original.getDataCount(); + for (int dim = 0; dim < original.getDimension(); dim++) { + final String dStr = dim < DEFAULT_AXES_NAME.length ? DEFAULT_AXES_NAME[dim] : "dim" + (dim + 1) + "-Axis"; + + assertEquals(original.getErrorType(dim), test.getErrorType(dim), dStr + " error Type"); + assertArrayEquals(Arrays.copyOfRange(original.getValues(dim), 0, dataCount), Arrays.copyOfRange(test.getValues(dim), 0, dataCount), DELTA, dStr + "-Values"); + assertArrayEquals(Arrays.copyOfRange(original.getErrorsPositive(dim), 0, dataCount), Arrays.copyOfRange(test.getErrorsPositive(dim), 0, dataCount), DELTA, dStr + "-Errors positive"); + assertArrayEquals(Arrays.copyOfRange(original.getErrorsNegative(dim), 0, dataCount), Arrays.copyOfRange(test.getErrorsNegative(dim), 0, dataCount), DELTA, dStr + "-Errors negative"); + } + } + + private static void testIdentityLabelsAndStyles(final DataSet2D originalDS, final DataSet testDS, final boolean binary) { + // check for labels & styles + for (int i = 0; i < originalDS.getDataCount(); i++) { + if (originalDS.getDataLabel(i) == null && testDS.getDataLabel(i) == null) { + // cannot compare null vs null + continue; + } + if (!originalDS.getDataLabel(i).equals(testDS.getDataLabel(i))) { + String msg = String.format("data set label do not match (%s): original(%d) ='%s' vs. copy(%d) ='%s' %n", + encodingBinary(binary), i, originalDS.getDataLabel(i), i, testDS.getDataLabel(i)); + throw new IllegalStateException(msg); + } + } + for (int i = 0; i < originalDS.getDataCount(); i++) { + if (originalDS.getStyle(i) == null && testDS.getStyle(i) == null) { + // cannot compare null vs null + continue; + } + if (!originalDS.getStyle(i).equals(testDS.getStyle(i))) { + String msg = String.format("data set style do not match (%s): original(%d) ='%s' vs. copy(%d) ='%s' %n", + encodingBinary(binary), i, originalDS.getStyle(i), i, testDS.getStyle(i)); + throw new IllegalStateException(msg); + } + } + } + + private static void testIdentityMetaData(final DataSetMetaData originalDS, final DataSetMetaData testDS, final boolean binary) { + // check for meta data and meta messages + if (!originalDS.getInfoList().equals(testDS.getInfoList())) { + String msg = String.format("data set info lists do not match (%s): original ='%s' vs. copy ='%s' %n", + encodingBinary(binary), originalDS.getInfoList(), testDS.getInfoList()); + throw new IllegalStateException(msg); + } + } + + private static class DataSetWrapper { + public DataSet source; + } +} diff --git a/serialiser/src/test/java/io/opencmw/serialiser/utils/CmwHelper.java b/serialiser/src/test/java/io/opencmw/serialiser/utils/CmwHelper.java new file mode 100644 index 00000000..29433540 --- /dev/null +++ b/serialiser/src/test/java/io/opencmw/serialiser/utils/CmwHelper.java @@ -0,0 +1,181 @@ +package io.opencmw.serialiser.utils; + +public final class CmwHelper { + /* + private static final Logger LOGGER = LoggerFactory.getLogger(SerialiserQuickBenchmark.class); // N.B. SerialiserQuickBenchmark reference on purpose + private static final DataSerializer cmwSerializer = DataFactory.createDataSerializer(); + + public static Data getCmwData(final TestDataCl + jo) { + Data data = DataFactory.createData(); + + data.append("bool1", pojo.bool1); + data.append("bool2", pojo.bool2); + data.append("byte1", pojo.byte1); + data.append("byte2", pojo.byte2); + data.append("char1", (short) pojo.char1); // work-around storing char as short + data.append("char2", (short) pojo.char2); // work-around storing char as short + data.append("short1", pojo.short1); + data.append("short2", pojo.short2); + data.append("int1", pojo.int1); + data.append("int2", pojo.int2); + data.append("long1", pojo.long1); + data.append("long2", pojo.long2); + data.append("float1", pojo.float1); + data.append("float2", pojo.float2); + data.append("double1", pojo.double1); + data.append("double2", pojo.double2); + data.append("string1", pojo.string1); + // data.append("string2", pojo.string2); // N.B. CMW handles only ASCII characters + + // 1-dim array + data.appendArray("boolArray", pojo.boolArray); + data.appendArray("byteArray", pojo.byteArray); + //data.appendArray("charArray", pojo.charArray); // not supported by CMW + data.appendArray("shortArray", pojo.shortArray); + data.appendArray("intArray", pojo.intArray); + data.appendArray("longArray", pojo.longArray); + data.appendArray("floatArray", pojo.floatArray); + data.appendArray("doubleArray", pojo.doubleArray); + data.appendArray("stringArray", pojo.stringArray); + + // multidim arrays + data.appendArray("nDimensions", pojo.nDimensions); + data.appendMultiArray("boolNdimArray", pojo.boolNdimArray, pojo.nDimensions); + data.appendMultiArray("byteNdimArray", pojo.byteNdimArray, pojo.nDimensions); + //data.appendMultiArray("charNdimArray", pojo.charNdimArray, pojo.nDimensions); // not supported by CMW + data.appendMultiArray("shortNdimArray", pojo.shortNdimArray, pojo.nDimensions); + data.appendMultiArray("intNdimArray", pojo.intNdimArray, pojo.nDimensions); + data.appendMultiArray("longNdimArray", pojo.longNdimArray, pojo.nDimensions); + data.appendMultiArray("floatNdimArray", pojo.floatNdimArray, pojo.nDimensions); + data.appendMultiArray("doubleNdimArray", pojo.doubleNdimArray, pojo.nDimensions); + + if (pojo.nestedData != null) { + data.append("nestedData", getCmwData(pojo.nestedData)); + } + + return data; + } + + public static void applyCmwData(final Data data, final TestDataClass pojo) { + pojo.bool1 = data.getBool("bool1"); + pojo.bool2 = data.getBool("bool2"); + pojo.byte1 = data.getByte("byte1"); + pojo.byte2 = data.getByte("byte2"); + // pojo.char1 = data.getCharacter("char1"); // not supported by CMW + // pojo.char2 = data.getCharacter("char2"); // not supported by CMW + pojo.char1 = (char) data.getShort("char1"); // work-around + pojo.char2 = (char) data.getShort("char2"); // work-around + pojo.short1 = data.getShort("short1"); + pojo.short2 = data.getShort("short2"); + pojo.int1 = data.getInt("int1"); + pojo.int2 = data.getInt("int2"); + pojo.long1 = data.getLong("long1"); + pojo.long2 = data.getLong("long2"); + pojo.float1 = data.getFloat("float1"); + pojo.float2 = data.getFloat("float2"); + pojo.double1 = data.getDouble("double1"); + pojo.double2 = data.getDouble("double2"); + pojo.string1 = data.getString("string1"); + //pojo.string2 = data.getString("string2"); // N.B. handles only ASCII characters + + // 1-dim array + pojo.boolArray = data.getBoolArray("boolArray"); + pojo.byteArray = data.getByteArray("byteArray"); + // pojo.charArray = data.getCharacterArray("byteArray"); // not supported by CMW + pojo.shortArray = data.getShortArray("shortArray"); + pojo.intArray = data.getIntArray("intArray"); + pojo.longArray = data.getLongArray("longArray"); + pojo.floatArray = data.getFloatArray("floatArray"); + pojo.doubleArray = data.getDoubleArray("doubleArray"); + pojo.stringArray = data.getStringArray("stringArray"); + + // multi-dim arrays + pojo.nDimensions = data.getIntArray("nDimensions"); + pojo.boolNdimArray = data.getBoolMultiArray("boolNdimArray").getElements(); + pojo.byteNdimArray = data.getByteMultiArray("byteNdimArray").getElements(); + // pojo.charNdimArray = data.getCharMultiArray("byteArray"); // not supported by CMW + pojo.shortNdimArray = data.getShortMultiArray("shortNdimArray").getElements(); + pojo.intNdimArray = data.getIntMultiArray("intNdimArray").getElements(); + pojo.longNdimArray = data.getLongMultiArray("longNdimArray").getElements(); + pojo.floatNdimArray = data.getFloatMultiArray("floatNdimArray").getElements(); + pojo.doubleNdimArray = data.getDoubleMultiArray("doubleNdimArray").getElements(); + + final Entry nestedEntry = data.getEntry("nestedData"); + if (nestedEntry != null) { + if (pojo.nestedData == null) { + pojo.nestedData = new TestDataClass(-1, -1, -1); + } + applyCmwData(nestedEntry.getData(), pojo.nestedData); + } + } + + public static void testSerialiserPerformanceMap(final int iterations, final TestDataClass inputObject, final TestDataClass outputObject) { + final Data sourceData = CmwHelper.getCmwData(inputObject); + + final long startTime = System.nanoTime(); + byte[] buffer = new byte[0]; + for (int i = 0; i < iterations; i++) { + buffer = cmwSerializer.serializeToBinary(sourceData); + final Data retrievedData = cmwSerializer.deserializeFromBinary(buffer); + if (sourceData.size() != retrievedData.size()) { + // check necessary so that the above is not optimised by the Java JIT compiler to NOP + throw new IllegalStateException("data mismatch"); + } + } + if (iterations <= 1) { + // JMH use-case + return; + } + final long stopTime = System.nanoTime(); + + final double diffMillis = TimeUnit.NANOSECONDS.toMillis(stopTime - startTime); + final double byteCount = iterations * ((buffer.length / diffMillis) * 1e3); + LOGGER.atInfo().addArgument(SerialiserQuickBenchmark.humanReadableByteCount((long) byteCount, true)) // + .addArgument(SerialiserQuickBenchmark.humanReadableByteCount((long) buffer.length, true)) + .addArgument(diffMillis) // + .log("CMW Serializer (Map only) throughput = {}/s for {} per test run (took {} ms)"); + } + + public static void testPerformancePojo(final int iterations, final TestDataClass inputObject, final TestDataClass outputObject) { + final long startTime = System.nanoTime(); + + byte[] buffer = new byte[0]; + for (int i = 0; i < iterations; i++) { + buffer = cmwSerializer.serializeToBinary(CmwHelper.getCmwData(inputObject)); + final Data retrievedData = cmwSerializer.deserializeFromBinary(buffer); + CmwHelper.applyCmwData(retrievedData, outputObject); + if (!inputObject.string1.contentEquals(outputObject.string1)) { + // check necessary so that the above is not optimised by the Java JIT compiler to NOP + throw new IllegalStateException("data mismatch"); + } + } + if (iterations <= 1) { + // JMH use-case + return; + } + final long stopTime = System.nanoTime(); + + final double diffMillis = TimeUnit.NANOSECONDS.toMillis(stopTime - startTime); + final double byteCount = iterations * ((buffer.length / diffMillis) * 1e3); + LOGGER.atInfo().addArgument(SerialiserQuickBenchmark.humanReadableByteCount((long) byteCount, true)) // + .addArgument(SerialiserQuickBenchmark.humanReadableByteCount((long) buffer.length, true)) + .addArgument(diffMillis) // + .log("CMW Serializer (POJO) throughput = {}/s for {} per test run (took {} ms)"); + } + + public static void checkSerialiserIdentity(final TestDataClass inputObject, final TestDataClass outputObject) { + outputObject.clear(); + final Data sourceData = getCmwData(inputObject); + final byte[] buffer = cmwSerializer.serializeToBinary(sourceData); + final Data retrievedData = cmwSerializer.deserializeFromBinary(buffer); + applyCmwData(retrievedData, outputObject); + final int nBytes = buffer.length; + LOGGER.atInfo().addArgument(nBytes).log("CMW serialiser nBytes = {}"); + + // disabled since UTF-8 is not supported which would fail this test for 'string2' which contains UTF-8 characters + //assertEquals(inputObject, outputObject, "TestDataClass input-output equality"); + } + + */ +} diff --git a/serialiser/src/test/java/io/opencmw/serialiser/utils/CmwLightHelper.java b/serialiser/src/test/java/io/opencmw/serialiser/utils/CmwLightHelper.java new file mode 100644 index 00000000..aaf8af30 --- /dev/null +++ b/serialiser/src/test/java/io/opencmw/serialiser/utils/CmwLightHelper.java @@ -0,0 +1,461 @@ +package io.opencmw.serialiser.utils; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.concurrent.TimeUnit; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.opencmw.serialiser.DataType; +import io.opencmw.serialiser.IoBuffer; +import io.opencmw.serialiser.IoClassSerialiser; +import io.opencmw.serialiser.IoSerialiser; +import io.opencmw.serialiser.benchmark.SerialiserQuickBenchmark; +import io.opencmw.serialiser.spi.CmwLightSerialiser; +import io.opencmw.serialiser.spi.FastByteBuffer; +import io.opencmw.serialiser.spi.ProtocolInfo; +import io.opencmw.serialiser.spi.WireDataFieldDescription; + +public class CmwLightHelper { + private static final Logger LOGGER = LoggerFactory.getLogger(SerialiserQuickBenchmark.class); // N.B. SerialiserQuickBenchmark reference on purpose + private static final IoBuffer byteBuffer = new FastByteBuffer(100000); + // private static final IoBuffer byteBuffer = new ByteBuffer(20000); + private static final CmwLightSerialiser cmwLightSerialiser = new CmwLightSerialiser(byteBuffer); + private static final IoClassSerialiser ioSerialiser = new IoClassSerialiser(byteBuffer, CmwLightSerialiser.class); + private static int nEntries = -1; + /* + public static void checkCmwLightVsCmwIdentityBackward(final TestDataClass inputObject, TestDataClass outputObject) { + final DataSerializer cmwSerializer = DataFactory.createDataSerializer(); + TestDataClass.setCmwCompatibilityMode(true); + + outputObject.clear(); + byteBuffer.reset(); + CmwLightHelper.serialiseCustom(cmwLightSerialiser, inputObject); + final int nBytesCmwLight = byteBuffer.position(); + LOGGER.atInfo().addArgument(nBytesCmwLight).log("backward compatibility check: CmwLight serialiser nBytes = {}"); + + // keep: checks serialised data structure + // wrapCmwBuffer.reset(); + // final WireDataFieldDescription fieldRoot = CmwLightHelper.deserialiseMap(cmwLightSerialiser); + // fieldRoot.printFieldStructure(); + + // N.B. cannot use custom deserialiser since entry order seems to be arbitrary in CMW Data object + byteBuffer.reset(); + final Data retrievedData = cmwSerializer.deserializeFromBinary(((FastByteBuffer) byteBuffer).elements()); + CmwHelper.applyCmwData(retrievedData, outputObject); + + // second test - both vectors should have the same initial values after serialise/deserialise + assertArrayEquals(inputObject.stringArray, outputObject.stringArray); + assertEquals(inputObject, outputObject, "TestDataClass input-output equality"); + + TestDataClass.setCmwCompatibilityMode(false); + cmwLightSerialiser.setBuffer(byteBuffer); + } + + public static void checkCmwLightVsCmwIdentityForward(final TestDataClass inputObject, TestDataClass outputObject) { + final DataSerializer cmwSerializer = DataFactory.createDataSerializer(); + TestDataClass.setCmwCompatibilityMode(true); + + outputObject.clear(); + byteBuffer.reset(); + CmwLightHelper.serialiseCustom(cmwLightSerialiser, inputObject); + final int nBytesCmwLight = byteBuffer.position(); + + final Data cmwData = CmwHelper.getCmwData(inputObject); + final byte[] cmwBuffer = cmwSerializer.serializeToBinary(cmwData); + FastByteBuffer wrapCmwBuffer = FastByteBuffer.wrap(cmwBuffer); + LOGGER.atInfo().addArgument(cmwBuffer.length).addArgument(nBytesCmwLight).log("forward compatibility check: CMW nBytes = {} vs. CmwLight serialiser nBytes = {}"); + if (cmwBuffer.length != nBytesCmwLight) { + throw new IllegalStateException("CMW byte buffer length = " + cmwBuffer.length + " vs. CmwLight byte buffer length = " + nBytesCmwLight); + } + + wrapCmwBuffer.reset(); + cmwLightSerialiser.setBuffer(wrapCmwBuffer); + final Data retrievedData = cmwSerializer.deserializeFromBinary(cmwBuffer); + CmwHelper.applyCmwData(retrievedData, outputObject); + + // keep: checks serialised data structure + // wrapCmwBuffer.reset(); + // final WireDataFieldDescription fieldRoot = CmwLightHelper.deserialiseMap(cmwLightSerialiser); + // fieldRoot.printFieldStructure(); + + // N.B. cannot use custom deserialiser since entry order seems to be arbitrary in CMW Data object + wrapCmwBuffer.reset(); + ioSerialiser.deserialiseObject(outputObject); + + // second test - both vectors should have the same initial values after serialise/deserialise + assertArrayEquals(inputObject.stringArray, outputObject.stringArray); + assertEquals(inputObject, outputObject, "TestDataClass input-output equality"); + + TestDataClass.setCmwCompatibilityMode(false); + cmwLightSerialiser.setBuffer(byteBuffer); + } +*/ + public static int checkCustomSerialiserIdentity(final TestDataClass inputObject, TestDataClass outputObject) { + outputObject.clear(); + byteBuffer.reset(); + CmwLightHelper.serialiseCustom(cmwLightSerialiser, inputObject); + final int nBytesCmwLight = byteBuffer.position(); + + // keep: checks serialised data structure + // byteBuffer.reset(); + // final WireDataFieldDescription fieldRoot = CmwLightHelper.deserialiseMap(cmwLightSerialiser); + // fieldRoot.printFieldStructure(); + + byteBuffer.reset(); + CmwLightHelper.deserialiseCustom(cmwLightSerialiser, outputObject); + + // second test - both vectors should have the same initial values after serialise/deserialise + assertArrayEquals(inputObject.stringArray, outputObject.stringArray); + + assertEquals(inputObject, outputObject, "TestDataClass input-output equality"); + return nBytesCmwLight; + } + + public static int checkSerialiserIdentity(final TestDataClass inputObject, TestDataClass outputObject) { + outputObject.clear(); + byteBuffer.reset(); + + ioSerialiser.serialiseObject(inputObject); + + // CmwLightHelper.serialiseCustom(cmwLightSerialiser, inputObject); + final int nBytes = byteBuffer.position(); + + // keep: checks serialised data structure + // byteBuffer.reset(); + // final WireDataFieldDescription fieldRoot = CmwLightHelper.deserialiseMap(cmwLightSerialiser); + // fieldRoot.printFieldStructure(); + + byteBuffer.reset(); + final Object returnedObject = ioSerialiser.deserialiseObject(outputObject); + + assertSame(outputObject, returnedObject, "Deserialisation expected to be in-place"); + + // second test - both vectors should have the same initial values after serialise/deserialise + assertArrayEquals(inputObject.stringArray, outputObject.stringArray); + + assertEquals(inputObject, outputObject, "TestDataClass input-output equality"); + return nBytes; + } + + public static void deserialiseCustom(IoSerialiser ioSerialiser, final TestDataClass pojo) { + deserialiseCustom(ioSerialiser, pojo, true); + } + + @SuppressWarnings("PMD.ExcessiveMethodLength") + public static void deserialiseCustom(IoSerialiser ioSerialiser, final TestDataClass pojo, boolean header) { + if (header) { + final ProtocolInfo headerField = ioSerialiser.checkHeaderInfo(); + byteBuffer.position(headerField.getDataStartPosition()); + } + // read 'nEntries' chunk + nEntries = byteBuffer.getInt(); + if (nEntries <= 0) { + throw new IllegalStateException("nEntries = " + nEntries + " <= 0!"); + } + getFieldHeader(ioSerialiser); + pojo.bool1 = ioSerialiser.getBoolean(); + getFieldHeader(ioSerialiser); + pojo.bool2 = ioSerialiser.getBoolean(); + + getFieldHeader(ioSerialiser); + pojo.byte1 = ioSerialiser.getByte(); + getFieldHeader(ioSerialiser); + pojo.byte2 = ioSerialiser.getByte(); + + if (!TestDataClass.isCmwCompatibilityMode()) { // disabled since reference CMW impl does not support char + getFieldHeader(ioSerialiser); + pojo.char1 = ioSerialiser.getChar(); + getFieldHeader(ioSerialiser); + pojo.char2 = ioSerialiser.getChar(); + } else { + // CMW compatibility mode + getFieldHeader(ioSerialiser); + pojo.char1 = (char) ioSerialiser.getShort(); + getFieldHeader(ioSerialiser); + pojo.char2 = (char) ioSerialiser.getShort(); + } + + getFieldHeader(ioSerialiser); + pojo.short1 = ioSerialiser.getShort(); + getFieldHeader(ioSerialiser); + pojo.short2 = ioSerialiser.getShort(); + + getFieldHeader(ioSerialiser); + pojo.int1 = ioSerialiser.getInt(); + getFieldHeader(ioSerialiser); + pojo.int2 = ioSerialiser.getInt(); + + getFieldHeader(ioSerialiser); + pojo.long1 = ioSerialiser.getLong(); + getFieldHeader(ioSerialiser); + pojo.long2 = ioSerialiser.getLong(); + + getFieldHeader(ioSerialiser); + pojo.float1 = ioSerialiser.getFloat(); + getFieldHeader(ioSerialiser); + pojo.float2 = ioSerialiser.getFloat(); + + getFieldHeader(ioSerialiser); + pojo.double1 = ioSerialiser.getDouble(); + getFieldHeader(ioSerialiser); + pojo.double2 = ioSerialiser.getDouble(); + + getFieldHeader(ioSerialiser); + pojo.string1 = ioSerialiser.getString(); + if (!TestDataClass.isCmwCompatibilityMode()) { // disabled since reference CMW impl does not support UTF-8 + getFieldHeader(ioSerialiser); + pojo.string2 = ioSerialiser.getString(); + } + + // 1-dim arrays + getFieldHeader(ioSerialiser); + pojo.boolArray = ioSerialiser.getBooleanArray(); + getFieldHeader(ioSerialiser); + pojo.byteArray = ioSerialiser.getByteArray(); + // getFieldHeader(ioSerialiser); + // pojo.charArray = ioSerialiser.getCharArray(ioSerialiser); + getFieldHeader(ioSerialiser); + pojo.shortArray = ioSerialiser.getShortArray(); + getFieldHeader(ioSerialiser); + pojo.intArray = ioSerialiser.getIntArray(); + getFieldHeader(ioSerialiser); + pojo.longArray = ioSerialiser.getLongArray(); + getFieldHeader(ioSerialiser); + pojo.floatArray = ioSerialiser.getFloatArray(); + getFieldHeader(ioSerialiser); + pojo.doubleArray = ioSerialiser.getDoubleArray(); + getFieldHeader(ioSerialiser); + pojo.stringArray = ioSerialiser.getStringArray(); + + // multidim case + getFieldHeader(ioSerialiser); + pojo.nDimensions = ioSerialiser.getIntArray(); + getFieldHeader(ioSerialiser); + pojo.boolNdimArray = ioSerialiser.getBooleanArray(); + getFieldHeader(ioSerialiser); + pojo.byteNdimArray = ioSerialiser.getByteArray(); + getFieldHeader(ioSerialiser); + pojo.shortNdimArray = ioSerialiser.getShortArray(); + getFieldHeader(ioSerialiser); + pojo.intNdimArray = ioSerialiser.getIntArray(); + getFieldHeader(ioSerialiser); + pojo.longNdimArray = ioSerialiser.getLongArray(); + getFieldHeader(ioSerialiser); + pojo.floatNdimArray = ioSerialiser.getFloatArray(); + getFieldHeader(ioSerialiser); + pojo.doubleNdimArray = ioSerialiser.getDoubleArray(); + + final WireDataFieldDescription field = getFieldHeader(ioSerialiser); + if (field == null) { + // reached the end + return; + } + + if (field.getDataType().equals(DataType.START_MARKER)) { + if (pojo.nestedData == null) { + pojo.nestedData = new TestDataClass(); + } + deserialiseCustom(ioSerialiser, pojo.nestedData, false); + } + } + + public static WireDataFieldDescription deserialiseMap(IoSerialiser ioSerialiser) { + return ioSerialiser.parseIoStream(true); + } + + public static IoBuffer getByteBuffer() { + return byteBuffer; + } + + public static CmwLightSerialiser getCmwLightSerialiser() { + return cmwLightSerialiser; + } + + public static void serialiseCustom(IoSerialiser ioSerialiser, final TestDataClass pojo) { + serialiseCustom(ioSerialiser, pojo, true); + } + + public static void serialiseCustom(final IoSerialiser ioSerialiser, final TestDataClass pojo, final boolean header) { + if (header) { + ioSerialiser.putHeaderInfo(); + } + + ioSerialiser.put("bool1", pojo.bool1); + ioSerialiser.put("bool2", pojo.bool2); + ioSerialiser.put("byte1", pojo.byte1); + ioSerialiser.put("byte2", pojo.byte2); + if (!TestDataClass.isCmwCompatibilityMode()) { // disabled since reference CMW impl does not support char + ioSerialiser.put("char1", pojo.char1); + ioSerialiser.put("char2", pojo.char2); + } else { + // CMW compatibility mode + ioSerialiser.put("char1", (short) pojo.char1); + ioSerialiser.put("char2", (short) pojo.char2); + } + ioSerialiser.put("short1", pojo.short1); + ioSerialiser.put("short2", pojo.short2); + ioSerialiser.put("int1", pojo.int1); + ioSerialiser.put("int2", pojo.int2); + ioSerialiser.put("long1", pojo.long1); + ioSerialiser.put("long2", pojo.long2); + ioSerialiser.put("float1", pojo.float1); + ioSerialiser.put("float2", pojo.float2); + ioSerialiser.put("double1", pojo.double1); + ioSerialiser.put("double2", pojo.double2); + ioSerialiser.put("string1", pojo.string1); + if (!TestDataClass.isCmwCompatibilityMode()) { // disabled since reference CMW impl does not support UTF-8 + ioSerialiser.put("string2", pojo.string2); + } + + // 1D-arrays + ioSerialiser.put("boolArray", pojo.boolArray, pojo.boolArray.length); + ioSerialiser.put("byteArray", pojo.byteArray, pojo.byteArray.length); + //ioSerialiser.put("charArray", pojo.charArray, pojo.charArray.length); // not supported by CMW + ioSerialiser.put("shortArray", pojo.shortArray, pojo.shortArray.length); + ioSerialiser.put("intArray", pojo.intArray, pojo.intArray.length); + ioSerialiser.put("longArray", pojo.longArray, pojo.longArray.length); + ioSerialiser.put("floatArray", pojo.floatArray, pojo.floatArray.length); + ioSerialiser.put("doubleArray", pojo.doubleArray, pojo.doubleArray.length); + ioSerialiser.put("stringArray", pojo.stringArray, pojo.stringArray.length); + + // multi-dim case + ioSerialiser.put("nDimensions", pojo.nDimensions, pojo.nDimensions.length); + ioSerialiser.put("boolNdimArray", pojo.boolNdimArray, pojo.nDimensions); + ioSerialiser.put("byteNdimArray", pojo.byteNdimArray, pojo.nDimensions); + //ioSerialiser.put("charNdimArray", pojo.nDimensions); // not supported by CMW + ioSerialiser.put("shortNdimArray", pojo.shortNdimArray, pojo.nDimensions); + ioSerialiser.put("intNdimArray", pojo.intNdimArray, pojo.nDimensions); + ioSerialiser.put("longNdimArray", pojo.longNdimArray, pojo.nDimensions); + ioSerialiser.put("floatNdimArray", pojo.floatNdimArray, pojo.nDimensions); + ioSerialiser.put("doubleNdimArray", pojo.doubleNdimArray, pojo.nDimensions); + + if (pojo.nestedData != null) { + final String dataStartMarkerName = "nestedData"; + final WireDataFieldDescription nestedDataMarker = new WireDataFieldDescription(ioSerialiser, null, dataStartMarkerName.hashCode(), dataStartMarkerName, DataType.START_MARKER, -1, -1, -1); + ioSerialiser.putStartMarker(nestedDataMarker); + serialiseCustom(ioSerialiser, pojo.nestedData, false); + ioSerialiser.putEndMarker(nestedDataMarker); + } + + if (header) { + final String dataEndMarkerName = "OBJ_ROOT_END"; + final WireDataFieldDescription dataEndMarker = new WireDataFieldDescription(ioSerialiser, null, dataEndMarkerName.hashCode(), dataEndMarkerName, DataType.START_MARKER, -1, -1, -1); + ioSerialiser.putEndMarker(dataEndMarker); + } + } + + public static void testCustomSerialiserPerformance(final int iterations, final TestDataClass inputObject, final TestDataClass outputObject) { + final long startTime = System.nanoTime(); + + for (int i = 0; i < iterations; i++) { + outputObject.clear(); + byteBuffer.reset(); + CmwLightHelper.serialiseCustom(cmwLightSerialiser, inputObject); + + byteBuffer.reset(); + CmwLightHelper.deserialiseCustom(cmwLightSerialiser, outputObject); + + if (!inputObject.string1.contentEquals(outputObject.string1)) { + // quick check necessary so that the above is not optimised by the Java JIT compiler to NOP + throw new IllegalStateException("data mismatch"); + } + } + if (iterations <= 1) { + // JMH use-case + return; + } + + final long stopTime = System.nanoTime(); + + final double diffMillis = TimeUnit.NANOSECONDS.toMillis(stopTime - startTime); + final double byteCount = iterations * ((byteBuffer.position() / diffMillis) * 1e3); + LOGGER.atInfo().addArgument(SerialiserQuickBenchmark.humanReadableByteCount((long) byteCount, true)) // + .addArgument(SerialiserQuickBenchmark.humanReadableByteCount(byteBuffer.position(), true)) // + .addArgument(diffMillis) // + .log("CmwLight Serializer (custom) throughput = {}/s for {} per test run (took {} ms)"); + } + + public static void testPerformancePojo(final int iterations, final TestDataClass inputObject, TestDataClass outputObject) { + cmwLightSerialiser.setPutFieldMetaData(true); + final long startTime = System.nanoTime(); + for (int i = 0; i < iterations; i++) { + outputObject.clear(); + if (i == 1) { + // only stream meta-data the first iteration + cmwLightSerialiser.setPutFieldMetaData(false); + } + byteBuffer.reset(); + ioSerialiser.serialiseObject(inputObject); + + byteBuffer.reset(); + + final Object returnedObject = ioSerialiser.deserialiseObject(outputObject); + + if (outputObject != returnedObject) { // NOPMD - we actually want to compare references + throw new IllegalStateException("Deserialisation expected to be in-place"); + } + + if (!inputObject.string1.contentEquals(outputObject.string1)) { + // quick check necessary so that the above is not optimised by the Java JIT compiler to NOP + throw new IllegalStateException("data mismatch"); + } + } + if (iterations <= 1) { + // JMH use-case + return; + } + final long stopTime = System.nanoTime(); + + final double diffMillis = TimeUnit.NANOSECONDS.toMillis(stopTime - startTime); + final double byteCount = iterations * ((byteBuffer.position() / diffMillis) * 1e3); + LOGGER.atInfo().addArgument(SerialiserQuickBenchmark.humanReadableByteCount((long) byteCount, true)) // + .addArgument(SerialiserQuickBenchmark.humanReadableByteCount(byteBuffer.position(), true)) // + .addArgument(diffMillis) // + .log("CmwLight Serializer (POJO) throughput = {}/s for {} per test run (took {} ms)"); + } + + public static WireDataFieldDescription testSerialiserPerformanceMap(final int iterations, final TestDataClass inputObject) { + final long startTime = System.nanoTime(); + + WireDataFieldDescription ret = null; + for (int i = 0; i < iterations; i++) { + byteBuffer.reset(); + CmwLightHelper.serialiseCustom(cmwLightSerialiser, inputObject); + byteBuffer.reset(); + ret = CmwLightHelper.deserialiseMap(cmwLightSerialiser); + + if (ret.getDataSize() == 0) { + // quick check necessary so that the above is not optimised by the Java JIT compiler to NOP + throw new IllegalStateException("data mismatch"); + } + } + if (iterations <= 1) { + // JMH use-case + return ret; + } + + final long stopTime = System.nanoTime(); + + final double diffMillis = TimeUnit.NANOSECONDS.toMillis(stopTime - startTime); + final double byteCount = iterations * ((byteBuffer.position() / diffMillis) * 1e3); + LOGGER.atInfo().addArgument(SerialiserQuickBenchmark.humanReadableByteCount((long) byteCount, true)) // + .addArgument(SerialiserQuickBenchmark.humanReadableByteCount(byteBuffer.position(), true)) // + .addArgument(diffMillis) // + .log("CmwLight Serializer (Map only) throughput = {}/s for {} per test run (took {} ms)"); + return ret; + } + + private static WireDataFieldDescription getFieldHeader(IoSerialiser ioSerialiser) { + if (nEntries == 0) { + return null; + } else if (nEntries <= 0) { + throw new IllegalStateException("nEntries = " + nEntries + " <= 0!"); + } + WireDataFieldDescription field = ioSerialiser.getFieldHeader(); + ioSerialiser.getBuffer().position(field.getDataStartPosition()); + nEntries--; + return field; + } +} diff --git a/serialiser/src/test/java/io/opencmw/serialiser/utils/FlatBuffersHelper.java b/serialiser/src/test/java/io/opencmw/serialiser/utils/FlatBuffersHelper.java new file mode 100644 index 00000000..a3490e7a --- /dev/null +++ b/serialiser/src/test/java/io/opencmw/serialiser/utils/FlatBuffersHelper.java @@ -0,0 +1,355 @@ +package io.opencmw.serialiser.utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.ByteBuffer; +import java.util.concurrent.TimeUnit; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.opencmw.serialiser.benchmark.SerialiserQuickBenchmark; + +import com.google.flatbuffers.ArrayReadWriteBuf; +import com.google.flatbuffers.FlexBuffers; +import com.google.flatbuffers.FlexBuffersBuilder; + +@SuppressWarnings("PMD") // complexity is part of the very large use-case surface that is being tested +public class FlatBuffersHelper { + private static final Logger LOGGER = LoggerFactory.getLogger(SerialiserQuickBenchmark.class); // N.B. SerialiserQuickBenchmark reference on purpose + private static final byte[] rawByteBuffer = new byte[100000]; + + public static ByteBuffer serialiseCustom(FlexBuffersBuilder builder, final TestDataClass pojo) { + return serialiseCustom(builder, pojo, true); + } + + public static ByteBuffer serialiseCustom(FlexBuffersBuilder builder, final TestDataClass pojo, final boolean header) { + final int map = builder.startMap(); + builder.putBoolean("bool1", pojo.bool1); + builder.putBoolean("bool2", pojo.bool2); + + builder.putInt("byte1", pojo.byte1); + builder.putInt("byte2", pojo.byte2); + builder.putInt("short1", pojo.short1); + builder.putInt("short2", pojo.short2); + builder.putInt("char1", pojo.char1); + builder.putInt("char2", pojo.char2); + builder.putInt("int1", pojo.int1); + builder.putInt("int2", pojo.int2); + builder.putInt("long1", pojo.long1); + builder.putInt("long2", pojo.long2); + + builder.putFloat("float1", pojo.float1); + builder.putFloat("float2", pojo.float2); + builder.putFloat("double1", pojo.double1); + builder.putFloat("double2", pojo.double2); + builder.putString("string1", pojo.string1); + builder.putString("string2", pojo.string2); + + // 1D-arrays + final boolean typed = false; + final boolean fixed = false; + int svec = builder.startVector(); + for (int i = 0; i < pojo.boolArray.length; i++) { + builder.putBoolean(pojo.boolArray[i]); + } + builder.endVector("boolArray", svec, typed, fixed); + + svec = builder.startVector(); + for (int i = 0; i < pojo.byteArray.length; i++) { + builder.putInt(pojo.byteArray[i]); + } + builder.endVector("byteArray", svec, typed, fixed); + + // builder.putFieldHeader("charArray", DataType.CHAR_ARRAY); + // builder.put(pojo.charArray); + + svec = builder.startVector(); + for (int i = 0; i < pojo.shortArray.length; i++) { + builder.putInt(pojo.shortArray[i]); + } + builder.endVector("shortArray", svec, typed, fixed); + + svec = builder.startVector(); + for (int i = 0; i < pojo.intArray.length; i++) { + builder.putInt(pojo.intArray[i]); + } + builder.endVector("intArray", svec, typed, fixed); + + svec = builder.startVector(); + for (int i = 0; i < pojo.longArray.length; i++) { + builder.putInt(pojo.longArray[i]); + } + builder.endVector("longArray", svec, typed, fixed); + + svec = builder.startVector(); + for (int i = 0; i < pojo.floatArray.length; i++) { + builder.putFloat(pojo.floatArray[i]); + } + builder.endVector("floatArray", svec, typed, fixed); + + svec = builder.startVector(); + for (int i = 0; i < pojo.doubleArray.length; i++) { + builder.putFloat(pojo.doubleArray[i]); + } + builder.endVector("doubleArray", svec, typed, fixed); + + svec = builder.startVector(); + for (int i = 0; i < pojo.stringArray.length; i++) { + builder.putString(pojo.stringArray[i]); + } + builder.endVector("stringArray", svec, typed, fixed); + + // multi-dim case + svec = builder.startVector(); + for (int i = 0; i < pojo.nDimensions.length; i++) { + builder.putInt(pojo.nDimensions[i]); + } + builder.endVector("nDimensions", svec, typed, fixed); + + svec = builder.startVector(); + for (int i = 0; i < pojo.boolNdimArray.length; i++) { + builder.putBoolean(pojo.boolNdimArray[i]); + } + builder.endVector("boolNdimArray", svec, typed, fixed); + + svec = builder.startVector(); + for (int i = 0; i < pojo.byteNdimArray.length; i++) { + builder.putInt(pojo.byteNdimArray[i]); + } + builder.endVector("byteNdimArray", svec, typed, fixed); + + // builder.putFieldHeader("charArray", DataType.CHAR_ARRAY); + // builder.put(pojo.charArray); + + svec = builder.startVector(); + for (int i = 0; i < pojo.shortNdimArray.length; i++) { + builder.putInt(pojo.shortNdimArray[i]); + } + builder.endVector("shortNdimArray", svec, typed, fixed); + + svec = builder.startVector(); + for (int i = 0; i < pojo.intNdimArray.length; i++) { + builder.putInt(pojo.intNdimArray[i]); + } + builder.endVector("intNdimArray", svec, typed, fixed); + + svec = builder.startVector(); + for (int i = 0; i < pojo.longNdimArray.length; i++) { + builder.putInt(pojo.longNdimArray[i]); + } + builder.endVector("longNdimArray", svec, typed, fixed); + + svec = builder.startVector(); + for (int i = 0; i < pojo.floatNdimArray.length; i++) { + builder.putFloat(pojo.floatNdimArray[i]); + } + builder.endVector("floatNdimArray", svec, typed, fixed); + + svec = builder.startVector(); + for (int i = 0; i < pojo.doubleNdimArray.length; i++) { + builder.putFloat(pojo.doubleNdimArray[i]); + } + builder.endVector("doubleNdimArray", svec, typed, fixed); + builder.endMap(null, map); + + if (pojo.nestedData != null) { + // final int nestedMap = builder.startMap(); + serialiseCustom(builder, pojo.nestedData, false); + // builder.endMap("nestedData", map); + } + + if (header) { + return builder.finish(); + } + return null; + } + + public static void deserialiseCustom(ByteBuffer buffer, final TestDataClass pojo) { + FlexBuffers.Map map = FlexBuffers.getRoot(new ArrayReadWriteBuf(buffer.array(), buffer.limit())).asMap(); + deserialiseCustom(map, pojo, true); + } + + public static void deserialiseCustom(FlexBuffers.Map map, final TestDataClass pojo, boolean header) { + pojo.bool1 = map.get("bool1").asBoolean(); + pojo.bool2 = map.get("bool2").asBoolean(); + pojo.byte1 = (byte) map.get("byte1").asInt(); + pojo.byte2 = (byte) map.get("byte2").asInt(); + pojo.char1 = (char) map.get("char1").asInt(); + pojo.char2 = (char) map.get("char2").asInt(); + pojo.short1 = (short) map.get("short1").asInt(); + pojo.short2 = (short) map.get("short2").asInt(); + pojo.int1 = map.get("int1").asInt(); + pojo.int2 = map.get("int2").asInt(); + pojo.long1 = map.get("long1").asLong(); + pojo.long2 = map.get("long2").asLong(); + pojo.float1 = (float) map.get("float1").asFloat(); + pojo.float2 = (float) map.get("float2").asFloat(); + pojo.double1 = map.get("double1").asFloat(); + pojo.double2 = map.get("double2").asFloat(); + pojo.string1 = map.get("string1").asString(); + pojo.string2 = map.get("string2").asString(); + + // 1-dim arrays + FlexBuffers.Vector vector; + + vector = map.get("boolArray").asVector(); + pojo.boolArray = new boolean[vector.size()]; + for (int i = 0; i < pojo.boolArray.length; i++) { + pojo.boolArray[i] = vector.get(i).asBoolean(); + } + + vector = map.get("byteArray").asVector(); + pojo.byteArray = new byte[vector.size()]; + for (int i = 0; i < pojo.byteArray.length; i++) { + pojo.byteArray[i] = (byte) vector.get(i).asInt(); + } + + vector = map.get("shortArray").asVector(); + pojo.shortArray = new short[vector.size()]; + for (int i = 0; i < pojo.shortArray.length; i++) { + pojo.shortArray[i] = (short) vector.get(i).asInt(); + } + + // vector = map.get("charArray").asVector(); + // pojo.charArray = new int[vector.size()]; + // for (int i = 0; i < pojo.boolArray.length; i++) { + // pojo.charArray[i] = (char)vector.get(i).asInt(); + // } + + vector = map.get("intArray").asVector(); + pojo.intArray = new int[vector.size()]; + for (int i = 0; i < pojo.intArray.length; i++) { + pojo.intArray[i] = vector.get(i).asInt(); + } + + vector = map.get("longArray").asVector(); + pojo.longArray = new long[vector.size()]; + for (int i = 0; i < pojo.longArray.length; i++) { + pojo.longArray[i] = vector.get(i).asLong(); + } + + vector = map.get("floatArray").asVector(); + pojo.floatArray = new float[vector.size()]; + for (int i = 0; i < pojo.floatArray.length; i++) { + pojo.floatArray[i] = (float) vector.get(i).asFloat(); + } + + vector = map.get("doubleArray").asVector(); + pojo.doubleArray = new double[vector.size()]; + for (int i = 0; i < pojo.doubleArray.length; i++) { + pojo.doubleArray[i] = vector.get(i).asFloat(); + } + + vector = map.get("stringArray").asVector(); + pojo.stringArray = new String[vector.size()]; + for (int i = 0; i < pojo.stringArray.length; i++) { + pojo.stringArray[i] = vector.get(i).asString(); + } + + // multidim case + vector = map.get("nDimensions").asVector(); + pojo.nDimensions = new int[vector.size()]; + for (int i = 0; i < pojo.nDimensions.length; i++) { + pojo.nDimensions[i] = vector.get(i).asInt(); + } + + vector = map.get("boolNdimArray").asVector(); + pojo.boolNdimArray = new boolean[vector.size()]; + for (int i = 0; i < pojo.boolNdimArray.length; i++) { + pojo.boolNdimArray[i] = vector.get(i).asBoolean(); + } + + vector = map.get("byteNdimArray").asVector(); + pojo.byteNdimArray = new byte[vector.size()]; + for (int i = 0; i < pojo.byteNdimArray.length; i++) { + pojo.byteNdimArray[i] = (byte) vector.get(i).asInt(); + } + + vector = map.get("shortNdimArray").asVector(); + pojo.shortNdimArray = new short[vector.size()]; + for (int i = 0; i < pojo.shortNdimArray.length; i++) { + pojo.shortNdimArray[i] = (short) vector.get(i).asInt(); + } + + // vector = map.get("charNdimArray").asVector(); + // pojo.charNdimArray = new int[vector.size()]; + // for (int i = 0; i < pojo.charNdimArray.length; i++) { + // pojo.charNdimArray[i] = (char)vector.get(i).asInt(); + // } + + vector = map.get("intNdimArray").asVector(); + pojo.intNdimArray = new int[vector.size()]; + for (int i = 0; i < pojo.intNdimArray.length; i++) { + pojo.intNdimArray[i] = vector.get(i).asInt(); + } + + vector = map.get("longNdimArray").asVector(); + pojo.longNdimArray = new long[vector.size()]; + for (int i = 0; i < pojo.longNdimArray.length; i++) { + pojo.longNdimArray[i] = vector.get(i).asLong(); + } + + vector = map.get("floatNdimArray").asVector(); + pojo.floatNdimArray = new float[vector.size()]; + for (int i = 0; i < pojo.floatNdimArray.length; i++) { + pojo.floatNdimArray[i] = (float) vector.get(i).asFloat(); + } + + vector = map.get("doubleNdimArray").asVector(); + pojo.doubleNdimArray = new double[vector.size()]; + for (int i = 0; i < pojo.doubleNdimArray.length; i++) { + pojo.doubleNdimArray[i] = vector.get(i).asFloat(); + } + + final FlexBuffers.Map nestedMap = map.get("nestedData").asMap(); + + if (nestedMap != null && nestedMap.size() != 0) { + deserialiseCustom(map.get("nestedData").asMap(), pojo.nestedData, false); + } + } + + public static void testCustomSerialiserPerformance(final int iterations, final TestDataClass inputObject, final TestDataClass outputObject) { + final long startTime = System.nanoTime(); + + ByteBuffer retVal = FlatBuffersHelper.serialiseCustom(new FlexBuffersBuilder(new ArrayReadWriteBuf(rawByteBuffer), FlexBuffersBuilder.BUILDER_FLAG_SHARE_KEYS_AND_STRINGS), inputObject); + for (int i = 0; i < iterations; i++) { + // retVal = FlatBuffersHelper.serialiseCustom(new FlexBuffersBuilder(new ArrayReadWriteBuf(rawByteBuffer), FlexBuffersBuilder.BUILDER_FLAG_SHARE_KEYS_AND_STRINGS), inputObject); + retVal = FlatBuffersHelper.serialiseCustom(new FlexBuffersBuilder(new ArrayReadWriteBuf(rawByteBuffer), FlexBuffersBuilder.BUILDER_FLAG_NONE), inputObject); + + FlatBuffersHelper.deserialiseCustom(retVal, outputObject); + + if (!inputObject.string1.contentEquals(outputObject.string1)) { + // quick check necessary so that the above is not optimised by the Java JIT compiler to NOP + throw new IllegalStateException("data mismatch"); + } + } + if (iterations <= 1) { + // JMH use-case + return; + } + final long stopTime = System.nanoTime(); + + final double diffMillis = TimeUnit.NANOSECONDS.toMillis(stopTime - startTime); + final double byteCount = iterations * ((retVal.limit() / diffMillis) * 1e3); + LOGGER.atInfo().addArgument(SerialiserQuickBenchmark.humanReadableByteCount((long) byteCount, true)) // + .addArgument(SerialiserQuickBenchmark.humanReadableByteCount(retVal.limit(), true)) // + .addArgument(diffMillis) // + .log("FlatBuffers (custom FlexBuffers) throughput = {}/s for {} per test run (took {} ms)"); + } + + public static int checkCustomSerialiserIdentity(final TestDataClass inputObject, final TestDataClass outputObject) { + //final FlexBuffersBuilder floatBuffersBuilder = new FlexBuffersBuilder(new ArrayReadWriteBuf(rawByteBuffer), FlexBuffersBuilder.BUILDER_FLAG_SHARE_KEYS_AND_STRINGS); + final FlexBuffersBuilder floatBuffersBuilder = new FlexBuffersBuilder(new ArrayReadWriteBuf(rawByteBuffer), FlexBuffersBuilder.BUILDER_FLAG_NONE); + final ByteBuffer retVal = FlatBuffersHelper.serialiseCustom(floatBuffersBuilder, inputObject); + final int nBytesFlatBuffers = retVal.limit(); + + FlatBuffersHelper.deserialiseCustom(retVal, outputObject); + + // second test - both vectors should have the same initial values after serialise/deserialise + // assertArrayEquals(inputObject.stringArray, outputObject.stringArray); + + assertEquals(inputObject, outputObject, "TestDataClass input-output equality"); + return nBytesFlatBuffers; + } +} diff --git a/serialiser/src/test/java/io/opencmw/serialiser/utils/GenericsHelperTests.java b/serialiser/src/test/java/io/opencmw/serialiser/utils/GenericsHelperTests.java new file mode 100644 index 00000000..e1a743f7 --- /dev/null +++ b/serialiser/src/test/java/io/opencmw/serialiser/utils/GenericsHelperTests.java @@ -0,0 +1,76 @@ +package io.opencmw.serialiser.utils; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +public class GenericsHelperTests { + @Test + public void testBoxedToPrimitiveConversions() { + assertArrayEquals(new boolean[] { true, false, true }, GenericsHelper.toBoolPrimitive(new Boolean[] { true, false, true })); + assertArrayEquals(new byte[] { (byte) 1, (byte) 0, (byte) 2 }, GenericsHelper.toBytePrimitive(new Byte[] { (byte) 1, (byte) 0, (byte) 2 })); + assertArrayEquals(new char[] { (char) 1, (char) 0, (char) 2 }, GenericsHelper.toCharPrimitive(new Character[] { (char) 1, (char) 0, (char) 2 })); + assertArrayEquals(new short[] { (short) 1, (short) 0, (short) 2 }, GenericsHelper.toShortPrimitive(new Short[] { (short) 1, (short) 0, (short) 2 })); + assertArrayEquals(new int[] { 1, 0, 2 }, GenericsHelper.toIntegerPrimitive(new Integer[] { 1, 0, 2 })); + assertArrayEquals(new long[] { (long) 1, (long) 0, (long) 2 }, GenericsHelper.toLongPrimitive(new Long[] { (long) 1, (long) 0, (long) 2 })); + assertArrayEquals(new float[] { (float) 1, (float) 0, (float) 2 }, GenericsHelper.toFloatPrimitive(new Float[] { (float) 1, (float) 0, (float) 2 })); + assertArrayEquals(new double[] { (double) 1, (double) 0, (double) 2 }, GenericsHelper.toDoublePrimitive(new Double[] { (double) 1, (double) 0, (double) 2 })); + assertArrayEquals(new String[] { "1.0", "0.0", "2.0" }, GenericsHelper.toStringPrimitive(new String[] { "1.0", "0.0", "2.0" })); + + assertArrayEquals(new boolean[] { true, false, true }, GenericsHelper.toPrimitive(new Boolean[] { true, false, true })); + assertArrayEquals(new byte[] { (byte) 1, (byte) 0, (byte) 2 }, GenericsHelper.toPrimitive(new Byte[] { (byte) 1, (byte) 0, (byte) 2 })); + assertArrayEquals(new char[] { (char) 1, (char) 0, (char) 2 }, GenericsHelper.toPrimitive(new Character[] { (char) 1, (char) 0, (char) 2 })); + assertArrayEquals(new short[] { (short) 1, (short) 0, (short) 2 }, GenericsHelper.toPrimitive(new Short[] { (short) 1, (short) 0, (short) 2 })); + assertArrayEquals(new int[] { 1, 0, 2 }, GenericsHelper.toPrimitive(new Integer[] { 1, 0, 2 })); + assertArrayEquals(new long[] { (long) 1, (long) 0, (long) 2 }, GenericsHelper.toPrimitive(new Long[] { (long) 1, (long) 0, (long) 2 })); + assertArrayEquals(new float[] { (float) 1, (float) 0, (float) 2 }, GenericsHelper.toPrimitive(new Float[] { (float) 1, (float) 0, (float) 2 })); + assertArrayEquals(new double[] { (double) 1, (double) 0, (double) 2 }, GenericsHelper.toPrimitive(new Double[] { (double) 1, (double) 0, (double) 2 })); + assertArrayEquals(new String[] { "1.0", "0.0", "2.0" }, GenericsHelper.toStringPrimitive(new String[] { "1.0", "0.0", "2.0" })); + } + + @Test + public void testPrimitiveToObjectConversions() { + assertArrayEquals(new Boolean[] { true, false, true }, GenericsHelper.toObject(new boolean[] { true, false, true })); + assertArrayEquals(new Byte[] { (byte) 1, (byte) 0, (byte) 2 }, GenericsHelper.toObject(new byte[] { (byte) 1, (byte) 0, (byte) 2 })); + assertArrayEquals(new Character[] { (char) 1, (char) 0, (char) 2 }, GenericsHelper.toObject(new char[] { (char) 1, (char) 0, (char) 2 })); + assertArrayEquals(new Short[] { (short) 1, (short) 0, (short) 2 }, GenericsHelper.toObject(new short[] { (short) 1, (short) 0, (short) 2 })); + assertArrayEquals(new Integer[] { 1, 0, 2 }, GenericsHelper.toObject(new int[] { 1, 0, 2 })); + assertArrayEquals(new Long[] { (long) 1, (long) 0, (long) 2 }, GenericsHelper.toObject(new long[] { (long) 1, (long) 0, (long) 2 })); + assertArrayEquals(new Float[] { (float) 1, (float) 0, (float) 2 }, GenericsHelper.toObject(new float[] { (float) 1, (float) 0, (float) 2 })); + assertArrayEquals(new Double[] { (double) 1, (double) 0, (double) 2 }, GenericsHelper.toObject(new double[] { (double) 1, (double) 0, (double) 2 })); + } + + @Test + public void testAnyToDoublePrimitive() { + assertArrayEquals(new double[] { 1.0, 0.0, 1.0 }, GenericsHelper.toDoublePrimitive(new boolean[] { true, false, true })); + assertArrayEquals(new double[] { 1.0, 0.0, 2.0 }, GenericsHelper.toDoublePrimitive(new byte[] { (byte) 1, (byte) 0, (byte) 2 })); + assertArrayEquals(new double[] { 1.0, 0.0, 2.0 }, GenericsHelper.toDoublePrimitive(new char[] { (char) 1, (char) 0, (char) 2 })); + assertArrayEquals(new double[] { 1.0, 0.0, 2.0 }, GenericsHelper.toDoublePrimitive(new short[] { (short) 1, (short) 0, (short) 2 })); + assertArrayEquals(new double[] { 1.0, 0.0, 2.0 }, GenericsHelper.toDoublePrimitive(new int[] { 1, 0, 2 })); + assertArrayEquals(new double[] { 1.0, 0.0, 2.0 }, GenericsHelper.toDoublePrimitive(new long[] { (long) 1, (long) 0, (long) 2 })); + assertArrayEquals(new double[] { 1.0, 0.0, 2.0 }, GenericsHelper.toDoublePrimitive(new float[] { (float) 1, (float) 0, (float) 2 })); + assertArrayEquals(new double[] { 1.0, 0.0, 2.0 }, GenericsHelper.toDoublePrimitive(new String[] { "1.0", "0.0", "2.0" })); + } + + @Test + public void testAnyToStringPrimitive() { + assertArrayEquals(new String[] { "true", "false", "true" }, GenericsHelper.toStringPrimitive(new Boolean[] { true, false, true })); + assertArrayEquals(new String[] { "1", "0", "2" }, GenericsHelper.toStringPrimitive(new Byte[] { (byte) 1, (byte) 0, (byte) 2 })); + assertArrayEquals(new String[] { "A", "B", "C" }, GenericsHelper.toStringPrimitive(new Character[] { (char) 65, (char) 66, (char) 67 })); + assertArrayEquals(new String[] { "1", "0", "2" }, GenericsHelper.toStringPrimitive(new Short[] { (short) 1, (short) 0, (short) 2 })); + assertArrayEquals(new String[] { "1", "0", "2" }, GenericsHelper.toStringPrimitive(new Integer[] { 1, 0, 2 })); + assertArrayEquals(new String[] { "1", "0", "2" }, GenericsHelper.toStringPrimitive(new Long[] { (long) 1, (long) 0, (long) 2 })); + assertArrayEquals(new String[] { "1.0", "0.0", "2.0" }, GenericsHelper.toStringPrimitive(new Float[] { (float) 1, (float) 0, (float) 2 })); + assertArrayEquals(new String[] { "1.0", "0.0", "2.0" }, GenericsHelper.toStringPrimitive(new Double[] { (double) 1, (double) 0, (double) 2 })); + assertArrayEquals(new String[] { "1.0", "0.0", "2.0" }, GenericsHelper.toStringPrimitive(new String[] { "1.0", "0.0", "2.0" })); + assertArrayEquals(new String[] {}, GenericsHelper.toStringPrimitive(new String[] {})); + } + + @Test + public void testHelper() { + assertThrows(IllegalArgumentException.class, () -> GenericsHelper.toBytePrimitive(null)); + assertDoesNotThrow(() -> GenericsHelper.toBytePrimitive(new Integer[] {})); + assertThrows(IllegalArgumentException.class, () -> GenericsHelper.toBytePrimitive(new Integer[] { null })); + assertThrows(IllegalArgumentException.class, () -> GenericsHelper.toBytePrimitive(new Integer[] { 1 })); + } +} diff --git a/serialiser/src/test/java/io/opencmw/serialiser/utils/JsonHelper.java b/serialiser/src/test/java/io/opencmw/serialiser/utils/JsonHelper.java new file mode 100644 index 00000000..ffc34800 --- /dev/null +++ b/serialiser/src/test/java/io/opencmw/serialiser/utils/JsonHelper.java @@ -0,0 +1,278 @@ +package io.opencmw.serialiser.utils; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.concurrent.TimeUnit; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.opencmw.serialiser.DataType; +import io.opencmw.serialiser.IoBuffer; +import io.opencmw.serialiser.IoSerialiser; +import io.opencmw.serialiser.benchmark.SerialiserQuickBenchmark; +import io.opencmw.serialiser.spi.FastByteBuffer; +import io.opencmw.serialiser.spi.JsonSerialiser; +import io.opencmw.serialiser.spi.WireDataFieldDescription; + +import com.jsoniter.JsonIterator; +import com.jsoniter.extra.PreciseFloatSupport; +import com.jsoniter.output.EncodingMode; +import com.jsoniter.output.JsonStream; +import com.jsoniter.spi.DecodingMode; +import com.jsoniter.spi.JsonException; + +public final class JsonHelper { + private static final Logger LOGGER = LoggerFactory.getLogger(SerialiserQuickBenchmark.class); // N.B. SerialiserQuickBenchmark reference on purpose + private static final IoBuffer byteBuffer = new FastByteBuffer(1000000); + // private static final IoBuffer byteBuffer = new ByteBuffer(20000); + private static final JsonSerialiser jsonSerialiser = new JsonSerialiser(byteBuffer); + + public static int checkCustomSerialiserIdentity(final TestDataClass inputObject, TestDataClass outputObject) { + outputObject.clear(); + byteBuffer.reset(); + JsonHelper.serialiseCustom(jsonSerialiser, inputObject); + byteBuffer.flip(); + + // keep: checks serialised data structure + // byteBuffer.reset(); + // final WireDataFieldDescription fieldRoot = CmwLightHelper.deserialiseMap(cmwLightSerialiser); + // fieldRoot.printFieldStructure(); + + jsonSerialiser.deserialiseObject(outputObject); + + // second test - both vectors should have the same initial values after serialise/deserialise + assertArrayEquals(inputObject.stringArray, outputObject.stringArray); + + assertEquals(inputObject, outputObject, "TestDataClass input-output equality"); + return byteBuffer.limit(); + } + + public static int checkSerialiserIdentity(final TestDataClass inputObject, TestDataClass outputObject) { + outputObject.clear(); + // JsonIterator.setMode(DecodingMode.DYNAMIC_MODE_AND_MATCH_FIELD_WITH_HASH); + // JsonStream.setMode(EncodingMode.DYNAMIC_MODE); + // JsonIterator.setMode(DecodingMode.REFLECTION_MODE); + // JsonStream.setIndentionStep(2); // sets line-breaks and indentation (more human readable) + //Base64Support.enable(); + //Base64FloatSupport.enableEncodersAndDecoders(); + JsonStream.setMode(EncodingMode.DYNAMIC_MODE); + + try { + PreciseFloatSupport.enable(); + } catch (JsonException e) { + // swallow subsequent enabling exceptions (function is guarded and supposed to be called only once) + } + + byteBuffer.reset(); + jsonSerialiser.serialiseObject(inputObject); + + byteBuffer.flip(); + + jsonSerialiser.deserialiseObject(outputObject); + + if (!inputObject.string1.contentEquals(outputObject.string1)) { + // quick check necessary so that the above is not optimised by the Java JIT compiler to NOP + throw new IllegalStateException("data mismatch"); + } + + // second test - both vectors should have the same initial values after serialise/deserialise + assertArrayEquals(inputObject.stringArray, outputObject.stringArray); + + assertEquals(inputObject, outputObject, "TestDataClass input-output equality"); + return byteBuffer.limit(); + } + + public static IoBuffer getByteBuffer() { + return byteBuffer; + } + + public static void serialiseCustom(IoSerialiser ioSerialiser, final TestDataClass pojo) { + serialiseCustom(ioSerialiser, pojo, true); + } + + public static void serialiseCustom(final IoSerialiser ioSerialiser, final TestDataClass pojo, final boolean header) { + if (header) { + ioSerialiser.putHeaderInfo(); + } + + ioSerialiser.put("bool1", pojo.bool1); + ioSerialiser.put("bool2", pojo.bool2); + ioSerialiser.put("byte1", pojo.byte1); + ioSerialiser.put("byte2", pojo.byte2); + ioSerialiser.put("char1", pojo.char1); + ioSerialiser.put("char2", pojo.char2); + ioSerialiser.put("short1", pojo.short1); + ioSerialiser.put("short2", pojo.short2); + ioSerialiser.put("int1", pojo.int1); + ioSerialiser.put("int2", pojo.int2); + ioSerialiser.put("long1", pojo.long1); + ioSerialiser.put("long2", pojo.long2); + ioSerialiser.put("float1", pojo.float1); + ioSerialiser.put("float2", pojo.float2); + ioSerialiser.put("double1", pojo.double1); + ioSerialiser.put("double2", pojo.double2); + ioSerialiser.put("string1", pojo.string1); + ioSerialiser.put("string2", pojo.string2); + + // 1D-arrays + ioSerialiser.put("boolArray", pojo.boolArray, pojo.boolArray.length); + ioSerialiser.put("byteArray", pojo.byteArray, pojo.byteArray.length); + //ioSerialiser.put("charArray", pojo.charArray, pojo.charArray.lenght); + ioSerialiser.put("shortArray", pojo.shortArray, pojo.shortArray.length); + ioSerialiser.put("intArray", pojo.intArray, pojo.intArray.length); + ioSerialiser.put("longArray", pojo.longArray, pojo.longArray.length); + ioSerialiser.put("floatArray", pojo.floatArray, pojo.floatArray.length); + ioSerialiser.put("doubleArray", pojo.doubleArray, pojo.doubleArray.length); + ioSerialiser.put("stringArray", pojo.stringArray, pojo.stringArray.length); + + // multi-dim case + ioSerialiser.put("nDimensions", pojo.nDimensions, pojo.nDimensions.length); + ioSerialiser.put("boolNdimArray", pojo.boolNdimArray, pojo.nDimensions); + ioSerialiser.put("byteNdimArray", pojo.byteNdimArray, pojo.nDimensions); + //ioSerialiser.put("charNdimArray", pojo.nDimensions); + ioSerialiser.put("shortNdimArray", pojo.shortNdimArray, pojo.nDimensions); + ioSerialiser.put("intNdimArray", pojo.intNdimArray, pojo.nDimensions); + ioSerialiser.put("longNdimArray", pojo.longNdimArray, pojo.nDimensions); + ioSerialiser.put("floatNdimArray", pojo.floatNdimArray, pojo.nDimensions); + ioSerialiser.put("doubleNdimArray", pojo.doubleNdimArray, pojo.nDimensions); + + if (pojo.nestedData != null) { + final String dataStartMarkerName = "nestedData"; + final WireDataFieldDescription nestedDataMarker = new WireDataFieldDescription(ioSerialiser, null, dataStartMarkerName.hashCode(), dataStartMarkerName, DataType.START_MARKER, -1, -1, -1); + ioSerialiser.putStartMarker(nestedDataMarker); + serialiseCustom(ioSerialiser, pojo.nestedData, false); + ioSerialiser.putEndMarker(nestedDataMarker); + } + + if (header) { + final String dataEndMarkerName = "OBJ_ROOT_END"; + final WireDataFieldDescription dataEndMarker = new WireDataFieldDescription(ioSerialiser, null, dataEndMarkerName.hashCode(), dataEndMarkerName, DataType.START_MARKER, -1, -1, -1); + ioSerialiser.putEndMarker(dataEndMarker); + } + } + + public static void testCustomSerialiserPerformance(final int iterations, final TestDataClass inputObject, final TestDataClass outputObject) { + final long startTime = System.nanoTime(); + + for (int i = 0; i < iterations; i++) { + outputObject.clear(); + byteBuffer.reset(); + JsonHelper.serialiseCustom(jsonSerialiser, inputObject); + + byteBuffer.flip(); + jsonSerialiser.deserialiseObject(outputObject); + + if (!inputObject.string1.contentEquals(outputObject.string1)) { + // quick check necessary so that the above is not optimised by the Java JIT compiler to NOP + throw new IllegalStateException("data mismatch"); + } + } + if (iterations <= 1) { + // JMH use-case + return; + } + + final long stopTime = System.nanoTime(); + + final double diffMillis = TimeUnit.NANOSECONDS.toMillis(stopTime - startTime); + final double byteCount = iterations * ((byteBuffer.limit() / diffMillis) * 1e3); + LOGGER.atInfo().addArgument(SerialiserQuickBenchmark.humanReadableByteCount((long) byteCount, true)) // + .addArgument(SerialiserQuickBenchmark.humanReadableByteCount(byteBuffer.limit(), true)) // + .addArgument(diffMillis) // + .log("JSON Serializer (custom) throughput = {}/s for {} per test run (took {} ms)"); + } + + public static void testPerformancePojo(final int iterations, final TestDataClass inputObject, TestDataClass outputObject) { + // works with all classes, in particular those having private fields + JsonStream.setMode(EncodingMode.REFLECTION_MODE); + JsonIterator.setMode(DecodingMode.REFLECTION_MODE); + + outputObject.clear(); + final long startTime = System.nanoTime(); + testPerformancePojoNoPrintout(iterations, inputObject, outputObject); + if (iterations <= 1) { + // JMH use-case + return; + } + final long stopTime = System.nanoTime(); + + final double diffMillis = TimeUnit.NANOSECONDS.toMillis(stopTime - startTime); + final double byteCount = iterations * ((byteBuffer.limit() / diffMillis) * 1e3); + LOGGER.atInfo().addArgument(SerialiserQuickBenchmark.humanReadableByteCount((long) byteCount, true)) // + .addArgument(SerialiserQuickBenchmark.humanReadableByteCount(byteBuffer.limit(), true)) // + .addArgument(diffMillis) // + .log("JSON Serializer (POJO, reflection-only) throughput = {}/s for {} per test run (took {} ms)"); + } + + public static void testPerformancePojoCodeGen(final int iterations, final TestDataClass inputObject, TestDataClass outputObject) { + // N.B. works only for all-public fields + JsonStream.setMode(EncodingMode.DYNAMIC_MODE); + JsonIterator.setMode(DecodingMode.DYNAMIC_MODE_AND_MATCH_FIELD_WITH_HASH); + + outputObject.clear(); + final long startTime = System.nanoTime(); + testPerformancePojoNoPrintout(iterations, inputObject, outputObject); + if (iterations <= 1) { + // JMH use-case + return; + } + final long stopTime = System.nanoTime(); + + final double diffMillis = TimeUnit.NANOSECONDS.toMillis(stopTime - startTime); + final double byteCount = iterations * ((byteBuffer.limit() / diffMillis) * 1e3); + LOGGER.atInfo().addArgument(SerialiserQuickBenchmark.humanReadableByteCount((long) byteCount, true)) // + .addArgument(SerialiserQuickBenchmark.humanReadableByteCount(byteBuffer.limit(), true)) // + .addArgument(diffMillis) // + .log("JSON Serializer (POJO, code-gen) throughput = {}/s for {} per test run (took {} ms)"); + } + + public static void testPerformancePojoNoPrintout(final int iterations, final TestDataClass inputObject, TestDataClass outputObject) { + for (int i = 0; i < iterations; i++) { + byteBuffer.reset(); + outputObject.clear(); + jsonSerialiser.serialiseObject(inputObject); + + byteBuffer.flip(); + jsonSerialiser.deserialiseObject(outputObject); + + if (!inputObject.string1.contentEquals(outputObject.string1)) { + // quick check necessary so that the above is not optimised by the Java JIT compiler to NOP + throw new IllegalStateException("data mismatch"); + } + } + } + + public static void testSerialiserPerformanceMap(final int iterations, final TestDataClass inputObject) { + byteBuffer.reset(); + jsonSerialiser.serialiseObject(inputObject); + + byteBuffer.flip(); + + final long startTime = System.nanoTime(); + + final WireDataFieldDescription wireDataHeader = jsonSerialiser.parseIoStream(true); + // wireDataHeader.printFieldStructure(); + + if (wireDataHeader == null || wireDataHeader.getChildren().get(0) == null || wireDataHeader.getChildren().get(0).findChildField("string1") == null + || !((WireDataFieldDescription) wireDataHeader.getChildren().get(0).findChildField("string1")).data().equals(inputObject.string1)) { + // quick check necessary so that the above is not optimised by the Java JIT compiler to NOP + throw new IllegalStateException("data mismatch"); + } + + if (iterations <= 1) { + // JMH use-case + return; + } + + final long stopTime = System.nanoTime(); + + final double diffMillis = TimeUnit.NANOSECONDS.toMillis(stopTime - startTime); + final double byteCount = iterations * ((byteBuffer.limit() / diffMillis) * 1e3); + LOGGER.atInfo().addArgument(SerialiserQuickBenchmark.humanReadableByteCount((long) byteCount, true)) // + .addArgument(SerialiserQuickBenchmark.humanReadableByteCount(byteBuffer.limit(), true)) // + .addArgument(diffMillis) // + .log("JSON Serializer (Map only) throughput = {}/s for {} per test run (took {} ms)"); + } +} diff --git a/serialiser/src/test/java/io/opencmw/serialiser/utils/SerialiserHelper.java b/serialiser/src/test/java/io/opencmw/serialiser/utils/SerialiserHelper.java new file mode 100644 index 00000000..f9fa04f3 --- /dev/null +++ b/serialiser/src/test/java/io/opencmw/serialiser/utils/SerialiserHelper.java @@ -0,0 +1,355 @@ +package io.opencmw.serialiser.utils; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.concurrent.TimeUnit; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.opencmw.serialiser.DataType; +import io.opencmw.serialiser.IoBuffer; +import io.opencmw.serialiser.IoClassSerialiser; +import io.opencmw.serialiser.IoSerialiser; +import io.opencmw.serialiser.benchmark.SerialiserQuickBenchmark; +import io.opencmw.serialiser.spi.BinarySerialiser; +import io.opencmw.serialiser.spi.FastByteBuffer; +import io.opencmw.serialiser.spi.WireDataFieldDescription; + +@SuppressWarnings("PMD") // complexity is part of the very large use-case surface that is being tested +public final class SerialiserHelper { + private static final Logger LOGGER = LoggerFactory.getLogger(SerialiserQuickBenchmark.class); // N.B. SerialiserQuickBenchmark reference on purpose + private static final IoBuffer byteBuffer = new FastByteBuffer(100000); + + // private static final IoBuffer byteBuffer = new ByteBuffer(20000); + private static final BinarySerialiser binarySerialiser = new BinarySerialiser(byteBuffer); + private static final IoClassSerialiser ioSerialiser = new IoClassSerialiser(byteBuffer, BinarySerialiser.class); + + public static int checkCustomSerialiserIdentity(final TestDataClass inputObject, TestDataClass outputObject) { + outputObject.clear(); + byteBuffer.reset(); + SerialiserHelper.serialiseCustom(binarySerialiser, inputObject); + + byteBuffer.flip(); + + // keep: checks serialised data structure + // byteBuffer.reset(); + // final WireDataFieldDescription fieldRoot = SerialiserHelper.deserialiseMap(byteBuffer); + // fieldRoot.printFieldStructure(); + + SerialiserHelper.deserialiseCustom(binarySerialiser, outputObject); + + // second test - both vectors should have the same initial values after serialise/deserialise + assertArrayEquals(inputObject.stringArray, outputObject.stringArray); + + assertEquals(inputObject, outputObject, "TestDataClass input-output equality"); + return byteBuffer.limit(); + } + + public static int checkSerialiserIdentity(final TestDataClass inputObject, TestDataClass outputObject) { + outputObject.clear(); + byteBuffer.reset(); + + ioSerialiser.serialiseObject(inputObject); + + // SerialiserHelper.serialiseCustom(byteBuffer, inputObject); + + byteBuffer.flip(); + // keep: checks serialised data structure + // byteBuffer.reset(); + // final WireDataFieldDescription fieldRoot = SerialiserHelper.deserialiseMap(byteBuffer); + // fieldRoot.printFieldStructure(); + + outputObject = ioSerialiser.deserialiseObject(outputObject); + + // second test - both vectors should have the same initial values after serialise/deserialise + assertArrayEquals(inputObject.stringArray, outputObject.stringArray); + + assertEquals(inputObject, outputObject, "TestDataClass input-output equality"); + + return byteBuffer.limit(); + } + + public static void deserialiseCustom(IoSerialiser ioSerialiser, final TestDataClass pojo) { + deserialiseCustom(ioSerialiser, pojo, true); + } + + public static void deserialiseCustom(IoSerialiser ioSerialiser, final TestDataClass pojo, boolean header) { + if (header) { + ioSerialiser.checkHeaderInfo(); + } + + getFieldHeader(ioSerialiser); + pojo.bool1 = ioSerialiser.getBoolean(); + getFieldHeader(ioSerialiser); + pojo.bool2 = ioSerialiser.getBoolean(); + + getFieldHeader(ioSerialiser); + pojo.byte1 = ioSerialiser.getByte(); + getFieldHeader(ioSerialiser); + pojo.byte2 = ioSerialiser.getByte(); + + getFieldHeader(ioSerialiser); + pojo.char1 = ioSerialiser.getChar(); + getFieldHeader(ioSerialiser); + pojo.char2 = ioSerialiser.getChar(); + + getFieldHeader(ioSerialiser); + pojo.short1 = ioSerialiser.getShort(); + getFieldHeader(ioSerialiser); + pojo.short2 = ioSerialiser.getShort(); + + getFieldHeader(ioSerialiser); + pojo.int1 = ioSerialiser.getInt(); + getFieldHeader(ioSerialiser); + pojo.int2 = ioSerialiser.getInt(); + + getFieldHeader(ioSerialiser); + pojo.long1 = ioSerialiser.getLong(); + getFieldHeader(ioSerialiser); + pojo.long2 = ioSerialiser.getLong(); + + getFieldHeader(ioSerialiser); + pojo.float1 = ioSerialiser.getFloat(); + getFieldHeader(ioSerialiser); + pojo.float2 = ioSerialiser.getFloat(); + + getFieldHeader(ioSerialiser); + pojo.double1 = ioSerialiser.getDouble(); + getFieldHeader(ioSerialiser); + pojo.double2 = ioSerialiser.getDouble(); + + getFieldHeader(ioSerialiser); + pojo.string1 = ioSerialiser.getString(); + getFieldHeader(ioSerialiser); + pojo.string2 = ioSerialiser.getString(); + + // 1-dim arrays + getFieldHeader(ioSerialiser); + pojo.boolArray = ioSerialiser.getBooleanArray(); + getFieldHeader(ioSerialiser); + pojo.byteArray = ioSerialiser.getByteArray(); + //getFieldHeader(ioSerialiser); + //pojo.charArray = ioSerialiser.getCharArray(ioSerialiser); + getFieldHeader(ioSerialiser); + pojo.shortArray = ioSerialiser.getShortArray(); + getFieldHeader(ioSerialiser); + pojo.intArray = ioSerialiser.getIntArray(); + getFieldHeader(ioSerialiser); + pojo.longArray = ioSerialiser.getLongArray(); + getFieldHeader(ioSerialiser); + pojo.floatArray = ioSerialiser.getFloatArray(); + getFieldHeader(ioSerialiser); + pojo.doubleArray = ioSerialiser.getDoubleArray(); + getFieldHeader(ioSerialiser); + pojo.stringArray = ioSerialiser.getStringArray(); + + // multidim case + getFieldHeader(ioSerialiser); + pojo.nDimensions = ioSerialiser.getIntArray(); + getFieldHeader(ioSerialiser); + pojo.boolNdimArray = ioSerialiser.getBooleanArray(); + getFieldHeader(ioSerialiser); + pojo.byteNdimArray = ioSerialiser.getByteArray(); + getFieldHeader(ioSerialiser); + pojo.shortNdimArray = ioSerialiser.getShortArray(); + getFieldHeader(ioSerialiser); + pojo.intNdimArray = ioSerialiser.getIntArray(); + getFieldHeader(ioSerialiser); + pojo.longNdimArray = ioSerialiser.getLongArray(); + getFieldHeader(ioSerialiser); + pojo.floatNdimArray = ioSerialiser.getFloatArray(); + getFieldHeader(ioSerialiser); + pojo.doubleNdimArray = ioSerialiser.getDoubleArray(); + + final WireDataFieldDescription field = getFieldHeader(ioSerialiser); + if (field.getDataType().equals(DataType.START_MARKER)) { + if (pojo.nestedData == null) { + pojo.nestedData = new TestDataClass(); + } + deserialiseCustom(ioSerialiser, pojo.nestedData, false); + + } else if (!field.getDataType().equals(DataType.END_MARKER)) { + throw new IllegalStateException("format error/unexpected tag with data type = " + field.getDataType() + " and field name = " + field.getFieldName()); + } + } + + public static WireDataFieldDescription deserialiseMap(IoSerialiser ioSerialiser) { + return ioSerialiser.parseIoStream(true); + } + + public static BinarySerialiser getBinarySerialiser() { + return binarySerialiser; + } + + public static IoBuffer getByteBuffer() { + return byteBuffer; + } + + public static void serialiseCustom(IoSerialiser ioSerialiser, final TestDataClass pojo) { + serialiseCustom(ioSerialiser, pojo, true); + } + + public static void serialiseCustom(final IoSerialiser ioSerialiser, final TestDataClass pojo, final boolean header) { + if (header) { + ioSerialiser.putHeaderInfo(); + } + + ioSerialiser.put("bool1", pojo.bool1); + ioSerialiser.put("bool2", pojo.bool2); + ioSerialiser.put("byte1", pojo.byte1); + ioSerialiser.put("byte2", pojo.byte2); + ioSerialiser.put("char1", pojo.char1); + ioSerialiser.put("char2", pojo.char2); + ioSerialiser.put("short1", pojo.short1); + ioSerialiser.put("short2", pojo.short2); + ioSerialiser.put("int1", pojo.int1); + ioSerialiser.put("int2", pojo.int2); + ioSerialiser.put("long1", pojo.long1); + ioSerialiser.put("long2", pojo.long2); + ioSerialiser.put("float1", pojo.float1); + ioSerialiser.put("float2", pojo.float2); + ioSerialiser.put("double1", pojo.double1); + ioSerialiser.put("double2", pojo.double2); + ioSerialiser.put("string1", pojo.string1); + ioSerialiser.put("string2", pojo.string2); + + // 1D-arrays + ioSerialiser.put("boolArray", pojo.boolArray, pojo.boolArray.length); + ioSerialiser.put("byteArray", pojo.byteArray, pojo.byteArray.length); + //ioSerialiser.put("charArray", pojo.charArray, pojo.charArray.lenght); + ioSerialiser.put("shortArray", pojo.shortArray, pojo.shortArray.length); + ioSerialiser.put("intArray", pojo.intArray, pojo.intArray.length); + ioSerialiser.put("longArray", pojo.longArray, pojo.longArray.length); + ioSerialiser.put("floatArray", pojo.floatArray, pojo.floatArray.length); + ioSerialiser.put("doubleArray", pojo.doubleArray, pojo.doubleArray.length); + ioSerialiser.put("stringArray", pojo.stringArray, pojo.stringArray.length); + + // multi-dim case + ioSerialiser.put("nDimensions", pojo.nDimensions, pojo.nDimensions.length); + ioSerialiser.put("boolNdimArray", pojo.boolNdimArray, pojo.nDimensions); + ioSerialiser.put("byteNdimArray", pojo.byteNdimArray, pojo.nDimensions); + //ioSerialiser.put("charNdimArray", pojo.nDimensions); + ioSerialiser.put("shortNdimArray", pojo.shortNdimArray, pojo.nDimensions); + ioSerialiser.put("intNdimArray", pojo.intNdimArray, pojo.nDimensions); + ioSerialiser.put("longNdimArray", pojo.longNdimArray, pojo.nDimensions); + ioSerialiser.put("floatNdimArray", pojo.floatNdimArray, pojo.nDimensions); + ioSerialiser.put("doubleNdimArray", pojo.doubleNdimArray, pojo.nDimensions); + + if (pojo.nestedData != null) { + final String dataStartMarkerName = "nestedData"; + final WireDataFieldDescription nestedDataMarker = new WireDataFieldDescription(ioSerialiser, null, dataStartMarkerName.hashCode(), dataStartMarkerName, DataType.START_MARKER, -1, -1, -1); + ioSerialiser.putStartMarker(nestedDataMarker); + serialiseCustom(ioSerialiser, pojo.nestedData, false); + ioSerialiser.putEndMarker(nestedDataMarker); + } + + if (header) { + final String dataEndMarkerName = "OBJ_ROOT_END"; + final WireDataFieldDescription dataEndMarker = new WireDataFieldDescription(ioSerialiser, null, dataEndMarkerName.hashCode(), dataEndMarkerName, DataType.START_MARKER, -1, -1, -1); + ioSerialiser.putEndMarker(dataEndMarker); + } + } + + public static void testCustomSerialiserPerformance(final int iterations, final TestDataClass inputObject, final TestDataClass outputObject) { + final long startTime = System.nanoTime(); + + for (int i = 0; i < iterations; i++) { + byteBuffer.reset(); + SerialiserHelper.serialiseCustom(binarySerialiser, inputObject); + + byteBuffer.reset(); + SerialiserHelper.deserialiseCustom(binarySerialiser, outputObject); + + if (!inputObject.string1.contentEquals(outputObject.string1)) { + // quick check necessary so that the above is not optimised by the Java JIT compiler to NOP + throw new IllegalStateException("data mismatch"); + } + } + if (iterations <= 1) { + // JMH use-case + return; + } + + final long stopTime = System.nanoTime(); + + final double diffMillis = TimeUnit.NANOSECONDS.toMillis(stopTime - startTime); + final double byteCount = iterations * ((byteBuffer.position() / diffMillis) * 1e3); + LOGGER.atInfo().addArgument(SerialiserQuickBenchmark.humanReadableByteCount((long) byteCount, true)) // + .addArgument(SerialiserQuickBenchmark.humanReadableByteCount(byteBuffer.position(), true)) // + .addArgument(diffMillis) // + .log("IO Serializer (custom) throughput = {}/s for {} per test run (took {} ms)"); + } + + public static void testPerformancePojo(final int iterations, final TestDataClass inputObject, TestDataClass outputObject) { + binarySerialiser.setPutFieldMetaData(true); + final long startTime = System.nanoTime(); + for (int i = 0; i < iterations; i++) { + if (i == 1) { + // only stream meta-data the first iteration + binarySerialiser.setPutFieldMetaData(false); + } + byteBuffer.reset(); + ioSerialiser.serialiseObject(inputObject); + + byteBuffer.reset(); + + outputObject = ioSerialiser.deserialiseObject(outputObject); + + if (!inputObject.string1.contentEquals(outputObject.string1)) { + // quick check necessary so that the above is not optimised by the Java JIT compiler to NOP + throw new IllegalStateException("data mismatch"); + } + } + if (iterations <= 1) { + // JMH use-case + return; + } + final long stopTime = System.nanoTime(); + + final double diffMillis = TimeUnit.NANOSECONDS.toMillis(stopTime - startTime); + final double byteCount = iterations * ((byteBuffer.position() / diffMillis) * 1e3); + LOGGER.atInfo().addArgument(SerialiserQuickBenchmark.humanReadableByteCount((long) byteCount, true)) // + .addArgument(SerialiserQuickBenchmark.humanReadableByteCount(byteBuffer.position(), true)) // + .addArgument(diffMillis) // + .log("IO Serializer (POJO) throughput = {}/s for {} per test run (took {} ms)"); + } + + public static WireDataFieldDescription testSerialiserPerformanceMap(final int iterations, final TestDataClass inputObject) { + final long startTime = System.nanoTime(); + + WireDataFieldDescription ret = null; + for (int i = 0; i < iterations; i++) { + byteBuffer.reset(); + SerialiserHelper.serialiseCustom(binarySerialiser, inputObject); + byteBuffer.reset(); + ret = SerialiserHelper.deserialiseMap(binarySerialiser); + + if (ret.getDataSize() == 0) { + // quick check necessary so that the above is not optimised by the Java JIT compiler to NOP + throw new IllegalStateException("data mismatch"); + } + } + if (iterations <= 1) { + // JMH use-case + return ret; + } + + final long stopTime = System.nanoTime(); + + final double diffMillis = TimeUnit.NANOSECONDS.toMillis(stopTime - startTime); + final double byteCount = iterations * ((byteBuffer.position() / diffMillis) * 1e3); + LOGGER.atInfo().addArgument(SerialiserQuickBenchmark.humanReadableByteCount((long) byteCount, true)) // + .addArgument(SerialiserQuickBenchmark.humanReadableByteCount(byteBuffer.position(), true)) // + .addArgument(diffMillis) // + .log("IO Serializer (Map only) throughput = {}/s for {} per test run (took {} ms)"); + return ret; + } + + private static WireDataFieldDescription getFieldHeader(IoSerialiser ioSerialiser) { + WireDataFieldDescription field = ioSerialiser.getFieldHeader(); + ioSerialiser.getBuffer().position(field.getDataStartPosition()); + return field; + } +} diff --git a/serialiser/src/test/java/io/opencmw/serialiser/utils/TestDataClass.java b/serialiser/src/test/java/io/opencmw/serialiser/utils/TestDataClass.java new file mode 100644 index 00000000..dfa2f013 --- /dev/null +++ b/serialiser/src/test/java/io/opencmw/serialiser/utils/TestDataClass.java @@ -0,0 +1,488 @@ +package io.opencmw.serialiser.utils; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; + +import java.util.Arrays; +import java.util.Objects; + +import org.opentest4j.AssertionFailedError; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@SuppressWarnings("PMD") // complexity is part of the very large use-case surface that is being tested +public class TestDataClass { + private static final Logger LOGGER = LoggerFactory.getLogger(TestDataClass.class); + private static transient boolean cmwCompatibilityMode = false; + + public boolean bool1; + public boolean bool2; + public byte byte1; + public byte byte2; + public char char1; + public char char2; + public short short1; + public short short2; + public int int1; + public int int2; + public long long1; + public long long2; + public float float1; + public float float2; + public double double1; + public double double2; + public String string1; + public String string2; + + // 1-dim arrays + public boolean[] boolArray; + public byte[] byteArray; + // public char[] charArray; + public short[] shortArray; + public int[] intArray; + public long[] longArray; + public float[] floatArray; + public double[] doubleArray; + public String[] stringArray; + + // generic n-dim arrays - N.B. striding-arrays: low-level format is the same except of 'nDimension' descriptor + public int[] nDimensions; + public boolean[] boolNdimArray; + public byte[] byteNdimArray; + // public char[] charNdimArray; + public short[] shortNdimArray; + public int[] intNdimArray; + public long[] longNdimArray; + public float[] floatNdimArray; + public double[] doubleNdimArray; + + public TestDataClass nestedData; + + public TestDataClass() { + this(-1, -1, -1); + } + + /** + * @param nSizePrimitives size of primitive arrays (smaller 0: do not initialise fields/allocate arrays) + * @param nSizeString size of String[] array (smaller 0: do not initialise fields/allocate arrays) + * @param nestedClassRecursion how many nested sub-classes should be allocated + */ + public TestDataClass(final int nSizePrimitives, final int nSizeString, final int nestedClassRecursion) { + if (nestedClassRecursion > 0) { + nestedData = new TestDataClass(nSizePrimitives, nSizeString, nestedClassRecursion - 1); + nestedData.init(nSizePrimitives + 1, nSizeString + 1); //N.B. '+1' to have different sizes for nested classes + } + + init(nSizePrimitives, nSizeString); + } + + public final void clear() { + bool1 = false; + bool2 = false; + byte1 = 0; + byte2 = 0; + char1 = 0; + char2 = 0; + short1 = 0; + short2 = 0; + int1 = 0; + int2 = 0; + long1 = 0; + long2 = 0; + float1 = 0; + float2 = 0; + double1 = 0; + double2 = 0; + + string1 = null; + string2 = null; + + // reset 1-dim arrays + boolArray = null; + byteArray = null; + // charArray = null; + shortArray = null; + intArray = null; + longArray = null; + floatArray = null; + doubleArray = null; + stringArray = null; + + // reset n-dim arrays + nDimensions = null; + boolNdimArray = null; + byteNdimArray = null; + // charNdimArray = null; + shortNdimArray = null; + intNdimArray = null; + longNdimArray = null; + floatNdimArray = null; + doubleNdimArray = null; + + nestedData = null; + } + + @Override + public boolean equals(final Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof TestDataClass)) { + LOGGER.atError().addArgument(obj).log("incompatible object type of obj = '{}'"); + return false; + } + final TestDataClass other = (TestDataClass) obj; + boolean returnState = true; + if (this.bool1 != other.bool1) { + LOGGER.atError().addArgument("bool1").addArgument(this.bool1).addArgument(other.bool1) // + .log("field '{}' does not match '{}' vs '{}'"); + returnState = false; + } + if (this.bool2 != other.bool2) { + LOGGER.atError().addArgument("bool2").addArgument(this.bool2).addArgument(other.bool2) // + .log("field '{}' does not match '{}' vs '{}'"); + returnState = false; + } + if (this.byte1 != other.byte1) { + LOGGER.atError().addArgument("byte1").addArgument(this.byte1).addArgument(other.byte1) // + .log("field '{}' does not match '{}' vs '{}'"); + returnState = false; + } + if (this.byte2 != other.byte2) { + LOGGER.atError().addArgument("byte2").addArgument(this.byte2).addArgument(other.byte2) // + .log("field '{}' does not match '{}' vs '{}'"); + returnState = false; + } + if (this.char1 != other.char1) { + LOGGER.atError().addArgument("char1").addArgument(this.char1).addArgument(other.char1) // + .log("field '{}' does not match '{}' vs '{}'"); + returnState = false; + } + if (this.char2 != other.char2) { + LOGGER.atError().addArgument("char2").addArgument(this.char2).addArgument(other.char2) // + .log("field '{}' does not match '{}' vs '{}'"); + returnState = false; + } + if (this.short1 != other.short1) { + LOGGER.atError().addArgument("short1").addArgument(this.short1).addArgument(other.short1) // + .log("field '{}' does not match '{}' vs '{}'"); + returnState = false; + } + if (this.short2 != other.short2) { + LOGGER.atError().addArgument("short2").addArgument(this.short2).addArgument(other.short2) // + .log("field '{}' does not match '{}' vs '{}'"); + returnState = false; + } + if (this.int1 != other.int1) { + LOGGER.atError().addArgument("int1").addArgument(this.int1).addArgument(other.int1) // + .log("field '{}' does not match '{}' vs '{}'"); + returnState = false; + } + if (this.int2 != other.int2) { + LOGGER.atError().addArgument("int2").addArgument(this.int2).addArgument(other.int2) // + .log("field '{}' does not match '{}' vs '{}'"); + returnState = false; + } + if (this.float1 != other.float1) { + LOGGER.atError().addArgument("float1").addArgument(this.float1).addArgument(other.float1) // + .log("field '{}' does not match '{}' vs '{}'"); + returnState = false; + } + if (this.float2 != other.float2) { + LOGGER.atError().addArgument("float2").addArgument(this.float2).addArgument(other.float2) // + .log("field '{}' does not match '{}' vs '{}'"); + returnState = false; + } + if (this.double1 != other.double1) { + LOGGER.atError().addArgument("double1").addArgument(this.double1).addArgument(other.double1) // + .log("field '{}' does not match '{}' vs '{}'"); + returnState = false; + } + if (this.double2 != other.double2) { + LOGGER.atError().addArgument("double2").addArgument(this.double2).addArgument(other.double2) // + .log("field '{}' does not match '{}' vs '{}'"); + returnState = false; + } + if (!Objects.equals(string1, other.string1)) { + LOGGER.atError().addArgument("string1").addArgument(this.string1).addArgument(other.string1) // + .log("field '{}' does not match '{}' vs '{}'"); + returnState = false; + } + if (!isCmwCompatibilityMode()) { + if (!Objects.equals(string2, other.string2)) { + LOGGER.atError().addArgument("string2").addArgument(this.string2).addArgument(other.string2) // + .log("field '{}' does not match '{}' vs '{}'"); + returnState = false; + } + } + + // test 1D-arrays + try { + assertArrayEquals(this.boolArray, other.boolArray); + } catch (AssertionFailedError e) { + LOGGER.atError().addArgument("boolArray").addArgument(e.getMessage()).log("field '{}' does not match '{}'"); + returnState = false; + } + try { + assertArrayEquals(this.byteArray, other.byteArray); + } catch (AssertionFailedError e) { + LOGGER.atError().addArgument("byteArray").addArgument(e.getMessage()).log("field '{}' does not match '{}'"); + returnState = false; + } + //try { + // assertArrayEquals(this.charArray, other.charArray); + //} catch(AssertionFailedError e) { + // LOGGER.atError().addArgument("charArray").addArgument(e.getMessage()).log("field '{}' does not match '{}'"); + // returnState = false; + //} + try { + assertArrayEquals(this.shortArray, other.shortArray); + } catch (AssertionFailedError e) { + LOGGER.atError().addArgument("shortArray").addArgument(e.getMessage()).log("field '{}' does not match '{}'"); + returnState = false; + } + try { + assertArrayEquals(this.intArray, other.intArray); + } catch (AssertionFailedError e) { + LOGGER.atError().addArgument("intArray").addArgument(e.getMessage()).log("field '{}' does not match '{}'"); + returnState = false; + } + try { + assertArrayEquals(this.longArray, other.longArray); + } catch (AssertionFailedError e) { + LOGGER.atError().addArgument("longArray").addArgument(e.getMessage()).log("field '{}' does not match '{}'"); + returnState = false; + } + try { + assertArrayEquals(this.floatArray, other.floatArray); + } catch (AssertionFailedError e) { + LOGGER.atError().addArgument("floatArray").addArgument(e.getMessage()).log("field '{}' does not match '{}'"); + returnState = false; + } + try { + assertArrayEquals(this.doubleArray, other.doubleArray); + } catch (AssertionFailedError e) { + LOGGER.atError().addArgument("doubleArray").addArgument(e.getMessage()).log("field '{}' does not match '{}'"); + returnState = false; + } + try { + assertArrayEquals(this.stringArray, other.stringArray); + } catch (AssertionFailedError e) { + LOGGER.atError().addArgument("doubleArray").addArgument(e.getMessage()).log("field '{}' does not match '{}'"); + returnState = false; + } + + // test n-dimensional -arrays + try { + assertArrayEquals(this.nDimensions, other.nDimensions); + } catch (AssertionFailedError e) { + LOGGER.atError().addArgument("nDimensions").addArgument(e.getMessage()).log("field '{}' does not match '{}'"); + returnState = false; + } + try { + assertArrayEquals(this.boolNdimArray, other.boolNdimArray); + } catch (AssertionFailedError e) { + LOGGER.atError().addArgument("boolNdimArray").addArgument(e.getMessage()).log("field '{}' does not match '{}'"); + returnState = false; + } + try { + assertArrayEquals(this.byteNdimArray, other.byteNdimArray); + } catch (AssertionFailedError e) { + LOGGER.atError().addArgument("byteNdimArray").addArgument(e.getMessage()).log("field '{}' does not match '{}'"); + returnState = false; + } + //try { + // assertArrayEquals(this.charNdimArray, other.charNdimArray); + //} catch(AssertionFailedError e) { + // LOGGER.atError().addArgument("charNdimArray").addArgument(e.getMessage()).log("field '{}' does not match '{}'"); + // returnState = false; + //} + try { + assertArrayEquals(this.shortNdimArray, other.shortNdimArray); + } catch (AssertionFailedError e) { + LOGGER.atError().addArgument("shortNdimArray").addArgument(e.getMessage()).log("field '{}' does not match '{}'"); + returnState = false; + } + try { + assertArrayEquals(this.intNdimArray, other.intNdimArray); + } catch (AssertionFailedError e) { + LOGGER.atError().addArgument("intNdimArray").addArgument(e.getMessage()).log("field '{}' does not match '{}'"); + returnState = false; + } + try { + assertArrayEquals(this.longNdimArray, other.longNdimArray); + } catch (AssertionFailedError e) { + LOGGER.atError().addArgument("longNdimArray").addArgument(e.getMessage()).log("field '{}' does not match '{}'"); + returnState = false; + } + try { + assertArrayEquals(this.floatNdimArray, other.floatNdimArray); + } catch (AssertionFailedError e) { + LOGGER.atError().addArgument("floatNdimArray").addArgument(e.getMessage()).log("field '{}' does not match '{}'"); + returnState = false; + } + try { + assertArrayEquals(this.doubleNdimArray, other.doubleNdimArray); + } catch (AssertionFailedError e) { + LOGGER.atError().addArgument("doubleNdimArray").addArgument(e.getMessage()).log("field '{}' does not match '{}'"); + returnState = false; + } + + // check for nested data content + if (this.nestedData != null) { + returnState = returnState & this.nestedData.equals(other.nestedData); + } else if (other.nestedData != null) { + LOGGER.atError().addArgument("nestedData").addArgument(this.nestedData).addArgument(other.nestedData).log("field '{}' error: this.nestedData == null ({}) && other.nestedData != null ({})"); + returnState = false; + } + + return returnState; + } + + @Override + public int hashCode() { + int result = Objects.hash(bool1, bool2, byte1, byte2, char1, char2, short1, short2, int1, int2, long1, long2, float1, float2, double1, double2, string1, string2, nestedData); + result = 31 * result + Arrays.hashCode(boolArray); + result = 31 * result + Arrays.hashCode(byteArray); + result = 31 * result + Arrays.hashCode(shortArray); + result = 31 * result + Arrays.hashCode(intArray); + result = 31 * result + Arrays.hashCode(longArray); + result = 31 * result + Arrays.hashCode(floatArray); + result = 31 * result + Arrays.hashCode(doubleArray); + result = 31 * result + Arrays.hashCode(stringArray); + result = 31 * result + Arrays.hashCode(nDimensions); + result = 31 * result + Arrays.hashCode(boolNdimArray); + result = 31 * result + Arrays.hashCode(byteNdimArray); + result = 31 * result + Arrays.hashCode(shortNdimArray); + result = 31 * result + Arrays.hashCode(intNdimArray); + result = 31 * result + Arrays.hashCode(longNdimArray); + result = 31 * result + Arrays.hashCode(floatNdimArray); + result = 31 * result + Arrays.hashCode(doubleNdimArray); + return result; + } + + public final void init(final int nSizePrimitives, final int nSizeString) { + if (nSizePrimitives >= 0) { + bool1 = true; + bool2 = false; + byte1 = 10; + byte2 = -100; + char1 = 'a'; + char2 = 'Z'; + short1 = 20; + short2 = -200; + int1 = 30; + int2 = -300; + long1 = 40; + long2 = -400; + float1 = 50.5f; + float2 = -500.5f; + double1 = 60.6; + double2 = -600.6; + + string1 = "Hello World!"; + string2 = "Γειά σου Κόσμε!"; + + // allocate 1-dim arrays + boolArray = getBooleanEnumeration(0, nSizePrimitives); + byteArray = getByteEnumeration(1, nSizePrimitives + 1); + // charArray = getCharEnumeration(2, nSizePrimitives + 2); + shortArray = getShortEnumeration(3, nSizePrimitives + 3); + intArray = getIntEnumeration(4, nSizePrimitives + 4); + longArray = getLongEnumeration(5, nSizePrimitives + 5); + floatArray = getFloatEnumeration(6, nSizePrimitives + 6); + doubleArray = getDoubleEnumeration(7, nSizePrimitives + 7); + + // allocate n-dim arrays -- N.B. for simplicity the dimension/low-level backing size is const + + nDimensions = new int[] { 2, 3, 2 }; + final int nMultiDim = nDimensions[0] * nDimensions[1] * nDimensions[2]; + boolNdimArray = getBooleanEnumeration(0, nMultiDim); + byteNdimArray = getByteEnumeration(1, nMultiDim + 1); + // charNdimArray = getCharEnumeration(2, nMultiDim + 2); + shortNdimArray = getShortEnumeration(3, nMultiDim + 3); + intNdimArray = getIntEnumeration(4, nMultiDim + 4); + longNdimArray = getLongEnumeration(5, nMultiDim + 5); + floatNdimArray = getFloatEnumeration(6, nMultiDim + 6); + doubleNdimArray = getDoubleEnumeration(7, nMultiDim + 7); + } + + if (nSizeString >= 0) { + stringArray = new String[nSizeString]; + for (int i = 0; i < nSizeString; ++i) { + stringArray[i] = string1; + } + } + } + + public static boolean isCmwCompatibilityMode() { + return cmwCompatibilityMode; + } + + public static void setCmwCompatibilityMode(final boolean cmwCompatibilityMode) { + TestDataClass.cmwCompatibilityMode = cmwCompatibilityMode; + } + + private static boolean[] getBooleanEnumeration(final int from, final int to) { + final boolean[] ret = new boolean[to - from]; + for (int i = from; i < to; i++) { + ret[i - from] = i % 2 == 0; + } + return ret; + } + + private static byte[] getByteEnumeration(final int from, final int to) { + final byte[] ret = new byte[to - from]; + for (int i = from; i < to; i++) { + ret[i - from] = (byte) i; + } + return ret; + } + + private static char[] getCharEnumeration(final int from, final int to) { + final char[] ret = new char[to - from]; + for (int i = from; i < to; i++) { + ret[i - from] = (char) i; + } + return ret; + } + + private static double[] getDoubleEnumeration(final int from, final int to) { + final double[] ret = new double[to - from]; + for (int i = from; i < to; i++) { + ret[i - from] = i / 10f; + } + return ret; + } + + private static float[] getFloatEnumeration(final int from, final int to) { + final float[] ret = new float[to - from]; + for (int i = from; i < to; i++) { + ret[i - from] = i / 10f; + } + return ret; + } + + private static int[] getIntEnumeration(final int from, final int to) { + final int[] ret = new int[to - from]; + for (int i = from; i < to; i++) { + ret[i - from] = i; + } + return ret; + } + + private static long[] getLongEnumeration(final int from, final int to) { + final long[] ret = new long[to - from]; + for (int i = from; i < to; i++) { + ret[i - from] = i; + } + return ret; + } + + private static short[] getShortEnumeration(final int from, final int to) { + final short[] ret = new short[to - from]; + for (int i = from; i < to; i++) { + ret[i - from] = (short) i; + } + return ret; + } +} diff --git a/serialiser/src/test/resources/simplelogger.properties b/serialiser/src/test/resources/simplelogger.properties new file mode 100644 index 00000000..a01ef764 --- /dev/null +++ b/serialiser/src/test/resources/simplelogger.properties @@ -0,0 +1,50 @@ +# SLF4J's SimpleLogger configuration file +# Simple implementation of Logger that sends all enabled log messages, for all defined loggers, to System.err. + +# Default logging detail level for all instances of SimpleLogger. +# Must be one of ("trace", "debug", "info", "warn", or "error"). +# If not specified, defaults to "info". +org.slf4j.simpleLogger.defaultLogLevel=debug + +# The output target which can be the path to a file, or the special values "System.out" and "System.err". +# Default is "System.err". +org.slf4j.simpleLogger.logFile=System.out + +# If the output target is set to "System.out" or "System.err" (see preceding entry), by default, +# logs will be output to the latest value referenced by System.out/err variables. +# By setting this parameter to true, the output stream will be cached, i.e. assigned once at initialization +# time and re-used independently of the current value referenced by System.out/err. +org.slf4j.simpleLogger.cacheOutputStream=true + +# Logging detail level for a SimpleLogger instance named "a.b.c". Right-side value must be one of +# "trace", "debug", "info", "warn", "error" or "off". When a SimpleLogger named "a.b.c" is initialized, +# its level is assigned from this property. If unspecified, the level of nearest parent logger will be used, +# and if none is set, then the value specified by org.slf4j.simpleLogger.defaultLogLevel will be used. +org.slf4j.simpleLogger.log.de.gsi.* + +# Logging detail level for a SimpleLogger instance named "xxxxx". +# Must be one of ("trace", "debug", "info", "warn", or "error"). +# If not specified, the default logging detail level is used. +#org.slf4j.simpleLogger.log.xxxxx= + +# Set to true if you want the current date and time to be included in output messages. +# Default is false, and will output the number of milliseconds elapsed since startup. +#org.slf4j.simpleLogger.showDateTime=false + +# The date and time format to be used in the output messages. +# The pattern describing the date and time format is the same that is used in java.text.SimpleDateFormat. +# If the format is not specified or is invalid, the default format is used. +# The default format is yyyy-MM-dd HH:mm:ss:SSS Z. +#org.slf4j.simpleLogger.dateTimeFormat=yyyy-MM-dd HH:mm:ss:SSS Z + +# Set to true if you want to output the current thread name. +# Defaults to true. +# org.slf4j.simpleLogger.showThreadName=false + +# Set to true if you want the Logger instance name to be included in output messages. +# Defaults to true. +#org.slf4j.simpleLogger.showLogName=true + +# Set to true if you want the last component of the name to be included in output messages. +# Defaults to false. +# org.slf4j.simpleLogger.showShortLogName=true \ No newline at end of file diff --git a/server-rest/pom.xml b/server-rest/pom.xml new file mode 100644 index 00000000..99a8d51a --- /dev/null +++ b/server-rest/pom.xml @@ -0,0 +1,122 @@ + + + 4.0.0 + + + io.opencmw + opencmw + ${revision}${sha1}${changelist} + ../pom.xml + + + server-rest + + + OpenCMW RESTful plugin extension to micro-service implementation. + + + + + io.opencmw + server + ${revision}${sha1}${changelist} + + + de.gsi.dataset + chartfx-dataset + ${version.chartfx} + test + + + + + io.javalin + javalin + ${version.javalin} + + + ch.qos.logback + logback-classic + + + org.jetbrains.kotlin + kotlin-stdlib-jdk8 + + + cc.vileda + kotlin-openapi3-dsl + + + io.github.classgraph + classgraph + + + com.fasterxml.jackson.core + jackson-databind + + + + + io.javalin + javalin-openapi + ${version.javalin} + + + + org.eclipse.jetty.http2 + http2-server + ${version.jetty} + + + org.eclipse.jetty + jetty-alpn-conscrypt-server + ${version.jetty} + + + + + org.apache.velocity + velocity-engine-core + ${version.velocity} + + + org.mindrot + jbcrypt + 0.4 + + + + + com.jsoniter + jsoniter + 0.9.23 + + + io.micrometer + micrometer-core + ${version.micrometer} + + + io.micrometer + micrometer-registry-prometheus + ${version.micrometer} + + + + + com.squareup.okhttp3 + okhttp + ${version.okHttp3} + test + + + com.squareup.okhttp3 + okhttp-sse + ${version.okHttp3} + test + + + + \ No newline at end of file diff --git a/server-rest/src/main/java/io/opencmw/server/rest/MajordomoRestPlugin.java b/server-rest/src/main/java/io/opencmw/server/rest/MajordomoRestPlugin.java new file mode 100644 index 00000000..c9ff4104 --- /dev/null +++ b/server-rest/src/main/java/io/opencmw/server/rest/MajordomoRestPlugin.java @@ -0,0 +1,491 @@ +package io.opencmw.server.rest; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import static io.javalin.apibuilder.ApiBuilder.get; +import static io.javalin.apibuilder.ApiBuilder.post; +import static io.javalin.plugin.openapi.dsl.DocumentedContentKt.anyOf; +import static io.javalin.plugin.openapi.dsl.DocumentedContentKt.documentedContent; +import static io.opencmw.OpenCmwProtocol.Command.GET_REQUEST; +import static io.opencmw.OpenCmwProtocol.Command.READY; +import static io.opencmw.OpenCmwProtocol.Command.SET_REQUEST; +import static io.opencmw.OpenCmwProtocol.Command.UNKNOWN; +import static io.opencmw.OpenCmwProtocol.EMPTY_FRAME; +import static io.opencmw.OpenCmwProtocol.MdpMessage; +import static io.opencmw.OpenCmwProtocol.MdpMessage.receive; +import static io.opencmw.OpenCmwProtocol.MdpSubProtocol.PROT_CLIENT; +import static io.opencmw.OpenCmwProtocol.MdpSubProtocol.PROT_WORKER; +import static io.opencmw.server.MajordomoBroker.INTERNAL_ADDRESS_PUBLISHER; +import static io.opencmw.server.MmiServiceHelper.INTERNAL_SERVICE_NAMES; +import static io.opencmw.server.MmiServiceHelper.INTERNAL_SERVICE_OPENAPI; +import static io.opencmw.server.rest.RestServer.prefixPath; +import static io.opencmw.server.rest.util.CombinedHandler.SseState.CONNECTED; +import static io.opencmw.server.rest.util.CombinedHandler.SseState.DISCONNECTED; + +import java.lang.reflect.ParameterizedType; +import java.net.ProtocolException; +import java.net.URI; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.BiConsumer; +import java.util.function.Predicate; + +import javax.validation.constraints.NotNull; + +import org.apache.commons.lang3.StringUtils; +import org.eclipse.jetty.util.BlockingArrayQueue; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.zeromq.SocketType; +import org.zeromq.ZContext; +import org.zeromq.ZMQ; + +import io.javalin.apibuilder.ApiBuilder; +import io.javalin.core.security.Role; +import io.javalin.http.BadRequestResponse; +import io.javalin.http.Context; +import io.javalin.http.Handler; +import io.javalin.http.sse.SseClient; +import io.javalin.plugin.openapi.dsl.OpenApiBuilder; +import io.javalin.plugin.openapi.dsl.OpenApiDocumentation; +import io.opencmw.MimeType; +import io.opencmw.OpenCmwProtocol; +import io.opencmw.QueryParameterParser; +import io.opencmw.rbac.RbacRole; +import io.opencmw.serialiser.FieldDescription; +import io.opencmw.serialiser.annotations.MetaInfo; +import io.opencmw.serialiser.spi.ClassFieldDescription; +import io.opencmw.serialiser.utils.ClassUtils; +import io.opencmw.server.BasicMdpWorker; +import io.opencmw.server.MajordomoWorker; +import io.opencmw.server.rest.util.CombinedHandler; +import io.opencmw.server.rest.util.MessageBundle; +import io.opencmw.utils.CustomFuture; + +import com.jsoniter.output.JsonStream; + +/** + * Majordomo Broker REST/HTTP plugin. + * + * This opens two http ports and converts and forwards incoming request to the OpenCMW protocol and provides + * some basic admin functionality + * + *

+ * Server parameter can be controlled via the following system properties: + *

    + *
  • restServerHostName: host name or IP address the server should bind to + *
  • restServerPort: the HTTP port + *
  • restServerPort2: the HTTP/2 port (encrypted) + *
  • restKeyStore: the path to the file containing the key store for the encryption + *
  • restKeyStorePassword: the path to the file containing the key store for the encryption + *
  • restUserPasswordStore: the path to the file containing the user passwords and roles encryption + *
+ * @see RestServer for more details regarding the RESTful specific aspects + * + * @author rstein + */ +@MetaInfo(description = "Majordomo Broker REST/HTTP plugin.

" + + " This opens two http ports and converts and forwards incoming request to the OpenCMW protocol and provides
" + + " some basic admin functionality
", + unit = "MajordomoRestPlugin") +@SuppressWarnings({ "PMD.ExcessiveImports", "PMD.TooManyStaticImports", "PMD.DoNotUseThreads" }) // makes the code more readable/shorter lines +public class MajordomoRestPlugin extends BasicMdpWorker { + private static final Logger LOGGER = LoggerFactory.getLogger(MajordomoRestPlugin.class); + private static final byte[] RBAC = {}; // TODO: implement RBAC between Majordomo and Worker + private static final String TEMPLATE_EMBEDDED_HTML = "/velocity/property/defaultTextPropertyLayout.vm"; + private static final String TEMPLATE_BAD_REQUEST = "/velocity/errors/badRequest.vm"; + private static final AtomicLong REQUEST_COUNTER = new AtomicLong(); + protected final ZMQ.Socket subSocket; + protected final Map subscriptionCount = new ConcurrentHashMap<>(); + protected static final byte[] REST_SUB_ID = "REST_SUBSCRIPTION".getBytes(UTF_8); + protected final ConcurrentMap registeredEndpoints = new ConcurrentHashMap<>(); + private final BlockingArrayQueue requestQueue = new BlockingArrayQueue<>(); + private final ConcurrentMap> requestReplies = new ConcurrentHashMap<>(); + private final BiConsumer newSseClientHandler; + + public MajordomoRestPlugin(ZContext ctx, final String serverDescription, String httpAddress, final RbacRole... rbacRoles) { + super(ctx, MajordomoRestPlugin.class.getSimpleName(), rbacRoles); + assert (httpAddress != null); + RestServer.setName(Objects.requireNonNullElse(serverDescription, MajordomoRestPlugin.class.getName())); + subSocket = ctx.createSocket(SocketType.SUB); + subSocket.setHWM(0); + subSocket.connect(INTERNAL_ADDRESS_PUBLISHER); + subSocket.subscribe(INTERNAL_SERVICE_NAMES); + subscriptionCount.computeIfAbsent(INTERNAL_SERVICE_NAMES, s -> new AtomicInteger()).incrementAndGet(); + + newSseClientHandler = (client, state) -> { + final String queryString = client.ctx.queryString() == null ? "" : ("?" + client.ctx.queryString()); + final String subService = StringUtils.stripEnd(StringUtils.stripStart(client.ctx.path(), "/"), "/") + queryString; + LOGGER.atDebug().addArgument(state).addArgument(subService).addArgument(subscriptionCount.computeIfAbsent(subService, s -> new AtomicInteger()).get()).log("RestPlugin {} to '{}' - existing subscriber count: {}"); + if (state == CONNECTED && subscriptionCount.computeIfAbsent(subService, s -> new AtomicInteger()).incrementAndGet() == 1) { + subSocket.subscribe(subService); + } + if (state == DISCONNECTED && subscriptionCount.computeIfAbsent(subService, s -> new AtomicInteger()).decrementAndGet() <= 0) { + subSocket.unsubscribe(subService); + subscriptionCount.remove(subService); + } + }; + + // add default root - here: redirect to mmi.service + RestServer.getInstance().get("/", restCtx -> restCtx.redirect("/mmi.service"), RestServer.getDefaultRole()); + + registerHandler(getDefaultRequestHandler()); // NOPMD - one-time call OK + + LOGGER.atInfo().addArgument(MajordomoRestPlugin.class.getName()).addArgument(RestServer.getPublicURI()).log("{} started on address: {}"); + } + + @Override + public boolean notify(@NotNull final MdpMessage notifyMessage) { + assert notifyMessage != null : "notify message must not be null"; + notifyRaw(notifyMessage); + return false; + } + + @Override + public synchronized void start() { // NOPMD 'synchronized' comes from JDK class definition + final Thread dispatcher = new Thread(getDispatcherTask()); + dispatcher.setDaemon(true); + dispatcher.setName(MajordomoRestPlugin.class.getSimpleName() + "Dispatcher"); + dispatcher.start(); + + final Thread serviceListener = new Thread(getServiceSubscriptionTask()); + serviceListener.setDaemon(true); + serviceListener.setName(MajordomoRestPlugin.class.getSimpleName() + "Subscriptions"); + serviceListener.start(); + + // send subscription request for new service added notifications + super.start(); + + // perform initial get request + String services = "(uninitialised)"; + final CustomFuture reply = dispatchRequest(new MdpMessage(null, PROT_CLIENT, GET_REQUEST, INTERNAL_SERVICE_NAMES.getBytes(UTF_8), EMPTY_FRAME, URI.create(INTERNAL_SERVICE_NAMES), EMPTY_FRAME, "", RBAC), true); + try { + final MdpMessage msg = reply.get(); + services = msg.data == null ? "" : new String(msg.data, UTF_8); + Arrays.stream(StringUtils.split(services, ",:;")).forEach(this::registerEndPoint); + } catch (final Exception e) { // NOPMD -- erroneous worker replies shall not stop the broker + LOGGER.atError().setCause(e).addArgument(services).log("could not perform initial registering of endpoints {}"); + } + } + + protected static OpenCmwProtocol.Command getCommand(@NotNull final Context restCtx) { + switch (restCtx.method()) { + case "GET": + return GET_REQUEST; + case "POST": + return SET_REQUEST; + default: + if (LOGGER.isDebugEnabled()) { + LOGGER.atWarn().addArgument(restCtx.req).log("unknown request: {}"); + } + return UNKNOWN; + } + } + + protected RequestHandler getDefaultRequestHandler() { + return handler -> { + switch (handler.req.command) { + case PARTIAL: + case FINAL: + if (handler.req.clientRequestID.length == 0 || Arrays.equals(REST_SUB_ID, handler.req.clientRequestID)) { + handler.rep = null; // NOPMD needs to be 'null' to suppress message being further processed + break; + } + final String clientRequestID = new String(handler.req.clientRequestID, UTF_8); + final CustomFuture replyFuture = requestReplies.remove(clientRequestID); + if (replyFuture == null) { + LOGGER.atWarn().addArgument(clientRequestID).addArgument(handler.req).log("could not match clientRequestID '{}' to Future. msg was: {}"); + return; + } + if (handler.req.errors == null || handler.req.errors.isBlank()) { + replyFuture.setReply(handler.req); + } else { + // exception occurred - forward it + replyFuture.setException(new ProtocolException(handler.req.errors)); + } + handler.rep = null; // NOPMD needs to be 'null' to suppress message being further processed + return; + case W_NOTIFY: + final String serviceName = handler.req.getSenderName(); + final String topicName = handler.req.topic.toString(); + final long eventTimeStamp = System.currentTimeMillis(); + final String notifyMessage = "new '" + topicName + "' @" + eventTimeStamp; + final Queue sseClients = RestServer.getEventClients(serviceName); + sseClients.forEach((final SseClient client) -> client.sendEvent(notifyMessage)); + return; + case GET_REQUEST: + case SET_REQUEST: + case DISCONNECT: + case READY: + case SUBSCRIBE: + case UNSUBSCRIBE: + case W_HEARTBEAT: + case UNKNOWN: + default: + break; + } + }; + } + + protected Runnable getDispatcherTask() { + return () -> { + final Queue notifyCopy = new ArrayDeque<>(); + while (runSocketHandlerLoop.get() && !Thread.interrupted()) { + synchronized (requestQueue) { + try { + requestQueue.wait(); + if (!requestQueue.isEmpty()) { + notifyCopy.addAll(requestQueue); + requestQueue.clear(); + } + } catch (InterruptedException e) { + LOGGER.atWarn().setCause(e).log("Interrupted!"); + // restore interrupted state... + Thread.currentThread().interrupt(); + } + } + if (notifyCopy.isEmpty()) { + continue; + } + notifyCopy.forEach(this::notify); + notifyCopy.clear(); + } + }; + } + + protected Runnable getServiceSubscriptionTask() { // NOSONAR NOPMD - complexity is acceptable + return () -> { + try (ZMQ.Poller subPoller = ctx.createPoller(1)) { + subPoller.register(subSocket, ZMQ.Poller.POLLIN); + while (runSocketHandlerLoop.get() && !Thread.interrupted() && subPoller.poll(TimeUnit.MILLISECONDS.toMillis(100)) != -1) { + // handle message from or to broker + boolean dataReceived = true; + while (dataReceived) { + dataReceived = false; + // handle subscription message from or to broker + final MdpMessage brokerMsg = receive(subSocket, true); + if (brokerMsg != null) { + dataReceived = true; + liveness = HEARTBEAT_LIVENESS; + + // handle subscription message + if (brokerMsg.data != null && brokerMsg.getServiceName().startsWith(INTERNAL_SERVICE_NAMES)) { + registerEndPoint(new String(brokerMsg.data, UTF_8)); // NOPMD in-loop instantiation necessary + } + notifySubscribedClients(brokerMsg.topic); + } + } + } + } + }; + } + + @Override + protected void reconnectToBroker() { + super.reconnectToBroker(); + final byte[] classNameByte = this.getClass().getName().getBytes(UTF_8); // used for OpenAPI purposes + new MdpMessage(null, PROT_WORKER, READY, serviceBytes, EMPTY_FRAME, RestServer.getPublicURI(), classNameByte, "", RBAC).send(workerSocket); + new MdpMessage(null, PROT_WORKER, READY, serviceBytes, EMPTY_FRAME, RestServer.getLocalURI(), classNameByte, "", RBAC).send(workerSocket); + } + + protected void registerEndPoint(final String endpoint) { + synchronized (registeredEndpoints) { + // needs to be synchronised since Javalin get(..), put(..) seem to be not thread safe (usually initialised during startup) + registeredEndpoints.computeIfAbsent(endpoint, ep -> { + final MdpMessage requestMsg = new MdpMessage(null, PROT_CLIENT, GET_REQUEST, INTERNAL_SERVICE_OPENAPI.getBytes(UTF_8), EMPTY_FRAME, URI.create(INTERNAL_SERVICE_OPENAPI), ep.getBytes(UTF_8), "", RBAC); + final CustomFuture openApiReply = dispatchRequest(requestMsg, true); + try { + final MdpMessage serviceOpenApiData = openApiReply.get(); + if (!serviceOpenApiData.errors.isBlank()) { + LOGGER.atWarn().addArgument(ep).addArgument(serviceOpenApiData).log("received erroneous message for service '{}': {}"); + return null; + } + final String handlerClassName = new String(serviceOpenApiData.data, UTF_8); + + OpenApiDocumentation openApi = getOpenApiDocumentation(handlerClassName); + + final Set accessRoles = RestServer.getDefaultRole(); + RestServer.getInstance().routes(() -> { + ApiBuilder.before(ep, restCtx -> { + // for some strange reason this needs to be executed to be able to read 'restCtx.formParamMap()' + if ("POST".equals(restCtx.method())) { + final Map> map = restCtx.formParamMap(); + if (map.size() == 0) { + LOGGER.atDebug().addArgument(restCtx.req.getPathInfo()).log("{} called without form data"); + } + } + }); + post(ep + "*", OpenApiBuilder.documented(openApi, getDefaultServiceRestHandler(ep)), accessRoles); + get(ep + "*", OpenApiBuilder.documented(openApi, getDefaultServiceRestHandler(ep)), accessRoles); + }); + + return openApi; + } catch (final Exception e) { // NOPMD -- erroneous worker replies shall not stop the broker + LOGGER.atError().setCause(e).addArgument(ep).log("could not register endpoint {}"); + } + return null; + }); + } + } + + @org.jetbrains.annotations.NotNull + private OpenApiDocumentation getOpenApiDocumentation(final String handlerClassName) { + OpenApiDocumentation openApi = OpenApiBuilder.document(); + try { + final Class clazz = Class.forName(handlerClassName); + final ClassFieldDescription fieldDescription = ClassUtils.getFieldDescription(clazz); + openApi.operation(openApiOperation -> { + openApiOperation.description(fieldDescription.getFieldDescription() + " - " + handlerClassName); + openApiOperation.operationId("myOperationId"); + openApiOperation.summary(fieldDescription.getFieldUnit()); + openApiOperation.deprecated(false); + openApiOperation.addTagsItem("user"); + }); + + if (MajordomoWorker.class.isAssignableFrom(clazz)) { + // class is a MajordomoWorker derivative + final ParameterizedType genericSuperClass = (ParameterizedType) clazz.getGenericSuperclass(); + final Class ctxClass = (Class) genericSuperClass.getActualTypeArguments()[0]; + final Class inClass = (Class) genericSuperClass.getActualTypeArguments()[1]; + final Class outClass = (Class) genericSuperClass.getActualTypeArguments()[2]; + + final ClassFieldDescription ctxFilter = ClassUtils.getFieldDescription(ctxClass); + for (FieldDescription field : ctxFilter.getChildren()) { + ClassFieldDescription classField = (ClassFieldDescription) field; + openApi.queryParam(classField.getFieldName(), (Class) classField.getType()); + openApi.formParam(classField.getFieldName(), (Class) classField.getType(), false); // find definition for required or not + } + + openApi.body(anyOf(documentedContent(outClass), documentedContent(inClass))); + openApi.body(outClass).json("200", outClass); // JSON definition + openApi.html("200").result("demo output"); // HTML definition + + //TODO: continue here -- work in progress + } + + } catch (Exception e) { // NOPMD + LOGGER.atWarn().setCause(e).addArgument(handlerClassName).log("could not find class definition for {}"); + } + return openApi; + } + + private CustomFuture dispatchRequest(final MdpMessage requestMsg, boolean expectReply) { + final String requestID = MajordomoRestPlugin.class.getSimpleName() + "#" + REQUEST_COUNTER.getAndIncrement(); + requestMsg.clientRequestID = requestID.getBytes(UTF_8); + + if (expectReply) { + requestMsg.clientRequestID = requestID.getBytes(UTF_8); + } else { + requestMsg.clientRequestID = REST_SUB_ID; + } + CustomFuture reply = new CustomFuture<>(); + final Object ret = requestReplies.put(requestID, reply); + if (ret != null) { + LOGGER.atWarn().addArgument(requestID).addArgument(requestMsg.getServiceName()).log("duplicate request {} for service {}"); + } + + if (!requestQueue.offer(requestMsg)) { + throw new IllegalStateException("could not add MdpMessage to requestQueue: " + requestMsg); + } + synchronized (requestQueue) { + requestQueue.notifyAll(); + } + return reply; + } + + protected void notifySubscribedClients(final @NotNull URI topic) { + final String topicString = topic.toString(); + final String notifyPath = prefixPath(topic.getPath()); + // TODO: upgrade to path & query matching - for the time being only path @see also CombinedHandler + final Queue clients = RestServer.getEventClients(notifyPath); + final Predicate filter = c -> { + final String clientPath = StringUtils.stripEnd(c.ctx.path(), "/"); + return clientPath.length() >= notifyPath.length() && clientPath.startsWith(notifyPath); + }; + clients.stream().filter(filter).forEach(s -> s.sendEvent(topicString)); + } + + private Handler getDefaultServiceRestHandler(final String restHandler) { // NOSONAR NOPMD - complexity is acceptable + return new CombinedHandler(restCtx -> { + if (LOGGER.isTraceEnabled()) { + LOGGER.atTrace().addArgument(restHandler).addArgument(restCtx.path()).addArgument(restCtx.fullUrl()).log("restHandler {} for service {} - full: {}"); + } + + final String service = StringUtils.stripStart(Objects.requireNonNullElse(restCtx.path(), restHandler), "/"); + final MimeType acceptMimeType = MimeType.getEnum(restCtx.header(RestServer.HTML_ACCEPT)); + final Map parameterMap = restCtx.req.getParameterMap(); + final String[] mimeType = parameterMap.get("contentType"); + final URI topic = mimeType == null || mimeType.length == 0 ? RestServer.appendUri(URI.create(restCtx.fullUrl()), "contentType=" + acceptMimeType.toString()) : URI.create(restCtx.fullUrl()); + + OpenCmwProtocol.Command cmd = getCommand(restCtx); + final byte[] requestData; + if (cmd == SET_REQUEST) { + requestData = getFormDataAsJson(restCtx); + } else { + requestData = EMPTY_FRAME; + } + final MdpMessage requestMsg = new MdpMessage(null, PROT_CLIENT, cmd, service.getBytes(UTF_8), EMPTY_FRAME, topic, requestData, "", RBAC); + + CustomFuture reply = dispatchRequest(requestMsg, true); + try { + final MdpMessage replyMessage = reply.get(); //TODO: add max time-out -- only if not long-polling (to be checked) + final @NotNull MimeType replyMimeType = QueryParameterParser.getMimeType(replyMessage.topic.getQuery()); + switch (replyMimeType) { + case HTML: + case TEXT: + final String queryString = topic.getQuery() == null ? "" : ("?" + topic.getQuery()); + if (cmd == SET_REQUEST) { + final String path = restCtx.req.getRequestURI() + StringUtils.replace(queryString, "&noMenu", ""); + restCtx.redirect(path); + } else { + final boolean noMenu = queryString.contains("noMenu"); + Map dataMap = MessageBundle.baseModel(restCtx); + dataMap.put("textBody", new String(replyMessage.data, UTF_8)); + dataMap.put("noMenu", noMenu); + restCtx.render(TEMPLATE_EMBEDDED_HTML, dataMap); + } + break; + case BINARY: + default: + restCtx.contentType(replyMimeType.toString()); + restCtx.result(replyMessage.data); + + break; + } + + } catch (Exception e) { // NOPMD - exception is rethrown + switch (acceptMimeType) { + case HTML: + case TEXT: + Map dataMap = MessageBundle.baseModel(restCtx); + dataMap.put("service", restHandler); + dataMap.put("exceptionText", e); + restCtx.render(TEMPLATE_BAD_REQUEST, dataMap); + return; + default: + } + throw new BadRequestResponse(MajordomoRestPlugin.class.getName() + ": could not process service '" + service + "' - exception:\n" + e.getMessage()); // NOPMD original exception forwared within the text, BadRequestResponse does not support exception forwarding + } + }, newSseClientHandler); + } + + private byte[] getFormDataAsJson(final Context restCtx) { + final byte[] requestData; + final Map> formMap = restCtx.formParamMap(); + final HashMap requestMap = new HashMap<>(); + formMap.forEach((k, v) -> { + if (v.isEmpty()) { + requestMap.put(k, null); + } else { + requestMap.put(k, v.get(0)); + } + }); + final String formData = JsonStream.serialize(requestMap); + requestData = formData.getBytes(UTF_8); + return requestData; + } +} diff --git a/server-rest/src/main/java/io/opencmw/server/rest/RestCommonThreadPool.java b/server-rest/src/main/java/io/opencmw/server/rest/RestCommonThreadPool.java new file mode 100644 index 00000000..fc4de1f1 --- /dev/null +++ b/server-rest/src/main/java/io/opencmw/server/rest/RestCommonThreadPool.java @@ -0,0 +1,69 @@ +package io.opencmw.server.rest; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicInteger; + +@SuppressWarnings("PMD.DoNotUseThreads") // purpose of this class +public final class RestCommonThreadPool implements ThreadFactory { + private static final int MAX_THREADS = getDefaultThreadCount(); + private static final int MAX_SCHEDULED_THREADS = getDefaultScheduledThreadCount(); + private static final ThreadFactory DEFAULT_FACTORY = Executors.defaultThreadFactory(); + private static final RestCommonThreadPool SELF = new RestCommonThreadPool(); + private static final ExecutorService COMMON_POOL = Executors.newFixedThreadPool(MAX_THREADS, SELF); + private static final ScheduledExecutorService SCHEDULED_POOL = Executors.newScheduledThreadPool(MAX_SCHEDULED_THREADS, SELF); + private static final AtomicInteger THREAD_COUNTER = new AtomicInteger(); + + private RestCommonThreadPool() { + // helper class + } + + @Override + public Thread newThread(final Runnable r) { + final Thread thread = DEFAULT_FACTORY.newThread(r); + THREAD_COUNTER.incrementAndGet(); + thread.setName("RestCommonThreadPool#" + THREAD_COUNTER.intValue()); + thread.setDaemon(true); + return thread; + } + + public static ExecutorService getCommonPool() { + return COMMON_POOL; + } + + public static ScheduledExecutorService getCommonScheduledPool() { + return SCHEDULED_POOL; + } + + public static RestCommonThreadPool getInstance() { + return SELF; + } + + public static int getNumbersOfThreads() { + return MAX_THREADS; + } + + private static int getDefaultScheduledThreadCount() { + int nthreads = 32; + try { + nthreads = Integer.parseInt(System.getProperty("restScheduledThreadCount", "32")); + } catch (final NumberFormatException e) { + // malformed number + } + + return Math.max(32, nthreads); + } + + private static int getDefaultThreadCount() { + int nthreads = 32; + try { + nthreads = Integer.parseInt(System.getProperty("restThreadCount", "64")); + } catch (final NumberFormatException e) { + // malformed number + } + + return Math.max(32, nthreads); + } +} diff --git a/server-rest/src/main/java/io/opencmw/server/rest/RestRole.java b/server-rest/src/main/java/io/opencmw/server/rest/RestRole.java new file mode 100644 index 00000000..f91e9595 --- /dev/null +++ b/server-rest/src/main/java/io/opencmw/server/rest/RestRole.java @@ -0,0 +1,23 @@ +package io.opencmw.server.rest; + +import org.jetbrains.annotations.NotNull; + +import io.javalin.core.security.Role; +import io.opencmw.rbac.RbacRole; + +/** + * REST specific role adapter mapping of OpenCMW's RbacRole to Javalin's Role interface description + * @author rstein + */ +public class RestRole implements Role { + public final RbacRole rbacRole; + + public RestRole(@NotNull final RbacRole rbacRole) { + this.rbacRole = rbacRole; + } + + @Override + public String toString() { + return rbacRole.toString(); + } +} \ No newline at end of file diff --git a/server-rest/src/main/java/io/opencmw/server/rest/RestServer.java b/server-rest/src/main/java/io/opencmw/server/rest/RestServer.java new file mode 100644 index 00000000..5bf2bbde --- /dev/null +++ b/server-rest/src/main/java/io/opencmw/server/rest/RestServer.java @@ -0,0 +1,497 @@ +package io.opencmw.server.rest; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.*; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.function.Supplier; + +import javax.servlet.ServletOutputStream; + +import org.eclipse.jetty.alpn.server.ALPNServerConnectionFactory; +import org.eclipse.jetty.http2.HTTP2Cipher; +import org.eclipse.jetty.http2.server.HTTP2ServerConnectionFactory; +import org.eclipse.jetty.server.*; +import org.eclipse.jetty.server.session.SessionHandler; +import org.eclipse.jetty.util.ssl.SslContextFactory; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.javalin.Javalin; +import io.javalin.apibuilder.ApiBuilder; +import io.javalin.core.compression.Gzip; +import io.javalin.core.event.HandlerMetaInfo; +import io.javalin.core.security.Role; +import io.javalin.core.util.Header; +import io.javalin.core.util.RouteOverviewPlugin; +import io.javalin.http.Context; +import io.javalin.http.sse.SseClient; +import io.javalin.http.util.RateLimit; +import io.javalin.plugin.json.JavalinJson; +import io.javalin.plugin.metrics.MicrometerPlugin; +import io.javalin.plugin.openapi.OpenApiOptions; +import io.javalin.plugin.openapi.OpenApiPlugin; +import io.javalin.plugin.openapi.annotations.HttpMethod; +import io.javalin.plugin.openapi.ui.ReDocOptions; +import io.javalin.plugin.openapi.ui.SwaggerOptions; +import io.opencmw.MimeType; +import io.opencmw.rbac.BasicRbacRole; +import io.opencmw.rbac.RbacRole; +import io.opencmw.server.rest.admin.RestServerAdmin; +import io.opencmw.server.rest.login.LoginController; +import io.opencmw.server.rest.user.RestUserHandler; +import io.opencmw.server.rest.user.RestUserHandlerImpl; +import io.opencmw.server.rest.util.MessageBundle; +import io.swagger.v3.oas.models.info.Info; + +import com.jsoniter.JsonIterator; +import com.jsoniter.output.JsonStream; + +/** + * Small RESTful server helper class. + * + *

+ * The Javalin framework is being used internally: https://javalin.io/ + * + * The primary purposes of this utility class is to provide + *

    + *
  • some convenience methods, default configuration (in particular relating to SSL and HTTP/2), and + *
  • to wrap the primary REST server implementation in view of back-end server upgrades or changing API. + *
  • to provide every GET route also with an long-polling and SSE listener/data-retrieval management. + *
+ * + *

+ * Server parameter can be controlled via the following system properties: + *

    + *
  • restServerHostName: host name or IP address the server should bind to + *
  • restServerPort: the HTTP port + *
  • restServerPort2: the HTTP/2 port (encrypted) + *
  • restKeyStore: the path to the file containing the key store for the encryption + *
  • restKeyStorePassword: the path to the file containing the key store for the encryption + *
  • restUserPasswordStore: the path to the file containing the user passwords and roles encryption + *
+ * some design choices: minimise exposing Javalin API outside this class, no usage of UI specific classes (ie. JavaFX) + * + * @author rstein + */ +@SuppressWarnings("PMD.ExcessiveImports") +public final class RestServer { // NOPMD -- nomen est omen + public static final String TAG_REST_SERVER_HOST_NAME = "restServerHostName"; + public static final String TAG_REST_SERVER_PORT = "restServerPort"; + public static final String TAG_REST_SERVER_PORT2 = "restServerPort2"; + public static final String REST_KEY_STORE = "restKeyStore"; + public static final String REST_KEY_STORE_PASSWORD = "restKeyStorePassword"; + // some HTML constants + public static final String HTML_ACCEPT = "accept"; + private static final Logger LOGGER = LoggerFactory.getLogger(RestServer.class); + private static final String DEFAULT_HOST_NAME = "0"; + private static final int DEFAULT_PORT = 8080; + private static final int DEFAULT_PORT2 = 8443; + private static final String REST_PROTOCOL = "protocol"; + + private static final String TEMPLATE_UNAUTHORISED = "/velocity/errors/unauthorised.vm"; + private static final String TEMPLATE_ACCESS_DENIED = "/velocity/errors/accessDenied.vm"; + private static final String TEMPLATE_NOT_FOUND = "/velocity/errors/notFound.vm"; + private static final String TEMPLATE_BAD_REQUEST = "/velocity/errors/badRequest.vm"; + private static final ConcurrentMap> EVENT_LISTENER_SSE = new ConcurrentHashMap<>(); + private static final List ENDPOINTS = new ArrayList<>(); + private static final Consumer ENDPOINT_ADDED_HANDLER = ENDPOINTS::add; + private static Javalin instance; + private static MimeType defaultProtocol = MimeType.HTML; + private static RestUserHandler userHandler = new RestUserHandlerImpl(BasicRbacRole.NULL); // include basic Rbac role definition + private static String serverName = "Undefined REST Server"; + + private RestServer() { + // this is a utility class + } + + public static void addLongPollingCookie(final Context ctx, final String key, final long lastUpdateMillies) { + // N.B. this is a workaround since javax.servlet.http.Cookie does not support the SameSite cookie field. + // workaround inspired by: https://github.com/tipsy/javalin/issues/780 + final String cookieComment = "stores the servcer-side time stamp of the last valid update (required for long-polling)"; + final String cookie = key + "=" + lastUpdateMillies + "; Comment=\"" + cookieComment + "\"; Expires=-1; SameSite=Strict;"; + ctx.res.addHeader("Set-Cookie", cookie); + } + + public static URI appendUri(URI oldUri, String appendQuery) throws URISyntaxException { + return new URI(oldUri.getScheme(), oldUri.getAuthority(), oldUri.getPath(), + oldUri.getQuery() == null ? appendQuery : oldUri.getQuery() + "&" + appendQuery, oldUri.getFragment()); + } + + /** + * guards this end point and returns HTTP error response if predefined rate limit is exceeded + * + * @param ctx end point context handler + * @param numRequests number of calls + * @param timeUnit time base reference + */ + public static void applyRateLimit(final Context ctx, final int numRequests, final TimeUnit timeUnit) { + new RateLimit(ctx).requestPerTimeUnit(numRequests, timeUnit); // + } + + public static MimeType getDefaultProtocol() { + return defaultProtocol; + } + + public static Set getDefaultRole() { + return Collections.singleton(new RestRole(BasicRbacRole.ANYONE)); + } + + public static List getEndpoints() { + return ENDPOINTS; + } + + public static Queue getEventClients(@NotNull final String endpointName) { + if (endpointName.isEmpty()) { + throw new IllegalArgumentException("endpointNmae must not be empty"); + } + + final String fullEndPointName = prefixPath(endpointName); + return EVENT_LISTENER_SSE.computeIfAbsent(fullEndPointName, key -> new ConcurrentLinkedQueue<>()); + } + + public static ConcurrentMap> getEventClientMap() { + return EVENT_LISTENER_SSE; + } + + public static String getHostName() { + return System.getProperty(TAG_REST_SERVER_HOST_NAME, DEFAULT_HOST_NAME); + } + + public static int getHostPort() { + final String property = System.getProperty(TAG_REST_SERVER_PORT, Integer.toString(DEFAULT_PORT)); + try { + return Integer.parseInt(property); + } catch (final NumberFormatException e) { + LOGGER.atError().addArgument(TAG_REST_SERVER_PORT).addArgument(property).addArgument(DEFAULT_PORT).log("could not parse {}='{}' return default port {}"); + return DEFAULT_PORT; + } + } + + public static int getHostPort2() { + final String property = System.getProperty(TAG_REST_SERVER_PORT2, Integer.toString(DEFAULT_PORT2)); + try { + return Integer.parseInt(property); + } catch (final NumberFormatException e) { + LOGGER.atError().addArgument(TAG_REST_SERVER_PORT2).addArgument(property).addArgument(DEFAULT_PORT2).log("could not parse {}='{}' return default port {}"); + return DEFAULT_PORT2; + } + } + + public static Javalin getInstance() { + if (instance == null) { + startRestServer(); + } + return instance; + } + + public static URI getLocalURI() { + try { + return new URI("http://localhost:" + getHostPort()); + } catch (final URISyntaxException e) { + LOGGER.atError().setCause(e).log("getLocalURL()"); + } + return null; + } + + public static String getName() { + return serverName; + } + + public static URI getPublicURI() { + final String ip = getLocalHostName(); + try (DatagramSocket socket = new DatagramSocket()) { + return new URI("https://" + ip + ":" + getHostPort2()); + } catch (final URISyntaxException | SocketException e) { + LOGGER.atError().setCause(e).log("getPublicURL()"); + } + return null; + } + + public static MimeType getRequestedMimeProtocol(final Context ctx, final MimeType... defaultProtocol) { + return MimeType.getEnum(getRequestedProtocol(ctx, defaultProtocol.length == 0 ? getDefaultProtocol().toString() : defaultProtocol[0].toString())); + } + + public static String getRequestedProtocol(final Context ctx, final String... defaultProtocol) { + String protocol = defaultProtocol.length == 0 ? getDefaultProtocol().toString() : defaultProtocol[0]; + String protocolHeader = ctx.header(Header.ACCEPT); + String protocolQuery = ctx.queryParam(REST_PROTOCOL); + + if (protocolHeader != null && !protocolHeader.isBlank()) { + protocol = protocolHeader; + } + if (protocolQuery != null && !protocolQuery.isBlank()) { + protocol = protocolQuery; + } + + return protocol; + } + + public static Set getSessionCurrentRoles(final Context ctx) { + return LoginController.getSessionCurrentRoles(ctx); + } + + public static String getSessionCurrentUser(final Context ctx) { + return LoginController.getSessionCurrentUser(ctx); + } + + public static String getSessionLocale(final Context ctx) { + return LoginController.getSessionLocale(ctx); + } + + public static RestUserHandler getUserHandler() { + return userHandler; + } + + public static String prefixPath(@NotNull final String path) { + return ApiBuilder.prefixPath(path); + } + + public static void setDefaultProtocol(MimeType defaultProtocol) { + RestServer.defaultProtocol = defaultProtocol; + } + + public static void setName(final String serverName) { + RestServer.serverName = serverName; + } + + /** + * Sets a new user handler. + * + * N.B: This will issue a warning to remind system admins or security-minded people + * that the default implementation may have been replaced with a better/worse/different implementation (e.g. based on + * LDAP or another data base) + * + * @param newUserHandler the new implementation + */ + public static void setUserHandler(final RestUserHandler newUserHandler) { + LOGGER.atWarn().addArgument(newUserHandler.getClass().getCanonicalName()).log("replacing default user handler with '{}'"); + userHandler = newUserHandler; + } + + public static void startRestServer() { + JavalinJson.setFromJsonMapper(JsonIterator::deserialize); + JavalinJson.setToJsonMapper(JsonStream::serialize); + instance = Javalin.create(config -> { + config.enableCorsForAllOrigins(); + config.addStaticFiles("/public"); + config.showJavalinBanner = false; + config.defaultContentType = getDefaultProtocol().toString(); + config.compressionStrategy(null, new Gzip(6)); + config.server(RestServer::createHttp2Server); + // show all routes on specified path + config.registerPlugin(new RouteOverviewPlugin("/admin/endpoints", Collections.singleton(new RestRole(BasicRbacRole.ADMIN)))); + config.registerPlugin(new MicrometerPlugin()); + config.sessionHandler(getCustomSessionHandlerSupplier()); + // add OpenAPI + config.registerPlugin(new OpenApiPlugin(getOpenApiOptions())); + }) + .events(event -> event.handlerAdded(ENDPOINT_ADDED_HANDLER)); + instance.start(); + + // add login management + LoginController.register(); + + // add basic RestServer admin interface + RestServerAdmin.register(); + + // some default error mappings + instance.error(400, ctx -> ctx.render(TEMPLATE_BAD_REQUEST, MessageBundle.baseModel(ctx))); + instance.error(401, ctx -> ctx.render(TEMPLATE_UNAUTHORISED, MessageBundle.baseModel(ctx))); + instance.error(403, ctx -> ctx.render(TEMPLATE_ACCESS_DENIED, MessageBundle.baseModel(ctx))); + instance.error(404, ctx -> ctx.render(TEMPLATE_NOT_FOUND, MessageBundle.baseModel(ctx))); + } + + public static void startRestServer(final int hostPort, final int hostPort2) { + System.setProperty(TAG_REST_SERVER_PORT, Integer.toString(hostPort)); + System.setProperty(TAG_REST_SERVER_PORT2, Integer.toString(hostPort2)); + startRestServer(); + } + + public static void startRestServer(final String hostName, final int hostPort, final int hostPort2) { + System.setProperty(TAG_REST_SERVER_HOST_NAME, hostName); + System.setProperty(TAG_REST_SERVER_PORT, Integer.toString(hostPort)); + System.setProperty(TAG_REST_SERVER_PORT2, Integer.toString(hostPort2)); + startRestServer(); + } + + public static void stopRestServer() { + if (Objects.requireNonNull(RestServer.getInstance().server()).server().isRunning()) { + RestServer.getInstance().stop(); + } + } + + /** + * Suppresses caching for this end point + * + * @param ctx end point context handler + */ + public static void suppressCaching(final Context ctx) { + // for for HTTP 1.1 + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control + ctx.res.addHeader("Cache-Control", "no-store"); + + // for HTTP 1.0 + ctx.res.addHeader("Pragma", "no-cache"); + + // for proxies: may need to check an appropriate value + ctx.res.addHeader("Expires", "0"); + } + + public static void writeBytesToContext(@NotNull final Context ctx, final byte[] bytes, final int nSize) { + // based on the suggestions at https://github.com/tipsy/javalin/issues/910 + try (ServletOutputStream outputStream = ctx.res.getOutputStream()) { + outputStream.write(bytes, 0, nSize); + outputStream.flush(); + } catch (final IOException e) { + LOGGER.atError().setCause(e); + } + } + + private static Server createHttp2Server() { + final Server server = new Server(); + + // unencrypted HTTP 1 anchor + try (ServerConnector connector = new ServerConnector(server)) { + final String hostName = getHostName(); + final int hostPort = getHostPort(); + LOGGER.atInfo().addArgument(getLocalHostName()).log("local hostname = '{}'"); + LOGGER.atInfo().addArgument(hostName).addArgument(hostPort).log("create HTTP 1.x connector at 'http://{}:{}'"); + connector.setHost(hostName); + connector.setPort(hostPort); + server.addConnector(connector); + } + + // HTTP Configuration + final HttpConfiguration httpConfig = new HttpConfiguration(); + httpConfig.setSendServerVersion(false); + httpConfig.setSecureScheme("https"); + httpConfig.setSecurePort(getHostPort2()); + + // HTTPS Configuration + final HttpConfiguration httpsConfig = new HttpConfiguration(httpConfig); + httpsConfig.addCustomizer(new SecureRequestCustomizer()); + + // HTTP/2 Connection Factory + final HTTP2ServerConnectionFactory h2 = new HTTP2ServerConnectionFactory(httpsConfig); + final ALPNServerConnectionFactory alpn = new ALPNServerConnectionFactory(); + alpn.setDefaultProtocol("h2"); + + // SSL Connection Factory + final SslConnectionFactory ssl = new SslConnectionFactory(createSslContextFactory(), alpn.getProtocol()); + + // HTTP/2 Connector + try (ServerConnector http2Connector = new ServerConnector(server, ssl, alpn, h2, new HttpConnectionFactory(httpsConfig))) { + final String hostName = getHostName(); + final int hostPort = getHostPort2(); + LOGGER.atInfo().addArgument(hostName).addArgument(hostPort).log("create HTTP/2 connector at 'http://{}:{}'"); + http2Connector.setHost(hostName); + http2Connector.setPort(hostPort); + server.addConnector(http2Connector); + } + + return server; + } + + private static SslContextFactory createSslContextFactory() { + final String keyStoreFile = System.getProperty(REST_KEY_STORE, null); // replace default with your real keystore + final String keyStorePwdFile = System.getProperty(REST_KEY_STORE_PASSWORD, null); // replace default with your real password + if (keyStoreFile == null || keyStorePwdFile == null) { + LOGGER.atInfo().addArgument(keyStoreFile).addArgument(keyStorePwdFile).log("using internal keyStore {} and/or keyStorePasswordFile {} -- PLEASE CHANGE FOR PRODUCTION -- THIS IS UNSAFE PRACTICE"); + } + LOGGER.atInfo().addArgument(keyStoreFile).log("using keyStore at '{}'"); + LOGGER.atInfo().addArgument(keyStorePwdFile).log("using keyStorePasswordFile at '{}'"); + + boolean readComplete = true; + String keyStorePwd = null; + KeyStore keyStore = null; + + // read keyStore password + try (BufferedReader br = keyStorePwdFile == null ? new BufferedReader(new InputStreamReader(RestServer.class.getResourceAsStream("/keystore.pwd"), UTF_8)) // + : Files.newBufferedReader(Paths.get(keyStorePwdFile), UTF_8)) { + keyStorePwd = br.readLine(); + } catch (final IOException e) { + readComplete = false; + LOGGER.atError().setCause(e).addArgument(keyStorePwdFile).log("error while reading key store password from '{}'"); + } + + if (readComplete && keyStorePwd != null) { + // read the actual keyStore + try (InputStream is = keyStoreFile == null ? RestServer.class.getResourceAsStream("/keystore.jks") // + : Files.newInputStream(Paths.get(keyStoreFile))) { + keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + keyStore.load(is, keyStorePwd.toCharArray()); + } catch (final NoSuchAlgorithmException | CertificateException | KeyStoreException | IOException e) { + readComplete = false; + LOGGER.atError().setCause(e).addArgument(keyStoreFile == null ? "internal" : keyStoreFile).log("error while reading key store from '{}'"); + } + } + + // SSL Context Factory for HTTPS and HTTP/2 + //noinspection deprecation + final SslContextFactory sslContextFactory = new SslContextFactory(true) {}; // trust all certificates + if (readComplete) { + sslContextFactory.setKeyStore(keyStore); + sslContextFactory.setKeyStorePassword(keyStorePwd); + } + sslContextFactory.setCipherComparator(HTTP2Cipher.COMPARATOR); + sslContextFactory.setProvider("Conscrypt"); + + return sslContextFactory; + } + + /** + * + * @return custom session handler that sets Jetty's JSESSIONID cookie to SameSite=strict + * + * N.B. to be used within Javalin's 'config.sessionHandler(getCustomSessionHandlerSupplier());' + */ + private static Supplier getCustomSessionHandlerSupplier() { + final SessionHandler sessionHandler = new SessionHandler(); + sessionHandler.getSessionCookieConfig().setHttpOnly(true); + sessionHandler.getSessionCookieConfig().setSecure(true); + sessionHandler.getSessionCookieConfig().setComment("__SAME_SITE_STRICT__"); + return () -> sessionHandler; + } + + private static String getLocalHostName() { + String ip; + try (DatagramSocket socket = new DatagramSocket()) { + socket.connect(InetAddress.getByName("8.8.8.8"), 10_002); // NOPMD - bogus hardcoded IP acceptable in this context + if (socket.getLocalAddress() == null) { + throw new UnknownHostException("bogus exception can be ignored"); + } + ip = socket.getLocalAddress().getHostAddress(); + + if (ip != null) { + return ip; + } + } catch (final SocketException | UnknownHostException e) { + LOGGER.atError().setCause(e).log("getLocalHostName()"); + } + return "localhost"; + } + + private static OpenApiOptions getOpenApiOptions() { + Info applicationInfo = new Info().version("1.0").description(serverName); + return new OpenApiOptions(applicationInfo).path("/swagger-docs").ignorePath("/admin/endpoints", HttpMethod.GET) // Disable documentation + .swagger(new SwaggerOptions("/swagger").title("My Swagger Documentation")) + .reDoc(new ReDocOptions("/redoc").title("My ReDoc Documentation")); + } +} diff --git a/server-rest/src/main/java/io/opencmw/server/rest/admin/RestServerAdmin.java b/server-rest/src/main/java/io/opencmw/server/rest/admin/RestServerAdmin.java new file mode 100644 index 00000000..1ff5a7e5 --- /dev/null +++ b/server-rest/src/main/java/io/opencmw/server/rest/admin/RestServerAdmin.java @@ -0,0 +1,93 @@ +package io.opencmw.server.rest.admin; + +import static io.javalin.apibuilder.ApiBuilder.get; +import static io.javalin.apibuilder.ApiBuilder.post; + +import java.util.Collections; +import java.util.Map; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.javalin.http.Handler; +import io.javalin.plugin.openapi.annotations.OpenApi; +import io.javalin.plugin.openapi.annotations.OpenApiContent; +import io.javalin.plugin.openapi.annotations.OpenApiResponse; +import io.opencmw.rbac.BasicRbacRole; +import io.opencmw.rbac.RbacRole; +import io.opencmw.server.rest.RestRole; +import io.opencmw.server.rest.RestServer; +import io.opencmw.server.rest.login.LoginController; +import io.opencmw.server.rest.user.RestUserHandler; +import io.opencmw.server.rest.util.MessageBundle; + +/** + * Basic ResetServer admin interface + * @author rstein + */ +@SuppressWarnings("PMD.FieldNamingConventions") +public class RestServerAdmin { // NOPMD - nomen est omen + private static final Logger LOGGER = LoggerFactory.getLogger(RestServerAdmin.class); + private static final String ENDPOINT_ADMIN = "/admin"; + private static final String TEMPLATE_ADMIN = "/velocity/admin/admin.vm"; + + @OpenApi( + description = "endpoint to receive admin requests", + operationId = "serveAdminPage", + summary = "serve ", + tags = { "RestServerAdmin" }, + responses = { + @OpenApiResponse(status = "200", content = @OpenApiContent(type = "text/html")) + }) + private static final Handler serveAdminPage + = ctx -> { + final String userName = LoginController.getSessionCurrentUser(ctx); + final Set roles = LoginController.getSessionCurrentRoles(ctx); + if (!roles.contains(BasicRbacRole.ADMIN)) { + LOGGER.atWarn().addArgument(userName).log("user '{}' does not have the required admin access rights"); + ctx.status(401).result("admin access denied"); + return; + } + RestUserHandler userHandler = RestServer.getUserHandler(); + final Map model = MessageBundle.baseModel(ctx); + model.put("userHandler", userHandler); + model.put("users", userHandler.getAllUserNames()); + model.put("endpoints", RestServer.getEndpoints()); + + ctx.render(TEMPLATE_ADMIN, model); + }; + + @OpenApi( + description = "endpoint to receive admin requests", + operationId = "handleAdminPost", + summary = "POST ", + tags = { "RestServerAdmin" }, + responses = { + @OpenApiResponse(status = "200", content = @OpenApiContent(type = "text/html")) + }) + private static final Handler handleAdminPost + = ctx -> { + final String userName = LoginController.getSessionCurrentUser(ctx); + final Set roles = LoginController.getSessionCurrentRoles(ctx); + if (!roles.contains(BasicRbacRole.ADMIN)) { + LOGGER.atWarn().addArgument(userName).log("user '{}' does not have the required admin access rights"); + ctx.status(401).result("admin access denied"); + return; + } + final Map model = MessageBundle.baseModel(ctx); + + // parse and process admin stuff + ctx.render(TEMPLATE_ADMIN, model); + }; + + /** + * registers the login/logout and locale change listener + */ + public static void register() { + RestServer.getInstance().routes(() -> { + post(ENDPOINT_ADMIN, handleAdminPost, Collections.singleton(new RestRole(BasicRbacRole.ADMIN))); + get(ENDPOINT_ADMIN, serveAdminPage, Collections.singleton(new RestRole(BasicRbacRole.ADMIN))); + }); + } +} diff --git a/server-rest/src/main/java/io/opencmw/server/rest/login/LoginController.java b/server-rest/src/main/java/io/opencmw/server/rest/login/LoginController.java new file mode 100644 index 00000000..6ddd2aab --- /dev/null +++ b/server-rest/src/main/java/io/opencmw/server/rest/login/LoginController.java @@ -0,0 +1,305 @@ +package io.opencmw.server.rest.login; + +import static io.javalin.apibuilder.ApiBuilder.*; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.javalin.core.security.AccessManager; +import io.javalin.http.Context; +import io.javalin.http.Handler; +import io.javalin.plugin.openapi.annotations.OpenApi; +import io.javalin.plugin.openapi.annotations.OpenApiContent; +import io.javalin.plugin.openapi.annotations.OpenApiResponse; +import io.opencmw.rbac.BasicRbacRole; +import io.opencmw.rbac.RbacRole; +import io.opencmw.server.rest.RestRole; +import io.opencmw.server.rest.RestServer; +import io.opencmw.server.rest.user.RestUserHandler; +import io.opencmw.server.rest.util.MessageBundle; + +@SuppressWarnings("PMD.FieldNamingConventions") +public class LoginController { // NOPMD - nomen est omen + private static final Logger LOGGER = LoggerFactory.getLogger(LoginController.class); + public static final String LOGIN_CONTROLLER = "LoginController"; + private static final String HTTP_200_OK = "200"; + private static final String MIME_HTML = "text/html"; + private static final String MIME_JSON = "text/json"; + private static final String DEFAULT_USER = "anonymous"; + private static final String ENDPOINT_LOGIN = "/login"; + private static final String ENDPOINT_LOGOUT = "/logout"; + private static final String ENDPOINT_CHANGE_PASSWORD = "/changepassword"; + + private static final String ATTR_LOCALE = "locale"; + private static final String ATTR_CURRENT_USER = "currentUser"; + private static final String ATTR_CURRENT_ROLES = "currentRoles"; + private static final String ATTR_LOGIN_REDIRECT = "loginRedirect"; + private static final String ATTR_LOGGED_OUT = "loggedOut"; + + private static final String QUERY_PASSWORD = "password"; + private static final String QUERY_PASSWORD_NEW1 = "passwordNew1"; + private static final String QUERY_PASSWORD_NEW2 = "passwordNew2"; + private static final String QUERY_USERNAME = "username"; + + private static final String AUTHENTICATION_SUCCEEDED = "authenticationSucceeded"; + private static final String AUTHENTICATION_FAILED = "authenticationFailed"; + private static final String AUTHENTICATION_PASSWORD_MISMATCH = "authenticationFailedPasswordsMismatch"; + + private static final String TEMPLATE_LOGIN = "/velocity/login/login.vm"; + private static final String TEMPLATE_PASSWORD_CHANGE = "/velocity/login/changePassword.vm"; + + /** + * Locale change can be initiated from any page The locale is extracted from the + * request and saved to the user's session + */ + private static final Handler handleLocaleChange = ctx -> { + if (ctx.queryParam(ATTR_LOCALE) != null) { + ctx.sessionAttribute(ATTR_LOCALE, ctx.queryParam(ATTR_LOCALE)); + ctx.redirect(ctx.path()); + } + }; + @OpenApi( + description = "endpoint to receive password login request", + operationId = "handleLoginPost", + summary = "POST login command", + tags = { LOGIN_CONTROLLER }, + responses = { + @OpenApiResponse(status = HTTP_200_OK, content = @OpenApiContent(type = MIME_HTML)) + , + @OpenApiResponse(status = HTTP_200_OK, content = @OpenApiContent(type = MIME_JSON)) + }) + private static final Handler handleLoginPost + = ctx -> { + final Map model = MessageBundle.baseModel(ctx); + final RestUserHandler userHandler = RestServer.getUserHandler(); + + final String userName = ctx.formParam(QUERY_USERNAME); + + if (userHandler.authenticate(userName, ctx.formParam(QUERY_PASSWORD))) { + ctx.sessionAttribute(ATTR_CURRENT_USER, userName); + ctx.sessionAttribute(ATTR_CURRENT_ROLES, userHandler.getUserRolesByUsername(userName)); + + model.put(AUTHENTICATION_SUCCEEDED, true); + model.put(ATTR_CURRENT_USER, userName); + model.put(ATTR_CURRENT_ROLES, userHandler.getUserRolesByUsername(userName)); + + final String loginRedirect = ctx.sessionAttribute(ATTR_LOGIN_REDIRECT); + if (loginRedirect != null) { + ctx.redirect(loginRedirect); + } + } else { + model.put(AUTHENTICATION_FAILED, true); + } + ctx.render(TEMPLATE_LOGIN, model); + }; + + @OpenApi( + description = "endpoint to receive password changes", + operationId = "handleChangePasswordPost", + summary = "POST password change page", + tags = { LOGIN_CONTROLLER }, + responses = { + @OpenApiResponse(status = HTTP_200_OK, content = @OpenApiContent(type = MIME_HTML)) + }) + private static final Handler handleChangePasswordPost + = ctx -> { + final Map model = MessageBundle.baseModel(ctx); + + final String userName = ctx.formParam(QUERY_USERNAME); + final String password1 = ctx.formParam(QUERY_PASSWORD_NEW1); + final String password2 = ctx.formParam(QUERY_PASSWORD_NEW2); + if (userName == null || password1 == null || password2 == null) { + model.put(AUTHENTICATION_FAILED, true); + ctx.render(TEMPLATE_PASSWORD_CHANGE, model); + return; + } + + if (!checkPasswordCriteria(password1) || !checkPasswordCriteria(password2) || !password1.equals(password2)) { + LOGGER.atWarn().addArgument(userName).log("password do not match for user '{}'"); + model.put(AUTHENTICATION_PASSWORD_MISMATCH, true); + ctx.render(TEMPLATE_PASSWORD_CHANGE, model); + return; + } + model.put(AUTHENTICATION_PASSWORD_MISMATCH, false); + + try { + final String password = ctx.formParam(QUERY_PASSWORD); + if (password == null) { + model.put(AUTHENTICATION_FAILED, true); + ctx.render(TEMPLATE_PASSWORD_CHANGE, model); + return; + } + + final RestUserHandler userHandler = RestServer.getUserHandler(); + if (userHandler.setPassword(userName, password, password1)) { + ctx.sessionAttribute(ATTR_CURRENT_USER, userName); + ctx.sessionAttribute(ATTR_CURRENT_ROLES, userHandler.getUserRolesByUsername(userName)); + + model.put(AUTHENTICATION_SUCCEEDED, true); + model.put(ATTR_CURRENT_USER, userName); + model.put(ATTR_CURRENT_ROLES, userHandler.getUserRolesByUsername(userName)); + + ctx.render(TEMPLATE_PASSWORD_CHANGE, model); + return; + } + + model.put(AUTHENTICATION_FAILED, true); + ctx.render(TEMPLATE_PASSWORD_CHANGE, model); + } catch (final SecurityException e) { + LOGGER.atWarn().setCause(e).addArgument(userName).log("may not change password for user '{}'"); + } + model.put(AUTHENTICATION_FAILED, true); + ctx.render(TEMPLATE_PASSWORD_CHANGE, model); + }; + + @OpenApi( + description = "endpoint to receive password logout request", + operationId = "handleLogoutPost", + summary = "POST logout command", + tags = { LOGIN_CONTROLLER }, + responses = { + @OpenApiResponse(status = HTTP_200_OK, content = @OpenApiContent(type = MIME_HTML)) + , + @OpenApiResponse(status = HTTP_200_OK, content = @OpenApiContent(type = MIME_JSON)) + }) + private static final Handler handleLogoutPost + = ctx -> { + ctx.sessionAttribute(ATTR_CURRENT_USER, null); + ctx.sessionAttribute(ATTR_CURRENT_ROLES, null); + ctx.sessionAttribute(ATTR_LOGGED_OUT, "true"); + ctx.redirect(ENDPOINT_LOGIN); + }; + + @OpenApi( + description = "endpoint to serve login page", + operationId = "serveLoginPage", + summary = "GET serve login page (HTML-only)", + tags = { LOGIN_CONTROLLER }, + + // method = HttpMethod.GET, + responses = { + @OpenApiResponse(status = HTTP_200_OK, content = @OpenApiContent(type = MIME_HTML)) + }) + private static final Handler serveLoginPage + = ctx -> { + final Map model = MessageBundle.baseModel(ctx); + model.put(ATTR_LOGGED_OUT, removeSessionAttrLoggedOut(ctx)); + ctx.render(TEMPLATE_LOGIN, model); + }; + + @OpenApi( + description = "endpoint to serve password change page", + operationId = "servePasswordChangePage", + summary = "GET serve password change page (HTML-only)", + tags = { LOGIN_CONTROLLER }, + responses = { + @OpenApiResponse(status = HTTP_200_OK, content = @OpenApiContent(type = MIME_HTML)) + }) + private static final Handler servePasswordChangePage + = ctx -> { + final Map model = MessageBundle.baseModel(ctx); + model.put(ATTR_LOGGED_OUT, removeSessionAttrLoggedOut(ctx)); + ctx.render(TEMPLATE_PASSWORD_CHANGE, model); + }; + + /** + * The origin of the request (request.pathInfo()) is saved in the session so the + * user can be redirected back after login + */ + public static final AccessManager accessManager = (handler, ctx, permittedRoles) -> { + final Set userRoles = LoginController.getSessionCurrentRoles(ctx); + final Set permittedRbacRoles = convertRoles(permittedRoles); + final Set intersection = new HashSet<>(permittedRbacRoles); + intersection.retainAll(userRoles); + if (permittedRbacRoles.isEmpty() || permittedRbacRoles.contains(BasicRbacRole.ANYONE) || !intersection.isEmpty()) { + handler.handle(ctx); + } else { + LOGGER.atWarn().addArgument(ctx.path()).addArgument(permittedRbacRoles).addArgument(intersection).log("could not log into '{}' permitted roles {} vs. have {}"); + + // try to login + if (ctx.sessionAttribute(ATTR_CURRENT_USER) == null) { + ctx.sessionAttribute(ATTR_LOGIN_REDIRECT, ctx.path()); + ctx.redirect(ENDPOINT_LOGIN); + } else { + ctx.status(401).result("Unauthorized"); + } + } + }; + + private static Set convertRoles(final Set javalinRoles) { + Set set = new HashSet<>(); + for (final io.javalin.core.security.Role role : javalinRoles) { + if (role instanceof RestRole) { + set.add(((RestRole) role).rbacRole); + } + } + return set; + } + + private LoginController() { + // primarily static helper class + } + + public static Set getSessionCurrentRoles(final Context ctx) { + Object val = ctx.sessionAttribute(ATTR_CURRENT_ROLES); + if (val == null) { + // second attempt mapping to DEFAULT_USER roles + val = RestServer.getUserHandler().getUserRolesByUsername(DEFAULT_USER); + } + if (!(val instanceof Set)) { + return Collections.singleton(BasicRbacRole.NULL); + } + try { + @SuppressWarnings("unchecked") + final Set roles = (Set) val; + return roles; + } catch (final ClassCastException e) { + LOGGER.atError().setCause(e).addArgument(ATTR_CURRENT_ROLES).log("could not cast '{}' attribute to Set -- something fishy is going on"); + } + + return Collections.singleton(BasicRbacRole.NULL); + } + + public static String getSessionCurrentUser(final Context ctx) { + return ctx.sessionAttribute(ATTR_CURRENT_USER); + } + + public static String getSessionLocale(final Context ctx) { + return ctx.sessionAttribute(ATTR_LOCALE); + } + + /** + * registers the login/logout and locale change listener + */ + public static void register() { + RestServer.getInstance().config.accessManager(accessManager); + RestServer.getInstance().routes(() -> { + // before(handleLoginPost) + before(handleLocaleChange); + post(ENDPOINT_LOGIN, handleLoginPost); + post(ENDPOINT_LOGOUT, handleLogoutPost); + post(ENDPOINT_CHANGE_PASSWORD, handleChangePasswordPost); + get(ENDPOINT_LOGIN, serveLoginPage); + get(ENDPOINT_CHANGE_PASSWORD, servePasswordChangePage); + }); + } + + private static boolean checkPasswordCriteria(final String password) { + //TODO: add better password rules + // goal: higher entropy and favour larger number of characters + // rather than complex special characters and/or number combinations + // see security recommendations at: https://xkcd.com/936/ + return password != null && password.length() >= 8; + } + + private static boolean removeSessionAttrLoggedOut(final Context ctx) { + final String loggedOut = ctx.sessionAttribute(ATTR_LOGGED_OUT); + ctx.sessionAttribute(ATTR_LOGGED_OUT, null); + return loggedOut != null; + } +} diff --git a/server-rest/src/main/java/io/opencmw/server/rest/user/RestUser.java b/server-rest/src/main/java/io/opencmw/server/rest/user/RestUser.java new file mode 100644 index 00000000..27a25f20 --- /dev/null +++ b/server-rest/src/main/java/io/opencmw/server/rest/user/RestUser.java @@ -0,0 +1,29 @@ +package io.opencmw.server.rest.user; + +import java.util.Collections; +import java.util.Set; + +import io.opencmw.rbac.RbacRole; + +public class RestUser { + protected final String userName; + protected String salt; + protected String hashedPassword; + private final Set roles; + + public RestUser(final String username, final String salt, final String hashedPassword, final Set roles) { + this.userName = username; + this.salt = salt; + this.hashedPassword = hashedPassword; + this.roles = roles == null ? Collections.emptySet() : Collections.unmodifiableSet(roles); + } + + protected Set getRoles() { + return roles; + } + + @Override + public String toString() { + return "RestUser{" + userName + ", roles=" + roles + "}"; + } +} diff --git a/server-rest/src/main/java/io/opencmw/server/rest/user/RestUserHandler.java b/server-rest/src/main/java/io/opencmw/server/rest/user/RestUserHandler.java new file mode 100644 index 00000000..28a326e6 --- /dev/null +++ b/server-rest/src/main/java/io/opencmw/server/rest/user/RestUserHandler.java @@ -0,0 +1,43 @@ +package io.opencmw.server.rest.user; + +import java.util.Set; + +import io.opencmw.rbac.RbacRole; + +/** + * Basic user handler interface to control access to various routes. + * + * N.B. new implementations may be injected through the RestServer factory. + * + * @author rstein + * @see io.opencmw.server.rest.RestServer#setUserHandler(RestUserHandler) + */ +public interface RestUserHandler { + /** + * Authenticates user against given back-end. + * + * @param username the user name + * @param password the secret password + * @return {@code true} if successful + */ + boolean authenticate(String username, String password); + + Iterable getAllUserNames(); + + RestUser getUserByUsername(String username); + + Set getUserRolesByUsername(String username); + + /** + * Sets new user password. + * + * N.B. Implementation may be implemented or omitted based on the specific back-end. + * + * @param userName existing + * @param oldPassword to verify + * @param newPassword to set + * @throws SecurityException if underlying implementation does not allow to change the password. + * @return {@code true} if successful + */ + boolean setPassword(String userName, String oldPassword, String newPassword) throws SecurityException; //NOPMD - name overload and exception intended +} \ No newline at end of file diff --git a/server-rest/src/main/java/io/opencmw/server/rest/user/RestUserHandlerImpl.java b/server-rest/src/main/java/io/opencmw/server/rest/user/RestUserHandlerImpl.java new file mode 100644 index 00000000..0011e092 --- /dev/null +++ b/server-rest/src/main/java/io/opencmw/server/rest/user/RestUserHandlerImpl.java @@ -0,0 +1,203 @@ +package io.opencmw.server.rest.user; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import org.jetbrains.annotations.NotNull; +import org.mindrot.jbcrypt.BCrypt; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.opencmw.rbac.BasicRbacRole; +import io.opencmw.rbac.RbacRole; +import io.opencmw.server.rest.RestServer; + +public class RestUserHandlerImpl implements RestUserHandler { + private static final Logger LOGGER = LoggerFactory.getLogger(RestUserHandlerImpl.class); + private static final String REST_USER_PASSWORD_STORE = "restUserPasswordStore"; + /** + * the password file location is statically allocated so that it cannot (for + * security reasons) be overwritting during run time + */ + private static final String REST_USER_PASSWORD_FILE = getUserPasswordStore(); + + private final Object usersLock = new Object(); + private final RbacRole protoRole; + private List users = Collections.emptyList(); + + public RestUserHandlerImpl(final RbacRole protoRole) { + this.protoRole = protoRole; + } + + /** + * Authenticate the user by hashing the input password using the stored salt, + * then comparing the generated hashed password to the stored hashed password + */ + @Override + public boolean authenticate(@NotNull final String username, @NotNull final String password) { + synchronized (usersLock) { + final RestUser user = getUserByUsername(username); + if (user == null) { + return false; + } + final String hashedPassword = BCrypt.hashpw(password, user.salt); + return hashedPassword.equals(user.hashedPassword); + } + } + + @Override + public Iterable getAllUserNames() { + synchronized (usersLock) { + if (users.isEmpty()) { + readPasswordFile(); + } + return users.stream().map(user -> user.userName).collect(Collectors.toList()); + } + } + + @Override + public RestUser getUserByUsername(final String userName) { + synchronized (usersLock) { + if (users.isEmpty()) { + readPasswordFile(); + } + return users.stream().filter(b -> b.userName.equals(userName)).findFirst().orElse(null); + } + } + + @Override + public Set getUserRolesByUsername(final String userName) { + synchronized (usersLock) { + if (users.isEmpty()) { + readPasswordFile(); + } + RestUser user = getUserByUsername(userName); + if (user != null) { + return user.getRoles(); + } + return Collections.singleton(BasicRbacRole.NULL); + } + } + + public void readPasswordFile() { + if (LOGGER.isDebugEnabled()) { + LOGGER.atDebug().log("readPasswordFile called"); + } + synchronized (usersLock) { + try (BufferedReader br = REST_USER_PASSWORD_FILE == null ? new BufferedReader(new InputStreamReader(RestServer.class.getResourceAsStream("/DefaultRestUserPasswords.pwd"), StandardCharsets.UTF_8)) // + : Files.newBufferedReader(Paths.get(new File(REST_USER_PASSWORD_FILE).getPath()), StandardCharsets.UTF_8)) { + final List newUserList = new ArrayList<>(10); + String userLine; + int lineCount = 0; + while ((userLine = br.readLine()) != null) { // NOPMD NOSONAR -- early return/continue on purpose + if (userLine.startsWith("#")) { + continue; + } + lineCount++; + parsePasswordLine(newUserList, userLine, lineCount); + } + users = Collections.unmodifiableList(newUserList); + if (LOGGER.isDebugEnabled()) { + LOGGER.atDebug().log("PasswordFile successfully read"); + } + } catch (IOException e) { + LOGGER.atError().setCause(e).addArgument(REST_USER_PASSWORD_FILE).log("could not read rest user passwords to '{}'"); + } + } + } + + @Override + public boolean setPassword(@NotNull final String userName, @NotNull final String oldPassword, @NotNull final String newPassword) { + if (REST_USER_PASSWORD_FILE == null) { + LOGGER.atWarn().log("cannot set password for default user password store"); + return false; + } + synchronized (usersLock) { + if (authenticate(userName, oldPassword)) { + final RestUser user = getUserByUsername(userName); + if (user == null) { + return false; + } + // N.B. default rounds is 2^10, increase this if necessary to harden passwords + final String newSalt = BCrypt.gensalt(); + final String newHashedPassword = BCrypt.hashpw(newPassword, newSalt); + user.salt = newSalt; + user.hashedPassword = newHashedPassword; + writePasswordFile(); + return true; + } + return false; + } + } + + @SuppressWarnings("unchecked") + public void writePasswordFile() { + if (LOGGER.isDebugEnabled()) { + LOGGER.atDebug().log("updatePasswordFile called"); + } + if (REST_USER_PASSWORD_FILE == null) { + LOGGER.atWarn().log("cannot write password for default user password store"); + return; + } + synchronized (usersLock) { + final File file = new File(REST_USER_PASSWORD_FILE); + try { + if (file.createNewFile()) { + LOGGER.atInfo().addArgument(REST_USER_PASSWORD_FILE).log("needed to create new password file '{}'"); + } + } catch (SecurityException | IOException e) { + LOGGER.atError().setCause(e).addArgument(REST_USER_PASSWORD_FILE).log("could not create user passwords file '{}'"); + return; + } + + try (BufferedWriter bw = Files.newBufferedWriter(Paths.get(file.getPath()), StandardCharsets.UTF_8)) { + final StringBuilder builder = new StringBuilder(); + for (final RestUser user : users) { + builder.delete(0, builder.length()); // inits and re-uses builder + builder.append(user.userName).append(':').append(user.salt).append(':').append(user.hashedPassword).append(':'); + // write roles + builder.append(protoRole.getRoles(user.getRoles())).append(':'); + bw.write(builder.toString()); + bw.newLine(); + } + if (LOGGER.isDebugEnabled()) { + LOGGER.atDebug().log("PasswordFile successfully updated"); + } + } catch (IOException e) { + LOGGER.atError().setCause(e).addArgument(REST_USER_PASSWORD_FILE).log("could not store rest user passwords to '{}'"); + } + } + } + + private void parsePasswordLine(@NotNull final List newUserList, final String userLine, final int lineCount) { + try { + final String[] items = userLine.split(":"); + if (items.length < 4) { // NOPMD + LOGGER.atWarn().addArgument(items.length).addArgument(lineCount).addArgument(userLine).log("insufficient arguments ({} < 4)- parsing line {}: '{}'"); + return; + } + newUserList.add(new RestUser(items[0], items[1], items[2], protoRole.getRoles(items[3]))); // NOPMD - needed + } catch (Exception e) { // NOPMD - catch generic exception since a faulty login should not crash the rest of the REST service + LOGGER.atWarn().setCause(e).addArgument(lineCount).addArgument(userLine).log("could not parse line {}: '{}'"); + } + } + + private static String getUserPasswordStore() { + final String passWordStore = System.getProperty(REST_USER_PASSWORD_STORE); + if (passWordStore == null) { + LOGGER.atWarn().log("using internal UserPasswordStore -- PLEASE CHANGE FOR PRODUCTION -- THIS IS UNSAFE PRACTICE"); + } + return passWordStore; + } +} diff --git a/server-rest/src/main/java/io/opencmw/server/rest/util/CombinedHandler.java b/server-rest/src/main/java/io/opencmw/server/rest/util/CombinedHandler.java new file mode 100644 index 00000000..6cf1b4d6 --- /dev/null +++ b/server-rest/src/main/java/io/opencmw/server/rest/util/CombinedHandler.java @@ -0,0 +1,111 @@ +package io.opencmw.server.rest.util; + +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +import javax.servlet.AsyncEvent; +import javax.servlet.AsyncListener; + +import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.javalin.core.util.Header; +import io.javalin.http.Context; +import io.javalin.http.Handler; +import io.javalin.http.sse.SseClient; +import io.opencmw.MimeType; +import io.opencmw.server.rest.RestServer; + +/** + * Combined GET and SSE request handler. + * + * N.B. This based on an original idea/implementation found in Javalin's {@link io.javalin.http.sse.SseHandler}. + * + * @author rstein + * + * @see io.javalin.http.sse.SseHandler + */ +public class CombinedHandler implements Handler { + private static final Logger LOGGER = LoggerFactory.getLogger(CombinedHandler.class); + private final Handler getHandler; + private BiConsumer sseClientConnectHandler; + + private final Consumer clientConsumer = client -> { + // TODO: upgrade to path & query matching - for the time being only path @see also MajordomoRestPlugin + // final String queryString = client.ctx.queryString() == null ? "" : ("?" + client.ctx.queryString()) + // final String endPointName = StringUtils.stripEnd(client.ctx.path(), "/") + queryString + final String endPointName = StringUtils.stripEnd(client.ctx.path(), "/"); + + RestServer.getEventClients(endPointName).add(client); + if (LOGGER.isDebugEnabled()) { + LOGGER.atDebug().addArgument(client.ctx.req.getRemoteHost()).addArgument(endPointName).log("added SSE client: '{}' to route '{}'"); + } + + if (sseClientConnectHandler != null) { + sseClientConnectHandler.accept(client, SseState.CONNECTED); + } + client.sendEvent("connected", "Hello, new SSE client " + client.ctx.req.getRemoteHost()); + + client.onClose(() -> { + if (sseClientConnectHandler != null) { + sseClientConnectHandler.accept(client, SseState.DISCONNECTED); + } + RestServer.getEventClients(endPointName).remove(client); + if (LOGGER.isDebugEnabled()) { + LOGGER.atDebug().addArgument(client.ctx.req.getRemoteHost()).addArgument(endPointName).log("removed client: '{}' from route '{}'"); + } + }); + }; + + public CombinedHandler(@NotNull Handler getHandler) { + this(getHandler, null); + } + + public CombinedHandler(@NotNull Handler getHandler, BiConsumer sseClientConnectHandler) { + this.getHandler = getHandler; + this.sseClientConnectHandler = sseClientConnectHandler; + } + + @Override + public void handle(@NotNull Context ctx) throws Exception { + if (MimeType.EVENT_STREAM.equals(RestServer.getRequestedMimeProtocol(ctx))) { + ctx.res.setStatus(200); + ctx.res.setCharacterEncoding("UTF-8"); + ctx.res.setContentType(MimeType.EVENT_STREAM.toString()); + ctx.res.addHeader(Header.CONNECTION, "close"); + ctx.res.addHeader(Header.CACHE_CONTROL, "no-cache"); + ctx.res.flushBuffer(); + + ctx.req.startAsync(ctx.req, ctx.res); + ctx.req.getAsyncContext().setTimeout(0); + clientConsumer.accept(new SseClient(ctx)); + + ctx.req.getAsyncContext().addListener(new AsyncListener() { + @Override + public void onComplete(AsyncEvent event) { /* not needed */ + } + @Override + public void onError(AsyncEvent event) { + event.getAsyncContext().complete(); + } + @Override + public void onStartAsync(AsyncEvent event) { /* not needed */ + } + @Override + public void onTimeout(AsyncEvent event) { + event.getAsyncContext().complete(); + } + }); + return; + } + + getHandler.handle(ctx); + } + + public enum SseState { + CONNECTED, + DISCONNECTED + } +} \ No newline at end of file diff --git a/server-rest/src/main/java/io/opencmw/server/rest/util/MessageBundle.java b/server-rest/src/main/java/io/opencmw/server/rest/util/MessageBundle.java new file mode 100644 index 00000000..851d7956 --- /dev/null +++ b/server-rest/src/main/java/io/opencmw/server/rest/util/MessageBundle.java @@ -0,0 +1,38 @@ +package io.opencmw.server.rest.util; + +import java.text.MessageFormat; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.ResourceBundle; + +import io.javalin.http.Context; +import io.opencmw.server.rest.RestServer; + +public class MessageBundle { + private static final String ATTR_CURRENT_MESSAGES = "msg"; + private static final String ATTR_CURRENT_USER = "currentUser"; + private static final String ATTR_CURRENT_ROLES = "currentRoles"; + private final ResourceBundle messages; + + public MessageBundle(final String languageTag) { + final Locale locale = languageTag == null ? Locale.ENGLISH : new Locale(languageTag); + messages = ResourceBundle.getBundle("localisation/messages", locale); + } + + public String get(final String message) { + return messages.getString(message); + } + + public final String get(final String key, final Object... args) { + return MessageFormat.format(get(key), args); + } + + public static Map baseModel(Context ctx) { + final Map model = new HashMap<>(); // NOPMD - thread-safe usage + model.put(ATTR_CURRENT_MESSAGES, new MessageBundle(RestServer.getSessionLocale(ctx))); + model.put(ATTR_CURRENT_USER, RestServer.getSessionCurrentUser(ctx)); + model.put(ATTR_CURRENT_ROLES, RestServer.getSessionCurrentRoles(ctx)); + return model; + } +} diff --git a/server-rest/src/main/resources/DefaultRestUserPasswords.pwd b/server-rest/src/main/resources/DefaultRestUserPasswords.pwd new file mode 100644 index 00000000..ad7374ea --- /dev/null +++ b/server-rest/src/main/resources/DefaultRestUserPasswords.pwd @@ -0,0 +1,6 @@ +# demo password file -- please change for production since these 'password's are not safe!! +admin:$2a$10$h.dl5J86rGH7I8bD9bZeZe:$2a$10$h.dl5J86rGH7I8bD9bZeZeci0pDt0.VwFTGujlnEaZXPf/q7vM5wO:ADMIN: +anonymous:$2a$10$e0MYzXyjpJS7Pd0RVvHwHe:$2a$10$e0MYzXyjpJS7Pd0RVvHwHe1HlCS4bZJ18JuywdEMLT83E1KDmUhCy:READ_WRITE: +alex:$2a$10$rQu1pxN6wCsS.bf6zYObX.:$2a$10$rQu1pxN6wCsS.bf6zYObX.QdkJN85dFpkcrzy1xwWK57ToqpKEVBy:READ_WRITE: +bernd:$2a$10$j2l8uniY45FyKvZLy3ZvaO:$2a$10$j2l8uniY45FyKvZLy3ZvaORFZjPHuGr69Rb/rRCs0W2aPdIfH99pi:READ_ONLY: +zoe:$2a$10$vLiwSkM2/krIqO1eDhGgcu:$2a$10$vLiwSkM2/krIqO1eDhGgcuRv/vJxE8Sh4xePZkBKKZeswP2pTzlAi:ANYONE: diff --git a/server-rest/src/main/resources/keystore.jks b/server-rest/src/main/resources/keystore.jks new file mode 100644 index 0000000000000000000000000000000000000000..9cf0969c2b7c4eb3d6f5e093f7aa4ebda14444a2 GIT binary patch literal 2619 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 z zR^<6GBDkNm-9G--!jvj`CI6R0(5sZNw+Np~P&<$}*3$M3Y3iD9+Wh|T3W@p(c*8JF z{T$&jk4sZN{-k_bC#K?_?~juadSbN+@mKlQFK#Scg~$su3(|@yO6MEwY4#W(cK}55 zw&8U%gb_jt!O6y|%)ZeroMQ, Javalin und OpenCMW
Bitte unterstützen Sie Freie Software und die Public Money? Public Code! Kampagne.
+COMMON_NAV_ALLIMAGES=Clipboard Galerie +COMMON_NAV_UPLOAD=Hinaufladen +COMMON_NAV_ADMIN=Chef +COMMON_NAV_LOGIN=Innlogg +COMMON_NAV_LOGOUT=Auslogg +ERROR_400_BAD_REQUEST=Schlechte GET oder SET Anfrage(error 400) +ERROR_401_UNAUTHORISED=Verboten! (error 401) +ERROR_403_ACCESS_DENIED=!!Zutritt Verboten!! (error 403) +ERROR_404_NOT_FOUND=Ve cannot find ze page you are looking for (error 404) +## Login +LOGIN_HEADING=Innlogg +LOGIN_INSTRUCTIONS=Please enter Dein username und Parole. +LOGIN_AUTH_SUCCEEDED=You''re logged in as ''{0}''. +LOGIN_AUTH_FAILED=Ze login informazion you zuplied vas incorrect. +LOGIN_AUTH_FAILED_PASSWORD_MISMATCH=Ze login informazion you zuplied dit not match. +LOGIN_LOGGED_OUT=You have been logged aus. +LOGIN_LABEL_USERNAME=Username +LOGIN_LABEL_PASSWORD=Parole +LOGIN_BUTTON_LOGIN=Innlogg +LOGIN_HEADING_CHANGE_PASSWORD=Parolen Änderung +LOGIN_LABEL_PASSWORD_OLD=olle Parole +LOGIN_LABEL_PASSWORD_NEW1=nieuve Parole +LOGIN_LABEL_PASSWORD_NEW2=nieuve Parole (wirklich) +LOGIN_BUTTON_CHANGE_PASSWORD=jetzt los +## Images +CLIPBOARD_HEADING_ALL=Alle Bildchen +CLIPBOARD_DATA = Keine Bildchen +CLIPBOARD_SUB_CATEGORIES = Unterrubriken +CLIPBOARD_CAPTION={0}
{1}
+CLIPBOARD_UPLOAD_HEADING=Upload Data +CLIPBOARD_UPLOAD_INSTRUCTIONS=Please provide a export topic name and data to be exported: +CLIPBOARD_UPLOAD_BUTTON=submit \ No newline at end of file diff --git a/server-rest/src/main/resources/localisation/messages_en.properties b/server-rest/src/main/resources/localisation/messages_en.properties new file mode 100644 index 00000000..2a97de75 --- /dev/null +++ b/server-rest/src/main/resources/localisation/messages_en.properties @@ -0,0 +1,36 @@ +## Common +LOCALE=en +COMMON_TITLE=OpenCMW Service +COMMON_FOOTER_TEXT=This service uses ZeroMQ, Javalin and OpenCMW
Please support Free Software and the Public Money? Public Code! campaign.
+COMMON_NAV_ALLIMAGES=Clipboard Gallery +COMMON_NAV_UPLOAD=Upload +COMMON_NAV_ADMIN=Admin +COMMON_NAV_LOGIN=Log in +COMMON_NAV_LOGOUT=Log out +ERROR_400_BAD_REQUEST=bad GET or SET request(error 400) +ERROR_401_UNAUTHORISED=unauthorised access (error 401) +ERROR_403_ACCESS_DENIED=access denied (error 403) +ERROR_404_NOT_FOUND=We cannot find the page you're looking for (error 404) +## Login +LOGIN_HEADING=Login +LOGIN_INSTRUCTIONS=Please enter your username and password. +LOGIN_AUTH_SUCCEEDED=You''re logged in as ''{0}''. +LOGIN_AUTH_FAILED=The login information you supplied was incorrect. +LOGIN_AUTH_FAILED_PASSWORD_MISMATCH=The provided passwords do not match +LOGIN_LOGGED_OUT=You have been logged out. +LOGIN_LABEL_USERNAME=username +LOGIN_LABEL_PASSWORD=password +LOGIN_BUTTON_LOGIN=Log in +LOGIN_HEADING_CHANGE_PASSWORD=Change Password +LOGIN_LABEL_PASSWORD_OLD=old password +LOGIN_LABEL_PASSWORD_NEW1=new password +LOGIN_LABEL_PASSWORD_NEW2=new password (verify) +LOGIN_BUTTON_CHANGE_PASSWORD=change password +## Images +CLIPBOARD_HEADING_ALL=All Snapshots +CLIPBOARD_DATA = Non-Image Data +CLIPBOARD_SUB_CATEGORIES = sub-categories +CLIPBOARD_CAPTION={0}
{1}
+CLIPBOARD_UPLOAD_HEADING=Upload Data +CLIPBOARD_UPLOAD_INSTRUCTIONS=Please provide a export topic name and data to be exported: +CLIPBOARD_UPLOAD_BUTTON=submit diff --git a/server-rest/src/main/resources/public/img/english.png b/server-rest/src/main/resources/public/img/english.png new file mode 100644 index 0000000000000000000000000000000000000000..3a170eec58aa39c5f00a99648c95a28365cb3c0e GIT binary patch literal 56203 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 PW})nB1<7)8{eb@h*Mbu# literal 0 HcmV?d00001 diff --git a/server-rest/src/main/resources/public/img/german.png b/server-rest/src/main/resources/public/img/german.png new file mode 100644 index 0000000000000000000000000000000000000000..fd64cf5908a4d97a8de4c13e2a26c19c491e2891 GIT binary patch literal 52123 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 literal 0 HcmV?d00001 diff --git a/server-rest/src/main/resources/public/img/logo_b.png b/server-rest/src/main/resources/public/img/logo_b.png new file mode 100644 index 0000000000000000000000000000000000000000..c3d8878221d3020cf1c6b16358c351052beab6ed GIT binary patch literal 19370 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 XfoXBlVy`Wb00000NkvXXu0mjfdV9h- literal 0 HcmV?d00001 diff --git a/server-rest/src/main/resources/public/img/logo_w.png b/server-rest/src/main/resources/public/img/logo_w.png new file mode 100644 index 0000000000000000000000000000000000000000..4c24d9de3a3c7bfffb4e4c84a6419e8ae4c179c5 GIT binary patch literal 16137 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%0D@Cmvp(f(>^Lv2T%y9 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 * { + float: left; +} + +.row-2 .col { + width: 49%; +} + +.row-2 .col:nth-child(odd) { + margin: 0% 1% 0% 0%; +} + +.row-2 .col:nth-child(even) { + margin: 0% 0% 0% 1%; +} + +.row-3 .col { + width: 32%; +} + +.row-3 .col:nth-child(3n+1) { + margin: 0% 1% 0% 0%; +} + +.row-3 .col:nth-child(3n+2) { + margin: 0% 1% 0% 1%; +} + +.row-3 .col:nth-child(3n+3) { + margin: 0% 0% 0% 1%; +} + +@media screen and (max-width: 550px) { + .row .col:nth-child(n) { + width: 100%; + margin-right: 0; + margin-left: 0; + } +} + +.col img { + display: block; + width: 100%; +} + +/* image related stuff */ +a.image { + display: block; + text-align: center; + text-decoration: none; + color: #333; + padding-top: 10px; + padding-left: 10px; + padding-right: 10px; + border-radius: 5px; +} + +a.image:hover { + background: #e2e9f5; +} + +.imageCover { + padding: 10px; + display: flex; + align-items: center; + justify-content: center; +} + +.imageCover img { + border-radius: 5px; + min-height: 200px; + max-height: 200px; + height: auto; + width: auto; + object-fit: contain; +} + +.imageCover img { + margin-top: 20px; + border-radius: 10px; + width: 100%; +} + +a.link { + text-decoration: none; + color: #008ab4; + padding: 0; + border-radius: 5px; +} + +a.link:hover { + background: #e2e9f5; +} + +/* Login Form */ +#loginForm { + max-width: 400px; + margin: 0 auto; +} + +#loginForm label { + display: block; + width: 100%; +} + +#loginForm input { + border: 1px solid #ddd; + padding: 8px 12px; + width: 100%; + border-radius: 3px; + margin: 2px 0 20px 0; +} + +#loginForm input[type="submit"] { + color: white; + background: #00599c; + border: 0; + cursor: pointer; +} + +.notification { + padding: 10px; + background: #333; + color: white; + border-radius: 3px; +} + +.good.notification { + background: #008900; +} + +.bad.notification { + background: #bb00; +} + +.skip-link { + position: absolute; + top: -40px; + left: 0; + background: #0000; + color: white; + padding: 8px; + z-index: 100; +} + +.skip-link:focus { + top: 0; +} + +/* Login Form */ +#propertyView { + max-width: 400px; + margin: 0 auto; +} + +#defaultProperty { + font-family: sans-serif; +} + +#defaultProperty fieldset { + border-style:solid; + border-radius: 5px; + border-color:#F5F5F5; + padding-top: 2px; + padding-bottom: 2px; + padding-left: 5px; + /*overflow: hidden;*/ +} + +#defaultProperty legend { + background: #00599c; + color: #fff; + padding: 2px 10px ; + font-size: 14px; + border-radius: 5px; + box-shadow: 0 0 0 2px #ddd; + margin-left: 5px; +} + +#defaultProperty label { + display: inline; + width: 20%; + min-width: 20%; +} + +#defaultProperty label[id="unit"] { + display: inline; + width: 10%; + min-width: 10%; +} + +#defaultProperty label[id="description"] { + display: inline; + width: 20%; + min-width: 20%; +} + +#defaultProperty input { + border: 1px solid #ddd; + padding: 8px 12px; + width: 50%; + border-radius: 3px; + margin: 2px 0 20px 0; +} + +#defaultProperty input[type="submit"] { + color: white; + width: 5%; + background: #00599c; + border: 0; + cursor: pointer; +} + +#defaultProperty input[value="SUBSCRIBE"] { + color: white; + width: 10%; + background: #00599c; + border: 0; + cursor: pointer; +} diff --git a/server-rest/src/main/resources/velocity/admin/admin.vm b/server-rest/src/main/resources/velocity/admin/admin.vm new file mode 100644 index 00000000..f826f882 --- /dev/null +++ b/server-rest/src/main/resources/velocity/admin/admin.vm @@ -0,0 +1,14 @@ +#parse("/velocity/layout.vm") +#@mainLayout() + +

Admin Interface

+Users: +
    +#foreach($user in $users) +
  • $user with roles $userHandler.getUserRolesByUsername($user)
  • +#end +
+
+Direct access:
/admin/endpoints + +#end \ No newline at end of file diff --git a/server-rest/src/main/resources/velocity/clipboard/all.vm b/server-rest/src/main/resources/velocity/clipboard/all.vm new file mode 100644 index 00000000..d825a8ff --- /dev/null +++ b/server-rest/src/main/resources/velocity/clipboard/all.vm @@ -0,0 +1,25 @@ +#parse("/velocity/layout.vm") +#@ mainLayout()

$msg.get("CLIPBOARD_HEADING_ALL") #if (!$category.isEmpty()) - $category #end

+#if (!$categories.isEmpty()) + $msg.get("CLIPBOARD_SUB_CATEGORIES"): +#foreach ($category in $categories) + $category +#end + +#end + +#if (!$data.isEmpty()) +

$msg.get("CLIPBOARD_DATA")

+#foreach ($dataItem in $data) +#set($dataStr = "/clipboard/$category" + "$dataItem.getExportNameData()") + $dataItem.getExportNameData() +#end +
+#end +#end diff --git a/server-rest/src/main/resources/velocity/clipboard/one_long.vm b/server-rest/src/main/resources/velocity/clipboard/one_long.vm new file mode 100644 index 00000000..741a4cdd --- /dev/null +++ b/server-rest/src/main/resources/velocity/clipboard/one_long.vm @@ -0,0 +1,86 @@ + + + + $title + + + + + + + + + + + + + + + + $imageSource + + + + \ No newline at end of file diff --git a/server-rest/src/main/resources/velocity/clipboard/one_sse.vm b/server-rest/src/main/resources/velocity/clipboard/one_sse.vm new file mode 100644 index 00000000..93ccf2cb --- /dev/null +++ b/server-rest/src/main/resources/velocity/clipboard/one_sse.vm @@ -0,0 +1,111 @@ + + + + $title + + + + + + + + + + + + + + + + + + + + + + + diff --git a/server-rest/src/main/resources/velocity/clipboard/upload.vm b/server-rest/src/main/resources/velocity/clipboard/upload.vm new file mode 100644 index 00000000..19f2cd35 --- /dev/null +++ b/server-rest/src/main/resources/velocity/clipboard/upload.vm @@ -0,0 +1,17 @@ +#parse("/velocity/layout.vm") +#@mainLayout() +
+ #if($authenticationFailed) +

$msg.get("LOGIN_AUTH_FAILED")

+ #elseif($authenticationSucceeded) +

$msg.get("LOGIN_AUTH_SUCCEEDED", $currentUser)

+ #elseif($loggedOut) +

$msg.get("LOGIN_LOGGED_OUT")

+ #end +

$msg.get("CLIPBOARD_UPLOAD_HEADING")

+

$msg.get("CLIPBOARD_UPLOAD_INSTRUCTIONS", "/index")

+ + + +
+#end diff --git a/server-rest/src/main/resources/velocity/errors/accessDenied.vm b/server-rest/src/main/resources/velocity/errors/accessDenied.vm new file mode 100644 index 00000000..fcf59529 --- /dev/null +++ b/server-rest/src/main/resources/velocity/errors/accessDenied.vm @@ -0,0 +1,4 @@ +#parse("/velocity/layout.vm") +#@mainLayout() +

$msg.get("ERROR_403_ACCESS_DENIED")

+#end diff --git a/server-rest/src/main/resources/velocity/errors/badRequest.vm b/server-rest/src/main/resources/velocity/errors/badRequest.vm new file mode 100644 index 00000000..9ef54e65 --- /dev/null +++ b/server-rest/src/main/resources/velocity/errors/badRequest.vm @@ -0,0 +1,8 @@ +#parse("/velocity/layout.vm") +#@mainLayout() +

$msg.get("ERROR_400_BAD_REQUEST")

+#if ($exceptionText) +Service '$service' error reply:
+ $exceptionText +#end +#end \ No newline at end of file diff --git a/server-rest/src/main/resources/velocity/errors/notFound.vm b/server-rest/src/main/resources/velocity/errors/notFound.vm new file mode 100644 index 00000000..15e3e424 --- /dev/null +++ b/server-rest/src/main/resources/velocity/errors/notFound.vm @@ -0,0 +1,4 @@ +#parse("/velocity/layout.vm") +#@mainLayout() +

$msg.get("ERROR_404_NOT_FOUND")

+#end diff --git a/server-rest/src/main/resources/velocity/errors/unauthorised.vm b/server-rest/src/main/resources/velocity/errors/unauthorised.vm new file mode 100644 index 00000000..9ad16981 --- /dev/null +++ b/server-rest/src/main/resources/velocity/errors/unauthorised.vm @@ -0,0 +1,4 @@ +#parse("/velocity/layout.vm") +#@mainLayout() +

$msg.get("ERROR_401_UNAUTHORISED")

+#end \ No newline at end of file diff --git a/server-rest/src/main/resources/velocity/layout.vm b/server-rest/src/main/resources/velocity/layout.vm new file mode 100644 index 00000000..6dc11fe0 --- /dev/null +++ b/server-rest/src/main/resources/velocity/layout.vm @@ -0,0 +1,56 @@ +#macro(mainLayout) + + + + $msg.get("COMMON_TITLE") + + + + + + + +
+ +
+
+
+ $bodyContent +
+
+
+ $msg.get("COMMON_FOOTER_TEXT") +
+ + +#end diff --git a/server-rest/src/main/resources/velocity/layoutNoFrame.vm b/server-rest/src/main/resources/velocity/layoutNoFrame.vm new file mode 100644 index 00000000..52f04a56 --- /dev/null +++ b/server-rest/src/main/resources/velocity/layoutNoFrame.vm @@ -0,0 +1,18 @@ +#macro(mainLayoutNoFrame) + + + + $msg.get("COMMON_TITLE") + + + + + + + +
+ $bodyContent +
+ + +#end diff --git a/server-rest/src/main/resources/velocity/login/changePassword.vm b/server-rest/src/main/resources/velocity/login/changePassword.vm new file mode 100644 index 00000000..e7baa511 --- /dev/null +++ b/server-rest/src/main/resources/velocity/login/changePassword.vm @@ -0,0 +1,28 @@ +#parse("/velocity/layout.vm") +#@mainLayout() +
+ #if($authenticationFailedPasswordsMismatch) +

$msg.get("LOGIN_AUTH_FAILED_PASSWORD_MISMATCH")

+ #end + #if($authenticationFailed) +

$msg.get("LOGIN_AUTH_FAILED")

+ #elseif($authenticationSucceeded) +

$msg.get("LOGIN_AUTH_SUCCEEDED", $currentUser)

+ #elseif($loggedOut) +

$msg.get("LOGIN_LOGGED_OUT")

+ #end +

$msg.get("LOGIN_HEADING_CHANGE_PASSWORD")

+

$msg.get("LOGIN_INSTRUCTIONS", "/index")

+ + + + + + + + #if($loginRedirect) + + #end + +
+#end diff --git a/server-rest/src/main/resources/velocity/login/login.vm b/server-rest/src/main/resources/velocity/login/login.vm new file mode 100644 index 00000000..689c5962 --- /dev/null +++ b/server-rest/src/main/resources/velocity/login/login.vm @@ -0,0 +1,22 @@ +#parse("/velocity/layout.vm") +#@mainLayout() +
+ #if($authenticationFailed) +

$msg.get("LOGIN_AUTH_FAILED")

+ #elseif($authenticationSucceeded) +

$msg.get("LOGIN_AUTH_SUCCEEDED", $currentUser)

+ #elseif($loggedOut) +

$msg.get("LOGIN_LOGGED_OUT")

+ #end +

$msg.get("LOGIN_HEADING")

+

$msg.get("LOGIN_INSTRUCTIONS", "/")
($msg.get("LOGIN_BUTTON_CHANGE_PASSWORD"))

+ + + + + #if($loginRedirect) + + #end + +
+#end diff --git a/server-rest/src/main/resources/velocity/property/defaultTextPropertyLayout.vm b/server-rest/src/main/resources/velocity/property/defaultTextPropertyLayout.vm new file mode 100644 index 00000000..91714521 --- /dev/null +++ b/server-rest/src/main/resources/velocity/property/defaultTextPropertyLayout.vm @@ -0,0 +1,11 @@ +#if (!$noMenu) + #parse("/velocity/layout.vm") + #@mainLayout() + $textBody + #end +#else + #parse("/velocity/layoutNoFrame.vm") + #@mainLayoutNoFrame() + $textBody + #end +#end \ No newline at end of file diff --git a/server-rest/src/main/resources/velocityconfig/velocity_implicit.vm b/server-rest/src/main/resources/velocityconfig/velocity_implicit.vm new file mode 100644 index 00000000..f854801a --- /dev/null +++ b/server-rest/src/main/resources/velocityconfig/velocity_implicit.vm @@ -0,0 +1,9 @@ +#* @implicitly included * # +#* @vtlvariable name = "msg" type = "io.opencmw.server.rest.util.MessageBundle" * # +#* @vtlvariable name = "users" type = "java.lang.Iterable" * # +#* @vtlvariable name = "currentUser" type = "java.lang.String" * # +#* @vtlvariable name = "currentRoles" type = "java.lang.List" * # +#* @vtlvariable name = "loggedOut" type = "java.lang.String" * # +#* @vtlvariable name = "authenticationFailed" type = "java.lang.String" * # +#* @vtlvariable name = "authenticationSucceeded" type = "java.lang.String" * # +#* @vtlvariable name = "loginRedirect" type = "java.lang.String" * # diff --git a/server-rest/src/test/java/io/opencmw/server/rest/MajordomoRestPluginTests.java b/server-rest/src/test/java/io/opencmw/server/rest/MajordomoRestPluginTests.java new file mode 100644 index 00000000..c9ae3e57 --- /dev/null +++ b/server-rest/src/test/java/io/opencmw/server/rest/MajordomoRestPluginTests.java @@ -0,0 +1,228 @@ +package io.opencmw.server.rest; + +import static org.awaitility.Awaitility.await; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.IOException; +import java.util.Objects; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingDeque; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import javax.net.ssl.*; + +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.opencmw.MimeType; +import io.opencmw.rbac.BasicRbacRole; +import io.opencmw.server.MajordomoBroker; +import io.opencmw.server.rest.test.HelloWorldService; +import io.opencmw.server.rest.test.ImageService; + +import okhttp3.Headers; +import okhttp3.MultipartBody; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.sse.EventSource; +import okhttp3.sse.EventSourceListener; +import okhttp3.sse.EventSources; +import zmq.util.Utils; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class MajordomoRestPluginTests { + private static final Logger LOGGER = LoggerFactory.getLogger(MajordomoRestPluginTests.class); + private MajordomoBroker primaryBroker; + private String brokerRouterAddress; + private MajordomoBroker secondaryBroker; + private String secondaryBrokerRouterAddress; + private OkHttpClient okHttp; + + @BeforeAll + void init() throws IOException { + okHttp = getUnsafeOkHttpClient(); // N.B. ignore SSL certificates + primaryBroker = new MajordomoBroker("PrimaryBroker", "", BasicRbacRole.values()); + brokerRouterAddress = primaryBroker.bind("mdp://localhost:" + Utils.findOpenPort()); + primaryBroker.bind("mds://localhost:" + Utils.findOpenPort()); + MajordomoRestPlugin restPlugin = new MajordomoRestPlugin(primaryBroker.getContext(), "My test REST server", "*:8080", BasicRbacRole.ADMIN); + primaryBroker.start(); + restPlugin.start(); + LOGGER.atInfo().log("Broker and REST plugin started"); + + // start simple test services/properties + final HelloWorldService helloWorldService = new HelloWorldService(primaryBroker.getContext()); + helloWorldService.start(); + final ImageService imageService = new ImageService(primaryBroker.getContext(), 100); + imageService.start(); + + // TODO: add OpenCMW client requesting binary and json models + + // second broker to test DNS functionalities + secondaryBroker = new MajordomoBroker("SecondaryTestBroker", brokerRouterAddress, BasicRbacRole.values()); + secondaryBrokerRouterAddress = secondaryBroker.bind("tcp://*:" + Utils.findOpenPort()); + secondaryBroker.start(); + } + + @AfterAll + void finish() { + secondaryBroker.stopBroker(); + primaryBroker.stopBroker(); + } + + @ParameterizedTest + @ValueSource(strings = { "http://localhost:8080", "https://localhost:8443" }) + void testDns(final String address) throws IOException { + final Request request = new Request.Builder().url(address + "/mmi.dns?noMenu").addHeader("accept", MimeType.HTML.getMediaType()).get().build(); + final Response response = okHttp.newCall(request).execute(); + final String body = Objects.requireNonNull(response.body()).string(); + + assertThat(body, containsString(brokerRouterAddress)); + assertThat(body, containsString(secondaryBrokerRouterAddress)); + assertThat(body, containsString("http://localhost:8080")); + } + + @ParameterizedTest + @EnumSource(value = MimeType.class, names = { "HTML", "BINARY", "JSON", "CMWLIGHT", "TEXT", "UNKNOWN" }) + void testGet(final MimeType contentType) throws IOException { + final Request request = new Request.Builder().url("http://localhost:8080/helloWorld?noMenu").addHeader("accept", contentType.getMediaType()).get().build(); + final Response response = okHttp.newCall(request).execute(); + final Headers header = response.headers(); + final String body = Objects.requireNonNull(response.body()).string(); + + if (contentType == MimeType.TEXT) { + assertEquals(MimeType.HTML.getMediaType(), header.get("Content-Type"), "you get the content type you asked for"); + } else { + assertEquals(contentType.getMediaType(), header.get("Content-Type"), "you get the content type you asked for"); + } + assertThat(body, containsString("byteReturnType")); + assertThat(body, containsString("Hello World! The local time is:")); + + switch (contentType) { + case JSON: + assertThat(body, containsString("\"byteReturnType\": 42,")); + break; + case TEXT: + default: + break; + } + } + + @ParameterizedTest + @EnumSource(value = MimeType.class, names = { "HTML", "BINARY", "JSON", "CMWLIGHT", "TEXT", "UNKNOWN" }) + void testGetException(final MimeType contentType) throws IOException { + final Request request = new Request.Builder().url("http://localhost:8080/mmi.openapi?noMenu").addHeader("accept", contentType.getMediaType()).get().build(); + final Response response = okHttp.newCall(request).execute(); + final Headers header = response.headers(); + final String body = Objects.requireNonNull(response.body()).string(); + switch (contentType) { + case HTML: + case TEXT: + assertEquals(200, response.code()); + assertThat(body, containsString("java.util.concurrent.ExecutionException: java.net.ProtocolException")); + break; + case BINARY: + case JSON: + case CMWLIGHT: + case UNKNOWN: + assertEquals(400, response.code()); + break; + default: + throw new IllegalStateException("test case not covered"); + } + } + + @ParameterizedTest + @EnumSource(value = MimeType.class, names = { "HTML", "JSON" }) + void testSet(final MimeType contentType) throws IOException { + final Request setRequest = new Request.Builder() // + .url("http://localhost:8080/helloWorld?noMenu") + .addHeader("accept", contentType.getMediaType()) + .post(new MultipartBody.Builder().setType(MultipartBody.FORM) // + .addFormDataPart("name", "needsName") + .addFormDataPart("customFilter", "myCustomName") + .addFormDataPart("byteReturnType", "1984") + .build()) + .build(); + final Response setResponse = okHttp.newCall(setRequest).execute(); + assertEquals(200, setResponse.code()); + + final Request getRequest = new Request.Builder().url("http://localhost:8080/helloWorld?noMenu").addHeader("accept", contentType.getMediaType()).get().build(); + final Response response = okHttp.newCall(getRequest).execute(); + final Headers header = response.headers(); + final String body = Objects.requireNonNull(response.body()).string(); + switch (contentType) { + case HTML: + assertThat(body, containsString("name=\"lsaContext\" value='myCustomName'")); + break; + case JSON: + assertThat(body, containsString("\"lsaContext\": \"myCustomName\",")); + break; + default: + throw new IllegalStateException("test case not covered"); + } + } + + @Test + void testSSE() { + AtomicInteger eventCounter = new AtomicInteger(); + Request request = new Request.Builder().url("http://localhost:8080/" + ImageService.PROPERTY_NAME).build(); + EventSourceListener eventSourceListener = new EventSourceListener() { + private final BlockingQueue events = new LinkedBlockingDeque<>(); + @Override + public void onEvent(final @NotNull EventSource eventSource, final String id, final String type, @NotNull String data) { + eventCounter.getAndIncrement(); + } + }; + final EventSource source = EventSources.createFactory(okHttp).newEventSource(request, eventSourceListener); + await().alias("wait for thread to start worker").atMost(1, TimeUnit.SECONDS).until(eventCounter::get, greaterThanOrEqualTo(3)); + assertThat(eventCounter.get(), greaterThanOrEqualTo(3)); + source.cancel(); + } + + private static OkHttpClient getUnsafeOkHttpClient() { + try { + // Create a trust manager that does not validate certificate chains + final TrustManager[] trustAllCerts = new TrustManager[] { + new X509TrustManager(){ + @Override + public void checkClientTrusted(java.security.cert.X509Certificate[] chain, String authType){} + + @Override + public void checkServerTrusted(java.security.cert.X509Certificate[] chain, String authType){} + + @Override + public java.security.cert.X509Certificate[] getAcceptedIssuers(){ + return new java.security.cert.X509Certificate[] {}; + } + } +}; + +// Install the all-trusting trust manager +final SSLContext sslContext = SSLContext.getInstance("SSL"); +sslContext.init(null, trustAllCerts, new java.security.SecureRandom()); +// Create an ssl socket factory with our all-trusting manager +final SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory(); + +OkHttpClient.Builder builder = new OkHttpClient.Builder(); +builder.sslSocketFactory(sslSocketFactory, (X509TrustManager) trustAllCerts[0]); +builder.hostnameVerifier((hostname, session) -> true); +return builder.build(); +} +catch (Exception e) { + throw new RuntimeException(e); +} +} +} diff --git a/server-rest/src/test/java/io/opencmw/server/rest/RestServerTests.java b/server-rest/src/test/java/io/opencmw/server/rest/RestServerTests.java new file mode 100644 index 00000000..6c1fe24d --- /dev/null +++ b/server-rest/src/test/java/io/opencmw/server/rest/RestServerTests.java @@ -0,0 +1,25 @@ +package io.opencmw.server.rest; + +import static io.opencmw.rbac.BasicRbacRole.ANYONE; + +import java.util.Collections; +import java.util.Set; + +import io.javalin.core.security.Role; +import io.javalin.plugin.openapi.dsl.OpenApiBuilder; +import io.javalin.plugin.openapi.dsl.OpenApiDocumentation; +import io.opencmw.server.rest.user.RestUser; + +public class RestServerTests { + public static void main(String[] argv) { + Set accessRoles = Collections.singleton(new RestRole(ANYONE)); + + OpenApiDocumentation apiDocumentation = OpenApiBuilder.document().body(RestUser.class).json("200", RestUser.class); + RestServer.getInstance().get("/", OpenApiBuilder.documented(apiDocumentation, ctx -> { + ctx.html("Hello World!"); + }), accessRoles); + RestServer.getInstance().get("/helloWorld", OpenApiBuilder.documented(apiDocumentation, ctx -> { + ctx.html("Hello World!"); + }), accessRoles); + } +} diff --git a/server-rest/src/test/java/io/opencmw/server/rest/helper/ReplyDataType.java b/server-rest/src/test/java/io/opencmw/server/rest/helper/ReplyDataType.java new file mode 100644 index 00000000..efc0cc0e --- /dev/null +++ b/server-rest/src/test/java/io/opencmw/server/rest/helper/ReplyDataType.java @@ -0,0 +1,35 @@ +package io.opencmw.server.rest.helper; + +import io.opencmw.filter.TimingCtx; +import io.opencmw.serialiser.annotations.MetaInfo; + +import de.gsi.dataset.spi.utils.MultiArray; + +@MetaInfo(description = "reply type class description", direction = "OUT") +public class ReplyDataType { + @MetaInfo(description = "ReplyDataType name to show up in the OpenAPI docs") + public String name; + public boolean booleanReturnType; + public byte byteReturnType; + public short shortReturnType; + @MetaInfo(description = "a return value", unit = "A", direction = "OUT", groups = { "A", "B" }) + public int intReturnValue; + public long longReturnValue; + public byte[] byteArray; + public MultiArray multiArray = MultiArray.wrap(new double[] { 1.0, 2.0, 3.0 }, 0, new int[] { 1 }); + @MetaInfo(description = "WR timing context", direction = "OUT", groups = { "A", "B" }) + public TimingCtx timingCtx; + @MetaInfo(description = "LSA timing context", direction = "OUT", groups = { "A", "B" }) + public String lsaContext = ""; + @MetaInfo(description = "custom enum reply option", direction = "OUT", groups = { "A", "B" }) + public ReplyOption replyOption = ReplyOption.REPLY_OPTION2; + + public ReplyDataType() { + // needs default constructor + } + + @Override + public String toString() { + return "ReplyDataType{outputName='" + name + "', returnValue=" + intReturnValue + '}'; + } +} diff --git a/server-rest/src/test/java/io/opencmw/server/rest/helper/ReplyOption.java b/server-rest/src/test/java/io/opencmw/server/rest/helper/ReplyOption.java new file mode 100644 index 00000000..5d9a3457 --- /dev/null +++ b/server-rest/src/test/java/io/opencmw/server/rest/helper/ReplyOption.java @@ -0,0 +1,11 @@ +package io.opencmw.server.rest.helper; + +import io.opencmw.serialiser.annotations.MetaInfo; + +@MetaInfo(unit = "custom enum", description = "just for example purposes") +public enum ReplyOption { + REPLY_OPTION1, + REPLY_OPTION2, + REPLY_OPTION3, + REPLY_OPTION4 +} diff --git a/server-rest/src/test/java/io/opencmw/server/rest/helper/RequestDataType.java b/server-rest/src/test/java/io/opencmw/server/rest/helper/RequestDataType.java new file mode 100644 index 00000000..8e91bf2d --- /dev/null +++ b/server-rest/src/test/java/io/opencmw/server/rest/helper/RequestDataType.java @@ -0,0 +1,48 @@ +package io.opencmw.server.rest.helper; + +import io.opencmw.MimeType; +import io.opencmw.filter.TimingCtx; +import io.opencmw.serialiser.annotations.MetaInfo; + +@MetaInfo(description = "request type class description", direction = "IN") +public class RequestDataType { + @MetaInfo(description = " RequestDataType name to show up in the OpenAPI docs") + public String name = ""; + @MetaInfo(description = "FAIR timing context selector, e.g. FAIR.SELECTOR.C=0, ALL, ...") + public TimingCtx ctx = TimingCtx.get("FAIR.SELECTOR.ALL"); + @MetaInfo(description = "custom filter") + public String customFilter = ""; + @MetaInfo(description = "requested MIME content type, eg. 'application/binary', 'text/html','text/json', ..") + public MimeType contentType = MimeType.BINARY; + + public RequestDataType() { + // needs default constructor + } + + @Override + public String toString() { + return "RequestDataType{name='" + name + "', ctx=" + ctx + ", customFilter='" + customFilter + "'}"; + } + + @Override + public boolean equals(final Object o) { + if (this == o) + return true; + if (!(o instanceof RequestDataType)) + return false; + final RequestDataType that = (RequestDataType) o; + if (!name.equals(that.name)) + return false; + if (!ctx.equals(that.ctx)) + return false; + return customFilter.equals(that.customFilter); + } + + @Override + public int hashCode() { + int result = name.hashCode(); + result = 31 * result + ctx.hashCode(); + result = 31 * result + customFilter.hashCode(); + return result; + } +} diff --git a/server-rest/src/test/java/io/opencmw/server/rest/helper/TestContext.java b/server-rest/src/test/java/io/opencmw/server/rest/helper/TestContext.java new file mode 100644 index 00000000..589ff940 --- /dev/null +++ b/server-rest/src/test/java/io/opencmw/server/rest/helper/TestContext.java @@ -0,0 +1,23 @@ +package io.opencmw.server.rest.helper; + +import io.opencmw.MimeType; +import io.opencmw.filter.TimingCtx; +import io.opencmw.serialiser.annotations.MetaInfo; + +public class TestContext { + @MetaInfo(description = "FAIR timing context selector, e.g. FAIR.SELECTOR.C=0, ALL, ...") + public TimingCtx ctx = TimingCtx.get("FAIR.SELECTOR.ALL"); + @MetaInfo(unit = "a.u.", description = "random test parameter") + public String testFilter = "default value"; + @MetaInfo(description = "requested MIME content type, eg. 'application/binary', 'text/html','text/json', ..") + public MimeType contentType = MimeType.BINARY; + + public TestContext() { + // needs default constructor + } + + @Override + public String toString() { + return "TestContext{ctx=" + ctx + ", testFilter='" + testFilter + "', contentType=" + contentType + '}'; + } +} diff --git a/server-rest/src/test/java/io/opencmw/server/rest/samples/MajordomoRestPluginSample.java b/server-rest/src/test/java/io/opencmw/server/rest/samples/MajordomoRestPluginSample.java new file mode 100644 index 00000000..e8ed5332 --- /dev/null +++ b/server-rest/src/test/java/io/opencmw/server/rest/samples/MajordomoRestPluginSample.java @@ -0,0 +1,43 @@ +package io.opencmw.server.rest.samples; + +import java.io.IOException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.opencmw.rbac.BasicRbacRole; +import io.opencmw.server.MajordomoBroker; +import io.opencmw.server.rest.MajordomoRestPlugin; +import io.opencmw.server.rest.test.HelloWorldService; +import io.opencmw.server.rest.test.ImageService; + +import zmq.util.Utils; + +public class MajordomoRestPluginSample { + private static final Logger LOGGER = LoggerFactory.getLogger(MajordomoRestPluginSample.class); + + public static void main(String[] args) throws IOException { + MajordomoBroker primaryBroker = new MajordomoBroker("PrimaryBroker", "", BasicRbacRole.values()); + final String brokerRouterAddress = primaryBroker.bind("tcp://*:" + Utils.findOpenPort()); + primaryBroker.bind("mds://*:" + Utils.findOpenPort()); + MajordomoRestPlugin restPlugin = new MajordomoRestPlugin(primaryBroker.getContext(), "My test REST server", "*:8080", BasicRbacRole.ADMIN); + primaryBroker.start(); + restPlugin.start(); + LOGGER.atInfo().log("Broker and REST plugin started"); + + // start simple test services/properties + final HelloWorldService helloWorldService = new HelloWorldService(primaryBroker.getContext()); + helloWorldService.start(); + final ImageService imageService = new ImageService(primaryBroker.getContext(), 2000); + imageService.start(); + + // TODO: add OpenCMW client requesting binary and json models + + // second broker to test DNS functionalities + MajordomoBroker secondaryBroker = new MajordomoBroker("SecondaryTestBroker", brokerRouterAddress, BasicRbacRole.values()); + secondaryBroker.bind("tcp://*:" + Utils.findOpenPort()); + secondaryBroker.start(); + + LOGGER.atInfo().log("added services"); + } +} diff --git a/server-rest/src/test/java/io/opencmw/server/rest/test/HelloWorldService.java b/server-rest/src/test/java/io/opencmw/server/rest/test/HelloWorldService.java new file mode 100644 index 00000000..825a5725 --- /dev/null +++ b/server-rest/src/test/java/io/opencmw/server/rest/test/HelloWorldService.java @@ -0,0 +1,55 @@ +package io.opencmw.server.rest.test; + +import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; +import java.util.Locale; +import java.util.concurrent.TimeUnit; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.zeromq.ZContext; + +import io.opencmw.OpenCmwProtocol; +import io.opencmw.filter.TimingCtx; +import io.opencmw.rbac.RbacRole; +import io.opencmw.serialiser.annotations.MetaInfo; +import io.opencmw.server.MajordomoWorker; +import io.opencmw.server.rest.helper.ReplyDataType; +import io.opencmw.server.rest.helper.RequestDataType; +import io.opencmw.server.rest.helper.TestContext; + +@MetaInfo(unit = "short description", description = "This is an example property implementation.
" + + "Use this as a starting point for implementing your own properties
") +public class HelloWorldService extends MajordomoWorker { + private static final Logger LOGGER = LoggerFactory.getLogger(HelloWorldService.class); + private String customFilter = "uninitialised"; + public HelloWorldService(final ZContext ctx, final RbacRole... rbacRoles) { + super(ctx, "helloWorld", TestContext.class, RequestDataType.class, ReplyDataType.class, rbacRoles); + + this.setHandler((rawCtx, reqCtx, in, repCtx, out) -> { + // LOGGER.atInfo().addArgument(rawCtx).log("received rawCtx = {}") + LOGGER.atInfo().addArgument(reqCtx).log("received reqCtx = {}"); + LOGGER.atInfo().addArgument(in.name).log("received in.name = {}"); + + // some arbitrary data processing + final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.UK); + out.name = "Hello World! The local time is: " + sdf.format(System.currentTimeMillis()); + out.byteArray = in.name.getBytes(StandardCharsets.UTF_8); + out.byteReturnType = 42; + out.timingCtx = TimingCtx.get("FAIR.SELECTOR.C=3"); + out.timingCtx.bpcts = TimeUnit.MILLISECONDS.toMicros(System.currentTimeMillis()); + if (rawCtx.req.command == OpenCmwProtocol.Command.SET_REQUEST) { + // poor man's local setting management + customFilter = in.customFilter; + } + out.lsaContext = customFilter; + + repCtx.ctx = out.timingCtx; + repCtx.contentType = reqCtx.contentType; + repCtx.testFilter = "HelloWorld - reply topic = " + reqCtx.testFilter; + + LOGGER.atInfo().addArgument(repCtx).log("received repCtx = {}"); + LOGGER.atInfo().addArgument(out.name).log("received out.name = {}"); + }); + } +} diff --git a/server-rest/src/test/java/io/opencmw/server/rest/test/ImageService.java b/server-rest/src/test/java/io/opencmw/server/rest/test/ImageService.java new file mode 100644 index 00000000..0b7954cc --- /dev/null +++ b/server-rest/src/test/java/io/opencmw/server/rest/test/ImageService.java @@ -0,0 +1,76 @@ +package io.opencmw.server.rest.test; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Timer; +import java.util.TimerTask; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.zeromq.ZContext; + +import io.opencmw.MimeType; +import io.opencmw.domain.BinaryData; +import io.opencmw.domain.NoData; +import io.opencmw.rbac.RbacRole; +import io.opencmw.serialiser.annotations.MetaInfo; +import io.opencmw.server.MajordomoWorker; +import io.opencmw.server.rest.helper.TestContext; + +import com.google.common.io.ByteStreams; + +@MetaInfo(unit = "test image service", description = "Simple test image service that rotates through" + + " a couple of pre-defined images @0.5 Hz") +public class ImageService extends MajordomoWorker { + private static final Logger LOGGER = LoggerFactory.getLogger(ImageService.class); + public static final String PROPERTY_NAME = "testImage"; + private static final String[] TEST_IMAGES = { "testimages/PM5544_test_signal.png", "testimages/SMPTE_Color_Bars.png" }; + private final byte[][] imageData; + private final AtomicInteger selectedImage = new AtomicInteger(); + + public ImageService(final ZContext ctx, final int updateInterval, final RbacRole... rbacRoles) { + super(ctx, PROPERTY_NAME, TestContext.class, NoData.class, BinaryData.class, rbacRoles); + imageData = new byte[TEST_IMAGES.length][]; + for (int i = 0; i < TEST_IMAGES.length; i++) { + try (final InputStream in = this.getClass().getResourceAsStream(TEST_IMAGES[i])) { + imageData[i] = ByteStreams.toByteArray(in); + LOGGER.atInfo().addArgument(TEST_IMAGES[i]).addArgument(imageData[i].length).log("read test image file: '{}' - bytes: {}"); + } catch (IOException e) { + LOGGER.atError().setCause(e).addArgument(TEST_IMAGES[i]).log("could not read test image file: '{}'"); + } + } + + final Timer timer = new Timer(); + timer.schedule(new TimerTask() { + @Override + public void run() { + selectedImage.set(selectedImage.incrementAndGet() % imageData.length); + final TestContext replyCtx = new TestContext(); + BinaryData reply = new BinaryData(); + reply.resourceName = "test.png"; + reply.data = imageData[selectedImage.get()]; //TODO rotate through images + replyCtx.contentType = MimeType.PNG; + ImageService.this.notify(replyCtx, reply); + // System.err.println("notify new image " + replyCtx) + } + }, TimeUnit.MILLISECONDS.toMillis(updateInterval), TimeUnit.MILLISECONDS.toMillis(updateInterval)); + + this.setHandler((rawCtx, reqCtx, in, repCtx, reply) -> { + final String path = StringUtils.stripStart(rawCtx.req.topic.getPath(), "/"); + LOGGER.atTrace().addArgument(reqCtx).addArgument(path).log("received reqCtx = {} - path='{}'"); + + reply.resourceName = StringUtils.stripStart(StringUtils.stripStart(path, PROPERTY_NAME), "/"); + reply.data = imageData[selectedImage.get()]; //TODO rotate through images + reply.contentType = MimeType.PNG; + + if (reply.resourceName.contains(".")) { + repCtx.contentType = reply.contentType; + } else { + repCtx.contentType = reqCtx.contentType; + } + }); + } +} diff --git a/server-rest/src/test/resources/io/opencmw/server/rest/test/testimages/PM5544_test_signal.png b/server-rest/src/test/resources/io/opencmw/server/rest/test/testimages/PM5544_test_signal.png new file mode 100644 index 0000000000000000000000000000000000000000..4c55c9dbfd78f517efeb63c402b511a9bcaf0e11 GIT binary patch literal 15118 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 Xd4fuKjbkpr77%r1oeM?htwa7BkS8Q( literal 0 HcmV?d00001 diff --git a/server-rest/src/test/resources/io/opencmw/server/rest/test/testimages/SMPTE_Color_Bars.png b/server-rest/src/test/resources/io/opencmw/server/rest/test/testimages/SMPTE_Color_Bars.png new file mode 100644 index 0000000000000000000000000000000000000000..34d265e4863ff4c028f47b3af405004b3390cc52 GIT binary patch literal 1611 zcmeAS@N?(olHy`uVBq!ia0y~yU}|7sV9elP0*ZJ9++_n&Ea{HEjtmSN`?>!lvI6-E z$sR$z3=CCj3=9n|3=F@3LJcn%7)lKo7+xhXFj&oCU=S~uvn$XBD8ZKG?e4&pI+gHupb=-YZn4F(2Qe@_?3kcwMxuN(R?If^tqtPCjN=jMvy=JrkC zPw;li-PAgLLT~|tG&kQvRUX#x1!mC_65Q6p!W@Q;+wX@nCZ61PGxnd)mfYxHDH|h} z%uL-p(_d}3$zzkJw&!cNMNU0wb=Ktc&!Szk)qY85T=LnZwYjv?)7bC!zOSp?o0%9m z3_wWX00=cOFar?-6AuVMBu26vBv7!2%Se%%2Fw5wAS93gLJkd#K*Yev20{=CD#DHWXlK2>eqj5@Icd}SKm8J&zjR7RN5_2e z{rCQT{W&rI_wvK~pVzGWRgnFtE5*X*5G~zt>~Z0GwtIQo*Zo?VwN~t*ll%jY?t)wM zk_{yI+SC7?sM~KZ!v~}@OsxL<>{@sFOU1Xf?q%Q{JDNCA6X%0fHOy8FM)`p}yz79a zt!jyDL`h0wNvc(HQ7VvPFfuSS*EO)vHLwgZG`BJ_v@*2NHZZU + + 4.0.0 + + + io.opencmw + opencmw + ${revision}${sha1}${changelist} + ../pom.xml + + + server + + + ZeroMQ and REST-based micro-service server implementation. + + + + + io.opencmw + core + ${revision}${sha1}${changelist} + + + de.gsi.dataset + chartfx-dataset + ${version.chartfx} + test + + + + org.apache.velocity + velocity-engine-core + ${version.velocity} + compile + + + + diff --git a/server/src/main/java/io/opencmw/server/BasicMdpWorker.java b/server/src/main/java/io/opencmw/server/BasicMdpWorker.java new file mode 100644 index 00000000..73c97457 --- /dev/null +++ b/server/src/main/java/io/opencmw/server/BasicMdpWorker.java @@ -0,0 +1,377 @@ +package io.opencmw.server; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import static io.opencmw.OpenCmwProtocol.*; +import static io.opencmw.OpenCmwProtocol.Command.*; +import static io.opencmw.OpenCmwProtocol.MdpMessage.receive; +import static io.opencmw.OpenCmwProtocol.MdpSubProtocol.PROT_WORKER; +import static io.opencmw.server.MajordomoBroker.SCHEME_MDP; +import static io.opencmw.server.MajordomoBroker.SCHEME_MDS; +import static io.opencmw.server.MajordomoBroker.SCHEME_TCP; +import static io.opencmw.utils.AnsiDefs.ANSI_RED; +import static io.opencmw.utils.AnsiDefs.ANSI_RESET; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.lang.management.ManagementFactory; +import java.net.URI; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.locks.LockSupport; + +import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.zeromq.SocketType; +import org.zeromq.ZContext; +import org.zeromq.ZMQ; +import org.zeromq.ZMsg; + +import io.opencmw.rbac.RbacRole; +import io.opencmw.serialiser.annotations.MetaInfo; +import io.opencmw.serialiser.utils.ClassUtils; +import io.opencmw.utils.SystemProperties; + +/** + * Majordomo Protocol Client API, Java version Implements the OpenCmwProtocol/Worker spec at + * http://rfc.zeromq.org/spec:7. + * + *

+ * The worder is controlled by the following environment variables (see also MajordomoBroker definitions): + *

    + *
  • 'OpenCMW.heartBeat' [ms]: default (2500 ms) heart-beat time-out [ms]
  • + *
  • 'OpenCMW.heartBeatLiveness' []: default (3) heart-beat liveness - 3-5 is reasonable + * N.B. heartbeat expires when last heartbeat message is more than HEARTBEAT_INTERVAL * HEARTBEAT_LIVENESS ms ago. + * this implies also, that worker must either return their message within 'HEARTBEAT_INTERVAL * HEARTBEAT_LIVENESS ms' + * or decouple their secondary handler interface into another thread.
  • + *
+ * + */ +@MetaInfo(description = "default BasicMdpWorker implementation") +@SuppressWarnings({ "PMD.GodClass", "PMD.ExcessiveImports", "PMD.TooManyStaticImports", "PMD.DoNotUseThreads", "PMD.TooManyFields", "PMD.TooManyMethods" }) // makes the code more readable/shorter lines +public class BasicMdpWorker extends Thread { + protected static final byte[] RBAC = {}; //TODO: implement RBAC between Majordomo and Worker + protected static final String WILDCARD = "*"; + protected static final int HEARTBEAT_LIVENESS = SystemProperties.getValueIgnoreCase("OpenCMW.heartBeatLiveness", 3); // [counts] 3-5 is reasonable + protected static final int HEARTBEAT_INTERVAL = SystemProperties.getValueIgnoreCase("OpenCMW.heartBeat", 2500); // [ms] + protected static final AtomicInteger WORKER_COUNTER = new AtomicInteger(); + private static final Logger LOGGER = LoggerFactory.getLogger(BasicMdpWorker.class); + + static { + final String reason = "recursive definitions inside ZeroMQ"; + ClassUtils.DO_NOT_PARSE_MAP.put(ZContext.class, reason); + ClassUtils.DO_NOT_PARSE_MAP.put(ZMQ.Socket.class, reason); + ClassUtils.DO_NOT_PARSE_MAP.put(ZMQ.Poller.class, reason); + } + + // --------------------------------------------------------------------- + protected final String uniqueID; + protected final ZContext ctx; + protected final String brokerAddress; + protected final String serviceName; + protected final byte[] serviceBytes; + + protected final AtomicBoolean runSocketHandlerLoop = new AtomicBoolean(true); + protected final SortedSet rbacRoles; // NOSONAR NOPMD + protected final ZMQ.Socket notifySocket; // Socket to listener -- needed for thread-decoupling + protected final ZMQ.Socket notifyListenerSocket; // Socket to notifier -- needed for thread-decoupling + protected final List activeSubscriptions = Collections.synchronizedList(new ArrayList<>()); + protected final boolean isExternal; // used to skip heart-beating and disconnect checks + protected ZMQ.Socket workerSocket; // Socket to broker + protected ZMQ.Socket pubSocket; // Socket to broker + protected long heartbeatAt; // When to send HEARTBEAT + protected int liveness; // How many attempts left + protected long reconnect = 2500L; // Reconnect delay, msecs + protected RequestHandler requestHandler; + protected ZMQ.Poller poller; + + public BasicMdpWorker(String brokerAddress, String serviceName, final RbacRole... rbacRoles) { + this(null, brokerAddress, serviceName, rbacRoles); + } + + public BasicMdpWorker(ZContext ctx, String serviceName, final RbacRole... rbacRoles) { + this(ctx, "inproc://broker", serviceName, rbacRoles); + } + + protected BasicMdpWorker(ZContext ctx, String brokerAddress, String serviceName, final RbacRole... rbacRoles) { + super(); + assert (brokerAddress != null); + assert (serviceName != null); + this.brokerAddress = StringUtils.stripEnd(brokerAddress, "/"); + this.serviceName = StringUtils.stripStart(serviceName, "/"); + this.serviceBytes = this.serviceName.getBytes(UTF_8); + this.isExternal = !brokerAddress.toLowerCase(Locale.UK).contains("inproc://"); + + // initialise RBAC role-based priority queues + this.rbacRoles = Collections.unmodifiableSortedSet(new TreeSet<>(Set.of(rbacRoles))); + + this.ctx = Objects.requireNonNullElseGet(ctx, ZContext::new); + if (ctx != null) { + this.setDaemon(true); + } + this.setName(BasicMdpWorker.class.getSimpleName() + "#" + WORKER_COUNTER.getAndIncrement()); + this.uniqueID = this.serviceName + "-PID=" + ManagementFactory.getRuntimeMXBean().getName() + "-TID=" + this.getId(); + this.setName(this.getClass().getSimpleName() + "-" + uniqueID); + + notifyListenerSocket = this.ctx.createSocket(SocketType.PAIR); + notifyListenerSocket.bind("inproc://notifyListener" + uniqueID); + notifyListenerSocket.setHWM(0); + notifySocket = this.ctx.createSocket(SocketType.PAIR); + notifySocket.connect("inproc://notifyListener" + uniqueID); + notifySocket.setHWM(0); + + LOGGER.atTrace().addArgument(serviceName).addArgument(uniqueID).log("created new service '{}' worker - uniqueID: {}"); + } + + public SortedSet getRbacRoles() { // NOSONAR NOPMD + return rbacRoles; + } + + public Duration getReconnectDelay() { + return Duration.ofMillis(reconnect); + } + + public RequestHandler getRequestHandler() { + return requestHandler; + } + + public String getServiceName() { + return serviceName; + } + + public String getUniqueID() { + return uniqueID; + } + + /** + * Sends pre-defined message to subscriber (provided there is any that matches the published topic) + * @param notifyMessage the message that is supposed to be broadcast + * @return {@code false} in case message has not been sent (e.g. due to no pending subscriptions + */ + public boolean notify(@NotNull final MdpMessage notifyMessage) { + // send only if there are matching topics and duplicate messages based on topics as necessary + final URI originalTopic = notifyMessage.topic; + final List subTopics = new ArrayList<>(activeSubscriptions); // copy for decoupling/performance reasons + // N.B. for the time being only the path is matched - TODO: upgrade to full topic matching + if (subTopics.stream().filter(s -> s.startsWith(originalTopic.getPath()) || s.isBlank()).findFirst().isEmpty()) { + // block further processing of message + return false; + } + + notifyMessage.senderID = EMPTY_FRAME; + notifyMessage.protocol = PROT_WORKER; + notifyMessage.command = W_NOTIFY; + notifyMessage.serviceNameBytes = EMPTY_FRAME; + notifyMessage.clientRequestID = EMPTY_FRAME; + notifyMessage.topic = originalTopic; + return notifyRaw(notifyMessage); + } + + public BasicMdpWorker registerHandler(final RequestHandler requestHandler) { + this.requestHandler = requestHandler; + return this; + } + + /** + * primary run loop + * Send reply, if any, to broker and wait for next request. + */ + @Override + public void run() { + reconnectToBroker(); + // Poll socket for a reply, with timeout and/or until the process is stopped or interrupted + // N.B. poll(..) returns '-1' when thread is interrupted + final MdpMessage heartbeatMsg = new MdpMessage(null, PROT_WORKER, W_HEARTBEAT, serviceBytes, EMPTY_FRAME, EMPTY_URI, EMPTY_FRAME, "", RBAC); + while (runSocketHandlerLoop.get() && !Thread.currentThread().isInterrupted() && poller.poll(HEARTBEAT_INTERVAL) != -1) { + boolean dataReceived = true; + while (dataReceived) { + // handle message from or to broker + final MdpMessage brokerMsg = receive(workerSocket, false); + dataReceived = MdpMessage.send(workerSocket, handleRequestsFromBroker(brokerMsg)); + + final ZMsg pubMsg = ZMsg.recvMsg(pubSocket, false); + dataReceived |= handleSubscriptionMsg(pubMsg); + + // handle message from or to notify thread + final MdpMessage notifyMsg = receive(notifyListenerSocket, false); + if (notifyMsg != null) { + // forward notify message to MDP broker + dataReceived |= notifyMsg.send(workerSocket); + } + } + + if (System.currentTimeMillis() > heartbeatAt && --liveness == 0) { + LOGGER.atWarn().addArgument(uniqueID).log("worker '{}' disconnected from broker - retrying"); + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(reconnect)); + reconnectToBroker(); + } + + // Send HEARTBEAT if it's time + if (System.currentTimeMillis() > heartbeatAt) { + heartbeatMsg.send(workerSocket); + heartbeatAt = System.currentTimeMillis() + HEARTBEAT_INTERVAL; + } + } + if (Thread.currentThread().isInterrupted()) { + LOGGER.atWarn().addArgument(uniqueID).log("worker '{}' interrupt received, killing worker"); + } + if (isExternal) { + ctx.destroy(); + } + } + + public void setReconnectDelay(final int reconnect, @NotNull final TimeUnit timeUnit) { + this.reconnect = timeUnit.toMillis(reconnect); + } + + @Override + public synchronized void start() { // NOPMD 'synchronized' comes from JDK class definition + runSocketHandlerLoop.set(true); + super.start(); + } + + public void stopWorker() { + runSocketHandlerLoop.set(false); + } + + protected List handleRequestsFromBroker(final MdpMessage request) { + if (request == null) { + return Collections.emptyList(); + } + + liveness = HEARTBEAT_LIVENESS; + + switch (request.command) { + case GET_REQUEST: + case SET_REQUEST: + case W_NOTIFY: + case PARTIAL: + case FINAL: + return processRequest(request); + case W_HEARTBEAT: + // Do nothing for heartbeats + return Collections.emptyList(); + case DISCONNECT: + // TODO: check whether to reconnect or to disconnect permanently + reconnectToBroker(); + return Collections.emptyList(); + case READY: + case SUBSCRIBE: + case UNSUBSCRIBE: + case UNKNOWN: + // N.B. not too verbose logging since we do not want that sloppy clients + // can bring down the broker through warning or info messages + if (LOGGER.isDebugEnabled()) { + LOGGER.atWarn().addArgument(getServiceName()).addArgument(request.command).log("service '{}' erroneously received {} command - should be handled in Majordomo broker"); + } + return Collections.emptyList(); + default: + } + throw new IllegalStateException("should not reach here - request message = " + request); + } + + protected boolean handleSubscriptionMsg(final ZMsg subMsg) { // NOPMD + if (subMsg == null || subMsg.isEmpty()) { + return false; + } + final byte[] topicBytes = subMsg.getFirst().getData(); + if (topicBytes.length == 0) { + return false; + } + final Command subType = topicBytes[0] == 1 ? SUBSCRIBE : (topicBytes[0] == 0 ? UNSUBSCRIBE : UNKNOWN); // '1'('0' being the default ZeroMQ (un-)subscribe command + final String subscriptionTopic = new String(topicBytes, 1, topicBytes.length - 1, UTF_8); + if (LOGGER.isDebugEnabled() && (subscriptionTopic.isBlank() || subscriptionTopic.contains(getServiceName()))) { + LOGGER.atDebug().addArgument(getServiceName()).addArgument(subType).addArgument(subscriptionTopic).log("Service '{}' received subscription request: {} to '{}'"); + } + + if (!subscriptionTopic.isBlank() && !subscriptionTopic.startsWith(getServiceName())) { + // subscription topic for another service + return false; + } + switch (subType) { + case SUBSCRIBE: + activeSubscriptions.add(subscriptionTopic); + return true; + case UNSUBSCRIBE: + activeSubscriptions.remove(subscriptionTopic); + return true; + case UNKNOWN: + default: + return false; + } + } + + protected boolean notifyRaw(@NotNull final MdpMessage notifyMessage) { + assert notifyMessage != null : "notify message must not be null"; + return notifyMessage.send(notifySocket); + } + + protected List processRequest(final MdpMessage request) { + // de-serialise byte[] -> PropertyMap() (+ getObject(Class)) + try { + final Context mdpCtx = new Context(request); + getRequestHandler().handle(mdpCtx); + return mdpCtx.rep == null ? Collections.emptyList() : List.of(mdpCtx.rep); + } catch (Throwable e) { // NOPMD on purpose since we want to catch exceptions and courteously return this to the user + final StringWriter sw = new StringWriter(); + final PrintWriter pw = new PrintWriter(sw); + e.printStackTrace(pw); + final String exceptionMsg = ANSI_RED + getClass().getName() + " caught exception for service '" + getServiceName() + + "'\nrequest msg: " + request + "\nexception: " + sw.toString() + ANSI_RESET; + if (LOGGER.isDebugEnabled()) { + LOGGER.atError().addArgument(exceptionMsg).log("could not processRequest(MdpMessage) - exception thrown:\n{}"); + } + return List.of(new MdpMessage(request.senderID, request.protocol, FINAL, request.serviceNameBytes, request.clientRequestID, request.topic, null, exceptionMsg, RBAC)); + } + } + + /** + * Connect or reconnect to broker + */ + protected void reconnectToBroker() { + if (workerSocket != null) { + workerSocket.close(); + } + final String translatedBrokerAddress = brokerAddress.replace(SCHEME_MDP, SCHEME_TCP).replace(SCHEME_MDS, SCHEME_TCP); + workerSocket = ctx.createSocket(SocketType.DEALER); + assert workerSocket != null : "worker socket is null"; + workerSocket.setHWM(0); + workerSocket.connect(translatedBrokerAddress + MajordomoBroker.SUFFIX_ROUTER); + + if (pubSocket != null) { + pubSocket.close(); + } + pubSocket = ctx.createSocket(SocketType.XPUB); + assert pubSocket != null : "publication socket is null"; + pubSocket.setHWM(0); + pubSocket.setXpubVerbose(true); + pubSocket.connect(translatedBrokerAddress + MajordomoBroker.SUFFIX_SUBSCRIBE); + + // Register service with broker + LOGGER.atInfo().addArgument(brokerAddress).log("register service with broker '{}"); + final byte[] classNameByte = this.getClass().getName().getBytes(UTF_8); // used for OpenAPI purposes + new MdpMessage(null, PROT_WORKER, READY, serviceBytes, EMPTY_FRAME, URI.create(serviceName), classNameByte, "", RBAC).send(workerSocket); + + if (poller != null) { + poller.unregister(workerSocket); + poller.close(); + } + poller = ctx.createPoller(3); + poller.register(workerSocket, ZMQ.Poller.POLLIN); + poller.register(pubSocket, ZMQ.Poller.POLLIN); + poller.register(notifyListenerSocket, ZMQ.Poller.POLLIN); + + // If liveness hits zero, queue is considered disconnected + liveness = HEARTBEAT_LIVENESS; + heartbeatAt = System.currentTimeMillis() + HEARTBEAT_INTERVAL; + } + + public interface RequestHandler { + void handle(Context ctx) throws Throwable; // NOPMD NOSONAR - should allow to throw any/generic exceptions + } +} diff --git a/server/src/main/java/io/opencmw/server/DefaultHtmlHandler.java b/server/src/main/java/io/opencmw/server/DefaultHtmlHandler.java new file mode 100644 index 00000000..7f089d1f --- /dev/null +++ b/server/src/main/java/io/opencmw/server/DefaultHtmlHandler.java @@ -0,0 +1,119 @@ +package io.opencmw.server; + +import java.io.StringWriter; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BiFunction; +import java.util.function.Consumer; + +import org.apache.commons.lang3.StringUtils; +import org.apache.velocity.VelocityContext; +import org.apache.velocity.app.VelocityEngine; +import org.zeromq.util.ZData; + +import io.opencmw.OpenCmwProtocol; +import io.opencmw.QueryParameterParser; +import io.opencmw.domain.BinaryData; +import io.opencmw.serialiser.FieldDescription; +import io.opencmw.serialiser.spi.ClassFieldDescription; +import io.opencmw.serialiser.utils.ClassUtils; + +public class DefaultHtmlHandler implements MajordomoWorker.Handler { + public static final String NO_MENU = "noMenu"; + private static final String TEMPLATE_DEFAULT = "/velocity/property/defaultPropertyLayout.vm"; + protected final Class mdpWorkerClass; + protected final Consumer> userContextMapModifier; + protected final String velocityTemplate; + protected final VelocityEngine velocityEngine = new VelocityEngine(); + + public DefaultHtmlHandler(final Class mdpWorkerClass, final String velocityTemplate, final Consumer> userContextMapModifier) { + this.mdpWorkerClass = mdpWorkerClass; + this.velocityTemplate = velocityTemplate == null ? TEMPLATE_DEFAULT : velocityTemplate; + this.userContextMapModifier = userContextMapModifier; + velocityEngine.setProperty("resource.loaders", "class"); + velocityEngine.setProperty("resource.loader.class.class", "org.apache.velocity.runtime.resource.loader.ClasspathResourceLoader"); + velocityEngine.setProperty("velocimacro.library.autoreload", "true"); + velocityEngine.setProperty("resource.loader.file.cache", "false"); + velocityEngine.setProperty("velocimacro.inline.replace_global", "true"); + velocityEngine.init(); + } + + @Override + public void handle(OpenCmwProtocol.Context rawCtx, C requestCtx, I request, C replyCtx, O reply) { + final String queryString = rawCtx.req.topic.getQuery(); + final boolean noMenu = queryString != null && queryString.contains(NO_MENU); + + final HashMap context = new HashMap<>(); + // pre-fill context + + context.put(NO_MENU, noMenu); + try { + context.put("requestedURI", rawCtx.req.topic.toString()); + context.put("requestedURInoFrame", QueryParameterParser.appendQueryParameter(rawCtx.req.topic, NO_MENU).toString()); + } catch (URISyntaxException e) { + throw new IllegalStateException("appendURI error for " + rawCtx.req.topic, e); + } + final ClassFieldDescription fieldDescription = mdpWorkerClass == null ? null : ClassUtils.getFieldDescription(mdpWorkerClass); + context.put("service", StringUtils.stripStart(rawCtx.req.topic.getPath(), "/")); + context.put("mdpClass", mdpWorkerClass); + context.put("mdpMetaData", fieldDescription); + context.put("mdpCommand", rawCtx.req.command); + context.put("clientRequestID", ZData.toString(rawCtx.req.clientRequestID)); + + context.put("requestTopic", rawCtx.req.topic); + context.put("replyTopic", rawCtx.rep.topic); + context.put("requestCtx", requestCtx); + context.put("replyCtx", replyCtx); + context.put("request", request); + context.put("reply", reply); + context.put("requestCtxClassData", generateQueryParameter(requestCtx)); + context.put("replyCtxClassData", generateQueryParameter(replyCtx)); + + context.put("requestClassData", generateQueryParameter(request)); + if (BinaryData.class.equals(request.getClass())) { + final byte[] rawData = ((BinaryData) request).data; + context.put("requestMimeType", ((BinaryData) reply).contentType.toString()); + context.put("requestResourceName", ((BinaryData) reply).resourceName); + context.put("requestRawData", Base64.getEncoder().encodeToString(rawData)); + } + + context.put("replyClassData", generateQueryParameter(reply)); + if (BinaryData.class.equals(reply.getClass())) { + final byte[] rawData = ((BinaryData) reply).data; + context.put("replyMimeType", ((BinaryData) reply).contentType.toString()); + context.put("replyResourceName", ((BinaryData) reply).resourceName); + context.put("replyRawData", Base64.getEncoder().encodeToString(rawData)); + } + + if (userContextMapModifier != null) { + userContextMapModifier.accept(context); + } + + StringWriter writer = new StringWriter(); + velocityEngine.getTemplate(velocityTemplate).merge(new VelocityContext(context), writer); + String returnVal = writer.toString(); + rawCtx.rep.data = returnVal.getBytes(StandardCharsets.UTF_8); + } + + public static Map generateQueryParameter(Object obj) { + final ClassFieldDescription fieldDescription = ClassUtils.getFieldDescription(obj.getClass()); + final Map map = new HashMap<>(); // NOPMD - no concurrent access, used in a single thread and is then destroyed + final List children = fieldDescription.getChildren(); + for (FieldDescription child : children) { + ClassFieldDescription field = (ClassFieldDescription) child; + final BiFunction mapFunction = QueryParameterParser.CLASS_TO_STRING_CONVERTER.get(field.getType()); + final String str; + if (mapFunction == null) { + str = QueryParameterParser.CLASS_TO_STRING_CONVERTER.get(Object.class).apply(obj, field); + } else { + str = mapFunction.apply(obj, field); + } + map.put(field, StringUtils.stripEnd(StringUtils.stripStart(str, "\"["), "\"]")); + } + return map; + } +} diff --git a/server/src/main/java/io/opencmw/server/MajordomoBroker.java b/server/src/main/java/io/opencmw/server/MajordomoBroker.java new file mode 100644 index 00000000..66426706 --- /dev/null +++ b/server/src/main/java/io/opencmw/server/MajordomoBroker.java @@ -0,0 +1,916 @@ +package io.opencmw.server; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import static org.zeromq.ZMQ.Socket; +import static org.zeromq.util.ZData.strhex; + +import static io.opencmw.OpenCmwProtocol.*; +import static io.opencmw.OpenCmwProtocol.Command.*; +import static io.opencmw.OpenCmwProtocol.MdpMessage.receive; +import static io.opencmw.OpenCmwProtocol.MdpSubProtocol.PROT_CLIENT; +import static io.opencmw.OpenCmwProtocol.MdpSubProtocol.PROT_WORKER; +import static io.opencmw.server.MmiServiceHelper.*; + +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.net.SocketException; +import java.net.URI; +import java.net.UnknownHostException; +import java.text.SimpleDateFormat; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; + +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.zeromq.SocketType; +import org.zeromq.ZContext; +import org.zeromq.ZMQ; +import org.zeromq.ZMsg; +import org.zeromq.util.ZData; + +import io.opencmw.rbac.BasicRbacRole; +import io.opencmw.rbac.RbacRole; +import io.opencmw.rbac.RbacToken; +import io.opencmw.utils.NoDuplicatesList; +import io.opencmw.utils.SystemProperties; + +/** + * Majordomo Protocol broker -- a minimal implementation of http://rfc.zeromq.org/spec:7 and spec:8 and following the OpenCMW specification + * + *

+ * The broker is controlled by the following environment variables: + *

    + *
  • 'OpenCMW.heartBeat' [ms]: default (2500 ms) heart-beat time-out [ms]
  • + *
  • 'OpenCMW.heartBeatLiveness' []: default (3) heart-beat liveness - 3-5 is reasonable + * N.B. heartbeat expires when last heartbeat message is more than HEARTBEAT_INTERVAL * HEARTBEAT_LIVENESS ms ago. + * this implies also, that worker must either return their message within 'HEARTBEAT_INTERVAL * HEARTBEAT_LIVENESS ms' + * or decouple their secondary handler interface into another thread.
  • + * + *
  • 'OpenCMW.clientTimeOut' [s]: default (3600, i.e. 1h) time-out after which unanswered client messages/infos are being deleted
  • + *
  • 'OpenCMW.nIoThreads' []: default (2) IO threads dedicated to network IO (ZeroMQ recommendation 1 thread per 1 GBit/s)
  • + *
  • 'OpenCMW.dnsTimeOut' [s]: default (60) DNS time-out after which an unresponsive client is dropped from the DNS table + * N.B. if registered, a HEARTBEAT challenge will be send that needs to be replied with a READY command/re-registering
  • + *
+ */ +@SuppressWarnings({ "PMD.DefaultPackage", "PMD.UseConcurrentHashMap", "PMD.TooManyFields", "PMD.TooManyMethods", "PMD.TooManyStaticImports", "PMD.CommentSize", "PMD.UseConcurrentHashMap" }) // package private explicitly needed for MmiServiceHelper, thread-safe/performance use of HashMap +public class MajordomoBroker extends Thread { + public static final byte[] RBAC = {}; // TODO: implement RBAC between Majordomo and Worker + // ----------------- default service names ----------------------------- + public static final String SUFFIX_ROUTER = "/router"; + public static final String SUFFIX_PUBLISHER = "/publisher"; + public static final String SUFFIX_SUBSCRIBE = "/subscribe"; + public static final String INPROC_BROKER = "inproc://broker"; + public static final String INTERNAL_ADDRESS_BROKER = INPROC_BROKER + SUFFIX_ROUTER; + public static final String INTERNAL_ADDRESS_PUBLISHER = INPROC_BROKER + SUFFIX_PUBLISHER; + public static final String INTERNAL_ADDRESS_SUBSCRIBE = INPROC_BROKER + SUFFIX_SUBSCRIBE; + public static final String SCHEME_HTTP = "http://"; + public static final String SCHEME_HTTPS = "https://"; + public static final String SCHEME_MDP = "mdp://"; + public static final String SCHEME_MDS = "mds://"; + public static final String SCHEME_TCP = "tcp://"; + public static final String WILDCARD = "*"; + private static final Logger LOGGER = LoggerFactory.getLogger(MajordomoBroker.class); + private static final long HEARTBEAT_LIVENESS = SystemProperties.getValueIgnoreCase("OpenCMW.heartBeatLiveness", 3); // [counts] 3-5 is reasonable + private static final long HEARTBEAT_INTERVAL = SystemProperties.getValueIgnoreCase("OpenCMW.heartBeat", 2500); // [ms] + private static final long HEARTBEAT_EXPIRY = HEARTBEAT_INTERVAL * HEARTBEAT_LIVENESS; + private static final long CLIENT_TIMEOUT = TimeUnit.SECONDS.toMillis(SystemProperties.getValueIgnoreCase("OpenCMW.clientTimeOut", 0)); // [s] + private static final int N_IO_THREAD = SystemProperties.getValueIgnoreCase("OpenCMW.nIoThreads", 1); // [] typ. 1 for < 1 GBit/s + private static final long DNS_TIMEOUT = TimeUnit.SECONDS.toMillis(SystemProperties.getValueIgnoreCase("OpenCMW.dnsTimeOut", 10)); // [ms] time when + private static final AtomicInteger BROKER_COUNTER = new AtomicInteger(); + // --------------------------------------------------------------------- + protected final ZContext ctx; + protected final Socket routerSocket; + protected final Socket pubSocket; + protected final Socket subSocket; + protected final Socket dnsSocket; + protected final String brokerName; + protected final String dnsAddress; + protected final List routerSockets = new NoDuplicatesList<>(); // Sockets for clients & public external workers + protected final SortedSet> rbacRoles; + /* default */ final Map services = new HashMap<>(); // known services Map<'service name', Service> + protected final Map workers = new HashMap<>(); // known workers Map clients = new HashMap<>(); + protected final Map activeSubscriptions = new HashMap<>(); // Map> + protected final Map> routerBasedSubscriptions = new HashMap<>(); // Map> + private final AtomicBoolean run = new AtomicBoolean(false); // NOPMD - nomen est omen + private final Deque waiting = new ArrayDeque<>(); // idle workers + /* default */ final Map dnsCache = new HashMap<>(); // + private long heartbeatAt = System.currentTimeMillis() + HEARTBEAT_INTERVAL; // When to send HEARTBEAT + private long dnsHeartbeatAt = System.currentTimeMillis() + DNS_TIMEOUT; // When to send a DNS HEARTBEAT + + /** + * Initialize broker state. + * + * @param brokerName specific Majordomo Broker name this instance is known for in the world + * @param dnsAddress specifc of other Majordomo broker that acts as primary DNS + * @param rbacRoles RBAC-based roles (used for IO prioritisation and service access control + */ + public MajordomoBroker(@NotNull final String brokerName, @NotNull final String dnsAddress, final RbacRole... rbacRoles) { + super(); + this.brokerName = brokerName; + final URI dnsService = URI.create(dnsAddress); + this.dnsAddress = dnsAddress.isBlank() ? "" : SCHEME_TCP + dnsService.getAuthority() + dnsService.getPath(); + this.setName(MajordomoBroker.class.getSimpleName() + "(" + brokerName + ")#" + BROKER_COUNTER.getAndIncrement()); + + ctx = new ZContext(N_IO_THREAD); + + // initialise RBAC role-based priority queues + this.rbacRoles = Collections.unmodifiableSortedSet(new TreeSet<>(Set.of(rbacRoles))); + + // generate and register internal default inproc socket + routerSocket = ctx.createSocket(SocketType.ROUTER); + routerSocket.setHWM(0); + routerSocket.bind(INTERNAL_ADDRESS_BROKER); // NOPMD + pubSocket = ctx.createSocket(SocketType.XPUB); + pubSocket.setHWM(0); + pubSocket.setXpubVerbose(true); + pubSocket.bind(INTERNAL_ADDRESS_PUBLISHER); // NOPMD + subSocket = ctx.createSocket(SocketType.SUB); + subSocket.setHWM(0); + subSocket.bind(INTERNAL_ADDRESS_SUBSCRIBE); // NOPMD + + registerDefaultServices(rbacRoles); // NOPMD + + dnsSocket = ctx.createSocket(SocketType.DEALER); + dnsSocket.setHWM(0); + if (this.dnsAddress.isBlank()) { + dnsSocket.connect(INTERNAL_ADDRESS_BROKER); + } else { + dnsSocket.connect(this.dnsAddress); + } + + LOGGER.atInfo().addArgument(getName()).addArgument(this.dnsAddress).log("register new '{}' broker with DNS: '{}'"); + } + + /** + * Add internal service. + * + * @param worker the worker + */ + public void addInternalService(final BasicMdpWorker worker) { + assert worker != null : "worker must not be null"; + requireService(worker.getServiceName(), worker); + } + + /** + * Bind broker to endpoint, can call this multiple times. We use a single + * socket for both clients and workers. + *

+ * + * @param endpoint the URI-based 'scheme://ip:port' endpoint definition the server should listen to

The protocol definition

  • 'mdp://' corresponds to a SocketType.ROUTER socket
  • 'mds://' corresponds to a SocketType.XPUB socket
  • 'tcp://' internally falls back to 'mdp://' and ROUTER socket
+ * @return the string + */ + public String bind(String endpoint) { + final boolean isRouterSocket = !endpoint.startsWith(SCHEME_MDS); + final String endpointAdjusted; + if (isRouterSocket) { + routerSocket.bind(endpoint.replace(SCHEME_MDP, SCHEME_TCP)); + endpointAdjusted = endpoint.replace(SCHEME_TCP, SCHEME_MDP); + } else { + pubSocket.bind(endpoint.replace(SCHEME_MDS, SCHEME_TCP)); + endpointAdjusted = endpoint.replace(SCHEME_TCP, SCHEME_MDS); + } + final String adjustedAddressPublic = endpointAdjusted.replace(WILDCARD, getLocalHostName()); + routerSockets.add(adjustedAddressPublic); + if (endpoint.contains(WILDCARD)) { + routerSockets.add(endpointAdjusted.replace(WILDCARD, "localhost")); + } + if (LOGGER.isDebugEnabled()) { + LOGGER.atDebug().addArgument(adjustedAddressPublic).log("Majordomo broker/0.1 is active at '{}'"); + } + return adjustedAddressPublic; + } + + public ZContext getContext() { + return ctx; + } + + public Socket getInternalRouterSocket() { + return routerSocket; + } + + /** + * Gets router sockets. + * + * @return unmodifiable list of registered external sockets + */ + public List getRouterSockets() { + return Collections.unmodifiableList(routerSockets); + } + + public Collection getServices() { + return services.values(); + } + + public boolean isRunning() { + return run.get(); + } + + public void removeService(final String serviceName) { + final Service ret = services.remove(serviceName); + ret.mdpWorker.forEach(BasicMdpWorker::stopWorker); + ret.waiting.forEach(worker -> new MdpMessage(worker.address, PROT_WORKER, DISCONNECT, worker.service.nameBytes, EMPTY_FRAME, URI.create(worker.service.name), EMPTY_FRAME, "", RBAC).send(worker.socket)); + } + + /** + * main broker work happens here + */ + @Override + public void run() { + try (ZMQ.Poller items = ctx.createPoller(4)) { // 4 -> four sockets defined below + items.register(routerSocket, ZMQ.Poller.POLLIN); + items.register(dnsSocket, ZMQ.Poller.POLLIN); + items.register(pubSocket, ZMQ.Poller.POLLIN); + items.register(subSocket, ZMQ.Poller.POLLIN); + while (run.get() && !Thread.currentThread().isInterrupted() && items.poll(HEARTBEAT_INTERVAL) != -1) { + int loopCount = 0; + boolean receivedMsg = true; + while (run.get() && !Thread.currentThread().isInterrupted() && receivedMsg) { + final MdpMessage routerMsg = receive(routerSocket, false); + receivedMsg = handleReceivedMessage(routerSocket, routerMsg); + + final MdpMessage subMsg = receive(subSocket, false); + receivedMsg |= handleReceivedMessage(subSocket, subMsg); + + final MdpMessage dnsMsg = receive(dnsSocket, false); + receivedMsg |= handleReceivedMessage(dnsSocket, dnsMsg); + + final ZMsg pubMsg = ZMsg.recvMsg(pubSocket, false); + receivedMsg |= handleSubscriptionMsg(pubMsg); + + processClients(); + if (loopCount % 10 == 0) { + // perform maintenance tasks during the first and every tenth + // iteration + purgeWorkers(); + purgeClients(); + purgeDnsServices(); + sendHeartbeats(); + sendDnsHeartbeats(false); + } + loopCount++; + } + } + } + destroy(); // interrupted + } + + private boolean handleSubscriptionMsg(final ZMsg subMsg) { + if (subMsg == null || subMsg.isEmpty()) { + return false; + } + final byte[] topicBytes = subMsg.getFirst().getData(); + if (topicBytes.length == 0) { + return false; + } + final Command subType = topicBytes[0] == 1 ? SUBSCRIBE : (topicBytes[0] == 0 ? UNSUBSCRIBE : UNKNOWN); // '1'('0' being the default ZeroMQ (un-)subscribe command + final String subscriptionTopic = new String(topicBytes, 1, topicBytes.length - 1, UTF_8); + LOGGER.atDebug().addArgument(subType).addArgument(subscriptionTopic).log("received subscription request: {} to '{}'"); + + switch (subType) { + case SUBSCRIBE: + if (activeSubscriptions.computeIfAbsent(subscriptionTopic, s -> new AtomicInteger()).incrementAndGet() == 1) { + subSocket.subscribe(subscriptionTopic); + } + return true; + case UNSUBSCRIBE: + if (activeSubscriptions.computeIfAbsent(subscriptionTopic, s -> new AtomicInteger()).decrementAndGet() <= 0) { + subSocket.unsubscribe(subscriptionTopic); + } + return true; + case UNKNOWN: + default: + return false; + } + } + + @Override + public synchronized void start() { // NOPMD - need to be synchronised on class level due to super definition + run.set(true); + services.forEach((serviceName, service) -> service.internalWorkers.forEach(Thread::start)); + super.start(); + sendDnsHeartbeats(true); // initial register of default routes + } + + /** + * Stop broker. + */ + public void stopBroker() { + run.set(false); + } + + /** + * Deletes worker from all data structures, and destroys worker. + * + * @param worker internal reference to worker + * @param disconnect true: send a disconnect message to worker + */ + protected void deleteWorker(Worker worker, boolean disconnect) { + assert (worker != null); + if (disconnect) { + new MdpMessage(worker.address, PROT_WORKER, DISCONNECT, + worker.serviceName, EMPTY_FRAME, + URI.create(new String(worker.serviceName, UTF_8)), EMPTY_FRAME, "", RBAC) + .send(worker.socket); + } + if (worker.service != null) { + worker.service.waiting.remove(worker); + } + workers.remove(worker.addressHex); + } + + /** + * Disconnect all workers, destroy context. + */ + protected void destroy() { + Worker[] deleteList = workers.values().toArray(new Worker[0]); + for (Worker worker : deleteList) { + deleteWorker(worker, true); + } + ctx.destroy(); + } + + /** + * Dispatch requests to waiting workers as possible + * + * @param service dispatch message for this service + */ + protected void dispatch(Service service) { + assert (service != null); + purgeWorkers(); + while (!service.waiting.isEmpty() && service.requestsPending()) { + final MdpMessage msg = service.getNextPrioritisedMessage(); + if (msg == null) { + // should be thrown only with VM '-ea' enabled -- assert noisily since + // this a (rare|design) library error + assert false : "getNextPrioritisedMessage should not be null"; + continue; + } + Worker worker = service.waiting.pop(); + waiting.remove(worker); + msg.serviceNameBytes = msg.senderID; + msg.senderID = worker.address; // replace sourceID with worker destinationID + msg.protocol = PROT_WORKER; // CLIENT protocol -> WORKER -> protocol + msg.send(worker.socket); + } + } + + /** + * Handle received message boolean. + * + * @param receiveSocket the receive socket + * @param msg the to be processed msg + * @return true if request was implemented and has been processed + */ + protected boolean handleReceivedMessage(final Socket receiveSocket, final MdpMessage msg) { + if (msg == null) { + return false; + } + final String topic = msg.topic.toString(); + switch (msg.protocol) { + case PROT_CLIENT: + case PROT_CLIENT_HTTP: + // Set reply return address to client sender + switch (msg.command) { + case READY: + if (msg.topic.getScheme() != null) { + // register potentially new service + DnsServiceItem ret = dnsCache.computeIfAbsent(msg.getServiceName(), s -> new DnsServiceItem(msg.senderID, msg.getServiceName())); + ret.uri.add(msg.topic); + ret.updateExpiryTimeStamp(); + } + return true; + case SUBSCRIBE: + if (activeSubscriptions.computeIfAbsent(topic, s -> new AtomicInteger()).incrementAndGet() == 1) { + subSocket.subscribe(topic); + } + routerBasedSubscriptions.computeIfAbsent(topic, s -> new ArrayList<>()).add(msg.senderID); + return true; + case UNSUBSCRIBE: + if (activeSubscriptions.computeIfAbsent(topic, s -> new AtomicInteger()).decrementAndGet() <= 0) { + subSocket.unsubscribe(topic); + } + routerBasedSubscriptions.computeIfAbsent(topic, s -> new ArrayList<>()).remove(msg.senderID); + if (routerBasedSubscriptions.get(topic).isEmpty()) { + routerBasedSubscriptions.remove(topic); + } + return true; + case W_HEARTBEAT: + sendDnsHeartbeats(true); + return true; + default: + } + + final String senderName = msg.getSenderName(); + final Client client = clients.computeIfAbsent(senderName, s -> new Client(receiveSocket, senderName, msg.senderID)); + client.offerToQueue(msg); + return true; + case PROT_WORKER: + processWorker(receiveSocket, msg); + return true; + default: + // N.B. not too verbose logging since we do not want that sloppy clients + // can bring down the broker through warning or info messages + if (LOGGER.isDebugEnabled()) { + LOGGER.atDebug().addArgument(msg).log("Majordomo broker invalid message: '{}'"); + } + return false; + } + } + + /** + * Process a request coming from a client. + */ + protected void processClients() { + // round-robbin + clients.forEach((name, client) -> { + final MdpMessage clientMessage = client.pop(); + if (clientMessage == null) { + return; + } + + // dispatch client message to worker queue + // old : final Service service = services.get(clientMessage.getServiceName()) + final Service service = getBestMatchingService(clientMessage.getServiceName()); + if (service == null) { + // not implemented -- according to Majordomo Management Interface (MMI) + // as defined in http://rfc.zeromq.org/spec:8 + new MdpMessage(clientMessage.senderID, PROT_CLIENT, FINAL, + clientMessage.serviceNameBytes, + clientMessage.clientRequestID, + URI.create(INTERNAL_SERVICE_NAMES), + "501".getBytes(UTF_8), "unknown service (error 501): '" + clientMessage.getServiceName() + '\'', RBAC) + .send(client.socket); + return; + } + // queue new client message RBAC-priority-based + service.putPrioritisedMessage(clientMessage); + + // dispatch service + dispatch(service); + }); + } + + Service getBestMatchingService(final String serviceName) { // NOPMD package private OK + final List sortedList = services.keySet().stream().filter(serviceName::startsWith).sorted(Comparator.comparingInt(String::length)).collect(Collectors.toList()); + return sortedList.isEmpty() ? null : services.get(sortedList.get(0)); + } + + /** + * Process message sent to us by a worker. + * + * @param receiveSocket the socket the message was received at + * @param msg the received and to be processed message + */ + protected void processWorker(final Socket receiveSocket, final MdpMessage msg) { //NOPMD + final String senderIdHex = strhex(msg.senderID); + final String serviceName = msg.getServiceName(); + final boolean workerReady = workers.containsKey(senderIdHex); + final Worker worker = requireWorker(receiveSocket, msg.senderID, senderIdHex, msg.serviceNameBytes); + + switch (msg.command) { + case READY: + LOGGER.atTrace().addArgument(serviceName).log("log new local/external worker for service {} - " + msg); + // Attach worker to service and mark as idle + worker.service = requireService(serviceName); + workerWaiting(worker); + worker.service.serviceDescription = Arrays.copyOf(msg.data, msg.data.length); + + if (!msg.topic.toString().isBlank() && msg.topic.getScheme() != null) { + routerSockets.add(msg.topic.toString()); + DnsServiceItem ret = dnsCache.computeIfAbsent(brokerName, s -> new DnsServiceItem(msg.senderID, brokerName)); + ret.uri.add(msg.topic); + } + + // notify potential listener + msg.data = msg.serviceNameBytes; + msg.serviceNameBytes = INTERNAL_SERVICE_NAMES.getBytes(UTF_8); + msg.command = W_NOTIFY; + msg.clientRequestID = this.getName().getBytes(UTF_8); + msg.topic = URI.create(INTERNAL_SERVICE_NAMES); + msg.errors = ""; + if (!pubSocket.sendMore(INTERNAL_SERVICE_NAMES) || !msg.send(pubSocket)) { + LOGGER.atWarn().addArgument(msg.getServiceName()).log("could not notify service change for '{}'"); + } + break; + case W_HEARTBEAT: + if (workerReady) { + worker.updateExpiryTimeStamp(); + } else { + deleteWorker(worker, true); + } + break; + case DISCONNECT: + deleteWorker(worker, false); + break; + case PARTIAL: + case FINAL: + if (workerReady) { + final Client client = clients.get(msg.getServiceName()); + if (client == null || client.socket == null) { + break; + } + // need to replace clientID with service name + final byte[] serviceID = worker.service.nameBytes; + msg.senderID = msg.serviceNameBytes; + msg.protocol = PROT_CLIENT; + msg.serviceNameBytes = serviceID; + msg.send(client.socket); + workerWaiting(worker); + } else { + deleteWorker(worker, true); + } + break; + case W_NOTIFY: + // need to replace clientID with service name + final byte[] serviceID = worker.service.nameBytes; + msg.senderID = msg.serviceNameBytes; + msg.serviceNameBytes = serviceID; + msg.protocol = PROT_CLIENT; + msg.command = FINAL; + + dispatchMessageToMatchingSubscriber(msg); + + break; + default: + // N.B. not too verbose logging since we do not want that sloppy clients + // can bring down the broker through warning or info messages + if (LOGGER.isDebugEnabled()) { + LOGGER.atDebug().addArgument(msg).log("Majordomo broker invalid message: '{}'"); + } + break; + } + } + + private void dispatchMessageToMatchingSubscriber(final MdpMessage msg) { + // final String queryString = msg.topic.getQuery() + // final String replyService = msg.topic.getPath() + (queryString == null || queryString.isBlank() ? "" : ("?" + queryString)) + // N.B. for the time being only the path is matched - TODO: upgrade to full topic matching + for (String specificTopic : activeSubscriptions.keySet()) { + URI subTopic = URI.create(specificTopic); + if (!subTopic.getPath().startsWith(msg.topic.getPath())) { + continue; + } + pubSocket.sendMore(specificTopic); + msg.send(pubSocket); + } + + // publish also via router socket directly to known and previously subscribed clients + final List tClients = routerBasedSubscriptions.get(msg.topic.toString()); + if (tClients == null) { + return; + } + for (final byte[] clientID : tClients) { + msg.senderID = clientID; + msg.send(routerSocket); + } + } + + /** + * Look for & kill expired clients. + */ + protected void purgeClients() { + if (CLIENT_TIMEOUT <= 0) { + return; + } + for (String clientName : clients.keySet()) { // NOSONAR NOPMD copy because + // we are going to remove keys + Client client = clients.get(clientName); + if (client == null || client.expiry < System.currentTimeMillis()) { + clients.remove(clientName); + if (LOGGER.isDebugEnabled()) { + LOGGER.atDebug().addArgument(client).log("Majordomo broker deleting expired client: '{}'"); + } + } + } + } + + /** + * Look for & kill expired workers. Workers are oldest to most recent, so + * we stop at the first alive worker. + */ + protected void purgeWorkers() { + for (Worker w = waiting.peekFirst(); w != null && w.expiry < System.currentTimeMillis(); w = waiting.peekFirst()) { + if (LOGGER.isInfoEnabled()) { + LOGGER.atInfo().addArgument(w.addressHex).addArgument(w.service == null ? "(unknown)" : w.service.name).log("Majordomo broker deleting expired worker: '{}' - service: '{}'"); + } + deleteWorker(waiting.pollFirst(), false); + } + } + + /** + * Look for & kill expired workers. Workers are oldest to most recent, so + * we stop at the first alive worker. + */ + protected void purgeDnsServices() { + if (System.currentTimeMillis() >= dnsHeartbeatAt) { + List cachedList = new ArrayList<>(dnsCache.values()); + final MdpMessage challengeMessage = new MdpMessage(null, PROT_CLIENT, W_HEARTBEAT, null, "dnsChallenge".getBytes(UTF_8), EMPTY_URI, EMPTY_FRAME, "", RBAC); + for (DnsServiceItem registeredService : cachedList) { + if (registeredService.serviceName.equalsIgnoreCase(brokerName)) { + registeredService.updateExpiryTimeStamp(); + } + // challenge remote broker with a HEARTBEAT + challengeMessage.senderID = registeredService.address; + challengeMessage.serviceNameBytes = registeredService.serviceName.getBytes(UTF_8); + challengeMessage.send(routerSocket); // NOPMD + if (System.currentTimeMillis() > registeredService.expiry) { + if (LOGGER.isInfoEnabled()) { + LOGGER.atInfo().addArgument(registeredService).log("Majordomo broker deleting expired dns service: '{}'"); + } + dnsCache.remove(registeredService.serviceName); + } + } + dnsHeartbeatAt = System.currentTimeMillis() + DNS_TIMEOUT; + } + } + + protected void registerDefaultServices(final RbacRole[] rbacRoles) { + // add simple internal Majordomo worker + final int nServiceThreads = 3; + + addInternalService(new MmiService(this, rbacRoles)); + addInternalService(new MmiOpenApi(this, rbacRoles)); + addInternalService(new MmiDns(this, rbacRoles)); + for (int i = 0; i < nServiceThreads; i++) { + addInternalService(new MmiEcho(this, rbacRoles)); // NOPMD valid instantiation inside loop + } + } + + /** + * Locates the service (creates if necessary). + * + * @param serviceName service name + * @param worker optional worker implementation (may be null) + * @return the existing (or new if absent) service this worker is responsible for + */ + protected Service requireService(final String serviceName, final BasicMdpWorker... worker) { + assert (serviceName != null); + final BasicMdpWorker w = worker.length > 0 ? worker[0] : null; + final Service service = services.computeIfAbsent(serviceName, s -> new Service(serviceName, serviceName.getBytes(UTF_8), w)); + if (w != null) { + w.start(); + } + return service; + } + + /** + * Finds the worker (creates if necessary). + * + * @param socket the socket + * @param address the address + * @param addressHex the address hex + * @param serviceName the service name + * @return the worker + */ + protected @NotNull Worker requireWorker(final Socket socket, final byte[] address, final String addressHex, final byte[] serviceName) { + assert (addressHex != null); + return workers.computeIfAbsent(addressHex, identity -> { + if (LOGGER.isInfoEnabled()) { + LOGGER.atInfo().addArgument(addressHex).log("registering new worker: '{}'"); + } + return new Worker(socket, address, addressHex, serviceName); + }); + } + + /** + * Send heartbeats to idle workers if it's time + */ + protected void sendHeartbeats() { + // Send heartbeats to idle workers if it's time + if (System.currentTimeMillis() >= heartbeatAt) { + final MdpMessage heartbeatMsg = new MdpMessage(null, PROT_WORKER, W_HEARTBEAT, null, EMPTY_FRAME, EMPTY_URI, EMPTY_FRAME, "", RBAC); + for (Worker worker : waiting) { + heartbeatMsg.senderID = worker.address; + heartbeatMsg.serviceNameBytes = worker.service.nameBytes; + heartbeatMsg.send(worker.socket); + } + heartbeatAt = System.currentTimeMillis() + HEARTBEAT_INTERVAL; + } + } + + /** + * Send heartbeats to the DNS server if necessary + * + * @param force sending regardless of time-out + */ + protected void sendDnsHeartbeats(boolean force) { + // Send heartbeats to idle workers if it's time + if (System.currentTimeMillis() >= dnsHeartbeatAt || force) { + final MdpMessage readyMsg = new MdpMessage(null, PROT_CLIENT, READY, brokerName.getBytes(UTF_8), "clientID".getBytes(UTF_8), URI.create(""), EMPTY_FRAME, "", RBAC); + for (String routerAddress : this.getRouterSockets()) { + readyMsg.topic = URI.create(routerAddress); + if (!dnsAddress.isBlank()) { + readyMsg.send(dnsSocket); // register with external DNS + } + // register with internal DNS + DnsServiceItem ret = dnsCache.computeIfAbsent(brokerName, s -> new DnsServiceItem(dnsSocket.getIdentity(), brokerName)); // NOPMD instantiation in loop necessary + ret.uri.add(URI.create(routerAddress)); + ret.updateExpiryTimeStamp(); + } + } + } + + /** + * This worker is now waiting for work. + * + * @param worker the worker + */ + protected void workerWaiting(Worker worker) { + // Queue to broker and service waiting lists + waiting.addLast(worker); + // TODO: evaluate addLast vs. push (addFirst) - latter should be more + // beneficial w.r.t. CPU context switches (reuses the same thread/context + // frequently + // do not know why original implementation wanted to spread across different + // workers (load balancing across different machines perhaps?!=) + // worker.service.waiting.addLast(worker); + worker.service.waiting.push(worker); + worker.updateExpiryTimeStamp(); + dispatch(worker.service); + } + + /** + * This defines a client service. + */ + protected static class Client { + protected final Socket socket; // Socket client is connected to + protected final String name; // client name + protected final byte[] nameBytes; // client name as byte array + protected final String nameHex; // client name as hex String + private final Deque requests = new ArrayDeque<>(); // List of client requests + protected long expiry = System.currentTimeMillis() + CLIENT_TIMEOUT; // Expires at unless heartbeat + + private Client(final Socket socket, final String name, final byte[] nameBytes) { + this.socket = socket; + this.name = name; + this.nameBytes = nameBytes == null ? name.getBytes(UTF_8) : nameBytes; + this.nameHex = strhex(nameBytes); + } + + private void offerToQueue(final MdpMessage msg) { + expiry = System.currentTimeMillis() + CLIENT_TIMEOUT; + requests.offer(msg); + } + + private MdpMessage pop() { + return requests.isEmpty() ? null : requests.poll(); + } + } + + /** + * This defines one worker, idle or active. + */ + protected static class Worker { + protected final Socket socket; // Socket worker is connected to + protected final byte[] address; // Address ID frame to route to + protected final String addressHex; // Address ID frame of worker expressed as hex-String + protected final byte[] serviceName; // service name of worker + + protected Service service; // Owning service, if known + protected long expiry; // Expires at unless heartbeat + + private Worker(final Socket socket, final byte[] address, final String addressHex, final byte[] serviceName) { // NOPMD direct storage of address OK + this.socket = socket; + this.address = address; + this.addressHex = addressHex; + this.serviceName = serviceName; + updateExpiryTimeStamp(); + } + + private void updateExpiryTimeStamp() { + expiry = System.currentTimeMillis() + HEARTBEAT_EXPIRY; + } + } + + /** + * This defines one DNS service item, idle or active. + */ + @SuppressWarnings("PMD.CommentDefaultAccessModifier") // needed for utility classes in the same package + static class DnsServiceItem { + protected final byte[] address; // Address ID frame to route to + protected final String serviceName; + protected final List uri = new NoDuplicatesList<>(); + protected long expiry; // Expires at unless heartbeat + + private DnsServiceItem(final byte[] address, final String serviceName) { // NOPMD direct storage of address OK + this.address = address; + this.serviceName = serviceName; + updateExpiryTimeStamp(); + } + + private void updateExpiryTimeStamp() { + expiry = System.currentTimeMillis() + DNS_TIMEOUT * HEARTBEAT_LIVENESS; + } + + @Override + public String toString() { + final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.UK); + return "DnsServiceItem{address=" + ZData.toString(address) + ", serviceName='" + serviceName + "', uri= '" + uri + "',expiry=" + expiry + " - " + sdf.format(expiry) + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } else if (!(o instanceof DnsServiceItem)) { + return false; + } + DnsServiceItem that = (DnsServiceItem) o; + return serviceName.equals(that.serviceName); + } + + public String getDnsEntry() { + return '[' + serviceName + ": " + uri.stream().map(URI::toString).collect(Collectors.joining(",")) + ']'; + } + + public String getDnsEntryHtml() { + Optional webHandler = uri.stream().filter(u -> "https".equalsIgnoreCase(u.getScheme())).findFirst(); + if (webHandler.isEmpty()) { + webHandler = uri.stream().filter(u -> "http".equalsIgnoreCase(u.getScheme())).findFirst(); + } + final String wrappedService = webHandler.isEmpty() ? serviceName : wrapInAnchor(serviceName, webHandler.get()); + return '[' + wrappedService + ": " + uri.stream().map(u -> wrapInAnchor(u.toString(), u)).collect(Collectors.joining(",")) + "]"; + } + + @Override + public int hashCode() { + return serviceName.hashCode(); + } + } + + /** + * This defines a single service. + */ + protected class Service { + protected final String name; // Service name + protected final byte[] nameBytes; // Service name as byte array + protected final List mdpWorker = new ArrayList<>(); + protected final Map, Queue> requests = new HashMap<>(); // RBAC-based queuing + protected final Deque waiting = new ArrayDeque<>(); // List of waiting workers + protected final List internalWorkers = new ArrayList<>(); + protected byte[] serviceDescription; // service OpenAPI description + + private Service(final String name, final byte[] nameBytes, final BasicMdpWorker mdpWorker) { + this.name = name; + this.nameBytes = nameBytes == null ? name.getBytes(UTF_8) : nameBytes; + if (mdpWorker != null) { + this.mdpWorker.add(mdpWorker); + } + rbacRoles.forEach(role -> requests.put(role, new ArrayDeque<>())); + requests.put(BasicRbacRole.NULL, new ArrayDeque<>()); // add default queue + } + + private boolean requestsPending() { + return requests.entrySet().stream().anyMatch( + map -> !map.getValue().isEmpty()); + } + + private MdpMessage getNextPrioritisedMessage() { + for (RbacRole role : rbacRoles) { + final Queue queue = requests.get(role); // matched non-empty queue + if (!queue.isEmpty()) { + return queue.poll(); + } + } + final Queue queue = requests.get(BasicRbacRole.NULL); // default queue + return queue.isEmpty() ? null : queue.poll(); + } + + private void putPrioritisedMessage(final MdpMessage queuedMessage) { + if (queuedMessage.hasRbackToken()) { + // find proper RBAC queue + final RbacToken rbacToken = RbacToken.from(queuedMessage.rbacToken); + final Queue roleBasedQueue = requests.get(rbacToken.getRole()); + if (roleBasedQueue != null) { + roleBasedQueue.offer(queuedMessage); + } + } else { + requests.get(BasicRbacRole.NULL).offer(queuedMessage); + } + } + } + + protected static String getLocalHostName() { + String ip; + try (DatagramSocket socket = new DatagramSocket()) { + socket.connect(InetAddress.getByName("8.8.8.8"), 10_002); // NOPMD - bogus hardcoded IP acceptable in this context + if (socket.getLocalAddress() == null) { + throw new UnknownHostException("bogus exception can be ignored"); + } + ip = socket.getLocalAddress().getHostAddress(); + + if (ip != null) { + return ip; + } + } catch (final SocketException | UnknownHostException e) { + LOGGER.atError().setCause(e).log("getLocalHostName()"); + } + return "localhost"; + } +} diff --git a/server/src/main/java/io/opencmw/server/MajordomoWorker.java b/server/src/main/java/io/opencmw/server/MajordomoWorker.java new file mode 100644 index 00000000..561c8c49 --- /dev/null +++ b/server/src/main/java/io/opencmw/server/MajordomoWorker.java @@ -0,0 +1,203 @@ +package io.opencmw.server; + +import static io.opencmw.OpenCmwProtocol.Command.W_NOTIFY; +import static io.opencmw.OpenCmwProtocol.EMPTY_FRAME; +import static io.opencmw.OpenCmwProtocol.MdpSubProtocol.PROT_WORKER; + +import java.net.URI; +import java.util.Arrays; + +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.zeromq.ZContext; + +import io.opencmw.MimeType; +import io.opencmw.OpenCmwProtocol; +import io.opencmw.OpenCmwProtocol.MdpMessage; +import io.opencmw.QueryParameterParser; +import io.opencmw.rbac.RbacRole; +import io.opencmw.serialiser.IoBuffer; +import io.opencmw.serialiser.IoClassSerialiser; +import io.opencmw.serialiser.annotations.MetaInfo; +import io.opencmw.serialiser.spi.BinarySerialiser; +import io.opencmw.serialiser.spi.CmwLightSerialiser; +import io.opencmw.serialiser.spi.FastByteBuffer; +import io.opencmw.serialiser.spi.JsonSerialiser; + +/** + * MajordomoWorker implementation including de-serialising and re-serialising to domain-objects. + *

+ * This implements GET/SET/NOTIFY handlers that are driven by PoJo domain objects. + * + * @author rstein + * + * @param generic type for the query/context mapping object + * @param generic type for the input domain object + * @param generic type for the output domain object (also notify) + */ +@SuppressWarnings("PMD.DataClass") // PMD - false positive data class +@MetaInfo(description = "default MajordomoWorker implementation") +public class MajordomoWorker extends BasicMdpWorker { + private static final Logger LOGGER = LoggerFactory.getLogger(MajordomoWorker.class); + private static final int MAX_BUFFER_SIZE = 4000; + protected final IoBuffer defaultBuffer = new FastByteBuffer(MAX_BUFFER_SIZE, true, null); + protected final IoBuffer defaultNotifyBuffer = new FastByteBuffer(MAX_BUFFER_SIZE, true, null); + protected final IoClassSerialiser deserialiser = new IoClassSerialiser(defaultBuffer); + protected final IoClassSerialiser serialiser = new IoClassSerialiser(defaultBuffer); + protected final IoClassSerialiser notifySerialiser = new IoClassSerialiser(defaultNotifyBuffer); + protected final Class contextClassType; + protected final Class inputClassType; + protected final Class outputClassType; + protected Handler handler; + protected Handler htmlHandler; + + public MajordomoWorker(final String brokerAddress, final String serviceName, + @NotNull final Class contextClassType, + @NotNull final Class inputClassType, + @NotNull final Class outputClassType, final RbacRole... rbacRoles) { + this(null, brokerAddress, serviceName, contextClassType, inputClassType, outputClassType, rbacRoles); + } + + public MajordomoWorker(final ZContext ctx, final String serviceName, + @NotNull final Class contextClassType, + @NotNull final Class inputClassType, + @NotNull final Class outputClassType, final RbacRole... rbacRoles) { + this(ctx, "inproc://broker", serviceName, contextClassType, inputClassType, outputClassType, rbacRoles); + } + + protected MajordomoWorker(final ZContext ctx, final String brokerAddress, final String serviceName, + @NotNull final Class contextClassType, + @NotNull final Class inputClassType, + @NotNull final Class outputClassType, final RbacRole... rbacRoles) { + super(ctx, brokerAddress, serviceName, rbacRoles); + + this.contextClassType = contextClassType; + this.inputClassType = inputClassType; + this.outputClassType = outputClassType; + deserialiser.setAutoMatchSerialiser(false); + serialiser.setAutoMatchSerialiser(false); + notifySerialiser.setAutoMatchSerialiser(false); + serialiser.setMatchedIoSerialiser(BinarySerialiser.class); + notifySerialiser.setMatchedIoSerialiser(BinarySerialiser.class); + + try { + // check if velocity is available + Class.forName("org.apache.velocity.app.VelocityEngine"); + setHtmlHandler(new DefaultHtmlHandler<>(this.getClass(), null, null)); + } catch (ClassNotFoundException e) { + LOGGER.atInfo().addArgument("velocity engine not present - omitting setting DefaultHtmlHandler()"); + } + + super.registerHandler(c -> { + final URI reqTopic = c.req.topic; + final String queryString = reqTopic.getQuery(); + final C requestCtx = QueryParameterParser.parseQueryParameter(contextClassType, c.req.topic.getQuery()); + final C replyCtx = QueryParameterParser.parseQueryParameter(contextClassType, c.req.topic.getQuery()); // reply is initially a copy of request + final MimeType requestedMimeType = QueryParameterParser.getMimeType(queryString); + + final I input; + if (c.req.data.length > 0) { + switch (requestedMimeType) { + case HTML: + case JSON: + case JSON_LD: + deserialiser.setDataBuffer(FastByteBuffer.wrap(c.req.data)); + deserialiser.setMatchedIoSerialiser(JsonSerialiser.class); + input = deserialiser.deserialiseObject(inputClassType); + break; + case CMWLIGHT: + deserialiser.setDataBuffer(FastByteBuffer.wrap(c.req.data)); + deserialiser.setMatchedIoSerialiser(CmwLightSerialiser.class); + input = deserialiser.deserialiseObject(inputClassType); + break; + case BINARY: + case UNKNOWN: + default: + deserialiser.setDataBuffer(FastByteBuffer.wrap(c.req.data)); + deserialiser.setMatchedIoSerialiser(BinarySerialiser.class); + input = deserialiser.deserialiseObject(inputClassType); + break; + } + } else { + // return default input object + input = inputClassType.getDeclaredConstructor().newInstance(); + } + + final O output = outputClassType.getDeclaredConstructor().newInstance(); + + // call user-handler + handler.handle(c, requestCtx, input, replyCtx, output); + + final String replyQuery = QueryParameterParser.generateQueryParameter(replyCtx); + c.rep.topic = new URI(reqTopic.getScheme(), reqTopic.getAuthority(), reqTopic.getPath(), replyQuery, reqTopic.getFragment()); + final MimeType replyMimeType = QueryParameterParser.getMimeType(replyQuery); + + defaultBuffer.reset(); + switch (replyMimeType) { + case HTML: + htmlHandler.handle(c, requestCtx, input, replyCtx, output); + break; + case JSON: + case JSON_LD: + serialiser.setMatchedIoSerialiser(JsonSerialiser.class); + serialiser.getMatchedIoSerialiser().setBuffer(defaultBuffer); + serialiser.serialiseObject(output); + defaultBuffer.flip(); + c.rep.data = Arrays.copyOf(defaultBuffer.elements(), defaultBuffer.limit() + 4); + break; + case CMWLIGHT: + serialiser.setMatchedIoSerialiser(CmwLightSerialiser.class); + serialiser.getMatchedIoSerialiser().setBuffer(defaultBuffer); + serialiser.serialiseObject(output); + defaultBuffer.flip(); + c.rep.data = Arrays.copyOf(defaultBuffer.elements(), defaultBuffer.limit()); + break; + case BINARY: + default: + serialiser.setMatchedIoSerialiser(BinarySerialiser.class); + serialiser.getMatchedIoSerialiser().setBuffer(defaultBuffer); + serialiser.serialiseObject(output); + defaultBuffer.flip(); + c.rep.data = Arrays.copyOf(defaultBuffer.elements(), defaultBuffer.limit()); + break; + } + }); + } + + @Override + public BasicMdpWorker registerHandler(final RequestHandler requestHandler) { + throw new UnsupportedOperationException("do not overwrite low-level request handler, use either 'setHandler(...)' or " + BasicMdpWorker.class.getName() + " directly"); + } + + public Handler getHandler() { + return handler; + } + + public void setHandler(final Handler handler) { + this.handler = handler; + } + + public Handler getHtmlHandler() { + return htmlHandler; + } + + public void setHtmlHandler(Handler htmlHandler) { + this.htmlHandler = htmlHandler; + } + + public void notify(final C replyCtx, final O reply) { + defaultNotifyBuffer.reset(); + notifySerialiser.serialiseObject(reply); + defaultNotifyBuffer.flip(); + final byte[] data = Arrays.copyOf(defaultNotifyBuffer.elements(), defaultNotifyBuffer.limit()); + URI topic = URI.create(serviceName + '?' + QueryParameterParser.generateQueryParameter(replyCtx)); + MdpMessage notifyMessage = new MdpMessage(null, PROT_WORKER, W_NOTIFY, serviceBytes, EMPTY_FRAME, topic, data, "", RBAC); + + super.notify(notifyMessage); + } + + public interface Handler { + void handle(OpenCmwProtocol.Context ctx, C requestCtx, I request, C replyCtx, O reply); + } +} diff --git a/server/src/main/java/io/opencmw/server/MmiServiceHelper.java b/server/src/main/java/io/opencmw/server/MmiServiceHelper.java new file mode 100644 index 00000000..e10c9495 --- /dev/null +++ b/server/src/main/java/io/opencmw/server/MmiServiceHelper.java @@ -0,0 +1,89 @@ +package io.opencmw.server; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import java.net.URI; +import java.util.stream.Collectors; + +import io.opencmw.MimeType; +import io.opencmw.QueryParameterParser; +import io.opencmw.rbac.RbacRole; +import io.opencmw.serialiser.annotations.MetaInfo; + +public final class MmiServiceHelper { + public static final String INTERNAL_SERVICE_NAMES = "mmi.service"; + public static final String INTERNAL_SERVICE_OPENAPI = "mmi.openapi"; + public static final String INTERNAL_SERVICE_DNS = "mmi.dns"; + public static final String INTERNAL_SERVICE_ECHO = "mmi.echo"; + + private MmiServiceHelper() { + // nothing to instantiate + } + + public static boolean isHtmlRequest(final URI topic) { + return topic != null && topic.getQuery() != null && MimeType.HTML == QueryParameterParser.getMimeType(topic.getQuery()); + } + + public static String wrapInAnchor(final String text, final URI uri) { + if (uri.getScheme() == null || uri.getScheme().equalsIgnoreCase("http") || uri.getScheme().equalsIgnoreCase("https")) { + return "" + text + ""; + } + return text; + } + + @MetaInfo(description = "output = input : echo service is that complex :-)", unit = "MMI Echo Service") + public static class MmiEcho extends BasicMdpWorker { + public MmiEcho(final MajordomoBroker broker, final RbacRole... rbacRoles) { + super(broker.getContext(), INTERNAL_SERVICE_ECHO, rbacRoles); + this.registerHandler(ctx -> ctx.rep.data = ctx.req.data); // output = input : echo service is complex :-) + } + } + + @MetaInfo(description = "Dynamic Name Service (DNS) returning registered internal and external broker endpoints' URIs (protocol, host ip, port etc.)", unit = "MMI DNS Service") + public static class MmiDns extends BasicMdpWorker { + public MmiDns(final MajordomoBroker broker, final RbacRole... rbacRoles) { + super(broker.getContext(), INTERNAL_SERVICE_DNS, rbacRoles); + this.registerHandler(ctx -> { + if (isHtmlRequest(ctx.req.topic)) { + ctx.rep.data = broker.dnsCache.values().stream().map(MajordomoBroker.DnsServiceItem::getDnsEntryHtml).collect(Collectors.joining(",
")).getBytes(UTF_8); + } else { + ctx.rep.data = broker.dnsCache.values().stream().map(MajordomoBroker.DnsServiceItem::getDnsEntry).collect(Collectors.joining(",")).getBytes(UTF_8); + } + }); + } + } + + @MetaInfo(description = "endpoint returning OpenAPI definitions", unit = "MMI OpenAPI Worker Class Definitions") + public static class MmiOpenApi extends BasicMdpWorker { + public MmiOpenApi(final MajordomoBroker broker, final RbacRole... rbacRoles) { + super(broker.getContext(), INTERNAL_SERVICE_OPENAPI, rbacRoles); + this.registerHandler(context -> { + final String serviceName = context.req.data == null ? "" : new String(context.req.data, UTF_8); + final MajordomoBroker.Service service = broker.services.get(serviceName); + if (service == null) { + throw new IllegalArgumentException("requested invalid service name '" + serviceName + "' msg " + context.req); + } + context.rep.data = service.serviceDescription; + }); + } + } + + @MetaInfo(description = "definition according to http://rfc.zeromq.org/spec:8", unit = "MMI Service/Property Definitions") + public static class MmiService extends BasicMdpWorker { + public MmiService(final MajordomoBroker broker, final RbacRole... rbacRoles) { + super(broker.getContext(), INTERNAL_SERVICE_NAMES, rbacRoles); + this.registerHandler(context -> { + final String serviceName = (context.req.data == null) ? "" : new String(context.req.data, UTF_8); + if (serviceName.isBlank()) { + if (isHtmlRequest(context.req.topic)) { + context.rep.data = broker.services.keySet().stream().sorted().map(s -> wrapInAnchor(s, URI.create("/" + s))).collect(Collectors.joining(",")).getBytes(UTF_8); + } else { + context.rep.data = broker.services.keySet().stream().sorted().collect(Collectors.joining(",")).getBytes(UTF_8); + } + } else { + context.rep.data = (broker.services.containsKey(serviceName) ? "200" : "400").getBytes(UTF_8); + } + }); + } + } +} diff --git a/server/src/main/java/module-info.java b/server/src/main/java/module-info.java new file mode 100644 index 00000000..d7c0951c --- /dev/null +++ b/server/src/main/java/module-info.java @@ -0,0 +1,12 @@ +open module io.opencmw.server { + requires org.slf4j; + requires io.opencmw.serialiser; + requires io.opencmw; + requires jeromq; + requires java.management; + requires org.apache.commons.lang3; + requires velocity.engine.core; + requires org.jetbrains.annotations; + + exports io.opencmw.server; +} \ No newline at end of file diff --git a/server/src/main/resources/velocity/property/defaultPropertyLayout.vm b/server/src/main/resources/velocity/property/defaultPropertyLayout.vm new file mode 100644 index 00000000..c087b7d3 --- /dev/null +++ b/server/src/main/resources/velocity/property/defaultPropertyLayout.vm @@ -0,0 +1,218 @@ + + +#if (!$noMenu) + + + +

+

Property: '$service'

+

$mdpMetaData.getFieldDescription()

+
+ + +
+Subscribe: + + +
+ +
+ +#else + +#macro( renderPropertyData $propertyDataName $propertyData ) + + + + + + #foreach ($field in $propertyData.keySet()) + #if ($field.getFieldUnit()) + #set($unit = "["+$field.getFieldUnit()+"]:") + #else + #set($unit = ":") + #end + #if ($field.isEnum()) + #set($unit = "["+$field.getTypeNameSimple()+"]:") + #end + #if ($field.getFieldDescription()) + #set($description = $field.getFieldDescription()) + #else + #set($description = "") + #end + #set($value = $propertyData.get($field)) + + + + + + #end + +
$propertyDataName Field NameUnit:Field Value:
$unit + #if ($field.isEnum()) + + #else + #if ($value == "true" || $value == "false") + #set($type = 'type="checkbox"') + #else + #set($type = 'type="text" placeholder=""') + #end + + #end +
+#end + + +
+
+ + +
+
+ #renderPropertyData( $requestCtx.getClass().getSimpleName() $requestCtxClassData ) + ## pushes form data as query string and reloads with the new URI +
+
+
+
+ + + +#if ($request && $requestClassData && $requestClassData.size() != 0 && $request.getClass().getSimpleName() != "NoData") +
+
+ + +
+
+ #renderPropertyData( $request.getClass().getSimpleName() $requestClassData ) + +
+
+
+
+#end + +
+
+ + +
+
+ #renderPropertyData( $replyCtx.getClass().getSimpleName() $replyCtxClassData ) +
+
+
+
+ +
+
+ + +
+
+ #renderPropertyData( $reply.getClass().getSimpleName() $replyClassData ) + #if ( $reply.getClass().getSimpleName() == "BinaryData") + + #end +
+
+
+
+#end \ No newline at end of file diff --git a/server/src/test/java/io/opencmw/server/BasicMdpWorkerTest.java b/server/src/test/java/io/opencmw/server/BasicMdpWorkerTest.java new file mode 100644 index 00000000..df1fb356 --- /dev/null +++ b/server/src/test/java/io/opencmw/server/BasicMdpWorkerTest.java @@ -0,0 +1,111 @@ +package io.opencmw.server; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import static org.awaitility.Awaitility.await; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; +import static org.junit.jupiter.api.Assertions.*; + +import static io.opencmw.OpenCmwProtocol.Command.W_NOTIFY; +import static io.opencmw.OpenCmwProtocol.EMPTY_FRAME; +import static io.opencmw.OpenCmwProtocol.MdpSubProtocol.PROT_WORKER; + +import java.net.URI; +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.LockSupport; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.zeromq.ZContext; + +import io.opencmw.OpenCmwProtocol; +import io.opencmw.rbac.BasicRbacRole; +import io.opencmw.utils.SystemProperties; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class BasicMdpWorkerTest { + @BeforeAll + void init() { + System.getProperties().setProperty("OpenCMW.heartBeat", "50"); + assertEquals(50, SystemProperties.getValueIgnoreCase("OpenCMW.heartBeat", 2500), "reduced heart-beat interval"); + } + + @Test + void testConstructors() { + assertDoesNotThrow(() -> new BasicMdpWorker(new ZContext(), "testService")); + assertDoesNotThrow(() -> new BasicMdpWorker(new ZContext(), "testService", BasicRbacRole.ADMIN)); + assertDoesNotThrow(() -> new BasicMdpWorker("mdp://localhost", "testService")); + assertDoesNotThrow(() -> new BasicMdpWorker("mdp://localhost", "testService", BasicRbacRole.ADMIN)); + } + + @Test + void testGetterSetter() { + BasicMdpWorker worker = new BasicMdpWorker(new ZContext(), "testService", BasicRbacRole.ADMIN); + BasicMdpWorker worker2 = new BasicMdpWorker(new ZContext(), "testService", BasicRbacRole.ADMIN); + + assertThat(worker.getReconnectDelay(), not(equalTo(Duration.of(123, ChronoUnit.MILLIS)))); + assertDoesNotThrow(() -> worker.setReconnectDelay(123, TimeUnit.MILLISECONDS)); + assertThat(worker.getReconnectDelay(), equalTo(Duration.of(123, ChronoUnit.MILLIS))); + + assertEquals("testService", worker.getServiceName()); + + assertNotNull(worker.getUniqueID()); + assertNotNull(worker2.getUniqueID()); + assertThat(worker.getUniqueID(), not(equalTo(worker2.getUniqueID()))); + + assertNotNull(worker.getRbacRoles()); + assertThat(worker.getRbacRoles(), contains(BasicRbacRole.ADMIN)); + + final BasicMdpWorker.RequestHandler handler = c -> {}; + assertThat(worker.getRequestHandler(), not(equalTo(handler))); + assertDoesNotThrow(() -> worker.registerHandler(handler)); + assertThat(worker.getRequestHandler(), equalTo(handler)); + } + + @ParameterizedTest + @ValueSource(booleans = { true /*, false */ }) + void testBasicThreadHander(boolean internal) { + // N.B. this is not a full-blown tests and only covers the basic failure mode, full tests is implemented in MajordomoBrokerTests + BasicMdpWorker worker = internal ? new BasicMdpWorker(new ZContext(), "testService", BasicRbacRole.ADMIN) : new BasicMdpWorker("mdp://*:8080/", "testService", BasicRbacRole.ADMIN); + + final AtomicBoolean run = new AtomicBoolean(false); + final AtomicBoolean stop = new AtomicBoolean(false); + new Thread(() -> { + run.set(true); + worker.start(); + stop.set(true); + }).start(); + + await().alias("wait for thread to start worker").atMost(1, TimeUnit.SECONDS).until(run::get, equalTo(true)); + await().alias("wait for thread to have finished starting worker").atMost(1, TimeUnit.SECONDS).until(stop::get, equalTo(true)); + run.set(false); + stop.set(false); + + assertTrue(worker.runSocketHandlerLoop.get(), "run loop is running"); + + // check basic notification + final OpenCmwProtocol.MdpMessage msg = new OpenCmwProtocol.MdpMessage(null, PROT_WORKER, W_NOTIFY, "testService".getBytes(UTF_8), EMPTY_FRAME, URI.create(""), EMPTY_FRAME, "", null); + assertFalse(worker.notify(msg)); // is filtered: no subscription -> false + assertTrue(worker.notifyRaw(msg)); // is unfiltered: no subscription -> true + + // wait for five heartbeats -> checks poller, heartbeat and reconnect features (to some extend) + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(5L * BasicMdpWorker.HEARTBEAT_INTERVAL)); + + new Thread(() -> { + run.set(true); + worker.stopWorker(); + stop.set(true); + }).start(); + await().alias("wait for thread to stop worker").atMost(1, TimeUnit.SECONDS).until(run::get, equalTo(true)); + await().alias("wait for thread to have finished stopping worker").atMost(1, TimeUnit.SECONDS).until(stop::get, equalTo(true)); + } +} \ No newline at end of file diff --git a/server/src/test/java/io/opencmw/server/MajordomoBrokerTests.java b/server/src/test/java/io/opencmw/server/MajordomoBrokerTests.java new file mode 100644 index 00000000..f35c72cb --- /dev/null +++ b/server/src/test/java/io/opencmw/server/MajordomoBrokerTests.java @@ -0,0 +1,368 @@ +package io.opencmw.server; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import static org.awaitility.Awaitility.await; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.jupiter.api.Assertions.*; + +import static io.opencmw.OpenCmwProtocol.Command.FINAL; +import static io.opencmw.OpenCmwProtocol.Command.GET_REQUEST; +import static io.opencmw.OpenCmwProtocol.MdpMessage; +import static io.opencmw.OpenCmwProtocol.MdpSubProtocol; +import static io.opencmw.OpenCmwProtocol.MdpSubProtocol.PROT_CLIENT; +import static io.opencmw.OpenCmwProtocol.MdpSubProtocol.PROT_WORKER; +import static io.opencmw.utils.AnsiDefs.ANSI_RED; + +import java.io.IOException; +import java.net.URI; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.locks.LockSupport; + +import org.junit.jupiter.api.Test; +import org.zeromq.SocketType; +import org.zeromq.Utils; +import org.zeromq.ZMQ; +import org.zeromq.ZMsg; + +import io.opencmw.rbac.BasicRbacRole; +import io.opencmw.rbac.RbacToken; + +class MajordomoBrokerTests { + private static final byte[] DEFAULT_RBAC_TOKEN = new RbacToken(BasicRbacRole.ADMIN, "HASHCODE").getBytes(); + private static final String DEFAULT_MMI_SERVICE = "mmi.service"; + private static final String DEFAULT_ECHO_SERVICE = "mmi.echo"; + private static final String DEFAULT_REQUEST_MESSAGE = "Hello World!"; + private static final byte[] DEFAULT_REQUEST_MESSAGE_BYTES = DEFAULT_REQUEST_MESSAGE.getBytes(UTF_8); + + /** + * Main method - create and start new broker. + * + * @param args none + */ + public static void main(String[] args) { + MajordomoBroker broker = new MajordomoBroker("TestMdpBroker", "tcp://*:5555", BasicRbacRole.values()); + // broker.setDaemon(true); // use this if running in another app that + // controls threads Can be called multiple times with different endpoints + broker.bind("tcp://*:5555"); + broker.bind("tcp://*:5556"); + + for (int i = 0; i < 10; i++) { + // simple internalSock echo + BasicMdpWorker workerSession = new BasicMdpWorker(broker.getContext(), "inproc.echo", BasicRbacRole.ADMIN); // NOPMD safe instantiation + workerSession.registerHandler(ctx -> ctx.rep.data = ctx.req.data); // output = input : echo service is complex :-) + workerSession.start(); + } + + broker.start(); + } + + @Test + void basicLowLevelRequestReplyTest() throws IOException { + MajordomoBroker broker = new MajordomoBroker("TestBroker", "", BasicRbacRole.values()); + // broker.setDaemon(true); // use this if running in another app that controls threads + final String brokerAddress = broker.bind("mdp://*:" + Utils.findOpenPort()); + assertFalse(broker.isRunning(), "broker not running"); + broker.start(); + assertTrue(broker.isRunning(), "broker running"); + // test interfaces + assertNotNull(broker.getContext()); + assertNotNull(broker.getInternalRouterSocket()); + assertNotNull(broker.getServices()); + assertEquals(4, broker.getServices().size()); + assertDoesNotThrow(() -> broker.addInternalService(new BasicMdpWorker(broker.getContext(), "demoService"))); + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(100)); // wait until services are started + assertEquals(5, broker.getServices().size()); + assertDoesNotThrow(() -> broker.removeService("demoService")); + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(100)); // wait until services are stopped + assertEquals(5, broker.getServices().size()); + + // wait until all services are initialised + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(200)); + + final ZMQ.Socket clientSocket = broker.getContext().createSocket(SocketType.DEALER); + clientSocket.setIdentity("demoClient".getBytes(UTF_8)); + clientSocket.connect(brokerAddress.replace("mdp", "tcp")); + + // wait until client is connected + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(200)); + + final byte[] clientRequestID = "unit-test-clientRequestID".getBytes(UTF_8); + new MdpMessage(null, PROT_CLIENT, GET_REQUEST, DEFAULT_ECHO_SERVICE.getBytes(UTF_8), clientRequestID, URI.create(DEFAULT_ECHO_SERVICE), DEFAULT_REQUEST_MESSAGE_BYTES, "", new byte[0]).send(clientSocket); + final MdpMessage clientMessage = MdpMessage.receive(clientSocket); + assertNotNull(clientMessage, "reply message w/o RBAC token not being null"); + assertNotNull(clientMessage.toString()); + assertNotNull(clientMessage.senderID); // default dealer socket does not export sender ID (only ROUTER and/or enabled sockets) + assertEquals(MdpSubProtocol.PROT_CLIENT, clientMessage.protocol, "equal protocol"); + assertEquals(FINAL, clientMessage.command, "matching command"); + assertArrayEquals(DEFAULT_ECHO_SERVICE.getBytes(UTF_8), clientMessage.serviceNameBytes, "equal service name"); + assertNotNull(clientMessage.data, "user-data not being null"); + assertArrayEquals(DEFAULT_REQUEST_MESSAGE_BYTES, clientMessage.data, "equal data"); + assertFalse(clientMessage.hasRbackToken()); + assertNotNull(clientMessage.rbacToken); + assertEquals(0, clientMessage.rbacToken.length, "rback token length (should be 0: not defined)"); + + broker.stopBroker(); + } + + @Test + void basicSynchronousRequestReplyTest() throws IOException { + final MajordomoBroker broker = new MajordomoBroker("TestBroker", "", BasicRbacRole.values()); + // broker.setDaemon(true); // use this if running in another app that controls threads + final String brokerAddress = broker.bind("mdp://*:" + Utils.findOpenPort()); + broker.start(); + assertEquals(4, broker.getServices().size()); + + // add external (albeit inproc) Majordomo worker to the broker + BasicMdpWorker internal = new BasicMdpWorker(broker.getContext(), "inproc.echo", BasicRbacRole.ADMIN); + internal.registerHandler(ctx -> ctx.rep.data = ctx.req.data); // output = input : echo service is complex :-) + internal.start(); + + // add external Majordomo worker to the broker + BasicMdpWorker external = new BasicMdpWorker(broker.getContext(), "ext.echo", BasicRbacRole.ADMIN); + external.registerHandler(ctx -> ctx.rep.data = ctx.req.data); // output = input : echo service is complex :-) + external.start(); + + // add external (albeit inproc) Majordomo worker to the broker + BasicMdpWorker exceptionService = new BasicMdpWorker(broker.getContext(), "inproc.exception", BasicRbacRole.ADMIN); + exceptionService.registerHandler(input -> { throw new IllegalAccessError("this is always thrown"); }); // always throw an exception + exceptionService.start(); + + // wait until all services are initialised + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(200)); + assertEquals(7, broker.getServices().size()); + + // using simple synchronous client + MajordomoTestClientSync clientSession = new MajordomoTestClientSync(brokerAddress, "customClientName"); + assertEquals(3, clientSession.getRetries()); + assertDoesNotThrow(() -> clientSession.setRetries(4)); + assertEquals(4, clientSession.getRetries()); + assertEquals(2500, clientSession.getTimeout()); + assertDoesNotThrow(() -> clientSession.setTimeout(2000)); + assertEquals(2000, clientSession.getTimeout()); + assertNotNull(clientSession.getUniqueID()); + + { + final String serviceName = "mmi.echo"; + final MdpMessage replyWithoutRbac = clientSession.send(serviceName, DEFAULT_REQUEST_MESSAGE_BYTES); // w/o RBAC + assertNotNull(replyWithoutRbac, "reply message w/o RBAC token not being null"); + assertNotNull(replyWithoutRbac.data, "user-data not being null"); + assertArrayEquals(DEFAULT_REQUEST_MESSAGE_BYTES, replyWithoutRbac.data, "equal data"); + } + + { + final String serviceName = "inproc.echo"; + final MdpMessage replyWithoutRbac = clientSession.send(serviceName, DEFAULT_REQUEST_MESSAGE_BYTES); // w/o RBAC + assertNotNull(replyWithoutRbac, "reply message w/o RBAC token not being null"); + assertNotNull(replyWithoutRbac.data, "user-data not being null"); + assertArrayEquals(DEFAULT_REQUEST_MESSAGE_BYTES, replyWithoutRbac.data, "equal data"); + } + + { + final String serviceName = "ext.echo"; + final MdpMessage replyWithoutRbac = clientSession.send(serviceName, DEFAULT_REQUEST_MESSAGE_BYTES); // w/o RBAC + assertNotNull(replyWithoutRbac, "reply message w/o RBAC token not being null"); + assertNotNull(replyWithoutRbac.data, "user-data not being null"); + assertArrayEquals(DEFAULT_REQUEST_MESSAGE_BYTES, replyWithoutRbac.data, "equal data"); + } + + { + final String serviceName = "inproc.exception"; + final MdpMessage replyWithoutRbac = clientSession.send(serviceName, DEFAULT_REQUEST_MESSAGE_BYTES); // w/o RBAC + assertNotNull(replyWithoutRbac, "reply message w/o RBAC token not being null"); + assertNotNull(replyWithoutRbac.data, "user-data not being null"); + assertNotNull(replyWithoutRbac.errors, "user-data not being null"); + assertFalse(replyWithoutRbac.errors.isBlank(), "check that error stack trace is non-null/non-blank"); + final String refString = ANSI_RED + "io.opencmw.server.BasicMdpWorker caught exception for service 'inproc.exception'"; + assertEquals(refString, replyWithoutRbac.errors.substring(0, refString.length()), "correct exception message"); + } + + { + final String serviceName = "mmi.echo"; + final MdpMessage replyWithRbac = clientSession.send(serviceName, DEFAULT_REQUEST_MESSAGE_BYTES, DEFAULT_RBAC_TOKEN); // with RBAC + assertNotNull(replyWithRbac, "reply message with RBAC token not being null"); + assertNotNull(replyWithRbac.data, "user-data not being null"); + assertArrayEquals(DEFAULT_REQUEST_MESSAGE_BYTES, replyWithRbac.data, "equal data"); + assertNotNull(replyWithRbac.rbacToken, "RBAC token not being null"); + assertEquals(0, replyWithRbac.rbacToken.length, "non-defined RBAC token length"); + } + + internal.stopWorker(); + external.stopWorker(); + exceptionService.stopWorker(); + broker.stopBroker(); + } + + @Test + void basicMmiTests() throws IOException { + MajordomoBroker broker = new MajordomoBroker("TestBroker", "", BasicRbacRole.values()); + // broker.setDaemon(true); // use this if running in another app that controls threads + final int openPort = Utils.findOpenPort(); + broker.bind("tcp://*:" + openPort); + broker.start(); + + // using simple synchronous client + MajordomoTestClientSync clientSession = new MajordomoTestClientSync("tcp://localhost:" + openPort, "customClientName"); + + { + final MdpMessage replyWithoutRbac = clientSession.send("mmi.echo", DEFAULT_REQUEST_MESSAGE_BYTES); // w/o RBAC + assertNotNull(replyWithoutRbac, "reply message w/o RBAC token not being null"); + assertNotNull(replyWithoutRbac.data, "user-data not being null"); + assertArrayEquals(DEFAULT_REQUEST_MESSAGE_BYTES, replyWithoutRbac.data, "MMI echo service request"); + } + + { + final MdpMessage replyWithoutRbac = clientSession.send(DEFAULT_MMI_SERVICE, DEFAULT_MMI_SERVICE.getBytes(UTF_8)); // w/o RBAC + assertNotNull(replyWithoutRbac, "reply message w/o RBAC token not being null"); + assertNotNull(replyWithoutRbac.data, "user-data not being null"); + assertEquals("200", new String(replyWithoutRbac.data, UTF_8), "known MMI service request"); + } + + { + final MdpMessage replyWithoutRbac = clientSession.send(DEFAULT_MMI_SERVICE, DEFAULT_ECHO_SERVICE.getBytes(UTF_8)); // w/o RBAC + assertNotNull(replyWithoutRbac, "reply message w/o RBAC token not being null"); + assertNotNull(replyWithoutRbac.data, "user-data not being null"); + assertEquals("200", new String(replyWithoutRbac.data, UTF_8), "known MMI service request"); + } + + { + // MMI service request: service should not exist + final MdpMessage replyWithoutRbac = clientSession.send(DEFAULT_MMI_SERVICE, DEFAULT_REQUEST_MESSAGE_BYTES); // w/o RBAC + assertNotNull(replyWithoutRbac, "reply message w/o RBAC token not being null"); + assertNotNull(replyWithoutRbac.data, "user-data not being null"); + assertEquals("400", new String(replyWithoutRbac.data, UTF_8), "known MMI service request"); + } + + { + // unknown service name + final MdpMessage replyWithoutRbac = clientSession.send("unknownService", DEFAULT_REQUEST_MESSAGE_BYTES); // w/o RBAC + assertNotNull(replyWithoutRbac, "reply message w/o RBAC token not being null"); + assertNotNull(replyWithoutRbac.data, "user-data not being null"); + assertEquals("501", new String(replyWithoutRbac.data, UTF_8), "unknown MMI service request"); + } + + broker.stopBroker(); + } + + @Test + void basicASynchronousRequestReplyTest() throws IOException { + MajordomoBroker broker = new MajordomoBroker("TestBroker", "", BasicRbacRole.values()); + // broker.setDaemon(true); // use this if running in another app that controls threads + final int openPort = Utils.findOpenPort(); + broker.bind("tcp://*:" + openPort); + broker.start(); + + final AtomicInteger counter = new AtomicInteger(0); + new Thread(() -> { + // using simple synchronous client + MajordomoTestClientAsync clientSession = new MajordomoTestClientAsync("tcp://localhost:" + openPort); + assertEquals(2500, clientSession.getTimeout()); + assertDoesNotThrow(() -> clientSession.setTimeout(2000)); + assertEquals(2000, clientSession.getTimeout()); + + // send bursts of 10 messages + for (int i = 0; i < 5; i++) { + clientSession.send("mmi.echo", DEFAULT_REQUEST_MESSAGE_BYTES); + clientSession.send(DEFAULT_ECHO_SERVICE, DEFAULT_REQUEST_MESSAGE_BYTES); + } + + // receive bursts of 10 messages + for (int i = 0; i < 10; i++) { + final MdpMessage reply = clientSession.recv(); + assertNotNull(reply, "reply message w/o RBAC token not being null"); + assertNotNull(reply.data, "user-data not being null"); + assertArrayEquals(DEFAULT_REQUEST_MESSAGE_BYTES, reply.data); + counter.getAndIncrement(); + } + }).start(); + + await().alias("wait for reply messages").atMost(1, TimeUnit.SECONDS).until(counter::get, equalTo(10)); + assertEquals(10, counter.get(), "received expected number of replies"); + + broker.stopBroker(); + } + + @Test + void testSubscription() throws IOException { + final MajordomoBroker broker = new MajordomoBroker("TestBroker", "", BasicRbacRole.values()); + // broker.setDaemon(true); // use this if running in another app that controls threads + final String brokerAddress = broker.bind("mdp://*:" + Utils.findOpenPort()); + final String brokerPubAddress = broker.bind("mds://*:" + Utils.findOpenPort()); + broker.start(); + + final String testServiceName = "device/property"; + final byte[] testServiceBytes = "device/property".getBytes(UTF_8); + + // add external (albeit inproc) Majordomo worker to the broker + BasicMdpWorker internal = new BasicMdpWorker(broker.getContext(), testServiceName, BasicRbacRole.ADMIN); + internal.registerHandler(ctx -> ctx.rep.data = ctx.req.data); // output = input : echo service is complex :-) + internal.start(); + + final MdpMessage testMessage = new MdpMessage(null, PROT_WORKER, FINAL, testServiceBytes, "clientRequestID".getBytes(UTF_8), URI.create(new String(testServiceBytes)), DEFAULT_REQUEST_MESSAGE_BYTES, "", new byte[0]); + + final AtomicInteger counter = new AtomicInteger(0); + final AtomicBoolean run = new AtomicBoolean(true); + final AtomicBoolean started1 = new AtomicBoolean(false); + new Thread(() -> { + // using simple synchronous client + MajordomoTestClientAsync clientSession = new MajordomoTestClientAsync(brokerAddress); + assertEquals(2500, clientSession.getTimeout()); + assertDoesNotThrow(() -> clientSession.setTimeout(2000)); + assertEquals(2000, clientSession.getTimeout()); + clientSession.subscribe(testServiceBytes); + + // send bursts of 10 messages + for (int i = 0; i < 10 && run.get(); i++) { + started1.set(true); + final MdpMessage reply = clientSession.recv(); + assertNotNull(reply, "reply message w/o RBAC token not being null"); + assertNotNull(reply.data, "user-data not being null"); + assertArrayEquals(DEFAULT_REQUEST_MESSAGE_BYTES, reply.data); + counter.getAndIncrement(); + } + }).start(); + + // low-level subscription + final AtomicInteger subCounter = new AtomicInteger(0); + final AtomicBoolean started2 = new AtomicBoolean(false); + final Thread subcriptionThread = new Thread(() -> { + // low-level subscription + final ZMQ.Socket sub = broker.getContext().createSocket(SocketType.SUB); + sub.setHWM(0); + sub.connect(brokerPubAddress.replace("mds://", "tcp://")); + sub.subscribe("device/property"); + sub.subscribe("device/otherProperty"); + sub.unsubscribe("device/otherProperty"); + while (run.get() && !Thread.interrupted()) { + started2.set(true); + final ZMsg msg = ZMsg.recvMsg(sub, false); + if (msg == null) { + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1)); + continue; + } + subCounter.getAndIncrement(); + } + sub.unsubscribe("device/property"); + }); + subcriptionThread.start(); + + // wait until all services are initialised + await().alias("wait for thread1 to start").atMost(1, TimeUnit.SECONDS).until(started1::get, equalTo(true)); + await().alias("wait for thread2 to start").atMost(1, TimeUnit.SECONDS).until(started2::get, equalTo(true)); + // send bursts of 10 messages + for (int i = 0; i < 10; i++) { + internal.notify(testMessage); + } + + await().alias("wait for reply messages").atMost(2, TimeUnit.SECONDS).until(counter::get, equalTo(10)); + run.set(false); + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(100)); + assertFalse(subcriptionThread.isAlive(), "subscription thread shut-down"); + assertEquals(10, counter.get(), "received expected number of replies"); + assertEquals(10, subCounter.get(), "received expected number of subscription replies"); + + broker.stopBroker(); + } +} diff --git a/server/src/test/java/io/opencmw/server/MajordomoTestClientAsync.java b/server/src/test/java/io/opencmw/server/MajordomoTestClientAsync.java new file mode 100644 index 00000000..1f2c1750 --- /dev/null +++ b/server/src/test/java/io/opencmw/server/MajordomoTestClientAsync.java @@ -0,0 +1,120 @@ +package io.opencmw.server; + +import static io.opencmw.OpenCmwProtocol.Command.GET_REQUEST; +import static io.opencmw.OpenCmwProtocol.Command.SUBSCRIBE; +import static io.opencmw.OpenCmwProtocol.EMPTY_FRAME; +import static io.opencmw.OpenCmwProtocol.MdpSubProtocol.PROT_CLIENT; + +import java.net.URI; +import java.nio.charset.StandardCharsets; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.zeromq.SocketType; +import org.zeromq.ZContext; +import org.zeromq.ZMQ; + +import io.opencmw.OpenCmwProtocol; + +/** + * Majordomo Protocol Client API, asynchronous Java version. Implements the + * OpenCmwProtocol/Worker spec at http://rfc.zeromq.org/spec:7. + */ +public class MajordomoTestClientAsync { + private static final Logger LOGGER = LoggerFactory.getLogger(MajordomoTestClientAsync.class); + private final String broker; + private final ZContext ctx; + private ZMQ.Socket clientSocket; + private long timeout = 2500; + private ZMQ.Poller poller; + + public MajordomoTestClientAsync(final String broker) { + this.broker = broker.replace("mdp://", "tcp://"); + ctx = new ZContext(); + reconnectToBroker(); + } + + public void destroy() { + ctx.destroy(); + } + + public long getTimeout() { + return timeout; + } + + /** + * Returns the reply message or NULL if there was no reply. Does not attempt + * to recover from a broker failure, this is not possible without storing + * all unanswered requests and resending them all… + * @return the MdpMessage + */ + public OpenCmwProtocol.MdpMessage recv() { + // Poll socket for a reply, with timeout + if (poller.poll(timeout * 1000) == -1) { + return null; // Interrupted + } + + if (poller.pollin(0)) { + return OpenCmwProtocol.MdpMessage.receive(clientSocket, false); + } + return null; + } + + /** + * Send request to broker and get reply by hook or crook Takes ownership of request message and destroys it when sent. + * + * @param service UTF-8 encoded service name byte array + * @param msgs message(s) to be sent to OpenCmwProtocol broker (if more than one, than the last is assumed to be a RBAC-token + */ + public boolean send(final byte[] service, final byte[]... msgs) { + final String topic = new String(service, StandardCharsets.UTF_8); + final byte[] rbacToken = msgs.length > 1 ? msgs[1] : null; + return new OpenCmwProtocol.MdpMessage(null, PROT_CLIENT, GET_REQUEST, service, "requestID".getBytes(StandardCharsets.UTF_8), URI.create(topic), msgs[0], "", rbacToken).send(clientSocket); + } + + /** + * Send subscription request to broker + * + * @param service UTF-8 encoded service name byte array + * @param rbacToken optional RBAC-token + */ + public boolean subscribe(final byte[] service, final byte[]... rbacToken) { + final String topic = new String(service, StandardCharsets.UTF_8); + final byte[] rbacTokenByte = rbacToken.length > 0 ? rbacToken[0] : null; + return new OpenCmwProtocol.MdpMessage(null, PROT_CLIENT, SUBSCRIBE, service, "requestID".getBytes(StandardCharsets.UTF_8), URI.create(topic), EMPTY_FRAME, "", rbacTokenByte).send(clientSocket); + } + + /** + * Send request to broker and get reply by hook or crook Takes ownership of request message and destroys it when sent. + * + * @param service UTF-8 encoded service name byte array + * @param msgs message(s) to be sent to OpenCmwProtocol broker (if more than one, than the last is assumed to be a RBAC-token + */ + public boolean send(final String service, final byte[]... msgs) { + return send(service.getBytes(StandardCharsets.UTF_8), msgs); + } + + public void setTimeout(long timeout) { + this.timeout = timeout; + } + + /** + * Connect or reconnect to broker + */ + void reconnectToBroker() { + if (clientSocket != null) { + clientSocket.close(); + } + clientSocket = ctx.createSocket(SocketType.DEALER); + clientSocket.setHWM(0); + clientSocket.setIdentity("clientV2".getBytes(StandardCharsets.UTF_8)); + clientSocket.connect(broker); + if (poller != null) { + poller.unregister(clientSocket); + poller.close(); + } + poller = ctx.createPoller(1); + poller.register(clientSocket, ZMQ.Poller.POLLIN); + LOGGER.atDebug().addArgument(broker).log("connecting to broker at: '{}'"); + } +} diff --git a/server/src/test/java/io/opencmw/server/MajordomoTestClientSync.java b/server/src/test/java/io/opencmw/server/MajordomoTestClientSync.java new file mode 100644 index 00000000..3b1791fb --- /dev/null +++ b/server/src/test/java/io/opencmw/server/MajordomoTestClientSync.java @@ -0,0 +1,134 @@ +package io.opencmw.server; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import static io.opencmw.OpenCmwProtocol.Command.GET_REQUEST; +import static io.opencmw.OpenCmwProtocol.MdpSubProtocol.PROT_CLIENT; + +import java.lang.management.ManagementFactory; +import java.net.URI; +import java.util.Arrays; +import java.util.Formatter; +import java.util.concurrent.atomic.AtomicInteger; + +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.zeromq.SocketType; +import org.zeromq.ZContext; +import org.zeromq.ZMQ; +import org.zeromq.ZMsg; + +import io.opencmw.OpenCmwProtocol; + +/** +* Majordomo Protocol Client API, implements the OpenCMW MDP variant +* +*/ +public class MajordomoTestClientSync { + private static final Logger LOGGER = LoggerFactory.getLogger(MajordomoTestClientSync.class); + private static final AtomicInteger CLIENT_V1_INSTANCE = new AtomicInteger(); + private final String uniqueID; + private final byte[] uniqueIdBytes; + private final String broker; + private final ZContext ctx; + private ZMQ.Socket clientSocket; + private long timeout = 2500; + private int retries = 3; + private final Formatter log = new Formatter(System.out); + private ZMQ.Poller poller; + + public MajordomoTestClientSync(String broker, String clientName) { + this.broker = broker.replace("mdp://", "tcp://"); + ctx = new ZContext(); + + uniqueID = clientName + "PID=" + ManagementFactory.getRuntimeMXBean().getName() + "-InstanceID=" + CLIENT_V1_INSTANCE.getAndIncrement(); + uniqueIdBytes = uniqueID.getBytes(ZMQ.CHARSET); + + reconnectToBroker(); + } + + /** + * Connect or reconnect to broker + */ + void reconnectToBroker() { + if (clientSocket != null) { + clientSocket.close(); + } + clientSocket = ctx.createSocket(SocketType.DEALER); + clientSocket.setHWM(0); + clientSocket.setIdentity(uniqueIdBytes); + clientSocket.connect(broker); + + if (poller != null) { + poller.unregister(clientSocket); + poller.close(); + } + poller = ctx.createPoller(1); + poller.register(clientSocket, ZMQ.Poller.POLLIN); + LOGGER.atDebug().addArgument(broker).log("connecting to broker at: '{}'"); + } + + public void destroy() { + ctx.destroy(); + } + + public int getRetries() { + return retries; + } + + public void setRetries(int retries) { + this.retries = retries; + } + + public long getTimeout() { + return timeout; + } + + public void setTimeout(long timeout) { + this.timeout = timeout; + } + + public String getUniqueID() { + return uniqueID; + } + + /** + * Send request to broker and get reply by hook or crook takes ownership of + * request message and destroys it when sent. Returns the reply message or + * NULL if there was no reply. + * + * @param service UTF-8 encoded service name + * @param msgs message(s) to be sent to OpenCmwProtocol broker (if more than one, than the last is assumed to be a RBAC-token + * @return reply message or NULL if there was no reply + */ + public OpenCmwProtocol.MdpMessage send(final String service, final byte[]... msgs) { + ZMsg reply = null; + + int retriesLeft = retries; + while (retriesLeft > 0 && !Thread.currentThread().isInterrupted()) { + final URI topic = URI.create(service); + final byte[] serviceBytes = StringUtils.stripStart(topic.getPath(), "/").getBytes(UTF_8); + final byte[] rbacToken = msgs.length > 1 ? msgs[1] : null; + if (!new OpenCmwProtocol.MdpMessage(null, PROT_CLIENT, GET_REQUEST, serviceBytes, "requestID".getBytes(UTF_8), topic, msgs[0], "", rbacToken).send(clientSocket)) { + throw new IllegalStateException("could not send request " + Arrays.toString(msgs)); + } + + // Poll socket for a reply, with timeout + if (poller.poll(timeout) == -1) + break; // Interrupted + + if (poller.pollin(0)) { + return OpenCmwProtocol.MdpMessage.receive(clientSocket, false); + } else { + if (--retriesLeft == 0) { + log.format("W: permanent error, abandoning\n"); + break; + } + log.format("W: no reply, reconnecting\n"); + reconnectToBroker(); + } + } + return null; + } +} diff --git a/server/src/test/java/io/opencmw/server/MajordomoWorkerTests.java b/server/src/test/java/io/opencmw/server/MajordomoWorkerTests.java new file mode 100644 index 00000000..b2eeaeb3 --- /dev/null +++ b/server/src/test/java/io/opencmw/server/MajordomoWorkerTests.java @@ -0,0 +1,409 @@ +package io.opencmw.server; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import static org.awaitility.Awaitility.await; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.junit.jupiter.api.Assertions.*; + +import static io.opencmw.OpenCmwProtocol.MdpMessage; + +import java.io.IOException; +import java.net.URI; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.locks.LockSupport; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.zeromq.SocketType; +import org.zeromq.Utils; +import org.zeromq.ZContext; +import org.zeromq.ZMQ; +import org.zeromq.util.ZData; + +import io.opencmw.MimeType; +import io.opencmw.OpenCmwProtocol; +import io.opencmw.QueryParameterParser; +import io.opencmw.domain.BinaryData; +import io.opencmw.domain.NoData; +import io.opencmw.filter.TimingCtx; +import io.opencmw.rbac.BasicRbacRole; +import io.opencmw.serialiser.IoBuffer; +import io.opencmw.serialiser.IoClassSerialiser; +import io.opencmw.serialiser.annotations.MetaInfo; +import io.opencmw.serialiser.spi.BinarySerialiser; +import io.opencmw.serialiser.spi.ClassFieldDescription; +import io.opencmw.serialiser.spi.CmwLightSerialiser; +import io.opencmw.serialiser.spi.FastByteBuffer; +import io.opencmw.serialiser.spi.JsonSerialiser; + +/** + * Basic test for MajordomoWorker abstract class + * @author rstein + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class MajordomoWorkerTests { + public static final String TEST_SERVICE_NAME = "basicHtml"; + private static final Logger LOGGER = LoggerFactory.getLogger(MajordomoWorkerTests.class); + private MajordomoBroker broker; + private TestHtmlService basicHtmlService; + private MajordomoTestClientSync clientSession; + + @Test + void testGetterSetter() { + assertDoesNotThrow(() -> new MajordomoWorker<>(broker.getContext(), "testServiceName", TestContext.class, RequestDataType.class, ReplyDataType.class, BasicRbacRole.ADMIN)); + assertDoesNotThrow(() -> new MajordomoWorker<>("mdp://localhost", "testServiceName", TestContext.class, RequestDataType.class, ReplyDataType.class, BasicRbacRole.ADMIN)); + + MajordomoWorker internal = new MajordomoWorker<>(broker.getContext(), "testServiceName", + TestContext.class, RequestDataType.class, ReplyDataType.class, BasicRbacRole.ADMIN); + + assertThrows(UnsupportedOperationException.class, () -> internal.registerHandler((rawCtx) -> {})); + + final MajordomoWorker.Handler handler = (rawCtx, reqCtx, in, repCtx, out) -> {}; + assertThat(internal.getHandler(), is(not(equalTo(handler)))); + assertDoesNotThrow(() -> internal.setHandler(handler)); + assertEquals(handler, internal.getHandler(), "handler get/set identity"); + + final MajordomoWorker.Handler htmlHandler = (rawCtx, reqCtx, in, repCtx, out) -> {}; + assertThat(internal.getHtmlHandler(), is(not(equalTo(handler)))); + assertDoesNotThrow(() -> internal.setHtmlHandler(htmlHandler)); + assertEquals(htmlHandler, internal.getHtmlHandler(), "HTML-handler get/set identity"); + } + + @Test + void simpleTest() throws IOException { + final MajordomoBroker broker = new MajordomoBroker("TestBroker", "", BasicRbacRole.values()); + // broker.setDaemon(true); // use this if running in another app that controls threads + final String brokerAddress = broker.bind("mdp://*:" + Utils.findOpenPort()); + final String brokerPubAddress = broker.bind("mds://*:" + Utils.findOpenPort()); + broker.start(); + + RequestDataType inputData = new RequestDataType(); + inputData.name = ""; + + final String requestTopic = "mdp://myServer:5555/helloWorld?ctx=FAIR.SELECTOR.C=2&testProperty=TestValue"; + final String testServiceName = URI.create(requestTopic).getPath(); + + // add external (albeit inproc) Majordomo worker to the broker + MajordomoWorker internal = new MajordomoWorker<>(broker.getContext(), testServiceName, + TestContext.class, RequestDataType.class, ReplyDataType.class, BasicRbacRole.ADMIN); + internal.setHandler((rawCtx, reqCtx, in, repCtx, out) -> { + LOGGER.atInfo().addArgument(reqCtx).log("received reqCtx = {}"); + LOGGER.atInfo().addArgument(in.name).log("received in.name = {}"); + + // processing data + out.name = in.name + "-modified"; + repCtx.ctx = TimingCtx.get("FAIR.SELECTOR.C=3"); + repCtx.ctx.bpcts = TimeUnit.MILLISECONDS.toMicros(System.currentTimeMillis()); + + LOGGER.atInfo().addArgument(repCtx).log("received reqCtx = {}"); + LOGGER.atInfo().addArgument(out.name).log("received out.name = {}"); + repCtx.contentType = MimeType.BINARY; // set default return type to OpenCMW's YaS + }); + internal.start(); + + // using simple synchronous client + MajordomoTestClientSync clientSession = new MajordomoTestClientSync(brokerAddress, "customClientName"); + + IoBuffer ioBuffer = new FastByteBuffer(4000); + IoClassSerialiser serialiser = new IoClassSerialiser(ioBuffer); + serialiser.serialiseObject(inputData); + byte[] input = Arrays.copyOf(ioBuffer.elements(), ioBuffer.position()); + { + final MdpMessage reply = clientSession.send(requestTopic, input); // w/o RBAC + if (!reply.errors.isBlank()) { + LOGGER.atError().addArgument(reply).log("reply with exceptions:\n{}"); + } + assertEquals("", reply.errors, "worker threw exception in reply"); + IoClassSerialiser deserialiser = new IoClassSerialiser(FastByteBuffer.wrap(reply.data)); + final ReplyDataType result = deserialiser.deserialiseObject(ReplyDataType.class); + + assertTrue(result.name.startsWith(inputData.name), "serialise-deserialise identity"); + } + + broker.stopBroker(); + } + + @Test + void testSerialiserIdentity() { + RequestDataType inputData = new RequestDataType(); + inputData.name = ""; + + IoBuffer ioBuffer = new FastByteBuffer(4000); + IoClassSerialiser serialiser = new IoClassSerialiser(ioBuffer); + serialiser.serialiseObject(inputData); + ioBuffer.flip(); + byte[] reply = Arrays.copyOf(ioBuffer.elements(), ioBuffer.limit() + 4); + + IoClassSerialiser deserialiser = new IoClassSerialiser(FastByteBuffer.wrap(reply)); + final RequestDataType result = deserialiser.deserialiseObject(RequestDataType.class); + assertTrue(result.name.startsWith(inputData.name), "serialise-deserialise identity"); + } + + @BeforeAll + void startBroker() throws IOException { + broker = new MajordomoBroker("TestBroker", "", BasicRbacRole.values()); + // broker.setDaemon(true); // use this if running in another app that controls threads + final String brokerAddress = broker.bind("mdp://*:" + Utils.findOpenPort()); + broker.start(); + + basicHtmlService = new TestHtmlService(broker.getContext()); + basicHtmlService.start(); + clientSession = new MajordomoTestClientSync(brokerAddress, "customClientName"); + } + + @AfterAll + void stopBroker() { + broker.stopBroker(); + } + + @ParameterizedTest + @ValueSource(strings = { "", "BINARY", "JSON", "CMWLIGHT", "HTML" }) + void basicEchoTest(final String contentType) { + final BinaryData inputData = new BinaryData(); + inputData.resourceName = "test"; + inputData.contentType = MimeType.TEXT; + inputData.data = "testBinaryData".getBytes(UTF_8); + + IoBuffer ioBuffer = new FastByteBuffer(4000, true, null); + IoClassSerialiser serialiser = new IoClassSerialiser(ioBuffer); + serialiser.setAutoMatchSerialiser(false); + switch (contentType) { + case "HTML": + case "JSON": + serialiser.setMatchedIoSerialiser(JsonSerialiser.class); + serialiser.serialiseObject(inputData); + break; + case "CMWLIGHT": + serialiser.setMatchedIoSerialiser(CmwLightSerialiser.class); + serialiser.serialiseObject(inputData); + break; + case "": + case "BINARY": + default: + serialiser.setMatchedIoSerialiser(BinarySerialiser.class); + serialiser.serialiseObject(inputData); + break; + } + ioBuffer.flip(); + byte[] requestData = Arrays.copyOf(ioBuffer.elements(), ioBuffer.limit()); + final String mimeType = contentType.isBlank() ? "" : ("?contentType=" + contentType) + ("HTML".equals(contentType) ? "&noMenu" : ""); + final OpenCmwProtocol.MdpMessage rawReply = clientSession.send(TEST_SERVICE_NAME + mimeType, requestData); + assertNotNull(rawReply, "rawReply not being null"); + assertEquals("", rawReply.errors, "no exception thrown"); + assertNotNull(rawReply.data, "user-data not being null"); + + // test input/output equality + switch (contentType) { + case "HTML": + // HTML return + final String replyHtml = new String(rawReply.data); + // very crude check whether required reply field ids are present - we skip detailed HTLM parsing -> more efficiently done by a human and browser + assertThat(replyHtml, containsString("id=\"resourceName\"")); + assertThat(replyHtml, containsString("id=\"contentType\"")); + assertThat(replyHtml, containsString("id=\"data\"")); + assertThat(replyHtml, containsString(inputData.resourceName)); + assertThat(replyHtml, containsString(inputData.contentType.name())); + return; + case "": + case "JSON": + case "CMWLIGHT": + case "BINARY": + IoClassSerialiser deserialiser = new IoClassSerialiser(FastByteBuffer.wrap(rawReply.data)); + final BinaryData reply = deserialiser.deserialiseObject(BinaryData.class); + ioBuffer.flip(); + + assertNotNull(reply); + System.err.println("reply " + reply); + assertEquals(inputData.resourceName, reply.resourceName, "identity resourceName field"); + assertEquals(inputData.contentType, reply.contentType, "identity contentType field"); + assertArrayEquals(inputData.data, reply.data, "identity data field"); + return; + default: + throw new IllegalStateException("unimplemented contentType test: contentType=" + contentType); + } + } + + @Test + void basicDefaultHtmlHandlerTest() { + assertDoesNotThrow(() -> new DefaultHtmlHandler<>(this.getClass(), null, map -> { + map.put("extraKey", "extraValue"); + map.put("extraUnkownObject", new NoData()); + })); + } + + @Test + void testGenerateQueryParameter() { + final TestParameterClass testParam = new TestParameterClass(); + final Map map = DefaultHtmlHandler.generateQueryParameter(testParam); + assertNotNull(map); + } + + @Test + void testNotifySubscription() { + // start low-level subscription + final AtomicInteger subCounter = new AtomicInteger(0); + final AtomicBoolean run = new AtomicBoolean(true); + final AtomicBoolean startedSubscriber = new AtomicBoolean(false); + final Thread subcriptionThread = new Thread(() -> { + try (ZMQ.Socket sub = broker.getContext().createSocket(SocketType.SUB)) { + sub.setHWM(0); + sub.connect(MajordomoBroker.INTERNAL_ADDRESS_PUBLISHER); + sub.subscribe(TEST_SERVICE_NAME); + sub.subscribe(""); + while (run.get() && !Thread.interrupted()) { + startedSubscriber.set(true); + final MdpMessage rawReply = MdpMessage.receive(sub, false); + if (rawReply == null) { + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1)); + continue; + } + IoClassSerialiser deserialiser = new IoClassSerialiser(FastByteBuffer.wrap(rawReply.data)); + final BinaryData reply = deserialiser.deserialiseObject(BinaryData.class); + final Map> queryMap = QueryParameterParser.getMap(rawReply.topic.getQuery()); + final List testValues = queryMap.get("testValue"); + final int iteration = subCounter.getAndIncrement(); + + // very basic check that the correct notifications have been received in order + assertEquals("resourceName" + iteration, reply.resourceName, "resourceName field"); + assertEquals(1, testValues.size(), "test query parameter number"); + assertEquals("notify" + iteration, testValues.get(0), "test query parameter name"); + } + sub.unsubscribe(TEST_SERVICE_NAME); + } + }); + subcriptionThread.start(); + + // wait until all services are initialised + await().alias("wait for thread2 to start").atMost(1, TimeUnit.SECONDS).until(startedSubscriber::get, equalTo(true)); + + // send bursts of 10 messages + for (int i = 0; i < 10; i++) { + TestContext notifyCtx = new TestContext(); + notifyCtx.testValue = "notify" + i; + BinaryData reply = new BinaryData(); + reply.resourceName = "resourceName" + i; + basicHtmlService.notify(notifyCtx, reply); + } + + await().alias("wait for reply messages").atMost(2, TimeUnit.SECONDS).until(subCounter::get, equalTo(10)); + run.set(false); + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(100)); + assertFalse(subcriptionThread.isAlive(), "subscription thread shut-down"); + assertEquals(10, subCounter.get(), "received expected number of subscription replies"); + } + + public static class TestContext { + @MetaInfo(description = "FAIR timing context selector, e.g. FAIR.SELECTOR.C=0, ALL, ...") + public TimingCtx ctx = TimingCtx.get("FAIR.SELECTOR.ALL"); + @MetaInfo(unit = "a.u.", description = "random test parameter") + public String testValue = "default value"; + @MetaInfo(description = "requested MIME content type, eg. 'application/binary', 'text/html','text/json', ..") + public MimeType contentType = MimeType.UNKNOWN; + + public TestContext() { + // needs default constructor + } + + @Override + public String toString() { + return "TestContext{ctx=" + ctx + ", testValue='" + testValue + "', contentType=" + contentType.getMediaType() + '}'; + } + } + + @MetaInfo(description = "request type class description", direction = "IN") + public static class RequestDataType { + @MetaInfo(description = " RequestDataType name to show up in the OpenAPI docs") + public String name; + public int counter; + public byte[] payload; + + public RequestDataType() { + // needs default constructor + } + + @Override + public boolean equals(final Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + + final RequestDataType requestDataType = (RequestDataType) o; + + if (!Objects.equals(name, requestDataType.name)) + return false; + return Arrays.equals(payload, requestDataType.payload); + } + + @Override + public int hashCode() { + int result = name != null ? name.hashCode() : 0; + result = 31 * result + Arrays.hashCode(payload); + return result; + } + + @Override + public String toString() { + return "RequestDataType{inputName='" + name + "', counter=" + counter + "', payload=" + ZData.toString(payload) + '}'; + } + } + + @MetaInfo(description = "reply type class description", direction = "OUT") + public static class ReplyDataType { + @MetaInfo(description = "ReplyDataType name to show up in the OpenAPI docs") + public String name; + @MetaInfo(description = "a return value", unit = "A", direction = "OUT", groups = { "A", "B" }) + public int returnValue; + public ReplyDataType() { + // needs default constructor + } + + @Override + public String toString() { + return "ReplyDataType{outputName='" + name + "', returnValue=" + returnValue + '}'; + } + } + + @MetaInfo(description = "test HTML enabled MajordomoWorker implementation") + private static class TestHtmlService extends MajordomoWorker { + private TestHtmlService(ZContext ctx) { + super(ctx, TEST_SERVICE_NAME, MajordomoWorkerTests.TestContext.class, BinaryData.class, BinaryData.class); + setHtmlHandler(new DefaultHtmlHandler<>(this.getClass(), null, map -> { + map.put("extraKey", "extraValue"); + map.put("extraUnkownObject", new NoData()); + })); + super.setHandler((rawCtx, reqCtx, request, repCtx, reply) -> { + reply.data = request.data; + reply.contentType = request.contentType; + reply.resourceName = request.resourceName; + }); + } + } + + static class TestParameterClass { + public String testString = "test1"; + public Object genericObject = new Object(); + public UnknownClass ctx = new UnknownClass(); + } + + static class UnknownClass { + public String name = "UnknownClass"; + } +} diff --git a/server/src/test/java/io/opencmw/server/MmiServiceHelperTests.java b/server/src/test/java/io/opencmw/server/MmiServiceHelperTests.java new file mode 100644 index 00000000..043c33b1 --- /dev/null +++ b/server/src/test/java/io/opencmw/server/MmiServiceHelperTests.java @@ -0,0 +1,151 @@ +package io.opencmw.server; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.zeromq.Utils; +import org.zeromq.util.ZData; + +import io.opencmw.OpenCmwProtocol; +import io.opencmw.rbac.BasicRbacRole; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class MmiServiceHelperTests { + private static final String DEFAULT_REQUEST_MESSAGE = "Hello World!"; + private static final byte[] DEFAULT_REQUEST_MESSAGE_BYTES = DEFAULT_REQUEST_MESSAGE.getBytes(UTF_8); + private MajordomoBroker broker; + private String brokerAddress; + + @BeforeAll + void startBroker() throws IOException { + broker = new MajordomoBroker("TestBroker", "", BasicRbacRole.values()); + // broker.setDaemon(true); // use this if running in another app that controls threads + brokerAddress = broker.bind("mdp://*:" + Utils.findOpenPort()); + broker.start(); + } + + @AfterAll + void stopBroker() { + broker.stopBroker(); + } + + @Test + void basicEchoTest() { + MajordomoTestClientSync clientSession = new MajordomoTestClientSync(brokerAddress, "customClientName"); + + final OpenCmwProtocol.MdpMessage reply = clientSession.send("mmi.echo", DEFAULT_REQUEST_MESSAGE_BYTES); // w/o RBAC + assertNotNull(reply, "reply not being null"); + assertNotNull(reply.data, "user-data not being null"); + assertArrayEquals(DEFAULT_REQUEST_MESSAGE_BYTES, reply.data, "equal data"); + } + + @Test + void basicEchoHtmlTest() { + MajordomoTestClientSync clientSession = new MajordomoTestClientSync(brokerAddress, "customClientName"); + + final OpenCmwProtocol.MdpMessage reply = clientSession.send("mmi.echo?contentType=HTML", DEFAULT_REQUEST_MESSAGE_BYTES); // w/o RBAC + assertNotNull(reply, "reply not being null"); + assertNotNull(reply.data, "user-data not being null"); + assertArrayEquals(DEFAULT_REQUEST_MESSAGE_BYTES, reply.data, "equal data"); + } + + @Test + void basicDnsTest() { + MajordomoTestClientSync clientSession = new MajordomoTestClientSync(brokerAddress, "customClientName"); + + final OpenCmwProtocol.MdpMessage reply = clientSession.send("mmi.dns", DEFAULT_REQUEST_MESSAGE_BYTES); // w/o RBAC + assertNotNull(reply, "reply not being null"); + assertNotNull(reply.data, "user-data not being null"); + assertTrue(ZData.toString(reply.data).startsWith("[TestBroker: mdp://")); + } + + @Test + void basicDnsHtmlTest() { + MajordomoTestClientSync clientSession = new MajordomoTestClientSync(brokerAddress, "customClientName"); + + final OpenCmwProtocol.MdpMessage reply = clientSession.send("mmi.dns?contentType=HTML", DEFAULT_REQUEST_MESSAGE_BYTES); // w/o RBAC + assertNotNull(reply, "reply not being null"); + assertNotNull(reply.data, "user-data not being null"); + assertTrue(ZData.toString(reply.data).startsWith("[TestBroker: mdp://")); + } + + @Test + void basicSerivceTest() { + MajordomoTestClientSync clientSession = new MajordomoTestClientSync(brokerAddress, "customClientName"); + + { + final OpenCmwProtocol.MdpMessage reply = clientSession.send("mmi.service", "".getBytes(UTF_8)); // w/o RBAC + assertNotNull(reply, "reply not being null"); + assertNotNull(reply.data, "user-data not being null"); + assertTrue(ZData.toString(reply.data).startsWith("mmi.dns,mmi.echo,mmi.openapi,mmi.service")); + } + + { + final OpenCmwProtocol.MdpMessage reply = clientSession.send("mmi.service", "mmi.service".getBytes(UTF_8)); // w/o RBAC + assertNotNull(reply, "reply not being null"); + assertNotNull(reply.data, "user-data not being null"); + assertTrue(ZData.toString(reply.data).startsWith("200")); + } + + { + final OpenCmwProtocol.MdpMessage reply = clientSession.send("mmi.service", "doesNotExist".getBytes(UTF_8)); // w/o RBAC + assertNotNull(reply, "reply not being null"); + assertNotNull(reply.data, "user-data not being null"); + assertTrue(ZData.toString(reply.data).startsWith("400")); + } + } + + @Test + void basicServiceHtmlTest() { + MajordomoTestClientSync clientSession = new MajordomoTestClientSync(brokerAddress, "customClientName"); + + { + final OpenCmwProtocol.MdpMessage reply = clientSession.send("mmi.service?contentType=HTML", "".getBytes(UTF_8)); // w/o RBAC + assertNotNull(reply, "reply not being null"); + assertNotNull(reply.data, "user-data not being null"); + assertTrue(ZData.toString(reply.data).startsWith("mmi.dns,mmi.echo,mmi.openapi,mmi.service")); + } + + { + final OpenCmwProtocol.MdpMessage reply = clientSession.send("mmi.service?contentType=HTML", "mmi.service".getBytes(UTF_8)); // w/o RBAC + assertNotNull(reply, "reply not being null"); + assertNotNull(reply.data, "user-data not being null"); + assertTrue(ZData.toString(reply.data).startsWith("200")); + } + + { + final OpenCmwProtocol.MdpMessage reply = clientSession.send("mmi.service?contentType=HTML", "doesNotExist".getBytes(UTF_8)); // w/o RBAC + assertNotNull(reply, "reply not being null"); + assertNotNull(reply.data, "user-data not being null"); + assertTrue(ZData.toString(reply.data).startsWith("400")); + } + } + + @Test + void basicOpenAPITest() { + MajordomoTestClientSync clientSession = new MajordomoTestClientSync(brokerAddress, "customClientName"); + + final OpenCmwProtocol.MdpMessage reply = clientSession.send("mmi.openapi", "mmi.echo".getBytes(UTF_8)); // w/o RBAC + assertNotNull(reply, "reply not being null"); + assertNotNull(reply.data, "user-data not being null"); + assertTrue(ZData.toString(reply.data).startsWith("io.opencmw.server.MmiServiceHelper$MmiEcho")); + } + + @Test + void basicOpenAPIExceptionTest() { + MajordomoTestClientSync clientSession = new MajordomoTestClientSync(brokerAddress, "customClientName"); + + final OpenCmwProtocol.MdpMessage reply = clientSession.send("mmi.openapi?contentType=HTML", "".getBytes(UTF_8)); // w/o RBAC + assertNotNull(reply, "reply not being null"); + assertNotNull(reply.data, "user-data not being null"); + assertNotNull(reply.errors); + assertFalse(reply.errors.isBlank()); + } +} diff --git a/server/src/test/resources/simplelogger.properties b/server/src/test/resources/simplelogger.properties new file mode 100644 index 00000000..4c11c945 --- /dev/null +++ b/server/src/test/resources/simplelogger.properties @@ -0,0 +1,50 @@ +# SLF4J's SimpleLogger configuration file +# Simple implementation of Logger that sends all enabled log messages, for all defined loggers, to System.err. + +# Default logging detail level for all instances of SimpleLogger. +# Must be one of ("trace", "debug", "info", "warn", or "error"). +# If not specified, defaults to "info". +org.slf4j.simpleLogger.defaultLogLevel=debug + +# The output target which can be the path to a file, or the special values "System.out" and "System.err". +# Default is "System.err". +org.slf4j.simpleLogger.logFile=System.out + +# If the output target is set to "System.out" or "System.err" (see preceding entry), by default, +# logs will be output to the latest value referenced by System.out/err variables. +# By setting this parameter to true, the output stream will be cached, i.e. assigned once at initialization +# time and re-used independently of the current value referenced by System.out/err. +org.slf4j.simpleLogger.cacheOutputStream=true + +# Logging detail level for a SimpleLogger instance named "a.b.c". Right-side value must be one of +# "trace", "debug", "info", "warn", "error" or "off". When a SimpleLogger named "a.b.c" is initialized, +# its level is assigned from this property. If unspecified, the level of nearest parent logger will be used, +# and if none is set, then the value specified by org.slf4j.simpleLogger.defaultLogLevel will be used. +org.slf4j.simpleLogger.log.io.opencmw.*=debug + +# Logging detail level for a SimpleLogger instance named "xxxxx". +# Must be one of ("trace", "debug", "info", "warn", or "error"). +# If not specified, the default logging detail level is used. +#org.slf4j.simpleLogger.log.xxxxx= + +# Set to true if you want the current date and time to be included in output messages. +# Default is false, and will output the number of milliseconds elapsed since startup. +#org.slf4j.simpleLogger.showDateTime=false + +# The date and time format to be used in the output messages. +# The pattern describing the date and time format is the same that is used in java.text.SimpleDateFormat. +# If the format is not specified or is invalid, the default format is used. +# The default format is yyyy-MM-dd HH:mm:ss:SSS Z. +#org.slf4j.simpleLogger.dateTimeFormat=yyyy-MM-dd HH:mm:ss:SSS Z + +# Set to true if you want to output the current thread name. +# Defaults to true. +# org.slf4j.simpleLogger.showThreadName=false + +# Set to true if you want the Logger instance name to be included in output messages. +# Defaults to true. +#org.slf4j.simpleLogger.showLogName=true + +# Set to true if you want the last component of the name to be included in output messages. +# Defaults to false. +# org.slf4j.simpleLogger.showShortLogName=true \ No newline at end of file diff --git a/setupGitHooks.sh b/setupGitHooks.sh new file mode 100755 index 00000000..afa619a7 --- /dev/null +++ b/setupGitHooks.sh @@ -0,0 +1,4 @@ +#/bin/bash +# git config core.hooksPath $PWD/config/hooks +# safe fall-back for older git versions +ln -s -r ./config/hooks/pre* -t ./.git/hooks/

+ * N.B. Multi-dimensional arrays are handled through one-dimensional striding arrays with the additional + * infos on number of dimensions and size for each individual dimension. + * + * @author rstein + * @see BinarySerialiser + * @see striding arrays + */ +public enum DataType { + // @formatter:off + // clang-format off + // start marker + START_MARKER(0, "start_marker", "", 0, Cat.SINGLE_VALUE), + // primitive types + BOOL(1, "bool", "boolean", 1, Cat.SINGLE_VALUE, boolean.class, Boolean.class), + BYTE(2, "byte", "byte", 1, Cat.SINGLE_VALUE, byte.class, Byte.class), + SHORT(3, "short", "short", 2, Cat.SINGLE_VALUE, short.class, Short.class), + INT(4, "int", "int", 4, Cat.SINGLE_VALUE, int.class, Integer.class), + LONG(5, "long", "long", 8, Cat.SINGLE_VALUE, long.class, Long.class), + FLOAT(6, "float", "float", 4, Cat.SINGLE_VALUE, float.class, Float.class), + DOUBLE(7, "double", "double", 8, Cat.SINGLE_VALUE, double.class, Double.class), + CHAR(8, "char", "char", 2, Cat.SINGLE_VALUE, char.class, Character.class), + STRING(9, "string", "java.lang.String", 1, Cat.ARRAY, String.class, String.class), + + // array of primitive types + BOOL_ARRAY(101, "bool_array", "[Z", 1, Cat.ARRAY, boolean[].class, Boolean[].class, MultiArrayBoolean.class), + BYTE_ARRAY(102, "byte_array", "[B", 1, Cat.ARRAY, byte[].class, Byte[].class, MultiArrayByte.class), + SHORT_ARRAY(103, "short_array", "[S", 2, Cat.ARRAY, short[].class, Short[].class, MultiArrayShort.class), + INT_ARRAY(104, "int_array", "[I", 4, Cat.ARRAY, int[].class, Integer[].class, MultiArrayInt.class), + LONG_ARRAY(105, "long_array", "[J", 8, Cat.ARRAY, long[].class, Long[].class, MultiArrayLong.class), + FLOAT_ARRAY(106, "float_array", "[F", 4, Cat.ARRAY, float[].class, Float[].class, MultiArrayFloat.class), + DOUBLE_ARRAY(107, "double_array", "[D", 8, Cat.ARRAY, double[].class, Double[].class, MultiArrayDouble.class), + CHAR_ARRAY(108, "char_array", "[C", 2, Cat.ARRAY, char[].class, Character[].class, MultiArrayChar.class), + STRING_ARRAY(109, "string_array", "[java.lang.String", 1, Cat.ARRAY, String[].class, String[].class, MultiArrayObject.class), + + // complex objects + ENUM(201, "enum", "java.lang.Enum", 4, Cat.ARRAY, Enum.class), + LIST(202, "list", "", 1, Cat.ARRAY, List.class), + MAP(203, "map", "", 1, Cat.ARRAY, Map.class), + QUEUE(204, "queue", "", 1, Cat.ARRAY, Queue.class), + SET(205, "set", "", 1, Cat.ARRAY, Set.class), + COLLECTION(200, "collection", "", 1, Cat.ARRAY, Collection.class), + + /** default for any other complex or object-type custom data structure, usually followed/refined + * by an additional user-provided custom type ID */ + OTHER(0xFD, "other", "", 1, Cat.COMPLEX_OBJECT, Object.class), + // end marker + END_MARKER(0xFE, "end_marker", "", 0, Cat.SINGLE_VALUE); + // clang-format on + // @formatter:on + + private final int uniqueID; + private final int primitiveSize; + private final String stringValue; + private final String javaName; + private final List> classTypes; + private final boolean scalar; + private final boolean array; + private final boolean object; + + DataType(final int uniqueID, final String stringValue, final String javaName, final int primitiveSize, + final Cat type, final Class... classType) { + this.uniqueID = uniqueID; + this.stringValue = stringValue; + this.javaName = javaName; + this.primitiveSize = primitiveSize; + classTypes = Arrays.asList(classType); + scalar = type.equals(Cat.SINGLE_VALUE); + array = type.equals(Cat.ARRAY); + object = type.equals(Cat.COMPLEX_OBJECT); + } + + /** + * Returns the uniqueID representation of the data type. + * + * @return the uniqueID representation + */ + public int getID() { + return uniqueID; + } + + /** + * Returns the string representation of the data type. + * + * @return the string representation + */ + public String getAsString() { + return stringValue; + } + + /** + * Returns the corresponding java class type matching the given data type + * + * @return the matching java class type + */ + public List> getClassTypes() { + return classTypes; + } + + /** + * Returns the string representation of the java class type. + * + * @return the string representation of the class + */ + public String getJavaName() { + return javaName; + } + + public int getPrimitiveSize() { + return primitiveSize; + } + + public boolean isArray() { + return array; + } + + public boolean isObject() { + return object; + } + + public boolean isScalar() { + return scalar; + } + + /** + * Returns the data type matching the given uniqueID representation, if any. + * + * @param value the value to be searched + * @return the matching data type + */ + public static DataType fromByte(final byte value) { + final int unsignedByte = (value & 0xFF); + for (final DataType type : DataType.values()) { + if (type.uniqueID == unsignedByte) { + return type; + } + } + throw new IllegalArgumentException("byte entry type is not supported: " + value); + } + + /** + * Returns the data type matching the given java class type, if any. + * + * @param classType the value to be searched + * @return the matching data type + */ + public static DataType fromClassType(final Class classType) { + for (final DataType dataType : DataType.values()) { + for (Class type : dataType.getClassTypes()) { + if (type.isAssignableFrom(classType)) { + return dataType; + } + } + } + + throw new IllegalArgumentException("data type not implemented " + classType.getSimpleName()); + } + + /** + * Returns the data type matching the given java string representation, if any. + * + * @param str the string to be searched + * @return the matching data type + */ + public static DataType fromJavaTypeString(final String str) { + for (final DataType type : DataType.values()) { + if (type.stringValue.equals(str)) { + return type; + } + } + throw new IllegalArgumentException("java string entry type is not supported: '" + str + "'"); + } + + /** + * Returns the data type matching the given string representation, if any. + * + * @param str the string to be searched + * @return the matching data type + */ + public static DataType fromString(final String str) { + for (final DataType type : DataType.values()) { + if (type.stringValue.equals(str)) { + return type; + } + } + throw new IllegalArgumentException("string entry type is not supported: '" + str + "'"); + } +} diff --git a/serialiser/src/main/java/io/opencmw/serialiser/FieldDescription.java b/serialiser/src/main/java/io/opencmw/serialiser/FieldDescription.java new file mode 100644 index 00000000..07a3985f --- /dev/null +++ b/serialiser/src/main/java/io/opencmw/serialiser/FieldDescription.java @@ -0,0 +1,94 @@ +package io.opencmw.serialiser; + +import java.lang.reflect.Type; +import java.util.List; + +import io.opencmw.serialiser.utils.ClassUtils; + +public interface FieldDescription { + boolean isAnnotationPresent(); + + FieldDescription findChildField(String fieldName); + + FieldDescription findChildField(final int fieldNameHashCode, final String fieldName); + + List getChildren(); + + /** + * @return the data size in bytes stored after the field header + */ + int getDataSize(); + + /** + * @return the offset in bytes from the field start position until the first data object can be read. + * (N.B. equals to 'getFieldstart() + getDataOffset()', the data ends at 'getDataStartOffset() + getDataSize()' + */ + int getDataStartOffset(); + + /** + * @return the buffer byte position from where the first data object can be read + */ + int getDataStartPosition(); + + /** + * @return the stored data type, see {@link DataType} for details + */ + DataType getDataType(); + + /** + * @return optional meta data tag describing the purpose of this data field (N.B. can be empty String) + */ + String getFieldDescription(); + + /** + * Return optional meta data tag describing the 'direction' of this data field. + * The information encodes the source servicedevelopers intend to the receiving user whether the field can be, for example, + * modified (get/set), set-only, or read-only, or attach any other similar information. Encoding/interpretation is + * left ad-lib to the source service developer. + * + * @return optional meta data (N.B. can be empty String). + */ + String getFieldDirection(); + + /** + * @return optional meta data describing the group/set this data field belongs to (N.B. empty String corresponds to 'all') + */ + List getFieldGroups(); + + /** + * @return the data field's name + */ + String getFieldName(); + + /** + * @return the data field name's hashcode (N.B. used for faster identification of the field) + */ + int getFieldNameHashCode(); + + /** + * + * @return buffer position in byte where the data field header starts + */ + int getFieldStart(); + + /** + * @return optional meta data tag describing the field's SI unit or similar (N.B. can be empty String) + */ + String getFieldUnit(); + + /** + * @return for a hierarchical/nested data structure refers to the parent this field belongs to (N.B. can be null if there isn't a parent, e.g. for a root element) + */ + FieldDescription getParent(); + + Type getType(); + + /** + * Prints the class field structure to the logging output for diagnostics purposes starting from this element as a root. + + * N.B. regarding formatting/parsing + * The indentation depth is controlled via {@link ClassUtils#setIndentationNumberOfSpace}. + * The max recursion depth during the class structure parsing is controlled via {@link ClassUtils#setMaxRecursionDepth}. + */ + void printFieldStructure(); +} diff --git a/serialiser/src/main/java/io/opencmw/serialiser/FieldSerialiser.java b/serialiser/src/main/java/io/opencmw/serialiser/FieldSerialiser.java new file mode 100644 index 00000000..9d40654b --- /dev/null +++ b/serialiser/src/main/java/io/opencmw/serialiser/FieldSerialiser.java @@ -0,0 +1,172 @@ +package io.opencmw.serialiser; + +import java.lang.reflect.Type; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.opencmw.serialiser.spi.ClassFieldDescription; + +/** + * default field serialiser implementation. The user needs to provide the reader and writer consumer lambdas to connect + * to the given serialiser back-end implementation. + * @param function return type + * @author rstein + */ +public class FieldSerialiser { + private static final Logger LOGGER = LoggerFactory.getLogger(FieldSerialiser.class); + private final Class classPrototype; + private final List classGenericArguments; + private final String name; + private final String canonicalName; + private final String simpleName; + private final int cachedHashCode; + protected TriConsumer readerFunction; + protected TriConsumer writerFunction; + protected TriFunction returnFunction; + + /** + * + * @param reader consumer executed when reading from the back-end serialiser implementation + * @param returnFunction function that is being executed for returning a new object to the back-end serialiser implementation + * @param writer consumer executed when writing to the back-end serialiser implementation + * @param classPrototype applicable class/interface prototype reference for which the consumers are applicable (e.g. + * example 1: 'List.class' for List<String> or example 2: 'Map.class' for Map<Integer, String>) + * @param classGenericArguments applicable generics definition (e.g. 'String.class' for List<String> or + * 'Integer.class, String.class' resp.) + */ + public FieldSerialiser(final TriConsumer reader, final TriFunction returnFunction, final TriConsumer writer, final Class classPrototype, Class... classGenericArguments) { + if ((reader == null || returnFunction == null || writer == null)) { + LOGGER.atWarn().addArgument(reader).addArgument(writer).log("caution: reader {}, return {} or writer {} is null"); + } + if (classPrototype == null) { + throw new IllegalArgumentException("classPrototype must not be null"); + } + this.readerFunction = reader; + this.returnFunction = returnFunction; + this.writerFunction = writer; + this.classPrototype = classPrototype; + this.classGenericArguments = Arrays.asList(classGenericArguments); + cachedHashCode = IoClassSerialiser.computeHashCode(classPrototype, this.classGenericArguments); + + String genericFieldString = this.classGenericArguments.isEmpty() ? "" : this.classGenericArguments.stream().map(Type::getTypeName).collect(Collectors.joining(", ", "<", ">")); + + canonicalName = classPrototype.getCanonicalName() + genericFieldString; + simpleName = classPrototype.getSimpleName() + IoClassSerialiser.getGenericFieldSimpleTypeString(this.classGenericArguments); + name = "Serialiser for " + canonicalName; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + return this.hashCode() == obj.hashCode(); + } + + /** + * + * @return canonical name of the class/interface description + */ + public String getCanonicalName() { + return canonicalName; + } + + /** + * + * @return class reference + */ + public Class getClassPrototype() { + return classPrototype; + } + + /** + * + * @return class reference to generics arguments + */ + public List getGenericsPrototypes() { + return classGenericArguments; + } + + /** + * + * @return consumer that is being executed for reading from the back-end serialiser implementation + */ + public TriConsumer getReaderFunction() { + return readerFunction; + } + + /** + * + * @return simple name name of the class/interface description + */ + public String getSimpleName() { + return simpleName; + } + + /** + * + * @return consumer that is being executed for writing to the back-end serialiser implementation + */ + public TriConsumer getWriterFunction() { + return writerFunction; + } + + /** + * + * @return function that is being executed for returning a new object to the back-end serialiser implementation + */ + public TriFunction getReturnObjectFunction() { + return returnFunction; + } + + @Override + public int hashCode() { + return cachedHashCode; + } + + @Override + public String toString() { + return name; + } + + /** + * used as lambda expression for user-level code to read/write data into the given serialiser back-end implementation + * + * @author rstein + */ + public interface TriConsumer { + /** + * Performs this operation on the given arguments. + * + * @param ioSerialiser the reference to the calling IoSerialiser + * @param rootObj the specific root object reference the given field is part of + * @param field the description for the given class member, if null then rootObj is written/read directly + */ + void accept(IoSerialiser ioSerialiser, Object rootObj, ClassFieldDescription field); + } + + /** + * used as lambda expression for user-level code to return new object data (read-case) from the given serialiser back-end implementation + * + * @author rstein + * @param generic return type + */ + public interface TriFunction { + /** + * Performs this operation on the given arguments. + * + * @param ioSerialiser the reference to the calling IoSerialiser + * @param rootObj the specific root object reference the given field is part of + * @param field the description for the given class member, if null then rootObj is written/read directly + * @return The value of the field which is either taken from rootObj if present or compatible or newly allocated otherwise + */ + R apply(IoSerialiser ioSerialiser, Object rootObj, ClassFieldDescription field); + } +} diff --git a/serialiser/src/main/java/io/opencmw/serialiser/IoBuffer.java b/serialiser/src/main/java/io/opencmw/serialiser/IoBuffer.java new file mode 100644 index 00000000..934c8a80 --- /dev/null +++ b/serialiser/src/main/java/io/opencmw/serialiser/IoBuffer.java @@ -0,0 +1,209 @@ +package io.opencmw.serialiser; + +/** + * Interface definition in line with the jdk Buffer abstract class. This definition is needed to allow for redirect or + * different buffer implementations. + * + * @author rstein + */ +@SuppressWarnings({ "PMD.ExcessivePublicCount", "PMD.TooManyMethods", "PMD.AvoidUsingShortType" }) // NOPMD - these are short-hand convenience methods +public interface IoBuffer extends IoBufferHeader { + /** + * @return underlying raw byte[] array buffer (if available) + */ + byte[] elements(); + + boolean getBoolean(int position); + + boolean getBoolean(); // NOPMD - nomen est omen + + default boolean[] getBooleanArray() { + return getBooleanArray(null, 0); + } + + default boolean[] getBooleanArray(final boolean[] dst) { + return getBooleanArray(dst, dst == null ? -1 : dst.length); + } + + boolean[] getBooleanArray(final boolean[] dst, final int length); + + byte getByte(int position); + + byte getByte(); + + default byte[] getByteArray() { + return getByteArray(null, 0); + } + + default byte[] getByteArray(final byte[] dst) { + return getByteArray(dst, dst == null ? -1 : dst.length); + } + + byte[] getByteArray(final byte[] dst, final int length); + + char getChar(int position); + + char getChar(); + + default char[] getCharArray() { + return getCharArray(null, 0); + } + + default char[] getCharArray(final char[] dst) { + return getCharArray(dst, dst == null ? -1 : dst.length); + } + + char[] getCharArray(final char[] dst, final int length); + + double getDouble(int position); + + double getDouble(); + + default double[] getDoubleArray() { + return getDoubleArray(null, 0); + } + + default double[] getDoubleArray(final double[] dst) { + return getDoubleArray(dst, dst == null ? -1 : dst.length); + } + + double[] getDoubleArray(final double[] dst, final int length); + + float getFloat(int position); + + float getFloat(); + + default float[] getFloatArray() { + return getFloatArray(null, 0); + } + + default float[] getFloatArray(final float[] dst) { + return getFloatArray(dst, dst == null ? -1 : dst.length); + } + + float[] getFloatArray(final float[] dst, final int length); + + int getInt(int position); + + int getInt(); + + default int[] getIntArray() { + return getIntArray(null, 0); + } + + default int[] getIntArray(final int[] dst) { + return getIntArray(dst, dst == null ? -1 : dst.length); + } + + int[] getIntArray(final int[] dst, final int length); + + long getLong(int position); + + long getLong(); + + default long[] getLongArray() { + return getLongArray(null, 0); + } + + default long[] getLongArray(final long[] dst) { + return getLongArray(dst, dst == null ? -1 : dst.length); + } + + long[] getLongArray(final long[] dst, final int length); + + short getShort(int position); + + short getShort(); + + default short[] getShortArray() { // NOPMD by rstein + return getShortArray(null, 0); + } + + default short[] getShortArray(final short[] dst) { // NOPMD by rstein + return getShortArray(dst, dst == null ? -1 : dst.length); + } + + short[] getShortArray(final short[] dst, final int length); + + String getString(int position); + + String getString(); + + default String[] getStringArray() { + return getStringArray(null, 0); + } + + default String[] getStringArray(final String[] dst) { + return getStringArray(dst, dst == null ? -1 : dst.length); + } + + String[] getStringArray(final String[] dst, final int length); + + String getStringISO8859(); + + /** + * @return {@code true} the ISO-8859-1 character encoding is being enforced for data fields (better performance), otherwise UTF-8 is being used (more generic encoding) + */ + boolean isEnforceSimpleStringEncoding(); + + /** + * @param state {@code true} the ISO-8859-1 character encoding is being enforced for data fields (better performance), otherwise UTF-8 is being used (more generic encoding) + */ + void setEnforceSimpleStringEncoding(boolean state); + + void putBoolean(int position, boolean value); + + void putBoolean(boolean value); + + void putBooleanArray(final boolean[] src, final int n); + + void putByte(int position, byte value); + + void putByte(final byte b); + + void putByteArray(final byte[] src, final int n); + + void putChar(int position, char value); + + void putChar(char value); + + void putCharArray(final char[] src, final int n); + + void putDouble(int position, double value); + + void putDouble(double value); + + void putDoubleArray(final double[] src, final int n); + + void putFloat(int position, float value); + + void putFloat(float value); + + void putFloatArray(final float[] src, final int n); + + void putInt(int position, int value); + + void putInt(int value); + + void putIntArray(final int[] src, final int n); + + void putLong(int position, long value); + + void putLong(long value); + + void putLongArray(final long[] src, final int n); + + void putShort(int position, short value); + + void putShort(short value); + + void putShortArray(final short[] src, final int n); + + void putString(int position, String value); + + void putString(String string); + + void putStringArray(final String[] src, final int n); + + void putStringISO8859(String string); +} diff --git a/serialiser/src/main/java/io/opencmw/serialiser/IoBufferHeader.java b/serialiser/src/main/java/io/opencmw/serialiser/IoBufferHeader.java new file mode 100644 index 00000000..b7ab5ca5 --- /dev/null +++ b/serialiser/src/main/java/io/opencmw/serialiser/IoBufferHeader.java @@ -0,0 +1,160 @@ +package io.opencmw.serialiser; + +import java.util.concurrent.locks.ReadWriteLock; + +/** + * Interface definition in line with the jdk Buffer abstract class. This definition is needed to allow to redirect and + * allow for different buffer implementations. + *