mirror of
https://github.com/slhaf/Partner.git
synced 2026-05-12 08:43:02 +08:00
refactor(config): support printing more information after init failed by ConfigDoc
This commit is contained in:
@@ -73,6 +73,11 @@
|
||||
<artifactId>kotlinx-coroutines-test</artifactId>
|
||||
<version>1.10.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jetbrains.kotlin</groupId>
|
||||
<artifactId>kotlin-reflect</artifactId>
|
||||
<version>${kotlin.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.openai</groupId>
|
||||
<artifactId>openai-java</artifactId>
|
||||
|
||||
@@ -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 = "",
|
||||
)
|
||||
|
||||
@@ -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?
|
||||
)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user