eblog
eblog 是一个根据B站UP主MarkerHub、基于 Springboot 开发的博客学习项目,视频链接https://www.bilibili.com/video/BV1ri4y1x71A。主要功能有:自定义 Freemarker 标签,使用 shiro+redis 完成会话共享,redis 的 zset 结构完成本周热议排行榜,t-io+websocket 完成即时消息通知和群聊,rabbitmq+elasticsearch 完成博客内容搜索引擎等。虽然内容很多,但是我还是把重点放在了后端实现的逻辑上,并未过多关注前端的实现。
项目文档:https://juejin.cn/post/5ee88c58518825434c3db0e5
讲解视频:https://www.bilibili.com/video/BV1ri4y1x71A
部署视频:https://www.bilibili.com/video/BV1dk4y1r7pi
项目源码:https://github.com/MarkerHub/eblog
前端页面素材:链接: https://pan.baidu.com/s/1u9iZ-KJ3U5B6O_sD_5iuKw 提取码: pcfb
1.项目搭建
pom.xml 的依赖包如下:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.6.3</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.lyj</groupId>
<artifactId>eblog</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>eblog</name>
<description>eblog</description>
<properties>
<java.version>11</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-freemarker</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!--mybatisPlus-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.1</version>
</dependency>
<!-- sql分析器 -->
<dependency>
<groupId>p6spy</groupId>
<artifactId>p6spy</artifactId>
<version>3.8.6</version>
</dependency>
<!-- commons-lang3 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.9</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
在 application.yml
中编写配置文件:
# DataSource Config
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
# driver-class-name: com.p6spy.engine.spy.P6SpyDriver
# url: jdbc:p6spy:mysql://localhost:3306/eblog?useSSL=true&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
url: jdbc:mysql://localhost:3306/eblog?useSSL=true&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
username: root
password: 7012+2
hikari:
# 连接池名
pool-name: DateHikariCP
# 最小空闲连接池
minimum-idle: 5
# 最大连接数 默认10
maximum-pool-size: 10
# 连接池返回的连接自动提交
auto-commit: true
# 连接最大存活时间 0表示永久存活 默认1800000 (30分钟)
max-lifetime: 1800000
# 连接超时时间 默认30000(30秒)
connection-timeout: 30000
# 测试连接是否可用的查询语句
connection-test-query: select 1
新建数据库,并使用 MybatisPlus 代码生成器自动生成对应的类。
2.页面
根据上图将首页划分为几个区域,前端页面的实现忽略,只关注后端代码。
-
有些数据是项目启动的时候就需要的
- ```java
/**
- 实现功能:
-
项目启动的时候便运行以下代码: header-panel中的类别 * */ @Component public class ContextStartUp implements ApplicationRunner, ServletContextAware {
@Autowired ICategoryService categoryService;
ServletContext servletContext;
@Autowired IPostService postService;
@Override public void run(ApplicationArguments args) throws Exception {
List<Category> categories = categoryService.list(new QueryWrapper<Category>() .eq("status", 0) ); servletContext.setAttribute("categories", categories); /*本周热议 * * 项目启功的时候便初始化 * */ postService.initWeekRank(); }
@Override public void setServletContext(ServletContext servletContext) { this.servletContext = servletContext; } }
```
- ```java
/**
-
首页
-
@Controller public class IndexController extends BaseController { @RequestMapping({"", "/", "/index"}) public String index() { /*参数:1分页信息 2分类 3用户 4置顶 5精选 6排序*/ IPage results = postService.paging(getPage(), null, null, null, null, "created"); /*分页的数据*/ request.setAttribute("pageData", results); /*默认首页的id是0*/ request.setAttribute("currentCategoryId", 0); return "index"; } }
- ```java
/**
- 服务类 *
- @author LiuYunJie
- @since 2022-02-08
*/
public interface IPostService extends IService
{ /** - 自实现的分页功能
- @param page 分页信息
- @param categoryId 分类信息
- @param userId 用户信息
- @param level 置顶等级
- @param recommend 是否推荐
- @param order 排序方式
- @return */ IPage paging(Page page, Long categoryId, Long userId, Integer level, Boolean recommend, String order); }
```
- ```java
/**
- 服务实现类 *
- @author LiuYunJie
-
@since 2022-02-08 */ @Service public class PostServiceImpl extends ServiceImpl<PostMapper, Post> implements IPostService {
@Autowired PostMapper postMapper;
@Autowired RedisUtil redisUtil;
@Override public IPage paging(Page page, Long categoryId, Long userId, Integer level, Boolean recommend, String order) { if (level == null) { level = -1; } QueryWrapper
wrapper = new QueryWrapper () .eq(categoryId != null, "category_id", categoryId) .eq(userId != null, "user_id", userId) .eq(level == 0, "level", 0) .gt(level > 0, "level", 0) .orderByDesc(order != null, order); return postMapper.selectPosts(page, wrapper); }
/**
-
本周热议 */ @Override public void initWeekRank() {
/获取7天内发表的文章/ List
posts = this.list(new QueryWrapper () .ge("created", DateUtil.lastWeek()) .select("id, title, user_id, comment_count, view_count, created")); /初始化文章的总评论量/ for (Post post: posts) { String key = “day:rank:” + DateUtil.format(post.getCreated(), DatePattern.PURE_DATE_FORMAT); redisUtil.zSet(key, post.getId(), post.getCommentCount());
/*7天后自动过期*/ long between = DateUtil.between(new Date(), post.getCreated(), DateUnit.DAY); // 天 long expireTime = (7 - between) * 24 * 3600; // 剩余过期时间 秒 redisUtil.expire(key, expireTime); /*设置key的存活时间*/ /*缓存文章的一些基本信息(id、标题、评论数量、作者)*/ this.hashCachePostInformation(post, expireTime);
}
/并集/ this.zunionAndStoredLast7DayForWeekRank();
}
/**
- 评论数增加以后自动更新本周热议功能
- @param postId
-
@param isIncr */ @Override public void incrCommentCountAndUnionForWeekRank(long postId, boolean isIncr) { String currentKey = “day:rank:” + DateUtil.format(new Date(), DatePattern.PURE_DATE_FORMAT); redisUtil.zIncrementScore(currentKey, postId, isIncr? 1: -1);
Post post = this.getById(postId);
// 7天后自动过期(15号发表,7-(18-15)=4) long between = DateUtil.between(new Date(), post.getCreated(), DateUnit.DAY); long expireTime = (7 - between) * 24 * 60 * 60; // 有效时间
// 缓存这篇文章的基本信息 this.hashCachePostInformation(post, expireTime);
// 重新做并集 this.zunionAndStoredLast7DayForWeekRank(); }
/**
- 阅读量加1
-
@param postVo */ @Override public void putViewCount(PostVo postVo) { String key = “rank:post:” + postVo.getId();
/1 从缓存中获取viewCount(阅读量)/ Integer viewCount = (Integer) redisUtil.hget(key, “post:viewCount”);
/2 如果没有 就从数据库中获取 再加1/ if (viewCount !=null) { postVo.setViewCount(viewCount + 1); } else { postVo.setViewCount(postVo.getViewCount() + 1); }
/3 同步到缓存中/ redisUtil.hset(key, “post:viewCount”, postVo.getViewCount()); }
/**
-
本周合并每日评论数量操作 */ private void zunionAndStoredLast7DayForWeekRank() { String currentKey = “day:rank:” + DateUtil.format(new Date(), DatePattern.PURE_DATE_FORMAT);
String destKey = “week:rank”; List
otherKeys = new ArrayList<>(); for(int i=-6; i < 0; i++) { String temp = "day:rank:" + DateUtil.format(DateUtil.offsetDay(new Date(), i), DatePattern.PURE_DATE_FORMAT); otherKeys.add(temp); }
redisUtil.zUnionAndStore(currentKey, otherKeys, destKey); }
/**
- 缓存文章的基本信息
- @param post
- @param expireTime */ private void hashCachePostInformation(Post post, long expireTime) { String key = “rank:post:” + post.getId(); boolean hasKey = redisUtil.hasKey(key); if (!hasKey) { redisUtil.hset(key, “post:id”, post.getId(), expireTime); redisUtil.hset(key, “post:title”, post.getTitle(), expireTime); redisUtil.hset(key, “post:commentCount”, post.getCommentCount(), expireTime); redisUtil.hset(key, “post:viewCount”, post.getViewCount(), expireTime); } } } ```
-
-
@Component public interface PostMapper extends BaseMapper<Post> { IPage<PostVo> selectPosts(Page page, @Param(Constants.WRAPPER) QueryWrapper<Post> wrapper); PostVo selectOnePost(@Param(Constants.WRAPPER)QueryWrapper<Post> wrapper); }
-
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.lyj.eblog.mapper.PostMapper"> <!-- 通用查询映射结果 --> <resultMap id="BaseResultMap" type="com.lyj.eblog.pojo.Post"> <id column="id" property="id" /> <result column="title" property="title" /> <result column="content" property="content" /> <result column="edit_mode" property="editMode" /> <result column="category_id" property="categoryId" /> <result column="user_id" property="userId" /> <result column="vote_up" property="voteUp" /> <result column="vote_down" property="voteDown" /> <result column="view_count" property="viewCount" /> <result column="comment_count" property="commentCount" /> <result column="recommend" property="recommend" /> <result column="level" property="level" /> <result column="status" property="status" /> <result column="created" property="created" /> <result column="modified" property="modified" /> </resultMap> <!-- 通用查询结果列 --> <sql id="Base_Column_List"> id, title, content, edit_mode, category_id, user_id, vote_up, vote_down, view_count, comment_count, recommend, level, status, created, modified </sql> <select id="selectPosts" resultType="com.lyj.eblog.Vo.PostVo"> SELECT p.*, u.id AS authorId, u.username AS authorName, u.avatar AS authorAvatar, c.id AS categoryId, c.name AS categoryName FROM m_post p LEFT JOIN m_user u ON p.user_id = u.id LEFT JOIN m_category c ON p.category_id = c.id ${ew.customSqlSegment} </select> <select id="selectOnePost" resultType="com.lyj.eblog.Vo.PostVo"> SELECT p.*, u.id AS authorId, u.username AS authorName, u.avatar AS authorAvatar, c.id AS categoryId, c.name AS categoryName FROM m_post p LEFT JOIN m_user u ON p.user_id = u.id LEFT JOIN m_category c ON p.category_id = c.id ${ew.customSqlSegment} </select> </mapper>
-
BaseController 的作用是减少重复的代码,将一些相似的代码放到一起。
@Controller
public class BaseController {
@Autowired
HttpServletRequest request;
@Autowired
IPostService postService;
@Autowired
ICommentService commentService;
@Autowired
IUserService userService;
@Autowired
IUserMessageService userMessageService;
@Autowired
IUserCollectionService userCollectionService;
@Autowired
ICategoryService categoryService;
@Autowired
WsService wsService;
@Autowired
SearchService searchService;
@Autowired
AmqpTemplate amqpTemplate;
public Page getPage() {
int pn = ServletRequestUtils.getIntParameter(request, "pn", 1);
int size = ServletRequestUtils.getIntParameter(request, "size", 2);
return new Page(pn, size);
}
protected AccountProfile getProfile() {
return (AccountProfile)SecurityUtils.getSubject().getPrincipal();
}
protected Long getProfileId() {
return getProfile().getId();
}
}
2.1导航栏
/**
* 实现功能:
* 项目启动的时候便运行以下代码: header-panel中的类别
*
*/
@Component
public class ContextStartUp implements ApplicationRunner, ServletContextAware {
@Autowired
ICategoryService categoryService;
ServletContext servletContext;
@Override
public void run(ApplicationArguments args) throws Exception {
List<Category> categories = categoryService.list(new QueryWrapper<Category>()
.eq("status", 0)
);
servletContext.setAttribute("categories", categories);
}
@Override
public void setServletContext(ServletContext servletContext) {
this.servletContext = servletContext;
}
}
2.2 分页
分页并不是使用的 MybatisPlus 自带的分页功能,而是根据不同场景的需求,自己在 MybatisPlus 的基础上是实现的。
2.2.1 首页的分页
根据观察可知,需要的不仅仅是博客的信息、还有作者的信息。
IndexController
@Controller
public class IndexController extends BaseController {
@RequestMapping({"", "/", "/index"})
public String index() {
/*参数:1分页信息 2分类 3用户 4置顶 5精选 6排序*/
IPage results = postService.paging(getPage(), null, null, null, null, "created");
/*分页的数据*/
request.setAttribute("pageData", results);
/*默认首页的id是0*/
request.setAttribute("currentCategoryId", 0);
return "index";
}
}
IPostService
public interface IPostService extends IService<Post> {
/**
* 自实现的分页功能
* @param page 分页信息
* @param categoryId 分类信息
* @param userId 用户信息
* @param level 置顶等级
* @param recommend 是否推荐
* @param order 排序方式
* @return
*/
IPage paging(Page page, Long categoryId, Long userId, Integer level,
Boolean recommend, String order);
@Service
public class PostServiceImpl extends ServiceImpl<PostMapper, Post> implements IPostService {
@Autowired
PostMapper postMapper;
@Override
public IPage paging(Page page, Long categoryId, Long userId,
Integer level, Boolean recommend, String order) {
if (level == null) {
level = -1;
}
QueryWrapper<Post> wrapper = new QueryWrapper<Post>()
.eq(categoryId != null, "category_id", categoryId)
.eq(userId != null, "user_id", userId)
.eq(level == 0, "level", 0)
.gt(level > 0, "level", 0)
.orderByDesc(order != null, order);
return postMapper.selectPosts(page, wrapper);
}
}
PostMapper.java
@Component
public interface PostMapper extends BaseMapper<Post> {
IPage<PostVo> selectPosts(Page page,
@Param(Constants.WRAPPER) QueryWrapper<Post> wrapper);
PostMapper.xml
<select id="selectPosts" resultType="com.lyj.eblog.Vo.PostVo">
SELECT
p.*,
u.id AS authorId,
u.username AS authorName,
u.avatar AS authorAvatar,
c.id AS categoryId,
c.name AS categoryName
FROM
m_post p
LEFT JOIN m_user u ON p.user_id = u.id
LEFT JOIN m_category c ON p.category_id = c.id
${ew.customSqlSegment}
</select>
2.2.2 我的主页的分页
UserController
/*我的消息*/
@GetMapping("/user/mess")
public String mess() {
IPage<UserMessageVo> page = userMessageService.paging(getPage(), new QueryWrapper<UserMessage>()
.eq("to_user_id", getProfileId())
.orderByDesc("created")
);
IUserMessageService
/**
* <p>
* 服务类
* </p>
*
* @author LiuYunJie
* @since 2022-02-08
*/
public interface IUserMessageService extends IService<UserMessage> {
IPage paging(Page page, QueryWrapper<UserMessage> wrapper);
}
UserMessageServiceImpl
@Service
public class UserMessageServiceImpl extends ServiceImpl<UserMessageMapper, UserMessage> implements IUserMessageService {
@Autowired
UserMessageMapper userMessageMapper;
@Override
public IPage paging(Page page, QueryWrapper<UserMessage> wrapper) {
return userMessageMapper.selectMessage(page, wrapper);
}
}
UserMessageMapper
@Component
public interface UserMessageMapper extends BaseMapper<UserMessage> {
IPage<UserMessageVo> selectMessage(Page page,
@Param(Constants.WRAPPER) QueryWrapper<UserMessage> wrapper);
}
UserMessageMapper.xml
<select id="selectMessage" resultType="com.lyj.eblog.Vo.UserMessageVo">
SELECT
m.*, (
SELECT
username
FROM
`m_user`
WHERE
id = m.from_user_id
) AS fromUserName,
(
SELECT
title
FROM
`m_post`
WHERE
id = m.post_id
) AS postTitle
FROM
`m_user_message` m
${ew.customSqlSegment}
</select>
2.2.3 MybatisPlus自带
UserController
/*发表的文章*/
@ResponseBody
@GetMapping("/user/public")
public Result userPublic() {
IPage page = postService.page(getPage(), new QueryWrapper<Post>()
.eq("user_id", getProfileId())
.orderByDesc("created"));
return Result.success(page);
}
/*收藏的文章*/
@ResponseBody
@GetMapping("/user/collection")
public Result collection() {
IPage page = postService.page(getPage(), new QueryWrapper<Post>()
.inSql("id", "SELECT post_id FROM m_user_collection where user_id = " + getProfileId())
);
return Result.success(page);
}
因为这两种需求只需要文章的信息(即只需要一个数据库表)即可。
3. 本周热议、文章阅读量
3.1 本周热议
本周:7天内
热议:评论最多
思路:单独记录每天的评论量。
方法:使用 Redis 的 Zset,存储日期以及文章id。
-
因为本周热议是项目启动就存在,所以在 ContextStartUp.java 中编写该功能。
-
/*本周热议 * * 项目启功的时候便初始化 * */ postService.initWeekRank();
-
-
实现该方法
- ```java
/**
- 服务实现类 *
- @author LiuYunJie
-
@since 2022-02-08 */ @Service public class PostServiceImpl extends ServiceImpl<PostMapper, Post> implements IPostService {
@Autowired PostMapper postMapper;
@Autowired RedisUtil redisUtil;
@Override public IPage paging(Page page, Long categoryId, Long userId, Integer level, Boolean recommend, String order) { if (level == null) { level = -1; } QueryWrapper
wrapper = new QueryWrapper () .eq(categoryId != null, "category_id", categoryId) .eq(userId != null, "user_id", userId) .eq(level == 0, "level", 0) .gt(level > 0, "level", 0) .orderByDesc(order != null, order); return postMapper.selectPosts(page, wrapper); }
@Override public PostVo selectOnePost(QueryWrapper
wrapper) { return postMapper.selectOnePost(wrapper); } /**
-
本周热议 */ @Override public void initWeekRank() {
/获取7天内发表的文章/ List
posts = this.list(new QueryWrapper () .ge("created", DateUtil.lastWeek()) .select("id, title, user_id, comment_count, view_count, created")); /初始化文章的总评论量/ for (Post post: posts) { String key = “day:rank:” + DateUtil.format(post.getCreated(), DatePattern.PURE_DATE_FORMAT); redisUtil.zSet(key, post.getId(), post.getCommentCount());
/*7天后自动过期*/ long between = DateUtil.between(new Date(), post.getCreated(), DateUnit.DAY); // 天 long expireTime = (7 - between) * 24 * 3600; // 剩余过期时间 秒 redisUtil.expire(key, expireTime); /*设置key的存活时间*/ /*缓存文章的一些基本信息(id、标题、评论数量、作者)*/ this.hashCachePostInformation(post, expireTime);
}
/并集/ this.zunionAndStoredLast7DayForWeekRank();
} /**
-
本周合并每日评论数量操作 */ private void zunionAndStoredLast7DayForWeekRank() { String currentKey = “day:rank:” + DateUtil.format(new Date(), DatePattern.PURE_DATE_FORMAT);
String destKey = “week:rank”; List
otherKeys = new ArrayList<>(); for(int i=-6; i < 0; i++) { String temp = "day:rank:" + DateUtil.format(DateUtil.offsetDay(new Date(), i), DatePattern.PURE_DATE_FORMAT); otherKeys.add(temp); }
redisUtil.zUnionAndStore(currentKey, otherKeys, destKey); }
/**
- 缓存文章的基本信息
- @param post
- @param expireTime */ private void hashCachePostInformation(Post post, long expireTime) { String key = “rank:post:” + post.getId(); boolean hasKey = redisUtil.hasKey(key); if (!hasKey) { redisUtil.hset(key, “post:id”, post.getId(), expireTime); redisUtil.hset(key, “post:title”, post.getTitle(), expireTime); redisUtil.hset(key, “post:commentCount”, post.getCommentCount(), expireTime); redisUtil.hset(key, “post:viewCount”, post.getViewCount(), expireTime); } } /**
- 评论数增加以后自动更新本周热议功能
- @param postId
-
@param isIncr */ @Override public void incrCommentCountAndUnionForWeekRank(long postId, boolean isIncr) { String currentKey = “day:rank:” + DateUtil.format(new Date(), DatePattern.PURE_DATE_FORMAT); redisUtil.zIncrementScore(currentKey, postId, isIncr? 1: -1);
Post post = this.getById(postId);
// 7天后自动过期(15号发表,7-(18-15)=4) long between = DateUtil.between(new Date(), post.getCreated(), DateUnit.DAY); long expireTime = (7 - between) * 24 * 60 * 60; // 有效时间
// 缓存这篇文章的基本信息 this.hashCachePostInformation(post, expireTime);
// 重新做并集 this.zunionAndStoredLast7DayForWeekRank(); } } ```
-
- ```java
/**
-
定义模板,没看懂
- ```java
/**
-
本周热议 */ @Component public class HotsTemplate extends TemplateDirective {
@Autowired RedisUtil redisUtil;
@Override public String getName() { return “hots”; }
@Override public void execute(DirectiveHandler handler) throws Exception { String weekRankKey = “week:rank”;
Set<ZSetOperations.TypedTuple> typedTuples = redisUtil.getZSetRank(weekRankKey, 0, 6); List<Map> hotPosts = new ArrayList<>(); for (ZSetOperations.TypedTuple typedTuple : typedTuples) { Map<String, Object> map = new HashMap<>(); Object value = typedTuple.getValue(); // post的id String postKey = "rank:post:" + value; map.put("id", value); map.put("title", redisUtil.hget(postKey, "post:title")); map.put("commentCount", typedTuple.getScore()); hotPosts.add(map); } handler.put(RESULTS, hotPosts).render(); } }
```
-
- ```java
/**
-
修改前端页面
3.2 文章阅读量
-
阅读量+1功能:
-
PostController.java
-
/** * 阅读量加1 * */ postService.putViewCount(postVo);
-
PostServerImpl.java
-
@Override public void putViewCount(PostVo vo) { String key = "rank:post:" + vo.getId(); // 1、从缓存中获取viewcount Integer viewCount = (Integer) redisUtil.hget(key, "post:viewCount"); // 2、如果没有,就先从实体里面获取,再加一 if(viewCount != null) { vo.setViewCount(viewCount + 1); } else { vo.setViewCount(vo.getViewCount() + 1); } // 3、同步到缓存里面 redisUtil.hset(key, "post:viewCount", vo.getViewCount()); }
-
-
定时器:缓存与数据库同步
-
@EnableScheduling @SpringBootApplication @MapperScan("com.lyj.eblog.mapper") public class EblogApplication { public static void main(String[] args) { SpringApplication.run(EblogApplication.class, args); System.out.println("Test"); } }
-
ViewCountSync.java
-
package com.lyj.eblog.schedules; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.lyj.eblog.pojo.Post; import com.lyj.eblog.service.IPostService; import com.lyj.eblog.util.RedisUtil; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import java.util.ArrayList; import java.util.List; import java.util.Set; /** * 定时器功能:将缓存中的文章阅读量同步到数据库 */ @Component public class ViewCountSync { @Autowired RedisUtil redisUtil; @Autowired RedisTemplate redisTemplate; @Autowired IPostService postService; @Scheduled(cron = "0/5 * * * * *") //每分钟同步 public void task() { Set<String> keys = redisTemplate.keys("rank:post:*"); List<String> ids = new ArrayList<>(); for (String key : keys) { if(redisUtil.hHasKey(key, "post:viewCount")){ ids.add(key.substring("rank:post:".length())); } } if(ids.isEmpty()) return; // 需要更新阅读量 List<Post> posts = postService.list(new QueryWrapper<Post>().in("id", ids)); posts.stream().forEach((post) ->{ Integer viewCount = (Integer) redisUtil.hget("rank:post:" + post.getId(), "post:viewCount"); post.setViewCount(viewCount); }); if(posts.isEmpty()) return; boolean isSuccess = postService.updateBatchById(posts); if(isSuccess) { ids.stream().forEach((id) -> { redisUtil.hdel("rank:post:" + id, "post:viewCount"); System.out.println(id + "---------------------->同步成功"); }); } } }
-
4. 登录与注册
4.1 登录逻辑
关于登录模块,我们先来梳理一下逻辑,首先是把登录注册的页面复制进来,然后改成模板形式(头和尾,侧边栏等),再然后集成 shiro 框架,写登录注册接口,login -> realm(认证)-> 写登录注册逻辑 -> 页面的 shiro 标签 -> 分布式 session 的相关配置.
LoginController
@GetMapping("/login")
public String login() {
return "/auth/login";
}
@ResponseBody
@PostMapping("/login")
public Result doLogin(String email, String password) {
if (StrUtil.isEmpty(email) || StrUtil.isBlank(password)) {
return Result.fail("邮箱或密码不能为空");
}
UsernamePasswordToken token = new UsernamePasswordToken(email, SecureUtil.md5(password));
try {
SecurityUtils.getSubject().login(token);
} catch (AuthenticationException e) {
if (e instanceof UnknownAccountException) {
return Result.fail("用户不存在");
} else if (e instanceof LockedAccountException) {
return Result.fail("用户被禁用");
} else if (e instanceof IncorrectCredentialsException) {
return Result.fail("密码错误");
} else {
return Result.fail("用户认证失败");
}
}
return Result.success().action("/");
}
上面的代码,首先分别写了一下 login 的 get 和 post 的方式,一个是跳转到 login,然后我们通过异步的 post 方式来提交 form 表单数据,login 的主要逻辑很简单,主要就一行代码:
SecurityUtils.getSubject().login(token);
根据我们对shiro的理解,login之后会最终委托给realm完成登录逻辑的认证,那么我们先来看看realm的内容(doGetAuthenticationInfo)
@Slf4j
@Component
public class AccountRealm extends AuthorizingRealm {
@Autowired
UserService userService;
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
return null;
}
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
UsernamePasswordToken token = (UsernamePasswordToken)authenticationToken;
//注意token.getUsername()是指email!!
AccountProfile profile = userService.login(token.getUsername(), String.valueOf(token.getPassword()));
log.info("---------------->进入认证步骤");
SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(profile, token.getCredentials(), getName());
return info;
}
}
doGetAuthenticationInfo
就是我们认证的方法,authenticationToken
就是我们的传过来的 UsernamePasswordToken
,包含着邮箱和密码。然后 userService.login
的内容就是校验一下账户的合法性,不合法就抛出对应的异常,合法最终就返回封装对象 AccountProfile
。
@Override
public AccountProfile login(String username, String password) {
log.info("------------>进入用户登录判断,获取用户信息步骤");
User user = this.getOne(new QueryWrapper<User>().eq("email", username));
if(user == null) {
throw new UnknownAccountException("账户不存在");
}
if(!user.getPassword().equals(password)) {
throw new IncorrectCredentialsException("密码错误");
}
//更新最后登录时间
user.setLasted(new Date());
this.updateById(user);
AccountProfile profile = new AccountProfile();
BeanUtil.copyProperties(user, profile);
return profile;
}
4.2 注册
注册过程设计到一个验证码校验的插件,这里我们使用 google 的验证码生成器 kaptcha。
-
引入依赖
<!--图像验证码--> <!-- https://mvnrepository.com/artifact/com.github.axet/kaptcha --> <dependency> <groupId>com.github.axet</groupId> <artifactId>kaptcha</artifactId> <version>0.0.9</version> </dependency>
-
然后配置一下验证码的图片生成规则:(边框、颜色、字体大小、长、高等)
/** * 图形验证码配置类 * */ @Configuration public class KaptchaConfig { @Bean public DefaultKaptcha producer () { Properties propertis = new Properties(); propertis.put("kaptcha.border", "no"); propertis.put("kaptcha.image.height", "38"); propertis.put("kaptcha.image.width", "150"); propertis.put("kaptcha.textproducer.font.color", "black"); propertis.put("kaptcha.textproducer.font.size", "32"); Config config = new Config(propertis); DefaultKaptcha defaultKaptcha = new DefaultKaptcha(); defaultKaptcha.setConfig(config); return defaultKaptcha; } }
-
提供一个访问的接口用于生成验证码图片
private static final String KAPTCHA_SESSION_KEY = "KAPTCHA_SESSION_KEY"; @Autowired Producer producer; @GetMapping("/kapthca.jpg") public void kaptcha(HttpServletResponse response) throws IOException { /*验证码*/ String text = producer.createText(); BufferedImage image = producer.createImage(text); /*将验证码信息存储到Session中*/ request.getSession().setAttribute(KAPTCHA_SESSION_KEY, text); response.setHeader("Cache-Control", "no-store, no-cache"); response.setContentType("image/ipeg"); ServletOutputStream outputStream = response.getOutputStream(); ImageIO.write(image, "jpg", outputStream); }
-
所以访问这个接口就能得到验证码图片流,页面中:
<div class=""> <image id="kapthca" src="/kapthca.jpg"></image> </div>
-
那么流是接通前端后端的,到后端还需要验证验证码的正确性,所以生成验证码的时候我们需要把验证码先存到 session 中,然后注册接口中再从 session 中获取出来然后比较是否正确。
@ResponseBody @PostMapping("/register") public Result doRegister(User user, String repass, String vercode) { /*检验用户名、密码、邮箱*/ ValidationUtil.ValidResult validResult = ValidationUtil.validateBean(user); if (validResult.hasErrors()) { return Result.fail(validResult.getErrors()); } /** * 这里如果把密码加密应该更好一些 * */ if (!user.getPassword().equals(repass)) { return Result.fail("两次输入密码不正确"); } /*图片验证码*/ String attribute = (String) request.getSession().getAttribute(KAPTCHA_SESSION_KEY); if (attribute == null || !attribute.equalsIgnoreCase(vercode)) { return Result.fail("验证码输入不正确"); } /*注册功能*/ Result result = userService.register(user); return result.action("/login"); }
-
注册的逻辑
/*用户注册*/ @Override public Result register(User user) { long count = this.count(new QueryWrapper<User>() .eq("email", user.getEmail()) .or() .eq("username", user.getUsername())); if (count > 0) { return Result.fail("邮箱或用户名已被占用"); } /*新建一个User对象而不直接传过来的user原因在于代码只判断了email、username、password三项; * 不能确保F12修改代码传输其他的属性 * */ User temp = new User(); temp.setUsername(user.getUsername()); temp.setPassword(SecureUtil.md5(user.getPassword())); temp.setEmail(user.getEmail()); temp.setAvatar("/res/images/avatar/default.png"); temp.setCreated(new Date()); temp.setPoint(0); temp.setVipLevel(0); temp.setCommentCount(0); temp.setPostCount(0); temp.setGender("0"); this.save(temp); return Result.success(); }
5.异常处理
使用 @ControllerAdvice
来进行统一异常处理,@ExceptionHandler(value = Exception.class)
来指定捕获的 Exception
各个类型异常 ,这个异常的处理,是全局的,所有类似的异常,都会跑到这个地方处理。
-
我们自定义一个异常类
HwException
,需要继承RuntimeException
,这样涉及到事务时候才会有回滚。HwException
将作为我们系统catch
到错误时候报出来的异常。public class HwException extends RuntimeException { private int code; public HwException() {} public HwException(int code) { this.code = code; } public HwException(String message) { super(message); } public HwException(int code, String message) { super(message); this.code = code; } public int getCode() { return code; } public void setCode(int code) { this.code = code; } }
-
定义全局异常处理,
@ControllerAdvice
表示定义全局控制器异常处理,@ExceptionHandler
表示针对性异常处理,可对每种异常针对性处理。@Slf4j @ControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(value = Exception.class) public ModelAndView defaultErrorHandler(HttpServletRequest req, Exception e) { log.error("------------------>捕捉到全局异常", e); if(e instanceof HwException) { //... } ModelAndView mav = new ModelAndView(); mav.addObject("exception", e); mav.addObject("message", e.getMessage()); mav.addObject("url", req.getRequestURL()); mav.setViewName("error"); return mav; } @ExceptionHandler(value = HwException.class) @ResponseBody public Result jsonErrorHandler(HttpServletRequest req, HwException e) { return Result.fail(e.getMessage(), "some error data"); } }
6. 集成Redis
-
引入
Redis
<!--redis--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>
-
配置
Redis
spring: redis: host: localhost port: 6379
-
为了让我们的存到
Redis
中的缓存数据能更加容易看懂,这里换一种序列化方式,默认的是jdk
的序列化方式,这里选用jackson2JsonRedisSerializer
。只需要重写redisTemplate
操作模板的生成方式即可。@Configuration public class RedisConfiguration { @Bean public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) throws UnknownHostException { RedisTemplate<Object, Object> template = new RedisTemplate(); template.setConnectionFactory(redisConnectionFactory); Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class); jackson2JsonRedisSerializer.setObjectMapper(new ObjectMapper()); template.setKeySerializer(jackson2JsonRedisSerializer); template.setValueSerializer(jackson2JsonRedisSerializer); return template; } }
-
使用
redisTemplate
操作数据相对比较麻烦,我们使用一个 util 封装类,让我们操作 redis 更加方便。package com.lyj.eblog.util; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ZSetOperations; import org.springframework.stereotype.Component; import org.springframework.util.CollectionUtils; import java.util.Collection; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.TimeUnit; @Component public class RedisUtil { @Autowired private RedisTemplate redisTemplate; /** * 指定缓存失效时间 * * @param key 键 * @param time 时间(秒) * @return */ public boolean expire(String key, long time) { try { if (time > 0) { redisTemplate.expire(key, time, TimeUnit.SECONDS); } return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * 根据key 获取过期时间 * * @param key 键 不能为null * @return 时间(秒) 返回0代表为永久有效 */ public long getExpire(String key) { return redisTemplate.getExpire(key, TimeUnit.SECONDS); } /** * 判断key是否存在 * * @param key 键 * @return true 存在 false不存在 */ public boolean hasKey(String key) { try { return redisTemplate.hasKey(key); } catch (Exception e) { e.printStackTrace(); return false; } } /** * 删除缓存 * * @param key 可以传一个值 或多个 */ @SuppressWarnings("unchecked") public void del(String... key) { if (key != null && key.length > 0) { if (key.length == 1) { redisTemplate.delete(key[0]); } else { redisTemplate.delete(CollectionUtils.arrayToList(key)); } } } //============================String============================= /** * 普通缓存获取 * * @param key 键 * @return 值 */ public Object get(String key) { return key == null ? null : redisTemplate.opsForValue().get(key); } /** * 普通缓存放入 * * @param key 键 * @param value 值 * @return true成功 false失败 */ public boolean set(String key, Object value) { try { redisTemplate.opsForValue().set(key, value); return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * 普通缓存放入并设置时间 * * @param key 键 * @param value 值 * @param time 时间(秒) time要大于0 如果time小于等于0 将设置无限期 * @return true成功 false 失败 */ public boolean set(String key, Object value, long time) { try { if (time > 0) { redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS); } else { set(key, value); } return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * 递增 * * @param key 键 * @param delta 要增加几(大于0) * @return */ public long incr(String key, long delta) { if (delta < 0) { throw new RuntimeException("递增因子必须大于0"); } return redisTemplate.opsForValue().increment(key, delta); } /** * 递减 * * @param key 键 * @param delta 要减少几(小于0) * @return */ public long decr(String key, long delta) { if (delta < 0) { throw new RuntimeException("递减因子必须大于0"); } return redisTemplate.opsForValue().increment(key, -delta); } //================================Map================================= /** * HashGet * * @param key 键 不能为null * @param item 项 不能为null * @return 值 */ public Object hget(String key, String item) { return redisTemplate.opsForHash().get(key, item); } /** * 获取hashKey对应的所有键值 * * @param key 键 * @return 对应的多个键值 */ public Map<Object, Object> hmget(String key) { return redisTemplate.opsForHash().entries(key); } /** * HashSet * * @param key 键 * @param map 对应多个键值 * @return true 成功 false 失败 */ public boolean hmset(String key, Map<String, Object> map) { try { redisTemplate.opsForHash().putAll(key, map); return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * HashSet 并设置时间 * * @param key 键 * @param map 对应多个键值 * @param time 时间(秒) * @return true成功 false失败 */ public boolean hmset(String key, Map<String, Object> map, long time) { try { redisTemplate.opsForHash().putAll(key, map); if (time > 0) { expire(key, time); } return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * 向一张hash表中放入数据,如果不存在将创建 * * @param key 键 * @param item 项 * @param value 值 * @return true 成功 false失败 */ public boolean hset(String key, String item, Object value) { try { redisTemplate.opsForHash().put(key, item, value); return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * 向一张hash表中放入数据,如果不存在将创建 * * @param key 键 * @param item 项 * @param value 值 * @param time 时间(秒) 注意:如果已存在的hash表有时间,这里将会替换原有的时间 * @return true 成功 false失败 */ public boolean hset(String key, String item, Object value, long time) { try { redisTemplate.opsForHash().put(key, item, value); if (time > 0) { expire(key, time); } return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * 删除hash表中的值 * * @param key 键 不能为null * @param item 项 可以使多个 不能为null */ public void hdel(String key, Object... item) { redisTemplate.opsForHash().delete(key, item); } /** * 判断hash表中是否有该项的值 * * @param key 键 不能为null * @param item 项 不能为null * @return true 存在 false不存在 */ public boolean hHasKey(String key, String item) { return redisTemplate.opsForHash().hasKey(key, item); } /** * hash递增 如果不存在,就会创建一个 并把新增后的值返回 * * @param key 键 * @param item 项 * @param by 要增加几(大于0) * @return */ public double hincr(String key, String item, double by) { return redisTemplate.opsForHash().increment(key, item, by); } /** * hash递减 * * @param key 键 * @param item 项 * @param by 要减少记(小于0) * @return */ public double hdecr(String key, String item, double by) { return redisTemplate.opsForHash().increment(key, item, -by); } //============================set============================= /** * 根据key获取Set中的所有值 * * @param key 键 * @return */ public Set<Object> sGet(String key) { try { return redisTemplate.opsForSet().members(key); } catch (Exception e) { e.printStackTrace(); return null; } } /** * 根据value从一个set中查询,是否存在 * * @param key 键 * @param value 值 * @return true 存在 false不存在 */ public boolean sHasKey(String key, Object value) { try { return redisTemplate.opsForSet().isMember(key, value); } catch (Exception e) { e.printStackTrace(); return false; } } /** * 将数据放入set缓存 * * @param key 键 * @param values 值 可以是多个 * @return 成功个数 */ public long sSet(String key, Object... values) { try { return redisTemplate.opsForSet().add(key, values); } catch (Exception e) { e.printStackTrace(); return 0; } } /** * 将set数据放入缓存 * * @param key 键 * @param time 时间(秒) * @param values 值 可以是多个 * @return 成功个数 */ public long sSetAndTime(String key, long time, Object... values) { try { Long count = redisTemplate.opsForSet().add(key, values); if (time > 0) expire(key, time); return count; } catch (Exception e) { e.printStackTrace(); return 0; } } /** * 获取set缓存的长度 * * @param key 键 * @return */ public long sGetSetSize(String key) { try { return redisTemplate.opsForSet().size(key); } catch (Exception e) { e.printStackTrace(); return 0; } } /** * 移除值为value的 * * @param key 键 * @param values 值 可以是多个 * @return 移除的个数 */ public long setRemove(String key, Object... values) { try { Long count = redisTemplate.opsForSet().remove(key, values); return count; } catch (Exception e) { e.printStackTrace(); return 0; } } //===============================list================================= /** * 获取list缓存的内容 * * @param key 键 * @param start 开始 * @param end 结束 0 到 -1代表所有值 * @return */ public List<Object> lGet(String key, long start, long end) { try { return redisTemplate.opsForList().range(key, start, end); } catch (Exception e) { e.printStackTrace(); return null; } } /** * 获取list缓存的长度 * * @param key 键 * @return */ public long lGetListSize(String key) { try { return redisTemplate.opsForList().size(key); } catch (Exception e) { e.printStackTrace(); return 0; } } /** * 通过索引 获取list中的值 * * @param key 键 * @param index 索引 index>=0时, 0 表头,1 第二个元素,依次类推;index<0时,-1,表尾,-2倒数第二个元素,依次类推 * @return */ public Object lGetIndex(String key, long index) { try { return redisTemplate.opsForList().index(key, index); } catch (Exception e) { e.printStackTrace(); return null; } } /** * 将list放入缓存 * * @param key 键 * @param value 值 * @return */ public boolean lSet(String key, Object value) { try { redisTemplate.opsForList().rightPush(key, value); return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * 将list放入缓存 * * @param key 键 * @param value 值 * @param time 时间(秒) * @return */ public boolean lSet(String key, Object value, long time) { try { redisTemplate.opsForList().rightPush(key, value); if (time > 0) expire(key, time); return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * 将list放入缓存 * * @param key 键 * @param value 值 * @return */ public boolean lSet(String key, List<Object> value) { try { redisTemplate.opsForList().rightPushAll(key, value); return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * 将list放入缓存 * * @param key 键 * @param value 值 * @param time 时间(秒) * @return */ public boolean lSet(String key, List<Object> value, long time) { try { redisTemplate.opsForList().rightPushAll(key, value); if (time > 0) expire(key, time); return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * 根据索引修改list中的某条数据 * * @param key 键 * @param index 索引 * @param value 值 * @return */ public boolean lUpdateIndex(String key, long index, Object value) { try { redisTemplate.opsForList().set(key, index, value); return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * 移除N个值为value * * @param key 键 * @param count 移除多少个 * @param value 值 * @return 移除的个数 */ public long lRemove(String key, long count, Object value) { try { Long remove = redisTemplate.opsForList().remove(key, count, value); return remove; } catch (Exception e) { e.printStackTrace(); return 0; } } //================有序集合 sort set=================== /** * 有序set添加元素 * * @param key * @param value * @param score * @return */ public boolean zSet(String key, Object value, double score) { return redisTemplate.opsForZSet().add(key, value, score); } public long batchZSet(String key, Set<ZSetOperations.TypedTuple> typles) { return redisTemplate.opsForZSet().add(key, typles); } public void zIncrementScore(String key, Object value, long delta) { redisTemplate.opsForZSet().incrementScore(key, value, delta); } public void zUnionAndStore(String key, Collection otherKeys, String destKey) { redisTemplate.opsForZSet().unionAndStore(key, otherKeys, destKey); } /** * 获取zset数量 * @param key * @param value * @return */ public long getZsetScore(String key, Object value) { Double score = redisTemplate.opsForZSet().score(key, value); if(score==null){ return 0; }else{ return score.longValue(); } } /** * 获取有序集 key 中成员 member 的排名 。 * 其中有序集成员按 score 值递减 (从大到小) 排序。 * @param key * @param start * @param end * @return */ public Set<ZSetOperations.TypedTuple> getZSetRank(String key, long start, long end) { return redisTemplate.opsForZSet().reverseRangeWithScores(key, start, end); } }
7. 用户中心
接下来要实现的是用户登录完成之后可以实现的功能,如下图所示:
7.1 我的主页
这是点击用户主页之后的显示效果,上面是用户的基本信息,左边是最近发表的文章,右边是最近的操作等(评论,发表等),最近操作部分我们暂时就不弄了,课下大家自行完成。
所以这个页面要完成很简单,只需要把用户的基本信息,和最近的文章传到页面就行了:
UserController.java
/**
* 个人中心
*/
@GetMapping("/user/home")
public String home() {
/*当前登录用户*/
User user = userService.getById(getProfileId());
List<Post> posts = postService.list(new QueryWrapper<Post>()
.eq("user_id", getProfileId())
// 30天内
//.gt("created", DateUtil.lastMonth()
.orderByDesc("created")
);
request.setAttribute("user", user);
request.setAttribute("posts", posts);
return "/user/home";
}
7.2 用户中心
6.2.1 我发的贴
/*发表的文章*/
@ResponseBody
@GetMapping("/user/public")
public Result userPublic() {
IPage page = postService.page(getPage(), new QueryWrapper<Post>()
.eq("user_id", getProfileId())
.orderByDesc("created"));
return Result.success(page);
}
6.2.2 我收藏的帖
我收藏的贴,因为涉及到关联表
UserCollection
Post
所以,需要关联查询
/*收藏的文章*/
@ResponseBody
@GetMapping("/user/collection")
public Result collection() {
IPage page = postService.page(getPage(), new QueryWrapper<Post>()
.inSql("id", "SELECT post_id FROM m_user_collection where user_id = " + getProfileId())
);
return Result.success(page);
}
7.3 基本设置
基本设置这里设计如下操作:
- 修改个人信息
- 修改头像
- 修改密码
7.3.1 展示基本信息以及修改个人信息
@ResponseBody
@PostMapping("/user/set")
public Result doSet(User user) {
if(StrUtil.isNotBlank(user.getAvatar())) {
User temp = userService.getById(getProfileId());
temp.setAvatar(user.getAvatar());
userService.updateById(temp);
AccountProfile profile = getProfile();
profile.setAvatar(user.getAvatar());
SecurityUtils.getSubject().getSession().setAttribute("profile", profile);
return Result.success().action("/user/set#avatar");
}
if(StrUtil.isBlank(user.getUsername())) {
return Result.fail("昵称不能为空");
}
long count = userService.count(new QueryWrapper<User>()
.eq("username", getProfile().getUsername())
.ne("id", getProfileId()));
if(count > 0) {
return Result.fail("该昵称已被占用");
}
User temp = userService.getById(getProfileId());
temp.setUsername(user.getUsername());
temp.setGender(user.getGender());
temp.setSign(user.getSign());
userService.updateById(temp);
AccountProfile profile = getProfile();
profile.setUsername(temp.getUsername());
profile.setSign(temp.getSign());
SecurityUtils.getSubject().getSession().setAttribute("profile", profile);
return Result.success().action("/user/set");
}
7.3.2 上传头像
/*上传头像*/
@ResponseBody
@RequestMapping("/user/upload")
public Result uploadAvatar(@RequestParam(value = "file") MultipartFile file) throws IOException {
return uploadUtil.upload(UploadUtil.type_avatar, file);
}
此处使用了一个工具类 uploadUtil
@Slf4j
@Component
public class UploadUtil {
@Autowired
Consts consts;
public final static String type_avatar = "avatar";
public Result upload(String type, MultipartFile file) throws IOException {
if(StrUtil.isBlank(type) || file.isEmpty()) {
return Result.fail("上传失败");
}
// 获取文件名
String fileName = file.getOriginalFilename();
log.info("上传的文件名为:" + fileName);
// 获取文件的后缀名
String suffixName = fileName.substring(fileName.lastIndexOf("."));
log.info("上传的后缀名为:" + suffixName);
// 文件上传后的路径
String filePath = consts.getUploadDir();
if ("avatar".equalsIgnoreCase(type)) {
AccountProfile profile = (AccountProfile) SecurityUtils.getSubject().getPrincipal();
fileName = "/avatar/avatar_" + profile.getId() + suffixName;
} else if ("post".equalsIgnoreCase(type)) {
fileName = "/post/post_" + DateUtil.format(new Date(), DatePattern.PURE_DATETIME_MS_PATTERN) + suffixName;
}
File dest = new File(filePath + fileName);
// 检测是否存在目录
if (!dest.getParentFile().exists()) {
dest.getParentFile().mkdirs();
}
try {
file.transferTo(dest);
log.info("上传成功后的文件路径未:" + filePath + fileName);
String path = filePath + fileName;
String url = "/upload" + fileName;
log.info("url ---> {}", url);
return Result.success(url);
} catch (IllegalStateException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
return Result.success(null);
}
}
这涉及到一些静态资源的加载问题,所以我们需要在 mvc 配置中添加这个静态资源的位置
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
Constant constant;
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/upload/avatar/**")
.addResourceLocations("file:///" + constant.getUploadDir() + "/avatar/");
}
上面的 WebMvcConfig 重写了 addResourceHandlers 方法,把 upload 的文件夹读取进去了,然后 Constant 是个加载的常量,我提取到了配置文件中。
@Data
@Component
public class Consts {
@Value("${file.upload.dir}")
private String uploadDir;
public static final Long IM_DEFAULT_USER_ID = 999L;
public final static Long IM_GROUP_ID = 999L;
public final static String IM_GROUP_NAME = "e-group-study";
//消息类型
public final static String IM_MESS_TYPE_PING = "pingMessage";
public final static String IM_MESS_TYPE_CHAT = "chatMessage";
public static final String IM_ONLINE_MEMBERS_KEY = "online_members_key";
public static final String IM_GROUP_HISTROY_MSG_KEY = "group_histroy_msg_key";
}
配置文件
- application.yml
file:
upload:
dir: ${user.dir}/upload
url: http://localhost:8080/upload
7.3.3 修改密码
密码修改也只是一个简单的 form 表单提交
/*更新密码*/
@ResponseBody
@PostMapping("/user/repass")
public Result repass(String nowpass, String pass, String repass) {
if(!pass.equals(repass)) {
return Result.fail("两次密码不相同");
}
User user = userService.getById(getProfileId());
String nowPassMd5 = SecureUtil.md5(nowpass);
if(!nowPassMd5.equals(user.getPassword())) {
return Result.fail("密码不正确");
}
user.setPassword(SecureUtil.md5(pass));
userService.updateById(user);
return Result.success().action("/user/set");
}
7.4 我的消息
我的消息包括两种,一个是系统消息,一个是别人评论了我的文章,或者收藏了我的文章等类型。
所以实体类 UserMessage
的 type 设置了 2 中类型:
-
type 消息类型,1 评论消息,2 系统消息
-
/** * 消息类型 0系统消息 1评论文章 2评论评论 */ private Integer type;
Controller
类实现如下:
/*我的消息*/
@GetMapping("/user/mess")
public String mess() {
IPage<UserMessageVo> page = userMessageService.paging(getPage(), new QueryWrapper<UserMessage>()
.eq("to_user_id", getProfileId())
.orderByDesc("created")
);
// 把消息改成已读状态
List<Long> ids = new ArrayList<>();
for(UserMessageVo messageVo : page.getRecords()) {
if(messageVo.getStatus() == 0) {
ids.add(messageVo.getId());
}
}
// 批量修改成已读
userMessageService.updateToReaded(ids);
request.setAttribute("pageData", page);
return "/user/message";
}
难点除了分页之外,便是 sql
语句的书写:
<select id="selectMessage" resultType="com.lyj.eblog.Vo.UserMessageVo">
SELECT
m.*, (
SELECT
username
FROM
`m_user`
WHERE
id = m.from_user_id
) AS fromUserName,
(
SELECT
title
FROM
`m_post`
WHERE
id = m.post_id
) AS postTitle
FROM
`m_user_message` m
${ew.customSqlSegment}
</select>
删除消息功能:
@ResponseBody
@PostMapping("/message/remove")
public Result messageRemove(Long id,
@RequestParam(defaultValue = "false") Boolean all) {
boolean remove = userMessageService.remove(new QueryWrapper<UserMessage>()
.eq("to_user_id", getProfileId())
.eq(!all, "id", id));
return remove ? Result.success("删除成功") : Result.fail("删除失败");
}
8. 博客处理
8.1 新建或者编辑博客
发布博客分为新发布和编辑发布,一般来说我们根据是否有传博客 id 过来判断,如果有 id,那么我们就查询出来,然后回显数据提交更新,因为页面都是一样的,所以,新发布、编辑我们用了一个方法。
@GetMapping("/post/edit")
public String edit() {
String id = request.getParameter("id");
if (StrUtil.isNotEmpty(id)) {
Post post = postService.getById(id);
Assert.isTrue(post != null, "该帖子已被删除");
Assert.isTrue(post.getUserId().longValue() == getProfileId().longValue(), "没有权限操作此文章");
request.setAttribute("post", post);
}
request.setAttribute("categories", categoryService.list());
return "/post/edit";
}
延伸:断言与异常
这里有个细节,当我们是新发布文章时候,那么我们传过去的
post
就是个new Post()
,属性都是空的,所以我们在页面中使用${post.title}
时候freemaker
会报错,这时候我们需要解决这个问题,让他不报错,解决方法很简单,我们只需要在配置文件中配置好freemaker
在这种情况下不报错就行了spring: freemarker: cache: false settings: classic_compatible: true
修改完成后提交:
@ResponseBody
@PostMapping("/post/submit")
public Result submit(Post post) {
ValidationUtil.ValidResult validResult = ValidationUtil.validateBean(post);
if (validResult.hasErrors()) {
return Result.fail(validResult.getErrors());
}
if (post.getId() == null) {
post.setUserId(getProfileId());
post.setModified(new Date());
post.setCreated(new Date());
post.setCommentCount(0);
post.setEditMode(null);
post.setLevel(0);
post.setRecommend(false);
post.setViewCount(0);
post.setVoteDown(0);
post.setVoteUp(0);
postService.save(post);
} else {
Post tempPost = postService.getById(post.getId());
Assert.isTrue(tempPost.getUserId().longValue() == getProfileId().longValue(), "无权限编辑此文章!");
tempPost.setTitle(post.getTitle());
tempPost.setContent(post.getContent());
tempPost.setCategoryId(post.getCategoryId());
postService.updateById(tempPost);
}
// 通知消息给mq,告知更新或添加
amqpTemplate.convertAndSend(RabbitConfig.es_exchage, RabbitConfig.es_bind_key,
new PostMqIndexMessage(post.getId(), PostMqIndexMessage.CREATE_OR_UPDATE));
return Result.success().action("/post/" + post.getId());
}
8.2 删除博客
删除博客之前需要做一些简单校验:
- 帖子是否存在
- 是否是自己的帖子
- 删除与帖子相关的消息或收藏等
@ResponseBody
@Transactional
@PostMapping("/post/delete")
public Result delete(Long id) {
Post post = postService.getById(id);
Assert.notNull(post, "该帖子已被删除");
Assert.isTrue(post.getUserId().longValue() == getProfileId().longValue(), "无权限删除此文章!");
postService.removeById(id);
// 删除相关消息、收藏等
userMessageService.removeByMap(MapUtil.of("post_id", id));
userCollectionService.removeByMap(MapUtil.of("post_id", id));
/* amqpTemplate.convertAndSend(RabbitConfig.es_exchage, RabbitConfig.es_bind_key,
new PostMqIndexMessage(post.getId(), PostMqIndexMessage.REMOVE));*/
return Result.success().action("/user/index");
}
8.3 收藏博客、取消收藏
-
收藏
-
/*收藏文章*/ @ResponseBody @PostMapping("/collection/add/") public Result collectionAdd(Long pid) { Post post = postService.getById(pid); Assert.isTrue(post != null, "改帖子已被删除"); long count = userCollectionService.count(new QueryWrapper<UserCollection>() .eq("user_id", getProfileId()) .eq("post_id", pid) ); if (count > 0) { return Result.fail("你已经收藏"); } UserCollection collection = new UserCollection(); collection.setUserId(getProfileId()); collection.setPostId(pid); collection.setCreated(new Date()); collection.setModified(new Date()); collection.setPostUserId(post.getUserId()); userCollectionService.save(collection); return Result.success(); }
-
-
取消收藏
-
/*取消收藏文章*/ @ResponseBody @PostMapping("/collection/remove/") public Result collectionRemove(Long pid) { /*判断文章存不存在*/ Post post = postService.getById(pid); Assert.isTrue(post != null, "该帖子已被删除"); userCollectionService.remove(new QueryWrapper<UserCollection>() .eq("user_id", getProfileId()) .eq("post_id", pid)); return Result.success(); }
-
8.4 发表评论
发表评论需要涉及到的东西:
- 判断文章是否存在
- 文章的评论数量加一
- 侧边栏的本周热议重新排行
- 通知文章作者有人评论了
- 通知 @的用户有人回复了你的评论
/*回复评论*/
@ResponseBody
@Transactional
@PostMapping("/post/reply/")
public Result reply(Long jid, String content) {
Assert.notNull(jid, "找不到对应的文章");
Assert.hasLength(content, "评论内容不能为空");
Post post = postService.getById(jid);
Assert.isTrue(post != null, "该文章已被删除");
Comment comment = new Comment();
comment.setPostId(jid);
comment.setContent(content);
comment.setUserId(getProfileId());
comment.setCreated(new Date());
comment.setModified(new Date());
comment.setLevel(0);
comment.setVoteDown(0);
comment.setVoteUp(0);
commentService.save(comment);
// 评论数量加一
post.setCommentCount(post.getCommentCount() + 1);
postService.updateById(post);
// 本周热议数量加一
postService.incrCommentCountAndUnionForWeekRank(post.getId(), true);
// 通知作者,有人评论了你的文章
// 作者自己评论自己文章,不需要通知
if (comment.getUserId() != post.getUserId()) {
UserMessage message = new UserMessage();
message.setPostId(jid);
message.setCommentId(comment.getId());
message.setFromUserId(getProfileId());
message.setToUserId(post.getUserId());
message.setType(1);
message.setContent(content);
message.setCreated(new Date());
message.setStatus(0);
userMessageService.save(message);
// 即时通知作者(websocket)
wsService.sendMessCountToUser(message.getToUserId());
}
// 通知被@的人,有人回复了你的文章
if (content.startsWith("@")) {
String username = content.substring(1, content.indexOf(" "));
System.out.println(username);
User user = userService.getOne(new QueryWrapper<User>().eq("username", username));
if (user != null) {
UserMessage message = new UserMessage();
message.setPostId(jid);
message.setCommentId(comment.getId());
message.setFromUserId(getProfileId());
message.setToUserId(user.getId());
message.setType(2);
message.setContent(content);
message.setCreated(new Date());
message.setStatus(0);
userMessageService.save(message);
// 即时通知被@的用户
}
}
return Result.success().action("/post/" + post.getId());
}
8.5 删除评论
评论的删除也是差不多的逻辑
- 判断对应的评论是否存在
- 评论删除
- 评论数量减一
- 本周热议重新排行
@ResponseBody
@Transactional
@PostMapping("/post/jieda-delete/")
public Result reply(Long id) {
Assert.notNull(id, "评论id不能为空!");
Comment comment = commentService.getById(id);
Assert.notNull(comment, "找不到对应评论!");
if(comment.getUserId() != getProfileId()) {
return Result.fail("不是你发表的评论!");
}
commentService.removeById(id);
// 评论数量减一
Post post = postService.getById(comment.getPostId());
post.setCommentCount(post.getCommentCount() - 1);
postService.saveOrUpdate(post);
//评论数量减一
postService.incrZsetValueAndUnionForLastWeekRank(comment.getPostId(), false);
return Result.succ(null);
}
9. 消息实时通信
至此,新消息通知已经 ok,接下来我们搞一个高大上一点的功能。我们刷微博简书头条等网站的时候,如果收到消息通知,一般来说不用我们刷新页面,而是实时给我们展示有消息来了,会突然有个新消息通知的图标提示我们,这是怎么做到的呢,结合我们之前学过的知识。我们可以找到几种方案来实现这个功能:
- ajax 定时加载刷新
- websocket 双工通讯
- 长链接
此处我们选择使用 websocket 双工通讯 的方式。
-
导入依赖
<!--websocket--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency>
-
编写配置
@EnableAsync @Configuration @EnableWebSocketMessageBroker // 表示开启使用STOMP协议来传输基于代理的消息 public class WsConfig implements WebSocketMessageBrokerConfigurer { @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/websocket") // 注册一个端点,websocket的访问地址 .withSockJS(); // } @Override public void configureMessageBroker(MessageBrokerRegistry registry) { registry.enableSimpleBroker("/user/", "/topic/"); //推送消息前缀 registry.setApplicationDestinationPrefixes("/app"); } }
-
发送消息数量给前端
/*userId,就是限定要给谁发送消息; count 是消息数量,这里我们考虑多种情况 count 不为空时候,我们返回 count 数量; 当 count 为空时候,我们搜索 userId 所有未读的消息数量然后返回。 */ @Service public class WsServiceImpl implements WsService { @Autowired IUserMessageService messageService; @Autowired SimpMessagingTemplate messagingTemplate; @Async @Override public void sendMessCountToUser(Long toUserId) { long count = messageService.count(new QueryWrapper<UserMessage>() .eq("to_user_id", toUserId) .eq("status", "0") ); // websocket通知 (/user/20/messCount) messagingTemplate.convertAndSendToUser(toUserId.toString(), "/messCount", count); } }
-
开启异步通讯
@EnableAsync
@EnableAsync @Configuration public class AsyncConfig { @Bean AsyncTaskExecutor asyncTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(100); executor.setQueueCapacity(25); executor.setMaxPoolSize(500); return executor; } }
所以使用了
@EnableAsync
注解之后我们就可以使用@Aysnc
注解来实现异步了,asyncTaskExecutor()
其实就是我用来重写AsyncTaskExecutor
用的,定义了最大线程组等信息。另外Async
其实还可以配置很多信息。
10.文章阅读量
实现方式:
- 是每访问一次我们就直接修改数据库?
- 我们使用缓存在解决这个问题,
- 每次访问,我们就直接缓存的阅读量增一,然后在某一时刻再同步到数据库中即可。
-
Controller
-
@GetMapping("/post/{id:\\d*}") public String detail(@PathVariable(name = "id") Long id) { /*文章*/ PostVo postVo = postService.selectOnePost(new QueryWrapper<Post>() .eq("p.id", id)); Assert.notNull(postVo, "文章已被删除"); /** * 阅读量加1 * */ postService.putViewCount(postVo); /*评论 * * 参数:1分页 2文章id 3用户id 4排序 * */ IPage<CommentVo> commentResults = commentService.paging(getPage(), postVo.getId(), null, "created"); request.setAttribute("currentCategoryId", postVo.getCategoryId()); request.setAttribute("post", postVo); request.setAttribute("pageData", commentResults); return "post/detail"; }
-
-
Service
-
/** * 阅读量加1 * @param postVo */ @Override public void putViewCount(PostVo postVo) { String key = "rank:post:" + postVo.getId(); /*1 从缓存中获取viewCount(阅读量)*/ Integer viewCount = (Integer) redisUtil.hget(key, "post:viewCount"); /*2 如果没有 就从数据库中获取 再加1*/ if (viewCount !=null) { postVo.setViewCount(viewCount + 1); } else { postVo.setViewCount(postVo.getViewCount() + 1); } /*3 同步到缓存中*/ redisUtil.hset(key, "post:viewCount", postVo.getViewCount()); }
-
-
设置定时器,然后定时将缓存中的阅读量同步到数据库中,实现数据同步。
-
@Slf4j @Component public class ScheduledTasks { @Autowired RedisUtil redisUtil; @Autowired private RedisTemplate redisTemplate; @Autowired PostService postService; /** * 阅读数量同步任务 * 每天2点同步 */ // @Scheduled(cron = "0 0 2 * * ?") @Scheduled(cron = "0 0/1 * * * *")//一分钟(测试用) public void postViewCountSync() { Set<String> keys = redisTemplate.keys("rank_post_*"); List<String> ids = new ArrayList<>(); for (String key : keys) { String postId = key.substring("rank_post_".length()); if(redisUtil.hHasKey("rank_post_" + postId, "post:viewCount")){ ids.add(postId); } } if(ids.isEmpty()) return; List<Post> posts = postService.list(new QueryWrapper<Post>().in("id", ids)); Iterator<Post> it = posts.iterator(); List<String> syncKeys = new ArrayList<>(); while (it.hasNext()) { Post post = it.next(); Object count =redisUtil.hget("rank_post_" + post.getId(), "post:viewCount"); if(count != null) { post.setViewCount(Integer.valueOf(count.toString())); syncKeys.add("rank_post_" + post.getId()); } else { //不需要同步的 } } if(posts.isEmpty()) return; boolean isSuccess = postService.updateBatchById(posts); if(isSuccess) { for(Post post : posts) { // 删除缓存中的阅读数量,防止重复同步(根据实际情况来) redisUtil.hdel("rank_post_" + post.getId(), "post:viewCount"); } } log.info("同步文章阅读成功 ------> {}", syncKeys); } }
-
11.搜索功能-ElasticSearch
要实现的功能:
- 搜索功能
- es 数据初始化
- es 与数据库的异步同步功能
集成 elasticsearch 的方式有很多,
- 比较原生的 TransportClient client
- spring 提供的 ElasticsearchTemplate
- spring jpa 提供的 ElasticsearchRepository
其中使用 ElasticsearchRepository 应该是开发量最小的一种方式,使用 template 或者 TransportClient client 方式可能会更灵活。
引入依赖
<!-- es -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
<version>2.1.1.RELEASE</version>
</dependency>
<!--整合rabbitmq-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<dependency>
<groupId>org.modelmapper</groupId>
<artifactId>modelmapper</artifactId>
<version>1.1.0</version>
</dependency>
我们已经决定了选用
ElasticsearchRepository
方式来访问我们的elasticsearch
,所以按照这个思路,我们需要准备一个model
、一个repository
,这是访问存储介质 es 的基础,新建 repository 很简单,因为是 spring data jpa,所以直接继承ElasticsearchRepository
就可以了:
@Repository
public interface PostRepository extends ElasticsearchRepository<PostDocument, Long> {
}
model
的内容如下:
@Data
@Document(indexName = "post", createIndex = true)
public class PostDocument implements Serializable {
@Id
private Long id;
// ik分词器
@Field(type = FieldType.Text, searchAnalyzer="ik_smart", analyzer = "ik_max_word")
private String title;
@Field(type = FieldType.Long)
private Long authorId;
@Field(type = FieldType.Keyword)
private String authorName;
private String authorAvatar;
private Long categoryId;
@Field(type = FieldType.Keyword)
private String categoryName;
private Integer level;
private Boolean recomment;
private Integer commentCount;
private Integer viewCount;
@Field(type = FieldType.Date)
private Date created;
}
搜索功能
@Controller
public class IndexController extends BaseController {
@RequestMapping("/search")
public String search(String q) {
IPage pageDate = searchService.search(getPage(), q);
request.setAttribute("q", q);
request.setAttribute("pageData", pageDate);
return "search";
}
}
@Slf4j
@Service
public class SearchServiceImpl implements SearchService {
@Autowired
PostRepository postRepository;
@Autowired
ModelMapper modelMapper;
@Autowired
IPostService postService;
@Override
public IPage search(Page page, String keyWord) {
// 分页信息 mybatis plus的page 转成 jpa的page
Long current = page.getCurrent() - 1;
Long size = page.getSize();
Pageable pageable = PageRequest.of(current.intValue(), size.intValue());
// 搜索es得到pageData
// MultiMatchQueryBuilder multiMatchQueryBuilder = QueryBuilders.multiMatchQuery(keyWord,
// "title", "authorName", "categoryName");
String[] strings = new String[]{"title", "authorName", "categoryName"};
org.springframework.data.domain.Page<PostDocument> documents =
// postRepository.search(multiMatchQueryBuilder, pageable);
postRepository.searchSimilar(new PostDocument(), strings, pageable);
// 结果信息 jpa的pageData转成mybatis plus的pageData
IPage pageData = new Page(page.getCurrent(), page.getSize(), documents.getTotalElements());
pageData.setRecords(documents.getContent());
return pageData;
}
@Override
public int initEsData(List<PostVo> records) {
if(records == null || records.isEmpty()) {
return 0;
}
List<PostDocument> documents = new ArrayList<>();
for(PostVo vo : records) {
// 映射转换
PostDocument postDocment = modelMapper.map(vo, PostDocument.class);
documents.add(postDocment);
}
postRepository.saveAll(docments);
return documents.size();
}
}
12. 置顶功能
其实就是赋予这篇文章一个状态。数据库表中存在
level
一个字段,1
表示置顶,0
表示不置顶。查询所有文章的时候按照
level
与 时间即可达成置顶的功能。
@Controller
@RequestMapping("/admin")
public class AdminController extends BaseController {
@ResponseBody
@PostMapping("/jie-set")
public Result jetSet(Long id, Integer rank, String field) {
Post post = postService.getById(id);
Assert.notNull(post, "该帖子已被删除");
if ("delete".equals(field)) {
postService.removeById(id);
return Result.success();
} else if ("status".equals(field)) {
post.setRecommend(rank == 1);
} else if ("stick".equals(field)) {
post.setLevel(rank);
}
postService.updateById(post);
return Result.success();
}
@ResponseBody
@PostMapping("/initEsData")
public Result initEsData() {
int size = 10000;
Page page = new Page();
page.setSize(size);
long total = 0;
for (int i = 1; i < 1000; i++) {
page.setCurrent(i);
IPage<PostVo> paging = postService.paging(page, null, null, null, null, null);
int num = searchService.initEsData(paging.getRecords());
total += num;
// 当一页查不出10000条的时候,说明是最后一页了
if (paging.getRecords().size() < size) {
break;
}
}
return Result.success("ES索引初始化成功,共 " + total + " 条记录!", null);
}
}