Add streaming json item writer

Resolves BATCH-2692
This commit is contained in:
Mahmoud Ben Hassine
2018-06-20 11:06:06 +02:00
committed by Michael Minella
parent 1f900ee83b
commit 84d349ea9d
25 changed files with 2068 additions and 585 deletions

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2006-2012 the original author or authors.
* Copyright 2006-2018 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.
@@ -16,29 +16,10 @@
package org.springframework.batch.item.file;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.Writer;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.nio.charset.UnsupportedCharsetException;
import java.util.List;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.batch.item.ExecutionContext;
import org.springframework.batch.item.ItemStream;
import org.springframework.batch.item.ItemStreamException;
import org.springframework.batch.item.WriteFailedException;
import org.springframework.batch.item.WriterNotOpenException;
import org.springframework.batch.item.file.transform.LineAggregator;
import org.springframework.batch.item.support.AbstractItemStreamItemWriter;
import org.springframework.batch.item.util.FileUtils;
import org.springframework.batch.support.transaction.TransactionAwareBufferedWriter;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.batch.item.support.AbstractFileItemWriter;
import org.springframework.core.io.Resource;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
@@ -57,48 +38,11 @@ import org.springframework.util.ClassUtils;
* @author Robert Kasanicky
* @author Dave Syer
* @author Michael Minella
* @author Mahmoud Ben Hassine
*/
public class FlatFileItemWriter<T> extends AbstractItemStreamItemWriter<T> implements ResourceAwareItemWriterItemStream<T>,
InitializingBean {
public class FlatFileItemWriter<T> extends AbstractFileItemWriter<T> {
public static final boolean DEFAULT_TRANSACTIONAL = true;
protected static final Log logger = LogFactory.getLog(FlatFileItemWriter.class);
public static final String DEFAULT_LINE_SEPARATOR = System.getProperty("line.separator");
// default encoding for writing to output files - set to UTF-8.
public static final String DEFAULT_CHARSET = "UTF-8";
private static final String WRITTEN_STATISTICS_NAME = "written";
private static final String RESTART_DATA_NAME = "current.count";
private Resource resource;
private OutputState state = null;
private LineAggregator<T> lineAggregator;
private boolean saveState = true;
private boolean forceSync = false;
private boolean shouldDeleteIfExists = true;
private boolean shouldDeleteIfEmpty = false;
private String encoding = DEFAULT_CHARSET;
private FlatFileHeaderCallback headerCallback;
private FlatFileFooterCallback footerCallback;
private String lineSeparator = DEFAULT_LINE_SEPARATOR;
private boolean transactional = DEFAULT_TRANSACTIONAL;
private boolean append = false;
protected LineAggregator<T> lineAggregator;
public FlatFileItemWriter() {
this.setExecutionContextName(ClassUtils.getShortName(FlatFileItemWriter.class));
@@ -117,28 +61,6 @@ InitializingBean {
}
}
/**
* Flag to indicate that changes should be force-synced to disk on flush.
* Defaults to false, which means that even with a local disk changes could
* be lost if the OS crashes in between a write and a cache flush. Setting
* to true may result in slower performance for usage patterns involving many
* frequent writes.
*
* @param forceSync the flag value to set
*/
public void setForceSync(boolean forceSync) {
this.forceSync = forceSync;
}
/**
* Public setter for the line separator. Defaults to the System property
* line.separator.
* @param lineSeparator the line separator to set
*/
public void setLineSeparator(String lineSeparator) {
this.lineSeparator = lineSeparator;
}
/**
* Public setter for the {@link LineAggregator}. This will be used to
* translate the item into a line for output.
@@ -149,509 +71,13 @@ InitializingBean {
this.lineAggregator = lineAggregator;
}
/**
* Setter for resource. Represents a file that can be written.
*
* @param resource the resource to be written to
*/
@Override
public void setResource(Resource resource) {
this.resource = resource;
}
/**
* Sets encoding for output template.
*
* @param newEncoding {@link String} containing the encoding to be used for
* the writer.
*/
public void setEncoding(String newEncoding) {
this.encoding = newEncoding;
}
/**
* Flag to indicate that the target file should be deleted if it already
* exists, otherwise it will be created. Defaults to true, so no appending
* except on restart. If set to false and {@link #setAppendAllowed(boolean)
* appendAllowed} is also false then there will be an exception when the
* stream is opened to prevent existing data being potentially corrupted.
*
* @param shouldDeleteIfExists the flag value to set
*/
public void setShouldDeleteIfExists(boolean shouldDeleteIfExists) {
this.shouldDeleteIfExists = shouldDeleteIfExists;
}
/**
* Flag to indicate that the target file should be appended if it already
* exists. If this flag is set then the flag
* {@link #setShouldDeleteIfExists(boolean) shouldDeleteIfExists} is
* automatically set to false, so that flag should not be set explicitly.
* Defaults value is false.
*
* @param append the flag value to set
*/
public void setAppendAllowed(boolean append) {
this.append = append;
}
/**
* Flag to indicate that the target file should be deleted if no lines have
* been written (other than header and footer) on close. Defaults to false.
*
* @param shouldDeleteIfEmpty the flag value to set
*/
public void setShouldDeleteIfEmpty(boolean shouldDeleteIfEmpty) {
this.shouldDeleteIfEmpty = shouldDeleteIfEmpty;
}
/**
* Set the flag indicating whether or not state should be saved in the
* provided {@link ExecutionContext} during the {@link ItemStream} call to
* update. Setting this to false means that it will always start at the
* beginning on a restart.
*
* @param saveState if true, state will be persisted
*/
public void setSaveState(boolean saveState) {
this.saveState = saveState;
}
/**
* headerCallback will be called before writing the first item to file.
* Newline will be automatically appended after the header is written.
*
* @param headerCallback {@link FlatFileHeaderCallback} to generate the header
*
*/
public void setHeaderCallback(FlatFileHeaderCallback headerCallback) {
this.headerCallback = headerCallback;
}
/**
* footerCallback will be called after writing the last item to file, but
* before the file is closed.
*
* @param footerCallback {@link FlatFileFooterCallback} to generate the footer
*
*/
public void setFooterCallback(FlatFileFooterCallback footerCallback) {
this.footerCallback = footerCallback;
}
/**
* Flag to indicate that writing to the buffer should be delayed if a
* transaction is active. Defaults to true.
*
* @param transactional true if writing to buffer should be delayed.
*
*/
public void setTransactional(boolean transactional) {
this.transactional = transactional;
}
/**
* Writes out a string followed by a "new line", where the format of the new
* line separator is determined by the underlying operating system. If the
* input is not a String and a converter is available the converter will be
* applied and then this method recursively called with the result. If the
* input is an array or collection each value will be written to a separate
* line (recursively calling this method for each value). If no converter is
* supplied the input object's toString method will be used.<br>
*
* @param items list of items to be written to output stream
* @throws Exception if the transformer or file output fail,
* WriterNotOpenException if the writer has not been initialized.
*/
@Override
public void write(List<? extends T> items) throws Exception {
if (!getOutputState().isInitialized()) {
throw new WriterNotOpenException("Writer must be open before it can be written to");
}
if (logger.isDebugEnabled()) {
logger.debug("Writing to flat file with " + items.size() + " items.");
}
OutputState state = getOutputState();
public String doWrite(List<? extends T> items) {
StringBuilder lines = new StringBuilder();
int lineCount = 0;
for (T item : items) {
lines.append(lineAggregator.aggregate(item) + lineSeparator);
lineCount++;
lines.append(this.lineAggregator.aggregate(item)).append(this.lineSeparator);
}
try {
state.write(lines.toString());
}
catch (IOException e) {
throw new WriteFailedException("Could not write data. The file may be corrupt.", e);
}
state.linesWritten += lineCount;
}
/**
* @see ItemStream#close()
*/
@Override
public void close() {
super.close();
if (state != null) {
try {
if (footerCallback != null && state.outputBufferedWriter != null) {
footerCallback.writeFooter(state.outputBufferedWriter);
state.outputBufferedWriter.flush();
}
}
catch (IOException e) {
throw new ItemStreamException("Failed to write footer before closing", e);
}
finally {
state.close();
if (state.linesWritten == 0 && shouldDeleteIfEmpty) {
try {
resource.getFile().delete();
}
catch (IOException e) {
throw new ItemStreamException("Failed to delete empty file on close", e);
}
}
state = null;
}
}
}
/**
* Initialize the reader. This method may be called multiple times before
* close is called.
*
* @see ItemStream#open(ExecutionContext)
*/
@Override
public void open(ExecutionContext executionContext) throws ItemStreamException {
super.open(executionContext);
Assert.notNull(resource, "The resource must be set");
if (!getOutputState().isInitialized()) {
doOpen(executionContext);
}
}
private void doOpen(ExecutionContext executionContext) throws ItemStreamException {
OutputState outputState = getOutputState();
if (executionContext.containsKey(getExecutionContextKey(RESTART_DATA_NAME))) {
outputState.restoreFrom(executionContext);
}
try {
outputState.initializeBufferedWriter();
}
catch (IOException ioe) {
throw new ItemStreamException("Failed to initialize writer", ioe);
}
if (outputState.lastMarkedByteOffsetPosition == 0 && !outputState.appending) {
if (headerCallback != null) {
try {
headerCallback.writeHeader(outputState.outputBufferedWriter);
outputState.write(lineSeparator);
}
catch (IOException e) {
throw new ItemStreamException("Could not write headers. The file may be corrupt.", e);
}
}
}
}
/**
* @see ItemStream#update(ExecutionContext)
*/
@Override
public void update(ExecutionContext executionContext) {
super.update(executionContext);
if (state == null) {
throw new ItemStreamException("ItemStream not open or already closed.");
}
Assert.notNull(executionContext, "ExecutionContext must not be null");
if (saveState) {
try {
executionContext.putLong(getExecutionContextKey(RESTART_DATA_NAME), state.position());
}
catch (IOException e) {
throw new ItemStreamException("ItemStream does not return current position properly", e);
}
executionContext.putLong(getExecutionContextKey(WRITTEN_STATISTICS_NAME), state.linesWritten);
}
}
// Returns object representing state.
private OutputState getOutputState() {
if (state == null) {
File file;
try {
file = resource.getFile();
}
catch (IOException e) {
throw new ItemStreamException("Could not convert resource to file: [" + resource + "]", e);
}
Assert.state(!file.exists() || file.canWrite(), "Resource is not writable: [" + resource + "]");
state = new OutputState();
state.setDeleteIfExists(shouldDeleteIfExists);
state.setAppendAllowed(append);
state.setEncoding(encoding);
}
return state;
}
/**
* Encapsulates the runtime state of the writer. All state changing
* operations on the writer go through this class.
*/
private class OutputState {
private FileOutputStream os;
// The bufferedWriter over the file channel that is actually written
Writer outputBufferedWriter;
FileChannel fileChannel;
// this represents the charset encoding (if any is needed) for the
// output file
String encoding = DEFAULT_CHARSET;
boolean restarted = false;
long lastMarkedByteOffsetPosition = 0;
long linesWritten = 0;
boolean shouldDeleteIfExists = true;
boolean initialized = false;
private boolean append = false;
private boolean appending = false;
/**
* Return the byte offset position of the cursor in the output file as a
* long integer.
*/
public long position() throws IOException {
long pos = 0;
if (fileChannel == null) {
return 0;
}
outputBufferedWriter.flush();
pos = fileChannel.position();
if (transactional) {
pos += ((TransactionAwareBufferedWriter) outputBufferedWriter).getBufferSize();
}
return pos;
}
/**
* @param append if true, append to previously created file
*/
public void setAppendAllowed(boolean append) {
this.append = append;
}
/**
* @param executionContext state from which to restore writing from
*/
public void restoreFrom(ExecutionContext executionContext) {
lastMarkedByteOffsetPosition = executionContext.getLong(getExecutionContextKey(RESTART_DATA_NAME));
linesWritten = executionContext.getLong(getExecutionContextKey(WRITTEN_STATISTICS_NAME));
if (shouldDeleteIfEmpty && linesWritten == 0) {
// previous execution deleted the output file because no items were written
restarted = false;
lastMarkedByteOffsetPosition = 0;
} else {
restarted = true;
}
}
/**
* @param shouldDeleteIfExists indicator
*/
public void setDeleteIfExists(boolean shouldDeleteIfExists) {
this.shouldDeleteIfExists = shouldDeleteIfExists;
}
/**
* @param encoding file encoding
*/
public void setEncoding(String encoding) {
this.encoding = encoding;
}
/**
* Close the open resource and reset counters.
*/
public void close() {
initialized = false;
restarted = false;
try {
if (outputBufferedWriter != null) {
outputBufferedWriter.close();
}
}
catch (IOException ioe) {
throw new ItemStreamException("Unable to close the the ItemWriter", ioe);
}
finally {
if (!transactional) {
closeStream();
}
}
}
private void closeStream() {
try {
if (fileChannel != null) {
fileChannel.close();
}
}
catch (IOException ioe) {
throw new ItemStreamException("Unable to close the the ItemWriter", ioe);
}
finally {
try {
if (os != null) {
os.close();
}
}
catch (IOException ioe) {
throw new ItemStreamException("Unable to close the the ItemWriter", ioe);
}
}
}
/**
* @param line String to be written to the file
* @throws IOException
*/
public void write(String line) throws IOException {
if (!initialized) {
initializeBufferedWriter();
}
outputBufferedWriter.write(line);
outputBufferedWriter.flush();
}
/**
* Truncate the output at the last known good point.
*
* @throws IOException if unable to work with file
*/
public void truncate() throws IOException {
fileChannel.truncate(lastMarkedByteOffsetPosition);
fileChannel.position(lastMarkedByteOffsetPosition);
}
/**
* Creates the buffered writer for the output file channel based on
* configuration information.
* @throws IOException if unable to initialize buffer
*/
private void initializeBufferedWriter() throws IOException {
File file = resource.getFile();
FileUtils.setUpOutputFile(file, restarted, append, shouldDeleteIfExists);
os = new FileOutputStream(file.getAbsolutePath(), true);
fileChannel = os.getChannel();
outputBufferedWriter = getBufferedWriter(fileChannel, encoding);
outputBufferedWriter.flush();
if (append) {
// Bug in IO library? This doesn't work...
// lastMarkedByteOffsetPosition = fileChannel.position();
if (file.length() > 0) {
appending = true;
// Don't write the headers again
}
}
Assert.state(outputBufferedWriter != null,
"Unable to initialize buffered writer");
// in case of restarting reset position to last committed point
if (restarted) {
checkFileSize();
truncate();
}
initialized = true;
}
public boolean isInitialized() {
return initialized;
}
/**
* Returns the buffered writer opened to the beginning of the file
* specified by the absolute path name contained in absoluteFileName.
*/
private Writer getBufferedWriter(FileChannel fileChannel, String encoding) {
try {
final FileChannel channel = fileChannel;
if (transactional) {
TransactionAwareBufferedWriter writer = new TransactionAwareBufferedWriter(channel, () -> closeStream());
writer.setEncoding(encoding);
writer.setForceSync(forceSync);
return writer;
}
else {
Writer writer = new BufferedWriter(Channels.newWriter(fileChannel, encoding)) {
@Override
public void flush() throws IOException {
super.flush();
if (forceSync) {
channel.force(false);
}
}
};
return writer;
}
}
catch (UnsupportedCharsetException ucse) {
throw new ItemStreamException("Bad encoding configuration for output file " + fileChannel, ucse);
}
}
/**
* Checks (on setState) to make sure that the current output file's size
* is not smaller than the last saved commit point. If it is, then the
* file has been damaged in some way and whole task must be started over
* again from the beginning.
* @throws IOException if there is an IO problem
*/
private void checkFileSize() throws IOException {
long size = -1;
outputBufferedWriter.flush();
size = fileChannel.size();
if (size < lastMarkedByteOffsetPosition) {
throw new ItemStreamException("Current file size is smaller than size at last commit");
}
}
return lines.toString();
}
}

View File

@@ -0,0 +1,45 @@
/*
* Copyright 2018 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.json;
import com.google.gson.Gson;
/**
* A json object marshaller that uses <a href="https://github.com/google/gson">Google Gson</a>
* to marshal an object into a json representation.
*
* @param <T> type of objects to marshal
* @author Mahmoud Ben Hassine
* @since 4.1
*/
public class GsonJsonObjectMarshaller<T> implements JsonObjectMarshaller<T> {
private Gson gson = new Gson();
/**
* Set the {@link Gson} object to use.
* @param gson object to use
*/
public void setGson(Gson gson) {
this.gson = gson;
}
@Override
public String marshal(T item) {
return gson.toJson(item);
}
}

View File

@@ -0,0 +1,52 @@
/*
* Copyright 2018 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.json;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.batch.item.ItemStreamException;
/**
* A json object marshaller that uses <a href="https://github.com/FasterXML/jackson">Jackson</a>
* to marshal an object into a json representation.
*
* @param <T> type of objects to marshal
* @author Mahmoud Ben Hassine
* @since 4.1
*/
public class JacksonJsonObjectMarshaller<T> implements JsonObjectMarshaller<T> {
private ObjectMapper objectMapper = new ObjectMapper();
/**
* Set the {@link ObjectMapper} to use.
* @param objectMapper to use
*/
public void setObjectMapper(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Override
public String marshal(T item) {
try {
return objectMapper.writeValueAsString(item);
} catch (JsonProcessingException e) {
throw new ItemStreamException("Unable to marshal object " + item + " to Json", e);
}
}
}

View File

@@ -0,0 +1,111 @@
/*
* Copyright 2018 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.json;
import java.util.Iterator;
import java.util.List;
import org.springframework.batch.item.support.AbstractFileItemWriter;
import org.springframework.core.io.Resource;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
/**
* Item writer that writes data in json format to an output file. The location
* of the output file is defined by a {@link Resource} and must represent a
* writable file. Items are transformed to json format using a
* {@link JsonObjectMarshaller}. Items will be enclosed in a json array as follows:
*
* <p>
* <code>
* [
* {json object},
* {json object},
* {json object}
* ]
* </code>
* </p>
*
* The implementation is <b>not</b> thread-safe.
*
* @see GsonJsonObjectMarshaller
* @see JacksonJsonObjectMarshaller
* @param <T> type of object to write as json representation
* @author Mahmoud Ben Hassine
* @since 4.1
*/
public class JsonFileItemWriter<T> extends AbstractFileItemWriter<T> {
private static final char JSON_OBJECT_SEPARATOR = ',';
private static final char JSON_ARRAY_START = '[';
private static final char JSON_ARRAY_STOP = ']';
private JsonObjectMarshaller<T> jsonObjectMarshaller;
/**
* Create a new {@link JsonFileItemWriter} instance.
* @param resource to write json data to
* @param jsonObjectMarshaller used to marshal object into json representation
*/
public JsonFileItemWriter(Resource resource, JsonObjectMarshaller<T> jsonObjectMarshaller) {
Assert.notNull(resource, "resource must not be null");
Assert.notNull(jsonObjectMarshaller, "json object marshaller must not be null");
setResource(resource);
setJsonObjectMarshaller(jsonObjectMarshaller);
setHeaderCallback(writer -> writer.write(JSON_ARRAY_START));
setFooterCallback(writer -> writer.write(this.lineSeparator + JSON_ARRAY_STOP + this.lineSeparator));
setExecutionContextName(ClassUtils.getShortName(JsonFileItemWriter.class));
}
/**
* Assert that mandatory properties (jsonObjectMarshaller) are set.
*
* @see org.springframework.beans.factory.InitializingBean#afterPropertiesSet()
*/
@Override
public void afterPropertiesSet() throws Exception {
if (this.append) {
this.shouldDeleteIfExists = false;
}
}
/**
* Set the {@link JsonObjectMarshaller} to use to marshal object to json.
* @param jsonObjectMarshaller the marshaller to use
*/
public void setJsonObjectMarshaller(JsonObjectMarshaller<T> jsonObjectMarshaller) {
this.jsonObjectMarshaller = jsonObjectMarshaller;
}
@Override
public String doWrite(List<? extends T> items) {
StringBuilder lines = new StringBuilder();
Iterator<? extends T> iterator = items.iterator();
while (iterator.hasNext()) {
if (iterator.hasNext() && state.getLinesWritten() > 0) {
lines.append(JSON_OBJECT_SEPARATOR).append(this.lineSeparator);
}
T item = iterator.next();
lines.append(' ').append(this.jsonObjectMarshaller.marshal(item));
if (iterator.hasNext()) {
lines.append(JSON_OBJECT_SEPARATOR).append(this.lineSeparator);
}
}
return lines.toString();
}
}

View File

@@ -0,0 +1,36 @@
/*
* Copyright 2018 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.json;
/**
* Strategy interface to marshal an object into a json representation.
* Implementations are required to return a valid json object.
*
* @param <T> type of objects to marshal
* @author Mahmoud Ben Hassine
* @since 4.1
*/
public interface JsonObjectMarshaller<T> {
/**
* Marshal an object into a json representation.
* @param object to marshal
* @return json representation fo the object
*/
String marshal(T object);
}

View File

@@ -0,0 +1,261 @@
/*
* Copyright 2018 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.json.builder;
import org.springframework.batch.item.file.FlatFileFooterCallback;
import org.springframework.batch.item.file.FlatFileHeaderCallback;
import org.springframework.batch.item.json.JsonFileItemWriter;
import org.springframework.batch.item.json.JsonObjectMarshaller;
import org.springframework.core.io.Resource;
import org.springframework.util.Assert;
/**
* Builder for {@link JsonFileItemWriter}.
*
* @param <T> type of objects to write as Json output.
* @author Mahmoud Ben Hassine
* @since 4.1
*/
public class JsonFileItemWriterBuilder<T> {
private Resource resource;
private JsonObjectMarshaller<T> jsonObjectMarshaller;
private FlatFileHeaderCallback headerCallback;
private FlatFileFooterCallback footerCallback;
private String name;
private String encoding = JsonFileItemWriter.DEFAULT_CHARSET;
private String lineSeparator = JsonFileItemWriter.DEFAULT_LINE_SEPARATOR;
private boolean append = false;
private boolean forceSync = false;
private boolean saveState = true;
private boolean shouldDeleteIfExists = true;
private boolean shouldDeleteIfEmpty = false;
private boolean transactional = JsonFileItemWriter.DEFAULT_TRANSACTIONAL;
/**
* Configure if the state of the {@link org.springframework.batch.item.ItemStreamSupport}
* should be persisted within the {@link org.springframework.batch.item.ExecutionContext}
* for restart purposes.
*
* @param saveState defaults to true
* @return The current instance of the builder.
*/
public JsonFileItemWriterBuilder<T> saveState(boolean saveState) {
this.saveState = saveState;
return this;
}
/**
* The name used to calculate the key within the
* {@link org.springframework.batch.item.ExecutionContext}. Required if
* {@link #saveState(boolean)} is set to true.
*
* @param name name of the reader instance
* @return The current instance of the builder.
* @see org.springframework.batch.item.ItemStreamSupport#setName(String)
*/
public JsonFileItemWriterBuilder<T> name(String name) {
this.name = name;
return this;
}
/**
* A flag indicating that changes should be force-synced to disk on flush. Defaults
* to false.
*
* @param forceSync value to set the flag to
* @return The current instance of the builder.
* @see JsonFileItemWriter#setForceSync(boolean)
*/
public JsonFileItemWriterBuilder<T> forceSync(boolean forceSync) {
this.forceSync = forceSync;
return this;
}
/**
* String used to separate lines in output. Defaults to the System property
* <code>line.separator</code>.
*
* @param lineSeparator value to use for a line separator
* @return The current instance of the builder.
* @see JsonFileItemWriter#setLineSeparator(String)
*/
public JsonFileItemWriterBuilder<T> lineSeparator(String lineSeparator) {
this.lineSeparator = lineSeparator;
return this;
}
/**
* Set the {@link JsonObjectMarshaller} to use to marshal objects to json.
*
* @param jsonObjectMarshaller to use
* @return The current instance of the builder.
* @see JsonFileItemWriter#setJsonObjectMarshaller(JsonObjectMarshaller)
*/
public JsonFileItemWriterBuilder<T> jsonObjectMarshaller(JsonObjectMarshaller<T> jsonObjectMarshaller) {
this.jsonObjectMarshaller = jsonObjectMarshaller;
return this;
}
/**
* The {@link Resource} to be used as output.
*
* @param resource the output of the writer.
* @return The current instance of the builder.
* @see JsonFileItemWriter#setResource(Resource)
*/
public JsonFileItemWriterBuilder<T> resource(Resource resource) {
this.resource = resource;
return this;
}
/**
* Encoding used for output.
*
* @param encoding encoding type.
* @return The current instance of the builder.
* @see JsonFileItemWriter#setEncoding(String)
*/
public JsonFileItemWriterBuilder<T> encoding(String encoding) {
this.encoding = encoding;
return this;
}
/**
* If set to true, once the step is complete, if the resource previously provided is
* empty, it will be deleted.
*
* @param shouldDelete defaults to false
* @return The current instance of the builder
* @see JsonFileItemWriter#setShouldDeleteIfEmpty(boolean)
*/
public JsonFileItemWriterBuilder<T> shouldDeleteIfEmpty(boolean shouldDelete) {
this.shouldDeleteIfEmpty = shouldDelete;
return this;
}
/**
* If set to true, upon the start of the step, if the resource already exists, it will
* be deleted and recreated.
*
* @param shouldDelete defaults to true
* @return The current instance of the builder
* @see JsonFileItemWriter#setShouldDeleteIfExists(boolean)
*/
public JsonFileItemWriterBuilder<T> shouldDeleteIfExists(boolean shouldDelete) {
this.shouldDeleteIfExists = shouldDelete;
return this;
}
/**
* If set to true and the file exists, the output will be appended to the existing
* file.
*
* @param append defaults to false
* @return The current instance of the builder
* @see JsonFileItemWriter#setAppendAllowed(boolean)
*/
public JsonFileItemWriterBuilder<T> append(boolean append) {
this.append = append;
return this;
}
/**
* A callback for header processing.
*
* @param callback {@link FlatFileHeaderCallback} implementation
* @return The current instance of the builder
* @see JsonFileItemWriter#setHeaderCallback(FlatFileHeaderCallback)
*/
public JsonFileItemWriterBuilder<T> headerCallback(FlatFileHeaderCallback callback) {
this.headerCallback = callback;
return this;
}
/**
* A callback for footer processing.
*
* @param callback {@link FlatFileFooterCallback} implementation
* @return The current instance of the builder
* @see JsonFileItemWriter#setFooterCallback(FlatFileFooterCallback)
*/
public JsonFileItemWriterBuilder<T> footerCallback(FlatFileFooterCallback callback) {
this.footerCallback = callback;
return this;
}
/**
* If set to true, the flushing of the buffer is delayed while a transaction is active.
*
* @param transactional defaults to true
* @return The current instance of the builder
* @see JsonFileItemWriter#setTransactional(boolean)
*/
public JsonFileItemWriterBuilder<T> transactional(boolean transactional) {
this.transactional = transactional;
return this;
}
/**
* Validate the configuration and build a new {@link JsonFileItemWriter}.
*
* @return a new instance of the {@link JsonFileItemWriter}
*/
public JsonFileItemWriter<T> build() {
Assert.notNull(this.resource, "A resource is required.");
Assert.notNull(this.jsonObjectMarshaller, "A json object marshaller is required.");
if (this.saveState) {
Assert.hasText(this.name, "A name is required when saveState is true");
}
JsonFileItemWriter<T> jsonFileItemWriter = new JsonFileItemWriter<>(this.resource, this.jsonObjectMarshaller);
jsonFileItemWriter.setName(this.name);
jsonFileItemWriter.setAppendAllowed(this.append);
jsonFileItemWriter.setEncoding(this.encoding);
if (this.headerCallback != null) {
jsonFileItemWriter.setHeaderCallback(this.headerCallback);
}
if (this.footerCallback != null) {
jsonFileItemWriter.setFooterCallback(this.footerCallback);
}
jsonFileItemWriter.setForceSync(this.forceSync);
jsonFileItemWriter.setLineSeparator(this.lineSeparator);
jsonFileItemWriter.setSaveState(this.saveState);
jsonFileItemWriter.setShouldDeleteIfEmpty(this.shouldDeleteIfEmpty);
jsonFileItemWriter.setShouldDeleteIfExists(this.shouldDeleteIfExists);
jsonFileItemWriter.setTransactional(this.transactional);
return jsonFileItemWriter;
}
}

View File

@@ -0,0 +1,636 @@
/*
* Copyright 2006-2018 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.support;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.Writer;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.nio.charset.UnsupportedCharsetException;
import java.util.List;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.batch.item.ExecutionContext;
import org.springframework.batch.item.ItemStream;
import org.springframework.batch.item.ItemStreamException;
import org.springframework.batch.item.WriteFailedException;
import org.springframework.batch.item.WriterNotOpenException;
import org.springframework.batch.item.file.FlatFileFooterCallback;
import org.springframework.batch.item.file.FlatFileHeaderCallback;
import org.springframework.batch.item.file.ResourceAwareItemWriterItemStream;
import org.springframework.batch.item.util.FileUtils;
import org.springframework.batch.support.transaction.TransactionAwareBufferedWriter;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.core.io.Resource;
import org.springframework.util.Assert;
/**
* Base class for item writers that write data to a file or stream.
* This class provides common features like restart, force sync, append etc.
* The location of the output file is defined by a {@link Resource} which must
* represent a writable file.<br>
*
* Uses buffered writer to improve performance.<br>
*
* The implementation is <b>not</b> thread-safe.
*
* @author Waseem Malik
* @author Tomas Slanina
* @author Robert Kasanicky
* @author Dave Syer
* @author Michael Minella
* @author Mahmoud Ben Hassine
*
* @since 4.1
*/
public abstract class AbstractFileItemWriter<T> extends AbstractItemStreamItemWriter<T>
implements ResourceAwareItemWriterItemStream<T>, InitializingBean {
public static final boolean DEFAULT_TRANSACTIONAL = true;
protected static final Log logger = LogFactory.getLog(AbstractFileItemWriter.class);
public static final String DEFAULT_LINE_SEPARATOR = System.getProperty("line.separator");
// default encoding for writing to output files - set to UTF-8.
public static final String DEFAULT_CHARSET = "UTF-8";
private static final String WRITTEN_STATISTICS_NAME = "written";
private static final String RESTART_DATA_NAME = "current.count";
private Resource resource;
protected OutputState state = null;
private boolean saveState = true;
private boolean forceSync = false;
protected boolean shouldDeleteIfExists = true;
private boolean shouldDeleteIfEmpty = false;
private String encoding = DEFAULT_CHARSET;
private FlatFileHeaderCallback headerCallback;
private FlatFileFooterCallback footerCallback;
protected String lineSeparator = DEFAULT_LINE_SEPARATOR;
private boolean transactional = DEFAULT_TRANSACTIONAL;
protected boolean append = false;
/**
* Flag to indicate that changes should be force-synced to disk on flush.
* Defaults to false, which means that even with a local disk changes could
* be lost if the OS crashes in between a write and a cache flush. Setting
* to true may result in slower performance for usage patterns involving many
* frequent writes.
*
* @param forceSync the flag value to set
*/
public void setForceSync(boolean forceSync) {
this.forceSync = forceSync;
}
/**
* Public setter for the line separator. Defaults to the System property
* line.separator.
* @param lineSeparator the line separator to set
*/
public void setLineSeparator(String lineSeparator) {
this.lineSeparator = lineSeparator;
}
/**
* Setter for resource. Represents a file that can be written.
*
* @param resource the resource to be written to
*/
@Override
public void setResource(Resource resource) {
this.resource = resource;
}
/**
* Sets encoding for output template.
*
* @param newEncoding {@link String} containing the encoding to be used for
* the writer.
*/
public void setEncoding(String newEncoding) {
this.encoding = newEncoding;
}
/**
* Flag to indicate that the target file should be deleted if it already
* exists, otherwise it will be created. Defaults to true, so no appending
* except on restart. If set to false and {@link #setAppendAllowed(boolean)
* appendAllowed} is also false then there will be an exception when the
* stream is opened to prevent existing data being potentially corrupted.
*
* @param shouldDeleteIfExists the flag value to set
*/
public void setShouldDeleteIfExists(boolean shouldDeleteIfExists) {
this.shouldDeleteIfExists = shouldDeleteIfExists;
}
/**
* Flag to indicate that the target file should be appended if it already
* exists. If this flag is set then the flag
* {@link #setShouldDeleteIfExists(boolean) shouldDeleteIfExists} is
* automatically set to false, so that flag should not be set explicitly.
* Defaults value is false.
*
* @param append the flag value to set
*/
public void setAppendAllowed(boolean append) {
this.append = append;
}
/**
* Flag to indicate that the target file should be deleted if no lines have
* been written (other than header and footer) on close. Defaults to false.
*
* @param shouldDeleteIfEmpty the flag value to set
*/
public void setShouldDeleteIfEmpty(boolean shouldDeleteIfEmpty) {
this.shouldDeleteIfEmpty = shouldDeleteIfEmpty;
}
/**
* Set the flag indicating whether or not state should be saved in the
* provided {@link ExecutionContext} during the {@link ItemStream} call to
* update. Setting this to false means that it will always start at the
* beginning on a restart.
*
* @param saveState if true, state will be persisted
*/
public void setSaveState(boolean saveState) {
this.saveState = saveState;
}
/**
* headerCallback will be called before writing the first item to file.
* Newline will be automatically appended after the header is written.
*
* @param headerCallback {@link FlatFileHeaderCallback} to generate the header
*
*/
public void setHeaderCallback(FlatFileHeaderCallback headerCallback) {
this.headerCallback = headerCallback;
}
/**
* footerCallback will be called after writing the last item to file, but
* before the file is closed.
*
* @param footerCallback {@link FlatFileFooterCallback} to generate the footer
*
*/
public void setFooterCallback(FlatFileFooterCallback footerCallback) {
this.footerCallback = footerCallback;
}
/**
* Flag to indicate that writing to the buffer should be delayed if a
* transaction is active. Defaults to true.
*
* @param transactional true if writing to buffer should be delayed.
*
*/
public void setTransactional(boolean transactional) {
this.transactional = transactional;
}
/**
* Writes out a string followed by a "new line", where the format of the new
* line separator is determined by the underlying operating system.
*
* @param items list of items to be written to output stream
* @throws Exception if an error occurs while writing items to the output stream
*/
@Override
public void write(List<? extends T> items) throws Exception {
if (!getOutputState().isInitialized()) {
throw new WriterNotOpenException("Writer must be open before it can be written to");
}
if (logger.isDebugEnabled()) {
logger.debug("Writing to file with " + items.size() + " items.");
}
OutputState state = getOutputState();
String lines = doWrite(items);
try {
state.write(lines);
}
catch (IOException e) {
throw new WriteFailedException("Could not write data. The file may be corrupt.", e);
}
state.setLinesWritten(state.getLinesWritten() + items.size());
}
/**
* Write out a string of items followed by a "new line", where the format of the new
* line separator is determined by the underlying operating system.
* @param items to be written
* @return written lines
*/
protected abstract String doWrite(List<? extends T> items);
/**
* @see ItemStream#close()
*/
@Override
public void close() {
super.close();
if (state != null) {
try {
if (footerCallback != null && state.outputBufferedWriter != null) {
footerCallback.writeFooter(state.outputBufferedWriter);
state.outputBufferedWriter.flush();
}
}
catch (IOException e) {
throw new ItemStreamException("Failed to write footer before closing", e);
}
finally {
state.close();
if (state.linesWritten == 0 && shouldDeleteIfEmpty) {
try {
resource.getFile().delete();
}
catch (IOException e) {
throw new ItemStreamException("Failed to delete empty file on close", e);
}
}
state = null;
}
}
}
/**
* Initialize the reader. This method may be called multiple times before
* close is called.
*
* @see ItemStream#open(ExecutionContext)
*/
@Override
public void open(ExecutionContext executionContext) throws ItemStreamException {
super.open(executionContext);
Assert.notNull(resource, "The resource must be set");
if (!getOutputState().isInitialized()) {
doOpen(executionContext);
}
}
private void doOpen(ExecutionContext executionContext) throws ItemStreamException {
OutputState outputState = getOutputState();
if (executionContext.containsKey(getExecutionContextKey(RESTART_DATA_NAME))) {
outputState.restoreFrom(executionContext);
}
try {
outputState.initializeBufferedWriter();
}
catch (IOException ioe) {
throw new ItemStreamException("Failed to initialize writer", ioe);
}
if (outputState.lastMarkedByteOffsetPosition == 0 && !outputState.appending) {
if (headerCallback != null) {
try {
headerCallback.writeHeader(outputState.outputBufferedWriter);
outputState.write(lineSeparator);
}
catch (IOException e) {
throw new ItemStreamException("Could not write headers. The file may be corrupt.", e);
}
}
}
}
/**
* @see ItemStream#update(ExecutionContext)
*/
@Override
public void update(ExecutionContext executionContext) {
super.update(executionContext);
if (state == null) {
throw new ItemStreamException("ItemStream not open or already closed.");
}
Assert.notNull(executionContext, "ExecutionContext must not be null");
if (saveState) {
try {
executionContext.putLong(getExecutionContextKey(RESTART_DATA_NAME), state.position());
}
catch (IOException e) {
throw new ItemStreamException("ItemStream does not return current position properly", e);
}
executionContext.putLong(getExecutionContextKey(WRITTEN_STATISTICS_NAME), state.linesWritten);
}
}
// Returns object representing state.
protected OutputState getOutputState() {
if (state == null) {
File file;
try {
file = resource.getFile();
}
catch (IOException e) {
throw new ItemStreamException("Could not convert resource to file: [" + resource + "]", e);
}
Assert.state(!file.exists() || file.canWrite(), "Resource is not writable: [" + resource + "]");
state = new OutputState();
state.setDeleteIfExists(shouldDeleteIfExists);
state.setAppendAllowed(append);
state.setEncoding(encoding);
}
return state;
}
/**
* Encapsulates the runtime state of the writer. All state changing
* operations on the writer go through this class.
*/
protected class OutputState {
private FileOutputStream os;
// The bufferedWriter over the file channel that is actually written
Writer outputBufferedWriter;
FileChannel fileChannel;
// this represents the charset encoding (if any is needed) for the
// output file
String encoding = DEFAULT_CHARSET;
boolean restarted = false;
long lastMarkedByteOffsetPosition = 0;
long linesWritten = 0;
boolean shouldDeleteIfExists = true;
boolean initialized = false;
private boolean append = false;
private boolean appending = false;
/**
* Return the byte offset position of the cursor in the output file as a
* long integer.
*/
public long position() throws IOException {
long pos = 0;
if (fileChannel == null) {
return 0;
}
outputBufferedWriter.flush();
pos = fileChannel.position();
if (transactional) {
pos += ((TransactionAwareBufferedWriter) outputBufferedWriter).getBufferSize();
}
return pos;
}
/**
* @param append if true, append to previously created file
*/
public void setAppendAllowed(boolean append) {
this.append = append;
}
/**
* @param executionContext state from which to restore writing from
*/
public void restoreFrom(ExecutionContext executionContext) {
lastMarkedByteOffsetPosition = executionContext.getLong(getExecutionContextKey(RESTART_DATA_NAME));
linesWritten = executionContext.getLong(getExecutionContextKey(WRITTEN_STATISTICS_NAME));
if (shouldDeleteIfEmpty && linesWritten == 0) {
// previous execution deleted the output file because no items were written
restarted = false;
lastMarkedByteOffsetPosition = 0;
} else {
restarted = true;
}
}
/**
* @param shouldDeleteIfExists indicator
*/
public void setDeleteIfExists(boolean shouldDeleteIfExists) {
this.shouldDeleteIfExists = shouldDeleteIfExists;
}
/**
* @param encoding file encoding
*/
public void setEncoding(String encoding) {
this.encoding = encoding;
}
public long getLinesWritten() {
return linesWritten;
}
public void setLinesWritten(long linesWritten) {
this.linesWritten = linesWritten;
}
/**
* Close the open resource and reset counters.
*/
public void close() {
initialized = false;
restarted = false;
try {
if (outputBufferedWriter != null) {
outputBufferedWriter.close();
}
}
catch (IOException ioe) {
throw new ItemStreamException("Unable to close the the ItemWriter", ioe);
}
finally {
if (!transactional) {
closeStream();
}
}
}
private void closeStream() {
try {
if (fileChannel != null) {
fileChannel.close();
}
}
catch (IOException ioe) {
throw new ItemStreamException("Unable to close the the ItemWriter", ioe);
}
finally {
try {
if (os != null) {
os.close();
}
}
catch (IOException ioe) {
throw new ItemStreamException("Unable to close the the ItemWriter", ioe);
}
}
}
/**
* @param line String to be written to the file
* @throws IOException
*/
public void write(String line) throws IOException {
if (!initialized) {
initializeBufferedWriter();
}
outputBufferedWriter.write(line);
outputBufferedWriter.flush();
}
/**
* Truncate the output at the last known good point.
*
* @throws IOException if unable to work with file
*/
public void truncate() throws IOException {
fileChannel.truncate(lastMarkedByteOffsetPosition);
fileChannel.position(lastMarkedByteOffsetPosition);
}
/**
* Creates the buffered writer for the output file channel based on
* configuration information.
* @throws IOException if unable to initialize buffer
*/
private void initializeBufferedWriter() throws IOException {
File file = resource.getFile();
FileUtils.setUpOutputFile(file, restarted, append, shouldDeleteIfExists);
os = new FileOutputStream(file.getAbsolutePath(), true);
fileChannel = os.getChannel();
outputBufferedWriter = getBufferedWriter(fileChannel, encoding);
outputBufferedWriter.flush();
if (append) {
// Bug in IO library? This doesn't work...
// lastMarkedByteOffsetPosition = fileChannel.position();
if (file.length() > 0) {
appending = true;
// Don't write the headers again
}
}
Assert.state(outputBufferedWriter != null,
"Unable to initialize buffered writer");
// in case of restarting reset position to last committed point
if (restarted) {
checkFileSize();
truncate();
}
initialized = true;
}
public boolean isInitialized() {
return initialized;
}
/**
* Returns the buffered writer opened to the beginning of the file
* specified by the absolute path name contained in absoluteFileName.
*/
private Writer getBufferedWriter(FileChannel fileChannel, String encoding) {
try {
final FileChannel channel = fileChannel;
if (transactional) {
TransactionAwareBufferedWriter writer = new TransactionAwareBufferedWriter(channel, () -> closeStream());
writer.setEncoding(encoding);
writer.setForceSync(forceSync);
return writer;
}
else {
Writer writer = new BufferedWriter(Channels.newWriter(fileChannel, encoding)) {
@Override
public void flush() throws IOException {
super.flush();
if (forceSync) {
channel.force(false);
}
}
};
return writer;
}
}
catch (UnsupportedCharsetException ucse) {
throw new ItemStreamException("Bad encoding configuration for output file " + fileChannel, ucse);
}
}
/**
* Checks (on setState) to make sure that the current output file's size
* is not smaller than the last saved commit point. If it is, then the
* file has been damaged in some way and whole task must be started over
* again from the beginning.
* @throws IOException if there is an IO problem
*/
private void checkFileSize() throws IOException {
long size = -1;
outputBufferedWriter.flush();
size = fileChannel.size();
if (size < lastMarkedByteOffsetPosition) {
throw new ItemStreamException("Current file size is smaller than size at last commit");
}
}
}
}