Spring Framework核心技术:验证、数据绑定和类型转换

https://docs.spring.io/spring-framework/reference/core/validation.html

将验证视为业务逻辑有其利弊,Spring提供了一种既不排斥也不包含它们的验证和数据绑定设计。具体来说,验证不应与web层绑定,并且应该容易本地化,还应该能够插入任何可用的验证器。考虑到这些因素,Spring提供了一个既基础又极其适用于应用程序每一层的Validator规范。

数据绑定对于让用户输入动态绑定到应用程序的域模型(或用来处理用户输入的任何对象)非常有用。Spring 提供了名副其实的 DataBinder 来完成这项工作。ValidatorDataBinder 构成了验证包,主要用于但不限于 web 层。

BeanWrapper 是 Spring 框架中的一个基本概念,在很多地方都有使用。然而,你可能并不需要直接使用 BeanWrapper

Spring 的 DataBinder 和更低级别的 BeanWrapper 都使用 PropertyEditorSupport 实现来解析和格式化属性值。PropertyEditorPropertyEditorSupport 类型是 JavaBeans 规范的一部分Spring 的 core.convert 包提供了通用的类型转换功能,以及一个更高级的 format 包,用于格式化 UI 字段值。你可以使用这些包作为 PropertyEditorSupport 实现的更简单替代方案。

Spring 通过设置基础设施和适配 Spring 自己的 Validator 接口的适配器来支持 Java Bean 验证。应用程序可以全局启用一次 Bean 验证,并专门使用它来完成所有验证需求。在 web 层,应用程序可以进一步为每个 DataBinder 注册控制器本地的 Spring Validator 实例,这对于插入自定义验证逻辑非常有用。

使用 Spring 的 Validator 接口进行验证

Spring 提供了一个 Validator 接口,可以使用它来验证对象。Validator 接口通过使用 Errors 对象工作,这样在验证过程中,验证器可以将验证失败报告给 Errors 对象。

考虑以下小型数据对象的示例:

public class Person {

	private String name;
	private int age;

	// the usual getters and setters...
}

下一个示例通过实现org.springframework.validation.Validator接口的以下两个方法为Person类提供验证行为:

  • supports(Class):这个 Validator 能验证提供的 Class 的实例吗?
  • validate(Object, org.springframework.validation.Errors):验证给定的对象,并在出现验证错误时,将那些错误注册到给定的Errors 对象中。

实现一个 Validator 相当直接,特别是当你知道 Spring 框架也提供了 ValidationUtils 帮助类时。以下示例实现了针对 Person 实例的 Validator

public class PersonValidator implements Validator {

	/**
	 * This Validator validates only Person instances
	 */
	public boolean supports(Class clazz) {
		return Person.class.equals(clazz);
	}

	public void validate(Object obj, Errors e) {
		ValidationUtils.rejectIfEmpty(e, "name", "name.empty");
		Person p = (Person) obj;
		if (p.getAge() < 0) {
			e.rejectValue("age", "negativevalue");
		} else if (p.getAge() > 110) {
			e.rejectValue("age", "too.darn.old");
		}
	}
}

ValidationUtils 类上,静态的 rejectIfEmpty(..) 方法被用来拒绝 name 属性,如果它是 null 或空字符串。

虽然实现一个单独的 Validator 类来验证富对象中的每个嵌套对象是可能的,但最好还是将每个嵌套对象的类的验证逻辑封装在其自己的 Validator 实现中。一个简单的“富”对象示例可能是由两个 String 属性(第一个和第二个名字)和一个复杂的 Address 对象组成的 CustomerAddress 对象可能独立于 Customer 对象使用,因此已经实现了一个独立的 AddressValidator。如果你希望你的 CustomerValidator 重用 AddressValidator 类中的逻辑,而不是采用复制粘贴的方式,你可以在你的 CustomerValidator 中依赖注入或实例化一个 AddressValidator,如下例所示:

public class CustomerValidator implements Validator {

	private final Validator addressValidator;

	public CustomerValidator(Validator addressValidator) {
		if (addressValidator == null) {
			throw new IllegalArgumentException("The supplied [Validator] is " +
				"required and must not be null.");
		}
		if (!addressValidator.supports(Address.class)) {
			throw new IllegalArgumentException("The supplied [Validator] must " +
				"support the validation of [Address] instances.");
		}
		this.addressValidator = addressValidator;
	}

	/**
	 * This Validator validates Customer instances, and any subclasses of Customer too
	 */
	public boolean supports(Class clazz) {
		return Customer.class.isAssignableFrom(clazz);
	}

	public void validate(Object target, Errors errors) {
		ValidationUtils.rejectIfEmptyOrWhitespace(errors, "firstName", "field.required");
		ValidationUtils.rejectIfEmptyOrWhitespace(errors, "surname", "field.required");
		Customer customer = (Customer) target;
		try {
			errors.pushNestedPath("address");
			ValidationUtils.invokeValidator(this.addressValidator, customer.getAddress(), errors);
		} finally {
			errors.popNestedPath();
		}
	}
}

验证错误被报告给传递给验证器的 Errors 对象。在 Spring Web MVC 的情况下,你可以使用 <spring:bind/> 标签来检查错误消息,但你也可以自己检查 Errors 对象。

验证器也可以本地调用,以立即验证给定的对象,不涉及绑定过程。从 6.1 版本开始,通过新的 Validator.validateObject(Object) 方法简化了这一过程,该方法现在默认可用,返回一个简单的 Errors 表示,可以进行检查:通常调用 hasErrors() 或新的 failOnError 方法将错误摘要消息转换为异常(例如 validator.validateObject(myObject).failOnError(IllegalArgumentException::new))。

解析代码为错误消息

在上一节展示的示例中,我们拒绝了 nameage 字段。如果我们想使用 MessageSource 输出错误消息,我们可以使用在拒绝字段时提供的 error code(在这个例子中是 ‘name’ 和 ‘age’)。当你调用(直接或间接地,例如通过使用 ValidationUtils 类)rejectValueErrors 接口的其它 reject 方法之一时,底层实现不仅注册了你传入的代码,还注册了一些额外的错误代码。MessageCodesResolver 决定了 Errors 接口注册哪些错误代码。默认情况下,使用 DefaultMessageCodesResolver,它(例如)不仅注册了你给出的代码的消息,还注册了包含你传递给 reject 方法的字段名的消息。因此,如果你使用 rejectValue("age", "too.darn.old") 拒绝一个字段,除了 too.darn.old 代码之外,Spring 还会注册 too.darn.old.agetoo.darn.old.age.int(第一个包含字段名,第二个包含字段的类型)。这样做是为了方便开发人员定位错误消息。

数据绑定

数据绑定对于将用户输入绑定到目标对象非常有用,其中用户输入是一个以属性路径为键的映射,遵循 JavaBeans 约定。DataBinder 是支持这一功能的主要类,它提供了两种绑定用户输入的方法:

  • 构造函数绑定 - 将用户输入绑定到公共数据构造函数,从用户输入中查找构造函数参数值。
  • 属性绑定 - 将用户输入绑定到setter方法,将来自用户输入的键与目标对象结构的属性进行匹配。

你可以同时应用构造函数和属性绑定,或者只使用其中一种。

构造函数绑定

要使用构造函数绑定:

  1. 创建一个目标对象为 nullDataBinder
  2. targetType 设置为目标类。
  3. 调用 construct

目标类应该有一个公共构造函数或一个带参数的非公共构造函数。如果有多个构造函数,则使用默认构造函数(如果存在)。

默认情况下,构造函数参数名称用于查找参数值,但你可以配置一个 NameResolver。Spring MVC 和 WebFlux 都依赖它来允许通过在构造函数参数上使用 @BindParam 注解来自定义绑定值的名称。

根据需要进行类型转换以将用户输入转换为所需类型。如果构造函数参数是一个对象,它将以相同的方式递归构建,但通过嵌套的属性路径。这意味着构造函数绑定既创建目标对象,也创建它包含的任何对象。

绑定和转换错误反映在 DataBinderBindingResult 中。如果目标成功创建,那么在调用 construct 之后,target 被设置为创建的实例。

使用 BeanWrapper 进行属性绑定

org.springframework.beans 包遵循 JavaBeans 标准。JavaBean 是一个具有默认无参数构造函数的类,并遵循一种命名约定,例如,一个名为 bingoMadness 的属性将有一个 setter 方法 setBingoMadness(..) 和一个 getter 方法 getBingoMadness()

beans 包中一个相当重要的类是 BeanWrapper 接口及其相应的实现(BeanWrapperImpl)。BeanWrapper 提供了设置和获取属性值(单独或批量)、获取属性描述符以及查询属性以确定它们是否可读或可写的功能。此外,BeanWrapper 还支持嵌套属性,使得可以在子属性上设置属性,深度无限。BeanWrapper 还支持添加标准的 JavaBeans PropertyChangeListenersVetoableChangeListeners,无需在目标类中提供支持代码。最后但同样重要的是,BeanWrapper 提供了设置索引属性的支持。BeanWrapper 通常不被应用程序代码直接使用,而是由 DataBinderBeanFactory 使用。

BeanWrapper 的工作方式部分地由其名称所暗示:它封装了一个 bean,以便对该 bean 执行操作,例如设置和检索属性。

设置和获取基本属性与嵌套属性

设置和获取属性是通过 BeanWrappersetPropertyValuegetPropertyValue 方法的重载变体来完成的。下表展示了这些约定的一些示例:
在这里插入图片描述

以下两个示例类使用 BeanWrapper 来获取和设置属性:

public class Company {

	private String name;
	private Employee managingDirector;

	public String getName() {
		return this.name;
	}

	public void setName(String name) {
		this.name = name;
	}

	public Employee getManagingDirector() {
		return this.managingDirector;
	}

	public void setManagingDirector(Employee managingDirector) {
		this.managingDirector = managingDirector;
	}
}
public class Employee {

	private String name;

	private float salary;

	public String getName() {
		return this.name;
	}

	public void setName(String name) {
		this.name = name;
	}

	public float getSalary() {
		return salary;
	}

	public void setSalary(float salary) {
		this.salary = salary;
	}
}

以下代码片段展示了如何检索和操作实例化的 CompanyEmployee 的一些属性的示例:

BeanWrapper company = new BeanWrapperImpl(new Company());
// setting the company name..
company.setPropertyValue("name", "Some Company Inc.");
// ... can also be done like this:
PropertyValue value = new PropertyValue("name", "Some Company Inc.");
company.setPropertyValue(value);

// ok, let's create the director and tie it to the company:
BeanWrapper jim = new BeanWrapperImpl(new Employee());
jim.setPropertyValue("name", "Jim Stravinsky");
company.setPropertyValue("managingDirector", jim.getWrappedInstance());

// retrieving the salary of the managingDirector through the company
Float salary = (Float) company.getPropertyValue("managingDirector.salary");

PropertyEditor

Spring 使用 PropertyEditor 的概念来实现ObjectString之间的转换。用与对象本身不同的方式表示属性可能会很方便。例如,日期可以用人类可读的方式表示(如字符串:‘2007-14-09’),而我们仍然可以将人类可读的形式转换回原始日期(或者,更好的是,将任何以人类可读形式输入的日期转换回 Date 对象)。通过注册 java.beans.PropertyEditor 类型的自定义编辑器,可以实现这种行为。在 BeanWrapper 上注册自定义编辑器,或者在特定的 IoC 容器中注册,可以让它知道如何将属性转换为所需的类型。

在 Spring 中使用属性编辑的一些示例:

  • 在 beans 上设置属性是通过使用 PropertyEditor 实现来完成的。当你在 XML 文件中声明某个 bean 的属性时,如果该属性的值是 String 类型,Spring(如果相应属性的 setter 方法具有 Class 参数)会使用ClassEditor 尝试将参数解析为 Class 对象。
  • 在 Spring 的 MVC 框架中解析 HTTP 请求参数是通过使用各种 PropertyEditor 实现来完成的,你可以在 CommandController 的所有子类中手动绑定这些编辑器。

Spring 有许多内置的 PropertyEditor 实现,它们都位于 org.springframework.beans.propertyeditors 包中。大多数(但不是全部)默认情况下由 BeanWrapperImpl 注册。如果属性编辑器在某种程度上是可配置的,你仍然可以注册自己的变体来覆盖默认设置。下面描述了 Spring 提供的各种 PropertyEditor 实现:

  • ByteArrayPropertyEditor:字节数组的编辑器。将字符串转换为其对应的字节表示形式。默认情况下由 BeanWrapperImpl 注册。
  • ClassEditor:将表示类的字符串解析为实际的类,反之亦然。当找不到类时,会抛出 IllegalArgumentException。默认情况下,由 BeanWrapperImpl 注册。
  • CustomBooleanEditor:可定制的Boolean 属性编辑器。默认情况下,由 BeanWrapperImpl 注册,但可以通过注册自定义实例作为自定义编辑器来覆盖。
  • CustomCollectionEditor:集合的属性编辑器,将任何源 Collection 转换为给定的目标 Collection 类型。
  • CustomDateEditor:可定制的 java.util.Date 属性编辑器,支持自定义 DateFormat。默认情况下不注册。必须由用户根据需要使用适当的格式注册。
  • CustomNumberEditor:任何 Number 子类的可定制属性编辑器,例如 IntegerLongFloatDouble。默认情况下,由 BeanWrapperImpl 注册,但可以通过注册自定义实例作为自定义编辑器来覆盖
  • FileEditor:将字符串解析为 java.io.File 对象。默认情况下,由 BeanWrapperImpl 注册。
  • InputStreamEditor:单向属性编辑器,可以接受一个字符串并通过中间的 ResourceEditorResource 生成一个 InputStream,以便可以直接将 InputStream 属性设置为字符串。请注意,默认用法不会为你关闭 InputStream。默认情况下,由 BeanWrapperImpl 注册。
  • LocaleEditor:可以将字符串解析为 Locale 对象,反之亦然(字符串格式为[language]_[country]_[variant],与 LocaletoString() 方法相同)。也接受空格作为分隔符,作为下划线的替代。默认情况下,由 BeanWrapperImpl 注册。
  • PatternEditor:可以将字符串解析为 java.util.regex.Pattern 对象,反之亦然。
  • PropertiesEditor:可以将字符串(使用 java.util.Properties 类 javadoc 中定义的格式)转换为 Properties 对象。默认情况下,由 BeanWrapperImpl 注册。
  • StringTrimmerEditor:去除字符串两端空白的属性编辑器。可以选择将空字符串转换为 null 值。默认情况下不注册,必须由用户注册。
  • URLEditor:可以将 URL 的字符串表示形式解析为实际的 URL 对象。默认情况下,由 BeanWrapperImpl 注册。

Spring 使用 java.beans.PropertyEditorManager 来设置可能需要的属性编辑器的搜索路径。搜索路径还包括 sun.bean.editors,其中包括 FontColor 和大多数原始类型的 PropertyEditor 实现。还要注意的是,标准的 JavaBeans 基础设施会自动发现 PropertyEditor 类(无需你显式注册它们),如果它们与所处理的类位于相同的包中,并且具有相同的名称,只是在末尾附加了 Editor。例如,可以有以下类和包结构,这对于 SomethingEditor 类被识别并用作 Something 类型属性的 PropertyEditor 就足够了。

com
  chank
    pop
      Something
      SomethingEditor // the PropertyEditor for the Something class

也可以在这里使用标准的 BeanInfo JavaBeans 机制。以下示例使用 BeanInfo 机制显式地将一个或多个 PropertyEditor 实例与关联类的属性注册在一起:

com
  chank
    pop
      Something
      SomethingBeanInfo // the BeanInfo for the Something class

以下 Java 源代码为引用的 SomethingBeanInfo 类,将 CustomNumberEditorSomething 类的 age 属性关联起来:

public class SomethingBeanInfo extends SimpleBeanInfo {

	public PropertyDescriptor[] getPropertyDescriptors() {
		try {
			final PropertyEditor numberPE = new CustomNumberEditor(Integer.class, true);
			PropertyDescriptor ageDescriptor = new PropertyDescriptor("age", Something.class) {
				@Override
				public PropertyEditor createPropertyEditor(Object bean) {
					return numberPE;
				}
			};
			return new PropertyDescriptor[] { ageDescriptor };
		}
		catch (IntrospectionException ex) {
			throw new Error(ex.toString());
		}
	}
}

自定义 PropertyEditor

当将 bean 属性设置为字符串值时,Spring IoC 容器最终使用标准的 JavaBeans PropertyEditor 实现将这些字符串转换为属性的复杂类型。Spring 预先注册了许多自定义 PropertyEditor 实现(例如,将表示类名的字符串转换为 Class 对象)。此外,Java 的标准 JavaBeans PropertyEditor 查找机制允许为一个类命名一个 PropertyEditor,并将其放置在与它提供支持的类相同的包中,以便可以自动找到它。

如果需要注册其他自定义 PropertyEditor,有几种机制可供选择。最手动的方法,通常不方便或不推荐,是使用 ConfigurableBeanFactory 接口的 registerCustomEditor() 方法,假设你有一个 BeanFactory 引用。另一种(稍微方便一些)机制是使用一个特殊的 bean 工厂后处理器,称为 CustomEditorConfigurer。虽然你可以将 bean 工厂后处理器与 BeanFactory 实现一起使用,但 CustomEditorConfigurer 具有嵌套属性设置,因此建议将其与 ApplicationContext 一起使用,在那里你可以像部署任何其它 bean 一样部署它,并且它可以被自动检测和应用。

请注意,所有 bean 工厂和应用程序上下文都通过使用 BeanWrapper 处理属性转换,自动使用了许多内置属性编辑器。此外,ApplicationContexts 还覆盖或添加了额外的编辑器,以适合特定应用程序上下文类型的方式处理资源查找。

标准的 JavaBeans PropertyEditor 实例用于将表示为字符串的属性值转换为属性的实际复杂类型。可以使用 CustomEditorConfigurer,一个 bean 工厂后处理器,方便地为 ApplicationContext 添加对额外 PropertyEditor 实例的支持。

考虑以下示例,其中定义了一个名为 ExoticType 的用户类,以及另一个名为 DependsOnExoticType 的类,该类需要将 ExoticType 设置为属性:

package example;

public class ExoticType {

	private String name;

	public ExoticType(String name) {
		this.name = name;
	}
}

public class DependsOnExoticType {

	private ExoticType type;

	public void setType(ExoticType type) {
		this.type = type;
	}
}

当正确设置时,我们希望能够将类型属性分配为字符串,然后由 PropertyEditor 将其转换为实际的 ExoticType 实例。以下 bean 定义显示了如何设置这种关系:

<bean id="sample" class="example.DependsOnExoticType">
	<property name="type" value="aNameForExoticType"/>
</bean>

PropertyEditor 的实现可能类似于以下内容:

package example;

import java.beans.PropertyEditorSupport;

// converts string representation to ExoticType object
public class ExoticTypeEditor extends PropertyEditorSupport {

	public void setAsText(String text) {
		setValue(new ExoticType(text.toUpperCase()));
	}
}

以下示例显示了如何使用 CustomEditorConfigurer 将新的 PropertyEditor 注册到 ApplicationContext,随后就可以根据需要使用它:

<bean class="org.springframework.beans.factory.config.CustomEditorConfigurer">
	<property name="customEditors">
		<map>
			<entry key="example.ExoticType" value="example.ExoticTypeEditor"/>
		</map>
	</property>
</bean>

PropertyEditorRegistrar

另一种将属性编辑器注册到 Spring 容器的机制是创建并使用 PropertyEditorRegistrar。当你需要在几种不同的情况下使用相同的一组属性编辑器时,这个接口特别有用。你可以编写一个相应的注册表并在每种情况下重用它。PropertyEditorRegistrar 实例与一个名为 PropertyEditorRegistry 的接口协同工作,该接口由 Spring BeanWrapper(和 DataBinder)实现。当与 CustomEditorConfigurer结合使用时,PropertyEditorRegistrar 实例特别方便,它暴露了一个名为 setPropertyEditorRegistrars(..) 的属性。以这种方式添加到 CustomEditorConfigurerPropertyEditorRegistrar 实例可以轻松地与 DataBinder 和 Spring MVC 控制器共享。此外,它避免了对自定义编辑器的同步需求:预期 PropertyEditorRegistrar 会为每次 bean 创建尝试创建新鲜的 PropertyEditor 实例。

以下示例显示了如何创建自己的 PropertyEditorRegistrar 实现:

package com.foo.editors.spring;

public final class CustomPropertyEditorRegistrar implements PropertyEditorRegistrar {

	public void registerCustomEditors(PropertyEditorRegistry registry) {

		// it is expected that new PropertyEditor instances are created
		registry.registerCustomEditor(ExoticType.class, new ExoticTypeEditor());

		// you could register as many custom property editors as are required here...
	}
}

下一个示例显示了如何配置 CustomEditorConfigurer 并将我们的 CustomPropertyEditorRegistrar 的一个实例注入其中:

<bean class="org.springframework.beans.factory.config.CustomEditorConfigurer">
	<property name="propertyEditorRegistrars">
		<list>
			<ref bean="customPropertyEditorRegistrar"/>
		</list>
	</property>
</bean>

<bean id="customPropertyEditorRegistrar"
	class="com.foo.editors.spring.CustomPropertyEditorRegistrar"/>

对于使用 Spring 的 MVC Web 框架的人来说,将 PropertyEditorRegistrar 与数据绑定 Web 控制器结合使用非常方便。以下示例在实现 @InitBinder 方法时使用了 PropertyEditorRegistrar

@Controller
public class RegisterUserController {

	private final PropertyEditorRegistrar customPropertyEditorRegistrar;

	RegisterUserController(PropertyEditorRegistrar propertyEditorRegistrar) {
		this.customPropertyEditorRegistrar = propertyEditorRegistrar;
	}

	@InitBinder
	void initBinder(WebDataBinder binder) {
		this.customPropertyEditorRegistrar.registerCustomEditors(binder);
	}

	// other methods related to registering a User
}

这种 PropertyEditor 注册样式可以使代码简洁(@InitBinder 方法的实现只有一行长),并允许将常见的 PropertyEditor 注册代码封装在一个类中,然后在需要的控制器之间共享。

Spring 类型转换

core.convert 包提供了一个通用的类型转换系统。该系统定义了一个 SPI 来实现类型转换逻辑,以及一个 API 在运行时执行类型转换。在 Spring 容器中,可以使用这个系统作为 PropertyEditor 实现的替代品,将外部化的 bean 属性值字符串转换为所需的属性类型。还可以在应用程序中的任何地方使用公共 API 进行类型转换。

Converter SPI

实现类型转换逻辑的 SPI 简单且强类型,如下所示的接口定义所示:

package org.springframework.core.convert.converter;

public interface Converter<S, T> {

	T convert(S source);
}

要创建自己的转换器,请实现 Converter 接口,并将 S 参数化为要转换的原始类型,将 T 参数化为要转换的目标类型。如果需要将 S 的集合或数组转换为 T 的数组或集合,只要已经注册了委托数组或集合转换器(DefaultConversionService 默认情况下会这样做),也可以透明地应用这样的转换器。

对于每次调用 convert(S),源参数保证不为 null。如果转换失败,你的Converter 可以抛出任何未检查的异常。特别是,它应该抛出 IllegalArgumentException 来报告无效的源值。请确保的 Converter 实现是线程安全的。

为了方便起见,在 core.convert.support 包中提供了几个转换器实现。这些包括从字符串到数字和其它常见类型的转换器。以下列表显示了 StringToInteger 类,它是一个典型的 Converter 实现:

package org.springframework.core.convert.support;

final class StringToInteger implements Converter<String, Integer> {

	public Integer convert(String source) {
		return Integer.valueOf(source);
	}
}

使用ConverterFactory

当你需要集中整个类层次结构的转换逻辑时(例如,从 String 转换为 Enum 对象时),可以实现 ConverterFactory,如下所示的示例所示:

package org.springframework.core.convert.converter;

public interface ConverterFactory<S, R> {

	<T extends R> Converter<S, T> getConverter(Class<T> targetType);
}

将 S 参数化为你要转换的原始类型,将 R 参数化为定义你可以转换为的类范围的基本类型。然后实现 getConverter(Class<T>),其中 T 是 R 的子类。

StringToEnumConverterFactory 为例:

package org.springframework.core.convert.support;

final class StringToEnumConverterFactory implements ConverterFactory<String, Enum> {

	public <T extends Enum> Converter<String, T> getConverter(Class<T> targetType) {
		return new StringToEnumConverter(targetType);
	}

	private final class StringToEnumConverter<T extends Enum> implements Converter<String, T> {

		private Class<T> enumType;

		public StringToEnumConverter(Class<T> enumType) {
			this.enumType = enumType;
		}

		public T convert(String source) {
			return (T) Enum.valueOf(this.enumType, source.trim());
		}
	}
}

使用GenericConverter

当你需要一个复杂的 Converter 实现时,请考虑使用 GenericConverter 接口。与 Converter 相比,它具有更灵活但不太强类型的签名,支持在多种源类型和目标类型之间进行转换。此外,GenericConverter 提供了可以在实现转换逻辑时使用的源字段和目标字段上下文。这样的上下文允许通过字段注解或字段签名上声明的泛型信息来驱动类型转换。以下列表显示了 GenericConverter 接口的定义:

package org.springframework.core.convert.converter;

public interface GenericConverter {

	public Set<ConvertiblePair> getConvertibleTypes();

	Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType);
}

要实现 GenericConverter,请让 getConvertibleTypes() 返回支持的源→目标类型对。然后实现 convert(Object, TypeDescriptor, TypeDescriptor) 以包含转换逻辑。源 TypeDescriptor 提供了访问保存正在转换的值的源字段的途径。目标 TypeDescriptor 提供了访问将设置已转换值的目标字段的途径。

GenericConverter一个很好的例子是 一个在 Java 数组和集合之间进行转换的转换器。这样的 ArrayToCollectionConverter 会检查声明目标集合类型的字段,以解析集合的元素类型。这允许源数组中的每个元素在设置到目标字段之前被转换为集合元素类型。

因为 GenericConverter 是一个更复杂的 SPI 接口,应该只在需要时使用它。对于基本类型转换需求,首选 ConverterConverterFactory

使用ConditionalGenericConverter

有时,你希望只有在特定条件成立时才运行 Converter。例如,可能希望仅在目标字段上存在特定注解时才运行 Converter,或者可能希望仅在目标类上定义了特定方法(例如静态的 valueOf 方法)时才运行 ConverterConditionalGenericConverterGenericConverterConditionalConverter 接口的联合体,它允许你定义此类自定义匹配标准:

public interface ConditionalConverter {

	boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType);
}

public interface ConditionalGenericConverter extends GenericConverter, ConditionalConverter {
}

一个很好的 ConditionalGenericConverter 示例是 IdToEntityConverter,它在持久实体标识符和实体引用之间进行转换。这样的 IdToEntityConverter 可能只有在目标实体类型声明了静态查找方法(例如 findAccount(Long))时才匹配。你可能会在 matches(TypeDescriptor, TypeDescriptor) 的实现中执行此类查找方法检查。

ConversionService API

ConversionService 定义了在运行时执行类型转换逻辑的统一 API。Converters 通常在以下接口后面运行:

package org.springframework.core.convert;

public interface ConversionService {

	boolean canConvert(Class<?> sourceType, Class<?> targetType);

	<T> T convert(Object source, Class<T> targetType);

	boolean canConvert(TypeDescriptor sourceType, TypeDescriptor targetType);

	Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType);
}

大多数 ConversionService 实现还实现了 ConverterRegistry,它提供了用于注册转换器的 SPI。在内部,ConversionService 实现委托给其注册的转换器来执行类型转换逻辑。

core.convert.support 包中提供了一个健壮的 ConversionService 实现。GenericConversionService 是适用于大多数环境的通用实现。ConversionServiceFactory 提供了创建常见 ConversionService 配置的便捷工厂。

配置一个ConversionService

ConversionService 是一个无状态对象,旨在在应用程序启动时实例化,然后在多个线程之间共享。在 Spring 应用程序中,通常为每个 Spring 容器(或 ApplicationContext)配置一个 ConversionService 实例。Spring 会在需要执行类型转换时使用该 ConversionService。也可以将此 ConversionService 注入到任何你的 bean 中,并直接调用它。

如果没有在 Spring 中注册 ConversionService,将使用基于 PropertyEditor 的原始系统。

要在 Spring 中注册默认的 ConversionService,请添加以下带有 idconversionService 的 bean 定义:

<bean id="conversionService"
	class="org.springframework.context.support.ConversionServiceFactoryBean"/>

默认的 ConversionService 可以在字符串、数字、枚举、集合、映射和其它常见类型之间进行转换。要使用自定义转换器补充或覆盖默认转换器,请设置 converters 属性。属性值可以实现 ConverterConverterFactoryGenericConverter 接口中的任何一个。

<bean id="conversionService"
		class="org.springframework.context.support.ConversionServiceFactoryBean">
	<property name="converters">
		<set>
			<bean class="example.MyCustomConverter"/>
		</set>
	</property>
</bean>

在 Spring MVC 应用程序中使用 ConversionService 也是很常见的。

程序化使用 ConversionService

要程序化地使用 ConversionService 实例,可以像对其它任何 bean 一样注入一个引用。以下示例展示了如何操作:

@Service
public class MyService {

	private final ConversionService conversionService;

	public MyService(ConversionService conversionService) {
		this.conversionService = conversionService;
	}

	public void doIt() {
		this.conversionService.convert(...)
	}
}

对于大多数用例,可以使用指定 targetTypeconvert 方法,但它不适用于更复杂的类型,如参数化元素的集合。例如,如果想要将 Integer 列表程序化地转换为 String 列表,需要提供源和目标类型的正式定义。

幸运的是,TypeDescriptor 提供了各种选项,使操作变得简单明了,如下示例所示:

DefaultConversionService cs = new DefaultConversionService();

List<Integer> input = ...
cs.convert(input,
	TypeDescriptor.forObject(input), // List<Integer> type descriptor
	TypeDescriptor.collection(List.class, TypeDescriptor.valueOf(String.class)));

DefaultConversionService 会自动注册适用于大多数环境的转换器。这包括集合转换器、标量转换器和基本的对象到字符串转换器。可以通过使用 DefaultConversionService 类上的静态 addDefaultConverters 方法,将相同的转换器注册到任何 ConverterRegistry

值类型的转换器可以用于数组和集合,因此无需创建特定的转换器来从 S 的集合转换为 T 的集合,假设标准的集合处理是合适的。

Spring 字段格式化

core.convert 是一个通用的类型转换系统。它提供了一个统一的 ConversionService API 以及一个强类型的 Converter SPI,用于实现从一个类型到另一个类型的转换逻辑。Spring 容器使用这个系统来绑定 bean 属性值。此外,Spring 表达式语言(SpEL)和 DataBinder 也使用这个系统来绑定字段值。例如,当 SpEL 需要将 Short 强制转换为 Long 以完成 expression.setValue(Object bean, Object value) 尝试时,core.convert 系统执行强制转换。

现在考虑典型客户端环境(如 Web 或桌面应用程序)的类型转换需求。在这样的环境中,通常需要将String 转换为支持客户端提交过程,以及将其转回String 以支持视图渲染过程。此外,经常需要本地化字符串值。更通用的 core.convert Converter SPI 并没有直接解决这些格式化需求。为了直接解决这些问题,Spring 提供了一个方便的 Formatter SPI,它为客户端环境提供了一种简单而健壮的替代方案,代替 PropertyEditor 实现。

通常,当需要实现通用类型转换逻辑时,例如在 java.util.DateLong 之间进行转换,可以使用 Converter SPI。当在客户端环境(如 Web 应用程序)中工作并需要解析和打印本地化字段值时,可以使用 Formatter SPI。ConversionService 为这两个 SPI 提供了统一的类型转换 API。

Formatter SPI

实现字段格式化逻辑的 Formatter SPI 简单且类型安全。以下列表显示了 Formatter 接口定义:

package org.springframework.format;

public interface Formatter<T> extends Printer<T>, Parser<T> {
}

FormatterPrinterParser 构建块接口继承。以下列表显示了这两个接口的定义:

public interface Printer<T> {

	String print(T fieldValue, Locale locale);
}
import java.text.ParseException;

public interface Parser<T> {

	T parse(String clientValue, Locale locale) throws ParseException;
}

要创建自己的 Formatter,实现前面显示的 Formatter 接口。将 T 参数化为希望格式化的对象类型——例如,java.util.Date。实现 print() 操作以在客户端区域设置中打印 T 的实例。实现 parse() 操作以从客户端区域设置返回的格式化表示中解析 T 的实例。如果解析尝试失败,你的 Formatter 应该抛出 ParseExceptionIllegalArgumentException。注意确保你的 Formatter 实现是线程安全的。

format 子包提供了几个 Formatter 实现,以方便使用。number 包提供了 NumberStyleFormatterCurrencyStyleFormatterPercentStyleFormatter,用于使用java.text.NumberFormat 格式化 Number 对象。datetime 包提供了 DateFormatter,用于使用 java.text.DateFormat 格式化 java.util.Date 对象。

以下 DateFormatter 是一个 Formatter 实现的示例:

package org.springframework.format.datetime;

public final class DateFormatter implements Formatter<Date> {

	private String pattern;

	public DateFormatter(String pattern) {
		this.pattern = pattern;
	}

	public String print(Date date, Locale locale) {
		if (date == null) {
			return "";
		}
		return getDateFormat(locale).format(date);
	}

	public Date parse(String formatted, Locale locale) throws ParseException {
		if (formatted.length() == 0) {
			return null;
		}
		return getDateFormat(locale).parse(formatted);
	}

	protected DateFormat getDateFormat(Locale locale) {
		DateFormat dateFormat = new SimpleDateFormat(this.pattern, locale);
		dateFormat.setLenient(false);
		return dateFormat;
	}
}

注解驱动的格式化

字段格式化可以通过字段类型或注解来配置。要将注解绑定到Formatter,需要实现AnnotationFormatterFactory。下面的列表显示了AnnotationFormatterFactory接口的定义:

package org.springframework.format;

public interface AnnotationFormatterFactory<A extends Annotation> {

	Set<Class<?>> getFieldTypes();

	Printer<?> getPrinter(A annotation, Class<?> fieldType);

	Parser<?> getParser(A annotation, Class<?> fieldType);
}

要创建一个实现:

  1. 将A参数化为你希望关联格式化逻辑的字段注解类型,例如org.springframework.format.annotation.DateTimeFormat
  2. getFieldTypes()返回可以使用该注解的字段类型。
  3. getPrinter()返回一个Printer,用于打印带注解字段的值。
  4. getParser()返回一个Parser,用于解析带注解字段的客户端值。

下面这个AnnotationFormatterFactory的实现将@NumberFormat注解绑定到格式化器,允许指定数字的样式或模式:

public final class NumberFormatAnnotationFormatterFactory
		implements AnnotationFormatterFactory<NumberFormat> {

	private static final Set<Class<?>> FIELD_TYPES = Set.of(Short.class,
			Integer.class, Long.class, Float.class, Double.class,
			BigDecimal.class, BigInteger.class);

	public Set<Class<?>> getFieldTypes() {
		return FIELD_TYPES;
	}

	public Printer<Number> getPrinter(NumberFormat annotation, Class<?> fieldType) {
		return configureFormatterFrom(annotation, fieldType);
	}

	public Parser<Number> getParser(NumberFormat annotation, Class<?> fieldType) {
		return configureFormatterFrom(annotation, fieldType);
	}

	private Formatter<Number> configureFormatterFrom(NumberFormat annotation, Class<?> fieldType) {
		if (!annotation.pattern().isEmpty()) {
			return new NumberStyleFormatter(annotation.pattern());
		}
		// else
		return switch(annotation.style()) {
			case Style.PERCENT -> new PercentStyleFormatter();
			case Style.CURRENCY -> new CurrencyStyleFormatter();
			default -> new NumberStyleFormatter();
		};
	}
}

要触发格式化,可以使用@NumberFormat注解字段,如下例所示:

public class MyModel {

	@NumberFormat(style=Style.CURRENCY)
	private BigDecimal decimal;
}

格式化注解API

org.springframework.format.annotation包中存在一个可移植的格式化注解API。可以使用@NumberFormat来格式化Number字段,如DoubleLong,以及使用@DateTimeFormat来格式化java.util.Datejava.util.CalendarLong(用于毫秒时间戳)以及JSR-310 java.time

下面的例子使用@DateTimeFormat来将java.util.Date格式化为ISO日期(yyyy-MM-dd):

public class MyModel {

	@DateTimeFormat(iso=ISO.DATE)
	private Date date;
}

FormatterRegistry SPI

FormatterRegistry是一个用于注册格式化器和转换器的SPI。FormattingConversionService是适用于大多数环境的FormatterRegistry的实现。可以通过编程或声明性地将此变体配置为Spring bean,例如使用FormattingConversionServiceFactoryBean。因为此实现还实现了ConversionService,所以可以直接配置它以与Spring的DataBinder和Spring Expression Language(SpEL)一起使用。

下面的列表显示了FormatterRegistry SPI:

package org.springframework.format;

public interface FormatterRegistry extends ConverterRegistry {

	void addPrinter(Printer<?> printer);

	void addParser(Parser<?> parser);

	void addFormatter(Formatter<?> formatter);

	void addFormatterForFieldType(Class<?> fieldType, Formatter<?> formatter);

	void addFormatterForFieldType(Class<?> fieldType, Printer<?> printer, Parser<?> parser);

	void addFormatterForFieldAnnotation(AnnotationFormatterFactory<? extends Annotation> annotationFormatterFactory);
}

如前面的列表所示,可以通过字段类型或注解来注册格式化器。

FormatterRegistry SPI允许集中配置格式化规则,而不是在控制器中重复这样的配置。例如,可能希望强制所有日期字段以某种特定方式进行格式化,或者希望带有特定注解的字段以某种特定方式进行格式化。有了共享的FormatterRegistry,可以定义一次这些规则,然后在需要格式化时应用它们。

FormatterRegistrar SPI

FormatterRegistrar是一个SPI,用于通过FormatterRegistry注册格式化器和转换器。下面的列表显示了它的接口定义:

package org.springframework.format;

public interface FormatterRegistrar {

	void registerFormatters(FormatterRegistry registry);
}

FormatterRegistrar在为给定的格式化类别(如日期格式化)注册多个相关的转换器和格式化器时非常有用。在声明性注册不足的情况下,它也很有用,例如,当格式化器需要根据与其自身不同的特定字段类型进行索引,或者注册Printer/Parser对时。

配置全局日期和时间格式

默认情况下,未使用@DateTimeFormat注解的日期和时间字段会使用DateFormat.SHORT样式从字符串转换。可以通过定义自己的全局格式来更改这一点。

要做到这一点,请确保Spring不注册默认的格式化器。相反,使用以下帮助手动注册格式化器:

  • org.springframework.format.datetime.standard.DateTimeFormatterRegistrar
  • org.springframework.format.datetime.DateFormatterRegistrar

如,以下Java配置注册了全局的yyyyMMdd格式:

@Configuration
public class AppConfig {

	@Bean
	public FormattingConversionService conversionService() {

		// Use the DefaultFormattingConversionService but do not register defaults
		DefaultFormattingConversionService conversionService =
			new DefaultFormattingConversionService(false);

		// Ensure @NumberFormat is still supported
		conversionService.addFormatterForFieldAnnotation(
			new NumberFormatAnnotationFormatterFactory());

		// Register JSR-310 date conversion with a specific global format
		DateTimeFormatterRegistrar dateTimeRegistrar = new DateTimeFormatterRegistrar();
		dateTimeRegistrar.setDateFormatter(DateTimeFormatter.ofPattern("yyyyMMdd"));
		dateTimeRegistrar.registerFormatters(conversionService);

		// Register date conversion with a specific global format
		DateFormatterRegistrar dateRegistrar = new DateFormatterRegistrar();
		dateRegistrar.setFormatter(new DateFormatter("yyyyMMdd"));
		dateRegistrar.registerFormatters(conversionService);

		return conversionService;
	}
}

如果你更喜欢基于XML的配置,可以使用FormattingConversionServiceFactoryBean。下面的例子展示了如何做到这一点:

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="
		http://www.springframework.org/schema/beans
		https://www.springframework.org/schema/beans/spring-beans.xsd">

	<bean id="conversionService" class="org.springframework.format.support.FormattingConversionServiceFactoryBean">
		<property name="registerDefaultFormatters" value="false" />
		<property name="formatters">
			<set>
				<bean class="org.springframework.format.number.NumberFormatAnnotationFormatterFactory" />
			</set>
		</property>
		<property name="formatterRegistrars">
			<set>
				<bean class="org.springframework.format.datetime.standard.DateTimeFormatterRegistrar">
					<property name="dateFormatter">
						<bean class="org.springframework.format.datetime.standard.DateTimeFormatterFactoryBean">
							<property name="pattern" value="yyyyMMdd"/>
						</bean>
					</property>
				</bean>
			</set>
		</property>
	</bean>
</beans>

Java Bean验证

Spring框架提供了对Java Bean验证API的支持。

Bean验证概述

Bean验证通过约束声明和元数据为Java应用程序提供了一种通用的验证方式。要使用它,可以使用声明性验证约束注解域模型属性,然后在运行时强制执行这些约束。有内置的约束,也可以定义自己的自定义约束。

考虑以下示例,它显示了一个带有两个属性的简单PersonForm模型:

public class PersonForm {
	private String name;
	private int age;
}

Bean Validation允许声明约束,如下例所示:

public class PersonForm {

	@NotNull
	@Size(max=64)
	private String name;

	@Min(0)
	private int age;
}

然后,Bean Validation验证器根据声明的约束验证此类的实例。

配置Bean Validation提供程序(Provider)

Spring提供了对Bean Validation API的全面支持,包括将Bean Validation提供程序作为Spring bean进行引导。这允许你在应用程序中需要验证的任何地方注入jakarta.validation.ValidatorFactoryjakarta.validation.Validator

你可以使用LocalValidatorFactoryBean将默认的Validator配置为Spring bean,如下例所示:

import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;

@Configuration
public class AppConfig {

	@Bean
	public LocalValidatorFactoryBean validator() {
		return new LocalValidatorFactoryBean();
	}
}

前面示例中的基本配置通过使用其默认的引导机制触发bean验证初始化。预期在类路径中存在Bean Validation提供程序,例如Hibernate Validator,并自动检测到。

注入Jakarta Validator

LocalValidatorFactoryBean实现了jakarta.validation.ValidatorFactoryjakarta.validation.Validator,因此如果您更喜欢直接使用Bean Validation API,可以注入后者的引用来应用验证逻辑,如下例所示:

import jakarta.validation.Validator;

@Service
public class MyService {

	@Autowired
	private Validator validator;
}

注入Spring Validator

除了实现jakarta.validation.Validator之外,LocalValidatorFactoryBean还适配了org.springframework.validation.Validator,因此如果你的bean需要Spring Validation API,可以注入后者的引用。

例如:

import org.springframework.validation.Validator;

@Service
public class MyService {

	@Autowired
	private Validator validator;
}

当作为org.springframework.validation.Validator使用时,LocalValidatorFactoryBean会调用底层的jakarta.validation.Validator,然后将ContraintViolations适配为FieldErrors,并将它们注册到传入validate方法的Errors对象中。

配置自定义约束

每个bean验证约束由两部分组成:

  • 一个声明约束及其可配置属性的@Constraint注解。
  • 实现jakarta.validation.ConstraintValidator接口的代码,实现了约束的行为。

要将声明与实现关联起来,每个@Constraint注解都会引用一个相应的ConstraintValidator实现类。在运行时,当在你的域模型中遇到约束注解时,ConstraintValidatorFactory会实例化所引用的实现。

默认情况下,LocalValidatorFactoryBean配置了一个SpringConstraintValidatorFactory,它使用Spring来创建ConstraintValidator实例。这使你的自定义ConstraintValidator能够像其它任何Spring bean一样从依赖注入中受益。

以下示例显示了一个自定义@Constraint声明,后面是使用Spring进行依赖注入的关联ConstraintValidator实现:

@Target({ElementType.METHOD, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy=MyConstraintValidator.class)
public @interface MyConstraint {
}
import jakarta.validation.ConstraintValidator;

public class MyConstraintValidator implements ConstraintValidator {

	@Autowired;
	private Foo aDependency;

	// ...
}

如前面的示例所示,ConstraintValidator实现可以像其它任何Spring bean一样使用@Autowired注入其依赖项。

Spring驱动的方法验证

可以通过MethodValidationPostProcessor bean定义将Bean Validation的方法验证功能集成到Spring上下文中:

import org.springframework.validation.beanvalidation.MethodValidationPostProcessor;

@Configuration
public class AppConfig {

	@Bean
	public MethodValidationPostProcessor validationPostProcessor() {
		return new MethodValidationPostProcessor();
	}
}

要符合Spring驱动的方法验证条件,目标类需要用Spring的@Validated注解进行标注,该注解也可以选择性地声明要使用的验证组。

方法验证依赖于目标类周围的AOP代理,无论是用于接口上方法的JDK动态代理还是CGLIB代理。使用代理存在某些限制。此外,请始终使用代理类上的方法及访问器;直接字段访问将不起作用。

Spring MVC和WebFlux对相同的底层方法验证有内置支持,但不需要AOP。

方法验证异常

默认情况下,会引发jakarta.validation.ConstraintViolationException,并附带由jakarta.validation.Validator返回的一组ConstraintViolation。作为替代方案,也可以改为引发MethodValidationException,并将ConstraintViolation适配为MessageSourceResolvable错误。要启用此功能,请设置以下标志:

import org.springframework.validation.beanvalidation.MethodValidationPostProcessor;

@Configuration
public class AppConfig {

	@Bean
	public MethodValidationPostProcessor validationPostProcessor() {
		MethodValidationPostProcessor processor = new MethodValidationPostProcessor();
		processor.setAdaptConstraintViolations(true);
		return processor;
	}
}

MethodValidationException 包含一个 ParameterValidationResult 列表,这个列表按照方法参数将错误进行分组。每一个 ParameterValidationResult 都暴露出一个 MethodParameter,参数值,以及一个从 ConstraintViolations 转换过来的 MessageSourceResolvable 错误列表。对于使用 @Valid 注解的方法参数,以及字段和属性上的级联违规,ParameterValidationResultParameterErrors,它实现了 org.springframework.validation.Errors,并将验证错误作为 FieldError 暴露出来。

自定义验证错误

通过配置的 MessageSource 和特定于地区和语言的资源包,可以将适应的 MessageSourceResolvable 错误转换为错误消息并显示给用户。本部分将提供一个示例进行说明。

给定以下的类声明:

record Person(@Size(min = 1, max = 10) String name) {
}

@Validated
public class MyService {

	void addStudent(@Valid Person person, @Max(2) int degrees) {
		// ...
	}
}

Person类的name()方法上有一个ConstraintViolation,它被适配成了一个FieldError,包含以下信息:

  • 错误码 “Size.student.name”, “Size.name”, “Size.java.lang.String”, 和 “Size
  • 消息参数 “name”, 10, 和 1(字段名和约束属性)
  • 默认消息 “size must be between 1 and 10”

要自定义默认消息,可以使用上述任何错误码和消息参数向MessageSource资源包中添加属性。另外,消息参数"name"本身也是一个MessageSourceResolvable,带有错误码"student.name"和"name",也可以进行自定义。例如:

Properties

Size.student.name=Please, provide a {0} that is between {2} and {1} characters long
student.name=username

degrees方法参数上的一个ConstraintViolation被适配成一个MessageSourceResolvable,包含以下信息:

  • 错误码 “Max.myService#addStudent.degrees”, “Max.degrees”, “Max.int”, “Max
  • 消息参数 “degrees” 和 2(字段名和约束属性)
  • 默认消息 “must be less than or equal to 2”

自定义上述默认消息,可以添加如下属性:

Properties

Max.degrees=You cannot provide more than {1} {0}

额外的配置选项

对于大多数情况,默认的LocalValidatorFactoryBean配置已经足够。对于各种Bean Validation构造,从消息插值到遍历解析,都有一些配置选项。

配置DataBinder

可以使用一个验证器(Validator)来配置一个DataBinder实例。一旦配置完成,可以通过调用binder.validate()来调用验证器。任何验证错误都将自动添加到绑定器(binder)的BindingResult中。

以下示例展示了如何以编程方式使用DataBinder,以便在绑定到目标对象后调用验证逻辑:

Foo target = new Foo();
DataBinder binder = new DataBinder(target);
binder.setValidator(new FooValidator());

// bind to the target object
binder.bind(propertyValues);

// validate the target object
binder.validate();

// get BindingResult that includes any validation errors
BindingResult results = binder.getBindingResult();

还可以通过dataBinder.addValidatorsdataBinder.replaceValidators来配置一个DataBinder,使其包含多个Validator实例。这在将全局配置的bean验证与DataBinder实例上本地配置的Spring Validator结合使用时非常有用。

  • 21
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值