0.前言

点赞功能是社交、电商等几乎所有的互联网项目中都广泛使用。虽然看起来简单,不过蕴含的技术方案和手段还是比较多的。

1.需求分析

点赞功能与其它功能不同,没有复杂的原型和需求,仅仅是一个点赞、取消点赞的操作。所以,今天我们就不需要从原型图来分析,而是仅仅从这个功能的实现方案来思考。

1.1.业务需求

首先我们来分析整理一下点赞业务的需求,一个通用点赞系统需要满足下列特性:

  • 通用:点赞业务在设计的时候不要与业务系统耦合,必须同时支持不同业务的点赞功能

  • 独立:点赞功能是独立系统,并且不依赖其它服务。这样才具备可迁移性。

  • 并发:一些热点业务点赞会很多,所以点赞功能必须支持高并发

  • 安全:要做好并发安全控制,避免重复点赞

1.2.实现思路

要保证安全,避免重复点赞,我们就必须保存每一次点赞记录。只有这样在下次用户点赞时我们才能查询数据,判断是否是重复点赞。同时,因为业务方经常需要根据点赞数量排序,因此每个业务的点赞数量也需要记录下来。

综上,点赞的基本思路如下:

whiteboard_exported_imageasd3wew2134324.png

但问题来了,我们说过点赞服务必须独立,因此必须抽取为一个独立服务。多个其它微服务业务的点赞数据都有点赞系统来维护。但是问题来了:

如果业务方需要根据点赞数排序,就必须在数据库中维护点赞数字段。但是点赞系统无法修改其它业务服务的数据库,否则就出现了业务耦合。该怎么办呢?

点赞系统可以在点赞数变更时,通过MQ通知业务方,这样业务方就可以更新自己的点赞数量了。并且还避免了点赞系统与业务方的耦合。

于是,实现思路变成了这样:

2.数据结构

点赞的数据结构分两部分,一是点赞记录,二是与业务关联的点赞数

点赞数自然是与具体业务表关联在一起记录,比如互动问答的点赞,自然是在问答表中记录点赞数。学员笔记点赞,自然是在笔记表中记录点赞数。

在之前实现互动问答的时候,我们已经给回答表设计了点赞数字段了:

其它业务也是类似的。

因此,本节我们只需要实现点赞记录的表结构设计即可。

2.1.ER图

点赞记录本质就是记录谁给什么内容点了赞,所以核心属性包括:

  • 点赞目标id

  • 点赞人id

不过点赞的内容多种多样,为了加以区分,我们还需要把点赞内的类型记录下来:

  • 点赞对象类型(为了通用性)

当然还有点赞时间,综上对应的数据库ER图如下:

2.2.表结构

由于点赞系统是独立于其它业务的,这里我们需要创建一个新的数据库:tj_remark

CREATE DATABASE tj_remark CHARACTER SET 'utf8mb4';

然后在ER图基础上,加上一些通用属性,点赞记录表结构如下:

CREATE TABLE IF NOT EXISTS `liked_record` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键id',
  `user_id` bigint NOT NULL COMMENT '用户id',
  `biz_id` bigint NOT NULL COMMENT '点赞的业务id',
  `biz_type` VARCHAR(16) NOT NULL COMMENT '点赞的业务类型',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `idx_biz_user` (`biz_id`,`user_id`)
) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='点赞记录表';

2.3.实体类

@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("liked_record")
@ApiModel(value="LikedRecord对象", description="点赞记录表")
public class LikedRecord implements Serializable {

    private static final long serialVersionUID = 1L;

    @ApiModelProperty(value = "主键id")
    @TableId(value = "id", type = IdType.ASSIGN_ID)
    private Long id;

    @ApiModelProperty(value = "用户id")
    private Long userId;

    @ApiModelProperty(value = "点赞的业务id")
    private Long bizId;

    @ApiModelProperty(value = "点赞的业务类型")
    private String bizType;

    @ApiModelProperty(value = "创建时间")
    private LocalDateTime createTime;

    @ApiModelProperty(value = "更新时间")
    private LocalDateTime updateTime;

}

3.实现点赞功能

从表面来看,点赞功能要实现的接口就是一个点赞接口。不过仔细观察所有的点赞页面,你会发现点赞按钮有灰色和点亮两种状态。

也就是说我们还需要实现查询用户点赞状态的接口,这样前端才能根据点赞状态渲染不同效果。因此我们要实现的接口包括:

  • 点赞/取消点赞

  • 根据多个业务id批量查询用户是否点赞多个业务

3.1.点赞或取消点赞

3.1.1.接口信息

当用户点击点赞按钮的时候,第一次点击是点赞,按钮会高亮;第二次点击是取消,点赞按钮变灰:

从后台实现来看,点赞就是新增一条点赞记录,取消就是删除这条记录。为了方便前端交互,这两个合并为一个接口即可。

因此,请求参数首先要包含点赞有关的数据,并且要标记是点赞还是取消:

  • 点赞的目标业务id:bizId

  • 谁在点赞(就是登陆用户,可以不用提交)

  • 点赞还是取消

除此以外,我们之前说过,在问答、笔记等功能中都会出现点赞功能,所以点赞必须具备通用性。因此还需要在提交一个参数标记点赞的类型:

  • 点赞目标的类型

返回值有两种设计:

  • 方案一:无返回值,200就是成功,页面直接把点赞数+1展示给用户即可

  • 方案二:返回点赞数量,页面渲染

这里推荐使用方案一,因为每次统计点赞数量也有很大的性能消耗。

综上,按照Restful风格设计,接口信息如下:

接口说明

用户可以给自己喜欢的内容点赞,也可以取消点赞

请求方式

POST

请求路径

/likes

请求参数格式

{
  "bizId": "1578558664933920770", // 点赞业务id
  "bizType": 1, // 点赞业务类型,1:问答;2:笔记;..
  "liked": true,  // 是否点赞,true:点赞,false:取消
}

返回值格式

3.1.2.实体

请求参数需要定义一个DTO实体类来接收,在课前资料已经提供了:

@Data
@ApiModel(description = "点赞记录表单实体")
public class LikeRecordFormDTO {
    @ApiModelProperty("点赞业务id")
    @NotNull(message = "业务id不能为空")
    private Long bizId;

    @ApiModelProperty("点赞业务类型")
    @NotNull(message = "业务类型不能为空")
    private String bizType;

    @ApiModelProperty("是否点赞,true:点赞;false:取消点赞")
    @NotNull(message = "是否点赞不能为空")
    private Boolean liked;
}

3.1.3.代码实现

首先是LikedRecordController

/**
 * <p>
 * 点赞记录表 控制器
 * </p>
 */
@RestController
@RequiredArgsConstructor
@RequestMapping("/likes")
@Api(tags = "点赞业务相关接口")
public class LikedRecordController {

    private final ILikedRecordService likedRecordService;

    @PostMapping
    @ApiOperation("点赞或取消点赞")
    public void addLikeRecord(@Valid @RequestBody LikeRecordFormDTO recordDTO) {
        likedRecordService.addLikeRecord(recordDTO);
    }
}

然后是ILikedRecordService

public interface ILikedRecordService extends IService<LikedRecord> {

    void addLikeRecord(LikeRecordFormDTO recordFormDTO);
}

最后是LikedRecordServiceImpl

@Service
public class LikedRecordServiceImpl extends ServiceImpl<LikedRecordMapper, LikedRecord> implements ILikedRecordService {

    @Override
    public void addLikeRecord(LikeRecordFormDTO recordFormDTO) {
        // TODO 实现点赞或取消点赞
    }
}

3.1.4.业务流程

我们先梳理一下点赞业务的几点需求:

  • 点赞就新增一条点赞记录,取消点赞就删除记录

  • 用户不能重复点赞

  • 点赞数由具体的业务方保存,需要通知业务方更新点赞数

由于业务方的类型很多,比如互动问答、笔记、课程等。所以通知方式必须是低耦合的,这里建议使用MQ来实现。

当点赞或取消点赞后,点赞数发生变化,我们就发送MQ通知。整体业务流程如图:

需要注意的是,由于每次点赞的业务类型不同,所以没有必要通知到所有业务方,而是仅仅通知与当前点赞业务关联的业务方即可

在RabbitMQ中,利用TOPIC类型的交换机,结合不同的RoutingKey,可以实现通知对象的变化。我们需要让不同的业务方监听不同的RoutingKey,然后发送通知时根据点赞类型不同,发送不同RoutingKey:

当然,真实的RoutingKey不一定如图中所示,这里只是做一个示意。

其实在tj-common中,我们已经定义了MQ的常量:

并且定义了点赞有关的ExchangeRoutingKey常量:

其中的RoutingKey只是一个模板,其中{}部分是占位符,不同业务类型就填写不同的具体值。

3.1.5.实现完整业务

首先我们需要定义一个MQ通知的消息体,由于这个消息体会在各个相关微服务中使用,需要定义到公用的模块中,这里我们定义到tj-api模块:

具体代码如下:

@Data
@NoArgsConstructor
@AllArgsConstructor
public class LikedTimesDTO {
    /**
     * 点赞的业务id
     */
    private Long bizId;
    /**
     * 总的点赞次数
     */
    private Integer likedTimes;
}

然后是com.tianji.remark.service.impl.LikedRecordServiceImpl完整的业务逻辑:

import static com.tianji.common.constants.MqConstants.Exchange.LIKE_RECORD_EXCHANGE;
import static com.tianji.common.constants.MqConstants.Key.LIKED_TIMES_KEY_TEMPLATE;

/**
 * <p>
 * 点赞记录表 服务实现类
 * </p>
 */
@Service
@RequiredArgsConstructor
public class LikedRecordServiceImpl extends ServiceImpl<LikedRecordMapper, LikedRecord> implements ILikedRecordService {

    private final RabbitMqHelper mqHelper;

    @Override
    public void addLikeRecord(LikeRecordFormDTO recordDTO) {
        // 1.基于前端的参数,判断是执行点赞还是取消点赞
        boolean success = recordDTO.getLiked() ? like(recordDTO) : unlike(recordDTO);
        // 2.判断是否执行成功,如果失败,则直接结束
        if (!success) {
            return;
        }
        // 3.如果执行成功,统计点赞总数
        Integer likedTimes = lambdaQuery()
                .eq(LikedRecord::getBizId, recordDTO.getBizId())
                .count();
        // 4.发送MQ通知
        mqHelper.send(
                LIKE_RECORD_EXCHANGE,
                StringUtils.format(LIKED_TIMES_KEY_TEMPLATE, recordDTO.getBizType()),
                LikedTimesDTO.of(recordDTO.getBizId(), likedTimes));
    }

    private boolean unlike(LikeRecordFormDTO recordDTO) {
        return remove(new QueryWrapper<LikedRecord>().lambda()
                .eq(LikedRecord::getUserId, UserContext.getUser())
                .eq(LikedRecord::getBizId, recordDTO.getBizId()));
    }

    private boolean like(LikeRecordFormDTO recordDTO) {
        Long userId = UserContext.getUser();
        // 1.查询点赞记录
        Integer count = lambdaQuery()
                .eq(LikedRecord::getUserId, userId)
                .eq(LikedRecord::getBizId, recordDTO.getBizId())
                .count();
        // 2.判断是否存在,如果已经存在,直接结束
        if (count > 0) {
            return false;
        }
        // 3.如果不存在,直接新增
        LikedRecord r = new LikedRecord();
        r.setUserId(userId);
        r.setBizId(recordDTO.getBizId());
        r.setBizType(recordDTO.getBizType());
        save(r);
        return true;
    }
}

3.2.批量查询点赞状态

由于这个接口是供其它微服务调用,实现完成接口后,还需要定义对应的FeignClient

3.2.1.接口信息

这里是查询多个业务的点赞状态,因此请求参数自然是业务id的集合。由于是查询当前用户的点赞状态,因此无需传递用户信息。

经过筛选判断后,我们把点赞过的业务id集合返回即可。

综上,按照Restful来设计该接口,接口信息如下:

接口说明

查询当前用户是否点赞了指定的业务

请求方式

GET

请求路径

/likes/list

请求参数格式

请求数据类型:application/x-www-form-urlencoded

例如:bizIds=1,2,3

代表业务id集合

返回值格式

[
    "业务id1",  "业务id2",  "业务id3",  "业务id4"
]

3.3.2.代码

首先是LikedRecordController

/**
 * <p>
 * 点赞记录表 控制器
 * </p>
 */
@RestController
@RequiredArgsConstructor
@RequestMapping("/likes")
@Api(tags = "点赞业务相关接口")
public class LikedRecordController {

    private final ILikedRecordService likedRecordService;

    @PostMapping
    @ApiOperation("点赞或取消点赞")
    public void addLikeRecord(@Valid @RequestBody LikeRecordFormDTO recordDTO) {
        likedRecordService.addLikeRecord(recordDTO);
    }

    @GetMapping("list")
    @ApiOperation("查询指定业务id的点赞状态")
    public Set<Long> isBizLiked(@RequestParam("bizIds") List<Long> bizIds){
        return likedRecordService.isBizLiked(bizIds);
    }
}

然后是ILikedRecordService

/**
 * <p>
 * 点赞记录表 服务类
 * </p>
 */
public interface ILikedRecordService extends IService<LikedRecord> {

    void addLikeRecord(LikeRecordFormDTO recordDTO);

    Set<Long> isBizLiked(List<Long> bizIds);
}

最后是LikedRecordServiceImpl

@Override
public Set<Long> isBizLiked(List<Long> bizIds) {
    // 1.获取登录用户id
    Long userId = UserContext.getUser();
    // 2.查询点赞状态
    List<LikedRecord> list = lambdaQuery()
            .in(LikedRecord::getBizId, bizIds)
            .eq(LikedRecord::getUserId, userId)
            .list();
    // 3.返回结果
    return list.stream().map(LikedRecord::getBizId).collect(Collectors.toSet());
}

3.3.3.暴露Feign接口

由于该接口是给其它微服务调用的,所以必须暴露出Feign客户端,并且定义好fallback降级处理:

我们在tj-api模块中定义一个客户端:

其中RemarkClient如下:

@FeignClient(value = "remark-service", fallbackFactory = RemarkClientFallback.class)
public interface RemarkClient {
    @GetMapping("/likes/list")
    Set<Long> isBizLiked(@RequestParam("bizIds") Iterable<Long> bizIds);
}

对应的fallback逻辑:

@Slf4j
public class RemarkClientFallback implements FallbackFactory<RemarkClient> {

    @Override
    public RemarkClient create(Throwable cause) {
        log.error("查询remark-service服务异常", cause);
        return new RemarkClient() {

            @Override
            public Set<Long> isBizLiked(Iterable<Long> bizIds) {
                return CollUtils.emptySet();
            }
        };
    }
}

由于RemarkClientFallback是定义在tj-apicom.tianji.api包,由于每个微服务扫描包不一致。因此其它引用tj-api的微服务是无法通过扫描包加载到这个类的。

我们需要通过SpringBoot的自动加载机制来加载这些fallback类:

由于SpringBoot会在启动时读取/META-INF/spring.factories文件,我们只需要在该文件中指定了要加载

FallbackConig类:

@Configuration
public class FallbackConfig {
    @Bean
    public LearningClientFallback learningClientFallback(){
        return new LearningClientFallback();
    }

    @Bean
    public TradeClientFallback tradeClientFallback(){
        return new TradeClientFallback();
    }

    @Bean
    public RemarkClientFallback remarkClientFallback(){
        return new RemarkClientFallback();
    }
}

这样所有在其中定义的fallback类都会被加载了。

3.4.监听点赞变更的消息

既然点赞后会发送MQ消息通知业务服务,那么每一个有关的业务服务都应该监听点赞数变更的消息,更新本地的点赞数量。

例如互动问答,我们需要在tj-learning服务中定义MQ监听器:

具体代码如下:

import static com.tianji.common.constants.MqConstants.Exchange.LIKE_RECORD_EXCHANGE;
import static com.tianji.common.constants.MqConstants.Key.QA_LIKED_TIMES_KEY;

@Slf4j
@Component
@RequiredArgsConstructor
public class LikeTimesChangeListener {

    private final IInteractionReplyService replyService;

    @RabbitListener(bindings = @QueueBinding(
            value = @Queue(name = "qa.liked.times.queue", durable = "true"),
            exchange = @Exchange(name = LIKE_RECORD_EXCHANGE, type = ExchangeTypes.TOPIC),
            key = QA_LIKED_TIMES_KEY
    ))
    public void listenReplyLikedTimesChange(LikedTimesDTO dto){
        log.debug("监听到回答或评论{}的点赞数变更:{}", dto.getBizId(), dto.getLikedTimes());
        InteractionReply r = new InteractionReply();
        r.setId(dto.getBizId());
        r.setLikedTimes(dto.getLikedTimes());
        replyService.updateById(r);
    }
}

下一篇:可迁移的高可用点赞系统实现-下 | 东的博客