From 188b5e8b530914dcc118b4eecc32df9bef135b19 Mon Sep 17 00:00:00 2001 From: slhafzjw Date: Sat, 4 Apr 2026 00:33:25 +0800 Subject: [PATCH] refactor(config): support printing more information after init failed by ConfigDoc --- Partner-Framework/pom.xml | 5 + .../api/agent/runtime/config/ConfigCenter.kt | 72 +++++++++++++- .../api/agent/runtime/config/reflect.kt | 97 +++++++++++++++++++ .../runtime/config/ConfigCenterTest.java | 72 ++++++++++++++ .../agent/runtime/config/KotlinDocConfig.kt | 12 +++ 5 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 Partner-Framework/src/main/java/work/slhaf/partner/api/agent/runtime/config/reflect.kt create mode 100644 Partner-Framework/src/test/java/work/slhaf/partner/api/agent/runtime/config/KotlinDocConfig.kt diff --git a/Partner-Framework/pom.xml b/Partner-Framework/pom.xml index 7aff8c26..b7687a5b 100644 --- a/Partner-Framework/pom.xml +++ b/Partner-Framework/pom.xml @@ -73,6 +73,11 @@ kotlinx-coroutines-test 1.10.2 + + org.jetbrains.kotlin + kotlin-reflect + ${kotlin.version} + com.openai openai-java diff --git a/Partner-Framework/src/main/java/work/slhaf/partner/api/agent/runtime/config/ConfigCenter.kt b/Partner-Framework/src/main/java/work/slhaf/partner/api/agent/runtime/config/ConfigCenter.kt index 5947ba45..1d7c6e3b 100644 --- a/Partner-Framework/src/main/java/work/slhaf/partner/api/agent/runtime/config/ConfigCenter.kt +++ b/Partner-Framework/src/main/java/work/slhaf/partner/api/agent/runtime/config/ConfigCenter.kt @@ -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).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): 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, + field: Field, + kotlinProperty: KProperty1? + ): String { + val configDoc = field.getAnnotation(ConfigDoc::class.java) + ?: kotlinProperty?.annotations?.filterIsInstance()?.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 { 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 = "", +) diff --git a/Partner-Framework/src/main/java/work/slhaf/partner/api/agent/runtime/config/reflect.kt b/Partner-Framework/src/main/java/work/slhaf/partner/api/agent/runtime/config/reflect.kt new file mode 100644 index 00000000..4239401a --- /dev/null +++ b/Partner-Framework/src/main/java/work/slhaf/partner/api/agent/runtime/config/reflect.kt @@ -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, + field: Field, + kotlinProperty: KProperty1? +): 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, field: Field): Set { + val annotationNames = linkedSetOf() + 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? +) + diff --git a/Partner-Framework/src/test/java/work/slhaf/partner/api/agent/runtime/config/ConfigCenterTest.java b/Partner-Framework/src/test/java/work/slhaf/partner/api/agent/runtime/config/ConfigCenterTest.java index 51ca9dad..5a0aa571 100644 --- a/Partner-Framework/src/test/java/work/slhaf/partner/api/agent/runtime/config/ConfigCenterTest.java +++ b/Partner-Framework/src/test/java/work/slhaf/partner/api/agent/runtime/config/ConfigCenterTest.java @@ -251,11 +251,83 @@ class ConfigCenterTest { Assertions.assertEquals(2, idempotentRegistration.lastConfig().version); } + private static String resolveConfigDoc(Class 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 { private final AtomicInteger initCount = new AtomicInteger(); private final AtomicInteger reloadCount = new AtomicInteger(); diff --git a/Partner-Framework/src/test/java/work/slhaf/partner/api/agent/runtime/config/KotlinDocConfig.kt b/Partner-Framework/src/test/java/work/slhaf/partner/api/agent/runtime/config/KotlinDocConfig.kt new file mode 100644 index 00000000..f495bebe --- /dev/null +++ b/Partner-Framework/src/test/java/work/slhaf/partner/api/agent/runtime/config/KotlinDocConfig.kt @@ -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 +}