JAR 瘦身插件

This commit is contained in:
张家训
2026-06-25 19:09:19 +08:00
parent b9e3f13cfd
commit caa07873df
6 changed files with 1747 additions and 0 deletions
+509
View File
@@ -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 基类子类
│ └─ 用户通过 <apiInterfaces> 显式声明
└─ 收集所有入口接口 → className[]
阶段2:可达性分析(类级别,ASM)
├─ 以入口接口为根,BFS 遍历引用图
│ ├─ 接口继承链(super interfaces)
│ ├─ 方法签名中的类型(参数类型 + 返回类型 + 异常类型)
│ │ └─ 递归解析泛型参数
│ ├─ 注解类型引用(类注解 + 方法注解 + 字段注解)
│ │ └─ 保留序列化相关注解:Jackson、Protobuf、Hessian、Fastjson 等
│ └─ 父类中的字段类型
└─ 输出:必需类集合 Set<ClassName>
阶段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(可选,根据配置)
│
└─ 业务注解(用户自定义)
└─ 通过 <retainedAnnotations> 配置
```
---
### 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<ClassName> ← Analyzer
<includes> 追加类 ← 用户配置
<excludes> 排除类 ← 用户配置
流程:
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
<!-- 原始 POM -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<!-- 精简后 POM -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
<scope>provided</scope> <!-- 调用方自行提供 -->
<optional>true</optional>
</dependency>
```
#### 3.2.4 配置模型
```xml
<plugin>
<groupId>com.example</groupId>
<artifactId>api-trim-maven-plugin</artifactId>
<version>1.0.0</version>
<configuration>
<!-- ===== 入口指定 ===== -->
<!-- 自动扫描 RPC 注解类 + 手动指定 -->
<apiInterfaces>
<apiInterface>com.example.api.UserService</apiInterface>
<apiInterface>com.example.api.OrderService</apiInterface>
</apiInterfaces>
<!-- RPC 框架标识,影响注解扫描策略 -->
<rpcFramework>DUBBO</rpcFramework> <!-- DUBBO | FEIGN | GRPC | SPRING_CLOUD | GENERIC -->
<!-- ===== 类过滤规则 ===== -->
<retainedAnnotations>
<annotation>com.example.MyBusinessAnnotation</annotation>
</retainedAnnotations>
<retainedPackages>
<package>com.example.common.dto.**</package>
<package>com.example.common.enums.**</package>
</retainedPackages>
<excludedPackages>
<package>com.example.**.impl.**</package>
<package>com.example.**.config.**</package>
</excludedPackages>
<!-- ===== 依赖处理 ===== -->
<dependencyStrategy>REWRITE_SCOPE</dependencyStrategy>
<!-- PROVIDED_ONLY: 标记为 provided
OPTIONAL_ONLY: 标记为 optional
REMOVE: 从 POM 中完全移除 -->
<excludedDependencies> <!-- 指定 POM 中排除的传递依赖 -->
<dependency>mysql:mysql-connector-java</dependency>
<dependency>org.mybatis:*</dependency>
</excludedDependencies>
<!-- ===== 安全 ===== -->
<failOnBrokenReference>true</failOnBrokenReference>
<outputReport>true</outputReport>
</configuration>
</plugin>
```
---
### 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 ← <plugin>api-trim-maven-plugin</plugin>
├── service-module ← 实现模块,正常打包
├── gateway-bridge ← 网关桥,依赖 api-module(会拿到精简版 JAR)
└── pom.xml
```
API 模块只需在 `pom.xml` 中声明该插件即可,其下游模块(gateway-bridge)通过普通 Maven 依赖机制拿到精简后的 JAR:
```xml
<!-- gateway-bridge/pom.xml -->
<dependency>
<groupId>com.example</groupId>
<artifactId>api-module</artifactId>
<version>${project.version}</version>
</dependency>
<!-- 无需任何额外配置,直接引用即可 -->
```
---
### 3.4 安全保障与验证机制
#### 3.4.1 编译时校验
```
校验项1:字节码引用完整性
├─ 读取精简后的 classes 集合
├─ 检查每个类的方法描述符中的所有类型引用
├─ 若引用到未包含在 JAR 中的非标准库类 → ERROR
└─ 区分:缺少的类在调用方 classpath 中 → WARN(需用户确认)
校验项2:序列化完整性
├─ 对每个 DTO 类:
│ ├─ 遍历所有字段 getter/setter
│ ├─ 检查序列化注解是否被意外移除
│ └─ 检查枚举值是否完整
└─ 生成 SerializationTest.java 模板
```
#### 3.4.2 运行时验证(可选,需用户配置测试类)
```
验证方式:API 接口契约对比
├─ 原始 JAR → 反射扫描所有 RPC 接口的方法签名 → Set<MethodSignature>
├─ 精简 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<String> retained = new HashSet<>();
private final Queue<String> queue = new LinkedList<>();
public Set<String> analyze(Set<String> 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<String> parseReferences(byte[] bytecode) {
// 使用 ASM ClassReader
// 提取:super class, interfaces, field types,
// method param/return types, annotation types,
// generic signatures
// CRITICAL: 递归解析泛型签名中的类型
// 如 List<Map<String, OrderDetailDTO>> → OrderDetailDTO 必须保留
List<String> 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
<!-- API 模块 POM -->
<plugin>
<groupId>com.example</groupId>
<artifactId>api-trim-maven-plugin</artifactId>
<configuration>
<rpcFramework>DUBBO</rpcFramework>
<!-- 自动扫描 @Service(interfaceClass=...) 找到所有 RPC 接口 -->
</configuration>
</plugin>
```
效果:
- 移除所有 `*ServiceImpl`、`*ManagerImpl` 实现类
- 移除 MyBatis、Spring Boot AutoConfigure、JDBC Driver
- 保留 RPC 接口、DTO、枚举、自定义异常
- JAR 从 45MB → ~3MB
### 场景2:Feign API 模块
```xml
<plugin>
<groupId>com.example</groupId>
<artifactId>api-trim-maven-plugin</artifactId>
<configuration>
<rpcFramework>FEIGN</rpcFramework>
<!-- 自动扫描 @FeignClient 注解 -->
</configuration>
</plugin>
```
---
## 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/通用混淆 |
+7
View File
@@ -41,6 +41,13 @@
<dependencies>
<!-- ASM 字节码分析(API Trim 依赖) -->
<dependency>
<groupId>org.ow2.asm</groupId>
<artifactId>asm</artifactId>
<version>9.6</version>
</dependency>
<!-- MyBatis Generator Core -->
<dependency>
<groupId>org.mybatis.generator</groupId>
@@ -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