Mybatis使用入门,这一篇就够了

mybatis中,封装了一个sqlsession 对象(里面封装有connection对象),由此对象来对数据库进行CRUD操作。

运行流程

mybatis有一个配置的xml,用于配置数据源、映射Mapping,xml的文件名可以任取,为了方便,我们还是起mybatis-config.xml

我们读取此配置的xml,获得一个sqlsession,之后由此对象类进行数据库的CRUD操作

Reader reader = Resources.getResourceAsReader("mybatis-config.xml");
SqlSessionFactory factory = new SqlSessionFactoryBuilder().build(reader);
SqlSession sqlSession = factory.openSession();

入门使用

1. 创建实体类和Dao类

2. 配置mybatis-config.xml文件,配置数据源

<?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>
    <!-- 引入外部资源文件-->
    <properties resource="jdbc.properties"/>

    <!-- 配置数据源环境 -->
    <environments default="development">
        <environment id="development">
            <!-- 数据库事务管理类型 -->
            <transactionManager type="JDBC"/>
            <!-- 数据源,type=pooled 说明是使用连接池方式,可以节省资源 -->
            <dataSource type="POOLED">
                <!-- 调用资源文件里的用户信息-->
                <property name="driver" value="${jdbc.driver}"/>
                <property name="url" value="${jdbc.url}"/>
                <property name="username" value="${jdbc.username}"/>
                <property name="password" value="${jdbc.password}"/>
            </dataSource>
        </environment>
    </environments>
</configuration>

3. 定义连接数据库工具,可以获得sqlsession对象

Dao类中每次进行CRUD操作,都要执行一次openSession方法来获得SqlSession对象,造成资源的浪费和代码的重复

所以,和之前的JdbcUtil工具类一样,我们也定义定义一个工具类MyBatisUtil,用来返回SQLSession对象

static SqlSessionFactory sqlSessionFactory = null;

static {
    try {
        // 加载mybatis配置文件,并创建SqlSessionFactory实例
        String resource = "mybatis-config.xml";
        InputStream inputStream = Resources.getResourceAsStream(resource);
        //这个build方法可以接受几种不同的参数,如Reader/InputSteam等
        sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
    } catch (IOException e) {

    }
}

public static SqlSession getSqlSession() {
    return sqlSessionFactory.openSession();
}

public static void closeSqlSession(SqlSession sqlSession){
    if (sqlSession != null) {
        sqlSession.close();
    }
}

4. sql语句写在mapper中

mapper文件放在了resources下面

《Mybatis使用入门,这一篇就够了》

Mybatis中,sql语句则是写在了xml文件中,这些xml文件也称为mapper映射文件

mapper标签如果带有xmln属性,IDEA会报解析xml错误,得把xmln属性删除

<?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">
<!--
namespace: 命名空间,用于标识每一个Mapper XML文件中的语句,预防在不同的Mapper XML文件中存在相同的语句ID
-->
<mapper namespace="employeeMapper">
    <!--
        resultType: 也称为自动映射,只有在表的列名与POJO类的属性完全一致时使用,会比较方便,全类名
    -->
    <select id="selectAll" resultType="com.wan.bean.Employee">
        select * from employee
    </select>
</mapper>

5. 在mybatis-config.xml文件中注册mapper

<?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>
    <!-- 省略数据源配置-->
    <mappers>
        <mapper resource="com/wan/mapping/employeeMapper.xml"/>
        <!--如果还有mapper,则继续添加 -->
    </mappers>
</configuration>

6. dao类通过sqlsession进行查询

SqlSession sqlSession = MybatisUtil.getSqlSession();
// 调用语句,如果有参数,传入参数 
//参数为命名空间namespace+id,执行xml中的sql语句
List<Employee> list = sqlSession.selectList("employeeMapper.selectAll");

PS:如果是插入、更新和删除操作,还需要提交操作,默认是不会自动提交的

sqlSession.commit();

补充

1.typeAliases标签

<select id="selectAll" resultType="com.wan.bean.Employee">
    select * from employee
</select>

resultType属性需要全类名,我们可以使用typeAliases标签来简化输入

typeAliases标签需要在mybatis-config.xml文件中进行设置

<?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>
    <properties resource="jdbc.properties"/>
    <!--指定一个bean包 -->
    <typeAliases>
        <package name="com.wan.bean"/>
    </typeAliases>
    <!--省略配置数据源等-->
</configuration>

之后我们的mapper文件中就可以这样写

<!--resultType就可以不用写全包名 -->
<select id="selectAll" resultType="Employee">
    select * from employee
</select>

我这里只介绍用法,详解请看下面的参考链接

参考 MyBatis 配置 typeAliases 详解

2.引入mapper的四种方法

1. 文件路径注册

<mappers>
    <mapper resource="com/wan/mapper/EmployeeMapper.xml" />
</mappers>

2. 包名扫描注册

<mappers>
    <package name="com.wan.mapper" />
</mappers>

使用这种,必须保证xxxMapper.java和xxxMapper.xml两者名字一模一样!而且是要在同一包里

3. 类名注册

<mappers>
    <mapper class="com.shizongger.chapter2.mapper.UserMapper" />
</mappers>

4. url注册

<mappers>
    <mapper url="file:/home/shizongger/workspace/Chapter3/src/com/shizongger/chapter2/mapper/RoleMapper.xml" />
</mappers>

参考:配置MyBatis时报错信息的解决方案

SQLSession方法说明

方法名说明
insert插入
delete删除
update更新
selectOne查找单行结果,返回一个Object
selectList查找多行结果,返回一个List

使用和之前一样,第一个参数传入一个namespce+id,就可以找到指定的mapper文件里面的sql语句,并执行

CRUD

查询

Employee中,属性名和表的列名对应

<select id="selectAll" resultType="Employee">
    select * from employee
</select>

如果属性和表的列名不一致,可以使用列名映射resultMap标签,(也就是自动转为别名)

<!--type也是需要全包名,由于之前定义的别名,所以就可以不写-->
<resultMap id="baseResultMap" type="Employee">
    <!--使用映射,把对应的列名映射为对应的属性 -->
    <id property="empno" column="EMPNO" />
    <result property="ename" column="ENAME"/>
    <result property="job" column="JOB"/>
    <result property="mgr" column="MGR"/>
    <result property="hiredate" column="HIREDATE"/>
    <result property="sal" column="SAL"/>
    <result property="comm" column="COMM"/>
    <result property="deptno" column="DEPTNO"/>
</resultMap>
<!--引用上面定义的resultMap-->
<select id="selectAll" resultMap="baseResultMap">
    select * from employee
</select>

带条件查询

<select id="selectById" parameterType="int" resultMap="baseResultMap">
    <!-- 如果参数类型是简单的基本或者包装类型,#{} 里面的可以任取,都是可以获得参数 -->
    select * from EMPLOYEE where EMPNO=#{id}
</select>

//使用
Employee e = sqlsession.selectOne("employeeMapper.selectById",7369);

上面的select语句相当于一个预编译语句

String s = "SELECT * FROM employee WHERE empno=?";
PreparedStatement ps = conn.prepareStatement(s);
ps.setInt(1,empno);

多条件查询

可以使用where标签,当然,之前的单条件也可以使用where标签,where标签好处是会自动删除多余的and

<select id="selectSelective" parameterType="Employee" resultMap="baseResultMap">
    select * from EMPLOYEE
    <where>
        <!--自动删除多余的and -->
        <!--#相当于从传入的bean对象(Employee)中通过getDeptno方法获得属性值 -->
        and deptno=#{deptno}
        and sal>=2000
    </where>
</select>

大小比较条件

条件中有大小比较,<号得通过CDATA存放条件

<select id="selectSelective" parameterType="Employee" resultMap="baseResultMap">
    select * from EMPLOYEE
    <where>
        <!--loSal为Employee的一个属性,#{loSal}相当于是通过Employee对象的get方法来获得loSal的属性值 -->
        and SAL>=#{loSal}
        <!--CDATA中的数据不会被解析器解析 -->
        <![CDATA[ and SAL<=#{hiSal} ]]>
    </where>
</select>

#与$区别:

${}用在我们能够确定值的地方,也就是我们程序员自己赋值的地方。
#{}一般用在用户输入值的地方!!

参考:
MyBatis中#{}和${}的不同和${}的妙用

模糊查询:

模糊查询中需要使用%等通配符,我们可以在xml中定义好,自动拼接通配符

<select id="selectSelective" parameterType="Employee" resultMap="baseResultMap">
    select * from EMPLOYEE
    <where>
        <if test="ename != null">
            <!--使用bind标签,设置格式,自动拼接通配符 -->
            <bind name="pattern" value="'%' + ename + '%'"/>
            and ENAME like #{pattern}
        </if>
    </where>
</select>

动态查询

Mybatis中提供了if标签用来实现动态查询,和JSTL标签库使用类似

<select id="selectSelective" parameterType="Employee" resultMap="baseResultMap">
    select * from EMPLOYEE
    <where>
        <!--#{ename}其实是通过Employee类中的get方法来获得对象的ename属性值 -->
        <if test="ename != null">
            and ename=#{ename}
        </if>
        <if test="job != null and job.trim().length>0">
            and JOB=#{job}
        </if>
        <if test="deptno != null">
            and DEPTNO=#{deptno}
        </if>
    </where>
</select>

插入

主键为序列

某个主键是由oracle中的序列生成的

<insert id="insert_1" parameterType="Employee">
    <!--
        keyProperty: 表示将从序列获得的值赋予实体的哪个属性
        order: 表示主键值生成的方式,可取值:BEFORE | AFTER
             由于不同的数据库对插入的数据时主键生成方式是不同,例如:
             mysql and ms server: 主键生成方式为后生成方式。
             oracle: 主键生成方式预生成.
    -->
    <!--调用数据库中的序列,并赋值给传入的Employee对象的empno属性 -->
    <selectKey keyProperty="empno" resultType="integer" order="BEFORE">
        select EMP_SEQ.nextval from dual
    </selectKey>
    <!--
        如果使用这种整表插入的方式,那当数据库表的某些列可以为空值时,我将要告诉底层的JDBC驱动如何处理空值的情况,这不是mybatis所需要的,
        而是底层有些JDBC驱动所需的特性,实际上就是让JDBC驱动去调用PrepareStatement.setNull()来设置空值
     -->
    <!--如果是常用的数据类型int,date等,jdbcType可以省略不写 -->
    insert into EMPLOYEE
    values (#{empno},#{ename},#{job},#{mgr,jdbcType=INTEGER},#{hiredate,jdbcType=DATE},#{sal,jdbcType=DOUBLE},#{comm,jdbcType=DOUBLE},#{deptno,jdbcType=INTEGER})

</insert>

复用sql语句

把insert要插入的列名和数值写在sql标签里,之后方便重用,之后重用的时候需要使用include子标签拼接sql语句

<!--insert into employee(ENAME,JOB..) values(xx,xx) -->
<!--(ENAME,JOB..) -->
<sql id="insert_set_column">
    <!-- suffixOverrides属性,会自动把多余的“,”删除 -->
    <trim prefix="(" suffix=")" suffixOverrides=",">
        empno,
        <if test="ename != null">ENAME,</if>
        <if test="job != null">JOB,</if>
        <if test="mgr != null">MGR,</if>
        <if test="hiredate != null">HIREDATE,</if>
        <if test="sal != null">SAL,</if>
        <if test="comm != null">COMM,</if>
        <if test="deptno != null">DEPTNO,</if>
    </trim>
</sql>
<!--(xx,xx,xx) -->
<sql id="insert_values">
    <trim prefix="values(" suffix=")" suffixOverrides=",">
        #{empno},
        <if test="ename != null">#{ename},</if>
        <if test="job != null">#{job},</if>
        <if test="mgr != null">#{mgr},</if>
        <if test="hiredate != null">#{hiredate},</if>
        <if test="sal != null">#{sal},</if>
        <if test="comm != null">#{comm},</if>
        <if test="deptno != null">#{deptno},</if>
    </trim>
</sql>

<insert id="insert_2" parameterType="Employee">
    <selectKey keyProperty="empno" resultType="integer" order="BEFORE">
        select EMP_SEQ.nextval from dual
    </selectKey>
    insert into EMPLOYEE 
    <!--拼接sql -->
    <include refid="insert_set_column"/> 
    <include refid="insert_values"/>
</insert>

更新

<update id="update_1" parameterType="Employee">
    update EMPLOYEE
    <set>
        <if test="ename != null and ename.trim().length>0">ENAME=#{ename},</if>
        <if test="job != null and job.trim().length>0">JOB=#{job},</if>
        <if test="mgr != null">MGR=#{mgr},</if>
        <if test="hiredate != null">HIREDATE=#{hiredate},</if>
        <if test="sal != null">SAL=#{sal},</if>
        <if test="comm != null">COMM=#{comm},</if>
        <if test="deptno != null">DEPTNO=#{deptno},</if>
    </set>
   <!-- <where>如果带多条件的更依然可以使<where>元素动态生成where子句</where> -->
   where EMPNO=#{empno}
</update>
<update id="update_2" parameterType="Employee">
    update EMPLOYEE
    <trim prefix="set" suffixOverrides=",">
        <if test="ename != null and ename.trim().length>0">ENAME=#{ename},</if>
        <if test="job != null and job.trim().length>0">JOB=#{job},</if>
        <if test="mgr != null">MGR=#{mgr},</if>
        <if test="hiredate != null">HIREDATE=#{hiredate},</if>
        <if test="sal != null">SAL=#{sal},</if>
        <if test="comm != null">COMM=#{comm},</if>
        <if test="deptno != null">DEPTNO=#{deptno},</if>
    </trim>
   <!-- <where>如果带多条件的更依然可以使<where>元素动态生成where子句</where> -->
   where EMPNO=#{empno}
</update>

删除

<delete id="delete" parameterType="Employee">
    delete EMPLOYEE EMPNO=#{empno}
    <!--条件多的话也可以使用<where>...</where> -->
</delete>

w3c select标签、delete标签等详解

高级使用

1.动态代理

我们之前,上面都是在Dao类中写上一段sqlsession.selectOne/selectList,还是比较麻烦

所以mybatis提供了一种简单的方法,使用动态代理(接口类)可以简化步骤

Mybatis中有这样的约定:

  1. 接口方法名与mapper中的id相同
  2. 接口方法参数与parameterType类型相同
  3. 接口方法的返回值类型与resultType类型相同

满足上面的条件,Mybatis就会将接口类中的方法和mapper中的sql语句一一对应起来,而不需要再次新建一个Dao,在Dao类里面编写方法

具体步骤:

1. 实体类编写

2. 新建接口类

如果方法的返回值为void,则mapper中就不需要定义resultType属性

如果方法返回值是List,mapper中的resultType为泛型T

package com.wan.mapping;

import com.wan.bean.Employee;

import java.util.List;

/**
 * @author StarsOne
 * @date Create in  2019/9/16 0016 20:38
 * @description
 */
public interface EmployeeMapper {
    List<Employee> selectAll();
}

2. 编写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="com.wan.mapping.EmployeeMapper">
    <!--特例:返回值为list,resultType=bean类-->
    <select id="selectAll" resultType="Employee" >
        select * from employee
    </select>
</mapper>

3. 注册mapper

这里我们由于使用了package注册mapper,一定保证xxmapper.java和xxmapper.xml两个名字相同,大小写都要一样

保证Mapper.xml和接口的那个Mapper在相同的包路径,在mybatis配置xml文件指定

<?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>
    <!--省略数据源配置 -->...
    <!-- 注册SQL映射文件,在这些文件中写SQL语句 -->
    <mappers>
        <!--指定整个包中的全部Mapper -->
        <package name="com.wan.mapper"/>
    </mappers>
</configuration>

4. 使用

使用还是和之前一样,获得SqlSession对象,此对象有个getMapper方法,把接口类传入,就可以回调接口的方法了

Reader reader = Resources.getResourceAsReader("mybatis-config.xml");
SqlSessionFactory factory = new SqlSessionFactoryBuilder().build(reader);
SqlSession sqlSession = factory.openSession();

EmployeeMapper mapper = sqlSession.getMapper(EmployeeMapper.class);
List<Employee> employees = mapper.selectAll();

接口类中的方法名与EmployeeMapper.xml中的对应

使用:

EmployeeMapper mapper = sqlsession.getMapper(EmployeeMapper.class);
mapper.selectById(7369);

2.遍历列表

Mybatis中提供了foreach标签,用于遍历

如果方法参数传入了一个List,可以使用此标签遍历,例子如下:

<!--相当于select * from employee where job in (...)) -->
<select id="selectByJobs" parameterType="list" resultMap="baseResultMap">
    select * from EMPLOYEE
    <where>
        <foreach item="job" collection="list" open="JOB IN(" close=")" separator=",">
            #{job}
        </foreach>
    </where>
</select>

foreach标签的属性主要有 item,index,collection,open,separator,close,使用和JSTL标签里面的foreach标签差不多

属性名说明
item表示集合中每一个元素进行迭代时的别名
index指定一个名字,用于表示在迭代过程中,每次迭代到的位置,
open表示该语句以什么开始,
separator表示在每次进行迭代之间以什么符号作为分隔 符,
close表示以什么结束。

关键属性:collection

  1. 如果传入的是单参数且参数类型是一个List的时候,collection属性值为list
  2. 如果传入的是单参数且参数类型是一个array数组的时候,collection的属性值为array
  3. 如果传入的参数是多个的时候,我们就需要把它们封装成一个Map了,当然单参数也可以封装成map,map的key就是参数名,所以这个时候collection属性值就是传入的List或array对象在自己封装的map里面的key

参考:mybatis 中 foreach collection的三种用法

3.考虑线程安全

使用ThreadLocal对象,保证每个线程取出的SqlSession是同一个对象

方法说明
void set(Object value)设置当前线程的线程局部变量的值。
public Object get()该方法返回当前线程所对应的线程局部变量。
public void remove()将当前线程局部变量的值删除,目的是为了减少内存的占用,该方法是JDK 5.0新增的方法。
protected Object initialValue()返回该线程局部变量的初始值,该方法是一个protected的方法,显然是为了让子类覆盖而设计的。
static ThreadLocal<SqlSession> threadLocal = new ThreadLocal<SqlSession>();
//设置
threadLocal.set(sqlsession);
//取出
SqlSession s = threadLocal.get();

嵌套查询

<!-- 结果集映射: 列《》属性名 -->
<resultMap id="baseResultMap" type="Employee">
    <!-- 专门映射主键列 -->
    <id property="empno" column="EMPNO" />
    <result property="ename" column="ENAME"/>
    <result property="job" column="JOB"/>
    <result property="mgr" column="MGR"/>
    <result property="hiredate" column="HIREDATE"/>
    <result property="sal" column="SAL"/>
    <result property="comm" column="COMM"/>
    <result property="deptno" column="DEPTNO"/>
</resultMap>

<!-- 扩展另一个结果映射 -->
<resultMap id="extendBaseResultMap" type="Employee" extends="baseResultMap">
    <association property="department" javaType="Department">
        <!-- 关联的嵌套结果 -->
        <id property="deptno" column="DEPTNO"/>
        <result property="dname" column="DNAME"/>
        <result property="location" column="LOC"/>
    </association>
</resultMap>


<!--
    1.嵌套结果(推荐使用)
    优点:性能好,一条语句把所有实体的数据完全查询出来。
    缺点:对SQL编写的要求高了,因为涉及多表连接查询
-->
<select id="selectById" resultMap="extendBaseResultMap" parameterType="int">
    select e.EMPNO,
          e.ENAME,
          e.JOB,
          e.MGR,
          e.HIREDATE,
          e.SAL,
          e.COMM,
          d.DEPTNO,
          d.DNAME,
          d.LOC
    from EMPLOYEE E
      inner join DEPARTMENT D
          on E.DEPTNO = D.DEPTNO
     where E.EMPNO=#{id}
</select>

<!--
    2. 嵌套查询
    优点:编写SQL简单,无需做多表的连接查询;关联的实体通过单独的SQL语句查询并单独封装。
    缺点:执行了N+1条件语句。性能差
-->
<resultMap id="extendBaseResultMap_2" type="Employee" extends="baseResultMap">
    <association property="department" column="DEPTNO" select="selectDepartmentById" />
</resultMap>

<select id="selectDepartmentById" parameterType="int" resultType="Department">
    select deptno,
           dname,
           loc as location
    from DEPARTMENT where DEPTNO=#{id}
</select>

<select id="selectById_2" resultMap="extendBaseResultMap_2" parameterType="int">
    select e.EMPNO,
          e.ENAME,
          e.JOB,
          e.MGR,
          e.HIREDATE,
          e.SAL,
          e.COMM,
          e.DEPTNO
    from EMPLOYEE E
     where E.EMPNO=#{id}
      <!-- or e.empno=7902 or e.empno=7844 -->
</select>

分页查询

分页的话,像以前那样使用三层嵌套查询也可以实现。

不过,有开发者为Mybatis开了个一个插件PageHelper,可以用来更为简单地使用分页查询

1.添加jar包

<dependency>
    <groupId>com.github.pagehelper</groupId>
    <artifactId>pagehelper</artifactId>
    <!--自动下载最新版本 -->
    <version>REALSE</version>
</dependency>

2.配置拦截器插件

<!-- 
    plugins在配置文件中的位置必须符合要求,否则会报错,顺序如下:
    properties?, settings?, 
    typeAliases?, typeHandlers?, 
    objectFactory?,objectWrapperFactory?, 
    plugins?, 
    environments?, databaseIdProvider?, mappers?
-->
<plugins>
    <!-- com.github.pagehelper为PageHelper类所在包名 -->
    <plugin interceptor="com.github.pagehelper.PageInterceptor">
        <!-- 使用下面的方式配置参数,后面会有所有的参数介绍 -->
        <property name="param1" value="value1"/>
    </plugin>
</plugins>

3.代码使用

只有在查询之前调用过startPage或者是offsetPage方法,后面的查询出来的List结果就会进行分页查询

下面的两个都是查询第一页,每一页有10条数据

//第二种,Mapper接口方式的调用,推荐这种使用方式。

PageHelper.startPage(1, 10);
List<Employee> employees = employeeMapper.selectAll();

//第三种,Mapper接口方式的调用,推荐这种使用方式。
PageHelper.offsetPage(1, 10);
List<Employee> employees = employeeMapper.selectAll();

这里提一下,这个插件还带有一个PageInfo类,里面有可以记录各种信息

刚开始,我以为是和我之前自己封装的Page一样,详情请看Jsp学习笔记(4)——分页查询

但是,其实不一样的,这个PageInfo就是一个封装而已,只是用来存放数据而已,里面有各种信息

属性说明
pageNum当前页号(第几页)
pageSize每页的显示的数据个数
size当前页的显示的数据个数
startRow当前页面第一个元素在数据库中的行号
endRow当前页面最后一个元素在数据库中的行号
pages总页数
prePage上一页的页号
nextPage下一页页号
isFirstPage是否为第一页
isLastPage是否为最后一页
hasPreviousPage是否有前一页
hasNextPage是否有下一页
navigatePages导航页码数
navigatepageNums所有导航页号
navigatePages导航条上的第一页
navigateFirstPage导航条上的第一页
navigateLastPage导航条上的最后一页

有个getTotal方法,可以获得查询结果的总记录数

PageHelper.startPage(1, 10);
List<Employee> employees = mapper.selectAll();
PageInfo<Employee> pageInfo = new PageInfo<>(employees);
    原文作者:Stars-one
    原文地址: https://www.cnblogs.com/kexing/p/11537439.html
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞