Chat项目总结

刮开看看:此篇文章用于总结在聊天项目中学习到的点

游标翻页

游标翻页应对复杂变换的列表

深翻页问题

我们在一般的后端开发场景中,比如管理系统,常常都会有分页条。她可以指定一页的条数以及快捷的调整页码

现在我们假如前端想查看第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<>();

/**
* 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表达式,加了缓存。
* 该缓存可能会在任意不定的时间被清除。
*
* <p>
* 通过反射调用实现序列化接口函数对象的writeReplace方法,从而拿到{@link SerializedLambda}<br>
* 该对象中包含了lambda表达式的所有信息。
* </p>
*
* @param func 需要解析的 lambda 对象
* @return 返回解析后的结果
*/
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
# Auto Configure
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;

/**
* OSS 访问端点,集群时需提供统一入口
*/
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) {
//批量组装key
List<String> keys = uids.stream().map(a -> RedisKey.getKey(RedisKey.USER_INFO_STRING, a)).collect(Collectors.toList());
//批量get
List<User> mget = RedisUtils.mget(keys, User.class);
Map<Long, User> map = mget.stream().filter(Objects::nonNull).collect(Collectors.toMap(User::getId, Function.identity()));
//发现差集——还需要load更新的uid
List<Long> needLoadUidList = uids.stream().filter(a -> !map.containsKey(a)).collect(Collectors.toList());
if (CollUtil.isNotEmpty(needLoadUidList)) {
//批量load
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);
//加载回redis
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;

/**
* Description: redis string类型的批量缓存框架
*/
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());
//组装key
List<String> keys = req.stream().map(this::getKey).collect(Collectors.toList());
//批量get
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<>();
//不足的重新加载进redis
if (CollectionUtil.isNotEmpty(loadReqs)) {
//批量load
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为什么会内存泄漏?