Skip to content

Commit 024894a

Browse files
authored
Support for addon dependencies and soft-dependencies (#730)
* Add support for addon dependencies and soft-dependencies * Apply spotless fixes * Add exception if addon is not loaded due to missing dependencies
1 parent 3df7836 commit 024894a

File tree

10 files changed

+368
-228
lines changed

10 files changed

+368
-228
lines changed

common/src/main/java/de/bluecolored/bluemap/common/BlueMapService.java

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -153,11 +153,7 @@ public synchronized Map<String, BmMap> getOrLoadMaps(Predicate<String> filter) t
153153
try {
154154
loadMap(entry.getKey(), entry.getValue());
155155
} catch (ConfigurationException ex) {
156-
Logger.global.logWarning(ex.getFormattedExplanation());
157-
Throwable cause = ex.getRootCause();
158-
if (cause != null) {
159-
Logger.global.logError("Detailed error:", ex);
160-
}
156+
ex.printLog(Logger.global);
161157
}
162158
}
163159
return Collections.unmodifiableMap(maps);
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
* This file is part of BlueMap, licensed under the MIT License (MIT).
3+
*
4+
* Copyright (c) Blue (Lukas Rieger) <https://bluecolored.de>
5+
* Copyright (c) contributors
6+
*
7+
* Permission is hereby granted, free of charge, to any person obtaining a copy
8+
* of this software and associated documentation files (the "Software"), to deal
9+
* in the Software without restriction, including without limitation the rights
10+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11+
* copies of the Software, and to permit persons to whom the Software is
12+
* furnished to do so, subject to the following conditions:
13+
*
14+
* The above copyright notice and this permission notice shall be included in
15+
* all copies or substantial portions of the Software.
16+
*
17+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
23+
* THE SOFTWARE.
24+
*/
25+
package de.bluecolored.bluemap.common.addons;
26+
27+
import lombok.Getter;
28+
import lombok.RequiredArgsConstructor;
29+
30+
import java.nio.file.Path;
31+
32+
@RequiredArgsConstructor
33+
@Getter
34+
public class Addon {
35+
36+
private final AddonInfo addonInfo;
37+
private final Path jarFile;
38+
39+
}

common/src/main/java/de/bluecolored/bluemap/common/addons/AddonInfo.java

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,59 @@
2424
*/
2525
package de.bluecolored.bluemap.common.addons;
2626

27+
import com.google.gson.FieldNamingPolicy;
28+
import com.google.gson.Gson;
29+
import com.google.gson.GsonBuilder;
30+
import de.bluecolored.bluemap.common.config.ConfigurationException;
2731
import lombok.Getter;
32+
import org.jetbrains.annotations.Nullable;
2833

34+
import java.io.IOException;
35+
import java.io.Reader;
36+
import java.nio.charset.StandardCharsets;
37+
import java.nio.file.FileSystem;
38+
import java.nio.file.FileSystems;
39+
import java.nio.file.Files;
40+
import java.nio.file.Path;
41+
import java.util.Set;
42+
43+
@SuppressWarnings({"FieldMayBeFinal", "unused"})
2944
@Getter
3045
public class AddonInfo {
3146
public static final String ADDON_INFO_FILE = "bluemap.addon.json";
3247

48+
private static final Gson GSON = new GsonBuilder()
49+
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_DASHES)
50+
.create();
51+
3352
private String id;
3453
private String entrypoint;
54+
private Set<String> dependencies = Set.of();
55+
private Set<String> softDependencies = Set.of();
56+
57+
public static @Nullable AddonInfo load(Path addonJarFile) throws ConfigurationException {
58+
try (FileSystem fileSystem = FileSystems.newFileSystem(addonJarFile, (ClassLoader) null)) {
59+
for (Path root : fileSystem.getRootDirectories()) {
60+
Path addonInfoFile = root.resolve(ADDON_INFO_FILE);
61+
if (!Files.exists(addonInfoFile)) continue;
62+
63+
try (Reader reader = Files.newBufferedReader(addonInfoFile, StandardCharsets.UTF_8)) {
64+
AddonInfo addonInfo = GSON.fromJson(reader, AddonInfo.class);
65+
66+
if (addonInfo.getId() == null)
67+
throw new ConfigurationException("'id' is missing");
68+
69+
if (addonInfo.getEntrypoint() == null)
70+
throw new ConfigurationException("'entrypoint' is missing");
71+
72+
return addonInfo;
73+
}
74+
}
75+
} catch (IOException e) {
76+
throw new ConfigurationException("There was an exception trying to access the file.", e);
77+
}
78+
79+
return null;
80+
}
3581

3682
}
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
/*
2+
* This file is part of BlueMap, licensed under the MIT License (MIT).
3+
*
4+
* Copyright (c) Blue (Lukas Rieger) <https://bluecolored.de>
5+
* Copyright (c) contributors
6+
*
7+
* Permission is hereby granted, free of charge, to any person obtaining a copy
8+
* of this software and associated documentation files (the "Software"), to deal
9+
* in the Software without restriction, including without limitation the rights
10+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11+
* copies of the Software, and to permit persons to whom the Software is
12+
* furnished to do so, subject to the following conditions:
13+
*
14+
* The above copyright notice and this permission notice shall be included in
15+
* all copies or substantial portions of the Software.
16+
*
17+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
23+
* THE SOFTWARE.
24+
*/
25+
package de.bluecolored.bluemap.common.addons;
26+
27+
import de.bluecolored.bluemap.common.config.ConfigurationException;
28+
import de.bluecolored.bluemap.core.BlueMap;
29+
import de.bluecolored.bluemap.core.logger.Logger;
30+
import lombok.Singular;
31+
import org.jetbrains.annotations.Nullable;
32+
33+
import java.io.IOException;
34+
import java.net.URL;
35+
import java.net.URLClassLoader;
36+
import java.nio.file.Files;
37+
import java.nio.file.Path;
38+
import java.util.*;
39+
import java.util.concurrent.ConcurrentHashMap;
40+
import java.util.stream.Collectors;
41+
import java.util.stream.Stream;
42+
43+
public final class AddonLoader {
44+
public static final AddonLoader INSTANCE = new AddonLoader();
45+
46+
private final Map<String, LoadedAddon> loadedAddons = new ConcurrentHashMap<>();
47+
48+
public void tryLoadAddons(Path root) {
49+
if (!Files.exists(root)) return;
50+
try (Stream<Path> files = Files.list(root)) {
51+
52+
// find all addons and load addon-info
53+
Map<String, Addon> availableAddons = files
54+
.filter(Files::isRegularFile)
55+
.filter(f -> f.getFileName().toString().endsWith(".jar"))
56+
.map(this::tryLoadAddonInfo)
57+
.filter(Objects::nonNull)
58+
.collect(Collectors.toMap(addon -> addon.getAddonInfo().getId(), addon -> addon));
59+
60+
// remove addons that have missing required dependencies
61+
while (!availableAddons.isEmpty()) {
62+
Addon addonToRemove = availableAddons.values().stream()
63+
.filter(a -> !availableAddons.keySet().containsAll(a.getAddonInfo().getDependencies()))
64+
.findAny()
65+
.orElse(null);
66+
if (addonToRemove == null) break;
67+
String id = addonToRemove.getAddonInfo().getId();
68+
availableAddons.remove(id);
69+
new ConfigurationException("Missing required dependencies %s to load addon '%s' (%s)".formatted(
70+
Arrays.toString(addonToRemove.getAddonInfo().getDependencies().toArray(String[]::new)),
71+
id,
72+
addonToRemove.getJarFile()
73+
)).printLog(Logger.global);
74+
}
75+
76+
// topography sort and load addons based on their dependencies
77+
Map<String, Long> dependenciesToLoad = new HashMap<>();
78+
Queue<String> loadNext = new ArrayDeque<>();
79+
for (Addon addon : availableAddons.values()) {
80+
long dependencyCount =
81+
addon.getAddonInfo().getDependencies().size() +
82+
addon.getAddonInfo().getSoftDependencies().stream()
83+
.filter(availableAddons::containsKey)
84+
.count();
85+
String id = addon.getAddonInfo().getId();
86+
if (dependencyCount == 0) loadNext.add(id);
87+
else dependenciesToLoad.put(id, dependencyCount);
88+
}
89+
90+
while (!loadNext.isEmpty()) {
91+
String id = loadNext.poll();
92+
Addon addon = availableAddons.get(id);
93+
94+
try {
95+
loadAddon(addon);
96+
for (Addon dependant : availableAddons.values()) {
97+
AddonInfo info = dependant.getAddonInfo();
98+
if (info.getDependencies().contains(id) || info.getSoftDependencies().contains(id)) {
99+
Long count = dependenciesToLoad.get(info.getId());
100+
if (count == null) continue;
101+
if (--count <= 0) {
102+
dependenciesToLoad.remove(info.getId());
103+
loadNext.add(info.getId());
104+
} else {
105+
dependenciesToLoad.put(info.getId(), count);
106+
}
107+
}
108+
}
109+
} catch (ConfigurationException ex) {
110+
new ConfigurationException("Failed to load addon '%s' (%s)".formatted(id, addon.getJarFile()), ex)
111+
.printLog(Logger.global);
112+
}
113+
}
114+
115+
// failed to resolve dependencies, possibly a cyclic reference
116+
// try to load anyway in case a soft dependency is involved
117+
for (String id : dependenciesToLoad.keySet()) {
118+
Addon addon = availableAddons.remove(id);
119+
try {
120+
if (addon != null) loadAddon(addon);
121+
} catch (ConfigurationException ex) {
122+
new ConfigurationException("Failed to load addon '%s' (%s)".formatted(id, addon.getJarFile()), ex)
123+
.printLog(Logger.global);
124+
}
125+
}
126+
127+
} catch (IOException e) {
128+
Logger.global.logError("Failed to load addons from '%s'".formatted(root), e);
129+
}
130+
}
131+
132+
private @Nullable Addon tryLoadAddonInfo(Path jarFile) {
133+
try {
134+
AddonInfo addonInfo = AddonInfo.load(jarFile);
135+
if (addonInfo == null) return null;
136+
return new Addon(addonInfo, jarFile);
137+
} catch (ConfigurationException e) {
138+
new ConfigurationException("Failed to load addon info from '%s'.".formatted(jarFile), e)
139+
.printLog(Logger.global);
140+
return null;
141+
}
142+
}
143+
144+
private synchronized void loadAddon(Addon addon) throws ConfigurationException {
145+
AddonInfo addonInfo = addon.getAddonInfo();
146+
Path jarFile = addon.getJarFile();
147+
148+
Logger.global.logInfo("Loading BlueMap Addon: %s (%s)".formatted(addonInfo.getId(), jarFile));
149+
150+
try {
151+
Set<ClassLoader> dependencyClassLoaders = new LinkedHashSet<>();
152+
for (String dependencyId : addon.getAddonInfo().getDependencies()) {
153+
LoadedAddon loadedAddon = loadedAddons.get(dependencyId);
154+
if (loadedAddon == null) throw new IllegalStateException("Required dependency '%s' is not loaded."
155+
.formatted(addon.getAddonInfo().getId()));
156+
dependencyClassLoaders.add(loadedAddon.getClassLoader());
157+
}
158+
for (String dependencyId : addon.getAddonInfo().getSoftDependencies()) {
159+
LoadedAddon loadedAddon = loadedAddons.get(dependencyId);
160+
if (loadedAddon == null) continue;
161+
dependencyClassLoaders.add(loadedAddon.getClassLoader());
162+
}
163+
164+
ClassLoader parent = BlueMap.class.getClassLoader();
165+
if (!dependencyClassLoaders.isEmpty())
166+
parent = new CombinedClassLoader(parent, dependencyClassLoaders.toArray(ClassLoader[]::new));
167+
168+
ClassLoader addonClassLoader = new URLClassLoader(
169+
new URL[]{ jarFile.toUri().toURL() },
170+
parent
171+
);
172+
Class<?> entrypointClass = addonClassLoader.loadClass(addonInfo.getEntrypoint());
173+
174+
// create addon instance
175+
Object instance = entrypointClass.getConstructor().newInstance();
176+
LoadedAddon loadedAddon = new LoadedAddon(
177+
addonInfo,
178+
jarFile,
179+
addonClassLoader,
180+
instance
181+
);
182+
183+
loadedAddons.put(addonInfo.getId(), loadedAddon);
184+
185+
// run addon
186+
if (instance instanceof Runnable runnable)
187+
runnable.run();
188+
189+
} catch (Exception e) {
190+
throw new ConfigurationException("There was an exception trying to initialize the addon!", e);
191+
}
192+
}
193+
194+
}

0 commit comments

Comments
 (0)