├── .gitignore ├── LICENSE.txt ├── README.adoc ├── build.gradle ├── circuit-breaker ├── README.adoc ├── build.gradle └── src │ ├── main │ ├── java │ │ └── org │ │ │ └── springframework │ │ │ └── integration │ │ │ └── microservices │ │ │ └── circuitbreaker │ │ │ └── CircuitBreakerApplication.java │ └── resources │ │ └── application.properties │ └── test │ └── java │ └── org │ └── springframework │ └── integration │ └── microservices │ └── circuitbreaker │ └── CircuitBreakerApplicationTests.java ├── distributed-tracing ├── README.adoc ├── build.gradle ├── src │ ├── main │ │ ├── java │ │ │ └── org │ │ │ │ └── springframework │ │ │ │ └── integration │ │ │ │ └── microservices │ │ │ │ └── distributedtracing │ │ │ │ └── DistributedTracingApplication.java │ │ └── resources │ │ │ └── application.properties │ └── test │ │ ├── java │ │ └── org │ │ │ └── springframework │ │ │ └── integration │ │ │ └── microservices │ │ │ └── distributedtracing │ │ │ └── DistributedTracingApplicationTests.java │ │ └── resources │ │ └── schema.sql └── trace-sample.jpg ├── framer ├── README.adoc ├── build.gradle └── src │ ├── main │ ├── java │ │ └── org │ │ │ └── springframework │ │ │ └── integration │ │ │ └── microservices │ │ │ └── framer │ │ │ └── FramerApplication.java │ └── resources │ │ └── application.properties │ └── test │ └── java │ └── org │ └── springframework │ └── integration │ └── microservices │ └── framer │ └── FramerApplicationTests.java ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── mrpc ├── README.adoc ├── build.gradle └── src │ ├── main │ ├── java │ │ └── org │ │ │ └── springframework │ │ │ └── integration │ │ │ └── microservices │ │ │ └── mrpc │ │ │ └── MrpcApplication.java │ └── resources │ │ └── application.properties │ └── test │ ├── java │ └── org │ │ └── springframework │ │ └── integration │ │ └── microservices │ │ └── mrpc │ │ └── MrpcApplicationTests.java │ └── resources │ └── application-uppercase.properties ├── normalizer ├── README.adoc ├── build.gradle └── src │ ├── main │ ├── java │ │ └── org │ │ │ └── springframework │ │ │ └── integration │ │ │ └── microservices │ │ │ └── normalizer │ │ │ ├── CreditCardTransaction.java │ │ │ └── NormalizerApplication.java │ └── resources │ │ └── application.properties │ └── test │ └── java │ └── org │ └── springframework │ └── integration │ └── microservices │ └── normalizer │ └── NormalizerApplicationTests.java ├── outbox ├── README.adoc ├── build.gradle └── src │ ├── main │ ├── java │ │ └── org │ │ │ └── springframework │ │ │ └── integration │ │ │ └── microservices │ │ │ └── outbox │ │ │ ├── OutboxApplication.java │ │ │ └── ShoppingOrder.java │ └── resources │ │ └── application.properties │ └── test │ └── java │ └── org │ └── springframework │ └── integration │ └── microservices │ └── outbox │ └── OutboxApplicationTests.java └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | *.ipr 3 | *.iws 4 | *.msg 5 | *.sw? 6 | */src/main/java/META-INF 7 | .classpath 8 | .DS_Store 9 | .gradle 10 | .idea 11 | .pmd 12 | .project 13 | .settings 14 | bin 15 | build 16 | out 17 | target 18 | .springBeans 19 | .sts4-cache 20 | /normalizer/cardTransactionInput/ 21 | /normalizer/cardTransactionOutput/ 22 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | https://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | https://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.adoc: -------------------------------------------------------------------------------- 1 | = Microservices Patterns with Spring Integration 2 | 3 | This project serves as a reference implementation of various Microservices Patterns described on https://microservices.io/index.html[Microservice Architecture] with https://spring.io/projects/spring-integration[Spring Integration Framework] and https://spring.io/projects/spring-boot[Spring Boot]. 4 | Since Spring Integration is fully based on messaging, which can be distributed, we also include some https://iwringer.wordpress.com/2015/08/03/patterns-for-streaming-realtime-analytics[Event Streaming Patterns] implementations together with https://spring.io/projects/spring-cloud-stream[Spring Cloud Stream Framework]. 5 | 6 | Every single module of this project is fully independent and ready to use Spring Boot application for particular pattern: they are just generated via https://start.spring.io[start.spring.io]. 7 | However, the `$springBootVersion` variable might be changes since it is pulled from the `gradle.properties` of the root project. 8 | 9 | You can run `./gradlew test` (or `build`) for the whole project or just chose specific module to run. 10 | The tests in modules verify the pattern in action and its implementation with Spring Integration. 11 | 12 | See specific module for more details of the pattern (or recipe) it implements and how: 13 | 14 | * link:circuit-breaker[`circuit-breaker`] - circuit breaker pattern implementation 15 | * link:distributed-tracing[`distributed-tracing`] - distributed tracing pattern implementation 16 | * link:framer[`Framer`] - multi-window distribution 17 | * link:mrpc[`mRPC`] - request-reply via Spring Cloud Stream and RPI pattern 18 | * link:normalizer[`Normalizer`] - an EIP Normalizer implementation 19 | * link:outbox[`Outbox`] - transactional outbox pattern implementation 20 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | description = 'Microservices Patterns with Spring Integration' -------------------------------------------------------------------------------- /circuit-breaker/README.adoc: -------------------------------------------------------------------------------- 1 | = Circuit Breaker 2 | 3 | The Circuit Breaker is not a new pattern and was not invented together with Microservices principle. 4 | Martin Fowler had explained this feature https://martinfowler.com/bliki/CircuitBreaker.html[years ago], but either way see more details on our https://microservices.io/patterns/reliability/circuit-breaker.html[Microservice Architecture] foundation. 5 | Spring Integration (together with Spring Boot auto-configuration), being a good framework for Microservices development, also provides its own simple https://docs.spring.io/spring-integration/docs/current/reference/html/messaging-endpoints.html#circuit-breaker-advice[`RequestHandlerCircuitBreakerAdvice`] implementation. 6 | Yes, there is also https://spring.io/projects/spring-cloud-circuitbreaker[Spring Cloud Circuit Breaker] project, which provides some primitives and API to configure and call services withing Circuit Breaker callbacks, but we really will concentrate in this demo exactly on Spring Integration native approach since this is really a goal of this project anyway. 7 | 8 | So, in two words: Circuit Breaker wraps a service call, breaks the circuit when number of attempts is exhausted, throwing specific "open" exception, then attempts again after `halfOpen` timeout. 9 | All descriptions for this pattern suggest some `fallback` option to be called in case of `open` state. 10 | Since Spring Integration operates more on the higher level than just method calls, it throws only a specific `CircuitBreakerOpenException` leaving the freedom to decide what to do up to target project. 11 | One of the option is to combine this advice with some other, e.g. with a `RequestHandlerRetryAdvice` like we do in this demo. 12 | The point of using this advice together with a `RequestHandlerCircuitBreakerAdvice` is to be able to retry automatically and perform a fallback when `CircuitBreakerOpenException` happens. 13 | 14 | This demo is a bit tricky and contains both microservices parts for simplicity to test in isolation. 15 | The `CircuitBreakerApplication.RemoteServiceConfiguration` exposes a REST service based on Spring Integration flow definition which is not started automatically to the `RequestHandlerCircuitBreakerAdvice` to do its logic during testing. 16 | This service just replies with `Hello` to the value of the `name` HTTP request parameter. 17 | The `CircuitBreaker` class represents the pattern in action via Spring Integration primitives and configuration. 18 | See its setters for more information. 19 | For demonstration purposes of the `CircuitBreakerOpenException` fallback option from the `RequestHandlerRetryAdvice`, the last one is configured with an `AlwaysRetryPolicy` and never retry on `CircuitBreakerOpenException`. 20 | The service to call is of course a `MessageHandler` for HTTP clients. 21 | The URL for this client is based on a randomly selected port for Tomcat when JUnit test is started. 22 | See `rest-service.url` property in the `resource/application.properties`. 23 | The `@ServiceActivator` POJO method on a `recoveryChannel` is for demonstrating a fallback option of the Circuit Breaker which is initiated by the framework via `RequestHandlerRetryAdvice.recoveryCallback`. 24 | It returns some stub payload and shows how to copy request message headers to preserve at least an important `replyChannel` header which is needed for initial request-reply call into this application. 25 | 26 | The unit test in this project starts an embedded Tomcat with a random port populated into a `local.server.port` configuration property. 27 | During test execution, some DEBUG information is printed from the `RetryTemplate` and `RequestHandlerCircuitBreakerAdvice` which may give some understanding in the logic of Circuit Breaker pattern implementation with Spring Integration. 28 | 29 | == Spring Boot and Spring Integration resources 30 | 31 | * https://docs.gradle.org[Official Gradle documentation] 32 | * https://docs.spring.io/spring-boot/docs/current/gradle-plugin/reference/html/[Spring Boot Gradle Plugin Reference Guide] 33 | * https://docs.spring.io/spring-boot/docs/current/gradle-plugin/reference/html/#build-image[Create an OCI image] 34 | * https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#messaging.spring-integration[Spring Integration] 35 | * https://docs.spring.io/spring-integration/reference/html/dsl.html[Spring Integration Java DSL Reference Guide] 36 | * https://docs.spring.io/spring-integration/reference/html/http.html[Spring Integration HTTP Module Reference Guide] 37 | 38 | 39 | -------------------------------------------------------------------------------- /circuit-breaker/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | repositories { 3 | gradlePluginPortal() 4 | } 5 | dependencies { 6 | classpath "org.springframework.boot:spring-boot-gradle-plugin:$springBootVersion" 7 | } 8 | } 9 | 10 | plugins { 11 | id 'java' 12 | id 'io.spring.dependency-management' version '1.1.0' 13 | } 14 | 15 | apply plugin: 'org.springframework.boot' 16 | 17 | group = 'org.springframework.integration.microservices' 18 | version = '0.0.1-SNAPSHOT' 19 | sourceCompatibility = '17' 20 | 21 | repositories { 22 | mavenCentral() 23 | } 24 | 25 | dependencies { 26 | implementation 'org.springframework.boot:spring-boot-starter-integration' 27 | implementation 'org.springframework.boot:spring-boot-starter-web' 28 | implementation 'org.springframework.integration:spring-integration-http' 29 | 30 | testImplementation 'org.springframework.boot:spring-boot-starter-test' 31 | testImplementation 'org.springframework.integration:spring-integration-test' 32 | } 33 | 34 | tasks.named('test') { 35 | useJUnitPlatform() 36 | } 37 | -------------------------------------------------------------------------------- /circuit-breaker/src/main/java/org/springframework/integration/microservices/circuitbreaker/CircuitBreakerApplication.java: -------------------------------------------------------------------------------- 1 | package org.springframework.integration.microservices.circuitbreaker; 2 | 3 | import org.springframework.beans.factory.annotation.Qualifier; 4 | import org.springframework.boot.SpringApplication; 5 | import org.springframework.boot.autoconfigure.SpringBootApplication; 6 | import org.springframework.context.annotation.Bean; 7 | import org.springframework.context.annotation.Configuration; 8 | import org.springframework.core.env.Environment; 9 | import org.springframework.http.HttpMethod; 10 | import org.springframework.integration.annotation.ServiceActivator; 11 | import org.springframework.integration.channel.DirectChannel; 12 | import org.springframework.integration.dsl.IntegrationFlow; 13 | import org.springframework.integration.dsl.IntegrationFlowAdapter; 14 | import org.springframework.integration.dsl.IntegrationFlowDefinition; 15 | import org.springframework.integration.handler.advice.ErrorMessageSendingRecoverer; 16 | import org.springframework.integration.handler.advice.RequestHandlerCircuitBreakerAdvice; 17 | import org.springframework.integration.handler.advice.RequestHandlerRetryAdvice; 18 | import org.springframework.integration.http.dsl.Http; 19 | import org.springframework.integration.http.dsl.HttpMessageHandlerSpec; 20 | import org.springframework.integration.support.MessageBuilder; 21 | import org.springframework.messaging.Message; 22 | import org.springframework.messaging.MessageChannel; 23 | import org.springframework.messaging.MessageHandler; 24 | import org.springframework.messaging.MessagingException; 25 | import org.springframework.retry.RetryPolicy; 26 | import org.springframework.retry.backoff.BackOffPolicy; 27 | import org.springframework.retry.backoff.FixedBackOffPolicy; 28 | import org.springframework.retry.policy.AlwaysRetryPolicy; 29 | import org.springframework.retry.support.RetryTemplateBuilder; 30 | 31 | @SpringBootApplication 32 | public class CircuitBreakerApplication { 33 | 34 | public static void main(String[] args) { 35 | SpringApplication.run(CircuitBreakerApplication.class, args); 36 | } 37 | 38 | @Configuration(proxyBeanMethods = false) 39 | public static class RemoteServiceConfiguration { 40 | 41 | @Bean 42 | IntegrationFlow restServiceFlow() { 43 | return IntegrationFlow.from( 44 | Http.inboundGateway("/service") 45 | .id("restService") 46 | .requestMapping((request) -> request.methods(HttpMethod.GET)) 47 | .payloadExpression("#requestParams.name[0]") 48 | .autoStartup(false)) 49 | .transform(String.class, "Hello "::concat) 50 | .get(); 51 | } 52 | 53 | } 54 | 55 | @Bean 56 | MessageChannel recoveryChannel() { 57 | return new DirectChannel(); 58 | } 59 | 60 | @ServiceActivator(inputChannel = "recoveryChannel") 61 | Message fallbackService(Message errorMessage) { 62 | return MessageBuilder 63 | .withPayload("The REST service is not available at the moment: " + errorMessage.getPayload().getCause()) 64 | .copyHeaders(errorMessage.getPayload().getFailedMessage().getHeaders()) 65 | .build(); 66 | } 67 | 68 | @Bean 69 | HttpMessageHandlerSpec httpService(Environment environment) { 70 | return Http.outboundGateway((m) -> environment.getProperty("rest-service.url")) 71 | .uriVariable("name", "payload") 72 | .httpMethod(HttpMethod.GET) 73 | .expectedResponseType(String.class); 74 | } 75 | 76 | @Bean 77 | CircuitBreaker circuitBreaker(@Qualifier("httpService") MessageHandler service, 78 | @Qualifier("recoveryChannel") MessageChannel recoveryChannel) { 79 | 80 | CircuitBreaker circuitBreaker = new CircuitBreaker(service); 81 | circuitBreaker.setThreshold(3); 82 | circuitBreaker.setHalfOpenAfter(500); 83 | circuitBreaker.setRecoveryChannel(recoveryChannel); 84 | circuitBreaker.setRetryPolicy(new AlwaysRetryPolicy()); 85 | FixedBackOffPolicy backOffPolicy = new FixedBackOffPolicy(); 86 | backOffPolicy.setBackOffPeriod(100L); 87 | circuitBreaker.setBackOffPolicy(backOffPolicy); 88 | return circuitBreaker; 89 | } 90 | 91 | public static class CircuitBreaker extends IntegrationFlowAdapter { 92 | 93 | private final RetryTemplateBuilder retryTemplateBuilder = 94 | new RetryTemplateBuilder() 95 | .notRetryOn(RequestHandlerCircuitBreakerAdvice.CircuitBreakerOpenException.class) 96 | .traversingCauses(); 97 | 98 | private final RequestHandlerRetryAdvice retryAdvice = new RequestHandlerRetryAdvice(); 99 | 100 | private final RequestHandlerCircuitBreakerAdvice circuitBreakerAdvice = new RequestHandlerCircuitBreakerAdvice(); 101 | 102 | private final MessageHandler service; 103 | 104 | public CircuitBreaker(MessageHandler service) { 105 | this.service = service; 106 | } 107 | 108 | public void setThreshold(int threshold) { 109 | this.circuitBreakerAdvice.setThreshold(threshold); 110 | } 111 | 112 | public void setHalfOpenAfter(long halfOpenAfter) { 113 | this.circuitBreakerAdvice.setHalfOpenAfter(halfOpenAfter); 114 | } 115 | 116 | public void setRetryPolicy(RetryPolicy retryPolicy) { 117 | this.retryTemplateBuilder.customPolicy(retryPolicy); 118 | } 119 | 120 | public void setBackOffPolicy(BackOffPolicy backOffPolicy) { 121 | this.retryTemplateBuilder.customBackoff(backOffPolicy); 122 | } 123 | 124 | public void setRecoveryChannel(MessageChannel recoveryChannel) { 125 | this.retryAdvice.setRecoveryCallback(new ErrorMessageSendingRecoverer(recoveryChannel)); 126 | } 127 | 128 | @Override 129 | protected IntegrationFlowDefinition buildFlow() { 130 | this.retryAdvice.setRetryTemplate(this.retryTemplateBuilder.build()); 131 | 132 | return from("circuitBreaker.input") 133 | .handle(this.service, 134 | (endpoint) -> endpoint.advice(this.retryAdvice, this.circuitBreakerAdvice)); 135 | } 136 | 137 | } 138 | 139 | } 140 | -------------------------------------------------------------------------------- /circuit-breaker/src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | rest-service.url=http://localhost:${local.server.port}/service?name={name} 2 | logging.level.root=warn 3 | logging.level.org.springframework.integration.handler.advice=debug 4 | logging.level.org.springframework.retry=debug -------------------------------------------------------------------------------- /circuit-breaker/src/test/java/org/springframework/integration/microservices/circuitbreaker/CircuitBreakerApplicationTests.java: -------------------------------------------------------------------------------- 1 | package org.springframework.integration.microservices.circuitbreaker; 2 | 3 | import org.junit.jupiter.api.Test; 4 | 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.beans.factory.annotation.Qualifier; 7 | import org.springframework.boot.test.context.SpringBootTest; 8 | import org.springframework.integration.core.MessagingTemplate; 9 | import org.springframework.integration.http.inbound.HttpRequestHandlingMessagingGateway; 10 | import org.springframework.messaging.MessageChannel; 11 | import org.springframework.test.annotation.DirtiesContext; 12 | 13 | import static org.assertj.core.api.Assertions.assertThat; 14 | 15 | @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) 16 | @DirtiesContext 17 | class CircuitBreakerApplicationTests { 18 | 19 | @Autowired 20 | @Qualifier("restService") 21 | HttpRequestHandlingMessagingGateway restService; 22 | 23 | @Autowired 24 | @Qualifier("circuitBreaker.input") 25 | MessageChannel circuitBreakerInput; 26 | 27 | @Test 28 | void circuitBreakerInAction() throws InterruptedException { 29 | MessagingTemplate messagingTemplate = new MessagingTemplate(); 30 | String result = messagingTemplate.convertSendAndReceive(this.circuitBreakerInput, "world", String.class); 31 | assertThat(result) 32 | .contains("The REST service is not available at the moment: ") 33 | .contains("RequestHandlerCircuitBreakerAdvice$CircuitBreakerOpenException"); 34 | 35 | this.restService.start(); 36 | 37 | // Even if service has already started, the Circuit Breaker is still in open state 38 | result = messagingTemplate.convertSendAndReceive(this.circuitBreakerInput, "world", String.class); 39 | assertThat(result).contains("The REST service is not available at the moment: "); 40 | 41 | // Block the thread a little to let the 'halfOpenAfter' interval to pass in Circuit Breaker 42 | Thread.sleep(1000); 43 | 44 | result = messagingTemplate.convertSendAndReceive(this.circuitBreakerInput, "world", String.class); 45 | assertThat(result).contains("Hello world"); 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /distributed-tracing/README.adoc: -------------------------------------------------------------------------------- 1 | = Distributed Tracing 2 | 3 | The https://microservices.io/patterns/observability/distributed-tracing.html[Distributed Tracing] requires from us a specific instrumentation in our services on some critical points to be able to restore a single request execution spanned throughout those services. 4 | Chris Richardson talks about Spring Cloud Sleuth on his site, but that one is already deprecated in favor of https://micrometer.io/docs/observation[Micrometer Observation] adopted as a first class instrumentation API in all the Spring portfolio projects. 5 | Spring Boot provides an auto-configuration for the `ObservationRegistry`, including Brave tracing (or Open Telemetry) and Zipkin publication. 6 | 7 | You need to have https://zipkin.io/pages/quickstart[Zipkin] running on a default port in your localhost, so the application can export its spans over there according to the auto-configuration. 8 | The application doesn't fail without this one, though, just a plain error in logs that no connection to respective host. 9 | We cannot have Zipkin as a part of our Testcontainers solution in the project unit test since its container is going to be destroyed after test finishes. 10 | With an external Zipkin you will be able to see traces from its Web UI any time and even compare them from different application runs. 11 | 12 | The `DistributedTracingApplication` consists from two logically independent microservices. 13 | The first one receives an HTTP request via https://docs.spring.io/spring-framework/docs/current/reference/html/web-reactive.html#webflux[WebFlux] (the mapping path is `/tracingService`) and put the record into AWS Kinesis stream `SOME_STREAM` using a respective binder for Spring Cloud Stream. 14 | The second service consumes the record from that stream via the mentioned binder as well. 15 | Then it stores received data into embedded H2 database (`traced_data` table) and publish a message into a JMS `SOME_QUEUE` destination on the embedded ActiveMQ Artemis broker. 16 | 17 | We have both microservices in a single Spring Boot application, so we can simply run it in a JUnit test where, in addition to the mentioned embedded DB and JMS broker, we also ensure a Localstack started as a Docker container via https://www.testcontainers.org[Testcontainers] library. 18 | Plus in the test we can perform additional logic relevant to the client of our "distributed" service. 19 | 20 | This sample is not strictly a demonstration of Spring Integration features, but rather a combination of different Spring portfolio projects with respective observability tooling auto-configured for us by Spring Boot and Spring Cloud. 21 | In addition, we use a https://jdbc-observations.github.io/datasource-micrometer/docs/current/docs/html[Datasource Micrometer] library to have an observation instrumentation around database operations, which is useful to have attached as a span into our distributed trace. 22 | See link:src/main/resources/application.properties[`application.properties`] for configuration we apply in this Microservices "composite" application: 23 | 24 | - the Kinesis producer and consumer are bound to respective Kinesis stream via Spring Cloud Stream; 25 | - an observation is enabled for Kinesis binder; 26 | - we expose `traceId` and `spanId` MDC variables into our logs to observe tracing in action; 27 | - the `jdbc.includes=QUERY` is an option for the mentioned Datasource Micrometer library to observe queries we perform against database; 28 | - since there is no observation instrumentation for JMS, we simulate it via an observation enabled on the `jmsChannel` used as an input channel for the `jmsFlow` in our consumer microservice. 29 | 30 | The WebFlux we use to initiate a distributed request is instrumented with an observation automatically. 31 | 32 | See also link:src/test/resources/schema.sql[`schema.sql`] for a table structure we are going to store the data alongside with the tracing info. 33 | Just for informative purpose and to confirm that distributed tracing works as it is claimed by the instrumentation provided in Spring projects. 34 | 35 | So, how it works: 36 | 37 | - the `DistributedTracingApplicationTests` starts some `test-parent-observation` for a demonstration purpose and verification in the end that all the services were performed within the same trace; 38 | - then this `Observation` instance is set to the Reactor context for the `WebClient` request; 39 | - this `Observation` is used then as a parent for an observation around `WebClient` request-reply execution. 40 | It is done automatically by the WebFlux framework; 41 | - the WebFlux server receives an HTTP request, restores an observation from its headers and starts a new server-side one. 42 | This is also done by the WebFlux framework automatically; 43 | - a reactive `Publsiher` wrapper for that `IntegrationFlow` is used as a source for Spring Cloud Function `Supplier` which is bound to the Kinesis binder on a `SOME_STREAM` destination; 44 | - the `doOnNext()` with log message is used to confirm that we are still in the same trace; 45 | - since we have enabled an observation on the binder, its producer starts a new observation (and therefore span for the current trace); 46 | - an observation information is serialized by the Brave tracer into message headers and this message is serialized into Kinesis record body since Kinesis does not support headers abstraction; 47 | - the Kinesis consumer on a `SOME_STREAM` destination, from the other side, deserializes the message from a record and restores observation information from headers. 48 | Then it starts a new its own observation; 49 | - the `publishSubscribeChannel()` of the `IntegrationFlow` distributes the message two database an JMS queue in parallel; 50 | - to make the parallel distributed observation-aware we use a `ContextExecutorService` wrapper from the `context-propagation` library; 51 | - the JDBC operation is observed with the mention Datasource Micrometer library; 52 | - the JMS operation observation is emulated around the channel and its subscriber producing to JMS queue; 53 | - in the end of test we use a `traceId` from the `test-parent-observation` started in the beginning to verify that JMS message and data stored in the DB have exactly the same trace id. 54 | 55 | All log messages we emit from this application are for the tracing information propagation down to the MDC and to confirm that one request is perform within a single trace: 56 | 57 | [source,console] 58 | ---- 59 | 2023-04-07T16:59:56.067-04:00 TRACE [distributed-tracing-pattern,6430844b325121b88ba6f96b4596faf7,19ff1630b72bea74] 11832 --- [ctor-http-nio-6] o.s.i.microservices.distributedtracing : HTTP request to trace: GenericMessage [payload=test data, headers={}] 60 | 2023-04-07T16:59:56.069-04:00 TRACE [distributed-tracing-pattern,6430844b325121b88ba6f96b4596faf7,19ff1630b72bea74] 11832 --- [ctor-http-nio-6] o.s.i.m.d.DistributedTracingApplication : Send message to Kinesis: GenericMessage [payload=test data, headers={}] 61 | 2023-04-07T16:59:57.987-04:00 TRACE [distributed-tracing-pattern,6430844b325121b88ba6f96b4596faf7,0827e2943074baf8] 11832 --- [esis-consumer-1] o.s.i.microservices.distributedtracing : Received message from Kinesis: GenericMessage [payload=test data, headers={}] 62 | 2023-04-07T16:59:57.989-04:00 TRACE [distributed-tracing-pattern,6430844b325121b88ba6f96b4596faf7,0827e2943074baf8] 11832 --- [pool-3-thread-1] o.s.i.microservices.distributedtracing : Save message to DB: GenericMessage [payload=test data, headers={}] 63 | 2023-04-07T16:59:57.990-04:00 TRACE [distributed-tracing-pattern,6430844b325121b88ba6f96b4596faf7,bdbd14425893b06d] 11832 --- [pool-3-thread-2] o.s.i.microservices.distributedtracing : Send message to JMS: MutableMessage [payload=test data, headers={}] 64 | ---- 65 | 66 | And here is a result of some run of `DistributedTracingApplicationTests`: 67 | 68 | .The sample of some trace in this application(s) 69 | image::trace-sample.jpg[] 70 | 71 | Right, as we discussed before, for simplicity all the microservices are part of a single Spring Boot application. 72 | That's why we see only `1` service on the picture. 73 | Two last spans for JDBC `INSERT` and JMS publishing are shown in parallel because we do produce them in parallel via `publishSubscribeChannel()` from an `IntegrationFlow` on the Kinesis Binder consumer. 74 | 75 | == Spring Boot and Spring Integration resources 76 | 77 | * https://docs.gradle.org[Official Gradle documentation] 78 | * https://docs.spring.io/spring-boot/docs/current/gradle-plugin/reference/html[Spring Boot Gradle Plugin Reference Guide] 79 | * https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#messaging.spring-integration[Spring Integration] 80 | * https://docs.spring.io/spring-integration/reference/html/dsl.html[Spring Integration Java DSL Reference Guide] 81 | * https://docs.spring.io/spring-integration/docs/current/reference/html/jdbc.html#jdbc[Spring Integration JDBC Reference Guide] 82 | * https://docs.spring.io/spring-integration/docs/current/reference/html/jms.html#jms[Spring Integration JMS Reference Guide] 83 | * https://micrometer.io/docs/tracing[Micrometer Tracing Reference Guide] 84 | * https://jdbc-observations.github.io/datasource-micrometer/docs/current/docs/html/[Datasource Micrometer Reference] 85 | * https://github.com/spring-cloud/spring-cloud-stream-binder-aws-kinesis/blob/main/spring-cloud-stream-binder-kinesis-docs/src/main/asciidoc/overview.adoc[Cloud Stream AWS Kinesis Binder] 86 | -------------------------------------------------------------------------------- /distributed-tracing/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | repositories { 3 | gradlePluginPortal() 4 | } 5 | dependencies { 6 | classpath "org.springframework.boot:spring-boot-gradle-plugin:$springBootVersion" 7 | } 8 | } 9 | 10 | plugins { 11 | id 'java' 12 | id 'io.spring.dependency-management' version '1.1.0' 13 | } 14 | 15 | apply plugin: 'org.springframework.boot' 16 | 17 | group = 'org.springframework.integration.microservices' 18 | version = '0.0.1-SNAPSHOT' 19 | sourceCompatibility = '17' 20 | 21 | repositories { 22 | mavenCentral() 23 | } 24 | 25 | ext { 26 | awsSdkVersion = '2.20.81' 27 | } 28 | 29 | dependencyManagement { 30 | imports { 31 | mavenBom "software.amazon.awssdk:bom:$awsSdkVersion" 32 | } 33 | } 34 | 35 | dependencies { 36 | implementation 'org.springframework.boot:spring-boot-starter-actuator' 37 | implementation 'org.springframework.boot:spring-boot-starter-artemis' 38 | implementation 'org.springframework.boot:spring-boot-starter-integration' 39 | implementation 'org.springframework.boot:spring-boot-starter-webflux' 40 | implementation 'io.micrometer:context-propagation' 41 | implementation 'io.micrometer:micrometer-tracing-bridge-brave' 42 | implementation 'io.zipkin.reporter2:zipkin-reporter-brave' 43 | implementation 'org.springframework.cloud:spring-cloud-stream-binder-kinesis:4.0.0' 44 | implementation 'net.ttddyy.observation:datasource-micrometer-spring-boot:1.0.2' 45 | implementation 'org.springframework.integration:spring-integration-jdbc' 46 | implementation 'org.springframework.integration:spring-integration-jms' 47 | implementation 'org.springframework.integration:spring-integration-webflux' 48 | 49 | testImplementation 'org.springframework.boot:spring-boot-starter-test' 50 | testImplementation 'io.projectreactor:reactor-test' 51 | testImplementation 'org.springframework.integration:spring-integration-test' 52 | testImplementation 'org.testcontainers:junit-jupiter' 53 | testImplementation 'org.testcontainers:localstack' 54 | testImplementation 'org.apache.activemq:artemis-jakarta-server' 55 | 56 | testRuntimeOnly 'com.h2database:h2' 57 | } 58 | 59 | tasks.named('test') { 60 | useJUnitPlatform() 61 | } 62 | -------------------------------------------------------------------------------- /distributed-tracing/src/main/java/org/springframework/integration/microservices/distributedtracing/DistributedTracingApplication.java: -------------------------------------------------------------------------------- 1 | package org.springframework.integration.microservices.distributedtracing; 2 | 3 | import java.util.concurrent.Executor; 4 | import java.util.concurrent.Executors; 5 | import java.util.function.Consumer; 6 | import java.util.function.Supplier; 7 | 8 | import io.micrometer.context.ContextExecutorService; 9 | import io.micrometer.tracing.Tracer; 10 | import net.ttddyy.observation.boot.autoconfigure.DataSourceNameResolver; 11 | import org.apache.commons.logging.Log; 12 | import org.apache.commons.logging.LogFactory; 13 | import org.reactivestreams.Publisher; 14 | import reactor.core.publisher.Flux; 15 | 16 | import org.springframework.boot.SpringApplication; 17 | import org.springframework.boot.autoconfigure.SpringBootApplication; 18 | import org.springframework.context.ApplicationContext; 19 | import org.springframework.context.annotation.Bean; 20 | import org.springframework.context.annotation.Configuration; 21 | import org.springframework.http.HttpMethod; 22 | import org.springframework.integration.dsl.IntegrationFlow; 23 | import org.springframework.integration.handler.LoggingHandler; 24 | import org.springframework.integration.jdbc.JdbcMessageHandler; 25 | import org.springframework.integration.jms.dsl.Jms; 26 | import org.springframework.integration.webflux.dsl.WebFlux; 27 | import org.springframework.jdbc.core.JdbcTemplate; 28 | import org.springframework.jms.core.JmsTemplate; 29 | import org.springframework.messaging.Message; 30 | 31 | @SpringBootApplication 32 | public class DistributedTracingApplication { 33 | 34 | private static final Log LOGGER = LogFactory.getLog(DistributedTracingApplication.class); 35 | 36 | public static void main(String[] args) { 37 | SpringApplication.run(DistributedTracingApplication.class, args); 38 | } 39 | 40 | @Configuration 41 | protected static class ProducerService { 42 | 43 | @Bean 44 | public Publisher> httpSupplierFlow() { 45 | return IntegrationFlow.from( 46 | WebFlux.inboundChannelAdapter("/tracingService") 47 | .requestPayloadType(String.class) 48 | .requestMapping(mapping -> mapping.methods(HttpMethod.POST))) 49 | .log(LoggingHandler.Level.TRACE, DistributedTracingApplication.class.getPackageName(), 50 | message -> "HTTP request to trace: " + message) 51 | .toReactivePublisher(true); 52 | } 53 | 54 | @Bean 55 | public Supplier>> kinesisSupplier(Publisher> httpRequestPublisher) { 56 | return () -> 57 | Flux.from(httpRequestPublisher) 58 | .doOnNext(message -> LOGGER.trace("Send message to Kinesis: " + message)); 59 | } 60 | 61 | } 62 | 63 | @Configuration 64 | protected static class ConsumerService { 65 | 66 | @Bean 67 | Executor executorWithPropagation() { 68 | return ContextExecutorService.wrap(Executors.newCachedThreadPool()); 69 | } 70 | 71 | @Bean 72 | public IntegrationFlow consumerFlow(Executor executorWithPropagation, 73 | IntegrationFlow jdbcFlow, IntegrationFlow jmsFlow) { 74 | 75 | return IntegrationFlow.from(MessageConsumer.class, gateway -> gateway.beanName("kinesisConsumer")) 76 | .log(LoggingHandler.Level.TRACE, DistributedTracingApplication.class.getPackageName(), 77 | message -> "Received message from Kinesis: " + message) 78 | .publishSubscribeChannel(executorWithPropagation, 79 | pubSub -> pubSub 80 | .subscribe(jdbcFlow) 81 | .subscribe(jmsFlow)) 82 | .get(); 83 | } 84 | 85 | @Bean 86 | public IntegrationFlow jdbcFlow(JdbcTemplate jdbcTemplate, Tracer tracer) { 87 | return f -> f 88 | .log(LoggingHandler.Level.TRACE, DistributedTracingApplication.class.getPackageName(), 89 | message -> "Save message to DB: " + message) 90 | .enrichHeaders(headers -> headers 91 | .headerFunction("traceId", m -> tracer.currentSpan().context().traceId()) 92 | .headerFunction("spanId", m -> tracer.currentSpan().context().spanId())) 93 | .handle(new JdbcMessageHandler(jdbcTemplate, 94 | "INSERT INTO traced_data(traceId, spanId, payload) " + 95 | "VALUES(:headers[traceId], :headers[spanId], :payload)")); 96 | 97 | } 98 | 99 | @Bean 100 | public IntegrationFlow jmsFlow(JmsTemplate jmsTemplate) { 101 | return IntegrationFlow.from("jmsChannel") 102 | .log(LoggingHandler.Level.TRACE, DistributedTracingApplication.class.getPackageName(), 103 | message -> "Send message to JMS: " + message) 104 | .handle(Jms.outboundAdapter(jmsTemplate).destination("SOME_QUEUE")) 105 | .get(); 106 | } 107 | 108 | @Bean 109 | DataSourceNameResolver dataSourceNameResolver(ApplicationContext applicationContext) { 110 | return (beanName, dataSource) -> applicationContext.getId(); 111 | } 112 | 113 | public interface MessageConsumer extends Consumer> { 114 | 115 | } 116 | 117 | } 118 | 119 | } 120 | -------------------------------------------------------------------------------- /distributed-tracing/src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.application.name=distributed-tracing-pattern 2 | management.tracing.sampling.probability=1 3 | logging.pattern.level=%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}] 4 | spring.cloud.function.definition=kinesisSupplier;kinesisConsumer 5 | spring.cloud.stream.bindings.kinesisSupplier-out-0.destination=SOME_STREAM 6 | spring.cloud.stream.bindings.kinesisConsumer-in-0.destination=SOME_STREAM 7 | spring.cloud.stream.bindings.kinesisConsumer-in-0.group=tracingConsumer 8 | spring.cloud.stream.kinesis.binder.enable-observation=true 9 | spring.integration.management.observation-patterns=jmsChannel 10 | jdbc.includes=QUERY 11 | logging.level.root=warn 12 | logging.level.org.springframework.integration.microservices.distributedtracing=trace 13 | -------------------------------------------------------------------------------- /distributed-tracing/src/test/java/org/springframework/integration/microservices/distributedtracing/DistributedTracingApplicationTests.java: -------------------------------------------------------------------------------- 1 | package org.springframework.integration.microservices.distributedtracing; 2 | 3 | import io.micrometer.observation.Observation; 4 | import io.micrometer.observation.ObservationRegistry; 5 | import io.micrometer.observation.contextpropagation.ObservationThreadLocalAccessor; 6 | import io.micrometer.tracing.handler.TracingObservationHandler; 7 | import jakarta.jms.JMSException; 8 | import jakarta.jms.Message; 9 | import org.junit.jupiter.api.Test; 10 | import org.testcontainers.containers.localstack.LocalStackContainer; 11 | import org.testcontainers.junit.jupiter.Container; 12 | import org.testcontainers.junit.jupiter.Testcontainers; 13 | import org.testcontainers.utility.DockerImageName; 14 | import reactor.core.publisher.Mono; 15 | import reactor.test.StepVerifier; 16 | import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; 17 | import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; 18 | import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; 19 | import software.amazon.awssdk.regions.Region; 20 | import software.amazon.awssdk.services.kinesis.KinesisAsyncClient; 21 | 22 | import org.springframework.beans.factory.annotation.Autowired; 23 | import org.springframework.boot.test.autoconfigure.actuate.observability.AutoConfigureObservability; 24 | import org.springframework.boot.test.context.SpringBootTest; 25 | import org.springframework.boot.test.context.TestConfiguration; 26 | import org.springframework.boot.test.web.server.LocalServerPort; 27 | import org.springframework.context.annotation.Bean; 28 | import org.springframework.http.HttpStatus; 29 | import org.springframework.http.HttpStatusCode; 30 | import org.springframework.http.ResponseEntity; 31 | import org.springframework.integration.metadata.MetadataStore; 32 | import org.springframework.integration.metadata.SimpleMetadataStore; 33 | import org.springframework.integration.support.locks.DefaultLockRegistry; 34 | import org.springframework.integration.support.locks.LockRegistry; 35 | import org.springframework.jdbc.core.JdbcTemplate; 36 | import org.springframework.jms.core.JmsTemplate; 37 | import org.springframework.test.context.DynamicPropertyRegistry; 38 | import org.springframework.test.context.DynamicPropertySource; 39 | import org.springframework.web.reactive.function.client.WebClient; 40 | 41 | import static org.assertj.core.api.Assertions.assertThat; 42 | 43 | @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) 44 | @AutoConfigureObservability 45 | @Testcontainers(disabledWithoutDocker = true) 46 | class DistributedTracingApplicationTests { 47 | 48 | @Container 49 | static LocalStackContainer LOCAL_STACK_CONTAINER = 50 | new LocalStackContainer(DockerImageName.parse("localstack/localstack:2.1.0")); 51 | 52 | @Autowired 53 | ObservationRegistry observationRegistry; 54 | 55 | @Autowired 56 | WebClient.Builder webClient; 57 | 58 | @LocalServerPort 59 | int port; 60 | 61 | @Autowired 62 | JmsTemplate jmsTemplate; 63 | 64 | @Autowired 65 | JdbcTemplate jdbcTemplate; 66 | 67 | @DynamicPropertySource 68 | static void redisProperties(DynamicPropertyRegistry registry) { 69 | registry.add("spring.cloud.aws.region.static", LOCAL_STACK_CONTAINER::getRegion); 70 | } 71 | 72 | @Test 73 | void distributedTracingInAction() throws JMSException { 74 | String testPayload = "test data"; 75 | 76 | Observation parentObservation = Observation 77 | .createNotStarted("test-parent-observation", this.observationRegistry) 78 | .lowCardinalityKeyValue("service.name", DistributedTracingApplicationTests.class.getName()) 79 | .start(); 80 | 81 | Mono responseMono = 82 | this.webClient.build() 83 | .post() 84 | .uri("http://localhost:{port}/tracingService", this.port) 85 | .bodyValue(testPayload) 86 | .retrieve() 87 | .toBodilessEntity() 88 | .map(ResponseEntity::getStatusCode) 89 | .contextWrite(context -> context.put(ObservationThreadLocalAccessor.KEY, parentObservation)) 90 | .doOnError(parentObservation::error) 91 | .doOnSuccess(r -> parentObservation.stop()); 92 | 93 | StepVerifier.create(responseMono) 94 | .expectNext(HttpStatus.OK) 95 | .verifyComplete(); 96 | 97 | this.jmsTemplate.setReceiveTimeout(60_000); 98 | Message receive = this.jmsTemplate.receive("SOME_QUEUE"); 99 | assertThat(receive).isNotNull(); 100 | String traceparent = receive.getStringProperty("traceparent"); 101 | assertThat(traceparent).isNotNull(); 102 | assertThat(receive.getBody(String.class)).isEqualTo(testPayload); 103 | 104 | String traceId = 105 | ((TracingObservationHandler.TracingContext) parentObservation.getContextView() 106 | .getRequired(TracingObservationHandler.TracingContext.class)) 107 | .getSpan() 108 | .context() 109 | .traceId(); 110 | 111 | assertThat(traceparent).contains(traceId); 112 | 113 | String payload = 114 | this.jdbcTemplate.queryForObject("SELECT payload FROM traced_data WHERE traceId = ?", 115 | String.class, traceId); 116 | 117 | assertThat(payload).isEqualTo(testPayload); 118 | } 119 | 120 | @TestConfiguration 121 | public static class KinesisBinderServicesConfiguration { 122 | 123 | @Bean 124 | LockRegistry lockRegistry() { 125 | return new DefaultLockRegistry(); 126 | } 127 | 128 | @Bean 129 | MetadataStore metadataStore() { 130 | return new SimpleMetadataStore(); 131 | } 132 | 133 | @Bean 134 | KinesisAsyncClient kinesisClient() { 135 | return KinesisAsyncClient.builder() 136 | .region(Region.of(LOCAL_STACK_CONTAINER.getRegion())) 137 | .credentialsProvider(credentialsProvider()) 138 | .endpointOverride(LOCAL_STACK_CONTAINER.getEndpointOverride(LocalStackContainer.Service.KINESIS)) 139 | .build(); 140 | } 141 | 142 | private static AwsCredentialsProvider credentialsProvider() { 143 | return StaticCredentialsProvider.create( 144 | AwsBasicCredentials.create(LOCAL_STACK_CONTAINER.getAccessKey(), 145 | LOCAL_STACK_CONTAINER.getSecretKey())); 146 | } 147 | 148 | } 149 | 150 | } 151 | -------------------------------------------------------------------------------- /distributed-tracing/src/test/resources/schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE traced_data (traceId VARCHAR(32) NOT NULL, spanId VARCHAR(16) NOT NULL, createdAt TIMESTAMP DEFAULT NOW() NOT NULL, payload VARCHAR(255), PRIMARY KEY (traceId)); 2 | -------------------------------------------------------------------------------- /distributed-tracing/trace-sample.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/artembilan/microservices-patterns-spring-integration/c47a45f384f1def533305557c15e428161167543/distributed-tracing/trace-sample.jpg -------------------------------------------------------------------------------- /framer/README.adoc: -------------------------------------------------------------------------------- 1 | = Framer Recipe 2 | 3 | There is no such a pattern in the wild, but it is based on https://www.oreilly.com/radar/the-world-beyond-batch-streaming-101/[Streaming Systems Windowing concept]. 4 | Unfortunately Spring Integration Aggregator component can place one message only into a single group for correlation. 5 | The rest of an Aggregator functionality is so powerful that it is easy to configure any possible windowing aspect for finite or streaming data. 6 | The input for this component could be a finance market data to calculate trading signals. 7 | 8 | An idea behind this recipe is to be able to distribute the same message into different groups - windows. 9 | Therefore, "framer" - a worker who knows how to manage windows. 10 | Or the one who sets boundaries for a thing when we talk about time frames. 11 | 12 | To put the same message in the aggregator into several groups, we use a custom splitter which produces as many messages as calculated by the windowing algorithm with the same payload. 13 | This is the crucial part of this recipe - a `splitter` which will produce several messages with unique correlation details, but with the same data to place into different windows. 14 | 15 | For simplicity of the proof of concept we group incoming data into batches of `3`, where every next event starts a new window and contributes itself back to two previous windows, but with its respective sequence in that group. 16 | So, if our streaming data is like this: `P1, P2, P3, P4, P5 ...` (`P` means "price"), we want to get these windows on the output: 17 | 18 | ---- 19 | [P1, P2, P3] 20 | [P2, P3, P4] 21 | [P3, P4, P5] 22 | ... 23 | ---- 24 | 25 | The unit test for this application emits 5 random numbers and the output may look like this: 26 | 27 | [source,console] 28 | ---- 29 | GenericMessage [payload=[94, 107, 98], headers={sequenceNumber=2, correlationId=0, id=3f7549d0-2ad6-211e-d07d-935ee18d88d4, sequenceSize=3, timestamp=1670276902285}] 30 | GenericMessage [payload=[107, 98, 94], headers={sequenceNumber=2, correlationId=1, id=92f1c34c-64dd-ece8-f4f2-514467af1ff9, sequenceSize=3, timestamp=1670276902286}] 31 | GenericMessage [payload=[98, 94, 101], headers={sequenceNumber=2, correlationId=2, id=168aef5a-4a4e-15af-7d75-a0d321a5f5f0, sequenceSize=3, timestamp=1670276902286}] 32 | ---- 33 | 34 | == Spring Boot and Spring Integration resources 35 | 36 | * https://docs.gradle.org[Official Gradle documentation] 37 | * https://docs.spring.io/spring-boot/docs/current/gradle-plugin/reference/html/#build-image[Spring Boot Gradle Plugin Reference Guide] 38 | * https://docs.spring.io/spring-boot/docs/current/gradle-plugin/reference/html/#build-image[Create an OCI image] 39 | * https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#messaging.spring-integration[Spring Integration] 40 | * https://docs.spring.io/spring-integration/reference/html/dsl.html[Spring Integration Java DSL Reference Guide] 41 | * https://docs.spring.io/spring-integration/docs/current/reference/html/message-routing.html#splitter[Spring Integration Splitter Reference Guide] 42 | * https://docs.spring.io/spring-integration/docs/current/reference/html/message-routing.html#aggregator[Spring Integration Aggregator Reference Guide] 43 | -------------------------------------------------------------------------------- /framer/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | repositories { 3 | gradlePluginPortal() 4 | } 5 | dependencies { 6 | classpath "org.springframework.boot:spring-boot-gradle-plugin:$springBootVersion" 7 | } 8 | } 9 | 10 | plugins { 11 | id 'java' 12 | id 'io.spring.dependency-management' version '1.1.0' 13 | } 14 | 15 | apply plugin: 'org.springframework.boot' 16 | 17 | group = 'org.springframework.integration.microservices' 18 | version = '0.0.1-SNAPSHOT' 19 | sourceCompatibility = '17' 20 | 21 | repositories { 22 | mavenCentral() 23 | } 24 | 25 | dependencies { 26 | implementation 'org.springframework.boot:spring-boot-starter-integration' 27 | testImplementation 'org.springframework.boot:spring-boot-starter-test' 28 | testImplementation 'org.springframework.integration:spring-integration-test' 29 | } 30 | 31 | tasks.named('test') { 32 | useJUnitPlatform() 33 | } 34 | -------------------------------------------------------------------------------- /framer/src/main/java/org/springframework/integration/microservices/framer/FramerApplication.java: -------------------------------------------------------------------------------- 1 | package org.springframework.integration.microservices.framer; 2 | 3 | import java.util.List; 4 | import java.util.Map; 5 | import java.util.concurrent.atomic.AtomicLong; 6 | import java.util.stream.IntStream; 7 | 8 | import org.springframework.boot.SpringApplication; 9 | import org.springframework.boot.autoconfigure.SpringBootApplication; 10 | import org.springframework.context.annotation.Bean; 11 | import org.springframework.integration.annotation.Splitter; 12 | import org.springframework.integration.dsl.IntegrationFlowAdapter; 13 | import org.springframework.integration.dsl.IntegrationFlowDefinition; 14 | import org.springframework.integration.store.MessageGroupStore; 15 | import org.springframework.integration.store.SimpleMessageStore; 16 | import org.springframework.integration.support.MessageBuilder; 17 | 18 | @SpringBootApplication 19 | public class FramerApplication { 20 | 21 | public static void main(String[] args) { 22 | SpringApplication.run(FramerApplication.class, args); 23 | } 24 | 25 | @Bean 26 | MessageGroupStore messageGroupStore() { 27 | return new SimpleMessageStore(); 28 | } 29 | 30 | @Bean 31 | Framer framer(MessageGroupStore messageGroupStore) { 32 | return new Framer(messageGroupStore); 33 | } 34 | 35 | public static class Framer extends IntegrationFlowAdapter { 36 | 37 | private final MessageGroupStore messageGroupStore; 38 | 39 | public Framer(MessageGroupStore messageGroupStore) { 40 | this.messageGroupStore = messageGroupStore; 41 | } 42 | 43 | @Override 44 | protected IntegrationFlowDefinition buildFlow() { 45 | return from("framer.input") 46 | .split(this, "slidingWindows", (splitter) -> splitter.applySequence(false)) 47 | .aggregate((aggregatorSpec) -> 48 | aggregatorSpec.messageStore(this.messageGroupStore) 49 | .expireGroupsUponCompletion(true)) 50 | .channel((channels) -> channels.queue("framer.output")); 51 | } 52 | 53 | private final AtomicLong messageSequence = new AtomicLong(); 54 | 55 | @Splitter 56 | public List> slidingWindows(Object payload) { 57 | Long correlationKey = this.messageSequence.getAndIncrement(); 58 | return IntStream.range(0, 3) 59 | .boxed() 60 | .map((sequenceNumber) -> Map.entry(correlationKey - sequenceNumber, sequenceNumber)) 61 | .filter((sequenceDetails) -> sequenceDetails.getKey() >= 0) 62 | .map((sequenceDetails) -> 63 | MessageBuilder.withPayload(payload) 64 | .setCorrelationId(sequenceDetails.getKey()) 65 | .setSequenceNumber(sequenceDetails.getValue()) 66 | .setSequenceSize(3)) 67 | .toList(); 68 | } 69 | 70 | } 71 | 72 | } 73 | -------------------------------------------------------------------------------- /framer/src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /framer/src/test/java/org/springframework/integration/microservices/framer/FramerApplicationTests.java: -------------------------------------------------------------------------------- 1 | package org.springframework.integration.microservices.framer; 2 | 3 | import java.util.List; 4 | import java.util.Random; 5 | 6 | import org.assertj.core.api.InstanceOfAssertFactories; 7 | import org.junit.jupiter.api.Test; 8 | 9 | import org.springframework.beans.factory.annotation.Autowired; 10 | import org.springframework.beans.factory.annotation.Qualifier; 11 | import org.springframework.boot.test.context.SpringBootTest; 12 | import org.springframework.integration.store.MessageGroupStore; 13 | import org.springframework.messaging.Message; 14 | import org.springframework.messaging.MessageChannel; 15 | import org.springframework.messaging.PollableChannel; 16 | import org.springframework.messaging.support.GenericMessage; 17 | 18 | import static org.assertj.core.api.Assertions.assertThat; 19 | 20 | @SpringBootTest 21 | class FramerApplicationTests { 22 | 23 | @Autowired 24 | @Qualifier("framer.input") 25 | MessageChannel framerInput; 26 | 27 | @Autowired 28 | @Qualifier("framer.output") 29 | PollableChannel framerOutput; 30 | 31 | @Autowired 32 | MessageGroupStore messageGroupStore; 33 | 34 | @Test 35 | void framerIsSlidingDataIntoWindows() { 36 | List prices = 37 | new Random() 38 | .ints(5, 90, 120) 39 | .boxed() 40 | .toList(); 41 | 42 | prices.forEach((price) -> this.framerInput.send(new GenericMessage<>(price))); 43 | 44 | for (int sequence = 0; sequence < 3; sequence++) { 45 | Message receive = this.framerOutput.receive(10_000); 46 | assertThat(receive).isNotNull() 47 | .satisfies(System.out::println) 48 | .extracting(Message::getPayload) 49 | .asInstanceOf(InstanceOfAssertFactories.LIST) 50 | .containsExactly(prices.get(sequence), prices.get(sequence + 1), prices.get(sequence + 2)); 51 | } 52 | 53 | assertThat(this.framerOutput.receive(100)).isNull(); 54 | 55 | // Two groups for [P4, P5] and for [P5] 56 | assertThat(this.messageGroupStore.getMessageGroupCount()).isEqualTo(2); 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | version=1.0.0-SNAPSHOT 2 | springBootVersion=3.1.0 3 | org.gradle.caching=true 4 | org.gradle.parallel=true 5 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/artembilan/microservices-patterns-spring-integration/c47a45f384f1def533305557c15e428161167543/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionSha256Sum=ff7bf6a86f09b9b2c40bb8f48b25fc19cf2b2664fd1d220cd7ab833ec758d0d7 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.2-bin.zip 5 | networkTimeout=10000 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | # This is normally unused 84 | # shellcheck disable=SC2034 85 | APP_BASE_NAME=${0##*/} 86 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit 87 | 88 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 89 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | 142 | # Increase the maximum file descriptors if we can. 143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 144 | case $MAX_FD in #( 145 | max*) 146 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 147 | # shellcheck disable=SC3045 148 | MAX_FD=$( ulimit -H -n ) || 149 | warn "Could not query maximum file descriptor limit" 150 | esac 151 | case $MAX_FD in #( 152 | '' | soft) :;; #( 153 | *) 154 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 155 | # shellcheck disable=SC3045 156 | ulimit -n "$MAX_FD" || 157 | warn "Could not set maximum file descriptor limit to $MAX_FD" 158 | esac 159 | fi 160 | 161 | # Collect all arguments for the java command, stacking in reverse order: 162 | # * args from the command line 163 | # * the main class name 164 | # * -classpath 165 | # * -D...appname settings 166 | # * --module-path (only if needed) 167 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 168 | 169 | # For Cygwin or MSYS, switch paths to Windows format before running java 170 | if "$cygwin" || "$msys" ; then 171 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 172 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 173 | 174 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 175 | 176 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 177 | for arg do 178 | if 179 | case $arg in #( 180 | -*) false ;; # don't mess with options #( 181 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 182 | [ -e "$t" ] ;; #( 183 | *) false ;; 184 | esac 185 | then 186 | arg=$( cygpath --path --ignore --mixed "$arg" ) 187 | fi 188 | # Roll the args list around exactly as many times as the number of 189 | # args, so each arg winds up back in the position where it started, but 190 | # possibly modified. 191 | # 192 | # NB: a `for` loop captures its iteration list before it begins, so 193 | # changing the positional parameters here affects neither the number of 194 | # iterations, nor the values presented in `arg`. 195 | shift # remove old arg 196 | set -- "$@" "$arg" # push replacement arg 197 | done 198 | fi 199 | 200 | # Collect all arguments for the java command; 201 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of 202 | # shell script including quotes and variable substitutions, so put them in 203 | # double quotes to make sure that they get re-expanded; and 204 | # * put everything else in single quotes, so that it's not re-expanded. 205 | 206 | set -- \ 207 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 208 | -classpath "$CLASSPATH" \ 209 | org.gradle.wrapper.GradleWrapperMain \ 210 | "$@" 211 | 212 | # Stop when "xargs" is not available. 213 | if ! command -v xargs >/dev/null 2>&1 214 | then 215 | die "xargs is not available" 216 | fi 217 | 218 | # Use "xargs" to parse quoted args. 219 | # 220 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 221 | # 222 | # In Bash we could simply go: 223 | # 224 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 225 | # set -- "${ARGS[@]}" "$@" 226 | # 227 | # but POSIX shell has neither arrays nor command substitution, so instead we 228 | # post-process each arg (as a line of input to sed) to backslash-escape any 229 | # character that might be a shell metacharacter, then use eval to reverse 230 | # that process (while maintaining the separation between arguments), and wrap 231 | # the whole thing up as a single "set" statement. 232 | # 233 | # This will of course break if any of these variables contains a newline or 234 | # an unmatched quote. 235 | # 236 | 237 | eval "set -- $( 238 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 239 | xargs -n1 | 240 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 241 | tr '\n' ' ' 242 | )" '"$@"' 243 | 244 | exec "$JAVACMD" "$@" 245 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 48 | echo. 49 | echo Please set the JAVA_HOME variable in your environment to match the 50 | echo location of your Java installation. 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 62 | echo. 63 | echo Please set the JAVA_HOME variable in your environment to match the 64 | echo location of your Java installation. 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /mrpc/README.adoc: -------------------------------------------------------------------------------- 1 | = mRPC Concept 2 | 3 | The https://microservices.io/patterns/communication-style/rpi.html[`RPC`] pattern and principle exists already for a log time. 4 | There are many ways to make a communication between client and the server and represent it really as a remote procedure call. 5 | Even if Spring Cloud Stream project is aimed for distributes streaming solutions through chain of functions exposed via bindings on a messaging middleware, every single function in this system can be treated exactly as a remote procedure where we could perform a request-reply pattern via messaging exchange. 6 | Such a function is just a simple cloud native microservice where it exposes connections into an input and output binding destinations through which we can send a request and receive reply using respective messaging API for the broker in between. 7 | Therefore, an `mRPC` - *messaging Remote Procedure Call*. 8 | In Spring Integration this request-reply interaction is called a `Gateway`. 9 | Since there is no a gateway implementation in Spring Cloud Stream, we are going to try to simulate a distributed request-reply scenario via Spring Integration API where we have all the required instruments still leveraging Spring Cloud Stream components as a first level requirement for our `mRPC` concept. 10 | 11 | The implementation is like this: 12 | 13 | 1. We use a Messaging Gateway from Spring Integration as end-user high-level API to send a request and wait from reply. 14 | The fact that underneath we perform a Spring Cloud Stream call over bindings is definitely hidden from end-user thanks to Spring Integration flows definitions; 15 | 2. Since we are going to perform a network communication via Spring Cloud Stream bindings, we have to ensure that data we transfer is serializable regarding a protocol dictated by the respective messaging middleware. 16 | Therefore, for Spring Integration's `TemporaryReplyChannel` header, which is crucial in request-reply pattern in the gateway implementation, we will rely on a `HeaderChannelRegistry` which can store this non-serializable `TemporaryReplyChannel` under some generated key representation; 17 | 3. Now we are able to send a request in Spring Cloud Stream binding, and we do that via `StreamBridge` API. 18 | Note that now this `replyChannel` header as a string representation is going to be sent over the network to remote function alongside with the payload; 19 | 4. Since the target remote function, we'd like to call, is exposed by Spring Cloud Stream bindings on dedicated endpoints, and it is not aware of our request-reply intentions, we don't have choice unless have a separate `Consumer` binding in our `mRPC` application. 20 | Therefore, we expose a `Consumer` binding to listen on the function's output destination; 21 | 5. As long as the target Spring Cloud Stream microservice produces output messages with headers as well, we will be able to restore automatically (thanks to Spring Integration functionality) a mentioned before `TemporaryReplyChannel` header from the `HeaderChannelRegistry` and correlate this reply back to the gateway request; 22 | 6. Since our `mRPC` application can be deployed in several instances, we are going to have several parallel consumers on the same reply destination by default (no explicit consumer group for Spring Cloud Stream) and this is good, since we with this distributed, not connected request-reply scenario we have to ensure that reply comes back to the caller. 23 | However, according to the anonymous subscriptions, all our Spring Cloud Stream consumers are going to get all the replies. 24 | Therefore, we have to filter out those replies which are not for our current instance using a `HeaderChannelRegistry` API against `replyChannel` header name from the received message: if `TemporaryReplyChannel` is not in the current application memory, there is no entry for respective key from the header. 25 | 26 | For simplicity of demonstration, this application is doing just a plain `to upper case` transformation in the target bound Spring Cloud Stream microservice. 27 | This `mRCP` sample is not going to work as is since there is no any binder in the dependencies. 28 | Plus the target Spring Cloud Stream microservice to call is left out of scope for this approach to demonstrate. 29 | However, the test-case brings for us a RabbitMQ binder and starts a Testcontainer for RabbitMQ broker. 30 | In addition, the test dependency includes out-of-the-box `spel-function` and `payload-converter-function` from https://spring.io/projects/spring-cloud-stream-applications#overview[Spring Cloud Stream Applications]. 31 | A composite `byteArrayTextToString|spelFunction` function is exposed as a Spring Cloud Stream microservice on the `upper-case.input` and `upper-case.output` destinations. 32 | The `mRPC` uses those destinations for its request-reply implementation via `StreamBridge` and `Consumer` binding. 33 | 34 | == Spring Boot and Spring Integration resources 35 | 36 | * https://docs.gradle.org[Official Gradle documentation] 37 | * https://docs.spring.io/spring-boot/docs/current/gradle-plugin/reference/html/[Spring Boot Gradle Plugin Reference Guide] 38 | * https://docs.spring.io/spring-boot/docs/current/gradle-plugin/reference/html/#build-image[Create an OCI image] 39 | * https://www.testcontainers.org[Testcontainers] 40 | * https://www.testcontainers.org/modules/rabbitmq[Testcontainers RabbitMQ Module Reference Guide] 41 | * https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#messaging.spring-integration[Spring Integration] 42 | * https://docs.spring.io/spring-integration/reference/html/amqp.html[Spring Integration AMQP Module Reference Guide] 43 | * https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#messaging.amqp[Spring for RabbitMQ] 44 | * https://docs.spring.io/spring-cloud-stream/docs/current/reference/html/spring-cloud-stream.html#spring-cloud-stream-overview-introducing[Cloud Stream] 45 | -------------------------------------------------------------------------------- /mrpc/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | repositories { 3 | gradlePluginPortal() 4 | } 5 | dependencies { 6 | classpath "org.springframework.boot:spring-boot-gradle-plugin:$springBootVersion" 7 | } 8 | } 9 | 10 | plugins { 11 | id 'java' 12 | id 'io.spring.dependency-management' version '1.1.0' 13 | } 14 | 15 | apply plugin: 'org.springframework.boot' 16 | 17 | repositories { 18 | mavenCentral() 19 | maven { url 'https://repo.spring.io/milestone' } 20 | } 21 | 22 | ext { 23 | springCloudVersion = '2022.0.3' 24 | streamApplicationsVersion = '4.0.0-M2' 25 | } 26 | 27 | dependencyManagement { 28 | imports { 29 | mavenBom "org.springframework.cloud:spring-cloud-dependencies:$springCloudVersion" 30 | } 31 | } 32 | 33 | dependencies { 34 | implementation 'org.springframework.boot:spring-boot-starter-integration' 35 | implementation 'org.springframework.cloud:spring-cloud-stream' 36 | 37 | testImplementation "org.springframework.cloud.fn:payload-converter-function:$streamApplicationsVersion" 38 | testImplementation "org.springframework.cloud.fn:spel-function:$streamApplicationsVersion" 39 | testImplementation 'org.springframework.cloud:spring-cloud-stream-binder-rabbit' 40 | testImplementation 'org.springframework.boot:spring-boot-starter-test' 41 | testImplementation 'org.testcontainers:junit-jupiter' 42 | testImplementation 'org.testcontainers:rabbitmq' 43 | } 44 | 45 | tasks.named('test') { 46 | useJUnitPlatform() 47 | } 48 | -------------------------------------------------------------------------------- /mrpc/src/main/java/org/springframework/integration/microservices/mrpc/MrpcApplication.java: -------------------------------------------------------------------------------- 1 | package org.springframework.integration.microservices.mrpc; 2 | 3 | import java.util.function.Consumer; 4 | 5 | import org.springframework.beans.factory.annotation.Value; 6 | import org.springframework.boot.SpringApplication; 7 | import org.springframework.boot.autoconfigure.SpringBootApplication; 8 | import org.springframework.cloud.stream.function.StreamBridge; 9 | import org.springframework.context.annotation.Bean; 10 | import org.springframework.integration.dsl.HeaderEnricherSpec; 11 | import org.springframework.integration.dsl.IntegrationFlow; 12 | import org.springframework.integration.support.channel.HeaderChannelRegistry; 13 | import org.springframework.messaging.Message; 14 | 15 | @SpringBootApplication 16 | public class MrpcApplication { 17 | 18 | public static void main(String[] args) { 19 | SpringApplication.run(MrpcApplication.class, args); 20 | } 21 | 22 | @Bean 23 | IntegrationFlow requestFlow(StreamBridge streamBridge, 24 | @Value("${spring.cloud.stream.mrpc.request.destination}") String requests) { 25 | 26 | return IntegrationFlow.from(UpperCaseGateway.class) 27 | .enrichHeaders(HeaderEnricherSpec::headerChannelsToString) 28 | .handle((message) -> streamBridge.send(requests, message)) 29 | .get(); 30 | } 31 | 32 | @Bean 33 | IntegrationFlow repliesFlow(HeaderChannelRegistry channelRegistry) { 34 | return IntegrationFlow.from(MessageConsumer.class, gateway -> gateway.beanName("replies")) 35 | .filter(Message.class, 36 | (message) -> 37 | channelRegistry.channelNameToChannel( 38 | (message).getHeaders().getReplyChannel().toString()) != null) 39 | .get(); 40 | } 41 | 42 | public interface MessageConsumer extends Consumer> { 43 | 44 | } 45 | 46 | public interface UpperCaseGateway { 47 | 48 | String toUpperCase(String payload); 49 | 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /mrpc/src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.cloud.stream.mrpc.request.destination=upper-case.input 2 | spring.cloud.stream.bindings.replies-in-0.destination=upper-case.output 3 | -------------------------------------------------------------------------------- /mrpc/src/test/java/org/springframework/integration/microservices/mrpc/MrpcApplicationTests.java: -------------------------------------------------------------------------------- 1 | package org.springframework.integration.microservices.mrpc; 2 | 3 | import org.junit.jupiter.api.BeforeAll; 4 | import org.junit.jupiter.api.Test; 5 | import org.testcontainers.containers.RabbitMQContainer; 6 | import org.testcontainers.junit.jupiter.Testcontainers; 7 | 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | import org.springframework.boot.test.context.SpringBootTest; 10 | import org.springframework.boot.test.context.TestConfiguration; 11 | import org.springframework.cloud.fn.spel.SpelFunctionConfiguration; 12 | import org.springframework.context.annotation.Import; 13 | import org.springframework.test.annotation.DirtiesContext; 14 | import org.springframework.test.context.ActiveProfiles; 15 | 16 | import static org.assertj.core.api.Assertions.assertThat; 17 | 18 | @ActiveProfiles("uppercase") 19 | @SpringBootTest 20 | @DirtiesContext 21 | @Testcontainers(disabledWithoutDocker = true) 22 | class MrpcApplicationTests { 23 | 24 | private static final RabbitMQContainer RABBITMQ = new RabbitMQContainer("rabbitmq").withExposedPorts(5672); 25 | 26 | @BeforeAll 27 | static void startContainer() { 28 | RABBITMQ.start(); 29 | System.setProperty("spring.rabbitmq.port", "" + RABBITMQ.getMappedPort(5672)); 30 | } 31 | 32 | @Autowired 33 | MrpcApplication.UpperCaseGateway upperCaseGateway; 34 | 35 | @Test 36 | void mrpcInAction() { 37 | assertThat(this.upperCaseGateway.toUpperCase("hello world")).isEqualTo("HELLO WORLD"); 38 | assertThat(this.upperCaseGateway.toUpperCase("mrpc in action")).isEqualTo("MRPC IN ACTION"); 39 | } 40 | 41 | @TestConfiguration 42 | @Import(SpelFunctionConfiguration.class) 43 | public static class TransformerProcessorConfiguration { 44 | 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /mrpc/src/test/resources/application-uppercase.properties: -------------------------------------------------------------------------------- 1 | spel.function.expression=payload.toUpperCase() 2 | spring.cloud.function.definition=byteArrayTextToString|spelFunction;replies 3 | spring.cloud.stream.function.bindings.byteArrayTextToString|spelFunction-in-0=input 4 | spring.cloud.stream.function.bindings.byteArrayTextToString|spelFunction-out-0=output 5 | spring.cloud.stream.bindings.input.destination=upper-case.input 6 | spring.cloud.stream.bindings.input.group=upper-case-group 7 | spring.cloud.stream.bindings.output.destination=upper-case.output -------------------------------------------------------------------------------- /normalizer/README.adoc: -------------------------------------------------------------------------------- 1 | = Normalizer 2 | 3 | There is no a `Normalizer` pattern on the https://microservices.io/patterns/index.html[Microservice Architecture]. 4 | However, it is explained properly by Gregor Hohpe in the https://www.enterpriseintegrationpatterns.com/Normalizer.html[Enterprise Integration Patterns]. 5 | I find this pattern very relevant to the Microservice Architecture and how those microservices can communicate to each other. 6 | In two words: the goal of `Normalizer` pattern is to convert various data from different sources into something common which can be understood by downstream service. 7 | So, this is some kind of intermediary (or if you wish - proxy) between clients and target service, where clients not fully care about data format the target service expects from us. 8 | Another obstacle could be a refreshed target data format where our client has not been adapted yet. 9 | 10 | According to EIP, the `Normalizer` pattern is a composite component which does not have a strict programming model and can be implemented different ways according to the target domain requirements. 11 | The input endpoint is a `router` pattern which may make a decision from some inbound message header or from a payload type or some other more sophisticated analysis against content like `xpath` or `jsonPath`. 12 | The source data format might be extracted as a logic of a routing sequence. 13 | A routing result might go directly to specific transformer, e.g. XML to JSON. 14 | Or it can be as a chain of several transformation steps, e.g. from YAML to string and then to the file. 15 | The output common data format might be requested from another microservice. 16 | With all of these vague target business requirements it is impossible to implement one single composite component in Spring Integration as it is done with a `Scatter-Gather`. 17 | Therefore, we provide this application as recommendation to follow original EI `Normalizer` pattern with some general ideas what it could be and how it may fit into a broader Microservice Architecture. 18 | 19 | For simplicity and for a most common use-case we implement a routing logic based on a `MessageHeaders.CONTENT_TYPE` header. 20 | This header is typically populated by HTTP request, so we chose a `WebFlux.inboundGateway("/normalize")` as a one of the entry point into our `Normalizer` implementation. 21 | This REST endpoint in our sample can accept `application/xml` and `text/csv` at the moment. 22 | Although having its general nature any content may be received if our router implementation can handle it. 23 | Again, for simplicity and sample readability, we just provide a set of various transformers according to the supported content types. 24 | As the output of this application we chose a JSON representation of a `CreditCardTransaction` domain model. 25 | Of course, those various inputs are just variants of the same model but in different formats. 26 | 27 | The `NormalizerApplicationTests` demonstrates a couple use-cases of producing an XML and CSV data over the REST call and expecting a `CreditCardTransaction` back in common JSON format. 28 | 29 | To demonstrate a flexibility of the approach with Spring Integration we provide another input endpoint - the shared `cardTransactionInput` directory scanning for `.properties` files. 30 | The `Files.inboundAdapter(inputDir)` polls this directory for files, sends a `Properties` object to the router with an `application/properties` content type. 31 | The router then propagate this data into a `propertiesToJson` `IntegrationFlow` which just converts this `Properties` object into a `CreditCardTransaction` JSON result. 32 | In the end this flow replies to the populated before `normalizerFilesOutput.input` `replyChannel` header. 33 | And JSON is written into a `cardTransactionOutput` directory as a file based on a `.properties` input file name with a `.json` extension. 34 | The `NormalizerApplicationTests.normalizePropertiesFile()` unit test demonstrates a processing of writing input file and verification of the content of the output file. 35 | 36 | NOTE: If `cardTransactionInput` and `cardTransactionOutput` are not present in the project, they are created automatically on application start. 37 | After running the `NormalizerApplicationTests`, you can observe `testCardTransaction.properties` input and `testCardTransaction.json` output files in those directories. 38 | 39 | Another external interaction could be implemented via Messaging middleware where transformation `Function` is bound by Spring Cloud Stream, but the routing and conversion logic in this `Normalizer` will remain the same. 40 | 41 | The microservice implementing this pattern could be as a part of https://microservices.io/patterns/apigateway.html[API Gateway]. 42 | 43 | == Spring Boot and Spring Integration resources 44 | 45 | * https://docs.gradle.org[Official Gradle documentation] 46 | * https://docs.spring.io/spring-boot/docs/current/gradle-plugin/reference/html/[Spring Boot Gradle Plugin Reference Guide] 47 | * https://docs.spring.io/spring-boot/docs/current/gradle-plugin/reference/html/#build-image[Create an OCI image] 48 | * https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#messaging.spring-integration[Spring Integration] 49 | * https://docs.spring.io/spring-integration/reference/html/dsl.html[Spring Integration Java DSL Reference Guide] 50 | * https://docs.spring.io/spring-integration/reference/html/http.html[Spring Integration HTTP Module Reference Guide] 51 | * https://docs.spring.io/spring-integration/reference/html/file.html[Spring Integration File Module Reference Guide] 52 | * https://docs.spring.io/spring-integration/reference/html/router.html[Spring Integration Router Implementations Reference Guide] 53 | 54 | 55 | -------------------------------------------------------------------------------- /normalizer/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | repositories { 3 | gradlePluginPortal() 4 | } 5 | dependencies { 6 | classpath "org.springframework.boot:spring-boot-gradle-plugin:$springBootVersion" 7 | } 8 | } 9 | 10 | plugins { 11 | id 'java' 12 | id 'io.spring.dependency-management' version '1.1.0' 13 | } 14 | 15 | apply plugin: 'org.springframework.boot' 16 | 17 | group = 'org.springframework.integration.microservices' 18 | version = '0.0.1-SNAPSHOT' 19 | sourceCompatibility = '17' 20 | 21 | repositories { 22 | mavenCentral() 23 | } 24 | 25 | dependencies { 26 | implementation 'org.springframework.boot:spring-boot-starter-integration' 27 | implementation 'org.springframework.boot:spring-boot-starter-webflux' 28 | implementation 'org.springframework.integration:spring-integration-webflux' 29 | implementation 'org.springframework.integration:spring-integration-file' 30 | implementation 'org.springframework.integration:spring-integration-xml' 31 | implementation 'com.sun.xml.bind:jaxb-impl' 32 | 33 | testImplementation 'org.springframework.boot:spring-boot-starter-test' 34 | testImplementation 'io.projectreactor:reactor-test' 35 | testImplementation 'org.springframework.integration:spring-integration-test' 36 | testImplementation 'org.awaitility:awaitility' 37 | } 38 | 39 | tasks.named('test') { 40 | useJUnitPlatform() 41 | } 42 | -------------------------------------------------------------------------------- /normalizer/src/main/java/org/springframework/integration/microservices/normalizer/CreditCardTransaction.java: -------------------------------------------------------------------------------- 1 | package org.springframework.integration.microservices.normalizer; 2 | 3 | import java.math.BigDecimal; 4 | import java.util.Date; 5 | import java.util.Objects; 6 | 7 | import jakarta.xml.bind.annotation.XmlRootElement; 8 | import jakarta.xml.bind.annotation.XmlType; 9 | 10 | @XmlType 11 | @XmlRootElement(name = "card-transaction") 12 | public class CreditCardTransaction { 13 | 14 | private Long id; 15 | 16 | private String cardNumber; 17 | 18 | private Date transactionDate; 19 | 20 | private BigDecimal amount; 21 | 22 | private String merchant; 23 | 24 | public Long getId() { 25 | return id; 26 | } 27 | 28 | public void setId(Long id) { 29 | this.id = id; 30 | } 31 | 32 | public String getCardNumber() { 33 | return cardNumber; 34 | } 35 | 36 | public void setCardNumber(String cardNumber) { 37 | this.cardNumber = cardNumber; 38 | } 39 | 40 | public Date getTransactionDate() { 41 | return transactionDate; 42 | } 43 | 44 | public void setTransactionDate(Date transactionDate) { 45 | this.transactionDate = transactionDate; 46 | } 47 | 48 | public BigDecimal getAmount() { 49 | return amount; 50 | } 51 | 52 | public void setAmount(BigDecimal amount) { 53 | this.amount = amount; 54 | } 55 | 56 | public String getMerchant() { 57 | return merchant; 58 | } 59 | 60 | public void setMerchant(String merchant) { 61 | this.merchant = merchant; 62 | } 63 | 64 | @Override 65 | public boolean equals(Object o) { 66 | if (this == o) return true; 67 | if (o == null || getClass() != o.getClass()) return false; 68 | CreditCardTransaction that = (CreditCardTransaction) o; 69 | return Objects.equals(id, that.id) 70 | && Objects.equals(cardNumber, that.cardNumber) 71 | && Objects.equals(transactionDate, that.transactionDate) 72 | && Objects.equals(amount, that.amount) 73 | && Objects.equals(merchant, that.merchant); 74 | } 75 | 76 | @Override 77 | public int hashCode() { 78 | return Objects.hash(id, cardNumber, transactionDate, amount, merchant); 79 | } 80 | 81 | } 82 | -------------------------------------------------------------------------------- /normalizer/src/main/java/org/springframework/integration/microservices/normalizer/NormalizerApplication.java: -------------------------------------------------------------------------------- 1 | package org.springframework.integration.microservices.normalizer; 2 | 3 | import java.io.File; 4 | import java.io.IOException; 5 | import java.io.UncheckedIOException; 6 | import java.math.BigDecimal; 7 | import java.util.Date; 8 | import java.util.Map; 9 | import java.util.Properties; 10 | 11 | import com.fasterxml.jackson.databind.ObjectMapper; 12 | 13 | import org.springframework.beans.factory.BeanClassLoaderAware; 14 | import org.springframework.beans.factory.InitializingBean; 15 | import org.springframework.beans.factory.annotation.Value; 16 | import org.springframework.boot.SpringApplication; 17 | import org.springframework.boot.autoconfigure.SpringBootApplication; 18 | import org.springframework.context.annotation.Bean; 19 | import org.springframework.core.io.FileSystemResource; 20 | import org.springframework.core.io.support.PropertiesLoaderUtils; 21 | import org.springframework.http.HttpMethod; 22 | import org.springframework.http.MediaType; 23 | import org.springframework.integration.dsl.IntegrationFlow; 24 | import org.springframework.integration.dsl.IntegrationFlowAdapter; 25 | import org.springframework.integration.dsl.IntegrationFlowDefinition; 26 | import org.springframework.integration.dsl.Transformers; 27 | import org.springframework.integration.file.FileHeaders; 28 | import org.springframework.integration.file.dsl.Files; 29 | import org.springframework.integration.handler.LoggingHandler; 30 | import org.springframework.integration.http.support.DefaultHttpHeaderMapper; 31 | import org.springframework.integration.support.json.Jackson2JsonObjectMapper; 32 | import org.springframework.integration.webflux.dsl.WebFlux; 33 | import org.springframework.integration.xml.transformer.UnmarshallingTransformer; 34 | import org.springframework.messaging.Message; 35 | import org.springframework.messaging.MessageHeaders; 36 | import org.springframework.oxm.jaxb.Jaxb2Marshaller; 37 | import org.springframework.util.StringUtils; 38 | 39 | @SpringBootApplication 40 | public class NormalizerApplication { 41 | 42 | public static void main(String[] args) { 43 | SpringApplication.run(NormalizerApplication.class, args); 44 | } 45 | 46 | @Bean 47 | IntegrationFlow normalizerRest() { 48 | DefaultHttpHeaderMapper headerMapper = DefaultHttpHeaderMapper.outboundMapper(); 49 | headerMapper.setExcludedOutboundStandardRequestHeaderNames(MessageHeaders.CONTENT_TYPE); 50 | 51 | return IntegrationFlow.from(WebFlux.inboundGateway("/normalize") 52 | .requestMapping(mapping -> mapping.methods(HttpMethod.POST)) 53 | .requestPayloadType(String.class) 54 | .headerMapper(headerMapper)) 55 | .channel("normalizer.input") 56 | .get(); 57 | } 58 | 59 | @Bean 60 | IntegrationFlow normalizerFilesInput(@Value("cardTransactionInput") File inputDir) { 61 | return IntegrationFlow.from(Files.inboundAdapter(inputDir), 62 | endpoint -> endpoint.poller(poller -> poller.fixedDelay(1000, 1000))) 63 | .transform(payload -> { 64 | try { 65 | return PropertiesLoaderUtils.loadProperties(new FileSystemResource(payload)); 66 | } 67 | catch (IOException ex) { 68 | throw new UncheckedIOException(ex); 69 | } 70 | }) 71 | .enrichHeaders( 72 | Map.of(MessageHeaders.CONTENT_TYPE, "application/properties", 73 | MessageHeaders.REPLY_CHANNEL, "normalizerFilesOutput.input")) 74 | .channel("normalizer.input") 75 | .get(); 76 | } 77 | 78 | @Bean 79 | IntegrationFlow normalizerFilesOutput(@Value("cardTransactionOutput") File outputDir) { 80 | return f -> f 81 | .handle(Files.outboundAdapter(outputDir) 82 | .fileNameGenerator(message -> 83 | message.getHeaders().get(FileHeaders.FILENAME, String.class).split("\\.")[0] + ".json")); 84 | } 85 | 86 | @Bean 87 | Normalizer normalizer(ObjectMapper objectMapper) { 88 | return new Normalizer(objectMapper); 89 | } 90 | 91 | 92 | public static class Normalizer extends IntegrationFlowAdapter implements BeanClassLoaderAware, InitializingBean { 93 | 94 | private final ObjectMapper objectMapper; 95 | 96 | private final Jaxb2Marshaller jaxb2Marshaller = new Jaxb2Marshaller(); 97 | 98 | public Normalizer(ObjectMapper objectMapper) { 99 | this.objectMapper = objectMapper; 100 | } 101 | 102 | @Override 103 | public void setBeanClassLoader(ClassLoader classLoader) { 104 | this.jaxb2Marshaller.setBeanClassLoader(classLoader); 105 | } 106 | 107 | @Override 108 | public void afterPropertiesSet() throws Exception { 109 | this.jaxb2Marshaller.setClassesToBeBound(CreditCardTransaction.class); 110 | this.jaxb2Marshaller.afterPropertiesSet(); 111 | } 112 | 113 | @Override 114 | protected IntegrationFlowDefinition buildFlow() { 115 | return from("normalizer.input") 116 | .log(LoggingHandler.Level.DEBUG, "org.springframework.integration.microservices.normalizer", "payload") 117 | .route(Message.class, m -> m.getHeaders().get(MessageHeaders.CONTENT_TYPE), 118 | routeMapping -> routeMapping 119 | .subFlowMapping(MediaType.APPLICATION_XML_VALUE, xmlToJson()) 120 | .subFlowMapping("text/csv", csvToJson()) 121 | .subFlowMapping("application/properties", propertiesToJson())); 122 | } 123 | 124 | private IntegrationFlow xmlToJson() { 125 | return f -> f.transform(new UnmarshallingTransformer(this.jaxb2Marshaller)); 126 | } 127 | 128 | private IntegrationFlow csvToJson() { 129 | return f -> f 130 | .transform(payload -> { 131 | String[] attributes = StringUtils.commaDelimitedListToStringArray(payload); 132 | CreditCardTransaction cardTransaction = new CreditCardTransaction(); 133 | cardTransaction.setId(Long.parseLong(attributes[0])); 134 | cardTransaction.setCardNumber(attributes[1]); 135 | cardTransaction.setTransactionDate(new Date(Long.parseLong(attributes[2]))); 136 | cardTransaction.setAmount(new BigDecimal(attributes[3])); 137 | cardTransaction.setMerchant(attributes[4]); 138 | return cardTransaction; 139 | }); 140 | } 141 | 142 | private IntegrationFlow propertiesToJson() { 143 | return f -> f 144 | .transform(payload -> { 145 | CreditCardTransaction cardTransaction = new CreditCardTransaction(); 146 | cardTransaction.setId(Long.parseLong(payload.getProperty("card-transaction.id"))); 147 | cardTransaction.setCardNumber(payload.getProperty("card-transaction.card-number")); 148 | cardTransaction.setTransactionDate(new Date(Long.parseLong(payload.getProperty("card-transaction.date")))); 149 | cardTransaction.setAmount(new BigDecimal(payload.getProperty("card-transaction.amount"))); 150 | cardTransaction.setMerchant(payload.getProperty("card-transaction.merchant")); 151 | return cardTransaction; 152 | }) 153 | .transform(Transformers.toJson(new Jackson2JsonObjectMapper(this.objectMapper))); 154 | } 155 | 156 | } 157 | 158 | } 159 | -------------------------------------------------------------------------------- /normalizer/src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | logging.level.org.springframework.integration.microservices.normalizer=debug 2 | spring.jackson.serialization.indent_output=true 3 | -------------------------------------------------------------------------------- /normalizer/src/test/java/org/springframework/integration/microservices/normalizer/NormalizerApplicationTests.java: -------------------------------------------------------------------------------- 1 | package org.springframework.integration.microservices.normalizer; 2 | 3 | import java.io.File; 4 | import java.io.FileOutputStream; 5 | import java.io.IOException; 6 | import java.math.BigDecimal; 7 | import java.util.Date; 8 | import java.util.Properties; 9 | 10 | import com.fasterxml.jackson.databind.ObjectMapper; 11 | import org.apache.commons.io.FileUtils; 12 | import org.junit.jupiter.api.AfterAll; 13 | import org.junit.jupiter.api.AfterEach; 14 | import org.junit.jupiter.api.BeforeAll; 15 | import org.junit.jupiter.api.BeforeEach; 16 | import org.junit.jupiter.api.Test; 17 | 18 | import org.springframework.beans.factory.annotation.Autowired; 19 | import org.springframework.beans.factory.annotation.Value; 20 | import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; 21 | import org.springframework.boot.test.context.SpringBootTest; 22 | import org.springframework.http.MediaType; 23 | import org.springframework.oxm.jaxb.Jaxb2Marshaller; 24 | import org.springframework.test.annotation.DirtiesContext; 25 | import org.springframework.test.web.reactive.server.WebTestClient; 26 | import org.springframework.xml.transform.StringResult; 27 | 28 | import static org.assertj.core.api.Assertions.assertThat; 29 | import static org.awaitility.Awaitility.await; 30 | 31 | @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) 32 | @AutoConfigureWebTestClient 33 | @DirtiesContext 34 | class NormalizerApplicationTests { 35 | 36 | @Autowired 37 | WebTestClient webTestClient; 38 | 39 | @Value("cardTransactionInput") 40 | File inputDir; 41 | 42 | @Value("cardTransactionOutput") 43 | File outputDir; 44 | 45 | @Autowired 46 | ObjectMapper objectMapper; 47 | 48 | CreditCardTransaction cardTransaction; 49 | 50 | @BeforeAll 51 | static void cleanup() throws IOException { 52 | FileUtils.cleanDirectory(new File("cardTransactionInput")); 53 | FileUtils.cleanDirectory(new File("cardTransactionOutput")); 54 | } 55 | 56 | @BeforeEach 57 | void setup() { 58 | this.cardTransaction = new CreditCardTransaction(); 59 | this.cardTransaction.setId(2134L); 60 | this.cardTransaction.setCardNumber("4576-0120-5553-5675"); 61 | this.cardTransaction.setTransactionDate(new Date()); 62 | this.cardTransaction.setAmount(new BigDecimal("3456.97")); 63 | this.cardTransaction.setMerchant("amazon.com"); 64 | } 65 | 66 | @Test 67 | void normalizeXml() { 68 | StringResult stringResult = new StringResult(); 69 | Jaxb2Marshaller jaxb2Marshaller = new Jaxb2Marshaller(); 70 | jaxb2Marshaller.setClassesToBeBound(CreditCardTransaction.class); 71 | jaxb2Marshaller.marshal(this.cardTransaction, stringResult); 72 | 73 | CreditCardTransaction creditCardTransaction = 74 | this.webTestClient.post() 75 | .uri("/normalize") 76 | .contentType(MediaType.APPLICATION_XML) 77 | .bodyValue(stringResult.toString()) 78 | .exchange() 79 | .expectHeader().contentType(MediaType.APPLICATION_JSON) 80 | .expectBody(CreditCardTransaction.class) 81 | .returnResult() 82 | .getResponseBody(); 83 | 84 | assertThat(creditCardTransaction).isEqualTo(this.cardTransaction); 85 | } 86 | 87 | @Test 88 | void normalizeCsv() { 89 | String csvRecord = 90 | String.valueOf(this.cardTransaction.getId()) + 91 | ',' + 92 | this.cardTransaction.getCardNumber() + 93 | ',' + 94 | this.cardTransaction.getTransactionDate().getTime() + 95 | ',' + 96 | this.cardTransaction.getAmount() + 97 | ',' + 98 | this.cardTransaction.getMerchant(); 99 | 100 | CreditCardTransaction creditCardTransaction = 101 | this.webTestClient.post() 102 | .uri("/normalize") 103 | .contentType(MediaType.parseMediaType("text/csv")) 104 | .bodyValue(csvRecord) 105 | .exchange() 106 | .expectHeader().contentType(MediaType.APPLICATION_JSON) 107 | .expectBody(CreditCardTransaction.class) 108 | .returnResult() 109 | .getResponseBody(); 110 | 111 | assertThat(creditCardTransaction).isEqualTo(this.cardTransaction); 112 | } 113 | 114 | @Test 115 | void normalizePropertiesFile() throws IOException { 116 | Properties cardTransactionProperties = new Properties(); 117 | cardTransactionProperties.setProperty("card-transaction.id", "" + this.cardTransaction.getId()); 118 | cardTransactionProperties.setProperty("card-transaction.card-number", this.cardTransaction.getCardNumber()); 119 | cardTransactionProperties.setProperty("card-transaction.date", "" + this.cardTransaction.getTransactionDate().getTime()); 120 | cardTransactionProperties.setProperty("card-transaction.amount", this.cardTransaction.getAmount().toPlainString()); 121 | cardTransactionProperties.setProperty("card-transaction.merchant", this.cardTransaction.getMerchant()); 122 | 123 | String fileName = "testCardTransaction"; 124 | 125 | File propertiesFile = new File(this.inputDir, fileName + ".properties"); 126 | 127 | try (FileOutputStream out = new FileOutputStream(propertiesFile)) { 128 | cardTransactionProperties.store(out, "The test card transaction"); 129 | } 130 | 131 | File jsonFile = new File(this.outputDir, fileName + ".json"); 132 | 133 | await().until(jsonFile::exists); 134 | 135 | CreditCardTransaction creditCardTransaction = this.objectMapper.readValue(jsonFile, CreditCardTransaction.class); 136 | assertThat(creditCardTransaction).isEqualTo(this.cardTransaction); 137 | } 138 | 139 | } 140 | -------------------------------------------------------------------------------- /outbox/README.adoc: -------------------------------------------------------------------------------- 1 | = Transactional Outbox 2 | 3 | See detailed explanation of this pattern on the https://microservices.io/patterns/data/transactional-outbox.html[Microservice Architecture]. 4 | In two words: we have to publish business data into a messaging middleware only when it is saved to database successfully. 5 | 6 | Fortunately, Spring Integration already has all the components required for this pattern implementation: 7 | 8 | 1. Channel adapters for databases and messaging brokers; 9 | 2. The message store EI pattern implementation for JDBC can be used as a mentioned in the Outbox pattern table for messages to relay; 10 | 3. The `QueueChannel` with transactional poller is exactly a relay to publish stored message in the end. 11 | 12 | The crucial component for an Outbox pattern engine we chose is a https://docs.spring.io/spring-integration/docs/current/reference/html/message-routing.html#router-implementations-recipientlistrouter[Recipient List Router] which is able to send the same message to several channels. 13 | In our case we mark it as transactional to be able to perform and `INSERT` into domain model table (See `ShoppingOrder` entity) and then sequentially into `INT_CHANNEL_MESSAGE` via `QueueChannel` based on the `JdbcChannelMessageStore`. 14 | The poller for this `QueueChannel` on the consumer side tries transactionally to publish a record with a business object into an Apache Kafka topic. 15 | 16 | NOTE: The business data must be `Serializable` since this is currently only a mechanism how `Message` can be stored into `INT_CHANNEL_MESSAGE` table for RDBMS. 17 | 18 | So, if we fail to insert data into domain model table, we won't insert message into `INT_CHANNEL_MESSAGE`. 19 | If publisher to messaging middleware on the consumer fails, the message will remain in the `INT_CHANNEL_MESSAGE` because polling transaction has been rolled back. 20 | 21 | The unit test for this project takes an `OrderGateway` entry point and produces some `ShoppingOrder` entity. 22 | Via `@KafkaListener` on the `orders` we verify that entity has been produced by the Outbox properly (pay attention the entity is (de)serialized as JSON for Apache Kafka interaction). 23 | Then we check that entity is stored successfully into `ORDERS` table and there is no orphaned messages stored in the `INT_CHANNEL_MESSAGE` - Outbox table. 24 | 25 | The JPA and JDBC solution can be changed to any supported NoSQL database, for example https://docs.spring.io/spring-integration/docs/current/reference/html/mongodb.html#mongodb[Spring Integration with MongoDb]. 26 | 27 | == Spring Boot and Spring Integration resources 28 | 29 | * https://docs.gradle.org[Official Gradle documentation] 30 | * https://docs.spring.io/spring-boot/docs/current/gradle-plugin/reference/html/[Spring Boot Gradle Plugin Reference Guide] 31 | * https://docs.spring.io/spring-boot/docs/current/gradle-plugin/reference/html/#build-image[Create an OCI image] 32 | * https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#messaging.kafka[Spring for Apache Kafka] 33 | * https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#data.sql.jpa-and-spring-data[Spring Data JPA] 34 | * https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#messaging.spring-integration[Spring Integration] 35 | * https://docs.spring.io/spring-integration/reference/html/dsl.html[Spring Integration Java DSL Reference Guide] 36 | * https://docs.spring.io/spring-integration/reference/html/jpa.html[Spring Integration JPA Module Reference Guide] 37 | * https://docs.spring.io/spring-integration/reference/html/jdbc.html[Spring Integration JDBC Module Reference Guide] 38 | * https://docs.spring.io/spring-integration/reference/html/kafka.html[Spring Integration Apache Kafka Module Reference Guide] 39 | 40 | 41 | -------------------------------------------------------------------------------- /outbox/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | repositories { 3 | gradlePluginPortal() 4 | } 5 | dependencies { 6 | classpath "org.springframework.boot:spring-boot-gradle-plugin:$springBootVersion" 7 | } 8 | } 9 | 10 | plugins { 11 | id 'java' 12 | id 'io.spring.dependency-management' version '1.1.0' 13 | } 14 | 15 | apply plugin: 'org.springframework.boot' 16 | 17 | group = 'org.springframework.integration.microservices' 18 | version = '0.0.1-SNAPSHOT' 19 | sourceCompatibility = '17' 20 | 21 | repositories { 22 | mavenCentral() 23 | } 24 | 25 | dependencies { 26 | implementation 'org.springframework.boot:spring-boot-starter-data-jpa' 27 | implementation 'org.springframework.boot:spring-boot-starter-integration' 28 | implementation 'org.springframework.integration:spring-integration-kafka' 29 | implementation 'org.springframework.integration:spring-integration-jpa' 30 | implementation 'org.springframework.integration:spring-integration-jdbc' 31 | implementation 'org.springframework.kafka:spring-kafka' 32 | 33 | runtimeOnly 'com.h2database:h2' 34 | 35 | testImplementation 'org.springframework.boot:spring-boot-starter-test' 36 | testImplementation 'org.springframework.integration:spring-integration-test' 37 | testImplementation 'org.springframework.kafka:spring-kafka-test' 38 | 39 | testRuntimeOnly 'org.junit.platform:junit-platform-launcher' 40 | } 41 | 42 | tasks.named('test') { 43 | useJUnitPlatform() 44 | } 45 | -------------------------------------------------------------------------------- /outbox/src/main/java/org/springframework/integration/microservices/outbox/OutboxApplication.java: -------------------------------------------------------------------------------- 1 | package org.springframework.integration.microservices.outbox; 2 | 3 | import javax.sql.DataSource; 4 | 5 | import jakarta.persistence.EntityManager; 6 | 7 | import org.springframework.boot.SpringApplication; 8 | import org.springframework.boot.autoconfigure.SpringBootApplication; 9 | import org.springframework.context.annotation.Bean; 10 | import org.springframework.integration.annotation.Gateway; 11 | import org.springframework.integration.annotation.MessagingGateway; 12 | import org.springframework.integration.dsl.IntegrationFlowAdapter; 13 | import org.springframework.integration.dsl.IntegrationFlowDefinition; 14 | import org.springframework.integration.jdbc.store.JdbcChannelMessageStore; 15 | import org.springframework.integration.jdbc.store.channel.H2ChannelMessageStoreQueryProvider; 16 | import org.springframework.integration.jpa.dsl.Jpa; 17 | import org.springframework.integration.jpa.dsl.JpaUpdatingOutboundEndpointSpec; 18 | import org.springframework.integration.jpa.outbound.JpaOutboundGateway; 19 | import org.springframework.integration.jpa.support.PersistMode; 20 | import org.springframework.integration.kafka.dsl.Kafka; 21 | import org.springframework.integration.kafka.dsl.KafkaProducerMessageHandlerSpec; 22 | import org.springframework.integration.kafka.outbound.KafkaProducerMessageHandler; 23 | import org.springframework.integration.store.ChannelMessageStore; 24 | import org.springframework.kafka.core.KafkaTemplate; 25 | import org.springframework.messaging.MessageHandler; 26 | 27 | @SpringBootApplication 28 | public class OutboxApplication { 29 | 30 | public static void main(String[] args) { 31 | SpringApplication.run(OutboxApplication.class, args); 32 | } 33 | 34 | @Bean 35 | JpaUpdatingOutboundEndpointSpec ordersJpaHandler(EntityManager entityManager) { 36 | return Jpa.outboundAdapter(entityManager).persistMode(PersistMode.PERSIST); 37 | } 38 | 39 | @Bean 40 | KafkaProducerMessageHandlerSpec ordersKafkaProducer(KafkaTemplate kafkaTemplate) { 41 | return Kafka.outboundChannelAdapter(kafkaTemplate).topic("orders"); 42 | } 43 | 44 | @Bean 45 | JdbcChannelMessageStore jdbcChannelMessageStore(DataSource dataSource) { 46 | JdbcChannelMessageStore jdbcChannelMessageStore = new JdbcChannelMessageStore(dataSource); 47 | jdbcChannelMessageStore.setChannelMessageStoreQueryProvider(new H2ChannelMessageStoreQueryProvider()); 48 | return jdbcChannelMessageStore; 49 | } 50 | 51 | @Bean 52 | Outbox outbox(JpaOutboundGateway ordersJpaHandler, KafkaProducerMessageHandler kafkaMessageHandler, 53 | ChannelMessageStore channelMessageStore) { 54 | 55 | return new Outbox(ordersJpaHandler, kafkaMessageHandler, channelMessageStore); 56 | } 57 | 58 | @MessagingGateway 59 | public interface OrderGateway { 60 | 61 | @Gateway(requestChannel = "outbox.input") 62 | void placeOrder(ShoppingOrder order); 63 | 64 | } 65 | 66 | public static class Outbox extends IntegrationFlowAdapter { 67 | 68 | private final MessageHandler businessDataHandler; 69 | 70 | private final MessageHandler messagePublisherHandler; 71 | 72 | private final ChannelMessageStore channelMessageStore; 73 | 74 | 75 | public Outbox(MessageHandler businessDataHandler, MessageHandler messagePublisherHandler, 76 | ChannelMessageStore channelMessageStore) { 77 | 78 | this.businessDataHandler = businessDataHandler; 79 | this.messagePublisherHandler = messagePublisherHandler; 80 | this.channelMessageStore = channelMessageStore; 81 | } 82 | 83 | @Override 84 | protected IntegrationFlowDefinition buildFlow() { 85 | return from("outbox.input") 86 | .routeToRecipients(routes -> routes 87 | .transactional() 88 | .recipientFlow(businessData -> businessData.handle(this.businessDataHandler)) 89 | .recipientFlow(messagingMiddleware -> messagingMiddleware 90 | .channel(c -> c.queue(this.channelMessageStore, "outbox")) 91 | .handle(this.messagePublisherHandler, 92 | e -> e.poller(poller -> poller.fixedDelay(1000).transactional())))); 93 | } 94 | 95 | } 96 | 97 | } 98 | -------------------------------------------------------------------------------- /outbox/src/main/java/org/springframework/integration/microservices/outbox/ShoppingOrder.java: -------------------------------------------------------------------------------- 1 | package org.springframework.integration.microservices.outbox; 2 | 3 | import java.io.Serializable; 4 | import java.math.BigDecimal; 5 | import java.util.Objects; 6 | 7 | import jakarta.persistence.Column; 8 | import jakarta.persistence.Entity; 9 | import jakarta.persistence.GeneratedValue; 10 | import jakarta.persistence.Id; 11 | import jakarta.persistence.Table; 12 | 13 | @Entity 14 | @Table(name = "ORDERS") 15 | public class ShoppingOrder implements Serializable { 16 | 17 | @Id 18 | @GeneratedValue 19 | private long id; 20 | 21 | @Column(nullable = false) 22 | private String name; 23 | 24 | @Column(nullable = false) 25 | private BigDecimal amount; 26 | 27 | public long getId() { 28 | return this.id; 29 | } 30 | 31 | public void setId(long id) { 32 | this.id = id; 33 | } 34 | 35 | public String getName() { 36 | return this.name; 37 | } 38 | 39 | public void setName(String name) { 40 | this.name = name; 41 | } 42 | 43 | public BigDecimal getAmount() { 44 | return this.amount; 45 | } 46 | 47 | public void setAmount(BigDecimal amount) { 48 | this.amount = amount; 49 | } 50 | 51 | @Override 52 | public boolean equals(Object o) { 53 | if (this == o) return true; 54 | if (o == null || getClass() != o.getClass()) return false; 55 | ShoppingOrder that = (ShoppingOrder) o; 56 | return this.id == that.id && this.name.equals(that.name) && this.amount.equals(that.amount); 57 | } 58 | 59 | @Override 60 | public int hashCode() { 61 | return Objects.hash(this.id, this.name, this.amount); 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /outbox/src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.task.scheduling.pool.size=10 2 | spring.kafka.consumer.value-deserializer=org.springframework.kafka.support.serializer.JsonDeserializer 3 | spring.kafka.consumer.properties[spring.json.trusted.packages]=org.springframework.integration.microservices.outbox 4 | spring.kafka.producer.value-serializer=org.springframework.kafka.support.serializer.JsonSerializer 5 | -------------------------------------------------------------------------------- /outbox/src/test/java/org/springframework/integration/microservices/outbox/OutboxApplicationTests.java: -------------------------------------------------------------------------------- 1 | package org.springframework.integration.microservices.outbox; 2 | 3 | import java.math.BigDecimal; 4 | import java.util.List; 5 | import java.util.concurrent.BlockingQueue; 6 | import java.util.concurrent.LinkedBlockingQueue; 7 | import java.util.concurrent.TimeUnit; 8 | 9 | import jakarta.persistence.EntityManager; 10 | import org.junit.jupiter.api.Test; 11 | 12 | import org.springframework.beans.factory.annotation.Autowired; 13 | import org.springframework.boot.test.context.SpringBootTest; 14 | import org.springframework.boot.test.context.TestConfiguration; 15 | import org.springframework.context.annotation.Bean; 16 | import org.springframework.jdbc.core.JdbcTemplate; 17 | import org.springframework.kafka.annotation.KafkaListener; 18 | import org.springframework.kafka.test.context.EmbeddedKafka; 19 | import org.springframework.test.annotation.DirtiesContext; 20 | 21 | import static org.assertj.core.api.Assertions.assertThat; 22 | 23 | @SpringBootTest 24 | @EmbeddedKafka(bootstrapServersProperty = "spring.kafka.bootstrap-servers") 25 | @DirtiesContext 26 | class OutboxApplicationTests { 27 | 28 | @Autowired 29 | OutboxApplication.OrderGateway orderGateway; 30 | 31 | @Autowired 32 | EntityManager entityManager; 33 | 34 | @Autowired 35 | JdbcTemplate jdbcTemplate; 36 | 37 | @Autowired 38 | BlockingQueue consumedOrders; 39 | 40 | @Test 41 | void verifyOutboxPatternOutputs() throws InterruptedException { 42 | ShoppingOrder testOrder = new ShoppingOrder(); 43 | testOrder.setName("test order"); 44 | testOrder.setAmount(new BigDecimal("124.31")); 45 | orderGateway.placeOrder(testOrder); 46 | 47 | ShoppingOrder shoppingOrder = this.consumedOrders.poll(10, TimeUnit.SECONDS); 48 | assertThat(shoppingOrder) 49 | .hasFieldOrPropertyWithValue("name", testOrder.getName()) 50 | .hasFieldOrPropertyWithValue("amount", testOrder.getAmount()) 51 | .extracting("id") 52 | .isNotNull(); 53 | 54 | 55 | List queryResult = 56 | this.entityManager.createQuery("SELECT o FROM ShoppingOrder o", ShoppingOrder.class) 57 | .getResultList(); 58 | 59 | assertThat(queryResult).hasSize(1) 60 | .containsExactly(shoppingOrder); 61 | 62 | assertThat(this.jdbcTemplate.queryForObject("SELECT COUNT(MESSAGE_ID) FROM int_channel_message", Integer.class)) 63 | .isEqualTo(0); 64 | } 65 | 66 | @TestConfiguration 67 | public static class KafkaListenerConfiguration { 68 | 69 | private final BlockingQueue consumedOrders = new LinkedBlockingQueue<>(); 70 | 71 | @Bean 72 | BlockingQueue consumedOrders() { 73 | return this.consumedOrders; 74 | } 75 | 76 | 77 | @KafkaListener(topics = "orders", groupId = "ordersGroup") 78 | void consumeOrder(ShoppingOrder order) { 79 | this.consumedOrders.offer(order); 80 | } 81 | 82 | } 83 | 84 | } 85 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'microservices-patterns-spring-integration' 2 | 3 | rootDir.eachDir { dir -> 4 | if (!(dir.name in ['.git', '.gradle', '.idea', 'build', 'gradle'])) { 5 | include ":${dir.name}" 6 | } 7 | } 8 | --------------------------------------------------------------------------------