# JavaWeb 后端
经过前面的学习,现在终于可以正式进入到后端的学习当中,不过,我们还是需要再系统地讲解一下 HTTP 通信基础知识,它是我们学习 JavaWeb 的基础知识,我们之前已经学习过 TCP 通信,而 HTTP 实际上是基于 TCP 协议之上的应用层协议,因此理解它并不难理解。
打好基础是关键!为什么要去花费时间来讲解计算机网络基础,我们学习一门技术,如果仅仅是知道如何使用却不知道其原理,那么就成了彻头彻尾的 “码农”,只知道搬运代码实现功能,却不知道这行代码的执行流程,在遇到一些问题的时候就不知道如何解决,无论是知识层面还是应用层面都得不到提升。
无论怎么样,我们都要明确,我们学习 JavaWeb 的最终目的是为了搭建一个网站,并且让用户能访问我们的网站并在我们的网站上做一些事情。
# 计算机网络基础
在计算机网络(谢希仁 第七版 第 264 页)中,是这样描述万维网的:
万维网(World Wide Web)并非是某种特殊的计算机网络,万维网是一个大规模的联机式信息储藏所,英文简称
Web
,万维网用 **<u> 链接 </u>** 的方法,能够非常方便地从互联网上的一个站点访问另一个站点,从而主动地按需求获取丰富的信息。
这句话说的非常官方,但是也蕴藏着许多的信息,首先它指明,我们的互联网上存在许许多多的服务器,而我们通过访问这些服务器就能快速获取服务器为我们提供的信息(比如打开百度就能展示搜索、打开小破站能刷视频、打开微博能查看实时热点)而这些服务器就是由不同的公司在运营。
其次,我们通过浏览器,只需要输入对应的网址或是点击页面中的一个链接,就能够快速地跳转到另一个页面,从而按我们的意愿来访问服务器。
而书中是这样描述万维网的工作方式:
万维网以客户服务器的方式工作,浏览器就是安装在用户主机上的万维网客户程序,万维网文档所驻留的主机则运行服务器程序,因此这台主机也称为万维网服务器。客户程序向服务器程序发出请求,服务器程序向客户程序送回客户所要的万维网文档,在一个客户程序主窗口上显示出的万维网文档称为页面。
上面提到的客户程序其实就是我们电脑上安装的浏览器,而服务端就是我们即将要去学习的 Web 服务器,也就是说,我们要明白如何搭建一个 Web 服务器并向用户发送我们提供的 Web 页面,在浏览器中显示的,一般就是 HTML 文档被解析后的样子。
那么,我们的服务器可能不止一个页面,可能会有很多个页面,那么客户端如何知道该去访问哪个服务器的哪个页面呢?这个时候就需要用到 URL
统一资源定位符。互联网上所有的资源,都有一个唯一确定的 URL,比如 http://www.baidu.com
URL 的格式为:
<协议>://< 主机 >:< 端口 >/< 路径 >
协议是指采用什么协议来访问服务器,不同的协议决定了服务器返回信息的格式,我们一般使用 HTTP 协议。
主机可以是一个域名,也可以是一个 IP 地址(实际上域名最后会被解析为 IP 地址进行访问)
端口是当前服务器上 Web 应用程序开启的端口,我们前面学习 TCP 通信的时候已经介绍过了,HTTP 协议默认使用 80 端口,因此有时候可以省略。
路径就是我们希望去访问此服务器上的某个文件,不同的路径代表访问不同的资源。
我们接着来了解一下什么是 HTTP 协议:
HTTP 是面向事务的应用层协议,它是万维网上能够可靠交换文件的重要基础。HTTP 不仅传送完成超文本跳转所需的必须信息,而且也传送任何可从互联网上得到的信息,如文本、超文本、声音和图像。
实际上我们之前访问百度、访问自己的网站,所有的传输都是以 HTTP 作为协议进行的。
我们来看看 HTTP 的传输原理:
HTTP 使用了面向连接的 TCP 作为运输层协议,保证了数据的可靠传输。HTTP 不必考虑数据在传输过程中被丢弃后又怎样被重传。但是 HTTP 协议本身是无连接的。也就是说,HTTP 虽然使用了 TCP 连接,但是通信的双方在交换 HTTP 报文之前不需要先建立 HTTP 连接。1997 年以前使用的是 HTTP/1.0 协议,之后就是 HTTP/1.1 协议了。
那么既然 HTTP 是基于 TCP 进行通信的,我们首先来回顾一下 TCP 的通信原理:
TCP 协议实际上是经历了三次握手再进行通信,也就是说保证整个通信是稳定的,才可以进行数据交换,并且在连接已经建立的过程中,双方随时可以互相发送数据,直到有一方主动关闭连接,这时在进行四次挥手,完成整个 TCP 通信。
而 HTTP 和 TCP 并不是一个层次的通信协议,TCP 是传输层协议,而 HTTP 是应用层协议,因此,实际上 HTTP 的内容会作为 TCP 协议的报文被封装,并继续向下一层进行传递,而传输到客户端时,会依次进行解包,还原为最开始的 HTTP 数据。
HTTP 使用 TCP 协议是为了使得数据传输更加可靠,既然它是依靠 TCP 协议进行数据传输,那么为什么说它本身是无连接的呢?我们来看一下 HTTP 的传输过程:
用户在点击鼠标链接某个万维网文档时,HTTP 协议首先要和服务器建立 TCP 连接。这需要使用三报文握手。当建立 TCP 连接的三报文握手的前两部分完成后(即经过了一个 RTT 时间后),万维网客户就把 HTTP 请求报文作为建立 TCP 连接的三报文握手中的第三个报文的数据,发送给万维网服务器。服务器收到 HTTP 请求报文后,就把所请求的文档作为响应报文返回给客户。
因此,我们的浏览器请求一个页面,需要两倍的往返时间。
最后,我们再来了解一下 HTTP 的报文结构:
由客户端向服务端发送是报文称为请求报文,而服务端返回给客户端的称为响应报文,实际上,整个报文全部是以文本形式发送的,通过使用空格和换行来完成分段。
现在,我们已经了解了 HTTP 协议的全部基础知识,那么什么是 Web 服务器呢,实际上,它就是一个软件,但是它已经封装了所有的 HTTP 协议层面的操作,我们无需关心如何使用 HTTP 协议通信,而是直接基于服务器软件进行开发,我们只需要关心我们的页面数据如何展示、前后端如何交互即可。
# 认识 Tomcat 服务器
Tomcat(汤姆猫)就是一个典型的 Web 应用服务器软件,通过运行 Tomcat 服务器,我们就可以快速部署我们的 Web 项目,并交由 Tomcat 进行管理,我们只需要直接通过浏览器访问我们的项目即可。
那么首先,我们需要进行一个简单的环境搭建,我们需要在 Tomcat 官网下载最新的 Tomcat 服务端程序:https://tomcat.apache.org/download-10.cgi(下载速度可能有点慢)
- 下载:64-bit Windows zip
下载完成后,解压,并放入桌面,接下来需要配置一下环境变量,打开 高级系统设置
,打开 环境变量
,添加一个新的系统变量,变量名称为 JRE_HOME
,填写 JDK 的安装目录 +/jre,比如 Zulujdk 默认就是:C:\Program Files\Zulu\zulu-8\jre
设置完成后,我们进入 tomcat 文件夹 bin 目录下,并在当前位置打开 CMD 窗口,将 startup.sh 拖入窗口按回车运行,如果环境变量配置有误,会提示,若没问题,服务器则正常启动。
如果出现乱码,说明编码格式配置有问题,我们修改一下服务器的配置文件,打开 conf
文件夹,找到 logging.properties
文件,这就是日志的配置文件(我们在前面已经给大家讲解过了)将 ConsoleHandler 的默认编码格式修改为 GBK 编码格式:
java.util.logging.ConsoleHandler.encoding = GBK |
现在重新启动服务器,就可以正常显示中文了。
服务器启动成功之后,不要关闭,我们打开浏览器,在浏览器中访问:http://localhost:8080/,Tomcat 服务器默认是使用 8080 端口(可以在配置文件中修改),访问成功说明我们的 Tomcat 环境已经部署成功了。
整个 Tomcat 目录下,我们已经认识了 bin 目录(所有可执行文件,包括启动和关闭服务器的脚本)以及 conf 目录(服务器配置文件目录),那么我们接着来看其他的文件夹:
- lib 目录:Tomcat 服务端运行的一些依赖,不用关心。
- logs 目录:所有的日志信息都在这里。
- temp 目录:存放运行时产生的一些临时文件,不用关心。
- work 目录:工作目录,Tomcat 会将 jsp 文件转换为 java 文件(我们后面会讲到,这里暂时不提及)
- webapp 目录:所有的 Web 项目都在这里,每个文件夹都是一个 Web 应用程序:
我们发现,官方已经给我们预设了一些项目了,访问后默认使用的项目为 ROOT 项目,也就是我们默认打开的网站。
我们也可以访问 example 项目,只需要在后面填写路径即可:http://localhost:8080/examples/,或是 docs 项目(这个是 Tomcat 的一些文档)http://localhost:8080/docs/
Tomcat 还自带管理页面,我们打开:http://localhost:8080/manager,提示需要用户名和密码,由于不知道是什么,我们先点击取消,页面中出现如下内容:
You are not authorized to view this page. If you have not changed any configuration files, please examine the file
conf/tomcat-users.xml
in your installation. That file must contain the credentials to let you use this webapp.For example, to add the
manager-gui
role to a user namedtomcat
with a password ofs3cret
, add the following to the config file listed above.<role rolename="manager-gui"/> <user username="tomcat" password="s3cret" roles="manager-gui"/>
Note that for Tomcat 7 onwards, the roles required to use the manager application were changed from the single
manager
role to the following four roles. You will need to assign the role(s) required for the functionality you wish to access.
manager-gui
- allows access to the HTML GUI and the status pagesmanager-script
- allows access to the text interface and the status pagesmanager-jmx
- allows access to the JMX proxy and the status pagesmanager-status
- allows access to the status pages onlyThe HTML interface is protected against CSRF but the text and JMX interfaces are not. To maintain the CSRF protection:
- Users with the
manager-gui
role should not be granted either themanager-script
ormanager-jmx
roles.- If the text or jmx interfaces are accessed through a browser (e.g. for testing since these interfaces are intended for tools not humans) then the browser must be closed afterwards to terminate the session.
For more information - please see the Manager App How-To.
现在我们按照上面的提示,去配置文件中进行修改:
<role rolename="manager-gui"/> | |
<user username="admin" password="admin" roles="manager-gui"/> |
现在再次打开管理页面,已经可以成功使用此用户进行登陆了。登录后,展示给我们的是一个图形化界面,我们可以快速预览当前服务器的一些信息,包括已经在运行的 Web 应用程序,甚至还可以查看当前的 Web 应用程序有没有出现内存泄露。
同样的,还有一个虚拟主机管理页面,用于一台主机搭建多个 Web 站点,一般情况下使用不到,这里就不做演示了。
我们可以将我们自己的项目也放到 webapp 文件夹中,这样就可以直接访问到了,我们在 webapp 目录下新建 test 文件夹,将我们之前编写的前端代码全部放入其中(包括 html 文件、js、css、icon 等),重启服务器。
我们可以直接通过 http://localhost:8080/test/ 来进行访问。
# 使用 Maven 创建 Web 项目
虽然我们已经可以在 Tomcat 上部署我们的前端页面了,但是依然只是一个静态页面(每次访问都是同样的样子),那么如何向服务器请求一个动态的页面呢(比如显示我们访问当前页面的时间)这时就需要我们编写一个 Web 应用程序来实现了,我们需要在用户向服务器发起页面请求时,进行一些处理,再将结果发送给用户的浏览器。
** 注意:** 这里需要使用终极版 IDEA,如果你的还是社区版,就很难受了。
我们打开 IDEA,新建一个项目,选择 Java Enterprise(社区版没有此选项!)项目名称随便,项目模板选择 Web 应用程序,然后我们需要配置 Web 应用程序服务器,将我们的 Tomcat 服务器集成到 IDEA 中。配置很简单,首先点击新建,然后设置 Tomcat 主目录即可,配置完成后,点击下一步即可,依赖项使用默认即可,然后点击完成,之后 IDEA 会自动帮助我们创建 Maven 项目。
创建完成后,直接点击右上角即可运行此项目了,但是我们发现,有一个 Servlet 页面不生效。
需要注意的是,Tomcat10 以上的版本比较新,Servlet API 包名发生了一些变化,因此我们需要修改一下依赖:
<dependency> | |
<groupId>jakarta.servlet</groupId> | |
<artifactId>jakarta.servlet-api</artifactId> | |
<version>5.0.0</version> | |
<scope>provided</scope> | |
</dependency> |
注意包名全部从 javax 改为 jakarta,我们需要手动修改一下。
感兴趣的可以了解一下为什么名称被修改了:
Eclipse 基金会在 2019 年对 Java EE 标准的每个规范进行了重命名,阐明了每个规范在 Jakarta EE 平台未来的角色。
新的名称 Jakarta EE 是 Java EE 的第二次重命名。2006 年 5 月,“J2EE” 一词被弃用,并选择了 Java EE 这个名称。在 YouTube 还只是一家独立的公司的时候,数字 2 就就从名字中消失了,而且当时冥王星仍然被认为是一颗行星。同样,作为 Java SE 5(2004)的一部分,数字 2 也从 J2SE 中删除了,那时谷歌还没有上市。
因为不能再使用 javax 名称空间,Jakarta EE 提供了非常明显的分界线。
- Jakarta 9(2019 及以后)使用 jakarta 命名空间。
- Java EE 5(2005)到 Java EE 8(2017)使用 javax 命名空间。
- Java EE 4 使用 javax 命名空间。
我们可以将项目直接打包为 war 包(默认),打包好之后,放入 webapp 文件夹,就可以直接运行我们通过 Java 编写的 Web 应用程序了,访问路径为文件的名称。
# Servlet
前面我们已经完成了基本的环境搭建,那么现在我们就可以开始来了解我们的第一个重要类 ——Servlet。
它是 Java EE 的一个标准,大部分的 Web 服务器都支持此标准,包括 Tomcat,就像之前的 JDBC 一样,由官方定义了一系列接口,而具体实现由我们来编写,最后交给 Web 服务器(如 Tomcat)来运行我们编写的 Servlet。
那么,它能做什么呢?我们可以通过实现 Servlet 来进行动态网页响应,使用 Servlet,不再是直接由 Tomcat 服务器发送我们编写好的静态网页内容(HTML 文件),而是由我们通过 Java 代码进行动态拼接的结果,它能够很好地实现动态网页的返回。
当然,Servlet 并不是专用于 HTTP 协议通信,也可以用于其他的通信,但是一般都是用于 HTTP。
# 创建 Servlet
那么如何创建一个 Servlet 呢,非常简单,我们只需要实现 Servlet
类即可,并添加注解 @WebServlet
来进行注册。
@WebServlet("/test") | |
public class TestServlet implements Servlet { | |
...实现接口方法 | |
} |
我们现在就可以去访问一下我们的页面:http://localhost:8080/test/test
我们发现,直接访问此页面是没有任何内容的,这是因为我们还没有为该请求方法编写实现,这里先不做讲解,后面我们会对浏览器的请求处理做详细的介绍。
除了直接编写一个类,我们也可以在 web.xml
中进行注册,现将类上 @WebServlet
的注解去掉:
<servlet> | |
<servlet-name>test</servlet-name> | |
<servlet-class>com.example.webtest.TestServlet</servlet-class> | |
</servlet> | |
<servlet-mapping> | |
<servlet-name>test</servlet-name> | |
<url-pattern>/test</url-pattern> | |
</servlet-mapping> |
这样的方式也能注册 Servlet,但是显然直接使用注解更加方便,因此之后我们一律使用注解进行开发。只有比较新的版本才支持此注解,老的版本是不支持的哦。
实际上,Tomcat 服务器会为我们提供一些默认的 Servlet,也就是说在服务器启动后,即使我们什么都不编写,Tomcat 也自带了几个默认的 Servlet,他们编写在 conf 目录下的 web.xml 中:
<!-- The mapping for the default servlet --> | |
<servlet-mapping> | |
<servlet-name>default</servlet-name> | |
<url-pattern>/</url-pattern> | |
</servlet-mapping> | |
<!-- The mappings for the JSP servlet --> | |
<servlet-mapping> | |
<servlet-name>jsp</servlet-name> | |
<url-pattern>*.jsp</url-pattern> | |
<url-pattern>*.jspx</url-pattern> | |
</servlet-mapping> |
我们发现,默认的 Servlet 实际上可以帮助我们去访问一些静态资源,这也是为什么我们启动 Tomcat 服务器之后,能够直接访问 webapp 目录下的静态页面。
我们可以将之前编写的页面放入到 webapp 目录下,来测试一下是否能直接访问。
# 探究 Servlet 的生命周期
我们已经了解了如何注册一个 Servlet,那么我们接着来看看,一个 Servlet 是如何运行的。
首先我们需要了解,Servlet 中的方法各自是在什么时候被调用的,我们先编写一个打印语句来看看:
public class TestServlet implements Servlet { | |
public TestServlet(){ | |
System.out.println("我是构造方法!"); | |
} | |
@Override | |
public void init(ServletConfig servletConfig) throws ServletException { | |
System.out.println("我是init"); | |
} | |
@Override | |
public ServletConfig getServletConfig() { | |
System.out.println("我是getServletConfig"); | |
return null; | |
} | |
@Override | |
public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException { | |
System.out.println("我是service"); | |
} | |
@Override | |
public String getServletInfo() { | |
System.out.println("我是getServletInfo"); | |
return null; | |
} | |
@Override | |
public void destroy() { | |
System.out.println("我是destroy"); | |
} | |
} |
我们首先启动一次服务器,然后访问我们定义的页面,然后再关闭服务器,得到如下的顺序:
我是构造方法!
我是 init
我是 service
我是 service(出现两次是因为浏览器请求了 2 次,是因为有一次是请求 favicon.ico,浏览器通病)我是 destroy
我们可以多次尝试去访问此页面,但是 init 和构造方法只会执行一次,而每次访问都会执行的是 service
方法,因此,一个 Servlet 的生命周期为:
- 首先执行构造方法完成 Servlet 初始化
- Servlet 初始化后调用 init () 方法。
- Servlet 调用 service() 方法来处理客户端的请求。
- Servlet 销毁前调用 destroy() 方法。
- 最后,Servlet 是由 JVM 的垃圾回收器进行垃圾回收的。
现在我们发现,实际上在 Web 应用程序运行时,每当浏览器向服务器发起一个请求时,都会创建一个线程执行一次 service
方法,来让我们处理用户的请求,并将结果响应给用户。
我们发现 service
方法中,还有两个参数, ServletRequest
和 ServletResponse
,实际上,用户发起的 HTTP 请求,就被 Tomcat 服务器封装为了一个 ServletRequest
对象,我们得到是其实是 Tomcat 服务器帮助我们创建的一个实现类,HTTP 请求报文中的所有内容,都可以从 ServletRequest
对象中获取,同理, ServletResponse
就是我们需要返回给浏览器的 HTTP 响应报文实体类封装。
那么我们来看看 ServletRequest
中有哪些内容,我们可以获取请求的一些信息:
@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()); // 获取请求方法 | |
// 获取头部信息 | |
Enumeration<String> enumeration = request.getHeaderNames(); | |
while (enumeration.hasMoreElements()){ | |
String name = enumeration.nextElement(); | |
System.out.println(name + ": " + request.getHeader(name)); | |
} | |
} |
我们发现,整个 HTTP 请求报文中的所有内容,都可以通过 HttpServletRequest
对象来获取,当然,它的作用肯定不仅仅是获取头部信息,我们还可以使用它来完成更多操作,后面会一一讲解。
那么我们再来看看 ServletResponse
,这个是服务端的响应内容,我们可以在这里填写我们想要发送给浏览器显示的内容:
// 转换为 HttpServletResponse(同上) | |
HttpServletResponse response = (HttpServletResponse) servletResponse; | |
// 设定内容类型以及编码格式(普通 HTML 文本使用 text/html,之后会讲解文件传输) | |
response.setHeader("Content-type", "text/html;charset=UTF-8"); | |
// 获取 Writer 直接写入内容 | |
response.getWriter().write("我是响应内容!"); | |
// 所有内容写入完成之后,再发送给浏览器 |
现在我们在浏览器中打开此页面,就能够收到服务器发来的响应内容了。其中,响应头部分,是由 Tomcat 帮助我们生成的一个默认响应头。
因此,实际上整个流程就已经很清晰明了了。
# 解读和使用 HttpServlet
前面我们已经学习了如何创建、注册和使用 Servlet,那么我们继续来深入学习 Servlet 接口的一些实现类。
首先 Servlet
有一个直接实现抽象类 GenericServlet
,那么我们来看看此类做了什么事情。
我们发现,这个类完善了配置文件读取和 Servlet 信息相关的的操作,但是依然没有去实现 service 方法,因此此类仅仅是用于完善一个 Servlet 的基本操作,那么我们接着来看 HttpServlet
,它是遵循 HTTP 协议的一种 Servlet,继承自 GenericServlet
,它根据 HTTP 协议的规则,完善了 service 方法。
在阅读了 HttpServlet 源码之后,我们发现,其实我们只需要继承 HttpServlet 来编写我们的 Servlet 就可以了,并且它已经帮助我们提前实现了一些操作,这样就会给我们省去很多的时间。
@Log | |
@WebServlet("/test") | |
public class TestServlet extends HttpServlet { | |
@Override | |
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { | |
resp.setContentType("text/html;charset=UTF-8"); | |
resp.getWriter().write("<h1>恭喜你解锁了全新玩法</h1>"); | |
} | |
} |
现在,我们只需要重写对应的请求方式,就可以快速完成 Servlet 的编写。
# @WebServlet 注解详解
我们接着来看 WebServlet 注解,我们前面已经得知,可以直接使用此注解来快速注册一个 Servlet,那么我们来想细看看此注解还有什么其他的玩法。
首先 name 属性就是 Servlet 名称,而 urlPatterns 和 value 实际上是同样功能,就是代表当前 Servlet 的访问路径,它不仅仅可以是一个固定值,还可以进行通配符匹配:
@WebServlet("/test/*") |
上面的路径表示,所有匹配 /test/随便什么
的路径名称,都可以访问此 Servlet,我们可以在浏览器中尝试一下。
也可以进行某个扩展名称的匹配:
@WebServlet("*.js") |
这样的话,获取任何以 js 结尾的文件,都会由我们自己定义的 Servlet 处理。
那么如果我们的路径为 /
呢?
@WebServlet("/") |
此路径和 Tomcat 默认为我们提供的 Servlet 冲突,会直接替换掉默认的,而使用我们的,此路径的意思为,如果没有找到匹配当前访问路径的 Servlet,那么久会使用此 Servlet 进行处理。
我们还可以为一个 Servlet 配置多个访问路径:
@WebServlet({"/test1", "/test2"}) |
我们接着来看 loadOnStartup 属性,此属性决定了是否在 Tomcat 启动时就加载此 Servlet,默认情况下,Servlet 只有在被访问时才会加载,它的默认值为 - 1,表示不在启动时加载,我们可以将其修改为大于等于 0 的数,来开启启动时加载。并且数字的大小决定了此 Servlet 的启动优先级。
@Log | |
@WebServlet(value = "/test", loadOnStartup = 1) | |
public class TestServlet extends HttpServlet { | |
@Override | |
public void init() throws ServletException { | |
super.init(); | |
log.info("我被初始化了!"); | |
} | |
@Override | |
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { | |
resp.setContentType("text/html;charset=UTF-8"); | |
resp.getWriter().write("<h1>恭喜你解锁了全新玩法</h1>"); | |
} | |
} |
其他内容都是 Servlet 的一些基本配置,这里就不详细讲解了。
# 使用 POST 请求完成登陆
我们前面已经了解了如何使用 Servlet 来处理 HTTP 请求,那么现在,我们就结合前端,来实现一下登陆操作。
我们需要修改一下我们的 Servlet,现在我们要让其能够接收一个 POST 请求:
@Log | |
@WebServlet("/login") | |
public class LoginServlet extends HttpServlet { | |
@Override | |
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { | |
req.getParameterMap().forEach((k, v) -> { | |
System.out.println(k + ": " + Arrays.toString(v)); | |
}); | |
} | |
} |
ParameterMap
存储了我们发送的 POST 请求所携带的表单数据,我们可以直接将其遍历查看,浏览器发送了什么数据。
现在我们再来修改一下前端:
<body> | |
<h1>登录到系统</h1> | |
<form method="post" action="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(注意不同路径的写法),也就是我们上面编写的 Servlet 路径。
运行服务器,测试后发现,在点击按钮后,确实向服务器发起了一个 POST 请求,并且携带了表单中文本框的数据。
现在,我们根据已有的基础,将其与数据库打通,我们进行一个真正的用户登录操作,首先修改一下 Servlet 的逻辑:
@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"); | |
// 权限校验(待完善) | |
}else { | |
resp.getWriter().write("错误,您的表单数据不完整!"); | |
} | |
} |
接下来我们再去编写 Mybatis 的依赖和配置文件,创建一个表,用于存放我们用户的账号和密码。
<?xml version="1.0" encoding="UTF-8" ?> | |
<!DOCTYPE configuration | |
PUBLIC "-//mybatis.org//DTD Config 3.0//EN" | |
"http://mybatis.org/dtd/mybatis-3-config.dtd"> | |
<configuration> | |
<environments default="development"> | |
<environment id="development"> | |
<transactionManager type="JDBC"/> | |
<dataSource type="POOLED"> | |
<property name="driver" value="${驱动类(含包名)}"/> | |
<property name="url" value="${数据库连接URL}"/> | |
<property name="username" value="${用户名}"/> | |
<property name="password" value="${密码}"/> | |
</dataSource> | |
</environment> | |
</environments> | |
</configuration> |
<dependency> | |
<groupId>org.mybatis</groupId> | |
<artifactId>mybatis</artifactId> | |
<version>3.5.7</version> | |
</dependency> | |
<dependency> | |
<groupId>mysql</groupId> | |
<artifactId>mysql-connector-java</artifactId> | |
<version>8.0.27</version> | |
</dependency> |
配置完成后,在我们的 Servlet 的 init 方法中编写 Mybatis 初始化代码,因为它只需要初始化一次。
SqlSessionFactory factory; | |
@SneakyThrows | |
@Override | |
public void init() throws ServletException { | |
factory = new SqlSessionFactoryBuilder().build(Resources.getResourceAsReader("mybatis-config.xml")); | |
} |
现在我们创建一个实体类以及 Mapper 来进行用户信息查询:
@Data | |
public class User { | |
String username; | |
String password; | |
} |
public interface UserMapper { | |
@Select("select * from users where username = #{username} and password = #{password}") | |
User getUser(@Param("username") String username, @Param("password") String password); | |
} |
<mappers> | |
<mapper class="com.example.dao.UserMapper"/> | |
</mappers> |
好了,现在完事具备,只欠东风了,我们来完善一下登陆验证逻辑:
// 登陆校验(待完善) | |
try (SqlSession sqlSession = factory.openSession(true)){ | |
UserMapper mapper = sqlSession.getMapper(UserMapper.class); | |
User user = mapper.getUser(username, password); | |
// 判断用户是否登陆成功,若查询到信息则表示存在此用户 | |
if(user != null){ | |
resp.getWriter().write("登陆成功!"); | |
}else { | |
resp.getWriter().write("登陆失败,请验证您的用户名或密码!"); | |
} | |
} |
现在再去浏览器上进行测试吧!
注册界面其实是同理的,这里就不多做讲解了。
# 上传和下载文件
首先我们来看看比较简单的下载文件,首先将我们的 icon.png 放入到 resource 文件夹中,接着我们编写一个 Servlet 用于处理文件下载:
@WebServlet("/file") | |
public class FileServlet extends HttpServlet { | |
@Override | |
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { | |
resp.setContentType("image/png"); | |
OutputStream outputStream = resp.getOutputStream(); | |
InputStream inputStream = Resources.getResourceAsStream("icon.png"); | |
} | |
} |
为了更加快速地编写 IO 代码,我们可以引入一个工具库:
<dependency> | |
<groupId>commons-io</groupId> | |
<artifactId>commons-io</artifactId> | |
<version>2.6</version> | |
</dependency> |
使用此类库可以快速完成 IO 操作:
resp.setContentType("image/png"); | |
OutputStream outputStream = resp.getOutputStream(); | |
InputStream inputStream = Resources.getResourceAsStream("icon.png"); | |
// 直接使用 copy 方法完成转换 | |
IOUtils.copy(inputStream, outputStream); |
现在我们在前端页面添加一个链接,用于下载此文件:
<hr> | |
<a href="file" download="icon.png">点我下载高清资源</a> |
下载文件搞定,那么如何上传一个文件呢?
首先我们编写前端部分:
<form method="post" action="file" enctype="multipart/form-data"> | |
<div> | |
<input type="file" name="test-file"> | |
</div> | |
<div> | |
<button>上传文件</button> | |
</div> | |
</form> |
注意必须添加 enctype="multipart/form-data"
,来表示此表单用于文件传输。
现在我们来修改一下 Servlet 代码:
@MultipartConfig | |
@WebServlet("/file") | |
public class FileServlet extends HttpServlet { | |
@Override | |
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { | |
try(FileOutputStream stream = new FileOutputStream("/Users/nagocoler/Documents/IdeaProjects/WebTest/test.png")){ | |
Part part = req.getPart("test-file"); | |
IOUtils.copy(part.getInputStream(), stream); | |
resp.setContentType("text/html;charset=UTF-8"); | |
resp.getWriter().write("文件上传成功!"); | |
} | |
} | |
} |
注意,必须添加 @MultipartConfig
注解来表示此 Servlet 用于处理文件上传请求。
现在我们再运行服务器,并将我们刚才下载的文件又上传给服务端。
# 使用 XHR 请求数据
现在我们希望,网页中的部分内容,可以动态显示,比如网页上有一个时间,旁边有一个按钮,点击按钮就可以刷新当前时间。
这个时候就需要我们在网页展示时向后端发起请求了,并根据后端响应的结果,动态地更新页面中的内容,要实现此功能,就需要用到 JavaScript 来帮助我们,首先在 js 中编写我们的 XHR 请求,并在请求中完成动态更新:
function updateTime() { | |
let xhr = new XMLHttpRequest(); | |
xhr.onreadystatechange = function() { | |
if (xhr.readyState === 4 && xhr.status === 200) { | |
document.getElementById("time").innerText = xhr.responseText | |
} | |
}; | |
xhr.open('GET', 'time', true); | |
xhr.send(); | |
} |
接着修改一下前端页面,添加一个时间显示区域:
<hr> | |
<div id="time"></div> | |
<br> | |
<button onclick="updateTime()">更新数据</button> | |
<script> | |
updateTime() | |
</script> |
最后创建一个 Servlet 用于处理时间更新请求:
@WebServlet("/time") | |
public class TimeServlet extends HttpServlet { | |
@Override | |
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { | |
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy年MM月dd日 HH:mm:ss"); | |
String date = dateFormat.format(new Date()); | |
resp.setContentType("text/html;charset=UTF-8"); | |
resp.getWriter().write(date); | |
} | |
} |
现在点击按钮就可以更新了。
GET 请求也能传递参数,这里做一下演示。
# 重定向与请求转发
当我们希望用户登录完成之后,直接跳转到网站的首页,那么这个时候,我们就可以使用重定向来完成。当浏览器收到一个重定向的响应时,会按照重定向响应给出的地址,再次向此地址发出请求。
实现重定向很简单,只需要调用一个方法即可,我们修改一下登陆成功后执行的代码:
resp.sendRedirect("time"); |
调用后,响应的状态码会被设置为 302,并且响应头中添加了一个 Location 属性,此属性表示,需要重定向到哪一个网址。
现在,如果我们成功登陆,那么服务器会发送给我们一个重定向响应,这时,我们的浏览器会去重新请求另一个网址。这样,我们在登陆成功之后,就可以直接帮助用户跳转到用户首页了。
那么我们接着来看请求转发,请求转发其实是一种服务器内部的跳转机制,我们知道,重定向会使得浏览器去重新请求一个页面,而请求转发则是服务器内部进行跳转,它的目的是,直接将本次请求转发给其他 Servlet 进行处理,并由其他 Servlet 来返回结果,因此它是在进行内部的转发。
req.getRequestDispatcher("/time").forward(req, resp); |
现在,在登陆成功的时候,我们将请求转发给处理时间的 Servlet,注意这里的路径规则和之前的不同,我们需要填写 Servlet 上指明的路径,并且请求转发只能转发到此应用程序内部的 Servlet,不能转发给其他站点或是其他 Web 应用程序。
现在再次进行登陆操作,我们发现,返回结果为一个 405 页面,证明了,我们的请求现在是被另一个 Servlet 进行处理,并且请求的信息全部被转交给另一个 Servlet,由于此 Servlet 不支持 POST 请求,因此返回 405 状态码。
那么也就是说,该请求包括请求参数也一起被传递了,那么我们可以尝试获取以下 POST 请求的参数。
现在我们给此 Servlet 添加 POST 请求处理,直接转交给 Get 请求处理:
@Override | |
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { | |
this.doGet(req, resp); | |
} |
再次访问,成功得到结果,但是我们发现,浏览器只发起了一次请求,并没有再次请求新的 URL,也就是说,这一次请求直接返回了请求转发后的处理结果。
那么,请求转发有什么好处呢?它可以携带数据!
req.setAttribute("test", "我是请求转发前的数据"); | |
req.getRequestDispatcher("/time").forward(req, resp); |
System.out.println(req.getAttribute("test")); |
通过 setAttribute
方法来给当前请求添加一个附加数据,在请求转发后,我们可以直接获取到该数据。
重定向属于 2 次请求,因此无法使用这种方式来传递数据,那么,如何在重定向之间传递数据呢?我们可以使用即将要介绍的 ServletContext 对象。
最后总结,两者的区别为:
- 请求转发是一次请求,重定向是两次请求
- 请求转发地址栏不会发生改变, 重定向地址栏会发生改变
- 请求转发可以共享请求参数 ,重定向之后,就获取不了共享参数了
- 请求转发只能转发给内部的 Servlet
# 了解 ServletContext 对象
ServletContext 全局唯一,它是属于整个 Web 应用程序的,我们可以通过 getServletContext()
来获取到此对象。
此对象也能设置附加值:
ServletContext context = getServletContext(); | |
context.setAttribute("test", "我是重定向之前的数据"); | |
resp.sendRedirect("time"); |
System.out.println(getServletContext().getAttribute("test")); |
因为无论在哪里,无论什么时间,获取到的 ServletContext 始终是同一个对象,因此我们可以随时随地获取我们添加的属性。
它不仅仅可以用来进行数据传递,还可以做一些其他的事情,比如请求转发:
context.getRequestDispatcher("/time").forward(req, resp); |
它还可以获取根目录下的资源文件(注意是 webapp 根目录下的,不是 resource 中的资源)
# 初始化参数
初始化参数类似于初始化配置需要的一些值,比如我们的数据库连接相关信息,就可以通过初始化参数来给予 Servlet,或是一些其他的配置项,也可以使用初始化参数来实现。
我们可以给一个 Servlet 添加一些初始化参数:
@WebServlet(value = "/login", initParams = { | |
@WebInitParam(name = "test", value = "我是一个默认的初始化参数") | |
}) |
它也是以键值对形式保存的,我们可以直接通过 Servlet 的 getInitParameter
方法获取:
System.out.println(getInitParameter("test")); |
但是,这里的初始化参数仅仅是针对于此 Servlet,我们也可以定义全局初始化参数,只需要在 web.xml 编写即可:
<context-param> | |
<param-name>lbwnb</param-name> | |
<param-value>我是全局初始化参数</param-value> | |
</context-param> |
我们需要使用 ServletContext 来读取全局初始化参数:
ServletContext context = getServletContext(); | |
System.out.println(context.getInitParameter("lbwnb")); |
有关 ServletContext 其他的内容,我们需要完成后面内容的学习,才能理解。
# Cookie
什么是 Cookie?不是曲奇,它可以在浏览器中保存一些信息,并且在下次请求时,请求头中会携带这些信息。
我们可以编写一个测试用例来看看:
Cookie cookie = new Cookie("test", "yyds"); | |
resp.addCookie(cookie); | |
resp.sendRedirect("time"); |
for (Cookie cookie : req.getCookies()) { | |
System.out.println(cookie.getName() + ": " + cookie.getValue()); | |
} |
我们可以观察一下,在 HttpServletResponse
中添加 Cookie 之后,浏览器的响应头中会包含一个 Set-Cookie
属性,同时,在重定向之后,我们的请求头中,会携带此 Cookie 作为一个属性,同时,我们可以直接通过 HttpServletRequest
来快速获取有哪些 Cookie 信息。
还有这么神奇的事情吗?那么我们来看看,一个 Cookie 包含哪些信息:
- name - Cookie 的名称,Cookie 一旦创建,名称便不可更改
- value - Cookie 的值,如果值为 Unicode 字符,需要为字符编码。如果为二进制数据,则需要使用 BASE64 编码
- maxAge - Cookie 失效的时间,单位秒。如果为正数,则该 Cookie 在 maxAge 秒后失效。如果为负数,该 Cookie 为临时 Cookie,关闭浏览器即失效,浏览器也不会以任何形式保存该 Cookie。如果为 0,表示删除该 Cookie。默认为 - 1。
- secure - 该 Cookie 是否仅被使用安全协议传输。安全协议。安全协议有 HTTPS,SSL 等,在网络上传输数据之前先将数据加密。默认为 false。
- path - Cookie 的使用路径。如果设置为 “/sessionWeb/”,则只有 contextPath 为 “/sessionWeb” 的程序可以访问该 Cookie。如果设置为 “/”,则本域名下 contextPath 都可以访问该 Cookie。注意最后一个字符必须为 “/”。
- domain - 可以访问该 Cookie 的域名。如果设置为 “.google.com”,则所有以 “google.com” 结尾的域名都可以访问该 Cookie。注意第一个字符必须为 “.”。
- comment - 该 Cookie 的用处说明,浏览器显示 Cookie 信息的时候显示该说明。
- version - Cookie 使用的版本号。0 表示遵循 Netscape 的 Cookie 规范,1 表示遵循 W3C 的 RFC 2109 规范
我们发现,最关键的其实是 name
、 value
、 maxAge
、 domain
属性。
那么我们来尝试修改一下 maxAge 来看看失效时间:
cookie.setMaxAge(20); |
设定为 20 秒,我们可以直接看到,响应头为我们设定了 20 秒的过期时间。20 秒内访问都会携带此 Cookie,而超过 20 秒,Cookie 消失。
既然了解了 Cookie 的作用,我们就可以通过使用 Cookie 来实现记住我功能,我们可以将用户名和密码全部保存在 Cookie 中,如果访问我们的首页时携带了这些 Cookie,那么我们就可以直接为用户进行登陆,如果登陆成功则直接跳转到首页,如果登陆失败,则清理浏览器中的 Cookie。
那么首先,我们先在前端页面的表单中添加一个勾选框:
<div> | |
<label> | |
<input type="checkbox" placeholder="记住我" name="remember-me"> | |
记住我 | |
</label> | |
</div> |
接着,我们在登陆成功时进行判断,如果用户勾选了记住我,那么就讲 Cookie 存储到本地:
if(map.containsKey("remember-me")){ // 若勾选了勾选框,那么会此表单信息 | |
Cookie cookie_username = new Cookie("username", username); | |
cookie_username.setMaxAge(30); | |
Cookie cookie_password = new Cookie("password", password); | |
cookie_password.setMaxAge(30); | |
resp.addCookie(cookie_username); | |
resp.addCookie(cookie_password); | |
} |
然后,我们修改一下默认的请求地址,现在一律通过 http://localhost:8080/yyds/login
进行登陆,那么我们需要添加 GET 请求的相关处理:
@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 帮我们返回静态页面 | |
} |
现在,30 秒内都不需要登陆,访问登陆页面后,会直接跳转到 time 页面。
现在已经离我们理想的页面越来越接近了,但是仍然有一个问题,就是我们的首页,无论是否登陆,所有人都可以访问,那么,如何才可以实现只有登陆之后才能访问呢?这就需要用到 Session 了。
# Session
由于 HTTP 是无连接的,那么如何能够辨别当前的请求是来自哪个用户发起的呢?Session 就是用来处理这种问题的,每个用户的会话都会有一个自己的 Session 对象,来自同一个浏览器的所有请求,就属于同一个会话。
但是 HTTP 协议是无连接的呀,那 Session 是如何做到辨别是否来自同一个浏览器呢?Session 实际上是基于 Cookie 实现的,前面我们了解了 Cookie,我们知道,服务端可以将 Cookie 保存到浏览器,当浏览器下次访问时,就会附带这些 Cookie 信息。
Session 也利用了这一点,它会给浏览器设定一个叫做 JSESSIONID
的 Cookie,值是一个随机的排列组合,而此 Cookie 就对应了你属于哪一个对话,只要我们的浏览器携带此 Cookie 访问服务器,服务器就会通过 Cookie 的值进行辨别,得到对应的 Session 对象,因此,这样就可以追踪到底是哪一个浏览器在访问服务器。
那么现在,我们在用户登录成功之后,将用户对象添加到 Session 中,只要是此用户发起的请求,我们都可以从 HttpSession
中读取到存储在会话中的数据:
HttpSession session = req.getSession(); | |
session.setAttribute("user", user); |
同时,如果用户没有登录就去访问首页,那么我们将发送一个重定向请求,告诉用户,需要先进行登录才可以访问:
HttpSession session = req.getSession(); | |
User user = (User) session.getAttribute("user"); | |
if(user == null) { | |
resp.sendRedirect("login"); | |
return; | |
} |
在访问的过程中,注意观察 Cookie 变化。
Session 并不是永远都存在的,它有着自己的过期时间,默认时间为 30 分钟,若超过此时间,Session 将丢失,我们可以在配置文件中修改过期时间:
<session-config> | |
<session-timeout>1</session-timeout> | |
</session-config> |
我们也可以在代码中使用 invalidate
方法来使 Session 立即失效:
session.invalidate(); |
现在,通过 Session,我们就可以更好地控制用户对于资源的访问,只有完成登陆的用户才有资格访问首页。
# Filter
有了 Session 之后,我们就可以很好地控制用户的登陆验证了,只有授权的用户,才可以访问一些页面,但是我们需要一个一个去进行配置,还是太过复杂,能否一次性地过滤掉没有登录验证的用户呢?
过滤器相当于在所有访问前加了一堵墙,来自浏览器的所有访问请求都会首先经过过滤器,只有过滤器允许通过的请求,才可以顺利地到达对应的 Servlet,而过滤器不允许的通过的请求,我们可以自由地进行控制是否进行重定向或是请求转发。并且过滤器可以添加很多个,就相当于添加了很多堵墙,我们的请求只有穿过层层阻碍,才能与 Servlet 相拥,像极了爱情。
添加一个过滤器非常简单,只需要实现 Filter 接口,并添加 @WebFilter
注解即可:
@WebFilter("/*") // 路径的匹配规则和 Servlet 一致,这里表示匹配所有请求 | |
public class TestFilter implements Filter { | |
@Override | |
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { | |
} | |
} |
这样我们就成功地添加了一个过滤器,那么添加一句打印语句看看,是否所有的请求都会经过此过滤器:
HttpServletRequest request = (HttpServletRequest) servletRequest; | |
System.out.println(request.getRequestURL()); |
我们发现,现在我们发起的所有请求,一律需要经过此过滤器,并且所有的请求都没有任何的响应内容。
那么如何让请求可以顺利地到达对应的 Servlet,也就是说怎么让这个请求顺利通过呢?我们只需要在最后添加一句:
filterChain.doFilter(servletRequest, servletResponse); |
那么这行代码是什么意思呢?
由于我们整个应用程序可能存在多个过滤器,那么这行代码的意思实际上是将此请求继续传递给下一个过滤器,当没有下一个过滤器时,才会到达对应的 Servlet 进行处理,我们可以再来创建一个过滤器看看效果:
@WebFilter("/*") | |
public class TestFilter2 implements Filter { | |
@Override | |
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { | |
System.out.println("我是2号过滤器"); | |
filterChain.doFilter(servletRequest, servletResponse); | |
} | |
} |
由于过滤器的过滤顺序是按照类名的自然排序进行的,因此我们将第一个过滤器命名进行调整。
我们发现,在经过第一个过滤器之后,会继续前往第二个过滤器,只有两个过滤器全部经过之后,才会到达我们的 Servlet 中。
实际上,当 doFilter
方法调用时,就会一直向下直到 Servlet,在 Servlet 处理完成之后,又依次返回到最前面的 Filter,类似于递归的结构,我们添加几个输出语句来判断一下:
@Override | |
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { | |
System.out.println("我是2号过滤器"); | |
filterChain.doFilter(servletRequest, servletResponse); | |
System.out.println("我是2号过滤器,处理后"); | |
} |
@Override | |
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { | |
System.out.println("我是1号过滤器"); | |
filterChain.doFilter(servletRequest, servletResponse); | |
System.out.println("我是1号过滤器,处理后"); | |
} |
最后验证我们的结论。
同 Servlet 一样,Filter 也有对应的 HttpFilter 专用类,它针对 HTTP 请求进行了专门处理,因此我们可以直接使用 HttpFilter 来编写:
public abstract class HttpFilter extends GenericFilter { | |
private static final long serialVersionUID = 7478463438252262094L; | |
public HttpFilter() { | |
} | |
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { | |
if (req instanceof HttpServletRequest && res instanceof HttpServletResponse) { | |
this.doFilter((HttpServletRequest)req, (HttpServletResponse)res, chain); | |
} else { | |
throw new ServletException("non-HTTP request or response"); | |
} | |
} | |
protected void doFilter(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws IOException, ServletException { | |
chain.doFilter(req, res); | |
} | |
} |
那么现在,我们就可以给我们的应用程序添加一个过滤器,用户在未登录情况下,只允许静态资源和登陆页面请求通过,登陆之后畅行无阻:
@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); | |
} | |
} |
现在,我们的页面已经基本完善为我们想要的样子了。
当然,可能跟着教程编写的项目比较乱,大家可以自己花费一点时间来重新编写一个 Web 应用程序,加深对之前讲解知识的理解。我们也会在之后安排一个编程实战进行深化练习。
# Listener
监听器并不是我们学习的重点内容,那么什么是监听器呢?
如果我们希望,在应用程序加载的时候,或是 Session 创建的时候,亦或是在 Request 对象创建的时候进行一些操作,那么这个时候,我们就可以使用监听器来实现。
默认为我们提供了很多类型的监听器,我们这里就演示一下监听 Session 的创建即可:
@WebListener | |
public class TestListener implements HttpSessionListener { | |
@Override | |
public void sessionCreated(HttpSessionEvent se) { | |
System.out.println("有一个Session被创建了"); | |
} | |
} |
有关监听器相关内容,了解即可。
# 了解 JSP 页面与加载规则
前面我们已经完成了整个 Web 应用程序生命周期中所有内容的学习,我们已经完全了解,如何编写一个 Web 应用程序,并放在 Tomcat 上部署运行,以及如何控制浏览器发来的请求,通过 Session+Filter 实现用户登陆验证,通过 Cookie 实现自动登陆等操作。到目前为止,我们已经具备编写一个完整 Web 网站的能力。
在之前的教程中,我们的前端静态页面并没有与后端相结合,我们前端页面所需的数据全部需要单独向后端发起请求获取,并动态进行内容填充,这是一种典型的前后端分离写法,前端只负责要数据和显示数据,后端只负责处理数据和提供数据,这也是现在更流行的一种写法,让前端开发者和后端开发者各尽其责,更加专一,这才是我们所希望的开发模式。
JSP 并不是我们需要重点学习的内容,因为它已经过时了,使用 JSP 会导致前后端严重耦合,因此这里只做了解即可。
JSP 其实就是一种模板引擎,那么何谓模板引擎呢?顾名思义,它就是一个模板,而模板需要我们填入数据,才可以变成一个页面,也就是说,我们可以直接在前端页面中直接填写数据,填写后生成一个最终的 HTML 页面返回给前端。
首先我们来创建一个新的项目,项目创建成功后,删除 Java 目录下的内容,只留下默认创建的 jsp 文件,我们发现,在 webapp 目录中,存在一个 index.jsp
文件,现在我们直接运行项目,会直接访问这个 JSP 页面。
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<!DOCTYPE html>
<html>
<head>
<title>JSP - Hello World</title>
</head>
<body>
<h1><%= "Hello World!" %>
</h1>
<br/>
<a href="hello-servlet">Hello Servlet</a>
</body>
</html>
但是我们并没有编写对应的 Servlet 来解析啊,那么为什么这个 JSP 页面会被加载呢?
实际上,我们一开始提到的两个 Tomcat 默认的 Servlet 中,一个是用于请求静态资源,还有一个就是用于处理 jsp 的:
<!-- The mappings for the JSP servlet --> | |
<servlet-mapping> | |
<servlet-name>jsp</servlet-name> | |
<url-pattern>*.jsp</url-pattern> | |
<url-pattern>*.jspx</url-pattern> | |
</servlet-mapping> |
那么,JSP 和普通 HTML 页面有什么区别呢,我们发现它的语法和普通 HTML 页面几乎一致,我们可以直接在 JSP 中编写 Java 代码,并在页面加载的时候执行,我们随便找个地方插入:
<%
System.out.println("JSP页面被加载");
%>
我们发现,请求一次页面,页面就会加载一次,并执行我们填写的 Java 代码。也就是说,我们可以直接在此页面中执行 Java 代码来填充我们的数据,这样我们的页面就变成了一个动态页面,使用 <%= %>
来填写一个值:
<h1><%= new Date() %></h1>
现在访问我们的网站,每次都会创建一个新的 Date 对象,因此每次访问获取的时间都不一样,我们的网站已经算是一个动态的网站的了。
虽然这样在一定程度上上为我们提供了便利,但是这样的写法相当于整个页面既要编写前端代码,也要编写后端代码,随着项目的扩大,整个页面会显得难以阅读,并且现在都是前后端开发人员职责非常明确的,如果要编写 JSP 页面,那就必须要招一个既会前端也会后端的程序员,这样显然会导致不必要的开销。
那么我们来研究一下,为什么 JSP 页面能够在加载的时候执行 Java 代码呢?
首先我们将此项目打包,并在 Tomcat 服务端中运行,生成了一个文件夹并且可以正常访问。
我们现在看到 work
目录,我们发现这个里面多了一个 index_jsp.java
和 index_jsp.class
,那么这些东西是干嘛的呢,我们来反编译一下就啥都知道了:
public final class index_jsp extends org.apache.jasper.runtime.HttpJspBase // 继承自 HttpServlet | |
implements org.apache.jasper.runtime.JspSourceDependent, | |
org.apache.jasper.runtime.JspSourceImports { | |
... | |
public void _jspService(final jakarta.servlet.http.HttpServletRequest request, final jakarta.servlet.http.HttpServletResponse response) | |
throws java.io.IOException, jakarta.servlet.ServletException { | |
if (!jakarta.servlet.DispatcherType.ERROR.equals(request.getDispatcherType())) { | |
final java.lang.String _jspx_method = request.getMethod(); | |
if ("OPTIONS".equals(_jspx_method)) { | |
response.setHeader("Allow","GET, HEAD, POST, OPTIONS"); | |
return; | |
} | |
if (!"GET".equals(_jspx_method) && !"POST".equals(_jspx_method) && !"HEAD".equals(_jspx_method)) { | |
response.setHeader("Allow","GET, HEAD, POST, OPTIONS"); | |
response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED, "JSP 只允许 GET、POST 或 HEAD。Jasper 还允许 OPTIONS"); | |
return; | |
} | |
} | |
final jakarta.servlet.jsp.PageContext pageContext; | |
jakarta.servlet.http.HttpSession session = null; | |
final jakarta.servlet.ServletContext application; | |
final jakarta.servlet.ServletConfig config; | |
jakarta.servlet.jsp.JspWriter out = null; | |
final java.lang.Object page = this; | |
jakarta.servlet.jsp.JspWriter _jspx_out = null; | |
jakarta.servlet.jsp.PageContext _jspx_page_context = null; | |
try { | |
response.setContentType("text/html; charset=UTF-8"); | |
pageContext = _jspxFactory.getPageContext(this, request, response, | |
null, true, 8192, true); | |
_jspx_page_context = pageContext; | |
application = pageContext.getServletContext(); | |
config = pageContext.getServletConfig(); | |
session = pageContext.getSession(); | |
out = pageContext.getOut(); | |
_jspx_out = out; | |
out.write("\n"); | |
out.write("\n"); | |
out.write("<!DOCTYPE html>\n"); | |
out.write("<html>\n"); | |
out.write("<head>\n"); | |
out.write(" <title>JSP - Hello World</title>\n"); | |
out.write("</head>\n"); | |
out.write("<body>\n"); | |
out.write("<h1>"); | |
out.print( new Date() ); | |
out.write("</h1>\n"); | |
System.out.println("JSP页面被加载"); | |
out.write("\n"); | |
out.write("<br/>\n"); | |
out.write("<a href=\"hello-servlet\">Hello Servlet</a>\n"); | |
out.write("</body>\n"); | |
out.write("</html>"); | |
} catch (java.lang.Throwable t) { | |
if (!(t instanceof jakarta.servlet.jsp.SkipPageException)){ | |
out = _jspx_out; | |
if (out != null && out.getBufferSize() != 0) | |
try { | |
if (response.isCommitted()) { | |
out.flush(); | |
} else { | |
out.clearBuffer(); | |
} | |
} catch (java.io.IOException e) {} | |
if (_jspx_page_context != null) _jspx_page_context.handlePageException(t); | |
else throw new ServletException(t); | |
} | |
} finally { | |
_jspxFactory.releasePageContext(_jspx_page_context); | |
} | |
} | |
} |
我们发现,它是继承自 HttpJspBase
类,我们可以反编译一下 jasper.jar(它在 tomcat 的 lib 目录中)来看看:
package org.apache.jasper.runtime; | |
import jakarta.servlet.ServletConfig; | |
import jakarta.servlet.ServletException; | |
import jakarta.servlet.http.HttpServlet; | |
import jakarta.servlet.http.HttpServletRequest; | |
import jakarta.servlet.http.HttpServletResponse; | |
import jakarta.servlet.jsp.HttpJspPage; | |
import java.io.IOException; | |
import org.apache.jasper.compiler.Localizer; | |
public abstract class HttpJspBase extends HttpServlet implements HttpJspPage { | |
private static final long serialVersionUID = 1L; | |
protected HttpJspBase() { | |
} | |
public final void init(ServletConfig config) throws ServletException { | |
super.init(config); | |
this.jspInit(); | |
this._jspInit(); | |
} | |
public String getServletInfo() { | |
return Localizer.getMessage("jsp.engine.info", new Object[]{"3.0"}); | |
} | |
public final void destroy() { | |
this.jspDestroy(); | |
this._jspDestroy(); | |
} | |
public final void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { | |
this._jspService(request, response); | |
} | |
public void jspInit() { | |
} | |
public void _jspInit() { | |
} | |
public void jspDestroy() { | |
} | |
protected void _jspDestroy() { | |
} | |
public abstract void _jspService(HttpServletRequest var1, HttpServletResponse var2) throws ServletException, IOException; | |
} |
实际上,Tomcat 在加载 JSP 页面时,会将其动态转换为一个 java 类并编译为 class 进行加载,而生成的 Java 类,正是一个 Servlet 的子类,而页面的内容全部被编译为输出字符串,这便是 JSP 的加载原理,因此,JSP 本质上依然是一个 Servlet!
如果同学们感兴趣的话,可以查阅一下其他相关的教程,本教程不再讲解此技术。
# 使用 Thymeleaf 模板引擎
虽然 JSP 为我们带来了便捷,但是其缺点也是显而易见的,那么有没有一种既能实现模板,又能兼顾前后端分离的模板引擎呢?
Thymeleaf(百里香叶)是一个适用于 Web 和独立环境的现代化服务器端 Java 模板引擎,官方文档:https://www.thymeleaf.org/documentation.html。
那么它和 JSP 相比,好在哪里呢,我们来看官网给出的例子:
<table> | |
<thead> | |
<tr> | |
<th th:text="#{msgs.headers.name}">Name</th> | |
<th th:text="#{msgs.headers.price}">Price</th> | |
</tr> | |
</thead> | |
<tbody> | |
<tr th:each="prod: ${allProducts}"> | |
<td th:text="${prod.name}">Oranges</td> | |
<td th:text="$ |