云小杰

相对于绝对成功的汲汲渴求,越无杂质的奔赴,越是动人。

要一个黄昏, 满是风, 和正在落下的夕阳。如此, 足够我爱这破碎泥泞的人间。


Download the theme

Eblog

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.项目搭建

image-20220207195608931

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.页面

Snipaste_2022-02-11_09-45-46

根据上图将首页划分为几个区域,前端页面的实现忽略,只关注后端代码。

  • 有些数据是项目启动的时候就需要的

    • ```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; } }

      ```

  • 首页

    • @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;
    }
}

image-20220210105417833

2.2 分页

分页并不是使用的 MybatisPlus 自带的分页功能,而是根据不同场景的需求,自己在 MybatisPlus 的基础上是实现的。

2.2.1 首页的分页

image-20220221215041390

根据观察可知,需要的不仅仅是博客的信息、还有作者的信息。

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 我的主页的分页

image-20220221223505269

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 /**
      • 本周热议 */ @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();  } }
        

      ```

  • 修改前端页面

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 + "---------------------->同步成功");
                  });
              }
          }
      }
          
      

image-20220211174317013

image-20220211174357769

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。

  1. 引入依赖

            <!--图像验证码-->
            <!-- https://mvnrepository.com/artifact/com.github.axet/kaptcha -->
            <dependency>
                <groupId>com.github.axet</groupId>
                <artifactId>kaptcha</artifactId>
                <version>0.0.9</version>
            </dependency>
    
  2. 然后配置一下验证码的图片生成规则:(边框、颜色、字体大小、长、高等)

    /**
     * 图形验证码配置类
     * */
    @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;
        }
    }
    
  3. 提供一个访问的接口用于生成验证码图片

        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);
        }
    
  4. 所以访问这个接口就能得到验证码图片流,页面中:

    <div class="">
        <image id="kapthca" src="/kapthca.jpg"></image>
    </div>
       
    
  5. 那么流是接通前端后端的,到后端还需要验证验证码的正确性,所以生成验证码的时候我们需要把验证码先存到 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");
        }
    
  6. 注册的逻辑

        /*用户注册*/
        @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 各个类型异常 ,这个异常的处理,是全局的,所有类似的异常,都会跑到这个地方处理。

  1. 我们自定义一个异常类 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;
        }
    }
    
  2. 定义全局异常处理,@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

  1. 引入 Redis

    <!--redis-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    
  2. 配置 Redis

    spring:
      redis:
        host: localhost
        port: 6379
    
  3. 为了让我们的存到 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;
        }
    }
    
  4. 使用 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. 用户中心

接下来要实现的是用户登录完成之后可以实现的功能,如下图所示:

image-20220223134353693

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 用户中心

image-20220223141326765

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 基本设置

image-20220223141749992

基本设置这里设计如下操作:

  • 修改个人信息
  • 修改头像
  • 修改密码

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);
    }
}

img

这涉及到一些静态资源的加载问题,所以我们需要在 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 收藏博客、取消收藏

image-20220223150209930

  • 收藏

    •     /*收藏文章*/
          @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 双工通讯 的方式。

  1. 导入依赖

    <!--websocket-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-websocket</artifactId>
    </dependency>
    
  2. 编写配置

    @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");
        }
    }
    
  3. 发送消息数量给前端

       
    /*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);
        }
    }
    
  4. 开启异步通讯@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 其实还可以配置很多信息。

  5. img

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. 置顶功能

image-20220223154156380

其实就是赋予这篇文章一个状态。数据库表中存在 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);
    }
}

最近的文章

三数之和

三数之和题目描述给你一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a,b,c ,使得 a + b + c = 0 ?请你找出所有和为 0 且不重复的三元组。注意:答案中不可以包含重复的三元组。方法:排序+双指针class Solution { public List<List<Integer>> threeSum(int[] nums) { int n = nums.length; Arrays.sort...…

继续阅读
更早的文章

整合SSM

整合SSM 1、MyBatis层 1.1、新建数据库配置文件 db.properties 1.2、新建MyBatis配置文件 MyBatis-Config.xml 1.3、编写实体类 Books 1.4、在 dao 文件夹下新建接口 BookMapper 1.5、新建接口 BookMapper 的配置文件 BookMapper.xml 1.6、在...…

Spring继续阅读