需求说明
好友功能 是目前社交场景必备功能之一,一般好友相关功能包含 : 关注/取关, 我(他)的关注或者粉丝,共同关注,我关注的人也关注他等等。
方式一 : 数据库 : 只能得到粉丝列表或者 关注列表,如果想查多用户的共同关注或者共同粉丝比较麻烦,低效
方式二 : Redis : 操作简单效率高。redis 本身带有专门针对这种集合的交集、并集、差集的操作。
设计思路
总体思路 采用 MySQL + Redis 方式结合完成。
MySQL 用于保存落地数据,Redis 的 Sets进行集合操作。Sets拥有去重(我们不能多次关注同一用户)功能。一个用户我们存贮两个集合:一个是保存用户关注的人 另一个是保存关注用户的人.
SADD 添加成员;命令格式: SADD key member [member …] ----- 关注
SREM 移除某个成员;命令格式: SREM key member [member …] -------取关
SCARD 统计集合内的成员数;命令格式: SCARD key -------关注/粉丝个数
SISMEMBER 判断是否是集合成员;命令格式:SISMEMBER key member ---------判断是否关注(如果是只可以点击取关)
SMEMBERS 查询集合内的成员;命令格式: SMEMBERS key -------列表使用(关注列表和粉丝列表)
SINTER 查询集合的交集;命令格式: SINTER key [key …] --------共同关注、我关注的人关注了他
表结构
建模块
pom依赖
<?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_follow</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>
<!-- spring 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>
配置文件
server:
port: 8094 # 端口
spring:
application:
name: fs_follow # 应用名
# 数据库
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.itkaka.follow
title: 美食社交好友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-feeds-server: http://fs_feeds/
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'
关注 / 取关
关注/取消关注 业务逻辑
实体类
package com.itkaka.follow.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 = "食客关注实体类")
@Getter
@Setter
public class Follow extends BaseModel { // 需要继承 BaseModel
@ApiModelProperty("当前登录用户的ID")
private Integer dinerId; // 包装类型默认值
@ApiModelProperty("被关注者的ID")
private Integer followDinerId;
}
配置类
package com.itkaka.follow.config;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisTemplateConfiguration {
/**
* redisTemplate 序列化使用的jdkSerializeable, 存储二进制字节码, 所以自定义序列化类
*
* @param redisConnectionFactory
* @return
*/
@Bean
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
//在这里形参报错是因为我的主启动类还没有写好,所以idea编译器检测不到这是一个springboot项目,所以无法通过形参注入成功
RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
// 使用Jackson2JsonRedisSerialize 替换默认序列化
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
jackson2JsonRedisSerializer.setObjectMapper(objectMapper);
// 设置key和value的序列化规则
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
}
package com.itkaka.follow.config;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
/**
* Rest 配置类
*/
@Configuration
public class RestTemplateConfiguration {
@LoadBalanced
@Bean
public RestTemplate restTemplate(){
return new RestTemplate();
}
}
全局异常
package com.itkaka.follow.handler;
import com.itkaka.commons.exception.ParameterException;
import com.itkaka.commons.model.domain.ResultInfo;
import com.itkaka.commons.utils.ResultInfoUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.util.Map;
@RestControllerAdvice // 将输出的内容写入 ResponseBody 中
@Slf4j
public class GlobalExceptionHandler {
@Resource
private HttpServletRequest request;
@ExceptionHandler(ParameterException.class)
public ResultInfo<Map<String,String>> handlerParameterException(ParameterException ex){
String path = request.getRequestURI();
ResultInfo<Map<String,String>> resultInfo =
ResultInfoUtil.buildError(ex.getErrorCode(),ex.getMessage(),path);
return resultInfo;
}
@ExceptionHandler(Exception.class)
public ResultInfo<Map<String,String>> handlerException(Exception ex){
log.info("未知异常: {}",ex);
String path = request.getRequestURI();
ResultInfo<Map<String,String>> resultInfo =
ResultInfoUtil.buildError(path);
return resultInfo;
}
}
持久层
package com.itkaka.follow.mapper;
import com.itkaka.follow.model.pojo.Follow;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
/*
* 好友服务
* */
public interface FollowMapper {
//查询关注信息
@Select("select id,diner_id,follow_diner_id,is_valid "+
" from t_follow where diner_id = #{dinerId} and follow_diner_id = #{follow_diner_id}")
Follow selectFollow(@Param("dinerId") Integer dinerId,
@Param("followDinerId") Integer followDinerId);
//添加关注信息
@Insert("insert into t_follow (diner_id, follow_diner_id, is_valid, create_date, update_date) " +
"values (#{dinerId}, #{followDinerId}, 1, now(), now())")
int save(@Param("dinerId") Integer dinerId,
@Param("followDinerId") Integer followDinerId);
//修改关注信息
@Update("update t_follow set is_valid = #{isFollowed},update_date = now() where id = #{id}")
int update(@Param("isFollowed") Integer isFollowed,@Param("id") Integer id);
}
业务层
package com.itkaka.follow.service;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
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.ShortDinerInfo;
import com.itkaka.commons.model.vo.SignInDinerInfo;
import com.itkaka.commons.utils.AssertUtil;
import com.itkaka.commons.utils.ResultInfoUtil;
import com.itkaka.follow.mapper.FollowMapper;
import com.itkaka.follow.model.pojo.Follow;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import javax.annotation.Resource;
import java.util.*;
import java.util.stream.Collectors;
/*
* 好友服务
* */
@Service
public class FollowService {
@Value("${service.name.fs_oauth-server}")
private String oauthServerName;
@Value("${service.name.fs_diners-server}")
private String dinersServerName;
// feed服务
@Value("${service.name.fs_feeds-server}")
private String feedsServerName;
@Resource
private RestTemplate restTemplate;
@Resource
private RedisTemplate redisTemplate;
@Resource
private FollowMapper followMapper;
//关注/取关
@Transactional(rollbackFor = Exception.class)
public ResultInfo follow(String accessToken,Integer followDinerId,
int isFollowed,String path){
//是否选择关注对象
AssertUtil.isTrue(followDinerId == null || followDinerId <1,
"请选择要关注的人");
// 获取登录用户信息
SignInDinerInfo dinerInfo = loadSignInDinerInfo(accessToken);
//获取当前登录用户和需要关注用户的关注信息
Follow follow = followMapper.selectFollow(dinerInfo.getId(),followDinerId);
//如果没有关注信息,且要关注操作
if (follow == null && isFollowed == 1){
//加关注信息
int count = followMapper.save(dinerInfo.getId(),followDinerId);
//把关注信息添加到 redis
if (count == 1){
addToRedisSet(dinerInfo.getId(),followDinerId);
//处理 Reed
}
return ResultInfoUtil.build(ApiConstant.SUCCESS_CODE,"关注成功",path,"关注成功");
}
/*
* 如果有关注信息,且 is_valid= 0 那么是取关
* 如果有关注信息,且 is_valid= 1 那么是关注中
* */
//如果有关注信息,且目前处于取关状态,要进行关注操作
if (follow != null && follow.getIsValid() == 0 && isFollowed == 1){
//重新关注
int count = followMapper.update(isFollowed,follow.getId()); // 采用修改操作
// 加到 redis 关注集合
if ( count == 1){
addToRedisSet(dinerInfo.getId(),followDinerId);
// 处理 feed
}
return ResultInfoUtil.build(ApiConstant.SUCCESS_CODE,"关注成功",path,"关注成功");
}
//如果有关注信息,目前处于关注中状态,且要进行取关操作
if (follow != null && follow.getIsValid() == 1 && isFollowed == 0){
//取关
int count = followMapper.update(isFollowed,follow.getId());
// 移除 redis 里面的关注信息
if (count == 1){
removeFromRedisSet(dinerInfo.getId(),followDinerId);
// 处理 feed
}
return ResultInfoUtil.build(ApiConstant.SUCCESS_CODE, "取关成功", path, "取关成功");
}
return ResultInfoUtil.buildSuccess(path,"操作成功");
}
// 移除 Redis 中关注信息
private void removeFromRedisSet(Integer dinerId, Integer followDinerId) {
redisTemplate.opsForSet().remove(RedisKeyConstant.following.getKey() + dinerId,followDinerId);
redisTemplate.opsForSet().remove(RedisKeyConstant.followers.getKey()+followDinerId,dinerId);
}
// 获取登录用户信息
private SignInDinerInfo loadSignInDinerInfo(String accessToken) {
// 判断是否有 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;
}
// 添加关注信息到 Redis 关注集合中
private void addToRedisSet(Integer dinerId, Integer followDinerId) {
redisTemplate.opsForSet().add(RedisKeyConstant.following.getKey()+dinerId,followDinerId);
redisTemplate.opsForSet().add(RedisKeyConstant.followers.getKey()+followDinerId,dinerId);
}
// 获取共同关注列表
public ResultInfo findCommonsFriends(Integer dinerId,String accessToken,String path){
// 是否选择关注对象
AssertUtil.isTrue(dinerId == null || dinerId <1,"请选择要查看的人");
//获取登录用户信息
SignInDinerInfo dinerInfo = loadSignInDinerInfo(accessToken);
// 获取登录用户关注信息
String loginDinerKey = RedisKeyConstant.following.getKey() + dinerInfo.getId();
// 获取查看用户的关注信息
String dinerKey = RedisKeyConstant.following.getKey() + dinerId;
// 计算交集
Set<Integer> followingDinerIds = redisTemplate.opsForSet().intersect(loginDinerKey,dinerKey);
// 没有交集
if (followingDinerIds == null || followingDinerIds.isEmpty()){
return ResultInfoUtil.buildSuccess(path,new ArrayList<ShortDinerInfo>());
}
// 有交集
// 根据 ids 查询食客信息
ResultInfo resultInfo = restTemplate.getForObject(dinersServerName + "findByIds?access_token={accessToken}&ids={ids}",
ResultInfo.class,accessToken, StrUtil.join(",", followingDinerIds));
// 处理结果集
List<LinkedHashMap> dinerInfoMaps = (List<LinkedHashMap>) resultInfo.getData();
List<ShortDinerInfo> dinerInfos = dinerInfoMaps.stream()
.map(diner -> BeanUtil.fillBeanWithMap(diner, new ShortDinerInfo(), false))
.collect(Collectors.toList());
return ResultInfoUtil.buildSuccess(path, dinerInfos);
}
// 获取粉丝列表
public Set<Integer> findFollwers(Integer dinerId){
AssertUtil.isNotNull(dinerId,"请选择要查看的用户");
Set<Integer> followers = redisTemplate.opsForSet()
.members(RedisKeyConstant.followers.getKey() + dinerId);
return followers;
}
/**
* 发送请求添加或移除关注人的Feed列表
*
* @param followDinerId
* @param accessToken
* @param type
*/
private void sendSaveOrRemoveFeed(Integer followDinerId, String accessToken, int type) {
String url = 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(url, entity, ResultInfo.class);
}
}
控制层
package com.itkaka.follow.controller;
import com.itkaka.commons.model.domain.ResultInfo;
import com.itkaka.commons.utils.ResultInfoUtil;
import com.itkaka.follow.service.FollowService;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.util.Set;
/*
* 好友服务
* */
@RestController
public class FollowController {
@Resource
private FollowService followService;
@Resource
private HttpServletRequest request;
// 关注/取关
@PostMapping("{followDinerId}")
public ResultInfo follow(@PathVariable Integer followDinerId,
@RequestParam int isFollowed,
String access_token){
return followService.follow(access_token,followDinerId,isFollowed, request.getServletPath());
}
//获取共同关注列表
@GetMapping("commons/{dinerId}")
public ResultInfo findCommonsFriends(@PathVariable Integer dinerId,String access_token){
return followService.findCommonsFriends(dinerId,access_token, request.getServletPath());
}
//获取粉丝列表
@GetMapping
public ResultInfo findFollowers(@PathVariable Integer dinerId){
Set<Integer> followers = followService.findFollwers(dinerId);
return ResultInfoUtil.buildSuccess(request.getServletPath(),followers);
}
}
启动类
package com.itkaka.follow;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@MapperScan("com.itkaka.follow.mapper")
@SpringBootApplication
public class FollowApplication {
public static void main(String[] args) {
SpringApplication.run(FollowApplication.class,args);
}
}
网关配置
- id: fs_follow
uri: lb://fs_follow
predicates:
- Path=/follow/**
filters:
- StripPrefix=1
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
测试PostMan
共同关注列表
- 从Redis中读取登录用户的关注列表与查看用户的关注列表,然后进行交集操作,获取共同关注的用户id
- 然后通过食客服务传入用户id数据获取用户基本信息
用户服务查询用户信息
添加视图对象
package com.itkaka.commons.model.vo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
@ApiModel(description = "关注食客信息")
@Getter
@Setter
public class ShortDinerInfo implements Serializable {
@ApiModelProperty("主键")
private Integer id;
@ApiModelProperty("昵称")
private String nickname;
@ApiModelProperty("头像")
private String avatarUrl;
}
根据用户的ids(多个以逗号分隔)
三层代码具体见上面的三层代码块;
测试
写在最后
好友功能
这个功能中我们实现了关注、取关、获取共同关注列表功能。
这个功能中 Redis 主要用于存储每个用户关注好友添加的 Feed 流集合,使用了 Sorted Set 数据类型。
👉 💕美好的一周,从周一开始,一起加油!后续持续更新,码字不易,麻烦大家小手一点 , 点赞或关注 , 感谢大家的支持!! ☀