Skip to content

Commit 0f9bd0b

Browse files
committed
quarkus.package.jar.tree-shake option to eliminate unused classes from runtime dependencies
1 parent 3d46ea4 commit 0f9bd0b

File tree

155 files changed

+7323
-53
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

155 files changed

+7323
-53
lines changed
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
#
2+
# Copyright (c) 2022, 2023 Oracle and/or its affiliates. All rights reserved.
3+
#
4+
# This program and the accompanying materials are made available under the
5+
# terms of the Eclipse Distribution License v. 1.0, which is available at
6+
# http://www.eclipse.org/org/documents/edl-v10.php.
7+
#
8+
# SPDX-License-Identifier: BSD-3-Clause
9+
10+
Args=--features=org.eclipse.angus.activation.nativeimage.AngusActivationFeature \
11+
--initialize-at-build-time=org.eclipse.angus.activation.MailcapFile \
12+
--initialize-at-build-time=org.eclipse.angus.activation.LogSupport

core/deployment/src/main/java/io/quarkus/deployment/pkg/PackageConfig.java

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,31 @@ interface JarConfig {
182182
@WithDefault("true")
183183
boolean addRunnerSuffix();
184184

185+
/**
186+
* Whether to perform class reachability analysis and exclude non-reachable classes
187+
* from the produced JAR.
188+
* <ul>
189+
* <li>{@code none} - No analysis or exclusion is performed.</li>
190+
* <li>{@code classes} - Exclude non-reachable classes from dependencies.</li>
191+
* </ul>
192+
*/
193+
@WithDefault("classes")
194+
TreeShakeLevel treeShake();
195+
196+
/**
197+
* The level of tree shaking to apply.
198+
*/
199+
enum TreeShakeLevel {
200+
/**
201+
* No analysis or exclusion is performed.
202+
*/
203+
NONE,
204+
/**
205+
* Exclude non-reachable classes from dependencies.
206+
*/
207+
CLASSES
208+
}
209+
185210
/**
186211
* Indicates a list of dependency for which the jar will use artifactId.type filename scheme
187212
* Each dependency needs to be expressed in the following format:
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package io.quarkus.deployment.pkg.builditem;
2+
3+
import java.util.Collections;
4+
import java.util.List;
5+
import java.util.Map;
6+
import java.util.Set;
7+
8+
import io.quarkus.builder.item.SimpleBuildItem;
9+
import io.quarkus.deployment.pkg.PackageConfig.JarConfig.TreeShakeLevel;
10+
import io.quarkus.maven.dependency.ArtifactKey;
11+
12+
/**
13+
* Build item that holds the results of dependency usage analysis for tree shaking.
14+
* Contains the tree shake level, the set of reachable class names (dot-separated),
15+
* and the sorted list of removed class resource paths per dependency.
16+
*/
17+
public final class JarTreeShakeBuildItem extends SimpleBuildItem {
18+
19+
private final TreeShakeLevel level;
20+
private final Set<String> reachableClassNames;
21+
private final Map<ArtifactKey, List<String>> removedClasses;
22+
23+
public JarTreeShakeBuildItem(TreeShakeLevel level, Set<String> reachableClassNames,
24+
Map<ArtifactKey, List<String>> removedClasses) {
25+
this.level = level;
26+
this.reachableClassNames = reachableClassNames;
27+
this.removedClasses = removedClasses;
28+
}
29+
30+
public TreeShakeLevel getLevel() {
31+
return level;
32+
}
33+
34+
/**
35+
* @return dot-separated class names of all reachable classes
36+
*/
37+
public Set<String> getReachableClassNames() {
38+
return reachableClassNames;
39+
}
40+
41+
/**
42+
* @return sorted list of removed class resource paths (e.g. "com/example/Foo.class") per dependency
43+
*/
44+
public Map<ArtifactKey, List<String>> getRemovedClasses() {
45+
return removedClasses;
46+
}
47+
48+
/**
49+
* Computes a pedigree string for the given dependency describing what was removed.
50+
*
51+
* @return pedigree text or {@code null} if nothing was removed
52+
*/
53+
public String computePedigree(ArtifactKey depKey) {
54+
if (level == TreeShakeLevel.NONE) {
55+
return null;
56+
}
57+
List<String> removed = removedClasses.getOrDefault(depKey, Collections.emptyList());
58+
if (removed.isEmpty()) {
59+
return null;
60+
}
61+
var sb = new StringBuilder("Removed ");
62+
sb.append(removed.get(0));
63+
for (int i = 1; i < removed.size(); ++i) {
64+
sb.append(",").append(removed.get(i));
65+
}
66+
return sb.toString();
67+
}
68+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package io.quarkus.deployment.pkg.builditem;
2+
3+
import io.quarkus.builder.item.MultiBuildItem;
4+
import io.quarkus.maven.dependency.ArtifactKey;
5+
6+
/**
7+
* Excludes an entire dependency artifact from jar tree-shake class removal.
8+
* All classes from excluded artifacts are treated as reachable and will never
9+
* be removed by the tree-shaker.
10+
* <p>
11+
* This is needed for libraries like BouncyCastle FIPS that perform self-integrity
12+
* checks (checksums over their own classes) and break if any classes are removed.
13+
*/
14+
public final class JarTreeShakeExcludedArtifactBuildItem extends MultiBuildItem {
15+
16+
private final ArtifactKey artifactKey;
17+
18+
public JarTreeShakeExcludedArtifactBuildItem(ArtifactKey artifactKey) {
19+
this.artifactKey = artifactKey;
20+
}
21+
22+
public ArtifactKey getArtifactKey() {
23+
return artifactKey;
24+
}
25+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package io.quarkus.deployment.pkg.builditem;
2+
3+
import io.quarkus.builder.item.MultiBuildItem;
4+
5+
/**
6+
* This build item is meant to be internal to {@link io.quarkus.deployment.pkg.steps.JarTreeShakeProcessor},
7+
* in a sense it's produced and consumed by it.
8+
* <p>
9+
* Declares a class that must be treated as a root for jar tree-shake reachability analysis.
10+
* Any class registered through this build item will not be removed by the tree-shaker,
11+
* and all classes reachable from it will also be preserved.
12+
*/
13+
public final class JarTreeShakeRootClassBuildItem extends MultiBuildItem {
14+
15+
private final String className;
16+
17+
public JarTreeShakeRootClassBuildItem(String className) {
18+
this.className = className;
19+
}
20+
21+
public String getClassName() {
22+
return className;
23+
}
24+
}

core/deployment/src/main/java/io/quarkus/deployment/pkg/jar/AbstractFastJarBuilder.java

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
import io.quarkus.deployment.pkg.PackageConfig;
5353
import io.quarkus.deployment.pkg.builditem.CurateOutcomeBuildItem;
5454
import io.quarkus.deployment.pkg.builditem.JarBuildItem;
55+
import io.quarkus.deployment.pkg.builditem.JarTreeShakeBuildItem;
5556
import io.quarkus.deployment.pkg.builditem.OutputTargetBuildItem;
5657
import io.quarkus.deployment.util.FileUtil;
5758
import io.quarkus.maven.dependency.ArtifactKey;
@@ -66,6 +67,7 @@ abstract class AbstractFastJarBuilder extends AbstractJarBuilder<JarBuildItem> {
6667

6768
private final List<AdditionalApplicationArchiveBuildItem> additionalApplicationArchives;
6869
private final Set<ArtifactKey> parentFirstArtifactKeys;
70+
private final JarTreeShakeBuildItem treeShakeResult;
6971

7072
AbstractFastJarBuilder(CurateOutcomeBuildItem curateOutcome,
7173
OutputTargetBuildItem outputTarget,
@@ -80,11 +82,13 @@ abstract class AbstractFastJarBuilder extends AbstractJarBuilder<JarBuildItem> {
8082
Set<ArtifactKey> parentFirstArtifactKeys,
8183
Set<ArtifactKey> removedArtifactKeys,
8284
ExecutorService executorService,
83-
ResolvedJVMRequirements jvmRequirements) {
85+
ResolvedJVMRequirements jvmRequirements,
86+
JarTreeShakeBuildItem treeShakeResult) {
8487
super(curateOutcome, outputTarget, applicationInfo, packageConfig, mainClass, applicationArchives, transformedClasses,
8588
generatedClasses, generatedResources, removedArtifactKeys, executorService, jvmRequirements);
8689
this.additionalApplicationArchives = additionalApplicationArchives;
8790
this.parentFirstArtifactKeys = parentFirstArtifactKeys;
91+
this.treeShakeResult = treeShakeResult;
8892
}
8993

9094
public JarBuildItem build() throws IOException {
@@ -232,7 +236,7 @@ public JarBuildItem build() throws IOException {
232236
copyDependency(parentFirstArtifactKeys, outputTarget, copiedArtifacts, mainLib, baseLib,
233237
fastJarJarsBuilder::addDependency, fastJarJarsBuilder::addParentFirstDependency, true,
234238
appDep, transformedClasses, removedArtifactKeys, packageConfig, manifestConfig,
235-
executorService);
239+
executorService, treeShakeResult);
236240
} else if (includeAppDependency(appDep, outputTarget.getIncludedOptionalDependencies(), removedArtifactKeys)) {
237241
appDep.getResolvedPaths().forEach(fastJarJarsBuilder::addDependency);
238242
}
@@ -311,7 +315,7 @@ public JarBuildItem build() throws IOException {
311315
copyDependency(parentFirstArtifactKeys, outputTarget, copiedArtifacts, deploymentLib, baseLib, p -> {
312316
}, p -> {
313317
}, false, appDep, new TransformedClassesBuildItem(Map.of()), removedArtifactKeys, packageConfig,
314-
manifestConfig, executorService); //we don't care about transformation here, so just pass in an empty item
318+
manifestConfig, executorService, null); //we don't care about transformation or tree shaking here
315319
}
316320
Map<ArtifactKey, List<String>> relativePaths = new HashMap<>();
317321
for (Entry<ArtifactKey, List<Path>> e : copiedArtifacts.entrySet()) {
@@ -398,7 +402,8 @@ private static void copyDependency(Set<ArtifactKey> parentFirstArtifacts, Output
398402
Map<ArtifactKey, List<Path>> runtimeArtifacts, Path libDir, Path baseLib, Consumer<Path> dependenciesConsumer,
399403
Consumer<Path> parentFirstDependenciesConsumer, boolean allowParentFirst, ResolvedDependency appDep,
400404
TransformedClassesBuildItem transformedClasses, Set<ArtifactKey> removedDeps,
401-
PackageConfig packageConfig, ApplicationManifestConfig.Builder manifestConfig, ExecutorService executorService)
405+
PackageConfig packageConfig, ApplicationManifestConfig.Builder manifestConfig, ExecutorService executorService,
406+
JarTreeShakeBuildItem treeShakeResult)
402407
throws IOException {
403408

404409
// Exclude files that are not jars (typically, we can have XML files here, see https://github.com/quarkusio/quarkus/issues/2852)
@@ -450,6 +455,40 @@ private static void copyDependency(Set<ArtifactKey> parentFirstArtifacts, Output
450455
}
451456
}
452457
}
458+
// When tree shake level is CLASSES, add non-reachable classes to removal set.
459+
// Use walkRaw to handle multi-release JARs: we need to add both base and
460+
// versioned entry paths to the removal set for unreachable classes.
461+
if (treeShakeResult != null
462+
&& treeShakeResult.getLevel() == PackageConfig.JarConfig.TreeShakeLevel.CLASSES) {
463+
try (var pathTree = appDep.getContentTree().open()) {
464+
pathTree.walkRaw(visit -> {
465+
String rel = visit.getRelativePath("/");
466+
String classRel = rel;
467+
// For multi-release entries, extract the actual class path
468+
if (rel.startsWith("META-INF/versions/")) {
469+
String afterVersions = rel.substring("META-INF/versions/".length());
470+
int slash = afterVersions.indexOf('/');
471+
if (slash > 0) {
472+
classRel = afterVersions.substring(slash + 1);
473+
} else {
474+
return;
475+
}
476+
}
477+
if (classRel.endsWith(".class") && !classRel.equals("module-info.class")) {
478+
String className = classRel.substring(0, classRel.length() - 6).replace('/', '.');
479+
if (!treeShakeResult.getReachableClassNames().contains(className)) {
480+
int dollarIdx = className.indexOf('$');
481+
if (dollarIdx < 0
482+
|| !treeShakeResult.getReachableClassNames()
483+
.contains(className.substring(0, dollarIdx))) {
484+
// Add the raw path (base or META-INF/versions/N/...) to removal set
485+
removedFromThisArchive.add(rel);
486+
}
487+
}
488+
}
489+
});
490+
}
491+
}
453492
var appComponent = ApplicationComponent.builder()
454493
.setPath(targetPath)
455494
.setResolvedDependency(appDep);
@@ -461,14 +500,10 @@ private static void copyDependency(Set<ArtifactKey> parentFirstArtifacts, Output
461500
// we copy jars for which we remove entries to the same directory
462501
// which seems a bit odd to me
463502
JarUnsigner.unsignJar(resolvedDep, targetPath, Predicate.not(removedFromThisArchive::contains));
464-
465-
var list = new ArrayList<>(removedFromThisArchive);
466-
Collections.sort(list);
467-
var sb = new StringBuilder("Removed ").append(list.get(0));
468-
for (int i = 1; i < list.size(); ++i) {
469-
sb.append(",").append(list.get(i));
470-
}
471-
appComponent.setPedigree(sb.toString());
503+
}
504+
String pedigree = treeShakeResult != null ? treeShakeResult.computePedigree(appDep.getKey()) : null;
505+
if (pedigree != null) {
506+
appComponent.setPedigree(pedigree);
472507
}
473508
manifestConfig.addComponent(appComponent);
474509
}

0 commit comments

Comments
 (0)