Tomcat 9 源码解析 -- StandardContext

 

StandardContext 类介绍

 

StandardContext 和其他 Container 一样,也是重写了 startInternal 方法。由于涉及到 webapp 的启动流程,需要很多准备工作,比如使用 WebResourceRoot 加载资源文件、利用 Loader 加载 class、使用 JarScanner 扫描 jar 包,等等。因此StandardContext 的启动逻辑比较复杂,这里描述下几个重要的步骤: 
1. 创建工作目录,比如$CATALINA_HOME\work\Catalina\localhost\examples;实例化 ContextServlet,应用程序拿到的是 ApplicationContext的外观模式 
2. 实例化 WebResourceRoot,默认实现类是 StandardRoot,用于读取 webapp 的文件资源 
3. 实例化 Loader 对象,Loader 是 tomcat 对于 ClassLoader 的封装,用于支持在运行期间热加载 class 
4. 发出  CONFIGURE_START_EVENT  事件,ContextConfig 会处理该事件,主要目的是从 webapp 中读取 servlet 相关的 Listener、Servlet、Filter 等 
5. 实例化 Sesssion 管理器,默认使用 StandardManager 
6. 调用 listenerStart,实例化 servlet 相关的各种 Listener,并且调用 
    ServletContextListener 
7. 处理 Filter 
8. 加载 Servlet

 

核心方法 为   startInternal()

 

Tomcat的生命周期机制告诉我们,一个组件的启动过程应该关注它的start方法,这个start方法是典型的模板方法设计模式。LifecycleBase是所有组件都继承的抽象类,该类提供了生命周期相关的通用方法,start()方法也可以在LifecycleBase中找到。

观察start方法,在该方法中定义了组件启动的应进行的操作,又留出一个抽象方法startInternal()方法供子类实现组件自身的操作。

所以来看 StandContext 的 startInternal() 方法。
 

@Override
    protected synchronized void startInternal() throws LifecycleException {

    	LogPropertiesTest.debug("14、StandardContext :  执行  startInternal() 方法,    执行类 :"+this.getClass());
    	
        if(log.isDebugEnabled())
            log.debug("Starting " + getBaseName());

        // 1.发布正在启动的JMX通知,这样可以通过NotificationListener来监听Web应用的启动。
        // Send j2ee.state.starting notification
        if (this.getObjectName() != null) {
            Notification notification = new Notification("j2ee.state.starting",
                    this.getObjectName(), sequenceNumber.getAndIncrement());
            broadcaster.sendNotification(notification);
        }

        setConfigured(false);
        boolean ok = true;

        // Currently this is effectively a NO-OP but needs to be called to
        // ensure the NamingResources follows the correct lifecycle
        // 2.启动当前维护的JNDI资源。
        if (namingResources != null) {
            namingResources.start();
        }

        
        // 3.初始化临时工作目录,即设置的workDir,默认为$CATALINA-BASE/work/<Engine名称>/<Host名称>/<Context名称>。
        // Post work directory
        postWorkDirectory();

        
        // 4.初始化当前Context使用的WebResouceRoot并启动。WebResouceRoot维护了Web应用所以的资源集合
        // (Class文件、Jar包以及其他资源文件),主要用于类加载器和按照路径查找资源文件。
        // Add missing components as necessary
        if (getResources() == null) {   // (1) Required by Loader
            if (log.isDebugEnabled())
                log.debug("Configuring default Resources");

            try {
                setResources(new StandardRoot(this));
            } catch (IllegalArgumentException e) {
                log.error(sm.getString("standardContext.resourcesInit"), e);
                ok = false;
            }
        }
        if (ok) {
        	// WebResourceRoot #createWebResourceSet ==》 "/WEB-INF/classes/META-INF/resources"
            resourcesStart();
        }

        // 5.创建Web应用类加载器webappLoader,webappLoader继承自LifecycleMBeanBase,在其启动后会去创建Web应用类加载器(ParallelWebappClassLoader)。
        if (getLoader() == null) {
            WebappLoader webappLoader = new WebappLoader(getParentClassLoader());
            webappLoader.setDelegate(getDelegate());
            setLoader(webappLoader);
        }

        // 6.如果没有设置Cookie处理器,默认为Rfc6265CookieProcessor。
        if (cookieProcessor == null) {
            cookieProcessor = new Rfc6265CookieProcessor();
        }

        // 7.设置字符集映射,用于根据Locale获取字符集编码。
        getCharsetMapper();

        // Validate required extensions
        // 8.web应用的依赖检测。
        boolean dependencyCheck = true;
        try {
            dependencyCheck = ExtensionValidator.validateApplication
                (getResources(), this);
        } catch (IOException ioe) {
            log.error(sm.getString("standardContext.extensionValidationError"), ioe);
            dependencyCheck = false;
        }

        if (!dependencyCheck) {
            // do not make application available if dependency check fails
            ok = false;
        }

        // Reading the "catalina.useNaming" environment variable
        String useNamingProperty = System.getProperty("catalina.useNaming");
        if ((useNamingProperty != null)
            && (useNamingProperty.equals("false"))) {
            useNaming = false;
        }

        // 9.NamingContextListener注册
        if (ok && isUseNaming()) {
            if (getNamingContextListener() == null) {
                NamingContextListener ncl = new NamingContextListener();
                ncl.setName(getNamingContextName());
                ncl.setExceptionOnFailedWrite(getJndiExceptionOnFailedWrite());
                addLifecycleListener(ncl);
                setNamingContextListener(ncl);
            }
        }

        // Standard container startup
        if (log.isDebugEnabled())
            log.debug("Processing standard container startup");


        // Binding thread
        ClassLoader oldCCL = bindThread();

        try {
            if (ok) {
                // Start our subordinate components, if any
            	// 10.启动Web应用类加载器,此时真正创建出ParallelWebappClassLoader实例。
                Loader loader = getLoader();
                if (loader instanceof Lifecycle) {
                    ((Lifecycle) loader).start();
                }

                // since the loader just started, the webapp classloader is now
                // created.
                setClassLoaderProperty("clearReferencesRmiTargets",
                        getClearReferencesRmiTargets());
                setClassLoaderProperty("clearReferencesStopThreads",
                        getClearReferencesStopThreads());
                setClassLoaderProperty("clearReferencesStopTimerThreads",
                        getClearReferencesStopTimerThreads());
                setClassLoaderProperty("clearReferencesHttpClientKeepAliveThread",
                        getClearReferencesHttpClientKeepAliveThread());
                setClassLoaderProperty("clearReferencesObjectStreamClassCaches",
                        getClearReferencesObjectStreamClassCaches());

                // By calling unbindThread and bindThread in a row, we setup the
                // current Thread CCL to be the webapp classloader
                unbindThread(oldCCL);
                oldCCL = bindThread();

                // Initialize logger again. Other components might have used it
                // too early, so it should be reset.
                logger = null;
                getLogger();

                // 11.启动安全组件。
                Realm realm = getRealmInternal();
                if(null != realm) {
                    if (realm instanceof Lifecycle) {
                        ((Lifecycle) realm).start();
                    }

                    // Place the CredentialHandler into the ServletContext so
                    // applications can have access to it. Wrap it in a "safe"
                    // handler so application's can't modify it.
                    CredentialHandler safeHandler = new CredentialHandler() {
                        @Override
                        public boolean matches(String inputCredentials, String storedCredentials) {
                            return getRealmInternal().getCredentialHandler().matches(inputCredentials, storedCredentials);
                        }

                        @Override
                        public String mutate(String inputCredentials) {
                            return getRealmInternal().getCredentialHandler().mutate(inputCredentials);
                        }
                    };
                    context.setAttribute(Globals.CREDENTIAL_HANDLER, safeHandler);
                }

                // Notify our interested LifecycleListeners   ContextConfig#webConfig()
                /**
                 
                 ContextConfig 它是一个 LifycycleListener,它在 Context 启动过程中是承担了一个非常重要的角色。StandardContext 会发出 CONFIGURE_START_EVENT 事件,而 ContextConfig 会处理该事件,主要目的是通过 web.xml 或者 Servlet3.0 的注解配置,读取 Servlet 相关的配置信息,比如 Filter、Servlet、Listener 等,其核心逻辑在 ContextConfig#webConfig() 方法中实现。下面,我们对 ContextConfig 进行详细分析
                 
                 CONFIGURE_START_EVENT = "configure_start";
                 */
                // 12.发布CONFIGURE_START_EVENT事件,ContextConfig 监听该事件以完成 Servlet 的创建。
                fireLifecycleEvent(Lifecycle.CONFIGURE_START_EVENT, null);

                // Start our child containers, if not already started
                // 13.启动Context子节点Wrapper。
                for (Container child : findChildren()) {
                    if (!child.getState().isAvailable()) {
                        child.start();
                    }
                }

                // Start the Valves in our pipeline (including the basic),
                // if any
                // 14.启动Context的pipeline。
                if (pipeline instanceof Lifecycle) {
                    ((Lifecycle) pipeline).start();
                }

                // Acquire clustered manager
                // 15.创建会话管理器。
                Manager contextManager = null;
                Manager manager = getManager();
                if (manager == null) {
                    if (log.isDebugEnabled()) {
                        log.debug(sm.getString("standardContext.cluster.noManager",
                                Boolean.valueOf((getCluster() != null)),
                                Boolean.valueOf(distributable)));
                    }
                    if ( (getCluster() != null) && distributable) {
                        try {
                            contextManager = getCluster().createManager(getName());
                        } catch (Exception ex) {
                            log.error("standardContext.clusterFail", ex);
                            ok = false;
                        }
                    } else {
                        contextManager = new StandardManager();
                    }
                }

                // Configure default manager if none was specified
                if (contextManager != null) {
                    if (log.isDebugEnabled()) {
                        log.debug(sm.getString("standardContext.manager",
                                contextManager.getClass().getName()));
                    }
                    setManager(contextManager);
                }

                if (manager!=null && (getCluster() != null) && distributable) {
                    //let the cluster know that there is a context that is distributable
                    //and that it has its own manager
                    getCluster().registerManager(manager);
                }
            }

            if (!getConfigured()) {
                log.error(sm.getString("standardContext.configurationFail"));
                ok = false;
            }

            // We put the resources into the servlet context
            // 16.将Context的Web资源集合添加到ServletContext。
            if (ok)
                getServletContext().setAttribute
                    (Globals.RESOURCES_ATTR, getResources());

            // 17.创建实例管理器instanceManager,用于创建对象实例,如Servlet、Filter等。
            if (ok ) {
                if (getInstanceManager() == null) {
                    javax.naming.Context context = null;
                    if (isUseNaming() && getNamingContextListener() != null) {
                        context = getNamingContextListener().getEnvContext();
                    }
                    Map<String, Map<String, String>> injectionMap = buildInjectionMap(
                            getIgnoreAnnotations() ? new NamingResourcesImpl(): getNamingResources());
                    setInstanceManager(new DefaultInstanceManager(context,
                            injectionMap, this, this.getClass().getClassLoader()));
                }
                getServletContext().setAttribute(
                        InstanceManager.class.getName(), getInstanceManager());
                InstanceManagerBindings.bind(getLoader().getClassLoader(), getInstanceManager());
            }

            // Create context attributes that will be required
            // 18.将Jar包扫描器添加到ServletContext。
            if (ok) {
                getServletContext().setAttribute(
                        JarScanner.class.getName(), getJarScanner());
            }

            // Set up the context init params
            //  19.合并参数。   指定 ServletContext 的相关参数
            mergeParameters();

            // 在初始化 Servlet、Listener 之前,便会先调用 ServletContainerInitializer,进行额外的初始化处理。
        	//  注意:ServletContainerInitializer 需要的是 Class 对象,而不是具体的实例对象,这个时候 servlet 相关的 Listener 
        	//  并没有被实例化,因此不会产生矛盾
            // Call ServletContainerInitializers
            // 调用 ServletContainerInitializer#onStartup()
            for (Map.Entry<ServletContainerInitializer, Set<Class<?>>> entry :
                initializers.entrySet()) {
                try {
                	
                	
                	Set<Class<?>> value = entry.getValue();
					/*
					 * for (Class<?> class1 : value) { LogPropertiesTest.
					 * debug("--ServletContainerInitializer--------------------14、StandardContext :  执行  startInternal() 方法,    class1 :"
					 * +class1.getClass()); }
					 */
                	
                	
                	//  20.启动添加到Context的ServletContainerInitializer。
                    entry.getKey().onStartup(entry.getValue(),
                            getServletContext());
                } catch (ServletException e) {
                    log.error(sm.getString("standardContext.sciFail"), e);
                    ok = false;
                    break;
                }
            }

            // Configure and call application event listeners
            // 21.实例化应用类监听器ApplicationListener。
            if (ok) {
                if (!listenerStart()) {
                    log.error(sm.getString("standardContext.listenerFail"));
                    ok = false;
                }
            }

            // Check constraints for uncovered HTTP methods
            // Needs to be after SCIs and listeners as they may programmatically
            // change constraints
            if (ok) {
                checkConstraintsForUncoveredMethods(findConstraints());
            }

            try {
                // Start manager
            	// 22.启动会话管理器。
                Manager manager = getManager();
                if (manager instanceof Lifecycle) {
                    ((Lifecycle) manager).start();
                }
            } catch(Exception e) {
                log.error(sm.getString("standardContext.managerFail"), e);
                ok = false;
            }

            // Configure and call application filters
            // 23.实例化FilterConfig、Filter并调用Filter.init()。
            if (ok) {
                if (!filterStart()) {
                    log.error(sm.getString("standardContext.filterFail"));
                    ok = false;
                }
            }

            // Load and initialize all "load on startup" servlets
            // 24.对于loadOnStartup大于等于0的Wrapper,调用Wrapper.load(),该方法负责实例化Servlet,并调用Servlet.init()进行初始化。
            if (ok) {
                if (!loadOnStartup(findChildren())){
                    log.error(sm.getString("standardContext.servletFail"));
                    ok = false;
                }
            }

            // Start ContainerBackgroundProcessor thread
            // 25.启动后台定时处理程序,只有backgroundProcessorDelay>0才启动,用于监控守护文件的变更。
            super.threadStart();
        } finally {
            // Unbinding thread
            unbindThread(oldCCL);
        }

        // Set available status depending upon startup success
        if (ok) {
            if (log.isDebugEnabled())
                log.debug("Starting completed");
        } else {
            log.error(sm.getString("standardContext.startFailed", getName()));
        }

        startTime=System.currentTimeMillis();

        // Send j2ee.state.running notification
        // 26.发布正在运行的JMX通知
        if (ok && (this.getObjectName() != null)) {
            Notification notification =
                new Notification("j2ee.state.running", this.getObjectName(),
                                 sequenceNumber.getAndIncrement());
            broadcaster.sendNotification(notification);
        }

        // The WebResources implementation caches references to JAR files. On
        // some platforms these references may lock the JAR files. Since web
        // application start is likely to have read from lots of JARs, trigger
        // a clean-up now.
        // 27.释放资源,如关闭jar文件。
        getResources().gc();

        // 28.设置Context状态。
        // Reinitializing if something went wrong
        if (!ok) {
            setState(LifecycleState.FAILED);
        } else {
            setState(LifecycleState.STARTING);
        }
        // StandContext启动很复杂,涉及很多知识面
    }

 

 

ContextConfig

 

首先我们看一下  StandardContext 类中的ContextConfig是何时创建的:


1、Bootstrap.load(String[])

2、Catalina #load()

Digester digester = createStartDigester();

3、Catalina # createStartDigester()

digester.addRuleSet(new HostRuleSet("Server/Service/Engine/"));

4、Digester #addRuleSet(RuleSet ruleSet)

public void addRuleSet(RuleSet ruleSet) {    // ruleSet  ==  org.apache.catalina.startup.HostRuleSet@687080dc
        ruleSet.addRuleInstances(this);   // this == org.apache.tomcat.util.digester.Digester@38bc8ab5
    }

5、HostRuleSet #addRuleInstances(Digester digester)

//  prefix == Server/Service/Engine/

digester.addRule(prefix + "Host",
                         new LifecycleListenerRule
                         ("org.apache.catalina.startup.HostConfig",
                          "hostConfigClass"));
        digester.addSetNext(prefix + "Host",
                            "addChild",  
                            "org.apache.catalina.Container");

       //   通过  Digester  创建  HostConfig, 然后调用 StandardHost 对象的addChild方法,将HostConfig对象添加到StandardHost,

     // 也就是添加到StandardHost父类LifecycleBase中的 属性集合中      private final List<LifecycleListener> lifecycleListeners =

     // new CopyOnWriteArrayList<>();

 

6、StandardHost #start()

7、StandardHost #startInternal()

super.startInternal();

8、StandardHost #fireLifecycleEvent(String type, Object data)

protected void fireLifecycleEvent(String type, Object data) {
        LifecycleEvent event = new LifecycleEvent(this, type, data);
        for (LifecycleListener listener : lifecycleListeners) {
            listener.lifecycleEvent(event);    // listener =  org.apache.catalina.startup.HostConfig@729d991e
        }
    }

9、 HostConfig #lifecycleEvent(LifecycleEvent event)

} else if (event.getType().equals(Lifecycle.START_EVENT)) {
            start();

10、 HostConfig #start()

if (host.getDeployOnStartup())
            deployApps();

11、 HostConfig #deployApps()

protected void deployApps() {

        File appBase = host.getAppBaseFile();
        File configBase = host.getConfigBaseFile();
        String[] filteredAppPaths = filterAppPaths(appBase.list());
        // Deploy XML descriptors from configBase
        deployDescriptors(configBase, configBase.list());
        // Deploy WARs
        deployWARs(appBase, filteredAppPaths);
        // Deploy expanded folders
        deployDirectories(appBase, filteredAppPaths);

    }

12、HostConfig #deployDirectories(File appBase, String[] files)

if (dir.isDirectory()) {
                ContextName cn = new ContextName(files[i], false);

                if (isServiced(cn.getName()) || deploymentExists(cn.getName()))
                    continue;

               //  ExecutorService es = host.getStartStopExecutor();

                results.add(es.submit(new DeployDirectory(this, cn, dir)));
            }

13、HostConfig #deployDirectory(ContextName cn, File dir)

            Class<?> clazz = Class.forName(host.getConfigClass());     //   host.getConfigClass()  == private String configClass =
                                                                                                           //                       "org.apache.catalina.startup.ContextConfig";
            LifecycleListener listener = (LifecycleListener) clazz.getConstructor().newInstance();
            context.addLifecycleListener(listener);  //  listener == org.apache.catalina.startup.ContextConfig@3e11f9e9

至此  HostConfig被添加到 StandardContext对象中。

 


ContextConfig是创建Context时默认添的一个生命周期监听器。它监听6个事件,其中三个和Context启动关系密切:AFTER_INIT_EVENT、BEFORE_START_EVENT、CONFIGURE_START_EVENT。

ContextConfig的lifecycleEvent()方法:

 

StandardContext #fireLifecycleEvent(String type, Object data)

protected void fireLifecycleEvent(String type, Object data) {
        LifecycleEvent event = new LifecycleEvent(this, type, data);
        for (LifecycleListener listener : lifecycleListeners) {
            listener.lifecycleEvent(event);
        }
    }

ContextConfig#lifecycleEvent()方法:

 @Override
    public void lifecycleEvent(LifecycleEvent event) {

        // Identify the context we are associated with
        try {
            context = (Context) event.getLifecycle();
        } catch (ClassCastException e) {
            log.error(sm.getString("contextConfig.cce", event.getLifecycle()), e);
            return;
        }

        // Process the event that has occurred
        if (event.getType().equals(Lifecycle.CONFIGURE_START_EVENT)) {
            configureStart();
        } else if (event.getType().equals(Lifecycle.BEFORE_START_EVENT)) {
            beforeStart();
        } else if (event.getType().equals(Lifecycle.AFTER_START_EVENT)) {
            // Restore docBase for management tools
            if (originalDocBase != null) {
                context.setDocBase(originalDocBase);
            }
        } else if (event.getType().equals(Lifecycle.CONFIGURE_STOP_EVENT)) {
            configureStop();
        } else if (event.getType().equals(Lifecycle.AFTER_INIT_EVENT)) {
            init();
        } else if (event.getType().equals(Lifecycle.AFTER_DESTROY_EVENT)) {
            destroy();
        }

    }

AFTER_INIT_EVENT  事件
严格说,该事件属于Context事件初始化阶段,主要用于Context属性的配置工作。

根据前面讲的,再来回顾一下Context的创建,有以下来源:

解析server.xml中的Context元素。
通过HostConfig部署Web应用时,解析Web应用(或者WAR包)根目录下的META-INF/context.xml文件。如果不存在,则自动创建一个默认的Context对象,只设置name,path,docBase等几个属性。
通诺HostConfig部署Web应用时,解析$CATALINA-BASE/conf/Catalina/localhost目录下的Context部署文件描述符创建。
除了Context创建时的属性配置,Tomcat提供的默认配置也要一并添加到Context实例中,AFTER_INIT_EVENT事件就是要完成这部分工作的。

来看该事件触发时执行的init()方法:
 

/**
     * Process a "init" event for this Context.
     
     	1、处理Context的两个默认配置文件:conf/context.xml和/conf/[enginename]/[hostname]/context.xml.default,解析到context中;
		2、对war包进行校验:主要是校验目录结构(是否有WEB-INF目录,是否有classes目录和META-INF目录等)
		3、对于没有解压的文件还会将其解压:是在ExpandWar类的expand方法中完成的	
     
     */
    protected synchronized void init() {
        // Called from StandardContext.init()

        Digester contextDigester = createContextDigester();
        contextDigester.getParser();

        if (log.isDebugEnabled()) {
            log.debug(sm.getString("contextConfig.init"));
        }
        context.setConfigured(false);
        ok = true;

        contextConfig(contextDigester);
    }

init首先会创建createContextDigester创建解析规则,点进去看可以发现会回到之前讲Server解析时提到的ContextRuleSet,只不过这时传进去的create参数值为false。

不多说,重点来看contextConfig()方法

protected void contextConfig(Digester digester) {

        String defaultContextXml = null;

        // Open the default context.xml file, if it exists
        if (context instanceof StandardContext) {
            defaultContextXml = ((StandardContext)context).getDefaultContextXml();
        }
        // set the default if we don't have any overrides
        if (defaultContextXml == null) {
            defaultContextXml = Constants.DefaultContextXml;
        }

        if (!context.getOverride()) {
            File defaultContextFile = new File(defaultContextXml);
            if (!defaultContextFile.isAbsolute()) {
                defaultContextFile =
                        new File(context.getCatalinaBase(), defaultContextXml);
            }
            if (defaultContextFile.exists()) {
                try {
                    URL defaultContextUrl = defaultContextFile.toURI().toURL();
                    processContextConfig(digester, defaultContextUrl);
                } catch (MalformedURLException e) {
                    log.error(sm.getString(
                            "contextConfig.badUrl", defaultContextFile), e);
                }
            }

            File hostContextFile = new File(getHostConfigBase(), Constants.HostContextXml);
            if (hostContextFile.exists()) {
                try {
                    URL hostContextUrl = hostContextFile.toURI().toURL();
                    processContextConfig(digester, hostContextUrl);
                } catch (MalformedURLException e) {
                    log.error(sm.getString(
                            "contextConfig.badUrl", hostContextFile), e);
                }
            }
        }
        if (context.getConfigFile() != null) {
            processContextConfig(digester, context.getConfigFile());
        }

    }

看到解析的过程如下:

1.如果Context的override属性为false(默认配置):

  1.1 如果存在defaultContextXml即conf/context.xml(Catalina容器级默认配置文件),那么解析该文件,更新Context实例属性。

  1.2 如果存在hostContextXml即$CATALINA-BASE/conf/Catalina/localhost/context.xml.default文件(Host级的默认配置),则解析该文件,更新Context实例属性。

2.如果context的configFile不为空(即$CATALINA-BASE/conf/Catalina/localhost下的Context部署描述文件或者Web应用根目录下的META-INF/context.xml文件),那么解析该文件,更新Context实例属性。

看到这会发现configFile其实被解析了两遍,在创建Context时会先解析一遍,这里再被解析一遍,这是什么原因呢?

因为这里会解析conf/context.xml和context.xml.default文件,配置默认属性,如果之前创建Context时已经配置了某个属性,而这个属性又在conf/context.xml和context.xml.default中存在,显然这时会被覆盖,想要配置Context级别的属性不被覆盖,所以这时再解析一遍。

根据上述,可以得出结论:

Tomcat中Context属性的优先级为:configFile > $CATALINA-BASE/conf/Catalina/localhost/context.xml.default > conf/context.xml,即Web应用配置优先级最高,Host级别配置次之,Catalina容器级别最低。
 

 

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值