Skip to content

Commit a3e0feb

Browse files
authored
feat(spawn-application): migrate launcher discovery to ServiceLoader (#25)
1 parent 70ca0c2 commit a3e0feb

9 files changed

Lines changed: 180 additions & 139 deletions

File tree

spawn-application/src/main/java/build/spawn/application/AbstractTemplatedPlatform.java

Lines changed: 19 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -39,45 +39,29 @@
3939
import build.codemodel.foundation.naming.NonCachingNameProvider;
4040
import build.codemodel.injection.ConfigurationResolver;
4141
import build.codemodel.injection.Context;
42-
import build.codemodel.injection.InjectionException;
4342
import build.codemodel.injection.InjectionFramework;
4443
import build.codemodel.jdk.JDKCodeModel;
4544
import build.spawn.application.option.LaunchIdentity;
4645

47-
import java.io.BufferedReader;
4846
import java.io.IOException;
49-
import java.io.InputStreamReader;
50-
import java.io.LineNumberReader;
5147
import java.lang.reflect.Modifier;
52-
import java.net.URL;
5348
import java.util.ArrayDeque;
54-
import java.util.Collections;
5549
import java.util.LinkedHashMap;
5650
import java.util.LinkedList;
57-
import java.util.List;
5851
import java.util.Map;
5952
import java.util.Objects;
60-
import java.util.Optional;
53+
import java.util.ServiceLoader;
6154
import java.util.concurrent.atomic.AtomicLong;
6255
import java.util.stream.Collectors;
6356
import java.util.stream.Stream;
6457

6558
/**
6659
* An abstract {@link TemplatedPlatform} that automatically detects the supported {@link Class}es of
67-
* {@link Application}s and associated {@link Launcher}s by loading and parsing properties files
68-
* (named using the concrete implementation of this {@link Class}) located in {@code META-INF/} folders on the
69-
* classpath.
60+
* {@link Application}s and associated {@link Launcher}s via {@link java.util.ServiceLoader} discovery of
61+
* {@link LauncherRegistration} providers.
7062
* <p>
71-
* The properties files define key-value pairs where each key is a fully-qualified-class-name for a {@link Class}
72-
* of {@link Application} and the corresponding value is the {@link Class} of {@link Launcher} to launch
73-
* said {@link Class}es of {@link Application} with the {@link Platform}.
74-
* <p>
75-
* For example, the {@code META-INF/build.spawn.local.LocalMachine} properties file for the
76-
* {@code LocalMachine} defines the following entries.
77-
* <code>
78-
* build.spawn.application.Application=build.spawn.local.LocalLauncher
79-
* build.spawn.application.java.JavaApplication=build.spawn.java.LocalJavaLauncher
80-
* </code>
63+
* Modules that wish to register a {@link Launcher} for a specific platform declare a {@link LauncherRegistration}
64+
* implementation and register it with {@code provides LauncherRegistration with ...} in their {@code module-info}.
8165
*
8266
* @author brian.oliver
8367
* @since Oct-2018
@@ -104,7 +88,7 @@ public abstract class AbstractTemplatedPlatform
10488
* The {@link Launcher}s by {@link Class} of {@link Application} that are supported
10589
* for this {@link Platform}.
10690
*/
107-
private final LinkedHashMap<Class<? extends Application>, Class<? extends Launcher>> launchers;
91+
private final LinkedHashMap<Class<? extends Application>, Class<? extends Launcher<?, ?>>> launchers;
10892

10993
/**
11094
* The {@link Server} that {@link Application}s may use to communicate with this {@link Platform}.
@@ -157,107 +141,14 @@ public AbstractTemplatedPlatform(final String name,
157141
throw new RuntimeException("Failed to create " + getClass().getCanonicalName(), e);
158142
}
159143

160-
// determine the launchers supported by this platform
144+
// determine the launchers supported by this platform via ServiceLoader discovery
161145
this.launchers = new LinkedHashMap<>();
162146

163-
// attempt to load the properties file defining the launchers for this class of platform
164-
final String path = "META-INF/" + getClass().getName();
165-
166-
final String platformClassName = getClass().getCanonicalName();
167-
LOGGER.debug("Locating Application Launchers for {} in {} (as System Resources)", platformClassName, path);
168-
169-
try {
170-
// determine the properties files defining launchers for the concrete type of platform
171-
final List<URL> resources = Collections.list(ClassLoader.getSystemResources(path));
172-
173-
LOGGER.debug("Located {} System Resource(s) defining Application Launchers", resources.size());
174-
175-
// attempt to find the Launcher for the class of Application
176-
for (final URL resource : resources) {
177-
178-
LOGGER.debug("Loading Application Launcher(s) from {}", resource);
179-
180-
try (LineNumberReader reader = new LineNumberReader(
181-
new BufferedReader(new InputStreamReader(resource.openStream())))) {
182-
183-
String line;
184-
while ((line = reader.readLine()) != null) {
185-
186-
line = Strings.trim(line);
187-
188-
if (!line.startsWith("#") && !Strings.isEmpty(line)) {
189-
final String[] parts = line.split("=");
190-
191-
// the optionally detected name of the class for launching the application
192-
Optional<String> launcherClassName = Optional.empty();
193-
194-
// by default we assume the specified launcher is for all classes of Application
195-
Class<? extends Application> applicationClass = Application.class;
196-
197-
if (parts.length == 1) {
198-
// when there's only a single parameter, assume that is the launcher
199-
launcherClassName = Optional.of(Strings.trim(parts[0]));
200-
} else if (parts.length == 2) {
201-
// when there's a "key=value", the key is the application and the value is the launcher
202-
final String specifiedClassName = Strings.trim(parts[0]);
203-
204-
try {
205-
final Class<?> specifiedClass = Class.forName(specifiedClassName);
206-
207-
if (Application.class.isAssignableFrom(specifiedClass)) {
208-
applicationClass = (Class<? extends Application>) specifiedClass;
209-
launcherClassName = Optional.of(Strings.trim(parts[1]));
210-
} else {
211-
LOGGER.warn("The specified class [{}] in [{}] at line [{}] is not an {}.",
212-
launcherClassName.get(), resource, reader.getLineNumber(),
213-
Application.class.getCanonicalName());
214-
}
215-
} catch (final ClassNotFoundException e) {
216-
LOGGER.warn("The specified class [{}] in [{}] at line [{}] is not found.",
217-
specifiedClassName, resource, reader.getLineNumber());
218-
}
219-
} else {
220-
// the file isn't formatted correctly!
221-
LOGGER.warn(
222-
"Invalid property definition detected in [{}] for [{}] at line [{}].\nLine:{}",
223-
resource, platformClassName, reader.getLineNumber(), line);
224-
}
225-
226-
if (launcherClassName.isPresent()) {
227-
try {
228-
final Class<?> launcherClass = Class.forName(launcherClassName.get());
229-
230-
if (Launcher.class.isAssignableFrom(launcherClass)) {
231-
232-
LOGGER.trace("[{}] classes will use [{}] for launching on [{}]",
233-
applicationClass, launcherClass, platformClassName);
234-
235-
// remember the launcher for this platform
236-
this.launchers.put(applicationClass,
237-
(Class<? extends Launcher>) launcherClass);
238-
} else {
239-
LOGGER.warn("The specified class [{}] in [{}] at line [{}] is not a {}",
240-
launcherClassName.get(), resource, reader.getLineNumber(),
241-
Launcher.class);
242-
}
243-
} catch (final InjectionException e) {
244-
LOGGER.warn(
245-
"The specified class [{}] in [{}] at line [{}] could not be created. The"
246-
+ " launcher will not be available", launcherClassName.get(), resource,
247-
reader.getLineNumber(), e);
248-
} catch (final ClassNotFoundException e) {
249-
LOGGER.warn("The specified class [{}] in [{}] at line [{}] is not found. The "
250-
+ "launcher will not be available", launcherClassName.get(), resource,
251-
reader.getLineNumber(), e);
252-
}
253-
}
254-
}
255-
}
256-
}
257-
}
258-
} catch (final IOException e) {
259-
LOGGER.warn("Failed to determine System Resources for [{}]", path, e);
260-
}
147+
ServiceLoader.load(LauncherRegistration.class, getClass().getClassLoader())
148+
.stream()
149+
.map(ServiceLoader.Provider::get)
150+
.filter(reg -> reg.platformClass().equals(getClass()))
151+
.forEach(reg -> this.launchers.put(reg.applicationClass(), reg.launcherClass()));
261152
}
262153

263154
@Override
@@ -430,15 +321,15 @@ public <A extends Application> A launch(final Specification<A> specification) {
430321
// determine the class of Launcher for the Application
431322
//
432323
// IMPORTANT: use min() to select the most specific (most derived) registered application class,
433-
// NOT findFirst(). The launchers map is a LinkedHashMap populated by iterating META-INF resource
434-
// files in classpath order. Using findFirst() meant that whichever jar appeared earlier on the
435-
// classpath determined the launcher — e.g. if spawn-local-platform appeared before spawn-local-jdk,
324+
// NOT findFirst(). The launchers map is a LinkedHashMap populated by iterating ServiceLoader
325+
// registrations in discovery order. Using findFirst() meant that whichever module was discovered
326+
// first determined the launcher — e.g. if spawn-local-platform was discovered before spawn-local-jdk,
436327
// then Application→LocalLauncher would win over JDKApplication→LocalJDKLauncher for any
437328
// JDKApplication subclass. This caused silent, order-dependent failures that were extremely hard
438329
// to diagnose (the symptom was "Failed to provide or determine the Executable to launch" or
439330
// the JVM receiving program arguments as JVM flags). Using min() with class hierarchy ordering
440-
// ensures the most derived registered type always wins, regardless of classpath order.
441-
final Class<? extends Launcher> launcherClass = this.launchers.entrySet()
331+
// ensures the most derived registered type always wins, regardless of discovery order.
332+
final Class<? extends Launcher<?, ?>> launcherClass = this.launchers.entrySet()
442333
.stream()
443334
.filter(e -> e.getKey().isAssignableFrom(applicationClass))
444335
.min((a, b) -> a.getKey().isAssignableFrom(b.getKey()) ? 1 : -1)
@@ -449,7 +340,8 @@ public <A extends Application> A launch(final Specification<A> specification) {
449340
+ applicationClass.getCanonicalName()));
450341

451342
// establish the Application.Launcher
452-
final Launcher<A, Platform> launcher = context.create(launcherClass);
343+
@SuppressWarnings("unchecked")
344+
final Launcher<A, Platform> launcher = (Launcher<A, Platform>) context.create(launcherClass);
453345

454346
// establish the launch Configuration
455347
final var launchConfiguration = launchConfigurationBuilder.build();
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package build.spawn.application;
2+
3+
/*-
4+
* #%L
5+
* Spawn Application
6+
* %%
7+
* Copyright (C) 2026 Workday, Inc.
8+
* %%
9+
* Licensed under the Apache License, Version 2.0 (the "License");
10+
* you may not use this file except in compliance with the License.
11+
* You may obtain a copy of the License at
12+
*
13+
* http://www.apache.org/licenses/LICENSE-2.0
14+
*
15+
* Unless required by applicable law or agreed to in writing, software
16+
* distributed under the License is distributed on an "AS IS" BASIS,
17+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18+
* See the License for the specific language governing permissions and
19+
* limitations under the License.
20+
* #L%
21+
*/
22+
23+
/**
24+
* A {@link LauncherRegistration} associates a {@link Launcher} with the {@link AbstractTemplatedPlatform}
25+
* and {@link Application} type it serves. Implementations are discovered via {@link java.util.ServiceLoader}.
26+
*
27+
* @author reed.vonredwitz
28+
* @since Apr-2026
29+
*/
30+
public interface LauncherRegistration {
31+
32+
/**
33+
* The concrete {@link AbstractTemplatedPlatform} class this registration applies to.
34+
*
35+
* @return the platform class
36+
*/
37+
Class<? extends AbstractTemplatedPlatform> platformClass();
38+
39+
/**
40+
* The {@link Application} class this {@link Launcher} handles.
41+
*
42+
* @return the application class
43+
*/
44+
Class<? extends Application> applicationClass();
45+
46+
/**
47+
* The {@link Launcher} class to use for launching the {@link Application}.
48+
*
49+
* @return the launcher class
50+
*/
51+
Class<? extends Launcher<?, ?>> launcherClass();
52+
}

spawn-application/src/main/java/module-info.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,6 @@
4747
exports build.spawn.application.console;
4848
exports build.spawn.application.facet;
4949
exports build.spawn.application.option;
50+
51+
uses build.spawn.application.LauncherRegistration;
5052
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package build.spawn.platform.local.jdk;
2+
3+
/*-
4+
* #%L
5+
* Spawn Local JDK
6+
* %%
7+
* Copyright (C) 2026 Workday, Inc.
8+
* %%
9+
* Licensed under the Apache License, Version 2.0 (the "License");
10+
* you may not use this file except in compliance with the License.
11+
* You may obtain a copy of the License at
12+
*
13+
* http://www.apache.org/licenses/LICENSE-2.0
14+
*
15+
* Unless required by applicable law or agreed to in writing, software
16+
* distributed under the License is distributed on an "AS IS" BASIS,
17+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18+
* See the License for the specific language governing permissions and
19+
* limitations under the License.
20+
* #L%
21+
*/
22+
23+
import build.spawn.application.AbstractTemplatedPlatform;
24+
import build.spawn.application.Launcher;
25+
import build.spawn.application.LauncherRegistration;
26+
import build.spawn.jdk.JDKApplication;
27+
import build.spawn.platform.local.LocalMachine;
28+
29+
/**
30+
* Registers {@link LocalJDKLauncher} as the {@link Launcher} for {@link JDKApplication} on {@link LocalMachine}.
31+
*
32+
* @author reed.vonredwitz
33+
* @since Apr-2026
34+
*/
35+
public record LocalJDKLauncherRegistration() implements LauncherRegistration {
36+
37+
@Override
38+
public Class<? extends AbstractTemplatedPlatform> platformClass() {
39+
return LocalMachine.class;
40+
}
41+
42+
@Override
43+
public Class<? extends JDKApplication> applicationClass() {
44+
return JDKApplication.class;
45+
}
46+
47+
@Override
48+
public Class<? extends Launcher<?, ?>> launcherClass() {
49+
return LocalJDKLauncher.class;
50+
}
51+
}

spawn-local-jdk/src/main/java/module-info.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,7 @@
4343

4444
provides build.spawn.platform.local.jdk.JDKDetector
4545
with build.spawn.platform.local.jdk.JDKHomeBasedPatternDetector;
46+
47+
provides build.spawn.application.LauncherRegistration
48+
with build.spawn.platform.local.jdk.LocalJDKLauncherRegistration;
4649
}

spawn-local-jdk/src/main/resources/META-INF/build.spawn.platform.local.LocalMachine

Lines changed: 0 additions & 6 deletions
This file was deleted.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package build.spawn.platform.local;
2+
3+
/*-
4+
* #%L
5+
* Spawn Local Platform
6+
* %%
7+
* Copyright (C) 2026 Workday, Inc.
8+
* %%
9+
* Licensed under the Apache License, Version 2.0 (the "License");
10+
* you may not use this file except in compliance with the License.
11+
* You may obtain a copy of the License at
12+
*
13+
* http://www.apache.org/licenses/LICENSE-2.0
14+
*
15+
* Unless required by applicable law or agreed to in writing, software
16+
* distributed under the License is distributed on an "AS IS" BASIS,
17+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18+
* See the License for the specific language governing permissions and
19+
* limitations under the License.
20+
* #L%
21+
*/
22+
23+
import build.spawn.application.AbstractTemplatedPlatform;
24+
import build.spawn.application.Application;
25+
import build.spawn.application.Launcher;
26+
import build.spawn.application.LauncherRegistration;
27+
28+
/**
29+
* Registers {@link LocalLauncher} as the {@link Launcher} for {@link Application} on {@link LocalMachine}.
30+
*
31+
* @author reed.vonredwitz
32+
* @since Apr-2026
33+
*/
34+
public record LocalLauncherRegistration() implements LauncherRegistration {
35+
36+
@Override
37+
public Class<? extends AbstractTemplatedPlatform> platformClass() {
38+
return LocalMachine.class;
39+
}
40+
41+
@Override
42+
public Class<? extends Application> applicationClass() {
43+
return Application.class;
44+
}
45+
46+
@Override
47+
public Class<? extends Launcher<?, ?>> launcherClass() {
48+
return LocalLauncher.class;
49+
}
50+
}

spawn-local-platform/src/main/java/module-info.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,7 @@
3030
requires transitive build.spawn.application;
3131

3232
exports build.spawn.platform.local;
33+
34+
provides build.spawn.application.LauncherRegistration
35+
with build.spawn.platform.local.LocalLauncherRegistration;
3336
}

spawn-local-platform/src/main/resources/META-INF/build.spawn.platform.local.LocalMachine

Lines changed: 0 additions & 6 deletions
This file was deleted.

0 commit comments

Comments
 (0)