INT-4568: Add ReactiveMongoDBMessageSource

JIRA: https://jira.spring.io/browse/INT-4568

Clean up per review and some updates to `mongodb.adoc`
This commit is contained in:
David Turanski
2020-01-16 15:26:25 -05:00
committed by Artem Bilan
parent 4b051684ef
commit 263995fb90
4 changed files with 509 additions and 15 deletions

View File

@@ -0,0 +1,235 @@
/*
* Copyright 2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.integration.mongodb.inbound;
import org.reactivestreams.Publisher;
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.data.mongodb.ReactiveMongoDatabaseFactory;
import org.springframework.data.mongodb.core.ReactiveMongoOperations;
import org.springframework.data.mongodb.core.ReactiveMongoTemplate;
import org.springframework.data.mongodb.core.convert.MongoConverter;
import org.springframework.data.mongodb.core.query.BasicQuery;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.expression.Expression;
import org.springframework.expression.TypeLocator;
import org.springframework.expression.common.LiteralExpression;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.expression.spel.support.StandardTypeLocator;
import org.springframework.integration.endpoint.AbstractMessageSource;
import org.springframework.integration.expression.ExpressionUtils;
import org.springframework.integration.mongodb.support.MongoHeaders;
import org.springframework.util.Assert;
import com.mongodb.DBObject;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
/**
* An instance of {@link org.springframework.integration.core.MessageSource} which returns
* a {@link org.springframework.messaging.Message} with a payload which is the result of
* execution of a {@link Query}. When {@code expectSingleResult} is false (default), the MongoDb
* {@link Query} is executed using {@link ReactiveMongoOperations#find(Query, Class)} method which
* returns a {@link Flux}. The returned {@link Flux} will be used as the payload of the
* {@link org.springframework.messaging.Message} returned by the {@link #receive()}
* method.
* <p>
* When {@code expectSingleResult} is true, the {@link ReactiveMongoOperations#findOne(Query, Class)} is
* used instead, and the message payload will be a {@link Mono} for the single object returned from the
* query.
*
* @author David Turanski
*
* @since 5.3
*/
public class ReactiveMongoDbMessageSource extends AbstractMessageSource<Publisher<?>>
implements ApplicationContextAware {
private final Expression queryExpression;
private Expression collectionNameExpression = new LiteralExpression("data");
private StandardEvaluationContext evaluationContext;
private ReactiveMongoOperations reactiveMongoTemplate;
private MongoConverter mongoConverter;
private ReactiveMongoDatabaseFactory reactiveMongoDatabaseFactory;
private Class<?> entityClass = DBObject.class;
private boolean expectSingleResult = false;
private ApplicationContext applicationContext;
private volatile boolean initialized = false;
/**
* Create an instance with the provided {@link ReactiveMongoDatabaseFactory} and SpEL expression
* which should resolve to a MongoDb 'query' string (see https://www.mongodb.org/display/DOCS/Querying).
* The 'queryExpression' will be evaluated on every call to the {@link #receive()} method.
* @param reactiveMongoDatabaseFactory The reactiveMongoDatabaseFactory factory.
* @param queryExpression The query expression.
*/
public ReactiveMongoDbMessageSource(ReactiveMongoDatabaseFactory reactiveMongoDatabaseFactory,
Expression queryExpression) {
Assert.notNull(reactiveMongoDatabaseFactory, "'reactiveMongoDatabaseFactory' must not be null");
Assert.notNull(queryExpression, "'queryExpression' must not be null");
this.reactiveMongoDatabaseFactory = reactiveMongoDatabaseFactory;
this.queryExpression = queryExpression;
}
/**
* Create an instance with the provided {@link ReactiveMongoOperations} and SpEL expression
* which should resolve to a Mongo 'query' string (see https://www.mongodb.org/display/DOCS/Querying).
* It assumes that the {@link ReactiveMongoOperations} is fully initialized and ready to be used.
* The 'queryExpression' will be evaluated on every call to the {@link #receive()} method.
* @param reactiveMongoTemplate The reactive Mongo template.
* @param queryExpression The query expression.
*/
public ReactiveMongoDbMessageSource(ReactiveMongoOperations reactiveMongoTemplate, Expression queryExpression) {
Assert.notNull(reactiveMongoTemplate, "'reactiveMongoTemplate' must not be null");
Assert.notNull(queryExpression, "'queryExpression' must not be null");
this.reactiveMongoTemplate = reactiveMongoTemplate;
this.queryExpression = queryExpression;
}
/**
* Allow you to set the type of the entityClass that will be passed to the
* {@link ReactiveMongoTemplate#find(Query, Class)} or {@link ReactiveMongoTemplate#findOne(Query, Class)}
* method.
* Default is {@link DBObject}.
* @param entityClass The entity class.
*/
public void setEntityClass(Class<?> entityClass) {
Assert.notNull(entityClass, "'entityClass' must not be null");
this.entityClass = entityClass;
}
/**
* Allow you to manage which find* method to invoke on {@link ReactiveMongoTemplate}.
* Default is 'false', which means the {@link #receive()} method will use
* the {@link ReactiveMongoTemplate#find(Query, Class)} method. If set to 'true',
* {@link #receive()} will use {@link ReactiveMongoTemplate#findOne(Query, Class)},
* and the payload of the returned {@link org.springframework.messaging.Message}
* will be the returned target Object of type
* identified by {@link #entityClass} instead of a List.
* @param expectSingleResult true if a single result is expected.
*/
public void setExpectSingleResult(boolean expectSingleResult) {
this.expectSingleResult = expectSingleResult;
}
/**
* Set the SpEL {@link Expression} that should resolve to a collection name
* used by the {@link Query}. The resulting collection name will be included
* in the {@link MongoHeaders#COLLECTION_NAME} header.
* @param collectionNameExpression The collection name expression.
*/
public void setCollectionNameExpression(Expression collectionNameExpression) {
Assert.notNull(collectionNameExpression, "'collectionNameExpression' must not be null");
this.collectionNameExpression = collectionNameExpression;
}
/**
* Allow you to provide a custom {@link MongoConverter} used to assist in deserialization
* data read from MongoDb. Only allowed if this instance was constructed with a
* {@link ReactiveMongoDatabaseFactory}.
* @param mongoConverter The mongo converter.
*/
public void setMongoConverter(MongoConverter mongoConverter) {
Assert.isNull(this.reactiveMongoTemplate,
"'mongoConverter' can not be set when instance was constructed with ReactiveMongoTemplate");
this.mongoConverter = mongoConverter;
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
@Override
public String getComponentType() {
return "mongo:reactive-inbound-channel-adapter";
}
@Override
protected void onInit() {
this.evaluationContext = ExpressionUtils.createStandardEvaluationContext(getBeanFactory());
TypeLocator typeLocator = this.evaluationContext.getTypeLocator();
if (typeLocator instanceof StandardTypeLocator) {
//Register MongoDB query API package so FQCN can be avoided in query-expression.
((StandardTypeLocator) typeLocator).registerImport("org.springframework.data.mongodb.core.query");
}
if (this.reactiveMongoTemplate == null) {
ReactiveMongoTemplate template =
new ReactiveMongoTemplate(this.reactiveMongoDatabaseFactory, this.mongoConverter);
if (this.applicationContext != null) {
template.setApplicationContext(this.applicationContext);
}
this.reactiveMongoTemplate = template;
}
this.initialized = true;
}
/**
* Execute a {@link Query} returning its results as the Message payload.
* The payload can be either {@link Flux} or {@link Mono} of objects of type
* identified by {@link #entityClass}, or a single element of type identified by {@link #entityClass}
* based on the value of {@link #expectSingleResult} attribute which defaults to 'false' resulting
* {@link org.springframework.messaging.Message} with payload of type
* {@link Flux}. The collection name used in the
* query will be provided in the {@link MongoHeaders#COLLECTION_NAME} header.
*/
@Override
public Object doReceive() {
Assert.isTrue(this.initialized, "This class is not yet initialized. Invoke its afterPropertiesSet() method");
Object value = this.queryExpression.getValue(this.evaluationContext);
Assert.notNull(value, "'queryExpression' must not evaluate to null");
Query query = null;
if (value instanceof String) {
query = new BasicQuery((String) value);
}
else if (value instanceof Query) {
query = ((Query) value);
}
else {
throw new IllegalStateException("'queryExpression' must evaluate to String " +
"or org.springframework.data.mongodb.core.query.Query, but not: " + query);
}
String collectionName = this.collectionNameExpression.getValue(this.evaluationContext, String.class);
Assert.notNull(collectionName, "'collectionNameExpression' must not evaluate to null");
Object result;
if (this.expectSingleResult) {
result = this.reactiveMongoTemplate.findOne(query, this.entityClass, collectionName);
}
else {
result = this.reactiveMongoTemplate.find(query, this.entityClass, collectionName);
}
return getMessageBuilderFactory()
.withPayload(result)
.setHeader(MongoHeaders.COLLECTION_NAME, collectionName);
}
}

View File

@@ -0,0 +1,214 @@
/*
* Copyright 2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.integration.mongodb.inbound;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import org.bson.conversions.Bson;
import org.junit.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.data.mongodb.ReactiveMongoDatabaseFactory;
import org.springframework.data.mongodb.core.ReactiveMongoTemplate;
import org.springframework.data.mongodb.core.convert.MappingMongoConverter;
import org.springframework.data.mongodb.core.mapping.MongoMappingContext;
import org.springframework.expression.Expression;
import org.springframework.expression.common.LiteralExpression;
import org.springframework.integration.mongodb.rules.MongoDbAvailable;
import org.springframework.integration.mongodb.rules.MongoDbAvailableTests;
import com.mongodb.BasicDBObject;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
/**
* @author David Turanski
*
* @since 5.3
*/
public class ReactiveMongoDbMessageSourceTests extends MongoDbAvailableTests {
@Test
public void withNullMongoDBFactory() {
assertThatIllegalArgumentException()
.isThrownBy(() -> new ReactiveMongoDbMessageSource((ReactiveMongoDatabaseFactory) null,
mock(Expression.class)));
}
@Test
public void withNullMongoTemplate() {
assertThatIllegalArgumentException()
.isThrownBy(() -> new ReactiveMongoDbMessageSource((ReactiveMongoTemplate) null,
mock(Expression.class)));
}
@Test
public void withNullQueryExpression() {
assertThatIllegalArgumentException()
.isThrownBy(() -> new ReactiveMongoDbMessageSource(mock(ReactiveMongoDatabaseFactory.class),
null));
}
@Test
@MongoDbAvailable
@SuppressWarnings("unchecked")
public void validateSuccessfulQueryWithSingleElementFluxOfDbObject() {
ReactiveMongoDatabaseFactory reactiveMongoDatabaseFactory = this.prepareReactiveMongoFactory();
ReactiveMongoTemplate template = new ReactiveMongoTemplate(reactiveMongoDatabaseFactory);
waitFor(template.save(this.createPerson(), "data"));
Expression queryExpression = new LiteralExpression("{'name' : 'Oleg'}");
ReactiveMongoDbMessageSource messageSource = new ReactiveMongoDbMessageSource(reactiveMongoDatabaseFactory,
queryExpression);
messageSource.setBeanFactory(mock(BeanFactory.class));
messageSource.afterPropertiesSet();
StepVerifier.create((Flux<BasicDBObject>) messageSource.receive().getPayload())
.assertNext(basicDBObject -> assertThat(basicDBObject.get("name")).isEqualTo("Oleg"))
.verifyComplete();
}
@Test
@MongoDbAvailable
@SuppressWarnings("unchecked")
public void validateSuccessfulQueryWithSingleElementFluxOfPerson() {
ReactiveMongoDatabaseFactory reactiveMongoDatabaseFactory = this.prepareReactiveMongoFactory();
ReactiveMongoTemplate template = new ReactiveMongoTemplate(reactiveMongoDatabaseFactory);
waitFor(template.save(this.createPerson(), "data"));
Expression queryExpression = new LiteralExpression("{'name' : 'Oleg'}");
ReactiveMongoDbMessageSource messageSource = new ReactiveMongoDbMessageSource(reactiveMongoDatabaseFactory,
queryExpression);
messageSource.setBeanFactory(mock(BeanFactory.class));
messageSource.afterPropertiesSet();
messageSource.setEntityClass(Person.class);
StepVerifier.create((Flux<Person>) messageSource.receive().getPayload())
.assertNext(person -> assertThat(person.getName()).isEqualTo("Oleg"))
.verifyComplete();
}
@Test
@MongoDbAvailable
public void validateSuccessfulQueryWithMultipleElements() {
final List<String> names = new ArrayList<>(Arrays.asList("Manny", "Moe", "Jack"));
StepVerifier.create(queryMultipleElements(new LiteralExpression("{'address.state' : 'PA'}")))
.expectNextMatches(person -> {
names.remove(person.getName());
return names.size() == 2;
})
.expectNextMatches(person -> {
names.remove(person.getName());
return names.size() == 1;
})
.expectNextMatches(person -> {
names.remove(person.getName());
return names.size() == 0;
})
.verifyComplete();
}
@Test
@MongoDbAvailable
public void validateSuccessfulQueryWithEmptyReturn() {
StepVerifier.create(queryMultipleElements(new LiteralExpression("{'address.state' : 'NJ'}")))
.verifyComplete();
}
@Test
@MongoDbAvailable
@SuppressWarnings("unchecked")
public void validateSuccessfulQueryWithCustomConverter() {
MappingMongoConverter converter = new ReactiveTestMongoConverter(this.prepareReactiveMongoFactory(),
new MongoMappingContext());
converter.afterPropertiesSet();
converter = spy(converter);
StepVerifier.create(queryMultipleElements(new LiteralExpression("{'address.state' : 'PA'}"),
Optional.of(converter))).expectNextCount(3)
.verifyComplete();
verify(converter, times(3)).read((Class<Person>) Mockito.any(), Mockito.any(Bson.class));
}
@Test
@MongoDbAvailable
@SuppressWarnings("unchecked")
public void validatePipelineInModifyOut() {
ReactiveMongoDatabaseFactory reactiveMongoDatabaseFactory = this.prepareReactiveMongoFactory();
ReactiveMongoTemplate template = new ReactiveMongoTemplate(reactiveMongoDatabaseFactory);
waitFor(template.save(BasicDBObject.parse("{'name' : 'Manny', 'id' : 1}"), "data"));
Expression queryExpression = new LiteralExpression("{'name' : 'Manny'}");
ReactiveMongoDbMessageSource messageSource = new ReactiveMongoDbMessageSource(reactiveMongoDatabaseFactory,
queryExpression);
messageSource.setExpectSingleResult(true);
messageSource.setBeanFactory(mock(BeanFactory.class));
messageSource.afterPropertiesSet();
BasicDBObject result = waitFor((Mono<BasicDBObject>) messageSource.receive().getPayload());
Object id = result.get("_id");
result.put("company", "PepBoys");
waitFor(template.save(result, "data"));
result = waitFor((Mono<BasicDBObject>) messageSource.receive().getPayload());
assertThat(result.get("_id")).isEqualTo(id);
}
private Flux<Person> queryMultipleElements(Expression queryExpression) {
return this.queryMultipleElements(queryExpression, Optional.empty());
}
@SuppressWarnings("unchecked")
private Flux<Person> queryMultipleElements(Expression queryExpression, Optional<MappingMongoConverter> converter) {
ReactiveMongoDatabaseFactory reactiveMongoDatabaseFactory = this.prepareReactiveMongoFactory();
ReactiveMongoTemplate template = new ReactiveMongoTemplate(reactiveMongoDatabaseFactory);
waitFor(template.save(this.createPerson("Manny"), "data"));
waitFor(template.save(this.createPerson("Moe"), "data"));
waitFor(template.save(this.createPerson("Jack"), "data"));
ReactiveMongoDbMessageSource messageSource = new ReactiveMongoDbMessageSource(reactiveMongoDatabaseFactory,
queryExpression);
messageSource.setBeanFactory(mock(BeanFactory.class));
messageSource.setEntityClass(Person.class);
converter.ifPresent(messageSource::setMongoConverter);
messageSource.afterPropertiesSet();
return (Flux<Person>) messageSource.receive().getPayload();
}
private static <T> T waitFor(Mono<T> mono) {
return mono.block(Duration.ofSeconds(10));
}
}

View File

@@ -38,7 +38,6 @@ import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty;
import org.springframework.integration.mongodb.outbound.MessageCollectionCallback;
import org.springframework.messaging.Message;
import com.mongodb.MongoClientSettings;
import com.mongodb.MongoException;
import com.mongodb.client.MongoClients;
import com.mongodb.client.MongoCollection;
@@ -60,16 +59,11 @@ public abstract class MongoDbAvailableTests {
public MongoDbAvailableRule mongoDbAvailableRule = new MongoDbAvailableRule();
public static final MongoDbFactory MONGO_DATABASE_FACTORY =
new SimpleMongoClientDbFactory(
MongoClients.create(
MongoClientSettings.builder().build()),
"test");
new SimpleMongoClientDbFactory(MongoClients.create(), "test");
public static final ReactiveMongoDatabaseFactory REACTIVE_MONGO_DATABASE_FACTORY =
new SimpleReactiveMongoDatabaseFactory(
com.mongodb.reactivestreams.client.MongoClients.create(
MongoClientSettings.builder().build()),
"test");
com.mongodb.reactivestreams.client.MongoClients.create(), "test");
protected MongoDbFactory prepareMongoFactory(String... additionalCollectionsToDrop) {
cleanupCollections(MONGO_DATABASE_FACTORY, additionalCollectionsToDrop);

View File

@@ -28,9 +28,32 @@ To download, install, and run MongoDB, see the https://www.mongodb.org/downloads
[[mongodb-connection]]
=== Connecting to MongoDb
==== Blocking or Reactive?
Beginning with version 5.3, Spring Integration provides support for reactive MongoDB drivers to enable non-blocking I/O when accessing MongoDB.
To enable reactive support, add the MongoDB reactive streams driver to your dependencies:
.Maven
[source, xml, subs="normal"]
----
<dependency>
<groupId>org.mongodb</groupId>
<artifactId>mongodb-driver-reactivestreams</artifactId>
<version>1.12.0</version>
</dependency>
----
.Gradle
[source, groovy, subs="normal"]
----
compile "org.mongodb:mongodb-driver-reactivestreams:1.12.0"
----
To begin interacting with MongoDB, you first need to connect to it.
Spring Integration builds on the support provided by another Spring project, https://projects.spring.io/spring-data-mongodb/[Spring Data MongoDB].
It provides a factory class called `MongoDbFactory`, which simplifies integration with the MongoDB Client API.
It provides factory classes called `MongoDbFactory` and `ReactiveMongoDatabaseFactory`, which simplify integration with the MongoDB Client API.
TIP: Spring Data provides provides the blocking MongoDB driver by default but you may opt-in for reactive usage by including the above dependency.
==== Using `MongoDbFactory`
@@ -68,7 +91,7 @@ The following example shows how to use `SimpleMongoDbFactory`, the out-of-the-bo
====
[source,java]
----
MongoDbFactory mongoDbFactory = new SimpleMongoDbFactory(new Mongo(), "test");
MongoDbFactory mongoDbFactory = new SimpleMongoDbFactory(com.mongodb.client.MongoClients.create(), "test");
----
====
@@ -79,7 +102,7 @@ The following example shows how to use `SimpleMongoDbFactory` in XML configurati
----
<bean id="mongoDbFactory" class="o.s.data.mongodb.core.SimpleMongoDbFactory">
<constructor-arg>
<bean class="com.mongodb.Mongo"/>
<bean class="com.mongodb.client.MongoClients" factory-method="create"/>
</constructor-arg>
<constructor-arg value="test"/>
</bean>
@@ -87,9 +110,36 @@ The following example shows how to use `SimpleMongoDbFactory` in XML configurati
====
`SimpleMongoDbFactory` takes two arguments: a `Mongo` instance and a `String` that specifies the name of the database.
If you need to configure properties such as `host`, `port`, and others, you can pass those by using one of the constructors provided by the underlying `Mongo` class.
If you need to configure properties such as `host`, `port`, and others, you can pass those by using one of the constructors provided by the underlying `MongoClients` class.
For more information on how to configure MongoDB, see the https://docs.spring.io/spring-data/data-mongo/docs/current/reference/html/[Spring-Data-MongoDB] reference.
==== Using `ReactiveMongoDatabaseFactory`
To connect to MongoDB with the reactive driver, you can use an implementation of the `ReactiveMongoDatabaseFactory` interface.
The following example shows how to use `SimpleReactiveMongoDatabaseFactory`, the out-of-the-box implementation, in Java:
====
[source,java]
----
new SimpleReactiveMongoDatabaseFactory(com.mongodb.reactivestreams.client.MongoClients.create(), "test");
----
====
The following example shows how to use `SimpleReactiveMongoDatabaseFactory` in XML configuration:
====
[source,xml]
----
<bean id="mongoDbFactory" class="o.s.data.mongodb.core.SimpleReactiveMongoDatabaseFactory">
<constructor-arg>
<bean class="com.mongodb.reactivestreams.client.MongoClients" factory-method="create"/>
</constructor-arg>
<constructor-arg value="test"/>
</bean>
----
====
[[mongodb-message-store]]
=== MongoDB Message Store
@@ -471,9 +521,10 @@ private MongoDbOutboundGatewaySpec collectionCallbackOutboundGateway() {
[[mongodb-reactive-channel-adapters]]
=== MongoDB Reactive Channel Adapters
Starting with version 5.3, the `ReactiveMongoDbStoringMessageHandler` implementation is provided.
It is based on the `ReactiveMongoOperations` from Spring Data and requires a `org.mongodb:mongodb-driver-reactivestreams` dependency.
This is an implementation of the `ReactiveMessageHandler` which is supported natively in the framework when reactive streams composition is involved in the integration flow definition.
Starting with version 5.3, the `ReactiveMongoDbStoringMessageHandler` and `ReactiveMongoDbMessageSource` implementations are provided.
They are based on the `ReactiveMongoOperations` from Spring Data and requires a `org.mongodb:mongodb-driver-reactivestreams` dependency.
The `ReactiveMongoDbStoringMessageHandler` is an implementation of the `ReactiveMessageHandler` which is supported natively in the framework when reactive streams composition is involved in the integration flow definition.
See more information in the <<./reactive-streams.adoc/reactive-message-handler,ReactiveMessageHandler>>.
From configuration perspective there is no difference with many other standard channel adapters.