本篇目录

  • 前言
  • 一、后端实现
  • 1.表设计
  • 2.pojo
  • 3.菜单查询接口(树结构查询)
  • 二、前端实现
  • 1.后台页面设计
  • 2.数据处理
  • 3.添加路由与渲染菜单
  • 下一步


前言

果然不出大家所料,我又没能按时写完文章。时至今日我已经没有什么愧疚的感觉了,这大概就是成长吧。不过话又说回来了,曾经我也是一个月写 10 篇的男人啊。

这篇文章的主要内容是实现按照用户角色动态加载后台管理页面的菜单,有如下几个重点:

  • 如何设计数据库以建立用户-角色-菜单之间的联系
  • 如何查询与处理树结构的数据
  • Vue 如何实现动态加载路由

在正文之前,给大家讲讲这两周背后的故事:

1、在官方活动 ——「CSDN 原力计划」 的加持下,我体验了一波飞速增长,读者数量翻了一倍。有那么几天我膨胀的不行,甚至一度想下定决心周更以早日达到破万的目标。后来我在房间里沉思良久,嚯完了一大杯快乐水,放弃了这个危险的念头。

2、最近我发现咱们这个项目除了入门练习,还可以作为一些常见应用的 脚手架。比如图书查询与管理系统、个人博客(主页)、企业门户网站之类。上周我甚至在这个项目的基础上做了一个投票系统给公司内部举办活动用,虽然自己感觉稀碎,但忽悠不懂代码的同事足够了。

为了让这个项目看起来正经一点,我对结构做了一些调整:

  • 把原来的开发的部分作为前台,并取消登录拦截
  • 登录后跳转到后台,在后台登出后跳转到前台首页
  • 去除图书馆页面增删改功能,之后会放在后台内容管理部分

其实就是前端配置了下路由,修改了图书组件,后端取消了图书查询端口的拦截,相信大家自己能做到哈。之前有读者指出我一个地方漏掉了导入组件的代码,一开始我以为真漏了,后来想起来应该是特意没把整个组件复制上去,因为感觉没有太大必要,大家不要一复制代码就想没有错误地跑起来,不可能的,就是直接 clone 别人的仓库都会有各种各样的问题出现,出了问题百度就好啦。

3、有读者给我发了十几封邮件,好几次贴了上几千行的错误信息,提问句式是:现在的问题是 XXXXX,然后就没了,连请我帮忙看看这样的话都没有,我好南啊(给我气笑了.jpg)

不过会说话的读者也越来越多啦,我脸皮薄,很多时候还是不忍心拒绝你们的,这两周也为了回答问题花了不少时间。更有一些认真的读者发现了项目的 BUG 并指出了改正方法,我感到十分欣慰,希望老天爷多给我分配一些这样的读者。随着项目越来越复杂,难免会出现各种纰漏,欢迎大家从各个方面指出不足,也可以直接在 GitHub 上提 Issue 或 PR,咱们一起把这个东西做好。

一、后端实现

实现动态加载菜单功能的第一步,是完成根据当前用户查询出可访问菜单信息的接口。

1.表设计

基于之前讲过的 RBAC 原则,我们应该设计一张角色表,用角色去对应菜单。同时,为了建立用户与角色、角色与菜单之间的关系,又需要两张中间表。加上之前的用户表,一共需要五张表,各表字段如下图所示:

springboot 动态表单设计_权限控制


这里我为后台管理专属的表加上了 admin 前缀。起名是一件十分重要的事情,好的名字是保证代码质量的前提,你们不要学我起的这么随意,很多公司都有自己的起名原则,可以学习一下。(推荐 《阿里巴巴 Java 开发手册》)

另外不同于之前 book 和 category 的做法,这里没有用到外键。一般决定用不用外键需要看系统对数据一致性和效率的要求哪个更突出,但是我觉得数据一致性问题都可以通过代码解决,用外键又麻烦又别扭。

这里我简单介绍下 admin_menu 表的各个字段:

字段

解释

id

唯一标识

path

与 Vue 路由中的 path 对应,即地址路径

name

与 Vue 路由中的 name 属性对应

name_zh

中文名称,用于渲染导航栏(菜单)界面

icon_cls

element 图标类名,用于渲染菜单名称前的小图标

component

组件名,用于解析路由对应的组件

parent_id

父节点 id,用于存储导航栏层级关系

表中的数据你可以自己设计,为了方便测试,记得多注册一个账号以配置不同的角色,并为角色配置相应的菜单。嫌麻烦的话就直接执行我的 sql 文件:

https://github.com/Antabot/White-Jotter/blob/master/wj/wj.sql

里面有三个账号,admin、test 和 editor,密码都是 123,admin 的角色是系统管理员,editor 是内容管理员,test 是空的。

2.pojo

因为我们使用了 JPA 做 ORM,创建 POJO 时需要注意以下几点:

P.S.其实过去我们创建的 POJO 应该再具体一点,称之为 PO(persistant object,持久对象)或者 Entity(实体),并使用 DTO(Data Transfer Object)与客户端进行交互。教程做了一些简化,源码添加了这些分类。

  • windows 下默认不区分 mysql 字段大小写,而 linux 区分,所以数据库字段不推荐大小写混用(最好都小写),而 Java 属性一般采用小驼峰法命名,JPA 会自动将小驼峰命名转换为下划线命名,比如 nameZh 自动转换为 name_zh
  • 数据库中不存在对应字段的属性,需要用 @Transient 注记标注出来

我们需要创建 AdminUserRoleAdminRoleAdminRoleMenuAdminMenu 四个 PO,比较特殊的是 AdminMenu,这里我贴出来代码:

package com.gm.wj.pojo;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;

import javax.persistence.*;
import java.util.List;

@Entity
@Table(name = "admin_menu")
@JsonIgnoreProperties({"handler", "hibernateLazyInitializer"})
public class AdminMenu {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id")
    int id;
    String path;
    String name;
    String nameZh;
    String iconCls;
    String component;
    int parentId;
    @Transient
    List<AdminMenu> children;

// getter and setter...

与数据库中不同的是这个 Children 属性,用于存储子节点。

3.菜单查询接口(树结构查询)

根据用户查询出对应菜单的步骤是:

  • 利用 shiro 获取当前登录用户的 id
  • 根据用户 id 查询出该用户对应所有角色的 id
  • 根据这些角色的 id,查询出所有可访问的菜单项
  • 根据 parentId 把子菜单放进父菜单对象中,整理返回有正确层级关系的菜单数据

为了实现这个接口,我们需要新增AdminUserRoleDAOAdminRoleMenuDAOAdminMenuDAO 三个数据库访问对象并编写 Service 对象,也可以顺道把 AdminRole 一套给写了,不过现在还用不上。

AdminMenuService中需要实现一个根据当前用户查询出所有菜单项的方法:

public List<AdminMenu> getMenusByCurrentUser() {
    // 从数据库中获取当前用户
    String username = SecurityUtils.getSubject().getPrincipal().toString();
    User user = userService.findByUsername(username);

    // 获得当前用户对应的所有角色的 id 列表
    List<Integer> rids = adminUserRoleService.listAllByUid(user.getId())
            .stream().map(AdminUserRole::getRid).collect(Collectors.toList());

    // 查询出这些角色对应的所有菜单项
    List<Integer> menuIds = adminRoleMenuService.findAllByRid(rids)
            .stream().map(AdminRoleMenu::getMid).collect(Collectors.toList());
    List<AdminMenu> menus = adminMenuDAO.findAllById(menuIds).stream().distinct().collect(Collectors.toList());

    // 处理菜单项的结构
    handleMenus(menus);
    return menus;
}

这个方法的主体部分对应上面的前三步。这里我们使用了 stream 来简化列表的处理,包括使用 map() 提取集合中的某一属性,通过 distinct() 对查询出的菜单项进行了去重操作,避免多角色情况下有冗余的菜单等。

接下来需要把查询出来的菜单数据列表整合成具有层级关系的菜单树,也就是编写 handleMenus() 方法。

这里我多说一嘴,由于导航菜单一般不会特别长,所以我们采用这种一次性取出的方式。上述过程中我们会在遍历列表的同时查询数据库,这样的多次交互在前台需要尽量避免,最好先一次性查询出全量数据以减轻服务器负担。但后台一般是给管理人员使用的,没有那么大的流量,所以不用担心。

如果数据量特别大,那就应该考虑按节点动态加载。即通过监听节点的展开事件向后端发送节点 id 作为参数,查询出所有的子节点,并在前端动态渲染。这种方式的实现等以后用到了再具体讲。

整合查询出的菜单数据的思路如下:

  • 遍历菜单项,根据每一项的 id 查询该项出所有的子项,并放进 children 属性
  • 剔除掉所有子项,只保留第一层的父项。比如 c 是 b 的子项,b 是 a 的子项,我们最后只要保留 a 就行,因为 a 包含了 b 和 c

整合方法如下:

public void handleMenus(List<AdminMenu> menus) {
	for (AdminMenu menu : menus) {
	    List<AdminMenu> children = getAllByParentId(menu.getId());
	    menu.setChildren(children);
	}
	
	Iterator<AdminMenu> iterator = menus.iterator();
	while (iterator.hasNext()) {
	    AdminMenu menu = iterator.next();
	    if (menu.getParentId() != 0) {
	        iterator.remove();
	    }
	}
}

先说明一下,查询树结构的方法有很多,我见过有按层级查询的,也有直接根据实际情况写死了的。我使用的这种方式好处就是无论几层都能正确地查询出来,虽然执行效率会低一些,但反正是后台用的,问题不大。

有的同学可能对这个方法有疑问,似乎遍历一次应该只有两个层级才对。此外,menus 明明已经查询出来了,在遍历中每次仍然调用查询方法,是不是会频繁访问数据库,并创建额外的对象,增加不必要的开销?虽然是后台,这么写也太离谱了吧?

(感谢 @m0_46435907 同学经过认真分析提供了这个问题的解答思路)

这里可以放心,JPA 为我们提供了持久化上下文(Persistence Context,是一个实体的集合),用于确保相同的持久化对象只有一个实例,且在存在相应实例时不会再次访问数据库(详细内容见 「JPA Persistence Context」)。因此,我们查询到的 children 列表中的每一个 AdminMenu 对象实例都复用了 Menus 列表中的 AdminMenu 对象。

同时,在 Java 里对象都是引用类型,假设我们把 b 放进了 a 的 children 里,又把 c 放进了 b 的 children 里,那么 c 就被放进了 a 的 children 的 children 里。因此,经过一次遍历,就能得到正确的层级关系。

而下面的 remove 方法,实际上是把对象名指向了 null,而对象本身仍然存在。所以虽然我们无法再通过 b、c 获取到原来的对象,但 a 里面的信息是不会变的。

为什么删除子项时用 iterator.remove() 而不用 List 的 remove 方法呢?是因为使用 List 遍历时,如果删除了某一个元素,后面的元素会补上来,也就是说后面元素的索引和列表长度都会发生改变。而循环仍然继续,循环的次数仍是最初的列表长度,这样既会漏掉一些元素,又会出现下标溢出,运行时表现就是会报 ConcurrentModificationException。而 iterator.remove() 进行了一些封装,会把当前索引和循环次数减 1,从而避免了这个问题。

JDK 8 以上版本可以使用 lambda 表达式:

public void handleMenus(List<AdminMenu> menus) {
    menus.forEach(m -> {
        List<AdminMenu> children = getAllByParentId(m.getId());
        m.setChildren(children);
    });

    menus.removeIf(m -> m.getParentId() != 0);
}

MenuController 中根据请求调用查询逻辑,代码如下:

@GetMapping("/api/menu")
 public List<AdminMenu> menu() {
     return adminMenuService.getMenusByCurrentUser();
 }

完成后可以测试下 menu 接口。在此之前要确保系统处于登录状态哈,要不可查询不到信息。

springboot 动态表单设计_动态菜单_02

二、前端实现

前端要做的事情就是处理后端传来的数据,并传递给路由和导航菜单,以实现动态渲染。

1.后台页面设计

我目前大概做了下面几个组件:

springboot 动态表单设计_springboot 动态表单设计_03


主要是实现了页面基础设计,并方便测试动态加载,目前还没有任何功能。效果大概是这个样子:

springboot 动态表单设计_异步加载_04


可以参考 GitHub 上的源码,也可以自己设计一下。开发完组件别忘了添加后台首页的路由哈,别的菜单对应的路由可以动态加载,这个不预先写好就进不去页面了。

2.数据处理

之前我们设计 AdminMenu 表,实际上包含了前端路由(router)与导航菜单需要的信息,从后台传来的数据,需要被整理成路由能够识别的格式。导航菜单倒是无所谓,赋给相应的属性就行。

进行格式转换的方法如下:

const formatRoutes = (routes) => {
  let fmtRoutes = []
  routes.forEach(route => {
    if (route.children) {
      route.children = formatRoutes(route.children)
    }

    let fmtRoute = {
      path: route.path,
      component: resolve => {
        require(['./components/admin/' + route.component + '.vue'], resolve)
      },
      name: route.name,
      nameZh: route.nameZh,
      iconCls: route.iconCls,
      children: route.children
    }
    fmtRoutes.push(fmtRoute)
  })
  return fmtRoutes
}

这里传入的参数 routes 代表我们从后端获取的菜单列表。遍历这个列表,首先判断一条菜单项是否含子项,如果含则进行递归处理。

下面的语句就是把路由的属性与菜单项的属性对应起来,其它的都好说,主要是 component 这个属性是一个对象,因此需要根据名称做出解析(即获取对象引用)。同时我们需要把组件导入进来,因此可以利用 Vue 的异步组件加载机制(也叫懒加载),在解析的同时完成导入。

我们数据库中存储的是组件相对 @components/admin 的路径,所以解析时要根据 js 文件的位置加上相应的前缀。

3.添加路由与渲染菜单

首先我们要思考一下什么时候需要去请求接口并渲染菜单。如果访问每个页面都加载一次,有点太浪费了。如果只在后台主页面渲染时加载一次,那么就不能在子页面中进行刷新操作。因此我们可以继续利用路由全局守卫,在用户已登录且访问以 /admin 开头的路径时请求菜单信息,完整的代码如下

router.beforeEach((to, from, next) => {
    if (store.state.user.username && to.path.startsWith('/admin')) {
        initAdminMenu(router, store)
    }
    // 已登录状态下访问 login 页面直接跳转到后台首页
    if (store.state.username && to.path.startsWith('/login')) {
      next({
        path: 'admin/dashboard'
      })
    }
    if (to.meta.requireAuth) {
      if (store.state.user.username) {
        axios.get('/authentication').then(resp => {
          if (resp) next()
        })
      } else {
        next({
          path: 'login',
          query: {redirect: to.fullPath}
        })
      }
    } else {
      next()
    }
  }
)

为了保证用户确实登录,仍旧需要向后台发送一个验证请求。

initAdminMenu 用于执行请求,调用格式化方法并向路由表中添加信息,代码如下:

const initAdminMenu = (router, store) => {
  if (store.state.adminMenus.length > 0) {
    return
  }
  axios.get('/menu').then(resp => {
    if (resp && resp.status === 200) {
      var fmtRoutes = formatRoutes(resp.data)
      router.addRoutes(fmtRoutes)
      store.commit('initAdminMenu', fmtRoutes)
    }
  })
}

首先判断一下 store 里有没有菜单数据,如果有说明是正常跳转,无需重新加载。(第一次进入或进行刷新时需要重新加载)

记得在 store.state 里添加变量 adminMenu: [],同时在 mutations 里添加如下方法:

initAdminMenu (state, menus) {
   state.adminMenus = menus
 }

这个 menus 就是上面的 fmtRoutes。当然也可以把数据放进 localStorage,记得登出时清空就好了。

最后,我们来编写一下菜单组件 AdminMenu.vue

<template>
  <div>
    <el-menu
      :default-active="'/admin/users'"
      class="el-menu-admin"
      router
      mode="vertical"
      background-color="#545c64"
      text-color="#fff"
      active-text-color="#ffd04b">
      <div style="height: 80px;"></div>
      <template v-for="(item,i) in adminMenus">
        <!--index 没有用但是必需字段且为 string -->
        <el-submenu :key="i" :index="i + ''" style="text-align: left">
          <span slot="title" style="font-size: 17px;">
            <i :class="item.iconCls"></i>
            {{item.nameZh}}
          </span>
          <el-menu-item v-for="child in item.children" :key="child.path" :index="child.path">
            <i :class="child.icon"></i>
            {{ child.nameZh }}
          </el-menu-item>
        </el-submenu>
      </template>
    </el-menu>
  </div>
</template>

<script>
    export default {
      name: 'AdminMenu',
      computed: {
        adminMenus () {
          return this.$store.state.adminMenus
        }
      }
    }
</script>

这里我们利用 element 的导航栏组件,进行两层循环,渲染出我们需要的菜单。<el-submenu> 代表一个有子菜单的菜单项,<el-menu-item> 则代表单独的菜单项。这么命名似乎有点毛病,又似乎没毛病。。。

如果有三个层级,就是 <el-submenu><el-submenu> 再套 <el-menu-item> ,以此类推。

终于大功告成了。我们来试试用 admin 账户登录,就是上面的效果,菜单是全的:

springboot 动态表单设计_springboot 动态表单设计_05


可以点击用户信息菜单,跳转到相应的路由并加载组件:

springboot 动态表单设计_动态菜单_06

使用 editor 账号登录,则只显示内容管理

springboot 动态表单设计_权限控制_07

下一步

这个页面做的比较着急,接下来计划按之前的设计完善各个模块,包括:

  • 开发用户角色、角色菜单分配组件
  • 迁移图书管理功能
  • 完成其它模块的基础界面
  • 实现功能级权限并开发分配组件

下篇文章的重点是功能级权限的实现,其它方面会顺带提到,但不会说的太细,因为都是讲过的知识点。

总算写完了,但感觉这篇文章还有些地方需要润色一下,大家有看不懂的地方可以尽情提,但像为什么粘了代码跑不起来这种问题我就直接忽略啦。