和低效 IO 說再見,回頭補一波 Java 7 的 NIO.2 特性

未讀程式碼發表於2020-09-29

其實在這之前已經寫過一篇關於 Java 7 的新特性文章了,那篇文章主要介紹了 Java 7 的資源自動關閉、Switch String 實現原理、異常捕獲 try-catch、新的二進位制書寫方式等,具體的內容也可以看下當初的這篇文章(補一波 Java 7 語法特性)。而在那篇文章裡唯獨沒有介紹到 Java 7 中對於 IO 操作的更新,而這部分恰好又是非常重要的一部分,該還的總是要還的,現在補上。

看完這篇文章你會了解到:

  1. 抽象檔案路徑操作方式,直觀方便少 BUG。
  2. 高效的檔案操作方式,寫入讀取複製檔案僅需一行
  3. 快速獲取不同系統下的檔案屬性
  4. 遍歷目錄下檔案和目錄的多種方式,且十分高效。
  5. 反應式事件通知,監測檔案變化。

在 Java 7 中,加強了檔案操作相關功能,也就是新的 java.nio.file 包裡的內容,它提供了諸如檔案路徑抽象、檔案目錄流、目錄樹、檔案屬性和變化監視服務等功能,可以大幅度提高我們對於檔案的操作。

NIO.2

檔案路徑

在 Java 7 之前對檔案路徑的操作都是以字串的操作,使用時你需要把一個字串直接扔進去,直接使用字串操作是低效的,比如你要拼接父路徑和子目錄,你只能進行字串的拼接。而且拼接這個本身操作就丟失了它作為檔案路徑的含義。另外使用字串進行各種路徑操作很有可能由於拼寫錯誤而出現各種問題。

Java 7 的到來讓這一切變的不一樣了,它提供了 Path 介面用來表示路徑的抽象,然後提供了一系列對於路徑的操作方法,讓這一切變得如此簡單。

為了方便的建立 Path 物件,又提供了Paths 工具類,如何使用讓我們先睹為快。

一切都從 Path path = Paths.get("/Users/darcy/java/"); 獲取一個 Path 物件開始。

Path path = Paths.get("/Users/darcy/java/");
System.out.println("完整路徑:" + path.toString());

Path pathParent = path.getParent();
System.out.println("父級路徑:" + pathParent.toString());

Path pathRoot = path.getRoot();
System.out.println("根目錄:" + pathRoot.toString());

int pathNameCount = path.getNameCount();
System.out.println("目錄深度:" + pathNameCount);

Path pathIndex3 = path.getName(2);
System.out.println("第三級目錄:" + pathIndex3);

Path subPath = path.subpath(1, 3);
System.out.println("第1級目錄到第三級目錄(包左不包右):" + subPath.toString());

// resolveSibling 從當前目錄父目錄開始拼接目錄
Path pathResolveSibling = path.resolveSibling("PathDemo.java");
System.out.println("父目錄開始拼接引數:" + pathResolveSibling.toString());

// resolve 把當前路徑當作父路徑,引數作為子目錄或者檔案
Path pathResolve = Paths.get("/Users/darcy/java/").resolve("PathDem.java");
System.out.println("當前目錄拼接後的目錄:" + pathResolve.toString());

// 引數路徑相對於主體路徑的相對路徑
Path path1 = Paths.get("/Users/darcy/");
Path path2 = Paths.get("/Users/darcy/java/PathDemo.java");
Path path3 = path1.relativize(path2);
System.out.println("相對路徑:" + path3.toString());

/* 輸出結果
完整路徑:/Users/darcy/java
父級路徑:/Users/darcy
根目錄:/
目錄深度:3
第三級目錄:java
第1級目錄到第三級目錄(包左不包右):darcy/java
父目錄開始拼接引數:/Users/darcy/PathDemo.java
當前目錄拼接後的目錄:/Users/darcy/java/PathDem.java
相對路徑:java/PathDemo.java
*/

可以看到上面程式碼裡除了建立 Path 物件時輸入了一次路徑,後續的操作都是使用 Path 中的方法進行操作的,在此之前你可能需要各種字串擷取拼接,十分繁瑣。

檔案操作

還記得初學 Java IO 時,檔案複製有多種寫法,但是不管是哪一種,寫起來都需要不少的程式碼,而且還需要考慮複製時的效能。讀取檔案那就更不用說了,定義各種讀取和接收變數,各種驗證。現在不一樣了,不僅檔案操作非常方便,而且像檔案複製和讀取等常用操作都可以一行搞定

使用過於簡單,直接程式碼。

// 如果檔案不存在,則建立一個檔案
Path path = Paths.get("test.txt");
Path pathBackup = Paths.get("test_bak.txt");
Path pathLink = Paths.get("test.txt.link");
Path pathDir = Paths.get("dir");

// 已存在則刪除
Files.deleteIfExists(path);
Files.deleteIfExists(pathBackup);
Files.deleteIfExists(pathLink);
Files.deleteIfExists(pathDir);

// 建立檔案寫入內容
Path file = Files.createFile(path);
Files.write(path, "關注公眾號:未讀程式碼".getBytes());
Files.write(path, System.lineSeparator().getBytes(), StandardOpenOption.APPEND);
Files.write(path, "歡迎加我微信:wn8398".getBytes(), StandardOpenOption.APPEND);
System.out.println("建立檔案:" + file.toString());

// 建立檔案連結
pathLink = Files.createLink(pathLink, path);
System.out.println("建立檔案:" + pathLink.toString());

// 建立目錄
Path directory = Files.createDirectory(pathDir);
System.out.println("建立目錄:" + directory.toString());

// 檔案複製
Files.copy(path, pathBackup);
System.out.println("複製檔案: " + path + " --> " + pathBackup);

// 讀取檔案
List<String> lines = Files.readAllLines(pathBackup);
for (String line : lines) {
    System.out.println("檔案讀取:" + line);
}

上面展示了 Files 類的檔案建立、刪除、寫入、拷貝、讀取的寫法,都是隻有一行程式碼。

檔案屬性

和路徑操作類似,Java 7 也提供了檔案屬性的抽象,增加了一系列檔案屬性的操作工具類。這部分程式碼在 java.nio.file.attribute 包內。它抽象出了一個 AttributeView 作為所有屬性檢視的父介面,然後用它的子類 Fi leAttributeView 表示檔案檢視,用子類 FileOwnerAttributeView 表示檔案所有者的屬性檢視。前者屬性如檔案的建立時間、修改時間、是否目錄等資訊,後者則包含檔案的相關資訊。為了相容不同的作業系統,Java 7 還提供了不同實現,如 DosFileAttributeView 檢視,很明顯他是為 Windows 作業系統準備的。

Java7 檔案屬性

使用起來過於簡單,直接程式碼奉上。

Path path = Paths.get("/Users/darcy/git/jdk-feature/README.md");
BasicFileAttributeView fileAttributeView = Files.getFileAttributeView(path, BasicFileAttributeView.class);
BasicFileAttributes basicFileAttributes = fileAttributeView.readAttributes();
FileTime creationTime = basicFileAttributes.creationTime();
FileTime lastModifiedTime = basicFileAttributes.lastModifiedTime();
FileTime lastAccessTime = basicFileAttributes.lastAccessTime();
System.out.println("建立時間:" + creationTime);
System.out.println("上次修改時間:" + lastModifiedTime);
System.out.println("上次訪問時間:" + lastAccessTime);

boolean directory = basicFileAttributes.isDirectory();
boolean regularFile = basicFileAttributes.isRegularFile();
boolean symbolicLink = basicFileAttributes.isSymbolicLink();
System.out.println("是否目錄:" + directory);
System.out.println("是否普通檔案:" + regularFile);
System.out.println("是否符號連結:" + symbolicLink);

long size = basicFileAttributes.size();
System.out.println("檔案大小:" + size);

PosixFileAttributeView linuxFileAttributeView = Files.getFileAttributeView(path, PosixFileAttributeView.class);
UserPrincipal owner = linuxFileAttributeView.getOwner();
System.out.println("檔案歸屬使用者:" + owner.getName());

示例程式碼執行後得到如下輸出。

建立時間:2020-09-06T13:35:14Z
上次修改時間:2020-09-06T13:35:14.649261371Z
上次訪問時間:2020-09-06T13:35:14.680968254Z
是否目錄:false
是否普通檔案:true
是否符號連結:false
檔案大小:3636
檔案歸屬使用者:darcy

檔案列表流

在 Java 7 之前遍歷檔案目錄和檔案,你應該會選擇 File 類的 listFiles 方法。

// 檔案直接遍歷,不會遍歷子目錄
String pathString = "/Users/darcy/project/mylab/src/main/java/com/wdbyte/java";
File file = new File(pathString);
File[] listFiles = file.listFiles();
for (File tempFile : listFiles) {
    System.out.println("file list: " + tempFile.getAbsolutePath());
}

這種遍歷方式看起來也是十分優雅的,可是這種方式在面對大量檔案時,效率會變的很低。所以 Java 7 也對此進行了改進,引入了 DirectoryStream 檔案列表流。它可以進行漸進式的檔案遍歷,每次讀取一定數量,降低遍歷時的效能開銷,但是 DirectoryStream 遍歷時只會遍歷它的直接目錄和檔案,不會遞迴的遍歷子目錄。下面是它的遍歷寫法。

String pathString = "/Users/darcy/project/mylab/src/main/java/com/wdbyte/java";
// Path 直接遍歷方式,不會遍歷子目錄
try (DirectoryStream<Path> directoryStream = Files.newDirectoryStream(Paths.get(pathString))) {
    for (Path pathTemp : directoryStream) {
        System.out.println("DirectoryStream: " + pathTemp);
    }
}

// Path 直接遍歷方式 - 篩選 .class 檔案
try (DirectoryStream<Path> directoryStream = Files.newDirectoryStream(Paths.get(pathString), "*.java")) {
    for (Path pathTemp : directoryStream) {
        System.out.println("DirectoryStream file type is class : " + pathTemp);
    }
}

這裡擴充套件一下,在 Java 8 中對 Files 類進行了增強,引入了 Java 8 的 Lambda 表示式,增加了 walk 方法,遍歷檔案也有異曲同工之妙(下面的例子中用到了 Lambda 表示式)。

// 遍歷所有目錄和子目錄
Stream<Path> pathStream = Files.walk(Paths.get("/Users/darcy/project/mylab/src/main/java/com/wdbyte"));
pathStream.forEach(pathTemp -> {
    System.out.println("Stream: " + pathTemp.toString());
});

// 遍歷所有目錄和子目錄 - 篩選 java 檔案
pathStream = Files.walk(Paths.get("/Users/darcy/project/mylab/src/main/java/com/wdbyte"));
pathStream
    .filter(pathTemp -> pathTemp.toString().endsWith(".java"))
    .forEach(pathTemp -> {
        System.out.println("Stream filter java: " + pathTemp.toString());
    });

檔案監視

檔案監視,也就是可以動態的監測指定目錄的檔案或者內容的變化,應用場景很多,比如熱部署時檢查 class 檔案是否更新,或者每當有檔案進來時就進行操作。在這之前你只能通過迴圈呼叫 listFiles 並與上次的呼叫結果對比才可以找到檔案的變化,而現在可以通過通知的方式進行反應式的邏輯處理,一切變的簡單了。

被監視的物件要實現 Watchable 介面,然後通過 register 方法註冊到監視服務 WatchService 介面的實現,同時指定要監視的事件型別。

// 建立
StandardWatchEventKinds.ENTRY_CREATE,
// 刪除
StandardWatchEventKinds.ENTRY_DELETE,
// 更新
StandardWatchEventKinds.ENTRY_MODIFY

具體怎麼使用呢?通過下面這個例子看下程式碼如何實現,下面的程式碼對資料夾 /Users/darcy/test 進行監測,註冊的感興趣事件是建立、刪除、更新操作。

WatchService watchService = FileSystems.getDefault().newWatchService();
Path path = Paths.get("/Users/darcy/test");
path.register(watchService,
    StandardWatchEventKinds.ENTRY_CREATE,
    StandardWatchEventKinds.ENTRY_DELETE,
    StandardWatchEventKinds.ENTRY_MODIFY);

while (true) {
    WatchKey watchKey = watchService.take();
    // 獲取事件型別
    for (WatchEvent<?> pollEvent : watchKey.pollEvents()) {
        // 具體的事件上下文資訊
        Path tempPath = (Path)pollEvent.context();
        Kind<?> kind = pollEvent.kind();
        if (kind.name().equals(StandardWatchEventKinds.ENTRY_CREATE.name())) {
            System.out.println("建立了一個檔案:" + tempPath.toString());
        }
        if (kind.name().equals(StandardWatchEventKinds.ENTRY_DELETE.name())) {
            System.out.println("刪除了一個檔案:" + tempPath.toString());
        }
        if (kind.name().equals(StandardWatchEventKinds.ENTRY_MODIFY.name())) {
            System.out.println("修改了一個檔案:" + tempPath.toString());
        }
    }
    // 事件處理完畢後要進行 reset 才能繼續監聽事件
    watchKey.reset();
    // 取消監視
    // watchKey.cancel();
}

註冊事件監聽後,通過一個迴圈,呼叫 take() 方法獲取事件結果,得到事件後再判斷事件型別進行日誌輸出。我啟動後進行了簡單測試,下面是日誌輸出。

# 下面是我的操作
➜  test pwd 
/Users/darcy/test
➜  test touch test.txt # 建立檔案
➜  test vim test.txt # 修改檔案
➜  test rm test.txt # 刪除檔案
# 得到的日誌輸出
建立了一個檔案:test.txt
建立了一個檔案:.test.txt.swp
修改了一個檔案:test.txt
刪除了一個檔案:.test.txt.swp
刪除了一個檔案:test.txt

因為使用 vim 編輯,所以有臨時的 swp 檔案生成和自動刪除,也被監測到了。

往期 Java 新特性系列文章:

最後的話

文章已經收錄在 Github.com/niumoo/JavaNotes ,歡迎Star和指教。更有一線大廠面試點,Java程式設計師需要掌握的核心知識等文章,也整理了很多我的文字,歡迎 Star 和完善,希望我們一起變得優秀。

文章有幫助可以點個「」或「分享」,都是支援,我都喜歡!
文章每週持續更新,要實時關注我更新的文章以及分享的乾貨,可以關注「 未讀程式碼 」公眾號或者我的部落格

公眾號

相關文章