本方案参考mallchat实现
普通翻页前端一般会有个分页条。能够指定一页的条数,以及任意选择查看第几页,假设我们想查询第11页的内容
传递过来的参数为:
pageNo
=11,pageSize
=10
对应的sql查询为:
select * from table limit 100,10
其中100
代表需要跳过的条数,10
代表跳过指定条数后,往后需要再取的条数。
假设翻页到1w条,那我们要先扫描到这1w条数据,再取10个选用,这样的话效率就会大大降低
那么如何能够在每次翻页时,不去扫描前面的记录呢
假设我们还是查第11页的内容
只需要我们加上一个条件
select * from table where id>100 order by id limit 0,10
只要id这个字段有索引,就能直接定位到101这个字段,然后去10条记录。以后无论翻页到多大,通过索引直接定位到读取的位置,效率基本是一样的。这个id>100
就是我们的游标,这就是游标翻页。
我们在传递值的时候,不再取传递pageNo
字段,而是传递cursor
游标字段.cursor
是上一次查询结果的位置,作为下一次查询的游标,由后端返回.
但是游标翻页不适合跳页,只能不断的往下翻
@Data
@ApiModel("游标翻页请求")
@AllArgsConstructor
@NoArgsConstructor
public class CursorPageBaseReq {
@ApiModelProperty("页面大小")
@Min(0)
@Max(100)
private Integer pageSize = 10;
@ApiModelProperty("游标(初始为null,后续请求附带上次翻页的游标)")
private String cursor;
public Page plusPage() {
return new Page(1, this.pageSize);
}
@JsonIgnore
public Boolean isFirstPage() {
return StringUtils.isEmpty(cursor);
}
}
package org.fth.mallchat.common.common.utils;
import cn.hutool.core.map.WeakConcurrentMap;
import cn.hutool.core.util.ReflectUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.toolkit.support.ColumnCache;
import com.baomidou.mybatisplus.core.toolkit.support.SFunction;
import lombok.SneakyThrows;
import org.apache.ibatis.reflection.property.PropertyNamer;
import java.io.Serializable;
import java.lang.invoke.SerializedLambda;
import java.lang.ref.WeakReference;
import java.lang.reflect.Field;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
*/
public class LambdaUtils {
/**
* 字段映射
*/
private static final Map<String, Map<String, ColumnCache>> COLUMN_CACHE_MAP = new ConcurrentHashMap<>();
/**
* SerializedLambda 反序列化缓存
*/
private static final Map<String, WeakReference<com.baomidou.mybatisplus.core.toolkit.support.SerializedLambda>> FUNC_CACHE = new ConcurrentHashMap<>();
private static Pattern RETURN_TYPE_PATTERN = Pattern.compile("\\(.*\\)L(.*);");
private static Pattern PARAMETER_TYPE_PATTERN = Pattern.compile("\\((.*)\\).*");
private static final WeakConcurrentMap<String, SerializedLambda> cache = new WeakConcurrentMap<>();
/**
* 获取Lambda表达式返回类型
*/
public static Class<?> getReturnType(Serializable serializable) {
String expr = _resolve(serializable).getInstantiatedMethodType();
Matcher matcher = RETURN_TYPE_PATTERN.matcher(expr);
if (!matcher.find() || matcher.groupCount() != 1) {
throw new RuntimeException("获取Lambda信息失败");
}
String className = matcher.group(1).replace("/", ".");
try {
return Class.forName(className);
} catch (ClassNotFoundException e) {
throw new RuntimeException("无法加载类", e);
}
}
@SneakyThrows
public static <T> Class<?> getReturnType(SFunction<T, ?> func) {
com.baomidou.mybatisplus.core.toolkit.support.SerializedLambda lambda = com.baomidou.mybatisplus.core.toolkit.LambdaUtils.resolve(func);
Class<?> aClass = lambda.getInstantiatedType();
String fieldName = PropertyNamer.methodToProperty(lambda.getImplMethodName());
Field field = aClass.getDeclaredField(fieldName);
field.setAccessible(true);
return field.getType();
}
/**
* 获取Lambda表达式的参数类型
*/
public static List<Class<?>> getParameterTypes(Serializable serializable) {
String expr = _resolve(serializable).getInstantiatedMethodType();
Matcher matcher = PARAMETER_TYPE_PATTERN.matcher(expr);
if (!matcher.find() || matcher.groupCount() != 1) {
throw new RuntimeException("获取Lambda信息失败");
}
expr = matcher.group(1);
return Arrays.stream(expr.split(";"))
.filter(StrUtil::isNotBlank)
.map(s -> s.replace("L", "").replace("/", "."))
.map(s -> {
try {
return Class.forName(s);
} catch (ClassNotFoundException e) {
throw new RuntimeException("无法加载类", e);
}
})
.collect(Collectors.toList());
}
/**
* 解析lambda表达式,加了缓存。
* 该缓存可能会在任意不定的时间被清除。
*
*
* 通过反射调用实现序列化接口函数对象的writeReplace方法,从而拿到{@link SerializedLambda}
* 该对象中包含了lambda表达式的所有信息。
*
*
* @param func 需要解析的 lambda 对象
* @return 返回解析后的结果
*/
private static SerializedLambda _resolve(Serializable func) {
return cache.computeIfAbsent(func.getClass().getName(), (key)
-> ReflectUtil.invoke(func, "writeReplace"));
}
}
package org.fth.mallchat.common.common.utils;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.lang.Pair;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.support.SFunction;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import org.fth.mallchat.common.common.domain.vo.req.CursorPageBaseReq;
import org.fth.mallchat.common.common.domain.vo.resp.CursorPageBaseResp;
import org.springframework.data.redis.core.ZSetOperations;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* Description: 游标分页工具类
*/
public class CursorUtils {
public static <T> CursorPageBaseResp<Pair<T, Double>> getCursorPageByRedis(CursorPageBaseReq cursorPageBaseReq, String redisKey, Function<String, T> typeConvert) {
Set<ZSetOperations.TypedTuple<String>> typedTuples;
if (StrUtil.isBlank(cursorPageBaseReq.getCursor())) {//第一次
typedTuples = RedisUtils.zReverseRangeWithScores(redisKey, cursorPageBaseReq.getPageSize());
} else {
typedTuples = RedisUtils.zReverseRangeByScoreWithScores(redisKey, Double.parseDouble(cursorPageBaseReq.getCursor()), cursorPageBaseReq.getPageSize());
}
List<Pair<T, Double>> result = typedTuples
.stream()
.map(t -> Pair.of(typeConvert.apply(t.getValue()), t.getScore()))
.sorted((o1, o2) -> o2.getValue().compareTo(o1.getValue()))
.collect(Collectors.toList());
String cursor = Optional.ofNullable(CollectionUtil.getLast(result))
.map(Pair::getValue)
.map(String::valueOf)
.orElse(null);
Boolean isLast = result.size() != cursorPageBaseReq.getPageSize();
return new CursorPageBaseResp<>(cursor, isLast, result);
}
public static <T> CursorPageBaseResp<T> getCursorPageByMysql(IService<T> mapper, CursorPageBaseReq request, Consumer<LambdaQueryWrapper<T>> initWrapper, SFunction<T, ?> cursorColumn) {
//游标字段类型
Class<?> cursorType = LambdaUtils.getReturnType(cursorColumn);
LambdaQueryWrapper<T> wrapper = new LambdaQueryWrapper<>();
//额外条件
initWrapper.accept(wrapper);
//游标条件
if (StrUtil.isNotBlank(request.getCursor())) {
wrapper.lt(cursorColumn, parseCursor(request.getCursor(), cursorType));
}
//游标方向
wrapper.orderByDesc(cursorColumn);
Page<T> page = mapper.page(request.plusPage(), wrapper);
//取出游标
String cursor = Optional.ofNullable(CollectionUtil.getLast(page.getRecords()))
.map(cursorColumn)
.map(CursorUtils::toCursor)
.orElse(null);
//判断是否最后一页
Boolean isLast = page.getRecords().size() != request.getPageSize();
return new CursorPageBaseResp<>(cursor, isLast, page.getRecords());
}
private static String toCursor(Object o) {
if (o instanceof Date) {
return String.valueOf(((Date) o).getTime());
} else {
return o.toString();
}
}
private static Object parseCursor(String cursor, Class<?> cursorClass) {
if (Date.class.isAssignableFrom(cursorClass)) {
return new Date(Long.parseLong(cursor));
} else {
return cursor;
}
}
}
getCursorPageByRedis
cursorPageBaseReq
: 传入的分页请求对象,包含游标 (cursor
)、每页大小 (pageSize
) 等信息。redisKey
: Redis 中的键,表示要查询的数据所在的有序集合。typeConvert
: 用于将 Redis 返回的字符串类型的值转换为目标类型的函数。zReverseRangeWithScores
获取 Redis 中的前 pageSize
个数据。zReverseRangeByScoreWithScores
从游标指定的位置开始获取数据。Pair.of
组合值和分数(score),并对结果按分数(score)进行降序排序。isLast
)。getCursorPageByMysql
mapper
: MyBatis-Plus 提供的 IService
接口,用于执行数据库查询。request
: 包含游标(cursor
)、每页大小等信息的分页请求对象。initWrapper
: 一个消费者函数,用于对 LambdaQueryWrapper
进行初始化,以添加额外的查询条件。cursorColumn
: 游标字段(通常为表中的某个字段,用作分页的依据)。LambdaQueryWrapper
。wrapper
中添加条件,表示从游标位置之前的数据进行分页查询。mapper.page()
方法查询数据,并根据游标字段排序。toCursor
Date
类型,则将其转换为时间戳(Long
类型的值)。toString()
方法。parseCursor
Date
类型,则将字符串转换为 Date
对象。该类为实现游标分页提供了丰富的功能,支持 Redis 和 MySQL 两种数据源的游标分页查询,适用于大规模数据处理的场景。它不仅提供了分页查询的基础功能,还支持游标字段的类型转换,确保了分页逻辑的通用性和灵活性。
public CursorPageBaseResp<UserFriend> getFriendPage(Long uid, CursorPageBaseReq cursorPageBaseReq) {
return CursorUtils.getCursorPageByMysql(this, cursorPageBaseReq,
wrapper -> wrapper.eq(UserFriend::getUid, uid), UserFriend::getId);
}