JavaWeb之一文搞懂Servlet

126 阅读43分钟

JavaWeb-网络编程

网络编程必然离不开服务器,而tomcat服务器则是用的比较广泛的一种

Tomcat下载

  • tomcat.org.apache 访问官网
  • tomcat还有另外一个名字:catalina(美国的一个岛屿,风景亮丽,据说作者是在这个岛上开发了一个轻量级的web服务器,体积小,运行速度快,因此tomcat又被称为catalina
  • tomcatjava语言写的
  • tomcat服务器想要运行,必须有jre(java的运行时环境)
  • tomcat的logo是一只公猫,寓意表示Tomcat服务器是轻巧的,小巧的)

在官网上下载对应版本和目标系统的安装包,这里我选择了Tomcat10

理论上安装完tomcat后是需要配置CATALINA_HOME环境变量的,但现在即使不配置也能启动,原因是脚本中有判断配,如果当前环境变量没有配置,会自动将tomcat的应用目录配置为CATALINA_HOME,这里就不再配置了

启动Tomcat

  1. 启动Tomcat服务器之前,请先安装JAVAJDK(纯运行只需要JRE),并配置JAVA_HOME环境变量,这里不再赘述

  2. 进入解压后的tomcat目录下的bin目录,里面存放着一些.bat.sh后缀的文件,batwindows系统的批处理文件,而sh则是在Unix系统中可以执行的shell命令(这里以执行sh文件演示)。将工作目录切换到服务器的bin下,输入./startup.sh后回车可以看到有输出Tomcat started即为成功。(想在任意目录下都可执行,只需要将bin目录添加到path中) image.png 实际上,执行startup.sh后,shell脚本也是去执行bin目录下的catalina.sh文件

  3. 执行catalina.sh后,可以看到提供了一堆可执行参数,以及这些参数的作用

  4. 选择输入start参数,可以看到和执行startup的输出一样

简单的了解Tomcat目录结构

由于默认的端口号配置的是8080,当服务器启动后,可以通过http://localhost:8080或者ip去访问根应用,不出意外的话会看到下面这个网站,说明Tomcat已经配置好且启动成功了(想关闭可以通过执行shutdown.sh)

如果Windows机器跑起来后有乱码问题

可以将tomcat目录下的conf文件夹中的logging.properties文件中,找到

UTF-8修改的GBK,编码格式不对的问题即可解决

Servlet规范

Servlet规范属于JavaEE规范之一,只要是符合Servlet规范的webapp就可以放到遵循Servlet规范的Web服务器中执行,规范了哪些东西?

  • 规范了Java接口和实现类
  • webapp应该有的配置映射文件
  • Java Servlet和配置文件的指定存放位置等
  • 我们必须遵守规范,才可以让我们的应用按照规则进行运行

开发一个Servlet WebApp

  1. 现在tomcat服务器下的webapps中,新建一个文件夹,取名为项目名称,这里示例取名为javaweb
  2. javaweb目录下新建一个文件夹叫WEB-INF,这是规范的要求,不可以随意更改
  3. WEB-INF中新建classes文件夹,存放的是我们最终编写的java程序编译后的class字节码文件
  4. WEB-INF中新建lib文件夹,这里存放的是java程序会依赖用到的第三方jar包,也是规范之一,不可以随意更改
  5. WEB-INF中新建web.xml文件,文件内容如下,格式可以从tomcat自带的webapp中的web.xml抄过来,留下一个空内容的模板,后续的java程序与访问地址需要通过这个web.xml进行配置
      <?xml version="1.0" encoding="UTF-8"?>
      
      <web-app xmlns="https://jakarta.ee/xml/ns/jakartaee"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee
                            https://jakarta.ee/xml/ns/jakartaee/web-app_5_0.xsd"
        version="5.0"
        metadata-complete="true">
      
      </web-app>
  1. 开始编写第一个Servlet程序,在这之前,需要知道的一些知识点:
    • JavaEE最高版本为JavaEE8
    • 目前已经归Oracle公司管,JavaEE9后更改名字后不再叫JavaEE而是叫jakarta EE,所以对应的就是JakartaEE9
    • 带来的问题就是以前的包名是javax.servlet.Servlet,现在改成了jakarta.servlet.Servlet
    • 如果编写的Java程序使用的包名是javax.servlet.Servlet,则无法放到Tomcat10+的版本运行(这里装的是Tomcat10),只能部署到Tomcat9-到服务器上
    • 这里先手撸,后续再使用IDEA进行开发,需要的只是Java编译后的class字节码文件,所以任意目录下开发编写即可

开发一个Servlet应用

需要实现Servlet接口的规范,也就是lib目录下的servlet-api.jar中的类

这个接口中需要实现的方法,也可以在官方文档中找到

编写代码:

package cn.webapp;

import jakarta.servlet.*;

public class FirstApp implements Servlet {
    @Override
    public void init(ServletConfig servletConfig) {
        System.out.println("First App run");
    }

    @Override
    public ServletConfig getServletConfig() {
        return null;
    }

    @Override
    public void service(ServletRequest servletRequest, ServletResponse servletResponse) {
        System.out.println("执行了servic");
    }

    @Override
    public String getServletInfo() {
        return null;
    }

    @Override
    public void destroy() {
    }
}

此时如果直接使用javac命令进行编译,百分百会报错,原因是我们使用了jakarta包下的类,但没有告诉javac去哪里找,所以需要使用-cp指定jar包的目录,顺便使用-d指定编译后的产出位置,根据上面说的,我们需要放置到tomcat目录下的webapps下的【项目名文件夹】/classes文件夹下,所以最终要执行的javac命令如下

编译成功即可看到class文件

接着修改上面定义过的WEB-INF下的web.xml,添加urlServlet程序的关系

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="https://jakarta.ee/xml/ns/jakartaee"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee
                      https://jakarta.ee/xml/ns/jakartaee/web-app_5_0.xsd"
  version="5.0"
  metadata-complete="true">
  <!-- 定义servlet -->
  <servlet>
    <servlet-name>first</servlet-name>
    <servlet-class>cn.webapp.FirstApp</servlet-class>
  </servlet>

  <!-- 形成映射 -->
  <servlet-mapping>
    <!-- 这里的name需要和servlet定义的name一致 -->
    <servlet-name>first</servlet-name>
    <!-- 这里的url需要以/开头,其他随意 -->
    <url-pattern>/first</url-pattern>
  </servlet-mapping>
</web-app>

接着通过catalina.sh run执行(我配置了path,所以可以这么用,否则请使用相对或者绝对路径),可以看到tomcat服务器没有报错,并且显示名为javaweb的应用已部署

在浏览器地址栏中输入http://localhost:8080/javaweb/first后按回车,回看到一片空白(没有处理响应的原因),但是回到shell中,可以看到tomcat服务器运行了我们编写的java代码,输出了init方法和service方法中的内容,如此便搞懂了Tomcat的执行机制和原理

想试错的朋友可以把Tomcat版本装个9的,一成不变的执行上述步骤,便会得到一个Servlet -- ClassNotFound的报错,原因就是因为包名不对,找不到jarkarta开头的包,改成javax后重新编译即可

理解了Tomcat如何运行的,使用IDEA开发就更简单

  1. 新建一个Java项目
  2. 右键项目选择add Framework Support,然后选择Web Application,便会为我们生成WEB-INF等一些列结构目录

照常编写代码后,需要处理IDEA找不到Servlet类的问题,此时需要添加项目依赖的jar包,在Project Structure中选择对应的模块,再选择Dependencies,点击小➕号后添加jar包,把Tomcat目录下的lib中的servlet-api.jar添加进来,即可解决问题

接着在运行配置中,点击小➕号选择Tomcat服务器,并选择好服务器所在的目录(CATALINA_HOME)Server中的默认参数基本不用动,选择Deployment进行部署应用

其中的Application context这个名字不能随便写,需要匹配到Tomcat目录中webapps里的应用名字

配置完后点击运行或者debug即可启动Tomcat并部署编写好的Servlet

Servlet的生命周期(Lifecycle)

如果给我们自己编写的Servlet添加一个无参构造函数并添加一些打印信息

  • Tomcat启动后,可以看到并不会输出内容,说明默认情况下不会实例化。
  • 当通过url访问后,便会对目标映射的对象进行实例化,先执行无参构造函数,再执行init方法,然后再执行service方法(由于是假单例模式,后续即使再访问1万次,也只会执行service方法,而不会执行构造方法和init方法)
  • 当服务器被关闭后,Servletdestroy方法便会被执行,由于是实例方法,在执行destroy后对象才被销毁释放

构造方法与生命周期

  • web.xml中,如果为servlet标签添加了<load-on-startup>0</load-on-startup>标签,便会在Tomcat启动后马上进行实例化,其中的整数表示优先级,越小优先级越高
  • 如果添加了一个非无参构造方法,但是又没有手动的添加无参构造,此时运行后就会出现服务器异常,Tomcat无法实例化Servlet,所以一般不建议开发者手动处理构造函数
  • 所以便有了init方法,若需要做一些执行一次的事情,如链接数据库等操作,便可以放在init生命周期函数中
  • destroy方法便是可以在销毁前去做一些释放资源或者存档等事情

引出新问题

现在的编写问题实际上非常的繁琐,每实现一个Servlet都要实现所有接口的方法,而实际上我们只想处理service方法业务,好在Tomcat已经有写好的一个抽象类,后续我们就不用再implements Servlet了,只需要extends GenericServlet,并且只实现service方法即可,因为其他方法在GenericServlet都有默认实现了

这里可以思考一下GenericServlet中的两个init方法的逻辑

我们都知道init方法有一个默认的ServletConfig对象传入作为第一个参数,很明显是通过Tomcat服务器传入的,那么GeneriServlet中做了一点操作,它内部定义了一个私有变量config,在Tomcat调用Servletinit方法时,把传入的Servlet对象保存到了对象实例成员中,然后再实现一个方法getServletConfig把这个私有成员返回出去,后续只需要调用这个方法就能获取到ServletConfig对象

而之所以有两个init,是因为如果开发者Override重写了带参的init后,父类的私有成员config便不会再被赋值,后续调用getServletConfig也只能获得null,所以提供了一个空参的init方法,如果确实需要在这个钩子处理一些事物,自己的实现类重写空参的init方法即可,因为父类带参的init会被tomcat调用,而这个方法又调用了无参的init,所以既保证了私有成员config的存在,也可以让开发者运行自己的代码逻辑。

ServletConfig是什么?

Servlet中我们获取并输出到控制台即可看到,实际上是Tomcat的一个实现类StandardWrapperFacade实现了这个规范,不同的Web服务器可能实现不同,但都肯定有这么一个实现类,在控制台输出查看

并且,不同的Servlet对象会有不同的ServletConfig实例对象,他们之间是属于1对1等关系,伪代码可以通过下面的推算帮助理解

public class TomcatImpl{
    public static void main(String[] args){
    	// 通过反射进行对象实例化
    	Class klass Class.forName("web.xml中某个servlet标签中servlet-class的全限定类名");
        // 多态类型转换,实例化目标Servlet
        Servlet servlet = (Servlet)klass.newInstance();

        // 创建一对一等配置实例对象
        ServletConfig servletConfig = new org.apache.catalina.core.StandardWrapperFacade();

        // 将servletConfig传入到init方法中
        servlet.init(servletConfig);
        
        servlet.service();
        // ....
    }
}

Tomcat会去解析web.xmlservlet标签内的信息,并注入到ServletConfig对象中,使程序运行时可以获取到配置中的信息,如通过init-param标签添加的servlet-nameinit-param的值

<servlet>
  <servlet-name>test</servlet-name>
  <servlet-class>cn.mgl.Test</servlet-class>
  <init-param>
    <param-name>driver</param-name>
    <param-value>com.mysql.cj.driver.Driver</param-value>
  </init-param>
  <init-param>
    <param-name>user</param-name>
    <param-value>root</param-value>
  </init-param>
</servlet>

可以通过ServletConfig提供的方法getInitParameterNamesgetInitParameter获取到配置中的值

ServletContext

ServletContext也是规范中的一个接口,Tomcat服务器也有其对应的实现类。在同一个webApp中,创建了两个Servlet,并且都在service方法中输出了ServletContext对象

可以观察到,实际上都是同一个对象

  • 在同一个webapp中,多个servlet共享一个servletContext(俗称应用上下文,应用域)
  • tomcat服务器下的webapps中有多少个webapp,就会有多少个servletContext
  • 一个ServletContext通常对应一个web.xml文件
  • 对象的创建由Tomcat完成,在启动webapp时创建

ServletConfig类似,都可以进行一些初始化参数的配置,一般来说如果配置就是要配置一些所有Servlet共享的,ServletConfig的叫init-param,而ServletContext则叫context-param,下面代码中定义了一个共享的配置分页页数为10条

<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
         version="4.0">
    <context-param>
        <param-name>page</param-name>
        <param-value>10</param-value>
    </context-param>
    <!--servlet省略-->
</web-app>

下面代码包含了获取servlet名字,servlet配置和context的配置信息,this可以不加,父类的方法可以直接调用,加了纯粹为了示例区分下代码

public class MainServlet extends GenericServlet {
    @Override
    public void service(ServletRequest servletRequest, ServletResponse servletResponse) {
        System.out.println("MainServlet");
        ServletConfig servletConfig = this.getServletConfig();
        String servletName = servletConfig.getServletName();
        System.out.println("servletName:" + servletName);
        // 获取当前servlet的配置信息
        Enumeration<String> initParameterNames = servletConfig.getInitParameterNames();
        while (initParameterNames.hasMoreElements()) {
            String key = initParameterNames.nextElement();
            System.out.println(String.format("key:%s,value:%s", key, getInitParameter(key)));
        }
        System.out.println("------------------------");
        // 获取servletContext的配置信息
        ServletContext servletContext = this.getServletContext();
        System.out.println("MainServlet获取到的servletContext:" + servletContext);
        // 需要注意后续,servletContext和config的方法名都一样,书写上需要区分
        Enumeration<String> initParameterNamesFormContext = servletContext.getInitParameterNames();
        while (initParameterNamesFormContext.hasMoreElements()) {
            String s = initParameterNamesFormContext.nextElement();
            System.out.println(String.format("key:%s,value:%s", s, servletContext.getInitParameter(s)));
        }
    }
}

访问servlet后输出的内容如下

  • servletContext.getContextPath方法获取应用的根路径,重点在于动态,因为部署时webapp取的名字没法确定,如webapp名为xxx,则获取到的就是/xxx
  • servletContext.getContextRealPath("/index.html"),入参为String,需要传入一个从应用根路径开始算起的文件名,返回对应的磁盘真实路径,拿到这个地址就可以进行一些IO流操作,路径中的/可以不加

利用ServletContext写日志

在使用IDEA启动项目时,可以留意控制台前几行输出,会发现有一个CATALINA_BASE变量的目录输出,在配置中配置了tomcat的实际目录,idea将这个tomcat拷贝了一份,并在运行时使用了拷贝的那一份,所以如果去配置的tomcat目录是找不到对应信息的

    	// 其他代码省略
        servletContext.log("调用context的log方法写入日志,欢迎访问");
    	servletContext.log("写一个可抛出的异常",new RuntimeException("认真的吗"));

执行后,会在CATALINA_BASE目录下/logs/localhost.[日期].log中进行日志写入

logs目录下有3种类型的日志文件

  1. catalina.[日期].log 用于记录服务器端java的控制台输出信息
  2. localhost.[日期].log 用于记录ServletContext对象的log方法的记录信息(开发者没有手动调,如报错等情况下Tomcat容器也会去执行这个方法记录日志)
  3. localhost_access_log.[日期].txt 用于记录访问日志(地址 时间 资源 状态)

利用ServletContext进行应用域的缓存

所谓的缓存就是为了减少开销,所有服务公用同一份资源,缓存的东西应该满足以下条件

  1. 较少的被更改(若会被频繁的修改,会涉及线程安全问题)
  2. 且数据量不大(数据量过大会占用内存资源,从而影响服务器性能)
  3. 需要被所有服务共享(不需要共享的话放进干啥~)

ServletContext提供了以下3个API进行读写操作

  • public void setAttribute(String name,Object value); 新增和修改,修改直接覆盖即可
  • public Obejct getAttribute(String name); 获取
  • public void removeAttribute(String name); 删除

api设计的好,其实一看就知道如何使用,只要在同一个应用域下,所有Servlet都可以访问到这一份缓存数据(需要注意线程安全问题)

基于Http协议开发

由于开发的为B/S结构应用,都是根据Http协议规范进行数据传输,所以有一个专门的实现类叫做HttpServlet,让开发者处理Http协议更加方便,只需要让原本继承GenericServlet类的改成继承HttpServlet(它继承了GenericServlet

但是得先了解下什么是Http协议

HTTP协议

超文本传输协议HTTP)是一个用于传输超媒体文档(例如 HTML)的应用层协议。它是为 Web 浏览器与 Web 服务器之间的通信而设计的,但也可以用于其他目的。HTTP 遵循经典的客户端 - 服务端模型,客户端打开一个连接以发出请求,然后等待直到收到服务器端响应。HTTP 是无状态协议,这意味着服务器不会在两个请求之间保留任何数据(状态)。尽管通常基于 TCP/IP 层,但它可以在任何可靠的传输层上使用,也就是说,该协议不会像 UDP 那样静默的丢失消息。RUDP——作为 UDP 的可靠化升级版本——是一种合适的替代选择。

  • 超文本的意思是除了字符外,还可以传输流媒体如图片,音频视频等内容
  • 浏览器与服务器之间通讯需要遵循HTTP规范
  • 浏览器向服务器发送数据(称为请求Request
  • 服务器向浏览器发送数据(称为响应Response
  • 不限定浏览器(Chrome),不限定服务器(Tomcat),只要遵循规则即可进行通讯

协议规定的请求格式(Request)

Get请求报文
GET /xmm/login.html HTTP/1.1(请求行)
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate, br
Accept-Language: en,zh-CN;q=0.9,zh;q=0.8
Cache-Control: no-cache
Connection: keep-alive
Cookie: Idea-53d14c26=0270aae0-2b88-451d-8e75-71e5c8948756
Host: localhost:9999
Pragma: no-cache
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: none
Sec-Fetch-User: ?1
Upgrade-Insecure-Requests: 1(从Accept开始都是请求头)
(空白行)
(请求体)

最后有两行空格,一行是空白行,一行是请求体

Post请求报文
POST /xmm/test/post HTTP/1.1 (请求行)
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6
Cache-Control: max-age=0
Connection: keep-alive
Content-Length: 26
Content-Type: application/x-www-form-urlencoded
Host: localhost:9999
Origin: http://localhost:9999
Referer: http://localhost:9999/xmm/login.html
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: same-origin
Sec-Fetch-User: ?1
Upgrade-Insecure-Requests: 1 (从Accept开始都是请求头)
(空白行)
username=jack&password=123 (请求体)
  1. 请求行,有3部分
    • 请求方式(7种):get,post,delete,put,head,options,trace
    • URI,统一资源标识符,代表网络中的某个资源名称,但是无法通过URI定位,URL才是统一资源定位符,可以准确的访问。可以简单理解为不带主机端口的/xmm/login.htmlURIhttp://localhost:9999/xmm/login.htmlURL
    • HTTP协议版本号 普遍为1.1
  2. 请求头,主要形式为键值对
  3. 空白行,可以理解为分割线
  4. 请求体,客户端需要向服务器发送的数据

协议规定的响应格式(Response)

HTTP/1.1 200 ok(响应行)
Content-Type: application/json;charset=utf-8
Content-Length: 33
Date: Fri, 09 Sep 2022 01:25:47 GMT
Keep-Alive: timeout=20
Connection: keep-alive(从Content-type开始都是响应头)

{"status":"success","code":200} (响应体)
  1. 状态行有三部分组成
    • 协议版本号
    • http协议状态码,列举一些常见的
      • 200表示成功
      • 401表示为进行权限校验(可以理解为未登录)
      • 403表示无权限(可以理解为登录了,但是确实没有资格访问)
      • 404表示资源不存在,常规情况下是客户端uri写错(一般4开头的都是客户端问题)
      • 500表示服务端错误,一般5开头的都是服务器问题
      • 有一个用猫来表示状态的网站,感兴趣可以看看 http猫
    • 状态的描述
      • ok 表示成功结束
      • not found 表示找不到资源
  2. 响应头,和请求报文的请求头类似,都是键值对的形式
  3. 空白行
  4. 响应体,其实就是一堆字符,会被浏览器解析渲染输出执行等,需要根据具体内容确定

Post和Get有什么区别

这是一个老生常谈的问题,从传输协议层上getpost没有任何本质的区别(以get举例,get请求也能带请求体,post请求也能用查询参数传参),都是一堆字节流在B/S传输,但由于浏览器或者服务器有一些规则筛选拦截,造成了即使客户端发了一个get请求带上请求体,但服务端却没有拿到

  • get请求一般发送数据放到URI后面,如/xmm/login?pageSize=10&page=1,以号文分割线,后面的都是键值对形式的数据,俗称查询参数,这里想表达的就是分页查第一页,每页十条(也就是get请求普遍在请求行发数据)
  • post请求一般在请求体中发送,数据不会出现在浏览器的地址栏中
  • get请求只能发送普通的字符串,且有长度限制,但不同浏览器限制不同,所以无法发送大量数据
  • post请求可以发任意类型数据,字符,流媒体,且可以发大数据,理论上没有长度限制
  • 一般来说使用get来获取数据(查),post发送数据(增删改)
  • 安全性
    • get是绝对安全的,因为不会修改服务器上的数据
    • post请求由于会修改服务器上的数据,需要谨慎操作
  • 缓存性
    • get支持缓存
    • 浏览器会有自己的缓存实现,如果在浏览器的缓存找不到才会再次从服务器获取
    • get请求不想被浏览器缓存的话,可以在uri上加特定的标识,比如在get请求的查询参数上加上时间戳,可以保证每一次的请求都是不一样的,”跳过“浏览器的缓存机制
    • post不支持缓存,因为post的目的是修改行为,如果缓存也没有太大意义,因为每次的结果并不绝对一致

解析HttpServlet源码

	// 这个方法是重写了GenericServlet的service
	// 可以上就是做了一个类型转换后,再将参数向下传递
    public void service(ServletRequest req, ServletResponse res) throws ServletException, IOException {        
        HttpServletRequest request;
        HttpServletResponse response;
        try {
            // ServletRequest通过类型强转成HttpServletRequest
            // ServletResponse通过类型强转成HttpServletResponse
            request = (HttpServletRequest)req;
            response = (HttpServletResponse)res;
        } catch (ClassCastException var6) {
            throw new ServletException(lStrings.getString("http.non_http"));
        }
        // 接着调用了目标方法,利用了重载,参数类型不同
        this.service(request, response);
    }

    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        // 获取请求的方法名称
        String method = req.getMethod();
        long lastModified;
        // 接着就是判断请求方法来决定走哪个分支,最后调用哪个方法
        // 这种设计模式就是模板方法
        // 定义好一系列的逻辑算法,但是具体的行为模板并不负责,子类只需要去实现要完成的目标方法
        if (method.equals("GET")) {
            lastModified = this.getLastModified(req);
            if (lastModified == -1L) {
                this.doGet(req, resp);
            } else {
                long ifModifiedSince;
                try {
                    ifModifiedSince = req.getDateHeader("If-Modified-Since");
                } catch (IllegalArgumentException var9) {
                    ifModifiedSince = -1L;
                }

                if (ifModifiedSince < lastModified / 1000L * 1000L) {
                    this.maybeSetLastModified(resp, lastModified);
                    this.doGet(req, resp);
                } else {
                    resp.setStatus(304);
                }
            }
        } else if (method.equals("HEAD")) {
            lastModified = this.getLastModified(req);
            this.maybeSetLastModified(resp, lastModified);
            this.doHead(req, resp);
        } else if (method.equals("POST")) {
            this.doPost(req, resp);
        } else if (method.equals("PUT")) {
            this.doPut(req, resp);
        } else if (method.equals("DELETE")) {
            this.doDelete(req, resp);
        } else if (method.equals("OPTIONS")) {
            this.doOptions(req, resp);
        } else if (method.equals("TRACE")) {
            this.doTrace(req, resp);
        } else {
            String errMsg = lStrings.getString("http.method_not_implemented");
            Object[] errArgs = new Object[]{method};
            errMsg = MessageFormat.format(errMsg, errArgs);
            resp.sendError(501, errMsg);
        }

    }

	// 以doGet举例,如果子类不重写该方法,访问该Servlet后就会有错误响应(描述方法不支持的响应)
	// 如果请求是get方法,doGet就会被执行
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        String msg = lStrings.getString("http.method_get_not_supported");
        this.sendMethodNotAllowed(req, resp, msg);
    }

	// 只有这里走了HttpServlet的service逻辑才会被调用,因为是私有方法,子类无法调用
    private void sendMethodNotAllowed(HttpServletRequest req, HttpServletResponse resp, String msg) throws IOException {
        String protocol = req.getProtocol();
        if (protocol.length() != 0 && !protocol.endsWith("0.9") && !protocol.endsWith("1.0")) {
            resp.sendError(405, msg);
        } else {
            resp.sendError(400, msg);
        }
    }

由源码可以得出总结:

  1. Servlet想要响应什么请求方式,就重写对应的方法,请求get就子类重写doGet,请求post就重写doPost
  2. 如果又想重写post又想重写get,那可以考虑重写service方法,改掉定义好的模板(实际上并不会这么做)
  3. 如果重写了service,就无法享受默认的405错误,因为这个方法是父类的私有方法

Tomcat站点的欢迎页

访问webapp不带任何资源路径时,就相当于访问欢迎页,在应用的WEB-INF同层级新建一个index.html文件,输入任意内容,然后启动站点访问webapp,如应用根路径为xxx,则访问http://localhost:8080/xxx就能访问到对应的欢迎页,根本原因是因为Tomcat有一个全局的默认配置,在Tomcat目录下/conf/web.xml文件中,有以下welcom-file-list配置

意思是代表着欢迎页的资源名称,优先级为在前优先,但其实是可以在应用级别的web.xml中配置更改自己的欢迎页,局部的web.xml配置会优先于全局的web.xml配置,这个欢迎页除了是资源文件以外,还可以是开发者自定义的servlet资源

public class XxServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        response.setContentType("text/html");
        PrintWriter out = response.getWriter();
        out.print("<h1>这是Servlet自定义的欢迎页</h1>");
    }
}
  1. 配置好XxServlet对应的servlet标签servlet-mapping标签后
  2. welcome-file中填写mappingurl-pattern值,开头的/要去掉,加了反而找不到
静态资源问题

当尝试在WEB-INF中放置静态资源,尝试通过/xxx/WEB-INF/任意资源,会出现404错误,WEB-INF有保护机制,无法直接进行路径访问,所以有静态资源存放需求时,必须放在WEB-INF

HttpServletRequest

Tomcat服务器会负责将这个抽象类实现,并将http报文中的全部信息封装到这个对象当中,让开发者直接使用HttpServletRequest编程就能获取数据。将HttpServletRequest输出可以看到其对应的实现类

  • 上面的HTTP知识中,描述了get请求一般通过查询参数的形式传参给服务器,也就是/aaa/bbb?pageSize=10&page=1这种形式,需要提出的是,存储结构并不是Map<String,String>,而是Map<String,String[]>,原因也很简单,用前者的数据结构会被后来者覆盖,而用数组则不会。为了演示数组结构再额外传一次pageSize:/aaa/bbb?pageSize=10&page=1&pageSize=20,并演示几个API的用法
@WebServlet("/test")
public class TestGetServlet extends HttpServlet {
	@Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        // 第一种,获取纯字符串,自己去解析
        String queryString = req.getQueryString();
        System.out.println("通过getQueryString获取查询参数的字符串" + queryString);
        System.out.println("-----------------------------");

        // 第二种,获取Map形式
        Map<String, String[]> parameterMap = req.getParameterMap();
        parameterMap.forEach((key, values) -> {
            System.out.println("getParameterMap遍历获取到的key:" + key);
            System.out.println("getParameterMap遍历获取到的values:" + Arrays.toString(values));
        });
        System.out.println("-----------------------------");

        //第三种,通过获取所有的name,再从name获取到所有的key
        Enumeration<String> parameterNames = req.getParameterNames();
        while (parameterNames.hasMoreElements()) {
            String key = parameterNames.nextElement();
            System.out.println("parameterNames获取到的key:" + key);
            System.out.println("通过key获取数组的第一个值:" + req.getParameter(key));
            System.out.println("通过key获取的数组值:" + Arrays.toString(req.getParameterValues(key)));
        }
    }
}

获取到的数据如图

可以看到,实际上key对应的value是一个String的数组,但理论上一般不会这么去传参

  • 所以使用getParameter去获取数组的第一项即可,不需要使用getParameterValues
  • getParameterMap可以返回Map对象
  • getParameterNames返回Map集合中所有的key

get请求也可以在请求体传递数据,感兴趣的可以自行尝试,用字节流获取getInputStream,用Postman或curl等工具直接发送请求报文(上面说过,和post没有本质求别)

get请求由于是在请求行相对简单,看看post请求

常规情况下,post请求会将要传输的数据放在请求体中,但是同样的可以给uri加上查询参数

这里利用Postman工具进行post请求的发送

public class TestPostHttpServlet extends HttpServlet {
    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws UnsupportedEncodingException {
        Enumeration<String> parameterNames = req.getParameterNames();
        while (parameterNames.hasMoreElements()) {
            String key = parameterNames.nextElement();
            System.out.println("parameterNames获取到的key:" + key);
            System.out.println("通过key获取数组的第一个值:" + req.getParameter(key));
            System.out.println("通过key获取的数组值:" + Arrays.toString(req.getParameterValues(key)));
        }
    }
}

执行后会发现,post请求中Tomcat的实现会把查询参数和请求体中的数据进行合并归纳到一个Map当中,但是这个是有前提条件的,那就是请求头中的Content-Type的值必须是:application/x-www-form-urlencoded,这是浏览器默认的表单值,作用就是把表单内容中所有键(name)值(value)对通过=&进行拼接,服务器就知道怎么去解析这段请求体了

如果是别的Content-Type的话,服务器便无法解析,比如常用的application/json,此时便需要用到字节输入流对象

public class TestPostHttpServlet extends HttpServlet {
    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        // request提供了getInputStream的方法获取字节流
        ServletInputStream is = req.getInputStream();
        // 字节流是默认采用ASCII码字符集,中文不适用,所以需要转成字符流
        // 字符流InputStreamReader指定了字符集为UTF-8
        InputStreamReader inputStreamReader = new InputStreamReader(is, StandardCharsets.UTF_8);
        int b;
        while ((b = inputStreamReader.read()) != -1) {
            System.out.print((char) b);
        }
        System.out.println("\n------------------");
        Enumeration<String> parameterNames = req.getParameterNames();
        while (parameterNames.hasMoreElements()) {
            String key = parameterNames.nextElement();
            System.out.println("parameterNames获取到的key:" + key);
            System.out.println("通过key获取数组的第一个值:" + req.getParameter(key));
            System.out.println("通过key获取的数组值:" + Arrays.toString(req.getParameterValues(key)));
        }
    }
}

测试结果,把请求体的Content-Type改成application/json,并输入一些字符(虽然是json格式,实际就是字符而已)

服务端得到的输出如图,可以成功的通过输入流拿到请求体,其实在后续要学习的SpringMvc中,有一个@RequestBody注解,其原理也是通过输入流拿到Json字符串再转成Object,在如今普遍前后端分离的前提下,基本都是使用Json规格进行数据传输交互

Request接口的常用方法
  • getRemoteAddr 获取客户端的ip地址
  • getMethod 获取请求方法 GET
  • getContextPath 获取应用(webapp)根路径 /xxx
  • getServletPath 获取webapp名称 /xxServlet
  • getRequestURI 获取包含webapp名称的路径 /xxx/xxServlet/ccc
  • setCharacterEncoding("utf-8") 用于在Tomcat9-中,请求体带有中文时乱码问题,Tomcat10已经默认为utf-8了,所以不需要处理,感兴趣的可以切换到Tomcat9测试,注意jakartajavax的包名更换
  • tomcat8之后,由于默认就有<Connector URIEncoding="UTF-8" />这一行参数配置,所以请求行不会有乱码问题

请求域对象

ServletContext是应用域,他的生命周期一直持续到Tomcat服务器关闭,而请求域的生命周期只在当前请求内有效,直到响应结束,请求域就会被销毁,100次请求就会有100次请求域的生成和销毁。

HttpServlet也提供了和ServletContext签名一样的数据缓存方法(get,set,remove),用于多个Servlet进行数据共享(转发)

    // 重写doGet方法用于响应get请求
    // 此时通过post请求此Servlet就会报405的错
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
		req.setAttribute("user", "jack"); // 设置值
        req.setAttribute("age", 100); // 设置值
        String user = (String) req.getAttribute("user"); // 获取值
        req.removeAttribute("user"); // 删除值
        // 只在这一次请求有效
    }

Request转发

转发和重定向有区别,对于客户端来说,转发还是一次请求,这里的转发指的是服务器内部转发,客户端请求的是/aaa,在/aaa的业务处理中又转发到了/bbb/bbb又转发到了/ccc,客户端并无感知

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
		req.setAttribute("user", "jack"); // 设置值
        req.setAttribute("age", 100); // 设置值
        String user = (String) req.getAttribute("user"); // 获取值
        req.removeAttribute("user"); // 删除值
        
        // 获取分发器
        RequestDispatcher requestDispatcher = req.getRequestDispatcher("/user");
        // 也可以转发到静态资源,只要是服务器合法的资源都可以
        //RequestDispatcher requestDispatcher = req.getRequestDispatcher("/login.html");

        // 进行转发,并把当前的req和resp进行传递
        requestDispatcher.forward(req, resp);
    }
  1. 只要是服务器合法资源都可以转发,不一定是servlet,路径以/开始,不用加webapp根路径
  2. 需要调用RequestDispatcherforward方法
  3. 代码结合了请求域的缓存案例,被转发目标servlet可以从HttpServletRequest获取设置的attribute
  4. 重定向有两个方法,forwardinclude
    1. 区别在于回不回回流,可以把forward看成飞镖,直线穿越,/a-->/b,如果在/a中处理调用了forward后,写在forward后面的代码不会再执行
    2. include就像回旋镖,/a-->/b-->/a,在/a中先转发到/b/b处理完后回到/a继续处理,并且/b中不可以设置响应行和响应头,即使写了也不会有实际效果,/b属于被包含的,只能处理响应体,最后回到/a时,会将/b/a的响应头合并再将响应报文返回
    3. include方法,在被包含者调用HttpServletRequest.getRequestURI(),获取到的值是/a的值,而forward获取到的则是/b
    4. 过于简单自行测试即可
  1. 使用重定向方式浏览器地址栏不会变化

sendRedirec

这里提前讲一个HttpServletResponse接口的方法sendRedirect方法,这个叫做重定向,会造成浏览器的刷新(用Postman,curl等发送则没有对应效果),本质是因为浏览器会去解析报文并去执行对应行为

当浏览器看到状态为302Location有对应URI时,会进行一次页面的跳转,由于是要被浏览器执行的,所以resp.sendRedirect("/xmm/wechat");需要把webapp的名称写上,被重定向的URI只要是合法的服务器资源都可以

类似于用户a打了个电话找bb回复了a并告诉a去找c,然后电话自动的进行了拨号给c

HttpServletResponse

用来操作响应对象,可以控制响应行,响应头,响应体的具体内容,Tomcat也有对应的实现类

直接看代码用法

@WebServlet("/testget")
public class TestHttpServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        resp.setStatus(HttpServletResponse.SC_NOT_FOUND); // 耍你404
        resp.setContentType("application/json;charset=utf-8");
        resp.getWriter().println("{"status":"success","code":200}");
    }
}

代码中设置了响应头,状态码,和响应体的内容。输出流可以决定返回的内容,这里有一坨毒瘤代码,我故意将响应体的状态码设置成404,所以说如果接口返回404不一定就是前端的问题,后端手欠也是可能存在的,除了状态行以外,其他数据都和正常请求结束一模一样

示例代码使用了WebServlet注解,简化配置

Servlet3.0开始,就有注解开发的方式,优点是不需要在xml中去配置,直接通过java代码添加注解的形式,达到一样的效果,相当于在xml中配置类了资源路径和类名映射

B/S系统的会话机制(俗称session)

当用户打开浏览器,访问站点,做他想做的事,关闭浏览器,这个过程就是一次会话,会话期间,在Java端会有一个Java对象叫做session,一次会话中可以有无数个请求

之所以有这个会话机制,主要原因是因为Http协议是无状态的,每一次请求发送都和上一次的没有关系(全新),这就导致了没有办法区分用户是否登录之类的状态。并且在请求结束后,链接就断开了,服务端没有办法可以知道客户端(浏览器)被关闭的这个动作。

原理:

  • jackEdge浏览器访问了服务器,服务器内部生成了一个此次Edge浏览器的专属Session对象
  • jackChrome浏览器访问了服务器,做同样的行为
  • 此时服务器内存中维护着一个session列表,数据结构是Mapkey就是sessionIdvalue就是对应的Session对象
  • 在第一次请求中,用户的请求响应头带有一个keySet-Cookie,值为服务端设置的key&value及有效期等信息,浏览器会自动解析存储做对应的动作(Cookie),只要还在这次会话中,后续同域的请求都会自动将Cookie的值自动带到请求头中
  • 当请求头中有一个Cookie字段,浏览器会将当前域下的所有key&value以字符串的形式自动带上,服务端便能从请求报文中解析来做后续处理
  • 当关闭浏览器后,内存消失导致Cookie消失,Cookie中存的值也消失了,相当于此次会话结束了,但这个结束只是对于客户端来说,因为服务端并不知道。那么如何才能让Session失效呢?
    • 通过web.xml中配置session-configsession-timeout,传入数字,单位为分钟,默认的Tomcat配置中这个值为30分钟,时间到了便失效(服务端的时间,不是客户端的)
    • 通过Session.invalidate()方法,调用后session便失效
  • 以前常见的登陆场景就是利用CookieSession机制处理的,登录后便可以在Session域中存储相关用户信息,现在流行前后端分离用的就少很多了,但还是应该了解

HttpSession

和请求域、应用域一样,Session对象也有setAttributegetAttribute方法,用法完全一样,区别是他们的宿主对象存活时间不同

  • 应按照能完成需求的最小域使用,能用请求域(request)存储的就不要用会话域(session),能用会话域完成的就不要用应用域(context

如何在Java代码中获取Session对象

HttpSession session = req.getSession();这行代码很关键,如何不调用这个方法,服务端是不会生成Session对象及写sessionid到响应头中的。作用就是根据当前会话是否已有Session对象,有则获取返回,没有则会新建

HttpSession session = req.getSession(false);方法还可以接受一个Boolean值,传入false时则意为,如果当前会话中,Session对象已存在,则返回,没有则返回空,不会新建一个Session对象,传入true和空参方法作用一致

下面用3个Servlet进行Session的简单使用

LoginService 提供登录

@WebServlet("/login")
public class LoginService extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        String username = req.getParameter("username");
        String password = req.getParameter("password");
        // 只是为了演示登录,实际肯定是查询数据库
        // 账号是admin,密码是123才算登录成功
        if ("admin".equals(username) && "123".equals(password)) {
            resp.addHeader("anim", "sha");
            // 获取session对象,没有则需要新建,因为一般这里是入口
            HttpSession session = req.getSession();
            // 尝试获取会话域中的用户名
            String sessionUsername = (String) session.getAttribute("username");
            if (sessionUsername == null) {
                // 用户名为空则新建一个随机用户名,这里用随机数示意
                String temp = "" + Math.random() * 1000;
                session.setAttribute("username", "随机的用户名:" + temp);
                System.out.println("已自动生成了用户名" + temp);
            } else {
                // 如果能从会话域获取到用户名,则说明已经登陆过了
                System.out.println("已登录过了,当前会话的用户名为:" + sessionUsername);
            }
        } else {
            System.out.println("账号名或密码错误");
        }
    }
}
  1. 当不传用户名密码时,会输出账号名或密码错误

  2. 当输入了正确(预设的账号)的账号密码时,服务端会自动生成Session对象,并且随机生成了一个用户名,存入到当前的会话域中,这样在同一个会话的所有请求,都可以共享这个信息

  3. 此时再进行登录操作,会提示已登录过,并且可以将Session域中的对象取出

GetSession 提供获取Session域存储值

@WebServlet("/getSession")
public class GetSessionService extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        // 这里要传入false,原因是不需要新建session,场景应该是登录后才能访问这个Servlet
        HttpSession session = req.getSession(false);
        if (session != null) {
            String username = (String) session.getAttribute("username");
            System.out.println("随机的用户名" + username);
        } else {
            System.out.println("访问" + req.getServletPath() + "时检测到没有登录");
        }
    }
}
  1. 当没有登录时,直接访问由于getSession传入的是false,因此不会新建Session对象

  2. 进行一次登录后,再次请求时,便能获取到会话域中存储的用户名

LogoutService 提供注销功能

@WebServlet("/logout")
public class LogoutService extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        HttpSession session = req.getSession(false);
        if (session != null) {
            // 一定要把invalidate放后面执行,否则这个对象的数据就会被清空,也就拿不到所有数据了
            System.out.println(session.getAttribute("username") + "用户被注销了");
            session.invalidate();
        } else {
            System.out.println("没有session,说明没有登录,无法调用注销");
        }
    }
}
  1. 未登录去调用

  2. 登录后去调用

  3. 此时再去访问getSession,如果检测到没有登录,则说明session已经失效了(浏览器可能还会将Cookie传入到请求报文中,但这个key对应服务端的Session对象已失效)

Cookie

上面主要是讲的服务端Session,这里了解一下Cookie的应用,上面说过,服务器会在Response对象中设置一个响应头,Set-Cookie里面就会有key&value的形式传送sessionid给客户端,客户端在限定规则下的资源访问时都会自动带上cookie,服务端接受到后就能根据sessionid去找到对应的Session对象,当没有传sessionid或者传了无效的sessionid,服务端也自然拿不到Session对象

Cookie是在客户端的概念,有两种存储方式:

  1. 内存中,只要浏览器关闭了cookie就消失
  2. 硬盘文件中,不受浏览器关闭的影响,只要文件存在cookie就有效

了解Cookie对象,这个类位于package jakarta.servlet.http中,没有提供空参构造方法,必须传入一个键值对来生成Cookie实例

下面代码演示Java端操作Cookie对象

@WebServlet("/operationCookie")
public class OperationCookie extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        Cookie username = new Cookie("username", "admin");
        Cookie password = new Cookie("password", "123");
        // username.setPath("/"),如果需要控制path的有效值,可以调用setPath进行更改
        resp.addCookie(username);
        resp.addCookie(password);
        System.out.println("成功设置了Cookie");
    }
}

当访问这个这个Servlet时,会在响应报文中设置两个Cookie值(忽略Idea的值),浏览器便会自动存储到内存中

可以在浏览器的 开发者工具->Application->Cookie中找到刚刚设置的两个Cookie,其中的DomainPath影响着访问什么资源会自动带着这俩Cookie,像下图示例,只要访问https://localhost:9999/xmm/*的资源,都会自动携带,这个Path可以通过Cookie.setPath去控制

访问一个符合https://localhost:9999/xmm/*格式的资源,会发现Cookie也会被带到请求报文中一并发送

获取Cookie

可以从响应报文设置Cookie,那就可以从请求报文获取Cookie

@WebServlet("/inspectCookie")
public class InspectCookie extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        // 如果请求报文中一个Cookie都没有,则获取到的是null,而不是长度为0的空数组
        Cookie[] cookies = req.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                System.out.println(cookie.getValue());
                System.out.println(cookie.getName());
                System.out.println("---------------------");
            }
        } else {
            System.out.println("没有Cookie");
        }
    }
}
  1. 先访问/operationCookie
  2. 再访问/inspectCookie,只要Cookie有值,便可以获取到对应的字符串

设置Cookie的时效性

默认情况下,Cookie保存在浏览器的内存中,浏览器关闭了就没有了,可以通过Cooke.setMaxAge,接受一个int类型,单位为秒去进行时效性的设置,代码进行演示

当访问Servlet时,报文设置如下

  1. 可以看到key0的值,Expires的值是epoch·time,也就是unix系统的起始时间,翻译过来就是这个Cookie的有效期是1970年1月1号,过了这一天就无效,相当于删除了这个Cookie
  2. key60的值,我当前的时间为2022年9月19号11点+,其中Expires的有效期格式为格林威治时间,这是个标准时间,中国所在区域为东8区,会比这个标准时间多8小时,11点减去8小时便是3点+,此时会存在硬盘文件中,在有效期内关闭浏览器再打开浏览器访问ServletCookie仍然有效。我这里设置的60s失效,60s过后,Cookie便会消失
  3. key-1的没有Expires信息,说明时效性是这一次会话内有效

在开发中工具的Cookie中,可以看到没有key0的内容(说明如果有这个Cookie也会被删除)

其中的HttpOnly可以控制客户端是否能通过脚本操作,在Java端可以用Cooke.setHttpOnly设置是否启用。通过Javascript端执行document.cooike结果如下 勾选了HttpOnlyCookie无法被JavaScript获取

了解了Cookie和Session后,实现X天免登陆就有思路了

在用户登录后,通过在Java端设置Cookiekey和值根据需求去设计,假如时效性为3天,只要客户端不乱操作,后续的请求都会在Cookie带上用户名和密码,只要能获取后反解析之类的拿到用户信息即可。如最直白的方式,将用户名和密码设置到客户端的Cookie中,在登录的Servlet去进行获取判断,与数据库中的数据进行比较,如果校验通过,则做重定向或放行访问之类的操作

由于客户端是不可控的,比如客户端被禁用Cookie,那么客户端就无法接受(服务端不受影响,仍然会发送),session机制的玩法就得通过url重写,http://localhost:9999/xmm/getSession:jsessionid=xxxxxxxxx

通过在资源路径后面加上冒号及key&value,但开发难度过大,一般不处理,禁用就禁用吧!

其实Session和Cookie属于HTTP规范的内容,即使不是Java换成其他语言做Web开发都需要这两套机制

Filter

过滤器可以在目标Servlet执行之前或者之后进行过滤规则,比如很多个业务Servlet都需要判断是否已登录,这样代码就非常冗余重复,使用过滤器只要编写一次,就可以实现按规则进行过滤

Filter的特点

  1. 在服务器启动的时候,Filter对象便会被新建
  2. Filter实例是单实例的
  3. 能否走进目标Filter
    • 请求的路径是否符合过滤器的规则
  4. 进入过滤器之后,目标Servlet能否被执行?
    • 取决在Filter,是否有调用chain.doFilter(req,resp)
  5. Filter的匹配规则
    • /xxx/yyyy 这些属于精准匹配
    • /* 匹配所有路径
    • *xxx 匹配以xxx后缀的Servlet
    • /auth/* 匹配以/auth/为前缀的Servlet
如何编写一个过滤器
  1. 编写一个类实现jarkata.servlet.Filtter其所有方法(有默认实现的可以不重写)

  2. init方法Filter对象被创建后调用,只调用一次

    • doFilter 只要命中一次请求符合过滤器规则,就会执行一次,类似Servletservice方法
    • destroy方法Filter对象被销毁之前调用,只调用一次
  3. web.xml中配置Filter,操作和Servlet非常类似(也可以使用注解,二选一)

目标Servlet是否会被执行,取决两个关键点
  1. 过滤器是否写了chain.doFilter(req,resp);
  2. 请求路径是否匹配Servlet

用一张图理解FilterContext可以粗略看成Tomact服务器,用户一个请求进来时,先被Tomcat处理一遍,将请求报文等数据进行清洗整理,原本是直接进入Servlet层的,但是由于请求路径匹配到了过滤器规则,则先进入过滤器执行相关操作,过滤器放行后发现没有后续过滤器了就会进入目标Servlet,当Servlet执行完毕后,又会回到过滤器中,过滤器处理完才到真正返回客户端响应(栈数据结构)

用代码理解

// 注解和web.xml配置各选一个
@WebFilter("/*")
public class BaseFilter implements Filter {
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        System.out.println("过滤器创建了");
    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        // 执行目标方法前做了点啥(请求)
        System.out.println("进入了过滤器");

        // 执行下一个过滤器,如果不是过滤器,则直接执行目标Servlet
        filterChain.doFilter(servletRequest, servletResponse);

        // 执行目标方法后做了点啥(响应)
        System.out.println("又回到了过滤器");
    }

    @Override
    public void destroy() {
        System.out.println("过滤器销毁了");
    }
}

如果不用注解可以在web.xml配置一下代码

<filter>
  <filter-name>baseFilter</filter-name>
  <filter-class>cn.mgl.filter.BaseFilter</filter-class>
</filter>
<filter-mapping>
  <filter-name>baseFilter</filter-name>
  <url-pattern>/*</url-pattern>
</filter-mapping>

现在再去访问之前Session案例里的随意一个Servlet

上面代码只是演示过滤器的使用方式,实际开发中更多的使用过滤器进行一些业务的前置条件校验,配置等操作

一些路径匹配规则:

  • /aFilter 这种属于精确匹配,路径必须完整一致
  • /* 相当于全局匹配,只要是访问/开头的资源都会命中过滤器
  • *.justMe 后缀匹配,只要是以.justMe结尾的资源访问都能命中
  • /pre/* 前缀匹配,只要是以/pre/开头的都能命中过滤器

多个Filter之间也有优先级关系

  • 使用web.xml的方式进行配置时,filter-mapping编写越前的,优先级越高
  • 使用注解式声明时,按照类名的字符字典顺序排序

FilterServlet的优先级高,且他们的生命周期完全一致,默认情况下,服务器启动阶段,不管有没有客户端访问,都会被实例化,而Servlet不会

Listener 监听器

监听器也属于Servlet规范中,所有监听器接口名字都是以Listener结尾

ServletContextListener

这个属于容器级别的监听器,需要实现ServletContextListener接口,接口中有两个方法,分别在容器初始化级销毁前执行,实现接口后还需要进行配置,例子使用了WebListener注解

    @WebListener
    public class ContextListener implements ServletContextListener {
        @Override
        public void contextInitialized(ServletContextEvent sce) {
            System.out.println("容器被初始化了");
        }

        @Override
        public void contextDestroyed(ServletContextEvent sce) {
            System.out.println("容器被销毁了");
        }
    }

如果用XML则需要编写如下配置

<listener>
  <listener-class>xxx.Contextlistener</listener>
</listener>

启动Tomcat服务器,然后关掉,应该能看到以下输出

ServletContextAttributeListener

这个监听器的作用为,在对容器级别的属性进行增删改时会触发,需要实现ServletContextAttributeListener中的所有方法,分别对应着容器属性的添加,删除,修改,并通过WebListener注解进行配置

    @WebListener
    public class ContextAttributeListener implements ServletContextAttributeListener {
        @Override
        public void attributeAdded(ServletContextAttributeEvent scae) {
            String name = scae.getName();
            System.out.println("attributeAdded监听到添加了属性,名为:" + name);
        }

        @Override
        public void attributeRemoved(ServletContextAttributeEvent scae) {
            String name = scae.getName();
            System.out.println("attributeRemoved监听到删除了属性,名为:" + name);
        }

        @Override
        public void attributeReplaced(ServletContextAttributeEvent scae) {
            String name = scae.getName();
            System.out.println("attributeRemoved监听到属性被替换了,名为:" + name);
            System.out.println("值为"+scae.getValue());
        }
    }

编写测试代码

    @Override
    public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
        ServletContext servletContext = getServletContext();
        String key = "testKey";
        servletContext.setAttribute(key,1); // 添加
        servletContext.setAttribute(key,2); // 修改
        try {
            Thread.sleep(1000);
            servletContext.removeAttribute(key); // 删除
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }

此时访问对应Servlet后,便会出现以下日志打印

  • HttpSessionListenerHttpSessionAttributeListener
  • ServletRequestListenerServletRequestAttributeListener

这俩的用法与Context的相当类似,只需要实现对应的方法并配置@WebLisenter即可

ServletRequestAttributeListener

这个类比较特殊,不需要进行什么配置,但是需要某个Pojo类实现该接口中的方法,示例中以一个User类为实例,当User对象被放入Session域时则会触发valueBound方法,当User对象从Session域中被删除时,则会触发valueUnbound方法,并且通过这个监听器可以实现一个简易的在线人数统计 功能

public class User implements HttpSessionBindingListener {
    public User(String username, Long id) {
        this.username = username;
        this.id = id;
    }

    @Override
    public void valueBound(HttpSessionBindingEvent event) {
        // 当User实例被绑定到Session中时触发
        ServletContext servletContext = event.getSession().getServletContext();
        Integer onlineCount = (Integer) servletContext.getAttribute("onlineCount");
        // 我们可以往容器的属性中添加一个onlineCount用来记录当前登陆人数
        if (onlineCount == null) {
            // 无责从1起
            System.out.println("第一个在线用户诞生了");
            servletContext.setAttribute("onlineCount", 1);
        } else {
            // 有则+1
            System.out.println("在线用户累加了" + (onlineCount + 1));
            servletContext.setAttribute("onlineCount", ++onlineCount);
        }
    }

    @Override
    public void valueUnbound(HttpSessionBindingEvent event) {
        System.out.println("用户被解绑了");
        // 当User实例被删除或Session被销毁了会被触发
        ServletContext servletContext = event.getSession().getServletContext();
        Integer onlineCount = (Integer) servletContext.getAttribute("onlineCount");
        System.out.println(onlineCount);
        // 只要触发必然会有在线人数,不需要判空,减就完事了
        servletContext.setAttribute("onlineCount", --onlineCount);
    }

    String username;
    Long id;
}

接着编写一个模拟用户登陆的Servlet

@WebServlet("/addUser")
public class AddUserToSession extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        // 获取Session
        HttpSession session = req.getSession();
        Object user = session.getAttribute("currentUser");
        // 尝试从Session中获取当前用户
        if (user == null) {
            // 没有的话则添加一个User实例,关键是这个User对象,内容不太关心便随意了
            session.setAttribute("currentUser", new User("test", 123L));
        } else {
            // 有则直接输出
            System.out.println("当前用户存在" + user);
        }
    }
}

为了验证效果,还需要再编写俩个辅助的Servlet,一个是注销的Servlet

@WebServlet("/destroySession")
public class DestroySession extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        // false很关键,没有session信息时则不会新建
        HttpSession session = req.getSession(false);
        // 调用invalidate相当于注销,注销后由于User对象实现了对应的监听接口
        // 就会触发解绑事件
        session.invalidate();
    }
}

还有一个是查看在线人数的Servlet

@WebServlet("/getOnlineCount")
public class OnlineCount extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        // 直接输出即可
        System.out.println("当前在线人数为:" + req.getServletContext().getAttribute("onlineCount"));
    }
}

简单讲讲测试流程

  1. 先用Edge浏览器访问addUser,此时应该会将容器的在线人数属性设为1,访问getOnlineCount得到的应该是1

  1. 再用Chrome浏览器访问addUser,此时在线人数会是2,访问getOnlineCount得到的应该是2

  1. 接着再Chrome浏览器访问destroySession执行注销,再次访问getOnlineCount得到的将是1,则验证成功

看到这里相信对JavaWeb有了一定的认知了,搞懂Servlet有助于后续的StringMVCSpringBoot的学习

完:)