Tomcat源码分析(一)Tomcat的总体架构及启动过程

先回顾一下Tomcat的基本知识

Tomcat

百度定义:

Tomcat是Apache 软件基金会(Apache Software Foundation)的Jakarta 项目中的一个核心项目,由Apache、Sun 和其他一些公司及个人共同开发而成。由于有了Sun 的参与和支持,最新的Servlet 和JSP 规范总是能在Tomcat 中得到体现,Tomcat 5支持最新的Servlet 2.4 和JSP 2.0 规范。因为Tomcat 技术先进、性能稳定,而且免费,因而深受Java 爱好者的喜爱并得到了部分软件开发商的认可,成为目前比较流行的Web 应用服务器。

开发者:

Tomcat最初是由Sun的软件构架师詹姆斯·邓肯·戴维森开发的

 

不同版本之间的差异  扩展知识不重要,可以大概了解下

==========================================================

Apache Tomcat 7.x

它在汲取了Tomcat 6.0.x优点的基础上,实现了对于Servlet 3.0、JSP 2.2和EL 2.2等特性的支持。

另外还有

Web应用内存溢出侦测和预防

增强了管理程序和服务器管理程序的安全性

一般 CSRF保护

支持web应用中的外部内容的直接引用

重构 (connectors, lifecycle)及很多核心代码的全面梳理

 

 

 

Apache Tomcat 6.x

在汲取 Tomcat 5.5.x优点的基础上,实现了Servlet 2.5和JSP 2.1等特性的支持。

其他的

 

· 内存使用优化

· 更大的IO容量

 

额外的

Apache Tomcat 5.0.x在Apache Tomcat 4.1的基础上做了很多改动,包括:

· 性能优化和减少垃圾回收动作

· 重构程序部署,通过一个可选的独立部署程序,允许在将一个web应用放进产品前验证和编译它

· 基于JMX的服务器全面监视及web程序管理

· 提高Taglibs的支撑能力,包括改进的数据池和tag插件

· 改进平台集成性,包括Windows和Unix

· 基于JMX的嵌入

· 增强的安全管理支撑

· 集成session集群

· 文档扩充

 

· 重构聚类

============================================================

 

Tomcat的配置不多讲了,以后有机会再整理下。

 

catalina.properties配置文件中参数功能

package.access:针对访问某个package内的代码,Java Security Manager机制提供了安全属性package.access,可以指定哪些包需要受到权限保护,会进行checkPackageAccess验证。

package.definition:同package.access,会进行checkPackageDefinition验证。

common.loader:tomcat自身和各web服务都会用到的类

server.loader:tomcat自身会用到的类

shared.loader:各web服务会用到的类

tomcat.util.buf.StringCache.*:字符缓存配置

 

 

 

重点是对Tomcat的分析

Tomcat中最顶层的容器叫Server,代表整个服务器

而最里面的Service包含Connector和Container.也是心脏组件。

Tomcat的结构分析

Tomcat的总体结构图

图 1.Tomcat 的总体结构

Tomcat 的结构很复杂,但是 Tomcat 也非常的模块化,找到了 Tomcat 最核心的模块,您就抓住了 Tomcat 的“七寸”。

Connector用于处理连接相关的事情,并提供socket与request和reponse之间的转化。

Container用于封装和管理Servlet,以及具体的request请求

Connector 组件是可以被替换,这样可以提供给服务器设计者更多的选择,因为这个组件是如此重要,不仅跟服务器的设计的本身,

而且和不同的应用场景也十分相关,所以一个 Container 可以选择对应多个 Connector。例如同时提供http和https连接

 

 

Tomcat里的Server由Catalina来管理,它里面有3个方法load,start,stop方法分别管理生命周期,还有await

 

Tomcat的启动分析

Tomcat正常启动通过bin目录下startup间接调用catalina,并从catalina中首先执行Bootstrap的main方法,之后通过反射启动Catalina中的方法进行tomcat启动(无论是批处理文件和sh文件这部分处理过程都一样)。

Tomcat的入口main方法在BootStrap中,具体处理过程还是使用Catalina完成,目的把入口和管理分离,方便创建多种启动方式

正常情况下Tomacat的启动就是调用BootStrap中的main方法

Bootstrap类实质上是通过反射的方法去执行Catalina中的函数。Catalina的main方法似乎只是用于调试的,本人目前并没有发现有其他调用的地方。

Bootstrap

Bootstrap.java启动代码如下:

 

        public static void main(String args[]) {
              //先创建了一个daemo
        if (daemon == null) {
            // Don't set daemon until init() has completed
            Bootstrap bootstrap = new Bootstrap();
            try {
                bootstrap.init();
            } catch (Throwable t) {
                handleThrowable(t);
                t.printStackTrace();
                return;
            }
            daemon = bootstrap;
        } else {
            // When running as a service the call to stop will be on a new
            // thread so make sure the correct class loader is used to prevent
            // a range of class not found exceptions.
            Thread.currentThread().setContextClassLoader(daemon.catalinaLoader);
        }

        try {
            String command = "start";
            if (args.length > 0) {
                command = args[args.length - 1];
            }

            if (command.equals("startd")) {
                args[args.length - 1] = "start";
                daemon.load(args);
                daemon.start();
            } else if (command.equals("stopd")) {
                args[args.length - 1] = "stop";
                daemon.stop();
            } else if (command.equals("start")) {
                daemon.setAwait(true);
                daemon.load(args);
                daemon.start();
            } else if (command.equals("stop")) {
                daemon.stopServer(args);
            } else if (command.equals("configtest")) {
                daemon.load(args);
                if (null==daemon.getServer()) {
                    System.exit(1);
                }
                System.exit(0);
            } else {
                log.warn("Bootstrap: command \"" + command + "\" does not exist.");
            }
        } catch (Throwable t) {
            // Unwrap the Exception for clearer error reporting
            if (t instanceof InvocationTargetException &&
                    t.getCause() != null) {
                t = t.getCause();
            }
            handleThrowable(t);
            t.printStackTrace();
            System.exit(1);
        }

    }

   

 

main方法的逻辑:

1.首先新建一个BootStrap

2.执行了init()方法初始化

3.处理main方法传入的参数,若为空,已默认start执行

 

在看init方法

 

public void init()
        throws Exception
    {

        // Set Catalina path
        setCatalinaHome();
        setCatalinaBase();

        initClassLoaders();

        Thread.currentThread().setContextClassLoader(catalinaLoader);

        SecurityClassLoad.securityClassLoad(catalinaLoader);

        // Load our startup class and call its process() method
        if (log.isDebugEnabled())
            log.debug("Loading startup class");
        Class<?> startupClass =
            catalinaLoader.loadClass
            ("org.apache.catalina.startup.Catalina");
        Object startupInstance = startupClass.newInstance();

        // Set the shared extensions class loader
        if (log.isDebugEnabled())
            log.debug("Setting startup class properties");
        String methodName = "setParentClassLoader";
        Class<?> paramTypes[] = new Class[1];
        paramTypes[0] = Class.forName("java.lang.ClassLoader");
        Object paramValues[] = new Object[1];
        paramValues[0] = sharedLoader;
        Method method =
            startupInstance.getClass().getMethod(methodName, paramTypes);
        method.invoke(startupInstance, paramValues);

        catalinaDaemon = startupInstance;

    }


在init方法中

 

1.初始化了ClassLoader

2.用ClassLoader创建了Catalina实例,赋给catalinaDaemo变量

3.对命令操作

 

从main方法中看出来对start命令调用了3个方法setAwait(true)  load(args)  start()  内部都调用了Catalina相应方法执行  只不过是反射来调用

看setAwait方法

 

/**
     * Set flag.
     */
    public void setAwait(boolean await)
        throws Exception {

        Class<?> paramTypes[] = new Class[1];
        paramTypes[0] = Boolean.TYPE;
        Object paramValues[] = new Object[1];
        paramValues[0] = Boolean.valueOf(await);
        Method method =
            catalinaDaemon.getClass().getMethod("setAwait", paramTypes);
        method.invoke(catalinaDaemon, paramValues);

    }

通过反射调用了catalina的setAwait方法

 

 

再看BootStrap的load方法

 

 /**
     * Load daemon.
     */
    private void load(String[] arguments)
        throws Exception {

        // Call the load() method
        String methodName = "load";
        Object param[];
        Class<?> paramTypes[];
        if (arguments==null || arguments.length==0) {
            paramTypes = null;
            param = null;
        } else {
            paramTypes = new Class[1];
            paramTypes[0] = arguments.getClass();
            param = new Object[1];
            param[0] = arguments;
        }
        Method method =
            catalinaDaemon.getClass().getMethod(methodName, paramTypes);
        if (log.isDebugEnabled())
            log.debug("Calling startup class " + method);
        method.invoke(catalinaDaemon, param);

    }

通过反射调用了Catalina的load方法
 

 

再看BootStrap的start方法

 

/**
     * Start the Catalina daemon.
     */
    public void start()
        throws Exception {
        if( catalinaDaemon==null ) init();

        Method method = catalinaDaemon.getClass().getMethod("start", (Class [] )null);
        method.invoke(catalinaDaemon, (Object [])null);

    }


做了对daemoCatiner是否初始化判断了一次,然后在使用Method进行反射调用Catalina的start方法

 

Catalina

那肯定要分析Catalina这个类了它是Server的管理类

Catalina的启动分析

 

    /*
     * Load using arguments
     */
    public void load(String args[]) {

        try {
            if (arguments(args)) {
                load();
            }
        } catch (Exception e) {
            e.printStackTrace(System.out);
        }
    }

load方法

 

load方法用于加载配置文件,创建并初始化Server;

start用于启动服务器

setAwait方法

Catalina的setAwait 用于设置Server启动后是否进入等待状态  如果为true进入等待

 /**
     * Use await.
     */
    protected boolean await = false;
 
   public void setAwait(boolean b) {
        await = b;
    }


load方法

    /*
     * Load using arguments
     */
    public void load(String args[]) {

        try {
            if (arguments(args)) {
                load();
            }
        } catch (Exception e) {
            e.printStackTrace(System.out);
        }
    }


load的无参实现

初始化目录

初始化名称

初始化配置

    public void load() {

        long t1 = System.nanoTime();

        initDirs();

        // Before digester - it may be needed

        initNaming();

        // Create and execute our Digester
        Digester digester = createStartDigester();

        InputSource inputSource = null;
        InputStream inputStream = null;
        File file = null;
        try {
            try {
                file = configFile();
                inputStream = new FileInputStream(file);
                inputSource = new InputSource(file.toURI().toURL().toString());
            } catch (Exception e) {
                if (log.isDebugEnabled()) {
                    log.debug(sm.getString("catalina.configFail", file), e);
                }
            }
            if (inputStream == null) {
                try {
                    inputStream = getClass().getClassLoader()
                        .getResourceAsStream(getConfigFile());
                    inputSource = new InputSource
                        (getClass().getClassLoader()
                         .getResource(getConfigFile()).toString());
                } catch (Exception e) {
                    if (log.isDebugEnabled()) {
                        log.debug(sm.getString("catalina.configFail",
                                getConfigFile()), e);
                    }
                }
            }

            // This should be included in catalina.jar
            // Alternative: don't bother with xml, just create it manually.
            if( inputStream==null ) {
                try {
                    inputStream = getClass().getClassLoader()
                            .getResourceAsStream("server-embed.xml");
                    inputSource = new InputSource
                    (getClass().getClassLoader()
                            .getResource("server-embed.xml").toString());
                } catch (Exception e) {
                    if (log.isDebugEnabled()) {
                        log.debug(sm.getString("catalina.configFail",
                                "server-embed.xml"), e);
                    }
                }
            }


            if (inputStream == null || inputSource == null) {
                if  (file == null) {
                    log.warn(sm.getString("catalina.configFail",
                            getConfigFile() + "] or [server-embed.xml]"));
                } else {
                    log.warn(sm.getString("catalina.configFail",
                            file.getAbsolutePath()));
                    if (file.exists() && !file.canRead()) {
                        log.warn("Permissions incorrect, read permission is not allowed on the file.");
                    }
                }
                return;
            }

            try {
                inputSource.setByteStream(inputStream);
                digester.push(this);
                digester.parse(inputSource);
            } catch (SAXParseException spe) {
                log.warn("Catalina.start using " + getConfigFile() + ": " +
                        spe.getMessage());
                return;
            } catch (Exception e) {
                log.warn("Catalina.start using " + getConfigFile() + ": " , e);
                return;
            }
        } finally {
            if (inputStream != null) {
                try {
                    inputStream.close();
                } catch (IOException e) {
                    // Ignore
                }
            }
        }

        getServer().setCatalina(this);

        // Stream redirection
        initStreams();

        // Start the new server
        try {
            getServer().init();
        } catch (LifecycleException e) {
            if (Boolean.getBoolean("org.apache.catalina.startup.EXIT_ON_INIT_FAILURE")) {
                throw new java.lang.Error(e);
            } else {
                log.error("Catalina.start", e);
}

}

}

简化一下没用的代码片

    public void load() {

        long t1 = System.nanoTime();

       //。。。。。。。。。。。。。。。。。。。。。。此处创建Server的代码,由Digester完成直接省略
     
        // Start the new server
        try {
            getServer().init();
        } catch (LifecycleException e) {
            if (Boolean.getBoolean("org.apache.catalina.startup.EXIT_ON_INIT_FAILURE")) {
                throw new java.lang.Error(e);
            } else {
                log.error("Catalina.start", e);
            }

        }

        long t2 = System.nanoTime();
        if(log.isInfoEnabled()) {
           //控制台可以看到的启动过程的一行
            log.info("Initialization processed in " + ((t2 - t1) / 1000000) + " ms");
        }

    }

 

整理一下Catalina的load方法

根据conf/server.xml创建Server对象,并赋值给server属性(具体开源解析操作是开源项目Digester完成的)

然后调用了server的init方法

 

start方法

最后来看下start方法

 

public void start() {

        if (getServer() == null) {
            load();
        }

        if (getServer() == null) {
            log.fatal("Cannot start server. Server instance is not configured.");
            return;
        }

        long t1 = System.nanoTime();

        // Start the new server
        try {
            getServer().start();
        } catch (LifecycleException e) {
            log.fatal(sm.getString("catalina.serverStartFail"), e);
            try {
                getServer().destroy();
            } catch (LifecycleException e1) {
                log.debug("destroy() failed for failed Server ", e1);
            }
            return;
        }

        long t2 = System.nanoTime();
        if(log.isInfoEnabled()) {
            log.info("Server startup in " + ((t2 - t1) / 1000000) + " ms");
        }

        // Register shutdown hook
        if (useShutdownHook) {
            if (shutdownHook == null) {
                shutdownHook = new CatalinaShutdownHook();
            }
            Runtime.getRuntime().addShutdownHook(shutdownHook);

            // If JULI is being used, disable JULI's shutdown hook since
            // shutdown hooks run in parallel and log messages may be lost
            // if JULI's hook completes before the CatalinaShutdownHook()
            LogManager logManager = LogManager.getLogManager();
            if (logManager instanceof ClassLoaderLogManager) {
                ((ClassLoaderLogManager) logManager).setUseShutdownHook(
                        false);
            }
        }

        if (await) {
            await();
            stop();
        }
    }


整理一下从中发现

1.它是调用了server的start启动并且对异常进行了了处理资源关闭和是否等待处理 

2.中间还有一部分是注册和关闭钩子直接忽视

3.如果进入了等待状态要进行await和stop其实内部也是调用了Server的await和stop  

实现就是个while循环  while结束调用stop

把我心态都搞崩了。。。

 

最后我们发现了Catalina的启动等待关闭是调用Service的启动关闭

然后我们来看一下

Server

Server的启动过程

了解一下Server接口实现了Lifecycle接口启动关闭等

 

public interface Server extends Lifecycle {


    // ------------------------------------------------------------- Properties


    /**
     * Return descriptive information about this Server implementation and
     * the corresponding version number, in the format
     * <code><description>/<version></code>.
     */
    public String getInfo();


    /**
     * Return the global naming resources.
     */
    public NamingResources getGlobalNamingResources();


    /**
     * Set the global naming resources.
     *
     * @param globalNamingResources The new global naming resources
     */
    public void setGlobalNamingResources
        (NamingResources globalNamingResources);


    /**
     * Return the global naming resources context.
     */
    public javax.naming.Context getGlobalNamingContext();


    /**
     * Return the port number we listen to for shutdown commands.
     */
    public int getPort();


    /**
     * Set the port number we listen to for shutdown commands.
     *
     * @param port The new port number
     */
    public void setPort(int port);


    /**
     * Return the address on which we listen to for shutdown commands.
     */
    public String getAddress();


    /**
     * Set the address on which we listen to for shutdown commands.
     *
     * @param address The new address
     */
    public void setAddress(String address);


    /**
     * Return the shutdown command string we are waiting for.
     */
    public String getShutdown();


    /**
     * Set the shutdown command we are waiting for.
     *
     * @param shutdown The new shutdown command
     */
    public void setShutdown(String shutdown);


    /**
     * Return the parent class loader for this component. If not set, return
     * {@link #getCatalina()} {@link Catalina#getParentClassLoader()}. If
     * catalina has not been set, return the system class loader.
     */
    public ClassLoader getParentClassLoader();


    /**
     * Set the parent class loader for this server.
     *
     * @param parent The new parent class loader
     */
    public void setParentClassLoader(ClassLoader parent);


    /**
     * Return the outer Catalina startup/shutdown component if present.
     */
    public Catalina getCatalina();

    /**
     * Set the outer Catalina startup/shutdown component if present.
     */
    public void setCatalina(Catalina catalina);

    // --------------------------------------------------------- Public Methods


    /**
     * Add a new Service to the set of defined Services.
     *
     * @param service The Service to be added
     */
    public void addService(Service service);


    /**
     * Wait until a proper shutdown command is received, then return.
     */
    public void await();


    /**
     * Return the specified Service (if it exists); otherwise return
     * <code>null</code>.
     *
     * @param name Name of the Service to be returned
     */
    public Service findService(String name);


    /**
     * Return the set of Services defined within this Server.
     */
    public Service[] findServices();


    /**
     * Remove the specified Service from the set associated from this
     * Server.
     *
     * @param service The Service to be removed
     */
    public void removeService(Service service);
}


其实说白了提供了addService  removeService来添加和删除Service,和获取设置端口号地址,是否关闭,获取设置父类加载器

 

它的init和start方法分别循环调用了Service的start和init方法来启动所有Service

Server的默认实现是StandardServer

final class StandardServer extends LifecycleMBeanBase implements Server

StandardServer继承LifecycleMBeansBase  

LifecycleMBeansBase  继承LifecycleBase中定义了start和init方法调用的是initInternal的startInternal方法,都是模板方法

所以就是LifecycleMBeansBase   调用start最后还是会变成调用startInternal   只不过在基类的start多了些状态异常判断,这个自己随便翻翻源码就能看到了。

这也就是Tomcat生命周期的管理方式

首先是


    /**
     * Invoke a pre-startup initialization. This is used to allow connectors
     * to bind to restricted ports under Unix operating environments.
     */
    @Override
    protected void initInternal() throws LifecycleException {
        
        super.initInternal();

        // Register global String cache
        // Note although the cache is global, if there are multiple Servers
        // present in the JVM (may happen when embedding) then the same cache
        // will be registered under multiple names
        onameStringCache = register(new StringCache(), "type=StringCache");

        // Register the MBeanFactory
        MBeanFactory factory = new MBeanFactory();
        factory.setContainer(this);
        onameMBeanFactory = register(factory, "type=MBeanFactory");
        
        // Register the naming resources
        globalNamingResources.init();
        
        // Populate the extension validator with JARs from common and shared
        // class loaders
        if (getCatalina() != null) {
            ClassLoader cl = getCatalina().getParentClassLoader();
            // Walk the class loader hierarchy. Stop at the system class loader.
            // This will add the shared (if present) and common class loaders
            while (cl != null && cl != ClassLoader.getSystemClassLoader()) {
                if (cl instanceof URLClassLoader) {
                    URL[] urls = ((URLClassLoader) cl).getURLs();
                    for (URL url : urls) {
                        if (url.getProtocol().equals("file")) {
                            try {
                                File f = new File (url.toURI());
                                if (f.isFile() &&
                                        f.getName().endsWith(".jar")) {
                                    ExtensionValidator.addSystemResource(f);
                                }
                            } catch (URISyntaxException e) {
                                // Ignore
                            } catch (IOException e) {
                                // Ignore
                            }
                        }
                    }
                }
                cl = cl.getParent();
            }
        }
        // Initialize our defined Services
        for (int i = 0; i < services.length; i++) {
            services[i].init();
        }
    }
    

我们现在要看的是StandardServer的startInternal方法

protected void startInternal() throws LifecycleException {

        fireLifecycleEvent(CONFIGURE_START_EVENT, null);
        setState(LifecycleState.STARTING);

        globalNamingResources.start();
        
        // Start our defined Services
        synchronized (servicesLock) {
            for (int i = 0; i < services.length; i++) {
                services[i].start();
            }
        }
    }

循环调用了每一个Service的start来启动

 

还有一个就是await方法的实现来看一下

 public void await() {
        // Negative values - don't wait on port - tomcat is embedded or we just don't like ports
        if( port == -2 ) {
            // undocumented yet - for embedding apps that are around, alive.
            return;
        }
        if( port==-1 ) {
            try {
                awaitThread = Thread.currentThread();
                while(!stopAwait) {
                    try {
                        Thread.sleep( 10000 );
                    } catch( InterruptedException ex ) {
                        // continue and check the flag
                    }
                }
            } finally {
                awaitThread = null;
            }
            return;
        }

        // Set up a server socket to wait on
        try {
            awaitSocket = new ServerSocket(port, 1,
                    InetAddress.getByName(address));
        } catch (IOException e) {
            log.error("StandardServer.await: create[" + address
                               + ":" + port
                               + "]: ", e);
            return;
        }

慢慢分析 逻辑为 可以画张图

首先判断端口号

如果端口号为-2直接退出

如果端口号为-1进入循环,无法通过网络退出,stopAwait标志只有调用了stop方法才能设置为true  所以在-1时,只有外部调用stop方法才能退出循环

否则创建一个监听关闭的ServerSocket  如果收到关闭命令则break退出循环

此处的port和关闭命令是在conf/server.xml中配置Server时设置的 默认为8005  SHUNTDOWN命令  如果不想使用网络命令关闭服务器那么可将端口号设为-1;

 

Service

分析完了Server的启动过程来分析Service的启动过程

Service的启动过程

Service接口其实也就是规定了获取和设置一些比如名字,服务的方法,而最重要的是它的实现

class StandardService extends LifecycleMBeanBase implements Service

是不是心里有了日了狗的感觉和StandardServer怎么那么相似呢?话不多说

那肯定start调用了startInternal  init调用了initInternal   闭着眼睛回答的。

 

我们为了专注代码分析,现在开始忽略异常处理和日志打印。

直接从initInternal方法来看

 

protected void initInternal() throws LifecycleException {

        super.initInternal();        
        if (container != null) {
            container.init();
        }

        // Initialize any Executors
        for (Executor executor : findExecutors()) {
            if (executor instanceof LifecycleMBeanBase) {
                ((LifecycleMBeanBase) executor).setDomain(getDomain());
            }
            executor.init();
        }

        // Initialize our defined Connectors
        synchronized (connectorsLock) {
            for (Connector connector : connectors) {
              //省略了这里的异常处理,日志打印
                    connector.init();
            }
        }
    }

快点讲解发现initInternal初始化了Excutors 循环并调用了Connector的init方法
这个Excutor是什么??

 

看一下startInternal

 

protected void startInternal() throws LifecycleException {

        if(log.isInfoEnabled())//日志
            log.info(sm.getString("standardService.start.name", this.name));
        setState(LifecycleState.STARTING);

        // Start our defined Container first
        if (container != null) {
            synchronized (container) {
                container.start();
            }
        }

        synchronized (executors) {
            for (Executor executor: executors) {
                executor.start();
            }
        }

        // Start our defined Connectors second
        synchronized (connectorsLock) {
            for (Connector connector: connectors) {
              //这里省略了日志异常
                    // If it has already failed, don't try and start it
                    if (connector.getState() != LifecycleState.FAILED) {
                        connector.start();
                    }
            }
        }
    }

发现它调用了Container的start和循环调容器用了Excutor和Connector的start。其他啥也没干

 

不知道为啥在老版本里还有个mapperListener是Mapper的监听器,可以监听Connector容器的变化,在tomcat7里没有了

而executors是用在Connectors中管理线程的线程池,在server.xml里注释起来的参考用法

关于Tomcat线程池的使用后续补充。

 

Tomcat的整个启动流程图如下

 

分析了半天原来是这样一张图,累死了

今天先分析到这  后续还有。。。。

Tomcat的生命周期管理

Container分析

Pipeliner-Value管道分析

Connector分析

 

 

欢迎关注我的个人订阅号,我会推送更好的文章给大家

订阅号

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值