仿牛客网社区开发——第4章 Redis,一站式高性能存储方案
Redis 入门#
• Redis 是一款基于键值对的 NoSQL 数据库,它的值支持多种数据结构:
字符串(strings)、哈希(hashes)、列表(lists)、集合(sets)、有序集合(sorted sets)等。
• Redis 将所有的数据都存放在内存中,所以它的读写性能十分惊人。同时,Redis 还可以将内存中的数据以快照或日志的形式保存到硬盘上,以保证数据的安全性。
快照:把内存中的所有数据存放到硬盘上,占用空间小,恢复速度快;但是存放过程比较耗时间,一般作为备份使用
日志:存取每一条执行的 Redis 命令,实时,速度快;但是日志占用空间大,且恢复起来需要逐条执行命令,时间较长
• Redis典型的应用场景包括:缓存、排行榜、计数器、社交网络、消息队列(不如专业的消息队列服务器,如 kafka)等。
下载 Redis 并配置环境变量#
github https://github.com/microsoftarchive/redis
常用命令#
存取字符串(strings)#
redis-cli //连接Redis
127.0.0.1:6379[2]> select 1 //选择数据库 一个16个 0-15
127.0.0.1:6379[1]> flushdb //刷新(清空)数据库
//存取数据 以key-value形式 存取字符串
127.0.0.1:6379[1]> set test:count 1
OK
127.0.0.1:6379[1]> get test:count
"1"
//增加减少
127.0.0.1:6379[1]> incr test:count
(integer) 2
127.0.0.1:6379[1]> decr test:count
(integer) 1
存取哈希(hashes)#
127.0.0.1:6379[1]> hset test:user id 1
(integer) 1
127.0.0.1:6379[1]> hset test:user username wangwu
(integer) 1
//获取相应值
127.0.0.1:6379[1]> hget test:user id
"1"
127.0.0.1:6379[1]> hget test:user username
"wangwu"
存取列表(lists)#
127.0.0.1:6379[1]> lpush test:ids 101 102 103 //左端方式存入数据 最终结果为 103 102 101
(integer) 3
127.0.0.1:6379[1]> llen test:ids //取列表长度
(integer) 3
127.0.0.1:6379[1]> lindex test:ids 0
"103"
127.0.0.1:6379[1]> lrange test:ids 0 2
1) "103"
2) "102"
3) "101"
127.0.0.1:6379> rpop test:ids //从右端弹出一个数据 列表长度减一
"101"
127.0.0.1:6379> rpop test:ids
"102"
127.0.0.1:6379> rpop test:ids
"103"
存取集合(sets)#
127.0.0.1:6379> sadd test:teachers aaa bbb ccc //存入数据 aaa bbb ccc
(integer) 3
127.0.0.1:6379> scard test:teachers //获取集长度
(integer) 3
127.0.0.1:6379> spop test:teachers //随机弹出一个数据
"aaa"
127.0.0.1:6379> smembers test:teachers //查询集合数据
1) "ccc"
2) "bbb"
存取有序集合(sorted sets)#
127.0.0.1:6379> zadd test:students 10 aaa 20 bbb 30 ccc //分数和姓名
(integer) 3
127.0.0.1:6379> zcard test:students //统计有多少个数据
(integer) 3
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> zrank test:students aaa
(integer) 0
127.0.0.1:6379> zrange test:students 0 2
1) "aaa"
2) "bbb"
3) "ccc"
常用全局命令#
127.0.0.1:6379> keys * //查看所有的key
1) "test:ids"
2) "test:students"
3) "test:teachers"
127.0.0.1:6379> type test:ids //看某一个key的类型
list
127.0.0.1:6379> exists test:user //是否存在某一个key
(integer) 0
127.0.0.1:6379> expire test:ids 10 //给key设置过期时间 /秒
(integer) 1
127.0.0.1:6379> keys *
1) "test:ids"
2) "test:students"
3) "test:teachers"
127.0.0.1:6379> keys *
1) "test:students"
2) "test:teachers"
注意点:#
- Redis 的一些基本概念,NoSQL,快照日志等
- Redis 的五种常见的数据类型及其常用命令,以及常用的全局命令
Spring 整合 Redis#
引入依赖 spring-boot-starter-data-redis#
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
(不需要写版本,pom 父文件中定义了互相兼容的版本)
配置 Redis - 配置数据库参数 - 编写配置类,构造 RedisTemplate#
# RedisProperties
spring.redis.database=11 //选择的库 一共16个 从0-15
spring.redis.host=localhost //地址ip
spring.redis.port=6379 //端口
访问 Redis#
编写 RedisConfig#
- 方法名就是 Bean 的名字(后面自动装配的时候必须写相同的名字,否则报错)
- 因为 Redis 是数据库,RedisTemplate 要想具备访问数据库的能力,它得能够创建连接。连接是由连接工厂创建的,所以需要注入连接工厂,再注入给 redisTemplate。在方法参数里声明连接工厂(在定义一个 Bean 的时候,方法上声明了这样的参数,Spring 会自动把它注入进来,它已经被容器装配了),再调用 template.setConnectionFactory(factory) 设置连接工厂
- 因为我们写的是 Java 程序,得到的数据是 Java 类型的数据。最终需要把数据存到 Redis 数据库里,需要指定一种序列化的方式
- 做完了一些设置后,最后需要调用 template.afterPropertiesSet() 触发一下使其生效
@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;
}
}
通过 redisTemplate 访问 Redis#
· redisTemplate.opsForValue()
@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));
}
· redisTemplate.opsForHash()
@Test
public void testHashes() {
String redisKey = "test:user";
redisTemplate.opsForHash().put(redisKey, "id", 1);
redisTemplate.opsForHash().put(redisKey, "username", "zwc");
System.out.println(redisTemplate.opsForHash().get(redisKey, "id"));
System.out.println(redisTemplate.opsForHash().get(redisKey, "username"));
}
· redisTemplate.opsForList()
@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));
System.out.println(redisTemplate.opsForList().range(redisKey, 0, 2));
System.out.println(redisTemplate.opsForList().leftPop(redisKey));
System.out.println(redisTemplate.opsForList().leftPop(redisKey));
System.out.println(redisTemplate.opsForList().leftPop(redisKey));
}
· redisTemplate.opsForSet()
@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));
}
· redisTemplate.opsForZSet()
@Test
public void testSortedSets() {
String redisKey = "test:students";
redisTemplate.opsForZSet().add(redisKey, "孙悟空", 90);
redisTemplate.opsForZSet().add(redisKey, "猪八戒", 60);
redisTemplate.opsForZSet().add(redisKey, "沙和尚", 50);
redisTemplate.opsForZSet().add(redisKey, "唐僧", 80);
redisTemplate.opsForZSet().add(redisKey, "白龙马", 70);
System.out.println(redisTemplate.opsForZSet().zCard(redisKey));
System.out.println(redisTemplate.opsForZSet().score(redisKey, "孙悟空"));
System.out.println(redisTemplate.opsForZSet().reverseRank(redisKey, "猪八戒"));
System.out.println(redisTemplate.opsForZSet().range(redisKey, 0, 4));
}
· 全局操作
@Test
public void testKeys() {
redisTemplate.delete("test:user");
System.out.println(redisTemplate.hasKey("test:user"));
redisTemplate.expire("test:students", 10, TimeUnit.SECONDS);
}
· 把 key 绑定到一个对象上,不用多次传入 key
// 多次访问同一个key
@Test
public void testBoundOperations() {
BoundValueOperations operations = redisTemplate.boundValueOps("test:count");
// 利用绑定的对象操作
operations.increment();
operations.increment();
operations.increment();
operations.increment();
operations.increment();
System.out.println(operations.get());
}
· 编程式事务
- 当一个客户端执行 MULTI 命令之后,它就进入了事务模式,这时用户输入的所有数据操作命令都不会立即执行,而是会按顺序放入一个事务队列中,等待事务执行时再统一执行。
- 当事务成功执行时,EXEC 命令将返回一个列表作为结果,这个列表会按照命令的入队顺序依次包含各个命令的执行结果。
@Test
public void testTx() {
Object obj = redisTemplate.execute(new SessionCallback() {
@Override
public Object execute(RedisOperations operations) throws DataAccessException {
String redisKey = "test:tx";
operations.multi();
operations.opsForSet().add(redisKey, "zhangsan");
operations.opsForSet().add(redisKey, "lisi");
operations.opsForSet().add(redisKey, "wangwu");
System.out.println(operations.opsForSet().members(redisKey));
return operations.exec();
}
});
System.out.println(obj); // [1, 1, 1, [zhangsan, lisi, wangwu]]
}
这里的 sout 输出为空,个人猜测是因为在 Java 代码的 multi 和 exec 之间的所有 Redis 命令被 Redis 加到事务执行命令队列,但是 Java 代码这个时候就已经被执行了,但是 members 那条命令不会立刻返回结果,所以这个时候 sout 拿不到结果就输出空。到最后 exec,Redis 把队列里的命令都执行了,返回一个结果,最后打印出来。感觉就是把 multi 和 exec 之间的 Redis 命令抽取出来了。(不一定对)
注意点:#
- Autowired 的时候,参数名需要和 Bean 的名字完全一样,否则会报错
- RedisConfig 的代码编写,注入连接工厂、设置序列化方式以及最后调用 afterPropertiesSet()
- 各种常见操作方法,包括绑定 key 的方法
- Redis 的事务特性(见上),编程式事务代码编写,multi、exec。
点赞#
编写生成点赞 key 的工具#
由通用的前缀,加上传入的实体类型、实体 id
public class RedisKeyUtil {
private static final String SPLIT = ":";
private 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;
}
}
编写 LikeService 实现点赞业务#
- 集合的元素是不重复的,所以可以根据 userId 找到对应的唯一用户,根据用户的数量可以得出点赞的数量
- 集合的 key 是每一个实体对应的唯一 key,比如传入帖子 id,这个帖子就生成了一个唯一 key
- 点赞时需要先判断用户有没有点过赞,看集合里有没有对应的 userId,如果已经点赞就移除该用户,取消赞;没有则添加该用户
- 除了点赞功能外,页面还需要显示点赞的状态和点赞的数量
@Service
public class LikeService {
@Autowired
private RedisTemplate redisTemplate;
// 点赞
public void like(int userId, int entityType, int entityId) {
String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId);
if(redisTemplate.opsForSet().isMember(entityLikeKey, userId)) {
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;
}
}
编写 LikeController#
- 点赞时需要知道当前用户是谁,即 Id 是多少,传入 Service
- 点赞后需要返回当前点赞状态和点赞数量,封装到一个 map 中,传回 JSON
@Controller
public class LikeController {
@Autowired
private LikeService likeService;
@Autowired
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);
}
}
修改页面#
- href="javascript:;"(没有括号),禁用超链接;添加 th:onclick="|like(this,1,${post.id});|"(注意写法和取值,这边是帖子的点赞),把超链接当按钮来用,添加单击事件
- “赞”和其数量都需要套上标签,方便动态取值和做修改
- 需要传入 this,知道点的是哪个“赞”,然后修改对应“赞”的状态和数量(取它的子标签,具体见下)
- 新建 discuss.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("b").text(data.likeStatus == 1 ? '已赞':'赞');
$(btn).children("i").text(data.likeCount);
} else {
alert(data.msg);
}
}
)
}
以帖子的点赞部分为例(评论和回复大同小异,注意取值即可):
<li class="d-inline ml-2">
<a href="javascript:;" th:onclick="|like(this,1,${post.id});|" class="text-primary">
<b th:text="${likeStatus==1?'已赞':'赞'}">赞</b> <i th:text="${likeCount}">11</i>
</a>
</li>
这里已经是最终版本了,原先 b 和 i 标签里面没有任何属性,导致一开始的“赞”和数量还是固定值,所以在访问帖子列表以及详情页面时就得带上点赞的状态和数量。
修改 HomeController 的 /index 请求,添加点赞状态及数量的显示#
@GetMapping("/index")
public String getIndexPage(Model model, Page page) {
page.setRows(discussPostService.findDiscussPostRows(0));
page.setPath("/index");
List<DiscussPost> list = discussPostService.findDiscussPosts(0, page.getOffset(), page.getLimit());
List<Map<String, Object>> discussPosts = new ArrayList<>();
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";
}
修改 DiscussPostController 的 /detail/{discussPostId} 请求,添加点赞状态及数量的显示#
注意,需要判断 user 是否为 null(否则 .getId() 报空指针异常)。当 user 为 null(用户未登录)时,设置点赞状态为 0(未登录也没法点赞,不可能为“已赞”)
(原先的代码做了省略)
@GetMapping("/detail/{discussPostId}")
public String findDiscussPostById (@PathVariable("discussPostId") int discussPostId, Model model, Page page) {
// 帖子
……
// 作者
……
// 点赞数量
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);
// 评论分页信息
……
// 评论列表
……
// 评论VO列表
……
for(Comment comment : commentList) {
// 评论VO
……
// 评论
……
// 用户
……
// 点赞数量
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);
// 回复列表
……
// 回复VO列表
……
for(Comment reply : replyList) {
// 回复VO
……
// 回复
……
// 用户
……
// 回复目标
……
// 点赞数量
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);
……
}
……
// 回复数量
……
}
……
}
修改对应页面动态取值#
index.html 中:
<li class="d-inline ml-2">赞 <span th:text="${map.likeCount}">11</span></li>
discuss-detail.html 中:
分别修改帖子、评论和回复的点赞部分,示例见上。
注意点:#
- 每个实体的赞以 set 形式保存在 Redis 数据库里,保存了各个用户的 id,方便判断该用户是否点赞以及得到赞的数量
- 修改页面中的 javascript:;(不加括号)禁用超链接、onclick 中的 js 函数参数要传入 this 来判断是点的页面中的哪个“赞”
- 用户未登录时,即 user 为 null,注意判空,返回状态 0,显示“赞”
我收到的赞#
重构点赞功能#
RedisKeyUtil 中添加用户的赞的 Key 前缀以及获取用户的赞的 Key 的方法#
public class RedisKeyUtil {
……
private static final String PREFIX_USER_LIKE = "like:user";
// 某个实体的赞
// like:entity:entityType:entityId -> set(userId)
……
// 某个用户的赞
// like:user:userId -> int
public static String getUserLikeKey(int userId) {
return PREFIX_USER_LIKE + SPLIT + userId;
}
}
重构 LikeService 的 like 点赞方法#
- 根据被点赞用户的 id 生成 key 存放到 Redis 中,重构点赞功能,点赞的时候传入被赞人的用户 id,如果是点赞就使该 key 的值加一,如果是取消赞就使被点赞人的 key 的值减一
- 因为涉及到两个修改操作,考虑使用 Redis 事务
- 获取被赞人的 id 不使用通过 entityType 和 entityId 获取 userid 的方法,需要访问数据库,这与使用 Redis 的高性能相违背。直接传入参数被赞人 id(Controller 层方法包括 js 中的方法也相应的添加该参数,页面中调用 like() 的 js 方法时就带上被赞用户的 id)
- 把查询放在事务之外(见上文的 Redis 事务特性)
// 点赞
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 = redisTemplate.opsForSet().isMember(entityLikeKey, userId);
operations.multi();
if(isMember) {
redisTemplate.opsForSet().remove(entityLikeKey, userId);
redisTemplate.opsForValue().decrement(userLikeKey);
} else {
redisTemplate.opsForSet().add(entityLikeKey, userId);
redisTemplate.opsForValue().increment(userLikeKey);
}
return operations.exec();
}
});
}
添加查询某用户获得赞的数量的方法#
注意判空,该用户没任何人点赞的话没有这个 key(一次都没 increment,经测试第一次 increment 会生成 key,且值为 1),得到的是 null,按逻辑应该返回 0,此时没人点赞
// 查询某个用户获得的赞
public int findUserLikeCount(int userId) {
String userLikeKey = RedisKeyUtil.getUserLikeKey(userId);
Integer count = (Integer) redisTemplate.opsForValue().get(userLikeKey);
return count == null ? 0 : count.intValue();
}
添加访问个人主页的请求处理方法#
- 用户需要判空,防止有人恶意攻击,传错的 userId 进来
- 将用户信息和点赞数量添加到 model 里传给模板进行显示
// 个人主页
@GetMapping("/profile/{userId}")
public String getProfilePage(@PathVariable("userId") int userId, Model model) {
User user = userService.findUserById(userId);
if(user == null) {
throw new IllegalArgumentException("该用户不存在!");
}
// 用户
model.addAttribute("user", user);
// 点赞数量
int likeCount = likeService.findUserLikeCount(userId);
model.addAttribute("likeCount", likeCount);
return "/site/profile";
}
修改页面#
- 各个涉及到用户头像的页面都修改超链接为 th:href="@{|/user/profile/${map.user.id}|}",其中动态取值的地方视情况而定。
- profile.html 添加 thymeleaf 模板引擎,修改相对路径,修改各个需要动态取值的地方(关注相关的后面章节再进行修改)
<!-- 个人信息 -->
<div class="media mt-5">
<img th:src="${user.headerUrl}" class="align-self-start mr-4 rounded-circle" alt="用户头像" style="width:50px;">
<div class="media-body">
<h5 class="mt-0 text-warning">
<span th:utext="${user.username}">nowcoder</span>
<button type="button" class="btn btn-info btn-sm float-right mr-5 follow-btn">关注TA</button>
</h5>
<div class="text-muted mt-3">
<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>
</div>
<div class="text-muted mt-3 mb-5">
<span>关注了 <a class="text-primary" href="followee.html">5</a> 人</span>
<span class="ml-4">关注者 <a class="text-primary" href="follower.html">123</a> 人</span>
<span class="ml-4">获得了 <i class="text-danger" th:text="${likeCount}">87</i> 个赞</span>
</div>
</div>
</div>
注意点:#
- 查询一定要放到事务之外(具体原因见前面的 Redis 事物特性)
- 注意 2 个判空的地方
关注、取消关注#
设计 Redis 的 key 和 value#
- followee:某个用户关注的实体,key 的形式为 followee:userId:entityType,某个用户的某种关注类型(用户为 3),value 为 zset(entityId,now)
- 使用有序集合,分数为关注时间,方便后面按照关注时间顺序进行显示
- follower:某个实体拥有的粉丝,key 的形式为 follower:entityType:entityId,某种类型(用户为 3)的某实体,value 为 zset(userId,now)
这里的 key 和 value 的形式是整个业务的核心,必须搞清楚
public class RedisKeyUtil {
……
private static final String PREFIX_FOLLOWEE = "followee";
private static final String PREFIX_FOLLOWER = "follower";
// 某个实体的赞
// like:entity:entityType:entityId -> set(userId)
……
// 某个用户的赞
// like:user:userId -> int
……
// 某个用户关注的实体
// 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#
- 关注(follow)和取关(unfollow)的方法。因为涉及到修改 2 个 key 的值,需要用 Redis 事务;分数使用当前时间的毫秒数
- 查询关注的实体的数量、查询实体的粉丝的数量以及查询当前用户是否已关注该实体的方法(页面需要显示)
@Service
public class FollowService implements CommunityConstant {
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private UserService userService;
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();
redisTemplate.opsForZSet().add(followeeKey, entityId, System.currentTimeMillis());
redisTemplate.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();
redisTemplate.opsForZSet().remove(followeeKey, entityId);
redisTemplate.opsForZSet().remove(followerKey, userId);
return operations.exec();
}
});
}
// 查询关注的实体的数量
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;
}
}
编写 FollowController#
得到当前用户的 id
@Controller
public class FollowController implements CommunityConstant {
@Autowired
private FollowService followService;
@Autowired
private HostHolder hostHolder;
@PostMapping("/follow")
@ResponseBody
@LoginRequired
public String follow(int entityType, int entityId) {
User user = hostHolder.getUser();
followService.follow(user.getId(), entityType, entityId);
return CommunityUtil.getJSONString(0, "已关注!");
}
@PostMapping("/unfollow")
@ResponseBody
@LoginRequired
public String unfollow(int entityType, int entityId) {
User user = hostHolder.getUser();
followService.unfollow(user.getId(), entityType, entityId);
return CommunityUtil.getJSONString(0, "已取消关注!");
}
}
修改 profile.js#
- 根据标签的样式来判断是关注还是取关
- 发送异步请求,成功后刷新页面
- 通过在 button 按钮的前面加上 <input> 标签(hidden)来获取 entityId
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");
}
}
修改 UserController 的访问个人主页的方法#
- 添加关注数量、粉丝数量和是否已关注
// 个人主页
@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 页面#
注意 button 标签的修改:
- 在之前要加上隐藏的 <input> 标签,便于 js 中获取 entityId(这里就是该用户的 id)
- 标签的值根据是否已关注动态变化
- 标签的样式也得根据是否已关注动态变化
- 如果未登录或者该用户是自己,则不予显示标签
<!-- 个人信息 -->
<div class="media mt-5">
<img th:src="${user.headerUrl}" class="align-self-start mr-4 rounded-circle" alt="用户头像" style="width:50px;">
<div class="media-body">
<h5 class="mt-0 text-warning">
<span th:utext="${user.username}">nowcoder</span>
<input type="hidden" th:value="${user.id}">
<button type="button" th:text="${hasFollowed?'已关注':'关注TA'}" th:if="${loginUser!=null&&loginUser.id!=user.id}"
th:class="|btn ${hasFollowed?'btn-secondary':'btn-info'} btn-sm float-right mr-5 follow-btn|">关注TA</button>
</h5>
<div class="text-muted mt-3">
<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>
</div>
<div class="text-muted mt-3 mb-5">
<span>关注了 <a class="text-primary" href="followee.html" th:text="${followeeCount}">5</a> 人</span>
<span class="ml-4">关注者 <a class="text-primary" href="follower.html" th:text="${followerCount}">123</a> 人</span>
<span class="ml-4">获得了 <i class="text-danger" th:text="${likeCount}">87</i> 个赞</span>
</div>
</div>
</div>
注意点:#
- follower、followee 以及各自的 key 和 value 的构成要理清楚
- 关注用户时,当前用户 id 和目标用户 id 分清楚,尤其有三个参数的方法(例如 hasFollowed),userId 和 entityId 分别对应谁,userId 不要取错了(hostHolder.getUser().getId())
- button 标签的一些修改
关注列表、粉丝列表#
修改 FollowService#
添加查询某用户关注的人和查询某用户粉丝的方法:
-
需要分页,因此需要传入分页的参数
-
按 zset 的分数(即关注时间)倒序查出目标 id(即用户关注的人或用户的粉丝组成的集合)
-
遍历 ids,通过 id 查到相应的 user 信息,放入到 map 里
-
通过 key 和 id 查出相应的分数即关注时间,放入到 map 里
// 查询某用户关注的人
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;
}
注意,Redis 的 range 方法返回的是 Set 的实现类(内置的,是它自己实现的一个有序集合)
修改 FollowController#
添加访问该用户关注的人和关注该用户的人的页面:
- 首先向模板中保存该用户的用户信息(判空),方便页面调用
- 设置分页参数(和之前类似)
- 调用 Service 查询该用户的关注列表和粉丝列表
- 遍历 userList,获取列表里的每一个用户
- 判断登录的用户是否关注了列表里的每一个用户,放入到 map 里,供页面显示未关注和已关注(未登录直接为 false 未关注,不过其实在页面校验后直接不显示关注按钮的)
@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) {
User user = hostHolder.getUser();
if (user == null) {
return false;
}
return followService.hasFollowed(user.getId(), ENTITY_TYPE_USER, userId);
}
修改页面#
1、个人页面上修改到关注列表和粉丝列表的超链接路径
<div class="text-muted mt-3 mb-5">
<span>关注了 <a class="text-primary" th:href="@{|/followees/${user.id}|}" th:text="${followeeCount}">5</a> 人</span>
<span class="ml-4">关注者 <a class="text-primary" th:href="@{|/followers/${user.id}|}" th:text="${followerCount}">123</a> 人</span>
<span class="ml-4">获得了 <i class="text-danger" th:text="${likeCount}">87</i> 个赞</span>
</div>
2、关注列表页面 followee.html 和粉丝列表页面 follower.html 的修改
以 followee.html 为例,follower.html 相同
<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">返回个人主页></a>
</div>
<!-- 关注列表 -->
<ul class="list-unstyled">
<li class="media pb-3 pt-3 mb-3 border-bottom position-relative" th:each="map:${users}">
<a th:href="@{|/user/profile/${map.user.id}|}">
<img th:src="${map.user.headerUrl}" class="mr-4 rounded-circle user-header" alt="用户头像" >
</a>
<div class="media-body">
<h6 class="mt-0 mb-3">
<span class="text-success" th:utext="${map.user.username}">落基山脉下的闲人</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>
</h6>
<div>
<input type="hidden" th:value="${map.user.id}">
<button type="button" th:class="|btn ${map.hasFollowed?'btn-secondary':'btn-info'} btn-sm float-right follow-btn|"
th:text="${map.hasFollowed?'已关注':'关注TA'}" th:if="${loginUser!=null&&loginUser.id!=map.user.id}">关注TA</button>
</div>
</div>
</li>
</ul>
注意点:#
- Redis 的 range 方法返回的是 Set 的实现类,它自己实现的一个有序集合
- 向模板中保存当前访问的用户的用户信息和当前用户是否已关注列表中每个用户的信息,供页面进行显示
优化登录模块#
把访问频率比较高,会影响效率的功能使用 Redis 进行优化
使用 Redis 存储验证码#
用户第一次访问登录界面,服务器生成随机字符串保存到 cookie 里发送给浏览器,同时生成相应的 key 保存验证码文本到 Redis 里
- 因为用户还未登录,所有无法获取用户信息,无法与验证码进行绑定,因此使用临时凭证作为 key 来存取验证码信息
- 用户访问登录页面的时候,先给用户发一个 cookie,存储随机生成的字符串,以此字符串作为临时凭证
- cookie 有效时间设置比较短以节省开销,设置为 60 秒即可
- Redis 中验证码也设置为 60 秒过期
private static final String PREFIX_KAPTCHA = "kaptcha";
……
// 登录验证码
public static String getKaptchaKey(String owner) {
return PREFIX_KAPTCHA + SPLIT + owner;
}
@GetMapping("/kaptcha")
public void kaptcha(HttpServletResponse response) {
// 生成验证码
……
// 将验证码存入session
// session.setAttribute("kaptcha", text);
// 验证码的归属
String kaptchaOwner = CommunityUtil.generateUUID();
Cookie cookie = new Cookie("kaptchaOwner", kaptchaOwner);
cookie.setMaxAge(60);
cookie.setPath(contextPath);
response.addCookie(cookie);
// 将验证码存入Redis
String kaptchaKey = RedisKeyUtil.getKaptchaKey(kaptchaOwner);
redisTemplate.opsForValue().set(kaptchaKey, text, 60, TimeUnit.SECONDS);
// 将图片输出给浏览器
……
}
@PostMapping("/login")
public String login(String username, String password, String code, boolean rememberme,
Model model, HttpServletResponse response,
@CookieValue(value = "kaptchaOwner", required = false) String kaptchaOwner) {
// 检查验证码
// String kaptcha = (String) session.getAttribute("kaptcha");
String kaptcha = null;
if(StringUtils.isNotBlank(kaptchaOwner)) {
String kaptchaKey = RedisKeyUtil.getKaptchaKey(kaptchaOwner);
kaptcha = (String) redisTemplate.opsForValue().get(kaptchaKey);
}
……
}
login 方法中,CookieValue 注解中带上 required = false,防止 cookie 过期后,取不到 cookie 值而报错。
使用 Redis 存储登录凭证#
private static final String PREFIX_TICKET = "ticket";
……
// 登录的凭证
public static String getTicketKey(String ticket) {
return PREFIX_TICKET + SPLIT + ticket;
}
原来的 LoginTicketMapper 上加上 @Deprecated 注解
public Map<String, Object> login(String username, String password, long expiredSeconds) {
……
// 生成登录凭证
……
// loginTicketMapper.insertLoginTicket(loginTicket);
String ticketKey = RedisKeyUtil.getTicketKey(loginTicket.getTicket());
redisTemplate.opsForValue().set(ticketKey, loginTicket);
……
}
退出的时候取出登录凭证,把状态设置为 1,再重新放入 Redis,没有直接删除,保留用户的登录记录,以便之后用到
public void logout(String ticket) {
// loginTicketMapper.updateStatus(ticket, 1);
String ticketKey = RedisKeyUtil.getTicketKey(ticket);
LoginTicket loginTicket = (LoginTicket) redisTemplate.opsForValue().get(ticketKey);
loginTicket.setStatus(1);
redisTemplate.opsForValue().set(ticketKey, loginTicket);
}
public LoginTicket findLoginTicket(String ticket) {
// return loginTicketMapper.selectByTicket(ticket);
String ticketKey = RedisKeyUtil.getTicketKey(ticket);
return (LoginTicket) redisTemplate.opsForValue().get(ticketKey);
}
使用 Redis 缓存用户信息#
- 当查询的时候,优先从缓存中取值;
- 缓存中取不到的时候再从 MySQL 数据库中取值,再初始化缓存数据,即放入 Redis 中;
- 数据变更时清除缓存数据(如果采取更新缓存中数据的方法可能会带来并发问题,并且比较繁琐);
- 往 Redis 中直接存取对象,会序列化成 Json 字符串
private static final String PREFIX_USER = "user";
……
// 用户
public static String getUserKey(int userId) {
return PREFIX_USER + SPLIT + userId;
}
// 1.优先从缓存中取值
private User getCache(int userId) {
String userKey = RedisKeyUtil.getUserKey(userId);
return (User) redisTemplate.opsForValue().get(userKey);
}
// 2.取不到时初始化缓存数据
private User initCache(int userId) {
User user = userMapper.selectById(userId);
String userKey = RedisKeyUtil.getUserKey(userId);
redisTemplate.opsForValue().set(userKey, user, 3600, TimeUnit.SECONDS);
return user;
}
// 3.数据变更时清除缓存数据
private void clearCache(int userId) {
String userKey = RedisKeyUtil.getUserKey(userId);
redisTemplate.delete(userKey);
}
public User findUserById(int id) {
// return userMapper.selectById(id);
User user = getCache(id);
if (user == null) {
user = initCache(id);
}
return user;
}
在每个 update 的方法后加上 clearCache 清除缓存,以修改头像为例:
public int updateHeader(int userId, String headerUrl) {
// return userMapper.updateHeader(userId, headerUrl);
int rows = userMapper.updateHeader(userId, headerUrl);
clearCache(userId);
return rows;
}
当然评论区也有人提到了,最好用 AOP 来做这件事情
注意点:#
- 用户在登录界面时,因为还未登录,无法将用户与验证码进行绑定,所以生成一个随机字符串作为临时凭证,通过 cookie 传给浏览器,并设置 60 秒过期
- login 方法上,使用 @CookieValue 注解时带上 required = false。经测试,60 秒验证码过期后,再登录会报错,取不到 kaptchaOwner
- 退出登录时是否删除登录凭证的问题。老师说不需要删,把状态改为 1,如果后面添加查询登录记录的功能的话会用到。不过评论区中提到,ticket 也就是一串随机字符串,不好查。这个问题着实有待商榷啊(⊙o⊙)…
- 缓存用户信息的三个要点(见上)。其中关于更新时删除缓存数据,评论区提到最好用 AOP 来做
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 记一次.NET内存居高不下排查解决与启示
· DeepSeek 开源周回顾「GitHub 热点速览」
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了