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

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


  • 首页

  • 归档

  • 搜索

天天用defineEmits宏函数,竟然不知道编译后是vue

发表于 2024-03-19

本文是 vue3编译原理揭秘 的第 5 篇,和该系列的其他文章一起服用效果更佳。

  1. vue3的宏到底是什么东西?
  2. Vue 3 的 setup语法糖到底是什么东西?
  3. 看不懂来打我,vue3的.vue文件(SFC)编译过程
  4. 为什么defineProps宏函数不需要从vue中import导入?
  5. 天天用defineEmits宏函数,竟然不知道编译后是vue2的选项式API?
  6. 面试官:只知道v-model是:modelValue和@onUpdate语法糖,那你可以走了
  7. defineModel是否破坏了vue3的单向数据流呢?
  8. 看不懂来打我,vue3如何将template编译成render函数
  9. 面试官:来说说vue3是怎么处理内置的v-for、v-model等指令?
  10. 面试官:在原生input上面使用v-model和组件上面使用有什么区别?

前言

我们每天都在使用 defineEmits 宏函数,但是你知道defineEmits 宏函数经过编译后其实就是vue2的选项式API吗?通过回答下面两个问题,我将逐步为你揭秘defineEmits 宏函数的神秘面纱。为什么 Vue 的 defineEmits 宏函数不需要 import 导入就可用?为什么defineEmits的返回值等同于$emit 方法用于在组件中抛出事件?

举两个例子

要回答上面提的几个问题我们先来看两个例子是如何声明事件和抛出事件,分别是vue2的选项式天天用defineEmits宏函数竟然不知道编译后是vue2的选项式API?和vue3的组合式API。

我们先来看vue2的选项式语法的例子,options-child.vue文件代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
xml复制代码    <template>
<button @click="handleClick">放大文字</button>
</template>

<script>
export default {
name: "options-child",
emits: ["enlarge-text"],
methods: {
handleClick() {
this.$emit("enlarge-text");
},
},
};
</script>

使用emits选项声明了要抛出的事件”enlarge-text”,然后在点击按钮后调用this.$emit方法抛出"enlarge-text"事件。这里的this大家都知道是指向的当前组件的vue实例,所以this.$emit是调用的当前vue实例的$emit方法。大家先记住vue2的选项式语法例子,后面我们讲defineEmits宏函数编译原理时会用。

关注公众号:【前端欧阳】,解锁我更多vue原理文章。还可以加我微信,让你的朋友圈多一位对vue有深入理解的人。也可以通过微信给我说你想看哪些vue原理文章,我会根据大家的反馈进行创作。

我们再来看看vue3的组合式语法的例子,composition-child.vue代码如下:

1
2
3
4
5
6
7
8
9
10
xml复制代码<template>
<button @click="handleClick">放大文字</button>
</template>

<script setup lang="ts">
const emits = defineEmits(["enlarge-text"]);
function handleClick() {
emits("enlarge-text");
}
</script>

在这个例子中我们使用了defineEmits宏函数声明了要抛出的事件”enlarge-text”,defineEmits宏函数执行后返回了一个emits函数,然后在点击按钮后使用 emits("enlarge-text")抛出"enlarge-text"事件。

通过debug搞清楚上面几个问题

首先我们要搞清楚应该在哪里打断点,在我之前的文章 vue文件是如何编译为js文件 中已经带你搞清楚了将vue文件中的<script>模块编译成浏览器可直接运行的js代码,底层就是调用vue/compiler-sfc包的compileScript函数。当然如果你还没看过我的vue文件是如何编译为js文件 文章也不影响这篇文章阅读。

所以我们将断点打在vue/compiler-sfc包的compileScript函数中,一样的套路,首先我们在vscode的打开一个debug终端。
debug-terminal

然后在node_modules中找到vue/compiler-sfc包的compileScript函数打上断点,compileScript函数位置在/node_modules/@vue/compiler-sfc/dist/compiler-sfc.cjs.js。在debug终端上面执行yarn dev后在浏览器中打开对应的页面,比如:http://localhost:5173/ 。此时断点就会走到compileScript函数中,由于每编译一个vue文件都要走到这个debug中,现在我们只想debug看看composition-child.vue文件,也就是我们前面举的vue3的组合式语法的例子。所以为了方便我们在compileScript中加了下面这样一段代码,并且去掉了在compileScript函数中加的断点,这样就只有编译composition-child.vue文件时会走进断点。加的这段代码中的sfc.fileName就是文件路径的意思,后面我们会讲。
debug-terminal

compileScript 函数

我们再来回忆一下composition-child.vue文件中的script模块代码如下:

1
2
3
4
5
6
7
xml复制代码<script setup lang="ts">
const emits = defineEmits(["enlarge-text"]);

function handleClick() {
emits("enlarge-text");
}
</script>

compileScript函数内包含了编译script模块的所有的逻辑,代码很复杂,光是源代码就接近1000行。这篇文章我们同样不会去通读compileScript函数的所有功能,只讲涉及到defineEmits流程的代码。这个是根据我们这个场景将compileScript函数简化后的代码:

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
ini复制代码function compileScript(sfc, options) {
const ctx = new ScriptCompileContext(sfc, options);
const startOffset = ctx.startOffset;
const endOffset = ctx.endOffset;
const scriptSetupAst = ctx.scriptSetupAst;

for (const node of scriptSetupAst.body) {
if (node.type === "ExpressionStatement") {
// ...
}

if (node.type === "VariableDeclaration" && !node.declare) {
const total = node.declarations.length;
for (let i = 0; i < total; i++) {
const decl = node.declarations[i];
const init = decl.init;
if (init) {
const isDefineEmits = processDefineEmits(ctx, init, decl.id);
if (isDefineEmits) {
ctx.s.overwrite(
startOffset + init.start,
startOffset + init.end,
"__emit"
);
}
}
}
}

if (
(node.type === "VariableDeclaration" && !node.declare) ||
node.type.endsWith("Statement")
) {
// ....
}
}

ctx.s.remove(0, startOffset);
ctx.s.remove(endOffset, source.length);

let runtimeOptions = ``;
const emitsDecl = genRuntimeEmits(ctx);
if (emitsDecl) runtimeOptions += `\n emits: ${emitsDecl},`;

const def =
(defaultExport ? `\n ...${normalScriptDefaultVar},` : ``) +
(definedOptions ? `\n ...${definedOptions},` : "");
ctx.s.prependLeft(
startOffset,
`\n${genDefaultAs} /*#__PURE__*/${ctx.helper(
`defineComponent`
)}({${def}${runtimeOptions}\n ${
hasAwait ? `async ` : ``
}setup(${args}) {\n${exposeCall}`
);
ctx.s.appendRight(endOffset, `})`);

return {
//....
content: ctx.s.toString(),
};
}

如果看过我上一篇 为什么defineProps宏函数不需要从vue中import导入?文章的小伙伴应该会很熟悉这个compileScript函数,compileScript函数内处理defineProps和defineEmits大体流程其实很相似的。

ScriptCompileContext类

我们将断点走到compileScript函数中的第一部分代码。

1
2
3
4
5
6
7
8
9
10
11
ini复制代码function compileScript(sfc, options) {
const ctx = new ScriptCompileContext(sfc, options);
const startOffset = ctx.startOffset;
const endOffset = ctx.endOffset;
const scriptSetupAst = ctx.scriptSetupAst;
// ...省略
return {
//....
content: ctx.s.toString(),
};
}

这部分代码主要使用ScriptCompileContext类new了一个ctx上下文对象,并且读取了上下文对象中的startOffset、endOffset、scriptSetupAst、s四个属性。我们将断点走进ScriptCompileContext类,看看他的constructor构造函数。下面这个是我简化后的ScriptCompileContext类的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
kotlin复制代码import MagicString from 'magic-string'

class ScriptCompileContext {
source = this.descriptor.source
s = new MagicString(this.source)
startOffset = this.descriptor.scriptSetup?.loc.start.offset
endOffset = this.descriptor.scriptSetup?.loc.end.offset

constructor(descriptor, options) {
this.descriptor = descriptor;
this.s = new MagicString(this.source);
this.scriptSetupAst = descriptor.scriptSetup && parse(descriptor.scriptSetup.content, this.startOffset);
}
}

在compileScript函数中new ScriptCompileContext时传入的第一个参数是sfc变量,然后在ScriptCompileContext类的构造函数中是使用descriptor变量来接收,接着赋值给descriptor属性。

在之前的vue文件是如何编译为js文件 文章中我们已经讲过了传入给compileScript函数的sfc变量是一个descriptor对象,descriptor对象是由vue文件编译来的。descriptor对象拥有template属性、scriptSetup属性、style属性、source属性,分别对应vue文件的<template>模块、<script setup>模块、<style>模块、源代码code字符串。在我们这个场景只关注scriptSetup和source属性就行了,其中sfc.scriptSetup.content的值就是<script setup>模块中code代码字符串。详情查看下图:
composition-child

现在我想你已经搞清楚了ctx上下文对象4个属性中的startOffset属性和endOffset属性了,startOffset和endOffset分别对应的就是descriptor.scriptSetup?.loc.start.offset和descriptor.scriptSetup?.loc.end.offset。startOffset为<script setup>模块中的内容开始的位置。endOffset为<script setup>模块中的内容结束的位置。

我们接着来看构造函数中的this.s = new MagicString(this.source)这段话,this.source是vue文件中的源代码code字符串,以这个字符串new了一个MagicString对象赋值给s属性。magic-string是一个用于高效操作字符串的 JavaScript 库。它提供丰富的 API,可以轻松地对字符串进行插入、删除、替换等操作。我们这里主要用到toString、remove、overwrite、prependLeft、appendRight五个方法。toString方法用于生成经过处理后返回的字符串,其余几个方法我举几个例子你应该就明白了。

s.remove( start, end )用于删除从开始到结束的字符串:

1
2
3
ini复制代码const s = new MagicString('hello word');
s.remove(0, 6);
s.toString(); // 'word'

s.overwrite( start, end, content ),使用content的内容替换开始位置到结束位置的内容。

1
2
3
ini复制代码const s = new MagicString('hello word');
s.overwrite(0, 5, "你好");
s.toString(); // '你好 word'

s.prependLeft( index, content )用于在指定index的前面插入字符串:

1
2
3
ini复制代码const s = new MagicString('hello word');
s.prependLeft(5, 'xx');
s.toString(); // 'helloxx word'

s.appendRight( index, content )用于在指定index的后面插入字符串:

1
2
3
ini复制代码const s = new MagicString('hello word');
s.appendRight(5, 'xx');
s.toString(); // 'helloxx word'

现在你应该已经明白了ctx上下文对象中的s属性了,我们接着来看最后一个属性scriptSetupAst。在构造函数中是由parse函数的返回值赋值的: this.scriptSetupAst = descriptor.scriptSetup && parse(descriptor.scriptSetup.content, this.startOffset)。parse函数的代码如下:

1
2
3
4
5
6
7
8
9
10
11
typescript复制代码import { parse as babelParse } from '@babel/parser'

function parse(input: string, offset: number): Program {
try {
return babelParse(input, {
plugins,
sourceType: 'module',
}).program
} catch (e: any) {
}
}

我们在前面已经讲过了descriptor.scriptSetup.content的值就是vue文件中的<script setup>模块的代码code字符串,parse函数中调用了babel提供的parser函数,将vue文件中的<script setup>模块的代码code字符串转换成AST抽象语法树。

在ScriptCompileContext构造函数中主要做了下面这些事情:
progress1

processDefineEmits函数

我们接着将断点走到compileScript函数中的第二部分,for循环遍历AST抽象语法树的地方,代码如下:

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
ini复制代码function compileScript(sfc, options) {
// ...省略
for (const node of scriptSetupAst.body) {
if (node.type === "ExpressionStatement") {
// ...
}

if (node.type === "VariableDeclaration" && !node.declare) {
const total = node.declarations.length;
for (let i = 0; i < total; i++) {
const decl = node.declarations[i];
const init = decl.init;
if (init) {
const isDefineEmits = processDefineEmits(ctx, init, decl.id);
if (isDefineEmits) {
ctx.s.overwrite(
startOffset + init.start,
startOffset + init.end,
"__emit"
);
}
}
}
}

if (
(node.type === "VariableDeclaration" && !node.declare) ||
node.type.endsWith("Statement")
) {
// ....
}
}
// ...省略
}

看过我上一篇 为什么defineProps宏函数不需要从vue中import导入?可能会疑惑了,为什么这里不列出满足node.type === "ExpressionStatement"条件的代码呢。原因是在上一篇文章中我们没有将defineProps函数的返回值赋值给一个变量,他是一条表达式语句,所以满足node.type === "ExpressionStatement"的条件。在这篇文章中我们将defineEmits函数的返回值赋值给一个emits变量,他是一条变量声明语句,所以他满足node.type === "VariableDeclaration" 的条件。

1
2
3
4
5
6
7
scss复制代码// 表达式语句
defineProps({
content: String,
});

// 变量声明语句
const emits = defineEmits(["enlarge-text"]);

将断点走进for循环里面,我们知道在script模块中第一行代码是变量声明语句const emits = defineEmits(["enlarge-text"]);。在console中看看由这条变量声明语句编译成的node节点长什么样子,如下图:
first-node

从上图中我们可以看到当前的node节点类型为变量声明语句,并且node.declare的值为undefined。我们再来看看node.declarations字段,他表示该节点的所有声明子节点。这句话是什么意思呢?说人话就是表示const右边的语句。那为什么declarations是一个数组呢?那是因为const右边可以有多条语句,比如const a = 2, b = 4;。在我们这个场景node.declarations字段就是表示emits = defineEmits(["enlarge-text"]);。接着来看declarations数组下的init字段,从名字我想你应该已经猜到了他的作用是表示变量的初始化值,在我们这个场景init字段就是表示defineEmits(["enlarge-text"])。而init.start表示defineEmits(["enlarge-text"]);中的开始位置,也就是字符串’d’的位置,init.end表示defineEmits(["enlarge-text"]);中的结束位置,也就是字符串’;’的位置。

现在我们将断点走到if语句内,下面的这些代码我想你应该能够很轻松的理解了:

1
2
3
4
5
6
7
8
9
10
11
ini复制代码if (node.type === "VariableDeclaration" && !node.declare) {
const total = node.declarations.length;
for (let i = 0; i < total; i++) {
const decl = node.declarations[i];
const init = decl.init;
if (init) {
const isDefineEmits = processDefineEmits(ctx, init, decl.id);
// 省略...
}
}
}

我们在控制台中已经看到了node.declare的值是undefined,并且这也是一条变量声明语句,所以断点会走到if里面。由于我们这里只声明了一个变量,所以node.declarations数组中只有一个值,这个值就是对应的emits = defineEmits(["enlarge-text"]);。接着遍历node.declarations数组,将数组中的item赋值给decl变量,然后使用decl.init读取到变量声明语句中的初始化值,在我们这里初始化值就是defineEmits(["enlarge-text"]);。如果有初始化值,那就将他传入给processDefineEmits函数判断是否在调用defineEmits函数。我们来看看processDefineEmits函数是什么样的:

1
2
3
4
5
6
7
8
ini复制代码const DEFINE_EMITS = "defineEmits";
function processDefineEmits(ctx, node, declId) {
if (!isCallOf(node, DEFINE_EMITS)) {
return false;
}
ctx.emitsRuntimeDecl = node.arguments[0];
return true;
}

在 processDefineEmits 函数中,我们首先使用 isCallOf 函数判断当前的 AST 语法树节点 node 是否在调用 defineEmits 函数。isCallOf 函数的第一个参数是 node 节点,第二个参数在这里是写死的字符串 “defineEmits”。isCallOf的代码如下:

1
2
3
4
5
6
7
8
9
10
11
ini复制代码export function isCallOf(node, test) {
return !!(
node &&
test &&
node.type === "CallExpression" &&
node.callee.type === "Identifier" &&
(typeof test === "string"
? node.callee.name === test
: test(node.callee.name))
);
}

我们在debug console中将node.type、node.callee.type、node.callee.name的值打印出来看看。
isCallOf

从图上看到node.type、node.callee.type、node.callee.name的值后,我们知道了当前节点确实是在调用 defineEmits 函数。所以isCallOf(node, DEFINE_EMITS) 的执行结果为 true,在 processDefineEmits 函数中我们是对 isCallOf 函数的执行结果取反,所以 !isCallOf(node, DEFINE_EMITS) 的执行结果为 false。

我们接着来看processDefineEmits函数:

1
2
3
4
5
6
7
8
ini复制代码const DEFINE_EMITS = "defineEmits";
function processDefineEmits(ctx, node, declId) {
if (!isCallOf(node, DEFINE_EMITS)) {
return false;
}
ctx.emitsRuntimeDecl = node.arguments[0];
return true;
}

如果是在执行defineEmits函数,就会执行接下来的代码ctx.emitsRuntimeDecl = node.arguments[0];。将传入的node节点第一个参数赋值给ctx上下文对象的emitsRuntimeDecl属性,这里的第一个参数其实就是调用defineEmits函数时给传入的第一个参数。为什么写死成取arguments[0]呢?是因为defineEmits函数只接收一个参数,传入的参数可以是一个对象或者数组。比如:

1
2
3
4
5
csharp复制代码const props = defineEmits({
'enlarge-text': null
})

const emits = defineEmits(['enlarge-text'])

记住这个在ctx上下文上面塞的emitsRuntimeDecl属性,后面会用到。

至此我们已经了解到了processDefineEmits中主要做了两件事:判断当前执行的表达式语句是否是defineEmits函数,如果是那么就将调用defineEmits函数时传入的参数转换成的node节点塞到ctx上下文的emitsRuntimeDecl属性中。

我们接着来看compileScript函数中的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ini复制代码if (node.type === "VariableDeclaration" && !node.declare) {
const total = node.declarations.length;
for (let i = 0; i < total; i++) {
const decl = node.declarations[i];
const init = decl.init;
if (init) {
const isDefineEmits = processDefineEmits(ctx, init, decl.id);
if (isDefineEmits) {
ctx.s.overwrite(
startOffset + init.start,
startOffset + init.end,
"__emit"
);
}
}
}
}

将processDefineEmits函数的执行结果赋值赋值给isDefineEmits变量,在我们这个场景当然是在调用defineEmits函数,所以会执行if语句内的ctx.s.overwrite方法。ctx.s.overwrite方法我们前面已经讲过了,作用是使用指定的内容替换开始位置到结束位置的内容。在执行ctx.s.overwrite前我们先在debug console中执行ctx.s.toString()看看当前的code代码字符串是什么样的。
before-overwrite

从上图我们可以看到此时的code代码字符串还是和我们的源代码是一样的,我们接着来看ctx.s.overwrite方法接收的参数。第一个参数为startOffset + init.start,startOffset我们前面已经讲过了他的值为script模块的内容开始的位置。init我们前面也讲过了,他表示emits变量的初始化值对应的node节点,在我们这个场景init字段就是表示defineEmits(["enlarge-text"])。所以init.start为emits变量的初始化值在script模块中开始的位置。而ctx.s.为操纵整个vue文件的code代码字符串,所以startOffset + init.start的值为emits变量的初始化值的起点在整个vue文件的code代码字符串所在位置。同理第二个参数startOffset + init.end的值为emits变量的初始化值的终点在整个vue文件的code代码字符串所在位置,而第三个参数是一个写死的字符串”__emit”。所以ctx.s.overwrite方法的作用是将const emits = defineEmits(["enlarge-text"]);替换为const emits = __emit;。

关于startOffset、init.start、 init.end请看下图:
params-overwrite

在执行ctx.s.overwrite方法后我们在debug console中再次执行ctx.s.toString()看看这会儿的code代码字符串是什么样的。
after-overwrite

从上图中我们可以看到此时代码中已经没有了defineEmits函数,已经变成了一个__emit变量。
convert-defineEmits

genRuntimeEmits函数

我们接着将断点走到compileScript函数中的第三部分,生成运行时的“声明事件”。我们在上一步将defineEmits声明事件的代码替换为__emit,那么总得有一个地方去生成“声明事件”。没错,就是在genRuntimeEmits函数这里生成的。compileScript函数中执行genRuntimeEmits函数的代码如下:

1
2
3
4
5
6
ini复制代码ctx.s.remove(0, startOffset);
ctx.s.remove(endOffset, source.length);

let runtimeOptions = ``;
const emitsDecl = genRuntimeEmits(ctx);
if (emitsDecl) runtimeOptions += `\n emits: ${emitsDecl},`;

从上面的代码中我们看到首先执行了两次remove方法,在前面已经讲过了startOffset为script模块中的内容开始的位置。所以ctx.s.remove(0, startOffset);的意思是删除掉template模块的内容和<script setup>开始标签。这行代码执行完后我们再看看ctx.s.toString()的值:
remove1

从上图我们可以看到此时template模块和<script setup>开始标签已经没有了,接着执行ctx.s.remove(endOffset, source.length);,这行代码的意思是删除</script >结束标签和<style>模块。这行代码执行完后我们再来看看ctx.s.toString()的值:
remove2

从上图我们可以看到,此时只有script模块中的内容了。

我们接着将compileScript函数中的断点走到调用genRuntimeEmits函数处,简化后代码如下:

1
2
3
4
5
6
7
ini复制代码function genRuntimeEmits(ctx) {
let emitsDecl = "";
if (ctx.emitsRuntimeDecl) {
emitsDecl = ctx.getString(ctx.emitsRuntimeDecl).trim();
}
return emitsDecl;
}

看到上面的代码是不是觉得和上一篇defineProps文章中讲的genRuntimeProps函数很相似。这里的上下文ctx上面的emitsRuntimeDecl属性我们前面讲过了,他就是调用defineEmits函数时传入的参数转换成的node节点。我们将断点走进ctx.getString函数,代码如下:

1
2
3
4
kotlin复制代码getString(node, scriptSetup = true) {
const block = scriptSetup ? this.descriptor.scriptSetup : this.descriptor.script;
return block.content.slice(node.start, node.end);
}

我们前面已经讲过了descriptor对象是由vue文件编译而来,其中的scriptSetup属性就是对应的<script setup>模块。我们这里没有传入scriptSetup,所以block的值为this.descriptor.scriptSetup。同样我们前面也讲过scriptSetup.content的值是<script setup>模块code代码字符串。请看下图:
script-code

这里传入的node节点就是我们前面存在上下文中ctx.emitsRuntimeDecl,也就是在调用defineEmits函数时传入的参数节点,node.start就是参数节点开始的位置,node.end就是参数节点的结束位置。所以使用content.slice方法就可以截取出来调用defineEmits函数时传入的参数。请看下图:
block-slice

现在我们再回过头来看compileScript函数中的调用genRuntimeEmits函数的代码你就能很容易理解了:

1
2
3
ini复制代码let runtimeOptions = ``;
const emitsDecl = genRuntimeEmits(ctx);
if (emitsDecl) runtimeOptions += `\n emits: ${emitsDecl},`;

这里的emitsDecl在我们这个场景中就是使用slice截取出来的emits定义,再使用字符串拼接 emits:,就得到了runtimeOptions的值。如图:
runtimeOptions

看到runtimeOptions的值是不是就觉得很熟悉了,又有name属性,又有emits属性,和我们前面举的两个例子中的vue2的选项式语法的例子比较相似。
genRuntimeEmits

拼接成完整的浏览器运行时 js 代码

我们接着将断点走到compileScript函数中的最后一部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
javascript复制代码const def =
(defaultExport ? `\n ...${normalScriptDefaultVar},` : ``) +
(definedOptions ? `\n ...${definedOptions},` : "");
ctx.s.prependLeft(
startOffset,
`\n${genDefaultAs} /*#__PURE__*/${ctx.helper(
`defineComponent`
)}({${def}${runtimeOptions}\n ${
hasAwait ? `async ` : ``
}setup(${args}) {\n${exposeCall}`
);
ctx.s.appendRight(endOffset, `})`);

return {
//....
content: ctx.s.toString(),
};

这块代码和我们讲defineProps文章中是一样的,先调用了ctx.s.prependLeft方法给字符串开始的地方插入了一串字符串,这串拼接的字符串看着很麻烦的样子,我们直接在debug console上面看看要拼接的字符串是什么样的:
prependLeft

看到这串你应该很熟悉,除了前面我们拼接的name和emits之外还有部分setup编译后的代码,但是这里的setup代码还不完整,剩余部分还在ctx.s.toString()里面。

将断点执行完ctx.s.prependLeft后,我们在debug console上面通过ctx.s.toString()看此时操作的字符串变成什么样了:
after-prependLeft

从上图可以看到此时的setup函数已经拼接完整了,已经是一个编译后的vue组件对象的代码字符串了,只差一个})结束符号,所以执行ctx.s.appendRight方法将结束符号插入进去。

我们最后再来看看经过compileScript函数处理后的浏览器可执行的js代码字符串,也就是ctx.s.toString()
full-code

从上图中我们可以看到编译后的代码中声明事件还是通过vue组件对象上面的emits选项声明的,和我们前面举的vue2的选项式语法的例子一模一样。

为什么defineEmits的返回值等同于$emit 方法用于在组件中抛出事件?

在上一节中我们知道了defineEmits函数在编译时就被替换为了__emit变量,然后将__emit赋值给我们定义的emits变量。在需要抛出事件时我们是调用的emits("enlarge-text");,实际就是在调用__emit("enlarge-text");。那我们现在通过debug看看这个__emit到底是什么东西?

首先我们需要在浏览器的source面板中找到由vue文件编译而来的js文件,然后给setup函数打上断点。在我们前面的 Vue 3 的 setup语法糖到底是什么东西?文章中已经手把手的教你了怎么在浏览器中找到编译后的js文件,所以在这篇文章中就不再赘述了。

给setup函数打上断点,刷新浏览器页面后,我们看到断点已经走进来了。如图:
setup-debug

从上图中我们可以看见defineEmits的返回值也就是__emit变量,实际就是setup函数的第二个参数对象中的emit属性。右边的Call Stack有的小伙伴可能不常用,他的作用是追踪函数的执行流。比如在这里setup函数是由callWithErrorHandling函数内调用的,在Call Stack中setup下面就是callWithErrorHandling。而callWithErrorHandling函数是由setupStatefulComponent函数内调用的,所以在Call Stack中callWithErrorHandling下面就是setupStatefulComponent。并且还可以通过点击函数名称跳转到对应的函数中。

为了搞清楚setup函数的第二个参数到底是什么,所以我们点击右边的Call Stack中的callWithErrorHandling函数,看看在callWithErrorHandling函数中是怎么调用setup函数的。代码如下:

1
2
3
4
5
6
7
php复制代码function callWithErrorHandling(fn, instance, type, args) {
try {
return args ? fn(...args) : fn();
} catch (err) {
handleError(err, instance, type);
}
}

从上面的代码中可以看到这个callWithErrorHandling函数实际就是用于错误处理的,如果有参数args,那就调用fn时将参数以...args的形式传入给fn。在我们这里fn就是setup函数,我们现在要看传递给setup的第二个参数,就对应的这里的是args数组中的第二项。现在我们知道了调用callWithErrorHandling函数时传入的第四个参数是一个数组,数组的第二项就是调用setup函数时传入的第二个参数对象。

我们接着来看在setupStatefulComponent函数中是如何调用callWithErrorHandling函数的,简化后代码如下:

1
2
3
4
5
6
7
8
ini复制代码function setupStatefulComponent(instance, isSSR) {
const setupContext = (instance.setupContext =
setup.length > 1 ? createSetupContext(instance) : null);
const setupResult = callWithErrorHandling(setup, instance, 0, [
true ? shallowReadonly(instance.props) : instance.props,
setupContext,
]);
}

从上面的代码中可以看到调用callWithErrorHandling函数时传入的第四个参数确实是一个数组,数组的第二项是setupContext,这个setupContext就是调用setup函数时传入的第二个参数对象。而setupContext的值是由createSetupContext函数返回的,在调用createSetupContext函数时传入了当前的vue实例。我们接着来看简化后的createSetupContext函数是什么样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
csharp复制代码function createSetupContext(instance) {
return Object.freeze({
get attrs() {
return getAttrsProxy(instance);
},
get slots() {
return getSlotsProxy(instance);
},
get emit() {
return (event, ...args) => instance.emit(event, ...args);
},
expose,
});
}

这里出现了一个我们平时不常用的Object.freeze方法,在mdn上面查了一下他的作用:

Object.freeze() 静态方法可以使一个对象被冻结。冻结对象可以防止扩展,并使现有的属性不可写入和不可配置。被冻结的对象不能再被更改:不能添加新的属性,不能移除现有的属性,不能更改它们的可枚举性、可配置性、可写性或值,对象的原型也不能被重新指定。freeze() 返回与传入的对象相同的对象。

从前面我们已经知道了createSetupContext函数的返回值就是调用setup函数时传入的第二个参数对象,我们要找的__emit就是第二个参数对象中的emit属性。当读取emit属性时就会走到上面的冻结对象的get emit() 中,当我们调用emit函数抛出事件时实际就是调用的是instance.emit方法,也就是vue实例上面的emit方法。

现在我想你应该已经反应过来了,调用defineEmits函数的返回值实际就是在调用vue实例上面的emit方法,其实在运行时抛出事件的做法还是和vue2的选项式语法一样的,只是在编译时就将看着高大上的defineEmits函数编译成vue2的选项式语法的样子。
full-emit-progress

总结

现在我们能够回答前面提的两个问题了:

  • 为什么 Vue 的 defineEmits 宏函数不需要 import 导入就可用?
    在遍历script模块转换成的AST抽象语法树时,如果当前的node节点是在调用defineEmits函数,就继续去找这个node节点下面的参数节点,也就是调用defineEmits函数传入的参数对应的node节点。然后将参数节点对象赋值给当前的ctx上下文的emitsRuntimeDecl属性中,接着根据defineEmits函数对应的node节点中记录的start和end位置对vue文件的code代码字符串进行替换。将defineEmits(["enlarge-text"])替换为__emit,此时在代码中已经就没有了 defineEmits 宏函数了,自然也不需要从vue中import导入。当遍历完AST抽象语法树后调用genRuntimeEmits函数,从前面存的ctx上下文中的emitsRuntimeDecl属性中取出来调用defineEmits函数时传入的参数节点信息。根据参数节点中记录的start和end位置,对script模块中的code代码字符串执行slice方法,截取出调用defineEmits函数时传入的参数。然后通过字符串拼接的方式将调用defineEmits函数时传入的参数拼接到vue组件对象的emits属性上。
  • 为什么defineEmits的返回值等同于$emit 方法用于在组件中抛出事件?
    defineEmits 宏函数在上个问题中我们已经讲过了会被替换为__emit,而这个__emit是调用setup函数时传入的第二个参数对象上的emit属性。而第二个参数对象是在setupStatefulComponent函数中调用createSetupContext函数生成的setupContext对象。在createSetupContext函数中我们看到返回的emit属性其实就是一个箭头函数,当调用defineEmits函数返回的emit函数时就会调用这个箭头函数,在箭头函数中其实是调用vue实例上的emit方法。

搞明白了上面两个问题我想你现在应该明白了为什么说vue3的defineEmits 宏函数编译后其实就是vue2的选项式API,defineEmits宏函数声明的事件经过编译后就变成了vue组件对象上的emits属性。defineEmits函数的返回值emit函数,其实就是在调用vue实例上的emit方法,这不就是我们在vue2的选项式API中声明事件和触发事件的样子吗。大部分看着高大上的黑魔法其实都是编译时做的事情,vue3中的像defineEmits这样的宏函数经过编译后其实还是我们熟悉的vue2的选项式API。

本文转载自: 掘金

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

Qt C++食用教程 1、QtCreator安装 2、QtC

发表于 2024-03-18

由于项目经常要展示检测结果,但直接运行代码进行检测对操作人员极其不友好,所以做一套能完美运行检测过程以及展示检测结果的UI界面显得非常重要。由于Qt几乎可以在任何设备上使用,你能在你的计算机,智能手机,电视以及其它的智能设备上使用它,而且qt设计的应用程序,无需经验,开发简单,对开发者非常友好、在工业上被广泛使用,所以我决定做个Qt设计的UI界面用来展示算法以及进行良好的用户交互,一起学习一下如何用Qt搭建一个简洁、舒适的UI界面吧。

1、QtCreator安装

进入官网Download Qt: Get Qt Online Installer下载QtCreator,自行选择系统版本,安装勾选Qt->MinGW以及MSVC即可。(MSVC方便进行VS编程)

2、QtCreator使用

S2.1:创建项目:

1
2
3
4
rust复制代码          step1:打开QtCreator;
step2:文件->新建文件和项目->Application->Qt Widgets Application->choose;
step3:一直点击next直到Kits选项,随后选择Desktop MSVC的64位版本以及Desktop MinGW的64位版本;
step4:选择完后继续next,完成项目的新建;

屏幕截图 2024-03-17 200342.png

屏幕截图 2024-03-17 201255.png

S2.2:导入项目:

1
2
3
4
5
rust复制代码        step1:打开QtCreator;
step2:文件->新建文件和项目->Import Project->导入现有项目->choose;
step3:创建自己的项目名称以及选择自己的项目位置;
step4:选择项目位置下需导入的文件;
step5:一直点击next,完成项目构建;

屏幕截图 2024-03-17 202137.png
屏幕截图 2024-03-17 202137.png

S2.3 项目文件介绍:

.pro文件:Qt项目的配置文件,可直接点击该文件进入qt对该文件进行编辑,里面包含了对库文件的声明,链接的三方库,当你想调用py库时需要向里面添加库路径以及声明;

.h文件:项目的头文件(学过c的都知道能从里面调用库函数),你可以在里面声明函数类,常用的public类能被被本类中的成员函数所引用,也可以被类的作用域内的其他函数引用,private则只能被本类中的成员函数引用;

.cpp文件:页面内容的实现,在这里你能将想要的页面效果写出来;

.ui文件:点击可进入qtdesigner设计界面,拖拽控件进入页面即可完成控件的添加;
设计界面的控件分类可参考下图:

屏幕截图 2024-03-17 210540.png

3、VS使用控件载入Qt

1
2
3
4
rust复制代码step1:进入VS,点击扩展->管理扩展->搜索Qt Visudio Tools->点击安装;
step2:加载msvc编译器,点击扩展->Qt Visudio Tools->Qt Versions
step3:version写自己Qt版本名即可,path选择MSVC目录下的bin中的.exe
step4:项目属性页中选择Qt Project Settings->点击Qt Modules->选择自己需要的模块

{2A74C12C-4B22-4069-897D-53D1311B72AD}.png
想要用VS快速打开.ui文件进入设计界面:在VS中右键你的.ui文件,选择打开方式,新建项,添加你的QtCreator.exe或Qt Designer.exe即可。

4、用例:

屏幕截图 2024-03-18 174113.png

屏幕截图 2024-03-18 174016.png
简单的配置使用介绍就到这了,去试试设计个登录界面吧!

本文转载自: 掘金

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

【Android 13源码分析】WindowContaine

发表于 2024-03-18

在安卓源码的设计中,将将屏幕分为了37层,不同的窗口将在不同的层级中显示。
对这一块的概念以及相关源码做了详细分析,整理出以下几篇。

【Android 13源码分析】WindowContainer窗口层级-1-初识窗口层级树

【Android 13源码分析】WindowContainer窗口层级-2-构建流程

【Android 13源码分析】WindowContainer窗口层级-3-实例分析

【Android 13源码分析】WindowContainer窗口层级-4-Surface树

本篇为第四篇,前面三篇已经将Android窗口树介绍完了,但是我们知道安卓真正控制显示的是在SurfaceFlinger层,难道说SurfaceFlinger层也有这么一个窗口树吗?如果有,Framework层构建窗口树的代码这么复杂,难道SurfaceFlinger也有这么一段复杂的逻辑吗?

首先回答第一个问题:SurfaceFlinger层也有这么一个窗口树,严格来说是SurfaceFlinger也有一个对应的Layer树。

winscope看window的SC.png

这是使用Winscope工具看到的当前屏幕信息,可以看到在SurfaceFlinger层也有一个和窗口树应用的层级关系,并且在WindowState层下面还多了一级,多出来的这层右边的属性中有一项“isBuffLayer= true”。

先以黑盒的形式补充几个Surface相关的知识点,对这块有了解的可跳过。

  1. Surface知识黑盒概念

    1. 触发创建Surface时就会触发创建出一个Layer,所以Surface和Layer是一一对应的,只不过在framework层侧重Surface,在SurfaceFlinger侧重Layer。
    1. 应用层只要有Surface,就可以将View的数据绘制保存到Surface中,也就可以显示到屏幕上
    1. Layer有多种类型,最常见的是“容器类型”和“buff类型”,只有“buff类型”的Layer才能保存UI数据。

2 容器类型的创建

前面几篇介绍窗口树的时候,知道那些类其实都是“容器”,作为容器他们本身是没有UI数据的,真正有显示数据的就是 “isBuffLayer= true”的这层Layer。

再回答第二个问题:SurfaceFlinger没有这么复杂构建Layer树的逻辑,因为只要Framework创建一个“容器”类的同时也触发创建一个Surface,这样SurfaceFlinger层就也能同步构造出一个Layer(Surface)树。

2.1 DisplayContent的Surface构建

首先看一下 屏幕(DisplayContent)对应的Surface是怎么创建的。

在构建流程开始的时候就为 DisplayContent的创建了Surface,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
java复制代码# DisplayContent

private void configureSurfaces(Transaction transaction) {
// 构建一个SurfaceControl
final SurfaceControl.Builder b = mWmService.makeSurfaceBuilder(mSession)
.setOpaque(true)
.setContainerLayer() // 设置为容器类型的Layer
.setCallsite("DisplayContent");
// 设置名字后构建 (Display 0 name="XXX")
mSurfaceControl = b.setName(getName()).setContainerLayer().build();
// 设置策略并构建显示区域层次结构
if (mDisplayAreaPolicy == null) {
// WMS的getDisplayAreaPolicyProvider方法按返回 DisplayAreaPolicy.Provider
// 然后其 instantiate的实现 目前只有DisplayAreaPolicy的内部类DefaultProvider

mDisplayAreaPolicy = mWmService.getDisplayAreaPolicyProvider().instantiate(
mWmService, this /* content */, this /* root */,
mImeWindowsContainer);
}
......
}

在构建窗口树源码分析的时候知道DisplayContent::configureSurfaces 是在 DisplayContent构造方法里执行的,也就是说在构建窗口树的时候,创建了DisplayContent容器的同时,也创建好了对应的Surface,这是第一个映射关系。

注意build的时候 setContainerLayer这个设置, 将其设置为容器类型的Layer

2.2 其他容器Surface的创建与挂载

层级树其他的各个容器创建的也会触发创建出对应的一个Surface,具体的调用链如下:

1
2
3
4
5
6
7
arduino复制代码WindowContainer::addChild
WindowContainer::setParent
WindowState::onParentChanged
WindowContainer::createSurfaceControl
WindowContainer::setInitialSurfaceControlProperties
SurfaceControl.Builder::build
SurfaceControl::init

调用链从WindowContainer::addChild 开始是因为每个容器类创建的时候,都会挂载到父节点下,挂载的方式也就是执行WindowContainer::addChild方法,添加到父容器的孩子集合下。

以一个应用的Task容器创建挂载为例:

Surface树构建-1.png

假设其他窗口树已经构建好(也已经有了一个对应的Layer树,暂时不知道是怎么来的没关系,稍后就明白了),这个时候应用启动了,肯定是需要创建一个Task,Task创建好后是还是一个单独的容器,这个时候会执行 WindowContainer::addChild 和 WindowContainer::setParent方法,执行完后Task就挂着到窗口树上了。

先看一下这2个方法的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
csharp复制代码# WindowContainer

// 当前容器的孩子容器集合
protected final WindowList<E> mChildren = new WindowList<E>();

@CallSuper
protected void addChild(E child, Comparator<E> comparator) {
......
// 1. 添加到集合中,也就是挂载
if (positionToAdd == -1) {
mChildren.add(child);
} else {
mChildren.add(positionToAdd, child);
}
// Set the parent after we've actually added a child in case a subclass depends on this.
// 2. 调用孩子容器设置父节点的方法
child.setParent(this);
}

首先将子容器添加到 mChildren 集合中,然后调用子容器的 setParent 方法。 这么2步执行后, 孩子与父容器就有绑定关系了,也就是成功挂载到了父节点执行。(细品,其实就是java集合操作)
先看一下这2个方法的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
csharp复制代码# WindowContainer

// 当前容器的孩子容器集合
protected final WindowList<E> mChildren = new WindowList<E>();

@CallSuper
protected void addChild(E child, Comparator<E> comparator) {
......
// 1. 添加到集合中,也就是挂载
if (positionToAdd == -1) {
mChildren.add(child);
} else {
mChildren.add(positionToAdd, child);
}
// Set the parent after we've actually added a child in case a subclass depends on this.
// 2. 调用孩子容器设置父节点的方法
child.setParent(this);
}

首先将子容器添加到 mChildren 集合中,然后调用子容器的 setParent 方法。 这么2步执行后, 孩子与父容器就有绑定关系了,也就是成功挂载到了父节点执行。(细品,其实就是java集合操作)

setParent 方法具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码# WindowContainer
// 父节点
private WindowContainer<WindowContainer> mParent = null;

final protected void setParent(WindowContainer<WindowContainer> parent) {
if (parent == null) {
Slog.d(TAG, "setParent old=" + mParent + ",new=" + parent + ",this window=" +
this + ",callers=" + Debug.getCallers(6));
}
final WindowContainer oldParent = mParent;
mParent = parent;
......
onParentChanged(mParent, oldParent);
......
}

现在 Task 就成功找到组织了,挂着到窗口树上了。

Surface树构建-2.png

但是这个时候,SurfaceFlinger那边还是没变化的,所以继续看后续流程。

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
scss复制代码# WindowContainer

void onParentChanged(ConfigurationContainer newParent, ConfigurationContainer oldParent,
PreAssignChildLayersCallback callback) {
super.onParentChanged(newParent, oldParent);
// 正常肯定是有父节点的
if (mParent == null) {
return;
}

if (mSurfaceControl == null) {
// If we don't yet have a surface, but we now have a parent, we should
// build a surface.
// 父亲有了,但是自身还为null,则触发创建自身Surface的逻辑
createSurfaceControl(false /*force*/);
} else {
......// 有则进行 reparent
reparentSurfaceControl(getSyncTransaction(), mParent.mSurfaceControl);
}
......
}
// 重点,触发Surface的创建
void createSurfaceControl(boolean force) {
setInitialSurfaceControlProperties(makeSurface());
}

注释比较详细就不多说了,这里肯定是走createSurfaceControl()逻辑,然后注意makeSurface()方法会创建出Surface,然后再调用setInitialSurfaceControlProperties。先看 makeSurface 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
scss复制代码# WindowContainer
// 当前容器的Surface
protected SurfaceControl mSurfaceControl;

SurfaceControl.Builder makeSurface() {
// 拿到父节点,调用makeChildSurface
final WindowContainer p = getParent();
// 传递当前,也就是Task
return p.makeChildSurface(this);
}

SurfaceControl.Builder makeChildSurface(WindowContainer child) {
// 拿到父亲
final WindowContainer p = getParent();
// Give the parent a chance to set properties. In hierarchy v1 we rely
// on this to set full-screen dimensions on all our Surface-less Layers.
// 调用父亲的makeChildSurface方法,再调用setParent
return p.makeChildSurface(child)
.setParent(mSurfaceControl);
}

这里方法虽然不多,但是逻辑有点绕,做一下解释:

    1. 是子容器调用的 makeChildSurface 方法,那子容器类就是 Task,父容器就是 DefaultTaskDisplayArea
    1. 执行 父容器 makeChildSurface方法的时候,又调用了getParent 获取父容器,执行 makeChildSurface,(眉头一皱,事情并不简单)这是开始递归了。
    1. 先不管递归,总之肯定的是 makeChildSurface方法不管怎么递归返回的还是一个SurfaceControl.Builder,然后调用setParent将DefaultTaskDisplayArea的Surface设置为其父节点。

这样一来,结果就是 :Task调用 父容器的makeChildSurface后,创建出了一个Surface,并且挂载到了父容器(DefaultTaskDisplayArea)的下面。
知道结果后,还是要弄清楚 递归方法是怎么创建 Task对应的Surface的

    1. 对于递归调用,最终要的就是找到递归结束的条件,当前这个递归结束的条件就是 DisplayContent 类重写了makeChildSurface方法,也就是说调到 DisplayContent::makeChildSurface 就意味着递归的结束。

DisplayContent作为一个屏幕的最底层的容器,肯定是会调用到的,毕竟 DefaultTaskDisplayArea也是挂载在这个树上的。

所以现在来看看 DisplayContent::makeChildSurface方法

1
2
3
4
5
6
7
8
9
10
11
12
13
ini复制代码# DisplayContent
@Override
SurfaceControl.Builder makeChildSurface(WindowContainer child) {
SurfaceSession s = child != null ? child.getSession() : getSession();
// 创建一个容器类型 Surface的Builder
final SurfaceControl.Builder b = mWmService.makeSurfaceBuilder(s).setContainerLayer();
if (child == null) {
return b;
}
// 设置容器名
return b.setName(child.getName())
.setParent(mSurfaceControl);
}

这里的参数 child 就是Task,

    1. 首先创建一个容器类型 SurfaceControl.Builder
    1. 设置name,当前场景是把Task的名字设置过去
    1. 然后设置一下父亲为DisplayContent的Surface

这里要注意,这里设置父节点最终是无效的,会被覆盖掉,因为上面分析看到了把 DefaultTaskDisplayArea设置为Task父容器。从代码的执行顺序上来说,DisplayContent的这次setParent先执行,会被后面的覆盖掉。 从结果来看,Task也确实是挂在DefaultTaskDisplayArea下的。 (不可能每个容器都直接作为DisplayContent的子节点)

调用链执行完了,SurfaceFlinger层也创建并且挂载好了Task的Surface。

Surface树构建-3.png

到这里,Framework层的窗口树, SurfaceFlinger的Surface树构建的差不多了,但是手机上还是不会有内容的,为什么呢? 因为这些都是 “容器”,真正的显示需要有Buff类型的Surface。

winscope看window的SC.png

再看一次这个图, 对应的窗口树到了WindowState就结束了, SurfaceFliner 这边可以看到WindowState下还有一个节点,这个节点才是真正有UI数据的 Layer。

需要体会一下区别

在Activity启动流程中时执行到目标应用进程创建时会触发Task和ActivityRecord创建和挂载。 这个时候WindowState还没出现,另外到这一步Activity的onCreate也没执行到,所以界面上肯定是没有UI显示的。

Activity进程创建后,会先执行addWindow流程触发 WindowState的创建和挂载,但是这步执行完也还是没有画面的, 因为WindowState也是一个“容器”。
真正触发显示图层创建的是在【relayoutWindow】流程,具体的流程不是当前的主题,目前只关注【relayoutWindow】流程中“Buff”类型图层的创建。

3.1 流程概览

这里才是第一次执行relayoutWindow 创建真正显示的surface的地方
relayoutWindow的调用链如下:

1
2
3
4
5
6
7
8
arduino复制代码WindowManagerService::relayoutWindow
WindowManagerService::createSurfaceControl
WindowStateAnimator::createSurfaceLocked -- 创建“Buff” 类型Surface
WindowStateAnimator::resetDrawState -- 设置窗口状态为DRAW_PENDING
WindowSurfaceController::init
SurfaceControl.Builder::build
SurfaceControl::init
WindowSurfaceController::getSurfaceControl -- 给应用端Surface赋值

开始撸代码

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

public int relayoutWindow(Session session, IWindow client, LayoutParams attrs,
int requestedWidth, int requestedHeight, int viewVisibility, int flags,
ClientWindowFrames outFrames, MergedConfiguration mergedConfiguration,
SurfaceControl outSurfaceControl, InsetsState outInsetsState,
InsetsSourceControl[] outActiveControls, Bundle outSyncIdBundle) {
......
result = createSurfaceControl(outSurfaceControl, result, win, winAnimator);
......
}

createSurfaceControl 方法有4个参数:

  • outSurfaceControl: WMS创建好一个Surface后,还需要返回给应用端用于View的绘制,就是通过这个参数,由参数命名也可以知道这是一个“出参”。
  • result: 方法执行结果
  • win: 当前窗口对应的WindowState,稍后创建Surface会挂载到这个WindowState节点之下
  • winAnimator:WindowStateAnimator对象,管理窗口状态和动画,稍后通过其内部方法创建Surface
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
csharp复制代码# WindowManagerService
private int createSurfaceControl(SurfaceControl outSurfaceControl, int result,
WindowState win, WindowStateAnimator winAnimator) {
// 1. 创建WindowSurfaceController对象
WindowSurfaceController surfaceController;
try {
// 2. 创建“Buff”类型Surface
Trace.traceBegin(TRACE_TAG_WINDOW_MANAGER, "createSurfaceControl");
surfaceController = winAnimator.createSurfaceLocked();
} finally {
Trace.traceEnd(TRACE_TAG_WINDOW_MANAGER);
}
if (surfaceController != null) {
// 3. 出参给应用端
surfaceController.getSurfaceControl(outSurfaceControl);
// 打印日志,outSurfaceControl复制到了framework的值
ProtoLog.i(WM_SHOW_TRANSACTIONS, "OUT SURFACE %s: copied", outSurfaceControl);

}......
return result;
}

这个方法主要有三步,都是围绕着 WindowSurfaceController 来的:

    1. 先创建出一个WindowSurfaceController 对象 surfaceController
    1. 通过WindowStateAnimator::createSurfaceLocked 对 surfaceController 赋值,根据方法名猜测是创建了一个Surface
    1. 通过 WindowSurfaceController::getSurfaceControl,给应用端 Surface 赋值

这么看来重点是在第二步 WindowStateAnimator::createSurfaceLocked 是如何创建Surface的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
csharp复制代码# WindowStateAnimator

WindowSurfaceController mSurfaceController;
// WindowState的状态
int mDrawState;

WindowSurfaceController createSurfaceLocked() {
final WindowState w = mWin;
if (mSurfaceController != null) {
return mSurfaceController;
}

w.setHasSurface(false);
// 打印窗口状态
ProtoLog.i(WM_DEBUG_ANIM, "createSurface %s: mDrawState=DRAW_PENDING", this);
// 重点* 1. 重置窗口状态
resetDrawState();
......
// 重点* 2. 创建WindowSurfaceController
mSurfaceController = new WindowSurfaceController(attrs.getTitle().toString(), format,
flags, this, attrs.type);
......
return mSurfaceController;
}

这里有2个重点:

    1. 设置窗口状态为 DRAW_PENDING
    1. 创建Surface

3.2 设置窗口状态–DRAW_PENDING

1
2
3
4
5
6
7
8
9
10
11
12
13
ini复制代码# WindowStateAnimator
void resetDrawState() {
// 设置windowState状态为DRAW_PENDING
mDrawState = DRAW_PENDING;

if (mWin.mActivityRecord == null) {
return;
}

if (!mWin.mActivityRecord.isAnimating(TRANSITION)) {
mWin.mActivityRecord.clearAllDrawn();
}
}

WindowState有很多状态,以后会单独说,这里需要注意

  1. WindowState状态是保存在WindowStateAnimator中
  2. WindowStateAnimator::createSurfaceLocked方法会将WindowState状态设置为DRAW_PENDING,表示等待绘制。

3.3 创建“Buff”类型Surface

继续回到主流程,看看 WindowSurfaceController 的构造方法

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复制代码# WindowSurfaceController

SurfaceControl mSurfaceControl;

WindowSurfaceController(String name, int format, int flags, WindowStateAnimator animator,
int windowType) {
mAnimator = animator;
// 1. 也会作为Surface的name
title = name;

mService = animator.mService;
// 2. 拿到WindowState
final WindowState win = animator.mWin;
mWindowType = windowType;
mWindowSession = win.mSession;

Trace.traceBegin(TRACE_TAG_WINDOW_MANAGER, "new SurfaceControl");
// 3. 重点* 构建Surface(也是通过makeSurface 方法)
final SurfaceControl.Builder b = win.makeSurface()
.setParent(win.getSurfaceControl()) // 设置为父节点
.setName(name)
.setFormat(format)
.setFlags(flags)
.setMetadata(METADATA_WINDOW_TYPE, windowType)
.setMetadata(METADATA_OWNER_UID, mWindowSession.mUid)
.setMetadata(METADATA_OWNER_PID, mWindowSession.mPid)
.setCallsite("WindowSurfaceController");

final boolean useBLAST = mService.mUseBLAST && ((win.getAttrs().privateFlags
& WindowManager.LayoutParams.PRIVATE_FLAG_USE_BLAST) != 0);
// 高版本都为BLAST
if (useBLAST) {
// 4. 重点* 设置为“Buff”图层
b.setBLASTLayer();
}
// 触发build
mSurfaceControl = b.build();
Trace.traceEnd(TRACE_TAG_WINDOW_MANAGER);
}

这个方法有4个点

  1. 第一个参数传递的字符串最终也会作为Surface的name
  2. 获取到WindowState对象,后面会设置为创建Surface的父节点
  3. 构建出一个Surface对象, 注意name和 父节点的设置。 另外可以知道也是通过makeSurface()方法构建的, 这个方法在 2.1小结看到是构建出一个“容器”类型的Surface。
  4. 将Surface设置为“Buff”类型,这个非常重要,因为上一步默认还是“容器”类型,所以需要设置成“Buff”类型,再后面就是build出一个Surface了

那么到这里Surface的创建就完成了,这里可能有的人如果对Surface知识不太清楚的话会比较迷糊,WindowSurfaceController,SurfaceController,Surface到底是什么关系,这个不在当前流程的重点,暂且理解为同级吧,有WindowSurfaceController就可以拿到内部的SurfaceController,而SurfaceController又可以获取到Surface。

3.4 返回Surface到应用端

最后再来看一下 WMS这边创建好后的Surface是如何设置给应用端的。

应用端View的绘制信息都是保存到Surface上的,因为必定要有一个”Buff”类型的Surface,也就是上面流程中创建的这个Surface。

应用端的ViewRootImpl触发WMS的relayoutWindow会传递一个出参 :outSurfaceControl过来, 现在WMS会通过以下方法将刚刚创建好是Surface传递到应用端。

这样一来应用端就有了可以保持绘制数据的Surface,然后就可以执行 View::draw。

1
2
3
4
5
csharp复制代码# WindowSurfaceController
void getSurfaceControl(SurfaceControl outSurfaceControl) {
// 将framework层的SurfaceControl copy给应用层传递过来的outSurfaceControl
outSurfaceControl.copyFrom(mSurfaceControl, "WindowSurfaceController.getSurfaceControl");
}

本文转载自: 掘金

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

工作不好找,简历该怎么写? 问题一:对简历的错误认知 问题二

发表于 2024-03-18

Hi,你好,我是猿java。

历时 3个月,投过上百份简历,收到的面试邀约却只有个位数,自己郁闷得快不行。

这是前两天,一位工作了 4年的 Java小伙伴向我阐述的情况。本以为是互联网寒冬,很多公司都在裁员,僧多粥少,看过他的简历后才明白这其中的原因。

作为面试官,这些年筛选过不少的技术简历,或多或少发现了不少的共性问题,这里总结成 3点,今天一起分享给你。

问题一:对简历的错误认知

在简历筛查过程中,发现最大的一个通病是求职者对简历的错误认知。很多求职者认为简历一定要把过往的工作内容详细地描述清楚,导致简历有4,5页,甚至更多。

其实,简历是对过往工作经历的简要描述,所以一定要写亮点和重点。内容最好控制在 1张 A4纸,最多不超过 2张,切勿长篇大论。

问题二:糟糕的排版

第二个比较大的问题是简历的排版,看过很多简历,有的不分栏目,内容杂乱无章;有的字体以及字体大小错乱不一;有的甚至出现很多错别字。

一份清爽的简历可以让面试官觉得很舒服,一般可以按照下面 5个部分进行描述:基本信息,个人技能,工作经历,项目经验,教育经历 。如下图:

图片

个人信息

描述个人的一些基本信息,姓名,性别,联系方式这 3项是最重要的必填项。

个人技能

描述对某些技术的掌握程度,在写技能的时候一定要注意对技术掌握程度的修饰词:了解、熟悉、熟练、精通,这个直接影响面试官对你技术考察的深度。

个人技能举例:张三在工作中只是使用过 kafka,然而他在个人技能里写成熟练掌握 kafka,结果在面试的时候一问三不知,面试结果可想而知。

所以对于个人技能给个小建议:不能讲清楚的技能最好不要写进简历

工作经历

描述在什么时间段内,在那哪家公司,担任什么担任的职务,以及离职原因, 特别要注意工作时间不能造假,现在很多大厂会做背调,一旦造假被查出,可能直接被取消 offer,甚至加入公司黑名单。

项目经验

主要描述过往做过的项目,一般需要说明项目解决的问题,负责的功能,遇到的问题,拿到的结果等等。

教育经历

描述教育经历,专业和时间,一般是最高学历。如果你是名校,学历是一个加分项,如果学历不太好,教育经历这个栏目可以不放在简历里。

问题三:项目经验抓不住重点

对于程序员这个技术工种来说,项目经验是整个简历的重头戏,它体现候选人的技术能力和核心技术价值,通过近几年的社招可以发现,面试官对项目的考察越来越多,越来越细,他们很在乎候选人对其做过的项目要知其然还要知其所以然,希望他们对项目和业务有更多的思考。

可是,在过往的简历筛选中,发现很多求职者的项目经验都是在记流水账,比如,自己只是友情客串的项目,培训机构的练手项目,没有任何技术含量的项目,有过程没结果的项目,看不到任何有价值数据的项目等等,项目罗列了一大串却没有一个能抓住重点,展现亮点。

这种简历一般很难通过筛选,它在一定意义上反映了求职者不善于总结或者没有经历过有挑战性的项目。

关于项目经验,如何突出重点和亮点,这里给出一个经典的 STAR 模型:

Situation(项目背景):介绍这个项目的一些背景,让面试官更好地去了解这个项目,了解项目复杂度和价值;

Task(要解决的问题):做这个项目要核心解决的问题是什么;

Action(采取的行动):体现自己主导或者核心参与的思考,设计,技术选型和最后的代码落地,体现出自己技术能力和核心价值;

Result(项目成果):项目拿到的结果,用结果和数据体现你的价值;

比如,这里以电商中的库存项目举例:

Situation(面临的场景) :每日订单快速增多,达到3000万/天,特别是促销期间,对服务的访问量剧增,QPS达到10w,TPQ达到 3w

Task(要解决的问题) : 如何保证高流量的访问下库存不超卖

Action(采取的行动) :根据C端流量读多写少的特点,做了库存服务器的主从分离部署,采用Redis cluster作为缓存来抗压力,然后怎么保证 Redis和Mysql数据一致等等

Result(拿到的结果) :防止库存超卖,从技术角度助力GMV,支持日均订单3000万****

介绍完简历最重要的 3个问题之后,根据以往的经验,再给出简历优化的几个建议。

几点建议

真实性:写简历的时候一定要保持真实性,简历可以适当的包装,但是不能造假,对于简历里面的内容要能完全解释清楚;

准确性:简历需要检查几次,尽量不要有错别字、不必要的语法错误和专业术语错误;

简洁性:内容最好 1张 A4纸,最多不超过 2张 A4 纸,字体最好保持一致;

格式:最好使用 PDF 格式,避免内容被篡改或者从招聘网站上下载后乱码;

命名:简历的命名最好按照:姓名-工作年限-岗位职级.pdf 方式, 比如:张三-10-Java专家,这样简历筛选时从名字就能一眼定位;

最后,简历只是敲门砖,不管面试什么岗位,一定要注意平时的能力积累和总结。最后能否通过面试主要还是看能力。

简历模板已放到公众号,关注公众号:猿java,回复:简历 即可获取。另外,有些小伙伴可能确实项目经验不多或者不知如何包装简历,如果你也有此类困惑,也可关注公众号,加我微信,我会以面试官的角度来帮你润色简历,顺便偷偷传授你一些面试的小窍门哦😊

原创好文

  • 肝了一周,彻底弄懂了 CMS收集器原理,这个轮子造的真值!
  • 9款常见的 JVM垃圾回收器
  • 美团一面:Git 是如何工作的?(推荐阅读)
  • 阿里 P7二面:Redis 执行 Lua,能保证原子性吗?
  • 当下环境,程序员需要修炼的 3项技能
  • AI是打工人的下一个就业风口吗?
  • 和斯坦福博士写代码的一个月
  • 肝了一周,这下彻底把 MySQL的锁搞懂了

本文转载自: 掘金

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

小程序用户登录:安全性与用户体验的平衡

发表于 2024-03-18

作者:汪奇超

前言

在移动互联网时代,移动应用作为连接用户和服务的桥梁,扮演着至关重要的角色。随着技术的不断发展和用户需求的日益多样化,小程序以轻量、便捷、快速启动的特点逐渐替代了传统App。
用户的信息对于服务来说也是至关重要,不仅仅只是私域运营以及大数据分析。而服务去保证用户行为是否真实有效,对于用户的认证则是所有后续操作的基础。

用户登录必要性

  • 安全性:只有认证的用户才能访问其相关信息。
  • 个性化定制:对于特定用户的特定行为轨迹,推送更适合用户的内容
  • 跨设备同步:对于用一用户的多设备访问,可以根据用户唯一标识(手机号,微信OpenId等信息)确定用户信息。
  • 数据分析:收集用户行为数据进行分析,确定产品功能的影响,以及分析改进方向。
  • 社交互动:产品可以提供用户之间的联动, 使用户之间更有互动感,也可以增加产品的辐射。

如何保证安全性

我们采用AccessToken和RefreshToken的方式鉴权业务接口。
为了安全,我们的AccessToken有效期一般设置较短,以避免被盗用。但过短的有效期会造成AccessToken经常过期。
所以有了RefreshToken,用来刷新AccessToken, 有效期稍长。

认证流程

image.png

用户体验的关注点

在小程序环境中,有他的特殊性,微信则是他的唯一标识,衍生出来的则是UnionId和OpenId。所以在小程序环境中,对于用户登录来说,对于用户的最佳体验来说就是无感。
如果产品有对于用户手机号的需求,那则需要用户操作将手机号与用户关联起来,原因是对于手机号微信等平台对于手机号有严格的把控,需要用户授权才可以获悉。

如何提升用户体验

现在来到了本篇文章的正题,如何来实现用户无感登录从而提升用户体验。

需要解决的问题

  • 自动登录
  • 如遇鉴权异常如何刷新token
  • 如何续接用户行为

思路

  • 通过平台API获取code,再通过服务端换取UnionId和OpenId,在服务端注册/登录用户,
  • 接口鉴权失败后执行refreshToken或者再次执行自动登录操作
  • 通过对request库401错误的拦截,等待重置Token后重试被拦截的借口

解决方案

这里都以微信端举例,其他平台大同小异。

自动登录

wx.login(Object object) | 微信开放文档

image.png

1
2
3
4
5
typescript复制代码const login = async() {
const { code, errMsg } = await wx.login();
// 通过接口服务端调用微信服务端接口获取用户标识,获取用户信息
const user = await loginFunc(code);
}

自动刷新Token

image.png

在request的错误拦截中判断哪些场景是不需要刷新
  • 401以外的接口错误
  • 用户已经主动退出登录
  • 某些特殊场景(业务决定)
重置用户信息
  • refreshToken
  • 静默登录
重试接口
  • 更新token
  • 重试被401拦截的接口
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
typescript复制代码/**
* 匹配状态码
* @param response
* @param statusCode
*/
const matchStatusCode = (response, statusCode) => {
return [response.status, response.data.code].includes(statusCode);
};

/**
* 是否需要重试接口
* @param error
* @param updateConfig
*/
const whetherToRetry = async (error, updateConfig) => {
const { user } = rootStore; // 全局变量
if (
!matchStatusCode(error?.response, 401) || // 去除401以外的错误
user.isLogout || // 用户主动退出登录
error?.config?.meta?.reTry === false // 某些特殊场景不需要刷新token
)
return false;
await resetUser(); // 重置用户信息(重新获取token)
if (!user.token) return false; // 获取新token失败则中断
updateConfig({ // 更新token后重试接口
headers: {
Authorization: `Bearer ${user.token}`,
},
});
return true;
}

/**
* 重置用户
*/
const resetUser = async () => {
const { user, common } = rootStore;
if (user.isLogin) {
await user.refreshToken();
} else {
await silentLogin(); // 静默登录
}
};

现在就完成了第一步,自动刷新了Token。

续接用户行为

要做到用户无感,肯定是需要延续用户之前的操作,完成用户动作。

  • 使用userInited记录用户重置状态
  • 使用requestQuene来记录等待续接的接口请求队列
  • 重置用户完成后执行所有等待队列

image.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typescript复制代码/**
* 重置用户
*/
const resetUser = async () => {
const { user, common } = rootStore;
if (!user.userInited)
return new Promise((resolve) => {
common.setRequestQueue([...common.requestQueue, resolve]);
});
user.setUserInited(false);
if (user.isLogin) {
await user.refreshToken();
} else {
await silentLogin();
}
user.setUserInited(true);
};
1
2
3
4
5
6
7
8
9
10
11
12
typescript复制代码constructor() {
reaction(
() => this.userInited,
(arg) => {
if (arg) {
const { common } = rootStore;
common.requestQueue.forEach((item) => item()); // 执行等待队列
common.setRequestQueue([]);
}
}
);
}

前置等待用户信息完成

如果用户信息还未完成初始化时,前置将需要鉴权的接口加入等待队列,减少无用的请求执行
image.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
typescript复制代码/**
* 等待初始化用户结束
*/
const waitForUserInited = async () => {
const { user, common } = rootStore;
if (user.userInited) return Promise.resolve();
// 未初始化则将请求阻塞在队列中
return new Promise((resolve) => {
common.setRequestQueue([...common.requestQueue, resolve]);
});
};

http.tap('request', async (config) => {
const { user } = rootStore;

// 需要授权接口
if (!NO_AUTH_URL.includes(config.rawURL)) {
await waitForUserInited();
config.headers.Authorization = `Bearer ${rootStore.user.token}`;
}
// ....
}

总结

  • 对于用户登录来说,安全性是最基础的保证,在这个前提下,需要做到优化用户体验,尽可以达到极致。
  • 减少用户不必要的操作,不仅对于用户体感上更佳,而且对于产品的转化也有更大的帮助。
  • 对于AT/RT都过期的情况,我们的处理方式是静默登录获取token,小程序环境下通过code获取openId关联私域会员信息

最后

📚 小茗文章推荐:

  • 老项目的外链太多?我是这么处理的
  • formily原来是这样解决这些表单难题
  • 古茗是如何将小程序编译速度提升3倍的

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

本文转载自: 掘金

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

node_koa后端——初始化配置jwt和swagger的k

发表于 2024-03-17

yma16-logo

⭐背景

大家好,我是yma16,本文分享 node_koa后端——初始化配置jwt和swagger的koa框架。

面向对象:前端初学者、前端转后端的同学

koa简介

Koa 是一个基于 Node.js 平台的下一代 web 开发框架,它采用了 ES6 的特性,基于 async/await 实现异步流程控制。在 Koa 中,路由是指将请求的 URL 路径(或者 URL + 请求方法)映射到具体的处理函数上的过程,这个过程就是路由。

Koa 提供了一个叫做 koa-router 的中间件,用于方便地处理路由。koa-router 的主要功能包括以下几个方面:

路由配置:koa-router 支持通过 put、post、get、delete 等方法定义路由,同时支持多个中间件函数。

路径参数匹配:koa-router 支持在路由路径中定义参数,方便地获取类似 /users/:id 这样的 URL 中的参数。

嵌套路由:koa-router 支持将路由进行嵌套,方便地组织路由,避免重复代码。

路由前缀:koa-router 支持在路由路径中添加前缀,方便地分组路由。

jwt简介

JWT (JSON Web Token)是一种基于 Token 的认证授权机制。

⭐上手搭建项目

💖npm init 初始化一个仓库

1
bash复制代码# npm init

image.png

安装 koa、koa2-cors、koa-jwt、swagger-jsdoc
关键依赖是koa-hwt

1
2
3
4
5
6
bash复制代码# npm install koa
# npm install koa-bodyparser
# npm install koa2-cors
# npm install koa-jwt
# npm install koa-router
# npm install koa2-swagger-ui swagger-jsdoc

💖封装路由文件自动读取

创建routes目录结构如下

1
2
3
4
5
6
7
bash复制代码-router
—routes
——index.js
—swagger
——index.js
——swagger.json
——index.js

router/index.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
javascript复制代码const fs = require('fs')
const path = require('path')
// swagger
const Router = require('koa-router');
const swaggerUI = require('koa2-swagger-ui').koaSwagger;
const swaggerSpec = require('./swagger/index');


// 路由
const router = new Router();


module.exports = app => {
// 读取当前路径下 遍历 routes目录所有文件
fs.readdirSync(path.join(__dirname, './routes')).forEach(file => {
const singleRouter = require(`./routes/${file}`);
// 虚拟路由
router.use('/api', singleRouter.routes(), singleRouter.allowedMethods());
})

//加载文档
router.get(
'/swagger',
swaggerUI({
routePrefix: false,
swaggerOptions: {
spec: swaggerSpec,
},
})
);
// app 运行 加载虚拟路由
app.use(router.routes()).use(router.allowedMethods())
}

router/swagger/index.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
javascript复制代码const swaggerJSDoc = require('swagger-jsdoc');
const path=require('path');
const fs=require('fs');
const options = {
definition: {
openapi: '3.0.0',
info: {
title: 'koa 项目 swagger 文档',
version: '1.0.0',
description: 'koa项目',
},
host: 'localhost:3000/swagger',// 想着改这里,如果不修改,那么接口文档访问地址为:localhost:8000/swagger
basePath: '/' // Base path (optional)
},
// 文档api
apis: [path.join(__dirname, '../routes/*.js')],
};

const swaggerSpec = swaggerJSDoc(options);

// 写入json
fs.writeFileSync(path.join(__dirname, 'swagger.json'), JSON.stringify(swaggerSpec, null, 2));

module.exports = swaggerSpec;

router\routes\index.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
javascript复制代码const Router = require('koa-router');
const router = new Router();

/**
* @swagger
* tags:
* name: IndexConfig
* description: IndexConfig
*/

/**
* @swagger
* components:
* schemas:
* IndexConfig:
* type: object
* properties:
* code:
* type: integer
* description: network status.
* msg:
* type: string
* description: des
*/

/**
* @swagger
* /:
* get:
* summary: return msg
* tags: [IndexConfig]
* responses:
* 200:
* description: A list of IndexConfig.
* content:
* application/json:
* schema:
* type: Object
* items:
* $ref: '#/components/schemas/IndexConfig'
*/
router.get('/', async (ctx) => {
ctx.body = [{ code: 200, msg: 'koa server'}];
});

module.exports = router;

💖主入口index.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
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
javascript复制代码const Koa = require('koa');
// jwt
const jwt = require('koa-jwt');

const { koaBody } = require('koa-body');


// 中间件
const cors = require('koa2-cors');

// 引入路由
const routing = require('./router')

const app = new Koa();

// 文件上传
app.use(koaBody({
multipart: true
}))

// 中间件
app.use(
cors({
// origin: function(ctx) { //设置允许来自指定域名请求
// // if (ctx.url === '/test') {
// // return '*'; // 允许来自所有域名请求
// // }
// // return 'http://localhost:8080'; //只允许http://localhost:8080这个域名的请求
// return '*'; // 允许来自所有域名请求
// },
origin: '*',
maxAge: 5, //指定本次预检请求的有效期,单位为秒。
credentials: true, //是否允许发送Cookie
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], //设置所允许的HTTP请求方法'
allowHeaders: ['Content-Type', 'Authorization', 'Accept'], //设置服务器支持的所有头信息字段
// exposeHeaders: ['WWW-Authenticate', 'Server-Authorization'] //设置获取其他自定义字段
})
);

// Middleware below this line is only reached if JWT token is valid
// 错误处理
app.use((ctx, next) => {
return next().catch((err) => {
if (err.status === 401) {
ctx.status = 401;
console.log('401', ctx)
ctx.body = 'Protected resource, use Authorization header to get access\n';
} else {
throw err;
}
})
});





// 注意:放在路由前面
app.use(jwt({
secret: 'yma16-app'
}).unless({ // 配置白名单 api 注册 获取 token 获取验证码
path: [/\/swagger/]
}))

// 使用路由
routing(app)



const PORT = 3333;
app.listen(PORT, () => {
console.log(`Server is running at http://localhost:${PORT}/swagger`);
});

💖node index.js运行查看效果

node index.js

运行成功!

node index
访问swagger文档http://localhost:3333/swagger

swagger doc
jwt校验访问的api 返回 Protected resource, use Authorization header to get access

image.png

⭐结束

本文分享到这结束,如有错误或者不足之处欢迎指出!
earth

👍 点赞,是我创作的动力!

⭐️ 收藏,是我努力的方向!

✏️ 评论,是我进步的财富!

💖 最后,感谢你的阅读!

本文转载自: 掘金

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

MapStructPlus 140 发布,体积更轻量!性

发表于 2024-03-17

MapStruct Plus 是 MapStruct 的增强工具,在 Mapstruct 的基础上,实现了自动生成 Mapper 接口的功能,并强化了部分功能,使 Java 类型转换更加便捷、优雅。

MapStructPlus官网

此次迎来了一次较大版本的更新,更新日志如下:

  • 优化复杂对象转换逻辑,占用元空间更小!性能更快!

此版本针对于复杂对象的自动转换逻辑,进行了优化和部分的重新设计,减少了一些不必要的转换方法,占用元空间更小。

以 RuoYi-Vue-Plus 为例,使用新版本后,元空间占用减少了 7MB,根据项目的复杂程度不同,减少的内存占用也有所不同。

且根据 issue#67 中提到,在 SpringBoot + Aop 场景下,性能会受影响, 在新版本中,修改了实现方式,性能比提升一半以上。

  • 去除 hutool 等依赖,目前项目中只依赖了 MapStruct

从 1.4.0 版本之后,去除了 MapStruct 之外的依赖,打包后体积更小。

但当需要使用 Map 与对象转换时,需要额外引入 hutool-core 依赖包。

  • 适配对象循环嵌套场景

类循环嵌套是指两个类互相引用,例如,源对象和目标对象结构都包含父对象和子对象之间的双向关联。
当存在这种情况时,直接进行转换时,会导致栈溢出的问题(stack overflow error)。

示例:

1
2
3
4
5
6
7
8
9
10
11
java复制代码@Data
public class TreeNode {
private TreeNode parent;
private List<TreeNode> children;
}

@Data
public class TreeNodeDto {
private TreeNodeDto parent;
private List<TreeNodeDto> children;
}

parent 属性可以是其他类型的,可能跨越一个更长的属性链形成的嵌套循环。

为了适配这种情况,MapStructPlus 的 AutoMapper 注解中增加了 cycleAvoiding 属性,该属性用于标识,是否需要避免循环嵌套的问题。
默认为 false,如果需要避免循环嵌套,需要将该属性设置为 true。

当配置为 true 时,在整个对象的转换过程链路中,会传递一个 CycleAvoidingMappingContext 对象,临时保存转换生成的对象,
在转换链路中,如果发现需要生成的对象已经存在,会直接返回该类型,从而避免栈溢出问题。

以上面的示例为例,在 AutoMapper 注解中,配置 cycleAvoiding 属性为 true,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码@Data
@AutoMapper(target = TreeNodeDto.class, cycleAvoiding = true)
public class TreeNode {
private TreeNode parent;
private List<TreeNode> children;
}

@Data
@AutoMapper(target = TreeNode.class, cycleAvoiding = true)
public class TreeNodeDto {
private TreeNodeDto parent;
private List<TreeNodeDto> children;
}

编译生成的转换逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码public TreeNodeDto convert(TreeNode arg0, CycleAvoidingMappingContext arg1) {
TreeNodeDto target = arg1.getMappedInstance(arg0, TreeNodeDto.class);
if (target != null) {
return target;
}

if (arg0 == null) {
return null;
}

TreeNodeDto treeNodeDto = new TreeNodeDto();

arg1.storeMappedInstance(arg0, treeNodeDto);

treeNodeDto.setParent(demoConvertMapperAdapterForCycleAvoiding.iglm_TreeNodeToTreeNodeDto(arg0.getParent(), arg1));
treeNodeDto.setChildren(
demoConvertMapperAdapterForCycleAvoiding.iglm_TreeNodeToTreeNodeDto(arg0.getChildren(), arg1));

return treeNodeDto;
}
  • AutoMapping、ReverseAutoMapping 支持 qualifiedByName、conditionQualifiedByName 和 dependsOn 属性
  • AutoMappings 支持配置在方法上面

我是「代码笔耕」,致力于打造高效简洁、稳定可靠代码的后端开发。

本文可能存在纰漏或错误,如有问题欢迎指正,感谢您阅读这篇文章,如果觉得还行的话,不要忘记点赞、评论、收藏喔!

最后欢迎大家关注我的公众号「代码笔耕」和开源项目:easii (easii) - Gitee.com

本文转载自: 掘金

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

面试题:如何让 var 【a, b】 = {a 1, b

发表于 2024-03-16

前言

这是一道字节面试题,大家第一眼看到这个面试题的时候,是不是脑瓜子嗡嗡的?有一种熟悉又陌生的感觉?我当时也一样的😭😭😭

在 JavaScript 中,解构赋值语法的左侧是一个数组,而右侧则应该是一个具有迭代器接口的对象(如数组、Map、Set等)。因此,将对象 {a: 1, b: 2} 解构赋值给 [a, b] 会导致语法错误,可偏偏面试官要求我们让这个解构赋值表达式成立,真是有苦说不出的感受啊!!!

思路

错误思路

既然将一个对象解构赋值给数组,是一个语法错误,那我们直接把这个解构语法变为对象的解构赋值语法不就好了。直接改成var { a, b } = { a: 1, b: 2 }; 如果这样做的话,哈哈哈哈哈哈,恭喜你面试结束了。

所以我们得好好想清楚,这可是一个字节的面试题,它的考点可不是这么显而易见的。

正确解题思路

我们首先来看看报错是什么样的:

var [a, b] = {a: 1, b: 2}

TypeError: {(intermediate value)(intermediate value)} is not iterable

这个错误是个类型错误,并且是对象有问题,因为对象是一个不具备迭代器属性的数据结构。所以我们可以知道,这个面试题就是考验我们对于迭代器属性的认识,我们再来个场景加深下理解。

1
2
3
4
5
6
7
8
9
10
11
12
scss复制代码let arr = [1, 2, 3]
let obj = {
a: 1,
b: 2,
c: 3
}
for(let item of arr){
console.log(item)
}
for(let item of obj){
console.log(item)
}

我们知道for of 只能遍历具有迭代器属性的,在遍历数组的时候会打印出1 2 3,遍历对象时会报这样的一个错误TypeError: obj is not iterable,那么数组上的迭代器属性究竟是什么样的呢,我们来看一看。

c9ec4305914751c05b8837de52e2cd0.png

我们可以在最下面发现,数组原型上有Symbol.iterator这样一个属性,这个属性显然是从Array身上继承到的,并且这个属性的值是一个函数体,如果我们调用一下这个函数体会怎么样?我们打印来看看

1
2
css复制代码console.log(arr.__proto__[Symbol.iterator]());
// Object [Array Iterator] {}

最重要的点来了🔥🔥🔥🔥

它返回的是一个对象类型,并且是一个迭代器对象!!!所以一个可迭代对象的基本结构是这样的:

1
2
3
4
5
6
javascript复制代码interable
{
[Symbol.iterator]: function () {
return 迭代器 (可通过next()就能读取到值)
}
}

我们可以得出只要一个数据结构身上,具有[Symbol.iterator]这样一个属性,且值是一个函数体,可以返回一个迭代器的话,我们就称这个数据结构是可迭代的。

这时候我们回到面试题之中,面试官要我们让 var [a, b] = {a: 1, b: 2} 这个等式成立,那么有了上面的铺垫,我们可以知道,我们接下来的操作就是:人为的为对象打造一个迭代器出来,也就是让对象的隐式原型可以继承到迭代器属性,我们可以先这样做:

1
2
3
4
5
6
css复制代码Object.prototype[Symbol.iterator] = function(){

}

var [a, b] = {a: 1, b: 2}
console.log(a,b);

这样的话,报错就改变了,变成:

TypeError: Result of the Symbol.iterator method is not an object

接下来,我们知道var [a, b] = [1, 2]这是肯定没有问题的,所以我们可以将对象身上的迭代器,打造成和数组身上的迭代器(arr[Symbol.iterator])一样,代码如下:

1
2
3
4
javascript复制代码Object.prototype[Symbol.iterator] = function(){
// 使用 Object.values(this) 方法获取对象的所有值,并返回这些值的迭代器对象
return Object.values(this)[Symbol.iterator]()
}

这段代码是将 Object.prototype 上的 [Symbol.iterator] 方法重新定义为一个新的函数。新的函数通过调用 Object.values(this) 方法获取对象的所有值,并返回这些值的迭代器对象。

通过这个代码,我们可以使得任何 JavaScript 对象都具有了迭代能力。例如,对于一个对象 obj,我们可以直接使用 for...of 循环或者 ... 操作符来遍历它的所有值。

结尾 🌸🌸🌸

看到这里,恭喜你解决了一个字节面试题!!!在被面试官询问这种熟悉又陌生的问题时,你也可以很好的应对了。最后祝你也祝我在今后日子里能够登高望远,心向彼岸。

本文转载自: 掘金

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

一行代码极速部署:开源流数据库推出面向个人开发者的流计算平台

发表于 2024-03-15

前言

1
arduino复制代码curl https://risingwave.com/sh | sh

2021 年初,我创立了 RisingWave,目标是让流计算技术普及化。在过去的三年中,我不断地向市场布道,希望 RisingWave 能在流计算市场中占有一席之地。经过不懈努力,如今,RisingWave 已被数百家企业采用,这些企业遍布各个领域,包括一些世界顶尖的金融交易、制造、安全、航空航天等领域的公司。

为大型企业提供优质服务总是令人感到兴奋。然而,我也始终在思考一个问题:为什么总是大公司?为什么不是中小型企业或者个人?
将流计算技术以开发者为中心来设计是否切实可行?
换句话说,个人开发者或中小型企业是否能够真正利用流计算技术的强大功能?

  1. 数据系统市场的普及化趋势

想要“让流计算技术普及化”并不是我的脑洞大开。如今的数据系统市场,云上的数据库服务已经呈现了很明显的向开发者市场下沉的趋势。

正如其他任何先进技术的发展历程一样,各类数据库通常都是首先被顶尖科技公司等利用,然后随着时间的推移,逐渐被广大人民群众,尤其是个人开发者与中小型公司,所使用。

技术普及化需要有两大前提同时被满足:

  • 从用户角度出发,开发者及中小型公司有足够的需求;
  • 从产品角度出发,市场上有足够好用便宜的产品。

对于 OLTP 数据库这一领域来说,技术普及化已经实现了:从搭建个人网站到构建大型服务,只要用户有存储数据的需求,都可能会选择使用 OLTP 数据库。PostgreSQL、MySQL 这类成熟的数据库早已被无数公司部署在生产环境中,而云上 SaaS 产品如 Supabase、Neon 等将 PostgreSQL 带到云上,并提供了一系列工具,让开发者可以使用小几十美金一个月的价格来搭建应用。

Supabase 的定价:个人开发者可以选择使用免费版本或者25美金一个月的服务。

Neon 的定价:个人开发者可以选择使用免费版本或者19或69美金一个月的服务。

对于 OLAP 数据库来说,技术普及化也正快速发展。当用户需要进行一定的数据统计与分析时,PostgreSQL 等操作型数据库就难以胜任了。选择 Snowflake、Redshift 这样的数据仓库系统对开发者来说成本高且复杂。如今,像 ClickHouse 这样的 OLAP 数据库,即便在单机环境下也能进行高效的数据分析。初创公司 Tinybird 基于 ClickHouse 搭建了无服务的计算平台,让大家能以很低的价格进行数据分析。

Tinybird的定价:个人开发者可以选择使用免费版本或者根据用量付费。

在 OLTP 与 OLAP 数据库这两个赛道中,我们都看到了同样的情况。但流计算已经经历了二十多年的发展,我们似乎并没有看到任何一个流计算产品真正下沉到开发者市场中,在个人开发者与中小型公司内部普及。我们如果看一下几个常见的流计算产品,无论是 Spark Streaming 还 Flink,目前更多应用在了具有一定规模工程团队的科技公司中。对于个人或者小团队来说,部署使用这类大数据组件很显然门槛较高。
可以想象一下,当一个小团队还在为构建产品雏形而加班加点的时候,几乎不可能有时间去考虑自己如何使用一套流计算框架。他们想要的仅仅是一个开箱即用的工具产品罢了。

在流计算领域里,面向中小型企业与开发者的系统还是一片蓝海。

思考到这里,我们不难发现,在流计算领域,面向企业的流计算系统已经日渐饱和,形成了竞争激烈的红海。相反,针对开发者的流计算系统似乎仍是一片未被充分开发的蓝海。我们不禁会想,在 2024 年这个时间点上,让流计算普及化的时机是否已经成熟?

  1. 流计算的普及化:从用户角度分析

在当前的技术环境中,流计算的应用范围越来越广泛,但关键的问题在于:个人开发者和中小型企业是否真正需要流计算技术? 从根本上来说,流计算适用场景必须同时满足两个核心条件:

  • 首先,数据以流的形式不断被摄入系统;
  • 其次,用户需要对这些即时的数据流进行分析以提取信息。

世界上的数据流的来源还是相对比较广泛的,包括但不限于:

  • 网站上的用户行为日志(页面访问、点击等等)
  • IoT 设备数据
  • 社交媒体数据
  • 金融交易数据
  • 电商订单、支付数据

在这些领域中,如果有开发者希望开发新的产品,并且这些产品需要对数据进行及时的分析,那么就可能需要流计算技术。例如,如果我们希望从股票交易中监控某只股票的波动范围,或者从电子设备回传的数据中分析异常情况,那都是使用流计算技术的非常好的场景。

那么实时数据同步与实时 ETL 场景,是否也是可以被开发者所利用呢?虽然对开发者具有一定吸引力,但实际上门槛可能还是相对较高。这是因为实时数据同步或 ETL 通常涉及至少两个系统的协作,在引入流计算系统后,开发者需要同时维护三套系统。对于个人开发者或小型创业团队来说,这样的成本是相当高的,因此这可能并非一个理想的应用场景。

总的来说,我认为从用户角度分析,流计算普及化的前置条件是成立的。

  1. 流计算的普及化:从产品角度分析

我们接着从产品角度来分析。无论是个人开发者还是小的开发团队,都是希望将精力专注于快速开发迭代产品,而非研究底层数据系统架构。数据系统的定位就是工具,而工具就是为了给人以更好更快解决问题的手段。

当我们要开发一个面向开发者的数据系统时,我相信需要满足以下特征:

  • 能单机部署,不依赖于 Docker 或 Kubernetes 等容器,如果能够做到嵌入式那是更好;
  • 能提供 all-in-one 解决方案,不要求所有能力做到最好,但是需要提供各种能力:用户永远希望简化架构,而不希望把很多系统堆叠起来;
  • 简单易用,上手门槛极低;
  • 与各种其他开发者工具打通。

如果按照这一标准来寻找流计算系统,很显然我们在市面上的可选项极其有限了。幸运的是,RisingWave 就是极个别能够满足所有选项的流计算系统。

  1. 流计算与批计算

我们讨论了这么多,其实最后又不得不回到一个老生常谈的问题:为什么需要流计算?用批计算不好吗?市面上不少批计算系统(尤其是 OLAP 数据库)都已经支持实时写入数据,那么为什么不直接使用批系统呢?我认为至少从三个方面来说,流计算系统拥有独一无二的优势:

  • 低延迟结果更新的要求强烈。对于金融交易、欺诈检测等场景来说,用户需要的往往是秒级甚至毫秒级的系统响应时间。对于这类应用来说,流计算也许是唯一的解法。OLAP 数据库中带有的物化视图功能尽管能够解决部分功能,但面对带有大状态的复杂查询的时候,可能就力不从心了。
  • 增量计算带来的好处明显大于存量计算。流计算使用的是增量计算模型,不难想象,这种计算模型大大避免了不必要的重复计算,使得计算效率得到大幅提升。
  • 流计算的思考方式更加直观。对于 IoT、金融等对于计算顺序有着强烈需求的场景来说,使用流计算更加符合正常思维方式,而批计算的计算反而可能“反直觉”。例如,如果我们想要不断监控过去 10 分钟内某只股票的平均价格,显然流计算的方式会更加容易被人接受。

当一个用户的使用场景满足这三个方面之一或更多时,我相信用户就可能会更加倾向于使用流计算系统。

  1. RisingWave 本地版的设计理念

未来总是充满未知,但与其等待答案,不如自己寻找答案。在 RisingWave 最新的 1.7 版本中,我们推出了面向开发者的本地版,希望通过这一版本让 RisingWave 触及广大开发者,让开发者能够轻松享受到流计算带来的价值。

RisingWave 本地版的核心设计理念就是“极简”。

5.1 安装部署简单

RisingWave 本地版的最大特色之一便是安装部署极其简单。开发者能够通过简单的一行命令就在他们的本地电脑上(Mac 或者 Ubuntu)安装 RisingWave:

1
arduino复制代码curl https://risingwave.com/sh | sh

用户也不需要使用 Kubernetes 或 Docker 等容器,真正实现了裸机安装。对于关心程序体积的用户,我们还提供了一些编译选项,可以将程序大小压缩到约 140 MB。如果对这一大小还感到不满意,也欢迎与我们联系,商量一下更小打包的方案。

5.2 All-In-One

与 Flink、Spark Streaming 等传统流计算系统不同,RisingWave 自带存储功能,也就是说,用户不再需要寻找一个所谓“下游数据库”来存储流计算结果。在支持流计算的同时,RisingWave 也支持对存储在自身内部的数据进行随机查询。对于用户来讲,RisingWave 实现了从计算到存储再到服务的所有功能。

5.3 上手门槛极低

RisingWave 兼容的是 PostgreSQL 语法。用户可以直接写 SQL 语句便进行流计算,完全不需要学习 Java/Scala 等语言的 API,更不需要了解如 Checkpoint、Savepoint 等系统内部细节。

5.4 丰富的系统集成

RisingWave 支持了数十种常用系统与管理工具的集成。对于开发者常用的 MySQL、PostgreSQL、MongoDB 等数据库,RisingWave 可以一条语句直接连接,免除中间消息队列等组件。感谢 PostgreSQL 的生态,RisingWave 同样可以与 Grafana、Superset、DBeaver、dbt 等可视化、管理、建模等工具无缝集成,大幅提升用户体验。

  1. 后记

RisingWave 本地版寄托着我们对流计算普及化的美好愿景,是我们对于流计算技术发展的一次探索。探索未知自然意味着风险与挑战,我们诚挚地希望读者们能够给予更多的支持,与我们携手共探未来的边界!

关于 RisingWave

RisingWave 是一款分布式 SQL 流处理数据库,旨在帮助用户降低实时应用的的开发成本。作为专为云上分布式流处理而设计的系统,RisingWave 为用户提供了与 PostgreSQL 类似的使用体验,并且具备比 Flink 高出 10 倍的性能以及更低的成本。

🔧如果你还不知道如何上手 RisingWave,请体验我们的入门教程:github.com/risingwavel…

💻想要更深入地理解并使用 RisingWave,请阅读我们的用户文档:zh-cn.risingwave.com/docs/curren…

🔍关于更多常见问题及答案,欢迎大家来这里搜索留言:github.com/orgs/rising…

本文转载自: 掘金

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

记录一次线上 GO 服务 oom 排查以及内存优化思路「附G

发表于 2024-03-15

写作背景

最近业务高峰期自动化营销某服务内存告警频繁、偶尔 oom,该服务主要是处理大量数据(工作日每天数据几百万)执行自动化操作。

最近也没有迭代、也没有改造底层触发引擎层,难道是数据量又增加了?马上打开监控果不其然,数据量增加了不少。

问题定位

问题是由可观测平台的一条告警发现的,因为业务非常重要,有任何告警我们都不会错过。
image.png

内存资源快打满了,但cou资源并不高,打开 grafana 监控。
image.png

业务高峰期内存和 cpu 都有明显瞬时波峰。另外内存消耗板块可以看出已有 pod 重启了。

尝试用下命令抓内存数据分析下。(ip和端口是我本地模拟的,非线上ip)

1
go复制代码go tool pprof http://192.168.50.73:6060/debug/pprof/heap

选择 pdf 即可
image.png

打开 pdf 文件,线头越粗表示内占用越高。发现 NewLz4Provider 函数内存使用高,看了pulsar 包源码,数据压缩用的 lz4。
image.png

压缩方式有多种,于是同事对底层压缩方式做了压测。
image.png

看了下压测结果,我们并没有着急替换底层压缩方式,我们使用的内部组件对 puslar client 进行二次封装用了协程池,并发数越高内存占用也越高 ,评估下来应该没有问题(可以调整协程池降低协程数量)。

虽然配置了监控 cpu、内存达到某一个阀值自动抓 pod 运行时内存、cpu 数据。瞬时波峰时间比较短,存活的对象内存分配采样很难抓到,决定重新研究下 pprof,发现 allocs 可以查看过去所有的内存分配,这里面会不会有蛛丝马迹?决定研究一番,如下图:
image.png

执行下面命令

1
go复制代码go tool pprof http://192.168.50.73:6060/debug/pprof/allocs

选择 pdf
image.png

找到 pdf 文件打开,一路往下拉发现有两处历史内存分配比较高。

日志库

日志库历史内存分配如下:

image.png

redis 库

redis 库历史内存分配如下:
image.png

研究了这两处代码调用都指向了 go 官方 json 库,排查了一波线上埋点日志发现在业务高峰期

日志打的多,日志 body 基本是中型数据。

这两处日志输出在业务高峰期和内存波动基本吻合。

猜测因为这两个基础组件底层都用了 golang json 库,json 函数序列化和反序列化内存占用比较大,在业务高峰期,造成内存波动大。

问题优化

于是决定按照下面三条优化方案快速发布上线看看效果

1、减少日志输出,非必要场景去掉日志打印。

2、减少日志包大小,部分场景只打印关键字段,用于定位问题。

3、决定换一个 json 库。

json 库调研

替换 json 库我主要考虑下面 2 方面

1、编码和解码性能高;

2、兼容官方 json 库,可以做到无缝替换,代码改造范围控制在最小。

主要调研了下面几款 json 库。

json-iterator

github 地址

1
vbnet复制代码https://github.com/json-iterator/go

100% 兼容官方 json 库,非常友好,并且性能也很高。
image.png

官方性能压测结果:

github.com/json-iterat…

于是决定翻翻使用姿势,使用上可以比较方便替换官方库,没啥改动成本低。
image.png

jsonparser

github 地址

1
bash复制代码github.com/buger/jsonparser

image.png

性能好,但只有json字符串解析为结构体/map功能,没有将结构体转为json字符串的功能。

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
css复制代码func main() {
data := []byte(`{
  "person": {
"name": {
  "first": "Leonid",
  "last": "Bugaev",
  "fullName": "Leonid Bugaev"
},
"github": {
  "handle": "buger",
  "followers": 109
},
"avatars": [
  { "url": "https://avatars1.githubusercontent.com/u/14009?v=3&s=460", "type": "thumbnail" }
]
  },
  "company": {
"name": "Acme"
  }
}`)

val, tp, offset, err := jsonparser.Get(data, "person", "github", "handle")
if err != nil {
panic(err)
}
fmt.Println(string(val))
fmt.Println(tp)
fmt.Println(offset)
}

通过字符匹配获取数值我觉得不好用,果断放弃了。

fastjson

github 地址

1
bash复制代码github.com/valyala/fastjson
1
2
3
4
5
6
7
8
9
10
11
12
swift复制代码var p fastjson.Parser
v, err := p.Parse(`{
                "str": "bar",
                "int": 123,
                "float": 1.23,
                "bool": true,
                "arr": [1, "foo", {}]
        }`)
if err != nil {
panic(err)
}
fmt.Printf("foo=%s\n", v.GetStringBytes("str"))

性能也很好但是只能解析JSON字符串,而没法生成JSON(即只有Unmarshal,没有Marshal)。看仓库已经很久没人维护了,也果断放弃了。

sonic(字节)

github 地址

1
arduino复制代码https://github.com/bytedance/sonic

image.png

基本是兼容官方库的,官方给出的压测结果来看比 json-iterator 性能还高,如下截图:
image.png

参考地址:github.com/bytedance/s…

压测性能对比

由于 json-iterator 比较主流、sonic 性能最好,最后决定在 go 官方库、json-iterator、sonic之间压测做对比。

sonic 有一个兼容性要考虑,大家注意下:
image.png

编码和解码性能对比

进入压测文件目录,执行下面命令

1
ini复制代码go test -test.bench=".*" -benchmem

编码 Marshal
image.png

解码 Unmarshal
image.png
sonic 真的神奇,解码只分配了 4 次内存,单次耗时是标准库的 1/3;sonic 单次分配消耗内存是最高的;标准库表现就没那么惊艳了单次耗时慢,分配次数也不低;json-iterator 内存分配次数最高,单次分配内存最小,单次分配最快。

内存消耗测试

由于 sonic 内存分配这块非常牛逼,于是我决定测试下这三个包真实内存消耗(只测试了解码),重点研究下 sonic。

验证内存分配情况我比较喜欢用下面两种方案。

1、pprof;

2、runtime.ReadMemStats。

先介绍 TotalAlloc、HeapAlloc、Alloc 三个关键字区别

TotalAlloc:分配过堆内存累计字节数,随着内存分配的增加而增加,但不受 GC 影响,所以不会减少。

HeapAlloc、Alloc:已分配堆对象的字节数,随着 GC 清理而减少,

Sonic 历史分配了 29438 MB+ 内存。

1
ini复制代码sonic库,TotalAlloc: 30710325328,HeapAlloc=3815240,HeapAlloc=3815240

image.png

GO 官方标准库分配了 5373 MB+ 内存,比 Sonic 分配还低。

1
ini复制代码std标准库,TotalAlloc: 5634131232,HeapAlloc=756392,HeapAlloc=756392

image.png

json-iterator 分配了 4000 MB+ 内存,对比下来 json-iterator 历史分配内存是最低的。

1
ini复制代码iterator库,TotalAlloc: 4227468600,HeapAlloc=3024600,Alloc=3024600

image.png

从内存分配结果来看,sonic 在整个压测过程中历史分配过的内存是最大的。垃圾回收之后内存差异不大。

sonic 内存为什么分配这么大

于是继续翻了翻官方文档,看了下面这段描述研发 sonic 背景是优化他们的 cpu 资源。参考:
github.com/bytedance/s…

image.png

看来对内存优化这块可能并没有那么好,但看了一些官方解决方案,决定尝试下。

预热

在使用 Marshal()/Unmarshal() 前运行了 Pretouch() 没有啥效果,因为我们的场景并非大模式。

image.png

字符串拷贝

image.png

翻了翻 sonic.Unmarshal() 源码,Unmarshal 使用默认 Config ConfigDefault ,CopyString 为 true 指解码器通过复制而不是引用来解码字符串值。源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
go复制代码var (
// ConfigDefault is the default config of APIs, aiming at efficiency and safty.
    ConfigDefault = Config{}.Froze()

// ConfigStd is the standard config of APIs, aiming at being compatible with encoding/json.
    ConfigStd = Config{
        EscapeHTML : true,
        SortMapKeys: true,
        CompactMarshaler: true,
        CopyString : true,
        ValidateString : true,
}.Froze()

// ConfigFastest is the fastest config of APIs, aiming at speed.
    ConfigFastest = Config{
        NoQuoteTextMarshaler: true,
        NoValidateJSONMarshaler: true,
}.Froze()
)

func Unmarshal(buf []byte, val interface{}) error {
return ConfigDefault.Unmarshal(buf, val)
}

稍微改造下代码,CopyString 设置为 false。

1
2
3
4
5
6
7
8
go复制代码config := sonic.Config{
CopyString: false,
}.Froze()

err := config.Unmarshal(mediumFixture, &data)
if err != nil {
panic(err)
}

测试后并没有太大区别。

如果你在使用过程中,ConfigDefault 不满足你的需求,sonic 支持你自定义配置,参考:sonic.Config 里面有一些你可以自定义配置 。

泛型的性能优化

我们是完全解析场景,Get()+Unmarshal() 方案是用不上了。
image.png

意外外发现

另外同事发现有一个 issue ,打包后可执行文件翻倍了(我没有亲测过)。
Execute file size is too big, can sonic be optimized when compile? · Issue #574 · bytedance/sonic · GitHub

image.png

image.png

看官方描述是为了提高 C-Go 内部调用性能,从回复来看这个 issue 目前还没有解决哦。

另外发现 gin 框架也支持 sonic 了。
image.png

benchmark 代码

下面是我写的压测代码,大家可以相互探讨下。

std 标准库

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
less复制代码import (
"encoding/json"
"fmt"
"net/http"
"runtime"
"testing"
)

func BenchmarkUnmarshalStdStruct(b *testing.B) {
b.N = n
b.ReportAllocs()

// TODO 如果仅压测可以去掉下面这3行代码
//g.Go(func() error {
// return http.ListenAndServe("192.168.50.73:6060", nil)
//})

var (
m    runtime.MemStats
data MediumPayload
)
for i := 0; i < b.N; i++ {
json.Unmarshal(mediumFixture, &data)
}

runtime.ReadMemStats(&m)
fmt.Printf("std 标准库TotalAlloc: %d,HeapAlloc=%d,HeapAlloc=%d\n", m.TotalAlloc, m.HeapAlloc, m.Alloc)

// TODO 如果仅压测可以去掉下面这几行代码
//if err := g.Wait(); err != nil {
// panic(err)
//}
}

func BenchmarkMarshalStd(b *testing.B) {
b.N = n
b.ReportAllocs()
var data MediumPayload

json.Unmarshal(mediumFixture, &data)
for i := 0; i < b.N; i++ {
json.Marshal(data)
}
}

sonic(字节)

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
less复制代码import (
"fmt"
"github.com/bytedance/sonic"
"net/http"
_ "net/http/pprof"
"runtime"
"testing"
)

func BenchmarkUnmarshalSonic(b *testing.B) {
b.N = n
b.ReportAllocs()

// TODO 如果仅压测可以去掉下面这3行代码
//g.Go(func() error {
// return http.ListenAndServe("192.168.50.73:6060", nil)
//})

var (
m    runtime.MemStats
data MediumPayload
)
for i := 0; i < b.N; i++ {
sonic.Unmarshal(mediumFixture, &data)
}

runtime.ReadMemStats(&m)
fmt.Printf("Sonic 标准库TotalAlloc: %d,HeapAlloc=%d,HeapAlloc=%d\n", m.TotalAlloc, m.HeapAlloc, m.Alloc)

// TODO 如果仅压测可以去掉下面这几行代码
//if err := g.Wait(); err != nil {
// panic(err)
//}
}

func BenchmarkMarshalSonic(b *testing.B) {
b.N = n
b.ReportAllocs()

var data MediumPayload
sonic.Unmarshal(mediumFixture, &data)
for i := 0; i < b.N; i++ {
sonic.Marshal(data)
}
}

json-iterator

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
less复制代码import (
"fmt"
jsoniter "github.com/json-iterator/go"
"golang.org/x/sync/errgroup"
"net/http"
_ "net/http/pprof"
"runtime"
"testing"
)

var jsonIterator = jsoniter.ConfigCompatibleWithStandardLibrary

var (
n = 11000000
g errgroup.Group
)

func BenchmarkUnmarshalJsoniter(b *testing.B) {
b.N = n
b.ReportAllocs()

// TODO 如果仅压测可以去掉下面这3行代码
//g.Go(func() error {
// return http.ListenAndServe("192.168.50.73:6060", nil)
//})

var (
m    runtime.MemStats
data MediumPayload
)
for i := 0; i < b.N; i++ {
jsonIterator.Unmarshal(mediumFixture, &data)
}

runtime.ReadMemStats(&m)
fmt.Printf("iterator 标准库TotalAlloc: %d,HeapAlloc=%d,HeapAlloc=%d\n", m.TotalAlloc, m.HeapAlloc, m.Alloc)

// TODO 如果仅压测可以去掉下面这几行代码
//if err := g.Wait(); err != nil {
// panic(err)
//}
}

func BenchmarkMarshalJsoniter(b *testing.B) {
b.N = n
b.ReportAllocs()

var data MediumPayload
jsonIterator.Unmarshal(mediumFixture, &data)
for i := 0; i < b.N; i++ {
jsonIterator.Marshal(data)
}
}

json 库替换+上线效果

从监控来看,内存优化才是本次重点,最终决定用 json- iterator 替换官方 json 库,代码改造也非常简单。替换代码如下:

1
2
3
4
5
6
7
kotlin复制代码var (
json = jsoniter.ConfigCompatibleWithStandardLibrary
)

var data YourStruct
out, err := json.Marshal(data)
json.Unmarshal(out, &data)

为什么会用 ConfigCompatibleWithStandardLibrary ?翻了翻源码,官方给出的是 100% 兼容标准库。

1
2
3
4
5
6
csharp复制代码// ConfigCompatibleWithStandardLibrary tries to be 100% compatible with standard library behavior
var ConfigCompatibleWithStandardLibrary = Config{
EscapeHTML:             true,
SortMapKeys:            true,
ValidateJsonRawMessage: true,
}.Froze()

当然他也有默认的 Config,也支持自定义参数,源码位置参考:

1
bash复制代码github.com/json-iterator/go@v1.1.12/config.go

下面是上线后优化效果

从最近几天的监控来看,按照下面3点优化后是有效果的。

1、减少日志输出,非必要场景去掉日志打印。

2、减少日志包大小,部分场景只打印关键字段,用于定位问题。

3、决定换一个 json 库。

企业微信截图_89804e93-e8e5-43ae-aa96-54d2a14758c5.png

看监控和 pprof 采样,pod 常驻内存还是不小,后续还会持续优化。如果想了解后续优化方案关注我。

2024.03.20 日更新

使用 jsoniter 发现在嵌套情况下,会 Panic。

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
css复制代码import (
"fmt"
jsoniter "github.com/json-iterator/go"
"testing"
)

var (
json = jsoniter.ConfigCompatibleWithStandardLibrary
)

type A struct {
B *B
}

type B struct {
A *A
}

func TestJson(t *testing.T) {
var a = A{}
var b = B{}
a.B = &b
b.A = &a

bb, _ := json.Marshal(a)
fmt.Println(string(bb))
}

上面代码结果输出如下

1
2
3
4
5
6
7
arduino复制代码=== RUN   TestJson
runtime: goroutine stack exceeds 1000000000-byte limit
runtime: sp=0xc020460320 stack=[0xc020460000, 0xc040460000]
fatal error: stack overflow

runtime stack:
runtime.throw({0x139be44?, 0x1624d40?})

使用上有一些缺陷,所以大家在替换时需谨慎。

2024.04.22更新

上文不是提过修改 puslar底层压缩方式吗?最近把压缩方式调成”ZLIB”压缩方式,上线后效果如下。
image.png

参考文献

sonic :基于 JIT 技术的开源全场景高性能 JSON 库

GitHub - bytedance/sonic: A blazingly fast JSON serializing & deserializing library

GitHub - json-iterator/go: A high-performance 100% compatible drop-in replacement of “encoding/json”

本文转载自: 掘金

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

1…474849…956

开发者博客

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