1.用户行为日志管理

日志业务分析及设计

背景分析

在实际项目中,用户操作软件的过程,通常会以日志记录。例如记录用户在什么时间点,执行了什么操作,访问了什么方法,传递了什么参数,执行时长是多少等这些信息要存储到数据库。

业务表的分析及设计

对于用户行为日志表的设计如下:

CREATE TABLE `sys_logs` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `username` varchar(50) DEFAULT NULL COMMENT '登录用户',
  `operation` varchar(50) DEFAULT NULL COMMENT '用户操作',
  `method` varchar(200) DEFAULT NULL COMMENT '请求方法',
  `params` varchar(5000) DEFAULT NULL COMMENT '请求参数',
  `time` bigint(20) NOT NULL COMMENT '执行时长(毫秒)',
  `ip` varchar(64) DEFAULT NULL COMMENT 'IP地址',
  `createdTime` datetime DEFAULT NULL COMMENT '创建时间',
   PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=175 DEFAULT CHARSET=utf8 COMMENT='系统日志';

日志业务原型设计

日志业务核心API设计

日志模块业务核心API设计,如图所示:

业务日志系统日志 业务日志数据表设计_java

其中:

  • SysLog (封装用户行为日志)
  • SysLogDao(执行日志数据逻辑)
  • SysLogService&SysLogServiceImpl (执行日志业务逻辑操作)
  • SysLogController(执行日志的请求、响应控制逻辑操作)

用户行为日志查询并呈现

业务分析与设计

业务分析

将用户行为日志从数据库查询出来以后,以统一的JSON格式,将数据响应给客户端

业务数据架构设计

用户日志行为数据查询时,其数据封装及传递架构如下:

业务日志系统日志 业务日志数据表设计_java_02

业务操作访问时序设计

基于业务描述,进行API访问时序设计,如图所示:

业务日志系统日志 业务日志数据表设计_业务日志系统日志_03

其中:

  • 页面加载时序设计

第一步:用户点击首页日志管理菜单时向服务端发送异步加载请求

第二步:服务端通过PageController中的方法处理日志页面加载请求

第三步:在日志列表页面加载完成以后,向服务端发起异步数据加载请求

  • 数据访问时序设计

第一步:用户向服务端发送数据查询请求(默认先查询第一页数据)

http://localhost/log/doFindPageObjects?pageCurrent=1

第二步:服务端调用SysLogController的doFindPageObjects方法处理查询请求并将响应结果封装到JsonResult,然后响应到客户端。

第三步:在SysLogController的doFindPageObjects方法内部调用SysLogService对象的findPageObjects方法执行分页查询逻辑,并将查询结果封装到PageObject对象,然后返回给控制层。

第四步:在SysLogServiceImpl的findPageObjects方法内部调用SysLogDao的getRowCount方法,findPageObjects方法获取用户日志总记录数以及当前页要呈现的记录。

服务端代码设计及实现

POJO类设计及实现

设计SysLog对象,通过此对象封装查询到用户日志行为信息,关键代码如下

package com.cy.pj.sys.pojo;
@Data
public class SysLog implements Serializable{
    private static final long serialVersionUID = 4805229781871486416L;
    private Integer id;
    private String ip;
    private String username;
    private String operation;
    private String method;
    private String params;
    private Integer time;
    private Date createdTime;
}
DAO 接口设计及实现

设计SysLogDao,并通过此类型的对象实现分页数据查询逻辑。

第一步:创建SysLogDao接口,关键代码如下:

package com.cy.pj.sys.dao;
@Mapper
public interface SysLogDao{
}

第二步:在SysLogDao接口中添加查询日志日志总记录数的方法,关键代码如下:

/**
* 基于条件查询用户日志总记录数
* @param username 查询条件
* @return 查询到的总记录数
*/
int getRowCout(String username);

第三步:在SysLogDao接口中添加当前页日志记录的方法,关键代码如下:

/**
* 基于条件查询当前页的日志记录
* @param username 查询条件
* @param startIndex 分页查询条件的起始位置
* @param pageSize 每页最多查询多少条记录
* @return 当前页用户行为日志
*/
List<SysLog> findPageObjects(String username,
                                   Integer startIndex,Integer pageSize);

第四步:定义SysLogDao中方法对应的SQL映射

在mappers/sys目录中创建SysLogMapper.xml文件,并在文件中定义SQL映射,关键代码如下:

<?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.cy.pj.sys.dao.SysLogDao">
    <!--通过sql元素提取共性-->
    <sql id="queryWhereId">
        from sys_logs
        <where>
          <if test="username!=null and username!=''">
           username like concat("%",#{username},"%")
          </if>
       </where>
    </sql>
    <!--基于条件查询总记录数-->
    <select id="getRowCount" resultType="int">
        select count (*)
        <include refid="queryWhereId"/>
    </select>
    <!--基于条件查询当前页记录-->
    <select id="findPageObjects" resultType="com.cy.pj.sys.pojo.SysLog">
         select *
         <include refid="queryWhereId"/>
         order by createdTime desc
         limit #{startIndex},#{pageSize}
    </select>
</mapper>
Service对象设计和实现

基于查询条件获取用户日志行为信息并进行计算和封装

第一步:定义SysLogService接口并添加日志查询方法,关键代码如下:

package com.cy.pj.sys.service;
public interface SysLogService{
    /**
    * 基于条件查询用户行为日志并进行封装
    * @param username 查询条件
    * @param pageCurrent 当前页面值
    * @return 封装分页查询结果
    */
    PageObject<SysLog> findPageObjects(String username,
    Integer pageCurrent);
}

第二步:定义SysLogService接口实现类并重写分页查询方法

package com.cy.pj.sys.service.impl;
@Service
public class SysLogServiceImpl implements SysLogService{
    @Autowired
    private SysLogDao sysLogDao;
    public PageObject<SysLog> findPageObjects(String username,
                               Integer pageCurrent){
         //1.对参数进行校验(可以自己校验,也可以借助框架:spring validation)
        if(pageCurrent==null||pageCurrent<1)
            throw new IllegalArgumentException("页码值不合法");//无效参数异常
        //2.基于查询条件查询总记录数并校验
        int rowCount=sysLogDao.getRowCount(username);
        if(rowCount==0)
            throw new ServiceException("没有找到对应记录");
        //3.查询当前页记录
        int pageSize=5;//页面大小,每页最多显示多少条记录
        int startIndex=(pageCurrent-1)*pageSize;//当前页起始位置
        List<SysLog> records=
        sysLogDao.findPageObjects(username,startIndex,pageSize);
        //4.封装查询结果
        return new PageObject<>(rowCount, records, pageSize, pageCurrent);
    }
}
Controller 类设计及实现

创建SysLogController类型,通过此类型的对象处理客户端日志请求。

第一步:创建SysLogController类型类型,关键代码如下

package com.cy.pj.sys.controller;
@RestController
@RequestMapping("/log/")
public class SysLogController{
    @Autowired
    private SysLogService sysLogService;
}

第二步:在SysLogController类中添加处理日志查询请求的方法,关键代码如下:

@GetMapping("doFindPageObjects")
public JsonResult doFindPageObjects(String username,Integer pageCurrent){
     PageObject<SysLog> pageObject=
     sysLogService.findPageObjects(username,pageCurrent);
     return new JsonResult(pageObject);
}

第三步:启动服务进行访问测试

打开浏览器,在地址栏中输入http://localhost/log/doFindPageObjects?pageCurrent=1进行资源方法,响应结果如图所示:

业务日志系统日志 业务日志数据表设计_java_04

客户端代码设计及实现

Starter页面事件处理

第一步: 定义响应请求页面的方法

在PageController类中,添加响应模块页面请求的方法,关键代码如下

@GetMapping("/{module}/{moduleUI}")
public String doModuleUI(@PathVariable String moduleUI){
   return "sys/"+moduleUI;
}

第二步:首页页面日志管理事件注册

starter.html页面加载完成以后,进行click事件注册,并基于请求的url加载页面资源,然后将响应页面呈现在浏览器中。

$(function(){
    doLoadUI("load-log-id","log/log_list");
})
function doLoadUI(id,url){
   $("#"+id).click(function(){
      //通过jquery中的load函数异步加载url对应的页面
      $("#mainContentId").load(url);
   })
}
日志列表页面事件处理

第一步:在PageController中定义响应分页页面方法,关键代码如下:

@GetMapping("doPageUI")
public String doPageUI(){
   return "common/page";
}

第二步:日志列表页面加载完成定义执行分页页面加载,关键代码如下

$(function(){
  $("#pageId").load("doPageUI");
})

第三步:日志分页页面加载完成以后异步加载服务端数据并进行呈现

修改分页页面加载的JS代码逻辑,关键代码如下:

$(function(){
  $("#pageId").load("doPageUI",doGetObjects);
})

定义异步加载日志信息的doGetObjects方法,关键代码如下:

function doGetObjects(){
   //1.params
   let params={pageCurrent:1};//暂时默认指定页码值为1
   //2.url
   let url='log/doFindPageObjects';
   //3.ajax request
   $.ajax({
      url:url,
      data:params,
      success(result){//JsonResult
         //处理查询到的响应结果
         doHandleQueryResponseResult(result);
      }
   })
}

将当前页记录呈现在页面上,关键代码如下:

function doHandleQueryResponseResult(result){
     if(result.state==1){//1表示ok
        doSetTableBodyRows(result.data.records);
     }else{
        doSetTableBodyErrors(result.message);
     }
}

定义处理错误信息的doSetTableBodyErrors方法,关键代码如下:

function doSetTableBodyErrors(msg){
   let thColumns=$("#log-table>thead>tr>th").length;
   let tBody=$("#log-table>tbody");
   let msgRow=`<tr><td colspan=${thColumns}>${msg}</td></tr>`
   tBody.html(msgRow);
}

迭代记录并将其追加到tbody中,关键代码如下:

function doSetTableBodyRows(records){
     let tBody=$("#log-table>tbody");
     tBody.empty();
     records.forEach((item,i)=>tBody.append(doCreateRow(item,i)))
}

基于每条记录进行"行渲染"

function doCreateRow(item,i){
   return `<tr>
            <td>${i+1}</td>
            <td>${item.username}</td>
            <td>${item.operation}</td>
            <td>${item.method}</td>
            <td>${item.params}</td>
            <td>${item.ip}</td>
            <td>${item.time}</td>
           </tr>`
}
Page 页面事件处理

第一步:定义初始化分页数据的方法,关键代码如下:

function doSetPagination(pageObject){
    $(".rowCount").html(`总记录数(${pageObject.rowCount})`);
    $(".pageCount").html(`总页数(${pageObject.pageCount})`);
    $(".pageCurrent").html(`当前页(${pageObject.pageCurrent})`);
    //存储当前页码值和总页数(后续要用)
    //data(key[,value])为jquery中的一个数据存取函数,没有value时就表示取
    $("#pageId").data("pageCurrent",pageObject.pageCurrent);
    $("#pageId").data("pageCount",pageObject.pageCount);
}

第二步:在日志列表页面中的处理查询结果的方法内部,调用doSetPagination方法

function doHandleQueryResponseResult(result){
     if(result.state==1){//1表示ok
        doSetTableBodyRows(result.data.records);
        doSetPagination(result.data);//pageObject
     }else{
        doSetTableBodyErrors(result.message);
     }
}

第三步:注册并处理分页点击事件,关键代码如下:

$(function(){
   //在pageId元素上注册click事件,当点击元素内部指定的子元素时,指定doJumToPage函数
   $("#pageId").on("click",".first,.pre,.next,.last",doJumpToPage)
})
function doJumToPage(){//基于新的页码值重新加载数据
    //1.基于点击事件修改当前页面值
    //1.1 获取当前页码值以及总页数
    let pageCurrent=$("#pageId").data("pageCurrent");
    let pageCount=$("#pageId").data("pageCount");
    //1.2 修改当前页码值
    //获取被点击对象的class属性值($(this)可以获取被点击对象)
    let cls=$(this).prop("class");
    if(cls=="first"){
      pageCurrent=1;
    }else if(cls=="pre"&&pageCurrent>1){
      pageCurrent--;
    }else if(cls=="next"&&pageCurrent<pageCount){
      pageCurrent++;
    }else if(cls=="last"){
      pageCurrent=pageCount;
    }else{
      return;
    }
    //2.基于新的页码值重新执行查询
    //2.1重新存储新的页码值
    $("#pageId").data("pageCurrent",pageCurrent);
    //2.2调用列表页面中的doGetObjects方法,并在方法内部基于新的页码值重新执行查询
    doGetObjects();
}

第四步:修改列表页面的doGetObjects方法内部参数的定义

let pageCurrent=$("#pageId").data("pageCurrent");
if(!pageCurrent)pageCurrent=1;
let params={pageCurrent:pageCurrent};//暂时默认指定页码值为1
列表页面查询按钮事件处理

第一步:查询按钮事件注册

日志列表"页面加载完成"以后,添加查询按钮事件注册操作,关键代码如下:

$(".input-group-btn").on("click",".btn-search",doQueryObjects);

定义查询按钮事件处理函数

function doQueryObjects(){
     //初始化页码值
     $("#pageId").data("pageCurrent",1);
     doGetObjects();
}

第二步:修改列表页面doGetObjects方法中的参数定义

let pageCurrent=$("#pageId").data("pageCurrent");
if(!pageCurrent)pageCurrent=1;
let params={pageCurrent:pageCurrent};//暂时默认指定页码值为1
let queryName=$("#searchNameId").val();//获取输入的用户名
if(queryName)params.username=queryName;//username不能随意写

章节总结(Summary)

重难点分析

  • 日志表的设计(记住有哪些字段)
  • 核心API的设计(SysLog,SysLogDao,SysLogService,SysLogController)
  • 日志数据查询结果的封装和传递(SysLog,PageObject,JsonResult)
  • 项目中统一异常处理类的设计(GlobalExceptionHandler)
  • 客户端向服务端传递的参数设计(username,pageCurrent)




2.菜单管理

菜单业务分析设计

背景分析

几乎所有软件都需要一个操作界面,通过界面中的一些选项或按钮操作具体的业务,这些选项和按钮我们通常称之为菜单.菜单是资源外在的一种表现形式,通过菜单操作我们系统中的资源.

业务设计分析

在数据库对应的表中设计并存储所有菜单信息,每个菜单可能都有一个对应的url,基于这个url可以找到对应的资源,进而可以访问和操作这些资源.其具体表的设计如下:

CREATE TABLE `sys_menus` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(50) DEFAULT NULL COMMENT '资源名称',
  `url` varchar(200) DEFAULT NULL COMMENT '资源URL',
  `type` int(11) DEFAULT NULL COMMENT '类型     1:菜单   2:按钮',
  `sort` int(11) DEFAULT NULL COMMENT '排序',
  `note` varchar(100) DEFAULT NULL COMMENT '备注',
  `parentId` int(11) DEFAULT NULL COMMENT '父菜单ID,一级菜单为0',
  `permission` varchar(500) DEFAULT NULL COMMENT '授权(如:user:create)',
  `createdTime` datetime DEFAULT NULL COMMENT '创建时间',
  `modifiedTime` datetime DEFAULT NULL COMMENT '修改时间',
  `createdUser` varchar(20) DEFAULT NULL COMMENT '创建用户',
  `modifiedUser` varchar(20) DEFAULT NULL COMMENT '修改用户',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=149 DEFAULT CHARSET=utf8 COMMENT='资源管理';

业务原型设计分析

基于菜单需求设计菜单的列表页面,如图所示:

业务日志系统日志 业务日志数据表设计_业务日志系统日志_05

基于菜单需求设计菜单编辑页面,如图所示:

业务日志系统日志 业务日志数据表设计_java_06

业务核心API设计分析

  • Pojo (SysMenu)
  • Dao (SysMenuDao)
  • Service (SysMenuService,SysMenuServiceImpl)
  • Controller (SysMenuController)

菜单数据查询设计及实现

业务分析

将数据库中菜单(SysMenu)表中的信息查询出来,然后在客户端以树结构(TreeGrid)方式进行呈现.

服务端设计及实现

Pojo对象设计

通过此对象封装菜单相关信息

package com.cy.pj.sys.pojo;
@Data
public class SysMenu implements Serializable {
   private static final long serialVersionUID = -4948259309231173588L;
   private Integer id;
   private String name;
   private String url;
   private Integer type;
   private Integer sort;
   private String note;
   private Integer parentId;
   private String parentName;
   private String permission;
   private Date createdTime;
   private Date modifiedTime;
   private String createdUser;
   private String modifiedUser;
}
Dao 接口及方法设计

定义菜单数据接口及数据查询方法

package com.cy.pj.sys.dao;
@Mapper
public interface SysMenuDao{
   /**查询所有菜单信息*/
   List<SysMenu> findObjects();
}

创建SysMenuMapper.xml文件并基于菜单查询方法定义SQL映射

<?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.cy.pj.sys.dao.SysMenuDao">
      <select id="findObjects" resultType="com.cy.pj.sys.pojo.SysMenu">
          select c.*,p.name parentName
          from sys_menus c left join sys_menus p
          on c.parentId=p.id
      </select>
</mapper>
Service接口及方法设计

第一步:定义菜单业务接口,关键代码如下:

package com.cy.pj.sys.service;
public interface SysMenuService{
    List<SysMenu> findObjects();
}

第二步:定义菜单业务接口的实现类,关键代码如下:

package com.cy.pj.sys.service.impl;
@Service
public class SysMenuServiceImpl implements SysMenuService{
    @Autowired
    private SysMenuDao sysMenuDao;
    public List<SysMenu> findObjects(){
        return sysMenuDao.findObjects();
    }
}
Controller 类及方法设计

创建SysMenuController类型,通过此类型对象处理客户端的菜单请求.

package com.cy.pj.sys.controller;
@RestController
@RequestMapping("/menu/")
public class SysMenuController{
   @Autowired
   private SysMenuService sysMenuService;
   
   @GetMapping("doFindObjects")
   public JsonResult doFindObjects(){
      List<SysMenu> list= sysMenuService.findObjects();
      return new JsonResult(list);
   }
}

启动服务,对controller进行访问测试,测试结果如下:

业务日志系统日志 业务日志数据表设计_当前页_07

客户端设计及实现

(省略)

菜单添加页面菜单树的加载

服务端设计及实现

Dao接口方法定义

在SysMenuDao中定义查询菜单信息的方法及sql映射.

@Select("select id,name,parentId from sys_menus")
public List<Node> findZtreeMenuNodes();
Service接口方法定义及实现

第一步:在SysMenuService接口中添加菜单节点查询方法,关键代码如下

public List<Node> findZtreeMenuNodes();

第二步:在SysMenuServiceImpl类中添加菜单节点查询方法的具体实现,关键代码如下

public List<Node> findZtreeMenuNodes(){
    return sysMenuDao.findZtreeMenuNodes()
}
Controller类中方法设计及实现

第一步:在SysMenuController类中添加处理查询菜单节点信息的方法,关键代码如下:

@GetMapping("doFindZtreeMenuNodes")
public JsonResult doFindZtreeMenuNodes(){
    return new JsonResult(sysMenuService.findZtreeMenuNodes());
}

第二步:启动服务进行访问测试,如图所示:

业务日志系统日志 业务日志数据表设计_java_08

客户端设计及实现

(省略)

菜单数据添加设计及实现

服务端设计及实现

Dao 接口方法定义

第一步:在SysMenuDao中添加向数据库新增菜单信息的方法

int insertObject(SysMenu entity);

第二步:在映射文件SysMenuMapper.xml中添加insert元素

<insert id="insertObject" parameterType="com.cy.pj.sys.pojo.SysMenu">
    insert into sys_menus
    (name,url,type,sort,note,parentId,permission,
    createdTime,modifiedTime,createdUser,modifiedUser)
    values
    (#{name},#{url},#{type},#{sort},#{note},#{parentId},#{permission},
    now(),now(),#{createdUser},#{modifiedUser})
</insert>
Service 接口方法设计及实现

第一步:在SysMenuService接口中添加新增菜单的方法,关键代码如下:

int saveObject(SysMenu entity);

第二步:在SysMenuServiceImpl实现类中重写接口方法,关键代码如下:

public int saveObject(SysMenu entity){
   //对参数校验(省略)
   int rows= sysMenuDao.insertObject(entity);
   return rows;
}
Controller类中方法设计及实现

第一步:在SysMenuController中添加处理新增菜单请求的方法,关键代码如下:

@PostMapping("doSaveObject")
public JsonResult doSaveObject(SysMenu entity){
   sysMenuService.saveObject(entity);
   return new JsonResult("save ok");
}

第二步:打开Postman工具通过post请求向服务端提交菜单信息

业务日志系统日志 业务日志数据表设计_java_09

执行send操作,假如出现如下结果形式则表示数据保存成功:

业务日志系统日志 业务日志数据表设计_页面加载_10

说明:假如控制层方法参数使用了@RequestBody描述,关键代码如下:

@PostMapping("doSaveObject")
public JsonResult doSaveObject(@RequestBody SysMenu entity){
   sysMenuService.saveObject(entity);
   return new JsonResult("save ok");
}

我们使用postman提交数据时,需要向服务端提交json格式数据,如图所示:

业务日志系统日志 业务日志数据表设计_页面加载_11

客户端设计及实现

(省略)

菜单修改页面数据的呈现

列表页面数据的绑定

菜单列表页面数据呈现实现,底层的数据绑定,绑定的目的是为后续获取提供便利。

业务日志系统日志 业务日志数据表设计_java_12

其中,具体的JS文件如图所示,

业务日志系统日志 业务日志数据表设计_java_13

修改按钮事件处理

业务日志系统日志 业务日志数据表设计_java_14

具体代码(省略),可参考menu_list.html,menu_edit.html页面

菜单页面数据更新设计及实现

服务端设计和实现

Dao 方法设计和实现

第一步:在SysMenuDao接口中添加菜单数据更新方法,关键代码如下:

int updateObject(SysMenu entity);

第二步:在SysMenuMapper.xml文件中添加SQL更新映射,关键代码如下:

<update id="updateObject" parameterType="com.cy.pj.sys.pojo.SysMenu">
   update sys_menus
   set name=#{name},
       type=#{type},
       url=#{url},
       sort=#{sort},
       parentId=#{parentId},
       permission=#{permission},
       modifiedTime=now(),
       modifiedUser=#{modifiedUser}
    where id=#{id}
</update>
Service 方法设计和实现

第一步:在SysMenuService接口中添加菜单更新方法,关键代码如下:

int updateObject(SysMenu entity);

第二步:在SysMenuServiceImpl类中添加菜单更新方法的具体实现,关键代码如下:

public int updateObject(SysMenu entity){
   return sysMenuDao.updateObject(entity);
}
Controller方法设计及实现

在SysMenuController中添加更新方法,关键代码如下:

@RequestMapping("doUpdateObject")
public JsonResult doUpdateObject(SysMenu entity){
   sysMenuService.updateObject(entity);
   return new JsonResult("update ok")
}

假如客户端向服务端传递的数据为json格式的数据,服务端方法参数需要使用@RequestBody进行描述,关键代码如下:

@PutMapping("doUpdateObject")
public JsonResult doUpdateObject(@RequestBody SysMenu entity){
   sysMenuService.updateObject(entity);
   return new JsonResult("update ok")
}

启动服务,进行菜单数据更新测试.

业务日志系统日志 业务日志数据表设计_业务日志系统日志_15

客户端设计和实现

省略

总结(Summary)

重难点分析

  • 数据表的设计(表中有哪些字段)
  • 菜单列表数据查询(左外关联)
  • 菜单数据封装的设计(SysMenu,Node,JsonResult)
  • 菜单数据的呈现(TreeGrid,Ztree)-知道有这样的插件即可(用时参考官网demo).

FAQ分析

  • 如何理解项目中的菜单?
    (菜单是资源的外在表现形式,通过菜单操作系统资源)
  • 菜单表中都有哪些字段?
  • 菜单数据的呈现是如何实现的?
  • 菜单数据写入到数据库是乱码?
    (首先检测服务端收到的是什么?url中配置编码了吗)




3.角色管理

角色业务分析及设计

背景分析

任何一个权限管理子系统,它的设计中都会有一个角色管理的模块,角色通常会与系统资源有一个对应关系,不同角色可能有不同或相同资源的访问权限.

核心业务分析

在权限管理子系统中,本质上控制的是用户对资源的访问权限,但是在授权时,一般会先将资源(菜单)的访问权限授予角色(Role),然后再将角色授予用户,此时用户就相当于拥有了某些资源的访问权限.具体角色表设计如下:

CREATE TABLE `sys_roles` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `name` varchar(100) DEFAULT NULL COMMENT '角色名称',
  `note` varchar(500) DEFAULT NULL COMMENT '备注',
  `createdTime` datetime DEFAULT NULL COMMENT '创建时间',
  `modifiedTime` datetime DEFAULT NULL COMMENT '修改时间',
  `createdUser` varchar(20) DEFAULT NULL COMMENT '创建用户',
  `modifiedUser` varchar(20) DEFAULT NULL COMMENT '修改用户',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=50 DEFAULT CHARSET=utf8 COMMENT='角色';

角色和菜单是一种多对多关系(Many2Many),对于多对多的关系,其关系的维护方通常会在关系表中进行实现,其关系表的设计如下:

CREATE TABLE `sys_role_menus` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `role_id` int(11) DEFAULT NULL COMMENT '角色ID',
  `menu_id` int(11) DEFAULT NULL COMMENT 'ID',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1400 DEFAULT CHARSET=utf8 COMMENT='角色与菜单对应关系';

业务原型设计分析

角色列表页面,如图所示:

业务日志系统日志 业务日志数据表设计_spring_16

角色添加页面,如图所示:

业务日志系统日志 业务日志数据表设计_java_17

业务核心API设计分析

  • POJO: SysRole
  • Dao: SysRoleDao,SysRoleMenuDao
  • Service: SysRoleService,SysRoleServiceImpl
  • Controller: SysRoleController

角色列表数据查询设计及实现

业务分析

将数据库角色表中的数据,基于分页查询的需求查询数据,并在页面上进行呈现

服务端设计及实现

Pojo对象设计及实现

定义SysRole类型,基于此类型封装从数据库查询到的角色信息.关键代码如下:

package com.cy.pj.sys.pojo;
@Data
public class SysRole  implements Serializable{
   private static final long serialVersionUID = 1937011637090526629L;
   private Integer id;
   private String name;
   private String note;
   private Date createdTime;
   private Date modifiedTime;
   private String createdUser;
   private String modifiedUser;
}
Dao对象设计及实现

第一步:创建SysRoleDao,并定义角色分页查询方法,关键代码如下\

package com.cy.pj.sys.dao;
@Mapper
public interface SysRoleDao{
    int getRowCount(String name);
    List<SysRole> findPageObjects(String name,
              Integer startIndex,Integer pageSize);
}

第二步:创建SysRoleMapper.xml文件,并定义SQL分页查询映射,关键代码如下:

<mapper namespace="com.cy.pj.sys.dao.SysRoleDao">
    <!--SQL共性定义-->
    <sql i="queryWhereId">
        from sys_roles
        <where>
            <if test="name!=null and name!=''">
                name like concat("%",#{name},"%")
            </if>
        </where>
    </sql>
    <!--统计总记录数-->
    <select id="getRowCount" resultType="int">
        select count(*)
        <include refid="queryWhereId"/>
    </select>
    <!--分页查询当前页记录-->
    <select id="findPageObjects" resultType="com.cy.pj.sys.pojo.SysRole">
        select *
        <include refid="queryWhereId"/>
        order by createdTime desc
        limit #{startIndex},#{pageSize}
    </select>
</mapper>
Service对象设计及实现

第一步:定义SysRoleService接口以及分页查询方法,关键代码如下

package com.cy.pj.sys.service;
public interface SysRoleService{
    PageObject<SysRole> findPageObjects(String name,Integer pageCurrent);
}

第二步:定义SysRoleService接口实现并重写分页查询方法,关键代码如下

package com.cy.pj.sys.service.impl;
@Service
public class SysRoleServiceImpl implements SysRoleService{
    @Autowired
    private SysRoleDao sysRoleDao;
    public  PageObject<SysRole> findPageObjects(String name,
      Integer pageCurrent){
       if(pageCurrent==null||pageCurrent<1)
           throw new IllegalArgumentException("页码值不正确");
       int rowCount=sysRoleDao.getRowCount(name);
       if(rowCount==0)
           throw new ServiceException("记录可能已经不存在");
       int pageSize=3;
       int startIndex=(pageCurrent-1)*pageSize;
       List<SysRole> records=
       sysRoleDao.findPageObjects(name,startIndex,pageSize);
       return new PageObject<>(rowCount,records,pageSize,pageCurrent);
    }
    
}
Controller对象设计及实现

第一步:定义SysRoleController及处理分页查询请求的方法,关键代码如下:

package com.cy.pj.sys.controller;

@RestController
@RequestMapping("/role/")
public class SysRoleController{

    @Autowired
    private SysRoleService sysRoleService;
    
    @GetMapping("doFindPageObjects")
    public JsonResult doFindPageObjects(String name,Integer pageCurrent){
       PageObject<SysRole> pageObject=
       sysRoleService.findPageObjects(name,pageCurrent);
       return new JsonResult(pageObject);
    }
}

第二步:启动服务,进行访问测试(在浏览器或postman输入访问url,检测输出结果),如图所示:

业务日志系统日志 业务日志数据表设计_spring_18

客户端设计及实现

(省略)

角色添加页面数据的保存实现

业务分析

在角色列表页面,点击添加按钮时进入角色添加页面,在添加页面中,输入要新增的角色信息以及要为角色赋予的权限信息(角色可访问的菜单),表单填写完成以后,点击save按钮,将数据异步提交到服务端.

业务日志系统日志 业务日志数据表设计_业务日志系统日志_19

服务端设计及实现

Dao 方法设计及定义

第一步:在SysRoleDao中添加用于新增角色信息的方法,关键代码如下:

int insertObject(SysRole entity);

第二步:在SysRoleMapper.xml文件中添加用于新增角色的SQL映射,关键代码如下

<insert id="insertObject" parameterType="com.cy.pj.sys.pojo.SysRole"
        keyProperty="id"
        useGeneratedKeys=true>
    insert into sys_roles
    (name,note,createdTime,modifiedTime,createdUser,modifiedUser)
    values
    (#{name},#{note},now(),now(),#{createdUser},#{modifiedUser})
</insert>

其中:

  1. useGeneratedKeys 表示要使用自增的主键值.
  2. keyProperty 表示要将自增主键值赋值给参数对象的哪个属性

第三步:创建角色菜单关系表对应的Dao接口及新增方法,关键代码如下

package com.cy.pj.sys.dao;
@Mapper
public interface SysRoleMenuDao{
 int insertObjects(Integer roleId,Integer[] menuIds);
}

第四步:创建角色菜单关系表对应的映射文件以及SQL映射,关键代码如下:

在指定目录添加SysRoleMenuMapper.xml文件以及SQL映射

<mapper namespace="com.cy.pj.sys.dao.SysRoleMenuDao">
    <insert id="insertObjects">
          insert into sys_role_menus
          (role_id,menu_id)
          values 
          <foreach collection="menuIds" separator="," item="menuId">
              (#{roleId},#{menuId})
          </foreach>
    </insert>
</mapper>
Service 方法设计及实现

第一步:在SysRoleService接口中添加新增角色信息的方法,关键代码如下:

int saveObject(SysRole entity,Integer[] menuIds);

第二步:在SysRoleServiceImpl中添加SysRoleMenuDao属性,关键代码如下:

@Autowired
private SysRoleMenuDao sysRoleMenuDao;

第三步:在SysRoleServiceImpl中重写新增角色的方法,关键代码如下:

public int saveObjects(SysRole entity,Integer[] menuIds){
    //1.参数校验(自己校验)
    //2.保存角色自身信息
    int rows=sysRoleDao.insertObject(entity);
    //3.保存角色和菜单关系数据
    sysRoleMenuDao.insertObjects(entity.getId(),menuIds);
    return rows;
}
Controller方法设计及实现

第一步:在SysRoleController中定义,处理角色新增请求的方法,关键代码如下:

@PostMapping("doSaveObject")
public JsonResult doSaveObject(SysRole entity,Integer []menuIds){
    sysRoleService.saveObject(entity,menuIds);
    return new JsonResult("save ok");
}

假如客户端提交的数据为json格式,可以将方法中的参数封装到一个pojo对象中,然后使用@RequestBody注解描述(一个方法中只能使用一个@RequestBody注解)即可。

第二步:打开Postman进行post请求测试

业务日志系统日志 业务日志数据表设计_页面加载_20

客户端设计及实现

省略

角色修改页面数据加载及呈现

业务分析

当点击角色列表页面的修改按钮时,我们要做哪些业务呢?如图所示:

业务日志系统日志 业务日志数据表设计_页面加载_21

基于页面上的需求分析,我们在点击修改按钮时,需要基于id查询角色自身信息以及角色对应的关系数据,对于这样的需求,首先确定我们的数据来源(两张表),基于数据来源,我们给出解决方案如下:

方案1:业务层向数据层发起两次单表查询请求,将两次查询结果在业务层进行封装。(最简单)

方案2:业务层发起一次查询请求,数据层进行表关联查询?(两个表需要有对应的关系字段)

方案3:业务层发起一次查询请求,数据层进行表嵌套查询? (数据层多次查询)

服务端设计及实现

Pojo 对象设计及实现

定义SysRoleMenu对象,用于封装基于id查询到的角色和菜单id信息,关键代码如下:

package com.cy.pj.sys.pojo;
@Data
public class SysRoleMenu implements Serializable{
   private static final long serialVersionUID = -2671028987524519218L;
   private Integer id;
   private String name;
   private String note;
   private List<Integer> menuIds;
}
Dao 方法设计及实现

方案1:数据层两次单表查询(依次查询角色表和角色菜单关系表)

第一步:在SysRoleDao中定义基于id查询角色信息的方法,关键代码如下:

SysRoleMenu findById(Integer id);

第二步:在SysRoleMapper中添加基于id查询角色自身信息的sql映射,关键代码如下:

<select id="findById" resultType="com.cy.pj.sys.pojo.SysRoleMenu">
    select id,name,note
    from sys_roles
    where id=#{id}
</select>

第三步:在SysRoleMenuDao中添加基于角色id查询菜单id的方法,关键代码如下

List<Integer> findMenuIdsByRoleId(Integer roleId);

第四步:在SysRoleMenuMapper中添加基于角色id查询菜单id的sql映射,关键代码如下:

<select id="findMenuIdsByRoleId" resultType="int">
     select menu_id
     from sys_role_menus
     where role_id=#{roleId}
</select>

方案2:数据层进行一次多表关联查询

第一步:在SysRoleDao中定义基于id查询角色信息的方法,关键代码如下(假如已有则无需再定义):

SysRoleMenu findById(Integer id);

第二步:在SysRoleMapper中添加基于角色id查询角色和菜单id的SQL映射,关键代码如下:

<select id="findById" resultMap="sysRoleMenu">
    select r.id,r.name,r.note,rm.menu_id
    from sys_roles r left join sys_role_menus rm
    on  r.id=rm.role_id
    where r.id=#{id}
</select>
<resultMap id="sysRoleMenu" type="com.cy.pj.sys.pojo.SysRoleMenu">
    <id property="id" column="id"/>
    <result property="name" column="name"/>
    <result property="note" column="note"/>
    <!--one2many映射时会使用collection元素-->
    <collection property="menuIds" ofType="integer">
          <result column="menu_id"/>
    </collection>
</resultMap>

映射分析,如图所示:

业务日志系统日志 业务日志数据表设计_页面加载_22

说明:resultMap元素是mybatis中实现高级查询映射的一个元素,当表中字段名与pojo类中的属性不同或者是多表查询,嵌套查询时,一般都会使用resultMap元素进行自定义映射。

Service 方法设计及实现

方案1:业务层发起多次单表查询(对应数据层方案1:两次单表查询)

第一步:在SysRoleService接口中添加基于角色id查询角色以及菜单id的方法,关键代码如下:

SysRoleMenu findById(Integer id);

第二步:在SysRoleServiceImpl类中添加基于角色id查询角色以及菜单id的方法实现,关键代码如下:

public SysRoleMenu findById(Integer id){
   SysRoleMenu roleMenu=sysRoleDao.findById(id);//id,name,note
   if(roleMenu==null)
      throw new ServiceException("记录可能已经不存在");
   List<Integer> menuIds=sysRoleMenuDao.findMenuIdsByRoleId(id);
   roleMenu.setMenuIds(menuIds);
   return roleMenu;
}

方案2,业务层发起一次查询,数据层表关联或表嵌套查询 (对应数据层的方案2和方案3)

public SysRoleMenu findById(Integer id){
   SysRoleMenu roleMenu=sysRoleDao.findById(id);//id,name,note
   if(roleMenu==null)
      throw new ServiceException("记录可能已经不存在");
   return roleMenu;
}
Controller 方法设计及实现

第一步:在SysRoleController中添加基于id查询角色和菜单id的方法,关键代码如下:

@GetMapping("doFindById/{id}")
public JsonResult doFindById(@PathVariable Integer id){
    return new JsonResult(sysRoleService.findById(id));
}

第二步:启动服务基于浏览器或postman进行访问测试,如图所示

业务日志系统日志 业务日志数据表设计_当前页_23

客户端设计及实现
原型设计分析

当点击角色列表页面上的更新按钮时,基于id查询角色以及角色对应的菜单id,然后更新到角色编辑页面,如图所示:

业务日志系统日志 业务日志数据表设计_业务日志系统日志_24

业务日志系统日志 业务日志数据表设计_当前页_25

将服务端响应到客户端的数据,更新到页面上,其核心js代码如下:

业务日志系统日志 业务日志数据表设计_业务日志系统日志_26

角色修改页面数据更新实现

业务分析

获取修改页面中的表单数据,然后将数据异步提交到服务端,执行更新操作。

服务端设计及实现

Dao 方法设计及实现

第一步:在SysRoleDao接口中定义更新角色自身信息的方法,关键代码如下:

int updateObject(SysRole entity);

第二步:在SysRoleMapping中定义更新角色信息的SQL映射,关键代码如下:

<update id="updateObject">
    update sys_roles
    set name=#{name},
        note=#{note},
        modifiedTime=now(),
        modifiedUser=#{modifiedUser}
    where id=#{id}
</update>

第三步:在SysRoleMenuDao中定义更新关系数据方法,关键代码如下:

对于one2many关系数据的更新,一般是先删除原有关系数据,再添加新的关系数据,所以dao

中要定义基于角色id删除角色菜单关系数据的方法

@Delete("delete from sys_role_menus where role_id=#{roleId}")
int deleteObjectsByRoleId(Integer roleId);

对于添加新的关系数据的方法及映射,在角色添加模块已经实现,这里不再重复实现。

Service 方法设计及实现

第一步:在SysRoleService中添加更新角色信息的方法,关键代码如下:

int updateObject(SysRole entity,Integer[] menuIds);

第二步:在SysRoleServiceImpl类中添加更新角色信息的方法实现,关键代码如下:

public int updateObject(SysRole entity,Integer[] menuIds){
    int rows=sysRoleDao.updateObject(entity);
    if(rows==0)
       throw new ServiceException("记录可能已经不存在了");
    sysRoleMenuDao.deleteObjectsByRoleId(entity.getId());
    sysRoleMenuDao.insertObjects(entity.getId(),menuIds);
    return rows;
}
Controller 方法设计及实现

第一步:在SysRoleController中添加更新角色信息的方法,关键代码如下:

@RequestMapping("doUpdateObject")
public JsonResult doUpdateObject(SysRole entity,Integer[]menuIds){
     sysRoleService.updateObject(entity,menuIds);
     return new JsonResult("update ok");
}

第二步:启动服务进行,借助post进行更新测试,如图所示:

业务日志系统日志 业务日志数据表设计_spring_27

客户端设计及实现

(省略)

总结(Summary)

重难点分析

  • 角色模块表设计
  • 角色模块与菜单模块的关系
  • 多对多关系数据的维护
  • 角色和角色对应的菜单数据的保存
  • 基于id查询角色以及角色对应的菜单id(三种方案)

FAQ 分析

  • Insert操作执行时,如何获取表中自动生成的主键值?
  • resultMap 元素要解决什么问题?(完成查询的高级映射-自定义映射,one2many等关系映射)
  • 业务层需要的数据来自多张表时,具体的查询方案是怎样的?
  • 修改角色信息时,角色和菜单的关系数据如何修改?
  • 执行查询时,客户端没有呈现服务端响应的数据?(先检查控制层数据,标准,…)
  • 执行save操作时,数据库中没有看到保存的数据?(先检查控制层是否收到了数据)

BUG 分析

  • 方法参数中使用多个@ResponseBody注解描述参数,无法获取客户端提交的数据?
  • 405异常,客户端请求方式与服务端处理请求的方式不匹配




4.AOP 技术简介

背景分析

对于一个业务而言,我们如何在不修改源代码的基础上对对象功能进行拓展,例如现有一个公告(通知)业务:

interface NoticeService{
    boolean send(String notice);
}
public class NoticeServiceImpl implements NoticeService{
  public boolean send(String notice){
       System.out.println(notice);
       return true;
  }
}

需求:

基于OCP(开闭原则-对扩展开放对修改关系)设计方式对NoticeServiceImpl类的功能进行扩展,例如在send业务方法执行之前和之后输出一下系统时间.

方案1:基于继承方式实现其功能扩展,关键设计如下:

public class CglibLogNoticeService extends NoticeServiceImpl{
    public boolean send(String notice){
       System.out.println("Start:"+System.currentTimeMillis());
       super.send(notice);
       System.out.println("After:"+System.currentTimeMillis());
       return true;
    }
}

测试类如下:

public class NoticeServiceTests{
     public static void main(String[] args){
         NoticeService ns=new CglibLogNoticeService();
         ns.send("hello");
     }
}

基于继承方式实现功能扩展的优势,劣势分析:

  1. 优势: 简单,容易理解.
  2. 劣势:不够灵活(只能直接一个接口下的子类)和稳定(父类一旦修改了其方法,所有子类都要改.)

方案2:基于组合方式实现其功能扩展,关键代码设计如下:

public class JdkLogNoticeService implements NoticeService{
      private NoticeService noticeService;//has a 
      public  JdkLogNoticeService(NoticeService noticeService){
        this.noticeService=noticeService;
      }
      public boolean send(String notice){
         System.out.println("Start:"+System.currentTimeMillis());
         this.noticeService.send(notice);
         System.out.println("After:"+System.currentTimeMillis());
         return true;
      }
}

测试类

public class NoticeServiceTests{
     public static void main(String[] args){
         NoticeService ns=
         new JdkLogNoticeService(new NoticeServiceImpl());
         ns.send("hello");
     }
}

基于组合方式实现功能扩展的优势,劣势分析:

  1. 优势: 灵活(可以为指定接口下的所有实现类做功能扩展),稳定(组合的具体对象发生变化,不会影响当前类)
  2. 劣势:相对继承而言不容易理解.

总结:

无论是继承,还是组合都是基于OCP方式实现了对象功能扩展,都有相应的优缺点,并且我们都要自己去写这些子类或兄弟类,在这些类中调用目标对象(父类或兄弟类对象)的方法以及扩展业务逻辑.对于这样的模板代码我们能否进行简化呢?例如.由框架实现其共性(创建目录类型的子类类型或兄弟类型),特性交给用户自己实现.

AOP 是什么?

AOP(Aspect Oriented Programming)是面向切面编程,是一种设计思想,它要在不改变原有目标对象的基础上,为目标对象基于动态织入的特定方式(可以是编译是的动态,也可以运行时的动态)进行功能扩展.我们可以将设计思想理解为OOP(面向对象编程)思想的补充和完善,OOP强调的一种静态过程(一个项目由哪些子系统构成,一个子系统有哪些模块,一个模块又由哪些对象构成,一个对象又有哪些属性和方法).AOP是一个动态过程,它要为设计好的对象在动态编译或运行时做服务增益,例如,记录日志,事务增强,权限控制等,如图所示:

业务日志系统日志 业务日志数据表设计_当前页_28

AOP 应用原理初步分析

AOP可以在系统启动时为目标类型创建子类或兄弟类型对象,这样的对象我们通常会称之为动态代理对象.如图所示:

业务日志系统日志 业务日志数据表设计_页面加载_29

其中:

为目标类型(XxxServiceImpl)创建其代理对象方式有两种(先了解):

第一种方式:借助JDK官方API为目标对象类型创建其兄弟类型对象,但是目标对象类型需要实现相应接口.

第二种方式:借助CGLIB库为目标对象类型创建其子类类型对象,但是目标对象类型不能使用final修饰.

AOP 相关术语概要分析

  • 切面对象(Aspect):封装了扩展业务逻辑的对象,在spring中可以使用@AspectJ描述.
  • 切入点(Pointcut): 定义了切入扩展业务逻辑的一些方法的集合(哪些方法运行时切入扩展业务),一般会通过表达式进行相关定义,一个切面中可以定义多个切入点的定义.
  • 连接点(JoinPoint):切入点方法集合中封装了某个正在执行的目标方法信息的对象,可以通过此对象获取具体的目标方法信息,甚至去调用目标方法.
  • 通知(Advice):切面(Aspect)内部封装扩展业务逻辑的具体方法对象,一个切面中可以有多个通知(例如@Around).

其中,切入点与连接点的分析,如图所示:

业务日志系统日志 业务日志数据表设计_java_30

说明:我们可以简单的将机场的一个安检口理解为连接点,多个安检口为切入点,安全检查过程看成是通知。总之,概念很晦涩难懂,多做例子,做完就会清晰。先可以按白话去理解。

Spring中AOP快速入门

业务描述

在项目中定义一个日志切面,通过切面中的通知方法为目标业务对象做日志功能增强.

添加AOP依赖

<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
  </dependency>

Spring 切面对象定义

在springboot工程中,切面对象需要使用@Aspect,关键代码如下:

package com.cy.pj.sys.service.aspect;
/**
 * @Aspect 注解描述的类型为切面对象类型,此切面中可以定义多个切入点和通知方法.
 */
@Slf4j
@Aspect
@Component
public class SysLogAspect {
    /**
     * @Pointcut注解用于定义切入点
     * bean("spring容器中bean的名字")这个表达式为切入点表达式定义的一种语法,
     * 它描述的是某个bean或多个bean中所有方法的集合为切入点,这个形式的切入点
     * 表达式的缺陷是不能精确到具体方法的.
     */
     @Pointcut("bean(sysUserServiceImpl)")
     public void doLog(){}//此方法只负责承载切入点的定义

    /**
     * @Around注解描述的方法,可以在切入点执行之前和之后进行业务拓展,
     * 在当前业务中,此方法为日志通知方法
     * @param joinPoint 连接点对象,此对象封装了要执行的切入点方法信息.
     * 可以通过连接点对象调用目标方法,这里的ProceedingJoinPoint类型只能应用于@Around描述的方法参数中
     * @return 目标方法的执行结果
     * @throws Throwable
     */
     @Around("doLog()")
     public Object doAround(ProceedingJoinPoint jp)throws Throwable{
         log.info("Start:{}",System.currentTimeMillis());
         try {
             Object result = jp.proceed();//执行目标方法(切点方法中的某个方法)
             log.info("After:{}",System.currentTimeMillis());
             return result;//目标业务方法的执行结果
         }catch(Throwable e){
             e.printStackTrace();
             log.error("Exception:{}",System.currentTimeMillis());
             throw e;
         }
     }
}

启动项目进行访问测试,例如访问sysUserServiceImpl对象中的方法,检测其日志输出.

Spring 切面工作原理分析

当我们切面内部,切入点对应的目标业务方法执行时,底层会通过代理对象访问切面中的通知方法,进而通过通知方法为目标业务做功能增强.

业务日志系统日志 业务日志数据表设计_spring_31

项目中JDK动态代理对象应用分析,如图所示:

业务日志系统日志 业务日志数据表设计_java_32

其中:springboot工程默认的AOP代理为CGLIB代理,假如希望是JDK代理,则需要在springboot的配置文件中进行如下配置:

spring: 
  aop: 
    proxy-target-class: false

项目中CGLIB动态代理对象应用分析,如图所示:

业务日志系统日志 业务日志数据表设计_当前页_33

Spring中AOP技术应用进阶

AOP 通知类型

Spring框架AOP模块定义通知类型,有如下几种:

  • @Around (所有通知中优先级最高的通知,可以在目标方法执行之前,之后灵活进行业务拓展.)
  • @Before (目标方法执行之前调用)
  • @AfterReturning (目标方法正常结束时执行)
  • @AfterThrowing (目标方法异常结束时执行)
  • @After (目标方法结束时执行,正常结束和异常结束它都会执行)

基于对通知类型的理解,通过案例分析通知的具体执行时间点,关键代码分析

package com.cy.pj.sys.service.aspect;
@Aspect
@Component
public class SysTimeAspect {
    @Pointcut("bean(sysRoleServiceImpl)")
    public void doTime(){}

    @Before("doTime()")
    public void doBefore(JoinPoint jp){
        System.out.println("@Before");
    }

    @After("doTime()")
    public void doAfter(){
        System.out.println("@After");
    }

    @AfterReturning("doTime()")
    public void doAfterReturning(){
        System.out.println("@AfterReturning");
    }

    @AfterThrowing("doTime()")
    public void doAfterThrowing(){
        System.out.println("@AfterThrowing");
    }
    //最重要,优先级也是最高
    @Around("doTime()")
    public Object doAround(ProceedingJoinPoint joinPoint)throws Throwable{
        try {
            System.out.println("@Around.before");
            Object result = joinPoint.proceed();
            System.out.println("@Around.AfterReturning");
            return result;
        }catch(Exception e){
            System.out.println("@Around.AfterThrowing");
            e.printStackTrace();
            throw e;
        }finally {
            System.out.println("@Around.after");
        }
    }

}

其中,实际项目中并不是要将所有通知方法都定义一遍,会结合具体业务添加通知方法.

AOP切入点表达式

在Spring工程中对于切入点表达式,可以分成两大类型:

  • 粗粒度切入点表达式定义(不能精确到具体方法),例如bean,within表达式
  • 细粒度切入点表达式定义(可以精确到具体方法),例如execution,@annotation表达式

粗粒切入点表达式

  • bean(“bean的名字”) 表达式案例分析
1) bean(sysUserServiceImpl),sysUserServiceImpl类中所有方法集合为切入点
2) bean(*ServiceImpl),以ServiceImpl结尾的所有方法集合为切入点
  • within (“包名.类型”) 表达式案例分析
1) within(com.pj.service.SysUserServiceImpl),SysUserServiceImpl类中所有方法集合为切入点
2) within(com.pj.service.*),com.pj.service包下所有类中的方法集合为切入点
3) within(com.pj.service..*),com.pj.service包以及子包中所有类中方法的集合为切入点

细粒度切入点表达式

  • execution(“返回值 类全名.方法名(参数列表)”) 表达式案例分析
1) execution(int com.cy.pj.service.SysUserService.validById(Integer,Integer))
2) execution(* com.cy.pj.service..*.*(..))
  • @annotation(“注解的类全名”)表达式案例分析
1) @annotation(com.annotation.RequiredCache),由RequiredCache注解描述的方法为缓存切入点方法
2) @annotation(com.annotation.RequiredLog),由RequiredLog注解描述的方法为日志切入点方法

AOP切面优先级设置分析

当项目中有多个切面时,并且对应着相同的切入点,假如切面中的通知方法的执行需要有严格顺序要求,此时我们需要设置切面的优先级。这个优先级可以通过@Order(数字优先级)注解进行描述,数字越小优先级越高。例如:

设置日志切面优先级,关键代码如下:

@Order(2)
@Aspect
@Component
public class SysLogAspect{}

设置缓存切面优先级,关键代码如下:

@Order(1)
@Aspect
@Component
public class SysCacheAspect{}

假如默认没有指定优先级,默认是最低的优先级。相同切入点下的多个切面会形成一个切面链。

Cache切面案例设计及实现

我们项目中有很多查询业务,为了提高其查询性能,减少对数据库访问,可以将业务层查询到数据存储到缓存中,下次查询从缓存中去查询,这样的切面及缓存设计如何实现?

第一步:在common项目中定义描述切入点方法的注解,关键代码如下:

package com.cy.pj.common.annotation;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RequiredCache{
}
package com.cy.pj.common.annotation;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ClearCache{
}

第二步:在common项目中定义缓存切面SysCacheAspect,关键代码如下:

package com.cy.pj.common.aspect;
@Order(1)
@Aspect
@Component
public class SysCacheAspect{

     @Pointcut("@annotation(com.cy.pj.common.annotation.RequiredCache)")
     public void doCache(){}
     
     @Pointcut("@annotation(com.cy.pj.common.annotation.ClearCache)")
     public void doClearCache(){}
    
     @AfterReturning("doClearCache()")
     public void doAfterReturning(){
          //清除缓存中数据
     }
     
     @Around("doCache()")
     public Object doCacheAround(ProceedingJoinPoint jp)throws Throwable{
         //1.从缓存去取数据,假如缓存中有则直接return缓存数据
         //2.缓存中没有则执行目标方法查询数据
         //3.将查询结果存储到缓存对象
         return null;//查询到的结果
     }
}

第三步:通过注解在业务类中定义切入点方法,关键代码如下:

以部门业务为例进行分析。

@RequiredCache
List<Map<String,Object> findObjects(){}//查询数据
@ClearCache
int updateObject(SysDept entity){}//更新数据的方法

用户日志切面案例设计及实现

获取登录用户行为日志,并将日志信息写入到数据库。

第一步:在SysLogDao中添加新增日志的方法,关键代码如下:

int insertObject(SysLog entity);

第二步:在SysLogMapper映射文件中添加新增日志的SQL映射,关键代码如下:

<insert id="insertObject">
    insert into sys_logs
    (username,ip,operation,method,params,time,createdTime)
    values
    (#{username},#{ip},#{operation},#{method},#{param},#{time},now())
</insert>

第三步:在SysLogService接口中添加新增日志的方法,关键代码如下:

void saveObject(SysLog entity);

第四步:在SysLogServiceImpl类中添加新增日志的方法,关键代码如下:

public void saveObject(SysLog entity){
    sysLogDao.insertObject(entity);
}

第五步:定义RequiredLog注解,通过此注解定义切入点方法以及操作名称,关键代码如下:

package com.cy.pj.common.annotation;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RequiredLog {
    String value() default "operation";
}

第六步:修改SysLogAspect切面对象,添加获取并新增日志的业务逻辑,关键操作及代码如下:

修改切入点表达式,基于注解方式进行定义(更加灵活)

@Pointcut("@annotation(com.cy.pj.common.annotation.RequiredLog)")
public void doLog(){}

添加用来进行保存日志信息的方法

@Autowired
private SysLogService sysLogService;
//记录用户行为日志
private void saveUserLog(ProceedingJoinPoint jp,long time)
             throws Exception {
   //1.获取用户行为日志
  //1.1获取登录用户名(没做登录时,可以先给个固定值)
   String username="cgb";
  //1.2获取ip地址
   String ip= IPUtils.getIpAddr();
  //1.3获取操作名(operation)-@RequiredLog注解中value属性的值
  //1.3.1获取目标对象类型
   Class<?> targetCls=jp.getTarget().getClass();
   //1.3.2获取目标方法
   MethodSignature ms= (MethodSignature) jp.getSignature();//方法签名
   Method targetMethod=
           targetCls.getMethod(ms.getName(),ms.getParameterTypes());
   //1.3.3 获取方法上RequiredLog注解
   RequiredLog annotation =
         targetMethod.getAnnotation(RequiredLog.class);
   //1.3.4 获取注解中定义操作名
   String operation=annotation.value();
   //1.4获取方法声明(类全名+方法名)
   String classMethodName=targetCls.getName()+"."+targetMethod.getName();
   //1.5获取方法实际参数信息
   Object[]args=jp.getArgs();
   String params=new ObjectMapper().writeValueAsString(args);
   //2.封装用户行为日志
   SysLog sysLog=new SysLog();
   sysLog.setUsername(username);
   sysLog.setIp(ip);
   sysLog.setOperation(operation);
   sysLog.setMethod(classMethodName);
   sysLog.setParams(params);
   sysLog.setTime(time);
   //3.存储用户信息日志到数据库
   sysLogService.saveObject(sysLog);
}

修改日志切面通知方法,在通知方法中调用save 日志的方法,关键代码如下:

@Around("doLog()")
 public Object doAround(ProceedingJoinPoint jp)throws Throwable{
   long t1=System.currentTimeMillis();
   log.info("Start:{}",t1);
   try {
     Object result = jp.proceed();//执行目标方法(切点方法中的某个方法)
     long t2=System.currentTimeMillis();
     log.info("After:{}",t2);
     saveUserLog(jp,t2-t1);
     return result;//目标业务方法的执行结果
    }catch(Throwable e){
      e.printStackTrace();
      log.error("Exception:{}",System.currentTimeMillis());
      throw e;
     }
}

Spring中AOP技术高级应用

Spring 中的异步操作

异步业务描述

当我们项目中的一些非核心业务运行时,影响到用户核心业务的响应时间,导致用户体验下降,可以将这些非业务放到新的线程中异步执行。

启动Spring中异步操作

在SpringBoot工程,可以在启动类的上面,添加启动异步操作的注解(@EnableAsync)描述,代码如下:

@EnableAsync
@SpringBootApplication
public class DbpmsApplication {

    public static void main(String[] args) {
        SpringApplication.run(DbpmsApplication.class, args);
    }
}

说明,当启动类上添加了@EnableAsync注解描述时,再次运行启动类时底层会帮我们配置一个线程池

业务方法上执行异步操作

假如此时某个业务方法需要执行异步操作,可以使用@Async注解对方法进行描述,例如写日志的业务。

@Async
public void saveObject(SysLog entity){
    sysLogDao.insertObject(entity);
}

其中,@Async注解描述的方法,在spring中会认为这是一个异步切入点方法, 在这个切入点方法执行时,底层会通过通知方法获取线程池中的线程,通过池中的线程调用切入点方法(底层默认池类型为ThreadPoolExecutor类型)。

Spring中异步线程池的配置

当springboot中默认的线程池配置,不满足我们实际项目需求时,我们可以对线程池进行自定义的配置,关键配置如下:

spring: 
   task:
    execution:
      pool:
        core-size: 8
        max-size: 256
        keep-alive: 60000
        queue-capacity: 256
      thread-name-prefix: db-service-task-

其中:

1)core-size :核心线程数,当池中线程数没达到core-size的值时,每接收一个新的任务都会创建一个新线程,然后存储到池。假如池中线程数已经达到core-size设置的值,再接收新的任务时,要检测是否有空闲的核心线程,假如有,则使用空闲的核心线程执行新的任务。

2)queue-capacity:队列容量,假如核心线程数已达到core-size设置的值,并且所有的核心线程都在忙,再来新的任务,会将任务存储到任务队列。

3)max-size: 最大线程数,当任务队列已满,核心线程也都在忙,再来新的任务则会创建新的线程,但所有线程数不能超过max-size设置的值,否则可能会出现异常(拒绝执行)

4)keep-alive:线程空闲时间,假如池中的线程数多余core-size设置的值,此时又没有新的任务,则一旦空闲线程空闲时间超过keep-alive设置的时间值,则会被释放。

5)thread-name-prefix:线程名的前缀,项目中设置线程名的目的主要是为了对线程进行识别,一旦出现线程问题,可以更好的定位问题。

Spring 中的事务处理

事务业务描述

事务是一个不可分割逻辑工作单元,是一个业务,事务的处理通常要结合业务进行落地的实现。进而更好保证业务的完整性(要么都成功,要么都失败)。

Spring 中的事务控制

Spring中的事务控制,推荐在业务层基于AOP方式进行实现,这样可以将事务逻辑与业务逻辑进行更好的解耦,同时可以重用事务逻辑代码.进而简化大量事务模板代码的编写.

SpringBoot工程中的事务控制,可以直接在需要进行事务控制的类或业务方法上通过@Transaction注解描述即可,由此注解描述的方法为事务切入点方法,底层在切入点方法执行时会通过“通知方法”进行事务逻辑增强,示例代码如下:

@Transactional
public int updateObject(...){
   ....
}

当一个类中有个方法都需要事务控制,我们可以使用@Transactional注解对类进行描述,示例代码如下:

@Transactional
public class XxxServiceImpl implements XxxService{}
Spring 中的事务属性分析

在使用@Transactional描述类或方法时候,还可以指定一些事务属性,例如:

  1. readOnly 用于描述此事务是否为只读事务,默认值是false(表示不是只读事务),对于查询而言建议设置值为true.
  2. timeout 事务的超时时间,超过设置的时间会抛出异常,默认为-1(不超时,实际项目中建议设置超时时间)。
  3. rollbackFor 设置出现什么异常时要回滚事务(默认为RuntimeException)。
  4. isolation 设置事务并发执行时的隔离级别(隔离级别越高,数据正确性越好,但并发越差)。

5)propagation 设置事务的传播特性(默认值为Propagation.REQUIRED),不同业务对象之间的方法出现相互调用时,事务的执行策略。REQUIRED表示参与到调用者的事务中去,其它选项自行查阅。

案例分析:

@Transactional(readOnly = false,
               rollbackFor = Throwable.class,
               isolation = Isolation.READ_COMMITTED,
               timeout = 5,
               propagation= Propagation.REQUIRED)
public class XxxServiceImpl implements XxxService{}

说明,假如类和方法上都定义了事务特性,那方法上定义的事务特性优先级比较高。

总结(Summary)

重难点分析

  • AOP 是什么,解决了什么问题,应用场景?
  • AOP 编程基本步骤及实现过程(以基于 AspectJ 框架实现为例)。
  • AOP 编程中的核心对象及应用关系。(代理对象,切面对象,通知,切入点)
  • AOP 思想在 Spring 中的实现原理分析。(基于代理方式进行扩展业务的织入)
  • AOP 应用中切入点表达式的定义方式(bean,@annotation)
  • AOP 应用中通知的应用类型以及执行时间点(@Around,@Before,@AfterReturning,@AfterThrowing,@After)
  • AOP 中切面的优先级设置?(@Order)
  • AOP中异步操作应用场景及配置过程,基本原理.
  • AOP中事务的控制方式,配置过程,基本原理.

FAQ分析

  • AOP中切面的定义需要注意什么?(@Aspect,@Component)
  • AOP 切面中都需要定义什么?(切入点,通知方法)
  • AOP中切面可以有多个吗?(可以)
  • AOP中多个切面可以作用于同一个切入点方法吗?(可以)
  • AOP中定义切入点的方式有哪些?
  • AOP中通知方法的应用类型哪些,分别是什么时间点执行?
  • SpringBoot中默认AOP代理对象创建方式?(CGLIB-更加灵活)
  • AOP方式进行异步操作时的一个基本步骤是什么?
  • AOP方式的事务控制注解(@Transaction)都有什么属性?

BUG分析

  • 切入点引入错误?
  • 事务超时




5.Shiro框架在项目中的应用

Shiro 框架简介

Shiro 概述

Shiro 是Apache公司推出一个权限管理框架,其内部封装了项目中认证,授权,加密,会话等逻辑操作,通过Shiro框架可以简化我们项目权限控制逻辑的代码的编写。其认证和授权业务分析,如图所示:

业务日志系统日志 业务日志数据表设计_当前页_34

Shiro 框架概要架构

Shiro 框架中主要通过Subject,SecurityManager,Realm对象完整认证和授权业务,其简要架构如下:

业务日志系统日志 业务日志数据表设计_java_35

其中:

  • Subject 此对象负责提交用户身份、权限等信息
  • SecurityManager 负责完成认证、授权等核心业务
  • Realm 负责通过数据逻辑对象获取数据库或文件中的数据。

Shiro 框架详细架构分析

Shiro 框架进行权限管理时,要涉及到的一些核心对象,主要包括:认证管理对象,授权管理对象,会话管理对象,缓存管理对象,加密管理对象以及 Realm 管理对象(领域对象:负责处理认证和授权领域的数据访问题)等,其具体架构如图- 所示:

业务日志系统日志 业务日志数据表设计_当前页_36

其中:

  1. Subject(主体):与软件交互的一个特定的实体(用户、第三方服务等)。
  2. SecurityManager(安全管理器) :Shiro 的核心,用来协调管理组件工作。
  3. Authenticator(认证管理器):负责执行认证操作。
  4. Authorizer(授权管理器):负责授权检测。
  5. SessionManager(会话管理):负责创建并管理用户 Session 生命周期,提供一

个强有力的 Session 体验。

  1. SessionDAO:代表 SessionManager 执行 Session 持久(CRUD)动作,它允

许任何存储的数据挂接到 session 管理基础上。

  1. CacheManager(缓存管理器):提供创建缓存实例和管理缓存生命周期的功能。
  2. Cryptography(加密管理器):提供了加密方式的设计及管理。
  3. Realms(领域对象):是 shiro 和你的应用程序安全数据之间的桥梁。

Shiro 框架基础配置

Shiro 依赖

在项目中添加Shiro相关依赖(参考官网http://shiro.apache.org/spring-boot.html),假如项目中添加过shiro-spring依赖,将shiro-spring依赖替换掉即可。

<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-spring-boot-web-starter</artifactId>
    <version>1.7.0</version>
</dependency>

说明,添加完此依赖,直接启动项目会启动失败,还需要额外的配置。

Shiro 基本配置

第一步:创建一个Realm类型的实现类(基于此类通过DAO访问数据库),关键代码如下:

package com.cy.pj.sys.service.realm;
public class ShiroRealm extends AuthorizingRealm {
    /**此方法负责获取并封装授权信息*/
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(
             PrincipalCollection principalCollection) {
        return null;
    }
    /**此方法负责获取并封装认证信息*/
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(
         AuthenticationToken authenticationToken) throws AuthenticationException {
        return null;
    }
}

第二步:在项目启动类中添加Realm对象配置,关键代码如下:

@Bean
public Realm realm(){//org.apache.shiro.realm.Realm
 return new ShiroRealm();
}

第三步:在启动类中定义过滤规则(哪些访问路径要进行认证才可以访问),关键代码如下:

@Bean
    public ShiroFilterChainDefinition shiroFilterChainDefinition() {
        DefaultShiroFilterChainDefinition chainDefinition = 
               new DefaultShiroFilterChainDefinition();
        LinkedHashMap<String,String> map=new LinkedHashMap<>();
        //设置允许匿名访问的资源路径(不需要登录即可访问)
        map.put("/bower_components/**","anon");//anon对应shiro中的一个匿名过滤器
        map.put("/build/**","anon");
        map.put("/dist/**","anon");
        map.put("/plugins/**","anon");
        //设置需认证以后才可以访问的资源(注意这里的顺序,匿名访问资源放在上面)
        map.put("/**","authc");//authc 对应一个认证过滤器,表示认证以后才可以访问
        chainDefinition.addPathDefinitions(map);
        return chainDefinition;
    }

第四步:配置认证页面(登录页面)

在spring的配置文件(application.yml)中,添加登录页面的配置,关键代码如下:

shiro:
   loginUrl: /login.html

其中,login.html页面为项目中static目录定义好的一个页面。

第五步:启动服务进行访问测试

打开浏览器,输入http://localhost/doIndexUI检测是否会出现登录窗口,如图所示:

业务日志系统日志 业务日志数据表设计_页面加载_37

Shiro认证业务分析及实现

认证流程分析

当我们在登录页面,输入用户信息,提交到服务端进行认证,其中shiro框架的认证时序如图所示:

业务日志系统日志 业务日志数据表设计_java_38

其中:

  1. token :封装用户提交的认证信息(例如用户名和密码)的一个对象。
  2. Subject: 负责将认证信息提交给SecurityManager对象的一个主体对象。
  3. SecurityManager是shiro框架的核心,负责完成其认证、授权等业务。
  4. Authenticator 认证管理器对象,SecurityManager继承了此接口。
  5. Realm 负责从数据库获取认证信息并交给认证管理器。

Shiro框架认证业务实现

第一步:在SysUserDao中定义基于用户名查询用户信息的方法,关键代码如下:

@Select("select * from sys_users where username=#{username}")
SysUser findUserByUsername(String username);

第二步:修改ShiroRealm中获取认证信息的方法,关键代码如下:

@Autowired
 private SysUserDao sysUserDao;
 @Override
 protected AuthenticationInfo doGetAuthenticationInfo(
            AuthenticationToken authenticationToken) throws AuthenticationException {
         //1.获取用户提交的认证用户信息
        UsernamePasswordToken upToken=(UsernamePasswordToken) authenticationToken;
        String username=upToken.getUsername();
         //2.基于用户名查询从数据库用户信息
        SysUser sysUser = sysUserDao.findUserByUsername(username);
        //3.判断用户是否存在
        if(sysUser==null) throw new UnknownAccountException();//账户不存在
         //4.判断用户是否被禁用
        if(sysUser.getValid()==0)throw new LockedAccountException();
         //5.封装认证信息并返回
        ByteSource credentialsSalt=
                ByteSource.Util.bytes(sysUser.getSalt());
         SimpleAuthenticationInfo info=
                new SimpleAuthenticationInfo(
                        sysUser, //principal 传入的用户身份
                        sysUser.getPassword(),//hashedCredentials
                        credentialsSalt,//credentialsSalt
                        getName());
        return info;//返回给认证管理器
    }

第三步:在ShiroRealm中重谢获取凭证加密算法的方法,关键代码如下:

@Override
    public CredentialsMatcher getCredentialsMatcher() {
        HashedCredentialsMatcher matcher=new HashedCredentialsMatcher();
        matcher.setHashAlgorithmName("MD5");//加密算法 
        matcher.setHashIterations(1);//加密次数
        return matcher;
    }

第四步:在SysUserController中添加处理登录请求的方法,关键代码如下:

@RequestMapping("doLogin")
public JsonResult doLogin(String username,String password){
   UsernamePasswordToken token = new UsernamePasswordToken(username, password);
   Subject currentUser = SecurityUtils.getSubject(); 
   currentUser.login(token);
   return new JsonResult("login ok");
}

第五步:统一异常处理类中添加shiro异常处理代码,关键如下:

@ExceptionHandler(ShiroException.class)
 public JsonResult doShiroException(ShiroException e){
       JsonResult r=new JsonResult();
       r.setState(0);
       if(e instanceof UnknownAccountException){
           r.setMessage("用户名不存在");
       }else if(e instanceof IncorrectCredentialsException){
           r.setMessage("密码不正确");
       }else if(e instanceof LockedAccountException){
           r.setMessage("账户被锁定");
       }else if(e instanceof AuthorizationException){
           r.setMessage("没有权限");
       }else{
           r.setMessage("认证或授权失败");
       }
       return r;
    }

第五步:在过滤配置中允许登录时的url匿名访问,关键代码如下:

...
map.put("/user/doLogin","anon");
...

第六步:再过滤配置中配置登出url操作,关键代码如下:

..
map.put("/doLogout","logout");//logout是shiro框架给出一个登出过滤器
...

第六步:启动服务器,进行登录访问测试

业务日志系统日志 业务日志数据表设计_spring_39

第七步:Shiro框架认证流程总结分析

  • Step01:登录客户端(login.html)中的用户输入的登录信息提交SysUserController对象
  • Step02:SysUserController对象基于doLogin方法处理登录请求.
  • Step03:SysUserController中的doLogin方法将用户信息封装token中, 然后基于subject对象将token提交给SecurityManager对象。
  • Step04:SecurityManager对象调用认证方法(authenticate)去完成认证,在此方法内部会调用ShiroRealm中的doGetAuthenticationInfo获取数据库中的用户信息,然后再与客户端提交的token中的信息进行比对,比对时会调用getCredentialsMatcher方法获取凭证加密对象,通过此对象对用户提交的token中的密码进行加密。

Shiro授权业务分析及实现

授权流程分析

已认证用户,在进行系统资源的访问时,我们还要检查用户是否有这个资源的访问权限。并不是所有认证用户都可以访问系统内所有资源,也应该是受限访问的,具体授权流程如图所示:

业务日志系统日志 业务日志数据表设计_spring_40

Shiro框架中的授权实现

第一步: 在SysMenuDao中定义查询用户权限标识的方法,关键代码分析:

Set<String> findUserPermissions(Integer userId);

第二步:在SysMenuMapper中添加查询用户权限标识的SQL映射,关键代码如下:

<select id="findUserPermissions" resultType="string">
 select distinct permission
 from sys_user_roles ur join sys_role_menus rm join sys_menus m
 on ur.role_id=rm.role_id and rm.menu_id=m.id
 where ur.user_id=#{userId} and m.permission is not null and  trim(m.permission)!='' 
</select>

第三步:修改ShiroRealm中获取权限并封装权限信息的方法,关键代码如下

@Override
    protected AuthorizationInfo doGetAuthorizationInfo(
            PrincipalCollection principalCollection) {
        //1.获取登录用户(登录时传入的用户身份是谁)
        SysUser user= (SysUser) principalCollection.getPrimaryPrincipal();
        //2.基于登录用户id获取用户权限标识
        Set<String> stringPermissions=
        sysMenuDao.findUserPermissions(user.getId());
        //3.封装数据并返回
        SimpleAuthorizationInfo info=new SimpleAuthorizationInfo();
        info.setStringPermissions(stringPermissions);
        return info;//返回给授权管理器
    }

第四步:定义授权切入点方法,示例代码如下:

在shiro框架中,授权切入点方法需要通过@RequiresPermissions注解进行描述,例如:

@RequiresPermissions("sys:user:update")
    public int validById(Integer id,Integer valid){
        int rows=sysUserDao.validById(id,valid);
        if(rows==0)throw new ServiceException("记录可能已经不存在");
        return rows;
    }

其中, @RequiresPermissions注解中定义的内容为,访问此方法需要的权限.

第五步:启动服务进行访问测试和原理分析

在访问时首先要检测一下用户有什么权限,检测过程,先查询用户有什么角色,再查看角色有什么菜单的访问权限.

授权原理分析:(底层基于AOP实现)

  • Step01 页面上用户通过菜单触发对服务端资源的访问.
  • Step02 服务端Controller处理客户端的资源访问请求
  • Step03 假如客户端请求访问的资源业务放上有@RequiresPermissions注解描述则底层Controller对象会调用Service的代理对象,代理对象会调用AOP中通知方法,在通知方法中获取@RequiresPermissions上的定义的权限标识.
  • Step04 通过Subject 对象提交@RequiresPermissions注解中的授权标识给SecurityManager对象,此对象会调用ShiroRealm中的获取用户权限的方法,最终会将从数据权限信息与@RequiresPermissions中的定义的权限信息做一个比对.

Shiro认证授权业务进阶实现

呈现登录用户信息

Controller 方法定义及实现

修改PageController中的doIndex方法,关键代码如下:

@GetMapping("doIndexUI")
 public String doIndexUI(Model model){
        //获取登录用户信息(shiro框架给出的固定写法)
        SysUser user=(SysUser)
        SecurityUtils.getSubject().getPrincipal();
        //存储登录用户信息
        model.addAttribute("username", user.getUsername());
        return "starter";
 }
页面Thymeleaf 表达式应用

打开starter.html页面,找到用户名对应的位置,然后通过[[${}]]表达式获取服务端model中数据,呈现在页面上,关键代码如下:

<span class="hidden-xs" id="loginUserId">[[${username}]]</span>

用户菜单的动态化呈现

业务分析

我们希望不同登录用户,登录系统以后,看到的用户菜单是不一样的。登录用户只能看到自己可以访问的一些菜单选项。

服务端设计及实现

第一步:定义pojo对象存储用户菜单信息,关键代码如下:

package com.cy.pj.sys.pojo;
@Data
public class SysUserMenu implements Serializable{
    private static final long serialVersionUID = -410105494012229800L;
    private Integer id;
    private String name;
    private String url;
    private List<SysUserMenu> childMenus;
}

第二步:在SysMenuDao中定义查询用户一级和二级菜单信息的方法,关键代码如下

List<SysUserMenu> findUserMenus(Integer userId);

第三步:在SysMenuMapper中定义查询用户一级和二级菜单信息时对应的sql映射,关键代码如下:

<select id="findUserMenus" resultMap="sysUserMenu">
	select p.id,p.name,p.url,c.id cid,c.name cname,c.url curl
	from sys_menus p left join sys_menus c
	on p.id=c.parentId
	where p.parentId is null and c.id in (
     	select rm.menu_id
     	from sys_user_roles ur join sys_role_menus rm
     	on ur.role_id=rm.role_id
     	where ur.user_id=#{userId}
	)
</select>
<resultMap id="sysUserMenu" type="com.cy.pj.sys.pojo.SysUserMenu">
     <id property="id" column="id"/>
     <result property="name" column="name"/>
     <result property="url" column="url"/>
     <collection property="childMenus" ofType="com.cy.pj.sys.pojo.SysUserMenu">
          <id property="id" column="cid"/>
          <result property="name" column="cname"/>
          <result property="url" column="curl"/>
     </collection>
</resultMap>

第四步:在SysMenuService接口中添加查询用户菜单信息的方法,关键代码如下:

List<SysUserMenu> findUserMenus(Integer userId);

第五步:在SysMenuServiceImpl类中添加查询用户菜单信息的方法,关键代码如下:

public List<SysUserMenu> findUserMenus(Integer userId){
    return sysMenuDao.findUserMenus(userId);
}

第六步:修改PageController中的doIndexUI方法,关键代码如下:

@Autowired
    private SysMenuService sysMenuService;
    @GetMapping("doIndexUI")
    public String doIndexUI(Model model){
        //获取登录用户信息(shiro框架给出的固定写法)
        SysUser user=(SysUser)
        SecurityUtils.getSubject().getPrincipal();
        //存储登录用户信息
        model.addAttribute("username", user.getUsername());
        //查询用户菜单
        List<SysUserMenu> userMenus=
        sysMenuService.findUserMenus(user.getId());
        model.addAttribute("userMenus", userMenus);
        return "starter";
    }
客户端设计及实现

第一步:修改starter页面菜单呈现部分的内容,关键代码如下:

<li class="treeview" th:each="p:${userMenus}">
          <a href="#"><i class="fa fa-link"></i> 
          <span>[[${p.name}]]</span>
            <span class="pull-right-container">
                <i class="fa fa-angle-left pull-right"></i>
              </span>
          </a>
          <ul class="treeview-menu">
            <li th:each="c:${p.childMenus}">
            <a th:onclick="doLoadRS([[${c.url}]])">[[${c.name}]]</a></li>
          </ul>
</li>

第二步:添加菜单事件处理函数,关键代码如下:

function doLoadRS(url){
     $("#mainContentId").load(url);
  }

第三步:启动服务进行访问测试:

。。。。。

密码修改业务的设计及实现

修改登录用户密码(参考课前资料用户模块)-作业

总结(Summary)

重难点分析

  • Shiro的基本配置(参考官网)
  • Shiro的认证流程及实现过程
  • Shiro的授权流程及实现过程

FAQ 分析

  • Shiro框架的核心组件?(Subject,SecurityManager,Realm)
  • Shiro框架中的认证流程是怎样的?(底层基于什么机制去实现的)
  • Shiro框架中的授权实现原理是怎样的?(AOP)