基于SpringBoot+Vue的博客后端

通过SpringBoot框架实现的博客后端模块,对前端接口进行规范实现以及文档编写。

项目需求分析

  • 实现后端接口即可,前端工程已经提供

  • 单体应用架构(具体架构包括单体、分布式、微服务等)

    用户→项目(内在功能)→数据库

  • 先快速开发实现需求,后项目优化(降低开发过程负担)

  • 优化部分:页面静态化、缓存、云存储、日志

  • docker部署

  • 云服务器和域名购买,域名备案

使用的技术

SpringBoot+MybatisPlus+Redis+MySQL

1、项目环境搭建

思路

1、我们先搭建父工程(blog-parent),然后在里面创建子工程后端模块(blog-api)。

正常情况下我们可以选择SpringInitiarazer来创建SpringBoot项目,但是此时我们选择的是另一种较为复杂的方法去熟悉一下SpringBoot项目从头搭建的过程:通过创建Maven项目继承SpringBoot项目。

在将Maven项目变成SpringBoot项目之后可以进行启动测试

在我们要添加Web依赖之后,添加了依赖之后我们就不能轻易启动项目了,要在配置文件中配置(端口,名称,数据库)之后才能启动项目。

思考:这种方式和创建SpringInitializer究竟有什么区别呢?

我们只需要讨论怎么将Maven项目转化为SpringBoot项目:

  • pom文件(parent可选择继承的SpringBoot版本,build构建Springboot插件,一大堆依赖)

  • 手写SpringBoot启动类

  • 手写SpringBootTest测试类

1.1、数据库创建

1.1.1、先创建数据库

1.2、创建父工程

1.2.1、创建Maven项目

一般来说我们会创建一个parent项目。(是为了之后创建项目的时候能够直接继承吗?)

1.2.2、pom文件完善

在pom文件中,我们完善四个大点

  • properties:文件中原来就有peoperties,在里面添加字符集和jdk版本的定义。

  • parent:此处定义Maven的父项目,我们选择在此处继承SpringBoot2.5.0

  • dependencies:在此处我们导入想要使用的相关依赖

  • build:在此处我们引入插件构建SpringBoot

  • dependencymanagement:在父工程的依赖外面加上dependencymanagement可以方便子项目导入依赖时可以不用管理版本,直接导入即可。

    1. 子工程添加依赖的时候可以不用写版本号,因为父工程已经版本号进行了控制

    2. 如果子工程想添加别的版本的依赖直接声明版本或者直接添加就行,并不会被真正的限制

    注意:如果父项目不用依赖管理,那么依赖会随着项目的继承而自动继承,可如果使用了项目管理,子项目自己就还要再导入一遍(只是不用再进行版本输入了。)

  • packaging设置为pom代表此工程是一个父工程(设置于最外层)<packaging>pom</packaging>

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
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
</properties>

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.5.0</version>
<relativePath/>
</parent>

<dependencies>

<!--引入SpringBootStarter,排除了默认使用的log-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
<!-- 排除 默认使用的logback -->
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
</exclusions>
</dependency>

<!-- 导入我们要用的日志门面log4j2 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-log4j2</artifactId>
</dependency>

<!--导入AOP依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>

<!--导入mail依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>

<!--Web项目依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!--测试依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

<!--Redis依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<!--JSON生成依赖-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.76</version>
</dependency>

<!--JDBC依赖-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>

<!--SpringBoot配置进程依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>

<!---->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>

<!---->
<dependency>
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>
<version>3.2.2</version>
</dependency>

<!--MybatisPlus依赖-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.3</version>
</dependency>

<!--Lombom依赖-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>

<!-- https://mvnrepository.com/artifact/joda-time/joda-time -->
<dependency>
<groupId>joda-time</groupId>
<artifactId>joda-time</artifactId>
<version>2.10.10</version>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

1.2.3、删除src文件

1.3、创建子工程

1.3.1、创建模块

1.3.2、完善pom依赖

由于我们上文并没有使用dependencymanagement对父项目依赖进行管理,所以我们此时创建了子项目之后,依赖就不用进行再次导入的了。

1.3.3、application配置文件

我们选择的是application.peoperties配置文件。进行基础的配置。由于前端会默认将文件部署到8080,那么我们后台就要换个端口进行部署,部署能用就行,但是要与前端的后端接口地址进行匹配,所以我们就干脆部署在前端要求的8888端口下。

前端设置的后端接口地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# server
server.port=8888
# 应用名称
spring.application.name=wahoyu_blog

# 数据库的配置datasource(字符编码以及时区)
spring.datasource.url=jdbc:mysql://localhost:3306/boot_blog?useUnicode=true&characterEncoding=UTF-8&serverTimeZone=UTC
spring.datasource.username=root
spring.datasource.password=123456
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

#配置MybatisPlus
## 日志实现
mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl
## 设置数据库表的前缀(本来数据库表和实体类之间是应该对应的,但是我们设置了数据库表的前缀)
## 设置前缀之后,数据库表名就可以有一个前缀
mybatis-plus.global-config.db-config.table-prefix=ms_

1.3.4、MybatisPlusConfig配置类

由于我们上面在配置文件中设置了MybatisPlus,所以我们还要设置一个启动类。

1.3.5、创建启动类并测试

1
2
3
4
5
6
@SpringBootApplication
public class BlogApp {
public static void main(String[] args) {
SpringApplication.run(BlogApp.class,args);
}
}

点击小三角测试成功,oh玛德,一点一点搭建SpringBoot项目简直不要更麻烦~(加油,以后就会更快了)

1.3.6、MybatisPlus分页插件注册

前端的访问是进行分页的,那么我们在这里进行一下分页配置。(MybatisPlusConfig.java)

1
2
3
4
5
6
7
//分页插件
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor(){
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
return interceptor;
}

1.3.7、跨域配置

由于前端部署在8080,我们后端部署在8888,所以涉及到要跨域名访问的问题。我们在配置文件中已经设置了后端部署的域名并与前端的接口链接进行了对应,现在我们要对后端进行一个跨域拦截,让域名为8080的前端可以通过。

1
2
3
4
5
6
7
8
9
10
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

@Override
public void addCorsMappings(CorsRegistry registry) {
//跨域配置,不可设置为*,不安全, 前后端分离项目,可能域名不一致
//本地测试 端口不一致 也算跨域 允许8080端口进行访问
registry.addMapping("/**").allowedOrigins("http://localhost:8080");
}
}

1.4、前端项目运行

可以使用控制台进入文件夹输入命令,也可使用IDE终端进行命令输入。

1.4.1、创建Vue项目

如果我们没有Vue项目,我们可以使用命令

1
2
# 创建一个初始化Vue项目
vue init webpack firstVue

如果已经有一个Vue项目,那么我们可以进入项目的目录

1.4.2、查看package.json文件

此时我们应该查看项目中的package.jsonscripts配置

1
2
3
4
5
"scripts": {
"dev": "webpack-dev-server --inline --progress --config build/webpack.dev.conf.js",
"start": "npm run dev",
"build": "node build/build.js"
},
1
2
3
4
5
"scripts": {
  "serve": "vue-cli-service serve",
  "build": "vue-cli-service build",
  "lint": "vue-cli-service lint"
},

1.4.3、添加项目依赖install

1
2
# 安装项目所需的依赖
npm install

1.4.4、打包项目build

1
2
# 打包前端项目成一个dist文件夹
npm run build

生成的文件夹不能直接运行,需要安装serve:npm install serve -S -g(一定要全局安装不然也会报错)。

必须要在dist文件夹的上一级运行部署命令。

1
serve dist

或者可以将dist文件拷贝到其他地方,比如使用hbuilder开启服务访问,或者复制到xampp的服务中访问

1.4.5、本地部署dev/server

查看其中的具体配置

1
2
3
4
# 本地部署项目
#此命令并不固定,主要是看package.json中的scripts的配置。
npm run dev
# 也可能是npm run server

2、首页内容与标签

2.1、首页-文章列表

在我们部署成功之后,下面我们主要进行实现的就是进行前端的接口实现。首先进行的就是实现首页文章列表的分页查询(包括标题,主要内容,作者等),这一步中的关键点在于:

  • 将前端的参数进行实体类Vo封装,传入Controller,对返回值进行总体Result封装

  • 使用MybatisPlus查询出Page(包括Page对象,对Wrapper的限制)

  • 使用MybatisPlus查询出Page(包括Page对象,对Wrapper的限制)

  • 通过getRecords()将Page对象转化为文章List对象

  • 查询出的文章List→适合前端输出的文章List

  • 将List转化成Vo实体类对象,再将Vo实体类对象作为data参数传入Result进行前端页面返回

2.1.1、接口说明

接口url:/articles

请求方式:POST

请求参数:

参数名称 参数类型 说明
page int 当前页数
pageSize int 每页显示的数量

返回数据:

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
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
{
"success": true, //可省略
"code": 200,
"msg": "success",
"data": [
{
"id": 1,
"title": "springboot介绍以及入门案例",
"summary": "通过Spring Boot实现的服务,只需要依靠一个Java类,把它打包成jar,并通过`java -jar`命令就可以运行起来。\r\n\r\n这一切相较于传统Spring应用来说,已经变得非常的轻便、简单。",
"commentCounts": 2,
"viewCounts": 54,
"weight": 1,
"createDate": "2609-06-26 15:58",
"author": "12",
"body": null,
"tags": [
{
"id": 5,
"avatar": null,
"tagName": "444"
},
{
"id": 7,
"avatar": null,
"tagName": "22"
},
{
"id": 8,
"avatar": null,
"tagName": "11"
}
],
"categorys": null
},
{
"id": 9,
"title": "Vue.js 是什么",
"summary": "Vue (读音 /vjuː/,类似于 view) 是一套用于构建用户界面的渐进式框架。",
"commentCounts": 0,
"viewCounts": 3,
"weight": 0,
"createDate": "2609-06-27 11:25",
"author": "12",
"body": null,
"tags": [
{
"id": 7,
"avatar": null,
"tagName": "22"
}
],
"categorys": null
},
{
"id": 10,
"title": "Element相关",
"summary": "本节将介绍如何在项目中使用 Element。",
"commentCounts": 0,
"viewCounts": 3,
"weight": 0,
"createDate": "2609-06-27 11:25",
"author": "12",
"body": null,
"tags": [
{
"id": 5,
"avatar": null,
"tagName": "444"
},
{
"id": 6,
"avatar": null,
"tagName": "33"
},
{
"id": 7,
"avatar": null,
"tagName": "22"
},
{
"id": 8,
"avatar": null,
"tagName": "11"
}
],
"categorys": null
}
]
}

2.1.2、文章列表主体内容

2.1.2.1、表架构

boot_blog.sql

2.1.2.2、vo.Result

作为给前端生成返回值的实体类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Data
public class ArticleVo {

private Long id;
private String title;
private String summary;
private int commentCounts;
private int viewCounts;
private int weight;
/**
* 创建时间
*/
private String createDate;
private String author;
// private ArticleBodyVo body;
private List<TagVo> tags;
// private List<CategoryVo> categorys;
}

2.1.2.3、实体类(entity、pojo)

创建Article、Tag、SysUser的实体类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//Article实体类
@Data
public class Article {

public static final int Article_TOP = 1;
public static final int Article_Common = 0;
private Long id;
private String title;
private String summary;
private int commentCounts;
private int viewCounts;
//作者id
private Long authorId;
//内容id
private Long bodyId;
//类别id
private Long categoryId;
//置顶
private int weight = Article_Common;
//创建时间
private Long createDate;
}

1
2
3
4
5
6
7
8
//Tag实体类
@Data
public class Tag {
private Long id;
private String avatar;
private String tagName;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//SysUser实体类
@Data
public class SysUser {
private Long id;
private String account;
private Integer admin;
private String avatar;
private Long createDate;
private Integer deleted;
private String email;
private Long lastLogin;
private String mobilePhoneNumber;
private String nickname;
private String password;
private String salt;
private String status;
}

2.1.2.4、Mapper(Dao)

mapper继承MybatisPlusa中的BaseMapper<实体类>,相当于自动实现众多方法。

1
2
3
4
5
//ArticleMapper
@Mapper
public interface ArticleMapper extends BaseMapper<Article> {
}

1
2
3
4
5
//SysUserMapper
@Mapper
public interface SysUserMapper extends BaseMapper<SysUser> {
}

1
2
3
4
5
//TagMapper
@Mapper
public interface TagMapper extends BaseMapper<Tag> {
}

2.1.2.5、Controller

负责将读取参数并将传入的参数进一步传递到Service中进行逻辑处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@RestController
@RequestMapping("articles")
public class ArticleController {

@Autowired
ArticleService articleService;

//首页文章列表
@PostMapping
public Result listArticle(@RequestBody PageParams pageParams){

return articleService.listArticle(pageParams);
}
}

2.1.2.6、Service

1
2
3
4
5
6
7
//ArticleService
public interface ArticleService {

//分页查询文章列表
Result listArticle(PageParams pageParams);
}

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
@Service
public class ArticleServiceImpl implements ArticleService {

@Autowired
ArticleMapper articleMapper;

@Override
public Result listArticle(PageParams pageParams) {

//通过传入的页数和页表大小创建Page对象
Page<Article> page = new Page<>(pageParams.getPage(), pageParams.getPageSize());

//创建查询用的Wrapper对象,并对wrapper对象进行限制
LambdaQueryWrapper<Article> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.orderByDesc(Article::getCreateDate,Article::getWeight);

//通过当前页数,页表大小,查询条件 -> 文章页
Page<Article> articlePage = articleMapper.selectPage(page, queryWrapper);

//文章页 -> 原生文章列表
List<Article> records = articlePage.getRecords();

// 原生文章列表 -> 精修后文章列表
List<ArticleVo> articleVoList = copyList(records);

//将精修的文章列表进行返回
return Result.success(articleVoList);
}

//调用copy函数,挨个元素,将 原生文章列表 -> 精修后的文章列表
private List<ArticleVo> copyList(List<Article> records){
List<ArticleVo> articleVoList = new ArrayList<>();
for(Article record : records){
articleVoList.add(copy(record));
}
return articleVoList;
}

//BeanUtils.copyProperties使用将一个实体类对象中的一个信息完全转移到另一个实体类对象
private ArticleVo copy (Article article){
ArticleVo articleVo = new ArticleVo();
BeanUtils.copyProperties(article,articleVo);
//单独设置时间
articleVo.setCreateDate(new DateTime(article.getCreateDate()).toString("yyyy-MM-dd HH:MM"));
return articleVo;
}
}

2.1.2.7、Vo对象(给前端传送的数据的封装实体类)

传入参数Vo

1
2
3
4
5
6
7
@Data
public class PageParams {

//请求文章列表时,前端向后端传输的前端信息
private int page = 1;
private int pageSize = 10;
}

返回结果Vo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Data
public class ArticleVo {

private Long id;
private String title;
private String summary;
private int commentCounts;
private int viewCounts;
private int weight;
//创建时间
private String createDate;
private String author;
// private ArticleBodyVo body;
private List<TagVo> tags;
// private List<CategoryVo> categorys;

}

2.1.2.8、测试

运行失败,重新部署后成功。可能是因为重启了项目?

目前,我们实现了简单的,将后端的文章列表数据库一条线衔接到前端接口。

目前我们实现了文章列表的简单实现,现在我们还要对队列中的众多细节进行实现:比如标签、作者等……

但是我们依旧要注意以下几点:

  • 一个文章只能有一个作者,却可以有多个标签

  • 作者也就是用户,博客注册后,不写文章就是普通的用户,写文章就是作者

  • 作者表通过id直接与文章表进行连接,但是标签与文章表之间间隔一个id互查表,所以要进行Vo转化。

2.1.3、文章列表中标签的显示

不是每一个文章都会有标签的,所以我们在进行文章copy的时候会进行tag和author的信息和判断👇

首先我们将文章的Tag列表赋给文章列表中的每个文章,这就涉及到对每个文章进行copy函数中(具体实现再ArticleService中的Article to ArticleVo的操作)

我们发现文章和标签的格式是一对一进行的,我们再输出文章列表的时候可以通过articleid对tags进行查询,将查询到的tags列表填入到articleVo实体类对象中。

articleService→tagService→tagMapper(xml书写查询规则)。在Tag部分,我们依旧使用copy原则将查询值进行返回。(将查询到的List<Tag>转化成tagList)

2.1.3.1、ArtivleService

在copy成ArticleVo的过程中将内部进行填补。再将author也进行填充之后,再对Article进行完善。

1
2
3
4
5
6
//判断是否有tag进行输出,如果有tag,向article-tag表格中获取articleid,获取tags列表放到articleVo实体类对象中
if (isTag) {
Long articleId = article.getId();
articleVo.setTags(tagService.findTagsByArticleId(articleId));
}

2.1.3.2、TagService

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
@Service
public class TagServiceImpl implements TagService {

@Autowired
private TagMapper tagMapper;

@Override
public List<TagVo> findTagsByArticleId(Long id) {
//MyabtisPlus 无法进行多表查询(可以通过JPA进行指令进行查询)
List<Tag> tags = tagMapper.findTagsByArticleId(id);
return copyList(tags);
}

//将Tag列表转化成articleVo可以识别的tagList
public List<TagVo> copyList(List<Tag> tagList){
List<TagVo> tagVoList = new ArrayList<>();
for (Tag tag : tagList) {
tagVoList.add(copy(tag));
}
return tagVoList;
}

//将tag列表中的每一个Tag进行格式转换
public TagVo copy(Tag tag){
TagVo tagVo = new TagVo();
BeanUtils.copyProperties(tag,tagVo);
return tagVo;
}
}

2.1.3.3、TagMapper

1
2
3
4
5
@Mapper
public interface TagMapper extends BaseMapper<Tag> {
//根据文章id查询标签列表
List<Tag> findTagsByArticleId(Long atricleId);
}

2.1.3.4、TagMapper.xml

1
2
3
4
5
6
7
8
9
10
11
<?xml version="1.0" encoding="UTF-8" ?>
<!--MyBatis配置文件-->
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.blog.mapper.TagMapper">
<select id="findTagsByArticleId" parameterType="long" resultType="com.blog.entity.Tag">
select id,avatar,tag_name as tagName from ms_tag
where id in
(select tag_id from ms_article_tag where article_id=#{articleId})
</select>
</mapper>

2.1.4、文章列表中作者的显示

对于User我们决定暂时不设置Vo类,因为我们可以直接通过两个表之间的数值进行查询。上面的标签是使用了另一个表将文章列表和标签页表进行连接(这是因为一个文章只能有一个作者,但是可以有许多个标签)

2.1.4.1、ArticleService

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
//调用copy函数,挨个元素,将 原生文章列表 -> 精修后的文章列表
private List<ArticleVo> copyList(List<Article> records, boolean isTag , boolean isAuthor){
List<ArticleVo> articleVoList = new ArrayList<>();
for(Article record : records){
articleVoList.add(copy(record,isTag,isAuthor));
}
return articleVoList;
}

//BeanUtils.copyProperties使用将一个实体类对象中的一个信息完全转移到另一个实体类对象
//判断是否需要输出tag和author进行输出
private ArticleVo copy (Article article , boolean isTag , boolean isAuthor){
ArticleVo articleVo = new ArticleVo();
BeanUtils.copyProperties(article,articleVo);
//单独设置时间
articleVo.setCreateDate(new DateTime(article.getCreateDate()).toString("yyyy-MM-dd HH:MM"));

//判断是否有tag进行输出,如果有tag,向article-tag表格中获取articleid,获取tags列表放到articleVo实体类对象中
if (isTag) {
Long articleId = article.getId();
articleVo.setTags(tagService.findTagsByArticleId(articleId));
}

//
if (isAuthor){
Long authorId = article.getAuthorId();
articleVo.setAuthor(sysUserService.findUserById(authorId).getNickname());
}
return articleVo;
}

2.1.4.2、UserService

1
2
3
4
public interface SysUserService {

SysUser findUserById(Long id);
}

2.1.4.3、UserServiceImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Service
public class SysUserServiceImpl implements SysUserService {

@Autowired
private SysUserMapper sysUserMapper;

@Override
public SysUser findUserById(Long id) {
SysUser sysUser = sysUserMapper.selectById(id);
if(sysUser == null){
sysUser = new SysUser();
sysUser.setNickname("Wahoyu");
}
return sysUser;
}
}

2.1.5、测试

2.2、首页-最热标签

我们想要在主页显示出被使用的数目最多的标签并进行显示,主要思路是:

  • 先通过SQL语句找出articleId-tagId表中最热门的六个标签的tagId,将tagIds列表传回Service

  • 通过底层的将tagIds列表转化成tags列表

2.2.1、接口说明

接口url:/tags/hot

请求方式:GET

请求参数:无

返回数据:

1
2
3
4
5
6
7
8
9
10
11
{
"success": true,
"code": 200,
"msg": "success",
"data": [
{
"id":1,
"tagName":"4444"
}
]
}

2.2.2、编码

2.2.2.1、Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@RestController
@RequestMapping("tags")
public class TagsController {

@Autowired
TagService tagService;

@GetMapping("hot")
public Result hot(){
int limit = 6;
return tagService.hots(limit);
}
}

2.2.2.2、Service

1
2
3
4
5
6
public interface TagService {

List<TagVo> findTagsByArticleId(Long articleId);

Result hots(int limit);
}

2.2.2.3、ServiceImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//最热标签
@Override
public Result hots(int limit) {

//查询出最热标签的ids
List<Long> tagIds = tagMapper.findHotsTagIds(limit);
if(CollectionUtils.isEmpty(tagIds)){
return Result.success(Collections.emptyList());
}

//将tagids转化成tags
List<Tag> tagList = tagMapper.findTagsByIds(tagIds);
return Result.success(tagList);
}

2.2.2.4、TagMapper

1
2
3
4
5
6
7
8
9
@Mapper
public interface TagMapper extends BaseMapper<Tag> {
//根据文章id查询标签列表
List<Tag> findTagsByArticleId(Long atricleId);

List<Long> findHotsTagIds(int limit);

List<Tag> findTagsByIds(List<Long> tagIds);
}

2.2.2.5、TagMapper.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!--查询最热标签ids(前n条)-->
<select id="findHotsTagIds" parameterType="int" resultType="java.lang.Long">
SELECT tag_id
FROM `ms_article_tag` GROUP BY tag_id
ORDER BY COUNT(*) DESC
LIMIT #{limit}
</select>

<!--将tagids转化成tagList-->
<select id="findTagsByIds" parameterType="list" resultType="com.blog.entity.Tag">
select id , tag_name as tagName from ms_tag
where id in
<foreach collection="tagIds" item="tagId" separator="," open="(" close= ")">
#{tagId}
</foreach>
</select>

2.2.3、测试

2.3、首页-最热文章

最热文章是通过article的浏览量进行判断的。所以我们要去article表中找一下view_count字段,根据他进行排序。并不是复杂查询,所以使用的是Wrapper进行筛选。

2.3.1 接口说明

接口url:/articles/hot

请求方式:POST

请求参数:

参数名称 参数类型 说明

返回数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"success": true,
"code": 200,
"msg": "success",
"data": [
{
"id": 1,
"title": "springboot介绍以及入门案例",
},
{
"id": 9,
"title": "Vue.js 是什么",
},
{
"id": 10,
"title": "Element相关",

}
]
}

2.3.2 编码

2.3.2.1、ArticleController

1
2
3
4
5
6
7
8
//首页显示最热文章
@PostMapping("hot")
public Result hotArticle(){

int limit = 5;
//返回主页文章列表
return articleService.hotArticle(limit);
}

2.3.2.2、ArticleService

1
2
//首页显示最热文章
Result hotArticle(int limit);

2.3.2.3、ArticleServiceImpl

1
2
3
4
5
6
7
8
9
10
11
//首页显示最热文章
@Override
public Result hotArticle(int limit) {
LambdaQueryWrapper<Article> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.orderByDesc(Article::getViewCounts);
queryWrapper.select(Article::getId,Article::getTitle);
queryWrapper.last("limit "+limit);
//sql: select id ,title from article order by view_counts desc limit 5
List<Article> articles = articleMapper.selectList(queryWrapper);
return Result.success(copyList(articles,false,false));
}

2.3.3测试

2.4、首页-最新文章

最新文章与最热文章类似,只不过最热文章是按照浏览量进行排序,最新文章是按照创建时间进降序排序。

2.4.1 接口说明

接口url:/articles/new

请求方式:POST

请求参数:

参数名称 参数类型 说明

返回数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"success": true,
"code": 200,
"msg": "success",
"data": [
{
"id": 1,
"title": "springboot介绍以及入门案例",
},
{
"id": 9,
"title": "Vue.js 是什么",
},
{
"id": 10,
"title": "Element相关",

}
]
}

2.4.2 编码

2.4.2.1、ArticleController

1
2
3
4
5
6
7
8
//首页显示最新文章
@PostMapping("new")
public Result newArticle(){

int limit = 5;
//返回主页文章列表
return articleService.newArticle(limit);
}

2.4.2.2、ArticleService

1
2
//首页显示最新文章
Result newArticle(int limit);

2.4.2.3、ArticleServiceImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//首页显示最新文章
@Override
public Result newArticle(int limit) {
//创建Wrapper对象
LambdaQueryWrapper<Article> queryWrapper = new LambdaQueryWrapper<>();
//通过文章的观看次数进行降序排序
queryWrapper.orderByDesc(Article::getCreateDate);
//限制查询id和title
queryWrapper.select(Article::getId,Article::getTitle);
//查询语句的结尾
queryWrapper.last("limit "+limit);
//sql: select id ,title from article order by view_counts desc limit 5

List<Article> articles = articleMapper.selectList(queryWrapper);
return Result.success(copyList(articles,false,false));
}

2.4.3 测试

2.5、首页-文章归档

每一个文章创建的时候都有时间,我们将其分为年月的形式。某年某月你发表了多少文章就是文章归档。相当于是你某段时间发表文章的计数。

具体的思路以及重点如下:

  • 通过SQL语句查出每个年-月的文章counts

  • 创建dos文件夹,用于放置不需要持久化的实体类对象(与Vo的差别在哪儿?)

分层领域模型规约:

  • DO( Data Object):与数据库表结构一一对应,通过DAO层向上传输数据源对象。

  • DTO( Data Transfer Object):数据传输对象,Service或Manager向外传输的对象。

  • BO( Business Object):业务对象。 由Service层输出的封装业务逻辑的对象。

  • AO( Application Object):应用对象。 在Web层与Service层之间抽象的复用对象模型,极为贴近展示层,复用度不高。

  • VO( View Object):显示层对象,通常是Web向模板渲染引擎层传输的对象。

  • POJO( Plain Ordinary Java Object):在本手册中, POJO专指只有setter/getter/toString的简单类,包括DO/DTO/BO/VO等。

Vo与Do的区别:

  • Vo是为了符合前端的渲染规则,创建了实体类的不同格式的副本

  • Do直接从数据库中产生的,直接符合数据库和前端的数据要求

2.5.1接口说明

接口url:/articles/listArchives

请求方式:POST

请求参数:

参数名称 参数类型 说明

返回数据:

1
2
3
4
5
6
7
8
9
10
11
12
{
"success": true,
"code": 200,
"msg": "success",
"data": [
{
"year": "2021",
"month": "6",
"count": 2
}
]
}

2.5.2 编码

2.5.2.1、ArticleController

1
2
3
4
5
//显示首页文章归档
@PostMapping("listArchives")
public Result listArchives(){
return articleService.listArchives();
}

2.5.2.2、dos.Archives

1
2
3
4
5
6
7
8
9
10
//用于展示首页文章归档
@Data
public class Archives {

private Integer year;

private Integer month;

private Integer count;
}

2.5.2.3、ArticleService

1
2
//首页显示文章归档
Result listArchives();

2.5.2.4、ArticleServiceImpl

1
2
3
4
5
6
//首页显示文章归档
@Override
public Result listArchives() {
List<Archives> archivesList = articleMapper.listArchives();
return Result.success(archivesList);
}

2.5.2.5、ArticleMapper

1
2
3
4
5
6
@Mapper
public interface ArticleMapper extends BaseMapper<Article> {

//显示主页文章归档
List<Archives> listArchives();
}

2.5.2.6、ArticleMapper.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?xml version="1.0" encoding="UTF-8" ?>
<!--MyBatis配置文件-->
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.blog.mapper.ArticleMapper">


<!--显示主页文章归档-->
<select id="listArchives" resultType="com.blog.dos.Archives">
select year(create_date) as year,month(create_date) as month,count(*) as count
from ms_article
group by year,month
</select>
</mapper>

2.5.3 测试

2.6、统一异常处理

不管是controller层还是service,dao层,都有可能报异常。如果是预料中的异常,可以直接捕获处理;如果是意料之外的异常,需要统一进行异常处理,进行记录,并给用户提示相对比较友好的信息。

1
2
3
4
5
6
7
8
9
10
11
12
//对加了@Controller注解的方法进行拦截处理 AOP的实现
@ControllerAdvice
public class AllExceptionHandler {
//进行异常处理,处理Exception.class的异常
@ExceptionHandler(Exception.class)
@ResponseBody //返回json数据
public Result doException(Exception ex){
ex.printStackTrace();
return Result.fail(-999,"系统异常");
}
}

当我们在任意一个层出现了奇怪的报错的时候,会进行报错。虽然前端会显示200,但是实际上获取到的信息是-999报错。但是实际上我们获取到的信息是-999。

3、用户认证相关操作

3.1、用户登陆

在此部分我们首先导入两个依赖:

  • 我们再程序中先将密码加密再和数据库中的密文进行核实:MD5加密算法

  • 登陆成功后,服务器发给浏览器token:JWT生成token

同时我们还要对程序进行简单的一些实体类/工具类优化:

  • 前端登录参数类

  • 生成token工具类

  • 统一异常代码枚举

代码思路:(我们主要使用LoginService)

  1. (获取用户信息时)访问权限页面时,会直接进行检验你有没有token(先验证你的token是否正确,再去redis中查找有没有),如果检验成功则获取用户信息,检验失败则进行登陆环节。

  2. 在Service中我们首先检查前端传入的参数进行控制判断,如果参数为空则报错

  3. 如果参数都存在,则直接对密码进行加密

  4. 此处调用SysUserService,根据用户名和加密密码去user表中查询找用户,如果没有用户则报错

  5. 如果存在用户,则利用JWT生成一个token

  6. 将token放入Redis数据库当中 (token:存放用户id) 设置过期时间;

  7. 将token信息return给前端,剩下的就交给前端吧。

3.1.1 接口说明

接口url:/login

请求方式:POST

请求参数:

参数名称 参数类型 说明
account string 账号
password string 密码

返回数据:

1
2
3
4
5
6
{
"success": true,
"code": 200,
"msg": "success",
"data": "token"
}

3.1.2 JWT技术

登录使用JWT技术。(Json Web Token)

jwt 可以生成 一个加密的token,做为用户登录的令牌,当用户登录成功之后,发放给客户端。

请求需要登录的资源或者接口的时候,将token携带,后端验证token是否合法。

jwt 有三部分组成:A.B.C

  • A:Header,{“type”:”JWT”,”alg”:”HS256”} 固定

  • B:playload,存放信息,比如,用户id,过期时间等等,可以被解密,不能存放敏感信息

  • C: 签证,A+B+秘钥 加密而成,只要秘钥不丢失,可以认为是安全的。

jwt 验证,主要就是验证C部分 是否合法。

依赖包:

1
2
3
4
5
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>

工具类:

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
package com.mszlu.blog.utils;

import io.jsonwebtoken.Jwt;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

import java.util.Date;
import java.util.HashMap;
import java.util.Map;

public class JWTUtils {

private static final String jwtToken = "123456Mszlu!@#$$";

public static String createToken(Long userId){
Map<String,Object> claims = new HashMap<>();
claims.put("userId",userId);
JwtBuilder jwtBuilder = Jwts.builder()
.signWith(SignatureAlgorithm.HS256, jwtToken) // 签发算法,秘钥为jwtToken
.setClaims(claims) // body数据,要唯一,自行设置
.setIssuedAt(new Date()) // 设置签发时间
.setExpiration(new Date(System.currentTimeMillis() + 24 * 60 * 60 * 60 * 1000));// 一天的有效时间
String token = jwtBuilder.compact();
return token;
}

public static Map<String, Object> checkToken(String token){
try {
Jwt parse = Jwts.parser().setSigningKey(jwtToken).parse(token);
return (Map<String, Object>) parse.getBody();
}catch (Exception e){
e.printStackTrace();
}
return null;

}

}

3.1.3 JWT测试

在此写一个SpringBootTest测试类对JWT工具类进行测试

1
2
3
4
5
6
7
8
9
10
@SpringBootTest
public class BlogAppTest {
@Test
public static void main(String[] args) {
String token = JWTUtils.createToken(100L);
System.out.println(token);
Map<String, Object> map = JWTUtils.checkToken(token);
System.out.println(map.get("userId"));
}
}

3.1.4 编码

3.1.4.1、导入依赖

导入MD5加密算法的依赖和JWT生成token的依赖

1
2
3
4
5
6
7
8
9
10
11
<!--MD5加密依赖-->
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
</dependency>
<!--JWT登陆实现技术-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>

3.1.4.2、创建Vo及工具类

1、统一异常代码

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
public enum  ErrorCode {

PARAMS_ERROR(10001,"参数有误"),
ACCOUNT_PWD_NOT_EXIST(10002,"用户名或密码不存在"),
NO_PERMISSION(70001,"无访问权限"),
SESSION_TIME_OUT(90001,"会话超时"),
NO_LOGIN(90002,"未登录"),;

private int code;
private String msg;

ErrorCode(int code, String msg){
this.code = code;
this.msg = msg;
}

public int getCode() {
return code;
}

public void setCode(int code) {
this.code = code;
}

public String getMsg() {
return msg;
}

public void setMsg(String msg) {
this.msg = msg;
}
}

2、JWT 生成token工具类

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
public class JWTUtils {

//密钥:相当于盐
private static final String jwtToken = "123456Mszlu!@#$$";

//生成Token
public static String createToken(Long userId){

//将UserId放入hashmap
Map<String,Object> claims = new HashMap<>();
claims.put("userId",userId);

//生成Token并继续返回
JwtBuilder jwtBuilder = Jwts.builder()
.signWith(SignatureAlgorithm.HS256, jwtToken) // 签发算法,秘钥为jwtToken
.setClaims(claims) // body数据,要唯一,自行设置
.setIssuedAt(new Date()) // 设置签发时间
.setExpiration(new Date(System.currentTimeMillis() + 24 * 60 * 60 * 60 * 1000));// 一天的有效时间
String token = jwtBuilder.compact();
return token;
}

//对Token进行验证,检测是否正确
public static Map<String, Object> checkToken(String token){
try {
//对Token进行解析
Jwt parse = Jwts.parser().setSigningKey(jwtToken).parse(token);
//解析成功后获取Token的Body部分(也就是B部分:用户的ID)
return (Map<String, Object>) parse.getBody();
}catch (Exception e){
e.printStackTrace();
}
return null;

}

}

3、前端登录参数类

1
2
3
4
5
6
@Data
public class LoginParams {

private String account;
private String password;
}

3.1.4.2、Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@RestController
@RequestMapping("login")
public class LoginController {

/*
//不推荐使用SysUserService,应该让UserService只负责与User表操作相关的操作
@Autowired
SysUserService sysUserService;
*/

@Autowired
private LoginService loginService;

@PostMapping
public Result login(@RequestBody LoginParams loginParams){
//登陆 验证用户 访问用户表
return loginService.login(loginParams);
}
}

3.1.4.3、LoginService和SysUserService

1
2
3
4
5
6
//LoginService
public interface LoginService {
//登陆验证
Result login(LoginParams loginParams);
}

1
2
3
4
5
//SysUserService

//通过用户名和密码查询用户
SysUser findUser(String account, String password);

3.1.4.4、LoginServiceImpl和SysUserServiceImpl

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
//LoginServiceImpl
@Service
public class LoginServiceImpl implements LoginService {

@Autowired
private SysUserService sysUserService;
@Autowired
private RedisTemplate<String,String> redisTemplate;
//加密盐
private static final String salt = "wahoyu!@#";

//登陆验证
@Override
public Result login(LoginParams loginParams) {

//获取前端的用户名和密码参数
String account = loginParams.getAccount();
String password = loginParams.getPassword();

//判断参数们进行空值判断
if (StringUtils.isBlank(account) || StringUtils.isBlank(password)){
return Result.fail(ErrorCode.PARAMS_ERROR.getCode(),ErrorCode.PARAMS_ERROR.getMsg());
}

//对密码进行md5加密 (需要导入依赖)(注意是commons-codec类)
password = DigestUtils.md5Hex(password + salt);

//去uesr表中查询加密后的用户名和密码是否存在
SysUser sysUser = sysUserService.findUser(account,password);

//判断表中是否存在用户名
if(sysUser == null){
return Result.fail(ErrorCode.ACCOUNT_PWD_NOT_EXIST.getCode(),ErrorCode.ACCOUNT_PWD_NOT_EXIST.getMsg());
}

//生成token
String token = JWTUtils.createToken(sysUser.getId());

//将Token放入Redis并设置时间(1天)
redisTemplate.opsForValue().set("TOKEN_"+token, JSON.toJSONString(sysUser),1, TimeUnit.DAYS);

return Result.success(token);
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//SysUserServiceImpl
//通过用户名和密码对用户进行查询
@Override
public SysUser findUser(String account, String password) {
LambdaQueryWrapper<SysUser> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SysUser::getAccount,account);
queryWrapper.eq(SysUser::getPassword, password);
queryWrapper.select(SysUser::getAccount, SysUser::getId, SysUser::getAvatar, SysUser::getNickname);
//只查找一个,不继续往下找,节省sql效率
queryWrapper.last("limit 1");

return sysUserMapper.selectOne(queryWrapper);
}

3.1.5、测试

  • 在测试类中输出加密后的password,手动存储在数据库中。
1
2
3
4
5
6
7
8
9
10
@SpringBootTest
public class BlogAppTest {
@Test
public static void main(String[] args) {
String password = "123456";
String salt = "wahoyu!@#";
password = DigestUtils.md5Hex(password + salt);
System.out.println(password); //455c1c9a7e5f5aa5d377eef865eea283
}
}
  • 运行Redis服务器,和SpringBoot项目

  • Postman对请求进行书写

3.2、获取用户信息

参数并不是直接传输到后端的,而是要我们直接从HttpHeader中获取。所以前端发的是GET请求不是POST。

获取用户信息思路步骤:

  1. Controller将header中的token取出传给后端

  2. Service通过JWT对token进行合法性校验,不合格则报错

  3. 取出Redis数据库中,token对应的json格式的sysUser对象

  4. 将json格式的信息转化成SysUser格式的对象

  5. 将sysUser对象复制成loginUserVo格式的对象,发给前端

后期优化:

  • 将验证token的步骤从sysUserService转移到了LoginService中方便后面,拦截器对token进行检查

3.2.1 接口说明

接口url:/users/currentUser

请求方式:GET

请求参数:

参数名称 参数类型 说明
Authorization string 头部信息(TOKEN)

返回数据:

1
2
3
4
5
6
7
8
9
10
11
{
"success": true,
"code": 200,
"msg": "success",
"data": {
"id":1,
"account":"admin",
"nickaname":"Wahoyu",
"avatar":"ss"
}
}

3.2.2、编码

3.2.2.1、LoginUserVo

1
2
3
4
5
6
7
8
9
10
11
@Data
public class LoginUserVo {

private Long id;

private String account;

private String nickname;

private String avatar;
}

3.2.2.2、Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@RestController
@RequestMapping("users")
public class UserController {

@Autowired
private SysUserService sysUserService;

//之前都是RequestBody意思是从前端的Body中进获取参数
//此方法是主动从Header中获取Authorization变量,命名为token
@GetMapping("currentUser")
public Result currentUser(@RequestHeader("Authorization") String token){

//将token传入service
return sysUserService.getUserInfoByToken(token);
}
}

3.2.2.3、统一异常码

1
TOKEN_ERROR(10001,"用户名或密码不存在"),

3.2.2.4、LoginService

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
//获取用户信息和设置拦截器的时候checkToken
//通过Token获取用户信息
@Override
public SysUser checkToken(String token) {

//判断token是否为空值
if (StringUtils.isBlank(token)){
return null;
}
//1.通过JWT对token进行合法性校验,不合格则报错
Map<String, Object> map = JWTUtils.checkToken(token);
if (map == null){
return null;
}

//我们当初进行Redis存储时,key是token,value是JSON.toJSONString(sysUser)
//通过JWT格式检查之后,在Redis中进行查出token对应的sysuser的json值,没有则报错
String userJson = redisTemplate.opsForValue().get("TOKEN_" + token);
if (StringUtils.isBlank(userJson)){
return null;
}

//通过json解析器,将userJson信息转化成SysUser格式的对象
SysUser sysUser = JSON.parseObject(userJson, SysUser.class);
return sysUser;
}

3.2.2.5、SysUserService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//通过Token获取用户信息
@Override
public Result getUserInfoByToken(String token) {

//检查token并生成sysUser
SysUser sysUser = loginService.checkToken(token);
if (sysUser == null){
Result.fail(ErrorCode.TOKEN_ERROR.getCode(),ErrorCode.TOKEN_ERROR.getMsg());
}

//创建一个适配前端的 用户信息 对象loginUserVo
LoginUserVo loginUserVo = new LoginUserVo();
//将上述sysUser对象的信息全部复制到Vo对象中
loginUserVo.setAccount(sysUser.getAccount());
loginUserVo.setAvatar(sysUser.getAvatar());
loginUserVo.setId(sysUser.getId());
loginUserVo.setNickname(sysUser.getNickname());

//返回用户信息对象Vo
return Result.success(loginUserVo);
}

3.2.3、测试

打开Redis+SpringBoot+HBuilder服务器(一定要打开redis)

如图显示登陆成功

3.3、退出登陆

此时我们只需要做一件事,那就是把token从Redis数据库中删除。(我们的Redis服务器就相当于Session,我们将redis清空之后,获取不到用户数据自然就退出了。我们能够操作的也只有后端Redis。)

3.3.1 接口说明

接口url:/logout

请求方式:GET

请求参数:

参数名称 参数类型 说明
Authorization string 头部信息(TOKEN)

返回数据:

1
2
3
4
5
6
{
"success": true,
"code": 200,
"msg": "success",
"data": null
}

3.3.2 编码

3.3.2.1、Controller

1
2
3
4
5
6
7
8
9
10
11
12
@RestController
@RequestMapping("logout")
public class LogoutController {

@Autowired
private LoginService loginService;

@GetMapping
public Result logout(@RequestHeader("Authorization") String token){
return loginService.logout(token);
}
}

3.3.2.2、Service

1
2
3
4
5
6
7
public interface LoginService {
//登陆验证
Result login(LoginParams loginParams);

//退出登陆
Result logout(String token);
}

3.3.2.3、ServiceImpl

1
2
3
4
5
6
7
//退出登陆
@Override
public Result logout(String token) {
//从Redis中将Token的信息进行删除
redisTemplate.delete("TOKEN_"+token);
return Result.success(null);
}

3.3.3 测试

记得开启Redis服务器进行测试。退出登陆成功

3.4、注册

关于文件:

由于注册功能与登陆功能类似,我们借用登陆的LoginParams,借用LoginService写注册主逻辑,借用SysUserService写关于用户查询和用户保存相关逻辑。

关于id:

我们默认的id生成算法时分布式(雪花算法)。如果我们想实现逐步递增,可以在sysuser的实体类id字段上添加注解@TableIdId(type = IdType.AUTO)。但是此时我们不进行修改,就是用分布式。以后用户多了之后,要进行分表操作 id就需要使用分布式id。

注册功能实现思路:

  1. 判断参数是否有空值

  2. 判断账户是否已经存在(返回账户已经存在)

  3. 注册用户(调用SysUserService的save方法)

  4. 生成token (直接让登陆完的用户回主页)

  5. 将token存入redis

  6. 将token返回给前端路程

  7. 给LoginService接口类加上事务注解 一旦中间出现任何问题 需要回滚 &#x20;

3.4.1 接口说明

接口url:/register

请求方式:POST

请求参数:

参数名称 参数类型 说明
account string 账号
password string 密码
nickname string 昵称

返回数据:

1
2
3
4
5
6
{
"success": true,
"code": 200,
"msg": "success",
"data": "token"
}

3.4.2 编码

3.4.2.1 LoginParams

参数LoginParam中 添加新的参数nickname。

1
2
3
4
5
6
@Data
public class LoginParams {
private String account;
private String password;
private String nickname;
}

3.4.2.2 RegisterController

借用登陆参数Vo来实现注册功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RestController
@RequestMapping("register")
public class RegisterController {

//将注册相关的功能放在LoginService里面
@Autowired
private LoginService loginService;

//借用loginparams去接收参数
@PostMapping
public Result register (@RequestBody LoginParams loginParams){
//sso 叫做单点登录。后期如果把登陆注册功能单独提出去(单独的服务,可以独立提供接口服务)
return loginService.register(loginParams);
}
}

3.4.2.3 LoginService

1
2
//注册
Result register(LoginParams loginParams);

3.4.2.4 LoginServiceImpl

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
//注册
@Override
public Result register(LoginParams loginParams) {
/**
* 1. 判断参数是否合法
* 2. 判断账户是否已经存在(返回账户已经被注册)
* 3. 注册用户
* 4. 生成token
* 5. 存入redis并返回
* 6. 注意加上事务 一旦中间出现任何问题 需要回滚
*/

//从前端参数中获取数据
String account = loginParams.getAccount();
String password = loginParams.getPassword();
String nickname = loginParams.getNickname();

//判断参数是否合法,不合法则报出异常
if (StringUtils.isBlank(account) || StringUtils.isBlank(password) || StringUtils.isBlank(nickname)){
return Result.fail(ErrorCode.PARAMS_ERROR.getCode(),ErrorCode.PARAMS_ERROR.getMsg());
}

//如果数据库中有这个用户,则报出用户已存在异常
SysUser sysUser = this.sysUserService.findUserByAccount(account);
if (sysUser != null){
return Result.fail(ErrorCode.ACCOUNT_EXIST.getCode(),ErrorCode.ACCOUNT_EXIST.getMsg());
}

//新建用户对象并填入信息(信息可以是前端参数传送过来的,可以是自己定义的)
sysUser = new SysUser();
sysUser.setNickname(nickname);
sysUser.setAccount(account);
sysUser.setPassword(DigestUtils.md5Hex(password+salt));
sysUser.setCreateDate(System.currentTimeMillis());
sysUser.setLastLogin(System.currentTimeMillis());
sysUser.setAvatar("/static/img/logo.b3a48c0.png");
sysUser.setAdmin(1); //1 为true
sysUser.setDeleted(0); // 0 为false
sysUser.setSalt("");
sysUser.setStatus("");
sysUser.setEmail("");

//将新建用户信息保存到数据库中
this.sysUserService.save(sysUser);

//通过JWT创建一个token
String token = JWTUtils.createToken(sysUser.getId());

//将token存入redis
redisTemplate.opsForValue().set("TOKEN_"+token, JSON.toJSONString(sysUser),1, TimeUnit.DAYS);

//给前端赋予token
return Result.success(token);
}

3.4.2.5 ErrorCode账号存在

1
ACCOUNT_EXIST(10004,"账号已存在"),

3.4.2.6 SysUserService

1
2
3
4
5
//注册时判断用户是否存在
SysUser findUserByAccount(String account);

//注册时保存用户
void save(SysUser sysUser);

3.4.2.7 SysUserServiceImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//注册时根据账户查找用户
@Override
public SysUser findUserByAccount(String account) {
LambdaQueryWrapper<SysUser> queryWrapper = new LambdaQueryWrapper<>();

//注意:此处的查询wrapper仔细考虑。我们使用SysUserh类中的getAccount方法,让他等于我们前端传入的account数值进行查询
queryWrapper.eq(SysUser::getAccount,account);
queryWrapper.last("limit 1");
return this.sysUserMapper.selectOne(queryWrapper);
}

//注册时保存用户
@Override
public void save(SysUser sysUser) {

//id会自动生成
//这个地方默认生成的id时分布式id,采用的是雪花算法
//在此处我们就不对用户id进行配置了(在SysUser的id上添加注解)-> @TableIdId(type = IdType.AUTO)
//以后 用户多了之后 要进行分表操作 id就需要使用分布式id
this.sysUserMapper.insert(sysUser);
}

3.4.2.8 事务注解

1
2
3
4
5
6
7
8
9
10
//事务注解
@Transactional
public interface LoginService {
//登陆验证
Result login(LoginParams loginParams);
//退出登陆
Result logout(String token);
//注册
Result register(LoginParams loginParams);
}

3.4.3 测试

打开三个服务器进行测试。

3.5、优化-滚去登陆

初始的登录逻辑时将登陆验证放在每一个文件中,利用代码进行判断。可如果登陆的逻辑发生了改变,我们岂不是要每一个代码都要发生改变?

所以我们要进行统一登陆判断,使用拦截器进行登录拦截。遇到需要登陆才能访问的接口,我们的拦截器将会进行登陆验证,如果没登陆,会把登陆页面交给你,让你滚去登录!

我们在SpringMVC中有一个功能叫拦截器

总的思路:

  1. 配置拦截器interceptor

  2. 在拦截器中添加日志(方便查看相关信息)

  3. 在WebMvcConfig中对拦截器进行具体的路径配置

拦截器具体思路:

  1. 需要判断 请求的接口路径 是否为 HandlerMethod(controller方法),如果是访问静态资源的RequestResourceHandler,应该放行

  2. 判断 token 是否为空(如果为空:未登录)

  3. 如果token不为空:进行登陆验证checkToken)

  4. 如果验证成功 运行即可

关于拦截路径:

由于我们的日志系统读文章不需要什么用户权限,所以我们现在知识对test路径下的controller方法进行限制

1
2
3
4
5
6
7
//配置访问限制
@Override
public void addInterceptors(InterceptorRegistry registry) {
//拦截test接口 后续遇到真正需要限制的接口时,再配置真正的接口
registry.addInterceptor(loginInterceptor)
.addPathPatterns("/test");
}

如果是其他日志系统,我们可以进行全局封存并进行部分放行(如登陆页面的放行)

1
2
3
4
5
6
7
8
9
10
11
12
//配置访问限制
@Override
public void addInterceptors(InterceptorRegistry registry) {
//拦截test接口 后续遇到真正需要限制的接口时,再配置真正的接口
registry.addInterceptor(loginInterceptor)
//拦截
.addPathPatterns("/**")

//放行
.excludePathPatterns("/login")
.excludePathPatterns("/register");
}

3.5.1 拦截器实现(添加日志)

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
@Component
@Slf4j
public class LoginInterceptor implements HandlerInterceptor {

@Autowired
private LoginService loginService;

//我们需要重写其中的prehandler实现(意思是在调用Controller的方法之前进行执行)
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
/**
* 1. 需要判断 请求的接口路径 是否为 HandlerMethod(controller方法),如果是访问静态资源的RequestResourceHandler,应该放行
* 2. 判断 token 是否为空(如果为空:未登录
* 3. 如果token不为空:进行登陆验证checkToken)
* 4. 如果验证成功 运行即可
*/

//放行 访问静态资源的 RequestResourceHandler
if (!(handler instanceof HandlerMethod)){
return true;
}

//获取token
String token = request.getHeader("Authorization");

//日志信息
log.info("=================request start===========================");
String requestURI = request.getRequestURI();
log.info("request uri:{}",requestURI);
log.info("request method:{}",request.getMethod());
log.info("token:{}", token);
log.info("=================request end===========================");

//判断token是否为空
if (StringUtils.isBlank(token)){
//返回json格式的错误信息
Result result = Result.fail(ErrorCode.NO_LOGIN.getCode(),"未登录");
response.setContentType("application/json;charset=utf-8");
response.getWriter().print(JSON.toJSONString(result));
return false;
}

//认证token
SysUser sysUser = loginService.checkToken(token);
if(sysUser == null){
//返回json格式的错误信息
Result result = Result.fail(ErrorCode.NO_LOGIN.getCode(),"未登录");
response.setContentType("application/json;charset=utf-8");
response.getWriter().print(JSON.toJSONString(result));
return false;
}

//登陆验证成功,放行
return true;
}
}

3.5.2在WebMvcConfig中注册拦截器

1
2
3
4
5
6
7
//配置访问限制
@Override
public void addInterceptors(InterceptorRegistry registry) {
//拦截test接口 后续遇到真正需要限制的接口时,再配置真正的接口
registry.addInterceptor(loginInterceptor)
.addPathPatterns("/test");
}

3.5.3 测试

token属于头部信息,需要使用API调试工具进行测试,直接在浏览器访问不可以。

而且我们不能对此博客系统进行全面封禁,因为本项目前端设置的请求文章列表和标签的时候,头部不会携带token信息。拦截器从头部中获取不到token,自然就不能通过拦截器。

1、设置TestController

2、头部未携带token进行访问时(失败):

3、携带token(一定要放在头部里!!!!!)进行访问(访问成功):

但是我们现在出现了一个问题:如果我们将全局进行封锁,只放行login和register。我们获取不到众多标签和文章,原因是我们的请求头中没有携带token

3.6、优化-ThreadLocal保存用户信息

我们现在,在登陆了之后,如果我们想直接从前端获取当前用户的信息(注意:获取用户信息是获取数据库中存储的对象,并不是获取token),我们该怎么办呢?

我们选择使用ThreadLocal技术进行数据存储。此方法的原理涉及多线程。简言之就是:往一个线程里面存数据,这个数据只能由这个线程获取,其他线程无法获取这个数据

3.6.1 ThreadLocal工具类

  • 放到Utils中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class UserThreadLocal {

private UserThreadLocal(){}

private static final ThreadLocal<SysUser> LOCAL = new ThreadLocal<>();

//存入数据
public static void put(SysUser sysUser){
LOCAL.set(sysUser);
}

//获取数据
public static SysUser get(){
return LOCAL.get();
}

//删除数据
public static void remove(){
LOCAL.remove();
}
}

3.6.2 拦截器配置

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
@Component
@Slf4j
public class LoginInterceptor implements HandlerInterceptor {
@Autowired
private LoginService loginService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//在执行controller方法(Handler)之前进行执行
/**
* 1. 需要判断 请求的接口路径 是否为 HandlerMethod (controller方法)
* 2. 判断 token是否为空,如果为空 未登录
* 3. 如果token 不为空,登录验证 loginService checkToken
* 4. 如果认证成功 放行即可
*/
if (!(handler instanceof HandlerMethod)){
//handler 可能是 RequestResourceHandler springboot 程序 访问静态资源 默认去classpath下的static目录去查询
return true;
}
String token = request.getHeader("Authorization");

log.info("=================request start===========================");
String requestURI = request.getRequestURI();
log.info("request uri:{}",requestURI);
log.info("request method:{}",request.getMethod());
log.info("token:{}", token);
log.info("=================request end===========================");

if (StringUtils.isBlank(token)){
Result result = Result.fail(ErrorCode.NO_LOGIN.getCode(), "未登录");
response.setContentType("application/json;charset=utf-8");
response.getWriter().print(JSON.toJSONString(result));
return false;
}
SysUser sysUser = loginService.checkToken(token);
if (sysUser == null){
Result result = Result.fail(ErrorCode.NO_LOGIN.getCode(), "未登录");
response.setContentType("application/json;charset=utf-8");
response.getWriter().print(JSON.toJSONString(result));
return false;
}
//登录验证成功,放行
//我希望在controller中 直接获取用户的信息 怎么获取?
UserThreadLocal.put(sysUser);
return true;
}

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserThreadLocal.remove();
}
}

3.6.3 TestController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.mszlu.blog.controller;

import com.mszlu.blog.dao.pojo.SysUser;
import com.mszlu.blog.utils.UserThreadLocal;
import com.mszlu.blog.vo.Result;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("test")
public class TestController {

@RequestMapping
public Result test(){
SysUser sysUser = UserThreadLocal.get();
System.out.println(sysUser);
return Result.success(null);
}
}

3.6.4 断点测试

获取用户信息成功

4、文章-相关内容

4.1、文章-查看具体内容

大致流程:

我们想要查看单个文章的具体内容,需要同时获得文章的详细内容、文章的标签列表、文章的分类信息,并将以上信息同时封装进一个Vo中。

详细思路:

  1. 数据库添加文章内容表分类表

  2. 添加文章内容分类表的实体类

  3. 写Controller,将前端获取到的文章id,将id传到Service以获取文章的全部信息

  4. 我们现在Service中调用一个总方法,此方法调用copy方法

  5. 我们决定需要在三个Vo用来放置参数(将三个Vo全部封装于ArticleVo中进行返回)

    1. 文章标签列表(之前写过)

    2. 文章body(文章详细内容)

    3. 文章分类Vo(分类id,分类的图标,分类的名称)

  6. 先模仿前面的Article对象copy转化成ArticleVo写一个逻辑demo

    1. 先通过id获取文章的基础信息(article表)

    2. 我们之前获取过文章对应的tag_list,此处可以直接调用方法

    3. 通过article表中的body_id对文章的具体内容进行查找

      • articleBody可以直接在ArticleService进行查询,并直接将值取过来进行返回(articleBody_id→content→转移给ArticleBodyVo)
    4. 通过article表中的category_id对文章的分类内容进行对应查找

      • category则需要在CategoryService中新建方法进行查询。(写一个空mapper→service→将查询到的直接copy到Vo中)
  7. 对上面的copyList方法进行一个重载,原来的用于展示文章列表,新加的里面对Body和分类都进行了判断与内容添加

关于复制属性:

  • 如果属性不是完全对应,可以使用articleBodyVo.setContent(articleBody.getContent());方法对单个属性进行copy

  • 如果属性完全对应(真的完全对应吗?)可以使用BeanUtils.copyProperties(A,B)方法,将A中的属性完全复制到B中。

4.1.1 接口说明

接口url:/articles/view/{id}

请求方式:POST

请求参数:

参数名称 参数类型 说明
id long 文章id(路径参数)

返回数据:

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
{
"success": true,
"code": 200,
"msg": "success",
"data": {
"id": 1,
"title": "springboot介绍以及入门案例",
"summary": "通过Spring Boot实现的服务,只需要依靠一个Java类,把它打包成jar,并通过`java -jar`命令就可以运行起来。\r\n\r\n这一切相较于传统Spring应用来说,已经变得非常的轻便、简单。",
"commentCounts": 16,
"viewCounts": 125,
"weight": 0,
"createDate": "2610-10-10 15:10",
"author": "Wahoyu",
"body": {
"content": "# 1. Spring Boot介绍\r\n\r\n## 1.1 简介\r\n\r\n在您第1次接触和学习Spring框架的时候,是否因为其繁杂的配置而退却了?\r\n\r\n在你第n次使用Spring框架的时候,是否觉得一堆反复黏贴的配置有一些厌烦?\r\n\r\n那么您就不妨来试试使用Spring Boot来让你更易上手,更简单快捷地构建Spring应用!\r\n\r\nSpring Boot让我们的Spring应用变的更轻量化。\r\n\r\n我们不必像以前那样繁琐的构建项目、打包应用、部署到Tomcat等应用服务器中来运行我们的业务服务。\r\n\r\n通过Spring Boot实现的服务,只需要依靠一个Java类,把它打包成jar,并通过`java -jar`命令就可以运行起来。\r\n\r\n这一切相较于传统Spring应用来说,已经变得非常的轻便、简单。\r\n\r\n**总结一下Spring Boot的主要优点:**\r\n\r\n1. 为所有Spring开发者更快的入门\r\n2. 开箱即用,提供各种默认配置来简化项目配置\r\n3. 内嵌式容器简化Web项目\r\n4. 没有冗余代码生成和XML配置的要求\r\n5. 统一的依赖管理\r\n6. 自动装配,更易使用,更易扩展\r\n\r\n## 1.2 使用版本说明\r\n\r\nSpringboot版本:使用最新的2.5.0版本\r\n\r\n教程参考了官方文档进行制作,权威。\r\n\r\n其他依赖版本:\r\n\r\n\t1. Maven 需求:3.5+\r\n\r\n \t2. JDK 需求 8+\r\n \t3. Spring Framework 5.3.7以上版本\r\n \t4. Tomcat 9.0\r\n \t5. Servlet版本 4.0 但是可以部署到Servlet到3.1+的容器中\r\n\r\n# 2. 快速入门\r\n\r\n快速的创建一个Spring Boot应用,并且实现一个简单的Http请求处理。通过这个例子对Spring Boot有一个初步的了解,并体验其结构简单、开发快速的特性。\r\n\r\n教程使用的Idea版本:2019.3\r\n\r\n## 2.1 创建基础项目\r\n\r\n**第一步:** 创建maven项目\r\n\r\npom.xml :\r\n\r\n~~~xml\r\n<?xml version=\"1.0\" encoding=\"UTF-8\"?>\r\n<project xmlns=\"http://maven.apache.org/POM/4.0.0\"\r\n xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\r\n xsi:schemaLocation=\"http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\">\r\n <modelVersion>4.0.0</modelVersion>\r\n\r\n <groupId>com.xiaopizhu</groupId>\r\n <artifactId>helloSpringBoot</artifactId>\r\n <version>1.0-SNAPSHOT</version>\r\n\t<!--springboot的父工程其中定义了常用的依赖,并且无依赖冲突-->\r\n <parent>\r\n <groupId>org.springframework.boot</groupId>\r\n <artifactId>spring-boot-starter-parent</artifactId>\r\n <version>2.5.0</version>\r\n </parent>\r\n</project>\r\n~~~\r\n\r\n注意上方的parent必须加,其中定义了springboot官方支持的n多依赖,基本上常用的已经有了,所以接下来导入依赖的时候,绝大部分都可以不加版本号。\r\n\r\n此时的工程结构为:\r\n\r\n![image-20210523173241557](img/image-20210523173241557.png)\r\n\r\n**第二步:** 添加web依赖\r\n\r\n~~~xml\r\n<dependencies>\r\n <dependency>\r\n <groupId>org.springframework.boot</groupId>\r\n <artifactId>spring-boot-starter-web</artifactId>\r\n </dependency>\r\n</dependencies>\r\n~~~\r\n\r\n添加上方的web依赖,其中间接依赖了spring-web,spring-webmvc,spring-core等spring和springmvc的包,并且集成了tomcat。\r\n\r\n**第三步:** 编写启动类\r\n\r\n~~~java\r\npackage com.xiaopizhu.springboot;\r\n\r\nimport org.springframework.boot.SpringApplication;\r\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\r\n\r\n@SpringBootApplication\r\npublic class HelloApp {\r\n\r\n public static void main(String[] args) {\r\n SpringApplication.run(HelloApp.class,args);\r\n }\r\n}\r\n\r\n~~~\r\n\r\n@SpringBootApplication注解标识了HelloApp为启动类,也是Spring Boot的核心。\r\n\r\n**第四步:** 运行启动类的main方法\r\n\r\n![image-20210523173712142](img/image-20210523173712142.png)\r\n\r\n看到如上配置,证明启动成功,tomcat端口号默认为8080。\r\n\r\n**第五步:** 如果想要修改端口号,可以在resources目录下新建application.properties\r\n\r\n~~~properties\r\nserver.port=8082\r\n~~~\r\n\r\n**第六步:** 重新运行\r\n\r\n![image-20210523174011613](img/image-20210523174011613.png)\r\n\r\n此时的项目结构为:\r\n\r\n![image-20210523174032053](img/image-20210523174032053.png)\r\n\r\n**src/main/java :** 编写java代码,注意启动类需要放在项目的根包下。\r\n\r\n**src/main/resources:** 放置资源的目录,比如springboot的配置文件,静态文件,mybatis配置,日志配置等。\r\n\r\n**src/test/java:** 测试代码\r\n\r\n## 2.2 编写一个Http接口\r\n\r\n**第一步:**创建`HelloController`类,内容如下:\r\n\r\n~~~java\r\npackage com.xiaopizhu.springboot.controller;\r\n\r\nimport org.springframework.web.bind.annotation.GetMapping;\r\nimport org.springframework.web.bind.annotation.RequestMapping;\r\nimport org.springframework.web.bind.annotation.RestController;\r\n\r\n@RestController\r\n@RequestMapping(\"hello\")\r\npublic class HelloController {\r\n\r\n @GetMapping(\"boot\")\r\n public String hello(){\r\n return \"hello spring boot\";\r\n }\r\n\r\n}\r\n\r\n~~~\r\n\r\n**注意包名,必须在启动类所在的包名下。**\r\n\r\n**第二步:**重启程序,使用postman或者直接在浏览器输入http://localhost:8082/hello/boot\r\n\r\n得到结果:hello spring boot\r\n\r\n## 2.3 编写单元测试用例\r\n\r\n**第一步:**添加spring boot测试依赖\r\n\r\n~~~xml\r\n\t\t<dependency>\r\n <groupId>org.springframework.boot</groupId>\r\n <artifactId>spring-boot-starter-test</artifactId>\r\n <scope>test</scope>\r\n </dependency>\r\n~~~\r\n\r\n**第二步:**在src/test 下,编写测试用例\r\n\r\n~~~java\r\npackage com.xiaopizhu.springboot.controller;\r\n\r\nimport org.junit.jupiter.api.BeforeAll;\r\nimport org.junit.jupiter.api.BeforeEach;\r\nimport org.junit.jupiter.api.Test;\r\nimport org.springframework.boot.test.context.SpringBootTest;\r\nimport org.springframework.http.MediaType;\r\nimport org.springframework.test.web.servlet.MockMvc;\r\nimport org.springframework.test.web.servlet.request.MockMvcRequestBuilders;\r\nimport org.springframework.test.web.servlet.setup.MockMvcBuilders;\r\n\r\nimport static org.hamcrest.Matchers.equalTo;\r\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;\r\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;\r\n\r\n@SpringBootTest\r\npublic class TestHelloController {\r\n\r\n private MockMvc mockMvc;\r\n\r\n @BeforeEach\r\n public void beforeEach(){\r\n mockMvc = MockMvcBuilders.standaloneSetup(new HelloController()).build();\r\n }\r\n @Test\r\n public void testHello() throws Exception {\r\n mockMvc.perform(MockMvcRequestBuilders.get(\"/hello/boot\")\r\n .accept(MediaType.APPLICATION_JSON))\r\n .andExpect(status().isOk())\r\n .andExpect(content().string(equalTo(\"hello spring boot\")));\r\n }\r\n}\r\n\r\n~~~\r\n\r\n上面的测试用例,是构建一个空的`WebApplicationContext`,并且在before中加载了HelloController,得以在测试用例中mock调用,模拟请求。\r\n\r\n## 2.4 打包为jar运行\r\n\r\n**第一步:**添加打包(maven构建springboot)插件\r\n\r\n~~~xml\r\n <build>\r\n <plugins>\r\n <plugin>\r\n <groupId>org.springframework.boot</groupId>\r\n <artifactId>spring-boot-maven-plugin</artifactId>\r\n </plugin>\r\n </plugins>\r\n </build>\r\n~~~\r\n\r\n在idea的右侧 maven中,使用package来打包程序,打包完成后,在target目录下生成helloSpringBoot-1.0-SNAPSHOT.jar\r\n\r\n![image-20210523181737720](img/image-20210523181737720.png)\r\n\r\n**第二步:**打开cmd:找到jar对应的目录\r\n\r\n输入命令\r\n\r\n~~~shell\r\njava -jar helloSpringBoot-1.0-SNAPSHOT.jar\r\n~~~\r\n\r\n![image-20210523182426404](img/image-20210523182426404.png)\r\n\r\n**第三步:**测试,使用postman或者直接在浏览器输入http://localhost:8082/hello/boot\r\n\r\n得到结果:hello spring boot\r\n\r\n## 2.5 查看jar包内容\r\n\r\n~~~shell\r\njar tvf helloSpringBoot-1.0-SNAPSHOT.jar\r\n~~~\r\n\r\n# 3. 小结\r\n\r\n1. 通过Maven构建了一个空白Spring Boot项目,再通过引入web模块实现了一个简单的请求处理。\r\n2. 通过修改配置文件,更改端口号\r\n3. 编写了测试用例\r\n4. 打包jar包运行\r\n\r\n"
},
"tags": [
{
"id": 1,
"tagName": "springboot"
},
{
"id": 2,
"tagName": "spring"
},
{
"id": 3,
"tagName": "springmvc"
}
],
"category": {
"id": 2,
"avatar": "/static/category/back.png",
"categoryName": "后端"
}
}
}

4.1.2 编码

4.1.2.1 数据库表

内容表

1
2
3
4
5
6
7
8
CREATE TABLE `ms_article_body`  (
`id` bigint(0) NOT NULL AUTO_INCREMENT,
`content` longtext CHARACTER SET utf8 COLLATE utf8_general_ci NULL,
`content_html` longtext CHARACTER SET utf8 COLLATE utf8_general_ci NULL,
`article_id` bigint(0) NOT NULL,
PRIMARY KEY (`id`) USING BTREE,
INDEX `article_id`(`article_id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 38 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

分类表

1
2
3
4
5
6
7
CREATE TABLE `blog`.`ms_category`  (
`id` bigint(0) NOT NULL AUTO_INCREMENT,
`avatar` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL,
`category_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL,
`description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 6 CHA RACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

4.1.2.2 Entity

1
2
3
4
5
6
7
8
@Data
public class ArticleBody {
private Long id;
private String content;
private String contentHtml;
private Long articleId;
}

1
2
3
4
5
6
7
8
@Data
public class Category {
private Long id;
private String avatar;
private String categoryName;
private String description;
}

4.1.2.3 Controller

1
2
3
4
5
6
7
//ArticleController
//显示文章详细信息
@PostMapping("view/{id}")
public Result findArticleById(@PathVariable("id") Long articleId){
return articleService.findArticleById(articleId);
}

4.1.2.4 梳理Vo之间的关系

我们之前也返回过ArticleVo,但是我们在其中加入了作者信息标签列表

现在我们对前端进行返回的是ArticleVo副本,里面添加了我们要加入的分类信息文章详细信息

4.1.2.7 Service与Copy重载

  • Contrller调用,用于展示文章详细信息的方法,并进行返回
1
2
3
4
5
6
7
8
9
10
11
12
13
//显示文章详细信息(内容加标签分类等全部)
@Override
public Result findArticleById(Long articleId) {

//先通过id创造出一个包含简单信息的article对象
Article article = this.articleMapper.selectById(articleId);

//将简单article 转化为 简单articleVo
ArticleVo articleVo = copy(article,true,true,true,true);

//将简单Vo进行返回
return Result.success(articleVo);
}
  • 原copy函数(用来显示文章列表)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//BeanUtils.copyProperties使用将一个实体类对象中的一个信息完全转移到另一个实体类对象
//判断是否需要输出tag和author进行输出
private ArticleVo copy (Article article , boolean isTag , boolean isAuthor){
ArticleVo articleVo = new ArticleVo();
BeanUtils.copyProperties(article,articleVo);
//单独设置时间
articleVo.setCreateDate(new DateTime(article.getCreateDate()).toString("yyyy-MM-dd HH:MM"));

//判断是否有tag进行输出,如果有tag,向article-tag表格中获取articleid,获取tags列表放到articleVo实体类对象中
if (isTag) {
Long articleId = article.getId();
articleVo.setTags(tagService.findTagsByArticleId(articleId));
}

//如果文章有对应作者,则显示
if (isAuthor){
Long authorId = article.getAuthorId();
articleVo.setAuthor(sysUserService.findUserById(authorId).getNickname());
}
return articleVo;
}
  • 第二个copy函数(用来显示文章详细信息)
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
//这是针对文章详细内容的ArticleVo-copy方法,为文章列表的copy方法之重载版本
@Autowired
CategoryService categoryService;
private ArticleVo copy (Article article , boolean isTag , boolean isAuthor,boolean isBody ,boolean isCategory){
ArticleVo articleVo = new ArticleVo();
BeanUtils.copyProperties(article,articleVo);
//单独设置时间
articleVo.setCreateDate(new DateTime(article.getCreateDate()).toString("yyyy-MM-dd HH:MM"));

//判断是否有tag进行输出,如果有tag,向article-tag表格中获取articleid,获取tags列表放到articleVo实体类对象中
if (isTag) {
Long articleId = article.getId();
articleVo.setTags(tagService.findTagsByArticleId(articleId));
}

//如果文章有对应作者,则显示
if (isAuthor){
Long authorId = article.getAuthorId();
articleVo.setAuthor(sysUserService.findUserById(authorId).getNickname());
}

//如果需要body信息,要进行查询
if (isBody){
Long bodyId = article.getBodyId();
articleVo.setBody(findArticleByBodyId(bodyId));
}

//如果需要category信息,要进行查询
if (isCategory){
Long categoryId = article.getCategoryId();
articleVo.setCategory(categoryService.findCategoryById(categoryId));
}

return articleVo;
}
  • ArticleBodyService借用ArticleService(将文章详细内容返回给第二版copy函数)
1
2
3
4
5
6
7
//查询文章详细信息(文章内容)
private ArticleBodyVo findArticleByBodyId(Long bodyId) {
ArticleBody articleBody = articleBodyMapper.selectById(bodyId);
ArticleBodyVo articleBodyVo = new ArticleBodyVo();
articleBodyVo.setContent(articleBody.getContent());
return articleBodyVo;
}
  • CategoryService(将分类信息返回给第二版copy函数)
1
2
3
4
5
6
//CategoryService
public interface CategoryService {
//显示详细文章信息时,对分类信息进行查询
CategoryVo findCategoryById(Long categoryId);
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//CategoryServiceImpl
@Service
public class CategoryServiceImpl implements CategoryService {

@Autowired
private CategoryMapper categoryMapper;

//显示详细文章信息时,对分类信息进行查询
@Override
public CategoryVo findCategoryById(Long categoryId) {
Category category = categoryMapper.selectById(categoryId);
CategoryVo categoryVo = new CategoryVo();
BeanUtils.copyProperties(category,categoryVo);
return categoryVo;
}
}

4.1.2.8 Mapper

  • ArticleBodyMapper
1
2
3
@Mapper
public interface ArticleBodyMapper extends BaseMapper<ArticleBody> {
}
  • CategoryMapper
1
2
3
@Mapper
public interface CategoryMapper extends BaseMapper<Category> {
}

4.1.3 测试

4.2、阅读数更新-线程池(JUC)

我们看了文章之后,文章阅读数是不是应该增加!

我们第一时间想到的是在Service中添加操作,在查看完文章新增阅读数目。但是此处有问题,我们在查看完之后,本应该直接返回数据了

  • 这个时候做了一个更新操作,更新会增加一个写锁,阻塞其他的操作,操作就会比较低。(无法解决)

  • 更新就会增加此次接口的耗时,而且如果一旦更新出问题了,很可能会直接影响后续的文章展示。(我们解决此问题)

所以我们打算使用线程池:

使用线程池防止两个问题:

  1. 防止更新操作出现问题,导致后面阅读失败

  2. 减少部分等待更新的耗时

我们把更新操作扔到线程池中去执行,就和主线程不相关了。开启线程池的目的是为了防止更新操作出现问题,从而影响文章阅读,即使我们开启redis,也应该将redis的操作放在线程池中。

4.2.1、模拟不使用线程池

我们让主线程sleep五秒之后再进行活动,用来模拟我们原来的单线程更新操作。我们发现无论线程更新操作放在哪里,都不会影响我们最后返回ArticleVo的时间。我们打开页面之后过了五秒,页面的内容才开始加载。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//ThreadService
@Component
public class ThreadService {

//期望此线程在线程池执行 , 不影响原有的主线程
public void updateArticleViewCount(ArticleMapper articleMapper , Article article){

try{
Thread.sleep(5000);
System.out.println("更新完成...");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//ArticleServiceImpl
@Autowired
ThreadService threadService;
//显示文章详细信息(内容加标签分类等全部)
@Override
public Result findArticleById(Long articleId) {

//先通过id创造出一个包含简单信息的article对象
Article article = this.articleMapper.selectById(articleId);

//将简单article 转化为 简单articleVo
ArticleVo articleVo = copy(article,true,true,true,true);

//模拟更新操作
threadService.updateArticleViewCount(articleMapper,article);

//将简单Vo进行返回
return Result.success(articleVo);
}

4.2.2、线程池配置类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Configuration
@EnableAsync //开启多线程
public class ThreadPoolConfig {

@Bean("taskExecutor")
public Executor asyncServiceExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 设置核心线程数
executor.setCorePoolSize(5);
// 设置最大线程数
executor.setMaxPoolSize(20);
//配置队列大小
executor.setQueueCapacity(Integer.MAX_VALUE);
// 设置线程活跃时间(秒)
executor.setKeepAliveSeconds(60);
// 设置默认线程名称
executor.setThreadNamePrefix("码神之路博客项目");
// 等待所有任务结束后再关闭线程池
executor.setWaitForTasksToCompleteOnShutdown(true);
//执行初始化
executor.initialize();
return executor;
}
}

4.2.3、模拟使用线程池

上面配置了线程池之后,此处加上注解,代表我们下面的操作将在线程池中进行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Component
public class ThreadService {

//期望此线程在线程池执行 , 不影响原有的主线程
@Async("taskExecutor")
public void updateArticleViewCount(ArticleMapper articleMapper , Article article){

try{
Thread.sleep(5000);
System.out.println("更新完成...");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

4.2.4、真正的更新逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Component
public class ThreadService {

//期望此线程在线程池执行 , 不影响原有的主线程
@Async("taskExecutor")
public void updateArticleViewCount(ArticleMapper articleMapper , Article article){

int viewCounts = article.getViewCounts();
Article articleUpdate = new Article();
articleUpdate.setViewCounts(viewCounts +1);
LambdaUpdateWrapper<Article> updateWrapper = new LambdaUpdateWrapper<>();
updateWrapper.eq(Article::getViewCounts,viewCounts);
//update article set view_count = 100 where view_count = 99 and id = 11
articleMapper.update(articleUpdate, updateWrapper);
try{
Thread.sleep(5000);
System.out.println("更新完成...");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

4.3、pojo-Bug修改

在我们使用AtricleVo对数据库表格进行数据更改时,我们只传入了View_Count,结果却导致我们我们的commentCounts和weight变成了0。这是因为在MybatisPlus中,只要我们传入的值不为null,那么就会在提交的时候传入默认值。然而int的默认值正是0。

解决办法就是将int都改成Integer,Integer的默认值是null。

并且将weight的默认值删除。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Data
public class Article {
public static final int Article_TOP = 1;
public static final int Article_Common = 0;
private Long id;
private String title;
private String summary;
private Integer commentCounts;
private Integer viewCounts;
private Long authorId;
private Long bodyId;
private Long categoryId;
private Integer weight;
private Long createDate;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Data
public class ArticleVo {
//文章id
private Long id;
//文章标题
private String title;
//主页文章简介
private String summary;
private Integer commentCounts;
private Integer viewCounts;
private Integer weight;
//创建时间
private String createDate;
private String author;
//文章详细内容
private ArticleBodyVo body;
private List<TagVo> tags;
//文章分类
private CategoryVo category;

}

4.4、文章内-评论列表展示

  • Comment,CommentVo和UserVo实体类解析:

    • 文章id和一级评论的id存在于comment实体类,可用此进行下一级评论的列表收集。children只存在于commentVo实体类,children代表着一级评论的二级评论,

    • 进行简单的筛选后,我们获得commentList,而后将commentList转化为commentVoList。

    • commentVo包含了UserVo,里面用来显示User的名字等相关信息

  • 评论一共设置了两级:

    • 一级评论level = 1,指向文章

    • 二级评论level = 2 ,指向了一级评论

思路:

  • 在Controller中,我们获取了前端的文章id,调用commentService中的获取评论的总方法。

  • 在此总方法中,我们通过level和文章id对评论进行了统一的筛查,获得了原生commentList

  • 而后我们使用copyList方法,将原生commentList转化成精修后的commentVoList

    • 在copyList方法中,我们使用copy方法,对每个comment对象复制到commentVo对象中
  • 使用copy方法对copyList中的每一个comment转化成commentVo

    • 先将同类型的字段进行复制,然后再对子评论UserVo进行操作

    • 我们在copy方法中,会将一级评论者信息的vo进行返回

    • 设置当前用户的作者信息和上一级的用户信息

    • 获取children时,我们获取当前以及评论的id,再通过id和level对评论进行查找并调用copyLiat方法进行转换,再将commentVoList赋值类Children属性。

4.4.1 接口说明

接口url:/comments/article/{id}

请求方式:GET

请求参数:

参数名称 参数类型 说明
id long 文章id(路径参数)

返回数据:

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
{
"success": true,
"code": 200,
"msg": "success",
"data": [
{
"id": 53,
"author": {
"nickname": "李四",
"avatar": "http://localhost:8080/static/img/logo.b3a48c0.png",
"id": 1
},
"content": "写的好",
"childrens": [
{
"id": 54,
"author": {
"nickname": "李四",
"avatar": "http://localhost:8080/static/img/logo.b3a48c0.png",
"id": 1
},
"content": "111",
"childrens": [],
"createDate": "1973-11-26 08:52",
"level": 2,
"toUser": {
"nickname": "李四",
"avatar": "http://localhost:8080/static/img/logo.b3a48c0.png",
"id": 1
}
}
],
"createDate": "1973-11-27 09:53",
"level": 1,
"toUser": null
}
]
}

4.4.2 编码

4.4.2.1 数据库表

1
2
3
4
5
6
7
8
9
10
11
12
CREATE TABLE `ms_comment`  (
`id` bigint(0) NOT NULL AUTO_INCREMENT,
`content` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`create_date` bigint(0) NOT NULL,
`article_id` int(0) NOT NULL,
`author_id` bigint(0) NOT NULL,
`parent_id` bigint(0) NOT NULL,
`to_uid` bigint(0) NOT NULL,
`level` varchar(1) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
PRIMARY KEY (`id`) USING BTREE,
INDEX `article_id`(`article_id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

4.4.2.2 实体类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Data
public class Comment {
//评论的id
private Long id;
//评论的内容
private String content;
//创建时间
private Long createDate;
//文章id
private Long articleId; //不显示于CommentVo中
//作者id
private Long authorId;
//指向 子评论 id
private Long parentId;
//指向 父评论 id
private Long toUid;
//是第几层评论 1是文章的评论 2是评论的评论
private Integer level;
}

4.4.2.3 Vos

1
2
3
4
5
6
7
8
9
10
11
12
//CommentVo
@Data
public class CommentVo {
private Long id;
private UserVo author;
private String content;
private List<CommentVo> childrens;
private String createDate;
private Integer level;
private UserVo toUser;
}

1
2
3
4
5
6
7
8
//UserVo
@Data
public class UserVo {
private String nickname;
private String avatar;
private Long id;
}

4.4.2.4 Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
@RestController
@RequestMapping("comments")
public class CommentsController {

@Autowired
private CommentsService commentsService;

//显示评论列表
@GetMapping("article/{id}")
public Result comments(@PathVariable("id") Long id){
return commentsService.commentsByArticle(id);
}
}

4.4.2.5 Service

  • CommentService
1
2
3
4
public interface CommentsService {
//通过文章id查找commentList
Result commentsByArticle(Long id);
}
  • CommentServiceImpl
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
75
76
77
78
79
80
81
82
83
84
85
86
@Service
public class CommentsServiceImpl implements CommentsService {
@Autowired
CommentMapper commentMapper;
@Autowired
SysUserService sysUserService;

//通过文章id显示comment列表
@Override
public Result commentsByArticle(Long id) {
/**
* 1. 根据文章id从 comment表中查询到 该文章的一级评论信息列表
* 2. 根据一级评论列表中的 一级评论者id 查询到 一级评论者的信息
* 3. 如果id = 1 要去查询有没有子评论
*
* 3.1 根据父评论id 从 评论表 中查询到 该一级评论信息 的所有二级评论
* 3.2 根据二级评论列表中的 二级评论者id 查询到 二级评论者的信息
*/

/**
* Vo关系:commentVo 包含 UserVo
*/

//通过id查找 原生commentList
LambdaQueryWrapper<Comment> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Comment::getArticleId, id);
queryWrapper.eq(Comment::getLevel,1);
List<Comment> comments = commentMapper.selectList(queryWrapper);

//将 原生commentList 转化成 精修后的commentsVoList
List<CommentVo> commentVoList = copyList(comments);

//将commentVoList
return Result.success(commentVoList);
}

//将commentLsit 转化成 commentVoList
private List<CommentVo> copyList(List<Comment> comments){

//定义一个空的commentVo列表
List<CommentVo> commentVoList = new ArrayList<>();

//在循环中将comment->commentVo
for(Comment comment : comments) commentVoList.add(copy(comment));

return commentVoList;
}

//将单个的comment 转化为 commentVo
private CommentVo copy(Comment comment){

//新建一个空的commentVo对象
CommentVo commentVo = new CommentVo();

//将类型的字段进行复制过来(id,content,level)
BeanUtils.copyProperties(comment,commentVo);

//额外添加作者信息
Long authorId = comment.getAuthorId();
UserVo userVo = this.sysUserService.findUserVoById(authorId);
commentVo.setAuthor(userVo);

//子评论
Integer level = comment.getLevel();
if (1 == level){
Long id = comment.getId();
List<CommentVo> commentVoList = findCommentsByParentId(id);
commentVo.setChildrens(commentVoList);
}

//to User 给谁评论
if (level > 1){
Long toUid = comment.getToUid();
UserVo toUserVo = this.sysUserService.findUserVoById(toUid);
commentVo.setToUser(toUserVo);
}
return commentVo;
}
//通过父评论id 查找 子评论列表
private List<CommentVo> findCommentsByParentId(Long id){
LambdaQueryWrapper<Comment> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Comment::getParentId,id);
queryWrapper.eq(Comment::getLevel,2);
return copyList(commentMapper.selectList(queryWrapper));
}
}
  • SysUserService
1
2
//通过userid找到找到UserVo
UserVo findUserVoById(Long id);
  • SysUserServiceImpl
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//通过作者id找到UserVo
@Override
public UserVo findUserVoById(Long id) {
SysUser sysUser = sysUserMapper.selectById(id);
if(sysUser == null){
sysUser = new SysUser();
sysUser.setId(1L);
sysUser.setAvatar("/static/img/logo.b3a48c0.png");
sysUser.setNickname("Wahoyu");
}
UserVo userVo = new UserVo();
BeanUtils.copyProperties(sysUser,userVo);
return userVo;
}

4.4.2.6 Mapper

  • CommentMapper
1
2
3
@Mapper
public interface CommentMapper extends BaseMapper<Comment> {
}
  • SysUserMapper
1
2
3
@Mapper
public interface SysUserMapper extends BaseMapper<SysUser> {
}

4.4.3 测试

4.5、文章内-发表评论

4.5.1 接口说明

接口url:/comments/create/change

请求方式:POST

请求参数:

参数名称 参数类型 说明
articleId long 文章id
content string 评论内容
parent long 父评论id
toUserId long 被评论的用户id

返回数据:

1
2
3
4
5
6
{
"success": true,
"code": 200,
"msg": "success",
"data": null
}

4.5.2 加入到登录拦截器中

1
2
3
4
5
6
@Override
public void addInterceptors(InterceptorRegistry registry) {
//拦截test接口,后续实际遇到需要拦截的接口时,在配置为真正的拦截接口
registry.addInterceptor(loginInterceptor)
.addPathPatterns("/test").addPathPatterns("/comments/create/change");
}

4.5.3 编码

4.5.3.1 Vo.param

1
2
3
4
5
6
7
8
9
10
11
//CommentParam
@Data
public class CommentParam {

private Long articleId;
private String content;
private Long parent;
private Long toUserId;

}

4.5.3.2 Controller

1
2
3
4
5
6
//写评论
@PostMapping("create/change")
public Result comment(@RequestBody CommentParam commentParam){
return commentService.comment(commentParam);
}

4.5.3.3 Service

  • CommentService
1
Result comment(CommentParam commentParam);
  • CommentServiceImpl
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
//写评论
@Override
public Result comment(CommentParam commentParam) {

//从ThreadLocal中获取用户
SysUser sysUser = UserThreadLocal.get();

//创建Comment对象,并想向其中填入数
Comment comment = new Comment();
comment.setArticleId(commentParam.getArticleId());
comment.setAuthorId(sysUser.getId());
comment.setContent(commentParam.getContent());
comment.setCreateDate(System.currentTimeMillis());

//从参数中获取父评论的数值
Long parent = commentParam.getParent();

//如果parent不等于1,那么它就是二级评论
if (parent == null || parent == 0) {
comment.setLevel(1);
}else{
comment.setLevel(2);
}

//如果有父评论,则设置该字段。如果没有父评论,则设置为0。
comment.setParentId(parent == null ? 0 : parent);

//从参数中获取到艾特用户的id。有则艾特,无则不艾特
Long toUserId = commentParam.getToUserId();
comment.setToUid(toUserId == null ? 0 : toUserId);

//利用MybatisPlus将对象进行插入
this.commentMapper.insert(comment);

//不做什么返回了
return Result.success(null);
}

4.5.2.4 雪花精度损失

防止前端精度损失,把id转为string进行传输
分布式id 比较长,传到前端数值会有精度损失,必须转为string类型进行传输

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//CommentVo
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import lombok.Data;
import java.util.List;

@Data
public class CommentVo {

@JsonSerialize(using = ToStringSerializer.class)
private Long id;

private UserVo author;

private String content;

private List<CommentVo> childrens;

private String createDate;

private Integer level;

private UserVo toUser;
}

4.5.4 测试

前端不会自动刷新,需要自己进行刷新测试。

5、写文章与AOP日志

写文章需要 三个接口:

  1. 获取所有的tags供作者选择

  2. 获取所有的categorys供作者选择

  3. 发布文章

获取分类和标签信息比较简单,前面我们已经实现了获取tagList的过程,现在我们只要获取category就可以了。

获取分类流程的思路也是:findAll()方法→copyList()→copy()方法,其中并没有任何的复杂逻辑~

注意:获取分类和标签的方法上面的地址@GetMapping不能加地址,整个Controller的前缀就已经有了。

5.1. 获取所有文章分类

5.1.1 接口说明

接口url:/categorys

请求方式:GET

请求参数:

参数名称 参数类型 说明

返回数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"success":true,
"code":200,
"msg":"success",
"data":
[
{"id":1,"avatar":"/category/front.png","categoryName":"前端"},
{"id":2,"avatar":"/category/back.png","categoryName":"后端"},
{"id":3,"avatar":"/category/lift.jpg","categoryName":"生活"},
{"id":4,"avatar":"/category/database.png","categoryName":"数据库"},
{"id":5,"avatar":"/category/language.png","categoryName":"编程语言"}
]
}

5.1.2 Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
@RestController
@RequestMapping("categorys")
public class CategoryController {

@Autowired
private CategoryService categoryService;

@GetMapping
public Result listCategory() {
return categoryService.findAll();
}
}

5.1.3 Service

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Override
public Result findAll() {
List<Category> categories = this.categoryMapper.selectList(new LambdaQueryWrapper<>());
return Result.success(copyList(categories));
}


public List<CategoryVo> copyList(List<Category> categoryList){
List<CategoryVo> categoryVoList = new ArrayList<>();
for (Category category : categoryList) {
categoryVoList.add(copy(category));
}
return categoryVoList;
}

public CategoryVo copy(Category category){
CategoryVo categoryVo = new CategoryVo();
BeanUtils.copyProperties(category,categoryVo);
return categoryVo;
}

5.1.4 测试

5.2. 获取所有文章标签

5.2.1 接口说明

接口url:/tags

请求方式:GET

请求参数:

参数名称 参数类型 说明

返回数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
"success": true,
"code": 200,
"msg": "success",
"data": [
{
"id": 5,
"tagName": "springboot"
},
{
"id": 6,
"tagName": "spring"
},
{
"id": 7,
"tagName": "springmvc"
},
{
"id": 8,
"tagName": "11"
}
]
}

5.2.2 Controller

1
2
3
4
@GetMapping
public Result findAll(){
return tagService.findAll();
}

5.2.3 Service

1
2
3
4
5
@Override
public Result findAll() {
List<Tag> tags = this.tagMapper.selectList(new LambdaQueryWrapper<>());
return Result.success(copyList(tags));
}

5.2.4 测试

5.3. 发布文章

  • Vo.Params说明

5.3.1 接口说明

接口url:/articles/publish

请求方式:POST

请求参数:

参数名称 参数类型 说明
id long 文章id(编辑有值)
title string 文章标题
summary string 文章概述
body object({content: “ww”, contentHtml: “ww↵”}) 文章内容
category {id: 2, avatar: “/category/back.png”, categoryName: “后端”} 文章类别
tags [{id: 5}, {id: 6}] 文章标签

返回数据:

1
2
3
4
5
6
{
"success": true,
"code": 200,
"msg": "success",
"data": {"id":12232323}
}

5.3.2 登陆拦截器配置

当然登录拦截器中,需要加入发布文章的配置:

1
2
3
4
5
6
7
8
@Override
public void addInterceptors(InterceptorRegistry registry) {
//拦截test接口,后续实际遇到需要拦截的接口时,在配置为真正的拦截接口
registry.addInterceptor(loginInterceptor)
.addPathPatterns("/test")
.addPathPatterns("/comments/create/change")
.addPathPatterns("/articles/publish");
}

5.3.3 编码

5.3.2.1 Vo.Params

1
2
3
4
5
6
7
8
9
10
@Data
public class ArticleParam {
private Long id;
private ArticleBodyParam body;
private CategoryVo category;
private String summary;
private List<TagVo> tags;
private String title;
}

1
2
3
4
5
6
@Data
public class ArticleBodyParam {
private String content;
private String contentHtml;
}

5.3.2.2 Vos

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
@Data
public class ArticleVo {
//一定要记得加 要不然 会出现精度损失
@JsonSerialize(using = ToStringSerializer.class)
private Long id;

private String title;

private String summary;

private Integer commentCounts;

private Integer viewCounts;

private Integer weight;
/**
* 创建时间
*/
private String createDate;

private String author;

private ArticleBodyVo body;

private List<TagVo> tags;

private CategoryVo category;

}

5.3.2.3 Dos

1
2
3
4
5
6
7
@Data
public class ArticleTag {
private Long id;
private Long articleId;
private Long tagId;
}

5.3.2.4 Controller

1
2
3
4
@PostMapping("publish")
public Result publish(@RequestBody ArticleParam articleParam){
return articleService.publish(articleParam);
}

5.3.2.5 Service

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
//写文章
@Autowired
ArticleTagMapper articleTagMapper;

@Override
//添加事务注解
@Transactional
public Result publish(ArticleParam articleParam) {

//从内存中获取了用户
SysUser sysUser = UserThreadLocal.get();

//创建article对象,存储到article表
Article article = new Article();
article.setAuthorId(sysUser.getId());
article.setCategoryId(articleParam.getCategory().getId());
article.setCreateDate(System.currentTimeMillis());
article.setCommentCounts(0);
article.setSummary(articleParam.getSummary());
article.setTitle(articleParam.getTitle());
article.setViewCounts(0);
article.setWeight(Article.Article_Common);
article.setBodyId(-1L);
this.articleMapper.insert(article);

//从参数中获取tag,存储到article--tag表
List<TagVo> tags = articleParam.getTags();
if (tags != null) {
for (TagVo tag : tags) {
ArticleTag articleTag = new ArticleTag();
articleTag.setArticleId(article.getId());
articleTag.setTagId(tag.getId());
this.articleTagMapper.insert(articleTag);
}
}

//创建articleBody表,存储到ArticleBody表中,并可以存储到article对象中
ArticleBody articleBody = new ArticleBody();
articleBody.setContent(articleParam.getBody().getContent());
articleBody.setContentHtml(articleParam.getBody().getContentHtml());
articleBody.setArticleId(article.getId());
articleBodyMapper.insert(articleBody);
//将articleBody对象存储到article对象中
article.setBodyId(articleBody.getId());

//存储articleMapper
articleMapper.updateById(article);

//创建articleVo,设置Id,进行返回
ArticleVo articleVo = new ArticleVo();
articleVo.setId(article.getId());
return Result.success(articleVo);
}

5.3.2.6 Mapper

1
2
3
public interface ArticleTagMapper  extends BaseMapper<ArticleTag> {
}

5.3.4 测试

5.4. 使用AOP记录日志

我们使用AOP(面向切面编程)来记录日志,AOP可以在不改变原方法及基础上对方法进行增强。

5.4.1 创建一个注解

1
2
3
4
5
6
7
8
9
10
11
//日志注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface LogAnnotation {

String module() default "";

String operation() default "";
}

5.4.2 开发AOP

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
//日志切面
@Aspect
@Component
@Slf4j
public class LogAspect {

@Pointcut("@annotation(com.mszlu.blog.common.aop.LogAnnotation)")
public void logPointCut() {
}

@Around("logPointCut()")
public Object around(ProceedingJoinPoint point) throws Throwable {
long beginTime = System.currentTimeMillis();
//执行方法
Object result = point.proceed();
//执行时长(毫秒)
long time = System.currentTimeMillis() - beginTime;
//保存日志
recordLog(point, time);
return result;
}

private void recordLog(ProceedingJoinPoint joinPoint, long time) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
LogAnnotation logAnnotation = method.getAnnotation(LogAnnotation.class);
log.info("=====================log start================================");
log.info("module:{}",logAnnotation.module());
log.info("operation:{}",logAnnotation.operation());

//请求的方法名
String className = joinPoint.getTarget().getClass().getName();
String methodName = signature.getName();
log.info("request method:{}",className + "." + methodName + "()");

//请求的参数
Object[] args = joinPoint.getArgs();
String params = JSON.toJSONString(args[0]);
log.info("params:{}",params);

//获取request 设置IP地址
HttpServletRequest request = HttpContextUtils.getHttpServletRequest();
log.info("ip:{}", IpUtils.getIpAddr(request));


log.info("excute time : {} ms",time);
log.info("=====================log end================================");
}

}

5.4.3 工具类

  • HttpContextUtils
1
2
3
4
5
6
public class HttpContextUtils {

public static HttpServletRequest getHttpServletRequest(){
return ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
}
}
  • IpUtils
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
/****
* IpUtils工具类
* @author Wahoyu
*/
public class IPUtils {

private static Logger logger = LoggerFactory.getLogger(IPUtils.class);

/**
* 获取IP地址
* 使用Nginx等反向代理软件, 则不能通过request.getRemoteAddr()获取IP地址
* 如果使用了多级反向代理的话,X-Forwarded-For的值并不止一个,而是一串IP地址,X-Forwarded-For中第一个非unknown的有效IP字符串,则为真实IP地址
*/
public static String getIpAddr(HttpServletRequest request) {
String ip = null;
try {
ip = request.getHeader("x-forwarded-for");
if (StringUtils.isEmpty(ip) || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (StringUtils.isEmpty(ip) || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (StringUtils.isEmpty(ip) || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_CLIENT_IP");
}
if (StringUtils.isEmpty(ip) || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_X_FORWARDED_FOR");
}
if (StringUtils.isEmpty(ip) || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
} catch (Exception e) {
logger.error("IPUtils ERROR ", e);
}
// 使用代理,则获取第一个IP地址
if (StringUtils.isEmpty(ip) && ip.length() > 15) {
if (ip.indexOf(",") > 0) {
ip = ip.substring(0, ip.indexOf(","));
}
}
return ip;
}
}

5.4.4 测试

我们在Service方法上面添加日志注解,进行日志测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//显示文章详细信息(内容加标签分类等全部)
@LogAnnotation
@Override
public Result findArticleById(Long articleId) {

//先通过id创造出一个包含简单信息的article对象
Article article = this.articleMapper.selectById(articleId);

//将简单article 转化为 简单articleVo
ArticleVo articleVo = copy(article,true,true,true,true);

//模拟更新操作
threadService.updateArticleViewCount(articleMapper,article);

//将简单Vo进行返回
return Result.success(articleVo);
}

5.5. 文章标签 - Bug修复

ArticleMapper.xml中的时间格式没对上

把原来的时间提取格式

1
2
3
4
5
6
<!--显示主页文章归档-->
<select id="listArchives" resultType="com.blog.dos.Archives">
select year(create_date) as year,month(create_date) as month,count(*) as count
from ms_article
group by year,month
</select>

转化成

1
2
3
4
5
6
<!--显示主页文章归档-->
<select id="listArchives" resultType="com.blog.dos.Archives">
select FROM_UNIXTIME(create_date/1000,'%Y') as year,FROM_UNIXTIME(create_date/1000,'%m') as month,count(*) as count
from ms_article
group by year,month
</select>

5.6. 上传文章图片

原本的文字是小内容资源。如今要上传的图片是大资源,我们要对整体流量进行一定的考虑。

假如我们使用1M带宽的服务器,如果一个人访问的图片是100K,那么10个人就1000K ≈ 1M。带宽便被10个使用者占满了。如果还有第11个人来,便访问不到页面资源。想要解决这个办法,我们就要为图片资源额外增加一个图片服务器。

5.6.1 接口说明

接口url:/upload

请求方式:POST

请求参数:

参数名称 参数类型 说明
image file 上传的文件名称

返回数据:

1
2
3
4
5
6
{
"success":true,
"code":200,
"msg":"success",
"data":"https://static.mszlu.com/aa.png"
}

5.6.2 编码

5.6.2.1 导入依赖

1
2
3
4
5
<dependency>
<groupId>com.qiniu</groupId>
<artifactId>qiniu-java-sdk</artifactId>
<version>[7.7.0, 7.7.99]</version>
</dependency>

5.6.2.2 Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@RestController
@RequestMapping("upload")
public class UploadController {
@Autowired
private QiniuUtils qiniuUtils;

@PostMapping
public Result upload(@RequestParam("image") MultipartFile file){
String fileName = UUID.randomUUID().toString() + "." + StringUtils.substringAfterLast(file.getOriginalFilename(), ".");
boolean upload = qiniuUtils.upload(file, fileName);
if (upload){
return Result.success(QiniuUtils.url + fileName);
}
return Result.fail(20001,"上传失败");
}
}

5.6.2.3 获取自己七牛云的一些参数

  • 测试域名:rhf0n6ci3.hb***************

  • AccessKet:vPMtxZlbVHgqRF***************

  • AccessSecretKry:y8jFIlCwbdl6nW****************

5.6.2.4 properties配置七牛云属性

1
2
3
4
5
6
7
8
9
# 七牛云服务器配置
## 上传文件总的最大值
spring.servlet.multipart.max-request-size=20MB
## 单个文件的最大值
spring.servlet.multipart.max-file-size=2MB
## 七牛ak
qiniu.accessKey=vPMtxZlbVHgqRF***************
## 七牛sk
qiniu.accessSecretKey=y8jFIlCwbdl6nW****************

5.6.2.5 七牛云配置类

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
@Component
public class QiniuUtils {

// public static final String url = "https://static.mszlu.com/";
public static final String url = "http://*************clouddn.com/";

@Value("${qiniu.accessKey}")
private String accessKey;
@Value("${qiniu.accessSecretKey}")
private String accessSecretKey;

public boolean upload(MultipartFile file,String fileName){

//构造一个带指定 Region 对象的配置类
Configuration cfg = new Configuration(Region.huabei());
//...其他参数参考类注释
UploadManager uploadManager = new UploadManager(cfg);
//空间名称
String bucket = "boo******25";
//默认不指定key的情况下,以文件内容的hash值作为文件名
try {
byte[] uploadBytes = file.getBytes();
Auth auth = Auth.create(accessKey, accessSecretKey);
String upToken = auth.uploadToken(bucket);
Response response = uploadManager.put(uploadBytes, fileName, upToken);
//解析上传成功的结果
DefaultPutRet putRet = JSON.parseObject(response.bodyString(), DefaultPutRet.class);
return true;
} catch (Exception ex) {
ex.printStackTrace();
}
return false;
}
}

5.6.5 测试

注意:

  • 配置华南华北的函数(注意看七牛云的函数配置)

  • 配置七牛云的域名

  • 配置七牛云的仓库名

  • 上传图片时候只能上传指定格式的图片

6、主页-其他页面

6.1. 查询所有的文章分类

6.1.1 接口说明

接口url:/categorys/detail

请求方式:GET

请求参数:

参数名称 参数类型 说明

返回数据:

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
{
"success": true,
"code": 200,
"msg": "success",
"data": [
{
"id": 1,
"avatar": "/static/category/front.png",
"categoryName": "前端",
"description": "前端是什么,大前端"
},
{
"id": 2,
"avatar": "/static/category/back.png",
"categoryName": "后端",
"description": "后端最牛叉"
},
{
"id": 3,
"avatar": "/static/category/lift.jpg",
"categoryName": "生活",
"description": "生活趣事"
},
{
"id": 4,
"avatar": "/static/category/database.png",
"categoryName": "数据库",
"description": "没数据库,啥也不管用"
},
{
"id": 5,
"avatar": "/static/category/language.png",
"categoryName": "编程语言",
"description": "好多语言,该学哪个?"
}
]
}

6.1.2 编码

6.1.2.1 Vo

1
2
3
4
5
6
7
8
9
10
11
12
import lombok.Data;

@Data
public class CategoryVo {

private Long id;
private String avatar;
private String categoryName;
private String description;

}

6.1.2.2 Controller

1
2
3
4
@GetMapping("detail")
public Result categoriesDetail(){
return categoryService.findAllDetail();
}

6.1.2.3 Service

1
2
3
4
5
6
@Override
public Result findAllDetail() {
List<Category> categories = categoryMapper.selectList(new LambdaQueryWrapper<>());
//页面交互的对象
return Result.success(copyList(categories));
}

6.1.3 测试

6.2. 查询所有的标签

6.2.1 接口说明

接口url:/tags/detail

请求方式:GET

请求参数:

参数名称 参数类型 说明

返回数据:

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
{
"success": true,
"code": 200,
"msg": "success",
"data": [
{
"id": 5,
"tagName": "springboot",
"avatar": "/static/tag/java.png"
},
{
"id": 6,
"tagName": "spring",
"avatar": "/static/tag/java.png"
},
{
"id": 7,
"tagName": "springmvc",
"avatar": "/static/tag/java.png"
},
{
"id": 8,
"tagName": "11",
"avatar": "/static/tag/css.png"
}
]
}

6.2.2 编码

6.2.2.1 Vo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.mszlu.blog.vo;

import lombok.Data;

@Data
public class TagVo {

private Long id;

private String tagName;

private String avatar;
}

6.2.2.3 Controller

1
2
3
4
@GetMapping("detail")
public Result findAllDetail(){
return tagService.findAllDetail();
}

6.2.2.4 Service

1
2
3
4
5
6
7
@Override
public Result findAllDetail() {
LambdaQueryWrapper<Tag> queryWrapper = new LambdaQueryWrapper<>();
List<Tag> tags = this.tagMapper.selectList(queryWrapper);
return Result.success(copyList(tags));
}

6.2.3 测试

6.3. 某分类的全部文章

6.3.1 接口说明

接口url:/category/detail/{id}

请求方式:GET

请求参数:

参数名称 参数类型 说明
id 分类id 路径参数

返回数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"success": true,
"code": 200,
"msg": "success",
"data":
{
"id": 1,
"avatar": "/static/category/front.png",
"categoryName": "前端",
"description": "前端是什么,大前端"
}
}

6.3.2 编码

6.3.2.1 Vo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.mszlu.blog.vo.params;

import lombok.Data;

@Data
public class PageParams {

private int page = 1;
private int pageSize = 10;
private Long categoryId;
private Long tagId;

}

6.3.2.2 Controller

1
2
3
4
5
@GetMapping("detail/{id}")
public Result categoriesDetailById(@PathVariable("id") Long id){
return categoryService.categoriesDetailById(id);
}

6.3.2.3 Service

1
2
3
4
5
6
@Override
public Result categoriesDetailById(Long id) {
Category category = categoryMapper.selectById(id);
CategoryVo categoryVo = copy(category);
return Result.success(categoryVo);
}

6.3.2.4 ArticleService

实际上我们就是在展示文章列表,但是如果我们有分类id这个属性的话,我们就把这个属性进行一个筛选。在之前展示所有文章列表的情况下,展示分类id明确的id。

1
2
3
4
//查询文章的参数 加上分类id,判断不为空 加上分类条件  
if (pageParams.getCategoryId() != null) {
queryWrapper.eq(Article::getCategoryId,pageParams.getCategoryId());
}

6.3.3 测试

6.4. 某标签的全部文章

6.4.1 接口说明

接口url:/tags/detail/{id}

请求方式:GET

请求参数:

参数名称 参数类型 说明
id 标签id 路径参数

返回数据:

1
2
3
4
5
6
7
8
9
10
11
12
{
"success": true,
"code": 200,
"msg": "success",
"data":
{
"id": 5,
"tagName": "springboot",
"avatar": "/static/tag/java.png"
}
}

6.4.2 编码

6.4.2.1 Controller

1
2
3
4
@GetMapping("detail/{id}")
public Result findDetailById(@PathVariable("id") Long id){
return tagService.findDetailById(id);
}

6.4.2.2 Service

1
2
3
4
5
6
@Override
public Result findDetailById(Long id) {
Tag tag = tagMapper.selectById(id);
TagVo copy = copy(tag);
return Result.success(copy);
}

6.4.2.3 修改ArticleService

此处我们要对所有文章列表的输出进行限制,限制某tag的文章才能够进行输出。开心的来讲,我们本来可以像category一样进行直接限制,皆大欢喜。但是我们突然发现了一个问题!我们的Article表中没有设置tag字段!那么我们就只能进行多表查询:

我们先内嵌了一个查询,先根据tagId把所有的行(小对象)查出来形成一个对象列表。再循环将每一个行(对象)中的ArticleId单独提取出来,存放到列表中。最后的最后,千万别忘了我们这是在一个查询listArticle的wrapper中,我们获取到articleList之后,还要使用这个列表对Article的输出进行限制。

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

@Override
public Result listArticle(PageParams pageParams) {
/**
* 1. 分页查询 article数据库表
*/
Page<Article> page = new Page<>(pageParams.getPage(),pageParams.getPageSize());
LambdaQueryWrapper<Article> queryWrapper = new LambdaQueryWrapper<>();

if (pageParams.getCategoryId() != null) {
queryWrapper.eq(Article::getCategoryId,pageParams.getCategoryId());
}

/*->*/ List<Long> articleIdList = new ArrayList<>();
if (pageParams.getTagId() != null){
LambdaQueryWrapper<ArticleTag> articleTagLambdaQueryWrapper = new LambdaQueryWrapper<>();
articleTagLambdaQueryWrapper.eq(ArticleTag::getTagId,pageParams.getTagId());
List<ArticleTag> articleTags = articleTagMapper.selectList(articleTagLambdaQueryWrapper);
for (ArticleTag articleTag : articleTags) {
articleIdList.add(articleTag.getArticleId());
}
if (articleIdList.size() > 0){
queryWrapper.in(Article::getId,articleIdList);
}
}

//是否置顶进行排序
//order by create_date desc
queryWrapper.orderByDesc(Article::getWeight,Article::getCreateDate);
Page<Article> articlePage = articleMapper.selectPage(page, queryWrapper);
List<Article> records = articlePage.getRecords();
//能直接返回吗? 很明显不能
List<ArticleVo> articleVoList = copyList(records,true,true);
return Result.success(articleVoList);
}

6.4.3 测试

6.5. 归档文章列表

归档页面的接口就是我们之前调用文章列表的接口,只不过它这个接口让我们多传了两个参数(year和month)。

在实体类中:

我们对月份进行了处理,如果月数只有一位的话,我们要在前面加上0。

在业务层和DAO层中:

因为我们要按照年和月去查询,所以我们要使用MySQL的一个函数,然而此函数MybatisPlus是不支持的,所以我们要自定义一个函数进行查询。

原本的listArticle:

我亲爱的逻辑,你是我在这个项目中写出的第一个复杂逻辑,经过不断的修改,你逐渐完善起来。可今天由于前端定义的重复接口,我即将要把你整个业务逻辑进行修改。在下面我将用漫长的逻辑说明笔记纪念你。

在原本的文章列表中,它主要包括:

  1. 获取前端传入的众多参数

  2. 通过前端的部分参数进行分页

  3. 通过时间和地位降序排列

  4. 如果我们要展示的是某分类的全部文章,或者某标签的全部文章,我们还可以对其进行条件限制

  5. 通过(当前页数,页表大小,查询条件) 生成文章页 -> 原生文章列表 -> 精修后文章列表

  6. 返回articleVoList

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
//获取主页文章列表
@Override
public Result listArticle(PageParams pageParams) {

//通过传入的页数和页表大小创建Page对象
Page<Article> page = new Page<>(pageParams.getPage(), pageParams.getPageSize());

//创建查询用的Wrapper对象,并对wrapper对象进行限制
LambdaQueryWrapper<Article> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.orderByDesc(Article::getCreateDate,Article::getWeight);

//展示某个分类下面的所有文章 --------查询文章的参数 加上分类id,判断不为空 加上分类条件
//后面将此逻辑放在了mapper.xml中
if (pageParams.getCategoryId() != null) {
queryWrapper.eq(Article::getCategoryId,pageParams.getCategoryId());
}

//展示某个标签下的全部文章
//后面将此逻辑放在了mapper.xml中
List<Long> articleIdList = new ArrayList<>();
if (pageParams.getTagId() != null){
LambdaQueryWrapper<ArticleTag> articleTagLambdaQueryWrapper = new LambdaQueryWrapper<>();
articleTagLambdaQueryWrapper.eq(ArticleTag::getTagId,pageParams.getTagId());
List<ArticleTag> articleTags = articleTagMapper.selectList(articleTagLambdaQueryWrapper);
for (ArticleTag articleTag : articleTags) {
articleIdList.add(articleTag.getArticleId());
}
if (articleIdList.size() > 0){
queryWrapper.in(Article::getId,articleIdList);
}
}

//通过(当前页数,页表大小,查询条件) -> 文章页 -> 原生文章列表 -> 精修后文章列表
//此逻辑后面在mapper.xml自动生成
Page<Article> articlePage = articleMapper.selectPage(page, queryWrapper);
List<Article> articleList = articlePage.getRecords();
List<ArticleVo> articleVoList = copyList(articleList,true,true);

//将精修的文章列表进行返回
return Result.success(articleVoList);
}

修改后的逻辑:

  1. 依旧是前端传入众多参数进入到

  2. 分页逻辑留在Service中

  3. !!将时间权重的降序排列转移到了sql语句的最后

  4. !!将某分类,某标签的文章列表逻辑,放在了sql语句的if函数中

  5. !!将page(包含了当前页和页面大小),CategoryId,TagId,Year,Month传入mapper中进行查询 生成当前文章页对象。

  6. 剩下的和原来的逻辑相同:文章页 -> 原生文章列表 -> 精修后文章列表

  7. 返回articleVoList

6.5.1 接口说明

接口url:/articles

请求方式:POST

请求参数:

参数名称 参数类型 说明
year string
month string

返回数据:

1
2
3
4
5
6
7
8
{
"success": true,
"code": 200,
"msg": "success",
"data": [文章列表,数据同之前的文章列表接口]

}

6.5.2 编码

6.5.2.1文章列表参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Data
public class PageParams {

private int page = 1;
private int pageSize = 10;
private Long categoryId;
private Long tagId;
private String year;
private String month;
public String getMonth(){
if (this.month != null && this.month.length() == 1){
return "0"+this.month;
}
return this.month;
}
}

6.5.2.2 ServiceImpl

1
2
3
4
5
6
@Override
public Result listArticle(PageParams pageParams) {
Page<Article> page = new Page<>(pageParams.getPage(),pageParams.getPageSize());
IPage<Article> articleIPage = this.articleMapper.listArticle(page,pageParams.getCategoryId(),pageParams.getTagId(),pageParams.getYear(),pageParams.getMonth());
return Result.success(copyList(articleIPage.getRecords(),true,true));
}

6.5.2.2 Mapper.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
<resultMap id="articleMap" type="com.mszlu.blog.dao.pojo.Article">
<id column="id" property="id" />
<result column="author_id" property="authorId"/>
<result column="comment_counts" property="commentCounts"/>
<result column="create_date" property="createDate"/>
<result column="summary" property="summary"/>
<result column="title" property="title"/>
<result column="view_counts" property="viewCounts"/>
<result column="weight" property="weight"/>
<result column="body_id" property="bodyId"/>
<result column="category_id" property="categoryId"/>
</resultMap>


<select id="listArticle" resultMap="articleMap">
select * from ms_article
<where>
1 = 1

<!--某分类下的文章列表-->
<if test="categoryId != null">
and category_id = #{categoryId}
</if>

<!--标签下的文章列表-->
<if test="tagId != null">
and id in (select article_id from ms_article_tag where tag_id=#{tagId})
</if>

<!--通过自定义函数将年月进行分类-->
<if test="year != null and year.length>0 and month != null and month.length>0">
and ( FROM_UNIXTIME(create_date/1000,'%Y') = #{year} and FROM_UNIXTIME(create_date/1000,'%m') = #{month} )
</if>

<!--先根据权重进行降序排列,再根据日期进行降序排列-->
</where>
order by weight desc , create_date desc
</select>

7、后续优化

7.1、统一缓存处理

7.1.1 思路

我们提到优化最先想到的肯定是缓存方面的优化,内存的访问速度远远大于 磁盘的访问速度 (多少倍?至少是1000倍起)。所以我们一般情况下,第一步骤进行的优化,就是让他尽量的减少对磁盘的访问,尽可能让它从内存中拿取数据

对每一个接口进行优化肯定不太合适,那么我们接下来就是要想办法做一个统一缓存处理,做统一处理,我们自然而然就会想到了我们的AOP技术。

我们决定使用Annoation+AOP+Redis的方式进行缓存管理。

使用了此缓存处理会怎样,怎么开始,怎样直接结束?

统一缓存的原理是这样的:

  • 自定义注解后,我们能遍地使用该注解

  • 在AOP中

    1. 我们设置切点和通知可以让遍地的注解,成为我们逻辑的跑腿小弟

    2. 我们的业务主要逻辑:让前端发送页面请求时先不顾一切获取方法名+类名+传入参数+注解传入的name,这几项进行加工就是我们规定好的key值。

    3. 当前端想要获取数据,我们就先用这个生成的key先进行查找,如果能查到,皆大欢喜,直接把你进行return。

    4. 如果没查到,那么不好意思,你没办法走捷径,必须访问磁盘。而且你回来路过的时候还得把你的数据留下~留着下次访问直接找到~

7.1.2 编码

7.1.2.1 注解自定义

注解也就是我们AOP当中的切点

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.blog.common.cache;

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Cache {

long expire() default 1 * 60 * 1000;

String name() default "";

}

7.1.2.2 AOP配置类

定义一个切面,定义了我们的切点和通知的关系

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
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
@Aspect
@Component
@Slf4j
public class CacheAspect {

//导入redis应用类
@Autowired
private RedisTemplate<String, String> redisTemplate;

//定义了切点
@Pointcut("@annotation(com.blog.common.cache.Cache)")
public void pt(){}

//环绕通知:可以在方法的前后进行增强
@Around("pt()")
public Object around(ProceedingJoinPoint pjp){


try {
/**
* 获取类名和方法名
*/
Signature signature = pjp.getSignature();
//1.获取类名
String className = pjp.getTarget().getClass().getSimpleName();
//2.获取方法名
String methodName = signature.getName();

/**
* 开始获取参数
*
*/
//先定义空数组A
Class[] parameterTypes = new Class[pjp.getArgs().length];
//获取切点方法传入的参数
Object[] args = pjp.getArgs();
//定义空参数P
String params = "";
//对切点的参数进行循环判断
//如果有值则存到parameterTypes中并转化为json(因为参数很可能是一个类)
for(int i=0; i<args.length; i++) {
if(args[i] != null) {
params += JSON.toJSONString(args[i]);

//为了下面拿到我们的方法Method
parameterTypes[i] = args[i].getClass();
}else {
parameterTypes[i] = null;
}
}

/**
* 为redis的key做准备(我们输入的缓存名称name+类名className+方法名methodName+JSON版本的参数params)
*/
//参数转化成JSON后进行MD5加密
if (StringUtils.isNotEmpty(params)) {
//加密 以防出现key过长以及字符转义获取不到的情况
params = DigestUtils.md5Hex(params);
}
//为了获取到我们加的cache注解
Method method = pjp.getSignature().getDeclaringType().getMethod(methodName, parameterTypes);
//获取Cache注解
Cache annotation = method.getAnnotation(Cache.class);
//缓存过期时间
long expire = annotation.expire();
//缓存名称
String name = annotation.name();


/**
* 获取数据的时候,我们先生成key,通过key再生成Value进行查找
* 如果我们获取到了数据,且不为空,那么我们就算是获取成功
*/
//生成key
String redisKey = name + "::" + className+"::"+methodName+"::"+params;
//通过key查找value
String redisValue = redisTemplate.opsForValue().get(redisKey);
//如果不为空,那我们就算是拿到缓存了
if (StringUtils.isNotEmpty(redisValue)){
log.info("获取到缓存===========,{},{}",className,methodName);
return JSON.parseObject(redisValue, Result.class);
}

/**
* 如果我们拿到的是空值,那么抱歉,你得去磁盘中读取数据,获取后还得把数据存一下,存入缓存
*/
Object proceed = pjp.proceed();
redisTemplate.opsForValue().set(redisKey,JSON.toJSONString(proceed), Duration.ofMillis(expire));
log.info("刚才没有缓存,现在有了,{},{}",className,methodName);
return proceed;

} catch (Throwable throwable) {
throwable.printStackTrace();
}
return Result.fail(-999,"系统错误");
}

}

7.1.3 测试

1
2
3
4
5
6
@PostMapping("hot")
@Cache(expire = 5 * 60 * 1000,name = "hot_article")
public Result hotArticle(){
int limit = 5;
return articleService.hotArticle(limit);
}

8、admin模块以及Security集成

做一个后台,用SpringSecurity做一个权限系统(api模块是jwt进行密码验证,admin模块是使用Security进行安全控制)。有什么用呢?我们知道我们做出的软件,其实是面向三个方向。

  • 开发人员什么都能接触到,也就是主要在api模块进行后端,app主要负责前端。

  • 软件做成后,也要搭建一个admin模块去给管理人员进行使用,他可以对用户进行修改,对权限进行配置(毕竟没有必要让不会开发的人员去操作数据库)。

  • 面向用户的就是前端页,用户在前端进行操作即可。

我们前端页面中设置了用户管理权限管理,我们这里主要对权限管理进行操作。此处也是Bug重灾区,因为权限既是我们胡乱操作测试的对象,又是我们进行操作的权限判断依据。

①新建了blog-admin模块(后台管理系统)

②对权限(实现了增删改查)

③配置了Security登录功能。

④配置了不同用户的不同权限(将前端传入的权限与数据库具有的权限进行对比)

8.1. 搭建项目

8.1.1 新建maven工程

我们导入依赖并暂时关闭Security依赖,我们先通过上面的依赖,在后端做一个简单的增删改查功能。

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
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
<dependencies>
<!-- 导入SpringBoot依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
<!-- 排除 默认使用的logback -->
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
</exclusions>
</dependency>

<!-- log4j2 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-log4j2</artifactId>
</dependency>

<!-- aop -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>

<!--Mail-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>

<!--web-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!--测试-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

<!--FastJson-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.76</version>
</dependency>

<!--MySql-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>

<!--yml属性文件的提示-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>

<!---->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>

<!---->
<dependency>
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>
<version>3.2.2</version>
</dependency>

<!---->
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
</dependency>

<!--MybatisPlus-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.3</version>
</dependency>

<!--Lombok-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>

<!--时间-->
<dependency>
<groupId>joda-time</groupId>
<artifactId>joda-time</artifactId>
<version>2.10.10</version>
</dependency>

<!--Security-->
<!--<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>-->
</dependencies>

8.1.2 properties配置

application.properties:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
server.port=8889
spring.application.name=blog_admin

#数据库的配置
# datasource
spring.datasource.url=jdbc:mysql://localhost:3306/boot_blog?useUnicode=true&characterEncoding=UTF-8&serverTimeZone=UTC
spring.datasource.username=root
spring.datasource.password=123456
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

#mybatis-plus
mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl
mybatis-plus.global-config.db-config.table-prefix=ms_

mybatis-plus配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
@MapperScan("com.mszlu.blog.admin.mapper")
public class MybatisPlusConfig {

//分页插件
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor(){
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
return interceptor;
}
}

8.1.3 创建启动类

1
2
3
4
5
6
7
8
@SpringBootApplication
public class AdminApp {

public static void main(String[] args) {
SpringApplication.run(AdminApp.class,args);
}
}

启动成功

8.1.4 导入前端工程

我们的前端页面时Vue+ElementUI进行开发的。

放入resources下的static目录中,前端工程在资料中有

我们访问http://localhost:8889/pages/main.html,打开前端管理页面。

8.1.5 新建表

管理员用户表

1
2
3
4
5
6
CREATE TABLE `ms_admin`  (
`id` bigint(0) NOT NULL AUTO_INCREMENT,
`username` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = Dynamic;

权限表

1
2
3
4
5
6
7
CREATE TABLE `ms_permission`  (
`id` bigint(0) NOT NULL AUTO_INCREMENT,
`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`path` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = Dynamic;

管理员x权限表

1
2
3
4
5
6
CREATE TABLE `ms_admin_permission`  (
`id` bigint(0) NOT NULL AUTO_INCREMENT,
`admin_id` bigint(0) NOT NULL,
`permission_id` bigint(0) NOT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = Dynamic;

8.2. 权限的CRUD基础实现

我们对权限进行增删改查时(相当于学生x 图书的增删改查)。但是当我们要控制数据库的访问权限的时候,它既是我们进行判断的依据,优势我们进行修改的工具。(我的建议是,基础的展示和增删改查不要变动,因为这些既是图书又是权限;我们新建权限用来测试即可,他们只是普通的图书)

8.2.1 Vo+Params

PageResult可放在Result中,用于返回展示列表给前端。

Param是接收前端传来的参数的实体类。

  • 用来对前端进行统一数据返回的封装类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Data
@AllArgsConstructor
public class Result {

private boolean success;

private int code;

private String msg;

private Object data;


public static Result success(Object data){
return new Result(true,200,"success",data);
}

public static Result fail(int code, String msg){
return new Result(false,code,msg,null);
}
}

  • 前端传入的参数
1
2
3
4
5
6
7
8
9
10
11
12
13
@Data
public class PageParam {

//当前页
private Integer currentPage;

//页面大小
private Integer pageSize;

//前端输入的权限名称(查询条件)
private String queryString;
}

  • 用于给前端返回分页信息的封装类,此类被封装在Result中进行返回
1
2
3
4
5
6
7
8
9
@Data
public class PageResult<T> {

//返回给前端的列表
private List<T> list;

//total不知道是什么,可能是总数?
private Long total;
}

8.2.2 Entity(Permission)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Data
public class Permission {

//权限id
@TableId(type = IdType.AUTO)
private Long id;

//权限名字
private String name;

//权限的路径(网址)
private String path;

//权限描述
private String description;
}

8.2.1 AdminController&#x20;

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
@RestController
@RequestMapping("admin")
public class AdminController {

@Autowired
private PermissionService permissionService;

//显示权限列表
//想要查看列表我们传入的参数就是与分页相关的函数(当前页,页面大小,查询条件)
@PostMapping("permission/permissionList")
public Result permissionList(@RequestBody PageParam pageParam){
return permissionService.listPermission(pageParam);
}

//添加权限
@PostMapping("permission/add")
public Result add(@RequestBody Permission permission){
return permissionService.add(permission);
}

//更新权限
@PostMapping("permission/update")
public Result update(@RequestBody Permission permission){
return permissionService.update(permission);
}

//删除权限
@GetMapping("permission/delete/{id}")
public Result delete(@PathVariable("id") Long id){
return permissionService.delete(id);
}
}

8.2.2 PermissionService&#x20;

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
@Service
public class PermissionServiceImpl implements PermissionService {

@Autowired
private PermissionMapper permissionMapper;

//显示权限列表
//传入分页所需的参数,获取列表
@Override
public Result listPermission(PageParam pageParam){

//获取 当前页 和 页容量 生成page对象
Page<Permission> page = new Page<>(pageParam.getCurrentPage(),pageParam.getPageSize());

//创建Wrapper对象
LambdaQueryWrapper<Permission> queryWrapper = new LambdaQueryWrapper<>();

//如果我们在前端输入了查询名称,那么我们就显示这个就OK了
if (StringUtils.isNotBlank(pageParam.getQueryString())) {
queryWrapper.eq(Permission::getName,pageParam.getQueryString());
}

//获取到查询到的页
Page<Permission> permissionPage = this.permissionMapper.selectPage(page, queryWrapper);

//创建一个返回给前端的分页结果,将我们查到的分页信息放入其中
PageResult<Permission> pageResult = new PageResult<>();
pageResult.setList(permissionPage.getRecords());
pageResult.setTotal(permissionPage.getTotal());

//将pageResult装入Result中返回到Controller
return Result.success(pageResult);
}

//添加权限
@Override
public Result add(Permission permission) {
this.permissionMapper.insert(permission);
return Result.success(null);
}

//增加权限
@Override
public Result update(Permission permission) {
this.permissionMapper.updateById(permission);
return Result.success(null);
}

//删除权限
@Override
public Result delete(Long id) {
this.permissionMapper.deleteById(id);
return Result.success(null);
}
}

8.2.3 Mapper

1
2
3
4
@Mapper
public interface PermissionMapper extends BaseMapper<Permission> {
}

8.2.4 测试

  • 查询

  • 增加

  • 删除(删除成功后前端不会自动刷新)

  • 修改

8.3. Security集成-登陆实现

上面我们实现了权限表的增删改,现在我们就将Security集成进去,实现我们的权限管理系统

我们将需要拦截的接口都放在/admin/下面,我们只需要对admin进行拦截即可,想要使用admin,必须先通过我们自定义的Service方法

8.3.1 添加依赖

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>

8.3.2 Security配置类

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
//我们的Security类要继承这个Adapter,然后加上@Configuration注册为Bean,好让Spring可以扫描到。
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

//使用BCrypt密码策略
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder(){
return new BCryptPasswordEncoder();
}

//生成密码
public static void main(String[] args) {
//加密策略 MD5 不安全 彩虹表 MD5 加盐
String mszlu = new BCryptPasswordEncoder().encode("mszlu");
System.out.println(mszlu);
}

//这个配置暂时不用管
@Override
public void configure(WebSecurity web) throws Exception {
super.configure(web);
}

//主要配置文件
@Override
protected void configure(HttpSecurity http) throws Exception {

http.authorizeRequests() //开启登录认证

//用户权限控制
//.antMatchers("/user/findAll").hasRole("admin") //访问接口需要admin的角色

//放行静态资源
.antMatchers("/css/**").permitAll()
.antMatchers("/img/**").permitAll()
.antMatchers("/js/**").permitAll()
.antMatchers("/plugins/**").permitAll()

//要通过我们自定义的特殊认证(此处我们自己写认证代码,返回true就代表认证通过)
//自定义service 来去实现实时的权限认证
//将所有需要拦截的都放在admin下,我们拦截admin即可
.antMatchers("/admin/**").access("@authService.auth(request,authentication)")

//此路径下的,只要登陆成功就能访问
.antMatchers("/pages/**").authenticated()

//自定义登录
.and()
.formLogin()
.loginPage("/login.html") //自定义的登录页面
.loginProcessingUrl("/login") //登录处理接口
.usernameParameter("username") //定义登录时的用户名的key 默认为username
.passwordParameter("password") //定义登录时的密码key,默认是password
.defaultSuccessUrl("/pages/main.html") //登陆成功后跳转
.failureUrl("/login.html") //登陆失败后跳转
.permitAll() //这是指和登录表单相关的接口 都通过(其实这里不生效,主要是我们只拦截admin下的,没设置这里)

//自定义退出
.and()
.logout() //退出登录配置
.logoutUrl("/logout") //退出登录接口
.logoutSuccessUrl("/login.html")
.permitAll() //退出登录的相关接口我们都放行(其实这里不生效,主要是我们只拦截admin下的,没设置这里)

//拦截postman等http访问的
.and()
.httpBasic()

//关闭csrf
.and()
.csrf().disable() //csrf关闭 如果自定义登录 需要关闭
.headers().frameOptions().sameOrigin();
}
}

8.3.3 admin实体类

1
2
3
4
5
6
7
8
9
10
11
@Data
public class Admin {

@TableId(type = IdType.AUTO)
private Long id;

private String username;

private String password;
}

8.3.4 登陆功能Service

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
@Component
@Slf4j
public class SecurityUserService implements UserDetailsService {
@Autowired
private AdminService adminService;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
log.info("username:{}",username);
//当用户登录的时候,springSecurity 就会将请求 转发到此
//根据用户名 查找用户,不存在 抛出异常,存在 将用户名,密码,授权列表 组装成springSecurity的User对象 并返回
Admin adminUser = adminService.findAdminByUserName(username);
if (adminUser == null){
throw new UsernameNotFoundException("用户名不存在");
}
ArrayList<GrantedAuthority> authorities = new ArrayList<>();
UserDetails userDetails = new User(username,adminUser.getPassword(), authorities);
//剩下的认证 就由框架帮我们完成
return userDetails;
}

public static void main(String[] args) {
System.out.println(new BCryptPasswordEncoder().encode("123456"));
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Service
public class AdminService {

@Autowired
private AdminMapper adminMapper;
@Autowired
private PermissionMapper permissionMapper;

public Admin findAdminByUserName(String username){
LambdaQueryWrapper<Admin> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Admin::getUsername,username).last("limit 1");
Admin adminUser = adminMapper.selectOne(queryWrapper);
return adminUser;
}

public List<Permission> findPermissionsByAdminId(Long adminId){
return permissionMapper.findPermissionsByAdminId(adminId);
}

}

1
2
3
4
@Mapper
public interface AdminMapper extends BaseMapper<Admin> {
}

1
2
3
4
5
6
@Mapper
public interface PermissionMapper extends BaseMapper<Permission> {

List<Permission> findPermissionsByAdminId(Long adminId);
}

1
2
3
4
5
6
7
8
9
10
<?xml version="1.0" encoding="UTF-8" ?>
<!--MyBatis配置文件-->
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.blog.admin.mapper.PermissionMapper">

<select id="findPermissionsByAdminId" parameterType="long" resultType="com.mszlu.blog.admin.pojo.Permission">
select * from ms_permission where id in (select permission_id from ms_admin_permission where admin_id=#{adminId})
</select>
</mapper>

8.3.5登录功能测试

登陆功能成功

8.4. 权限认证

根据权限中定义的权限来判断该用户是否能反过来对权限进行操作。(太生草了)

8.4.1 数据库表定义权限

  • admin

  • permission

🥕注意:此处的权限既是我们修改的对象,又是我们进行操作的依据

  • admin_permission(admin有资格进行修改,test没有)

8.4.2 Service

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
@Service
@Slf4j
public class AuthService {

@Autowired
private AdminService adminService;

public boolean auth(HttpServletRequest request, Authentication authentication){
String requestURI = request.getRequestURI();
log.info("request url:{}", requestURI);
//true代表放行 false 代表拦截
Object principal = authentication.getPrincipal();
if (principal == null || "anonymousUser".equals(principal)){
//未登录
return false;
}
UserDetails userDetails = (UserDetails) principal;
String username = userDetails.getUsername();
Admin admin = adminService.findAdminByUserName(username);
if (admin == null){
return false;
}
if (admin.getId() == 1){
//认为是超级管理员
return true;
}
List<Permission> permissions = adminService.findPermissionsByAdminId(admin.getId());
requestURI = StringUtils.split(requestURI,'?')[0];
for (Permission permission : permissions) {
if (requestURI.equals(permission.getPath())){
log.info("权限通过");
return true;
}
}
return false;
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class MySimpleGrantedAuthority implements GrantedAuthority {
private String authority;
private String path;

public MySimpleGrantedAuthority(){}

public MySimpleGrantedAuthority(String authority){
this.authority = authority;
}

public MySimpleGrantedAuthority(String authority,String path){
this.authority = authority;
this.path = path;
}

@Override
public String getAuthority() {
return authority;
}

public String getPath() {
return path;
}
}

基于SpringBoot+Vue的博客后端
http://wahoyu.xyz/2022/08/31/基于SpringBoot+Vue的博客后端/
作者
Wahoyu
发布于
2022年8月31日
许可协议