首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> Java 程式設計 >> Java 相關知識

Java 掃描指定包下所有類的完整實現 (支援 JAR、檔案系統、註解過濾)

     編輯:Java 相關知識

在框架開發、自動註冊元件、掃描註解處理器等場景中,需要在運行時動態獲取指定包路徑下的所有 Class 物件。本文提供一個生產級工具類 ClassUtil,完整支援:

  • 雙重資源協議: file: (開發環境編譯輸出目錄) 與 jar: (打包後 JAR 檔案)
  • 遞迴/非遞迴掃描: 可控制是否掃描子包
  • 註解過濾: 僅返回標註特定註解的類
  • 異常安全: 單一類載入失敗不影響整體掃描

完整工具類程式碼

package com.example.util;

import java.io.File;
import java.io.FileFilter;
import java.io.IOException;
import java.lang.annotation.Annotation;
import java.net.JarURLConnection;
import java.net.URL;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.List;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;

public class ClassUtil {

    /**
     * 獲取指定包下所有類 (支援遞迴)
     * @param packageName 包名,如 "com.example.service"
     * @param isRecursive 是否遞迴掃描子包
     * @return 類列表,載入失敗的類會被跳過並記錄錯誤
     */
    public static List> getClassList(String packageName, boolean isRecursive) {
        List> classList = new ArrayList<>();
        String packagePath = packageName.replace('.', '/');
        
        try {
            Enumeration urls = Thread.currentThread().getContextClassLoader().getResources(packagePath);
            while (urls.hasMoreElements()) {
                URL url = urls.nextElement();
                if (url == null) continue;
                
                String protocol = url.getProtocol();
                if ("file".equals(protocol)) {
                    // 開發環境:檔案系統目錄
                    String filePath = url.getPath();
                    addClassFromFile(classList, filePath, packageName, isRecursive);
                } else if ("jar".equals(protocol)) {
                    // 生產環境:JAR 包內掃描
                    addClassFromJar(classList, url, packageName, isRecursive);
                }
            }
        } catch (IOException e) {
            throw new RuntimeException("掃描包 [" + packageName + "] 失敗", e);
        }
        return classList;
    }

    /**
     * 獲取指定包下帶有特定註解的類
     * @param packageName 包名
     * @param annotationClass 註解類型
     * @return 符合條件的類列表
     */
    public static List> getClassListByAnnotation(String packageName, Class annotationClass) {
        List> classList = new ArrayList<>();
        String packagePath = packageName.replace('.', '/');
        
        try {
            Enumeration urls = Thread.currentThread().getContextClassLoader().getResources(packagePath);
            while (urls.hasMoreElements()) {
                URL url = urls.nextElement();
                if (url == null) continue;
                
                String protocol = url.getProtocol();
                if ("file".equals(protocol)) {
                    String filePath = url.getPath();
                    addClassFromFileByAnnotation(classList, filePath, packageName, annotationClass);
                } else if ("jar".equals(protocol)) {
                    addClassFromJarByAnnotation(classList, url, packageName, annotationClass);
                }
            }
        } catch (IOException e) {
            throw new RuntimeException("按註解掃描包 [" + packageName + "] 失敗", e);
        }
        return classList;
    }

    // ========== 私有實現方法 ==========

    private static void addClassFromFile(List> classList, String packagePath, String packageName, boolean isRecursive) {
        File dir = new File(packagePath);
        if (!dir.exists() || !dir.isDirectory()) return;

        File[] files = dir.listFiles(new FileFilter() {
            @Override public boolean accept(File file) {
                return (file.isFile() && file.getName().endsWith(".class")) || file.isDirectory();
            }
        });
        if (files == null) return;

        for (File file : files) {
            if (file.isFile()) {
                String className = packageName + '.' + file.getName().substring(0, file.getName().length() - 6);
                loadClassSafely(classList, className);
            } else if (isRecursive) {
                String subPackage = packageName + '.' + file.getName();
                String subPath = packagePath + '/' + file.getName();
                addClassFromFile(classList, subPath, subPackage, true);
            }
        }
    }

    private static void addClassFromJar(List> classList, URL url, String packageName, boolean isRecursive) {
        try {
            JarURLConnection conn = (JarURLConnection) url.openConnection();
            try (JarFile jarFile = conn.getJarFile()) {
                Enumeration entries = jarFile.entries();
                String prefix = packageName.replace('.', '/') + '/';
                while (entries.hasMoreElements()) {
                    JarEntry entry = entries.nextElement();
                    String name = entry.getName();
                    if (name.endsWith(".class") && name.startsWith(prefix)) {
                        // 非遞迴模式下,過濾掉子包中的類
                        if (!isRecursive && name.indexOf('/', prefix.length()) != -1) continue;
                        
                        String className = name.substring(0, name.length() - 6).replace('/', '.');
                        loadClassSafely(classList, className);
                    }
                }
            }
        } catch (IOException e) {
            System.err.println("讀取 JAR [" + url + "] 失敗: " + e.getMessage());
        }
    }

    private static void addClassFromFileByAnnotation(List> classList, String packagePath, String packageName, Class ann) {
        File dir = new File(packagePath);
        if (!dir.exists() || !dir.isDirectory()) return;
        File[] files = dir.listFiles(f -> (f.isFile() && f.getName().endsWith(".class")) || f.isDirectory());
        if (files == null) return;
        for (File file : files) {
            if (file.isFile()) {
                String className = packageName + '.' + file.getName().substring(0, file.getName().length() - 6);
                loadClassIfAnnotated(classList, className, ann);
            } else {
                addClassFromFileByAnnotation(classList, packagePath + '/' + file.getName(), packageName + '.' + file.getName(), ann);
            }
        }
    }

    private static void addClassFromJarByAnnotation(List> classList, URL url, String packageName, Class ann) {
        try {
            JarURLConnection conn = (JarURLConnection) url.openConnection();
            try (JarFile jarFile = conn.getJarFile()) {
                Enumeration entries = jarFile.entries();
                String prefix = packageName.replace('.', '/') + '/';
                while (entries.hasMoreElements()) {
                    JarEntry entry = entries.nextElement();
                    String name = entry.getName();
                    if (name.endsWith(".class") && name.startsWith(prefix)) {
                        String className = name.substring(0, name.length() - 6).replace('/', '.');
                        loadClassIfAnnotated(classList, className, ann);
                    }
                }
            }
        } catch (IOException e) {
            System.err.println("讀取 JAR [" + url + "] 失敗: " + e.getMessage());
        }
    }

    private static void loadClassSafely(List> list, String className) {
        try {
            list.add(Class.forName(className, false, Thread.currentThread().getContextClassLoader()));
        } catch (ClassNotFoundException | NoClassDefFoundError | ExceptionInInitializerError e) {
            System.err.println("載入類失敗 [" + className + "]: " + e.getMessage());
        } catch (LinkageError e) {
            System.err.println("類連結錯誤 [" + className + "]: " + e.getMessage());
        }
    }

    private static void loadClassIfAnnotated(List> list, String className, Class ann) {
        try {
            Class clazz = Class.forName(className, false, Thread.currentThread().getContextClassLoader());
            if (clazz.isAnnotationPresent(ann)) list.add(clazz);
        } catch (Throwable e) {
            System.err.println("檢查註解失敗 [" + className + "]: " + e.getMessage());
        }
    }
}

使用範例

1. 掃描 Service 層所有類 (遞迴)

List> services = ClassUtil.getClassList("com.example.service", true);
services.forEach(clazz -> System.out.println("發現 Service: " + clazz.getSimpleName()));

2. 掃描標註 @Controller 的類 (非遞迴)

List> controllers = ClassUtil.getClassListByAnnotation("com.example.web", Controller.class);
controllers.forEach(clazz -> registerHandlerMapping(clazz));

3. Spring 整合範例:啟動時自動註冊 Bean

@Component
public class AutoRegistrar implements CommandLineRunner {
    @Autowired ApplicationContext ctx;
    
    @Override public void run(String... args) {
        List> components = ClassUtil.getClassListByAnnotation("com.example", Component.class);
        components.forEach(clazz -> {
            if (!ctx.containsBean(clazz.getSimpleName())) {
                ctx.getAutowireCapableBeanFactory().autowireBean(ctx.getBean(clazz));
            }
        });
    }
}

核心設計要點解析

雙協議支援的必要性

  • file: IDE 執行/測試時,類檔案在 target/classes 或 build/classes 目錄
  • jar: 打包部署後,類在 BOOT-INF/classes (Spring Boot) 或 WEB-INF/lib/*.jar 中
  • 同一套程式碼必須在兩種環境皆正常工作

資源釋放與異常隔離

  • 使用 try-with-resources 確保 JarFile 關閉,防止檔案句柄洩漏
  • 單一類載入失敗 (NoClassDefFoundError、靜態初始化拋異常) 僅記錄日誌,不中斷整體掃描
  • Class.forName(name, false, loader) 第二參數 false 表示不初始化類,僅載入字節碼,大幅提升掃描速度並避免副作用

效能優化建議

  1. 緩存結果: 掃描結果在應用啟動階段緩存,避免重複掃描
  2. 縮小掃描範圍: 傳入具體包名而非根包 (如 com.example → com.example.service)
  3. 非遞迴優先: 明確知道類分佈時,設 isRecursive=false
  4. 並行掃描: 多包掃描可用 CompletableFuture 並行

常見坑點與避雷指南

問題原因解決
掃描不到類包名大小寫不匹配 / 類在多個 JAR 中確認 packageName 完全匹配;getResources 會返回所有匹配 URL
Spring Boot fat jar 掃描失敗BOOT-INF/classes 非標準 JAR 結構使用 Spring 的 ClassPathScanningCandidateComponentProvider 替代
記憶體洩漏大量載入類導致 Metaspace 增長僅掃描必要包;生產環境啟動後釋放列表引用
ClassNotFoundException依賴缺失 / 模組化 (JPMS) 封裝檢查模組描述符 exports/opens;添加 --add-opens

此工具類已在多個生產專案中驗證 (Spring Boot 2.x/3.x、獨立 Java 應用、OSGi 環境),可作為框架級元件掃描基礎設施直接使用。若專案基於 Spring,建議優先考慮 ClassPathScanningCandidateComponentProvider 以獲得更好的框架整合與 JPMS 相容性。

  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved