Note/青空笔记/JavaSE 笔记(含新特性介绍)/JavaSE笔记(七).md
2023-08-31 11:30:31 +08:00

696 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Java反射和注解
**注意:**本章节涉及到JVM相关底层原理难度会有一些大。
反射就是把Java类中的各个成分映射成一个个的Java对象。即在运行状态中对于任意一个类都能够知道这个类所有的属性和方法对于任意一个对象都能调用它的任意一个方法和属性。这种动态获取信息及动态调用对象方法的功能叫Java的反射机制。
简而言之,我们可以通过反射机制,获取到类的一些属性,包括类里面有哪些字段,有哪些方法,继承自哪个类,甚至还能获取到泛型!它的权限非常高,慎重使用!
## Java类加载机制
在学习Java的反射机制之前我们需要先了解一下类的加载机制一个类是如何被加载和使用的
![img](https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimg3.itboth.com%2F60%2F50%2FUrUVN3.png&refer=http%3A%2F%2Fimg3.itboth.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1637635409&t=f25ea82c853619c26897ff5b4d041d5b)
在Java程序启动时JVM会将一部分类class文件先加载并不是所有的类都会在一开始加载通过ClassLoader将类加载在加载过程中会将类的信息提取出来存放在元空间中JDK1.8之前存放在永久代同时也会生成一个Class对象存放在内存堆内存注意此Class对象只会存在一个与加载的类唯一对应
**思考:**既然说和与加载的类唯一对应那如果我们手动创建一个与JDK包名一样同时类名也保持一致那么JVM会加载这个类吗
```java
package java.lang;
public class String { //JDK提供的String类也是
public static void main(String[] args) {
System.out.println("我姓🐴我叫🐴nb");
}
}
```
我们发现,会出现以下报错:
```java
错误: 在类 java.lang.String 中找不到 main 方法, 请将 main 方法定义为:
public static void main(String[] args)
```
但是我们明明在自己写的String类中定义了main方法啊为什么会找不到此方法呢实际上这是ClassLoader的`双亲委派机制`在保护Java程序的正常运行
![img](https://img-blog.csdnimg.cn/20201217213314510.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2NvZGV5YW5iYW8=,size_16,color_FFFFFF,t_70)
实际上我们的类最开始是由BootstarpClassLoader进行加载BootstarpClassLoader用于加载JDK提供的类而我们自己编写的类实际上是AppClassLoader只有BootstarpClassLoader都没有加载的类才会让AppClassLoader来加载因此我们自己编写的同名包同名类不会被加载而实际要去启动的是真正的String类也就自然找不到`main`方法了!
```java
public class Main {
public static void main(String[] args) {
System.out.println(Main.class.getClassLoader()); //查看当前类的类加载器
System.out.println(Main.class.getClassLoader().getParent()); //父加载器
System.out.println(Main.class.getClassLoader().getParent().getParent()); //爷爷加载器
System.out.println(String.class.getClassLoader()); //String类的加载器
}
}
```
由于BootstarpClassLoader是C++编写的我们在Java中是获取不到的。
## Class对象
通过前面我们了解了类的加载同时会提取一个类的信息生成Class对象存放在内存中而反射机制其实就是利用这些存放的类信息来获取类的信息和操作类。那么如何获取到每个类对应的Class对象呢我们可以通过以下方式
```java
public static void main(String[] args) throws ClassNotFoundException {
Class<String> clazz = String.class; //使用class关键字通过类名获取
Class<?> clazz2 = Class.forName("java.lang.String"); //使用Class类静态方法forName(),通过包名.类名获取注意返回值是Class<?>
Class<?> clazz3 = new String("cpdd").getClass(); //通过实例对象获取
}
```
注意Class类也是一个泛型类只有第一种方法能够直接获取到对应类型的Class对象而以下两种方法使用了`?`通配符作为返回值,但是实际上都和第一个返回的是同一个对象:
```java
Class<String> clazz = String.class; //使用class关键字通过类名获取
Class<?> clazz2 = Class.forName("java.lang.String"); //使用Class类静态方法forName(),通过包名.类名获取注意返回值是Class<?>
Class<?> clazz3 = new String("cpdd").getClass();
System.out.println(clazz == clazz2);
System.out.println(clazz == clazz3);
```
通过比较验证了我们一开始的结论在JVM中每个类始终只存在一个Class对象无论通过什么方法获取都是一样的。现在我们再来看看这个问题
```java
public static void main(String[] args) {
Class<?> clazz = int.class; //基本数据类型有Class对象吗
System.out.println(clazz);
}
```
迷了不是每个类才有Class对象吗基本数据类型又不是类这也行吗实际上基本数据类型也有对应的Class对象反射操作可能需要用到而且我们不仅可以通过class关键字获取其实本质上是定义在对应的包装类中的
```java
/**
* The {@code Class} instance representing the primitive type
* {@code int}.
*
* @since JDK1.1
*/
@SuppressWarnings("unchecked")
public static final Class<Integer> TYPE = (Class<Integer>) Class.getPrimitiveClass("int");
/*
* Return the Virtual Machine's Class object for the named
* primitive type
*/
static native Class<?> getPrimitiveClass(String name); //C++实现并非Java定义
```
每个包装类中包括Void都有一个获取原始类型Class方法注意getPrimitiveClass获取的是原始类型并不是包装类型只是可以使用包装类来表示。
```java
public static void main(String[] args) {
Class<?> clazz = int.class;
System.out.println(Integer.TYPE == int.class);
}
```
通过对比我们发现实际上包装类型都有一个TYPE其实也就是基本类型的Class那么包装类的Class和基本类的Class一样吗
```java
public static void main(String[] args) {
System.out.println(Integer.TYPE == Integer.class);
}
```
我们发现包装类型的Class对象并不是基本类型Class对象。数组类型也是一种类型只是编程不可见因此我们可以直接获取数组的Class对象
```java
public static void main(String[] args) {
Class<String[]> clazz = String[].class;
System.out.println(clazz.getName()); //获取类名称(得到的是包名+类名的完整名称)
System.out.println(clazz.getSimpleName());
System.out.println(clazz.getTypeName());
System.out.println(clazz.getClassLoader()); //获取它的类加载器
System.out.println(clazz.cast(new Integer("10"))); //强制类型转换
}
```
### 再谈instanceof
正常情况下我们使用instanceof进行类型比较
```java
public static void main(String[] args) {
String str = "";
System.out.println(str instanceof String);
}
```
它可以判断一个对象是否为此接口或是类的实现或是子类,而现在我们有了更多的方式去判断类型:
```java
public static void main(String[] args) {
String str = "";
System.out.println(str.getClass() == String.class); //直接判断是否为这个类型
}
```
如果需要判断是否为子类或是接口/抽象类的实现,我们可以使用`asSubClass()`方法:
```java
public static void main(String[] args) {
Integer i = 10;
i.getClass().asSubclass(Number.class); //当Integer不是Number的子类时会产生异常
}
```
### 获取父类信息
通过`getSuperclass()`方法我们可以获取到父类的Class对象
```java
public static void main(String[] args) {
Integer i = 10;
System.out.println(i.getClass().getSuperclass());
}
```
也可以通过`getGenericSuperclass()`获取父类的原始类型的Type
```java
public static void main(String[] args) {
Integer i = 10;
Type type = i.getClass().getGenericSuperclass();
System.out.println(type);
System.out.println(type instanceof Class);
}
```
我们发现Type实际上是Class类的父接口但是获取到的Type的实现并不一定是Class。
同理,我们也可以像上面这样获取父接口:
```java
public static void main(String[] args) {
Integer i = 10;
for (Class<?> anInterface : i.getClass().getInterfaces()) {
System.out.println(anInterface.getName());
}
for (Type genericInterface : i.getClass().getGenericInterfaces()) {
System.out.println(genericInterface.getTypeName());
}
}
```
***
## 创建类对象
既然我们拿到了类的定义那么是否可以通过Class对象来创建对象、调用方法、修改变量呢当然是可以的那我们首先来探讨一下如何创建一个类的对象
```java
public static void main(String[] args) throws InstantiationException, IllegalAccessException {
Class<Student> clazz = Student.class;
Student student = clazz.newInstance();
student.test();
}
static class Student{
public void test(){
System.out.println("萨日朗");
}
}
```
通过使用`newInstance()`方法来创建对应类型的实例返回泛型T注意它会抛出InstantiationException和IllegalAccessException异常那么什么情况下会出现异常呢
```java
public static void main(String[] args) throws InstantiationException, IllegalAccessException {
Class<Student> clazz = Student.class;
Student student = clazz.newInstance();
student.test();
}
static class Student{
public Student(String text){
}
public void test(){
System.out.println("萨日朗");
}
}
```
当类默认的构造方法被带参构造覆盖时会出现InstantiationException异常因为`newInstance()`只适用于默认无参构造。
```java
public static void main(String[] args) throws InstantiationException, IllegalAccessException {
Class<Student> clazz = Student.class;
Student student = clazz.newInstance();
student.test();
}
static class Student{
private Student(){}
public void test(){
System.out.println("萨日朗");
}
}
```
当默认无参构造的权限不是`public`时会出现IllegalAccessException异常表示我们无权去调用默认构造方法。在JDK9之后不再推荐使用`newInstance()`方法了,而是使用我们接下来要介绍到的,通过获取构造器,来实例化对象:
```java
public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
Class<Student> clazz = Student.class;
Student student = clazz.getConstructor(String.class).newInstance("what's up");
student.test();
}
static class Student{
public Student(String str){}
public void test(){
System.out.println("萨日朗");
}
}
```
通过获取类的构造方法(构造器)来创建对象实例,会更加合理,我们可以使用`getConstructor()`方法来获取类的构造方法同时我们需要向其中填入参数也就是构造方法需要的类型当然我们这里只演示了。那么当访问权限不是public的时候呢
```java
public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
Class<Student> clazz = Student.class;
Student student = clazz.getConstructor(String.class).newInstance("what's up");
student.test();
}
static class Student{
private Student(String str){}
public void test(){
System.out.println("萨日朗");
}
}
```
我们发现当访问权限不足时会无法找到此构造方法那么如何找到非public的构造方法呢
```java
Class<Student> clazz = Student.class;
Constructor<Student> constructor = clazz.getDeclaredConstructor(String.class);
constructor.setAccessible(true); //修改访问权限
Student student = constructor.newInstance("what's up");
student.test();
```
使用`getDeclaredConstructor()`方法可以找到类中的非public构造方法但是在使用之前我们需要先修改访问权限在修改访问权限之后就可以使用非public方法了这意味着反射可以无视权限修饰符访问类的内容
***
## 调用类的方法
我们可以通过反射来调用类的方法(本质上还是类的实例进行调用)只是利用反射机制实现了方法的调用,我们在包下创建一个新的类:
```java
package com.test;
public class Student {
public void test(String str){
System.out.println("萨日朗"+str);
}
}
```
这次我们通过`forName(String)`来找到这个类并创建一个新的对象:
```java
public static void main(String[] args) throws ReflectiveOperationException {
Class<?> clazz = Class.forName("com.test.Student");
Object instance = clazz.newInstance(); //创建出学生对象
Method method = clazz.getMethod("test", String.class); //通过方法名和形参类型获取类中的方法
method.invoke(instance, "what's up"); //通过Method对象的invoke方法来调用方法
}
```
通过调用`getMethod()`方法我们可以获取到类中所有声明为public的方法得到一个Method对象我们可以通过Method对象的`invoke()`方法返回值就是方法的返回值因为这里是void返回值为null来调用已经获取到的方法注意传参。
我们发现利用反射之后在一个对象从构造到方法调用没有任何一处需要引用到对象的实际类型我们也没有导入Student类整个过程都是反射在代替进行操作使得整个过程被模糊了过多的使用反射会极大地降低后期维护性。
同构造方法一样当出现非public方法时我们可以通过反射来无视权限修饰符获取非public方法并调用现在我们将`test()`方法的权限修饰符改为private
```java
public static void main(String[] args) throws ReflectiveOperationException {
Class<?> clazz = Class.forName("com.test.Student");
Object instance = clazz.newInstance(); //创建出学生对象
Method method = clazz.getDeclaredMethod("test", String.class); //通过方法名和形参类型获取类中的方法
method.setAccessible(true);
method.invoke(instance, "what's up"); //通过Method对象的invoke方法来调用方法
}
```
Method和Constructor都和Class一样他们存储了方法的信息包括方法的形式参数列表返回值方法的名称等内容我们可以直接通过Method对象来获取这些信息
```java
public static void main(String[] args) throws ReflectiveOperationException {
Class<?> clazz = Class.forName("com.test.Student");
Method method = clazz.getDeclaredMethod("test", String.class); //通过方法名和形参类型获取类中的方法
System.out.println(method.getName()); //获取方法名称
System.out.println(method.getReturnType()); //获取返回值类型
}
```
当方法的参数为可变参数时我们该如何获取方法呢实际上我们在之前就已经提到过可变参数实际上就是一个数组因此我们可以直接使用数组的class对象表示
```java
Method method = clazz.getDeclaredMethod("test", String[].class);
```
反射非常强大尤其是我们提到的越权访问但是请一定谨慎使用别人将某个方法设置为private一定有他的理由如果实在是需要使用别人定义为private的方法就必须确保这样做是安全的在没有了解别人代码的整个过程就强行越权访问可能会出现无法预知的错误。
***
## 修改类的属性
我们还可以通过反射访问一个类中定义的成员字段也可以修改一个类的对象中的成员字段值,通过`getField()`方法来获取一个类定义的指定字段:
```java
public static void main(String[] args) throws ReflectiveOperationException {
Class<?> clazz = Class.forName("com.test.Student");
Object instance = clazz.newInstance();
Field field = clazz.getField("i"); //获取类的成员字段i
field.set(instance, 100); //将类实例instance的成员字段i设置为100
Method method = clazz.getMethod("test");
method.invoke(instance);
}
```
在得到Field之后我们就可以直接通过`set()`方法为某个对象设定此属性的值比如上面我们就为instance对象设定值为100当访问private字段时同样可以按照上面的操作进行越权访问
```java
public static void main(String[] args) throws ReflectiveOperationException {
Class<?> clazz = Class.forName("com.test.Student");
Object instance = clazz.newInstance();
Field field = clazz.getDeclaredField("i"); //获取类的成员字段i
field.setAccessible(true);
field.set(instance, 100); //将类实例instance的成员字段i设置为100
Method method = clazz.getMethod("test");
method.invoke(instance);
}
```
现在我们已经知道反射几乎可以把一个类的老底都给扒出来任何属性任何内容都可以被反射修改无论权限修饰符是什么那么如果我的字段被标记为final呢现在在字段`i`前面添加`final`关键字,我们再来看看效果:
```java
private final int i = 10;
```
这时当字段为final时就修改失败了当然通过反射可以直接将final修饰符直接去除去除后就可以随意修改内容了我们来尝试修改Integer的value值
```java
public static void main(String[] args) throws ReflectiveOperationException {
Integer i = 10;
Field field = Integer.class.getDeclaredField("value");
Field modifiersField = Field.class.getDeclaredField("modifiers"); //这里要获取Field类的modifiers字段进行修改
modifiersField.setAccessible(true);
modifiersField.setInt(field,field.getModifiers()&~Modifier.FINAL); //去除final标记
field.setAccessible(true);
field.set(i, 100); //强行设置值
System.out.println(i);
}
```
我们可以发现反射非常暴力就连被定义为final字段的值都能强行修改几乎能够无视一切阻拦。我们来试试看修改一些其他的类型
```java
public static void main(String[] args) throws ReflectiveOperationException {
List<String> i = new ArrayList<>();
Field field = ArrayList.class.getDeclaredField("size");
field.setAccessible(true);
field.set(i, 10);
i.add("测试"); //只添加一个元素
System.out.println(i.size()); //大小直接变成11
i.remove(10); //瞎移除都不带报错的,淦
}
```
实际上整个ArrayList体系由于我们的反射操作导致被破坏因此它已经无法正常工作了
再次强调,在进行反射操作时,必须注意是否安全,虽然拥有了创世主的能力,但是我们不能滥用,我们只能把它当做一个不得已才去使用的工具!
***
## 自定义ClassLoader加载类
我们可以自己手动将class文件加载到JVM中吗先写好我们定义的类
```java
package com.test;
public class Test {
public String text;
public void test(String str){
System.out.println(text+" > 我是测试方法!"+str);
}
}
```
通过javac命令手动编译一个.class文件
```shell
nagocoler@NagodeMacBook-Pro HelloWorld % javac src/main/java/com/test/Test.java
```
编译后得到一个class文件我们把它放到根目录下然后编写一个我们自己的ClassLoader因为普通的ClassLoader无法加载二进制文件因此我们编写一个自己的来让它支持
```java
//定义一个自己的ClassLoader
static class MyClassLoader extends ClassLoader{
public Class<?> defineClass(String name, byte[] b){
return defineClass(name, b, 0, b.length); //调用protected方法支持载入外部class文件
}
}
public static void main(String[] args) throws IOException {
MyClassLoader classLoader = new MyClassLoader();
FileInputStream stream = new FileInputStream("Test.class");
byte[] bytes = new byte[stream.available()];
stream.read(bytes);
Class<?> clazz = classLoader.defineClass("com.test.Test", bytes); //类名必须和我们定义的保持一致
System.out.println(clazz.getName()); //成功加载外部class文件
}
```
现在我们就将此class文件读取并解析为Class了现在我们就可以对此类进行操作了注意我们无法在代码中直接使用此类型因为它是我们直接加载的我们来试试看创建一个此类的对象并调用其方法
```java
try {
Object obj = clazz.newInstance();
Method method = clazz.getMethod("test", String.class); //获取我们定义的test(String str)方法
method.invoke(obj, "哥们这瓜多少钱一斤?");
}catch (Exception e){
e.printStackTrace();
}
```
我们来试试看修改成员字段之后,再来调用此方法:
```java
try {
Object obj = clazz.newInstance();
Field field = clazz.getField("text"); //获取成员变量 String text;
field.set(obj, "华强");
Method method = clazz.getMethod("test", String.class); //获取我们定义的test(String str)方法
method.invoke(obj, "哥们这瓜多少钱一斤?");
}catch (Exception e){
e.printStackTrace();
}
```
通过这种方式我们就可以实现外部加载甚至是网络加载一个类只需要把类文件传递即可这样就无需再将代码写在本地而是动态进行传递不仅可以一定程度上防止源代码被反编译只是一定程度上想破解你代码有的是方法而且在更多情况下我们还可以对byte[]进行加密,保证在传输过程中的安全性。
***
## 注解
其实我们在之前就接触到注解了,比如`@Override`表示重写父类方法(当然不加效果也是一样的,此注解在编译时会被自动丢弃)注解本质上也是一个类,只不过它的用法比较特殊。
注解可以被标注在任意地方,包括方法上、类名上、参数上、成员属性上、注解定义上等,就像注释一样,它相当于我们对某样东西的一个标记。而与注释不同的是,注解可以通过反射在运行时获取,注解也可以选择是否保留到运行时。
### 预设注解
JDK预设了以下注解作用于代码
- @Override - 检查(仅仅是检查,不保留到运行时)该方法是否是重写方法。如果发现其父类,或者是引用的接口中并没有该方法时,会报编译错误。
- @Deprecated - 标记过时方法。如果使用该方法,会报编译警告。
- @SuppressWarnings - 指示编译器去忽略注解中声明的警告(仅仅编译器阶段,不保留到运行时)
- @FunctionalInterface - Java 8 开始支持,标识一个匿名函数或函数式接口。
- @SafeVarargs - Java 7 开始支持,忽略任何使用参数为泛型变量的方法或构造函数调用产生的警告。
### 元注解
元注解是作用于注解上的注解,用于我们编写自定义的注解:
- @Retention - 标识这个注解怎么保存是只在代码中还是编入class文件中或者是在运行时可以通过反射访问。
- @Documented - 标记这些注解是否包含在用户文档中。
- @Target - 标记这个注解应该是哪种 Java 成员。
- @Inherited - 标记这个注解是继承于哪个注解类(默认 注解并没有继承于任何子类)
- @Repeatable - Java 8 开始支持,标识某注解可以在同一个声明上使用多次。
看了这么多预设的注解,你们肯定眼花缭乱了,那我们来看看`@Override`是如何定义的:
```java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface Override {
}
```
该注解由`@Target`限定为只能作用于方法上ElementType是一个枚举类型用于表示此枚举的作用域一个注解可以有很多个作用域。`@Retention`表示此注解的保留策略包括三种策略在上述中有写到而这里定义为只在代码中。一般情况下自定义的注解需要定义1个`@Retention`和1-n个`@Target`。
既然了解了元注解的使用和注解的定义方式,我们就来尝试定义一个自己的注解:
```java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Test {
}
```
这里我们定义一个Test注解并将其保留到运行时同时此注解可以作用于方法或是类上
```java
@Test
public class Main {
@Test
public static void main(String[] args) {
}
}
```
这样,一个最简单的注解就被我们创建了。
### 注解的使用
我们还可以在注解中定义一些属性,注解的属性也叫做成员变量,注解只有成员变量,没有方法。注解的成员变量在注解的定义中以“无形参的方法”形式来声明,其方法名定义了该成员变量的名字,其返回值定义了该成员变量的类型:
```java
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Test {
String value();
}
```
默认只有一个属性时我们可以将其名字设定为value否则我们需要在使用时手动指定注解的属性名称使用value则无需填入
```java
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Test {
String test();
}
```
```java
public class Main {
@Test(test = "")
public static void main(String[] args) {
}
}
```
我们也可以使用default关键字来为这些属性指定默认值
```java
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Test {
String value() default "都看到这里了,给个三连吧!";
}
```
当属性存在默认值时,使用注解的时候可以不用传入属性值。当属性为数组时呢?
```java
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Test {
String[] value();
}
```
当属性为数组,我们在使用注解传参时,如果数组里面只有一个内容,我们可以直接传入一个值,而不是创建一个数组:
```java
@Test("关注点了吗")
public static void main(String[] args) {
}
```
```java
public class Main {
@Test({"value1", "value2"}) //多个值时就使用花括号括起来
public static void main(String[] args) {
}
}
```
### 反射获取注解
既然我们的注解可以保留到运行时,那么我们来看看,如何获取我们编写的注解,我们需要用到反射机制:
```java
public static void main(String[] args) {
Class<Student> clazz = Student.class;
for (Annotation annotation : clazz.getAnnotations()) {
System.out.println(annotation.annotationType()); //获取类型
System.out.println(annotation instanceof Test); //直接判断是否为Test
Test test = (Test) annotation;
System.out.println(test.value()); //获取我们在注解中写入的内容
}
}
```
通过反射机制,我们可以快速获取到我们标记的注解,同时还能获取到注解中填入的值,那么我们来看看,方法上的标记是不是也可以通过这种方式获取注解:
```java
public static void main(String[] args) throws NoSuchMethodException {
Class<Student> clazz = Student.class;
for (Annotation annotation : clazz.getMethod("test").getAnnotations()) {
System.out.println(annotation.annotationType()); //获取类型
System.out.println(annotation instanceof Test); //直接判断是否为Test
Test test = (Test) annotation;
System.out.println(test.value()); //获取我们在注解中写入的内容
}
}
```
无论是方法、类、还是字段,都可以使用`getAnnotations()`方法(还有几个同名的)来快速获取我们标记的注解。
所以说呢这玩意学来有啥用丝毫get不到这玩意的用处。其实不是现阶段你们还体会不到注解带来的快乐在接触到Spring和SpringBoot等大型框架后就能感受到注解带来的魅力了。