From 49773f19e43af7d39f441aac06adbc6a4052a063 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Tue, 26 Apr 2022 15:42:15 +0200 Subject: [PATCH] Provides a CachingOuptutStream and a CachingWriter (#184) Provides a CachingOuptutStream and a CachingWriter --- .../plexus/util/io/CachingOutputStream.java | 175 ++++++++++++++++++ .../plexus/util/io/CachingWriter.java | 62 +++++++ .../util/io/CachingOutputStreamTest.java | 145 +++++++++++++++ .../plexus/util/io/CachingWriterTest.java | 143 ++++++++++++++ 4 files changed, 525 insertions(+) create mode 100644 src/main/java/org/codehaus/plexus/util/io/CachingOutputStream.java create mode 100644 src/main/java/org/codehaus/plexus/util/io/CachingWriter.java create mode 100644 src/test/java/org/codehaus/plexus/util/io/CachingOutputStreamTest.java create mode 100644 src/test/java/org/codehaus/plexus/util/io/CachingWriterTest.java diff --git a/src/main/java/org/codehaus/plexus/util/io/CachingOutputStream.java b/src/main/java/org/codehaus/plexus/util/io/CachingOutputStream.java new file mode 100644 index 00000000..521d5373 --- /dev/null +++ b/src/main/java/org/codehaus/plexus/util/io/CachingOutputStream.java @@ -0,0 +1,175 @@ +package org.codehaus.plexus.util.io; + +/* + * Copyright The Codehaus Foundation. + * + * 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. + */ + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.Buffer; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.FileTime; +import java.time.Instant; +import java.util.Objects; + +/** + * Caching OutputStream to avoid overwriting a file with + * the same content. + */ +public class CachingOutputStream extends OutputStream +{ + private final Path path; + private FileChannel channel; + private ByteBuffer readBuffer; + private ByteBuffer writeBuffer; + private boolean modified; + + public CachingOutputStream( File path ) throws IOException + { + this( Objects.requireNonNull( path ).toPath() ); + } + + public CachingOutputStream( Path path ) throws IOException + { + this( path, 32 * 1024 ); + } + + public CachingOutputStream( Path path, int bufferSize ) throws IOException + { + this.path = Objects.requireNonNull( path ); + this.channel = FileChannel.open( path, + StandardOpenOption.READ, StandardOpenOption.WRITE, StandardOpenOption.CREATE ); + this.readBuffer = ByteBuffer.allocate( bufferSize ); + this.writeBuffer = ByteBuffer.allocate( bufferSize ); + } + + @Override + public void write( int b ) throws IOException + { + if ( writeBuffer.remaining() < 1 ) + { + ( ( Buffer ) writeBuffer ).flip(); + flushBuffer( writeBuffer ); + ( ( Buffer ) writeBuffer ).clear(); + } + writeBuffer.put( ( byte ) b ); + } + + @Override + public void write( byte[] b ) throws IOException + { + write( b, 0, b.length ); + } + + @Override + public void write( byte[] b, int off, int len ) throws IOException + { + if ( writeBuffer.remaining() < len ) + { + ( ( Buffer ) writeBuffer ).flip(); + flushBuffer( writeBuffer ); + ( ( Buffer ) writeBuffer ).clear(); + } + int capacity = writeBuffer.capacity(); + while ( len >= capacity ) + { + flushBuffer( ByteBuffer.wrap( b, off, capacity ) ); + off += capacity; + len -= capacity; + } + if ( len > 0 ) + { + writeBuffer.put( b, off, len ); + } + } + + @Override + public void flush() throws IOException + { + ( ( Buffer ) writeBuffer ).flip(); + flushBuffer( writeBuffer ); + ( ( Buffer ) writeBuffer ).clear(); + super.flush(); + } + + private void flushBuffer( ByteBuffer writeBuffer ) throws IOException + { + if ( modified ) + { + channel.write( writeBuffer ); + } + else + { + int len = writeBuffer.remaining(); + ByteBuffer readBuffer; + if ( this.readBuffer.capacity() >= len ) + { + readBuffer = this.readBuffer; + ( ( Buffer ) readBuffer ).clear(); + } + else + { + readBuffer = ByteBuffer.allocate( len ); + } + while ( len > 0 ) + { + int read = channel.read( readBuffer ); + if ( read <= 0 ) + { + modified = true; + channel.position( channel.position() - readBuffer.position() ); + channel.write( writeBuffer ); + return; + } + len -= read; + } + ( ( Buffer ) readBuffer ).flip(); + if ( readBuffer.compareTo( writeBuffer ) != 0 ) + { + modified = true; + channel.position( channel.position() - readBuffer.remaining() ); + channel.write( writeBuffer ); + } + } + } + + @Override + public void close() throws IOException + { + flush(); + long position = channel.position(); + if ( position != channel.size() ) + { + if ( !modified ) + { + FileTime now = FileTime.from( Instant.now() ); + Files.setLastModifiedTime( path, now ); + modified = true; + } + channel.truncate( position ); + } + channel.close(); + } + + public boolean isModified() + { + return modified; + } +} diff --git a/src/main/java/org/codehaus/plexus/util/io/CachingWriter.java b/src/main/java/org/codehaus/plexus/util/io/CachingWriter.java new file mode 100644 index 00000000..23cc4411 --- /dev/null +++ b/src/main/java/org/codehaus/plexus/util/io/CachingWriter.java @@ -0,0 +1,62 @@ +package org.codehaus.plexus.util.io; + +/* + * Copyright The Codehaus Foundation. + * + * 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. + */ + +import java.io.File; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.StringWriter; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Objects; + +/** + * Caching Writer to avoid overwriting a file with + * the same content. + */ +public class CachingWriter extends OutputStreamWriter +{ + private final CachingOutputStream cos; + + public CachingWriter( File path, Charset charset ) throws IOException + { + this( Objects.requireNonNull( path ).toPath(), charset ); + } + + public CachingWriter( Path path, Charset charset ) throws IOException + { + this( path, charset, 32 * 1024 ); + } + + public CachingWriter( Path path, Charset charset, int bufferSize ) throws IOException + { + this( new CachingOutputStream( path, bufferSize ), charset ); + } + + private CachingWriter( CachingOutputStream outputStream, Charset charset ) throws IOException + { + super( outputStream, charset ); + this.cos = outputStream; + } + + public boolean isModified() + { + return cos.isModified(); + } +} diff --git a/src/test/java/org/codehaus/plexus/util/io/CachingOutputStreamTest.java b/src/test/java/org/codehaus/plexus/util/io/CachingOutputStreamTest.java new file mode 100644 index 00000000..3c329ea9 --- /dev/null +++ b/src/test/java/org/codehaus/plexus/util/io/CachingOutputStreamTest.java @@ -0,0 +1,145 @@ +package org.codehaus.plexus.util.io; + +/* + * Copyright The Codehaus Foundation. + * + * 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. + */ + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.attribute.FileTime; +import java.util.Objects; + +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +public class CachingOutputStreamTest +{ + + Path tempDir; + Path checkLastModified; + FileTime lm; + + @Before + public void setup() throws IOException + { + Path dir = Paths.get( "target/io" ); + Files.createDirectories( dir ); + tempDir = Files.createTempDirectory( dir, "temp-" ); + checkLastModified = tempDir.resolve( ".check" ); + Files.newOutputStream( checkLastModified ).close(); + lm = Files.getLastModifiedTime( checkLastModified ); + } + + private void waitLastModified() throws IOException, InterruptedException + { + while ( true ) + { + Files.newOutputStream( checkLastModified ).close(); + FileTime nlm = Files.getLastModifiedTime( checkLastModified ); + if ( !Objects.equals( nlm, lm ) ) + { + lm = nlm; + break; + } + Thread.sleep( 10 ); + } + } + + @Test + public void testWriteNoExistingFile() throws IOException, InterruptedException + { + byte[] data = "Hello world!".getBytes( StandardCharsets.UTF_8 ); + Path path = tempDir.resolve( "file.txt" ); + assertFalse( Files.exists( path ) ); + + try ( CachingOutputStream cos = new CachingOutputStream( path, 4 ) ) + { + cos.write( data ); + } + assertTrue( Files.exists( path ) ); + byte[] read = Files.readAllBytes( path ); + assertArrayEquals( data, read ); + FileTime modified = Files.getLastModifiedTime( path ); + + waitLastModified(); + + try ( CachingOutputStream cos = new CachingOutputStream( path, 4 ) ) + { + cos.write( data ); + } + assertTrue( Files.exists( path ) ); + read = Files.readAllBytes( path ); + assertArrayEquals( data, read ); + FileTime newModified = Files.getLastModifiedTime( path ); + assertEquals( modified, newModified ); + modified = newModified; + + waitLastModified(); + + // write longer data + data = "Good morning!".getBytes( StandardCharsets.UTF_8 ); + try ( CachingOutputStream cos = new CachingOutputStream( path, 4 ) ) + { + cos.write( data ); + } + assertTrue( Files.exists( path ) ); + read = Files.readAllBytes( path ); + assertArrayEquals( data, read ); + newModified = Files.getLastModifiedTime( path ); + assertNotEquals( modified, newModified ); + modified = newModified; + + waitLastModified(); + + // different data same size + data = "Good mornong!".getBytes( StandardCharsets.UTF_8 ); + try ( CachingOutputStream cos = new CachingOutputStream( path, 4 ) ) + { + cos.write( data ); + } + assertTrue( Files.exists( path ) ); + read = Files.readAllBytes( path ); + assertArrayEquals( data, read ); + newModified = Files.getLastModifiedTime( path ); + assertNotEquals( modified, newModified ); + modified = newModified; + + waitLastModified(); + + // same data but shorter + data = "Good mornon".getBytes( StandardCharsets.UTF_8 ); + try ( CachingOutputStream cos = new CachingOutputStream( path, 4 ) ) + { + cos.write( data ); + } + assertTrue( Files.exists( path ) ); + read = Files.readAllBytes( path ); + assertArrayEquals( data, read ); + newModified = Files.getLastModifiedTime( path ); + assertNotEquals( modified, newModified ); + modified = newModified; + } + +} diff --git a/src/test/java/org/codehaus/plexus/util/io/CachingWriterTest.java b/src/test/java/org/codehaus/plexus/util/io/CachingWriterTest.java new file mode 100644 index 00000000..f5b95903 --- /dev/null +++ b/src/test/java/org/codehaus/plexus/util/io/CachingWriterTest.java @@ -0,0 +1,143 @@ +package org.codehaus.plexus.util.io; + +/* + * Copyright The Codehaus Foundation. + * + * 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. + */ + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.attribute.FileTime; +import java.util.Objects; + +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +public class CachingWriterTest +{ + + Path tempDir; + Path checkLastModified; + FileTime lm; + + @Before + public void setup() throws IOException + { + Path dir = Paths.get( "target/io" ); + Files.createDirectories( dir ); + tempDir = Files.createTempDirectory( dir, "temp-" ); + checkLastModified = tempDir.resolve( ".check" ); + Files.newOutputStream( checkLastModified ).close(); + lm = Files.getLastModifiedTime( checkLastModified ); + } + + private void waitLastModified() throws IOException, InterruptedException + { + while ( true ) + { + Files.newOutputStream( checkLastModified ).close(); + FileTime nlm = Files.getLastModifiedTime( checkLastModified ); + if ( !Objects.equals( nlm, lm ) ) + { + lm = nlm; + break; + } + Thread.sleep( 10 ); + } + } + + @Test + public void testWriteNoExistingFile() throws IOException, InterruptedException + { + String data = "Hello world!"; + Path path = tempDir.resolve( "file.txt" ); + assertFalse( Files.exists( path ) ); + + try ( CachingWriter cos = new CachingWriter( path, StandardCharsets.UTF_8, 4 ) ) + { + cos.write( data ); + } + assertTrue( Files.exists( path ) ); + String read = new String( Files.readAllBytes( path ), StandardCharsets.UTF_8 ); + assertEquals( data, read ); + FileTime modified = Files.getLastModifiedTime( path ); + + waitLastModified(); + + try ( CachingWriter cos = new CachingWriter( path, StandardCharsets.UTF_8, 4 ) ) + { + cos.write( data ); + } + assertTrue( Files.exists( path ) ); + read = new String( Files.readAllBytes( path ), StandardCharsets.UTF_8 ); + assertEquals( data, read ); + FileTime newModified = Files.getLastModifiedTime( path ); + assertEquals( modified, newModified ); + modified = newModified; + + waitLastModified(); + + // write longer data + data = "Good morning!"; + try ( CachingWriter cos = new CachingWriter( path, StandardCharsets.UTF_8, 4 ) ) + { + cos.write( data ); + } + assertTrue( Files.exists( path ) ); + read = new String( Files.readAllBytes( path ), StandardCharsets.UTF_8 ); + assertEquals( data, read ); + newModified = Files.getLastModifiedTime( path ); + assertNotEquals( modified, newModified ); + modified = newModified; + + waitLastModified(); + + // different data same size + data = "Good mornong!"; + try ( CachingWriter cos = new CachingWriter( path, StandardCharsets.UTF_8, 4 ) ) + { + cos.write( data ); + } + assertTrue( Files.exists( path ) ); + read = new String( Files.readAllBytes( path ), StandardCharsets.UTF_8 ); + assertEquals( data, read ); + newModified = Files.getLastModifiedTime( path ); + assertNotEquals( modified, newModified ); + modified = newModified; + + waitLastModified(); + + // same data but shorter + data = "Good mornon"; + try ( CachingWriter cos = new CachingWriter( path, StandardCharsets.UTF_8, 4 ) ) + { + cos.write( data ); + } + assertTrue( Files.exists( path ) ); + read = new String( Files.readAllBytes( path ), StandardCharsets.UTF_8 ); + assertEquals( data, read ); + newModified = Files.getLastModifiedTime( path ); + assertNotEquals( modified, newModified ); + modified = newModified; + } +}