JavaEE是Java Platform Enterprise Edition的缩写,即Java企业平台。
基于标准JDK的开发都是JavaSE,即Java Platform Standard Edition。
JavaME:Java Platform Micro Edition,是Java移动开发平台(非Android)。
三者关系如下:
image.png
JavaEE最核心的组件就是基于Servlet标准的Web服务器,开发者编写的应用程序是基于Servlet API并运行在Web服务器内部的:
image.png
JavaEE还有一系列技术标准:

  • EJB:Enterprise JavaBean,企业级JavaBean,早期经常用于实现应用程序的业务逻辑,现在基本被轻量级框架如Spring所取代;
  • JAAS:Java Authentication and Authorization Service,一个标准的认证和授权服务,常用于企业内部,Web程序通常使用更轻量级的自定义认证;
  • JCA:JavaEE Connector Architecture,用于连接企业内部的EIS系统等;
  • JMS:Java Message Service,用于消息服务;
  • JTA:Java Transaction API,用于分布式事务;
  • JAX-WS:Java API for XML Web Services,用于构建基于XML的Web服务;

目前流行的基于Spring的轻量级JavaEE开发架构,使用最广泛的是Servlet和JMS,以及一系列开源组件。

1.Web基础

访问网站,使用App时,都是基于Web这种Browser/Server模式,简称BS架构,它的特点是,客户端只需要浏览器,应用程序的逻辑和数据都存储在服务器端。浏览器只需要请求服务器,获取Web页面,并把Web页面展示给用户即可。
Web页面具有极强的交互性。由于Web页面是用HTML编写的,而HTML具备超强的表现力,并且,服务器端升级后,客户端无需任何部署就可以使用到新的版本,因此,BS架构升级非常容易。

1.HTTP协议

在Web应用中,浏览器请求一个URL,服务器就把生成的HTML网页发送给浏览器,而浏览器和服务器之间的传输协议是HTTP,所以:

  • HTML是一种用来定义网页的文本,会HTML,就可以编写网页;
  • HTTP是在网络上传输HTML的协议,用于浏览器和服务器的通信。

HTTP协议是一个基于TCP协议之上的请求-响应协议,它非常简单。
对于Browser(浏览器)来说,请求页面的流程如下:

  1. 与服务器建立TCP连接;
  2. 发送HTTP请求;
  3. 收取HTTP响应,然后把网页在浏览器中显示出来。

浏览器发送的HTTP请求如下:

  1. GET / HTTP/1.1
  2. Host: www.sina.com.cn
  3. User-Agent: Mozilla/5.0 xxx
  4. Accept: */*
  5. Accept-Language: zh-CN,zh;q=0.9,en-US;q=0.8

其中,第一行表示使用GET请求获取路径为/的资源,并使用HTTP/1.1协议,从第二行开始,每行都是以Header: Value形式表示的HTTP头,比较常用的HTTP Header包括:

  • Host: 表示请求的主机名,因为一个服务器上可能运行着多个网站,因此,Host表示浏览器正在请求的域名;
  • User-Agent: 标识客户端本身,例如Chrome浏览器的标识类似Mozilla/5.0 … Chrome/79,IE浏览器的标识类似Mozilla/5.0 (Windows NT …) like Gecko;
  • Accept:表示浏览器能接收的资源类型,如text/,image/或者/表示所有;
  • Accept-Language:表示浏览器偏好的语言,服务器可以据此返回不同语言的网页;
  • Accept-Encoding:表示浏览器可以支持的压缩类型,例如gzip, deflate, br。

服务器的响应如下:

  1. HTTP/1.1 200 OK
  2. Content-Type: text/html
  3. Content-Length: 21932
  4. Content-Encoding: gzip
  5. Cache-Control: max-age=300
  6. <html>...网页数据...

服务器响应的第一行总是版本号+空格+数字+空格+文本,数字表示响应代码,其中2xx表示成功,3xx表示重定向,4xx表示客户端引发的错误,5xx表示服务器端引发的错误。数字是给程序识别,文本则是给开发者调试使用的。常见的响应代码有:

  • 200 OK:表示成功;
  • 301 Moved Permanently:表示该URL已经永久重定向;
  • 302 Found:表示该URL需要临时重定向;
  • 304 Not Modified:表示该资源没有修改,客户端可以使用本地缓存的版本;
  • 400 Bad Request:表示客户端发送了一个错误的请求,例如参数无效;
  • 401 Unauthorized:表示客户端因为身份未验证而不允许访问该URL;
  • 403 Forbidden:表示服务器因为权限问题拒绝了客户端的请求;
  • 404 Not Found:表示客户端请求了一个不存在的资源;
  • 500 Internal Server Error:表示服务器处理时内部出错,例如因为无法连接数据库;
  • 503 Service Unavailable:表示服务器此刻暂时无法处理请求。

从第二行开始,服务器每一行均返回一个HTTP头。服务器经常返回的HTTP Header包括:

  • Content-Type:表示该响应内容的类型,例如text/html,image/jpeg;
  • Content-Length:表示该响应内容的长度(字节数);
  • Content-Encoding:表示该响应压缩算法,例如gzip;
  • Cache-Control:指示客户端应如何缓存,例如max-age=300表示可以最多缓存300秒。

HTTP请求和响应都由HTTP Header和HTTP Body构成,其中HTTP Header每行都以\r\n结束。如果遇到两个连续的\r\n,那么后面就是HTTP Body。浏览器读取HTTP Body,并根据Header信息中指示的Content-Type、Content-Encoding等解压后显示网页、图像或其他内容。
通常浏览器获取的第一个资源是HTML网页,在网页中,如果嵌入了JavaScript、CSS、图片、视频等其他资源,浏览器会根据资源的URL再次向服务器请求对应的资源。
关于HTTP协议的详细内容,请参考Mozilla开发者网站
以服务器的身份响应客户端请求,编写服务器程序来处理客户端请求通常就称之为Web开发。
使用B/S架构时,总是通过HTTP协议实现通信;
Web开发通常是指开发服务器端的Web应用程序。

2.Servlet入门

在JavaEE平台上,处理TCP连接,解析HTTP协议这些底层工作统统扔给现成的Web服务器去做,我们只需要把自己的应用程序跑在Web服务器上。为了实现这一目的,JavaEE提供了Servlet API,我们使用Servlet API编写自己的Servlet来处理HTTP请求,Web服务器实现Servlet API接口,实现底层功能:
image.png
实现一个最简单的Servlet:

  1. // WebServlet注解表示这是一个Servlet,并映射到地址/:
  2. @WebServlet(urlPatterns = "/")
  3. public class HelloServlet extends HttpServlet {
  4. protected void doGet(HttpServletRequest req, HttpServletResponse resp)
  5. throws ServletException, IOException {
  6. // 设置响应类型:
  7. resp.setContentType("text/html");
  8. // 获取输出流:
  9. PrintWriter pw = resp.getWriter();
  10. // 写入响应:
  11. pw.write("<h1>Hello, world!</h1>");
  12. // 最后不要忘记flush强制输出:
  13. pw.flush();
  14. }
  15. }

一个Servlet总是继承自HttpServlet,然后覆写doGet()或doPost()方法。注意到doGet()方法传入了HttpServletRequest和HttpServletResponse两个对象,分别代表HTTP请求和响应。我们使用Servlet API时,并不直接与底层TCP交互,也不需要解析HTTP协议,因为HttpServletRequest和HttpServletResponse就已经封装好了请求和响应。以发送响应为例,我们只需要设置正确的响应类型,然后获取PrintWriter,写入响应即可。
现在问题来了:Servlet API是谁提供?
Servlet API是一个jar包,我们需要通过Maven来引入它,才能正常编译。编写pom.xml文件如下:

  1. <project xmlns="http://maven.apache.org/POM/4.0.0"
  2. xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  3. xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
  4. <modelVersion>4.0.0</modelVersion>
  5. <groupId>com.itranswarp.learnjava</groupId>
  6. <artifactId>web-servlet-hello</artifactId>
  7. <packaging>war</packaging>
  8. <version>1.0-SNAPSHOT</version>
  9. <properties>
  10. <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  11. <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
  12. <maven.compiler.source>11</maven.compiler.source>
  13. <maven.compiler.target>11</maven.compiler.target>
  14. <java.version>11</java.version>
  15. </properties>
  16. <dependencies>
  17. <dependency>
  18. <groupId>javax.servlet</groupId>
  19. <artifactId>javax.servlet-api</artifactId>
  20. <version>4.0.0</version>
  21. <scope>provided</scope>
  22. </dependency>
  23. </dependencies>
  24. <build>
  25. <finalName>hello</finalName>
  26. </build>
  27. </project>

注意到这个pom.xml与前面我们讲到的普通Java程序有个区别,打包类型不是jar,而是war,表示Java Web Application Archive:

  1. <packaging>war</packaging>

引入的Servlet API如下:

  1. <dependency>
  2. <groupId>javax.servlet</groupId>
  3. <artifactId>javax.servlet-api</artifactId>
  4. <version>4.0.0</version>
  5. <scope>provided</scope>
  6. </dependency>

注意到指定为provided,表示编译时使用,但不会打包到.war文件中,因为运行期Web服务器本身已经提供了Servlet API相关的jar包。
我们还需要在工程目录下创建一个web.xml描述文件,放到src/main/webapp/WEB-INF目录下(固定目录结构,不要修改路径,注意大小写)。文件内容可以固定如下:

  1. <!DOCTYPE web-app PUBLIC
  2. "-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN"
  3. "http://java.sun.com/dtd/web-app_2_3.dtd">
  4. <web-app>
  5. <display-name>Archetype Created Web Application</display-name>
  6. </web-app>

整个工程结构如下:
image.png
运行Maven命令mvn clean package,在target目录下得到一个hello.war文件,这个文件就是我们编译打包后的Web应用程序。
现在问题又来了:我们应该如何运行这个war文件?
普通的Java程序是通过启动JVM,然后执行main()方法开始运行。但是Web应用程序有所不同,我们无法直接运行war文件,必须先启动Web服务器,再由Web服务器加载我们编写的HelloServlet,这样就可以让HelloServlet处理浏览器发送的请求。
因此,我们首先要找一个支持Servlet API的Web服务器。常用的服务器有:

  • Tomcat:由Apache开发的开源免费服务器;
  • Jetty:由Eclipse开发的开源免费服务器;
  • GlassFish:一个开源的全功能JavaEE服务器。

还有一些收费的商用服务器,如Oracle的WebLogic,IBM的WebSphere
无论使用哪个服务器,只要它支持Servlet API 4.0(因为我们引入的Servlet版本是4.0),我们的war包都可以在上面运行。这里我们选择使用最广泛的开源免费的Tomcat服务器。
要运行我们的hello.war,首先要下载Tomcat服务器,解压后,把hello.war复制到Tomcat的webapps目录下,然后切换到bin目录,执行startup.sh或startup.bat启动Tomcat服务器。
在浏览器输入http://localhost:8080/hello/即可看到HelloServlet的输出。
为啥路径是/hello/而不是/?因为一个Web服务器允许同时运行多个Web App,而我们的Web App叫hello,因此,第一级目录/hello表示Web App的名字,后面的/才是我们在HelloServlet中映射的路径。
那能不能直接使用/而不是/hello/?毕竟/比较简洁。
答案是肯定的。先关闭Tomcat(执行shutdown.sh或shutdown.bat),然后删除Tomcat的webapps目录下的所有文件夹和文件,最后把我们的hello.war复制过来,改名为ROOT.war,文件名为ROOT的应用程序将作为默认应用,启动后直接访问http://localhost:8080/即可。
实际上,类似Tomcat这样的服务器也是Java编写的,启动Tomcat服务器实际上是启动Java虚拟机,执行Tomcat的main()方法,然后由Tomcat负责加载我们的.war文件,并创建一个HelloServlet实例,最后以多线程的模式来处理HTTP请求。如果Tomcat服务器收到的请求路径是/(假定部署文件为ROOT.war),就转发到HelloServlet并传入HttpServletRequest和HttpServletResponse两个对象。
因为我们编写的Servlet并不是直接运行,而是由Web服务器加载后创建实例运行,所以,类似Tomcat这样的Web服务器也称为Servlet容器。
在Servlet容器中运行的Servlet具有如下特点:

  • 无法在代码中直接通过new创建Servlet实例,必须由Servlet容器自动创建Servlet实例;
  • Servlet容器只会给每个Servlet类创建唯一实例;
  • Servlet容器会使用多线程执行doGet()或doPost()方法。

复习一下Java多线程的内容,我们可以得出结论:

  • 在Servlet中定义的实例变量会被多个线程同时访问,要注意线程安全;
  • HttpServletRequest和HttpServletResponse实例是由Servlet容器传入的局部变量,它们只能被当前线程访问,不存在多个线程访问的问题;
  • 在doGet()或doPost()方法中,如果使用了ThreadLocal,但没有清理,那么它的状态很可能会影响到下次的某个请求,因为Servlet容器很可能用线程池实现线程复用。

因此,正确编写Servlet,要清晰理解Java的多线程模型,需要同步访问的必须同步。
编写Web应用程序就是编写Servlet处理HTTP请求;
Servlet API提供了HttpServletRequest和HttpServletResponse两个高级接口来封装HTTP请求和响应;
Web应用程序必须按固定结构组织并打包为.war文件;
需要启动Web服务器来加载我们的war包来运行Servlet。

3.Servlet开发

一个完整的Web应用程序的开发流程如下:

  1. 编写Servlet;
  2. 打包为war文件;
  3. 复制到Tomcat的webapps目录下;
  4. 启动Tomcat。

Tomcat实际上也是一个Java程序,我们看看Tomcat的启动流程:

  1. 启动JVM并执行Tomcat的main()方法;
  2. 加载war并初始化Servlet;
  3. 正常服务。

启动Tomcat无非就是设置好classpath并执行Tomcat某个jar包的main()方法,我们完全可以把Tomcat的jar包全部引入进来,然后自己编写一个main()方法,先启动Tomcat,然后让它加载我们的webapp就行。
我们新建一个web-servlet-embedded工程,编写pom.xml如下:

  1. <project xmlns="http://maven.apache.org/POM/4.0.0"
  2. xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  3. xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  4. <modelVersion>4.0.0</modelVersion>
  5. <groupId>com.itranswarp.learnjava</groupId>
  6. <artifactId>web-servlet-embedded</artifactId>
  7. <version>1.0-SNAPSHOT</version>
  8. <packaging>war</packaging>
  9. <properties>
  10. <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  11. <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
  12. <maven.compiler.source>11</maven.compiler.source>
  13. <maven.compiler.target>11</maven.compiler.target>
  14. <java.version>11</java.version>
  15. <tomcat.version>9.0.26</tomcat.version>
  16. </properties>
  17. <dependencies>
  18. <dependency>
  19. <groupId>org.apache.tomcat.embed</groupId>
  20. <artifactId>tomcat-embed-core</artifactId>
  21. <version>${tomcat.version}</version>
  22. <scope>provided</scope>
  23. </dependency>
  24. <dependency>
  25. <groupId>org.apache.tomcat.embed</groupId>
  26. <artifactId>tomcat-embed-jasper</artifactId>
  27. <version>${tomcat.version}</version>
  28. <scope>provided</scope>
  29. </dependency>
  30. </dependencies>
  31. </project>

其中,类型仍然为war,引入依赖tomcat-embed-core和tomcat-embed-jasper,引入的Tomcat版本为9.0.26。
不必引入Servlet API,因为引入Tomcat依赖后自动引入了Servlet API。因此,我们可以正常编写Servlet如下:

  1. @WebServlet(urlPatterns = "/")
  2. public class HelloServlet extends HttpServlet {
  3. protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
  4. resp.setContentType("text/html");
  5. String name = req.getParameter("name");
  6. if (name == null) {
  7. name = "world";
  8. }
  9. PrintWriter pw = resp.getWriter();
  10. pw.write("<h1>Hello, " + name + "!</h1>");
  11. pw.flush();
  12. }
  13. }

然后,我们编写一个main()方法,启动Tomcat服务器:

  1. public class Main {
  2. public static void main(String[] args) throws Exception {
  3. // 启动Tomcat:
  4. Tomcat tomcat = new Tomcat();
  5. tomcat.setPort(Integer.getInteger("port", 8080));
  6. tomcat.getConnector();
  7. // 创建webapp:
  8. Context ctx = tomcat.addWebapp("", new File("src/main/webapp").getAbsolutePath());
  9. WebResourceRoot resources = new StandardRoot(ctx);
  10. resources.addPreResources(
  11. new DirResourceSet(resources, "/WEB-INF/classes", new File("target/classes").getAbsolutePath(), "/"));
  12. ctx.setResources(resources);
  13. tomcat.start();
  14. tomcat.getServer().await();
  15. }
  16. }

这样,我们直接运行main()方法,即可启动嵌入式Tomcat服务器,然后,通过预设的tomcat.addWebapp(“”, new File(“src/main/webapp”),Tomcat会自动加载当前工程作为根webapp,可直接在浏览器访问http://localhost:8080/。
通过main()方法启动Tomcat服务器并加载我们自己的webapp有如下好处:

  1. 启动简单,无需下载Tomcat或安装任何IDE插件;
  2. 调试方便,可在IDE中使用断点调试;
  3. 使用Maven创建war包后,也可以正常部署到独立的Tomcat服务器中。

SpringBoot也支持在main()方法中一行代码直接启动Tomcat,并且还能方便地更换成Jetty等其他服务器。它的启动方式和我们介绍的是基本一样的。
注意:引入的Tomcat的scope为provided,在Idea下运行时,需要设置Run/Debug Configurations,选择Application - Main,钩上Include dependencies with “Provided” scope,这样才能让Idea在运行时把Tomcat相关依赖包自动添加到classpath中。
开发Servlet时,推荐使用main()方法启动嵌入式Tomcat服务器并加载当前工程的webapp,便于开发调试,且不影响打包部署,能极大地提升开发效率。

4.Servlet进阶

一个Web App就是由一个或多个Servlet组成的,每个Servlet通过注解说明自己能处理的路径。例如:

  1. @WebServlet(urlPatterns = "/hello")
  2. public class HelloServlet extends HttpServlet {
  3. ...
  4. }

上述HelloServlet能处理/hello这个路径的请求。
早期的Servlet需要在web.xml中配置映射路径,但最新Servlet版本只需要通过注解就可以完成映射。
因为浏览器发送请求的时候,还会有请求方法(HTTP Method):即GET、POST、PUT等不同类型的请求。因此,要处理GET请求,我们要覆写doGet()方法:

  1. @WebServlet(urlPatterns = "/hello")
  2. public class HelloServlet extends HttpServlet {
  3. @Override
  4. protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
  5. ...
  6. }
  7. }

类似的,要处理POST请求,就需要覆写doPost()方法。
如果没有覆写doPost()方法,那么HelloServlet能不能处理POST /hello请求呢?
我们查看一下HttpServlet的doPost()方法就一目了然了:它会直接返回405或400错误。因此,一个Servlet如果映射到/hello,那么所有请求方法都会由这个Servlet处理,至于能不能返回200成功响应,要看有没有覆写对应的请求方法。
一个Webapp完全可以有多个Servlet,分别映射不同的路径。例如:

  1. @WebServlet(urlPatterns = "/hello")
  2. public class HelloServlet extends HttpServlet {
  3. ...
  4. }
  5. @WebServlet(urlPatterns = "/signin")
  6. public class SignInServlet extends HttpServlet {
  7. ...
  8. }
  9. @WebServlet(urlPatterns = "/")
  10. public class IndexServlet extends HttpServlet {
  11. ...
  12. }

浏览器发出的HTTP请求总是由Web Server先接收,然后,根据Servlet配置的映射,不同的路径转发到不同的Servlet:
image.png
这种根据路径转发的功能我们一般称为Dispatch。映射到/的IndexServlet比较特殊,它实际上会接收所有未匹配的路径,相当于/*,因为Dispatcher的逻辑可以用伪代码实现如下:

  1. String path = ...
  2. if (path.equals("/hello")) {
  3. dispatchTo(helloServlet);
  4. } else if (path.equals("/signin")) {
  5. dispatchTo(signinServlet);
  6. } else {
  7. // 所有未匹配的路径均转发到"/"
  8. dispatchTo(indexServlet);
  9. }

所以我们在浏览器输入一个http://localhost:8080/abc也会看到IndexServlet生成的页面。

1.HttpServletRequest

HttpServletRequest封装了一个HTTP请求,它实际上是从ServletRequest继承而来。最早设计Servlet时,设计者希望Servlet不仅能处理HTTP,也能处理类似SMTP等其他协议,因此,单独抽出了ServletRequest接口,但实际上除了HTTP外,并没有其他协议会用Servlet处理,所以这是一个过度设计。
我们通过HttpServletRequest提供的接口方法可以拿到HTTP请求的几乎全部信息,常用的方法有:

  • getMethod():返回请求方法,例如,”GET”,”POST”;
  • getRequestURI():返回请求路径,但不包括请求参数,例如,”/hello”;
  • getQueryString():返回请求参数,例如,”name=Bob&a=1&b=2”;
  • getParameter(name):返回请求参数,GET请求从URL读取参数,POST请求从Body中读取参数;
  • getContentType():获取请求Body的类型,例如,”application/x-www-form-urlencoded”;
  • getContextPath():获取当前Webapp挂载的路径,对于ROOT来说,总是返回空字符串””;
  • getCookies():返回请求携带的所有Cookie;
  • getHeader(name):获取指定的Header,对Header名称不区分大小写;
  • getHeaderNames():返回所有Header名称;
  • getInputStream():如果该请求带有HTTP Body,该方法将打开一个输入流用于读取Body;
  • getReader():和getInputStream()类似,但打开的是Reader;
  • getRemoteAddr():返回客户端的IP地址;
  • getScheme():返回协议类型,例如,”http”,”https”;

此外,HttpServletRequest还有两个方法:setAttribute()和getAttribute(),可以给当前HttpServletRequest对象附加多个Key-Value,相当于把HttpServletRequest当作一个Map使用。
调用HttpServletRequest的方法时,注意务必阅读接口方法的文档说明,因为有的方法会返回null,例如getQueryString()的文档就写了:

  1. ... This method returns null if the URL does not have a query string...

2.HttpServletResponse

HttpServletResponse封装了一个HTTP响应。由于HTTP响应必须先发送Header,再发送Body,所以,操作HttpServletResponse对象时,必须先调用设置Header的方法,最后调用发送Body的方法。
常用的设置Header的方法有:

  • setStatus(sc):设置响应代码,默认是200;
  • setContentType(type):设置Body的类型,例如,”text/html”;
  • setCharacterEncoding(charset):设置字符编码,例如,”UTF-8”;
  • setHeader(name, value):设置一个Header的值;
  • addCookie(cookie):给响应添加一个Cookie;
  • addHeader(name, value):给响应添加一个Header,因为HTTP协议允许有多个相同的Header;

写入响应时,需要通过getOutputStream()获取写入流,或者通过getWriter()获取字符流,二者只能获取其中一个。
写入响应前,无需设置setContentLength(),因为底层服务器会根据写入的字节数自动设置,如果写入的数据量很小,实际上会先写入缓冲区,如果写入的数据量很大,服务器会自动采用Chunked编码让浏览器能识别数据结束符而不需要设置Content-Length头。
但是,写入完毕后调用flush()却是必须的,因为大部分Web服务器都基于HTTP/1.1协议,会复用TCP连接。如果没有调用flush(),将导致缓冲区的内容无法及时发送到客户端。此外,写入完毕后千万不要调用close(),原因同样是因为会复用TCP连接,如果关闭写入流,将关闭TCP连接,使得Web服务器无法复用此TCP连接。
写入完毕后对输出流调用flush()而不是close()方法!
有了HttpServletRequest和HttpServletResponse这两个高级接口,我们就不需要直接处理HTTP协议。注意到具体的实现类是由各服务器提供的,而我们编写的Web应用程序只关心接口方法,并不需要关心具体实现的子类。

3.Servlet多线程模型

一个Servlet类在服务器中只有一个实例,但对于每个HTTP请求,Web服务器会使用多线程执行请求。因此,一个Servlet的doGet()、doPost()等处理请求的方法是多线程并发执行的。如果Servlet中定义了字段,要注意多线程并发访问的问题:

  1. public class HelloServlet extends HttpServlet {
  2. private Map<String, String> map = new ConcurrentHashMap<>();
  3. protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
  4. // 注意读写map字段是多线程并发的:
  5. this.map.put(key, value);
  6. }
  7. }

对于每个请求,Web服务器会创建唯一的HttpServletRequest和HttpServletResponse实例,因此,HttpServletRequest和HttpServletResponse实例只有在当前处理线程中有效,它们总是局部变量,不存在多线程共享的问题。
一个Webapp中的多个Servlet依靠路径映射来处理不同的请求;
映射为/的Servlet可处理所有“未匹配”的请求;
如何处理请求取决于Servlet覆写的对应方法;
Web服务器通过多线程处理HTTP请求,一个Servlet的处理方法可以由多线程并发执行。

4.重定向与转发

1.Redirect

重定向是指当浏览器请求一个URL时,服务器返回一个重定向指令,告诉浏览器地址已经变了,麻烦使用新的URL再重新发送新请求。
例如,我们已经编写了一个能处理/hello的HelloServlet,如果收到的路径为/hi,希望能重定向到/hello,可以再编写一个RedirectServlet:

  1. @WebServlet(urlPatterns = "/hi")
  2. public class RedirectServlet extends HttpServlet {
  3. protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
  4. // 构造重定向的路径:
  5. String name = req.getParameter("name");
  6. String redirectToUrl = "/hello" + (name == null ? "" : "?name=" + name);
  7. // 发送重定向响应:
  8. resp.sendRedirect(redirectToUrl);
  9. }
  10. }

如果浏览器发送GET /hi请求,RedirectServlet将处理此请求。由于RedirectServlet在内部又发送了重定向响应,因此,浏览器会收到如下响应:

  1. HTTP/1.1 302 Found
  2. Location: /hello

当浏览器收到302响应后,它会立刻根据Location的指示发送一个新的GET /hello请求,这个过程就是重定向:
image.png
所以重定向有两次HTTP请求。
并且浏览器的地址栏路径自动更新为/hello。
重定向有两种:一种是302响应,称为临时重定向,一种是301响应,称为永久重定向。两者的区别是,如果服务器发送301永久重定向响应,浏览器会缓存/hi到/hello这个重定向的关联,下次请求/hi的时候,浏览器就直接发送/hello请求了。
重定向有什么作用?重定向的目的是当Web应用升级后,如果请求路径发生了变化,可以将原来的路径重定向到新路径,从而避免浏览器请求原路径找不到资源。
HttpServletResponse提供了快捷的redirect()方法实现302重定向。如果要实现301永久重定向,可以这么写:

  1. resp.setStatus(HttpServletResponse.SC_MOVED_PERMANENTLY); // 301
  2. resp.setHeader("Location", "/hello");

2.Forward

Forward是指内部转发。当一个Servlet处理请求的时候,它可以决定自己不继续处理,而是转发给另一个Servlet处理。
例如,我们已经编写了一个能处理/hello的HelloServlet,继续编写一个能处理/morning的ForwardServlet:

  1. @WebServlet(urlPatterns = "/morning")
  2. public class ForwardServlet extends HttpServlet {
  3. protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
  4. req.getRequestDispatcher("/hello").forward(req, resp);
  5. }
  6. }

ForwardServlet在收到请求后,它并不自己发送响应,而是把请求和响应都转发给路径为/hello的Servlet,即下面的代码:

  1. req.getRequestDispatcher("/hello").forward(req, resp);

后续请求的处理实际上是由HelloServlet完成的。这种处理方式称为转发(Forward),我们用流程图画出来如下:
image.png
转发和重定向的区别在于,转发是在Web服务器内部完成的,对浏览器来说,它只发出了一个HTTP请求。
注意到使用转发的时候,浏览器的地址栏路径仍然是/morning,浏览器并不知道该请求在Web服务器内部实际上做了一次转发。
使用重定向时,浏览器知道重定向规则,并且会自动发起新的HTTP请求;
使用转发时,浏览器并不知道服务器内部的转发逻辑。

5.使用Session和Cookie

在Web应用程序中,我们经常要跟踪用户身份。当一个用户登录成功后,如果他继续访问其他页面,Web程序如何才能识别出该用户身份?
因为HTTP协议是一个无状态协议,即Web应用程序无法区分收到的两个HTTP请求是否是同一个浏览器发出的。为了跟踪用户状态,服务器可以向浏览器分配一个唯一ID,并以Cookie的形式发送到浏览器,浏览器在后续访问时总是附带此Cookie,这样,服务器就可以识别用户身份。

1.Session

我们把这种基于唯一ID识别用户身份的机制称为Session。每个用户第一次访问服务器后,会自动获得一个Session ID。如果用户在一段时间内没有访问服务器,那么Session会自动失效,下次即使带着上次分配的Session ID访问,服务器也认为这是一个新用户,会分配新的Session ID。
JavaEE的Servlet机制内建了对Session的支持。我们以登录为例,当一个用户登录成功后,我们就可以把这个用户的名字放入一个HttpSession对象,以便后续访问其他页面的时候,能直接从HttpSession取出用户名:

  1. @WebServlet(urlPatterns = "/signin")
  2. public class SignInServlet extends HttpServlet {
  3. // 模拟一个数据库:
  4. private Map<String, String> users = Map.of("bob", "bob123", "alice", "alice123", "tom", "tomcat");
  5. // GET请求时显示登录页:
  6. protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
  7. resp.setContentType("text/html");
  8. PrintWriter pw = resp.getWriter();
  9. pw.write("<h1>Sign In</h1>");
  10. pw.write("<form action=\"/signin\" method=\"post\">");
  11. pw.write("<p>Username: <input name=\"username\"></p>");
  12. pw.write("<p>Password: <input name=\"password\" type=\"password\"></p>");
  13. pw.write("<p><button type=\"submit\">Sign In</button> <a href=\"/\">Cancel</a></p>");
  14. pw.write("</form>");
  15. pw.flush();
  16. }
  17. // POST请求时处理用户登录:
  18. protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
  19. String name = req.getParameter("username");
  20. String password = req.getParameter("password");
  21. String expectedPassword = users.get(name.toLowerCase());
  22. if (expectedPassword != null && expectedPassword.equals(password)) {
  23. // 登录成功:
  24. req.getSession().setAttribute("user", name);
  25. resp.sendRedirect("/");
  26. } else {
  27. resp.sendError(HttpServletResponse.SC_FORBIDDEN);
  28. }
  29. }
  30. }

上述SignInServlet在判断用户登录成功后,立刻将用户名放入当前HttpSession中:

  1. HttpSession session = req.getSession();
  2. session.setAttribute("user", name);

在IndexServlet中,可以从HttpSession取出用户名:

  1. @WebServlet(urlPatterns = "/")
  2. public class IndexServlet extends HttpServlet {
  3. protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
  4. // 从HttpSession获取当前用户名:
  5. String user = (String) req.getSession().getAttribute("user");
  6. resp.setContentType("text/html");
  7. resp.setCharacterEncoding("UTF-8");
  8. resp.setHeader("X-Powered-By", "JavaEE Servlet");
  9. PrintWriter pw = resp.getWriter();
  10. pw.write("<h1>Welcome, " + (user != null ? user : "Guest") + "</h1>");
  11. if (user == null) {
  12. // 未登录,显示登录链接:
  13. pw.write("<p><a href=\"/signin\">Sign In</a></p>");
  14. } else {
  15. // 已登录,显示登出链接:
  16. pw.write("<p><a href=\"/signout\">Sign Out</a></p>");
  17. }
  18. pw.flush();
  19. }
  20. }

如果用户已登录,可以通过访问/signout登出。登出逻辑就是从HttpSession中移除用户相关信息:

  1. @WebServlet(urlPatterns = "/signout")
  2. public class SignOutServlet extends HttpServlet {
  3. protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
  4. // 从HttpSession移除用户名:
  5. req.getSession().removeAttribute("user");
  6. resp.sendRedirect("/");
  7. }
  8. }

对于Web应用程序来说,我们总是通过HttpSession这个高级接口访问当前Session。如果要深入理解Session原理,可以认为Web服务器在内存中自动维护了一个ID到HttpSession的映射表,我们可以用下图表示:
image.png
而服务器识别Session的关键就是依靠一个名为JSESSIONID的Cookie。在Servlet中第一次调用req.getSession()时,Servlet容器自动创建一个Session ID,然后通过一个名为JSESSIONID的Cookie发送给浏览器:
image.png
这里要注意的几点是:

  • JSESSIONID是由Servlet容器自动创建的,目的是维护一个浏览器会话,它和我们的登录逻辑没有关系;
  • 登录和登出的业务逻辑是我们自己根据HttpSession是否存在一个”user”的Key判断的,登出后,Session ID并不会改变;
  • 即使没有登录功能,仍然可以使用HttpSession追踪用户,例如,放入一些用户配置信息等。

除了使用Cookie机制可以实现Session外,还可以通过隐藏表单、URL末尾附加ID来追踪Session。这些机制很少使用,最常用的Session机制仍然是Cookie。
使用Session时,由于服务器把所有用户的Session都存储在内存中,如果遇到内存不足的情况,就需要把部分不活动的Session序列化到磁盘上,这会大大降低服务器的运行效率,因此,放入Session的对象要小,通常我们放入一个简单的User对象就足够了:

  1. public class User {
  2. public long id; // 唯一标识
  3. public String email;
  4. public String name;
  5. }

在使用多台服务器构成集群时,使用Session会遇到一些额外的问题。通常,多台服务器集群使用反向代理作为网站入口:
image.png
如果多台Web Server采用无状态集群,那么反向代理总是以轮询方式将请求依次转发给每台Web Server,这会造成一个用户在Web Server 1存储的Session信息,在Web Server 2和3上并不存在,即从Web Server 1登录后,如果后续请求被转发到Web Server 2或3,那么用户看到的仍然是未登录状态。
要解决这个问题,方案一是在所有Web Server之间进行Session复制,但这样会严重消耗网络带宽,并且,每个Web Server的内存均存储所有用户的Session,内存使用率很低。
另一个方案是采用粘滞会话(Sticky Session)机制,即反向代理在转发请求的时候,总是根据JSESSIONID的值判断,相同的JSESSIONID总是转发到固定的Web Server,但这需要反向代理的支持。
无论采用何种方案,使用Session机制,会使得Web Server的集群很难扩展,因此,Session适用于中小型Web应用程序。对于大型Web应用程序来说,通常需要避免使用Session机制。

2.Cookie

实际上,Servlet提供的HttpSession本质上就是通过一个名为JSESSIONID的Cookie来跟踪用户会话的。除了这个名称外,其他名称的Cookie我们可以任意使用。
如果我们想要设置一个Cookie,例如,记录用户选择的语言,可以编写一个LanguageServlet:

  1. @WebServlet(urlPatterns = "/pref")
  2. public class LanguageServlet extends HttpServlet {
  3. private static final Set<String> LANGUAGES = Set.of("en", "zh");
  4. protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
  5. String lang = req.getParameter("lang");
  6. if (LANGUAGES.contains(lang)) {
  7. // 创建一个新的Cookie:
  8. Cookie cookie = new Cookie("lang", lang);
  9. // 该Cookie生效的路径范围:
  10. cookie.setPath("/");
  11. // 该Cookie有效期:
  12. cookie.setMaxAge(8640000); // 8640000秒=100天
  13. // 将该Cookie添加到响应:
  14. resp.addCookie(cookie);
  15. }
  16. resp.sendRedirect("/");
  17. }
  18. }

创建一个新Cookie时,除了指定名称和值以外,通常需要设置setPath(“/“),浏览器根据此前缀决定是否发送Cookie。如果一个Cookie调用了setPath(“/user/“),那么浏览器只有在请求以/user/开头的路径时才会附加此Cookie。通过setMaxAge()设置Cookie的有效期,单位为秒,最后通过resp.addCookie()把它添加到响应。
如果访问的是https网页,还需要调用setSecure(true),否则浏览器不会发送该Cookie。
因此,务必注意:浏览器在请求某个URL时,是否携带指定的Cookie,取决于Cookie是否满足以下所有要求:

  • URL前缀是设置Cookie时的Path;
  • Cookie在有效期内;
  • Cookie设置了secure时必须以https访问。

我们可以在浏览器看到服务器发送的Cookie:
image.png
如果我们要读取Cookie,例如,在IndexServlet中,读取名为lang的Cookie以获取用户设置的语言,可以写一个方法如下:

  1. private String parseLanguageFromCookie(HttpServletRequest req) {
  2. // 获取请求附带的所有Cookie:
  3. Cookie[] cookies = req.getCookies();
  4. // 如果获取到Cookie:
  5. if (cookies != null) {
  6. // 循环每个Cookie:
  7. for (Cookie cookie : cookies) {
  8. // 如果Cookie名称为lang:
  9. if (cookie.getName().equals("lang")) {
  10. // 返回Cookie的值:
  11. return cookie.getValue();
  12. }
  13. }
  14. }
  15. // 返回默认值:
  16. return "en";
  17. }

可见,读取Cookie主要依靠遍历HttpServletRequest附带的所有Cookie。
Servlet容器提供了Session机制以跟踪用户;
默认的Session机制是以Cookie形式实现的,Cookie名称为JSESSIONID;
通过读写Cookie可以在客户端设置用户偏好等。

5.JSP开发

JSP是Java Server Pages的缩写,它的文件必须放到/src/main/webapp下,文件名必须以.jsp结尾,整个文件与HTML并无太大区别,但需要插入变量,或者动态输出的地方,使用特殊指令<% … %>。
我们来编写一个hello.jsp,内容如下:

  1. <html>
  2. <head>
  3. <title>Hello World - JSP</title>
  4. </head>
  5. <body>
  6. <%-- JSP Comment --%>
  7. <h1>Hello World!</h1>
  8. <p>
  9. <%
  10. out.println("Your IP address is ");
  11. %>
  12. <span style="color:red">
  13. <%= request.getRemoteAddr() %>
  14. </span>
  15. </p>
  16. </body>
  17. </html>

整个JSP的内容实际上是一个HTML,但是稍有不同:

  • 包含在<%—和—%>之间的是JSP的注释,它们会被完全忽略;
  • 包含在<%和%>之间的是Java代码,可以编写任意Java代码;
  • 如果使用<%= xxx %>则可以快捷输出一个变量的值。

JSP页面内置了几个变量:

  • out:表示HttpServletResponse的PrintWriter;
  • session:表示当前HttpSession对象;
  • request:表示HttpServletRequest对象。

这几个变量可以直接使用。
访问JSP页面时,直接指定完整路径。例如,http://localhost:8080/hello.jsp。
JSP和Servlet有什么区别?其实它们没有任何区别,因为JSP在执行前首先被编译成一个Servlet。在Tomcat的临时目录下,可以找到一个hello_jsp.java的源文件,这个文件就是Tomcat把JSP自动转换成的Servlet源码:

  1. package org.apache.jsp;
  2. import ...
  3. public final class hello_jsp extends org.apache.jasper.runtime.HttpJspBase
  4. implements org.apache.jasper.runtime.JspSourceDependent,
  5. org.apache.jasper.runtime.JspSourceImports {
  6. ...
  7. public void _jspService(final javax.servlet.http.HttpServletRequest request, final javax.servlet.http.HttpServletResponse response)
  8. throws java.io.IOException, javax.servlet.ServletException {
  9. ...
  10. out.write("<html>\n");
  11. out.write("<head>\n");
  12. out.write(" <title>Hello World - JSP</title>\n");
  13. out.write("</head>\n");
  14. out.write("<body>\n");
  15. ...
  16. }
  17. ...
  18. }

可见JSP本质上就是一个Servlet,只不过无需配置映射路径,Web Server会根据路径查找对应的.jsp文件,如果找到了,就自动编译成Servlet再执行。在服务器运行过程中,如果修改了JSP的内容,那么服务器会自动重新编译。

1.JSP高级功能

JSP的指令非常复杂,除了<% … %>外,JSP页面本身可以通过page指令引入Java类:

  1. <%@ page import="java.io.*" %>
  2. <%@ page import="java.util.*" %>

这样后续的Java代码才能引用简单类名而不是完整类名。
使用include指令可以引入另一个JSP文件:

  1. <html>
  2. <body>
  3. <%@ include file="header.jsp"%>
  4. <h1>Index Page</h1>
  5. <%@ include file="footer.jsp"%>
  6. </body>

2.JSP Tag

JSP还允许自定义输出的tag,例如:

  1. <c:out value = "${sessionScope.user.name}"/>

JSP Tag需要正确引入taglib的jar包,并且还需要正确声明,使用起来非常复杂,对于页面开发来说,不推荐使用JSP Tag,因为我们后续会介绍更简单的模板引擎,这里我们不再介绍如何使用taglib。
JSP是一种在HTML中嵌入动态输出的文件,它和Servlet正好相反,Servlet是在Java代码中嵌入输出HTML;
JSP可以引入并使用JSP Tag,但由于其语法复杂,不推荐使用;
JSP本身目前已经很少使用,我们只需要了解其基本用法即可。

6.MVC开发

  • Servlet适合编写Java代码,实现各种复杂的业务逻辑,但不适合输出复杂的HTML;
  • JSP适合编写HTML,并在其中插入动态内容,但不适合编写复杂的Java代码。

将两者结合起来,发挥各自的优点。
编写了几个JavaBean:

  1. public class User {
  2. public long id;
  3. public String name;
  4. public School school;
  5. }
  6. public class School {
  7. public String name;
  8. public String address;
  9. }

在UserServlet中,我们可以从数据库读取User、School等信息,然后,把读取到的JavaBean先放到HttpServletRequest中,再通过forward()传给user.jsp处理:

  1. @WebServlet(urlPatterns = "/user")
  2. public class UserServlet extends HttpServlet {
  3. protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
  4. // 假装从数据库读取:
  5. School school = new School("No.1 Middle School", "101 South Street");
  6. User user = new User(123, "Bob", school);
  7. // 放入Request中:
  8. req.setAttribute("user", user);
  9. // forward给user.jsp:
  10. req.getRequestDispatcher("/WEB-INF/user.jsp").forward(req, resp);
  11. }
  12. }

在user.jsp中,我们只负责展示相关JavaBean的信息,不需要编写访问数据库等复杂逻辑:

  1. <%@ page import="com.itranswarp.learnjava.bean.*"%>
  2. <%
  3. User user = (User) request.getAttribute("user");
  4. %>
  5. <html>
  6. <head>
  7. <title>Hello World - JSP</title>
  8. </head>
  9. <body>
  10. <h1>Hello <%= user.name %>!</h1>
  11. <p>School Name:
  12. <span style="color:red">
  13. <%= user.school.name %>
  14. </span>
  15. </p>
  16. <p>School Address:
  17. <span style="color:red">
  18. <%= user.school.address %>
  19. </span>
  20. </p>
  21. </body>
  22. </html>

请注意几点:

  • 需要展示的User被放入HttpServletRequest中以便传递给JSP,因为一个请求对应一个HttpServletRequest,我们也无需清理它,处理完该请求后HttpServletRequest实例将被丢弃;
  • 把user.jsp放到/WEB-INF/目录下,是因为WEB-INF是一个特殊目录,Web Server会阻止浏览器对WEB-INF目录下任何资源的访问,这样就防止用户通过/user.jsp路径直接访问到JSP页面;
  • JSP页面首先从request变量获取User实例,然后在页面中直接输出,此处未考虑HTML的转义问题,有潜在安全风险。

我们在浏览器访问http://localhost:8080/user,请求首先由UserServlet处理,然后交给user.jsp渲染。
我们把UserServlet看作业务逻辑处理,把User看作模型,把user.jsp看作渲染,这种设计模式通常被称为MVC:Model-View-Controller,即UserServlet作为控制器(Controller),User作为模型(Model),user.jsp作为视图(View),整个MVC架构如下:
image.png
使用MVC模式的好处是,Controller专注于业务处理,它的处理结果就是Model。Model可以是一个JavaBean,也可以是一个包含多个对象的Map,Controller只负责把Model传递给View,View只负责把Model给“渲染”出来,这样,三者职责明确,且开发更简单,因为开发Controller时无需关注页面,开发View时无需关心如何创建Model。
MVC模式广泛地应用在Web页面和传统的桌面程序中。
MVC模式是一种分离业务逻辑和显示逻辑的设计模式,广泛应用在Web和桌面应用程序。

7.MVC高级开发

通过结合Servlet和JSP的MVC模式,我们可以发挥二者各自的优点:

  • Servlet实现业务逻辑;
  • JSP实现展示逻辑。

但是,直接把MVC搭在Servlet和JSP之上还是不太好,原因如下:

  • Servlet提供的接口仍然偏底层,需要实现Servlet调用相关接口;
  • JSP对页面开发不友好,更好的替代品是模板引擎;
  • 业务逻辑最好由纯粹的Java类实现,而不是强迫继承自Servlet。

能不能通过普通的Java类实现MVC的Controller?类似下面的代码:

  1. public class UserController {
  2. @GetMapping("/signin")
  3. public ModelAndView signin() {
  4. ...
  5. }
  6. @PostMapping("/signin")
  7. public ModelAndView doSignin(SignInBean bean) {
  8. ...
  9. }
  10. @GetMapping("/signout")
  11. public ModelAndView signout(HttpSession session) {
  12. ...
  13. }
  14. }

上面的这个Java类每个方法都对应一个GET或POST请求,方法返回值是ModelAndView,它包含一个View的路径以及一个Model,这样,再由MVC框架处理后返回给浏览器。
如果是GET请求,我们希望MVC框架能直接把URL参数按方法参数对应起来然后传入:

  1. @GetMapping("/hello")
  2. public ModelAndView hello(String name) {
  3. ...
  4. }

如果是POST请求,我们希望MVC框架能直接把Post参数变成一个JavaBean后通过方法参数传入:

  1. @PostMapping("/signin")
  2. public ModelAndView doSignin(SignInBean bean) {
  3. ...
  4. }

为了增加灵活性,如果Controller的方法在处理请求时需要访问HttpServletRequest、HttpServletResponse、HttpSession这些实例时,只要方法参数有定义,就可以自动传入:

  1. @GetMapping("/signout")
  2. public ModelAndView signout(HttpSession session) {
  3. ...
  4. }

以上就是我们在设计MVC框架时,上层代码所需要的一切信息。

1.设计MVC框架

如何设计一个MVC框架?在上文中,我们已经定义了上层代码编写Controller的一切接口信息,并且并不要求实现特定接口,只需返回ModelAndView对象,该对象包含一个View和一个Model。实际上View就是模板的路径,而Model可以用一个Map表示,因此,ModelAndView定义非常简单:

  1. public class ModelAndView {
  2. Map<String, Object> model;
  3. String view;
  4. }

比较复杂的是我们需要在MVC框架中创建一个接收所有请求的Servlet,通常我们把它命名为DispatcherServlet,它总是映射到/,然后,根据不同的Controller的方法定义的@Get或@Post的Path决定调用哪个方法,最后,获得方法返回的ModelAndView后,渲染模板,写入HttpServletResponse,即完成了整个MVC的处理。
这个MVC的架构如下:
image.png
其中,DispatcherServlet以及如何渲染均由MVC框架实现,在MVC框架之上只需要编写每一个Controller。
我们来看看如何编写最复杂的DispatcherServlet。首先,我们需要存储请求路径到某个具体方法的映射:

  1. @WebServlet(urlPatterns = "/")
  2. public class DispatcherServlet extends HttpServlet {
  3. private Map<String, GetDispatcher> getMappings = new HashMap<>();
  4. private Map<String, PostDispatcher> postMappings = new HashMap<>();
  5. }

处理一个GET请求是通过GetDispatcher对象完成的,它需要如下信息:

  1. class GetDispatcher {
  2. Object instance; // Controller实例
  3. Method method; // Controller方法
  4. String[] parameterNames; // 方法参数名称
  5. Class<?>[] parameterClasses; // 方法参数类型
  6. }

有了以上信息,就可以定义invoke()来处理真正的请求:

  1. class GetDispatcher {
  2. ...
  3. public ModelAndView invoke(HttpServletRequest request, HttpServletResponse response) {
  4. Object[] arguments = new Object[parameterClasses.length];
  5. for (int i = 0; i < parameterClasses.length; i++) {
  6. String parameterName = parameterNames[i];
  7. Class<?> parameterClass = parameterClasses[i];
  8. if (parameterClass == HttpServletRequest.class) {
  9. arguments[i] = request;
  10. } else if (parameterClass == HttpServletResponse.class) {
  11. arguments[i] = response;
  12. } else if (parameterClass == HttpSession.class) {
  13. arguments[i] = request.getSession();
  14. } else if (parameterClass == int.class) {
  15. arguments[i] = Integer.valueOf(getOrDefault(request, parameterName, "0"));
  16. } else if (parameterClass == long.class) {
  17. arguments[i] = Long.valueOf(getOrDefault(request, parameterName, "0"));
  18. } else if (parameterClass == boolean.class) {
  19. arguments[i] = Boolean.valueOf(getOrDefault(request, parameterName, "false"));
  20. } else if (parameterClass == String.class) {
  21. arguments[i] = getOrDefault(request, parameterName, "");
  22. } else {
  23. throw new RuntimeException("Missing handler for type: " + parameterClass);
  24. }
  25. }
  26. return (ModelAndView) this.method.invoke(this.instance, arguments);
  27. }
  28. private String getOrDefault(HttpServletRequest request, String name, String defaultValue) {
  29. String s = request.getParameter(name);
  30. return s == null ? defaultValue : s;
  31. }
  32. }

上述代码比较繁琐,但逻辑非常简单,即通过构造某个方法需要的所有参数列表,使用反射调用该方法后返回结果。
类似的,PostDispatcher需要如下信息:

  1. class PostDispatcher {
  2. Object instance; // Controller实例
  3. Method method; // Controller方法
  4. Class<?>[] parameterClasses; // 方法参数类型
  5. ObjectMapper objectMapper; // JSON映射
  6. }

和GET请求不同,POST请求严格地来说不能有URL参数,所有数据都应当从Post Body中读取。这里我们为了简化处理,_只支持_JSON格式的POST请求,这样,把Post数据转化为JavaBean就非常容易。

  1. class PostDispatcher {
  2. ...
  3. public ModelAndView invoke(HttpServletRequest request, HttpServletResponse response) {
  4. Object[] arguments = new Object[parameterClasses.length];
  5. for (int i = 0; i < parameterClasses.length; i++) {
  6. Class<?> parameterClass = parameterClasses[i];
  7. if (parameterClass == HttpServletRequest.class) {
  8. arguments[i] = request;
  9. } else if (parameterClass == HttpServletResponse.class) {
  10. arguments[i] = response;
  11. } else if (parameterClass == HttpSession.class) {
  12. arguments[i] = request.getSession();
  13. } else {
  14. // 读取JSON并解析为JavaBean:
  15. BufferedReader reader = request.getReader();
  16. arguments[i] = this.objectMapper.readValue(reader, parameterClass);
  17. }
  18. }
  19. return (ModelAndView) this.method.invoke(instance, arguments);
  20. }
  21. }

最后,我们来实现整个DispatcherServlet的处理流程,以doGet()为例:

  1. public class DispatcherServlet extends HttpServlet {
  2. ...
  3. @Override
  4. protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
  5. resp.setContentType("text/html");
  6. resp.setCharacterEncoding("UTF-8");
  7. String path = req.getRequestURI().substring(req.getContextPath().length());
  8. // 根据路径查找GetDispatcher:
  9. GetDispatcher dispatcher = this.getMappings.get(path);
  10. if (dispatcher == null) {
  11. // 未找到返回404:
  12. resp.sendError(404);
  13. return;
  14. }
  15. // 调用Controller方法获得返回值:
  16. ModelAndView mv = dispatcher.invoke(req, resp);
  17. // 允许返回null:
  18. if (mv == null) {
  19. return;
  20. }
  21. // 允许返回`redirect:`开头的view表示重定向:
  22. if (mv.view.startsWith("redirect:")) {
  23. resp.sendRedirect(mv.view.substring(9));
  24. return;
  25. }
  26. // 将模板引擎渲染的内容写入响应:
  27. PrintWriter pw = resp.getWriter();
  28. this.viewEngine.render(mv, pw);
  29. pw.flush();
  30. }
  31. }

这里有几个小改进:

  • 允许Controller方法返回null,表示内部已自行处理完毕;
  • 允许Controller方法返回以redirect:开头的view名称,表示一个重定向。

这样使得上层代码编写更灵活。例如,一个显示用户资料的请求可以这样写:

  1. @GetMapping("/user/profile")
  2. public ModelAndView profile(HttpServletResponse response, HttpSession session) {
  3. User user = (User) session.getAttribute("user");
  4. if (user == null) {
  5. // 未登录,跳转到登录页:
  6. return new ModelAndView("redirect:/signin");
  7. }
  8. if (!user.isManager()) {
  9. // 权限不够,返回403:
  10. response.sendError(403);
  11. return null;
  12. }
  13. return new ModelAndView("/profile.html", Map.of("user", user));
  14. }

最后一步是在DispatcherServlet的init()方法中初始化所有Get和Post的映射,以及用于渲染的模板引擎:

  1. public class DispatcherServlet extends HttpServlet {
  2. private Map<String, GetDispatcher> getMappings = new HashMap<>();
  3. private Map<String, PostDispatcher> postMappings = new HashMap<>();
  4. private ViewEngine viewEngine;
  5. @Override
  6. public void init() throws ServletException {
  7. this.getMappings = scanGetInControllers();
  8. this.postMappings = scanPostInControllers();
  9. this.viewEngine = new ViewEngine(getServletContext());
  10. }
  11. ...
  12. }

如何扫描所有Controller以获取所有标记有@GetMapping和@PostMapping的方法?当然是使用反射了。
整个MVC框架就搭建完毕。

2.实现渲染

如何实现上述的ViewEngine?其实ViewEngine非常简单,只需要实现一个简单的render()方法:

  1. public class ViewEngine {
  2. public void render(ModelAndView mv, Writer writer) throws IOException {
  3. String view = mv.view;
  4. Map<String, Object> model = mv.model;
  5. // 根据view找到模板文件:
  6. Template template = getTemplateByPath(view);
  7. // 渲染并写入Writer:
  8. template.write(writer, model);
  9. }
  10. }

Java有很多开源的模板引擎,常用的有:

一个MVC框架是基于Servlet基础抽象出更高级的接口,使得上层基于MVC框架的开发可以不涉及Servlet相关的HttpServletRequest等接口,处理多个请求更加灵活,并且可以使用任意模板引擎,不必使用JSP。

8.使用Filter

为了把一些公用逻辑从各个Servlet中抽离出来,JavaEE的Servlet规范还提供了一种Filter组件,即过滤器,它的作用是,在HTTP请求到达Servlet之前,可以被一个或多个Filter预处理,类似打印日志、登录检查等逻辑,完全可以放到Filter中。
例如,我们编写一个最简单的EncodingFilter,它强制把输入和输出的编码设置为UTF-8:

  1. @WebFilter(urlPatterns = "/*")
  2. public class EncodingFilter implements Filter {
  3. public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
  4. throws IOException, ServletException {
  5. System.out.println("EncodingFilter:doFilter");
  6. request.setCharacterEncoding("UTF-8");
  7. response.setCharacterEncoding("UTF-8");
  8. chain.doFilter(request, response);
  9. }
  10. }

编写Filter时,必须实现Filter接口,在doFilter()方法内部,要继续处理请求,必须调用chain.doFilter()。最后,用@WebFilter注解标注该Filter需要过滤的URL。这里的/*表示所有路径。
添加了Filter之后,整个请求的处理架构如下:
image.png
还可以继续添加其他Filter,例如LogFilter:

  1. @WebFilter("/*")
  2. public class LogFilter implements Filter {
  3. public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
  4. throws IOException, ServletException {
  5. System.out.println("LogFilter: process " + ((HttpServletRequest) request).getRequestURI());
  6. chain.doFilter(request, response);
  7. }
  8. }

多个Filter会组成一个链,每个请求都被链上的Filter依次处理:
image.png
有多个Filter的时候,Filter的顺序如何指定?多个Filter按不同顺序处理会造成处理结果不同吗?
答案是Filter的顺序确实对处理的结果有影响。但遗憾的是,Servlet规范并没有对@WebFilter注解标注的Filter规定顺序。如果一定要给每个Filter指定顺序,就必须在web.xml文件中对这些Filter再配置一遍。
注意到上述两个Filter的过滤路径都是/*,即它们会对所有请求进行过滤。也可以编写只对特定路径进行过滤的Filter,例如AuthFilter:

  1. @WebFilter("/user/*")
  2. public class AuthFilter implements Filter {
  3. public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
  4. throws IOException, ServletException {
  5. System.out.println("AuthFilter: check authentication");
  6. HttpServletRequest req = (HttpServletRequest) request;
  7. HttpServletResponse resp = (HttpServletResponse) response;
  8. if (req.getSession().getAttribute("user") == null) {
  9. // 未登录,自动跳转到登录页:
  10. System.out.println("AuthFilter: not signin!");
  11. resp.sendRedirect("/signin");
  12. } else {
  13. // 已登录,继续处理:
  14. chain.doFilter(request, response);
  15. }
  16. }
  17. }

注意到AuthFilter只过滤以/user/开头的路径,因此:

  • 如果一个请求路径类似/user/profile,那么它会被上述3个Filter依次处理;
  • 如果一个请求路径类似/test,那么它会被上述2个Filter依次处理(不会被AuthFilter处理)。

再注意观察AuthFilter,当用户没有登录时,在AuthFilter内部,直接调用resp.sendRedirect()发送重定向,且没有调用chain.doFilter(),因此,当用户没有登录时,请求到达AuthFilter后,不再继续处理,即后续的Filter和任何Servlet都没有机会处理该请求了。
可见,Filter可以有针对性地拦截或者放行HTTP请求。
如果一个Filter在当前请求中生效,但什么都没有做:

  1. @WebFilter("/*")
  2. public class MyFilter implements Filter {
  3. public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
  4. throws IOException, ServletException {
  5. // TODO
  6. }
  7. }

那么,用户将看到一个空白页,因为请求没有继续处理,默认响应是200+空白输出。
如果Filter要使请求继续被处理,就一定要调用chain.doFilter()!
Filter是一种对HTTP请求进行预处理的组件,它可以构成一个处理链,使得公共处理代码能集中到一起;
Filter适用于日志、登录检查、全局设置等;
设计合理的URL映射可以让Filter链更清晰。

1.修改请求

Filter可以对请求进行预处理,因此,我们可以把很多公共预处理逻辑放到Filter中完成。
考察这样一种需求:我们在Web应用中经常需要处理用户上传文件,例如,一个UploadServlet可以简单地编写如下:

  1. @WebServlet(urlPatterns = "/upload/file")
  2. public class UploadServlet extends HttpServlet {
  3. protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
  4. // 读取Request Body:
  5. InputStream input = req.getInputStream();
  6. ByteArrayOutputStream output = new ByteArrayOutputStream();
  7. byte[] buffer = new byte[1024];
  8. for (;;) {
  9. int len = input.read(buffer);
  10. if (len == -1) {
  11. break;
  12. }
  13. output.write(buffer, 0, len);
  14. }
  15. // TODO: 写入文件:
  16. // 显示上传结果:
  17. String uploadedText = output.toString(StandardCharsets.UTF_8);
  18. PrintWriter pw = resp.getWriter();
  19. pw.write("<h1>Uploaded:</h1>");
  20. pw.write("<pre><code>");
  21. pw.write(uploadedText);
  22. pw.write("</code></pre>");
  23. pw.flush();
  24. }
  25. }

但是要保证文件上传的完整性怎么办?在哈希算法一节中,我们知道,如果在上传文件的同时,把文件的哈希也传过来,服务器端做一个验证,就可以确保用户上传的文件一定是完整的。
这个验证逻辑非常适合写在ValidateUploadFilter中,因为它可以复用。
我们先写一个简单的版本,快速实现ValidateUploadFilter的逻辑:

  1. @WebFilter("/upload/*")
  2. public class ValidateUploadFilter implements Filter {
  3. @Override
  4. public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
  5. throws IOException, ServletException {
  6. HttpServletRequest req = (HttpServletRequest) request;
  7. HttpServletResponse resp = (HttpServletResponse) response;
  8. // 获取客户端传入的签名方法和签名:
  9. String digest = req.getHeader("Signature-Method");
  10. String signature = req.getHeader("Signature");
  11. if (digest == null || digest.isEmpty() || signature == null || signature.isEmpty()) {
  12. sendErrorPage(resp, "Missing signature.");
  13. return;
  14. }
  15. // 读取Request的Body并验证签名:
  16. MessageDigest md = getMessageDigest(digest);
  17. InputStream input = new DigestInputStream(request.getInputStream(), md);
  18. byte[] buffer = new byte[1024];
  19. for (;;) {
  20. int len = input.read(buffer);
  21. if (len == -1) {
  22. break;
  23. }
  24. }
  25. String actual = toHexString(md.digest());
  26. if (!signature.equals(actual)) {
  27. sendErrorPage(resp, "Invalid signature.");
  28. return;
  29. }
  30. // 验证成功后继续处理:
  31. chain.doFilter(request, response);
  32. }
  33. // 将byte[]转换为hex string:
  34. private String toHexString(byte[] digest) {
  35. StringBuilder sb = new StringBuilder();
  36. for (byte b : digest) {
  37. sb.append(String.format("%02x", b));
  38. }
  39. return sb.toString();
  40. }
  41. // 根据名称创建MessageDigest:
  42. private MessageDigest getMessageDigest(String name) throws ServletException {
  43. try {
  44. return MessageDigest.getInstance(name);
  45. } catch (NoSuchAlgorithmException e) {
  46. throw new ServletException(e);
  47. }
  48. }
  49. // 发送一个错误响应:
  50. private void sendErrorPage(HttpServletResponse resp, String errorMessage) throws IOException {
  51. resp.setStatus(HttpServletResponse.SC_BAD_REQUEST);
  52. PrintWriter pw = resp.getWriter();
  53. pw.write("<html><body><h1>");
  54. pw.write(errorMessage);
  55. pw.write("</h1></body></html>");
  56. pw.flush();
  57. }
  58. }

这个ValidateUploadFilter的逻辑似乎没有问题,我们可以用curl命令测试:

  1. $ curl http://localhost:8080/upload/file -v -d 'test-data' \
  2. -H 'Signature-Method: SHA-1' \
  3. -H 'Signature: 7115e9890f5b5cc6914bdfa3b7c011db1cdafedb' \
  4. -H 'Content-Type: application/octet-stream'
  5. * Trying ::1...
  6. * TCP_NODELAY set
  7. * Connected to localhost (::1) port 8080 (#0)
  8. > POST /upload/file HTTP/1.1
  9. > Host: localhost:8080
  10. > User-Agent: curl/7.64.1
  11. > Accept: */*
  12. > Signature-Method: SHA-1
  13. > Signature: 7115e9890f5b5cc6914bdfa3b7c011db1cdafedb
  14. > Content-Type: application/octet-stream
  15. > Content-Length: 9
  16. >
  17. * upload completely sent off: 9 out of 9 bytes
  18. < HTTP/1.1 200
  19. < Transfer-Encoding: chunked
  20. < Date: Thu, 30 Jan 2020 13:56:39 GMT
  21. <
  22. * Connection #0 to host localhost left intact
  23. <h1>Uploaded:</h1><pre><code></code></pre>
  24. * Closing connection 0

ValidateUploadFilter对签名进行验证的逻辑是没有问题的,但是,UploadServlet并未读取到任何数据!
这里的原因是对HttpServletRequest进行读取时,只能读取一次。如果Filter调用getInputStream()读取了一次数据,后续Servlet处理时,再次读取,将无法读到任何数据。怎么办?
这个时候,我们需要一个“伪造”的HttpServletRequest,具体做法是使用代理模式,对getInputStream()和getReader()返回一个新的流:

  1. class ReReadableHttpServletRequest extends HttpServletRequestWrapper {
  2. private byte[] body;
  3. private boolean open = false;
  4. public ReReadableHttpServletRequest(HttpServletRequest request, byte[] body) {
  5. super(request);
  6. this.body = body;
  7. }
  8. // 返回InputStream:
  9. public ServletInputStream getInputStream() throws IOException {
  10. if (open) {
  11. throw new IllegalStateException("Cannot re-open input stream!");
  12. }
  13. open = true;
  14. return new ServletInputStream() {
  15. private int offset = 0;
  16. public boolean isFinished() {
  17. return offset >= body.length;
  18. }
  19. public boolean isReady() {
  20. return true;
  21. }
  22. public void setReadListener(ReadListener listener) {
  23. }
  24. public int read() throws IOException {
  25. if (offset >= body.length) {
  26. return -1;
  27. }
  28. int n = body[offset] & 0xff;
  29. offset++;
  30. return n;
  31. }
  32. };
  33. }
  34. // 返回Reader:
  35. public BufferedReader getReader() throws IOException {
  36. if (open) {
  37. throw new IllegalStateException("Cannot re-open reader!");
  38. }
  39. open = true;
  40. return new BufferedReader(new InputStreamReader(new ByteArrayInputStream(body), "UTF-8"));
  41. }
  42. }

注意观察ReReadableHttpServletRequest的构造方法,它保存了ValidateUploadFilter读取的byte[]内容,并在调用getInputStream()时通过byte[]构造了一个新的ServletInputStream。
然后,我们在ValidateUploadFilter中,把doFilter()调用时传给下一个处理者的HttpServletRequest替换为我们自己“伪造”的ReReadableHttpServletRequest:

  1. public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
  2. throws IOException, ServletException {
  3. ...
  4. chain.doFilter(new ReReadableHttpServletRequest(req, output.toByteArray()), response);
  5. }

再注意到我们编写ReReadableHttpServletRequest时,是从HttpServletRequestWrapper继承,而不是直接实现HttpServletRequest接口。这是因为,Servlet的每个新版本都会对接口增加一些新方法,从HttpServletRequestWrapper继承可以确保新方法被正确地覆写了,因为HttpServletRequestWrapper是由Servlet的jar包提供的,目的就是为了让我们方便地实现对HttpServletRequest接口的代理。
我们总结一下对HttpServletRequest接口进行代理的步骤:

  1. 从HttpServletRequestWrapper继承一个XxxHttpServletRequest,需要传入原始的HttpServletRequest实例;
  2. 覆写某些方法,使得新的XxxHttpServletRequest实例看上去“改变”了原始的HttpServletRequest实例;
  3. 在doFilter()中传入新的XxxHttpServletRequest实例。

虽然整个Filter的代码比较复杂,但它的好处在于:这个Filter在整个处理链中实现了灵活的“可插拔”特性,即是否启用对Web应用程序的其他组件(Filter、Servlet)完全没有影响。
借助HttpServletRequestWrapper,我们可以在Filter中实现对原始HttpServletRequest的修改。

2.修改响应

既然我们能通过Filter修改HttpServletRequest,自然也能修改HttpServletResponse,因为这两者都是接口。
我们来看一下在什么情况下我们需要修改HttpServletResponse。
假设我们编写了一个Servlet,但由于业务逻辑比较复杂,处理该请求需要耗费很长的时间:

  1. @WebServlet(urlPatterns = "/slow/hello")
  2. public class HelloServlet extends HttpServlet {
  3. protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
  4. resp.setContentType("text/html");
  5. // 模拟耗时1秒:
  6. try {
  7. Thread.sleep(1000);
  8. } catch (InterruptedException e) {
  9. }
  10. PrintWriter pw = resp.getWriter();
  11. pw.write("<h1>Hello, world!</h1>");
  12. pw.flush();
  13. }
  14. }

好消息是每次返回的响应内容是固定的,因此,如果我们能使用缓存将结果缓存起来,就可以大大提高Web应用程序的运行效率。
缓存逻辑最好不要在Servlet内部实现,因为我们希望能复用缓存逻辑,所以,编写一个CacheFilter最合适:

  1. @WebFilter("/slow/*")
  2. public class CacheFilter implements Filter {
  3. // Path到byte[]的缓存:
  4. private Map<String, byte[]> cache = new ConcurrentHashMap<>();
  5. public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
  6. throws IOException, ServletException {
  7. HttpServletRequest req = (HttpServletRequest) request;
  8. HttpServletResponse resp = (HttpServletResponse) response;
  9. // 获取Path:
  10. String url = req.getRequestURI();
  11. // 获取缓存内容:
  12. byte[] data = this.cache.get(url);
  13. resp.setHeader("X-Cache-Hit", data == null ? "No" : "Yes");
  14. if (data == null) {
  15. // 缓存未找到,构造一个伪造的Response:
  16. CachedHttpServletResponse wrapper = new CachedHttpServletResponse(resp);
  17. // 让下游组件写入数据到伪造的Response:
  18. chain.doFilter(request, wrapper);
  19. // 从伪造的Response中读取写入的内容并放入缓存:
  20. data = wrapper.getContent();
  21. cache.put(url, data);
  22. }
  23. // 写入到原始的Response:
  24. ServletOutputStream output = resp.getOutputStream();
  25. output.write(data);
  26. output.flush();
  27. }
  28. }

实现缓存的关键在于,调用doFilter()时,我们不能传入原始的HttpServletResponse,因为这样就会写入Socket,我们也就无法获取下游组件写入的内容。如果我们传入的是“伪造”的HttpServletResponse,让下游组件写入到我们预设的ByteArrayOutputStream,我们就“截获”了下游组件写入的内容,于是,就可以把内容缓存起来,再通过原始的HttpServletResponse实例写入到网络。
这个CachedHttpServletResponse实现如下:

  1. class CachedHttpServletResponse extends HttpServletResponseWrapper {
  2. private boolean open = false;
  3. private ByteArrayOutputStream output = new ByteArrayOutputStream();
  4. public CachedHttpServletResponse(HttpServletResponse response) {
  5. super(response);
  6. }
  7. // 获取Writer:
  8. public PrintWriter getWriter() throws IOException {
  9. if (open) {
  10. throw new IllegalStateException("Cannot re-open writer!");
  11. }
  12. open = true;
  13. return new PrintWriter(output, false, StandardCharsets.UTF_8);
  14. }
  15. // 获取OutputStream:
  16. public ServletOutputStream getOutputStream() throws IOException {
  17. if (open) {
  18. throw new IllegalStateException("Cannot re-open output stream!");
  19. }
  20. open = true;
  21. return new ServletOutputStream() {
  22. public boolean isReady() {
  23. return true;
  24. }
  25. public void setWriteListener(WriteListener listener) {
  26. }
  27. // 实际写入ByteArrayOutputStream:
  28. public void write(int b) throws IOException {
  29. output.write(b);
  30. }
  31. };
  32. }
  33. // 返回写入的byte[]:
  34. public byte[] getContent() {
  35. return output.toByteArray();
  36. }
  37. }

可见,如果我们想要修改响应,就可以通过HttpServletResponseWrapper构造一个“伪造”的HttpServletResponse,这样就能拦截到写入的数据。
修改响应时,最后不要忘记把数据写入原始的HttpServletResponse实例。
这个CacheFilter同样是一个“可插拔”组件,它是否启用不影响Web应用程序的其他组件(Filter、Servlet)。
借助HttpServletResponseWrapper,我们可以在Filter中实现对原始HttpServletResponse的修改。

9.使用Listener

除了Servlet和Filter外,JavaEE的Servlet规范还提供了第三种组件:Listener。
Listener顾名思义就是监听器,有好几种Listener,其中最常用的是ServletContextListener,我们编写一个实现了ServletContextListener接口的类如下:

  1. @WebListener
  2. public class AppListener implements ServletContextListener {
  3. // 在此初始化WebApp,例如打开数据库连接池等:
  4. public void contextInitialized(ServletContextEvent sce) {
  5. System.out.println("WebApp initialized.");
  6. }
  7. // 在此清理WebApp,例如关闭数据库连接池等:
  8. public void contextDestroyed(ServletContextEvent sce) {
  9. System.out.println("WebApp destroyed.");
  10. }
  11. }

任何标注为@WebListener,且实现了特定接口的类会被Web服务器自动初始化。上述AppListener实现了ServletContextListener接口,它会在整个Web应用程序初始化完成后,以及Web应用程序关闭后获得回调通知。我们可以把初始化数据库连接池等工作放到contextInitialized()回调方法中,把清理资源的工作放到contextDestroyed()回调方法中,因为Web服务器保证在contextInitialized()执行后,才会接受用户的HTTP请求。
很多第三方Web框架都会通过一个ServletContextListener接口初始化自己。
除了ServletContextListener外,还有几种Listener:

  • HttpSessionListener:监听HttpSession的创建和销毁事件;
  • ServletRequestListener:监听ServletRequest请求的创建和销毁事件;
  • ServletRequestAttributeListener:监听ServletRequest请求的属性变化事件(即调用ServletRequest.setAttribute()方法);
  • ServletContextAttributeListener:监听ServletContext的属性变化事件(即调用ServletContext.setAttribute()方法);

    1.ServletContext

    一个Web服务器可以运行一个或多个WebApp,对于每个WebApp,Web服务器都会为其创建一个全局唯一的ServletContext实例,我们在AppListener里面编写的两个回调方法实际上对应的就是ServletContext实例的创建和销毁:

    1. public void contextInitialized(ServletContextEvent sce) {
    2. System.out.println("WebApp initialized: ServletContext = " + sce.getServletContext());
    3. }

    ServletRequest、HttpSession等很多对象也提供getServletContext()方法获取到同一个ServletContext实例。ServletContext实例最大的作用就是设置和共享全局信息。
    此外,ServletContext还提供了动态添加Servlet、Filter、Listener等功能,它允许应用程序在运行期间动态添加一个组件,虽然这个功能不是很常用。
    通过Listener我们可以监听Web应用程序的生命周期,获取HttpSession等创建和销毁的事件;
    ServletContext是一个WebApp运行期的全局唯一实例,可用于设置和共享配置信息。

    10.部署

    对一个Web应用程序来说,除了Servlet、Filter这些逻辑组件,还需要JSP这样的视图文件,外加一堆静态资源文件,如CSS、JS等。
    合理组织文件结构非常重要。我们以一个具体的Web应用程序为例:
    image.png
    我们把所有的静态资源文件放入/static/目录,在开发阶段,有些Web服务器会自动为我们加一个专门负责处理静态文件的Servlet,但如果IndexServlet映射路径为/,会屏蔽掉处理静态文件的Servlet映射。因此,我们需要自己编写一个处理静态文件的FileServlet:

    1. @WebServlet(urlPatterns = "/static/*")
    2. public class FileServlet extends HttpServlet {
    3. protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    4. ServletContext ctx = req.getServletContext();
    5. // RequestURI包含ContextPath,需要去掉:
    6. String urlPath = req.getRequestURI().substring(ctx.getContextPath().length());
    7. // 获取真实文件路径:
    8. String filepath = ctx.getRealPath(urlPath);
    9. if (filepath == null) {
    10. // 无法获取到路径:
    11. resp.sendError(HttpServletResponse.SC_NOT_FOUND);
    12. return;
    13. }
    14. Path path = Paths.get(filepath);
    15. if (!path.toFile().isFile()) {
    16. // 文件不存在:
    17. resp.sendError(HttpServletResponse.SC_NOT_FOUND);
    18. return;
    19. }
    20. // 根据文件名猜测Content-Type:
    21. String mime = Files.probeContentType(path);
    22. if (mime == null) {
    23. mime = "application/octet-stream";
    24. }
    25. resp.setContentType(mime);
    26. // 读取文件并写入Response:
    27. OutputStream output = resp.getOutputStream();
    28. try (InputStream input = new BufferedInputStream(new FileInputStream(filepath))) {
    29. input.transferTo(output);
    30. }
    31. output.flush();
    32. }
    33. }

    这样一来,在开发阶段,我们就可以方便地高效开发。
    类似Tomcat这样的Web服务器,运行的Web应用程序通常都是业务系统,因此,这类服务器也被称为应用服务器。应用服务器并不擅长处理静态文件,也不适合直接暴露给用户。通常,我们在生产环境部署时,总是使用类似Nginx这样的服务器充当反向代理和静态服务器,只有动态请求才会放行给应用服务器,所以,部署架构如下:
    image.png
    实现上述功能的Nginx配置文件如下:

    1. server {
    2. listen 80;
    3. server_name www.local.liaoxuefeng.com;
    4. # 静态文件根目录:
    5. root /path/to/src/main/webapp;
    6. access_log /var/log/nginx/webapp_access_log;
    7. error_log /var/log/nginx/webapp_error_log;
    8. # 处理静态文件请求:
    9. location /static {
    10. }
    11. # 处理静态文件请求:
    12. location /favicon.ico {
    13. }
    14. # 不允许请求/WEB-INF:
    15. location /WEB-INF {
    16. return 404;
    17. }
    18. # 其他请求转发给Tomcat:
    19. location / {
    20. proxy_pass http://127.0.0.1:8080;
    21. proxy_set_header Host $host;
    22. proxy_set_header X-Real-IP $remote_addr;
    23. proxy_set_header X-Forwarded-Proto $scheme;
    24. proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    25. }
    26. }

    使用Nginx配合Tomcat服务器,可以充分发挥Nginx作为网关的优势,既可以高效处理静态文件,也可以把https、防火墙、限速、反爬虫等功能放到Nginx中,使得我们自己的WebApp能专注于业务逻辑。
    部署Web应用程序时,要设计合理的目录结构,同时考虑开发模式需要便捷性,生产模式需要高性能。