Java Web 基础学习笔记

zangxin

Java Web 基础学习笔记

本篇整理 Java Web 开发中的基础知识,包括 XML、HTTP、Tomcat、Servlet、Cookie 与 Session、监听器、过滤器、文件上传下载、JSP、EL、JSTL、JSON,以及一个商城项目中的典型设计。

XML

XML(Extensible Markup Language)是一种可扩展标记语言,能够描述层级化数据。它曾广泛用于程序间交换数据,现在仍常见于 Spring、Maven 等框架的配置文件。

基本语法

XML 文档通常以声明开头,并且有且只有一个根元素:

1
2
3
4
5
6
7
8
<?xml version="1.0" encoding="UTF-8"?>

<students>
    <student id="100">
        <name>Alice</name>
        <age>20</age>
    </student>
</students>

需要注意:

  • 标签必须正确闭合,标签名区分大小写。
  • 属性值必须使用单引号或双引号包裹。
  • 同一个标签中不能出现同名属性。
  • <>& 等字符需要使用实体转义。
字符 XML 实体
< &lt;
> &gt;
& &amp;
" &quot;
' &apos;

不希望内容被解析为 XML 标记时,可以使用 CDATA:

1
2
3
4
5
<![CDATA[
    if (left < right && right > 0) {
        // 原样保存
    }
]]>

DOM 与 SAX

解析方式 特点 适用场景
DOM 将整个文档加载为树形结构,便于随机访问和修改 文件较小,需要增删改查
SAX 按事件顺序读取,不保存完整文档树 文件较大,只需顺序读取

DOM4J 对 DOM 操作进行了封装,API 较为简洁。

DOM4J 解析

创建或读取 Document

1
2
3
4
5
6
7
8
9
10
11
12
SAXReader reader = new SAXReader();

// 从文件读取
Document document = reader.read(new File("students.xml"));

// 从文本读取
Document textDocument =
    DocumentHelper.parseText("<student><name>Alice</name></student>");

// 创建新文档
Document newDocument = DocumentHelper.createDocument();
newDocument.addElement("students");

遍历元素:

1
2
3
4
5
6
7
8
9
Element root = document.getRootElement();

for (Element student : root.elements("student")) {
    String id = student.attributeValue("id");
    String name = student.elementText("name");
    String age = student.elementText("age");

    System.out.printf("%s, %s, %s%n", id, name, age);
}

增删改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 添加
Element student = root.addElement("student");
student.addAttribute("id", "300");
student.addElement("name").setText("Tom");
student.addElement("age").setText("21");

// 修改
Element first = root.elements("student").get(0);
first.element("age").setText("23");

// 删除
Element last = root.elements("student")
    .get(root.elements("student").size() - 1);
last.getParent().remove(last);

保存文档:

1
2
3
4
5
6
7
8
9
10
11
OutputFormat format = OutputFormat.createPrettyPrint();
format.setEncoding("UTF-8");

try (
    XMLWriter writer = new XMLWriter(
        new FileOutputStream("students.xml"),
        format
    )
) {
    writer.write(document);
}

HTTP 协议

HTTP 是浏览器与服务器交换资源的应用层协议。一次通信由请求和响应组成。

1
curl -i https://example.com/

HTTP 请求

典型请求报文:

1
2
3
4
5
6
7
GET /users?page=1 HTTP/1.1
Host: example.com
Accept: application/json
Accept-Language: zh-CN
Connection: keep-alive
Cookie: JSESSIONID=example-session-id

请求由以下部分组成:

  1. 请求行:请求方法、资源路径和协议版本。
  2. 请求头:客户端能力、认证信息、内容格式等元数据。
  3. 空行:分隔请求头和请求体。
  4. 请求体:POST、PUT 等请求携带的数据。

常见请求头:

请求头 作用
Host 目标主机和端口
User-Agent 客户端信息
Accept 客户端能够接收的响应类型
Accept-Language 客户端偏好的语言
Accept-Encoding 客户端支持的压缩格式
Content-Type 请求体的媒体类型
Content-Length 请求体的字节长度
Cookie 发送当前域名下匹配的 Cookie
Referer 当前请求来源页面
Origin 发起跨域请求的源

HTTP 响应

典型响应报文:

1
2
3
4
5
6
7
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Content-Length: 53
Connection: keep-alive

<!doctype html>
<html><body><h1>Hello</h1></body></html>

常见响应头:

响应头 作用
Content-Type 响应体的媒体类型和字符集
Content-Length 响应体字节长度
Location 重定向目标地址
Set-Cookie 要求浏览器保存 Cookie
ETag 资源版本标识
Last-Modified 资源最后修改时间
Cache-Control 缓存策略

状态码

范围 含义 常见状态码
1xx 信息响应 101 Switching Protocols
2xx 请求成功 200 OK201 Created204 No Content
3xx 重定向或缓存 301302304
4xx 客户端错误 400401403404
5xx 服务端错误 500502503

302 响应通常携带 Location,浏览器根据它发起第二次请求。304 表示资源未修改,响应通常不再传输完整资源内容。

页面中每个外部资源都可能产生独立请求:

1
2
<img src="first.jpg" alt="">
<img src="second.jpg" alt="">

访问该页面至少会请求 HTML 和两张图片。

GET 与 POST

对比项 GET POST
主要用途 查询资源 提交或修改数据
参数位置 通常在 URL 查询字符串 通常在请求体
浏览器缓存 更容易被缓存 默认不以相同方式缓存
幂等性 应保持幂等 不要求幂等
数据可见性 URL 中可见 不直接显示在 URL

URL 不适合保存密码等敏感数据,但 POST 也不等于加密。敏感通信仍必须使用 HTTPS。

MIME 类型

MIME 类型通过 Content-Type 描述数据格式:

MIME 类型 数据
text/html HTML
text/plain 纯文本
text/css CSS
application/json JSON
application/xml XML
application/octet-stream 通用二进制文件
image/jpeg JPEG 图片
multipart/form-data 包含文件的表单

Tomcat

Tomcat 是 Servlet 容器。它负责监听端口、解析 HTTP、创建请求和响应对象、管理 Servlet 生命周期,并将业务代码产生的结果写回客户端。

目录结构

目录 作用
bin 启动和关闭脚本
conf server.xmlweb.xml 等配置
lib Tomcat 运行所需类库
logs 运行日志
webapps 默认应用部署目录
work JSP 编译等运行时工作目录
temp 临时文件

传统 Web 应用的基本结构:

1
2
3
4
5
6
7
8
9
webapp/
├── css/
├── js/
├── images/
├── index.html
└── WEB-INF/
    ├── classes/
    ├── lib/
    └── web.xml

WEB-INF 下的资源不能被浏览器直接访问,只能由服务端转发或内部读取。

访问地址通常由以下部分组成:

1
http://主机:端口/应用上下文/资源路径

请求处理过程

  1. Tomcat 启动并监听端口。
  2. 客户端建立连接并发送 HTTP 请求。
  3. Tomcat 解析请求行、请求头和请求体。
  4. 静态资源交给默认 Servlet 处理。
  5. 动态请求根据映射找到业务 Servlet。
  6. Tomcat 创建 HttpServletRequestHttpServletResponse
  7. 容器在线程中调用 Servlet 的 service()
  8. 响应数据被编码为 HTTP 响应并写回客户端。

简易 Servlet 容器的核心思路

手写简化版 Tomcat 可以帮助理解容器职责:

1
2
3
4
5
6
7
ServerSocket
  -> 接收 Socket
  -> 解析 HTTP 请求
  -> 根据 URL 查找 Servlet
  -> 首次访问时反射创建 Servlet
  -> 调用 service(request, response)
  -> 输出 HTTP 响应

简化接口:

1
2
3
4
5
6
7
public interface MyServlet {
    void init();

    void service(MyHttpRequest request, MyHttpResponse response);

    void destroy();
}

根据请求方法分发:

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
public abstract class MyHttpServlet implements MyServlet {
    @Override
    public void service(
        MyHttpRequest request,
        MyHttpResponse response
    ) {
        if ("GET".equals(request.getMethod())) {
            doGet(request, response);
        } else if ("POST".equals(request.getMethod())) {
            doPost(request, response);
        }
    }

    protected void doGet(
        MyHttpRequest request,
        MyHttpResponse response
    ) {
    }

    protected void doPost(
        MyHttpRequest request,
        MyHttpResponse response
    ) {
    }
}

Servlet 映射可以保存在两个 Map 中:

1
2
3
4
5
private static final Map<String, String> URL_CLASS_MAP =
    new HashMap<>();

private static final Map<String, MyServlet> URL_SERVLET_MAP =
    new ConcurrentHashMap<>();

启动时解析 web.xml 得到 URL 与类名的映射,首次请求时通过反射创建对象:

1
2
3
4
5
6
7
8
9
10
public static MyServlet createServlet(String className)
    throws ReflectiveOperationException {
    Class<?> type = Class.forName(className);
    Constructor<?> constructor = type.getDeclaredConstructor();
    constructor.setAccessible(true);

    MyServlet servlet = (MyServlet) constructor.newInstance();
    servlet.init();
    return servlet;
}

这只是帮助理解原理的教学模型。真实容器还要处理连接复用、协议解析、线程池、异步 IO、安全、类加载、会话和规范兼容等大量细节。

Servlet

Servlet 是 Java Web 的核心规范之一。开发者编写 Servlet 处理业务请求,容器负责实例化、初始化、调用和销毁。

生命周期

方法 调用时机 调用次数
init() Servlet 创建后 通常一次
service() 每次匹配请求到达 多次
destroy() 应用停止或 Servlet 被卸载 通常一次

Servlet 默认通常按单例管理,但会同时处理多个线程的请求,因此不要把请求相关的可变数据保存在 Servlet 实例字段中。

注解配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@WebServlet(
    name = "helloServlet",
    urlPatterns = {"/hello", "/greeting"},
    loadOnStartup = 1
)
public class HelloServlet extends HttpServlet {
    @Override
    protected void doGet(
        HttpServletRequest request,
        HttpServletResponse response
    ) throws IOException {
        response.setContentType("text/plain;charset=UTF-8");
        response.getWriter().println("Hello");
    }
}

HttpServlet.service() 会根据 HTTP 方法分发到 doGet()doPost() 等方法。

URL 映射

匹配方式 示例 说明
精确匹配 /users 只匹配指定路径
路径匹配 /users/* 匹配指定前缀
扩展名匹配 *.action 匹配指定后缀
默认匹配 / 可能影响静态资源处理

优先级通常为:精确匹配、最长路径匹配、扩展名匹配、默认匹配。

HttpServletRequest

HttpServletRequest 封装客户端请求。

方法 作用
getMethod() 获取请求方法
getRequestURI() 获取请求 URI
getRequestURL() 获取完整请求 URL
getContextPath() 获取应用上下文路径
getParameter(name) 获取单值请求参数
getParameterValues(name) 获取多值参数
getHeader(name) 获取请求头
getCookies() 获取 Cookie
getRemoteAddr() 获取客户端地址
setAttribute() / getAttribute() 操作请求域数据
getRequestDispatcher() 获取请求转发器

处理表单请求体中的中文参数时,应在读取参数前设置编码:

1
request.setCharacterEncoding("UTF-8");

HttpServletResponse

HttpServletResponse 用于构造响应:

1
2
3
4
response.setStatus(HttpServletResponse.SC_OK);
response.setHeader("X-Request-Id", requestId);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"success\":true}");

字符流和字节流不能在同一次响应中混用:

  • getWriter():输出文本。
  • getOutputStream():输出图片、压缩包等二进制数据。

ServletConfig 与 ServletContext

对象 范围 作用
ServletConfig 单个 Servlet 获取该 Servlet 的初始化参数
ServletContext 整个 Web 应用 获取应用参数、资源和应用域数据

Servlet 初始化参数:

1
2
3
4
5
6
7
8
<servlet>
    <servlet-name>hello</servlet-name>
    <servlet-class>com.example.HelloServlet</servlet-class>
    <init-param>
        <param-name>message</param-name>
        <param-value>Hello</param-value>
    </init-param>
</servlet>
1
2
String message = getServletConfig()
    .getInitParameter("message");

应用级参数:

1
2
3
4
<context-param>
    <param-name>applicationName</param-name>
    <param-value>Java Web Demo</param-value>
</context-param>
1
2
3
4
ServletContext context = getServletContext();
String applicationName =
    context.getInitParameter("applicationName");
String contextPath = context.getContextPath();

应用域数据:

1
2
3
context.setAttribute("onlineCount", 10);
Integer onlineCount =
    (Integer) context.getAttribute("onlineCount");

转发与重定向

对比项 转发 重定向
发起方 服务器内部 浏览器
请求次数 一次 两次或更多
地址栏 不变 改变
Request 域 可以共享 不能共享
WEB-INF 可以转发进入 不能直接重定向进入
外部站点 不支持 支持

转发:

1
2
3
request.setAttribute("user", user);
request.getRequestDispatcher("/WEB-INF/views/user.jsp")
    .forward(request, response);

重定向:

1
2
3
response.sendRedirect(
    request.getContextPath() + "/users"
);

保存数据后通常采用 PRG(Post/Redirect/Get)模式,避免刷新页面时重复提交表单。

HTTP 本身是无状态协议。Cookie 和 Session 用于在多次请求之间识别用户和保存会话数据。

服务器通过响应头要求浏览器保存 Cookie:

1
2
3
4
5
6
7
Cookie cookie = new Cookie("theme", "dark");
cookie.setMaxAge(7 * 24 * 60 * 60);
cookie.setPath(request.getContextPath());
cookie.setHttpOnly(true);
cookie.setSecure(true);

response.addCookie(cookie);

浏览器随后在匹配域名和路径的请求中携带:

1
Cookie: theme=dark; JSESSIONID=example-session-id

常用属性:

属性 作用
Max-Age / Expires 控制过期时间
Path 限制 Cookie 生效路径
Domain 限制生效域名
HttpOnly 禁止客户端 JavaScript 读取
Secure 仅通过 HTTPS 发送
SameSite 限制跨站请求携带方式

读取 Cookie:

1
2
3
4
5
6
7
8
9
Cookie[] cookies = request.getCookies();

if (cookies != null) {
    for (Cookie cookie : cookies) {
        if ("theme".equals(cookie.getName())) {
            System.out.println(cookie.getValue());
        }
    }
}

删除 Cookie 时需要使用相同名称和路径,并将存活时间设为 0。

Cookie 容量有限,不应存储密码、权限判断依据或大量数据。权限必须由服务端验证。

Session

Session 在服务端保存会话数据:

1
2
3
4
5
HttpSession session = request.getSession();
session.setAttribute("loginUser", user);

User loginUser =
    (User) session.getAttribute("loginUser");

典型过程:

  1. 浏览器首次访问且没有有效 JSESSIONID
  2. 服务端创建 Session,并生成唯一 ID。
  3. 响应通过 Set-Cookie 写入 JSESSIONID
  4. 浏览器后续请求携带该 ID。
  5. 服务端根据 ID 找到对应 Session。

常用方法:

方法 作用
getId() 获取会话 ID
isNew() 判断是否刚创建
setAttribute() 保存会话数据
getAttribute() 获取会话数据
removeAttribute() 删除指定属性
setMaxInactiveInterval() 设置最大空闲时间
invalidate() 立即销毁 Session

Session 超时表示两次访问之间允许的最大空闲时长,不是从创建开始累计的固定时长。

监听器与过滤器

监听器

监听器观察 ServletContext、Session、Request 的生命周期或属性变化。

监听器 监听内容 常见用途
ServletContextListener 应用启动和停止 初始化资源、关闭资源
ServletContextAttributeListener 应用域属性变化 记录共享状态
HttpSessionListener Session 创建和销毁 在线人数统计
HttpSessionAttributeListener Session 属性变化 登录状态审计
ServletRequestListener Request 创建和销毁 请求日志和耗时统计
ServletRequestAttributeListener Request 属性变化 调试和监控
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@WebListener
public class ApplicationListener
    implements ServletContextListener {

    @Override
    public void contextInitialized(
        ServletContextEvent event
    ) {
        System.out.println("application started");
    }

    @Override
    public void contextDestroyed(
        ServletContextEvent event
    ) {
        System.out.println("application stopped");
    }
}

Filter

Filter 可以在目标资源执行前后统一处理请求和响应,常用于:

  • 登录与权限校验。
  • 字符集处理。
  • 请求日志和耗时统计。
  • 跨域配置。
  • 事务边界。
  • 内容压缩或安全响应头。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@WebFilter("/*")
public class EncodingFilter implements Filter {
    @Override
    public void doFilter(
        ServletRequest request,
        ServletResponse response,
        FilterChain chain
    ) throws IOException, ServletException {
        request.setCharacterEncoding("UTF-8");
        response.setCharacterEncoding("UTF-8");

        chain.doFilter(request, response);
    }
}

调用 chain.doFilter() 表示继续执行后续过滤器或目标资源;不调用就会中断请求链。

过滤器链

1
2
3
4
5
6
7
请求
  -> Filter A 前置逻辑
    -> Filter B 前置逻辑
      -> Servlet / JSP
    -> Filter B 后置逻辑
  -> Filter A 后置逻辑
-> 响应

多个过滤器和目标资源处理的是同一次请求,并共享同一个 Request 对象。

传统 web.xml 可以配置 DispatcherType:

1
2
3
4
5
6
7
<filter-mapping>
    <filter-name>AuthFilter</filter-name>
    <url-pattern>/*</url-pattern>
    <dispatcher>REQUEST</dispatcher>
    <dispatcher>FORWARD</dispatcher>
    <dispatcher>ERROR</dispatcher>
</filter-mapping>

登录鉴权示例

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
@WebFilter("/manage/*")
public class AuthFilter implements Filter {
    @Override
    public void doFilter(
        ServletRequest servletRequest,
        ServletResponse servletResponse,
        FilterChain chain
    ) throws IOException, ServletException {
        HttpServletRequest request =
            (HttpServletRequest) servletRequest;
        HttpServletResponse response =
            (HttpServletResponse) servletResponse;

        User user =
            (User) request.getSession()
                .getAttribute("loginUser");

        if (user != null) {
            chain.doFilter(request, response);
            return;
        }

        response.sendRedirect(
            request.getContextPath() + "/login.html"
        );
    }
}

文件上传与下载

文件上传

上传文件的表单必须使用 POST 和 multipart/form-data

1
2
3
4
5
6
7
8
9
<form
  action="/files/upload"
  method="post"
  enctype="multipart/form-data"
>
  <input type="text" name="description">
  <input type="file" name="file">
  <button type="submit">上传</button>
</form>

Servlet 3 可以通过 Part 处理上传:

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
@MultipartConfig(
    maxFileSize = 10 * 1024 * 1024,
    maxRequestSize = 20 * 1024 * 1024
)
@WebServlet("/files/upload")
public class UploadServlet extends HttpServlet {
    @Override
    protected void doPost(
        HttpServletRequest request,
        HttpServletResponse response
    ) throws IOException, ServletException {
        Part part = request.getPart("file");
        String submittedName = Path.of(
            part.getSubmittedFileName()
        ).getFileName().toString();

        String savedName =
            UUID.randomUUID() + "-" + submittedName;

        Path uploadDirectory = Path.of(
            getServletContext().getRealPath("/uploads")
        );
        Files.createDirectories(uploadDirectory);

        part.write(
            uploadDirectory.resolve(savedName).toString()
        );
        response.sendRedirect(
            request.getContextPath() + "/upload-success.html"
        );
    }
}

上传功能需要额外防护:

  • 限制文件大小、数量和允许类型。
  • 不信任客户端文件名,防止路径穿越。
  • 使用随机名称避免覆盖。
  • 上传目录尽量与可执行代码隔离。
  • 必要时检查文件真实内容,而不只检查扩展名。

文件下载

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
Path baseDirectory = Path.of(
    getServletContext().getRealPath("/downloads")
).normalize();

String requestedName = request.getParameter("filename");
Path file = baseDirectory.resolve(requestedName).normalize();

if (!file.startsWith(baseDirectory) || !Files.isRegularFile(file)) {
    response.sendError(HttpServletResponse.SC_NOT_FOUND);
    return;
}

String contentType = Files.probeContentType(file);
response.setContentType(
    contentType != null
        ? contentType
        : "application/octet-stream"
);

String encodedName = URLEncoder.encode(
    file.getFileName().toString(),
    StandardCharsets.UTF_8
).replace("+", "%20");

response.setHeader(
    "Content-Disposition",
    "attachment; filename*=UTF-8''" + encodedName
);
response.setContentLengthLong(Files.size(file));

try (InputStream input = Files.newInputStream(file)) {
    input.transferTo(response.getOutputStream());
}

下载接口同样需要验证路径,不能直接把用户输入拼接为任意磁盘路径。

Web 应用中的路径

路径最容易混淆的是第一个 / 由谁解析。

浏览器解析

浏览器把 / 解释为当前站点根路径:

1
<a href="/app/users">用户列表</a>

对应:

1
http://主机:端口/app/users

服务端解析

服务端转发中的 / 通常相对于当前 Web 应用:

1
2
request.getRequestDispatcher("/WEB-INF/views/users.jsp")
    .forward(request, response);

推荐写法

JSP 中使用上下文路径生成客户端地址:

1
2
3
<a href="${pageContext.request.contextPath}/users">
    用户列表
</a>

重定向也应包含上下文路径:

1
2
3
response.sendRedirect(
    request.getContextPath() + "/users"
);

<base> 可以改变相对地址解析基准,但容易影响页面中所有链接,使用时要谨慎。

JSP、EL 与 JSTL

JSP(JavaServer Pages)是一种服务端页面模板。Tomcat 首次访问 JSP 时,会将其转换为 Servlet 源码并编译。

现代项目更常使用前后端分离或 Thymeleaf 等模板技术,但理解 JSP 有助于理解服务端渲染和 Servlet 运行机制。

JSP 指令

1
2
3
4
5
<%@ page
    contentType="text/html;charset=UTF-8"
    pageEncoding="UTF-8"
    language="java"
%>

JSP 传统脚本:

语法 作用
<%! ... %> 声明成员
<% ... %> 编写 Java 代码
<%= ... %> 输出表达式
<%-- ... --%> JSP 注释

不建议在 JSP 中大量编写 Java 代码,应优先使用 EL 和 JSTL。

EL 表达式

EL(Expression Language)用于读取域对象数据:

1
2
3
${requestScope.user.name}
${sessionScope.loginUser.username}
${applicationScope.applicationName}

未指定域时,按以下顺序查找:

1
page -> request -> session -> application

常用运算:

1
2
3
${empty requestScope.users}
${user.age >= 18 ? "成年" : "未成年"}
${pageContext.request.contextPath}

JSTL

引入核心标签库:

1
2
3
4
<%@ taglib
    prefix="c"
    uri="http://java.sun.com/jsp/jstl/core"
%>

条件:

1
2
3
<c:if test="${not empty sessionScope.loginUser}">
    欢迎,${sessionScope.loginUser.username}
</c:if>

多分支:

1
2
3
4
5
6
7
8
9
10
11
<c:choose>
    <c:when test="${score >= 80}">
        优秀
    </c:when>
    <c:when test="${score >= 60}">
        及格
    </c:when>
    <c:otherwise>
        不及格
    </c:otherwise>
</c:choose>

遍历:

1
2
3
<c:forEach items="${requestScope.users}" var="user">
    <p>${user.id} - ${user.name}</p>
</c:forEach>

JSP 内置对象

对象 类型或作用
request HttpServletRequest
response HttpServletResponse
session HttpSession
application ServletContext
pageContext 当前 JSP 上下文和页面域
config ServletConfig
out JspWriter
page 当前 Servlet 实例
exception 错误页面中的异常对象

四大域对象

有效范围
pageContext 当前 JSP 页面
request 当前请求,包括内部转发
session 当前会话中的多次请求
application 整个 Web 应用运行期间

数据范围从小到大:

1
pageContext < request < session < application

应尽量把数据放在满足需求的最小范围中。

JSON 与 Jackson

Jackson 可以完成 Java 对象与 JSON 之间的转换。

序列化

1
2
3
4
ObjectMapper objectMapper = new ObjectMapper();

Book book = new Book(1L, "Java Web");
String json = objectMapper.writeValueAsString(book);

反序列化

单个对象:

1
2
3
4
Book book = objectMapper.readValue(
    json,
    Book.class
);

列表:

1
2
3
4
5
List<Book> books = objectMapper.readValue(
    json,
    new TypeReference<List<Book>>() {
    }
);

Map:

1
2
3
4
5
Map<String, Book> books = objectMapper.readValue(
    json,
    new TypeReference<Map<String, Book>>() {
    }
);

实体类通常需要可访问的属性、getter/setter 或明确的 Jackson 注解。

Servlet 返回 JSON:

1
2
3
4
5
6
7
8
response.setContentType(
    "application/json;charset=UTF-8"
);

objectMapper.writeValue(
    response.getOutputStream(),
    result
);

商城项目实践

MVC 与分层架构

职责
View 展示页面并收集用户输入
Controller / Servlet 接收请求、校验参数、调用业务层、组织响应
Service 实现业务规则和事务边界
DAO 访问数据库
Domain / DTO / VO 在不同层之间承载数据
Util 放置无状态通用工具

MVC 的核心目的是分离展示、调度和业务数据,让各层可以独立维护和测试。

合并同类 Servlet

如果每个操作都创建一个 Servlet,类数量会快速增加。可以让一个资源 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
public abstract class BaseServlet extends HttpServlet {
    @Override
    protected void service(
        HttpServletRequest request,
        HttpServletResponse response
    ) throws ServletException, IOException {
        request.setCharacterEncoding("UTF-8");
        response.setCharacterEncoding("UTF-8");

        String uri = request.getRequestURI();
        String methodName =
            uri.substring(uri.lastIndexOf('/') + 1);

        try {
            Method method = getClass().getMethod(
                methodName,
                HttpServletRequest.class,
                HttpServletResponse.class
            );
            method.invoke(this, request, response);
        } catch (ReflectiveOperationException exception) {
            throw new ServletException(exception);
        }
    }
}

该方案适合学习反射分发原理。成熟项目通常使用 Spring MVC 等框架完成路由和参数绑定。

参数绑定与服务端校验

客户端校验只能改善交互,不能替代服务端校验。服务端至少要检查:

  • 必填值是否为空。
  • 字符串长度和格式。
  • 数字范围。
  • 枚举值是否合法。
  • 当前用户是否有操作权限。
  • 数据库中的资源是否存在。

BeanUtils.populate() 可以把请求参数映射到 JavaBean,但类型转换失败和默认值可能掩盖错误,仍需显式校验。

分页模型

分页结果通常包含:

1
2
3
4
5
6
7
public class PageResult<T> {
    private int pageNumber;
    private int pageSize;
    private long totalRows;
    private long totalPages;
    private List<T> records;
}

总页数:

1
2
long totalPages =
    (totalRows + pageSize - 1) / pageSize;

登录与权限

登录成功后,把用户身份保存到 Session:

1
2
request.getSession()
    .setAttribute("loginUser", user);

Filter 根据登录状态和权限控制路径访问。前端是否显示菜单不能作为权限依据,服务端必须再次验证。

验证码

验证码的一般流程:

  1. 服务端生成验证码文本和图片。
  2. 文本保存到 Session,图片返回浏览器。
  3. 用户提交验证码。
  4. 服务端读取并立即删除 Session 中的验证码。
  5. 比较用户输入和服务端保存值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
private boolean checkCode(HttpServletRequest request) {
    String input = request.getParameter("code");
    HttpSession session = request.getSession();

    String expected = (String) session.getAttribute(
        Constants.KAPTCHA_SESSION_KEY
    );
    session.removeAttribute(
        Constants.KAPTCHA_SESSION_KEY
    );

    return expected != null
        && expected.equalsIgnoreCase(input);
}

刷新验证码图片时可以追加时间戳,避免浏览器复用缓存:

1
2
3
image.src = contextPath
  + "/captcha?t="
  + Date.now();

购物车设计

Session 版购物车适合未登录用户:

1
2
3
4
5
6
7
8
Cart
├── items: Map<productId, CartItem>
├── addItem()
├── removeItem()
├── updateCount()
├── clear()
├── getTotalCount()
└── getTotalPrice()

CartItem 通常包含商品 ID、名称、图片、单价、数量和小计。

订单模型

用户与订单是一对多关系,订单与订单项也是一对多关系。

订单表:

1
2
3
4
5
6
7
8
create table tbl_order (
    id bigint primary key auto_increment,
    order_code varchar(32) not null unique,
    user_id bigint not null,
    amount decimal(12, 2) not null default 0,
    status tinyint not null default 0,
    create_time timestamp not null default current_timestamp
);

订单项表:

1
2
3
4
5
6
7
8
9
create table tbl_order_item (
    id bigint primary key auto_increment,
    order_code varchar(32) not null,
    product_id bigint not null,
    product_name varchar(128) not null,
    quantity int not null,
    price decimal(12, 2) not null,
    total_price decimal(12, 2) not null
);

下单过程通常需要在一个事务中完成:

  1. 验证用户和购物车。
  2. 查询并校验库存。
  3. 创建订单。
  4. 创建订单项。
  5. 扣减库存并更新销量。
  6. 提交事务。
  7. 清空购物车。

Filter 与 ThreadLocal 管理事务

一次同步请求中的 Servlet、Service 和 DAO 通常在同一线程执行,可以使用 ThreadLocal<Connection> 让各层共享同一个数据库连接。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private static final ThreadLocal<Connection> CONNECTION_HOLDER =
    new ThreadLocal<>();

public static Connection getConnection() throws SQLException {
    Connection connection = CONNECTION_HOLDER.get();

    if (connection == null) {
        connection = dataSource.getConnection();
        connection.setAutoCommit(false);
        CONNECTION_HOLDER.set(connection);
    }

    return connection;
}

提交与回滚必须清理 ThreadLocal:

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
public static void commitAndClose() throws SQLException {
    Connection connection = CONNECTION_HOLDER.get();

    try {
        if (connection != null) {
            connection.commit();
        }
    } finally {
        closeAndRemove(connection);
    }
}

public static void rollbackAndClose() throws SQLException {
    Connection connection = CONNECTION_HOLDER.get();

    try {
        if (connection != null) {
            connection.rollback();
        }
    } finally {
        closeAndRemove(connection);
    }
}

private static void closeAndRemove(Connection connection)
    throws SQLException {
    try {
        if (connection != null) {
            connection.close();
        }
    } finally {
        CONNECTION_HOLDER.remove();
    }
}

事务过滤器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@WebFilter("/*")
public class TransactionFilter implements Filter {
    @Override
    public void doFilter(
        ServletRequest request,
        ServletResponse response,
        FilterChain chain
    ) throws IOException, ServletException {
        try {
            chain.doFilter(request, response);
            JdbcUtils.commitAndClose();
        } catch (Exception exception) {
            try {
                JdbcUtils.rollbackAndClose();
            } catch (SQLException rollbackException) {
                exception.addSuppressed(rollbackException);
            }
            throw new ServletException(exception);
        }
    }
}

线程池会复用线程,所以无论成功还是失败,都必须调用 remove()

错误页面

传统 web.xml 可以统一配置错误页面:

1
2
3
4
5
6
7
8
9
<error-page>
    <error-code>404</error-code>
    <location>/views/error/404.html</location>
</error-page>

<error-page>
    <error-code>500</error-code>
    <location>/views/error/500.html</location>
</error-page>

异常必须继续向容器传播,否则统一错误处理无法感知。

AJAX 与重定向

Servlet 对 AJAX 请求调用 sendRedirect(),改变的是该 AJAX 请求的响应过程,不会自动让顶层浏览器页面跳转。

服务端可以返回跳转地址:

1
2
3
4
{
  "success": false,
  "redirectUrl": "/login.html"
}

前端显式跳转:

1
2
3
4
5
6
7
8
9
10
11
fetch("/cart/items")
  .then((response) => response.json())
  .then((result) => {
    if (result.redirectUrl) {
      window.location.href = result.redirectUrl;
      return;
    }

    document.querySelector("#cart-count").textContent =
      result.totalCount;
  });

这种方式比依赖 AJAX 请求内部的转发或重定向更清晰。