refactor(config): support printing more information after init failed by ConfigDoc

This commit is contained in:
2026-04-04 00:33:25 +08:00
parent db4dc6d040
commit 188b5e8b53
5 changed files with 257 additions and 1 deletions

View File

@@ -5,12 +5,17 @@ import org.slf4j.LoggerFactory
import work.slhaf.partner.api.agent.runtime.exception.AgentLaunchFailedException
import work.slhaf.partner.api.common.support.DirectoryWatchSupport
import java.io.IOException
import java.lang.reflect.Field
import java.lang.reflect.Modifier
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Path
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import kotlin.reflect.KProperty1
import kotlin.reflect.full.memberProperties
import kotlin.reflect.jvm.javaField
object ConfigCenter : AutoCloseable {
@@ -86,11 +91,64 @@ object ConfigCenter : AutoCloseable {
if (defaultConfig != null) {
(registration as ConfigRegistration<Config>).init(defaultConfig)
}
throw AgentLaunchFailedException("Failed to init config, related path: $path")
val configDoc = resolveConfigDoc(registration.type())
throw AgentLaunchFailedException("Failed to init config, related path: $path, config definition: $configDoc")
}
}
private fun resolveConfigDoc(type: Class<out Config>): String {
val kotlinProperties = if (type.isKotlinClass()) {
type.kotlin.memberProperties.associateBy { property ->
property.javaField?.name ?: property.name
}
} else {
emptyMap()
}
val fieldDocs = type.declaredFields.asSequence()
.filterNot(::shouldSkipField)
.map { field -> resolveFieldDoc(type, field, kotlinProperties[field.name]) }
.toList()
return buildString {
append("Expected fields:")
if (fieldDocs.isNotEmpty()) {
append('\n')
append(fieldDocs.joinToString("\n\n"))
}
}
}
private fun resolveFieldDoc(
ownerType: Class<out Config>,
field: Field,
kotlinProperty: KProperty1<out Any, *>?
): String {
val configDoc = field.getAnnotation(ConfigDoc::class.java)
?: kotlinProperty?.annotations?.filterIsInstance<ConfigDoc>()?.firstOrNull()
val nullableInfo = resolveNullableInfo(ownerType, field, kotlinProperty)
val lines = mutableListOf(
"- ${field.name}: ${resolveDisplayType(field.type)}",
" Description: ${configDoc?.description ?: "No description provided"}"
)
configDoc?.unit?.takeIf { it.isNotBlank() }?.let { lines += " Unit: $it" }
configDoc?.constraint?.takeIf { it.isNotBlank() }?.let { lines += " Constraint: $it" }
configDoc?.example?.takeIf { it.isNotBlank() }?.let { lines += " Example: $it" }
lines += buildString {
append(" Nullable: ")
append(nullableInfo.nullable)
nullableInfo.note?.let {
append(" (")
append(it)
append(')')
}
}
return lines.joinToString("\n")
}
private fun shouldSkipField(field: Field): Boolean {
return field.isSynthetic || Modifier.isStatic(field.modifiers)
}
private fun handleUpsert(thisDir: Path, context: Path?) {
if (context == null || !Files.isRegularFile(context) || !isJsonFile(context)) {
return
@@ -193,3 +251,15 @@ interface ConfigRegistration<T : Config> {
fun onReload(config: T) {}
fun defaultConfig(): T?
}
@Target(
AnnotationTarget.FIELD,
AnnotationTarget.VALUE_PARAMETER,
AnnotationTarget.PROPERTY
)
annotation class ConfigDoc(
val description: String,
val unit: String = "",
val constraint: String = "",
val example: String = "",
)

View File

@@ -0,0 +1,97 @@
package work.slhaf.partner.api.agent.runtime.config
import net.bytebuddy.jar.asm.*
import java.lang.reflect.Field
import kotlin.reflect.KProperty1
internal fun Class<*>.isKotlinClass(): Boolean {
return getAnnotation(Metadata::class.java) != null
}
internal fun resolveDisplayType(type: Class<*>): String {
if (type.isArray) {
return "${resolveDisplayType(type.componentType)}[]"
}
return when (type) {
java.lang.Integer.TYPE, java.lang.Integer::class.java -> "Int"
java.lang.Long.TYPE, java.lang.Long::class.java -> "Long"
java.lang.Boolean.TYPE, java.lang.Boolean::class.java -> "Boolean"
java.lang.Double.TYPE, java.lang.Double::class.java -> "Double"
java.lang.Float.TYPE, java.lang.Float::class.java -> "Float"
java.lang.Short.TYPE, java.lang.Short::class.java -> "Short"
java.lang.Byte.TYPE, java.lang.Byte::class.java -> "Byte"
java.lang.Character.TYPE, java.lang.Character::class.java -> "Char"
String::class.java -> "String"
else -> type.simpleName
}
}
internal fun resolveNullableInfo(
ownerType: Class<out Config>,
field: Field,
kotlinProperty: KProperty1<out Any, *>?
): NullableInfo {
if (ownerType.isKotlinClass()) {
if (kotlinProperty != null) {
return NullableInfo(kotlinProperty.returnType.isMarkedNullable, null)
}
return NullableInfo(false, "inferred because Kotlin property metadata was not found")
}
val annotationNames = resolveJavaFieldAnnotationNames(ownerType, field)
if (annotationNames.any { it.endsWith(".Nullable") || it == "Nullable" }) {
return NullableInfo(true, null)
}
if (annotationNames.any { it.endsWith(".NotNull") || it == "NotNull" }) {
return NullableInfo(false, null)
}
return NullableInfo(false, "inferred from missing nullability annotation, may be unreliable")
}
private fun resolveJavaFieldAnnotationNames(ownerType: Class<out Config>, field: Field): Set<String> {
val annotationNames = linkedSetOf<String>()
annotationNames += (field.annotations.asSequence() + field.annotatedType.annotations.asSequence())
.map { it.annotationClass.java.name }
.toSet()
val resourcePath = "${ownerType.name.replace('.', '/')}.class"
val classStream = (ownerType.classLoader?.getResourceAsStream(resourcePath)
?: ClassLoader.getSystemResourceAsStream(resourcePath))
?: return annotationNames
classStream.use { input ->
ClassReader(input).accept(object : ClassVisitor(Opcodes.ASM9) {
override fun visitField(
access: Int,
name: String,
descriptor: String?,
signature: String?,
value: Any?
): FieldVisitor? {
if (name != field.name) {
return null
}
return object : FieldVisitor(Opcodes.ASM9) {
override fun visitAnnotation(descriptor: String, visible: Boolean): AnnotationVisitor? {
annotationNames += Type.getType(descriptor).className
return null
}
override fun visitTypeAnnotation(
typeRef: Int,
typePath: net.bytebuddy.jar.asm.TypePath?,
descriptor: String,
visible: Boolean
): AnnotationVisitor? {
annotationNames += Type.getType(descriptor).className
return null
}
}
}
}, ClassReader.SKIP_CODE or ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES)
}
return annotationNames
}
internal data class NullableInfo(
val nullable: Boolean,
val note: String?
)

View File

@@ -251,11 +251,83 @@ class ConfigCenterTest {
Assertions.assertEquals(2, idempotentRegistration.lastConfig().version);
}
private static String resolveConfigDoc(Class<? extends Config> type) throws Exception {
var method = java.util.Arrays.stream(ConfigCenter.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().startsWith("resolveConfigDoc"))
.filter(candidate -> candidate.getParameterCount() == 1)
.findFirst()
.orElseThrow();
method.setAccessible(true);
return (String) method.invoke(ConfigCenter.INSTANCE, type);
}
@Test
@Order(8)
void testResolveConfigDocForJavaConfig() throws Exception {
String doc = resolveConfigDoc(JavaDocConfig.class);
Assertions.assertEquals("""
Expected fields:
- port: Int
Description: WebSocket 监听端口
Example: 29600
Nullable: false (inferred from missing nullability annotation, may be unreliable)
- heartbeatInterval: Int
Description: 心跳间隔
Unit: ms
Constraint: > 0
Example: 10000
Nullable: false (inferred from missing nullability annotation, may be unreliable)
- tag: String
Description: 标签
Nullable: true
""".stripTrailing(), doc);
}
@Test
@Order(9)
void testResolveConfigDocForKotlinConfig() throws Exception {
String doc = resolveConfigDoc(KotlinDocConfig.class);
Assertions.assertEquals("""
Expected fields:
- port: Int
Description: WebSocket 监听端口
Example: 29600
Nullable: false
- heartbeatInterval: Int
Description: 心跳间隔
Unit: ms
Constraint: > 0
Example: 10000
Nullable: true
- tag: String
Description: 标签
Nullable: true
""".stripTrailing(), doc);
}
public static class TestConfig extends Config {
public String name;
public int version;
}
public static class JavaDocConfig extends Config {
@ConfigDoc(description = "WebSocket 监听端口", example = "29600")
public int port;
@ConfigDoc(description = "心跳间隔", unit = "ms", constraint = "> 0", example = "10000")
public int heartbeatInterval;
@Nullable
@ConfigDoc(description = "标签")
public String tag;
}
private static class TrackingRegistration implements ConfigRegistration<TestConfig> {
private final AtomicInteger initCount = new AtomicInteger();
private final AtomicInteger reloadCount = new AtomicInteger();

View File

@@ -0,0 +1,12 @@
package work.slhaf.partner.api.agent.runtime.config
class KotlinDocConfig : Config() {
@ConfigDoc(description = "WebSocket 监听端口", example = "29600")
var port: Int = 29600
@ConfigDoc(description = "心跳间隔", unit = "ms", constraint = "> 0", example = "10000")
var heartbeatInterval: Int? = 10000
@ConfigDoc(description = "标签")
var tag: String? = null
}