这一章讨论下中后台管理系统的核心:管理系统的灵魂是什么?
是权限吗? 是,但不完全是
我认为管理系统的灵魂是基于权限的各种资源的正确显示与合理交互,做到这点,权限之上的管理业务才能落地生产。
计算机科学中常见的权限控制模式有两种, Role-based access control 跟 Access control list。 RBAC 为基于角色的访问控制用户与权限之间建立起角色集合,将权限赋予角色再将角色赋予用户。 ACL 直接将系统操作的各种权限赋予具体用户,为指定哪些用户或系统进程被授予对资源的访问权限,以及允许对给定资源执行哪些操作。
但实际业务中,老牌软件的权限的思路并不能直接运用到生产中,产品经理设计的权限千奇百怪,这就导致了一个中后台系统中,用户既存在角色维度的资源权限,又存在用户维度的资源权限。 开发人员单纯照搬理论知识,进行 RBAC 或者 ACL 的模式开发,根本满足不了需求。
所谓一力破万法,如果你看清并理解前端本质之后,系统鉴权实际并不复杂。
系统鉴权本质
前后端分离的架构中,后端作为数据生产端,负责持久化数据,前端是数据的消费端,前端的职能是对服务端返回的数据进行缓存与展示并提供界面给用户交互。权限本质上就是一对数据的集合,最新的权限数据永远是存放在后端数据库内部的,所以对于前端来说,永远也拿不到最新的权限数据,前端的用户权限,只是某一时刻前端请求接口拿到的数据,永远具备滞后性。
前端根据缓存的权限数据进行交互渲染,所以前端的鉴权可能是不准确的,于是对于整个系统来说,鉴权必须是前后端一起做。
比如一个全权限用户 A 在 browse 挂着,没有任何操作,但是某一时刻他的用户权限被人修改了,用户 A 被修改成了无权限用户,但在用户 A 的视角里,他还在系统的控制台,他还有着系统的全部权限,他可以随意的进行按钮点击,菜单跳转,但这事实上这些都是越权的操作。 因此后端不能完全信任前端鉴权,后端必须根据数据库中存取的用户权限数据进行权限判定,后端发现前端的权限数据有问题时,需要及时提醒前端刷新权限数据,让前端踢出用户或者让前端重新请求权限数据进行数据刷新,避免用户拿着旧的权限数据再做一些越权的操作。
中后台中前端鉴权很简单,要做的只是根据某一时刻缓存的用户权限渲染出正确视图,然后交付给用户进行交互即可。 前端鉴权可以用一个公式表达 A includes B === true A 是视图渲染某个组件,某个资源所需要的权限 B 是目前缓存在前端的用户权限 无论是页面权限,路由权限,菜单权,还是说颗粒度到按钮的权限,都可以用这个公式表达,不管是封装成 hook 还是 指令,本质上还是看用户是否有访问当前资源所需要的权限。
菜单鉴权
不管是横向的菜单还是纵向的菜单,菜单的本质数据结构都是一个树
因此进行菜单的鉴权很简单,就是进行一次深度优先遍历,将鉴权不通过的节点全部去除掉,然后进行渲染。
同时菜单的数据来源一般就是路由表的子集, 路由表中 views 级别的所有路由节点连同父级构成一颗树,经过鉴权后的数据就是展示菜单所需要的数据。
const getSubMenu = (node: RouteRecordRaw) => {
if (permission.checkRoutePermission(node)) {
const menuData = getMenuData(node)
context.currentNode = menuData
if (node.children === undefined) {
return menuData
} else {
const list: MenuData[] = []
for (let j = 0; j < node.children.length; j++) {
const child = getSubMenu(node.children[j])
if (child) list.push(child)
}
if (list.length) {
menuData.children = list
return menuData
}
return null
}
} else {
return null
}
}
const nodeList = []
for (let i = 0; i < appRoutes.length; i++) {
const menuNode = getSubMenu(appRoutes[i], )
if (menuNode) {
nodeList.push(menuNode)
}
}
如果单纯讲菜单鉴权,事实上到这里已经结束了。
但开发中还会出现几个需求
- 页面的面包屑
- 菜单组件的 selectedKeys 跟 openKeys 即当前选定的菜单项跟多个多级菜单的打开状态
这些问题都涉及到了一类问题,就是节点到根节点的路径,因而抽象成一个算法问题,即已知一个子节点,求个根节点到这个子节点的路径问题,这个算法也是一道深度优先遍历问题,可以搜索解决。
不进行搜索可以做么? 也可以,可以写到每个路由文件的 meta 属性里面,然后需要读取时,再在 route 里面进行读取
但这样实现起来并不优雅,同时之后随着路由表的内部加入各种业务属性,比如菜单 icon,排序数值,是否隐藏等,一个路由节点也变得逐步臃肿。
事实上,我们在深度优先遍历路由表的过程中,只需要跟父级联系起来,就可以得到某个节点的路径,然后以哈希表的思想,空间换时间的思路,避免构建面包屑、计算 selectedKeys 跟 openKeys 的搜索过程。哈希表读取的时间复杂度可是 O(1)啊。
修改上述深度优先遍历的过程,一边进行遍历,一边进行 map 构建
export default function useAppRoute() {
const permission = usePermission()
const appRouteData = computed(() => {
const getMenuData = (route: RouteRecordRaw, context: Context) => {
const ret: MenuData = {
name: isString(route.name) ? route.name : '',
locale: typeof route.meta?.locale === 'string' ? route.meta.locale : '',
localePath: [],
namePath: []
}
ret.namePath.push(ret.name)
ret.localePath.push(ret.locale)
if (context.parent?.localePath) {
ret.localePath = context.parent.localePath.concat(ret.localePath)
}
if (context.parent?.namePath) {
ret.namePath = context.parent.namePath.concat(ret.namePath)
}
if (ret.name in routeIconMap) {
ret.icon = routeIconMap[ret.name]
}
return ret
}
const getSubMenu = (node: RouteRecordRaw, context: Context) => {
if (permission.checkRoutePermission(node)) {
const menuData = getMenuData(node, context)
context.currentNode = menuData
if (node.children === undefined) {
_map[menuData.name] = menuData
return menuData
} else {
const list: MenuData[] = []
for (let j = 0; j < node.children.length; j++) {
context.parent = menuData
const child = getSubMenu(node.children[j], context)
if (child) list.push(child)
}
if (list.length) {
menuData.children = list
_map[menuData.name] = menuData
return menuData
}
return null
}
} else {
return null
}
}
const _map: Record<RouteRecordName, MenuData | undefined> = {}
const nodeList = []
for (let i = 0; i < appRoutes.length; i++) {
const context: Context = {
currentNode: null,
parent: null
}
const menuNode = getSubMenu(appRoutes[i], context)
if (menuNode) {
nodeList.push(menuNode)
}
}
return { tree: nodeList, map: _map }
})
return {
appRouteData
}
}
这样就可以得到鉴权后的菜单 tree 跟面包屑 selectedKeys 跟 openKeys 所需要的 map,使用时,直接通过 map 进行读取路径
listenerRouteChange((newRoute) => {
if (newRoute.name) {
const appRoute = appRouteData.value.map[newRoute.name]
if (appRoute) {
const namePath = appRoute.namePath
openKeys.value = Array.from(new Set([...namePath, ...openKeys.value]))
const stackTopName = namePath[namePath.length - 1]
selectedKey.value = [stackTopName]
}
}
}, true)
路由鉴权
路由鉴权对于存放在前端的路由表来说做好路由导航守卫即可,
即在路由表中页面级别的路由中找到一个可以访问的路由,找不到时跳转到一个兜底页面,这样就完成了路由的鉴权。
export default function setupPermissionGuard(router: Router) {
router.beforeEach(async (to, from, next) => {
const Permission = usePermission()
const permissionsAllow = Permission.checkRoutePermission(to as unknown as RouteRecord)
if (permissionsAllow) next()
else {
const destination = firstPermissionRoute?.name || ViewNames.notFound
next({ name: destination })
}
})
}
这个算法也可以抽象成一个算法:寻找一棵树中满足条件的最左子叶子节点,也是通过深度优先遍历求解
const firstPermissionRoute = (() => {
const getFirstChild = (node: RouteRecordRaw): null | RouteRecordRaw => {
if (permission.checkRoutePermission(node)) {
if (node.children === undefined) {
return node
} else {
for (let i = 0; i < node.children.length; i++) {
const findRes = getFirstChild(node.children[i])
if (findRes) {
return findRes
}
}
}
}
return null
}
for (let i = 0; i < appRoutes.length; i++) {
const findRes = getFirstChild(appRoutes[i])
if (findRes) {
return findRes
}
}
return null
})
本文所涉及的技术在 vue-tsx-admin 中可以找到完整的实例,希望对你写 Vue 的项目有所帮助。欢迎 star 和提出不足。
系列文章: