Java Servlet 笔记

Servlet(Server Applet),全称 Java Servlet,未有中文译文。是用 Java 编写的服务器端程序。其主要功能在于交互式地浏览和修改数据,动态生成 Web 内容。狭义的 Servlet 是指 Java 语言实现的一个接口(javax.servlet.Servlet),广义的 Servlet 是指任何实现了这个 Servlet 接口的类,一般情况下,人们将 Servlet 理解为后者。
Servlet 运行于支持 Java 的应用服务器中(常用的有 Tomcat、WebLogic)。从实现上讲,Servlet 可以响应任何类型的请求,但绝大多数情况下 Servlet 只用来扩展基于 HTTP 协议的 Web 服务器。

Servlet 相关简介

介绍 Servlet 之前,先来了解 静态页面动态页面 的区别:

  • 静态页面:或称为静态资源,一般除了 php、asp、jsp、cgi 等文件外,其它的都是静态资源。如常见的 html、css、js、png、gif、gzip 文件。用户访问某个静态资源时,web 服务器进行的操作很简单,就是添加适当的 HTTP 响应头部,然后一个空行,表示头部结束,最后去磁盘中读取这个文件,将读取到的数据追加到这个空行的后面,同时将 HTTP 响应数据发送给浏览器,也就是一个简单的文件传输过程,仅此而已。
  • 动态页面:与静态资源不同,对于 php、asp、jsp、cgi 等动态页面,web 服务器不再是简单的去磁盘读取这些文件,然后将其发送给浏览器(如果是这样的,那我们访问 php 页面得到的将是该页面的 php 源码,而不是执行后的输出),而是先运行这些 php、asp、jsp、cgi 程序,获取它们的输出,最后将输出结果发送给浏览器。也就是说,这些响应数据是程序运算后动态生成的,不再是简单的文件传输了。

那么我们该如何编写动态页面呢?也就是说,我们该如何将编写的程序与 web 服务器进行交互,来动态的生成 web 内容呢?最简单的方式就是 CGI 了。CGI 是 Common Gateway Interface 的缩写,即 通用网关接口。CGI 不是一门编程语言,它只是规定 web 服务器与执行程序之间如何通信的一个协议。CGI 是独立于任何语言的,CGI 程序可以使用任何脚本语言或者是完全独立的编程语言实现,只要这个语言可以在当前系统上运行。比如 Python、Perl、PHP、Shell、C/C++ 等等(其实 Java 也是可以的,不过要借助 Shell 脚本)。

我们来简要的了解一下 HTTP 的两种常见请求方式:GET、POST。GET 请求只有 请求行请求头,而 POST 请求还有 请求体。其实我更喜欢将请求行和请求头看作是一部分,后面提到的”请求头”若未特别说明,应理解为”请求行” + “请求头”。请求头由多个报头域组成,每个报头域都是一个 name-value 名值对,格式 NAME: VALUE[CRLF],注意冒号后面有一个空格符。请求头与请求体之间使用 [CRLF](回车符 + 换行符)分隔。

curl http://www.baidu.com 的请求头部如下:

curl http://127.0.0.1 -d 'username=Otokaze' -d 'password=123456' 的请求头部如下:

curl http://127.0.0.1 -F 'file=@/etc/resolv.conf' 的请求头部如下:

curl http://127.0.0.1 -F 'file1=@/etc/resolv.conf' -F 'file2=@/etc/hosts' 的请求头部如下:

Web 服务器与 CGI 程序的通信过程

  • Web 服务器 => CGI 程序:请求头转换为环境变量传递给 CGI 程序,请求体则写入到 CGI 程序的标准输入中。
  • CGI 程序 => Web 服务器:CGI 程序的输出分为两个部分,即响应头和响应体,它们之间使用 [CRLF] 分隔。

因此,只要一门语言可以读取环境变量,可以读取标准输入,可以向标准输出中写入数据,那么就可以用来编写 CGI 程序。符合这三个条件的语言实在是太多了,或者说,很难找到不符合这些条件的编程语言。

为了更加深刻的理解这个过程,我用 Linux 中使用最广泛的 Shell 脚本和 Apache 服务器来编写一个简单的 CGI 程序:

curl 127.0.0.1,GET 请求,请求体为空:

curl 127.0.0.1 -d 'username=Otokaze' -d 'password=123456',POST 请求(表单提交):

curl 127.0.0.1 -F 'file=@/etc/resolv.conf',POST 请求,multipart/form-data(文件上传):

请求体没什么好说的,就是原始数据,没有变动,我们主要分析与请求头相关的环境变量。可以发现,除了 Content-TypeContent-Length 两个与请求体有关的报头域外,其它报头域(包括自定义的报头域)均被转换为以 HTTP_ 开头的环境变量,且被转换为大写(头域 Name 是不区分大小写的),而连字符则被转换为下划线。而请求行则被表示为 REQUEST_METHODREQUEST_URIREQUEST_SCHEME 几个字段。

可以发现,使用 CGI 编写动态页面是非常简单的,但是,这并不是说 CGI 就完美了,事实上,它有很多缺点,以至于现在基本没人使用 CGI 编写动态页面。为什么呢?最主要的问题就是 CGI 的 fork-and-execute 工作模式。每次访问 CGI 页面时,Web 服务器都要启动一个新的 CGI 进程,然后服务完成后又退出。也就是说,当前有多少个请求在线,就有多少个 CGI 进程在系统运行。这种工作模式在大规模的并发下,就会死翘翘了。

为了提高 CGI 的性能,有人提出了 FastCGI。FastCGI 是 Fast Common Gateway Interface 的缩写,即 快速通用网关接口。同样的,FastCGI 也是一种协议,它是早期 CGI 的增强版本。FastCGI 致力于减少 Web 服务器与 CGI 程序之间交互的开销,从而使服务器可以同时处理更多的 Web 请求。

CGI 使外部程序与 Web 服务器之间交互成为可能。CGI 程序运行在独立的进程中,并对每个 Web 请求创建一个进程,这种方法非常容易实现,但效率很差,难以扩展。面对大量请求,进程的大量创建和消亡使操作系统性能大大下降。此外,由于地址空间无法共享,也限制了资源重用。

与为每个请求创建一个新的进程不同,FastCGI 会预先启动多个 CGI 进程,当 Web 服务器接到一个请求时,将相关的环境变量与请求主体通过 Socket 传递给 FastCGI 管理器,FastCGI 管理器从 CGI 进程池中随机挑选一个进程,来处理这个请求,处理完毕后,该 CGI 进程不会立即退出,而是放回进程池中,继续等待下一个 Web 请求,直到 FastCGI 关闭。因为没有了额外的进程创建与进程销毁过程,所以 FastCGI 的性能相比 CGI 大大提高。

但是,进程池的消耗也是不小的,如果可以采取线程池的方式,那就更加完美了,这就是 Servlet 采取的方法,因为 Java 天生就支持多线程,况且还有 J.U.C 包的助力。文章开头说了,狭义上的 Servlet 是指 JavaEE 中的一个接口 javax.servlet.Servlet,广义上的 Servlet 是指实现了 Servlet 接口的类,我们通常将 Servlet 理解为后者。Servlet 是一个普通的 Java 类,可以简单的将一个 Servlet 类理解为一个动态页面。

为了更好的利用 Java 的优势,我们不再使用 Apache、Nginx 等 Web 服务器了,而是使用 Java 编写的 Web 服务器。此 Java 服务器程序监听在 0.0.0.0:80 端口,维护一个线程池,等待客户端的连接请求。当有一个请求到达时,服务器程序从线程池中选择一个空闲线程,执行对应的 Servlet 程序,当有另一个请求到达时,也是从线程池中抽取一个空闲线程,执行对应的 Servlet 程序。执行完毕后,这些线程不会立即销毁,而是回到线程池中,等待新的任务到达。

而这个 Java 语言实现的 HTTP 服务器被称为 应用服务器,实际上,应用服务器根本不用我们自己开发,市面上有很多 Java 应用服务器的实现,有开源的(如 Tomcat),也有付费的(如 WebLogic)。因此,我们实际上要操心的只是 Servlet 而已。因为从始至终都只有应用服务器这一个 Java 进程在系统运行,所以此方案占用的系统资源是非常少的,最大的资源消耗也就是线程池了。

注意,Java 应用服务器只能执行 Java 程序(比如这里的 Servlet,但实际上还有 Filter、Listener 等等),那么 静态资源 该怎么处理呢?也是通过 Servlet 来处理,让 Servlet 去磁盘中读取这些静态资源,然后发送回客户端。也就是说,在 Java 应用服务器中,静态资源和动态资源都是要经过 Servlet 处理的,这也带来一个问题,在处理静态资源时,Java 肯定是没有 C、C++ 快速的,况且,Java 应用服务器没有 Apache、Nginx 这么多关于静态资源的优化参数,就更慢了。所以,通常情况下,我们都是将 Apache/Nginx + Tomcat 两者整合在一起,Apache/Nginx 在前,Tomcat 在后,所有 Web 请求都先到达 Apache/Nginx,Apache/Nginx 判断是否为动态资源,如果是则交给后端的 Tomcat 处理,否则(静态资源)直接使用 Apache/Nginx 处理。这样的架构下,静态资源和动态资源的处理速度都非常好,两全其美。

这里先理清楚几个名词,通常所说的 Servlet 是指实现了 javax.servlet.Servlet 接口的类,但是要运行这些 Servlet 类就必须要有一个专用的执行环境,而提供这个环境的就是 Servlet 容器(其实就是一个普通的 Java 程序),目前有很多 Servlet 容器的实现,比如开源的 Tomcat,但 Tomcat 不只包含 Servlet 容器,还包含其它一些组件,Tomcat 的 Servlet 容器有一个专有名称 Catalina。而 Tomcat 又是 Apache 软件基金会下属的 Jakarta 项目开发的一个 Servlet 容器,因此你经常看到这样一个名词 Apache Tomcat,其实就是指 Tomcat。

差点忘了介绍 Java EE,目前,Java 平台有 3 个版本,它们分别是:

  • Java SE:Java 平台标准版,所谓的标准版就是 JVM + Java标准类库,也就是 JRE。
  • Java EE:Java 平台企业版,它只是一个规范,任何人都可以去实现,如 Tomcat 实现了 Servlet API。
  • Java ME:Java 平台微型版,这是一套专门为于移动设备、嵌入式设备设计的 API,注意 Android 不使用 JavaME。

相关文档 [参考]

Servlet 环境配置

首先是下载 Tomcat 了,这里选择最新稳定版 Tomcat 8.5apache-tomcat-8.5.30.tar.gz,tomcat 是绿色软件,不需要安装,解压后可以在 bin 目录看到 .bat、.sh 脚本,前者适用于 Windows,后者适用于 Linux。tomcat 目录下的文件夹命名都比较规范,一看就懂,这里就不详细说它们的作用了。多个 tomcat 版本可以共存,也可以同时运行,但要注意使用不同的端口号。

然后配置与 Tomcat 相关的两个环境变量,CATALINA_HOMEPATH,前者指定 tomcat 的绝对路径,后者则将 $tomcat/bin/ 添加到 PATH 搜索路径中。在 Linux 中,可以新建一个文件 /etc/profile.d/tomcat.sh,配置如下(这里假设 tomcat 的路径为 /usr/local/tomcat8/):

配好之后,执行 source /etc/profile 立即生效,运行 startup.sh 命令即可启动 tomcat,tomcat 默认监听在 8080 端口,打开浏览器,输入 http://localhost:8080 即可查看 tomcat 的欢迎页。使用 shutdown.sh 命令关闭 tomcat。但是,我觉得这些脚本不是很好用,比如查看 tomcat 是否正在运行,就只能使用 ps | grep java 了。所以我在 tomcat.sh 中添加了如下内容,怎么用就不用我说了吧,一看就懂:

Tomcat 欢迎页

不过,如果你要编写 Servlet 类,那么还需要将 tomcat 提供的 servlet-api.jar 添加到 CLASSPATH 中,不过,为了一劳永逸,我建议直接将 $CATALINA_HOME/lib 目录下的所有 jar 文件添加到 CLASSPATH 中。配置如下(添加到 tomcat.sh 中):

Tomcat 使用简介

tomcat 目录结构

  • bin:存放启动、关闭 tomcat 的相关脚本。
  • lib:存放 tomcat 服务器运行时需要的 jar 包。
  • conf:存放 tomcat 全局配置文件,如 server.xml。
  • webapps:存放 tomcat webapp(项目存放目录)。
  • logs:存放 tomcat 服务器运行时产生的日志文件。
  • work:存放 tomcat 服务器运行时产生的数据文件。
  • temp:存放 tomcat 服务器运行时产生的临时文件。

bin 目录下的几个常用脚本:

  • startup.sh:启动 tomcat。
  • shutdown.sh:关闭 tomcat。
  • version.sh:查看 tomcat 版本。
  • configtest.sh:测试 server.xml。
  • catalina.sh:does everything,其它脚本均调用此脚本。

conf 目录下的几个重要文件:

  • server.xml:tomcat 服务器配置文件,如修改 tomcat 监听端口,虚拟主机配置,https 支持。此文件修改后须重启 tomcat 生效。
  • tomcat-users.xml:tomcat 用户角色配置文件,如果需要使用 manager、host-manager,则需要配置此文件,修改后也是重启生效。
  • web.xml:web 项目中 servlet/filter/listener 相关配置(url 映射、listener 注册、mime-type 定义、欢迎页配置、错误页配置等),全局配置文件。$webapps/[app-name]/WEB-INF/web.xml 为项目配置文件,优先级更高。这两个配置文件默认情况下是热加载的(context.xml 中可配置),tomcat 会自动扫描这些配置文件,如果发现文件修改时间变了,则自动进行重载,无需手动重启 tomcat。
  • context.xml:上下文相关配置,所谓的上下文就是 web 项目,web 项目也叫做 app 应用,全局配置文件。$conf/Catalina/[hostname]/context.xml.default 为主机配置文件,$conf/Catalina/[hostname]/[app-name].xml 为项目配置文件,$webapps/[app-name]/META-INF/context.xml 为嵌入配置文件(默认启用,默认不会自动复制到 $conf/Catalina/[hostname]/[app-name].xml,可通过 $conf/server.xml 的 Host 元素的 copyXML、deployXML 属性进行配置)。$conf/Catalina/[hostname]/ 目录下的上下文配置文件优先级最高。

webapps 目录结构
web 项目一般都放在这个目录,$CATALINA_HOME/webapps/[app-name] 对应的 url 为 http://[hostname]/[app-name]。其中有一个特殊的 app-name:ROOT,访问 ROOT app 不需要任何前缀,即 http://[hostname]

$CATALINA_HOME/webapps/[app-name]/WEB-INF/web.xml 文件管理 Servlet 与访问 URL 之间的对应关系。假设,web.xml 定义了一条映射:/index.do 对应某个 Servlet。如果 app-name 为 ROOT,则访问的 url 为 http://[hostname]/index.do,如果 app-name 为 myapp,则访问的 url 为 http://[hostname]/myapp/index.do,其它的同理。也就是说 web.xml 中的 / 是相对于 http://[hostname]/[app-name] 的(ROOT 除外,它没有特定前缀)。

打开 webapps 目录,可以看到几个默认的文件夹,如下:

  • ROOT:tomcat 默认 app。【Servlet 环境配置】中的欢迎页就是这个 app。
  • manager:tomcat 自带的 app 管理器。默认禁用,向 tomcat-users.xml 中添加用户后才能使用。通过 manager,可以在线启动、停止、重载、添加、删除 app。manager 只能管理当前虚拟主机中的 app。如果需要在其他虚拟主机中使用 manager,只需将 $CATALINA_HOME/webapps/manager 目录复制到给定虚拟主机 webapps 目录下即可。
  • host-manager:tomcat 自带的 host 管理器。默认禁用,向 tomcat-users.xml 中添加用户后才能使用。通过 host-manager,可以在线添加、删除、停止 vhost。manager 可以管理除当前 vhost 外的 vhost。
  • docs:tomcat 自带文档。
  • examples:tomcat 自带例子。

每个 app 的目录结构:

WEB-INF 目录不能直接被客户端访问,如果要访问里面的资源,必须在 web.xml 中进行相应的映射,因此该目录也称为安全目录。其中 classes 目录用于存放 Servlet 类(按照全限定类名的目录结构存放,比如上面的 com.zfl9.Test 类),lib 目录存放 classes 中的类的相关依赖包,只能以 jar 包的方式存放,如果没有依赖包,lib 目录可以不要。而 web.xml 的作用之前已经说了,存放 servlet/filter/listener 相关的配置(如 url 映射关系),web.xml 还有一个专业的名称:部署描述符文件。在 servlet 3.0 后,web.xml 文件不再是必须的了,因为我们可以使用 javax.servlet.annotation 包中的相关注解来配置 servlet/filter/listener。

META-INF 应该是 Tomcat 独有的,主要用于存放 context.xml 上下文配置文件,这个目录客户端无法访问。

除了这两个目录外,其它地方都可以用来存放静态资源(大部分 jsp 页面也放在这里),比如上面的 index.html,这些静态资源不需要在 web.xml 注册(但实际上它们已经在 $CATALINA_HOME/conf/web.xml 中注册了),我们可以直接通过对应的 url 访问它们。

logs 目录的几个日志文件:

  • catalina.out:tomcat 运行时的标准输出、标准错误均被重定向至此文件,同时 tomcat 的一些运行日志也会写入到这里,这也是为什么 catalina.out 与 catalina.log 也很多相同的日志内容(直接合并一个文件多好)。
  • catalina.yyyy-MM-dd.log:tomcat 服务器运行日志,其中 yyyy-MM-dd 为记录日志的日期。
  • manager.yyyy-MM-dd.log:manager 内置项目的运行日志,yyyy-MM-dd 意义同上。
  • host-manager.yyyy-MM-dd.log:host-manager 内置项目的运行日志,yyyy-MM-dd 意义同上。
  • localhost.yyyy-MM-dd.log:默认 vhost 的运行日志(访问记录除外),yyyy-MM-dd 意义同上。
  • localhost_access_log.yyyy-MM-dd.txt:默认 vhost 的访问日志,每行都是一个 HTTP 请求记录。

work 目录结构 $CATALINA_HOME/work/Catalina/[hostname]/[app-name],存放运行时由 JSP 页面生成的 Servlet 源文件以及编译后的类文件、Session 持久化文件等。

temp 目录主要存放 tomcat 运行时产生的一些临时文件,可以安全删除这个目录下的内容,但是切记不要删除 temp 这个目录。

Servlet 生命周期

第一节中我们说到,Servlet 容器在内部维护了一个线程池,当一个 Web 请求到达时,容器会从池中随机挑选一个线程来执行相应的 Servlet。Servlet 是一个实现了 javax.servlet.Servlet 接口的类,Servlet 接口定义了 5 个方法,其中比较重要的 3 个如下:
void init(ServletConfig config) throws ServletException:初始化方法,servlet 实例化时此方法被调用,相当于构造函数,只会在实例化时执行一次。
void service(ServletRequest req, ServletResponse res) throws ServletException, IOException:服务方法,主要方法,用于动态生成 Web 响应内容。
void destroy():servlet 实例被回收前执行的方法,相当于 C++ 的析构函数,和初始化方法一样,只会执行一次,主要用于资源释放,如关闭数据库连接。

但实际上,我们不会直接实现 Servlet 接口,而是继承 HttpServlet 类(HttpServlet 继承自 GenericServlet,而 GenericServlet 实现了 Servlet 接口)。HttpServlet 的 service() 方法其实也不需要我们重写,该方法的可能实现如下(网上找到,重在理解):

很容易知道,我们只需要重写 HttpServlet 的 doXxx() 方法,最常用的两个就是 doGet() 和 doPost()。

Servlet 对象的生命周期:

  • 通过 init() 方法进行初始化,只在实例化时执行一次;
  • 通过 service() 方法处理客户端请求,servlet 对象的主要方法;
  • 通过 destroy() 方法进行资源回收,只在 servlet 对象被回收前执行一次;
  • 最后,servlet 对象被 JVM 垃圾回收线程(GC 线程)回收,释放占用的内存。

一般来说,servlet 对象在实例化后就不会被回收了,常驻内存,并且,在同一个 Servlet 容器中,一个 Servlet 类只存在一个实例。而又因为 Servlet 容器是多线程模型的,所以要特别注意 Servlet 的线程安全问题。在 Servlet 中应尽量避免使用实例变量,可以的话,使用 service() 方法的局部变量代替。

Servlet HelloWorld

为了方便,我们先将 $CATALINA_HOME/webapps/ROOT 目录重命名为 $CATALINA_HOME/webapps/tomcat,然后重新建立一个 ROOT 项目(这样做主要是为了少打字~)。在这之后,如果想要访问 tomcat 的默认页面,可以通过 http://[hostname]/tomcat/ 来访问。

然后我们在新的 ROOT 目录中新建几个文件夹,具体的:

其中 src 目录用于存放 servlet 源文件(也可以使用其它名字),假设包名为 com.zfl9,则:

然后,我们在 src/com/zfl9/ 中新建 Index.java,hello-world 程序:

编译 Index.java,添加 WEB-INF/web.xml 部署描述符文件:

为了给后面的 Nginx + Tomcat 整合做准备,建议规范 Servlet 页面的后缀名,这里我选择常用的 *.do。默认情况下,welcome-file-list 中没有添加 index.do 为默认主页,修改 conf/web.xml 全局配置文件中的 welcome-file-list,添加 index.do 条目:

因为 tomcat 服务器默认监听在 0.0.0.0:8080/tcp 地址,而 http 协议的默认端口号为 80,为了不输入端口号,我建议先将 tomcat 的端口改为 80,修改 conf/server.xml,定位至 Server -> Service -> Connector[protocol=”HTTP/1.1”],修改它的 port 属性,改为 80 端口即可。

最后,启动 tomcat,打开浏览器,键入 http://localhosthttp://localhost/index.do(一样的)。不过,我喜欢使用 curl 来访问,输出如下:

Index.java 的代码很容易理解,其实和第一节中的 CGI 程序很相似,只不过现在是用 Java 写的摆了。基本结构都是:先写响应头,再写响应体(但这不是强制性的,和 Shell 脚本还是有点不一样的)。javax.servlet.ServletResponse 中有两个方法获取 out 对象(这两个方法不可同时调用,否则抛出异常,页面会返回 500 错误),分别是:

  • PrintWriter getWriter() throws IOException:PrintWriter 对象,用于写入纯文本数据。
  • ServletOutputStream getOutputStream() throws IOException:OutputStream 对象,用于写入二进制数据。

现在来分析一下 web.xml 文件,其中最主要的就是 <servlet> ... </servlet><servlet-mapping> ... </servlet-mapping>。这两个元素一般都是成对出现的,前者用于注册 Servlet,后者用于映射 Servlet。它们有一个共同子元素 <servlet-name>,表示 servlet 的名称,这个名称可以随便取,但是它们两个必须保持一致。<servlet-class> 中填写 Servlet 的全限定类名。然后我们来详细分析 url-pattern。

url-pattern 可以有多个,url-pattern 共有四种语法,它们按照从高到低的优先级排列如下:

  • 精确匹配:如 /index.do,只匹配 /index.do 这个 url。
  • 前缀匹配:如 /index/*,匹配 /index/ 目录下的任意 url。
  • 后缀匹配:如 *.do,匹配任意以 .do 结尾的 url。
  • 默认匹配/,任意未被上述模式匹配的 url 将被它匹配。

当我们访问 http://localhost/index.do?name1=value1&name2=value2 时,tomcat 首先去除 query-string 部分,得到 /index.do,该 url 与 ROOT 项目相匹配,然后 tomcat 查找 conf/web.xml、WEB-INF/web.xml 文件中的 url-pattern 元素,按照上述优先级,依次匹配对应的 url-pattern 模式,一旦命中,就会停止继续匹配,然后找到该 url-pattern 对应的 Servlet,最后交给它处理这个请求。

这里强调一点,tomcat 是拿着 url 去匹配 web.xml 中的 url-pattern 模式,然后再找到对应的 Servlet。我们来看看默认的 conf/web.xml 文件内容:

这里有两个预注册的 servlet,他们分别是:

  • org.apache.catalina.servlets.DefaultServlet,name 为 default,url-pattern 为 /
  • org.apache.jasper.servlet.JspServlet,name 为 jsp,url-pattern 为 *.jsp*.jspx

在 tomcat 中,所有 url 都会被某个 servlet 命中,无论它是 servlet 页面、jsp 页面、静态页面。也就是说,tomcat 其实只能处理 servlet。访问 jsp 页面时,被 JspServlet 这个 Servlet 命中,它将对应的 jsp 页面转换为 servlet 源文件,然后编译为 servlet 类文件,最后运行这个 servlet。访问其它静态资源时,被 DefaultServlet 这个 Servlet 命中,它先判断这个静态资源是否存在磁盘中,如果存在,则读取文件内容,发送给客户端,如果不存在,则返回 404 Not Found 页面。Java 的 IO 库性能肯定是比 C/C++ 低的,所以,这也是为什么 tomcat 处理静态资源不如 apache/nginx 了。

上面我们只使用了 service() 方法,这里演示一下 init()、destroy() 方法,并观察它们的执行顺序。

配置 web.xml 部署描述符文件,添加以下内容:

然后重启 tomcat,tomcat.restart,使用 curl 测试,并观察 logs/catalina.out 日志文件:

关闭 tomcat,过滤后,catalina.out 日志文件的内容如下,可以看出,init()、destroy() 只执行了一次:

除了在 void init(config) 方法中使用 ServletConfig 对象外,还可以在其它方法中调用 this.getServletConfig() 来获取 config 对象的引用。除了通过 config 对象来获取 init-param Sevlet 初始化参数外,还可以直接使用 GenericServlet 中定义的 getInitParameter(name) 方法来获取对应的值,另一个方法 getInitParameterNames() 用于获取所有 init-param 的 name 枚举。

在 GenericServlet 类中,定义了两个 log() 方法,用于记录日志(保存到 logs/localhost.yyyy-MM-dd.log):
public void log(java.lang.String msg):INFO 级别(信息),打印指定 message 字符串;
public void log(java.lang.String msg, java.lang.Throwable t):SEVERE 级别(严重),打印堆栈跟踪。
格式如 org.apache.catalina.core.ApplicationContext.log com.zfl9.Test: test(servlet-name 为前缀)。

除了 Servlet 外,我们还经常接触到一个名词:Servlet 上下文。所谓的 Servlet 上下文就是指该 Servlet 所在的 WebApp(Web 项目、Web 应用)。ServletContext 其实就是一组 Servlet(逻辑分组),webapps 目录下的每个目录都是一个 ServletContext。除了 ROOT 上下文外,访问其他上下文需要加上与文件夹名相同的前缀。

在 web.xml 中,可以通过 context-param 元素给当前 ServletContext 传递静态参数(和 Servlet 中的 init-param 参数作用类似)。那么我们该如何在具体的 Servlet 中访问这些上下文静态参数呢?要访问它们,首先得获取 ServletContext 对象的引用,在 GenericServlet 类中,提供了 getServletContext() 方法用于获取 ServletContext 对象的引用。然后再使用上下文对象的 getInitParameter(name)getInitParameterNames() 方法来读取这些静态参数。

例子:

web.xml 中添加如下内容:

编译,重启 tomcat,然后使用 curl 查看输出:

在 ServletContext 中,也定义了两个 log() 方法,它们的函数声明是一样的,这 4 个方法记录的日志都保存在 logs/localhost.yyyy-MM-dd.log 文件。它们之间的输出差异如下(前者为 Context,后者为 Servlet):

Servlet 3.0 之后,web.xml 部署描述符文件不再是必须的了,因为我们可以使用更加简便的注解来配置 Servlet,相关的注解都在 javax.servlet.annotation 包。主要的几个注解如下:

  • WebServlet:与 Servlet 相关的注解。
    • String name = "":servlet 的名称,对应 <servlet-name>
    • String[] value = {}:servlet 的 url,对应 <url-pattern>,注意是数组,因为可以有多个 url-pattern。
    • String[] urlPatterns = {}:servlet 的 url,对应 <url-pattern>。完全同 value,但二者不可同时指定。
    • int loadOnStartup = -1:是否在容器启动时实例化 servlet,并调用 init() 方法,默认在首次访问时初始化。
    • WebInitParam[] initParams = {}:init() 方法中的 config 对象的相关配置,对应 <init-param> xml 元素。
  • WebFilter:与 Filter 相关的注解。
    • String filterName = "":filter 的名称,对应 <filter-name>
    • String[] value = {}:filter 的 url,对应 <url-pattern>
    • String[] urlPatterns = {}:filter 的 url,对应 <url-pattern>
    • String[] servletNames = {}:filter 作用的 servlet,对应 <filter-mapping> 中的 <servlet-name>
    • DispatcherType[] dispatcherTypes = {DispatcherType.REQUEST}:从哪来的 servlet 请求将被 filter 处理。
    • WebInitParam[] initParams = {}:init() 方法中的 config 对象的相关配置,对应 <init-param> xml 元素。
  • WebListener:与 Listener 相关的注解。
    • String value = "":listener 的描述信息,该注解一般没有元素,用来注册 listener。
  • WebInitParam:Servlet/Filter 的 init-param。
    • String name:init-param 的 name。
    • String value:init-param 的 value。
  • MultipartConfig:用于 Servlet 类,与 multipart/form-data 上传相关的配置。
    • String location = "":存放上传的文件的文件夹的绝对路径。
    • long maxFileSize = -1L:上传的文件的最大大小,-1 为无限制。
    • long maxRequestSize = -1L:POST 请求体的最大大小,-1 为无限制。
    • int fileSizeThreshold = 0:当文件大小大于此值时,将被写入临时文件中。

现在,我们来演示一下,如何使用注解替代 web.xml 配置,还是上面这个例子,使用注解替代:

Servlet 字符编码

tomcat 默认的字符编码是 ISO 8859-1,是专门处理西欧语言的字符编码,处理中文时必定出现乱码问题。因此,我们很有必要将默认字符编码改为 UTF-8。因为要全局设置字符编码,因此会提前涉及到 Filter 的知识。

首先,修改 server.xml 服务器配置文件,HTTP 连接器、AJP 连接器的字符编码:

然后,创建 EncodingFilter 过滤器,在 Servlet 处理 request、response 前规范编码:

最后,我们编写 Servlet 时,添加如下语句(别嫌麻烦):

  • resp.setContentType("text/html; charset=UTF-8")
  • out.println("<meta charset='UTF-8'>"),HTML 头部元素。

Servlet 请求数据

客户端发送的请求正文,有两种常见的 MIME 类型,分别是:

  • application/x-www-form-urlencoded:url 编码,也称为百分号编码。可通过 GET、POST 方法传递。
  • multipart/form-data:上传文件使用的 MIME 类型就是这个,字面意思:附带多个请求体(有分隔符)。

application/x-www-form-urlencoded 其实就是 name-value 名值对,多个名值对之间使用 & 连接。格式:name1=value1&name2=value2&name3=value3。因为这里有两个特殊字符,=&,因此,必须采用某种编码方式,避免 name、value 中出现这两个特殊字符,不然就无法解析了。首先,uri 中的字符分为两类:特殊字符普通字符,特殊字符也就是有特殊意义的字符,比如上面提到的 =&,而普通字符使用正则可表示为 [a-zA-Z0-9._~-]。如果 name、value 中出现了非普通字符(如特殊字符、中文、日文),那么需要将这些非普通字符转换为 utf-8 字节序(十六进制表示),然后在每个字节前面(也就是两个十六进制数字)插入一个 % 符号。因为编码后的字符串有很多百分号,因此也被称作百分号编码。注意,如果 name、value 中没有夹杂非普通字符,则不需要进行百分号编码(如纯英文)。

举个例子,语言=中文的 utf-8 字节序为E8AFAD的 utf-8 字节序为E8A880的 utf-8 字节序为E4B8AD的 utf-8 字节序为E69687。拼接在一起,为 E8AFADE8A880=E4B8ADE69687,然后每两个十六进制数字前插入一个百分号,为 %E8%AF%AD%E8%A8%80=%E4%B8%AD%E6%96%87

开头我们说了,application/x-www-form-urlencoded 有两种传输方式,一种是通过 GET 方法,一种是通过 POST 方法。GET 方法是比较简单的,只需要在 URL 的尾部添加一个 ?,然后再将编码后的字符串添加到 ? 后面就可以了。而 POST 方法则是将编码后的字符串作为请求主体,发送给服务器的。GET 方法传递时,查询数据会显示在浏览器地址栏,而 POST 方法传递时则不会显示到地址栏,可以通过浏览器控制台看到。因此,如果涉及到用户密码等隐私数据,建议使用 POST 方式传递,避免出现安全问题。同时,GET 方式传递还有一个限制,那就是查询参数有长度限制。其实 HTTP/1.1 协议本身对 URL 是没有长度限制的,但是浏览器和服务器通常是有长度限制的(比如 nginx 会限制客户端请求头部的长度,通常是 4k/8k)。

在 Servlet 中,可以通过 ServletRequest 对象的这几个方法来解析 application/x-www-form-urlencoded 数据:
String getParameter(String name):获取给定 name 的 value。如果有多个相同的 name,则获取第一个 value。没有则返回 null。
String[] getParameterValues(String name):获取给定 name 的所有 value。注意返回的是一个数组,如果没有则返回 null。
Enumeration<String> getParameterNames():获取所有 name 的枚举,如果没有查询参数,则返回一个空枚举对象。
Map<String,String[]> getParameterMap():获取由所有 name、value 组成的键值对。key 为 String、value 为 String[]。

特别注意,上面几个方法中的 name 参数不需要经过编码,比如获取 语言=中文 这个名值对,name 就写为 语言

例子,先编写一个 form.html 表单页面,一个使用 GET 方式传递,一个使用 POST 方式传递。

然后,编写 Servlet 类 com.zfl9.URLEncodedTest,程序如下:

说完第一种,我们再来说第二种:multipart/form-data。在 Servlet 3.0 之前,我们只能借助第三方 API 来完成 multipart/form-data 的解析(如 Apache 的 commons-fileupload.jar、commons-io.jar)。Servlet 3.0 之后,终于有官方 API 来完成这件事了(早该如此,像 PHP 多明智啊)。

先来看看 application/x-www-form-urlencodedmultipart/form-data 请求的区别(POST 为例):

表单数据

文件上传

文件上传的 Content-Type:multipart/form-data; boundary=------------------------f979f247a1e5df24,分号前面的是请求正文的类型,分号后面的是附带的参数。boundary的中文意思是”边界”,从后面的请求正文可以看出,共有 2 个部分(称为 Part),每个 Part 以 boundary 值开头,然后是两个请求头域,第一个头域用于描述当前 Part(如 Part 的名称,name),第二个头域用于指明当前 Part 的类型,头域与内容之间使用一个空行(crlf)分隔,内容后又是一个空行(crlf),最后一个 Part 后面还有一个 boundary,并且尾部多了两个 - 符号,表示 multipart 结束了。要说明的是,boundary 每次都是不一样的,它是浏览器随机生成的字符串。

从上面的分析中可以看出,multipart/form-data 的请求正文是由多个 Part 组成的,每个 Part 都以一个随机的 boundary 行开头,请求正文以 boundary 表示结束(添加两个连字符)。Servlet 3.0 中的 HttpServletRequest 对象提供了两个方法,用于获取对应的 Part:

  • Part getPart(String name) throws IOException, ServletException:获取给定 name 的 Part 对象。
  • Collection<Part> getParts() throws IOException, ServletException:获取所有 Part 对象(集合)。

javax.servlet.http.Part 接口的方法列表:

  • String getName():获取当前 part 的名称。
  • long getSize():获取当前 part 的内容大小(byte)。
  • String getContentType():获取当前 part 的内容类型(mime)。
  • String getSubmittedFileName():获取当前 part 的文件名称(客户端提供)。
  • String getHeader(String name):获取当前 part 的给定头部或返回 null。
  • Collection<String> getHeaders(String name):获取当前 part 的给定头部或空集合。
  • Collection<String> getHeaderNames():获取当前 part 的所有头部的 name 集合(可能为空)。
  • InputStream getInputStream() throws IOException:获取当前 part 的内容的输入流。
  • void write(String fileName) throws IOException:便携方法,将 part 内容写入到文件中。
  • void delete() throws IOException:删除与 part 的相关操作产生的各种临时文件。

例子,先编写一个 upload.html,包含一个简单的文件上传表单(html5):

编写 com.zfl9.Upload 类,处理客户端上传的文件(使用 Servlet 3.0 API 实现):

注意,处理上传文件的 Servlet 类必须被 javax.servlet.annotation.MultipartConfig 所注解,否则运行时返回 500 错误,抛出 java.lang.IllegalStateException: Unable to process parts as no multi-part configuration has been provided 异常。

保存文件时需要提供绝对路径,借助 ServletContext 对象的 getRealPath() 方法可以获取其真实路径。

Servlet 过滤器

过滤器是指实现了 javax.servlet.Filter 接口的类,Filter 接口有三个方法,与 Servlet 接口类似:

  • void init(FilterConfig config) throws ServletException:容器启动时,实例化 filter,并调用 init() 方法。
  • void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException:过滤方法。
  • void destroy():容器关闭时,调用此方法,主要用于资源的回收(如关闭连接),最后回收 filter 对象的内存。

Filter 有两种作用,预处理后处理。这里的预处理和后处理均是相对于 Servlet 说的。比如前面的字符编码 Filter 就是典型的预处理,在 Servlet 处理之前,先将字符编码设为 UTF-8。再举个后处理的例子,Web 服务器一般都会记录客户端的访问日志,格式一般为:访问时间 - 客户端 IP - 请求的 URL - 响应的状态 - 响应的大小。因为需要知道响应状态、响应体大小等信息,所以需要在 Servlet 处理完后再进行日志的记录。

一个 URL 可以匹配多个 Filter,而对于 Servlet 来说,一个 URL 只能被一个 Servlet 处理。因为可能有多个 Filter,因此它们之间必定有某种执行顺序,我们可以通过 <filter-mapping> 元素的前后相对顺序来决定多个 Filter 间的执行顺序(使用注解也要这么做),即写在前面的 <filter-mapping> 比写在后面的 <filter-mapping> 的对应的 filter 先执行。还有一点,Filter 对象的初始化是在容器启动时进行的,而 Servlet 对象的初始化默认是在第一次被访问时进行的(可通过 web.xml、WebServlet 注解的相关元素配置为容器启动时初始化)。

假设:某个 servlet 的 url-pattern 为 /index.do,filter1、filter2 的 url-pattern 为 /index.do,且 filter1 的 filter-mapping 位于 filter2 的前面。当浏览器访问 http://localhost/index.do 时,首先执行 filter1 的 doFilter() 方法的预处理部分,然后执行 filter2 的 doFilter() 方法的预处理部分,然后执行 servlet 的 service() 方法(如果还没初始化则先进行初始化),然后执行 filter2 的 doFilter() 方法的后处理部分,最后执行 filter1 的 doFilter() 方法的后处理部分。即:client -> filter1 -> fitler2 -> servlet -> filter2 -> filter1 -> client。

那么 doFilter() 方法中是如何区分 预处理部分后处理部分 的呢?看到 doFilter() 方法的第三个参数没?FilterChain 对象。chain 对象只有一个方法,doFilter(request, response)。Filter 类的 doFilter() 方法的一般形式:

个人理解,容器收到一个请求时,先查找 web.xml(这里假设没有使用注解),找到与该 url 匹配的所有 filter、servlet,然后将它们按照顺序保存到一个列表中。先假设容器找到了至少一个 filter,于是调用 filter.doFilter() 方法,运行到 chain.doFilter() 位置时,查找这个列表的下一个元素,假设下一个元素也是一个 filter,则转而执行该 filter 的 doFilter() 方法,然后又运行到 chain.doFilter() 位置,接着查找这个列表的下一个元素,假设下一个元素是一个 servlet(最后一个元素),于是转而执行该 servlet 的 service() 方法,service() 方法执行完后,chain.doFilter() 返回,接着执行后面的代码,也即后面那个 filter 的后处理部分,而当该 doFilter() 方法运行完后,最开始的 chain.doFilter() 方法也返回,于是开始执行前面那个 filter 的后处理部分,最后返回给容器,容器再将响应结果返回给客户端。简而言之,这就是一个函数调用链。回到开头,假设没有匹配到任何 filter,则直接运行对应 servlet 的 service() 方法。

不过,不是什么资源 filter 都会处理的,一个资源要被 filter 处理也是有条件的。这个条件使用 javax.servlet.DispatcherType 枚举常量表示,在 web.xml 中,使用 <filter-mapping><dispatcher> 元素表示(它们的取值是一样的)。DispatcherType 枚举常量有:

  • REQUEST:表示当前请求是直接从客户端过来的(原始请求),默认值。
  • FORWARD:表示当前请求是被 requestDispatcher.forward() 转发过来的。
  • INCLUDE:表示当前请求是被 requestDispatcher.include() 包含过来的。
  • ERROR:表示当前请求是被 web.xml 中注册的错误处理程序触发而来的。

dispatcher 可以有多个,如果没有指定,则默认为 REQUEST,如果指定了,则使用指定的 dispatcher(不会默认包含 REQUEST)。

一个简单的例子,验证 filter、servlet 之间的执行顺序。
FilterA.java

FilterB.java

ServletTest.java

使用 curl 测试,观察 filterA、filterB、servlet 之间的执行顺序:

Servlet 异常处理

Servlet 中主要有两种异常情况,一是类似 404 Not Found 这样的 HTTP 异常,二是 Servlet 在运行时抛出了一个异常。

第一种,假设客户端访问 /noSuch.html(一个不存在的文件),容器收到请求后,发现它与 / 这个 url-pattern 相匹配,于是将它交给 default-servlet 处理,default-servlet 在尝试读取此文件时,发现这个文件根本不存在,于是尝试查找 web.xml 文件,看看有没有已定义的异常处理程序,如果没有,则采用默认的异常处理程序,即直接将 404 Not Found 页面发送给用户。

第二种,假设客户端访问 /index.do,对应的 servlet 在执行 service() 方法的过程中,因为某种原因,抛出了一个异常,容器捕获后会写入到日志文件中,然后查询 web.xml 文件,因为没有找到相应的异常处理程序,于是采取默认的处理方式,返回一个 500 状态码给客户端(返回的页面通常不完整,对用户极不友好),仓促的结束访问。

其实第一种还好,用户可以一眼看出这是个什么错误,但是第二种,如果异常发生在 body 前,则用户获取的页面是空白的,没有任何提示,如果异常发生在 body 后(假如是最后面),那么用户可能会获取一个完整的页面,但是实际上这是不正确的处理结果,并且用户也是看不到任何提示的(只能通过控制台查看,但是你觉得普通用户会去查看控制台吗,天真)。所以这样的默认处理方式对用户来说是极不友好的,我们很有必要自己来处理 500 错误,至少告诉用户发生了什么。

通过 web.xml 的 <error-page> 元素,可以自定义错误处理页面,它有 3 个子元素:

  • <error-code>:要匹配的错误码(4xx、5xx)
  • <exception-type>:要匹配的异常类(全限定类名)
  • <location>:指定处理该错误的 url(rewrite 机制)

注意,<error-code><exception-type> 只能二选一,并且最多只能有一个,可以没有,即只有一个 <location> 元素,此时,它匹配所有异常情况。如果要匹配所有异常(Java 语言中的异常类),可以使用 java.lang.Throwable,因为它是所有异常类的父类。

有个问题需要注意一下,如果 servlet 在执行过程中,发生了异常,并且没有在内部消化,那么此时返回的 HTTP 状态码为 500(Internal Server Error)。容器捕获到这个异常后,查找 web.xml 中注册的异常处理程序,此时假设找到了这两个条目:

你觉得这个请求会被 rewrite 到哪个 url 呢?好像两个都符合条件,它即是 500 错误,也是 java.lang.Throwable 的子类。经实际测验,它会被 rewrite 到 /exception.html 这个 url。我得出的结论是:如果是未捕获的异常而导致的 500 错误,那么首先会查找 <exception-type> 元素,如果没找到,才会去匹配 <error-code>500</error-code>,无论它们的先后顺序如何。

如果容器找到的是这两个条目(假设抛出的异常是 java.lang.NullPointerException):

那么该请求会被 rewrite 到哪个 url 呢?是按照前后顺序匹配,还是按照相关度匹配呢?答案是后者,也即匹配最相关的那个。所谓的最相关就是最接近的意思,在这里,空指针异常当然与 java.lang.NullPointerException 更相关了,所以会被 rewrite 至 /nullptr.html。其实上面那个例子也是一样的,都是匹配最相关的那个。

上面说到的异常都是被动发生的,比如说 404,servlet 本来以为它存在,然而并没有;比如运行时抛出异常,这也是被动的,我们是期望它不会抛出异常的。其实也有些异常是开发人员主动触发的,比如,某页面访问前需要进行认证,如果用户没有经过认证,就想访问该页面,那我们也可以主动触发一个异常,返回一个 403 页面,告诉用户要先认证才能访问。

HttpServletResponse 中有两种方式,用来设置 HTTP Status Code,分别是:

  • void setStatus(int sc):设置状态码,不触发错误处理机制,可设置的状态码有:2xx、3xx、4xx、5xx。
  • void sendError(int sc) throws IOException:设置状态码,并触发错误处理机制,响应缓冲区的内容被丢弃。
  • void sendError(int sc, String msg) throws IOException:同上,向错误处理页面提供一个简短的错误信息。

它们的主要区别就是会不会触发错误处理机制。一般,setStatus() 用于设置 2xx、3xx 状态码,作为正常响应,表示当前没有发生异常。而 sendError() 用于设置 4xx、5xx 错误码,提示用户该请求没有正常响应。注意,setStatus() 就如同 setHeader()/addHeader() 方法,只是单纯的修改响应行/响应头部,并不会丢弃当前响应缓冲区中的数据,调用 setStatus() 方法后,可以继续写入正常页面内容。而 sendError() 则会修改响应行,并且丢弃当前响应缓冲区中的数据,同时,触发容器的错误处理机制,查找 web.xml 中注册的错误处理程序,最后 rewrite 到对应的 url,相当于当前页面完全被替换了,因此,调用 sendError() 方法后,不应该再往响应缓冲区中写入数据,因为此时控制权已经不在当前 servlet 中了。

<location> 元素中指定的 url 被称为错误处理程序,它可以是一个静态 html 页面,也可以是 servlet/jsp 页面。容器在 rewrite 过程中,会传递 6 个 attribute 属性给错误处理程序,attribute 保存在 request 对象中,通过 getAttribute() 方法可获取这些信息。其实所谓的 attribute 就是哈希表,key 是字符串,value 为 Object 对象。而 getAttribute() 方法就是使用给定的 key 来查找对应的 value。涉及的 6 个 key 如下:

  • javax.servlet.error.status_code:状态码,java.lang.Integer 类型。
  • javax.servlet.error.exception:异常对象,java.lang.Throwable 类型。
  • javax.servlet.error.exception_type:异常类型,java.lang.Class 类型。
  • javax.servlet.error.message:描述信息,java.lang.String 类型。
  • javax.servlet.error.servlet_name:servlet 名称,java.lang.String 类型。
  • javax.servlet.error.request_uri:请求 uri,java.lang.String 类型。

例子,编写一个 error-handler.do 通用错误处理页面:

然后在 web.xml 文件中注册它就可以了,如下:

什么是 Cookie?(MDN):

HTTP Cookie(也叫 Web Cookie 或浏览器 Cookie)是服务器发送到用户浏览器并保存在本地的一小块数据,它会在浏览器下次向同一服务器再发起请求时被携带并发送到服务器上。通常,它用于告知服务端两个请求是否来自同一浏览器,如保持用户的登录状态。Cookie 使基于无状态的 HTTP 协议记录稳定的状态信息成为了可能。

从这段介绍中,可以得到一些重要的信息:

  • Cookie 是服务器发送给客户端的(实际上 JS 也可以设置)
  • Cookie 是保存在客户端的一小块数据(大小限制在 4KB 左右)
  • Cookie 中文意思是 小文本,因此 Cookie 存储的是纯文本数据
  • Cookie 会在浏览器下次访问同一服务器(不严谨)时被自动发送到服务器
  • Cookie 的主要作用是识别不同的 HTTP 请求是否来自同一用户(HTTP 协议是无状态的)
  • Cookie 的出现使得基于无状态的 HTTP 协议记录稳定的状态信息成为了可能(但不是唯一途径)

为什么说 HTTP 协议是无状态的?假设你现在要写一个统计用户访问次数的页面,即为每个用户记录他访问本网页的次数。乍一看你会觉得很简单,只需要每次访问时进行递增操作就好了呀。但实际上,这样真的就可以了吗?那我问你,你怎么知道两个不同的 HTTP 请求是不是来自同一个用户呢?或许你会说,根据 IP 地址啊,但是很可惜,IPv4 地址早就枯竭了,现在绝大多数用户分配到的 IP 都是运营商级 NAT 内网地址(100.64.0.0/10),一个外网 IP 往往对应多个不同的用户,你这个方法是行不通的,就算每个人都有公网 IP,那假设某个用户换了个设备,比如之前使用电脑登录,现在使用手机登录,IP 地址也是不一样的啊,这时服务器会认为它们是不同的用户,但实际上它们是相同的用户。

这时有人想到个方法,我们可以在请求 URL 中添加一个查询参数,假设为 uid,为每个用户分配一个不同的 uid,然后每次用户访问时,都将这个 uid 附带到 url 中,发送给服务器,这样服务器就知道是哪个用户了。办法是个好方法,就是不太方便,毕竟每次访问页面时都要附带一个 uid 查询参数。

有没有方法可以让浏览器自动发送这个 uid 给服务器呢?最简单的方法就是使用 Cookie 了。前面说了,Cookie 是服务器发送给客户端的,那么我们先来看看发送了 Cookie 的响应请求是怎样的:

出现了一个新的 HTTP 响应头部:Set-Cookie,其实服务器可以发送多个 Cookie,每个 Cookie 对应一个 Set-Cookie 头。上面的 Set-Cookie 头部还是比较简单的,就是一个 name-value 名值对。但实际上,Set-Cookie 头部可以包含更多的信息,如下:
Set-Cookie: <name>=<value>; expires=<date>; max-age=<date>; domain=<domain>; path=<path>; httponly; secure
除了 <name>=<value> 字段外,其他部分都是可选的,这些字段的意义如下:

  • <name>:除 控制字符空白符( ) < > @ , ; : \ " / [ ] ? = { } 外的任意 ASCII 字符。
  • <value>:除 控制字符空白符双引号逗号分号 以及 反斜线 外的任意 ASCII 字符。许多应用会对 cookie 值按照 URL 编码规则进行编码,但是按照 RFC 规范,这不是必须的。不过满足规范中对于 <value> 所允许使用的字符的要求是有用的。
  • expires到期时间,如果省略此字段,则表示这是一个 会话期 Cookie,客户端关闭时会话期 Cookie 被移除。如果此时间已过去,则表示,服务端希望客户端移除此 Cookie。
  • max-age生存时间(秒数,优先级高),一些老浏览器不支持该字段(IE6、IE7、IE8),对于其他浏览器,如果同时存在 expiresmax-age 字段,则 max-age 优先级高。
  • domain作用域名,指定该 Cookie 作用于哪些域名,如果省略,默认为当前域名;如果指定一个具体域名,则表示作用于该域名及其子域名。
  • path作用路径,指定该 Cookie 作用于哪些路径,如果省略,默认为当前路径;如果指定一个具体路径,则表示作用于该路径及其子路径。
  • httponly:只允许通过 HTTP 来访问 Cookie,即 不允许如 JS 脚本访问该 Cookie,以防跨站脚本攻击
  • secure该 Cookie 只允许通过 HTTPS 方式发送到服务器,在 Chrome 52+、Firefox 52+ 后,不允许 http 站点设置此属性。

浏览器接收到此响应后,会根据 Set-Cookie 头域的相关字段决定如何存储这些 Cookie。如果 expires/max-age 被省略,则可能保存在内存中,因为这是一个会话期 Cookie;如果没有省略,则保存到本地磁盘的某个文件中。

当浏览器再次向这个页面发起请求时,会读取已保存的 Cookie,如果没有过期,则将它们附带到请求头的 Cookie 头域中,格式:Cookie: <name1>=<value1>[; <name2>=<value2>[; ...]]。注意,浏览器发送的 HTTP 请求头中,只会有一个 Cookie 头域,其中包含所有相关的 cookie 名值对,不同的名值对之间使用英文分号隔开(不以分号结尾)。完整的 curl 示例如下:

会话期 Cookie
会话期 Cookie 是最简单的 Cookie:浏览器关闭之后它会被自动删除,也就是说它仅在会话期内有效。会话期 Cookie 不需要指定过期时间(Expires)或者有效期(Max-Age)。需要注意的是,有些浏览器提供了会话恢复功能,这种情况下即使关闭了浏览器,会话期 Cookie 也会被保留下来,就好像浏览器从来没有关闭一样。

持久性 Cookie
和关闭浏览器便失效的会话期 Cookie 不同,持久性 Cookie 可以指定一个特定的过期时间(Expires)或有效期(Max-Age)。
如:Set-Cookie: id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT
注:当 Cookie 的过期时间被设定时,设定的日期和时间只与客户端的本地时间有关。

利用浏览器自动检索可用的 cookie 并发送给服务器的特点,可以很方便的在基于无状态的 HTTP 协议中保持稳定的状态信息。

发送 Cookie
在 Servlet-API 中,HTTP Cookie 使用 javax.servlet.http.Cookie 类的一个对象表示。构造函数:
public Cookie(String name, String value),根据给定的 name、value 创建一个 cookie 对象。
因为 cookie 的 name/value 有些字符不能使用,为了符合规范,建议对 value 进行 url 编码或 base64 编码。

Cookie 类的成员方法:

  • public String getName():获取 name(创建后无法修改)
  • public void setValue(String newValue):设置 value
  • public String getValue():获取 value
  • public void setDomain(String pattern):设置作用域名
  • public String getDomain():获取作用域名
  • public void setPath(String uri):设置作用路径(须包含上下文路径)
  • public String getPath():获取作用路径
  • public void setMaxAge(int expiry):设置到期时间,负值:会话期cookie、零值:删除此cookie、正值:生存时间,单位为秒
  • public int getMaxAge():获取到期时间,负值表示此 cookie 为会话期 cookie、正值表示此 cookie 的有效时间,单位为秒
  • public void setSecure(boolean flag):设置是否使用安全协议,即只在 HTTPS 连接下发送此 Cookie,默认为 false
  • public boolean getSecure():获取是否使用安全协议,默认为 false
  • public void setHttpOnly(boolean httpOnly):设置是否对客户端脚本隐藏(JS),默认为 false
  • public boolean isHttpOnly():判断是否对客户端脚本隐藏(JS)
  • public void setComment(String purpose):设置注释
  • public String getComment():获取注释,或 null
  • public void setVersion(int v):设置版本号,默认是 0(一般不改)
  • public int getVersion():获取版本号
  • public Object clone():克隆当前 Cookie 对象

获得 cookie 对象后,调用 HttpServletResponse.addCookie(cookie) 方法,添加 Set-Cookie 头。

接收 Cookie
读取 Cookie 也很简单,使用 HttpServletRequest.getCookies() 方法,获取 cookie 对象的数组,或者 null。

删除 Cookie
如果 cookie.setMaxAge(expiry) 的 expiry 为 0,则表示删除此 cookie。删除 cookie 的步骤如下:

  1. 调用 request.getCookies() 方法,获取该 cookie 对象的引用
  2. 调用 cookie.setMaxAge(0) 方法,将 max-age 设为 0,表示想删除它
  3. 调用 response.addCookie(cookie) 方法,将修改后的 cookie 发送回客户端

例子,实现一个简单的登录/登出功能。两个页面:login.html 登录表单、user.do 用户中心。流程:用户只需打开 user.do 页面,user.do 内部进行判断,实现登录、显示个人信息、登出功能。

login.html 登录表单:
登录表单

user.do 用户中心:

第一次访问 /user.do,自动跳转到 /login.html 登录页面:
登录表单

填写信息后,点击登录按钮,跳转到 /user.do 用户中心:
用户中心

关闭页面,再次打开 user.do,解析 cookie,显示个人信息:
个人信息

点击【退出登录】按钮,删除相关 cookie,跳转到登录页面,再次访问 user.do 还是会跳转到登录页面。

Servlet Session

// TODO