HotswapAgent源码解析

15 篇文章 1 订阅

HotwapAgent
Java unlimited redefinition of classes at runtime

使用方法

  • 按照github和官网说明操作即可, 完整的热更新依赖于DCEVM,不需要DCEVM也可以使用(但是java1.7、1.8不允许改类结构,比如新增字段,新增方法和修改类的父类等)
  • 如果自定义监控外部文件,需要将hotswap-agent.properties添加到src/main/resources中,并且要配置extraClasspath属性和启用autoHotswap, 可以通过propertiesFilePath参数指定自定义配置路径,例如 :-javaagent:/hotswap-agent.jar=propertiesFilePath=/data/hotswap-agent.properties,autoHotswap=true
  • 远程服务启用HotwapAgent并且开启JPDA后,通过IDE debug连上服务以后可以编译本地的类实现远程热更新 java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=8000 --XXaltjvm=dcevm -javaagent:HotswapAgent.jar MainClass
  • docker镜像可以参考hotswap-docklands来生成

源码解析

评价

  • 代码非常优雅,没有一个过长的类,每一个类源码都值得学习
  • 字节码操作使用的是javassit, 可以非常直观的在某个方法的前面或者后面插入代码,性能比asm直接操作字节码要差一点
  • 热部署的原理和美团的sonic非常相似,如果要实现sonic,可以基于HotswapAgent来实现
  • 用了很多事件观察者模式,解耦了各个模块
  • 利用注解,非常巧妙的拆解了热部署需要的基础功能,可以很简单的实现一个自定义插件

编译调试

  • 源码必须要用带有dcevm的jdk11版本才能编译,只能下载trava-jdk-11-dcevm
    • 吐槽一下github下载速度真慢,gitee只能代理下载源码,没法下载release资源,谁有好的办法麻烦分享一下
    • 在IDE中执行单元测试的时候还需要额外添加一个jdk1.8或1.7中的包${java.home}/../lib/tools.jar,jdk11的lib找不到tools.jar
  • 有一个坑点是instrument.transform函数在重定义类redefiningClass的时候,无法调试。在SpringPlugin.registerBasePackage中有一个说明,可能是JVMTI-debugger的bug
     // v.d.: Force load/Initialize ClassPathBeanRefreshCommand classe in JVM. This is hack, in whatever reason sometimes new ClassPathBeanRefreshCommand()
     //       stays locked inside agent's transform() call. It looks like some bug in JVMTI or JVMTI-debugger() locks handling.
    

核心代码

  • org.hotswap.agent.HotswapAgent agent类
  • org.hotswap.agent.config.PluginManager 插件管理类
  • org.hotswap.agent.config.PluginRegistry 插件注册类
  • org.hotswap.agent.util.HotswapTransformer 字节操作类
  • org.hotswap.agent.annotation.handler.AnnotationProcessor 注解解析类
  • org.hotswap.agent.util.classloader.URLClassLoaderHelper URLClassLoader操作辅助类
  • ClassLoaderDefineClassPatcher.patch 将classLoaderFrom下的所有字节绑定到classLoaderTo
  • org.hotswap.agent.util.ReflectionHelper 发射工具类
  • org.hotswap.agent.util.classloader.WatchResourcesClassLoader Special URL classloader to get only changed resources from URL.
  • org.hotswap.agent.plugin.spring.ResetSpringStaticCaches 清理所有Spring静态缓存
  • org.hotswap.agent.distribution.PluginDocs 文档生成
    • 骚操作,将所有插件模块的README.md转换为html,和热部署代码无关

初始化主流程

在这里插入图片描述

核心插件plugin

WatchResourcesPlugin

org.hotswap.agent.plugin.watchResources.WatchResourcesPlugin

监控自定义目录,如果没有watchResources不会初始化

初始化流程如下

  • 初始化watchResourcesClassLoader.initWatchResources
  • 修改appClassloader优先使用watchResourcesClassLoader,这里有个骚操作,使用javaassist进行代理增强实现的
  • WatchResourcesClassLoader将自定义的watchResources路径放在最前面,优先从自定义的目录下面加载类,原理是优先使用UrlOnlyClassLoader类来查找资源,只从URL[]查找资源而不会通过父类去加载
    public static class UrlOnlyClassLoader extends URLClassLoader {
        public UrlOnlyClassLoader(URL[] urls) {
            super(urls);
        }
        // do not use parent resource (may introduce infinite loop)
        @Override
        public URL getResource(String name) {
            return findResource(name);
        }
    };
    
HotswapperPlugin
  • org.hotswap.agent.plugin.hotswapper.HotswapperPlugin
  • Watch for any class file change and reload (hotswap) it on the fly. 说明文档
  • 只有配置了hotswap-agent.properties并且启用autoHotswap时才会初始化
  • 这个插件监听所有类创建和变更@OnClassFileEvent(classNameRegexp = ".*", events = {FileEvent.MODIFY, FileEvent.CREATE})
  • 有两种热部署模式,本地热加载,和通过JDPA API远程热更新
    • 本地热加载:没有配置端口的时候使用这种模式,会调用pluginManager.hotswap,进而调用instrumentation.redefineClasses来主动发起类重定义
    • JDPA API热加载:如果配置了端口,通过com.sun.jdi.SocketAttach来启动一个jvm attach到localhost:jdwP端口对应的进程上面,另外一个进程启动时需要添加-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=jdwP端口号启动参数,发现类变更事件以后,调用jvm.redefineClasses方法热更新另外一个进程的字节(本地进程还有必要通过jdwp调试模式来修改吗?我测试这种模式热更新会失败,没有找到原因,可能要在jdk11中使用
JdkPlugin
  • org.hotswap.agent.plugin.jdk.JdkPlugin JDK插件
    • 监听重定义事件LoadEvent.REDEFINE flushBeanIntrospectorCaches
      • Removing from threadGroupContext
      • Removing class from declaredMethodCache.
    • 监听重定义事件LoadEvent.REDEFINE flushIntrospectClassInfoCache
      • Flushing class from com.sun.beans.introspect.ClassInfo cache
    • 监听重定义事件LoadEvent.REDEFINE flushObjectStreamCaches
      • Flushing class from ObjectStreamClass caches
AnonymousClassPatchPlugin
  • org.hotswap.agent.plugin.jvm.AnonymousClassPatchPlugin 匿名类插件
    • 只监听匿名类的变更.*\$\d+
    • 匿名内是按照MyClass$1, MyClass$2这种在代码中的顺序生成的,如果更换位置以后原来的类位置都会发生变更
    • 为了热更新的时候保证匿名类不错乱,才疏学浅某天沉下心来再深入研究
ClassInitPlugin
  • org.hotswap.agent.plugin.jvm.ClassInitPlugin 静态类和静态变量插件,类重定义LoadEvent.REDEFINE时处理
    • 先删除过去增强注入的老的$$ha$clinit方法
    • 如果类有静态变量,则将静态变量初始化的函数注入成public static $$ha$clinit()方法,如果静态变量有变更或者枚举有变更,将通过scheduleCommand在150ms以后重新调用这个类的$$ha$clinit()方法,因为代理类重定义是在100ms以后,静态变量在代理类重定义以后再执行

自定义插件

SpringPlugin

实例化流程
  • 1.pluginRegistry.scanPlugins时发现SpringPlugin类将其加入到registeredPlugins
  • 2.annotationProcessor.processAnnotations 将SpringPlugin上面两个@OnClassLoadEvent注解的静态方法生成两个PluginClassFileTransformer注册到hotswapTransformer
    • @Plugin注解上的3个supportClass的静态注解也同样会被 annotationProcessor处理,supportClass的注解处理和SpringPlugin是一样的
    • 2.1 ClassPathBeanDefinitionScannerTransformer监听了org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider类加载事件,在其findCandidateComponents方法的最后插入了ClassPathBeanDefinitionScannerAgent.registerBasePackage代码
    • 2.2 ProxyReplacerTransformer监听了DefaultListableBeanFactoryorg.springframework.cglib.reflect.FastClass.Generatornet.sf.cglib.reflect.FastClass.Generator这三个类加载事件,在这些类字节被加载的时候进行代码插桩增强
    • 2.3 XmlBeanDefinitionScannerTransformer监听了org.springframework.beans.factory.xml.XmlBeanDefinitionReader类加载事件,在loadBeanDefinitions方法的最后插入了org.hotswap.agent.plugin.spring.scanner.XmlBeanDefinitionScannerAgent.registerXmlBeanDefinitionScannerAgent代码
  • 3.当org.springframework.beans.factory.support.DefaultListableBeanFactory类的字节码被定义的时候,会反射调用上面注册的transformer的函数,将SpringPlugin初始化和init函数代码插入到DefaultListableBeanFactory的构造函数前面
    • 3.1 当DefaultListableBeanFactory被实例化的时候SpringPlugin才开始实例化
    • 3.2 当SpringPlugin进行实例化的时候,执行annotationProcessor.processAnnotations 方法
      • a.会将实例化对象pluginInstance的非静态注解变量(hotswapTransformer, watcher, scheduler,appClassLoader)通过反射赋值
      • b.会将@OnResourceFileEvent(path="/", filter = ".*.xml", events = {FileEvent.MODIFY})注解的registerResourceListeners方法加入到资源文件监听listener中,当xml文件变更以后会执行XmlBeanRefreshCommand命令重新加载xml中的bean
    • 3.3 执行SpringPlugin.init方法
      • 如果在配置文件中自定义了的包路径,会调用registerBasePackage,生成一个字节转换HaClassFileTransformer, 该转换器在加载类的时候判定字节是否和原来的类定义有变化, 如果有变更生成一个ClassPathBeanRefreshCommand命令重新对该bean进行初始化
  • 4.当org.springframework.aop.framework.CglibAopProxy类的字节码被定义的时候,会反射调用上面注册的transformer的函数,将CglibAopProxy类的createEnhancer方法替换,禁用缓存
  • 5.当ClassPathScanningCandidateComponentProvider类执行findCandidateComponents的时候,会调用ClassPathBeanDefinitionScannerAgent.registerBasePackage代码,进而执行SpringPluginregisterComponentScanBasePackage方法,会和3.3一样,将包名进行注册registerBasePackage转换增强,同时将资源包下面的子包所有URL加入到watcher中, 子包中的类加载的时候后续会通过ClassPathBeanRefreshCommand命令重新再加载一次(子包应该是jar包,并且一个类在这个watcher中只会实例化一次)
如何实现监控文件变更的
  1. agent启动的时候初始化了一个watcher
    • 使用的是nio的FileSystems.getDefault().newWatchService()监听所有文件资源
    • windows下支持额外的ExtendedWatchEvent Modifier.FILE_TREE
  2. watcher启动了一个线程循环的从系统事件取出WatchEvent,然后丢到dispatcher(EventDispatcher)的eventQueue(ArrayBlockingQueue)队列中
  3. dispatcher也启动了一个线程循环的从队列eventQueue中取出事件Event,然后callListeners通知关心事件的监听者, watcher和dispatcher用的是同一个listeners对象
  4. 插件类在初始化的时候会注册监听者到wacher上面
    • 自定义的监控目录是通过WatchResourcesPlugin插件注册了listener
    • 可以直接调用watcher.addEventListener来注册listener
    • 可以在插件类的方法上面加上@OnClassFileEvent或者OnResourceFileEvent注解, 在资源变更以后会反射调用该方法
      • 注解是通过WatchHandler来绑定的, initMethod—》registerResources —> registerResourceListener —》pluginManager.getWatcher().addEventListener
      • listener执行的时候会创建WatchEventCommand,WatchEventCommand.createCmdForEvent(pluginAnnotation, event, classLoader),然后会通过scheduleCommand来执行pluginAnnotation上面的method
当类的加载或者重定义时如何处理事件
  • 通过OnClassLoadEvent注解,注册了很多PluginClassFileTransformer
    • hotswapTransformer.registerTransformer(appClassLoader, annot.classNameRegexp(), new PluginClassFileTransformer(pluginManager, pluginAnnotation));
  • 类第一次加载或重新加载时都会触发instrument.transform函数,在这个函数中会找到匹配的Transformer,然后执行Transformer的transform方法
.class文件变更或者新增以后如何判定是否需要重新加载bean
  1. WatchHandler.registerResourceListener注册了很多事件更新的listener,文件更新以后会callListener

    • WatchResourcesClassLoader.initWatchResources时通过watcher.addEventListener注册了一个监听器listener,文件变更以后通过listener然后将类保存到changedUrls中, 用来判断类是否发生了变更
    • WatchResourcesClassLoader在getResource方法会判断类是否发生了变更,如果有变更返回变更后的resource
  2. IDE调试的时候是由IDE触发了instrumentation.redefineClasses事件,调试断点不生效

    • HotswapperPlugin如果启用并且没有配置端口的时候会主动触发redefineClasses,插件默认是禁用的,如果是用jar包等启动方式的话应该启用autoHotswap来实现热更新
  3. 创建了一个新的类文件
    在SpringPlugin实例化流程的步骤5中registerComponentScanBasePackage基于basePackageURL创建了一个资源文件监听listenerwatcher.addEventListener,这个listener在发现新类.class文件的时候会创建一个ClassPathBeanRefreshCommand命令来实例化这个类对应的bean

  4. 类文件更新
    在SpringPlugin实例化流程的步骤3.3和步骤5中调用registerBasePackage方法生成并注册了HaClassFileTransformer,当类重新加载的时候会通过老的class定义和新的字节码判定类是否变更,变更后会重新加载bean

    	private void registerBasePackage(final String basePackage) {
        final SpringChangesAnalyzer analyzer = new SpringChangesAnalyzer(appClassLoader);
        // v.d.: Force load/Initialize ClassPathBeanRefreshCommand classe in JVM. This is hack, in whatever reason sometimes new ClassPathBeanRefreshCommand()
        //       stays locked inside agent's transform() call. It looks like some bug in JVMTI or JVMTI-debugger() locks handling.
        ClassPathBeanRefreshCommand fooCmd = new ClassPathBeanRefreshCommand();
        hotswapTransformer.registerTransformer(appClassLoader, getClassNameRegExp(basePackage), new HaClassFileTransformer() {
            @Override
            public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
                if (classBeingRedefined != null) {
                   // 类重定义的时候分析判断是否有必要重新初始化bean,ClassSignatureComparer.isPoolClassDifferent
                    if (analyzer.isReloadNeeded(classBeingRedefined, classfileBuffer)) {
                        scheduler.scheduleCommand(new ClassPathBeanRefreshCommand(classBeingRedefined.getClassLoader(),
                                basePackage, className, classfileBuffer));
                    }
                }
                return classfileBuffer;
            }
    
            @Override
            public boolean isForRedefinitionOnly() {
                // 只有类重新定义的时候才执行这个转换器
                return true;
            }
        });
    }
    
xml文件变更后如何更新

上面的3.2.b这一步监听了所有xml文件变更事件,变更以后会通过XmlBeanRefreshCommand命令重新加载xml中的bean

  1. org.hotswap.agent.plugin.spring.scanner.XmlBeanDefinitionScannerAgent.reloadXml
  2. 实际调用的是xmlBeanDefinitionScannerAgent.reloadBeanFromXml
public void reloadBeanFromXml(URL url) {
        // this will call registerBeanDefinition which in turn call resetBeanDefinition to destroy singleton
        this.reader.loadBeanDefinitions(new FileSystemResource(url.getPath()));
        // spring won't rebuild dependency map if injectionMetadataCache is not cleared
        // which lead to singletons depend on beans in xml won't be destroy and recreate, may be a spring bug?
		ResetBeanPostProcessorCaches.reset(maybeRegistryToBeanFactory());
        ProxyReplacer.clearAllProxies();
        reloadFlag = false;
    }
如何重新加载Spring bean

可以看代码细节来了解Spring bean从字节码到注册的整个流程

  1. ClassPathBeanRefreshCommand.executeCommand,通过反射代用下面的refreshClass方法
  2. ClassPathBeanDefinitionScannerAgent.refreshClass方法
    1. ResetSpringStaticCaches.reset() 清理所有静态缓存
      • 为什么所有类变更都要清掉缓存,如果不是Spring bean不清理可不可以?
    2. resolveBeanDefinition 解析bean定义
      public BeanDefinition resolveBeanDefinition(byte[] bytes) throws IOException {
          Resource resource = new ByteArrayResource(bytes);
          resetCachingMetadataReaderFactoryCache();  //清理metadataReaderFactory缓存
          MetadataReader metadataReader = getMetadataReaderFactory().getMetadataReader(resource);
          // 只有Spring bean类才去拿到beanDefinition
          if (isCandidateComponent(metadataReader)) {
              ScannedGenericBeanDefinition sbd = new ScannedGenericBeanDefinition(metadataReader);
              sbd.setResource(resource);
              sbd.setSource(resource);
              if (isCandidateComponent(sbd)) {
                  return sbd;
              } else {
                  return null;
              }
          }else....
      }
      
    3. defineBean 重新定义类,这里加了同步锁,将整个ClassPathBeanDefinitionScannerAgent类锁住了
      -. 为什么前面调用Spring方法的时候都通过反射调用,最后调用freezeConfiguration时是直接调用?
public void defineBean(BeanDefinition candidate) {
        synchronized (getClass()) {
            ScopeMetadata scopeMetadata = this.scopeMetadataResolver.resolveScopeMetadata(candidate);
            candidate.setScope(scopeMetadata.getScopeName());
            String beanName = this.beanNameGenerator.generateBeanName(candidate, registry);
            if (candidate instanceof AbstractBeanDefinition) {
                // 反射执行classPathBeanDefinitionScanner.postProcessBeanDefinition方法
                postProcessBeanDefinition((AbstractBeanDefinition) candidate, beanName);
            }
            if (candidate instanceof AnnotatedBeanDefinition) {
                // 反射执行AnnotationConfigUtils.processCommonDefinitionAnnotations方法
                processCommonDefinitionAnnotations((AnnotatedBeanDefinition) candidate);
            }
            removeIfExists(beanName); // 先清理存在的bean
            if (checkCandidate(beanName, candidate)) {
                BeanDefinitionHolder definitionHolder = new BeanDefinitionHolder(candidate, beanName);
                // 反射执行AnnotationConfigUtils.applyScopedProxyMode方法
                definitionHolder = applyScopedProxyMode(scopeMetadata, definitionHolder, registry);
                // 发射执行ClassPathBeanDefinitionScanner.registerBeanDefinition方法
                registerBeanDefinition(definitionHolder, registry);
                DefaultListableBeanFactory bf = maybeRegistryToBeanFactory();
                if (bf != null)
                    // 清理Spring HandlerMapping, 包括handlerMethods、urlMap、nameMap等字段
                    // 然后重新调用afterPropertiesSet方法重新初始化HandlerMapping
                    ResetRequestMappingCaches.reset(bf);
                // 清理插件代理,在SpringPlugin实例化流程的2.2中给com.sun.proxy.$Proxy代理类创建了代理类
                ProxyReplacer.clearAllProxies();
                // 调用beanFactory.freezeConfiguration的方法,Allow for caching all bean definition metadata, not expecting further changes
                freezeConfiguration();
            }
        }
    }

MyBatisPlugin

只支持任何一个mybatis xml文件修改以后进行全量解析更新,新增xml和注解这个插件不支持

  • MyBatisTransformers监听mybaits核心类加载事然后对其进行增强
    • org.apache.ibatis.parsing.XPathParser
      • 增加了一个变量$$ha$srcFileName
      • createDocument方法中this.$$ha$srcFileName=org.hotswap.agent.util.IOUtils.extractFileNameFromInputSource($1), 得到inputSource的文件名赋给变量$$ha$srcFileName
      • 新增$$ha$refreshDocument方法,如果$$ha$srcFileName 不为null,基于$$ha$srcFileName 文件中createDocument
    • org.apache.ibatis.builder.BaseBuilder
      • 去掉变量configuration的final属性
    • org.apache.ibatis.builder.xml.XMLConfigBuilder
      • 构造函数的后面插入代码
        • 实例化MyBatisPlugin
        • 调用MyBatisPlugin.registerConfigurationFile方法,记录configFile,用来过滤mybatis xml文件
        • ConfigurationProxy.getWrapper(this).proxy(this.configuration)对configuration创建一个代理
      • 增加一个$$ha$refresh方法,该方法会在xml文件变更以后调用
        • 调用XPathParserCaller.refreshDocument()函数, 反射调用org.apache.ibatis.parsing.XPathParser.$$ha$refreshDocument()
        • 然后调用XMLConfigBuilder.parse()函数重新解析配置,这个方法源码中根据this.parsed来判断只允许解析一次,所以需要在调用前需要将this.parsed设置为false
    • org.apache.ibatis.builder.xml.XMLMapperBuilder
      • 构造函数的后面插入代码
        • 实例化MyBatisPlugin
        • 调用MyBatisPlugin.registerConfigurationFile方法,记录configFile,用来过滤mybatis xml文件
  • MybatisPlugin中注册xml文件修改监听事件,当监听到.xml文件修改以后,创建一个ReflectionCommand命令,反射执行MyBatisRefreshCommands.reloadConfiguration方法
    @OnResourceFileEvent(path = "/", filter = ".*.xml", events = {FileEvent.MODIFY})
    public void registerResourceListeners(URL url) throws URISyntaxException {
        if (configurationMap.containsKey(Paths.get(url.toURI()).toFile().getAbsolutePath())) {
            // 500ms以后执行reloadConfigurationCommand命令
            refresh(500);
        }
    }
    private void refresh(int timeout) {
        scheduler.scheduleCommand(reloadConfigurationCommand, timeout);
    }    
    
    • MyBatisRefreshCommands.reloadConfiguration内部调用的是ConfigurationProxy.refreshProxiedConfigurations()方法, 调用了MyBatisTransformers.$$ha$refresh方法更新,这个方法是通过类增强插入的
   public static void refreshProxiedConfigurations() {
        for (ConfigurationProxy wrapper : proxiedConfigurations.values())
            try {
                wrapper.refreshProxiedConfiguration();
            } catch (Exception e) {
                e.printStackTrace();
            }
    }
    public void refreshProxiedConfiguration() throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
        this.configuration = new Configuration();
        ReflectionHelper.invoke(configBuilder, MyBatisTransformers.REFRESH_METHOD);
    }

JacksonPlugin

  • ObjectMapperSerializerCacheDeserializerCacheReadOnlyClassToSerializerMapTypeFactoryLRUMap等类定义时进行了增强
    • 给这些类的构造函数中都加入了代码,初始化JacksonPlugin,然后执行registerNeedToClearCacheObjects方法将实例对象添加到JacksonPlugin的needToClearCacheObjects
    • 都插入了一个public void ha$$clearCache()方法对缓存进行清理
  • 当监听到类变更以后执行needToClearCacheObject.ha$$clearCache()方法清理所有缓存

LogbackPlugin

监听logback.xml文件,变更以后重新配置

  • ch.qos.logback.core.joran.GenericConfigurator定义的时候在其doConfigure(url)函数的后面插入代码
    • 实例化LogbackPlugin插件
    • 调用LogbackPlugin.initLogback(final Object configurator, final URL url)
    • 注册一个url资源(就是logback.xml文件的url)监听者,监听到变更以后reload
      • 先调用ch.qos.logback.classic.LoggerContext.reset()方法
      • 再调用ch.qos.logback.core.joran.GenericConfigurator.doConfigure(url)方法

LogbackPlugin.reload方法源码如下

protected void reload(Object configurator, URL url) {
        ....
        try {
            synchronized (configurator) {
                ClassLoader classLoader = configurator.getClass().getClassLoader();
                Class<?> configuratorClass = classLoader.loadClass("ch.qos.logback.core.joran.GenericConfigurator");
                Class<?> contextAwareBaseClass = classLoader.loadClass("ch.qos.logback.core.spi.ContextAwareBase");
                Class<?> contextClass = classLoader.loadClass("ch.qos.logback.classic.LoggerContext");
                // reset current context
                Object context = contextAwareBaseClass.getDeclaredMethod("getContext").invoke(configurator);
                contextClass.getDeclaredMethod("reset").invoke(context);
                // configure the URL
                configuratorClass.getDeclaredMethod("doConfigure", URL.class).invoke(configurator, url);
            }
        } catch (Exception e) {
            LOGGER.error("Unable to reload {} with logback configurator {}", e, url, configurator);
        }
}
  • 5
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 5
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值