Skip to Content
Course content

98: Working with Path and File APIs

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I’ve spent a fair amount of time in the past fighting with file systems, and if there's one thing I've learned, it's that Java (and by extension, Kotlin) has two ways of doing almost everything when it comes to files. We have the old java.io.File and the newer java.nio.file (NIO.2) API. If you're looking at a legacy codebase, you'll see File everywhere. But for anything new, we want Path.

Let's say we're building a small utility to manage a directory of server logs. I've got a folder called /logs/server_logs, and I want to find the largest log file in there to see if it's bloating our disk space. Let's start with the most intuitive way and see where it breaks.

The "Old Way" Frustration

My first instinct might be to use java.io.File because it feels simple. I'll try to list the files and find the biggest one:

import java.io.File

fun findBiggestLogOldWay() {
    val logDir = File("logs/server_logs")
    val files = logDir.listFiles() 
    
    // What if listFiles() returns null? 
    // (It does if the directory doesn't exist or isn't a directory)
    val biggest = files?.maxByOrNull { it.length() }
    println("Biggest file is: ${biggest?.name} at ${biggest?.length()} bytes")
}

This works... mostly. But java.io.File has a nasty habit: many of its methods return null or a boolean instead of throwing an actual exception when something goes wrong. If listFiles() fails, I just get a null. That's a recipe for NullPointerException land if I'm not incredibly careful with my optional chaining. Plus, it doesn't handle symbolic links very well. I'm not a fan of guessing why a file operation failed.

Switching to Path and Files

Let's pivot to the NIO.2 API. Instead of File, we use Path to represent the location and the Files utility class to actually do the work. This separates the "where" from the "how."

import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths

fun findBiggestLogNewWay() {
    val logPath = Paths.get("logs/server_logs")
    
    // Files.list returns a Stream of Paths
    val biggest = Files.list(logPath).max(java.util.Comparator.comparingLong { 
        Files.size(it) 
    })
    
    println("Biggest file: ${biggest.orElseThrow().fileName}")
}

This feels more modern. Paths.get() is clean, and Files.size(it) is explicit. But if I run this in a loop or a production environment, I'm going to hit a wall. I just noticed something in the documentation: Files.list() opens a directory stream. If I don't close it, I'm leaking file handles. That's a classic "junior dev" mistake that leads to "Too many open files" errors in production.

Closing the Leak

In Kotlin, we have a wonderful tool for this: .use { ... }. It's our version of try-with-resources. Since Files.list() returns a Java Stream (which implements AutoCloseable), I can wrap it. But since we're in Kotlin, I'd rather be working with Kotlin Sequences or Collections than Java Streams.

Let's refine this. I'll use asSequence() to make the code more idiomatic, while ensuring the stream is closed:

import java.nio.file.Files
import java.nio.file.Paths
import kotlin.streams.asSequence

fun findBiggestLogRefined() {
    val logPath = Paths.get("logs/server_logs")
    
    Files.list(logPath).use { stream ->
        val biggest = stream.asSequence()
            .filter { it.toString().endsWith(".log") }
            .maxByOrNull { Files.size(it) }
            
        if (biggest != null) {
            println("Found it: ${biggest.fileName} (${Files.size(biggest)} bytes)")
        } else {
            println("No log files found.")
        }
    }
}

Now we're talking. We've filtered out non-log files, we're using a lazy sequence for efficiency, and most importantly, the .use block ensures the directory handle is closed the moment we're done, regardless of whether the code succeeded or threw an exception.

Handling the "Path" vs "String" Trap

One last thing I've noticed when mentoring others: people often try to treat Path objects as strings. You'll see someone do path.toString() + "/file.txt". Please, don't do that. Different operating systems use different separators (forward slash vs. backslash).

If I want to point to a specific file inside my log directory, I should use resolve():

val logPath = Paths.get("logs/server_logs")
val specificFile = logPath.resolve("error.log") 
// Result: logs/server_logs/error.log (on Linux) or logs\server_logs\error.log (on Windows)

It's a small detail, but it's the difference between code that only works on your MacBook and code that works on the Linux server where the app actually lives.




📋 Practical Task

Exercise: The Log Archive Pruner

You need to build a utility that helps prevent disk overflow. Create a function called pruneLargeLogs that takes a Path to a directory and a Long representing a maximum allowed file size in bytes.

Your function should:

  1. List all files in the provided directory.
  2. Filter for files that end with the .log extension.
  3. Identify any log files that exceed the maximum size.
  4. Delete those oversized files using Files.delete(path).
  5. Print the name of each file that was deleted.
Requirements:
  • Use java.nio.file.Path and java.nio.file.Files.
  • Ensure the directory stream is properly closed using .use { ... }.
  • Use .asSequence() to handle the file filtering and processing.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.