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

@@ -73,6 +73,11 @@
<artifactId>kotlinx-coroutines-test</artifactId> <artifactId>kotlinx-coroutines-test</artifactId>
<version>1.10.2</version> <version>1.10.2</version>
</dependency> </dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-reflect</artifactId>
<version>${kotlin.version}</version>
</dependency>
<dependency> <dependency>
<groupId>com.openai</groupId> <groupId>com.openai</groupId>
<artifactId>openai-java</artifactId> <artifactId>openai-java</artifactId>

View File

@@ -5,12 +5,17 @@ import org.slf4j.LoggerFactory
import work.slhaf.partner.api.agent.runtime.exception.AgentLaunchFailedException import work.slhaf.partner.api.agent.runtime.exception.AgentLaunchFailedException
import work.slhaf.partner.api.common.support.DirectoryWatchSupport import work.slhaf.partner.api.common.support.DirectoryWatchSupport
import java.io.IOException import java.io.IOException
import java.lang.reflect.Field
import java.lang.reflect.Modifier
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ExecutorService import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors import java.util.concurrent.Executors
import kotlin.reflect.KProperty1
import kotlin.reflect.full.memberProperties
import kotlin.reflect.jvm.javaField
object ConfigCenter : AutoCloseable { object ConfigCenter : AutoCloseable {
@@ -86,11 +91,64 @@ object ConfigCenter : AutoCloseable {
if (defaultConfig != null) { if (defaultConfig != null) {
(registration as ConfigRegistration<Config>).init(defaultConfig) (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?) { private fun handleUpsert(thisDir: Path, context: Path?) {
if (context == null || !Files.isRegularFile(context) || !isJsonFile(context)) { if (context == null || !Files.isRegularFile(context) || !isJsonFile(context)) {
return return
@@ -193,3 +251,15 @@ interface ConfigRegistration<T : Config> {
fun onReload(config: T) {} fun onReload(config: T) {}
fun defaultConfig(): 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); 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 static class TestConfig extends Config {
public String name; public String name;
public int version; 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 static class TrackingRegistration implements ConfigRegistration<TestConfig> {
private final AtomicInteger initCount = new AtomicInteger(); private final AtomicInteger initCount = new AtomicInteger();
private final AtomicInteger reloadCount = 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
}