diff --git a/src/main/java/com/example/workutils/biz/apitrimmer/ApiTrimmerWindow.java b/src/main/java/com/example/workutils/biz/apitrimmer/ApiTrimmerWindow.java index 875bd9a..039bdb3 100644 --- a/src/main/java/com/example/workutils/biz/apitrimmer/ApiTrimmerWindow.java +++ b/src/main/java/com/example/workutils/biz/apitrimmer/ApiTrimmerWindow.java @@ -2,69 +2,126 @@ package com.example.workutils.biz.apitrimmer; import com.example.workutils.biz.apitrimmer.service.ApiTrimmerService; import com.example.workutils.system.config.WorkUtilsProperties; +import com.example.workutils.system.support.SystemOpenHelper; import com.example.workutils.system.ui.StyledButton; import com.example.workutils.system.ui.Theme; import javafx.application.Platform; import javafx.beans.property.*; import javafx.collections.FXCollections; import javafx.collections.ObservableList; +import javafx.concurrent.Task; import javafx.geometry.Insets; import javafx.geometry.Pos; +import javafx.scene.Node; import javafx.scene.Parent; import javafx.scene.Scene; import javafx.scene.control.*; -import javafx.scene.control.cell.CheckBoxTableCell; +import javafx.scene.control.cell.CheckBoxTreeTableCell; import javafx.scene.layout.*; import javafx.scene.text.Font; -import javafx.scene.text.Text; -import javafx.scene.text.TextFlow; -import javafx.stage.*; +import javafx.stage.FileChooser; +import javafx.stage.Modality; +import javafx.stage.Stage; -import java.awt.Desktop; -import java.io.*; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.util.*; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; import java.util.concurrent.CountDownLatch; -/** - * API Trim 可视化窗口。 - *

- * 功能:选择 API JAR → 查看目录结构并勾选整目录删除 → 分析冗余 → 执行精简。 - */ public class ApiTrimmerWindow { + private static final String PATTERNS_FILE = "api-trimmer-patterns.txt"; + private static final String DATA_DIR = "data"; + private final Stage stage; private final CountDownLatch closeLatch; private final ApiTrimmerService service; private final WorkUtilsProperties properties; - // ---- UI 组件 ---- private TextField jarPathField; - private Label autoCheckLabel; // 显示当前配置的关键词 - // ---- 目录表格 ---- - private TableView dirTable; + private Label autoCheckLabel; + private Label targetLabel; + private Label selectionLabel; + private Label riskLabel; + private Label statusLabel; + private ProgressIndicator progressIndicator; + + private static final int TREE_FONT_SIZE = 12; + private static final int TREE_ROW_HEIGHT = 28; + + private TreeTableView dirTree; + private TreeItem treeRoot; + private Stage candidatePopupStage; + private TreeTableView candidatePopupTree; + private TreeItem candidatePopupRoot; private final ObservableList dirRows = FXCollections.observableArrayList(); private Label dirTableLabel; - // ---- + private TextField candidateFilterField; + private CheckBox selectedOnlyCheck; + private CheckBox riskOnlyCheck; + private TextArea selectedPathsArea; + private VBox resultBox; + private Button previewBtn; private Button trimBtn; private Button cancelBtn; - // ---- 状态 ---- + private ApiTrimmerService.TrimPlan currentPlan; private ApiTrimmerService.TrimAnalysis analysis; - /** 自动勾选关键词集合,从配置文件加载 */ private Set autoCheckPatterns = new LinkedHashSet<>(); - /** data 目录下的配置文件路径 */ private Path configFile; + private boolean refreshingSelection = false; - // ==================== 配置加载 ==================== + public ApiTrimmerWindow(ApiTrimmerService service, WorkUtilsProperties properties, + CountDownLatch closeLatch) { + this.service = service; + this.properties = properties; + this.closeLatch = closeLatch; + this.stage = new Stage(); + initConfigFile(); + loadPatterns(); + buildUI(); + } - private static final String PATTERNS_FILE = "api-trimmer-patterns.txt"; - private static final String DATA_DIR = "data"; + public void show() { + stage.show(); + } + + public Parent getContentPane() { + return stage.getScene().getRoot(); + } + + Stage getStage() { + return stage; + } + + private int fsLabel() { + return properties.getFontFieldLabel(); + } + + private int fsButton() { + return properties.getFontButton(); + } + + private int fsBody() { + return properties.getFontBody(); + } + + private int compactFont() { + return Math.max(11, Math.min(13, fsBody() - 2)); + } - /** 初始化配置文件:若 data 目录下不存在,则从 classpath 模板复制 */ private void initConfigFile() { Path dataDir = Path.of(DATA_DIR); configFile = dataDir.resolve(PATTERNS_FILE); @@ -73,24 +130,19 @@ public class ApiTrimmerWindow { Files.createDirectories(dataDir); } if (!Files.exists(configFile)) { - // 从 classpath 模板复制 - try (InputStream is = getClass().getClassLoader() - .getResourceAsStream(PATTERNS_FILE)) { + try (InputStream is = getClass().getClassLoader().getResourceAsStream(PATTERNS_FILE)) { if (is != null) { Files.copy(is, configFile); } else { - // fallback - Files.writeString(configFile, "lombok\nttf\nclass50\n"); + Files.writeString(configFile, "lombok\nttf\nclass50\n", StandardCharsets.UTF_8); } } } } catch (IOException e) { - // 兜底 configFile = dataDir.resolve(PATTERNS_FILE); } } - /** 从配置文件读取关键词到 autoCheckPatterns */ private void loadPatterns() { autoCheckPatterns.clear(); if (configFile == null || !Files.exists(configFile)) { @@ -112,259 +164,427 @@ public class ApiTrimmerWindow { } } - /** 用系统默认编辑器打开配置文件 */ private void onEditConfig() { - if (configFile == null) return; + if (configFile == null) { + return; + } try { if (!Files.exists(configFile)) { Files.createDirectories(configFile.getParent()); - Files.writeString(configFile, "# API Trim 自动勾选关键词\nlombok\nttf\nclass50\n"); - } - if (Desktop.isDesktopSupported()) { - Desktop.getDesktop().edit(configFile.toFile()); - } else { - // fallback: 用 notepad 打开 - Runtime.getRuntime().exec(new String[]{"notepad.exe", configFile.toAbsolutePath().toString()}); + Files.writeString(configFile, "# API Trim 自动勾选关键词\nlombok\nttf\nclass50\n", + StandardCharsets.UTF_8); } + SystemOpenHelper.editFile(configFile); } catch (IOException e) { showAlert("无法打开配置文件: " + e.getMessage()); } } - /** 刷新配置显示标签 */ private String patternsDisplay() { return String.join(", ", autoCheckPatterns); } - // ==================== TableView 数据行 ==================== - - public static class DirRow { - private final ApiTrimmerService.DirInfo info; - private final BooleanProperty selected; - private final StringProperty name; - private final IntegerProperty fileCount; - private final IntegerProperty classCount; - private final StringProperty size; - - DirRow(ApiTrimmerService.DirInfo info, boolean selected) { - this.info = info; - this.selected = new SimpleBooleanProperty(selected); - this.name = new SimpleStringProperty(info.getName()); - this.fileCount = new SimpleIntegerProperty(info.getFileCount()); - this.classCount = new SimpleIntegerProperty(info.getClassCount()); - this.size = new SimpleStringProperty(formatSize(info.getSizeBytes())); - } - - public ApiTrimmerService.DirInfo getInfo() { return info; } - - public BooleanProperty selectedProperty() { return selected; } - public boolean isSelected() { return selected.get(); } - public void setSelected(boolean v) { selected.set(v); } - - public StringProperty nameProperty() { return name; } - public String getName() { return name.get(); } - - public IntegerProperty fileCountProperty() { return fileCount; } - public int getFileCount() { return fileCount.get(); } - - public IntegerProperty classCountProperty() { return classCount; } - public int getClassCount() { return classCount.get(); } - - public StringProperty sizeProperty() { return size; } - public String getSize() { return size.get(); } - - private static String formatSize(long bytes) { - if (bytes < 1024) return bytes + " B"; - if (bytes < 1_048_576) return String.format("%.1f KB", bytes / 1024.0); - return String.format("%.1f MB", bytes / 1_048_576.0); - } - } - - // ==================== 构造 ==================== - - public ApiTrimmerWindow(ApiTrimmerService service, WorkUtilsProperties properties, - CountDownLatch closeLatch) { - this.service = service; - this.properties = properties; - this.closeLatch = closeLatch; - this.stage = new Stage(); - initConfigFile(); - loadPatterns(); - buildUI(); - } - - private int fsLabel() { return properties.getFontFieldLabel(); } - private int fsButton() { return properties.getFontButton(); } - private int fsBody() { return properties.getFontBody(); } - - public void show() { stage.show(); } - - public Parent getContentPane() { - return stage.getScene().getRoot(); - } - - Stage getStage() { return stage; } - - // ==================== UI 构建 ==================== - private void buildUI() { - stage.setTitle("API Trim - 精简API JAR"); + stage.setTitle("API Trim - 安全精简 API JAR"); stage.initModality(Modality.NONE); + stage.setResizable(true); + stage.setMinWidth(1280); + stage.setMinHeight(760); stage.setOnCloseRequest(e -> close()); - VBox root = new VBox(Theme.S3); - root.setPadding(new Insets(Theme.S4)); + VBox root = new VBox(Theme.S2); + root.setPadding(new Insets(Theme.S3)); root.setStyle("-fx-background-color: " + Theme.toWeb(Theme.BG_BODY) + ";"); - Label title = new Label("API Trim - 精简API JAR包"); - title.setFont(Theme.heading(properties.getFontSectionTitle() + 2)); + Label title = new Label("API Trim - 安全精简 API JAR"); + title.setFont(Theme.heading(Math.min(properties.getFontSectionTitle(), 22))); title.setTextFill(Theme.TEXT_PRIMARY); VBox inputSection = createInputSection(); - - // 结果区 - resultBox = new VBox(Theme.S2); - resultBox.setPadding(new Insets(Theme.S2, Theme.S3, Theme.S2, Theme.S3)); - resultBox.setStyle("-fx-background-color: white; -fx-background-radius: " - + Theme.RADIUS_SM + "; -fx-border-color: " + Theme.toWeb(Theme.BORDER) - + "; -fx-border-width: 1; -fx-border-radius: " + Theme.RADIUS_SM + ";"); - resultBox.setVisible(false); - resultBox.setManaged(false); + resultBox = createResultBox(); ScrollPane resultScroll = new ScrollPane(resultBox); resultScroll.setFitToWidth(true); - resultScroll.setPrefHeight(180); + resultScroll.setPrefHeight(190); resultScroll.setStyle("-fx-background-color: transparent;"); HBox btnBar = createButtonBar(); root.getChildren().addAll(title, inputSection, resultScroll, btnBar); - VBox.setVgrow(resultScroll, Priority.ALWAYS); + VBox.setVgrow(inputSection, Priority.ALWAYS); - Scene scene = new Scene(root, 920, 780); + Scene scene = new Scene(root, 1440, 900); stage.setScene(scene); + stage.setMaximized(true); + updateSummary(); } @SuppressWarnings("unchecked") private VBox createInputSection() { - VBox section = new VBox(Theme.S2); + VBox section = new VBox(Theme.S3); section.setPadding(new Insets(Theme.S3)); section.setStyle("-fx-background-color: white; -fx-background-radius: " + Theme.RADIUS_MD + "; -fx-border-color: " + Theme.toWeb(Theme.BORDER) + "; -fx-border-width: 1; -fx-border-radius: " + Theme.RADIUS_MD + ";"); - // ---- JAR 选择行 ---- Label jarLabel = new Label("API JAR 文件:"); jarLabel.setFont(Font.font("Microsoft YaHei", fsLabel())); jarPathField = new TextField(); - jarPathField.setPromptText("选择 api.jar..."); + jarPathField.setPromptText("选择 Maven 构建出的 API JAR..."); jarPathField.getStyleClass().add(Theme.CSS_TEXT_FIELD); HBox.setHgrow(jarPathField, Priority.ALWAYS); - Button browseBtn = StyledButton.small("浏览...", - Font.font("Microsoft YaHei", fsBody() - 1)); + Button browseBtn = StyledButton.small("浏览...", Font.font("Microsoft YaHei", fsBody() - 1)); browseBtn.setOnAction(e -> onBrowseJar()); - Button refreshBtn = StyledButton.small("刷新目录", - Font.font("Microsoft YaHei", fsBody() - 1)); + Button refreshBtn = StyledButton.small("重新扫描", Font.font("Microsoft YaHei", fsBody() - 1)); refreshBtn.setOnAction(e -> onRefreshDirs()); HBox jarRow = new HBox(Theme.S2, jarLabel, jarPathField, browseBtn, refreshBtn); jarRow.setAlignment(Pos.CENTER_LEFT); - // ---- 自动勾选配置信息行 ---- - autoCheckLabel = new Label("自动勾选: " + patternsDisplay()); + autoCheckLabel = new Label("自动规则: " + patternsDisplay()); autoCheckLabel.setFont(Font.font("Microsoft YaHei", fsBody() - 1)); autoCheckLabel.setTextFill(Theme.TEXT_MUTED); - Button editConfigBtn = StyledButton.small("编辑", - Font.font("Microsoft YaHei", fsBody() - 2)); + Button editConfigBtn = StyledButton.small("编辑规则", Font.font("Microsoft YaHei", fsBody() - 2)); editConfigBtn.setOnAction(e -> onEditConfig()); HBox configRow = new HBox(Theme.S2, autoCheckLabel, editConfigBtn); configRow.setAlignment(Pos.CENTER_LEFT); - configRow.setPadding(new Insets(Theme.S1, 0, Theme.S1, 0)); - // ---- 目录表格 (7-Zip 风格) ---- - dirTableLabel = new Label("JAR 内目录(勾选 = 整目录删除):"); - dirTableLabel.setFont(Font.font("Microsoft YaHei", fsLabel())); + GridPane summary = createSummaryPanel(); - dirTable = new TableView<>(dirRows); - dirTable.setEditable(true); - dirTable.setPrefHeight(320); - dirTable.setPlaceholder(new Label("(选择 JAR 文件后自动加载目录)")); + HBox selectRow = new HBox(Theme.S2); + selectRow.setAlignment(Pos.CENTER_LEFT); + Button selectRecommendedBtn = StyledButton.small("选择安全推荐", Font.font("Microsoft YaHei", fsBody() - 1)); + selectRecommendedBtn.setOnAction(e -> selectRecommended()); + Button clearBtn = StyledButton.small("清空选择", Font.font("Microsoft YaHei", fsBody() - 1)); + clearBtn.setOnAction(e -> clearSelection()); + Button selectSafeBtn = StyledButton.small("仅保留安全项", Font.font("Microsoft YaHei", fsBody() - 1)); + selectSafeBtn.setOnAction(e -> selectSafeOnly()); + Button expandAllBtn = StyledButton.small("展开全部", Font.font("Microsoft YaHei", compactFont())); + expandAllBtn.setOnAction(e -> setTreeExpanded(true)); + Button collapseAllBtn = StyledButton.small("折叠全部", Font.font("Microsoft YaHei", compactFont())); + collapseAllBtn.setOnAction(e -> setTreeExpanded(false)); + Button popupCandidatesBtn = StyledButton.small("弹出候选窗口", Font.font("Microsoft YaHei", compactFont())); + popupCandidatesBtn.setOnAction(e -> openCandidatePopup()); + Button maximizeBtn = StyledButton.small("最大化窗口", Font.font("Microsoft YaHei", compactFont())); + maximizeBtn.setOnAction(e -> { + stage.setMaximized(true); + stage.toFront(); + }); + selectRow.getChildren().addAll(selectRecommendedBtn, clearBtn, selectSafeBtn, + expandAllBtn, collapseAllBtn, popupCandidatesBtn, maximizeBtn); - // 列1:复选框 - TableColumn selCol = new TableColumn<>(""); - selCol.setPrefWidth(36); - selCol.setMinWidth(36); - selCol.setMaxWidth(36); - selCol.setSortable(false); - selCol.setCellValueFactory(cdf -> cdf.getValue().selectedProperty()); - selCol.setCellFactory(CheckBoxTableCell.forTableColumn(selCol)); - // 点击复选框区域即可切换,同时支持表头全选/全不选 - selCol.setCellFactory(tc -> { - CheckBoxTableCell cell = new CheckBoxTableCell<>(); - return cell; + candidateFilterField = new TextField(); + candidateFilterField.setPromptText("筛选路径 / 类型 / 删除原因 / Dubbo 说明"); + candidateFilterField.getStyleClass().add(Theme.CSS_TEXT_FIELD); + HBox.setHgrow(candidateFilterField, Priority.ALWAYS); + candidateFilterField.textProperty().addListener((obs, oldVal, newVal) -> updateCandidateFilter()); + + selectedOnlyCheck = new CheckBox("只看已选"); + selectedOnlyCheck.setFont(Theme.base(compactFont())); + selectedOnlyCheck.selectedProperty().addListener((obs, oldVal, newVal) -> updateCandidateFilter()); + + riskOnlyCheck = new CheckBox("只看需确认"); + riskOnlyCheck.setFont(Theme.base(compactFont())); + riskOnlyCheck.selectedProperty().addListener((obs, oldVal, newVal) -> updateCandidateFilter()); + + Button clearFilterBtn = StyledButton.small("清除筛选", Font.font("Microsoft YaHei", fsBody() - 2)); + clearFilterBtn.setOnAction(e -> { + candidateFilterField.clear(); + selectedOnlyCheck.setSelected(false); + riskOnlyCheck.setSelected(false); }); - // 列2:目录名 - TableColumn nameCol = new TableColumn<>("目录"); - nameCol.setCellValueFactory(cdf -> cdf.getValue().nameProperty()); - nameCol.setPrefWidth(280); + HBox filterRow = new HBox(Theme.S2, candidateFilterField, selectedOnlyCheck, riskOnlyCheck, clearFilterBtn); + filterRow.setAlignment(Pos.CENTER_LEFT); - // 列3:文件数 - TableColumn fileCol = new TableColumn<>("文件数"); - fileCol.setCellValueFactory(cdf -> cdf.getValue().fileCountProperty()); - fileCol.setPrefWidth(80); - fileCol.setStyle("-fx-alignment: CENTER-RIGHT;"); + selectedPathsArea = new TextArea(); + selectedPathsArea.setEditable(false); + selectedPathsArea.setWrapText(true); + selectedPathsArea.setPrefRowCount(3); + selectedPathsArea.setMaxHeight(88); + selectedPathsArea.setFont(Theme.base(compactFont())); + selectedPathsArea.setText("有效删除前缀: -"); - // 列4:类文件 - TableColumn classCol = new TableColumn<>("类文件"); - classCol.setCellValueFactory(cdf -> cdf.getValue().classCountProperty()); - classCol.setPrefWidth(80); - classCol.setStyle("-fx-alignment: CENTER-RIGHT;"); + dirTableLabel = new Label("JAR 文件和目录(勾选 = 删除该文件或整目录):"); + dirTableLabel.setFont(Font.font("Microsoft YaHei", Math.max(12, fsLabel() - 2))); - // 列5:大小 - TableColumn sizeCol = new TableColumn<>("大小"); - sizeCol.setCellValueFactory(cdf -> cdf.getValue().sizeProperty()); - sizeCol.setPrefWidth(100); - sizeCol.setStyle("-fx-alignment: CENTER-RIGHT;"); + treeRoot = new TreeItem<>(); + treeRoot.setExpanded(true); + dirTree = createCandidateTree(treeRoot); - dirTable.getColumns().setAll(selCol, nameCol, fileCol, classCol, sizeCol); - - // 表格样式 - dirTable.setStyle( - "-fx-font-family: 'Microsoft YaHei';" + - "-fx-font-size: " + (fsBody() - 1) + "px;" + - "-fx-border-color: " + Theme.toWeb(Theme.BORDER) + ";" + - "-fx-border-width: 0 0 1 0;"); - - section.getChildren().addAll(jarRow, configRow, dirTableLabel, dirTable); - VBox.setVgrow(dirTable, Priority.ALWAYS); + section.getChildren().addAll(jarRow, configRow, summary, selectRow, filterRow, + selectedPathsArea, dirTableLabel, dirTree); + VBox.setVgrow(dirTree, Priority.ALWAYS); return section; } + private TreeTableView createCandidateTree(TreeItem rootItem) { + TreeTableView tree = new TreeTableView<>(rootItem); + tree.setShowRoot(false); + tree.setEditable(true); + tree.setPrefHeight(520); + tree.setFixedCellSize(TREE_ROW_HEIGHT); + tree.setPlaceholder(new Label("选择 JAR 文件后自动生成安全瘦身方案")); + tree.getStyleClass().add(Theme.CSS_TABLE); + tree.setStyle("-fx-font-size: " + TREE_FONT_SIZE + "px;"); + + TreeTableColumn selCol = new TreeTableColumn<>(""); + selCol.setPrefWidth(42); + selCol.setMinWidth(42); + selCol.setMaxWidth(42); + selCol.setSortable(false); + selCol.setEditable(true); + selCol.setCellValueFactory(cdf -> { + DirRow row = cdf.getValue().getValue(); + return row == null ? new SimpleBooleanProperty(false) : row.selectedProperty(); + }); + selCol.setCellFactory(CheckBoxTreeTableCell.forTreeTableColumn(selCol)); + + TreeTableColumn iconCol = treeIconColumn("图标", 42); + TreeTableColumn nameCol = treeNameColumn("名称", 300); + TreeTableColumn fileTypeCol = treeTextColumn("文件类型", 92, DirRow::fileTypeProperty); + TreeTableColumn riskCol = treeTextColumn("风险", 70, DirRow::riskProperty); + TreeTableColumn categoryCol = treeTextColumn("删除类型", 120, DirRow::categoryProperty); + TreeTableColumn coverageCol = treeTextColumn("覆盖", 78, DirRow::coverageProperty); + TreeTableColumn sizeCol = treeTextColumn("大小", 86, DirRow::sizeProperty); + sizeCol.setStyle("-fx-alignment: CENTER-RIGHT;"); + TreeTableColumn countCol = treeTextColumn("文件/类", 88, DirRow::countProperty); + countCol.setStyle("-fx-alignment: CENTER-RIGHT;"); + TreeTableColumn reasonCol = treeTextColumn("为什么可删除", 360, DirRow::reasonProperty); + TreeTableColumn rpcCol = treeTextColumn("RPC/Dubbo 安全说明", 440, DirRow::rpcSafetyProperty); + + tree.getColumns().setAll(selCol, iconCol, nameCol, fileTypeCol, riskCol, categoryCol, + coverageCol, sizeCol, countCol, reasonCol, rpcCol); + tree.setTreeColumn(nameCol); + tree.setRowFactory(table -> new TreeTableRow<>() { + @Override + protected void updateItem(DirRow item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null) { + setStyle(""); + return; + } + switch (item.getRiskLevel()) { + case SAFE: + setStyle("-fx-background-color: #f0fdf4;"); + break; + case DANGER: + case BLOCKED: + setStyle("-fx-background-color: #fff1f2;"); + break; + default: + setStyle(""); + } + } + }); + return tree; + } + + private TreeTableColumn treeNameColumn(String title, int width) { + TreeTableColumn col = new TreeTableColumn<>(title); + col.setPrefWidth(width); + col.setCellValueFactory(cdf -> { + DirRow row = cdf.getValue().getValue(); + return row == null ? new SimpleStringProperty("") : row.nameProperty(); + }); + col.setCellFactory(column -> new TreeTableCell<>() { + private final Tooltip tooltip = new Tooltip(); + + @Override + protected void updateItem(String item, boolean empty) { + super.updateItem(item, empty); + DirRow row = getTreeTableRow() == null ? null : getTreeTableRow().getItem(); + if (empty || item == null || row == null) { + setText(null); + setGraphic(null); + setTooltip(null); + return; + } + setText(item); + setGraphic(null); + tooltip.setText(row.getInfo().getPath()); + setTooltip(tooltip); + } + }); + return col; + } + + private TreeTableColumn treeIconColumn(String title, int width) { + TreeTableColumn col = new TreeTableColumn<>(title); + col.setPrefWidth(width); + col.setMinWidth(width); + col.setMaxWidth(width); + col.setSortable(false); + col.setCellValueFactory(cdf -> new SimpleStringProperty("")); + col.setCellFactory(column -> new TreeTableCell<>() { + @Override + protected void updateItem(String item, boolean empty) { + super.updateItem(item, empty); + DirRow row = getTreeTableRow() == null ? null : getTreeTableRow().getItem(); + if (empty || row == null) { + setText(null); + setGraphic(null); + return; + } + setText(null); + setGraphic(createFileIcon(row)); + setAlignment(Pos.CENTER); + } + }); + return col; + } + + private TreeTableColumn treeTextColumn(String title, int width, + java.util.function.Function valueFactory) { + TreeTableColumn col = new TreeTableColumn<>(title); + col.setPrefWidth(width); + col.setCellValueFactory(cdf -> { + DirRow row = cdf.getValue().getValue(); + return row == null ? new SimpleStringProperty("") : valueFactory.apply(row); + }); + col.setCellFactory(column -> new TreeTableCell<>() { + private final Tooltip tooltip = new Tooltip(); + + @Override + protected void updateItem(String item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null) { + setText(null); + setTooltip(null); + } else { + setText(item); + DirRow row = getTreeTableRow() == null ? null : getTreeTableRow().getItem(); + tooltip.setText("名称".equals(title) && row != null ? row.getInfo().getPath() : item); + setTooltip(tooltip); + } + } + }); + return col; + } + + private Node createFileIcon(DirRow row) { + if (row.isDirectory()) { + StackPane icon = new StackPane(); + icon.setMinSize(16, 14); + icon.setPrefSize(16, 14); + icon.setMaxSize(16, 14); + + Region tab = new Region(); + tab.setPrefSize(7, 4); + tab.setStyle("-fx-background-color: #fbbf24; -fx-background-radius: 1 1 0 0;"); + StackPane.setAlignment(tab, Pos.TOP_LEFT); + StackPane.setMargin(tab, new Insets(1, 0, 0, 1)); + + Region body = new Region(); + body.setPrefSize(15, 10); + body.setStyle("-fx-background-color: #facc15; -fx-border-color: #d97706; " + + "-fx-border-width: 0.6; -fx-background-radius: 1.5; -fx-border-radius: 1.5;"); + StackPane.setAlignment(body, Pos.BOTTOM_LEFT); + StackPane.setMargin(body, new Insets(4, 0, 0, 0)); + + icon.getChildren().addAll(tab, body); + return icon; + } + + StackPane icon = new StackPane(); + icon.setMinSize(14, 16); + icon.setPrefSize(14, 16); + icon.setMaxSize(14, 16); + Region page = new Region(); + page.setPrefSize(12, 15); + page.setStyle("-fx-background-color: #ffffff; -fx-border-color: #94a3b8; " + + "-fx-border-width: 0.8; -fx-background-radius: 1.5; -fx-border-radius: 1.5;"); + Region corner = new Region(); + corner.setPrefSize(4, 4); + corner.setStyle("-fx-background-color: #e2e8f0; -fx-border-color: #94a3b8; " + + "-fx-border-width: 0 0 0.6 0.6;"); + StackPane.setAlignment(corner, Pos.TOP_RIGHT); + StackPane.setMargin(corner, new Insets(1, 1, 0, 0)); + icon.getChildren().addAll(page, corner); + return icon; + } + + private GridPane createSummaryPanel() { + GridPane grid = new GridPane(); + grid.setHgap(Theme.S4); + grid.setVgap(Theme.S2); + grid.setPadding(new Insets(Theme.S2, Theme.S3, Theme.S2, Theme.S3)); + grid.setStyle("-fx-background-color: " + Theme.toWeb(Theme.BG_CARD) + + "; -fx-background-radius: " + Theme.RADIUS_SM + ";"); + + targetLabel = summaryValue(); + selectionLabel = summaryValue(); + riskLabel = summaryValue(); + statusLabel = summaryValue(); + progressIndicator = new ProgressIndicator(); + progressIndicator.setPrefSize(18, 18); + progressIndicator.setVisible(false); + progressIndicator.setManaged(false); + + grid.add(summaryName("目标体积"), 0, 0); + grid.add(targetLabel, 1, 0); + grid.add(summaryName("已选删除"), 2, 0); + grid.add(selectionLabel, 3, 0); + grid.add(summaryName("风险"), 0, 1); + grid.add(riskLabel, 1, 1); + grid.add(summaryName("状态"), 2, 1); + HBox statusBox = new HBox(Theme.S1, progressIndicator, statusLabel); + statusBox.setAlignment(Pos.CENTER_LEFT); + grid.add(statusBox, 3, 1); + return grid; + } + + private Label summaryName(String text) { + Label label = new Label(text + ":"); + label.setFont(Theme.bold(fsBody() - 1)); + label.setTextFill(Theme.TEXT_SECONDARY); + return label; + } + + private Label summaryValue() { + Label label = new Label("-"); + label.setFont(Theme.base(fsBody() - 1)); + label.setTextFill(Theme.TEXT_PRIMARY); + return label; + } + + private VBox createResultBox() { + VBox box = new VBox(Theme.S2); + box.setPadding(new Insets(Theme.S2, Theme.S3, Theme.S2, Theme.S3)); + box.setStyle("-fx-background-color: white; -fx-background-radius: " + + Theme.RADIUS_SM + "; -fx-border-color: " + Theme.toWeb(Theme.BORDER) + + "; -fx-border-width: 1; -fx-border-radius: " + Theme.RADIUS_SM + ";"); + Label empty = new Label("分析预览和执行结果会显示在这里。安全模式会优先保留 Dubbo 接口、DTO、枚举、异常及其签名可达类。"); + empty.setFont(Font.font("Microsoft YaHei", fsBody() - 1)); + empty.setTextFill(Theme.TEXT_MUTED); + box.getChildren().add(empty); + return box; + } + private HBox createButtonBar() { - HBox bar = new HBox(Theme.S4); + HBox bar = new HBox(Theme.S3); bar.setAlignment(Pos.CENTER_RIGHT); bar.setPadding(new Insets(Theme.S2, 0, 0, 0)); - trimBtn = StyledButton.primary("执行精简", - Font.font("Microsoft YaHei", fsButton())); + previewBtn = StyledButton.secondary("分析预览", Font.font("Microsoft YaHei", fsButton())); + previewBtn.setPrefWidth(110); + previewBtn.setOnAction(e -> onPreview()); + + trimBtn = StyledButton.primary("执行精简", Font.font("Microsoft YaHei", fsButton())); trimBtn.setPrefWidth(110); trimBtn.setOnAction(e -> onTrim()); - cancelBtn = StyledButton.secondary("取消", - Font.font("Microsoft YaHei", fsButton())); + cancelBtn = StyledButton.secondary("取消", Font.font("Microsoft YaHei", fsButton())); cancelBtn.setPrefWidth(80); cancelBtn.setOnAction(e -> close()); - bar.getChildren().addAll(trimBtn, cancelBtn); + bar.getChildren().addAll(previewBtn, trimBtn, cancelBtn); return bar; } - // ==================== TrimConfig 构建 ==================== - private ApiTrimmerService.TrimConfig getTrimConfig() { ApiTrimmerService.TrimConfig config = new ApiTrimmerService.TrimConfig(); config.skipLombok = false; @@ -372,23 +592,17 @@ public class ApiTrimmerWindow { config.skipIdeAnnotations = false; config.skipShadeRepack = false; config.skipNativeLibs = false; + config.skipMavenMeta = false; config.skipSpringMeta = false; - for (DirRow row : dirRows) { - if (row.isSelected()) { - config.excludedDirs.add(row.getInfo().getPath()); - } - } + config.excludedDirs.addAll(selectedPrefixes()); return config; } - // ==================== 事件处理 ==================== - private void onBrowseJar() { FileChooser chooser = new FileChooser(); chooser.setTitle("选择 API JAR 文件"); - chooser.getExtensionFilters().add( - new FileChooser.ExtensionFilter("JAR 文件", "*.jar")); - File file = chooser.showOpenDialog(stage); + chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("JAR 文件", "*.jar")); + java.io.File file = chooser.showOpenDialog(stage); if (file != null) { jarPathField.setText(file.getAbsolutePath()); refreshDirList(Path.of(file.getAbsolutePath())); @@ -396,158 +610,672 @@ public class ApiTrimmerWindow { } private void onRefreshDirs() { - String path = jarPathField.getText().trim(); - if (path.isEmpty()) return; - Path jarPath = Path.of(path); - if (Files.exists(jarPath)) { - loadPatterns(); - autoCheckLabel.setText("自动勾选: " + patternsDisplay()); - refreshDirList(jarPath); + Path jarPath = currentJarPath(); + if (jarPath == null) { + return; } + loadPatterns(); + autoCheckLabel.setText("自动规则: " + patternsDisplay()); + refreshDirList(jarPath); } private void refreshDirList(Path jarPath) { - dirRows.clear(); - try { - List dirs = service.scanJarDirectories(jarPath); - for (ApiTrimmerService.DirInfo d : dirs) { - boolean preSelect = matchesAnyPattern(d.getName()); - dirRows.add(new DirRow(d, preSelect)); + Task task = new Task<>() { + @Override + protected ApiTrimmerService.TrimPlan call() throws Exception { + return service.buildTrimPlan(jarPath, autoCheckPatterns); } - dirTableLabel.setText("JAR 内目录(勾选 = 整目录删除):"); - } catch (IOException e) { - dirTableLabel.setText("目录扫描失败: " + e.getMessage()); - } + }; + task.setOnRunning(e -> setBusy(true, "正在扫描 JAR...")); + task.setOnSucceeded(e -> { + currentPlan = task.getValue(); + analysis = null; + fillRows(currentPlan); + showPlanWarnings(currentPlan); + setBusy(false, "扫描完成"); + updateSummary(); + }); + task.setOnFailed(e -> { + setBusy(false, "扫描失败"); + showAlert("目录扫描失败: " + task.getException().getMessage()); + }); + new Thread(task, "api-trim-scan").start(); } - private boolean matchesAnyPattern(String dirName) { - String lower = dirName.toLowerCase(); - for (String p : autoCheckPatterns) { - if (lower.contains(p)) return true; + private void fillRows(ApiTrimmerService.TrimPlan plan) { + dirRows.clear(); + refreshingSelection = true; + try { + for (ApiTrimmerService.TrimCandidate candidate : plan.getCandidates()) { + DirRow row = new DirRow(candidate); + row.selectedProperty().addListener((obs, oldVal, newVal) -> { + if (refreshingSelection) { + return; + } + if (newVal && row.getRiskLevel() == ApiTrimmerService.RiskLevel.BLOCKED) { + row.setSelected(false); + showAlert("该目录被标记为禁止删除: " + row.getName()); + return; + } + analysis = null; + updateCoverageMarkers(); + updateCandidateFilter(); + updateSummary(); + }); + dirRows.add(row); + } + clearCoveredSelections(); + } finally { + refreshingSelection = false; } - return false; + updateCoverageMarkers(); + updateCandidateFilter(); + dirTableLabel.setText("JAR 文件和目录(安全项已默认勾选,文件/谨慎/高风险项需人工确认):"); + } + + private void onPreview() { + Path jarPath = currentJarPath(); + if (jarPath == null || !ensureHasRows()) { + return; + } + ApiTrimmerService.TrimConfig config = getTrimConfig(); + Task task = new Task<>() { + @Override + protected PreviewResult call() throws Exception { + ApiTrimmerService.TrimAnalysis a = service.analyze(jarPath, List.of(), config); + long estimatedSize = service.estimateOutputSize(jarPath, a, config); + return new PreviewResult(a, estimatedSize); + } + }; + task.setOnRunning(e -> setBusy(true, "正在分析接口可达类...")); + task.setOnSucceeded(e -> { + PreviewResult result = task.getValue(); + analysis = result.analysis; + showPreviewResult(result); + setBusy(false, "分析完成"); + }); + task.setOnFailed(e -> { + setBusy(false, "分析失败"); + showAlert("分析失败: " + task.getException().getMessage()); + }); + new Thread(task, "api-trim-preview").start(); } private void onTrim() { - String path = jarPathField.getText().trim(); - if (path.isEmpty()) { - showAlert("请先选择 API JAR 文件"); - return; - } - Path jarPath = Path.of(path); - if (!Files.exists(jarPath)) { - showAlert("文件不存在: " + path); + Path jarPath = currentJarPath(); + if (jarPath == null || !ensureHasRows() || !confirmRiskSelection()) { return; } FileChooser chooser = new FileChooser(); chooser.setTitle("保存精简后的 JAR"); - chooser.getExtensionFilters().add( - new FileChooser.ExtensionFilter("JAR 文件", "*.jar")); + chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("JAR 文件", "*.jar")); String srcName = jarPath.getFileName().toString(); chooser.setInitialFileName(srcName.replace(".jar", "-trimmed.jar")); - File outputFile = chooser.showSaveDialog(stage); - if (outputFile == null) return; + java.io.File outputFile = chooser.showSaveDialog(stage); + if (outputFile == null) { + return; + } Path outputPath = outputFile.toPath(); - - trimBtn.setDisable(true); - trimBtn.setText("精简中..."); - ApiTrimmerService.TrimConfig config = getTrimConfig(); - new Thread(() -> { - try { - analysis = service.analyze(jarPath, List.of(), config); - Platform.runLater(this::showResult); - - service.execute(jarPath, analysis, outputPath, config); + Task task = new Task<>() { + @Override + protected ExecutionResult call() throws Exception { + ApiTrimmerService.TrimAnalysis a = service.analyze(jarPath, List.of(), config); + if (!a.getExcludedEntryPoints().isEmpty()) { + throw new IllegalStateException("选择的删除项覆盖了 RPC/API 入口类: " + + String.join(", ", a.getExcludedEntryPoints())); + } + service.execute(jarPath, a, outputPath, config); + ApiTrimmerService.VerificationResult verification = service.verifyOutput(outputPath, a); long newSize = Files.size(outputPath); - double reduction = 100.0 * (1.0 - (double) newSize / analysis.getOriginalSizeBytes()); - Platform.runLater(() -> { - appendOutputLine("精简完成!"); - appendOutputLine("输出: " + outputPath.toAbsolutePath()); - appendOutputLine(String.format("新大小: %.1f MB ( %.1f%%)", - newSize / 1_048_576.0, reduction)); - showInfo("精简完成", - "输出文件:\n" + outputPath.toAbsolutePath() + "\n\n" - + String.format("原始: %.1f MB 精简: %.1f MB ( %.1f%%)", - analysis.getOriginalSizeBytes() / 1_048_576.0, - newSize / 1_048_576.0, - reduction)); - }); - } catch (IOException e) { - Platform.runLater(() -> showAlert("精简失败: " + e.getMessage())); - } finally { - Platform.runLater(() -> { - trimBtn.setDisable(false); - trimBtn.setText("执行精简"); - }); + return new ExecutionResult(a, outputPath, newSize, verification); } - }, "api-trim-execute").start(); + }; + task.setOnRunning(e -> setBusy(true, "正在精简并验证...")); + task.setOnSucceeded(e -> { + ExecutionResult result = task.getValue(); + analysis = result.analysis; + showExecutionResult(result); + setBusy(false, result.verification.isOk() ? "精简完成,验证通过" : "精简完成,验证未通过"); + }); + task.setOnFailed(e -> { + setBusy(false, "精简失败"); + Throwable ex = task.getException(); + showAlert("精简失败: " + (ex.getMessage() != null ? ex.getMessage() : ex.getClass().getSimpleName())); + }); + new Thread(task, "api-trim-execute").start(); } - // ==================== 结果显示 ==================== + private boolean confirmRiskSelection() { + Set effectiveSelected = selectedPrefixes(); + List blocked = dirRows.stream() + .filter(DirRow::isSelected) + .filter(row -> effectiveSelected.contains(row.getInfo().getPath())) + .filter(row -> row.getRiskLevel() == ApiTrimmerService.RiskLevel.BLOCKED) + .toList(); + if (!blocked.isEmpty()) { + showAlert("存在禁止删除项,请取消后再执行: " + blocked.get(0).getName()); + return false; + } + List dangerous = dirRows.stream() + .filter(DirRow::isSelected) + .filter(row -> effectiveSelected.contains(row.getInfo().getPath())) + .filter(row -> row.getRiskLevel() == ApiTrimmerService.RiskLevel.DANGER) + .toList(); + if (dangerous.isEmpty()) { + return true; + } + StringBuilder msg = new StringBuilder("你选择了高风险目录,可能影响 Dubbo 接口或序列化类型。\n\n"); + for (int i = 0; i < Math.min(5, dangerous.size()); i++) { + DirRow row = dangerous.get(i); + msg.append("- ").append(row.getName()).append(": ").append(row.getReason()).append("\n"); + } + msg.append("\n仍要继续吗?"); + Alert alert = new Alert(Alert.AlertType.CONFIRMATION); + alert.setTitle("确认高风险删除"); + alert.setHeaderText(null); + alert.setContentText(msg.toString()); + alert.initOwner(stage); + return alert.showAndWait().filter(ButtonType.OK::equals).isPresent(); + } - private void showResult() { + private void updateSummary() { + if (targetLabel == null) { + return; + } + Path jarPath = currentJarPath(); + if (jarPath == null || dirRows.isEmpty()) { + targetLabel.setText("目标 < " + formatSize(ApiTrimmerService.TARGET_SIZE_BYTES)); + selectionLabel.setText("未选择 JAR"); + riskLabel.setText("-"); + statusLabel.setText("等待选择 API JAR"); + if (selectedPathsArea != null) { + selectedPathsArea.setText("有效删除前缀: -"); + } + if (previewBtn != null) { + previewBtn.setDisable(true); + trimBtn.setDisable(true); + } + return; + } + updateCoverageMarkers(); + Set selected = selectedPrefixes(); + updateSelectedPathsArea(selected); + try { + ApiTrimmerService.SelectionSummary summary = service.summarizeSelection(jarPath, selected); + targetLabel.setText("目标 < " + formatSize(ApiTrimmerService.TARGET_SIZE_BYTES) + + ",估算 " + formatSize(summary.getEstimatedOutputBytes()) + + (summary.isTargetReached() ? "(已达标)" : "(未达标)")); + selectionLabel.setText(selected.size() + " 项 / " + + formatSize(summary.getExcludedBytes()) + " / " + + summary.getExcludedFiles() + " 文件 / " + + summary.getExcludedClasses() + " 类"); + } catch (IOException e) { + selectionLabel.setText("统计失败: " + e.getMessage()); + } + long safe = dirRows.stream().filter(DirRow::isSelected) + .filter(row -> row.getRiskLevel() == ApiTrimmerService.RiskLevel.SAFE).count(); + long caution = dirRows.stream().filter(DirRow::isSelected) + .filter(row -> row.getRiskLevel() == ApiTrimmerService.RiskLevel.CAUTION).count(); + long danger = dirRows.stream().filter(DirRow::isSelected) + .filter(row -> row.getRiskLevel() == ApiTrimmerService.RiskLevel.DANGER).count(); + riskLabel.setText("安全 " + safe + " / 谨慎 " + caution + " / 高风险 " + danger); + statusLabel.setText("已生成候选方案"); + previewBtn.setDisable(selected.isEmpty()); + trimBtn.setDisable(selected.isEmpty()); + } + + private void showPlanWarnings(ApiTrimmerService.TrimPlan plan) { resultBox.getChildren().clear(); - resultBox.setVisible(true); - resultBox.setManaged(true); - - addStatRow("原始类数:", String.valueOf(analysis.getTotalClassCount())); - addStatRow("保留类数:", analysis.getRetainedClassCount() + " (" + - String.format("%.1f%%", 100.0 - analysis.getReductionPercent()) + ")"); - addStatRow("移除类数:", analysis.getRemovedClassCount() + " (" + - String.format("%.1f%%", analysis.getReductionPercent()) + ")"); - addStatRow("入口类数:", String.valueOf(analysis.getEntryPoints().size())); - addStatRow("原始大小:", String.format("%.1f MB", analysis.getOriginalSizeBytes() / 1_048_576.0)); - - if (!analysis.getEntryPoints().isEmpty()) { - Label entryLabel = new Label("入口类:"); - entryLabel.setFont(Font.font("Microsoft YaHei", fsBody())); - entryLabel.setStyle("-fx-font-weight: bold;"); - resultBox.getChildren().add(entryLabel); - TextFlow flow = new TextFlow(); - flow.setStyle("-fx-background-color: " + Theme.toWeb(Theme.BG_BODY) - + "; -fx-padding: 4 8 4 8; -fx-background-radius: " + Theme.RADIUS_SM + ";"); - for (int i = 0; i < Math.min(analysis.getEntryPoints().size(), 20); i++) { - Text t = new Text(analysis.getEntryPoints().get(i)); - t.setFont(Font.font("Consolas", fsBody() - 2)); - t.setFill(Theme.TEXT_PRIMARY); - flow.getChildren().add(t); - if (i < Math.min(analysis.getEntryPoints().size(), 20) - 1) { - flow.getChildren().add(new Text("\n")); - } - } - if (analysis.getEntryPoints().size() > 20) { - flow.getChildren().add(new Text( - "\n...还有 " + (analysis.getEntryPoints().size() - 20) + " 个")); - } - resultBox.getChildren().add(flow); + addResultLine("扫描完成", Theme.TEXT_PRIMARY, true); + addResultLine("原始大小: " + formatSize(plan.getOriginalSizeBytes()) + + ",默认安全方案估算: " + formatSize(plan.getEstimatedOutputBytes()) + + ",目标: < " + formatSize(plan.getTargetSizeBytes()), Theme.TEXT_PRIMARY, false); + addResultLine("绿色安全项会说明为什么可删以及为什么不影响 Dubbo/RPC。" + + "如果默认方案仍超过 4M,请先点“分析预览”,再逐项确认谨慎项。", Theme.TEXT_SECONDARY, false); + for (String warning : plan.getWarnings()) { + addResultLine("[提示] " + warning, Theme.WARNING, false); } } - private void addStatRow(String label, String value) { - HBox row = new HBox(Theme.S2); - row.setAlignment(Pos.CENTER_LEFT); - Label lbl = new Label(label); - lbl.setFont(Font.font("Microsoft YaHei", fsBody())); - lbl.setPrefWidth(100); - lbl.setStyle("-fx-font-weight: bold;"); - Label val = new Label(value); - val.setFont(Font.font("Consolas", fsBody())); - val.setTextFill(Theme.PRIMARY); - row.getChildren().addAll(lbl, val); - resultBox.getChildren().add(row); + private void showPreviewResult(PreviewResult result) { + resultBox.getChildren().clear(); + addResultLine("分析预览", Theme.TEXT_PRIMARY, true); + addResultLine("原始类数: " + result.analysis.getTotalClassCount() + + ",保留类数: " + result.analysis.getRetainedClassCount() + + ",移除类数: " + result.analysis.getRemovedClassCount(), Theme.TEXT_PRIMARY, false); + addResultLine("识别入口类: " + result.analysis.getEntryPoints().size() + + ",估算输出: " + formatSize(result.estimatedSize) + + (result.estimatedSize <= ApiTrimmerService.TARGET_SIZE_BYTES ? "(预计达标)" : "(预计未达标)"), + result.estimatedSize <= ApiTrimmerService.TARGET_SIZE_BYTES ? Theme.SUCCESS : Theme.WARNING, false); + if (!result.analysis.getExcludedEntryPoints().isEmpty()) { + addResultLine("阻断: 删除项覆盖了入口类 " + String.join(", ", result.analysis.getExcludedEntryPoints()), + Theme.DANGER, false); + } + appendEntryPoints(result.analysis); } - private void appendOutputLine(String text) { - Label line = new Label(text); - line.setFont(Font.font("Consolas", fsBody() - 1)); - line.setTextFill(Theme.SUCCESS); - Platform.runLater(() -> resultBox.getChildren().add(line)); + private void showExecutionResult(ExecutionResult result) { + resultBox.getChildren().clear(); + double reduction = 100.0 * (1.0 - (double) result.outputSize / result.analysis.getOriginalSizeBytes()); + addResultLine("精简完成", Theme.TEXT_PRIMARY, true); + addResultLine("输出: " + result.outputPath.toAbsolutePath(), Theme.TEXT_PRIMARY, false); + addResultLine(String.format("原始: %s,输出: %s,缩减: %.1f%%,目标: %s", + formatSize(result.analysis.getOriginalSizeBytes()), + formatSize(result.outputSize), + reduction, + result.outputSize <= ApiTrimmerService.TARGET_SIZE_BYTES ? "已小于 4M" : "仍大于 4M"), + result.outputSize <= ApiTrimmerService.TARGET_SIZE_BYTES ? Theme.SUCCESS : Theme.WARNING, false); + ApiTrimmerService.VerificationResult verification = result.verification; + addResultLine("验证: " + (verification.isOk() ? "通过" : "未通过") + + ",入口类 " + verification.getEntryPointsChecked() + + ",签名可达类 " + verification.getRetainedClassesChecked(), + verification.isOk() ? Theme.SUCCESS : Theme.DANGER, false); + for (String error : verification.getErrors()) { + addResultLine("[错误] " + error, Theme.DANGER, false); + } + for (String warning : verification.getWarnings()) { + addResultLine("[提示] " + warning, Theme.WARNING, false); + } + appendEntryPoints(result.analysis); } - // ==================== 对话框 ==================== + private void appendEntryPoints(ApiTrimmerService.TrimAnalysis result) { + if (result.getEntryPoints().isEmpty()) { + addResultLine("未识别到接口或 Dubbo 入口类,请确认该 JAR 是否为 API 模块。", Theme.WARNING, false); + return; + } + addResultLine("入口类示例:", Theme.TEXT_PRIMARY, true); + int limit = Math.min(12, result.getEntryPoints().size()); + for (int i = 0; i < limit; i++) { + addResultLine(" " + result.getEntryPoints().get(i), Theme.TEXT_SECONDARY, false); + } + if (result.getEntryPoints().size() > limit) { + addResultLine(" ...还有 " + (result.getEntryPoints().size() - limit) + " 个", Theme.TEXT_SECONDARY, false); + } + } + + private void addResultLine(String text, javafx.scene.paint.Color color, boolean bold) { + Label label = new Label(text); + label.setWrapText(true); + label.setFont(bold ? Theme.bold(fsBody()) : Theme.base(fsBody() - 1)); + label.setTextFill(color); + resultBox.getChildren().add(label); + } + + private void selectRecommended() { + refreshingSelection = true; + try { + for (DirRow row : dirRows) { + row.setSelected(row.isSelectedByDefault()); + } + clearCoveredSelections(); + } finally { + refreshingSelection = false; + } + analysis = null; + updateCoverageMarkers(); + updateCandidateFilter(); + updateSummary(); + } + + private void selectSafeOnly() { + refreshingSelection = true; + try { + for (DirRow row : dirRows) { + row.setSelected(row.getRiskLevel() == ApiTrimmerService.RiskLevel.SAFE); + } + clearCoveredSelections(); + } finally { + refreshingSelection = false; + } + analysis = null; + updateCoverageMarkers(); + updateCandidateFilter(); + updateSummary(); + } + + private void clearSelection() { + refreshingSelection = true; + try { + for (DirRow row : dirRows) { + row.setSelected(false); + } + } finally { + refreshingSelection = false; + } + analysis = null; + updateCoverageMarkers(); + updateCandidateFilter(); + updateSummary(); + } + + private void clearCoveredSelections() { + Set effective = selectedPrefixes(); + for (DirRow row : dirRows) { + if (row.isSelected() && !effective.contains(row.getInfo().getPath())) { + row.setSelected(false); + } + } + } + + private void updateCoverageMarkers() { + Set selected = rawSelectedPrefixes(); + for (DirRow row : dirRows) { + String ancestor = findSelectedAncestor(row.getInfo().getPath(), selected); + if (ancestor == null) { + row.setCoverage(""); + } else if (row.isSelected()) { + row.setCoverage("父级覆盖"); + } else { + row.setCoverage("父级已选"); + } + } + } + + private void updateCandidateFilter() { + rebuildCandidateTree(); + } + + private void rebuildCandidateTree() { + if (treeRoot != null) { + rebuildCandidateTree(treeRoot); + } + if (candidatePopupRoot != null) { + rebuildCandidateTree(candidatePopupRoot); + } + } + + private void rebuildCandidateTree(TreeItem rootItem) { + Set expandedPaths = collectExpandedPaths(rootItem); + boolean firstBuild = rootItem.getChildren().isEmpty(); + + Map byPath = new LinkedHashMap<>(); + for (DirRow row : dirRows) { + byPath.put(row.getInfo().getPath(), row); + } + + Set included = new LinkedHashSet<>(); + for (DirRow row : dirRows) { + if (matchesCurrentFilter(row)) { + included.add(row); + addExistingAncestors(row.getInfo().getPath(), byPath, included); + } + } + + rootItem.getChildren().clear(); + Map> itemByPath = new LinkedHashMap<>(); + included.stream() + .sorted(Comparator + .comparing((DirRow row) -> !row.isDirectory()) + .thenComparing(row -> row.getInfo().getPath())) + .forEach(row -> { + TreeItem item = itemByPath.computeIfAbsent(row.getInfo().getPath(), + key -> new TreeItem<>(row)); + TreeItem parent = rootItem; + String parentPath = parentPrefix(row.getInfo().getPath()); + while (parentPath != null) { + TreeItem candidateParent = itemByPath.get(parentPath); + if (candidateParent != null) { + parent = candidateParent; + break; + } + parentPath = parentPrefix(parentPath); + } + parent.getChildren().add(item); + }); + rootItem.setExpanded(true); + if (firstBuild) { + expandTreeToDepth(rootItem, 2); + } else { + restoreExpandedPaths(rootItem, expandedPaths); + } + } + + private boolean matchesCurrentFilter(DirRow row) { + String keyword = candidateFilterField == null ? "" : candidateFilterField.getText(); + String lowerKeyword = keyword == null ? "" : keyword.trim().toLowerCase(Locale.ROOT); + boolean selectedOnly = selectedOnlyCheck != null && selectedOnlyCheck.isSelected(); + boolean riskOnly = riskOnlyCheck != null && riskOnlyCheck.isSelected(); + if (selectedOnly && !row.isSelected()) { + return false; + } + if (riskOnly && row.getRiskLevel() == ApiTrimmerService.RiskLevel.SAFE) { + return false; + } + return lowerKeyword.isEmpty() || row.matches(lowerKeyword); + } + + private void addExistingAncestors(String path, Map byPath, Set included) { + String parent = parentPrefix(path); + while (parent != null) { + DirRow row = byPath.get(parent); + if (row != null) { + included.add(row); + } + parent = parentPrefix(parent); + } + } + + private static String parentPrefix(String path) { + if (path == null || path.isEmpty()) { + return null; + } + String normalized = path.endsWith("/") ? path.substring(0, path.length() - 1) : path; + int slash = normalized.lastIndexOf('/'); + if (slash < 0) { + return null; + } + return normalized.substring(0, slash + 1); + } + + private void openCandidatePopup() { + if (candidatePopupStage != null && candidatePopupStage.isShowing()) { + candidatePopupStage.setMaximized(true); + candidatePopupStage.toFront(); + return; + } + + candidatePopupRoot = new TreeItem<>(); + candidatePopupRoot.setExpanded(true); + candidatePopupTree = createCandidateTree(candidatePopupRoot); + candidatePopupTree.setPrefHeight(760); + + Label title = new Label("候选删除项"); + title.setFont(Theme.heading(18)); + title.setTextFill(Theme.TEXT_PRIMARY); + + Button expandBtn = StyledButton.small("展开全部", Font.font("Microsoft YaHei", compactFont())); + expandBtn.setOnAction(e -> setTreeExpanded(candidatePopupRoot, true)); + Button collapseBtn = StyledButton.small("折叠全部", Font.font("Microsoft YaHei", compactFont())); + collapseBtn.setOnAction(e -> { + setTreeExpanded(candidatePopupRoot, false); + candidatePopupRoot.setExpanded(true); + }); + Button closeBtn = StyledButton.secondary("关闭", Font.font("Microsoft YaHei", compactFont())); + + HBox toolbar = new HBox(Theme.S2, expandBtn, collapseBtn); + toolbar.setAlignment(Pos.CENTER_LEFT); + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + HBox titleBar = new HBox(Theme.S2, title, spacer, closeBtn); + titleBar.setAlignment(Pos.CENTER_LEFT); + + VBox root = new VBox(Theme.S2, titleBar, toolbar, candidatePopupTree); + root.setPadding(new Insets(Theme.S3)); + root.setStyle("-fx-background-color: " + Theme.toWeb(Theme.BG_BODY) + ";"); + VBox.setVgrow(candidatePopupTree, Priority.ALWAYS); + + candidatePopupStage = new Stage(); + candidatePopupStage.setTitle("API Trim - 候选删除项"); + candidatePopupStage.initOwner(stage); + candidatePopupStage.initModality(Modality.NONE); + candidatePopupStage.setResizable(true); + candidatePopupStage.setMinWidth(1180); + candidatePopupStage.setMinHeight(720); + candidatePopupStage.setScene(new Scene(root, 1380, 840)); + candidatePopupStage.setOnCloseRequest(e -> clearCandidatePopupReferences()); + closeBtn.setOnAction(e -> { + candidatePopupStage.close(); + clearCandidatePopupReferences(); + }); + + rebuildCandidateTree(candidatePopupRoot); + candidatePopupStage.show(); + candidatePopupStage.setMaximized(true); + } + + private void clearCandidatePopupReferences() { + candidatePopupStage = null; + candidatePopupTree = null; + candidatePopupRoot = null; + } + + private void setTreeExpanded(boolean expanded) { + if (treeRoot != null) { + setTreeExpanded(treeRoot, expanded); + treeRoot.setExpanded(true); + } + if (candidatePopupRoot != null) { + setTreeExpanded(candidatePopupRoot, expanded); + candidatePopupRoot.setExpanded(true); + } + } + + private void setTreeExpanded(TreeItem item, boolean expanded) { + item.setExpanded(expanded); + for (TreeItem child : item.getChildren()) { + setTreeExpanded(child, expanded); + } + } + + private void expandTreeToDepth(TreeItem item, int depth) { + item.setExpanded(depth >= 0); + if (depth < 0) { + return; + } + for (TreeItem child : item.getChildren()) { + expandTreeToDepth(child, depth - 1); + } + } + + private Set collectExpandedPaths(TreeItem item) { + Set expanded = new LinkedHashSet<>(); + collectExpandedPaths(item, expanded); + return expanded; + } + + private void collectExpandedPaths(TreeItem item, Set expanded) { + DirRow row = item.getValue(); + if (row != null && item.isExpanded()) { + expanded.add(row.getInfo().getPath()); + } + for (TreeItem child : item.getChildren()) { + collectExpandedPaths(child, expanded); + } + } + + private void restoreExpandedPaths(TreeItem item, Set expandedPaths) { + DirRow row = item.getValue(); + item.setExpanded(row == null || expandedPaths.contains(row.getInfo().getPath())); + for (TreeItem child : item.getChildren()) { + restoreExpandedPaths(child, expandedPaths); + } + } + + private void updateSelectedPathsArea(Set selected) { + if (selectedPathsArea == null) { + return; + } + if (selected.isEmpty()) { + selectedPathsArea.setText("有效删除前缀: -"); + return; + } + StringBuilder sb = new StringBuilder("有效删除前缀(父级已覆盖的子项不会重复执行):\n"); + int index = 0; + for (String prefix : selected) { + if (index >= 16) { + sb.append("... 还有 ").append(selected.size() - index).append(" 项"); + break; + } + sb.append("- ").append(prefix); + if (++index < selected.size()) { + sb.append('\n'); + } + } + selectedPathsArea.setText(sb.toString()); + } + + private Set rawSelectedPrefixes() { + Set selected = new LinkedHashSet<>(); + for (DirRow row : dirRows) { + if (row.isSelected()) { + selected.add(row.getInfo().getPath()); + } + } + return selected; + } + + private Set selectedPrefixes() { + List raw = new ArrayList<>(rawSelectedPrefixes()); + raw.sort(Comparator.comparingInt(String::length).thenComparing(String::compareTo)); + Set selected = new LinkedHashSet<>(); + for (String prefix : raw) { + if (findSelectedAncestor(prefix, selected) == null) { + selected.add(prefix); + } + } + return selected; + } + + private String findSelectedAncestor(String path, Set selected) { + String matched = null; + for (String prefix : selected) { + if (!prefix.equals(path) && covers(prefix, path)) { + matched = prefix; + } + } + return matched; + } + + private static boolean covers(String parentPrefix, String childPath) { + return parentPrefix.endsWith("/") && childPath.startsWith(parentPrefix) + && childPath.length() > parentPrefix.length(); + } + + private boolean ensureHasRows() { + if (dirRows.isEmpty()) { + showAlert("请先选择并扫描 API JAR 文件"); + return false; + } + return true; + } + + private Path currentJarPath() { + if (jarPathField == null) { + return null; + } + String path = jarPathField.getText(); + if (path == null || path.trim().isEmpty()) { + return null; + } + Path jarPath = Path.of(path.trim()); + if (!Files.exists(jarPath)) { + return null; + } + return jarPath; + } + + private void setBusy(boolean busy, String message) { + progressIndicator.setVisible(busy); + progressIndicator.setManaged(busy); + previewBtn.setDisable(busy || selectedPrefixes().isEmpty()); + trimBtn.setDisable(busy || selectedPrefixes().isEmpty()); + cancelBtn.setDisable(false); + statusLabel.setText(message); + } private void showAlert(String msg) { Platform.runLater(() -> { @@ -560,19 +1288,207 @@ public class ApiTrimmerWindow { }); } - private void showInfo(String title, String msg) { - Platform.runLater(() -> { - Alert alert = new Alert(Alert.AlertType.INFORMATION); - alert.setTitle(title); - alert.setHeaderText(null); - alert.setContentText(msg); - alert.initOwner(stage); - alert.showAndWait(); - }); - } - private void close() { closeLatch.countDown(); stage.close(); } + + private static String formatSize(long bytes) { + if (bytes < 1024) { + return bytes + " B"; + } + if (bytes < 1_048_576) { + return String.format("%.1f KB", bytes / 1024.0); + } + return String.format("%.1f MB", bytes / 1_048_576.0); + } + + public static class DirRow { + private final ApiTrimmerService.TrimCandidate candidate; + private final BooleanProperty selected; + private final StringProperty name; + private final StringProperty depth; + private final StringProperty fileType; + private final StringProperty risk; + private final StringProperty category; + private final StringProperty coverage; + private final StringProperty size; + private final StringProperty count; + private final StringProperty reason; + private final StringProperty rpcSafety; + + DirRow(ApiTrimmerService.TrimCandidate candidate) { + this.candidate = candidate; + this.selected = new SimpleBooleanProperty(candidate.isSelectedByDefault()); + this.name = new SimpleStringProperty(treeName(candidate.getInfo())); + this.depth = new SimpleStringProperty(String.valueOf(candidate.getInfo().getDepth())); + this.fileType = new SimpleStringProperty(detectFileType(candidate.getInfo().getPath())); + this.risk = new SimpleStringProperty(candidate.getRiskLevel().getLabel()); + this.category = new SimpleStringProperty(candidate.getCategory()); + this.coverage = new SimpleStringProperty(""); + this.size = new SimpleStringProperty(formatSize(candidate.getInfo().getSizeBytes())); + this.count = new SimpleStringProperty(candidate.getInfo().getFileCount() + + " / " + candidate.getInfo().getClassCount()); + this.reason = new SimpleStringProperty(candidate.getReason()); + this.rpcSafety = new SimpleStringProperty(candidate.getRpcSafety()); + } + + public ApiTrimmerService.DirInfo getInfo() { + return candidate.getInfo(); + } + + public ApiTrimmerService.RiskLevel getRiskLevel() { + return candidate.getRiskLevel(); + } + + public boolean isSelectedByDefault() { + return candidate.isSelectedByDefault(); + } + + public BooleanProperty selectedProperty() { + return selected; + } + + public boolean isSelected() { + return selected.get(); + } + + public void setSelected(boolean selected) { + this.selected.set(selected); + } + + public String getName() { + return name.get(); + } + + public String getReason() { + return reason.get(); + } + + public boolean isDirectory() { + return candidate.getInfo().getPath().endsWith("/"); + } + + public void setCoverage(String value) { + coverage.set(value == null ? "" : value); + } + + public boolean matches(String lowerKeyword) { + return candidate.getInfo().getPath().toLowerCase(Locale.ROOT).contains(lowerKeyword) + || fileType.get().toLowerCase(Locale.ROOT).contains(lowerKeyword) + || risk.get().toLowerCase(Locale.ROOT).contains(lowerKeyword) + || category.get().toLowerCase(Locale.ROOT).contains(lowerKeyword) + || coverage.get().toLowerCase(Locale.ROOT).contains(lowerKeyword) + || reason.get().toLowerCase(Locale.ROOT).contains(lowerKeyword) + || rpcSafety.get().toLowerCase(Locale.ROOT).contains(lowerKeyword); + } + + public StringProperty nameProperty() { + return name; + } + + public StringProperty depthProperty() { + return depth; + } + + public StringProperty fileTypeProperty() { + return fileType; + } + + public StringProperty riskProperty() { + return risk; + } + + public StringProperty categoryProperty() { + return category; + } + + public StringProperty coverageProperty() { + return coverage; + } + + public StringProperty sizeProperty() { + return size; + } + + public StringProperty countProperty() { + return count; + } + + public StringProperty reasonProperty() { + return reason; + } + + public StringProperty rpcSafetyProperty() { + return rpcSafety; + } + + private static String treeName(ApiTrimmerService.DirInfo info) { + return leafName(info.getPath()); + } + + private static String leafName(String path) { + String normalized = path.endsWith("/") ? path.substring(0, path.length() - 1) : path; + int slash = normalized.lastIndexOf('/'); + return slash >= 0 ? normalized.substring(slash + 1) : normalized; + } + + private static String detectFileType(String path) { + if (path.endsWith("/")) { + return "文件夹"; + } + String name = leafName(path); + int dot = name.lastIndexOf('.'); + if (dot < 0 || dot == name.length() - 1) { + return "文件"; + } + String ext = name.substring(dot + 1).toLowerCase(Locale.ROOT); + switch (ext) { + case "class": + return "CLASS 文件"; + case "properties": + return "Properties 文件"; + case "xml": + return "XML 文件"; + case "json": + return "JSON 文件"; + case "yml": + case "yaml": + return "YAML 文件"; + case "txt": + return "Text 文件"; + case "md": + return "Markdown 文件"; + case "mf": + return "MANIFEST 文件"; + default: + return ext.toUpperCase(Locale.ROOT) + " 文件"; + } + } + } + + private static class PreviewResult { + final ApiTrimmerService.TrimAnalysis analysis; + final long estimatedSize; + + PreviewResult(ApiTrimmerService.TrimAnalysis analysis, long estimatedSize) { + this.analysis = analysis; + this.estimatedSize = estimatedSize; + } + } + + private static class ExecutionResult { + final ApiTrimmerService.TrimAnalysis analysis; + final Path outputPath; + final long outputSize; + final ApiTrimmerService.VerificationResult verification; + + ExecutionResult(ApiTrimmerService.TrimAnalysis analysis, Path outputPath, long outputSize, + ApiTrimmerService.VerificationResult verification) { + this.analysis = analysis; + this.outputPath = outputPath; + this.outputSize = outputSize; + this.verification = verification; + } + } } diff --git a/src/main/java/com/example/workutils/biz/apitrimmer/service/ApiTrimmerService.java b/src/main/java/com/example/workutils/biz/apitrimmer/service/ApiTrimmerService.java index 048b319..52c391a 100644 --- a/src/main/java/com/example/workutils/biz/apitrimmer/service/ApiTrimmerService.java +++ b/src/main/java/com/example/workutils/biz/apitrimmer/service/ApiTrimmerService.java @@ -4,84 +4,292 @@ import lombok.extern.slf4j.Slf4j; import org.objectweb.asm.*; import org.springframework.stereotype.Service; -import java.io.*; -import java.nio.file.*; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.*; -import java.util.jar.*; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.JarInputStream; +import java.util.jar.JarOutputStream; -/** - * API JAR 精简服务:通过 ASM 字节码可达性分析,剔除实现类及冗余依赖。 - *

- * 算法流程: - *

    - *
  1. 读取 JAR 中所有 .class → className→byte[] 字典
  2. - *
  3. 扫描 RPC 入口(接口 + RPC 注解类)
  4. - *
  5. BFS 可达性分析:从入口出发遍历所有类型引用
  6. - *
  7. JAR 重打包:只输出可达类
  8. - *
- */ @Slf4j @Service public class ApiTrimmerService { - /** 精简配置(UI 中的可选开关) */ + public static final long TARGET_SIZE_BYTES = 4L * 1024 * 1024; + public enum RiskLevel { + SAFE("安全", 0), + CAUTION("谨慎", 1), + DANGER("高风险", 2), + BLOCKED("禁止", 3); + + private final String label; + private final int order; + + RiskLevel(String label, int order) { + this.label = label; + this.order = order; + } + + public String getLabel() { + return label; + } + + int getOrder() { + return order; + } + } + public static class TrimConfig { public boolean skipLombok = true; public boolean skipFonts = true; public boolean skipIdeAnnotations = true; public boolean skipShadeRepack = true; public boolean skipNativeLibs = true; + public boolean skipMavenMeta = true; public boolean skipSpringMeta = true; - /** 直接整目录删除的路径前缀列表(如 "org/projectlombok/", "fonts/") */ - public Set excludedDirs = new HashSet<>(); + public Set excludedDirs = new LinkedHashSet<>(); } - /** JAR 内目录信息 */ public static class DirInfo { - private final String path; // 如 "org/projectlombok/" - private final String name; // 如 "org/projectlombok" + private final String path; + private final String name; + private final int depth; private final int classCount; private final int fileCount; private final long sizeBytes; - public DirInfo(String path, String name, int classCount, int fileCount, long sizeBytes) { + public DirInfo(String path, String name, int depth, int classCount, int fileCount, long sizeBytes) { this.path = path; this.name = name; + this.depth = depth; this.classCount = classCount; this.fileCount = fileCount; this.sizeBytes = sizeBytes; } - public String getPath() { return path; } - public String getName() { return name; } - public int getClassCount() { return classCount; } - public int getFileCount() { return fileCount; } - public long getSizeBytes() { return sizeBytes; } + + public String getPath() { + return path; + } + + public String getName() { + return name; + } + + public int getDepth() { + return depth; + } + + public int getClassCount() { + return classCount; + } + + public int getFileCount() { + return fileCount; + } + + public long getSizeBytes() { + return sizeBytes; + } + } + + public static class TrimCandidate { + private final DirInfo info; + private final boolean recommended; + private final boolean selectedByDefault; + private final RiskLevel riskLevel; + private final String category; + private final String reason; + private final String rpcSafety; + + public TrimCandidate(DirInfo info, boolean recommended, boolean selectedByDefault, + RiskLevel riskLevel, String category, String reason, String rpcSafety) { + this.info = info; + this.recommended = recommended; + this.selectedByDefault = selectedByDefault; + this.riskLevel = riskLevel; + this.category = category; + this.reason = reason; + this.rpcSafety = rpcSafety; + } + + public DirInfo getInfo() { + return info; + } + + public boolean isRecommended() { + return recommended; + } + + public boolean isSelectedByDefault() { + return selectedByDefault; + } + + public RiskLevel getRiskLevel() { + return riskLevel; + } + + public String getCategory() { + return category; + } + + public String getReason() { + return reason; + } + + public String getRpcSafety() { + return rpcSafety; + } + } + + public static class TrimPlan { + private final long originalSizeBytes; + private final long targetSizeBytes; + private final List candidates; + private final Set defaultExcludedDirs; + private final long defaultExcludedBytes; + private final int defaultExcludedFiles; + private final int defaultExcludedClasses; + private final long estimatedOutputBytes; + private final List warnings; + + public TrimPlan(long originalSizeBytes, long targetSizeBytes, List candidates, + Set defaultExcludedDirs, long defaultExcludedBytes, + int defaultExcludedFiles, int defaultExcludedClasses, + long estimatedOutputBytes, List warnings) { + this.originalSizeBytes = originalSizeBytes; + this.targetSizeBytes = targetSizeBytes; + this.candidates = Collections.unmodifiableList(candidates); + this.defaultExcludedDirs = Collections.unmodifiableSet(defaultExcludedDirs); + this.defaultExcludedBytes = defaultExcludedBytes; + this.defaultExcludedFiles = defaultExcludedFiles; + this.defaultExcludedClasses = defaultExcludedClasses; + this.estimatedOutputBytes = estimatedOutputBytes; + this.warnings = Collections.unmodifiableList(warnings); + } + + public long getOriginalSizeBytes() { + return originalSizeBytes; + } + + public long getTargetSizeBytes() { + return targetSizeBytes; + } + + public List getCandidates() { + return candidates; + } + + public Set getDefaultExcludedDirs() { + return defaultExcludedDirs; + } + + public long getDefaultExcludedBytes() { + return defaultExcludedBytes; + } + + public int getDefaultExcludedFiles() { + return defaultExcludedFiles; + } + + public int getDefaultExcludedClasses() { + return defaultExcludedClasses; + } + + public long getEstimatedOutputBytes() { + return estimatedOutputBytes; + } + + public boolean isTargetReached() { + return estimatedOutputBytes <= targetSizeBytes; + } + + public List getWarnings() { + return warnings; + } + } + + public static class SelectionSummary { + private final long excludedBytes; + private final int excludedFiles; + private final int excludedClasses; + private final long estimatedOutputBytes; + + public SelectionSummary(long excludedBytes, int excludedFiles, int excludedClasses, long estimatedOutputBytes) { + this.excludedBytes = excludedBytes; + this.excludedFiles = excludedFiles; + this.excludedClasses = excludedClasses; + this.estimatedOutputBytes = estimatedOutputBytes; + } + + public long getExcludedBytes() { + return excludedBytes; + } + + public int getExcludedFiles() { + return excludedFiles; + } + + public int getExcludedClasses() { + return excludedClasses; + } + + public long getEstimatedOutputBytes() { + return estimatedOutputBytes; + } + + public boolean isTargetReached() { + return estimatedOutputBytes <= TARGET_SIZE_BYTES; + } } - /** 分析结果数据对象 */ public static class TrimAnalysis { private final int totalClassCount; private final int retainedClassCount; private final Set retainedClasses; private final long originalSizeBytes; private final List entryPoints; + private final List excludedEntryPoints; public TrimAnalysis(int totalClassCount, int retainedClassCount, Set retainedClasses, long originalSizeBytes, - List entryPoints) { + List entryPoints, List excludedEntryPoints) { this.totalClassCount = totalClassCount; this.retainedClassCount = retainedClassCount; this.retainedClasses = Collections.unmodifiableSet(retainedClasses); this.originalSizeBytes = originalSizeBytes; this.entryPoints = Collections.unmodifiableList(entryPoints); + this.excludedEntryPoints = Collections.unmodifiableList(excludedEntryPoints); } - public int getTotalClassCount() { return totalClassCount; } - public int getRetainedClassCount() { return retainedClassCount; } - public int getRemovedClassCount() { return totalClassCount - retainedClassCount; } - public Set getRetainedClasses() { return retainedClasses; } - public long getOriginalSizeBytes() { return originalSizeBytes; } - public List getEntryPoints() { return entryPoints; } + public int getTotalClassCount() { + return totalClassCount; + } + + public int getRetainedClassCount() { + return retainedClassCount; + } + + public int getRemovedClassCount() { + return totalClassCount - retainedClassCount; + } + + public Set getRetainedClasses() { + return retainedClasses; + } + + public long getOriginalSizeBytes() { + return originalSizeBytes; + } + + public List getEntryPoints() { + return entryPoints; + } + + public List getExcludedEntryPoints() { + return excludedEntryPoints; + } public double getReductionPercent() { return totalClassCount == 0 ? 0 : @@ -89,71 +297,148 @@ public class ApiTrimmerService { } } - // ==================== 目录扫描(供 UI 展示选择) ==================== + public static class VerificationResult { + private final boolean ok; + private final int entryPointsChecked; + private final int retainedClassesChecked; + private final List errors; + private final List warnings; - /** - * 扫描 JAR 内所有顶层目录,返回目录信息列表(按大小降序)。 - */ - public List scanJarDirectories(Path jarPath) throws IOException { - Map classCounts = new HashMap<>(); - Map sizeMap = new HashMap<>(); - Map fileCounts = new HashMap<>(); + public VerificationResult(boolean ok, int entryPointsChecked, int retainedClassesChecked, + List errors, List warnings) { + this.ok = ok; + this.entryPointsChecked = entryPointsChecked; + this.retainedClassesChecked = retainedClassesChecked; + this.errors = Collections.unmodifiableList(errors); + this.warnings = Collections.unmodifiableList(warnings); + } - try (JarInputStream jis = new JarInputStream(Files.newInputStream(jarPath))) { - JarEntry entry; - while ((entry = jis.getNextJarEntry()) != null) { - String name = entry.getName(); - if (name.equals("META-INF/") || name.equals("module-info.class")) continue; + public boolean isOk() { + return ok; + } - // 提取顶层目录名 - int slash = name.indexOf('/'); - if (slash <= 0) continue; - String topDir = name.substring(0, slash + 1); // "com/" "org/" "fonts/" + public int getEntryPointsChecked() { + return entryPointsChecked; + } - fileCounts.merge(topDir, 1, Integer::sum); - sizeMap.merge(topDir, entry.getCompressedSize(), Long::sum); - if (name.endsWith(".class")) { - classCounts.merge(topDir, 1, Integer::sum); + public int getRetainedClassesChecked() { + return retainedClassesChecked; + } + + public List getErrors() { + return errors; + } + + public List getWarnings() { + return warnings; + } + } + + public TrimPlan buildTrimPlan(Path jarPath, Set autoPatterns) throws IOException { + long originalSize = Files.size(jarPath); + List entries = readEntries(jarPath); + LinkedHashSet prefixes = collectCandidatePrefixes(entries, autoPatterns); + List candidates = new ArrayList<>(); + for (String prefix : prefixes) { + DirInfo info = summarizePrefix(prefix, entries); + if (info.getFileCount() == 0) { + continue; + } + candidates.add(classifyCandidate(info, autoPatterns, entries)); + } + candidates.sort(Comparator + .comparing((TrimCandidate c) -> !c.isSelectedByDefault()) + .thenComparing(c -> c.getRiskLevel().getOrder()) + .thenComparing(c -> c.getInfo().getPath())); + + LinkedHashSet selected = new LinkedHashSet<>(); + for (TrimCandidate candidate : candidates) { + if (candidate.isSelectedByDefault()) { + selected.add(candidate.getInfo().getPath()); + } + } + SelectionSummary summary = summarizeSelection(jarPath, selected); + List warnings = new ArrayList<>(); + if (originalSize > TARGET_SIZE_BYTES && !summary.isTargetReached()) { + warnings.add("默认安全方案预计仍大于 4M,需要再分析谨慎项或业务依赖。"); + } + return new TrimPlan(originalSize, TARGET_SIZE_BYTES, candidates, selected, + summary.getExcludedBytes(), summary.getExcludedFiles(), summary.getExcludedClasses(), + summary.getEstimatedOutputBytes(), warnings); + } + + public SelectionSummary summarizeSelection(Path jarPath, Set excludedPrefixes) throws IOException { + long originalSize = Files.size(jarPath); + List entries = readEntries(jarPath); + long bytes = 0; + int files = 0; + int classes = 0; + for (EntryInfo entry : entries) { + if (matchesAnyPrefix(entry.name, excludedPrefixes)) { + bytes += entry.sizeBytes; + files++; + if (entry.classFile) { + classes++; } } } + long estimatedOutput = Math.max(0, originalSize - bytes); + return new SelectionSummary(bytes, files, classes, estimatedOutput); + } - return sizeMap.entrySet().stream() - .sorted(Map.Entry.comparingByValue().reversed()) - .map(e -> new DirInfo( - e.getKey(), - e.getKey().substring(0, e.getKey().length() - 1), - classCounts.getOrDefault(e.getKey(), 0), - fileCounts.getOrDefault(e.getKey(), 0), - e.getValue())) + public long estimateOutputSize(Path jarPath, TrimAnalysis analysis, TrimConfig config) throws IOException { + long estimate = 0; + for (EntryInfo entry : readEntries(jarPath)) { + if (isUnderExcludedDir(entry.name, config)) { + continue; + } + if (!entry.classFile) { + if (!shouldSkipResource(entry.name, config)) { + estimate += entry.sizeBytes; + } + continue; + } + if (analysis.getRetainedClasses().contains(jarEntryToClassName(entry.name))) { + estimate += entry.sizeBytes; + } + } + return estimate; + } + + public List scanJarDirectories(Path jarPath) throws IOException { + List entries = readEntries(jarPath); + LinkedHashSet topDirs = new LinkedHashSet<>(); + for (EntryInfo entry : entries) { + String top = topLevelPrefix(entry.name); + if (top != null) { + topDirs.add(top); + } + } + return topDirs.stream() + .map(prefix -> summarizePrefix(prefix, entries)) + .sorted(Comparator.comparingLong(DirInfo::getSizeBytes).reversed()) .toList(); } - // ==================== 分析入口 ==================== - - /** - * 分析 JAR 文件,返回精简方案。 - * - * @param jarPath 原始 JAR 路径 - * @param extraPackages 额外保留的包名模式(如 com.example.common.dto.**) - * @param config 排除规则开关 - */ public TrimAnalysis analyze(Path jarPath, List extraPackages, TrimConfig config) throws IOException { long originalSize = Files.size(jarPath); + TrimConfig noDirectoryExclusions = copyConfig(config); + noDirectoryExclusions.excludedDirs.clear(); + Map originalClasses = loadAllClasses(jarPath, noDirectoryExclusions); + Set originalEntryPoints = findEntryPoints(originalClasses); - // Step 1: 读取所有类(排除已选 junk 类) Map allClasses = loadAllClasses(jarPath, config); - log.info("分析 JAR: {} 个类, 大小 {}MB", allClasses.size(), + List excludedEntryPoints = originalEntryPoints.stream() + .filter(entry -> !allClasses.containsKey(entry)) + .sorted() + .toList(); + + log.info("分析 JAR: {} 类, 原始入口 {} 个, 大小 {}MB", + allClasses.size(), originalEntryPoints.size(), String.format("%.1f", originalSize / 1_048_576.0)); - // Step 2: 扫描入口 - Set entryPoints = findEntryPoints(allClasses); - log.info("找到 {} 个入口类", entryPoints.size()); + Set reachable = computeReachable(allClasses, originalEntryPoints); - // Step 3: BFS 可达性分析 - Set reachable = computeReachable(allClasses, entryPoints); - - // Step 4: 追加用户指定的额外包 if (extraPackages != null) { int before = reachable.size(); for (String pkg : extraPackages) { @@ -164,11 +449,10 @@ public class ApiTrimmerService { } } if (reachable.size() > before) { - log.info("额外包追加了 {} 个类", reachable.size() - before); + log.info("额外保留包追加了 {} 个类", reachable.size() - before); } } - // Step 5: 对 reachable 再做一轮闭包(新加入的类可能引用其他类) Set closure = computeReachable(allClasses, reachable); if (closure.size() > reachable.size()) { log.info("闭包追加了 {} 个类", closure.size() - reachable.size()); @@ -179,15 +463,11 @@ public class ApiTrimmerService { closure.size(), closure, originalSize, - new ArrayList<>(entryPoints) + new ArrayList<>(originalEntryPoints), + excludedEntryPoints ); } - // ==================== 执行精简 ==================== - - /** - * 根据分析结果重新打包 JAR,只保留白名单中的类。 - */ public Path execute(Path jarPath, TrimAnalysis analysis, Path outputPath, TrimConfig config) throws IOException { Set retained = analysis.getRetainedClasses(); int written = 0; @@ -199,19 +479,20 @@ public class ApiTrimmerService { while ((entry = jis.getNextJarEntry()) != null) { String name = entry.getName(); - // 排除用户选择的整目录 - if (isUnderExcludedDir(name, config)) continue; + if (isUnderExcludedDir(name, config)) { + continue; + } - // 保留非 class 文件(manifest、资源等) if (!name.endsWith(".class")) { - if (shouldSkipResource(name, config)) continue; + if (shouldSkipResource(name, config)) { + continue; + } jos.putNextEntry(new JarEntry(name)); jis.transferTo(jos); jos.closeEntry(); continue; } - // class 文件 → 判断是否在白名单 String className = jarEntryToClassName(name); if (retained.contains(className)) { jos.putNextEntry(new JarEntry(name)); @@ -222,14 +503,348 @@ public class ApiTrimmerService { } } - log.info("精简完成: {} 个类 → {}", analysis.getTotalClassCount(), written); + log.info("精简完成: {} 类 -> {}", analysis.getTotalClassCount(), written); log.info("输出: {} ({}MB)", outputPath, String.format("%.1f", Files.size(outputPath) / 1_048_576.0)); return outputPath; } - // ==================== 内部:JAR 读取 ==================== + public VerificationResult verifyOutput(Path outputPath, TrimAnalysis analysis) throws IOException { + Set outputClasses = loadClassNames(outputPath); + List errors = new ArrayList<>(); + List warnings = new ArrayList<>(); + + for (String entryPoint : analysis.getEntryPoints()) { + if (!outputClasses.contains(entryPoint)) { + errors.add("缺少 RPC/API 入口类: " + entryPoint); + } + } + for (String retained : analysis.getRetainedClasses()) { + if (!outputClasses.contains(retained)) { + errors.add("缺少接口签名可达类: " + retained); + if (errors.size() >= 20) { + warnings.add("缺失类超过 20 个,已截断显示。"); + break; + } + } + } + if (!analysis.getExcludedEntryPoints().isEmpty()) { + errors.add("用户选择的删除项覆盖了入口类: " + String.join(", ", analysis.getExcludedEntryPoints())); + } + if (analysis.getEntryPoints().isEmpty()) { + warnings.add("未识别到接口或 Dubbo/Feign/gRPC/Thrift 入口类,请确认该 JAR 是否为 API 模块。"); + } + return new VerificationResult(errors.isEmpty(), analysis.getEntryPoints().size(), + analysis.getRetainedClasses().size(), errors, warnings); + } + + private List readEntries(Path jarPath) throws IOException { + List entries = new ArrayList<>(); + try (JarFile jarFile = new JarFile(jarPath.toFile())) { + Enumeration enumeration = jarFile.entries(); + while (enumeration.hasMoreElements()) { + JarEntry entry = enumeration.nextElement(); + if (entry.isDirectory()) { + continue; + } + long size = entry.getCompressedSize(); + if (size < 0) { + size = Math.max(0, entry.getSize()); + } + entries.add(new EntryInfo(entry.getName(), size, entry.getName().endsWith(".class"))); + } + } + return entries; + } + + private LinkedHashSet collectCandidatePrefixes(List entries, Set autoPatterns) { + LinkedHashSet prefixes = new LinkedHashSet<>(); + addIfPresent(prefixes, entries, "lombok/"); + addIfPresent(prefixes, entries, "org/projectlombok/"); + addIfPresent(prefixes, entries, "org/jetbrains/"); + addIfPresent(prefixes, entries, "org/intellij/"); + addIfPresent(prefixes, entries, "javax/annotation/processing/"); + addIfPresent(prefixes, entries, "META-INF/maven/"); + + for (EntryInfo entry : entries) { + String lower = entry.name.toLowerCase(Locale.ROOT); + if (isFontResource(lower) || isNativeLibrary(lower)) { + prefixes.add(directoryPrefix(entry.name)); + } else if (lower.equals("meta-inf/spring-configuration-metadata.json")) { + prefixes.add(entry.name); + } + } + + for (EntryInfo entry : entries) { + addDirectoryPrefixes(prefixes, entry.name); + prefixes.add(entry.name); + } + + if (autoPatterns != null && !autoPatterns.isEmpty()) { + List patterns = autoPatterns.stream() + .filter(Objects::nonNull) + .map(s -> s.toLowerCase(Locale.ROOT).trim()) + .filter(s -> !s.isEmpty()) + .toList(); + for (EntryInfo entry : entries) { + String prefix = directoryPrefix(entry.name); + String lowerTop = prefix.toLowerCase(Locale.ROOT); + for (String pattern : patterns) { + if (lowerTop.contains(pattern)) { + prefixes.add(prefix); + } + } + } + } + return prefixes; + } + + private void addIfPresent(Set prefixes, List entries, String prefix) { + for (EntryInfo entry : entries) { + if (entry.name.startsWith(prefix)) { + prefixes.add(prefix); + return; + } + } + } + + private static void addDirectoryPrefixes(Set prefixes, String entryName) { + int from = 0; + while (true) { + int slash = entryName.indexOf('/', from); + if (slash < 0) { + return; + } + prefixes.add(entryName.substring(0, slash + 1)); + from = slash + 1; + } + } + + private TrimCandidate classifyCandidate(DirInfo info, Set autoPatterns, List entries) { + String path = info.getPath(); + String lower = path.toLowerCase(Locale.ROOT); + boolean customMatched = matchesPattern(lower, autoPatterns); + boolean fileEntry = !path.endsWith("/"); + + if (lower.equals("lombok/") || lower.equals("org/projectlombok/")) { + return safe(info, "Lombok 编译期依赖", + "Lombok 只在编译期生成 getter/setter/构造器等字节码,运行时 Dubbo 消费端不需要 Lombok 处理器或注解包。", + "Dubbo 调用依赖接口、方法签名、DTO/枚举/异常的 class;这些字节码已经生成,不依赖 Lombok。"); + } + if (lower.equals("org/jetbrains/") || lower.equals("org/intellij/") + || lower.equals("javax/annotation/processing/")) { + return safe(info, "IDE/编译期注解", + "这些包用于 IDE 提示、静态分析或注解处理,不参与 RPC 方法调用和序列化。", + "接口签名中的业务类型会被可达性分析保留;IDE 注解缺失不会影响 Dubbo 代理调用。"); + } + if (lower.startsWith("lombok/") || lower.startsWith("org/projectlombok/") + || lower.startsWith("org/jetbrains/") || lower.startsWith("org/intellij/") + || lower.startsWith("javax/annotation/processing/")) { + return safe(info, "编译期/IDE 文件", + "该文件位于已知编译期或 IDE 辅助依赖目录下,不属于 API 契约运行时必需内容。", + "Dubbo 消费端加载接口、DTO、枚举、异常等业务 class;这些辅助文件不参与 RPC 代理和序列化。"); + } + if (lower.equals("meta-inf/maven/")) { + return safe(info, "Maven 构建元数据", + "META-INF/maven 是构建和依赖溯源信息,Dubbo 运行时不会通过它解析接口或 DTO。", + "Dubbo 消费端按 class 加载接口与模型对象,不依赖 pom.properties/pom.xml 元数据。"); + } + if (lower.startsWith("meta-inf/maven/") && fileEntry) { + return safe(info, "Maven 构建元数据文件", + "该文件是 Maven 构建或依赖溯源信息,不承载 RPC 接口签名。", + "Dubbo 消费端不依赖 pom.xml/pom.properties 等构建元数据完成接口调用。"); + } + if (isFontCandidate(path, entries) && info.getClassCount() == 0) { + return safe(info, "字体/前端资源", + "字体资源只用于页面或文档展示,API JAR 作为 RPC 契约包时不需要字体文件。", + "Dubbo 参数序列化只依赖接口和参数/返回值类型,不读取 ttf/otf/woff 字体资源。"); + } + if (isNativeCandidate(path, entries) && info.getClassCount() == 0) { + return safe(info, "原生动态库", + "dll/so/dylib 属于 JNI 或平台运行库,API 契约 JAR 不应依赖本地动态库完成 Dubbo 调用。", + "Dubbo 网关桥只需要 Java 接口和模型类;本地库不参与接口代理和序列化。"); + } + if (lower.equals("meta-inf/spring-configuration-metadata.json")) { + return safe(info, "Spring 配置提示元数据", + "该文件用于 IDE/Spring Boot 配置提示,不参与 Dubbo 接口调用。", + "删除配置提示不会删除接口 class,也不会影响 Dubbo 消费端生成代理。"); + } + if (customMatched && info.getClassCount() == 0) { + return safe(info, "自定义规则命中", + "命中自动勾选规则且不包含 class 文件,通常属于资源类冗余内容。", + "不包含 class 文件,不会删除 Dubbo 接口、DTO、枚举或异常类型。"); + } + if (fileEntry && lower.endsWith(".class")) { + return new TrimCandidate(info, false, false, RiskLevel.CAUTION, + "Java class 文件", + "单个 class 文件可能是接口、DTO、枚举、异常、注解或签名可达类型,插件不会默认删除。", + "如需删除,建议先确认它不在接口签名和序列化模型中;预览/执行校验会检查入口类和签名可达类。"); + } + if (fileEntry) { + return new TrimCandidate(info, false, false, RiskLevel.CAUTION, + "资源文件", + "该文件不是默认安全规则命中的资源,可能只是说明/配置,也可能被框架或运行时读取。", + "删除非 class 文件通常不影响 Dubbo 接口签名,但 SPI、Spring、配置类资源仍需人工确认。"); + } + if (lower.equals("com/")) { + return new TrimCandidate(info, false, false, RiskLevel.BLOCKED, + "业务/API 顶层包", + "com 通常包含 API 接口、DTO、枚举、异常等业务契约,整目录删除会直接破坏 Dubbo 调用。", + "Dubbo 消费端必须加载接口和参数/返回值 class;该目录禁止默认删除。"); + } + if (lower.equals("org/") || lower.equals("javax/") || lower.equals("jakarta/")) { + return new TrimCandidate(info, false, false, RiskLevel.DANGER, + "第三方顶层包", + "整目录删除可能移除 Dubbo 注解、校验注解、序列化相关类型或接口签名依赖。", + "建议只删除下方明确安全的子目录,例如 org/projectlombok,而不是删除整个顶层目录。"); + } + if (lower.startsWith("com/")) { + return new TrimCandidate(info, false, false, RiskLevel.CAUTION, + "业务/第三方子包", + "该子目录可能包含 API 接口、DTO、枚举、异常,也可能只是被 shade 进来的第三方依赖;插件无法默认判断可删。", + "只有在确认它不是接口签名、注解、序列化模型依赖后再勾选;执行前后会用入口类和签名可达类做校验兜底。"); + } + if (lower.startsWith("org/") || lower.startsWith("javax/") || lower.startsWith("jakarta/")) { + return new TrimCandidate(info, false, false, RiskLevel.CAUTION, + "第三方子包", + "相比顶层目录,子包删除粒度更小,但仍可能包含 Dubbo、校验、序列化、注解或接口签名依赖。", + "适合在默认安全项仍超过 4M 时逐项确认;若删除了入口或签名可达类,预览/执行校验会提示阻断。"); + } + if (lower.equals("meta-inf/")) { + return new TrimCandidate(info, false, false, RiskLevel.CAUTION, + "JAR 元数据目录", + "META-INF 中可能包含 MANIFEST、services、签名或框架发现文件,整目录删除存在兼容风险。", + "可删除 META-INF/maven 等明确元数据,不建议整目录删除。"); + } + if (lower.startsWith("meta-inf/")) { + return new TrimCandidate(info, false, false, RiskLevel.CAUTION, + "JAR 元数据子目录", + "META-INF 子目录可能是构建元数据,也可能是 SPI、框架自动发现或签名文件,需要按目录含义确认。", + "META-INF/maven 已作为安全项默认勾选;services、spring、签名相关目录不建议盲删。"); + } + if (customMatched) { + return new TrimCandidate(info, false, false, RiskLevel.CAUTION, + "自定义规则命中", + "命中自动勾选规则,但目录中包含 class 文件,需要人工确认是否属于非 API 依赖。", + "如果删除了接口签名可达类,执行后验证会失败;建议先查看原因再选择。"); + } + return new TrimCandidate(info, false, false, RiskLevel.CAUTION, + "未分类目录", + "未命中安全规则,插件无法证明整目录删除一定安全。", + "仅安全模式不会默认删除;如需压到 4M 以下,可逐项确认并依赖执行后验证兜底。"); + } + + private TrimCandidate safe(DirInfo info, String category, String reason, String rpcSafety) { + return new TrimCandidate(info, true, true, RiskLevel.SAFE, category, reason, rpcSafety); + } + + private DirInfo summarizePrefix(String prefix, List entries) { + int fileCount = 0; + int classCount = 0; + long size = 0; + for (EntryInfo entry : entries) { + if (matchesPrefix(entry.name, prefix)) { + fileCount++; + size += entry.sizeBytes; + if (entry.classFile) { + classCount++; + } + } + } + return new DirInfo(prefix, displayName(prefix), depthOfPrefix(prefix), classCount, fileCount, size); + } + + private static boolean matchesAnyPrefix(String name, Set prefixes) { + for (String prefix : prefixes) { + if (matchesPrefix(name, prefix)) { + return true; + } + } + return false; + } + + private static boolean matchesPrefix(String name, String prefix) { + return prefix.endsWith("/") ? name.startsWith(prefix) : name.equals(prefix); + } + + private static boolean matchesPattern(String lowerPath, Set patterns) { + if (patterns == null) { + return false; + } + for (String pattern : patterns) { + if (pattern != null && !pattern.trim().isEmpty() + && lowerPath.contains(pattern.toLowerCase(Locale.ROOT).trim())) { + return true; + } + } + return false; + } + + private static String topLevelPrefix(String entryName) { + int slash = entryName.indexOf('/'); + if (slash <= 0) { + return null; + } + return entryName.substring(0, slash + 1); + } + + private static String directoryPrefix(String entryName) { + int slash = entryName.lastIndexOf('/'); + if (slash < 0) { + return entryName; + } + return entryName.substring(0, slash + 1); + } + + private static int depthOfPrefix(String prefix) { + int depth = 0; + for (int i = 0; i < prefix.length(); i++) { + if (prefix.charAt(i) == '/') { + depth++; + } + } + return Math.max(depth, 1); + } + + private static String displayName(String prefix) { + if (prefix.endsWith("/")) { + return prefix.substring(0, prefix.length() - 1); + } + return prefix; + } + + private static boolean isFontCandidate(String prefix, List entries) { + for (EntryInfo entry : entries) { + if (matchesPrefix(entry.name, prefix) && isFontResource(entry.name.toLowerCase(Locale.ROOT))) { + return true; + } + } + return false; + } + + private static boolean isNativeCandidate(String prefix, List entries) { + for (EntryInfo entry : entries) { + if (matchesPrefix(entry.name, prefix) && isNativeLibrary(entry.name.toLowerCase(Locale.ROOT))) { + return true; + } + } + return false; + } + + private static boolean isFontResource(String lowerName) { + return lowerName.endsWith(".ttf") + || lowerName.endsWith(".otf") + || lowerName.endsWith(".woff") + || lowerName.endsWith(".woff2") + || lowerName.contains("/fonts/"); + } + + private static boolean isNativeLibrary(String lowerName) { + return lowerName.endsWith(".dll") + || lowerName.endsWith(".so") + || lowerName.endsWith(".dylib"); + } private Map loadAllClasses(Path jarPath, TrimConfig config) throws IOException { Map classes = new LinkedHashMap<>(); @@ -238,8 +853,12 @@ public class ApiTrimmerService { while ((entry = jis.getNextJarEntry()) != null) { if (entry.getName().endsWith(".class") && !entry.getName().equals("module-info.class")) { String className = jarEntryToClassName(entry.getName()); - if (isJunkClass(className, config)) continue; - if (isUnderExcludedDir(entry.getName(), config)) continue; + if (isJunkClass(className, config)) { + continue; + } + if (isUnderExcludedDir(entry.getName(), config)) { + continue; + } classes.put(className, jis.readAllBytes()); } } @@ -247,16 +866,33 @@ public class ApiTrimmerService { return classes; } - /** 判断 entry 是否位于用户选择的排除目录下 */ + private Set loadClassNames(Path jarPath) throws IOException { + Set classes = new LinkedHashSet<>(); + try (JarFile jarFile = new JarFile(jarPath.toFile())) { + Enumeration enumeration = jarFile.entries(); + while (enumeration.hasMoreElements()) { + JarEntry entry = enumeration.nextElement(); + if (!entry.isDirectory() && entry.getName().endsWith(".class") + && !entry.getName().equals("module-info.class")) { + classes.add(jarEntryToClassName(entry.getName())); + } + } + } + return classes; + } + private static boolean isUnderExcludedDir(String entryName, TrimConfig config) { - if (config.excludedDirs.isEmpty()) return false; + if (config.excludedDirs.isEmpty()) { + return false; + } for (String dir : config.excludedDirs) { - if (entryName.startsWith(dir)) return true; + if (matchesPrefix(entryName, dir)) { + return true; + } } return false; } - /** 判断是否跳过冗余类:根据 config 开关 */ private static boolean isJunkClass(String className, TrimConfig config) { if (config.skipLombok && (className.startsWith("org.projectlombok.") || className.startsWith("lombok."))) { @@ -268,73 +904,47 @@ public class ApiTrimmerService { || className.startsWith("javax.annotation.processing."))) { return true; } - if (config.skipShadeRepack && className.contains(".shaded.")) { - return true; - } - return false; + return config.skipShadeRepack && className.contains(".shaded."); } - /** 判断是否跳过非 API 必需的资源文件:根据 config 开关 */ private static boolean shouldSkipResource(String entryName, TrimConfig config) { - // Maven 元数据(始终跳过) - if (entryName.startsWith("META-INF/maven/") || entryName.equals("META-INF/")) { + String lower = entryName.toLowerCase(Locale.ROOT); + if (config.skipMavenMeta && (entryName.startsWith("META-INF/maven/") || entryName.equals("META-INF/"))) { return true; } - // Spring 配置元数据 if (config.skipSpringMeta) { - if (entryName.startsWith("META-INF/spring-configuration-metadata.json") - || entryName.startsWith("META-INF/spring.factories") + if (entryName.equals("META-INF/spring-configuration-metadata.json") || entryName.startsWith("META-INF/spring/") - || entryName.contains("application")) { + || entryName.equals("META-INF/spring.factories") + || lower.contains("application")) { return true; } } - // 字体文件 - if (config.skipFonts) { - String lower = entryName.toLowerCase(); - if (lower.endsWith(".ttf") || lower.endsWith(".otf") - || lower.endsWith(".woff") || lower.endsWith(".woff2") - || entryName.contains("/fonts/")) { - return true; - } + if (config.skipFonts && isFontResource(lower)) { + return true; } - // 原生动态库 - if (config.skipNativeLibs) { - String lower = entryName.toLowerCase(); - if (lower.endsWith(".dll") || lower.endsWith(".so") - || lower.endsWith(".dylib")) { - return true; - } - } - return false; + return config.skipNativeLibs && isNativeLibrary(lower); } private static String jarEntryToClassName(String entryName) { return entryName.substring(0, entryName.length() - 6).replace('/', '.'); } - // ==================== 内部:入口扫描 ==================== - - /** - * 扫描入口类:所有接口 + 带 RPC 注解的类。 - */ private Set findEntryPoints(Map allClasses) { Set entries = new LinkedHashSet<>(); for (Map.Entry e : allClasses.entrySet()) { String name = e.getKey(); byte[] code = e.getValue(); - // 匿名内部类不作为入口 - if (name.contains("$")) continue; - + if (name.contains("$")) { + continue; + } try { ClassReader cr = new ClassReader(code); int access = cr.getAccess(); - // 接口是天然入口 if ((access & Opcodes.ACC_INTERFACE) != 0) { entries.add(name); continue; } - // 检查是否有 RPC 注解 EntryDetector detector = new EntryDetector(); cr.accept(detector, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG); if (detector.isRpcEntry) { @@ -346,34 +956,26 @@ public class ApiTrimmerService { return entries; } - // ==================== 内部:BFS 可达性分析 ==================== - - private Set computeReachable(Map allClasses, - Set seeds) { + private Set computeReachable(Map allClasses, Set seeds) { Set reachable = new LinkedHashSet<>(); Queue queue = new ArrayDeque<>(); - // 将 seeds 中存在于此 JAR 的类入队 for (String seed : seeds) { - if (allClasses.containsKey(seed)) { - if (reachable.add(seed)) { - queue.add(seed); - } + if (allClasses.containsKey(seed) && reachable.add(seed)) { + queue.add(seed); } } while (!queue.isEmpty()) { String cls = queue.poll(); byte[] code = allClasses.get(cls); - if (code == null) continue; - + if (code == null) { + continue; + } try { - // 提取所有类型引用 Set refs = extractTypeReferences(code); for (String ref : refs) { - // 只关注 JAR 内的类 - if (!allClasses.containsKey(ref)) continue; - if (reachable.add(ref)) { + if (allClasses.containsKey(ref) && reachable.add(ref)) { queue.add(ref); } } @@ -393,25 +995,46 @@ public class ApiTrimmerService { return refs; } - // ==================== 内部:包名匹配 ==================== - private static boolean matchPackage(String className, String pattern) { - // 支持: com.example.dto.** 或 com.example.dto.* 或 com.example.dto if (pattern.endsWith(".**")) { return className.startsWith(pattern.substring(0, pattern.length() - 3)); } if (pattern.endsWith(".*")) { String prefix = pattern.substring(0, pattern.length() - 2); - if (!className.startsWith(prefix + ".")) return false; + if (!className.startsWith(prefix + ".")) { + return false; + } String suffix = className.substring(prefix.length() + 1); return !suffix.contains("."); } return className.startsWith(pattern); } - // ==================== ASM Visitors ==================== + private static TrimConfig copyConfig(TrimConfig config) { + TrimConfig copy = new TrimConfig(); + copy.skipLombok = config.skipLombok; + copy.skipFonts = config.skipFonts; + copy.skipIdeAnnotations = config.skipIdeAnnotations; + copy.skipShadeRepack = config.skipShadeRepack; + copy.skipNativeLibs = config.skipNativeLibs; + copy.skipMavenMeta = config.skipMavenMeta; + copy.skipSpringMeta = config.skipSpringMeta; + copy.excludedDirs = new LinkedHashSet<>(config.excludedDirs); + return copy; + } + + private static class EntryInfo { + final String name; + final long sizeBytes; + final boolean classFile; + + EntryInfo(String name, long sizeBytes, boolean classFile) { + this.name = name; + this.sizeBytes = sizeBytes; + this.classFile = classFile; + } + } - /** 检测类是否有 RPC 框架注解 */ private static class EntryDetector extends ClassVisitor { boolean isRpcEntry = false; @@ -427,14 +1050,13 @@ public class ApiTrimmerService { || annotationName.contains("ThriftService") || annotationName.contains("GrpcService") || (annotationName.startsWith("org.apache.dubbo.config.annotation.") - && annotationName.contains("Service"))) { + && annotationName.contains("Service"))) { isRpcEntry = true; } return null; } } - /** 收集类的所有类型引用 */ private static class ReferenceCollector extends ClassVisitor { private final Set refs; @@ -448,18 +1070,31 @@ public class ApiTrimmerService { String signature, String superName, String[] interfaces) { addInternalName(superName); if (interfaces != null) { - for (String itf : interfaces) addInternalName(itf); + for (String itf : interfaces) { + addInternalName(itf); + } } - // 泛型签名中可能包含类型引用 parseSignature(signature); } + @Override + public AnnotationVisitor visitAnnotation(String desc, boolean visible) { + addAnnotationDesc(desc); + return new AnnotationRefCollector(refs); + } + @Override public FieldVisitor visitField(int access, String name, String desc, String signature, Object value) { addTypeDesc(desc); parseSignature(signature); - return null; // 字段类型引用已从 desc/signature 提取 + return new FieldVisitor(Opcodes.ASM9) { + @Override + public AnnotationVisitor visitAnnotation(String desc, boolean visible) { + addAnnotationDesc(desc); + return new AnnotationRefCollector(refs); + } + }; } @Override @@ -470,15 +1105,36 @@ public class ApiTrimmerService { } addType(Type.getReturnType(desc)); if (exceptions != null) { - for (String ex : exceptions) addInternalName(ex); + for (String ex : exceptions) { + addInternalName(ex); + } } parseSignature(signature); - return null; // 方法参数/返回类型已从 desc/signature 提取 + return new MethodVisitor(Opcodes.ASM9) { + @Override + public AnnotationVisitor visitAnnotation(String desc, boolean visible) { + addAnnotationDesc(desc); + return new AnnotationRefCollector(refs); + } + + @Override + public AnnotationVisitor visitParameterAnnotation(int parameter, String desc, boolean visible) { + addAnnotationDesc(desc); + return new AnnotationRefCollector(refs); + } + }; + } + + private void addAnnotationDesc(String desc) { + if (desc != null) { + addType(Type.getType(desc)); + } } private void addInternalName(String internalName) { - if (internalName == null) return; - refs.add(internalName.replace('/', '.')); + if (internalName != null) { + refs.add(internalName.replace('/', '.')); + } } private void addTypeDesc(String desc) { @@ -494,28 +1150,26 @@ public class ApiTrimmerService { } private void parseSignature(String signature) { - if (signature == null) return; - // 从泛型签名中提取所有 L...; 类型引用 + if (signature == null) { + return; + } int pos = 0; while (pos < signature.length()) { int lt = signature.indexOf('L', pos); - if (lt < 0) break; - int semi = signature.indexOf(';', lt); - if (semi < 0) break; - String typeRef = signature.substring(lt + 1, semi).replace('/', '.'); - // 排除 JDK 标准库 - if (!typeRef.startsWith("java.") && !typeRef.startsWith("javax.")) { - refs.add(typeRef); + if (lt < 0) { + break; } + int semi = signature.indexOf(';', lt); + if (semi < 0) { + break; + } + String typeRef = signature.substring(lt + 1, semi).replace('/', '.'); + refs.add(typeRef); pos = semi + 1; } } } - /** - * 收集注解中的类型引用(保留供未来扩展)。 - */ - @SuppressWarnings("unused") private static class AnnotationRefCollector extends AnnotationVisitor { private final Set refs; @@ -527,8 +1181,7 @@ public class ApiTrimmerService { @Override public void visit(String name, Object value) { if (value instanceof Type) { - Type t = (Type) value; - refs.add(t.getClassName()); + refs.add(((Type) value).getClassName()); } }