目录
JDBC
JDBC英文全称为Java DataBase Connectivity,即Java数据库连接,也就是Java程序连接数据库的一种技术。说白了,就是当程序代码和数据库需要进行数据传输(交互)的时候使用的一门技术。
JDBC技术的基础使用
从上面的描述我们知道,除了搭建开发环境之外,搭建开发环境也就是需要使用哪些软件,因为本文是阐述JDBC的,这里就不作过多介绍咯,不会搭建java开发环境的可以问度娘,比较简单。还需要的就是把之前说的接口和实现准备好,因为接口是在jdk中,无需准备,实现的话,我们本次使用的是MySQL数据库,所以需要导入MySQL数据库的jar包,在百度中可以随意下载一个,比如我下载了一个5.1的版本,名称为"mysql-connector-java-5.1.17-bin.jar",然后导入到MyEclipse开发环境中。这里先作一个说明,数据库dbjdbc和数据表users我都已经建好了,Java项目JDBCDemo也建好了,就不作过程的阐述咯。
(1)加载MySQL的驱动类(要想使用mysql的实现类,得先注册一把)
Class.forName("com.mysql.jdbc.Driver");
为啥要这么做,加MySQL的驱动类加载到JVM中,说白了就是咱们要使用MySQL数据库的jar包了,是不是先得注册一把?
(2)根据DriverManager得到Connection对象(要java程序和数据库发生交互,得先连接上对应的数据)
Connection conn=DriverManager.getConnection(url, user, password);
a.其中url="jdbc:mysql://localhost:3306/dbjdbc",user="root",password="root"
b.url表示要连接到mysql的哪个数据库,mysql的默认端口是3306,前面的无需改,dbjdbc这里填写你自己的数据库名称
c.user填写mysql数据库的用户名
d.password填写mysql数据库的密码
e.之前我们说过,要使用JDBC技术,无非就是找类,找方法,找属性等,那么java代码要想和数据库发生联系,必定得先要有一个对象连接上对应的数据库,这个对象就是Connection对象,从名称中也可以看出,但是要注意的是Connection conn定义导包的时候,最好选择java.sql中的Connection,以接口的形式声明,这样以后的扩展性比较好,这就是多态的应用,这里不展开论述,有机会专门说说多态。
(3)根据连接对象Connection得到Statement对象(Connection对象只负责连接到哪个数据库,要想进行增删改查操作得重新找一个对象,比如Statement对象)
Statement stmt = conn.createStatement();
有了Connection连接对象之后,我们就知道具体要连接哪个数据库,接下来就是要进行具体的增删改查操作了,那怎么做呢?记住万物皆对象,Connection对象是负责连接的,而负责进行增删改查的对象又是什么呢?没错,就是Statement对象,当然这里是以Statement对象为例,还可以是其他对象,毕竟能做成一件事的途径不止一个,我们这里是为了演示使用。而Statement对象操作的是哪个数据库,这取决于由哪个Connection对象生成的Statement对象,这点很好理解吧?不过要记住,Statement对象在导包的时候也需要导入java.sql中的,原理和上面一样。
(4)增加(准备需要增加的sql语句,交给Statement对象的executeUpdate(sql)执行)
好了,说了这么多,接下来总可以进行插入操作了吧?也就是在java代码中通过jdbc技术向数据表中插入数据。这里肯定要准备一条sql语句,因为sql语句知道向哪个表中插入什么样的数据对不对?比如写上一条sql语句,String sql="insert into users(name) values('插入一条新数据!')"。那么如何让这条sql语句生效呢?这时候就想到了Statement对象,只要调用该对象的executeUpdate方法即可,并且将sql语句作为参数传入。返回值为int类型,表示受影响的行数,倘若上述的sql语句执行成功了,就会有一行受影响。
执行结果
(5)修改(修改的原理和增加的原理一样,准备一条需要修改的sql语句)
比如我们需要将id为1的name值改成"欢迎关注忆说就懂微信公众号",准备一条sql语句
String sql="update users set name='欢迎关注忆说就懂微信公众号' where id=1";
然后将该sql语句同样交给Statement对象的executeUpdate方法即可,如下图所示
执行结果
(6)删除(删除的原理和增加的原理一样,准备一条需要删除的sql语句)
比如我们需要将id为3的记录删除,准备一条sql语句
String sql="delete from users where id=3";
然后将该sql语句同样交给Statement对象的executeUpdate方法即可,如下图所示
执行结果
(7)查询
通过上面的增删改案例可以看出,增删改的原理是一样的,都是准备一条需要的sql语句,然后交给Statement对象的executeUpdate方法执行即可。那么查询呢?毋庸置疑,查询肯定也需要准备一条sql语句,因为你得告诉程序需要查询哪张表,哪条记录,查询条件是什么等等。这里我们查询users表的所有记录,然后打印输出到控制台。
String sql="select * from users";
然后将该sql语句交给Statement对象,此时调用的是executeQuery,从名称中可以看出,Query的意思是查询。一般使用一个方法,我们往往关心的是参数列表和返回值,这里的参数是sql,那么返回值是什么呢?既然是查询,那么返回值肯定是查询的结果,这个结果使用的是一个对象保存,即ResultSet,因为万物皆对象,该对象的结构和数据表的结构是一样的,要想取出其中的数据,循环读取出来,下面我们直接看代码。
从while循环开始理解,while(rs.next())表示循环读取行,数据表中每有一行就循环一次,这个比较容易理解,比如目前数据表中有两条记录,那么该循环就执行两次。
rs.getXxx明显是取出每行的值,比如第一次循环,读取的是第一行,我们知道对于表格,只要知道行与列就可以对应到某个具体单元格的值,那么现在行有了,列呢?rs.getXxx括号中的参数可以是列的序号,比如1,2,3表示第一列,第二列,第三列,也可以是列的名称,比如"id","name"。但是要注意,如果使用标号,该标号是从1开始的,因为程序中通常是从下标0开始,这点不同,另外如果通过列名来获取值,列名要和数据表中的名称对应,不然会报错,取不到值。至于rs.getXxx有时候是Int,有时候是String,是根据数据表中该列的类型决定的。这样一说,是不是非常容易理解。
比如在while进行第一次循环时,rs.getInt("id")表示取出第一行中列名为"id"的单元格的值,也就是第一行第一列的值,发现是"1";比如在while进行第二次循环时,rs.getString(2)表示取出第二行中第二列的单元格的值,发现是"JDBC",我们来看下运行的结果。
5.JDBC技术的进一步使用
有了上面的增删改查基础之后,我们不妨来看一个具体的案例,以"管理员登录"为例,查找admin表中是否存在该用户名和密码,从而判断是否登录成功。主要是为了了解JDBC的进一步使用。好了,咱们来开始。
首先来创建一张"admin"的数据表,如下图所示,其中有这些字段
然后我们使用之前的jdbc增加的技术对给admin表中插入一条记录,代码如下图所示
这样就可以向admin数据表中插入一条对应的记录,这里我就不截图数据表的图片了,肯定可以插入成功。
然后咱们定义一个方法叫做login,其中有两个参数username,password,模拟用户登录的情况。我们的想法是,当别人传来username和password的时候,我们用这两者作为select语句的查询条件部分,如果这样从数据表中能够查询到对应的记录,说明用户名和密码正确,否则用户名或者密码错误。sql语句如下图所示,这里进行了字符串的拼接,因为外面传来的是两个变量,直接写成"select * from admin where username=username and password=password"显然不合适,这样查询条件就变成了用户名为username,密码为password了,所以需要进行字符串的拼接。
将该sql语句交给Statement对象的executeQuery方法执行,得到一个ResultSet对象,最后我们来判断ResultSet对象中是否有数据就可以得出用户名和密码是否正确,流程比较简单,但这里想说明几个问题。
(1)拼接字符串好麻烦,能不能不拼接字符串?
大家肯定会有这样一个疑惑,万一后面的条件变多了,字段变多了,那么拼起来更加麻烦,还容易出错,那有没有解决方案呢?答案是肯定的,需要引出另外一个对象PreparedStatement,这个对象也是用于增删改查操作的,只不过和Statement对象有点不同,具体哪边不同,我们来看下面的改造。
a.得到PreparedStatement对象
之前通过Connection对象的createStatement方法可以得到Statement对象,那么PrepareStatement对象如何获得呢?如下图所示,只要调用Connection对象的prepareStatement方法即可,与createStatement对象不同的是,它需要接受一个sql语句,所以我们需要将sql语句放到创建该对象之前,这倒没问题太大问题,关键是这样有什么好处吗?再来看第二点
b.有了PreparedStatement对象之后,sql语句就可以进行改写
String sql="select * from admin where username=? and password=?";
有没有发现方便很多?那其中的"?"表示什么,它代表的是占位符,也就是它先把位置占在这,具体值是什么,接下来再指定
c.指定占位符的值
发现通过PreparedStatement对象可以对占位符的值进行指定,有了前面getXxx的基础,这里用pst.setXxx就很容易理解了吧?因为数据表中的username和password字段是varchar类型,所以这里用String来设置,第一个参数1和2,表示sql语句中占位符的顺序,比如1代表username=?中的"?",第二个参数表示用什么值来代替此占位符,发现是用别人传来的参数username和password进行代替的,这样是不是就简化了sql语句的写法,不用那样复杂的拼接字符串了。
d.执行查询操作
那接下来怎样进行查询操作呢?如下图所示,也是调用PreparedStatement对象的excuteQuery方法,与Statement对象不同的是,此时不用传sql语句了,因为在Connection对象创建PreparedStatement对象时已经把sql语句传入进行了预处理,得到的结果仍然是ResultSet对象,这点不难理解,同样是一个表格的形式。
(2)调用一下login方法,来做一个测试
好了,PreparedStatement对象咱们也会使用了,那接下来就模拟一下登录,在主函数中调用该login方法,传入username和password。为了看到效果,我们将ResultSet的结果进行一下判断,然后打印在控制台。这里之所以用if,是因为数据表中只有一条记录,如果能查询到ResultSet中也是一条记录,进行一次读取即可。
然后在主函数中进行调用,登录成功代码
运行结果
登录失败代码
运行结果
(3)好了,到了这里,应该说JDBC的基本操作已经都OK,还有一个问题需要说明一下
在根据username和password进行查询的时候,得到一个ResultSet结果集,我们肯定不能单单打印出里面的值,需要用一个对象将用户登录成功的数据保存起来,因为万物皆对象,而且在登录成功之后,比如需要显示用户的信息,像Xxx欢迎你等等,肯定不能说再查一次数据表,这样没有意义,也就是说当用户登录成功之后,我们需要用对象将其保存。
所以咱们需要设计一个对象,也就是实体类,用于保存用户登录成功之后的数据,如下图所示,于是我们设计了这样一个实体类,实体类的类名和数据表的表明对应,实体类的属性名称和数据表的字段名称对应,这是一般的设计原则,大家对为什么需要实体类有疑惑,可以关注下我们的微信公众号"忆说就懂",我们会写一份关于实体类的文章。总之现在知道一点,当用户登录成功之后,数据在ResultSet中,我们需要将ResultSet中的数据搬运到该实体类中,具体怎么做呢?
如下图所示,创建一个Admin对象,用于保存接下来的数据,然后读取ResultSet中的数据,读到每一行每一列的值分别赋值给admin对象里面的属性,通过set方法。另外,需要将login方法的返回值从void改成Admin,因为调用login方法后,需要得到一个实体类对象,这样别人才可以使用到该实体类对象中保存的数据,这点很容易理解吧?但是要注意,admin=new Admin()这句话一定要放到if语句中,不然通过判断admin是否为null的时候每次都是登录成功,细心的你一定知道是为什么。
于是我们在主函数中调用的时候,就可以根据该Admin对象是否为null,从而判断用户名和密码是否正确了,下面只演示了一下登录成功的情况,登录失败就不作截图咯。从代码和运行结果可以看出,不仅可以判断出用户名和密码是否正确,而且可以通过Admin对象拿到登录成功者的相关信息。
运行结果为
(4)但是不知道大家有没有发现,实际上这样的jdbc代码写起来还是有点麻烦的,一方面sql语句那里需要占位符,然后又要给占位符设置值,另外一方面在将ResultSet中的数据搬运到实体类中时,过程也麻烦。如果一个数据表中的字段很多,这样的过程写起来是非常痛苦的,而且感觉没什么价值。所以对于jdbc代码纵然可以实现crud操作,但不是很方便,于是有了各种对jdbc代码封装的框架,比如DBUtils,Hibernate,MyBatis等等,也就是它们把那些麻烦的操作都给封装起来了,只要我们学会使用它们即可,当然,框架的优势不仅如此,还有很多其他的优势,同时也有弊端,这里就不展开说明咯,等聊到具体的框架时咱们再分析。
orm框架的出现
java是一门编程语言,控制程序逻辑
产生的数据,需要用另外程序来处理和保存(数据库、SQL专门用来操作数据的)
通信:标准 :java出了一套自己的标准 JDBC(Java DataBase Connection) java和数据库连接的标准
跟数据库通信的规则:数据库桥接协议 jdbc:
第一步:要建立连接
第二步:要登陆数据库 用户名和密码
第三步:要通过Java程序来发送SQL命令给数据库,执行Java发过来的这些SQL(SQL语句,字符串)
第四步:要通过Java程序能够接收到数据所返回的结果。(包装)
ORM框架:
MyBatis Hibernate SpringJDBC JPA
1. 什么是ORM?
对象-关系映射(Object-Relational Mapping,简称ORM),面向对象的开发方法是当今企业级应用开发环境中的主流开发方法,关系数据库是企业级应用环境中永久存放数据的主流数据存储系统。对象和关系数据是业务实体的两种表现形式,业务实体在内存中表现为对象,在数据库中表现为关系数据。内存中的对象之间存在关联和继承关系,而在数据库中,关系数据无法直接表达多对多关联和继承关系。因此,对象-关系映射(ORM)系统一般以中间件的形式存在,主要实现程序对象到关系数据库数据的映射。
2.为什么使用ORM?
当我们实现一个应用程序时(不使用O/R Mapping),我们可能会写特别多数据访问层的代码,从数据库保存、删除、读取对象信息,而这些代码都是重复的。而使用ORM则会大大减少重复性代码。对象关系映射(Object Relational Mapping,简称ORM),主要实现程序对象到关系数据库数据的映射。
mybatis框架缓存使用
MyBatis 与 Hibernate
Hibernate提供全面的数据封装机制,是**全自动的**ORM,实现POJO和数据库表之间的映射,以及SQL自动生成和执行
MyBatis是**半自动的**ORM框架,不会自动生成SQL语句,需要自己编写,通过SQL语句映射文件将SQL所需的参数以及返回结果字段映射到指定POJO
MyBatis特点
- 在xml中配置SQL语句实现SQL语句与代码分离,给维护带来便利
- 可以结合数据库自身特点灵活控制SQL语句,具有更高的查询效率
MyBatis中configuration 配置
<?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>
<settings>
<!--当返回行的所有列都是空时,MyBatis默认返回null-->
<setting name="jdbcTypeForNull" value="NULL"/>
<!--cacheEnabled - 使全局的映射器启用或禁用缓存-->
<setting name="cacheEnabled" value="true"/>
</settings>
<!--类别名配置,配置后可以通过简单类名代替全限定类名-->
<typeAliases>
<typeAlias type="beans.UserInfo" alias="UserInfo"></typeAlias>
</typeAliases>
<!-- 配置运行环境-->
<environments default="mysql">
<environment id="mysql">
<!-- MyBatis 中有两种事务管理器类型,分别是:-->
<!--1. type = "JDBC" (依赖于数据源得到的连接来管理事务范围)-->
<!--2. type = "MANAGED" (不提交或回滚连接,让容器来管理事务的整个周期)-->
<transactionManager type="JDBC"/>
<dataSource type="POOLED">
<property name="driver" value="com.mysql.jdbc.Driver"/>
<property name="url" value="jdbc:mysql://localhost:3306/conference"/>
<property name="username" value="root"/>
<property name="password" value="qwer123456"/>
<!--poolMaximumActiveConnections – 正在使用连接的数量。默认值:10 -->
<!--poolMaximumIdleConnections – 任意时间存在的空闲连接数-->
</dataSource>
</environment>
</environments>
<mappers>
<mapper resource="userinfoMapper.xml"/>
</mappers>
</configuration>
#{ }中可以放的内容:
- 参数对象的属性
- 随意内容,此时的
#{ }
相当于一个占位符 - 接口方法参数类型Map时,
#{}
里可以存放Map对象的key
值 - 接口方法参数类型Map且Map对象的value为实体类对象时,
#{}
里可以存放该实体类对象对应的属性 - 参数的索引号
延迟加载
指在进行关联查询时,按照设置延迟规则推迟对关联对象的select查询,以减小数据库的压力。MyBatis只对关联对象的查询有延迟设置
,对主加载对象是直接执行查询语句。
MyBatis根据关联对象查询的select语句的执行时机分为3种类型:
- 直接加载(执行完主加载对象的select语句,马上执行关联对象的select查询)
- 侵入式延迟加载(执行对主加载对象的查询时,不会执行对关联对象的查询,当要访问主加载对象详情时才会执行关联的select查询)
- 深度延迟加载(执行对主加载对象的查询时,不会执行对关联对象的查询,访问主加载对象的详情时也不会执行关联对象的select查询,只有当真正访问关联对象详情时才会执行对关联对象的select查询)
延迟加载应用要求:
关联对象的查询与主加载对象的查询是分别进行的select语句,不能使用多表连接所进行的select查询
延迟加载设置: 在MyBatis主配置文件里的settings
标签下添加name为lazyLoadingEnabled
的value为true
的setting标签
<settings>
<!--当返回行的所有列都是空时,MyBatis默认返回null-->
<setting name="jdbcTypeForNull" value="NULL"/>
<!--cacheEnabled - 使全局的映射器启用或禁用缓存-->
<setting name="cacheEnabled" value="true"/>
<!--lazyLoadingEnabled - 全局的延迟加载设置-->
<setting name="lazyLoadingEnabled" value="true"/>
<!--aggressiveLazyLoading - 侵入式延迟加载设置-->
<setting name="aggressiveLazyLoading" value="flase"/>
</settings>
查询缓存
MyBatis查询缓存的作用域是根据映射配置文件mapper.xml
的namespace划分的,相同的namespace的mapper查询数据存放在同一个缓存区域,不同的namespace下的数据互不干扰。
无论是一级缓存还是二级缓存都是按照namespace进行分别存放的
MyBatis查询缓存机制根据缓存区的作用域(生命周期)可分为两种
- 一级查询缓存
- 一级查询缓存是基于
org.apache.ibatis.cache.impl.PerpetualCache
类的HashMap本地缓存,其生命周期为整个SqlSession,同一个SqlSession
执行两次相同的sql查询,第一次执行完毕后会将结果写入缓存,第二次查询时直接从缓存里查询。MyBatis一级查询缓存默认开启且不能关闭 - MyBatis中一级查询缓存读取数据的依据是:**mapper配置文件
查询标签select
的**id属性和sql语句;Hibernate中查询缓存的依据是:查询结果对象的id
- 一级查询缓存是基于
- 二级查询缓存
- 使用二级缓存的目的不是为了在多个查询间共享查询结果(Hibernate就是为了共享查询结果而设计的),而是为了防止同一个查询的返回执行
- 二级缓存清空的实质是对所查找的key对应的value置为null,并不是删除Map对象中存放的
Entry
- 使用了缓存要到数据库中查找的情况有两种:
- 缓存Map对象中没有要查找的
key
- 缓存中
key
值存在,但value
为 null
- 缓存Map对象中没有要查找的
开启内置二级缓存的步骤:
- 对实体进行序列化,被缓存的Bean要求实现
java.io.Serializable
接口 - 在映射文件中添加
<cache/>
标签
二级缓存使用Demo
// 接口定义
public interface IUsrImageDao {
Set<UsrImage> queryImageInfoByUserId(Integer uid);
}
UsrImage定义如下:
// 测试类
@Test
public void queryImageInfoByUserIdTest() throws IOException {
InputStream inputStream = Resources.getResourceAsStream("mybatis.xml");
SqlSessionFactory factory = new SqlSessionFactoryBuilder().build(inputStream);
// 创建第一个SqlSession并创建dao实例
SqlSession session = factory.openSession(true);
IUsrImageDao dao = session.getMapper(IUsrImageDao.class);
System.out.println("二级缓存测试\n第一次查询");
Set<UsrImage> images = dao.queryImageInfoByUserId(4);
for (UsrImage element : images) {
System.out.println(element.toString());
}
// 执行完成后关闭SqlSession
session.close();
System.out.println("第二次查询");
// 再次创建SqlSession对象并获取dao实例
session = factory.openSession(true);
dao = session.getMapper(IUsrImageDao.class);
// 再次执行一样的查询
images = dao.queryImageInfoByUserId(4);
for (UsrImage element : images) {
System.out.println(element.toString());
}
}
mapper.xml映射配置文件
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="dao.IUsrImageDao">
<cache eviction="LRU" size="512"/>
<!--<cache/>-->
<select id="queryImageInfoByUserId" resultMap="imageMapper" useCache="true">
select name,url,image.id,email,username
from image,account
where user_id = #{id} and user_id = account.id
</select>
<resultMap id="imageMapper" type="UsrImage">
<id column="id" property="image_id"/>
<result column="name" property="image_name"/>
<result column="url" property="image_url"/>
<result column="date" property="upload_date"/>
<association property="user" javaType="UserInfo">
<id column="id" property="id"/>
<result column="username" property="username"/>
<result column="pwd" property="pwd"/>
<result column="email" property="email"/>
</association>
</resultMap>
</mapper>
运行结果
二级缓存的使用原则
- 多个namespace不要操作同一张表
- 不要在关联关系表上增删改操作
- 查询多于修改时使用二级缓存
第三方缓存 —— ehcache
使用步骤
-
添加
ehcache
依赖的jar包或在Maven添加依赖<!-- https://mvnrepository.com/artifact/org.mybatis.caches/mybatis-ehcache -->
<dependency>
<groupId>org.mybatis.caches</groupId>
<artifactId>mybatis-ehcache</artifactId>
<version>1.1.0</version>
</dependency><!-- https://mvnrepository.com/artifact/net.sf.ehcache/ehcache-core -->
<dependency>
<groupId>net.sf.ehcache</groupId>
<artifactId>ehcache-core</artifactId>
<version>2.6.6</version>
</dependency> -
在mapper配置文件里的
<cache/>
标签里的 type 属性指明ehcache
的类型
<cache type = "org.mybatis.caches.ehcache.EhcacheCache" />
-
在
classpath
下添加配置文件,名称为ehcache.xml
<ehcache xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="../config/ehcache.xsd">
<diskStore path="java.io.tmpdir"/>
<defaultCache
maxElementsInMemory="10000"
eternal="false"
timeToIdleSeconds="120"
timeToLiveSeconds="120"
maxElementsOnDisk="10000000"
diskExpiryThreadIntervalSeconds="120"
memoryStoreEvictionPolicy="LRU">
<persistence strategy="localTempSwap"/>
</defaultCache>
</ehcache>
<cache />
语句的效果:
- 映射文件中所有的select语句将被缓存
- 执行映射文件中任意
insert
、update
、delete
语句缓存将被刷新 - 缓存采用LRU(最近最久未使用)算法回收
- 缓存不会被设定的时间所清空
- MyBatis的缓存是可读/可写的缓存,意味着对象检索不是共享的,而是可以安全地被调用者修改,而不会干扰其它线程所做的潜在修改
缓存的回收策略有:
- LRU
- FIFO
- SOFT -软引用(基于垃圾回收器状态和软引用规则的对象)
- WEAK-弱引用(强制性地移除基于垃圾收集器状态和弱引用规则的对象)
<!-- 创建1个FIFO缓存,每60s清空缓存,存储512个对象或列表引用,返回结果只读 -->
<cache eviction="FIFO" flushInterval="60000" size="512" readOnly="true"/>
一级缓存和二级缓存的不同点:
- 一级缓存的生命周期为SqlSession,一旦关闭SqlSession缓存就无效
- 二级缓存是与整个应用同步,一级缓存是同一个线程共享,二级缓存是多个线程共享
查询缓存的底层实现是Map,value里保存查询结果,key为查询依据;MyBatis中增删改操作(无论有没有提交)都会刷新一级缓存,把缓存清空。
借鉴网站:https://blog.csdn.net/itcrazy2016/article/details/73612444
https://blog.csdn.net/u011867674/article/details/78941848
https://blog.csdn.net/jianyuerensheng/article/details/50804360