mirror of
https://github.com/slhaf/Partner.git
synced 2026-05-12 16:53:04 +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>
|
<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>
|
||||||
|
|||||||
@@ -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 = "",
|
||||||
|
)
|
||||||
|
|||||||
@@ -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);
|
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();
|
||||||
|
|||||||
@@ -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