BATCH-2267: added ItemReader and ItemWriter implementations for elasticsearch

This commit is contained in:
Hasnain Javed
2014-07-08 11:18:28 +05:00
parent 581cb5e6fa
commit c59aa883d8
7 changed files with 725 additions and 0 deletions

4
spring-batch-elasticsearch/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/target
.classpath
.project
.settings

View File

@@ -0,0 +1,63 @@
# spring-batch-elasticsearch
ItemReader and ItemWriter implementations for Elasticsearch
## To index a document via ElasticsearchItemWriter
@Document(indexName="some_index", type="some_type")
public class SomeClass {
// field(s) with getter(s) and setter(s)
}
Create an item processor
public class SampleItemProcess implements ItemProcessor<Object, IndexQuery> {
@Override
public IndexQuery process(Object item) throws Exception {
SomeClass someClass = new SomeClass();
// pouplate someClass from item (Object)
IndexQueryBuilder builder = new IndexQueryBuilder();
builder.withObject(someClass);
// use other methods on builder as required
return builder.build();
}
}
## Configuration for reading/writing documents from/to Elasticsearch
@Configuration
public class ReaderWriterConfig {
@Bean
public ElasticsearchItemReader<SomeInputClass> elasticsearchItemReader() {
return new ElasticsearchItemReader<>(elasticsearchOperations(), query(), SomeInputClass.class);
}
@Bean
public ElasticsearchItemWriter elasticsearchItemWriter() {
return new ElasticsearchItemWriter(elasticsearchOperations());
}
@Bean
public SearchQuery query() {
NativeSearchQueryBuilder builder = new NativeSearchQueryBuilder();
// create query as required using the methods on the builder object
return builder.build();
}
@Bean
public ElasticsearchOperations elasticsearchOperations() {
// configure and return elastic search template
}
}
##### NOTE
The Pageable object from the Query object will be used for paged requests. Setting the page and pageSize fields (inherited from AbstractPaginatedDataItemReader) will have no effect.

View File

@@ -0,0 +1,62 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.springframewor.batch</groupId>
<artifactId>spring-batch-elasticsearch</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<java.version>1.7</java.version>
<spring-batch.version>3.0.1.RELEASE</spring-batch.version>
<spring-data-elasticsearch.version>1.0.1.RELEASE</spring-data-elasticsearch.version>
<mockito.verion>1.9.5</mockito.verion>
<junit.verion>4.11</junit.verion>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>2.5.1</version>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
</configuration>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>org.springframework.batch</groupId>
<artifactId>spring-batch-core</artifactId>
<version>${spring-batch.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-elasticsearch</artifactId>
<version>${spring-data-elasticsearch.version}</version>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-all</artifactId>
<version>${mockito.verion}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>${junit.verion}</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,89 @@
/*
* Copyright 2002-2014 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
*
* http://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.batch.item.data;
import static org.slf4j.LoggerFactory.getLogger;
import static org.springframework.util.Assert.state;
import static org.springframework.util.ClassUtils.getShortName;
import java.util.Iterator;
import org.slf4j.Logger;
import org.springframework.batch.item.ExecutionContext;
import org.springframework.batch.item.ItemReader;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.data.domain.Pageable;
import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
import org.springframework.data.elasticsearch.core.query.SearchQuery;
/**
* <p>
* Restartable {@link ItemReader} that reads documents from Elasticsearch
* via a paging technique.
* </p>
*
* <p>
* It executes the query object {@link SearchQuery} to retrieve the requested
* documents. The query is executed using paged requests specified in the
* {@link org.springframework.data.elasticsearch.core.query.AbstractQuery#setPageable(Pageable pageable)}.
* Additional pages are requested as needed to provide data when the {@link #read()} method is called.
* </p>
*
* <p>
* The implementation is thread-safe between calls to
* {@link #open(ExecutionContext)}, but remember to use <code>saveState=false</code>
* if used in a multi-threaded client (no restart available).
* </p>
*
*
* @author Hasnain Javed
* @since 3.x.x
*/
public class ElasticsearchItemReader<T> extends AbstractPaginatedDataItemReader<T> implements InitializingBean {
private final Logger logger;
private final ElasticsearchOperations elasticsearchOperations;
private final SearchQuery query;
private final Class<? extends T> targetType;
public ElasticsearchItemReader(ElasticsearchOperations elasticsearchOperations, SearchQuery query, Class<? extends T> targetType) {
setName(getShortName(getClass()));
logger = getLogger(getClass());
this.elasticsearchOperations = elasticsearchOperations;
this.query = query;
this.targetType = targetType;
}
@Override
public void afterPropertiesSet() throws Exception {
state(elasticsearchOperations != null, "An ElasticsearchOperations implementation is required.");
state(query != null, "A query is required.");
state(targetType != null, "A target type to convert the input into is required.");
}
@Override
@SuppressWarnings("unchecked")
protected Iterator<T> doPageRead() {
logger.debug("executing query {}", query.getQuery());
return (Iterator<T>)elasticsearchOperations.queryForList(query, targetType).iterator();
}
}

View File

@@ -0,0 +1,161 @@
/*
* Copyright 2002-2014 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
*
* http://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.batch.item.data;
import static java.lang.String.valueOf;
import static java.util.UUID.randomUUID;
import static org.slf4j.LoggerFactory.getLogger;
import static org.springframework.transaction.support.TransactionSynchronizationManager.bindResource;
import static org.springframework.transaction.support.TransactionSynchronizationManager.getResource;
import static org.springframework.transaction.support.TransactionSynchronizationManager.hasResource;
import static org.springframework.transaction.support.TransactionSynchronizationManager.isActualTransactionActive;
import static org.springframework.transaction.support.TransactionSynchronizationManager.registerSynchronization;
import static org.springframework.transaction.support.TransactionSynchronizationManager.unbindResource;
import static org.springframework.util.Assert.state;
import static org.springframework.util.CollectionUtils.isEmpty;
import java.util.List;
import org.slf4j.Logger;
import org.springframework.batch.item.ItemWriter;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
import org.springframework.data.elasticsearch.core.query.IndexQuery;
import org.springframework.transaction.support.TransactionSynchronizationAdapter;
/**
* <p>
* A {@link ItemWriter} implementation that writes to Elasticsearch store using an implementation of Spring Data's
* {@link ElasticsearchOperations}. Similar to MongoDB, Elasticsearch is not a transactional store,
* the strategy for writing data is similar to {@link MongoItemWriter}. There is no roll back if an error occurs
* </p>
*
* <p>
* This writer is thread-safe once all properties are set (normal singleton behavior) so it can be used in multiple
* concurrent transactions.
* </p>
*
* @author Hasnain Javed
* @since 3.x.x
*
*/
public class ElasticsearchItemWriter implements ItemWriter<IndexQuery>, InitializingBean {
private final String dataKey;
private final Logger logger;
private ElasticsearchOperations elasticsearchOperations;
private boolean delete;
public ElasticsearchItemWriter(ElasticsearchOperations elasticsearchOperations) {
super();
dataKey = valueOf(randomUUID());
logger = getLogger(getClass());
delete = false;
this.elasticsearchOperations = elasticsearchOperations;
}
/**
* A flag for removing items given to the writer. Default value is set to false indicating that the items will be saved.
* otherwise, the items will be removed.
*
* @param delete flag
*/
public void setDelete(boolean delete) {
this.delete = delete;
}
@Override
public void afterPropertiesSet() throws Exception {
state(elasticsearchOperations != null, "An ElasticsearchOperations implementation is required.");
}
@Override
public void write(List<? extends IndexQuery> items) throws Exception {
if(isActualTransactionActive()) {
addToBuffer(items);
}else {
writeItems(items);
}
}
/**
* Writes to Elasticsearch via the template.
* This can be overridden by a subclass if required.
*
* @param items the list of items to be indexed.
*/
protected void writeItems(List<? extends IndexQuery> items) {
if(isEmpty(items)) {
logger.warn("no items to write to elasticsearch. list is empty or null");
}else {
for(IndexQuery item : items) {
if(delete) {
String id = item.getId();
logger.debug("deleting item with id {}", id);
elasticsearchOperations.delete(item.getObject().getClass(), id);
}else {
String id = elasticsearchOperations.index(item);
logger.debug("added item to elasticsearch with id {}", id);
}
}
}
}
@SuppressWarnings("unchecked")
private void addToBuffer(List<? extends IndexQuery> items) {
if(hasResource(dataKey)) {
logger.debug("appending items to buffer under key {}", dataKey);
List<IndexQuery> buffer = (List<IndexQuery>) getResource(dataKey);
buffer.addAll(items);
}else {
logger.debug("adding items to buffer under key {}", dataKey);
bindResource(dataKey, items);
registerSynchronization(new TransactionSynchronizationCallbackImpl());
}
}
private class TransactionSynchronizationCallbackImpl extends TransactionSynchronizationAdapter {
@SuppressWarnings("unchecked")
@Override
public void beforeCommit(boolean readOnly) {
List<IndexQuery> items = (List<IndexQuery>) getResource(dataKey);
if(!isEmpty(items)) {
if(!readOnly) {
writeItems(items);
}else{
logger.warn("can not write items to elasticsearch as transaction is read only");
}
}else {
logger.warn("no items to write to elasticsearch. list is empty or null");
}
}
@Override
public void afterCompletion(int status) {
if(hasResource(dataKey)) {
logger.debug("removing items from buffer under key {}", dataKey);
unbindResource(dataKey);
}
}
}
}

View File

@@ -0,0 +1,102 @@
/*
* Copyright 2002-2014 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
*
* http://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.batch.item.data;
import static java.util.Arrays.asList;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.fail;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.MockitoAnnotations.initMocks;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.mockito.Mock;
import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
import org.springframework.data.elasticsearch.core.query.NativeSearchQueryBuilder;
import org.springframework.data.elasticsearch.core.query.SearchQuery;
public class ElasticsearchItemReaderTest {
private ElasticsearchItemReader<Object> reader;
@Mock
private ElasticsearchOperations elasticsearchOperations;
private SearchQuery query;
@Before
public void setUp() throws Exception {
initMocks(this);
query = new NativeSearchQueryBuilder().build();
reader = new ElasticsearchItemReader<Object>(elasticsearchOperations, query, Object.class);
reader.afterPropertiesSet();
}
@After
public void tearDown() {
query = null;
elasticsearchOperations = null;
}
@Test(expected=IllegalStateException.class)
public void shouldFailAssertionOnNullElasticsearchOperations() throws Exception {
try {
new ElasticsearchItemReader<Object>(null, null, null).afterPropertiesSet();
fail("Assertion should have thrown exception on null ElasticsearchOperations");
}catch(IllegalStateException e) {
assertEquals("An ElasticsearchOperations implementation is required.", e.getMessage());
throw e;
}
}
@Test(expected=IllegalStateException.class)
public void shouldFailAssertionOnNullQuery() throws Exception {
try {
new ElasticsearchItemReader<Object>(elasticsearchOperations, null, null).afterPropertiesSet();
fail("Assertion shold have thrown exception on null Query");
}catch(IllegalStateException e) {
assertEquals("A query is required.", e.getMessage());
throw e;
}
}
@Test(expected=IllegalStateException.class)
public void shouldFailAssertionOnNullTargetType() throws Exception {
try {
new ElasticsearchItemReader<Object>(elasticsearchOperations, query, null).afterPropertiesSet();
fail("Assertion shold have thrown exception on null Target Type");
}catch(IllegalStateException e) {
assertEquals("A target type to convert the input into is required.", e.getMessage());
throw e;
}
}
@Test
public void shouldQueryForList() {
when(elasticsearchOperations.queryForList(query, Object.class)).thenReturn(asList());
reader.doPageRead();
verify(elasticsearchOperations).queryForList(query, Object.class);
}
}

View File

@@ -0,0 +1,244 @@
/*
* Copyright 2002-2014 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
*
* http://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.batch.item.data;
import static java.util.Arrays.asList;
import static org.junit.Assert.fail;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyZeroInteractions;
import static org.mockito.MockitoAnnotations.initMocks;
import java.util.ArrayList;
import java.util.List;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.mockito.Mock;
import org.springframework.batch.support.transaction.ResourcelessTransactionManager;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
import org.springframework.data.elasticsearch.core.query.IndexQuery;
import org.springframework.data.elasticsearch.core.query.IndexQueryBuilder;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.TransactionCallback;
import org.springframework.transaction.support.TransactionTemplate;
public class ElasticsearchItemWriterTest {
@Document(indexName="test_index", type="test_type")
public class DummyDocument {
private String id;
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
}
private ElasticsearchItemWriter writer;
@Mock
private ElasticsearchOperations elasticsearchOperations;
private TransactionTemplate transactionTemplate;
private DummyDocument dummyDocument;
@Before
public void setUp() throws Exception {
initMocks(this);
transactionTemplate = new TransactionTemplate(new ResourcelessTransactionManager());
writer = new ElasticsearchItemWriter(elasticsearchOperations);
writer.afterPropertiesSet();
dummyDocument = new DummyDocument();
}
@After
public void tearDown() {
transactionTemplate = null;
writer = null;
dummyDocument = null;
}
@Test(expected=IllegalStateException.class)
public void shouldFailAssertion() throws Exception {
new ElasticsearchItemWriter(null).afterPropertiesSet();
fail("Assertion shold have thrown exception on null ElasticsearchOperations");
}
@Test
public void shouldNotWriteWhenNoTransactionIsActiveAndNoItem() throws Exception {
writer.write(null);
verifyZeroInteractions(elasticsearchOperations);
writer.write(new ArrayList<IndexQuery>(0));
verifyZeroInteractions(elasticsearchOperations);
}
@Test
public void shouldWriteItemWhenNoTransactionIsActive() throws Exception {
IndexQueryBuilder builder = new IndexQueryBuilder();
builder.withObject(dummyDocument);
List<IndexQuery> items = asList(builder.build());
writer.write(items);
verify(elasticsearchOperations).index(items.iterator().next());
}
@Test
public void shouldWriteItemWhenInTransaction() throws Exception {
IndexQueryBuilder builder = new IndexQueryBuilder();
builder.withObject(dummyDocument);
final List<IndexQuery> items = asList(builder.build());
transactionTemplate.execute(new TransactionCallback<Void>() {
@Override
public Void doInTransaction(TransactionStatus status) {
try {
writer.write(items);
} catch (Exception e) {
fail("An error occurred while writing: " + e.getMessage());
}
return null;
}
});
verify(elasticsearchOperations).index(items.iterator().next());
}
@Test
public void shouldNotWriteItemWhenTransactionFails() throws Exception {
IndexQueryBuilder builder = new IndexQueryBuilder();
builder.withObject(dummyDocument);
final List<IndexQuery> items = asList(builder.build());
try {
transactionTemplate.execute(new TransactionCallback<Void>() {
@Override
public Void doInTransaction(TransactionStatus status) {
try {
writer.write(items);
} catch (Exception ignore) {
fail("unexpected error occurred");
}
throw new RuntimeException("rollback");
}
});
} catch (RuntimeException re) {
// ignore
} catch (Throwable t) {
fail("Unexpected error occurred");
}
verifyZeroInteractions(elasticsearchOperations);
}
@Test
public void shouldNotWriteItemWhenTransactionIsReadOnly() throws Exception {
IndexQueryBuilder builder = new IndexQueryBuilder();
builder.withObject(dummyDocument);
final List<IndexQuery> items = asList(builder.build());
try {
transactionTemplate.setReadOnly(true);
transactionTemplate.execute(new TransactionCallback<Void>() {
@Override
public Void doInTransaction(TransactionStatus status) {
try {
writer.write(items);
} catch (Exception ignore) {
fail("unexpected error occurred");
}
return null;
}
});
} catch (Throwable t) {
fail("unexpected error occurred");
}
verifyZeroInteractions(elasticsearchOperations);
}
@Test
public void shouldRemoveItemWhenNoTransactionIsActive() throws Exception {
writer.setDelete(true);
dummyDocument.setId("123456");
IndexQueryBuilder builder = new IndexQueryBuilder();
builder.withId(dummyDocument.getId());
builder.withObject(dummyDocument);
final List<IndexQuery> items = asList(builder.build());
writer.write(items);
verify(elasticsearchOperations).delete(items.iterator().next().getObject().getClass(), items.iterator().next().getId());
}
@Test
public void shouldRemoveItemWhenInTransaction() throws Exception {
writer.setDelete(true);
dummyDocument.setId("123456");
IndexQueryBuilder builder = new IndexQueryBuilder();
builder.withId(dummyDocument.getId());
builder.withObject(dummyDocument);
final List<IndexQuery> items = asList(builder.build());
transactionTemplate.execute(new TransactionCallback<Void>() {
@Override
public Void doInTransaction(TransactionStatus status) {
try {
writer.write(items);
} catch (Exception e) {
fail("An error occurred while writing: " + e.getMessage());
}
return null;
}
});
verify(elasticsearchOperations).delete(items.iterator().next().getObject().getClass(), items.iterator().next().getId());
}
}