粗浅看 Tomcat系统架构分析

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

整体结构

Tomcat 总体结构图


从上图中可以看出Tomcat的心脏是两个组件:Connector Container,关于这两个组件将在后面详细介绍。Connector 组件是可以被替换,这样可以提供给服务器设计者更多的选择,因为这个组件是如此重要,不仅跟服务器的设计的本身,而且和不同的应用场景也十分相关,所以一个Container 可以选择对应多个Connector多个Connector和一个Container 就形成了一个ServiceService 的概念大家都很熟悉了,有了Service 就可以对外提供服务了,但是Service还要一个生存的环境,必须要有人能够给她生命、掌握其生死大权,那就非Server莫属了。所以整个Tomcat的生命周期由Server控制。

以Service  作为“婚姻”

我们将 Tomcat ConnectorContainer 作为一个整体比作一对情 侣的话,Connector 主要负责对外交流,可以比作为 BoyContainer 主要处理 Connector 接受的请求,主要是处理内部事务,可以比作Girl那么这个 Service就是连接这对男女的结婚证了。是Service将它们连接在一起,共同组成一个家庭。当然要组成一个家庭还要很多其它的元素。

说白了,Service 只是在Connector Container外面多包一层,把它们组装在一起,向外面提供服务,一个Service可以设置多个Connector但是只能有一个 Container 容器。这个 Service 口的 方法列表如下:

①Service接口


Service接口中定义的方法中可以看出,它主要是为了关联ConnectorContainer,同时会初始化它下面的其它组件,注意接 口中它并没有规定一定要控制它下面的组件的生命周期。所有组件的 生命周期在一个 Lifecycle 接口中控制,这里用到了一个重要的设 计模式,关于这个接口将在后面介绍。

Tomcat Service接口的标准实现类是StandardService它不仅实现了 Service 借口同时还实现了 Lifecycle 接口,这样它就可以控 制它下面的组件的生命周期了。StandardService 类结构图如下:

②StandardService的类结构图


从上图中可以看出除了 Service接口的方法的实现以及控制组件生命周期的 Lifecycle 口的实现,还有几个方法是用于在事件监听的 方法的实现,不仅是这个 Service 组件Tomcat 其它组件也同样 有这几个方法,这也是一个典型的设计模式,将在后面介绍。

下面看一StandardService 中主要的几个方法实现的代码,下面是setContaineraddConnector 方法的源码:

StandardService. SetContainer

public void setContainer(Container container) {

Container oldContainer = this.container;

if ((oldContainer != null) && (oldContainer instanceof Engine))

((Engine) oldContainer).setService(null);

this.container = container;

if ((this.container != null) && (this.container instanceof Engine))

((Engine)  this.container).setService(this);

if (started && (this.container != null) && (this.container instanceof Lifecycle))

{

try {

((Lifecycle) this.container).start();

} catch (LifecycleException e) {

;

}

}

synchronized (connectors) {

for (int i = 0; i < connectors.length; i++)

connectors[i].setContainer(this.container);

}

if (started && (oldContainer != null) && (oldContainer instanceof Lifecycle)) {

try {

((Lifecycle)  oldContainer).stop();

} catch (LifecycleException e) {

;

}

}

support.firePropertyChange("container", oldContainer, this.container);
—————————————————————————————
}

这段代码很简单,其实就是先判断当前的这个 Service 没有已经关 联了 Container,如果已经关联了,那么去掉这个关联关系——oldContainer.setService(null)。如果这个 oldContainer 已经被启动 了,结束它的生命周期。然后再替换新的关联、再初始化并开始这个新的 Container 的生命周期。最后将这个过程通知感兴趣的事件监听程序。这里值得注意的地方就是,修改Container 时要将新的 Container关联到每个Connector,还好 Container Connector 没有双向关联,不然这个关联关系将会很难维护。

StandardService. addConnector

public void addConnector(Connector connector) {

synchronized (connectors) {

connector.setContainer(this.container);

connector.setService(this);

Connector results[] = new Connector[connectors.length + 1];

System.arraycopy(connectors, 0, results, 0, connectors.length);

results[connectors.length] = connector;

connectors = results;

if (initialized) {

try {

connector.initialize();

} catch (LifecycleException e) {

e.printStackTrace(System.err);

}

}
 

if (started && (connector instanceof Lifecycle)) {

try {

((Lifecycle) connector).start();

} catch (LifecycleException e) {

;

}

}

support.firePropertyChange("connector", null, connector);

}

}

上面是 addConnector 法,这个方法也很简单,首先是设置关联关 系,然后是初始化工作,开始新的生命周期。这里值得一提的是,注 意 Connector 用的是数组而不是 List 集合,这个从性能角度考虑可 以理解,有趣的是这里用了数组但是并没有向我们平常那样,一开始 就分配一个固定大小的数组,它这里的实现机制是:重新创建一个当 前大小的数组对象,然后将原来的数组对象 copy 到新的数组中,这 种方式实现了类似的动态数组的功能,这种实现方式,值得我们以后 拿来借鉴。

最新的 Tomcat6 StandardService也基本没有变化,但是从Tomcat5 开始 ServiceServer 和容器类都继承了MBeanRegistration接口,Mbeans 的管理更加合理。

以 Server  为“居”

前面说一对情侣因为 Service 而成为一对夫妻,有了能够组成一个家 庭的基本条件,但是它们还要有个实体的家,这是它们在社会上生存 之本,有了家它们就可以安心的为人民服务了,一起为社会创造财富。

Server要完成的任务很简单,就是要能够提供一个接口让其它程序能够访问到这个 Service 集合、同时要维护它所包含的所有 Service 的生命周期,包括如何初始化、如何结束服务、如何找到别人要访问Service。还有其它的一些次要的任务,如您住在这个地方要向当 地政府去登记啊、可能还有要配合当地公安机关日常的安全检查什么 的。

Server的类结构图如下:

①Server的类结构图


它的标准实现类 StandardServer 实现了上面这些方法,同时也实现 了 LifecycleMbeanRegistration 两个接口的所有方法,下面主要看 一下 StandardServer 重要的一个方法 addService的实现:

②StandardServer.addService

public void addService(Service service) {

service.setServer(this);

synchronized (services) {

Service results[] = new Service[services.length + 1];

System.arraycopy(services, 0, results, 0, services.length);

results[services.length] = service;

services = results;

if (initialized) {

try {

service.initialize();

} catch (LifecycleException e) {

e.printStackTrace(System.err);

}

}

if (started && (service instanceof Lifecycle)) {

try {

((Lifecycle) service).start();

} catch (LifecycleException e) {

;

}

}

support.firePropertyChange("service", null, service);

}

}

从上面第一句就知道了 ServiceServer是相互关联的,Server也是和 Service 管理 Connector 一样管理它,也是将 Service 放在 一个数组中,后面部分的代码也是管理这个新加进来的 Service 的生 命周期。Tomcat6 中也是没有什么变化的。

组件的生命线“Lifecycle”

前面一直在说 Service Server 管理它下面组件的生命周期,那它 们是如何管理的呢?

Tomcat 中组件的生命周期是通过Lifecycle 接口来控制的,组件只 要继承这个接口并实现其中的方法就可以统一被拥有它的组件控制 了,这样一层一层的直到一个最高级的组件就可以控制 Tomcat 所有组件的生命周期,这个最高的组件就是 Server,而控制 Server的是 Startup,也就是您启动和关闭Tomcat

下面是 Lifecycle 接口的类结构图:

①Lifecycle类结构图

除了控制生命周期的 Start 和 Stop 方法外还有一个监听机制,在生命周期开始和结束的时候做一些额外的操作。这个机制在其它的框架中也被使用,如在Spring 中。关于这个设计模式会在后面介绍。

Lifecycle接口的方法的实现都在其它组件中,就像前面中说的,组件的生命周期由包含它的父组件控制,所以它的 Start 方法自然就是调用它下面的组件的 Start 方法Stop 方法也是一样。如在 Server Start 方法就会调用Service组件的 Start方法,Server Start方法代码如下:

②StandardServer.Start

public void start() throws LifecycleException {

if (started) {

log.debug(sm.getString("standardServer.start.started"));

return;

}

lifecycle.fireLifecycleEvent(BEFORE_START_EVENT,  null);

lifecycle.fireLifecycleEvent(START_EVENT,  null);

started = true;

synchronized (services) {

for (int i = 0; i < services.length; i++) {

if (services[i] instanceof Lifecycle)

((Lifecycle) services[i]).start();

}

}

lifecycle.fireLifecycleEvent(AFTER_START_EVENT, null);

}
监听的代码会包围 Service 件的启动过程,就是简单的循环启动所Service组件的Start 方法,但是所有Service必须要实现

Lifecycle接口,这样做会更加灵活。

ServerStop 方法代码如下:

③StandardServer.Stop

public void stop() throws LifecycleException {

if (!started)

return;

lifecycle.fireLifecycleEvent(BEFORE_STOP_EVENT, null);

lifecycle.fireLifecycleEvent(STOP_EVENT,  null);

started = false;

for (int i = 0; i < services.length; i++) {

if (services[i] instanceof Lifecycle)

((Lifecycle) services[i]).stop();

}

lifecycle.fireLifecycleEvent(AFTER_STOP_EVENT, null);

}

它所要做的事情也和Start方法差不多。

Connector组件

Connector组件是Tomcat中两个核心组件之一,它的主要任务是负责接收浏览器的发过来的tcp连接请求,创建个Request 和处理这个请求并把产生的Request 和 Response对象传给处理这个请求的线程,处理这个请求的线程就是Container 组件要做的事了。

由于这个过程比较复杂,大体的流程可以用下面的顺序图来解释:

①Connector处理一次请求顺序图



Tomcat5 中默认的 Connector Coyote这个 Connector 是可以选择替换的。Connector 重要的功能就是接收连接请求然后分配线 程让 Container 来处理这个请求,所以这必然是多线程的,多线程的处理是 Connector 设计的核心。Tomcat5将这个过程更加细化,它将 Connector划分成 ConnectorProcessorProtocol, 另外Coyote也定义自己的Request Response对象。

下面主要看一下 Tomcat 中如何处理多线程的连接请求,先看一下Connector的主要类图:

② Connector的主要类图


看一下HttpConnectorStart 方法:

③HttpConnector.Start

public void start() throws LifecycleException {

if (started)

throw new LifecycleException

(sm.getString("httpConnector.alreadyStarted"));

threadName = "HttpConnector[" + port + "]";

lifecycle.fireLifecycleEvent(START_EVENT,  null);

started = true;

threadStart();

while (curProcessors < minProcessors) {

if ((maxProcessors > 0) && (curProcessors >= maxProcessors))

break;

HttpProcessor processor = newProcessor();

recycle(processor);

}

}

threadStart()执行就会进入等待请求的状态,直到一个新的请求到来才会激活它继续执行,这个激活是在HttpProcessor assign 方法中,这个方法是代码如下 :

④ HttpProcessor.assign

synchronized void assign(Socket socket) {

while (available) {

try {

wait();

} catch (InterruptedException e) {
 
—————————————————————————————
}

}

this.socket = socket;

available = true;

notifyAll();

if ((debug >= 1) && (socket != null))

log(" An incoming request is being assigned");

}

创建 HttpProcessor 对象是会把 available 设为 false,所以当请求 到来时不会进入 while循环,将请求的socket 赋给当期处理的 socket,并将 available设为true,当 available设为true HttpProcessorrun方法将被激活,接下去将会处理这次请求。

Run方法代码如下:

⑤HttpProcessor.Run

public void run() {

while (!stopped) {

Socket socket = await();

if (socket == null)

continue;

try {

process(socket);

} catch (Throwable t) {

log("process.invoke", t);

}

connector.recycle(this);

}

—————————————————————————————
synchronized (threadSync) {

threadSync.notifyAll();

}

}

解析 socket 的过程在 process 法中process 方法的代码片段如 下:

⑥HttpProcessor.process

private void process(Socket socket) {

boolean ok = true;

boolean finishResponse = true;

SocketInputStream input = null;

OutputStream output = null;

try {

input = new SocketInputStream(socket.getInputStream(),connector.getBufferSize());
} catch (Exception e) {

log("process.create", e);

ok = false;

}

keepAlive = true;

while (!stopped && ok && keepAlive) {

finishResponse = true;

try {

request.setStream(input);

request.setResponse(response);

output = socket.getOutputStream();

response.setStream(output);

response.setRequest(request);

((HttpServletResponse)  response.getResponse())

 
—————————————————————————————
.setHeader("Server", SERVER_INFO);

} catch (Exception e) {

log("process.create", e);

ok = false;

}

try {

if (ok) {

parseConnection(socket);

parseRequest(input, output);

if (!request.getRequest().getProtocol().startsWith("HTTP/0"))

parseHeaders(input);

if (http11) {

ackRequest(output);

if  (connector.isChunkingAllowed())

response.setAllowChunking(true);

}

}

try {

((HttpServletResponse)  response).setHeader

("Date",  FastHttpDateFormat.getCurrentDate());

if (ok) {

connector.getContainer().invoke(request, response);

}

}

try {

shutdownInput(input);

socket.close();

} catch (IOException e) {

;

} catch (Throwable e) {
 

log("process.invoke", e);

}

socket = null;

}

Connectorsocket 连接封装成 request response 对象后 接下来的事情就交给 Container 来处理了。

Servlet容器“Container”

Container是容器的父接口,所有子容器都必须实现这个接口,Container容器的设计用的是典型的责任链的设计模式,它有四个子 容器组件构成,分别是:Engine、Host、Context、Wrapper,这四个组件不是平行的,而是父子关系,Engine包含 Host,Host 包含 Context,Context 包含 Wrapper。通常一个 Servlet class 对应一个 Wrapper,如果有多个 Servlet 就可以定义多个 Wrapper,如果有多 个 Wrapper 就要定义一个更高的Container 了,如 Context, Context 通常就是对应下面这个配置:

①Server.xml

<Context

path="/library"
 

docBase="D:\projects\library\deploy\target\library.war"

reloadable="true"

/>
②容器的总体设计

Context 还可以定义在父容器Host中,Host 不是必须的,但是要运行 war 序,就必须要 Hostwar 必有 web.xml 文件, 这个文件的解析就需要 Host 了,如果要有多个 Host 就要定义一个 top 容器 Engine 了。Engine 没有父容器了,一个 Engine 代表 一个完整的 Servlet 引擎。

那么这些容器是如何协同工作的呢?先看一下它们之间的关系图:

 四个容器的关系图


Connector接受到一个连接请求时,将请求交给ContainerContainer如何处理这个请求的?这四个组件是怎么分工的,怎么 把请求传给特定的子容器的呢?又是如何将最终的请求交给 Servlet处理。下面是这个过程的时序图:

②Engine和Host  处理请求的时序图


这里看到Valve 是不是很熟悉,没错 Valve 的设计在其他框架中 也有用的,同样 Pipeline的原理也基本是相似的,它是一个管道,EngineHost都会执行这个 Pipeline,您可以在这个管道上增加 任意的 ValveTomcat 会挨个执行这些Valve,而且四个组件都会 有自己的一套 Valve 集合。您怎么才能定义自己的Valve 呢?在server.xml 文件中可以添加,如给 Engine 和 Host 增加一个 Valve如下:

③Server.xml

<Engine defaultHost="localhost" name="Catalina">

<Valve   className="org.apache.catalina.valves.RequestDumperValve"/>

………

<Host appBase="webapps" autoDeploy="true" name="localhost" unpackWARs="true"

xmlNamespaceAware="false"  xmlValidation="false">

<Valve   className="org.apache.catalina.valves.FastCommonAccessLogValve"

directory="logs" prefix="localhost_access_log." suffix=".txt"

pattern="common" resolveHosts="false"/>

…………

</Host>

</Engine>

StandardEngineValveStandardHostValveEngineHost的默认的 Valve,它们是最后一个Valve 负责将请求传给它们的子 容器,以继续往下执行。

前面是 EngineHost容器的请求过程,下面看Context Wrapper 容器时如何处理请求的。下面是处理请求的时序图:

④Context 和wrapper  的处理请求时序图


从 Tomcat5 开始,子容器的路由放在了 request 中,request 中保 存了当前请求正在处理的 HostContext wrapper

③Engine 容器

Engine容器比较简单,它只定义了一些基本的关联关系,接口类图如下:

①Engine 接口的类结构


它的标准实现类是StandardEngine,这个类注意一点就是 Engine没有父容器了,如果调用 setParent 法时将会报错。添加子容器也 只能是 Host 类型的,代码如下:

②StandardEngine. addChild

public void addChild(Container child) {

if (!(child instanceof Host))

throw new IllegalArgumentException

(sm.getString("standardEngine.notHost"));

super.addChild(child);

}

public void setParent(Container container) {

throw new IllegalArgumentException

(sm.getString("standardEngine.notParent"));

}

它的初始化方法也就是初始化和它相关联的组件,以及一些事件的监听。

④Host容器

HostEngine 的字容器,一个HostEngine中代表一个虚拟主机,这个虚拟主机的作用就是运行多个应用,它负责安装和展开这些应用,并且标识这个应用以便能够区分它们。它的子容器通常是Context,它除了关联子容器外,还有就是保存一个主机应该有的信 息。

①Host 相关的类图

从上图中可以看出除了所有容器都继承的ContainerBase外, StandardHost还实现了Deployer 接口,上图清楚的列出了这个接口的主要方法,这些方法都是安装、展开、启动和结束每个web application

Deployer 接口的实现是 StandardHostDeployer这个类实现了的最要的几个方法,Host 可以调用这些方法完成应用的部署等。

⑤Context容器

Context Servlet Context具备了 Servlet 行的基本环 境,理论上只要有 Context 就能运行Servlet 了。简单的 Tomcat可以没有 Engine Host

Context 最重要的功能就是管理它里面的Servlet实例,Servlet 例在 Context 中是以Wrapper 出现的,还有一点就是 Context 何才能找到正确的Servlet 来执行它呢?Tomcat5以前是通过一 Mapper 类来管理的,Tomcat5 以后这个功能被移到了request 中,在前面的时序图中就可以发现获取子容器都是通过request 分配的。

Context Servlet 的运行环境是在 Start 方法开始的,这个方法 的代码片段如下:

①StandardContext.start

public synchronized void start() throws LifecycleException {

………

if( !initialized ) {

try {

init();

} catch( Exception ex ) {

throw new LifecycleException("Error initializaing ", ex);

}

}

………

lifecycle.fireLifecycleEvent(BEFORE_START_EVENT,   null);

setAvailable(false);

setConfigured(false);

boolean ok = true;

File configBase = getConfigBase();

if (configBase != null) {

if (getConfigFile() == null) {

File file = new File(configBase, getDefaultConfigFile());

setConfigFile(file.getPath());

try {

File appBaseFile = new File(getAppBase());

if (!appBaseFile.isAbsolute()) {

appBaseFile = new File(engineBase(), getAppBase());

}

String appBase = appBaseFile.getCanonicalPath();

String basePath =

(new  File(getBasePath())).getCanonicalPath();

if (!basePath.startsWith(appBase)) {

Server server = ServerFactory.getServer();

((StandardServer)  server).storeContext(this);

}

} catch (Exception e) {

log.warn("Error storing config file", e);

}

} else {

try {

String canConfigFile =  (new File(getConfigFile())).getCanonicalPath();
if (!canConfigFile.startsWith (configBase.getCanonicalPath())) {

File file = new File(configBase, getDefaultConfigFile());

if (copy(new File(canConfigFile), file)) {
 
—————————————————————————————
setConfigFile(file.getPath());

}

}

} catch (Exception e) {

log.warn("Error setting config file", e);

}

}

}
………

Container children[] = findChildren();

for (int i = 0; i < children.length; i++) {

if (children[i] instanceof Lifecycle)

((Lifecycle)  children[i]).start();

}

if (pipeline instanceof Lifecycle)

((Lifecycle) pipeline).start();

………

}

它主要是设置各种资源属性和管理组件,还有非常重要的就是启动子容器和 Pipeline

我们知道 Context 的配置文件中有个 reloadable 属性,如下面配置:

②Server.xml

<Context

path="/library"
 
—————————————————————————————
docBase="D:\projects\library\deploy\target\library.war"

reloadable="true"

/>

当这个 reloadable 设为 true 时,war被修改后 Tomcat 会自动的重新加载这个应用。如何做到这点的呢? 这个功能是在StandardContextbackgroundProcess 方法中实现的,这个方法的代码如下:

③StandardContext. backgroundProcess

public void backgroundProcess() {

if (!started) return;

count = (count + 1) % managerChecksFrequency;

if ((getManager() != null) && (count == 0)) {

try {

getManager().backgroundProcess();

} catch ( Exception x ) {

log.warn("Unable to perform background process on manager",x);

}

}

if (getLoader() != null) {

if (reloadable && (getLoader().modified())) {

try {

Thread.currentThread().setContextClassLoader

(StandardContext.class.getClassLoader());

reload();

} finally {

if (getLoader() != null) {

Thread.currentThread().setContextClassLoader
 

(getLoader().getClassLoader());

}

}

}

if (getLoader() instanceof WebappLoader) {

((WebappLoader)  getLoader()).closeJARs(false);

}

}

}

它会调用 reload 方法,而 reload方法会先调用 stop方法然后再调用 Start 方法,完成Context 的一次重新加载。可以看出执行reload方法的条件是reloadable true 和应用被修改,那么这个backgroundProcess 方法是怎么被调用的呢?

这个方法是在 ContainerBase 类中定义的内部类ContainerBackgroundProcessor被周期调用的,这个类是运行在一个后台线程中,它会周期的执行 run 法,它的 run 方法会周期调 用所有容器的 backgroundProcess 方法,因为所有容器都会继承ContainerBase类,所以所有容器都能够在backgroundProcess 法中定义周期执行的事件。

⑥Wrapper容器

Wrapper 代表一个Servlet,它负责管理一个 Servlet,包括的 Servlet的装载、初始化、执行以及资源回收。Wrapper是最底层的 容器,它没有子容器了,所以调用它的 addChild 将会报错。

Wrapper 的实现类是 StandardWrapperStandardWrapper 实现 了拥有一个 Servlet 初始化信息的ServletConfig,由此看出 StandardWrapper 将直接和Servlet的各种信息打交道。

下面看一下非常重要的一个方法loadServlet,代码片段如下:

①StandardWrapper.loadServlet
public synchronized Servlet loadServlet() throws ServletException {

………

Servlet servlet;

try {

………

ClassLoader classLoader = loader.getClassLoader();

………

Class classClass = null;

………

servlet = (Servlet) classClass.newInstance();

if ((servlet instanceof ContainerServlet) &&

(isContainerProvidedServlet(actualClass)  ||

((Context)getParent()).getPrivileged() )) {

((ContainerServlet)  servlet).setWrapper(this);

}
 

classLoadTime=(int) (System.currentTimeMillis() -t1);

try {


instanceSupport.fireInstanceEvent(InstanceEvent.BEFORE_INIT_EVENT,servlet);

if( System.getSecurityManager() != null) {

Class[] classType = new Class[]{ServletConfig.class};

Object[] args = new Object[]{((ServletConfig)facade)};

SecurityUtil.doAsPrivilege("init",servlet,classType,args);

} else {

servlet.init(facade);

}

if ((loadOnStartup >= 0) && (jspFile != null)) {

………

if( System.getSecurityManager() != null) {

Class[] classType = new Class[]{ServletRequest.class,

ServletResponse.class};

Object[] args = new Object[]{req, res};

SecurityUtil.doAsPrivilege("service",servlet,classType,args);

} else {

servlet.service(req, res);

}

}


instanceSupport.fireInstanceEvent(InstanceEvent.AFTER_INIT_EVENT,servlet);

………

return servlet;

}

它基本上描述了对Servlet 的操作,当装载了Servlet后就会调用Servlet的 init方法,同时会传一个StandardWrapperFacade对象给Servlet,这个对象包装了StandardWrapper,ServletConfig 与它们的关系图如下:

②ServletConf 与StandardWrapperFacade、StandardWrapper的关系


Servlet可以获得的信息都在StandardWrapperFacade封装,这些信息又是StandardWrapper 对象中拿到的。所以 Servlet 可以通 过 ServletConfig 拿到有限的容器的信息。

Servlet 被初始化完成后,就等着 StandardWrapperValve 去调用 它的 service 方法了,调用 service 法之前要调用 Servlet 有的 filter

Tomcat中其它组件

Tomcat 还有其它重要的组件,如安全组件security、logger 日 志组件、session、mbeans、naming 等其它组件。这些组件共同为Connector和 Container 提供必要的服务。

业务思想

关于Tomcat服务器的了解,算是很长时间的了解了,很好用。本博文中关于Tomcat系统架构的学习和总结,算是个人的理解,写一写总结总感觉很有必要,收获颇多。多加使用,方感颇深。大家有什么好的理解,欢迎交流!


  • 4
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 5
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值