【转载】Spring Converter 体系

最近封装 RPC 相关的模块,领导说数据转换可以考虑使用 Spring 原有的 Converter 体系。

  • Converter<S, T> 最简单的转换器、相关的顶层接口有 ConditionalConverterGenericConverterConverterFactoryConvertingComparatorConverterRegistry
  • ConversionService Spring 数据转换的入口、它根据相关参数将调用路由到具体的 Converter。 相关的接口和类 FormattingConversionServiceDefaultConversionServiceConversionServiceFactoryBeanFormattingConversionServiceFactoryBean

Converter

/**
 * A converter converts a source object of type {@code S} to a target of type {@code T}.
 *
 * <p>Implementations of this interface are thread-safe and can be shared.
 *
 * <p>Implementations may additionally implement {@link ConditionalConverter}.
 *
 * @author Keith Donald
 * @since 3.0
 * @param <S> the source type
 * @param <T> the target type
 */
@FunctionalInterface
public interface Converter<S, T> {

   /**
    * Convert the source object of type {@code S} to target type {@code T}.
    * @param source the source object to convert, which must be an instance of {@code S} (never {@code null})
    * @return the converted object, which must be an instance of {@code T} (potentially {@code null})
    * @throws IllegalArgumentException if the source cannot be converted to the desired target type
    */
   @Nullable
   T convert(S source);

}
复制代码

实现该接口要确保其线程安全、一般来说除了实现该接口、还会实现 ConditionalConverter 接口

看一些常见的 Spring 内部提供给我们使用的

字符串转布尔

final class StringToBooleanConverter implements Converter<String, Boolean> {

   private static final Set<String> trueValues = new HashSet<>(8);

   private static final Set<String> falseValues = new HashSet<>(8);

   static {
      trueValues.add("true");
      trueValues.add("on");
      trueValues.add("yes");
      trueValues.add("1");

      falseValues.add("false");
      falseValues.add("off");
      falseValues.add("no");
      falseValues.add("0");
   }

   @Override
   @Nullable
   public Boolean convert(String source) {
      String value = source.trim();
      if (value.isEmpty()) {
         return null;
      }
      value = value.toLowerCase();
      if (trueValues.contains(value)) {
         return Boolean.TRUE;
      }
      else if (falseValues.contains(value)) {
         return Boolean.FALSE;
      }
      else {
         throw new IllegalArgumentException("Invalid boolean value '" + source + "'");
      }
   }

}

字符转 Charset

class StringToCharsetConverter implements Converter<String, Charset> {

   @Override
   public Charset convert(String source) {
      return Charset.forName(source);
   }

}
复制代码

Converter 是一比一之间的转换、相对来说是比较简单的

实际项目常用】ConverterFactory

这个是一比多之间的转换

public interface ConverterFactory<S, R> {

   /**
    * Get the converter to convert from S to target type T, where T is also an instance of R.
    * @param <T> the target type
    * @param targetType the target type to convert to
    * @return a converter from S to T
    */
   <T extends R> Converter<S, T> getConverter(Class<T> targetType);

}

看下 String 转为各种枚举类

final class StringToEnumConverterFactory implements ConverterFactory<String, Enum> {

   @Override
   public <T extends Enum> Converter<String, T> getConverter(Class<T> targetType) {
      return new StringToEnum(ConversionUtils.getEnumType(targetType));
   }

   private static class StringToEnum<T extends Enum> implements Converter<String, T> {

      private final Class<T> enumType;

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

      @Override
      @Nullable
      public T convert(String source) {
         if (source.isEmpty()) {
            // It's an empty enum identifier: reset the enum value to null.
            return null;
         }
         return (T) Enum.valueOf(this.enumType, source.trim());
      }
   }
}

GenericConverter

N:N 的转换

public interface GenericConverter {

  // 这里就返回了转换关系、是一个集合
   @Nullable
   Set<ConvertiblePair> getConvertibleTypes();

   @Nullable
   Object convert(@Nullable Object source, TypeDescriptor sourceType, TypeDescriptor targetType);

   final class ConvertiblePair {

      private final Class<?> sourceType;

      private final Class<?> targetType;

      public ConvertiblePair(Class<?> sourceType, Class<?> targetType) {
         this.sourceType = sourceType;
         this.targetType = targetType;
      }
   }
}

看一下 ObjectToOptionalConverter

final class ObjectToOptionalConverter implements ConditionalGenericConverter {

   private final ConversionService conversionService;

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

   @Override
   public Set<ConvertiblePair> getConvertibleTypes() {
      Set<ConvertiblePair> convertibleTypes = new LinkedHashSet<>(4);
      convertibleTypes.add(new ConvertiblePair(Collection.class, Optional.class));
      convertibleTypes.add(new ConvertiblePair(Object[].class, Optional.class));
      convertibleTypes.add(new ConvertiblePair(Object.class, Optional.class));
      return convertibleTypes;
   }

   @Override
   public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) {
      if (targetType.getResolvableType().hasGenerics()) {
         return this.conversionService.canConvert(sourceType, new GenericTypeDescriptor(targetType));
      }
      else {
         return true;
      }
   }

   @Override
   public Object convert(@Nullable Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {
      if (source == null) {
         return Optional.empty();
      }
      else if (source instanceof Optional) {
         return source;
      }
      else if (targetType.getResolvableType().hasGenerics()) {
         Object target = this.conversionService.convert(source, sourceType, new GenericTypeDescriptor(targetType));
         if (target == null || (target.getClass().isArray() && Array.getLength(target) == 0) ||
                  (target instanceof Collection && ((Collection<?>) target).isEmpty())) {
            return Optional.empty();
         }
         return Optional.of(target);
      }
      else {
         return Optional.of(source);
      }
   }

   @SuppressWarnings("serial")
   private static class GenericTypeDescriptor extends TypeDescriptor {

      public GenericTypeDescriptor(TypeDescriptor typeDescriptor) {
         super(typeDescriptor.getResolvableType().getGeneric(), null, typeDescriptor.getAnnotations());
      }
   }

}

这里借助了 ConversionService 协助将 source 对象转换为 Optional<T> 中 T 泛型对象

可以看到 getConvertibleTypes 返回的是三个转换关系的。

ConversionService

作为整个转换系统的入口

public interface ConversionService {

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

   boolean canConvert(@Nullable TypeDescriptor sourceType, TypeDescriptor targetType);

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

   @Nullable
   Object convert(@Nullable Object source, @Nullable TypeDescriptor sourceType, TypeDescriptor targetType);

}

如果是泛型相关建议使用 TypeDescriptor 参数的 converter 方法

我们看一下 ConverterRegistry

ConfigurableConversionService

ConfigurableConversionService 只是做了两个接口的整合、啥都没做

public interface ConfigurableConversionService extends ConversionService, ConverterRegistry {

}

GenericConversionService

GenericConversionServiceConversionServiceConverterRegistry 的实现类

public class GenericConversionService implements ConfigurableConversionService {

	// 当不需要进行转换的时候使用该转换器、也就是源对象的类型是目标类型或者其子类
	private static final GenericConverter NO_OP_CONVERTER = new NoOpConverter("NO_OP");
	// 没有找到合适的转换器、使用该转换器作为占位符、放在缓存 Map 中占位
	private static final GenericConverter NO_MATCH = new NoOpConverter("NO_MATCH");

  // 内部类、用来管理 Converter 的
	private final Converters converters = new Converters();
	// 缓存、可以看到 value 是 GenericConverter
	private final Map<ConverterCacheKey, GenericConverter> converterCache = new ConcurrentReferenceHashMap<>(64);

	@Override
	public void addConverter(Converter<?, ?> converter) {
		ResolvableType[] typeInfo = getRequiredTypeInfo(converter.getClass(), Converter.class);
    // 转为 GenericConverter
		addConverter(new ConverterAdapter(converter, typeInfo[0], typeInfo[1]));
	}

	@Override
	public <S, T> void addConverter(Class<S> sourceType, Class<T> targetType, Converter<? super S, ? extends T> converter) {
        // 转为 GenericConverter
		addConverter(new ConverterAdapter(
				converter, ResolvableType.forClass(sourceType), ResolvableType.forClass(targetType)));
	}

	@Override
	public void addConverter(GenericConverter converter) {
		this.converters.add(converter);
		invalidateCache();
	}

	@Override
	public void addConverterFactory(ConverterFactory<?, ?> factory) {
    // 获取声明的泛型信息
		ResolvableType[] typeInfo = getRequiredTypeInfo(factory.getClass(), ConverterFactory.class);
	  ........
     // 转为 GenericConverter
		addConverter(new ConverterFactoryAdapter(factory,
				new ConvertiblePair(typeInfo[0].toClass(), typeInfo[1].toClass())));
	}

	@Override
	public void removeConvertible(Class<?> sourceType, Class<?> targetType) {
		this.converters.remove(sourceType, targetType);
		invalidateCache();
	}
  
	@Override
	public boolean canConvert(@Nullable Class<?> sourceType, Class<?> targetType) {
		Assert.notNull(targetType, "Target type to convert to cannot be null");
		return canConvert((sourceType != null ? TypeDescriptor.valueOf(sourceType) : null),
				TypeDescriptor.valueOf(targetType));
	}

	@Override
	public boolean canConvert(@Nullable TypeDescriptor sourceType, TypeDescriptor targetType) {
		Assert.notNull(targetType, "Target type to convert to cannot be null");
		if (sourceType == null) {
			return true;
		}
		GenericConverter converter = getConverter(sourceType, targetType);
		return (converter != null);
	}

	public boolean canBypassConvert(@Nullable TypeDescriptor sourceType, TypeDescriptor targetType) {
		Assert.notNull(targetType, "Target type to convert to cannot be null");
		if (sourceType == null) {
			return true;
		}
		GenericConverter converter = getConverter(sourceType, targetType);
		return (converter == NO_OP_CONVERTER);
	}

	@Override
	@SuppressWarnings("unchecked")
	@Nullable
	public <T> T convert(@Nullable Object source, Class<T> targetType) {
		Assert.notNull(targetType, "Target type to convert to cannot be null");
		return (T) convert(source, TypeDescriptor.forObject(source), TypeDescriptor.valueOf(targetType));
	}

	@Override
	@Nullable
	public Object convert(@Nullable Object source, @Nullable TypeDescriptor sourceType, TypeDescriptor targetType) {
		.......
		GenericConverter converter = getConverter(sourceType, targetType);
		if (converter != null) {
			Object result = ConversionUtils.invokeConverter(converter, source, sourceType, targetType);
			return handleResult(sourceType, targetType, result);
		}
		return handleConverterNotFound(source, sourceType, targetType);
	}

DefaultConversionService

DefaultConversionService 提供方法为 ConverterRegistry 增加一些常用的 Converter

public class DefaultConversionService extends GenericConversionService {

   @Nullable
   private static volatile DefaultConversionService sharedInstance;


   public DefaultConversionService() {
      addDefaultConverters(this);
   }

   public static ConversionService getSharedInstance() {
      DefaultConversionService cs = sharedInstance;
      if (cs == null) {
         synchronized (DefaultConversionService.class) {
            cs = sharedInstance;
            if (cs == null) {
               cs = new DefaultConversionService();
               sharedInstance = cs;
            }
         }
      }
      return cs;
   }

   public static void addDefaultConverters(ConverterRegistry converterRegistry) {
      addScalarConverters(converterRegistry);
      addCollectionConverters(converterRegistry);

      converterRegistry.addConverter(new ByteBufferConverter((ConversionService) converterRegistry));
      converterRegistry.addConverter(new StringToTimeZoneConverter());
      converterRegistry.addConverter(new ZoneIdToTimeZoneConverter());
      converterRegistry.addConverter(new ZonedDateTimeToCalendarConverter());

      converterRegistry.addConverter(new ObjectToObjectConverter());
      converterRegistry.addConverter(new IdToEntityConverter((ConversionService) converterRegistry));
      converterRegistry.addConverter(new FallbackObjectToStringConverter());
      converterRegistry.addConverter(new ObjectToOptionalConverter((ConversionService) converterRegistry));
   }

   public static void addCollectionConverters(ConverterRegistry converterRegistry) {
      ConversionService conversionService = (ConversionService) converterRegistry;

      converterRegistry.addConverter(new ArrayToCollectionConverter(conversionService));
      converterRegistry.addConverter(new CollectionToArrayConverter(conversionService));

      converterRegistry.addConverter(new ArrayToArrayConverter(conversionService));
      converterRegistry.addConverter(new CollectionToCollectionConverter(conversionService));
      converterRegistry.addConverter(new MapToMapConverter(conversionService));

      converterRegistry.addConverter(new ArrayToStringConverter(conversionService));
      converterRegistry.addConverter(new StringToArrayConverter(conversionService));

      converterRegistry.addConverter(new ArrayToObjectConverter(conversionService));
      converterRegistry.addConverter(new ObjectToArrayConverter(conversionService));

      converterRegistry.addConverter(new CollectionToStringConverter(conversionService));
      converterRegistry.addConverter(new StringToCollectionConverter(conversionService));

      converterRegistry.addConverter(new CollectionToObjectConverter(conversionService));
      converterRegistry.addConverter(new ObjectToCollectionConverter(conversionService));

      converterRegistry.addConverter(new StreamConverter(conversionService));
   }

   private static void addScalarConverters(ConverterRegistry converterRegistry) {
      converterRegistry.addConverterFactory(new NumberToNumberConverterFactory());

      converterRegistry.addConverterFactory(new StringToNumberConverterFactory());
      converterRegistry.addConverter(Number.class, String.class, new ObjectToStringConverter());

      converterRegistry.addConverter(new StringToCharacterConverter());
      converterRegistry.addConverter(Character.class, String.class, new ObjectToStringConverter());

      converterRegistry.addConverter(new NumberToCharacterConverter());
      converterRegistry.addConverterFactory(new CharacterToNumberFactory());

      converterRegistry.addConverter(new StringToBooleanConverter());
      converterRegistry.addConverter(Boolean.class, String.class, new ObjectToStringConverter());

      converterRegistry.addConverterFactory(new StringToEnumConverterFactory());
      converterRegistry.addConverter(new EnumToStringConverter((ConversionService) converterRegistry));

      converterRegistry.addConverterFactory(new IntegerToEnumConverterFactory());
      converterRegistry.addConverter(new EnumToIntegerConverter((ConversionService) converterRegistry));

      converterRegistry.addConverter(new StringToLocaleConverter());
      converterRegistry.addConverter(Locale.class, String.class, new ObjectToStringConverter());

      converterRegistry.addConverter(new StringToCharsetConverter());
      converterRegistry.addConverter(Charset.class, String.class, new ObjectToStringConverter());

      converterRegistry.addConverter(new StringToCurrencyConverter());
      converterRegistry.addConverter(Currency.class, String.class, new ObjectToStringConverter());

      converterRegistry.addConverter(new StringToPropertiesConverter());
      converterRegistry.addConverter(new PropertiesToStringConverter());

      converterRegistry.addConverter(new StringToUUIDConverter());
      converterRegistry.addConverter(UUID.class, String.class, new ObjectToStringConverter());
   }

}

FormattingConversionService

FormattingConversionService 则实现了 FormatterRetistry 接口

/**
 * Prints objects of type T for display.
 *
 * @author Keith Donald
 * @since 3.0
 * @param <T> the type of object this Printer prints
 */
@FunctionalInterface
public interface Printer<T> {

   /**
    * Print the object of type T for display.
    * @param object the instance to print
    * @param locale the current user locale
    * @return the printed text string
    */
   String print(T object, Locale locale);

}

Printer 主要是为了打印展示某个类型对象的、根据 locale

/**
 * Parses text strings to produce instances of T.
 *
 * @author Keith Donald
 * @since 3.0
 * @param <T> the type of object this Parser produces
 */
@FunctionalInterface
public interface Parser<T> {

   /**
    * Parse a text String to produce a T.
    * @param text the text string
    * @param locale the current user locale
    * @return an instance of T
    * @throws ParseException when a parse exception occurs in a java.text parsing library
    * @throws IllegalArgumentException when a parse exception occurs
    */
   T parse(String text, Locale locale) throws ParseException;

}

Parser 则是从 String 根据 locale 解释为 T、这个跟 Converter 优点类似、但是是根据 Locale 的不用来解释转换的

/**
 * Formats objects of type T.
 * A Formatter is both a Printer <i>and</i> a Parser for an object type.
 *
 * @author Keith Donald
 * @since 3.0
 * @param <T> the type of object this Formatter formats
 */
public interface Formatter<T> extends Printer<T>, Parser<T> {

}

Formatter 则直接整合这两个接口

无论是 Printer 或者是 Parser 最终都会转换为 GenericConverter、在 convert 方法中在调用对应的方法进行转换解释。

ApplicationConversionService

Spring Boot 默认使用的 ConversionService 。

但是在 Spring 中管理的却是 WebConversionService。它最终也会调用 addBeans 方法

public class ApplicationConversionService extends FormattingConversionService {

   private static volatile ApplicationConversionService sharedInstance;

   public ApplicationConversionService() {
      this(null);
   }

   public ApplicationConversionService(StringValueResolver embeddedValueResolver) {
      if (embeddedValueResolver != null) {
         setEmbeddedValueResolver(embeddedValueResolver);
      }
      configure(this);
   }

   public static ConversionService getSharedInstance() {
      ApplicationConversionService sharedInstance = ApplicationConversionService.sharedInstance;
      if (sharedInstance == null) {
         synchronized (ApplicationConversionService.class) {
            sharedInstance = ApplicationConversionService.sharedInstance;
            if (sharedInstance == null) {
               sharedInstance = new ApplicationConversionService();
               ApplicationConversionService.sharedInstance = sharedInstance;
            }
         }
      }
      return sharedInstance;
   }

   public static void configure(FormatterRegistry registry) {
      DefaultConversionService.addDefaultConverters(registry);
      DefaultFormattingConversionService.addDefaultFormatters(registry);
      addApplicationFormatters(registry);
      addApplicationConverters(registry);
   }

   public static void addApplicationConverters(ConverterRegistry registry) {
      addDelimitedStringConverters(registry);
      registry.addConverter(new StringToDurationConverter());
      registry.addConverter(new DurationToStringConverter());
      registry.addConverter(new NumberToDurationConverter());
      registry.addConverter(new DurationToNumberConverter());
      registry.addConverter(new StringToPeriodConverter());
      registry.addConverter(new PeriodToStringConverter());
      registry.addConverter(new NumberToPeriodConverter());
      registry.addConverter(new StringToDataSizeConverter());
      registry.addConverter(new NumberToDataSizeConverter());
      registry.addConverter(new StringToFileConverter());
      registry.addConverter(new InputStreamSourceToByteArrayConverter());
      registry.addConverterFactory(new LenientStringToEnumConverterFactory());
      registry.addConverterFactory(new LenientBooleanToEnumConverterFactory());
   }

   public static void addDelimitedStringConverters(ConverterRegistry registry) {
      ConversionService service = (ConversionService) registry;
      registry.addConverter(new ArrayToDelimitedStringConverter(service));
      registry.addConverter(new CollectionToDelimitedStringConverter(service));
      registry.addConverter(new DelimitedStringToArrayConverter(service));
      registry.addConverter(new DelimitedStringToCollectionConverter(service));
   }

   public static void addApplicationFormatters(FormatterRegistry registry) {
      registry.addFormatter(new CharArrayFormatter());
      registry.addFormatter(new InetAddressFormatter());
      registry.addFormatter(new IsoOffsetFormatter());
   }

   public static void addBeans(FormatterRegistry registry, ListableBeanFactory beanFactory) {
      Set<Object> beans = new LinkedHashSet<>();
      beans.addAll(beanFactory.getBeansOfType(GenericConverter.class).values());
      beans.addAll(beanFactory.getBeansOfType(Converter.class).values());
      beans.addAll(beanFactory.getBeansOfType(Printer.class).values());
      beans.addAll(beanFactory.getBeansOfType(Parser.class).values());
      for (Object bean : beans) {
         if (bean instanceof GenericConverter) {
            registry.addConverter((GenericConverter) bean);
         }
         else if (bean instanceof Converter) {
            registry.addConverter((Converter<?, ?>) bean);
         }
         else if (bean instanceof Formatter) {
            registry.addFormatter((Formatter<?>) bean);
         }
         else if (bean instanceof Printer) {
            registry.addPrinter((Printer<?>) bean);
         }
         else if (bean instanceof Parser) {
            registry.addParser((Parser<?>) bean);
         }
      }
   }

}

并且会将所有的 GenericConverter、Converter、Printer、Parser bean 注册到 FormatterRegistry 中

Converter VS PropertyEditor

  • PropertyEditor 从 String 转为其他类型
  • Converter 从各种类型转为各种类型

Spring 同时支持两者。

BeanWrapper 实现了 TypeConverter、而 TypeConverter 则 先使用PropertyEditor转换器器转换,如果没找到对应的转换器器,会⽤ConversionService来进⾏行行对象转换。将两者整合起来做转换。

ConditionalConverter

public interface ConditionalConverter {
   boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType);
}
public interface ConditionalGenericConverter extends GenericConverter, ConditionalConverter {
}

当你实现了 Converter 接口和 ConditionalConverter 接口的时候、在 ConverterAdapter 适配类中会回调你的 matchs 方法

还有一种情况是

当存在多个一样的 sourceType 和 targetType 的转换器时、怎么选择出一个合适的 Converter

private static class ConvertersForPair {

   private final Deque<GenericConverter> converters = new ConcurrentLinkedDeque<>();

   public void add(GenericConverter converter) {
      this.converters.addFirst(converter);
   }

   @Nullable
   public GenericConverter getConverter(TypeDescriptor sourceType, TypeDescriptor targetType) {
      for (GenericConverter converter : this.converters) {
         if (!(converter instanceof ConditionalGenericConverter) ||
               ((ConditionalGenericConverter) converter).matches(sourceType, targetType)) {
            return converter;
         }
      }
      return null;
   }

   @Override
   public String toString() {
      return StringUtils.collectionToCommaDelimitedString(this.converters);
   }
}

实际问题

封装 RPC 模块过程中、涉及到各种类型的转换、有 json和 PoJo 的转换、也有 xml 和 PoJo 的转换、更有一些自定义协议报文到 PoJo 的转换、而这些 PoJo 具体是什么类型、在 RPC 的底层模块中是不知道的、只有在运行时通过泛型相关的信息获取的

比如 Json 和 PoJo 之间的转换、可能需要两个转换器、一个是 String 到 PoJo 的、一个是 PoJo 到 String

PoJo 到 String 是比较简单的、这里讨论下 String 到 PoJo。

String 到 PoJo 、这个关系貌似就是 1:N 的关系、貌似使用 ConverterFactory 就可以解决

第一个是泛型问题、而且作为 ConverterFactory 需要手动注册到 ConverterRegistry 中、

最终选定 GenericConverter、

@Component
public class JsonStringToObjectConverter implements
        GenericConverter , ConditionalConverter {

    @Autowired
    private ObjectMapper objectMapper;

    @Override
    public Set<ConvertiblePair> getConvertibleTypes() {
         return null;
    }

    @Override
    public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {
        try {
            if (source instanceof JsonStringMessage) {
                return objectMapper.readValue(((JsonStringMessage) source).getSource(), targetType.getType());
            }else {
                // impossible
                throw new IllegalArgumentException("illegal argument");
            }
        } catch (JsonProcessingException e) {
            throw new IllegalStateException(e);
        }
    }

    @Override
    public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) {
        return sourceType.getType().equals(JsonStringMessage.class);
    }

getConvertibleTypes 返回 null 的话、必须实现接口 ConditionalConverter

此 Converter 作为 globalConverter

public void add(GenericConverter converter) {
   Set<ConvertiblePair> convertibleTypes = converter.getConvertibleTypes();
   if (convertibleTypes == null) {
      Assert.state(converter instanceof ConditionalConverter,
            "Only conditional converters may return null convertible types");
      this.globalConverters.add(converter);
   }
   else {
      for (ConvertiblePair convertiblePair : convertibleTypes) {
         getMatchableConverters(convertiblePair).add(converter);
      }
   }
}

作者:CoderLi
链接:https://juejin.cn/post/7098322657061371941

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值