前言 这个项目看起来比较详细,接下来我会按照流程学习一下这个项目。 在这里感谢 Evan-Nightly的教程https://learner.blog.csdn.net/article/details/88925013 https://github.com/Antabot/White-Jotter
项目简介 我之前也学习过前后端的一些知识,但是一直没能将知识串起来做出一个小的实现 项目,这个教程就是使用最新的前后端分离技术,这个项目的名字叫做白卷,按照 Evan的话说,这是一个简陋的带后台的门户网站
应用架构 用户界面层是Vue负责,剩下的内容都是在SpringBoot中完成
用户界面层 用户看到的页面
服务层 向服务器发出请求后台接收请求
业务逻辑层 针对具体问题的操作
数据访问层 对数据库的操作
数据存储 数据库
技术架构 我学习的内容是Vue+SpringBoot+mybatis开发一个前后端分离项目
第一部分
如何从 0 开始搭建 Web 项目?
什么是前后端分离?如何实现前后端分离?
单页面应用有哪些特点?
如何在 Web 项目中使用数据库并利用网页实现增删改查?
在开发中如何利用各种辅助手段?
Vue.js 的基本概念与用法
简单的前端页面设计
如何部署 Web 应用?
首页 图书页面展示
第二部分
后台管理模块的常见功能与布局(内容管理、用户\权限管理、运维监控)
用户身份验证、授权、会话管理与信息加密存储
Shiro 框架的使用
实现不同粒度的访问控制(动态菜单、功能控制、数据控制)
结合内容管理,实现文章的编写与展示
后台基本结构 后台页面效果 图书管理 用户管理 文章列表 文章详情
第三部分 第三部分是在前面的基础上分析项目存在的不足,并对其进行由点及面的优化, 这一部分作者还未更新
构建Vue.js项目 具体构建过程可以参考我的博客:《vue总结》
index.html 首页文件的初始化代码,viewpoint是用户网页的可视区域,用于适配手机浏览 index.html描述首页的内容,Vue项目中只有一个html文件,这是一个单页面 应用,之后所有开发的页面都会在这个单页面内部显示
1 2 3 4 5 6 7 8 9 10 11 12 13 <!DOCTYPE html > <html lang ="en" > <head > <meta http-equiv ="X-UA-Compatible" content ="IE=edge" > <meta name ="viewport" content ="width=device-width,initial-scale=1.0" > <link rel ="icon" href ="<%= BASE_URL %>favicon.ico" > <title > <%= htmlWebpackPlugin.options.title %> </title > </head > <body > <div id ="app" > </div > </body > </html >
App.vue 这是一个根组件,所有组件都包含在这个组件中。vue文件是一种自定义文 件类型,类似于html。srcipt中的内容是js代码,就是将这个组件整体导 出,这里的id=”app”与index.html中的id没有关系,只是与css对应
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 <template> <div id="app"> <!-- 这是一个容器,叫做路由视图,当前路由(URL)指向 的内容都会显示在这个容器中,所以即使其他组件拥有自己的 路由,表面上是一个单独的页面,实际上还是在App.vue内部--> <router-view/> </div> </template> <script> <!-- 将组件整体导出,之后就可以通过import引入这个组件 --> export default { name: 'App' } </script> <style> </style>
main.js 在这个js文件中创建了一个vue实例,App.vue组件挂载在main.js中的vue上 项目启动时会加载main.js,在main.js中会实例化vue,实例化vue的时候会 指定路由、模板、组件以及挂载点的信息
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import Vue from 'vue' import App from './App.vue' import router from './router' import store from './store' Vue.config.productionTip = false new Vue({ router, store, render: h => h(App) }).$mount('#app' )
前后端结合测试 后端项目创建 可以参考我的SpringBoot博客
关于前后端结合 在开发的时候,前端用前端的服务器Nginx,后端用后端的服务器Tomcat,当 开发前端内容的时候,可以把前端的请求通过前端服务器传递给后端(称为反 向代理),这样就可以实时观察结果,而不需要知道后端如何实现
前端页面开发 首先开发登录组件,在src/components文件夹下创建Login.vue组件
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 <template> <div> 用户名:<input type="text" v-model= "loginForm.username" placeholder="请输入用户名"/> <br><br> 密码: <input type="password" v-model= "loginForm.password" placeholder="请输入密码"/> <br><br> <button @click="login" >登录</button> </div> </template> <script> export default { name: 'Login', data () { return { loginForm: { username: '', password: '' }, responseResult: [] } }, methods: { login () { this.$axios .post('/hellologin',{ username: this.loginForm.username, password: this.loginForm.password }) .then(successResponse => { console.log(successResponse) if (successResponse.data.code === 200) { this.$router.replace({path: '/index'}) } }) .catch(failResponse => { }) } } } </script>
开发首页界面,在src/components文件夹下创建home文件夹,在home文件夹下 创建AppIndex.vue,也就是登录成功后跳转到的页面
1 2 3 4 5 6 7 8 9 10 11 12 <template> <div> Hello World! </div> </template> <script> export default { name: 'AppIndex' } </script> <style scoped> </style>
前端相关的配置 修改src/main.js文件,设置反向代理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import Vue from 'vue' import App from './App.vue' import router from './router' import store from './store' import ElementUI from 'element-ui' import 'element-ui/lib/theme-chalk/index.css' var axios = require ('axios' )axios.defaults.baseURL = 'http://localhost:8081/api' Vue.prototype.$axios = axios Vue.config.productionTip = false Vue.use(ElementUI) new Vue({ router, store, render: h => h(App) }).$mount('#app' )
配置页面路由 之前编写了Login和APPIndex文件,可以为这两个文件配置路由,router中 有一个index.js文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 const routes = [ { path: '' , name: 'App' , component: App, redirect: Login }, { path: '/login' , name: 'Login' , component: Login }, { path: '/index' , name: 'AppIndex' , component: AppIndex } ]
解决跨域问题 在根目录下创建 vue.config.js
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 module .exports = { publicPath: process.env.NODE_ENV === 'production' ? '/public/' : './' , outputDir: 'dist' , assetsDir: "assets" , productionSourceMap: false , filenameHashing: false , lintOnSave: true , devServer: { open: true , host: '0.0.0.0' , port: 8080 , https: false , hotOnly: false , proxy: { '/api' : { target: 'http://localhost:8081' , changeOrigin: true , }, }, }, }
后端开发 User类,根据前端发送给后端的数据,后端需要创建相应的数据对象
1 2 3 4 .post('/hellologin' , { username: this .loginForm.username, password: this .loginForm.password })
1 2 3 4 5 6 7 package com.atguigu.mybatis.entity;public class User { int id; String username; String password; ... }
Result类是为了构造response,主要是响应码
1 2 3 4 5 6 7 8 9 10 package com.atguigu.mybatis.result;public class Result { private int code; public Result (int code) { this .code = code; } ... }
controller是对响应进行处理的部分,设定账号admin,密码是1223456,分别 与接收到的User信息进行比较,根据结果返回不同的Result
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @RestController public class Logincontroller { @PostMapping (value="api/hellologin" ) public Result login (@RequestBody User requestUser) { String username = requestUser.getUsername(); username = HtmlUtils.htmlEscape(username); if (!Objects.equals("admin" , username) || !Objects.equals("123456" , requestUser.getPassword())) { String message = "账号密码错误" ; System.out.println("test" ); return new Result(400 ); } else { return new Result(200 ); } } }
数据库的引入 我用的是MySQL和MySQL Workbench。在application.yml中配置
1 2 3 4 5 6 7 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/white_jotter?serverTimezone=UTC username: root password: 19991005
为这个项目创建一个数据库,并创建一个登录信息数据表
1 2 3 4 5 6 7 8 create database white_jotter; use white_jotter; create table user( id int(11) auto_increment primary key, username varchar(255) default null, password varchar(255) default null, )ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8; insert into user values(1,'admin','123456');
使用数据库验证登录 在controller层中进行验证,验证的逻辑如下
获取前端发来的用户名和密码信息
查询数据库中是否存在同一对用户名和密码
如果存在返回200,否则返回400,这里的200不是返回信息中的200,如果 成功返回信息那么response中的状态码一定是200
项目相关的配置 在pom.xml中配置所有的依赖,可以参考我在SpringBoot教程中的配置
登录控制器 应该建立的结构如下,接下来完善登录控制器 首先application.yml中配置mybatis
1 2 3 4 5 6 7 8 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.atguigu.mybatis.entity configuration: map-underscore-to-camel-case: true
entity 这个包中应该存放对应于数据库中表的实体类
1 2 3 4 5 6 7 package com.atguigu.mybatis.entity;public class User { int id; String username; String password; ... }
mapper 这个包下的接口用于操纵数据库对象,与数据库直接交互,这种操作 可以认为具有原子性
1 2 3 4 5 6 7 8 package com.atguigu.mybatis.mapper;@Repository public interface Usermapper { User findByUsername (String username) ; User getByUsernameAndPassword (String username, String password) ; }
通过mapper.xml文件创建表和类映射关系,通过id值区分标签,resultType 是返回类型,namespace放映射文件的路径,parameterType是动态传入的参 数类型
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" > <mapper namespace ="com.atguigu.mybatis.mapper.Usermapper" > <select id ="findByUsername" parameterType ="java.lang.String" resultMap ="userresult" > select * from user where username = #{username} </select > <select id ="getByUsernameAndPassword" parameterType ="HashMap" resultMap ="userresult" > select * from user where username=#{username} and password =#{password} </select > <resultMap id ="userresult" type ="com.atguigu.mybatis.entity.User" > <id property ="id" column ="id" /> <result property ="username" column ="username" /> <result property ="password" column ="password" /> </resultMap > </mapper >
service 负责业务逻辑,与功能相关的代码。一般来说在mapper中定义最 基本的增删查改等操作,具体的操作由service完成,可以认为由多个原子 组合
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 package com.atguigu.mybatis.service;@Service public class Userservice { @Autowired Usermapper usermapper; public boolean isExist (String username) { User user = getByName(username); return null !=user; } public User getByName (String username) { return usermapper.findByUsername(username); } public User get (String username, String password) { return usermapper.getByUsernameAndPassword(username, password); } }
controller 登录控制器的核心部分,使用Userservice提供的方法查询 数据库
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @RestController public class Logincontroller { @Autowired Userservice userservice; @PostMapping (value="api/hellologin" ) public Result login (@RequestBody User requestUser) { String username = requestUser.getUsername(); username = HtmlUtils.htmlEscape(username); User user=userservice.get(username,requestUser.getPassword()); if (user==null ) return new Result(400 ); else return new Result(200 ); } }
分别启动前端和后端项目,输入正确的用户名和密码,得到正确结果,这一 部分主要是在跨域的时候出现问题,参考我的跨域解决方式
使用ElementUI辅助前端开发 这个工具是后端开发人员的福音!可以帮助我们开发前端界面
引入ElementUI 参照我的ElementUi博客
优化登录页面 可以在Element中查找需要的样式,比如我们要做一个登录表格就在Form中寻找 具体的代码我放在ElementUi中,名字是Form表单。此时已经完成了登录页面的 开发,但是存在风险,用户可以绕过登录页面直接访问别的页面,接下来还要开 发一个拦截器
前端路由和登录拦截器 使用History模式 将Vue中配置的路由从默认的hash模式切换到history模式,在router.index.js 中修改
1 2 3 4 const router = new VueRouter({ routes, mode: 'history' })
后端登录拦截器只有将前后端项目整合在一起才会生效,暂时先不使用这种方式
Vuex与前端登录拦截器 实现前端登录拦截器需要在前端判断用户的状态,可以像之前一样在组件的 data属性中设置一个状态标志,但是登录状态应该是一个全局属性,不应该 写入某一个组件中。这里可以引入Vuex,专门为Vue开发的状态管理方案 在store/index.js中设置我们需要的状态变量和方法,我们需要一个记录 用户信息的变量,这个变量是一个对象,同时设置一个方法触发这个方法 的时候会为这个用户变量赋值
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import Vue from 'vue' import Vuex from 'vuex' Vue.use(Vuex) export default new Vuex.Store({ state: { user: { username: window .localStorage.getItem('user' || '[]' ) == null ? '' : JSON .parse(window .localStorage.getItem('user' || '[]' )).username } }, mutations: { login (state, user) { state.user = user window .localStorage.setItem('user' , JSON .stringify(user)) } } })
localStorage是本地存储,在项目打开的时候会判断本地存储中是否有user这 个对象,如果存在就取出并获得username值,否则就把username设置为空,这 样只要不清除缓存登录的状态就会一直保存
修改路由配置 为了区分页面是否需要拦截,在需要拦截的路由中加一条元数据
1 2 3 4 5 6 7 path: '/index' , name: 'AppIndex' , component: AppIndex, meta: { requireAuth: true }
使用钩子函数判断是否需要拦截 钩子函数就是在某些时机会被调用的函数,在src/main.js中添加对store的引 用,首先判断访问的路径是否需要登录,如果需要判断store里有没有存储user 的信息,如果存在则放行否则跳转到登录页面,并存储访问的登录路径(以便在 登录后跳转到访问页)
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 import store from './store' router.beforeEach((to, from , next ) => { if (to.meta.requireAuth) { if (store.state.user.username) { next() } else { next({ path: 'login' , query: {redirect : to.fullPath} }) } } else { next() } } ) new Vue({ router, store, render: h => h(App) }).$mount('#app' )
修改Login.vue 之前的登录组件只需要判断后端返回的状态码然后跳转到首页,修改后的逻辑
点击登录时向后端传送数据
接收到后端返回的成功代码时触发store的login方法,把loginForm对象 传递给store中的user对象
获取登录前页面的路径并跳转,如果没有则跳转到首页
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 login () { var _this = this console .log(this .$store.state) this .$axios .post('/login' , { username: this .loginForm.username, password: this .loginForm.password }) .then(successResponse => { if (successResponse.data.code === 200 ) { _this.$store.commit('login' , _this.loginForm) var path = this .$route.query.redirect this .$router.replace({path : path === '/' || path === undefined ? '/index' : path}) } }) .catch(failResponse => { }) }
当我直接访问index页面的时候会跳转到登录页面
导航栏与图书页面设计 导航栏的实现 这个项目是一个单页面应用,当表面有多个页面功能,比如首页、图书馆 和笔记本等,为了实现在这些页面之间能够相互切换,可以使用导航栏
能够在每个页面显示
比较美观,即使是专门的后端程序员也要注意前端页面写好看点
路由配置 实现第一个要求,即需要把导航栏放在其他页面的父页面,App.vue是所 有组件的父组件,其他组件的内容都会在这里显示,有一个问题,在登录 的时候不应该显示导航栏,在components目录下新建一个组件,命名为 Home.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 <template> <div> <!--Home组件的子组件可以在这里显示--> <router-view/> </div> </template> <script> export default { name: 'Home' } </script> <style scoped> </style>
接下来建立路由的父子关系,注意在一个组件中通过导入引入了其它组件, 可以称为父子组件。但是要通过router-view控制子组件的显示还要进行 路由的相关配置,在router/index.js中修改
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 { path: '/home' , name: 'Home' , component: Home, redirect: '/index' , children: [ { path: '/index' , name: 'AppIndex' , component: AppIndex, meta: { requireAuth: true } } ] }
在Element文档中找到NavMenu,在components中新建一个文件夹common, 里面放公共的组件。新建NavMenu组件,具体代码放在ElementUi。接下来 修改Home.vue的代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 <template> <div> <!--这样除了登录页面其余页面都会显示Home父组件这个导航栏 --> <nav-menu></nav-menu> <router-view/> </div> </template> <script> import NavMenu from './common/NavMenu.vue' export default { name: 'Home', components: {NavMenu} } </script> <style scoped> </style>
当路由是/index的时候,由于/index是/home的子路由,所以/index的内容 实际是包含在/home的内部
图书页面管理 这是一个核心页面,先设计功能然后具体实现,以下这些实现组件配置的路由 都会是Home的子路由,先把之后完成的结果展示出来
图书展示区域 也就是右边的部分
分类导航栏 左侧导航栏
搜索栏 在图书内容的上方
页码 在最下方
LibraryIndex 在components中新建文件夹Library,新建LibraryIndex.vue作为图书 页面的根组件,代码放在ElementUi中的LibraryIndex
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 { path: '/home' , name: 'Home' , component: Home, redirect: '/index' , children: [ { path: '/index' , name: 'AppIndex' , component: AppIndex, meta: { requireAuth: true } }, { path: '/library' , name: 'Library' , component: LibraryIndex, meta: { requireAuth: true } } ] }
编写一个侧边栏组件,放在library文件夹中,然后在LibraryIndex.vue中 引入这个组件,代码放在ElementUi中的SideMenu中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 <template> <el-container> <el-aside style="width: 200px;margin-top: 20px"> <switch></switch> <SideMenu></SideMenu> </el-aside> <el-main> <!--<books></books>--> </el-main> </el-container> </template> <script> import SideMenu from './SideMenu.vue' export default { name: 'AppLibrary', components: {SideMenu} } </script>
Books 最后用一个组件来显示图书,这部分是主要显示内容,代码放在ElementUi中的 Books,最后将Books组件放在LibraryIndex.vue中,这是主要区域
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 <template> <el-container> <el-aside style="width: 200px;margin-top: 20px"> <switch></switch> <SideMenu></SideMenu> </el-aside> <el-main> <books class="books-area"></books> </el-main> </el-container> </template> <script> import SideMenu from './SideMenu.vue' import Books from './Books.vue' export default { name: 'AppLibrary', components: {SideMenu,Books} } </script> <style scoped> .books-area { width: 990px; margin-left: auto; margin-right: auto; } </style>
数据库设计与增删查改 增删查改也有技术含量,不要小看它
数据库设计 在设计图书界面之后也需要设计一个相应的数据表,具体的需求如下
展示书籍的信息,包括封面、标题、作者、出版日期、出版社、摘要和分类
维护分类信息
这里需要构建三张表user book category。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 #user表已经建立 #book中的cid与category中的id对应,代表图书类型 create table category ( id int(11) primary key, name varchar(255) not null )ENGINE=InnoDB DEFAULT CHARSET=utf8; create table book ( #每本书都有一个唯一的id id int(11) auto_increment, cover varchar(255) default '', title varchar(255) default '', author varchar(255) default '', data varchar(20) default '', press varchar(255) default '', abs varchar(255) default '', cid int(11), primary key(id), #建立索引 KEY fk_book_category_on_cid (cid), constraint fk_book_category_on_cid foreign key(cid) references category(id) ON DELETE SET NULL ON UPDATE CASCADE )ENGINE=InnoDB AUTO_INCREMENT=102 DEFAULT CHARSET=utf8;
在Evan的github仓库中有相应的数据库信息,可以从那上面导入数据https://github.com/Antabot/White-Jotter/blob/master/src/main/resources/data.sql
增删查改 需求如下
查 查询书籍信息
增 上传书籍信息
改 修改书籍信息
删 删除书籍信息
新建两个映射数据库的类 Book Category
1 2 3 4 5 6 7 8 9 10 11 public class Book { int id; String cover; String title; String author; String date; String press; String abs; int cid; .. }
DAO层 在mapper文件夹下新建 Bookmapper和Categorymapper
1 2 3 4 5 6 7 8 9 @Repository public interface Bookmapper { List<Book> findall () ; void add (Book book) ; void update (Book book) ; void deletebyid (int id) ; List<Book> findAllByCid (int cid) ; List<Book> findAllByTitleLikeOrAuthorLike (String title, String author) ; }
1 2 3 4 5 @Repository public interface Categorymapper { Category findbyid (int id) ; List<Category> findall () ; }
因为我用的是mybatis,所以创建Bookmapper.xml文件和Categorymapper.xml文件
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 <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" > <mapper namespace ="com.atguigu.mybatis.mapper.Bookmapper" > <select id ="findAllByCid" parameterType ="int" resultType ="com.atguigu.mybatis.entity.Book" > select * from book where cid=#{id} </select > <select id ="findAllByTitleLikeOrAuthorLike" parameterType ="HashMap" resultMap ="com.atguigu.mybatis.entity.Book" > select * from book where title like '%${title}%' or author like '%${title}%' </select > <select id ="findall" resultType ="com.atguigu.mybatis.entity.Book" > select * from book </select > <select id ="add" parameterType ="com.atguigu.mybatis.entity.Book" > insert into book values (null,#{cover},#{title},#{author},#{date},#{press},#{abs},#{cid}) </select > <select id ="update" parameterType ="com.atguigu.mybatis.entity.Book" > update book set cover=#{cover},title=#{title},author=#{author}, date=#{date},press=#{press},abs=#{abs},cid=#{cid} where id=#{id} </select > <select id ="deletebyid" parameterType ="int" > delete from book where id=#{id}; </select > <resultMap id ="bookresult" type ="com.atguigu.mybatis.entity.Book" > <id column ="id" property ="id" /> <result column ="cover" property ="cover" /> <result column ="title" property ="title" /> <result column ="author" property ="author" /> <result column ="date" property ="date" /> <result column ="press" property ="press" /> <result column ="abs" property ="abs" /> <result column ="cid" property ="cid" /> <association property ="id" column ="id" javaType ="com.atguigu.mybatis.entity.Category" select ="com.atguigu.mybatis.mapper.Bookmapper. findAllByCid" /> </resultMap > </mapper >
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" > <mapper namespace ="com.atguigu.mybatis.mapper.Categorymapper" > <select id ="findbyid" parameterType ="int" resultMap ="category" > select * from category where id=#{id} </select > <select id ="findall" resultType ="com.atguigu.mybatis.entity.Category" > select * from category </select > <resultMap id ="category" type ="com.atguigu.mybatis.entity.Category" > <id column ="id" property ="id" /> <result column ="name" property ="name" /> </resultMap > </mapper >
Service层 创建Categoryservice和Bookservice类
1 2 3 4 5 6 7 8 9 @Service public class Categoryservice { @Autowired Categorymapper categorymapper; public List<Category> list () { return categorymapper.findall(); } }
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 @Service public class Bookservice { @Autowired Bookmapper bookmapper; @Autowired Categoryservice categoryservice; public List<Book> findall () { return bookmapper.findall(); } public void add (Book book) { bookmapper.add(book); } public void update (Book book) { bookmapper.update(book); } public void deletebyid (int id) { bookmapper.deletebyid(id); } public List<Book> listbycategory (int cid) { return bookmapper.findAllByCid(cid); } public List<Book> search (String keywords) { return bookmapper.findAllByTitleLikeOrAuthorLike('%' +keywords+'%' , '%' +keywords+'%' ); } }
Controller 创建Librarycontroller
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 @RestController public class Librarycontroller { @Autowired Bookservice bookservice; @GetMapping ("api/books" ) public List<Book> list () { return bookservice.findall(); } @PostMapping ("api/books" ) public Book addorupdate (@RequestBody Book book) { List<Book> list=bookservice.search(book.getTitle()); if (list==null ) bookservice.add(book); else bookservice.update(book); return book; } @PostMapping ("api/delete" ) public void delete (@RequestBody Book book) { bookservice.deletebyid(book.getId()); } @GetMapping ("api/categories/{cid}/books" ) public List<Book> listbycategory (@PathVariable("cid" ) int cid) { if (cid!=0 ) return bookservice.listbycategory(cid); else return list(); } @GetMapping ("api/search" ) public List<Book> searchResult (@RequestParam("keywords" ) String keywords) { if ("" .equals(keywords)) return list(); else return bookservice.search(keywords); } }
核心功能的前端实现 之前已经在后端完成了增删查改的功能实现,现在完善前端相应的实现
这个组件是增加或者修改图书的弹出表单,同样放在Library文件夹下 代码放在ElementUi中的EditForm
SearchBar 这个组件是用于搜索的搜索框,代码放在ElementUi中的SearchBar
Books 继续修改Books.vue
添加搜索框
添加增加删除按钮
完善分页功能
构造增删查改对应的请求
LibraryIndex 这里的修改主要是实现分类查询
侧边分类导航栏的修改主要是实现了点击分类引发查询事件
要点讲解 之前的内容非常繁多,说实话我一时半会无法消化,但是问题不解决就一直 是问题,问题解决了就不是问题,现在我们一起解决一下
查询功能的实现 项目中应用查询的地方有三处
打开页面,默认查询出所有图书并显示
点击左侧分类栏,按照分类显示图书
在搜索栏输入作者或书名,可以模糊查询出所有书籍
页面的初始化 第一个功能是打开页面显示所有图书,即在打开页面时就自动触发相应代码 发送请求并渲染页面,可以使用钩子函数mounted,即已挂载,挂载就是在 我们写的Vue代码被转换为HTML并替换相应的DOM这个过程,这个过程结束 时就会指定mounted里的代码,以下内容在Books.vue中
1 2 3 4 5 6 7 8 9 10 11 12 13 mounted: function ( ) { this .loadBooks() } loadBooks () { var _this = this this .$axios.get('/books' ).then(resp => { if (resp && resp.status === 200 ) { _this.books = resp.data } }) }
分类显示 分类这个功能的前端实现逻辑是,点击左侧导航栏,向后端发送一个带有参数的 get请求,然后同样是修改data里的数据以实现动态渲染 这个方法在LibraryIndex.vue中
1 2 3 4 5 6 7 8 9 10 listByCategory () { var _this = this var cid = this .$refs.sideMenu.cid var url = 'categories/' + cid + '/books' this .$axios.get(url).then(resp => { if (resp && resp.status === 200 ) { _this.$refs.booksArea.books = resp.data } }) }
SideMenu中有一个方法,接下来就是组件之间的通信,在LibraryIndex组件中 的方法需要获取SideMenu组件中的数据。知道SideMenu组件是LibraryIndex 组件的子组件
1 2 3 4 5 handleSelect (key, keyPath) { this .cid = key this .$emit('indexSelect' ) }
我们用ref属性设置一个引用名,这样就可以通过_this.$ref.sideMenu来引用 侧面导航栏的实例并获取data,而且 @indexSelect=”listByCategory” 为 listByCategory方法设置了触发事件。在SideMenu中有一个$emit方法,emit 就是触发的意思,在子组件中使用$emit,即可触发在父组件中定义的事件,而 这个handleSelect方法则是由@select事件触发
1 <SideMenu @indexSelect="listByCategory" ref="sideMenu"></SideMenu>
总结一下,当你点击侧边导航栏的一个标签后内部的操作如下
触发el-menu组件的@select事件,执行handselect方法
handselect方法触发indexSelect事件,并把key也就是le-menu-item标签 的index属性的值赋给data中定义的属性,即分类码
父组件收到指令,执行时间对应的方法,即listByCategory
发送请求,后端执行查询代码,返回数据,再通过refs修改Books组件的data 以动态渲染页面
最后需要注意url的构造方式。后端通过@PathVariable接收
1 var url = 'categories/' + cid + '/books'
搜索栏查询 在后端实现关键字查询的接口
1 2 3 4 5 public List<Book> search (String keywords) { return bookmapper.findAllByTitleLikeOrAuthorLike(keywords,keywords); }
控制层对应的方法如下,通过@RequestParam来接收参数
1 2 3 4 5 6 7 8 @GetMapping ("api/search" ) public List<Book> searchResult (@RequestParam("keywords" ) String keywords) { if ("" .equals(keywords)) return list(); else return bookservice.search(keywords); }
前端对应的代码放在ElementUI中的SearchBar。点击搜索会触发父组件Books 中的方法,具体内容看ElementUI中修改后的Books
增加、修改、删除 增删改操作需要向后端发送Post请求对数据库进行操作,发送完请求后有 两个选择,一是在接收后端返回的成功代码后直接利用前端的数据刷新显 示,二是直接进行查询以显示修改后的数据。前一种做法如果代码不够严 谨可能出现未按期望修改数据库却返回成功代码的情况,会造成数据的 不一致。后一种做法有两种方式,一是直接刷新页面,二是查询对应的 Ajax请求,利用双向绑定更新显示,但是会有明显的卡顿
增加和修改 增加和修改使用的是同一个表单,不同的是修改需要先查询原来的信息,然后 对数据库进行更新操作,而增加是直接执行插入操作
1 <i class ="el-icon-circle-plus-outline" @click="dialogFormVisible = true" ></i>
el-dialog 通过该组件的 :visble.sync 属性控制它的显示
在EditForm组件中实现了一个clear方法,目的是在关闭输入框时清空原来 的数据
onSumbit 提交数据,并触发父组件中定义的 onSubmit 事件,而这个事件 对应的方法则是 loadBooks(),即查询出所有的书籍
注意Evan在前端页面使用了category这个值,我用cid直接替换
图片上传与项目的打包部署 之前的内容问题都不多,接下来的学习教程问题越来越多。。。
图片上传 之前的封面保存在网上的图床中,如果网址改变那么就无法访问到图片,就比如 Melody博客的封面就是网上的图片。上传文件的逻辑是:前端向后端发送Post 请求,后端对接收到的数据进行处理(压缩、格式转换、重命名等),并保存 到服务器中指定的位置,再把该位置对应的URL返回给前端即可
前端部分 利用Element提供的组件el-upload可以轻松搞定前端,新建一个ImgUpload组件 代码放在ElementUi中的ImgUpload。EditForm对应的有两处需要修改,一是在 表单中封面的位置添加该组件
1 2 3 <el-form-item label="出版社" :label-width="formLabelWidth" prop="press" > <el-input v-model="form.press" autocomplete="off" ></el-input> </ el-form-item>
上述代码改为
1 2 3 4 5 <el-form-item label="封面" :label-width="formLabelWidth" prop="cover" > <el-input v-model="form.cover" autocomplete="off" placeholder="图片 URL" > </el-input> <img-upload @onUpload="uploadImg" ref="imgUpload"></img -upload></el-form-item>
第二处是在method中添加对应的方法
1 2 3 uploadImg () { this .form.cover = this .$refs.imgUpload.url }
后端部分 后端主要解决如下两个问题
如何接收前端传来的图片数据并保存
如何避免重名(图片资源的名字可能重复,如不修改可能出现问题)
现在先在后端建立utils包,创建一个工具类StringUtils并编写生成对应随机 字符串的方法
1 2 3 4 5 6 7 8 9 10 11 12 public class StringUtils { public static String getRandomString (int length) { String base = "abcdefghijklmnopqrstuvwxyz0123456789" ; Random random = new Random(); StringBuffer sb = new StringBuffer(); for (int i = 0 ; i < length; i++) { int number = random.nextInt(base.length()); sb.append(base.charAt(number)); } return sb.toString(); } }
在Librarycontroller中添加PostMapping
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @PostMapping ("api/covers" )public String coversUpload (MultipartFile file) throws Exception { String folder = "D:/workspace/img" ; File imageFolder = new File(folder); File f = new File(imageFolder, StringUtils.getRandomString(6 ) + file.getOriginalFilename() .substring(file.getOriginalFilename().length() - 4 )); if (!f.getParentFile().exists()) f.getParentFile().mkdirs(); try { file.transferTo(f); String imgURL = "http://localhost:8081/api/file/" + f.getName(); return imgURL; } catch (IOException e) { e.printStackTrace(); return "" ; } }
这里涉及到对文件的操作,对接收到的文件重命名,但保留原始的格式。可以进一步 做一下压缩,或者校验前端传来的数据是否为指定格式,这个URL的前缀是我们自己 构建的,还需要把它跟我们设置的图片资源文件夹,即D:/workspace/img对应起来 在CrosConfig中添加如下代码
1 2 3 4 5 6 @Override public void addResourceHandlers (ResourceHandlerRegistry registry) { registry.addResourceHandler("/api/file/**" ).addResourceLocations( "file:" + "d:/workspace/img/" ); }
部署项目到服务器 前后端分离模式开发在部署的时候有两种选择
把前端项目部署在 web 服务器中,把后端项目部署在应用服务器中
把前端项目打包作为后端项目的静态文件,再把后端项目部署在应用服务器中
Web服务器的好处如下
可以实现反向代理,提高网站的安全性,也即是外网也无法访问内部
方便维护,一些小的修改不必同时协调前后端开发人员
对静态资源的加载速度更快
用户角色权限管理模式设计 之前是第一个部分,接下来是第二个部分,前一个部分我个人觉得对Vue知识的要 求比较高,这个部分主要就是后端的知识了
模块设计 用户角色权限管理是各类后台管理系统的重要组成部分
用户管理
用户的信息 显示用户的基本信息(昵称、联系方式、角色、部门等)
组织架构 显示、配置(增删改)组织架构、一般为树结构
用户操作 为用户分配角色(多对多)、组织架构(多对多),删除用户
用户黑白名单 对特殊用户进行特别控制
角色管理
角色信息显示角色的基本信息(名称、权限)
角色操作 根据需要增删角色、为角色分配权限(多对多,按不同粒度分配, 并实现权限的互斥性检验)
权限管理 权限一般有如下三种粒度
菜单权限
操作/功能权限 进行某一操作或使用某一功能的权限(如删除用户的权限)
数据权限 访问某种数据的权限,或对可操作数据量的控制
UI设计 使用纵向导航布局,这样比较贴切后台管理的设计思想
技术分析 从开发的角度考虑,该模块的技术要点如下
用户、角色、权限、组织架构表结构设计
用户身份验证、授权、会话管理、用户信息的加密存储
不同粒度权限的具体实现
接下来会使用shiro这个安全框架简化后端开发
访问控制及其实现思路 之前讲解了功能的实现逻辑,接下来讲解RBAC这种模式,下一篇博客的开头 就是RBAC的具体讲解
基础知识
RBAC 用户-角色-权限管理是访问控制的一种实现方式,也叫RBAC,基于角色的 权限访问控制。RABC支持三个原则
重点不是原则,而是为什么要在用户和权限管理之间加一个角色,而不是直接 将权限赋给用户。如果要修改用户权限的话,只有几个用户的时候不难操作, 但是如果有上千级别的用户数量需要同时获取或去除同一个权限,便会变得 非常麻烦,加入角色其实就是解耦合的思想,牵一发而动全身
Shiro Java Web可以选用两个安全框架 Spring Security 和 Shiro。前者比较复 杂一点,所以这个项目先使用Shiro吧。Shiro可以实现身份验证、授权、加密 和会话管理等功能,关于Shiro可以参考我相关的博客
实现思路
菜单权限 对菜单的控制权限是最常见的,在后台管理中,这个菜单通常表现为一个单 独的页面,拥有自己的URL或路由,以这个项目为例,如果想要控制用户对 不同页面的访问思路如下
使用全局前置守卫,在导航触发时向后端发送一个包含用户信息的请求
后端查询数据库中该用户可以访问的菜单(也就是Vue的路由信息)并返回
前端把接收到的数据添加到路由里,并根据新的路由表动态地渲染出导航 栏,使不同用户登录后看到不同的菜单。同时路由表也是按需加载的所以用 户无法通过URL访问没有权限的页面
系统管理员登录时加载所有的导航 普通用户登录时不加载用户管理模块 这是前后端分离的思路,传统项目实现更为简单
利用过滤器,如果用户有权限则返回需要访问的页面,没有则返回为授权 的页面
菜单的动态渲染利用模板的常规功能即可实现
功能权限 所谓功能,反映在前端就是一个组件,比如按钮或者图表,在后端就是一个接 口,其实现逻辑与对菜单的控制类似,只是触发的时机不同
第一种思路是按权限加载组件,即在渲染页面前向后端发送请求,获取有 权限使用的组件并动态渲染,并在需要调用后端接口时进行判断,防止用户 通过自行构造请求的方式绕过限制
第二种思路就是先将前端组件全部加载出来,当需要调用后端接口时进行 判断,如果无权限则弹出相应提示,这种适合对按钮的控制,图表直接不加 载数据就显得不友好
拥有权限的用户可正常使用添加角色功能 无权限的用户点击按钮会弹出提示
数据权限 数据权限也有两个层次,一是对可访问性的控制,二是对数据量的控制,可 访问性可以针对表、字段或满足某些条件的数据。针对表、字段的控制,主要 依靠在业务逻辑执行前进行判断,比如在调用对收支信息表的查询前判断当 前用户是否具有财务权限,而访问特定数据,可以直接通过 SQL 语句来实 现,比如当前用户只能查询出自身拥有的书籍,就可以通过类似SELECT * FROM book WHERE uid = #{uid} 的语句来实现。 对数据量的控制多样,比如普通用户一天只能访问2000条数据,可以通过引 用计数器来实现,此外,还有需要对一次的访问量进行控制、对某段时间能 够处理的数据量进行控制等应用场景等等