Tomcat8源码解析

本文介绍了如何下载Tomcat8源码并导入工程,然后逐步手写一个简单的Tomcat,通过Socket编程实现监听端口和数据交互。作者分析了源码中的servlet加载和优化过程,探讨了StandardContext.loadOnStartup()方法以及wrapper与servlet的关系。文章还验证了Tomcat如何加载servlets和监听端口,并提供了源码分析的整体流程,帮助读者理解Tomcat的工作原理。
摘要由CSDN通过智能技术生成

Tomcat是一款大家非常熟悉的web服务器,具体的功能和怎么使用我就不赘述咯,今天我们主要来分析Tomcat的源码,基于的版本是8.0.11。问题是从哪边开始着手呢?我是这样想的,肯定是先把Tomcat8.0.11的源码在官网中下载下来,然后导入到工程中。

1 Tomcat源码下载及导入工程

1.1 源码下载

Tomcat版本:Tomcat8.0.11

下载地址:点击下载

然后选择对应的平台即可

1.2 pom文件准备及导入工程

下载好了之后解压,我是基于windows平台的,我们可以基于maven的方式进行构建,所以需要引入pom.xml文件相关的依赖,下面是我给大家准备的pom.xml文件,大家可以直接使用,放到源码的根目录下。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">

    <modelVersion>4.0.0</modelVersion>
    <groupId>org.apache.tomcat</groupId>
    <artifactId>Tomcat8.0</artifactId>
    <name>Tomcat8.0</name>
    <version>8.0</version>

    <build>
        <finalName>Tomcat8.0</finalName>
        <sourceDirectory>java</sourceDirectory>
        <testSourceDirectory>test</testSourceDirectory>
        <resources>
            <resource>
                <directory>java</directory>
            </resource>
        </resources>
        <testResources>
            <testResource>
                <directory>test</directory>
            </testResource>
        </testResources>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>2.3</version>
                <configuration>
                    <encoding>UTF-8</encoding>
                    <source>1.8</source>
                    <target>1.8</target>
                </configuration>
            </plugin>
        </plugins>
    </build>

    <dependencies>
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.12</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.easymock</groupId>
            <artifactId>easymock</artifactId>
            <version>3.4</version>
        </dependency>
        <dependency>
            <groupId>ant</groupId>
            <artifactId>ant</artifactId>
            <version>1.7.0</version>
        </dependency>
        <dependency>
            <groupId>wsdl4j</groupId>
            <artifactId>wsdl4j</artifactId>
            <version>1.6.2</version>
        </dependency>
        <dependency>
            <groupId>javax.xml</groupId>
            <artifactId>jaxrpc</artifactId>
            <version>1.1</version>
        </dependency>
        <dependency>
            <groupId>org.eclipse.jdt.core.compiler</groupId>
            <artifactId>ecj</artifactId>
            <version>4.5.1</version>
        </dependency>
    </dependencies>
</project>

好了之后,可以将源码导入到工程中,这里我使用的是idea,导入完成之后如下图所示。

接下来就是分析源码吗?别急,现在分析也不会有任何思路,完全一脸懵逼,所以我们得想想该如何入手。看了看这些文件,我们能够得出的结论是,发现Tomcat是Java语言开发的,其实这个可能大家一开始就知道,没什么了不起的。既然是Java语言开发的,我们又是Java开发人员,不妨先自己手写一个Tomcat,然后看根据手写的思路再去推导源码的实现方式,这样不就无缝结合了吗?ok,那就手写咯。

2 手写Tomcat

手写之前先想想Tomcat的作用是什么?是web服务器,它是给客户端提供访问并且返回给客户端资源的,此时得要写一段代码能够让客户端连接上来,然后再进行数据交互操作。怎样让客户端连接上来?用Java中已有的知识可以采用Socket编程。

2.1 ServerSocket监听端口等待客户端连接

class MyTomcat{
    ServerSocket server=new ServerSocket(8080);
    Socket socket=server.accept();
}

2.2 根据客户端的连接处理数据交互

class MyTomcat{
    ServerSocket server=new ServerSocket(8080);
    Socket socket=server.accept();
    //接受客户端传来的数据
    InputStream in=socket.getInputstream();
    //进行相应的业务处理
    ......
    //返回给客户端数据
    OutputStream out=socket.getOutputStream();
}

至此,通过Socket编程和IO一个mini版的Tomcat就完成了,关键是我们得想想是否可以优化。

2.3 优化Tomcat

  • Request和Response优化

了解过http协议的小伙伴都知道,http传来的请求有请求头,请求行,请求体这些,最主要的是哪些键值对数据,既然Java是一门面向对象的开发语言,不妨把这些数据封装成Java中的对象,于是我们声明一个Request类,如下所示。

class Request{
    private String accept;
    private String host;
    priavte ...
}

这些属性和http请求中的数据时一一对应的,有了request之后,不妨把response也封装一下咯。

class Response{
    private String Content-Type;
    private Date date;
    private ...
}

这时候http中的请求和响应和Java中的Request和Response对象已经对应起来了,所以代码可以优化成如下所示。

class MyTomcat{
    ServerSocket server=new ServerSocket(8080);
    Socket socket=server.accept();
    //接受客户端传来的数据
    InputStream in=socket.getInputstream();
    //将输入流封装成Request对象
    Request request=new Request(in);
    //进行相应的业务处理
    ......
    //返回给客户端数据
    OutputStream out=socket.getOutputStream();
    //将输入流封装成Response对象
    Response response=new Response(out);
}
  • Servlet优化

这时候Tomcat已经相对于前面的优雅一些了,此时我们来假设一个业务场景,比如需要做一个登陆的功能,肯定要拿到客户端传来的用户名和密码。也就是业务代码一定要获取到用户名和密码,但是如果使用自己写的Tomcat,发现Request对象又被封装在了Tomcat内部,发现获取该对象不是太方便,所以得换个思路。其实在JavaEE的Servlet规范中,Servlet帮我们封装好了请求和响应的类,大家一定很熟悉。

class Servlet{
    service(Request request,Response response){
        ...
    }
}

也就是说如果我们想要进行业务代码的开发,比如登录,可以写一个类继承Servlet,然后就可以获取到客户端传来的请求和响应。

class LoginServlet extends Servlet{
    doService(Request request,Response response){
        ...
    }
}

这样一来就可以顺利拿到客户端传来的数据以及返回数据给客户端。

关键是这里的request和原来我们自己写的tomcat中的request的关系是如何的呢?这样不就重复定义了吗?好像有点,那不妨把类似于LoginServlet添加到Tomcat源码中?也就是但凡有一个servlet就加一个到Tomcat中进行处理,也就是Tomcat源码可以优化成这样。

class MyTomcat{
    ServerSocket server=new ServerSocket(8080);
    Socket socket=server.accept();
    //这样地方就可以用一个List集合不断地把业务代码中servlet加载进来
    list.add(servlets);
}

我们的web项目中到底有多少个servlet呢?根据大家的开发经验都知道,每个业务代码的Servlet类都可以在web.xml文件中进行配置,类似于下面这段配置。

<servlet>
       <servlet-name>LoginServlet</servlet-name>
       <servlet-class>com.gupao.web.servlet.SimpleServlet</servlet-class>
</servlet>
<servlet-mapping>  
       <servlet-name>LoginServlet</servlet-name> 
       <url-pattern>/login</url-pattern>
</servlet-mapping>

这样在Tomcat源码中只需要读取到每个项目对应的web.xml文件,然后解析其中的servlet标签,将一个个servlet实例化加载到list集合中即可。

2.4 思路过渡

有了上面手写的思路分析,此时可以总结出两点。第一,监听端口;第二,加载servlets。发现说起来还挺合情合理的,但是这些都是我们自己的主观臆断,官网源码是否也是这样的思路,这个我们需要验证,那接下来咱们就围绕这两点进行一下验证,先验证servlets再验证监听端口。

3 验证加载servlets和监听端口

3.1 加载servlets

那源码中到底哪里是加载servlet的呢?我先给大家一条搜索主线。

Context->StandardContext.loadOnStartup(Container children[])这个方法就是加载每个web项目加载servlets的地方。很明显,下面方法上的英文注释已经表明了,而且在代码中也能找到list.add(wrapper)这个方法,和手写中的list.add(servlets)非常像,只要证明wrapper就是servlet即可。

/**
 * Load and initialize all servlets marked "load on startup" in the
 * web application deployment descriptor.
 *
 * @param children Array of wrappers for all currently defined
 *  servlets (including those not declared load on startup)
 */
public boolean loadOnStartup(Container children[]) {
    // Collect "load on startup" servlets that need to be initialized
    TreeMap<Integer, ArrayList<Wrapper>> map = new TreeMap<>();
    for (int i = 0; i < children.length; i++) {
        Wrapper wrapper = (Wrapper) children[i];
        int loadOnStartup = wrapper.getLoadOnStartup();
        if (loadOnStartup < 0)
            continue;
        Integer key = Integer.valueOf(loadOnStartup);
        ArrayList<Wrapper> list = map.get(key);
        if (list == null) {
            list = new ArrayList<>();
            map.put(key, list);
        }
        list.add(wrapper);
    }
    ...
}

这时候大家可能会有两个疑惑,一是为什么是找StandardContext.loadOnStartup()这个方法,二是wrapper到底是不是servlet。接下来我们就围绕这两个问题探讨一下。

3.1.1 为什么是StandardContext.loadOnStartup()

我是这样想的,如果我是Tomcat源码的设计者,加载servlet一定会以web项目为单位,因为Tomcat中可能会有很多的web项目,而每个web项目中又有很多的Servlet类,所以以web项目为单位是最合适的。那Tomcat源码中什么可以代表一个web项目呢?问题就会聚焦到找代表web项目的东西。

先不急,我们可以回到tomcat产品中在conf下有一个server.xml文件,而这个文件是tomcat的核心配置文件,我找到一份server.xml文件,并且把其中的注释删掉了,如下所示。

<?xml version='1.0' encoding='utf-8'?>
<Server port="8005" shutdown="SHUTDOWN">
  <Listener className="org.apache.catalina.core.AprLifecycleListener" SSLEngine="on" />
  <!-- Prevent memory leaks due to use of particular java/javax APIs-->
  <Listener className="org.apache.catalina.core.JreMemoryLeakPreventionListener" />
  <Listener className="org.apache.catalina.mbeans.GlobalResourcesLifecycleListener" />
  <Listener className="org.apache.catalina.core.ThreadLocalLeakPreventionListener" />
  <GlobalNamingResources>
    <Resource name="UserDatabase" auth="Container"
              type="org.apache.catalina.UserDatabase"
              description="User database that can be updated and saved"
              factory="org.apache.catalina.users.MemoryUserDatabaseFactory"
              pathname="conf/tomcat-users.xml" />
  </GlobalNamingResources>
  <Service name="Catalina">
    <Connector port="8080" protocol="HTTP/1.1"
               connectionTimeout="20000"
               redirectPort="8443" />
    <Connector port="8009" protocol="AJP/1.3" redirectPort="8443" />
    <Engine name="Catalina" defaultHost="localhost">
      <Realm className="org.apache.catalina.realm.LockOutRealm">
        <Realm className="org.apache.catalina.realm.UserDatabaseRealm"
               resourceName="UserDatabase"/>
      </Realm>
      <Host name="localhost"  appBase="webapps"
            unpackWARs="true" autoDeploy="true">
        <Valve className="org.apache.catalina.valves.AccessLogValve" directory="logs"
               prefix="localhost_access_log" suffix=".txt"
               pattern="%h %l %u %t &quot;%r&quot; %s %b" />
      </Host>
    </Engine>
  </Service>
</Server>

大家可以先不用具体关系每个标签的含义,至少这个文件大家是见过的,而且我们可能还配置过一些标签属性之类的,比如我们想要把web项目放到Tomcat中供外界访问,可以将项目deploy到webapps下,这是一种方式。除此之外,在里面还可以配置这样的标签。是放到Host标签里面的,也就是可以通过这样的方式同样达到和deploy到webapps一样的效果。

<Context path="/gupao" docBase="E:/gupao"/>

这说明什么呢?是不是可以某种意义一个Context标签就可以代表一个web项目?是的,没错,可以。再看server.xml文件中的标签还有什么含义?看过一些框架源码的哥们应该知道,这些标签一般和源码中的类或接口是一一对应的,我们可以尝试在Tomcat源码中搜索这些标签名称的类,我这里就不带领大家搜索了,大家可以自己做一个验证。

另外我再给大家展示一张图,这张图是Tomat的架构图,通过这张图大家能够发现,图中的组件和server.xml文件中的标签名称一一对应,而且包含的层次也是一一对应的。当然,图中的有些组件的名称可能在默认server.xml文件中没有配置,图解中如果某个组件是多层的意味着该标签在server.xml文件可以配置多个。也就是说Tomcat架构图,server.xml中标签和源码中的类是一一对应的关系。

ok,啰嗦了这么多,回到我们想讨论的内容。我们已经能够知道Context标签可以代表web项目,也就是Tomcat源码中一定有一个Context类,而且该类代表的是web项目。

前面说加载servlets一定是以web项目为单位的,如果我想要找加载servlets在源码中的地方,一定是先找到Context类,发现它是一个接口,肯定继续找它的实现类,它有一个标准的实现类StandardContext。接着要想找加载servlets的地方,肯定是找这个类的方法,打开该类的所有方法,发现只有loadOnStartup是命名最像的。至此,Tomcat源码加载servlets的完成。

3.1.2 wrapper到底是不是servlet

接下来要探讨的问题就是在loadOnStartup中list.add(wrapper)这个到底是不是添加的servlet,换句话说就是wrapper到底是不是servlet。

先把脑袋放空,什么都不要想,要想证明wrapper是servlet,我觉得可以先看另外一个问题。也就是我们找到了Tomcat源码加载servlet的地方,但是都没有看到Tomcat源码解析每个web项目中web.xml文件servlet标签的过程,不妨把这个先找一下。

既然Context代表web项目,那web项目的配置命名应该是ContextConfig,源码中搜索一下该类,果然有,也就是读取web项目的配置文件是在这个类中发生的,关键是找哪个方法呢?

一不小心找到一个webConfig()方法,如下所示

/**
     * Scan the web.xml files that apply to the web application and merge them
     * using the rules defined in the spec. For the global web.xml files,
     * where there is duplicate configuration, the most specific level wins. ie
     * an application's web.xml takes precedence over the host level or global
     * web.xml file.
     */
    protected void webConfig() {
    	...
    }

该方法上的注释想必大家都能看到,英语应该都过了8级对吧?显然是扫描每个web项目中对应的web.xml文件。

  • 找到web.xml文件的路径

要想加载解析web.xml文件肯定要先知道它的路径,大家可以按照下面这条线找到对应的路径。

org.apache.catalina.startup.ContextConfig#webConfig()->getContextWebXmlSource()->servletContext.getResourceAsStream(Constants.ApplicationWebXml)

我们可以看到一个ApplicationWebXml配置:

Constants.ApplicationWebXml = "/WEB-INF/web.xml";
  • 解析web.xml文件中的标签并创建对象

继续回到webConfig()方法,找到step9有这样一段代码

// Step 9. Apply merged web.xml to Context
if (ok) {
    configureContext(webXml);
}

也就是configureContext(webXml)就是解析的,那点开这个方法看一下咯。慢慢往下拉,会发现有很多web.xml文件中熟悉的标签,比如ErrorPage,FilterMap,listener,ContextResource等。继续往下看,会发现有这段代码。这里明显就是解析web.xml文件中的servlet,并且创建相应的对象,然后会将servlet包装成wrapper。ok,至此,我们可以确定的是wrapper就是对servlet的包装,也就是servlet。

for (ServletDef servlet : webxml.getServlets().values()) {
    Wrapper wrapper = context.createWrapper();
    // Description is ignored
    // Display name is ignored
    // Icons are ignored

    // jsp-file gets passed to the JSP Servlet as an init-param

    if (servlet.getLoadOnStartup() != null) {
        wrapper.setLoadOnStartup(servlet.getLoadOnStartup().intValue());
    }
    if (servlet.getEnabled() != null) {
        wrapper.setEnabled(servlet.getEnabled().booleanValue());
    }
    wrapper.setName(servlet.getServletName());
    ...
}

3.2 监听端口

经过上面一同胡说八道,终于解决了手写Tomcat中加载servlets以及源码验证部分的内容。接下来就是要解决监听端口在Tomcat源码中是否也有同样的实现方式。说白了就是要找到new ServerSocket(port)这样的代码。

关键是从哪入手呢?不妨把目光还是转移到server.xml文件和Tomcat架构图,忘了那张架构图的可以往上翻一下,我这里就不贴出来咯。要想和客户端发生点关系,用脚指头想也知道肯定和Connector相关,也就是说和源码中Connector类相关,那接下来咱们就从Connector开始找找ServerSocket在哪。

Connector.initInternal()->protocolHandler.init()->AbstractProtocol.init()->endpoint.init()->bind()-JIoEndpoint.bind()

对于bind()方法其实支持很多种IO实现方式,比如有BIO,NIO,NIO2等。

打开JIoEndpoint.bind()方法,发现其中有这段代码

if (getAddress() == null) {
    serverSocket = serverSocketFactory.createSocket(getPort(),
            getBacklog());
} else {
    serverSocket = serverSocketFactory.createSocket(getPort(),
            getBacklog(), getAddress());
}

很明显,这不就是我们要找的ServerSocket创建的代码吗?继续点开createSocket(getPort(),
getBacklog())方法,找到DefaultServerSocketFactory的实现。

@Override
public ServerSocket createSocket (int port, int backlog)
        throws IOException {
    return new ServerSocket (port, backlog);
}

ok,到这里已经找到了Tomcat源码中使用ServerSocket监听的代码部分,这其实也是BIO的实现方法,也就是JIo,即Java IO[传统Java IO的实现方式]

4 Tomcat整体源码阅读

经过上面的折腾,我们已经能从Tomcat中找到加载servlet和监听端口所在的地方,接下来我们将整个过程串一串,也就是从Tomcat启动开始大概经历的过程。

4.1 Tomcat启动入口类

既然Tomcat是Java开发的,那么肯定有一个入口类,这个类中一定会有一个main函数。

从方法的注释可以看得出来,该方法是启动Tomcat的一个入口方法。

/**
 * Main method and entry point when starting Tomcat via the provided
 * scripts.
 *
 * @param args Command line arguments to be processed
 */
public static void main(String args[]) {

    if (daemon == null) {
        // Don't set daemon until init() has completed
        Bootstrap bootstrap = new Bootstrap();
        try {
        }
    }
    ...
}

继续往下走,会发现有startstop这样的字符串。顾名思义,肯定是根据脚本名称执行相应的操作,比如我们想进行start操作,这时候会调用这段代码。

if (command.equals("startd")) {
    args[args.length - 1] = "start";
    //先加载
    daemon.load(args);
    //再启动
    daemon.start();
}

4.2 load()过程

当调用load的时候,发现它使用的是反射的方式调用Catalina.load()方法。

来到该方法,发现如下代码,看上面的注释发现是Start a new server instance.也就是开始一个server的实例。

/**
 * Start a new server instance.
 */
public void load() {
    long t1 = System.nanoTime();
    ...
}

这时候我们不妨先想想,为什么是Server呢?这个名称好像在哪看过。没错,在server.xml文件中,
Tomcat架构图最外层的组件,Tomcat源码中都有这样的Server名称。是不是可以这样认为?现在Tomcat源码实际上是要根据server.xml文件来创建相应的对象,而这个创建的过程是从最外面的Server开始,然后依次往里面进行加载,比如Service,Connector等,如果是这样,我们继续往下看。

4.3 找到server.xml文件位置

不管先加载创建哪个名称,肯定是先要找到server.xml文件所在位置,来看代码

file = configFile()->File file = new File(configFile)->protected String configFile = "conf/server.xml";

这样就顺利找到了server.xml文件所在位置。

4.4 解析server.xml文件加载Server标签创建对象

继续回到Catalina.init()方法,该方法加载到server.xml文件之后,就会把起解析到input输入流中。

inputStream = new FileInputStream(file);
inputSource = new InputSource(file.toURI().toURL().toString());
try {
    inputSource.setByteStream(inputStream);
    digester.push(this);
    digester.parse(inputSource);
}

然后就可以解析server.xml由外向内创建相应对象咯,这一块大家可以把之前的Tomcat架构图放在一旁结合源码一起看会更有感觉。

// 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);
    }
}

4.5 LifeCycle的意义

点开getServer().init()方法,发现来到的是LifeCycle.init()方法,很奇怪,为啥?大家想一想,无论是Server,Service,Connector等这些组件是不是都有会init,start,destroy方法,也就是说它们都会有这些生命周期,所以按照Java面向对象开发的思想,不妨把这个生命周期统一管理起来,于是有了LifeCycle。通过会将LifeCycle设计为接口,然后会有默认的实现类LifeCycleBase

public interface Lifecycle {
    // ----------------------------------------------------- Manifest Constants
    /**
     * The LifecycleEvent type for the "component after init" event.
     */
    public static final String BEFORE_INIT_EVENT = "before_init";
}

所以LifeCycle.init()会继续调用LifeCycleBase.init()方法,在LifeCycleBase.init()中会调用initInternal(),发现该方法是抽象的,所以要找对应的实现类。具体找哪个实现类要根据目前想要初始化的类是哪一个。

protected abstract void initInternal() throws LifecycleException;

比如目前要初始化的Server对象,那肯定是调用Server的initInternal()方法。

4.6 StandardServer.initInternal()

通常Server,Service都是一个接口,所以要找它们对应的实现类,比如Server就会找StandardServer类,也就是调用StandardServer.initInternal()方法对其进行初始化,最后会有这段代码。即初始化完Server之后,要对里面的小弟进行初始化,比如Service,大家可以结合Tomcat架构图来看,只不过发现这边使用的for循环,为何?从架构图中可以看到Service是可以有多层的,所以当然要用循环来初始化。

// Initialize our defined Services
for (int i = 0; i < services.length; i++) {
    services[i].init();
}

4.7 services[i].init()

继续点开service[i].init()方法,查看init的逻辑,发现还是来到的是LifeCycle.init(),意料之中,也就是会走这条调用线路:service[i].init()-LifeCycle.init()->LifeCycleBase.init()->initInternal()->StandardService.initInternal()

由下面这段代码可以发现,初始化完Service之后,会初始化Service里面的小弟,这边大家一定要结合架构图来看,比如有Executor,Connector等,而且用的也是循环结构,因为在架构图显示的是多层。比如我们来看Connector的初始化。

@Override
protected void initInternal() throws LifecycleException {
    super.initInternal();
    if (container != null) {
        container.init();
    }
    // Initialize any Executors
    for (Executor executor : findExecutors()) {
        if (executor instanceof JmxEnabled) {
            ((JmxEnabled) executor).setDomain(getDomain());
        }
        executor.init();
    }

    // Initialize mapper listener
    mapperListener.init();

    // Initialize our defined Connectors
    synchronized (connectorsLock) {
        for (Connector connector : connectors) {
            try {
                connector.init();
            } catch (Exception e) {
                String message = sm.getString(
                        "standardService.connector.initFailed", connector);
                log.error(message, e);

                if (Boolean.getBoolean("org.apache.catalina.startup.EXIT_ON_INIT_FAILURE"))
                    throw new LifecycleException(message);
            }
        }
    }
}

4.8 connector.init()

connector这个组件在前面分析监听端口的时候,我们看到接触过。接下来我们来看下connector.init()调用流程。

connector.init()->LifeCycleBase.init()->LifeCycleBase.initInternal()->Connector.initInternal()

这块有没有想起对于Connector监听端口我们找的就是initInternal()方法?这样就和前面监听端口的部分串联起来了,然后往下走的话就是这样的流程。

Connector.initInternal()->protocolHandler.init()->AbstractProtocol.init()->endpoint.init()->bind()-JIoEndpoint.bind()

4.9 小结

通过上面流程的分析,我们能知道Tomcat从入口类开始的整体流程,我也给大家画好了一张总结图,大家可以对照这个图再看看前面的分析,希望对大家有所帮助。如若有不对之处,欢迎大家一起探讨交流,谢谢。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值