1. 介绍
为避免用户频繁请求某些重要的业务接口(如涉及金钱、或业务耗时长的),系统一般需要设计接口防刷功能。当发现用户请求过于频繁,则抛弃改业务请求并提醒用户稍后再尝试。
涉及技术栈:Redis,AOP
实现方案:使用缓存如Redis保存用户的请求时间和请求次数,每次有新请求时计算请求次数是否过多或者间隔是否过短,以此决定是否放行请求。防刷的粗细度可通过注解来配置,如果防刷粒度为用户级别,则我们可以在token中获取用户ID作为缓存Key的一部分。
2. 代码实现
2.1 自定义注解
package org.springblade.common.accesslimit;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
public @interface AccessLimit {
/**
* key前缀
*/
String prefix() default "access:limit:";
/**
* key类型,用于控制防刷的粗细度
*/
LimitType type() default LimitType.IP;
/**
* 当key类型是CUSTOM时,通过JsonPath从请求入参获取具体值
* JsonPath介绍:https://blog.csdn.net/software_test010/article/details/125427926
*/
String[] jsonPaths() default {};
/**
* 限制时间单位
*/
LimitUnit unit() default LimitUnit.MINUTE;
/**
* 时间单位内允许访问的次数
*/
int limit() default 10;
/**
* 请求之间的间隔时间(ms)
*/
long gap() default 1000;
}
2.2 限制类型(粗细度)
package org.springblade.common.accesslimit;
public enum LimitType {
IP,
USER,
CUSTOM;
}
2.3 限制时间单位
package org.springblade.common.accesslimit;
import lombok.Getter;
@Getter
public enum LimitUnit {
SECOND(1000L),
MINUTE(1000L * 60),
HOUR(1000L * 60 * 60),
DAY(1000L * 60 * 60 * 24),
WEEK(1000L * 60 * 60 * 24 * 7),
MONTH(1000L * 60 * 60 * 24 * 30),
YEAR(1000L * 60 * 60 * 24 * 365);
/**
* 有效时间(ms)
*/
private final Long valid;
LimitUnit(Long valid) {
this.valid = valid;
}
}
2.4 限制信息实体类
该信息需要缓存起来并进行后续判断
package org.springblade.common.accesslimit;
import lombok.Data;
import java.io.Serializable;
@Data
public class AccessLimitEntity implements Serializable {
private static final long serialVersionUID = 8801582506943177496L;
/**
* 开始时间
*/
private Long startTime;
/**
* 当前请求时间
*/
private Long requestTime;
/**
* 版本号:时间戳
*/
private Long version;
/**
* 目前请求次数
*/
private Integer count;
/**
* 单位时间内限制次数
*/
private Integer limit;
/**
* 时间单位
*/
private LimitUnit unit;
/**
* 请求之间的间隔时间(ms)
*/
private Long gap;
public static AccessLimitEntity init(Long now, AccessLimit accessLimit) {
AccessLimitEntity res = new AccessLimitEntity();
res.startTime = now;
res.requestTime = now;
res.version = now;
res.count = 1;
res.limit = accessLimit.limit();
res.unit = accessLimit.unit();
res.gap = accessLimit.gap();
return res;
}
public AccessLimitEntity update(Long now) {
requestTime = now;
version = now;
count++;
return this;
}
}
2.5 切面实现
package org.springblade.common.accesslimit;
import cn.hutool.json.JSONUtil;
import com.jayway.jsonpath.JsonPath;
import lombok.AllArgsConstructor;
import org.apache.commons.collections4.MapUtils;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springblade.common.exception.ResultCodeEnum;
import org.springblade.common.exception.ServerException;
import org.springblade.common.utils.CommonUtil;
import org.springblade.core.redis.cache.BladeRedis;
import org.springblade.core.redis.lock.LockType;
import org.springblade.core.redis.lock.RedisLockClient;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.utils.DigestUtil;
import org.springblade.core.tool.utils.WebUtil;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.time.Duration;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
@Aspect
@Component
@AllArgsConstructor
public class AccessLimitAspect {
private static final String LOCK = "ACCESS_LOCK:";
private final BladeRedis bladeRedis;
private final RedisLockClient redisLockClient;
@Pointcut("@annotation(org.springblade.common.accesslimit.AccessLimit)")
public void pointcut() {
}
@Around("pointcut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
// 没有配置注解直接放行
AccessLimit accessLimit = method.getAnnotation(AccessLimit.class);
if (Objects.isNull(accessLimit)) {
return joinPoint.proceed();
}
// 根据注解配置获取key
String key = generateKey(accessLimit, joinPoint.getArgs());
String redisKey = accessLimit.prefix().concat(key);
String lock = LOCK.concat(key);
// 加分布式锁,即抛弃并行请求
if (!redisLockClient.tryLock(lock, LockType.FAIR, 0L, -1L, TimeUnit.SECONDS)) {
throw new ServerException(ResultCodeEnum.SYSTEM_BUSY);
}
try {
// 获取旧的访问限制信息
Object obj = bladeRedis.get(redisKey);
AccessLimitEntity newEntity;
// 判断是否能访问,能访问则在redis上更新访问限制信息
if (Objects.nonNull(newEntity = canAccess((AccessLimitEntity) obj, accessLimit))) {
bladeRedis.setEx(redisKey, newEntity, Duration.ofMillis(accessLimit.unit().getValid()));
return joinPoint.proceed();
} else {
throw new ServerException("请求过于频繁,请稍后再访问");
}
} finally {
redisLockClient.unLock(lock, LockType.FAIR);
}
}
/**
* 生成请求接口的key
*/
private String generateKey(AccessLimit accessLimit, Object[] args) throws Exception {
HttpServletRequest request = WebUtil.getRequest();
StringBuilder res = new StringBuilder(request.getRequestURI()).append("_");
LimitType type = accessLimit.type();
if (LimitType.IP == type) {
res.append(WebUtil.getIP());
}
if (LimitType.USER == type) {
// 粗细度是用户时,拼接登录用户ID
res.append(AuthUtil.getUserId());
}
if (LimitType.CUSTOM == type) {
String json = "{}";
String requestBody = WebUtil.getRequestBody(request.getInputStream());
if (StringUtils.isNotEmpty(requestBody)) {
json = requestBody;
} else if (MapUtils.isNotEmpty(request.getParameterMap())) {
// 表单形式的入参,或者是类似 ?id=123&name=TOM
json = JSONUtil.toJsonStr(request.getParameterMap());
}
String[] jsonPaths = accessLimit.jsonPaths();
int len = jsonPaths.length;
for (int i = 0; i < len; i++) {
Object read = JsonPath.read(json, jsonPaths[i]);
res.append(read);
if (i != len - 1) {
res.append("_");
}
}
}
// 转成哈希值,避免过长
return DigestUtil.md5Hex(res.toString());
}
/**
* 判断是否能访问接口
*
* @param exist 已存在的限制数据
* @param accessLimit 注解配置
* @return 新的限制数据;返回null表示不允许访问
*/
private AccessLimitEntity canAccess(AccessLimitEntity exist, AccessLimit accessLimit) {
// 获取当前时间戳
long now = CommonUtil.getNowMilli();
if (Objects.isNull(exist)) {
return AccessLimitEntity.init(now, accessLimit);
}
long gap = now - exist.getStartTime();
if (gap > exist.getUnit().getValid()) {
return AccessLimitEntity.init(now, accessLimit);
}
// 请求次数过多
if (exist.getCount() + 1 > exist.getLimit()) {
return null;
}
// 请求过于频繁
if (now - exist.getRequestTime() < exist.getGap()) {
return null;
}
return exist.update(now);
}
}