20230710 8.1. 注解

使用注解

注解是那些插入到源代码中使用其他工具可以对其进行处理的标签。这些工具可以在源码层次上进行操作,或者可以处理编译器在其中放置了注解的类文件

注解不会改变程序的编译方式。Java 编译器对于包含注解和不包含注解的代码会生成相同的虚拟机指令

为了能够受益于注解,你需要选择一个 处理工具 ,然后向你的处理工具可以理解的代码中插入注解,之后运用该处理工具处理代码

注解的使用范围还是很广泛 ,并且这种广泛性让人乍一看会觉得有些杂乱无章

注解简介

在 Java 中,注解是当作一个 修饰符 来使用的,它被置于被注解项之前,中间没有分号 (修饰符就是诸如 publicstatic 之类的关键词 )

每一个注解的名称前面都加上了 @ 符号,这有点类似于 Javadoc 的注释。然而, Javadoc 注释出现在 /** ... */ 定界符的内部,而注解是代码的一部份

@Test 注解自身并不会做任何事情,它需要工具支持才会有用

注解可以定义成包含 元素 的形式,这些元素可以被读取这些注解的工具去处理。每个注解都必须通过一个 注解接口 进行定义。这些接口中的方法与注解中的元素相对应

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MyTest {
    long timeout() default 0L;

    // ...
}

@interface 声明了一个真正的 Java 接口。处理注解的工具将接受那些实现了这个注解接口的对象。这类工具可以调用 timeout 方法来获取某个特定 MyTest 注解的 timeout 元素

注解 TargetRetention 是元注解

使用定义在 AnnotatedElement 接口中的 getAnnotation 方法。MethodConstructorFieldClassPackage 这些类都实现了这个接口

注解可以在运行时处理,也可以在源码级别上对它们进行处理,也可以在字节码级别上进行处理

java.lang.reflect.AnnotatedElement 方法名称 方法声明 描述
isAnnotationPresent default boolean isAnnotationPresent(Class<? extends Annotation> annotationClass) 如果该项具有给定类型的注解,则返回 true
getAnnotation <T extends Annotation> T getAnnotation(Class<T> annotationClass); 获得给定类型的注解,如果该项不具有这样的注解, 返回 null
getAnnotationsByType default <T extends Annotation> T[] getAnnotationsByType(Class<T> annotationClass) 获得某个可重复注解类型的所有注解,或者返回长度为 0 的数组
getAnnotations Annotation[] getAnnotations(); 获得作用于该项的所有注解,包括继承而来的注解。如果没有出现任何注解,那么将返回一个长度为 0 的数组
getDeclaredAnnotations Annotation[] getDeclaredAnnotations(); 获得为该项声明的所有注解,不包含继承而来的注解。如果没有出现任何注解,那么将返回一个长度为 0 的数组

注解语法

注解接口

注解是由注解接口来定义的:

modifiers @interface AnnotationName 
{
    elementDeclaration1 
    elementDeclaration2
    ...
}

每个元素声明都具有下面这种形式:

type elementName();
type elementName() default value;

所有的注解接口都隐式地扩展自 java.lang.annotation.Annotation 接口。这个接口是一个常规接口,不是一个注解接口

无法扩展注解接口,也从来不用提供那些实现了注解接口的类

注解元素的类型为下列之一:

  • 基本类型( intshortlongbytechardoublefloat 或者 boolean
  • String
  • Class ( 具有一个可选的类型参数,例如 Class<? extends MyClass>
  • enum 类型
  • 注解类型
  • 由前面所述类型组成的数组(由数组组成的数组不是合法的元素类型)
public @interface BugReport {
    enum Status {UNCONFIRMED, CONFIRMED, FIXED, NOTABUG}

    boolean showStopper() default false;

    String assignedTo() default "[none]";

    Class<?> testCase() default Void.class;

    Status status() default Status.UNCONFIRMED;

    Reference ref() default @Reference(); // an annotation type

    String[] reportedBy();
}
java.lang.annotation.Annotation 方法名称 方法声明 描述
annotationType Class<? extends Annotation> annotationType(); 返回 Class 对象,它用于描述该注解对象的注解接口。注意 :调用注解对象上的 getClass 方法可以返回真正的类,而不是接口
equals boolean equals(Object obj); 如果 other 是一个实现了与该注解对象相同的注解接口的对象,并且如果该对象和
hashCode int hashCode(); 返回一个与 equals 方法兼容、由注解接口名以及元素值衍生而来的散列码
toString String toString(); 返回 个包含注解接口名以及元素值的字符串表示,例如,@BugReport (assignedTo=[none], severity=0)

注解

每个注解都具有下面这种格式:

@AnnotationName(elementName1=value1, elementName2=value2, . . . )

元素的顺序无关紧要

如果某个元素的值并未指定,那么就使用声明的默认值

默认值并不是和注解存储在一起的;相反地,它们是动态计算而来的

有两个特殊的快捷方式可以用来简化注解:

  • 如果没有指定元素,要么是因为注解中没有任何元素,要么是因为所有元素都使用默认值,那么你就不需要使用圆括号了。这样的注解又称为 标记注解

    @BugReport
    
  • 另外一种快捷方式是 单值注解 。如果一个元素具有特殊的名字 value ,并且没有指定其他元素,那么你就可以忽略掉这个元素名以及等号

    public @interface ActionListenerFor {
        String value();
    }
    
    @ActionListenerFor("yellowButton")
    

因为注解是由编译器计算而来的,因此,所有元素值必须是编译期常量

一个项可以有多个不同的注解

如果注解的作者将其声明为可重复的,那么你就可以在同一个项上多次重复使用同一个注解

警告:一个注解元素永远不能设置为 null ,甚至不允许其默认值为 null 。这样在实际应用中会相当不方便。你必须使用其他的默认值,例如 "" 或者 Void.class

如果元素值是一个数组,那么要将它的值用括号括起来:

@BugReport(reportedBy = {"Harry", "Carl"})

如果该元素具有单值,那么可以忽略这些括号:

@BugReport(reportedBy = "Harry") // 等同于 @BugReport(reportedBy = {"Harry"})

既然一个注解元素可以是另一个注解,那么就可以创建出任意复杂的注解。例如:

@BugReport(ref = @Reference(id="123456"), ...)

在注解中引入循环依赖是一种错误。例如,因为 BugReport 具有一个注解类型为 Reference 的元素,所以 Reference 就不能再拥有一个类型为 BugReport 的元素

注解各类声明

注解可以出现在许多地方,这些地方可以分为两类:声明类型用法

声明注解可以出现在下列声明处:

  • 类(包括 enum )
  • 接口(包括注解接口)
  • 方法
  • 构造器
  • 实例域(包括 enum 常量)
  • 局部变量
  • 参数变量
  • 类型参数

对于类和接口,需要将注解放置在 classinterface 关键词的前面:

@Entity public class User { ... }

对于变量,需要将它们放置在类型的前面:

@SuppressWarnings ("unchecked") List<User> users = ... ; 
public User getUser(@Param("id") String userId)

泛化类或方法中的类型参数可以像下面这样被注解:

public class Cache<@Immutable V> { . . . }

包是在文件 package-info.java 中注解的,该文件只包含以注解先导的包语句

/** 
	Package-level Javadoc 
*/ 
@GPL(version="3") 
package com.horstmann.corejava; 
import org.gnu.GPL ;

注意: 对局部变量的注解只能在源码级别上进行处理。类文件并不描述局部变量。因此,所有的局部变量注解在编译完一个类的时候就会被遗弃掉。同样地,对包的注解不能在源码级别之外存在

注解类型用法

声明注解提供了正在被声明的项的相关信息:

public User getUser(@NonNull String user)

就断言 user 参数不为空

注意@NonNull 注解是 Checker Framework 的一部分 。通过使用这个框架,可以在程序中包含断言,例如某个参数不为空,或者某个 String 包含一个正则表达式。然后,静态分析工具将检查在给定的源代码段中这些断言是否有效

类型用法注解可以出现在下面的位置:

  • 与泛化类型引元一起使用:List<@NonNull String> , Comparator.<@NonNull String> reverseOrder()
  • 数组中的任何位置:
    • @NonNull String[][] wordswords[i][j] 不为 null
    • String @NonNull [][] wordswords 不为 null
    • String [] @NonNull [] wordswords[i] 不为 null
  • 与超类和实现接口一起使用: class Warning extends @Localized Message
  • 与构造器调用一起使用: new @Localized String ( . . . )
  • 与强制转型和 instanceof 检查一起使用: (@Localized String) text , if (text instanceof @Localized String) (这些注解只供外部工具使用,它们对强制转型 instanceof 检查不会产生任何影响)
  • 与异常规约一起使用: public String read() throws @Localized IOException
  • 与通配符和类型边界一起使用: List<@Localized ? extends Message> , List<? extends @Localized Message>
  • 与方法和构造器引用一起使用:@Localized Message::getText

有多种类型位置是不能被注解的:

@NonNull String.class // ERROR: Cannot annotate class literal
import java.lang.@NonNull String; // ERROR: Cannot annotate import

可以将注解放置到诸如 privatestatic 这样的其他修饰符的前面或后面。习惯(但不是必需)的做法,是将类型用法注解放置到其他修饰符的后面,将声明注解放置到其他修饰符的前面:

private @NonNull String text; // Annotates the type use 
@Id private String userId; // Annotates the variable

在注解一个记录构建时,可以将注解应用于生成的域、getter 方法和构造器参数上。

注解的作者需要指定特定的注解可以出现在哪里。

注解 this

public class Point {
    public boolean equals(@ReadOnly Object other) {
        // do something
        return true;
    }
}

处理这个注解的工具在看到下面的调用时

p.equals(q)

就会推理出 q 没有被修改过。但是 p 呢?当该方法被调用时, this 变量是绑定到 p 的。但是 this 从来都没有被声明过,因此你
无法注解它

可以用一种很少用的语法变体来声明它,这样你就可以添加注解了:

public boolean equals(@ReadOnly Point this, @ReadOnly Object other) {
    // do something
    return true;
}

每个实例方法的第一个隐式参数就是 this

第一个参数被称为接收器参数,它必须被命名为 this ,而它的类型就是要构建的类

注意:你只能为方法而不能为构造器提供接收器参数。从概念上讲,构造器中的 this 引用在构造器没有执行完之前还不是给定类型的对象。所以,放置在构造器上的注解描述的是被构建的对象的属性

传递给 内部类构造器 的是另一个不同的隐藏参数, 对其外围类对象的引用。你也可以让这个参数显式化:

public class Sequence {
    private int from;
    private int to;

    class IteratorImpl implements Iterator<Integer> {

        private int current;

        public IteratorImpl(@ReadOnly Sequence Sequence.this) {
            this.current = Sequence.this.from;
        }
        
    }

}

标准注解

注解接口 应用场合 目的
Deprecated 全部 将项标记为过时的
SuppressWarnings 除了包和注解之外的所有情况 阻止某个给定类型的警告信息
SafeVarargs 方法和构造器 断言 varargs 参数可安全使用
Override 方法 检查该方法是否覆盖了某一个超类方法
Serial 方法 检查该方法是不是正确序列化的方法
FunctionalInterface 接口 将接口标记为只有一个抽象方法的函数式接口
Generated 全部 用于标记已生成的源代码。 它还可用于将用户编写的代码与单个文件中生成的代码区分开来
Target 注解 指明可以应用这个注解的那些项
Retention 注解 指明这个注解可以保留多久
Documented 注解 指明这个注解应该包含在注解项的文档中
Inherited 注解 指明当这个注解应用于一个类的时候,能够自动被它的子类继承
Repeatable 注解 指明这个注解可以在同一个项上应用多次

用于编译的注解

@Deprecated 注解可以被添加到任何不再鼓励使用的项上。所以,当你使用一个已过时的项时, 编译器将会发出警告。这个注解与 Javadoc 标签 @deprecated 具有同等功效

@SuppressWarnings 注解会告知编译器阻止特定类型的警告信息

@SuppressWarnings("unchecked")

@Override 注解只能应用到方法上。编译器会检查具有这种注解的方法是否真正覆盖了一个来自于超类的方法

@Generated 注解的目的是供代码生成工具来使用。任何生成的源代码都可以被注解,从而与程序员提供的代码区分开。每个注解都必须包含一个表示代码生成器的唯一标识符,而日期字符串 ( ISO8601 格式) 和注释字符串是可选的

@Generated(value = "com.horstmann.beanproperty", date = "2008-01-04T12:08:56.235-0700")

元注解

@Target 元注解可以应用于一个注解,以限制该注解可以应用到哪些项上

@Target 元注解的所有可能的取值情况,它们属于枚举类型 ElementType 。可以指定任意数量的元素类型, 用括号括起来

@Target 注解的元素类型

元素类型 注解适用场合
ANNOTATION_TYPE 注解类型声明
PACKAGE
TYPE 类(包括 enum )及接口 (包括注解类型)
METHOD 方法
CONSTRUCTOR 构造器
FIELD 成员域(包括 enum 常量)
PARAMETER 方法或构造器参数
LOCAL_VARIABLE 局部变量
TYPE_PARAMETER 类型参数
TYPE_USE 类型用法

一条没有 @Target 限制的注解可以应用于任何项上。编译器将检查你是否将一条注解只应用到了某个允许的项上

@Retention 元注解用于指定一条注解应该保留多长时间。其默认值是 RetentionPolicy.CLASS

用于 @Retention 注解的保留策略

保留规则 描述
SOURCE 不包括在类文件中的注解
CLASS 包括在类文件中的注解,但是虚拟机不需要将它们载入
RUNTIME 包括在类文件中的注解,并由虚拟机载入。通过反射 API 可获得它们

@Documented 元注解为像 Javadoc 这样的归档工具提供了一些提示。应该像处理其他修饰符(例如 protectedstatic )一样来处理归档注解,以实现其归档目的。其他注解的使用并不会纳入归档的范畴。如果某个注解是暂时的,那么就不应该对它们的用法进行归档

注意:将一个注解应用到它自身上是合法的。例如,@Documented 注解被它自身注解为 @Documented 。因此,针对注解的 Javadoc 文档表明了它们是否可以归档

@Inherited 元注解只能应用于对类的注解。如果一个类具有继承注解,那么它的所有子类都自动具有同样的注解。这使得创建一个与 Serializable 这样的标记接口具有相同运行方式的注解变得很容易

实际上,@Serializable 注解应该比没有任何方法的 Serializable 标记接口更适用。一个类之所以可以被序列化,是因为存在着对它的成员域进行读写的运行期支持,而不是因为任何面向对象的设计原则。注解比接口继承更擅长描述这一事实。当然了,可序列化接口
是在 JDK1.1 中产生的,远比注解出现得早

@Inherited
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Persistent {
}


@Persistent
public class Employee {
}


public class Manager extends Employee{
}


public class Main {
    public static void main(String[] args) {
        Class<Manager> managerClass = Manager.class;
        Annotation[] annotations = managerClass.getAnnotations();
        System.out.println(Arrays.toString(annotations));   // [@v2ch08.persist.Persistent()]

        Annotation[] declaredAnnotations = managerClass.getDeclaredAnnotations();
        System.out.println(Arrays.toString(declaredAnnotations));   // []

        Class<Employee> employeeClass = Employee.class;
        System.out.println(Arrays.toString(employeeClass.getAnnotations()));    // [@v2ch08.persist.Persistent()]
        System.out.println(Arrays.toString(employeeClass.getDeclaredAnnotations()));    // [@v2ch08.persist.Persistent()]
    }
}

对于 Java SE 来说,将同种类型的注解多次应用于某一项是合法的。为了向后兼容,可重复注解的实现者需要提供一个容器注解,它可以将这些重复注解存储到一个数组中

@Repeatable(TestCases.class)
@Retention(RUNTIME)
@Target(TYPE)
public @interface TestCase {
    String params() default "";
}


@Retention(RUNTIME)
@Target(TYPE)
public @interface TestCases {
    TestCase[] value();
}


@TestCase(params = "p1")
@TestCase(params = "p2")
public class MyCase {
}


public class MyMain {
    public static void main(String[] args) {
        Class<MyCase> myCaseClass = MyCase.class;
        System.out.println(Arrays.toString(myCaseClass.getAnnotations()));  // [@v2ch08.test.TestCases(value=[@v2ch08.test.TestCase(params=p1), @v2ch08.test.TestCase(params=p2)])]
        TestCase testCase = myCaseClass.getAnnotation(TestCase.class);
        System.out.println(testCase);     // null
        TestCase[] annotationsByType = myCaseClass.getAnnotationsByType(TestCase.class);
        System.out.println(Arrays.toString(annotationsByType));     // [@v2ch08.test.TestCase(params=p1), @v2ch08.test.TestCase(params=p2)]
        TestCases testCases = myCaseClass.getAnnotation(TestCases.class);
        System.out.println(testCases);     // @v2ch08.test.TestCases(value=[@v2ch08.test.TestCase(params=p1), @v2ch08.test.TestCase(params=p2)])

        System.out.println("==========");
        for (TestCase tc : testCases.value()) {
            System.out.println(tc.params());
        }
        /*
            p1
            p2
         */
    }
}

无论何时,只要用户提供了两个或更多个 @TestCase 注解,那么它们就会自动地被包装到一个 @TestCases 注解中。但是如果只有一个 @TestCase 注解,不会被包装

警告:在处理可重复注解时必须非常仔细。如果调用 getAnnotation 来查找某个可重复注解,而该注解又确实重复了,那么就会得到 null 。这是因为重复注解放包装到了容器注解中。

在这种情况下,应该调用 getAnnotationsByType 。这个调用会“遍历”容器,并给出一个重复注解的数组。如果只有一条注解,那么该数组的长度就为 1 。通过使用这个方法,你就不用操心如何处理容器注解了

源码级注解处理

注解的另一种用法是自动处理源代码以产生更多的源代码、配置文件、脚本或其他任何我们想要生成的东西

注解处理

注解处理已经被集成到了 Java 编译器中。 在编译过程中,你可以通过运行下面的命令来调用注解处理器

javac -processor ProcessorClassName1, ProcessorClassName2, ... sourceFiles

编译器会定位源文件中的注解。每个注解处理器会依次执行,并得到它表示感兴趣的注解。如果某个注解处理器创建了一个新的源文件,那么将重复执行这个处理过程。如果某次处理循环没有再产生任何新的源文件,那么就编译所有的源文件

注意:注解处理器只能产生新的源文件,它无法修改已有的源文件

注解处理器通常通过扩展 AbstractProcessor 类而实现 Processor 接口。你需要指定你的处理器支持的注解

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.SOURCE)
public @interface ToString {
    boolean includeName() default true;
}
@SupportedAnnotationTypes("v2ch08.sourceAnnotations.ToString")
@SupportedSourceVersion(SourceVersion.RELEASE_8)
public class ToStringAnnotationProcessor extends AbstractProcessor {
    @Override
    public boolean process(Set<? extends TypeElement> annotations,
                           RoundEnvironment currentRound) {
        if (annotations.size() == 0) {
            return true;
        }
        try {
            JavaFileObject sourceFile = processingEnv.getFiler().createSourceFile("sourceAnnotations.ToStrings");
            try (PrintWriter out = new PrintWriter(sourceFile.openWriter())) {
                out.println("// Automatically generated by sourceAnnotations.ToStringAnnotationProcessor");
                out.println("package sourceAnnotations;");
                out.println("public class ToStrings {");

                for (Element e : currentRound.getElementsAnnotatedWith(ToString.class)) {
                    if (e instanceof TypeElement) {
                        TypeElement te = (TypeElement) e;
                        writeToStringMethod(out, te);
                    }
                }
                out.println("    public static String toString(Object obj) {");
                out.println("        return java.util.Objects.toString(obj);");
                out.println("    }");
                out.println("}");
            }
        } catch (IOException ex) {
            processingEnv.getMessager().printMessage(Kind.ERROR, ex.getMessage());
        }
        return true;
    }

    private void writeToStringMethod(PrintWriter out, TypeElement te) {
        String className = te.getQualifiedName().toString();
        out.println("    public static String toString(" + className + " obj) {");
        ToString ann = te.getAnnotation(ToString.class);
        out.println("        StringBuilder result = new StringBuilder();");
        if (ann.includeName()) {
            out.println("        result.append(\"" + className + "\");");
        }
        out.println("        result.append(\"[\");");
        boolean first = true;
        for (Element c : te.getEnclosedElements()) {
            String methodName = c.getSimpleName().toString();
            ann = c.getAnnotation(ToString.class);
            if (ann != null) {
                if (first) {
                    first = false;
                } else {
                    out.println("        result.append(\",\");");
                }
                if (ann.includeName()) {
                    String fieldName = Introspector.decapitalize(methodName.replaceAll("^(get|is)", ""));
                    // Turn getWidth into width, isDone into done, getURL into URL
                    out.println("        result.append(\"" + fieldName + "=" + "\");");
                }
                out.println("        result.append(toString(obj." + methodName + "()));");
            }
        }
        out.println("        result.append(\"]\");");
        out.println("        return result.toString();");
        out.println("    }");
    }
}

处理器可以声明具体的注解类型或诸如 com.horstmann* 这样的通配符( com.horstmann 包及其所有子包中的注解),甚至是 * (所有注解)

在每一轮中, process 方法都会被调用一次,调用时会传递给由这一轮在所有文件中发现的所有注解构成的集,以及包含了有关当前处理轮次的信息的 RoundEnvironment 引用

语言模型 API

应该使用语言模型 API 来分析源码级的注解。与呈现类和方法的虚拟机表示形式的反射 API 不同,语言模型 API 让我们可以根据 Java 言的规则去分析 Java 程序

编译器会产生一棵树,其节点是实现了 javax.lang.model.element.Element 接口及其 TypeElementVariableElementExecutableElement 等子接口的类的实例。这些节点可以类比于编译时的 ClassField / ParameterMethod / Constructor 反射类

它是如何处理注解的:

  • RoundEnvironment 通过调用下面的方法交给你一个由特定注解标注过的所有元素构成的集

    Set<? extends Element> getElementsAnnotatedWith(Class<? extends Annotation> a);
    
  • 在源码级别上等价于 AnnotatedElement 接口的是 AnnotatedConstruct 。使用下面的方法就可以获得属于给定注解类的单条注解或重复的注解

    <A extends Annotation> A getAnnotation(Class<A> annotationType);
    <A extends Annotation> A[] getAnnotationsByType(Class<A> annotationType);
    
  • TypeElement 表示一个类或接口,而 getEnclosedElements 方法会产生一个由它的域和方法构成的列表

  • Element 上调用 getSimpleName 或在 TypeElement 上调用 getQualifiedName 会产生一个 Name 对象,它可以用 toString 方法转换为一个字符串

使用注解来生成源码

注解处理器只能产生新的类,而不能修改已有类

生成的文件并非一定要是源文件。注解处理器可以选择生成 XML 描述符、属性文件 Shell 脚本 HTML 文档等

注意:有些人建议使用注解来完成一项更繁重的体力活。如果破碎的获取器和设置器可以自动生成,那岂不是很好?但是,这些方法需要被添加到同一个类中 这需要编辑源文件而不是产生另一个文件,而这超出了注解处理器的能力范围。我们可以为实现此目的而构建另一个工具,但是这种工具超出了注解的职责范围。注解被设计为对代码项的描述,而不是添加或修改代码的指令

Lombok

字节码工程

除了在运行期或者在源码级别上对注解进行处理的,还有第 3 种可能:在字节码级别上进行处理。除非将注解在源码级别上删除,否则它们会一直存在于类文件中。类文件格式是归过档的,这种格式相当复杂,并且在没有特殊类库的情况下,处理类文件具有很大的挑
战性。ASM 库就是这样的特殊类库之一 ,可以从网站上获得

修改类文件

字节码工程的强大之处:注解可以用来向程序中添加一些指示,而字节码编辑工具则可以提取这些指示,然后修改虚拟机指令

在加载时修改字节码

更吸引人的做法是将字节码工程延迟到载入时,即类加载器加载类的时候

设备 ( instrumentation ) API 提供了一个安装字节码转换器的挂钩。不过,必须在程序的 main 方法调用之前安装这个转换器。通过定义一个代理,即被加载用来按照某种方式监视程序的一个类库,就可以处理这个需求。代理代码可以在 premain 方法中执行初始化

修改过的字节码并不保存成文件。相反地,转换器只是将它们返回,以加载到虚拟机中。换句话说,这项技术实现的是“即时( just in time )”字节码修改

posted @   流星<。)#)))≦  阅读(0)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
· 上周热点回顾(2.24-3.2)
历史上的今天:
2020-01-13 20200113 SpringBoot整合MyBatis
点击右上角即可分享
微信分享提示