001/* 002 * JDrupes Builder 003 * Copyright (C) 2025 Michael N. Lipp 004 * 005 * This program is free software: you can redistribute it and/or modify 006 * it under the terms of the GNU Affero General Public License as 007 * published by the Free Software Foundation, either version 3 of the 008 * License, or (at your option) any later version. 009 * 010 * This program is distributed in the hope that it will be useful, 011 * but WITHOUT ANY WARRANTY; without even the implied warranty of 012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 013 * GNU Affero General Public License for more details. 014 * 015 * You should have received a copy of the GNU Affero General Public License 016 * along with this program. If not, see <https://www.gnu.org/licenses/>. 017 */ 018 019package org.jdrupes.builder.uberjar; 020 021import com.google.common.flogger.FluentLogger; 022import java.io.IOException; 023import java.nio.file.FileSystems; 024import java.nio.file.Path; 025import java.nio.file.PathMatcher; 026import java.util.ArrayList; 027import java.util.Arrays; 028import java.util.List; 029import java.util.Map; 030import java.util.Objects; 031import java.util.concurrent.ConcurrentHashMap; 032import java.util.function.Predicate; 033import java.util.jar.JarEntry; 034import java.util.stream.Stream; 035import org.jdrupes.builder.api.BuildException; 036import org.jdrupes.builder.api.FileTree; 037import org.jdrupes.builder.api.Generator; 038import org.jdrupes.builder.api.IOResource; 039import org.jdrupes.builder.api.Intent; 040import static org.jdrupes.builder.api.Intent.*; 041import org.jdrupes.builder.api.Project; 042import org.jdrupes.builder.api.Resource; 043import org.jdrupes.builder.api.ResourceRequest; 044import org.jdrupes.builder.api.ResourceType; 045import static org.jdrupes.builder.api.ResourceType.*; 046import org.jdrupes.builder.api.Resources; 047import org.jdrupes.builder.java.AppJarFile; 048import org.jdrupes.builder.java.ClassTree; 049import org.jdrupes.builder.java.ClasspathElement; 050import org.jdrupes.builder.java.JarFile; 051import org.jdrupes.builder.java.JarFileEntry; 052import org.jdrupes.builder.java.JavaResourceTree; 053import static org.jdrupes.builder.java.JavaTypes.*; 054import org.jdrupes.builder.java.LibraryBuilder; 055import org.jdrupes.builder.java.LibraryJarFile; 056import org.jdrupes.builder.java.ServicesEntryResource; 057import org.jdrupes.builder.mvnrepo.MvnRepoJarFile; 058import org.jdrupes.builder.mvnrepo.MvnRepoLookup; 059import org.jdrupes.builder.mvnrepo.MvnRepoResource; 060import static org.jdrupes.builder.mvnrepo.MvnRepoTypes.*; 061 062/// A [Generator] for uber jars. 063/// 064/// Depending on the request, the generator provides two types of resources. 065/// 066/// 1. A [JarFile]. This type of resource is also returned if a more 067/// general [ResourceType] such as [ClasspathElement] is requested. 068/// 069/// 2. An [AppJarFile]. When requesting this special jar type, the 070/// generator checks if a main class is specified. 071/// 072/// The generator takes the following approach: 073/// 074/// * Request `Resources<ClasspathElement>` from the providers. Add the 075/// resource trees and the jar files to the sources to be processed. 076/// Ignore jar files from maven repositories (instances of 077/// [MvnRepoJarFile]). 078/// * Request all [MvnRepoResource]s from the providers and use them for 079/// a dependency resolution. Add the jar files from the dependency 080/// resolution to the resources to be processed. 081/// * Add resources from the sources to the uber jar. Merge the files in 082/// `META-INF/services/` that have the same name by concatenating them. 083/// * Filter out any other duplicate direct child files of `META-INF`. 084/// These files often contain information related to the origin jar 085/// that is not applicable to the uber jar. 086/// * Filter out any module-info.class entries. 087/// 088/// Note that the [UberJarBuilder] does deliberately not request the 089/// [ClasspathElement]s as `RuntimeResources` because this may return 090/// resources twice if a project uses another project as runtime 091/// dependency (i.e. with [Intent#Consume]. If this rule causes entries 092/// to be missing, simply add them explicitly. 093/// 094/// The resource type of the uber jar generator's output is one 095/// of the resource types of its inputs, because uber jars can also be used 096/// as [ClasspathElement]. Therefore, if you want to create an uber jar 097/// from all resources provided by a project, you must not add the 098/// generator to the project like this: 099/// ```java 100/// generator(UberJarGenerator::new).add(this); // Circular dependency 101/// ``` 102/// 103/// This would add the project as provider and thus make the uber jar 104/// generator as supplier to the project its own provider (via 105/// [Project.resources][Project#resources]). Rather, you have to use this 106/// slightly more complicated approach to adding providers to the uber 107/// jar generator: 108/// ```java 109/// generator(UberJarGenerator::new) 110/// .addAll(providers().select(Forward, Expose, Supply)); 111/// ``` 112/// This requests the same providers from the project as 113/// [Project.resources][Project#resources] does, but allows the uber jar 114/// generator's [addFrom] method to filter out the uber jar 115/// generator itself from the providers. The given intents can 116/// vary depending on the requirements. 117/// 118/// If you don't want the generated uber jar to be available to other 119/// generators of your project, you can also add it to a project like this: 120/// ```java 121/// dependency(new UberJarGenerator(this) 122/// .from(providers(EnumSet.of(Forward, Expose, Supply))), Intent.Forward) 123/// ``` 124/// 125/// Of course, the easiest thing to do is separate the generation of 126/// class trees or library jars from the generation of the uber jar by 127/// generating the uber jar in a project of its own. Often the root 128/// project can be used for this purpose. 129/// 130public class UberJarBuilder extends LibraryBuilder { 131 132 private static final FluentLogger logger = FluentLogger.forEnclosingClass(); 133 private Map<Path, java.util.jar.JarFile> openJars = Map.of(); 134 private Predicate<Resource> resourceFilter = _ -> true; 135 private final List<PathMatcher> ignoredDuplicates = new ArrayList<>(); 136 137 /// Instantiates a new uber jar generator. 138 /// 139 /// @param project the project 140 /// 141 public UberJarBuilder(Project project) { 142 super(Objects.requireNonNull(project)); 143 } 144 145 @Override 146 public UberJarBuilder name(String name) { 147 rename(Objects.requireNonNull(name)); 148 return this; 149 } 150 151 /// Ignore duplicates matching the given glob patterns when merging. 152 /// 153 /// @param patterns the patterns 154 /// @return the uber jar builder 155 /// 156 public UberJarBuilder ignoreDuplicates(String... patterns) { 157 Arrays.asList(patterns).forEach( 158 p -> ignoredDuplicates.add(FileSystems.getDefault() 159 .getPathMatcher("glob:" + p))); 160 return this; 161 } 162 163 @Override 164 protected void collectFromProviders( 165 Map<Path, Resources<IOResource>> contents) { 166 openJars = new ConcurrentHashMap<>(); 167 providers().stream().map(p -> p.resources( 168 of(ClasspathElementType).using(Supply, Expose))) 169 .flatMap(s -> s).parallel() 170 .filter(resourceFilter::test).forEach(cpe -> { 171 if (cpe instanceof FileTree<?> fileTree) { 172 collect(contents, fileTree); 173 } else if (cpe instanceof JarFile jarFile 174 // Ignore jar files from maven repositories, see below 175 && !(jarFile instanceof MvnRepoJarFile)) { 176 addJarFile(contents, jarFile, openJars); 177 } 178 }); 179 180 // Jar files from maven repositories must be resolved before 181 // they can be added to the uber jar, i.e. they must be added 182 // with their transitive dependencies. 183 var lookup = new MvnRepoLookup(); 184 lookup.resolve(providers().stream().map( 185 p -> p.resources(of(MvnRepoDependencyType).usingAll())) 186 .flatMap(s -> s)); 187 project().context().resources(lookup, of(ClasspathElementType) 188 .using(Consume, Reveal, Supply, Expose, Forward)) 189 .parallel().filter(resourceFilter::test).forEach(cpe -> { 190 if (cpe instanceof MvnRepoJarFile jarFile) { 191 addJarFile(contents, jarFile, openJars); 192 } 193 }); 194 } 195 196 /// Apply the given filter to the resources obtained from the provider. 197 /// The resources can be [ClasspathElement]s or [MvnRepoResource]s. 198 /// This may be required to avoid warnings about duplicates if e.g. 199 /// a sub-project provides generated resources both as 200 /// [ClassTree]/[JavaResourceTree] and as [LibraryJarFile]. 201 /// 202 /// @param filter the filter. Returns `true` for resources to be 203 /// included. 204 /// @return the uber jar generator 205 /// 206 public UberJarBuilder resourceFilter(Predicate<Resource> filter) { 207 resourceFilter = Objects.requireNonNull(filter); 208 return this; 209 } 210 211 private void addJarFile(Map<Path, Resources<IOResource>> entries, 212 JarFile jarFile, Map<Path, java.util.jar.JarFile> openJars) { 213 @SuppressWarnings({ "PMD.CloseResource" }) 214 java.util.jar.JarFile jar 215 = openJars.computeIfAbsent(jarFile.path(), _ -> { 216 try { 217 return new java.util.jar.JarFile(jarFile.path().toFile()); 218 } catch (IOException e) { 219 throw new BuildException("Cannot open resource %s: %s", 220 jarFile, e).from(this).cause(e); 221 } 222 }); 223 jar.stream().filter(Predicate.not(JarEntry::isDirectory)) 224 .filter(e -> !Path.of(e.getName()) 225 .endsWith(Path.of("module-info.class"))) 226 .filter(e -> { 227 // Filter top-level entries in META-INF/ 228 var segs = Path.of(e.getRealName()).iterator(); 229 if (segs.next().equals(Path.of("META-INF"))) { 230 segs.next(); 231 return segs.hasNext(); 232 } 233 return true; 234 }).forEach(e -> { 235 var relPath = Path.of(e.getRealName()); 236 entries.computeIfAbsent(relPath, 237 _ -> project().newResource(IOResourcesType)) 238 .add(new JarFileEntry(jar, e)); 239 }); 240 } 241 242 @SuppressWarnings({ "PMD.UselessPureMethodCall", 243 "PMD.AvoidLiteralsInIfCondition" }) 244 @Override 245 protected void resolveDuplicates(Map<Path, Resources<IOResource>> entries) { 246 entries.entrySet().parallelStream().forEach(item -> { 247 var entryName = item.getKey(); 248 var candidates = item.getValue(); 249 if (candidates.stream().count() == 1) { 250 return; 251 } 252 if (entryName.startsWith("META-INF/services")) { 253 var combined = new ServicesEntryResource(); 254 candidates.stream().forEach(service -> { 255 try { 256 combined.add(service); 257 } catch (IOException e) { 258 throw new BuildException("Cannot read %s: %s", 259 service, e).from(this).cause(e); 260 } 261 }); 262 candidates.clear(); 263 candidates.add(combined); 264 return; 265 } 266 if (entryName.startsWith("META-INF")) { 267 candidates.clear(); 268 } 269 if (ignoredDuplicates.stream().map(m -> m.matches(entryName)) 270 .filter(Boolean::booleanValue).findFirst().isPresent()) { 271 return; 272 } 273 candidates.stream().reduce((a, b) -> { 274 logger.atWarning().log("Entry %s from %s duplicates" 275 + " entry from %s and is skipped.", entryName, a, b); 276 return a; 277 }); 278 }); 279 } 280 281 @Override 282 @SuppressWarnings({ "PMD.CollapsibleIfStatements", "unchecked", 283 "PMD.CloseResource", "PMD.UseTryWithResources", 284 "PMD.CognitiveComplexity", "PMD.CyclomaticComplexity" }) 285 protected <T extends Resource> Stream<T> 286 doProvide(ResourceRequest<T> requested) { 287 if (!requested.accepts(AppJarFileType) 288 && !requested.accepts(CleanlinessType)) { 289 return Stream.empty(); 290 } 291 292 // Maybe only delete 293 if (requested.accepts(CleanlinessType)) { 294 destination().resolve(jarName()).toFile().delete(); 295 return Stream.empty(); 296 } 297 298 // Make sure mainClass is set for app jar 299 if (requested.requires(AppJarFileType) && mainClass() == null) { 300 throw new BuildException("Main class must be set for %s", name()) 301 .from(this); 302 } 303 304 // Upgrade to most specific type to avoid duplicate generation 305 if (mainClass() != null && !requested.type().equals(AppJarFileType)) { 306 return (Stream<T>) context() 307 .resources(this, project().of(AppJarFileType)); 308 } 309 if (mainClass() == null && !requested.type().equals(JarFileType)) { 310 return (Stream<T>) context() 311 .resources(this, project().of(JarFileType)); 312 } 313 314 // Prepare jar file 315 var destDir = destination(); 316 if (!destDir.toFile().exists()) { 317 if (!destDir.toFile().mkdirs()) { 318 throw new BuildException("Cannot create directory " + destDir); 319 } 320 } 321 var jarResource = requested.requires(AppJarFileType) 322 ? project().newResource(AppJarFileType, 323 destDir.resolve(jarName())) 324 : project().newResource(LibraryJarFileType, 325 destDir.resolve(jarName())); 326 try { 327 buildJar(jarResource); 328 } finally { 329 // buidJar indirectly calls collectFromProviders which opens 330 // resources that are used in buildJar. Close them now. 331 for (var jarFile : openJars.values()) { 332 try { 333 jarFile.close(); 334 } catch (IOException e) { // NOPMD 335 // Ignore, just trying to be nice. 336 } 337 } 338 } 339 return Stream.of((T) jarResource); 340 } 341}