深入Jetty源码之Servlet框架及实现(AsyncContext、RequestDispatcher、HttpSession)

本文涉及的产品
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
云数据库 Tair(兼容Redis),内存型 2GB
简介:

概述

Servlet是Server Applet的缩写,即在服务器端运行的小程序,而Servlet框架则是对HTTP服务器(Servlet Container)和用户小程序中间层的标准化和抽象。这一层抽象隔离了HTTP服务器的实现细节,而Servlet规范定义了各个类的行为,从而保证了这些“服务器端运行的小程序”对服务器实现的无关性(即提升了其可移植性)。
在Servlet规范有以下几个核心类(接口):
ServletContext :定义了一些可以和Servlet Container交互的方法。
Registration :实现Filter和Servlet的动态注册。
ServletRequest(HttpServletRequest) :对HTTP请求消息的封装。
ServletResponse(HttpServletResponse) :对HTTP响应消息的封装。
RequestDispatcher :将当前请求分发给另一个URL,甚至ServletContext以实现进一步的处理。
Servlet(HttpServlet) :所有“服务器小程序”要实现了接口,这些“服务器小程序”重写doGet、doPost、doPut、doHead、doDelete、doOption、doTrace等方法(HttpServlet)以实现响应请求的相关逻辑。
Filter(FilterChain) :在进入Servlet前以及出Servlet以后添加一些用户自定义的逻辑,以实现一些横切面相关的功能,如用户验证、日志打印等功能。
AsyncContext :实现异步请求处理。

AsyncContext

在Servlet 3.0中引入了AsyncContext,用于实现一个请求可以暂停处理,然后在将来的某个时候重新处理该请求,以释放当前请求处理过程中占用的线程。在使用时,当发现请求需要等待一段时间后才能做进一步处理时,可以调用ServletRequest.startAsync()方法,返回AsyncContext实例,使用自己的线程池启动一个线程来做接下来的处理或者将其放入一个任务队列中,以由一个线程不断的检查它的可用状态,以实现最后的返回处理,或调用dispatch方法将其分发给其他URL做进一步响应处理。这项功能对SocketConnector没有多大意义,因为即使Servlet的servic俄方发退出了,其所占用的线程会继续等待,并不会被回收,只有对SelectChannelConnector来说才有效果,因为它的等待不在HttpConnection的handleRequest方法中(AsyncContinuation的scheduleTimeout方法中),而是将timeout的信息提交给SelectSet,它内部会有Acceptors个线程对timeout进行检查。AsyncContext接口定义如下:
public  interface AsyncContext {
     // 原始请求相关信息的属性名,即dispatch方法所基于的计算信息。
     static  final String ASYNC_REQUEST_URI = "javax.servlet.async.request_uri";
     static  final String ASYNC_CONTEXT_PATH = "javax.servlet.async.context_path";
     static  final String ASYNC_PATH_INFO = "javax.servlet.async.path_info";
     static  final String ASYNC_SERVLET_PATH = "javax.servlet.async.servlet_path";
     static  final String ASYNC_QUERY_STRING = "javax.servlet.async.query_string";

     // AsyncContinuation是Jetty对AsyncContext的实现,它是一个有限状态机。
    // 1. 在初始化时,它处于IDLE状态,initial状态为true。
    // 2. 调用其handling()方法使其进入处理模式:若它处于IDLE状态,则设置initial状态为false,将状态转移到DISPATCHED,清除AsyncListener,返回true;若它处于REDISPATCH状态,将状态转移到REDISPATCHED,返回true;若它处于COMPLETING状态,状态转移到UNCOMPLETED,返回false;若它处于ASYNCWAIT状态,返回false。对其他状态,抛出IllegalStateException。
    // 3. 如果当前请求因为某些原因无法进一步处理时,可以调用ServletRequest.startAsync方法让当前请求进入ASYNCSTARTED状态,即调用AsyncContinuation.suspend方法,只有它处于DISPATCHED、REDISPATCHED状态下才能调用suspend方法,即在调用handling()方法之后。此方法还会更新AsyncEventState字段的信息,以及调用已注册的AsyncListener的onStartAsync方法,并清除已注册的AsyncListener。
    // 4. 调用unhandle()方法判断这个当前请求是否不需要做进一步处理而可以退出handleRequest中的循环:对ASYNCSTARTED状态,将其状态设置为ASYNCWAIT,并向SelectChannelHttpConnection中schedle一个timeout时间,如果此时它还是处于ASYNCWAIT状态(因为对非SelectChannelConnector,它会一直等待下一个dispatch/complete/timeout事件的到来,更新当前状态,并取消等待),则返回true,否则如果它变为COMPLETING状态,则设置状态为UNCOMPLETED,返回true,否则设置其状态为REDISPATCHED,并返回false;对DISPATCHED、REDISPATCHED状态,设置状态为UNCOMPLETED,返回true;对REDISPATCHING状态,设置为REDISPATCHED状态,返回false;对COMPLETING状态,设置为UNCOMPLETED,返回true;对其他状态,抛出异常。
    // 5. 当进入异步状态的请求完成后,需要将当前处理交由Container做进一步处理,如由另一个path完成进一步处理等,调用AsyncContext的dispatch方法,将当前请求分发回Container:如果当前AsyncContinuation处于ASYNCWAIT状态并且没有超时,设置状态为REDISPATCH,并cancelTimeout()、scheduleDispatch();对已经处于REDISPATCH状态,直接返回;对处于ASYNCSTARTED状态,设置为REDISPATCHING,并返回。
    // 6. 如果当前AsyncContinuation超时,调用其expired方法:对于处于ASYNCSARTED、ASYNCWAIT状态,触发AsyncListener的onTimeout事件,调用complete方法,并scheduleDispatch
    // 7. 当完成异步请求处理时,调用其complete方法:如果处于ASYNCWAIT状态,设置状态为COMPLETING,如果没有超时,scheduleTimeout、scheduleDispatch;当前状态为ASYNCSTARTED,设置状态为COMPLETING;对其他状态,抛出异常。
    // 8. 当退出handleRequest方法时,如果当前AsyncContinuation处于UNCOMPLETE状态,调用其doComplete方法,将其状态设置为COMPLETE,如果出现异常,注册javax.servlet.error.exception, javax.servlet.error.message属性,并触发AsyncListener的onError事件,否则触发onComplete事件。
    // 9. 对状态为ASYNCSTARTED、REDISPATCHING、COMPLETING、ASYNCWAIT,表示处于suspend状态。
    // 10. 对状态为ASYNCSTARTED、REDISPATCHING、REDISPATCH、ASYNCWAIT,表示其处于异步请求开启的状态。
    // 11. 对状态不是IDLE、DISPATCHED、UNCOMPLETED、COMPLETED,表示当前正处于异步请求状态。 


     // 在调用ServletRequest.startAsync方法中使用的ServletRequest、ServletResponse实例。在调用ServletRequest.startAsync方法时,内部调用AsyncContinuation的suspend方法,
    // 传入ServletContext、ServletRequest、ServletResponse实例,在有在AsyncContinuation实例处于DISPATCHED、REDISPATCHED状态下才能调用suspend方法。此时将_expired、_resumed状态设置为false,更新AsyncEventState中的AsyncContext、ServletContext(_suspendedContext, _dispatchedContext)、ServletRequest、ServletResponse、Path等信息(即如果传入的Request、Response、_suspendContext和当前已保存的实例不同或_event实例为null,则重新创建AsyncEventState实例,否则清除_event中的_dispatchedContext和_path字段)。将当前AsyncContinuation的状态设置为ASYNCSTARTED,保存已注册的AsyncListener列表(_asyncListeners)到_lastAsyncListeners,清除_asyncListeners列表,并触发_lastAsyncListeners中的onStartAsync事件(该事件中可以决定是否需要将自己注册回去)。
    // 对于AsyncContext实例,如果已经调用suspend方法,则返回_event中的ServletRequest、ServletResponse,否则返回HttpConnection中的ServletRequest、ServletResponse。 

     public ServletRequest getRequest();
     public ServletResponse getResponse();

     // 当前AsyncContext是否使用原始的request、response实例进行初始化。
     public  boolean hasOriginalRequestAndResponse();

     public  void dispatch();
     public  void dispatch(String path);
     public  void dispatch(ServletContext context, String path);
     public  void complete();
     public  void start(Runnable run);

     public  void addListener(AsyncListener listener);
     public  void addListener(AsyncListener listener, ServletRequest servletRequest, ServletResponse servletResponse);

    public <T  extends AsyncListener> T createListener(Class<T> clazz)  throws ServletException; 

     public  void setTimeout( long timeout);
     public  long getTimeout();
}
在Server的handleAsync()方法中,他使用HttpConnection的Request字段的AsyncEventState中的ServletRequest、ServletResponse作为Handler调用handle方法的参数,如果AsyncEventState中有path值,则会用该值来更新baseRequest中的URI相关信息。

RequestDispatcher

在Servlet中RequestDispatcher用于将请求分发到另一个URL中,或向响应中包含更多的信息。一般用于对当前请求做一些前期处理,然后需要后期其他Servlet、JSP来做进一步处理。在Jetty中使用Dispatcher类实现该接口,其接口定义如下:
public  interface RequestDispatcher {
     // 在Dispatcher类中包含了ContextHandler、uri、path、dQuery、named字段,其中ContextHandler是当前Web Application配置的Handler链用于将请求分发给当前Container(调用handle()方法)做进一步处理、dipatch后请求的全URI、path表示uriInContext、dQuery表示新传入的parameter、named表示可以使用Servlet名称创建Dispatcher,即将当前请求分发到一个命名的Servlet中。
    // 在Dispatcher类中有三个方法:forward、error、include。对forward、error来说,如果Response已经commit,会抛出IllegalStateException。
    // 其中forward和error只是DispatcherType不一样(FORWARD、ERROR),其他逻辑一样:清除ServletResponse中所有响应相关的字段,如Content Buffer、locale、ContentType、CharacterEncoding、MimeType等信息,设置ServletRequest的DispatchType;对named方式的Dispatcher,直接调用ContextHandler的handle方法,其target参数即为传入的named;如果dQuery字段不为null,将该dQuery中的包含的参数合并到当前请求中;更新Request的URI、ContextPath,并在其Request属性中添加原始请求的pathInfo、queryString、RequestURI、contextPath、servletPath信息,分别对应该接口中定义的字段,如果这是第二次forward,则保留最原始的请求相关的信息;最后调用ContextHandler的handle方法,target为path属性;在调用结束后,将RequestURI、ContextPath、ServletPath、PathInfo、Attributes、Parameters、QueryString、DispatcherType属性设置为原来的值。
    // 对include方法,它不会清除Response中的Buffer等信息:首先设置DispatcherType为INCLUDE,HttpConnection中的include字段加1,表示正处于INCLUDE的dispatch状态,从而阻止对ServletResponse响应头的设置、发送重定向响应、发送Error响应等操作,该include字段会在该方法结束是调用HttpConnection的included方法将其减1;同样对于named设置的Dispatcher实例,直接调用ContextHandler的handle方法,target为named值;对以path方式的include,首先合并传入的dQuery参数到Request中,更新Request中属性的requestURI、contextPath、pathInfo、query等,后调用ContextHandler的handle方法,target为path,在handle方法完成后,将请求Attributes、Parameters、DispatcherType设置会原有值。

     // 在forward中,原始请求对应信息使用的属性名。
     static  final String FORWARD_REQUEST_URI = "javax.servlet.forward.request_uri";
     static  final String FORWARD_CONTEXT_PATH = "javax.servlet.forward.context_path";
     static  final String FORWARD_PATH_INFO = "javax.servlet.forward.path_info";
     static  final String FORWARD_SERVLET_PATH = "javax.servlet.forward.servlet_path";
     static  final String FORWARD_QUERY_STRING = "javax.servlet.forward.query_string";

     // 在include中,原始请求对应信息使用的属性名。
     static  final String INCLUDE_REQUEST_URI = "javax.servlet.include.request_uri";
     static  final String INCLUDE_CONTEXT_PATH = "javax.servlet.include.context_path";
     static  final String INCLUDE_PATH_INFO = "javax.servlet.include.path_info";
     static  final String INCLUDE_SERVLET_PATH = "javax.servlet.include.servlet_path";
     static  final String INCLUDE_QUERY_STRING = "javax.servlet.include.query_string";

     // 在error中,原始请求对应信息使用的属性名。
     public  static  final String ERROR_EXCEPTION = "javax.servlet.error.exception";
     public  static  final String ERROR_EXCEPTION_TYPE = "javax.servlet.error.exception_type";
     public  static  final String ERROR_MESSAGE = "javax.servlet.error.message";
     public  static  final String ERROR_REQUEST_URI = "javax.servlet.error.request_uri";
     public  static  final String ERROR_SERVLET_NAME = "javax.servlet.error.servlet_name";
     public  static  final String ERROR_STATUS_CODE = "javax.servlet.error.status_code";

     public  void forward(ServletRequest request, ServletResponse response)  throws ServletException, IOException;
     public  void include(ServletRequest request, ServletResponse response)  throws ServletException, IOException;
}

HttpSession

Http的请求是无状态的,这种方式的好处是逻辑简单,因为服务器不需要根据当前服务器的状态做一些特殊处理,然而在实际应用中,有些时候希望一系列的请求共享一些数据和信息,在HTTP中可以有两种方式实现这种需求:一种是cookie,所有这些共享的数据和信息都使用cookie在发送请求时发送给服务器,在响应请求时将最新的信息和状态通过set-cookie的方式重新发送回客户端,这种方式可以使服务器依然保持简单无状态的处理逻辑,然而它每次都要来回传送这种状态信息,会占用带宽,而且cookie本身有大小限制,有些客户端处于安全的因素会禁止cookie使用,另外cookie采用明文方式,对有些数据来说是不适合的;另一种方式则是采用服务器端Session的方法,即使用SessionId将一系列的请求关联在一起,可以向Session存储这些请求共享的信息和数据,Session方式的好处是这些数据保存在服务器端,因而它是安全的,而且不需要每次在客户端和服务器端传输,可以减少带宽,而它不好的地方是会增加服务器负担,因为如果Session过多会占用服务器内存,另外它也会增加服务器端的逻辑,服务器要有一种机制保证相同的SessionId确实属于同一个系列的请求。
在Servlet种使用HttpSession抽象这种服务器端Session的信息。它包含了SessionId、CreationTime、LastAccessedTime、MaxInactiveInterval、Attributes等信息,在Servlet中的可见范伟是ServletContext,即跨Web Application的Session是不可见的。在Jetty中使用SessionManager来管理Session,Session可以存储在数据库中(JDBCSessionManager),也可以存在内存中(HashSessionManager)。在Jetty中使用AbstractSessionManager的内部类Session来实现HttpSession接口,并且该实现是线程安全的。HttpSession的接口定义如下:
public  interface HttpSession {
     // HttpSession创建的时间戳,从1970-01-01 00:00:00.000开始算到现在的毫秒数。
     public  long getCreationTime();
    
     // 当前Session的ID号,它用来唯一标识Web Application中的一个Session实例。在Jetty的实现中,有两种ID:NodeId和ClusterId,在SessionIdManager创建一个ClusterId时,可以使用一个SecureRandom的两次nextLong的36进制的字符串相加或者两次当前SessionIdManager的hashCode、当前可用内存数、random.nextInt()、Request的hashCode左移32位的异或操作的36进制字符串相加,并添加workName前缀,如果该ID已经存在,则继续使用以上逻辑,直到找到一个没有被使用的唯一的ID号。如果请求中的RequestedSessionId存在并在使用,则使用该值作为SessionID;如果当前请求已经存在一个正在使用的SessionId(在org.eclipse.jetty.server.newSessionId请求熟悉中),则使用该ID。而对与NodeId,它会在ClusterId之后加一个".workerName",可以通过SessionIdManager设置workName或在HashSessionIdManager中使用org.eclipse.jetty.ajp.JVMRoute请求属性设置。在AbstractSessionManager中设置NodeIdInSessionId为true来配置使用NodeId作为SessionId,默认使用ClusterId作为SessionId。
     public String getId();
    
     // 返回最后一次访问时间戳。在每一次属于同一个Session的新的Request到来时都会更新该值。
     public  long getLastAccessedTime();
    
     // 返回该Session对应的ServletContext。
     public ServletContext getServletContext();

     // 设置Session的Idle时间,以秒为单位。
     public  void setMaxInactiveInterval( int interval);
     public  int getMaxInactiveInterval();    

     // Attribute相关操作。在设置属性时,如果传入value为null,则移除该属性;如果该属性已存在,则替换该属性;如果属性值实现了HttpSessionBindingListener,则它在替换时会触发其valueUnbound事件,属性设置时会触发valueBound事件;如果HttpSession中注册了HttpSessionAttributeListener,则会触发响应的attributeAdded、attributeReplaced事件。而removeAttribute时,也会触发相应的valueUnbound事件以及attributeRemoved事件。
     public Object getAttribute(String name);
     public Enumeration<String> getAttributeNames();    
     public  void setAttribute(String name, Object value);
     public  void removeAttribute(String name);

     // Session失效,它移除所有和其绑定的属性,并且将Session实例从SessionManager中移除。
     public  void invalidate();
    
     // true表识客户端没有使用session。此时客户端请求可能不需要使用session信息或者它使用cookie作为session的信息交互。
     public  boolean isNew();
}
在Jetty中使用SessionIdManager来创建管理SessionId信息,默认实现有HashSessionIdManager和JDBCSessionIdManager:
public  interface SessionIdManager  extends LifeCycle {
     public  boolean idInUse(String id);
     public  void addSession(HttpSession session);
     public  void removeSession(HttpSession session);
     public  void invalidateAll(String id);
     public String newSessionId(HttpServletRequest request, long created);
     public String getWorkerName();
     public String getClusterId(String nodeId);
     public String getNodeId(String clusterId,HttpServletRequest request);  
}
而HttpSession的创建和管理则使用SessionManager,默认有HashSessionManager和JDBCSessionManager两个实现:
public  interface SessionManager  extends LifeCycle {
     // 在AbstractSessionManager定义了Session内部类实现了HttpSession接口,使用SessionIdManager来生成并管理SessionId,可以注册HttpSessionAttributeListener和HttpSessionListener(在HttpSession创建和销毁时分别触发sessionCreated、sessionDestroyed事件)。另外它还实现了SessionCookieConfig内部类,用于使用Cookie配置Session的信息,如Name(默认JSESSIONID)、domain、path、comment、httpOnly、secure、maxAge等。在HashSessionManager和JDBCSessionManager中还各自有一个线程会检查Session的expire状态,并Invalidate已经expired的Session。最后,AbstractSessionManager还包含了Session相关的统计信息。

    // 在SessionManager中定义的一些属性,可以使用该方法定义的一些属性在ServletContext的initParam中设置,即web.xml文件中的init-param中设置。

     // 创建并添加HttpSession实例。
     public HttpSession newHttpSession(HttpServletRequest request);

     // 根据SessionId获取HttpSession实例。
     public HttpSession getHttpSession(String id);

     // 获取Cookie作为SessionTrackingMode时,该Cookie是否属于httpOnly(用来阻止某些cross-script攻击)
     public  boolean getHttpOnly();

     // Session的最大Idle时间,秒为单位
     public  int getMaxInactiveInterval();
     public  void setMaxInactiveInterval( int seconds);

     public  void setSessionHandler(SessionHandler handler);

     // 事件相关操作
     public  void addEventListener(EventListener listener);
     public  void removeEventListener(EventListener listener);
     public  void clearEventListeners();

     // 在使用Cookie作为SessionTrackingMode时,获取作为Session Tracking的Cookie
     public HttpCookie getSessionCookie(HttpSession session, String contextPath,  boolean requestIsSecure);

     public SessionIdManager getIdManager();
     public  void setIdManager(SessionIdManager idManager);
     public  boolean isValid(HttpSession session);
     public String getNodeId(HttpSession session);
     public String getClusterId(HttpSession session);

     // 更新Session的AccessTime。
     public HttpCookie access(HttpSession session,  boolean secure);

     public  void complete(HttpSession session);

     // 使用URL作为SessionTrackingMode时,在URL中作为SessionId的parameter name。
     public  void setSessionIdPathParameterName(String parameterName);
     public String getSessionIdPathParameterName();

     // 使用URL作为SessionTrackingMode时,在URL中SessionId信息的前缀,默认为:;<sessionIdParameterName>=
     public String getSessionIdPathParameterNamePrefix();

     public  boolean isUsingCookies();
     public  boolean isUsingURLs();
     public Set<SessionTrackingMode> getDefaultSessionTrackingModes();
     public Set<SessionTrackingMode> getEffectiveSessionTrackingModes();
     public  void setSessionTrackingModes(Set<SessionTrackingMode> sessionTrackingModes);
     public SessionCookieConfig getSessionCookieConfig();

     public  boolean isCheckingRemoteSessionIdEncoding();
     public  void setCheckingRemoteSessionIdEncoding( boolean remote);
}

SessionHandler

SessionHandler继承子ScopedHandler,它主要使用SessionManager在doScope方法中为当前Scope设置Session信息。
1. 如果使用Cookie作为SessionId的通信,则首先从Cookie中向Request设置RequestedSessionId。
2. 否则,从URL中计算出RequestedSessionId,并设置到Request中。
3. 如果SessionManager发生变化,则更新Request中SessionManager实例以及Session实例。
4. 如果Session发生变化,则更新Session的AccessTime,并将返回的cookie写入Response中。
5. 在退出时设置回Request原有的SessionManager和Session实例,如果需要的话。

相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore &nbsp; &nbsp; ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库&nbsp;ECS 实例和一台目标数据库&nbsp;RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&amp;RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
相关文章
|
2月前
|
缓存 Java Spring
servlet和SpringBoot两种方式分别获取Cookie和Session方式比较(带源码) —— 图文并茂 两种方式获取Header
文章比较了在Servlet和Spring Boot中获取Cookie、Session和Header的方法,并提供了相应的代码实例,展示了两种方式在实际应用中的异同。
183 3
servlet和SpringBoot两种方式分别获取Cookie和Session方式比较(带源码) —— 图文并茂 两种方式获取Header
|
7月前
|
Java 计算机视觉
java实现人脸识别源码【含测试效果图】——Servlet层(FaceServlet)
java实现人脸识别源码【含测试效果图】——Servlet层(FaceServlet)
|
7月前
|
Java 应用服务中间件 数据库连接
Spring5源码(51)-Servlet知识点回顾以及SpringMVC分析入口
Spring5源码(51)-Servlet知识点回顾以及SpringMVC分析入口
69 0
|
7月前
|
前端开发 Java BI
Servlet+Jsp+JDBC实现房屋租赁管理系统(源码+数据库+论文+系统详细配置指导+ppt)
Servlet+Jsp+JDBC实现房屋租赁管理系统(源码+数据库+论文+系统详细配置指导+ppt)
|
7月前
|
前端开发 JavaScript Java
servlet+jsp实现小区门户网站后台管理系统(源码+数据库+文档)
servlet+jsp实现小区门户网站后台管理系统(源码+数据库+文档)
|
7月前
|
前端开发 JavaScript Java
基于servlet实现的日记本管理系统(源码+数据库+文档)
基于servlet实现的日记本管理系统(源码+数据库+文档)
|
7月前
|
Java 关系型数据库 MySQL
基于jsp+servlet+mysql框架的旅游管理系统【源码+数据库+报告】
基于jsp+servlet+mysql框架的旅游管理系统【源码+数据库+报告】
203 0
|
7月前
|
前端开发 JavaScript Java
基于servlet+jsp+mysql实现的工资管理系统【源码+数据库】
基于servlet+jsp+mysql实现的工资管理系统【源码+数据库】
127 0
|
7月前
|
Java 应用服务中间件 Maven
框架的优点(SpringBoot VS Servlet)
框架的优点(SpringBoot VS Servlet)
|
4月前
|
缓存 安全 Java
Java服务器端技术:Servlet与JSP的集成与扩展
Java服务器端技术:Servlet与JSP的集成与扩展
39 3