nodejs抓取动态网页(【系列文章目录】「项目源码(GitHub)」本篇目录)
优采云 发布时间: 2022-02-03 05:10nodejs抓取动态网页(【系列文章目录】「项目源码(GitHub)」本篇目录)
重要链接:
"系列文章目录"
“项目源代码(GitHub)”
本目录
前言
不出所料,我没能按时写完文章。时至今日,我已经没有任何愧疚感了,大概就是长大了。但话说回来,我曾经是一个每月写10篇文章的人。
这个文章的主要内容是根据用户角色动态加载后台管理页面的菜单,关键点如下:
在正文之前,让我告诉你这两周背后的故事:
1、在官方活动——《CSDN发力计划》的加持下,经历了一波快速增长的浪潮,阅读量翻了一番。一连几天都肿不起来,甚至想下定决心,尽快达到破万的目标。然后我在房间里沉思良久,喝了一大杯快乐水,放弃了这个危险的想法。
2、最近,我发现我们的项目除了入门练习之外,还可以作为一些常见应用的脚手架。如图书查询管理系统、个人博客(首页)、企业门户网站等。上周,我什至基于这个项目为公司内部活动搭建了一个投票系统。虽然觉得自己很崩溃,但是骗过不懂代码的同事就够了。
为了让这个项目看起来很严肃,我对结构做了一些调整:
其实前端配置了路由,修改了book组件,后端取消了对book查询端口的拦截。我相信你可以自己做。一位读者指出,我在一个地方漏掉了导入组件的代码。一开始我以为我错过了,但后来我想起我没有故意复制整个组件,因为我认为没有必要。不要只是复制代码并考虑它。不可能不报错,就是直接克隆别人的仓库,会出现各种问题。如果有问题,百度就可以了。
3、有些读者给我发了十几封邮件,上千行的错误信息贴了好几次。没有这回事,我太南了(给我狂笑.jpg)
但是,会说话的读者越来越多。我脸皮薄,很多时候还是不忍心拒绝你。在过去的两周里,我花了很多时间回答问题。一些认真的读者发现了项目的错误并指出了如何纠正它们。我很高兴,希望上帝能给我分配更多这样的读者。随着项目越来越复杂,难免会出现各种缺陷。欢迎大家指出各方面的不足,也可以直接在GitHub上提交Issue或者PR,一起来做吧。
一、后端实现
实现动态加载菜单功能的第一步是完成根据当前用户查询可访问菜单信息的界面。
1.餐桌设计
基于前面提到的RBAC原理,我们应该设计一个角色表,并使用角色来对应菜单。同时,为了建立用户和角色、角色和菜单之间的关系,需要两个中间表。除了前面的用户表,一共需要五个表,每个表的字段如下图所示:
在这里,我将 admin 前缀添加到专用于后台管理的表中。命名是一件非常重要的事情。好名字是保证代码质量的前提。不要那么随随便便地跟着我。很多公司都有自己的命名原则,大家可以学习一下。(推荐《阿里巴巴Java开发手册》)
另外,和之前book和category的做法不同,这里没有使用外键。一般来说,决定使用外键取决于系统对数据一致性和效率的要求哪个更突出,但我认为数据一致性问题可以通过代码来解决,使用外键既麻烦又尴尬。
这里我简单介绍一下admin_menu表的字段:
字段说明
ID
唯一标识
小路
对应Vue路由中的路径,即地址路径
名称
对应Vue路由中的name属性
name_zh
中文名称,用于渲染导航栏(菜单)界面
icon_cls
元素图标类名,用于渲染菜单名前的小图标
零件
组件名称,用于解析路由对应的组件
parent_id
父节点id,用于存储导航栏层次结构
您可以自己设计表中的数据。为了方便测试,记得多注册一个账号来配置不同的角色,为角色配置相应的菜单。如果嫌麻烦,直接执行我的sql文件就行了:
里面有admin、test和editor三个账号,密码都是123,admin的角色是系统管理员,editor是内容管理员,test是空的。
2.pojo
因为我们使用JPA作为ORM,所以在创建POJO时需要注意以下几点:
PS 其实我们过去创建的POJO应该更具体一些,叫PO(persistant object,持久对象)或者Entity(实体),使用DTO(Data Transfer Object)与客户端进行交互。教程简化了一点,源代码添加了这些类别。
我们需要创建四个 PO,AdminUserRole、AdminRole、AdminRoleMenu 和 AdminMenu。特别的是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 children;
// getter and setter...
与数据库不同的是 Children 属性,用于存储子节点。
3.菜单查询界面(树结构查询)
根据用户查询找到对应菜单的步骤为:
为了实现这个接口,我们需要添加三个数据库访问对象,AdminUserRoleDAO、AdminRoleMenuDAO和AdminMenuDAO,并编写Service对象。一路上我们也可以写一组AdminRoles,但是还不能用。
在AdminMenuService中,需要实现一个根据当前用户查询所有菜单项的方法:
public List getMenusByCurrentUser() {
// 从数据库中获取当前用户
String username = SecurityUtils.getSubject().getPrincipal().toString();
User user = userService.findByUsername(username);
// 获得当前用户对应的所有角色的 id 列表
List rids = adminUserRoleService.listAllByUid(user.getId())
.stream().map(AdminUserRole::getRid).collect(Collectors.toList());
// 查询出这些角色对应的所有菜单项
List menuIds = adminRoleMenuService.findAllByRid(rids)
.stream().map(AdminRoleMenu::getMid).collect(Collectors.toList());
List menus = adminMenuDAO.findAllById(menuIds).stream().distinct().collect(Collectors.toList());
// 处理菜单项的结构
handleMenus(menus);
return menus;
}
该方法的主体对应于上面的前三个步骤。这里我们使用stream来简化列表的处理,包括使用map()提取集合中的某个属性,以及通过distinct()对查询到的菜单项进行去重,避免在多角色的情况下出现多余的菜单等。
接下来,需要将查询到的菜单数据列表整合成具有层次关系的菜单树,即编写handleMenus()方法。
这里多说一点,因为导航菜单一般不会很长,所以我们使用这种一次性取出的方式。在上面的过程中,我们会一边遍历列表一边查询数据库。在前台需要尽可能避免这样的多重交互。最好一次性查询全量数据,减轻服务器负担。不过后台一般都是管理员用的,流量也不大,不用担心。
如果数据量特别大,应该考虑按节点动态加载。即通过*敏*感*词*节点的展开事件,将节点id作为参数发送到后端,在前端动态查询并渲染所有子节点。该方法的实现将在后面详细讨论。
对查询到的菜单数据进行整合的思路如下:
整合方法如下:
public void handleMenus(List menus) {
for (AdminMenu menu : menus) {
List children = getAllByParentId(menu.getId());
menu.setChildren(children);
}
Iterator iterator = menus.iterator();
while (iterator.hasNext()) {
AdminMenu menu = iterator.next();
if (menu.getParentId() != 0) {
iterator.remove();
}
}
}
首先,让我解释一下,查询树结构的方法有很多种。看到有的逐级查询,有的根据实际情况直接写死。我用的这个方法的好处是不管多少层都可以正确查询,虽然执行效率会低一些,但是反正是在后台使用的,问题不大。
有的同学可能对这个方法有疑问。似乎一次应该只有两个级别的遍历。另外,菜单已经被查询过了,每次遍历的时候还是会调用查询方法。会不会频繁访问数据库,创建额外的对象,增加不必要的开销?虽然是背景,但这样写也太离谱了吧?
(感谢@m0_46435907仔细分析后提供了这个问题的答案)
这里可以放心,JPA为我们提供了持久化上下文(Persistence Context,是实体的集合),用来保证同一个持久化对象只有一个实例,不会再访问数据库当有对应的实例时(详见“JPA Persistence Context”)。因此,我们查询到的子列表中的每个 AdminMenu 对象实例都复用了 Menus 列表中的 AdminMenu 对象。
同时,Java 中的对象都是引用类型。假设我们将b放入a的孩子中,将c放入b的孩子中,然后将c放入a的孩子的孩子中。因此,经过一次遍历,就可以得到正确的层次关系。
下面的remove方法实际上是把对象名指向了null,对象本身还是存在的。所以虽然我们不能再通过b和c获取到原来的对象,但是a中的信息不会改变。
为什么在移除子节点时使用 iterator.remove() 而不是 List 的 remove 方法?这是因为在使用List遍历的时候,如果删除了一个元素,就会添加后面的元素,也就是说后面的元素的索引和列表长度会发生变化。循环继续,循环次数还是列表的原创长度,不仅会漏掉一些元素,还会导致下标溢出,运行时性能会报ConcurrentModificationException。iterator.remove() 做了一些封装,将当前索引和循环次数减1,从而避免了这个问题。
JDK 8+ 可以使用 lambda 表达式:
public void handleMenus(List menus) {
menus.forEach(m -> {
List children = getAllByParentId(m.getId());
m.setChildren(children);
});
menus.removeIf(m -> m.getParentId() != 0);
}
在 MenuController 中,根据请求调用查询逻辑。代码如下:
@GetMapping("/api/menu")
public List menu() {
return adminMenuService.getMenusByCurrentUser();
}
完成后就可以测试菜单界面了。在此之前,请确保系统处于登录状态,否则无法查询信息。
二、前端实现
前端要做的就是处理来自后端的数据,并将其传递给路由和导航菜单进行动态渲染。
1.背景页面设计
我目前有以下组件:
主要是实现页面的基本设计,方便测试的动态加载,暂时还没有这个功能。效果大致是这样的:
可以参考 GitHub 上的源码,也可以自己设计。开发完组件后,别忘了添加后台首页的路由。其他菜单对应的路由可以动态加载。如果没有事先写好,您将无法进入该页面。
2.数据处理
在我们设计AdminMenu表之前,它实际上收录了前端路由器和导航菜单所需的信息。从后台传来的数据需要整理成路由器能识别的格式。导航菜单无所谓,分配给相应的属性即可。
格式转换方法如下:
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代表我们从后端获取的菜单列表。遍历这个列表,首先判断一个菜单项是否收录子项,如果是,则进行递归处理。
下面的语句是将路由的属性与菜单项的属性对应起来。其余的都好说,主要是组件属性是一个对象,所以需要根据名字来解析(也就是获取对象引用)。同时,我们需要导入组件,所以我们可以使用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.state中添加变量adminMenu: [],在mutations中添加如下方法:
initAdminMenu (state, menus) {
state.adminMenus = menus
}
这个菜单就是上面的 fmtRoutes。当然,你也可以将数据放入localStorage,只要记得注销时清空即可。
最后,让我们编写菜单组件 AdminMenu.vue:
{{item.nameZh}}
{{ child.nameZh }}
export default {
name: 'AdminMenu',
computed: {
adminMenus () {
return this.$store.state.adminMenus
}
}
}
这里我们使用 element 的导航栏组件来执行一个两层循环来渲染我们需要的菜单。表示带有子菜单的菜单项,并表示单个菜单项。命名似乎有问题,而且似乎没有错。. .
如果有三个级别,则设置然后设置,以此类推。
终于完成了。我们试试用admin账号登录,就是上面的效果,菜单满了:
可以点击用户信息菜单跳转到对应的路由并加载组件:
使用编辑器账号登录,只会显示内容管理
下一步
这个页面很匆忙。接下来,我们计划根据之前的设计对各个模块进行改进,包括:
下一篇文章的重点是功能级权限的实现。其他方面会顺带提一下,但我不会过多赘述,因为都是已经讨论过的知识点。
终于写完了,但是感觉这个文章还是有一些地方需要打磨的。如果大家有什么不明白的地方可以随便提,但我就直接忽略代码为什么不能运行的问题。
另外,本文内容参考《_江南小雨》,是松哥的实现思路。我首先从松哥的项目中学习了 Vue。我们项目里很多前端代码都是模仿松哥的“微”写的“人力资源”项目,现在微人有11.6k星,大家可以学习一下,比我做了什么。
上一篇:Vue+Spring Boot项目实战(十四):用户认证方案和完善的访问拦截
下一篇:Vue+Spring Boot项目实战(十六):功能级访问控制的实现