# 前言

以前的 Web 开发会用到 Servlet,在后端使用 Cookie,Session,Fliter,HttpServletRequest 之类的,当然也不代表现在开发 / 维护的项目就没有这些。只是说在 Springboot+vue 开发中,这些东西越来越简化,使用起来越来越便利,前端传过来的数据可以直接用 @RequestMapping 参数接收,界面跳转,设置 cookie 之类的在 vue 就可以完成。

为了完整性,还是将这些在本文写一下。

# 传统 Web 开发

现在的 web 开发大部分都用不到了,但是出于兴趣或者了解知识,我们可以学习一下。tomcat,maven 我就没讲了,可以自己网上搜一下配置(像 springboot 就不需要我们自己配置 tomcat 了)。

创建项目:

  • New Project 左侧选择 Java Enterprise 创建项目。

  • 如果 main 包下没有 webappp 文件夹,就手动创建一个,其中 html 文件就放在里面。

  • 配置项目在 Servlet 里面会讲。

# Servlet

我们先看一下 Web 的执行流程:浏览器输入 URL 发出请求 — 后端 Servlet 处理对应请求 —Servlet 返回请求结果(如重定向到另一界面)。

可以通过实现 Servlet 来进行动态网页响应,使用 Servlet,不再是直接由 Tomcat 服务器发送我们编写好的静态网页内容(HTML 文件),而是由我们通过 Java 代码进行动态拼接的结果,它能够很好地实现动态网页的返回。

先导入依赖:

<dependency>
    <groupId>javax.servlet</groupId>
    <artifactId>servlet-api</artifactId>
    <version>2.5</version>
</dependency>

需要注意的是,Tomcat10 以上的版本比较新,Servlet API 包名发生了一些变化,因此我们需要修改一下依赖:

<dependency>
    <groupId>jakarta.servlet</groupId>
    <artifactId>jakarta.servlet-api</artifactId>
    <version>5.0.0</version>
</dependency>
  • Jakarta 9(2019 及以后)使用 jakarta 命名空间。

  • Java EE 5(2005)到 Java EE 8(2017)使用 javax 命名空间。

  • Java EE 4 使用 javax 命名空间。

然后写一个 Servlet

@WebServlet("/test")
public class TestServlet implements Servlet {
    // 其实直接继承 HttpServlet 也可以
		...实现接口方法
}

项目名为 demo,所以运行,我们就可以访问:http://localhost:8080/demo/test IDEA 里面需要修改一下配置:

再改一下 Deployment:

我们这里统一下, /demo_war_exploded 都改成 /demo

写一个 login.html 放在 webappp 里,就可以访问 http://localhost:8080/demo/login.html 了。

这个着重说一下,访问 html,相当于直接向服务器请求一个静态资源,服务器给的响应(包含)就是 html 资源。之前我们写的直接在浏览器访问 URL .../demo/test 也是一个请求,只不过请求的不是静态资源。

# @WebServlet 注解

可以是通配符匹配:

@WebServlet("/test/*")

上面的路径表示,所有匹配 /test/随便什么 的路径名称,都可以访问此 Servlet。

该注解甚至可以配置多个路径,配置该 servlet 是否在服务器启动时加载此 Servlet 等,这里不再深究。

# 生命周期

运行服务器,发送请求,再关闭服务器,servlet 里面执行的方法顺序是:

  • 先执行构造方法完成 Sevlet 初始化。
  • 调用 init() 方法。
  • Servlet 调用 service () 方法处理请求(有时调用两次是因为浏览器请求 favicon.ico
  • Servlet 销毁前调用 destory 方法。

每当浏览器向服务器发起一个请求时,都会创建一个线程执行一次 service 方法,来让我们处理用户的请求,并将结果响应给用户。

service 方法中,还有两个参数, ServletRequestServletResponse ,实际上,用户发起的 HTTP 请求,就被 Tomcat 服务器封装为了一个 ServletRequest 对象,我们得到是其实是 Tomcat 服务器帮助我们创建的一个实现类,HTTP 请求报文中的所有内容,都可以从 ServletRequest 对象中获取,同理, ServletResponse 就是我们需要返回给浏览器的 HTTP 响应报文实体类封装。

@Override
public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
    
    // 首先将其转换为 HttpServletRequest(继承自 ServletRequest,一般是此接口实现)
    HttpServletRequest request = (HttpServletRequest) servletRequest;
    System.out.println(request.getProtocol());  // 获取协议版本
    System.out.println(request.getRemoteAddr());  // 获取访问者的 IP 地址
    System.out.println(request.getMethod());   // 获取请求方法
    // 转换为 HttpServletResponse(同上)
    HttpServletResponse response = (HttpServletResponse) servletResponse;
    // 设定内容类型以及编码格式(普通 HTML 文本使用 text/html,之后会讲解文件传输)
    response.setHeader("Content-type", "text/html;charset=UTF-8");
    // 获取 Writer 直接写入内容
    response.getWriter().write("我是响应内容!");
    // 所有内容写入完成之后,再发送给浏览器
}

# Post 请求实现登录

这里我们看一下登录实例:

前端 login.html

<body>
    <h1>登录到系统</h1>
    <form method="post" action="login">	<!-- 发送 login 请求,不要加 /,不然就会当成根目录 -->
        <hr>
        <div>
            <label>
                <input type="text" placeholder="用户名" name="username">
            </label>
        </div>
        <div>
            <label>
                <input type="password" placeholder="密码" name="password">
            </label>
        </div>
        <div>
            <button>登录</button>
        </div>
    </form>
</body>

通过修改 form 标签的属性,现在我们点击登录按钮,会自动向后台发送一个 POST 请求,请求地址为当前地址 +/login(注意不同路径的写法,加 / 就是根目录 +/login),也就是我们上面编写的 Servlet 路径。

后端 LoginServlet

@Log
@WebServlet("/login")
public class LoginServlet extends HttpServlet {
    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        // 首先设置一下响应类型
        resp.setContentType("text/html;charset=UTF-8");
        // 获取 POST 请求携带的表单数据
        Map<String, String[]> map = req.getParameterMap();
        // 判断表单是否完整
        if(map.containsKey("username") && map.containsKey("password")) {
            String username = req.getParameter("username");
            String password = req.getParameter("password");
            // 假设有数据库的数据校验
            resp.getWriter().write("登陆成功!");
            // 之后学了请求转发和重定向就可以跳转到其他界面
        }else {
            resp.getWriter().write("错误,您的表单数据不完整!");
        }
    }
}

现在我们请求一下 http://localhost:8888/demo/login.html ,写点东西提交上去,看看访问的 URL

那么我希望登录成功是跳转到另一个界面,就可以使用重定向

if(map.containsKey("username") && map.containsKey("password")) {
    String username = req.getParameter("username");
    String password = req.getParameter("password");
    // 假设有数据库的数据校验
    resp.getWriter().write("登陆成功!");
    resp.sendRedirect("success.html");// 记得在 webapp 下创建一个 success.html
    // 之后学了请求转发和重定向就可以跳转到其他界面
}else {
    resp.getWriter().write("错误,您的表单数据不完整!");
}

调用后,响应的状态码会被设置为 302,并且响应头中添加了一个 Location 属性,此属性表示,需要重定向到哪一个网址。

请求转发是在服务器内部跳转,直接将本次请求转发给其他 Servlet 进行处理,并由其他 Servlet 来返回结果,因此它是在进行内部的转发。

比如登陆后,我希望交给关于时间的 servlet 来处理:

req.getRequestDispatcher("/time").forward(req, resp);

这是在服务器内部跳转的,浏览器是只请求了一次。请求转发的有点就在于可以传输数据。

最后总结,两者的区别为:

  • 请求转发是一次请求,重定向是两次请求

  • 请求转发地址栏不会发生改变, 重定向地址栏会发生改变

  • 请求转发可以共享请求参数 ,重定向之后,就获取不了共享参数了

  • 请求转发只能转发给内部的 Servlet

# ServletContext 对象

这是 Servlet 最后一部分,重定向就可以通过这个传输数据。

ServletContext 全局唯一,它是属于整个 Web 应用程序的,我们可以通过 getServletContext() 来获取到此对象。

ServletContext context = getServletContext();
context.setAttribute("test", "我是重定向之前的数据");
resp.sendRedirect("time");

因为无论在哪里,无论什么时间,获取到的 ServletContext 始终是同一个对象,因此我们可以随时随地获取我们添加的属性。还可以获取根目录的资源文件,注意是 webapp 目录下的文件。

在浏览器中保存一些信息,并且在下次请求时,请求头中会携带这些信息

  • HttpServletRequest 请求获得 cookierequest.getCookie()
  • cookie 写入 HttpServletResponse 响应: response.addCookie(cookie)

Cookie 包含的重要信息:

  • name - Cookie 的名称,Cookie 一旦创建,名称便不可更改。

  • value - Cookie 的值,如果值为 Unicode 字符,需要为字符编码。如果为二进制数据,则需要使用 BASE64 编码。

  • maxAge - Cookie 失效的时间,单位秒。如果为正数,则该 Cookie 在 maxAge 秒后失效。如果为负数,该 Cookie 为临时 Cookie,关闭浏览器即失效,浏览器也不会以任何形式保存该 Cookie。如果为 0,表示删除该 Cookie。默认为 - 1。

  • domain - 可以访问该 Cookie 的域名。如果设置为 “.google.com”,则所有以 “google.com” 结尾的域名都可以访问该 Cookie。注意第一个字符必须为 “.”。

通过 Cookie 来校验登录信息(之前登录过一次,在有效时间内,浏览器再次请求该服务器,会将之前的 Cookie 一起发送过去)

@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    Cookie[] cookies = req.getCookies();
    if(cookies != null){
        String username = null;
        String password = null;
        for (Cookie cookie : cookies) {
            if(cookie.getName().equals("username")) username = cookie.getValue();
            if(cookie.getName().equals("password")) password = cookie.getValue();
        }
        if(username != null && password != null){
            // 登陆校验
            try (SqlSession sqlSession = factory.openSession(true)){
                UserMapper mapper = sqlSession.getMapper(UserMapper.class);
                User user = mapper.getUser(username, password);
                if(user != null){
                    resp.sendRedirect("time");
                    return;   // 直接返回
                }
            }
        }
    }
    req.getRequestDispatcher("/").forward(req, resp);  
    // 正常情况还是转发给默认的 Servlet 帮我们返回静态页面
}

# Session

Session 会给浏览器设定一个叫做 JSESSIONID 的 Cookie,值是一个随机的排列组合,而此 Cookie 就对应了你属于哪一个对话,只要我们的浏览器携带此 Cookie 访问服务器,服务器就会通过 Cookie 的值进行辨别,得到对应的 Session 对象,因此,这样就可以追踪到底是哪一个浏览器在访问服务器。

其实我感觉和 Cookie 使用差不多,只是说 Session 是针对该浏览器和该服务器特殊的 Cookie。

// 设置 session
HttpSession session = req.getSession();
session.setAttribute("user", user);
// 获取 session
HttpSession session = request.getSession();
User user = (User) session.getAttribute("user");
if(user == null) {
    resp.sendRedirect("login");
    return;
}

Session 并不是永远都存在的,它有着自己的过期时间,默认时间为 30 分钟,若超过此时间,Session 将丢失。

# Fliter

在 Session 的代码就可以控制没有登陆的请求跳转到登录界面,这种请求也可以配合过滤器使用。只有过滤器允许通过的请求,才可以顺利地到达对应的 Servlet,而过滤器不允许的通过的请求,我们可以自由地进行控制是否进行重定向或是请求转发。并且过滤器可以添加很多个,就相当于添加了很多堵墙。

@WebFilter("/*")   // 路径的匹配规则和 Servlet 一致,这里表示匹配所有请求
public class TestFilter implements Filter {
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        
    }
}

让请求通过该过滤器:

filterChain.doFilter(servletRequest, servletResponse);

由于我们整个应用程序可能存在多个过滤器,那么这行代码的意思实际上是将此请求继续传递给下一个过滤器,当没有下一个过滤器时,才会到达对应的 Servlet 进行处理。过滤器的过滤顺序是按照类名的自然排序进行的,因此我们将第一个过滤器命名进行调整。

这种模式处理让我想起了 Nettty 的 Channel 🤣。

实际上,当 doFilter 方法调用时,就会一直向下直到 Servlet,在 Servlet 处理完成之后,又依次返回到最前面的 Filter,类似于递归的结构。

同 Servlet 一样,实现一个 Filter 类可以继承 HttpFilter 。下面看一下🌰,为应用程序添加一个过滤器,在用户未登录的情况下,只允许静态资源和登录页面请求通过:

@WebFilter("/*")
public class MainFilter extends HttpFilter {
    @Override
    protected void doFilter(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws IOException, ServletException {
        String url = req.getRequestURL().toString();
        // 判断是否为静态资源
        if(!url.endsWith(".js") && !url.endsWith(".css") && !url.endsWith(".png")){
            HttpSession session = req.getSession();
            User user = (User) session.getAttribute("user");
            // 判断是否未登陆
            if(user == null && !url.endsWith("login")){
                res.sendRedirect("login");
                return;
            }
        }
        // 交给过滤链处理
        chain.doFilter(req, res);
    }
}

# JSP

JSP 并不是我们需要重点学习的内容,因为它已经过时了,使用 JSP 会导致前后端严重耦合,因此这里只做了解即可。

JSP 是模板引擎,模板需要我们填入数据才可以变成页面,我们可以直接在 JSP 中编写 Java 代码,并在页面加载的时候执行。

<h1><%= new Date() %></h1>

这样的写法相当于整个页面既要编写前端代码,也要编写后端代码,随着项目的扩大,整个页面会显得难以阅读。

实际上,Tomcat 在加载 JSP 页面时,会将其动态转换为一个 java 类并编译为 class 进行加载,而生成的 Java 类,正是一个 Servlet 的子类,而页面的内容全部被编译为输出字符串,这便是 JSP 的加载原理,因此,JSP 本质上依然是一个 Servlet!

之后介绍的百里香叶就是一种技能实现模板,又能兼顾前后端分离的模板引擎。

# 参考

https://www.yuque.com/qingkongxiaguang/javaweb/hkgbvo#Listener