整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

mybatis拦截器实现主键自动生成

mybatis拦截器实现主键自动生成

前阵子和朋友聊天,他说他们项目有个需求,要实现主键自动生成,不想每次新增的时候,都手动设置主键。于是我就问他,那你们数据库表设置主键自动递增不就得了。他的回答是他们项目目前的id都是采用雪花算法来生成,因此为了项目稳定性,不会切换id的生成方式。

朋友问我有没有什么实现思路,他们公司的orm框架是mybatis,我就建议他说,不然让你老大把mybatis切换成mybatis-plus。mybatis-plus就支持注解式的id自动生成,而且mybatis-plus只是对mybatis进行增强不做改变。朋友还是那句话,说为了项目稳定,之前项目组没有使用mybatis-plus的经验,贸然切换不知道会不会有什么坑。后面没招了,我就跟他说不然你用mybatis的拦截器实现一个吧。于是又有一篇吹水的创作题材出现。

前置知识

在介绍如何通过mybatis拦截器实现主键自动生成之前,我们先来梳理一些知识点

1、mybatis拦截器的作用

mybatis拦截器设计的初衷就是为了供用户在某些时候可以实现自己的逻辑而不必去动mybatis固有的逻辑

2、Interceptor拦截器

每个自定义拦截器都要实现

org.apache.ibatis.plugin.Interceptor

这个接口,并且自定义拦截器类上添加@Intercepts注解

3、拦截器能拦截哪些类型

Executor:拦截执行器的方法。
ParameterHandler:拦截参数的处理。
ResultHandler:拦截结果集的处理。
StatementHandler:拦截Sql语法构建的处理。

4、拦截的顺序

a、不同类型拦截器的执行顺序

Executor -> ParameterHandler -> StatementHandler -> ResultSetHandler

b、多个拦截器拦截同种类型同一个目标方法,执行顺序是后配置的拦截器先执行

比如在mybatis配置如下

	<plugins>
		<plugin interceptor="com.lybgeek.InterceptorA" />
		<plugin interceptor="com.lybgeek.InterceptorB" />
	</plugins>

则InterceptorB先执行。

如果是和spring做了集成,先注入spring ioc容器的拦截器,则后执行。比如有个mybatisConfig,里面有如下拦截器bean配置

 @Bean
    public InterceptorA interceptorA(){
        return new InterceptorA();
    }

    @Bean
    public InterceptorB interceptorB(){
        return new InterceptorB();
    }

则InterceptorB先执行。当然如果你是直接用@Component注解这形式,则可以配合@Order注解来控制加载顺序

5、拦截器注解介绍

@Intercepts:标识该类是一个拦截器

@Signature:指明自定义拦截器需要拦截哪一个类型,哪一个方法。
@Signature注解属性中的type表示对应可以拦截四种类型(Executor、ParameterHandler、ResultHandler、StatementHandler)中的一种;method表示对应类型(Executor、ParameterHandler、ResultHandler、StatementHandler)中的哪类方法;args表示对应method中的参数类型

6、拦截器方法介绍

a、 intercept方法

public Object intercept(Invocation invocation) throws Throwable

这个方法就是我们来执行我们自己想实现的业务逻辑,比如我们的主键自动生成逻辑就是在这边实现。

Invocation这个类中的成员属性target就是@Signature中的type;method就是@Signature中的method;args就是@Signature中的args参数类型的具体实例对象

b、 plugin方法

public Object plugin(Object target)

这个是用返回代理对象或者是原生代理对象,如果你要返回代理对象,则返回值可以设置为

Plugin.wrap(target, this);
this为拦截器

如果返回是代理对象,则会执行拦截器的业务逻辑,如果直接返回target,就是没有拦截器的业务逻辑。说白了就是告诉mybatis是不是要进行拦截,如果要拦截,就生成代理对象,不拦截是生成原生对象

c、 setProperties方法

public void setProperties(Properties properties)

用于在Mybatis配置文件中指定一些属性

主键自动生成思路

1、定义一个拦截器

主要拦截

 `Executor#update(MappedStatement ms, Object parameter)`} 

这个方法。mybatis的insert、update、delete都是通过这个方法,因此我们通过拦截这个这方法,来实现主键自动生成。其代码块如下

@Intercepts(value={@Signature(type=Executor.class,method="update",args={MappedStatement.class,Object.class})})
public class AutoIdInterceptor implements Interceptor {}

2、判断sql操作类型

Executor 提供的方法中,update 包含了 新增,修改和删除类型,无法直接区分,需要借助 MappedStatement 类的属性 SqlCommandType 来进行判断,该类包含了所有的操作类型

public enum SqlCommandType {
  UNKNOWN, INSERT, UPDATE, DELETE, SELECT, FLUSH;
}

当SqlCommandType类型是insert我们才进行主键自增操作

3、填充主键值

3.1、编写自动生成id注解

Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface AutoId {
    /**
     * 主键名
     * @return
     */
    String primaryKey();

    /**
     * 支持的主键算法类型
     * @return
     */
    IdType type() default IdType.SNOWFLAKE;

    enum IdType{
        SNOWFLAKE
    }
}

3.2、 雪花算法实现

我们可以直接拿hutool这个工具包提供的idUtil来直接实现算法。

引入

 <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
        </dependency>
Snowflake snowflake=IdUtil.createSnowflake(0,0);
long value=snowflake.nextId();

3.3、填充主键值

其实现核心是利用反射。其核心代码片段如下

   ReflectionUtils.doWithFields(entity.getClass(), field->{
                    ReflectionUtils.makeAccessible(field);
                    AutoId autoId=field.getAnnotation(AutoId.class);
                    if(!ObjectUtils.isEmpty(autoId) && (field.getType().isAssignableFrom(Long.class))){
                        switch (autoId.type()){
                            case SNOWFLAKE:
                                SnowFlakeAutoIdProcess snowFlakeAutoIdProcess=new SnowFlakeAutoIdProcess(field);
                                snowFlakeAutoIdProcess.setPrimaryKey(autoId.primaryKey());
                                finalIdProcesses.add(snowFlakeAutoIdProcess);
                                break;
                        }
                    }
                });
public class SnowFlakeAutoIdProcess extends BaseAutoIdProcess {

    private static Snowflake snowflake=IdUtil.createSnowflake(0,0);


    public SnowFlakeAutoIdProcess(Field field) {
        super(field);
    }

    @Override
    void setFieldValue(Object entity) throws Exception{
        long value=snowflake.nextId();
        field.set(entity,value);
    }
}

如果项目中的mapper.xml已经的insert语句已经含有id,比如

insert into sys_test( `id`,`type`, `url`,`menu_type`,`gmt_create`)values( #{id},#{type}, #{url},#{menuType},#{gmtCreate})

则只需到填充id值这一步。拦截器的任务就完成。如果mapper.xml的insert不含id,形如

insert into sys_test( `type`, `url`,`menu_type`,`gmt_create`)values( #{type}, #{url},#{menuType},#{gmtCreate})

则还需重写insert语句以及新增id参数

4、重写insert语句以及新增id参数(可选)

4.1 重写insert语句

方法一:
从 MappedStatement 对象中获取 SqlSource 对象,再从从 SqlSource 对象中获取获取 BoundSql 对象,通过 BoundSql#getSql 方法获取原始的sql,最后在原始sql的基础上追加id

方法二:

引入

<dependency>
			<groupId>com.alibaba</groupId>
			<artifactId>druid</artifactId>
			<version>${druid.version}</version>
		</dependency>

通过

com.alibaba.druid.sql.dialect.mysql.parser.MySqlStatementParser

获取相应的表名、需要insert的字段名。然后重新拼凑出新的insert语句

4.2 把新的sql重置给Invocation

其核心实现思路是创建一个新的MappedStatement,新的MappedStatement绑定新sql,再把新的MappedStatement赋值给Invocation的args[0],代码片段如下

 private void resetSql2Invocation(Invocation invocation, BoundSqlHelper boundSqlHelper,Object entity) throws SQLException {
        final Object[] args=invocation.getArgs();
        MappedStatement statement=(MappedStatement) args[0];
        MappedStatement newStatement=newMappedStatement(statement, new BoundSqlSqlSource(boundSqlHelper));
        MetaObject msObject=MetaObject.forObject(newStatement, new DefaultObjectFactory(), new DefaultObjectWrapperFactory(),new DefaultReflectorFactory());
        msObject.setValue("sqlSource.boundSqlHelper.boundSql.sql", boundSqlHelper.getSql());

            args[0]=newStatement;

    }

4.3 新增id参数

其核心是利用

org.apache.ibatis.mapping.ParameterMapping

核心代码片段如下

  private void setPrimaryKeyParaterMapping(String primaryKey) {
           ParameterMapping parameterMapping=new ParameterMapping.Builder(boundSqlHelper.getConfiguration(),primaryKey,boundSqlHelper.getTypeHandler()).build();
           boundSqlHelper.getBoundSql().getParameterMappings().add(parameterMapping);
       }

5、将mybatis拦截器注入到spring容器

可以直接在拦截器上加

@org.springframework.stereotype.Component

注解。也可以通过

 @Bean
    public AutoIdInterceptor autoIdInterceptor(){
        return new AutoIdInterceptor();
    }

6、在需要实现自增主键的实体字段上加如下注解

@AutoId(primaryKey="id")
	private Long id;

测试

1、对应的测试实体以及单元测试代码如下

@Data
public class TestDO implements Serializable {
	private static final long serialVersionUID=1L;

	@AutoId(primaryKey="id")
	private Long id;
	private Integer type;
	private String url;
	private Date gmtCreate;
	private String menuType;
}
@Autowired
    private TestService testService;

    @Test
    public void testAdd(){
        TestDO testDO=new TestDO();
        testDO.setType(1);
        testDO.setMenuType("1");
        testDO.setUrl("www.test.com");
        testDO.setGmtCreate(new Date());
        testService.save(testDO);
        testService.get(110L);
    }

    @Test
    public void testBatch(){
        List<TestDO> testDOList=new ArrayList<>();
        for (int i=0; i < 3; i++) {
            TestDO testDO=new TestDO();
            testDO.setType(i);
            testDO.setMenuType(i+"");
            testDO.setUrl("www.test"+i+".com");
            testDO.setGmtCreate(new Date());
            testDOList.add(testDO);
        }

        testService.saveBatch(testDOList);
    }

2、当mapper的insert语句中含有id,形如下

<insert id="save" parameterType="com.lybgeek.TestDO" useGeneratedKeys="true" keyProperty="id">
		insert into sys_test(`id`,`type`, `url`,`menu_type`,`gmt_create`)
		values( #{id},#{type}, #{url},#{menuType},#{gmtCreate})
	</insert>

以及批量插入sql

<insert id="saveBatch"  parameterType="java.util.List" useGeneratedKeys="false">
		insert into sys_test( `id`,`gmt_create`,`type`,`url`,`menu_type`)
		values
		<foreach collection="list" item="test" index="index" separator=",">
			( #{test.id},#{test.gmtCreate},#{test.type}, #{test.url},
			#{test.menuType})
		</foreach>
	</insert>

查看控制台sql打印语句

15:52:04 [main] DEBUG com.lybgeek.dao.TestDao.save -==>  Preparing: insert into sys_test(`id`,`type`, `url`,`menu_type`,`gmt_create`) values( ?,?, ?,?,? ) 
15:52:04 [main] DEBUG com.lybgeek.dao.TestDao.save -==> Parameters: 356829258376544258(Long), 1(Integer), www.test.com(String), 1(String), 2020-09-11 15:52:04.738(Timestamp)
15:52:04 [main] DEBUG com.nlybgeek.dao.TestDao.save - <==Updates: 1
15:52:04 [main] DEBUG c.n.lybgeek.dao.TestDao.saveBatch -==>  Preparing: insert into sys_test( `id`,`gmt_create`,`type`,`url`,`menu_type`) values ( ?,?,?, ?, ?) , ( ?,?,?, ?, ?) , ( ?,?,?, ?, ?) 
15:52:04 [main] DEBUG c.n.lybgeek.dao.TestDao.saveBatch -==> Parameters: 356829258896637961(Long), 2020-09-11 15:52:04.847(Timestamp), 0(Integer), www.test0.com(String), 0(String), 356829258896637960(Long), 2020-09-11 15:52:04.847(Timestamp), 1(Integer), www.test1.com(String), 1(String), 356829258896637962(Long), 2020-09-11 15:52:04.847(Timestamp), 2(Integer), www.test2.com(String), 2(String)
15:52:04 [main] DEBUG c.n.lybgeek.dao.TestDao.saveBatch - <==Updates: 3

查看数据库

3、当mapper的insert语句中不含id,形如下

<insert id="save" parameterType="com.lybgeek.TestDO" useGeneratedKeys="true" keyProperty="id">
		insert into sys_test(`type`, `url`,`menu_type`,`gmt_create`)
		values(#{type}, #{url},#{menuType},#{gmtCreate})
	</insert>

以及批量插入sql

<insert id="saveBatch"  parameterType="java.util.List" useGeneratedKeys="false">
		insert into sys_test(`gmt_create`,`type`,`url`,`menu_type`)
		values
		<foreach collection="list" item="test" index="index" separator=",">
			(#{test.gmtCreate},#{test.type}, #{test.url},
			#{test.menuType})
		</foreach>
	</insert>

查看控制台sql打印语句

15:59:46 [main] DEBUG com.lybgeek.dao.TestDao.save -==>  Preparing: insert into sys_test(`type`,`url`,`menu_type`,`gmt_create`,id) values (?,?,?,?,?) 
15:59:46 [main] DEBUG com.lybgeek.dao.TestDao.save -==> Parameters: 1(Integer), www.test.com(String), 1(String), 2020-09-11 15:59:46.741(Timestamp), 356831196144992264(Long)
15:59:46 [main] DEBUG com.lybgeek.dao.TestDao.save - <==Updates: 1
15:59:46 [main] DEBUG c.n.lybgeek.dao.TestDao.saveBatch -==>  Preparing: insert into sys_test(`gmt_create`,`type`,`url`,`menu_type`,id) values (?,?,?,?,?),(?,?,?,?,?),(?,?,?,?,?) 
15:59:46 [main] DEBUG c.n.lybgeek.dao.TestDao.saveBatch -==> Parameters: 2020-09-11 15:59:46.845(Timestamp), 0(Integer), www.test0.com(String), 0(String), 356831196635725829(Long), 2020-09-11 15:59:46.845(Timestamp), 1(Integer), www.test1.com(String), 1(String), 356831196635725828(Long), 2020-09-11 15:59:46.845(Timestamp), 2(Integer), www.test2.com(String), 2(String), 356831196635725830(Long)
15:59:46 [main] DEBUG c.n.lybgeek.dao.TestDao.saveBatch - <==Updates: 3

从控制台我们可以看出,当mapper.xml没有配置id字段时,则拦截器会自动帮我们追加id字段

查看数据库

总结

本文虽然是介绍mybatis拦截器实现主键自动生成,但文中更多讲解如何实现一个拦截器以及主键生成思路,并没把intercept实现主键方法贴出来。其原因主要是主键自动生成在mybatis-plus里面就有实现,其次是有思路后,大家就可以自己实现了。最后对具体实现感兴趣的朋友,可以查看文末中demo链接

参考文档

https://www.cnblogs.com/chenchen127/p/12111159.html
https://blog.csdn.net/hncaoyuqi/article/details/103187983
https://blog.csdn.net/zsj777/article/details/81986096

demo链接

https://github.com/lyb-geek/springboot-learning/tree/master/springboot-mybatis-autoId

. 登录页面的jsp

login.jsp

<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%
    String path=request.getContextPath();
    String basePath=request.getScheme() + "://" + request.getServerName() + ":" + request.getServerPort() + path + "/";
%>
<!DOCTYPE html PUBLIC "-//W3C//D HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
    <title>登录页面</title>
    <base href="<%=basePath %>">
    <link rel="stylesheet" type="text/css" href="js/jquery-easyui-1.4.1/themes/default/easyui.css">
    <link rel="stylesheet" type="text/css" href="js/jquery-easyui-1.4.1/themes/icon.css">
    <link rel="stylesheet" type="text/css" href="css/common.css">
    <script type="text/javascript" src="js/jquery3.4.1/jquery3.4.1.min.js"></script>
    <script type="text/javascript" src="js/jquery-easyui-1.4.1/jquery.min.js"></script>
    <script type="text/javascript" src="js/jquery-easyui-1.4.1/jquery.easyui.min.js"></script>
    <script type="text/javascript" src="commons/validate.js"></script>
    <script type="text/javascript" src="js/jquery-easyui-1.4.1/locale/easyui-lang-zh_CN.js"></script>
    <script type="text/javascript" src="js/common.js"></script>
</head>

<body>
<div id="login_frame">
    <img src="images/logo.png" class="logo">
    <form method="post" action="/login/login" onsubmit="return check()">
        <p><label class="label_input">用户名</label><input type="text" id="username" name="username" class="text_field"/>
        </p>
        <p><label class="label_input">密码</label><input type="password" id="password" name="password"
                                                       class="text_field"/></p>

        <div id="login_control">
            <input type="submit" id="btn_login" value="登录"/>
            <%--  <a id="forget_pwd" href="forget_pwd.html">忘记密码?</a>--%>
        </div>
    </form>
</div>

</body>
</html>
<script>
    function check() {
        var username=$("#username").val();
        var password=$("#password").val();
        if (username==="" || username===null) {
            alert("请输入用户名");
            return false;
        } else if (password==="" || password===null) {
            alert("请输入密码");
            return false;
        } else {
            return true;
        }
    }
</script>
<style>

    body {
        background-size: 100%;
        background-repeat: no-repeat;
    }

    #login_frame {
        width: 400px;
        height: 260px;
        padding: 13px;

        position: absolute;
        left: 50%;
        top: 50%;
        margin-left: -200px;
        margin-top: -200px;

        background-color: #bed2c7;

        border-radius: 10px;
        text-align: center;
    }

    form p > * {
        display: inline-block;
        vertical-align: middle;
    }

    #image_logo {
        margin-top: 22px;
    }

    .label_input {
        font-size: 14px;
        font-family: 宋体;

        width: 65px;
        height: 28px;
        line-height: 28px;
        text-align: center;

        color: white;
        background-color: #00303E;
        border-top-left-radius: 5px;
        border-bottom-left-radius: 5px;
    }

    .text_field {
        width: 278px;
        height: 28px;
        border-top-right-radius: 5px;
        border-bottom-right-radius: 5px;
        border: 0;
    }

    #btn_login {
        font-size: 14px;
        font-family: 宋体;

        width: 120px;
        height: 28px;
        line-height: 28px;
        text-align: center;

        color: white;
        background-color: #00303E;
        border-radius: 6px;
        border: 0;

        float: left;
    }

    #forget_pwd {
        font-size: 12px;
        color: white;
        text-decoration: none;
        position: relative;
        float: right;
        top: 5px;

    }

    #forget_pwd:hover {
        color: blue;
        text-decoration: underline;
    }

    #login_control {
        padding: 0 28px;
    }

    .logo {
        width: 40px;
        height: 35px;
        margin-top: -10px;
    }
</style>

2. 登录的拦截器

public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object o) throws Exception {
        // 获取请求的uri
        String uri=request.getRequestURI();
        // 除了login.jsp是可以公开访问的,其它的URL都没拦截
        if (uri.indexOf("/login") >=0) {
            return true;
        } else {
            // 获取session
            HttpSession session=request.getSession();
            UserPojo user=(UserPojo) session.getAttribute("USER_SESSION");
            // 判断session中是否有用户数据,如果有数据,则返回true。否则重定向到登录页面
            if (user !=null) {
                return true;
            } else {
                response.sendRedirect("/login/login");
                return false;
            }
        }
    }

    @Override
    public void postHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o,
                           ModelAndView modelAndView) throws Exception {

    }

    @Override
    public void afterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse,
                                Object o, Exception e) throws Exception {

    }
}

3. 登录的 Controller

小伙伴使用spring boot开发多年,但是对于过滤器和拦截器的主要区别依然傻傻分不清。今天就对这两个概念做一个全面的盘点。

定义与作用范围

  • 过滤器(Filter):过滤器是一种可以动态地拦截、处理和响应HTTP请求和响应的对象。它基于Servlet规范,可以拦截所有到达Web应用的请求,包括静态资源(如HTML、CSS、JavaScript文件等)和动态资源(如Servlet、JSP页面等)。过滤器主要用于请求的预处理和响应的后处理,如字符编码处理、URL访问记录等。
  • 拦截器(Interceptor):拦截器是Spring MVC框架中的一部分,它基于Spring框架的AOP(面向切面编程)思想。它主要拦截Controller层的方法调用,不会拦截对静态资源的请求。拦截器主要用于在请求处理的不同阶段(如请求进入控制器方法之前或之后)执行额外的逻辑操作,如权限检查、接口日志记录、异常处理等。

过滤器实例

定义过滤器:实现javax.servlet.Filter接口,并重写doFilter方法。

注册过滤器:通过@WebFilter注解自动注册,或者使用FilterRegistrationBean在Spring配置类中注册。

import org.springframework.stereotype.Component;
 
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
 
@Component
public class SimpleCORSFilter implements Filter {
 
    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
        HttpServletResponse response=(HttpServletResponse) res;
        HttpServletRequest request=(HttpServletRequest) req;
 
        response.setHeader("Access-Control-Allow-Origin", "*");
        response.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE");
        response.setHeader("Access-Control-Max-Age", "3600");
        response.setHeader("Access-Control-Allow-Headers", "x-requested-with, Content-Type");
 
        if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
            response.setStatus(HttpServletResponse.SC_OK);
        } else {
            chain.doFilter(req, res);
        }
    }
 
    @Override
    public void init(FilterConfig filterConfig) {}
 
    @Override
    public void destroy() {}
}

上面代码定义了一的跨域资源共享(CORS)过滤器,用于处理跨域请求。它设置了允许的源、方法和头部,并处理预检请求,这个在开发中经常用到。

拦截器实例

定义拦截器:实现HandlerInterceptor接口,并重写preHandle方法。

@Component  
public class AuthInterceptor implements HandlerInterceptor {  
    @Override  
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)  
            throws Exception {  
        // 获取用户信息,进行身份验证  
        // ...  
          
        // 如果用户未登录或权限不足,返回false并设置响应状态  
        if (!isUserAuthenticated(request)) {  
            response.setStatus(HttpServletResponse.FORBIDDEN);  
            return false;  
        }  
          
        // 用户已登录且权限足够,放行请求  
        return true;  
    }  
}

注册拦截器:在Spring配置类中注册拦截器,并指定拦截的路径和顺序。

@Configuration  
public class WebConfig implements WebMvcConfigurer {  
    @Autowired  
    private AuthInterceptor authInterceptor;  
      
    @Override  
    public void addInterceptors(InterceptorRegistry registry) {  
        registry.addInterceptor(authInterceptor).addPathPatterns("/**").excludePathPatterns("/login");  
    }  
}

上面代码主要实现一个权限检查拦截器,用于在请求进入控制器方法之前进行身份验证。

总结

作用范围

  • 过滤器拦截所有到达Web应用的请求(包括静态资源和动态资源)
  • 拦截器主要拦截Controller层的方法调用,不拦截静态资源

操作对象

  • 过滤器只能对请求(request)和响应(response)进行操作
  • 拦截器可以对请求(request)、响应(response)、处理器(handler)、模型视图(modelAndView)、异常(exception)等进行


每天一个小知识,每天进步一点点!!![加油][加油][加油]

喜欢这类文章,请关注、点赞、收藏、转发,谢谢!!!