本章探討 Unix 檔案搜尋系統的設計。目標是設計一組類別,抽象化檔案搜尋系統中的關鍵實體(目錄、檔案、過濾條件),建構出清晰、可運作且具擴展性的結構。

Unix file search

需求蒐集#

題目情境#

想像你是一位開發者,想在 Unix 系統的深層目錄中找特定檔案——例如某使用者擁有的、或符合某 pattern 的文字檔。你執行搜尋指令並指定條件,系統快速回傳符合的檔案。背景中,它遞迴遍歷目錄、評估檔案屬性並套用過濾條件。請設計這樣的系統。

需求釐清對話#

Candidate:find 指令以哪些屬性搜尋?

Interviewer:依 size、file type、filename、owner 等條件。

Candidate:需要處理目錄嗎?

Interviewer:是的,目錄也視為一種檔案,只是 file type 不同。

Candidate:支援哪些比較類型?

Interviewer:依屬性類型而定。字串支援 equals 與 regex match;數值支援 greater than、equals、less than。

Candidate:能否在同一屬性上組合多重條件?

Interviewer:可以,使用 and、or、not 邏輯運算組合。

Candidate:所以是設計一個從目錄遞迴搜尋並回傳符合條件檔案的系統?

Interviewer:是的。

具體範例#

  • 簡單搜尋:在 / 內遞迴尋找 size > 10 的檔案
  • 複雜搜尋((size > 10 and size < 1000 and owner = "alice") or (size > 1000 and !(filename matches /prefix.*/)))

需求整理#

功能性需求#

  • 可依 sizetypefilenameowner 等屬性搜尋檔案
  • 不同屬性支援不同比較類型(字串:equals/regex match;數值:greater than/equals/less than)
  • 可用 and、or、not 邏輯運算組合多重條件
  • 支援目錄內的遞迴搜尋
  • 搜尋條件可同時套用於檔案與目錄

非功能性需求#

  • 可擴展性:高效處理含數百萬檔案的大型目錄樹
  • 可擴充性:支援新增屬性(如修改時間)與比較運算子,而不更動既有遍歷或過濾邏輯
  • 關注點分離:遍歷邏輯與過濾邏輯需保持分離

辨識核心物件#

  • FileSearch:管理搜尋流程的中心實體,作為應用邏輯入口。從某個 File(目錄)遞迴遍歷檔案系統,並依 FileSearchCriteria 回傳符合者
  • File:模擬檔案系統中的檔案或目錄,儲存大小、類型、檔名、擁有者等屬性,並支援階層結構

Design Choice:File 物件以單一實體代表檔案與目錄,讓 FileSearch 能進行一致的遍歷與評估。這呼應 Unix「萬物皆檔案(everything is a file)」的設計哲學。

  • FileSearchCriteria:封裝搜尋條件,並透過委派 Predicate 來判斷檔案是否符合。將執行邏輯(FileSearch)與條件評估邏輯(Predicate)解耦
  • Predicate:定義評估檔案是否符合條件的契約介面,支援簡單檢查與複合條件
  • SimplePredicate:實作 Predicate,比較單一屬性與目標值
  • CompositePredicate:擴充 Predicate 以組合條件,包括 AndPredicate、OrPredicate、NotPredicate
  • ComparisonOperator:定義屬性值如何比較的介面,具體類別包括 EqualsOperator、RegexMatchOperator、GreaterThanOperator、LessThanOperator

替代方案:可將 FileSearchCriteria 與 Predicate 合併並直接內嵌於 FileSearch。這雖簡化結構但會讓搜尋邏輯與條件評估緊密耦合,難以替換。

類別圖設計#

File#

我們不直接使用 Java 標準函式庫的 java.io.File,而是自訂 File 類別作為核心實體,搭配 FileAttribute enum 定義可搜尋屬性。

File class

Design Choice:FileAttribute 定為 enum 提供型別安全的固定屬性集合,確保只用合法屬性,並避免執行期錯誤。新增屬性(如修改時間)只需擴充 enum,不動既有邏輯。

FileSearch#

FileSearch 負責從給定 File 開始遍歷檔案系統,使用 FileSearchCriteria 篩選並回傳符合的檔案。把遍歷與過濾分離維持了模組化與可擴展性。

FileSearch class diagram

FileSearchCriteria#

FileSearchCriteria 連結 FileSearch 與 Predicate,告訴 FileSearch 何謂「符合」。

FileSearchCriteria class diagram

Design Choice:FileSearchCriteria 委派 Predicate 處理評估邏輯,職責清楚且模組化。如此可在不更動 FileSearch 或 FileSearchCriteria 的前提下,更換或組合過濾邏輯。

Predicate 與 SimplePredicate#

關鍵設計之一是定義「哪些檔案應出現在結果中」的條件。這些條件可從簡單到複雜:

  • 名稱符合 regex report.*
  • 大小超過 10 bytes

我們以 Predicate 介面為評估基礎,定義單一方法:接受 File,回傳 boolean。

對於單純條件,SimplePredicate 評估單一屬性對某值的比較,運用 FileAttribute enum 與 ComparisonOperator 實例。

Predicate interface and concrete class

ComparisonOperator#

ComparisonOperator 介面定義「比較檔案屬性與期望值」的契約。具體實作:

  • EqualsOperator — 確認兩值是否相等(例如 owner 是否為 ‘bob’)
  • GreaterThanOperator — 驗證一值是否較大(例如 size 是否 > 10)
  • LessThanOperator — 確認一值是否較小
  • RegexMatchOperator — 評估字串值是否符合 regex

ComparisonOperator interface and concrete classes

替代方案的取捨

  • 字串表示(如 "equals"">"):實作簡單,但驗證移至執行期,效能差且易拋例外
  • enum(如 EQUALSGREATER_THAN):型別安全且編譯期可檢,但新增運算需更動 enum
  • 介面(本設計):可建立新類別而不動既有程式碼,遵循開放/封閉原則

Composite Predicate#

真實搜尋常需以邏輯運算組合多重條件。我們採用 Composite design pattern 從簡單條件構築複雜述詞。

想了解 Composite Pattern,請參考章末**延伸閱讀**。

考慮以下搜尋:

((size > 10 and size < 1000 and owner = "alice") or (size > 1000 and !(filename matches /prefix.*/)))

把每個簡單條件標記為:

  • A:size > 10
  • B:size < 1000
  • C:owner = ‘alice’
  • D:size > 1000
  • E:filename matches ‘prefix.*’

整體變為:((A and B and C) or (D and !(E)))

這種樹狀結構需要系統化的方式評估。

Tree evaluation

我們定義 CompositePredicate 介面(繼承 Predicate),具體實作:

  • AndPredicate:接受多個 predicate,全部成立才回傳 true
  • OrPredicate:任一成立即回傳 true
  • NotPredicate:包裝單一 predicate 並反轉結果

CompositePredicate interface and concrete classes

完整類別圖#

Class Diagram of File Search

程式實作#

File#

File 類別同時模擬檔案與目錄,封裝 size、name、owner 等屬性與一個布林旗標標示是否為目錄。它透過子 File 集合維持階層結構,並設計為不可變(immutable)——屬性在建構時設定後即不可更動。

// Represents a file or directory in the file system
// Contains basic file attributes and supports hierarchical structure
public class File {
    private final boolean isDirectory;
    private final int size;
    private final String owner;
    private final String filename;
    // Set of directory entries (files and subdirectories)
    private final Set<File> entries = new HashSet<>();

    // Creates a new file with the specified attributes
    public File(
            final boolean isDirectory,
            final int size,
            final String owner,
            final String filename) {
        this.isDirectory = isDirectory;
        this.size = size;
        this.owner = owner;
        this.filename = filename;
    }

    // Extracts the value of a specified file attribute
    public Object extract(final FileAttribute attributeName) {
        switch (attributeName) {
            case SIZE -> {
                return size;
            }
            case OWNER -> {
                return owner;
            }
            case IS_DIRECTORY -> {
                return isDirectory;
            }
            case FILENAME -> {
                return filename;
            }
        }
        throw new IllegalArgumentException("invalid filter criteria type");
    }

    // Adds a file or directory entry to this directory
    public void addEntry(final File entry) {
        entries.add(entry);
    }

    // getter methods omitted for brevity
}

// Represents the different attributes that can be checked for a file
public enum FileAttribute {
    IS_DIRECTORY,
    SIZE,
    OWNER,
    FILENAME
}

關鍵方法:

  • extract — 將 FileAttribute enum 對應到實際欄位值,提供 SimplePredicate 評估所需資料
  • addEntry — 將 File 加入 entries 集合,建構支援遞迴遍歷的階層結構

Predicate#

// Base interface for all file search predicates
public interface Predicate {
    // Checks if the given file matches the search condition
    boolean isMatch(final File inputFile);
}

抽象介面確保彈性,支援從簡單檢查到複雜組合的所有情境。

ComparisonOperator#

我們使用 generics<T>)強制型別安全,確保數值屬性只與數值比較、字串屬性只與字串比較。

// Base interface for all comparison operations in the file search system
public interface ComparisonOperator<T> {
    boolean isMatch(final T attributeValue, final T expectedValue);
}

具體實作:

// Implements exact equality comparison between values
public class EqualsOperator<T> implements ComparisonOperator<T> {
    @Override
    public boolean isMatch(final T attributeValue, final T expectedValue) {
        return Objects.equals(attributeValue, expectedValue);
    }
}

// Implements greater than comparison for numeric values
class GreaterThanOperator<T extends Number> implements ComparisonOperator<T> {
    @Override
    public boolean isMatch(final T attributeValue, final T expectedValue) {
        return Double.compare(attributeValue.doubleValue(), expectedValue.doubleValue()) > 0;
    }
}

// Implements less than comparison for numeric values
class LessThanOperator<T extends Number> implements ComparisonOperator<T> {
    @Override
    public boolean isMatch(final T attributeValue, final T expectedValue) {
        return Double.compare(attributeValue.doubleValue(), expectedValue.doubleValue()) < 0;
    }
}

// Implements regular expression pattern matching for string values
public class RegexMatchOperator<T extends String> implements ComparisonOperator<T> {
    @Override
    public boolean isMatch(final T attributeValue, final T expectedValue) {
        final Pattern p = Pattern.compile(expectedValue);
        return p.matcher(attributeValue).matches();
    }
}

Implementation Choice:使用 generics 在編譯期強制型別安全,例如 Double 對應 size、String 對應 filename。

SimplePredicate#

// A basic predicate that compares a file attribute with an expected value
public class SimplePredicate<T> implements Predicate {
    // The name of the file attribute to check
    private final FileAttribute attributeName;
    // The operator to use for comparison (equals, contains, greater than, etc.)
    private final ComparisonOperator<T> operator;
    // The expected value to compare against
    T expectedValue;

    // Creates a new simple predicate with the specified attribute, operator, and
    // expected value
    public SimplePredicate(
            final FileAttribute attributeName,
            final ComparisonOperator<T> operator,
            final T expectedValue) {
        this.attributeName = attributeName;
        this.operator = operator;
        this.expectedValue = expectedValue;
    }

    @Override
    public boolean isMatch(final File inputFile) {
        // Extract the actual value of the attribute from the file
        Object actualValue = inputFile.extract(attributeName);
        // Check if the actual value is of the correct type
        if (expectedValue.getClass().isInstance(actualValue)) {
            // Perform the comparison using the specified operator
            return operator.isMatch((T) actualValue, expectedValue);
        } else {
            return false;
        }
    }
}

CompositePredicate#

CompositePredicate 介面繼承 Predicate 以組合多個述詞為複雜條件。我們遵循 Composite design pattern,將述詞組成一棵樹:簡單者(SimplePredicate)為葉節點、組合者(AndPredicate 等)為內部節點,皆共享 Predicate 介面。

public interface CompositePredicate extends Predicate {
    // This interface is intentionally empty as it serves as a marker
    // to identify predicates that combine multiple other predicates (AND, OR, NOT)
}

// Implements logical AND operation between multiple predicates
public class AndPredicate implements CompositePredicate {
    // List of predicates that must all match for this predicate to match
    private final List<Predicate> operands;

    // Creates a new AND predicate with the specified predicates
    public AndPredicate(final List<Predicate> operands) {
        this.operands = operands;
    }

    // Checks if the given file matches ALL predicates
    @Override
    public boolean isMatch(final File inputFile) {
        return operands.stream().allMatch(predicate -> predicate.isMatch(inputFile));
    }
}

// Implements logical OR operation between multiple predicates
public class OrPredicate implements CompositePredicate {
    // List of predicates, at least one of which must match
    private final List<Predicate> operands;

    // Creates a new OR predicate with the specified predicates
    public OrPredicate(final List<Predicate> operands) {
        this.operands = operands;
    }

    @Override
    public boolean isMatch(final File inputFile) {
        return operands.stream().anyMatch(predicate -> predicate.isMatch(inputFile));
    }
}

// Implements logical NOT operation on a predicate
public class NotPredicate implements CompositePredicate {
    // The predicate to negate
    private final Predicate operand;

    // Creates a new NOT predicate with the specified predicate to negate
    public NotPredicate(final Predicate operand) {
        this.operand = operand;
    }

    @Override
    public boolean isMatch(final File inputFile) {
        return !operand.isMatch(inputFile);
    }
}

重點:

  • AndPredicate:所有條件皆成立才為真
  • OrPredicate:任一條件成立即為真
  • NotPredicate:取單一條件並反轉
  • AndPredicate / OrPredicate 用 List<Predicate> 支援任意數量條件
  • NotPredicate 僅取一個條件

FileSearchCriteria#

// Wrapper class that encapsulates a search condition for file matching
public class FileSearchCriteria {
    // The predicate that defines what makes a file match
    private final Predicate predicate;

    // Constructor that takes a predicate defining the criteria
    public FileSearchCriteria(final Predicate predicate) {
        this.predicate = predicate;
    }

    // Checks if the given file matches the search criteria
    public boolean isMatch(final File inputFile) {
        return predicate.isMatch(inputFile);
    }
}

Implementation Choice:將 Predicate 包在 FileSearchCriteria 中,可讓 FileSearch 專注於遍歷邏輯,並把條件評估隔離到獨立、可重用的層。

FileSearch#

FileSearch 使用堆疊式(stack-based)做法遍歷檔案系統,避免遞迴造成的 stack overflow。

// Main class responsible for performing file system searches
public class FileSearch {
    // Performs a recursive search through the file system starting from root
    // Returns a list of files that match the given criteria
    public List<File> search(final File root, final FileSearchCriteria criteria) {
        // List to store matching files
        final List<File> result = new ArrayList<>();
        // Stack to handle recursive traversal without actual recursion
        final ArrayDeque<File> recursionStack = new ArrayDeque<>();
        // Start with the root directory
        recursionStack.add(root);
        // Continue until we've processed all files
        while (!recursionStack.isEmpty()) {
            // Get the next file to process
            File next = recursionStack.pop();
            // Check if the file matches our criteria
            if (criteria.isMatch(next)) {
                result.add(next);
            }
            // Add all directory entries to the stack for processing
            for (File entry : next.getEntries()) {
                recursionStack.push(entry);
            }
        }
        return result;
    }
}

流程:

  1. 初始化結果清單與包含 root 的 ArrayDeque 堆疊
  2. 從堆疊取出檔案
  3. criteria.isMatch 評估,符合者加入結果
  4. 將其子項目推入堆疊以進行深度優先遍歷
  5. 回傳結果清單

Implementation Choice:選擇堆疊而非遞迴呼叫,是為了在深層檔案系統中避免 stack overflow。遞迴方法在小型結構可行,但在大或深層巢狀目錄上有失敗風險。

深度討論#

檔案搜尋測試#

實作完成後,建議用測試案例驗證端到端邏輯。以下測試 「擁有者匹配 ‘ge.*’ 的非目錄檔案」:

public class FileSearchTest {
    @Test
    public void testFileSearch() {
        // Create a root directory and two files with different owners
        final File root = new File(true, 0, "adam", "root");
        final File a = new File(false, 2000, "adam", "a");
        final File b = new File(false, 3000, "george", "b");

        // Add files to the root directory
        root.addEntry(a);
        root.addEntry(b);

        // Search criteria: Find non-directory files owned by users matching "ge.*"
        final FileSearchCriteria criteria =
                new FileSearchCriteria(
                        new AndPredicate(
                                List.of(
                                        new SimplePredicate<>(
                                                FileAttribute.IS_DIRECTORY,
                                                new EqualsOperator<>(),
                                                false),
                                        new SimplePredicate<>(
                                                FileAttribute.OWNER,
                                                new RegexMatchOperator<>(),
                                                "ge.*"))));

        // Execute the search and get results
        final FileSearch fileSearch = new FileSearch();
        final List<File> result = fileSearch.search(root, criteria);

        // Verify that only one file matches the criteria
        assertEquals(1, result.size());
        // Verify that the matching file is "b"
        assertEquals("b", result.get(0).getFilename());
    }
}

本章小結#

系統的可維護性可擴展性透過清晰的職責分離達成:

  • File:代表檔案
  • FileSearch:遍歷目錄
  • FileSearchCriteria:評估條件
  • Predicate:定義匹配邏輯

設計選擇如分離 FileSearch 與 FileSearchCriteria、在 ComparisonOperator 中使用 generics,皆提升了可擴展性與型別安全。雖然將 FileSearchCriteria 與 Predicate 合併能讓初始設計更緊湊,但會模糊兩者的角色,使更新或替換條件邏輯時容易影響遍歷。

延伸閱讀:Composite Design Pattern#

Composite Design Pattern#

Composite 是結構型設計模式,讓你能將物件組成樹狀結構,並像處理單一物件一樣處理整棵樹。

問題情境#

想像你有兩種物件:File 與 Folder。Folder 可包含多個 File 與更多子 Folder。如果要建立搜尋系統,如何讓搜尋對單一 File 與含巢狀 Folder 的結構都能一致運作?

解法#

Composite 模式建議透過一個共同介面來處理 File 與 Folder。介面宣告一個檢查條件的方法:

  • 對 File:直接檢查條件(如 size > 10),回傳結果
  • 對 Folder
    • 對其中每個項目逐一檢查
    • 對任何巢狀資料夾遞迴套用此流程
    • 也可加上自身限制(如「非目錄」)來精煉結果

Item interface and concrete classes

Item 是 File 與 Folder 共同實作的介面。透過共同介面,無論 File 或巢狀 Folder 都能用相同方式處理。

何時使用#

  • 需要建立樹狀物件結構
  • 希望客戶端程式能統一處理簡單與複雜元素