1

问题起源

最近因为自己在给公司开发新的管理系统,参考了开源系统若依中的很多地方,尤其是若依系统中的菜单管理,角色管理,用户管理部分。在学习研究这几个版块时,发现一个比较“怪”的现象,在若依系统中添加操作,通常我们称为add编辑操作,通常称为update,在其系统中大多数,或者至少目前看到的情况均是拆分开的两个独立接口。

例如,添加菜单与更新菜单的接口:

添加菜单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 新增菜单
*/
@PreAuthorize("@ss.hasPermi('system:menu:add')")
@Log(title = "菜单管理", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@Validated @RequestBody SysMenu menu)
{
if (UserConstants.NOT_UNIQUE.equals(menuService.checkMenuNameUnique(menu)))
{
return AjaxResult.error("新增菜单'" + menu.getMenuName() + "'失败,菜单名称已存在");
}
else if (UserConstants.YES_FRAME.equals(menu.getIsFrame()) && !StringUtils.ishttp(menu.getPath()))
{
return AjaxResult.error("新增菜单'" + menu.getMenuName() + "'失败,地址必须以http(s)://开头");
}
menu.setCreateBy(getUsername());
return toAjax(menuService.insertMenu(menu));
}

更新菜单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 修改菜单
*/
@PreAuthorize("@ss.hasPermi('system:menu:edit')")
@Log(title = "菜单管理", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@Validated @RequestBody SysMenu menu)
{
if (UserConstants.NOT_UNIQUE.equals(menuService.checkMenuNameUnique(menu)))
{
return AjaxResult.error("修改菜单'" + menu.getMenuName() + "'失败,菜单名称已存在");
}
else if (UserConstants.YES_FRAME.equals(menu.getIsFrame()) && !StringUtils.ishttp(menu.getPath()))
{
return AjaxResult.error("修改菜单'" + menu.getMenuName() + "'失败,地址必须以http(s)://开头");
}
else if (menu.getMenuId().equals(menu.getParentId()))
{
return AjaxResult.error("修改菜单'" + menu.getMenuName() + "'失败,上级菜单不能选择自己");
}
menu.setUpdateBy(getUsername());
return toAjax(menuService.updateMenu(menu));
}

又比如添加角色与更新角色的接口:

添加角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 新增角色
*/
@PreAuthorize("@ss.hasPermi('system:role:add')")
@Log(title = "角色管理", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@Validated @RequestBody SysRole role)
{
if (UserConstants.NOT_UNIQUE.equals(roleService.checkRoleNameUnique(role)))
{
return AjaxResult.error("新增角色'" + role.getRoleName() + "'失败,角色名称已存在");
}
else if (UserConstants.NOT_UNIQUE.equals(roleService.checkRoleKeyUnique(role)))
{
return AjaxResult.error("新增角色'" + role.getRoleName() + "'失败,角色权限已存在");
}
role.setCreateBy(getUsername());
return toAjax(roleService.insertRole(role));

}

更新角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/**
* 修改保存角色
*/
@PreAuthorize("@ss.hasPermi('system:role:edit')")
@Log(title = "角色管理", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@Validated @RequestBody SysRole role)
{
roleService.checkRoleAllowed(role);
if (UserConstants.NOT_UNIQUE.equals(roleService.checkRoleNameUnique(role)))
{
return AjaxResult.error("修改角色'" + role.getRoleName() + "'失败,角色名称已存在");
}
else if (UserConstants.NOT_UNIQUE.equals(roleService.checkRoleKeyUnique(role)))
{
return AjaxResult.error("修改角色'" + role.getRoleName() + "'失败,角色权限已存在");
}
role.setUpdateBy(getUsername());

if (roleService.updateRole(role) > 0)
{
// 更新缓存用户权限
LoginUser loginUser = getLoginUser();
if (StringUtils.isNotNull(loginUser.getUser()) && !loginUser.getUser().isAdmin())
{
loginUser.setPermissions(permissionService.getMenuPermission(loginUser.getUser()));
loginUser.setUser(userService.selectUserByUserName(loginUser.getUser().getUserName()));
tokenService.setLoginUser(loginUser);
}
return AjaxResult.success();
}
return AjaxResult.error("修改角色'" + role.getRoleName() + "'失败,请联系管理员");
}

在比如用户版块:

添加用户

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
* 新增用户
*/
@PreAuthorize("@ss.hasPermi('system:user:add')")
@Log(title = "用户管理", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@Validated @RequestBody SysUser user)
{
if (UserConstants.NOT_UNIQUE.equals(userService.checkUserNameUnique(user.getUserName())))
{
return AjaxResult.error("新增用户'" + user.getUserName() + "'失败,登录账号已存在");
}
else if (StringUtils.isNotEmpty(user.getPhonenumber())
&& UserConstants.NOT_UNIQUE.equals(userService.checkPhoneUnique(user)))
{
return AjaxResult.error("新增用户'" + user.getUserName() + "'失败,手机号码已存在");
}
else if (StringUtils.isNotEmpty(user.getEmail())
&& UserConstants.NOT_UNIQUE.equals(userService.checkEmailUnique(user)))
{
return AjaxResult.error("新增用户'" + user.getUserName() + "'失败,邮箱账号已存在");
}
user.setCreateBy(getUsername());
user.setPassword(SecurityUtils.encryptPassword(user.getPassword()));
return toAjax(userService.insertUser(user));
}

更新用户

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 修改用户
*/
@PreAuthorize("@ss.hasPermi('system:user:edit')")
@Log(title = "用户管理", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@Validated @RequestBody SysUser user)
{
userService.checkUserAllowed(user);
if (StringUtils.isNotEmpty(user.getPhonenumber())
&& UserConstants.NOT_UNIQUE.equals(userService.checkPhoneUnique(user)))
{
return AjaxResult.error("修改用户'" + user.getUserName() + "'失败,手机号码已存在");
}
else if (StringUtils.isNotEmpty(user.getEmail())
&& UserConstants.NOT_UNIQUE.equals(userService.checkEmailUnique(user)))
{
return AjaxResult.error("修改用户'" + user.getUserName() + "'失败,邮箱账号已存在");
}
user.setUpdateBy(getUsername());
return toAjax(userService.updateUser(user));
}

猜测其接口拆分的原因

更新接口与添加接口,一般都会有很多判断的先决条件,如判断数据是否合法,包括手机号唯一性,邮箱账号唯一性,菜单名称唯一性等等,这些通常无论更新与添加都是需要判断的。

以菜单接口为例,其添加接口判断参数合法性包括:菜单名称唯一,外链数据合法性;

菜单更新接口除判断这两个数据外,还需要判断更新时所选择的上级菜单不是自己;

另外菜单添加与更新的区别是,菜单添加设置的字段为createBycreateTime,而更新菜单时,需要设置的是updateByupdateTime

更新角色与添加角色的区别在于,更新角色时,还需要通过SpringSecurity来更新用户的权限数据。

添加用户时需要设置其密码字段,而更新用户时则不需要。

我们分析,其将接口拆分的主要原因是,这样会使得程序逻辑更加的简单,清晰,而不用每次需要什么操作都额外判断添加与更新问题。

1

问题起源

最近在给公司的后台管理系统添加用户管理,部门管理,角色管理等等功能,设计到一个很重要的显示是,树状Tree来显示部门层级结构,如下图:

image.png

而数据库mysql中的数据是这样的:

image-20220413211919160

是二维的,扁平的,一行一行的,准确的说,检索出来是一个条状的list,这个list通过parent_id有所关联。

那么,如何将其转换为树状层级结构呢?

通常由两种方式,一种是后台转换,在查询的时候,通过后台程序构造树状结构关联。一种是前端转换,通过javascript来将后端返回的列表list转换为一颗或多颗树。

后台转换

逐层查找

我们先来看看如何通过后端查询来进行转换:

首先查询根部节点:

1
2
3
4
5
6
7
public List<Record> getDeptTree() {
List<Record> orgs = Db.find("SELECT dept_id as id, dept_name as `label` FROM sys_dept WHERE parent_id = 0 ORDER by order_num ASC ");
for (Record o : orgs) {
buildChildren(o);
}
return orgs;
}

重点关注其中的方法buildChildren(Record dept),这是一个递归函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private void buildChildren(Record org) {
List<Record> children = getOrgChildren(org.getInt("id"));
if (!children.isEmpty()) {
for (Record r : children) {
buildChildren(r);
}
org.set("children", children);
}
}

private List<Record> getOrgChildren(Integer id) {
List<Record> children = Db.find("SELECT dept_id as id, dept_name as `label` FROM sys_dept WHERE parent_id = ? ORDER by order_num ASC , id);
return children;
}

如此便将其数据从一行一行的数据表中构造为树状结构,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
[
{
"id": 100,
"label": "若依科技",
"children": [
{
"id": 101,
"label": "深圳总公司",
"children": [
{
"id": 103,
"label": "研发部门"
},
{
"id": 104,
"label": "市场部门"
},
{
"id": 105,
"label": "测试部门"
},
{
"id": 106,
"label": "财务部门"
},
{
"id": 107,
"label": "运维部门"
}
]
},
{
"id": 102,
"label": "长沙分公司",
"children": [
{
"id": 108,
"label": "市场部门"
},
{
"id": 109,
"label": "财务部门"
}
]
}
]
}
]

上述算法是我们在自己的数据中重新实现的,

若依中的实现似乎是另一种方式。

检索全部数据算法构造

即一次性检索出全部的数据,然后通过算法来构造树状结构。若依中便是这样来实现的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Override
public List<TreeSelect> buildDeptTreeSelect(List<SysDept> depts)
{
List<SysDept> deptTrees = buildDeptTree(depts);
return deptTrees.stream().map(TreeSelect::new).collect(Collectors.toList());
}

/**
* 构建前端所需要树结构
*
* @param depts 部门列表
* @return 树结构列表
*/
@Override
public List<SysDept> buildDeptTree(List<SysDept> depts)
{
List<SysDept> returnList = new ArrayList<SysDept>();
List<Long> tempList = new ArrayList<Long>();
for (SysDept dept : depts)
{
tempList.add(dept.getDeptId());
}
for (Iterator<SysDept> iterator = depts.iterator(); iterator.hasNext();)
{
SysDept dept = (SysDept) iterator.next();
// 如果是顶级节点, 遍历该父节点的所有子节点
if (!tempList.contains(dept.getParentId()))
{
recursionFn(depts, dept);
returnList.add(dept);
}
}
if (returnList.isEmpty())
{
returnList = depts;
}
return returnList;
}

其中的判断语句:

1
if (!tempList.contains(dept.getParentId())) { ...将节点添加到返回列表中 }

判断的是,当前列表中是否有其父节点,如果某有,说明该节点是最顶级的节点。

再来看其中的递归方法recursionFn(depts, dept)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 递归列表
*/
private void recursionFn(List<SysDept> list, SysDept t)
{
// 得到子节点列表
List<SysDept> childList = getChildList(list, t);
t.setChildren(childList);
for (SysDept tChild : childList)
{
if (hasChild(list, tChild))
{
recursionFn(list, tChild);
}
}
}

这里的list总是完整检索的数据,而参数t表示以t为父节点的所有子节点。

获取节点子节点的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 得到子节点列表
*/
private List<SysDept> getChildList(List<SysDept> list, SysDept t)
{
List<SysDept> tlist = new ArrayList<SysDept>();
Iterator<SysDept> it = list.iterator();
while (it.hasNext())
{
SysDept n = (SysDept) it.next();
if (StringUtils.isNotNull(n.getParentId()) && n.getParentId().longValue() == t.getDeptId().longValue())
{
tlist.add(n);
}
}
return tlist;
}

总结

两种方式各有优劣,第二种方式数据库检索次数少,每次检索子节点不需要向数据库发起请求,但是其需要满足能够一次性查询出所有的数据;第一种方式数据库查询次数多,但是如果仅知道一个父节点id通过这种方式来构造数据比较直观,尤其是当你的数据库层级结构设计上没有ancestors字段时。

1

问题起源

最近发现若依系统的用户、角色以及关联的菜单使用非常不错,想也拷贝一份在自己的系统中,因此学习一番。

角色表设计

若依系统中的角色表涉及role字段相关,包括主表sys_role, 以及三个关联表:sys_role_dept,sys_role_menu,sys_user_role,分别表示角色与部门的关联,角色与菜单的关联,角色与用户的关联。

sys_role

sys_role是角色主表,如图所示:

image-20220412144453030

比较有特色的设计字段包括:

role_key: 角色权限字符串;

data_scope:数据范围(1:全部数据权限 2:自定数据权限 3:本部门数据权限 4:本部门及以下数据权限)

menu_check_strictly: 菜单树是否关联显示

dept_check_strictly:部门树选择项是否关联显示

创建与编辑角色的页面如下:

image-20220412144734553

sys_role_dept

该表为角色与部门的关联表,是若依中存在的一个被称为“数据权限”的东西,给角色分配数据权限的页面如下:

image-20220412144838441

sys_role_menu

该表为角色与菜单的管理表,是创建角色时选择的菜单列表。

image-20220412150428686

sys_user_role

该表为角色与用户的关联表,一个用户可以用有多个角色,一个角色也可以用多个用户,用户与角色是多对多的关系。

分配角色有以下两个操作页面,分别表示给角色分配用户,以及给用户分配角色:

image-20220412150609295

image-20220412150631306

页面与接口

问题起源

最近在从若依系统中研究其管理系统设计,例如,最近在学习其中的菜单管理功能,其中涉及到“添加”菜单功能,感觉其代码实现具有一定的参考意义,特来总结学习一下。

若依系统中的add操作

我们先来看看若依系统中添加add操作是如何设计的。

先来看看后端接口,以创建菜单为例,接口位置位于ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysMenuController中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 新增菜单
*/
@PreAuthorize("@ss.hasPermi('system:menu:add')")
@Log(title = "菜单管理", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@Validated @RequestBody SysMenu menu)
{
if (UserConstants.NOT_UNIQUE.equals(menuService.checkMenuNameUnique(menu)))
{
return AjaxResult.error("新增菜单'" + menu.getMenuName() + "'失败,菜单名称已存在");
}
else if (UserConstants.YES_FRAME.equals(menu.getIsFrame()) && !StringUtils.ishttp(menu.getPath()))
{
return AjaxResult.error("新增菜单'" + menu.getMenuName() + "'失败,地址必须以http(s)://开头");
}
menu.setCreateBy(getUsername());
return toAjax(menuService.insertMenu(menu));
}
  1. 接口权限控制:

    注解部分权限控制,需要有权限system:menu:add

1
@PreAuthorize("@ss.hasPermi('system:menu:add')")
  1. 日志记录
1
@Log(title = "菜单管理", businessType = BusinessType.INSERT)
  1. 接口映射:PostMapping,表明该方法承接默认的POST请求
1
@PostMapping

我们再来看方法体内部,最后一句:

1
return toAjax(menuService.insertMenu(menu));

其对应的方法体为:

1
2
3
4
5
6
7
8
9
10
11
/**
* 新增保存菜单信息
*
* @param menu 菜单信息
* @return 结果
*/
@Override
public int insertMenu(SysMenu menu)
{
return menuMapper.insertMenu(menu);
}

即最后一句为实际插入的SQL执行语句。

我们更关心在执行插入前,数据做了哪些检查:

添加前的检查工作

  1. name的唯一性

其代码为:

1
2
3
4
if (UserConstants.NOT_UNIQUE.equals(menuService.checkMenuNameUnique(menu)))
{
return AjaxResult.error("新增菜单'" + menu.getMenuName() + "'失败,菜单名称已存在");
}

见字知意,我们拆开其方法checkMenuNameUnique体内部:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 校验菜单名称是否唯一
*
* @param menu 菜单信息
* @return 结果
*/
@Override
public String checkMenuNameUnique(SysMenu menu)
{
Long menuId = StringUtils.isNull(menu.getMenuId()) ? -1L : menu.getMenuId();
SysMenu info = menuMapper.checkMenuNameUnique(menu.getMenuName(), menu.getParentId());
if (StringUtils.isNotNull(info) && info.getMenuId().longValue() != menuId.longValue())
{
return UserConstants.NOT_UNIQUE;
}
return UserConstants.UNIQUE;
}

其判断重复的算法是:首先根据当前给定的名称与区域范围(我所指的是给定的parentId,即相当于在某个目录下添加菜单时,该目录下不应该有重名的菜单,而非目录下的菜单是允许重复的),查找是否有“menu”,再判断给定预创建的对象与查询出的结果的唯一标识是否一致或者查询出的结果为空,即语句:

1
StringUtils.isNotNull(info) && info.getMenuId().longValue() != menuId.longValue()
  1. 数据的合法性

除检查名称是否重复外,还需要检查给定的数据是否合法,例如下面这行代码:

1
2
3
4
else if (UserConstants.YES_FRAME.equals(menu.getIsFrame()) && !StringUtils.ishttp(menu.getPath()))
{
return AjaxResult.error("新增菜单'" + menu.getMenuName() + "'失败,地址必须以http(s)://开头");
}

即:在设定对象为外链时,则给定的地址就应该是以http开头,否则数据不合法,也不能创建成功。

  1. 是否有相关联的操作

这里的关联操作,我以若依中编辑菜单为例:

1
2
3
4
else if (menu.getMenuId().equals(menu.getParentId()))
{
return AjaxResult.error("修改菜单'" + menu.getMenuName() + "'失败,上级菜单不能选择自己");
}

这个ParentId对应选择父级节点的操作,如图:

image-20220412124336776

同样的,删除菜单时也会有相关联的数据,比如菜单或目录中有子节点,则该菜单就不允许直接删除:

1
2
3
4
if (menuService.hasChildByMenuId(menuId))
{
return AjaxResult.error("存在子菜单,不允许删除");
}

总结

因此,在插入/更新/删除数据时,至少要考虑这几个方面:

  1. 某些字段的唯一性,比如名称;
  2. 数据的合法性,或者相互关联的字段的合法性判断,这一点大概是很多程序中比较耗时耗力;
  3. 数据的关联性,比如与其他数据有关联的数据不能删除,数据不能以自己为父节点等等逻辑。

小国无罪,恃实其罪。(小国没有罪,将国家安全完全依赖大国而自己毫无防备才真正是它的罪过)——古文观止

问题起源

最近接手的某款办公软件,其中要添加一些额外的功能,涉及到树状层级的部门,人员查找。又最近在学习若依管理系统,其中也设计到层级的部门管理,查看了其中的部门表设计与一些SQL编写,发掘有一些小小的差异,即可带来检索与查找的极大遍历。容我们慢慢道来。

若依中的dept表设计

若依系统中的sys_dept表设计字段如下:

image-20220413095230338

若依系统中添加部门的操作页面如下:

image-20220413095443654

我们选几个重点字段说一下:

  1. parent_id字段:表明当前部门的上级部门节点id;
  2. order_num:表明当前部门在其上级部门下的排序次序;
  3. ancestors: 注释为“祖级列表”,我们来看一下表中的数据以及对应的真实的数据结构:

比如,若依中默认的部门级结构如下:

image-20220413095625266

我们来看一下其中”若依科技->深圳总公司->研发部门”的数据库具体数据,尤其是ancestors数据是什么样的:

image-20220413095753417

如图所示,研发部门的ancestors数据为“0,100,101”,0为所有部门父级,不表示具体部门,如果一个部门的父级只有0,表明其为最高级的部门;100表示dept_id=100的若依科技,101表示dept_id=101的深圳总公司。

乍一看这个字段设计的如此复杂,需要保存从最高级直到本级中的所有部门节点,给保存、更新带来了很大的复杂度,这样设计有什么好处呢?

另一种常用设计

说是另一种常用设计,更准确是说是我们当前系统的表设计,为了不违反公司相关规章,这里我就不贴真实的数据库表截图了,大概是这样的:

id dept_name parent_id
1 总公司 0
2 一级部门 1
3 二级部门 2

即仅通过一个parent_id来表明层级关系,这样带来的一个显而易见的好处便是保存与更新带来的操作比较简单。

差异

差异就在于若依中的层级表设计有字段来保存部门祖级的所有节点。最近实现业务代码涉及到这块儿功能才发觉其有很好的实用性。

列举所有子部门

层级结构中最常见的一个业务是列出部门下的所有子部门,那么应该如下编写代码呢?

显然,如果没有ancestors的帮助,我们需要在代码中通过parent_id逐层逐级的遍历列举以及合并列表,而有了ancestors帮助,我们只需要一行SQL语句:

1
.....WHERE dept_id IN ( SELECT dept_id FROM sys_dept WHERE find_in_set('100', ancestors ) )

逐级查找所有父级部门

这种业务通常处于低级部门规则覆盖高级部门规则的场景下,即部门人员总是采用部门层级最接近自己的部门的规章,按照这一原则来实现代码的话,ancestors直接列举出了从高到底各个层级部门,而仅有parent_id则需要通过代码来循环查找父级部门。

做难事必有所得。——金一南

两个样例的基础

在我们学习了jfinal-shiro-jwt项目,以及jfinal-shiro-plugin项目后,我们对jfinal如何嵌入shiro有了一个较为清晰但是实际模糊的感觉,一切还是要自己动手实现来的实际,深入!

实际上jfinal-shiro-jwt项目已经给我们了一个很好的无状态样例,但是jfinal-shiro-plugin中有一个很好的地方,那就是他支持shiro中的注解。我们可以用类似下面的语句来控制接口权限:

1
2
3
4
@RequiresRoles("abc")
public void abc() {
renderAppMsg("接口访问成功");
}

其中的语句:@RequiresRoles("abc")表示需要当前登录人拥有角色abc才可以访问当前接口。

接下来,我们逐步尝试探索将两者的优点结合起来,既能满足使用token进行无状态登录,又能很好地支持shiro注解。

自定义Realm

我们借鉴使用jfinal-shiro-jwt中的ShiroDbRealm:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
package com.xxx;

import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;

public class ShiroDbRealm extends AuthorizingRealm{


/**
* 重写shiro的token
*/
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JWTToken;
}

/**
* 角色,权限认证
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
//String username = JwtUtils.getUsername(principals.toString());
SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
//这里可以连接数据库根据用户账户进行查询用户角色权限等信息,为简便,直接set
simpleAuthorizationInfo.addRole("admin");
simpleAuthorizationInfo.addStringPermission("all");
return simpleAuthorizationInfo;
}

/**
* 自定义认证
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) throws AuthenticationException {
String token = (String) auth.getCredentials();
// 解密获得username,用于和数据库进行对比
String userName = JwtUtils.getUsername(token);
if (userName == null || userName == "") {
throw new AuthenticationException("token 校验失败");
}
//TODO 根据解密的token得到用户名到数据库查询(为省事,直接设置)
if(JwtUtils.verifyJwt(token, userName) == null) {
throw new AuthenticationException("用户名或者密码错误");
}
return new SimpleAuthenticationInfo(token, token, getName());
}

}

需要注意的时,ShiroDbRealm中的后两个方法,第一个返回的是AuthorizationInfo,是做权限授权的,第二个返回的是AuthenticationInfo,是做登录认证的。

Controller中的接口

我们在测试Controller中设置了三个接口,具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Clear
@Before(MyShiroInterceptor.class)
public void noLogin() {
renderAppError("请登录");
}

@Clear
@Before(MyShiroInterceptor.class)
public void testLogin() {
String name = getPara("name");
String password = getPara("password");
// TODO 判断密码与用户名是否正确
if ("admin".equals(name)) {
Map<String,String> map = new HashMap<>();
map.put("name", name);
String token = JwtUtils.createJwt(map, new Date(System.currentTimeMillis()+360000));
renderAppJson(new Record().set("token", token));
} else {
renderAppError("用户名与密码错误");
}
}

@Clear
@Before(MyShiroInterceptor.class)
public void testAfterLogin() {
renderAppMsg("我已经登录了");
}

这三个接口分别表示:

/login/noLogin: 登录验证失败,返回错误信息“请登录”,即在JWTFilter中使用代码配置的接口。

/login/testLogin: 登录接口,根据用户名与密码,获取登录token。

/login/testAfterLogin: 测试登录是否成功的接口。

我们尝试运行,发现无论怎么配置,包括在web.xml中配置始终无法像jfinal-shiro-jwt中一样,执行到JWTFilter。

我们查找项目与jfinal-shiro-jwt的区别:

  1. 所使用的jfinal版本不同,我们所使用的jfinal版本我4.8,而jfinal-shiro-jwt中使用的版本是3.6。因为其版本相比我们低,所以我们尝试将jwt项目中的版本升级到4.8,之后发现jwt仍然可以正常执行到过滤器中。

  2. 所使用的shiro版本不同,我们所使用的的shiro为1.9.0,而jwt中使用的是1.4.0,我们尝试给jwt升级shiro版本,之后发现其仍然可以正常运行。

  3. 除版本号外,我们发现两者的启动方式不同,jfinal 4.8中,我们的启动方式:

    1
    UndertowServer.start(XXXConfig.class);

    而jwt项目中的启动方式为jetty:

    1
    JFinal.start("src/main/webapp", 8088, "/", 5);

    将我们系统中的启动方式修改为jetty后~重要发现Filter中拦截到了请求信息。

总结

最终,我们发现其配置的Filter没有执行到,是因为undertow中配置filter不是通过web.xml配置。

寻觅

最近几天一直在寻找jfinal+shiro的结合方式,特别是适配自定义的token验证方式,自然是希望能找到已经开源的项目,其次再说自我实现代码。

关于shiro

官方地址:https://shiro.apache.org/

有些文档还是需要读一读的:

  1. 官方文档:https://shiro.apache.org/documentation.html
  2. infoQ介绍文章:https://www.infoq.com/articles/apache-shiro/

jfinal-shiro-jwt

jwt,JSON WEB TOKEN验证,目前我们使用该种方式进行身份验证。另外在框架管理系统发展中,希望能嵌入后端的权限管理,所以最近看上了shiro。所以一直在寻觅jfinal+shiro的最佳组合方式。

前些天看了看jfinal-shiro-plugins,是使用拦截器来实现的。

同事发现网上有一个jfinal-shiro-jwt的项目,或许对实现权限管理有所帮助,因此来学习研究一番。

项目地址:https://github.com/perfree/Jfinal-shiro-jwt

保险起见,我把它复制了一份在gitee中:https://gitee.com/wieweicoding/Jfinal-shiro-jwt

clone到本地:

1
git clone https://gitee.com/wieweicoding/Jfinal-shiro-jwt

然后使用IDE打开项目,等待编译完成。目录结果如下图所示:

image-20220409164117488

除jfinal常规的Controller,程序入口以及配置外,shiro相关的类包括:ShiroDbRealm,ShiroInterceptor,JWTFilter。token转换相关的类包括JWTToken,JwtUtils。

TestController中有三个接口,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package com.perfree.controller;

import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import com.jfinal.core.Controller;
import com.perfree.common.AjaxResult;
import com.perfree.jwt.JwtUtils;
import org.apache.shiro.authz.annotation.RequiresRoles;

/**
* 测试Controller
* @author Perfree
*/
public class TestController extends Controller{

/**
* 首页
*/
public void index() {
renderText("这是首页");
}

/**
* 登录页
*/
public void login() {
renderText("请登录");
}

/**
* 登录操作
*/
public void doLogin() {
try {
String name = getPara("name");
String password = getPara("password");
if(name.equals("perfree") && password.equals("123456")) {
Map<String,String> map = new HashMap<>();
map.put("name", name);
renderJson(new AjaxResult(AjaxResult.SUCCESS, JwtUtils.createJwt(map, new Date(System.currentTimeMillis()+360000))));
}else {
renderJson(new AjaxResult(AjaxResult.ERROR,"用户名或密码错误"));
}
} catch (Exception e) {
renderJson(new AjaxResult(AjaxResult.FAILD,"系统异常"));
}
}
}

我们看一下shiro.ini的文件内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
[main]
#realm  自定义realm 
shiroDbRealm=com.perfree.shiro.ShiroDbRealm
securityManager.realms = $shiroDbRealm
sessionManager=org.apache.shiro.session.mgt.DefaultSessionManager
securityManager.sessionManager=$sessionManager
securityManager.sessionManager.sessionValidationSchedulerEnabled = false
# 退出跳转路径
logout.redirectUrl = /login
[filters]
app_authc = com.perfree.jwt.JWTFilter
app_authc.loginUrl = /login
# 登录成功跳转路径 可以自己定义
app_authc.successUrl = /index

#路径角色权限设置
[urls]
/login = anon
/doLogin = anon
/resources/** = anon
/logout = logout
/** = app_authc,roles[admin]

这里我们说一下路径角色权限设置的含义:

anon:表示无需认证即可访问,即允许匿名访问。

authc:需要认证才可访问。

user:点击“记住我”功能可访问。

其实这些含义表示这些访问路径指定了过滤器,如anon表示指定了过滤器为AnonymousFilter。

同样的,app_authc也对应了在配置文件上半部分配置的过滤器:

1
2
3
4
5
[filters]
app_authc = com.perfree.jwt.JWTFilter
app_authc.loginUrl = /login
# 登录成功跳转路径 可以自己定义
app_authc.successUrl = /index

运行测试

我们将项目运行起来,程序入口为com.perfree.Main.class

首先,我们尝试访问/index接口,如下图为postman访问测试:

image-20220409203048932

接口没有返回预期的内容,而是返回了“请登录”三个字,这似乎是下面login接口的返回内容,这是怎么一回事呢?

我们可以从配置行/** = app_authc,roles[admin]得到一些启发。而其对应的配置中有app_authc.loginUrl = /login,那么是否跟这个有关呢?

再让我们看一下这个过滤器的具体内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
package com.perfree.jwt;

import java.io.IOException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter;

/**
* 自定义Shiro的过滤器
* @author Perfree
*
*/
public class JWTFilter extends BasicHttpAuthenticationFilter {

/**
* 判断用户是否想要登入。
* 检测header里面是否包含authc字段即可
*/
@Override
protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
HttpServletRequest req = (HttpServletRequest) request;
String authorization = req.getHeader("authc");
return authorization != null;
}

/**
* 如果携带token进行登录
*/
@Override
protected boolean executeLogin(ServletRequest request, ServletResponse response){
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String authorization = httpServletRequest.getHeader("authc");

JWTToken token = new JWTToken(authorization);
// 提交给realm进行登入,如果错误他会抛出异常并被捕获
getSubject(request, response).login(token);
// 如果没有抛出异常则代表登入成功,返回true
return true;
}

@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
HttpServletResponse resp = (HttpServletResponse)response;
Boolean flag = true;
//判断用户是否携带了token
if (isLoginAttempt(request, response)) {
try {
executeLogin(request, response);
} catch (Exception e) {
flag = false;
}
if(!flag) {
try {
resp.sendRedirect("/login");
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
return flag;
}else {
//未携带token,重定向至登录页面
try {
resp.sendRedirect("/login");
} catch (IOException e1) {
}
return false;
}

}
}

我们看到有几行代码中有:

1
resp.sendRedirect("/login");

到底是配置文件在其作用,还是该重定向代码呢?

我们先打断点看看:

image-20220409203824428

首先,确实是执行到了具体重定向语句,我们尝试修改该语句,再来测试一下:

/login修改为/noLogin,其接口内容如下:

1
2
3
public void noLogin() {
renderText("未登录测试!");
}

重新测试:

首先,我们发现,该过滤器会一直自我循环中,猜测是因为配置文件中配置的/**=app_authc导致重定向/noLogin也会走该过滤器的问题,所以我们在ini中配置:

1
/noLogin = anon

再次测试:

image-20220409204818770

由此可见,配置与代码不一时,代码是要优先于配置设置的。

沮丧

最近,生产环境中所使用的jfinal框架想要集成shiro,但是经过两天的摸索,目前始终无法解决jfinal无状态stateless情况下如何获取登录对象的问题,即使用TOKEN验证方式,如何在登录后根据token获取其登录对象的问题。

为此,今天我们深入研究一下在shiro框架中,执行getSubject方法时,到底程序做了些什么?

shiro基础样例测试

为此,我们使用最简单的shiro测试代码来进行研究:

其Realm类如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package com.xxx.test;

import org.apache.shiro.authc.*;
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.ArrayList;
import java.util.List;

public class Realm extends AuthorizingRealm {

/**
* 获取用户验证信息
* @param authenticationToken 所需验证的token
* @return null or 身份信息
* @throws AuthenticationException 验证异常
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
UsernamePasswordToken token = (UsernamePasswordToken) authenticationToken;
token.getUsername();
// 用户名
Object principal = token.getPrincipal();
// 密码
Object credentials = token.getCredentials();
SimpleAuthenticationInfo simpleAuthenticationInfo = new SimpleAuthenticationInfo(principal, credentials, getName());
return simpleAuthenticationInfo;
}


//授权
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
SimpleAuthorizationInfo info=new SimpleAuthorizationInfo();
List<String> permissions=new ArrayList<>();
List<String> roles=new ArrayList<>();
String username= (String) principalCollection.getPrimaryPrincipal();
permissions.add("xxx");
roles.add("abc");
info.addRoles(roles);//设置角色
info.addStringPermissions(permissions);//设置权限
return info;
}
}

然后我们编写测试用例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import com.jfinal.kit.LogKit;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.UsernamePasswordToken;
import org.apache.shiro.config.IniSecurityManagerFactory;
import org.apache.shiro.realm.SimpleAccountRealm;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.util.Factory;
import org.junit.Assert;
import org.junit.Test;

import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;

public class ShiroTest {


@Test
public void testHelloworld() {
//1、获取SecurityManager工厂,此处使用Ini配置文件初始化SecurityManager
Factory<org.apache.shiro.mgt.SecurityManager> factory = new IniSecurityManagerFactory("classpath:shiro.ini");
//2、得到SecurityManager实例 并绑定给SecurityUtils
org.apache.shiro.mgt.SecurityManager securityManager = factory.getInstance();
SecurityUtils.setSecurityManager(securityManager);
//3、得到Subject及创建用户名/密码身份验证Token(即用户身份/凭证)
Subject subject = SecurityUtils.getSubject();
LogKit.info(subject.toString());
UsernamePasswordToken token = new UsernamePasswordToken("zhang", "123");
try {
//4、登录,即身份验证
subject.login(token);
LogKit.info(subject.toString());
} catch (AuthenticationException e) {
//5、身份验证失败
}
Assert.assertEquals(true, subject.isAuthenticated()); //断言用户已经登录
//6、退出
subject.logout();
}
}

在下面这行代码打上断点:

1
Subject subject = SecurityUtils.getSubject();

执行测试,我们发现程序跳转到来了下面的方法体中:

1
2
3
4
5
6
7
8
9
public static Subject getSubject() {
Subject subject = ThreadContext.getSubject();
if (subject == null) {
subject = (new Builder()).buildSubject();
ThreadContext.bind(subject);
}

return subject;
}

继续深入:

1
2
3
4
5
6
/**
* ThreadContext.class
*/
public static Subject getSubject() {
return (Subject)get(SUBJECT_KEY);
}

其中的SUBJECT_KEY值为:org.apache.shiro.util.ThreadContext_SUBJECT_KEY,我们进入get方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static Object get(Object key) {
if (log.isTraceEnabled()) {
String msg = "get() - in thread [" + Thread.currentThread().getName() + "]";
log.trace(msg);
}

Object value = getValue(key);
if (value != null && log.isTraceEnabled()) {
String msg = "Retrieved value of type [" + value.getClass().getName() + "] for key [" + key + "] bound to thread [" + Thread.currentThread().getName() + "]";
log.trace(msg);
}

return value;
}

继续深入:

1
2
3
4
5
private static Object getValue(Object key) {
Map<Object, Object> perThreadResources = (Map)resources.get();
return perThreadResources != null ? perThreadResources.get(key) : null;
}

最终,我们发现subject对象来自一个map对象中,subject的key为``org.apache.shiro.util.ThreadContext_SUBJECT_KEY`,我们看一下resources中存了什么:

1
private static final ThreadLocal<Map<Object, Object>> resources = new ThreadContext.InheritableThreadLocalMap();

该map是一个ThreadLocal修饰的线程局部对象。

我们发现其执行结果返回为null:

image-20220408231014621

然后逐层返回,便返回到下面这一行:

image-20220408231131671

构建对象函数如下:

image-20220408231357704

具体到方法内部:

1
2
3
4
5
6
7
8
9
public Subject createSubject(SubjectContext subjectContext) {
SubjectContext context = this.copy(subjectContext);
context = this.ensureSecurityManager(context);
context = this.resolveSession(context);
context = this.resolvePrincipals(context);
Subject subject = this.doCreateSubject(context);
this.save(subject);
return subject;
}

我们再来看看绑定bind:

1
2
3
4
5
6
public static void bind(Subject subject) {
if (subject != null) {
put(SUBJECT_KEY, subject);
}

}

如何便在局部线程内部,将subject保存下来。

总结

关键思想:线程局部变量ThreadLocal,生成subject与绑定分开执行。

在德不在鼎。….. 德之休明,虽小,重也;其奸回昏乱,虽大,轻也。鼎之轻重,未可问也。

起源:本地100,线上200

最近生产环境除了一个奇怪的事情:我司程序员张三在其本地调试运行某个功能模块的代码后,上线运行通过,但是发现其执行结果与预期不符合,而且,最最诡异的事情是,在其本地执行与预期结果是相符合的。

简单来说,一段程序在张三的电脑上运行,执行结果为100。将同样的一段代码放在服务器上执行,执行结果就变成200了。

定位:打log

为了找到问题,我们在代码各个IF判断位置插入日志,重新部署在服务器上执行,然后根据日志判断其程序走向与定位问题。

结果

最终,我们定位到这样一个方法:

1
2
3
4
5
6
7
8
9
/**
* 星期几
* @param date
* @return 星期几
*/
public static String getChineseWeek(Date date) {
SimpleDateFormat EEEE = new SimpleDateFormat("EEEE");
return EEEE.format(date);
}

首先我们在本地运行一下这段代码,编写测试用例:

1
2
3
4
5
@Test
public void testSimpleDateFormate() {
SimpleDateFormat EEEE = new SimpleDateFormat("EEEE");
System.out.println(EEEE.format(new Date()));
}

执行结果如下:

1
星期四

但是我们在服务器中,同样的代码,执行结果却不同,如下所示:

1
[INFO]-[Thread: catalina-exec-7699]-[com.jfinal.kit.LogKit.info()]: Thursday

经过查询发现,原来SimpleDateFormat是和其运行环境有关的。其完整文档如下:

1
>public SimpleDateFormat(String pattern)

使用给定模式SimpleDateFormat并使用默认的FORMAT语言环境的默认日期格式符号。 注意:此构造函数可能不支持所有区域设置。 要全面覆盖,请使用DateFormat类中的工厂方法。

这相当于调用SimpleDateFormat(pattern, Locale.getDefault(Locale.Category.FORMAT))

  • 参数

pattern - 描述日期和时间格式的模式

  • 异常

NullPointerException - 如果给定的模式为空

IllegalArgumentException - 如果给定的模式无效

  • 另请参见:

Locale.getDefault(java.util.Locale.Category)Locale.Category.FORMAT

我们通常使用的格式:yyyy-MM-dd不论在中文环境下还是英文环境下输出都是一样的,而遇到星期这种表达,中文与英文出现了分期,那么,如何才能避免这种的表达形式的,那就是指明所使用的语言。

如下所示:

1
2
3
4
5
6
7
8
9
/**
* 星期几
* @param date
* @return 星期几
*/
public static String getChineseWeek(Date date) {
SimpleDateFormat EEEE = new SimpleDateFormat("EEEE", Locale.CHINESE);
return EEEE.format(date);
}

如此便不会出现因为服务器区域设定不同而造成的格式不达预期的问题。

设定区域方法的完整官方文档如下:

1
2
public SimpleDateFormat(String pattern,
Locale locale)

构造一个SimpleDateFormat使用给定的模式和给定的区域设置的默认日期格式符号。 注意:此构造函数可能不支持所有区域设置。 为了全面覆盖,请使用DateFormat类中的工厂方法。

  • 参数

    pattern - 描述日期和时间格式的模式

    locale - 应使用日期格式符号的区域设置

  • 异常

    NullPointerException - 如果给定的模式或区域设置为空

    IllegalArgumentException - 如果给定的模式无效

权限控制之角色

在阐述完若依框架中的菜单后,我们来看一下若依系统中的角色是如何设计的。

前端

在前端页面上,系统管理->角色管理中,我们可以看到其角色管理的主页面:

image-20220404163430920

点击新增,我们来看一下一个角色需要配置什么信息:

image-20220404165400253

除名称外,另外两个比较值得关注的,一个是菜单权限,一个是权限字符,我们分别来看。

权限字符

权限字符,我们展开label前面的提示信息:

控制器中定义的权限字符,如@PreAuthorize(@ss.hasRole("admin"))

可见,权限字符是可以做到后端接口权限控制的。

对于其中的PreAuthorize注解,我们稍后展开来讲。

菜单权限

这个也很好理解,就是该角色拥有查看哪些目录、菜单以及按钮的权限。

角色分配

我们再来看一下若依系统中是如何给用户分配角色的。

用户管理部分分配角色

可以在用户管理版块给用户分配角色,并且该处可以给一个用户分配多个角色。如下图:

image-20220404170420215

角色管理部分分配用户

如下图所示,点击某个角色后面的更多按钮,在下拉框中点击“分配用户”,在分配用户界面去多选用户批量给一个角色分配用户。

image-20220404170545287

image-20220404170632310

经过测试,两个版块的操作数据是互通的。

PreAuthorize

接下来,我们阐述一下Spring-Security中的注解PreAuthorize。

该方法是在方法调用前进行权限检查。

比如,我们找到若依系统中的一个样例:

1
2
3
4
5
6
7
8
9
10
11
/**
* 查询代码生成列表
*/
@PreAuthorize("@ss.hasPermi('tool:gen:list')")
@GetMapping("/list")
public TableDataInfo genList(GenTable genTable)
{
startPage();
List<GenTable> list = genTableService.selectGenTableList(genTable);
return getDataTable(list);
}

我们可以点击查看hasPermi方法,查看其执行逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/**
* RuoYi首创 自定义权限实现,ss取自SpringSecurity首字母
*
* @author ruoyi
*/
@Service("ss")
public class PermissionService
{

/**
* 验证用户是否具备某权限
*
* @param permission 权限字符串
* @return 用户是否具备某权限
*/
public boolean hasPermi(String permission)
{
if (StringUtils.isEmpty(permission))
{
return false;
}
LoginUser loginUser = SecurityUtils.getLoginUser();
if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getPermissions()))
{
return false;
}
return hasPermissions(loginUser.getPermissions(), permission);
}

通俗的说,hasPermi就是判断当前登录用户是否拥有某个权限,有则返回true,无此权限则返回false。

角色表设计

若依系统中的角色表为sys_role,我们来大致看一下其中的字段设计:

image-20220404205216072