项目收获

刮开看看:此篇文章旨总结在各种项目中学习到的点

Git常用命令

这个就应该被置顶,在你看到这个的时候你可能会想,Git不是直接使用IDEA提供图形化界面就可以了吗?我想说是的,但是在我提交修复前端Bug的时候,VsCode我就没习惯他的插件提交:
故 笔者列出来了常用的命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 创建新分支
git branch 分支名
# 更新本地远程分支情况
git fetch origin

# 查看目前的所有分支
git branch

# 切换分支
git checkout 分支名

# 拉取远程代码更新
git pull origin 分支名

# 合并本地分支到当前本地分支(master指从master合并到当前分支)
git merge master // rebase可切换merge

# 或者直接指定两个分支:(不常用)
git merge master feature

Arthas

Arthas 官网

神级调试工具,笔者目前还在了解中,笔记待更新

简介

Arthas 是一款线上监控诊断产品,通过全局视角实时查看应用 load、内存、gc、线程的状态信息,并能在不修改应用代码的情况下,对业务问题进行诊断,包括查看方法调用的出入参、异常,监测方法执行耗时,类加载信息等,大大提升线上问题排查效率。

背景

通常,本地开发环境无法访问生产环境。如果在生产环境中遇到问题,则无法使用 IDE 远程调试。更糟糕的是,在生产环境中调试是不可接受的,因为它会暂停所有线程,导致服务暂停。

开发人员可以尝试在测试环境或者预发环境中复现生产环境中的问题。但是,某些问题无法在不同的环境中轻松复现,甚至在重新启动后就消失了。

如果您正在考虑在代码中添加一些日志以帮助解决问题,您将必须经历以下阶段:测试、预发,然后生产。这种方法效率低下,更糟糕的是,该问题可能无法解决,因为一旦 JVM 重新启动,它可能无法复现,如上文所述。

Arthas 旨在解决这些问题。开发人员可以在线解决生产问题。无需 JVM 重启,无需代码更改。 Arthas 作为观察者永远不会暂停正在运行的线程。

Arthas(阿尔萨斯)能为你做什么?

Arthas 是 Alibaba 开源的 Java 诊断工具,深受开发者喜爱。

当你遇到以下类似问题而束手无策时,Arthas可以帮助你解决:

这个类从哪个 jar 包加载的?为什么会报各种类相关的 Exception?
我改的代码为什么没有执行到?难道是我没 commit?分支搞错了?
遇到问题无法在线上 debug,难道只能通过加日志再重新发布吗?
线上遇到某个用户的数据处理有问题,但线上同样无法 debug,线下无法重现!
是否有一个全局视角来查看系统的运行状况?
有什么办法可以监控到 JVM 的实时运行状态?
怎么快速定位应用的热点,生成火焰图?
怎样直接从 JVM 内查找某个类的实例?

IDEA 插件辅助

IDEA 下载插件先 Arthas Idea

比如我们现在有一个代码为

1
2
3
4
@Override
public boolean addOrUpdate(TagModel tagModel) {
// ---
}

我需要对其进行监控调用

我们可以右键有Arthas Command,然后可以看到有 watch、trace 等命令

点击之后就会在剪切板上有一个快捷命令

然后在运行的 Arthas 输入即可

使用

把 arthas 拉取下来,然后启动

1
java -jar arthas-boot.jar

然后就会让我们选择诊断的 Java 进程,选择对应进程,例如 [1] my.jar,我们输入 1 就可以了。

使用命令

  1. dashboard 总体jvm面板 线程 jvm内存 环境变量
  2. thead -n 高cpu线程堆栈 thead -b死锁
  3. jad 反编译类 用于看看自己的代码提交没
  4. getstatic 用于得到静态属性的值
  5. ognl 直接修改线上属性的值 救急

以下配合 idea 插件

  1. watch 查看一个方法的入参出参
  2. trance 查看一个方法的栈调用耗时
  3. stack 查看一个方法的栈调用 主要用于查看方法哪里调用
    springboot项目
  4. tt 监控接口入参出参耗时 并且回放接口调用

游标翻页

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

深翻页问题

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

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

AQS

AQS 类定义以及 Node 节点数据结构说明

众所周知 AQS 是AbstractQueuedSynchronizer 的简称

然后他有两个重要的属性

  1. state: volatile int state

它代表着一个同步状态,他在不同的实现子类中有不同的实现

例如在ReentrantLock 中 state = 1 代表当前共享资源已经被加锁,> 1 则代表被多次加锁

然后对于 state 的操作,他有三个方法

1
2
3
final int getState();
final void setState();
final boolean compareAndSetState();

其实compareAndSetState()我们可以看做 CAS

CAS: 全称是CompareAndSwap,是一种用于在多线程环境下实现同步功能的机制。CAS操作包含三个操作数:内存位置预期数值新值.

CAS的实现逻辑是将内存位置处的数值与预期数值想比较,若相等,则将内存位置处的值替换为新值。若不相等,则不做任何操作。

  1. Node 节点

其中 Node 节点结构如下:(双向链表 同步队列)

int waitStatus
Node prev
Node next
Thread thread

对于 Node 节点,做如下解释:当一个线程拿不到锁的时候,他就会被添加到 Node 节点的双向链表中

​ +——+ prev +—–+ +—–+

head | | <—- | | <—- | | tail

​ +——+ +—–+ +—–+

其中 Head 与 Tail 指针是用于标记阻塞线程节点的头尾指针,中间的节点其实就是 Node 节点,可以理解为队列,一个一个的取,处理完成则去除当前线程节点,然后指向下一个节点。

AQS同步队列

如果 thread-0 执行完毕后,他对应的 state = 0,然后释放锁,此时 AQS 队列中排队等待的线程则会按连接顺序依次出队列。此时 thread-1 不是说 thread-1 释放锁了他就直接拿到锁,他还是像之前没有拿到锁一样,通过 cas 进行修改 state 值然后拿锁。假设 thread-1 成功持有锁了,那么 aqs 维护的队列就会让队头元素出队,然后刚刚获取锁的线程成为头结点。然后一直重复入队出队的操作。

:持有锁修改 state 为 1,释放锁修改 state 为 0

从ReentrantLock的非公平独占锁实现来看AQS的原理

通过了解类 AbstractQueuedSynchronizer 我们可以知道他是一个抽象类,其中他还有一个父类AbstractOwnableSynchronizer

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
public abstract class AbstractOwnableSynchronizer implements java.io.Serializable {
private static final long serialVersionUID = 3737899427754241961L;
protected AbstractOwnableSynchronizer() { }

/**
* 目前持有独占锁的线程
* The current owner of exclusive mode synchronization.
*/
private transient Thread exclusiveOwnerThread;

/**
* 此项用于设置上述属性的值
* Sets the thread that currently owns exclusive access.
* A {@code null} argument indicates that no thread owns access.
* This method does not otherwise impose any synchronization or
* {@code volatile} field accesses.
* @param thread the owner thread
*/
protected final void setExclusiveOwnerThread(Thread thread) {
exclusiveOwnerThread = thread;
}

/**
* 此项用于获取上述属性的值
* Returns the thread last set by {@code setExclusiveOwnerThread},
* or {@code null} if never set. This method does not otherwise
* impose any synchronization or {@code volatile} field accesses.
* @return the owner thread
*/
protected final Thread getExclusiveOwnerThread() {
return exclusiveOwnerThread;
}
}

其中 AQS 中比较重要的成员变量有:1. head:头指针 2. tail:尾指针

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
public abstract class AbstractQueuedSynchronizer
extends AbstractOwnableSynchronizer
implements java.io.Serializable {

private static final long serialVersionUID = 7373984972572414691L;

protected AbstractQueuedSynchronizer() { }

/**
* Head of the wait queue, lazily initialized. Except for
* initialization, it is modified only via method setHead. Note:
* If head exists, its waitStatus is guaranteed not to be
* CANCELLED.
*/
private transient volatile Node head;

/**
* Tail of the wait queue, lazily initialized. Modified only via
* method enq to add new wait node.
*/
private transient volatile Node tail;

/**
* The synchronization state.
*/
private volatile int state;

/**
* Returns the current value of synchronization state.
* This operation has memory semantics of a {@code volatile} read.
* @return current state value
*/
protected final int getState() {
return state;
}

/**
* Sets the value of synchronization state.
* This operation has memory semantics of a {@code volatile} write.
* @param newState the new state value
*/
protected final void setState(int newState) {
state = newState;
}

/**
* Atomically sets synchronization state to the given updated
* value if the current state value equals the expected value.
* This operation has memory semantics of a {@code volatile} read
* and write.
*
* @param expect the expected value
* @param update the new value
* @return {@code true} if successful. False return indicates that the actual
* value was not equal to the expected value.
*/
protected final boolean compareAndSetState(int expect, int update) {
// See below for intrinsics setup to support this
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

// Queuing utilities

/**
* The number of nanoseconds for which it is faster to spin
* rather than to use timed park. A rough estimate suffices
* to improve responsiveness with very short timeouts.
*/
static final long spinForTimeoutThreshold = 1000L;

}

state:在前一节已经解释过

spinForTimeoutThreshold:超时中断

其中AQS 中还有一个 Node 静态内部类代码块

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
static final class Node {
/** 共享 -- 共享锁 */
/** Marker to indicate a node is waiting in shared mode */
static final Node SHARED = new Node();
/** 排他 -- 独占锁 */
/** Marker to indicate a node is waiting in exclusive mode */
static final Node EXCLUSIVE = null;
/** 初始化 */
/** waitStatus value to indicate thread has cancelled */
static final int CANCELLED = 1;
/** 下一个节点需要被唤醒 */
/** waitStatus value to indicate successor's thread needs unparking */
static final int SIGNAL = -1;
/** 下一个节点需要被唤醒 */
/** waitStatus value to indicate thread is waiting on condition */
static final int CONDITION = -2;
/**
* waitStatus value to indicate the next acquireShared should
* unconditionally propagate
*/
static final int PROPAGATE = -3;

volatile int waitStatus; // 初始化为0,枚举值使用上面的值

/**
* 前驱节点
*/
volatile Node prev;

/**
* 后继节点
*/
volatile Node next;

/**
* The thread that enqueued this node. Initialized on
* construction and nulled out after use.
*/
volatile Thread thread;

/**
* 在条件队列指向的是下一个指针
* 在同步队列中指向的是我们当前节点想获取的是共享锁还是排他锁
*/
Node nextWaiter;

/**
* Returns true if node is waiting in shared mode.
*/
final boolean isShared() {
return nextWaiter == SHARED;
}

/**
* Returns previous node, or throws NullPointerException if null.
* Use when predecessor cannot be null. The null check could
* be elided, but is present to help the VM.
*
* @return the predecessor of this node
*/
final Node predecessor() throws NullPointerException {
Node p = prev;
if (p == null)
throw new NullPointerException();
else
return p;
}

Node() { // Used to establish initial head or SHARED marker
}

Node(Thread thread, Node mode) { // Used by addWaiter
this.nextWaiter = mode;
this.thread = thread;
}

Node(Thread thread, int waitStatus) { // Used by Condition
this.waitStatus = waitStatus;
this.thread = thread;
}
}

抽象类的使用(最佳实践)

当前抽象类为: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();
}
}

聊天项目中的工厂模式和策略模式组合

例如我们现在有一个需求,对于传入的不同数据进行不同的处理(比如QQ发消息,他聊天框可以发送不同类型的消息,这个是不是就可以用策略模式+工厂模式进行推送处理,即我需要对不同的数据进行不同的处理)

那么就可以使用这个组合:

我们先定义一个抽象类模板:

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
/**
* Description: 消息处理器抽象类
*/
public abstract class AbstractMsgHandler<Req> {
@Autowired
private MessageDao messageDao;
private Class<Req> bodyClass;

@PostConstruct
private void init() {
ParameterizedType genericSuperclass = (ParameterizedType) this.getClass().getGenericSuperclass();
this.bodyClass = (Class<Req>) genericSuperclass.getActualTypeArguments()[0];
MsgHandlerFactory.register(getMsgTypeEnum().getType(), this);
}

/**
* 消息类型
*/
abstract MessageTypeEnum getMsgTypeEnum();

protected void checkMsg(Req body, Long roomId, Long uid) {

}

@Transactional
public Long checkAndSaveMsg(ChatMessageReq request, Long uid) {
Req body = this.toBean(request.getBody());
//统一校验
AssertUtil.allCheckValidateThrow(body);
//子类扩展校验
checkMsg(body, request.getRoomId(), uid);
Message insert = MessageAdapter.buildMsgSave(request, uid);
//统一保存
messageDao.save(insert);
//子类扩展保存
saveMsg(insert, body);
return insert.getId();
}
//
private Req toBean(Object body) {
if (bodyClass.isAssignableFrom(body.getClass())) {
return (Req) body;
}
return BeanUtil.toBean(body, bodyClass);
}
// 需要保存的逻辑
protected abstract void saveMsg(Message message, Req body);

/**
* 展示消息
*/
public abstract Object showMsg(Message msg);

/**
* 被回复时——展示的消息
*/
public abstract Object showReplyMsg(Message msg);

/**
* 会话列表——展示的消息
*/
public abstract String showContactMsg(Message msg);

}

提供一个实现类样例

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
@Component
public class EmojisMsgHandler extends AbstractMsgHandler<EmojisMsgDTO> {
@Autowired
private MessageDao messageDao;

@Override
MessageTypeEnum getMsgTypeEnum() {
return MessageTypeEnum.EMOJI;
}

@Override
public void saveMsg(Message msg, EmojisMsgDTO body) {
MessageExtra extra = Optional.ofNullable(msg.getExtra()).orElse(new MessageExtra());
Message update = new Message();
update.setId(msg.getId());
update.setExtra(extra);
extra.setEmojisMsgDTO(body);
messageDao.updateById(update);
}

@Override
public Object showMsg(Message msg) {
return msg.getExtra().getEmojisMsgDTO();
}

@Override
public Object showReplyMsg(Message msg) {
return "表情";
}

@Override
public String showContactMsg(Message msg) {
return "[表情包]";
}
}

然后我们需要提供一个工厂创建场景对象

1
2
3
4
5
6
7
8
9
10
11
12
13
public class MsgHandlerFactory {
private static final Map<Integer, AbstractMsgHandler> STRATEGY_MAP = new HashMap<>();

public static void register(Integer code, AbstractMsgHandler strategy) {
STRATEGY_MAP.put(code, strategy);
}

public static AbstractMsgHandler getStrategyNoNull(Integer code) {
AbstractMsgHandler strategy = STRATEGY_MAP.get(code);
AssertUtil.isNotEmpty(strategy, CommonErrorEnum.PARAM_INVALID);
return strategy;
}
}

使用

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 发送消息
*/
@Override
@Transactional
public Long sendMsg(ChatMessageReq request, Long uid) {
check(request, uid);
AbstractMsgHandler<?> msgHandler = MsgHandlerFactory.getStrategyNoNull(request.getMsgType()); // 这里
Long msgId = msgHandler.checkAndSaveMsg(request, uid);
//发布消息发送事件
applicationEventPublisher.publishEvent(new MessageSendEvent(this, msgId));
return msgId;
}

实际开发也能用到(笔者在实习的时候就用上了!)

一步一步改造

场景:我 需要对不同数据进行不同的处理,什么个不同法,比如这个数据需要求和 另外一个需要求平均 还有需要求时间最早的等等等场景

1
2
CountModeType: 求和/平均 ...
Data: List<?>/String[] ...

实际开发中的工厂加策略模式

笔者扩展的

工厂模式 1(策略抽象一层)

对于固定的模版(比如处理 excel 的数据,他是固定的)

(下面图例为工厂 1 的模式)

img

那么我们可以定义一个模版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import javax.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.List;

/**
* @ClassName AbstractDataHandler
* @Description 数据处理抽象类
* @Author Calyee
*/

public abstract class AbstractDataHandler {

@PostConstruct
private void init() {
DataHandlerFactory.register(getCountModeEnums(), this);
}
/**
* 获取模式类型
*
* @return CountModeEnums
*/
public abstract CountModeEnums getCountModeEnums();
public abstract Object processData(List<?> o);
}

策略(例如我们在此是求和策略,当然也可以求平均)

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
import java.util.List;

/**
* @ClassName SumHandler
* @Description 求和处理
* @Author Calyee
*/

@Component
public class OperationSumBaseDataHandler extends AbstractDataHandler {

@Override
public CountModeEnums getCountModeEnums() {
return CountModeEnums.sum;
}

@Override
public Object processData(List<?> o) {
Assert.notNull(o, "当前处理的数据不能为空");
Object type = o.get(0);
switch (type.getClass().getSimpleName()) {
case "Integer":
return o.stream()
.mapToInt(Integer.class::cast)
.sum();
case "Long":
return o.stream()
.mapToLong(Long.class::cast)
.sum();
case "Double":
return o.stream()
.mapToDouble(Double.class::cast)
.sum();
case "Float":
return o.stream()
.mapToDouble(Float.class::cast)
.sum();
default:
throw new RuntimeException("该类型暂不支持");
}
}
}

工厂类型 1:我们需要提供方法给他进行调用计算

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
import org.springframework.util.Assert;

import java.util.HashMap;
import java.util.Map;

public class DataHandlerFactory {
private static final Map<CountModeEnums, AbstractDataHandler> STRATEGY_MAP = new HashMap<>();

public static void register(CountModeEnums enums, AbstractDataHandler strategy) {
STRATEGY_MAP.put(enums, strategy);
}

public static AbstractDataHandler getStrategyNoNullByObj(Object o) {
CountModeEnums countModeEnums = null;
if (o instanceof Example) {
Example p = (Example) o;
countModeEnums = p.getCountModeEnums();
}else if(){
// 其他的枚举
}else{

}
return haveSuitableStrategy(countModeEnums); // 拿到具体的策略
}
private static AbstractDataHandler haveSuitableStrategy(CountModeEnums enums) {
AbstractDataHandler handler = STRATEGY_MAP.get(enums);
Assert.notNull(handler, "没有对应的处理策略");
return handler;
}
}

使用的表现形式体现为:我们需要手动调用,这种方式常常用于策略模式很多的情况

1
2
3
4
5
6
7
8
Example avg = new Example();
avg.setCountModeEnums(CountModeEnums.avg);
avg.setData(Arrays.asList(1.0, 2.0, 3.0, 4.0, 5.0));
AbstractHandler avgHandler = DataHandlerFactory.getStrategyNoNullByObj(avg);
// ========这一步我们需要手动调用=========
Object oavg = avgHandler.processData(sum.getData());
// =====================================
log.info("avg: {}, exp: {}, equals: {}", oavg, 3.0, oavg.equals(3.0)); // 3.0 3.0 true

工厂模式一总结:对于策略固定且策略较多的情况 ,我们可以使用此类型,即我们模版可以使用同一个,然后具体实现具体分析,通过给对象然后从工厂生产出具体的策略,然后自己调用

工厂模式 2(策略抽象两层 可拓展性更强)

当前模式适用于更复杂的场景,例如:

(工厂 2 模式 出场)

img

对于当前的策略:

定义基础抽象父类模版

1
2
3
4
5
6
7
/**
* @ClassName AbstractHandler
* @Description 上层基础模版
* @Author Calyee
*/
public abstract class AbstractHandler {
}

分区抽象模版

基础数据处理抽象类

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
/**
* @ClassName AbstractDataHandler
* @Description 基础数据处理抽象模版方法
* @Author Calyee
*/

public abstract class AbstractDataBaseOperationHandler extends AbstractHandler {
@PostConstruct
private void init() {
DataHandlerFactory.register(getCountModeEnums(), this);
}
/**
* 获取模式类型
*
* @return CountModeEnums
*/
public abstract CountModeEnums getCountModeEnums();

/**
* 数据处理
* @param o
* @return
*/
public abstract Object processData(List<?> o);
}
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
/**
* @ClassName AbstractDataTimeOperationHandler
* @Description 对于时间的运算抽象模版方法
* @Author Calyee
*/

public abstract class AbstractDataTimeOperationHandler extends AbstractHandler {
@PostConstruct
private void init() {
DataHandlerFactory.register(getCountModeEnums(), this);
}

/**
* 获取模式类型
*
* @return CountModeEnums
* @see com.bdsoft.peration.enums.CountModeEnums
*/
public abstract CountModeEnums getCountModeEnums();

/**
* 处理年份数据
* 说明:时间与数据应该一一对应(例如)
* data1: 2023-03-01 2023-03-02 2023-03-04 2023-03-06 ...
* data2: 100 300 20 40 ...
*
* @param data1 时间对应
* @param data2 数据对应
* @return ans
*/
public abstract Object processData(List<Date> data1, List<?> data2);
}

策略具体实现(仅给出一个案例)

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
/**
* @ClassName SumHandler
* @Description 求和处理
* @Author Calyee
*/

@Component
public class OperationSumBaseDataHandler extends AbstractDataBaseOperationHandler {

@Override
public CountModeEnums getCountModeEnums() {
return CountModeEnums.sum;
}

@Override
public Object processData(List<?> o) {
Assert.notNull(o, "当前处理的数据不能为空");
Object type = o.get(0);
switch (type.getClass().getSimpleName()) {
case "Integer":
return o.stream()
.mapToInt(Integer.class::cast)
.sum();
case "Long":
return o.stream()
.mapToLong(Long.class::cast)
.sum();
case "Double":
return o.stream()
.mapToDouble(Double.class::cast)
.sum();
case "Float":
return o.stream()
.mapToDouble(Float.class::cast)
.sum();
default:
throw new RuntimeException("该类型暂不支持");
}
}
}

工厂方法

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
public class DataHandlerFactory {
private static final Map<CountModeEnums, AbstractHandler> STRATEGY_MAP = new HashMap<>();

public static void register(CountModeEnums enums, AbstractHandler strategy) {
STRATEGY_MAP.put(enums, strategy);
}

public static Object getStrategyNoNullByObj(Object o) {
if (o instanceof ExampleBaseDataCalculate) { // 简单基础数据运算
ExampleBaseDataCalculate p = (ExampleBaseDataCalculate) o;
AbstractDataBaseOperationHandler handler = (AbstractDataBaseOperationHandler) haveSuitableStrategy(p.getCountModeEnums());
return handler.processData(p.getData());
} else if (o instanceof ExampleYearCalculate) {// 年份数据运算
ExampleYearCalculate p = (ExampleYearCalculate) o;
AbstractDataTimeOperationHandler handler = (AbstractDataTimeOperationHandler) haveSuitableStrategy(p.getEnums());
return handler.processData(p.getData1(), p.getData2());
} else {
return "暂时没有该处理";
}
}


private static AbstractHandler haveSuitableStrategy(CountModeEnums enums) {
AbstractHandler handler = STRATEGY_MAP.get(enums);
Assert.notNull(handler, "没有对应的处理策略");
return handler;
}
}

测试用例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ExampleBaseDataCalculate sum = new ExampleBaseDataCalculate();
// 模拟查询到的数据
sum.setCountModeEnums(CountModeEnums.sum);
sum.setData(Arrays.asList(1.0, 2.0, 3.0, 4.0, 5.0));
// 数据处理
Object ans = DataHandlerFactory.getStrategyNoNullByObj(sum);

log.info("sum: {}, exp:{}, equals: {}", ans, 15.0, ans.equals(15.0));


ExampleBaseDataCalculate avg = new ExampleBaseDataCalculate();
// 模拟查询到的数据
avg.setCountModeEnums(CountModeEnums.avg);
avg.setData(Arrays.asList(1.0, 2.0, 3.0, 4.0, 5.0));
// 数据处理
Object avgAns = DataHandlerFactory.getStrategyNoNullByObj(avg);
log.info("avg: {}, exp:{}, equals: {}", avgAns, 3.0, avgAns.equals(3.0));

通过测试案例 可以知道,我们现在的话 不是通过自己去调用方法去处理了,而是直接传入对象及其行为,直接返回数据处理结果即可。

小总结

对于 1,我们是采用的 直接回送策略,然后自己进行处理,然后模版是一致的

对于 2,我们采用的是 传入对象,我们直接内部处理 让调用者不需要关心里面具体的逻辑,传入对象即可,然后抽象是多层的,我们可以通过这个定义某一系列的行为,然后去实现

MyBatis

MyBatisPlus saveBatch 优化

设置rewriteBatchedStatements=true批量插入

下面我们为数据库的连接加上rewriteBatchedStatements=true的属性,再测试批量加入的耗时。

1
rewriteBatchedStatements=true

rewriteBatchedStatements

Mybatis 插件机制Interceptor与InnerInterceptor

在 Mybatis 中,插件机制提供了强大的扩展能力,在 sql 最终执行之前,提供了四个拦截点,支持不同场景的功能扩展:

  • Executor (update, query, flushStatements, commit, rollback, getTransaction, close, isClosed)
  • ParameterHandler (getParameterObject, setParameters)
  • ResultSetHandler (handleResultSets, handleOutputParameters)
  • StatementHandler (prepare, parameterize, batch, update, query)

例如我们需要对查询出来的数据进行解密(数据库保存的是加密信息,然后我们可以定义一个注解取修饰字段,假如字段被这个注解修饰的话,那么后续的操作我们则需要进行加密解密)

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
/**
* 解密
**/
@Intercepts({
@Signature(type = ResultSetHandler.class, method = "handleResultSets", args = {Statement.class})
})
@Component
@Slf4j
public class DecryptInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
Object resultObject = invocation.proceed();
if (Objects.isNull(resultObject))
return null;
if (resultObject instanceof ArrayList) {
//基于selectList
ArrayList resultList = (ArrayList) resultObject;
if (!CollectionUtils.isEmpty(resultList) && needToDecrypt(resultList.get(0))) {
for (Object result : resultList) {
//逐一解密
decrypt(result);
}
}
} else if (needToDecrypt(resultObject))
//基于selectOne
decrypt(resultObject);
return resultObject;
}
@Override
public Object plugin(Object o) {
return Plugin.wrap(o, this);
}
// function needToDecrypt -> 这个方法其实就是判断字段是否有我们自定义的注解修饰
}

/**
* @description 加密和完整性计算拦截器 (目前完整性计算只支持mybatis plus 自带的API)
**/
@Slf4j
public class EncryptAndVerfyWholenessInterceptor extends JsqlParserSupport implements InnerInterceptor {
/**
* 变量占位符正则
*/
private static final Pattern PARAM_PAIRS_RE = Pattern.compile("#\\{ew\\.paramNameValuePairs\\.(" + Constants.WRAPPER_PARAM + "\\d+)\\}");

/**
* 如果查询条件是加密数据列,那么要将查询条件进行数据加密。
* 例如,手机号加密存储后,按手机号查询时,先把要查询的手机号进行加密,再和数据库存储的加密数据进行匹配
*/
@Override
public void beforeQuery(Executor executor, MappedStatement mappedStatement, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) {
// 具体逻辑 不列出,可查看解密逻辑
}
/**
* 新增、更新数据时,如果包含隐私数据,则进行加密
* 还有个问题,进行修改时,如果传过来的实体类的属性有属于脱敏的数据,那么这个脱敏的数据没有被修改的情况下需要被识别出来,不更新到数据库
*/
@Override
public void beforeUpdate(Executor executor, MappedStatement mappedStatement, Object parameterObject) throws SQLException {
// 具体逻辑 不列出,可查看解密逻辑
}
}

关系型数据库实现类动态字段数据库

为什么需要用到动态数据库呢?

场景:我们有一个需求就是(以前端的视角来描述),前端的需求需要动态渲染指标列,假如后端多了一个指标,那么前端也需要对应上去也多一个列去动态渲染。

​ 这个时候对于普通的关系型数据库是做不到的,因为普通的我们指标字段是定下来的,此时的这个老传统数据结构是解决不了这个需求的,那么我和同事想到了可以使用动态模版数据库。(什么是动态模版数据库,其实就是通过定义模版表单,然后具体的表单对应具体的字段指标,然后数据绑定表单与具体的指标列)

那么 — 因为动态数据库对应的数据,一次性填的是一个指标的项,然后到最后查询查询出来的却是列级别数据, 这个时候与前端渲染模式有点出入,那么这个时候我们需要使用到数据翻转。

那么表结构可以参考如下:

指标表

column name column type desc
id varchar PK
indicator_name varchar 指标名称
parent_id varchar 上级指标
usable varchar 是否可用
sort_num int 排序字段

填报表单

column name column type desc
id varchar PK
form_id varchar 表单id
form_desc varchar 表单描述
sec_indicator_id varchar FK 表单二级指标id
sec_indicator_name varchar 表单二级指标名字
year int 填报年份(用于统计)
post_status varchar 表单发表状态(发布则不可再选,具体业务具体分析)
usable varchar 表单是否可用
editable varchar 表单是否可编辑
post_org_id varchar FK 表单发布单位id
post_org_name varchar 表单发布单位名

当前的二级指标指的是:可以理解为一共就只有三个指标,一级指标就是一级路由,然后展开就是二级指标(路由),二级路由对应绑定了页面,页面渲染的是三级指标

填报数据

column name column type desc
id varchar PK
form_id varchar FK 填报表单id
form_name varchar 填报表单名称
org_id varchar FK 填报单位id
person_id varchar FK 填报人id
indicator_id varchar FK 指标id(三级指标)
data varchar 指标值(例如: 100)
status varchar 数据状态

data:为填报的数据

可能这个表部分地方设计不合理,但是大概步骤是这样的,如果有人看到或者发现有啥建议,可以在下面评论,笔者会第一时间回复(虽然应该没人看😆)

数据翻转与 Stream 流操作

渲染分析与翻转

对于我们现在有一个这样的要求就是,我们的数据在第一次处理完成之后是这样的

Year Data [ 对应的指标列,即模版数据库的一行数据 ]
2023 { groupName: “指标 1”,value: 100 , other: “其他描述” }
2024 { groupName: “指标 2”,value: 100 , other: “其他描述” }

这个一行是指:例如我们在 excel 中,一次填报一行的数据,其中一行就对应一个指标值。此时可以填报多列(多个指标),只不过在数据库中不在同一行存储

可能这个多个指标有点抽象,那么我们在此场景仅理解为一个列即可

上面那个表格也许在数据库中,我们通过联表查询后是这样:

year groupName value other
2023 指标 1 100 其他描述
2024 指标 1 100 其他描述

那么对应 Java 中,我们在通过 ORM 框架查询后,转换为 Bean 后,他就是对应一个 List list。其中 bean 很容易知道属性有 year、groupName …

此时我们可以通过 stream 流处理数据,把他变成一个 map

1
2
Map<Integer, List<OurBean>> groupByYear = 
list.stream().collect(Collectors.groupingBy(OurBean::getYear));

然后就变成一个 Map 了,Key 是我们根据年份分组的,value 就是剩下的字段

此时就是笔者第一次列出来的表格。

如果是这样的结构,返回给前端是这样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
code: 200,
data:{
[
{
year: 2023,
otherData:{
groupName: "指标 1",
value: 100,
other: "其他描述"
}
},
{
year: 2024,
otherData:{
groupName: "指标 1",
value: 100,
other: "其他描述"
}
}
]
}
msg: "查询成功"
}

因为前端是渲染表格,我们这样的话,返回的是一个列的数据 [ 就是他们本来就是属于一个指标下的 ],按照逻辑来说,一个指标下的数据我们应该这样显示

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
{
code: 200,
data:{
"2023": [
{
groupName: "指标 1",
value: 100,
other: "其他描述"
},
{
groupName: "指标 2",
value: 300,
other: "这是本年度指标二数据下的值(前面没有列出)"
}
],
"2024": [
{
groupName: "指标 1",
value: 100,
other: "其他描述"
},
{
groupName: "指标 2",
value: 200,
other: "描述"
}
]
},
msg: "查询成功"
}

就是行级数据,这样才是正常的渲染逻辑,一次性渲染一行,第一次渲染 2023 年度的一指标与二指标的数据,第二次渲染第二行 2024 年度的数据。

对于这种列数据转换行数据,我们同样是使用 map 操作,前面那个渲染结果结构,显然就是 key-value 的结构,只不过 value 是对应多个的情况,那么即可定义

ByYearInnerDataVO:为我们需要的渲染内部结构

1
Map<Integer, List<ByYearInnerDataVO>> result = new HashMap<>();

按照年度分组

1
2
Map<String, List<PreviCalculateVo>> processMap = 
resultList.stream().collect(Collectors.groupingBy(PreviCalculateVo::getYear));

第一层遍历每一个年份组数据

1
2
3
4
for (Map.Entry<String, List<PreviCalculateVo>> entry 
: processMap.entrySet()) {
// 处理逻辑
}

第二层处理 value 的 list 结构

1
2
3
entry.getValue().forEach((k) -> {
// 处理里面的值
}

最后的总体结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Map<String, List<PreviCalculateVo>> processMap = resultList.stream().collect(Collectors.groupingBy(PreviCalculateVo::getYear));

for (Map.Entry<String, List<PreviCalculateVo>> entry : processMap.entrySet()) {
int year = Integer.parseInt(entry.getKey());
entry.getValue().forEach((k) -> {
List<ByYearInnerDataVO> list = yearIndicatorData.get(year); // 有此年份数据
if (CollectionUtil.isEmpty(list)) {
list = new ArrayList<>();
}
list.add(new ByYearInnerDataVO()
.setValue(k.getValue())
.setData(k.getData()));
// replace
result.put(Integer.parseInt(entry.getKey()), list);
});
}

似山代码

开始写似山了

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
// 5.4 处理不同的长度填充
// yearIndicatorData.entrySet().stream().map(entry->{
// List<ByYearInnerDataVO> collect = entry.getValue().stream().map(i -> {
// ByYearInnerDataVO empty = new ByYearInnerDataVO();
// if (!yearAndIndicator.containsKey(i.getIndicatorName())) {
// // 则需要补充空
// empty.setIndicatorName(i.getIndicatorName());
// }
// return empty;
// }).collect(Collectors.toList());
// return new AbstractMap.SimpleEntry<>(entry.getKey(), collect);
// }).collect(Collectors.groupingBy(Map.Entry::getKey, Collectors.mapping(Map.Entry::getValue, Collectors.toList())));
Map<Integer, List<ByYearInnerDataVO>> optimizedData = yearIndicatorData.entrySet().stream()
.collect(Collectors.toMap(Map.Entry::getKey,
entry -> {
Set<String> indicator = yearAndIndicator.keySet(); // 指标对应年份 -- 即补充标准
return indicator.stream() // 创建指标集合的流
.map(indicatorName -> { // 对每个指标名进行处理
return entry.getValue().stream() // 获取当前年份的内部数据列表的流
.filter(i -> indicatorName.equals(i.getIndicatorName())) // 过滤出匹配指标名的数据(按照补充标准)
.findFirst() // 找到第一个匹配的数据(则不需要处理,使用原有数据即可)
.orElseGet(() -> { // 如果没有找到,则创建并返回一个新的ByYearInnerDataVO实例
return new ByYearInnerDataVO()
.setIndicatorName(indicatorName) // 设置指标名
.setCount("0")
.setRate("/")
.setIncr("/")
.setValue(0);
}); // 补充空数据
}).collect(Collectors.toList()); // 将结果收集成列表
}
));

替换方案

改用传统 for 循环

替换代码如下(减少了流的使用、它需要频繁创建流对象,采用迭代器,,对于业务无关紧要的代码提取)

TIPS: 使用for循环的版本避免了内部流操作的多次迭代,减少了函数调用,并且直接操作了数据结构,这通常会带来更好的性能。在某些情况下,特别是在处理复杂的数据转换和操作时,流可以提供更简洁、更易读的代码。但是,对于性能敏感的操作,特别是在数据量很大时,传统的for循环可能是一个更好的选择。

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
Map<Integer, List<ByYearInnerDataVO>> result = new HashMap<>();
for (Map.Entry<Integer, List<ByYearInnerDataVO>> entry : yearIndicatorData.entrySet()) {
List<ByYearInnerDataVO> dataList = new ArrayList<>(); // 对应每一个结果
for (String indicatorName : indicatorData) { // 指标标准数据
Optional<ByYearInnerDataVO> found = entry.getValue().stream()
.filter(i -> indicatorName.equals(i.getIndicatorName()))
.findFirst();
dataList.add(found.orElseGet(
() -> createEmptyData(indicatorName))); // 符合条件则构造空数据
}
result.put(entry.getKey(), dataList); // 结果封装
}

// 建议使用(手动设置)
private static ByYearInnerDataVO createEmptyData(String indicatorName) {
ByYearInnerDataVO innerDataVO = new ByYearInnerDataVO();
innerDataVO.setIndicatorName(indicatorName);
innerDataVO.setCount("0");
innerDataVO.setRate("/");
innerDataVO.setIncr("/");
innerDataVO.setValue(0);
return innerDataVO;
}


// 方法提取(链式 不建议)
private ByYearInnerDataVO createEmptyData(String indicatorName) {
return new ByYearInnerDataVO()
.setIndicatorName(indicatorName)
.setCount("0")
.setRate("/")
.setIncr("/")
.setValue(0);
}

再优化(不使用链式调用创建对象,采用手动 填充数据,并且声明为静态类):

1
2
3
4
5
6
7
8
9
private static ByYearInnerDataVO createEmptyData(String indicatorName) {
ByYearInnerDataVO innerDataVO = new ByYearInnerDataVO();
innerDataVO.setIndicatorName(indicatorName);
innerDataVO.setCount("0");
innerDataVO.setRate("/");
innerDataVO.setIncr("/");
innerDataVO.setValue(0);
return innerDataVO;
}

Excel/Word 数据相关

EasyExcel

Excel 可以使用阿里的 easyexcel

这里什么都没有
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
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelWriter;
import com.alibaba.excel.converters.longconverter.LongStringConverter;
import com.alibaba.excel.util.DateUtils;
import com.alibaba.excel.write.metadata.WriteSheet;
import com.alibaba.excel.write.metadata.fill.FillConfig;
import com.alibaba.excel.write.style.column.LongestMatchColumnWidthStyleStrategy;
import org.springframework.web.multipart.MultipartFile;

import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.List;
import java.util.Map;

/**
* Excel 工具类
*/
public class ExcelUtils {

/**
* @description 模板填充方式Excel导出
* 模板注意 用{} 来表示你要用的变量 如果本来就有"{","}" 特殊字符 用"\{","\}"代替
* {} 代表普通变量 {.} 代表是list的变量
* @param response 响应
* @param dataList 数据行列表(list的变量)
* @param dataMap 普通数据变量
* @param filename 文件名
* @param templatePath 填充模板路径
* @return void
**/
public static <T> void templateFillWrite(HttpServletResponse response, List<T> dataList, Map<String, Object> dataMap,
String filename, String templatePath) throws IOException {
response.setCharacterEncoding("utf-8");
String preFileName = filename + DateUtils.format(new Date(),DateUtils.DATE_FORMAT_14);
response.setHeader("Content-disposition", "attachment; filename=" + URLEncoder.encode(preFileName, "UTF-8") + ".xlsx");
response.setContentType("application/vnd.ms-excel;charset=UTF-8");

ExcelWriter excelWriter = EasyExcel
.write(response.getOutputStream())
.withTemplate(templatePath)
.autoCloseStream(Boolean.FALSE) // 这里需要设置不关闭流
.build();
WriteSheet writeSheet = EasyExcel.writerSheet()
.build();
// 开启forceNewRow
FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build();
// 写入list数据
excelWriter.fill(dataList,fillConfig,writeSheet);
// 填充模板参数
excelWriter.fill(dataMap, writeSheet);
//结束
excelWriter.finish();
}

/**
* 自定义表头Excel导出
*
* @param response 响应
* @param filename 文件名
* @param sheetName Excel sheet 名
* @param head Excel head 头
* @param data 数据列表哦
* @param <T> 泛型,保证 head 和 data 类型的一致性
* @throws IOException 写入失败的情况
*/
public static <T> void writeWithCusTomHeader(HttpServletResponse response, String filename, String sheetName,
List<List<String>> head, List<T> data) throws IOException {
// 1.输出 Excel
EasyExcel.write(response.getOutputStream())
.head(head)
.autoCloseStream(false) // 不要自动关闭,交给 Servlet 自己处理
.registerWriteHandler(new LongestMatchColumnWidthStyleStrategy()) // 基于 column 长度,自动适配。最大 255 宽度
.registerConverter(new LongStringConverter()) // 避免 Long 类型丢失精度
.sheet(sheetName).doWrite(data);
// 2.设置 header 和 contentType。写在最后的原因是,避免报错时,响应 contentType 已经被修改了
response.addHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode(filename, StandardCharsets.UTF_8.name()));
response.setContentType("application/vnd.ms-excel;charset=UTF-8");
}

/**
* 将列表以 Excel 响应给前端
*
* @param response 响应
* @param filename 文件名
* @param sheetName Excel sheet 名
* @param head Excel head 头
* @param data 数据列表哦
* @param <T> 泛型,保证 head 和 data 类型的一致性
* @throws IOException 写入失败的情况
*/
public static <T> void write(HttpServletResponse response, String filename, String sheetName,
Class<T> head, List<T> data) throws IOException {
// 1.输出 Excel
EasyExcel.write(response.getOutputStream(), head)
.autoCloseStream(false) // 不要自动关闭,交给 Servlet 自己处理
.registerWriteHandler(new LongestMatchColumnWidthStyleStrategy()) // 基于 column 长度,自动适配。最大 255 宽度
.registerConverter(new LongStringConverter()) // 避免 Long 类型丢失精度
.sheet(sheetName).doWrite(data);
// 2.设置 header 和 contentType。写在最后的原因是,避免报错时,响应 contentType 已经被修改了
response.addHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode(filename, StandardCharsets.UTF_8.name()));
response.setContentType("application/vnd.ms-excel;charset=UTF-8");
}

public static <T> List<T> read(MultipartFile file, Class<T> head) throws IOException {
return EasyExcel.read(file.getInputStream(), head, null)
.autoCloseStream(false) // 不要自动关闭,交给 Servlet 自己处理
.autoTrim(true) // 自动去除空格
.sheet()
.doReadSync();
}

}

Apache poi

Apache poi

如果是需要创建 Word 直接使用下面这个(他是创建很棒的Word文档

https://deepoove.com/poi-tl

类加载读取resource

有用!(可以与上面的导出生成Excel/Word相结合)

如果是使用模板填充的方式,那么需要保存模板,我们则需要读取模板文件,如果传统的放入代码文件夹,我们会发现是打包不了的(笔者血的教训),bane五年则需要采用下面的方式

类加载读取 resource 文件

[springboot项目读取 resources 目录下的文件的9种方式(总结)-阿里云开发者社区 (aliyun.com)](https://developer.aliyun.com/article/1462486#:~:text=10:总结 1 使用 ClassLoader.getResourceAsStream () 方法 这是一种通用的方式,可以适用于大多数情况。 2,目录下的文件,可以使用 Spring 提供的 ClassPathResource 类来加载文件。 这种方式比较简单,不需要提供完整的文件路径。 需要注意的是%3A使用不同的方式需要了解其适用的场景和使用方法。 对于不同的项目和需求,可能需要选择不同的方式。)

XxlJob定时任务

数据同步方案

2024.8.12今天mentor给我布置了一个数据同步的任务,其实要做到数据同步,方案还是挺多的,我们可以使用

– 需要内嵌代码

  1. Spring Listener发布监听事件
  2. Spring Schedule自带的定时任务

– 不需要内嵌代码

  1. alibaba canel:监听MySQL的Binlog日志,需要开启MySQL的日志,需要读取二进制文件,可以用 但是不是很友好对于笔者当前的场景
  2. XxlJog:在笔者当场景适合,不在原有的业务逻辑上新增代码,我们只需要定时去轮询数据库的数据(对于仅仅需要操作少量数据),如果你是ToC可能这个轮询数据库的操作不是很适合,对于笔者ToG的项目当然合适,反正用户量也不多,定时同步完全够了

我们只需要做到增量同步/全量同步即可

动态数据源

具体如何切换,参考(我们一般在 Service 业务层上控制切换的切面)

1
2
3
4
5
@Service
@DS("fzzyk")
public class FzzykSAuRoleServiceImpl extends ServiceImpl<FzzykSAuRoleMapper, FzzykSAuRole> implements FzzykSAuRoleService {

}

如果是这样的设置,我们在注入该服务的时候就是用的从数据源,当然还可以在方法上面去使用该注解

例如 MyBatisPlus 提供的样例

1
2
3
4
5
6
7
8
9
10
11
12
13
@Service
@DS("slave")
public class UserServiceImpl implements UserService {

@Autowired
private JdbcTemplate jdbcTemplate;

@Override
@DS("slave_1")
public List selectByCondition() {
return jdbcTemplate.queryForList("select * from user where age >10");
}
}

当前执行顺序以局部为主,类上面决定里面默认数据源,方法上决定该局部的数据源

XxlJob 正轨

具体按照参考[分布式任务调度平台XXL-JOB (xuxueli.com)](https://www.xuxueli.com/xxl-job/#1.5 下载)

我们直接把源码拉取过来即可。

在前面的所有步骤都完成之后(即到官网的 HelloWord 步骤时),我们可以开始创建自己的 Job 模块

在把代码拉取下来之后

我们的项目结构是现在这个样子的

  • xxl-job

    • xxl-job-admin:网页调度中心
    • xxl-job-core:公共依赖
    • xxl-job-executor:我们的 Job 任务模块
      • xxl-job-executor-my-job:我们自己创建的业务

怎么创建 Job:[分布式任务调度平台XXL-JOB (xuxueli.com)](https://www.xuxueli.com/xxl-job/#3.2 BEAN模式(方法形式))

1
2
3
4
5
// 可参考Sample示例执行器中的 "com.xxl.job.executor.service.jobhandler.SampleXxlJob" ,如下:
@XxlJob("demoJobHandler")
public void demoJobHandler() throws Exception {
XxlJobHelper.log("XXL-JOB, Hello World.");
}

到这里了,那么剩下的其实就和普通的 SpringBoot 项目开发业务逻辑一模一样的,我们要操作数据库,那么先定义实体,然后 Service+Mapper(dao)去数据访问以及业务处理。

但是这里我们可以用类似于工厂模式的方法去实现动态的对象处理

eg: 假如我们的 Job 是这样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Component
@Slf4j
public class SAuUserJob {

@Autowired
private BasePushExecutor executor;
@Autowired
private ISAuUserService sAuUserService;

@XxlJob("SAuUserJob")
public void doJobHandler() {
log.info("[系统用户数据同步开始]");
String params = XxlJobHelper.getJobParam();
if (StrUtil.isNotEmpty(params))
XxlJobHelper.log("接收到参数:" + params);
// 可以增加参数

// 执行
executor.baseHandler(sAuUserService, "USER_PUSH");
log.info("[系统用户数据同步结束]");
}
}

BasePushExecutor 其实就是定义的一个抽象接口

1
2
3
public interface BasePushExecutor {
void baseHandler(BaseService service, String type);
}

具体实现

1
2
3
4
5
6
7
@Component
public class BasePushExecutorImpl implements BasePushExecutor {
@Override
public void baseHandler(BaseService baseService, String type) {
baseService.pushData();
}
}

其中这个 baseService 就是具体业务实现类的抽象接口父类

可以理解成为这个结构

  • BaseService

    • AService:具体业务 A
    • BService:具体业务 B

我们观察到这个 BaseService 他有一个方法规范为 void pushData();

那么我们在使用 MyBatisPlus 的时候,在实现 完 IService 的时候,也得实现 BaseService

1
public interface ISAuUserService extends IService<SAuUser>, BaseService {}

这样的话,在后续继承实现该类的时候,我们只需要实现父类处理业务的接口即可

1
2
3
4
5
6
7
8
9
10
11
12
@Service
public class SAuUserServiceImpl extends ServiceImpl<SAuUserMapper, SAuUser> implements ISAuUserService {

@Autowired
@Qualifier("sAuUserServiceTargetlfgj")
private com.xxl.job.executor.dataTarget.service.SAuUserServiceLfgj sAuUserServiceTargetlfgj;

@Override
public void pushData() {
// 具体业务
}
}

项目结构如下:

img

笔者的场景:数据同步。

那么对于源数据其实就是原始数据,主数据。

对于目标数据处理,那么不就是把主数据同步到目标数据吗,只不过拉取数据的规范在源数据里面实现了。

对于 XxlJob 业务,其实就是定义控制,类似于 Controller,我们可以在 Web 端控制任务的执行以及参数的传入,根据不同的需求执行不同的业务定义。

异步任务

CompletableFuture

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 第一次推送
if (firstPush) {
long startT = System.currentTimeMillis();
batchData = this.lambdaQuery().eq(STreeNode::getDeleteFlag, 0).list();
List<FzzykSTreeNode> fzzykSTreeNodes = BeanUtil.copyToList(batchData, FzzykSTreeNode.class);
List<LfgjSTreeNode> lfgjSTreeNodes = BeanUtil.copyToList(batchData, LfgjSTreeNode.class);
// ASYNC
CompletableFuture<Void> fzzykFuture = CompletableFuture.runAsync(
() -> fzzykISTreeNodeService.saveBatch(fzzykSTreeNodes));
CompletableFuture<Void> lfgjFuture = CompletableFuture.runAsync(
() -> lfgjistreeNodeService.saveBatch(lfgjSTreeNodes));
// Optionally wait for both futures to complete
CompletableFuture.allOf(fzzykFuture, lfgjFuture).join();
long endT = System.currentTimeMillis();
log.info("[STreeNode Copy Success!,{}, 执行时间:{} 毫秒", new Date(), (endT - startT));
}

在上述案例中,我们在批量更新/插入的时候,可能不是对于一个数据源,当前部分应该与上一个部分以及上上个部分相结合(#MyBatisPlus saveBatch 优化 && #XxlJob -> 多数据源),实现异步多批量插入,一个任务的执行不会阻塞另外一个

当前方案笔者测试未通过

ThreadPoolTaskExecutor 线程池

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 第一次推送
if (firstPush) {
long startT = System.currentTimeMillis();
batchData = this.lambdaQuery().eq(STreeNode::getDeleteFlag, 0).list();
if (CollectionUtil.isEmpty(batchData)) {
return;
}
List<FzzykSTreeNode> fzzykSTreeNodes = BeanUtil.copyToList(batchData, FzzykSTreeNode.class);
List<LfgjSTreeNode> lfgjSTreeNodes = BeanUtil.copyToList(batchData, LfgjSTreeNode.class);
int split = 3, size = batchData.size();
for (int i = 0; i < split; i++) {
int start = i * size / split;
int end = (i + 1) * size / split;
executor.execute(() -> {
fzzykISTreeNodeService.saveBatch(fzzykSTreeNodes.subList(start, end));
lfgjistreeNodeService.saveBatch(lfgjSTreeNodes.subList(start, end));
});
}
long endT = System.currentTimeMillis();
log.info("[STreeNode Copy Success!,{}, 执行时间:{} 毫秒", new Date(), (endT - startT));
}

还是这个熟悉,

ElasticSearch

依赖

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>

EsUtil

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
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.toolkit.LambdaUtils;
import com.baomidou.mybatisplus.core.toolkit.support.SFunction;
import com.baomidou.mybatisplus.core.toolkit.support.SerializedLambda;
import lombok.SneakyThrows;
import org.springframework.data.elasticsearch.annotations.Field;

/**
* es工具类
*/
public class EsUtil {

/**
* 获取es实体类列名
*/
@SneakyThrows
public static <T> String getColumn(SFunction<T, ?> func) {
SerializedLambda resolve = LambdaUtils.resolve(func);
Class<?> implClass = resolve.getImplClass();
String implMethodName = resolve.getImplMethodName();
String fieldName = getFieldName(implMethodName);
java.lang.reflect.Field field = implClass.getDeclaredField(fieldName);
Field fieldAnno = field.getAnnotation(Field.class);
if (null != fieldAnno && ObjectUtil.isNotEmpty(fieldAnno.name())) {
return fieldAnno.name();
} else {
return getFieldName(implMethodName);
}
}

public static String getFieldName(String getterOrSetterName) {
if (getterOrSetterName.startsWith("get") || getterOrSetterName.startsWith("set")) {
return StrUtil.removePreAndLowerFirst(getterOrSetterName, 3);
} else if (getterOrSetterName.startsWith("is")) {
return StrUtil.removePreAndLowerFirst(getterOrSetterName, 2);
} else {
throw new IllegalArgumentException("Invalid Getter or Setter name: " + getterOrSetterName);
}
}
}

EsInit

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
public class ElasticSearchInit {
/**
* 创建索引库
* 需要创建一个实体类,其中配置实体类和文档的映射关系,使用注解配置
* @title createIndex
* @param restTemplate
* @param indexName
* @param T
* @throws
*/
public static void createIndex(ElasticsearchRestTemplate restTemplate,String indexName, Class T){
if (!restTemplate.indexOps(IndexCoordinates.of(indexName)).exists()){
//创建索引库
restTemplate.indexOps(IndexCoordinates.of(indexName)).create();
//设置mapping信息
Document mapping = restTemplate.indexOps(IndexCoordinates.of(indexName)).createMapping(T);
restTemplate.indexOps(IndexCoordinates.of(indexName)).putMapping(mapping);
}
}

/**
* 只添加索引
* @param restTemplate
* @param indexName
*/
public static void createIndex(ElasticsearchRestTemplate restTemplate,String indexName){
if (!restTemplate.indexOps(IndexCoordinates.of(indexName)).exists()){
//创建索引库
restTemplate.indexOps(IndexCoordinates.of(indexName)).create();
}
}

/**
* 添加索引的字段映射
* @param restTemplate
* @param indexName
* @param T
*/
public static void createIndexMappings(ElasticsearchRestTemplate restTemplate,String indexName, Class T){
if (!restTemplate.indexOps(IndexCoordinates.of(indexName)).exists()){
//设置mapping信息
Document mapping = restTemplate.indexOps(IndexCoordinates.of(indexName)).createMapping(T);
restTemplate.indexOps(IndexCoordinates.of(indexName)).putMapping(mapping);
}
}

/**
* 删除索引库
* @title deleteIndex
* @param restTemplate
* @param indexName
* @throws
*/
public static void deleteIndex(ElasticsearchRestTemplate restTemplate,String indexName){
if (restTemplate.indexOps(IndexCoordinates.of(indexName)).exists()){
restTemplate.indexOps(IndexCoordinates.of(indexName)).delete();
}
}
}
1
2
3
4
@Component
public interface XxxxSearchRepository
extends ElasticsearchRepository<XxxxSearch, String> {
}

那么这个其实就是有点类似于 MyBatisPlus 那一套

其中Repository 就是实现的他原封装的

操作:Controller -> Service -> Repository

到这里 基本结构已经没什么问题了

分页查询 + 高亮 + 内联查询

例如我现在有一个需求,我需要查询 一页二十条数据 并且 我需要查询的字段为 Name 其中查询出来的数据得高亮

那么对于构建复杂的查询,笔者是推荐使用NativeSearchQueryBuilder 结合 BoolQueryBuilder。

那么大体结构我们可以这样:

1
2
3
4
// 构造最后查询条件
NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();
// 设置分页参数
queryBuilder.withPageable(PageRequest.of((page - 1), size));

收集内联条件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
BoolQueryBuilder innerQuery = QueryBuilders.boolQuery();
if (!StrUtil.isEmptyOrUndefined(name)) {
// 内条件查询
innerQuery.must(QueryBuilders.matchQuery(
EsUtil.getColumn(XxxxSearch::getName), // 查询字段
name
));
highlightBuilder.field(EsUtil.getColumn(XxxxSearch::getName))
.preTags("<span style='color:red'>")// 设置高亮 颜色
.postTags("</span>")
.numOfFragments(0)
.fragmentSize(250);
}
// 例如
boolQueryBuilder.must(innerQuery()); // 这个must可以更换为其他的 具体见官网

// 小结:对于需要构造复杂的查询,我们可以使用BoolQueryBuilder套BoolQueryBuilder进行筛选

然后条件补充完之后,再吧当前内条件(innerQuery)给封装到外条件(boolQueryBuilder)中,最后在封装到原生查询条件(NativeSearchQueryBuilder)中

1
2
3
4
5
6
// 查询条件收集
queryBuilder.withQuery(boolQueryBuilder);
// 高亮条件收集
queryBuilder.withHighlightBuilder(highlightBuilder);
// 根据得分排序
queryBuilder.withSort(SortBuilders.fieldSort("_score").order(SortOrder.DESC));

结果集查询

此时则不能使用旧的那个 search(即实现了ElasticsearchRepository 的那个类,因为它在高版本已经被废弃了),我们可以采用注入

1
2
@Autowired
private ElasticsearchRestTemplate elasticsearchTemplate;

然后所以这个模版方法进行查询

1
2
SearchHits<XxxxSearch> searchHits = elasticsearchTemplate.search(queryBuilder.build(),
XxxxSearch.class);

处理高亮

高亮其实就行遍历我们刚刚拿到的SearchHits,然后里面会有一个getHighlightFields( )

1
2
3
4
5
6
7
8
9
10
11
for (SearchHit<XxxxSearch> searchHit : searchHits) {
Map<String, Object> map = new HashMap<>();
//高亮的内容
Map<String, List<String>> highlightFields = searchHit.getHighlightFields();
XxxxSearch obj = searchHit.getContent();
map.put("id", obj.getId());
// Flag1可以理解为:我这个字段是否参与了高亮的操作
map.put("name", flag1 ? highlightFields.get("name").get(0) : obj.getName());
// 结果列表封装
titleMaps.add(map);
}

分页对象封装

1
2
3
4
5
6
7
PageInfo<Map<String, Object>> info = new PageInfo<>();
info.setSize((int) searchHits.getTotalHits()); // CurrentSize
info.setList(titleMaps); // 刚刚处理完高亮的结果
info.setTotal(searchHits.getTotalHits()); // Total
info.setPageSize(size); // PageSize
info.setPageNum(page); // PageNum
return info;

可视化 Edge 小插件

去拓展商店搜索 ‘es-client’即可下载,连接即可使用

问题项

  1. 如果你的实体字段日期属性使用的是 Date,那么在格式化的时候会出现异常。

解决方案:改为LocalDate

  1. 对于 ES 的数据同步问题:我们有两个方案 ① 一个是前端对于一个数据库操作发送两个请求(一个是操作数据库,一个是对于刚刚的数据进行 es 同步)② 后端操作:我们可以在需要数据同步的方法里同步进行 es 和 sql 操作。

很显然:② 比较合适,因为前端可能会因为网络波动而导致第二个请求失效。

做聊天项目遇到一些题

ThreadLocal

RequestHolder的内部类为 ThreadLocal, ThreadLocal的key为弱引用, Value为强引用, 面试题: ThreadLocal为什么会内存泄漏?

Redis与MySQL的数据一致性

  • 先删后更
    先删除缓存,后更新数据库
    可以采用的方案:延迟双删

  • 先更后删
    先更新数据库,后删除缓存
    采用中间添加一个消息中间件

    1
    2
    数据库        生产消息        消息中间件     消费消息       缓存
    SQL -> (推送需要删除缓存的消息)-> MQ -> (消费推送的消息) —> 更新Redis

进程 线程 多线程

进程:是系统运行的基本单位,例如 qq 等,他运行其实就可以看作成进程,从 qq 的运行到结束的过程可以看作成进程的创建到消亡的过程,其中进程可以包含很多个线程组成
线程:进程和线程很像,但是线程是一个比进程更小的执行单位。

什么时候使用多线程,什么时候使用多进程?

● 多线程
对于 I/O 密集型,因为需要大量的 IO 操作,在进行 IO 操作时候 CPU 处于空闲状态,那么可以交给其他的线程去执行
● 多进程
对于 CPU 密集型,他需要大量的 CPU 计算,那么可以充分利用多进程进行计算,提升效率(多核 CPU)
当需要并行计算的时候也可以使用多进程

如何查看死锁?判断发生了死锁?

● Jconsole:jdk 自带的图形化界面
● jstack:jps -ef | grep 进程 id

项目部署上去了,有用过日志吗?怎么在服务器查看日志?

1
2
3
tail -f xxx.log    # 动态查看日志
tail -10 xxx.log # 查看最后10行日志
tail -n 10 xxx.log # 查看最后10行日志

引申 :

如果需要查看某进程(例如需要查看 java 的进程)

1
ps -ef | grep java

如果需要不挂起运行,然后后台可以指定日志

1
nohup /root/runoob.sh > runoob.log 2>&1 &

如需检查 CPU 使用 top 和 cat(特定路径)

1
2
3
# 通过proc文件系统
cat /proc/cpuinfo #查看cpu信息
cat /proc/meminfo #查看memery信息
  • top
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# top效果
top - 21:35:19 up 12 days, 2:19, 1 user, load average: 0.00, 0.00, 0.00
Tasks: 107 total, 1 running, 106 sleeping, 0 stopped, 0 zombie
%Cpu(s): 0.3 us, 0.2 sy, 0.0 ni, 99.5 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
MiB Mem : 1890.1 total, 77.4 free, 1505.6 used, 307.2 buff/cache
MiB Swap: 0.0 total, 0.0 free, 0.0 used. 231.7 avail Mem

PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
7634 root 20 0 137292 21596 9016 S 0.7 1.1 97:19.68 AliYunDunMonito
2086 root 20 0 1287708 10156 7096 S 0.3 0.5 79:23.59 argusagent
7623 root 20 0 94708 8892 6384 S 0.3 0.5 58:19.71 AliYunDun
8415 root 20 0 689932 9512 6064 S 0.3 0.5 11:25.31 aliyun-service
8596 root 20 0 19432 2696 1740 S 0.3 0.1 2:17.24 assist_daemon
21434 root 20 0 3348728 885380 9064 S 0.3 45.7 26:24.20 java
32110 root 20 0 11844 3764 3104 R 0.3 0.2 0:00.01 top

内存使用 top free cat(特定路径)

  • free -h
1
2
3
4
5
free -h  #h:以人类可读的单位显示例如1G 23M
root@xxxxxxxx:~# free -h
total used free shared buff/cache available
Mem: 1.8Gi 1.5Gi 77Mi 2.0Mi 307Mi 232Mi
Swap: 0B 0B 0B
  • free -m
1
2
3
4
free -m  #m:以mb为单位显示
root@xxxxxxxx:~# free -m
total used free shared buff/cache available
Mem: 1890 1504 77 2 307 232

查看Java进程相关

查看java后端服务对应的进程ID

  • jps -l
    生成线程快照 并保存为文件
  • jstack 进程id >dump_threads.txt