【牛客讨论区】第四章:Redis

1. Redis入门

  • Redis是一款基于键值对的NoSQL数据库,它的值支持多种数据结构: 字符串(strings)、哈希(hashes)、列表(lists)、集合(sets)、有序集合(sorted sets)等。
  • Redis将所有的数据都存放在内存中,所以它的读写性能十分惊人。 同时,Redis还可以将内存中的数据以快照或日志的形式保存到硬盘上,以保证数据的安全性。
  • Redis典型的应用场景包括:缓存、排行榜、计数器、社交网络、消息队列等

https://redis.io
https://github.com/microsoftarchive/redis

安装Redis

双击 “Redis-x64-3.2.100.msi”,将安装目录配到环境变量中,打开 cmd,输入 redis-cli,连接 Redis:

在这里插入图片描述

String 类型的存取

127.0.0.1:6379> set test:count 1
OK
127.0.0.1:6379> get test:count
"1"
127.0.0.1:6379> incr test:count
(integer) 2
127.0.0.1:6379> decr test:count
(integer) 1

哈希类型的存取

127.0.0.1:6379> hset test:user id 1
(integer) 1
127.0.0.1:6379> hset test:user username zhangsan
(integer) 1
127.0.0.1:6379> hget test:user id
"1"
127.0.0.1:6379> hget test:user username
"zhangsan"

list类型的存取

127.0.0.1:6379> lpush test:ids 101 102 103
(integer) 3
127.0.0.1:6379> llen test:ids
(integer) 3
127.0.0.1:6379> lindex test:ids 0
"103"
127.0.0.1:6379> lindex test:ids 2
"101"
127.0.0.1:6379> lrange test:ids 0 2
1) "103"
2) "102"
3) "101"
127.0.0.1:6379> rpop test:ids
"101"

集合类型的存取
无序
scard 统计集合内的元素数
spop 随机弹出一个元素
smembers 查看集合剩余元素

127.0.0.1:6379> sadd test:teachers aaa bbb ccc ddd eee
(integer) 5
127.0.0.1:6379> scard test:teachers
(integer) 5
127.0.0.1:6379> spop test:teachers
"eee"
127.0.0.1:6379> smembers test:teachers
1) "aaa"
2) "ddd"
3) "bbb"
4) "ccc"

有序
zscore 查看某个元素的分数
zrank 查看某个元素的排名

127.0.0.1:6379> zadd test:students 10 aaa 20 bbb 30 ccc 40 ddd 50 eee
(integer) 5
127.0.0.1:6379> zcard test:students
(integer) 5
127.0.0.1:6379> zscore test:students ccc
"30"
127.0.0.1:6379> zrank test:students ccc
(integer) 2
127.0.0.1:6379> zrange test:students 0 2
1) "aaa"
2) "bbb"
3) "ccc"

全局命令
对所有的数据类型都生效

查看库中所有的 key

127.0.0.1:6379> keys *
1) "test:ids"
2) "test:user"
3) "test:students"
4) "test:teachers"
5) "test:count"

所有以 test 开头的 key

127.0.0.1:6379> keys test*
1) "test:ids"
2) "test:user"
3) "test:students"
4) "test:teachers"
5) "test:count"

查看某个 key 的类型

127.0.0.1:6379> type test:user
hash

查看某个 key 是否存在,1表示存在

127.0.0.1:6379> exists test:user
(integer) 1

删掉某个 key

127.0.0.1:6379> del test:user
(integer) 1
127.0.0.1:6379> exists test:user
(integer) 0

设置某个 key 的过期时间 (秒)

127.0.0.1:6379> expire test:stundets 10
(integer) 0

10秒之后查看 test:stundets,不存在了

127.0.0.1:6379> exists test:stundets
(integer) 0

2. SpringBoot整合Redis

pom.xml
版本已经在父 pom 中指定了,所以可以不写

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

application.properties

# redis
spring.redis.database=11
spring.redis.host=localhost
spring.redis.port=6379

配置类

package com.nowcoder.community.Config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.RedisSerializer;

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);
        // 设置key的序列化方式
        template.setKeySerializer(RedisSerializer.string());
        // 设置value的序列化方式
        template.setValueSerializer(RedisSerializer.json());
        // 设置hash的key的序列化方式
        template.setHashKeySerializer(RedisSerializer.string());
        // 设置hash的value的序列化方式
        template.setHashValueSerializer(RedisSerializer.json());

        template.afterPropertiesSet();
        return template;
    }
}

测试一下
创新的测试类

package com.nowcoder.community;

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.core.BoundValueOperations;
import org.springframework.data.redis.core.RedisOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.SessionCallback;

import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;

@SpringBootTest
public class RedisTests {

    @Resource
    private RedisTemplate redisTemplate;

    @Test
    public void testStrings() {
        String redisKey = "test:count";
        redisTemplate.opsForValue().set(redisKey, 1);

        System.out.println(redisTemplate.opsForValue().get(redisKey));//取值
        System.out.println(redisTemplate.opsForValue().increment(redisKey));//增加
        System.out.println(redisTemplate.opsForValue().decrement(redisKey));//减少
    }

    @Test
    public void testHashes() {
        String redisKey = "test:user";
        redisTemplate.opsForHash().put(redisKey, "id", 1);
        redisTemplate.opsForHash().put(redisKey, "username", "张三");
        //取值
        System.out.println(redisTemplate.opsForHash().get(redisKey, "id"));
        System.out.println(redisTemplate.opsForHash().get(redisKey, "username"));
    }

    @Test
    public void testLists() {
        String redisKey = "test:ids";
        redisTemplate.opsForList().leftPush(redisKey, 101);
        redisTemplate.opsForList().leftPush(redisKey, 102);
        redisTemplate.opsForList().leftPush(redisKey, 103);
        //取值
        System.out.println(redisTemplate.opsForList().size(redisKey));
        System.out.println(redisTemplate.opsForList().index(redisKey, 0));//左边下标0的数据
        System.out.println(redisTemplate.opsForList().range(redisKey, 0, 2));//左边[0,2]的数据

        System.out.println(redisTemplate.opsForList().leftPop(redisKey));//从左边弹出
        System.out.println(redisTemplate.opsForList().leftPop(redisKey));
        System.out.println(redisTemplate.opsForList().leftPop(redisKey));
    }

    @Test
    public void testSets() {
        String redisKey = "test:teachers";
        redisTemplate.opsForSet().add(redisKey, "刘备", "关羽", "张飞");
        //取值
        System.out.println(redisTemplate.opsForSet().size(redisKey));
        System.out.println(redisTemplate.opsForSet().pop(redisKey));//随机弹出一个
        System.out.println(redisTemplate.opsForSet().members(redisKey));//剩余元素
    }

    @Test
    public void testSortedSets() {
        String redisKey = "test:students";
        redisTemplate.opsForZSet().add(redisKey, "唐僧", 80);//80是唐僧的分数
        redisTemplate.opsForZSet().add(redisKey, "悟空", 90);
        redisTemplate.opsForZSet().add(redisKey, "沙僧", 50);
        redisTemplate.opsForZSet().add(redisKey, "八戒", 60);

        System.out.println(redisTemplate.opsForZSet().zCard(redisKey));//统计元素个数
        System.out.println(redisTemplate.opsForZSet().score(redisKey, "八戒"));//统计某个元素的分数
        //统计某个元素的排名,默认由小到大
        System.out.println(redisTemplate.opsForZSet().rank(redisKey, "八戒"));
        //由大到小
        System.out.println(redisTemplate.opsForZSet().reverseRank(redisKey, "八戒"));
        //最小的前三名(默认由小到大排)
        System.out.println(redisTemplate.opsForZSet().range(redisKey, 0, 2));
        //最大的前三名
        System.out.println(redisTemplate.opsForZSet().reverseRange(redisKey, 0, 2));

    }

    @Test
    public void testKeys() { //测试全局命令
        redisTemplate.delete("test:user");
        System.out.println(redisTemplate.hasKey("test:user"));

        redisTemplate.expire("test:students", 10, TimeUnit.SECONDS);
    }

    //多次访问同一个key,使用绑定,简化代码
    @Test
    public void testBoundOperations() {
        String redisKey = "test:count";
        BoundValueOperations operations = redisTemplate.boundValueOps(redisKey);
        operations.increment();
        operations.increment();
        operations.increment();
        System.out.println(operations.get());
    }

    //编程式事务
    @Test
    public void testTransactional() {
        Object obj = redisTemplate.execute(new SessionCallback() {
            @Override
            public Object execute(RedisOperations operations) throws DataAccessException {
                String redisKey = "test:tx";

                operations.multi();//启动事务

                operations.opsForSet().add(redisKey, "张三");
                operations.opsForSet().add(redisKey, "李四");
                operations.opsForSet().add(redisKey, "王五");

				//没有数据,因为此时还未执行add
                System.out.println(operations.opsForSet().members(redisKey));

                return operations.exec();//提交事务
            }
        });
        System.out.println(obj);
    }
}

3. 点赞

  • 点赞
    • 支持对帖子、评论点赞。
    • 第1次点赞,第2次取消点赞。
  • 首页点赞数量
    • 统计帖子的点赞数量。
  • 详情页点赞数量
    • 统计点赞数量。
    • 显示点赞状态。

因为点赞是频率非常高的操作,所以把点赞数据存到 Redis 中提高性能

写个工具类

package com.nowcoder.community.util;

public class RedisKeyUtil {

    public static final String SPLIT = ":";
    public static final String PREFIX_ENTITY_LIKE = "like:entity";

    //某个实体(帖子、评论)的赞
    //like:entity:entityType:entityId-->set(userId)
    public static String getEntityLikeKey(int entityType, int entityId) {
        return PREFIX_ENTITY_LIKE + SPLIT + entityType + SPLIT + entityId;
    }

}

service

package com.nowcoder.community.service;

import com.nowcoder.community.util.RedisKeyUtil;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;

@Service
public class LikeService {

    @Resource
    private RedisTemplate redisTemplate;

    // 点赞
    public void like(int userId, int entityType, int entityId) {
        String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId);
        Boolean isMember = redisTemplate.opsForSet().isMember(entityLikeKey, userId);
        if (isMember) { //点过赞了,则取消
            redisTemplate.opsForSet().remove(entityLikeKey, userId);
        } else {
            redisTemplate.opsForSet().add(entityLikeKey, userId);
        }
    }

    //查询某实体赞的数量
    public long findEntityLikeCount(int entityType, int entityId) {
        String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId);
        return redisTemplate.opsForSet().size(entityLikeKey);
    }

    // 查询某人对某实体的点赞状态
    public int findEntityLikeStatus(int userId, int entityType, int entityId) {
        String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId);
        return redisTemplate.opsForSet().isMember(entityLikeKey, userId) ? 1 : 0;
    }
}

controller

package com.nowcoder.community.controller;

import com.nowcoder.community.entity.User;
import com.nowcoder.community.service.LikeService;
import com.nowcoder.community.util.CommunityUtil;
import com.nowcoder.community.util.HostHolder;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import javax.annotation.Resource;
import java.util.HashMap;
import java.util.Map;

@Controller
public class LikeController {

    @Resource
    private LikeService likeService;

    @Resource
    private HostHolder hostHolder;

    @PostMapping("/like")
    @ResponseBody
    public String like(int entityType, int entityId) {
        User user = hostHolder.getUser();
        // 点赞
        likeService.like(user.getId(), entityType, entityId);
        // 数量
        long likeCount = likeService.findEntityLikeCount(entityType, entityId);
        // 状态
        int likeStatus = likeService.findEntityLikeStatus(user.getId(), entityType, entityId);
        // 返回的结果
        Map<String, Object> map = new HashMap<>();
        map.put("likeCount", likeCount);
        map.put("likeStatus", likeStatus);
        return CommunityUtil.getJSONString(0, null, map);
    }
}

discuss-detail.html
86行

<li class="d-inline ml-2">
	<a href="javascript:;" th:onclick="|like(this, 1, ${post.id});|" class="text-primary">
		<b></b> <i>11</i>
	</a>
</li>

138行

<li class="d-inline ml-2">
	<a href="javascript:;" th:onclick="|like(this, 2, ${cvo.comment.id})|" class="text-primary">
		<b></b>(<i>1</i>)
	</a>
</li>

164行

<li class="d-inline ml-2">
	<a href="javascript:;" th:onclick="|like(this, 2, ${rvo.reply.id})|" class="text-primary">
		<b></b>(<i>1</i>)
	</a>
</li>

在最后面加一个 script 标签

<script th:src="@{/js/discuss.js}"></script>

resources / static / js 下新建 discuss.js

function like(btn, entityType, entityId) {
    $.post(
        CONTEXT_PATH + "/like",
        {"entityType":entityType, "entityId":entityId},
        function (data) {
            data = $.parseJSON(data);
            if (data.code == 0) {
                $(btn).children("i").text(data.likeCount);
                $(btn).children("b").text(data.likeStatus==1?'已赞':'赞');
            } else {
                alert(data.msg);
            }
        }
    );
}

启动,测试:登陆之后找个帖子点赞试试。再点一下赞,取消了,然后对评论以及评论的回复点赞试试

在这里插入图片描述


修改初始时赞的数量不对的问题:

1.处理首页

完善 HomeController 的 getIndexPage() 方法

public class HomeController implements CommunityConstant { //为了使用常量

	@Resource
	private LikeService likeService;

	@GetMapping("/index")
    public String getIndexPage(Model model, Page page) {
        //方法调用前, SpringMVC会自动实例化Model和Page,并将Page注入Model.
        // 所以,在thymeleaf中可以直接访问Page对象中的数据.
        page.setRows(discussPostService.findDiscussPostRows(0));
        page.setPath("/index");

        //这个list里的帖子含有外键userId,我们需要查到userName拼接到帖子上
        List<DiscussPost> list = discussPostService.findDiscussPosts(0, page.getOffset(), page.getLimit());
        List<Map<String, Object>> discussPosts = new ArrayList<>();
        if (list != null) {
            for (DiscussPost post : list) {
                Map<String, Object> map = new HashMap<>();
                map.put("post", post);
                User user = userService.findUserById(post.getUserId());
                map.put("user", user);

				//加了这两句
                long likeCount = likeService.findEntityLikeCount(ENTITY_TYPE_POST, post.getId());
                map.put("likeCount", likeCount);

                discussPosts.add(map);
            }
        }
        model.addAttribute("discussPosts", discussPosts);
        return "/index";
    }

index.html
134行

<li class="d-inline ml-2"><span th:text="${map.likeCount}">11</span></li>

2.处理帖子详情页

DiscussPostController 的 getDiscussPost() 方法

	@Resource
	private LikeService likeService;

	@GetMapping("/detail/{discussPostId}")
    public String getDiscussPost(@PathVariable("discussPostId") int discussPostId, Model model, Page page) {
        //帖子
        DiscussPost post = discussPostService.findDiscussPostById(discussPostId);
        model.addAttribute("post", post);
        //作者
        User user = userService.findUserById(post.getUserId());
        model.addAttribute("user", user);

        // 点赞数量
        long likeCount = likeService.findEntityLikeCount(ENTITY_TYPE_POST, discussPostId);
        model.addAttribute("likeCount", likeCount);
        // 点赞状态
        int likeStatus = hostHolder.getUser() == null ? 0 :
                likeService.findEntityLikeStatus(hostHolder.getUser().getId(), ENTITY_TYPE_POST, discussPostId);
        model.addAttribute("likeStatus", likeStatus);

        //评论的分页信息
        page.setLimit(5);
        page.setPath("/discuss/detail/" + discussPostId);
        page.setRows(post.getCommentCount());

        //评论:给帖子的评论
        //回复:给评论的评论
        //评论列表
        List<Comment> commentList = commentService.findCommentsByEntity(
                ENTITY_TYPE_POST, post.getId(), page.getOffset(), page.getLimit());
        //评论VO列表
        List<Map<String, Object>> commentVoList = new ArrayList<>();
        if (commentList != null) {
            for (Comment comment : commentList) {
                //评论VO
                Map<String, Object> commentVo = new HashMap<>();
                //往VO中添加评论
                commentVo.put("comment", comment);
                //添加作者
                commentVo.put("user", userService.findUserById(comment.getUserId()));

                // 点赞数量
                likeCount = likeService.findEntityLikeCount(ENTITY_TYPE_COMMENT, comment.getId());
                commentVo.put("likeCount", likeCount);
                // 点赞状态
                likeStatus = hostHolder.getUser() == null ? 0 :
                        likeService.findEntityLikeStatus(hostHolder.getUser().getId(), ENTITY_TYPE_COMMENT, comment.getId());
                commentVo.put("likeStatus", likeStatus);

                //查询回复列表
                List<Comment> replyList = commentService.findCommentsByEntity(
                        ENTITY_TYPE_COMMENT, comment.getId(), 0, Integer.MAX_VALUE);
                //回复VO列表
                List<Map<String, Object>> replyVoList = new ArrayList<>();
                if (replyList != null) {
                    for (Comment reply : replyList) {
                        Map<String, Object> replyVo = new HashMap<>();
                        //回复
                        replyVo.put("reply", reply);
                        //作者
                        replyVo.put("user", userService.findUserById(reply.getUserId()));
                        //回复的目标
                        User target = reply.getTargetId() == 0 ? null : userService.findUserById(reply.getTargetId());
                        replyVo.put("target", target);

                        // 点赞数量
                        likeCount = likeService.findEntityLikeCount(ENTITY_TYPE_COMMENT, reply.getId());
                        replyVo.put("likeCount", likeCount);
                        // 点赞状态
                        likeStatus = hostHolder.getUser() == null ? 0 :
                                likeService.findEntityLikeStatus(hostHolder.getUser().getId(), ENTITY_TYPE_COMMENT, reply.getId());
                        replyVo.put("likeStatus", likeStatus);

                        replyVoList.add(replyVo);
                    }
                }
                commentVo.put("replys", replyVoList);
                //回复的数量
                int replyCount = commentService.findCommentCount(ENTITY_TYPE_COMMENT, comment.getId());
                commentVo.put("replyCount", replyCount);
                commentVoList.add(commentVo);
            }
        }
        model.addAttribute("comments", commentVoList);
        return "site/discuss-detail";
    }

discuss-detail.html
88行

<b th:text="${likeStatus==1?'已赞':''}"></b> <i th:text="${likeCount}">11</i>

140行

<b th:text="${cvo.likeStatus==1?'已赞':''}"></b>(<i th:text="${cvo.likeCount}">1</i>)

166行

<b th:text="${rvo.likeStatus==1?'已赞':''}"></b>(<i th:text="${rvo.likeCount}">1</i>)

4. 我收到的赞

重构点赞功能

  • 以用户为key,记录点赞数量
  • increment(key),decrement(key)

开发个人主页

  • 以用户为key,查询点赞数量

RedisKeyUtil 增加属性和方法

public static final String PREFIX_USER_LIKE = "like:user";

// 某个用户的赞
// like:user:userId -> int
public static String getUserLikeKey(int userId) {
    return PREFIX_USER_LIKE + SPLIT + userId;
}

LikeService
重构 like() 方法

// 点赞
public void like(int userId, int entityType, int entityId, int entityUserId) {
    redisTemplate.execute(new SessionCallback() {
        @Override
        public Object execute(RedisOperations operations) throws DataAccessException {
            String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId);
            String userLikeKey = RedisKeyUtil.getUserLikeKey(entityUserId);
            //这句查询不能放在事务内部
            boolean isMember = operations.opsForSet().isMember(entityLikeKey, userId);
            //开启事务
            operations.multi();
            if (isMember) {
                operations.opsForSet().remove(entityLikeKey, userId);
                operations.opsForValue().decrement(userLikeKey);
            } else {
                operations.opsForSet().add(entityLikeKey, userId);
                operations.opsForValue().increment(userLikeKey);
            }
            return operations.exec();
        }
    });
}

增加一个方法,统计赞的数量

// 查询某个用户获得的赞的数量
public int findUserLikeCount(int userId) {
    String userLikeKey = RedisKeyUtil.getUserLikeKey(userId);
    Integer count = (Integer) redisTemplate.opsForValue().get(userLikeKey);
    return count == null ? 0 : count.intValue();
}

LikeController
like() 方法 增加一个形参 entityUserId

public String like(int entityType, int entityId, int entityUserId) {
	...
	// 点赞
    likeService.like(user.getId(), entityType, entityId, entityUserId);

discuss-detail.html
78行

<a th:href="@{|/user/profile/${user.id}|}">

87行

<a href="javascript:;" th:onclick="|like(this, 1, ${post.id}, ${post.userId});|" class="text-primary">

139行

<a href="javascript:;" th:onclick="|like(this, 2, ${cvo.comment.id},${cvo.comment.userId})|" class="text-primary">

165行

<a href="javascript:;" th:onclick="|like(this, 2, ${rvo.reply.id}, ${rvo.reply.userId})|" class="text-primary">

discuss.js

function like(btn, entityType, entityId, entityUserId) {
    $.post(
        CONTEXT_PATH + "/like",
        {"entityType":entityType, "entityId":entityId, "entityUserId":entityUserId},
        function (data) {
            data = $.parseJSON(data);
            if (data.code == 0) {
                $(btn).children("i").text(data.likeCount);
                $(btn).children("b").text(data.likeStatus==1?'已赞':'赞');
            } else {
                alert(data.msg);
            }
        }
    );
}

UserController

@Resource
private LikeService likeService;

//个人主页
@GetMapping("/profile/{userId}")
public String getProfilePage(@PathVariable("userId") int userId, Model model) {
    User user = userService.findUserById(userId);
    if (user == null) {
        throw new RuntimeException("该用户不存在!");
    }
    // 用户
    model.addAttribute("user", user);
    // 获赞数量
    int likeCount = likeService.findUserLikeCount(userId);
    model.addAttribute("likeCount", likeCount);
    return "/site/profile";
}

index.html
43行

<a class="dropdown-item text-center" th:href="@{|/user/profile/${loginUser.id}|}">个人主页</a>

122行

<a th:href="@{|/user/profile/${map.user.id}|}">

profile.html
2行

<html lang="en" xmlns:th="http://www.thymeleaf.org">

8行

<link rel="stylesheet" th:href="@{/css/global.css}" />

14行

<!-- 头部 -->
<header class="bg-dark sticky-top" th:replace="index::header">

165-166行

<script th:src="@{/js/global.js}"></script>
<script th:src="@{/js/profile.js}"></script>

80行

<img th:src="${user.headerUrl}" class="align-self-start mr-4 rounded-circle" alt="用户头像" style="width:50px;">

83行

<span th:utext="${user.username}">nowcoder</span>

87行

<span>注册于 <i class="text-muted" th:text="${#dates.format(user.createTime, 'yyyy-MM-dd HH:mm:ss')}">2015-06-12 15:20:12</i></span>

92行

<span class="ml-4">获得了 <i class="text-danger" th:text="${likeCount}">87</i> 个赞</span>

启动测试,先删掉之前的点赞数据

C:\Users\15642>redis-cli
127.0.0.1:6379> select 11
OK
127.0.0.1:6379[11]> keys *
1) "like:entity:2:94"
2) "like:entity:1:234"
3) "test:teachers"
4) "test:tx"
5) "test:count"
127.0.0.1:6379[11]> flushdb
OK
127.0.0.1:6379[11]> keys *
(empty list or set)

登陆之后,随便选个人的帖子进行点赞,再给他的评论点赞,然后去他的主页看看收到的赞的数量对不对

5. 关注、取消关注

需求

  • 开发关注、取消关注功能。
  • 统计用户的关注数、粉丝数。

关键

  • 若A关注了B,则A是B的Follower(粉丝),B是A的Followee(目标)。
  • 关注的目标可以是用户、帖子、题目等,在实现时将这些目标抽象为实体。

RedisKeyUtil
增加属性和方法

public static final String PREFIX_FOLLOWEE = "followee";
public static final String PREFIX_FOLLOWER = "follower";

//某个用户关注的实体
//followee:userId:entityType -> zSet(entityId, now)
public static String getFolloweeKey(int userId, int entityType) {
    return PREFIX_FOLLOWEE + SPLIT + userId + SPLIT + entityType;
}

//某个用户拥有的粉丝
//follower:entityType:entityId -> zSet(userId, now)
public static String getFollowerKey(int entityType, int entityId) {
    return PREFIX_FOLLOWER + SPLIT + entityType + SPLIT + entityId;
}

新建 FollowService

package com.nowcoder.community.service;

import com.nowcoder.community.util.RedisKeyUtil;
import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.core.RedisOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.SessionCallback;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;

@Service
public class FollowService {

    @Resource
    private RedisTemplate redisTemplate;
    //关注
    public void follow(int userId, int entityType, int entityId) {
        redisTemplate.execute(new SessionCallback() {
            @Override
            public Object execute(RedisOperations operations) throws DataAccessException {
                String followeeKey = RedisKeyUtil.getFolloweeKey(userId, entityType);
                String followerKey = RedisKeyUtil.getFollowerKey(entityType, entityId);
                operations.multi();
                operations.opsForZSet().add(followeeKey, entityId, System.currentTimeMillis());
                operations.opsForZSet().add(followerKey, userId, System.currentTimeMillis());
                return operations.exec();
            }
        });
    }
    //取消关注
    public void unfollow(int userId, int entityType, int entityId) {
        redisTemplate.execute(new SessionCallback() {
            @Override
            public Object execute(RedisOperations operations) throws DataAccessException {
                String followeeKey = RedisKeyUtil.getFolloweeKey(userId, entityType);
                String followerKey = RedisKeyUtil.getFollowerKey(entityType, entityId);
                operations.multi();
                operations.opsForZSet().remove(followeeKey, entityId);
                operations.opsForZSet().remove(followerKey, userId);
                return operations.exec();
            }
        });
    }
}

新建 FollowController

package com.nowcoder.community.controller;

import com.nowcoder.community.entity.User;
import com.nowcoder.community.service.FollowService;
import com.nowcoder.community.util.CommunityUtil;
import com.nowcoder.community.util.HostHolder;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import javax.annotation.Resource;

@Controller
public class FollowController {

    @Resource
    private FollowService followService;

    @Resource
    private HostHolder hostHolder;

    @PostMapping("/follow")
    @ResponseBody
    public String follow(int entityType, int entityId) {
        User user = hostHolder.getUser();
        followService.follow(user.getId(), entityType, entityId);
        return CommunityUtil.getJSONString(0, "已关注!");
    }

    @PostMapping("/unfollow")
    @ResponseBody
    public String unfollow(int entityType, int entityId) {
        User user = hostHolder.getUser();
        followService.unfollow(user.getId(), entityType, entityId);
        return CommunityUtil.getJSONString(0, "已取消关注!");
    }
}

CommunityConstant

//实体类型:用户
int ENTITY_TYPE_USER = 3;

profile.html
84行 “关注TA” 的上一行加上:

<input type="hidden" id="entityId" th:value="${user.id}">

修改后的 profile.js

$(function(){
	$(".follow-btn").click(follow);
});

function follow() {
	var btn = this;
	if($(btn).hasClass("btn-info")) {
		// 关注TA
		$.post(
			CONTEXT_PATH + "/follow",
			{"entityType":3, "entityId":$(btn).prev().val()},
			function (data) {
				data = $.parseJSON(data);
				if (data.code == 0) {
					window.location.reload();
				} else {
					alert(data.msg);
				}
			}
		);
		// $(btn).text("已关注").removeClass("btn-info").addClass("btn-secondary");
	} else {
		// 取消关注
		$.post(
			CONTEXT_PATH + "/unfollow",
			{"entityType":3, "entityId":$(btn).prev().val()},
			function (data) {
				data = $.parseJSON(data);
				if (data.code == 0) {
					window.location.reload();
				} else {
					alert(data.msg);
				}
			}
		);
		// $(btn).text("关注TA").removeClass("btn-secondary").addClass("btn-info");
	}
}

FollowService
增加方法

// 查询关注的实体的数量
public long findFolloweeCount(int userId, int entityType) {
    String followeeKey = RedisKeyUtil.getFolloweeKey(userId, entityType);
    return redisTemplate.opsForZSet().zCard(followeeKey);
}

// 查询实体的粉丝数量
public long findFollowerCount(int entityType, int entityId) {
    String followerKey = RedisKeyUtil.getFollowerKey(entityType, entityId);
    return redisTemplate.opsForZSet().zCard(followerKey);
}

// 查询当前用户是否已关注该实体
public boolean hasFollowed(int userId, int entityType, int entityId) {
    String followeeKey = RedisKeyUtil.getFolloweeKey(userId, entityType);
    return redisTemplate.opsForZSet().score(followeeKey, entityId) != null;
}

UserController
完善 getProfilePage 方法

public class UserController implements CommunityConstant {

	@Resource
	private FollowService followService;

	//个人主页
    @GetMapping("/profile/{userId}")
    public String getProfilePage(@PathVariable("userId") int userId, Model model) {
        User user = userService.findUserById(userId);
        if (user == null) {
            throw new RuntimeException("该用户不存在!");
        }
        // 用户
        model.addAttribute("user", user);
        // 获赞数量
        int likeCount = likeService.findUserLikeCount(userId);
        model.addAttribute("likeCount", likeCount);

        // 关注数量
        long followeeCount = followService.findFolloweeCount(userId, ENTITY_TYPE_USER);
        model.addAttribute("followeeCount", followeeCount);
        // 粉丝数量
        long followerCount = followService.findFollowerCount(ENTITY_TYPE_USER, userId);
        model.addAttribute("followerCount", followerCount);
        // 是否已关注
        boolean hasFollowed = false;
        if (hostHolder.getUser() != null) {
            hasFollowed = followService.hasFollowed(hostHolder.getUser().getId(), ENTITY_TYPE_USER, userId);
        }
        model.addAttribute("hasFollowed", hasFollowed);
        return "/site/profile";
    }

profile.html
91行

<span>关注了 <a class="text-primary" href="followee.html" th:text="${followeeCount}">5</a></span>

92行

<span class="ml-4">关注者 <a class="text-primary" href="follower.html" th:text="${followerCount}">123</a></span>

85行

<button type="button" class="btn btn-info btn-sm float-right mr-5 follow-btn"
		th:text="${hasFollowed?'已关注':'关注TA'}" th:if="${loginUser!=null&&loginUser.id!=user.id}">关注TA</button>

启动,测试

在这里插入图片描述

6. 关注列表、粉丝列表

业务层

  • 查询某个用户关注的人,支持分页。
  • 查询某个用户的粉丝,支持分页。

表现层

  • 处理“查询关注的人”、“查询粉丝”请求。
  • 编写“查询关注的人”、“查询粉丝”模板。

FollowService
增加方法

public class FollowService implements CommunityConstant {

	@Resource
    private UserService userService;

	// 查询某用户关注的人
    public List<Map<String, Object>> findFollowees(int userId, int offset, int limit) {
        String followeeKey = RedisKeyUtil.getFolloweeKey(userId, ENTITY_TYPE_USER);
        Set<Integer> targetIds = redisTemplate.opsForZSet().reverseRange(followeeKey, offset, offset + limit - 1);
        if (targetIds == null) {
            return null;
        }
        List<Map<String, Object>> list = new ArrayList<>();
        for (Integer targetId : targetIds) {
            Map<String, Object> map = new HashMap<>();
            User user = userService.findUserById(targetId);
            map.put("user", user);
            Double score = redisTemplate.opsForZSet().score(followeeKey, targetId);
            map.put("followTime", new Date(score.longValue()));
            list.add(map);
        }
        return list;
    }
    // 查询某用户的粉丝
    public List<Map<String, Object>> findFollowers(int userId, int offset, int limit) {
        String followerKey = RedisKeyUtil.getFollowerKey(ENTITY_TYPE_USER, userId);
        Set<Integer> targetIds = redisTemplate.opsForZSet().reverseRange(followerKey, offset, offset + limit - 1);
        if (targetIds == null) {
            return null;
        }
        List<Map<String, Object>> list = new ArrayList<>();
        for (Integer targetId : targetIds) {
            Map<String, Object> map = new HashMap<>();
            User user = userService.findUserById(targetId);
            map.put("user", user);
            Double score = redisTemplate.opsForZSet().score(followerKey, targetId);
            map.put("followTime", new Date(score.longValue()));
            list.add(map);
        }
        return list;
    }

FollowController
增加方法

public class FollowController implements CommunityConstant {

	@Resource
	private UserService userService;

	@GetMapping("/followees/{userId}")
    public String getFollowees(@PathVariable("userId") int userId, Page page, Model model) {
        User user = userService.findUserById(userId);
        if (user == null) {
            throw new RuntimeException("该用户不存在!");
        }
        model.addAttribute("user", user);
        page.setLimit(5);
        page.setPath("/followees/" + userId);
        page.setRows((int) followService.findFolloweeCount(userId, ENTITY_TYPE_USER));

        List<Map<String, Object>> userList = followService.findFollowees(userId, page.getOffset(), page.getLimit());
        if (userList != null) {
            for (Map<String, Object> map : userList) {
                User u = (User) map.get("user");
                map.put("hasFollowed", hasFollowed(u.getId()));
            }
        }
        model.addAttribute("users", userList);
        return "/site/followee";
    }

    @GetMapping("/followers/{userId}")
    public String getFollowers(@PathVariable("userId") int userId, Page page, Model model) {
        User user = userService.findUserById(userId);
        if (user == null) {
            throw new RuntimeException("该用户不存在!");
        }
        model.addAttribute("user", user);
        page.setLimit(5);
        page.setPath("/followers/" + userId);
        page.setRows((int) followService.findFollowerCount(ENTITY_TYPE_USER, userId));

        List<Map<String, Object>> userList = followService.findFollowers(userId, page.getOffset(), page.getLimit());
        if (userList != null) {
            for (Map<String, Object> map : userList) {
                User u = (User) map.get("user");
                map.put("hasFollowed", hasFollowed(u.getId()));
            }
        }
        model.addAttribute("users", userList);
        return "/site/follower";
    }

    private boolean hasFollowed(int userId) {
        if (hostHolder.getUser() == null) {
            return false;
        }
        return followService.hasFollowed(hostHolder.getUser().getId(), ENTITY_TYPE_USER, userId);
    }

profile.html
92行

<span>关注了 <a class="text-primary" th:href="@{|/followees/${user.id}|}" th:text="${followeeCount}">5</a></span>

93行

<span class="ml-4">关注者 <a class="text-primary" th:href="@{|/followers/${user.id}|}" th:text="${followerCount}">123</a></span>

followee.html
2行

<html lang="en" xmlns:th="http://www.thymeleaf.org">

8行

<link rel="stylesheet" th:href="@{/css/global.css}" />

14行

<!-- 头部 -->
<header class="bg-dark sticky-top" th:replace="index::header">

233-234行

<script th:src="@{/js/global.js}"></script>
<script th:src="@{/js/profile.js}"></script>

删掉93-148行,只留一个 li 标签即可

64行的 div 标签

<div class="position-relative">
	<!-- 选项 -->
	<ul class="nav nav-tabs mb-3">
		<li class="nav-item">
			<a class="nav-link position-relative active" th:href="@{|/followees/${user.id}|}">
				<i class="text-info" th:utext="${user.username}">Nowcoder</i> 关注的人
			</a>
		</li>
		<li class="nav-item">
			<a class="nav-link position-relative" th:href="@{|/followers/${user.id}|}">
				关注<i class="text-info" th:utext="${user.username}">Nowcoder</i> 的人
			</a>
		</li>
	</ul>
	<a th:href="@{|/user/profile/${user.id}|}" class="text-muted position-absolute rt-0">返回个人主页&gt;</a>
</div>

83行

<li class="media pb-3 pt-3 mb-3 border-bottom position-relative" th:each="map:${users}">

84行 a 标签

<a th:href="@{|/user/profile/${map.user.id}|}">
	<img th:src="${map.user.headerUrl}" class="mr-4 rounded-circle user-header" alt="用户头像" >
</a>

89行

<span class="text-success" th:utext="${map.user.username}">落基山脉下的闲人</span>

90行 span 标签

<span class="float-right text-muted font-size-12">
	关注于 <i th:text="${#dates.format(map.followTime, 'yyyy-MM-dd HH:mm:ss')}">2019-04-28 14:13:25</i>
</span>

101行

<!-- 分页 -->
<nav class="mt-5" th:replace="index::pagination">

95行前面加一行

<input type="hidden" id="entityId" th:value="${map.user.id}">

96行 button 标签

<button type="button" th:class="|btn ${map.hasFollowed?'btn-secondary':'btn-info'} btn-sm float-right follow-btn|"
	th:if="${loginUser!=null&&loginUser.id!=map.user.id}" th:text="${map.hasFollowed?'已关注':'关注TA'}">关注TA
</button>

follower.html 做一模一样的处理!

启动,测试,登录一个账号,随便关注一个人,再登录那个人的账号,查看是否粉丝+1

7. 优化登录模块

使用Redis存储验证码

  • 验证码需要频繁的访问与刷新,对性能要求较高。
  • 验证码不需永久保存,通常在很短的时间后就会失效。
  • 分布式部署时,存在Session共享的问题。

使用Redis存储登录凭证

  • 处理每次请求时,都要查询用户的登录凭证,访问的频率非常高。

使用Redis缓存用户信息

  • 处理每次请求时,都要根据凭证查询用户信息,访问的频率非常高。

7.1 使用Redis存储验证码

RedisKeyUtil
增加属性和方法

public static final String PREFIX_KAPTCHA = "kaptcha";

// 登录验证码的key
public static String getKaptchaKey(String owner) {
    return PREFIX_KAPTCHA + SPLIT + owner;
}

LoginController
重构 getKaptcha() 方法、login() 方法

@Resource
private RedisTemplate redisTemplate;

@GetMapping("/kaptcha")
public void getKaptcha(HttpServletResponse response) {
    //生成验证码
    String text = kaptchaProducer.createText();
    BufferedImage image = kaptchaProducer.createImage(text);

    //验证的归属
    String kaptchaOwner = CommunityUtil.generateUUID();
    Cookie cookie = new Cookie("kaptchaOwner", kaptchaOwner);
    cookie.setMaxAge(60);
    cookie.setPath(contextPath);
    response.addCookie(cookie);

    //将验证码存入 Redis
    String redisKey = RedisKeyUtil.getKaptchaKey(kaptchaOwner);
    redisTemplate.opsForValue().set(redisKey, text, 60, TimeUnit.SECONDS);

    //将图片输出给浏览器
    response.setContentType("image/png");
    try {
        OutputStream os = response.getOutputStream();
        ImageIO.write(image, "png", os);
    } catch (IOException e) {
        logger.error("响应验证码失败:" + e.getMessage());
    }
}

@PostMapping("/login")
public String login(String username, String password, String code,
                    boolean rememberme, Model model, HttpServletResponse response,
                    @CookieValue("kaptchaOwner") String kaptchaOwner) {
    //检查验证码
    String kaptcha = null;
    if (StringUtils.isNotBlank(kaptchaOwner)) {
        String redisKey = RedisKeyUtil.getKaptchaKey(kaptchaOwner);
        kaptcha = (String) redisTemplate.opsForValue().get(redisKey);
    }
    
    //后面的不变
    if (StringUtils.isBlank(kaptcha) || StringUtils.isBlank(code) || !kaptcha.equalsIgnoreCase(code)) {
        model.addAttribute("codeMsg", "验证码不正确!");
        return "/site/login";
    }
    //检查账号、密码
    int expiredSeconds = rememberme ?  REMEMBER_EXPIRED_SECONDS : DEFAULT_EXPIRED_SECONDS;
    Map<String, Object> map = userService.login(username, password, expiredSeconds);
    if (map.containsKey("ticket")) {//只有登陆成功才会存ticket
        Cookie cookie = new Cookie("ticket", map.get("ticket").toString());
        cookie.setPath(contextPath);
        cookie.setMaxAge(expiredSeconds);
        response.addCookie(cookie);
        return "redirect:/index";
    } else {
        model.addAttribute("usernameMsg", map.get("usernameMsg"));
        model.addAttribute("passwordMsg", map.get("passwordMsg"));
        return "/site/login";
    }
}

7.2 使用Redis存储登录凭证

RedisKeyUtil

public static final String PREFIX_TICKET = "ticket";

// 登录凭证
public static String getTicketKey(String ticket) {
    return PREFIX_TICKET + SPLIT + ticket;
}

LoginTicketMapper 可以废弃掉了,在类上加上注解 @Deprecated

UserService

// @Resource
// private LoginTicketMapper loginTicketMapper;

@Resource
private RedisTemplate redisTemplate;

修改 login() 方法的后半段

//生成登录凭证
LoginTicket loginTicket = new LoginTicket();
loginTicket.setUserId(user.getId());
loginTicket.setTicket(CommunityUtil.generateUUID());
loginTicket.setStatus(0);
loginTicket.setExpired(new Date(System.currentTimeMillis() + expiredSeconds * 1000));
// loginTicketMapper.insertLoginTicket(loginTicket);

String redisKey = RedisKeyUtil.getTicketKey(loginTicket.getTicket());
redisTemplate.opsForValue().set(redisKey, loginTicket);

map.put("ticket", loginTicket.getTicket());
return map;

logout() 方法

public void logout(String ticket) {
 // loginTicketMapper.updateStatus(ticket, 1);
    String redisKey = RedisKeyUtil.getTicketKey(ticket);
    LoginTicket loginTicket = (LoginTicket) redisTemplate.opsForValue().get(redisKey);
    loginTicket.setStatus(1);//状态为1,表示删除
    redisTemplate.opsForValue().set(redisKey, loginTicket);
}

findLoginTicket() 方法

public LoginTicket findLoginTicket(String ticket) {
//  return loginTicketMapper.selectByTicket(ticket);
    String redisKey = RedisKeyUtil.getTicketKey(ticket);
    return (LoginTicket) redisTemplate.opsForValue().get(redisKey);
}

7.3 使用Redis缓存用户信息

RedisKeyUtil

public static final String PREFIX_USER = "user";

// 用户
public static String getUserKey(int userId) {
    return PREFIX_USER + SPLIT + userId;
}

UserService
增加三个方法

// 1.优先从缓存中取值
private User getCache(int userId) {
    String redisKey = RedisKeyUtil.getUserKey(userId);
    return (User) redisTemplate.opsForValue().get(redisKey);
}

// 2.取不到时初始化缓存数据
private User initCache(int userId) {
    User user = userMapper.selectById(userId);
    String redisKey = RedisKeyUtil.getUserKey(userId);
    redisTemplate.opsForValue().set(redisKey, user, 3600, TimeUnit.SECONDS);
    return user;
}
// 3.数据变更时清除缓存数据
private void clearCache(int userId) {
    String redisKey = RedisKeyUtil.getUserKey(userId);
    redisTemplate.delete(redisKey);
}

修改 findUserById() 方法

public User findUserById(int id) {
//  return userMapper.selectById(id);
    User user = getCache(id);
    if (user == null) {
        user = initCache(id);
    }
    return user;
}

修改 activation() 方法

public int activation(int userId, String code) {
    User user = userMapper.selectById(userId);
    if (user.getStatus() == 1) {
        return ACTIVATION_REPEAT;
    } else if (user.getActivationCode().equals(code)) {
        userMapper.updateStatus(userId, 1);
        
        clearCache(userId);
        
        return ACTIVATION_SUCCESS;
    } else {
        return ACTIVATION_FAILURE;
    }
}

修改 updateHeader() 方法

public int updateHeader(int userId, String headerUrl) {
    int rows = userMapper.updateHeader(userId, headerUrl);
    clearCache(userId);
    return rows;
}
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值