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 字节码可达性分析,剔除实现类及冗余依赖。
- *
- * 算法流程:
- *
- * - 读取 JAR 中所有 .class → className→byte[] 字典
- * - 扫描 RPC 入口(接口 + RPC 注解类)
- * - BFS 可达性分析:从入口出发遍历所有类型引用
- * - JAR 重打包:只输出可达类
- *
- */
@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());
}
}