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

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


  • 首页

  • 归档

  • 搜索

实战演示Go反射的使用方法和应用场景

发表于 2021-11-01

今天来聊一个平时用的不多,但是很多框架或者基础库会用到的语言特性–反射,反射并不是Go语言独有的能力,其他编程语言都有。这篇文章的目标是简单地给大家梳理一下反射的应用场景和使用方法。

我们平时写代码能接触到与反射联系比较紧密的一个东西是结构体字段的标签,这个我准备放在后面的文章再梳理。

我准备通过用反射搞一个通用的SQL构造器的例子,带大家掌握反射这个知识点。这个是看了国外一个博主写的例子,觉得思路很好,我又对其进行了改进,让构造器的实现更丰富了些。

本文的思路参考自:golangbot.com/reflection/ ,本文内容并非只是对原文的简单翻译,具体看下面的内容吧~!

文章内容已收录到《Go开发参考书》 这个仓库里,目前已经收集了70多条开发实践。

什么是反射

反射是程序在运行时检查其变量和值并找到它们类型的能力。听起来比较笼统,接下来我通过文章的例子一步步带你认识反射。

为什么需要反射

当学习反射的时候,每个人首先会想到的问题都是 “为什么我们要在运行时检查变量的类型呢,程序里的变量在定义的时候我们不都已经给他们指定好类型了吗?” 确实是这样的,但也并非总是如此,看到这你可能心里会想,大哥,你在说什么呢,em… 还是先写一个简单的程序,解释一下。

1
2
3
4
5
6
7
8
9
10
go复制代码package main

import (
"fmt"
)

func main() {
i := 10
fmt.Printf("%d %T", i, i)
}

在上面的程序里, 变量i的类型在编译时是已知的,我们在下一行打印了它的值和类型。

现在让我们理解一下 ”在运行时知道变量的类型的必要“。假设我们要编写一个简单的函数,它将一个结构体作为参数,并使用这个参数创建一个SQL插入语句。

考虑一下下面这个程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
go复制代码package main

import (
"fmt"
)

type order struct {
ordId int
customerId int
}

func main() {
o := order{
ordId: 1234,
customerId: 567,
}
fmt.Println(o)
}

我们需要写一个接收上面定义的结构体o作为参数,返回类似INSERT INTO order VALUES(1234, 567) 这样的SQL语句。这个函数定义写来很容易,比如像下面这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
go复制代码package main

import (
"fmt"
)

type order struct {
ordId int
customerId int
}

func createQuery(o order) string {
i := fmt.Sprintf("INSERT INTO order VALUES(%d, %d)", o.ordId, o.customerId)
return i
}

func main() {
o := order{
ordId: 1234,
customerId: 567,
}
fmt.Println(createQuery(o))
}

上面例子的createQuery使用参数o 的ordId和customerId字段创建SQL。

现在让我们将我们的SQL创建函数定义地更抽象些,下面还是用程序附带说明举一个案例,比如我们想泛化我们的SQL创建函数使其适用于任何结构体。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
go复制代码package main

type order struct {
ordId int
customerId int
}

type employee struct {
name string
id int
address string
salary int
country string
}

func createQuery(q interface{}) string {
}

现在我们的目标是,改造createQuery函数,让它能接受任何结构作为参数并基于结构字段创建INSERT 语句。比如如果传给createQuery的参数不再是order类型的结构体,而是employee类型的结构体时

1
2
3
4
5
6
7
go复制代码 e := employee {
name: "Naveen",
id: 565,
address: "Science Park Road, Singapore",
salary: 90000,
country: "Singapore",
}

那它应该返回的INSERT语句应该是

1
2
go复制代码INSERT INTO employee (name, id, address, salary, country) 
VALUES("Naveen", 565, "Science Park Road, Singapore", 90000, "Singapore")

由于createQuery 函数要适用于任何结构体,因此它需要一个 interface{}类型的参数。为了说明问题,简单起见,我们假定createQuery函数只处理包含string 和 int 类型字段的结构体。

编写这个createQuery函数的唯一方法是检查在运行时传递给它的参数的类型,找到它的字段,然后创建SQL。这里就是需要反射发挥用的地方啦。在后续步骤中,我们将学习如何使用Go语言的反射包来实现这一点。

Go语言的反射包

Go语言自带的reflect包实现了在运行时进行反射的功能,这个包可以帮助识别一个interface{}类型变量其底层的具体类型和值。我们的createQuery函数接收到一个interface{}类型的实参后,需要根据这个实参的底层类型和值去创建并返回INSERT语句,这正是反射包的作用所在。

在开始编写我们的通用SQL生成器函数之前,我们需要先了解一下reflect包中我们会用到的几个类型和方法,接下来我们先逐个学习一下。

reflect.Type 和 reflect.Value

经过反射后interface{}类型的变量的底层具体类型由reflect.Type表示,底层值由reflect.Value表示。reflect包里有两个函数reflect.TypeOf() 和reflect.ValueOf() 分别能将interface{}类型的变量转换为reflect.Type和reflect.Value。这两种类型是创建我们的SQL生成器函数的基础。

让我们写一个简单的例子来理解这两种类型。

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
go复制代码package main

import (
"fmt"
"reflect"
)

type order struct {
ordId int
customerId int
}

func createQuery(q interface{}) {
t := reflect.TypeOf(q)
v := reflect.ValueOf(q)
fmt.Println("Type ", t)
fmt.Println("Value ", v)


}
func main() {
o := order{
ordId: 456,
customerId: 56,
}
createQuery(o)

}

上面的程序会输出:

1
2
go复制代码Type  main.order  
Value {456 56}

上面的程序里createQuery函数接收一个interface{}类型的实参,然后把实参传给了reflect.Typeof和reflect.Valueof 函数的调用。从输出,我们可以看到程序输出了interface{}类型实参对应的底层具体类型和值。

Go语言反射的三法则

这里插播一下反射的三法则,他们是:

  1. 从接口值可以反射出反射对象。
  2. 从反射对象可反射出接口值。
  3. 要修改反射对象,其值必须可设置。

反射的第一条法则是,我们能够吧Go中的接口类型变量转换成反射对象,上面提到的reflect.TypeOf和 reflect.ValueOf 就是完成的这种转换。第二条指的是我们能把反射类型的变量再转换回到接口类型,最后一条则是与反射值是否可以被更改有关。三法则详细的说明可以去看看德莱文大神写的文章 Go反射的实现原理,文章开头就有对三法则说明的图解,再次膜拜。

下面我们接着继续了解完成我们的SQL生成器需要的反射知识。

reflect.Kind

reflect包中还有一个非常重要的类型,reflect.Kind。

reflect.Kind和reflect.Type类型可能看起来很相似,从命名上也是,Kind和Type在英文的一些Phrase是可以互转使用的,不过在反射这块它们有挺大区别,从下面的程序中可以清楚地看到。

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
go复制代码package main
import (
"fmt"
"reflect"
)

type order struct {
ordId int
customerId int
}

func createQuery(q interface{}) {
t := reflect.TypeOf(q)
k := t.Kind()
fmt.Println("Type ", t)
fmt.Println("Kind ", k)


}
func main() {
o := order{
ordId: 456,
customerId: 56,
}
createQuery(o)

}

上面的程序会输出

1
2
arduino复制代码Type  main.order  
Kind struct

通过输出让我们清楚了两者之间的区别。 reflect.Type 表示接口的实际类型,即本例中main.order 而Kind表示类型的所属的种类,即main.order是一个「struct」类型,类似的类型map[string]string的Kind就该是「map」。

反射获取结构体字段的方法

我们可以通过reflect.StructField类型的方法来获取结构体下字段的类型属性。reflect.StructField可以通过reflect.Type提供的下面两种方式拿到。

1
2
3
4
5
6
go复制代码// 获取一个结构体内的字段数量
NumField() int
// 根据 index 获取结构体内字段的类型对象
Field(i int) StructField
// 根据字段名获取结构体内字段的类型对象
FieldByName(name string) (StructField, bool)

reflect.structField是一个struct类型,通过它我们又能在反射里知道字段的基本类型、Tag、是否已导出等属性。

1
2
3
4
5
6
go复制代码type StructField struct {
Name string
Type Type // field type
Tag StructTag // field tag string
......
}

与reflect.Type提供的获取Field信息的方法相对应,reflect.Value也提供了获取Field值的方法。

1
2
3
4
5
6
7
go复制代码func (v Value) Field(i int) Value {
...
}

func (v Value) FieldByName(name string) Value {
...
}

这块需要注意,不然容易迷惑。下面我们尝试一下通过反射拿到order结构体类型的字段名和值

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
go复制代码package main

import (
"fmt"
"reflect"
)

type order struct {
ordId int
customerId int
}

func createQuery(q interface{}) {
t := reflect.TypeOf(q)
if t.Kind() != reflect.Struct {
panic("unsupported argument type!")
}
v := reflect.ValueOf(q)
for i:=0; i < t.NumField(); i++ {
fmt.Println("FieldName:", t.Field(i).Name, "FiledType:", t.Field(i).Type,
"FiledValue:", v.Field(i))
}

}
func main() {
o := order{
ordId: 456,
customerId: 56,
}
createQuery(o)

}

上面的程序会输出:

1
2
arduino复制代码FieldName: ordId FiledType: int FiledValue: 456
FieldName: customerId FiledType: int FiledValue: 56

除了获取结构体字段名称和值之外,还能获取结构体字段的Tag,这个放在后面的文章我再总结吧,不然篇幅就太长了。

reflect.Value转换成实际值

现在离完成我们的SQL生成器还差最后一步,即还需要把reflect.Value转换成实际类型的值,reflect.Value实现了一系列Int(), String(),Float()这样的方法来完成其到实际类型值的转换。

用反射搞一个SQL生成器

上面我们已经了解完写这个SQL生成器函数前所有的必备知识点啦,接下来就把他们串起来,加工完成createQuery函数。

这个SQL生成器完整的实现和测试代码如下:

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
go复制代码package main

import (
"fmt"
"reflect"
)

type order struct {
ordId int
customerId int
}

type employee struct {
name string
id int
address string
salary int
country string
}

func createQuery(q interface{}) string {
t := reflect.TypeOf(q)
v := reflect.ValueOf(q)
if v.Kind() != reflect.Struct {
panic("unsupported argument type!")
}
tableName := t.Name() // 通过结构体类型提取出SQL的表名
sql := fmt.Sprintf("INSERT INTO %s ", tableName)
columns := "("
values := "VALUES ("
for i := 0; i < v.NumField(); i++ {
// 注意reflect.Value 也实现了NumField,Kind这些方法
// 这里的v.Field(i).Kind()等价于t.Field(i).Type.Kind()
switch v.Field(i).Kind() {
case reflect.Int:
if i == 0 {
columns += fmt.Sprintf("%s", t.Field(i).Name)
values += fmt.Sprintf("%d", v.Field(i).Int())
} else {
columns += fmt.Sprintf(", %s", t.Field(i).Name)
values += fmt.Sprintf(", %d", v.Field(i).Int())
}
case reflect.String:
if i == 0 {
columns += fmt.Sprintf("%s", t.Field(i).Name)
values += fmt.Sprintf("'%s'", v.Field(i).String())
} else {
columns += fmt.Sprintf(", %s", t.Field(i).Name)
values += fmt.Sprintf(", '%s'", v.Field(i).String())
}
}
}
columns += "); "
values += "); "
sql += columns + values
fmt.Println(sql)
return sql
}

func main() {
o := order{
ordId: 456,
customerId: 56,
}
createQuery(o)

e := employee{
name: "Naveen",
id: 565,
address: "Coimbatore",
salary: 90000,
country: "India",
}
createQuery(e)
}

同学们可以把代码拿到本地运行一下,上面的例子会根据传递给函数不同的结构体实参,输出对应的标准SQL插入语句

1
2
go复制代码INSERT INTO order (ordId, customerId); VALUES (456, 56); 
INSERT INTO employee (name, id, address, salary, country); VALUES ('Naveen', 565, 'Coimbatore', 90000, 'India');

总结

这篇文章通过利用反射完成一个实际应用来教会大家Go语言反射的基本使用方法,虽然反射看起来挺强大,但使用反射编写清晰且可维护的代码非常困难,应尽可能避免,仅在绝对必要时才使用。

我的看法是如果是要写业务代码,根本不需要使用反射,如果要写类似encoding/json,gorm这些样的库倒是可以利用反射的强大功能简化库使用者的编码难度。

文章内容已收录到《Go开发参考书》 这个仓库里,目前已经收集了70多条开发实践。

本文转载自: 掘金

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

sql中相似条件统计的优化

发表于 2021-11-01
  1. 原sql

1
2
3
4
5
6
7
8
9
10
sql复制代码select 
u.id,
u.name,
(select count(1) from t_user_log t
where t.log_date >= '2021-10-01' and <= '2021-10-03' and t.user_id = u.id) as num,
(select count(1) from t_user_log t
where t.log_date >= '2021-10-01' and <= '2021-10-03' and t.user_id = u.id and t.type = 1) as appNum,
(select count(1) from t_user_log t
where t.log_date >= '2021-10-01' and <= '2021-10-03' and t.user_id = u.id and t.type = 2) as pcNum
from t_user u
  1. 性能分析

t_user表用户不超过10000条,所以查询起来效率可以
子查询需要查询t_user_log表统计,且需要为每一个t_user的结果集数据查询,所以效率比较低,而且还有3个查询相同表类似统计的子查询,效率会相当低。本人实际工作中,就遇到该种查询,即使对t_user_log再进行索引相关的优化,效率依然很低,且容易死锁。

  1. 优化方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
sql复制代码select 
u.id,
u.name,
nvl(l.num, 0) as num,
nvl(l.appNum, 0) as appNum,
nvl(l.pcNum, 0) as pcNum
from t_user u
left join (
select
count(1) as num,
sum(decode(t.type, 1, 1, 0)) as appNum,
sum(decode(t.type, 2, 1, 0)) as pcNum,
from t_user_log t
where t.log_date >= '2021-10-01' and <= '2021-10-03'
group by t.user_id
) l on u.id = t.user_id
  1. 优化结果

原sql是查询出t_user结果集后,对结果集进行子查询。
优化后sql是查询出t_user结果集和t_user_log的统计结果集,然后进行关联。
本人工作中的该问题,优化前直接死锁,优化后几乎是秒查。

本文转载自: 掘金

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

趣谈装饰器模式,让你一辈子不会忘

发表于 2021-11-01

本文节选自《设计模式就该这样学》

1 使用装饰器模式解决煎饼加码问题

来看这样一个场景,上班族大多有睡懒觉的习惯,每天早上上班都时间很紧张,于是很多人为了多睡一会儿,就用更方便的方式解决早餐问题,有些人早餐可能会吃煎饼。煎饼中可以加鸡蛋,也可以加香肠,但是不管怎么加码,都还是一个煎饼。再比如,给蛋糕加上一些水果,给房子装修,都是装饰器模式。

下面用代码来模拟给煎饼加码的业务场景,先来看不用装饰器模式的情况。首先创建一个煎饼Battercake类。

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码
public class Battercake {

protected String getMsg(){
return "煎饼";
}

public int getPrice(){
return 5;
}

}

然后创建一个加鸡蛋的煎饼BattercakeWithEgg类。

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码
public class BattercakeWithEgg extends Battercake{
@Override
protected String getMsg() {
return super.getMsg() + "+1个鸡蛋";
}

@Override
//加1个鸡蛋加1元钱
public int getPrice() {
return super.getPrice() + 1;
}
}

再创建一个既加鸡蛋又加香肠的BattercakeWithEggAndSausage类。

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码
public class BattercakeWithEggAndSausage extends BattercakeWithEgg{
@Override
protected String getMsg() {
return super.getMsg() + "+1根香肠";
}

@Override
//加1根香肠加2元钱
public int getPrice() {
return super.getPrice() + 2;
}
}

最后编写客户端测试代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码
public static void main(String[] args) {

Battercake battercake = new Battercake();
System.out.println(battercake.getMsg() + ",总价格:" + battercake.getPrice());

Battercake battercakeWithEgg = new BattercakeWithEgg();
System.out.println(battercakeWithEgg.getMsg() + ",总价格:" +
battercakeWithEgg.getPrice());

Battercake battercakeWithEggAndSausage = new BattercakeWithEggAndSausage();
System.out.println(battercakeWithEggAndSausage.getMsg() + ",总价格:" +
battercakeWithEggAndSausage.getPrice());

}

运行结果如下图所示。

file

运行结果没有问题。但是,如果用户需要一个加2个鸡蛋和1根香肠的煎饼,则用现在的类结构是创建不出来的,也无法自动计算出价格,除非再创建一个类做定制。如果需求再变,那么一直加定制显然是不科学的。
下面用装饰器模式来解决上面的问题。首先创建一个煎饼的抽象Battercake类。

1
2
3
4
5
java复制代码
public abstract class Battercake {
protected abstract String getMsg();
protected abstract int getPrice();
}

创建一个基本的煎饼(或者叫基础套餐)BaseBattercake。

1
2
3
4
5
6
7
8
java复制代码
public class BaseBattercake extends Battercake {
protected String getMsg(){
return "煎饼";
}

public int getPrice(){ return 5; }
}

然后创建一个扩展套餐的抽象装饰器BattercakeDecotator类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码
public abstract class BattercakeDecorator extends Battercake {
//静态代理,委派
private Battercake battercake;

public BattercakeDecorator(Battercake battercake) {
this.battercake = battercake;
}
protected abstract void doSomething();

@Override
protected String getMsg() {
return this.battercake.getMsg();
}
@Override
protected int getPrice() {
return this.battercake.getPrice();
}
}

接着创建鸡蛋装饰器EggDecorator类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码
public class EggDecorator extends BattercakeDecorator {
public EggDecorator(Battercake battercake) {
super(battercake);
}

protected void doSomething() {}

@Override
protected String getMsg() {
return super.getMsg() + "+1个鸡蛋";
}

@Override
protected int getPrice() {
return super.getPrice() + 1;
}
}

创建香肠装饰器SausageDecorator类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码
public class SausageDecorator extends BattercakeDecorator {
public SausageDecorator(Battercake battercake) {
super(battercake);
}

protected void doSomething() {}

@Override
protected String getMsg() {
return super.getMsg() + "+1根香肠";
}
@Override
protected int getPrice() {
return super.getPrice() + 2;
}
}

再编写客户端测试代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
java复制代码
public class BattercakeTest {
public static void main(String[] args) {
Battercake battercake;
//买一个煎饼
battercake = new BaseBattercake();
//煎饼有点小,想再加1个鸡蛋
battercake = new EggDecorator(battercake);
//再加1个鸡蛋
battercake = new EggDecorator(battercake);
//很饿,再加1根香肠
battercake = new SausageDecorator(battercake);

//与静态代理的最大区别就是职责不同
//静态代理不一定要满足is-a的关系
//静态代理会做功能增强,同一个职责变得不一样

//装饰器更多考虑的是扩展
System.out.println(battercake.getMsg() + ",总价:" + battercake.getPrice());
}
}

运行结果如下图所示。

file

最后来看类图,如下图所示。

file

2 使用装饰器模式扩展日志格式输出

为了加深印象,我们再来看一个应用场景。需求大致是这样的,系统采用的是SLS服务监控项目日志,以JSON格式解析,因此需要将项目中的日志封装成JSON格式再打印。现有的日志体系采用Log4j + Slf4j框架搭建而成。客户端调用如下。

1
2
3
java复制代码
  private static final Logger logger = LoggerFactory.getLogger(Component.class);
logger.error(string);

这样打印出来的是毫无规则的一行行字符串。当考虑将其转换成JSON格式时,笔者采用装饰器模式。目前有的是统一接口Logger和其具体实现类,笔者要加的就是一个装饰类和真正封装成JSON格式的装饰产品类。创建装饰器类DecoratorLogger。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码
public class DecoratorLogger implements Logger {

public Logger logger;

public DecoratorLogger(Logger logger) {

this.logger = logger;
}

public void error(String str) {}

public void error(String s, Object o) {

}
//省略其他默认实现
}

创建具体组件JsonLogger类。

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
java复制代码
public class JsonLogger extends DecoratorLogger {
public JsonLogger(Logger logger) {
super(logger);
}

@Override
public void info(String msg) {

JSONObject result = composeBasicJsonResult();
result.put("MESSAGE", msg);
logger.info(result.toString());
}

@Override
public void error(String msg) {

JSONObject result = composeBasicJsonResult();
result.put("MESSAGE", msg);
logger.error(result.toString());
}

public void error(Exception e) {

JSONObject result = composeBasicJsonResult();
result.put("EXCEPTION", e.getClass().getName());
String exceptionStackTrace = Arrays.toString(e.getStackTrace());
result.put("STACKTRACE", exceptionStackTrace);
logger.error(result.toString());
}

private JSONObject composeBasicJsonResult() {
//拼装了一些运行时的信息
return new JSONObject();
}
}

可以看到,在JsonLogger中,对于Logger的各种接口,我们都用JsonObject对象进行一层封装。在打印的时候,最终还是调用原生接口logger.error(string),只是这个String参数已经被装饰过了。如果有额外的需求,则可以再写一个函数去实现。比如error(Exception e),只传入一个异常对象,这样在调用时就非常方便。
另外,为了在新老交替的过程中尽量不改变太多代码和使用方式,笔者又在JsonLogger中加入了一个内部的工厂类JsonLoggerFactory(这个类转移到DecoratorLogger中可能更好一些)。它包含一个静态方法,用于提供对应的JsonLogger实例。最终在新的日志体系中,使用方式如下。

1
2
3
4
5
6
7
java复制代码
private static final Logger logger = JsonLoggerFactory.getLogger(Client.class);

public static void main(String[] args) {

logger.error("错误信息");
}

对于客户端而言,唯一与原先不同的地方就是将LoggerFactory改为JsonLoggerFactory即可,这样的实现,也会更快更方便地被其他开发者接受和习惯。最后看如下图所示的类图。

file

装饰器模式最本质的特征是将原有类的附加功能抽离出来,简化原有类的逻辑。通过这样两个案例,我们可以总结出来,其实抽象的装饰器是可有可无的,具体可以根据业务模型来选择。

关注『 Tom弹架构 』回复“设计模式”可获取完整源码。

【推荐】Tom弹架构:30个设计模式真实案例(附源码),挑战年薪60W不是梦

本文为“Tom弹架构”原创,转载请注明出处。技术在于分享,我分享我快乐!如果本文对您有帮助,欢迎关注和点赞;如果您有任何建议也可留言评论或私信,您的支持是我坚持创作的动力。关注『 Tom弹架构 』可获取更多技术干货!

本文转载自: 掘金

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

Java 8 lamda表达式

发表于 2021-11-01

这是我参与11月更文挑战的第1天,活动详情查看:2021最后一次更文挑战

前些日子在看Spring cloud gateway源码时,常看到lamda表达式。由于自己对lamda表达式不是很熟悉,特此重新学习。

前言

Lambda 表达式是 Java 8 发布的最重要新特性。它允许把函数作为一个方法的参数,传递进方法中去。使用 Lambda 表达式可以使代码变的更加简洁紧凑。需要注意的是lamda表达式的使用依赖于函数式接口。

为什么要使用lamda表达式

目前来说,使用lamda表达式最突出的优点在于简化代码,使代码变的更加简洁紧凑。

以最常见的线程创建为例:

  1. 传统方式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码public class Test {
   public static void main(String[] args) {
       Thread thread = new Thread(new RunTest());
       thread.run();
  }
}
​
​
class RunTest implements Runnable{
​
   @Override
   public void run() {
       System.out.println(123);
  }
}
  1. 普通匿名内部类的方式
1
2
3
4
5
6
7
8
9
10
11
java复制代码public class Test {
   public static void main(String[] args) {
       Thread thread = new Thread(new Runnable() {
           @Override
           public void run() {
               System.out.println("123");
          }
      });
       thread.run();
  }
}
  1. lamda表达式方式
1
2
3
4
5
6
7
8
9
java复制代码public class Test {
​
   public static void main(String[] args) {
       Thread thread = new Thread(() -> {
           System.out.println(123);
      });
       thread.run();
  }
}

通过比较可以发现,使用lamda表达式的方式代码更为简洁。

相关概念

lamda表达式格式

lamda表达式一般由以下格式构成:

1
2
3
java复制代码(parameters) -> expression
或
(parameters) ->{ statements; }

具体见以下示例

1
2
3
4
5
java复制代码()->5; // 无参数,返回5
​
(int x,int y)->5*x+6*y; // 多参数,返回5*x+6*y的结果
​
x->5*x // 单参数,返回5*x的结果。当只有一个参数时,可以忽略括号和参数类型

函数式接口

lamda表达式的使用依赖于函数式接口。所谓函数式接口,是指任何接口,如果只包含唯一一个抽象方法,那么它就是一个函数式接口。

代码示例如下所示:

1
2
3
4
java复制代码@FunctionalInterface
public interface TestInterface {
   int test(int x);
}

其中,为了避免后来人给这个接口添加函数后,导致该接口有多个函数,不再是函数式接口,我们可以在接口类的上方声明 @FunctionalInterface。

用lamda实现函数式接口的方式如下:

1
2
3
4
5
6
7
8
9
10
11
java复制代码public class Test {
   public static void main(String[] args) throws Exception {
       TestInterface test;
       test = x->{
           System.out.println(5*x);
           return 5*x;
      };
       int num = test.test(6);
       System.out.println(num);
  }
}

由以上代码可见,对函数式接口赋值lamda语句,本质上是对接口方法进行实现。

方法引用

除了普通lamda表达式->类型外,还有另外一种写法,被称为方法引用。

所谓方法引用,就是指如果一个方法的参数一致,返回值类型相同,那么就可以通过方法引用的方式对函数式接口进行赋值。

方法引用的格式如下:

1
2
java复制代码// 类名::方法名
Integer::parseInt

代码示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码public class Test {
   public static void main(String[] args) throws Exception {
       TestInterface testInterface;
       testInterface = Integer::parseInt;
       int test1 = testInterface.test("123");
       // 上述代码等价于以下代码
       testInterface = x -> {
           return Integer.parseInt(x);
      };
       int test2 = testInterface.test("123");
  }
}

除此之外,方法引用还有以下情况:

  • 构造器引用: 它的语法是Class::new,或者更一般的Class< T >::new实例如下:
1
2
java复制代码final Car car = Car.create( Car::new ); 
final List< Car > cars = Arrays.asList( car );
  • 静态方法引用: 它的语法是Class::static_method,实例如下:
1
java复制代码cars.forEach( Car::collide );
  • 特定类的任意对象的方法引用: 它的语法是Class::method实例如下:
1
java复制代码cars.forEach( Car::repair );
  • 特定对象的方法引用: 它的语法是instance::method实例如下:
1
java复制代码final Car police = Car.create( Car::new ); cars.forEach( police::follow );

本文转载自: 掘金

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

【实战干货】Springboot实现多数据源整合的两种方式

发表于 2021-11-01

「这是我参与11月更文挑战的第1天,活动详情查看:2021最后一次更文挑战」。


  • 💬 如果文章对你有帮助、欢迎关注、点赞、收藏(一键三连)和订阅专栏哦。

🚐 一、前言

  • 大家好,我是小诚,不知不觉上一次更文已经是20多天前了!其实这段时间也一直没有闲着,一个是在梳理之前的文章知识和资源,用于搭建技术圈子,另外一个就是在思考自己的一个输出方向,社区发展得很迅速,热榜各种各类的文章都有,深思熟虑后,还是坚持文章在精不在多,质量标准更加重要,所以今后博文的方向会更加偏向实战和经验,争取分享更加有价值的博文!
  • 如果文章对你有帮助,可以帮忙一键三连和专栏订阅哦! 技术圈子经过这段时间的筹划,已经初步成型!有兴趣、志同道合的小伙伴可以查看左边导航栏的技术圈子介绍,期待你们的加入!
  • 本篇文章重点介绍SpringBoot集合MyBatis和MyBatis-Plus整合多数据源方面的知识!

🚅 二、专栏推荐

  良心推荐: 下面的相关技术专栏还在免费分享哦,大家可以帮忙点点订阅哦!

  JAVA进阶知识大全

  算法日记修行

🚔 三、整合多数据源需要了解的知识

  1、何时会使用到多数据源

  一个技术的出现、应用必然是为了解决存在的某些问题,多数据源出现常见的场景如下:

  (1)、与第三方对接时,有些合作方并不会为了你的某些需求而给你开发一个功能,他们可以提供给你一个可以访问数据源的只读账号,你需要获取什么数据由你自己进行逻辑处理,这时候就避免不了需要进行多数据源整合了。

  (2)、业务数据达到了一个量级,使用单一数据库存储达到了一个瓶颈,需要进行分库分表等操作进行数据管理,在操作数据时,不可避免的涉及到多数据源问题。

  2、多数据源整合有哪些方式

  参考了网上的许多材料,发现整合方式无外乎以下几种:

  (1)、使用分包方式,不同的数据源配置不同的MapperScan和mapper文件

  (2)、使用AOP切片方式,实现动态数据源切换(如果对Aop不是很熟悉,欢迎查看我之前的一篇文章,这知识保熟哦!【什么是面向切面编程?】)

  (3)、使用数据库代理中间件,如Mycat等

  3、不同方式之间的区别

  (1)、分包方式可以集合JTA(JAVA Transactional API)实现分布式事务,但是整个流程的实现相对来说比较复杂。

  (2)、AOP动态配置数据源方式缺点在于无法实现全局分布式事务,所以如果只是对接第三方数据源,不涉及到需要保证分布式事务的话,是可以作为一种选择。

  (3)、使用数据库代理中间件方式是现在比较流行的一种方式,很多大厂也是使用这种方式,开发者不需要关注太多与业务无关的问题,把它们都交给数据库代理中间件去处理,大量的通用的数据聚合,事务,数据源切换都由中间件来处理,中间件的性能与处理能力将直接决定应用的读写性能,比较常见的有Mycat、TDDL等。现在阿里出了100%自研的分布式数据库OceanBase,从最底层支持分布式,性能也非常强大,大家感兴趣的可以去了解下!

  4、本文实战选择的方式

  鉴于本次遇到需求的整合多数据源的场景是需要 对接第三方的数据,暂不涉及到分布式事务问题 ,所以本文实战整合多数据源使用的方式是【分包方式】实现简单的多数据源整合,至于其他方式和分布式事务的坑,后面再慢慢填吧(o(╥﹏╥)o)!

🚢 四、SpringBoot+MyBatis整合多数据源

🔴 4.1 说明

  本次案例涉及到的代码比较多,因此文章只贴出部分,全部案例代码已经上传到Gitee,需要者可直接访问:【SpringBoot结合MyBatis整合多数据源】,项目结构如下:

🟠 4.2 涉及依赖包

  • spring-boot-starter-web – web相关支持
  • mybatis-spring-boot-starter – springboot整合mybatis依赖
  • mysql-connector-java – mysql数据驱动
  • lombok – 自动生成实体类常用方法依赖包
  • hutool-all – 常用方法封装依赖包
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java复制代码<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.2.0</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.13</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.4.5</version>
</dependency>

🟡 4.3 项目配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
java复制代码# 项目启动端口
server:
port: 9090

# 项目 名称
spring:
application:
name: multi-datasource-instance
datasource:
# 主数据库
master:
# 注意,整合多数据源时如果使用springboot默认的数据库连接池Hikari,指定连接数据使用的是jdbc-url而不是url属性
jdbc-url: jdbc:mysql://localhost:3306/test1?serverTimezone=UTC&useUnicode=true&characterEncoding=utf8&useSSL=false
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
# 副数据库
slave:
# 注意,整合多数据源时如果使用springboot默认的数据库连接池Hikari,指定连接数据使用的是jdbc-url而不是url属性
jdbc-url: jdbc:mysql://localhost:3306/test2?serverTimezone=UTC&useUnicode=true&characterEncoding=utf8&useSSL=false
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver

🟢 4.4 编写主副数据库数据源配置

  1、主数据源相关配置:主要是指定主数据源、扫描的mapper地址、事务管理器等信息。

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
java复制代码@Configuration
// 指定主数据库扫描对应的Mapper文件,生成代理对象
@MapperScan(basePackages ="com.diary.it.multi.datasource.mapper" ,sqlSessionFactoryRef = "masterSqlSessionFactory")
public class MasterDataSourceConfig {

// mapper.xml所在地址
private static final String MAPPER_LOCATION = "classpath*:mapper/*.xml";


/**
* 主数据源,Primary注解必须增加,它表示该数据源为默认数据源
* 项目中还可能存在其他的数据源,如获取时不指定名称,则默认获取这个数据源,如果不添加,则启动时候回报错
*/
@Primary
@Bean(name = "masterDataSource")
// 读取spring.datasource.master前缀的配置文件映射成对应的配置对象
@ConfigurationProperties(prefix = "spring.datasource.master")
public DataSource dataSource() {
DataSource build = DataSourceBuilder.create().build();
return build;
}

/**
* 事务管理器,Primary注解作用同上
*/
@Bean(name = "masterTransactionManager")
@Primary
public PlatformTransactionManager dataSourceTransactionManager(@Qualifier("masterDataSource") DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}

/**
* session工厂,Primary注解作用同上
*/

@Bean(name = "masterSqlSessionFactory")
@Primary
public SqlSessionFactory sqlSessionFactory(@Qualifier("masterDataSource") DataSource dataSource) throws Exception {
final SqlSessionFactoryBean sessionFactoryBean = new SqlSessionFactoryBean();
sessionFactoryBean.setDataSource(dataSource);
sessionFactoryBean.setMapperLocations(new PathMatchingResourcePatternResolver().getResources(MasterDataSourceConfig.MAPPER_LOCATION));
return sessionFactoryBean.getObject();
}

}

  2、副数据源相关配置:主要是指定数据源、扫描的mapper地址、事务管理器等信息。

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
java复制代码
@Configuration
// 指定从数据库扫描对应的Mapper文件,生成代理对象
@MapperScan(basePackages = "com.diary.it.multi.datasource.mapper2", sqlSessionFactoryRef = "slaveSqlSessionFactory")
public class SlaveDataSourceConfig {
// mapper.xml所在地址
private static final String MAPPER_LOCATION = "classpath*:mapper2/*.xml";

/**
* 数据源
*/
@Bean(name = "slaveDataSource")
// 读取spring.datasource.slave前缀的配置文件映射成对应的配置对象
@ConfigurationProperties(prefix = "spring.datasource.slave")
public DataSource dataSource() {
DataSource build = DataSourceBuilder.create().build();
return build;
}


/**
* 事务管理器
*/
@Bean(name = "slaveTransactionManager")
public PlatformTransactionManager dataSourceTransactionManager(@Qualifier("slaveDataSource") DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}

/**
* session工厂
*/

@Bean(name = "slaveSqlSessionFactory")
public SqlSessionFactory sqlSessionFactory(@Qualifier("slaveDataSource") DataSource dataSource) throws Exception {
final SqlSessionFactoryBean sessionFactoryBean = new SqlSessionFactoryBean();
sessionFactoryBean.setDataSource(dataSource);
sessionFactoryBean.setMapperLocations(new PathMatchingResourcePatternResolver().getResources(SlaveDataSourceConfig.MAPPER_LOCATION));
return sessionFactoryBean.getObject();
}

}

🔵 4.5 执行结果

  完成上面的步骤后,就跟我们平常写业务逻辑的方式一样,在service中写业务逻辑,在mapper中写sql语句等,下面看看执行结果!

🟣 4.6 整合中遇到的问题

  看完上面的教程,是不是发现其实整合多数据源其实也挺简单的!但是,完全按照教学流程整合还是遇到各种问题的现象真的太常见了,下面博主就总结下整合中遇到的各种问题,如果你在整合过程中也遇到了,可以直接按照博主的解决方案来哦(贴心吧!)。

  问题1、出现 jdbcUrl is required with driverClassName异常

  原因: SpringBoot2.x后默认的数据库连接池就是HikariCP(号称史上最快,性能最高),HikariCP连接池中命名规则和其他的连接池不太一样,指定连接数据库的地址时,它使用的是jdbc-url而不是url,所以如果我们不指定数据库连接池如druid而使用springboot默认的连接池的话,需要将配置中连接数据库的url改成jdbc-url属性。

  问题2、 出现 Invalid bound statement (not found)异常

  原因:

    (1)、在定义数据源配置信息时没有指定SqlSessionFactoryBean扫描的mapper.xml文件的位置即 sessionFactoryBean.setMapperLocations(xxx)。

    (2)、mapper.xml文件中namespace属性对应的路径不准确或者对应方法的id名称、parameterType属性不对

    (3)、xxxMapper.java的方法返回值是List,而select元素没有正确配置ResultMap,或者只配置ResultType

  问题3、 出现 required a single bean, but 2 were found异常

  原因: 因为我们在指定主副数据源配置时已经使用MapperScan注解进行扫描对应的mapper.java,此时被扫描到的mapper.java已经生成代理类到Spring容器,如果此时在启动类中再使用MapperScan扫描则会成出现上面的问题(奇怪的是:这个问题我换一台电脑就不报错了,所以出现这个问题先按照这个方案解决吧)

  问题4、 主数据源配置类中为什么添加Primary注解

  原因: 因为整合了多数据源,所以DataSource、PlatformTransactionManager等实例都会注入多个到Spring容器中,Primary注解的作用就是:当我们使用自动配置的方式如Autowired注入Bean时,如果这个Bean有多个候选者,如果其中一个候选者具有@Primary注解修饰,该候选者会被选中,作为自动配置的值。

  问题5、 com.mysql.jdbc.Driver 和 com.mysql.cj.jdbc.Driver的区别

  原因: 细心的小伙伴会发现,在数据库配置中driver-class-name属性的值为com.mysql.cj.jdbc.Driver,其实com.mysql.jdbc.Driver 是 对应mysql-connector-java 5驱动的,com.mysql.cj.jdbc.Driver 是 mysql-connector-java 6及之后的数据库驱动的,如果使用了6.x后的mysql数据库驱动还继续使用com.mysql.jdbc.Driver 则启动时会报deprecated(过时的),同时使用mysql6.x后的驱动需要指定时区serverTimezone:

🚲 五、SpringBoot+Mybatis-Plus整合多数据源

  上面Mybatis使用分包的方式整合多数据源多少还是有些麻烦的,但是使用MyBatis-Plus就比较简单了,MyBatis-Plus官方就支持了多数据源,使用的时候只需要一个注解就可以实现,整合多数据源的时候推荐使用该种方式。

🟥 5.1 说明

  本次案例涉及到的代码比较多,因此文章只贴出部分,全部案例代码已经上传到Gitee,需要者可直接访问:【实战-SpringBoot结合MyBatis-Plus整合多数据源】,mybatis-plus多数据源支持:

  项目结构如下:

🟧 5.2 涉及的依赖包

  • spring-boot-starter-web – web相关支持
  • mybatis-plus-boot-starter– springboot整合mybatis-plus依赖
  • dynamic-datasource-spring-boot-starter – mybatis-plus管理数据源依赖
  • mysql-connector-java – mysql数据驱动
  • lombok – 自动生成实体类常用方法依赖包
  • hutool-all – 常用方法封装依赖包
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
java复制代码<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>

<dependency>
<groupId>com.baomidou</groupId>
<artifactId>dynamic-datasource-spring-boot-starter</artifactId>
<version>3.4.1</version>
</dependency>

<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.3</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.4.5</version>
</dependency>

🟨 5.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
java复制代码# 启动端口
server:
port: 9091

# 项目名称
spring:
application:
name: multi-datasource-instance2
datasource:
# 采用动态选取
dynamic:
primary: master #设置默认的数据源或者数据源组,默认值即为master
strict: false #严格匹配数据源,默认false. true未匹配到指定数据源时抛异常,false使用默认数据源
datasource:
# 主数据库
master:
url: jdbc:mysql://localhost:3306/test1?serverTimezone=UTC&useUnicode=true&characterEncoding=utf8&useSSL=false
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
# 副数据库
slave:
url: jdbc:mysql://localhost:3306/test2?serverTimezone=UTC&useUnicode=true&characterEncoding=utf8&useSSL=false
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver

🟩 5.4 使用方式

🟦 5.5 执行结果

🚀 六、写在最后

  文章中所有代码都已上传到Gitee,有需要可以自取(后面会传到CSDN免费下载),如果有帮助不要忘了star哦,后面会有更多实战文章(顺便透露下下篇文章是:关于Ftp文件上传到服务器和下载到本地的实战),Gitee项目直通车如下:

  1、SpringBoot+MyBatis整合多数据源

  2、SpringBoot+MyBatis-Plus整合多数据源

  最近这段时间一直忙着整理技术圈子资源,所以更文比较少,现在技术圈子资源已经初步整理完毕,后面会陆续恢复更文速度。【技术圈子】中有免费面试资源、简历模板、年终汇报PPT、CSDN VIP下载资源等等,感兴趣者可以查看主页领取

本文转载自: 掘金

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

基于Redis分布式锁面临的问题

发表于 2021-11-01

这是我参与11月更文挑战的第1天,活动详情查看:2021最后一次更文挑战

在很多场景中,我们为了保证数据的最终一致性,需要很多的技术方案来支持,比如分布式事务、分布式锁等。有很多基于Redis实现的分布式锁方案或者库,但是有些库并没有解决分布式环境下的一些问题陷阱。

分布式锁的特点

  • 互斥 在同一时刻只有一个客户端可以持有锁;这是分布式锁的基本属性。
  • 无死锁 每个锁请求都可以最终获得锁;即使是持有锁的客户端也会崩溃或遇到异常。

不同的实现

许多分布式锁实现都是基于分布式共识算法(Paxos、Raft、ZAB、Pacifica)的,比如基于Paxos的Chubby、基于ZAB的Zookeeper等,以及基于Raft的Consul。Redis的作者还提出了一种分布式锁,名为RedLock。

在接下来的章节中,我将展示如何基于Redis一步步实现分布式锁,并且在每一步中,我都试图解决分布式环境中可能发生的一个问题。

场景一:单实例Redis

为了简单起见,假设我们有两个客户端和一个Redis实例。一个简单的实现应该是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
java复制代码boolean tryAcquire(String lockName, long leaseTime, OperationCallBack operationCallBack) {
// 加锁
boolean getLockSuccessfully = getLock(lockName, leaseTime);
if (getLockSuccessfully) {
try {
operationCallBack.doOperation();
} finally {
releaseLock(lockName);
}
return true;
} else {
return false;
}
}

boolean getLock(String lockName, long expirationTimeMillis) {
// 给当前线程创建一个唯一的lockValue
String lockValue = createUniqueLockValue();
try {
// 如果lockName没有加锁,则将lockName作为key保存到redis中,并指定过期时间
String response = storeLockInRedis(lockName, lockValue, expirationTimeMillis);
return response.equalsIgnoreCase("OK");
} catch (Exception exception) {
releaseLock(lockName);
throw exception;
}
}

void releaseLock(String lockName) {
String lockValue = createUniqueLockValue();
// 移除锁lockName,如果锁的值是lockValue
removeLockFromRedis(lockName, lockValue);
}

这种方式有什么问题呢?

假如客户端1请求服务端获取一个锁,并指定了锁超时时间,如果服务器响应的时间大于锁的超时时间,客户端1拿到的则是一个过期的锁,这时客户端2同时可以获取该锁进行业务操作。 这打破了分布式锁应该具备的相互排斥原则。

为了解决这个问题,我们应该给redis客户端设置一个请求超时时间timeout,这个时间应该小于锁的超时时间。

当时这还不能完全解决这个问题,假设Redis服务器因为掉电重启,则会有其他的问题,我们接下来看第二个场景。

场景二:单实例Redis的单点故障

如果你对Redis的数据持久化方案有所了解,那一定知道Redis有两种方式做数据持久化。

RDB(Redis Database):按指定的时间间隔将Redis的数据快照保存到磁盘。

AOF(Append-Only File):将服务器接收到的写操作指令记录下来,这些操作指令在服务重启时可以重新执行来恢复原始数据。

默认情况下,只会开启RDB模式,会按照如下方式配置:

1
2
3
4
5
shell复制代码save 900 1 

save 300 10

save 60 10000

例如,第一行表示在900秒(15min)内如果有一次写操作,就将数据同步到数据文件。

所以在最坏的情况下,将一个加锁数据保存需要15分钟,如果在加锁成功时Redis服务掉电重启,则无法恢复内存中的加锁数据,其它客户端同样可以获取到相同的锁 :

为了解决这个问题,我们必须使用fsync=always选项来启用AOF,然后在Redis中设置键。

注意,启用这个选项对Redis的性能有一定的影响,但我们需要这个选项以保持强一致性。

场景三:主从复制

在这个配置中,我们有一个或多个实例(通常称为从实例或副本),它们是主实例的精确副本。

默认情况下,Redis中的复制是异步的;这意味着主服务器不会等待命令被副本处理完毕再返回给客户端。

问题是在复制发生之前,主服务器可能出现故障,并发生故障转移;在此之后,如果另一个客户端请求获得锁,它将成功!或者假设存在一个临时的网络问题,因此其中一个副本没有接收到命令,网络变得稳定,故障转移很快发生;没有接收到命令的节点成为主节点。

最终,该锁将从所有实例中删除!下图说明了这种情况:

作为解决方案,有一个等待命令,等待指定数量的确认副本并返回副本的数量,承认之前的写命令发送等待命令,两个的情况下达到指定数量的副本或者超时。

例如,如果我们有两个副本,下面的命令最多等待1秒(1000毫秒)来从两个副本获得确认并返回:

1
shell复制代码WAIT  2  1000

到目前为止,一切顺利,但还有另一个问题;副本可能会丢失写入(由于错误的环境)。例如,一个副本在保存操作完成之前失败,同时主节点也失败,故障转移操作选择重新启动的副本作为新的主节点。在与新主服务器同步后,所有副本和新主服务器都没有旧主服务器中的密钥!

为了使所有的从服务器和主服务器完全一致,我们应该在获得锁之前为所有Redis实例启用fsync=always的AOF。

注意:在这种方法中,我们为了强一致性而破坏了可用性,AOF会有一定的性能损耗。

场景四:自动刷新的锁

在这个场景中,只要客户端是活的并且连接是正常的,就可以持有获取的锁。

我们需要一种机制来在锁到期之前刷新锁。我们还应该考虑不能刷新锁的情况;在这种情况下,必须立即退出。

此外,当锁的持有者释放锁时,其他客户端应该能够等待获得锁并进入临界区:

小结

我这里,每一步都解决了一个新的问题。
但有一些重要的问题还没有解决我想在这里指出。

  1. 不同节点之间的时钟漂移问题;
  2. 获取锁之后客户端出现长线程的暂停或者进程暂停;
  3. 一个客户端可能要等待很长时间才能获得锁,而与此同时,另一个客户端会立即获得锁;非公平锁。

许多三方库使用Redis提供分布式锁的服务,我们应该去了解它们是如何工作的以及可能发生的问题,在它们的正确性和性能之间做出权衡。

要是对你有所帮助,点个赞是对我最大的鼓励!

本文转载自: 掘金

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

字典树学习与应用

发表于 2021-11-01

「这是我参与11月更文挑战的第1天,活动详情查看:2021最后一次更文挑战」

前言

最近在使用公司的系统发现智能问答系统的输入提醒挺有意思。具体的效果类似百度搜索时候的文本提醒。今天就研究一下具体的实现,主要的数据结构就是字典树。

先看看具体要实现的效果(这里以百度搜索为例):

image.png

解读一下:通过输入文字联想到想要搜索的文字。非常适合特定领域以及知识库的搜索。

正文

字典树简介

字典树,英文名 trie。顾名思义,就是一个像字典一样的树。

字典树的特点如下:

  • 根节点不包含字符,除根节点外每一个节点都只包含一个字符。
  • 从根节点到某一节点,路径上经过的字符连接起来,为该节点对应的字符串。
  • 每个节点的所有子节点包含的字符都不相同。

image.png

如图即为一颗字典树 1->2->6->11 即为字符串 aba。

字典树的实现

具体的实现可以参考 letcode

字典树的应用

如前言中的图片展示,可以用在搜索时候的自动补齐。下面就用 Python 实现一颗字典树,除了基本的功能外,完成数据的自动推荐。

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
python复制代码class Trie:

def __init__(self):
self.root = {}


def insert(self, word: str) -> None:
node = self.root
for s in word:
if s in node.keys():
node = node[s]
else:
node[s] = {}
node = node[s]
node['is_word'] = True

def search(self, word: str) -> bool:
node = self.root
for s in word:
if s in node.keys():
node = node[s]
else:
return False

if 'is_word' in node.keys():
return True
else:
return False


def startsWith(self, prefix: str) -> bool:
node = self.root
for s in prefix:
if s in node.keys():
node = node[s]
else:
return False

return True

def startsWithWords(self, prefix: str):
node = self.root
for s in prefix:
if s in node.keys():
node = node[s]
else:
return [""]
res = []
self.printWords(node, prefix, res)

return res

def printWords(self, node: dict, start: str, res):
if node.get('is_word'):
res.append(start)
return
for k in node.keys():
self.printWords(node[k], start+k, res)

下面就手动插入数据进行测试,结果如下:

1
2
3
4
5
6
7
8
9
10
11
python复制代码t = Trie()
t.insert("餐饮发票")
t.insert("加班餐费怎么报销")
t.insert("打车发票怎么获取")
t.insert("差旅费用的标准")
t.insert("发票邮寄")
t.insert("餐饮发票类型的要求")
t.insert("发票抬头怎么写")

print(t.startsWithWords("发票"))
# ['发票邮寄', '发票抬头怎么写']

PS: 这里只演示代码中的实现,具体搜索框的实现感兴趣的小伙伴可以自行封装 API 。

总结

  1. 上面只是介绍基本字典树的实现,这里推荐一个谷歌基于 Python 实现的字典树

简单的使用如下,功能类似为根据前缀匹配出可能的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
python复制代码import pygtrie 

t = pygtrie.StringTrie()
t['发票'] = '发票'
t['发票/快递'] = '发票快递'
t['发票/报销'] = '发票报销'
t['发票/税率'] = '发票税率'

t['餐饮/发票'] = '餐饮发票'
t['交通/发票'] = '交通发票'

print(t.items(prefix='发票'))

# [('发票', '发票'), ('发票/快递', '发票快递'), ('发票/报销', '发票报销'), ('发票/税率', '发票税率')]
  1. 这里关于字典树的实现并没有考虑空间复杂度,关于更多字典树的种类以及实现可以参考 小白详解 Trie 树 。
  2. letcode 上有很多关于字典树的实现,感兴趣的小伙伴可以自行了解。

本文转载自: 掘金

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

小册上新 基于 Kafka Connect 的低代码平台

发表于 2021-11-01

贝壳找房商业化广告中台技术负责人甘罗,将在《基于 Kafka Connect 的低代码平台实战》小册中,带你从0到1,搭建一个工业级可用的异构数据双向流式处理平台。

小册自述.png

作者介绍

20211101-145345.png

甘罗,贝壳找房商业化广告中台技术负责人,主要负责商业化广告中台相关研发和管理工作。主导过广告物料数据存储引擎统一和检索重构,从0到1搭建日均处理10亿+数据量级的、支持多种异构数据双向流式处理平台。现在,整体C端广告流量分发核心服务可用性5个9,平稳承载贝壳C端日均10亿+广告流量分发的流量洪峰。

曾先后任职于蘑菇街、腾讯、火币集团,擅长电商交易和营销、社交内容、数字货币高频交易多领域核心研发和基础架构工作。

🚀 十亿量级数据治理面临的挑战

在大数据时代下,我们经常需要从海量的数据中精准地筛选出需要的数据。最开始,我们需要处理的数据在百万甚至是更低的量级。这种情况下,主流的离线计算和实时计算的数据处理方案,在性能方面的表现是非常稳定的。

但随着时代的飞速发展,处理数十亿级数据量的情况越来越普遍,而大多数公司的数据同步和清洗技术手段还比较传统,存在延迟高、吞吐低、性能差等一系列问题。这就导致,服务的整体技术架构将会面临可用性和稳定性的挑战。

比如说,业务方给你提了一个诉求:请将存储在 MySQL、MongoDB中的数十亿离线数据同步到 Kafka 中,以供我们实时消费。那么你可能需要开发监听 MySQL Binlog / MongoDB Oplog 的服务,实现把 MySQL / MongoDB 中的海量数据迁移到 Kafka 集群,同时还要保证数据一致性。

如果业务方的需求变为将 Hive 中的离线数据同步到 Kafka 中,以供实时消费呢?此时,你可能需要使用 MapReduce 或 Spark 进行离线数据批转流处理,如果是海量数据,数据的一致性和容错机制很难保证。

我们将可能遇到的挑战归纳为 4 种场景:

  • 有海量数据同步和清洗诉求,但是不懂 MapReduce / Spark / Flink,或不想依赖很重的中间件;
  • 有多种异构数据源的数据同步和清洗诉求,但是不想每次都有开发量,缺乏规模可扩展性和可重用性;
  • 有多种异构数据源的数据同步和清洗诉求,但是缺乏异常容错管理和任务执行状态监控体系;
  • 有海量数据同步和清洗诉求,但是不想投入很多机器计算资源,或不想做复杂中间件集群运维工作。

面临上述问题,Kafka Connect 一定是你的不二选择。

🔥 Kafka Connect 的优势

简单来说,Kafka Connect 是 Apache Kafka 的一部分,主要是为其他外部数据存储系统和 Kafka 提供流式集成的数据通道。

Kafka Connect 天然支持在异构数据源(MySQL、MongoDB、Elasticsearch、Kafka)下,让离线数据(批数据)转实时流(Kafka)或者反向的流转批,还提供了在 Data Pipline(数据同步管道)上的处理能力,让开发者在数据管道中对实时数据进行结构化的清洗,具备高度的灵活性。

作者甘罗带领的团队,就在2020年基于 Kafka Connect 自建了异构数据双向流式同步服务,它运行着 100+ Source 和 Sink Connectors 集群,覆盖 MySQL、MongoDB、Hive、Elasticsearch、Kafka 多种异构存储引擎,日均处理离线和实时数据量级 10+ 亿。

此外,他们还定制开发了 Kafka Connect 集群控制台,除了满足日常 Connectors 集群管理,还实现了数据同步任务从异构数据接入,到选择数据清洗规则,再到选择写入数据源的全流程自助接入,真正实现了零开发即可新建异构数据流式同步 Connectors 集群。

在这个过程中,他们总结了很多最佳实践,作者甘罗非常想在小册中把它们分享给大家。

🏆 学习小册,你能得到哪些提升?

小册将划分为7个模块,从当前主流的各种数据同步框架选型,到基于 Kafka Connect 开源生态到搭建新的数据流式双向同步新架构,再到定制开发异构数据双向流式同步 Connector 组件。

最终,你不仅能收获一个工业级可用、可伸缩扩展、易接入维护的支撑日均处理数十亿级海量异构数据的双向流式处理平台,还能在面对海量数据的同步和清洗工作时,更加游刃有余!

更详细点来说,你将收获:

  • 面对海量异构数据的通用流式处理技术方案和架构设计
  • 一个工业级可用、可扩展、易维护的多种异构数据双向流式处理平台
  • Kafka Connect、CDC机制、Data Routing & Pipeline 等技术栈的底层原理和生产实战
  • 掌握 Source 和 Sink Connectors 架构剖析和扩展开发能力
  • 掌握 Transforms 的架构设计理念,并能定制开发轻量级 ETL 组件
  • 掌握基于 JMX、Prometheus Exporter、Grafana 一站式的指标收集和监控体系搭建

最后,如果你想要精通或提升离线和实时数据同步和处理能力,想要掌握 Kafka 核心特性、MySQL 和 MongoDB 底层存储机制、CDC架构的理念和适用场景、Elasticsearch 分片/路由/管道等高阶操作、通用的数据ETL组件和框架等进阶技能,那这本小册你一定不要错过!

上新优惠5折,限时14.95元,戳链接即可购买:sourl.cn/cHk2xT

🎤 名人推荐

1 推荐语-横版.png

2 推荐语-横版.png

本文转载自: 掘金

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

问:重写equals不重写hashCode会怎样?

发表于 2021-11-01

在java中,equals和hashCode方法是Object类的方法,因此,所有用户编写的类都默认拥有这两个方法。

equals方法的作用

根据equals的英文翻译可以看出,这个方法的作用主要是用于比较两个对象是否相等。

在Object类中,equals方法默认使用“==”号来对两个对象进行判断,而在java中,“==”号用在两个对象身上时,比较的是两个对象的地址值,这就意味着当两个对象不是同一个时(地址值不一致就不是同一个对象),就返回为false。

这种判断方式本质上没错,但是不太符合实际需求,就好比在两个不同的超市里面都有矿泉水,但是因为地址值不同,在使用equals做判断时,这两个超市的矿泉水就会返回为false;因此在实际开发中我们往往需要重写Object的equals方法。

hashCode方法的作用

未重写hashCode方法时,它的作用主要是根据当前对象返回一个整型的hash值,不同对象调用hashCode返回的值往往是不一样的。

在java底层集合框架中,为了提高查询效率,往往使用hashCode方法来确定元素的保存位置。

重写equals不重写hashCode会怎样?

以下Sudent类只重写了,Object的equals方法,没有重写hashCode方法\

测试一下:

测试结果:

按照我们的常规理解,只要两个学生对象的id和name是一样的,我们就可以认为这两个学生对象指的是同一个人,因此重写了equals方法,让Student对象只要name和id相同就返回true,而且并没有重写hashCode方法。

在测试中,新建了两个Student对象,并且让他们的id和name完全一样,在调用equals方法时,返回为true,说明这两个对象时相等的。但是由于没有重写hashCode方法,所以这两个对象调用的hashCode还是Object类那里的hashCode方法,并且他们的值并不相等。

由此可以得出结论:重写了equals方法,不重写hashCode方法时,可能会出现equals方法返回为true,而hashCode方法却返回不同的结果。

那么这样会有什么影响呢?

在java底层的集合框架中(如HashMap,HashSet等),为了提高查询的效率,在确定某个对象的存储位置时,往往需要通过调用对象的hashCode方法来实现。

例如在上例中,我们把新建出来的两个Student对象放入HashSet集合中:

按照我们主观理解,这两个对象equals为true,那么HashSet应该主动帮我们去重,最终的HashSet中应该只保留1个对象,即最终输出的HashSet的size为1,但是我们得到的结果却是2:

之所以会产生这种结果,是因为HashSet的底层其实时数组结构,当需要把一个对象放进去时,会先调用这个对象的hashCode方法,并对返回的结果进行处理,最终得出该对象在HashSet中的具体位置。

由于Student没有重写hashCode方法,所以即使equals判断为true,但是他们保存的位置在底层数组上时不一样的,不会触发HashSet的去重功能,而对于程序员来说,两个相同的对象却会在HashSet中出现多次。

本文转载自: 掘金

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

阿里云消息队列 RocketMQ 50 全新升级:消息、事

发表于 2021-11-01

简介: RocketMQ5.0 的发布标志着阿里云消息从消息领域正式迈向了“消息、事件、流”场景大融合的新局面。未来阿里云消息产品的演进也将继续围绕消息、事件、流核心场景而开展。

从“消息”到“消息、事件、流”的大融合

消息队列作为当代应用的通信基础设施,微服务架构应用的核心依赖,通过异步解耦能力让用户更高效地构建分布式、高性能、弹性健壮的应用程序。

从数据价值和业务价值角度来看,消息队列的价值不断深化。消息队列中流动的业务核心数据涉及集成传输、分析计算和处理等不同环节与场景。伴随着不断演进,我们可以预见消息队列势必在数据通道、事件集成驱动、分析计算等场景不断产生新价值,创造新的“化学反应”。

RocketMQ 诞生于阿里巴巴内部电商系统,发展至今日,其核心架构经历了多次关键演进:

早在 2007 年,淘宝电商系统做服务化拆分的时候,就诞生了第一代消息服务 Notify,这是 RocketMQ 最早雏形。Notify 采用了关系型数据库作为存储,使用推模式。在阿里淘宝这种高频交易场景中,具有非常广泛地应用。

在 2007-2013 年期间,随着阿里集团业务发展,不仅需要交易场景异步调用,同时需要支持大量传输埋点数据、数据同步。此时,内部衍生出 MetaQ 以及 RocketMQ3.0 版本,这两个版本开始探索自研存储引擎,采用了自研专有消息存储,支持了单机海量 Topic,并前瞻性地去除了 Zookeeper 等组件的外部依赖。在十年后的今天,我们看到去各种 keeper 已成为整个消息领域的发展主流。

经历了前三代的内部业务打磨后,阿里巴巴积极参与开源并将 RocketMQ3.0 贡献到开源社区,并于 2017 年从 Apache 孵化器毕业,成为中国首个非 Hadoop 生态体系的 Apache 社区顶级项目。此后,RocketMQ 也开始服务于阿里云企业客户。秉承开源、商业、内部三位一体发展策略,18 年发布的 4.x 版,在高可靠低延迟方面重点优化,构建了全新的低延迟存储引擎和多场景容灾解决方案、并提供了丰富的消息特性。这也使得 RocketMQ 成为金融级的业务消息首选方案。

上个月社区发布了 RocketMQ5.0-preview 版,正式宣告 5.0 的到来。RocketMQ5.0 将不再局限于消息解耦的基本场景,更是通过统一内核、存储的优势,提供消息、事件、流一体化的处理能力。

回顾 RocketMQ 发展的十余年,良好的社区环境和商业支持使得大量企业开发者可以很方便的跟进业务特点和诉求进行选型和验证。在社区活跃影响力方面,RocketMQ 社区项目收获 15000+Star,活跃的贡献者有 400+ 位,多语言、生态连接等周边活跃项目 30+ 个,深受社区开发者欢迎。在应用规模方面,RocketMQ 作为金融级业务消息方案,积累了互联网游戏、在线教育、金融证券、银行、政企能源、汽车出行等众多行业数以万计的企业客户。同时,在阿里巴巴内部担负业务核心链路,每天流转万亿级消息流量,扛过了历届双十一的零点峰值。在行业评测方面,RocketMQ 也多次斩获大奖。

官宣:阿里云新一代 RocketMQ“消息、事件、流”融合处理平台

今天发布阿里云消息队列 RocketMQ 版 5.0,我们称之为一站式“消息、事件、流”融合处理平台。

新版本核心诞生两大新亮点,首先是消息核心场景的扩展和布局,RocketMQ 5.0 不再局限于消息解耦场景,将全新布局事件驱动和消息流式处理场景;其次则是一站式融合处理的技术架构和趋势。

“消息、事件、流”一站式融合处理的技术架构可以实现一份消息存储,支持消息的流式计算、异步投递、集成驱动多种场景,极大地降低业务人员运维多套系统的技术复杂度和运维成本。可以说,无论是微服务的指令调用、异步通知,还是 CDC 变更日志、行为埋点数据,亦或是资源运维、审计事件,统一的 RocketMQ5.0 产品栈都能统一处理。

重大发布一:RocketMQ 基础架构全新升级

首先,最重要的升级是阿里云 RocketMQ 的技术架构全面焕新。

全新的 RocketMQ5.0 版将通用的存储逻辑下沉,集中解决消息存储的多副本、低延迟、海量队列分区等技术问题,将上层的消息处理和剥离出完全的无状态计算层,主要完成协议适配、权限管理、消费状态、可观测运维体系支持。得益于存算分离的架构设计,从 SDK 接入到线上运维全链路带来全面提升:

  1. 轻量版 SDK 的开放和全链路可观测系统的提升:同时支持 4.x 通信协议和全新的 gRPC 通信协议,并内置 OpenTelemetry 埋点支持,新版本 SDK 新增了 10 余个指标埋点。
  2. 消息级负载均衡:新版本 SDK 不再参与实际存储队列的负载均衡,消息负载均衡将更加轻量,以单条消息为调度最小单元。
  3. 多网络访问支持:新版本支持单一实例同时暴露公网、内网等访问形式,方便客户多网络接入访问。
  4. 海量分级存储:新版本开放分级存储历史消息保存能力,消息低成本无大小限制,最长保存 30 天。冷热数据进行分离设计,极大降低消费历史消息对实例的性能影响。

重大发布二:RocketMQ Streaming 云上最佳实践——消息ETL

消息基础架构的能力提升之外,阿里云 RocketMQ 在 Streaming 流式处理场景推出了轻量级消息 ETL 功能。

用户在数据库变更、终端数据上报、后台埋点日志等场景产生的消息,典型的消费场景就是数据清洗转化,同时再存储到外部的存储和离线分析、在线分析系统中。传统实现方案需要搭建 Flink 等重量级实时计算服务或者自建消费应用做消息处理。而使用商业版 RocketMQ ETL 功能,简单控制台配置即可实现消息的清洗和转化。RocketMQ ETL 功能有三大优势:

  1. 轻量无依赖:作为阿里云消息原生功能,使用时不需要部署外部计算服务或消费程序,方案更轻量。
  2. 开发门槛低:内置常见清洗转化模板,满足绝大多数消息内容处理需求,并支持用户快速编写自定义函数来支持特殊的业务逻辑。整体开发成本非常低,1 小时即可完成业务上线。
  3. Serverless 弹性:无需预先估算容量,采取 Serverless 无服务器模式,实现按需弹性伸缩。

重大发布三:EDA 云上最佳实践——事件中心 EventBridge

本次 RocketMQ 最后一个发布点是在事件驱动的业务场景的布局和演进。早在 2018 年,Gartner 评估报告将 EDA(Event-Driven-Architecture) 列为十大战略技术趋势之一,事件驱动架构将成为未来微服务主流。我们首先下一个定义:

事件驱动其本质是对消息驱动的再升级,是企业IT架构深度演进的下一个必然阶段。

事件驱动架构和消息驱动架构的区别和关联主要集中于以下三点:

  1. EDA 更加强调深层次解耦:消息驱动是同一业务、组织系统内不同组件之间在技术架构层面的调用解耦,其信息封装和处理都是有预期、预定义的。事件驱动适配是更宽泛的业务、组织系统,基于事件的解耦上下游之间无需有预期和行为定义,上下游统一遵循标准化的规范,这是更深度的解耦。
  2. EDA 更加强调连接能力:消息驱动更多是单一系统内的调用,而事件驱动往往会涉及到不同的地域、账户主体以及三方 SaaS 的协同,事件驱动的一大特征就是生态的强连接能力。
  3. EDA 更加强调 Serverless 低代码开发:类比于消息和微服务的协同关系,未来业务架构 Serverless 化的大趋势会推动业务开发模式逐步转向低代码配置化。事件驱动的另一大特征就是低代码开发,基于丰富的工具能力,业务侧不需要像消息驱动一样编写大量的生产消费代码。

因此,阿里云统一事件中心 EventBridge 产品带来如下能力:

  1. 统一标准化的事件集成生态:作为阿里云事件中心,集成 80 余款云产品的业务事件,支持 800 多种事件类型,用户使用 EventBridge 可以一次性管理所有云产品资源的变更、操作使用事件,避免对接多个产品接口的重复性劳动。
  2. 全球事件互通网络:贯彻事件驱动强连接的属性能力,本次发布了全球事件互通网络,首批支持国内五大地域事件互通。企业客户简单配置即可实现跨账号、跨地域、跨网络的事件聚合和流转。
  3. Serverless 低代码开发:内置十余种事件目标和处理模板,涵盖了大多数业务场景,客户简单配置、低代码,无需部署服务即可完成事件的驱动和处理。

面向未来:坚定推动“消息、事件、流”大融合的发展

RocketMQ5.0 的发布标志着阿里云消息从消息领域正式迈向了“消息、事件、流”场景大融合的新局面。未来阿里云消息产品的演进也将继续围绕消息、事件、流核心场景而开展。消息基础架构本身也必将步伐不断,继续朝着 Serverless 弹性、强容灾能力、可观测免运维方向推进,给客户带来高性能、高可靠、强容灾的高 SLA 服务;并在 Streaming 的场景会基于客户业务诉求,联合生态产品持续推出更多的消息处理计算服务;打造面向未来的企业集成模式,联合生态伙伴和开源社区大力推动事件驱动进一步发展。

原文链接

本文为阿里云原创内容,未经允许不得转载。

本文转载自: 掘金

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

1…442443444…956

开发者博客

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