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

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


  • 首页

  • 归档

  • 搜索

深度解读《深度探索C++对象模型》之默认构造函数

发表于 2024-04-16

接下来我将持续更新“深度解读《深度探索C++对象模型》”系列,敬请期待,欢迎关注!也可以关注公众号:iShare爱分享,自动获得推文。

提到默认构造函数,很多文章和书籍里提到:“在需要的时候编译器会自动生成一个默认构造函数”。那么关键的问题来了,到底是什么时候需要?是谁需要?比如下面的代码会生成默认构造函数吗?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
c++复制代码#include <cstdio>

class Object {
public:
int val;
char* str;
};

int main() {
Object obj;
if (obj.val == 0 || obj.str == nullptr) {
printf("1\n");
} else {
printf("2\n");
}

return 0;
}

答案是不会,为了获得确切的信息,我们把它编译成汇编语言,编译器是否有在背后给我们的代码增加代码或者扩充修改我们的代码,编译成汇编代码后便一目了然。下面是上面代码对应的汇编代码:

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
makefile复制代码main:                                   # @main
push rbp
mov rbp, rsp
sub rsp, 32
mov dword ptr [rbp - 4], 0
cmp dword ptr [rbp - 24], 0
je .LBB0_2
cmp qword ptr [rbp - 16], 0
jne .LBB0_3
.LBB0_2:
lea rdi, [rip + .L.str]
mov al, 0
call printf@PLT
jmp .LBB0_4
.LBB0_3:
lea rdi, [rip + .L.str.1]
mov al, 0
call printf@PLT
.LBB0_4:
xor eax, eax
add rsp, 32
pop rbp
ret
.L.str:
.asciz "1\n"
.L.str.1:
.asciz "2\n"

从生成的汇编代码中并没有看到默认构造函数的代码,说明编译器这时不会为我们生成一个默认构造函数。

上面的C++例子中,程序的意图是想要有一个默认构造函数来初始化两个数据成员,这种情况是上面提到的“在有需要的时候”吗?很显然不是。这是程序的需要,是需要写代码的程序员去做这个事情,是程序员的责任而不是编译器的责任。只有编译器需要的时候,编译器才会生成一个默认构造函数,而且就算编译器生成了默认构造函数,类中的那两个数据成员也不会被初始化,除非定义的对象是全局的或者静态的。请记住,初始化对象中的成员的责任是程序员的,不是编译器的。现在我们知道了在只有编译器需要的时候才会生成默认构造函数,那么是什么时候才会生成呢?下面我们分几种情况来一一探究。

类中含有默认构造函数的类类型成员

编译器会生成默认构造函数的前提是:

  1. 没有任何用户自定义的构造函数;
  2. 类中至少含有一个成员是类类型的成员。

在上面的例子中我们增加一个类的定义,此类定义了一个默认构造函数,然后在Object类的定义里面增加一个这个类的对象成员,增加代码如下:

1
2
3
4
5
6
7
8
9
10
c++复制代码class Base {
public:
Base() {
printf("Base class default constructor\n");
}
int a;
};

// 在Object类的定义里加上
Base b;

编译后运行输出:

1
2
text复制代码Base class default constructor
2 // 这一行的输出是随机的,暂时先不管

从结果可以看到,Base类的默认构造函数被调用了,那么肯定是有一个地方来调用它的,调用它的地方就在Object类的默认构造函数里面,我们来看下汇编代码,节选部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
vbnet复制代码main:                                   # @main
push rbp
mov rbp, rsp
sub rsp, 32
mov dword ptr [rbp - 4], 0
lea rdi, [rbp - 32]
call Object::Object() [base object constructor]
cmp dword ptr [rbp - 32], 0
je .LBB0_2
cmp qword ptr [rbp - 24], 0
jne .LBB0_3

Object::Object() [base object constructor]: # @Object::Object() [base object constructor]
push rbp
mov rbp, rsp
sub rsp, 16
mov qword ptr [rbp - 8], rdi
mov rdi, qword ptr [rbp - 8]
add rdi, 16
call Base::Base() [base object constructor]
add rsp, 16
pop rbp
ret

从上面main函数的汇编代码里面第7行看到调用了Object::Object()函数,这个就是编译器为我们代码生成的Object类的默认构造函数,看看这个构造函数的汇编代码,在第20行代码里看到它调用了Base::Base(),也就是调用了Base的默认构造函数。

我们再仔细看一下Object类的默认构造函数的汇编代码,发现里面根本没有给两个成员变量val和str初始化,这也确确实实地说明了,类中成员变量的初始化的责任是程序员的责任,不是编译器的责任,如果需要初始化成员变量,需要在代码中明确地对它们进行初始化,编译器不会在背后隐式地初始化成员变量。所以上面程序的输出结果是一个随机的结果,有可能是1也有可能是2,因为不知道var或者str的值到底是什么。

那么如果Base类里面没有定义了默认构造函数,那么是否还会生成默认构造函数呢?可以把Base类里的默认构造函数代码注释掉,编译试试,结果可以看到这时并不会生成默认构造函数,无论是Base类还是Object类都不会。因为这时候编译器不需要,编译器不需要生成代码去调用Base类的默认构造函数,这也验证了是否生成默认构造函数是看编译器的需要而非看程序的需要。

那如果在Object类里已经定义了默认构造函数呢?如下面的代码:

1
2
3
4
5
c++复制代码Object() {
printf("Object class default constructor\n");
val = 1;
str = nullptr;
}

上面的默认构造函数的代码里显示地初始化了两个数据成员,但并没有显示初始化类成员对象b,我们看下运行输出结果:

1
2
3
text复制代码Base class default constructor
Object class default constructor
1 // 这时这行输出结果是明确的

从结果来看,Base类的默认构造函数是被调用了的,我们并没有显示地调用它,那么它是在哪里被调用的呢?我们继续看下汇编代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ini复制代码Object::Object() [base object constructor]: # @Object::Object() [base object constructor]
push rbp
mov rbp, rsp
sub rsp, 16
mov qword ptr [rbp - 8], rdi
mov rdi, qword ptr [rbp - 8]
mov qword ptr [rbp - 16], rdi # 8-byte Spill
add rdi, 16
call Base::Base() [base object constructor]
lea rdi, [rip + .L.str.2]
mov al, 0
call printf@PLT
mov rax, qword ptr [rbp - 16] # 8-byte Reload
mov dword ptr [rax], 1
mov qword ptr [rax + 8], 0
add rsp, 16
pop rbp
ret

上面只节选了Object类的默认构造函数的汇编代码,其它的代码不用关注。上面汇编代码的第9行就是调用Base类的默认构造函数,第13到15行是给val和str赋值,[rbp - 16]是对象的起始地址,把它放到rax寄存器中,然后给它赋值为1,按声明顺序这应该是val变量,rax+8表示对象首地址偏移8字节,也即是str的地址,给它赋值为0,也就是空指针。这说明了在有用户自定义默认函数的情况下,编译器会插入一些代码去调用类类型成员的构造函数,帮助程序员去构造这个类对象成员,前提是这个类对象成员定义了默认构造函数,它需要被调用去初始化这个类对象,编译器这时才会生成一些代码去自动调用它。如果类中定义多个类对象成员,那么编译器将会按照声明的顺序依次去调用它们的构造函数。

继承自带有默认构造函数的类

编译器会自动生成默认构造函数的第二中情况是:

  1. 类中没有定义任何构造函数,
  2. 但继承自一个父类,这个父类定义了默认构造函数。

把上面的代码修改一下,Base类不再是Object的成员,而是改为Object类继承了Base类,修改如下:

1
2
3
c++复制代码class Object: public Base {
// 删除掉Base b;,其它不变
}

查看生成的汇编代码,可以看到编译器生成了Object类的默认构造函数的代码,里面调用了Base类的默认构造函数,代码这里就不贴出来了,跟上面的代码大同小异。其它的情况跟上面小节的分析很相似,这里也不再重复分析。

类中声明或者继承一个虚函数

  1. 如果一个类中定义了一个及以上的虚函数;
  2. 或者继承链上有一个以上的父类有定义了虚函数,同时类中没有任何自定义的构造函数。

那么编译器则会生成一个默认构造函数。《C++对象封装后的内存布局》一文中也提到,增加了虚函数后对象的大小会增加一个指针的大小,大小为8字节或者4字节(跟平台有关)。这个指针指向一个虚函数表,一般位于对象的起始位置。其实这个指针的值就是编译器设置的,在没有用户自定义构造函数的情况下,编译器会自动生成默认构造函数,并在其中设置这个值。下面来看下例子,去掉继承,在Object类中增加一个虚函数,其它不变,如下:

1
2
3
4
5
6
7
8
c++复制代码class Object {
public:
virtual void virtual_func() {
printf("This is a virtual function\n");
}
int val;
char* str;
};

来看下生成的汇编代码,节选Object类的默认构造函数:

1
2
3
4
5
6
7
8
9
10
typescript复制代码Object::Object() [base object constructor]:		# @Object::Object() [base object constructor]
push rbp
mov rbp, rsp
mov qword ptr [rbp - 8], rdi
mov rax, qword ptr [rbp - 8]
lea rcx, [rip + vtable for Object]
add rcx, 16
mov qword ptr [rax], rcx
pop rbp
ret

说明编译器为我们生成了一个默认构造函数,我们来看看默认构造函数的代码里面干了什么。第2、3行时保存上个函数的堆栈信息,保证不破坏上个函数的堆栈。第4行rdi寄存器保存的是第一个参数,这个值是main函数调用这个默认构造函数时设置的,是对象的首地址,第5行是把它保存到rax寄存器中。第6-8行是最主要的内容,它的作用就是设置虚函数表指针的值,[rip + vtable for Object]是虚表的起始地址,看看它的内容是什么:

1
2
3
4
text复制代码vtable for Object:
.quad 0
.quad typeinfo for Object
.quad Object::virtual_func()

它是一个表格,每一项占用8字节大小,共有三项内容,第一项内容为0,暂时先不管它,第二项是RTTI信息,这里只是一个指针,指向具体的RTTI表格,这里先不展开,第三项才是保存的虚函数Object::virtual_func的地址。所以第7行代码中加了16字节的偏移量,就是跳过前面两项,取得第三项的地址,然后第8行里把它赋值给[rax],这个地址就是对象的首地址,至此就完成了在对象的起始地址插入虚函数表指针的动作。

如果已经自定义了默认构造函数,那么编译器则会在自定义的函数里面插入设置虚函数表指针的这段代码,确保虚函数能够被正确调用。如果Object类没有定义虚函数,而是继承了一个有虚函数的类,那么它也继承了这个虚函数,编译器就会为它生成虚函数表,然后设置虚函数表指针,也就是会生成默认构造函数来做这个事情。

这里顺带提一下一个编码的误区,如果不小心可能就会掉入坑里,就是在这种情况下,如果你想要快速初始化两个数据成员,或者是受C语言使用习惯影响,直接使用memset函数来把obj对象清0,如下面这样:

1
2
3
4
c++复制代码Object obj;
memset((void*)&obj, 0, sizeof(obj));
Object* pobj = &obj;
pobj->virtual_func();

那么如最后一行使用指针或者引用来调用虚函数的时候,程序执行到这里就会崩溃,因为在默认构造函数里给对象设置的虚函数表指针被清空了,调用虚函数的时候是需要通过虚函数表指针去拿到虚函数的地址然后调用的,所以这里解引用了一个空指针,引起了程序的崩溃。所以请记住不要随便对一个类对象进行memset操作,除非这个类你确定只含有纯数据成员才可以这样做。

类的继承链上有一个virtual base class

如果类的继承链上有一个虚基类,同时类中没有定义任何构造函数,那么编译器就会自动生成一个默认构造函数,它的作用同上面分析虚函数时是一样的,就是在默认构造函数里设置虚表指针。如下面的例子:

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
c++复制代码class Grand {
public:
int a;
};

class Base1: virtual public Grand {
public:
int b;
};

class Base2: virtual public Grand {
public:
int c;
};

class Derived: public Base1, public Base2 {
public:
int d;
};

int main() {
Derived obj;
obj.a = 1;
Grand* p = &obj;
p->a = 10;

return 0;
}

想要访问爷爷类Grand中的成员a,如果是通过静态类型的方式访问,如上面代码中的第23行,那么编译时是可以确定a相对于对象起始地址的偏移量的,直接通过偏移量就可以访问到,这在编译时就可以确定下来的。如果是通过动态类型来访问,也就是说是通过父类的指针或者引用类型来访问,因为在编译时不知道在运行时它指向什么类型,它既可以指向爷爷类或者父类,也可以指向孙子类,所以在编译时并不能确定它的具体类型,也就不能确定它的偏移量,所以这种情况只能通过虚表来访问,编译器在编译时会生成一个虚表,其实是和虚函数共用同一张表,也有的编译器是分开的,不同的编译器有不同的实现方法。通过在表中记录不同的类型有不同的偏移量,那么在运行时可以通过访问表得到具体的偏移量,从而得到成员a的地址。所以需要在对象构造时设置虚表的指针,具体的汇编代码跟上面虚函数的类似。

类内初始化

在C++11标准中,新增了在定义类时直接对成员变量进行初始化的机制,称为类内初始化。如下面的代码:

1
2
3
4
5
6
7
8
9
10
11
c++复制代码class Object {
public:
int val = 1;
char* str = nullptr;
};

int main() {
Object obj;

return 0;
}

编译成对应的汇编代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
vbnet复制代码main:                                   # @main
push rbp
mov rbp, rsp
sub rsp, 32
mov dword ptr [rbp - 4], 0
lea rdi, [rbp - 24]
call Object::Object() [base object constructor]
xor eax, eax
add rsp, 32
pop rbp
ret
Object::Object() [base object constructor]: # @Object::Object() [base object constructor]
push rbp
mov rbp, rsp
mov qword ptr [rbp - 8], rdi
mov rax, qword ptr [rbp - 8]
mov dword ptr [rax], 1
mov qword ptr [rax + 8], 0
pop rbp
ret

类内初始化,就是告诉编译器需要对这些成员变量在构造时进行初始化,那么编译器就需要生成一个默认构造函数来做这个事情,从上面的汇编代码可以看到,在Object::Object()函数里,第17、18行代码即是对两个成员分别赋值。

总结

上面的五种情况,编译器必须要为没有定义构造函数的类生成一个默认构造函数,或者在程序员定义的默认构造函数中扩充内容。这个被生成出来的默认构造函数只是为了满足编译器的需要而非程序员的需要,它需要去调用类对象成员或者父类的默认构造函数,或者设置虚表指针,所以在这个生成的默认构造函数里,它默认不会去初始化类中的数据成员,初始化它们是程序员的责任。

除了这几种情况之外的,如果我们没有定义任何构造函数,编译器也没有生成默认构造函数,但是它们却可以构造出来。C++语言的语义保证了在这种情况下,它们有一个隐式的、平凡的(或者无用的)默认构造函数来帮助构造对象,但是它们并不会也不需要被显示的生成出来。

此篇文章同步发布于我的微信公众号:编译器背后的行为之默认构造函数

如果您感兴趣这方面的内容,请在微信上搜索公众号iShare爱分享或者微信号iTechShare并关注,以便在内容更新时直接向您推送。

本文转载自: 掘金

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

美团二面:如何保证Redis与Mysql双写一致性?连续两个

发表于 2024-04-16

引言

Redis作为一款高效的内存数据存储系统,凭借其优异的读写性能和丰富的数据结构支持,被广泛应用于缓存层以提升整个系统的响应速度和吞吐量。尤其是在与关系型数据库(如MySQL、PostgreSQL等)结合使用时,通过将热点数据存储在Redis中,可以在很大程度上缓解数据库的压力,提高整体系统的性能表现。

然而,在这种架构中,一个不容忽视的问题就是如何确保Redis缓存与数据库之间的双写一致性。所谓双写一致性,是指当数据在数据库中发生变更时,能够及时且准确地反映在Redis缓存中,反之亦然,以避免出现因缓存与数据库数据不一致导致的业务逻辑错误或用户体验下降。尤其在高并发场景下,由于网络延迟、并发控制等因素,保证双写一致性变得更加复杂。

在实际业务开发中,若不能妥善处理好缓存与数据库的双写一致性问题,可能会带来诸如数据丢失、脏读、重复读等一系列严重影响系统稳定性和可靠性的后果。本文将尝试剖析这一问题,介绍日常开发中常用的一些策略和模式,并结合具体场景分析不同的解决方案,为大家提供一些有力的技术参考和支持。

image.png

谈谈分布式系统中的一致性

分布式系统中的一致性指的是在多个节点上存储和处理数据时,确保系统中的数据在不同节点之间保持一致的特性。在分布式系统中,一致性通常可以分为以下几个类别:

  1. 强一致性: 所有节点在任何时间都看到相同的数据。任何更新操作都会立即对所有节点可见,保证了数据的强一致性。这意味着,如果一个节点完成了写操作,那么所有其他节点读取相同的数据之后,都将看到最新的结果。强一致性通常需要付出更高的代价,例如增加通信开销和降低系统的可用性。
  2. 弱一致性: 系统中的数据在某些情况下可能会出现不一致的状态,但最终会收敛到一致状态。弱一致性下的系统允许在一段时间内,不同节点之间看到不同的数据状态。弱一致性通常用于需要在性能和一致性之间进行权衡的场景,例如缓存系统等。
  3. 最终一致性: 是弱一致性的一种特例,它保证了在经过一段时间后,系统中的所有节点最终都会达到一致状态。尽管在数据更新时可能会出现一段时间的不一致,但最终数据会收敛到一致状态。最终一致性通常通过一些技术手段来实现,例如基于版本向量或时间戳的数据复制和同步机制。

除此之外,还有一些其他的一致性类别,例如:因果一致性,顺序一致性,基于本篇文章讨论的重点,我们暂且只讨论以上三种一致性类别。

什么是双写一致性问题?

在分布式系统中,双写一致性主要指在一个数据同时存在于缓存(如Redis)和持久化存储(如数据库)的情况下,任何一方的数据更新都必须确保另一方数据的同步更新,以保持双方数据的一致状态。这一问题的核心在于如何在并发环境下正确处理缓存与数据库的读写交互,防止数据出现不一致的情况。

典型场景分析

  1. 写数据库后忘记更新缓存: 当直接对数据库进行更新操作而没有相应地更新缓存时,后续的读请求可能仍然从缓存中获取旧数据,导致数据的不一致。
  2. 删除缓存后数据库更新失败: 在某些场景下,为了保证数据新鲜度,会在更新数据库前先删除缓存。但如果数据库更新过程中出现异常导致更新失败,那么缓存将长时间处于空缺状态,新的查询将会直接命中数据库,加重数据库压力,并可能导致数据版本混乱。
  3. 并发环境下读写操作的交错执行: 在高并发场景下,可能存在多个读写请求同时操作同一份数据的情况。比如,在删除缓存、写入数据库的过程中,新的读请求获取到了旧的数据库数据并放入缓存,此时就出现了数据不一致的现象。
  4. 主从复制延迟与缓存失效时间窗口冲突: 对于具备主从复制功能的数据库集群,主库更新数据后,存在一定的延迟才将数据同步到从库。如果在此期间缓存刚好过期并重新从数据库加载数据,可能会从尚未完成同步的从库读取到旧数据,进而导致缓存与主库数据的不一致。

数据不一致不仅会导致业务逻辑出错,还可能引发用户界面展示错误、交易状态不准确等问题,严重时甚至会影响系统的正常运行和用户体验。

解决双写一致性问题的主要策略

在解决Redis缓存与数据库双写一致性问题上,有多种策略和模式。我们主要介绍以下几种主要的策略:

Cache Aside Pattern(旁路缓存模式)

Cache Aside Pattern 是一种在分布式系统中广泛采用的缓存和数据库协同工作策略,在这个模式中,数据以数据库为主存储,缓存作为提升读取效率的辅助手段。也是日常中比较常见的一种手段。其工作流程如下:

image.png

由上图我们可以看出Cache Aside Pattern的工作原理:

  • 读取操作:首先尝试从缓存中获取数据,如果缓存命中,则直接返回;否则,从数据库中读取数据并将其放入缓存,最后返回给客户端。
  • 更新操作:当需要更新数据时,首先更新数据库,然后再清除或使缓存中的对应数据失效。这样一来,后续的读请求将无法从缓存获取数据,从而迫使系统从数据库加载最新的数据并重新填充缓存。

我们从更新操作上看会发现两个很有意思的问题:

为什么操作缓存的时候是删除旧缓存而不是直接更新缓存?

我们举例模拟下并发环境下的更新DB&缓存:

  • 线程A先发起一个写操作,第一步先更新数据库,然后更新缓存
  • 线程B再发起一个写操作,第二步更新了数据库,然后更新缓存 当以上两个线程的执行,如果严格先后顺序执行,那么对于更新缓存还是删除缓存去操作缓存都可以,但是如果两个线程同时执行时,由于网络或者其他原因,导致线程B先执行完更新缓存,然后线程A才会更新缓存。如下图:

image.png

这时候缓存中保存的就是线程A的数据,而数据库中保存的是线程B的数据。这时候如果读取到的缓存就是脏数据。但是如果使用删除缓存取代更新缓存,那么就不会出现这个脏数据。这种方式可以简化并发控制、保证数据一致性、降低操作复杂度,并能更好地适应各种潜在的异常场景和缓存策略。尽管这种方法可能会增加一次数据库访问的成本,但在实际应用中,考虑到数据的一致性和系统的健壮性,这是值得付出的折衷。

并且在写多读少的情况下,数据很多时候并不会被读取到,但是一直被频繁的更新,这样也会浪费性能。实际上,写多的场景,用缓存也不是很划算。只有在读多写少的情况下使用缓存才会发挥更大的价值。

为什么是先操作数据库再操作缓存?

在操作缓存时,为什么要先操作数据库而不是先操作缓存?我们同样举例模拟两个线程,线程A写入数据,先删除缓存在更新DB,线程B读取数据。流程如下:

  1. 线程A发起一个写操作,第一步删除缓存
  2. 此时线程B发起一个读操作,缓存中没有,则继续读DB,读出来一个老数据
  3. 然后线程B把老数据放入缓存中
  4. 线程A更新DB数据

image.png

image.png

所以这样就会出现缓存中存储的是旧数据,而数据库中存储的是新数据,这样就出现脏数据,所以我们一般都采取先操作数据库,在操作缓存。这样后续的读请求从数据库获取最新数据并重新填充缓存。这样的设计降低了数据不一致的风险,提升了系统的可靠性。同时,这也符合CAP定理中对于一致性(Consistency)和可用性(Availability)权衡的要求,在很多场景下,数据一致性被优先考虑。

Cache Aside Pattern相对简单直观,容易理解和实现。只需要简单的判断和缓存失效逻辑即可,对已有系统的改动较小。并且由于缓存是按需加载的,所以不会浪费宝贵的缓存空间存储未被访问的数据,同时我们可以根据实际情况决定何时加载和清理缓存。

尽管Cache Aside Pattern在大多数情况下可以保证最终一致性,但它并不能保证强一致性。在数据库更新后的短暂时间内(还未开始操作缓存),如果有读请求发生,缓存中仍是旧数据,但是实际数据库中已是最新数据,造成短暂的数据不一致。在并发环境下,特别是在更新操作时,有可能在更新数据库和删除缓存之间的时间窗口内,新的读请求加载了旧数据到缓存,导致不一致。

Read-Through/Write-Through(读写穿透)

Read-Through 和 Write-Through 是两种与缓存相关的策略,它们主要用于缓存系统与持久化存储之间的数据交互,旨在确保缓存与底层数据存储的一致性。

Read-Through(读穿透)

Read-Through 是一种在缓存中找不到数据时,自动从持久化存储中加载数据并回填到缓存中的策略。具体执行流程如下:

  • 客户端发起读请求到缓存系统。
  • 缓存系统检查是否存在请求的数据。
  • 如果数据不在缓存中,缓存系统会透明地向底层数据存储(如数据库)发起读请求。
  • 数据库返回数据后,缓存系统将数据存储到缓存中,并将数据返回给客户端。
  • 下次同样的读请求就可以直接从缓存中获取数据,提高了读取效率。

image.png

image.png

整体简要流程类似Cache Aside Pattern,但在缓存未命中的情况下,Read-Through 策略会自动隐式地从数据库加载数据并填充到缓存中,而无需应用程序显式地进行数据库查询。

Cache Aside Pattern 更多地依赖于应用程序自己来管理缓存与数据库之间的数据流动,包括缓存填充、失效和更新。而Read-Through Pattern 则是在缓存系统内部实现了一个更加自动化的过程,使得应用程序无需关心数据是从缓存还是数据库中获取,以及如何保持两者的一致性。在Read-Through 中,缓存系统承担了更多的职责,实现了更紧密的缓存与数据库集成,从而简化了应用程序的设计和实现。

Write-Through(写穿透)

Write-Through 是一种在缓存中更新数据时,同时将更新操作同步到持久化存储的策略。具体流程如下:

  • 当客户端向缓存系统发出写请求时,缓存系统首先更新缓存中的数据。
  • 同时,缓存系统还会把这次更新操作同步到底层数据存储(如数据库)。
  • 当数据在数据库中成功更新后,整个写操作才算完成。
  • 这样,无论是从缓存还是直接从数据库读取,都能得到最新一致的数据。

image.png

image.png

Read-Through 和 Write-Through 的共同目标是确保缓存与底层数据存储之间的一致性,并通过自动化的方式隐藏了缓存与持久化存储之间的交互细节,简化了客户端的处理逻辑。这两种策略经常一起使用,以提供无缝且一致的数据访问体验,特别适用于那些对数据一致性要求较高的应用场景。然而,需要注意的是,虽然它们有助于提高数据一致性,但在高并发或网络不稳定的情况下,仍然需要考虑并发控制和事务处理等问题,以防止数据不一致的情况发生。

Write behind (异步缓存写入)

Write Behind(异步缓存写入),也称为 Write Back(回写)或 异步更新策略,是一种在处理缓存与持久化存储(如数据库)之间数据同步时的策略。在这种模式下,当数据在缓存中被更新时,并非立即同步更新到数据库,而是将更新操作暂存起来,随后以异步的方式批量地将缓存中的更改写入持久化存储。其流程如下:

  • 应用程序首先在缓存中执行数据更新操作,而不是直接更新数据库。
  • 缓存系统会将此次更新操作记录下来,暂存于一个队列(如日志文件或内存队列)中,而不是立刻同步到数据库。
  • 在后台有一个独立的进程或线程定期(或者当队列积累到一定大小时)从暂存队列中取出更新操作,然后批量地将这些更改写入数据库。

image.png

使用 Write Behind 策略时,由于更新并非即时同步到数据库,所以在异步处理完成之前,如果缓存或系统出现故障,可能会丢失部分更新操作。并且对于高度敏感且要求强一致性的数据,Write Behind 策略并不适用,因为它无法提供严格的事务性和实时一致性保证。Write Behind 适用于那些可以容忍一定延迟的数据一致性场景,通过牺牲一定程度的一致性换取更高的系统性能和扩展性。

解决双写一致性问题的3种方案

以上我们主要讲解了解决双写一致性问题的主要策略,但是每种策略都有一定的局限性,所以我们在实际运用中,还要结合一些其他策略去屏蔽上述策略的缺点。

1. 延时双删策略

延时双删策略主要用于解决在高并发场景下,由于网络延迟、并发控制等原因造成的数据库与缓存数据不一致的问题。

当更新数据库时,首先删除对应的缓存项,以确保后续的读请求会从数据库加载最新数据。 但是由于网络延迟或其他不确定性因素,删除缓存与数据库更新之间可能存在时间窗口,导致在这段时间内的读请求从数据库读取数据后写回缓存,新写入的缓存数据可能还未反映出数据库的最新变更。

所以为了解决这个问题,延时双删策略在第一次删除缓存后,设定一段短暂的延迟时间,如几百毫秒,然后在这段延迟时间结束后再次尝试删除缓存。这样做的目的是确保在数据库更新传播到所有节点,并且在缓存中的旧数据彻底过期失效之前,第二次删除操作可以消除缓存中可能存在的旧数据,从而提高数据一致性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
typescript复制代码public class DelayDoubleDeleteService {

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Autowired
    private TaskScheduler taskScheduler;

    public void updateAndScheduleDoubleDelete(String key, String value) {
        // 更新数据库...
        updateDatabase(key, value);

        // 删除缓存
        redisTemplate.delete(key);

        // 延迟执行第二次删除
        taskScheduler.schedule(() -> {
            redisTemplate.delete(key);
        }, new CronTrigger("0/1 * * * * ?")); // 假设1秒后执行,实际应根据需求设置定时表达式
    }

    // 更新数据库的逻辑
    private void updateDatabase(String key, String value) {
        
    }
}

这种方式可以较好地处理网络延迟导致的数据不一致问题,较少的并发写入数据库和缓存,降低系统的压力。但是,延迟时间的选择需要权衡,过短可能导致实际效果不明显,过长可能影响用户体验。并且对于极端并发场景,仍可能存在数据不一致的风险。

2. 删除缓存重试机制

删除缓存重试机制是在删除缓存操作失败时,设定一个重试策略,确保缓存最终能被正确删除,以维持与数据库的一致性。

在执行数据库更新操作后,尝试删除关联的缓存项。如果首次删除缓存失败(例如网络波动、缓存服务暂时不可用等情况),系统进入重试逻辑,按照预先设定的策略(如指数退避、固定间隔重试等)进行多次尝试。直到缓存删除成功,或者达到最大重试次数为止。通过这种方式,即使在异常情况下也能尽量保证缓存与数据库的一致性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
typescript复制代码@Service
public class RetryableCacheService {

    @Autowired
    private CacheManager cacheManager;

    @Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000L))
    public void deleteCacheWithRetry(String key) {
        ((org.springframework.data.redis.cache.RedisCacheManager) cacheManager).getCache("myCache").evict(key);
    }

    public void updateAndDeleteCache(String key, String value) {
        // 更新数据库...
        updateDatabase(key, value);

        // 尝试删除缓存,失败时自动重试
        deleteCacheWithRetry(key);
    }

    // 更新数据库的逻辑,此处仅示意
    private void updateDatabase(String key, String value) {
        // ...
    }
}

这种重试方式确保缓存删除操作的成功执行,可以应对网络抖动等导致的临时性错误,提高数据一致性。但是可能占用额外的系统资源和时间,重试次数过多可能会阻塞其他操作。

监听并读取biglog异步删除缓存

在数据库发生写操作时,将变更记录在binlog或类似的事务日志中,然后使用一个专门的异步服务或者监听器订阅binlog的变化(比如Canal),一旦检测到有数据更新,便根据binlog中的操作信息定位到受影响的缓存项。讲这些需要更新缓存的数据发送到消息队列,消费者处理消息队列中的事件,异步地删除或更新缓存中的对应数据,确保缓存与数据库保持一致。

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
typescript复制代码@Service
public class BinlogEventHandler {

    @Autowired
    private RocketMQTemplate rocketMQTemplate;

    public void handleBinlogEvent(BinlogEvent binlogEvent) {
        // 解析binlogEvent,获取需要更新缓存的key
        String cacheKey = deriveCacheKeyFromBinlogEvent(binlogEvent);

        // 发送到RocketMQ
        rocketMQTemplate.asyncSend("cacheUpdateTopic", cacheKey, new SendCallback() {
            @Override
            public void onSuccess(SendResult sendResult) {
                // 发送成功处理
            }

            @Override
            public void onException(Throwable e) {
                // 发送失败处理
            }
        });
    }

    // 从binlog事件中获取缓存key的逻辑,这里仅为示意
    private String deriveCacheKeyFromBinlogEvent(BinlogEvent binlogEvent) {
        // ...
    }
}

@RocketMQMessageListener(consumerGroup = "myConsumerGroup", topic = "cacheUpdateTopic")
public class CacheUpdateConsumer {

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Override
    public void onMessage(MessageExt messageExt) {
        String cacheKey = new String(messageExt.getBody());
        redisTemplate.delete(cacheKey);
    }
}

这种方法的好处是将缓存的更新操作与主业务流程解耦,避免阻塞主线程,同时还能处理数据库更新后由于网络问题或并发问题导致的缓存更新滞后情况。当然,实现这一策略相对复杂,需要对数据库的binlog机制有深入理解和定制开发。

总结

在分布式系统中,为了保证缓存与数据库双写一致性,可以采用以下方案:

  1. 读取操作:
* 先尝试从缓存读取数据,若缓存命中,则直接返回缓存中的数据。
* 若缓存未命中,则从数据库读取数据,并将数据放入缓存。
  1. 更新操作:
* 在更新数据时,首先在数据库进行写入操作,确保主数据库数据的即时更新。
* 为了减少数据不一致窗口,采用异步方式处理缓存更新,具体做法是监听数据库的binlog事件,异步进行删除缓存。
* 在一主多从的场景下,为了确保数据一致性,需要等待所有从库的binlog事件都被处理后才删除缓存(确保全部从库均已更新)。

同时,还需注意以下要点:

  • 对于高并发环境,可能需要结合分布式锁、消息队列或缓存失效延时等技术,进一步确保并发写操作下的数据一致性.
  • 异步处理binlog时,务必考虑异常处理机制和重试策略,确保binlog事件能够正确处理并执行缓存更新操作。

本文已收录于我的个人博客:码农Academy的博客,专注分享Java技术干货,包括Java基础、Spring Boot、Spring Cloud、Mysql、Redis、Elasticsearch、中间件、架构设计、面试题、程序员攻略等

本文转载自: 掘金

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

面试官问你Transactional和Async能一起用

发表于 2024-04-15
  1. 引言

@Transactional 和 @Async能混在一起使用吗?我猜“百分之180”的人都回答不上来这个问题,也许根本就没有这么用过。

在本文中,我们将探讨 Spring 框架中的 @Transactional 和 @Async 注解之间的兼容性。

  1. 理解 @Transactional 和 @Async

@Transactional 注解创建了一个原子代码块。因此,如果其中一个代码块执行异常,所有部分都会回滚。因此,新创建的原子单元只有在所有部分都成功时,通过提交才能成功完成。

创建事务允许我们避免代码中的部分失败,从而提高数据的一致性。

另一方面,@Async 告诉 Spring 注解的单元可以与调用线程并行运行。换句话说,如果我们从一个线程调用一个 @Async 方法或类,Spring 会在另一个具有不同上下文的线程中运行其代码。

定义异步代码可以通过与调用线程并行执行单元来提高执行时间性能。

有些场景下,我们需要代码中同时具备性能和一致性。在 Spring 中,我们可以将 @Transactional 和 @Async 结合使用,以实现这两个目标,只要我们注意如何一起使用这些注解。

在以下部分中,我们将探讨不同的场景。

  1. @Transactional 和 @Async 能一起使用吗?

如果我们没有正确实现异步和事务代码,可能会产生诸如数据不一致等问题。

要充分利用 @Async 和 @Transactional 并避免Bug和陷阱,我们必须注意 Spring 的事务上下文以及上下文之间的数据传播。

3.1. 创建Demo应用程序

在这里,我们以银行的转账功能来说明事务和异步代码的使用。

简而言之,我们可以通过从一个账户中取出钱并将其添加到另一个账户来实现资金转账。我们可以将其想象成选择涉及的账户并更新其资金余额的数据库操作:

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码public void transfer(Long depositorId, Long favoredId, BigDecimal amount) {
Account depositorAccount = accountRepository.findById(depositorId)
.orElseThrow(IllegalArgumentException::new);
Account favoredAccount = accountRepository.findById(favoredId)
.orElseThrow(IllegalArgumentException::new);

depositorAccount.setBalance(depositorAccount.getBalance().subtract(amount));
favoredAccount.setBalance(favoredAccount.getBalance().add(amount));

accountRepository.save(depositorAccount);
accountRepository.save(favoredAccount);
}

我们首先使用 findById() 方法找到涉及的账户,如果给定的 ID 没有找到账户,则抛出 IllegalArgumentException。

然后,我们用新的金额更新检索到的账户。最后,我们使用 CrudRepository 的 save() 方法保存新更新的账户。

在这个简单的例子中,存在几个潜在的失败点。例如,我们可能找不到 favoredAccount 并因异常而失败。或者,save() 操作对 depositorAccount 完成,但对 favoredAccount 失败。这些被定义为部分失败,因为失败之前发生的事情无法撤销。

因此,如果我们不使用事务机制来编写和管理代码,部分失败会导致数据一致性问题。例如,我们可能从一个账户中移除了资金,但没有有效地将其传递给另一个账户。

3.2. 从 @Async 调用 @Transactional

如果我们从 @Async 方法中调用 @Transactional 方法,Spring 会正确管理事务并传播其上下文,确保数据的一致性。

例如,让我们从 @Async 调用者中调用一个 @Transactional 的 transfer() 方法:

1
2
3
4
5
6
java复制代码@Async
public void transferAsync(Long depositorId, Long favoredId, BigDecimal amount) {
transfer(depositorId, favoredId, amount);

// other async operations, isolated from transfer
}
1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码@Transactional
public void transfer(Long depositorId, Long favoredId, BigDecimal amount) {
Account depositorAccount = accountRepository.findById(depositorId)
.orElseThrow(IllegalArgumentException::new);
Account favoredAccount = accountRepository.findById(favoredId)
.orElseThrow(IllegalArgumentException::new);

depositorAccount.setBalance(depositorAccount.getBalance().subtract(amount));
favoredAccount.setBalance(favoredAccount.getBalance().add(amount));

accountRepository.save(depositorAccount);
accountRepository.save(favoredAccount);
}

transferAsync() 方法在一个与调用线程不同的上下文中并行运行,因为它使用了 @Async 注解。

然后,我们调用事务性的 transfer() 方法来执行关键的业务逻辑。在这种情况下,Spring 会正确地将 transferAsync() 线程的上下文传播给 transfer()。因此,我们不会在这个交互中丢失任何数据。

transfer() 方法定义了一组关键的数据库操作,如果发生任何失败,则必须回滚这些操作。Spring 只处理 transfer() 事务,这会将 transfer() 方法体外的所有代码与事务隔离开来。因此,只有在 transfer() 中发生失败时,Spring 才会回滚其代码。

从 @Async 方法中调用 @Transactional 方法可以提高性能,因为它可以在与调用线程并行执行操作的同时,确保特定内部操作的数据一致性。

3.3. 从 @Transactional 调用 @Async

Spring 当前使用 ThreadLocal 来管理当前线程的事务。所以,它不会在我们应用程序的不同线程之间共享线程上下文。

因此,如果 @Transactional 方法调用 @Async 方法,Spring 不会传播同一事务的线程上下文。

为了说明这一点,让我们在 transfer() 方法内部添加一个对异步 printReceipt() 方法的调用:

1
2
3
4
java复制代码@Async
public void transferAsync(Long depositorId, Long favoredId, BigDecimal amount) {
transfer(depositorId, favoredId, amount);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码@Transactional
public void transfer(Long depositorId, Long favoredId, BigDecimal amount) {
Account depositorAccount = accountRepository.findById(depositorId)
.orElseThrow(IllegalArgumentException::new);
Account favoredAccount = accountRepository.findById(favoredId)
.orElseThrow(IllegalArgumentException::new);

depositorAccount.setBalance(depositorAccount.getBalance().subtract(amount));
favoredAccount.setBalance(favoredAccount.getBalance().add(amount));

printReceipt();
accountRepository.save(depositorAccount);
accountRepository.save(favoredAccount);
}
1
java复制代码@Async public void printReceipt() { // logic to print the receipt with the results of the transfer }

transfer() 方法的逻辑与之前相同,但现在我们调用 printReceipt() 来打印转账结果。由于 printReceipt() 是 @Async 的,Spring 会在另一个上下文的不同线程上运行其代码。

问题在于,收据信息依赖于 transfer() 方法的整个正确执行。此外,printReceipt() 和 transfer() 中保存到数据库的其余代码在不同的线程上运行,且数据不同,这使得应用程序的行为变得不可预测。例如,我们可能会打印一个成功保存到数据库之前的转账交易结果。

因此,为了避免这种数据一致性问题,我们必须避免从 @Transactional 方法中调用 @Async 方法,因为不会发生线程上下文传播。

3.4. 在类级别使用 @Transactional

使用 @Transactional 注解定义一个类,会使其所有公共方法都可用于 Spring 的事务管理。因此,该注解会一次性为所有方法创建事务。

在类级别使用 @Transactional 时,可能会发生在同一个方法中混合使用 @Async 的情况。实际上,我们是在该方法的周围创建一个事务单元,该事务单元在与调用线程不同的线程上运行:

1
2
3
4
5
6
7
8
9
10
11
java复制代码@Transactional
public class AccountService {
@Async
public void transferAsync() {
// this is an async and transactional method
}

public void transfer() {
// transactional method
}
}

在上面的例子中,transferAsync() 方法既是事务性的又是异步的。因此,它定义了一个事务单元并在不同的线程上运行。因此,它可用于事务管理,但不在与调用线程相同的上下文中。

因此,如果发生失败,transferAsync() 内部的代码会回滚,因为它是 @Transactional 的。然而,由于该方法也是 @Async 的,Spring 不会将调用上下文传播给它。因此,在失败的情况下,Spring 不会回滚 trasnferAsync() 之外的任何代码,就像我们调用一系列仅包含事务的方法时一样。因此,这与从 @Transactional 中调用 @Async 面临相同的数据完整性问题。

类级别的注解对于编写较少代码以创建定义一系列完全事务性方法的类非常有用。

但是,这种混合的事务性和异步行为在调试代码时可能会造成混淆。例如,我们期望在发生失败时,一系列仅包含事务的方法调用中的所有代码都会回滚。然而,如果这一系列方法中的某个方法也是 @Async 的,那么行为就会出乎意料。
file

  1. 结论

在本教程中,我们从数据完整性的角度学习了何时可以安全地将 @Transactional 和 @Async 注解一起使用。

通常,从 @Async 方法中调用 @Transactional 方法可以保证数据完整性,因为 Spring 会正确地传播相同的上下文。

但是,从 @Transactional 中调用 @Async 方法时,我们可能会遇到数据完整性问题。

你学废了吗?

本文转载自: 掘金

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

泰裤辣 🚀 原来实现一个无头组件比传统组件简单辣么多!! 前

发表于 2024-04-15

作者:易师傅 、github

声明:本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!

前言

承接上文,我们已经知道了无头组件库 Headless UI 是什么,以及有什么样的作用,包括怎么去实现一个最基本 Headless UI 无头组件库框架;

如果你还不知道也没关系,看看《Headless UI》这个免费的专栏就行了;

正所谓坐而论道、夸夸其谈、纸上谈兵的人比比皆是,我们要做的不仅仅要能论其道、夸其谈,也要行胜于言,更要实战,真正做到知行合一。

那么接下来,就来到了第二篇的敲代码实战环节 ~

一、分析对比

1)分析传统 UI 组件库的 Popover 组件需要什么?

我相信大家多多少少都使用过 Ant-Design、Element、Vant等等传统 UI 组件库中的其中一种,而且几乎都使用过了其中的 Popover 组件 功能,那么我们分析一下他们的共同点:

1.1 传统 UI 组件库的 Popover 组件基本使用:

1. Element:

image.png

2. Ant-Design:

image.png

1.2 其它更多功能:

  • 触发方式:点击、聚焦、悬浮等等
  • 位置显示
  • 自定义内容

我相信这是大部分 Popover 组件所需的功能点,如果你能全部实现,那基本就算是一个合格的组件了。

我们可以根据此来抽离其中的样式,来实现一个仅需要交互逻辑的无头 Popover 组件;

2)分析一个无头组件库 Popover 组件需要什么?

其实,一个只有交互逻辑的无头 Popover 组件与传统组件的功能是大差不差的,一些基本使用等等都是必须要具备的;

只是在样式这块我们不需要下更多的功夫;

那么我们实现的功能点就主要包括以下:

  • 基本使用
  • 触发方式
  • 位置显示
  • 自定义内容

分析完之后,那我们开始实战 ~

二、实战之「基本使用」

因为我们在上一篇文章中已经搭建好了基本项目框架的架子,所以我们基于此来进行开发吧!

1)创建

1
2
3
4
5
bash复制代码# 来到 package/vue 目录下
cd package/vue

# 新建 Popover 目录
mkdir src/Popover

2)实现基本 PopoverRoot 根组件

2.1. 基本配置

新建 PopoverRoot.vue 文件

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
ts复制代码
<script lang="ts">
import type { Ref } from 'vue'
import { createContext } from '@yi-ui/shared'

// 暴露的三个参数
export interface PopoverRootProps {
/**
* 打开状态,当它最初被渲染时。当您不需要控制其打开状态时使用。
*/
defaultOpen?: boolean
/**
* 控制当前组件的打开状态
*/
open?: boolean
/**
* popover的模式。当设置为true时,将禁用与外部元素的交互,并且只有弹出式的内容对屏幕阅读器可见。
*
* @defaultValue false
*/
modal?: boolean
}

// 暴露的事件
export type PopoverRootEmits = {
/**
* 打开状态事件回调
*/
'update:open': [value: boolean]
}

// 组件上下文传递时所需的参数
export interface PopoverRootContext {
triggerElement: Ref<HTMLElement | undefined>
contentId: string
open: Ref<boolean>
modal: Ref<boolean>
onOpenChange(value: boolean): void
onOpenToggle(): void
hasCustomAnchor: Ref<boolean>
}

// 传递组件上下文
export const [injectPopoverRootContext, providePopoverRootContext]
= createContext<PopoverRootContext>('PopoverRoot')
</script>

这段代码很简,我相信大家基本都看得懂,只是其中有一个 createContext函数 的出处需要解释一下;

createContext函数是一个写在 yi-ui/shared 子包中的公共方法;

主要作用就是让整个组件上下文建立联系,方便整个父子组件的联调;

2.2 核心代码

老规矩,先看代码

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
ts复制代码<script setup lang="ts">
import { ref, toRefs } from 'vue'
import { useVModel } from '@vueuse/core'
import { PopperRoot } from '../Popper'

// 设置组件的默认属性值
const props = withDefaults(defineProps<PopoverRootProps>(), {
defaultOpen: false,
open: undefined,
modal: false,
})
// 事件
const emit = defineEmits<PopoverRootEmits>()
const { modal } = toRefs(props)

// vueuse 的一个双向绑定 hook
// vue 3.4+ 可以使用 defineModel:https://cn.vuejs.org/api/sfc-script-setup.html#definemodel
const open = useVModel(props, 'open', emit, {
defaultValue: props.defaultOpen,
passive: (props.open === undefined) as false,
}) as Ref<boolean>

const triggerElement = ref<HTMLElement>()
const hasCustomAnchor = ref(false)

// 暴露给子组件的属性与方法
providePopoverRootContext({
contentId: '',
modal,
open,
onOpenChange: (value) => {
open.value = value
},
onOpenToggle: () => {
open.value = !open.value
},
triggerElement,
hasCustomAnchor,
})
</script>

<template>
<PopperRoot>
<slot />
</PopperRoot>
</template>

其实整个代码也是很简单的,大家几乎都能看的懂,语义化是很明显的;

其中可能有疑问的地方,应该就只有 PopperRoot 了;

PopperRoot 是一个基于 @floating-ui/vue 库实现的基本组件;

主要作用是为浮动元素提供锚点定位,并且将其位置定位在当前参考元素旁边的库;

几乎绝大多数的组件库都有使用到类似的库,这里就不展开讲了,大家明白就行了 ~

到这里这个最基本的 PopoverRoot 根组件就实现完毕了,是不是很简单呢,对的,就是这么简单,如果你还没看懂,建议多看几遍哦~

2.3 导出

新建 index.ts

1
2
3
4
5
ts复制代码export {
default as PopoverRoot,
type PopoverRootProps,
type PopoverRootEmits,
} from './PopoverRoot.vue'

到这基本就完成了;

那么既然开发完毕了,那么咱们下一步就是开始去调试了

2.4 playground 调试

基本使用:

进入目录:

1
ts复制代码cd playground/vue3

新增 Popover.vue 组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
ts复制代码<template>
<PopoverRoot :default-open="true">
<div>Your popover content here</div>
</PopoverRoot>
</template>

<script setup lang="ts">
import { PopoverRoot } from '@yi-ui/vue';

</script>

<style scoped>
</style>

渲染效果:

image.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
ts复制代码<template>
<PopoverRoot v-model:open="toggleState" >
<!-- PopoverTrigger -->
<button @click="handleVisible">点击显示/隐藏</button>

<!-- PopoverContent -->
<ul v-if="toggleState">
<li>popover content: 11111</li>
<li>popover content: 22222</li>
<li>popover content: 33333</li>
<li>popover content: 44444</li>
<li>popover content: 55555</li>
</ul>
</PopoverRoot>
</template>

<script setup lang="ts">
import { PopoverRoot } from '@yi-ui/vue';
import { ref } from 'vue';

const toggleState = ref(false);

const handleVisible = () => {
toggleState.value = !toggleState.value;
};

</script>

我们能看到点击按钮会显示与隐藏:

7.gif

其实代码中的实现与现在的 PopoverRoot 根组件 关联性不是很大;

所以接下来我们要做的就是把这个 PopoverTrigger 组件 和 PopoverContent 组件 嵌入到 PopoverRoot 根组件中去,这样使用的时候才会事半功倍;

3)实现 PopoverTrigger 子组件

3.1 功能介绍

PopoverTrigger 子组件的功能其实很简单,那就是切换弹出 Popover 的一个触发器功能,默认情况下,它将 PopoverContent 子组件(也就是主要的内容部分)定位在当前触发器上。

3.2 核心代码

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
ts复制代码<script lang="ts">
import type { PrimitiveProps } from '@/Primitive'
import { useForwardExpose } from '@yi-ui/shared'

export interface PopoverTriggerProps extends PrimitiveProps {}
</script>

<script setup lang="ts">
import { onMounted } from 'vue'
import { injectPopoverRootContext } from './PopoverRoot.vue'
import { Primitive } from '@/Primitive'
import { PopperAnchor } from '@/Popper'

const props = withDefaults(defineProps<PopoverTriggerProps>(), {
as: 'button',
})

const rootContext = injectPopoverRootContext()

const { forwardRef, currentElement: triggerElement } = useForwardExpose()

onMounted(() => {
rootContext.triggerElement.value = triggerElement.value
})
</script>

<template>
<component
:is="rootContext.hasCustomAnchor.value ? Primitive : PopperAnchor"
as-child
>
<Primitive
:ref="forwardRef"
:type="as === 'button' ? 'button' : undefined"
aria-haspopup="dialog"
:aria-expanded="rootContext.open.value"
:aria-controls="rootContext.contentId"
:data-state="rootContext.open.value ? 'open' : 'closed'"
:as="as"
:as-child="props.asChild"
@click="rootContext.onOpenToggle"
>
<slot />
</Primitive>
</component>
</template>

其实它的代码就只需要这么多,我们简单解释一下其中的核心函数:

useForwardExpose:使其可以处理自己的 Ref;

Primitive:一个通用的基本元素组件;

PopperAnchor:瞄点定位元素;

3.3 导出

1
2
3
4
ts复制代码export {
default as PopoverTrigger,
type PopoverTriggerProps,
} from './PopoverTrigger.vue'

3.4 playground 调试

编辑 Popover.vue 组件:

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
ts复制代码<script setup lang="ts">
import { PopoverRoot, PopoverTrigger } from '@yi-ui/vue'
import { ref } from 'vue'

const toggleState = ref(false)

// function handleVisible() {
// toggleState.value = !toggleState.value
// }
</script>

<template>
<PopoverRoot v-model:open="toggleState">
<!-- PopoverTrigger -->
<!-- <button @click="handleVisible">点击显示/隐藏</button> -->
<PopoverTrigger>
点击显示/隐藏
</PopoverTrigger>

<!-- PopoverContent -->
<ul v-if="toggleState">
<li>popover content: 11111</li>
<li>popover content: 22222</li>
<li>popover content: 33333</li>
<li>popover content: 44444</li>
<li>popover content: 55555</li>
</ul>
</PopoverRoot>
</template>

根据与上面 PopoverRoot根组件 调试的对比,我们很明显的少了 handleVisible 函数,这是因为我们已经在 PopoverTrigger 子组件的 onOpenToggle 中实现了。

7.gif

好了,到这里我们已经有了触发器 PopoverTrigger 子组件 ,接下来就是要实现 PopoverContent 子组件主要内容了。

4)实现 PopoverContent 子组件

4.1 功能介绍

PopoverContent 子组件 主要就是在 PopoverTrigger 子组件 触发器在弹出窗口时的组件内容。

4.2 核心代码

老规矩,先看代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
ts复制代码<script lang="ts">
export interface PopoverContentProps {
forceMount?: boolean
}
</script>

<script setup lang="ts">
import { injectPopoverRootContext } from './PopoverRoot.vue'
import { useForwardExpose } from '@yi-ui/shared'
import { Presence } from '@/Presence'

const rootContext = injectPopoverRootContext()

const { forwardRef } = useForwardExpose()

</script>

<template>
<Presence :ref="forwardRef" :present="rootContext.open.value">
<slot />
</Presence>
</template>

其实一看,PopoverContent 子组件 实现起来是不是贼简单;

是的,的确贼简单;

我相信上面的代码,大家也就对 Presence 会比较陌生,咱们简单介绍下:

主要作用就是有条件的显示和隐藏当前的组件,就类似于 vue 的 v-if 功能。

4.3 导出

新建 index.ts

1
2
3
4
ts复制代码export {
default as PopoverContent,
type PopoverContentProps,
} from './PopoverContent.vue'

4.4 playground 调试

编辑 Popover.vue 组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
ts复制代码<script setup lang="ts">
import { PopoverRoot, PopoverTrigger, PopoverContent } from '@yi-ui/vue'

</script>

<template>
<PopoverRoot>
<PopoverTrigger>
点击显示/隐藏
</PopoverTrigger>

<PopoverContent>
<ul>
<li>popover content: 11111</li>
<li>popover content: 22222</li>
<li>popover content: 33333</li>
<li>popover content: 44444</li>
<li>popover content: 55555</li>
</ul>
</PopoverContent>
</PopoverRoot>
</template>

根据与上面 PopoverRoot根组件 调试的对比,我们又少了 toggleState 数据,这是因为我们已经用 rootContext.open 来进行关联了。

7.gif

到这里我们一个最最最基本的 Popover 组件 就已经实现了,但是我们从上面分析得来的,一个完整的 Popover 组件 离不开四个大的功能:

  • 基本使用
  • 触发方式
  • 位置显示
  • 自定义内容

显然我们已经实现了基本使用和触发方式功能,那么接下就是位置显示和自定义内容了

三、实战之「位置显示」

1)思考&分析

根据我们上面实现的基本使用,咱们思考一下,位置显示我们应该在哪个组件中实现呢?

很明显还是在 PopoverContent 子组件 中,毕竟咱们现实主要内容就是它;

在上面,咱们讲过了 @floating-ui/vue 库是干嘛的了,之所以用到它,还有一个就是,它能自定义位置;

接下来咱们就基于 @floating-ui/vue 库来实现吧 ~

2)@floating-ui/vue 的简单实用

2.1 安装

1
ts复制代码pnpm install @floating-ui/vue

2.2 基本使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
ts复制代码<template>
<button ref="reference">参考元素</button>
<ul ref="floating" :style="floatingStyles">
<li>111浮动元素内容</li>
<li>222浮动元素内容</li>
<li>333浮动元素内容</li>
<li>444浮动元素内容</li>
<li>555浮动元素内容</li>
</ul>
</template>

<script lang="ts" setup>
import { ref } from 'vue';
import { useFloating } from '@floating-ui/vue';

// 用于定位的参考(或锚点)元素
const reference = ref(null);
// 用于浮动的元素
const floating = ref(null);
// 用于控制浮动元素的样式
const { floatingStyles } = useFloating(reference, floating);
</script>

渲染效果如下:

image.png

默认情况下,浮动元素内容将定位在参考元素的底部中心,所以看到效果是这样的。
那么接下来,就是要更改位置的进阶使用了。

2.3 进阶使用

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
ts复制代码<template>
<div class="content">
<div>
<button ref="reference">参考元素向下</button>
<ul ref="floating" :style="floatingStyles">
<li>浮动元素内容</li>
<li>浮动元素内容</li>
<li>浮动元素内容</li>
<li>浮动元素内容</li>
<li>浮动元素内容</li>
</ul>
</div>
<div>
<button ref="referenceTop">参考元素向上</button>
<ul ref="floatingTop" :style="floatingTopStyles">
<li>浮动元素内容</li>
<li>浮动元素内容</li>
<li>浮动元素内容</li>
<li>浮动元素内容</li>
<li>浮动元素内容</li>
</ul>
</div>
<div>
<button ref="referenceLeft">参考元素向左</button>
<ul ref="floatingLeft" :style="floatingLeftStyles">
<li>浮动元素内容</li>
<li>浮动元素内容</li>
<li>浮动元素内容</li>
<li>浮动元素内容</li>
<li>浮动元素内容</li>
</ul>
</div>
<div>
<button ref="referenceRight">参考元素向右</button>
<ul ref="floatingRight" :style="floatingRightStyles">
<li>浮动元素内容</li>
<li>浮动元素内容</li>
<li>浮动元素内容</li>
<li>浮动元素内容</li>
<li>浮动元素内容</li>
</ul>
</div>
</div>
</template>

<script lang="ts" setup>
import { ref } from 'vue';
import { useFloating, shift, flip, offset } from '@floating-ui/vue';

// 用于定位的参考(或锚点)元素
const reference = ref(null);
const referenceTop = ref(null);
const referenceLeft = ref(null);
const referenceRight = ref(null);
// 用于浮动的元素
const floating = ref(null);
const floatingTop = ref(null);
const floatingLeft = ref(null);
const floatingRight = ref(null);
// 中间件
const middleware = [shift(), flip(), offset(10)];
// 用于控制浮动元素的样式
const { floatingStyles } = useFloating(reference, floating, {
// 指定初始化浮动位置
placement: "bottom",
middleware,
});
const { floatingStyles: floatingTopStyles } = useFloating(referenceTop, floatingTop, {
// 指定初始化浮动位置
placement: "top",
middleware,
});
const { floatingStyles: floatingLeftStyles } = useFloating(referenceLeft, floatingLeft, {
// 指定初始化浮动位置
placement: "left",
middleware,
});
const { floatingStyles: floatingRightStyles } = useFloating(referenceRight, floatingRight, {
// 指定初始化浮动位置
placement: "right",
middleware,
});

</script>


<style scoped>
.content {
display: flex;
width: 100vw;
height: 1000px;
padding-top: 300px;
}
.content > div {
width: 20%;
height: 500px;
}
ul, li {
list-style: none;
margin: 0;
padding: 0;
}

ul {
border: 1px solid #ccc;
text-align: center;
}
</style>

咱们直接看渲染结果:

image.png

到这里,咱们已经熟悉了@floating-ui/vue 的基本使用了,当然还有超多复杂的API 使用,这里就不赘述了,感兴趣的同学可以自行了解官方文档:传送门。

那么接下来就是如何代入到我们的无头组件库中去了。

3)封装 Popper 共用组件

如果直接按照上面的使用,我想这不是一个合格的组件库,因为咱们考量的还有很多,比如代码解耦、代码复用等等。

所以咱们需要根据上面来简单封装一个 Popper 共用组件。

该组件包括:

  • PopperRoot: 根组件
  • PopperContent:主要内容组件
  • PopperArrow:箭头
  • PopperAnchor:触发器

其中核心的 PopperContent 代码(抽取其中部分,主要为了实现位置显示):

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
ts复制代码<script lang="ts">
import type {
Placement,
} from '@floating-ui/vue'
import { createContext, useForwardExpose } from '@yi-ui/shared'
import type {
Align,
Side,
} from './utils'

export const PopperContentPropsDefaultValue = {
side: 'bottom' as Side,
sideOffset: 0,
align: 'center' as Align,
alignOffset: 0,
}

export interface PopperContentProps {
/**
* 位置
*
* @defaultValue "top"
*/
side?: Side

/**
* 距离触发器的距离
*
* @defaultValue 0
*/
sideOffset?: number

/**
* 对齐方式相对于触发器
*
* @defaultValue "center"
*/
align?: Align

/**
* 偏移量
*
* @defaultValue 0
*/
alignOffset?: number
}

export interface PopperContentContext {}

export const [injectPopperContentContext, providePopperContentContext]
= createContext<PopperContentContext>('PopperContent')
</script>

<script setup lang="ts">
import { computed, ref } from 'vue'
import {
useFloating,
} from '@floating-ui/vue'
import { injectPopperRootContext } from './PopperRoot.vue'
import {
Primitive,
} from '../Primitive'

const props = withDefaults(defineProps<PopperContentProps>(), {
...PopperContentPropsDefaultValue,
})
const rootContext = injectPopperRootContext()

const { forwardRef } = useForwardExpose()

const floatingRef = ref<HTMLElement>()

const desiredPlacement = computed(
() =>
(props.side
+ (props.align !== 'center' ? `-${props.align}` : '')) as Placement,
)


const { floatingStyles } = useFloating(
rootContext.anchor,
floatingRef,
{
strategy: 'fixed',
placement: desiredPlacement,
},
)

</script>

<template>
<div
ref="floatingRef"
:style="{
...floatingStyles,
}"
>
<Primitive
:ref="forwardRef"
>
<slot />
</Primitive>
</div>
</template>

我相信通过上面的 @floating-ui/vue 的简单实用,大家都能看的懂其中的代码;

还有更多的细节和相关组件这里就不做赘述介绍了,当然一个完整的 Popper 共用组件 远不止于此,感兴趣的同学可以看看源代码:传送门

4)完善 PopoverContent 子组件

根据上面的已经实现了的 PopoverContent 子组件 来完善

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
ts复制代码<script lang="ts">
import type { PopperContentProps } from '@/Popper'
import type { PrimitiveProps } from '@/Primitive'

export interface PopoverContentProps extends PopperContentProps, PrimitiveProps {
forceMount?: boolean
}
</script>

<script setup lang="ts">
import { injectPopoverRootContext } from './PopoverRoot.vue'
import { useForwardExpose, useForwardPropsEmits } from '@yi-ui/shared'
import { PopperContent } from '@/Popper'
import { Presence } from '@/Presence'

const props = defineProps<PopperContentProps>()
const emits = defineEmits()

const rootContext = injectPopoverRootContext()

const forwarded = useForwardPropsEmits(props, emits)
const { forwardRef } = useForwardExpose()

</script>

<template>
<Presence :ref="forwardRef" :present="rootContext.open.value">
<PopperContent
v-bind="forwarded"
:ref="forwardRef"
>
<slot />
</PopperContent>
</Presence>
</template>

是的,这样就实现了,基本的位置了

5)playground 调式

5.1 vue 项目中调试

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
ts复制代码<script setup lang="ts">
import { PopoverContent, PopoverRoot, PopoverTrigger } from '@yi-ui/vue'
</script>

<template>
<PopoverRoot>
<PopoverTrigger>
点击显示/隐藏左边的内容
</PopoverTrigger>

<PopoverContent side="left">
<ul>
<li>popover content: 11111</li>
<li>popover content: 22222</li>
<li>popover content: 33333</li>
<li>popover content: 44444</li>
<li>popover content: 55555</li>
</ul>
</PopoverContent>
</PopoverRoot>
<PopoverRoot>
<PopoverTrigger>
点击显示/隐藏右边的内容
</PopoverTrigger>

<PopoverContent side="right">
<ul>
<li>popover content: 11111</li>
<li>popover content: 22222</li>
<li>popover content: 33333</li>
<li>popover content: 44444</li>
<li>popover content: 55555</li>
</ul>
</PopoverContent>
</PopoverRoot>
</template>

5.2 渲染效果

image.png

四、实战之「自定义内容」

自定义内容,其实在上面就已经实现了,只是没有具体的 demo 展示,咱们根据上面实现了的来自定义一下内容

1)demo 展示:

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
ts复制代码<script setup lang="ts">
import { PopoverContent, PopoverRoot, PopoverTrigger } from '@yi-ui/vue'
</script>

<template>
<PopoverRoot>
<PopoverTrigger>
切换县市
</PopoverTrigger>

<PopoverContent>
<ul>
<li>北京市</li>
<li>上海市</li>
<li>广州市</li>
<li>深圳市</li>
</ul>
</PopoverContent>
</PopoverRoot>
</template>

<style scoped>
ul, li {
list-style: none;
margin: 0;
padding: 0;
}

ul {
border: 1px solid #ccc;
text-align: center;
width: 100px;
background: #ffffff;
border-radius: 4px;
box-shadow: 0px 3px 16px 2px rgba(0,0,0,0.04), 0px 7px 14px 1px rgba(0,0,0,0.08), 0px 5px 5px -3px rgba(0,0,0,0.08);
}

ul li {
height: 40px;
line-height: 40px;
font-size: 14px;
cursor: pointer;
color: #333333;
}
li:hover {
background: #f2f8ff;
color: #006aff;
}
</style>

2)渲染效果:

image.png

五、小结

如果你有实现过一个传统组件库,我相信这个无头组件库的实现方式肯定会让你眼前一亮;

因为它的确少了很多东西,就单独拿 style 样式来说,你不仅要把一些样式抽离出来,还要写成可共用的样式,怎么恶心怎么来;

不说了,说多了都是泪~


当然我们一个完整的 Popover 无头组件 绝不仅限于此,为了更好的突出其功能的自定义,还有许多组件与功能封装,这里就不是一两篇文章能讲解的清楚的,大家只要知道其基本实现方式与概念即可。


原本想在这篇文章中把文档和单元测试都讲解了的,但是写着发现越写越多,内容越来越多,毕竟 4 千多字已经够看了。

还未完善的有:

  • 文档撰写
  • 单元测试
  • 支持 Nuxt 调试
  • 打包构建

所以做了一个功能分割取舍,这篇以核心代码为主;

下一篇再讲解文档编写和单元测试的讲解,敬请期待~

总结

当前主要实现了一个最基本的 Popover 无头组件,包括其中的一些核心知识点,我相信无论你是要做一个无头组件库还是传统组件库这都会对受益匪浅的。

Headless UI 往期相关文章:

  1. 在 2023 年屌爆了一整年的 shadcn/ui 用的 Headless UI 到底是何方神圣?
  2. 实战开始 🚀 在 React 和 Vue3 中使用 Headless UI 无头组件库
  3. 无头组件库既然这么火 🔥 那么我们自己手动实现一个来完成所谓的 KPI 吧

Headless UI (1).png

如果想跟我一起讨论技术吹水摸鱼, 欢迎加入前端学习群聊

如果扫码人数满了,可以扫码添加个人 vx 拉你:JeddyGong

感谢大家的支持,码字实在不易,其中如若有错误,望指出,如果您觉得文章不错,记得 点赞关注加收藏 哦 ~

关注我,带您一起搞前端 ~

本文转载自: 掘金

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

【禁止血压飙升】如何拥有一个优雅的 controller

发表于 2024-04-15

前言

见过几千行代码的 controller吗?我见过。

见过全是 try catch 的 controller 吗,我见过。

见过全是字段校验的 controller 吗,我见过。

见过全是业务代码的 controller 吗?不好意思,我们公司很多业务写在 controller 的。

看见这些我真的血压高。

正文

不优雅的 controller

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
java复制代码
@RestController
@RequestMapping("/user/test")
public class UserController {

private static Logger logger = LoggerFactory.getLogger(UserController.class);

@Autowired
private UserService userService;

@Autowired
private AuthService authService;

@PostMapping
public CommonResult userRegistration(@RequestBody UserVo userVo) {
if (StringUtils.isBlank(userVo.getUsername())){
return CommonResult.error("用户名不能为空");
}
if (StringUtils.isBlank(userVo.getPassword())){
return CommonResult.error("密码不能为空");
}
logger.info("注册用户:{}" , userVo.getUsername());
try {
userService.registerUser(userVo.getUsername());
return CommonResult.ok();
}catch (Exception e){
logger.error("注册用户失败:{}", userVo.getUsername(), e);
return CommonResult.error("注册失败");
}
}

@PostMapping("/login")
@PermitAll
@ApiOperation("使用账号密码登录")
public CommonResult<AuthLoginRespVO> login(@RequestBody AuthLoginReqVO reqVO) {
if (StringUtils.isBlank(reqVO.getUsername())){
return CommonResult.error("用户名不能为空");
}
if (StringUtils.isBlank(reqVO.getPassword())){
return CommonResult.error("密码不能为空");
}
try {
return success(authService.login(reqVO));
}catch (Exception e){
logger.error("注册用户失败:{}", reqVO.getUsername(), e);
return CommonResult.error("注册失败");
}
}

}

优雅的controller

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
java复制代码@RestController
@RequestMapping("/user/test")
public class UserController1 {

private static Logger logger = LoggerFactory.getLogger(UserController1.class);

@Autowired
private UserService userService;

@Autowired
private AuthService authService;

@PostMapping("/userRegistration")
public CommonResult userRegistration(@RequestBody @Valid UserVo userVo) {
userService.registerUser(userVo.getUsername());
return CommonResult.ok();
}

@PostMapping("/login")
@PermitAll
@ApiOperation("使用账号密码登录")
public CommonResult<AuthLoginRespVO> login(@RequestBody @Valid AuthLoginReqVO reqVO) {
return success(authService.login(reqVO));
}

}

代码量直接减一半呀,这还不算上有些直接把业务逻辑写在 controller 的,看到这些我真的直接吐血

改造流程

校验方式

这个 if 校验看得我哪哪都不爽。好歹给我写一个断言吧。Assert.notNull(userVo.getUsername(), “用户名不能为空”);

这不香吗?确实不香。

使用 spring 提供的@Valid

  • 在入参时使用@Valid注解,并且在 vo 中使用校验注解,如AuthLoginReqVO
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码@ApiModel(value = "管理后台 - 账号密码登录 Request VO")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class AuthLoginReqVO {

@ApiModelProperty(value = "账号", required = true, example = "user")
@NotEmpty(message = "登录账号不能为空")
@Length(min = 4, max = 16, message = "账号长度为 4-16 位")
@Pattern(regexp = "^[A-Za-z0-9]+$", message = "账号格式为数字以及字母")
private String username;

@ApiModelProperty(value = "密码", required = true, example = "password")
@NotEmpty(message = "密码不能为空")
@Length(min = 4, max = 16, message = "密码长度为 4-16 位")
private String password;

}

@Valid

在SpringBoot中,@Valid是一个非常有用的注解,主要用于数据校验。以下是关于@Valid的一些详细信息:

  1. 为什么使用 @Valid 来验证参数:在编写接口时,我们经常需要验证请求参数。通常,我们可能会写大量的 if 和 if else 代码来进行判断。但这样的代码不仅不优雅,而且如果存在大量的验证逻辑,这会使代码看起来混乱,大大降低代码可读性。为了简化这个过程,我们可以使用 @Valid 注解来帮助我们简化验证逻辑。
  2. @Valid 注解的作用:@Valid 的主要作用是用于数据效验,可以在定义的实体中的属性上,添加不同的注解来完成不同的校验规则,而在接口类中的接收数据参数中添加 @valid 注解,这时你的实体将会开启一个校验的功能。
  3. @Valid 的相关注解:在实体类中不同的属性上添加不同的注解,就能实现不同数据的效验功能。
  4. 使用 @Valid 进行参数效验步骤:整个过程如下,用户访问接口,然后进行参数效验,因为 @Valid 不支持平面的参数效验(直接写在参数中字段的效验)所以基于 GET 请求的参数还是按照原先方式进行效验,而 POST 则可以以实体对象为参数,可以使用 @Valid 方式进行效验。如果效验通过,则进入业务逻辑,否则抛出异常,交由全局异常处理器进行处理。
  5. @Validated与@Valid的区别:@Validated 是 @Valid 的变体。通过声明实体中属性的 groups ,再搭配使用 @Validated ,就能决定哪些属性需要校验,哪些不需要校验。

全局异常处理

  • 这个全局异常处理,可以根据自己的异常,自定义异常处理,并设置一个兜底的异常处理
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
java复制代码
@ResponseBody
@RestControllerAdvice
public class ExceptionHandlerAdvice {
protected Logger logger = LoggerFactory.getLogger(getClass());

@ExceptionHandler(MethodArgumentNotValidException.class)
public CommonResult<Object> handleValidationExceptions(MethodArgumentNotValidException ex) {
logger.error("[handleValidationExceptions]", ex);
StringBuilder sb = new StringBuilder();
ex.getBindingResult().getAllErrors().forEach(error -> {
String fieldName = ((org.springframework.validation.FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
sb.append(fieldName).append(":").append(errorMessage).append(";");
});
return CommonResult.error(sb.toString());
}

/**
* 处理系统异常,兜底处理所有的一切
*/
@ExceptionHandler(value = Exception.class)
public CommonResult<?> defaultExceptionHandler(Throwable ex) {
logger.error("[defaultExceptionHandler]", ex);
// 返回 ERROR CommonResult
return CommonResult.error(INTERNAL_SERVER_ERROR.getCode(), INTERNAL_SERVER_ERROR.getMsg());
}

}

就这么多,搞定,这样就拥有了漂流优雅的 controller 了

在日常开发中,还有那些血压飙升瞬间

  • 我拿出下图阁下如何面对

image-20240411185003067.png

  • 这个阁下又如何面对,我不说,你能知道这个什么吗【狗头】

image-20240411185134843.png

总结

  • 不是很明白为什么有些喜欢在 controller 写业务逻辑的,曾经有个同事问我(就是喜欢在 controller 写业务的),你这个接口写在那里,我需要调一下你这个接口。我满脸问号??不是隔壁的模块吗,为什么要调我的接口?直接引用的我的 service 去调方法就好了。
  • 这个就是痛点,各写各的,冗余代码一堆。
  • 曾经看到一个同事写一个保存的方法,虽然逻辑挺多,我滑动了好久都还没有方法还没有结束。一个方法整整几百行……
  • 看过 spring 源码都知道,spring 源码难啃,就是因为 spring 无限往下套娃,基本每个方法干每个方法的事情。比如我保存用户时,就只是保存用户,至于什么校验丢给校验的方法处理,什么发送消息丢给发送消息处理,这些就不能耦合在一起。
  • 对于看到一些 if 下面一丢逻辑,然后 if 再一丢逻辑,看代码时很多情况不需要知道这个逻辑怎么实现的,知道入参出参就大概这里做什么了。即使想知道详细情况点进去就知道了。突出这个当前方法要做的事情就好了。
  • 阿里的开发手册就推荐一个方法不能超过 80 行,超过可以根据业务具体调整一下。

本文转载自: 掘金

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

当卧龙遇上凤雏:钉钉小程序+F2图表库的踩坑指南

发表于 2024-04-15

作者:千梦凯

前言

古茗加盟商在经营店铺的过程中,需要一款可视化的工具来将门店的经营情况。通过图表将数据呈现得更加直观、易懂;并且可以根据数据趋势,分析门店经营情况。

目前古茗主要通过钉钉小程序来辅助加盟商经营,并且开发使用Taro+React的方式进行开发小程序,本文将介绍如何使用Taro在钉钉小程序中实现数据图表化展示。

可视化图表库技术选型

目前市面上比较流行的可视化工具库有

  • ECharts:一个基于 JavaScript 的开源可视化图表库官网地址
  • Chart.js:基于HTML5的简单易用的JavaScript图表库 官方网站
  • D3.js:一个数据驱动的可视化库,通过使用HTML、SVG和CSS来操作数据,创造出交互式和动态的图表官方网站
  • AntV:蚂蚁企业级数据可视化解决方案官网地址

在wbe端我们使用任何一个库都可以实现可视化展示。不过针对钉钉小程序,我们要根据一下几个关键字来进行筛选使用的图表库(移动端、小程序、手势交互)

  • Echarts上面有丰富的图表库,但是代码体积过大。全量代码将近1000k,选择常用功能也要500k左右,在小程序主包只有2m的情况下还是占用了太多的内容。
  • Chart.js 依赖于DOM API,而钉钉小程序的环境并不提供完整的DOM API。所以无法直接使用
  • D3.js是通过使用SVG来支持图表,但是目前小程序不支持使用SVG,所以无法在小程序中使用
  • Antv/F2 F2 是一个专注于移动端,面向常规统计图表,开箱即用的可视化引擎,完美支持 H5 环境同时兼容多种环境(Node, 小程序)

对比之下,F2图表方案更适合在小程序中使用。

由于我们在接入图表库时,4.x版本尚未更新,所以第一版本使用了3.x版本。在后续F2的更新迭代中,由于图表库是在主包中展示,考虑到文件大小的原因,最终使用了4.x版本。

如果是初次接入,推荐直接使用最新版本,功能更加强大。

在Taro+React中使用F2 4.x

在查看F2的文档时,发现文档中有React和小程序的接入,并没有教程说明如何使用Taro的接入。既然可以在小程序和React工程中使用,理论上在Taro+React工程中也可以使用。说干就干,让我们先跟着文档上面的React教程进行接入。

创建Taro+React工程

根据Taro文档,创建React工程,并且新增钉钉小程序编译选项文档教程

安装F2相关依赖

1
2
PowerShell复制代码npm install @antv/f2 --save
npm install @antv/f2-react --save

在index文件中复制F2官网Demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
typescript复制代码import Canvas from '@antv/f2-react';
import { Chart, Interval } from '@antv/f2';

const data = [
{ genre: 'Sports', sold: 275 },
{ genre: 'Strategy', sold: 115 },
{ genre: 'Action', sold: 120 },
{ genre: 'Shooter', sold: 350 },
{ genre: 'Other', sold: 150 },
];

export default () => <Canvas>
<Chart data={data}>
<Interval x='genre' y='sold' />
</Chart>
</Canvas>

执行代码

通过上述代码,执行后,钉钉小程序控制台报错
控制台错误信息

创建Canvas绘图上下文

查看错误代码,发现错误文件为@antv/f2-react报错。通过查看代码,发现钉钉小程序是通过调用dd.createCanvasContext(canvasId) 创建canvas绘图上下文,调整相关代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
typescript复制代码
class ReactCanvas extends React.Component<CanvasProps> {
// 81行
getProps = () => {
const { canvasRef, props } = this;
// 删除改代码 start
// const canvasEl = canvasRef.current;
// const context = canvasEl.getContext('2d');
// 删除结束 end
// context根据canvasId获取
const context = Taro.createCanvasContext(props.id ?? 'canvasId');
};
render: () => {
const { props } = this;

return React.createElement(TaroCanvas,
// 添加canvas属性Id,获取上下文
id: props.id ?? 'f2Canvas',
});
}
}

调整代码后,控制台不报错了,并且也渲染出来图表的,不过图表渲染的有点奇怪,只有左上角一点点图形

通过查看F2源码,发现在初始化图表时,需要获取Canvas的宽高。如果外部没有传入宽高,代码中通过DOM API获取元素宽高,但是小程序不支持该方式,所以显示图形异常。以下为相关代码

获取Canvas宽高

1
2
3
4
5
6
7
8
9
10
11
scala复制代码class Canvas extends EventEmit {

_initCanvas() {
// 获取canvas的宽高,对于小程序,如果外部不传入,则宽高为0
const width = this.get('width') || getWidth(canvas) || canvas.width;

const height = this.get('height') || getHeight(canvas) || canvas.height;
}
}

export default Canvas;

初始化设置canvas宽高

既然无法默认获取元素宽高,那我们可以使用小程序提供获取元素宽高的方式,来手动的获取元素的宽高,并且赋值给Props对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
scala复制代码// 调整f2-canvas相关代码
class ReactCanvas extends React.Component<CanvasProps> {

renderOrUpdateCanvas = async (type: 'init' | 'update') => {
// 接收一个id参数,并且通过小程序API获取元素宽高
const canvasId = props.id ?? 'f2Canvas'

// 伪代码,获取元素的宽高
const { width, height } = await getWidthAndHeight(canvasId)

return {
width,
height,
};
};

componentDidMount() {
// 元素可能没有渲染出来,等下一帧执行
nextTick(() => {
this.renderOrUpdateCanvas("init")
})
}
}

设置宽高后,柱状图可以正常显示

虽然柱状图正常显示出来了,不过图表很模糊,像是带了老花镜看似的。通过查看F2文档,发现可以通过配置pixelRatio来设置图表清晰度。并且钉钉小程序可以通过getSystemInfoSync获取设备的分辨率

pixelRatio方案设置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
scala复制代码// 调整f2-canvas相关代码
class ReactCanvas extends React.Component<CanvasProps> {

pixelRatio: number;

renderOrUpdateCanvas = async (type: 'init' | 'update') => {
const config = {
// 已经有高清方案,这里使用设备分辨率
pixelRatio: this.pixelRatio,
...props,
};
};

componentDidMount() {
// 获取设备的分辨率
this.pixelRatio = Taro.getSystemInfoSync().pixelRatio
// 元素可能没有渲染出来,等下一帧执行
nextTick(() => {
this.renderOrUpdateCanvas("init")
})
}
}

设置完成之后,查看柱状图,直接不显示数据了。

通过查找钉钉小程序API,没有找到任何关于Canvas精确度的问题。不过最终通过查看钉钉小程序老大哥支付宝小程序的开发文档,发现了相关内容 支付宝小程序文档-canvas画布问题,通过给Canvas元素设置高分辨率宽高来解决

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
kotlin复制代码// 调整f2-canvas相关代码
class ReactCanvas extends React.Component<CanvasProps> {
pixelRatio: number;
canvasIsInit: boolean;

// 保存变量值
state: Readonly<{ width: number; height: number }>;

renderCanvas = async () => {
return {
// 已经有高清方案,这里默认用1
pixelRatio: this.pixelRatio,
};
};

componentDidMount() {
this.pixelRatio = Taro.getSystemInfoSync().pixelRatio

nextTick(() => {
getWidthAndHeight(this.props.id ?? 'f2Canvas').then(res => {
// 设置高精度宽高
this.setState({
width: res.width * this.pixelRatio,
height: res.height * this.pixelRatio,
})
})
})
}

render() {
const { props } = this;

return React.createElement(TaroCanvas, {
id: props.id ?? 'f2Canvas',
// 设置Canvas的宽高,用于高清展示图表
width: this.state.width || '100%',
height: this.state.height || '100%',
});
}
}

设置完成之后,图表就可以高清展示了

notice:设置Canvas宽高后,TS会报错,width属性不存在。是因为Taro中没有定义Canvas的width和height属性。可以手动添加一下ts文件

1
2
3
4
5
6
7
typescript复制代码declare module '@tarojs/components' {
export * from '@tarojs/components/types/index';

import { CanvasProps } from '@tarojs/components/types/Canvas';

export const Canvas: ComponentType<CanvasProps & { width?: number | string; height?: number | string }>;
}

抹平context差异

图表虽然可以正常展示了,不过并没有坐标信息。当我们尝试给图标添加坐标信息时,发现页面代码报错会报错

1
2
3
4
5
6
typescript复制代码    <F2Canvas id='wrap'>
<Chart data={data}>
<Interval x='genre' y='sold' />
<Axis field='genre' />
</Chart>
</F2Canvas>

通过查看在小程序中使用F2相关文档,发现F2 是基于 CanvasRenderingContext2D 的标准接口绘制的,但是小程序中给的 context 对象不是标准的 CanvasRenderingContext2D , 所以需要将context对象进行封装兼容处理,详情可见: github.com/antvis/f2-c…, 其他小程序也可以按同样的思路封装。继续修改相关代码,抹平小程序context差异。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
scala复制代码import { my } from '@antv/f2-context'
class ReactCanvas extends React.Component<CanvasProps> {

renderCanvas = async () => {
// 抹平context实例,引用f2-context文件
const context = my(Taro.createCanvasContext(canvasId));
return {
// 上下文
context,
};
};

componentDidUpdate() {
this.renderCanvas()
}
}

当我们完成所有操作后,图表和坐标信息就可以完整的展示出来了

事件传递

图表已经可以正常展示,不过当我们使用Tooltip组件时,我们所有的事件都没有作用。

通过查看f-my代码时,我们需要当触发Canvas容器组件事件时,触发图表组件事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
typescript复制代码
function wrapEvent(e) {
if (e && !e.preventDefault) {
e.preventDefault = function () {};
}
return e;
}

class ReactCanvas extends React.Component<CanvasProps> {
canvasEl;
handleTouchStart = (e) => {
const canvasEl = this.canvasEl;
if (!canvasEl) {
return;
}
canvasEl.dispatchEvent('touchstart', wrapEvent(e));
};
render() {
return React.createElement(TaroCanvas, {
onTouchStart: this.handleTouchStart,
});
}
}

事件传递后,可以正常显示文案提示

小结

本章节在Taro+React中接入F2的过程中遇到了不少问题。通过查看React与小程序的接入方式,一步一步的解决下面的问题

  • 在小程序中Canvas的上下文获取方式和web不一致
  • 每次执行代码时需要手动获取Canvas的宽高(无法通过DOM API获取)
  • 小程序模糊问题(pixelRatio的设置)
  • 小程序中Canvas的context不是标准的CanvasRenderingContext2D对象,需要对齐添加补丁。目前查询支付宝小程序文档,发现最新版本已经调整为标准对象了,不过钉钉小程序目前还不支持
  • 小程序事件需要显式定义,并且传递给图表库

目前已经有人封装好了对应的接入代码,我们可以直接在github中查看使用
Taro+React+F2

实战使用

当我们完成上述代码后,就可以正常的根据F2的官网示例在钉钉小程序中使用图表了。不过部分功能需要额外开发

自定义Tooltip

目前F2中默认的Tooltip提示都是在图表顶部显示,并且展示上面只能设置部分属性,不太满足这边的UI规范。好在Tooltip提供了自定义实现的方式,让我们可以自定义显示Tooltip提示。目前古茗通过使用View标签,并且绝对定位的方式来显示对应的文本信息

自定义配置方式

通过设置属性custom,F2不会显示默认Tooltip。通过onChange获取当前的选中的元素信息,可以拿到对应的位置信息与数据信息。从而可以自定义显示对应文本

1
2
ini复制代码// 自定义配置 
<Tooltip custom ref={tooltipRef} triggerOn="click" onChange={handleTooltipChange} />

View标签位置获取

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
ini复制代码import { View } from '@tarojs/components';
import { nextTick } from '@tarojs/taro';
import classNames from 'classnames';
import { Ref, forwardRef, useImperativeHandle, useRef, useState } from 'react';
import { CustomTooltipRef, TooltipChangeEvent } from './types';
import { PREFIX, getEleClientRect } from '../../../../utils';
import './index.less';

/**
样式设置
.custom-tooltip {
position: absolute;
z-index: 2;
display: none;
padding: 16px 20px;
color: #ffffff;
font-weight: 400;
background: rgba(12, 12, 12, 0.7);
border-radius: 4px;
transform: translateY(50%);
pointer-events: none;
}

*/

let num = 0;
const cls = `custom-tooltip`;
export const CustomTooltip = forwardRef(
<T extends {}>(
props: {
children: React.ReactNode;
placement?: 'center';
},
ref?: Ref<CustomTooltipRef>
) => {
const { children, placement } = props;
const customTooltipRef = useRef<HTMLDivElement>(null);
const [classNum] = useState(num++);

const handleTooltipChange = (_items: Array<TooltipChangeEvent<T> & T>) => {
// 优先取第一条数据
const [{ x, y, yMax }] = _items;
const otherY = _items?.[1]?.y;
let yTop = y || otherY;
/* 在图片中间位置显示 */
if (placement === 'center') {
yTop = (yMax || _items?.[1]?.yMax || y) / 2;
}
// 处理 props 并更新状态
// 更新 Tooltip 样式
const elStyle = customTooltipRef.current?.style;
if (elStyle) {
elStyle.left = '0';
elStyle.top = String(yTop);
elStyle.visibility = 'hidden';
elStyle.display = 'block';
nextTick(() => {
getEleClientRect(`.${cls}-${classNum}`).then((res) => {
elStyle.left = String(x > res.width ? x - res.width : x);
elStyle.visibility = 'visible';
});
});
}
};

const hide = () => {
const elStyle = customTooltipRef.current?.style;
if (elStyle) {
elStyle.display = 'none';
}
};

useImperativeHandle(ref, () => ({
handleTooltipChange,
hide,
}));

return (
<View ref={customTooltipRef} className={classNames(cls, `${cls}-${classNum}`)}>
{children}
</View>
);
}
);

实现效果

使用过程中存在的“坑”

示例

横坐标为0,无法触发Tooltip事件

当Axis坐标轴的值为0时,Tooltip点击事件不能点击执行

折线图反转后表现不一致

折线图反转后,空值直接链接了。没有截断处理

解决方案

一般上解决三方库中的问题

  • 对于不在维护的库,通过patch的方式进行处理
  • 升级库版本解决
  • 对于class组件,可以通过本地覆盖式更新代码

目前F2上面所有的组件都是class组件,所以可以通过继承或者修改原型链的方式来解决上述相关问题。
因为上面两个问题属于明显的bug,在我们这边采用修改原型链上面的方法来解决bug。

横坐标为0问题分析

因为是Tooltip的show方法没有执行,通过寻找代码,找到最终原因为判断date值时,没有处理0导致的。5.x已优化该问题

Tooltip中withTooltip的show方法

1
2
3
4
5
6
7
8
arduino复制代码  show(point, _ev?) {
const { props } = this;
const { chart } = props;
// 该代码获取坐标相关位置
const snapRecords = chart.getSnapRecords(point, true); // 超出边界会自动调整

this.showSnapRecords(snapRecords);
}

Chart的getSnapRecords方法

1
2
3
4
5
6
kotlin复制代码  getSnapRecords(point, inCoordRange?) {
const geometrys = this.getGeometrys();
if (!geometrys.length) return;
// geometrys[0]为点击的相关线Line
return geometrys[0].getSnapRecords(point, inCoordRange);
}

Line的getSnapRecords方法继承Geometry中的getSnapRecords

1
2
3
4
5
6
arduino复制代码  getSnapRecords(point, inCoordRange?): any[] {
// 该处理没有对value等于0的判断,导致没有返回坐标轴相关信息
if (!value) {
return rst;
}
}
解决方案,重写Geometry的getSnapRecords方法
1
2
3
4
5
6
7
8
9
10
11
javascript复制代码const resetGeometryGetSnapRecords = () => {
Geometry.prototype.getSnapRecords = function (point, inCoordRange?) {
// 省略代码
const value = this._getXSnap(invertPoint.x);
// 放过value为0的场景
if (!value && value !== 0) {
return rst;
}
return rst;
}
}

折线图反转后表现不一致

该问题为折线图展示的线不一致问题。第一个图为两条线,第二个图为一条线。查看Line相关代码,发现折线图在render的时候通过this.mapping()获取了对应的记录点

折线图没有处理坐标反转时的坐标

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
javascript复制代码import { jsx } from '../../jsx';
import { isArray } from '@antv/util';
import Geometry from '../geometry';
import { LineProps } from './types';

export default (View) => {
return class Line extends Geometry<LineProps> {

splitNulls(points, connectNulls) {
// 该方法只是判断了y轴是否为空,但是坐标轴反转的时候需要判断x轴是否为空
for (let i = 0, len = points.length; i < len; i++) {
const point = points[i];
const { y } = point;
if (isArray(y)) {
if (isNaN(y[0])) {
if (tmpPoints.length) {
result.push(tmpPoints);
tmpPoints = [];
}
continue;
}
tmpPoints.push(point);
continue;
}
if (isNaN(y)) {
if (tmpPoints.length) {
result.push(tmpPoints);
tmpPoints = [];
}
continue;
}
tmpPoints.push(point);
}
if (tmpPoints.length) {
result.push(tmpPoints);
}
return result;
}

mapping() {

return records.map((record) => {
// 获取坐标点位
const splitPoints = this.splitNulls(points, connectNulls);
});
}

render() {
// 获取点位信息
const records = this.mapping();
// 省略其他代码...
return <View {...props} coord={coord} records={records} clip={clip} />;
}
};
};
解决方案,重写Line的splitNulls方法
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
ini复制代码  Line.prototype.splitNulls = function (points, connectNulls) {
const result = [];
let tmpPoints = [];
for (let i = 0, len = points.length; i < len; i++) {
const point = points[i];
const { x, y } = point;
/* start 打补丁,处理坐标轴转换引起折线渲染链接问题 */
if (this.props.coord.transposed) {
if (Array.isArray(x)) {
if (isNaN(x[0])) {
if (tmpPoints.length) {
result.push(tmpPoints);
tmpPoints = [];
}
continue;
}
tmpPoints.push(point);
continue;
}
if (isNaN(x)) {
if (tmpPoints.length) {
result.push(tmpPoints);
tmpPoints = [];
}
continue;
}
}
/* end 打补丁,处理坐标轴转换引起折线渲染链接问题 */
}
return result;
};

总结

以上我们通过分析世面上的图表库,选择了在钉钉小程序中使用F2图表库进行开发。因为小程序不支持DOM相关API和Canvas不是标准的CanvasRenderingContext2D对象,接入过程中踩了不少的坑,对新人不太友好。不过最后还是根据官方的接入文档完成了小程序的接入。强烈建议F2官网可以在官网中加入Taro的接入,降低使用门槛

小茗推荐

  • 去掉状态管理工具(formily)的 observer
  • 小程序用户登录:安全性与用户体验的平衡
  • 古茗如何做前端数据中心 - SDK 设计篇

最后

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

本文转载自: 掘金

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

移动开发中关于音频的基本概念 模拟音频 数字音频 数字音频压

发表于 2024-04-13

在音频开发过程中,总会面对很多音频领域的一些专业名词,如果不能理解这些名词,可能会对开发领过程造成极大的困扰,在进入一些新领域时,熟悉这个领域常用的概念是很有必要的,可以极大减少了沟通和理解的成本。

模拟音频

声音是物体的震动产生的波,声波一般不是正弦波,比如这种

image.png

我们常说声音的三要素:音调,音色,响度。

音调:主要指的是声音的频率,频率越高,音调越高。
音色:主要指的是波形不同(谐波)
响度:主要指的是声音的音量,在声波中的体现就是振幅,振幅越大,响度越大

看到这里可能有疑惑,既然声音是不规则的波形,为什么声音三要素的定义似乎都参照正弦波这种规则波形定义呢?

因为傅里叶变换告诉我们,任何波形都可以转换为N个正弦波来表示,其中频率最小,振幅最大的正弦波为基波,其余的为谐波,音调和响度基本依照基波来定,而音色则是谐波来决定的。

image.png

音调和响度决定了发音方式,比如每个人都可以对某个字发音,但是不同的人音色不同,也就是其声音的谐波不同,所以听起来是不一样的。

数字音频

在现实世界中,声音是连续的,但是在网络世界,数据则是离散的,现实世界的声音录入计算机往往需要经历一次采样,就是在连续的声音波形中定期打点取样,只要每秒打点次数足够多,那么就可以近似认为这些点组成了连续的波形。

image.png

PCM

PCM 是指脉冲编码调制(Pulse Code Modulation)

把声音从模拟信号转化为数字信号的技术,即对声音进行采样、量化的过程,经过PCM处理后的数据,是最原始的音频数据,即未对音频数据进行任何的编码和压缩处理。

而且在数字世界,声音不再用音调,响度,音色来定义,而是需要关注采样率,采样精度(位深度),声道数。

采样率(sample rate)

采样率就是我们说的一定周期内对现实声波采样的次数,比如我们常见的44100hz,就是每秒钟对声音采样44100次,相当于每秒钟记录了44100个声音的数据。

声音的采样频率一般共分为22.05KHz、44.1KHz、48KHz三个等级:

  • 22.05kHz 采样率的声音可以达到CD音质的一半
  • 44.1kHz采样率是标准的CD音质,可以达到很好的听觉效果(一般最常使用)
  • 48KHz:miniDV、数字电视、DVD、电影和专业音频

采样精度(位深度/bit depth)

每个采样点所能表示的数据范围,范围越大表明声音越丰富,越细腻。波形的纵轴就表示采样点的大小。

image.png

通常有8bit和16bit两种,也有更高的,比如20bit,24bit,32bit.

8bit为低品质

16bit为高品质(最为常见)

假如声音的采样精度为16bits,则每个采样点能表示得范围是2^16,如果是8bits,则每个采样点能表达的范围是2^8,显然,前者比后者更加精确,在数字音频领域,就表现为声音的还原度更高,声音更细腻。

声道数(channel/通道)

声道是存储音频的轨道,用来给发声设备发声,一般音频文件中可能不止一个声道,多个声道保存了多份音频数据,用来给不同的设备发声。

常见声道有单声道(mono)、双声道(stereo)、2.1声道、4声道、5.1声道、7.1声道。

其中单声道是一个声道,双声道是两个声道,后面数字表示的声道,声道数就是是小数点前后数字相加,比如2.1是三个声道,5.1是6个声道,7.1是8个声道。

  • 双声道: 左声道+右声道 也称作立体声
  • 多声道: 超过2个声道即可
  • 2.1声道: 两个中高音单元+1个低音单元
  • 4声道: 前左、前右,后左、后右四个发声单元

image.png

  • 5.1声道:两前置单元,两后置单元,一个中央单元,一个低音单元,最早应用于早期的电影院
  • 7.1声道:在5.1的基础上增加了左后和右后两个发声单元,主要应用于BD以及现代的电影院

比特率(码率/bit rate)

表示一秒钟音频的信息量。

因此它是一个可以计算的数据:

bit_rate = channel_count * sample_rate * bit_depth / 8

我们可以利用音频的比特率来计算当前音频帧的显示时间戳(PTS)

数字音频压缩

我们提到采样到数字领域的音频数据是无压缩的原始数据,因此后续会经过一些编码算法处理来进行压缩。

压缩算法主要可以分为无损压缩和有损压缩

无损压缩

无损压缩指的是在无损格式之间的压缩,无论压缩成什么格式,音质都是不变的,并且都能被还原成最初同样的文件格式。

FLAC(Free Lossless Audio Codec)

压缩比高,编码算法也相当成熟,当flac文件受损时依然能正常播放。

参数

  • 采样率 1–655350 Hz (逐1hz微调)
  • 比特率 灵活
  • 位深度 8, 16, 20, 24, 32
  • 多通道 1-8

ALAC

无损压缩,采样率灵活,采样深度范围较大

参数

  • 压缩率
  • 采样率 1–384000 Hz
  • 比特率 灵活
  • 位深度 16, 20, 24, 32
  • 多通道 1-8

APE(Monkey’s Audio)

无损压缩,采样率灵活。

不支持多通道,采样深度不够

参数

  • 采样率 1–655350 Hz
  • 比特率 灵活
  • 位深度 8, 16, 24
  • 多通道 否

有损压缩

MP3 (MPEG Audio Layer III)

比特率的限制320kbit/s

采样频率最高为48kHz,对于超过48kHz采样频率的音频无法编码在MP3内

参数

  • 采样率 32khz 44.1 khz 48 kHz (仅允许三种)
  • 比特率 6、12、24…96, 112, 128, 144, 160, 192, 224, 256, 288, 320 kbit/s等 (128以上音质不错,CD上未经压缩的音频比特率为1411.2 kbps)
  • 采样精度 8, 16,
  • 声道数

AAC (Advanced Audio Coding)

mp3的升级版,有更好的采样率,采样深度,更多声道数,更好的压缩算法。

参数

  • 采样率 8–192 kHz
  • 比特率 8–529 kbit/s
  • 采样精度 8, 16, 24, 32 bit
  • 多通道 1-48

资料

blog.csdn.net/hello_1995/…

zh.wikipedia.org/zh-cn/%E6%9…

zh.wikipedia.org/zh-cn/%E9%9…

本文转载自: 掘金

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

java多线程面试系列——旧版api

发表于 2024-04-13

在Android开发的面试中,Java多线程的问题是绕不开的。这个系列主要介绍面试过程中涉及到的多线程的知识点,以及相关的面试题。这是本系列的第二篇,介绍Java中多线程的旧版api,及其对应的常见的面试题。

  • Java多线程面试系列——为什么需要多线程 - 掘金 (juejin.cn)
  • java多线程面试——旧版api - 掘金 (juejin.cn)
  • java多线程面试——新版api - 掘金 (juejin.cn)

java创建线程的方式

java创建线程有三种方式,分别是:

  1. 通过Runnable创建线程
1
2
3
4
5
6
7
8
9
10
11
12
java复制代码public class RunnableTest implements Runnable {
@Override
public void run() {

}
}

public static void main(String[] args) throws Exception {
Runnable runnable = new RunnableTest();
Thread thread = new Thread(runnable);
thread.start();//启动线程
}

2.继承Thread创建线程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码public class MyThread extends Thread {
public MyThread(String name) {
super(name);
}

@Override
public void run() {

}
}

public static void main(String[] args) throws Exception {
Thread thread = new MyThread("线程");
thread.start();
}
  1. 使用Callable和Future创建线程
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
arduino复制代码复制代码
public class MyCallable implements Callable<String> {
@Override
public String call() throws Exception {

}
}

public static void main(String[] args) throws Exception {
Callable<String> callable = new MyCallable();
FutureTask<String> task = new FutureTask<>(callable);
Thread t = new Thread(task);
t.start();
System.out.println(task.get());
}

面试题1:一个线程两次调用start()方法会出现什么情况

Java的线程是不允许启动两次的,第二次调用会抛出 IllegalThreadStateException 异常。

面试题2:runnable 和 callable 有什么区别?

runnable 没有返回值,callable 可以拿到有返回值,callable 可以看作是 runnable 的补充。

面试题3: 线程的 run() 和 start() 有什么区别?

start() 方法用于启动线程,run() 方法用于执行线程的运行时代码。run() 可以重复调用,而 start() 只能调用一次。

线程的生命周期

线程有六种状态,分别如下:

  1. NEW(初始化状态)
  2. RUNNABLE(可运行 / 运行状态)
  3. BLOCKED(阻塞状态)
  4. WAITING(无时限等待)
  5. TIMED_WAITING(有时限等待)
  6. TERMINATED(终止状态)

可以通过getState()获取线程所处的状态。状态之间的切换原因如下:

状态 切换原因
NEW(初始化状态) 创建Thread
RUNNABLE(可运行 / 运行状态) 调用start方法
BLOCKED(阻塞状态) 只有线程等待synchronized锁时
WAITING(无时限等待) 调用wait、join、LockSupport.park 时
TIMED_WAITING(有时限等待) 调用sleep,带时间的wait、join、LockSupport.parkNanos
TERMINATED(终止状态) 执行完run方法后,自动切换到TERMINATED状态

面试题4:如果当前线程等待 CPU 使用权或者等待 I/O ,这时线程的状态是什么?

线程等待 CPU 使用权与等待 I/O 时也是RUNNABLE状态

wait、notify、notifyAll

1
2
3
4
5
6
7
java复制代码synchronized (obj) {  
try {
obj.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}

调用 wait 方法会让线程进入等待状态(WAITING),同时释放掉锁。如果需要唤醒线程,需要调用 notify 或者 notifyAll 方法。notify 方法是随机唤醒一个线程,而 notifyAll 方法是会唤醒所有等待线程

sleep

1
2
3
4
5
java复制代码try {  
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}

调用sleep方法会让线程休眠一段时间,进入有时限等待状态(TIMED_WAITING)。需要注意,sleep 方法不会释放锁,但会释放CPU资源

面试题5:wait与sleep区别有哪些

  1. wait释放锁,sleep不释放锁
  2. wait需要被唤醒,sleep不需要
  3. wait需要获取到监视器(在synchronized代码块里面),否则抛异常,sleep不需要
  4. wait是object顶级父类的方法,sleep则是Thread的方法

join

1
2
3
4
5
6
7
8
java复制代码//主线程执行
Thread thread = new Thread(runnable);
thread.start();
try {
thread.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}

join 方法的作用是等待指定的线程的结束。如上面的代码所示,主线程会等待 thread 线程执行结束后再执行。这时主线程会变成 WAITING 状态。需要注意,join方法只有在start方法之后调用才有效。

线程的中断

1
2
3
java复制代码void interrupt() //请求线程中断,线程不一定会中断
boolean isInterrupted() //检测当前线程是否中断,不会改变中断状态,能多次判断
static boolean interrupted() //检测当前线程是否中断,会重置中断状态为false,只能判断一次

我们调用 interrupt 方法来请求线程中断,这时中断的操作只会改变中断状态,并不会中断程序, 这时需要我们使用 if(Thread.currentThread().isInterrupted()) 来判断是否处于中断状态,自己来处理,这样增加了灵活性。

面试题6:isInterrupted 和 interrupted 的区别

  • isInterrupted:是成员方法,作用是检测当前线程是否中断,不会改变中断状态,因此可以多次判断是否中断
  • interrupted:是静态方法,作用是检测当前线程是否中断,它会重置中断状态为false,因此只能判断一次是否中断

面试题7:中断操作在线程所有状态下都生效吗

当线程处于New或Terminated状态时,中断操作无效,即中断状态不会改变; 当线程处于Runnable或Blocked时,中断操作会改变中断状态;当线程处于 WAITING 或 TIMED_WAITING 时,中断操作会抛出异常,isInterrupted() 不会返回 true,并且结束程序。

需要注意:当线程 A 处于 WAITING、TIMED_WAITING 状态时,如果其他线程调用线程 A 的 interrupt() 方法,会使线程 A 返回到 RUNNABLE 状态,同时线程 A 的代码会触发InterruptedException 异常。ReentranLocak.lock方法会进入waiting状态,但是不会被interrupt打断

面试题8:终止线程为什么不用 stop

stop 方法会直接杀死线程,如果此时线程持有 ReentrantLock 锁,被 stop 的线程并不会自动调用 ReentrantLock 的 unlock() 去释放锁,此时其他线程就再也没机会获得 ReentrantLock 锁。

线程优先级

每一个线程都有一个优先级,在默认的情况下,一个线程继承它父类线程的优先级。 可以使用setPriority(int newPriority)设置优先级。

优先级的等级在MIN_PRIORITY(默认值为1)和MAX_PRIORITY(默认值为10)之间;默认为 NORM_PRIORITY(默认值为5).

守护线程

1
2
3
4
5
6
7
8
9
10
java复制代码public static void main(String[] args) {  
Runnable runnable = new Runnable() {
@Override
public void run() {
while (true);
}
};
Thread thread = new Thread(runnable);
thread.start();
}

当我们执行上面代码的时候,由于子线程会一直执行,这时即使主线程结束了,该进程也不会结束。如果我们希望主线程结束了,子线程也会结束,我们可以通过
thread.setDaemon(true); 让子线程成为守护线程。

守护线程的唯一用途是为其他线程通过服务。注意:守护线程应该永远不去 访问固有资源,如文件,数据库,因为它会在任何时刻甚至在一个操作中间发生中断

ThreadLocal

ThreadLocal 是指线程本地变量,它可以让每个线程都有自己独立的变量副本,互不干扰。

1
2
3
4
5
6
7
8
9
10
11
csharp复制代码public class RunnableTest implements Runnable { 
private final ThreadLocal<Integer> value = new ThreadLocal<>();

@Override
public void run() {
//设置value
value.set(124);
//获取value
value.get();
}
}

实现原理如下图所示:

java多线程.jpg

在 Thread 类中,有 ThreadLocalMap 的属性,当我们通过 ThreadLocal 的 set 方法设置 value 时,它会往当前 Thread 中的 ThreadLocalMap 插入 key 为 ThreadLocal,value 为指定值的一项。

面试题9:为什么ThreadLocal会造成内存泄漏问题

ThreadLocal 虽然是弱引用,但是其对应的value为强引用。如果 ThreadLocal 被垃圾回收,这时 key 为 null,但是 value 不会为null。如果该线程生命周期比较长(可能是线程池的核心线程),这时value这块内存就会一直在内存中,造成内存泄漏。

synchronized

synchronized 是旧版java中来在多线程中保持同步的关键字。synchronied 可以修饰普通方法,等价于synchronized(this){},是给当前类的对象加锁;synchronied 修饰静态方法,等价于synchronized(class){},是给当前类对象加锁。

需要注意,synchronized(obj){} 中的obj不要使用 String、Integer 等的对象,这是因为它们内部会缓存常量字符串、数字,会对同步造成影响

同步容器

同步容器是早期Java解决多线程操作容器的方案,同步容器有 Hashtable、Vector、Stack。它们的实现原理很简单就是给方法加上 synchronized 关键字,但是这会造成性能的问题。新版的java中提供了新的并发容器来解决这个问题,在下一篇文章会介绍新版的多线程接口。

参考

  • 14个Java并发容器,Java高手都知道!-阿里云开发者社区 (aliyun.com)
  • Java面试题|多线程22道必看面试题 - 知乎 (zhihu.com)
  • 【建议收藏】106道Android核心面试题及答案汇总(总结最全面的面试题)
  • 面试官问我什么是JMM - 知乎 (zhihu.com)
  • Java 并发编程实战 (geekbang.org)
  • final保证可见性和this引用逃逸 - 知乎 (zhihu.com)
  • Synchronized的底层实现原理(原理解析,面试必备)_synchronized底层实现原理-CSDN博客
  • 线程间到底共享了哪些进程资源 - 知乎 (zhihu.com)
  • stackoverflow.com/questions/1…
  • spotcodereviews.com/articles/co…
  • Linux内核同步机制之(三):memory barrier (wowotech.net)
  • 万字长文!一文彻底搞懂Java多线程 - 掘金 (juejin.cn)

本文转载自: 掘金

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

⌛ Redis7 十大数据类型 (下篇) Redis7 十大

发表于 2024-04-13

Redis7 十大数据类型介绍及使用

1.1 Redis环境搭建

1.1.1 用虚拟机 VMware Workstation Pro 搭建 centos7

image.png

1.1.2 用 FinalShell 连接linux

image.png

【成功连接画面】

image.png

我们这里使用 Docker+Shell 脚本安装Redis最新版

shell脚本百度网盘下载地址:

image.png

image.png

1.1.2 用 Another Redis Desktop Manager 连接Redis

image.png

image.png

image.png

1.2 redis位图(bitmap)

Redis的 位图(bitmap) 是由0和1状态表现的二进制位的bit数组,主要适合在一些场景下进行空间的节省,并有意义的记录数据。例如一些大量的bool类型的存取,一个用户365天的签到记录,签到了是1,没签到是0,如果用普通的key/value进行存储,当用户量很大的时候,需要的存储空间是很大的。如果使用位图进行存储,一年365天,用365个bit就可以存储,365个bit换算成46个字节(一个稍长的字符串),如此就节省了很多的存储空间。

1.2.1 setbit

SETBIT key offset value (setbit 键 偏移位 只能零或者1)

1.2.2 getbit

GETBIT key offset

image.png

1.2.3 strlen

STRLEN key (统计字节数占用多少)

image.png

可以看出来bitmap的偏移量是从0开始的,不是字符串长度而是占据几个字节,超过8位后自己按照8位一组一byte再扩容

1.2.4 bitcount

BITCOUNT key [start] [end] (全部键里面含有1的有多少个)

image.png

1.2.5 bitop

BITPOS key bit [start] [end] (统计连续偏移位为1的有几个)
image.png

1.3 redis基数统计(HyperLogLog)

Redis 的 HyperLogLog 是一种用于基数统计的算法,主要用于在极大数据量的情况下快速估算集合中唯一元素
的数量。

基数统计 :用于统计一个集合中不重复的元素个数,就是对集合去重复后剩余元素的计算

1.3.1 PFADD

PFADD key element [element …] (添加指定元素到 HyperLogLog 中。)

1.3.2 PFCOUNT

PFCOUNT key [key…] (返回给定 HyperLogLog 的基数估算值)

1.3.3 PFMERGE

PFMERGE destkey sourcekey[sourcekey….] (将多个 HyperLogLog 合并为一个 HyperLogLog)

image.png

需要注意的是HyperLogLog统计的是基数的数量,并不保存具体数值

1.4 redis地理空间(GEO)

Redis的GEO(Geo Redis)是一个用于存储和操作地理空间数据的Redis模块,它提供了一组命令,可以将地理位置数据存储为Redis键值,并支持各种地理位置查询和操作。

1.4.1 GEOADD

GEOADD key [NX|XX] [CH] longitude latitude member [longitude latitude member …] (添加经纬度坐标 )

1.4.2 GEOPOS

GEOADD key [NX|XX] [CH] longitude latitude member [longitude latitude member …] (添加经纬度坐标 )

1.4.3 GEOHASH

GEOHASH key member [member ..] (返回坐标的geohash表示 )

1.4.4 GEODIST

GEODlST key member1 member2 [unit] (两个位置之间距离 )

1.4.5 GEORADIUS

GEORADIUS (以给定的经纬度为中心, 返回键包含的位置元素当中, 与中心的距离不超过给定最大距离的所有位置元素 )

1.4.6 GEOADIUSBYMEMBER

GEOADIUSBYMEMBER (找出位于指定范围内的元素,中心点是由给定的位置元素决定 )

image.png

image.png

1.5 redis流(Stream)

Redis Stream是Redis 5.0版本引入的一个新的数据类型,主要用于实现消息队列(MQ,Message Queue)。它提供了消息的持久化和主备复制功能,可以让任何客户端访问任何时刻的数据,并且能记住每一个客户端的访问位置,还能保证消息不丢失。

1.5.1 XADD

XADD key lD field string [field string …] (添加消息到队列末尾 )

image.png

客户端显示传入规则:Redis对于ID有强制要求,格式必须是时间戳-自增Id这样的方式,且后续ID不能小于前一个ID

Stream的消息内容,也就是图中的Message Content它的结构类似Hash结构,以key-value的形式存在。

1.5.2 XRANGE

XRANGE key start end [COUNT count] (用于获取消息列表(可以指定范围),忽略删除的消息 )

image.png

1.5.3 XREVRANGE

XREVRANGE key end start [COUNT count] (倒序展示消息队列)

image.png

1.5.4 XDEL

XDEL key ID [lD ..] (根据ID删除消息)

image.png

1.5.5 XLEN

XDEL key ID [lD ..] (获取某个消息中的个数)

image.png

1.5.6 XTRIM

XTRIM key MAXLEN [~] count (用于对Stream的长度进行截取,如超长会进行截取)

image.png

1.5.7 XREAD

XREAD [COUNT count] [BLOCK milliseconds] STREAMS key [key …] D [lD …] (用于获取消息(阻塞/非阻塞),只会返回大于指定ID的消息)

image.png

1.5.8 XGROUP CREATE

XGROUP CREATE key groupname id$ [MKSTREAM] (用于创建消费者组)

image.png

$表示从Stream尾部开始消费

0表示从Stream头部开始消费

创建消费者组的时候必须指定 ID, ID 为 0 表示从头开始消费,为 $ 表示只消费新的消息,队尾新来

1.5.9 XREADGROUP GROUP

XREADGROUP GROUP group consumer [COUNT count] [BLOCK miliseconds] [NOACK, STREAS key [key . D [lD ..] (读取消费者里面的全部数据)

image.png

stream中的消息一旦被消费组里的一个消费者读取了,就不能再被该消费组内的其他消费者读取了,即同一个消费组里的消费者不能消费同一条消息。刚才的XREADGROUP命令再执行次,此时读到的就是空值

1.5.10 XPENDING

XPENDING key group [start end count] [consumer] (查询每个消费组内所有消费者「已读取、但尚未确认」的消息)

image.png

1.5.11 ACK

向消息队列确认消息处理已完成

image.png

1.6 总结

本问主要介绍了 redis位图(bitmap)、redis基数统计(HyperLogLog)、redis地理空间(GEO)、redis地理空间(GEO)、redis流(Stream)的内容以及用法

redis 常用于以下场景:

  1. 缓存:加速数据的访问,减少对后端数据库的负载。
  2. 会话管理:存储用户会话信息。
  3. 排行榜:处理诸如点击量、评分等数据的排名。
  4. 发布/订阅:实现消息的发布和订阅。
  5. 计数器:用于计数,如文章的阅读量等。

本文转载自: 掘金

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

前端说你的API接口太慢了,怎么办? 常用的方法总结 前端快

发表于 2024-04-12

当有千万条海量数据时,前端调取接口发现接口响应的太慢,前端这时让你优化一下接口,你说有几千万条数据,觉得自己尽力了,前端觉得你好菜,别急,读完这篇文章,让前端喊你一声:大佬,厉害!!!

常用的方法总结

通过合理的分页加载、索引优化、数据缓存、异步处理、压缩数据等手段,可以有效地优化接口性能,提升系统的响应速度。以下是一些优化建议:

  1. 分页加载数据: 如果可能的话,通过分页加载数据来减少每次请求返回的数据量。这样可以减轻服务器的负担,同时也减少了前端需要处理的数据量。
  2. 使用索引: 确保数据库表中的字段上建立了合适的索引,这样可以加快查询速度。分析常用的查询条件,并在这些字段上建立索引,这样可以大幅提升查询效率。
  3. 缓存数据: 如果数据不经常变化,可以考虑将数据缓存到内存中或者使用缓存服务,减少对数据库的频繁查询。这样可以大幅提高接口的响应速度。
  4. 异步处理: 如果接口需要执行一些耗时的操作,可以考虑将这些操作异步化,让接口能够快速返回响应。可以使用消息队列等方式来实现异步处理。
  5. 压缩数据: 在传输大量数据时,可以使用压缩算法对数据进行压缩,减少网络传输时间。
  6. 分析和优化代码: 定期对接口的代码进行性能分析,找出性能瓶颈,并进行相应的优化。可能存在一些不必要的数据处理或者重复查询,通过优化这些部分可以提升接口性能。
  7. 使用合适的服务器配置: 确保服务器具有足够的资源来处理大量数据请求,包括 CPU、内存、磁盘等。根据实际情况考虑是否需要升级服务器配置。
  8. 使用缓存技术: 可以考虑使用诸如 Redis 等缓存技术,将热门数据缓存起来,减少数据库的访问压力。

理论大家都懂,看完还是不会,别急,下面来点实战吧!以下是使用 Node.js 的示例代码来说明如何应用上述优化建议:

1. 分页加载数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
javascript复制代码// 假设使用 Express 框架
const express = require('express');
const app = express();

app.get('/api/data', (req, res) => {
const page = req.query.page || 1;
const pageSize = 10; // 每页数据量

// 根据页码和每页数据量来查询数据
const data = getDataFromDatabase(page, pageSize);

res.json(data);
});

function getDataFromDatabase(page, pageSize) {
// 根据页码和每页数据量查询数据库
// 例如使用 Sequelize 或者 MongoDB 进行查询
// 返回对应的数据
}

app.listen(3000, () => {
console.log('Server is running on port 3000');
});

2. 使用索引

1
2
3
4
5
6
7
8
9
10
11
12
13
javascript复制代码// 在数据库中为常用查询条件的字段创建索引
// 例如在 Sequelize 中创建索引可以这样做
const Model = sequelize.define('Model', {
// 定义模型属性
}, {
indexes: [
// 创建名为 index_name 的索引
{
name: 'index_name',
fields: ['fieldName']
}
]
});

3. 缓存数据

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
javascript复制代码// 使用 Redis 进行数据缓存
const redis = require('redis');
const client = redis.createClient();

app.get('/api/data', async (req, res) => {
const cachedData = await getFromCache('data');
if (cachedData) {
res.json(cachedData);
} else {
const data = await getDataFromDatabase();
await setToCache('data', data);
res.json(data);
}
});

function getFromCache(key) {
return new Promise((resolve, reject) => {
client.get(key, (err, reply) => {
if (err) reject(err);
else resolve(JSON.parse(reply));
});
});
}

function setToCache(key, data) {
return new Promise((resolve, reject) => {
client.set(key, JSON.stringify(data), (err, reply) => {
if (err) reject(err);
else resolve(reply);
});
});
}

4. 异步处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
javascript复制代码// 使用异步处理执行耗时操作
const { Worker, isMainThread, parentPort } = require('worker_threads');

app.get('/api/data', async (req, res) => {
if (isMainThread) {
const worker = new Worker('./worker.js');
worker.postMessage('start');

worker.on('message', (message) => {
res.json(message);
});
}
});

// worker.js
const { parentPort } = require('worker_threads');

parentPort.on('message', async (message) => {
if (message === 'start') {
const data = await getDataFromDatabase();
parentPort.postMessage(data);
}
});

这些示例展示了如何在 Node.js 中应用分页加载数据、使用索引、缓存数据和异步处理来优化接口性能。
除了上述提到的优化方法之外,还有一些额外的优化策略可以考虑:

5. 数据压缩

1
2
3
javascript复制代码// 使用 gzip 压缩数据
const compression = require('compression');
app.use(compression());

这将在服务器端压缩响应数据,减少传输的数据量,提高网络传输速度。

6. 定时任务

1
2
3
4
5
6
7
8
javascript复制代码// 使用定时任务定期更新缓存数据
const schedule = require('node-schedule');

// 每天凌晨1点更新缓存数据
schedule.scheduleJob('0 1 * * *', async () => {
const data = await getDataFromDatabase();
await setToCache('data', data);
});

这样可以避免每次请求都需要查询数据库,提高接口的响应速度。

7. 使用流处理大数据

1
2
3
4
5
6
7
8
9
10
11
javascript复制代码// 使用流来处理大量数据,而不是一次性加载到内存中
const fs = require('fs');
const stream = fs.createReadStream('large_data.txt');

stream.on('data', (chunk) => {
// 处理数据块
});

stream.on('end', () => {
// 数据处理完成
});

这种方式可以有效地减少内存占用,适用于处理大量数据的情况。

通过以上优化方法的综合应用,可以进一步提高接口性能,提升用户体验。

还有一些其他的优化方法可以考虑:

8. 数据库连接池

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
javascript复制代码// 使用数据库连接池来管理数据库连接
const { Pool } = require('pg');
const pool = new Pool();

app.get('/api/data', async (req, res) => {
const client = await pool.connect();
try {
const data = await getDataFromDatabase(client);
res.json(data);
} finally {
client.release();
}
});

async function getDataFromDatabase(client) {
// 使用数据库连接执行查询操作
}

这样可以有效地管理数据库连接,避免频繁地创建和销毁连接,提高数据库访问的效率。

9. 使用 CDN 加速静态资源

1
2
3
4
5
6
7
javascript复制代码// 将静态资源部署到 CDN 上,加速静态资源的加载
app.use(express.static('public', {
maxAge: '1d',
setHeaders: (res, path, stat) => {
res.setHeader('Cache-Control', 'public, max-age=86400');
}
}));

这样可以减少服务器的负载,加快静态资源的加载速度。

10. 监控和日志记录

1
2
3
javascript复制代码// 添加监控和日志记录,及时发现和解决性能问题
const logger = require('morgan');
app.use(logger('dev'));

这样可以帮助及时发现接口性能问题,并进行相应的优化调整。

通过以上补充的优化方法,可以进一步提高接口性能,确保系统能够高效稳定地运行。

还有一些其他的优化方法可以考虑,如下所示:

11. 使用缓存预热

1
2
3
4
5
6
7
javascript复制代码// 在服务启动时预先加载热门数据到缓存中,避免冷启动时的性能问题
app.listen(3000, async () => {
// 预热缓存数据
const data = await getDataFromDatabase();
await setToCache('data', data);
console.log('Server is running on port 3000');
});

这样可以在服务启动时,提前将热门数据加载到缓存中,减少首次请求的响应时间。

12. 使用 HTTP/2

1
2
3
javascript复制代码// 启用 HTTP/2,以提高网络传输效率
const http2 = require('http2');
const server = http2.createSecureServer(options, app);

HTTP/2 相比于 HTTP/1.x 有更高的性能,可以减少网络传输的延迟,提高接口的响应速度。

13. 使用缓存策略

1
2
3
4
5
6
javascript复制代码// 设置合适的缓存策略,如根据数据的更新频率设置合适的缓存过期时间
app.get('/api/data', async (req, res) => {
res.set('Cache-Control', 'public, max-age=3600'); // 设置缓存有效期为1小时
const data = await getDataFromDatabase();
res.json(data);
});

这样可以减少对服务器的请求,加快接口的响应速度。

14. 垃圾回收优化

1
javascript复制代码// 使用内存管理工具(如 Node.js 的 heapdump)来分析和优化内存使用情况,避免内存泄漏和过度消耗内存

这样可以确保应用程序能够高效地利用系统资源,提高系统的稳定性和性能。

通过综合应用以上的优化方法,可以进一步提升接口性能,优化系统的整体运行效率。

还有一个重要的优化方法是:

15. 使用服务端渲染 (SSR)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
javascript复制代码// 使用 SSR 技术,在服务器端生成页面内容,减轻客户端负担,提高页面加载速度
const express = require('express');
const React = require('react');
const ReactDOMServer = require('react-dom/server');
const App = require('./App');

const app = express();

app.get('/', (req, res) => {
// 在服务器端渲染 React 组件
const html = ReactDOMServer.renderToString(React.createElement(App));
res.send(html);
});

app.listen(3000, () => {
console.log('Server is running on port 3000');
});

使用 SSR 技术可以在服务器端生成页面内容,减轻客户端的渲染负担,提高页面加载速度和用户体验。

通过综合应用以上的优化方法,可以有效地提高接口性能和系统整体的响应速度,优化用户体验。

还有一些其他的优化方法可以考虑:

16. 使用 CDN 缓存 API 响应

1
2
3
4
5
6
7
8
9
10
javascript复制代码// 将 API 的响应缓存到 CDN 中,减少服务器压力并加快全球范围内的访问速度
const CDNClient = require('cdn-client');
const cdn = new CDNClient('YOUR_CDN_API_KEY');

app.get('/api/data', async (req, res) => {
const data = await getDataFromDatabase();
// 将响应缓存到 CDN 中,设置合适的过期时间
cdn.cache('api/data', data, { expiresIn: '1h' });
res.json(data);
});

这样可以将 API 响应缓存到 CDN 中,全球范围内的用户都可以快速访问缓存的响应数据。

17. 使用负载均衡器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
javascript复制代码// 使用负载均衡器将请求分发到多个服务器,提高系统的吞吐量和可用性
const cluster = require('cluster');
const numCPUs = require('os').cpus().length;

if (cluster.isMaster) {
// Fork workers
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
} else {
// Worker process
const express = require('express');
const app = express();

// Define routes and start the server
app.listen(3000, () => {
console.log('Server is running on port 3000');
});
}

通过使用负载均衡器,可以将请求分发到多个服务器上,提高系统的吞吐量和可用性,同时减轻单个服务器的压力。

18. 使用 Web Workers 进行并行处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
javascript复制代码// 使用 Web Workers 在后台进行并行处理,提高系统的处理能力
const { Worker, isMainThread, parentPort } = require('worker_threads');

app.get('/api/data', async (req, res) => {
if (isMainThread) {
const worker = new Worker('./worker.js');
worker.postMessage('start');

worker.on('message', (message) => {
res.json(message);
});
}
});

// worker.js
const { parentPort } = require('worker_threads');

parentPort.on('message', async (message) => {
if (message === 'start') {
const data = await getDataFromDatabase();
parentPort.postMessage(data);
}
});

这样可以利用多个线程并行处理请求,提高系统的处理能力和并发性能。

通过综合应用以上的优化方法,可以进一步提高系统的性能和可扩展性,优化用户体验。

除此之外,你也可以了解一下前端的优化方式,顺便给前端科普一波,让前端由衷喊你一声:大佬!!!

前端快速处理几十万条数据的方式?

请查看我的另一篇文章:前端快速处理几十万条数据的方式

看完后,是不是觉得,这都不是事!!!!早点下班吧!!!!

本文转载自: 掘金

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

1…394041…956

开发者博客

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