GH-119 - Improve rendering of application modules structure as JSON.

Introduced ApplicationModulesExporter to render an ApplicationModules instances as JSON directly. To avoid a dependency to a JSON library and as we only have to be able to render rather simple arrangements, we just build up the JSON string ourselves.

ApplicationModulesEndpoint now caches the structure calculated once to avoid repeated work.
This commit is contained in:
Oliver Drotbohm
2023-01-18 18:31:56 +01:00
parent 9082ca27bb
commit 090ddc6fba
6 changed files with 254 additions and 58 deletions

View File

@@ -15,26 +15,17 @@
*/
package org.springframework.modulith.actuator;
import static java.util.stream.Collectors.*;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Collector;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.modulith.core.ApplicationModule;
import org.springframework.modulith.core.ApplicationModuleDependency;
import org.springframework.modulith.core.ApplicationModules;
import org.springframework.modulith.core.DependencyType;
import org.springframework.modulith.core.util.ApplicationModulesExporter;
import org.springframework.util.Assert;
import org.springframework.util.function.SingletonSupplier;
/**
* A Spring Boot actuator endpoint to expose the application module structure of a Spring Modulith based application.
@@ -46,20 +37,7 @@ public class ApplicationModulesEndpoint {
private static final Logger LOGGER = LoggerFactory.getLogger(ApplicationModulesEndpoint.class);
private static final Function<Set<DependencyType>, Set<DependencyType>> REMOVE_DEFAULT_DEPENDENCY_TYPE_IF_OTHERS_PRESENT = it -> {
if (it.stream().anyMatch(type -> type != DependencyType.DEFAULT)) {
it.remove(DependencyType.DEFAULT);
}
return it;
};
private static final Collector<ApplicationModuleDependency, ?, Set<DependencyType>> MAPPER = mapping(
ApplicationModuleDependency::getDependencyType,
collectingAndThen(toSet(), REMOVE_DEFAULT_DEPENDENCY_TYPE_IF_OTHERS_PRESENT));
private final Supplier<ApplicationModules> runtime;
private final SingletonSupplier<String> structure;
/**
* Creates a new {@link ApplicationModulesEndpoint} for the given {@link ApplicationModules}.
@@ -72,7 +50,7 @@ public class ApplicationModulesEndpoint {
LOGGER.debug("Activating Spring Modulith actuator.");
this.runtime = runtime;
this.structure = SingletonSupplier.of(new ApplicationModulesExporter(runtime.get())::toJson);
}
/**
@@ -81,34 +59,7 @@ public class ApplicationModulesEndpoint {
* @return will never be {@literal null}.
*/
@ReadOperation
Map<String, Object> getApplicationModules() {
var modules = runtime.get();
return modules.stream()
.collect(
Collectors.toMap(ApplicationModule::getName, it -> toInfo(it, modules), (l, r) -> r, LinkedHashMap::new));
}
private static Map<String, Object> toInfo(ApplicationModule module, ApplicationModules modules) {
return Map.of( //
"displayName", module.getDisplayName(), //
"basePackage", module.getBasePackage().getName(), //
"dependencies", module.getDependencies(modules).stream() //
.collect(Collectors.groupingBy(ApplicationModuleDependency::getTargetModule, MAPPER))
.entrySet() //
.stream() //
.map(ApplicationModulesEndpoint::toInfo) //
.toList() //
);
}
private static Map<String, Object> toInfo(Entry<ApplicationModule, ? extends Set<DependencyType>> types) {
return Map.of( //
"target", types.getKey().getName(), //
"types", types.getValue() //
);
String getApplicationModules() {
return structure.obtain();
}
}

View File

@@ -22,7 +22,6 @@ import net.minidev.json.JSONArray;
import org.junit.jupiter.api.Test;
import org.springframework.modulith.test.TestApplicationModules;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.jayway.jsonpath.JsonPath;
/**
@@ -38,7 +37,7 @@ class ApplicationModulesEndpointIntegrationTests {
var modules = TestApplicationModules.of("example");
var endpoint = new ApplicationModulesEndpoint(() -> modules);
var result = endpoint.getApplicationModules();
var context = JsonPath.parse(new ObjectMapper().writeValueAsString(result));
var context = JsonPath.parse(result);
assertThat(context.<String> read("$.a.basePackage")).isEqualTo("example.a");
assertThat(context.<JSONArray> read("$.a.dependencies")).isEmpty();

View File

@@ -0,0 +1,120 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.modulith.core.util;
import static java.util.stream.Collectors.*;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collector;
import java.util.stream.Collectors;
import org.springframework.modulith.core.ApplicationModule;
import org.springframework.modulith.core.ApplicationModuleDependency;
import org.springframework.modulith.core.ApplicationModules;
import org.springframework.modulith.core.DependencyType;
import org.springframework.util.Assert;
/**
* Export the structure of {@link ApplicationModules} as JSON.
*
* @author Oliver Drotbohm
*/
public class ApplicationModulesExporter {
private static final Function<Set<DependencyType>, Set<DependencyType>> REMOVE_DEFAULT_DEPENDENCY_TYPE_IF_OTHERS_PRESENT = it -> {
if (it.stream().anyMatch(type -> type != DependencyType.DEFAULT)) {
it.remove(DependencyType.DEFAULT);
}
return it;
};
private static final Collector<ApplicationModuleDependency, ?, Set<DependencyType>> MAPPER = mapping(
ApplicationModuleDependency::getDependencyType,
collectingAndThen(toSet(), REMOVE_DEFAULT_DEPENDENCY_TYPE_IF_OTHERS_PRESENT));
private final ApplicationModules modules;
/**
* Creates a new {@link ApplicationModulesExporter} for the given {@link ApplicationModules}.
*
* @param modules must not be {@literal null}.
*/
public ApplicationModulesExporter(ApplicationModules modules) {
Assert.notNull(modules, "ApplicationModules must not be null!");
this.modules = modules;
}
/**
* Simple main method to render the {@link ApplicationModules} instance defined for the Java package given as first
* argument.
*
* @param args a single-element array containing a Java package name to bootstrap an {@link ApplicationModules}
* instance from.
*/
public static void main(String[] args) {
Assert.notNull(args, "Arguments must not be null!");
Assert.isTrue(args.length == 1, "A java package name is required as only argument!");
System.out.println(new ApplicationModulesExporter(ApplicationModules.of(args[0])).toJson());
}
/**
* Returns the {@link ApplicationModules} structure as JSON String.
*
* @return will never be {@literal null}.
*/
public String toJson() {
return Json.toString(toMap());
}
private Map<String, Object> toMap() {
return modules.stream()
.collect(
Collectors.toMap(ApplicationModule::getName, it -> toInfo(it, modules), (l, r) -> r, LinkedHashMap::new));
}
private static Map<String, Object> toInfo(ApplicationModule module, ApplicationModules modules) {
return Map.of( //
"displayName", module.getDisplayName(), //
"basePackage", module.getBasePackage().getName(), //
"dependencies", module.getDependencies(modules).stream() //
.collect(Collectors.groupingBy(ApplicationModuleDependency::getTargetModule, MAPPER))
.entrySet() //
.stream() //
.map(ApplicationModulesExporter::toInfo) //
.toList() //
);
}
private static Map<String, Object> toInfo(Entry<ApplicationModule, ? extends Set<DependencyType>> types) {
return Map.of( //
"target", types.getKey().getName(), //
"types", types.getValue() //
);
}
}

View File

@@ -0,0 +1,76 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.modulith.core.util;
import java.util.Collection;
import java.util.Map;
import java.util.Map.Entry;
import java.util.stream.Collectors;
/**
* Helper to render a {@link Map} as JSON. Key need to be {@link #toString()}-able, values need to be
* {@link #toString()}-able, too, be an enum, a {@link Collection} or {@link Map}.
*
* @author Oliver Drotbohm
*/
class Json {
@SuppressWarnings({ "rawtypes", "unchecked" })
static String toString(Object object) {
if (object instanceof String string) {
return quote(string);
}
if (object instanceof Collection collection) {
return toString(collection);
}
if (object instanceof Map map) {
return toString(map);
}
if (object instanceof Enum value) {
return quote(value.name());
}
return object.toString();
}
private static String toString(Map<Object, Object> map) {
return map.isEmpty()
? "{}"
: map.entrySet().stream()
.map(Json::toString)
.collect(Collectors.joining(", ", "{ ", " }"));
}
private static String toString(Entry<Object, Object> entry) {
return "\"" + entry.getKey() + "\" : " + toString(entry.getValue());
}
private static String toString(Collection<Object> collection) {
return collection.isEmpty()
? "[]"
: collection.stream().map(Json::toString).collect(Collectors.joining(", ", "[ ", " ]"));
}
private static String quote(String string) {
return "\"" + string + "\"";
}
}

View File

@@ -52,12 +52,20 @@
<artifactId>spring-tx</artifactId>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.jgrapht</groupId>
<artifactId>jgrapht-core</artifactId>

View File

@@ -0,0 +1,42 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.modulith.core.util;
import static org.assertj.core.api.Assertions.*;
import org.junit.jupiter.api.Test;
import org.springframework.modulith.core.ApplicationModules;
import com.acme.myproject.Application;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* Unit tests for {@link ApplicationModulesExporter}.
*
* @author Oliver Drotbohm
*/
public class ApplicationModulesExporterUnitTests {
@Test // #119
void rendersApplicationModulesAsJson() {
var json = new ApplicationModulesExporter(ApplicationModules.of(Application.class)).toJson();
assertThatNoException().isThrownBy(() -> {
new ObjectMapper().readTree(json);
});
}
}