project_03_基于SpringBoot的个人博客系统


本文介绍基于SpringBoot的个人博客系统。

1. 概述

前端已经准备好,采用Vue框架开发,基于Node.js平台运行。后端采用SpringBoot、MyBatis开发。

2. 开发环境搭建

3. 功能开发

3.1 首页文章列表

3.2 首页最热标签

即该标签所属文章最多的那几个标签。

3.3 最热文章

根据浏览量字段(view_counts),最多的那几篇文章。

3.4 最新文章

3.5 文章归档

指的是,某年某月发表了多少篇文章。即将文章按照时间段分组,统计数量,并展示。

而给定的数据表create_date是bigint类型,需要将其转换成date,然后除以毫秒数,形成日期,最终获取到年月并按照年月分组计数。

1
select year(FROM_UNIXTIME(create_date/1000)) year,month(FROM_UNIXTIME(create_date/1000)) month, count(*) count from ms_article group by year,month;

3.6 登录注册功能

采用JWT技术实现登录和注册功能。

注意,cookie认证、session认证、token认证、JWT认证的区别。

Http是无连接、无状态的协议,客户端的每次请求对于服务器来说,都是一次未知的请求。Http有一个request请求对象,每来一次请求,都会创建一个request对象接收这个请求。另外,Http有一个会话session对象,这个对象可用于连接多次request请求,即将这些request请求关联起来,表示一个客户端。

那么怎么关联呢?

注意,每次发起request请求时,都会自动携带客户端的cookie。如果没有,那么就是为空。在服务端,接收到request时,会根据reqeust中的cookie来进行匹配,看看服务器中是否有与该cookie匹配的session。

因为是第一次访问,那么cookie就为空,即与该request对应的session还没有,所以会创建session,并生成与之绑定的cookie,通过response在响应内容的时候返回给客户端。这样,该session就与该request对应起来了。因此说,如果客户端禁用了cookie,那么也就意味着无法基于cookie实现session。

那么之后,该request就会自动消失,但是cookie已经随着响应到达了客户端,之后的其他request会自动携带该cookie。那么之后,其他request也就会和服务器中的该session对应上了。

如果一定时间内,没有reqeust与session对应,那么就会自动回收session,即一次会话结束。

总体上说,客户端通过cookie,表明从本客户端发送出的多个request都是属于本客户端的。而服务器则通过session表明收到的这几个reqeust是属于同一个客户端的。

此时,可在cookie中存储用户名等标识信息,因为每次request都会自带cookie,所以可根据cookie中的信息表明当前登录用户是哪个用户,并且可设置cookie的生存时间,可实现免密登陆。那么首次通过账号密码登录成功后,服务器可在返回的cookie中存储该用户名,方便后续验证,后续只要cookie中有用户名,就说明之前已经成功登录过,即认证了该请求【可通过拦截器实现】

但是这样做,显然是不安全的,黑客完全可伪造cookie。那么该怎么办呢?

可在session中存储用户名,因为request和session已经通过cookie关联了,可通过request找到session。那么首次通过账号密码登录成功后,服务器可在session中存储该用户名,方便后续验证,后续只要session中有用户名,就说明之前已经成功登录过,即认证了该请求【可通过拦截器实现】

但是这样做,在服务器中存储数据,显然随着访问量的增加,服务器的压力变大。

另外,由于cookie是存在一个客户端上,而session则也是一台服务器上,如果是分布式架构,并且负载均衡,显然需要服务器之间共享session数据,否则cookie对应的session只能在那一台服务器上识别。

共享数据的话,就需要存储到数据库,可利用redis做缓存数据库。但是安全方面,cookie和session还是没办法做到。其实本质上说,cookie就是无法保证传过来的签名信息是权威的(可参考国科大网络安全李杨老师课上讲解的加解密、权威认证部分)。

因此,可对上面cookie中的唯一标识信息(比如用户名)进行加密。这便是token。而JWT是实现token的一种方式。JWT有三部分组成:A.B.C:

  1. A:Header,{“type”: “JWT”, “alg”: “HS256”},固定内容
  2. B:payload,存放信息,比如,用户id、过期时间等等,可以被解密,不能存放敏感信息。
  3. C:签证,A和B加上密钥 加密而成,只要密钥不丢失,可以认为是安全的。

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

3.7 客户端获取用户信息

前面将token返回给客户端后,后续的每次请求,都会在header中携带token,此时服务端在接收到请求后,需要从中获取到token信息进行比对,从而获得当前登录的用户信息。同理,前端如果想要显示当前登录的用户,显然如果存到客户端,不太安全也不太权威,所以可发送请求,通过服务端解析token来返回当前的登录信息。即登录成功后,会再次发送请求,获取当前用户信息。

3.8 退出登录

也就是安全退出,只要将redis中当前用户的token对应的key、value清除即可。对于客户端的token,其实这里是没办法清除的。

3.9 用户注册

注册,用户名account不能重复。另外,注册成功之后自动登录,即需要生成token、保存到redis中。

3.10 登录拦截器

一般情况下,博客网站,供大家阅读。除了发表评论、写文章等需要拦截外,其余的不需要拦截。

3.11 服务端获取当前请求的用户信息

其实在reqeust中是可以获得token的,但是在不同方法中总是传输request对象是不合理的。此时就需要将token存储到另一个比较变量中,如ThreadLocal,这个是线程中的一个变量。服务器针对每个request都会分配一个线程来处理该请求,显然可将一次请求中共用的数据存储到ThreadLocal变量中,这样如果有些方法不方便获取到request对象,显然可通过ThreadLocal来获取到。

另外,因为ThreadLocal是一个线程共有的,所以当该线程结束之后,需要我们手动删除ThreadLocal中保存的信息。

因此,如果有拦截器,可在preHandler中设置ThreadLocal中的存储,在afterCompletion中清空ThreadLocal中的存储。

史上最全ThreadLocal 详解(一)_FMcGee的博客-CSDN博客_threadlocal,注意,其实每次调用ThreadLocal时,实际上就是获取的是当前线程的ThreadLocal变量,即该变量是线程隔离的。

3.12 文章详情

文章详情,就是通过指定文章表ms_article中的id,获取到其所表示的文章的具体内容【ms_article_body】,也需要涉及到文章的具体类别ms_category【注意,和标签tag不是一回事,类别只能有一个,tag则可以有多个】,和标签ms_tag。

注意,雪花算法在前端精度损失。

3.13 阅读次数

阅读次数,显然是在文章详情那里,点击一次文章详情操作,阅读次数就加一。但是这样的话,会存在什么问题呢?

阅读次数加一,意味着是对表进行更新操作,那么就会对该表加锁,此时,就必须等待阅读次数更新完之后,才会返回文章详情的结果,显然,阅读次数如果更新失败,本方法就不会返回结果,从而导致文章详情失败,即不允许读文章。这显然是不合理的。

阅读次数和文章详情虽然是关联的,但是不能影响文章显示内容。可以把更新操作扔到线程池中去异步执行,这样就和主线程不相关了。

SpringBoot开启多线程。

update t_act set balance = (select t.new_balance from (select balance + 1 as new_balance from t_act where actno=111) as t ) where actno = 111

3.14 显示评论

3.15 评论

注意,在评论之前,要先登录。所以需要将评论url加入到登录拦截器中。

3.16 写文章

和评论类似,在发布文章之前,一样要先进行登录拦截。

写文章,有三个请求:获取到所有的分类选项【category】,获取到所有的标签选项【tag】,发布文章。

注意,写入数据库的时候,要写三个表:ms_article,ms_article_body,ms_article_tag。发布完文章后,跳转到该文章详情页面,也需要返回刚发布的文章id。

3.17 AOP日志记录

日志在生产环境中是很重要的,比如日志过程中有什么异常出现了、异常出现的时间、接口等等。但是日志记录肯定是不能切入到业务代码中的,只能以切面的形式切入到系统中。

可在某个方法(比如Controller中的某个方法)上面加入注解@LogAnnotation(module="文章", operater="获取文章列表"),表示对该接口记录日志。

此时可开发该注解以及该注解的具体切面实现即可。

定义该注解:

1
2
3
4
5
6
7
8
9
@Target({ElementType.METHOD})     // 表示可放在方法上修饰
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface LogAnnotation {

String module() default "";

String operator() default "";
}

实现该注解的功能切面实现:

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
@Component
@Aspect // 切面 定义了通知和切点的关系
@Slf4j
public class LogAspect {

// 定义具体的切入点
@Pointcut("@annotation(org.hianian.blog.commons.aop.LogAnnotation)")
public void pt(){}

// 环绕通知,即对切点方法的前后都可增强
// 注意,函数执行发生的异常(proceed)一定要抛出,因为本来就是该方法抛出的异常,理应由原系统处理。
// 其实,本切面,因为目的就是为了记录执行过程,所以对执行中的其他任何情况都无需处理,对于执行结果也应该返回。
@Around("pt()")
public Object log(ProceedingJoinPoint joinPoint) throws Throwable {

long beginTime = System.currentTimeMillis();

// 执行原有方法
Object result = joinPoint.proceed();

long time = System.currentTimeMillis() - beginTime;

// 记录日志
recordLog(joinPoint, 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.operator());

//请求的方法名
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================================");
}
}

上面切面功能实现用到的两个工具类:

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

/**
* 获取IP地址
* <p>
* 使用Nginx等反向代理软件, 则不能通过request.getRemoteAddr()获取IP地址
* 如果使用了多级反向代理的话,X-Forwarded-For的值并不止一个,而是一串IP地址,X-Forwarded-For中第一个非unknown的有效IP字符串,则为真实IP地址
*/
public static String getIpAddr(HttpServletRequest request) {
String ip = null, unknown = "unknown", seperator = ",";
int maxLength = 15;
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) {
log.error("IpUtils ERROR ", e);
}

// 使用代理,则获取第一个IP地址
if (StringUtils.isEmpty(ip) && ip.length() > maxLength) {
int idx = ip.indexOf(seperator);
if (idx > 0) {
ip = ip.substring(0, idx);
}
}

return ip;
}

/**
* 获取ip地址
*
* @return
*/
public static String getIpAddr() {
HttpServletRequest request = HttpContextUtils.getHttpServletRequest();
return getIpAddr(request);
}
}
1
2
3
4
5
6
7
public class HttpContextUtils {

public static HttpServletRequest getHttpServletRequest() {
return ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
}

}

实现了上面的功能,可在要记录日志的方法上面加入注解即可。编译器会找到该注解,并且找到该注解的具体功能实现。

1
2
3
4
5
@PostMapping("/articles")
@LogAnnotation(module="文章", operator="获取文章列表") // 测试一下利用自定义注解来记录日志(本质上是利用AOP实现日志记录)
public ReturnObject listArticle(@RequestBody PageParams pageParams){
...
}

3.18 文章上传图片

文章中肯定是需要图片的,采用markdown格式的图片,可能显示失败。这里设置一个本地上传图片的功能。

但是注意,图片这里不再存储到服务器上,而是存到一个专门的服务器上,后续用户访问文章的时候,会从本服务器上获取文章内容,从该服务器上获取具体的图片,即动静分离。

3.19 导航【文章分类、标签】

这里和前面查询所有分类、所有标签是类似的,只不过这里显示的更加全面,多了description、头像地址等等。其实就是在Vo对象中加入对应的属性,然后查询数据库的时候,查询全部字段即可。

3.20 文章分类

点击文章类别,显示所有属于该类别的文章。但是换句话说,即在查询所有文章时,限制一个category_id条件就行。

本意上仍然在categoryContoller中操作,找到该Category的具体id返回即可,前端接收到之后,会拼接该id参数,查询category_id为该id的article。其实这部分代码,也不需要修改。只需要在PageParams类中加入category_id和tag_id两个属性即可,之后在SQL语句分页查询中,采用动态SQL作为条件查询即可。

3.21 文章标签

点击对应的文章标签,显示所有包含该标签的文章。注意,标签和分类不一样,一个文章有多个标签,所以标签id没有在article表中。

这个时候,需要在article_tag表中,找到tag_id为该id的所有记录,找到所有的article_id,然后再根据id查询article,返回articleVo。

3.22 文章归档

按照时间(年月)来查询文章。

4. 优化

4.1 统一缓存处理

内存的访问速速,远远大于磁盘的访问速度。所以优化的时候,应该尽量减少磁盘访问。

4.2 搜索文章,ElasticSearch引擎

4.3 阅读数和评论数

可将这两个数据,放入到Redis中,incr自增,使用定时任务,将数据写入到数据库中。

5. 后台管理

SpringSecurity权限管理。

新建子模块,blog-admin。先设置permission数据表,表中存储具体的权限,字段有id、name、path、description。其实最主要的就是path。可以对该表进行增删改查设置权限。

然后添加SpringSecurity依赖。

5.1 权限认证

不同的用户有不同的权限,所以当用户访问某个url时,进行权限认证,通过ms_admin获取到该用户【id】,然后通过ms_admin_permission获取到该用户的所有权限id,然后通过ms_permission,获取到权限id对应的path,即访问的url。判断当前访问的url是否在path中。

1
select * from ms_permission where id in (select permission_id from ms_admin_permission where id=#{id})

6. 总结

  1. jwt+token

    token令牌的登录方式,访问认证速度快,session共享,安全性。Redis做了令牌和用户信息的对应管理,

    1. 进一步增加了安全性
    2. 登录用户做了缓存
    3. 灵活控制用户的过期(续期,提掉线等等)
  2. ThreadLocal使用了保存用户信息,在请求的线程内,可以随时获取登录的用户,做了线程隔离。

  3. 在使用完ThreadLocal之后,做了value删除,防止了内存泄露

  4. 线程安全【阅读次数更新】

  5. 线程池,使用非常广,面试7个核心参数(对当前的主业务流程无影响的操作,放入线程池执行)

    1. 记录登录日志等等
  6. 权限系统,重点内容。

  7. 统一日志记录,统一缓存处理。


文章作者: 浮云
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 浮云 !
  目录