The previous lesson ended with a sentence worth taking seriously: reading is safe, writing destroys. If you get reading wrong, you get incorrect data and you notice. If you get writing wrong, the previous file no longer exists, and you may not notice until somebody needs it.
In this lesson BiblioTech saves for the first time. And for it to save properly, there are three things to understand before writing a line: the append parameter, which decides between adding at the end of the file and emptying it completely; the buffer, which explains why what you have written may not be on the disk yet; and atomic writing, the pattern that stops a file being left half finished when the process fails midway.
That third point is the same problem you solved in 06-05 with compensation in finally: keeping the system in a consistent state whatever happens. There the state was in memory; here it is on the disk, and the consequences last longer.
As in the whole of the previous lesson, every resource is opened with
try-with-resources(06-06). It is taken as known and is not justified again. In writing it matters even more than in reading, because a writer'sclose()flushes the buffer to disk: if you do not close, you have not written.
Contents
FileWriter: create, overwrite and append- The
appendparameter, or how to lose a whole file - Writing text with
write PrintWriter: the convenient wrapper- The buffer and flushing:
flushversusclose - What happens if the program ends without closing
- Explicit encoding when writing
- The system line separator
- Permissions, read-only files and
IOException - Atomic writing: temporary file and rename
- Lock files and accidental overwriting
- BiblioTech:
CatalogExporterreally writes - Common Mistakes and Tips
- Exercises
FileWriter: create, overwrite and append
FileWriter: create, overwrite and appendFileWriter is the exact counterpart of FileReader: it writes text to a file. Its behaviour on opening is the first thing to pin down:
import java.io.FileWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
public class FirstWrite {
public static void main(String[] args) throws IOException {
try (FileWriter writer = new FileWriter("output.txt", StandardCharsets.UTF_8)) {
writer.write("BiblioTech catalogue\n");
writer.write("Nexus Software\n");
}
System.out.println("Written.");
}
}What exactly happens on opening:
| Previous situation | Default behaviour |
|---|---|
| The file does not exist | It is created, empty, and written to |
| The file exists | It is truncated to zero bytes and written from the start |
| The parent directory does not exist | IOException: FileWriter does not create directories |
| There is no write permission | IOException (FileNotFoundException, which extends it) |
Look at the second row, because it is the one that ruins your day: opening a FileWriter on an existing file empties it immediately, at construction time, before you write anything. If you open the file and then throw an exception before writing, you are left with an empty file and none of the previous data.
And look at the third row too: FileWriter does not create the directory. If you write to data/catalog.txt and data/ does not exist, you get an IOException whose typical message is "No such file or directory", which many people read as "it cannot find the file" when in fact the folder is missing. Creating directories is the job of mkdirs() in the old API or of Files.createDirectories() in NIO.2 (07-06).
The available constructors:
// Overwrites. Platform charset: DO NOT USE (07-01, section 10)
new FileWriter("output.txt");
// Overwrites, explicit charset (Java 11+). THIS is the correct form
new FileWriter("output.txt", StandardCharsets.UTF_8);
// Appends at the end, platform charset
new FileWriter("output.txt", true);
// Appends at the end, explicit charset (Java 11+). The other correct form
new FileWriter("output.txt", StandardCharsets.UTF_8, true);Beware the ambiguity of the second parameter.
new FileWriter(path, true)means "append".new FileWriter(path, StandardCharsets.UTF_8)means "overwrite with UTF-8". They look very much alike and mean different things. When you want both, use the three-parameter version, and note that thebooleangoes last.
- The
append parameter, or how to lose a whole file
append parameter, or how to lose a whole fileThis is the section to read twice. The table is short and the consequences are long:
| Constructor | New file | Existing file with data |
|---|---|---|
new FileWriter(path, UTF_8) |
It is created | It is emptied. The previous data is lost |
new FileWriter(path, UTF_8, false) |
It is created | It is emptied. Identical to the previous one |
new FileWriter(path, UTF_8, true) |
It is created | It is kept and writing happens at the end |
The real case, and it happens constantly:
// BiblioTech records every loan in an audit file.
// The file has been accumulating operations for three years.
public void recordOperation(String line) throws IOException {
// BUG: the 'true' is missing. Every call DELETES the whole history
// and leaves only the last line.
try (FileWriter writer = new FileWriter("data/audit.txt", StandardCharsets.UTF_8)) {
writer.write(line + System.lineSeparator());
}
}That code works: it throws no exceptions, gives no warnings, the file exists and has content. It just has one line instead of three hundred thousand. And since every run leaves it with one line again, the failure is perfectly stable and silent. It is discovered the day somebody asks for the history.
The correct version:
public void recordOperation(String line) throws IOException {
// The final 'true': APPEND, do not overwrite
try (FileWriter writer =
new FileWriter("data/audit.txt", StandardCharsets.UTF_8, true)) {
writer.write(line + System.lineSeparator());
}
}Three professional defences against this mistake:
- Name the intent. Do not leave the
trueloose in the call:
private static final boolean APPEND = true;
private static final boolean OVERWRITE = false;
new FileWriter(path, StandardCharsets.UTF_8, APPEND); // it reads itself-
Use the explicit NIO.2 options when you get to 07-06.
StandardOpenOption.APPENDandStandardOpenOption.TRUNCATE_EXISTINGsay literally what they do, and there is no way to confuse them with a charset. -
Always write to a temporary file and rename when you are regenerating a whole file. That is section 10, and it eliminates the entire category of problems.
And a warning that goes beyond this parameter:
Opening a
FileWriter"just to check something" already destroys the file. There is no "open only if I can" mode. If you need to know whether the file exists before deciding, check it before opening, and even then remember the TOCTOU warning from 06-07: between the check and the opening, the world can change.
- Writing text with
write
writeWriter —the superclass of FileWriter— offers several write overloads:
import java.io.FileWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
public class WaysOfWriting {
public static void main(String[] args) throws IOException {
try (FileWriter w = new FileWriter("demo.txt", StandardCharsets.UTF_8)) {
w.write("Complete text"); // whole String
w.write('\n'); // a single character (int)
w.write("Just one part here", 5, 3); // substring: from 5, 3 characters -> "one"
w.write('\n');
char[] buffer = { 'B', 'i', 'b', 'l', 'i', 'o' };
w.write(buffer); // whole array
w.write(buffer, 0, 3); // part of the array -> "Bib"
w.append("Chainable") // append returns the Writer
.append(' ')
.append("and convenient");
}
}
}| Method | Writes |
|---|---|
write(String) |
The complete string |
write(String, int start, int length) |
A substring, without creating intermediate objects |
write(int) |
A single character, given by its code |
write(char[]) |
The whole array |
write(char[], int start, int length) |
Part of the array |
append(CharSequence) |
The same as write(String), but returns the Writer, chainable |
A detail that throws people: write(int) writes a character, not the number. w.write(65) writes the letter A, not the text 65. If you want to write the number, convert it: w.write(String.valueOf(65)). It is the same asymmetry you already saw in read() in 07-01, and for the same reason: the unit of the API is the character, and the int is there so that -1 fits.
And the most obvious practical shortcoming: FileWriter has no println. There is no method that adds the line break for you, nor any that formats. You have to write the separator by hand every time. PrintWriter solves that.
PrintWriter: the convenient wrapper
PrintWriter: the convenient wrapperPrintWriter wraps another Writer and adds all the convenience of System.out: the same print, println and printf you have been using since module 1.
import java.io.FileWriter;
import java.io.PrintWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
public class ReportWithPrintWriter {
public static void main(String[] args) throws IOException {
try (PrintWriter output = new PrintWriter(
new FileWriter("report.txt", StandardCharsets.UTF_8))) {
output.println("FINE REPORT - BiblioTech");
output.println("========================");
output.println();
// printf with the same syntax as module 1
output.printf("%-25s %-15s %8s%n", "MATERIAL", "EMPLOYEE", "FINE");
output.printf("%-25s %-15s %8.2f%n", "Effective Java", "Marta Ruiz", 3.75);
output.printf("%-25s %-15s %8.2f%n", "Design Patterns", "Diego Alonso", 0.00);
output.printf("%-25s %-15s %8.2f%n", "Refactoring", "Nuria Vidal", 20.00);
// Explicit Locale: in much of Europe the decimal separator is a comma.
// For a file another program is going to read, pin Locale.ROOT.
output.printf(Locale.ROOT, "%nTOTAL: %.2f EUR%n", 23.75);
}
}
}The result, on a machine whose locale uses the decimal comma, is:
FINE REPORT - BiblioTech
========================
MATERIAL EMPLOYEE FINE
Effective Java Marta Ruiz 3,75
Design Patterns Diego Alonso 0,00
Refactoring Nuria Vidal 20,00
TOTAL: 23.75 EURNotice the difference between the body lines (with a decimal comma, because they use the machine's Locale) and the total line (with a dot, because it pins Locale.ROOT). In a file meant to be read by another program, always pin the Locale; in one meant to be read by a person in a comma country, the comma is correct. This same conflict will come back with CSV files in 07-07 and is more important than it looks.
What PrintWriter adds over FileWriter:
| Method | What it does |
|---|---|
println(x) |
Writes and adds the system line separator |
print(x) |
Writes without a break. Accepts any type, including objects (it uses toString()) |
printf(fmt, args...) |
Formats like System.out.printf |
format(fmt, args...) |
Identical to printf. It exists for symmetry with String.format |
write(String) |
Inherited from Writer |
And now the big PrintWriter trap, which you absolutely must know:
PrintWriterswallows exceptions. Itsprintlnandprintfmethods do not declareIOException. If the disk fills up or the device fails, you will not know: the error is stored in an internal flag instead of being thrown.
The only way to find out is to ask:
try (PrintWriter output = new PrintWriter(
new FileWriter("report.txt", StandardCharsets.UTF_8))) {
output.println("line 1");
output.println("line 2");
// COMPULSORY CHECK in serious code.
// checkError() flushes the buffer and returns true if THERE HAS BEEN any
// failure at any moment since it was opened.
if (output.checkError()) {
throw new IOException("Failed to write the report (detected by checkError)");
}
}This design comes from the fact that PrintWriter was born for System.out, where a console write failure should not force a try/catch around every println. For a file, that compromise is dangerous.
Practical rule:
- For console output and debugging dumps:
PrintWriterand nothing more, with its convenience. - For files whose content matters:
PrintWriterwithcheckError()before closing, orBufferedWriterdirectly (07-04), whose methods do declareIOException.
One more warning about the PrintWriter constructors:
// These TWO open the file on their own, with the platform charset
// in the old versions. They are convenient and treacherous.
new PrintWriter("output.txt");
new PrintWriter(new File("output.txt"));
// With an explicit charset (Java 10+): correct
new PrintWriter("output.txt", StandardCharsets.UTF_8);
// Wrapping a Writer you control: the clearest and most flexible form
new PrintWriter(new FileWriter("output.txt", StandardCharsets.UTF_8, true));The last one is the recommended one: you decide the charset and the append mode, and PrintWriter only contributes the formatting. That is composition, and in 07-03 you will see that this is the principle organising the whole stream API.
- The buffer and flushing:
flush versus close
flush versus closeHere is the concept that separates someone who writes files from someone who writes files properly.
When you call write, the data does not go to the disk. It accumulates in a buffer in memory, and is only sent to the operating system when the buffer fills up or when somebody asks for it explicitly. And there is more than one level of accumulation:
flowchart TD
A["output.println(...)"] --> B["PrintWriter or<br/>BufferedWriter buffer"]
B -->|"flush or full buffer"| C["Internal FileWriter buffer"]
C -->|"write system call"| D["Operating system<br/>page cache"]
D -->|"fsync or the OS itself"| E["Physical disk"]
style B fill:#e3f2fd
style C fill:#e3f2fd
style D fill:#fff3e0
style E fill:#f3e5f5
And here is the key point that almost nobody is clear about:
| Operation | Guarantees |
|---|---|
write(...) |
That the data is in the application buffer. Nothing more |
flush() |
That the data has reached the operating system. It no longer depends on your process |
close() |
A flush() plus the release of the file descriptor |
FileDescriptor.sync() |
That the data is physically on the disk. The only thing that survives a power cut |
In other words: close() protects against your program ending; only sync() protects against the power going off. For the vast majority of applications, close() is enough and sync() is an unnecessary cost. For a system where losing the last operation is unacceptable —a payment, an accounting entry— you have to go all the way to the disk.
A demonstration of the difference between flush and doing nothing:
import java.io.FileWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
public class BufferDemo {
public static void main(String[] args) throws Exception {
FileWriter w = new FileWriter("demo-buffer.txt", StandardCharsets.UTF_8);
w.write("First line\n");
System.out.println("After write, size on disk: " + size()); // 0
w.flush();
System.out.println("After flush, size on disk: " + size()); // 11
w.write("Second line\n");
System.out.println("After 2nd write, size: " + size()); // 11
w.close();
System.out.println("After close, size on disk: " + size()); // 23
}
private static long size() {
return new java.io.File("demo-buffer.txt").length();
}
}Output:
After write, size on disk: 0
After flush, size on disk: 11
After 2nd write, size: 11
After close, size on disk: 23The file has 0 bytes after the first write. The data exists, but it is in the process memory. If at that instant somebody looks at the file from outside, they see it empty.
A very common practical consequence: if you are writing a log file and you watch it with tail -f while the program runs, you will see nothing for a long while, because the buffer has not filled up. That is not a failure of your code; it is the buffer doing its job. For logs you need to be able to follow live, you flush() after every line, at the cost of performance —and that is exactly what the FileHandler of java.util.logging you configured in 06-07 does, and the reason logging has an appreciable cost.
When to call flush() explicitly:
- When another process or person needs to see the data now, without waiting for the close.
- When you are going to keep the file open for a long time and want to bound how much would be lost in a crash.
- Before a long or risky operation, to leave what has been written so far on the disk.
- Never just before
close(): it is redundant,close()already does it.
- What happens if the program ends without closing
This demonstration is worth running, because the result is surprising:
import java.io.FileWriter;
import java.nio.charset.StandardCharsets;
public class NotClosed {
public static void main(String[] args) throws Exception {
// DELIBERATELY WRONG: no try-with-resources and no close
FileWriter w = new FileWriter("not-closed.txt", StandardCharsets.UTF_8);
w.write("This line may never reach the disk.\n");
System.out.println("Ending without closing...");
// main ends here. There is no close(). There is no flush().
}
}The usual result: the file exists and is empty. The data stayed in the process buffer, and when the process ended the buffer went with it.
Questions that always come up:
- Does the garbage collector not close it? The
finalize()of some I/O classes did something similar, but it is deprecated since Java 9 and removed in recent versions. It was never a guarantee: the collector does not promise to run before the process ends. Never count on it. - What if the system kills the process? With
kill -9or a power cut, everything that has not reached the operating system is lost. Not even the shutdown hooks from 06-05 run withkill -9. - What if it ends with an exception? Without
try-with-resources, it is lost just the same. Withtry-with-resources, closing is guaranteed and the data gets through.
The correct version, and the only one written in real code:
try (FileWriter w = new FileWriter("properly-closed.txt", StandardCharsets.UTF_8)) {
w.write("This line DOES reach the disk.\n");
} // close() guaranteed: on success, on exception and on return (06-06)This gives
try-with-resourcesextra weight when writing. When reading, not closing is a resource leak: annoying but not destructive. When writing, not closing is losing data. The file is left empty or truncated, and often nobody finds out until much later.
- Explicit encoding when writing
Everything from 07-01 about encoding applies just the same, with a nuance that makes things worse:
An encoding failure when reading is visible and fixable. When writing, it is recorded. If you write a catalogue with the wrong charset, the file is wrong on disk. You can carry on reading it correctly as long as you use the same wrong charset, and the problem only appears when another tool —an editor, a spreadsheet, another system— tries to read it as UTF-8. By then the file has been accumulating corrupt data for months.
And there is a worse case still: writing a character that the target charset cannot represent:
import java.io.FileWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
public class UnrepresentableCharacter {
public static void main(String[] args) throws IOException {
// US-ASCII has neither 'e' with an acute accent nor the euro sign
try (FileWriter w = new FileWriter("ascii.txt", StandardCharsets.US_ASCII)) {
w.write("Café Society costs 45 €");
}
// It does NOT throw. It replaces the unrepresentable with '?'.
// The file ends up with: "Caf? Society costs 45 ?"
}
}Total silence and destroyed data. The default behaviour of the encoder is CodingErrorAction.REPLACE. If you want it to fail instead of destroying, you have to go down to the level of CharsetEncoder, which is java.nio.charset API and lies outside the scope of this lesson; the important thing is that you know that silence is the default behaviour.
The rule is the one from 07-01, repeated because it deserves repeating:
// ALWAYS, on every text write opening
new FileWriter(path, StandardCharsets.UTF_8);
new FileWriter(path, StandardCharsets.UTF_8, APPEND);
new PrintWriter(new FileWriter(path, StandardCharsets.UTF_8));And a symmetry that must always be respected: whoever writes and whoever reads the same file must use the same charset. If CatalogExporter writes in UTF-8, CatalogLoader has to read in UTF-8. Best of all is for that charset to be declared only once in a shared constant:
package com.nexussoftware.bibliotech.infrastructure;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
/** Format conventions for all BiblioTech files. */
public final class FileFormat {
/** The single charset of every text file in the system. */
public static final Charset CHARSET = StandardCharsets.UTF_8;
/** Field separator for the tabulated files. */
public static final String FIELD_SEPARATOR = ";";
/** Prefix of comment lines. */
public static final String COMMENT = "#";
private FileFormat() { }
}A single place to change, and no possibility of the reader and the writer disagreeing.
- The system line separator
Operating systems never agreed on how to end a line:
| System | Bytes | Java escape | Name |
|---|---|---|---|
| Linux, modern macOS | 0x0A |
\n |
LF |
| Windows | 0x0D 0x0A |
\r\n |
CRLF |
| Classic macOS (up to 2001) | 0x0D |
\r |
CR |
Java exposes the one for the current system:
When does it matter? It depends on who is going to read the file:
| Consumer of the file | Does \n bother it on Windows? |
|---|---|
Your own program with BufferedReader.readLine() |
No: it accepts all three forms |
| A modern editor (VS Code, Notepad++, IntelliJ) | No |
| The old Windows Notepad | Yes: it showed everything on one line |
| Windows command-line tools | Sometimes |
| Another system expecting the native format | Yes |
In practice:
PrintWriter.println()already uses the system separator. You do not have to do anything.BufferedWriter.newLine()does too. It is the correct method (07-04).- Writing
"\n"by hand in awritealways produces LF, whatever the system.
That said, there is an argument in favour of always writing \n, and it is a serious one: reproducibility. If a file generated on Windows has CRLF and the same file generated on Linux has LF, a version control system will see them as completely different even though the content is identical. For data files that are versioned or compared, many teams pin LF on purpose.
The decision, summarised:
| Type of file | Recommended separator |
|---|---|
| Report for a person to read on their system | System.lineSeparator() (or println) |
| Data file that is compared or versioned | Fixed "\n", and document it |
| File whose format another system defines | Whatever that system demands |
| Network protocol | Whatever the protocol says. HTTP demands CRLF (module 9) |
In BiblioTech we will use System.lineSeparator() for the reports and a fixed "\n" for the data files. The constant goes, like the charset, in FileFormat.
- Permissions, read-only files and
IOException
IOExceptionWriting can fail for reasons that do not depend on your code:
| Cause | Exception | Typical message |
|---|---|---|
| The parent directory does not exist | FileNotFoundException |
No such file or directory |
| No write permission | FileNotFoundException |
Permission denied |
| File marked read-only | FileNotFoundException |
Access is denied (Windows) |
| The path is a directory | FileNotFoundException |
Is a directory |
| Disk full | IOException |
No space left on device |
| Network drive disconnected | IOException |
Varies |
| User quota exceeded | IOException |
Disk quota exceeded |
Note an important detail: opening failures arrive as FileNotFoundException —just as in reading, with an equally misleading name— and failures during writing arrive as a generic IOException. And the most treacherous of all is the full disk, because it does not happen on opening but halfway through the file: by the time it fires, you already have half a file written.
That is precisely the definitive argument in favour of the next section.
Handling by layers, with the criterion from 06-07:
import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.logging.Level;
import java.util.logging.Logger;
public class WriteWithHandling {
private static final Logger LOG = Logger.getLogger(WriteWithHandling.class.getName());
public void export(String path, String content) throws CatalogNotAccessibleException {
try (FileWriter w = new FileWriter(path, StandardCharsets.UTF_8)) {
w.write(content);
} catch (FileNotFoundException e) {
// A LOCATION or PERMISSIONS problem: the user can correct it
LOG.log(Level.WARNING, "Cannot write to "
+ new java.io.File(path).getAbsolutePath(), e);
throw new CatalogNotAccessibleException(path, e); // 06-04, with the cause
} catch (IOException e) {
// A problem DURING the write: disk full, network down...
LOG.log(Level.SEVERE, "I/O failure writing " + path, e);
throw new CatalogNotAccessibleException(path, e);
}
}
}Notice that CatalogNotAccessibleException is reused, the checked exception you declared in 06-04 precisely for this: an infrastructure failure for which the application has a reasonable alternative, and which therefore deserves the compiler forcing a decision about what to do. The original cause travels inside, following the chaining rule of 06-03.
- Atomic writing: temporary file and rename
This is the professional pattern of the lesson. The problem is this:
// DANGEROUS: regenerates the whole catalogue by overwriting the good file
try (PrintWriter w = new PrintWriter(
new FileWriter("data/catalog.txt", StandardCharsets.UTF_8))) {
for (Material m : catalog.list()) { // 10,000 materials
w.println(serialise(m));
}
// If it fails at material 6,000 (disk full, an exception in serialise,
// the process dies), the file is left with 6,000 lines and the ORIGINAL
// catalogue NO LONGER EXISTS: it was truncated on opening.
}The good file was destroyed at the instant of opening the FileWriter, and the new one never got finished. You have lost the catalogue. And the worst part: the resulting file looks valid —it has the correct format, it reads without errors— it just happens to be missing 4,000 materials. A silent failure, which is the worst kind.
The solution is the write-and-rename pattern:
flowchart LR
A["catalog.txt<br/>good version"] --> B{"Write everything to<br/>catalog.txt.tmp"}
B -->|"success"| C["Rename tmp<br/>over catalog.txt"]
B -->|"failure"| D["Delete the tmp"]
C --> E["catalog.txt<br/>complete new version"]
D --> F["catalog.txt<br/>good version UNTOUCHED"]
style A fill:#e8f5e9
style E fill:#e8f5e9
style F fill:#e8f5e9
style D fill:#ffebee
Why it works: renaming within the same file system is an atomic operation as far as any reader is concerned. There is no instant at which the file is half done. A concurrent reader sees either the complete old version or the complete new one, never a mixture. And if something fails before the rename, the good file has not even been touched.
The implementation:
package com.nexussoftware.bibliotech.infrastructure;
import java.io.File;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.FileWriter;
import java.nio.charset.StandardCharsets;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Atomic writing of text files.
*
* Writes to a temporary file and, ONLY if everything went well, renames it
* over the definitive one. It guarantees the target file is never half done.
*
* It is the on-disk equivalent of the compensation in finally from 06-05: if
* the operation does not complete, the previous state is kept untouched.
*/
public final class AtomicWrite {
private static final Logger LOG = Logger.getLogger(AtomicWrite.class.getName());
private static final String TEMP_SUFFIX = ".tmp";
private AtomicWrite() { }
/**
* Contract for what is going to be written. A functional interface (04-06):
* it allows the writing logic to be passed as a lambda.
*
* It is not java.util.function.Consumer because we need it to be able to
* declare IOException, and Consumer.accept() does not declare it.
*/
@FunctionalInterface
public interface Content {
void writeTo(PrintWriter output) throws IOException;
}
/**
* Writes atomically.
*
* @param target final file
* @param content what to write
* @throws IOException if the write or the rename fails
*/
public static void write(File target, Content content) throws IOException {
File temp = new File(target.getAbsolutePath() + TEMP_SUFFIX);
// 1. Make sure the parent directory exists (FileWriter does not create it)
File parent = target.getAbsoluteFile().getParentFile();
if (parent != null && !parent.exists() && !parent.mkdirs()) {
throw new IOException("Could not create the directory " + parent.getAbsolutePath());
}
boolean completed = false;
try {
// 2. Write EVERYTHING to the temporary file
try (PrintWriter output = new PrintWriter(
new FileWriter(temp, StandardCharsets.UTF_8))) {
content.writeTo(output);
// PrintWriter swallows IOException: you have to ask it
if (output.checkError()) {
throw new IOException("Failed to write to "
+ temp.getAbsolutePath());
}
} // close() guaranteed: here the temp file is closed and complete
// 3. Rename ONLY if we got this far
if (!rename(temp, target)) {
throw new IOException("Could not rename "
+ temp.getName() + " over " + target.getName());
}
completed = true;
LOG.fine(() -> "Atomic write completed: " + target.getAbsolutePath());
} finally {
// 4. COMPENSATION (06-05): if it did not complete, leave no rubbish.
// The original target is still untouched because it was never opened.
if (!completed && temp.exists() && !temp.delete()) {
LOG.warning(() -> "A temporary file was left undeleted: "
+ temp.getAbsolutePath());
}
}
}
/**
* Renames the temporary file over the target.
*
* File.renameTo returns a mute boolean (07-01) and on Windows it fails if
* the target exists. Hence the prior delete. In 07-06 this is solved
* with Files.move and ATOMIC_MOVE, which is the correct way.
*/
private static boolean rename(File temp, File target) {
if (target.exists() && !target.delete()) {
return false;
}
return temp.renameTo(target);
}
}It is used like this, with a lambda:
AtomicWrite.write(new File("data/catalog.txt"), output -> {
output.println("# BiblioTech catalogue");
for (Material m : catalog.list()) {
output.printf("%s;%s;%s;%b%n",
m.getType(), m.getReference(), m.getTitle(), m.isAvailable());
}
});Three observations about this implementation:
- The
finallycompensates exactly as in 06-05. If it did not complete, the temporary file is deleted. And the target needs no compensation because it was never opened: that is the whole point of the pattern. checkError()is compulsory withPrintWriter. Without it, a full disk would go unnoticed and you would rename a truncated file over the good one, which is exactly what we wanted to avoid.renameTois defective —a muteboolean, different behaviour on Windows, a window between thedeleteand therenameTo. In 07-06 it is replaced byFiles.move(source, target, StandardCopyOption.ATOMIC_MOVE), which really is atomic and throws informative exceptions. The version here is the one you had to write before Java 7, and it serves to understand exactly what NIO.2 guarantees.
When to use atomic writing and when not:
| Situation | Atomic? |
|---|---|
| Regenerating a whole file (catalogue, export, configuration) | Yes, always |
| Adding a line to an audit log | No: it is opened in append mode and closed |
| A temporary working file that is deleted afterwards | Not needed |
| Any file another process may be reading | Yes, essential |
- Lock files and accidental overwriting
Two more dangers, brief but real.
Two processes writing the same file. If two instances of BiblioTech export the catalogue at the same time, the result is unpredictable: interleaved lines, a truncated file, or one of the two writes lost. Neither the operating system nor Java prevents it by default.
The traditional solution is a lock file: a file whose mere existence means "busy".
package com.nexussoftware.bibliotech.infrastructure;
import java.io.File;
import java.io.IOException;
/**
* Inter-process lock based on the existence of a file.
*
* AutoCloseable (06-06): the lock is released on leaving the try, however
* you leave it. Without that guarantee, an orphaned lock leaves the system
* unusable until somebody deletes the file by hand.
*/
public class FileLock implements AutoCloseable {
private final File lockFile;
private boolean acquired = false;
public FileLock(String protectedPath) throws IOException {
this.lockFile = new File(protectedPath + ".lock");
// createNewFile() is ATOMIC: it checks and creates in a single operation.
// That is why it works as a lock and an exists() + create() would NOT:
// the other process fits between the two calls (TOCTOU, 06-07).
if (!lockFile.createNewFile()) {
throw new IOException("The file '" + protectedPath
+ "' is being used by another process"
+ " (" + lockFile.getName() + " exists)");
}
acquired = true;
lockFile.deleteOnExit(); // safety net against an abrupt exit
}
@Override
public void close() {
if (!acquired) {
return; // idempotent (06-06)
}
acquired = false;
if (!lockFile.delete()) {
System.err.println("Warning: could not release " + lockFile.getAbsolutePath());
}
}
}Usage:
try (FileLock lock = new FileLock("data/catalog.txt")) {
AtomicWrite.write(new File("data/catalog.txt"), output -> { /* ... */ });
} // the lock is released here, whatever happensHonest limitations of this mechanism, which you have to know: if the process dies abruptly, the .lock is left orphaned and blocks everybody else. Serious systems store the process identifier and its start time inside so they can detect stale locks. Java also offers FileLock through FileChannel, which is a real operating system lock. And as soon as threads come into play, the problem changes in nature: that is module 8.
Accidental overwriting. Before regenerating an important file, keep a copy:
/** Keeps the previous version before overwriting. */
private static void makeBackup(File file) {
if (!file.exists()) {
return;
}
File backup = new File(file.getAbsolutePath() + ".bak");
if (backup.exists()) {
backup.delete();
}
if (!file.renameTo(backup)) {
LOG.warning("Could not back up " + file.getName());
}
}In 07-06 this becomes a rotating backup with NIO.2: .bak.1, .bak.2, .bak.3, keeping the last three versions.
- BiblioTech:
CatalogExporter really writes
CatalogExporter really writesTime to settle the debt. In 06-06 you declared CatalogExporter as Closeable and explained why its close() should indeed propagate IOException —because closing flushes the buffer, and if that fails the data is not written. But its writing was a sketch. Now it is written in full, with everything from this lesson.
package com.nexussoftware.bibliotech.service;
import java.io.File;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.List;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;
import com.nexussoftware.bibliotech.domain.Book;
import com.nexussoftware.bibliotech.domain.Material;
import com.nexussoftware.bibliotech.domain.Loan;
import com.nexussoftware.bibliotech.infrastructure.AtomicWrite;
import com.nexussoftware.bibliotech.infrastructure.FileFormat;
/**
* Exports the BiblioTech catalogue and loan register to text files,
* ATOMICALLY: the target file is never left half done.
*
* Design change with respect to 06-06: this class is NO LONGER Closeable.
* It keeps no file open between calls; each export opens, writes and closes
* inside AtomicWrite. An object that owns no live resources should not be
* closeable: it would be an empty promise.
*
* The format is the one CatalogLoader reads (07-01). Both share the
* constants of FileFormat so that they cannot disagree.
*/
public class CatalogExporter {
private static final Logger LOG = Logger.getLogger(CatalogExporter.class.getName());
private static final String CATALOG_HEADER =
FileFormat.COMMENT + " BiblioTech catalogue - Nexus Software";
private static final String CATALOG_FIELDS =
FileFormat.COMMENT + " type;reference;title;author;year";
private final File directory;
public CatalogExporter(String dataDirectory) {
this.directory = new File(
Objects.requireNonNull(dataDirectory, "The directory cannot be null"));
}
/**
* Exports the complete catalogue.
*
* @return number of materials written
* @throws IOException if the write could not be completed
*/
public int exportCatalog(Catalog catalog) throws IOException {
Objects.requireNonNull(catalog, "The catalogue cannot be null");
List<Material> materials = catalog.list();
File target = new File(directory, "catalog.txt");
long start = System.nanoTime();
AtomicWrite.write(target, output -> {
output.println(CATALOG_HEADER);
output.println(CATALOG_FIELDS);
for (Material m : materials) {
output.println(serialise(m));
}
});
double ms = (System.nanoTime() - start) / 1_000_000.0;
// Logger, never System.out for diagnostics (06-07). Lazy form.
LOG.info(() -> String.format("Catalogue exported: %d materials to %s (%.1f ms)",
materials.size(), target.getAbsolutePath(), ms));
return materials.size();
}
/**
* Serialises a material into a line.
*
* The fields are sanitised: a ';' inside a title would break the file
* when read back. Here it is substituted; in 07-07 it will be done
* PROPERLY, quoting and escaping per the CSV convention.
*/
private String serialise(Material m) {
String sep = FileFormat.FIELD_SEPARATOR;
if (m instanceof Book book) { // pattern from 03-06
return String.join(sep,
"BOOK",
sanitise(book.getIsbn()),
sanitise(book.getTitle()),
sanitise(book.getAuthor()),
String.valueOf(book.getPublicationYear()));
}
return String.join(sep,
m.getType().toUpperCase(),
sanitise(m.getReference()),
sanitise(m.getTitle()),
"-",
"0");
}
/** Removes separators and line breaks that would break the format. */
private String sanitise(String value) {
if (value == null) {
return "";
}
return value.replace(FileFormat.FIELD_SEPARATOR, ",")
.replace("\n", " ")
.replace("\r", " ")
.trim();
}
/**
* Adds a line to the loan audit log.
*
* THIS file IS opened in APPEND mode: it is a cumulative history,
* not a file that gets regenerated. It has no atomic writing because
* there is nothing to replace; it is only added to at the end.
*/
public void recordLoan(Loan loan, String operation) {
Objects.requireNonNull(loan, "The loan cannot be null");
File audit = new File(directory, "audit.txt");
// The final 'true' is APPEND. Without it, every call would delete the history.
try (PrintWriter output = new PrintWriter(
new java.io.FileWriter(audit, FileFormat.CHARSET, true))) {
output.printf("%s;%s;%s;%s%n",
operation,
loan.getReference(),
loan.getMaterial().getReference(),
loan.getBorrower().getIdentifier());
if (output.checkError()) {
throw new IOException("Failed writing to " + audit.getAbsolutePath());
}
} catch (IOException e) {
// POLICY: the audit must NOT bring down the business operation.
// The loan has already been registered in memory and is valid.
// It degrades: a warning in the log and carry on (06-07).
LOG.log(Level.WARNING, "Could not audit the operation "
+ operation + " of " + loan.getReference(), e);
}
}
}The five design decisions to understand in this class:
- It has stopped being
Closeable. In 06-06 it kept aBufferedWriteropen throughout its life. Now every export opens and closes insideAtomicWrite, so it owns no live resource. An object with nothing to close should not implementCloseable: it would be an empty promise that makes whoever uses it write a pointlesstry-with-resources. - The catalogue is written atomically; the audit, in
appendmode. They are two different natures: one is regenerated in full, the other grows. Atomic writing is for regenerating;appendmode is for growing. Confusing them produces, in one direction, a destroyed file, and in the other, a file that duplicates everything each time. - An audit failure does not abort the operation. The loan is valid even if it could not be audited. It is the recoverable/unrecoverable distinction of 06-07 applied here: not auditing is a loss of information, not an incorrect piece of data. If company policy demanded otherwise —and in a financial system it would— this decision would be reversed and documented.
- The
checkError()is in both places. Without it,PrintWritersays nothing when the disk fills up. sanitise()is a patch declared as such. Replacing the;with a comma loses information: the title comes back wrong. It is a conscious compromise until 07-07, where CSV escaping will really solve it. Flagging workarounds in a comment is part of the craft.
And saving on exit, hooked to the shutdown hook of 06-05:
package com.nexussoftware.bibliotech.presentation;
import java.io.IOException;
import java.util.logging.Level;
import java.util.logging.Logger;
import com.nexussoftware.bibliotech.service.Catalog;
import com.nexussoftware.bibliotech.service.CatalogLoader;
import com.nexussoftware.bibliotech.service.CatalogExporter;
import com.nexussoftware.bibliotech.infrastructure.LogConfiguration;
public class BiblioTechApp {
private static final Logger LOG = Logger.getLogger(BiblioTechApp.class.getName());
private static final String DATA_DIRECTORY = "data";
public static void main(String[] args) {
LogConfiguration.initialise();
Catalog catalog = new CatalogLoader(DATA_DIRECTORY + "/catalog.txt").load();
CatalogExporter exporter = new CatalogExporter(DATA_DIRECTORY);
// Save on exit. Covers the normal exit and Ctrl+C; does NOT cover kill -9.
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
try {
int n = exporter.exportCatalog(catalog);
LOG.info("Catalogue saved on exit: " + n + " materials");
} catch (IOException e) {
LOG.log(Level.SEVERE, "COULD NOT SAVE THE CATALOGUE ON EXIT", e);
}
}, "final-save"));
System.out.println("BiblioTech - Nexus Software");
System.out.println("Materials in the catalogue: " + catalog.size());
new BiblioTechMenu(catalog, exporter).start();
}
}An important warning about the hook. Saving only on exit is fragile:
kill -9, a power cut or anOutOfMemoryErrorskip it. And the hook has a limited time before the system kills the process. In a real system you save as well after every relevant operation, or periodically. The hook is the safety net, not the strategy.
BiblioTech's status at the close of this lesson: the catalogue is loaded on start-up (07-01) and saved on exit (07-02). For the first time in seven modules, the application remembers.
Common Mistakes and Tips
- Forgetting the
trueofappendmode. The most expensive mistake in the module. Every run deletes the history and leaves one line. It does not fail, it does not warn: it destroys silently. Use a namedAPPENDconstant. - Confusing
new FileWriter(path, true)withnew FileWriter(path, UTF_8). They look alike and mean opposite things. When you want both, use the three-parameter version with thebooleanlast. - Believing that opening the file is harmless. Constructing a
FileWriterwithoutappendtruncates the file immediately, before writing anything. If it then fails, you are left with no data and no new file. - Not closing the writer. The file is left empty. In reading, not closing is a leak; in writing it is data loss.
try-with-resourcesalways. - Trusting the garbage collector to close it.
finalize()is deprecated and removed, and it was never a guarantee. - Confusing
flush()with "it is on the disk".flush()reaches the operating system; onlysync()reaches the platter. For almost everything,close()is enough. - Ignoring that
PrintWriterswallowsIOException. A full disk goes unnoticed and you rename a truncated file over the good one.checkError()before closing, or useBufferedWriter. - Writing over the definitive file. If it fails halfway, you lose the original and get an incomplete one that looks valid. Temporary file and rename.
- Not creating the parent directory.
FileWriterdoes not create it; it throws anIOExceptionwith a message that seems to say something else. - Not specifying the charset when writing. Worse than when reading: the data is recorded wrong, and the problem shows up months later when another tool reads it.
- Writing characters not representable in the target charset. They are replaced by
?without warning.€andéin US-ASCII disappear silently. - Having the reader and the writer use different charsets. Declare the charset only once in a shared constant.
- Using
\nwhen the consumer expects CRLF, or the other way round.printlnandnewLine()use the system one; for files that are versioned, pin\nand document it. - Believing that
write(65)writes "65". It writes the letterA. It is a character, not a number. - Two processes writing the same file. Unpredictable result. A lock file, and even so with its limits.
- Tip: always ask yourself "is this file regenerated or does it grow?". Regenerating demands atomic writing; growing demands
appendmode. The whole lesson fits into that question. - Tip: try filling the disk. Write to a small device —a temporary partition of a few MB— and check what your code does when it runs out. Most code does not cope, and a failure halfway through a file is the scenario atomic writing exists to cover.
- Tip: check with an external editor. Open the generated file with another program and with another encoding. If you only read it with your own code, format and charset errors are invisible.
- Tip: do not write the password or the national ID number to the file. Everything from 06-07 about what not to log applies equally to what is persisted, and with more reason: the file stays.
Exercises
Exercise 1: append demonstrator
Write AppendDemo that empirically demonstrates the difference between the two modes:
- A method
writeLine(String path, String text, boolean append)that writes a line in the given mode with UTF-8 charset. - A method
show(String path)that prints the content and the number of lines. - A
mainthat: deletes the file if it exists; writes three lines withoutappendshowing the state after each one; and repeats the experiment withappend. - A final comment explaining the result in one sentence.
Exercise 2: atomic writing with verification
Extend AtomicWrite with a method writeVerified(File target, Content content, int expectedLines) that, in addition to what it already does:
- Counts the lines actually written to the temporary file.
- Before renaming, checks that the number matches
expectedLines; if not, throwsIOExceptionand does not rename. - Also checks that the temporary file has a size greater than zero.
- Logs the result of the verification.
Write a main that causes the failure on purpose —a lambda that throws an exception halfway— and checks that the target file keeps its previous content.
Exercise 3: rotating audit log
Write BiblioTechAudit that appends lines to an audit file with rotation by size:
record(String operation, String reference, String employeeId)that adds a line with the formatoperation;reference;employeeIdinappendmode.- Before writing, if the file exceeds
MAX_SIZE(use 1 KB so you can test it), rename it toaudit-1.txt, shifting the previous ones up to a maximum of 3, and start a new one. - Explicit charset and a fixed
\nline separator (it is a data file, not a report). - A write failure must not propagate: it is logged and execution continues, following the policy of
CatalogExporter. - A
mainthat generates 200 operations and shows the resulting files with their sizes.
Solutions
Solution 1
package com.nexussoftware.bibliotech.demo;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Scanner;
/**
* Empirical demonstration of the append parameter.
*/
public class AppendDemo {
/** Names for the boolean: avoids the loose, unreadable 'true'. */
private static final boolean APPEND = true;
private static final boolean OVERWRITE = false;
private static final String PATH = "demo-append.txt";
public static void writeLine(String path, String text, boolean append)
throws IOException {
try (FileWriter w = new FileWriter(path, StandardCharsets.UTF_8, append)) {
w.write(text);
w.write('\n');
} // close() flushes the buffer: without it, the file would be empty
}
public static void show(String path) {
File f = new File(path);
if (!f.exists()) {
System.out.println(" (the file does not exist)");
return;
}
System.out.println(" size: " + f.length() + " bytes");
try (Scanner sc = new Scanner(f, StandardCharsets.UTF_8)) {
int n = 0;
while (sc.hasNextLine()) {
System.out.println(" | " + sc.nextLine());
n++;
}
System.out.println(" lines: " + n);
} catch (IOException e) {
System.out.println(" error while reading: " + e.getMessage());
}
}
public static void main(String[] args) throws IOException {
File f = new File(PATH);
if (f.exists() && !f.delete()) {
System.err.println("Could not delete the previous file");
return;
}
System.out.println("=== EXPERIMENT 1: WITHOUT append (overwrite) ===");
for (int i = 1; i <= 3; i++) {
writeLine(PATH, "Operation number " + i, OVERWRITE);
System.out.println(" After writing line " + i + ":");
show(PATH);
}
f.delete();
System.out.println();
System.out.println("=== EXPERIMENT 2: WITH append ===");
for (int i = 1; i <= 3; i++) {
writeLine(PATH, "Operation number " + i, APPEND);
System.out.println(" After writing line " + i + ":");
show(PATH);
}
// CONCLUSION: without append, every write TRUNCATES the file to zero
// at the moment of opening it, so only the last line survives.
// With append, the pointer sits at the end and the content grows.
}
}Output:
=== EXPERIMENT 1: WITHOUT append (overwrite) ===
After writing line 1:
size: 19 bytes
| Operation number 1
lines: 1
After writing line 2:
size: 19 bytes
| Operation number 2
lines: 1
After writing line 3:
size: 19 bytes
| Operation number 3
lines: 1
=== EXPERIMENT 2: WITH append ===
After writing line 1:
size: 19 bytes
| Operation number 1
lines: 1
After writing line 2:
size: 38 bytes
| Operation number 1
| Operation number 2
lines: 2
After writing line 3:
size: 57 bytes
| Operation number 1
| Operation number 2
| Operation number 3
lines: 3Experiment 1 is the bug from section 2 live. Notice that the size stays constant at 19 bytes: the file does not grow because every opening empties it. Nothing fails, nothing warns, and the history does not exist.
Solution 2
package com.nexussoftware.bibliotech.infrastructure;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.util.Scanner;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Atomic writing with verification prior to the rename.
*
* It adds an integrity check BEFORE replacing the good file: if the result
* is not what was expected, the original is kept untouched.
*/
public final class VerifiedAtomicWrite {
private static final Logger LOG =
Logger.getLogger(VerifiedAtomicWrite.class.getName());
private static final String TEMP_SUFFIX = ".tmp";
private VerifiedAtomicWrite() { }
@FunctionalInterface
public interface Content {
void writeTo(PrintWriter output) throws IOException;
}
/**
* Writes atomically, verifying the result.
*
* @param expectedLines number of lines the result MUST have
* @throws IOException if the write or the verification fails
*/
public static void writeVerified(File target, Content content,
int expectedLines) throws IOException {
File temp = new File(target.getAbsolutePath() + TEMP_SUFFIX);
File parent = target.getAbsoluteFile().getParentFile();
if (parent != null && !parent.exists() && !parent.mkdirs()) {
throw new IOException("Could not create the directory " + parent.getAbsolutePath());
}
boolean completed = false;
try {
// ---- PHASE 1: write to the temporary file ----
try (PrintWriter output = new PrintWriter(
new FileWriter(temp, StandardCharsets.UTF_8))) {
content.writeTo(output);
if (output.checkError()) {
throw new IOException("Write failure in " + temp.getName());
}
}
// ---- PHASE 2: verify BEFORE touching the target ----
long size = temp.length();
if (size == 0) {
throw new IOException("The temporary file was left empty; not replacing "
+ target.getName());
}
int actualLines = countLines(temp);
if (actualLines != expectedLines) {
throw new IOException(String.format(
"Verification failed in %s: expected %d lines but found %d. "
+ "The original file has NOT been modified.",
target.getName(), expectedLines, actualLines));
}
LOG.fine(() -> String.format("Verification OK: %d lines, %d bytes",
actualLines, size));
// ---- PHASE 3: replace. Only if everything above went well ----
if (target.exists() && !target.delete()) {
throw new IOException("Could not remove the target " + target.getName());
}
if (!temp.renameTo(target)) {
throw new IOException("Could not rename " + temp.getName());
}
completed = true;
LOG.info(() -> String.format("Verified write of %s: %d lines, %d bytes",
target.getAbsolutePath(), expectedLines, size));
} finally {
// COMPENSATION (06-05): no rubbish, and the original untouched
if (!completed && temp.exists() && !temp.delete()) {
LOG.warning(() -> "Temporary file left undeleted: " + temp.getAbsolutePath());
}
}
}
private static int countLines(File f) throws IOException {
int n = 0;
try (Scanner sc = new Scanner(f, StandardCharsets.UTF_8)) {
while (sc.hasNextLine()) {
sc.nextLine();
n++;
}
}
return n;
}
// ------------------------- DEMONSTRATION -------------------------
public static void main(String[] args) throws IOException {
File target = new File("data/catalog-verified.txt");
// 1. Correct write of 3 lines
writeVerified(target, output -> {
output.println("BOOK;978-0000000001;Effective Java;Bloch;2018");
output.println("BOOK;978-0000000002;Design Patterns;Gamma;1994");
output.println("BOOK;978-0000000003;Refactoring;Fowler;1999");
}, 3);
System.out.println("After the correct write: "
+ target.length() + " bytes, "
+ countLines(target) + " lines");
// 2. A write that FAILS halfway
try {
writeVerified(target, output -> {
output.println("BOOK;978-0000000004;New book;Author;2024");
// Simulated failure: disk full, serialisation error, whatever
throw new IOException("Simulated failure halfway through the export");
}, 4);
} catch (IOException e) {
System.out.println("Failure caught: " + e.getMessage());
}
// 3. THE KEY CHECK: the good file is still there, complete
System.out.println("After the failure: "
+ target.length() + " bytes, "
+ countLines(target) + " lines");
System.out.println("Any .tmp rubbish left? "
+ new File(target.getAbsolutePath() + ".tmp").exists());
// 4. Write with an incorrect number of lines: rejected
try {
writeVerified(target, output -> output.println("Only one line"), 5);
} catch (IOException e) {
System.out.println("Verification: " + e.getMessage());
}
System.out.println("After the failed verification: "
+ countLines(target) + " lines (untouched)");
}
}Output:
After the correct write: 137 bytes, 3 lines
Failure caught: Simulated failure halfway through the export
After the failure: 137 bytes, 3 lines
Any .tmp rubbish left? false
Verification: Verification failed in catalog-verified.txt: expected 5 lines but found 1. The original file has NOT been modified.
After the failed verification: 3 lines (untouched)The three lines that matter in that output are the third, the fourth and the last: after a failure halfway through a write and after a failed verification, the good file still has its 3 lines and no temporary rubbish has been left. That is exactly what the pattern contributes, and it is impossible to achieve by writing directly over the target.
Notice too that the verification goes between the write and the rename. That gap is what makes the pattern so valuable: you can check whatever you like —number of lines, header, format, even reread and parse it entirely— before committing to replace the original.
Solution 3
package com.nexussoftware.bibliotech.infrastructure;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* BiblioTech audit log with rotation by size.
*
* APPEND mode: the file grows, it is not regenerated. That is why it does NOT
* use atomic writing: there is nothing to replace.
*
* Fixed '\n' line separator: it is a DATA file that can be compared between
* machines, not a report to be read on screen (section 8).
*/
public class BiblioTechAudit {
private static final Logger LOG = Logger.getLogger(BiblioTechAudit.class.getName());
private static final boolean APPEND = true;
private static final String NEWLINE = "\n";
private static final String SEP = ";";
/** 1 KB, deliberately small so the rotation can be tested. */
private static final long MAX_SIZE = 1024L;
/** Archived files that are kept: audit-1 .. audit-3. */
private static final int MAX_ARCHIVES = 3;
private final File directory;
private final String baseName;
private int operationsRecorded = 0;
private int rotations = 0;
private int failures = 0;
public BiblioTechAudit(String directory) {
this.directory = new File(
Objects.requireNonNull(directory, "The directory cannot be null"));
this.baseName = "audit";
if (!this.directory.exists() && !this.directory.mkdirs()) {
LOG.warning("Could not create " + this.directory.getAbsolutePath());
}
}
/**
* Records an operation.
*
* POLICY: an audit failure NEVER propagates. The business operation is
* already valid; losing its trace is an acceptable degradation and it
* is reported in the log (06-07).
*/
public void record(String operation, String reference, String employeeId) {
Objects.requireNonNull(operation, "The operation cannot be null");
File current = new File(directory, baseName + ".txt");
try {
// 1. Rotate BEFORE writing, if it is due
if (current.exists() && current.length() >= MAX_SIZE) {
rotate(current);
}
// 2. Append the line. The 'true' is what makes this
// a history and not a one-line file.
try (PrintWriter output = new PrintWriter(
new FileWriter(current, StandardCharsets.UTF_8, APPEND))) {
output.write(String.join(SEP, operation, reference, employeeId));
output.write(NEWLINE);
if (output.checkError()) {
throw new IOException("Failed writing to " + current.getAbsolutePath());
}
}
operationsRecorded++;
} catch (IOException e) {
failures++;
LOG.log(Level.WARNING, "Could not audit " + operation
+ " of " + reference, e);
// No rethrow: the degradation is deliberate
}
}
/**
* Shifts the archives: -2 becomes -3, -1 becomes -2, the current one becomes -1.
*
* It is traversed from HIGHEST to LOWEST. The other way round, the first
* rename would crush the next one before it had been shifted.
*/
private void rotate(File current) throws IOException {
// The oldest one is lost
File oldest = archive(MAX_ARCHIVES);
if (oldest.exists() && !oldest.delete()) {
throw new IOException("Could not delete " + oldest.getName());
}
for (int i = MAX_ARCHIVES - 1; i >= 1; i--) {
File source = archive(i);
File target = archive(i + 1);
if (source.exists() && !source.renameTo(target)) {
throw new IOException("Could not rotate " + source.getName());
}
}
if (!current.renameTo(archive(1))) {
throw new IOException("Could not rotate " + current.getName());
}
rotations++;
LOG.fine(() -> "Audit rotated (rotation number " + rotations + ")");
}
private File archive(int n) {
return new File(directory, baseName + "-" + n + ".txt");
}
public int getOperationsRecorded() { return operationsRecorded; }
public int getRotations() { return rotations; }
public int getFailures() { return failures; }
// ------------------------- DEMONSTRATION -------------------------
public static void main(String[] args) {
BiblioTechAudit audit = new BiblioTechAudit("data/audit");
String[] operations = { "LOAN", "RETURN", "ADD", "REMOVE" };
String[] employees = { "EMP-001", "EMP-002", "EMP-003" };
for (int i = 1; i <= 200; i++) {
audit.record(
operations[i % operations.length],
String.format("LN-%04d", i),
employees[i % employees.length]);
}
System.out.println("=== AUDIT ===");
System.out.printf(" Operations recorded: %d%n",
audit.getOperationsRecorded());
System.out.printf(" Rotations performed: %d%n", audit.getRotations());
System.out.printf(" Failures : %d%n", audit.getFailures());
System.out.println(" --- Files ---");
File dir = new File("data/audit");
File[] files = dir.listFiles(); // it may be null: it is checked
if (files != null) {
java.util.Arrays.sort(files); // 05-09
for (File f : files) {
System.out.printf(" %-20s %6d bytes%n", f.getName(), f.length());
}
}
}
}Output:
=== AUDIT ===
Operations recorded: 200
Rotations performed: 4
Failures : 0
--- Files ---
audit-1.txt 1044 bytes
audit-2.txt 1044 bytes
audit-3.txt 1044 bytes
audit.txt 174 bytesThe four teaching points:
- The rotation loop goes from highest to lowest. It is the detail people get wrong most often. If you rotated from 1 to 3, the first
renameTowould crushaudit-2.txtbefore it had been shifted to-3, and you would lose the whole history except the last one. Draw the three arrows in your head before writing it. - Rotation happens before writing, not afterwards. That way the file never appreciably exceeds the limit, and you do not have to worry about the size of the line about to go in.
- The failure does not propagate and is counted. The
failurescounter lets you detect in the report that the audit is degraded, even though the application keeps working. Degrading without leaving a trace would be worse than failing. listFiles()can returnnull. The check is not paranoia: it returnsnullif the directory does not exist or if access fails. It is the mutebooleanof section 6 of 07-01 in another form, and in 07-06Files.list()solves it.
And notice that this mechanism is exactly what the FileHandler of java.util.logging you configured in 06-07 does internally, with its bibliotech-%g.log, its 5 files and its 1 MB limit. Now you know how it is implemented.
Conclusion
BiblioTech now saves.
You know FileWriter and its behaviour on opening: it creates the file if it does not exist and —this is what to remember above everything else— it truncates it to zero bytes if it does exist, at the instant of construction, before writing anything. You have mastered the append parameter and you know that forgetting it turns a three-hundred-thousand-line history into a one-line file, with no exception, no warning and in a perfectly stable way. You have the three defences: naming the boolean with a constant, using the explicit NIO.2 options you will see in 07-06, and always writing to a temporary file when you regenerate a whole file.
You know how to write with write in its five overloads, with the asymmetry of write(int) which writes a character and not a number, and you know PrintWriter as a wrapper contributing println, printf and format, reusing the formatting of module 1 —with Locale.ROOT when a program is going to read the file and the system Locale when a person is. And you know its greatest trap: PrintWriter does not throw IOException, it stores it in a flag, so a full disk goes unnoticed if you do not call checkError().
You understand the buffer and the complete chain of flushes: write reaches the application buffer, flush reaches the operating system, close does a flush and releases, and only sync reaches the disk platter. You know why a just-written file has 0 bytes, why tail -f shows nothing for a while, and exactly what happens if the program ends without closing: the file is left empty, because the garbage collector closes nothing and finalize() has been removed. That is why try-with-resources matters more when writing than when reading: there, not closing is a leak; here it is data loss.
You know that an explicit charset is even more critical when writing, because the mistake is recorded and only appears when another tool reads the file; and that writing an unrepresentable character —an é in US-ASCII— replaces it with ? without saying anything. You know System.lineSeparator(), the three end-of-line conventions, and the criterion for choosing: the system one for reports, a fixed \n for data files that are compared or versioned. And you know that the charset and the separator must be declared only once in a constant shared by the reader and the writer.
And you have the pattern that separates amateur code from professional code: atomic writing. Write to a temporary file, verify, and rename only at the end. You know why it works —the rename is atomic for whoever reads, so a half-finished file is never seen—, you know that the finally compensates by deleting the temporary file exactly as you learned in 06-05, and you know that the target needs no compensation because it was never opened. And you know when to apply it: when the file is regenerated; never when the file grows, which is the append mode case. The whole lesson fits into that question: is this file regenerated or does it grow?
You also know lock files with createNewFile() as an atomic check-and-create operation, their honest limitations —orphaned locks, the FileLock alternative—, and the rotating backups that 07-06 will do properly with NIO.2.
BiblioTech, at the close of this lesson, loads its catalogue on start-up and saves it on exit. CatalogExporter really writes, atomically, shares the FileFormat constants with CatalogLoader so that they cannot disagree, and has stopped being Closeable because it no longer owns any live resource —an object with nothing to close should not promise that it closes. Its audit log is opened in append mode because it grows, and its failures do not bring down the business operation but degrade with a warning in the log. And a shutdown hook guarantees the save on normal exit, with the explicit warning that a kill -9 skips it and that the serious strategy is to save during execution too.
There remains an underlying question the two lessons have dodged. You have used FileReader, FileWriter, Scanner and PrintWriter as if they were loose pieces, and they are not: they are part of a design with a very deliberate structure. Why does PrintWriter wrap FileWriter instead of replacing it? Why are there two different families of classes, some ending in Reader/Writer and others in InputStream/OutputStream? And how do you read an image, which is not text?
In lesson 07-03, File Streams, that is answered. You will see the conceptual model of the stream with its vocabulary of source, destination and direction; the two hierarchies —bytes and characters— and why there had to be two; the distinction between node classes and filter classes, which is the Decorator pattern in its purest form and explains once and for all those new BufferedInputStream(new FileInputStream(...)) that appear everywhere; the bridges InputStreamReader and OutputStreamWriter, which are exactly the point where the encoding you have been specifying for two lessons is decided; reading and writing binary data by bytes and by blocks, with the copying of a book's cover image; and transferTo, the modern way of copying a whole stream in one line. By the end of it, you will stop using these classes from memory and start composing them knowing exactly what each layer does.
Java Programming Course
Module 1: Introduction to Java
- Introduction to Java
- Setting Up the Development Environment
- Basic Syntax and Structure
- Variables and Data Types
- Operators
- Console Input and Output
- Your First Complete Program: BiblioTech
Module 2: Control Flow
- Conditional Statements
- Loops
- Switch Statements
- Break and Continue
- Debugging and Execution Traces
- Project: The BiblioTech Interactive Menu
Module 3: Object-Oriented Programming
- Introduction to OOP
- Classes and Objects
- Methods
- Constructors
- Inheritance
- Polymorphism
- Encapsulation
- Abstraction
- The Object Class: equals, hashCode and toString
Module 4: Advanced Object-Oriented Programming
- Interfaces
- Abstract Classes
- Inner Classes
- Anonymous Classes
- Lambda Expressions
- Functional Interfaces and Method References
- Enums and Records
Module 5: Data Structures and Collections
- Arrays
- The Collections Framework
- ArrayList
- LinkedList
- HashMap
- HashSet
- Queue and Deque
- Stack
- Sorting and Searching Collections
Module 6: Exception Handling
- Introduction to Exceptions
- The Try-Catch Block
- Throw and Throws
- Custom Exceptions
- The Finally Block
- Try-with-resources and AutoCloseable
- Error Handling Strategies and Logging
Module 7: File Input/Output
- Reading Files
- Writing Files
- File Streams
- BufferedReader and BufferedWriter
- Serialization
- The NIO.2 API: Path and Files
- Interchange Formats: CSV and Properties
Module 8: Multithreading and Concurrency
- Introduction to Multithreading
- Creating Threads
- Thread Lifecycle
- Synchronization
- Concurrency Utilities
- Concurrent Collections and Atomic Variables
- Asynchronous Tasks with CompletableFuture
Module 9: Networking
- Introduction to Networking
- Sockets
- ServerSocket
- DatagramSocket and DatagramPacket
- URL and HttpURLConnection
- The Modern HTTP Client
Module 10: Advanced Topics
- Generics
- Annotations
- Reflection
- Java 8 Features: Streams and Optional
- Dates and Times with java.time
- Java 9 and Beyond
- Memory, Garbage Collection and Performance
Module 11: Java Frameworks and Libraries
- Introduction to Java Frameworks
- Spring Framework
- Hibernate
- JUnit
- Maven
- Advanced Testing with Mockito
- Essential Ecosystem Libraries
