详解Java如何创建Annotation

前言

注解是Java很强大的部分,但大多数时候我们倾向于使用而不是去创建注解。例如,在Java源代码里不难找到Java编译器处理的@Override注解,Spring框架的@Autowired注解, 或Hibernate框架使用的@Entity 注解,但我们很少看到自定义注解。虽然自定义注解是Java语言中经常被忽视的一个方面,但在开发可读性代码时它可能是非常有用的资产,同样有助于理解常见框架(如Spring或Hibernate)如何简洁地实现其目标。

在本文中,我们将介绍注解的基础知识,包括注解是什么,它们如何在示例中使用,以及如何处理它们。为了演示注解在实践中的工作原理,我们将创建一个Javascript Object Notation(JSON)序列化程序,用于处理带注解的对象并生成表示每个对象的JSON字符串。在此过程中,我们将介绍许多常见的注解块,包括Java反射框架和注解可见性问题。感兴趣的读者可以在GitHub上找到已完成的JSON序列化程序的源代码。

什么是注解?

注解是应用于Java结构的装饰器,例如将元数据与类,方法或字段相关联。这些装饰器是良性的,不会自行执行任何代码,但运行时,框架或编译器可以使用它们来执行某些操作。更正式地说,Java语言规范(JLS)第9.7节提供了以下定义:

注解是信息与程序结构相关联的标记,但在运行时没有任何影响。

请务必注意此定义中的最后一句:注解在运行时对程序没有影响。这并不是说框架不会基于注解的存在而改变其运行时行为,而是包含注解本身的程序不会改变其运行时行为。虽然这可能看起来是细微差别,但为了掌握注解的实用性,理解这一点非常重要。

例如,某个实例的字段添加了@Autowired注解,其本身不会改变程序的运行时行为:编译器只是在运行时包含注解,但注解不执行任何代码或注入任何逻辑来改变程序的正常行为(忽略注解时的预期行为)。一旦我们在运行时引入Spring框架,我们就可以在解析程序时获得强大的依赖注入(DI)功能。通过引入注解,我们已经指示Spring框架向我们的字段注入适当的依赖项。我们将很快看到(当我们创建JSON序列化程序时)注解本身并没有完成此操作,而是充当标记,通知Spring框架我们希望将依赖项注入到带注解的字段中。

Retention和Target

创建注解需要两条信息:(1)retention策略和(2)target。保留策略(retention)指定了在程序的生命周期注解应该被保留多长时间。例如,注解可以在编译时或运行时期间保留,具体取决于与注解关联的保留策略。从Java 9开始,有三种标准保留策略,总结如下:

正如我们稍后将看到的,注解保留的运行时选项是最常见的选项之一,因为它允许Java程序反射访问注解并基于存在的注解执行代码,以及访问与注解相关联的数据。请注意,注解只有一个关联的保留策略。

注解的目标(target)指定注解可以应用于哪个Java结构。例如,某些注解可能仅对方法有效,而其他注解可能对类和字段都有效。从Java 9开始,有11个标准注解目标,如下表所示:

有关这些目标的更多信息,请参见JLS的第9.7.4节。要注意,注解可以关联一个或多个目标。例如,如果字段和构造函数目标与注解相关联,则可以在字段或构造函数上使用注解。另一方面,如果注解仅关联方法目标,则将注解应用于除方法之外的任何构造都会在编译期间导致错误。

注解参数

注解也可以具有参数。这些参数可以是基本类型(例如int或double),String,类,枚举,注解或前五种类型中任何一种的数组(参见JLS的第9.6.1节)。将参数与注解相关联允许注解提供上下文信息或者可以参数化注解的处理器。例如,在我们的JSON序列化程序实现中,我们将允许一个可选的注解参数,该参数在序列化时指定字段的名称(如果没有指定名称,则默认使用字段的变量名称)。

如何创建注解?

对于我们的JSON序列化程序,我们将创建一个字段注解,允许开发人员在序列化对象时标记要转换的字段名。例如,如果我们创建汽车类,我们可以使用我们的注解来注解汽车的字段(例如品牌和型号)。当我们序列化汽车对象时,生成的JSON将包括make和model键,其中值分别代表make和model字段的值。为简单起见,我们假设此注解仅用于String类型的字段,确保字段的值可以直接序列化为字符串。

要创建这样的字段注解,我们使用@interface 关键字声明一个新的注解:

@Retention(RetentionPolicy.RUNTIME)@Target(ElementType.FIELD)public @interface JsonField {
	public String value() default "";
}

我们声明的核心是public @interface JsonField,声明带有public修饰符的注解——允许我们的注解在任何包中使用(假设在另一个模块中正确导入包)。注解声明一个String类型value的参数,默认值为空字符串。

请注意,变量名称value具有特殊含义:它定义单元素注解(JLS的第9.7.3节),并允许我们的注解用户向注解提供单个参数,而无需指定参数的名称。例如,用户可以使用@JsonField("someFieldName")并且不需要将注解声明为注解@JsonField(value = "someFieldName"),尽管后者仍然可以使用(但不是必需的)。包含默认值空字符串允许省略该值,value如果没有显式指定值,则导致值为空字符串。例如,如果用户使用表单声明上述注解@JsonField,则该value参数设置为空字符串。

注解声明的保留策略和目标分别使用@Retention和@Target注解指定。保留策略使用java.lang.annotation.RetentionPolicy枚举指定,并包含三个标准保留策略的常量。同样,指定目标为java.lang.annotation.ElementType枚举,包括11种标准目标类型中每种类型的常量。

总之,我们创建了一个名为JsonField的public单元素注解,它在运行时由JVM保留,并且只能应用于字段。此注解只有单个参数,类型String的value,默认值为空字符串。通过创建注解,我们现在可以注解要序列化的字段。

如何使用注解?

使用注解仅需要将注解放在适当的结构(注解的任何有效目标)之前。例如,我们可以创建一个Car类:

public class Car {
	@JsonField("manufacturer") private final String make;
	@JsonField private final String model;
	private final String year;
	public Car(String make, String model, String year) {
		this.make = make;
		this.model = model;
		this.year = year;
	}
	public String getMake() {
		return make;
	}
	public String getModel() {
		return model;
	}
	public String getYear() {
		return year;
	}
	@Override public String toString() {
		return year + " " + make + " " + model;
	}
}

该类使用@JsonField注解的两个主要用途:(1)具有显式值,(2)具有默认值。我们也可以使用@JsonField(value = "someName")注解一个字段,但这种样式过于冗长,并没有助于代码的可读性。因此,除非在单元素注解中包含注解参数名称可以增加代码的可读性,否则应该省略它。对于具有多个参数的注解,需要显式指定每个参数的名称来区分参数(除非仅提供一个参数,在这种情况下,如果未显式提供名称,则参数将映射到value参数)。

鉴于@JsonField注解的上述用法,我们希望将Car序列化为JSON字符串{"manufacturer":"someMake", "model":"someModel"} (注意,我们稍后将会看到,我们将忽略键manufacturer 和model在此JSON字符串的顺序)。在这之前,重要的是要注意添加@JsonField注解不会改变类Car的运行时行为。如果编译这个类,包含@JsonField注解不会比省略注解时增强类的行为。类的类文件中只是简单地记录这些注解以及参数的值。改变系统的运行时行为需要我们处理这些注解。

如何处理注解?

处理注解是通过Java反射应用程序编程接口(API)完成的。反射API允许我们编写代码来访问对象的类、方法、字段等。例如,如果我们创建一个接受Car对象的方法,我们可以检查该对象的类(即Car),并发现该类有三个字段:(1)make,(2)model和(3)year。此外,我们可以检查这些字段以发现每个字段是否都使用特定注解进行注解。

这样,我们可以遍历传递给方法的参数对象关联类的每个字段,并发现哪些字段使用@JsonField注解。如果该字段使用了@JsonField注解,我们将记录该字段的名称及其值。处理完所有字段后,我们就可以使用这些字段名称和值创建JSON字符串。

确定字段的名称需要比确定值更复杂的逻辑。如果@JsonField包含value参数的提供值(例如"manufacturer"之前使用的@JsonField("manufacturer")),我们将使用提供的字段名称。如果value参数的值是空字符串,我们知道没有显式提供字段名称(因为这是value参数的默认值),否则,显式提供了一个空字符串。后面这几种情况下,我们都将使用字段的变量名作为字段名称(例如,在private final String model声明中)。

将此逻辑组合到一个JsonSerializer类中:

public class JsonSerializer {
	public String serialize(Object object) throws JsonSerializeException {
		try {
			Class<?> objectClass = requireNonNull(object).getClass();
			Map<String, String> jsonElements = new HashMap<>();
			for (Field field: objectClass.getDeclaredFields()) {
				field.setAccessible(true);
				if (field.isAnnotationPresent(JsonField.class)) {
					jsonElements.put(getSerializedKey(field), (String) field.get(object));
				}
			}
			System.out.println(toJsonString(jsonElements));
			return toJsonString(jsonElements);
		}
		catch (IllegalAccessException e) {
			throw new JsonSerializeException(e.getMessage());
		}
	}
	private String toJsonString(Map<String, String> jsonMap) {
		String elementsString = jsonMap.entrySet() .stream() .map(entry -> """ + entry.getKey() + "":"" + entry.getValue() + """) .collect(Collectors.joining(",")); return "{
			" + elementsString + "
		}
		"; } private static String getSerializedKey(Field field) { String annotationValue = field.getAnnotation(JsonField.class).value(); if (annotationValue.isEmpty()) { return field.getName(); } else { return annotationValue; } }}

请注意,为简洁起见,已将多个功能合并到该类中。有关此序列化程序类的重构版本,请参阅codebase存储库中的此分支。我们还创建了一个异常,用于表示在serialize方法处理对象时是否发生了错误:

public class JsonSerializeException extends Exception {
	private static final long serialVersionUID = -8845242379503538623L;
	public JsonSerializeException(String message) {
		super(message);
	}
}

尽管JsonSerializer该类看起来很复杂,但它包含三个主要任务:(1)查找使用@JsonField注解的所有字段,(2)记录包含@JsonField注解的所有字段的名称(或显式提供的字段名称)和值,以及(3)将所记录的字段名称和值的键值对转换成JSON字符串。

requireNonNull(object).getClass()检查提供的对象不是null (如果是,则抛出一个NullPointerException)并获得与提供的对象关联的Class对象。并使用此对象关联的类来获取关联的字段。接下来,我们创建String到String的Map,存储字段名和值的键值对。

随着数据结构的建立,接下来遍历类中声明的每个字段。对于每个字段,我们配置为在访问字段时禁止Java语言访问检查。这是非常重要的一步,因为我们注解的字段是私有的。在标准情况下,我们将无法访问这些字段,并且尝试获取私有字段的值将导致IllegalAccessException抛出。为了访问这些私有字段,我们必须禁止对该字段的标准Java访问检查。setAccessible(boolean) 定义如下:

返回值true 表示反射对象应禁止Java语言访问检查。false 表示反射对象应强制执行Java语言访问检查。

请注意,随着Java 9中模块的引入,使用setAccessible 方法要求将包含访问其私有字段的类的包在其模块定义中声明为open。有关更多信息,请参阅 this explanation by Michał Szewczyk和Accessing Private State of Java 9 Modules by Gunnar Morling。

在获得对该字段的访问权限之后,我们检查该字段是否使用了注解@JsonField。如果是,我们确定字段的名称(通过@JsonField注解中提供的显式名称或默认名称),并在我们先前构造的map中记录名称和字段值。处理完所有字段后,我们将字段名称映射转换为JSON字符串。

处理完所有记录后,我们将所有这些字符串与逗号组合在一起。这会产生一个字符串"<fieldName1>":"<fieldValue1>","<fieldName2>":"<fieldValue2>",...。一旦这个字符串被连接起来,我们用花括号括起来,创建一个有效的JSON字符串。

为了测试这个序列化器,我们可以执行以下代码:

Car car = new Car("Ford", "F150", "2018");
JsonSerializer serializer = new JsonSerializer();
serializer.serialize(car);

输出:

{"model":"F150","manufacturer":"Ford"}

正如预期的那样,Car对象的maker和model字段已经被序列化,使用字段的名称作为键,字段的值作为值。请注意,JSON元素的顺序可能与上面看到的输出相反。发生这种情况是因为对于类的声明字段数组没有明确的排序,如getDeclaredFields文档中所述:

返回数组中的元素未排序,并且不按任何特定顺序排列。

由于此限制,JSON字符串中元素的顺序可能会有所不同。为了使元素的顺序具有确定性,我们必须自己强加排序。由于JSON对象被定义为一组无序的键值对,因此根据JSON标准,不需要强制排序。但请注意,序列化方法的测试用例应该输出{"model":"F150","manufacturer":"Ford"} 或者{"manufacturer":"Ford","model":"F150"}。

结论

Java注解是Java语言中非常强大的功能,但大多数情况下,我们使用标准注解(例如@Override)或通用框架注解(例如@Autowired),而不是开发人员。虽然不应使用注解来代替以面向对象的方式,但它们可以极大地简化重复逻辑。例如,我们可以注解每个可序列化字段而不是在接口中的方法创建一个toJsonString以及所有可以序列化的类实现此接口。它还将序列化逻辑与域逻辑分离,从域逻辑的简洁性中消除了手动序列化的混乱。

虽然在大多数Java应用程序中不经常使用自定义注解,但是对于Java语言的任何中级或高级用户来说,需要了解此功能。这个特性的知识不仅增强了开发人员的知识储备,同样也有助于理解最流行的Java框架中的常见注解。

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持我们。

(0)

相关推荐

  • 详解java nio中的select和channel

    什么是NIO? 线程在处理数据时,如果线程还处于将数据从channel读到buffer的这段时间内,线程可以去做别的事情,等数据都读到buffer了,线程再回来处理读到的数据 channel是什么? 类比流的概念.与流的区别在于 1.channel是可读可写的,但是一个流要么写要么读 2.chanel可以异步的读和写 3.数据总是从channel中读到buffer,或者从buffer中写到channel 流的读取或写一般是一次性的操作,数据在读取过程中不会有缓存,这也就意味着没有办法自己随便移动

  • 泛谈Java NIO

    前言 非阻塞IO,也被称之为新IO,它重新定义了一些概念. 1.缓冲buffer 2.通道 channel 3.通道选择器 BIO 阻塞IO,几乎所有的java程序员都会的字节流,字符流,输入流,输出流等分类就是针对BIO而言的.我们在使用BIO的时候都是建立基本的节点流然后用过滤流进行包装. 不同于BIO,NIO所有的IO操作都是通过通道读写buffer完成的.数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中. 通道 NIO的通道类似流,但是有所不同. 1.既可以从通道中读取数据,又可以

  • 全面总结java IO体系

    1.Java Io流的概念,分类,类图. 1.1 Java Io流的概念 java的io是实现输入和输出的基础,可以方便的实现数据的输入和输出操作.在java中把不同的输入/输出源(键盘,文件,网络连接等)抽象表述为"流"(stream).通过流的形式允许java程序使用相同的方式来访问不同的输入/输出源.stram是从起源(source)到接收的(sink)的有序数据. 注:java把所有的传统的流类型都放到在java io包下,用于实现输入和输出功能. 1.2 Io流的分类: 按照

  • Java IO流之字符缓冲流实例详解

    字符流: 1.加入字符缓存流,增强读取功能(readLine) 2.更高效的读取数据 BufferedReader 从字符输入流读取文本,缓冲各个字符,从而实现字符.数组和行的高效读取. FileReader:内部使用InputStreamReader,解码过程,byte->char,默认缓存大小为8k BufferReader:默认缓存大小为8k,但可以手动指定缓存大小,把数据读取到缓存中,减少每次转换过程,效率更高 /字符输入缓冲流 private static void charReade

  • 详解Java如何创建Annotation

    前言 注解是Java很强大的部分,但大多数时候我们倾向于使用而不是去创建注解.例如,在Java源代码里不难找到Java编译器处理的@Override注解,Spring框架的@Autowired注解, 或Hibernate框架使用的@Entity 注解,但我们很少看到自定义注解.虽然自定义注解是Java语言中经常被忽视的一个方面,但在开发可读性代码时它可能是非常有用的资产,同样有助于理解常见框架(如Spring或Hibernate)如何简洁地实现其目标. 在本文中,我们将介绍注解的基础知识,包括注

  • 详解Java对象创建的过程及内存布局

    一.对象的内存布局 对象头 对象头主要保存对象自身的运行时数据和用于指定该对象属于哪个类的类型指针. 实例数据 保存对象的有效数据,例如对象的字段信息,其中包括从父类继承下来的. 对齐填充 对齐填充不是必须存在的,没有特别的含义,只起到一个占位符的作用. 二.对象的创建过程 实例化一个类的对象的过程是一个典型的递归过程. 在准备实例化一个类的对象前,首先准备实例化该类的父类,如果该类的父类还有父类,那么准备实例化该类的父类的父类,依次递归直到递归到Object类. 此时,首先实例化Object类

  • 一文详解Java如何创建和销毁对象

    目录 一.介绍 二.实例构造(Instance Construction) 2.1 隐式(implicitly)构造器 2.2 无参构造器(Constructors without Arguments) 2.3 有参构造器(Constructors with Arguments) 2.4 初始化块(Initialization Blocks) 2.5 构造保障(Construction guarantee) 2.6 可见性(Visibility) 2.7 垃圾回收(Garbage collect

  • 详解Java编程中Annotation注解对象的使用方法

    注解(也被称为元数据)为我们在代码中添加信息提供了一种形式化的方法,使我们可以在稍后某个时刻非常方便地使用这些数据.   1.基本语法 Java SE5内置三种标准注解 @Override:表示当前的方法定义将覆盖超类中的方法.如果你不小心拼写错误,或者方法签名对不上被覆 盖的方法,编译器就会发出错误提示 @Deprecated:如果程序员使用了注解为它的元素,那么编译器就会发出警告信息 @SupperessWarnings:关闭不当的编译器警告信息. Java SE5内置四种元注解 @Targ

  • 详解Java是如何通过接口来创建代理并进行http请求

    场景 现在想要做这么一个事情,公司的dubbo服务都是内网的,但是提供了一个对外的出口,通过链接就能请求到对应的dubbo服务.(具体怎么做的应该就是个网关,然后将http请求转为dubbo请求,通过泛化调用去进行调用.代码看不到.)现在为了方便测试,我需要将配置的接口,通过http请求去请求对应的链接. 分析 项目的思想其实跟mybatis-spring整合包的思想差不多,都是生成代理去执行接口方法. https://www.jb51.net/article/153378.htm 项目是个简单

  • 详解Java线程的创建及休眠

    一.进程vs线程 1.进程是系统分配资源的最小单位:线程是系统调度的最小单位 2.一个进程中至少要包含一个线程 3.线程必须要依附于继承,线程是进程实质工作的一个最小单位 二.线程的创建方式 继承Thread类 实现线程的创建(2种写法) 1种写法 public class ThreadDemo03 { static class MyThread extends Thread{ @Override public void run(){ System.out.println("线程名称:"

  • 详解Java创建线程的五种常见方式

    目录 Java中如何创建线程呢? 1.显示继承Thread,重写run来指定现成的执行代码. 2.匿名内部类继承Thread,重写run来执行线程执行的代码. 3.显示实现Runnable接口,重写run方法. 4.匿名内部类实现Runnable接口,重写run方法 5.通过lambda表达式来描述线程执行的代码 [面试题]:Thread的run和start之间的区别? Thread类的具体用法 Thread类常见的一些属性 中断一个线程 1.方法一:让线程run完 2.方法二:调用interr

  • 详解Java中的八种单例创建方式

    目录 定义 使用场景 单例模式八种方式 饿汉式(静态常量) 饿汉式(静态代码块) 懒汉式(线程不安全) 懒汉式(同步方法) 懒汉式(同步代码块) 双重检查锁方式 静态内部类方式 枚举方式 总结 定义 单例设计模式,就是采取一定的方法保证在整个的软件系统中,对某个类只能存在一个对象实例,并且该类只提供一个取得其对象实例的方法(静态方法) 使用场景 对于一些需要频繁创建销毁的对象 重量级的对象 经常使用到的对象 工具类对象 数据源 session 单例模式八种方式 饿汉式(静态常量) 代码 /**

  • Java详解聊天窗口的创建流程

    目录 Swing组件 JPanel JScrollPane JScrollPane的常用构造方法 JScrollPane的方法 如何向容器中添加按钮 文本组件 文本组件的常用方法 文本框(JTextField) 文本域(JTextArea) 聊天窗口示例 小结 Swing组件 JPanel JPanel和AWT中的Panel组件使用方法基本一致,是一个无边框,不能被移动,放大,缩小,或者关闭面板,它的默认布局管理器是FlowLayout,也可以用JPanel带参数的构造函数JPanel(Layo

  • Java详解表格的创建与使用流程

    目录 Java 的表格 JTable的构造函数 表格的创建 小结 Java 的表格 表格是一个由多行,多列组成的二维显示区.Swing的JTable以及相关类提供了对这种表格的支持,程序既可以使用简单的代码创建出表格来显示二维数据,也可以开发出功能丰富的表格,还可以为表格制定各种显示外观,编辑特性. JTable的构造函数 方法描述 功能说明 JTable() 建立一个新的JTable,并使用系统默认的Model JTable(int numRows,int numColumns) 建立一个具有

随机推荐