功能05-好友關注
6.功能05-好友關注
6.1關注和取關
6.1.1需求分析
在探店圖文的詳情頁面中,可以關注釋出筆記的作者:
- 關注和取關:點選關注按鈕就會發出請求(上圖):
http://127.0.0.1:8080/api/follow/2/true
(2是關注的使用者id,最後面的引數可以是true或者false,取決於當前的關注狀態) - 查詢當前關注狀態:(下圖)
http://127.0.0.1:8080/api/follow/or/not/2
,返回兩種狀態:true(已關注)或者false(未關注)。關注和取關功能根據關注狀態來實現。 - 整體流程:進入頁面詳情的時候,會自動查詢當前使用者對blog博主的關注狀態,根據關注狀態來懸渲染“關注”或“已關注”按鈕,根據關注狀態,使用者可以做相對的“關注”或者“取關”操作。
需求:基於該表資料結構,實現兩個介面:
- 關注和取關介面
- 判斷是否關注的介面
關注是User之間的關係,是博主與粉絲之間的關係,資料庫使用tb_follow來表示:
CREATE TABLE `tb_follow` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
`user_id` bigint(20) unsigned NOT NULL COMMENT '使用者id',
`follow_user_id` bigint(20) unsigned NOT NULL COMMENT '關聯的使用者id',
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '建立時間',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=COMPACT
注意:這裡要把主鍵改為自增長,簡化開發。
取關就是刪除該表的一條對應記錄,關注就是新增一條表對應的記錄。根據user_id和follow_user_id判斷關注狀態。
6.1.2程式碼實現
(1)Follow.java,記錄使用者和博主的關係
package com.hmdp.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 關注關係(使用者和博主)
*
* @author 李
* @version 1.0
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("tb_follow")
public class Follow implements Serializable {
private static final long serialVersionUID = 1L;
//主鍵
@TableId(value = "id", type = IdType.AUTO)
private Long id;
//使用者id(粉絲)
private Long userId;
//關注的使用者id(博主)
private Long followUserId;
//建立時間
private LocalDateTime createTime;
}
(2)IFollowService.java,宣告方法介面
package com.hmdp.service;
import com.hmdp.dto.Result;
import com.hmdp.entity.Follow;
import com.baomidou.mybatisplus.extension.service.IService;
/**
* 服務類
*
* @author 李
* @version 1.0
*/
public interface IFollowService extends IService<Follow> {
Result follow(Long followUserId, Boolean isFollow);
Result isFollow(Long followUserId);
}
(3)FollowServiceImpl.java,實現方法
package com.hmdp.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.hmdp.dto.Result;
import com.hmdp.entity.Follow;
import com.hmdp.mapper.FollowMapper;
import com.hmdp.service.IFollowService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.UserHolder;
import org.springframework.stereotype.Service;
/**
* 服務實現類
*
* @author 李
* @version 1.0
*/
@Service
public class FollowServiceImpl extends ServiceImpl<FollowMapper, Follow> implements IFollowService {
//關注or取關功能
@Override
public Result follow(Long followUserId, Boolean isFollow) {
if (UserHolder.getUser() == null) {
return Result.fail("使用者未登入");
}
//1.獲取登入使用者
Long userId = UserHolder.getUser().getId();
//2.判斷是關注還是取關功能
if (isFollow) {
//3.關注,新增資料
Follow follow = new Follow();
follow.setUserId(userId);
follow.setFollowUserId(followUserId);
save(follow);
} else {
//4.取關,刪除資料 delete form tb_follow where user_id = ? and follow_user_id = ?
remove(new QueryWrapper<Follow>()
.eq("user_id", userId).eq("follow_user_id", followUserId));
}
return Result.ok();
}
//查詢當前使用者對某博主的關注狀態
@Override
public Result isFollow(Long followUserId) {
if (UserHolder.getUser() == null) {
return Result.fail("使用者未登入");
}
//1.獲取登入使用者
Long userId = UserHolder.getUser().getId();
//2.查詢是否關注 select count(*) from tb_follow where user_id =? and follow_user_id =?
Integer count = query().eq("user_id", userId).eq("follow_user_id", followUserId).count();
return Result.ok(count > 0);//如果count>0,表示已關注,返回true,反之,返回false
}
}
(4)FollowController.java
package com.hmdp.controller;
import com.hmdp.dto.Result;
import com.hmdp.service.IFollowService;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
/**
* 前端控制器
*
* @author 李
* @version 1.0
*/
@RestController
@RequestMapping("/follow")
public class FollowController {
@Resource
private IFollowService followService;
@PutMapping("/{id}/{isFollow}")
public Result follow(@PathVariable("id") Long followUserId, @PathVariable("isFollow") Boolean isFollow) {
return followService.follow(followUserId, isFollow);
}
@GetMapping("/or/not/{id}")
public Result follow(@PathVariable("id") Long followUserId) {
return followService.isFollow(followUserId);
}
}
(5)測試:重啟專案,進入部落格詳情
點選關注按鈕,提示關注成功:
資料庫的tb_follow表新增一條新資料:
再點選取消關注按鈕,提示取消關注成功:
資料庫tb_follow刪除該條資料:
6.2共同關注
6.2.1博主首頁資訊
點選博主頭像,可以進入博主首頁,檢視博主首頁的資訊:包括博主資訊,釋出的筆記,共同關注。
當點選進入博主首頁的時候,將會發出兩個請求:
- 請求博主的使用者資訊
- 請求博主釋出過的筆記資訊
當點選共同關注的時候,就會發出請求查詢共同關注。
博主個人首頁依賴於兩個功能:
(1)在UserController.java中增加queryUserById()方法,用於請求博主的使用者資訊
//根據id查詢使用者資訊
@GetMapping("/{id}")
public Result queryUserById(@PathVariable("id") Long userId) {
//查詢詳情
User user = userService.getById(userId);
if (user == null) {
return Result.ok();
}
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
//返回
return Result.ok(userDTO);
}
(2)在BlogController.java中增加queryBlogByUserId方法,用於查詢最近10條筆記
//根據使用者id查詢blog
@GetMapping("/of/user")
public Result queryBlogByUserId(
@RequestParam(value = "current", defaultValue = "1") Integer current,
@RequestParam("id") Long id) {
//根據使用者查詢
Page<Blog> page = blogService.query()
.eq("user_id", id)
.page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
//獲取當前頁資料
List<Blog> records = page.getRecords();
return Result.ok(records);
}
(3)重啟專案,點選某個博主首頁,顯示如下:
6.2.2共同關注
需求:使用Redis合適的資料結構,實現共同關注功能。在博主個人頁面展示出當前使用者與博主的共同好友。
我們可以使用Redis的Set結構,求多個set集合的交集:
SINTER key [key ...]
summary: Intersect multiple sets
since: 1.0.0
例如:
127.0.0.1:6379> SADD s1 m1 m2
(integer) 2
127.0.0.1:6379> SADD s2 m2 m3
(integer) 2
127.0.0.1:6379> SINTER s1 s2
1) "m2"
程式碼實現
要使用set結構實現共同關注功能,首先將使用者關注的列表新增到redis的set集合中。
因此,我們需要修改之前的關注功能:在關注使用者的時候,不僅要記錄到資料庫中,還要將關注的使用者放到redis的set集合中(key為當前使用者id,value為當前使用者關注的所有使用者的id)。
(1)修改FollowServiceImpl.java的follow方法:
//關注or取關功能
@Override
public Result follow(Long followUserId, Boolean isFollow) {
if (UserHolder.getUser() == null) {
return Result.fail("使用者未登入");
}
//1.獲取登入使用者
Long userId = UserHolder.getUser().getId();
String key = "follows:" + userId;
//2.判斷是關注還是取關功能
if (isFollow) {
//3.關注,新增資料
Follow follow = new Follow();
follow.setUserId(userId);
follow.setFollowUserId(followUserId);
boolean isSuccess = save(follow);
if (isSuccess) {
//將關注使用者的id,放入到redis的set集合中 sadd userId followerUserId
stringRedisTemplate.opsForSet().add(key, followUserId.toString());
}
} else {
//4.取關,刪除資料 delete form tb_follow where user_id = ? and follow_user_id = ?
boolean isSuccess = remove(new QueryWrapper<Follow>()
.eq("user_id", userId).eq("follow_user_id", followUserId));
if (isSuccess) {
//將關注使用者的id從set集合中移除
stringRedisTemplate.opsForSet().remove(key, followUserId);
}
}
return Result.ok();
}
(2)測試,使用一個使用者任意關注兩個博主後,redis中的資料:
資料庫:
重新登入一個使用者,關注兩個博主:
可以看到使用者1034和使用者1的共同關注為使用者2號,關注功能已經修改完畢,接下來實現共同關注功能。
(3)修改IFollowService介面,宣告followCommons方法
Result followCommons(Long id);
(4)修改FollowServiceImpl,實現followCommons()方法
@Override
public Result followCommons(Long id) {
//1.獲取當前使用者
Long userId = UserHolder.getUser().getId();
String key = "follows:" + userId;
String key2 = "follows:" + id;
//2.求交集
//結果為交集的所有使用者id
Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key, key2);
if (intersect == null || intersect.isEmpty()) {
//如果沒有交集
return Result.ok(Collections.emptyList());
}
//3.解析id集合
List<Long> ids = intersect.stream().map(Long::valueOf).collect(Collectors.toList());
//4.查詢使用者
List<UserDTO> users = userService.listByIds(ids)
.stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());
return Result.ok(users);
}
(5)修改FollowController,增加介面
@GetMapping("/common/{id}")
public Result followCommons(@PathVariable("id") Long id) {
return followService.followCommons(id);
}
(6)測試:登入id為1034的使用者,檢視id為1的使用者主頁,頁面顯示兩個使用者共同關注為2號使用者。
和redis中的資料一致,測試透過。
6.3關注推送
6.3.1Feed流的Timeline模式
當我們關注了使用者後,如果這個使用者發了動態,那麼我們應該把這些資料推送給使用者,這個需求又叫做Feed流。關注推送也叫做Feed流,直譯為投餵。為使用者持續的提供“沉浸式”的體驗,透過無限下拉重新整理獲取新的資訊。
對於傳統的模式的內容解鎖:需要使用者去透過搜尋引擎或者是其他的方式去解鎖想要看的內容
對於新型的Feed流的的效果:不需要使用者再去推送資訊,而是系統分析使用者到底想要什麼,然後直接把內容推送給使用者,從而使使用者能夠更加的節約時間,不用主動去尋找。
Feed流產品有兩種常見模式:
- Timeline:不做內容篩選,簡單地按照內容釋出時間排序,常用於好友或關注。例如朋友圈
- 優點:資訊全面,不會有缺失。並且實現也相對簡單
- 缺點:資訊“噪音”較多,使用者不一定感興趣,內容獲取效率低
- 智慧排序:利用智慧演算法遮蔽掉違規的、使用者不感興趣的內容。推送使用者感興趣的資訊來吸引使用者
- 優點:投餵使用者感興趣資訊,使用者粘度很高,容易沉迷
- 缺點:如果演算法不精準,可能會起到反作用
在本例中的個人主頁,是基於關注的好友來做Feed流的,因此採用的是Timeline的模式。
該模式的實現方案有三種:
- 拉模式
- 推模式
- 拉推結合模式
(1)拉模式:也叫讀擴散
如上所示,每個博主都會發布自己的筆記,影片等資料,我們稱之為“訊息”。每個人都會有一個發件箱,傳送的訊息會發到各自的發件箱裡(訊息除了資料本身,還會附帶一個時間戳)。
粉絲會有一個收件箱,這個收件箱平常是空的,只有當他要去讀訊息的時候,才會從他關注的人的發件箱中,一個一個地拉取訊息到自己的收件箱中,然後按照資訊會按照時間排序,這樣他就可以按照時間去讀訊息了。
拉模式只有在讀訊息的時候才會拉取一個訊息副本,因此拉模式又叫讀擴散
- 優點:節省記憶體空間。因為收件箱讀完後就可以清理掉資料了,下一次要讀的時候再重新拉取訊息,訊息只儲存在發件人的發件箱中,比較節省記憶體空間。
- 缺點:延時高。使用者每次讀訊息的時候,都要重新拉取發件箱的訊息,然後做訊息排序,這一系列動作耗時長,讀取的延遲比較高。
(2)推模式:也叫寫擴散。
推模式沒有發件箱,博主釋出的訊息會直接推送到他的所有粉絲的收件箱中,然後收件箱中的訊息會按照時間進行排序。粉絲要讀訊息的時候,就可以直接讀取已經排序好的訊息,不需要臨時去拉取訊息。
因此這種模式的優點是延時低,但缺點是記憶體佔用高。
推模式模式在釋出訊息的時候,透過直接寫到收件箱中來進行訊息擴散,因此叫做寫擴散。
(3)推拉結合模式:也叫做讀寫混合,兼具推和拉兩種模式的優點。
-
在發件人角度來看
- 如果是普通使用者,將採用寫擴散方式,直接把資料寫入到粉絲的收件箱中去,因為普通使用者的粉絲關注量比較小,所以這樣做沒有壓力
- 如果是大V,則直接將資料先寫入到一份到發件箱裡邊去,然後再直接寫一份到活躍粉絲收件箱裡邊去
-
在收件人角度來看
- 如果是活躍粉絲,那麼大V和普通的人發的都會直接寫入到自己收件箱裡邊來
- 如果是普通粉絲,由於他們上線不是很頻繁,所以等他們上線時,再從發件箱裡邊去拉資訊
(4)三種模式對比
這裡採取推模式。
6.3.2基於推模式實現關注推送
6.3.2.1需求分析
- 修改新增探店筆記的業務,在儲存blog到資料庫的同時,推送到粉絲的收件箱
- 收件箱滿足可以根據時間戳排序,必須用Redis的資料結構實現
- 查詢收件箱資料時,可以實現分頁查詢
推模式是沒有發件箱的,使用者釋出的訊息會直接推送訊息到其粉絲收件箱中。在我們的業務中,訊息就是探店筆記,每當有人釋出探店筆記時,我們就應該將筆記推送到其粉絲的收件箱。
之前實現的探店筆記功能:當使用者釋出探店筆記時,會將筆記的資訊直接儲存到資料庫中。為了實現新功能——訊息推送,需要改造釋出探店筆記功能:儲存筆記到資料庫的同時,還要將筆記推送到粉絲的收件箱中。為了節省記憶體空間,推送訊息時,只需要推送一個blogId即可。粉絲去查詢筆記時,再根據id到資料庫中查詢筆記詳細資訊。
綜上,關注推送業務的關鍵,就是:
-
實現收件箱
-
推送訊息
最後是訊息的分頁功能:
Redis中的list和zset結構都可以實現排序,list結構可以按照腳標查詢;zset結構沒有腳標,但是可以按照排名(根據score)進行查詢,也可以實現分頁。那麼應該如何選擇呢?
Feed流的分頁問題:
因為Feed流中的資料會不斷更新,所以資料的腳標也在變化,因此不能使用傳統的分頁模式:
如下,t1時刻有10條訊息,它們按照時間排序。此時讀取的第一頁(假設為5條)為訊息10-6。在t2時刻釋出了一條新訊息,由於是按時間排序,此條訊息會被放到最上面。這時,當讀取第二頁的時候,由於分頁是從當前的第一條訊息(11)開始計算,因此讀取的就是6-2。
我們可以發現6被重複讀取了兩次,分頁出現了混亂,因此Feed不能採用傳統的分頁模式。
Feed流的滾動分頁:
所謂的滾動分頁,其實就是記錄每次查詢的最後一條,下一次查詢以該位置作為起始位置。第一次查詢時,起始位置記為無窮。
如下,t1時讀取了第一頁,記錄lastId為6;t2時釋出一條新訊息,經過排序放到了最新的位置。t3時刻讀取第二頁,由於記錄了lastId為6,就不會出現重複讀取的問題。
Feed流滾動分頁選用的資料結構:
回到之前的問題:list結構不支援這種滾動分頁,因為在list中查詢資料,只能按照腳標查詢(即只能實現傳統的分頁模式)。zset可以按照score值排序,但如果按照排名1,2,3,4....這樣查詢,就和list腳標查詢一樣了。
但是,zset還支援按照score值範圍進行查詢:在score中存放時間戳,每一次查詢時,記住最小的時間戳(即當前頁的最後一條訊息),這樣就相當於記錄了lastId;下次查詢時,去找比這個時間戳小的訊息,如此就可以實現滾動分頁了。
ZREVRANGEBYSCORE key max min [WITHSCORES] [LIMIT offset count]
summary: Return a range of members in a sorted set, by score, with scores ordered from high to low
since: 2.2.0
因此,我們可以選擇zset結構作為實現Feed流分頁的底層結構。
(在資料有變化的情況下,儘量不要使用List這種佇列去做分頁,而是使用SortedSet,例如排行榜)
6.3.2.2程式碼實現
需求1:修改新增探店筆記的業務,在儲存blog到資料庫的同時,推送到粉絲的收件箱
(1)修改IBlogService.java,增加方法宣告
Result saveBlog(Blog blog);
(2)修改BlogServiceImpl.java,實現saveBlog()方法
@Override
public Result saveBlog(Blog blog) {
//1.獲取登入使用者
UserDTO user = UserHolder.getUser();
blog.setUserId(user.getId());
//2.儲存探店筆記
boolean isSuccess = save(blog);
if (!isSuccess) {
return Result.fail("新增筆記失敗!");
}
//3.查詢筆記作者的所有粉絲 select * from tb_follow where follow_user_id=?
List<Follow> follows = followService.query()
.eq("follow_user_id", user.getId()).list();
//4.推送筆記id給所有粉絲
for (Follow follow : follows) {
//4.1獲取粉絲id
Long userId = follow.getUserId();
//4.2推送
String key = "feed:" + userId;
//key為粉絲id,value為blogId,score為時間戳
stringRedisTemplate.opsForZSet()
.add(key, blog.getId().toString(), System.currentTimeMillis());
}
//5.返回筆記id
return Result.ok(blog.getId());
}
(3)修改BlogController,新增介面
@PostMapping
public Result saveBlog(@RequestBody Blog blog) {
return blogService.saveBlog(blog);
}
(4)測試
可以看到當前id=1和id=1034的使用者都關注了id=2的使用者
我們登陸id=2的使用者,釋出一篇探店筆記:
在資料庫的tb_blog表中可以看到已經成功儲存筆記資料:blogId=11
在redis中,可以看到id=1和id=1034兩個使用者的收件箱中都分別收到了blogId=11的筆記推送(每個使用者都有一個收件箱):
測試透過。
需求2:在個人主頁的“關注”卡片中,查詢並展示推送的Blog資訊,並實現分頁查詢