【探花交友DAY 09】最近访客和FastDFS实现小视频功能

1. 访客功能

1.1 功能分析

在用户的首页,如果某一个用户查看过当前用户的详细信息,那么就会显示最近来访的访客。这一功能类似于QQ空间中的访客功能。

image-20230102214247007

访客功能实际上分为了三大部分

  • 在用户查询某一个用户的详情页面的时,需要记录用户的访问记录
  • 访客查询功能:会自动的查询出最近来访的前五个用户,并显示在主页上
  • 用户点击访客,会显示完整的访客列表

1.2 保存访问记录

我们先来看第一部分的保存访客记录功能。这一个功能很简单,只需要修改原来查看用户详情的代码即可。我们先来看一下用户访问记录表的表结构。

image-20230102214653201

需要注意的是,这里面有两个关于日期的字段。date记录的就是访问时间对应的时间戳,而visitDate记录的是访问的日期。同一个用户一天之内访问的记录我们只记录最新的一次,因此在代码中可以根据visitDate来判断当前用户是否今天已经访问。如果访问过则更新数据,否则插入数据。

代码实现具体如下:

/**
 * 查询佳人详情
 *
 * @param userId 用户id
 * @return
 */
public TodayBest getTodayBestById(Long userId) {
    // 1. 查询UserInfo表
    UserInfo userInfo = this.userInfoApi.getUserInfoById(userId);
    // 2. 查询RecommendUser表
    RecommendUser user = this.recommendUserApi.getRecommendUserByUserId(userId);

    // 记录访问记录
    Visitors visitors = new Visitors();
    visitors.setDate(System.currentTimeMillis());
    visitors.setVisitorUserId(UserHolder.getUserId());
    visitors.setUserId(userId);
    visitors.setVisitDate(new SimpleDateFormat("yyyyMMdd").format(new Date()));
    visitors.setScore(user.getScore());
    visitors.setFrom("圈子");

    this.visitorApi.save(visitors);
    // 3. 构建VO
    return TodayBest.init(userInfo, user);
}

1.3 查询最近访客

用户登陆后,可以在界面上看到自己最新的5个访客。这一点似乎直接从数据库中按照访问时间排序查询即可。但实际上,如果12点的时候有两个访客,用户点击了查询所有访客。那么13点的时候又有1个访客。如果仅仅按照访问时间排序查询的话,那么12点的访客也会被查询出来,但实际上用户点击查询所有访客后,那么12点的访客就不应该再出现了,而只显示13点的1个访客。

为了解决上述问题,我们可以将每一次用户点击了查询所有用户的时间戳保存起来,下一次查询只查询比保存的时间戳大的访客记录即可。在我们的项目中,我们把这个数据保存到了Redis中。Redis采用了hash的存储结构,其中每一个hashkey就是用户的id,里面存储的值就是时间戳。

image-20230102220312131

这样查询最近访客功能的具体业务如下:

  • 首先根据用户id到Redis中查询最后查询时间。
  • 如果查询不到,那么直接从数据库中读取最新访问的5条记录即可
  • 如果查询得到,那么就需要在查询到时候在加上一个条件,即记录的时间要大于查询时间。

image-20230102220645099

具体代码如下:

@Data
@NoArgsConstructor
@AllArgsConstructor
public class VideoVo implements Serializable {


    private Long userId;
    private String avatar; //头像
    private String nickname; //昵称

    private String id;
    private String cover; //封面
    private String videoUrl; //视频URL
    private String signature; //发布视频时,传入的文字内容


    private Integer likeCount; //点赞数量
    private Integer hasLiked; //是否已赞(1是,0否)
    private Integer hasFocus; //是否关注 (1是,0否)
    private Integer commentCount; //评论数量

    public static VideoVo init(UserInfo userInfo, Video item) {
        VideoVo vo = new VideoVo();
        //copy用户属性
        BeanUtils.copyProperties(userInfo,vo);  //source,target
        //copy视频属性
        BeanUtils.copyProperties(item,vo);
        vo.setCover(item.getPicUrl());
        vo.setId(item.getId().toHexString());
        vo.setSignature(item.getText());
        vo.setHasFocus(0);
        vo.setHasLiked(0);
        return vo;
    }
}
/**
 * 查询谁看过我
 *
 * @return
 */
@GetMapping("/visitors")
public ResponseEntity visitors() {
    List<VisitorsVo> voList = this.momentService.visitors();
    return ResponseEntity.ok(voList);
}
/**
 * 查询谁看过我
 *
 * @return
 */
public List<VisitorsVo> visitors() {
    // 1. 从Redis中获取数据 查看最后一次查看完整访客列表的时间
    // Redis中的key VISITORS 哈希key 用户id value为最后一次查看的时间戳
    String lastTime = (String) this.redisTemplate.opsForHash().get(VISITORS, UserHolder.getUserId().toString());
    Long time = lastTime != null ? Long.valueOf(lastTime) : null;
    // 2. 查询访客表
    List<Visitors> visitorsList = this.visitorApi.getVisitors(UserHolder.getUserId(), time);
    List<Long> ids = CollUtil.getFieldValues(visitorsList, "visitorUserId", Long.class);
    // 3. 查询访客的用户详情
    Map<Long, UserInfo> map = this.userInfoApi.getUserInfoByIds(ids, null);
    // 4. 封装数据
    List<VisitorsVo> voList = new ArrayList<>();
    for (Visitors visitors : visitorsList) {
        Long visitorUserId = visitors.getVisitorUserId();
        UserInfo userInfo = map.get(visitorUserId);
        if (userInfo != null) {
            voList.add(VisitorsVo.init(userInfo, visitors));
        }
    }

    return voList;
}
/**
 * 查询访客
 *
 * @param userId
 * @param time
 * @return
 */
@Override
public List<Visitors> getVisitors(Long userId, Long time) {
    Criteria criteria = Criteria.where("userId").is(userId);
    if (time != null) {
        criteria.and("date").gt(time);
    }
    Query query = new Query(criteria).limit(5).with(Sort.by(Sort.Order.desc("date")));
    return this.mongoTemplate.find(query, Visitors.class);
}

1.4 查询访客列表

这一个接口就非常好实现了,就是分页从数据库中查询访问记录,按照时间倒序排列即可。这里就不在赘述了。

2. FastDFS

2.1 使用背景

小视频功能类似于抖音、快手小视频的应用,用户可以上传小视频进行分享,也可以浏览查看别人分享的视频,并且可以对视频评论和点赞操作。

image-20230102222029186

小视频需要解决的一个问题是视频的存放问题。我们知道比起图片数据,视频占用的存储空间是非常大的,如果我们和图片一样利用第三方OSS来做的话,费用是非常恐怖的。因此在保存视频的时候,我们项目采用了FastDFS的分布式文件系统实现。

2.2 FastDFS 简介

FastDFS是分布式文件系统。使用 FastDFS很容易搭建一套高性能的文件服务器集群提供文件上传、下载等服务。

FastDFS 架构包括 Tracker server 和 Storage server。客户端请求 Tracker server 进行文件上传、下载,通过 Tracker server 调度最终由 Storage server 完成文件上传和下载。

Tracker server 作用是负载均衡和调度,通过 Tracker server 在文件上传时可以根据一些策略找到 Storage server 提供文件上传服务。可以将 tracker 称为追踪服务器或调度服务器。

Storage server 作用是文件存储,客户端上传的文件最终存储在 Storage 服务器上,Storage server 没有实现自己的文件系统而是利用操作系统的文件系统来管理文件。可以将storage称为存储服务器。每个 tracker 节点地位平等。收集 Storage 集群的状态。

image-20230102222836921

Storage 分为多个组,每个组之间保存的文件是不同的。每个组内部可以有多个成员,组成员内部保存的内容是一样的,组成员的地位是一致的,没有主从的概念。

FastDFS的上传流程如下:

  • Storage server 定时向tracker汇报自己的情况
  • 客户端发来向tracker发送文件上传请求
  • tracker查询可用的Storage server,并且将Storage server的IP和端口号返回给客户端
  • 客户端根据IP和端口号向Storage server上传文件
  • 文件上传完成后,Storage server返回文件的file_id,然后经过客户端拼接,最终形成可以访问的URL地址。

image-20230102223440647

2.3 在Springboot中使用FastDFS

找到tanhua-server的pom文件,打开fastdfs的依赖如下

<dependency>
    <groupId>com.github.tobato</groupId>
    <artifactId>fastdfs-client</artifactId>
    <version>1.26.7</version>
    <exclusions>
        <exclusion>
            <groupId>ch.qos.logback</groupId>
            <artifactId>logback-classic</artifactId>
        </exclusion>
    </exclusions>
</dependency>
application.yml
# ===================================================================
# 分布式文件系统FDFS配置
# ===================================================================
fdfs:
  so-timeout: 1500
  connect-timeout: 600
  #缩略图生成参数
  thumb-image:
    width: 150
    height: 150
  #TrackerList参数,支持多个
  tracker-list: 192.168.136.160:22122
  web-server-url: http://192.168.136.160:8888/
编写测试代码
@RunWith(SpringRunner.class)
@SpringBootTest(classes = TanhuaServerApplication.class)
public class TestFastDFS {

    //测试将文件上传到FastDFS文件系统中

    //从调度服务器获取,一个目标存储服务器,上传
    @Autowired
    private FastFileStorageClient client;

    @Autowired
    private FdfsWebServer webServer;// 获取存储服务器的请求URL

    @Test
    public void testFileUpdate() throws FileNotFoundException {
 		//1、指定文件
        File file = new File("D:\\1.jpg");
		//2、文件上传
        StorePath path = client.uploadFile(new FileInputStream(file),
                file.length(), "jpg", null);
		//3、拼接访问路径
        String url = webServer.getWebServerUrl() + path.getFullPath();
    }
}

4. 小视频功能

我们采用之前介绍的FastDFS来保存上传的小视频。小视频的表结构如下:

image-20230102223855626

这里由于已经做过动态的点赞,评论和喜欢了,小视频和动态的这几个功能实现起来是一样的,因此这里重点是小视频的发布。

4.1 发布视频

发布视频的业务逻辑如下:

  • 将用户上传的视频保存到FastDFS中
  • 将视频封面保存到阿里云OSS中。
  • 创建Video对象,保存到数据库中。

image-20230102224125082

具体代码如下:

/**
 * 发布小视频
 *
 * @param videoThumbnail 视频封面
 * @param videoFile      视频文件
 * @return
 */
@PostMapping
public ResponseEntity publish(MultipartFile videoThumbnail, MultipartFile videoFile) throws IOException {
    this.videoService.save(videoThumbnail, videoFile);
    return ResponseEntity.ok(null);
}
/**
 * 发布小视频
 *
 * @param videoThumbnail
 * @param videoFile
 * @throws IOException
 */
public void save(MultipartFile videoThumbnail, MultipartFile videoFile) throws IOException {
    if (videoFile.isEmpty() || videoThumbnail.isEmpty()) {
        // 文件为空
        throw new BusinessException(ErrorResult.error());
    }

    // 将缩略图保存到阿里云
    String imageUrl = this.ossTemplate.uploadFile(videoThumbnail.getOriginalFilename(), videoThumbnail.getInputStream());

    // 保存视频到Fast DFS
    String ext = videoFile.getOriginalFilename().substring(videoFile.getOriginalFilename().lastIndexOf(".") + 1);
    StorePath storePath = fastFileStorageClient.uploadFile(videoFile.getInputStream(), videoFile.getSize(), ext, null);
    String videoUrl = fdfsWebServer.getWebServerUrl() + storePath.getFullPath();

    // 创建Video对象
    Video video = new Video();
    video.setVideoUrl(videoUrl);
    video.setPicUrl(imageUrl);
    video.setUserId(UserHolder.getUserId());
    video.setText("这是我的第一条视频");

    String id = this.videoApi.save(video);

    if (StringUtils.isEmpty(id)) {
        throw new BusinessException(ErrorResult.error());
    }
}
@Override
public String save(Video video) {
    video.setVid(idWorker.getNextId("video"));
    video.setCreated(System.currentTimeMillis());
    return this.mongoTemplate.save(video).getId().toHexString();
}

在测试过程中,可能会出现文件大小超出限制的错误,我们只需要在配置文件中配置文件上传的大小限制即可。

Spring:
  servlet:
    multipart:
      max-file-size: 30MB
      max-request-size: 30MB

4.2 查询视频列表

在后台会根据用户的喜好,给用户推荐相关的视频,我们的任务就是展示推荐给用户的视频。具体的业务逻辑如下:

  • 首先从Redis中查询推荐的视频vid
  • 如果查询不到,则直接查询小视频表,按照时间倒序排列
  • 根据查询到的vid,查询到相关的视频。
  • 由于还需要显示用户头像等信息,因此还需要根据视频作者的用户id查询到用户详细信息。
  • 最终封装VO返回

image-20230102225001223

需要注意的是,在进行分页时。会存在一下的问题。假设目前Redis中有推荐的视频,用户进行分页查询,查询到第3页的时候,Redis中所有推荐的视频都读取完毕了,此时需要从数据库中分页查询。此时前端传递的页号为3,但是从数据库中肯定要从第一页查询。因此需要用户传递的page - Redis中所有推荐视频所占据的页数=数据库的页号

具体代码如下:

/**
 * 获取小视频列表
 *
 * @param page
 * @param pagesize
 * @return
 */
@GetMapping
public ResponseEntity getVideos(@RequestParam(defaultValue = "1") Integer page,
                                @RequestParam(defaultValue = "5") Integer pagesize) {
    PageResult pageResult = this.videoService.getVideos(page, pagesize);
    return ResponseEntity.ok(pageResult);
}
/**
 * 分页查询小视频列表
 *
 * @param page
 * @param pagesize
 * @return
 */
public PageResult getVideos(Integer page, Integer pagesize) {
    // 1. 获取用户id
    Long userId = UserHolder.getUserId();
    // 2. 根据用户Id 到redis中查询数据 判断推荐信息是否存在
    String key = Constants.VIDEOS_RECOMMEND + userId;
    String recommendStr = redisTemplate.opsForValue().get(key);
    List<Video> videoList = new ArrayList<>();
    int redisTotalPage = 0;
    if (StringUtils.isNotEmpty(recommendStr)) {
        String[] split = recommendStr.split(",");
        // 判断是否还需要分页
        if ((page - 1) * pagesize < split.length) {
            List<Long> vids = Arrays.stream(split)
                    .skip((page - 1) * pagesize)
                    .limit(pagesize)
                    .map(e -> Long.valueOf(e))
                    .collect(Collectors.toList());

            // 根据pids查询出所有的movement
            videoList = this.videoApi.getVideoByPids(vids);

        }
        redisTotalPage = PageUtil.totalPage(split.length, pagesize);
    }
    if (videoList.isEmpty()) {
        // 到MongoDB中按照时间顺序分页查找
        videoList = this.videoApi.getVideos(page - redisTotalPage, pagesize);
    }

    // 查询视频作者的详细信息
    List<Long> ids = CollUtil.getFieldValues(videoList, "userId", Long.class);
    Map<Long, UserInfo> map = this.userInfoApi.getUserInfoByIds(ids, null);
    // 封装对象
    List<VideoVo> voList = new ArrayList<>();
    for (Video video : videoList) {
        Long id = video.getUserId();
        UserInfo userInfo = map.get(id);
        if (userInfo != null) {
            voList.add(VideoVo.init(userInfo, video));
        }
    }
    return new PageResult(page, pagesize, 0, voList);
}

4.3 关注/取消关注视频作者

这一部分表简单,只涉及到对于user_focus表的操作。具体的表结构如下:

image-20230102225823329

接口文档如下:

image-20230102225845716

具体代码如下:

@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "focus_user")
public class FocusUser implements java.io.Serializable{

    private static final long serialVersionUID = 3148619072405056052L;

    private ObjectId id; //主键id
    private Long userId; //用户id    106
    private Long followUserId; //关注的用户id   1
    private Long created; //关注时间
}
/**
 * 关注小视频作者
 * @param userId
 * @return
 */
@PostMapping("/{id}/userFocus")
public ResponseEntity focus(@PathVariable(name = "id") Long userId) {
    this.videoService.focus(userId);
    return ResponseEntity.ok(null);
}

/**
 * 取消关注小视频作者
 * @param userId
 * @return
 */
@PostMapping("/{id}/userUnFocus")
public ResponseEntity unfocus(@PathVariable(name = "id") Long userId) {
    this.videoService.unfocus(userId);
    return ResponseEntity.ok(null);
}
/**
 * 关注小视频作者
 *
 * @param userId
 */
public void focus(Long userId) {
    FocusUser focusUser = new FocusUser();
    focusUser.setUserId(UserHolder.getUserId());
    focusUser.setFollowUserId(userId);
    focusUser.setCreated(System.currentTimeMillis());
    this.focusUserApi.save(focusUser);

    // 保存到Redis
    String key = Constants.FOCUS_USER + UserHolder.getUserId();
    String hashKey = String.valueOf(userId);

    this.redisTemplate.opsForHash().put(key, hashKey, "1");
}

/**
 * 取消关注小视频作者
 *
 * @param userId
 */
public void unfocus(Long userId) {
    FocusUser focusUser = new FocusUser();
    focusUser.setUserId(UserHolder.getUserId());
    focusUser.setFollowUserId(userId);
    this.focusUserApi.delete(focusUser);

    // 保存到Redis
    String key = Constants.FOCUS_USER + UserHolder.getUserId();
    String hashKey = String.valueOf(userId);

    this.redisTemplate.opsForHash().delete(key, hashKey);
}

需要注意的是,我们在保存用户关注的时候,也将用户关注的作者id保存到了Redis中,方便查询

4.4 Spring Cache改造

在小视频的查询中,可能查询的次数会比较频繁,因此应该引入缓存来降低数据库的压力。这里采用Spring Cache的方式对原有代码进行改造。

在发布视频的方法上添加一下内容:

@CacheEvict(value="video",allEntries = true)

在查询视频列表的方法上添加一下内容:

@Cacheable(
        value = "video",
        key = "T(com.tanhua.server.interceptor.UserHolder).getUserId() + '_' + #page +'_' + #pagesize"
)

具体有关Spring Cache的讲解看这一篇文章:https://blog.csdn.net/lyx7762/article/details/128486041

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值