diff --git a/docs/api-trim-maven-plugin-design.md b/docs/api-trim-maven-plugin-design.md new file mode 100644 index 0000000..fed4e9e --- /dev/null +++ b/docs/api-trim-maven-plugin-design.md @@ -0,0 +1,509 @@ +# api-trim-maven-plugin 架构设计 + +## 1. 问题定义 + +API 模块旨在为网关桥应用提供 RPC 接口调用能力,理想的 JAR 依赖范围应限于: + +- RPC 接口定义 +- 入参/出参 DTO 模型 +- 序列化注解与类型 + +实际产生的冗余: + +| 冗余来源 | 示例 | +|---------|------| +| 实现类 | `*ServiceImpl.java`、`*ManagerImpl.java` | +| 内部工具体系 | `*Utils.java`、`*Helper.java`、`*Converter.java` | +| Spring 配置/自动装配 | `*Config.java`、`*AutoConfiguration.java` | +| 数据层依赖 | MyBatis Mapper、JDBC Driver、DataSource | +| 传递依赖 | 由实现类引入的 spring-boot-starter-*、数据库连接池、缓存等 | + +--- + +## 2. 整体架构 + +``` +┌────────────────────────────────────────────────────────────┐ +│ api-trim-maven-plugin │ +├────────────────────────────────────────────────────────────┤ +│ Goal: api-trim:trim (默认绑定 package 阶段) │ +│ api-trim:analyze(仅分析,不修改产物) │ +├────────────────────────────────────────────────────────────┤ +│ ┌──────────────┐ │ +│ ① analyze │ 依赖分析器 │ ASM 字节码扫描 │ +│ │ Analyzer │ Maven 依赖树解析 │ +│ └──────┬───────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────┐ │ +│ ② trim │ 精简执行器 │ 类/资源白名单策略 │ +│ │ Trimmer │ 依赖重写 或 shade │ +│ └──────┬───────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────┐ │ +│ ③ verify │ 安全校验器 │ 接口契约 diff │ +│ │ Verifier │ 字节码引用完整性 │ +│ └──────────────┘ │ +└────────────────────────────────────────────────────────────┘ +``` + +--- + +## 3. 核心组件详细设计 + +### 3.1 依赖分析器(Analyzer) + +分析器负责区分「RPC 必需的 API 表面」与「冗余实现及传递依赖」。 + +#### 3.1.1 三阶段分析 + +``` +阶段1:入口类识别 + ├─ 扫描目标模块 compiled classes + ├─ 按注解识别 RPC 服务接口 + │ ├─ @DubboService / @Service(interfaceClass=...) + │ ├─ @FeignClient + │ ├─ @RestController / @RequestMapping(若 API 模块含 HTTP 接口) + │ ├─ gRPC Service 基类子类 + │ └─ 用户通过 显式声明 + └─ 收集所有入口接口 → className[] + +阶段2:可达性分析(类级别,ASM) + ├─ 以入口接口为根,BFS 遍历引用图 + │ ├─ 接口继承链(super interfaces) + │ ├─ 方法签名中的类型(参数类型 + 返回类型 + 异常类型) + │ │ └─ 递归解析泛型参数 + │ ├─ 注解类型引用(类注解 + 方法注解 + 字段注解) + │ │ └─ 保留序列化相关注解:Jackson、Protobuf、Hessian、Fastjson 等 + │ └─ 父类中的字段类型 + └─ 输出:必需类集合 Set + +阶段3:依赖坐标分析(Maven,可选) + ├─ 解析 dependency:tree + ├─ 对每个传递依赖: + │ ├─ 若所含类均在「必需类集合」外 → 标记为可移除 + │ └─ 若所含类部分必需 → 保留(后续通过类级过滤处理) + └─ 输出:可移除的 Maven 坐标列表 +``` + +#### 3.1.2 引用图遍历规则 + +``` +BFS 起点:所有入口接口 +遍历策略: +┌─ 遇到类/接口 → 加入必需集 → 展开 +│ ├─ 父类 / 父接口 → 加入队列 +│ ├─ 泛型参数中引用的类 → 加入队列 +│ ├─ 方法参数/返回类型 → 加入队列 +│ ├─ 注解中引用的类 → 加入队列 +│ └─ 字段类型 → 加入队列(仅对 DTO 类) +│ +├─ 遇到标准库类(java.* / javax.*) → 终止(不展开) +├─ 遇到第三方库类 → 加入必需集但标记为 external(不影响 JAR 精简,仅用于依赖分析) +└─ 遇到非目标模块类 → 记录为「外部依赖」,用于验证阶段 +``` + +#### 3.1.3 注解保留策略 + +``` +强制保留的注解分类: +├─ 序列化注解 +│ ├─ Jackson: @JsonProperty, @JsonIgnore, @JsonFormat, @JsonTypeInfo... +│ ├─ Protobuf: @Protobuf, @ProtoField... +│ ├─ Hessian: java.io.Serializable(接口继承) +│ └─ Fastjson: @JSONField +│ +├─ 校验注解(RPC 调用方可能使用) +│ └─ JSR-303: @NotNull, @Valid, @Size, @Pattern... +│ +├─ API 文档注解 +│ └─ Swagger: @ApiModel, @ApiModelProperty(可选,根据配置) +│ +└─ 业务注解(用户自定义) + └─ 通过 配置 +``` + +--- + +### 3.2 精简执行器(Trimmer) + +#### 3.2.1 策略对比与选择 + +| 策略 | 方案 | 优点 | 缺点 | +|------|------|------|------| +| **A: 类级过滤** | 重新打包 JAR,仅保留分析器输出的必需类 | 极致精简,JAR 最小 | 需要处理 MANIFEST、资源文件 | +| **B: POM 重写** | 修改依赖声明,exclude 非必需传递依赖 | Maven 原生支持 | 粒度粗,一个 JAR 内可能只有几个类需要 | +| **C: Shade + 过滤** | maven-shade-plugin + filters + minimizeJar | 成熟稳定 | 配置复杂,对 RPC 语义不感知 | + +**推荐:策略 A(类级过滤)**,原因: +- RPC API 模块依赖中,一个三方 JAR 可能包含 200 个类但仅 3 个 DTO 需要——B 粒度太粗 +- 策略 C 的 `minimizeJar` 对反射/序列化场景不够友好,可能有遗漏 +- 策略 A 基于可达性分析,天然感知 RPC 语义 + +#### 3.2.2 类级过滤执行流程 + +``` +输入:原始 JAR ← maven-jar-plugin 产物 + 必需类集合 Set ← Analyzer + 追加类 ← 用户配置 + 排除类 ← 用户配置 + +流程: +1. 创建临时目录 temp/ +2. 遍历原始 JAR 中所有条目: + ├─ *.class → 判断类全限定名是否在「必需集合 + includes」 + │ ├─ YES → 解压到 temp/ + │ └─ NO → 跳过 + │ + ├─ META-INF/MANIFEST.MF → 保留 + ├─ META-INF/spring.factories → 跳过(不含自动装配) + ├─ *.xml / *.properties(Spring 配置)→ 跳过 + ├─ *.proto / *.avsc → 按需保留(若入口是 gRPC/Protobuf) + └─ 其他资源 → 跳过 + +3. 重新打包 temp/ → ${project.build.finalName}-api.jar +4. 可选:若启用 deps 重写,重写 POM 中依赖 scope +``` + +#### 3.2.3 依赖 scope 重写 + +对于传递给下游的 POM,将非必需依赖标记为 `provided`: + +```xml + + + org.springframework.boot + spring-boot-starter + + + + + org.springframework.boot + spring-boot-starter + provided + true + +``` + +#### 3.2.4 配置模型 + +```xml + + com.example + api-trim-maven-plugin + 1.0.0 + + + + + + com.example.api.UserService + com.example.api.OrderService + + + + DUBBO + + + + com.example.MyBusinessAnnotation + + + + com.example.common.dto.** + com.example.common.enums.** + + + + com.example.**.impl.** + com.example.**.config.** + + + + REWRITE_SCOPE + + + + mysql:mysql-connector-java + org.mybatis:* + + + + true + true + + +``` + +--- + +### 3.3 Maven 生命周期集成 + +#### 3.3.1 Goal 绑定 + +``` +Goal: trim(默认) +├─ 绑定阶段: package +├─ 执行顺序: compile → jar → trim → [install/deploy] +├─ requiresDependencyResolution: compile+runtime +└─ 输出产物: ${project.build.finalName}-api.jar(替换默认 JAR) + +Goal: analyze(诊断用) +├─ 绑定阶段: verify(或手动执行 api-trim:analyze) +├─ 输出: target/api-trim/report.txt +└─ 可选: target/api-trim/dependency-graph.dot +``` + +#### 3.3.2 执行时序 + +``` +mvn clean package + +phase: compile → javac 编译 +phase: process-classes +phase: prepare-package + ┌─────────────────┐ +phase: package ──────▶│ api-trim:trim │ + │ │ + │ 1. 解析入口接口 │ + │ 2. 字节码可达性 │ + │ 3. 构建白名单 │ + │ 4. JAR 重打包 │ + │ 5. POM rewrite │ + │ 6. 安全校验 │ + └────────┬────────┘ + │ +phase: verify ←──────────────┘ (report) +phase: install → 安装精简后的 JAR 到本地仓库 +phase: deploy → 发布精简后的 JAR 到远程仓库 +``` + +#### 3.3.3 多模块项目集成 + +``` +parent +├── api-module ← 在此模块配置 api-trim:trim +│ └── pom.xml ← api-trim-maven-plugin +├── service-module ← 实现模块,正常打包 +├── gateway-bridge ← 网关桥,依赖 api-module(会拿到精简版 JAR) +└── pom.xml +``` + +API 模块只需在 `pom.xml` 中声明该插件即可,其下游模块(gateway-bridge)通过普通 Maven 依赖机制拿到精简后的 JAR: + +```xml + + + com.example + api-module + ${project.version} + + +``` + +--- + +### 3.4 安全保障与验证机制 + +#### 3.4.1 编译时校验 + +``` +校验项1:字节码引用完整性 +├─ 读取精简后的 classes 集合 +├─ 检查每个类的方法描述符中的所有类型引用 +├─ 若引用到未包含在 JAR 中的非标准库类 → ERROR +└─ 区分:缺少的类在调用方 classpath 中 → WARN(需用户确认) + +校验项2:序列化完整性 +├─ 对每个 DTO 类: +│ ├─ 遍历所有字段 getter/setter +│ ├─ 检查序列化注解是否被意外移除 +│ └─ 检查枚举值是否完整 +└─ 生成 SerializationTest.java 模板 +``` + +#### 3.4.2 运行时验证(可选,需用户配置测试类) + +``` +验证方式:API 接口契约对比 +├─ 原始 JAR → 反射扫描所有 RPC 接口的方法签名 → Set +├─ 精简 JAR → 同上 +├─ 对比两者: +│ ├─ 方法数量一致 +│ ├─ 方法签名完全匹配(名称 + 参数 + 返回类型 + 异常) +│ └─ 泛型签名一致 +└─ 不一致 → BUILD FAILURE + +验证方式:序列化往返测试(自动生成测试代码) +├─ 为每个 DTO 生成: +│ public void test_${ClassName}_serialization() { +│ ${ClassName} obj = new ${ClassName}(); +│ // ... populate random data +│ byte[] data = serialize(obj); +│ ${ClassName} result = deserialize(data); +│ assertThat(result).isEqualTo(obj); +│ } +└─ 建议用户将此测试加入 CI 流程 +``` + +#### 3.4.3 报告输出 + +``` +target/api-trim/ +├── report.txt # 精简摘要 +│ ├── 原始 JAR 大小: 45.2 MB +│ ├── 精简后 JAR 大小: 3.8 MB (↓ 91.6%) +│ ├── 原始 class 数量: 8,342 +│ ├── 保留 class 数量: 312 +│ ├── 移除的传递依赖: 23 个 +│ └── 耗时: 1.2s +│ +├── retained-classes.txt # 保留的类清单 +├── removed-classes.txt # 移除的类清单 +└── dependency-graph.dot # 依赖图(Graphviz) +``` + +--- + +## 4. 关键实现细节 + +### 4.1 可达性分析(核心算法) + +```java +public class ReachabilityAnalyzer { + private final Set retained = new HashSet<>(); + private final Queue queue = new LinkedList<>(); + + public Set analyze(Set entryPoints, Path classesDir) { + // Step 1: 注册入口 + queue.addAll(entryPoints); + retained.addAll(entryPoints); + + // Step 2: BFS + while (!queue.isEmpty()) { + String className = queue.poll(); + byte[] bytecode = readClass(classesDir, className); + if (bytecode == null) continue; // 外部类,跳过 + + // 解析类引用 + for (String ref : parseReferences(bytecode)) { + if (isStandardLibrary(ref)) continue; + if (retained.add(ref)) { + queue.add(ref); // 新发现,入队 + } + } + } + return retained; + } + + private List parseReferences(byte[] bytecode) { + // 使用 ASM ClassReader + // 提取:super class, interfaces, field types, + // method param/return types, annotation types, + // generic signatures + + // CRITICAL: 递归解析泛型签名中的类型 + // 如 List> → OrderDetailDTO 必须保留 + List refs = new ArrayList<>(); + // ... ASM visitor logic + return refs; + } +} +``` + +### 4.2 ASM Visitor 设计 + +``` +ApiSurfaceClassVisitor extends ClassVisitor +├─ visit(version, access, name, signature, superName, interfaces) +│ └─ 记录 superName + interfaces + signature(泛型) +├─ visitAnnotation(desc, visible) +│ └─ 记录注解类型(即使不在保留白名单,也记录引用) +├─ visitField(access, name, desc, signature, value) +│ └─ 记录: desc 中类型 + signature 中泛型 + 字段注解类型 +│ └─ 注意: desc="Lcom/example/UserDTO;" → 提取 UserDTO +├─ visitMethod(access, name, desc, signature, exceptions) +│ └─ 记录: 参数类型 + 返回类型 + 异常类型 + signature +│ └─ desc="(ILcom/example/ReqDTO;)Lcom/example/RespDTO;" +│ → 提取 ReqDTO, RespDTO +└─ visitEnd() +``` + +### 4.3 类名规范化 + +``` +字节码内部名 → 全限定类名转换: + "com/example/UserService" → "com.example.UserService" + "[Lcom/example/DTO;" → "com.example.DTO[]"(保留数组标记) + "Lcom/example/Inner$Nested;" → "com.example.Inner$Nested" + +跳过规则: + java/* javax/* sun/* jdk/* → skip(JDK 标准库) + [ZBSCIJFD → skip(8大基本类型 + void) +``` + +--- + +## 5. 典型使用场景 + +### 场景1:Dubbo API 模块 + +```xml + + + com.example + api-trim-maven-plugin + + DUBBO + + + +``` + +效果: +- 移除所有 `*ServiceImpl`、`*ManagerImpl` 实现类 +- 移除 MyBatis、Spring Boot AutoConfigure、JDBC Driver +- 保留 RPC 接口、DTO、枚举、自定义异常 +- JAR 从 45MB → ~3MB + +### 场景2:Feign API 模块 + +```xml + + com.example + api-trim-maven-plugin + + FEIGN + + + +``` + +--- + +## 6. 优先级与技术可行性 + +| 模块 | 可行性 | 复杂度 | 备注 | +|------|--------|--------|------| +| 入口扫描(注解识别) | ✅ 成熟 | 低 | ASM 读注解 | +| 类级可达性分析 | ✅ 成熟 | 中 | BFS + ASM,已有类似工具(如 ProGuard) | +| JAR 重打包 | ✅ 成熟 | 低 | Java NIO + JarOutputStream | +| POM 依赖重写 | ✅ 成熟 | 低 | maven-model 库直接修改 | +| 字节码引用完整性校验 | ✅ 可行 | 中 | ASM 二次扫描精简后 JAR | +| 序列化验证 | ✅ 可行 | 中 | 需框架特定处理(Jackson/Protobuf/Hessian) | +| 泛型签名解析 | ⚠️ 有坑 | 中-高 | signature 字段可能包含复杂泛型,需递归解析 | + +--- + +## 7. 与其他方案对比 + +| 方案 | JAR 大小 | 配置复杂度 | RPC 安全性 | 适用场景 | +|------|----------|------------|------------|----------| +| **api-trim-plugin** | ★★★★★ (3MB) | 中 | 高(ASM 可达性确保) | RPC API 模块专用 | +| maven-shade minimizeJar | ★★★★ (5MB) | 高 | 中(反射不感知) | 通用 JAR 精简 | +| maven-dependency exclude | ★★ (20MB+) | 低 | 高 | 粗粒度排除 | +| 手工拆分模块 | ★★★★ | 很高 | 高 | 架构重构 | +| ProGuard/R8 | ★★★★★ | 高 | 中(可能破坏序列化) | Android/通用混淆 | diff --git a/pom.xml b/pom.xml index b5394f4..ee8cbed 100644 --- a/pom.xml +++ b/pom.xml @@ -41,6 +41,13 @@ + + + org.ow2.asm + asm + 9.6 + + org.mybatis.generator diff --git a/src/main/java/com/example/workutils/biz/apitrimmer/ApiTrimmerPlugin.java b/src/main/java/com/example/workutils/biz/apitrimmer/ApiTrimmerPlugin.java new file mode 100644 index 0000000..ac68c85 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/apitrimmer/ApiTrimmerPlugin.java @@ -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 窗口已关闭"); + } + } +} diff --git a/src/main/java/com/example/workutils/biz/apitrimmer/ApiTrimmerWindow.java b/src/main/java/com/example/workutils/biz/apitrimmer/ApiTrimmerWindow.java new file mode 100644 index 0000000..875bd9a --- /dev/null +++ b/src/main/java/com/example/workutils/biz/apitrimmer/ApiTrimmerWindow.java @@ -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 可视化窗口。 + *

+ * 功能:选择 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 dirTable; + private final ObservableList dirRows = FXCollections.observableArrayList(); + private Label dirTableLabel; + // ---- + private VBox resultBox; + private Button trimBtn; + private Button cancelBtn; + + // ---- 状态 ---- + private ApiTrimmerService.TrimAnalysis analysis; + /** 自动勾选关键词集合,从配置文件加载 */ + private Set 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 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; + }); + + // 列2:目录名 + TableColumn nameCol = new TableColumn<>("目录"); + nameCol.setCellValueFactory(cdf -> cdf.getValue().nameProperty()); + nameCol.setPrefWidth(280); + + // 列3:文件数 + TableColumn fileCol = new TableColumn<>("文件数"); + fileCol.setCellValueFactory(cdf -> cdf.getValue().fileCountProperty()); + fileCol.setPrefWidth(80); + fileCol.setStyle("-fx-alignment: CENTER-RIGHT;"); + + // 列4:类文件 + TableColumn classCol = new TableColumn<>("类文件"); + classCol.setCellValueFactory(cdf -> cdf.getValue().classCountProperty()); + classCol.setPrefWidth(80); + classCol.setStyle("-fx-alignment: CENTER-RIGHT;"); + + // 列5:大小 + TableColumn 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 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(); + } +} 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 new file mode 100644 index 0000000..048b319 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/apitrimmer/service/ApiTrimmerService.java @@ -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 字节码可达性分析,剔除实现类及冗余依赖。 + *

+ * 算法流程: + *

    + *
  1. 读取 JAR 中所有 .class → className→byte[] 字典
  2. + *
  3. 扫描 RPC 入口(接口 + RPC 注解类)
  4. + *
  5. BFS 可达性分析:从入口出发遍历所有类型引用
  6. + *
  7. JAR 重打包:只输出可达类
  8. + *
+ */ +@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 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 retainedClasses; + private final long originalSizeBytes; + private final List entryPoints; + + public TrimAnalysis(int totalClassCount, int retainedClassCount, + Set retainedClasses, long originalSizeBytes, + List 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 getRetainedClasses() { return retainedClasses; } + public long getOriginalSizeBytes() { return originalSizeBytes; } + public List getEntryPoints() { return entryPoints; } + + public double getReductionPercent() { + return totalClassCount == 0 ? 0 : + 100.0 * getRemovedClassCount() / totalClassCount; + } + } + + // ==================== 目录扫描(供 UI 展示选择) ==================== + + /** + * 扫描 JAR 内所有顶层目录,返回目录信息列表(按大小降序)。 + */ + public List scanJarDirectories(Path jarPath) throws IOException { + Map classCounts = new HashMap<>(); + Map sizeMap = new HashMap<>(); + Map 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.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 extraPackages, TrimConfig config) throws IOException { + long originalSize = Files.size(jarPath); + + // Step 1: 读取所有类(排除已选 junk 类) + Map allClasses = loadAllClasses(jarPath, config); + log.info("分析 JAR: {} 个类, 大小 {}MB", allClasses.size(), + String.format("%.1f", originalSize / 1_048_576.0)); + + // Step 2: 扫描入口 + Set entryPoints = findEntryPoints(allClasses); + log.info("找到 {} 个入口类", entryPoints.size()); + + // Step 3: BFS 可达性分析 + Set 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 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 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 loadAllClasses(Path jarPath, TrimConfig config) throws IOException { + Map 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 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; + + 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 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); + } + } + } + + while (!queue.isEmpty()) { + String cls = queue.poll(); + byte[] code = allClasses.get(cls); + if (code == null) continue; + + try { + // 提取所有类型引用 + Set 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 extractTypeReferences(byte[] classBytes) { + Set 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 refs; + + ReferenceCollector(Set 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 refs; + + AnnotationRefCollector(Set 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()); + } + } +} diff --git a/src/main/resources/api-trimmer-patterns.txt b/src/main/resources/api-trimmer-patterns.txt new file mode 100644 index 0000000..ef11a9e --- /dev/null +++ b/src/main/resources/api-trimmer-patterns.txt @@ -0,0 +1,5 @@ +# API Trim 自动勾选目录名配置(每行一个关键词,忽略大小写,匹配即勾选) +# 修改后保存,点击「刷新目录」即可生效 +lombok +ttf +class50