发送到服务器端的请求要先经过过滤器
①拦截
过滤器之所以能够对请求进行预处理,关键是对请求进行拦截,把请求拦截下来才能够做后续的操作。而且对于一个具体的过滤器,它必须明确它要拦截的请求,而不是所有请求都拦截。
②过滤
根据业务功能实际的需求,看看在把请求拦截到之后,需要做什么检查或什么操作,写对应的代码即可。
③放行
过滤器完成自己的任务或者是检测到当前请求符合过滤规则,那么可以将请求放行。所谓放行,就是让请求继续去访问它原本要访问的资源。
从头分析一遍
(1)注解@webFilter,servlet的注解是@webServlet,看起来差不多。后面这个标签要和关联的servlet的标签一样
在上面那张图里,filter是放在servlet之前的,可以说filter和servlet成对出现。只有标签一样,才能说这俩成对有关系
(2)一个类要成为filter,他得去实现javax.servlet.Filter这个接口
(3)实现接口之后,有三个方法要重写,init方法,doFilter方法,destroy方法。这个结构和servlet很像,init,service,destroy
(4)拦截,过滤和放行都是在doFilter方法中实现,所谓的拦截,其实就是在调用servlet之前,先调用filter
(5)doFilter在参数上和service有区别
doFilter用的是servletRequest和servletResponse
service用的是HttpServletRequest和HttpServletResponse
servletRequest接口是HttpServletRequest接口的父接口
servletResponse接口是HttpServletResponse接口的父接口
(6)FilterChain也是接口,其实现类的实例调用doFilter方法实现放行,这个doFilter方法是FilterChain自己定义的
public interface FilterChain {
void doFilter(ServletRequest var1, ServletResponse var2) throws IOException, ServletException;
}
//@WebFilter("/demo01.do")
@WebFilter("*.do")
public class Demo01Filter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
System.out.println("helloA");
//放行
filterChain.doFilter(servletRequest,servletResponse);
System.out.println("helloA2");
}
@Override
public void destroy() {
}
}
前面也说了,不只是请求,服务器端的响应也要经过过滤器,也就是说,请求先被拦截,打印helloA,然后放行,执行servlet的service方法,响应传回给客户端之前,响应被过滤器拦截,打印helloA2,最后,响应传到客户端
Filter也属于Servlet规范
Filter开发步骤:新建类实现Filter接口,然后实现其中的三个方法:init、doFilter、destroy
配置Filter,可以用注解@WebFilter,也可以使用xml文件
Filter在配置时,和servlet一样,也可以配置通配符,例如 @WebFilter(“*.do”)表示拦截所有以.do结尾的请求
过滤器链
1)执行的顺序依次是: A B C demo03 C2 B2 A2
2)如果采取的是注解的方式进行配置,那么过滤器链的拦截顺序是按照全类名的先后顺序排序的
3)如果采取的是xml的方式进行配置,那么按照配置的先后顺序进行排序
public interface FilterChain {
void doFilter(ServletRequest var1, ServletResponse var2) throws IOException, ServletException;
}
FilterChain有非常实际的应用场景,在调用控制器之前,需要对请求进行多层过滤
每个过滤器的设置都是一样的,标签都和配对的servlet一样
过滤器的顺序由全类名的先后顺序决定,也就是过滤器的名字按照字典序排列。这有点好笑,我估计底层是获取了所有的过滤器名,然后排了序
如果采取的是注解的方式进行配置,那么过滤器链的拦截顺序是按照全类名的先后顺序排序的
如果采取的是xml的方式进行配置,那么按照配置的先后顺序进行排序
之前中央控制器要负责请求的转码工作,现在转码可以交给过滤器去做
过滤器里边也可以写配置文件,设置一些初始化参数。之前在servlet中做过,有init-param,context-param
初始化参数存在FilterConfig实例中,妈的,这个FilterConfig也是一个接口
@WebFilter(urlPatterns = {"*.do"},initParams = {@WebInitParam(name = "encoding",value = "UTF-8")})
public class CharacterEncodingFilter implements Filter {
private String encoding = "UTF-8";
@Override
public void init(FilterConfig filterConfig) throws ServletException {
String encodingStr = filterConfig.getInitParameter("encoding");
if(StringUtil.isNotEmpty(encodingStr)){
encoding = encodingStr ;
}
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
((HttpServletRequest)servletRequest).setCharacterEncoding(encoding);
filterChain.doFilter(servletRequest,servletResponse);
}
@Override
public void destroy() {
}
}
在doFilter方法中,我们看到这么一句(HttpServletRequest)servletRequest
,将一个ServletRequest实例强转为HttpServletRequest实例,但是ServletRequest是HttpServletRequest的父接口啊,这里我就觉得很别扭
但是,这里用了多态,我们看不到接口的实现类,于是我去tomcat提供的servlet API里面去找,还真让我找着了
ServletRequest接口的实现类是ServletRequestWrapper
HttpServletRequest接口的实现类是HttpServletRequestWrapper
ServletRequestWrapper是HttpServletRequestWrapper的父类
public class HttpServletRequestWrapper extends ServletRequestWrapper implements HttpServletRequest{}
即便是找到了实现类,父类对象强转为子类对象,我印象中应该是不行的,我模仿这个结构试了一下,报错了com.atguigu.java.Father cannot be cast to com.atguigu.java.Son,父类不能强转为子类
不清楚这里为啥可以
我突然想到是为啥可以强转了
在这里,形参+实参是
ServletRequest servletRequest = HttpServletRequestWrapper req
(HttpServletRequest)servletRequest
但这里的关系是真的复杂,强转为子接口类型还真是没听说过
父接口的引用 = 子接口的实现类的实例
将父接口的引用强转为子接口类型
DML,数据操作语言,也就是增删改查
定义:一组逻辑操作单元,也就是一个或多个DML操作
原则:保证所有事务都作为一个工作单元来执行,即使出现了故障,都不能改变这种执行方式
(1)要么所有的事务都被提交(commit),那么这些修改就永久地保存下来
(2)要么数据库管理系统将放弃所作的所有修改,整个事务回滚(rollback)到最初状态(回滚默认情况下是回滚到最近的一次DML操作,每进行一次DML都会默认提交一次,这在之前批量插入查询语句时碰到过)
DDL数据定义语言:
create alter(质变,而非change所指的表象上的变化) drop rename truncate(清空)
不可回滚的操作
(1)DDL操作一旦执行,都会自动提交。
set autocommit = false 对DDL操作失效
(2)DML默认情况下,一旦执行,就会自动提交。
我们可以通过set autocommit = false的方式取消DML操作的自动提交。
(3)默认在关闭连接时,会自动的提交数据
在JDBC的课程中,讲到事务管理的时候,还没有引入DAO的概念。当时只是实现了增删改查的通用操作,然后在一个test方法中实现了事务管理。当时举的例子是,将两个改操作合并为一个事务
如果引入DAO的概念,那么这个事务就可以作为DAOImpl中的一个方法
由此,我们再看上面的图,所谓的DAO1, DAO2, DAO3可以看做是DAOImpl中的三个方法,这三个方法相当于是3个事务
但是,在引入业务层之后,上面的结构就不再适用。因为事务的范围改变了,扩大了,业务层的一个方法才能称之为一个事务,业务层方法调用的任何一个DAO方法执行失败,则该事务失败,全部都要回滚
具体该怎么做呢???业务层和DAO层都加上事务管理吗??
前面说了,会话层的一个方法就是一个事务,而会话层的一个方法其实就是早期的servlet,比如说AddServlet,EditServlet,DelServlet,UpdateServlet等,一个请求不就对应了一个servlet吗,不就对应了会话层的一个方法了,那么一个请求不就对应了一个事务吗!!!!
离请求最近的不就是过滤器吗,那我用一个过滤器来做事务管理不就很合理了吗
会话层的一个业务方法可能调用DAOImpl中的多个方法,而DAOImpl中的每个方法独立使用数据库连接,现在我们希望调用的多个DAOImpl方法共用一个数据库连接
前面已经说了使用一个过滤器来负责事务管理
OpenSessionInView这个词直译过来是在视图中打开会话,很抽象,我查了一下,还有一种说法是 open session也就是open request, 这个session存在于一个请求-响应周期内,是否可以理解为延长了生命周期啥的,不是很清楚
@WebFilter("*.do")
public class OpenSessionInViewFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {}
@Override
public void destroy() {}
}
在doFilter方法中进行事务管理的相关操作
我们将事务管理的具体细节封装到TransactionManager类中,其结构如下
这个类来自于java.lang包
ThreadLocal是一个线程内部的数据存储类,通过它可以在指定的线程中存储数据,数据存储以后,只能在指定的线程中获取存储的数据,对于其他线程来说则是无法获取到的
感觉上也可以把她当做容器,但是他和线程应该有关系,更靠近线程
引入ThredLocal类的目的是为了实现一个事务共用一个数据库连接的需求
我们获取连接时只从ThreadLocal实例中获取,如果没有拿到,就造一个连接放到ThreadLocal实例中,然后再从ThreadLocal实例中取出数据库连接
这样就保证了事务执行过程中只有一个连接
我们将获取数据库连接的方法封装到了BaseDAO中,要想获取连接,就必须实例化XxxDAOImpl类,因此我们将获取数据库连接的操作封装到另外一个工具类ConnUtil类
ConnUtil类负责和连接相关的操作
于是TransactionManager就可以调用ConnUtil中获取数据库连接的方法来开启事务
同样的,提交事务和回滚事务也可完成
回顾上面TransactionManager中的代码,我们可以看到获取数据库连接的部分还是有重复代码,虽然已经封装了通过DriverManager获取连接的操作
因此,将重复代码封装到ConnUtil类中
(1)将通过DriverManager获取连接封装为一个方法
(2)从ThreadLocal实例获取连接封装为一个方法
private static ThreadLocal<Connection> threadLocal = new ThreadLocal<>();
public static final String DRIVER
public static final String URL
public static final String USER
public static final String PWD
private static Connection createConn(){
try {
//1.加载驱动
Class.forName(DRIVER);
//2.通过驱动管理器获取连接对象
return DriverManager.getConnection(URL, USER, PWD);
} catch (ClassNotFoundException | SQLException e) {
e.printStackTrace();
}
return null ;
}
public static Connection getConn(){
Connection conn = threadLocal.get();
if(conn==null){
conn =createConn();
threadLocal.set(conn);
}
return threadLocal.get() ;
}
于是TransactionManager中的结构变成
public class TransactionManager {
//开启事务
public static void beginTrans() throws SQLException {
ConnUtil.getConn().setAutoCommit(false);
}
//提交事务
public static void commit() throws SQLException {
ConnUtil.getConn().commit();
}
//回滚事务
public static void rollback() throws SQLException {
ConnUtil.getConn().rollback();
}
}
前面说了,一个业务就是一个事务,那如果两个业务组合呢,他们算一个事务还是两个事务,还是怎么着??
但是先不考虑这么复杂
请注意,在引入ConnUtil类之后,BaseDAO里面的结构变化
我们的目的是保证一个事务执行过程中只有一个数据库连接,此时BaseDAO中获取数据库连接的操作已经被封装到了ConnUtil类,但是BaseDAO中通用的增删改查方法仍然是调用BaseDAO中获取数据库连接的方法,因此BaseDAO中获取数据库连接的方法要修改,他直接调用ConnUtil类中获取数据库连接的方法,实质上就是要从ThreadLocal实例中取出数据库连接
protected Connection getConn(){return ConnUtil.getConn();}
这一套改下来,实质上已经实现同一个事务,同一个连接
再次回顾BaseDAO中的代码,通用的增删改查方法内部调用了BaseDAO中关闭资源的方法,关键在于他把数据库连接给关掉了,假如我的事务包含两个查询,第一个查询结束后就把连接关了,这样就破坏了同一个事务,同一个连接这一条件
因此,将BaseDAO中关闭资源的方法的方法体置空
protected void close(ResultSet rs , PreparedStatement psmt , Connection conn){}
我们还是需要关闭连接的,但是前面说了我们获取连接时只从ThreadLocal实例中获取,因此,在引入ThreadLocal背景之下,关闭连接实际上是要处理掉存储在ThreadLocal实例里面的数据库连接
跟连接相关的操作还是封装到ConnUtil类中
从程序来看,还是先从ThreadLocal实例中取出连接,再看这个连接是否关闭,没关闭就关闭,然后将ThreadLocal实例中的Connection类型的属性设为null
public static void closeConn() throws SQLException {
Connection conn = threadLocal.get();
if(conn==null){
return ;
}
if(!conn.isClosed()){
conn.close();
threadLocal.set(null);
}
}
之后就是在TransactionManager中关闭连接
//提交事务
public static void commit() throws SQLException {
Connection conn = ConnUtil.getConn();
conn.commit();
ConnUtil.closeConn();
}
//回滚事务
public static void rollback() throws SQLException {
Connection conn = ConnUtil.getConn();
conn.rollback();
ConnUtil.closeConn();
}
完成TransactionManager的编写之后,就可以完成过滤器的编写了
@WebFilter("*.do")
public class OpenSessionInViewFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
try{
TransactionManager.beginTrans();
System.out.println("开启事务....");
filterChain.doFilter(servletRequest, servletResponse);
TransactionManager.commit();
System.out.println("提交事务...");
}catch (Exception e){
e.printStackTrace();
try {
TransactionManager.rollback();
System.out.println("回滚事务....");
} catch (SQLException ex) {
ex.printStackTrace();
}
}
}
@Override
public void destroy() {
}
}
到目前为止,过滤器,中央控制器,控制器,业务层,DAO,我们大量使用try catch,带来的直接问题就是一旦出现异常,try catch在异常出现的位置就把他处理了,我们不清楚到底是哪一层的那个位置出了问题
我们希望建立这样一种异常体系,如果DAO层出现异常,那么应该有提示说DAO层出现了异常
尝试着在XxxDAOImpl里边抛出DAO层的异常
自定义DAO层异常
public class DAOException extends RuntimeException{
public DAOException(String msg){
super(msg);
}
}
值得注意的是,他是一个运行时异常
运行时异常有特定用途 - 它们表示编程问题只能通过更改代码来解决,而不是更改程序运行的环境
一般来说,需要抛出运行时异常的情况有两类:
(1)传递无效参数值 - 这是运行时异常的最常见原因。大多数参数验证异常应该是运行时异常。 Java 提供了几个子类来表示这些特定问题。
(2)以错误的顺序调用方法 - 这是另一个常见原因。当某些方法在类完成初始化或其他一些准备步骤之前无法调用时,在错误的时间调用会导致运行时异常。
但是,这种做法的问题在于有很多重复代码,XxxDAOImpl中的每一个方法都需要去抛DAO异常
反正XxxDAOImpl也是调用父类BaseDAO中的增删改查,那么就在父类里边抛DAO异常呗,BaseDAO中的方法数量是固定的
//执行更新,返回影响行数
protected int executeUpdate(String sql , Object... params) {
boolean insertFlag = false ;
insertFlag = sql.trim().toUpperCase().startsWith("INSERT");
conn = getConn();
try{
}catch (SQLException e){
e.printStackTrace();
throw new DAOException("BaseDAO executeUpdate出错了");
}
}
控制器里边都是调用业务层的方法,没有try catch异常
中央控制器里边倒是有try catch
前面说了,这个类来自于java.lang
直译过来是本地线程,他也确实和线程有关。如果你在线程1中向ThreadLocal实例存了一个变量,那么在线程2中你是无法从同一个ThreadLocal实例获取到这个变量
ThreadLocal是一个线程内部的数据存储类,通过它可以在指定的线程中存储数据,数据存储以后,只能在指定的线程中获取存储的数据,对于其他线程来说则是无法获取到的
只能存一个变量,所以说不需要setXxx,直接set就行了
源码
public void set(T value) {
Thread t = Thread.currentThread(); //获取当前的线程
ThreadLocalMap map = getMap(t); //每一个线程都维护各自的一个容器(ThreadLocalMap)
if (map != null)
map.set(this, value); //这里的key对应的是ThreadLocal,因为我们的组件中需要传输(共享)的对象可能会有多个(不止Connection)
else
createMap(t, value); //默认情况下map是没有初始化的,那么第一次往其中添加数据时,会去初始化
}
ThreadLocalMap类是ThreadLocal类的静态内部类
static class ThreadLocalMap{}
如果说同一个线程内有多个ThreadLocal,他们共用同一个ThreadLocalMap容器
public T get() {
Thread t = Thread.currentThread(); //获取当前的线程
ThreadLocalMap map = getMap(t); //获取和这个线程(企业)相关的ThreadLocalMap(也就是工作纽带的集合)
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this); //this指的是ThreadLocal对象,通过它才能知道是哪一个工作纽带
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value; //entry.value就可以获取到工具箱了
return result;
}
}
return setInitialValue();
}
entry在集合中出现过,在计算机领域可以译为项,一项,两项的项,好像就是表示键值对
监听器的种类很多,其实就跟咱们的任务管理器一样,对相关组件都要进行监听
tomcat容器中有3个map容器(或者叫域对象)。HttpServletRequest,HttpSession,ServletContext
ServletRequest是HttpServletRequest的父接口,他们的实现类由tomcat提供,都加上了后缀Wrapper
1) ServletContextListener - 监听ServletContext对象的创建和销毁的过程(initialize,destroy)
2) HttpSessionListener - 监听HttpSession对象的创建和销毁的过程
3) ServletRequestListener - 监听ServletRequest对象的创建和销毁的过程
4) ServletContextAttributeListener - 监听ServletContext的保存作用域的改动(add,remove,replace)
5) HttpSessionAttributeListener - 监听HttpSession的保存作用域的改动(add,remove,replace)
6) ServletRequestAttributeListener - 监听ServletRequest的保存作用域的改动(add,remove,replace)
7) HttpSessionBindingListener - 监听某个对象在Session域中的创建与移除
8) HttpSessionActivationListener - 监听某个对象在Session域中的序列化和反序列化
和之前自定义过滤器一样,自定义的监听器需要去实现上面的接口
以 ServletContextListener 为例,它是javax.servlet下的一个接口,继承了java.util下的接口EventListener
public interface EventListener {
}
public interface ServletContextListener extends EventListener {
void contextInitialized(ServletContextEvent var1);
void contextDestroyed(ServletContextEvent var1);
}
//@WebListener
public class MyServletContextListener implements ServletContextListener {
@Override
public void contextInitialized(ServletContextEvent servletContextEvent) {
System.out.println("Servlet上下文对象初始化动作被我监听到了....");
}
@Override
public void contextDestroyed(ServletContextEvent servletContextEvent) {
System.out.println("Servlet上下文对象销毁动作被我监听到了.....");
}
}
配置也相对简单,有两种方法:
(1)注解配置@WebListener
(2)在web.xm中配置,不需要像过滤器,serlet那样配置mapping,直接给出自定义监听器的位置即可
<listener>
<listener-class>com.atguigu.listener.MyServletContextListenerlistener-class>
listener>
我们在中央控制器重写的init方法中实例化了IOC层,但是我们希望将实例化的时间提前,提前到ServletContext初始化的时候,ServletContext容器是随着tomcat的启动而创建。这一做法的目的是提高系统的相应速度
前面已经看了监听器的结构
public interface ServletContextListener extends EventListener {
void contextInitialized(ServletContextEvent var1);
void contextDestroyed(ServletContextEvent var1);
}
到这儿,我们才知道这东西应该怎么用。我们自定义的监听器需要重写上面这两个方法,就从方法的角度讲,就是说当我们监听到ServletContext初始化的时候,可以执行我们重写的contextInitialized方法中的逻辑,比如说我们就可以实例化IOC层
但是,这样一来,中央控制器是没有办法拿到IOC实例的。难道说我们还要实例化自定义的监听器吗???没有这么麻烦,请注意ServletContext也是一个容器,而且他的作用域是当前的web应用,只要tomcat服务器开着,他就一直存在。所以,我们将IOC实例存到ServletContext容器里边,然后在中央控制器里把IOC实例从ServletContext容器中取出来
于是,我们先自定义一个监听器
注意看contextInitialized方法的参数,表示当ServletContext容器初始化的时候会捕获到一个ServletContextEvent实例传到contextInitialized方法
ServletContextEvent也是一个类,来自javax.servlet,继承EventObject类public class ServletContextEvent extends EventObject
EventObject类,来自java.util,实现了java.io.Serializable接口
//监听上下文启动,在上下文启动的时候去创建IOC容器,然后将其保存到application作用域
//后面中央控制器再从application作用域中去获取IOC容器
@WebListener
public class ContextLoaderListener implements ServletContextListener {
@Override
public void contextInitialized(ServletContextEvent servletContextEvent) {
//创建IOC容器
BeanFactory beanFactory = new ClassPathXmlApplicationContext();
//获取ServletContext对象
ServletContext application = servletContextEvent.getServletContext();
//将IOC容器保存到application作用域
application.setAttribute("beanFactory",beanFactory);
}
@Override
public void contextDestroyed(ServletContextEvent servletContextEvent) {
}
}
然后中央控制器再从application作用域中去获取IOC容器
private BeanFactory beanFactory ;
public DispatcherServlet(){
}
public void init() throws ServletException {
super.init();
//之前是在此处主动创建IOC容器的
//现在优化为从application作用域去获取
//beanFactory = new ClassPathXmlApplicationContext();
ServletContext application = getServletContext();
Object beanFactoryObj = application.getAttribute("beanFactory");
if(beanFactoryObj!=null){
beanFactory = (BeanFactory)beanFactoryObj ;
}else{
throw new RuntimeException("IOC容器获取失败!");
}
}
在原版的IOC容器中,我们在程序中硬编码了.xml配置文件的文件名,一般来说,我们希望这些第三方API都要尽可能隐藏起来
于是我们修改了IOC层的ClassPathXMLApplicationContext类,他增加了一个空参构造器,其内部调用的还是那个带参构造器
但奇怪的是为什么要有一个String类型的属性path,感觉它没什么作用. 无参构造器里边也不能使用这个path,因为使用无参构造器的时候,path还没有产生
private Map<String,Object> beanMap = new HashMap<>();
private String path = "applicationContext.xml" ;
//空参构造器:内部调用的还是带参构造器
public ClassPathXmlApplicationContext(){
this("applicationContext.xml");
}
public ClassPathXmlApplicationContext(String path){
if(StringUtil.isEmpty(path)){
throw new RuntimeException("IOC容器的配置文件没有指定...");
}
try {
InputStream inputStream = getClass().getClassLoader().getResourceAsStream(path);
//1.创建DocumentBuilderFactory
DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
//2.创建DocumentBuilder对象
DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder() ;
//3.创建Document对象
Document document = documentBuilder.parse(inputStream);
IOC加了带参构造器后,在监听器里边实例化IOC容器的时候就可以往里边传入.xml配置文件的位置
//监听上下文启动,在上下文启动的时候去创建IOC容器,然后将其保存到application作用域
//后面中央控制器再从application作用域中去获取IOC容器
@WebListener
public class ContextLoaderListener implements ServletContextListener {
@Override
public void contextInitialized(ServletContextEvent servletContextEvent) {
//1.获取ServletContext对象
ServletContext application = servletContextEvent.getServletContext();
//2.获取上下文的初始化参数
String path = application.getInitParameter("contextConfigLocation");
//3.创建IOC容器
BeanFactory beanFactory = new ClassPathXmlApplicationContext(path);
//4.将IOC容器保存到application作用域
application.setAttribute("beanFactory",beanFactory);
}
@Override
public void contextDestroyed(ServletContextEvent servletContextEvent) {
}
}
之前已经学过,三大容器,ServletContext,HttpServletRequest,HttpSession都可以配置初始化参数,于是我们可以在ServletContext容器的初始化参数中加上IOC配置文件的位置
在web.xml文件中配置
<context-param>
<param-name>contextConfigLocationparam-name>
<param-value>applicationContext.xmlparam-value>
context-param>
这是我目前写过最复杂的项目了,我之前的贪吃蛇也不过5,600行,而且逻辑很简单
还有一堆依赖:
(1)tomcat
(2)用于渲染的thymeleaf
(3)用于DAO的mysql
(4)用于数据库连接池的druid(德鲁伊)