Appearance
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接口,实现底层功能:
┌───────────┐
│My Servlet │
├───────────┤
│Servlet API│
┌───────┐ HTTP ├───────────┤
│Browser│◀──────▶│Web Server │
└───────┘ └───────────┘
我们来实现一个最简单的Servlet:
java
// WebServlet注解表示这是一个Servlet,并映射到地址/:
@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>");
// 最后不要忘记flush强制输出:
pw.flush();
}
}
一个Servlet总是继承自HttpServlet
,然后覆写doGet()
或doPost()
方法。注意到doGet()
方法传入了HttpServletRequest
和HttpServletResponse
两个对象,分别代表HTTP请求和响应。我们使用Servlet API时,并不直接与底层TCP交互,也不需要解析HTTP协议,因为HttpServletRequest
和HttpServletResponse
就已经封装好了请求和响应。以发送响应为例,我们只需要设置正确的响应类型,然后获取PrintWriter
,写入响应即可。
现在问题来了:Servlet API是谁提供?
Servlet API是一个jar包,我们需要通过Maven来引入它,才能正常编译。编写pom.xml
文件如下:
xml
<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/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.itranswarp.learnjava</groupId>
<artifactId>web-servlet-hello</artifactId>
<packaging>war</packaging>
<version>1.0-SNAPSHOT</version>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<version>5.0.0</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<finalName>hello</finalName>
</build>
</project>
注意到这个pom.xml
与前面我们讲到的普通Java程序有个区别,打包类型不是jar
,而是war
,表示Java Web Application Archive:
xml
<packaging>war</packaging>
引入的Servlet API如下:
xml
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<version>5.0.0</version>
<scope>provided</scope>
</dependency>
注意到<scope>
指定为provided
,表示编译时使用,但不会打包到.war
文件中,因为运行期Web服务器本身已经提供了Servlet API相关的jar包。
Servlet版本 🚀
要务必注意servlet-api
的版本。4.0及之前的servlet-api
由Oracle官方维护,引入的依赖项是javax.servlet:javax.servlet-api
,编写代码时引入的包名为:
java
import javax.servlet.*;
而5.0及以后的servlet-api
由Eclipse开源社区维护,引入的依赖项是jakarta.servlet:jakarta.servlet-api
,编写代码时引入的包名为:
java
import jakarta.servlet.*;
教程采用最新的jakarta.servlet:5.0.0
版本,但对于很多仅支持Servlet 4.0版本的框架来说,例如Spring 5,我们就只能使用javax.servlet:4.0.0
版本,这一点针对不同项目要特别注意。
注意
引入不同的Servlet API版本,编写代码时导入的相关API的包名是不同的。
整个工程结构如下:
web-servlet-hello/
├── pom.xml
└── src/
└── main/
├── java/
│ └── com/
│ └── itranswarp/
│ └── learnjava/
│ └── servlet/
│ └── HelloServlet.java
├── resources/
└── webapp/
目录webapp
目前为空,如果我们需要存放一些资源文件,则需要放入该目录。有的同学可能会问,webapp
目录下是否需要一个/WEB-INF/web.xml
配置文件?这个配置文件是低版本Servlet必须的,但是高版本Servlet已不再需要,所以无需该配置文件。
运行Maven命令mvn clean package
,在target
目录下得到一个hello.war
文件,这个文件就是我们编译打包后的Web应用程序。
注意
如果执行package命令遇到Execution default-war of goal org.apache.maven.plugins:maven-war-plugin:2.2:war failed错误时,可手动指定maven-war-plugin最新版本3.3.2,参考练习工程的pom.xml。
现在问题又来了:我们应该如何运行这个war
文件?
普通的Java程序是通过启动JVM,然后执行main()
方法开始运行。但是Web应用程序有所不同,我们无法直接运行war
文件,必须先启动Web服务器,再由Web服务器加载我们编写的HelloServlet
,这样就可以让HelloServlet
处理浏览器发送的请求。
因此,我们首先要找一个支持Servlet API的Web服务器。常用的服务器有:
还有一些收费的商用服务器,如Oracle的WebLogic,IBM的WebSphere。
无论使用哪个服务器,只要它支持Servlet API 5.0(因为我们引入的Servlet版本是5.0),我们的war包都可以在上面运行。这里我们选择使用最广泛的开源免费的Tomcat服务器。
要运行我们的hello.war
,首先要下载Tomcat服务器,解压后,把hello.war
复制到Tomcat的webapps
目录下,然后切换到bin
目录,执行startup.sh
或startup.bat
启动Tomcat服务器:
plain
$ ./startup.sh
Using CATALINA_BASE: .../apache-tomcat-10.1.x
Using CATALINA_HOME: .../apache-tomcat-10.1.x
Using CATALINA_TMPDIR: .../apache-tomcat-10.1.x/temp
Using JRE_HOME: .../jdk-17.jdk/Contents/Home
Using CLASSPATH: .../apache-tomcat-10.1.x/bin/bootstrap.jar:...
Tomcat started.
在浏览器输入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容器。
Tomcat版本 🚀
由于Servlet版本分为<=4.0和>=5.0两种,所以,要根据使用的Servlet版本选择正确的Tomcat版本。从Tomcat版本页可知:
- 使用Servlet<=4.0时,选择Tomcat 9.x或更低版本;
- 使用Servlet>=5.0时,选择Tomcat 10.x或更高版本。
运行本节代码需要使用Tomcat>=10.x版本。
在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。
Servlet开发 🚀
在上一节中,我们看到,一个完整的Web应用程序的开发流程如下:
- 编写Servlet;
- 打包为war文件;
- 复制到Tomcat的webapps目录下;
- 启动Tomcat。
这个过程是不是很繁琐?如果我们想在IDE中断点调试,还需要打开Tomcat的远程调试端口并且连接上去。
许多初学者经常卡在如何在IDE中启动Tomcat并加载webapp,更不要说断点调试了。
我们需要一种简单可靠,能直接在IDE中启动并调试webapp的方法。
因为Tomcat实际上也是一个Java程序,我们看看Tomcat的启动流程:
- 启动JVM并执行Tomcat的
main()
方法; - 加载war并初始化Servlet;
- 正常服务。
启动Tomcat无非就是设置好classpath并执行Tomcat某个jar包的main()
方法,我们完全可以把Tomcat的jar包全部引入进来,然后自己编写一个main()
方法,先启动Tomcat,然后让它加载我们的webapp就行。
我们新建一个web-servlet-embedded
工程,编写pom.xml
如下:
xml
<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>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<java.version>17</java.version>
<tomcat.version>10.1.1</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
,引入的Tomcat版本<tomcat.version>
为10.1.1
。
不必引入Servlet API,因为引入Tomcat依赖后自动引入了Servlet API。因此,我们可以正常编写Servlet如下:
java
@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服务器:
java
public class Main {
public static void main(String[] args) throws Exception {
// 启动Tomcat:
Tomcat tomcat = new Tomcat();
tomcat.setPort(Integer.getInteger("port", 8080));
tomcat.getConnector();
// 创建webapp:
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包后,也可以正常部署到独立的Tomcat服务器中。
生成可执行war包
如果要生成可执行的war包,用java -jar xxx.war
启动,则需要把Tomcat的依赖项的<scope>
去掉,然后配置maven-war-plugin
如下:
xml
<project ...>
...
<build>
<finalName>hello</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-war-plugin</artifactId>
<version>3.3.2</version>
<configuration>
<!-- 复制classes到war包根目录 -->
<webResources>
<resource>
<directory>${project.build.directory}/classes</directory>
</resource>
</webResources>
<archiveClasses>true</archiveClasses>
<archive>
<manifest>
<!-- 添加Class-Path -->
<addClasspath>true</addClasspath>
<!-- Classpath前缀 -->
<classpathPrefix>tmp-webapp/WEB-INF/lib/</classpathPrefix>
<!-- main启动类 -->
<mainClass>com.itranswarp.learnjava.Main</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
</plugins>
</build>
</project>
生成的war包结构如下:
hello.war
├── META-INF
│ ├── MANIFEST.MF
│ └── maven
│ └── ...
├── WEB-INF
│ ├── classes
│ ├── lib
│ │ ├── ecj-3.18.0.jar
│ │ ├── tomcat-annotations-api-10.1.1.jar
│ │ ├── tomcat-embed-core-10.1.1.jar
│ │ ├── tomcat-embed-el-10.1.1.jar
│ │ ├── tomcat-embed-jasper-10.1.1.jar
│ │ └── web-servlet-embedded-1.0-SNAPSHOT.jar
│ └── web.xml
└── com
└── itranswarp
└── learnjava
├── Main.class
├── TomcatRunner.class
└── servlet
└── HelloServlet.class
之所以要把编译后的classes复制到war包根目录,是因为用java -jar hello.war
启动时,JVM的Class Loader不会查找WEB-INF/lib
的jar包,而是直接从hello.war
的根目录查找。MANIFEST.MF
生成的内容如下:
plain
Main-Class: com.itranswarp.learnjava.Main
Class-Path: tmp-webapp/WEB-INF/lib/tomcat-embed-core-10.1.1.jar tmp-weba
pp/WEB-INF/lib/tomcat-annotations-api-10.1.1.jar tmp-webapp/WEB-INF/lib
/tomcat-embed-jasper-10.1.1.jar tmp-webapp/WEB-INF/lib/tomcat-embed-el-
10.1.1.jar tmp-webapp/WEB-INF/lib/ecj-3.18.0.jar
注意到Class-Path
的路径,这里定义的Class-Path
相当于java -cp
指定的Classpath,JVM不会在一个jar包中查找jar包内的jar包,它只会在文件系统中搜索,因此,我们要修改main()
方法,在执行main()
方法时,先自解压war
包,再启动Tomcat:
java
public class Main {
public static void main(String[] args) throws Exception {
// 判定是否从jar/war启动:
String jarFile = Main.class.getProtectionDomain().getCodeSource().getLocation().getFile();
boolean isJarFile = jarFile.endsWith(".war") || jarFile.endsWith(".jar");
// 定位webapp根目录:
String webDir = isJarFile ? "tmp-webapp" : "src/main/webapp";
if (isJarFile) {
// 解压到tmp-webapp:
Path baseDir = Paths.get(webDir).normalize().toAbsolutePath();
if (Files.isDirectory(baseDir)) {
Files.delete(baseDir);
}
Files.createDirectories(baseDir);
System.out.println("extract to: " + baseDir);
try (JarFile jar = new JarFile(jarFile)) {
List<JarEntry> entries = jar.stream().sorted(Comparator.comparing(JarEntry::getName))
.collect(Collectors.toList());
for (JarEntry entry : entries) {
Path res = baseDir.resolve(entry.getName());
if (!entry.isDirectory()) {
System.out.println(res);
Files.createDirectories(res.getParent());
Files.copy(jar.getInputStream(entry), res);
}
}
}
// JVM退出时自动删除tmp-webapp:
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
try {
Files.walk(baseDir).sorted(Comparator.reverseOrder()).map(Path::toFile).forEach(File::delete);
} catch (IOException e) {
e.printStackTrace();
}
}));
}
// 启动Tomcat:
TomcatRunner.run(webDir, isJarFile ? "tmp-webapp" : "target/classes");
}
}
// Tomcat启动类:
class TomcatRunner {
public static void run(String webDir, String baseDir) throws Exception {
Tomcat tomcat = new Tomcat();
tomcat.setPort(Integer.getInteger("port", 8080));
tomcat.getConnector();
Context ctx = tomcat.addWebapp("", new File(webDir).getAbsolutePath());
WebResourceRoot resources = new StandardRoot(ctx);
resources.addPreResources(new DirResourceSet(resources, "/WEB-INF/classes", new File(baseDir).getAbsolutePath(), "/"));
ctx.setResources(resources);
tomcat.start();
tomcat.getServer().await();
}
}
现在,执行java -jar hello.war
时,JVM先定位hello.war
的Main
类,运行main()
,自动解压后,文件系统目录如下:
<work>
├── hello.war
└── tmp-webapp
└── WEB-INF
├── lib
│ ├── ecj-3.18.0.jar
│ ├── tomcat-annotations-api-10.1.1.jar
│ ├── tomcat-embed-core-10.1.1.jar
│ ├── tomcat-embed-el-10.1.1.jar
│ ├── tomcat-embed-jasper-10.1.1.jar
│ └── web-servlet-embedded-1.0-SNAPSHOT.jar
└── web.xml
解压后的目录结构和我们在MANIFEST.MF
中设定的Class-Path
一致,因此,JVM能顺利加载Tomcat的jar包,然后运行Tomcat,启动Web App。
编写可执行的jar或者war需要注意的几点:
- 必须在
MANIFEST.MF
中指定Main-Class
和Class-Path
; Main
必须能在jar/war包的根目录下被JVM的Class Loader加载;Main
负责解压jar/war,解压后的目录结构与MANIFEST.MF
中设定的Class-Path
一致;Main
不能引用任何解压后才能被加载的类,例如org.apache.catalina.startup.Tomcat
。
对SpringBoot有所了解的童鞋可能知道,SpringBoot也支持在main()
方法中一行代码直接启动Tomcat,并且还能方便地更换成Jetty等其他服务器。它的启动方式和我们介绍的是基本一样的,后续涉及到SpringBoot的部分我们还会详细讲解。
注意:引入的Tomcat的scope为provided
,在Idea下运行时,需要设置Run/Debug Configurations
,选择Application - Main
,钩上Include dependencies with "Provided" scope
,这样才能让Idea在运行时把Tomcat相关依赖包自动添加到classpath中。
小结
开发Servlet时,推荐使用main()
方法启动嵌入式Tomcat服务器并加载当前工程的webapp,便于开发调试,且不影响打包部署,能极大地提升开发效率。
重定向与转发 🚀
Redirect
重定向是指当浏览器请求一个URL时,服务器返回一个重定向指令,告诉浏览器地址已经变了,麻烦使用新的URL再重新发送新请求。
例如,我们已经编写了一个能处理/hello
的HelloServlet
,如果收到的路径为/hi
,希望能重定向到/hello
,可以再编写一个RedirectServlet
:
java
@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
在内部又发送了重定向响应,因此,浏览器会收到如下响应:
plain
HTTP/1.1 302 Found
Location: /hello
当浏览器收到302响应后,它会立刻根据Location
的指示发送一个新的GET /hello
请求,这个过程就是重定向:
┌───────┐ GET /hi ┌───────────────┐
│Browser│ ────────────▶ │RedirectServlet│
│ │ ◀──────────── │ │
└───────┘ 302 └───────────────┘
┌───────┐ GET /hello ┌───────────────┐
│Browser│ ────────────▶ │ HelloServlet │
│ │ ◀──────────── │ │
└───────┘ 200 <html> └───────────────┘
观察Chrome浏览器的网络请求,可以看到两次HTTP请求,并且浏览器的地址栏路径自动更新为/hello
。
重定向有两种:一种是302响应,称为临时重定向,一种是301响应,称为永久重定向。两者的区别是,如果服务器发送301永久重定向响应,浏览器会缓存/hi
到/hello
这个重定向的关联,下次请求/hi
的时候,浏览器就直接发送/hello
请求了。
重定向有什么作用?重定向的目的是当Web应用升级后,如果请求路径发生了变化,可以将原来的路径重定向到新路径,从而避免浏览器请求原路径找不到资源。
HttpServletResponse
提供了快捷的redirect()
方法实现302重定向。如果要实现301永久重定向,可以这么写:
java
resp.setStatus(HttpServletResponse.SC_MOVED_PERMANENTLY); // 301
resp.setHeader("Location", "/hello");
Forward
Forward是指内部转发。当一个Servlet处理请求的时候,它可以决定自己不继续处理,而是转发给另一个Servlet处理。
例如,我们已经编写了一个能处理/hello
的HelloServlet
,继续编写一个能处理/morning
的ForwardServlet
:
java
@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,即下面的代码:
java
req.getRequestDispatcher("/hello").forward(req, resp);
后续请求的处理实际上是由HelloServlet
完成的。这种处理方式称为转发(Forward),我们用流程图画出来如下:
┌────────────────────────┐
│ ┌───────────────┐ │
│ ────▶│ForwardServlet │ │
┌───────┐ GET /morning │ └───────────────┘ │
│Browser│ ──────────────▶ │ │ │
│ │ ◀────────────── │ ▼ │
└───────┘ 200 <html> │ ┌───────────────┐ │
│ ◀────│ HelloServlet │ │
│ └───────────────┘ │
│ Web Server │
└────────────────────────┘
转发和重定向的区别在于,转发是在Web服务器内部完成的,对浏览器来说,它只发出了一个HTTP请求。注意到使用转发的时候,浏览器的地址栏路径仍然是/morning
,浏览器并不知道该请求在Web服务器内部实际上做了一次转发。
小结
使用重定向时,浏览器知道重定向规则,并且会自动发起新的HTTP请求;
使用转发时,浏览器并不知道服务器内部的转发逻辑。
使用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
取出用户名:
java
@WebServlet(urlPatterns = "/signin")
public class SignInServlet extends HttpServlet {
// 模拟一个数据库:
private Map<String, String> users = Map.of("bob", "bob123", "alice", "alice123", "tom", "tomcat");
// GET请求时显示登录页:
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();
}
// POST请求时处理用户登录:
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);
}
}
}
上述SignInServlet
在判断用户登录成功后,立刻将用户名放入当前HttpSession
中:
java
HttpSession session = req.getSession();
session.setAttribute("user", name);
在IndexServlet
中,可以从HttpSession
取出用户名:
java
@WebServlet(urlPatterns = "/")
public class IndexServlet extends HttpServlet {
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
// 从HttpSession获取当前用户名:
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
中移除用户相关信息:
java
@WebServlet(urlPatterns = "/signout")
public class SignOutServlet extends HttpServlet {
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
// 从HttpSession移除用户名:
req.getSession().removeAttribute("user");
resp.sendRedirect("/");
}
}
对于Web应用程序来说,我们总是通过HttpSession
这个高级接口访问当前Session。如果要深入理解Session原理,可以认为Web服务器在内存中自动维护了一个ID到HttpSession
的映射表,我们可以用下图表示:
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
│ ┌───────────────┐ │
┌───▶│ IndexServlet │◀──────────┐
│ │ └───────────────┘ ▼ │
┌───────┐ │ ┌───────────────┐ ┌────────┐
│Browser│──┼─┼───▶│ SignInServlet │◀────▶│Sessions││
└───────┘ │ └───────────────┘ └────────┘
│ │ ┌───────────────┐ ▲ │
└───▶│SignOutServlet │◀──────────┘
│ └───────────────┘ │
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
而服务器识别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
对象就足够了:
java
public class User {
public long id; // 唯一标识
public String email;
public String name;
}
在使用多台服务器构成集群时,使用Session会遇到一些额外的问题。通常,多台服务器集群使用反向代理作为网站入口:
┌────────────┐
┌───▶│Web Server 1│
│ └────────────┘
┌───────┐ ┌─────────────┐ │ ┌────────────┐
│Browser│────▶│Reverse Proxy│───┼───▶│Web Server 2│
└───────┘ └─────────────┘ │ └────────────┘
│ ┌────────────┐
└───▶│Web Server 3│
└────────────┘
如果多台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机制。
Cookie
实际上,Servlet提供的HttpSession
本质上就是通过一个名为JSESSIONID
的Cookie来跟踪用户会话的。除了这个名称外,其他名称的Cookie我们可以任意使用。
如果我们想要设置一个Cookie,例如,记录用户选择的语言,可以编写一个LanguageServlet
:
java
@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 cookie = new Cookie("lang", lang);
// 该Cookie生效的路径范围:
cookie.setPath("/");
// 该Cookie有效期:
cookie.setMaxAge(8640000); // 8640000秒=100天
// 将该Cookie添加到响应:
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以获取用户设置的语言,可以写一个方法如下:
java
private String parseLanguageFromCookie(HttpServletRequest req) {
// 获取请求附带的所有Cookie:
Cookie[] cookies = req.getCookies();
// 如果获取到Cookie:
if (cookies != null) {
// 循环每个Cookie:
for (Cookie cookie : cookies) {
// 如果Cookie名称为lang:
if (cookie.getName().equals("lang")) {
// 返回Cookie的值:
return cookie.getValue();
}
}
}
// 返回默认值:
return "en";
}
可见,读取Cookie主要依靠遍历HttpServletRequest
附带的所有Cookie。
小结
Servlet容器提供了Session机制以跟踪用户;
默认的Session机制是以Cookie形式实现的,Cookie名称为JSESSIONID
;
通过读写Cookie可以在客户端设置用户偏好等。