JAR 瘦身插件
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
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.plugin.Plugin;
|
||||
import com.example.workutils.system.plugin.PluginDescriptor;
|
||||
import com.example.workutils.system.plugin.VisualPluginFactory;
|
||||
import com.example.workutils.system.support.JfxBootstrap;
|
||||
import javafx.scene.Parent;
|
||||
import javafx.scene.layout.Pane;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Scanner;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
|
||||
/**
|
||||
* API Trim 插件:精简 API JAR 包,移除冗余实现类与传递依赖。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ApiTrimmerPlugin implements Plugin {
|
||||
|
||||
private static final PluginDescriptor DESCRIPTOR = PluginDescriptor.builder()
|
||||
.id("api-trim")
|
||||
.name("API Trim")
|
||||
.version("1.0.0")
|
||||
.description("精简API模块JAR包,移除冗余实现类与传递依赖")
|
||||
.order(65)
|
||||
.icon("✂️")
|
||||
.build();
|
||||
|
||||
private static final String[] MENU_OPTIONS = {
|
||||
"1. UI可视化操作(选择JAR → 分析 → 精简)",
|
||||
"0. 返回上级菜单"
|
||||
};
|
||||
|
||||
private final ApiTrimmerService service;
|
||||
private final WorkUtilsProperties properties;
|
||||
|
||||
@Override public PluginDescriptor getDescriptor() { return DESCRIPTOR; }
|
||||
@Override public String[] getMenuOptions() { return MENU_OPTIONS; }
|
||||
|
||||
@Override
|
||||
public VisualPluginFactory getVisualFactory() {
|
||||
return new VisualPluginFactory() {
|
||||
private ApiTrimmerWindow window;
|
||||
private Parent embeddedRoot;
|
||||
private CountDownLatch latch;
|
||||
|
||||
@Override
|
||||
public Parent getEmbeddedPane() {
|
||||
if (embeddedRoot != null) return embeddedRoot;
|
||||
latch = new CountDownLatch(1);
|
||||
window = new ApiTrimmerWindow(service, properties, latch);
|
||||
embeddedRoot = window.getContentPane();
|
||||
window.getStage().getScene().setRoot(new Pane());
|
||||
return embeddedRoot;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void open(Runnable onClosed) {
|
||||
if (window == null) {
|
||||
latch = new CountDownLatch(1);
|
||||
window = new ApiTrimmerWindow(service, properties, latch);
|
||||
}
|
||||
if (embeddedRoot != null && window.getStage().getScene().getRoot() != embeddedRoot) {
|
||||
window.getStage().getScene().setRoot(embeddedRoot);
|
||||
}
|
||||
window.show();
|
||||
new Thread(() -> {
|
||||
try { latch.await(); } catch (InterruptedException ignored) {}
|
||||
onClosed.run();
|
||||
}, "apitrim-close").start();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleChoice(int choice, Scanner scanner) throws Exception {
|
||||
if (choice == 1) {
|
||||
System.out.println("正在打开 API Trim 窗口...");
|
||||
JfxBootstrap.await();
|
||||
if (JfxBootstrap.isFailed()) {
|
||||
System.err.println("JavaFX 初始化失败");
|
||||
return;
|
||||
}
|
||||
CountDownLatch closeLatch = new CountDownLatch(1);
|
||||
javafx.application.Platform.runLater(() ->
|
||||
new ApiTrimmerWindow(service, properties, closeLatch).show());
|
||||
closeLatch.await();
|
||||
System.out.println("API Trim 窗口已关闭");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
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.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.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Parent;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.control.cell.CheckBoxTableCell;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.scene.text.Font;
|
||||
import javafx.scene.text.Text;
|
||||
import javafx.scene.text.TextFlow;
|
||||
import javafx.stage.*;
|
||||
|
||||
import java.awt.Desktop;
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
|
||||
/**
|
||||
* API Trim 可视化窗口。
|
||||
* <p>
|
||||
* 功能:选择 API JAR → 查看目录结构并勾选整目录删除 → 分析冗余 → 执行精简。
|
||||
*/
|
||||
public class ApiTrimmerWindow {
|
||||
|
||||
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<DirRow> dirTable;
|
||||
private final ObservableList<DirRow> dirRows = FXCollections.observableArrayList();
|
||||
private Label dirTableLabel;
|
||||
// ----
|
||||
private VBox resultBox;
|
||||
private Button trimBtn;
|
||||
private Button cancelBtn;
|
||||
|
||||
// ---- 状态 ----
|
||||
private ApiTrimmerService.TrimAnalysis analysis;
|
||||
/** 自动勾选关键词集合,从配置文件加载 */
|
||||
private Set<String> autoCheckPatterns = new LinkedHashSet<>();
|
||||
/** data 目录下的配置文件路径 */
|
||||
private Path configFile;
|
||||
|
||||
// ==================== 配置加载 ====================
|
||||
|
||||
private static final String PATTERNS_FILE = "api-trimmer-patterns.txt";
|
||||
private static final String DATA_DIR = "data";
|
||||
|
||||
/** 初始化配置文件:若 data 目录下不存在,则从 classpath 模板复制 */
|
||||
private void initConfigFile() {
|
||||
Path dataDir = Path.of(DATA_DIR);
|
||||
configFile = dataDir.resolve(PATTERNS_FILE);
|
||||
try {
|
||||
if (!Files.exists(dataDir)) {
|
||||
Files.createDirectories(dataDir);
|
||||
}
|
||||
if (!Files.exists(configFile)) {
|
||||
// 从 classpath 模板复制
|
||||
try (InputStream is = getClass().getClassLoader()
|
||||
.getResourceAsStream(PATTERNS_FILE)) {
|
||||
if (is != null) {
|
||||
Files.copy(is, configFile);
|
||||
} else {
|
||||
// fallback
|
||||
Files.writeString(configFile, "lombok\nttf\nclass50\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// 兜底
|
||||
configFile = dataDir.resolve(PATTERNS_FILE);
|
||||
}
|
||||
}
|
||||
|
||||
/** 从配置文件读取关键词到 autoCheckPatterns */
|
||||
private void loadPatterns() {
|
||||
autoCheckPatterns.clear();
|
||||
if (configFile == null || !Files.exists(configFile)) {
|
||||
autoCheckPatterns.addAll(List.of("lombok", "ttf", "class50"));
|
||||
return;
|
||||
}
|
||||
try (BufferedReader br = Files.newBufferedReader(configFile, StandardCharsets.UTF_8)) {
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) {
|
||||
line = line.trim().toLowerCase();
|
||||
if (!line.isEmpty() && !line.startsWith("#")) {
|
||||
autoCheckPatterns.add(line);
|
||||
}
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
if (autoCheckPatterns.isEmpty()) {
|
||||
autoCheckPatterns.addAll(List.of("lombok", "ttf", "class50"));
|
||||
}
|
||||
}
|
||||
|
||||
/** 用系统默认编辑器打开配置文件 */
|
||||
private void onEditConfig() {
|
||||
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()});
|
||||
}
|
||||
} 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.initModality(Modality.NONE);
|
||||
stage.setOnCloseRequest(e -> close());
|
||||
|
||||
VBox root = new VBox(Theme.S3);
|
||||
root.setPadding(new Insets(Theme.S4));
|
||||
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));
|
||||
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);
|
||||
ScrollPane resultScroll = new ScrollPane(resultBox);
|
||||
resultScroll.setFitToWidth(true);
|
||||
resultScroll.setPrefHeight(180);
|
||||
resultScroll.setStyle("-fx-background-color: transparent;");
|
||||
|
||||
HBox btnBar = createButtonBar();
|
||||
|
||||
root.getChildren().addAll(title, inputSection, resultScroll, btnBar);
|
||||
VBox.setVgrow(resultScroll, Priority.ALWAYS);
|
||||
|
||||
Scene scene = new Scene(root, 920, 780);
|
||||
stage.setScene(scene);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private VBox createInputSection() {
|
||||
VBox section = new VBox(Theme.S2);
|
||||
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.getStyleClass().add(Theme.CSS_TEXT_FIELD);
|
||||
HBox.setHgrow(jarPathField, Priority.ALWAYS);
|
||||
|
||||
Button browseBtn = StyledButton.small("浏览...",
|
||||
Font.font("Microsoft YaHei", fsBody() - 1));
|
||||
browseBtn.setOnAction(e -> onBrowseJar());
|
||||
|
||||
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.setFont(Font.font("Microsoft YaHei", fsBody() - 1));
|
||||
autoCheckLabel.setTextFill(Theme.TEXT_MUTED);
|
||||
|
||||
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()));
|
||||
|
||||
dirTable = new TableView<>(dirRows);
|
||||
dirTable.setEditable(true);
|
||||
dirTable.setPrefHeight(320);
|
||||
dirTable.setPlaceholder(new Label("(选择 JAR 文件后自动加载目录)"));
|
||||
|
||||
// 列1:复选框
|
||||
TableColumn<DirRow, Boolean> 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<DirRow, Boolean> cell = new CheckBoxTableCell<>();
|
||||
return cell;
|
||||
});
|
||||
|
||||
// 列2:目录名
|
||||
TableColumn<DirRow, String> nameCol = new TableColumn<>("目录");
|
||||
nameCol.setCellValueFactory(cdf -> cdf.getValue().nameProperty());
|
||||
nameCol.setPrefWidth(280);
|
||||
|
||||
// 列3:文件数
|
||||
TableColumn<DirRow, Number> fileCol = new TableColumn<>("文件数");
|
||||
fileCol.setCellValueFactory(cdf -> cdf.getValue().fileCountProperty());
|
||||
fileCol.setPrefWidth(80);
|
||||
fileCol.setStyle("-fx-alignment: CENTER-RIGHT;");
|
||||
|
||||
// 列4:类文件
|
||||
TableColumn<DirRow, Number> classCol = new TableColumn<>("类文件");
|
||||
classCol.setCellValueFactory(cdf -> cdf.getValue().classCountProperty());
|
||||
classCol.setPrefWidth(80);
|
||||
classCol.setStyle("-fx-alignment: CENTER-RIGHT;");
|
||||
|
||||
// 列5:大小
|
||||
TableColumn<DirRow, String> sizeCol = new TableColumn<>("大小");
|
||||
sizeCol.setCellValueFactory(cdf -> cdf.getValue().sizeProperty());
|
||||
sizeCol.setPrefWidth(100);
|
||||
sizeCol.setStyle("-fx-alignment: CENTER-RIGHT;");
|
||||
|
||||
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);
|
||||
return section;
|
||||
}
|
||||
|
||||
private HBox createButtonBar() {
|
||||
HBox bar = new HBox(Theme.S4);
|
||||
bar.setAlignment(Pos.CENTER_RIGHT);
|
||||
bar.setPadding(new Insets(Theme.S2, 0, 0, 0));
|
||||
|
||||
trimBtn = StyledButton.primary("执行精简",
|
||||
Font.font("Microsoft YaHei", fsButton()));
|
||||
trimBtn.setPrefWidth(110);
|
||||
trimBtn.setOnAction(e -> onTrim());
|
||||
|
||||
cancelBtn = StyledButton.secondary("取消",
|
||||
Font.font("Microsoft YaHei", fsButton()));
|
||||
cancelBtn.setPrefWidth(80);
|
||||
cancelBtn.setOnAction(e -> close());
|
||||
|
||||
bar.getChildren().addAll(trimBtn, cancelBtn);
|
||||
return bar;
|
||||
}
|
||||
|
||||
// ==================== TrimConfig 构建 ====================
|
||||
|
||||
private ApiTrimmerService.TrimConfig getTrimConfig() {
|
||||
ApiTrimmerService.TrimConfig config = new ApiTrimmerService.TrimConfig();
|
||||
config.skipLombok = false;
|
||||
config.skipFonts = false;
|
||||
config.skipIdeAnnotations = false;
|
||||
config.skipShadeRepack = false;
|
||||
config.skipNativeLibs = false;
|
||||
config.skipSpringMeta = false;
|
||||
for (DirRow row : dirRows) {
|
||||
if (row.isSelected()) {
|
||||
config.excludedDirs.add(row.getInfo().getPath());
|
||||
}
|
||||
}
|
||||
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);
|
||||
if (file != null) {
|
||||
jarPathField.setText(file.getAbsolutePath());
|
||||
refreshDirList(Path.of(file.getAbsolutePath()));
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private void refreshDirList(Path jarPath) {
|
||||
dirRows.clear();
|
||||
try {
|
||||
List<ApiTrimmerService.DirInfo> dirs = service.scanJarDirectories(jarPath);
|
||||
for (ApiTrimmerService.DirInfo d : dirs) {
|
||||
boolean preSelect = matchesAnyPattern(d.getName());
|
||||
dirRows.add(new DirRow(d, preSelect));
|
||||
}
|
||||
dirTableLabel.setText("JAR 内目录(勾选 = 整目录删除):");
|
||||
} catch (IOException e) {
|
||||
dirTableLabel.setText("目录扫描失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean matchesAnyPattern(String dirName) {
|
||||
String lower = dirName.toLowerCase();
|
||||
for (String p : autoCheckPatterns) {
|
||||
if (lower.contains(p)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
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);
|
||||
return;
|
||||
}
|
||||
|
||||
FileChooser chooser = new FileChooser();
|
||||
chooser.setTitle("保存精简后的 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;
|
||||
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);
|
||||
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("执行精简");
|
||||
});
|
||||
}
|
||||
}, "api-trim-execute").start();
|
||||
}
|
||||
|
||||
// ==================== 结果显示 ====================
|
||||
|
||||
private void showResult() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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 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 showAlert(String msg) {
|
||||
Platform.runLater(() -> {
|
||||
Alert alert = new Alert(Alert.AlertType.WARNING);
|
||||
alert.setTitle("提示");
|
||||
alert.setHeaderText(null);
|
||||
alert.setContentText(msg);
|
||||
alert.initOwner(stage);
|
||||
alert.showAndWait();
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
package com.example.workutils.biz.apitrimmer.service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.objectweb.asm.*;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.jar.*;
|
||||
|
||||
/**
|
||||
* API JAR 精简服务:通过 ASM 字节码可达性分析,剔除实现类及冗余依赖。
|
||||
* <p>
|
||||
* 算法流程:
|
||||
* <ol>
|
||||
* <li>读取 JAR 中所有 .class → className→byte[] 字典</li>
|
||||
* <li>扫描 RPC 入口(接口 + RPC 注解类)</li>
|
||||
* <li>BFS 可达性分析:从入口出发遍历所有类型引用</li>
|
||||
* <li>JAR 重打包:只输出可达类</li>
|
||||
* </ol>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class ApiTrimmerService {
|
||||
|
||||
/** 精简配置(UI 中的可选开关) */
|
||||
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 skipSpringMeta = true;
|
||||
/** 直接整目录删除的路径前缀列表(如 "org/projectlombok/", "fonts/") */
|
||||
public Set<String> excludedDirs = new HashSet<>();
|
||||
}
|
||||
|
||||
/** JAR 内目录信息 */
|
||||
public static class DirInfo {
|
||||
private final String path; // 如 "org/projectlombok/"
|
||||
private final String name; // 如 "org/projectlombok"
|
||||
private final int classCount;
|
||||
private final int fileCount;
|
||||
private final long sizeBytes;
|
||||
|
||||
public DirInfo(String path, String name, int classCount, int fileCount, long sizeBytes) {
|
||||
this.path = path;
|
||||
this.name = name;
|
||||
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 static class TrimAnalysis {
|
||||
private final int totalClassCount;
|
||||
private final int retainedClassCount;
|
||||
private final Set<String> retainedClasses;
|
||||
private final long originalSizeBytes;
|
||||
private final List<String> entryPoints;
|
||||
|
||||
public TrimAnalysis(int totalClassCount, int retainedClassCount,
|
||||
Set<String> retainedClasses, long originalSizeBytes,
|
||||
List<String> entryPoints) {
|
||||
this.totalClassCount = totalClassCount;
|
||||
this.retainedClassCount = retainedClassCount;
|
||||
this.retainedClasses = Collections.unmodifiableSet(retainedClasses);
|
||||
this.originalSizeBytes = originalSizeBytes;
|
||||
this.entryPoints = Collections.unmodifiableList(entryPoints);
|
||||
}
|
||||
|
||||
public int getTotalClassCount() { return totalClassCount; }
|
||||
public int getRetainedClassCount() { return retainedClassCount; }
|
||||
public int getRemovedClassCount() { return totalClassCount - retainedClassCount; }
|
||||
public Set<String> getRetainedClasses() { return retainedClasses; }
|
||||
public long getOriginalSizeBytes() { return originalSizeBytes; }
|
||||
public List<String> getEntryPoints() { return entryPoints; }
|
||||
|
||||
public double getReductionPercent() {
|
||||
return totalClassCount == 0 ? 0 :
|
||||
100.0 * getRemovedClassCount() / totalClassCount;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 目录扫描(供 UI 展示选择) ====================
|
||||
|
||||
/**
|
||||
* 扫描 JAR 内所有顶层目录,返回目录信息列表(按大小降序)。
|
||||
*/
|
||||
public List<DirInfo> scanJarDirectories(Path jarPath) throws IOException {
|
||||
Map<String, Integer> classCounts = new HashMap<>();
|
||||
Map<String, Long> sizeMap = new HashMap<>();
|
||||
Map<String, Integer> fileCounts = new HashMap<>();
|
||||
|
||||
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;
|
||||
|
||||
// 提取顶层目录名
|
||||
int slash = name.indexOf('/');
|
||||
if (slash <= 0) continue;
|
||||
String topDir = name.substring(0, slash + 1); // "com/" "org/" "fonts/"
|
||||
|
||||
fileCounts.merge(topDir, 1, Integer::sum);
|
||||
sizeMap.merge(topDir, entry.getCompressedSize(), Long::sum);
|
||||
if (name.endsWith(".class")) {
|
||||
classCounts.merge(topDir, 1, Integer::sum);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sizeMap.entrySet().stream()
|
||||
.sorted(Map.Entry.<String, Long>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()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// ==================== 分析入口 ====================
|
||||
|
||||
/**
|
||||
* 分析 JAR 文件,返回精简方案。
|
||||
*
|
||||
* @param jarPath 原始 JAR 路径
|
||||
* @param extraPackages 额外保留的包名模式(如 com.example.common.dto.**)
|
||||
* @param config 排除规则开关
|
||||
*/
|
||||
public TrimAnalysis analyze(Path jarPath, List<String> extraPackages, TrimConfig config) throws IOException {
|
||||
long originalSize = Files.size(jarPath);
|
||||
|
||||
// Step 1: 读取所有类(排除已选 junk 类)
|
||||
Map<String, byte[]> allClasses = loadAllClasses(jarPath, config);
|
||||
log.info("分析 JAR: {} 个类, 大小 {}MB", allClasses.size(),
|
||||
String.format("%.1f", originalSize / 1_048_576.0));
|
||||
|
||||
// Step 2: 扫描入口
|
||||
Set<String> entryPoints = findEntryPoints(allClasses);
|
||||
log.info("找到 {} 个入口类", entryPoints.size());
|
||||
|
||||
// Step 3: BFS 可达性分析
|
||||
Set<String> reachable = computeReachable(allClasses, entryPoints);
|
||||
|
||||
// Step 4: 追加用户指定的额外包
|
||||
if (extraPackages != null) {
|
||||
int before = reachable.size();
|
||||
for (String pkg : extraPackages) {
|
||||
for (String cls : allClasses.keySet()) {
|
||||
if (matchPackage(cls, pkg)) {
|
||||
reachable.add(cls);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (reachable.size() > before) {
|
||||
log.info("额外包追加了 {} 个类", reachable.size() - before);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: 对 reachable 再做一轮闭包(新加入的类可能引用其他类)
|
||||
Set<String> closure = computeReachable(allClasses, reachable);
|
||||
if (closure.size() > reachable.size()) {
|
||||
log.info("闭包追加了 {} 个类", closure.size() - reachable.size());
|
||||
}
|
||||
|
||||
return new TrimAnalysis(
|
||||
allClasses.size(),
|
||||
closure.size(),
|
||||
closure,
|
||||
originalSize,
|
||||
new ArrayList<>(entryPoints)
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 执行精简 ====================
|
||||
|
||||
/**
|
||||
* 根据分析结果重新打包 JAR,只保留白名单中的类。
|
||||
*/
|
||||
public Path execute(Path jarPath, TrimAnalysis analysis, Path outputPath, TrimConfig config) throws IOException {
|
||||
Set<String> retained = analysis.getRetainedClasses();
|
||||
int written = 0;
|
||||
|
||||
try (JarInputStream jis = new JarInputStream(Files.newInputStream(jarPath));
|
||||
JarOutputStream jos = new JarOutputStream(Files.newOutputStream(outputPath))) {
|
||||
|
||||
JarEntry entry;
|
||||
while ((entry = jis.getNextJarEntry()) != null) {
|
||||
String name = entry.getName();
|
||||
|
||||
// 排除用户选择的整目录
|
||||
if (isUnderExcludedDir(name, config)) continue;
|
||||
|
||||
// 保留非 class 文件(manifest、资源等)
|
||||
if (!name.endsWith(".class")) {
|
||||
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));
|
||||
jis.transferTo(jos);
|
||||
jos.closeEntry();
|
||||
written++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info("精简完成: {} 个类 → {}", analysis.getTotalClassCount(), written);
|
||||
log.info("输出: {} ({}MB)", outputPath,
|
||||
String.format("%.1f", Files.size(outputPath) / 1_048_576.0));
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
// ==================== 内部:JAR 读取 ====================
|
||||
|
||||
private Map<String, byte[]> loadAllClasses(Path jarPath, TrimConfig config) throws IOException {
|
||||
Map<String, byte[]> classes = new LinkedHashMap<>();
|
||||
try (JarInputStream jis = new JarInputStream(Files.newInputStream(jarPath))) {
|
||||
JarEntry entry;
|
||||
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;
|
||||
classes.put(className, jis.readAllBytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
return classes;
|
||||
}
|
||||
|
||||
/** 判断 entry 是否位于用户选择的排除目录下 */
|
||||
private static boolean isUnderExcludedDir(String entryName, TrimConfig config) {
|
||||
if (config.excludedDirs.isEmpty()) return false;
|
||||
for (String dir : config.excludedDirs) {
|
||||
if (entryName.startsWith(dir)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 判断是否跳过冗余类:根据 config 开关 */
|
||||
private static boolean isJunkClass(String className, TrimConfig config) {
|
||||
if (config.skipLombok &&
|
||||
(className.startsWith("org.projectlombok.") || className.startsWith("lombok."))) {
|
||||
return true;
|
||||
}
|
||||
if (config.skipIdeAnnotations &&
|
||||
(className.startsWith("org.intellij.lang.annotations.")
|
||||
|| className.startsWith("org.jetbrains.annotations.")
|
||||
|| className.startsWith("javax.annotation.processing."))) {
|
||||
return true;
|
||||
}
|
||||
if (config.skipShadeRepack && className.contains(".shaded.")) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 判断是否跳过非 API 必需的资源文件:根据 config 开关 */
|
||||
private static boolean shouldSkipResource(String entryName, TrimConfig config) {
|
||||
// Maven 元数据(始终跳过)
|
||||
if (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")
|
||||
|| entryName.startsWith("META-INF/spring/")
|
||||
|| entryName.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.skipNativeLibs) {
|
||||
String lower = entryName.toLowerCase();
|
||||
if (lower.endsWith(".dll") || lower.endsWith(".so")
|
||||
|| lower.endsWith(".dylib")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String jarEntryToClassName(String entryName) {
|
||||
return entryName.substring(0, entryName.length() - 6).replace('/', '.');
|
||||
}
|
||||
|
||||
// ==================== 内部:入口扫描 ====================
|
||||
|
||||
/**
|
||||
* 扫描入口类:所有接口 + 带 RPC 注解的类。
|
||||
*/
|
||||
private Set<String> findEntryPoints(Map<String, byte[]> allClasses) {
|
||||
Set<String> entries = new LinkedHashSet<>();
|
||||
for (Map.Entry<String, byte[]> e : allClasses.entrySet()) {
|
||||
String name = e.getKey();
|
||||
byte[] code = e.getValue();
|
||||
// 匿名内部类不作为入口
|
||||
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) {
|
||||
entries.add(name);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
// ==================== 内部:BFS 可达性分析 ====================
|
||||
|
||||
private Set<String> computeReachable(Map<String, byte[]> allClasses,
|
||||
Set<String> seeds) {
|
||||
Set<String> reachable = new LinkedHashSet<>();
|
||||
Queue<String> queue = new ArrayDeque<>();
|
||||
|
||||
// 将 seeds 中存在于此 JAR 的类入队
|
||||
for (String seed : seeds) {
|
||||
if (allClasses.containsKey(seed)) {
|
||||
if (reachable.add(seed)) {
|
||||
queue.add(seed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while (!queue.isEmpty()) {
|
||||
String cls = queue.poll();
|
||||
byte[] code = allClasses.get(cls);
|
||||
if (code == null) continue;
|
||||
|
||||
try {
|
||||
// 提取所有类型引用
|
||||
Set<String> refs = extractTypeReferences(code);
|
||||
for (String ref : refs) {
|
||||
// 只关注 JAR 内的类
|
||||
if (!allClasses.containsKey(ref)) continue;
|
||||
if (reachable.add(ref)) {
|
||||
queue.add(ref);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("BFS 分析 {} 失败: {}", cls, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return reachable;
|
||||
}
|
||||
|
||||
private Set<String> extractTypeReferences(byte[] classBytes) {
|
||||
Set<String> refs = new LinkedHashSet<>();
|
||||
ClassReader cr = new ClassReader(classBytes);
|
||||
cr.accept(new ReferenceCollector(refs),
|
||||
ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES);
|
||||
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;
|
||||
String suffix = className.substring(prefix.length() + 1);
|
||||
return !suffix.contains(".");
|
||||
}
|
||||
return className.startsWith(pattern);
|
||||
}
|
||||
|
||||
// ==================== ASM Visitors ====================
|
||||
|
||||
/** 检测类是否有 RPC 框架注解 */
|
||||
private static class EntryDetector extends ClassVisitor {
|
||||
boolean isRpcEntry = false;
|
||||
|
||||
EntryDetector() {
|
||||
super(Opcodes.ASM9);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AnnotationVisitor visitAnnotation(String desc, boolean visible) {
|
||||
String annotationName = Type.getType(desc).getClassName();
|
||||
if (annotationName.contains("DubboService")
|
||||
|| annotationName.contains("FeignClient")
|
||||
|| annotationName.contains("ThriftService")
|
||||
|| annotationName.contains("GrpcService")
|
||||
|| (annotationName.startsWith("org.apache.dubbo.config.annotation.")
|
||||
&& annotationName.contains("Service"))) {
|
||||
isRpcEntry = true;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 收集类的所有类型引用 */
|
||||
private static class ReferenceCollector extends ClassVisitor {
|
||||
private final Set<String> refs;
|
||||
|
||||
ReferenceCollector(Set<String> refs) {
|
||||
super(Opcodes.ASM9);
|
||||
this.refs = refs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void visit(int version, int access, String name,
|
||||
String signature, String superName, String[] interfaces) {
|
||||
addInternalName(superName);
|
||||
if (interfaces != null) {
|
||||
for (String itf : interfaces) addInternalName(itf);
|
||||
}
|
||||
// 泛型签名中可能包含类型引用
|
||||
parseSignature(signature);
|
||||
}
|
||||
|
||||
@Override
|
||||
public FieldVisitor visitField(int access, String name, String desc,
|
||||
String signature, Object value) {
|
||||
addTypeDesc(desc);
|
||||
parseSignature(signature);
|
||||
return null; // 字段类型引用已从 desc/signature 提取
|
||||
}
|
||||
|
||||
@Override
|
||||
public MethodVisitor visitMethod(int access, String name, String desc,
|
||||
String signature, String[] exceptions) {
|
||||
for (Type type : Type.getArgumentTypes(desc)) {
|
||||
addType(type);
|
||||
}
|
||||
addType(Type.getReturnType(desc));
|
||||
if (exceptions != null) {
|
||||
for (String ex : exceptions) addInternalName(ex);
|
||||
}
|
||||
parseSignature(signature);
|
||||
return null; // 方法参数/返回类型已从 desc/signature 提取
|
||||
}
|
||||
|
||||
private void addInternalName(String internalName) {
|
||||
if (internalName == null) return;
|
||||
refs.add(internalName.replace('/', '.'));
|
||||
}
|
||||
|
||||
private void addTypeDesc(String desc) {
|
||||
addType(Type.getType(desc));
|
||||
}
|
||||
|
||||
private void addType(Type type) {
|
||||
if (type.getSort() == Type.OBJECT) {
|
||||
refs.add(type.getClassName());
|
||||
} else if (type.getSort() == Type.ARRAY) {
|
||||
addType(type.getElementType());
|
||||
}
|
||||
}
|
||||
|
||||
private void parseSignature(String signature) {
|
||||
if (signature == null) return;
|
||||
// 从泛型签名中提取所有 L...; 类型引用
|
||||
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);
|
||||
}
|
||||
pos = semi + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集注解中的类型引用(保留供未来扩展)。
|
||||
*/
|
||||
@SuppressWarnings("unused")
|
||||
private static class AnnotationRefCollector extends AnnotationVisitor {
|
||||
private final Set<String> refs;
|
||||
|
||||
AnnotationRefCollector(Set<String> refs) {
|
||||
super(Opcodes.ASM9);
|
||||
this.refs = refs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void visit(String name, Object value) {
|
||||
if (value instanceof Type) {
|
||||
Type t = (Type) value;
|
||||
refs.add(t.getClassName());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public AnnotationVisitor visitAnnotation(String name, String desc) {
|
||||
refs.add(Type.getType(desc).getClassName());
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AnnotationVisitor visitArray(String name) {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void visitEnum(String name, String desc, String value) {
|
||||
refs.add(Type.getType(desc).getClassName());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# API Trim 自动勾选目录名配置(每行一个关键词,忽略大小写,匹配即勾选)
|
||||
# 修改后保存,点击「刷新目录」即可生效
|
||||
lombok
|
||||
ttf
|
||||
class50
|
||||
Reference in New Issue
Block a user