当Spring遇到递归注解

552 阅读8分钟

好久不见,想来也是有一阵没有写东西了。什么?你问我《webflux的探索与实战》是不是被我给忘了?没有没有,不要担心,我可没有忘喔。

但是今天的话题不是那个。今天开门见山,让我们再再再再聊一聊,注解 这个小东西。

讲道理,注解真是一个神奇的东西,算下来前前后后这已经是第三篇文章了(有一篇只在公众号里)。这东西平时看起来并不起眼,但是如果用的稍微复杂一点儿,能发现的趣事儿也还是有的。

不过当然了,这么多有趣的事情并非都是Java自己贡献的,其中有很大一部分是Kotlin的功劳。

我看不懂.png

递归注解

好了,话不多说,首先让我们来看看下面这段代码:

annotation class Foo(val bar: Bar)

annotation class Bar(vararg val value: Foo)

OK,让我们先在这里打断一下。还记得前两篇有关于注解的文章的主题都是跟什么挂钩吗?是的,都跟 可重复注解 相关,实际上这次的内容也是由可重复注解引申而来的。上面这段代码的完整形象应该是这样的:

@JvmRepeatable(Bar::class)
annotation class Foo(val bar: Bar)

annotation class Bar(vararg val value: Foo)

只不过对于 "递归注解" 来讲,@JvmRepeatable 注解的存在与否,或者说对于它们是不是可重复注解,已经不再重要,因此将其简化掉了。

至于这个 @JvmRepeatable ,则是Kotlin 1.6 所提供的用来提供对Java中的可重复注解 @java.lang.annotation.Repeatable 更友好的支持的一个特性。要知道,在Kotlin1.6之前,可以说很难在Kotlin中实现一个Java中能够兼容的可重复注解。不过这些问题也伴随着其1.6的特性而被解决了。

更多有关于这方面的内容,你可以参考:

blog.jetbrains.com/kotlin/2021…

那么回到正题,先来猜一猜,上面这段代码能够正常使用吗?

思考.jpg





答案是可以的。让我们来看看这段代码:

@JvmRepeatable(Bar::class)
annotation class Foo(val bar: Bar)

annotation class Bar(vararg val value: Foo)

@Foo(Bar())
@Foo(Bar())
class Tar

@OptIn(ExperimentalStdlibApi::class)
fun main() {
    Tar::class.findAnnotations<Foo>().forEach {
        println(it)
    }
}

他的运行结果为:

@Foo(bar=@Bar(value=[]))
@Foo(bar=@Bar(value=[]))

这也就是说明,对于Kotlin来讲,上述的这种递归注解就像普通的嵌套注解一样是可以使用的。

那么问题到底在哪儿呢?问题在于,这种写法在Java中有着截然不同的结果。

首先,在Java中定义类似于上述结构的注解如下:

@interface JBar {
    JFoo foo();
}

@interface JFoo {
    JBar[] value() default {};
}

那么让我们再来猜一猜,这段代码是否存在问题呢?

思考2.jpg











实际上,按照正常逻辑来讲,你肯定会和我当时的第一感觉一样,认为这并没有问题(当然,如果你能够知道其中的问题所在,那么说明你的见识真的很广),但是实际上,这段代码就在写完后,便会得到编译异常:

Cyclic annotation element type

这个提示翻译过来是说“循环注解元素类型”,也就是我所指的“递归注解”。

洪东尼.jpg

那么为什么会出现这个问题呢?首先来看看 stackoverflow 上的一篇帖子:

stackoverflow.com/questions/7…

实际上根据这篇帖子来看,早在10年前的2011年就有人提出了这个问题,这个人的问题是:

I want to create tree structure with annotation

@Retention(RetentionPolicy.RUNTIME)
public @interface MyNode {
     String name();
     MyNode next() default null;
}

I wonder why it is not allowed and how can I make something like it?

看样子,这个人是打算通过注解来定义一个类似于树形结构的“节点”注解,但是却发现Java编译器不允许这种递归注解的存在,因而提问如何解决这种问题。

从下面的回复者中,有一个人 Sean Patrick Floyd 提出的观点很好:

  1. Annotations are compile-time constants.
  2. Annotation members can only be compile-time constants (Strings, primitives, enums, annotations, class literals).
  3. Anything that references itself can't be a constant, so an annotation can't reference itself.

这里他指出了三个注解的定义:

  1. 注解是编译时常量
  2. 注解的所有属性也都只能是编译时常量(比如字符串、基本数据类型、枚举、其他注解、Class)
  3. 任何引用自己的东西都不是常量,因此注解也不能引用自己

这些有关注解能有什么、应该有什么之类的定义,实际上在 JLS 中有着很详细的定义(比如可以参考 JLS-9.6),比如说 JLS-9.6.1 中提到了对于一个注解中函数(即注解的属性)的返回值都应该是什么类型的描述。

原文:

The return type of a method declared in an annotation type must be one of the following, or a compile-time error occurs:

  • A primitive type
  • String
  • Class or an invocation of Class (§4.5)
  • An enum type
  • An annotation type
  • An array type whose component type is one of the preceding types (§10.1).

以及这段话下面紧接着的有关递归注解的编译错误的示例,有兴趣的可以自己去翻看翻看。

哄豆哒.jpg


那么,说了这么多,我想大家应该已经充分理解了或者至少是了解到了为什么Java编译器中不允许递归注解了。

那么一个比较有趣的问题便出现了,想必大家已经知道了,Kotlin是支持对于嵌套注解的编译的。回到文章最开始的地方,然后稍作修改(给属性加上默认值):

@JvmRepeatable(Bar::class)
annotation class Foo(val bar: Bar = Bar())

annotation class Bar(vararg val value: Foo = [])

对于上述这种注解结构,尽管我们不能直接在Java中进行定义,但是Kotlin可以定义并编译,而且这种注解在编译后是可以在Java中正常使用的,比如:

@Foo(bar = @Bar)
@Foo(bar = @Bar(@Foo))
@Foo(bar = @Bar({@Foo, @Foo(bar = @Bar(@Foo))}))
public class JTar {

}

由此可见,尽管Java编译器不允许递归注解,但是对于JVM来讲这并不是什么大问题。

原来是这样啊.jpg

对于这个现象,Spring的开发者也表示十分有趣。在 spring-framework 的仓库中的 issues#28012 中,有两位大佬分别表达了他们对Kotlin支持递归注解这件事的态度:

philwebb: The exception is coming from Spring Framework's meta-annotation processing code. It's interesting that Kotlin allows this
....
....
....
....
I guess Framework will need an additional guard.
sbrannen: Yes, if Kotlin allows that to be compiled (which I also find a bit strange), I suppose we should at least guard against infinite recursion in our annotation processing.
....

什么?你问我为什么会对这个issue这么熟悉?嘿嘿... 说来惭愧,因为这个issue就是我自己在不久前提交的。

理直气壮.jpg

当Spring遇上递归注解

书接上文,经由Kotlin编译后的递归注解是可以在Java中正常编译的,那么我为什么会给Spring提issue呢?难道就只是为了与他们分享这则趋势吗?那肯定不会,否则这会显得我像个白痴。

那么让我们继续,来看看当Spring遇上这种递归注解会发生什么事情。

首先我们直接创建一个基于Kotlin的Spring项目(当然,你也可以在Java项目中引用一个编译了递归注解的Kotlin子项目,但是这里为了方便,直接使用Kotlin的Spring项目了),然后编写一个非常简单的例子。让我们以 maven 为例,项目的pom.xml配置为:

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.6.3</version>
</parent>

<!-- 其他省略... -->

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-autoconfigure</artifactId>
    </dependency>
    <dependency>
        <groupId>org.jetbrains.kotlin</groupId>
        <artifactId>kotlin-reflect</artifactId>
    </dependency>
    <dependency>
        <groupId>org.jetbrains.kotlin</groupId>
        <artifactId>kotlin-stdlib-jdk8</artifactId>
    </dependency>
</dependencies>

<!-- 其他省略... -->

然后,编写一个启动类 Main.kt:

@SpringBootApplication
class Main

fun main(args: Array<String>) {
    runApplication<Main>(*args)
}

@JvmRepeatable(Bar::class)
annotation class Foo(val value: Bar = Bar())
annotation class Bar(vararg val value: Foo)


@Component
class HelloComponent {

    @Foo
    @Foo
    fun hi() = "hello"
}

这样就完成了,很简单对不对。点击运行,不出意外的话,你就会看到非常壮观、令程序员们熟悉又头疼的异常信息:堆栈溢出

傻眼.jpg

OHNO.gif

下面节选堆栈溢出中的一部分日志:

Caused by: java.lang.StackOverflowError
	at org.springframework.util.ConcurrentReferenceHashMap$Segment.getReference(ConcurrentReferenceHashMap.java:495)
	at org.springframework.util.ConcurrentReferenceHashMap.getReference(ConcurrentReferenceHashMap.java:265)
	at org.springframework.util.ConcurrentReferenceHashMap.get(ConcurrentReferenceHashMap.java:235)
	at java.util.concurrent.ConcurrentMap.computeIfAbsent(ConcurrentMap.java:323)
	at org.springframework.core.annotation.AttributeMethods.forAnnotationType(AttributeMethods.java:252)
	at org.springframework.core.annotation.AnnotationTypeMapping.<init>(AnnotationTypeMapping.java:96)
	at org.springframework.core.annotation.AnnotationTypeMappings.addIfPossible(AnnotationTypeMappings.java:112)
	at org.springframework.core.annotation.AnnotationTypeMappings.addAllMappings(AnnotationTypeMappings.java:75)
	at org.springframework.core.annotation.AnnotationTypeMappings.<init>(AnnotationTypeMappings.java:68)
	at org.springframework.core.annotation.AnnotationTypeMappings.<init>(AnnotationTypeMappings.java:46)
	at org.springframework.core.annotation.AnnotationTypeMappings$Cache.createMappings(AnnotationTypeMappings.java:245)
	at java.util.concurrent.ConcurrentMap.computeIfAbsent(ConcurrentMap.java:324)
	at org.springframework.core.annotation.AnnotationTypeMappings$Cache.get(AnnotationTypeMappings.java:241)
	at org.springframework.core.annotation.AnnotationTypeMappings.forAnnotationType(AnnotationTypeMappings.java:199)
	at org.springframework.core.annotation.AnnotationTypeMappings.forAnnotationType(AnnotationTypeMappings.java:182)
	at org.springframework.core.annotation.AnnotationTypeMappings.forAnnotationType(AnnotationTypeMappings.java:169)
	at org.springframework.core.annotation.AnnotationTypeMapping.computeSynthesizableFlag(AnnotationTypeMapping.java:343)
	at org.springframework.core.annotation.AnnotationTypeMapping.<init>(AnnotationTypeMapping.java:106)
	at org.springframework.core.annotation.AnnotationTypeMappings.addIfPossible(AnnotationTypeMappings.java:112)
	at org.springframework.core.annotation.AnnotationTypeMappings.addAllMappings(AnnotationTypeMappings.java:75)
	at org.springframework.core.annotation.AnnotationTypeMappings.<init>(AnnotationTypeMappings.java:68)
	at org.springframework.core.annotation.AnnotationTypeMappings.<init>(AnnotationTypeMappings.java:46)

可以从日志中看到,造成堆栈溢出的主要罪魁祸首是Spring内部的注解处理器:AnnotationTypeMappings。至于具体的细节大家可以自己去通过上述示例进行尝试并跟踪堆栈来看看到底为什么,在这里我就简单的总结一下就是它会解析这个注解以及注解的各个属性,而由于上述示例中注解 FooBar是相互嵌套的,因此注解处理器就懵圈了然后开始原地打转,最终导致了堆栈溢出。

issues#28012 中,他们对我的回复中也提到了:

....
....
In any case, we'll investigate what we can do to avoid throwing exceptions (or resulting in a stack overflow) if recursive annotations are encountered while processing annotations in Spring Framework.

大概意思就是:无论如何,我们将研究在Spring Framework中处理注解时,如果遇到递归注解,我们可以做些什么来避免抛出异常(或导致堆栈溢出)。

他们将这个问题标记为了 type:bug并定于在Spring 5.3.16里程碑中对其进行修复。

尾声

好了,这次的小插曲就分享到这里了,如果你能看到这里,那么非常感谢!

最后,隆重感谢 DeepL 等各大翻译软件对于我这种没文化的人在与外国友人进行交流以及查阅外文资料这些事上的鼎力协助!

感谢.jpg