开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

钉钉小程序实现签名板

发表于 2024-01-29

a9bfc1a2df7e55aa9fd786810e95fee9.png

前言

古茗目前已经有近万家门店了,为了对门店做规范管理,会进行巡店且输出巡店报告,此时就需要有一个老板签名的功能,证明老板认可且了解当前结果。由于我们巡店用到的是钉钉小程序,所以下面将会为大家展示如何在小程序中实现一个签名板功能。

签名效果

设计实现

为了实现签名功能,需要用到 canvas,我们翻阅钉钉 api 文档,发现支持Canvas组件,very nice,下面开始实现。(由于我们内部使用 taro 框架,以下代码均为 taro + react,我们设计稿均为 750,所以样式中数值均是实际的2倍)

创建canvas

我们先在页面中创建一个canvas画布

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
jsx复制代码// SignaturePad.jsx
import { Canvas } from '@tarojs/components';

const SignaturePad = () => {
return (
<Canvas
id="signature"
canvasId="signature"
className="canvas"
width="343"
height="180"
/>
)
}

// SignaturePad.less
.canvas {
background: #fff;
}

1705740633476.png

此时会发生一个神奇的现象,明明设置了 width=343 和 height=180,怎么还是钉钉默认的 300x225 ?别急,我们往下走。

调整画布

为了得到正确的展示大小,我们可以通过设置样式实现

1
2
3
4
5
6
jsx复制代码// SignaturePad.less
.canvas {
background: #fff;
width: 100%;
height: 360px;
}

1705741273750.png

确实是起效了,那么设置width和height属性有什么用呢,我们看下钉钉文档,可以发现这两个属性可以用来控制绘画精细度,解决在高dpr的情况下造成的绘画模糊问题。

1705741420270.png

这里还需要注意,宽高属性需要和css中宽高属性保持相同比例,否则绘画会出现扭曲情况

初始化

画布创建完成了,接下来需要实现画笔功能,这时候就需要结合CanvasContext绘图上下文对象预设画笔属性以及后续绘图需要用到的坐标轴

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
jsx复制代码// SignaturePad.jsx
let ctx = null;
let startX = 0;
let startY = 0;

const SignaturePad = () => {
const initCanvas = () => {
// 创建 canvas 的绘图上下文 CanvasContext 对象
ctx = Taro.createCanvasContext('signature');
// 设置描边颜色
ctx.setStrokeStyle('#000000');
// 设置线条的宽度
ctx.setLineWidth(4);
// 设置线条的端点样式
ctx.setLineCap('round');
// 设置线条的交点样式
ctx.setLineJoin('round');
};

useEffect(() => {
initCanvas();
return () => {
ctx = null;
};
}, []);

...
}

绘画

所有准备工作完成,然后就是如何实现绘画功能了。想要实现绘画,要对 canvas 有所了解,canvas 元素默认被网格所覆盖。通常来说网格中的一个单元相当于 canvas 元素中的一像素。栅格的起点为左上角,坐标为 (0,0) 。所有元素的位置都相对于原点来定位。所以图中蓝色方形左上角的坐标为距离左边(X 轴)x 像素,距离上边(Y 轴)y 像素,坐标为 (x, y)

Canvas_default_grid.png

Canvas相关属性

image.png

了解了基础知识,我们就基本知道如何实现了。通过onTouchStart确定画笔开始坐标,onTouchMove获取用户在canvas内的绘画路径,将路径上所有的点都填充上颜色。

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
jsx复制代码// 是否绘画过
const isPaint = useRef(false)

const canvasStart = (e) => {
startX = e.touches[0].x;
startY = e.touches[0].y;
// 开始创建一个路径
ctx.beginPath();
};

const canvasMove = (e) => {
if (startX !== 0 && !isPaint.current) {
isPaint.current = true;
}
const { x, y } = e.touches[0];
// 把路径移动到画布中的指定点,不创建线条
ctx.moveTo(startX, startY);
// 增加一个新点,然后创建一条从上次指定点到目标点的线
ctx.lineTo(x, y);
// 画出当前路径的边框
ctx.stroke();
// 将之前在绘图上下文中的描述(路径、变形、样式)画到 canvas 中
ctx.draw(true);
startX = x;
startY = y;
};

const canvasEnd = () => {
ctx.closePath();
};

return (
<Canvas
id="signature"
canvasId="signature"
className="canvas"
onTouchStart={canvasStart}
onTouchMove={canvasMove}
onTouchEnd={canvasEnd}
onTouchCancel={canvasEnd}
width="343"
height="180"
disableScroll // 禁止屏幕滚动以及下拉刷新
/>
)

添加操作

到这里,基础的绘画已经完成了,但是我们是需要将生成的签名保存到服务端的,所以还需要有一个确定操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
jsx复制代码const createImg = async () => {
if (!isPaint.current) {
Taro.showToast({
title: '签名内容不能为空!',
icon: 'none',
});
return false;
}
// 把画布内容导出成图片,返回文件路径
const { filePath } = await ctx.toTempFilePath();
// 这里就可以做拿到路径的后续操作了
// ...
};

有了确定操作,假如用户签错名字了想要重写,还需要一个清除操作。

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
jsx复制代码let canvasw = 0;
let canvash = 0;

// 获取 canvas 的尺寸(宽高)
const getCanvasSize = () => {
nextTick(() => {
// 小程序查询节点信息方法
const query = Taro.createSelectorQuery();
query
.select('#signature')
.boundingClientRect()
.exec(([rect]) => {
canvasw = rect.width;
canvash = rect.height;
});
});
};

useEffect(() => {
getCanvasSize();
...
}, []);

const clearDraw = () => {
startX = 0;
startY = 0;
// 清除画布上在该矩形区域内的内容
ctx.clearRect(0, 0, canvasw, canvash);
ctx.draw(true);
setIsPaint(false);
};

签名-1.gif
到这里,一个基础的签名板已经完成了,但是还有一些可以优化的地方,下面我们将继续对它进行一些优化。

优化

撤回

清空虽然能解决用户写错的问题,但是只撤回上一笔对用户体验来说是更好的。我们可以创建一个history用于记录用户每一次绘画,然后通过getImageData获取canvas区域隐含的像素数据,将其push()到history中,在触发撤回操作时,将最新一条数据pop()同时清空画布,再通过putImageData将history最后一条像素数据绘制到画布上,这样就能实现撤回效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
jsx复制代码const history = useRef([]);

const canvasEnd = async () => {
ctx.closePath();
const res = await ctx.getImageData({ x: 0, y: 0, width: canvasw, height: canvash });
history.current.push(res);
};

// 撤回
const revoke = () => {
if (!history.current.length) return;
history.current.pop();
if (!history.current.length) {
ctx.clearRect(0, 0, canvasw, canvash);
ctx.draw(true);
return;
}
ctx.putImageData(history.current[history.current.length - 1]);
};

签名-3.gif

横屏

竖屏时签字区域相对较小,只要将其切到横屏那么体验将会好非常多了。查阅钉钉文档,发现并没有提供小程序切换横竖屏的api,那么只能我们自己做一个横屏效果了。我们可以通过rotate和translate样式,将签名版横置,再对其调整宽高。

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
jsx复制代码// SignaturePad.jsx
const [full, setFull] = useState(false);

const toggleSize = () => {
setFull(!full);
};

return (
<View className="signature-pad-wrap">
<View className={`signature-pad ${full ? 'full-screen' : ''}`}>
{/* canvas */}
...
</View>
</View>
)

// SignaturePad.less
.signature-pad {
box-sizing: border-box;
width: 100%;
padding: 32px 32px 30px;
transform-origin: top left;
transition: transform 0.3s;

.canvas {
width: 686px;
height: 360px;
background: #fff;
}

&.full-screen {
width: 100vh;
height: 100vw;
transform: rotate(90deg) translate(0, -756px);

.canvas {
width: 1386px;
height: 630px;
}
}
}

签名-4.gif

然后,我们就可以看到如图效果,签名版是横置了,但是这个签名功能明显不对了。通过打印onTouchMove的event,我们发现x,y依然是(0, 0),因为屏幕的xy轴不会变,但是我们旋转了整个签名版,所以展示出的canvas的xy轴是跟随着变形了,导致了上图情况。

第一步第二步

既然canvas旋转会导致xy轴变化,那么我们可以换个角度,只改变canvas的宽高,将标题按钮区域进行transform是不是就可以了

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
jsx复制代码// SignaturePad.jsx
<View className={`signature-pad ${full ? 'full-screen' : ''}`}>
<View className="signature-top">
<View className="title">签名板</View>
{/* 一系列按钮 */}
</View>
</View>
<Canvas
id="signature"
canvasId="signature"
className="canvas"
...
/>
</View>

// SignaturePad.less
.signature-pad {
.signature-top {
transform-origin: top left;
transition: transform 0.3s;
}

.canvas {
width: 686px;
height: 360px;
overflow: hidden;
background: #fff;
}

&.full-screen {
.signature-top {
position: absolute;
width: calc(100vh - 64px);
transform: translate(686px, 0) rotate(90deg);
}

.canvas {
width: 630px;
height: 1386px;
}
}
}

签名-6.gif

ok,可以看到,签名功能又正常了。但是,在我们点击清空的时候发现清空也坏了,这是因为我们调用的clearRect是清除画布上在该矩形区域内的内容,所以原本在初始化获取的Canvas宽高在横屏的时候实际上已经发生了变化,只要在横屏时重新获取一次组件宽高即可

1
2
3
4
5
6
jsx复制代码const toggleSize = () => {
setFull(!full);
setTimeout(() => {
getCanvasSize();
}, 200);
};

好了,到这里已经能得到一个相对完整的签名版功能了

签名-7.gif

总结

以上就是签名版的实现,实际上H5的实现也是类似的,只是某些部分会和小程序有所区别。整个签名板的实现基本上就是使用canvas,没有特别复杂的点,但是过程中总会遇到奇奇怪怪的问题,当你一个一个解决之后,你会发现,今天的姿势又能+1,这不就是程序员的快乐吗。感谢阅读。

最后

📚 小茗文章推荐:

  • JSPDF + html2canvas A4分页截断
  • Formily JSON Schema 渲染流程及案例浅析
  • 古茗是如何做前端数据中心的

关注公众号「Goodme前端团队」,获取更多干货实践,欢迎交流分享~

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

美团今年的校招薪资。。。

发表于 2024-01-27

美团校招情况分析

老规矩了,校招薪资分析和建议,这次轮到美团。

先来看看和公众号读者相关性较高的岗位对应的校招待遇:

开发 算法 产品 运营
白菜 18k~21k 21k~24k 18k~19k 10k~14k
SP 22k~27k 25k~30k 20k~22k 15k~16k
SSP 28k~32k 31k~46k 23k~25k 17k~18k

以上所有岗位 \ 15.5 即为年包收入。*

部分岗位(例如算法的 SP 及以上)还会有签字费(一般为 555W)和股票(不定,最高去到 70w70w70w,分四年归属)。

虽然「大厂校招薪资」系列文章我们已经写到了第 555 篇了,但还是有同学会对一些概念比较模糊。

这一定程度上也反映了确实不少首次参与校招的同学会关注这个系列。

既然这个系列本就以帮助他们为初衷,那么有义务连带着对一些概念进行解释。

  • base:每月的税前底薪;
  • 签字费:你可以理解成新员工的红包奖金,只有部分面试阶段特别优秀的人选或重要岗位会有。会在入职的时候的直接发放,只发放一次;
  • 年终奖/绩效奖:通常我们会用「base * X个月」来计算年包中的现金部分,那么 X 中大于 121212 个月的部分,就是年终奖。

年终奖没有一个统一的标准,有些好的公司会写到劳动合同上,有些则不写,甚至还有连 offer 上都不写年终奖细则的公司,仅在与 HR 的交流中进行口头承诺;

年终奖和签字费不同,是后面的年份都按这个标准执行,并非只承诺入职第一年的哈。除非晋升或者内部调岗,才有可能会重新调整(这个有不少同学搞混这个)。

  • 期权股票:这是年包中的非现金部分,也不是所有入职员工都会有的,除非公司全员持股。

一般来说,不同的公司,会根据是否已上市,有不同的发放规则。

对于新人入职,关于期权股票发放,最为常见的是,承诺给你 xxxx 期权/股票,分 xxxx 年归属/到账(一般是 333~555 年),然后可能季度或者年度,还会再给一些期权/股票。

只有归属/到账后,你才能按比例参与赎回/交易,转成现金。

这部分可能还是有点抽象,举个实际的 🌰 吧:

例如入职时承诺给 100010001000 股,等额按 444 年归属,归属后需等待 111 年才能换现金。

那么最快的第一笔,需要在入职第 222 年年末才能换现金(第 111 年归属、第 222 年等待);最慢的第四笔,需要在入职第 555 年年末才能换现金(第 444 年归属、第 555 年等待)。

也就是说,入职送的这 100010001000 股,实际上你需要很多年后才能彻底换现金。中间的价格波动,也会反映成你的升值 or 贬值。

好,交代完这些基本概念之后,你大概率明白美团的年包大概去到什么水平了。

…

看完校招薪资待遇,传统习惯,我们还会从读者或者网上,收集一些在该厂工作的真实体验分享给大家。

这次分享给大家的是来自牛客网的一篇热乎实习贴:

还就是那个“开水团”:暗指除了开水免费喝,没有其他福利。

但公司氛围还是不错的,各位同学可结合自身条件进行综合比较。

…

回归主线。

想拿美团的校招 offer,需要掌握好算法,尤其是需要掌握「链表」类的算法题。

在 LeetCode 上的美团真题榜中,排名前三的,有两道是链表类题目。

我们今天先来做排名前三中通过率最低的一道。

题目描述

平台:LeetCode

题号:82

存在一个按升序排列的链表,给你这个链表的头节点 head,请你删除链表中所有存在数字重复情况的节点,只保留原始链表中没有重复出现的数字。

返回同样按升序排列的结果链表。

示例 1:

1
2
3
ini复制代码输入:head = [1,2,3,3,4,4,5]

输出:[1,2,5]

示例 2:

1
2
3
ini复制代码输入:head = [1,1,1,2,3]

输出:[2,3]

提示:

  • 链表中节点数目在范围 [0,300][0, 300][0,300] 内
  • −100<=Node.val<=100-100 <= Node.val <= 100−100<=Node.val<=100
  • 题目数据保证链表已经按升序排列

基本思路

几乎所有的链表题目,都具有相似的解题思路。

  1. 建一个虚拟头节点 dummy 以减少边界判断,往后的答案链表会接在 dummy 后面
  2. 使用 tail 代表当前有效链表的结尾
  3. 通过原输入的 head 指针进行链表扫描

我们会确保「进入外层循环时 head 不会与上一节点相同」,因此插入时机:

  1. head 已经没有下一个节点,head 可以被插入
  2. head 有一下个节点,但是值与 head 不相同,head 可以被插入

Java 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Java复制代码class Solution {
public ListNode deleteDuplicates(ListNode head) {
ListNode dummy = new ListNode();
ListNode tail = dummy;
while (head != null) {
// 进入循环时,确保了 head 不会与上一节点相同
if (head.next == null || head.val != head.next.val) {
tail.next = head;
tail = head;
}
// 如果 head 与下一节点相同,跳过相同节点
while (head.next != null && head.val == head.next.val) head = head.next;
head = head.next;
}
tail.next = null;
return dummy.next;
}
}

C++ 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
C++复制代码class Solution {
public:
ListNode* deleteDuplicates(ListNode* head) {
ListNode* dummy = new ListNode();
ListNode* tail = dummy;
while (head != nullptr) {
// 进入循环时,确保了 head 不会与上一节点相同
if (head->next == nullptr || head->val != head->next->val) {
tail->next = head;
tail = head;
}
// 如果 head 与下一节点相同,跳过相同节点
while (head->next != nullptr && head->val == head->next->val) head = head->next;
head = head->next;
}
tail->next = nullptr;
return dummy->next;
}
};

Python 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Python复制代码class Solution:
def deleteDuplicates(self, head: Optional[ListNode]) -> Optional[ListNode]:
dummy = ListNode()
tail = dummy
while head:
# 进入循环时,确保了 head 不会与上一节点相同
if not head.next or head.val != head.next.val:
tail.next = head
tail = head
# 如果 head 与下一节点相同,跳过相同节点
while head.next and head.val == head.next.val: head = head.next
head = head.next
tail.next = None
return dummy.next

TypeScript 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
TypeScript复制代码function deleteDuplicates(head: ListNode | null): ListNode | null {
const dummy: ListNode = new ListNode();
let tail: ListNode | null = dummy;
while (head) {
// 进入循环时,确保了 head 不会与上一节点相同
if (!head.next || head.val !== head.next.val) {
tail.next = head;
tail = head;
}
// 如果 head 与下一节点相同,跳过相同节点
while (head.next && head.val === head.next.val) head = head.next;
head = head.next;
}
tail.next = null;
return dummy.next;
};
  • 时间复杂度:O(n)O(n)O(n)
  • 空间复杂度:O(1)O(1)O(1)

拓展

如果问题变为「相同节点保留一个」,该如何实现?

还是类似的解题思路。

  1. 建一个虚拟头节点 dummy 以减少边界判断,往后的答案链表会接在 dummy 后面
  2. 使用 tail 代表当前有效链表的结尾
  3. 通过原输入的 head 指针进行链表扫描

Java 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Java复制代码class Solution {
public ListNode deleteDuplicates(ListNode head) {
if (head == null) return head;
ListNode dummy = new ListNode(-109);
ListNode tail = dummy;
while (head != null) {
// 值不相等才追加,确保了相同的节点只有第一个会被添加到答案
if (tail.val != head.val) {
tail.next = head;
tail = tail.next;
}
head = head.next;
}
tail.next = null;
return dummy.next;
}
}

C++ 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
C++复制代码class Solution {
public:
ListNode* deleteDuplicates(ListNode* head) {
if (!head) return head;
ListNode* dummy = new ListNode(-109);
ListNode* tail = dummy;
while (head) {
// 值不相等才追加,确保了相同的节点只有第一个会被添加到答案
if (tail->val != head->val) {
tail->next = head;
tail = tail->next;
}
head = head->next;
}
tail->next = nullptr;
return dummy->next;
}
};

Python 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
Python复制代码class Solution:
def deleteDuplicates(self, head: Optional[ListNode]) -> Optional[ListNode]:
if not head: return head
dummy = ListNode(-109)
tail = dummy
while head:
# 值不相等才追加,确保了相同的节点只有第一个会被添加到答案
if tail.val != head.val:
tail.next = head
tail = tail.next
head = head.next
tail.next = None
return dummy.next

TypeScript 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
TypeScript复制代码function deleteDuplicates(head: ListNode | null): ListNode | null {
if (!head) return head;
const dummy: ListNode = new ListNode(-109);
let tail: ListNode | null = dummy;
while (head) {
// 值不相等才追加,确保了相同的节点只有第一个会被添加到答案
if (tail.val !== head.val) {
tail.next = head;
tail = tail.next;
}
head = head.next;
}
tail.next = null;
return dummy.next;
};
  • 时间复杂度:O(n)O(n)O(n)
  • 空间复杂度:O(1)O(1)O(1)

我是宫水三叶,每天都会分享算法题解,并和大家聊聊近期的所见所闻。

欢迎关注,明天见。

更多更全更热门的「笔试/面试」相关资料可访问排版精美的 合集新基地 🎉🎉

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

这简历,进大厂能拿几k?

发表于 2024-01-26

今天要揭开高校里一些不为人知的真相,可能有些敏感,有可能会被和谐,请大家转发你们的同学,让更多人看到。

先给大家看一份简历,看看你们觉得他进大厂能拿多少薪资。

好家伙,又是蓝桥杯二等奖,国家二等奖,西部赛区二等奖,妥妥的大奖收割机啊。

大家感觉如何呢?没感觉?那再看另一份简历。

是不是也看起来很牛逼?他实际上也拿了很多大奖,限于篇幅就不截图了。

首先值得肯定的是这两位同学大学四年下来确实有花了心思在打比赛上面,没有荒废学业,至少没有像有些同学四年都是打游戏、睡觉度过的。

言归正转,像这样的简历,大家觉得他们能不能进大厂?能拿到多少薪资呢?

实际上,在面试的时候,问了他们几个问题,几乎都是一问三不知。而且,我也没问非常难的问题,都是非常基础的问题,比如:

  1. 推挽输出与开漏输出有啥区别?
  2. GPIO 口有几种工作模式?都是什么?
  3. 简单说下 I2C 协议。

如果你们不知道自己是什么水平,我这里也准备了一份嵌入式面试自测题,大家可以测试一下自己是处于什么段位。

面试自测题:www.lxlinux.net/e/stm32/emb…

这种问题,只要有好好学过一遍单片机,我相信大家都能回答上来。但是,就是这样一个参加了这么多比赛,拿了这么多奖的同学,居然一个都答不上来!

这种学生,别说大厂了,像我这种可能随时倒闭的小公司都过不了!

究其原因,其实也很好理解。我面试过很多人,都是差不多的问题。

第一种可能,老师根本就没教底层

现在很多学校都有开设单片机相关的课程,并且老师实验室也有带大家做 STM32 相关的项目。但是,很多学生学习的技术就是浮于表面,只懂得如何使用某个外设,而这个外设细节的内容却没学好。

比如说,ESP8266 很多人都有用过,但是,大家基本都只是拿现成的驱动代码来使用,再做业务逻辑的开发。至于 ESP8266 具体是如何驱动的,一概不知,甚至连一些基本的 AT 指令都没了解。

第二种可能,学生只为了应付考试

有些学校老师确实教了底层知识点,但是学生不一定听,听了不一定会,会了不一定记得,记得了也不一定会用。

知识点只在考前记得,考完就抛之脑后了,更不用说真正能拿来做项目了。

第三种可能,学生是被老师「推」上领奖台的

每届学生都会参加的比赛,老师早已摸出了比赛的套路,在老师丰富经验的带领下,老师手把手教导项目,学生只是机械的完成项目,没有学到真本领。

简单来说,学生看似参加了很多比赛,拿了很多大奖,但实际上他们只是个机器人站在台前,真正背后的人是老师。

什么比赛不比赛了,就是套路,老师早已烂熟于心,可以批量生产一批又一批的「获奖者」。

所以很多人看似参加了很多比赛,也拿到了奖,但那些项目无非就是平衡小车,智能小车,机械手,无人机,等等,网上都有一大堆现成的代码。

大家基本都是拿现成的代码过来,在老师的指导下改改,而关于底层开发的内容知之甚少,根本就没学到什么东西。

好一个 CV 工程师!

而且,对于有用到的外设可能会熟悉一些,而没用过的外设,根本就不了解,甚至都没听说过。

比如做 PID 平衡小车,需要用到 GPIO、定时器、中断、I2C 等等外设,他可能对 DMA、SPI、ADC 这些外设就不熟悉,甚至都没学过。

简单来说,现在参加比赛的同学,以及在实验室的同学,大多都是拿现成的代码改改,看看效果再调整,用到哪个外设就去学哪个外设,而且学的也是很浅,底层原理根本就不了解。

作为一名合格的嵌入式开发工程师,如果只掌握到这种程度是万万不可的。

一名合格的嵌入式工程师,至少你 STM32 所有的外设都应该学一遍,比如 GPIO、中断、定时器、串口、ADC、DAC、I2C、SPI、DMA、看门狗、LCD 屏幕,等等。

常见模块的驱动也要了解驱动原理,比如 OLED 屏幕,ESP8266,DHT11,LCD1602,蓝牙等等。

另外,操作系统肯定要学一个。我面试过的候选人,居然有拿过奖的人连 FreeRTOS 都没听说过,真的是让我大跌眼镜。FreeRTOS、RT-Thread、Ucos,至少要学一个。

还有,网络也要学一下,现在基本什么东西都上网了,网络相关的知识能不学吗?这也是很多学生问题所在,大多数的学生没学过网络,或者学得很差。

MQTT、https、TCP/UDP、TCP/IP架构,这些东西一定要烂熟于心。实际上,在学校里做的项目基本上都不连网的,所以大家对网络学得比较少。

只有做到这样,大家才有可能拿到高薪 offer,否则,拿再多的奖也是白搭。

给大家做了个简图,大家可以对照一下自己还缺什么。

最后,祝大家都拿到满意的 offer !

另外,想进大厂的同学,一定要好好学算法,这是面试必备的。这里准备了一份 BAT 大佬总结的 LeetCode 刷题宝典,很多人靠它们进了大厂。

刷题 | LeetCode算法刷题神器,看完 BAT 随你挑!

有收获?希望老铁们来个三连击,给更多的人看到这篇文章

推荐阅读:

  • 程序员必备编程资料大全
  • 程序员必备软件资源

欢迎关注我的博客:良许嵌入式教程网,满满都是干货!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

公司大佬对excel导入、导出的封装,那叫一个秒啊 环境准备

发表于 2024-01-26

最近在封装公司统一使用的组件,主要目的是要求封装后开发人员调用简单,不用每个项目组中重复去集成同一个依赖l,写的五花八门,代码不规范,后者两行泪。

为此,我们对EasyExcel进行了二次封装,我会先来介绍下具体使用,然后再给出封装过程

环境准备

开发环境:SpringBoot+mybatis-plus+db

数据库:

1
2
3
4
5
6
7
8
sql复制代码 -- `dfec-tcht-platform-dev`.test definition

CREATE TABLE `test` (
`num` decimal(10,0) DEFAULT NULL COMMENT '数字',
`sex` varchar(100) DEFAULT NULL COMMENT '性别',
`name` varchar(100) DEFAULT NULL COMMENT '姓名',
`born_date` datetime DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

使用

第一步、在接口类中引入以下

1
2
3
less复制代码
@Aurowired
ExcelService excelService;

第二步、标注字段

这些个注解是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
less复制代码/**
* 【请填写功能名称】对象 test
*
* @author trg
* @date Fri Jan 19 14:14:08 CST 2024
*/
@Data
@TableName("test")
public class TestEntity {

/**
* 数字
*/
@Schema(description = "数字")
@ExcelProperty("数字")
private BigDecimal num;


/**
* 性别
*/
@Schema(description = "性别")
@ExcelProperty("性别")
private String sex;


/**
* 姓名
*/
@Schema(description = "姓名")
@ExcelProperty("姓名")
private String name;


/**
* 创建时间
*/
@Schema(description = "创建时间")
@ExcelProperty(value = "创建时间")
private Date bornDate;


}

第三步、使用

1
2
3
4
5
6
7
8
9
10
less复制代码@PostMapping("/importExcel")
public void importExcel(@RequestParam MultipartFile file){
excelService.importExcel(file, TestEntity.class,2,testService::saveBatch);
}


@PostMapping("/exportExcel")
public void exportExcel(HttpServletResponse response) throws IOException {
excelService.exportExcel(testService.list(),TestEntity.class,response);
}

完整代码

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
java复制代码package com.dfec.server.controller;


import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.dfec.framework.excel.service.ExcelService;
import com.dfec.server.entity.TestEntity;
import com.dfec.server.entity.TestVo;
import com.dfec.server.service.TestService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
import java.util.function.Function;

/**
* @author trg
* @title: TestController
* @projectName df-platform
* @description: TODO
* @date 2023/6/1915:22
*/

@RestController
@RequestMapping("test")
@RequiredArgsConstructor
public class TestController {


private final ExcelService excelService;

private final TestService testService;


@PostMapping("/importExcel")
public void importExcel(@RequestParam MultipartFile file){
excelService.importExcel(file, TestEntity.class,2,testService::saveBatch);
}


@PostMapping("/exportExcel")
public void exportExcel(HttpServletResponse response) throws IOException {
excelService.exportExcel(testService.list(),TestEntity.class,filePath,response);
}


}

哈哈哈,是不是非常简洁

以上只是一个简单的使用情况,我们还封装了支持模板的导入、导出,数据转换等问题,客官请继续向下看。

如果遇到有读取到的数据和实际保存的数据不一致的情况下,可以使用如下方式导入,这里给出一个示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码  @PostMapping("/importExcel")
public void importExcel(@RequestParam MultipartFile file){
Function<TestEntity, TestVo> map = new Function<TestEntity, TestVo>() {
@Override
public TestVo apply(TestEntity testEntities) {
TestVo testVo = new TestVo();
testVo.setNum(testEntities.getNum());
testVo.setSex(testEntities.getSex());
testVo.setBaseName(testEntities.getName());
return testVo;
}
};
excelService.importExcel(file, TestEntity.class,2,map,testService::saveBatchTest);
}

封装过程

核心思想:

对导入和导出提供接口、保持最少依赖原则

我们先从ExcelService接口类出发,依次看下封装的几个核心类

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
java复制代码package com.dfec.framework.excel.service;


import org.springframework.web.multipart.MultipartFile;

import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Function;

/**
* ExcelService
*
* @author LiuBin
* @interfaceName ExcelService
* @date 2024/1/16 11:21
**/
public interface ExcelService {

/**
* 导出Excel,默认
* @param list 导出的数据
* @param tClass 带有excel注解的实体类
* @param response 相应
* @return T
* @author trg
* @date 2024/1/15 17:32
*/
<T> void exportExcel(List<T> list, Class<T> tClass, HttpServletResponse response) throws IOException;

/**
* 导出Excel,增加类型转换
* @param list 导出的数据
* @param tClass 带有excel注解的实体类
* @param response 相应
* @return T
* @author trg
* @date 2024/1/15 17:32
*/
<T, R> void exportExcel(List<T> list, Function<T, R> map, Class<R> tClass, HttpServletResponse response) throws IOException;


/**
* 导出Excel,按照模板导出,这里是填充模板
* @param list 导出的数据
* @param tClass 带有excel注解的实体类
* @param template 模板
* @param response 相应
* @return T
* @author trg
* @date 2024/1/15 17:32
*/
<T> void exportExcel(List<T> list, Class<T> tClass, String template, HttpServletResponse response) throws IOException;

/**
* 导入Excel
* @param file 文件
* @param tClass 带有excel注解的实体类
* @param headRowNumber 表格头行数据
* @param map 类型转换
* @param consumer 消费数据的操作
* @return T
* @author trg
* @date 2024/1/15 17:32
*/
<T, R> void importExcel(MultipartFile file, Class<T> tClass, Integer headRowNumber, Function<T, R> map, Consumer<List<R>> consumer);


/**
* 导入Excel
* @param file 文件
* @param tClass 带有excel注解的实体类
* @param headRowNumber 表格头行数据
* @param consumer 消费数据的操作
* @return T
* @author trg
* @date 2024/1/15 17:32
*/
<T> void importExcel(MultipartFile file, Class<T> tClass, Integer headRowNumber, Consumer<List<T>> consumer);

}

以上接口只有个导入、导出,只是加了几个重载方法而已

再看下具体的实现类

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
java复制代码package com.dfec.framework.excel.service.impl;

import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.support.ExcelTypeEnum;
import com.dfec.framework.excel.convert.LocalDateTimeConverter;
import com.dfec.framework.excel.service.ExcelService;
import com.dfec.framework.excel.util.ExcelUtils;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.stream.Collectors;

/**
* DefaultExcelServiceImpl
*
* @author LiuBin
* @className DefaultExcelServiceImpl
* @date 2024/1/16 11:42
**/
@Service
public class DefaultExcelServiceImpl implements ExcelService {

@Override
public <T> void exportExcel(List<T> list, Class<T> tClass, HttpServletResponse response) throws IOException {
setResponse(response);
EasyExcel.write(response.getOutputStream())
.head(tClass)
.excelType(ExcelTypeEnum.XLSX)
.registerConverter(new LocalDateTimeConverter())
.sheet("工作簿1")
.doWrite(list);

}

@Override
public <T, R> void exportExcel(List<T> list, Function<T, R> map, Class<R> tClass, HttpServletResponse response) throws IOException {
setResponse(response);
List<R> result = list.stream().map(map::apply).collect(Collectors.toList());
exportExcel(result, tClass, response);
}

@Override
public <T> void exportExcel(List<T> list, Class<T> tClass,String template, HttpServletResponse response) throws IOException {
setResponse(response);
EasyExcel.write(response.getOutputStream())
.withTemplate(template)
.excelType(ExcelTypeEnum.XLS)
.useDefaultStyle(false)
.registerConverter(new LocalDateTimeConverter())
.sheet(0)
.doFill(list) ;

}

@Override
public <T,R> void importExcel(MultipartFile file, Class<T> tClass,Integer headRowNumber, Function<T, R> map,Consumer<List<R>> consumer) {
List<T> excelData = ExcelUtils.readExcelData(file,tClass,headRowNumber);
List<R> result = excelData.stream().map(map::apply).collect(Collectors.toList());
consumer.accept(result);
}

@Override
public <T> void importExcel(MultipartFile file, Class<T> tClass,Integer headRowNumber, Consumer<List<T>> consumer) {
List<T> excelData = ExcelUtils.readExcelData(file,tClass,headRowNumber);
consumer.accept(excelData);

}


public void setResponse(HttpServletResponse response) throws UnsupportedEncodingException {
response.setContentType("application/vnd.ms-excel");
response.setCharacterEncoding("utf-8");
// 这里URLEncoder.encode可以防止中文乱码 当然和easyexcel没有关系
String fileName = URLEncoder.encode("data", "UTF-8").replaceAll("\\+", "%20");
response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xls");
}
}

ExcelUtils

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
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
java复制代码package com.dfec.framework.excel.util;

import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelReader;
import com.alibaba.excel.read.builder.ExcelReaderBuilder;
import com.alibaba.excel.read.metadata.ReadSheet;
import com.alibaba.excel.util.MapUtils;
import com.alibaba.fastjson.JSON;
import com.dfec.common.exception.ServiceException;
import com.dfec.framework.excel.listener.ExcelListener;
import com.dfec.framework.excel.service.ExcelBaseService;
import org.springframework.web.multipart.MultipartFile;

import javax.servlet.http.HttpServletResponse;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
* @author trg
* @description: Excel 工具类
* @title: ExcelUtils
* @email 1446232546@qq.com
* @date 2023/9/14 9:18
*/
public class ExcelUtils {


/**
* 将列表以 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 excelExport(HttpServletResponse response, String fileName, String sheetName,
Class<T> head, List<T> data) throws IOException {
write(response, fileName);
// 这里需要设置不关闭流
EasyExcel.write(response.getOutputStream(), head).autoCloseStream(Boolean.FALSE).sheet(sheetName)
.doWrite(data);
}


/**
* 根据模板导出
*
* @param response 响应
* @param templatePath 模板名称
* @param fileName 文件名
* @param sheetName Excel sheet 名
* @param head Excel head 头
* @param data 数据列表哦
* @param <T> 泛型,保证 head 和 data 类型的一致性
* @throws IOException 写入失败的情况
*/
public static <T> void excelExport(HttpServletResponse response, String templatePath, String fileName, String sheetName,
Class<T> head, List<T> data) throws IOException {
write(response, fileName);
// 这里需要设置不关闭流
EasyExcel.write(response.getOutputStream(), head).withTemplate(templatePath).autoCloseStream(Boolean.FALSE).sheet(sheetName)
.doWrite(data);
}

/**
* 根据参数,只导出指定列
*
* @param response 响应
* @param fileName 文件名
* @param sheetName Excel sheet 名
* @param head Excel head 头
* @param data 数据列表哦
* @param excludeColumnFiledNames 排除的列
* @param <T> 泛型,保证 head 和 data 类型的一致性
* @throws IOException 写入失败的情况
*/
public static <T> void excelExport(HttpServletResponse response, String fileName, String sheetName,
Class<T> head, List<T> data, Set<String> excludeColumnFiledNames) throws IOException {
write(response, fileName);
// 这里需要设置不关闭流
EasyExcel.write(response.getOutputStream(), head).autoCloseStream(Boolean.FALSE).excludeColumnFiledNames(excludeColumnFiledNames).sheet(sheetName)
.doWrite(data);
}


private static void write(HttpServletResponse response, String fileName) {
try {
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setCharacterEncoding("utf-8");
// 这里URLEncoder.encode可以防止中文乱码 当然和easyexcel没有关系
response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx");

} catch (Exception e) {
// 重置response
response.reset();
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
Map<String, String> map = MapUtils.newHashMap();
map.put("status", "failure");
map.put("message", "下载文件失败" + e.getMessage());
try {
response.getWriter().println(JSON.toJSONString(map));
} catch (IOException ex) {
throw new RuntimeException(ex);
}
}
}

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


/**
* 读取 Excel(多个 sheet)
*
* @param excel 文件
* @param rowModel 实体类映射
* @return Excel 数据 list
*/
public static <T> List<T> readExcelData(MultipartFile excel, Class<T> rowModel, Integer headRowNumber) {
ExcelListener excelListener = new ExcelListener();
ExcelReaderBuilder readerBuilder = getReader(excel, excelListener);
if (readerBuilder == null) {
return null;
}
if (headRowNumber == null) {
headRowNumber = 1;
}
readerBuilder.head(rowModel).headRowNumber(headRowNumber).doReadAll();
return excelListener.getData();
}


/**
* 读取 Excel(多个 sheet)
*
* @param excel 文件
* @param rowModel 实体类映射
* @return Excel 数据 list
*/
public static <T> List<T> excelImport(MultipartFile excel, ExcelBaseService excelBaseService, Class rowModel) {
ExcelListener excelListener = new ExcelListener(excelBaseService);
ExcelReaderBuilder readerBuilder = getReader(excel, excelListener);
if (readerBuilder == null) {
return null;
}
readerBuilder.head(rowModel).doReadAll();
return excelListener.getData();
}

/**
* 读取某个 sheet 的 Excel
*
* @param excel 文件
* @param rowModel 实体类映射
* @param sheetNo sheet 的序号 从1开始
* @param headLineNum 表头行数,默认为1
* @return Excel 数据 list
*/
public static <T> List<T> excelImport(MultipartFile excel, ExcelBaseService excelBaseService, Class rowModel, int sheetNo,
Integer headLineNum) {
ExcelListener excelListener = new ExcelListener(excelBaseService);
ExcelReaderBuilder readerBuilder = getReader(excel, excelListener);
if (readerBuilder == null) {
return null;
}
ExcelReader reader = readerBuilder.headRowNumber(headLineNum).build();
ReadSheet readSheet = EasyExcel.readSheet(sheetNo).head(rowModel).build();
reader.read(readSheet);
return excelListener.getData();
}

/**
* 返回 ExcelReader
*
* @param excel 需要解析的 Excel 文件
* @param excelListener 监听器
*/
private static ExcelReaderBuilder getReader(MultipartFile excel,
ExcelListener excelListener) {
String filename = excel.getOriginalFilename();
if (filename == null || (!filename.toLowerCase().endsWith(".xls") && !filename.toLowerCase().endsWith(".xlsx"))) {
throw new ServiceException("文件格式错误!");
}
InputStream inputStream;
try {
inputStream = new BufferedInputStream(excel.getInputStream());
return EasyExcel.read(inputStream, excelListener);
} catch (IOException e) {
e.printStackTrace();
}
return null;
}

}

ExcelListener.java

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
java复制代码package com.dfec.framework.excel.listener;

import com.alibaba.excel.context.AnalysisContext;
import com.alibaba.excel.event.AnalysisEventListener;
import com.alibaba.fastjson.JSON;
import com.dfec.framework.excel.service.ExcelBaseService;
import lombok.extern.slf4j.Slf4j;


import java.util.ArrayList;
import java.util.List;
import java.util.Map;

/**
* @author trg
* @description: Excel导入的监听类
* @title: ExcelListener
* @projectName df-platform
* @email 1446232546@qq.com
* @date 2023/9/14 16:23
*/
@Slf4j
public class ExcelListener<T> extends AnalysisEventListener<T> {

private ExcelBaseService excelBaseService;

public ExcelListener(){}

public ExcelListener(ExcelBaseService excelBaseService){
this.excelBaseService = excelBaseService;
}

/**
* 每隔1000条存储数据库,实际使用中可以3000条,然后清理list ,方便内存回收
*/
private static final int BATCH_COUNT = 1000;
List<T> list = new ArrayList<>();

@Override
public void invoke(T data, AnalysisContext context) {
list.add(data);
log.info("解析到一条数据:{}", JSON.toJSONString(data));

}

@Override
public void doAfterAllAnalysed(AnalysisContext context) {
log.info("所有数据解析完成!");
}



/**
* 返回list
*/
public List<T> getData() {
return this.list;
}

}

遇到的问题

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
java复制代码package com.dfec.server;

import com.alibaba.excel.converters.Converter;
import com.alibaba.excel.converters.ReadConverterContext;
import com.alibaba.excel.enums.CellDataTypeEnum;
import com.dfec.common.utils.str.StringUtils;

import java.util.Date;

/**
* DateConverter
*
* @author trg
* @className DateConverter
* @date 2024/1/25 16:09
**/
public class DateConverter implements Converter<Date> {

@Override
public Date convertToJavaData(ReadConverterContext<?> context) throws Exception {
Class<?> aClass = context.getContentProperty().getField().getType();
CellDataTypeEnum type = context.getReadCellData().getType();
String stringValue = context.getReadCellData().getStringValue();
if(aClass.equals(Date.class) && type.equals(CellDataTypeEnum.STRING) && StringUtils.isBlank(stringValue)){
return null;
}

return Converter.super.convertToJavaData(context);
}
}

实体类上添加

1
2
3
4
5
6
java复制代码 /**
* 创建时间
*/
@Schema(description = "创建时间")
@ExcelProperty(value = "创建时间",converter = DateConverter.class)
private Date bornDate;

同理,这块

注意这里也是可以用相同的方法去做字典值类型的转换的,可以参考下芋道源码的DictConvert.java

2、POI版本

这里切记POI版本和ooxml的版本一堆要保持一致,不然会出现各种问题

3、日期类型 LocalDateTime 转换的问题

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
java复制代码package com.dfec.framework.excel.convert;

import com.alibaba.excel.converters.Converter;
import com.alibaba.excel.enums.CellDataTypeEnum;
import com.alibaba.excel.metadata.GlobalConfiguration;
import com.alibaba.excel.metadata.data.ReadCellData;
import com.alibaba.excel.metadata.data.WriteCellData;
import com.alibaba.excel.metadata.property.ExcelContentProperty;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

/**
* 解决 EasyExcel 日期类型 LocalDateTime 转换的问题
*/
public class LocalDateTimeConverter implements Converter<LocalDateTime> {

@Override
public Class<LocalDateTime> supportJavaTypeKey() {
return LocalDateTime.class;
}

@Override
public CellDataTypeEnum supportExcelTypeKey() {
return CellDataTypeEnum.STRING;
}

@Override
public LocalDateTime convertToJavaData(ReadCellData cellData, ExcelContentProperty contentProperty,
GlobalConfiguration globalConfiguration) {
return LocalDateTime.parse(cellData.getStringValue(), DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}

@Override
public WriteCellData<String> convertToExcelData(LocalDateTime value, ExcelContentProperty contentProperty,
GlobalConfiguration globalConfiguration) {
return new WriteCellData(value.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
}

}

遗留问题

目前我们使用的这个EasyExcel版本是3.3.2,但是发现,导出的时候按照模板去导出文件数据的话只能支持xls,xlsx的不支持,目前还未有解决方案,有遇到的朋友还望不吝赐教

参照:

EasyExcel官方文档;easyexcel.opensource.alibaba.com/docs/curren…

参照芋道源码

微信关注博主,有更多精彩内容哦,更新频率频繁,经常更新面试题目

image.png

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

阿里员工自曝:某多多的4轮面试都通过了,但到了谈薪资的环节,

发表于 2024-01-25

HR 的为难

又是一期「排雷+心理按摩」,正在密谋年底跳槽的同学需要额外注意。

起源是我看到了这么一篇帖子分享:

一位目前应该还是在职的阿里巴巴的员工,前后花了一个多月的时间,顺利通过了某多多的 4 轮面试。

按道理,4 轮面试都过了,流程也差不多进入尾声了。

这时候如果再因为一些非硬性指标,未能谈成,会尤其可惜。

沉没成本嘛,很好理解。

然而,就在候选人觉得一切顺利的时候,却遭遇了 HR 的“特殊”对待。

其实就是谈薪的时候,HR 需要候选人的上一份工作的薪资证明。

候选人显示开具了税前收入证明,被 HR 拒绝,要求开具银行流水。

候选人再提供了带公章的银行流水,再次被 HR 拒绝,要求开具工资单加交税证明。

此时候选人有点不耐烦了,说「要不算了吧」,结果对方直接挂电话了 🤣

从这件事上,看得出这位 HR 很不专业。

需要的材料没有一次说明清楚,在候选人表达不满的时候,不是选择进行安抚说明,而是直接挂掉电话,似乎就是在等候选人主动放弃岗位一样。

再细看评论区,发现有同样经历的不止楼主一人:

这位网友也是面完 4 轮,到谈薪阶段,该网友给出报价后,遭遇 HR 的冷漠拒绝:只说给不到,也不明说该岗位的薪资范围。

另外一位,则是上份工作是在华为的网友爆料:

给 HR 提供的个税截图被质疑真实性,还提出个税中的收入金额是否包含报销费用等问题,引发候选人的不满,直接放弃入职。

…

确实,有时候我们很难判断 HR 是真的缺乏专业性,还是因为不想招人了而进行的故意拖延。

但我们又无法要求将 HR 沟通的这一步进行前置,这不现实。

目前所能做的最合理的做法只能是:求职过程中,有些公司的流程快,有些公司流程慢,无论快慢,都以发 offer 为准,不要将流程的繁琐视为沉没成本,如果在已经离职的情况下,尽量多家面试并行投递推进,既可以帮助自己快速进入面试状态,也不会落入流程走了一个多月,到之后没谈拢的局面。

…

回归主线。

现在的大厂面试,不管校招还是社招,去哪都得做算法面试题。

来一道「华为」面试真题。

题目描述

平台:LeetCode

题号:524

给你一个字符串 s 和一个字符串数组 dictionary 作为字典。

找出并返回字典中最长的字符串,该字符串可以通过删除 s 中的某些字符得到。

如果答案不止一个,返回长度最长且字典序最小的字符串。

如果答案不存在,则返回空字符串。

示例 1:

1
2
3
ini复制代码输入:s = "abpcplea", dictionary = ["ale","apple","monkey","plea"]

输出:"apple"

示例 2:

1
2
3
ini复制代码输入:s = "abpcplea", dictionary = ["a","b","c"]

输出:"a"

提示:

  • 1<=s.length<=10001 <= s.length <= 10001<=s.length<=1000
  • 1<=dictionary.length<=10001 <= dictionary.length <= 10001<=dictionary.length<=1000
  • 1<=dictionary[i].length<=10001 <= dictionary[i].length <= 10001<=dictionary[i].length<=1000
  • s 和 dictionary[i] 仅由小写英文字母组成

排序 + 双指针 + 贪心

根据题意,我们需要找到 dictionary 中为 s 的子序列,且「长度最长(优先级 111)」及「字典序最小(优先级 222)」的字符串。

数据范围全是 100010001000。

我们可以先对 dictionary 根据题意进行自定义排序:

  1. 长度不同的字符串,按照字符串长度排倒序;
  2. 长度相同的,则按照字典序排升序。

然后我们只需要对 dictionary 进行顺序查找,找到的第一个符合条件的字符串即是答案。

具体的,我们可以使用「贪心」思想的「双指针」实现来进行检查:

  1. 使用两个指针 i 和 j 分别代表检查到 s 和 dictionary[x] 中的哪位字符;
  2. 当 s[i] != dictionary[x][j],我们使 i 指针右移,直到找到 s 中第一位与 dictionary[x][j] 对得上的位置,然后当 i 和 j 同时右移,匹配下一个字符;
  3. 重复步骤 222,直到整个 dictionary[x] 被匹配完。

证明:对于某个字符 dictionary[x][j] 而言,选择 s 中 当前 所能选择的下标最小的位置进行匹配,对于后续所能进行选择方案,会严格覆盖不是选择下标最小的位置,因此结果不会变差。

Java 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Java复制代码class Solution {
public String findLongestWord(String s, List<String> dictionary) {
Collections.sort(dictionary, (a,b)->{
if (a.length() != b.length()) return b.length() - a.length();
return a.compareTo(b);
});
int n = s.length();
for (String ss : dictionary) {
int m = ss.length();
int i = 0, j = 0;
while (i < n && j < m) {
if (s.charAt(i) == ss.charAt(j)) j++;
i++;
}
if (j == m) return ss;
}
return "";
}
}

C++ 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
C++复制代码class Solution {
public:
string findLongestWord(string s, vector<string>& dictionary) {
sort(dictionary.begin(), dictionary.end(), [](const string& a, const string& b) {
if (a.length() != b.length()) return b.length() < a.length();
return a < b;
});
int n = s.length();
for (const string& word : dictionary) {
int m = word.length();
int i = 0, j = 0;
while (i < n && j < m) {
if (s[i] == word[j]) j++;
i++;
}
if (j == m) return word;
}
return "";
}
};

Python 代码:

1
2
3
4
5
6
7
8
9
10
11
12
Python复制代码class Solution:
def findLongestWord(self, s: str, dictionary: List[str]) -> str:
dictionary.sort(key=lambda x: (-len(x), x))
n = len(s)
for word in dictionary:
m = len(word)
i, j = 0, 0
while i < n and j < m:
if s[i] == word[j]: j += 1
i += 1
if j == m: return word
return ""

TypeScript 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
TypeScript复制代码function findLongestWord(s: string, dictionary: string[]): string {
dictionary.sort((a, b) => {
if (a.length !== b.length) return b.length - a.length;
return a.localeCompare(b);
});
const n = s.length;
for (const word of dictionary) {
const m = word.length;
let i = 0, j = 0;
while (i < n && j < m) {
if (s[i] === word[j]) j++;
i++;
}
if (j === m) return word;
}
return "";
};
  • 时间复杂度:令 n 为 s 的长度,m 为 dictionary 的长度。排序复杂度为 O(mlog⁡m)O(m\log{m})O(mlogm);对 dictionary 中的每个字符串进行检查,单个字符串的检查复杂度为 O(min⁡(n,dictionary[i]))≈O(n)O(\min(n, dictionary[i]))\approx O(n)O(min(n,dictionary[i]))≈O(n)。整体复杂度为 O(mlog⁡m+m×n)O(m\log{m} + m \times n)O(mlogm+m×n)
  • 空间复杂度:O(log⁡m)O(\log{m})O(logm)

我是宫水三叶,每天都会分享算法题解,并和大家聊聊近期的所见所闻。

欢迎关注,明天见。

更多更全更热门的「笔试/面试」相关资料可访问排版精美的 合集新基地 🎉🎉

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

京东零售交易团队 DDD技术方案落地实践

发表于 2024-01-25

一、引言

从接触领域驱动设计的初学阶段,到实现一个旧系统改造到DDD模型,再到按DDD规范落地的3个的项目。对于领域驱动模型设计研发,从开始的各种疑惑到吸收各种先进的理念,目前在技术实施这一块已经基本比较成熟。在既往经验中总结了一些在开发中遇到的技术问题和解决方案进行分享。

因为DDD的建模理论及方法论有比较成熟的教程,如《领域驱动设计》,这里我对DDD的理论部分只做简要回顾,如果需要了解DDD建模和基础的理论知识,请移步相关书籍进行学习。本文主要针对我们团队在DDD落地实践中的一些技术点进行分享。

二、理论回顾

理论部分只做部分提要,关于DDD建模及基础知识相关,可参考 Eric Evans 的《领域驱动设计》一书及其它理论书籍,这里只做部分内容摘抄。

图片

2.1.1 名词

领域及划分: 领域、子域、核心域、通用域、支撑域,限界上下文;模型:聚合、聚合根、实体、值对象;

图片

图片

实体

是指描述了领域中唯一的且可持续变化的抽象模型,有ID标识,有生命周期,有状态(用值对象来描述状态),实体通过ID进行区分;

每个实体对象都有唯一的 ID。我们可以对一个实体对象进行多次修改,修改后的数据和原来的数据可能会大不相同。比如商品是商品上下文的一个实体,通过唯一的商品 ID 来标识,不管这个商品的数据如何变化,商品的 ID 一直保持不变,它始终是同一个商品。

在 DDD 里,这些实体类通常采用充血模型,与这个实体相关的所有业务逻辑都在实体类的方法中实现。

聚合根

聚合根是实体,是一个根实体,聚合根的ID全局唯一标识,聚合根下面的实体的ID在聚合根内唯一即可;

聚合根是聚合还原和保存的唯一入口,聚合的还原应该保证完整性即整存整取;

聚合设计的原则

  1. 聚合是用来封装真正的不变性,而不是简单的将对象组合在一起;
  2. 聚合应尽量设计的小,主要因为业务决定聚合,业务改变聚合,尽可能小的拆分,可以避免重构,重新拆分;
  3. 聚合之间的关联通过ID,而不是对象引用;
  4. 聚合内强一致性,聚合之间最终一致性;

值对象

值对象的核心本质是值,与是否有复杂类型无关,值对象没有生命周期,通过两个值对象的值是否相同区分是否是同一个值对象;

值对象应该设计为只读模式, 如果任一属性发生变化,应该重新构建一个新的值对象而不是改变原来值对象的属性;

领域事件

在事件风暴过程中,会识别出命令、业务操作、实体等,此外还有事件。比如当业务人员的描述中出现类似“当完成…后,则…”,“当发生…时,则…”等模式时,往往可将其用领域事件来实现。领域事件表示在领域中发生的事件,它会导致进一步的业务操作。如电商中,支付完成后触发的事件,会导致生成订单、扣减库存等操作。

在一次事务中,最多只能更改一个聚合的状态。如何一个业务操作涉及多个聚合状态的更改,可以采用领域事件的方式,实现聚合之间的解耦;在聚合根和跨上下文之间实现最终一致性。聚合内数据强一致性,聚合之间数据最终一致性。

事件的生成和发布:构建的事件应包含事件ID、时间戳、事件类型、事件源等基本属性,以便事件可以无歧义地在不同上下文间传播;此外事件还应包含具体的业务数据。

领域事件为已发生的事务,具有只读,不可变更性。一般接收消息为异步监听,处理的后续处理需要考虑时序和重复发送的问题。

2.1.2 聚合根、实体、值对象的区别?

从标识的角度:

聚合根具有全局的唯一标识,而实体只有在聚合内部有唯一的本地标识,值对象没有唯一标识;

从是否只读的角度:

聚合根除了唯一标识外,其他所有状态信息都理论上可变;实体是可变的;值对象是只读的;

从生命周期的角度:

聚合根有独立的生命周期,实体的生命周期从属于其所属的聚合,实体完全由其所属的聚合根负责管理维护;值对象无生命周期可言,因为只是一个值;

【 2.2 建模方法 】

图片

2.2.1 事件风暴

事件⻛暴法类似头脑⻛暴,简单来说就是谁在何时基于什么做了什么,产⽣了什么,影响了什么事情。

在事件风暴的过程中,领域专家会和设计、开发人员一起建立领域模型,在领域建模的过程中会形成通用的业务术语和用户故事。事件风暴也是一个项目团队统一语言的过程。

图片

图片

2.2.2 用户故事

用户故事在软件开发过程中被作为描述需求的一种表达形式,并着重描述角色(谁要用这个功能)、功能(需要完成什么样子的功能)和价值(为什么需要这个功能,这个功能带来什么样的价值)。

例:

作为一个“网站管理员”,我想要“统计每天有多少人访问了我的网站”,以便于“我的赞助商了解我的网站会给他们带来什么收益。

通过用户故事分析会形成一个个的领域对象,这些领域对象对应领域模型的业务对象,每一个业务对象和领域对象都有通用的名词术语,并且一一映射。

2.2.3 统一语言

在事件风暴和用户故事梳理过程及日常讨论中,会有越来越多的名词冒出来,这个时候,需要团队成员统一意见,形成名词字典。在后续的讨论和描述中,使用统一的名称名词来指代模型中的对象、属性、状态、事件、用例等信息。

可以用Excel或者在线文档等方式记录存储,标注名称,描述和提取时间和参与人等信息。

代码模型设计的时侯就要建立领域对象和代码对象的一一映射,从而保证业务模型和代码模型的一致,实现业务语言与代码语言的统一。

2.2.4 领域划分及建模

DDD 内核的代码模型来源于领域模型,每个代码模型的代码对象跟领域对象一一对应。

通过UML类图(通过颜色标注区分聚合根、实体、值对象等)、用例图、时序图完成软件模型设计。

图片

【 2.3 整洁架构(洋葱架构)】

图片

整洁架构(Clean Architecture**)是由Bob大叔在2012年提出的一个架构模型,顾名思义,是为了使架构更简洁。

整洁架构最主要原则是依赖原则,它定义了各层的依赖关系,越往里,依赖越低,代码级别越高。外圆代码依赖只能指向内圆,内圆不知道外圆的任何事情。一般来说,外圆的声明(包括方法、类、变量)不能被内圆引用。同样的,外圆使用的数据格式也不能被内圆使用。

整洁架构各层主要职能如下:

**Entities:**实现领域内核心业务逻辑,它封装了企业级的业务规则。一个 Entity 可以是一个带方法的对象,也可以是一个数据结构和方法集合。一般我们建议创建充血模型。

**Use Cases:**实现与用户操作相关的服务组合与编排,它包含了应用特有的业务规则,封装和实现了系统的所有用例。

**Interface Adapters:**它把适用于 Use Cases 和 entities 的数据转换为适用于外部服务的格式,或把外部的数据格式转换为适用于 Use Casess 和 entities 的格式。

**Frameworks and Drivers:**这是实现所有前端业务细节的地方,UI,Tools,Frameworks 等以及数据库等基础设施。

三、落地实践

【 3.1 概述 】

在整个DDD开发过程中,除了建模方法和理论的学习,实际技术落地还会遇到很多问题。在多个项目的不断开发演进过程中,循序渐进的总结了很多经验和小技巧,用于解决过往的缺憾和不足。走向DDD的路有千万条,这些只是其中的一些可选方案,如有纰漏还请指正。

【 3.2 工程示例简介 】

目前我们采用的是内核整体分离,如下图所示。

b2b-baseproject-kernel 内核模块说明

其中: b2b-baseproject-kernel 为内核的Maven工程示例, b2b-baseproject-center为读写服务汇总的中心对外服务工程示例。

图片

图3-1 kernel基础工程示例

1
2
3
4
5
6
7
8
9
10
11
markdown复制代码内核Maven工程模块说明:

1. b2b-baseproject-kernel-common 常用工具类,常量等,不对外SDK暴露;
2. b2b-baseproject-kernel-export 内核对外暴露的信息,为常量,枚举等,可直接让外部SDK依赖并对外,减少通用知识重复定义(可选);
3. b2b-baseproject-kernel-dto 数据传输层,方便app层和domain层共享数据传输对象,不对外SDK暴露;
4. b2b-baseproject-kernel-ext-sdk 扩展点;(可选,不需要可直接移除)
5. b2b-baseproject-kernel-domain 领域层等(也可以不划分子模块,按需划分即可);
(b2b-baseproject-kernel-domain-common 通用领域,主要为一些通用值对象;
(b2b-baseproject-kernel-domain-ctxmain 核心领域模型,可自行调整名称;
6. b2b-baseproject-kernel-read-app 读服务应用层;(可选,不需要可直接移除)
7. b2b-baseproject-kernel-app 写服务应用层;

b2b-baseproject-center 实现模块说明

图片

图3-2 center基础工程示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
scss复制代码center Maven工程模块说明:

对外SDK
1. b2b-baseproject-sdk 对外sdk工程;
1.1 b2b-baseproject-base-sdk 基础sdk;
1.2 b2b-baseproject-core-sdk 写服务sdk;
1.3 b2b-baseproject-svr-sdk 读服务sdk;
基础设施
2. b2b-baseproject-center-common 常用工具类,常量等;
3. b2b-baseproject-center-infrastructure 基础设施实现层;
(b2b-baseproject-center-dao 基础设施层的数据库访问层,也可不分,直接融合到infrastructure);
(b2b-baseproject-center-es 基础设施层的ES访问层,也可不分,直接融合到infrastructure);

center服务层
4. b2b-baseproject-center-service center的业务服务层;

接入层
5. b2b-baseproject-center-provider 服务接入实现;

springboot启动
6. b2b-baseproject-center-bootstrap springboot应用启动层;

备注:对外SDK主要考虑适配CQRS原则,将读写分为两个单独的module, 如果感觉麻烦,也可以合并为一个SDK对外,用不同的分包隔离即可。

内核和实现的关联

使用内核和具体实现应用分离的划分是因为前期因为有商业化衍生出了多版本开发。当然目前架构组是不建议一个内核多套实现的,而是建议一个内核加上一个主版本实现。避免因为多版本实现造成分裂,徒增开发和维护成本,改为采用配置和扩展点来满足差异化需求。

目前我们开发只保持一个主版本,但是工程继续使用内核分离的方式,即一个内核+一个主版本实现。

优点:

  1. 内核和实现代码完全隔离,得到一个比较干净存粹的内核;
  2. 虽万不得已不建议多版本实现,但是万一要支持多版本,可以直接复用内核;
  3. 某种意义上,是一种更合理的分离,保证了内核和实现版本的分离,各自关注各自模块的核心问题;

缺点:

  1. 联调成本增加,每次改完需要本地install 或者推送到远程Maven仓库;

基于以上原因,对于小工程不必做以上分离,直接在一个Maven工程中进行依赖开发即可 ,从很多示例教程也是推荐如此。

图片

CQRS(命令与查询职责分离)

CQRS 就是读写分离,读写分离的主要目的是为了提高查询性能,同时达到读、写解耦。而 DDD 和 CQRS 结合,可以分别对读和写建模。

图片

查询模型是一种非标准化数据模型,它不反映领域行为,只用于数据查询和显示;命令模型执行领域行为,在领域行为执行完成后通知查询模型。

命令模型如何通知到查询模型呢?如果查询模型和领域模型共享数据源,则可以省却这一步;如果没有共享数据源,可以借助于发布订阅的消息模式通知到查询模型,从而达到数据最终一致性。

Martin 在 blog 中指出:CQRS 适用于极少数复杂的业务领域,如果不是很适合反而会增加复杂度;另一个适用场景是为了获取高性能的查询服务。

对于写少读多的共享类通用数据服务(如主数据类应用)可以采用读写分离架构模式。单数据中心写入数据,通过发布订阅模式将数据副本分发到多数据中心。通过查询模型微服务,实现多数据中心数据共享和查询。

领域与读模型的联系与差异

图片

领域模型(以聚合根为唯一入口)是承载本体变更的核心,其是对业务模型的根本建模。若聚合根为每一个普通的人体,聚合根主键就是身份证ID。假设人人生而自由,不受人控制,那么当一个人接受到合理命令后进行自我属性变更,然后对外发送信息。

而视图层是人体和社会信息的投影,就如我们的教育情况,职业情况,健康情况等一样。是对某个时刻对本体信息的投影。

视图因为基于消息传播的特性,我们的很多视图可能是延迟或者不一致的。事例:

1
2
3
markdown复制代码1. 你已经阳了,而你的健康码还是绿码;
2. 你已经结婚,而户口本还是未婚;
3. 你的结婚证上聚合了你配偶的信息;

现实世界的不一致已经给我们带来了很多麻烦和困扰,对于IT系统来说也是一样。视图的实时更新总是令人神往,但是在分布式系统中面临诸多挑战。而为了消除领域模型变更后各种视图层的延迟和不一致,就需要在消息传播和更新时机上做一些优化。但是在业务处理上,还是需要容忍一定程度的延迟和不一致,因为分布式系统是很难做到100%的准实时和一致性的。

【 3.3 问题及解决方案 】

3.3.1 领域资源注册中心

背景

一般来讲,领域模型不持有仓库也不不持有其他服务,是一个比较。这就造成领域模型在做一些验证的时候,仅能进行内存态的验证。对于rpc服务,以及涉及一些重复性验证的情况,就显得无能为力。为了更好的解决这个问题,我们采用了领域模型注册中心,采用一个单例的类来持有这些服务;

那我们在领域模型中,从数据库重新加载回来的领域模型,不需要通过spring进行数据封装,就可以直接使用所依赖的服务。

基于此,这些服务必须是无状态的,通过输入领域模型完成数据服务。

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
less复制代码/**
* 租户注册中心
*
* @author david
* @date 12/12/22
*/
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
@Setter
public class TenantRegistry {
/**
* 仓库
*/
private TenantRepository tenantRepository;

/**
* 单例
*/
private static TenantRegistry INSTANCE = new TenantRegistry();

/**
* 获取单例
*
* @return
*/
public static TenantRegistry getInstance() {
return INSTANCE;
}

}

在领域模型进行数据保存的时候,可用获取仓库或者验证服务进行数据验证。

1
2
3
4
5
6
7
8
9
10
csharp复制代码

/**
* 保存数据
*/
public void save() {
this.validate();
TenantRepository tenantRepository = TenantRegistry.getInstance().getTenantRepository();
tenantRepository.save(this);
}

3.3.2 内核模块化

一般来讲,主站因为服务的客户量广,需求多样,导致功能及依赖服务也会很庞大。然后在进行商业化部署的时候,往往只需要其中10%~50%的能力,如果在部署的时候,全量的服务和领域模型加载意味着需要配置相关的底层资源和依赖,否则可能启动异常。

内核能力模块化就显得尤为重要,目前我们主要利用spring的条件加载实现内核模块化。如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
less复制代码/**
* 租户构建工厂
*
* @author david
*/
@Component
@ConditionalOnExpression("${b2b.baseproject.kernel.ability.tenant:true}")
public class TenantInfoFactory {
}

/**
* 租户应用服务实现
*
* @author david
*/
@Service
@ConditionalOnExpression("${b2b.baseproject.kernel.ability.tenant:true}")
public class TenantAppServiceImpl implements TenantAppService {
}

//其它相关资源类似,通过@ConditionalOnExpression("${b2b.baseproject.kernel.ability.tenant:true}") 进行动态开关;

这样在applicaiton.yml 配置相关能力的true/false, 就可以实现相关能力的按需加载,当然这是强依赖spring的基础能力情况下。

1
2
3
4
5
6
7
8
9
yaml复制代码//appliciaton.yml 配置

b2b:
baseproject:
kernel:
ability:
tenant: true
dict: true
scene: true

可选进一步优化依赖

条件加载使用了spring的注解,某种意义上导致内核和spring进行了耦合。然而,项目中总有终极DDD患者,希望内核中最好连spring的依赖也去掉。这个时候,可以将spring的装配专门抽取到一个Maven的module作为starter,由这个starter负责spring的相关的注入和依赖进行适配。对于模块化加载配置,可以继续沿用conditional的配置,本质上差异不大。

3.3.3 仓库层diff实践(可选项)

本案例仅在使用关系型数据库,且为了提升更新时性能场景适用。如果能偏向于采用支持事务的NoSQL数据库,那么本实践可直接略过。

如果不是受制于关系型数据库的更加流行的制约,在面向DDD开发之后,大家可能更偏向于NoSQL数据库,可以将领域对象以聚合根的为整体进行整存整取,这样可以大大的降低仓库层存取持久化数据的开发量。而现状是大部分项目都依赖于关系型数据库,故而很多数据依然存在复杂的数据库存储关系。

如果聚合根下关联多个实体,那么在更新的时候,比较简洁的方式是整体覆盖,即使数据行没有发生变更。有时候为了提升数据库更新的性能,就需要按需更新,这时候就需要追踪实体对象是否发生变更。

对实体对象的变更追踪有两个方式:

1
2
css复制代码A -> 保存更新前快照,使用反射工具深度对比值是否变更;
B -> 使用RecordLog 作为数据状态跟踪;

在过往项目中,A/B方案均采用过,A方案的代码侵入较少,但是需要保留更新前完整快照,使用反射情况下性能会略有影响。 B方案不需要保持更新前完整快照, 也不用反射,但是需要在需要diff的实体对象中增加RecordLog值对象标记数据是新增、修改、或者未变更。

目前我们主要采用B方案,在涉及实体变更的入口方法,顺便调用RecordLog的更新方法,这样在仓库层既可以判断是新增、修改、还是没有发生变更。仓库层在执行保存的时候,则可用通过recordLog值对象的creating, updating判断数据的状态。

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
typescript复制代码/**
* 日志值对象,用于记录数据日志信息
*
* @author david
* @date 2020-08-24
*/
@Getter
@Setter
@ToString
@ValueObject
public class RecordLog implements Serializable, RecordLogCompatible {

/**
* 创建人
*/
private String creator;
/**
* 操作人
*/
private String operator;
/**
* 并发版本号,不一定以第三方传入的为准
*/
private Integer concurrentVersion;
/**
* 创建时间,不一定以第三方传入的为准
*/
private Date created;
/**
* 修改时间, 不一定以第三方传入的为准
*/
private Date modified;

/**
* 创建中
*/
private transient boolean creating;
/**
* 修改中
*/
private transient boolean updating;

/**
* 创建时构建
*
* @param creator
* @return
*/
public static RecordLog buildWhenCreating(String creator) {
return buildWhenCreating(creator, new Date());
}

/**
* 创建时构建,传入创建时间
*
* @param creator
* @param createTime
* @return
*/
public static RecordLog buildWhenCreating(String creator, Date createTime) {
RecordLog recordLog = new RecordLog();
recordLog.creator = creator;
recordLog.created = createTime;
recordLog.modified = createTime;
recordLog.operator = creator;
recordLog.concurrentVersion = 1;
recordLog.creating = true;
return recordLog;
}

/**
* 更新
*
* @param operator
*/
public void update(String operator) {
setOperator(operator);
setModified(new Date());
setUpdating(true);
concurrentVersion++;
}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
arduino复制代码// 实体变更的时候,需要同步标记recordLog

public class TenantInfo implements AggregateRoot<TenantIdentifier> {

/**
* 失效数据
*
* @param operator
*/
public void invalid(String operator) {
setStatus(StatusEnum.NO);
recordLog.update(operator);

}

/**
* 发布
*
* @param operator
*/
public void publish(String operator) {
setBusinessStatus(TenantBusinessStatusEnum.PUBLISH);
recordLog.update(operator);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
less复制代码      /**
* 保存到仓库
*
* @param tenantInfo
*/
@Override
@Transactional
public void save(TenantInfo tenantInfo) {
TenantInfoPO tenantInfoPO = TenantInfoAssembler.convertToPO(tenantInfo);
RecordLog recordLog = tenantInfo.getRecordLog();
//创建diff判断
if (recordLog.isCreating()) {
tenantInfoMapper.insert(tenantInfoPO);
} else if (recordLog.isUpdating()) { //更新diff判断
UpdateWrapper<TenantInfoPO> updateWrapper = new UpdateWrapper<>();
updateWrapper.lambda().eq(TenantInfoPO::getTenantId, tenantInfoPO.getTenantId());
tenantInfoMapper.update(tenantInfoPO, updateWrapper);
}
//将领域事件转换为taskPo, 并在一个事务之中保存到数据库,以便保证最终被消费
tenantInfo.publish(localTaskEventFactory.buildEventPersistenceAdapter(event -> TaskAssembler.tenantEventToTaskPO(event)));
}

3.3.4 读服务设计

一个完整的领域服务,只是写入没有读取是不够的,只写不读会出现信息黑洞,导致领域变更无法被外部感知和使用。如前面所述,读服务是面向视图的,其需要的是容易检索(索引服务),宽表(冗余关联信息),摘要信息。且读服务不对源数据进行修改,无需进行加锁更注重响应快速。

目前内核能相对标准化的读服务,主要针对聚合根进行基本的详情检索,如通过聚合根主键返回基本视图信息、列表检索等;其他个性化定制化的查询参数和响应结果可以依据需求自行设计和扩展,如果是比较定制的查询服务,可以不必落地到内核之中。

在b2b-baseproject-kernel工程的 read-app 模块中,我们定义了读服务的接口和约束返回对象,则在实现的center工程中,主要实现底层的读仓库和SDK接入层即可(可通过ES, 关系型数据库, redis 等来提供底层的检索服务)。

读服务接口:

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
typescript复制代码/**
* 租户应用查询服务
*
* @author david
**/
public interface TenantInfoQueryService {

/**
* 通过租户code查询
*
* @param req
* @return
*/
TenantConstraint getTenantByCode(GetTenantByCodeReq req);
}

/**
* 通过租户编码查询租户信息请求
*
* @author david
*/
@Setter
@Getter
@ToString
public class GetTenantByCodeReq implements Serializable, Verifiable {
/**
* 租户编码
*/
private String tenantCode;

@Override
public void validate() {
Validate.notEmpty(tenantCode, CodeDetailEnum.TENANT);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
csharp复制代码
/**
* 示例租户读服务约束接口
*
* @author david
* @date 4/15/22
*/
public interface TenantConstraint extends RecordLogCompatible {
/**
* 租户id
*/
Long getTenantId();

/**
* 租户id,编码
*/
Integer getTenantCode();

// ...
}
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
typescript复制代码
/**
* 租户应用查询服务内核实现
*
* @author david
**/
@Service
public class TenantInfoQueryServiceImpl implements TenantInfoQueryService {

//租户读仓库
@Resource
private TenantReadRepo tenantReadRepo;

/**
* 通过租户id查询
*
* @param req
* @return
*/
@Override
public TenantConstraint getTenantByCode(GetTenantByCodeReq req) {
req.validate();
return tenantReadRepo.getTenantByCode(req.getTenantCode());
}

//...
}

3.3.5 领域事件发布

如果不依赖binlog和事务性消息组件, 为了保证领域事件一定被发送出去,就需要依赖本地事务表。我们将领域对象保存和领域事件发布任务记录在一个事务中得以执行。在领域事件推送消息中间件MQ中,在数据库保存完毕后,先主动发送一次(容许失败),如果发送失败再等待定时调度扫描事件表重新发送。如下图所示:

图片

一般情况下,领域事件都是在业务操作的时候产生,此时我们将领域事件暂存到注册中心。待入库的时候,在一个事务包裹中进行保存。发布者如下所示,如果聚合根需要使用此发布者事件注册服务,只需要实现此Publisher接口即可。因为内部使用了WeakHashMap 作为容器,如果当前对象不再被应用,之前注册的事件列表会被自动回收掉。

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
csharp复制代码/**
* 描述:发布者接口
*
*/
public interface Publisher {
/**
* 容器
*/
Map<Object, List<DomainEvent>> container = Collections.synchronizedMap(new WeakHashMap<>());

/**
* 注册事件
*
* @param domainEvent
*/
default void register(DomainEvent domainEvent) {
List<DomainEvent> domainEvents = container.get(this);
if (Objects.isNull(domainEvents)) {
domainEvents = Lists.newArrayListWithCapacity(2);
container.put(this, domainEvents);
}
domainEvents.add(domainEvent);
}

/**
* 获取事件列表
*
* @return
*/
default List<DomainEvent> getEventList() {
return container.get(this);
}

// 更多代码...略

}

简化方案

如果一些简单的应用,不需要使用MQ消息队列进行事件中转,也可以将本地事件表的发送状态作为任务处理状态。这样可以简化一些网络开销,如在一个应用内,借助guava的EventBus组件完成消息发布-订阅机制。即简化为:订阅处理器如果全部执行成功,才更新消息表为已发送(也可以认为是已执行)。

在实际开发中,实际上我们很多领域事件都是基于此简化方案进行处理的,因领域事件的部分处理功能简单,使用简化方案能节省很多开发时间和代码量。

3.3.6 SAGA事务

概述

采用DDD之后,虽然还是可以从应用层采用基础的事务性编程保证本地数据库的事务性。然而当处于微服务架构模式,我们的业务常常需要多个跨应用的微服务协同,采用事务进行一致性保证就显得鞭长莫及。

即使不采用DDD编程, 我们过往已经开始采用Binlog(MySQL的主从同步机制)或者事务性消息来实现最终一致性。在越来越流行的微服务架构趋势下(应用资源的分布式特性),通过传统的事务ACID(atomicity、consistency、isolation、durability)保证一致性已经很难,现在我们通过牺牲原子性(atomicity)和隔离性(Isolation),转而通过保证CD来实现最终一致性。

解决分布式事务,有许多技术方案如:两阶段提交(XA)、TCC、SAGA。

关于分布式事务方案的优缺点,有很多论文和技术文章,为什么选择SAGA ,正如 Chris Richardson在《微服务架构设计模式》中所述:

  1. XA对中间件要求很高,跨系统的微服务更是让XA鞭长莫及;XA和分布式应用天生不匹配;
  2. TCC 对每一个参与方需要实现(Try-confirm-cancel)三步,侵入性较大;
  3. SAGA是一种在微服务架构中维护数据一致性的机制,它可以避免分布式事务带来的问题。通过异步消息来协调一系列本地事务,从而维护多个服务直接的数据一致性;
  4. SAGA理论部分, 可以参考:分布式事务:SAGA模式 和 Pattern: Saga

SAGA 理论

1987年普林斯顿大学的Hector Garcia-Molina和Kenneth Salem发表了一篇Paper Sagas,讲述的是如何处理long lived transaction(长活事务)。Saga是一个长活事务可被分解成可以交错运行的子事务集合。其中每个子事务都是一个保持数据库一致性的真实事务。 论文地址:sagas

Saga的组成

  • 每个Saga由一系列sub-transaction Ti 组成; (每个Ti是保证原子性提交);
  • 每个Ti 都有对应的补偿动作Ci,补偿动作用于撤销Ti造成的结果; (Ti如果验证逻辑且只读,可以为空补偿,即不需要补偿);
  • 每一个Ti操作在分布式系统中,要求保证幂等性(可重复请求而不产生脏数据);

Saga的执行顺序有两种:

1、T1, T2, T3, …, Tn (理想状态,直接成功);

2、T1, T2, …, Tj, Cj,…, C2, C1,其中0 < j < n (向前恢复模式,一般为业务失败);

Saga补偿示例: 如果在一个事务处理中,Ti为发邮件, Saga不会先保存草稿等事务提交时再发送,而是立刻发送完成。 如果任务最终执行失败, Ti已发出的邮件将无法撤销,Ci操作是补发一封邮件进行撤销说明。

SAGA有两种主要的模式,协同式、编排式。

A 事件协同式SAGA(Event choreography)

把Saga的决策和执行顺序逻辑分布在Saga的每个参与方中,他们通过相互发消息的方式来沟通。

在事件编排方法中,第一个服务执行一个事务,然后发布一个事件,该事件被一个或多个服务进行监听,这些服务再执行本地事务并发布(或不发布)新的事件。当最后一个服务执行本地事务并且不发布任何事件时,意味着分布式事务结束,或者它发布的事件没有被任何 Saga 参与者听到都意味着事务结束。

① 优点:

  • 避免中央协调器单点故障风险;
  • 当涉及的步骤较少服务开发简单,容易实现;

② 缺点:

  • 服务之间存在循环依赖的风险;
  • 当涉及的步骤较多,服务间关系混乱,难以追踪调测;
  • 参与方需要彼此感知上下耦合关联性,无法做到服务单元化;

B 命令编排式SAGA(Order Orchestrator)

中央协调器(Orchestrator,简称 OSO)以命令/回复的方式与每项服务进行通信,全权负责告诉每个参与者该做什么以及什么时候该做什么。

① 优点:

  • 服务之间关系简单,避免服务间循环依赖,因为 Saga 协调器会调用 Saga 参与者,但参与者不会调用协调器。
  • 程序开发简单,只需要执行命令/回复(其实回复消息也是一种事件消息),降低参与者的复杂性。
  • 易维护扩展,在添加新步骤时,事务复杂性保持线性,回滚更容易管理,更容易实施和测试。

② 缺点:

  • 中央协调器处理逻辑容易变得庞大复杂,导致难以维护。
  • 存在协调器单点故障风险。

命令编排式SAGA示例—— 非订单聚合提票开票申请

Saga在发票开票申请的案例如下所示,提票申请被拆分为2个主要的SAGA协调器。

① 在接收到【母申请单已经创建事件】即触发生成协调器1调度——开票申请SAGA协调器, 用于参数验证、订单锁定、占用应开金额和数量、最后按开票规则拆分为多个子申请单(一个子申请单对一张实际的发票)。在多个子申请单完成创建后, 会发布【子申请单已创建】事件。

② 在接收到【子申请单已经创建事件】即触发生成协调器2调度——子申请单提票SAGA协调器, 用于子申请单预占流水记录、提交财务开票、接收财务状态同步子申请单状态。

图片

图片

使用编排式Saga, 对每一个步骤的调用也不一定是同步的,也可以发送处理请求后挂起协调处理器,等待异步消息通知。通过消息中间件如MQ收到某个步骤的处理结果消息,然后再恢复协调器的继续调度。假设Saga事务的每个步骤都是异步的,那么编排式协调器和事件协调器就非常类同,唯一的好处是整个业务处理的消息收发均要通过Saga协调器作为中枢。当前在哪一步骤,下一步要做什么可以由SAGA协调器统一支配。

对于一个比较复杂的长活事务,从业务的完整性和排查问题的方便性考虑,我们推荐使用Saga编排式事务来收敛业务的调度复杂度,以免在消息发送接收网络中迷失。编排式事务有时候类似一个状态机,当前任务执行到哪个步骤,哪个状态能够被保存和复原,且条理性更加清晰。

在编排式Saga事务中,我们需要使用到eventSource类似的事件记录,以便记录每一个步骤的执行情况和部分上下文信息。除了手动建表之外(目前我们采用的方案),也有很多成熟的框架可供选择,如:alibaba的seata,微服务架构设计模式推荐的eventuate 。

风险:

当然在使用saga中,还需要考虑隔离性缺失带来的风险,尤其是在交易和金融环节。这不是saga能直接解决的问题,这需要通过语义锁(未提交数据加字段锁,防止脏读)、交换式更新、版本文件、重读值等方案进行处理。

  1. 参考资料

4.1 参考书籍

Domain-Driven Design《领域驱动设计》–Eric Evans

MicroServices Patterns《微服务架构设计模式》 – Chirs Richardson

《DDD 实战课》 – 欧创新

4.2 网络资料

领域模型核心概念:实体、值对象和聚合根

聚合(根)、实体、值对象精炼思考总结

DDD(Domain-Driven Design)领域驱动设计在互联网业务开发中的实践

DDD落地实践

www.jianshu.com/p/91bfc4f21…

www.jianshu.com/p/4a0d89dd7…

领域驱动设计(2) 领域事件、DDD分层架构

my.oschina.net/lxd6825/blo…

saga分布式事务_本地事务和分布式事务-tencent

作者:京东零售 张世彬

来源:京东零售技术 转载请注明来源

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

高效Mac开发工具大揭秘:提升后端程序员的生产力秘籍 一、前

发表于 2024-01-24

一、前言

作为一名后端开发者,选择正确的工具能显著提高我们的工作效率。在这篇文章中,我将分享我多年使用Mac进行开发所累积的心得,介绍一些我认为对后端程序员特别有帮助的Mac软件和插件。

二、软件分类

1)提升效率的Mac小工具

1.1) ishot

截图工具,为什么好用?对我来讲,我喜欢它的地方主要有几点

  1. 它可以贴图,也就是截图后可以选择它固定在屏幕上,这样的好处就是在需要对比不同情况数据的时候,可以留下之前原模原样的样式和数据进行对比,如下图右边是贴图

image

  1. 它可以截长图,在选定范围后,移动画面即可录制长图
  2. 它可以作为取色器,直接选中对应的颜色按R即可复制对应的颜色RGB
  3. 其它的比如在截图上勾勾画画那都是截图的基本功能了,它都有

1.2)超级右键

和上面的ishot是同一个公司的,其实搜到ishot,它的其它软件就都能看到了,大部分软件都蛮实用的,超级右键效果如下:

image

同一个公司还有些软件我也在用的比如上面右键显示的FastZip解压缩、ICopy通过双击Alt唤起你曾经复制过的文本图片文件等信息、IBar类似Bartender的效果,但是免费,还有一些别的可以自己去体验,我主要用这些。

1.3)HapiGo、utools

HapiGo是国产的类似Alfred的工具,体验效果不错,搜索功能和Alfred一样都能实现文件搜索,文件内容搜索,app搜索,而且符合国人习惯,支持拼音搜索,搜索过一次的软件之后排名也会靠前,也支持给软件赋予别名,用多了会很顺手。但是复杂功能方面可能就不如Alfred了,想实现类似工作流的功能还是要Alfred,但对Alfred没强依赖的人可以直接上手

和HapiGo类似的utools,更早出现,有比较成熟的插件市场,同样支持基本的搜索,但是可能是插件装多了,反而对app的搜索形成干扰,所以我现在都是搜索用HapiGo,用插件的时候才用utools。

utools的插件我常用的也大致介绍下

  • json

我最常用的是它的json功能,做开发的都知道,POST数据json解析是家常便饭,utools的json插件解析非常快,一些网页版的解析数据量到一定程度直接崩了,半天解析不出来,utools这个做的还是比较好的,没崩过。它也有些翻译插件可以快速翻译,但我还是觉得不够快,后面我会推荐翻译软件。

  • hosts

它可以对hosts进行管理快速修改,自定义多份hosts配置

image

  • 其它插件比如编程小助手,diff对比插件等等,可能偶尔会用下

image

1
复制代码可以看到它也有剪切板,和icopy功能重合,但我还是用了icopy,因为打开插件的时间我也要省,嫌弃它还是搜索插件并打开才能复制

1.4)Bob翻译

我用过最好用的软件级翻译,之所以软件级因为它在mac上任何地方都可以唤起使用,而有些插件只能在软件内使用,比如浏览器翻译插件,idea翻译插件

image

可以看到如上截图,我们可以设置多个翻译来源方同时一键翻译,有些是默认内置的,有些需要自己去官网获取API Key,划词后通过快捷键可以快速得到想要的翻译

1.5)dev-sidecar

一笔带过,用它可以加速我们的github访问,git,npm,pip等操作的速度

2)书写工具Craft

我用过多款笔记软件,一开始用过有道、语雀、印象笔记,之后喜欢上markdown的写法,于是用上了vnote,但是这个软件需要自己用坚果云同步数据,比较麻烦,用了一段时间就换了。

开始尝试notion,但是notion的缺点就是国内网络问题,大家都懂,有时候卡顿。

之后发现抄袭notion的wolai,的确抄的更符合国人习惯,缺点是没网络打不开,于是当我写了一段时间想要导出数据备份的时候发现,tmd没会员不给导,想要自己的数据要不自己一条条复制,要不开会员,我的文章都不是我的了要它合用,果断放弃。

又在网上找了一圈,发现个国外最近比较火的笔记工具Craft,可以用markdown的语法写作,一些字体颜色也比较漂亮,做成模板写个日报也不错,可以把部分内容转换成块或者页面层层嵌套,我一年的日报都可以写在一个文件里,周报里面加日报,每月还有50次免费AI,和Notion的AI用法差不多,可以直接改文档

image

image

3)Mac上的必备开发工具

3.1)Another Redis Desktop Manager

mac好用的redis管理工具,界面化的管理和删除key

3.2)Chat2DB

想要代替Navicat的一款新一代DB管理工具,支持AI生成SQL,目前界面还不是很成熟,但基本操作五脏俱全

image

1
复制代码支持以下数据库

image

3.3)Warp

terminal替代品,比较智能好用,命令输入的时候它会自动提示你,推荐的也很多了,我再推荐一遍

3.4)Charles

这是接口拦截记录的工具,google浏览器不能记录跳转前的请求,换edge后没这问题了,但还保留了这个软件

3.5)Apifox

代替postman的国产工具,有idea的插件可以实现接口的一键上传测试,我用着还是不错的,推荐

image

3.6)Cursor

这个号称用GPT4进行代码智能修改的开发软件,针对部分代码的智能快速修改可以用下的,毕竟idea插件的代码修改建议都是在对话框里的,并不会直接改代码,这是优势,但劣势就是并没有idea其它方面的快捷键好用,有些开发工具的功能不具备

3.7)OrbStack

相比mac上的docker软件,OrbStack也可以管理容器,且启动速度更快,所以我卸载了docker选择OrbStack

3.8)ServBay

可以快速在本地构建一些开发基本环境,比如postgresql,redis,memcache,php,nodejs等

image

4)Parallels Desktop

image

一款好用的mac虚拟机,有些一定要windows来完成的工作可以在这里完成

5)FinalShell

我一直用的shell,ftp工具,可以写占位符的shell命令保存

image

6)idea开发插件

6.1)Apifox Helper

适用于Apifox软件的接口上传插件

6.2)arthas idea

都知道吧,阿里巴巴的神器,可以在idea中快速复制对应的命令

6.3)CamelCase

字符串驼峰,大小写各种格式快速转换

6.4)Fitten Code beta(需要去官网下,目前就体验版没上传idea商店)

Github Copilot的替代品,AI代码提示,目前我还在体验中,其号称提示速度比Copilot还快,经我的测试,在算法题上,提示还是比较好的,优化细节比Copilot还好,但是不代表全方位超越,等我体验一段时间再来评价

6.5)GitToolBox

代码每一行光标点上去都会在最后灰色提示最近这一行的修改人是谁,对多人共做的项目这种提示还是蛮省事的,出问题快速找到对应人

6.6)GsonFormat-Plus

json快速生成对象代码

6.7)IdeaVim

用习惯了还是很好用的,不是用它的vim移动光标的方式,用它移动光标我mac电脑触摸板随便一移就到了,用这个用的是它的nnoremap,和idea自己的action相结合,可以产生神奇的化学反应

比如我用Arthas idea插件要复制一个方法的watch去服务器查看,我只需要按**《空格>a>w》**顺序按一遍就可以完成复制命令,原因则是在配置文件.ideavimrc,我对其进行了按键映射

1
2
3
4
5
6
json复制代码" watch
nnoremap <Space>aw :action ArthasWatch<CR>
" trace
nnoremap <Space>at :action ArthasTrace<CR>
" jad
nnoremap <Space>dc :action ArthasJadCommandAction<CR>

如上不同的按键顺序我可以得到不同的命令结果,只要按习惯,用的多了就会觉得很方便,不用慢慢右键找Arthas找对应的命令去点击了。

我这样说吸引力不知道够不够,我再举个例子,这样的例子很多,可以尽情发挥想象,我们有时候需要选中一个很长的方法去删掉,这个时候我们需要找到这个方法的头点下光标,再不断下移找到方法的尾部shift+光标点击全部选中后删除,这样移来移去是不是很麻烦,我就有很简单的方法,配置如下命令,按《空格>e>x》顺序按键,即可快速选中整个方法,包括其注释

1
json复制代码nnoremap <Space>ex va{<Bar>:action EditorSelectWord<CR>:action EditorSelectWord<CR>:action EditorSelectWord<CR>:action EditorSelectWord<CR>

image

至于更多的配置文件如果有很多人感兴趣我就单独出一篇,包含idea中如何使用vim

结语

希望这篇文章能为您在选择和使用Mac开发工具方面提供有价值的见解。无论您是刚入门的新手,还是经验丰富的老手,这些工具都将成为您不可或缺的助手。

最后,我非常期待听到您的反馈和经验分享。您最喜欢哪款工具?还有哪些神器值得推荐?请在评论区留言,让我们共同探讨,不断学习和进步。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

基于SpringBoot IP黑白名单的实现 业务场景 核心

发表于 2024-01-24

业务场景

IP黑白名单是网络安全管理中常见的策略工具,用于控制网络访问权限,根据业务场景的不同,其应用范围广泛,以下是一些典型业务场景:

  1. 服务器安全防护:
* 黑名单:可以用来阻止已知的恶意IP地址或曾经尝试攻击系统的IP地址,防止这些来源对服务器进行未经授权的访问、扫描、攻击等行为。
* 白名单:仅允许特定IP或IP段访问关键服务,比如数据库服务器、内部管理系统等,实现最小授权原则,降低被未知风险源入侵的可能性。
  1. 网站安全防护:
* 黑名单:对于频繁发起恶意请求、爬取数据、DDoS攻击等活动的IP,将其加入黑名单以限制其对网站的访问。
* 白名单:如果只希望特定合作伙伴、内部员工或特定区域用户访问网站内容,则可通过白名单来限定合法访问者的范围。
  1. API接口保护:
* 对于对外提供的API接口,通过设置IP黑白名单,确保只有经过认证或信任的系统和客户端才能调用接口。

比如比较容易被盗刷的短信接口、文件接口,都需要添加IP黑白名单加以限制。

核心实现

获取客户端IP地址

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
java复制代码@UtilityClass
public class IpUtils {
private final String UNKNOWN = "unknown";
private final String X_FORWARDED_FOR = "X-Forwarded-For";
private final String PROXY_CLIENT_IP = "Proxy-Client-IP";
private final String WL_PROXY_CLIENT_IP = "WL-Proxy-Client-IP";
private final Pattern COMMA_SEPARATED_VALUES_PATTERN = Pattern.compile("\s*,\s*");

/**
* 默认情况下内网代理的子网可以是(后面有需要可以进行配置):
* 1. 10/8
* 2. 192.168/16
* 3. 169.254/16
* 4. 127/8
* 5. 172.16/12
* 6. ::1
*/
private final Pattern INTERNAL_PROXIES = Pattern.compile(
"10\.\d{1,3}\.\d{1,3}\.\d{1,3}|" +
"192\.168\.\d{1,3}\.\d{1,3}|" +
"169\.254\.\d{1,3}\.\d{1,3}|" +
"127\.\d{1,3}\.\d{1,3}\.\d{1,3}|" +
"172\.1[6-9]\.\d{1,3}\.\d{1,3}|" +
"172\.2[0-9]\.\d{1,3}\.\d{1,3}|" +
"172\.3[0-1]\.\d{1,3}\.\d{1,3}|" +
"0:0:0:0:0:0:0:1|::1"
);

/**
* 获取请求的IP
*
* @return 请求的IP
*/
public String getIp() {
var requestAttributes = RequestContextHolder.getRequestAttributes();
if (Objects.isNull(requestAttributes)) {
return null;
}
var request = ((ServletRequestAttributes) requestAttributes).getRequest();
var ip = getRemoteIp(request);
if (!StringUtils.hasLength(ip) || UNKNOWN.equalsIgnoreCase(ip)) {
ip = request.getHeader(PROXY_CLIENT_IP);
}
if (!StringUtils.hasLength(ip) || UNKNOWN.equalsIgnoreCase(ip)) {
ip = request.getHeader(WL_PROXY_CLIENT_IP);
}
if (!StringUtils.hasLength(ip) || UNKNOWN.equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
return ip;
}

/**
* 获取客户端真实IP地址,防止使用X-Forwarded-For进行IP伪造攻击,防御思路见类注释
*
* @return 真实IP地址
*/
private String getRemoteIp(HttpServletRequest request) {
var remoteIp = request.getRemoteAddr();
var isInternal = INTERNAL_PROXIES.matcher(remoteIp).matches();

if (isInternal) {
var concatRemoteIpHeaderValue = new StringBuilder();

for (var e = request.getHeaders(X_FORWARDED_FOR); e.hasMoreElements(); ) {
if (concatRemoteIpHeaderValue.length() > 0) {
concatRemoteIpHeaderValue.append(", ");
}
concatRemoteIpHeaderValue.append(e.nextElement());
}

var remoteIpHeaderValue = commaDelimitedListToArray(concatRemoteIpHeaderValue.toString());
for (var i = remoteIpHeaderValue.length - 1; i >= 0; i--) {
var currentRemoteIp = remoteIpHeaderValue[i];
if (!INTERNAL_PROXIES.matcher(currentRemoteIp).matches()) {
return currentRemoteIp;
}
}
return null;
} else {
return remoteIp;
}
}

private String[] commaDelimitedListToArray(String commaDelimitedStrings) {
return (commaDelimitedStrings == null || commaDelimitedStrings.isEmpty())
? new String[0]
: COMMA_SEPARATED_VALUES_PATTERN.split(commaDelimitedStrings);
}
}

获取到客户端IP后,我们只要比对客户端IP是否在配置的白名单/黑名单中即可。
为了简化使用,可以采用注解的方式进行拦截。

新增注解@IpCheck

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
java复制代码/**
* IP白名单校验
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
@Documented
@Inherited
public @interface IpCheck {

/**
* 白名单IP列表,支持${...}
*/
@AliasFor("whiteList")
String value() default "";

/**
* 白名单IP列表,支持${...}
*/
@AliasFor("value")
String whiteList() default "";

/**
* 黑名单IP列表,支持${...}
*/
String blackList() default "";

}

新增IpCheckHandlerInterceptorImpl

我们实现HandlerInterceptor,在接口上进行拦截,如果不满足配置的黑白名单,则抛出异常。

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
java复制代码/**
* @author <a href="mailto:gcwm99@gmail.com">gcdd1993</a>
* Created by gcdd1993 on 2023/9/20
*/
@Component
public class IpCheckHandlerInterceptorImpl implements HandlerInterceptor, EmbeddedValueResolverAware {
private StringValueResolver stringValueResolver;

@Override
public boolean preHandle(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull Object handler) {
// 检查是否有IpWhitelistCheck注解,并且是否开启IP白名单检查
if (!(handler instanceof HandlerMethod)) {
return true; // 如果没有注解或者注解中关闭了IP白名单检查,则继续处理请求
}
var handlerMethod = (HandlerMethod) handler;
var method = handlerMethod.getMethod();
var annotation = AnnotationUtils.getAnnotation(method, IpCheck.class);
if (annotation == null) {
return true;
}
var clientIp = IpUtils.getIp();

// 检查客户端IP是否在白名单中
var whiteList = Stream.of(Optional.ofNullable(stringValueResolver.resolveStringValue(annotation.whiteList()))
.map(it -> it.split(","))
.orElse(new String[]{}))
.filter(StringUtils::hasText)
.map(String::trim)
.collect(Collectors.toUnmodifiableSet());
if (!whiteList.isEmpty() && whiteList.contains(clientIp)) {
return true; // IP在白名单中,继续处理请求
}
var blackList = Stream.of(Optional.ofNullable(stringValueResolver.resolveStringValue(annotation.blackList()))
.map(it -> it.split(","))
.orElse(new String[]{}))
.filter(StringUtils::hasText)
.map(String::trim)
.collect(Collectors.toUnmodifiableSet());
if (!blackList.isEmpty() && !blackList.contains(clientIp)) {
return true; // IP不在黑名单中,继续处理请求
}
// IP不在白名单中,可以返回错误响应或者抛出异常
// 例如,返回一个 HTTP 403 错误
throw new RuntimeException("Access denied, remote ip " + clientIp + " is not allowed.");
}

@Override
public void setEmbeddedValueResolver(StringValueResolver resolver) {
this.stringValueResolver = resolver;
}
}

自动装配

核心逻辑写完了,该怎么使用呢?为了达到开箱即用的效果,我们可以接着新增自动装配的代码

新建IpCheckConfig

实现WebMvcConfigurer接口,添加接口拦截器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typescript复制代码/**
* @author <a href="mailto:gcwm99@gmail.com">gcdd1993</a>
* Created by gcdd1993 on 2024/1/24
*/
public class IpCheckConfig implements WebMvcConfigurer {

@Resource
private IpCheckHandlerInterceptorImpl ipCheckHandlerInterceptor;

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(ipCheckHandlerInterceptor);
}

}

新建@EnableIpCheck

参考@EnableScheduling的实现,自己实现一个@EnableIpCheck,该注解可以控制功能是否启用

1
2
3
4
5
6
7
8
9
10
11
less复制代码/**
* @author <a href="mailto:gcwm99@gmail.com">gcdd1993</a>
* Created by gcdd1993 on 2024/1/24
*/
@Retention(value = RetentionPolicy.RUNTIME)
@Target(value = ElementType.TYPE)
@Documented
@ComponentScan("xxx.ip") // 这里是IpCheckConfig的包名
@Import(IpCheckConfig.class)
public @interface EnableIpCheck {
}

业务测试

简单地用代码来试验下效果

新建SampleApplication

1
2
3
4
5
6
7
8
9
less复制代码@SpringBootApplication
@EnableIpCheck
public class SampleApplication {

public static void main(String[] args) {
SpringApplication.run(SampleApplication.class, args);
}

}

新建测试接口

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
kotlin复制代码@RestController
@RequestMapping("/sample/ip-checker")
public class IpCheckSample {

@GetMapping("/white")
@IpCheck(value = "0:0:0:0:0:0:0:1")
String whiteList() {
return "127.0.0.1";
}

@GetMapping("/black")
@IpCheck(blackList = "0:0:0:0:0:0:0:1")
String blackList() {
return "127.0.0.1";
}

/**
* 同时配置白名单和黑名单,要求IP既在白名单,并且不在黑名单,否则抛出异常
*/
@GetMapping("/all")
@IpCheck(value = "0:0:0:0:0:0:0:1", blackList = "0:0:0:0:0:0:0:1")
String all() {
return "127.0.0.1";
}

/**
* 同时配置白名单和黑名单,要求IP既在白名单,并且不在黑名单,否则抛出异常
* 支持解析Spring 配置文件
*/
@GetMapping("/config")
@IpCheck(value = "${digit.ip.check.white-list}", blackList = "${digit.ip.check.black-list}")
String config() {
return "127.0.0.1";
}

/**
* 同时配置白名单和黑名单,要求IP既在白名单,并且不在黑名单,否则抛出异常
* 支持解析Spring 配置文件
*/
@GetMapping("/black-config")
@IpCheck(blackList = "${digit.ip.check.black-list}")
String blackConfig() {
return "127.0.0.1";
}

}

由于本机请求IP地址是0:0:0:0:0:0:0:1,所以这里使用0:0:0:0:0:0:0:1而不是127.0.0.1。

访问/sample/ip-checker/white

接口返回127.0.0.1

访问/sample/ip-checker/black

1
bash复制代码java.lang.RuntimeException: Access denied, remote ip 0:0:0:0:0:0:0:1 is not allowed.

访问/sample/ip-checker/all

接口返回127.0.0.1

  • 既配置白名单,也配置黑名单,需要既不在白名单,同时在黑名单里,才会拦截。

修改配置

1
2
3
4
5
yaml复制代码digit:
ip:
check:
white-list: 127.0.0.1, 192.168.1.1, 192.168.1.2
black-list: 127.0.0.1, 192.168.1.1, 192.168.1.2,0:0:0:0:0:0:0:1

访问/sample/ip-checker/black-config

1
bash复制代码java.lang.RuntimeException: Access denied, remote ip 0:0:0:0:0:0:0:1 is not allowed.

最后,可以结合配置中心,以便配置后立即生效。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

面试理想汽车,给我整懵了。。。

发表于 2024-01-24

理想汽车

今天看到一个帖子,挺有意思的。

先别急着骂草台班子。

像理想汽车这种情况,其实还挺常见的。

就是:面试官说出一个错误的结论,我们该咋办?

比较好的做法还是先沟通确认清楚,看看大家是否针对的为同一场景,对某些名词的认识是否统一,其实就是对错误结论的再次确认。

如果确定清楚是面试官的错误,仅做一次不直白的提醒后,看对方是否会陷入不确定,然后进入下一个问题,如果是的话,那就接着往下走。

如果对方还是揪着那个错误结论不放,不断追问。

此时千万不要只拿你认为正确的结论出来和对方辩论。

因为他只有一个结论,你也只有一个结论的话,场面就成了没有理据的争论,谁也说服不了谁。

我们可以从两个方向进行解释:

  • 用逻辑进行正向推导,证明你的结论的正确性
  • 用类似反证法的手段进行解释,试图从他的错误结论出发,往回推,直到推出一个对方能理解的,与常识相违背的基本知识

那么对应今天这个例子,关于「后序遍历」的属于一个定义类的认识。

我们可以用正向推导的方法,试图纠正对方。

可以从另外两种遍历方式进行入手,帮助对方理解。

比如你说:

“您看,前序遍历是「中/根 - 左 - 右」,中序遍历是「左 - 中/根 - 右」”

“所以它这个「X序遍历」的命名规则,主要是看对于一棵子树来说,根节点被何时访问。”

“所以我理解的后序遍历应该是「左 - 右 - 中/根」。”

“这几个遍历确实容易混,所以我都是这样的记忆理解的。”

大家需要搞清楚,这一段的主要目的,不是真的为了教面试官知识,因此适当舍弃一点点的严谨性,提高易懂性,十分重要。

因为我们的主要目的是:想通过有理据的解释,让他不要再在这个问题下纠缠下去。

如果是单纯想争对错,就不会有前面的「先进行友好提示,对方如果进行下一问,就接着往下」的前置处理环节。

搞清楚这一段表达的实际目的之后,你大概知道用什么口吻进行解释了,包括上述的最后一句,给对方台阶下,我觉得也是必要的。

对方是错了,但是你没必要给别人落一个「得理不饶人」的印象。

还是谦逊一些,面试场上争对错,赢没赢都是候选人输。

可能会有一些刚毕业的同学,心高气傲,觉得连二叉树这么简单的问题都搞岔的面试官,不值得被尊重。

你要知道,Homebrew 作者去面谷歌的时候,也不会翻转二叉树呢。

难道你要说这世上只有那些知识面是你知识面超集的人,才值得被尊重吗?

显然不是的,大家还是要学会带着同理心的去看待世界。

…

看了一眼,底下评论点赞最高的那位:

什么高情商说法,还得是网友。

所以面试官说的后序遍历是「右 - 左 - 中」?interesting。

…

回归主线。

也别二叉树后续遍历了,直接来个 nnn 叉树的后序遍历。

题目描述

平台:LeetCode

题号:590

给定一个 nnn 叉树的根节点 rootrootroot ,返回 其节点值的后序遍历。

nnn 叉树在输入中按层序遍历进行序列化表示,每组子节点由空值 null 分隔(请参见示例)。

示例 1:

1
2
3
ini复制代码输入:root = [1,null,3,2,4,null,5,6]

输出:[5,6,3,2,4,1]

示例 2:

1
2
3
csharp复制代码输入:root = [1,null,2,3,4,5,null,null,6,7,null,8,null,9,10,null,null,11,null,12,null,13,null,null,14]

输出:[2,6,14,11,7,3,12,8,4,13,9,10,5,1]

提示:

  • 节点总数在范围 [0,104][0, 10^4][0,104] 内
  • 0<=Node.val<=1040 <= Node.val <= 10^40<=Node.val<=104
  • nnn 叉树的高度小于或等于 100010001000

进阶:递归法很简单,你可以使用迭代法完成此题吗?

递归

常规做法,不再赘述。

Java 代码:

1
2
3
4
5
6
7
8
9
10
11
12
Java复制代码class Solution {
List<Integer> ans = new ArrayList<>();
public List<Integer> postorder(Node root) {
dfs(root);
return ans;
}
void dfs(Node root) {
if (root == null) return;
for (Node node : root.children) dfs(node);
ans.add(root.val);
}
}

C++ 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
C++复制代码class Solution {
public:
vector<int> postorder(Node* root) {
vector<int> ans;
dfs(root, ans);
return ans;
}
void dfs(Node* root, vector<int>& ans) {
if (!root) return;
for (Node* child : root->children) dfs(child, ans);
ans.push_back(root->val);
}
};

Python 代码:

1
2
3
4
5
6
7
8
9
10
Python复制代码class Solution:
def postorder(self, root: 'Node') -> List[int]:
def dfs(root, ans):
if not root: return
for child in root.children:
dfs(child, ans)
ans.append(root.val)
ans = []
dfs(root, ans)
return ans

TypeScript 代码:

1
2
3
4
5
6
7
8
9
10
TypeScript复制代码function postorder(root: Node | null): number[] {
const dfs = function(root: Node | null, ans: number[]): void {
if (!root) return ;
for (const child of root.children) dfs(child, ans);
ans.push(root.val);
};
const ans: number[] = [];
dfs(root, ans);
return ans;
};
  • 时间复杂度:O(n)O(n)O(n)
  • 空间复杂度:忽略递归带来的额外空间开销,复杂度为 O(1)O(1)O(1)

非递归

针对本题,使用「栈」模拟递归过程。

迭代过程中记录 (cnt = 当前节点遍历过的子节点数量, node = 当前节点) 二元组,每次取出栈顶元素,如果当前节点已经遍历完所有的子节点(当前遍历过的子节点数量为 cnt=子节点数量cnt = 子节点数量cnt=子节点数量),则将当前节点的值加入答案。

否则更新当前元素遍历过的子节点数量,并重新入队,即将 (cnt+1,node)(cnt + 1, node)(cnt+1,node) 入队,以及将下一子节点 (0,node.children[cnt])(0, node.children[cnt])(0,node.children[cnt]) 进行首次入队。

Java 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Java复制代码class Solution {
public List<Integer> postorder(Node root) {
List<Integer> ans = new ArrayList<>();
Deque<Object[]> d = new ArrayDeque<>();
d.addLast(new Object[]{0, root});
while (!d.isEmpty()) {
Object[] poll = d.pollLast();
Integer cnt = (Integer)poll[0]; Node t = (Node)poll[1];
if (t == null) continue;
if (cnt == t.children.size()) ans.add(t.val);
if (cnt < t.children.size()) {
d.addLast(new Object[]{cnt + 1, t});
d.addLast(new Object[]{0, t.children.get(cnt)});
}
}
return ans;
}
}

C++ 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
C++复制代码class Solution {
public:
vector<int> postorder(Node* root) {
vector<int> ans;
stack<pair<int, Node*>> st;
st.push({0, root});
while (!st.empty()) {
auto [cnt, t] = st.top();
st.pop();
if (!t) continue;
if (cnt == t->children.size()) ans.push_back(t->val);
if (cnt < t->children.size()) {
st.push({cnt + 1, t});
st.push({0, t->children[cnt]});
}
}
return ans;
}
};

Python 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
Python复制代码class Solution:
def postorder(self, root: 'Node') -> List[int]:
ans = []
stack = [(0, root)]
while stack:
cnt, t = stack.pop()
if not t: continue
if cnt == len(t.children):
ans.append(t.val)
if cnt < len(t.children):
stack.append((cnt + 1, t))
stack.append((0, t.children[cnt]))
return ans

TypeScript 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
TypeScript复制代码function postorder(root: Node | null): number[] {
const ans = [], stack = [];
stack.push([0, root]);
while (stack.length > 0) {
const [cnt, t] = stack.pop()!;
if (!t) continue;
if (cnt === t.children.length) ans.push(t.val);
if (cnt < t.children.length) {
stack.push([cnt + 1, t]);
stack.push([0, t.children[cnt]]);
}
}
return ans;
};
  • 时间复杂度:O(n)O(n)O(n)
  • 空间复杂度:O(n)O(n)O(n)

通用「非递归」

另外一种「递归」转「迭代」的做法,是直接模拟系统执行「递归」的过程,这是一种更为通用的做法。

由于现代编译器已经做了很多关于递归的优化,现在这种技巧已经无须掌握。

在迭代过程中记录当前栈帧位置状态 loc,在每个状态流转节点做相应操作。

Java 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Java复制代码class Solution {
public List<Integer> postorder(Node root) {
List<Integer> ans = new ArrayList<>();
Deque<Object[]> d = new ArrayDeque<>();
d.addLast(new Object[]{0, root});
while (!d.isEmpty()) {
Object[] poll = d.pollLast();
Integer loc = (Integer)poll[0]; Node t = (Node)poll[1];
if (t == null) continue;
if (loc == 0) {
d.addLast(new Object[]{1, t});
int n = t.children.size();
for (int i = n - 1; i >= 0; i--) d.addLast(new Object[]{0, t.children.get(i)});
} else if (loc == 1) {
ans.add(t.val);
}
}
return ans;
}
}

C++ 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
C++复制代码class Solution {
public:
vector<int> postorder(Node* root) {
vector<int> ans;
stack<pair<int, Node*>> st;
st.push({0, root});
while (!st.empty()) {
int loc = st.top().first;
Node* t = st.top().second;
st.pop();
if (!t) continue;
if (loc == 0) {
st.push({1, t});
for (int i = t->children.size() - 1; i >= 0; i--) {
st.push({0, t->children[i]});
}
} else if (loc == 1) {
ans.push_back(t->val);
}
}
return ans;
}
};

Python 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Python复制代码class Solution:
def postorder(self, root: 'Node') -> List[int]:
ans = []
stack = [(0, root)]
while stack:
loc, t = stack.pop()
if not t: continue
if loc == 0:
stack.append((1, t))
for child in reversed(t.children):
stack.append((0, child))
elif loc == 1:
ans.append(t.val)
return ans

TypeScript 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
TypeScript复制代码function postorder(root: Node | null): number[] {
const ans: number[] = [];
const stack: [number, Node | null][] = [[0, root]];
while (stack.length > 0) {
const [loc, t] = stack.pop()!;
if (!t) continue;
if (loc === 0) {
stack.push([1, t]);
for (let i = t.children.length - 1; i >= 0; i--) {
stack.push([0, t.children[i]]);
}
} else if (loc === 1) {
ans.push(t.val);
}
}
return ans;
};
  • 时间复杂度:O(n)O(n)O(n)
  • 空间复杂度:O(n)O(n)O(n)

我是宫水三叶,每天都会分享算法题解,并和大家聊聊近期的所见所闻。

欢迎关注,明天见。

更多更全更热门的「笔试/面试」相关资料可访问排版精美的 合集新基地 🎉🎉

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

写代码这件事,迈入第七个年头才有了一些心得(第四章 泛型 +

发表于 2024-01-24

写代码这件事,迈入第七个年头才有了一些心得(第四章 泛型 + 函数式编程)

将泛型和函数式编程结合,只是为了让代码更加优雅! –uzong

🍁一、成长往事

CV大法是入门时候的必备技能,但随着对自己技术要求的提高,对于重复的代码也开始变得抵触。
于是我开始抽取那些看得见的重复代码;有一天突然发现,虽然我抽取了大量重复性的代码;但太多 “骨架相似”、”结构化式” 的代码却若隐若现,虽然代码上它们不重复,但从”外形”却极其相似!,就像下图一样,看起来不相同,其形状却相似。

image.png

我以为把重复代码抽成一个独立的方法,从而实现代码的复用,这就是所谓的重构优化;但这种方式却太表象了,没有灵魂和深度,过去的那些日子,我感觉自己的编程水平也就限于把重复的代码抽一抽,(如下图所示一样),甚至觉得代码优化不就是这样吗,这样的状态一直维持很久。

image.png

随着对代码本身的思考,我才慢慢体会到了编程的艺术,也才慢慢体会到了代码其实也可以更优雅! 而让我感受到这种优雅艺术的点,正是泛型和函数式编程!

今天就带大家一步一步地感受,泛型和函数式编程的优雅所在!

📒二、案例分析

2.1 结构化的代码

以分页为例子,来感受一下什么是结构化的代码。特别说明一下:

  • 分页还需当前页数、页大小,以及校验等,本案例忽略;
  • 代码主要逻辑:查询分页条数,如果为 0 ,则不用查询列表详情,直接返回;如果分页条数大于 0 则查询列表详情。

代码一、返回总数和分页详情,查询 Book 表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Java复制代码public PageData queryBook(BookRequest request) {
// 1. 创建分页对象
PageData pageData = new PageData();

// 2. 计算满足的记录数
int count = bookMapper.queryBookCount(request);

// 3. 为 0,则表示没有符合的数据,直接返回
if (count == 0) {
pageData.setCount(0);
return pageData;
}
// 4. 不为 0,计算记录详情
List<BookDO> bookList = bookMapper.queryBookList(request);

// 5. 封装记录总数和
pageData.setCount(count);
pageData.setResult(bookList);

return pageData;
}

代码二、返回总数和分页详情,查询 Pencil 表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
java复制代码public PageData queryPencil(PencilRequest request) {
// 1. 创建分页对象
PageData pageData = new PageData();

// 2. 计算满足的记录数
int count = pencilMapper.queryPencilCount(request);

// 3. 为 0,则表示没有符合的数据,直接返回
if (count == 0) {
pageData.setCount(0);
return pageData;
}
// 4. 不为 0,计算记录详情
List<PencilDO> pencilList = pencilMapper.queryPencilList(request);

// 5. 封装记录总数和
pageData.setCount(count);
pageData.setResult(pencilList);

return pageData;
}

将两段代码进行对比:

book pencil
image.png image.png

得出结论:

  • 结构相似:外形
  • 语义相似:分页语义一致,先查询 count,然后再根据 count 是否查询 List 详情

如果再有其他实体对象的分页,那么 CV 一下,改改我上面的红框的地方即可。

基于上面这个案例,我们再深度思考一下。

2.2 重复代码的考量

在系统里面,这样结构化的代码随处可见。那么这两个方法代码有重复代码吗?

好像并没有(IDEA没有提示),因为很难找到大块重复的代码;也很难抽取出来一个具体的方法,然后被调用!

就像下面图中展示的那样,选中重复代码,然后抽取一个新的方法,对重复代码进行替换! 下面的这种操作就是我早期进行代码重构的核心技能!

image.png

虽然很难找到大块重复代码,但是上面的代码从 “外形骨架” 上看,却极其相似,难道这种相似不应该也是一种重复吗?

这种结构化式的重复,曾经困扰了我很久,我很难像抽取重复代码一样去抽取这种相似的结构!

遇到这样结构化的代码,我也不得不加入 CV 大军;并自我PUA,这样的代码并不是重复的代码!!!

随着对代码的思考和深入,一种独特的组合,彻底解决这种结构化的重复,那便是泛型+函数式编程

📑三、泛型 + 函数式编程

我认为它们的组合是天生解决这种结构化的!

3.1 泛型特性

泛型,”一切皆行”,泛型在于它的普适性、通用性。太多工具类都使用了泛型! 当然我也喜欢用泛型,因为它很优雅!

下面是一个代码片段。对请求返回结果进行包装; 特别适用于 RPC 远程调用结果等场景。针对不同的返回实体,可以使用 T 类型 来表示。非常通用!

  • T 代表一切实体
  • success 是否请求成功
  • errorCode、errorMsg 出错时提供错误码和错误消息
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
java复制代码public class ServiceResult<T> {

/**
* 请求是否成
*/
private Boolean success;

/**
* 错误码
*/
private String errorCode ;

/**
* 错误信息
*/
private String errorMsg;

/**
* 返回内容
*/
private T content;

......

}

除了上面这个案例以外,在很多工具类库中都十分常见。例如:下面是 hutool 包中的一个工具类。

  • 一个 set 集合如果为 null,则创建一个空的集合对象,否则返回原来的集合对象

image.png

泛型为简化重复代码而生!

泛型的适用的场景太多,比如下面场景:

  • 工具类中使用
  • 抽象类;模板方法,构建标准步骤中使用
  • 顶层接口类中使用
  • 甚至使用 Object 的场景都可以考虑使用泛化来替代

……

接下来再聊聊从 JDK8 开始的新特性(语法糖),函数式编程。

3.2 函数式编程

在函数式编程中,可将方法作为参数进行传递调用;灵活性不言而喻!

下面这几行代码是基于 guava 的 ListenableFuture 封装的一个异步回调。Callable 可以代表所有的方法。(匿名内部类)

1
2
3
4
Java复制代码public static <V> ListenableFuture<V> invokeWithFuture(
Callable<V> callable) {
return gPool.submit(callable);
}

使用如下:

1
2
3
4
5
6
7
8
Java复制代码@Test
public void invokeWithFuture() throws Execution {

ListenableFuture<String> result =
AsyncInvoke.invokeWithFuture(() -> "hello");

System.out.println(result.get());
}

() -> "hello" 作为一个代码块被传入到了方法中。是的,将代码块作为参数传递!

函数式编程的好处,让代码变得如此的灵活。困扰我多年的问题终于有了解法了。

📜四、华丽转身

  • 泛型:解决通用性
  • 函数式编程:将代码块用函数作为参数进行传递

于是基于分页的结构化问题,使用 泛型 + 函数式编程 进行解决!

  • 分页总数,使用 countFunction 计算
  • 分页详情,使用 listFunction 获取

如下所示:

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
Java复制代码@SneakyThrows
public static <T> PageData buildPageData(
Callable<Integer> countFunction,
Callable<List<T>> listFunction) {
// 1. 创建分页对象
PageData pageData = new PageData();

// 2. 计算满足的记录数
int count = countFunction.call();

// 3. 为 0,则表示没有符合的数据,直接返回
if (count == 0) {
pageData.setCount(0);
return pageData;
}
// 4. 不为 0,计算记录详情
List<T> resultList = listFunction.call();


// 5. 封装记录总数和
pageData.setCount(count);
pageData.setResult(resultList);

return pageData;
}

补充分页对象代码:

1
2
3
4
5
jAVA复制代码@Data
static class PageData<T> {
private int count;
private T result;
}

使用情况:

1
2
3
4
Java复制代码@Test
public void testBuildPageData(String[] args) {
buildPageData(()-> 1, () -> Arrays.asList("1"));
}
  • 第一个参数是求记录数的方法
  • 第二个参数是求详情的方法

从那时起,结构化的代码,我不再进行 CV 了。泛型和函数式的编程让我的代码重复率又下降一个水位!

小秘诀:将变动的部分以函数方式进行变量替换;从而保留骨架,达到泛化和通用。就像下面这张图一样,“骨肉分离”,肉是细节代码;骨是结构框架。

image.png

📚五、更多案例

我开始大量实践后,这样的代码也越来越多。接着再分析一个详细的案例。

下面是一个异步回调的工具类。

首先,定义异步回调的任务接口,所有目标对象需要实现该接口才能作为异步回调的参数进行调用

1
2
3
4
5
6
7
Java复制代码public interface CallbackTask<R> {
R execute();
default void onSuccess(R r) {
}
default void onFailure(Throwable t) {
}
}

方法理解:

  • execute 主方法,目标任务需要具体实现
  • 方法执行成功后回调 onSuccess 方法
  • 方法执行失败后回调 onFailure 方法

下面是具体实现:通过 CompletableFuture 来实现异步回调!调用执行链路逻辑:

  • supplyAsync 异步执行任务
  • whenComplete 异步执行结束回调,不管失败成功都会调用,因此我做了一下判断
  • exceptionally 失败场景回调
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
Java复制代码/**
* 借助 CompletableFuture 来实现异步行为。
* 不会抛出异常,在 onFailure 中处理异常
*
* @param executeTask
* @param <R>
* @return
*/
private static <R> CompletableFuture<R> doInvoker(
CallbackTask<R> executeTask) {
CompletableFuture<R> invoke = CompletableFuture
.supplyAsync(() -> {
try {
return executeTask.execute();
} catch (Exception exception) {
throw new BizException(
ASYNC_INVOKER_ERROR.getErrorCode(),
exception.getMessage());
}
}, gPool)
.whenComplete((result, throwable) -> {
// 不管成功与失败,whenComplete 都会执行,
// 通过 throwable == null 跳过执行
if (throwable == null) {
executeTask.onSuccess(result);
}
})
.exceptionally(throwable -> {
executeTask.onFailure(throwable);
// todo 给一个默认值,或者使用 Optional包装一下,否者异常会出现NPE
return null;
});
return invoke;
}

上面代码是整个骨架, 实现了异步回调。

下面是具体使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Java复制代码CompletableFuture<Integer> result = 
AsyncInvoke.doInvoker(new CallbackTask<Integer>() {
public Integer execute() {
int result = 1 + 1;
return result;
}

@Override
public void onSuccess(Integer integer) {
System.out.println("on success result: " + integer);
}

@Override
public void onFailure(Throwable t) {
System.out.println("error " + t.getMessage());
}
});
  • result#get 可以获取异步结果
  • 执行成功后调用 onSuccess,失败会调用 onFailure

还有很多其他场景都可以使用 泛型 + 函数式编程 来解决

  • 针对每个方法限流
  • 针对每个方法重试

……

它解决了重复,让代码看起来优雅!

虽然如此,但这样的组合,也会带来一些不足。

📝六、不足之处

  • 泛型和函数式编程不方便调试;出问题的时候比较显著
  • 理解有成本,尤其方法不熟悉的时候
  • 同时降低一定的可读性

🔊七、最后感想

泛型和函数式编程只是 Java 中的语法糖,它算不上编程的内功心法,只是一种展现形式而已。我们更多应该关注的是如何对一系列具体的场景进行抽象,然后再通过工具去实现它们。就像如何去定义一个泛型,如何去抽象一个函数一样。

🍁🍁🍁 重复等于不精彩,我不喜欢!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…606162…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%