JavaEE Learning Note - Web

Servlet入门

使用教程为廖雪峰老师的Java教程

最简单的Servlet实现:

  • 将Servlet API导入Maven

    编辑pox.xml,添加打包类型,设置为war,表示为Java Web Application Archive

    1
    <packaging>war</packaging>

    引入项目依赖

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    <dependency>
    <groupId>jakarta.servlet</groupId>
    <artifactId>jakarta.servlet-api</artifactId>
    <version>6.0.0</version> <!--版本号可修改为最新的-->
    <scope>provided</scope>
    </dependency>

    <!--打包为war文件需要的插件-->
    <build>
    <plugins>
    <plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-war-plugin</artifactId>
    <version>3.3.2</version>
    </plugin>
    </plugins>
    </build>

    注意<scope>指定为provided,表示编译时使用,不会打包进war文件中,因为运行期Web服务器本身已经提供了Servlet API相关的jar包。

    基本代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    import jakarta.servlet.*;
    import jakarta.servlet.annotation.WebServlet;
    import jakarta.servlet.http.*;

    import java.io.*;

    // 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();
    }
    }

    项目工程结构为:

    1
    2
    3
    4
    5
    6
    7
    8
    JavaEELearn
    ├── pom.xml
    └── src
    └── main
    ├── java
    │ └── HelloServlet.java
    ├── resources ##存放网站的资源文件
    └── webapp
  • 下载Tomcat并安装

    • 使用Servlet<=4.0时,使用Tomcat 9.x
    • 使用Servlet>=5.0时,使用Tomcat 10.x
  • 项目构建运行

    使用mvn clean package生成war文件,将war文件重命名为Root.war,放入%Tomcat%/webapps中,运行%Tomcat%/bin/startup.bat或者%Tomcat%/bin/startup.sh启动Tomcat服务器,默认监听端口为8080,访问http://localhost:8080/即可。

Servlet进阶

一个Web App就是由一个或多个Servlet组成的,每个Servlet通过注解设置自己处理的路径。

浏览器发送请求时,有多种不同类型的请求,比如GET,POST,PUT。为了处理这些不同的请求,我们需要覆写这些请求的方法,例如doGet() doPost()

HttpServletRequest

HttpServletRequest封装了一个Http请求,实际上时从ServletRequest继承的,可以通过HttpServletRequest提供的接口方法获得Http请求的几乎全部信息,常用的方法有:

  • getMethod() 获取请求方法,例如GET POST
  • getRequestURI() 获取请求路径,但不包括请求的参数,例如"/index"
  • getQuerryString() 获取请求参数,例如"name=123&age=11&sex=1"
  • getParameter(name) 获取指定的参数,GET从URL读取,POST从Body中读取
  • getContentType() 获取请求Body的类型,例如"application/x-www-form-urlencoded"
  • getContextPath() 获取当前Webapp挂载的路径
  • getCookies() 获取请求携带的所有Cookie
  • getHeader(name) 获取指定的Header
  • getHeaderNames() 获取所有的Header
  • getInputStream() 打开一个输入流读取Body
  • getReader() 打开一个Reader读取Body
  • getRemoteAddr() 获取客户端的IP
  • getScheme() 获取协议类型,例如http https

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(),底层服务器会自动设置好。

写入完毕后,必须调用flush()刷新缓冲区,将缓冲区的内容发送到客户端,并且,禁止调用close(),因为这样会关闭TCP连接,使Web服务器无法复用此连接

Servlet多线程模型

一个Servlet类在服务器中只有一个实例,但对于每个Http请求,服务器会使用多线程执行请求,因此,一个Servlet的处理请求的方法是多线程并发执行的,要注意多线程并发访问的问题。

对于每个请求,Web服务器会创建唯一的HttpServletRequestHttpServletResponse实例,他们总是局部变量,不存在多线程共享的问题。

重定向与转发

Redirect

重定向是指当浏览器请求一个URL时,服务器返回一个重定向指令,告诉浏览器地址已经变了,麻烦使用新的URL再重新发送新请求。

比如:

1
2
3
4
5
6
7
8
@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请求,会收到服务器的302回应,要求访问新路径。产生两次Http请求,浏览器地址栏自动更新为重定向后的地址。

302响应为临时重定向,301响应为永久重定向

永久重定向写法为:

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

Forward

Forward是指内部转发,当一个Servlet处理请求时,它可以决定自己不继续处理,而是转发给另一个Servlet处理,例如:

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完成的。

转发与重定向的区别在于,转发是在Web服务器内部完成的,对于浏览器来说,他只是发出了一个Http请求,浏览器并不知道该请求在Web服务器内实际做了一次转发。

Session和Cookie

Http协议是一个无状态协议,Web服务器无法区分收到的两个请求是否是同一个浏览器发出的,为了跟踪用户状态,服务器可以向浏览器分配一个唯一ID,以Cookie的形式发送到浏览器,浏览器在后续访问中总是附带此Cookie,这样,服务器就可以识别出来用户的身份。

Session

我们把这种基于唯一ID识别用户身份的机制称为Session,每个用户第一次访问服务器后,会自动后将获得一个Session ID。若用户一段时间未访问服务器,Session会自动失效,即使下次访问带着上次分配的Session ID访问,服务器也会认作新用户,分配一个新的Session ID。

例如:

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");

// 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);
}
}
}

在其他页面获取用户名:

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 {
// 从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();
}
}

移除Session:

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 {
// 从HttpSession移除用户名:
req.getSession().removeAttribute("user");
resp.sendRedirect("/");
}
}

对于Web服务器来说,我们通过HttpSession接口访问当前Session,可以理解为Web服务器在内存中自动维护了一个ID到HttpSession的映射表。

服务器识别Session的关键就是依靠一个名为JSESSIONID的Cookie。在第一次调用req.getSession()时,服务器自动创建一个SessionID,然后通过名为JSESSIONID的Cookie发送给浏览器。

使用Session时,由于服务器把所有用户的Session都存储在内存中,如果遇到内存不足的情况,就需要把部分不活动的Session序列化到磁盘上,这会大大降低服务器的运行效率,因此,放入Session的对象要小,通常我们放入一个简单的User对象就足够了。

在使用多台服务器构成集群时,使用Session会遇到一些额外的问题。Session适用于中小型Web应用程序,对于大型应用程序来说,通常需要避免使用Session机制.

Servlet提供的HttpSession本质上是通过一个名为JSESSIONID的Cookie来跟踪用户对话,除这个名称外,其他名称的Cookie我们可以任意使用。

比如可以编写一个LanguageServlet设置Cookie记录用户选择的语言。

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 cookie = new Cookie("lang", lang);
// 该Cookie生效的路径范围:
cookie.setPath("/");
// 该Cookie有效期:
cookie.setMaxAge(8640000); // 8640000秒=100天
// 将该Cookie添加到响应:
resp.addCookie(cookie);
}
resp.sendRedirect("/");
}
}

创建Cookie时,除了指定名称和值以外,通常还要设置setPath("<Path>"),浏览器根据此前缀决定是否发送Cookie,如果网页是Https,还需要调用setSecure(true),否则浏览器不会发送该Cookie。

Cookie的读取主要靠遍历HttpServletRequest实现,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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";
}

JSP

JSP文件的内容其实是一个HTML,必须放在/src/main/webapp下,文件名以.jsp结尾,在需要动态输出的地方,使用特殊指令<% ... %>

1
2
3
<%-- 注释 --%>
<% java代码 %> <%-- 可以编写任意java代码 --%>
<%= xxx %> <%-- 可以快捷输出一个变量的值 --%>

JSP页面内置了几个变量:

  1. out:表示HttpServletResponsePrintWriter
  2. session:表示当前HttpSession对象
  3. request:表示HttpServletRequest对象

JSP本质上就是一个Servlet,只是无需配置映射路径。在服务器运行过程中,如果修改了JSP的内容,服务器会自动重新编译。

MVC开发

MVC开发就是将Servlet和JSP相结合,Servlet负责处理数据,JSP负责展示页面。

比如:

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;
}
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);
// 放入Request中:
req.setAttribute("user", user);
// forward给user.jsp:
req.getRequestDispatcher("/WEB-INF/user.jsp").forward(req, resp);
}
}
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>

需要注意的是:

  1. 要把user.jsp放到/WEB-INF/目录下,阻止用户通过/user.jsp绕过Servlet直接访问JSP页面。
  2. JSP直接从request获取User实例,然后直接输出,此处未考虑html的转义,有潜在安全风险。

简介

我们把UserServlet看作业务逻辑处理,把User看作模型,把user.jsp看作渲染,这种设计模式通常被称为MVC:Model-View-Controller,即UserServlet作为控制器(Controller),User作为模型(Model),user.jsp作为视图(View)

使用MVC模式的好处是,Controller专注于业务处理,它的处理结果就是Model。Model可以是一个JavaBean,也可以是一个包含多个对象的Map,Controller只负责把Model传递给View,View只负责把Model给“渲染”出来,这样,三者职责明确,且开发更简单,因为开发Controller时无需关注页面,开发View时无需关心如何创建Model。

MVC的设计

MVC框架有一个接收所有请求的Servlet,通常我们把它命名为DispatcherServlet,映射到/,然后根据不同Controller方法定义的@Get@Post的Path决定调用哪个方法,最后获得方法返回的ModeAndView渲染模板,写入HttpServletResponse

其中,DispatcherServlet以及如何渲染均由MVC框架实现,在MVC框架之上只需要编写每一个Controller。

Filter

Filter可以在Http请求到达Servlet前,进行预处理,比如:

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);
}
} //在进行下一步前将request和response的编码设置为UTF-8

编写Filter时,必须实现Filter接口,在doFilter()方法内部,要继续处理请求,必须调用chain.doFilter()。最后,用@WebFilter注解标注该Filter需要过滤的URL。这里的/*表示所有路径。

如果编写了多个Filter,Filter会组成一个链,依次进行处理(无序,若需有序,在web.xml中配置)

Filter可以有针对性地拦截或放行Http请求。如果Filter没有调用chain.doFilter(),后续的Filter和Servlet都没有机会处理该请求,如果一个Filter在请求中生效,但什么都没做,用户将看到一个空白页,因为请求没有继续处理,默认响应200和空白输出。

Filter可以进行预处理,因此,可以把很多公共预处理逻辑放到Filter中完成,比如:

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 {
// 读取Request Body:
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);
}
// TODO: 写入文件:
// 显示上传结果:
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();
}
}

此为上传文件的Servlet,为了保证文件的完整性,可以在服务端做一个验证,让客户端把文件的哈希也传过来,比如:

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;
}
// 读取Request的Body并验证签名:
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);
}

// 将byte[]转换为hex string:
private String toHexString(byte[] digest) {
StringBuilder sb = new StringBuilder();
for (byte b : digest) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}

// 根据名称创建MessageDigest:
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();
}
}

以上这个Filter队签名进行验证是没问题的,但UploadServlet将读取不到任何信息,因为HttpServletRequest读取时,只能读取一次,后续无法读取任何信息,此时需要伪造一个HttpServletRequest,获得一个新的流:

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;
}

// 返回InputStream:
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;
}
};
}

// 返回Reader:
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"));
}
}

并将Filter的doFilter()更改为chain.doFilter(new ReReadableHttpServletRequest(req, output.toByteArray()), response);

虽然整个Filter的代码比较复杂,但这个Filter在整个处理链中实现了灵活的可插拔性,是否启用对Web应用程序的其他组件完全没有影响。

Listener

任何标注为@WebListener且内部实现了特定接口的类都会被Web服务器自动初始化,ServletContextListener接口会在整个Web应用程序初始化完毕后以及Web应用程序关闭后得到回调通知,可以把初始化数据库等工作放到contextInitialized()中,把清理资源的工作放到contextDestory()中。

ServletContextListener写法:

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

除了ServletContextListener外还有几种Listener:

  1. HttpSessionListener:监听HttpSession的创建和销毁
  2. ServletRequestListener:监听ServletRequest的创建和销毁
  3. ServletRequestAttributeListener:监听ServletRequest请求的属性变化(ServletQuest.setAttribute())
  4. ServletContextAttributeListener:监听ServletContext的属性变化(ServletContext.setAttribute())