在一般的web应用开发里通常会使用开发工具(如Eclipse、IntelJ)集成tomcat,这样可以将web工程项目直接发布到tomcat中,然后一键启动。经常遇到的一种情况是直接修改一个类的源文件,此时开发工具会直接将编译后的class文件发布到tomcat的web工程里,但如果tomcat没有配置应用的自动加载功能的话,当前JVM中运行的class还是源文件修改之前编译好的class文件。可以重启tomcat来加载新的class文件,但这样做需要再手工点击一次【restart】,为了能够在应用中即时看到java文件修改之后的执行情况,可以在tomcat中将应用配置成自动加载模式,其配置很简单,只要在配置文件的Context节点中加上一个reloadable属性为true即可,示例如下:
<Context path="/HelloWorld" docBase="C:/apps/apache-tomcat/DeployedApps/HelloWorld" reloadable="true"/>
如果你的开发工具已经集成了tomcat的话应该会有一个操作界面配置来代替手工添加文件信息,如Eclipse中是如下界面来配置的:
此时需要把【Auto reloading enabled】前面的复选框钩上。其背后的原理实际也是在server.xml文件中加上Context节点的描述:
<Context docBase="test" path="/test" reloadable="true"/>
这样Tomcat就会监控所配置的web应用实际路径下的/WEB-INF/classes和/WEB-INF/lib两个目录下文件的变动,如果发生变更tomcat将会自动重启该应用。
熟悉Tomcat的人应该都用过这个功能,就不再详述它的配置步骤了。我感兴趣的是这个自动加载功能在Tomcat7中是怎么实现的。
在前面的文章中曾经讲过Tomcat7在启动完成后会有一个后台线程ContainerBackgroundProcessor[StandardEngine[Catalina]],这个线程将会定时(默认为10秒)执行Engine、Host、Context、Wrapper各容器组件及与它们相关的其它组件的backgroundProcess方法,这段代码在所有容器组件的父类org.apache.catalina.core.ContainerBase类的backgroundProcess方法中:
public void backgroundProcess() { if (!getState().isAvailable()) return; if (cluster != null) { try { cluster.backgroundProcess(); } catch (Exception e) { log.warn(sm.getString("containerBase.backgroundProcess.cluster", cluster), e); } } if (loader != null) { try { loader.backgroundProcess(); } catch (Exception e) { log.warn(sm.getString("containerBase.backgroundProcess.loader", loader), e); } } if (manager != null) { try { manager.backgroundProcess(); } catch (Exception e) { log.warn(sm.getString("containerBase.backgroundProcess.manager", manager), e); } } Realm realm = getRealmInternal(); if (realm != null) { try { realm.backgroundProcess(); } catch (Exception e) { log.warn(sm.getString("containerBase.backgroundProcess.realm", realm), e); } } Valve current = pipeline.getFirst(); while (current != null) { try { current.backgroundProcess(); } catch (Exception e) { log.warn(sm.getString("containerBase.backgroundProcess.valve", current), e); } current = current.getNext(); } fireLifecycleEvent(Lifecycle.PERIODIC_EVENT, null); }
与自动加载类相关的代码在loader的backgroundProcess方法的调用时。每一个StandardContext会关联一个loader变量,该变量的初始化代码在org.apache.catalina.core.StandardContext类的startInternal方法中的这段代码:
if (getLoader() == null) { WebappLoader webappLoader = new WebappLoader(getParentClassLoader()); webappLoader.setDelegate(getDelegate()); setLoader(webappLoader); }
所以上面的loader.backgroundProcess()方法的调用将会执行org.apache.catalina.loader.WebappLoader类的backgroundProcess方法:
public void backgroundProcess() { if (reloadable && modified()) { try { Thread.currentThread().setContextClassLoader (WebappLoader.class.getClassLoader()); if (container instanceof StandardContext) { ((StandardContext) container).reload(); } } finally { if (container.getLoader() != null) { Thread.currentThread().setContextClassLoader (container.getLoader().getClassLoader()); } } } else { closeJARs(false); } }
其中reloadable变量的值就是本文开始提到的配置文件的Context节点的reloadable属性的值,当它为true并且modified()方法返回也是true时就会执行StandardContext的reload方法:
public synchronized void reload() { // Validate our current component state if (!getState().isAvailable()) throw new IllegalStateException (sm.getString("standardContext.notStarted", getName())); if(log.isInfoEnabled()) log.info(sm.getString("standardContext.reloadingStarted", getName())); // Stop accepting requests temporarily. setPaused(true); try { stop(); } catch (LifecycleException e) { log.error( sm.getString("standardContext.stoppingContext", getName()), e); } try { start(); } catch (LifecycleException e) { log.error( sm.getString("standardContext.startingContext", getName()), e); } setPaused(false); if(log.isInfoEnabled()) log.info(sm.getString("standardContext.reloadingCompleted", getName())); }
reload方法中将先执行stop方法将原有的该web应用停掉,再调用start方法启动该Context,start方法的分析前文已经说过,stop方法可以参照start方法一样分析,不再赘述。
这里重点要说的是上面提到的监控文件变动的方法modified,只有它返回true才会导致应用自动加载。看下该方法的实现:
public boolean modified() { return classLoader != null ? classLoader.modified() : false ; }
可以看到这里面实际调用的是WebappLoader的实例变量classLoader的modified方法来判断的,下文就详细分析这个modified方法的实现。
先简要说一下Tomcat中的加载器。在Tomcat7中每一个web应用对应一个Context节点,这个节点在JVM中就对应一个org.apache.catalina.core.StandardContext对象,而每一个StandardContext对象内部都有一个加载器实例变量(即其父类org.apache.catalina.core.ContainerBase的loader实例变量),前面已经看到这个loader变量实际上是org.apache.catalina.loader.WebappLoader对象。而每一个WebappLoader对象内部关联了一个classLoader变量(就这这个类的定义中,可以看到该变量的类型是org.apache.catalina.loader.WebappClassLoader)。
在Tomcat7的源码中给出了6个web应用:
所以在Tomcat启动完成之后理论上应该有6个StandardContext对象,6个WebappLoader对象,6个WebappClassLoader对象。用jvisualvm观察实际情况也证实了上面的判断:
StandardContext实例数
WebappLoader实例数
WebappClassLoader实例数
上面讲过了WebappLoader的初始化代码,接下来讲一下WebappClassLoader的对象初始化代码。同样还是在StandardContext类的startInternal方法中,有这么两段代码:
if (getLoader() == null) { WebappLoader webappLoader = new WebappLoader(getParentClassLoader()); webappLoader.setDelegate(getDelegate()); setLoader(webappLoader); }
这一段上面已经说过是WebappLoader的初始化代码。
try { if (ok) { // Start our subordinate components, if any if ((loader != null) && (loader instanceof Lifecycle)) ((Lifecycle) loader).start();
这一段与WebappLoader的对象相关,执行的就是WebappLoader类的start方法,因为WebappLoader继承自LifecycleBase类,所以调用它的start方法最终将会执行该类自定义的startInternal方法,看下startInternal方法中的这段代码:
classLoader = createClassLoader(); classLoader.setResources(container.getResources()); classLoader.setDelegate(this.delegate); classLoader.setSearchExternalFirst(searchExternalFirst); if (container instanceof StandardContext) { classLoader.setAntiJARLocking( ((StandardContext) container).getAntiJARLocking()); classLoader.setClearReferencesStatic( ((StandardContext) container).getClearReferencesStatic()); classLoader.setClearReferencesStopThreads( ((StandardContext) container).getClearReferencesStopThreads()); classLoader.setClearReferencesStopTimerThreads( ((StandardContext) container).getClearReferencesStopTimerThreads()); classLoader.setClearReferencesHttpClientKeepAliveThread( ((StandardContext) container).getClearReferencesHttpClientKeepAliveThread()); } for (int i = 0; i < repositories.length; i++) { classLoader.addRepository(repositories[i]); } // Configure our repositories setRepositories(); setClassPath(); setPermissions(); ((Lifecycle) classLoader).start();
一开始调用了createClassLoader方法:
/** * Create associated classLoader. */ private WebappClassLoader createClassLoader() throws Exception { Class<?> clazz = Class.forName(loaderClass); WebappClassLoader classLoader = null; if (parentClassLoader == null) { parentClassLoader = container.getParentClassLoader(); } Class<?>[] argTypes = { ClassLoader.class }; Object[] args = { parentClassLoader }; Constructor<?> constr = clazz.getConstructor(argTypes); classLoader = (WebappClassLoader) constr.newInstance(args); return classLoader; }
可以看出这里通过反射实例化了一个WebappClassLoader对象。
回到文中上面提的问题,看下WebappClassLoader的modified方法代码:
/** * Have one or more classes or resources been modified so that a reload * is appropriate? */ public boolean modified() { if (log.isDebugEnabled()) log.debug("modified()"); // Checking for modified loaded resources int length = paths.length; // A rare race condition can occur in the updates of the two arrays // It's totally ok if the latest class added is not checked (it will // be checked the next time int length2 = lastModifiedDates.length; if (length > length2) length = length2; for (int i = 0; i < length; i++) { try { long lastModified = ((ResourceAttributes) resources.getAttributes(paths[i])) .getLastModified(); if (lastModified != lastModifiedDates[i]) { if( log.isDebugEnabled() ) log.debug(" Resource '" + paths[i] + "' was modified; Date is now: " + new java.util.Date(lastModified) + " Was: " + new java.util.Date(lastModifiedDates[i])); return (true); } } catch (NamingException e) { log.error(" Resource '" + paths[i] + "' is missing"); return (true); } } length = jarNames.length; // Check if JARs have been added or removed if (getJarPath() != null) { try { NamingEnumeration<Binding> enumeration = resources.listBindings(getJarPath()); int i = 0; while (enumeration.hasMoreElements() && (i < length)) { NameClassPair ncPair = enumeration.nextElement(); String name = ncPair.getName(); // Ignore non JARs present in the lib folder if (!name.endsWith(".jar")) continue; if (!name.equals(jarNames[i])) { // Missing JAR log.info(" Additional JARs have been added : '" + name + "'"); return (true); } i++; } if (enumeration.hasMoreElements()) { while (enumeration.hasMoreElements()) { NameClassPair ncPair = enumeration.nextElement(); String name = ncPair.getName(); // Additional non-JAR files are allowed if (name.endsWith(".jar")) { // There was more JARs log.info(" Additional JARs have been added"); return (true); } } } else if (i < jarNames.length) { // There was less JARs log.info(" Additional JARs have been added"); return (true); } } catch (NamingException e) { if (log.isDebugEnabled()) log.debug(" Failed tracking modifications of '" + getJarPath() + "'"); } catch (ClassCastException e) { log.error(" Failed tracking modifications of '" + getJarPath() + "' : " + e.getMessage()); } } // No classes have been modified return (false); }
这段代码从总体上看共分成两部分,第一部分检查web应用中的class文件是否有变动,根据class文件的最近修改时间来比较,如果有不同则直接返回true,如果class文件被删除也返回true。第二部分检查web应用中的jar文件是否有变动,如果有同样返回true。稍有编程经验的人对于以上比较代码都容易理解,但对这些变量的值,特别是里面比较时经常用到WebappClassLoader类的实例变量的值是在什么地方赋值的会比较困惑,这里就这点做一下说明。
以class文件变动的比较为例,比较的关键代码是:
long lastModified = ((ResourceAttributes) resources.getAttributes(paths[i])) .getLastModified(); if (lastModified != lastModifiedDates[i]) {
即从WebappClassLoader的实例变量resources中取出文件当前的最近修改时间,与WebappClassLoader原来缓存的该文件的最近修改时间做比较。
关于resources.getAttributes方法,看下resources的声明类型javax.naming.directory.DirContext可知实际这里面执行的是通常的JNDI查询一个属性的方法(如果对JNDI不熟悉请看一下JNDI的相关文档大致了解一下,这里不再做单独介绍),所以有必要把resources变量究竟是何对象拎出来说一下。
在上面看WebappLoader的startInternal方法的源码里createClassLoader()方法调用并赋值给classLoader下一行:
classLoader.setResources(container.getResources());
这里设置的resources就是上面用到的resources变量,可以看到它实际是WebappLoader所关联容器的实例变量resources。按前面的描述所关联的容器及StandardContext,再来看看StandardContext中resources是怎么赋值的。
还是在StandardContext的startInternal方法中,开头部分有这段代码:
// Add missing components as necessary if (webappResources == null) { // (1) Required by Loader if (log.isDebugEnabled()) log.debug("Configuring default Resources"); try { if ((getDocBase() != null) && (getDocBase().endsWith(".war")) && (!(new File(getBasePath())).isDirectory())) setResources(new WARDirContext()); else setResources(new FileDirContext()); } catch (IllegalArgumentException e) { log.error("Error initializing resources: " + e.getMessage()); ok = false; } } if (ok) { if (!resourcesStart()) { log.error( "Error in resourceStart()"); ok = false; } }
因为默认的应用是不是war包发布,而是以目录形式发布的所以会执行setResources(new FileDirContext())方法。这里稍微曲折的地方是setResources里实际只是给StandardContext的webappResources变量赋值,而StandardContext的resources变量赋为null,在上面源码中的最后resourcesStart方法的调用中才会给resources赋值。看下resourcesStart方法:
public boolean resourcesStart() { boolean ok = true; Hashtable<String, String> env = new Hashtable<String, String>(); if (getParent() != null) env.put(ProxyDirContext.HOST, getParent().getName()); env.put(ProxyDirContext.CONTEXT, getName()); try { ProxyDirContext proxyDirContext = new ProxyDirContext(env, webappResources); if (webappResources instanceof FileDirContext) { filesystemBased = true; ((FileDirContext) webappResources).setAllowLinking (isAllowLinking()); } if (webappResources instanceof BaseDirContext) { ((BaseDirContext) webappResources).setDocBase(getBasePath()); ((BaseDirContext) webappResources).setCached (isCachingAllowed()); ((BaseDirContext) webappResources).setCacheTTL(getCacheTTL()); ((BaseDirContext) webappResources).setCacheMaxSize (getCacheMaxSize()); ((BaseDirContext) webappResources).allocate(); // Alias support ((BaseDirContext) webappResources).setAliases(getAliases()); if (effectiveMajorVersion >=3 && addWebinfClassesResources) { try { DirContext webInfCtx = (DirContext) webappResources.lookup( "/WEB-INF/classes"); // Do the lookup to make sure it exists webInfCtx.lookup("META-INF/resources"); ((BaseDirContext) webappResources).addAltDirContext( webInfCtx); } catch (NamingException e) { // Doesn't exist - ignore and carry on } } } // Register the cache in JMX if (isCachingAllowed()) { String contextName = getName(); if (!contextName.startsWith("/")) { contextName = "/" + contextName; } ObjectName resourcesName = new ObjectName(this.getDomain() + ":type=Cache,host=" + getHostname() + ",context=" + contextName); Registry.getRegistry(null, null).registerComponent (proxyDirContext.getCache(), resourcesName, null); } this.resources = proxyDirContext; } catch (Throwable t) { ExceptionUtils.handleThrowable(t); log.error(sm.getString("standardContext.resourcesStart"), t); ok = false; } return (ok); }
可以看出resources赋的是proxyDirContext对象,而proxyDirContext是一个代理对象,代理的就是webappResources,按上面的描述即org.apache.naming.resources.FileDirContext。
org.apache.naming.resources.FileDirContext继承自抽象父类org.apache.naming.resources.BaseDirContext,而BaseDirContext又实现了javax.naming.directory.DirContext接口。所以JNDI操作中的lookup、bind、getAttributes、rebind、search等方法都已经在这两个类中实现了。当然里面还有JNDI规范之外的方法如list等。
这里就看下前面看到的getAttributes方法的调用,在BaseDirContext类中所有的getAttributes方法最终都会调用抽象方法doGetAttributes来返回查询属性的结果,这个方法在FileDirContext的定义如下:
protected Attributes doGetAttributes(String name, String[] attrIds) throws NamingException { // Building attribute list File file = file(name); if (file == null) return null; return new FileResourceAttributes(file); }
可以看到内部执行了file方法:
/** * Return a File object representing the specified normalized * context-relative path if it exists and is readable. Otherwise, * return <code>null</code>. * * @param name Normalized context-relative path (with leading '/') */ protected File file(String name) { File file = new File(base, name); if (file.exists() && file.canRead()) { if (allowLinking) return file; // Check that this file belongs to our root path String canPath = null; try { canPath = file.getCanonicalPath(); } catch (IOException e) { // Ignore } if (canPath == null) return null; // Check to see if going outside of the web application root if (!canPath.startsWith(absoluteBase)) { return null; } // Case sensitivity check - this is now always done String fileAbsPath = file.getAbsolutePath(); if (fileAbsPath.endsWith(".")) fileAbsPath = fileAbsPath + "/"; String absPath = normalize(fileAbsPath); canPath = normalize(canPath); if ((absoluteBase.length() < absPath.length()) && (absoluteBase.length() < canPath.length())) { absPath = absPath.substring(absoluteBase.length() + 1); if (absPath == null) return null; if (absPath.equals("")) absPath = "/"; canPath = canPath.substring(absoluteBase.length() + 1); if (canPath.equals("")) canPath = "/"; if (!canPath.equals(absPath)) return null; } } else { return null; } return file; }
了解java的文件操作的人这段代码就很容易理解了,实际就是根据传入的文件名查找目录下是否存在该文件,如果存在则返回包装了的文件属性对象FileResourceAttributes。FileResourceAttributes类实际是对java.io.File类做了一层包装,如getLastModified方法实际调用的是File类的lastModified方法返回。
long lastModified = ((ResourceAttributes) resources.getAttributes(paths[i])) .getLastModified(); if (lastModified != lastModifiedDates[i]) {
以上分析了上面这段代码中((ResourceAttributes) resources.getAttributes(paths[i])).getLastModified()这部分,但两个内置变量paths和lastModifiedDates值究竟什么时候赋的呢?
这个简要说一下WebappClassLoader这个自定义类加载器的用法,在Tomcat中所有web应用内WEB-INF\classes目录下的class文件都是用这个类加载器来加载的,一般的自定义加载器都是覆写ClassLoader的findClass方法,这里也不例外。WebappClassLoader覆盖的是URLClassLoader类的findClass方法,而在这个方法内部最终会调用findResourceInternal(String name, String path)方法:
该方法代码段较长,为不偏离主题,摘出本文描述相关的代码段:
// Register the full path for modification checking // Note: Only syncing on a 'constant' object is needed synchronized (allPermission) { int j; long[] result2 = new long[lastModifiedDates.length + 1]; for (j = 0; j < lastModifiedDates.length; j++) { result2[j] = lastModifiedDates[j]; } result2[lastModifiedDates.length] = entry.lastModified; lastModifiedDates = result2; String[] result = new String[paths.length + 1]; for (j = 0; j < paths.length; j++) { result[j] = paths[j]; } result[paths.length] = fullPath; paths = result; }
这里可以看到在加载一个新的class文件时会给WebappClassLoader的实例变量lastModifiedDates和paths数组添加元素。这里就解答了上面提到的文件变更比较代码的疑问。要说明的是在tomcat启动后web应用中所有的class文件并不是全部加载的,而是配置在web.xml中描述的需要与应用一起加载的才会立即加载,否则只有到该类首次使用时才会由类加载器加载。
关于Tomcat的自定义类加载器是一个很有意思的话题,可说的地方很多,后面会专文另述。而关于jar包文件变动的比较代码同class文件比较的类似,同样是取出当前web应用的WEB-INF\lib目录下的所有jar文件,与WebappClassLoader内部缓存的jarNames数组做比较,如果文件名不同或新加或删除了jar文件都返回true。
但这里jarNames变量的初始赋值代码在WebappClassLoader类的addJar方法中的开头部分:
if ((jarPath != null) && (jar.startsWith(jarPath))) { String jarName = jar.substring(jarPath.length()); while (jarName.startsWith("/")) jarName = jarName.substring(1); String[] result = new String[jarNames.length + 1]; for (i = 0; i < jarNames.length; i++) { result[i] = jarNames[i]; } result[jarNames.length] = jarName; jarNames = result; }
而addJar方法是在WebappLoader类的startInternal方法中,上面已经给出与这个相关的代码,里面的这段代码部分:
// Configure our repositories setRepositories(); setClassPath();
在setRepositories的方法最后部分:
try { JarFile jarFile = new JarFile(destFile); classLoader.addJar(filename, jarFile, destFile); } catch (Exception ex) { // Catch the exception if there is an empty jar file // Should ignore and continue loading other jar files // in the dir } loaderRepositories.add( filename );
即在tomcat启动时的加载web应用的过程里就会加载该应用的lib目录下的所有jar文件,同时给WebappClassLoader的实例变量jarNames添加数组元素。
addJar方法的调用路径
在看jar包加载的代码时会不断碰到resources对象list、getAttributes等方法的调用,记住这里实际上调用的是上面提到的FileDirContext的相关方法,也即对于文件的查询访问方法就清楚了。
相关推荐
介绍了让tomcat自动加载的 代码及文件
这个"DevLoader.zip"文件可能包含与Tomcat自定义类加载器相关的资料,特别是名为"DevLoader"的类加载器,这可能是Tomcat为开发者提供的一种特殊加载器。 首先,我们来理解一下类加载器的基本概念。在Java中,类加载...
Tomcat默认情况下并不会自动检测到类或Servlet的更改并重新加载,这主要是为了提高性能和稳定性。但是,通过配置可以开启热部署功能,即在不重启服务器的情况下,Tomcat能够识别到代码的更改,并自动重新加载受影响...
Tomcat7.0.62 启动类加载日志
这个实例主要探讨如何使用Quartz实现自动化的任务调度,包括自动生成表达式、动态更新XML配置文件以及在Tomcat服务器上实现自动加载。 首先,Quartz的核心在于Job和Trigger。Job是实际执行的任务,而Trigger则定义...
在Tomcat中,我们可以通过配置`catalina.properties`文件和`server.xml`文件来调整类加载策略,例如设置自定义的类加载顺序或启用共享类加载器。 此外,Tomcat还支持热部署,即在不重启服务器的情况下更新Web应用的...
Java 类加载器是Java虚拟机(JVM)的核心组成部分,它负责将编译后的字节码文件(.class文件)加载到JVM中并转换为运行时的数据结构。Tomcat,作为广泛使用的Java Servlet容器,它自定义了一套类加载机制,以满足Web...
tomcat7源码调试,测试类中依赖的一个类,这里提供这个类的文件
Tomcat通过自定义的类加载器实现了特定的类加载顺序,以确保能够正确处理不同来源的类文件,避免类的重复加载和类版本冲突问题。以下是Tomcat启动过程中类加载的具体顺序: #### 三、类加载顺序详述 1. **Bootstrap...
标题中的“tomcat7修改catalina.out日志按天生成jar文件”指的是在Tomcat 7这个流行的Java应用服务器中,对`catalina.out`日志文件进行配置,以便按照日期自动分割生成新的日志文件。这有助于管理日志,避免单个日志...
标题中的“Tomcat7本地服务器”指的是Apache Tomcat 7,这是一个开源的、基于Java的Web应用程序服务器,特别适合小型到中型的项目。Tomcat是Java Servlet和JavaServer Pages(JSP)技术的主要实现,它允许开发人员...
jsp解决文件上传后重启Tomcat时文件自动删除问题 本文主要介绍了jsp解决文件上传后重启Tomcat时文件自动删除问题,包括问题分析和详细解决方法。该问题是指在jsp项目中上传文件后,重启Tomcat服务器或清理缓存时,...
- **类结构**:程序主要由`CheckTomcat`类构成,其中包含了`checkTomcatIsAlive`方法用于检测Tomcat状态,以及`main`方法作为程序入口。 - **代码示例**: - **初始化配置**:在`main`方法中初始化`monitorurl`和`...
在IT领域,特别是Web应用服务器的管理与维护中,Tomcat自动关闭是一个常见的问题,它可能由多种因素引起,对业务连续性和系统稳定性造成严重影响。本文将深入探讨导致Tomcat自动关闭的原因,以便采取有效措施预防和...
设置好 tomcat 本地路径和webApp目录 快速删除相应的temp文件。 便于开发
在Java和Tomcat的环境中,有时我们可能需要清理Tomcat服务器下的临时文件,这通常是出于优化性能、释放磁盘空间或解决某些问题的需要。Tomcat作为一款广泛使用的Java应用服务器,它会在运行过程中生成各种临时文件,...
win7自动清除tomcat缓存及日志
Tomcat7支持多种方式部署Web应用,如通过webapps目录下的WAR文件自动部署,或者通过管理工具(如Manager App)进行远程部署。此外,还提供了热部署功能,允许在运行时更新应用。 五、Tomcat7的安全性 Tomcat7提供...
此外,Tomcat9还引入了新的部署特性,如自动检测应用更新,以及对Java EE 8的部分实现,比如JASPIC(Java Authentication and Authorization Service for Containers)和JAR签名验证。 在Eclipse中配置Tomcat的过程...