spring基本使用(24)-springMVC8-SpringMVC的校验器Validator

1、JSR-303校验规范,应该叫约束constraints

      改约束主要定义的很多约束注解,如下图:

         

        这些注解就是规范,那么谁来解析这个注解呢?当然是校验器,Java没有帮我们实现校验器,因此只能去找了,比较有名的校验器就是hibernate-validator,别想了这个东西跟orm 框架Hibernate没半毛钱关系,hibernate-validator不仅仅支持JSR-303的约束,自己还扩展了一些约,并实现了校验的业务功能。

 

2、在springMVC中的校验器

      2.1、spring 一贯的作风就是集成,一般不会重复造轮子,所以你只要引入JSR-303约束的依赖 + hibernate-validator的依赖,你就能在springMVC中使用校验器。

      2.2、需要引入的maven依赖

        <dependency>
            <groupId>javax.validation</groupId>
            <artifactId>validation-api</artifactId>
            <version>2.0.1.Final</version>
        </dependency>

        <dependency>
            <groupId>org.hibernate.validator</groupId>
            <artifactId>hibernate-validator</artifactId>
            <version>6.0.4.Final</version>
        </dependency>

       2.3、使用方式

    @RequestMapping("test")
    public String test(@Validated Huma huma, BindingResult bindingResult) {
        使用@Validated标注需要校验的实体类,在添加一个校验结果绑定的入参bindingResult
        huma.setAge(18);
        return "register";
    }

            

    @RequestMapping("user2")
    @ResponseBody
    private MyResponse <String> user1(@RequestBody @Validated Huma huma, BindingResult bindingResult) {
        int i = 1;
        if (i == 1) {
            throw new BusinessException(101, "BusinessException名称不能为空!");
        }

        return MyResponse.buildSuccess("register success");
    }
    public class Huma implements Serializable {

        @NotBlank(message = "名称不能为空!")
        private String  name;
    
        @Min(value = 0, message = "年龄不能小于0")
        private Integer age;

        public String getName() {
           return name;
        }
        public void setName(String name) {
           this.name = name;
        }
        public Integer getAge() {
           return age;
        }
        public void setAge(Integer age) {
           this.age = age;
        }
    }

      2.4、验证结果展示:在浏览器中输入http://localhost:7070/user/test?name=&age=10  注意我们没传入name属性

               

 

3、分组校验

      很多时候我们的rest接口在做校验是的时候会发现一个问题就是,比如说多个接口的如参数类型是同一个实体类,但是在不同的接口校验入参的时候,可能校验策略就会不一样,比如在save数据操作的时候我们不需要传入id值,但是在update数据操作的时候id值是必须的,但是我们只定义了一个实体类,这个时候怎么办呢? 分组校验登场了。

       先看使用案例: 

           实体类代码:

     public class Huma implements Serializable {

        定义只有在指定HumaUpdateGroup分组的时候才会触发校验。
        @NotBlank(message = "id不能为空!",groups = HumaUpdateGroup.class)
        private String id;

        定义只有在指定HumaUpdateGroup、HumaSaveGroup分组的时候才会触发校验。
        @NotBlank(message = "名称不能为空!", groups = {HumaUpdateGroup.class, HumaSaveGroup.class})
        private String  name;

        不指定分组就一定会触发校验,指定了分组,如果分组不匹配就不会校验。
        @Min(value = 0, message = "年龄不能小于0",groups = {HumaUpdateGroup.class, HumaSaveGroup.class, HumaSelectGroup.class})
        private Integer age;

        public String getName() {
           return name;
        }
        public void setName(String name) {
           this.name = name;
        }
        public Integer getAge() {
           return age;
        }
        public void setAge(Integer age) {
           this.age = age;
        }
    }

            我们定义三个表示分组的接口,分组接口只有标记作用,除了标记没有任何作用。

            save操作分组接口:

       public interface HumaSaveGroup {
       }

            update操作分组接口:

       public interface HumaUpdateGroup {
       }

            select操作分组接口:

       public interface HumaSelectGroup {
       }

           rest接口上指定需要使用那个分组来进行校验:

                 save操作指定使用HumaSaveGroup分组进行校验:

         @RequestMapping("test/save")
         public String test(@Validated(value = HumaSaveGroup.class)  Huma huma, BindingResult bindingResult) {
            huma.setAge(18);
            return "register";
         }

                update操作指定使用HumaUpdateGroup分组进行校验:

         @RequestMapping("test/update")
         public String testupdate(@Validated(value = HumaUpdateGroup.class)  Huma huma, BindingResult bindingResult) {
            huma.setAge(18);
            return "register";
         }

                 select操作指定使用HumaSelectGroup分组进行校验:

         @RequestMapping("test/select")
         public String testselect(@Validated(value = HumaSelectGroup.class)  Huma huma, BindingResult bindingResult) {
           huma.setAge(18);
           return "register";
         }

               测试save操作:http://localhost:7070/user/test/save?name=wenzongyuan&age=10&id=    不传入id,看看会不会有验证错误。

                                         

 

                测试update操作:http://localhost:7070/user/test/update?name=wenzongyuan&age=10&id=  入参不变

                                         

                 测试select操作:http://localhost:7070/user/test/select?name=wenzongyuan&age=10&id=  入参还是不变

                                         

 

                 以上就是分组校验的实践。

 

4、结合MessageSource进行校验的错误描述信息message进行国际化展示

      4.1、实体类修改:

     public class Huma implements Serializable {

        @NotBlank(message = "{id.notBlank}",groups = HumaUpdateGroup.class)
        private String id;

        @NotBlank(message = "{id.notBlank}", groups = {HumaUpdateGroup.class, HumaSaveGroup.class})
        private String  name;

        @Min(value = 0, message = "{age.ge.zero}",groups = {HumaUpdateGroup.class, HumaSaveGroup.class, HumaSelectGroup.class})
        private Integer age;

        public String getName() {
           return name;
        }
        public void setName(String name) {
           this.name = name;
        }
        public Integer getAge() {
           return age;
        }
        public void setAge(Integer age) {
           this.age = age;
        }
    }

      4.2、配置校验器 + 消息源 以及注解驱动中指定校验器

         <bean id="validator" class="org.springframework.validation.beanvalidation.LocalValidatorFactoryBean">
           <property name="providerClass" value="org.hibernate.validator.HibernateValidator"/>
           <!--不设置则默认为classpath下的 ValidationMessages.properties -->
           <property name="validationMessageSource" ref="messageSource"/>
         </bean>

 

         <bean id="messageSource" class="org.springframework.context.support.ResourceBundleMessageSource">
            <property name="basenames">
                <list>
                   <value>i18/messages</value>
                </list>
            </property>
         </bean>

                  也许会有疑问,我们之前没配置校验器,为啥也能正常提供校验功能???这个后面会详细分析。   

        <mvc:annotation-driven  validator="validator"/>

                 国际化文件目录:

                                                      

                                          messages_en_US.properties中配置:

                                                      

                                          messages_zh_CN.properties中配置:中文全部转为ASCLL码

                                                       

 

       4.3、结合LocaleChangeInterceptor + SessionLocaleResolver 来进行本地化信息切换

         <mvc:interceptors>
            <bean class="org.springframework.web.servlet.i18n.LocaleChangeInterceptor" p:paramName="locale"/>
         </mvc:interceptors>

 

         <bean id="localeResolver" class="org.springframework.web.servlet.i18n.SessionLocaleResolver">
            <!--配置在HttpSession中 本地化参数的key,在SessionLocaleResolver解析的是会使用
            这个key去HttpSession中获取本地化Locale实例-->
            <property name="localeAttributeName" value="locale"/>
         </bean>

        4.4、开始测试

                 测试代码:

    @RequestMapping("test/save")
    public String test(@Validated(value = HumaSaveGroup.class)  Huma huma, BindingResult bindingResult) {
        huma.setAge(18);
        return "register";
    }

                 浏览器输入:http://localhost:7070/user/test/save?name=&age=10&id=&locale=zh_CN  先测试汉语_中国

                      

                接着测试英语_美国:http://localhost:7070/user/test/save?name=&age=10&id=&locale=en_US

                      

                 以上就是结合本地化信息来进行验证错误信息本地化的案例。

 

 

5、校验器在SpringMVC中的工作原理

      5.1、校验器的配置方式

               方式1:不配置,使用SpringMVC默认配置的校验器,依赖于SpringMVC注解驱动<mvc:annotation-driven/>

                            在SpringMVC的注解驱动的解析期间会帮我们自动注册一个校验器的BeanDefinition到spring容器中,在AnnotationDrivenBeanDefinitionParser的parse方法中有如下处理的代码片段:注意:代码非连续

             1、注册一个Validator的BenaDefinition到SpringMVC的容器中,并返回一个引用。
             RuntimeBeanReference validator = getValidator(element, source, parserContext);


             2、构建一个类型是ConfigurableWebBindingInitializer的BeanDefintion实例,然后设置
ConfigurableWebBindingInitializer实例的属性validator=上面一步构建的RuntimeBeanReference。
ConfigurableWebBindingInitializer的作用就是初始化绑定者WebDataBinder,在WebDataBinder中维护了
验证器的列表,在使用ConfigurableWebBindingInitializer初始化WebDataBinder的时候会将配置的
Validator添加到WebDataBinder中维护的验证器的列表中,然后使用校验器进行参数校验。
             RootBeanDefinition bindingDef = new RootBeanDefinition(ConfigurableWebBindingInitializer.class);
             bindingDef.getPropertyValues().add("validator", validator);

             3、构建一个类型是RequestMappingHandlerAdapter的BeanDefinitions实例并设置其webBindingInitializer属性等于bindingDef
             RootBeanDefinition handlerAdapterDef = new RootBeanDefinition(RequestMappingHandlerAdapter.class);
             handlerAdapterDef.getPropertyValues().add("webBindingInitializer", bindingDef);

                 构建Validator的BenaDefinition到SpringMVC的容器中,并返回一个引用的源码实现:

	private RuntimeBeanReference getValidator(Element element, Object source, ParserContext parserContext) {

        1、入参的element就是注解驱动元素 <mvc:annotation-driven/>   
		if (element.hasAttribute("validator")) {
            2、如果有validator属性,那就直接使用配置的验证器的beanName构建一个
                                                 RuntimeBeanReference实例返回。
			return new RuntimeBeanReference(element.getAttribute("validator"));
		}

        3、如果没有配置validator属性,那就判断是否依赖了javax.validation.Validator这个类。
		else if (javaxValidationPresent) {

            如果依赖了javax.validation.Validator这个类就构建一个类型是
            org.springframework.validation.beanvalidation.OptionalValidatorFactoryBean的
            BeanDefinition然后注册到SpringMVC容器中,然后使用其优化后的beanName创建一个
            RuntimeBeanReference返回。
			RootBeanDefinition validatorDef = new RootBeanDefinition(
					"org.springframework.validation.beanvalidation.OptionalValidatorFactoryBean");
			validatorDef.setSource(source);
			validatorDef.setRole(BeanDefinition.ROLE_INFRASTRUCTURE);
			String validatorName = parserContext.getReaderContext().registerWithGeneratedName(validatorDef);
			parserContext.registerComponent(new BeanComponentDefinition(validatorDef, validatorName));
			return new RuntimeBeanReference(validatorName);
		}
		else {
			return null;
		}
	}

              方式2:显示配置一个Validator,配置方式上面演示过,不在累赘。

 

       5.2、Validator验证器的工作时机

                我们知道了在WebDataBinder中维护了验证器列表,那么验证器肯定是在参数绑定器里面工作的了,纵观源码我们找到了验证器的工作时机是在参数解析器HandlerMethodArgumentResolver的resolveArgument方法中。

                我们使用ServletModelAttributeMethodProcessor参数解析器来进行讲解:代码片段如下:

         0、先完成入参绑定。
         Object attribute = (mavContainer.containsAttribute(name) ? mavContainer.getModel().get(name) :
				createAttribute(name, parameter, binderFactory, webRequest));

         1、先使用webs数据绑定工厂创建一个web数据绑定器
         WebDataBinder binder = binderFactory.createBinder(webRequest, attribute, name);
         if (binder.getTarget() != null) {
           if (!mavContainer.isBindingDisabled(name)) {
	         bindRequestParameters(binder, webRequest);
	       }
            
           2、如果需要进行参数校验,就是判断参数是否被注解@Validated标注,如果标注了就进行校验。
           validateIfApplicable(binder, parameter);

           3、如果验证错误且在绑定验证错误的时候失败,那就抛出验证错误绑定到BindingResult失败异常。
	       if (binder.getBindingResult().hasErrors() && isBindExceptionRequired(binder, parameter  )) {
		      throw new BindException(binder.getBindingResult());
	       }
         }
        // Add resolved attribute and BindingResult at the end of the model
        Map<String, Object> bindingResultModel = binder.getBindingResult().getModel();
        mavContainer.removeAttributes(bindingResultModel);
        mavContainer.addAllAttributes(bindingResultModel);

        4、如果需要的话对入参类型进行转换。
        return binder.convertIfNecessary(binder.getTarget(), parameter.getParameterType(), parameter);

               validateIfApplicable(binder, parameter)实现源码:

	protected void validateIfApplicable(WebDataBinder binder, MethodParameter parameter) {
        1、获取入参上面标注的注解。
		Annotation[] annotations = parameter.getParameterAnnotations();
		for (Annotation ann : annotations) {
            2、获取入参上标注的@Validated注解
			Validated validatedAnn = AnnotationUtils.getAnnotation(ann, Validated.class);

            3、如果有@Validated注解 或者有以Valid开头的注解都可以进行验证。
			if (validatedAnn != null || ann.annotationType().getSimpleName().startsWith("Valid")) {
                4、获取@Validated或者以@Valid名称开头的注解的成员属性value值,也就是分组列表。
				Object hints = (validatedAnn != null ? validatedAnn.value() : AnnotationUtils.getValue(ann));
				Object[] validationHints = (hints instanceof Object[] ? (Object[]) hints : new Object[] {hints});

                5、传如分组信息,然后使用web数据绑定者进行参数校验。
				binder.validate(validationHints);
				break;
			}
		}
	}

                来到WebDataBinder的父类的validate(Object... validationHints)方法:

	public void validate(Object... validationHints) {

        1、循环当前WebDataBinder的验证器列表,我们配置的验证器也会在此列表中。
		for (Validator validator : getValidators()) {

            2、如果校验的分组信息不为空且校验器实现了SmartValidator接口,那就强转后进行校验
			if (!ObjectUtils.isEmpty(validationHints) && validator instanceof SmartValidator) {

                                3、getTarget()返回当时参数以及绑定好的入参value值,
                                   getBindingResult()就是创建一个BindingResult实例,
                                   validationHints是分组数组。SpringMVC默认配置的是
                                   OptionalValidatorFactoryBean类型,那么就会执行此处。
				((SmartValidator) validator).validate(getTarget(), getBindingResult(), validationHints);
			}
			else if (validator != null) {

                基于这里我们可以自定义一些校验器来使用,比如我们可以专门为某个实
                体类写一个检验器,然后添加到SpringMVC中,就可以在此处进行调用。
				validator.validate(getTarget(), getBindingResult());
			}
		}
	}

                我们以SpringMVC默认配置的OptionalValidatorFactoryBean校验来进行跟踪,接着来到器父类SpringValidatorAdapter的validate(Object target, Errors errors, Object... validationHints)方法:

	@Override
	public void validate(Object target, Errors errors, Object... validationHints) {
		if (this.targetValidator != null) {
            1、先使用group的数组构建一个Set集合,目的是去重。
			Set<Class<?>> groups = new LinkedHashSet<Class<?>>();
			if (validationHints != null) {
				for (Object hint : validationHints) {
					if (hint instanceof Class) {
						groups.add((Class<?>) hint);
					}
				}
			}

            2、处理约束校验
			processConstraintViolations(
					this.targetValidator.validate(target, ClassUtils.toClassArray(groups)), errors);
		}
	}

                   使用目标校验器进行校验this.targetValidator.validate(target, ClassUtils.toClassArray(groups),SpringValidatorAdapter是一个校验器适配器,里面会委会一个真正干事情的校验器,这里时使用了Hibernate-validation里实现的校验器,ValidatorImpl,这个校验器校验完成后会返回一个校验结果集Set<ConstraintViolation<T>> T类型是我们需要校验的实体类的类型。ConstraintViolation是一个包装单个校验失败的字段实例,里面封装了验证失败的字段、以及验证失败的message,注意此处的message如果有配置需要本地换转换的也会在此步转化好。

                   问题:Hibernate-validation怎么使用到MessageSource进行转换的,又是怎么获取到本地化信息Locale的?

                   答:还记得我们在配置LocalValidatorFactoryBean的时候配置了一个属性叫validationMessageSource吗,我们去看看LocalValidatorFactoryBean是如何设置这个属性的,源码如下:

	    public void setValidationMessageSource(MessageSource messageSource) {

        使用我们传入的messageSource构建一个MessageInterpolator类型的消息插补器,
        并赋值给我们的校验器,真正的消息插补器的类型是ResourceBundleMessageInterpolator,但是
        这个消息插补器是不具备本地化信息功能的插补器。
		this.messageInterpolator = 
            HibernateValidatorDelegate.buildMessageInterpolator(messageSource);
	    }

                       消息插补器MessageInterpolator:

                                  

                                 LocalValidatorFactoryBean这个我们配置的bean在初始化的时候会对上面创建的ResourceBundleMessageInterpolator进行包装成为LocaleContextMessageInterpolator具有解析当前请求的本地化信息的消息插补器,源码体现在LocalValidatorFactoryBean的初始化方法afterPropertiesSet()中:

	    先获取到setValidationMessageSource方法执行的时候构建的ResourceBundleMessageInterpolator实例
        MessageInterpolator targetInterpolator = this.messageInterpolator;
		if (targetInterpolator == null) {
			targetInterpolator = configuration.getDefaultMessageInterpolator();
		}

        然后将整个校验环境的配置中的消息插补器包装成为LocaleContextMessageInterpolator带请求本地化解析的消息插补器。
		configuration.messageInterpolator(new 
                                    LocaleContextMessageInterpolator(targetInterpolator));


        使用上面构建好的校验环境的配置实例来创建验证器工厂validatorFactory,configuration的类型
是Hibernate-validation中的ConfigurationImpl 这个就是Hibernate-validation对jsr303的
Configuration接口的实现,在配置LocalValidatorFactoryBean的时候指定了一个
providerClass=org.hibernate.validator.HibernateValidator所以这个configuration实例就是使用其构
建的。所以我们最终得到的验证器工厂就是Hibernate-validation中的ValidatorFactoryImpl类型。
        this.validatorFactory = configuration.buildValidatorFactory();
        
        构建好验证器工厂后那就使用此validatorFactory 来构建一个验证器并设置到
LocalValidatorFactoryBean实例的父类SpringValidatorAdapter的targetValidator属性,然后在校验的时
候就是用这个目标验证器来进行校验。这个目标校验器的类型是Hibernate-validation中的ValidatorImpl类型
        setTargetValidator(this.validatorFactory.getValidator());

                   上面我们分析了LocalValidatorFactoryBean在初始化的时候会构建一个目标校验器targetValidator,而在使用Hibernate-validation的时候这个目标验证器的类型是Hibernate-validation中的ValidatorImpl类型,我们发现在这个ValidatorImpl中有一个是属性private final MessageInterpolator messageInterpolator; 没错就是消息插补器,而这个插补器正是我们包装好的LocaleContextMessageInterpolator实例,整条链路就是这样,我们来看看LocaleContextMessageInterpolator的实现方式:我们跟踪到ValidatorImpl最终会调用LocaleContextMessageInterpolator的interpolate(messageTemplate, context);方法来获取验证错误的提示信息message,我们就查看LocaleContextMessageInterpolator的String interpolate(String messageTemplate, Context context)实现原理:源码如下:

	@Override
	public String interpolate(String message, Context context) {
	   return this.targetInterpolator.interpolate(message, context, LocaleContextHolder.getLocale());
	}

                   实现里面获取本地化信息的方式是使用LocaleContextHolder来获取当前的本地化信息,LocaleContextHolder时使用ThreadLocal来实现的,里面存的value值类型是LocaleContext也就是本地换信息上下文,那么设置本地化信息是什么时候设置进去的呢?跟我们的本地化信息解析器LocaleResolver又是如何搭配着干活的呢?????   TMD 大神不好当啊。。。。

                   LocaleContext既然是本地化信息上下文,那么里面必然能够获取到当前的本地化信息实例,这是必然的,LocaleContext的类图如下:

                        

               接下来我们再了解一个东西LocaleContextResolver接口:

         public interface LocaleContextResolver extends LocaleResolver {

            使用HttpServletRequest实例来获取一个LocaleContext实例
	        LocaleContext resolveLocaleContext(HttpServletRequest request);

            将解析好的LocaleContext实例设置到请求的属性集中。
	        void setLocaleContext(HttpServletRequest request, HttpServletResponse response, LocaleContext localeContext);

         }

               这个接口是本地化解析LocaleResolver的派生接口其类图如下:

                      

              LocaleContextResolver这个东西也讲清楚后我们来剖析什么时候会将解析好的LocaleContext实例绑定到LocaleContextHolder的ThreadLocal中???

              纵观源码实现我们找到了有2个地方都进行了LocaleContext的构建与线程绑定,分别如下:

                    1、RequestContextListener的requestInitialized(ServletRequestEvent requestEvent):源码片段如下:

              LocaleContextHolder.setLocale(request.getLocale());

                           RequestContextListener是web容器(如Tomcat)的请求事件,也就是说一旦有请求第1就会执行其requestInitialized(ServletRequestEvent requestEvent)方法,因此在这一步会获取request实例的一个本地化信息,注意此时本地化解析器LocaleResolver还没有工作。

                     2、FrameworkServlet的processRequest(HttpServletRequest request, HttpServletResponse response)方法,这个方法是在DispatcherServlet的doService方法之前执行,此方法源码片段如下:

           1、先获取当前线程绑定好的LocaleContext实例,这里能够获取到在RequestContextListener里面设置的LocaleContext事实例。
           LocaleContext previousLocaleContext = LocaleContextHolder.getLocaleContext();

           2、使用请求实例解析一个LocaleContext实例,这里就会使用到LocaleResolver了,这里的实现在DispatcherServlet中。
		   LocaleContext localeContext = buildLocaleContext(request);
           ...
           3、然后将解析到的LocaleContext实例再次进行线程绑定,会覆盖RequestContextListener里面设置的值。
           initContextHolders(request, localeContext, requestAttributes);

                        DispatcherServlet中buildLocaleContext(final HttpServletRequest request)的实现:

	     @Override
	     protected LocaleContext buildLocaleContext(final HttpServletRequest request) {

             1、判断配置的本地化解析器LocaleResolver是否实现了LocaleContextResolver接口,上面的类图里面有展示。
		     if (this.localeResolver instanceof LocaleContextResolver) {

                2、如果是那就强转然后调用其resolveLocaleContext方法解析到一个LocaleContext实例
			    return ((LocaleContextResolver) 
                             this.localeResolver).resolveLocaleContext(request);
		     }
		     else {

                3、如果不是构建一个LocaleContext的匿名对象然后实现的getLocale就使用localeResolver解析到的Locale实例。
			    return new LocaleContext() {
				    @Override
				    public Locale getLocale() {
					   return localeResolver.resolveLocale(request);
				    }
			    };
		     }
	     }

                            我们以SessionLocaleResolver的resolveLocaleContext(final HttpServletRequest request)来举例:

	         @Override
	         public LocaleContext resolveLocaleContext(final HttpServletRequest request) {

                  构建一个匿名的TimeZoneAwareLocaleContext实例,获取Locale实例的方式是从
request实例的属性列表中取,那么问题来了什么时候LocaleResolver解析好的Locale实例什么时候放进去呢?
就是在setLocaleContext方法中放进去的,如果配置了LocaleChangeInterceptor的话,拦截器会构建好
Locale实例后调用setLocaleContext方法进行设置,如果没有的话就是直接从HttpSession中获取,前提是就需
要做我们之前的操作实现设置好,然后才能获取到。
		          return new TimeZoneAwareLocaleContext() {
			          @Override
			          public Locale getLocale() {
				          Locale locale = (Locale) WebUtils.getSessionAttribute(request, localeAttributeName);
				          if (locale == null) {
					          locale = determineDefaultLocale(request);
				          }
				          return locale;
			         }
			         @Override
			         public TimeZone getTimeZone() {
				          TimeZone timeZone = (TimeZone) WebUtils.getSessionAttribute(request, timeZoneAttributeName);
				          if (timeZone == null) {
					          timeZone = determineDefaultTimeZone(request);
				          }
				          return timeZone;
		             }
		         };
	        }

 

以上就是整个校验器的原理。

 

 

 

 

 

 

 

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值