项目项目游标翻页Chat项目总结
Calyee刮开看看:此篇文章用于总结在聊天项目中学习到的点
游标翻页
游标翻页应对复杂变换的列表
深翻页问题
我们在一般的后端开发场景中,比如管理系统,常常都会有分页条。她可以指定一页的条数以及快捷的调整页码。
现在我们假如前端想查看第11页的内容,传的值为 pageNo=11,pageSize=10
那么对于数据库的查询语句就是:
1
| select * from table limit 100,10
|
其中100
代表需要跳过的条数,10
代表跳过指定条数后,往后需要再取的条数。
对应就是这样的一个效果,需要在数据库的位置先读出100条,然后丢弃。丢弃完100条后,再继续取10条选用。
那么假如我们需要查询到100000条后的10条数据, 那么前面的是不是都被没用的丢弃了?
我们经常需要定时任务全量去跑一张表的数据,普通翻页去跑的话,到后面数据量大的时候,就会越跑越慢,这就是深翻页带来的问题。
游标翻页解决深翻页问题
游标翻页可以完美的解决深翻页问题,依赖的就是我们的游标,即cursor
。针对mysql的游标翻页,我们需要通过cursor
快速定位到指定记录,意味着游标必须添加索引。
下面这个示例就是游标翻页的例子: 我们需要查询101-110的数据, 我们通过索引直接定位到100条的位置, 然后再取十条则是我们想要的
1
| select * from table where id > 100 order by id limit 0,10
|
只要id这个字段有索引,就能直接定位到101这个字段,然后去10条记录。以后无论翻页到多大,通过索引直接定位到读取的位置,效率基本是一样的。这个id>100
就是我们的游标,这就是游标翻页。
前端之前传的pageNo
字段改成了cursor
字段。cursor
是上一次查询结果的位置,作为下一次查询的游标,由后端返回:
那么我们则需要定义示例下面的游标类
1 2 3 4 5 6
| @ApiModelProperty("页大小") @Max(50) private Integer pageSize = 10;
@ApiModelProperty("游标(初始为null,后续请求附带上一次翻页的游标)") private String cursor;
|
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
| @Data @ApiModel("游标翻页返回") @AllArgsConstructor @NoArgsConstructor public class CursorPageBaseResp<T> {
@ApiModelProperty("游标(下次翻页带上这参数)") private String cursor;
@ApiModelProperty("是否最后一页") private Boolean isLast = Boolean.FALSE;
@ApiModelProperty("数据列表") private List<T> list;
public static <T> CursorPageBaseResp<T> init(CursorPageBaseResp cursorPage, List<T> list) { CursorPageBaseResp<T> cursorPageBaseResp = new CursorPageBaseResp<T>(); cursorPageBaseResp.setIsLast(cursorPage.getIsLast()); cursorPageBaseResp.setList(list); cursorPageBaseResp.setCursor(cursorPage.getCursor()); return cursorPageBaseResp; }
@JsonIgnore public Boolean isEmpty() { return CollectionUtil.isEmpty(list); }
public static <T> CursorPageBaseResp<T> empty() { CursorPageBaseResp<T> cursorPageBaseResp = new CursorPageBaseResp<T>(); cursorPageBaseResp.setIsLast(true); cursorPageBaseResp.setList(new ArrayList<T>()); return cursorPageBaseResp; }
}
|
工具类封装
CursorUtils
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
| 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; } } }
|
LambdaUtils
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
| public class LambdaUtils {
private static final Map<String, Map<String, ColumnCache>> COLUMN_CACHE_MAP = new ConcurrentHashMap<>();
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<>();
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(); }
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()); }
private static SerializedLambda _resolve(Serializable func) { return cache.computeIfAbsent(func.getClass().getName(), (key) -> ReflectUtil.invoke(func, "writeReplace")); }
}
|
封装Starter
虽然在前面的SpringBoot篇章我们也封装了一次,但是现在是实战
此时我们需要封装一个OSS的starter,支持自定义切换(简略,仅提供思路)
众所周知,封装一个Starter我们需要提供一个AutoConfiguration给SpringBoot
那么肯定需要创建resource/META-INF/spirng.factories
1 2 3
| org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ com.calyee.chat.oss.MinIOConfiguration
|
这样boot就会扫描到这个自动配置类,然后加载配置
MinIOConfiguration
自动配置类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| @Configuration(proxyBeanMethods = false) @EnableConfigurationProperties(OssProperties.class) @ConditionalOnExpression("${oss.enabled}") @ConditionalOnProperty(value = "oss.type", havingValue = "minio") public class MinIOConfiguration { @Bean @SneakyThrows @ConditionalOnMissingBean(MinioClient.class) public MinioClient minioClient(OssProperties ossProperties) { return MinioClient.builder() .endpoint(ossProperties.getEndpoint()) .credentials(ossProperties.getAccessKey(), ossProperties.getSecretKey()) .build(); } @Bean @ConditionalOnBean({MinioClient.class}) @ConditionalOnMissingBean(MinIOTemplate.class) public MinIOTemplate minioTemplate(MinioClient minioClient, OssProperties ossProperties) { return new MinIOTemplate(minioClient, ossProperties); } }
|
OssProperties
读取yml配置文件
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
| @Data @ConfigurationProperties(prefix = "oss") public class OssProperties {
Boolean enabled;
OssType type;
String endpoint;
String accessKey;
String secretKey;
String bucketName; }
|
仅仅列出上述两点,对于其他的文件大同小异,仅有当前两项是重点,一个是自动配置类,一个是读取配置文件,其中还有一些注解需要理解(一些可以参见SpringBoot章节)
抽象类的使用(最佳实践)
当前抽象类为:Redis批量缓存
先抽象接口
BatchCache interface
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| public interface BatchCache<IN, OUT> {
OUT get(IN req);
Map<IN, OUT> getBatch(List<IN> req);
void delete(IN req);
void deleteBatch(List<IN> req); }
|
在抽象类,抽象的原方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
|
public Map<Long, User> getUserInfoBatch(Set<Long> uids) { List<String> keys = uids.stream().map(a -> RedisKey.getKey(RedisKey.USER_INFO_STRING, a)).collect(Collectors.toList()); List<User> mget = RedisUtils.mget(keys, User.class); Map<Long, User> map = mget.stream().filter(Objects::nonNull).collect(Collectors.toMap(User::getId, Function.identity())); List<Long> needLoadUidList = uids.stream().filter(a -> !map.containsKey(a)).collect(Collectors.toList()); if (CollUtil.isNotEmpty(needLoadUidList)) { List<User> needLoadUserList = userDao.listByIds(needLoadUidList); Map<String, User> redisMap = needLoadUserList.stream().collect(Collectors.toMap(a -> RedisKey.getKey(RedisKey.USER_INFO_STRING, a.getId()), Function.identity())); RedisUtils.mset(redisMap, 5 * 60); map.putAll(needLoadUserList.stream().collect(Collectors.toMap(User::getId, Function.identity()))); } return map; }
|
抽象后的方法,因为后面都需要复用这样的方法,所以抽取公共的
AbstractRedisStringCache
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
| import cn.hutool.core.collection.CollectionUtil; import com.calyee.chat.common.common.utils.RedisUtils; import org.springframework.data.util.Pair;
import java.lang.reflect.ParameterizedType; import java.util.*; import java.util.stream.Collectors;
public abstract class AbstractRedisStringCache<IN, OUT> implements BatchCache<IN, OUT> {
private Class<OUT> outClass;
protected AbstractRedisStringCache() { ParameterizedType genericSuperclass = (ParameterizedType) this.getClass().getGenericSuperclass(); this.outClass = (Class<OUT>) genericSuperclass.getActualTypeArguments()[1]; }
protected abstract String getKey(IN req);
protected abstract Long getExpireSeconds();
protected abstract Map<IN, OUT> load(List<IN> req);
@Override public OUT get(IN req) { return getBatch(Collections.singletonList(req)).get(req); }
@Override public Map<IN, OUT> getBatch(List<IN> req) { if (CollectionUtil.isEmpty(req)) { return new HashMap<>(); } req = req.stream().distinct().collect(Collectors.toList()); List<String> keys = req.stream().map(this::getKey).collect(Collectors.toList()); List<OUT> valueList = RedisUtils.mget(keys, outClass); List<IN> loadReqs = new ArrayList<>(); for (int i = 0; i < valueList.size(); i++) { if (Objects.isNull(valueList.get(i))) { loadReqs.add(req.get(i)); } } Map<IN, OUT> load = new HashMap<>(); if (CollectionUtil.isNotEmpty(loadReqs)) { load = load(loadReqs); Map<String, OUT> loadMap = load.entrySet().stream() .map(a -> Pair.of(getKey(a.getKey()), a.getValue())) .collect(Collectors.toMap(Pair::getFirst, Pair::getSecond)); RedisUtils.mset(loadMap, getExpireSeconds()); }
Map<IN, OUT> resultMap = new HashMap<>(); for (int i = 0; i < req.size(); i++) { IN in = req.get(i); OUT out = Optional.ofNullable(valueList.get(i)) .orElse(load.get(in)); resultMap.put(in, out); } return resultMap; }
@Override public void delete(IN req) { deleteBatch(Collections.singletonList(req)); }
@Override public void deleteBatch(List<IN> req) { List<String> keys = req.stream().map(this::getKey).collect(Collectors.toList()); RedisUtils.del(keys); } }
|
其中 RedisUtils见[开发小手册 | Calyee`Blog](https://blog.calyee.top/2023/11/04/开发小手册/)
样例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| @Component public class UserInfoCache extends AbstractRedisStringCache<Long, User> { @Autowired private UserDao userDao;
@Override protected String getKey(Long uid) { return RedisKey.getKey(RedisKey.USER_INFO_STRING, uid); }
@Override protected Long getExpireSeconds() { return 5 * 60L; }
@Override protected Map<Long, User> load(List<Long> uidList) { List<User> needLoadUserList = userDao.listByIds(uidList); return needLoadUserList.stream().collect(Collectors.toMap(User::getId, Function.identity())); } }
|
请求上下文RequestHolder
对于登录的用户,我们会将uid设置为请求属性,在CollectorInterceptor中统一收集。
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
| @Order @Slf4j @Component public class CollectorInterceptor implements HandlerInterceptor, WebMvcConfigurer {
@Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(this) .addPathPatterns("/**"); }
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { RequestInfo info = new RequestInfo(); info.setUid(Optional.ofNullable(request.getAttribute(TokenInterceptor.ATTRIBUTE_UID)).map(Object::toString).map(Long::parseLong).orElse(null)); info.setIp(ServletUtil.getClientIP(request)); RequestHolder.set(info); return true; }
@Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { RequestHolder.remove(); } }
|
RequestHolder的内部类为 ThreadLocal, ThreadLocal的key为弱引用, Value为强引用, 面试题: ThreadLocal为什么会内存泄漏?