第十一章 Tomcat的系统架构与设计模式
- 11.1 Tomcat总体设计模式

Connector组件是可以被替换的,这样给服务器设计者提供更多的选择,所以一个Container可以选择对应多个Connector。多个Connector和一个Container就形成了一个Service,有了Service就可以对外提供服务。但是Service还要一个生存环境,Tomcat的生命周期是有Server控制。
1、以Service作为“婚姻”
Connector主要负责对外交流,可以比作男孩,Container主要处理Connector接受的请求,主要是处理内部事务,可以比作女孩。那这个Service就是连接这对男女的结婚证,是Service将这对男女联系在一起,共同组建一个家庭。Service只是在Connector和Container外面多包一层,把他们组装在一起,向外面提供服务,一个Service可以设置多个Connector,但是只能有一个Container容器。
从Service接口中定义的方法可以看出,主要是为了关联Connector和Container,同时会初始化它下面的组件。注意,接口中并没有规定一定要控制它下面的组件的生命周期。所有组件的生命周期在一个Lifecycle的接口中控制。
public class StandardService extends LifecycleMBeanBase implements Service{ private Container container; public void setContainer(Container container) { Container oldContainer = this.container; if (started && (oldContainer != null) && (oldContainer instanceof Engine)) { ((Engine) oldContainer).setService(null);//判断当前这个Service有没有已经关联了Container,已经关联了就去掉关联关系 } this.container = container; if ((this.container != null) && (this.container instanceof Engine)) { ((Engine) this.container).setService(this); } if ((this.container != null) && (this.container instanceof Lifecycle)) { try { ((Lifecycle) this.container).start(); } catch (LifecycleException e) { ; } } //修改Container时需要将新的Container关联待每个Connector synchronized (connectors) { for(int i=0; i<connectors.length; i++) { connectors[i].setContainer(this.container); } } //如果oldContainer已经被启动了,就结束生命周期 if(started && (oldContainer != null) && (oldContainer instanceof Lifecycle)) { try { ((Lifecycle) oldContainer).stop(); } catch (LifecycleException e) { ; } } suppor.firePropertyChange("container", oldContainer, this.container); }
}
AddConnector方法源码
public void addConnector(Connector connector) { synchronized (connectorsLock) { 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 (getState().isAvailable()) { try { connector.start(); } catch (LifecycleException e) { log.error(sm.getString( "standardService.connector.startFailed", connector), e); } } // Report this property change to interested listeners support.firePropertyChange("connector", null, connector); } }
2、以Server为“居”
Server要完成的任务就是提供一个接口让其他程序能够访问到这个Service集合,同时要维护它所包含的所有Service生命周期,包括如何初始化、如何结束服务、如何找别人要访问的Service。还有其他的一些次要的任务,类似于你住在这个地方要向当地政府去登记,可能还要配合当地公安机关日常检查。

Server的标准实现类StandardServer实现了上面这些方法,同时也实现了Lifecycle、MbeanRegistration两个接口的所有方法
/** * Add a new Service to the set of defined Services. * * @param service The Service to be added */ 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) { log.error(e); } } if (started && (service instanceof Lifecycle)) { try { ((Lifecycle) service).start(); } catch (LifecycleException e) { ; } } // Report this property change to interested listeners support.firePropertyChange("service", null, service); } }
3、组件的生命线“Lifecycle”

public void start() throws LifecycleException { // Validate and update our current component state if (started) { if (log.isInfoEnabled()) { log.info(sm.getString("standardService.start.started")); } return; } if( ! initialized ) init(); // Notify our interested LifecycleListeners lifecycle.fireLifecycleEvent(BEFORE_START_EVENT, null); if(log.isInfoEnabled()) log.info(sm.getString("standardService.start.name", this.name)); lifecycle.fireLifecycleEvent(START_EVENT, null); started = true; // Start our defined Container first if (container != null) { synchronized (container) { if (container instanceof Lifecycle) { ((Lifecycle) container).start(); } } } synchronized (executors) { for ( int i=0; i<executors.size(); i++ ) { executors.get(i).start(); } } // Start our defined Connectors second synchronized (connectors) { for (int i = 0; i < connectors.length; i++) { try { ((Lifecycle) connectors[i]).start(); } catch (Exception e) { log.error(sm.getString( "standardService.connector.startFailed", connectors[i]), e); } } } // Notify our interested LifecycleListeners lifecycle.fireLifecycleEvent(AFTER_START_EVENT, null); }
public void stop() throws LifecycleException { // Validate and update our current component state if (!started) { return; } // Notify our interested LifecycleListeners lifecycle.fireLifecycleEvent(BEFORE_STOP_EVENT, null); // Stop our defined Connectors first synchronized (connectors) { for (int i = 0; i < connectors.length; i++) { try { connectors[i].pause(); } catch (Exception e) { log.error(sm.getString( "standardService.connector.pauseFailed", connectors[i]), e); } } } // Heuristic: Sleep for a while to ensure pause of the connector try { Thread.sleep(1000); } catch (InterruptedException e) { // Ignore } lifecycle.fireLifecycleEvent(STOP_EVENT, null); if(log.isInfoEnabled()) log.info (sm.getString("standardService.stop.name", this.name)); started = false; // Stop our defined Container second if (container != null) { synchronized (container) { if (container instanceof Lifecycle) { ((Lifecycle) container).stop(); } } } // FIXME pero -- Why container stop first? KeepAlive connetions can send request! // Stop our defined Connectors first synchronized (connectors) { for (int i = 0; i < connectors.length; i++) { try { ((Lifecycle) connectors[i]).stop(); } catch (Exception e) { log.error(sm.getString( "standardService.connector.stopFailed", connectors[i]), e); } } } synchronized (executors) { for ( int i=0; i<executors.size(); i++ ) { executors.get(i).stop(); } } if( oname==controller ) { // we registered ourself on init(). // That should be the typical case - this object is just for // backward compat, nobody should bother to load it explicitely Registry.getRegistry(null, null).unregisterComponent(oname); Executor[] executors = findExecutors(); for (int i = 0; i < executors.length; i++) { try { ObjectName executorObjectName = new ObjectName(domain + ":type=Executor,name=" + executors[i].getName()); Registry.getRegistry(null, null).unregisterComponent(executorObjectName); } catch (Exception e) { // Ignore (invalid ON, which cannot happen) } } } // Notify our interested LifecycleListeners lifecycle.fireLifecycleEvent(AFTER_STOP_EVENT, null); }
Connector 组件
Connerctor组件是Tomcat中两个核心组件之一,主要任务是负责接收浏览器发过来的TCP连接请求,创建一个Request和Response对象分别用于和请求端交换数据。然后会产生一个线程来处理这个请求并把产生的Request和Response对象传给处理这个请求的线程,处理这个请求的线程就是Container组件需要做的。

Tomcat5默认的Connector是Coyote,这个Connector是可以选择替换的。Connector最重要的功能就是接收连接请求,然后分配线程让Container来处理这个请求,所以必然是多线程的,多线程的处理是Connector设计的核心。

public void start() throws LifecycleException { if(started) throw new LifecycleException (sm.getString("httpConnector.alreadyStarted")); threadName = "HttpConnector [" + port +"]"; lifecycle.fireLifecycleEvent(START_EVENT, null); started = ture; threadStart(); while (curProcessors < minProcessors) { if((maxProcessors > 0) && (curProcessors >= maxProcessors)) break; HttpProcessor processor = newProcessor(); recycle(processor); } }
当程序执行到threadStart()方法时,就会进入等待请求的状态,直到一个新的请求到来才激活它继续执行,这个激活是在HttpProcessor的assign方法中,代码如下
synchronized void assign(Socket socket) { while(avaliable) { 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,之后,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方法中
private void process(Socket socket) { boolean ok = true; boolean finishResponse = true; SocketInputStream input = null; OutputStream output = null; try { input = new SocketInputStream(socket.getlnputStream(),connector.getBufferSize()); } catch (Exception e) { log("process•create", e); ok = false; } keepAlive = true; while (Istopped && 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/O")); parseHeaders(input); if (httpll) { 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; }
当Connector将Socket连接封装成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 path="/library" docBase="D:\projects\library\deploy\target\library .war" reloadable="true"/>
1、容器的总体设计
Context还可以定义在父容器Host中,Host不是必须的,但是要运行war程序,就必须要用Host,因为war中必有web.xml文件,这个文件的解析就需要Host。如果需要多个Host就要定义一个top容器Engine。而Engine没有父容器,一个Engine代表一个完整的Servlet引擎。


Valve的设计在其他框架中也有用到,同样Pipeline的原理也是类似的。它是一个管道,Engine和Host都会执行这个Pipeline,你可以在这个管道上增加任意的Valve,Tomcat会执行这些valve,而这四个组件都会有自己的一套Valve集合。在server.xml文件中可以添加定义自己的Valve。如下面给Engine和Host增加一个Valve。
<Engine defaultHost="localhost" name="Catalina"> <Valve className="org.apache.catalina.valves.RequestDumperValven"/> ....... <Host appBase="webappsn 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>
StandardEngineValve和StandardHostValve是Engine和Host默认的Valve,最后一个Valve负责将请求传给它们的子容器,以继续往下执行。
2、Engine容器
Engine容器只定义了一些基本的关联关系,它的标准实现类是StandardEngine,注意Engine没有父容器,如果调用setParent方法将会报错。添加子容器也只能是Host类型的。

/** * Add a child Container, only if the proposed child is an implementation * of Host. * * @param child Child container to be added */ public void addChild(Container child) { if (!(child instanceof Host)) throw new IllegalArgumentException (sm.getString("standardEngine.notHost")); super.addChild(child); }
/** * Disallow any attempt to set a parent for this Container, since an * Engine is supposed to be at the top of the Container hierarchy. * * @param container Proposed parent Container */ public void setParent(Container container) { throw new IllegalArgumentException (sm.getString("standardEngine.notParent")); }
3、Host容器
Host是Engine的子容器,一个Host在Engine中代表一个虚拟主机,这个虚拟主机的作用就是运行多个应用,负责安装和展开这些应用,并标识这个应用以便能够区分它们。它的子容器通常是Context,它除了关联子容器外,还有就是保存一个主机应该有的信息。

4、Context容器
Context代表Servlet的Context,具备了servlet运行的基本环境。理论上只要有Context就能运行Servlet了。简单的Tomcat可以没有Engine和Host。
Context最重要的功能就是管理它里面的Servlet实例,Servlet实例在Context中是以Wrapper出现的。还有就是Context如何才能找到正确的Servlet来执行它?Tomcat5以前是通过一个Mapper类来管理的,Tomcat 5以后这个功能被移动到Request中,在前面的时序图可以发现获取子容器都是通过Request来分配的。
Context准备Servlet的运行环境是在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()f 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属性:
<Context path="/library" docBase="D:\projects\library\deploy\target\library.war" reloadable="true" />
当这个reloadable设为true时,war被修改后Tomcat会自动重新加载这个应用。这个功能是在StandardContext的backgroupprocess方法中实现的,代码如下
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方法会周期地调用所有容器的backgroundProcessor方法,因为所有容器都会继承ContainerBase类,所以所有容器都能够在backgroundProcess方法中定义周期执行的事件。
5、Wrapper容器
Wrapper代表了一个Servlet,它负责管理一个Servlet,包括Servlet的装载、初始化、执行以及资源回收。Wrapper是最底层的容器,它没有子容器了,所以调用它的addChild将会报错。
Wrapper的实现类是StandardWrapper,StandardWrapper还实现了拥有一个Servlet初始化信息的ServletConfig,由此看出StandardWrapper将直接和Servlet的各种信息打交道。
public synchronized Servlet loadServlet() throws ServletException { ...... Servlet servlet; try { ...... ClassLoader classLoader = loader.getClassLoader(); ...... Class classClass = null; ...... servlet = (Servlet) classClass.newInstance(); if ((servlet instanceof ContainerServlet) && (isContainerProvidedSfirvlet(actualClass) || ((Context)getParent()).getPrivileged())) { ((ContainerServlet) servlet).setWrapper(this); } classLoadTime=(int) (System.currentTimeMillis() -tl); try { instanceSupport.firelnstanceEvent(InstanceEvent.REFORE_INIT_EVENT,servlet); if( System.getSecurityManager() != null) { Class[] classType = new Class[]{ServletConfig.class}; Object[] args = new Object[]{((ServletConfig)facade)}; SecurityUtil. doAsPrivilege(f,initf,f servlet, classType, args); } else { servlet.init(facade); } if((loadOnstartup >= 0) && (jspFile != nill)) { ...... if (System.getSecurityManager() != nill) { Class[] classType = new Class[]{ServletRequest.class,ServletResponse.class}; Object[] args = new Object[]{req,res}; SecurityUtil.doAsPrivilege("servlet",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与它们的关系。

Servlet可以获得的信息都在StandardWrapperFacade里封装,这些信息又是在StandardWrapper对象中拿到的,所以Servlet可以通过ServletConfig拿到有限的容器的信息。
当Servlet被初始化完成后,就等着StandardWrapperValve去调用它的Service方法了,调用Service方法之前要调用Servlet所有的filter。
Tomcat中其他组件
Tomcat中其他重要组件,如安全组件security、日志组件logger、session、mbeans、naming等。
- 11.2 Tomcat中的设计模式
门面设计模式
1、门面设计模式原理
顾名思义就是将一个东西封装成一个门面好与人家更容易地进行交流,就像一个国家的外交部一样。这种设计模式主要用在一个大的系统中有多个子系统时,这时多个子系统肯定要涉及相互通信,但是每个子系统又不能将自己的内部数据过多的暴露给其他系统,不然就没有必要划分子系统了。每个子系统都会设计一个门面,把别的系统感兴趣的数据封装起来,通过这个门面来进行访问。这就是门面设计模式存在的意义。

Client只能访问Facade中提供的数据是门面设计模式的关键,至于如何访问怎样提供没有规定死。
2、Tomcat的门面设计模式实例
Tomcat中门面设计模式使用的很多,因为Tomcat中有很多组件,每个组件要相互交互数据,用门面设计模式隔离数据是个很好的方法。

从图中可以看出,HttpRequestFacade类封装了HttpRequest接口,能够提供数据,通过HttpRequestFacade访问到的数据都被代理到HttpRequest中,通常被封装的对象都被设为Private或者Protected的,以防止在Facade中被直接访问。
观察者设计模式
也叫发布-订阅模式,也就是监听机制。通常在某个时间发生的前后会触发一些操作。
1、观察者模式的原理
就是在你做事的时候哦岸边总有个人在盯着你,当你做的事情是它感兴趣的事情的时候,它就会跟着做另外一件事情。但是盯着你的人必须要到你那去登记,不然你无法通知它。通常包含以下几个角色:
Subject抽象主题:它负责管理所有观察者的引用,同时定义主要的事件操作
ConcreteSubject具体主题:它实现了抽象主题定义的所有接口i,当自己发生变化时,会通知所有观察者
Observer观察者:监听主题发生拜年话的操作接口
2、Tomcat的观察者模式实例
Tomcat中观察者模式也有很多,前面的控制组件生命周期的Lifecycle、还有对Servlet实例的创建、Session的管理、Container等都是同样的原理。

在上面的结构图中,LifecycleListener代表的是抽象观察者,它定义一个lifecycleEvent方法,这个方法就是当主题变化时要执行的方法。ServerLifecycleListener代表的是具体的观察者,它实现了LifecycleListener接口的方法,就是这个具体的观察者具体的实现方式。Lifecycle接口代表的是抽象主题,它定义了管理观察者的方法和它要做的其他方法。而StandardServer代表的是具体主题,它实现了抽象主题的所有方法。这里Tomcat对观察者做了扩展,增加了另两个类:LifecycleSupport和LifecycleEvent,他们作为辅助类扩展的功能。
LifecycleEvent使得可以定义事件类别,不同的事件可区别处理,更加灵活。
LifecycleSuppor代理了主题对多观察者的管理,将这个管理抽出来统一实现,以后如果修改只要修改LifecycleSupport就可以。
LifecycleSuppor调用观察者方法代码:
public void fireLifecycleEvent(String type. Object data) { LifecycleEvent event = new LifecycleEvent(lifecycle, type, data); LifecycleListener interestedf] = null; synchronized (listeners) { interested = (LifecycleListener[]) listeners.clone (); } for (int i = 0; i < interested.length; i++) interested[i].lifecycleEvent(event); }
主题通知观察者:
public void start() throws LifecycleException { lifecycle.fireLifecycleEvent(BEFORE_START_EVENTT, 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); }
命令设计模式
Connector通过命令模式调用Container
1、命令模式的原理
命令模式主要作用是封装命令,把发出命令的责任和执行命令的责任分开,也是一种功能的分工。不同的模块可以对同一命令做出不同的解释。命令模式通常包含以下角色:
Client:创建一个命令,并决定接收者
Command:命令接口,定义一个抽象方法
ConcreteCommand:具体命令,负责调用接收者的相应操作
Invoker:请求者,负责调用命令对象执行请求
Receiver:接受者,负责具体实施和执行一次请求。
2、Tomcat中命令模式的示例

Connector作为抽象请求者,HttpConnector作为具体请求者。HttpProcessor作为命令。Container作为命令的抽象接受者,ContainerBase作为具体的接受者。客户端就是应用服务器Server组件了。Server首先创建命令请求者HttpConnector对象,然后创建命令HttpProcessor对象。再把命令对象交给命令接受者ContainerBase容器来处理。命令最终是被Tomcat的Container执行的。命令可以以队列的方式进来,Container也可以以不同方式来处理请求。
责任链设计模式
整个容器就是通过一个链连接在一起,这个链一直将请求正确地传递给最终处理请求的那个Servlet。
1、责任链模式的原理
是很多对象由每个对象对其下家的引用而连接起来形成一条链,请求在这条链上传递,直到链上的某个对象处理此请求,或者每个对象都可以处理请求,并传给“下家”,直到最终链上每个对象都处理完。这样可以不影响客户端而能够在链上增加任意的处理节点。通常责任模式包含的角色:
Handler(抽象处理者):定义一个处理请求的接口
ConcreteHandler(具体处理者):处理请求的具体类,或者传给“下家”
2、Tomcat中责任模式示例

上图基本描述了四个子容器使用责任链模式的类结构图,对应的责任链模式的角色中Container扮演抽象处理者角色,具体处理者由StandarEngine等子容器扮演。与标准的责任链不同,这里引入了Pipeline和Valve接口。
实际上Pipeline和Valve接口扩展了这个链的功能,使得在链往下传递过程中,能够接受外界的干预。Pipeline就是连接每个子容器的管子,里面传递的Request和Response对象好比管子里流动的水,而Valve就是这个管子上开的一个个小口子,让你有机会能够接触到里面的水,做些额外的事情。
为了防止水被引出来而不能流到下一个容器中,每个管子最后总有一个节点保证它一定能流到下一个自容器,所以每个容器都有一个StandarXXXValve。

浙公网安备 33010602011771号