前言: 本篇博客 Feed服务 是美食社交项目内第六篇文章, 是写在好友服务之后的一篇博客,大家可以先尝试阅读并编写第五篇–美食社交好友服务的文章之后再阅读此文,效果更佳;因为两者是有联系的!
1. 概念
移动互联网时代,Feed流产品非常常见,比如 朋友圈,微博,非常典型的Feed流产品,还有图片分享网站Pinterest,花瓣网等又是另一种形式的Feed流产品。除此之外,很多App的都会有一个模块,要么叫动态,要么叫消息广场,这些也是Feed流产品,可以说,Feed流产品是遍布天下所有的App中。
Feed:Feed流中的每一条状态或者消息都是Feed,比如朋友圈中的一个状态就是一个Feed,微博中的一条微博就是一个Feed。
Feed流:持续更新并呈现给用户内容的信息流。每个人的朋友圈,微博关注页等等都是一个Feed流。
Timeline:Timeline其实是一种Feed流的类型,微博,朋友圈都是Timeline类型的Feed流,但是由于Timeline类型出现最早,使用最广泛,最为人熟知,有时候也用Timeline来表示Feed流。
关注页Timeline:展示其他人Feed消息的页面,比如朋友圈,微博的首页等。
个人页Timeline:展示自己发送过的Feed消息的页面,比如微信中的相册,微博的个人页等。
2. 特征
多账号内容流:Feed流系统中肯定会存在成千上万的账号,账号之间可以关注,取关,加好友和拉黑等操作。只要满足这一条,那么就可以当做Feed流系统来设计。
非稳定的账号关系:由于存在关注,取关等操作,所以系统中的用户之间的关系就会一直在变化,是一种非稳定的状态。
读写比例100:1:读写严重不平衡,读多写少,一般读写比例在10:1,甚至100:1以上。
消息必达性要求高:比如发送了一条朋友圈后,结果部分朋友看到了,部分朋友没看到,如果偏偏女朋友没看到,那么可能会产生很严重的感情矛盾,后果很严重。
3. 分类
Timeline:按发布的时间顺序排序,先发布的先看到,后发布的排列在最顶端,类似于微信朋友圈,微博等。这也是一种最常见的形式。产品如果选择Timeline类型,那么就是认为 Feed流中的Feed不多,但是每个Feed都很重要,都需要用户看到 。
Rank:按某个非时间的因子排序,一般是按照用户的喜好度排序,用户最喜欢的排在最前面,次喜欢的排在后面。这种一般假定用户可能看到的Feed非常多,而用户花费在这里的时间有限,那么就为用户选择出用户最想看的Top N结果,场景的应用场景有图片分享、新闻推荐类、商品推荐等。
4. 实现
解决Feed流最核心的两个问题:一个是存储,另一个是推送
4.1 存储
因为该项目中Feed比较简单,就类比于空间说说,因此可以使用MySQL关系型数据库存储,如果对于数据结构比较复杂的Feed流就要使用NoSQL数据库,这样存储更方便与高效,比如MongoDB或者HBase
4.2 实现
在推送方案里面的,有三种方案,分别是:
拉方案:也称为 读扩散 ,用户主动去拉取关注人的Feed内容
推方案:也成为 写扩散 ,当用户添加Feed时,会自动将Feed通知给关注的人(优选)
使用Redis Sorted Sets(方便按时间排序Timeline)维护粉丝的Feed集合,当博主添加Feed时,主动将内容推送到粉丝的Feed集合中,这样用户可以很方便快速从集合中读取
推拉结合:比如微博,大部分用户的账号关系都是几百个,但是有个别用户是1000万以上才使用。
5. 表结构设计
CREATE TABLE `t_feeds` (
`id` int(11) NOT NULL AUTO_INCREMENT ,
`content` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL
DEFAULT NULL COMMENT '内容' ,
`fk_diner_id` int(11) NULL DEFAULT NULL ,
`praise_amount` int(11) NULL DEFAULT NULL COMMENT '点赞数量' ,
`comment_amount` int(11) NULL DEFAULT NULL COMMENT '评论数量' ,
`fk_restaurant_id` int(11) NULL DEFAULT NULL ,
`create_date` datetime NULL DEFAULT NULL ,
`update_date` datetime NULL DEFAULT NULL ,
`is_valid` tinyint(1) NULL DEFAULT NULL ,
PRIMARY KEY (`id`)
)
ENGINE=InnoDB
DEFAULT CHARACTER SET=utf8mb4 COLLATE=utf8mb4_general_ci
AUTO_INCREMENT=14
ROW_FORMAT=COMPACT
;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
6. 构建模块
创建 fs_feeds 子模块
POM.XML导入依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>food_social</artifactId>
<groupId>com.itkaka</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>fs_feeds</artifactId>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
</properties>
<dependencies>
<!-- eureka client -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<!-- spring web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- mysql -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- sprin data redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- mybatis -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
</dependency>
<!-- commons 公共项目 -->
<dependency>
<groupId>com.itkaka</groupId>
<artifactId>fs_commons</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>
<!-- 集中定义项目所需插件 -->
<build>
<plugins>
<!-- spring boot maven 项目打包插件 -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
配置文件
server:
port: 8095 # 端口
spring:
application:
name: fs_feeds # 应用名
# 数据库
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
username: root
password: root
url: jdbc:mysql://127.0.0.1:3306/db_lezijie_food_social?serverTimezone=Asia/Shanghai&characterEncoding=utf8&useUnicode=true&useSSL=false
# Redis
redis:
port: 6379
host: 192.168.10.101
timeout: 3000
password: 123456
database: 2
# Swagger
swagger:
base-package: com.imooc.feeds
title: 美食社交Feed流API接口文档
# 配置 Eureka Server 注册中心
eureka:
instance:
prefer-ip-address: true
instance-id: ${spring.cloud.client.ip-address}:${server.port}
client:
service-url:
defaultZone: http://localhost:8090/eureka/
service:
name:
fs-oauth-server: http://fs_oauth/
fs-diners-server: http://fs_diners/
fs-follow-server: http://fs_follow/
mybatis:
configuration:
map-underscore-to-camel-case: true # 开启驼峰映射
# 配置日志
logging:
pattern:
console: '%d{2100-01-01 13:14:00.666} [%thread] %-5level %logger{50} - %msg%n'
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
7. 编写实体类
package com.itkaka.feeds.model.pojo;
import com.itkaka.commons.model.base.BaseModel;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Getter;
import lombok.Setter;
@ApiModel(description = "Feed信息类")
@Getter
@Setter
public class Feeds extends BaseModel {
@ApiModelProperty("内容")
private String content;
@ApiModelProperty("食客")
private Integer fkDinerId;
@ApiModelProperty("点赞")
private int praiseAmount;
@ApiModelProperty("评论")
private int commentAmount;
@ApiModelProperty("关联的餐厅")
private Integer fkRestaurantId;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
7.1 配置类以及全局异常处理
Redis配置类和Rest配置类,还有全局异常处理。 同之前模块一样,拷贝即可
7.2 此时项目结构如下图:
8. 添加Feed功能
8.1 FeedsMapper添加操作
package com.itkaka.feeds.mapper;
import com.itkaka.feeds.model.pojo.Feeds;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Options;
/**
* Feed服务 Mapper
*/
public interface FeedsMapper {
// Feed 添加
@Insert("insert into t_feeds (content, fk_diner_id, praise_amount, comment_amount,fk_restaurant_id, create_date, update_date, is_valid)" +
" values (#{content}, #{fkDinerId}, #{praiseAmount}, #{commentAmount},#{fkRestaurantId}, now(), now(), 1)")
@Options(useGeneratedKeys = true,keyProperty = "id")
int save(Feeds feeds);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
8.2 RedisKeyConstant
package com.itkaka.commons.constant;
import lombok.Getter;
@Getter
public enum RedisKeyConstant {
verify_code("verify_code:", "验证码"),
seckill_vouchers("seckill_vouchers:", "秒杀券的key"),
lock_key("lockby:", "分布式锁的key"),
following("following:", "关注集合Key"),
followers("followers:", "粉丝集合Key"),
// 新增这部分
following_feeds("following_feeds:", "我关注的好友的FeedsKey"),
;
private String key;
private String desc;
RedisKeyConstant(String key, String desc) {
this.key = key;
this.desc = desc;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
8.3 FeedsService中添加业务逻辑
package com.itkaka.feeds.service;
import cn.hutool.core.bean.BeanUtil;
import com.itkaka.commons.constant.ApiConstant;
import com.itkaka.commons.constant.RedisKeyConstant;
import com.itkaka.commons.exception.ParameterException;
import com.itkaka.commons.model.domain.ResultInfo;
import com.itkaka.commons.model.vo.SignInDinerInfo;
import com.itkaka.commons.utils.AssertUtil;
import com.itkaka.feeds.mapper.FeedsMapper;
import com.itkaka.feeds.model.pojo.Feeds;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;
import javax.annotation.Resource;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Feed服务 Service
*/
@Service
public class FeedsService {
@Value("${service.name.fs_oauth-server}")
private String oauthServerName;
@Value("${service.name.fs_diners-server}")
private String dinersServerName;
@Value("${service.name.fs_follow-server}")
private String followServerName;
@Resource
private RestTemplate restTemplate;
@Resource
private RedisTemplate redisTemplate;
@Resource
private FeedsMapper feedsMapper;
/**
* 添加 Feed
*
* @param feeds
* @param accessToken
*/
@Transactional(rollbackFor = Exception.class)
public void create(Feeds feeds,String accessToken){
// 非空校验
AssertUtil.isNotEmpty(feeds.getContent(),"其内容不为空,请输入内容!");
AssertUtil.isTrue(feeds.getContent().length() > 255,"输入内容太多,请重新输入");
//获取登录用户信息
SignInDinerInfo dinerInfo = loadSignInDinerInfo(accessToken);
// Feed 关联用户信息
feeds.setFkDinerId(dinerInfo.getId());
// 添加 Feed
int count = feedsMapper.save(feeds);
AssertUtil.isTrue(count == 0,"添加失败!");
//推送到粉丝列表 首先拿我的粉丝列表
List<Integer> followers = findFollowers(dinerInfo.getId());
// 推送 Feeds, 以时间作为分数存储到 Redis ZSet
long now = System.currentTimeMillis();
followers.forEach(follower -> {
String key = RedisKeyConstant.following_feeds.getKey() + follower;
redisTemplate.opsForZSet().add(key,feeds.getId(),now);
});
}
//获取粉丝列表篇
private List<Integer> findFollowers(Integer dinerId) {
String url = followServerName + "followers/" +dinerId;
ResultInfo resultInfo = restTemplate.getForObject(url,ResultInfo.class);
if (resultInfo.getCode() != ApiConstant.SUCCESS_CODE){
throw new ParameterException(resultInfo.getCode(),resultInfo.getMessage());
}
return (List<Integer>) resultInfo.getData();
}
//获取登录用户信息
private SignInDinerInfo loadSignInDinerInfo(String accessToken) {
// 令牌是否存在
AssertUtil.mustLogin(accessToken);
// 拼接 URL
String url = oauthServerName + "user/me?access_token={accessToken}";
// 发送请求
ResultInfo resultInfo = restTemplate.getForObject(url,ResultInfo.class,accessToken);
if (resultInfo.getCode() != ApiConstant.SUCCESS_CODE){
throw new ParameterException(resultInfo.getCode(),resultInfo.getMessage());
}
SignInDinerInfo dinerInfo = BeanUtil.fillBeanWithMap((LinkedHashMap) resultInfo.getData(),new SignInDinerInfo(),false);
return dinerInfo;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
8.4 FeedsController方法
package com.itkaka.feeds.controller;
import com.itkaka.commons.model.domain.ResultInfo;
import com.itkaka.commons.utils.ResultInfoUtil;
import com.itkaka.feeds.model.pojo.Feeds;
import com.itkaka.feeds.service.FeedsService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
/**
* Feed服务 Controller
*/
@RestController
public class FeedsController {
@Resource
private FeedsService feedsService;
@Resource
private HttpServletRequest request;
/**
* 添加 Feed
*
* @param feeds
* @param access_token
* @return
*/
@PostMapping
public ResultInfo<String> create(@RequestBody Feeds feeds, String access_token) {
feedsService.create(feeds, access_token);
return ResultInfoUtil.buildSuccess(request.getServletPath(), "添加成功");
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
8.5 网关配置
:::info
注意配置文件的缩进
:::
- id: fs_feeds
uri: lb://fs-feeds
predicates:
- Path=/feeds/**
filters:
- StripPrefix=1
1
2
3
4
5
6
server:
port: 80 # 端口
spring:
application:
name: fs_gateway # 应用名
cloud:
gateway:
discovery:
locator:
enabled: true # 开启配置注册中心进行路由功能
lower-case-service-id: true # 将服务名称转小写
routes:
- id: fs_diners
uri: lb://fs_diners
predicates:
- Path=/diners/**
filters:
- StripPrefix=1
- id: fs_oauth
uri: lb://fs_oauth
predicates:
- Path=/auth/**
filters:
- StripPrefix=1
- id: fs_seckill
uri: lb://fs_seckill
predicates:
- Path=/seckill/**
filters:
- StripPrefix=1
- id: fs_follow
uri: lb://fs_follow
predicates:
- Path=/follow/**
filters:
- StripPrefix=1
- id: fs_feeds
uri: lb://fs_feeds
predicates:
- Path=/feeds/**
filters:
- StripPrefix=1
secure:
ignore:
urls: # 配置白名单路径
- /actuator/**
- /auth/oauth/**
- /diners/signin
- /diners/send
- /diners/checkPhone
- /diners/register
- /seckill/add
- /restaurants/detail/**
# 配置 Eureka Server 注册中心
eureka:
instance:
# 开启 ip 注册
prefer-ip-address: true
instance-id: ${spring.cloud.client.ip-address}:${server.port}
client:
service-url:
defaultZone: http://localhost:8090/eureka/
# 配置日志
logging:
pattern:
console: '%d{2100-01-01 13:14:00.666} [%thread] %-5level %logger{50} - %msg%n'
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
获取粉丝列表
通过关注微服务获取用户的粉丝列表ID
fs-follow微服务中添加获取粉丝列表的方法
fs-follow的FollowService中添加获取粉丝方法
/**
* 获取粉丝列表
*
* @param dinerId
* @return
*/
public Set<Integer> findFollowers(Integer dinerId) {
AssertUtil.isNotNull(dinerId, "请选择查看的用户");
Set<Integer> followers = redisTemplate.opsForSet()
.members(RedisKeyConstant.followers.getKey() + dinerId);
return followers;
}
1
2
3
4
5
6
7
8
9
10
11
12
fs-follow的FollowController中添加
/**
* 获取粉丝列表
*
* @param dinerId
* @return
*/
@GetMapping("followers/{dinerId}")
public ResultInfo findFollowers(@PathVariable Integer dinerId) {
Set<Integer> followers = followService.findFollowers(dinerId);
return ResultInfoUtil.buildSuccess(request.getServletPath(), followers);
}
1
2
3
4
5
6
7
8
9
10
11
测试
访问:http://localhost/feeds?access_token=a37b83ee-81e7-4bf1-8255-d3ea83bc327f
9. 删除 Feed
9.1 FeedsMapper操作
// 逻辑删除 Feed
@Update("update t_feeds set is_valid = 0, update_date = now() where id = #{id} and is_valid = 1")
int delete(@Param("id") Integer id);
1
2
3
9.2 FeedsService编写删除逻辑
package com.itkaka.feeds.service;
import cn.hutool.core.bean.BeanUtil;
import com.itkaka.commons.constant.ApiConstant;
import com.itkaka.commons.constant.RedisKeyConstant;
import com.itkaka.commons.exception.ParameterException;
import com.itkaka.commons.model.domain.ResultInfo;
import com.itkaka.commons.model.vo.SignInDinerInfo;
import com.itkaka.commons.utils.AssertUtil;
import com.itkaka.feeds.mapper.FeedsMapper;
import com.itkaka.feeds.model.pojo.Feeds;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;
import javax.annotation.Resource;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Feed服务 Service
*/
@Service
public class FeedsService {
@Value("${service.name.fs_oauth-server}")
private String oauthServerName;
@Value("${service.name.fs_diners-server}")
private String dinersServerName;
@Value("${service.name.fs_follow-server}")
private String followServerName;
@Resource
private RestTemplate restTemplate;
@Resource
private RedisTemplate redisTemplate;
@Resource
private FeedsMapper feedsMapper;
/**
* 添加 Feed
*
* @param feeds
* @param accessToken
*/
@Transactional(rollbackFor = Exception.class)
public void create(Feeds feeds,String accessToken){
// 非空校验
AssertUtil.isNotEmpty(feeds.getContent(),"其内容不为空,请输入内容!");
AssertUtil.isTrue(feeds.getContent().length() > 255,"输入内容太多,请重新输入");
//获取登录用户信息
SignInDinerInfo dinerInfo = loadSignInDinerInfo(accessToken);
// Feed 关联用户信息
feeds.setFkDinerId(dinerInfo.getId());
// 添加 Feed
int count = feedsMapper.save(feeds);
AssertUtil.isTrue(count == 0,"添加失败!");
//推送到粉丝列表 首先拿我的粉丝列表
List<Integer> followers = findFollowers(dinerInfo.getId());
// 推送 Feeds, 以时间作为分数存储到 Redis ZSet
long now = System.currentTimeMillis();
followers.forEach(follower -> {
String key = RedisKeyConstant.following_feeds.getKey() + follower;
redisTemplate.opsForZSet().add(key,feeds.getId(),now);
});
}
//获取粉丝列表篇
private List<Integer> findFollowers(Integer dinerId) {
String url = followServerName + "followers/" +dinerId;
ResultInfo resultInfo = restTemplate.getForObject(url,ResultInfo.class);
if (resultInfo.getCode() != ApiConstant.SUCCESS_CODE){
throw new ParameterException(resultInfo.getCode(),resultInfo.getMessage());
}
return (List<Integer>) resultInfo.getData();
}
//获取登录用户信息
private SignInDinerInfo loadSignInDinerInfo(String accessToken) {
// 令牌是否存在
AssertUtil.mustLogin(accessToken);
// 拼接 URL
String url = oauthServerName + "user/me?access_token={accessToken}";
// 发送请求
ResultInfo resultInfo = restTemplate.getForObject(url,ResultInfo.class,accessToken);
if (resultInfo.getCode() != ApiConstant.SUCCESS_CODE){
throw new ParameterException(resultInfo.getCode(),resultInfo.getMessage());
}
SignInDinerInfo dinerInfo = BeanUtil.fillBeanWithMap((LinkedHashMap) resultInfo.getData(),new SignInDinerInfo(),false);
return dinerInfo;
}
/**
* 删除 Feed
*
* @param id
* @param accessToken
*/
@Transactional(rollbackFor = Exception.class)
public void delete(Integer id,String accessToken){
// 参数校验
AssertUtil.isTrue(id == null || id <1,"请选择要删除的Feed");
// 获取登录用户
SignInDinerInfo dinerInfo = loadSignInDinerInfo(accessToken);
// 获取 Feed
Feeds feeds = feedsMapper.findById(id);
// 判断 Feed 是否已被删除 且 只能删除自己的 Feed
AssertUtil.isTrue(feeds == null,"该 Feed 已被删除!");
AssertUtil.isTrue(!feeds.getFkDinerId().equals(dinerInfo.getId()),"只能删除自己的Feed");
// 执行删除操作
int count = feedsMapper.delete(id);
if (count == 0){
return;
}
// 将内容从粉丝关注的 Feed 集合中删除 先拿我的粉丝列表
List<Integer> followers = findFollowers(dinerInfo.getId());
followers.forEach(follower ->{
String key = RedisKeyConstant.following_feeds.getKey() + follower;
redisTemplate.opsForZSet().remove(key,feeds.getId());
});
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
由于调用查询接口,因此需要在持久层新建查询语句,完善之后的mapper如下
package com.itkaka.feeds.mapper;
import com.itkaka.feeds.model.pojo.Feeds;
import org.apache.ibatis.annotations.*;
/**
* Feed服务 Mapper
*/
public interface FeedsMapper {
// Feed 添加
@Insert("insert into t_feeds (content, fk_diner_id, praise_amount, comment_amount,fk_restaurant_id, create_date, update_date, is_valid)" +
" values (#{content}, #{fkDinerId}, #{praiseAmount}, #{commentAmount},#{fkRestaurantId}, now(), now(), 1)")
@Options(useGeneratedKeys = true,keyProperty = "id")
int save(Feeds feeds);
// 逻辑删除 Feed
@Update("update t_feeds set is_valid = 0, update_date = now() where id = #{id} and is_valid = 1")
int delete(@Param("id") Integer id);
// 查询 Feed
@Select("select id,content, fk_diner_id, praise_amount, comment_amount,fk_restaurant_id, create_date, update_date, is_valid" +
" from t_feeds where id = #{id} and is_valid = 1")
Feeds findById(@Param("id") Integer id);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
9.3 FeedsController编写API方法
package com.itkaka.feeds.controller;
import com.itkaka.commons.model.domain.ResultInfo;
import com.itkaka.commons.utils.ResultInfoUtil;
import com.itkaka.feeds.model.pojo.Feeds;
import com.itkaka.feeds.service.FeedsService;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
/**
* Feed服务 Controller
*/
@RestController
public class FeedsController {
@Resource
private FeedsService feedsService;
@Resource
private HttpServletRequest request;
/**
* 添加 Feed
*
* @param feeds
* @param access_token
* @return
*/
@PostMapping
public ResultInfo<String> create(@RequestBody Feeds feeds, String access_token) {
feedsService.create(feeds, access_token);
return ResultInfoUtil.buildSuccess(request.getServletPath(), "添加成功");
}
/**
* 删除 Feed
*
* @param id
* @param access_token
* @return
*/
@DeleteMapping("{id}")
public ResultInfo<String> delete(@PathVariable Integer id, String access_token) {
feedsService.delete(id, access_token);
return ResultInfoUtil.buildSuccess(request.getServletPath(), "删除成功");
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
9.4 Postman测试
访问:http://localhost/feeds/15?access_token=a37b83ee-81e7-4bf1-8255-d3ea83bc327f
10. [修改Feed] 关注/取关时处理用户
当A用户关注B用户时,那么要实时的将B的所有Feed推送到A用户的Feed集合中,同样如果A用户取关B用户,那么要将B用户所有的Feed从A用户的Feed集合中移除。
10.1 FeedsMapper查询方法
/**
* 根据食客 ID 查询 Feed
*/
@Select("SELECT id, content, update_date FROM t_feeds " +
"WHERE fk_diner_id = #{dinerId} AND is_valid = 1")
List<Feeds> findByDinerId(@Param("dinerId") Integer dinerId);
1
2
3
4
5
6
10.2 Service层处理添加和移除逻辑
/**
* 变更 Feed
*
* @param followingDinerId 关注的好友的 ID
* @param accessToken 登录用户的 token
* @param type 1 关注 0 取关
*/
@Transactional(rollbackFor = Exception.class)
public void addFollowingFeeds(Integer followingDinerId, String accessToken, int type) {
// 参数校验
AssertUtil.isTrue(followingDinerId == null || followingDinerId < 1,
"请选择关注或取关的好友");
// 获取登录用户
SignInDinerInfo dinerInfo = loadSignInDinerInfo(accessToken);
// 获取关注/取关食客所有 Feed
List<Feeds> followingFeeds = feedsMapper.findByDinerId(followingDinerId);
if (followingFeeds == null || followingFeeds.isEmpty()) {
return;
}
// 我关注的好友的 FeedsKey
String key = RedisKeyConstant.following_feeds.getKey() + dinerInfo.getId();
// 取关
if (type == 0) {
List<Integer> feedIds = followingFeeds.stream()
.map(feed -> feed.getId())
.collect(Collectors.toList());
redisTemplate.opsForZSet().remove(key, feedIds.toArray(new Integer[]{}));
} else {
// 关注
Set<ZSetOperations.TypedTuple> typedTuples = followingFeeds.stream()
.map(feed -> new DefaultTypedTuple<>(feed.getId(), (double) feed.getUpdateDate().getTime()))
.collect(Collectors.toSet());
redisTemplate.opsForZSet().add(key, typedTuples);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
10.3 在Controller层变API方法
/**
* 变更 Feed
*
* @param followingDinerId
* @param access_token
* @param type
* @return
*/
@PostMapping("updateFollowingFeeds/{followingDinerId}")
public ResultInfo<String> addFollowingFeeds(@PathVariable Integer followingDinerId,
String access_token, int type) {
feedsService.addFollowingFeeds(followingDinerId, access_token, type);
return ResultInfoUtil.buildSuccess(request.getServletPath(), "操作成功");
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
10.4 follow微服务中的关注和取关接口中添加Feed操作的方法
service:
name:
fs-oauth-server: http://fs_oauth/
fs-diners-server: http://fs_diners/
fs-feeds-server: http://fs_feeds/
1
2
3
4
5
在以下地方进行修改:
@Value("${service.name.fs-feeds-server}")
private String feedsServerName;
/**
* 发送请求添加或者移除关注人的Feed列表
*
* @param followDinerId 关注好友的ID
* @param accessToken 当前登录用户token
* @param type 0=取关 1=关注
*/
private void sendSaveOrRemoveFeed(Integer followDinerId, String accessToken, int type) {
String feedsUpdateUrl = feedsServerName + "updateFollowingFeeds/" + followDinerId + "?access_token=" + accessToken;
// 构建请求头
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
// 构建请求体(请求参数)
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("type", type);
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body,headers);
restTemplate.postForEntity(feedsUpdateUrl, entity, ResultInfo.class);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
以下地方也需修改完善:
// 如果没有关注信息,且要进行关注操作
if (follow == null && isFollowed == 1) {
// 添加关注信息
int count = followMapper.save(dinerInfo.getId(), followDinerId);
// 添加关注列表到 Redis
if (count == 1) {
addToRedisSet(dinerInfo.getId(), followDinerId);
// 保存 Feed
sendSaveOrRemoveFeed(followDinerId, accessToken, 1);
}
return ResultInfoUtil.build(ApiConstant.SUCCESS_CODE,"关注成功", path, "关注成功");
}
// 如果有关注信息,且目前处于取关状态,且要进行关注操作
if (follow != null && follow.getIsValid() == 0 && isFollowed == 1) {
// 重新关注
int count = followMapper.update(follow.getId(), isFollowed);
// 添加关注列表
if (count == 1) {
addToRedisSet(dinerInfo.getId(), followDinerId);
// 保存 Feed
sendSaveOrRemoveFeed(followDinerId, accessToken, 1);
}
return ResultInfoUtil.build(ApiConstant.SUCCESS_CODE, "关注成功", path, "关注成功");
}
// 如果有关注信息,且目前处于关注中状态,且要进行取关操作
if (follow != null && follow.getIsValid() == 1 && isFollowed == 0) {
// 取关
int count = followMapper.update(follow.getId(), isFollowed);
if (count == 1) {
// 移除 Redis 关注列表
removeFromRedisSet(dinerInfo.getId(), followDinerId);
// 移除 Feed
sendSaveOrRemoveFeed(followDinerId, accessToken, 0);
}
return ResultInfoUtil.build(ApiConstant.SUCCESS_CODE, "成功取关", path, "成功取关");
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
Postman测试
访问:http://localhost/follow/2?isFollowed=1&access_token=116af711-645c-4ce9-bc32-cd304bdda020
先让 ID 5 用户关注 ID 2 用户。
访问:http://localhost/follow/2?isFollowed=0&access_token=116af711-645c-4ce9-bc32-cd304bdda020
然后让 ID 5 用户取关 ID 2 用户。
11. 查询 Feed
11.1 构建返回的FeedsVO
package com.itkaka.feeds.model.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.itkaka.commons.model.base.BaseModel;
import com.itkaka.commons.model.vo.ShortDinerInfo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Getter;
import lombok.Setter;
import java.util.Date;
@ApiModel(description = "Feed显示信息")
@Getter
@Setter
public class FeedsVo extends BaseModel {
@ApiModelProperty("主键")
private Integer id;
@ApiModelProperty("内容")
private String content;
@ApiModelProperty("点赞数")
private int praiseAmount;
@ApiModelProperty("评论数")
private int commentAmount;
@ApiModelProperty("餐厅")
private Integer fkRestaurantId;
@ApiModelProperty("用户ID")
private Integer fkDinerId;
@ApiModelProperty("用户信息")
private ShortDinerInfo dinerInfo;
@ApiModelProperty("显示时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm")
public Date createDate;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
⭐11.2 FeedsMapper查询方法
/**
* 根据多主键查询 Feed
*/
@Select("<script> " +
" select id, content, fk_diner_id, praise_amount, comment_amount, " +
" fk_restaurant_id, create_date, update_date, is_valid " +
" from t_feeds where is_valid = 1 and id in " +
" <foreach item='id' collection='feedIds' open='(' separator=',' close=')'> " +
" #{id} " +
" </foreach> order by id desc" +
" </script>")
List<Feeds> findFeedsByIds(@Param("feedIds") Set<Integer> feedIds);
1
2
3
4
5
6
7
8
9
10
11
12
11.3 ApiConstant
// Feed 默认每页条数
public static final int PAGE_SIZE = 20;
1
2
⭐11.4 编写service查询Feed的方法
获取登录用户信息
构建分页查询的参数start,end
从Redis的sorted sets中按照score的降序进行读取Feed的id
从数据库中获取Feed的信息
构建Feed关联的用户信息(不是循环逐条读取,而是批量获取)
/**
* 根据时间由近到远,每次查询 20 条 Feed
*
* @param page 页码
* @param accessToken 登录用户信息
* @return
*/
public List<FeedsVo> selectForPage(Integer page, String accessToken) {
if (page == null) {
page = 1;
}
// 获取登录用户信息
SignInDinerInfo dinerInfo = loadSignInDinerInfo(accessToken);
// 我关注的好友的 FeedsKey
String key = RedisKeyConstant.following_feeds.getKey() + dinerInfo.getId();
// SortedSet 的 ZREVRANGE 命令是闭区间(左闭右闭)
long start = (page - 1) * ApiConstant.PAGE_SIZE;
long end = page * ApiConstant.PAGE_SIZE - 1;
// 获取 20 条 Feed ID
Set<Integer> feedIds = redisTemplate.opsForZSet().reverseRange(key, start, end); // 闭区间,左闭右闭
if (feedIds == null || feedIds.isEmpty()) {
return Lists.newArrayList();
}
// 根据多主键查询 Feed
List<Feeds> feeds = feedsMapper.findFeedsByIds(feedIds);
// 初始化关注好友 ID 集合
List<Integer> followindDinerIds = new ArrayList<>();
// 添加用户 ID 至集合,顺带将 Feeds 转为 VO 对象
List<FeedsVo> feedsVos = feeds.stream()
.map(feed -> {
FeedsVo feedsVo = new FeedsVo();
BeanUtil.copyProperties(feed, feedsVo);
// 添加用户 ID 至集合
followindDinerIds.add(feed.getFkDinerId());
return feedsVo;
}).collect(Collectors.toList());
// 获取 Feed 中用户信息
ResultInfo resultInfo = restTemplate.getForObject(dinersServerName +
"findByIds?access_token={accessToken}&ids={ids}", ResultInfo.class,
accessToken, StrUtil.join(",", followindDinerIds));
if (resultInfo.getCode() != ApiConstant.SUCCESS_CODE) {
throw new ParameterException(resultInfo.getCode(), resultInfo.getMessage());
}
List<LinkedHashMap> dinerInfoMaps = (List<LinkedHashMap>) resultInfo.getData();
// 构建一个 Key 为用户 ID,Value 为 ShortDinerInfo 的 Map
Map<Integer, ShortDinerInfo> dinerInfos = dinerInfoMaps.stream()
.collect(Collectors.toMap(
// key
diner -> (Integer) diner.get("id"),
// value
diner -> BeanUtil.fillBeanWithMap(diner, new ShortDinerInfo(), false)
));
// 循环 vo 集合,根据用户 ID 从 Map 中获取用户信息并设置至 vo 对象
feedsVos.forEach(feedsVo -> feedsVo.setDinerInfo(dinerInfos.get(feedsVo.getFkDinerId())));
return feedsVos;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
11.5 添加controller查询方法
传入页码进行查询,默认是20条
/**
* 分页获取关注的 Feed 数据
*
* @param page
* @param access_token
* @return
*/
@GetMapping("{page}")
public ResultInfo selectForPage(@PathVariable Integer page, String access_token) {
List<FeedsVo> feedsVos = feedsService.selectForPage(page, access_token);
return ResultInfoUtil.buildSuccess(request.getServletPath(), feedsVos);
}
1
2
3
4
5
6
7
8
9
10
11
12
Postman测试
访问:http://localhost/feeds/1?access_token=116af711-645c-4ce9-bc32-cd304bdda020
写在最后
Feed功能
这个功能中我们实现了添加 Feed、删除 Feed、关注取关时变更 Feed、查询 Feed 功能。
这个功能中 Redis 主要用于存储每个用户关注好友添加的 Feed 流集合,使用了 Sorted Set 数据类型。
————————————————
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
原文链接:https://blog.csdn.net/Kaka_csdn14/article/details/130694205