• 软件测试技术
  • 软件测试博客
  • 软件测试视频
  • 开源软件测试技术
  • 软件测试论坛
  • 软件测试沙龙
  • 软件测试资料下载
  • 软件测试杂志
  • 软件测试人才招聘
    暂时没有公告

字号: | 推荐给好友 上一篇 | 下一篇

Tomcat 的过滤诀窍

发布: 2007-6-21 12:06 | 作者:   | 来源:   | 查看: 14次 | 进入软件测试论坛讨论

领测软件测试网

   
  Servlet 2.3 规范的附加过滤为 J2EE 应用程序提供了强化的性能

Sing Li (westmakaha@yahoo.com)
作家, Wrox 出版社
2001 年 6 月

新的 Java Servlet 2.3 规范有不少最激动人心的功能,其中之一便是过滤。乍一看,Servlet 2.3 过滤似乎与 Apache、IIS、Netscape Web 服务器及其它服务器中已有的传统过滤器非常相似。事实上,Servlet 2.3 过滤从结构上来说是一个完全不同的设计 -- 补充支持 Java 平台面向对象的特性,以提供更高级别的性能。本文向您介绍了 Tomcat 4 中的过滤,并展示了如何在项目中高效地使用过滤器。请点击文章顶部或底部的 讨论,参与讨论论坛,与本文作者和其他读者分享您对本文的想法。

过滤是 Tomcat 4 的新功能。(如想了解 Tomcat 的简要历史,请参阅 Tomcat 的故事)。它是 Servlet 2.3 规范的一部分,并且最终将为所有支持此标准的 J2EE 容器的厂商所采用执行。开发人员将能够用过滤器来实现以前使用不便的或难以实现的功能,这些功能包括:

* 资源访问(Web 页、JSP 页、servlet)的定制身份认证

* 应用程序级的访问资源的审核和记录

* 应用程序范围内对资源的加密访问,它建立在定制的加密方案基础上

* 对被访问资源的及时转换,包括从 servlet 和 JSP 的动态输出

这个清单当然并没有一一罗列,但它让您初步体验到了过滤所带来的额外价值。在本文中,我们将详细讨论 Servlet 2.3 的过滤,来看一看过滤器是如何配合 J2EE 处理模型的。不像其它传统的过滤方案,Servlet 2.3 过滤是建立在嵌套调用的基础上的。我们来研究一下这一差别是怎样在架构上与新的高性能 Tomcat 4 设计取得一致的。最后,我们将获得一些编写及测试两个 Servlet 2.3 过滤器的实际经验。这些过滤器只完成很简单的功能,使我们得以将注意力集中于编写过滤器以及如何将它们集成进 Web 应用程序的机制。

作为 Web 应用程序构建模块的过滤器
在物理结构上,过滤器是 J2EE Web 应用程序中的应用程序级的 Java 代码组件。除了 servlet 和 JSP 页以外,遵循 Servlet 2.3 规范编码的开发人员能将过滤器作为在 Web 应用程序中加入活动行为的机制。与在特定的 URL 上工作的 servlet 和 JSP 页不同,过滤器接入 J2EE 容器的处理管道,并能跨越由 Web 应用程序提供的 URL 子集(或所有 URL)进行工作。图 1 说明了过滤是在哪里配合 J2EE 请求处理的。

图 1. 过滤器与 J2EE 请求处理

[myimg]upload/fig1.png[/myimg]

兼容 Servlet 2.3 的容器允许过滤器在请求被处理(通过 Servlet 引擎)以前以及请求得到处理以后(过滤器将可以访问响应)访问 Web 请求。

在这些情况下,过滤器可以:

* 在请求得到处理以前修改请求的标题
* 提供它自己的请求版本以供处理
* 在请求处理以后和被传回给用户以前修改响应
* 先取得由容器进行的所有请求处理,并产生自己的响应

比过滤器的可用性更为重要的是,接入 J2EE 处理管道需要创建不可移植的、容器专用的和系统范围的扩展机制(如 Tomcat 3 拦截器)。

概念上的 Tomcat 过滤
不同于在 Apache、IIS 或 Netscape 服务器中能找到的熟悉的过滤机制,Servlet 2.3 过滤器并非建立在挂钩式函数调用上。事实上, Tomcat 4 级别的引擎架构脱离了传统的 Tomcat 3.x 版本。新的 Tomcat 4 引擎取代了在请求处理的不同阶段调用挂钩式方法的整体式引擎,它在内部使用了一系列的嵌套调用、包装请求及响应。不同的过滤器和资源处理器构成了一个链。

在传统架构中:

* 每次接受到请求,挂钩式方法就被调用,不论它们是否执行(有时甚至是空的)。

* 方法的作用域及并发关系(每个方法可能在不同的线程上被调用)不允许在处理相同的请求时简单、高效地共享不同挂钩式方法调用间的变量和信息。

在新架构中:

* 嵌套的方法调用通过一系列过滤器实现,它仅有应用于当前请求的过滤器组成;基于挂钩式调用的传统执行方式需要在处理短句中调用挂钩式例程,即使一个特定短句的处理逻辑不起任何作用。

* 局部变量在实际的过滤方法返回之前都作保留,并且可用(因为上游过滤器的调用总在堆栈上,等待后续调用的返回)。

这一新架构为今后的 Tomcat 性能调整与优化提供了一个新的、更对象友好的基础。Servlet 2.3 过滤器是这个新的内部架构的自然扩展。该架构为 Web 应用程序设计人员提供了一个可移植的执行过滤行为的方法。

调用链
所有过滤器都服从调用的过滤器链,并通过定义明确的接口得到执行。一个执行过滤器的 Java 类必须执行这一 javax.servlet.Filter 接口。这一接口含有三个过滤器必须执行的方法:

* doFilter(ServletRequest, ServletResponse, FilterChain):这是一个完成过滤行为的方法。这同样是上游过滤器调用的方法。引入的 FilterChain 对象提供了后续过滤器所要调用的信息。

* init(FilterConfig):这是一个容器所调用的初始化方法。它保证了在第一次 doFilter() 调用前由容器调用。您能获取在 web.xml 文件中指定的初始化参数。

* destroy():容器在破坏过滤器实例前,doFilter()中的所有活动都被该实例终止后,调用该方法。

请注意:Filter 接口的方法名及语义在最近的几个 beta 周期中曾有过不断的改变。Servlet 2.3 规范仍未处于最后的草案阶段。在 Beta 1 中,该接口包括 setFilterConfig() 和 getFilterConfig() 方法,而不是 init() 和 destroy()。

嵌套调用在 doFilter() 方法执行中发生。除非您建立一个过滤器明确阻止所有后续处理(通过其它过滤器及资源处理器),否则过滤器一定会在 doFilter 方法中作以下的调用:

FilterChain.doFilter(request, response);



安装过滤器:定义与映射
容器通过 Web 应用程序中的配置描述符 web.xml 文件了解过滤器。有两个新的标记与过滤器相关:<filter> 和 <filter-mapping>。应该指定它们为 web.xml 文件内 <web-app> 标记的子标记。

过滤器定义的元素
<filter> 标记是一个过滤器定义,它必定有一个 <filter- name> 和 <filter-class> 子元素。<filter-name> 子元素给出了一个与过滤器实例相关的、基于文本的名字。<filter-class> 指定了由容器载入的实际类。您能随意地包含一个 <init-param> 子元素为过滤器实例提供初始化参数。例如,下面的过滤器定义指定了一个叫做 IE Filter 的过滤器:

清单 1. 过滤器定义标记

<web-app>

<filter>
<filter-name>IE Filter</filter-name>
<filter-class>com.ibm.devworks.filters.IEFilter</filter-class>
</filter>

</web-app>

容器处理 web.xml 文件时,它通常为找到的每个过滤器定义创建一个过滤器实例。这一实例用来服务所有的可用 URL 请求;因此,以线程安全的方式编写过滤器是最为重要的。

过滤器映射及子元素
<filter-mapping> 标记代表了一个过滤器的映射,指定了过滤器会对其产生作用的 URL 的子集。它必须有一个 <filter-name> 子元素与能找到您希望映射的过滤器的过滤器定义相对应。接下来,您可以使用 <servlet-name> 或 <url-pattern> 子元素来指定映射。<servlet-name> 指定了一个过滤器应用的 servlet (在 web.xml 文件中的其它地方已定义)。您能使用 <url-pattern> 来指定一个该过滤器应用的 URL 的子集。例如, /* 的样式用来代表该过滤器映射应用于该应用程序用到的每个 URL,而 /dept/humanresources/* 的样式则表明该过滤器映射只应用于人力资源部专有的 URL。

容器使用这些过滤器映射来确定一个特定的过滤器是否应参与某个特定的请求。清单 1 是为应用程序的所有 URL 定义的应用于 IE Filter 的一个过滤器映射:

清单 2. 过滤器映射标记

<filter-mapping>
<filter-name>IE Filter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>




创建一个简单的过滤器
现在该来定义我们的第一个过滤器了。这是一个不重要的过滤器,检查请求标题以确定是不是使用 Internet Explorer 浏览器来查看 URL 的。如果是 Internet Explorer 浏览器,过滤器就显示“拒绝访问”的信息。尽管操作并不重要,但这个示例演示了:

* 一个过滤器的一般剖析
* 一个在请求到达资源处理器前检查其标题信息的过滤器
* 如何编写一个过滤器来阻止基于运行时间检测到的条件(验证参数、源 IP、时间…等等)的后续处理

此过滤器的源代码作为 IEFilter.java,com.ibm.devworks.filters 包的一部分位于源代码发布区中(请参阅参考资料)。现在就让我们来仔细研究一下该过滤器的代码。

清单 3. 使用 Filter 接口

public final class IEFilter implements Filter {
private FilterConfig filterConfig = null;



所有的过滤器都须执行 Filter 接口。我们创建了一个局部变量以容纳由容器在初始化过滤器时传递进来的 filterConfig。这有时发生在第一次调用 doFilter() 前。

清单 4. doFilter 方法

public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain)
throws IOException, ServletException {
String browserDet =
((HttpServletRequest) request).getHeader("User-Agent").toLowerCase();

if ( browserDet.indexOf("msie") != -1) {
PrintWriter out = response.getWriter();
out.println("<html><head></head><body>");
out.println("<h1>Sorry, page cannot be displayed!</h1>");
out.println("</body></html>");
out.flush();
return;
}



doFilter() 完成了大部分工作。我们来检查一下叫做“用户代理”标题的请求标题。所有的浏览器都提供这个标题。我们将其转换成小写字母,然后查找说明问题的标识字符串 "msie"。如果检测到了 Internet Explorer,我们就从响应对象中获取一个 PrintWriter 来写出自己的响应。在写出了定制的响应后,方法无需连到其它过滤器就能返回。这就是过滤器阻止后续处理的方法。

如果浏览器并非 Internet Explorer,我们就能进行正常的链式操作,让后续过滤器和处理器能在得到请求时获得执行的机会:

清单 5. 进行正常链式操作

chain.doFilter(request, response);

}



随后,我们粗略地执行该过滤器中的 init() 和 destroy() 方法:

清单 6. init() 和 destroy() 方法

public void destroy() {
}

public void init(FilterConfig filterConfig) {
this.filterConfig = filterConfig;

}
}



测试 IEFilter
假设您安装了 Tomcat 4 beta 3 (或更新版本)并能使用,请按下列步骤启动 IEFilter 并运行:

1.

在 $TOMCAT_HOME/conf 目录下的 server.xml 文件里创建一个新的应用程序上下文,如下所示:


<!-- Tomcat Examples Context -->
<Context path="/examples" docBase="examples" debug="0"
reloadable="true">
...
</Context>

<Context path="/devworks" docBase="devworks" debug="0"
reloadable="true">
<Logger className="org.apache.catalina.logger.FileLogger"
prefix="localhost_devworks_log." suffix=".txt"
timestamp="true"/>

</Context>



2.

编辑代码区的 devworks/WEB-INF 下的 web.xml 文件,以包括下列的过滤器定义及映射:

<web-app>

<filter>
<filter-name>IE Filter</filter-name>
<filter-class>com.ibm.devworks.filters.IEFilter</filter-class>
</filter>

<filter-mapping>
<filter-name>IE Filter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>

</web-app>



3.

在 $TOMCAT_HOME/webapps 目录下创建一个叫做 devworks 的新目录,并将所有 devworks 目录下的东西(包括所有子目录)从源代码区复制到该位置。现在就准备好启动 Tomcat 4 了。
4.

使用下面的 URL 来访问一个简单的 index.html 页面:
http://<hostname>/devworks/index.html

如果您使用的是 Internet Explorer,就能看见如图 2 所示的定制的“拒绝访问”信息。

图 2. IEFilter 在遇到 Internet Explorer 的运行效果

[myimg]upload/fig2.png[/myimg]

如果您使用的是 Netscape,那就能看见如图 3 所示的确切的 HTML 页面。

图 3. IEFilter 用 Netscape 浏览器的浏览效果

[myimg]upload/fig3.png[/myimg]

编写转换资源的过滤器
现在该来试一下更复杂的过滤器了。该过滤器:

* 从过滤器定义的实例初始化参数中读取一组 "search" 及 "replace" 文本

* 过滤被访问的 URL,将出现的第一个 "search" 文本替代为 "replace" 文本

在我们深入研究这个过滤器的过程中,您将对内容转换/替代过滤器的架构加深了解。相同的架构能用于任何加密、压缩及转换(如由 XSLT 转换来的 SML)过滤器。

核心机密是在链式处理的过程中传递一个定制的响应对象的包装版本。该定制的包装响应对象须隐藏原响应对象(从而对其实现 包装),并提供一个定制的流以供后续处理器写入。如果工作(文本替换、转换、压缩、加密…等)能迅速完成,定制流的执行就能中止后续记录并完成需要的工作。然后定制的流就会将经转换的数据写入包装的响应对象(也就是说,简单的字符替换加密)。如果工作无法迅速完成,定制的流就需等待,直到后续处理器完成对流的写入(也就是说,当其关闭或刷新流时)。然后它才完成转换工作,并将经转换的输出结果写入“真正的”响应中。

在我们的过滤器(ReplaceTextFilter)中,定制的包装响应对象叫作 ReplaceTextWrapper。定制流的执行叫做 ReplaceTextStream。您能在 com.ibm.devworks.filters 包中的 ReplaceTextFilter.java 文件里找到源代码。(请参阅参考资料)。现在就让我们来研究一下源代码吧。

清单 7. ReplaceTextStream 类

class ReplaceTextStream extends ServletOutputStream {
private OutputStream intStream;
private ByteArrayOutputStream baStream;
private boolean closed = false;

private String origText;
private String newText;

public ReplaceTextStream(OutputStream outStream,
String searchText,
String replaceText) {
intStream = outStream;
baStream = new ByteArrayOutputStream();
origText = searchText;
newText = replaceText;
}



这是定制的输出流代码。intStream 变量包含了对来自响应对象的实际流的引用。baStream 是我们输出流的缓冲版本,后续处理器就写入这里。closed 标记标明了 close() 是否在此实例流中被调用。构造器将来自响应对象的流引用存储起来并创建了缓冲流。它还将文本字符串存储起来供以后的替代操作使用。

清单 8. write() 方法

public void write(int i) throws java.io.IOException {
baStream.write(i);
}



我们须提供自己的源于 ServletOutputStream 的 write() 方法。在此,我们当然是写入缓冲流。所有来自后续处理器的更高级输出方法都将以最低级别使用该方法,以保证所有的写入都指向缓冲流。

清单 9. close() 及 flush() 方法

public void close() throws java.io.IOException {
if (!closed) {

processStream();
intStream.close();
closed = true;
}
}

public void flush() throws java.io.IOException {
if (baStream.size() != 0) {
if (! closed) {
processStream(); // need to synchronize the flush!
baStream = new ByteArrayOutputStream();
}
}
}



close() 及 flush() 方法是我们完成转换的语句。根据后续处理器不同,其中的一个或两个程序都有可能被调用。我们使用布尔型的 closed 标识来避免异常情况。请注意,我们将实际的替代工作委托给了 processStream() 方法。

清单 10. processStream() 方法

public void processStream() throws java.io.IOException {
intStream.write(replaceContent(baStream.toByteArray()));
intStream.flush();
}



processStream() 方法将经转换的输出结果从 baStream 写入其已经配有的 intStream 中去。转换工作独立于 replaceContent() 方法。

清单 11. replaceContent() 方法

public byte [] replaceContent(byte [] inBytes) {
String retVal ="";
String firstPart="";

String tpString = new String(inBytes);
String srchString = (new String(inBytes)).toLowerCase();

int endBody = srchString.indexOf(origText);

if (endBody != -1) {
firstPart = tpString.substring(0, endBody);
retVal = firstPart + newText +
tpString.substring(endBody + origText.length());

} else {
retVal=tpString;
}

return retVal.getBytes();
}

}



replaceContent() 是发生搜索与替换的语句。它将一个字节数组作为输入并返回一个字节数组,创建一个原始的概念接口。事实上,我们能通过替换该方法中的逻辑部分来完成任何形式的转换。这里,我们进行非常简单的文本替换。

清单 12. ReplaceTextWrapper 类

class ReplaceTextWrapper extends HttpServletResponseWrapper {
private PrintWriter tpWriter;
private ReplaceTextStream tpStream;

public ReplaceTextWrapper(ServletResponse inResp, String searchText,
String replaceText)
throws java.io.IOException {
super((HttpServletResponse) inResp);
tpStream = new ReplaceTextStream(inResp.getOutputStream(),
searchText,
replaceText);
tpWriter = new PrintWriter(tpStream);
}

public ServletOutputStream getOutputStream() throws java.io.IOException {

return tpStream;
}
public PrintWriter getWriter() throws java.io.IOException {

return tpWriter;
}
}



我们定制的包装响应能方便地从帮助类 HttpServletResponseWrapper 中导出。这一类粗略地执行许多方法,允许我们简单地覆盖 getOutputStream() 方法以及 getWriter() 方法,提供了定制输出流的实例。

清单 13. ReplaceTextWrapper() 方法

public final class ReplaceTextFilter implements Filter {
private FilterConfig filterConfig = null;
private String searchText = ".";
private String replaceText = ".";
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain)
throws IOException, ServletException {


ReplaceTextWrapper myWrappedResp = new ReplaceTextWrapper( response,
searchText, replaceText);
chain.doFilter(request, myWrappedResp);
myWrappedResp.getOutputStream().close();

}

public void destroy() {
}



最后,还有过滤器本身。它所做的不过是使用 FilterChain 为递交响应后续创建一个定制的包装响应实例,如下所示:

清单 14. 创建一个定制的包装响应实例

public void init(FilterConfig filterConfig) {

String tpString;
if (( tpString = filterConfig.getInitParameter("search") ) != null)
searchText = tpString;
if (( tpString = filterConfig.getInitParameter("replace") ) != null)
replaceText = tpString;

this.filterConfig = filterConfig;
}

}



在 init 方法中,我们取回了过滤器定义中指定的初始参数。filterConfig 对象中的 getInitParameter() 方法便于用来实现这个目的。

测试 ReplaceTextFilter
假如您使用先前提及的步骤测试了 IEFilter,并将所有文件复制到了 $TOMCAT/webapps/devworks 下,您就能用以下的步骤来测试 ReplaceTextFilter:

1.

编辑 $TOMCAT/wepapps/devworks/WEB-INF 目录下的 web.xml 文件,以包含下列过滤器的定义及映射:

<web-app>

<filter>
<filter-name>Replace Text Filter</filter-name>
<filter-class>com.ibm.devworks.filters.ReplaceTextFilter</filter-class>
<init-param>
<param-name>search</param-name>
<param-value>cannot</param-value>
</init-param>
<init-param>
<param-name>replace</param-name>
<param-value>must not</param-value>
</init-param>

</filter>


<filter-mapping>
<filter-name>Replace Text Filter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>

</web-app>



2.

重新启动 Tomcat。
3.

现在,请用下面的 URL 来访问 index.html 页面:
http://<host name>:8080/devworks/index.html

请注意, ReplaceTextFilter 是如何迅速地将 cannot 变为 must not 的。想确信过滤使用了所有资源,您可以尝试编写输出结果含有字符串 cannot 的 JSP 页或 servlet。

过滤器链排列顺序的重要性
过滤器链式排列的顺序取决于 web.xml 描述信息内 <filter-mapping> 语句的顺序。在大多数情况下,过滤器链式排列的顺序是非常重要的。也就是说,在应用 A 过滤器前使用 B 过滤器与在使用 B 过滤器前使用 A 过滤器所得到的结果是完全不同的。如果一个应用程序中使用了一个以上的过滤器,那么在写入 <filter-mapping> 语句的时候要小心。

我们能轻易地通过排列 web.xml 文件中 <filter-mapping> 的顺序看到这一效果:

清单 15. 过滤的顺序 -- IE Filter 为先

<web-app>

<filter-mapping>
<filter-name>IE Filter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>

<filter-mapping>
<filter-name>Replace Text Filter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>

</web-app>



现在,用 Internet Explorer 载入 index.html 页。您能看到由于 IE Filter 处于过滤器链中的第一位,所以 Replace Text Filter 没有机会执行。因此,输出的信息是 "Sorry, page cannot be displayed!"

现在,将 <filter-mapping> 标记的顺序颠倒过来,变为:

清单 16. 过滤的顺序 -- Replace Text Filter 为先

<web-app>

<filter-mapping>
<filter-name>Replace Text Filter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>

<filter-mapping>
<filter-name>IE Filter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>

</web-app>



再次用 Internet Explorer 载入 index.html 页面。这次,Replace Text Filter 先执行,将包装的响应对象提供给 IE Filter。在 IE Filter 写入了其定制的响应后,专用的响应对象在输出结果到达最终用户处以前完成转换。故而,我们看到了这条信息:Sorry, page must not be displayed!

在应用程序中使用过滤器
写这篇文章的时候, Tomcat 4 正处于 beta 周期的后期,正式发行的日子已为期不远。主要的 J2EE 容器厂商都准备好了将 Servlet 2.3 规范整合到其产品中去。对于 Servlet 2.3 过滤器如何工作有一个基本的了解有助于您在设计及编写基于 J2EE 的应用程序时往自己的工具库中再加入一件多功能的工具。

参考资料

* 下载本文中用到的源代码。

* Tomcat 主页提供了关于 Tomcat 所有可获得的发行版本信息。请定期查阅该页上的文件链接以了解有关过滤的文档更新信息。

* 请阅读正式规范, Java Servlet 2.3,以了解关于过滤的详细信息。

* 请加入官方的 Servlet 兴趣邮件列表来讨论 Servlet 2.3 过滤的复杂问题。

* 更深入地了解 WebSphere 3.5 是如何处理 servlet 过滤的。

* 该 IBM 红皮书提供了关于 利用 servlet、JSP 页和 EJB 组件的 Web 应用程序设计。

* "Developing JSP Tag Libraries with Apache Tomcat 4.0 and VisualAge for Java" 向您展示了如何将面向 Java 的 VisualAge 附加到已安装的 Apache Tomcat 4.0 服务器上,以及如何在标记代码中设置断点。

* 在 专业 JSP 第 2 版(Wrox 出版社,2000 年 5 月)中,有许多过滤器编写示例,包括一个压缩过滤器和一个 XSLT 转换过滤器。

* 对 servlet 编程感兴趣吗?来试试该免费的 dW 专用教程,它描述了 利用 servlet 和 JSP 技术建立因特网应用程序的技巧(developerWorks,2000 年 12 月)。

* 了解一下 JSP 技术为满足开发人员的需要而在不断改变(developerWorks,2001 年 6 月)。

* 在 developerWorks Java 技术专区中寻找更多的 Java 参考资料。



关于作者
Sing Li 是 Professional Jini 以及许多其它由 Wrox 出版社出版的书籍的作者。他是技术杂志的定期投稿者,也是 P2P 革命的积极传播者。Sing 是个顾问,还是个自由作家,能通过 westmakaha@yahoo.com 联系他。

文章来源于领测软件测试网 https://www.ltesting.net/


关于领测软件测试网 | 领测软件测试网合作伙伴 | 广告服务 | 投稿指南 | 联系我们 | 网站地图 | 友情链接
版权所有(C) 2003-2010 TestAge(领测软件测试网)|领测国际科技(北京)有限公司|软件测试工程师培训网 All Rights Reserved
北京市海淀区中关村南大街9号北京理工科技大厦1402室 京ICP备10010545号-5
技术支持和业务联系:info@testage.com.cn 电话:010-51297073

软件测试 | 领测国际ISTQBISTQB官网TMMiTMMi认证国际软件测试工程师认证领测软件测试网