From a6c72f234e9a51beb4021323ebd28282c76fcc3c Mon Sep 17 00:00:00 2001 From: Goooler Date: Thu, 3 Sep 2026 17:02:05 +0800 Subject: [PATCH] Keep implementations when service interface is used in minimize Retain service provider implementations under `META-INF/services/` (and their transitive dependencies) when the corresponding service interface is kept during minimization. Refs: - https://issues.apache.org/jira/browse/MSHADE-313 - https://github.com/apache/maven-shade-plugin/commit/8278c9f28d94f8af6d98655b3db7bd295b9785aa --- CHANGELOG.md | 1 + .../gradle/plugins/shadow/MinimizeTest.kt | 172 ++++++++++++++++++ .../plugins/shadow/internal/UnusedTracker.kt | 100 +++++++++- .../gradle/plugins/shadow/tasks/ShadowJar.kt | 11 ++ 4 files changed, 283 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfc196d0d..1950081b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Support manifest header relocation via configurable `attributesToRelocate` property. - Allow disabling default ProGuard rules in R8 minimization with `R8Spec.useDefaultRules`. ([#2252](https://github.com/GradleUp/shadow/pull/2252)) - Allow passing classpath files to R8 minimization with `R8Spec.classpath`. ([#2255](https://github.com/GradleUp/shadow/pull/2255)) +- Keep implementations when service interface is used in minimize. ([#2275](https://github.com/GradleUp/shadow/pull/2275)) ### Changed diff --git a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/MinimizeTest.kt b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/MinimizeTest.kt index 5716568df..270a8bd3b 100644 --- a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/MinimizeTest.kt +++ b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/MinimizeTest.kt @@ -298,6 +298,178 @@ class MinimizeTest : BasePluginTest() { } } + @Test + fun keepServiceImplementationsWhenServiceInterfaceIsUsed() { + settingsScript.appendText( + """ + |include 'used-service', 'unused-service', 'dependency-service', 'app' + |""" + .trimMargin() + ) + projectScript.deleteExisting() + + path("unused-service/src/main/java/unused/UnusedServiceInterface.java") + .writeText( + """ + |package unused; + |public interface UnusedServiceInterface {} + |""" + .trimMargin() + ) + path("unused-service/src/main/java/unused/UnusedServiceClass.java") + .writeText( + """ + |package unused; + |public class UnusedServiceClass implements UnusedServiceInterface {} + |""" + .trimMargin() + ) + path("unused-service/src/main/resources/META-INF/services/unused.UnusedServiceInterface") + .writeText("unused.UnusedServiceClass\n") + path("unused-service/build.gradle") + .writeText( + """ + |plugins { + | id 'java' + |} + |""" + .trimMargin() + ) + + path("dependency-service/src/main/java/dep/DependencyServiceInterface.java") + .writeText( + """ + |package dep; + |public interface DependencyServiceInterface {} + |""" + .trimMargin() + ) + path("dependency-service/src/main/java/dep/DependencyServiceClass.java") + .writeText( + """ + |package dep; + |public class DependencyServiceClass implements DependencyServiceInterface {} + |""" + .trimMargin() + ) + path("dependency-service/src/main/java/dep/DependencyUnreferencedClass.java") + .writeText( + """ + |package dep; + |public class DependencyUnreferencedClass {} + |""" + .trimMargin() + ) + path("dependency-service/src/main/resources/META-INF/services/dep.DependencyServiceInterface") + .writeText("dep.DependencyServiceClass\n") + path("dependency-service/build.gradle") + .writeText( + """ + |plugins { + | id 'java' + |} + |""" + .trimMargin() + ) + + path("used-service/src/main/java/used/SomeServiceInterface.java") + .writeText( + """ + |package used; + |public interface SomeServiceInterface {} + |""" + .trimMargin() + ) + path("used-service/src/main/java/used/SomeServiceClass.java") + .writeText( + """ + |package used; + |import dep.DependencyServiceInterface; + |public class SomeServiceClass implements SomeServiceInterface { + | private final Class dep = DependencyServiceInterface.class; + |} + |""" + .trimMargin() + ) + path("used-service/src/main/java/used/SomeUnreferencedClass.java") + .writeText( + """ + |package used; + |public class SomeUnreferencedClass {} + |""" + .trimMargin() + ) + path("used-service/src/main/resources/META-INF/services/used.SomeServiceInterface") + .writeText("used.SomeServiceClass\n") + path("used-service/build.gradle") + .writeText( + """ + |plugins { + | id 'java' + |} + |dependencies { + | implementation project(':dependency-service') + |} + |""" + .trimMargin() + ) + + path("app/src/main/java/app/Main.java") + .writeText( + """ + |package app; + |import used.SomeServiceInterface; + |public class Main { + | private final Class service = SomeServiceInterface.class; + |} + |""" + .trimMargin() + ) + path("app/build.gradle") + .writeText( + """ + |${getDefaultProjectBuildScript("java")} + |dependencies { + | implementation project(':used-service') + | implementation project(':unused-service') + |} + |$shadowJarTask { + | minimize() + |} + |""" + .trimMargin() + ) + + runWithSuccess(":app:$SHADOW_JAR_TASK_NAME") + + val outputAppShadowedJar = jarPath("app/build/libs/app-1.0-all.jar") + assertThat(outputAppShadowedJar).useAll { + containsAtLeast( + "app/Main.class", + "used/SomeServiceInterface.class", + "used/SomeServiceClass.class", + "dep/DependencyServiceInterface.class", + "dep/DependencyServiceClass.class", + "META-INF/services/used.SomeServiceInterface", + "META-INF/services/dep.DependencyServiceInterface", + *manifestEntries, + ) + containsNone( + "used/SomeUnreferencedClass.class", + "dep/DependencyUnreferencedClass.class", + "unused/UnusedServiceInterface.class", + "unused/UnusedServiceClass.class", + ) + classLoader { + loadClass("app.Main") + loadClass("used.SomeServiceInterface") + loadClass("used.SomeServiceClass") + loadClass("dep.DependencyServiceInterface") + loadClass("dep.DependencyServiceClass") + } + } + } + private fun writeApiLibAndImplModules() { settingsScript.appendText( """ diff --git a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/UnusedTracker.kt b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/UnusedTracker.kt index db7803b96..aa8d04220 100644 --- a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/UnusedTracker.kt +++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/UnusedTracker.kt @@ -1,6 +1,7 @@ package com.github.jengelman.gradle.plugins.shadow.internal import java.io.File +import java.io.IOException import org.gradle.api.Project import org.gradle.api.artifacts.component.ModuleComponentIdentifier import org.gradle.api.attributes.Category @@ -8,6 +9,7 @@ import org.gradle.api.attributes.LibraryElements import org.gradle.api.attributes.Usage import org.gradle.api.plugins.JavaPlugin.API_CONFIGURATION_NAME import org.gradle.api.provider.Provider +import org.vafer.jdependency.Clazz import org.vafer.jdependency.Clazzpath internal fun Project.getApiJars(): Provider> { @@ -42,12 +44,20 @@ internal fun Project.getApiJars(): Provider> { } } -/** Finds unused classes in the project classpath. */ +/** + * Finds unused classes in the project classpath. + * + * Modified from + * [org.apache.maven.plugins.shade.filter.MinijarFilter.java](https://github.com/apache/maven-shade-plugin/blob/master/src/main/java/org/apache/maven/plugins/shade/filter/MinijarFilter.java). + * + * Related to MSHADE-313. + */ internal fun findUnusedClasses( sourceSetsClassesDirs: Iterable, classJars: Iterable, toMinimize: Iterable, dependencies: Iterable, + resourcesDirs: Iterable, ): Set { val cp = Clazzpath() val projectUnits = @@ -65,5 +75,93 @@ internal fun findUnusedClasses( unused.removeAll(cpu.clazzes) unused.removeAll(cpu.transitiveDependencies) } + + removeServices(cp, unused, dependencies + sourceSetsClassesDirs + resourcesDirs) + return unused.map { it.name }.toSet() } + +private const val SERVICES_PATH = "META-INF/services/" + +private fun removeServices( + cp: Clazzpath, + unused: MutableSet, + files: Iterable, +) { + val services = mutableMapOf>() + for (file in files) { + collectServices(file, services) + } + + if (services.isEmpty()) return + + do { + var repeatScan = false + for ((serviceName, providers) in services) { + val serviceClazz = cp.getClazz(serviceName) ?: continue + if (serviceClazz !in unused) { + for (providerName in providers) { + val providerClazz = cp.getClazz(providerName) ?: continue + if (providerClazz in unused) { + unused.remove(providerClazz) + unused.removeAll(providerClazz.transitiveDependencies) + repeatScan = true + } + } + } + } + } while (repeatScan) +} + +private fun collectServices(file: File, services: MutableMap>) { + when { + file.isDirectory -> { + val servicesDir = file.resolve(SERVICES_PATH) + if (servicesDir.isDirectory) { + servicesDir + .listFiles() + ?.filter { it.isFile } + ?.forEach { serviceFile -> + val serviceName = serviceFile.name + if (serviceName.isNotEmpty() && !serviceName.contains('/')) { + try { + serviceFile.useLines { lines -> + lines.forEach { line -> + val provider = line.substringBefore('#').trim() + if (provider.isNotEmpty()) { + services.getOrPut(serviceName) { mutableSetOf() }.add(provider) + } + } + } + } catch (_: IOException) { + // Ignore unreadable files. + } + } + } + } + } + file.isFile -> { + try { + file.useZip { + entries().asSequence().forEach { entry -> + if (!entry.isDirectory && entry.name.startsWith(SERVICES_PATH)) { + val serviceName = entry.name.removePrefix(SERVICES_PATH) + if (serviceName.isNotEmpty() && !serviceName.contains('/')) { + getInputStream(entry).bufferedReader().useLines { lines -> + lines.forEach { line -> + val provider = line.substringBefore('#').trim() + if (provider.isNotEmpty()) { + services.getOrPut(serviceName) { mutableSetOf() }.add(provider) + } + } + } + } + } + } + } + } catch (_: Exception) { + // Ignore unreadable or non-zip files. + } + } + } +} diff --git a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowJar.kt b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowJar.kt index 8cbe4beaf..ed2895ecb 100644 --- a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowJar.kt +++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowJar.kt @@ -546,6 +546,7 @@ public abstract class ShadowJar : Jar() { classJars = apiJars, toMinimize = toMinimize, dependencies = includedDependencies, + resourcesDirs = sourceSetsResourcesDirs, ) } else { emptySet() @@ -606,6 +607,16 @@ public abstract class ShadowJar : Jar() { private val _minimizeJar get() = @Suppress("DEPRECATION") minimizeJar + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + private val sourceSetsResourcesDirs: ConfigurableFileCollection = objectFactory.fileCollection { + project.provider { + project.sourceSetsOrNull?.map { sourceSet -> + sourceSet.output.resourcesDir?.takeIf(File::isDirectory) + } ?: emptySet() + } + } + private val isR8Enabled: Boolean get() = _minimizeJar.get() && minimizeSpec.tool.get() == MinimizeTool.R8