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

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


  • 首页

  • 归档

  • 搜索

30岁之前透支,30岁之后还债。

发表于 2023-11-13

前言

看到不少私信问我为啥没有更新了,我没有一一回复,实在是身体抱恙,心情沉重,加上应付于工作,周旋于家庭,自然挤压了自我空间。

今天思来想去,重新执键,决定久违地又一次写点分享,奉劝大家珍惜身体,愉悦生活。

愉悦二字说来容易,但各位都一样,奔波于现实,劳累于生活,岂是三言两语就能改变的。

病来如山倒

我又病了,有些意外和突然的,令我措手不及。

一天早上我起来,脖子有些酸,就伸手揉揉捏捏,忽然发现脖颈左侧有一个肿块,仔细拿捏,发现竟然是在里面,而且硬邦邦的,伴有轻微的疼痛感。

当时早上对着镜子拍下来的肿块,我还保留了照片。

1.jpg

立马便一身冷汗冒出,我从未经历过这样的事情,去年身体毕竟出过问题,两相叠加之下,内心更是难以描述。

因为是周一,怀着忐忑的心情去上班了,接下来一直都有些神经兮兮,觉得自己身体出了大问题。

之前我有文章讲过自己去年其实已经检查出血脂的问题,停更半年之久,调养了一番,才真正感觉到身体有所恢复,根据我发文的日期可见一二。

恢复更新的这段时间,报复式地写作和分享,一度不知不觉地排到榜单第二,今天登录看了一下,居然还在月榜前三没下来,也是意外。

话说回来,人一旦身体冒出点病痛,整个心情都显得低沉萎靡,很快就能在方方面面反应出来。

我是硬着头皮上班的,抽空网上查了下好让自己有个心理准备。

百度一搜便是绝症,这是很多年前就知道的,但病急乱投医果然是人之本性,我毅然决然还是搜了。

然后,各种甲状腺之类的就来了,再搜,淋巴瘤也来了,再搜,好家伙,直接恶性肿瘤十有八九了。

面对未知而产生的接近绝望的心情,想必不少人有类似经验。

比如我,下意识先想到的竟然不是我是不是要完蛋了,而是想到自己是家中独子,父母年迈身体有恙,妻子操劳,孩子尚小,家中主要经济来源也是我。

我一旦倒下,实在不敢想,往深了一想各种负面因子都蜂拥而来。

我不知道有多少人和我的性格相似,就是身体出了这种未知的问题,一面觉得应该去医院看看,一面又怕折腾来去最后拿到最不可接受的结果,可能不知道反而能活久一点,大概就是这种心情了。

是的,我大体是个胆子还算大的人,也猛然间抗拒去医院了。

不去医院的结果,就是你每天都在意这个肿块,每天都要摸摸它是不是变小了,是不是消失了,每天都小心呵护着它,甚至还想对它说说话倾诉一下,像是自己偷养的小情人一样。

只盼着某天睡觉醒来,用手一摸,哈哈没有了这样。

我就是差不多一个月都这样惶惶不可终日地度过,直到这周六才被妻子赶去医院做了检查。

透支和还债

30岁之前透支,30岁之后还债。

说来好笑,摸到肿块的第二天吧,还有朋友私信找我合作,换做平时,我肯定欣然接受,并开始设计文稿。

但身体有问题,一切都索然无味了,再次真切地体会到这种被现实打碎一切欲望的撕裂感。

2.png

为什么我30岁之后身体慢慢开始出现各种问题,这两年我有静下心来思考过。

到底还是30岁之前透支太多了,30岁之后你依然养成30岁之前的生活习惯,无异于自杀行为。

我把身体比作一根橡皮筋,它大概只能扯那么长,我长期将它扯那么那么长,我以为它没事,直到有一次我将它扯那么那么那么长,砰的一声它就断了。

我们都无法知道自己的这根橡皮筋到底能扯多长,只要它没断,我们都觉得它还能扯很长,代价就是,只需断一次,你再也无法重来了。

30岁之前,我努力学习各种知识,熬夜那是家常便饭,睡一觉便生龙活虎。

我就像以前上学的三好学生一样,在学校我扎扎实实,放学了我还进补习班,补习班回来了我还上网学知识。

回头想想,真特么离谱啊,我上学都没这样,走上社会了竟然付出了之前在学校几倍的努力。

早知如此,我好好上学读书最后进入一个更优质的圈子,不就少走很多弯路了吗,但是谁又会听当年的老师和父母一番肺腑之言呢。

埋怨过去没有什么意义,只能偶尔借着都市小说幻想一下带着记忆重生回校园的自己。

细数下来,我30岁之前熬过的夜比我加的班还多,我不是天天加班,但好像真的天天熬夜。

可我身体一点问题都没有,我觉得自己不是那种被命运抛弃的人,内心一直这么侥幸,你是不是也和我一样呢。

30岁之后,该来的还是来了,32岁那年,我有一次咳嗽入院,反复高烧,退了又发烧,医生一度以为是新冠,或结核,或白血病什么的,后来全部检查了都不是,发现就是普通的肺部感染。

每天两瓶抗病毒的点滴,大概半个月才逐渐恢复,人都瘦脱相了,这是我人生头一次住院,躺在病床上像废人一样。

等到33岁也就是去年,偶然头晕了一次,那种眩晕,天旋地转,犯恶心,怎么站怎么坐怎么躺都不行,真正要死的感觉。

后面我一度以为是年纪轻轻得了高血压,结果查了下是血脂的问题,还不算严重,但继续下去很可能会变成一些心脑血管疾病。

我难以置信,这可都是老年病啊,我一个30几岁的程序员说来就来了?

调养半年多,肉眼可见身体有好转,我又开始没忍住熬夜了,想做自己的课题,想分享更多的东西,这些都要花时间,而且包括一些其他领域的内容,想得太多,自然花的时间就多。

一不小心就连续熬了一个多月,平均每晚都是2点左右躺下,有时中午还不午休,刷手机找素材。

终于,脖子上起了肿块,让我整个人都蒙圈了,觉得一切努力都是在玩弄自己,忽然间什么都没意思了。

我尽量把这种感受描述出来,希望你们能看明白,真切体会一二。

为什么30岁之后我一熬夜就有问题出现,说白了,30岁之前透支了已经,一来是身体负荷达到临界,二来养成了多年的坏习惯,一时想改还改不过来。

30岁之前真别玩弄自己的身体了xdm,橡皮筋断了就真断了,接不上了,接上了也没以前的弹性了。

健康取决于自律和心情

对于程序员来说,健康取决于两点:自律和心情。

30岁之前,请学会自律,学习时间自律,生活作息自律,一日三餐自律,养成这样的习惯,30岁之后的你会受益匪浅。

自律真的很难,我就是一个很难做到的人,我有倔强地适应过,却又悲哀地失败了。

就像你是一个歇斯底里的人,忽然让你温文尔雅,你又能坚持多久呢。

我用很多鸡汤说服过自己,对于已经30几岁的我来说,也只能维持一段时间。

想看的多,想玩的多,想学的也多,时间是真不够啊,真想向天再借五百年。

我应该算是幸运的那一类,至少我这般透支身体,我还活着,也没用余生去直面绝望。

我用这两年的身体故障给自己上了重要的一课,人死如灯灭。

如果能重来,我一定会学习时间规划,我一定会把每天的时间安排的好好的。

我一定会保证一日三餐不落下,少吃外卖,多吃水果蔬菜。

我一定会保证每晚充足的睡眠,早睡早起,绝不熬夜。

我一定会每天下班和放假抽出一些时间运动和锻炼。

我不是说给自己听的,因为我已经透支了。

我是说给在看文章的你们听的,还年轻点的,还没透支的,请用我的现在当做你可能更坏的未来,早点醒悟,为时不晚。

自律很难,但不自律可能等死,这个选择一点也不难。

工作压力大,作为程序员是避免不了的,所以我以前有劝过大家,薪水的重要性只占一半,你应该追寻一份薪水尚可,但压力一定在承受范围内的工作,这是我认为在国内对于程序员来说相对友好的途径。

我进入IT行业目前为止的整个生涯中,学习阶段听到过传智播客张孝祥老师的猝死,工作阶段听说过附近的4396游戏公司里面30多岁程序员猝死,今年又听到了左耳朵耗子先生的离世。

我想着,那一天,离我和你还有多远。

心情真的很重要,至少能快速反应在身体上。

当我这周六被妻子劝说去检查的时候,我内心一直是紧张的,妻子没去,就在家陪着孩子,跟我说你自己去吧,如果有坏消息就别回复了,等回来再说,如果没什么事那就发个微信。

我想我理解她的意思了,点了点头就骑车去了医院。

医院真不是什么好地方,我就是给医院干活的,我全身上下都讨厌这里。

最煎熬的时间是做彩超前的一个多小时,因为人太多,我得排队,盯着大屏上的号序,我脑子里想了很多事情,甚至连最坏的打算都想好了。

人就很奇怪,越是接近黑暗,越是能回忆起非常多的往事,连高中打篮球挥洒汗水的模样和搞笑的投篮姿势都能想起来。

喊到我的时候,我心跳了一下,然后麻木地进去了,躺下的时候,医生拿着仪器对着我的脖子扫描,此时的我是近一个月以来第一次内心平静,当真好奇怪的感觉。

随着医生一句:没什么事,就一个淋巴结。

犹如审判一般,我感觉一下无罪释放了。

当时听到这句话简直犹如天籁,这会儿想起来还感觉毛孔都在欢快地愉悦。

我问她不是什么肿瘤或甲状腺吧,她说不是,就一个正常的淋巴结,可能是炎症导致了增生,这种一般3个多月至半年才会完全消掉。

这是当时拍的结果

3.jpg

拿给主任医师看了之后,对方也说一点事没有,只是告诫我别再熬夜了。

我不知道人生还会给我几次机会,但我从20几岁到30几岁,都没有重视过这个问题,也没有认真思考过。

直到最近,我才发现,活着真好。

当晚是睡得最踏实的一晚,一点梦都没做,中途也没醒,一觉到天亮。

更离谱的是,早上我摸了一下脖子,竟然真的小了点,这才短短一天,说了都没人信。

我头一次相信,心情真的会影响身体,你心情好了,身体的器官和血液仿佛都欢腾了起来。

如何保持一个好心情,原来这般重要,我拿自己的身体给大家做实验了,有用!

希望大家每天在自律的基础上保持好心情,不负年华,不负自己。

总结

xdm,好好活着,快乐活着。

公众号 –> 【Java分享客栈】


如果喜欢,请点赞+关注↑↑↑,持续分享干货哦~

本文转载自: 掘金

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

一文了解Webpack中Tapable事件机制

发表于 2023-11-13

作者:刘锦泉

引言

Webpack 是前端工程化常用的静态模块打包工具。在合适的时机通过 Webpack 提供的 API 改变输出结果,使 Webpack 可以执行更广泛的任务,拥有更强的构建能力。

Webpack 的插件机制本质上是一种事件流的机制,它的工作流程就是将各个插件串联起来,而实现这一切的核心就是Tapable,Webpack中最核心的负责编译的Compiler和负责创建bundles的Compilation都是Tapable的实例。

本文将介绍 Tapable 的基本使用以及底层实现。

Tapable

Tapable 是一个类似于 Node.js 中的 EventEmitter 的库,但它更专注于自定义事件的触发和处理。通过 Tapable 我们可以注册自定义事件,然后在适当的时机去执行自定义事件。这个和我们所熟知的生命周期函数类似,在特定的时机去触发。

我们先看一个 简单 Tapable 的 例子:

1
2
3
4
5
6
7
8
9
10
11
12
js复制代码const { SyncHook } = require("tapable");

// 实例化 钩子函数 定义形参
const syncHook = new SyncHook(["name"]);

//通过tap函数注册事件
syncHook.tap("同步钩子1", (name) => {
console.log("同步钩子1", name);
});

//同步钩子 通过call 发布事件
syncHook.call("古茗前端");

通过上面的例子,我们大致可以将 Tapable 的使用分为以下三步:

  • 实例化钩子函数
  • 事件注册
  • 事件触发

事件注册

  • 同步的钩子要用 tap 方法来注册事件
  • 异步的钩子可以像同步方式一样用 tap 方法来注册,也可以用 tapAsync 或 tapPromise 异步方法来注册。
    • tapAsync: 使用用 tapAsync 方法来注册 hook 时,必须调用callback 回调函数。
    • tapPromise:使用 tapPromise 方法来注册 hook 时,必须返回一个 pormise ,异步任务完成后 resolve 。

事件触发

  • 同步的钩子要用 call 方法来触发
  • 异步的钩子需要用 callAsync 或 promise 异步方法来触发。
    • callAsync:当我们用 callAsync 方法来调用 hook 时,第二个参数是一个回调函数,回调函数的参数是执行任务的最后一个返回值
    • promise:当我们用 promise 方法来调用 hook 时,需要使用 then 来处理执行结果,参数是执行任务的最后一个返回值。

Tapable Hook 钩子

tapable 内置了 9 种 hook 。分为 同步、异步 两种执行方式。异步执行 Hook 又可以分为 串行 执行和 并行 执行。除此之外,hook 可以根据执行机制分为 常规 瀑布模式 熔断模式 循环模式 四种执行机制。

image.png

同步钩子

同步钩子顾名思义:同步执行,上一个钩子执行完才会执行下一个钩子。

示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
js复制代码const { SyncHook } = require("tapable");

// 实例化 钩子函数 定义形参
const syncHook = new SyncHook(["name"]);

//通过tap函数注册事件
syncHook.tap("同步钩子1", (name) => {
console.log("同步钩子1", name);
});

//该监听函数有返回值
syncHook.tap("同步钩子2", (name) => {
console.log("同步钩子2", name);
});


//同步钩子 通过call 发布事件
syncHook.call("古茗前端");

执行结果如下所示:

image.png

异步钩子

异步钩子分为: 串行执行和并行执行。在串行执行中,如果上一个钩子没有调用callback 回调函数,下一个钩子将不会触发对应的事件监听。

示例代码如下:

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
js复制代码const { AsyncParallelHook, AsyncSeriesHook } = require("tapable");


const asyncParallelHook = new AsyncParallelHook(["name"]);

const asyncSeriesHook = new AsyncSeriesHook(["name"]);


//通过tap函数注册事件
asyncParallelHook.tapAsync("异步并行钩子1", (name, callback) => {
setTimeout(() => {
console.log("异步并行钩子1", name);
}, 3000);
});

//该监听函数有返回值
asyncParallelHook.tapAsync("异步并行钩子2", (name, callback) => {
setTimeout(() => {
console.log("异步并行钩子2", name);
}, 1500);
});

//通过tap函数注册事件
asyncSeriesHook.tapAsync("异步串行钩子1", (name, callback) => {
setTimeout(() => {
console.log("异步串行钩子1", name);
}, 3000);
});

//该监听函数有返回值
asyncSeriesHook.tapAsync("异步串行钩子2", (name, callback) => {
setTimeout(() => {
console.log("异步串行钩子2", name);
}, 1500);
});


// 异步并行钩子 通过 callAsync 发布事件
asyncParallelHook.callAsync("古茗前端", () => {
console.log("1111");
return "1122";
});

// 异步串行钩子 通过 callAsync 发布事件
asyncSeriesHook.callAsync("古茗前端", () => {
console.log("1111");
return "1122";
});

控制台输出结果如下图所示:

image.png

串行钩子1没有调用callback, 所以串行钩子2没有触发。添加callback后,控制台输出结果:

image.png

熔断类

AsyncSeriesBailHook 是一个异步串行、熔断类型的 Hook。在串行的执行过程中,只要其中一个有返回值,后面的就不会执行了。

示例代码如下:

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
js复制代码const { SyncBailHook, AsyncSeriesBailHook } = require("tapable");

const syncBailHook = new SyncBailHook(["name"]);
const asyncSeriesBailHook = new AsyncSeriesBailHook(["name"]);

syncBailHook.tap("保险类同步钩子1", (name) => {
console.log("保险类同步钩子1", name);
});

syncBailHook.tap("保险类同步钩子2", (name) => {
console.log("保险类同步钩子2", name);
return "有返回值";
});

syncBailHook.tap("保险类同步钩子3", (name) => {
console.log("保险类同步钩子3", name);
});

asyncSeriesBailHook.tapAsync("保险类异步串行钩子1", (name, callback) => {
setTimeout(() => {
console.log("保险类异步串行钩子1", name);
callback();
}, 3000);
});

asyncSeriesBailHook.tapAsync("保险类2步串行钩子1", (name, callback) => {
setTimeout(() => {
console.log("保险类异步串行钩子2", name);
callback("有返回值");
}, 2000);
});

asyncSeriesBailHook.tapAsync("保险类异步串行钩子3", (name) => {
setTimeout(() => {
console.log("保险类异步串行钩子3", name);
}, 1000);
});

syncBailHook.call("古茗前端");
asyncSeriesBailHook.callAsync("古茗前端", (result) => {
console.log("result", result);
});

控制台输出结果如下图所示:

image.png

循环类

SyncLoopHook 是一个同步、循环类型的 Hook。循环类型的含义是不停的循环执行事件函数,直到所有函数结果 result === undefined,不符合条件就调头重新开始执行。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
js复制代码const { SyncLoopHook } = require("tapable");

const syncLoopHook = new SyncLoopHook(["name"]);

let count = 4;

syncLoopHook.tap("循环钩子1", (name) => {
console.log("循环钩子1", count);
return count <= 3 ? undefined : count--;
});

syncLoopHook.tap("循环钩子2", (name) => {
console.log("循环钩子2", count);
return count <= 2 ? undefined : count--;
});

syncLoopHook.tap("循环钩子3", (name) => {
console.log("循环钩子3", count);
return count <= 1 ? undefined : count--;
});


syncLoopHook.call();

控制台输出结果:

image.png

瀑布类

AsyncSeriesWaterfallHook 是一个异步串行、瀑布类型的 Hook。如果前一个事件函数的结果 result !== undefined,则 result 会作为后一个事件函数的第一个参数(也就是上一个函数的执行结果会成为下一个函数的参数)。

示例代码:

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
js复制代码const { AsyncSeriesWaterfallHook, SyncWaterfallHook } = require("tapable");

const syncWaterfallHook = new SyncWaterfallHook(["name"]);

const asyncSeriesWaterfallHook = new AsyncSeriesWaterfallHook(["name"]);

syncWaterfallHook.tap("瀑布式同步钩子1", (name) => {
console.log("瀑布式同步钩子1", name);

return "古茗前端1";
});

syncWaterfallHook.tap("瀑布式同步钩子2", (name) => {
console.log("瀑布式同步钩子2", name);
});

syncWaterfallHook.tap("瀑布式同步钩子3", (name) => {
console.log("瀑布式同步钩子3", name);

return "古茗前端3";
});

asyncSeriesWaterfallHook.tapAsync("瀑布式异步串行钩子1", (name, callback) => {
setTimeout(() => {
console.log("瀑布式异步串行钩子1", name);

callback();
}, 1000);
});

asyncSeriesWaterfallHook.tapAsync("瀑布式异步串行钩子2", (name, callback) => {
console.log("瀑布式异步串行钩子2", name);
setTimeout(() => {
callback();
}, 2000);
});

asyncSeriesWaterfallHook.tapAsync("瀑布式异步串行钩子3", (name, callback) => {
console.log("瀑布式异步串行钩子3", name);
setTimeout(() => {
callback("古茗前端3");
}, 3000);
});

syncWaterfallHook.call("古茗前端");

asyncSeriesWaterfallHook.callAsync("古茗前端", (result) => {
console.log("result", result);
});

控制台输出结果:

image.png

Tapable 高级特性

Intercept

除了通常的 tap/call 之外,所有 hook 钩子都提供额外的拦截API。— intercept 接口。

intercept 支持的中间件如下图所示:

intercept 类型 描述
call (...args) => void 当钩子被触发时,向拦截器添加调用将被触发。您可以访问hooks参数
tap (tap: Tap) 将tap添加到拦截器将在插件点击钩子时触发。提供的是Tap对象。无法更改Tap对象
loop (...args) => void 向拦截器添加循环将触发循环钩子的每个循环。
register (tap: Tap) => Tap 或 undefined 将注册添加到拦截器将触发每个添加的Tap,并允许对其进行修改。

context

插件和拦截器可以选择访问可选的上下文对象,该对象可用于向后续插件和拦截器传递任意值。

我们通过下面的小案例,来帮助我们理解。示例代码如下:

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
js复制代码const { SyncHook } = require("tapable");

// 实例化 钩子函数 定义形参
const syncHook = new SyncHook(["name"]);

syncHook.intercept({
context: true,
register(context, name) {
console.log("every time tap", context, name);
},
call(context, name) {
console.log("before call", context, name);
},
loop(context, name) {
console.log("before loop", context, name);
},
tap(context, name) {
console.log("before tap", context, name);
},
});

//通过tap函数注册事件
syncHook.tap("同步钩子1", (name) => {
console.log("同步钩子1", name);
});

//通过tap函数注册事件
syncHook.tap("同步钩子2", (name) => {
console.log("同步钩子2", name);
});

//同步钩子 通过call 发布事件
syncHook.call("古茗前端");
syncHook.call("古茗前端 call2");

控制台输入结果如图所示:

image.png

由上面的案例结果,我们可以知道。intercept 中的 register 会在每一次的 tap 触发。 有几个 tap 就会触发几次 register。然后依次执行钩子里面的 call、tap.

intercept 特性在 webpack 内主要被用作进度提示,如 webpack/lib/ProgressPlugin 插件中,分别对 compiler.hooks.emit 、compiler.hooks.afterEmit 钩子应用了记录进度的中间件函数。

HookMap

HookMap HookMap是具有Hooks的Map的辅助类.提供了一种集合操作能力,能够降低创建与使用的复杂度,用法比较简单:

1
2
3
4
5
6
7
8
9
10
js复制代码const { SyncHook, HookMap } = require("tapable"); 
const syncMap = new HookMap(() => new SyncHook());

// 通过 for 函数过滤集合中的特定钩子
syncMap.for("some-key").tap("MyPlugin", (arg) => { /* ... */ });
syncMap.for("some-key").tapAsync("MyPlugin", (arg, callback) => { /* ... */ });
syncMap.for("some-key").tapPromise("MyPlugin", (arg) => { /* ... */ });

// 触发 guming-test 类型的钩子
syncMap.get("guming-test").call();

Tapable 底层原理

我们先将 Tapable 工程源码克隆到本地, 执行如下指令:

1
shell复制代码$ git clone https://github.com/webpack/tapable.git

Tapable 源码的 lib 目录结构, 如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
vbnet复制代码lib
├─ AsyncParallelBailHook.js
├─ AsyncParallelHook.js
├─ AsyncSeriesBailHook.js
├─ AsyncSeriesHook.js
├─ AsyncSeriesLoopHook.js
├─ AsyncSeriesWaterfallHook.js
├─ Hook.js
├─ HookCodeFactory.js
├─ HookMap.js
├─ MultiHook.js
├─ SyncBailHook.js
├─ SyncHook.js
├─ SyncLoopHook.js
├─ SyncWaterfallHook.js
├─ index.js
└─ util-browser.js

除了上面我们所提及的基本 hooks 函数、HookMap高级特性,还会有一些 HookCodeFactory、Hook 这些文件。我们简单过一下 hooks 函数内的内容,
会发现所有的 hooks 函数都会引用 HookCodeFactory 和 Hook 这两个文件所导出的对象实例。

我们以 syncHook 钩子函数为例, 如下图所示:

image.png

我们大致能够知道 一个 hooks 函数 会由一个 CodeFactory 代码工厂 以及 Hook 实例组成。 Hook 实例会针对不同场景的 hooks 函数, 更改其对应的 注册钩子(tapAsync ,tap, tapPromise ),事件触发钩子( call , callAsync ), 编译函数(complier)。 Complier 函数会由我们 HookCodeFactory 实现。

接下来,我们将通过分析 HookCodeFactory 及 Hook 的内部实现来了解 Tapable 的内部实现机制。

Hook 实例

Hook 实例会生成我们 hooks 钩子函数通用的 事件注册,事件触发。核心逻辑,我们大致可以分为以下三个部分:

  1. 实例初始化构造函数
  2. 事件注册 的实现
  3. 事件触发的实现

构造函数

构造函数会对实例属性初始化赋值。代码如下图所示:

image.png

注册事件

注册事件主要分为两块,一块是 适配器注册调用, 第二块是 触发事件注册。核心逻辑在 _tap 函数内部实现,代码如下图所示:

image.png

适配器调用

在这里会对携带 register 函数的适配器进行调用,更改 options 配置,返回新的 options 配置。代码如下图所示:

image.png

触发事件注册

Hook 实例的 taps 会存储我们的注册事件, 同时会根据,注册事件配置的执行顺序去存储对应的注册事件。

image.png

触发事件

触发事件会通过调用内部 的 _createCall 函数,函数内部会调用实例的 compile 函数。我们会发现:Hook 实例内部不会去实现 complier的逻辑, 不同钩子的 complier 函数会通过通过对应的 继承 HookCodeFactory 的实例去实现。代码如下图所示:

image.png

image.png

接下来,我们继续 探究 HookCodeFactory 实例,了解 Tapable 事件触发的逻辑。

HookCodeFactory 实例

HookCodeFactory 实例会根据我们传入的事件触发类型 (sync, async, promise)以及我们的触发机制类型 (常规 瀑布模式 保险模式 循环模式), 生成事件触发函数的函数头, 函数体。通过 new Function 构造出事件触发函数。

Tapable 事件触发的执行,是动态生成执行代码, 包含我们的参数,函数头,函数体,然后通过 new Function 来执行。相较于我们通常的遍历/递归调用事件,这无疑让 webpack 的整个事件机制的执行有了一个更高的性能优势。

由上面我们可知, Hook 实例 的 complier 函数是 HookCodeFactory 实例 create 函数 的返回。

接下来,我们就从 create 函数 一步步揭秘 Tapable 的动态生成执行函数的核心实现。

create

create 函数通过对应的 函数参数, 函数 header, 函数 content方法构造出我们事件触发的函数的内容, 通过 new Function 创建出我们的触发函数。

create 函数会根据事件的触发类型 ( sync、async、promise),进行不同的逻辑处理。代码如下图所示:
image.png

每一种触发机制,都会由 this.args, this.header, this.contentWithInterceptors 三个函数去实现 动态函数的 code。代码如下图所示:

image.png

接下来我们看一看 this.contentWithInterceptors 函数如何生成我们事件触发函数的函数体。

contentWithInterceptors

contentWithInterceptors 函数里包含两个模块, 一个是适配器 (interceptor), 一个 content 生成函数。 同时,HookCodeFactory 实例本身不会去实现 content 函数的逻辑,会由继承的实例去实现。整体结构代码如下图所示:

image.png

每个 hooks 钩子函数的 CodeFactory 实例会去实现 content 函数。 content 函数会调用 HookCodeFactory 实现的不同运行机制的方法( callTap、callTapsSeries、callTapsLooping、callTapsParallel), 构造出最终的函数体。实现代码如下图所示:

image.png

接下来,就是不同运行机制,根据不同的调用方式 ( sync, async, promise ) 生成对应的执行代码。

动态函数

我们通过下面一个简单案例,看 New Function 输入的内容是什么?

实例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
js复制代码
const { SyncHook, AsyncSeriesHook } = require("tapable");

// 实例化 钩子函数 定义形参
const syncHook = new SyncHook(["name"]);
const asyncSeriesHook = new AsyncSeriesHook(["name"])

//通过tap函数注册事件
syncHook.tap("同步钩子1", (name) => {
console.log("同步钩子1", name);
});

//通过tap函数注册事件
asyncSeriesHook.tapAsync("同步钩子1", (name) => {
console.log("同步钩子1", name);
});

//同步钩子 通过call 发布事件
syncHook.call("古茗前端sync");
asyncSeriesHook.callAsync("古茗前端async");
asyncSeriesHook.promise("古茗前端promise")

在 HookCodeFactory 的 create 打印 fn, 实例代码如图所示:

image.png

sync 同步调用的输出结果如下:

1
2
3
4
5
6
7
8
9
js复制代码 function anonymous(name
) {
"use strict";
var _context;
var _x = this._x;
var _fn0 = _x[0];
_fn0(name);

}

async 异步调用的输出结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
js复制代码function anonymous(name, _callback
) {
"use strict";
var _context;
var _x = this._x;
var _fn0 = _x[0];
_fn0(name, (function(_err0) {
if(_err0) {
_callback(_err0);
} else {
_callback();
}
}));

}

promise 调用的输出结果如下

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
js复制代码 function anonymous(name
) {
"use strict";
var _context;
var _x = this._x;
return new Promise((function(_resolve, _reject) {
var _sync = true;
function _error(_err) {
if(_sync)
_resolve(Promise.resolve().then((function() { throw _err; })));
else
_reject(_err);
};
var _fn0 = _x[0];
_fn0(name, (function(_err0) {
if(_err0) {
_error(_err0);
} else {
_resolve();
}
}));
_sync = false;
}));

}

三种不同方式调用,内部代码实现差异还是比较清晰的。async 调用相较于 sync 多了回调函数。 async 和 promise 的区别再去返回 promise 还是回调函数。

最后,我们来用一些结构图来总结一下 Tapable 中事件触发的逻辑。

HookCodeFactory 会根据我们触发的方式,生成我们对应 new Function 里面的 content, args, header。

image.png

content 最终会由 callTapsSeries、callTapsLooping、callTapsParallel 生成。每种生成方式都会包含 Done 处理、Error 处理以及 Result 处理。

image.png

总结

其他机制的 Hooks 钩子实现原理大致是相同的, 这里就不一一赘述了。Tapable 是一个非常优秀的库,灵活扩展性高,许多优秀的开源项目的插件化设计都借鉴或采纳了 Tapable 的设计思想,是一个非常值得推荐学习的一个开源工具库。

最后

📚 小茗文章推荐:

  • 古茗打印机技术的演进
  • 5分钟带你了解,古茗的代码发布与回滚
  • 古茗前端第二届论坛 —— Typescript篇

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

本文转载自: 掘金

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

深入认识js四大常用数据类型判断,一次性带你搞明白(内含面试

发表于 2023-11-12

js中大体上有四种方法可以判断数据类型,分别为typeof、instanceof、Array.isArray()和Object.prototype.toString.call()

在进行判断数据类型前,我们需要复习下数据有哪些类型。

js数据类型可以分为两大类,原始数据类型和引用数据类型

  • 原始数据类型:数字,字符串,布尔,undefined,null,BigInt,Symbol
  • 引用数据类型:对象,数组,函数,日期等

一、typeof

  1. 可以准确判断除null之外的原始数据类型
  2. 可以判断function

es6之后新增的两个原始数据类型bigint和symbol对于typeof来说同样可以识别。这里也顺便介绍下这两种类型

bigint

俗称大整型。js有个最大安全值为2^53,大整型就可以突破这个安全值。当然在js中书写次方是两个乘号,也就是 2 ** 53 再大就需要用上bigint了,我们只需要在数字后面加个n即可

1
ini复制代码let big = 223n

symbol

symbol用于定义独一无二的值,通常不参与逻辑运算中

1
2
3
ini复制代码let s1 = Symbol('hello')
let s2 = Symbol('hello')
console.log(s1 === s2) // false

当我们怕自己框架的数据取名跟别人源码中数据取名相同的时候我们就可以用symbol来定义

有小伙伴留言问到== 和 === 的区别,这里我也顺便带过一下

== 和 ===

== 是用来判断值是否相等,===是用来判断值和数据类型是否相同

引用数据类型中===还需要比上一个指针,也就是地址

1
2
ini复制代码console.log(1 == '1') // true 这里是因为数据类型转换,比较值时字符串会被转为数字
console.log(1 === '1') // false

typeof判断逻辑

这里把我之前原型那期挂下面,文章最后介绍了typeof的判断逻辑

面试官真烦,问我这么简单的js原型问题(小心有坑) - 掘金 (juejin.cn)

typeof的底层逻辑就是将数据类型都转换成二进制,引用类型前三位都是0,当初设计二进制时,设计师忘记了null这个类型,全都是0,因此,null也被误读成了对象。

当然这有个特例,typeof虽说不能识别出具体引用类型,但是它可以识别出函数

1
javascript复制代码console.log(typeof Function()) // function

二、instanceof

  1. 只能判断引用数据类型
  2. 通过原型链查找
1
2
3
4
5
6
7
8
9
10
javascript复制代码// 定义四种引用类型
let obj = {}
let arr = []
let fn = function(){}
let date = new Date()
// 进行判断
console.log(obj instanceof Object); // true
console.log(arr instanceof Array); // true
console.log(fn instanceof Function); // true
console.log(date instanceof Date); // true

这么看instanceof好像确实可以判断引用类型,但是请看下面,我试试看arr和fn是不是对象

1
2
javascript复制代码console.log(arr instanceof Object); // true
console.log(fn instanceof Object); // true

额……好像也没毛病,数组和函数确实都是一种特殊的对象,但是instanceof是怎么知道的呢?其实这里就是因为instanceof判断是通过原型链的,接下来讲的内容建议大家先看下上面放出的原型文章。

arr是个空数组,其实就相当于new Array(),arr所以是一个实例化对象。a instanceof b在判断的时候,会先判断a的隐式原型是否等于b的显示原型,如果不等于a就会继续沿着原型链深入。拿arr为例,arr的隐式原型就是构造函数Array的显示原型,然后构造函数Array也是个对象,他被Object实例出来的,所以构造函数Array的隐式原型就是Ojbect的显示原型,匹配成功返回true

所以arr instanceof Object的查找是下面这样

1
2
javascript复制代码arr.__proto__ 是否为 Object.prototype // 否
arr.__proto__.__proto__是否为Object.prototype // 是 返回true

a instanceof b的a最终会沿着__proto__找到object的null值(object.__proto__ == null)

因此我们可以自己模范一个instanceof的函数,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
scss复制代码function instanceOF(L, R){
// 判断左边的隐式原型是否为右边的显示原型
// 如果不是,判断左边的隐式原型的隐式原始是否为右边的显示原型
// 如此循环下去,如果左边到null了,就返回false
let left = L.__proto__
let right = R.prototype
while(left !== null){
if(left == right) return true
left = left.__proto__
}
return false
}
instanceOF([],Array) // true
instanceOF([],Object) // true
instanceOF(Function(),Object) // true

无限地判断左边的隐式原型是否等于右边的显示原型,有些人可能觉得不用循环都可以,因为一个实例对象好像也就是a.__proto__.__proto__.__proto__三层就到底了,用循环是因为你还要考虑到a可能自己也创造了隐式原型

考点

instanceof也很容易被面试官问到,他会问让你手动做一个instanceof的函数,就是上面的,也有可能让你口头说一下,如下

a instanceof b就是a通过循环不断找a的隐式原型是否为b的显示原型,找到了就会返回true,找不到最终到一个null,返回false

三、Array.isArray()

  1. 只能判断是否为数组

这个方法不是Array原型身上的,是Array自带的方法,如果是原型身上的,中间会有个prototype,因此这个方法不能被实例对象去调用,必须是Array

当你用instanceof的时候,数组也可以被认为是对象,这个方法就可以专门用来判断是否为数组

1
2
javascript复制代码Array.isArray([]) // true
Array.isArray({}) // false

四、Object.prototype.toStrring.call()

这是js中最完美的判断数据类型的方法,但是想要理解它的原理可不简单

下面是官方文档给出的介绍

如果你能读明白js官方文档的所有内容,那你一定可以在简历上面自信的写出:精通js

官方文档地址es5.github.io/#x15.2.4.2

1.png

当toString这个方法被调用时,将会采取下面的步骤

  1. 如果this的值是undefined,那么返回”[object Undefined]”
  2. 如果this的值是null,那么返回”[object Null]”
  3. 将O作为ToObject的执行结果
  4. 让class成为O内部属性[[Class]]的值
  5. 返回由 “[object “和 class 以及 “]” 三部分组成的字符串,class就是执行出来的类型

这里读不懂没关系,我们下面先介绍下用法

将想要读取的东西放进call中即可,既然是最好用的判断方法,我们就来看下效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
javascript复制代码// 7种原始数据类型都能识别
Object.prototype.toString.call(123) // '[object Number]'
Object.prototype.toString.call('Hello') // '[object String]'
Object.prototype.toString.call(true) // '[object Boolean]'
Object.prototype.toString.call(undefined) // '[object Undefined]'
Object.prototype.toString.call(null) // '[object Null]'
Object.prototype.toString.call(123n) // '[object BigInt]'
Object.prototype.toString.call(Symbol('hello')) // '[object Symbol]'

// 四种引用类型也都可以识别
Object.prototype.toString.call({}) // '[object Object]'
Object.prototype.toString.call([]) // '[object Array]'
Object.prototype.toString.call(function(){}) // '[object Function]'
Object.prototype.toString.call(new Date()) // '[object Date]'

看完我是直呼牛逼!这东西也太强大了,前面的判断方法都有缺陷,这玩意儿全给你识别出来,甚至null这个bug都行!

既然如此,我们如何去使用它呢?

判断

可以看出,它返回一个字符串类型,我们只需要把返回结果的第二个单词提取出来进行判断即可。

我们把第二个单词截取出来,使用slice(start,end)方法即可截取,(这个方法同样适用于字符串)第一个参数start是起始下标,截取出来的单词的起始下标都是8,固定不变,第二个参数end如何设定呢,我们需要的截取的单词长度不一,但是有个规律,里面的单词的最后一个字母都是倒数第二个字符,因此我们填入-1即可,为何不是-2,因为这个方法的参数是左闭右开,-2的话就截取到-3那个下标去了

1
2
3
4
5
6
7
8
9
javascript复制代码let s = 'hello'

function isType(s){
return Object.prototype.toString.call(s).slice(8,-1)
}

if(isType(s) === 'String'){
return true
}

这个方法好是好,但是就是判断起来会比较麻烦(悲

不知道大家有没有发现,官方给的方法是Object.prototype.toString()而不是Object.prototype.toString.call(),这是为什么?

关于Object.prototype.toString()这个东西,其实也很好理解,就是object这个万物之源有个原型,object原型中有个toString方法,如果我们不用call行得通吗

1
javascript复制代码Object.prototype.toString(123) // '[object Object]'

行不通,123这个东西调用不了左边那部分,不管你输入什么类型进去都是这个输出,你就是需要call这个东西来让123调用toString 方法

那我们看看下面有什么区别

1
2
scss复制代码(123).toString() // '123'
Object.prototype.toString.call(123) // '[object Number]'

既然如此,上面两个不应该是一种意思吗,123通过call可以调用左边的toString,为何这里输出一个值的类型,而不会输出'123'?这里你也无需问为什么了,官方已经给你定死了。(123).toString()其实是有两个步骤,先进行判断数据类型,然后将其转换输出,而Object.prototype.toString.call(123)只有第一步,判断数据类型

我们现在知道了,Object.prototype.toString()这个方法需要通过call来调用才行得通,我的文章js中this究竟指向哪里?现在终于搞定了! - 掘金 (juejin.cn)中也讲过call的作用。比如下面的foo.call(obj)就是把foo的this指向了obj这个对象

1
2
3
4
5
6
7
8
9
10
javascript复制代码function foo(){
console.log(this.a)
}

var obj = {
a: 2
}

foo.call(obj) // 2
// foo中this本应指向全局(因为默认绑定或者说foo定义在全局中,它的词法作用域为全局),但是被call给改到obj中去了

call源码

call的用法

1
2
3
4
5
6
7
8
9
javascript复制代码var obj = {
a: 1
}

function foo(){
console.log(this.a);
}

foo.call(obj) // 1

我们不妨再看下隐式绑定

1
2
3
4
5
6
7
8
9
10
javascript复制代码var obj = {
a: 1,
fn: foo
}

function foo(){
console.log(this.a);
}

obj.fn() // 1

这里你做一个比较,肯定会猜到call就是利用了隐式绑定的规则,它将foo这个函数体挂在了obj中,成为了一个属性,然后再利用obj这个对象去调用它,使其成为一个隐式绑定,导致this指向改变

有了这一想法,我们其实就可以自己做一个call的源码,实际上call可以接收多个参数,我们也要考虑进去

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
javascript复制代码var obj = {
a: 1
}

function foo(a,b){
console.log(this.a,a+b);
}
// call是所有函数原型自带的方法,因此我们这里也需要这样去创建
Function.prototype.myCall = function(context){
// call左边必须是函数,必须是函数去调用它,不是就抛出一个错误
if(typeof this !== 'function'){
throw new TypeError('myCall is not a function')
}
// Array.from类数组转成数组,类数组没有数组的slice方法,因此要转一下。args就是接收的形参,因为第一个参数是对象,把他切割掉,其余就是函授接收的形参,并且不影响原数组
let args = Array.from(arguments).slice(1)
// let args = [...arguments].slice(1)
// 这里的this就是传进来的函数,往对象属性中挂函数声明,隐式绑定!
context.fn = this
// 调用函数,这些参数必须还给foo函数本身,当然接受的形参必须解构掉,不能是个数组
let res = context.fn(...args)
// 触发隐式绑定后删掉,否则对象多了个函数key啊
delete context.fn
// 防止foo也会有个返回值,这样写foo没有return也没关系
return res
}
foo.myCall(obj,1,2) // 1 3
console.log(obj) // { a: 1 }

这里的解释我基本上都放在代码的注释中去了,一定要看仔细了

arguments

foo中函数的形参你无法知道有几个参数,因此我们用上arguments这个关键字,它是函数中形参的统称,括号中的形参都可以不用写了

1
2
3
4
scss复制代码function foo(){
console.log(arguments)
}
foo(1,2,3) // Arguments(3) [1, 2, 3]

arguments是个类数组,后面我们再来详细讲解下类数组,类数组只有数组的下标,和长度,没有数组的那些方法,你可以理解为是一个阉割版的数组,其本质上还是个对象。所有的函数都有这个关键字,arguments[0]是第一个形参

Array.from

将类数组转换成数组,否则类数组用不了slice方法,类数组也可以被解构成数组,所以上面的代码let args = Array.from(arguments).slice(1)可以写成let args = [...arguments].slice(1)

数组的解构

这是es6新增的写法,后面文章我还会提到这个

1
2
javascript复制代码let arr = [1, 2, 3]
console.log(...arr) // 1 2 3

解构也很好理解,直接把数组的值都解剖出来

考点

面试官可能会问你Object.prototype.toString.call()为什么可以准确判断数据类型,或者说问你这个方法有了call为何就不一样,你只需要把call的源码逻辑告诉他就可以,其实就是一个隐式绑定。

比如Object.prototype.toString.call(num)就是call把左边的toString搬到了右边num里面,然后让num去执行toString方法,当然这里是识别出数据类型,toString本身是不接受参数的,call把toString搬到了num身上并且去调用而已

总结

typeof是用来判断原始数据类型的(除null这个bug外),引用类型只能判断一个函数。instanceof只能判断引用类型,但是判断数组,函数不准,因为他是通过原型链来判断的。Array.isArray()是Array自带的方法,只能判断数组。Object.prototype.toString.call(***)可以判断所有的数据类型,但是判断起来会麻烦一点,并且call的作用就是把toString方法搬到了***中,让***去调用toString方法


如果觉得本文对你有帮助的话,可以给个免费的赞吗[doge] 还有可以给我的gitee链接codeSpace: 记录coding中的点点滴滴 (gitee.com)点一个免费的star吗[星星眼]

本文转载自: 掘金

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

深入了解异地多活

发表于 2023-11-10

前言

故障不可避免,可能来自硬件故障、软件问题,甚至是不可抗力的因素。因此,如何快速而有效地恢复系统运行,成为衡量可用性的关键因素。在这个背景下,异地多活应运而生,为解决系统故障时的快速恢复提供了高效的解决方案。

本文将深入探讨异地多活的概念、原理和实现方式,为读者提供全面的技术视角。通过深入了解异地多活,我们能够更好地应对系统面临的各种挑战,确保系统在面对硬件故障、软件问题或不可抗力时能够以最快的速度恢复,保障用户体验和业务的连续性。

让我们一同探索异地多活的奥秘,为构建高性能、高可用、易扩展的软件系统提供有力支持。

系统性能三指标

要想理解异地多活,我们需要从架构设计的原则说起

现如今,我们开发一个软件系统,对其要求越来越高,一个好的软件架构应该遵循以下3个原则:

  1. 高性能
  2. 高可用
  3. 易扩展
  • 高性能:系统拥有更大流量的处理能力,同时接口返回的速度应该尽可能的快。
  • 易扩展:系统在迭代新功能的时候,能够以最小的代价去扩展,系统遇到流量压力的时候,可以在不改动代码的前提下去扩容系统。
  • 高可用:通常由两个指标来衡量,分别是平均故障时间和故障恢复时间

不同的软件,不同阶段的公司,产品开发的不同阶段,对这些指标的要求是不一样的:

比如一个初创公司,这个时候用户、流量最重要,开发功能,让页面尽可能美观,做产品比其他指标更重要;

当流量上来了之后,怎么尽可能缩短响应时间,让系统能处理的QPS更大,就成了至关重要的问题;

当产品使用的人数足够多,影响力足够大,可用性的重要就凸现出来了,怎么保证系统尽可能稳定,不出问题,对于公司来说才是最重要的。

系统发生故障是不可避免的,尤其是规模越大的系统,互相之间的调用也更加复杂,对于硬件的要求也越高,从理论上来说发生问题的概率也越大。这些故障一般提现在3个方面:

  1. 硬件故障:CPU 内存 磁盘 网卡 交换机 路由器
  2. 软件问题:代码BUG 版本迭代 线上故障等等
  3. 不可抗力:地震 水灾 火灾 战争

image.png
我们通常用N个9来表示系统的可用性高不高,从数据上能看出来,越到后面系统可用性带来的平均收益是越小的,但是难度是指数级别上升的。我们平常写的小玩具和企业级的应用差别也无非是这几个方面:高并发、高性能、高可用。

这些风险随时都有可能发生,所以在面对故障的时候,系统能否以最快的速度恢复,就成了可用性的关键。

如何做到快速恢复呢?

异地多活就是为了解决这个问题,而提出的高效解决方案.

单机房

单机

image.png
客户端请求先进来,业务应用读写数据库,之后返回结果。

这里的数据库是单机部署的,所以有一个致命的缺点:一旦遭遇意外,例如磁盘损坏、操作系统异常、误删数据,这意味着所有数据就全部【丢失】了,这个损失是巨大的。

备份

我们可以对数据做备份,将数据库文件【定期】copy到另一台机器上,这样即使原机器丢失数据,依旧可以通过备份把数据【恢复】回来,以此保证数据安全。

这个方案实施起来虽然比较简单,但存在两个问题

  1. 恢复需要时间:业务需要先停机,在恢复数据,这段时间服务是不可用的,对于一个系统来说这显然不能忍受
  2. 数据不完整:因为是定期备份,数据肯定不是最新的,会有丢数据的风险。

这里补充一句,其实现代系统在分布式的场景下,想要做到数据实时强一致性,几乎是不可能的,做的比较好的可能数据同步的时间是秒级,弱一点的则是分钟级,在实际中我们追求的是数据的最终一致性,即数据不出错。

主从

image.png
可以在另一台机器上,再部署一个数据库实例,成为这个原实例的副本,让两者保持【实时同步】,这里的实时同步要打上引号,因为两台机器有网络隔离,永远不可能真正的实时同步,比如当主库写一条数据,还没同步的时候就宕机了,这个时候从库就会有丢数据的可能。
我们一般把原实例称为主库(master),新实例称作从库(slave),这个方案的优点在于

  • 数据完整性高:主从副本实时同步,数据差异很小
  • 抗故障能力提升:主库有任何异常,从库可以随时切换为主库,继续提供服务
  • 读性能提示:从库可以直接用来读

主从+多机器

同样的,业务应用也也可以在其他机器部署一份,避免单点。因为业务应用通常是【无状态】的,这里的无状态很好理解,业务代码的逻辑部分(除去数据库的部分),在什么机器上都能跑,且不会对机器造成持久化的影响,不像数据库一样存储数据,所以直接部署即可,非常简单。
image.png
因为业务应用部署了多个,所以现在还需要一个接入层,来做请求的负载均衡,一般是用nginx或者是lvs,这样当一台机器宕机之后,另一台机器也可以【接管】所有流量,持续提供服务。
image.png

提高可用性的核心思想

从这些方案可以看出,提升可用性的关键思路就是:冗余

担心一个实例故障,那就部署多个实例;

担心一个机器宕机,那就部署多台机器;

担心一个数据库可能会崩然后丢数据,那就多整几个数据库;

这种冗余的思想放在机房层面,就产生了同城灾备、同城双活等方案,放在城市层面,就产生了两地三中心、异地双活、异地多活等方案.

以上说的方案还是有缺点的,因为应用虽然部署了多台机器,但是这些机器的分布情况,我们并没有去深究。
而一个机房有很多服务器,这些服务器通常会分布在一个个【机柜】上,如果使用的机器刚好在一个机柜,还是存在风险。

如果恰好链接这个机柜的交换机/路由器发生故障,那么你的应用依旧有【不可用】的风险

哪怕是在不同机柜上,依旧会有风险,因为它们始终还是属于一个机房。

机房的故障率从现实角度来分析其实真的很低,建设一个机房的要求是很高的,地理位置、温湿度控制、备用电源等等。机房厂商会在各方面做好防护,但即使这样,还是有以下事故

  • 15年支付宝因为光纤被挖断,5小时无法访问支付宝
  • 21年b站服务器着火,3小时无法访问
  • 21年富途证券服务器断电,2小时无法访问

可见,哪怕机房级别的防护已经做的足够好,但只要有概率出现问题,那现实情况就有可能发生。虽然概率很小,但一旦发生,就会造成重大损失。

像前文所说的一样,不同体量的系统,关注的重点是不一样的。小系统关注的重点是用户,这个阶段用户的规模、增长就是一切。在用户体量上来之后,会重点关注性能,优化接口响应时间,接口打开速度等等。这个阶段更多的是关注用户体验,而体量再大下去,可用性就会变得尤为重要。像微信、支付宝这种全民级别的应用,如果机房发生一次故障,那么影响和损失都是巨大的。

我们该如何应对机房级别的故障呢?没错,还是冗余。

多机房

同城灾备

简单起见,可以在同一个城市再搭建一个机房,原机房为A,新机房为B,这两个机房的网络用一条【专线】连通。
image.png
为了避免A机房故障导致数据丢失,所以我们需要把数据在B机房也做【定时备份】。这种方案,我们成为【冷备】。因为B机房只做备份,不提供服务,只有在A机房故障的时候才会弃用。

或者可以把AB之间的关系换成主从的关系,这样不仅能提高系统吞吐量,也能够更加保证数据的完整性
image.png
在这个方案的设想中,如果A机房真挂掉了,要想保证服务不中断,还需要做这些事情

  1. B机房所有从库升级成主库
  2. 在B机房部署应用,启动服务
  3. 部署接入层,配置转发规则
  4. DNS指向B机房接入层,接入流量,业务恢复

整个过程的每一步需要人为介入,且需要花费大量时间,回复之前整个服务还是不可用的,如果想要做到故障之后立即【切换】,就需要考虑下面这种架构
image.png

这样的话,A机房整个挂掉,我们只需要做两件事

  1. B机房所有从库提升为主库
  2. DNS指向B机房接入层,接入流量,业务回复

这种方案我们叫【热备】,热备相比于冷备的最大优点是随时可切换,不同点有需要多加一层应用层和接入层,同时数据库层面的定时备份变成了实时备份,这些都是需要额外开销的。我们把这两个方案统称为:同城灾备。

同城灾备的最大优势在于,我们不用担心【机房】级别的故障了,一个机房发生风险,我们只需要把流量切换到另一个机房,当然这不一定会没有问题,比如冷备的问题是之前的备用系统没有经过流量的测试,不一定能扛得住;热备也是,瘫了一个主系统,那么备用系统的压力范围,也不一定能抗住。

同城双活

虽然有了应对机房故障的解决方案,但是有个问题是不能忽略的:A机房挂掉,全部流量切到B机房,B机房是否真的能如我们所愿,正常提供服务?

另外从成本的角度上看,我们新部署一个机房,需要购买很多硬件资源,花费成本也是非常高昂的,如果只是放在那里不去使用,是很浪费资源的一种表现。

因此我们需要让B机房也接入流量,实时提供服务

只需要把B机房的接入层IP地址,加入到dns服务中,这样B机房从上层就可以有流量进来了
image.png

这里有一个新问题:B机房的存储都是从库,而从库默认都是不可写的,也就是说B机房是处理不了写请求的。这个问题就应该在业务应用层解决,需要区分读写分离,一般是通过中间件实现,写流量给A机房,读流量可以给两个机房。

这种架构有什么问题呢?

两地三中心

因为把两个机房当成一个整体来规划,如果是一个城市的话,当整个城市发生自然灾害的时候,例如地震、水灾,那么依旧可能有【全局覆没】的风险

这个时候就可以将备份机房放在另一个城市

两地三中心就是指两个城市,三个机房,其中2个机房在同一个城市,并且同时提供服务,第三个机房部署在异地,制作数据灾备。

这种架构方案,通常用在银行、金融、政企相关的项目中,问题还是启用后的服务,不确定能否如期工作。
所以想要真正抵御城市级别的故障,越来越多的互联网公司,开始实施【异地双活】。

异地双活

主要问题是跨机房的延迟调用,当B地的应用去跨区域读写A地的存储,网络延迟就会让整个请求变得非常慢。而要解决这个问题,就必须在存储层做改造了。

B机房的存储不再是从库,而也要变为主库,同时两个机房的数据还要【互相同步】,无论客户端写哪一个机房,都要把数据同步到另一个机房。因为只有两个机房都拥有全量数据,才能支持任意切换机房,持续提供服务。MySQL本身是提供了双主架构的,支持双向数据复制,但平时用的不多。而且Redis、mongoDB等数据库是没有这个功能的,所以必须开发对应的【数据同步中间件】来实现双向同步的功能。

除了数据库这种有状态的软件之外,通常还会用到消息队列,例如rabbitMQ,kafka等,这些也是有状态的服务,所以它们也需要开发双向同步的中间件,支持任意机房写入数据,同步至另一个机房.

业界开源出了很多数据同步中间件,例如阿里的canal、redisshake、mongoshake,可分别在两个机房同步MySQL、REDIS、MONGODB数据.

这样的话有一个新的问题,两个机房都可以写,如果操作的是同一条数据,就很容易发生竞态的问题
分别有两个方案

  1. 消息同步中间件要有自动解决数据的能力,区分出操作的先后顺序
  2. 从源头避免数据冲突的发生

一般都是采用第二种方案:在最上层接入流量的时候,就不要让冲突的情况发生。
具体来讲就是将用户区分开,部分用户请求固定达到北京机房,其他用户请求固定打到上海机房。进入某个机房的用户请求,之后的所有业务操作,都在这一个机房内完成,从根源上避免【跨机房】。
这时候需要在接入层之上,再部署一个路由层,自己配置路由规则,把用户分流到不同的机房内。
一般来说有三种方式

  • 按业务类型分片,比如某个子域的请求固定全打在某个机房
  • 直接哈希分片,先对请求进行哈希,再对机房的数量进行取模,这样可以保证流量均匀分布到某个机房,但是对于某些请求来说可能速度会慢,比如一个新疆的请求,打到了广州机房,网络延迟就会比打在西安机房大
  • 按地理位置分片,请求只会打在距离自己最近的机房,处理请求的速度快,但是流量不均匀

异地多活

把异地双活的思想推到多个城市,部署多个机房。

结语

本文主要由浅入深,带大家了解服务可用性提升的必要性,具体实现思路,以及理解【冗余】的思想,从点到面深入了解异地多活思想及实现。

本文转载自: 掘金

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

🔥🔥探索人工智能的世界:构建智能问答系统之实战篇 引言 环境

发表于 2023-11-09

引言

前面我们已经做好了必要的准备工作,包括对相关知识点的了解以及环境的安装。今天我们将重点关注代码方面的内容。如果你已经具备了Java编程基础,那么理解Python语法应该不会成为问题,毕竟只是语法的差异而已。随着时间的推移,你自然会逐渐熟悉和掌握这门语言。现在让我们开始吧!

环境安装命令

在使用之前,我们需要先进行一些必要的准备工作,其中包括执行一些命令。如果你已经仔细阅读了Milvus的官方文档,你应该已经了解到了这一点。下面是需要执行的一些命令示例:

1
2
3
4
5
6
7
8
9
10
11
ini复制代码pip3 install langchain

pip3 install openai

pip3 install protobuf==3.20.0

pip3 install grpcio-tools

python3 -m pip install pymilvus==2.3.2

python3 -c "from pymilvus import Collection"

快速入门

现在,我们来尝试使用官方示例,看看在没有集成LangChain的情况下,我们需要编写多少代码才能完成插入、查询等操作。官方示例已经在前面的注释中详细讲解了所有的流程。总体流程如下:

  1. 连接到数据库
  2. 创建集合(这里还有分区的概念,我们不深入讨论)
  3. 插入向量数据(我看官方文档就简单插入了一些数字…)
  4. 创建索引(根据官方文档的说法,通常在一定数据量下是不会经常创建索引的)
  5. 查询数据
  6. 删除数据
  7. 断开与数据库的连接

通过以上步骤,你会发现与连接MySQL数据库的操作非常相似。

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
python复制代码# hello_milvus.py demonstrates the basic operations of PyMilvus, a Python SDK of Milvus.
# 1. connect to Milvus
# 2. create collection
# 3. insert data
# 4. create index
# 5. search, query, and hybrid search on entities
# 6. delete entities by PK
# 7. drop collection
import time

import numpy as np
from pymilvus import (
connections,
utility,
FieldSchema, CollectionSchema, DataType,
Collection,
)

fmt = "\n=== {:30} ===\n"
search_latency_fmt = "search latency = {:.4f}s"
num_entities, dim = 3000, 8

#################################################################################
# 1. connect to Milvus
# Add a new connection alias `default` for Milvus server in `localhost:19530`
# Actually the "default" alias is a buildin in PyMilvus.
# If the address of Milvus is the same as `localhost:19530`, you can omit all
# parameters and call the method as: `connections.connect()`.
#
# Note: the `using` parameter of the following methods is default to "default".
print(fmt.format("start connecting to Milvus"))
connections.connect("default", host="localhost", port="19530")

has = utility.has_collection("hello_milvus")
print(f"Does collection hello_milvus exist in Milvus: {has}")

#################################################################################
# 2. create collection
# We're going to create a collection with 3 fields.
# +-+------------+------------+------------------+------------------------------+
# | | field name | field type | other attributes | field description |
# +-+------------+------------+------------------+------------------------------+
# |1| "pk" | VarChar | is_primary=True | "primary field" |
# | | | | auto_id=False | |
# +-+------------+------------+------------------+------------------------------+
# |2| "random" | Double | | "a double field" |
# +-+------------+------------+------------------+------------------------------+
# |3|"embeddings"| FloatVector| dim=8 | "float vector with dim 8" |
# +-+------------+------------+------------------+------------------------------+
fields = [
FieldSchema(name="pk", dtype=DataType.VARCHAR, is_primary=True, auto_id=False, max_length=100),
FieldSchema(name="random", dtype=DataType.DOUBLE),
FieldSchema(name="embeddings", dtype=DataType.FLOAT_VECTOR, dim=dim)
]

schema = CollectionSchema(fields, "hello_milvus is the simplest demo to introduce the APIs")

print(fmt.format("Create collection `hello_milvus`"))
hello_milvus = Collection("hello_milvus", schema, consistency_level="Strong")

################################################################################
# 3. insert data
# We are going to insert 3000 rows of data into `hello_milvus`
# Data to be inserted must be organized in fields.
#
# The insert() method returns:
# - either automatically generated primary keys by Milvus if auto_id=True in the schema;
# - or the existing primary key field from the entities if auto_id=False in the schema.

print(fmt.format("Start inserting entities"))
rng = np.random.default_rng(seed=19530)
entities = [
# provide the pk field because `auto_id` is set to False
[str(i) for i in range(num_entities)],
rng.random(num_entities).tolist(), # field random, only supports list
rng.random((num_entities, dim)), # field embeddings, supports numpy.ndarray and list
]

insert_result = hello_milvus.insert(entities)

hello_milvus.flush()
print(f"Number of entities in Milvus: {hello_milvus.num_entities}") # check the num_entities

################################################################################
# 4. create index
# We are going to create an IVF_FLAT index for hello_milvus collection.
# create_index() can only be applied to `FloatVector` and `BinaryVector` fields.
print(fmt.format("Start Creating index IVF_FLAT"))
index = {
"index_type": "IVF_FLAT",
"metric_type": "L2",
"params": {"nlist": 128},
}

hello_milvus.create_index("embeddings", index)

################################################################################
# 5. search, query, and hybrid search
# After data were inserted into Milvus and indexed, you can perform:
# - search based on vector similarity
# - query based on scalar filtering(boolean, int, etc.)
# - hybrid search based on vector similarity and scalar filtering.
#

# Before conducting a search or a query, you need to load the data in `hello_milvus` into memory.
print(fmt.format("Start loading"))
hello_milvus.load()

# -----------------------------------------------------------------------------
# search based on vector similarity
print(fmt.format("Start searching based on vector similarity"))
vectors_to_search = entities[-1][-2:]
search_params = {
"metric_type": "L2",
"params": {"nprobe": 10},
}

start_time = time.time()
result = hello_milvus.search(vectors_to_search, "embeddings", search_params, limit=3, output_fields=["random"])
end_time = time.time()

for hits in result:
for hit in hits:
print(f"hit: {hit}, random field: {hit.entity.get('random')}")
print(search_latency_fmt.format(end_time - start_time))

# -----------------------------------------------------------------------------
# query based on scalar filtering(boolean, int, etc.)
print(fmt.format("Start querying with `random > 0.5`"))

start_time = time.time()
result = hello_milvus.query(expr="random > 0.5", output_fields=["random", "embeddings"])
end_time = time.time()

print(f"query result:\n-{result[0]}")
print(search_latency_fmt.format(end_time - start_time))

# -----------------------------------------------------------------------------
# pagination
r1 = hello_milvus.query(expr="random > 0.5", limit=4, output_fields=["random"])
r2 = hello_milvus.query(expr="random > 0.5", offset=1, limit=3, output_fields=["random"])
print(f"query pagination(limit=4):\n\t{r1}")
print(f"query pagination(offset=1, limit=3):\n\t{r2}")

# -----------------------------------------------------------------------------
# hybrid search
print(fmt.format("Start hybrid searching with `random > 0.5`"))

start_time = time.time()
result = hello_milvus.search(vectors_to_search, "embeddings", search_params, limit=3, expr="random > 0.5",
output_fields=["random"])
end_time = time.time()

for hits in result:
for hit in hits:
print(f"hit: {hit}, random field: {hit.entity.get('random')}")
print(search_latency_fmt.format(end_time - start_time))

###############################################################################
# 6. delete entities by PK
# You can delete entities by their PK values using boolean expressions.
ids = insert_result.primary_keys

expr = f'pk in ["{ids[0]}" , "{ids[1]}"]'
print(fmt.format(f"Start deleting with expr `{expr}`"))

result = hello_milvus.query(expr=expr, output_fields=["random", "embeddings"])
print(f"query before delete by expr=`{expr}` -> result: \n-{result[0]}\n-{result[1]}\n")

hello_milvus.delete(expr)

result = hello_milvus.query(expr=expr, output_fields=["random", "embeddings"])
print(f"query after delete by expr=`{expr}` -> result: {result}\n")

###############################################################################
# 7. drop collection
# Finally, drop the hello_milvus collection
print(fmt.format("Drop collection `hello_milvus`"))
utility.drop_collection("hello_milvus")

升级版

现在,让我们来看一下使用LangChain版本的代码。由于我们使用的是封装好的Milvus,所以我们需要一个嵌入模型。在这里,我们选择了HuggingFaceEmbeddings中的sensenova/piccolo-base-zh模型作为示例,当然你也可以选择其他模型,这里没有限制。只要能将其作为一个变量传递给LangChain定义的函数调用即可。

下面是一个简单的示例,包括数据库连接、插入数据、查询以及得分情况的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
python复制代码from langchain.embeddings import HuggingFaceEmbeddings
from langchain.vectorstores import Milvus


model_name = "sensenova/piccolo-base-zh"
embeddings = HuggingFaceEmbeddings(model_name=model_name)

print("链接数据库")
vector_db = Milvus(
embeddings,
connection_args={"host": "localhost", "port": "19530"},
collection_name="hello_milvus",
)
print("简单传入几个值")
vector_db.add_texts(["12345678","789","努力的小雨是一个知名博主,其名下有公众号【灵墨AI探索室】,博客:稀土掘金、博客园、51CTO及腾讯云等","你好啊","我不好"])

print("查询前3个最相似的结果")
docs = vector_db.similarity_search_with_score("你好啊",3)

print("查看其得分情况,分值越低越接近")
for text in docs:
print('文本:%s,得分:%s'%(text[0].page_content,text[1]))

image

注意,以上代码只是一个简单示例,具体的实现可能会根据你的具体需求进行调整和优化。

在langchain版本的代码中,如果你想要执行除了自己需要开启docker中的milvus容器之外的操作,还需要确保你拥有网络代理。这里不多赘述,因为HuggingFace社区并不在国内。

个人定制版

接下来,我们将详细了解如何调用openai模型来回答问题!

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
python复制代码from dotenv import load_dotenv
from langchain.prompts import ChatPromptTemplate, SystemMessagePromptTemplate, HumanMessagePromptTemplate;
from langchain import PromptTemplate
from langchain.chains import LLMChain
from langchain.chat_models.openai import ChatOpenAI
from langchain.schema import BaseOutputParser

# 加载env环境变量里的key值
load_dotenv()
# 格式化输出
class CommaSeparatedListOutputParser(BaseOutputParser):
"""Parse the output of an LLM call to a comma-separated list."""

def parse(self, text: str):
"""Parse the output of an LLM call."""
return text.strip().split(", ")
# 先从数据库查询问题解
docs = vector_db.similarity_search("努力的小雨是谁?")
doc = docs[0].page_content

chat = ChatOpenAI(model_name='gpt-3.5-turbo', temperature=0)
template = "请根据我提供的资料回答问题,资料: {input_docs}"
system_message_prompt = SystemMessagePromptTemplate.from_template(template)
human_template = "{text}"
human_message_prompt = HumanMessagePromptTemplate.from_template(human_template)

chat_prompt = ChatPromptTemplate.from_messages([system_message_prompt, human_message_prompt])

# chat_prompt.format_messages(input_docs=doc, text="努力的小雨是谁?")
chain = LLMChain(
llm=chat,
prompt=chat_prompt,
output_parser=CommaSeparatedListOutputParser()
)
chain.run(input_docs=doc, text="努力的小雨是谁?")

当你成功运行完代码后,你将会得到你所期望的答案。如下图所示,这些答案将会展示在你的屏幕上。不然,如果系统不知道这些问题的答案,那它又如何能够给出正确的回答呢?

image

总结

通过本系列文章的学习,我们已经对个人或企业知识库有了一定的了解。尽管OpenAI已经提供了私有知识库的部署选项,但是其高昂的成本对于一些企业来说可能是难以承受的。无论将来国内企业是否会提供个人或企业知识库的解决方案,我们都需要对其原理有一些了解。无论我们的预算多少,都可以找到适合自己的玩法,因为不同预算的玩法也会有所不同。

本文转载自: 掘金

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

浅谈安卓离线内存分析项目 前言 前世今生 项目结构 Core

发表于 2023-11-08

前言

上一篇文章《Usap64 进程莫名死锁问题分析》中使用到分析技术 art-parser 工具是我未开源项目中的一个最重要组成部分。可能因为文章的选题有趣,也因此我的掘金账号粉丝数突涨,让我意想不到的是登上了周热榜一。

作者榜.jpg 文章热榜.jpg

其中也有注意到这项工具,并对此感兴趣。这里也浅谈下关于 art-parser 这个工具的由来背景,以及它未来会不会开源等。

前世今生

毕业于 2016 年,当时最新的 AOSP 版号为 7.0,直接跳过了 Dalvik 虚拟机时代,诸如预编译 OAT 文件等也都在这个时期发生,正是如此第一次接触到 Android 应用程序发生 Native Crash 问题,堆栈显示最后 #0 帧处为 boot.oat 文件上,面对这类问题,当时简直是灾难一般。

1
2
早期的NativeCrash复制代码#00 pc 00561a48 /data/dalvik-cache/arm/system@framework@boot.art
#01 pc 71cc8f87 /data/dalvik-cache/arm/system@framework@boot.oat (offset 0x1fb5000)

查阅 AOSP 源代码了解到它是一个 ELF 格式的文件,要解决这样的问题,需要了解许多其它相关知识,如 Java Code 与 ART 预编译生成 Machine Code 的关系,ART Java 对象模型,Tombstone 机制以及 Coredump 文件等。随着版本更新,Tombstone 显示的堆栈也更加的详细以及通过 GDB 来解析 Coredump 文件也能够显示更多信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
现在的OAT文件复制代码(gdb) bt
#0 __epoll_pwait ()
#1 0x0000007df00f6a50 in android::Looper::pollInner (this=<optimized out>, timeoutMillis=3671)
#2 0x0000007df00f6930 in android::Looper::pollOnce (this=0xb400007d67f387c0, timeoutMillis=3671, outFd=0x0, outEvents=0x0, outData=0x0)
#3 0x0000007de88beec0 in android::Looper::pollOnce (this=0xfffffffffffffffc, timeoutMillis=<optimized out>)
#4 android::NativeMessageQueue::pollOnce (env=0xb400007d67e8ce00, pollObj=<optimized out>, timeoutMillis=<optimized out>, this=<optimized out>)
#5 android::android_os_MessageQueue_nativePollOnce (env=0xb400007d67e8ce00, obj=<optimized out>, ptr=-5476376608838249344, timeoutMillis=-305220912)
#6 0x00000000716e7098 in android::os::MessageQueue::nativePollOnce (this=...)
#7 0x0000000071af21dc in android::os::MessageQueue::next (this=...)
#8 0x0000000071aef934 in android::os::Looper::loopOnce (me=..., ident=<optimized out>, thresholdOverride=<optimized out>)
#9 0x0000000071aef840 in android::os::Looper::loop ()

(gdb) frame 7
#7 0x0000000071af21dc in android::os::MessageQueue::next (this=...)

(gdb) p this
$1 = (android::os::MessageQueue &) @0x13a80000: <incomplete type>

尽管现在有更多信息可以输出,但也无法直接展开 0x13a80000 这个对象的数据结构,在过去,我都是基于 GDB 的脚本功能来编写相关的解析功能,方便是挺方便的,但是这样的脚本无法满足我的需求,更多是局限于分析 Native Crash 问题上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ini复制代码art-parser> p 0x13a80000
Size: 0x28
Padding: 0x1
Object Name: android.os.MessageQueue
iFields of android.os.MessageQueue
[0x24] boolean mBlocked = 0x1
[0x8] android.util.SparseArray mFileDescriptorRecords = 0x0
[0xc] java.util.ArrayList mIdleHandlers = 0x171e5cd0
[0x10] android.os.Message mMessages = 0x1301f848
[0x20] int mNextBarrierToken = 0x0
[0x14] android.os.MessageQueue$IdleHandler[] mPendingIdleHandlers = 0x171e5d30
[0x18] long mPtr = 0xb400007d45f01480
[0x25] boolean mQuitAllowed = 0x0
[0x26] boolean mQuitting = 0x0
iFields of java.lang.Object
[0x0] java.lang.Class shadow$_klass_ = 0x70098050
[0x4] int shadow$_monitor_ = 0x40001130

而在安卓上发生纯粹的 Native Crash 时,分析 Coredump 转储文件,现有的工具有 GDB、LLDB 等,基本能够满足绝大部分开发者。但是将更多的问题转变为分析 Ramdump、Coredump 时需面临的难点,但优点在于它如同现场一般,我们几乎能够查询到所有在内存上的数据。

原问题类型 核心数据 离线分析难点
Java OOM 需要对应的程序的 Java Hprof 文件 从 Coredump 转储文件中统计各个 Java 对象的数量占用内存大小。
Java 常见异常 AndroidRuntime 的 Fatal 日志 从 Coredump 转储文件中定位 Java 的调用堆栈,检索具体 Java 对象数据,如某对象成员空指针等信息。
ANR 、Watchdog anr_trace.txt 文件,如耗时问题需记录到具体调用栈
冻屏 范围广,表现多,如问题表现在用户态时,大部分需要 Dumpsys 数据 从 Ramdump 取得 system_server 的 Coredump 转储,从 Coredump 中得到具体的 Dumpsys 数据,计算量大,人工手动计算基本不可能。
…

在互联网技术中,也有类似的如 jmap + gcore 来完成现场保存以及离线提取 hprof 文件。这么棒的解析技术,为什么 Android 的技术栈上会没有呢?于是我最初的设想解决转储问题、提取 hprof 文件以及展开 Java 对象数据结构。

项目结构

随着项目的进行,发现最初的设想无法满足我自身需求,如 Java 堆栈解析,ART 虚拟机寄存器解析,检索对象,统计对象数量等。然而功能实现上虽然并不难,但是 Core 文件的内容也并不是每一个都那么的全,要考虑到许多缺页的情况。比如该 Core 采用的是原生默认参数抓取,那么会缺失的文件页表。部分抓 Core 的机制会缺失重要的 bss 段内存,如 MTK 的项目常常会发生。从 Ramdump 文件中转储 Coredump 文件,可以使用 crash-utility 项目的 gcore 插件来完成,早一些的项目这个过程是没有任何问题的,只是现在的手机项目 zram 的大小占用越来越大,以致于现今要从 Ramdump 中解析用户态的数据较为依赖 zram 的解析。然而 crash-utility 项目中 zram 长久以来都无人维护,也因此 gcore 的转储方案不适用于我们现在的手机项目。

UML 图 (9).jpg
由于这一套解决方案中采用了 crash-utility 来补充内核态解析,彻底打通这个桥梁于是我也给该项目维护更新适配最新的内核。

标题
Fix “rd” command to display data on zram on Linux 5.17 and later github.com/crash-utili…
arm64: Fix “vtop” command to display swap information on Linux 5.19 and later github.com/crash-utili…

Core 从何而来

既然是离线内存分析,那么首当其冲的能力是解决 Core 从何而来的问题。

名称 用途
ram2core 应用于 crash-tools 环境下,从 ramdump 中提取目标进程的 coredump。配合 zram、shmem 来解析特殊内存。
proc2core 应用于真机 Root 环境下,抓取目标进程 coredump,并且不会破坏现场。
OpenCoreSDK 应用于安卓应用程序非 Root 环境下,需开发者集成的开源方案介绍:《Android 应用程序如何抓取 Coredump》应用:《结合 OpenCore 分析安卓应用程序 Crash 问题》
其它方案 如配置原生参数《如何理解Native Crash问题》该文在我还没创建掘金前在鸿洋的公众号上投稿过。

Core 文件页修复

从 Ramdump 中转储得到某个进程的 Coredump,往往文件页相关的内存都存在大量的丢失,如果是纯粹的 Native 进程,一般也只有 .so 等动态库文件的需求,而 .so 可在符号表文件里找到,因此都可在 LLDB、GDB 上通过外部映射。

但是非 Native 进程,并且需要解析 Java 数据,那么需求就不一样了,它需要依赖 DexCache 内存,也就是对应的原始 .jar,.apk,.dex 等文件内容,此时要拿到这些文件,要么在真机里刷入对应版本的 ROM,或者是通过解压刷机包的 super.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
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
完整的修复过程复制代码# crash SYS_COREDUMP/SYS_COREDUMP vmlinux

找到 systemui 进程
crash> ps | grep "systemui"
2506 827 0 ffffff81615ddc80 IN 0.3 11381528 42780 ndroid.systemui

添加 zram.ko 符号表
crash> mod -s zram zram.ko
MODULE NAME BASE SIZE OBJECT FILE
ffffffc0033f2ac0 zram ffffffc0033e9000 73728 zram.ko

添加 zsmalloc.ko 符号表
crash> mod -s zsmalloc zsmalloc.ko
MODULE NAME BASE SIZE OBJECT FILE
ffffffc0033e12c0 zsmalloc ffffffc0033da000 57344 zsmalloc.ko

添加 zram 插件
crash> extend zram.so
./zram.so: shared object loaded

添加 shmem 插件
crash> extend shmem.so
./shmem.so: shared object loaded

添加 ram2core 插件
crash> extend ram2core.so
./ram2core.so: shared object loaded

开始对 2506 进程进行转储
crash> ram2core -p 2506 -s zram -m shmem
Write ELF Header
Write Program Headers
Write Segments
>>>> 10% <<<<
>>>> 20% <<<<
>>>> 30% <<<<
>>>> 40% <<<<
>>>> 50% <<<<
>>>> 60% <<<<
>>>> 70% <<<<
>>>> 80% <<<<
>>>> 90% <<<<
Done
Saved [core.2506].
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ini复制代码art-parser --core-file=core.2506

此时我们拿到的 Core.2506 文件是不完整的,许多内容都无法展示
art-parser> map
Warn: Not found exec dynamic.
Warn: Not found r_debug, You can try command linker.

art-parser> p 0x12c00000
Size: 0x10
Object Name:
iFields of
iFields of
[0xc] byte = 0x23
[0x8] byte = 0xffffff90
iFields of
[0x0] byte = 0x70
[0x4] byte = 0x0
例如前面的两个命令,一个是缺少 app_process64 的文件页、另一是缺少 core-oj.jar 导致无法展示。

此时我们需要填补缺少的文件页,类似的像 GDB 、LLDB 那样通过原始文件映射到运行环境下。

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
ini复制代码将 app_process64 映射到 art-parser 环境下
art-parser> execfn out/app_process64
Load [0] segment [0x5b1754f000, 0x5b17551000) out/app_process64 [0x0]
Load [1] segment [0x5b17551000, 0x5b17552000) out/app_process64 [0x2000]

此时的 link map 才能正常输出
art-parser> map
0x74ef15f0e0 [0x5b1754f000, 0x5b17551000) [out/app_process64] [*] 被映射后
0x74f0403200 [0x74f02d2000, 0x74f0309000) [/system/bin/linker64] [*]
0x74ef15f338 [0x74f02d1000, 0x74f02d2000) [[vdso]] [*]
0x74ef15f590 [0x74e8969000, 0x74e8a28000) [/system/lib64/libandroid_runtime.so] [*]
0x74ef15f7e8 [0x74d6244000, 0x74d6294000) [/system/lib64/libbinder.so] [*]
0x74ef15fa40 [0x74d63d5000, 0x74d63de000) [/system/lib64/libcutils.so] [*]

将其它 .so 映射到 art-parser 环境下
art-parser> sysroot out
Load [0] segment [0x74e8969000, 0x74e8a28000) out/root/system/lib64/libandroid_runtime.so [0x0]
Load [1] segment [0x74e8a28000, 0x74e8b7e000) out/root/system/lib64/libandroid_runtime.so [0xbf000]
Load [0] segment [0x74d6244000, 0x74d6294000) out/root/system/lib64/libbinder.so [0x0]
Load [1] segment [0x74d6294000, 0x74d62f1000) out/root/system/lib64/libbinder.so [0x50000]

将 .dex, .jar, .apk 等依赖的 DexCache 文件映射到 art-parser 环境下
art-parser> dex out
Load [classes.dex] segment [0x74cac0c000, 0x74cac1b000) out/root/system/framework/Booster.jar [0x0]
Load [classes3.dex] segment [0x7426a5c000, 0x7426a8b000) out/root/system_ext/priv-app/SystemUI/SystemUI.apk [0x12b6000]
Load [classes2.dex] segment [0x738f4e6000, 0x738f7c1000) out/root/product/app/SystemUIPlugin/SystemUIPlugin.apk [0x540000]
Load [classes.dex] segment [0x74d9e00000, 0x74d9e04000) out/root/system_ext/framework/framework-pointer-pad.jar [0x0]
Load [classes2.dex] segment [0x73abbb0000, 0x73ac560000) out/root/system_ext/priv-app/SystemUI/SystemUI.apk [0x907000]

此时的 print 输出也算是正常了
art-parser> p 0x12c00000 -b
Size: 0x10
Object Name: java.lang.StringBuilder
iFields of java.lang.StringBuilder
iFields of java.lang.AbstractStringBuilder
[0xc] int count = 0x23
[0x8] char[] value = 0x12c00090
iFields of java.lang.Object
[0x0] java.lang.Class shadow$_klass_ = 0x70615c70
[0x4] int shadow$_monitor_ = 0x0
Binary:
0x12c00000: 0x70615c70 0x00000000 0x12c00090 0x00000023

修复 Core 文件
art-parser> restore /tmp/restore.2506.core
Saved [/tmp/restore.2506.core].

例如该问题是一个内存泄露导致系统死机问题,我们可以在 Ramdump 中解析出该 App 的所有对象,以及统计出占用的内存大小,以及提取成 hprof 文件在 AS 上分析。

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
ini复制代码art-parser --core-file=/tmp/restore.2506.core --art-path=libart.so

依据Native Size大小进行排序,可看到 HardwareBuffer 占了近 9G 内存大小。
art-parser> top 20 -d -n
Address Allocations ShallowSize NativeSize ClassName
0x70e8d830 1013 24312 9656024064 android.hardware.HardwareBuffer
0x70d36350 4278 196788 395797260 android.graphics.Bitmap
0x70d3c110 1289 21913 1289000 android.os.BinderProxy
0x70ec5a98 565 20340 282500 android.app.LoadedApk$ServiceDispatcher$InnerConnection
0x70ec58f0 174 6960 87000 android.app.LoadedApk$ReceiverDispatcher$InnerReceiver
0x70ecad68 153 5508 76500 android.database.ContentObserver$Transport
0x70d8bca8 139 4448 71168 android.view.SurfaceControl$Transaction
0x70e0b3c0 80 2560 40000 android.os.Binder
0x72c2b7f8 11 396 5500 android.bluetooth.BluetoothProfileConnector$1
0x71007e78 10 400 5000 android.view.ViewRootImpl$W
0x159aa1a8 9 756 4500 com.android.systemui.qs.external.TileServices
0x710913d8 9 360 4500 android.window.WindowOnBackInvokedDispatcher$OnBackInvokedCallbackWrapper
0x70ecaf20 8 416 4000 android.database.CursorToBulkCursorAdaptor
0x70f474e8 8 288 4000 android.media.AudioManager$2
0x70f47698 8 288 4000 android.media.AudioManager$3
0x70f47840 8 288 4000 android.media.AudioManager$4
0x70f479e8 8 288 4000 android.media.AudioManager$5
0x70ec8e68 7 364 3500 android.content.ContentProvider$Transport
0x70f4aee8 5 180 2500 android.os.Handler$MessengerImpl
0x70f49988 3 108 1500 android.media.session.MediaSessionManager$SessionsChangedWrapper$1

检索 android.hardware.HardwareBuffer 对象
art-parser> search android.hardware.HardwareBuffer
[0] 0x12cc3290 android.hardware.HardwareBuffer
[1] 0x12d80dc8 android.hardware.HardwareBuffer
[2] 0x12e408d8 android.hardware.HardwareBuffer
[3] 0x12e50348 android.hardware.HardwareBuffer
[4] 0x12eac320 android.hardware.HardwareBuffer
[5] 0x12eb8728 android.hardware.HardwareBuffer
[6] 0x12f44bb8 android.hardware.HardwareBuffer
[7] 0x12f62190 android.hardware.HardwareBuffer
[8] 0x12f6d6a8 android.hardware.HardwareBuffer
[9] 0x12fa69e0 android.hardware.HardwareBuffer
[10] 0x12fa6d08 android.hardware.HardwareBuffer
[11] 0x12fcc318 android.hardware.HardwareBuffer
[12] 0x12ff6328 android.hardware.HardwareBuffer
[13] 0x130363f0 android.hardware.HardwareBuffer
[14] 0x130994b8 android.hardware.HardwareBuffer
[15] 0x131405d0 android.hardware.HardwareBuffer
...

输出对象引用关系
art-parser> p 0x12d80dc8 -r
Size: 0x18
Object Name: android.hardware.HardwareBuffer
iFields of android.hardware.HardwareBuffer
[0x8] java.lang.Runnable mCleaner = 0x0
[0xc] dalvik.system.CloseGuard mCloseGuard = 0x12d80e08
[0x10] long mNativeObject = 0x0
iFields of java.lang.Object
[0x0] java.lang.Class shadow$_klass_ = 0x70e8d830
[0x4] int shadow$_monitor_ = 0x0
References:
>> 0x12d80cf8 android.window.TaskSnapshot
>> 0x12d80de0 java.lang.ref.FinalizerReference
>> 0x12d80e50 sun.misc.Cleaner

进一步转储 hprof 文件,该过程会非常耗时。
art-parser> hprof 6.14.ramdump.systemui.full.hprof

技术体系

该项目属于一项技术兜底,使用有一定门槛,对大部分开发者而言难以理解,也很难会想到可以运用上,包括我在内部也不容易推广。因为遇上需要用到这项技术来分析的问题,那么想必是穷途末路的时候,但是一旦能用上,或者把问题化简为繁时会有奇效。
UML 图 (10).jpg

开源问题

我从设计初就考虑将 art-parser 开放给应用开发者使用的,因此从设计上考虑到开发者没有符号表文件,所以并不依赖符号表文件。在今年的 3 月份时联系过内部提报开源项目,只能说至今未果,因此我也不知道能不能将该项目进行开源。

我还是比较热衷于将此项目开源,类似下边的王者荣耀发生 Java 空指针问题,但却没有报 JE 空指针,而是报 NE 问题,不过使用 art-parser 可以轻松秒杀,想必能帮助到应用开发者解决一些头疼的 NE 问题,尤其是与 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
ini复制代码art-parser> bt 31033 
"UnityMain" prio=5 tid=49 Unknown
| group="main" sCount=0 ucsCount=0 flags=0 obj=0x13401ce8 self=0xa7000056eaefa000
| sysTid=31033 nice=<unknown> cgrp=<unknown> sched=<unknown> handle=0x6a370bacb0
| stack=0x6a36fb3000-0x6a36fb7000 stackSize=0x107cb0
| held mutexes= "mutator lock"
x0 0x000000000000000b x1 0xd80000749a199410 x2 0xb9000058eb66ee20 x3 0x0000000000000074
x4 0x00000000ffffffff x5 0x0000000000000001 x6 0x0000000000000000 x7 0x0000000012f2e090
x8 0x0000000000000008 x9 0x0b9000058eb66ed0 x10 0x0b9000058eb66ed2 x11 0x0b9000058eb66ed1
x12 0x0040000d6001b2a6 x13 0x0000006a370bb000 x14 0xb9000058eb66ed20 x15 0x0040000d6001b222
x16 0x0000000000000000 x17 0x0000000000000000 x18 0x0000000000000001 x19 0xa7000056eaefa000
x20 0x0200006c00000000 x21 0x0000000000000000 x22 0x0040000d6001b2c6 x23 0x000000000000106e
x24 0x0040000d6001b226 x25 0x000000000000000b x26 0x0000006a370b81b4 x27 0x0000006a370b8188
x28 0x0000006a370b81e0 x29 0xb9000058eb66ed40
lr 0x005e7474bedd684c sp 0xb9000058eb66ebe0 pc 0x000000749a1911dc pst 0x0000000060001000
FP[0x58eb66ed40] PC[0x749a1911dc] native: #00 (art::SignalChain::Handler(int, siginfo*, void*)+0x9c) /apex/com.android.art/lib64/libsigchain.so
FP[0x58eb66ed40] PC[0x74bedd684c] native: #01 () [vdso]
FP[0x58eb66fff0] PC[0x74bedd684c] native: #02 () [vdso]
<<maybe handle signal>>
x0 0x000000000000002d x1 0x0000000000000000 x2 0x000000000000002d x3 0x0000000000000074
x4 0x00000000ffffffff x5 0x0000000000000001 x6 0x0000000000000000 x7 0x0000000012f2e090
x8 0x3d068dc5bdcab74a x9 0x3d068dc5bdcab74a x10 0x2500004feae84b00 x11 0x0000000000000010
x12 0x0200006b000d9128 x13 0x0200006bffffffff x14 0x0000006a370b7a70 x15 0x0040000d6001b222
x16 0x0000000000000000 x17 0x0000000000000000 x18 0x0000000000000001 x19 0xa7000056eaefa000
x20 0x0000000000000000 x21 0x0000000000000000 x22 0x0000006a73c4bd42 x23 0x000000000000106e
x24 0x0000007478c00880 x25 0x0000006a370b8188 x26 0x0000006a370b81b4 x27 0x0000006a370b8188
x28 0x0000006a370b81e0 x29 0x0000006a370b81b4
lr 0x0000007478c03fc8 sp 0x0000006a370b8160 pc 0x0000007478c03fa8 pst 0x0000000060001000
FP[0x6a370b81b4] PC[0x7478c03fa8] native: #00 (17ContentionLogData13AddToWaitTimeEm+0x3f68) /apex/com.android.art/lib64/libart.so
FP[0x6a370b81b4] PC[0x7478c03fc8] native: #01 (17ContentionLogData13AddToWaitTimeEm+0x3f88) /apex/com.android.art/lib64/libart.so
QF[0x6a370b8160] PC[0x7478c03fc8] at dex-pc 0x6a73c4bd42 com.tencent.smtt.utils.LogFileUtils.writeDataToStorage //AM[0x744bdc3808]
QF[0x6a370b8280] PC[0x7478c09338] at dex-pc 0x6a73c4cb12 com.tencent.smtt.utils.TbsLogClient.writeLogToDisk //AM[0x744bdc26e0]
QF[0x6a370b8380] PC[0x7478c0a258] at dex-pc 0x6a73c4ca9a com.tencent.smtt.utils.TbsLogClient.writeLog //AM[0x744bdc26c0]
QF[0x6a370b8470] PC[0x7478c0a258] at dex-pc 0x6a73c4c564 com.tencent.smtt.utils.TbsLog.i //AM[0x9db10b48]
QF[0x6a370b8570] PC[0x7478c09338] at dex-pc 0x6a73c3f200 com.tencent.smtt.sdk.o.b //AM[0x744bdb1b18]
QF[0x6a370b8680] PC[0x7478c0a258] at dex-pc 0x6a73c1c2c0 com.tencent.smtt.sdk.QbSdk.initX5Environment //AM[0x9db10258]
QF[0x6a370b8780] PC[0x7478c09338] at dex-pc 0x698d9ef27e com.tencent.up.nb.NBApplication.initTBS //AM[0x698ddb5708]
QF[0x6a370b8860] PC[0x7478c0a258] at dex-pc 0x698d9ef1e2 com.tencent.up.nb.NBApplication.init //AM[0x698ddb56c8]
QF[0x6a370b8940] PC[0x7478c0a258] at dex-pc 0x698d9fa94a com.tencent.up.nbsdk.EnterManager.initApplication //AM[0x6a54786210]
QF[0x6a370b8a40] PC[0x7478c09338] at dex-pc 0x698d9faaba com.tencent.up.nbsdk.EnterManager.preloadPackageByUrl //AM[0x6a54786270]
QF[0x6a370b8b30] PC[0x7478c09338] at dex-pc 0x698d9dff60 com.tencent.grobot.XYEnterManager.preLoad //AM[0x6a54781768]
QF[0x6a370b9250] PC[0x0000000000] at dex-pc 0x0000000000 java.lang.reflect.Method.invoke(Native method) //AM[0x6fa90f18]
QF[0x6a370b9300] PC[0x7478c0a2b4] at dex-pc 0x6a73d730be com.tencent.xplug.Reflector.callByCaller //AM[0x6a542f91b0]
QF[0x6a370b93f0] PC[0x7478c0a258] at dex-pc 0x6a73d7309e com.tencent.xplug.Reflector.call //AM[0x6a542f9190]
QF[0x6a370b94d0] PC[0x7478c0a258] at dex-pc 0x6a7368cf5e com.tencent.grobot.GRobotDelegateManager.preLoad //AM[0x6a542f87f8]
QF[0x6a370b95e0] PC[0x7478c0a258] at dex-pc 0x6a7368c3a6 com.tencent.grobot.GRobot.init //AM[0x6a542f8490]
QF[0x6a370ba430] PC[0x0000000000] at dex-pc 0x0000000000 com.unity3d.player.UnityPlayer.nativeRender(Native method) //AM[0x9db12df8]
QF[0x6a370ba4e0] PC[0x7478c0a2b4] at dex-pc 0x6a73d7c57a com.unity3d.player.UnityPlayer.c //AM[0x9db12978]
QF[0x6a370ba5b0] PC[0x009baf50c8] at dex-pc 0x6a73d7bbf2 com.unity3d.player.UnityPlayer$d$1.handleMessage //AM[0x744bdb8f28]
QF[0x6a370ba600] PC[0x00715806b8] at dex-pc 0x747672c4fc android.os.Handler.dispatchMessage //AM[0x70818a18]
QF[0x6a370ba630] PC[0x0071583adc] at dex-pc 0x747674fa6c android.os.Looper.loopOnce //AM[0x70819af8]
QF[0x6a370ba700] PC[0x0071583604] at dex-pc 0x7476750194 android.os.Looper.loop //AM[0x70819ad8]
QF[0x6a370ba750] PC[0x7478c0939c] at dex-pc 0x6a73d7bd2a com.unity3d.player.UnityPlayer$d.run //AM[0x744bdb4588]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ini复制代码  QF[0x6a370b8160] PC[0x7478c03fc8] at dex-pc 0x6a73c4bd42 com.tencent.smtt.utils.LogFileUtils.writeDataToStorage  //AM[0x744bdc3808]
{
Virtual registers
{
v0 = 0x134c7110 v1 = 0x00000000 v2 = 0x00000000 v3 = 0x370b82c8
v4 = 0x0000006a v5 = 0x78c0f544 v6 = 0x136b2340 v7 = 0x12f2e5d0
v8 = 0x136b2378 v9 = 0x00000000 v10 = 0x00000001
}
Physical registers
{
x20 = 0x0 x21 = 0x0 x22 = 0x6a73c4bd42 x23 = 0x106e
x24 = 0x7478c00880 x25 = 0x6a370b8188 x26 = 0x6a370b81b4 x27 = 0x6a370b8188
x28 = 0x6a370b81e0 x29 = 0x6a370b81b4 x30 = 0x7478c03fc8
}
}
1
2
3
4
arduino复制代码art-parser> disassemble 0x744bdc3808 -i 0x6a73c4bd42
void com.tencent.smtt.utils.LogFileUtils.writeDataToStorage(java.io.File, java.lang.String, byte[], java.lang.String, boolean) [dex_method_idx=26051]
DEX CODE:
0x6a73c4bd42: 106e efce 0001 | invoke-virtual {v1}, boolean java.io.File.mkdirs() // method@61390
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
arduino复制代码从字节码可得知调用 v1.mkdirs(),而当前 v1 = 0x0,因此空指针异常。

分析其空指针原因,输出 12 条字节码信息:
art-parser> disassemble 0x744bdc3808 -d 12
void com.tencent.smtt.utils.LogFileUtils.writeDataToStorage(java.io.File, java.lang.String, byte[], java.lang.String, boolean) [dex_method_idx=26051]
DEX CODE:
0x6a73c4bd20: 001c 0f4e | const-class v0, com.tencent.smtt.utils.LogFileUtils // type@TypeIndex[3918]
0x6a73c4bd24: 001d | monitor-enter v0
0x6a73c4bd26: 2071 65c1 0097 | invoke-static {v7, v9}, byte[] com.tencent.smtt.utils.LogFileUtils.encrypt(java.lang.String, java.lang.String) // method@26049
0x6a73c4bd2c: 070c | move-result-object v7
0x6a73c4bd2e: 0112 | const/4 v1, #+0
0x6a73c4bd30: 0738 0004 | if-eqz v7, 0x6a73c4bd38 //+4
0x6a73c4bd34: 1907 | move-object v9, v1
0x6a73c4bd36: 0228 | goto 0x6a73c4bd3a //+2
0x6a73c4bd38: 1707 | move-object v7, v1
0x6a73c4bd3a: 106e efc3 0006 | invoke-virtual {v6}, java.io.File java.io.File.getParentFile() // method@61379
0x6a73c4bd40: 010c | move-result-object v1
0x6a73c4bd42: 106e efce 0001 | invoke-virtual {v1}, boolean java.io.File.mkdirs() // method@61390

从字节码上看得知: v1 = v6.getParentFile();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ini复制代码art-parser> p 0x136b2340 -b
Size: 0x18
Object Name: java.io.File
iFields of java.io.File
[0x8] java.nio.file.Path filePath = 0x0
[0xc] java.lang.String path = tbslog.txt
[0x14] int prefixLength = 0x0
[0x10] java.io.File$PathStatus status = 0x0
iFields of java.lang.Object
[0x0] java.lang.Class shadow$_klass_ = 0x6f889e78
[0x4] int shadow$_monitor_ = 0x0
Binary:
0x136b2340: 0x6f889e78 0x00000000 0x00000000 0x13503e18
0x136b2350: 0x00000000 0x00000000

由于 path 为 tbslog.txt 没有根目录路径,于是:
File parent = file.getParentFile(); 得到的 parent 对象为 null。
parent.mkdirs(); // 事实上是发生了 Java NullPointerException。

最后能不能开源,我也不知道,只能说尽量争取,毕竟现今在公司里会使用该项目解决一些问题,也仅有几位同事,在这里也只是简单的介绍下,只言片语不易传达,没有真正的使用体会,不知大家对该项目是否感兴趣。

本文转载自: 掘金

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

js中this究竟指向哪里?现在终于搞定了!

发表于 2023-11-08

thsi据说是js中最复杂的机制,不过相信我,看完这篇文章,小白也能跨过js中this这座大山

this是个代词,代指某个东西,在我们js中,他是某作用域的代名词

this的绑定规则有六种方式,分别为默认绑定、隐式绑定、隐式丢失、显示绑定、new绑定、箭头函数,下面就开始一一对其进行介绍

一、默认绑定

函数在哪个词法作用域中生效,this就指向哪里,你可以理解为this最终指向了全局

在下面引例之前我需要先解释下,函数本身是没有词法作用域([[scope]])的,它的词法作用域是由函数声明的位置确定的或者this坚决不能访问词法作用域内部

比如我们看下面这个例子

1
2
3
4
5
javascript复制代码function foo(){
var a = 1
console.log(this.a);
}
foo() // undefined

foo中的this指向了foo的词法作用域,但是由于函数是没有词法作用域的,它的词法作用域就是函数声明的位置—全局确定的,因此this代表了全局,全局中没有a这个属性,因此输出undefined(非报错是因为这是对象,找不到也是undefined)

再来一个

1
2
3
4
5
6
7
8
9
javascript复制代码function foo() {
var a = 2
this.bar()
}
function bar() {
var b = 3
console.log(this.b)
}
foo() // undefined

foo声明在全局中,因此this指向了全局,去全局寻找bar这个属性,找到后执行bar这个函数,bar声明在全局中,因此this指向了全局,全局中没有b这个值,找不到后输出undefined

再来看个正常的

1
2
3
4
5
javascript复制代码var b = 1
function bar() {
console.log(this.b)
}
bar() // 1

bar声明在全局中,因此bar中的this指向了全局,全局中恰好有b这个值,打印出1

再来看个误导性较强的

1
2
3
4
5
6
7
8
scss复制代码function foo(){
var b = 1
bar()
}
function bar(){
console.log(this.b);
}
foo() // undefined

调用foo也就是调用bar,bar中this因为bar是声明在全局中,所以指向了全局,全局中没有b这个值所以输出undefined,如果大家认为是1,你就是把bar的最终词法作用域搞错了,并不是看他的调用环境

我们改一下上面的代码

1
2
3
4
5
6
7
8
9
10
11
12
scss复制代码var b = 2
function foo(){
var b = 1
bar()
}
function bar(){
baz()
}
function baz(){
console.log(this.b)
}
foo() // 2

baz中this指向了全局,因为他就是声明在全局中,因此直接去了全局找b,输出2,与foo没有关系

我再改下

1
2
3
4
5
6
7
8
9
10
11
12
scss复制代码var b = 2
function foo(){
var b = 1
bar()
function bar(){
baz()
}
function baz(){
console.log(this.b)
}
}
foo() // 2

baz中this指向了foo,因为他在foo中声明了,但是foo并没有自身的词法作用域,它的词法指向的是全局(因为他声明在全局中),因此最终打印出2,而非1

这里还有个小知识:全局定义变量相当于是往window这个全局对象挂属性,当然得是在浏览器环境中,node中全局对象是global,你可以试试var a = 1然后在浏览器输出window.a

二、隐式绑定

当一个函数被一个对象所引用(非调用),再次调用时,函数中的this会指向对象

直接看下面这个例子

1
2
3
4
5
6
7
8
9
10
11
12
javascript复制代码function foo(){
console.log(this.a);
}
var obj = {
a: 2,
foo: foo
// 相当于下面这样
// foo: function foo(){
// console.log(this.a)
// }
}
obj.foo() // 2

先解释下这段代码:obj中有个foo属性,然后这个属性的值又是一个函数体,就相当于foo这个函数体写在了foo这个key里面的value中,最后obj.foo()中obj.foo就是相当于foo这个函数体,然后()就是一个调用的意思

根据隐式绑定的规则,foo这个函数被obj这个函数引用,等你obj再次调用这个函数时,函数中的this最终指向了obj这个对象,而非全局,这就是隐式绑定,因此最终打印2

来个误导性强的

1
2
3
4
5
6
7
8
javascript复制代码function foo(){
console.log(this.a);
}
var obj = {
a: 3,
foo: foo()
}
obj.foo // undefined

这里对象中并非是引用这个foo函数,因为有个括号,这是调用,所以不符合隐式绑定规则,只能是默认绑定,this还是指向了全局,找不到a的值,输出undefined

三、隐式丢失

隐式丢失是隐式绑定的一种,当一个函数被多个函数链式调用时,函数中的this最终指向引用函数的对象

直接看下面这个例子

1
2
3
4
5
6
7
8
9
10
11
12
css复制代码function foo(){
console.log(this.a);
}
var obj = {
a: 2,
foo: foo
}
var obj2 = {
a: 3,
obj: obj
}
obj2.obj.foo()

其实这个绑定规则就是在隐式绑定的规则上了多了对象进行了一个链式的调用

这段代码如何理解呢?我们有两种理解关于最后一行代码,一种是obj2.obj就是obj这个对象,然后obj这个对象去obj.foo(),就是成了一个隐式绑定,this会指向obj。另一种是obj2.(obj.foo()),obj.foo()就是foo(),然后obj2.foo(),相当于obj2调用了这个函数,this会指向obj2。两种理解好像都没问题,其实大部分人应该就是第一种理解,其实最终确实是第一种理解,换句话说,就近原则,最终输出2

来个误导性强的

1
2
3
4
5
6
7
javascript复制代码function foo(){
console.log(this.a);
}
var obj = {
a: 2
}
foo() // undefined

如果你理得清关系,就清楚这里并没有引用这个函数foo,因此只能是默认绑定规则,this指向了全局,全局没有a这个变量因此输出undefined

如果我就是想要把这个this指向obj,但是又不能引用这个函数,我们如何去改变这个this的指向呢,那就是下面显示绑定的作用了

四、显示绑定

显示绑定就是使用call、apply或bind方法人为干预他,让他代指谁

call

1
2
3
4
5
6
7
javascript复制代码function foo(){
console.log(this.a);
}
var obj = {
a: 2
}
foo.call(obj) // 2

这个call的用法就是把foo的this指向了obj

当foo中有参数时如何使用呢

1
2
3
4
5
6
7
javascript复制代码function foo(n){
console.log(this.a,n);
}
var obj = {
a: 2
}
foo.call(obj,100) // 2 100

可以看出call与foo共用了这个括号

apply

1
2
3
4
5
6
7
javascript复制代码function foo(){
console.log(this.a);
}
var obj = {
a: 2
}
foo.apply(obj)

无参数时用法与call相同

传参

1
2
3
4
5
6
7
javascript复制代码function foo(n,m){
console.log(this.a,n,m);
}
var obj = {
a: 2
}
foo.apply(obj,[100,200]) // 2 100 200

apply传参可以看出是以数组的形式

bind

1
2
3
4
5
6
7
8
javascript复制代码function foo(n,m){
console.log(this.a,n,m);
}
var obj = {
a: 2
}
var bar = foo.bind(obj,100,200)
bar() // 2 100 200

bind会返回一个新的函数体,这个函数体拥有了obj的词法作用域,并且foo中this会指向obj。

bind传参有下面三种方式

  1. 就是上面的例子,全部放进bind()中
  2. var bar = foo.bind(obj)

bar(100,200) foo的参数可以全部写在返回的新函数中
3. var bar = foo.bind(obj,100)

bar(200) bind和返回的函数体都可以进行传参

如果是下面这种情况

var bar = foo.bind(obj,100,100)

bar(200) // 2 100 100 这种情况并不覆盖

我们来看一个误导性极强的例子

1
2
3
4
5
6
7
8
9
scss复制代码function foo(){
var a = 1
function bar(){
console.log(this.a);
}
var baz = bar.bind(foo)
baz()
}
foo() // undefined

这里使用bind就是让bar中的this指向了foo,但是foo是没有词法作用域,foo有个[[scope]]指向了全局,因为在全局中声明,所以this最终指向了全局,而非foo,因此输出undefined。这里其实就是一个默认绑定规则,恰好应证了this不能引用一个词法作用域内部的内容,其实就是说的函数

三者区别

call和apply

call和apply非常类似,只是apply传入的参数是数组

bind和call,apply

bind返回一个新的函数,并且需要再进行调用

五、new绑定

当使用new关键字进行实例化一个构造函数时,this最终指向该实例化对象

引用上次讲原型的时候的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
javascript复制代码Person.prototype.say = function(){
console.log("hello " + this.name);
}
function Person(name){
this.name = name;
// 相当于下面
// var this = {
// name: name,
// __proto__:Person.prototype
// }
// return this
}
var person1 = new Person("Dolphin")
person1.say()

new的作用其实就是在构造函数中创建一个this对象,然后构造函数中的内容就相当于往this里面挂属性,另外还会放一个实例对象的隐式原型,其值就是构造函数的显示原型,最终return出这个this对象

六、箭头函数

箭头函数没有this这个概念,在箭头函数中的this指向了外层第一个非箭头函数

这里我们不对箭头函数用法做仔细介绍,总之,箭头函数是es6新引进的函数,代码较普通函数更为简洁

我们来看个例子

1
2
3
4
5
javascript复制代码var a = 1
var bar = () => {
console.log(this.a)
}
bar() // 1

这个很好理解,箭头函数中的this就是指向了外层第一个非箭头函数,外层是全局,直接输出1

再来个误导性强的

1
2
3
4
5
6
7
8
9
10
javascript复制代码var obj = {
name: 'Tom',
show: function(){
var bar = () => {
console.log(this.name)
}
bar()
}
}
obj.show() // Tom

bar中的this指向了外面第一个非箭头函数,也就是function这个函数,也就是说function中有个this,这不就是一个对象引用了一个函数吗,然后这个函数中的this就是指向了该对象,所以最终输出Tom

再来一个

1
2
3
4
5
6
7
8
9
scss复制代码a = 5
var obj = function(){
    a = 10
    var foo = ()=>{
        console.log(this.b)
    }
    foo()
}
obj() // 10

这里输出10是因为函数中a = 10 相当于跑到全局中去了,因为a前面没有用声明。如果这里var b = 10 打印this.b也是10 ,同样的道理,函数中没有赋值操作没有声明就会跑到全局中去

既然箭头函数不承认this这个东西,那我使用new给他一个this能否成功

1
2
3
4
javascript复制代码var Foo = () => {

}
console.log(new Foo()); // 报错

好吧,这样是不行的,箭头函数不能当作构造函数来用

总结

this的绑定规则其实就是可以分成默认和隐式,隐式丢失是隐式一种,显示三种函数以及new另外处理。默认永远指向全局,隐式注意一定是对象体中引用函数而非调用函数。有人觉得this很难,我们直接不用不就可以了吗,this的作用太多了以至于你无法避免使用,就拿传参来说,this隐式传参,不需要显示地传参。另外this也是面试热点问题,面试官会让你聊聊this的指向问题,call,apply,bind三者的区别。


如果觉得本文对你有帮助的话,可以给个免费的赞吗[doge] 还有可以给我的gitee链接codeSpace: 记录coding中的点点滴滴 (gitee.com)点一个免费的star吗[星星眼]

本文转载自: 掘金

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

写给自己的前端性能优化

发表于 2023-11-07

一直想写篇前端性能相关的总结,个人觉得这块的内容会比较分散,面试的时候问起来,也不容易有一个清晰的框架,但是平时的习惯是想起来什么就写什么,所以攒了好久的内容只能躺在一堆笔记草稿里面;

—🚩🚩正文分割线🚩🚩—

按页面加载链路分类,从下面几个方面开始

  • 首屏加载
  • 代码优化
  • 构建工具

首屏加载

这部分其实就是把从获取资源到页面呈现中可以优化的点提取出来

1. DNS预解析

不需要用户点击链接就在后台解析,在head中添加

1
html复制代码<link rel="dns-prefetch" href="//example.com">

但是要注意会增加一定的网络请求和带宽消耗,非必要域名谨慎使用

2. 开启HTTP2

首先说下相对于HTTP1的优势

  • 多路复用,能够在单个TCP连接上同时传输多个请求和响应;HTTP1.1有一个可选的Pipelining技术,但它是按照顺序处理响应的,后发的请求可能被先发的请求阻塞,所以很多浏览器默认不开启。
  • 首部压缩,使用HPACK算法对请求和响应头部进行压缩,减少了首部大小,节省了带宽。而在HTTP/1.x中,每次请求都需要发送完整的头部信息,很容易造成不必要的带宽浪费。
  • 服务器推送,服务端可以在发送页面HTML,也就是客户端请求对应HTML页面时主动推送其它资源,而不用等到浏览器解析到相应位置,发起请求再响应。
  • 二进制分帧,使用二进制协议对数据进行分帧传输。二进制协议更高效,减少了解析数据的开销,并提高了传输速度。

在nginx中开启HTTP2

1
2
3
4
5
6
7
8
9
10
11
bash复制代码# 修改nginx.conf中的配置
server {
listen 443 ssl http2;
server_name example.com;

# SSL证书和密钥路径
ssl_certificate /path/to/ssl/cert;
ssl_certificate_key /path/to/ssl/key;

# 其他配置项
}

3. 资源的预加载

这个其实是安排资源以更高的优先级进行下载和缓存,更详细的可以看看MDN文档

1
2
html复制代码<link rel="preload" href="styles.css" as="style">
<link rel="preload" href="main.js" as="script" />

4. 动态创建加载脚本

不管是js还是css,在下载过程中其实都是会阻塞页面的,动态加载会在下载资源的同时,也不影响后续代码的执行

1
2
3
js复制代码const script = document.createElement("script");
script.src = "myscript.js";
document.body.appendChild(script);

5. 同构渲染

其实就是服务端渲染(SSR)+ 客户端渲染(CSR),服务端渲染的升级版,像现在的Nuxt.js或者Next.js就可以实现;

引用《vue.js设计与实现》的对比来更直观的了解同构渲染的特点

CSR SSR 同构
SEO 不友好 友好 友好
白屏问题 有 无 无
占用服务器资源 少 多 中
用户体验 好 差 好

6. 可见性优化

这部分主要是针对非可视区域进行延迟加载来减少首屏执行的逻辑

非可视区域

  • 延迟接口请求,使用setTimeout或者then函数来置后加载时机;
  • 图片懒加载,使用IntersectionObserver实现可视区域判断;

虚拟滚动

只加载上下及当前页的数据;可以通过滚动时分页或者vue-virtual-scroll-list及react-virtualized一类的插件实现;

7. 针对白屏/抖动

加载过程中无法避免的会有短暂白屏,

  • 骨架屏,可以选择固定灰色块,或者计算页面元素宽高生成灰色快;
  • loading,加入比较有意思的loading动画;
  • 定义宽高,图片或者接口数据在渲染到页面之后,会撑开所在的元素,就造成页面抖动,设置好盒子或者图片的宽高或者设置个占位;
  • 字体闪烁,加载字体且生效之前的闪烁,通过压缩字体减小资源体积,设置font-display:block来解决加载过程中的字体样式异常;

8. 静态资源

  • 合理使用协商缓存和强缓存及本地存储
  • 使用字体图标代替图片图标
  • 使用webp
  • 图片压缩
  • 使用cdn

代码优化

这部分只是列出来可优化点,感兴趣可以去搜索相关实现,建议只有出现明确的性能问题存在时,才进行优化

1. JS

  • 使用script的async和defer属性避免阻塞;
  • service worker,拦截网络请求,灵活的判断是否需要缓存资源;
  • Web Worker,创建一个新的线程,在一个独立的js环境中执行逻辑,不会阻塞后续逻辑的执行,针对耗时的计算任务或者执行时间比较久的逻辑处理;
  • 批量请求及事件任务切片;
  • 节流和防抖;
  • 事件委托;
  • 及时销毁闭包及定时器;
  • 缓存变量及dom属性;
  • 变量作用域的合理声明;

2. CSS

  • 回流属性放在一块集中修改;
  • 避免选择器嵌套过深;
  • 用CSS动画代替JS动画;
  • 使用伪元素简化html结构,如:before代替div;
  • 开启GPU加速,这个非必要不推荐开启;
  • 减少CSS类名查找范围,浏览器解析CSS遵循的是从右到左的查找规范,先找.b再找.a,将.wrap .a .b改为wrap .b;

3. Vue

  • v-show和v-if的合理使用,频繁更新显示状态使用v-show;
  • 使用keep-alive和v-once减少多余的更新渲染;
  • 通过Object.freeze移除双向绑定,减少不必要的数据监听;
  • 避免template中使用复杂的表达式;

4. React

  • memo,减少子组件的重复渲染,简单组件不会有太大的效果,并且会加大内存消耗;
  • useMemo,相当于Vue中的computed函数,所设置的依赖没有变化时,就会返回上一次的计算结果;
  • useCallback,避免重复创建函数;
  • 组件卸载清理,Class组件:componentWillUnmount,Function 组件:useEffect return
  • 使用React Fragment,减少额外节点的渲染;

构建工具

目前基本上使用vite,这里主要针对vite优化,webpack就简单带过

1. webpack

  • 指定模块解析范围,设置解析文件类型范围
  • webpack打包/构建缓存hard-source-webpack-plugin;
  • 资源的压缩,拆分、第三方包的提取合并(config配置optimization.splitChunks);

2. 摇树优化

就是在保证代码运行结果不变的前提下,去除无用的代码;其实Rollup会默认开启摇树优化,但需要是ES6 module模块,第三方包尽管可能使用esm版本,本身体积会更小,而且能有更好的压缩效果

1
2
3
4
5
js复制代码import { cloneDeep } from 'lodash'
// 改为
import { cloneDeep } from 'lodash-es'

const obj = cloneDeep({}) // 如果这行被注释,vite就不会再引入lodash包

删除线上的console和debugger,这个根据项目需求决定是否需要配置

1
2
3
4
5
js复制代码  {
esbuild: {
drop: ['console', 'debugger'],
}
}

3. gzip压缩

这个就是在客户端进行文件压缩,服务端直接调用

1
2
3
4
5
6
7
8
9
10
js复制代码import viteCompression from 'vite-plugin-compression';

viteCompression({
verbose: true,
disable: false, // 不禁⽤压缩
deleteOriginFile: false, // 压缩后是否删除原⽂件
threshold: 10240, // 压缩前最⼩⽂件⼤⼩
algorithm: 'gzip', // 压缩算法
ext: '.gz', // ⽂件类型
}),

nginx配置静态gzip压缩,会直接读取文件夹中.gz文件

1
2
3
4
bash复制代码# 修改nginx.conf中的配置
http {
gzip_static on;
}

Content-Encoding为gzip就表示设置成功了

1698390259660[1].jpg

4. 图片压缩

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
js复制代码import viteImagemin from 'vite-plugin-imagemin'

viteImagemin({
gifsicle: { // gif图片压缩
optimizationLevel: 3, // 选择1到3之间的优化级别
interlaced: false, // 隔行扫描gif进行渐进式渲染
// colors: 2 // 将每个输出GIF中不同颜色的数量减少到num或更少。数字必须介于2和256之间。
},
optipng: { // png
optimizationLevel: 7, // 选择0到7之间的优化级别
},
mozjpeg: {// jpeg
quality: 20, // 压缩质量,范围从0(最差)到100(最佳)。
},
pngquant: {// png
quality: [0.8, 0.9], // Min和max是介于0(最差)到1(最佳)之间的数字,类似于JPEG。达到或超过最高质量所需的最少量的颜色。如果转换导致质量低于最低质量,图像将不会被保存。
speed: 4, // 压缩速度,1(强力)到11(最快)
},
svgo: { // svg压缩
plugins: [
{
name: 'removeViewBox',
},
{
name: 'removeEmptyAttrs',
active: false,
},
],
},
})

5. 依赖分析

基本上都是用这种方式来查找不必要的依赖引用来减小包体积

1
2
3
4
5
6
7
8
9
10
11
js复制代码import { visualizer } from 'rollup-plugin-visualizer';

const command = process.env.npm_lifecycle_event

{
plugins: [
command === 'report' ?
visualizer({ open: true, brotliSize: true, filename: 'report.html' })
: null
]
}

6. 提取依赖

1
2
3
4
5
6
7
8
9
10
11
js复制代码build: {
rollupOptions: {
output: {
manualChunks: {
'vue-vendor': ['vue', 'vue-router', 'pinia'],
'echarts': ['echarts'],
'lodash': ['lodash'],
},
},
},
}

最后

从整体架构方面还可以做下面几件事;

  • 组件增加权重,针对单个组件,给组件添加权重值,针对权重大的组件优先展示;
  • 按机型加载资源,根据当前系统版本、机型配置做不同的资源加载,动画交互降级;
  • 微前端 拆分应用,剥离业务,减少业务之间的关联影响,使用micro-app或者qiankun;

最后优化是有成本的,也需要根据场景决定是否进行优化

传送门

万字长文:分享前端性能优化知识体系

本文转载自: 掘金

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

面试官真烦,问我这么简单的js原型问题(小心有坑)

发表于 2023-11-06

js原型是个很难的知识点,跟js的闭包,包装类类似,都是十分干货且难点很多的知识点,不过,笔者我尽量站在小白的角度用自己的话术带你理解透彻这个难点,面试题在文章末尾,为了帮助大家清楚面试官在问什么,我需要先把原型讲清楚来。

原型

原型分为函数原型和对象原型,但是如果我们直接说原型一般都是默认为函数原型。

函数原型

函数原型是一种特殊的对象,它是用来定义函数的属性和方法,并且这个属性和方法是可以公用的。每个函数都有一个默认原型对象。并且这个对象包含了一个自带的属性,constructor属性,它指向了函数自身。这句话现在看不懂没关系,待会儿再进行解释。

tom_疑惑1.png

这么解释很抽象,并且听得云里雾里。

我给大家看个例子

1
2
3
4
5
6
7
ini复制代码function Person(){
this.name = "小黑子"
this.age = 18
}
let p1 = new Person()
let p2 = new Person()
console.log(p1 === p2);

这里p1和p2都是实例化对象,你们说这两个p相同吗?我在上一篇文章(此处有一个上一篇文章的链接)讲过,对象是存放在堆中的,存在调用栈中的对象仅仅是个地址而已,这里p1和p2是不同的对象实例,尽管他们的属性值相同。但是他们的地址不同,所以p1和p2不同,输出false。

我们引入原型

1
2
3
4
5
6
7
8
9
10
11
12
javascript复制代码Person.prototype.say = function(){
return 'Hello'
}
function Person(){
this.name = "小黑子"
this.age = 18
}
let p1 = new Person()
let p2 = new Person()
console.log(p1);
console.log(p1.say())
console.log(p1.say() === p2.say())

大家可以先猜一下这个输出结果,猜完之后再看下下面的答案,然后看我的分析。

Person.prototype就是一个原型,函数都会有一个原型,也就是函数.prototype,这个原型既然可以存放键值对,那么他就一定是一个对象(这里存放的是一个函数,函数是一个特殊的对象),我们管构造函数的原型称为显示原型,前面也有介绍,并且他一般是公共属性,这里我们没有体现出来,稍后我们再引入一个例子理解公共属性。如果你打印p1或者p2,他只能给你打印构造函数体内的属性,啊?这是为什么,我们都给这个构造函数添加了公共属性,难道没有吗,我们再来看第二个输出,p1.say(),这个是可以打印出来的。发现没有,我们打印p1不会打印出原型的属性方法,我们管这个叫做隐式继承,像是构造函数体中this.name是显示继承,this就是对应的实例对象。所以这里输出结果为

1
2
3
yaml复制代码Person { name: '小黑子', age: 18 }
Hello
true

我们再看一个例子理解原型是公共属性

1
2
3
4
5
6
7
8
9
10
ini复制代码function Car(owner, color){
this.name = "Volvo"
this.lang = 500
this.height = 555
// 上面的属性就是公共的
this.owner = owner
this.color = color
}
var Car1 = new Car('小黑子', 'pink')
var Car2 = new Car('大黑子', 'black')

我们每次实例化对象的时候,都会重复地把name、lang、height读取一遍,是不是感觉很没必要,浪费内存!所以我们的原型就解决了这个问题,我们把这些重复的属性放在原型中去,这样可以减少内存消耗。

1
2
3
4
5
6
7
8
9
10
11
12
13
ini复制代码Car.prototype.name = 'Volvo'
Car.prototype.lang = 500
Car.prototype.height = 555

function Car(owner, color){
// this.name = "Volvo"
// this.lang = 500
// this.height = 555
this.owner = owner
this.color = color
}
var Car1 = new Car('小黑子', 'pink')
var Car2 = new Car('大黑子', 'black')

既然是公共属性,我们是否可以对其进行操作,我们下面可以试试看

1
2
3
4
5
6
arduino复制代码Car1.name = 'BMW'
console.log(Car1.name)
console.log(Car2.name)
输出结果
BMW
Volvo

输出结果合情合理,你买了车改你的车标,我买的车的车标肯定不会受影响。

我们可以把构造函数Car比作一个车厂,原型相当于总部,我们私下买了车可以对车进行改装,但是别人买的车肯定还是原来的样子。如果我们就是想要从车厂买到BMW的车,我们怎么改呢,如下,直接去总部更改

1
ini复制代码Car.prototype.name = 'BMW'

同样,我们实例对象改动不原型的属性,要改得原型自己改,要删得原型自己删

收回开头函数原型对象的属性constructor,这个单词是创建者的意思,每个函数原型(对象)都有一个constructor属性,并且他指向函数自身。这里大家可以去浏览器控制台试试看,当你实例化一个car的时候,在输入这个实例对象的名字,他会返回一个Car{},你可以把他展开,里面会有一个原型,原型也可以展开,里面有个constructor属性,这个属性的内容就是构造函数本身。这里非常绕,大家一定要沉下心来理解,可以去浏览器自己实践下看,或者可以看下这里

1
2
3
4
javascript复制代码function Car(){

}
console.log(Car.prototype.constructor);

输出

1
csharp复制代码[Function: Car]

既然如此,constructor是个属性并且值为构造函数本身,我们是否可以进行更改呢?比如我再来个构造函数Bus,让Car的原型中的constructor值变成Bus这个函数体可以吗?我们下面试试

1
2
3
4
5
6
7
8
9
10
javascript复制代码function Bus(){

}
Car.prototype = {
constructor: Bus
}
function Car(){

}
console.log(Car.prototype.constructor);

输出结果

1
csharp复制代码[Function: Bus]

居然是可以强行更改的,神奇。像是这种东西我们知道了就好,一般不会去改动这玩意。

对象原型

对象原型指的是对一个构造函数实例化之后出现的对象,它也具有一个原型,这个原型我们称之为隐式原型,并且这个原型它能够继承构造函数中显示原型的属性和方法,所以实例对象的隐式原型 等于 构造函数的显示原型(这其实是new导致的),并且当你访问这个实例化对象属性的时候,他会先找对象显示具有的属性(也就是构造函数体内的属性),找不到再去对象的隐式原型,而这个隐式原型就是构造函数的显示原型。隐式原型为了区分显示原型,我们用__proto__表示。

我们看下下面代码就可以理解上面的内容了

1
2
3
4
5
6
7
8
9
javascript复制代码Person.prototype.say = function(){
console.log("hello " + this.name);
}
function Person(name){
this.name = name;
}
var person1 = new Person("Dolphin")
person1.say()
console.log(person1.__proto__ === Person.prototype)

输出结果

1
2
arduino复制代码hello Dolphin
true

在理解这个代码之前我们需要再来认识下this这个东西,上篇文章说过,new的作用就是先创建一个this对象(其属性和方法就是构造函数中的),然后再返回this这个对象。根据这些知识我们只知道this是个对象。我们现在看下这个语句的第一个输出,为什么构造函数原型也能访问到this?原来原型中this也是指向了调用函数的对象,第一个输出结果我们可以理解了。第二个呢?这里就是应证了实例对象的隐式原型等于构造函数的显示原型。

原型链

其实原型链我已经在上面的隐式原型讲过了,原型链其实就是顺着实例对象的隐式原型不断地向上查找,直到找到匹配的属性或方法,隐式原型(__proto__)就是中间的枢纽。

关于原型链有个非常经典的图。这里我也把它贴出来

原型链.jpg

这个图怎么看呢,其实我们只需要知道从f1顺着箭头方向到Object对象就可以了,这个箭头就是隐式原型。我带大家来分析下。f1是Foo()构造函数的实例化对象,假设我们现在需要找到say这个属性,我们需要先去构造函数体内找,发现没有我们就去我们自身的隐式原型中找,隐式原型就是构造函数的显示原型,所以有了f1.__proto__ == Foo.prototype,Foo.prototype就是Foo函数的原型,Foo.prototype显示原型中有个constructor属性,指向构造函数本身,所以有了右上角连接正左那个关系。这里都是上文就讲过的,完全可以理解。

接下来就是未知领域。Foo.prototype是不是一个对象(含有constructor属性),既然是个对象,他又是谁new出来的呢?这里别晕了啊,我这句话的意思就相当于f1是一个对象,他是由Foo这个构造函数创建的,那我现在这里的Foo.prototype又是谁创建的呢,这里不同于constructor,constructor只是这个对象的一个属性罢了。大家肯定没有思考过这个问题,其实构造函数的原型对象是由构造函数Object创建的,所以它的隐式原型就是Object,这个东西是万物之主!这张图你也可以看出,很多东西的隐式原型都是Object,图中其余部分我就不解释了,你看图是可以理解的,如果还是不懂欢迎留言我帮你解答。所以我现在找f1的属性找到object来了,这个object自身是没有内容的,所以他就依靠自己的隐式原型,但是自己的隐式原型是null值,根本就没有。最终返回undefined,肯定又有人要疑惑了,既然找不到还不报错吗,对!对象就是这么特殊,js这门语言就是这样设计的,它允许你在对象中执行动态属性查找而不导致程序崩溃。之前的文章讲查找并没有深入如此底层,现在大家应该明白了。

我们再给几个例子帮助大家加深理解

1
2
3
4
5
6
7
8
9
10
javascript复制代码    Person.prototype.name = '小黑子'
function Person(){
// new的作用
// var this = {
// __proto__: Person.prototype
// }
// return this
}
var p = new Person()
console.log(p);

这个代码最终打印出名字属性是因为构造函数体内没有,于是去自己的隐式原型中找,隐式原型就是构造函数的显示原型,所以这个new的作用就相当于在函数体内创建了个this对象,然后this对象存入隐式原型,隐式原型要继承构造函数的显示原型,最后return this。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
javascript复制代码    Ground.prototype.lastName = '蔡'
function Ground(){

}
var Ground = new Ground()

Father.prototype = Ground
function Father(){
this.name = '徐坤'
}
var father = new Father()

Son.prototype = father
function Son(){
this.hobbit = 'playing'
}
var son = new Son()
console.log(son.name);
console.log(son.lastName);

这个例子我也解读下,大家可以自己先思考,整个查找过程,我这里的例子命名也非常到位。

第一个语句,需要找到son的name属性,于是我去构造函数Son体内找,没找到,于是去son的隐式原型中找,son的隐式原型就是Son的显示原型,这里发现Son的显示原型是一个实例对象father,于是我们就去father的构造函数Father体内找,这才找到name,这里还是强调一点,只要是在原型链上,this一定是指向了实例对象找到后,输出,第二个输出同理,就不赘述了。

我们再来看一个难点的

1
2
3
4
5
6
7
8
9
10
11
javascript复制代码Person.prototype = {
name: "小黑子",
sayName: function(){
console.log(this.name);
}
}
function Person(){
this.name = '大黑子'
}
var p = new Person();
p.sayName()

这个会是什么样的输出呢,大家先思考思考在看我的解析。

实例对象p需要找到sayName这个属性,构造函数体内没找到,去隐式原型中找,也就是构造函数的显示原型,里面确实是有个sayName,于是调用这个函数,发现打印this.name,这个this刚刚说了,只要是在原型链上,this一定指向了实例对象,所以,我们又要回到实例对象身上找name属性,第一步,构造函数体内中找,找到!所以这里输出大黑子。如果我们把构造函数体内的name属性去掉,那么接下来就会去隐式原型中找,也就是构造函数的显示原型,找到!那样就会输出小黑子。如果都没有呢,那么接下来就会去到构造函数显示原型的隐式原型中找,也就是Object中找,Object自身没有于是求助自身的隐式原型,Object.__proto__为null,还是没找到,最终输出undefined。

接下来又是一个高能知识点。

toString()哪来的?

我们都知道,原始数据类型中数字,字符串,布尔都可以调用toString这个属性,那问题来了,这里的底层逻辑是什么样的?我们不妨去浏览器的控制台中输入下面这段代码

1
javascript复制代码Number.prototype

大家不要在node中运行这个,node只会返回一个空对象,无法展开看到里面的属性。

给大家看下浏览器中展开后里面是什么

1.png

啊?妙啊!原来这些基本数据类型的包装对象中的原型有这么多方法,里面就包含了我们经常使用的toString方法。所以我们执行下面的代码

1
2
vbscript复制代码let num = 123
console.log(num.toString());

就等同于

1
2
vbscript复制代码let num = new Number(123)
console.log(num.toString())

我们需要去num实例对象中找这个toString方法,去到了Number体内中找,啥也没有,就去到了num自身的隐式原型中找,也就是构造函数Number中的显示原型找,而这个Number.portotype如下

1
2
3
4
5
6
7
8
9
lua复制代码constructor: ƒ Number()
toExponential: ƒ toExponential()
toFixed: ƒ toFixed()
toLocaleString: ƒ toLocaleString()
toPrecision: ƒ toPrecision()
toString: ƒ toString()
valueOf: ƒ valueOf()
[[Prototype]]: Object
[[PrimitiveValue]]: 0

里面就有我们需要的toString方法,简直是Amazing啊!

精彩.png

同理,大家也可以自行去浏览器试试String和Boolean这两个的显示原型,看看展开后里面有什么,String里面一大堆东西。

接下来聊聊之前上期文章留下的坑——create创建对象

create方法创建对象

我们来看下它的用法

1
2
3
4
5
6
7
javascript复制代码let obj = {
a:1
}
// 必须接收一个参数,应该是一个对象
let obj1 = Object.create(obj)
console.log(obj1)
console.log(obj1.a)

输出结果

1
2
复制代码{}
1

从这个输出结果来看我们就会发现这个方法创建的对象跟我们上面聊的函数显示原型如出一辙。其实就是一样的,等同于下面

1
2
3
4
5
6
7
8
9
javascript复制代码Foo.prototype = {
a: 1
}
function Foo(){

}
let f = new Foo()
console.log(f)
console.log(f.a)

因此我们可以总结出create这个方法创建出来的新对象是作为新对象的原型,并且create()括号中是对象,放原始数据类型会报错

收回标题!

面试官:所有的对象最终都会继承自Object.__proto__?

我寻思我终于把原型的底层逻辑给学明白了,你问个这么简单个问题是什么意思!瞧不起我吗,对象不都是最终指向Object.__proto__。所以肯定是对的啊!

实则答案却是错的,因为null除外!

啊,null不是原始数据类型吗!他怎么也算进对象中去了?

这其实是个bug,算是个js历史遗留问题。原来js判断一个数据类型是先把数据转换成二进制,对象这个东西的二进制前三位是000,其他数据类型不是,当然,除了null,当初js设计师老爷子给null的二进制全部设置成了0,所以在js眼里,null被错误的当成了对象。

大家可以在浏览器控制台中看下空对象和null对象两个的区别。

1
2
3
4
5
ini复制代码let obj = {

}
obj
// 输出{},展开后为[Prototype]: Object

2.png

这里合情合理,只要是对象,哪怕是空的,也会有原型,并且也会继承Object

1
2
3
4
javascript复制代码let obj1 = null
let obj2 = Object.create(obj1)
obj2
// 输出{},展开后显示无属性

3.png

其实在介绍用法的时候那段代码就有个线索,create里面的参数我写成了“应该”为对象,如果你看得很仔细那个时候就会很疑惑,怎么可以用应该二字!

所以我们create参数只能放对象和null,否则报错。

同样,如果你用typeof测null,也会当成对象

4.png

今天的内容分享就结束了,主要讲了原型这个东西,我讲的会比较广,发散性高,希望对大家有所帮助,也希望各位可以点个关注,有意思的内容第一时间获取。


如果觉得本文对你有帮助的话,可以给个免费的赞吗[doge] 还有可以给我的gitee链接codeSpace: 记录coding中的点点滴滴 (gitee.com)点一个免费的star吗[星星眼]

本文转载自: 掘金

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

古茗打印机技术的演进

发表于 2023-11-06

作者:蔡钧

一句话概括一下热敏打印机:通过温度控制打印内容。

不要在碰到一些小票打印出来模糊的时候说没墨了,他根本没有墨!核心在纸,这就是为什么我们点外卖或者堂食给到我们的小票用指甲划一下就能变黑。

热敏打印机原理

热敏打印技术的关键在于加热元件,热敏打印机芯上有一排微小的半导体元件,这些元件排得很密,从200DPI到600DPI不等,这些元件在通过一定电流时,会很快产生高温,当热敏纸的涂层遇到这些元件时,在极短时间内温度就会升高,热敏纸上的涂层就会发生化学反应,显现色彩。

这个涂层的主要成分是双酚A,学名2,2-二(4-羟基苯基)丙烷,简称BPA。

插句话,这个玩意儿属低毒性化学物,比较有争议性。2011年5月30日,卫生部等6部门对外发布公告称,鉴于婴幼儿属于敏感人群,为防范食品安全风险,保护婴幼儿健康,禁止双酚A用于婴幼儿奶瓶。

热敏纸一般主要由三部分组成:防护层、变色层、纸基。有单防、三防、五防等等。。古茗的标签用的是三防(指甲划过不留痕),票据用的单防(指甲划过留痕)

快速浏览指南

古茗打印技术经历了5次迭代,就以人类发展史来类比:石器、青铜、农业、工业、信息时代。所有代码已精简。

干货较多,快速浏览可以只看

  1. 背景
  2. 各个时代的打印效果和结论
  3. 我对这个行业的一些吐槽

背景

门店收银机上来订单的时候需要打印出对应的小票和杯贴。

设备

门店收银机:windows系统,在出厂时已经安装好小票(票据)和杯贴(条码)打印机驱动。

门店打印机:门店有两台打印机,连接方式99%是usb,剩下1%是网线连接。

系统

开发框架:electron

结构

打印方案落地

石器时代(驱动打印)

需求:

能打印就行

方案:

采用electron直接调用驱动打印,提前准备好打印模板文件(React class组件),用umd打包,模板在render层生成。

链路:

收到打印信息 -> printer进程开始拉取对应模板 -> 结合socket消息的数据结合模板生成真实模板 -> 调用打印方法

实践:

1. 收到消息后给printer进程发送打印消息
1
kotlin复制代码this.window.webContents.send(ipcEvent.SET_PRINT_DATA, printData, traceId);
2. printer进程接受消息后开始渲染模板,渲染完成后让main执行打印动作(EXEC_PRINT)
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
typescript复制代码import { useEffect, useState } from 'react';
import AsyncComponent from './AsyncComponent'

interface PrintData {
templateUrl: string // 模板
data: PrintDataInfo // 数据
deviceInfo: PrintDeviceInfo // 打印机相关信息
}

export default () => {
const [templateUrl, setTemplateUrl] = useState('');
const [templateProps, setTemplateProps] = useState({});

useEffect(() => {
ipcRenderer.on(ipcEvent.SET_PRINT_DATA, (
_e: Electron.IpcRendererEvent,
printData: PrintData,
traceId: TraceID
) => {
const { templateUrl, data, deviceInfo } = printData;
setTemplateUrl(templateUrl);
setTemplateProps(() => data);
// 在子组件渲染完毕后执行打印方法,先用一个setTimeout模拟一下
setTimeout(() => {
ipcRenderer.send(
ipcEvent.EXEC_PRINT,
deviceInfo.deviceName,
traceId
);
}, 200)
});
})

return templateUrl ? (
<AsyncComponent
data={data}
templateUrl={templateUrl}
/> : null
)
}
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
javascript复制代码import loadjs from 'loadjs';
import React, { useEffect, useState } from 'react';

export default ({ templateUrl, data = {} }) => {
const [Comp, setComp] = useState<React.ReactNode | null>(null);

useEffect(() => {
// 传入的templateUrl为D:/XiaoPiao/XiaoPiao.js
const name = templateUrl.split('/')[1];

const componentName = `Micro_${name}`; // umd打包规则是前面加上前缀 Micro_

if (window[componentName]) {
setComp(() => window[componentName]);
return;
}

// 通过loadjs动态加载js文件
loadjs(`${name}?timespan=${Date.now()}`, {
success: () => {
setComp(() => window[componentName]);
},
error: (error) => {
console.log(error);
},
});

return () => {
window[componentName] = null;
};
}, [templateUrl]);

return Comp ? (
<Comp
data={data}
/>
) : null;
};
3. main执行打印方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
dart复制代码ipcMain.on(ipcEvent.EXEC_PRINT, (_, deviceName, traceId) => {
this.window.webContents.print(
{
silent: true,
printBackground: true,
copies: 1,
deviceName
},
(success, failReason) => {
logger.info(Lable.打印, traceId, deviceName, '打印结果', success, failReason);
if (success !== true) {
logger.error(Lable.打印, '', '打印失败', '驱动打印异常', failReason);
}
}
);
});

效果

)

问题

  • 但是效果不佳,杯贴打印机会偏移、打不全,需要修改驱动中的卷、浓度等参数才能打印正常
  • 打印模糊
  • 打印速度很慢,从点击打印到真实打出来需要隔好几秒

结论

驱动打印不行。

在前期的门店测试中,发现这个打印方式并不好,我们需要手动对门店的打印驱动进行设置才能让打印正常,如果全国所有门店都需要这样操作过一次投入的人力成本会很大,而且驱动打印的打印速度慢影响门店经营。

青铜时代(文字指令打印)

需求:

无需人工配置驱动参数,提高打印速度。

方案:

通过node-escpos-win(npm包)获取并发送数据给打印机,通过escpos(npm包)生成16进制数据,提前准备cjs模板,模板在node层生成。

链路:

收到打印信息 -> printer进程拉取cjs模板 -> 结合socket消息的数据结合模板生成16进制数据 -> 发送数据到打印机。

知识点:

票据ESC/POS指令, 条码TSPL指令。

实践:

1. 收到消息后主进程拉取模板并生成数据
1
2
3
4
5
6
7
8
9
10
11
javascript复制代码const getPrinterBuffer = async (printData, traceId) => {
const { templateUrl, data } = printData;
// templateUrl: D:/XiaoPiao/XiaoPiao.js
const command = require(templateUrl);

const buffer = await command({
data
})

return buffer
}
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
typescript复制代码const command = async (data => {
const cmd = require('escpos');

const p = new cmd.Printer(
"",
{
encoding: "gbk",
width: 48,
}
);

// 编写模板
p.newLine();
p.size(2, 2).align("lt").text(`${data.number}号 ${data.type}`);
p.size(1, 1);
p.text(data.shopName).text(data.time);

// ...中间有一大坨模板相关代码

// 执行切纸
p.cut()

return p.buffer._buffer;
})

module.exports = command;
2. 将buffer推送给指定的usb设备,由于我们只知道从哪台驱动打印机出,所以要从驱动打印找到端口再找到正确usbpath
  • )

利用wmic可以获取到打印机驱动对应的端口以及usb设备的端口

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
typescript复制代码// 获取打印机驱动的信息
const getPrinter = () => {
const wmic = require('wmic-js');
return new Promise<Win32Printer.Printer[]>((res, rej) => {
wmic()
.alias('printer')
.get('Name', 'printerState', 'printerStatus', 'WorkOffline', 'PortName')
.then((data: Win32Printer.Printer[]) => {
res(data);
})
.catch((err: unknown) => {
rej(err);
});
});
}

// 获取每个端口上的usb设备是什么
// 这个命令会包含usbprint设备而上一条就是这个usb设备的usbPath

const printPortMap = () => {
return new Promise<Record<string, string>>((res, rej) => {
exec('wmic path Win32_USBControllerDevice get Dependent /format:list', (err, stdout) => {
if (err) {
rej(err);
return;
}
const usbList: string[] = [];
const map: Record<string, string> = {};
const lines = stdout.split('\r\r\n');
lines.forEach((line) => {
if (line.startsWith('Dependent=')) {
const usb = line.replace('Dependent=', '');
usbList.push(usb);
}
});
for (let i = 0; i < usbList.length; i++) {
if (usbList[i].indexOf('USBPRINT') > -1) {
const line = usbList[i].replace(/"/g, '');
const portName = line.substr(line.length - 6);
const usbPath = usbList[i - 1].replace(/&amp;/g, '&');
if (portName.indexOf('USB') > -1) {
map[portName] = usbPath;
}
}
}
res(map);
});
});
};

// 打印方法
const print = async (buffer, deviceName, traceId) => {
const printList = await getPrinter();
const portMap = await printPortMap();
const escpos = require('node-escpos-win');

// 这里获取到的usbList里就会有跟portMap中usbPath一样的设备
const usb = escpos.GetDeviceList('USB');
const usbList = usb.list.filter(
(item) => item.service === 'usbprint' || item.name === 'USB 打印支持'
);

printList.forEach(item => {
if (item.name === deviceName) {
const usbDevice = usbList.find(item => {
return item.path.indexOf(portMap[item.portName]) !== -1
})
const res = escpos.Print(usbDevice.path, buffer);
logger.info(String(res), traceId);
escpos.Disconnect(usbDevice.path);
}
})
}

效果

)

结论

指令打印好啊,太好了,打印清晰、流畅、速度快,而且不需要配置打印机驱动。

问题就是只能打宋体,而且字体大小都是预设好的没办法调整,不过至少能保证门店正常经营了,先推吧~

农业时代(图片指令打印)

插曲:

全国门店都替换完指令打印这套方案后的一段时间。。。

(敲桌子)”你们这个东西也太丑了吧,谁设计的站出来”

我站了起来:”这个打印机打出来就是这样子的,你不信你看外卖点的单子是不是都长这样”

(更加愤怒的敲桌子)”这么丑,品牌形象都没了,改!”

我死死的盯着他:”改!就!改!”

背景:

指令打印太丑,并且业务方想在”上新“、”季节性活动“时增加品宣信息

方案:

ESC/POS、TSPL均采用图片指令的形式,提前准备cjs模板,模板在render层生成

链路:

收到打印信息 -> render进程拉取cjs模板 -> 结合socket消息的数据用canvas绘制 -> 将图片处理成打印机需要的格式 -> 发送数据到打印机

前置知识:

打印机如何打印图片。

票据打印机的指令和条码打印机的指令对于打印图片的格式要求基本都相似,所以就举例其中之一进行讲解。

看看ESC/POS指令的文档

)

。。看不懂,干脆直接试试好了,从如何打印一个像素的小黑点开始。

注意到x的最小单位是字节数,而一个字节等于8个比特也就是说如果其实我能一次性控制8个点的打印。

所以打印一个小黑点的指令就得出是:1D 76 30 00 01 00 01 00 80

所以按这个公式理论上可以在一张小票纸上的任意位置打出黑点。ok那么开始实践

实践:

1. 还是从打印一个黑点开始,用到get-pixels这个库可以获取到图片的宽高以及每个像素点的rgba。提前准备好一张宽高均为1px的全黑图片”dot.png”。
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
ini复制代码// 目标是得到Buffer 1D 76 30 00 01 00 01 00 80

const escpos = require('node-escpos-win');
const getPixel = require('get-pixels');

const usb = escpos.GetDeviceList('USB');

const list = usb.list.filter((item) => item.service === 'usbprint' || item.name === 'USB 打印支持');

const printer = list[0];

getPixel('./dot.png',(err, { data, shape }) => {
// data: [0, 0, 0, 255]
// shape: [1, 1, 4]
const imgData = rgba2hex(data, shape);
const width = shape[0];
const height = shape[1];
const xL = Math.ceil((width / 8) % 256); // 1
const xH = Math.floor((width / 8) / 256); // 0
const yL = height % 256; // 1
const yH = Math.floor(height / 256); // 0
const buffer = Buffer.from([0x1d, 0x76, 0x30, 0, xL, xH, yL, yH, ...imgData]);

const res = escpos.Print(printer.path, buffer);
})

const rgba2hex = (arr, shape) => {
const bitArr = [];
for (let i = 0; i < data.length; i = i + 4) {
if (i[3] === 0) {
bitArr.push(0);
continue;
}
// 计算平均值判断
const bit = (data[i] + data[i + 1] + data[i + 2]) / 3 > 160 ? 0 : 1;
bitArr.push(bit);
}
// bitArr: [1]
// 对bitArr做补0的动作
const newBitArr = [];
const width = shape[0];
const isNeed = width % 8 !== 0;
const height = shape[1];
if (isNeed) {
for (let i = 0; i < height; i++) {
newBitArr.push(...bitArr.slice(i * width, (i + 1) * width));
for (let j = 0; j < 8 - (width % 8); j++) {
newBitArr.push(0);
}
}
} else {
newBitArr = bitArr;
}
// newBitArr: [1, 0, 0, 0, 0, 0, 0, 0]
const byteArr = [];
for (let i = 0; i < newBit.length; i = i + 8) {
const byte =
(newBit[i] << 7) +
(newBit[i + 1] << 6) +
(newBit[i + 2] << 5) +
(newBit[i + 3] << 4) +
(newBit[i + 4] << 3) +
(newBit[i + 5] << 2) +
(newBit[i + 6] << 1) +
newBit[i + 7];
byteArr.push(byte);
}
// byteArr: [128] = [0x80];
return new Uint8Array(byteArr);
}

成功打印了但是像素太小,所以另外准备一张图片试试并且

贴心的给不在工位的同事贴上

)

后面考虑到图片也是我们自己生成的,所以只要提前保证图片的宽度像素是8的倍数就行,能省去”补0”的操作。

2. 模板文件通过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
ini复制代码// 因为是在electron里,所以渲染进程可以用cjs

// 票据模板的高度是动态变化的,所以用一个简单粗暴的方式,计算高度,然后再渲染

module.exports = (data) => {
const canvas = document.createElement("canvas");
canvas.width = 576;
let canvasY = 0;
const drawList = [];
const headerY = canvasY;
drawList.push(() => {
ctx.font = "40px sans-bold";
ctx.fillStyle = "#231815";
ctx.fillText(data.number, 0, headerY + 52);
});

canvasY += 64;

const bodyY = canvasY;
drawList.push(() => {
ctx.font = "24px sans-bold";
ctx.fillStyle = "#231815";
ctx.fillText(data.shopName, 0, bodyY + 24);
});

canvasY += 24;

// ... 一大堆画画代码

canvas.height = canvasY;
ctx.fillStyle = "white";
ctx.fillRect(0, 0, 576, canvasY); // 将背景设置成白色

for (let i = 0; i < drawList.length; i++) {
drawList[i]();
}

return canvas.toDataURL();
}
3. 在主进程中获得渲染后的base64进行打印即可
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ini复制代码// 渲染进程中
const command = require(templateUrl);
const buffer = command(data);

// 将buffer交给主进程处理
const getPixel = require('get-pixels');
getPixel(buffer,(err, { data, shape }) => {
const imgData = rgba2hex(data, shape);
const width = shape[0];
const height = shape[1];
const xL = Math.ceil((width / 8) % 256);
const xH = Math.floor((width / 8) / 256);
const yL = height % 256;
const yH = Math.floor(height / 256);
const buffer = Buffer.from([0x1d, 0x76, 0x30, 0, xL, xH, yL, yH, ...imgData]);

const res = escpos.Print(printer.path, buffer);
})

效果

)

结论

图片指令打印行。

通过这个打印方式可以控制这张纸上能被热敏头接触到的任意地方都能打印出自己想要的信息,所以可以说是最好的打印方式,但唯一不足的是打印速度由于数据量的增加所以稍微慢了一点点但在接受范围内,打印方案以此为终点。

不过我们依然保留了驱动和文字指令两种打印方式,以便在出现门店出现问题时快速切换保证打印的正常。

tip:某些打印机厂商的打印机没有好好实现规范于是按上述的图片指令打印方案图片高度超过一定程度就会出现乱码,需要把这张图片拆分成多张小图片才能正常打。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
ini复制代码// 渲染进程中
const command = require(templateUrl);
const buffer = command(data);

// 将buffer交给主进程处理
const getPixel = require('get-pixels');
getPixel(buffer,(err, { data, shape }) => {
const imgData = rgba2hex(data, shape);
const width = shape[0];
const height = shape[1];
const xL = Math.ceil((width / 8) % 256);
const xH = Math.floor((width / 8) / 256);
const buffer = [];
for(let h=0;h<height;h++) {
buffer.push(...[0x1d, 0x76, 0x30, 0, xL, xH, 1, 0, imgData.slice(i*(xL + 256*xH), (i+1)*(xL + 256*xH))]);
}

const res = escpos.Print(printer.path, Buffer.from(buffer));
})

工业时代(打印机全配置化)

背景:

业务方会以高频率来修改模板其中的品宣内容,并且需要根据区域或指定门店进行宣传,门店打印机种类繁多,需要对打印进行设置

方案:

打印模板建站(电子菜单相同方案,直接CV)

业务流程:

运营人员新建打印模板 - 在打印模板建站页里对模板调调改改 - 生成模板配置进行模板资源快照(rollup编译,编译完成后上传资源到oss) - 下发资源给门店 - 门店生效

相关代码:

对模板项目的改造,核心是给本次编译传入templateConfig生成js文件

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
javascript复制代码import replace from "@rollup/plugin-replace";

const templateMap = {
"LABEL": "BiaoQian",
"TICKET": "XiaoPiao",
};

const template = process.env.template;
const templateConfig = process.env.templateConfig || "null";
const buildEnv = process.env.ENV || "dev";
const token = process.env.Token;

const buildTemplate = template
?.split(",")
?.map((item) => templateMap[item])
?.join("|");

if (!buildTemplate?.length) {
throw new Error("请指定打包模板");
}

const files = glob.sync(
`./src/package/@(${buildTemplate})**/index.@(tsx|ts)`,
{}
);

const hash = `/${Math.random().toString(36).substring(2, 20)}/`;

const entryName = file.slice(14, file.lastIndexOf("/"));

export default {
return {
input: { [entryName]: file },
output: {
sourcemap: true,
format: file.indexOf("tsx") > -1 ? "umd" : "cjs",
dir: `dist${hash}${entryName}`,
entryFileNames: `${entryName}.js`,
name: `Micro_${entryName}`,
},
plugins: [
replace({
"process.env.templateConfig": templateConfig,
})
]
};
}
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
ini复制代码module.export = (data, printConfig) => {
const templateConfig = process.env.templateConfig || {
logoUrl: "",
brandUrl: "",
/// ...
};

const canvas = document.createElement("canvas");
// ...原本的模板代码

// 真实生成的图片可以通过传入的打印设置进行位置&大小调整
const { offsetX, offsetY, width, height } = printConfig;

const scaleCanvas = document.createElement("canvas");
scaleCanvas.width = printConfig.width;
scaleCanvas.height = printConfig.height;
const scaleCtx = scaleCanvas.getContext("2d");
// 白色背景
scaleCtx.fillStyle = "white";
scaleCtx.fillRect(0, 0, scaleCanvas.width, scaleCanvas.height);
scaleCtx.drawImage(
canvas,
offsetX + 8,
offsetY,
scaleCanvas.width,
scaleCanvas.height
);

return scaleCanvas.toDataURL();
}

结论:

通过打印模板建站释放了开发人员,后续只需要在建站页上进行迭代即可,日常相关的改动都可以由运营同学完成,并且不影响门店对打印机的设置。

信息时代(打印监控)

背景:

门店的收银机和打印机的连接存在不稳定的情况,例如USB端口松动、usb线老化、打印机老化等等原因可能导致门店的连接不稳定出现漏单、乱码等情况出现。

方案:

与打印机供应商协调开发,在USB通道上增加消息通信完成软硬件之间的打印监控,以小票举例,以切纸指令为结束符,打印机执行后回一个完成消息。

链路:

查询打印机版本是否支持 - 若支持开启监控能力 - 每次打印结束后以切纸为结束约定 - 打印机执行切纸指令后在usb通道上返回一个消息 - 判断消息监控打印机是否打印完成

实践:

找了一圈都没发现node有什么库可以比较方便的跟USB设备做通信,于是决定自己撸一个。

1. 采用rust编写相关通信代码,用napi-rs打包成node能直接require的模块。
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
rust复制代码#![deny(clippy::all)]

use std::ffi::CString;
use napi::bindgen_prelude::Buffer;
use std::mem::zeroed;
use std::ptr::null_mut;
use winapi::shared::minwindef::{DWORD, FALSE, TRUE};
use winapi::shared::ntdef::NULL;
use winapi::shared::winerror::{ERROR_IO_INCOMPLETE, ERROR_IO_PENDING};
use winapi::um::errhandlingapi::GetLastError;
use winapi::um::fileapi::{CreateFileA, ReadFile, WriteFile, OPEN_EXISTING};
use winapi::um::handleapi::CloseHandle;
use winapi::um::ioapiset::GetOverlappedResult;
use winapi::um::minwinbase::OVERLAPPED;
use winapi::um::winbase::{FILE_FLAG_NO_BUFFERING, FILE_FLAG_OVERLAPPED};
use winapi::um::winnt::{
FILE_ATTRIBUTE_NORMAL, FILE_SHARE_READ, FILE_SHARE_WRITE, GENERIC_READ, GENERIC_WRITE,
};

#[macro_use]
extern crate napi_derive;

#[napi]
pub fn send_usb(path: String, buffer: Buffer) -> String {
let path = CString::new(path).unwrap();
let access = GENERIC_READ | GENERIC_WRITE;
let share_mode = FILE_SHARE_READ | FILE_SHARE_WRITE;
let creation_disposition = OPEN_EXISTING;
let flags_and_attributes = FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED | FILE_FLAG_NO_BUFFERING;
let handle = unsafe {
CreateFileA(
path.as_ptr(),
access,
share_mode,
null_mut(),
creation_disposition,
flags_and_attributes,
NULL,
)
};

// 发送并接收数据
let mut overlapped = unsafe { zeroed::<OVERLAPPED>() };
let mut bytes_written: DWORD = 0;
let mut bytes_read: DWORD = 0;

let mut res_buffer: Vec<u8> = vec![0; 1024];
let mut ret = unsafe {
WriteFile(
handle,
buffer.as_ptr() as *const _,
buffer.len() as u32,
&mut bytes_written,
&mut overlapped,
)
};
if ret == FALSE {
let err = unsafe { GetLastError() };
if err != ERROR_IO_PENDING && err != ERROR_IO_INCOMPLETE {
return format!("err: {:?}", err);
}
}
ret = unsafe { GetOverlappedResult(handle, &mut overlapped, &mut bytes_written, TRUE) };
if ret == FALSE {
return format!("err: {:?}", unsafe { GetLastError() });
}

ret = unsafe {
ReadFile(
handle,
res_buffer.as_mut_ptr() as *mut _,
res_buffer.len() as u32,
&mut bytes_read,
&mut overlapped,
)
};
if ret == FALSE {
let err = unsafe { GetLastError() };
if err != ERROR_IO_PENDING && err != ERROR_IO_INCOMPLETE {
return format!("err: {:?}", err);
}
}

ret = unsafe { GetOverlappedResult(handle, &mut overlapped, &mut bytes_read, TRUE) };

if ret == FALSE {
return format!("err: {:?}", unsafe { GetLastError() });
}
unsafe {
CloseHandle(handle);
}

res_buffer.truncate(bytes_read as usize);

let res_str = String::from_utf8_lossy(&res_buffer).to_string();

res_str
}

构建出32位和64位的包

2. 在electron中引入试试
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
ini复制代码const { sendUsb } = require('./index.js');
const escpos = require('node-escpos-win');
const usb = escpos.GetDeviceList('USB');
const getPixel = require('get-pixels');

const list = usb.list.filter(
(item) =>
item.service === 'usbprint' ||
item.name === 'USB 打印支持' ||
item.name === 'USB Device Driver for POS/KIOSK Printers'
);

const printer = list[0];

getPixel('./dot.png',(err, { data, shape }) => {
const imgData = rgba2hex(data, shape);
const width = shape[0];
const height = shape[1];
const xL = Math.ceil((width / 8) % 256);
const xH = Math.floor((width / 8) / 256);
const yL = height % 256;
const yH = Math.floor(height / 256);
const buffer = Buffer.from([0x1d, 0x76, 0x30, 0, xL, xH, yL, yH, ...imgData, 0x1d, 0x56, 65, 0]);

const res = sendUsb(printer.path, buffer);

console.log(res); // complete
})

结论:

可以通过napi的形式引入高级语言写的通信模块来实现打印机与usb通信的能力,在此基础上就能配合usb通道的返回做队列和漏单时的补偿逻辑,实现监控能力。

这块其实也有很多种实现,我们目前采用启动两个线程,一个线程专门写,另一个线程持续去读,这样把某些同步的指令全都以异步的消息来接收。

感想

在这套方案落地的过程中有一些比较有趣的事

  1. 我们有一个小进程去判断usb设备的插拔,当替换usb打印机时自动去设置驱动里的端口,实现即插即用。
  2. napi的使用在某些老的windows版本上需要打系统补丁。
  3. 打印机会受环境影响,某些强磁场环境下信号会丢失导致打印出问题(漏单、重复单、乱码等)。
  4. 提升沟(chao)通(jia)能力。
  5. 一些windows api(主要是kernel32)和热敏打印机原理相关的知识还是很有趣的。

想要让打印机打印出我们想要的东西,实际上只需要做好 保证16进制数据正确 和 保证数据能正常传输给设备 这两点就够了,本文只是举例了在windows上usb连接该如何做,明确了这两点,对症下药,就会发现跟设备打交道其实很容易。

吐槽

虽然说从软件角度把整套监控链路已经做起来了,但想要排查门店问题,实际上还受很多硬件的影响,例如当出现打印问题的时候,我们只能排查上位机是否有问题而不能排查数据线、打印机、电压、磁场干扰等众多变量,只能一个个去替换排查,导致排错的效率较低,就这点而言热敏打印机还是有很长的一段路可以走的。

另外想说一点,比较大的打印机厂商都会探索出自己认为最合适的指令规范,这就导致有很多厂商为了自己的打印机能无缝让用户从其他厂商的产品切换自己的产品,兼容适配其他厂商的指令规范不断往自己的打印机上加各种适配。听说TSC有一堆祖传代码,如果以后有机会自己做打印机了,一定要好好见识一下。

最后

📚 小茗文章推荐:

  • 5分钟带你了解,古茗的代码发布与回滚
  • 古茗前端第二届论坛 —— Typescript篇
  • 深入Git:4个关键步骤解锁版本控制机制

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

本文转载自: 掘金

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

1…707172…956

开发者博客

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