从本章开始我们就进入到JavaEE的领域。JavaEE并不是一个软件产品,它更多的是一种软件架构和设计思想。我们可以把JavaEE看作是在JavaSE的基础上,开发的一系列基于服务器的组件、API标准和通用架构。
JavaEE最核心的部件是基于Servlet标准的Web服务器,开发者编写的应用程序是基于Servlet API并运行在Web服务器内部的。此外,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,以及一系列开源组件。本章我们将详细介绍基于Servlet的Web开发。
Web基础 今天我们访问网站、使用App时,都是基于Web这种Browser/Server模式,简称BS架构。它的特点是,客户端只需要浏览器,应用程序的逻辑和数据都存储在服务器端。浏览器只需要请求服务器,获取Web页面,并把Web页面展示给用户即可。
因为Web页面是用HTML编写的,而HTML具有强大的表现力,所以Web页面具有极强的交互性。并且,服务端升级后,客户端无需任何部署就可以使用到新版本,因此,BS架构升级非常容易。
对于Browser来说,请求页面的流程如下:
与服务器建立TCP连接 
发送HTTP请求 
接收HTTP相应,然后把网页在浏览器端显示出来 
 
HTTP协议 浏览器发送的HTTP请求如下:
1 2 3 4 5 GET / HTTP/1.1 Host: www.sina.com.cn User-Agent: Mozilla/5.0 xxx Accept: */* 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 2 3 4 5 6 7 HTTP/1.1 200 OK Content-Type: text/html Content-Length: 21932 Content-Encoding: gzip Cache-Control: max-age=300 <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等解压后显示网页、图像或其他内容。
通常浏览器获取的第一个资源是HTTP网页,在网页中,如果嵌入了JavaScript、CSS、图片、视频等其他资源,浏览器会根据资源的URL再次向服务器请求对应的资源。
编写HTTP Server 一个HTTP Server本质上是一个TCP服务器,我们先用TCP编程 的知识实现服务器端的框架。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 public  class  Server   {     public  static  void  main (String[] args)  throws  IOException  {         ServerSocket ss = new  ServerSocket(8080 );          System.out.println("server is running..." );         for  (;;) {             Socket sock = ss.accept();             System.out.println("connected from "  + sock.getRemoteSocketAddress());             Thread t = new  Handler(sock);             t.start();         }     } }class  Handler  extends  Thread   {     Socket sock;     public  Handler (Socket sock)   {         this .sock = sock;     }     public  void  run ()   {         try  (InputStream input = this .sock.getInputStream()) {             try  (OutputStream output = this .sock.getOutputStream()) {                 handle(input, output);             }         } catch  (Exception e) {             try  {                 this .sock.close();             } catch  (IOException ioe) {             }             System.out.println("client disconnected." );         }     }     private  void  handle (InputStream input, OutputStream output)  throws  IOException  {         var  reader = new  BufferedReader(new  InputStreamReader(input, StandardCharsets.UTF_8));         var  writer = new  BufferedWriter(new  OutputStreamWriter(output, StandardCharsets.UTF_8));                  System.out.println("Process new http request..." );     var  reader = new  BufferedReader(new  InputStreamReader(input, StandardCharsets.UTF_8));     var  writer = new  BufferedWriter(new  OutputStreamWriter(output, StandardCharsets.UTF_8));          boolean  requestOk = false ;     String first = reader.readLine();     if  (first.startsWith("GET / HTTP/1." )) {         requestOk = true ;     }     for  (;;) {         String header = reader.readLine();         if  (header.isEmpty()) {              break ;         }         System.out.println(header);     }     System.out.println(requestOk ? "Response OK"  : "Response Error" );     if  (!requestOk) {                  writer.write("HTTP/1.0 404 Not Found\r\n" );         writer.write("Content-Length: 0\r\n" );         writer.write("\r\n" );         writer.flush();     } else  {                  String data = "<html><body><h1>Hello, world!</h1></body></html>" ;         int  length = data.getBytes(StandardCharsets.UTF_8).length;         writer.write("HTTP/1.0 200 OK\r\n" );         writer.write("Connection: close\r\n" );         writer.write("Content-Type: text/html\r\n" );         writer.write("Content-Length: "  + length + "\r\n" );         writer.write("\r\n" );          writer.write(data);         writer.flush();     } }
 
这里的核心代码是,先读取HTTP请求,这里我们只处理GET /的请求。当读取到空行时,表示已读到连续两个\r\n,说明请求结束,可以发送响应。发送响应的时候,首先发送响应代码HTTP/1.0 200 OK表示一个成功的200响应,使用HTTP/1.0协议,然后,依次发送Header,发送完Header后,再发送一个空行标识Header结束,紧接着发送HTTP Body,在浏览器输入http://localhost:8080/就可以看到响应页面。
HTTP目前有多个版本,1.0是早期版本,浏览器每次建立TCP连接后,只发送一个HTTP请求并接收一个HTTP响应,然后就关闭TCP连接。由于创建TCP连接本身就需要消耗一定的时间,因此,HTTP 1.1允许浏览器和服务器在同一个TCP连接上反复发送、接收多个HTTP请求和响应,这样就大大提高了传输效率。
我们注意到HTTP协议是一个请求-响应协议,它总是发送一个请求,然后接收一个响应。能不能一次性发送多个请求,然后再接收多个响应呢?HTTP 2.0可以支持浏览器同时发出多个请求,但每个请求需要唯一标识,服务器可以不按请求的顺序返回多个响应,由浏览器自己把收到的响应和请求对应起来。可见,HTTP 2.0进一步提高了传输效率,因为浏览器发出一个请求后,不必等待响应,就可以继续发下一个请求。
HTTP 3.0为了进一步提高速度,将抛弃TCP协议,改为使用无需创建连接的UDP协议,目前HTTP 3.0仍然处于实验阶段。
 
Servlet入门 在上一节中我们看到,编写HTTP服务器其实是比较简单的,只需要先编写基于多线程的TCP服务,然后在一个TCP连接中去读HTTP请求,发送HTTP响应即可。但是,要编写一个完善的HTTP服务器,以HTTP/1.1为例,需要考虑:
识别正确和错误的HTTP请求; 
识别正确和错误的HTTP头; 
复用TCP连接; 
复用线程; 
IO异常处理; 
… 
 
这些基础的工作需要耗费大量的时间,并且经过长期测试才能稳定运行。如果我们只需要输出一个简单的HTML页面,就不得不编写上千行底层代码,那就无法做到高效而可靠的开发。
因此,在JavaEE平台上,处理TCP连接,解析HTTP协议这些底层工作统统交给现成的Web服务器去做,我们只需要把自己的应用程序跑在Web服务器上。为了实现这一目的,JavaEE提供了Servlet API,我们使用Servlet API编写自己的Servlet来处理HTTP请求,Web服务器实现Servlet API接口,实现底层功能:
1 2 3 4 5 6 7                  ┌───────────┐                  │My Servlet │                  ├───────────┤                  │Servlet API│ ┌───────┐  HTTP  ├───────────┤ │Browser│<──────>│Web Server │ └───────┘        └───────────┘
 
我们来实现一个最简单的Servlet:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @WebServlet(urlPatterns = "/") public  class  HelloServlet  extends  HttpServlet   {     protected  void  doGet (HttpServletRequest req, HttpServletResponse resp)             throws  ServletException, IOException  {                  resp.setContentType("text/html" );                  PrintWriter pw = resp.getWriter();                  pw.write("<h1>Hello, world!</h1>" );                  pw.flush();     } }
 
一个Servlet总是继承自HttpServlet,然后覆写doGet()和doPost()方法。注意到doGet()方法传入了HttpServletRequest和HttpServletResponse,分别表示HTTP请求和响应。我们使用Servlet API时,并不与底层TCP交互。也不需要解析HTTP协议,因为HttpServletRequest和HttpServletResponse都已经封装好了请求和响应。以发送响应为例,我们只需要设置正确的响应类型,然后获取PrintWriter,写入响应即可。
那么Servlet API是谁提供的呢?
Servlet API是一个jar包,我们需要通过Maven来引入它,才能正常编译:
1 2 3 4 5 6 7 8 9 10 <packaging > war</packaging > <dependencies >    <dependency >      <groupId > javax.servlet</groupId >      <artifactId > javax.servlet-api</artifactId >      <version > 4.0.0</version >      <scope > provided</scope >    </dependency > </dependencies > 
 
注意到这个pom.xml与之前讲到的普通Java程序有点不同,打包类型是不jar而是war,表示Java Web Application Archive。注意到<scope>指定为provided,表示编译时使用,但不会打包到.war文件中,因为运行期Web服务器本身已经提供了Servlet API相关的jar包。
我们还需要在工程目录下创建一个web.xml描述文件,放到src/main/webapp/WEB-INF目录下(固定目录结构,不要修改路径,注意大小写)。文件内容可以固定如下:
1 2 3 4 5 6 <!DOCTYPE web-app PUBLIC  "-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN"   "http://java.sun.com/dtd/web-app_2_3.dtd" > <web-app>   <display-name>Archetype Created Web Application</display-name> </web-app>
 
1 2 3 4 5 6 7 8 9 10 11 12 13 14 web-servlet-hello ├── pom.xml └── src     └── main         ├── java         │   └── com         │       └── itranswarp         │           └── learnjava         │               └── servlet         │                   └── HelloServlet.java         ├── resources         └── webapp             └── WEB-INF                 └── web.xml
 
运行Maven命令mvn clean package,在target目录下得到一个hello.war文件,这个文件就是我们编译打包后的Web应用程序。
普通的Java程序是通过启动JVM,然后执行main()方法开始运行。但是Web应用程序有所不同,我们无法直接运行war文件,必须先启动Web服务器,再由Web服务器加载我们编写的HelloServlet,这样就可以让HelloServlet处理浏览器发送的请求。
因此,我们首先要找一个支持Servlet API的Web服务器。常用的服务器有:
无论使用哪个服务器,只要它支持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的多线程模型,需要同步访问的必须同步。
Servlet开发 在上一节中我们看到,一个完整的Web应用程序开发流程如下:
编写Servlet 
打包为war文件 
复制到Tomcat的webapps目录下 
启动Tomcat 
 
这个过程有点繁琐,比如我们想在IDE中断点调试,还需要打开Tomcat的远程调试窗口并连接上去。因此,我们需要一种简单可靠,能直接在IDE中启动并调试webapp的方法。我们先来看看Tomcat的启动流程:
启动JVM并执行Tomcat的main()方法 
加载war并初始化Servlet 
正常服务 
 
启动Tomcat无非就是设置好classpath并执行Tomcat某个jar包的main()方法,我们完全可以把Tomcat的jar包全部引入进来,然后自己编写main()方法,让它先启动Tomcat,再加载我们的webapp就行。
我们新建一个web-servlet-embedded工程,编写pom.xml如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 <project  xmlns ="http://maven.apache.org/POM/4.0.0"      xmlns:xsi ="http://www.w3.org/2001/XMLSchema-instance"      xsi:schemaLocation ="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd" >      <modelVersion > 4.0.0</modelVersion >      <groupId > com.itranswarp.learnjava</groupId >      <artifactId > web-servlet-embedded</artifactId >      <version > 1.0-SNAPSHOT</version >      <packaging > war</packaging >      <properties >          <project.build.sourceEncoding > UTF-8</project.build.sourceEncoding >          <project.reporting.outputEncoding > UTF-8</project.reporting.outputEncoding >          <maven.compiler.source > 11</maven.compiler.source >          <maven.compiler.target > 11</maven.compiler.target >          <java.version > 11</java.version >          <tomcat.version > 9.0.26</tomcat.version >      </properties >      <dependencies >          <dependency >              <groupId > org.apache.tomcat.embed</groupId >              <artifactId > tomcat-embed-core</artifactId >              <version > ${tomcat.version}</version >              <scope > provided</scope >          </dependency >          <dependency >              <groupId > org.apache.tomcat.embed</groupId >              <artifactId > tomcat-embed-jasper</artifactId >              <version > ${tomcat.version}</version >              <scope > provided</scope >          </dependency >      </dependencies > </project > 
 
其中,<packaging>类型仍然为war,引入依赖tomcat-embed-core和tomcat-embed-jasper。不必再引入Servlet API,因为引入的Tomcat依赖启动后自动引入了Servlet API,我们正常编写Servlet如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 @WebServlet(urlPatterns = "/") public  class  HelloServlet  extends  HttpServlet   {     protected  void  doGet (HttpServletRequest req, HttpServletResponse resp)  throws  ServletException, IOException  {         resp.setContentType("text/html" );         String name = req.getParameter("name" );         if  (name == null ) {             name = "world" ;         }         PrintWriter pw = resp.getWriter();         pw.write("<h1>Hello, "  + name + "!</h1>" );         pw.flush();     } }
 
然后,我们编写一个main()方法,启动Tomcat服务器:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public  class  Main   {     public  static  void  main (String[] args)  throws  Exception  {                  Tomcat tomcat = new  Tomcat();         tomcat.setPort(Integer.getInteger("port" , 8080 ));         tomcat.getConnector();                  Context ctx = tomcat.addWebapp("" , new  File("src/main/webapp" ).getAbsolutePath());         WebResourceRoot resources = new  StandardRoot(ctx);         resources.addPreResources(                 new  DirResourceSet(resources, "/WEB-INF/classes" , new  File("target/classes" ).getAbsolutePath(), "/" ));         ctx.setResources(resources);         tomcat.start();         tomcat.getServer().await();     } }
 
这样,我们运行main()方法,即可启动嵌入式Tomcat服务器,然后,通过预设的tomcat.addWebapp("", new File("src/main/webapp")),Tomcat会自动加载当前工程为根webapp,可直接在浏览器访问http://localhost:8080/ 。
通过main()方法启动Tomcat服务器并加载我们自己的webapp有如下好处:
启动简单,无需下载Tomcat或安装任何IDE插件 
调试方便,可在IDE中使用断点调试 
使用Maven创建war包后,也可以正常部署到独立的Tomcar应用服务器 
 
对SpringBoot有所了解的童鞋可能知道,SpringBoot也支持在main()方法中一行代码直接启动Tomcat,并且还能方便地更换成Jetty等其他服务器。它的启动方式和我们介绍的是基本一样的,后续涉及到SpringBoot的部分我们还会详细讲解。
 
开发Servlet时,推荐使用main()方法启动嵌入式Tomcat服务器并加载当前工程的webapp,便于开发调试,且不影响打包部署,能提升开发效率。
Servlet进阶 一个Web App是由一个或多个Servlet组成,每个Servlet通过注解说明自己能处理的路径。比如:
1 2 3 4 5 6 7 8 @WebServlet(urlPatterns = "/hello") public  class  HelloServlet  extends  HttpServlet   {     @Override      protected  void  doGet (HttpServletRequest req, HttpServletResponse resp)  throws  ServletException, IOException  {         ...     }   ... }
 
上述HelloServlet能处理/hello这个路径的请求。早期的Servlet需要在web.xml中配置映射路径,但最新Servlet版本只需要通过注解就可以完成映射。
浏览器发送请求时,还会有请求方法(HTTP Method):即GET、POST、PUT等不同类型的请求,因此要处理GET请求必须覆写doGet()方法。类似地,要处理POST请求,就需要覆写doPost()方法。如果没有覆写doPost()方法,那么HelloServlet能不能处理POST/hello请求呢?
我们查看HttpServlet的doPost():它会直接返回405或400错误。因此,如果一个Servlet映射到/hello,那么这个路径下的所有请求方法都会由这个Servlet来处理,至于能不能返回200成功响应,要看有没有覆写对应的请求方法。
一个WebApp完全可以有多个Servlet,分别映射不同的路径。浏览器发送的HTTP请求总是由Web Server先接收,然后根据Servlet配置的映射,不同的路径转发到不同的Servlet。这种根据路径转发的功能一般称为Dispatch。映射到/的Servlet比较特殊,它会接收所有未匹配的路径。
一、HttpServletRequest封装了一个HTTP请求,通过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<String, Object>使用。调用HttpServletRequest的方法时,注意务必阅读接口方法的文档说明,因为有的方法会返回null。
二、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服务器无法复用此连接。
有了HttpServletRequest和HttpServletResponse这两个高级接口,我们就不需要直接处理HTTP协议,注意到具体的实现类是由各服务器提供的,而我们编写的Web应用程序只关心接口方法,不需要关心具体的实现子类。
三、一个Servlet类在服务器中只有一个实例,但对于每个HTTP请求,Web服务器会使用多线程执行请求。因此,一个Servlet的doGet()、doPost()等处理请求是多线程并发执行的。如果Servlet中定义了字段,要注意多线程并发访问的问题。
1 2 3 4 5 6 7 8 public  class  HelloServlet  extends  HttpServlet   {     private  Map<String, String> map = new  ConcurrentHashMap<>();     protected  void  doGet (HttpServletRequest req, HttpServletResponse resp)  throws  ServletException, IOException  {                  this .map.put(key, value);     } }
 
对于每个请求,Web服务器会创建唯一的HttpServletRequest和HttpServletResponse实例,因此,HttpServletRequest和HttpServletResponse实例只有在当前处理线程中有效,它们总是局部变量,不存在多线程共享的问题。
重定向与转发 Redirect 重定向是指浏览器请求一个URL时,浏览器返会一个重定向指令,告诉浏览器地址已经变了,请使用新的URL发送新的请求。
例如,我们已经编写了一个能处理/hello的HelloServlet,如果收到的路径为/hi,希望能重定向到/hello,可以再编写一个RedirectServlet:
1 2 3 4 5 6 7 8 9 10 @WebServlet(urlPatterns = "/hi") public  class  RedirectServlet  extends  HttpServlet   {     protected  void  doGet (HttpServletRequest req, HttpServletResponse resp)  throws  ServletException, IOException  {                  String name = req.getParameter("name" );         String redirectToUrl = "/hello"  + (name == null  ? ""  : "?name="  + name);                  resp.sendRedirect(redirectToUrl);     } }
 
使用浏览器发送GET /hi请求,RedirectServlet将处理此请求,RedirectServlet内部又发送了重定向响应,因此浏览器会收到如下响应:
1 2 HTTP/1.1 302 Found Location: /hello
 
当浏览器收到302响应时,它会立刻根据Location的提示发送一个新的GET /hello请求,这个过程就是重定向。观察Chrome浏览器的网络请求,可以看到两次HTTP请求。
重定向有两种:一种是302响应,称为临时重定向;一种是301响应,称为永久重定向。两者的区别是,如果服务器发301永久重定向响应,浏览器会缓存/hi到/hello这个重定向的关联,下次请求/hi的时候,浏览器就直接发送/hello请求。
重定向有什么作用?重定向的目的是当Web应用升级时,如果请求路径发生了变化,可以将原来的路径重定向到新路径,避免浏览器请求原路径找不到资源。
HttpServletResponse提供了快捷的redirect()方法实现302重定向。如果要实现301永久重定向,可以这么写:
1 2 resp.setStatus(HttpServletResponse.SC_MOVED_PERMANENTLY);  resp.setHeader("Location" , "/hello" );
 
Forward Forward是指内部转发。当一个Servlet处理请求时,它可以自己决定不处理,而是转发给另一个Servlet处理。
例如,我们编写一个能处理/hello的HelloServlet,继续编写一个能处理/morning的ForwardServlet:
1 2 3 4 5 6 @WebServlet(urlPatterns = "/morning") public  class  ForwardServlet  extends  HttpServlet   {     protected  void  doGet (HttpServletRequest req, HttpServletResponse resp)  throws  ServletException, IOException  {         req.getRequestDispatcher("/hello" ).forward(req, resp);     } }
 
ForwardServlet在收到请求后,它并不自己发送响应,而是把请求和响应都转发给路径为/hello的Servlet。后续请求的处理实际上是由HelloServlet完成的。这种处理方式称为转发(Forward)。
转发和重定向的区别在于,转发是在Web服务器内部完成的,对浏览器来说它只发送了一个HTTP请求,浏览器并不知道请求在Web服务器内部做了一次转发。
使用Session和Cookie 在Web应用程序中,我们经常需要跟踪用户身份。当一个用户登录成功后,如果用户继续访问其他页面,Web程序如何才能识别用户身份?而HTTP协议是无状态的,即Web应用程序无法区分收到的两个HTTP请求是否是同一个浏览器发出的。为了跟踪用户状态,服务器可以向浏览器分配一个唯一ID,并以Cookie的形式发送到浏览器,浏览器在后续访问时总附带此Cookie,这样,服务器就可以识别用户身份。
Session 我们把这种基于唯一ID识别用户身份的机制称为Session。每个用户第一次访问服务器后,会自动获得一个Session ID。如果用户一段时间内没有访问服务器,那么Session就会自动失效,下次即使带着上次分配的Session ID访问,服务器也会认为是一个新用户,会分配新的Session ID。
JavaEE的Servlet机制内建了对Session的支持。我们以登录为例,当一个用户登录成功后,我们就可以把这个用户的名字放入一个HttpSession对象,以便后续访问其他页面时,能直接从HttpSession取出用户名。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 @WebServlet(urlPatterns = "/signin") public  class  SignInServlet  extends  HttpServlet   {          private  Map<String, String> users = Map.of("bob" , "bob123" , "alice" , "alice123" , "tom" , "tomcat" );          protected  void  doGet (HttpServletRequest req, HttpServletResponse resp)  throws  ServletException, IOException  {         resp.setContentType("text/html" );         PrintWriter pw = resp.getWriter();         pw.write("<h1>Sign In</h1>" );         pw.write("<form action=\"/signin\" method=\"post\">" );         pw.write("<p>Username: <input name=\"username\"></p>" );         pw.write("<p>Password: <input name=\"password\" type=\"password\"></p>" );         pw.write("<p><button type=\"submit\">Sign In</button> <a href=\"/\">Cancel</a></p>" );         pw.write("</form>" );         pw.flush();     }          protected  void  doPost (HttpServletRequest req, HttpServletResponse resp)  throws  ServletException, IOException  {         String name = req.getParameter("username" );         String password = req.getParameter("password" );         String expectedPassword = users.get(name.toLowerCase());         if  (expectedPassword != null  && expectedPassword.equals(password)) {                          req.getSession().setAttribute("user" , name);             resp.sendRedirect("/" );         } else  {             resp.sendError(HttpServletResponse.SC_FORBIDDEN);         }     } }
 
在IndexServlet中,可以从HttpSession取出用户名:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @WebServlet(urlPatterns = "/") public  class  IndexServlet  extends  HttpServlet   {     protected  void  doGet (HttpServletRequest req, HttpServletResponse resp)  throws  ServletException, IOException  {                  String user = (String) req.getSession().getAttribute("user" );         resp.setContentType("text/html" );         resp.setCharacterEncoding("UTF-8" );         resp.setHeader("X-Powered-By" , "JavaEE Servlet" );         PrintWriter pw = resp.getWriter();         pw.write("<h1>Welcome, "  + (user != null  ? user : "Guest" ) + "</h1>" );         if  (user == null ) {                          pw.write("<p><a href=\"/signin\">Sign In</a></p>" );         } else  {                          pw.write("<p><a href=\"/signout\">Sign Out</a></p>" );         }         pw.flush();     } }
 
如果用户已登录,可以通过访问/signout登出。登出逻辑就是从HttpSession中移除用户相关信息:
1 2 3 4 5 6 7 8 @WebServlet(urlPatterns = "/signout") public  class  SignOutServlet  extends  HttpServlet   {     protected  void  doGet (HttpServletRequest req, HttpServletResponse resp)  throws  ServletException, IOException  {                  req.getSession().removeAttribute("user" );         resp.sendRedirect("/" );     } }
 
对于Web应用程序来说,我们总是通过HttpSession这个高级接口访问当前Session。要深入理解Session原理,可以任务Web服务器在内存中自动维护了一个ID到HttpSession的映射表。而服务器识别Session的关键是依靠一个名为JSESSIONID的Cookie。在Servlet中第一次调用req.getSession()时,Servlet容器自动创建一个Session ID,然后通过一个名为JSESSIONID的Cookie发送给浏览器。
这里要注意的几点是:
JSESSIONID是由Servlet容器自动创建的,目的是维护一个浏览器回话,它和我们的登录逻辑没有关系 
登录和登出的业务逻辑是我们自己根据HttpSession是否存在一个“user”的Key判断的,登出后,Session ID并不会改变 
即使没有登录功能,仍然可以使用HttpSession追踪用户,例如,放入一些用户配置信息 
 
除了使用Cookie机制实现Session之外,还可以通过隐藏表单、URL末尾附加ID来追踪Session。这些机制很少使用,最常用的Session机制仍然是Cookie。
使用Session时,由于服务器把所有用户的Session都存储在内存中,如果遇到内存不足的情况,就需要把部分不活动的Session序列化到磁盘上,这会大大降低服务器的运行效率。因此,放入Session的对象要小,通常我们放入一个简单的User对象就够了。
1 2 3 4 5 public  class  User   {     public  long  id;      public  String email;     public  String name; }
 
在使用多台服务器构建集群时,使用Session会遇到一些额外的问题。通常,多台服务器集群使用反向代理作为网站入口。
1 2 3 4 5 6 7 8 9                                      ┌────────────┐                                 ┌───>│Web Server 1│                                 │    └────────────┘ ┌───────┐     ┌─────────────┐   │    ┌────────────┐ │Browser│────>│Reverse Proxy│───┼───>│Web Server 2│ └───────┘     └─────────────┘   │    └────────────┘                                 │    ┌────────────┐                                 └───>│Web Server 3│                                      └────────────┘
 
如果多台Web Server采用无状态集群,那么反向代理总是以轮询的方式将请求依此转发给每台Web Server,这回造成一个用户在Web Server1存储的Session信息,在Web Server2和3上并不存在。即从Web Server1登录后,如果后续请求被转发到Web Server2和3,那么依然会是无登录状态。
要解决这个问题,方案一是在所有Web Server之间进行Session复制,但这样会严重消耗网络带宽。并且,每台Web Server的内存都存储了所有用户的Session,内存使用率很低。
另一个方案是采用粘滞会话(Sticky Session)机制,即反向代理在转发请求时,总是根据JSESSIONID的值判断,相同的JSESSIONID总是转发到固定的Web Server,但这需要反向代理的支持。
无论采用哪种方案,使用Session机制会使得Web Server的集群很难扩展,因此,Session适用于中小型Web应用程序。对于大型Web应用程序来说,通常需要避免采用Session机制。
Cookie 实际上,Servlet提供的HttpSession本质上就是通过一个名为JSESSIONID的Cookie来跟踪用户会话的。除了这个名称外,其他名称的Cookie我们可以任意使用。
如果我们想要设置一个Cookie,例如记录用户选择的语言,可以编写一个LanguageServlet:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @WebServlet(urlPatterns = "/pref") public  class  LanguageServlet  extends  HttpServlet   {     private  static  final  Set<String> LANGUAGES = Set.of("en" , "zh" );     protected  void  doGet (HttpServletRequest req, HttpServletResponse resp)  throws  ServletException, IOException  {         String lang = req.getParameter("lang" );         if  (LANGUAGES.contains(lang)) {                          Cookie cookie = new  Cookie("lang" , lang);                          cookie.setPath("/" );                          cookie.setMaxAge(8640000 );                           resp.addCookie(cookie);         }         resp.sendRedirect("/" );     } }
 
创建一个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。
如果我们要读取Cookie,例如,在IndexServlet中,读取名为lang的Cookie以获取用户设置的语言,可以写一个方法如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 private  String parseLanguageFromCookie (HttpServletRequest req)   {          Cookie[] cookies = req.getCookies();          if  (cookies != null ) {                  for  (Cookie cookie : cookies) {                          if  (cookie.getName().equals("lang" )) {                                  return  cookie.getValue();             }         }     }          return  "en" ; }
 
可见,读取Cookie主要依靠遍历HttpServletRequest附带的所有Cookie。
JSP开发 从前面的章节中可以看到,Servlet就是一个能处理HTTP请求、发送HTTP响应的小程序,而发送响应无非就是获取PrintWriter,然后输出HTML。
1 2 3 4 5 6 7 PrintWriter pw = resp.getWriter(); pw.write("<html>" ); pw.write("<body>" ); pw.write("<h1>Welcome, "  + name + "!</h1>" ); pw.write("</body>" ); pw.write("</html>" ); pw.flush();
 
只不过,用PrintWriter输出HTML比较痛苦,因为不但要正确编写HTML,还需要插入各种变量。如果想在Servlet中输出一个类似新浪首页的HTML,写对HTML基本不可能。那么有没有更简单的输出HTML的方法?
有!
我们可以使用JSP,JSP是Java Server Pages的缩写,它的文件必须放到/src/main/webapp下,文件名必须以.jsp结尾,整个文件与HTML无太大差别。但需要插入变量,或者动态输出的地方,使用特殊指令<%...%>。我们来编写一个hello.jsp,内容如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 <html> <head>     <title>Hello World - JSP</title> </head> <body>     <%-- JSP Comment --%>     <h1>Hello World!</h1>     <p>     <%          out.println("Your IP address is " );     %>     <span style="color:red" >         <%= request.getRemoteAddr() %>     </span>     </p> </body> </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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 package  org.apache.jsp;import  ...public  final  class  hello_jsp  extends  org .apache .jasper .runtime .HttpJspBase     implements  org .apache .jasper .runtime .JspSourceDependent ,                org .apache .jasper .runtime .JspSourceImports   {     ...     public  void  _jspService (final  javax.servlet.http.HttpServletRequest request, final  javax.servlet.http.HttpServletResponse response)         throws  java.io.IOException, javax.servlet.ServletException  {         ...         out.write("<html>\n" );         out.write("<head>\n" );         out.write("    <title>Hello World - JSP</title>\n" );         out.write("</head>\n" );         out.write("<body>\n" );         ...     }     ... }
 
可见JSP本质上就是一个Servlet,只不过无需配置映射路径,Web Server会根据路径查找对应的.jsp文件,如果找到了,就自动编译成Servlet再执行。在服务器运行过程中,如果修改了JSP的内容,那么服务器会自动重新编译。
JSP高级功能 JSP的指令非常复杂,除了<% ... %>外,JSP页面本身可以通过page指令引入Java类:
1 2 <%@ page import ="java.io.*"  %> <%@ page import ="java.util.*"  %>
 
这样后续的Java代码才能引用简单类名而不是完整类名。
使用include指令可以引入另一个JSP文件:
1 2 3 4 5 6 <html> <body>     <%@ include file="header.jsp" %>     <h1>Index Page</h1>     <%@ include file="footer.jsp" %> </body>
 
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目前已很少使用,我们只需要了解其基本用法即可。
MVC开发 通过前面的章节可以看到:
Servlet适合编写Java代码,实现各种复杂的业务逻辑,但不适合输出复杂的HTML 
JSP适合编写HTML,并在其中插入动态内容,但不适合编写复杂的Java代码 
 
能否将两者结合起来,发挥各自的优点?答案是肯定的,这就是MVC,MVC模式是一种分离业务逻辑和显示逻辑的设计模式,广泛应用在Web和桌面应用程序。我们来看一个例子。
假设我们已经编写了几个JavaBean:
1 2 3 4 5 6 7 8 9 10 public  class  User   {     public  long  id;     public  String name;     public  School school; }public  class  School   {     public  String name;     public  String address; }
 
在UserServlet中,我们可以从数据库读取User、School等信息,然后,把读取到的JavaBean先放到HttpServletRequest中,再通过forward()传给user.jsp处理:
1 2 3 4 5 6 7 8 9 10 11 12 @WebServlet(urlPatterns = "/user") public  class  UserServlet  extends  HttpServlet   {     protected  void  doGet (HttpServletRequest req, HttpServletResponse resp)  throws  ServletException, IOException  {                  School school = new  School("No.1 Middle School" , "101 South Street" );         User user = new  User(123 , "Bob" , school);                  req.setAttribute("user" , user);                  req.getRequestDispatcher("/WEB-INF/user.jsp" ).forward(req, resp);     } }
 
在user.jsp中,我们只负责展示相关JavaBean的信息,不需要编写访问数据库等复杂逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 <%@ page import ="com.itranswarp.learnjava.bean.*" %> <%     User user = (User) request.getAttribute("user" ); %> <html> <head>     <title>Hello World - JSP</title> </head> <body>     <h1>Hello <%= user.name %>!</h1>     <p>School Name:     <span style="color:red" >         <%= user.school.name %>     </span>     </p>     <p>School Address:     <span style="color:red" >         <%= user.school.address %>     </span>     </p> </body> </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。即User作为模型Model,user.jsp作为视图View,UserServlet作为控制器Controller。整个MVC架构如下:
1 2 3 4 5 6 7 8 9 10 11 12                    ┌───────────────────────┐              ┌────>│Controller: UserServlet│              │     └───────────────────────┘              │                 │ ┌───────┐    │           ┌─────┴─────┐ │Browser│────┘           │Model: User│ │       │<───┐           └─────┬─────┘ └───────┘    │                 │              │                 ▼              │     ┌───────────────────────┐              └─────│    View: user.jsp     │                    └───────────────────────┘
 
使用MVC模式的好处是,Controller专注于业务处理,它的处理结果是Model。Model可以是一个JavaBean,也可以是一个包含多个对象的Map,Controller只负责把Model传递给View,View只负责把Model给渲染出来。这样,三者职责明确且开发更简单。但它还不够简洁和灵活,后续我们会介绍更简单的Spring MVC开发。
MVC高级开发 通过结合Servlet和JSP的MVC模式,我们可以发挥二者各自的优点。但是,直接把MVC搭在Servlet和JSP上还是不太好,因为:
Servlet提供的接口仍然偏底层,需要实现Servlet调用相关的接口 
JSP对页面开发不太友好,更好的替代品是模板引擎 
业务逻辑最好用纯粹的Java类实现,而不是强迫继承自Servlet 
 
能不能通过普通的Java类实现MVC的Controller?类似下面的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public  class  UserController   {     @GetMapping("/signin")      public  ModelAndView signin ()   {         ...     }     @PostMapping("/signin")      public  ModelAndView doSignin (SignInBean bean)   {         ...     }     @GetMapping("/signout")      public  ModelAndView signout (HttpSession session)   {         ...     } }
 
上面的这个Java类每个方法都对应一个GET或POST请求,方法返回值是ModelAndView,它包含一个View的路径以及一个Model,这样,再由MVC框架处理后返回给浏览器。
如果是GET请求,我们希望MVC框架能直接把URL参数按方法参数对应起来然后传入:
1 2 3 4 @GetMapping("/hello") public  ModelAndView hello (String name)   {     ... }
 
如果是POST请求,我们希望MVC框架能直接把Post参数变成一个JavaBean后通过方法参数传入:
1 2 3 4 @PostMapping("/signin") public  ModelAndView doSignin (SignInBean bean)   {     ... }
 
为了增加灵活性,如果Controller方法在处理请求时需要访问HttpServletRequest、HttpServletResponse、HttpSession这些实例时,只要方法参数有定义,就可以自动传入:
1 2 3 4 @GetMapping("/signout") public  ModelAndView signout (HttpSession session)   {     ... }
 
设计MVC框架 如何设计一个MVC框架?在上文中,我们已经定义了上层代码编写Controller的一切接口信息,并且并不需要实现特定接口,只需返回ModelAndView对象,该对象包含一个View和Model。实际上View就是模板的路径,而Model可以用一个Map<String, Object>表示,因此ModelAndView的定义非常简单:
1 2 3 4 public  class  ModelAndView   {     Map<String, Object> model;     String view; }
 
比较复杂的是我们需要在MVC框架创建一个接收所有请求的Servlet,通常我们把它命名为DispatcherServlet,它总是映射到/。然后,根据不同的Controller的方法定义的@Get和@Post的Path决定调用哪个方法。最后,获得方法返回的ModelAndView后,渲染模板,写入HttpServletResponse,即完成了整个MVC的处理。
这个MVC的框架如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15    HTTP Request    ┌─────────────────┐ ──────────────────>│DispatcherServlet│                    └─────────────────┘                             │                ┌────────────┼────────────┐                ▼            ▼            ▼          ┌───────────┐┌───────────┐┌───────────┐          │Controller1││Controller2││Controller3│          └───────────┘└───────────┘└───────────┘                │            │            │                └────────────┼────────────┘                             ▼    HTTP Response ┌────────────────────┐ <────────────────│render(ModelAndView)│                  └────────────────────┘
 
其中,DispatcherServlet以及如何渲染均由MVC框架实现,在MVC框架之上只需要编写每一个Controller。
我们来看看如何编写最复杂的DispatcherServlet。首先,我们需要存储请求路径到某个具体方法的映射:
1 2 3 4 5 @WebServlet(urlPatterns = "/") public  class  DispatcherServlet  extends  HttpServlet   {     private  Map<String, GetDispatcher> getMappings = new  HashMap<>();     private  Map<String, PostDispatcher> postMappings = new  HashMap<>(); }
 
处理一个GET请求是通过GetDispather对象完成的,它需要如下信息:
1 2 3 4 5 6 class  GetDispatcher   {     Object instance;      Method method;      String[] parameterNames;      Class<?>[] parameterClasses;  }
 
有了以上信息,就可以定义invoke()来处理真正的请求:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 class  GetDispatcher   {     ...     public  ModelAndView invoke (HttpServletRequest request, HttpServletResponse response)   {         Object[] arguments = new  Object[parameterClasses.length];         for  (int  i = 0 ; i < parameterClasses.length; i++) {             String parameterName = parameterNames[i];             Class<?> parameterClass = parameterClasses[i];             if  (parameterClass == HttpServletRequest.class) {                 arguments[i] = request;             } else  if  (parameterClass == HttpServletResponse.class) {                 arguments[i] = response;             } else  if  (parameterClass == HttpSession.class) {                 arguments[i] = request.getSession();             } else  if  (parameterClass == int .class) {                 arguments[i] = Integer.valueOf(getOrDefault(request, parameterName, "0" ));             } else  if  (parameterClass == long .class) {                 arguments[i] = Long.valueOf(getOrDefault(request, parameterName, "0" ));             } else  if  (parameterClass == boolean .class) {                 arguments[i] = Boolean.valueOf(getOrDefault(request, parameterName, "false" ));             } else  if  (parameterClass == String.class) {                 arguments[i] = getOrDefault(request, parameterName, "" );             } else  {                 throw  new  RuntimeException("Missing handler for type: "  + parameterClass);             }         }         return  (ModelAndView) this .method.invoke(this .instance, arguments);     }     private  String getOrDefault (HttpServletRequest request, String name, String defaultValue)   {         String s = request.getParameter(name);         return  s == null  ? defaultValue : s;     } }
 
上述代码比较繁琐,但逻辑非常简单,即通过构造某个方法需要的所有参数列表,使用反射调用该方法返回结果。类似地,PostDispatcher需要如下信息:
1 2 3 4 5 6 class  PostDispatcher   {     Object instance;      Method method;      Class<?>[] parameterClasses;      ObjectMapper objectMapper;  }
 
和GET请求不同,POST请求严格来说不能有URL参数,所有数据都应当从Post Body中读取。这里我们为了简化处理,只支持 JSON格式的POST请求,这样,把Post数据转化为JavaBean就非常容易。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 class  PostDispatcher   {     ...     public  ModelAndView invoke (HttpServletRequest request, HttpServletResponse response)   {         Object[] arguments = new  Object[parameterClasses.length];         for  (int  i = 0 ; i < parameterClasses.length; i++) {             Class<?> parameterClass = parameterClasses[i];             if  (parameterClass == HttpServletRequest.class) {                 arguments[i] = request;             } else  if  (parameterClass == HttpServletResponse.class) {                 arguments[i] = response;             } else  if  (parameterClass == HttpSession.class) {                 arguments[i] = request.getSession();             } else  {                                  BufferedReader reader = request.getReader();                 arguments[i] = this .objectMapper.readValue(reader, parameterClass);             }         }         return  (ModelAndView) this .method.invoke(instance, arguments);     } }
 
最后,我们来实现整个DispatcherServlet的处理流程,以doGet()为例。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 public  class  DispatcherServlet  extends  HttpServlet   {     ...     @Override      protected  void  doGet (HttpServletRequest req, HttpServletResponse resp)  throws  ServletException, IOException  {         resp.setContentType("text/html" );         resp.setCharacterEncoding("UTF-8" );         String path = req.getRequestURI().substring(req.getContextPath().length());                  GetDispatcher dispatcher = this .getMappings.get(path);         if  (dispatcher == null ) {                          resp.sendError(404 );             return ;         }                  ModelAndView mv = dispatcher.invoke(req, resp);                  if  (mv == null ) {             return ;         }                  if  (mv.view.startsWith("redirect:" )) {             resp.sendRedirect(mv.view.substring(9 ));             return ;         }                  PrintWriter pw = resp.getWriter();         this .viewEngine.render(mv, pw);         pw.flush();     } }
 
这里有几个小改进:
允许Controller方法返回null,表示内部已自行处理完毕 
允许Controller方法返回以redirect:开头的view名称,表示一个重定向 
 
这样使得上层代码编写更灵活,例如,一个显示用户资料的请求可以这样写:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @GetMapping("/user/profile") public  ModelAndView profile (HttpServletResponse response, HttpSession session)   {     User user = (User) session.getAttribute("user" );     if  (user == null ) {                  return  new  ModelAndView("redirect:/signin" );     }     if  (!user.isManager()) {                  response.sendError(403 );         return  null ;     }     return  new  ModelAndView("/profile.html" , Map.of("user" , user)); }
 
最后一步是在DispatcherServlet的init()方法中初始化所有Get和Post的映射,以及用于渲染的模板引擎:
1 2 3 4 5 6 7 8 9 10 11 12 13 public  class  DispatcherServlet  extends  HttpServlet   {     private  Map<String, GetDispatcher> getMappings = new  HashMap<>();     private  Map<String, PostDispatcher> postMappings = new  HashMap<>();     private  ViewEngine viewEngine;     @Override      public  void  init ()  throws  ServletException  {         this .getMappings = scanGetInControllers();         this .postMappings = scanPostInControllers();         this .viewEngine = new  ViewEngine(getServletContext());     }     ... }
 
如何扫描所有Controller以获取所有标记有@GetMapping和@PostMapping的方法?当然是使用反射了。虽然代码比较繁琐,但我们相信各位童鞋可以轻松实现。
这样,整个MVC框架就搭建完毕。
实现渲染 有的童鞋对如何使用模板引擎进行渲染有疑问,即如何实现上述的ViewEngine?其实ViewEngine非常简单,只需要实现一个简单的render()方法:
1 2 3 4 5 6 7 8 9 10 public  class  ViewEngine   {     public  void  render (ModelAndView mv, Writer writer)  throws  IOException  {         String view = mv.view;         Map<String, Object> model = mv.model;                  Template template = getTemplateByPath(view);                  template.write(writer, model);     } }
 
Java有很多开源的模板引擎,常用的有Thymeleaf,FreeMarker,Velocity。它们的用法都大同小异,这里我们推荐一个使用Jinja 语法的模板引擎Pebble ,它的特点是语法简单,支持模板继承,编写出来的模板类似:
1 2 3 4 5 6 7 8 9 <html > <body >    <ul >    {% for user in users %}     <li > <a  href ="{{ user.url }}" > {{ user.username }}</a > </li >    {% endfor %}   </ul > </body > </html > 
 
即变量用{{ xxx }}表示,控制语句用{% xxx %}表示。
使用Pebble渲染只需要如下几行代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 public  class  ViewEngine   {     private  final  PebbleEngine engine;     public  ViewEngine (ServletContext servletContext)   {                  ServletLoader loader = new  ServletLoader(servletContext);                  loader.setCharset("UTF-8" );                  loader.setPrefix("/WEB-INF/templates" );                  loader.setSuffix("" );                  this .engine = new  PebbleEngine.Builder()             .autoEscaping(true )              .cacheActive(false )              .loader(loader).build();     }     public  void  render (ModelAndView mv, Writer writer)  throws  IOException  {                  PebbleTemplate template = this .engine.getTemplate(mv.view);                  template.evaluate(writer, mv.model);     } }
 
最后我们来看看整个工程的结构:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 web-mvc ├── pom.xml └── src     └── main         ├── java         │   └── com         │       └── itranswarp         │           └── learnjava         │               ├── Main.java         │               ├── bean         │               │   ├── SignInBean.java         │               │   └── User.java         │               ├── controller         │               │   ├── IndexController.java         │               │   └── UserController.java         │               └── framework         │                   ├── DispatcherServlet.java         │                   ├── FileServlet.java         │                   ├── GetMapping.java         │                   ├── ModelAndView.java         │                   ├── PostMapping.java         │                   └── ViewEngine.java         └── webapp             ├── WEB-INF             │   ├── templates             │   │   ├── _base.html             │   │   ├── hello.html             │   │   ├── index.html             │   │   ├── profile.html             │   │   └── signin.html             │   └── web.xml             └── static                 ├── css                 │   └── bootstrap.css                 └── js                     ├── bootstrap.js                     └── jquery.js
 
其中,framework包是MVC的框架,完全可以单独编译后作为一个Maven依赖引入,controller包才是我们需要编写的业务逻辑。
我们还硬性规定模板必须放在webapp/WEB-INF/templates目录下,静态文件必须放在webapp/static目录下,因此,为了便于开发,我们还顺带实现一个FileServlet来处理静态文件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 @WebServlet(urlPatterns = { "/favicon.ico", "/static/*" }) public  class  FileServlet  extends  HttpServlet   {     protected  void  doGet (HttpServletRequest req, HttpServletResponse resp)  throws  ServletException, IOException  {                  ServletContext ctx = req.getServletContext();                  String urlPath = req.getRequestURI().substring(ctx.getContextPath().length());                  String filepath = ctx.getRealPath(urlPath);         if  (filepath == null ) {                          resp.sendError(HttpServletResponse.SC_NOT_FOUND);             return ;         }         Path path = Paths.get(filepath);         if  (!path.toFile().isFile()) {                          resp.sendError(HttpServletResponse.SC_NOT_FOUND);             return ;         }                  String mime = Files.probeContentType(path);         if  (mime == null ) {             mime = "application/octet-stream" ;         }         resp.setContentType(mime);                  OutputStream output = resp.getOutputStream();         try  (InputStream input = new  BufferedInputStream(new  FileInputStream(filepath))) {             input.transferTo(output);         }         output.flush();     } }
 
运行代码,在浏览器中输入URLhttp://localhost:8080/hello?name=Bob即可看到渲染出来的页面。
有些用过Spring MVC的童鞋会发现,本节实现的这个MVC框架,上层代码使用的公共类如GetMapping、PostMapping和ModelAndView都和Spring MVC非常类似。实际上,我们这个MVC框架主要参考就是Spring MVC,通过实现一个“简化版”MVC,可以掌握Java Web MVC开发的核心思想与原理,对将来直接使用Spring MVC是非常有帮助的。
小结 一个MVC框架是基于Servlet基础抽象出更高级的接口,使得上层基于MVC框架的开发可以不涉及Servlet相关的HttpServletRequest等接口,处理多个请求更加灵活,并且可以使用任意模板引擎,不必使用JSP。
使用Filter 在一个比较复杂的Web应用程序中,通常都有很多URL映射,对应的,也会有多个Servlet来处理URL。我们考察这样一个论坛应用程序:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20             ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐                /             ┌──────────────┐             │ ┌─────────────>│ IndexServlet │ │               │              └──────────────┘             │ │/signin       ┌──────────────┐ │               ├─────────────>│SignInServlet │             │ │              └──────────────┘ │               │/signout      ┌──────────────┐ ┌───────┐   │ ├─────────────>│SignOutServlet│ │ │Browser├─────┤              └──────────────┘ └───────┘   │ │/user/profile ┌──────────────┐ │               ├─────────────>│ProfileServlet│             │ │              └──────────────┘ │               │/user/post    ┌──────────────┐             │ ├─────────────>│ PostServlet  │ │               │              └──────────────┘             │ │/user/reply   ┌──────────────┐ │               └─────────────>│ ReplyServlet │             │                └──────────────┘ │              ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
 
各个Servlet设计功能如下:
IndexServlet:浏览帖子 
SignInServlet:登录 
SignOutServlet:登出 
ProfileServlet:修改用户资料 
PostServlet:发帖 
ReplyServlet:回复 
 
其中ProfileServlet、PostServlet和ReplyServlet都需要用户登录后才能操作,否则应当直接跳转到登录页面。我们可以直接把登录逻辑写到这3个Servlet中,但是同样的逻辑重复3次没有必要,并且,如果后续继续加Servlet并且也需要验证登录时,还继续要重复这个登录逻辑。
为了把一些公用逻辑从各个Servlet中抽离出来,JavaEE的Servlet规范还提供了一种Filter组件,即过滤器。它的作用是在HTTP请求到达Servlet之前,可以被一个或多个Filter处理。类似打印日志、登录检查等逻辑,完全可以放到Filter中。
例如,我们编写一个最简单的EncodingFilter,它强制把输入和输出的编码设置为UTF-8:
1 2 3 4 5 6 7 8 9 10 @WebFilter(urlPatterns = "/*") public  class  EncodingFilter  implements  Filter   {     public  void  doFilter (ServletRequest request, ServletResponse response, FilterChain chain)             throws  IOException, ServletException  {         System.out.println("EncodingFilter:doFilter" );         request.setCharacterEncoding("UTF-8" );         response.setCharacterEncoding("UTF-8" );         chain.doFilter(request, response);     } }
 
编写Filter时,必须实现Filter接口,在doFilter()方法内部,要继续处理请求,必须调用chain.doFilter()。最后,用@WebFilter注解标注该Filter需要过滤的URL,这里的/*表示所有路径。
添加了Filter后,整个请求的处理架构如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20             ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐                                    /             ┌──────────────┐             │                     ┌─────────────>│ IndexServlet │ │                                   │              └──────────────┘             │                     │/signin       ┌──────────────┐ │                                   ├─────────────>│SignInServlet │             │                     │              └──────────────┘ │                                   │/signout      ┌──────────────┐ ┌───────┐   │   ┌──────────────┐  ├─────────────>│SignOutServlet│ │ │Browser│──────>│EncodingFilter├──┤              └──────────────┘ └───────┘   │   └──────────────┘  │/user/profile ┌──────────────┐ │                                   ├─────────────>│ProfileServlet│             │                     │              └──────────────┘ │                                   │/user/post    ┌──────────────┐             │                     ├─────────────>│ PostServlet  │ │                                   │              └──────────────┘             │                     │/user/reply   ┌──────────────┐ │                                   └─────────────>│ ReplyServlet │             │                                    └──────────────┘ │              ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
 
还可以继续添加其他Filter,例如LogFilter:
1 2 3 4 5 6 7 8 @WebFilter("/*") public  class  LogFilter  implements  Filter   {     public  void  doFilter (ServletRequest request, ServletResponse response, FilterChain chain)             throws  IOException, ServletException  {         System.out.println("LogFilter: process "  + ((HttpServletRequest) request).getRequestURI());         chain.doFilter(request, response);     } }
 
多个Filter会组成一个链,每个请求都被链上的Filter依次处理:
1 2 3 4 5 6 7 8 9                                         ┌────────┐                                      ┌─>│ServletA│                                      │  └────────┘     ┌──────────────┐    ┌─────────┐  │  ┌────────┐ ───>│EncodingFilter│───>│LogFilter│──┼─>│ServletB│     └──────────────┘    └─────────┘  │  └────────┘                                      │  ┌────────┐                                      └─>│ServletC│                                         └────────┘
 
那么有多个Filter时,Filter的顺序如何指定?多个Filter按不同的顺序处理会造成处理结果不同吗?答案是Filter的顺序确实对处理的结果有影响,但遗憾的是,Servlet规范并没有对@WebFilter注解标注的Filter规定顺序。如果一定要给每个Filter指定顺序,就必须在web.xml中对这些Filter再配置一遍。
注意到上述两个Filter的过滤路径都是/*,即它们会对所有请求进行过滤。也可以编写只对特定路径进行过滤的Filter,例如AuthFilter:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @WebFilter("/user/*") public  class  AuthFilter  implements  Filter   {     public  void  doFilter (ServletRequest request, ServletResponse response, FilterChain chain)             throws  IOException, ServletException  {         System.out.println("AuthFilter: check authentication" );         HttpServletRequest req = (HttpServletRequest) request;         HttpServletResponse resp = (HttpServletResponse) response;         if  (req.getSession().getAttribute("user" ) == null ) {                          System.out.println("AuthFilter: not signin!" );             resp.sendRedirect("/signin" );         } else  {                          chain.doFilter(request, response);         }     } }
 
注意到AuthFilter只过滤以/user/开头的路径,因此:
如果一个请求路径类似/user/profile,那么它会被上述3个Filter依次处理 
如果一个请求路径类似/test,那么它会被上述2个Filter依次处理(不会被AuthFilter处理) 
 
再注意观察AuthFilter,当用户没有登录时,在AuthFilter内部,直接调用resp.sendRedirect()发送重定向,且没有调用chain.doFilter(),因此,当用户没有登录时,请求到达AuthFilter后,不再继续处理,即后续的Filter和任何Servlet都没有机会处理该请求了。
可见,Filter可以针对性地拦截或放行HTTP请求。
如果一个Filter在当前请求中生效,但什么都没有做:
1 2 3 4 5 6 7 @WebFilter("/*") public  class  MyFilter  implements  Filter   {     public  void  doFilter (ServletRequest request, ServletResponse response, FilterChain chain)             throws  IOException, ServletException  {              } }
 
那么,用户将看到一个空白页,因为请求没有继续处理,默认响应是200加空白输出。**如果Filter要使请求继续被处理,就一定要调用chain.doFilter()**。
总结一下,Filter是一种对HTTP请求进行预处理的组件,他可以构成一个处理链,使得公共代码能集中到一起。Filter适用于日志、登录检查、全局设置等。设计合理的URL映射可以让Filter链更清晰。
修改请求 Filter可以对请求进行预处理,因此,我们可以把很多公共预处理逻辑放到Filter中完成。考察这样一种需求:我们在Web应用中经常需要处理用户上传文件,例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 @WebServlet(urlPatterns = "/upload/file") public  class  UploadServlet  extends  HttpServlet   {     protected  void  doPost (HttpServletRequest req, HttpServletResponse resp)  throws  ServletException, IOException  {                  InputStream input = req.getInputStream();         ByteArrayOutputStream output = new  ByteArrayOutputStream();         byte [] buffer = new  byte [1024 ];         for  (;;) {             int  len = input.read(buffer);             if  (len == -1 ) {                 break ;             }             output.write(buffer, 0 , len);         }                           String uploadedText = output.toString(StandardCharsets.UTF_8);         PrintWriter pw = resp.getWriter();         pw.write("<h1>Uploaded:</h1>" );         pw.write("<pre><code>" );         pw.write(uploadedText);         pw.write("</code></pre>" );         pw.flush();     } }
 
要保证文件上传的一致性,我们在上传文件时,把文件的哈希也传上去,服务端做一个验证,就可以确保用户上传的文件一定是完整的。这个验证逻辑非常适合写在ValidateUploadFilter中,因为它可以复用,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 @WebFilter("/upload/*") public  class  ValidateUploadFilter  implements  Filter   {     @Override      public  void  doFilter (ServletRequest request, ServletResponse response, FilterChain chain)             throws  IOException, ServletException  {         HttpServletRequest req = (HttpServletRequest) request;         HttpServletResponse resp = (HttpServletResponse) response;                  String digest = req.getHeader("Signature-Method" );         String signature = req.getHeader("Signature" );         if  (digest == null  || digest.isEmpty() || signature == null  || signature.isEmpty()) {             sendErrorPage(resp, "Missing signature." );             return ;         }                  MessageDigest md = getMessageDigest(digest);         InputStream input = new  DigestInputStream(request.getInputStream(), md);         byte [] buffer = new  byte [1024 ];         for  (;;) {             int  len = input.read(buffer);             if  (len == -1 ) {                 break ;             }         }         String actual = toHexString(md.digest());         if  (!signature.equals(actual)) {             sendErrorPage(resp, "Invalid signature." );             return ;         }                  chain.doFilter(request, response);     }          private  String toHexString (byte [] digest)   {         StringBuilder sb = new  StringBuilder();         for  (byte  b : digest) {             sb.append(String.format("%02x" , b));         }         return  sb.toString();     }          private  MessageDigest getMessageDigest (String name)  throws  ServletException  {         try  {             return  MessageDigest.getInstance(name);         } catch  (NoSuchAlgorithmException e) {             throw  new  ServletException(e);         }     }          private  void  sendErrorPage (HttpServletResponse resp, String errorMessage)  throws  IOException  {         resp.setStatus(HttpServletResponse.SC_BAD_REQUEST);         PrintWriter pw = resp.getWriter();         pw.write("<html><body><h1>" );         pw.write(errorMessage);         pw.write("</h1></body></html>" );         pw.flush();     } }
 
ValidateUploadFilter对签名进行验证的逻辑是没有问题的,但是UploadServlet并为读取到任何数据。这是因为对HttpServletRequest进行读取时,只能读取一次。如果Filter调用getInputStream()读取了一次数据,后续Servlet处理时,再次读取将无法读到任何数据,咋么办?这时,我们需要“伪造”一个HttpServletRequest,具体做法是使用代理模式,对getInputStream()和getReader()返回一个新的流:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 class  ReReadableHttpServletRequest  extends  HttpServletRequestWrapper   {     private  byte [] body;     private  boolean  open = false ;     public  ReReadableHttpServletRequest (HttpServletRequest request, byte [] body)   {         super (request);         this .body = body;     }          public  ServletInputStream getInputStream ()  throws  IOException  {         if  (open) {             throw  new  IllegalStateException("Cannot re-open input stream!" );         }         open = true ;         return  new  ServletInputStream() {             private  int  offset = 0 ;             public  boolean  isFinished ()   {                 return  offset >= body.length;             }             public  boolean  isReady ()   {                 return  true ;             }             public  void  setReadListener (ReadListener listener)   {             }             public  int  read ()  throws  IOException  {                 if  (offset >= body.length) {                     return  -1 ;                 }                 int  n = body[offset] & 0xff ;                 offset++;                 return  n;             }         };     }          public  BufferedReader getReader ()  throws  IOException  {         if  (open) {             throw  new  IllegalStateException("Cannot re-open reader!" );         }         open = true ;         return  new  BufferedReader(new  InputStreamReader(new  ByteArrayInputStream(body), "UTF-8" ));     } }
 
注意观察ReReadableHttpServletRequest的构造方法,它保存了ValidateUploadFilter读取的byte[],并在调用getInputStream()时通过byte[]构造了一个新的ServletInputStream。然后,我们在ValidateUploadFilter中,把doFilter()调用时传给下一个处理者的HttpServletRequest替换成我们自己“伪造”的ReReadableHttpServletRequest。
1 2 3 4 5 public  void  doFilter (ServletRequest request, ServletResponse response, FilterChain chain)         throws  IOException, ServletException  {     ...     chain.doFilter(new  ReReadableHttpServletRequest(req, output.toByteArray()), response); }
 
再注意到我们编写ReReadableHttpServletRequest时,是从HttpServletRequestWrapper继承,而不是直接实现HttpServletRequest接口。这是因为,Servlet的每个新版本都会对接口增加一些新方法,从HttpServletRequestWrapper继承可以确保新方法被正确地覆写了,因为HttpServletRequestWrapper是由Servlet的jar包提供的,目的就是为了让我们方便地实现对HttpServletRequest接口的代理。
我们总结一下对HttpServletRequest接口进行代理的步骤:
从HttpServletRequestWrapper继承一个XxxHttpServletRequest,需要传入原始的HttpServletRequest实例; 
覆写某些方法,使得新的XxxHttpServletRequest实例看上去“改变”了原始的HttpServletRequest实例; 
在doFilter()中传入新的XxxHttpServletRequest实例。 
 
虽然整个Filter的代码比较复杂,但它的好处在于:这个Filter在整个处理链中实现了灵活的“可插拔”特性,即是否启用对Web应用程序的其他组件(Filter、Servlet)完全没有影响。
修改响应 我们来看一下什么时候需要修改HttpServletResponse。假设我们编写了一个Servlet,但由于业务逻辑比较复杂,处理该请求需要耗费很长时间。好消息是每次返回的响应内容是固定的,因此,如果我们能把结果缓存起来,就可以大大提高Web应用程序的运行效率。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @WebServlet(urlPatterns = "/slow/hello") public  class  HelloServlet  extends  HttpServlet   {     protected  void  doGet (HttpServletRequest req, HttpServletResponse resp)  throws  ServletException, IOException  {         resp.setContentType("text/html" );                  try  {             Thread.sleep(1000 );         } catch  (InterruptedException e) {         }         PrintWriter pw = resp.getWriter();         pw.write("<h1>Hello, world!</h1>" );         pw.flush();     } }
 
缓存逻辑最好不要在Servlet内部实现,因为我们希望能复用缓存逻辑,编写一个CacheFilter最合适。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 @WebFilter("/slow/*") public  class  CacheFilter  implements  Filter   {          private  Map<String, byte []> cache = new  ConcurrentHashMap<>();     public  void  doFilter (ServletRequest request, ServletResponse response, FilterChain chain)             throws  IOException, ServletException  {         HttpServletRequest req = (HttpServletRequest) request;         HttpServletResponse resp = (HttpServletResponse) response;                  String url = req.getRequestURI();                  byte [] data = this .cache.get(url);         resp.setHeader("X-Cache-Hit" , data == null  ? "No"  : "Yes" );         if  (data == null ) {                          CachedHttpServletResponse wrapper = new  CachedHttpServletResponse(resp);                          chain.doFilter(request, wrapper);                          data = wrapper.getContent();             cache.put(url, data);         }                  ServletOutputStream output = resp.getOutputStream();         output.write(data);         output.flush();     } }
 
实现缓存的关键在于,调用doFilter()时,我们不能传入原始的HttpServletResponse,因为这样就会写入Socket,我们也就无法获取下游组件写入的内容。如果我们传入的是“伪造”的HttpServletResponse,让下游组件写入到我们预设的ByteArrayOutputStream,我们就“截获”了下游组件写入的内容,于是,可以把内容缓存起来,再通过原始的HttpServletResponse实例写入到网络。
这个CachedHttpServletResponse实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 class  CachedHttpServletResponse  extends  HttpServletResponseWrapper   {     private  boolean  open = false ;     private  ByteArrayOutputStream output = new  ByteArrayOutputStream();     public  CachedHttpServletResponse (HttpServletResponse response)   {         super (response);     }          public  PrintWriter getWriter ()  throws  IOException  {         if  (open) {             throw  new  IllegalStateException("Cannot re-open writer!" );         }         open = true ;         return  new  PrintWriter(output, false , StandardCharsets.UTF_8);     }          public  ServletOutputStream getOutputStream ()  throws  IOException  {         if  (open) {             throw  new  IllegalStateException("Cannot re-open output stream!" );         }         open = true ;         return  new  ServletOutputStream() {             public  boolean  isReady ()   {                 return  true ;             }             public  void  setWriteListener (WriteListener listener)   {             }                          public  void  write (int  b)  throws  IOException  {                 output.write(b);             }         };     }          public  byte [] getContent() {         return  output.toByteArray();     } }
 
可见,如果我们想要修改响应,就可以通过HttpServletResponseWrapper构造一个“伪造”的HttpServletResponse,这样就能拦截到写入的数据。修改响应后,不要忘记把数据写入原始的HttpServletResponse实例。
这个CacheFilter同样是一个“可拔插”组件,它是否启用不影响Web应用程序的其他组件(Filter,Servlet)。
使用Listener 除了Servlet和Filter,JavaEE的Servlet规范还提供了第三种组件:Listener。Listener叫做监听器,有好几种Listener,其中最常用的是ServletContextListener,我们编写一个实现了ServletContextListener接口的类如下:
1 2 3 4 5 6 7 8 9 10 11 12 @WebListener public  class  AppListener  implements  ServletContextListener   {          public  void  contextInitialized (ServletContextEvent sce)   {         System.out.println("WebApp initialized." );     }          public  void  contextDestroyed (ServletContextEvent sce)   {         System.out.println("WebApp destroyed." );     } }
 
任何标注为@WebListener,且实现了特定接口的类,都会被Web服务器自动初始化。上述AppListener实现了ServletContextListener接口,它会在整个Web应用程序初始化完成后,以及Web应用程序关闭后获得回调通知。我们可以把初始化数据库连接池等工作放到contextInitialized()回调方法中,把清理资源的工作放到contextDestroyed()回调方法中,因为Web服务器保证在contextInitialized()执行后,才会接受用户的HTTP请求。
很多第三方Web框架都会通过一个ServletContextListener接口初始化自己。此外,还有几种Listener:
HttpSessionListener:监听HttpSession的创建和销毁事件 
ServletRequestListener:监听ServletRequest请求的创建和销毁事件 
ServletRequestAttributeListener:监听ServletRequest请求的属性变化事件(即调用ServletRequest.setAttribute()方法) 
ServletContextAttributeListener:监听ServletContext的属性变化事件(即调用ServletContext.setAttribute()方法) 
 
ServletContext 一个Web服务器可以运行一个或多个WebApp,对于每个WebApp,Web服务器都会为其创建一个全局唯一的ServletContext实例,我们在AppListener里面编写的两个回调方法,实际上对应的就是ServletContext的创建和销毁。
ServletRequest、HttpSession等很多对象也提供getServletContext()方法获取到同一个ServletContext实例,ServletContext是一个WebApp运行期的全局唯一实例。ServletContext实例最大的作用就是设置和共享全局信息。此外,ServletContext还提供了动态添加Servlet、Filter、Listener等功能,它允许应用程序在运行期间动态添加一个组件,虽然这个功能不是很常用。
部署 对一个Web应用程序来说,除了Servlet、Filter这些逻辑组件,还需要JSP这样的视图文件,外加一堆静态资源文件,如CSS、JS等。
合理组织文件结构非常重要。我们以一个具体的Web应用程序为例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 webapp ├── pom.xml └── src     └── main         ├── java         │   └── com         │       └── itranswarp         │           └── learnjava         │               ├── Main.java         │               ├── filter         │               │   └── EncodingFilter.java         │               └── servlet         │                   ├── FileServlet.java         │                   └── HelloServlet.java         ├── resources         └── webapp             ├── WEB-INF             │   └── web.xml             ├── favicon.ico             └── static                 └── bootstrap.css
 
我们把所有的静态资源文件放入/static/目录,在开发阶段,有些Web服务器会自动为我们加一个专门负责处理静态文件的Servlet,但如果IndexServlet映射路径为/,会屏蔽掉处理静态文件的Servlet映射。因此,我们需要自己编写一个处理静态文件的FileServlet:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 @WebServlet(urlPatterns = "/static/*") public  class  FileServlet  extends  HttpServlet   {     protected  void  doGet (HttpServletRequest req, HttpServletResponse resp)  throws  ServletException, IOException  {         ServletContext ctx = req.getServletContext();                  String urlPath = req.getRequestURI().substring(ctx.getContextPath().length());                  String filepath = ctx.getRealPath(urlPath);         if  (filepath == null ) {                          resp.sendError(HttpServletResponse.SC_NOT_FOUND);             return ;         }         Path path = Paths.get(filepath);         if  (!path.toFile().isFile()) {                          resp.sendError(HttpServletResponse.SC_NOT_FOUND);             return ;         }                  String mime = Files.probeContentType(path);         if  (mime == null ) {             mime = "application/octet-stream" ;         }         resp.setContentType(mime);                  OutputStream output = resp.getOutputStream();         try  (InputStream input = new  BufferedInputStream(new  FileInputStream(filepath))) {             input.transferTo(output);         }         output.flush();     } }
 
这样一来,在开发阶段,我们就可以方便地高效开发。
类似Tomcat这样的Web服务器,运行的Web应用程序通常都是业务系统,因此,这类服务器也被称为应用服务器。应用服务器并不擅长处理静态文件,也不适合直接暴露给用户。通常,我们在生产环境部署时,总是使用类似Nginx这样的服务器充当反向代理和静态服务器,只有动态请求才会放行给应用服务器,所以,部署架构如下:
1 2 3 4 5 6 7 8 9              ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐              │  /static/*            │ ┌───────┐      ┌──────────> file │Browser├────┼─┤                     │    ┌ ─ ─ ─ ─ ─ ─ ┐ └───────┘      │/          proxy_pass              │ └─────────────────────┼───>│  Web Server │                        Nginx              └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘    └ ─ ─ ─ ─ ─ ─ ┘
 
实现上述功能的Nginx配置文件如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 server {     listen 80;     server_name www.local.liaoxuefeng.com;     # 静态文件根目录:     root /path/to/src/main/webapp;     access_log /var/log/nginx/webapp_access_log;     error_log  /var/log/nginx/webapp_error_log;     # 处理静态文件请求:     location /static {     }     # 处理静态文件请求:     location /favicon.ico {     }     # 不允许请求/WEB-INF:     location /WEB-INF {         return 404;     }     # 其他请求转发给Tomcat:     location / {         proxy_pass       http://127.0.0.1:8080;         proxy_set_header Host $host;         proxy_set_header X-Real-IP $remote_addr;         proxy_set_header X-Forwarded-Proto $scheme;         proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;     } }
 
使用Nginx配合Tomcat服务器,可以充分发挥Nginx作为网关的优势,既可以高效处理静态文件,也可以把https、防火墙、限速、反爬虫等功能放到Nginx中,使得我们自己的WebApp能专注于业务逻辑。
部署Web应用程序时,要设计合理的目录结构,同时考虑开发模式需要便捷性,生产模式需要高性能。