day10-好友關注

一刀一個小西瓜發表於2023-05-03

功能05-好友關注

6.功能05-好友關注

6.1關注和取關

6.1.1需求分析

在探店圖文的詳情頁面中,可以關注釋出筆記的作者:

  1. 關注和取關:點選關注按鈕就會發出請求(上圖):http://127.0.0.1:8080/api/follow/2/true(2是關注的使用者id,最後面的引數可以是true或者false,取決於當前的關注狀態)
  2. 查詢當前關注狀態:(下圖)http://127.0.0.1:8080/api/follow/or/not/2,返回兩種狀態:true(已關注)或者false(未關注)。關注和取關功能根據關注狀態來實現。
  3. 整體流程:進入頁面詳情的時候,會自動查詢當前使用者對blog博主的關注狀態,根據關注狀態來懸渲染“關注”或“已關注”按鈕,根據關注狀態,使用者可以做相對的“關注”或者“取關”操作。
image-20230502201711496

需求:基於該表資料結構,實現兩個介面:

  1. 關注和取關介面
  2. 判斷是否關注的介面

關注是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)測試:重啟專案,進入部落格詳情

點選關注按鈕,提示關注成功:

image-20230502214936254

資料庫的tb_follow表新增一條新資料:

image-20230502215200169

再點選取消關注按鈕,提示取消關注成功:

image-20230502215027846

資料庫tb_follow刪除該條資料:

image-20230502215242963

6.2共同關注

6.2.1博主首頁資訊

點選博主頭像,可以進入博主首頁,檢視博主首頁的資訊:包括博主資訊,釋出的筆記,共同關注。

當點選進入博主首頁的時候,將會發出兩個請求:

  1. 請求博主的使用者資訊
  2. 請求博主釋出過的筆記資訊
image-20230503141405082

當點選共同關注的時候,就會發出請求查詢共同關注。

博主個人首頁依賴於兩個功能:

(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)重啟專案,點選某個博主首頁,顯示如下:

image-20230503143926514

6.2.2共同關注

需求:使用Redis合適的資料結構,實現共同關注功能。在博主個人頁面展示出當前使用者與博主的共同好友。

image-20230503144257628

我們可以使用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中的資料:

image-20230503153247870

資料庫:

image-20230503153414091

重新登入一個使用者,關注兩個博主:

image-20230503153737358

可以看到使用者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號使用者。

image-20230503161044716

和redis中的資料一致,測試透過。

6.3關注推送

6.3.1Feed流的Timeline模式

當我們關注了使用者後,如果這個使用者發了動態,那麼我們應該把這些資料推送給使用者,這個需求又叫做Feed流。關注推送也叫做Feed流,直譯為投餵。為使用者持續的提供“沉浸式”的體驗,透過無限下拉重新整理獲取新的資訊。

對於傳統的模式的內容解鎖:需要使用者去透過搜尋引擎或者是其他的方式去解鎖想要看的內容

image-20230503234805013

對於新型的Feed流的的效果:不需要使用者再去推送資訊,而是系統分析使用者到底想要什麼,然後直接把內容推送給使用者,從而使使用者能夠更加的節約時間,不用主動去尋找。

image-20230503234755264

Feed流產品有兩種常見模式:

  • Timeline:不做內容篩選,簡單地按照內容釋出時間排序,常用於好友或關注。例如朋友圈
    • 優點:資訊全面,不會有缺失。並且實現也相對簡單
    • 缺點:資訊“噪音”較多,使用者不一定感興趣,內容獲取效率低
  • 智慧排序:利用智慧演算法遮蔽掉違規的、使用者不感興趣的內容。推送使用者感興趣的資訊來吸引使用者
    • 優點:投餵使用者感興趣資訊,使用者粘度很高,容易沉迷
    • 缺點:如果演算法不精準,可能會起到反作用

在本例中的個人主頁,是基於關注的好友來做Feed流的,因此採用的是Timeline的模式。

image-20230503164523130

該模式的實現方案有三種:

  • 拉模式
  • 推模式
  • 拉推結合模式

(1)拉模式:也叫讀擴散

image-20230503164858817

如上所示,每個博主都會發布自己的筆記,影片等資料,我們稱之為“訊息”。每個人都會有一個發件箱,傳送的訊息會發到各自的發件箱裡(訊息除了資料本身,還會附帶一個時間戳)。

粉絲會有一個收件箱,這個收件箱平常是空的,只有當他要去讀訊息的時候,才會從他關注的人的發件箱中,一個一個地拉取訊息到自己的收件箱中,然後按照資訊會按照時間排序,這樣他就可以按照時間去讀訊息了。

拉模式只有在讀訊息的時候才會拉取一個訊息副本,因此拉模式又叫讀擴散

  • 優點:節省記憶體空間。因為收件箱讀完後就可以清理掉資料了,下一次要讀的時候再重新拉取訊息,訊息只儲存在發件人的發件箱中,比較節省記憶體空間。
  • 缺點:延時高。使用者每次讀訊息的時候,都要重新拉取發件箱的訊息,然後做訊息排序,這一系列動作耗時長,讀取的延遲比較高。

(2)推模式:也叫寫擴散。

image-20230503171740382

推模式沒有發件箱,博主釋出的訊息會直接推送到他的所有粉絲的收件箱中,然後收件箱中的訊息會按照時間進行排序。粉絲要讀訊息的時候,就可以直接讀取已經排序好的訊息,不需要臨時去拉取訊息。

因此這種模式的優點是延時低,但缺點是記憶體佔用高。

推模式模式在釋出訊息的時候,透過直接寫到收件箱中來進行訊息擴散,因此叫做寫擴散。

(3)推拉結合模式:也叫做讀寫混合,兼具推和拉兩種模式的優點。

image-20230503174933451
  • 在發件人角度來看

    • 如果是普通使用者,將採用寫擴散方式,直接把資料寫入到粉絲的收件箱中去,因為普通使用者的粉絲關注量比較小,所以這樣做沒有壓力
    • 如果是大V,則直接將資料先寫入到一份到發件箱裡邊去,然後再直接寫一份到活躍粉絲收件箱裡邊去
  • 在收件人角度來看

    • 如果是活躍粉絲,那麼大V和普通的人發的都會直接寫入到自己收件箱裡邊來
    • 如果是普通粉絲,由於他們上線不是很頻繁,所以等他們上線時,再從發件箱裡邊去拉資訊

(4)三種模式對比

image-20230503175308998

這裡採取推模式。

6.3.2基於推模式實現關注推送

6.3.2.1需求分析
  1. 修改新增探店筆記的業務,在儲存blog到資料庫的同時,推送到粉絲的收件箱
  2. 收件箱滿足可以根據時間戳排序,必須用Redis的資料結構實現
  3. 查詢收件箱資料時,可以實現分頁查詢

推模式是沒有發件箱的,使用者釋出的訊息會直接推送訊息到其粉絲收件箱中。在我們的業務中,訊息就是探店筆記,每當有人釋出探店筆記時,我們就應該將筆記推送到其粉絲的收件箱。

之前實現的探店筆記功能:當使用者釋出探店筆記時,會將筆記的資訊直接儲存到資料庫中。為了實現新功能——訊息推送,需要改造釋出探店筆記功能:儲存筆記到資料庫的同時,還要將筆記推送到粉絲的收件箱中。為了節省記憶體空間,推送訊息時,只需要推送一個blogId即可。粉絲去查詢筆記時,再根據id到資料庫中查詢筆記詳細資訊。

綜上,關注推送業務的關鍵,就是:

  1. 實現收件箱

  2. 推送訊息

最後是訊息的分頁功能:

Redis中的list和zset結構都可以實現排序,list結構可以按照腳標查詢;zset結構沒有腳標,但是可以按照排名(根據score)進行查詢,也可以實現分頁。那麼應該如何選擇呢?

Feed流的分頁問題:

因為Feed流中的資料會不斷更新,所以資料的腳標也在變化,因此不能使用傳統的分頁模式:

如下,t1時刻有10條訊息,它們按照時間排序。此時讀取的第一頁(假設為5條)為訊息10-6。在t2時刻釋出了一條新訊息,由於是按時間排序,此條訊息會被放到最上面。這時,當讀取第二頁的時候,由於分頁是從當前的第一條訊息(11)開始計算,因此讀取的就是6-2。

我們可以發現6被重複讀取了兩次,分頁出現了混亂,因此Feed不能採用傳統的分頁模式。

image-20230503201318731Feed流的滾動分頁:

所謂的滾動分頁,其實就是記錄每次查詢的最後一條,下一次查詢以該位置作為起始位置。第一次查詢時,起始位置記為無窮。

如下,t1時讀取了第一頁,記錄lastId為6;t2時釋出一條新訊息,經過排序放到了最新的位置。t3時刻讀取第二頁,由於記錄了lastId為6,就不會出現重複讀取的問題。

image-20230503202817319

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的使用者

image-20230503225824199

我們登陸id=2的使用者,釋出一篇探店筆記:

image-20230503230147397 image-20230503230222065

在資料庫的tb_blog表中可以看到已經成功儲存筆記資料:blogId=11

image-20230503230420878

在redis中,可以看到id=1和id=1034兩個使用者的收件箱中都分別收到了blogId=11的筆記推送(每個使用者都有一個收件箱):

image-20230503230722256 image-20230503230710798

測試透過。

需求2:在個人主頁的“關注”卡片中,查詢並展示推送的Blog資訊,並實現分頁查詢

image-20230503225302848

相關文章