spring mvc集成shiro权限控制

一、概述

          在上一篇spring mvc集成mybatis进行数据库访问中,用户登录验证是直接将数据库存放的密码和登录传到后台的密码进行对比,而实际情况下,认证和授权等权限控制比这复杂得多。Apache Shiro是一个功能强大,使用简单的Java安全框架,它为开发人员提供一个直观而全面的认证,授权,加密及会话管理的解决方案。按照一贯的写作手法,这里仅讲解集成,不对shiro做过多介绍,对shiro感兴趣的可以参考官网:http://shiro.apache.org/

二、操作步骤

1、添加pom依赖

<!--shiro-->
    <dependency>
      <groupId>org.apache.shiro</groupId>
      <artifactId>shiro-spring</artifactId>
    </dependency>
    <dependency>
      <groupId>org.apache.shiro</groupId>
      <artifactId>shiro-web</artifactId>
    </dependency>

2、在web.xml中添加shiro过滤器

<!--过滤器-->
  <!--shiro filter 必须放在其他filter前面-->
  <filter>
    <filter-name>myShiroFilter</filter-name>
    <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
    <init-param>
      <param-name>targetFilterLifecycle</param-name>
      <param-value>true</param-value>
    </init-param>
  </filter>
  <filter-mapping>
    <filter-name>myShiroFilter</filter-name>
    <url-pattern>/*</url-pattern>
  </filter-mapping>

3、在resources目录下添加applicationContext-security.xml

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.0.xsd">

    <!-- 配置shrio的验证 -->
    <bean id="shiroRealm" class="com.xxx.demo.security.ShiroRealm">
        <property name="userServiceImpl" ref="userServiceImpl"/>
    </bean>

    <!-- 配置shrio的验证管理 -->
    <bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager">
        <property name="realms">
            <list>
                <ref bean="shiroRealm"/>
            </list>
        </property>
    </bean>

    <!-- 配置shrio的过滤功能,通过javaconfig实现,不放在xml中配置
    <bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean"></bean>
    -->

    <!-- 保证实现了Shiro内部lifecycle函数的bean执行 -->
    <bean id="lifecycleBeanPostProcessor" class="org.apache.shiro.spring.LifecycleBeanPostProcessor"/>

</beans>

      ShiroRealm:shiro定义了认证和授权的一套流程,但是这个认证信息和授权信息如何取得则由用户自己决定,可以放在数据、可以通过接口获取,可以放在任何地方。shiroRealm就是用于认证和授权时,定义如何获取认证信息和授权信息的,用户需要重写里面的doGetAuthenticationInfo和doGetAuthorizationInfo两个方法。
      安全管理器;即所有与安全有关的操作都会与SecurityManager交互;且它管理着所有Subject(用户信息的一个集合);可以看出它是Shiro的核心,它负责与后边介绍的其他组件进行交互。定义安全管理器时,会将ShrioRealm注入进去。
        ShrioFilter:shiro过滤的工厂对象,负责定义登录页面,登录成功后的页面及过滤链等等,他告诉shiro哪些访问是需要认证的,哪些又是需要授权的。过滤链如果放在xml中配置,不方便进行动态改变,所以我们将其通过java config进行配置,从数据库中读取过滤链,这个稍候会讲到。

4、通过Java config配置ShiroFilter

新建一个ShiroConfig配置类,如图所示:

package com.xxx.demo.security;

import com.xxx.demo.dataaccess.api.SysAccessPermissionService;
import com.xxx.demo.dataaccess.model.SysAccessPermissionTest;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.apache.shiro.mgt.SecurityManager;

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
 * shiro filter放在Java代码里配置,方便从数据库动态加载配置
 * Created by gameloft9 on 2017/12/4.
 */
@Configuration
@Slf4j
public class ShiroConfig {

    @Autowired
    SysAccessPermissionService sysAccessPermissionServiceImpl;

    @Autowired
    SecurityManager securityManager;

    /**
     * 配置FilterFactoryBean
     * */
    @Bean(name = "myShiroFilter")
    public ShiroFilterFactoryBean myShiroFilter() {
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        //设置 SecurityManager
        shiroFilterFactoryBean.setSecurityManager(securityManager);
        //设置登录链接
        shiroFilterFactoryBean.setLoginUrl("/index");
        // 登录成功后要跳转的链接
        shiroFilterFactoryBean.setSuccessUrl("/home");
        // 未授权跳转链接;
        shiroFilterFactoryBean.setUnauthorizedUrl("/403");

        //拦截链配置
        Map<String, String> filterChainDefinitionMap = constructFilterChainDefinitionMap();
        shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
        log.info("ShiroFilterFactoryBean注入成功!");
        return shiroFilterFactoryBean;
    }

    /**
     * 构造shiro过滤链配置
     * */
    private Map<String,String> constructFilterChainDefinitionMap(){
        Map<String, String> filterChainDefinitionMap = new LinkedHashMap<String, String>();
        //动态加载url权限配置,从数据库获取
        List<SysAccessPermissionTest> list = sysAccessPermissionServiceImpl.getAll();
        for (SysAccessPermissionTest item : list) {
            filterChainDefinitionMap.put(item.getUrl(),item.getRoles());
        }

        return filterChainDefinitionMap;
    }

}
定义ShrioFilter时需要注入sercurityManager,sercurityManager已经在applicationContext-sercurity.xml中配置好了,这里直接通过@Autowired注入进来并set到ShiroFilter。
开始是通用的一些配置,比如登录链接、登录成功后的链接以及未授权的链接。重点是过滤链的配置,这里将过滤链存在了数据库中,然后通过mybatis进行获取。

过滤链表结构

CREATE TABLE SYS_ACCESS_PERMISSION_TEST
(
  ID           VARCHAR2(32)                    NOT NULL,
  URL          VARCHAR2(50),
  ROLES        VARCHAR2(255),
  SORT         NUMBER,
  IS_DELETED   VARCHAR2(1)                     DEFAULT 0,
  CREATE_USER  VARCHAR2(50),
  CREATE_TIME  TIMESTAMP(6)                    DEFAULT SYSDATE,
  UPDATE_USER  VARCHAR2(50),
  UPDATE_TIME  TIMESTAMP(6)                    DEFAULT SYSDATE
)

ALTER TABLE SYS_ACCESS_PERMISSION_TEST ADD CONSTRAINT PK_PERMISSION PRIMARY KEY (ID)

COMMENT ON TABLE SYS_ACCESS_PERMISSION_TEST IS '访问权限表';

COMMENT ON COLUMN SYS_ACCESS_PERMISSION_TEST.URL IS '访问链接';

COMMENT ON COLUMN SYS_ACCESS_PERMISSION_TEST.ROLES IS '角色列表用,分割';

COMMENT ON COLUMN SYS_ACCESS_PERMISSION_TEST.SORT IS '排序号';

COMMENT ON COLUMN SYS_ACCESS_PERMISSION_TEST.IS_DELETED IS '是否删除';

COMMENT ON COLUMN SYS_ACCESS_PERMISSION_TEST.CREATE_USER IS '创建用户';

COMMENT ON COLUMN SYS_ACCESS_PERMISSION_TEST.CREATE_TIME IS '创建时间';

COMMENT ON COLUMN SYS_ACCESS_PERMISSION_TEST.UPDATE_USER IS '更新用户';

COMMENT ON COLUMN SYS_ACCESS_PERMISSION_TEST.UPDATE_TIME IS '更新时间';

最重要的就是url、roles和sort三个字段,shiro就是通过这三个东西配置过滤链。下面是数据库中的数据:
shiro过滤链匹配会根据顺序来,因此会有一个sort字段,查询的时候要根据sort进行一个升序排序。

SysAccessPermissionService

package com.xxx.demo.dataaccess.impl;

import com.xxx.demo.dataaccess.api.SysAccessPermissionService;
import com.xxx.demo.dataaccess.dao.SysAccessPermissionTestMapper;
import com.xxx.demo.dataaccess.model.SysAccessPermissionTest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;

/**
 * Created by gameloft9 on 2017/12/4.
 */
@Slf4j
@Service
public class SysAccessPermissionServiceImpl implements SysAccessPermissionService{

    @Autowired
    SysAccessPermissionTestMapper sysAccessPermissionTestDao;

    public List<SysAccessPermissionTest> getAll(){
        return sysAccessPermissionTestDao.selectAll();
    }


}

这个是纯mybatis的数据库访问了,就不多讲了。另外,如果动态更改了权限,比如通过页面配置新增或删除了权限,需要清空原有的过滤链配置,并重新设置,这里新增了一个专门处理类:
package com.xxx.demo.security;

import com.xxx.demo.dataaccess.api.SysAccessPermissionService;
import com.xxx.demo.dataaccess.model.SysAccessPermissionTest;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.filter.mgt.DefaultFilterChainManager;
import org.apache.shiro.web.filter.mgt.PathMatchingFilterChainResolver;
import org.apache.shiro.web.servlet.AbstractShiroFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
 * shiro 动态权限配置相关服务
 * @author gameloft9
 */
@Service
@Slf4j
public class ShiroConfigService {

    @Autowired
    ShiroFilterFactoryBean shiroFilterFactoryBean;

    @Autowired
    SysAccessPermissionService sysAccessPermissionServiceImpl;

    /**
     * 从数据库加载权限列表
     */
    public Map<String, String> loadFilterChainDefinitions() {
        Map<String, String> filterChainDefinitionMap = new LinkedHashMap<String, String>();
        List<SysAccessPermissionTest> list = sysAccessPermissionServiceImpl.getAll();
        for (SysAccessPermissionTest item : list) {
            filterChainDefinitionMap.put(item.getUrl(), item.getRoles());
        }
        return filterChainDefinitionMap;
    }

    /**
     * 更新权限
     */
    public void updatePermission() {
        synchronized (shiroFilterFactoryBean) {
            AbstractShiroFilter shiroFilter = null;
            try {
                shiroFilter = (AbstractShiroFilter) shiroFilterFactoryBean
                        .getObject();
            } catch (Exception e) {
                throw new RuntimeException(
                        "get ShiroFilter from shiroFilterFactoryBean error!");
            }

            PathMatchingFilterChainResolver filterChainResolver = (PathMatchingFilterChainResolver) shiroFilter
                    .getFilterChainResolver();
            DefaultFilterChainManager manager = (DefaultFilterChainManager) filterChainResolver
                    .getFilterChainManager();

            // 清空老的权限控制
            manager.getFilterChains().clear();
            shiroFilterFactoryBean.getFilterChainDefinitionMap().clear();
            shiroFilterFactoryBean.setFilterChainDefinitionMap(loadFilterChainDefinitions());

            // 重新构建生成
            Map<String, String> chains = shiroFilterFactoryBean.getFilterChainDefinitionMap();
            for (Map.Entry<String, String> entry : chains.entrySet()) {
                String url = entry.getKey();
                String chainDefinition = entry.getValue().trim().replace(" ", "");
                manager.createChain(url, chainDefinition);
            }
            log.info("更新权限成功!!");
        }
    }
}

5、自定义ShiroRealm

用户通过subject.login()进行登录时,会自动转到ShiroRealm中的doGetAuthenticationInfo方法,从中获取用户的认证信息,并同登录提供的认证信息进行对比,以此来判断是否认证通过。授权则是根据过滤链的配置,对需要授权的访问链接,通过doGetAuthorizationInfo方法,获取用户授权信息,并进行授权验证。
package com.xxx.demo.security;

import com.xxx.demo.dataaccess.api.UserService;
import com.xxx.demo.dataaccess.model.UserTest;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.*;
import org.apache.shiro.authz.AuthorizationException;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;

import java.util.List;

/**
 * 认证授权。
 * @author gameloft9
 */
@Slf4j
@Data
public class ShiroRealm extends AuthorizingRealm {

	/**
	 * 通过setter注入,这里没有通过@Autowired注入
	 * */
	private UserService userServiceImpl;

	/**
	 * 获取授权信息方法,返回用户角色信息
	 * */
	@Override
	protected AuthorizationInfo doGetAuthorizationInfo(
			PrincipalCollection principals) {
		if (principals == null) {
			throw new AuthorizationException("PrincipalCollection method argument cannot be null.");
		}

		UserTest user = (UserTest) principals.getPrimaryPrincipal();
		SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
		if (user != null) {//获取用户角色信息
			List<String> roles = userServiceImpl.getRoleNames(user.getId());
			info.addRoles(roles);
		} else {
			SecurityUtils.getSubject().logout();
		}
		return info;
	}

	/**
	 * 重写回调认证方法,subject.login()调用后回调此方法,获取认证信息。
	 * 如果是与第三方用户系统集成,可在此处进行身份认证,成功后可构造一个同登录token一致的认证信息。
	 * 或者干脆跳过shiro的认证,自己实现认证逻辑,成功后将用户信息放入session、cookie.
	 * */
	@Override
	protected AuthenticationInfo doGetAuthenticationInfo(
			AuthenticationToken authcToken) throws AuthenticationException {
		UsernamePasswordToken token = (UsernamePasswordToken) authcToken;
		UserTest user = userServiceImpl.getByLoginName(token.getUsername());

		if (user == null) {//用户不存在
			throw new UnknownAccountException();
		}

		//构造一个用户认证信息并返回,后面会通过这个和token的pwd进行对比。
		return new SimpleAuthenticationInfo(user,user.getPassword(),user.getRealName());
	}
}

在doGetAuthenticationInfo中,我们从数据库获取用户信息(主要是密码),然后构造一个用户认证信息并返回,之后shiro就会进行认证。相应的登录代码如下:

LoginController

 /**
     * 登录请求
     */
    @RequestMapping(value = "/login", method = RequestMethod.POST)
    public String login(Model model, String name, String pwd) {
        if(StringUtils.isBlank(name)||StringUtils.isBlank(pwd)){
           log.info("用户名和密码为空");
            model.addAttribute("errInfo", "用户名或密码不能为空!");
            return "login";
        }

        //当前用户
        Subject currentUser = SecurityUtils.getSubject();
        //获取基于用户名和密码的令牌(生产环境下密码需要转换为加密后的密码)
        UsernamePasswordToken token = new UsernamePasswordToken(name, pwd);
        try {
            //提交认证,会调用Realm的doGetAuthenticationInfo,进行认证
            currentUser.login(token);
        } catch(UnknownAccountException e){
            log.info("用户不存在");
            model.addAttribute("errInfo", "用户不存在!");
            return "login";
        }catch(IncorrectCredentialsException e){
            log.info("用户名或密码错误");
            model.addAttribute("errInfo", "用户名或密码错误!");
            return "login";
        }catch (AuthenticationException e) {
            log.error("认证异常",e);
            model.addAttribute("errInfo", "认证发生异常!请联系管理员。");
            return "login";
        }

        //验证是否通过
        if(currentUser.isAuthenticated()){
            //还可以把用户信息放入session中
            log.info("验证成功!");
            model.addAttribute("name", name);
            return "home";
        }

        log.info("用户名密码错误,name:{},pwd:{}",name,pwd);
        model.addAttribute("errInfo", "用户名或密码错误!");
        return "login";
    }

在登录代码中,我们构造了一个用户名密码的token,并传递给了subject.login(),shiro就把这个认证信息和后面获取到的认证信息进行对比。

授权认证时,我们在doGetAuthorizationInfo中,从数据库获取用户的角色列表,并以此构造一个授权信息返回,shiro就把这个授权信息和过滤链中的授权信息进行对比。角色表和用户角色表结构如下:

角色表

CREATE TABLE SYS_ROLE_TEST
(
  ID           VARCHAR2(32)                      NOT NULL,
  ROLE_NAME    VARCHAR2(50),
  IS_SUPER     VARCHAR2(1)                        DEFAULT 0,
  IS_DELETED   VARCHAR2(1)                        DEFAULT 0,
  CREATE_USER  VARCHAR2(50),
  CREATE_TIME  TIMESTAMP(6)                     DEFAULT SYSDATE,
  UPDATE_USER  VARCHAR2(50),
  UPDATE_TIME  TIMESTAMP(6)                     DEFAULT SYSDATE
)

ALTER TABLE SYS_ROLE_TEST ADD CONSTRAINT PK_ROLE PRIMARY KEY (ID)

COMMENT ON TABLE SYS_ROLE IS '角色表';

COMMENT ON COLUMN SYS_ROLE.ROLE_NAME IS '角色名';

COMMENT ON COLUMN SYS_ROLE.IS_SUPER IS '是否是超级管理员';

COMMENT ON COLUMN SYS_ROLE.IS_DELETED IS '是否删除';

COMMENT ON COLUMN SYS_ROLE.CREATE_USER IS '创建用户';

COMMENT ON COLUMN SYS_ROLE.CREATE_TIME IS '创建时间';

COMMENT ON COLUMN SYS_ROLE.UPDATE_USER IS '更新用户';

COMMENT ON COLUMN SYS_ROLE.UPDATE_TIME IS '更新时间';

数据库中数据


用户角色表

CREATE TABLE SYS_USER_ROLE_TEST
(
  ID       VARCHAR2(32)                    NOT NULL,
  USER_ID  VARCHAR2(32),
  ROLE_ID  VARCHAR2(32)
)

COMMENT ON TABLE SYS_USER_ROLE_TEST IS '用户角色表';
COMMENT ON COLUMN SYS_USER_ROLE_TEST.ID IS '主键';
COMMENT ON COLUMN SYS_USER_ROLE_TEST.USER_ID IS '用户ID';
COMMENT ON COLUMN SYS_USER_ROLE_TEST.ROLE_ID IS '角色ID';

ALTER TABLE SYS_USER_ROLE_TEST ADD CONSTRAINT PK_USER_ROLE PRIMARY KEY (ID)

数据库中数据



用户表结构请参考上一篇文章,这里仅给出表数据:

为了不增加复杂度,密码存的是明文,可以根据需要对明文进行加密存储,这个shiro也有提供,可自行百度。另外数据库访问相关的代码,这里就不贴了,可以去demo里面看。最后贴2个完整的controller。

LoginController

package com.xxx.demo.controllers;

import com.xxx.demo.dataaccess.api.UserService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.IncorrectCredentialsException;
import org.apache.shiro.authc.UnknownAccountException;
import org.apache.shiro.authc.UsernamePasswordToken;
import org.apache.shiro.subject.Subject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;

import javax.servlet.http.HttpServletResponse;

/**
 * Created by gameloft9 on 2017/11/27.
 */
@Slf4j
@Controller
public class LoginController {

   @Autowired
    UserService userServiceImpl;

    /**
     * 登录页面入口
     */
    @RequestMapping(value = "/index", method = RequestMethod.GET)
    public String index(Model model, HttpServletResponse response) {
        log.info("进入登录页面");
        return "login";
    }

    /**
     * 登录请求
     */
    @RequestMapping(value = "/login", method = RequestMethod.POST)
    public String login(Model model, String name, String pwd) {
        if(StringUtils.isBlank(name)||StringUtils.isBlank(pwd)){
           log.info("用户名和密码为空");
            model.addAttribute("errInfo", "用户名或密码不能为空!");
            return "login";
        }

        //当前用户
        Subject currentUser = SecurityUtils.getSubject();
        //获取基于用户名和密码的令牌(生产环境下密码需要转换为加密后的密码)
        UsernamePasswordToken token = new UsernamePasswordToken(name, pwd);
        try {
            //提交认证,会调用Realm的doGetAuthenticationInfo,进行认证
            currentUser.login(token);
        } catch(UnknownAccountException e){
            log.info("用户不存在");
            model.addAttribute("errInfo", "用户不存在!");
            return "login";
        }catch(IncorrectCredentialsException e){
            log.info("用户名或密码错误");
            model.addAttribute("errInfo", "用户名或密码错误!");
            return "login";
        }catch (AuthenticationException e) {
            log.error("认证异常",e);
            model.addAttribute("errInfo", "认证发生异常!请联系管理员。");
            return "login";
        }

        //验证是否通过
        if(currentUser.isAuthenticated()){
            //还可以把用户信息放入session中
            log.info("验证成功!");
            model.addAttribute("name", name);
            return "home";
        }

        log.info("用户名密码错误,name:{},pwd:{}",name,pwd);
        model.addAttribute("errInfo", "用户名或密码错误!");
        return "login";
    }

    /**
     * 登出
     * */
    @RequestMapping(value = "/logout.do", method = RequestMethod.GET)
    public String logout(){
        try{
            SecurityUtils.getSubject().logout();
        }catch (Exception e){
            log.info("登出异常",e);
        }

        log.info("登出成功");
        return "login";
    }

    /**
     * 未授权
     * */
    @RequestMapping(value = "/403", method = RequestMethod.GET)
    public String unauthorization(){
        return "403";
    }
}

SysRoleController

package com.xxx.demo.controllers;

import com.xxx.demo.dataaccess.api.SysRoleService;
import com.xxx.demo.dataaccess.model.SysRoleTest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;

import java.util.List;

/**
 * Created by gameloft9 on 2017/12/4.
 */
@Controller
@Slf4j
@RequestMapping("/role")
public class SysRoleController {

    @Autowired
    SysRoleService sysRoleServiceImpl;

    /**
     * 获取所有角色列表
     * */
    @RequestMapping(value = "/roleList",method = RequestMethod.GET)
    @ResponseBody
    public String getRoleList(){
        return sysRoleServiceImpl.getAll().toString();
    }
}

三、运行结果

1、不存在的用户

2、密码错误

3、登录成功

4、在浏览器输入localhost:8080/demo/role/roleList访问角色列表

5、修改访问权限表,将/role/*角色权限从admin修改成别的


然后重起应用,登录后输入localhost:8080/demo/role/roleList访问角色列表

可以看到,未授权的访问转向了403。

github工程地址:https://github.com/gameloft9/spring-shiro-demo
CSDN下载地址:http://download.csdn.net/download/gameloft9/10145790

下一篇将写一个spring mvc通用基础框架,主要包括:统一返回处理、访问日志处理、异常处理、数据校验等等,为后面的前后端分离方案打下基础,敬请期待。

猜你喜欢

转载自blog.csdn.net/gameloft9/article/details/78683504