001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      https://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.commons.io.output;
018
019import java.io.File;
020import java.io.FileOutputStream;
021import java.io.FileWriter;
022import java.io.IOException;
023import java.io.OutputStreamWriter;
024import java.io.UnsupportedEncodingException;
025import java.io.Writer;
026import java.nio.charset.Charset;
027import java.util.Objects;
028
029import org.apache.commons.io.Charsets;
030import org.apache.commons.io.FileUtils;
031import org.apache.commons.io.build.AbstractOrigin;
032import org.apache.commons.io.build.AbstractStreamBuilder;
033
034/**
035 * FileWriter that will create and honor lock files to allow simple cross thread file lock handling.
036 * <p>
037 * This class provides a simple alternative to {@link FileWriter} that will use a lock file to prevent duplicate writes.
038 * </p>
039 * <p>
040 * <strong>Note:</strong> The lock file is deleted when {@link #close()} is called - or if the main file cannot be opened initially. In the (unlikely) event
041 * that the lock file cannot be deleted, an exception is thrown.
042 * </p>
043 * <p>
044 * By default, the file will be overwritten, but this may be changed to append. The lock directory may be specified, but defaults to the system property
045 * {@code java.io.tmpdir}. The encoding may also be specified, and defaults to the platform default.
046 * </p>
047 * <p>
048 * To build an instance, use {@link Builder}.
049 * </p>
050 *
051 * @see Builder
052 */
053public class LockableFileWriter extends Writer {
054
055    // @formatter:off
056    /**
057     * Builds a new {@link LockableFileWriter}.
058     *
059     * <p>
060     * Using a CharsetEncoder:
061     * </p>
062     * <pre>{@code
063     * LockableFileWriter w = LockableFileWriter.builder()
064     *   .setPath(path)
065     *   .setAppend(false)
066     *   .setLockDirectory("Some/Directory")
067     *   .get();}
068     * </pre>
069     *
070     * @see #get()
071     * @since 2.12.0
072     */
073    // @formatter:on
074    public static class Builder extends AbstractStreamBuilder<LockableFileWriter, Builder> {
075
076        private boolean append;
077        private AbstractOrigin<?, ?> lockDirectory = newFileOrigin(FileUtils.getTempDirectoryPath());
078
079        /**
080         * Constructs a new builder of {@link LockableFileWriter}.
081         */
082        public Builder() {
083            setBufferSizeDefault(AbstractByteArrayOutputStream.DEFAULT_SIZE);
084            setBufferSize(AbstractByteArrayOutputStream.DEFAULT_SIZE);
085        }
086
087        private File checkOriginFile() {
088            return checkOrigin().getFile();
089        }
090
091        /**
092         * Constructs a new instance.
093         * <p>
094         * You must set an aspect that supports {@link File} on this builder, otherwise, this method throws an exception.
095         * </p>
096         * <p>
097         * This builder uses the following aspects:
098         * </p>
099         * <ul>
100         * <li>{@link File} is the target aspect.</li>
101         * <li>{@link #getCharset()}</li>
102         * <li>append</li>
103         * <li>lockDirectory</li>
104         * </ul>
105         *
106         * @return a new instance.
107         * @throws UnsupportedOperationException if the origin cannot provide a File.
108         * @throws IllegalStateException         if the {@code origin} is {@code null}.
109         * @throws IOException                   if an I/O error occurs converting to an {@link File} using {@link #getFile()}.
110         * @see AbstractOrigin#getFile()
111         * @see #getUnchecked()
112         */
113        @Override
114        public LockableFileWriter get() throws IOException {
115            return new LockableFileWriter(this);
116        }
117
118        /**
119         * Sets whether to append (true) or overwrite (false).
120         *
121         * @param append whether to append (true) or overwrite (false).
122         * @return {@code this} instance.
123         */
124        public Builder setAppend(final boolean append) {
125            this.append = append;
126            return this;
127        }
128
129        /**
130         * Sets the directory in which the lock file should be held.
131         *
132         * @param lockDirectory the directory in which the lock file should be held.
133         * @return {@code this} instance.
134         */
135        public Builder setLockDirectory(final File lockDirectory) {
136            this.lockDirectory = newFileOrigin(lockDirectory != null ? lockDirectory : FileUtils.getTempDirectory());
137            return this;
138        }
139
140        /**
141         * Sets the directory in which the lock file should be held.
142         *
143         * @param lockDirectory the directory in which the lock file should be held.
144         * @return {@code this} instance.
145         */
146        public Builder setLockDirectory(final String lockDirectory) {
147            this.lockDirectory = newFileOrigin(lockDirectory != null ? lockDirectory : FileUtils.getTempDirectoryPath());
148            return this;
149        }
150
151    }
152
153    /** The extension for the lock file. */
154    private static final String LCK = ".lck";
155
156    /**
157     * Constructs a new {@link Builder}.
158     *
159     * @return a new {@link Builder}.
160     * @since 2.12.0
161     */
162    public static Builder builder() {
163        return new Builder();
164    }
165
166    /** The writer to decorate. */
167    private final Writer out;
168
169    /** The lock file. */
170    private final File lockFile;
171
172    private LockableFileWriter(final Builder builder) throws IOException {
173        this(builder.checkOriginFile(), builder.getCharset(), builder.append, builder.lockDirectory.getFile().toString());
174    }
175
176
177    /**
178     * Constructs a LockableFileWriter. If the file exists, it is overwritten.
179     *
180     * @param file the file to write to, not null
181     * @throws NullPointerException if the file is null
182     * @throws IOException          in case of an I/O error
183     * @deprecated Use {@link #builder()}, {@link Builder}, and {@link Builder#get()}
184     */
185    @Deprecated
186    public LockableFileWriter(final File file) throws IOException {
187        this(file, false, null);
188    }
189
190    /**
191     * Constructs a LockableFileWriter.
192     *
193     * @param file   the file to write to, not null
194     * @param append true if content should be appended, false to overwrite
195     * @throws NullPointerException if the file is null
196     * @throws IOException          in case of an I/O error
197     * @deprecated Use {@link #builder()}, {@link Builder}, and {@link Builder#get()}
198     */
199    @Deprecated
200    public LockableFileWriter(final File file, final boolean append) throws IOException {
201        this(file, append, null);
202    }
203
204    /**
205     * Constructs a LockableFileWriter.
206     * <p>
207     * The new instance uses the virtual machine's {@link Charset#defaultCharset() default charset}.
208     * </p>
209     *
210     * @param file    the file to write to, not null
211     * @param append  true if content should be appended, false to overwrite
212     * @param lockDir the directory in which the lock file should be held
213     * @throws NullPointerException if the file is null
214     * @throws IOException          in case of an I/O error
215     * @deprecated Use {@link #LockableFileWriter(File, Charset, boolean, String)} instead
216     */
217    @Deprecated
218    public LockableFileWriter(final File file, final boolean append, final String lockDir) throws IOException {
219        this(file, Charset.defaultCharset(), append, lockDir);
220    }
221
222    /**
223     * Constructs a LockableFileWriter with a file encoding.
224     *
225     * @param file    the file to write to, not null
226     * @param charset the charset to use, null means platform default
227     * @throws NullPointerException if the file is null
228     * @throws IOException          in case of an I/O error
229     * @since 2.3
230     * @deprecated Use {@link #builder()}, {@link Builder}, and {@link Builder#get()}
231     */
232    @Deprecated
233    public LockableFileWriter(final File file, final Charset charset) throws IOException {
234        this(file, charset, false, null);
235    }
236
237    /**
238     * Constructs a LockableFileWriter with a file encoding.
239     *
240     * @param file    the file to write to, not null
241     * @param charset the name of the requested charset, null means platform default
242     * @param append  true if content should be appended, false to overwrite
243     * @param lockDir the directory in which the lock file should be held
244     * @throws NullPointerException if the file is null
245     * @throws IOException          in case of an I/O error
246     * @since 2.3
247     * @deprecated Use {@link #builder()}, {@link Builder}, and {@link Builder#get()}
248     */
249    @Deprecated
250    public LockableFileWriter(final File file, final Charset charset, final boolean append, final String lockDir) throws IOException {
251        // init file to create/append
252        final File absFile = Objects.requireNonNull(file, "file").getAbsoluteFile();
253        if (absFile.getParentFile() != null) {
254            FileUtils.forceMkdir(absFile.getParentFile());
255        }
256        if (absFile.isDirectory()) {
257            throw new IOException("File specified is a directory");
258        }
259        // init lock file
260        final File lockDirFile = new File(lockDir != null ? lockDir : FileUtils.getTempDirectoryPath());
261        FileUtils.forceMkdir(lockDirFile);
262        testLockDir(lockDirFile);
263        lockFile = new File(lockDirFile, absFile.getName() + LCK);
264        // check if locked
265        createLock();
266        // init wrapped writer
267        out = initWriter(absFile, charset, append);
268    }
269
270    /**
271     * Constructs a LockableFileWriter with a file encoding.
272     *
273     * @param file        the file to write to, not null
274     * @param charsetName the name of the requested charset, null means platform default
275     * @throws NullPointerException                         if the file is null
276     * @throws IOException                                  in case of an I/O error
277     * @throws java.nio.charset.UnsupportedCharsetException thrown instead of {@link UnsupportedEncodingException} in version 2.2 if the encoding is not
278     *                                                      supported.
279     * @deprecated Use {@link #builder()}, {@link Builder}, and {@link Builder#get()}
280     */
281    @Deprecated
282    public LockableFileWriter(final File file, final String charsetName) throws IOException {
283        this(file, charsetName, false, null);
284    }
285
286    /**
287     * Constructs a LockableFileWriter with a file encoding.
288     *
289     * @param file        the file to write to, not null
290     * @param charsetName the encoding to use, null means platform default
291     * @param append      true if content should be appended, false to overwrite
292     * @param lockDir     the directory in which the lock file should be held
293     * @throws NullPointerException                         if the file is null
294     * @throws IOException                                  in case of an I/O error
295     * @throws java.nio.charset.UnsupportedCharsetException thrown instead of {@link UnsupportedEncodingException} in version 2.2 if the encoding is not
296     *                                                      supported.
297     * @deprecated Use {@link #builder()}, {@link Builder}, and {@link Builder#get()}
298     */
299    @Deprecated
300    public LockableFileWriter(final File file, final String charsetName, final boolean append, final String lockDir) throws IOException {
301        this(file, Charsets.toCharset(charsetName), append, lockDir);
302    }
303
304    /**
305     * Constructs a LockableFileWriter. If the file exists, it is overwritten.
306     *
307     * @param fileName the file to write to, not null
308     * @throws NullPointerException if the file is null
309     * @throws IOException          in case of an I/O error
310     * @deprecated Use {@link #builder()}, {@link Builder}, and {@link Builder#get()}
311     */
312    @Deprecated
313    public LockableFileWriter(final String fileName) throws IOException {
314        this(fileName, false, null);
315    }
316
317    /**
318     * Constructs a LockableFileWriter.
319     *
320     * @param fileName file to write to, not null
321     * @param append   true if content should be appended, false to overwrite
322     * @throws NullPointerException if the file is null
323     * @throws IOException          in case of an I/O error
324     * @deprecated Use {@link #builder()}, {@link Builder}, and {@link Builder#get()}
325     */
326    @Deprecated
327    public LockableFileWriter(final String fileName, final boolean append) throws IOException {
328        this(fileName, append, null);
329    }
330
331    /**
332     * Constructs a LockableFileWriter.
333     *
334     * @param fileName the file to write to, not null
335     * @param append   true if content should be appended, false to overwrite
336     * @param lockDir  the directory in which the lock file should be held
337     * @throws NullPointerException if the file is null
338     * @throws IOException          in case of an I/O error
339     * @deprecated Use {@link #builder()}, {@link Builder}, and {@link Builder#get()}
340     */
341    @Deprecated
342    public LockableFileWriter(final String fileName, final boolean append, final String lockDir) throws IOException {
343        this(new File(fileName), append, lockDir);
344    }
345
346    /**
347     * Closes the file writer and deletes the lock file.
348     *
349     * @throws IOException if an I/O error occurs.
350     */
351    @Override
352    public void close() throws IOException {
353        try {
354            out.close();
355        } finally {
356            FileUtils.delete(lockFile);
357        }
358    }
359
360    /**
361     * Creates the lock file.
362     *
363     * @throws IOException if we cannot create the file
364     */
365    private void createLock() throws IOException {
366        synchronized (LockableFileWriter.class) {
367            if (!lockFile.createNewFile()) {
368                throw new IOException("Can't write file, lock " + lockFile.getAbsolutePath() + " exists");
369            }
370            lockFile.deleteOnExit();
371        }
372    }
373
374    /**
375     * Flushes the stream.
376     *
377     * @throws IOException if an I/O error occurs.
378     */
379    @Override
380    public void flush() throws IOException {
381        out.flush();
382    }
383
384    /**
385     * Initializes the wrapped file writer. Ensure that a cleanup occurs if the writer creation fails.
386     *
387     * @param file    the file to be accessed
388     * @param charset the charset to use
389     * @param append  true to append
390     * @return The initialized writer
391     * @throws IOException if an error occurs
392     */
393    private Writer initWriter(final File file, final Charset charset, final boolean append) throws IOException {
394        final boolean fileExistedAlready = file.exists();
395        try {
396            return new OutputStreamWriter(new FileOutputStream(file.getAbsolutePath(), append), Charsets.toCharset(charset));
397        } catch (final IOException | RuntimeException ex) {
398            FileUtils.deleteQuietly(lockFile);
399            if (!fileExistedAlready) {
400                FileUtils.deleteQuietly(file);
401            }
402            throw ex;
403        }
404    }
405
406    /**
407     * Tests that we can write to the lock directory.
408     *
409     * @param lockDir the File representing the lock directory
410     * @throws IOException if we cannot write to the lock directory
411     * @throws IOException if we cannot find the lock file
412     */
413    private void testLockDir(final File lockDir) throws IOException {
414        if (!lockDir.exists()) {
415            throw new IOException("Could not find lockDir: " + lockDir.getAbsolutePath());
416        }
417        if (!lockDir.canWrite()) {
418            throw new IOException("Could not write to lockDir: " + lockDir.getAbsolutePath());
419        }
420    }
421
422    /**
423     * Writes the characters from an array.
424     *
425     * @param cbuf the characters to write
426     * @throws IOException if an I/O error occurs.
427     */
428    @Override
429    public void write(final char[] cbuf) throws IOException {
430        out.write(cbuf);
431    }
432
433    /**
434     * Writes the specified characters from an array.
435     *
436     * @param cbuf the characters to write
437     * @param off  The start offset
438     * @param len  The number of characters to write
439     * @throws IOException if an I/O error occurs.
440     */
441    @Override
442    public void write(final char[] cbuf, final int off, final int len) throws IOException {
443        out.write(cbuf, off, len);
444    }
445
446    /**
447     * Writes a character.
448     *
449     * @param c the character to write
450     * @throws IOException if an I/O error occurs.
451     */
452    @Override
453    public void write(final int c) throws IOException {
454        out.write(c);
455    }
456
457    /**
458     * Writes the characters from a string.
459     *
460     * @param str the string to write
461     * @throws IOException if an I/O error occurs.
462     */
463    @Override
464    public void write(final String str) throws IOException {
465        out.write(str);
466    }
467
468    /**
469     * Writes the specified characters from a string.
470     *
471     * @param str the string to write
472     * @param off The start offset
473     * @param len The number of characters to write
474     * @throws IOException if an I/O error occurs.
475     */
476    @Override
477    public void write(final String str, final int off, final int len) throws IOException {
478        out.write(str, off, len);
479    }
480
481}