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

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


  • 首页

  • 归档

  • 搜索

如何完美的向面试官阐述你对IOC的理解?

发表于 2019-04-03

1.1、IoC是什么Ioc—Inversion of Control,即“控制反转”,不是什么技术,而是一种设计思想。在Java开发中,Ioc意味着将你设计好的对象交给容器控制,而不是传统的在你的对象内部直接控制。如何理解好Ioc呢?理解好Ioc的关键是要明确“谁控制谁,控制什么,为何是反转(有反转就应该有正转了),哪些方面反转了”,那我们来深入分析一下:

谁控制谁,控制什么:

传统Java SE程序设计,我们直接在对象内部通过new进行创建对象,是程序主动去创建依赖对象;而IoC是有专门一个容器来创建这些对象,即由Ioc容器来控制对 象的创建;谁控制谁?当然是IoC 容器控制了对象;控制什么?那就是主要控制了外部资源获取(不只是对象包括比如文件等)。

为何是反转,哪些方面反转了:

有反转就有正转,传统应用程序是由我们自己在对象中主动控制去直接获取依赖对象,也就是正转;而反转则是由容器来帮忙创建及注入依赖对象;为何是反转?因为由容器帮我们查找及注入依赖对象,对象只是被动的接受依赖对象,所以是反转;哪些方面反转了?依赖对象的获取被反转了

IoC能做什么IoC

不是一种技术,只是一种思想,一个重要的面向对象编程的法则,它能指导我们如何设计出松耦合、更优良的程序。传统应用程序都是由我们在类内部主动创建依赖对象,从而导致类与类之间高耦合,难于测试;有了IoC容器后,把创建和查找依赖对象的控制权交给了容器,由容器进行注入组合对象,所以对象与对象之间是 松散耦合,这样也方便测试,利于功能复用,更重要的是使得程序的整个体系结构变得非常灵活。

IoC(控制反转)首先想说说IoC(Inversion of Control,控制反转)。

这是spring的核心,贯穿始终。所谓IoC,对于spring框架来说,就是由spring来负责控制对象的生命周期和对象间的关系。这是什么意思呢,举个简单的例子,我们是如何找女朋友的?常见的情况是,我们到处去看哪里有长得漂亮身材又好的mm,然后打听她们的兴趣爱好、qq号、电话号、ip号、iq号………,想办法认识她们,投其所好送其所要,然后嘿嘿……这个过程是复杂深奥的,我们必须自己设计和面对每个环节。传统的程序开发也是如此,在一个对象中,如果要使用另外的对象,就必须得到它(自己new一个,或者从JNDI中查询一个),使用完之后还要将对象销毁(比如Connection等),对象始终会和其他的接口或类藕合起来。那么IoC是如何做的呢?有点像通过婚介找女朋友,在我和女朋友之间引入了一个第三者:婚姻介绍所。婚介管理了很多男男女女的资料,我可以向婚介提出一个列表,告诉它我想找个什么样的女朋友,比如长得像李嘉欣,身材像林熙雷,唱歌像周杰伦,速度像卡洛斯,技术像齐达内之类的,然后婚介就会按照我们的要求,提供一个mm,我们只需要去和她谈恋爱、结婚就行了。简单明了,如果婚介给我们的人选不符合要求,我们就会抛出异常。整个过程不再由我自己控制,而是有婚介这样一个类似容器的机构来控制。Spring所倡导的开发方式就是如此,所有的类都会在spring容器中登记,告诉spring你是个什么东西,你需要什么东西,然后spring会在系统运行到适当的时候,把你要的东西主动给你,同时也把你交给其他需要你的东西。所有的类的创建、销毁都由 spring来控制,也就是说控制对象生存周期的不再是引用它的对象,而是spring。对于某个具体的对象而言,以前是它控制其他对象,现在是所有对象都被spring控制,所以这叫控制反转。

DI(依赖注入)IoC的一个重点是在系统运行中,动态的向某个对象提供它所需要的其他对象。

这一点是通过DI(Dependency Injection,依赖注入)来实现的。比如对象A需要操作数据库,以前我们总是要在A中自己编写代码来获得一个Connection对象,有了 spring我们就只需要告诉spring,A中需要一个Connection,至于这个Connection怎么构造,何时构造,A不需要知道。在系统运行时,spring会在适当的时候制造一个Connection,然后像打针一样,注射到A当中,这样就完成了对各个对象之间关系的控制。A需要依赖 Connection才能正常运行,而这个Connection是由spring注入到A中的,依赖注入的名字就这么来的。那么DI是如何实现的呢? Java 1.3之后一个重要特征是反射(reflection),它允许程序在运行的时候动态的生成对象、执行对象的方法、改变对象的属性,spring就是通过反射来实现注入的。理解了IoC和DI的概念后,一切都将变得简单明了,剩下的工作只是在spring的框架中堆积木而已。

本文转载自: 掘金

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

Golang面向并发的内存模型

发表于 2019-04-02

在早期,CPU都是以单核的形式顺序执行机器指令。Go语言的祖先C语言正是这种顺序编程语言的代表。顺序编程语言中的顺序是指:所有的指令都是以串行的方式执行,在相同的时刻有且仅有一个CPU在顺序执行程序的指令。

随着处理器技术的发展,单核时代以提升处理器频率来提高运行效率的方式遇到了瓶颈,目前各种主流的CPU频率基本被锁定在了3GHZ附近。单核CPU的发展的停滞,给多核CPU的发展带来了机遇。相应地,编程语言也开始逐步向并行化的方向发展。Go语言正是在多核和网络化的时代背景下诞生的原生支持并发的编程语言。

常见的并行编程有多种模型,主要有多线程、消息传递等。从理论上来看,多线程和基于消息的并发编程是等价的。由于多线程并发模型可以自然对应到多核的处理器,主流的操作系统因此也都提供了系统级的多线程支持,同时从概念上讲多线程似乎也更直观,因此多线程编程模型逐步被吸纳到主流的编程语言特性或语言扩展库中。而主流编程语言对基于消息的并发编程模型支持则相比较少,Erlang语言是支持基于消息传递并发编程模型的代表者,它的并发体之间不共享内存。Go语言是基于消息并发模型的集大成者,它将基于CSP模型的并发编程内置到了语言中,通过一个go关键字就可以轻易地启动一个Goroutine,与Erlang不同的是Go语言的Goroutine之间是共享内存的。

Goroutine和系统线程

Goroutine是Go语言特有的并发体,是一种轻量级的线程,由go关键字启动。在真实的Go语言的实现中,goroutine和系统线程也不是等价的。尽管两者的区别实际上只是一个量的区别,但正是这个量变引发了Go语言并发编程质的飞跃。

首先,每个系统级线程都会有一个固定大小的栈(一般默认可能是2MB),这个栈主要用来保存函数递归调用时参数和局部变量。固定了栈的大小导致了两个问题:一是对于很多只需要很小的栈空间的线程来说是一个巨大的浪费,二是对于少数需要巨大栈空间的线程来说又面临栈溢出的风险。针对这两个问题的解决方案是:要么降低固定的栈大小,提升空间的利用率;要么增大栈的大小以允许更深的函数递归调用,但这两者是没法同时兼得的。相反,一个Goroutine会以一个很小的栈启动(可能是2KB或4KB),当遇到深度递归导致当前栈空间不足时,Goroutine会根据需要动态地伸缩栈的大小(主流实现中栈的最大值可达到1GB)。因为启动的代价很小,所以我们可以轻易地启动成千上万个Goroutine。

Go的运行时还包含了其自己的调度器,这个调度器使用了一些技术手段,可以在n个操作系统线程上多工调度m个Goroutine。Go调度器的工作和内核的调度是相似的,但是这个调度器只关注单独的Go程序中的Goroutine。Goroutine采用的是半抢占式的协作调度,只有在当前Goroutine发生阻塞时才会导致调度;同时发生在用户态,调度器会根据具体函数只保存必要的寄存器,切换的代价要比系统线程低得多。运行时有一个runtime.GOMAXPROCS变量,用于控制当前运行正常非阻塞Goroutine的系统线程数目。

在Go语言中启动一个Goroutine不仅和调用函数一样简单,而且Goroutine之间调度代价也很低,这些因素极大地促进了并发编程的流行和发展。

原子操作

所谓的原子操作就是并发编程中“最小的且不可并行化”的操作。通常,如果多个并发体对同一个共享资源进行的操作是原子的话,那么同一时刻最多只能有一个并发体对该资源进行操作。从线程角度看,在当前线程修改共享资源期间,其它的线程是不能访问该资源的。原子操作对于多线程并发编程模型来说,不会发生有别于单线程的意外情况,共享资源的完整性可以得到保证。

一般情况下,原子操作都是通过“互斥”访问来保证的,通常由特殊的CPU指令提供保护。当然,如果仅仅是想模拟下粗粒度的原子操作,我们可以借助于sync.Mutex来实现:

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
复制代码import (
"sync"
)

var total struct {
sync.Mutex
value int
}

func worker(wg *sync.WaitGroup) {
defer wg.Done()

for i := 0; i <= 100; i++ {
total.Lock()
total.value += i
total.Unlock()
}
}

func main() {
var wg sync.WaitGroup
wg.Add(2)
go worker(&wg)
go worker(&wg)
wg.Wait()

fmt.Println(total.value)
}

在worker的循环中,为了保证total.value += i的原子性,我们通过sync.Mutex加锁和解锁来保证该语句在同一时刻只被一个线程访问。对于多线程模型的程序而言,进出临界区前后进行加锁和解锁都是必须的。如果没有锁的保护,total的最终值将由于多线程之间的竞争而可能会不正确。

用互斥锁来保护一个数值型的共享资源,麻烦且效率低下。标准库的sync/atomic包对原子操作提供了丰富的支持。我们可以重新实现上面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
复制代码import (
"sync"
"sync/atomic"
)

var total uint64

func worker(wg *sync.WaitGroup) {
defer wg.Done()

var i uint64
for i = 0; i <= 100; i++ {
atomic.AddUint64(&total, i)
}
}

func main() {
var wg sync.WaitGroup
wg.Add(2)

go worker(&wg)
go worker(&wg)
wg.Wait()
}

atomic.AddUint64函数调用保证了total的读取、更新和保存是一个原子操作,因此在多线程中访问也是安全的。

原子操作配合互斥锁可以实现非常高效的单件模式。互斥锁的代价比普通整数的原子读写高很多,在性能敏感的地方可以增加一个数字型的标志位,通过原子检测标志位状态降低互斥锁的使用次数来提高性能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
复制代码type singleton struct {}

var (
instance *singleton
initialized uint32
mu sync.Mutex
)

func Instance() *singleton {
if atomic.LoadUint32(&initialized) == 1 {
return instance
}

mu.Lock()
defer mu.Unlock()

if instance == nil {
defer atomic.StoreUint32(&initialized, 1)
instance = &singleton{}
}
return instance
}

我们可以将通用的代码提取出来,就成了标准库中sync.Once的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码type Once struct {
m Mutex
done uint32
}

func (o *Once) Do(f func()) {
if atomic.LoadUint32(&o.done) == 1 {
return
}

o.m.Lock()
defer o.m.Unlock()

if o.done == 0 {
defer atomic.StoreUint32(&o.done, 1)
f()
}
}

基于sync.Once重新实现单件模式:

1
2
3
4
5
6
7
8
9
10
11
复制代码var (
instance *singleton
once sync.Once
)

func Instance() *singleton {
once.Do(func() {
instance = &singleton{}
})
return instance
}

sync/atomic包对基本的数值类型及复杂对象的读写都提供了原子操作的支持。atomic.Value原子对象提供了Load和Store两个原子方法,分别用于加载和保存数据,返回值和参数都是interface{}类型,因此可以用于任意的自定义复杂类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
复制代码var config atomic.Value // 保存当前配置信息

// 初始化配置信息
config.Store(loadConfig())

// 启动一个后台线程, 加载更新后的配置信息
go func() {
for {
time.Sleep(time.Second)
config.Store(loadConfig())
}
}()

// 用于处理请求的工作者线程始终采用最新的配置信息
for i := 0; i < 10; i++ {
go func() {
for r := range requests() {
c := config.Load()
// ...
}
}()
}

这是一个简化的生产者消费者模型:后台线程生成最新的配置信息;前台多个工作者线程获取最新的配置信息。所有线程共享配置信息资源。

顺序一致性内存模型

如果只是想简单地在线程之间进行数据同步的话,原子操作已经为编程人员提供了一些同步保障。不过这种保障有一个前提:顺序一致性的内存模型。要了解顺序一致性,我们先看看一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码var a string
var done bool

func setup() {
a = "hello, world"
done = true
}

func main() {
go setup()
for !done {}
print(a)
}

我们创建了setup线程,用于对字符串a的初始化工作,初始化完成之后设置done标志为true。main函数所在的主线程中,通过for !done {}检测done变为true时,认为字符串初始化工作完成,然后进行字符串的打印工作。

但是Go语言并不保证在main函数中观测到的对done的写入操作发生在对字符串a的写入的操作之后,因此程序很可能打印一个空字符串。更糟糕的是,因为两个线程之间没有同步事件,setup线程对done的写入操作甚至无法被main线程看到,main函数有可能陷入死循环中。

在Go语言中,同一个Goroutine线程内部,顺序一致性内存模型是得到保证的。但是不同的Goroutine之间,并不满足顺序一致性内存模型,需要通过明确定义的同步事件来作为同步的参考。如果两个事件不可排序,那么就说这两个事件是并发的。为了最大化并行,Go语言的编译器和处理器在不影响上述规定的前提下可能会对执行语句重新排序(CPU也会对一些指令进行乱序执行)。

因此,如果在一个Goroutine中顺序执行a = 1; b = 2;两个语句,虽然在当前的Goroutine中可以认为a = 1;语句先于b = 2;语句执行,但是在另一个Goroutine中b = 2;语句可能会先于a = 1;语句执行,甚至在另一个Goroutine中无法看到它们的变化(可能始终在寄存器中)。也就是说在另一个Goroutine看来, a = 1; b = 2;两个语句的执行顺序是不确定的。如果一个并发程序无法确定事件的顺序关系,那么程序的运行结果往往会有不确定的结果。比如下面这个程序:

1
2
3
复制代码func main() {
go println("你好, 世界")
}

根据Go语言规范,main函数退出时程序结束,不会等待任何后台线程。因为Goroutine的执行和main函数的返回事件是并发的,谁都有可能先发生,所以什么时候打印,能否打印都是未知的。

用前面的原子操作并不能解决问题,因为我们无法确定两个原子操作之间的顺序。解决问题的办法就是通过同步原语来给两个事件明确排序:

1
2
3
4
5
6
7
8
9
10
复制代码func main() {
done := make(chan int)

go func(){
println("你好, 世界")
done <- 1
}()

<-done
}

当<-done执行时,必然要求done <- 1也已经执行。根据同一个Gorouine依然满足顺序一致性规则,我们可以判断当done <- 1执行时,println("你好, 世界")语句必然已经执行完成了。因此,现在的程序确保可以正常打印结果。

当然,通过sync.Mutex互斥量也是可以实现同步的:

1
2
3
4
5
6
7
8
9
10
11
复制代码func main() {
var mu sync.Mutex

mu.Lock()
go func(){
println("你好, 世界")
mu.Unlock()
}()

mu.Lock()
}

可以确定后台线程的mu.Unlock()必然在println("你好, 世界")完成后发生(同一个线程满足顺序一致性),main函数的第二个mu.Lock()必然在后台线程的mu.Unlock()之后发生(sync.Mutex保证),此时后台线程的打印工作已经顺利完成了。

初始化顺序

前面函数章节中我们已经简单介绍过程序的初始化顺序,这是属于Go语言面向并发的内存模型的基础规范。

Go程序的初始化和执行总是从main.main函数开始的。但是如果main包里导入了其它的包,则会按照顺序将它们包含进main包里(这里的导入顺序依赖具体实现,一般可能是以文件名或包路径名的字符串顺序导入)。如果某个包被多次导入的话,在执行的时候只会导入一次。当一个包被导入时,如果它还导入了其它的包,则先将其它的包包含进来,然后创建和初始化这个包的常量和变量。然后就是调用包里的init函数,如果一个包有多个init函数的话,实现可能是以文件名的顺序调用,同一个文件内的多个init则是以出现的顺序依次调用(init不是普通函数,可以定义有多个,所以不能被其它函数调用)。最终,在main包的所有包常量、包变量被创建和初始化,并且init函数被执行后,才会进入main.main函数,程序开始正常执行

要注意的是,在main.main函数执行之前所有代码都运行在同一个Goroutine中,也是运行在程序的主系统线程中。如果某个init函数内部用go关键字启动了新的Goroutine的话,新的Goroutine和main.main函数是并发执行的。

因为所有的init函数和main函数都是在主线程完成,它们也是满足顺序一致性模型的。

Goroutine的创建

go语句会在当前Goroutine对应函数返回前创建新的Goroutine. 例如:

1
2
3
4
5
6
7
8
9
10
复制代码var a string

func f() {
print(a)
}

func hello() {
a = "hello, world"
go f()
}

执行go f()语句创建Goroutine和hello函数是在同一个Goroutine中执行, 根据语句的书写顺序可以确定Goroutine的创建发生在hello函数返回之前, 但是新创建Goroutine对应的f()的执行事件和hello函数返回的事件则是不可排序的,也就是并发的。调用hello可能会在将来的某一时刻打印"hello, world",也很可能是在hello函数执行完成后才打印。

基于Channel的通信

Channel通信是在Goroutine之间进行同步的主要方法。在无缓存的Channel上的每一次发送操作都有与其对应的接收操作相配对,发送和接收操作通常发生在不同的Goroutine上(在同一个Goroutine上执行2个操作很容易导致死锁)。无缓存的Channel上的发送操作总在对应的接收操作完成前发生.

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码var done = make(chan bool)
var msg string

func aGoroutine() {
msg = "你好, 世界"
done <- true
}

func main() {
go aGoroutine()
<-done
println(msg)
}

可保证打印出“hello, world”。该程序首先对msg进行写入,然后在done管道上发送同步信号,随后从done接收对应的同步信号,最后执行println函数。

若在关闭Channel后继续从中接收数据,接收者就会收到该Channel返回的零值。因此在这个例子中,用close(c)关闭管道代替done <- false依然能保证该程序产生相同的行为。

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码var done = make(chan bool)
var msg string

func aGoroutine() {
msg = "你好, 世界"
close(done)
}

func main() {
go aGoroutine()
<-done
println(msg)
}

对于从无缓冲Channel进行的接收,发生在对该Channel进行的发送完成之前。

基于上面这个规则可知,交换两个Goroutine中的接收和发送操作也是可以的(但是很危险):

1
2
3
4
5
6
7
8
9
10
11
12
复制代码var done = make(chan bool)
var msg string

func aGoroutine() {
msg = "hello, world"
<-done
}
func main() {
go aGoroutine()
done <- true
println(msg)
}

也可保证打印出“hello, world”。因为main线程中done <- true发送完成前,后台线程<-done接收已经开始,这保证msg = "hello, world"被执行了,所以之后println(msg)的msg已经被赋值过了。简而言之,后台线程首先对msg进行写入,然后从done中接收信号,随后main线程向done发送对应的信号,最后执行println函数完成。但是,若该Channel为带缓冲的(例如,done = make(chan bool, 1)),main线程的done <- true接收操作将不会被后台线程的<-done接收操作阻塞,该程序将无法保证打印出“hello, world”。

对于带缓冲的Channel,对于Channel的第K个接收完成操作发生在第K+C个发送操作完成之前,其中C是Channel的缓存大小。 如果将C设置为0自然就对应无缓存的Channel,也即使第K个接收完成在第K个发送完成之前。因为无缓存的Channel只能同步发1个,也就简化为前面无缓存Channel的规则:对于从无缓冲Channel进行的接收,发生在对该Channel进行的发送完成之前。

我们可以根据控制Channel的缓存大小来控制并发执行的Goroutine的最大数目, 例如:

1
2
3
4
5
6
7
8
9
10
11
12
复制代码var limit = make(chan int, 3)

func main() {
for _, w := range work {
go func() {
limit <- 1
w()
<-limit
}()
}
select{}
}

最后一句select{}是一个空的管道选择语句,该语句会导致main线程阻塞,从而避免程序过早退出。还有for{}、<-make(chan int)等诸多方法可以达到类似的效果。因为main线程被阻塞了,如果需要程序正常退出的话可以通过调用os.Exit(0)实现。

不靠谱的同步

前面我们已经分析过,下面代码无法保证正常打印结果。实际的运行效果也是大概率不能正常输出结果。

1
2
3
复制代码func main() {
go println("你好, 世界")
}

刚接触Go语言的话,可能希望通过加入一个随机的休眠时间来保证正常的输出:

1
2
3
4
复制代码func main() {
go println("hello, world")
time.Sleep(time.Second)
}

因为主线程休眠了1秒钟,因此这个程序大概率是可以正常输出结果的。因此,很多人会觉得这个程序已经没有问题了。但是这个程序是不稳健的,依然有失败的可能性。我们先假设程序是可以稳定输出结果的。因为Go线程的启动是非阻塞的,main线程显式休眠了1秒钟退出导致程序结束,我们可以近似地认为程序总共执行了1秒多时间。现在假设println函数内部实现休眠的时间大于main线程休眠的时间的话,就会导致矛盾:后台线程既然先于main线程完成打印,那么执行时间肯定是小于main线程执行时间的。当然这是不可能的。

严谨的并发程序的正确性不应该是依赖于CPU的执行速度和休眠时间等不靠谱的因素的。严谨的并发也应该是可以静态推导出结果的:根据线程内顺序一致性,结合Channel或sync同步事件的可排序性来推导,最终完成各个线程各段代码的偏序关系排序。如果两个事件无法根据此规则来排序,那么它们就是并发的,也就是执行先后顺序不可靠的。

解决同步问题的思路是相同的:使用显式的同步。

转自Go语言高级编程

本文转载自: 掘金

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

【有趣】这段java代码太古怪

发表于 2019-04-01

首先呢,来一段java代码来开点胃。等等等等,耍我呢,这是java代码?

1
2
3
4
5
复制代码\u0070\u0075\u0062\u006c\u0069\u0063\u0020\u0063\u006c\u0061\u0073\u0073\u0020\u0058\u004a\u004a\u0020\u007b
\u0020\u0020\u0020\u0020\u0070\u0075\u0062\u006c\u0069\u0063\u0020\u0073\u0074\u0061\u0074\u0069\u0063\u0020\u0076\u006f\u0069\u0064\u0020\u006d\u0061\u0069\u006e\u0028\u0053\u0074\u0072\u0069\u006e\u0067\u005b\u005d\u0020\u0061\u0072\u0067\u0073\u0029\u0020\u007b
\u0020\u0020\u0020\u0020\u0020\u0020\u0020\u0020\u0053\u0079\u0073\u0074\u0065\u006d\u002e\u006f\u0075\u0074\u002e\u0070\u0072\u0069\u006e\u0074\u006c\u006e\u0028\u0022\u5c0f\u59d0\u59d0\u6211\u7231\u4f60\u0022\u0029\u003b
\u0020\u0020\u0020\u0020\u007d
\u007d

非常负责任的告诉你,是的!不信请看下图。纯纯正正的java代码,class为XJJ的java源码,执行后打印小姐姐我爱你。

还是不信?自个儿拷贝下去执行一下。不过,IDEA是会报错的,用命令行哦。

好隐晦的表白方式,是暗恋么?

其实没什么神奇的,我们不过是将正常的源代码翻译成了unicode编码方式。就是这段java代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
复制代码private static String toUnicode(String str) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < str.length(); i++) {
if (str.charAt(i) != '\n') {
int cp = Character.codePointAt(str, i);
int charCount = Character.charCount(cp);
if (charCount > 1) {
i += charCount - 1;
if (i >= str.length()) {
throw new IllegalArgumentException("truncated unexpectedly");
}
}
sb.append(String.format("\\u%04x", cp));
} else {
sb.append("\n");
}
}
return sb.toString();
}

耍到这里,我突然有了一个好主意。我要将我的java项目,全部编码成这种方式,然后传到github,嘿嘿。能编译但不可读,比base64更冷门。

所以以下几行python代码诞生了(仅用于python3):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码#!/usr/bin/env python
# -*- coding: utf-8 -*-
import sys
java = sys.argv[1]
s = sb = u""

with open(java, 'r' , encoding='utf-8') as f:
s = f.read()
for _c in s:
sb += '\\u%04x' % ord(_c)

with open(java, 'w' , encoding='utf-8') as f:
f.write(sb)

print(java)

在命令行中执行以下命令,将会将指定目录(test)中的所有java文件翻译成我们所想要的。

1
复制代码find ./test | grep \\.java$  | xargs -I '{}' python3 uni.py {}

是不是很简单?

那改完的java文件怎么恢复呢?我只管编码不管解码,剩下的要靠自己啦,这可是了解unicode编码的好机会。

码农世界可能是太过寂寥,无聊的项目也是频出。比如这个,判断数字是不是13,竟然接近4k星了。github.com/jezen/is-th…

贴上它的API感受下来自码农世界深深的空虚感吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码
var is = require('is-thirteen');
// Now with elegant syntax.

is(13).thirteen(); // true
is(12.8).roughly.thirteen(); // true
is(6).within(10).of.thirteen(); // true
is(2003).yearOfBirth(); // true

// check your math skillz
is(4).plus(5).thirteen(); // false
is(12).plus(1).thirteen(); // true
is(4).minus(12).thirteen(); // false
is(14).minus(1).thirteen(); // true
is(1).times(8).thirteen(); // false
is(26).divideby(2).thirteen(); //true

本文转载自: 掘金

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

优雅的缓存解决方案--设置过期时间 1 前言 2 配置

发表于 2019-03-31
  1. 前言

上篇文章介绍了利用 SpringCache 和 Redis 设置缓存,但是SpringCache 注解并不支持设置缓存时间,确实很令人头疼。这篇文章将叫你用最简单的方式解决 SpringCache 和 Redis 设置缓存并设置缓存时间。
此篇文章基于上篇博客,有啥不懂的地方请查看上篇博客。
上篇文章链接:
优雅的缓存解决方案–SpringCache和Redis集成(SpringBoot)

  1. 配置

@Cacheable注解不支持配置过期时间,所有需要通过配置CacheManneg来配置默认的过期时间和针对每个类或者是方法进行缓存失效时间配置。

解决
  可以采用如下的配置信息来解决的设置失效时间问题配置信息

修改配置类

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
复制代码import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.cache.RedisCacheWriter;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import java.io.Serializable;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;

/**
* @Author: MaoLin
* @Date: 2019/3/26 17:04
* @Version 1.0
*/

@Configuration
@EnableCaching
public class RedisConfig implements Serializable {

/**
* 申明缓存管理器,会创建一个切面(aspect)并触发Spring缓存注解的切点(pointcut)
* 根据类或者方法所使用的注解以及缓存的状态,这个切面会从缓存中获取数据,将数据添加到缓存之中或者从缓存中移除某个值
*/

/* @Bean
public RedisCacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
return RedisCacheManager.create(redisConnectionFactory);
}

@Bean
public RedisTemplate redisTemplate(RedisConnectionFactory factory) {
// 创建一个模板类
RedisTemplate<String, Object> template = new RedisTemplate<String, Object>();
// 将刚才的redis连接工厂设置到模板类中
template.setConnectionFactory(factory);
// 设置key的序列化器
template.setKeySerializer(new StringRedisSerializer());
// 设置value的序列化器
//使用Jackson 2,将对象序列化为JSON
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
//json转对象类,不设置默认的会将json转成hashmap
ObjectMapper om = new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
jackson2JsonRedisSerializer.setObjectMapper(om);
template.setValueSerializer(jackson2JsonRedisSerializer);

return template;
}*/


/**
* 最新版,设置redis缓存过期时间
*/

@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
return new RedisCacheManager(RedisCacheWriter.nonLockingRedisCacheWriter(redisConnectionFactory), this.getRedisCacheConfigurationWithTtl( 60), this.getRedisCacheConfigurationMap() // 指定 key 策略
);
}

private Map<String, RedisCacheConfiguration> getRedisCacheConfigurationMap() {
Map<String, RedisCacheConfiguration> redisCacheConfigurationMap = new HashMap<>();
//SsoCache和BasicDataCache进行过期时间配置
redisCacheConfigurationMap.put("messagCache", this.getRedisCacheConfigurationWithTtl(30 * 60)); redisCacheConfigurationMap.put("userCache", this.getRedisCacheConfigurationWithTtl(60));//自定义设置缓存时间

return redisCacheConfigurationMap;
}

private RedisCacheConfiguration getRedisCacheConfigurationWithTtl(Integer seconds) {
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper om = new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
jackson2JsonRedisSerializer.setObjectMapper(om);
RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig();
redisCacheConfiguration = redisCacheConfiguration.serializeValuesWith(
RedisSerializationContext
.SerializationPair
.fromSerializer(jackson2JsonRedisSerializer)
).entryTtl(Duration.ofSeconds(seconds));

return redisCacheConfiguration;
}
}

测试

  • 设置缓存名称及缓存时间(如下为60秒)
1
复制代码redisCacheConfigurationMap.put("userCache",this.getRedisCacheConfigurationWithTtl(60));
  • 使用
    加上注解即可 @Cacheable("userCache")
  • 注:名称为配置类里面设置的名称userCache,可设置多个缓存名称及时间*

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
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
复制代码
import com.ml.demo.dao.UserDao;
import com.ml.demo.entity.User;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.io.Serializable;

/**
* @Author: MaoLin
* @Date: 2019/3/26 17:03
* @Version 1.0
*/


@RestController
public class testController implements Serializable {
@Resource
private UserDao userDao;

/**
* 查询出一条数据并且添加到缓存
*
* @param userId
* @return
*/
@RequestMapping("/getUser")
@Cacheable("userCache")
public User getUser(@RequestParam(required = true) String userId) {
System.out.println("如果没有缓存,就会调用下面方法,如果有缓存,则直接输出,不会输出此段话");
return userDao.getUser(Integer.parseInt(userId));
}

/**
* 删除一个缓存
*
* @param userId
* @return
*/
@RequestMapping(value = "/deleteUser")
@CacheEvict("userCache")
public String deleteUser(@RequestParam(required = true) String userId) {
return "删除成功";
}

/**
* 添加一条保存的数据到缓存,缓存的key是当前user的id
*
* @param user
* @return
*/
@RequestMapping("/saveUser")
@CachePut(value = "userCache", key = "#result.userId +''")
public User saveUser(User user) {
return user;
}


/**
* 返回结果userPassword中含有nocache字符串就不缓存
*
* @param userId
* @return
*/
@RequestMapping("/getUser2")
@CachePut(value = "userCache", unless = "#result.userPassword.contains('nocache')")
public User getUser2(@RequestParam(required = true) String userId) {
System.out.println("如果走到这里说明,说明缓存没有生效!");
User user = new User(Integer.parseInt(userId), "name_nocache" + userId, "nocache");
return user;
}


@RequestMapping("/getUser3")
@Cacheable(value = "userCache", key = "#root.targetClass.getName() + #root.methodName + #userId")
public User getUser3(@RequestParam(required = true) String userId) {
System.out.println("如果第二次没有走到这里说明缓存被添加了");
return userDao.getUser(Integer.parseInt(userId));
}

}

测试运行及结果

  • 保存缓存

  • 查看缓存

  • 查看redis

  • 一分钟后缓存过期

  • 再查询缓存

  • 控制台运行结果

  1. 报错解决

1
2
3
复制代码2019-03-31 14:21:05.163 ERROR 17056 --- [nio-8080-exec-4] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.springframework.data.redis.serializer.SerializationException: Could not read JSON: Cannot construct instance of `com.ml.demo.entity.User` (no Creators, like default construct, exist): cannot deserialize from Object value (no delegate- or property-based Creator)
at [Source: (byte[])"["com.ml.demo.entity.User",{"userId":11,"userName":"\"张三\"","userPassword":"123"}]"; line: 1, column: 29]; nested exception is com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `com.ml.demo.entity.User` (no Creators, like default construct, exist): cannot deserialize from Object value (no delegate- or property-based Creator)
at [Source: (byte[])"["com.ml.demo.entity.User",{"userId":11,"userName":"\"张三\"","userPassword":"123"}]"; line: 1, column: 29]] with root cause

这个 bug 调了好久才解决,其实问题很简单。

原因:

原因是我在该实体类中添加了一个为了方便实例化该类用的构造函数,导致JVM不会添加默认的无参构造函数,而jackson的反序列化需要无参构造函数,因此报错。

Response实体类同理。

解决:

在实体类中补上一个无参构造器即可。

public User() {}

小结&参考资料

小结

利用 Spring 提供的缓存机制(对象)结合Redis 实现缓存其实是很好的方法,但是没有提供设置缓存时间,这个就很不人性化了,Redis 的使用其实 Spring 还提供了 RedisTemplate 和 StringRedisTemplate 这两个类都支持设置缓存时间,如果要是觉得 SpringCache 的使用不太方便,可以利用 RedisTemplate 类自定义 Redis 工具类来实现缓存。

  • Git源码,欢迎clone和fork

参考资料

  • spring 2.0以上 整合redis和cache后使用@Cacheable 时间失效
  • Spring Boot在反序列化过程中:jackson.databind.exc.InvalidDefinitionException cannot deserialize from Object value

本文转载自: 掘金

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

SpringCloud学习之路(二)- 服务的注册与发现Eu

发表于 2019-03-27

1.创建服务注册中心

在这里,我还是采用Eureka作为服务注册与发现的组件,至于Consul 之后会出文章详细介绍。

1.1 首先创建一个maven主工程。

首先创建一个主Maven工程,在其pom文件引入依赖,spring Boot版本为2.0.3.RELEASE,Spring Cloud版本为Finchley.RELEASE。这个pom文件作为父pom文件,起到依赖版本控制的作用,其他module工程继承该pom。这一系列文章全部采用这种模式,其他文章的pom跟这个pom一样。再次说明一下,以后不再重复引入。代码如下:

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
复制代码<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>com.forezp</groupId>
<artifactId>sc-f-chapter1</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>pom</packaging>

<name>sc-f-chapter1</name>
<description>Demo project for Spring Boot</description>

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.0.3.RELEASE</version>
<relativePath/>
</parent>

<modules>
<module>eureka-server</module>
<module>service-hi</module>
</modules>

<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
<spring-cloud.version>Finchley.RELEASE</spring-cloud.version>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

1.2 然后创建2个model工程:

一个model工程作为服务注册中心,即Eureka Server,另一个作为Eureka Client。

下面以创建server为例子,详细说明创建过程:

右键工程->创建model-> 选择spring initialir 如下图:

Paste_Image.png

下一步->选择cloud discovery->eureka server ,然后一直下一步就行了。

Paste_Image.png

创建完后的工程,其pom.xml继承了父pom文件,并引入spring-cloud-starter-netflix-eureka-server的依赖,代码如下:

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
复制代码<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>com.forezp</groupId>
<artifactId>eureka-server</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>

<name>eureka-server</name>
<description>Demo project for Spring Boot</description>

<parent>
<groupId>com.forezp</groupId>
<artifactId>sc-f-chapter1</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>

<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
</dependency>
</dependencies>

1.3 启动一个服务注册中心

只需要一个注解@EnableEurekaServer,这个注解需要在springboot工程的启动application类上加:

1
2
3
4
5
6
7
复制代码@SpringBootApplication
@EnableEurekaServer
public class EurekaServerApplication {
public static void main(String[] args) {
SpringApplication.run( EurekaServerApplication.class, args );
}
}

eureka是一个高可用的组件,它没有后端缓存,每一个实例注册之后需要向注册中心发送心跳(因此可以在内存中完成),在默认情况下erureka server也是一个eureka client ,必须要指定一个 server。eureka server的配置文件appication.yml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码server:
port: 8761

eureka:
instance:
hostname: localhost
client:
registerWithEureka: false
fetchRegistry: false
serviceUrl:
defaultZone: http://${eureka.instance.hostname}:${server.port}/eureka/

spring:
application:
name: eurka-server

通过eureka.client.registerWithEureka:false和fetchRegistry:false来表明自己是一个eureka server.

eureka server 是有界面的,启动工程,打开浏览器访问:http://localhost:8761 ,界面如下:

Paste_Image.png

No application available 没有服务被发现 ……_
因为没有注册服务当然不可能有服务被发现了。

2.创建一个服务提供者 (eureka client)

当client向server注册时,它会提供一些元数据,例如主机和端口,URL,主页等。Eureka server 从每个client实例接收心跳消息。 如果心跳超时,则通常将该实例从注册server中删除。

创建过程同server类似,创建完pom.xml如下:

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
复制代码<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>com.forezp</groupId>
<artifactId>service-hi</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>

<name>service-hi</name>
<description>Demo project for Spring Boot</description>

<parent>
<groupId>com.forezp</groupId>
<artifactId>sc-f-chapter1</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>

<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

通过注解@EnableEurekaClient 表明自己是一个eurekaclient.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码@SpringBootApplication
@EnableEurekaClient
@RestController
public class ServiceHiApplication {
public static void main(String[] args) {
SpringApplication.run( ServiceHiApplication.class, args );
}

@Value("${server.port}")
String port;

@RequestMapping("/hi")
public String home(@RequestParam(value = "name", defaultValue = "forezp") String name) {
return "hi " + name + " ,i am from port:" + port;
}
}

仅仅@EnableEurekaClient是不够的,还需要在配置文件中注明自己的服务注册中心的地址,application.yml配置文件如下:

1
2
3
4
5
6
7
8
9
10
11
复制代码server:
port: 8762

spring:
application:
name: service-hi

eureka:
client:
serviceUrl:
defaultZone: http://localhost:8761/eureka/

需要指明spring.application.name,这个很重要,这在以后的服务与服务之间相互调用一般都是根据这个name 。
启动工程,打开http://localhost:8761 ,即eureka server 的网址:

Paste_Image.png

你会发现一个服务已经注册在服务中了,服务名为SERVICE-HI ,端口为7862

这时打开 http://localhost:8762/hi?name=forezp ,你会在浏览器上看到 :

hi forezp,i am from port:8762

转载自:blog.csdn.net/forezp/arti…

本文转载自: 掘金

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

egg-从入门到上线 (上)

发表于 2019-03-26

1 环境搭建、创建、运行

1.1 介绍

egg.js是阿里旗下基于node.js和koa是一个node企业级应用开发框架,可以帮助开发团队,和开发人员减少成本。
基于koa2、es6、es7使得node具有更有规范的开发模式,更低的学习成本、更优雅的代码、更少的维护成本。

image.png

image.png

1.2 环境搭建

1、要求nodejs版本必须大于8.0并且要用LTS 版本
2、创建egg的环境 npm i egg-init -g / cnpm i egg-init -g (只需要安装一次)
3、创建项目
cd 到目录里面 (注意目录不要用中文 不要有空格)

1.3 创建

1
2
3
4
复制代码$ npm i egg-init -g
$ egg-init egg-example --type=simple //例如:egg-init 项目名称 --type=simple
$ cd egg-example
$ npm i

1.4 运行项目

1
2
复制代码npm run dev  
open localhost:7001 //一般性来说默认端口是7001

2 目录结构介绍

2.1 目录结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
复制代码egg-project
├── package.json
├── app.js (可选)
├── agent.js (可选)
├── app(项目开发目录)
| ├── router.js (用于配置 URL 路由规则)
│ ├── controller (用于解析用户的输入,处理后返回相应的结果)
│ | └── home.js
│ ├── service (用于编写业务逻辑层)
│ | └── user.js
│ ├── middleware (用于编写中间件)
│ | └── response_time.js
│ ├── schedule (可选)
│ | └── my_task.js
│ ├── public (用于放置静态资源)
│ | └── reset.css
│ ├── view (可选)
│ | └── home.tpl
│ └── extend (用于框架的扩展)
│ ├── helper.js (可选)
│ ├── request.js (可选)
│ ├── response.js (可选)
│ ├── context.js (可选)
│ ├── application.js (可选)
│ └── agent.js (可选)
├── config (用于编写配置文件)
| ├── plugin.js(用于配置需要加载的插件)
| ├── config.default.js
│ ├── config.prod.js
| ├── config.test.js (可选)
| ├── config.local.js (可选)
| └── config.unittest.js (可选)
└── test (用于单元测试)
├── middleware
| └── response_time.test.js
└── controller
└── home.test.js

image.png

3 访问路由

egg在设计完全符合比较好的mvc的设计模式。

3.1 那么什么是mvc呢?

全名是Model View Controller,是模型(model)-视图(view)-控制器(controller)的缩写,一种软件设计典范。

在egg中视图 (view)、控制器(controller) 和数据模型 Model(Service) 和配置文件(config)

3.2 控制器(controller)

  • app/controller 目录下面实现 Controller
1
2
3
4
5
6
7
8
9
10
11
12
复制代码// app/controller/home.js

const Controller = require('egg').Controller;

class HomeController extends Controller {
async index() {
const { ctx } = this;
ctx.body = 'hi, world';
}
}

module.exports = HomeController;

输入 npm run dev
查看 http://127.0.0.1:7001
输出 hi, world

我认为控制器就是一个接口,他管理输入和输出

*同样你可以在app/controller 目录下 写很多个这样个js的,来代表接口

3.3 路由(Router)

主要用来描述请求 URL 和具体承担执行动作的 Controller 的对应关系, 框架约定了 app/router.js 文件用于统一所有路由规则。

现在很多单页面,都是存在相对于的路由,你写个js,同样就要写一个路由

1
2
3
4
5
6
7
8
9
复制代码// app/controller/user.js
class UserController extends Controller {
async info() {
const { ctx } = this;
ctx.body = {
name: `hello ${ctx.params.id}`,
};
}
}
1
2
3
4
5
复制代码// app/router.js
module.exports = app => {
const { router, controller } = app;
router.get('/user/:id', controller.user.info);
};

3.4 数据模型 Model(Service)

简单来说,Service 就是在复杂业务场景下用于做业务逻辑封装的一个抽象层,提供这个抽象有以下几个好处:

  • 保持 Controller 中的逻辑更加简洁。
  • 保持业务逻辑的独立性,抽象出来的 Service 可以被多个 Controller 重复调用。
  • 将逻辑和展现分离,更容易编写测试用例。
1
2
3
4
5
6
7
8
9
10
11
复制代码// app/service/user.js
const Service = require('egg').Service;

class UserService extends Service {
async addName(name) {
const user = `你好,${name}`;
return user;
}
}

module.exports = UserService;
1
2
3
4
5
6
7
8
复制代码// app/controller/user.js
class UserController extends Controller {
async info() {
const { ctx } = this;
const userInfo = await ctx.service.user.addName('wjw');
ctx.body = userInfo;
}
}

3.5 egg中视图 (view)

egg中的模板渲染,但是我认为前端后端分离的设计,更加有利于作为服务型架构设计,所以这边不描述view的构造

4 get、post请求

4.1 get 请求

4.1.1 query

在 URL 中 ?后面的部分是一个 Query String,这一部分经常用于 GET 类型的请求中传递参数。例如 GET /search?name=egg&age=26中 name=egg&age=26 就是用户传递过来的参数。我们可以通过 context.query(为一个对象)拿到解析过后的这个参数体

1
2
3
4
5
6
7
8
9
10
11
12
复制代码module.exports = app => {

class HomeController extends Controller {
async getQuery() {
const queryObj = this.ctx.query;
console.log(queryObj.age);
console.log(queryObj);
//打印结果:{ name: 'egg', age: '26' }
}
}
return SearchController;
};

当 Query String 中的 key 重复时,context.query只取 key 第一次出现时的值,后面再出现的都会被忽略。GET /posts?category=egg&category=koa 通过 context.query拿到的值是 { category: 'egg' }。

4.1.2 queries

有时候我们的系统会设计成让用户传递相同的 key,例如 GET /posts?category=egg&id=1&id=2&id=3。针对此类情况,框架提供了 context.queries 对象,这个对象也解析了 Query String,但是它不会丢弃任何一个重复的数据,而是将他们都放到一个数组中:

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码// GET /posts?category=egg&id=1&id=2&id=3
const Controller = require('egg').Controller;

class HomeController extends Controller {
async getQueries() {
console.log(this.ctx.queries);
//result:
// {
// category: [ 'egg' ],
// id: [ '1', '2', '3' ],
// }
}
};

context.queries上所有的 key 如果有值,也一定会是数组类型。

4.2 post 请求

1
2
3
4
5
6
7
8
9
10
11
12
复制代码// 获取参数方法 post 请求


module.exports = app => {
class HomeController extends Controller {
async postObj() {
const queryObj = ctx.request.body;
ctx.body = queryObj;
}
}
return SearchController;
};

但是我们请求有时是get,有时是post,有时本来应该是post的请求,但是为了测试方便,还是做成get和post请求都支持的请求,于是一个能同时获取get和post请求参数的中间件就很有必要了.

4.3 编写中间层解决get、post请求

4.3.1 在app目录下新建middleware文件夹

4.3.2 在middleware里面新建params.js,内容如下

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码/**
* 获取请求参数中间件
* 可以使用ctx.params获取get或post请求参数
*/
module.exports = options => {
return async function params(ctx, next) {
ctx.params = {
...ctx.query,
...ctx.request.body
}
await next();
};
};

本质上就是把get请求的参数和post请求的参数都放到params这个对象里,所以,不管是get还是post都能获取到请求参数

4.3.3 在/config/config.default.js里注入中间件

1
2
3
4
5
6
7
8
9
复制代码'use strict';
module.exports = appInfo => {
const config = exports = {};
// 注入中间件
config.middleware = [
'params',
];
return config;
};

4.3.4 使用文章获取

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
复制代码/**
* 添加文章接口
*/
'use strict';
const Service = require('egg').Service;
class ArticleService extends Service {
async add() {
const { ctx } = this;
// 获取请求参数
const {
userId,
title,
content,
} = ctx.params;
const result = await ctx.model.Article.create({
userId,
title,
content,
});
return result;
}
}
module.exports = ArticleService;

4.3.5 允许post请求跨域

1
2
3
4
5
复制代码// config/plugin.js
exports.cors = {
enable: true,
package: 'egg-cors',
};
1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码 // config/config.default.js
config.security = {
csrf: {
enable: false,
ignoreJSON: true,
},
domainWhiteList: [ 'http://www.baidu.com' ], // 配置白名单
};

config.cors = {
// origin: '*',//允许所有跨域访问,注释掉则允许上面 白名单 访问
allowMethods: 'GET,HEAD,PUT,POST,DELETE,PATCH',
};

*一般性最好使用白名单,不要使用全部允许跨域,不安全

5 mysql数据库

框架提供了 egg-mysql 插件来访问 MySQL 数据库。这个插件既可以访问普通的 MySQL 数据库,也可以访问基于 MySQL 协议的在线数据库服务。

5.1 安装与配置

安装对应的插件 egg-mysql :

1
复制代码npm i --save egg-mysql

开启插件:

1
2
3
4
5
复制代码// config/plugin.js
exports.mysql = {
enable: true,
package: 'egg-mysql',
};

在 config/config.${env}.js 配置各个环境的数据库连接信息。

5.1.1 单数据源

如果我们的应用只需要访问一个 MySQL 数据库实例,可以如下配置:
使用方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
复制代码// config/config.${env}.js
exports.mysql = {
// 单数据库信息配置
client: {
// host
host: 'mysql.com',
// 端口号
port: '3306',
// 用户名
user: 'test_user',
// 密码
password: 'test_password',
// 数据库名
database: 'test',
},
// 是否加载到 app 上,默认开启
app: true,
// 是否加载到 agent 上,默认关闭
agent: false,
};
1
复制代码await app.mysql.query(sql, values); // 单实例可以直接通过 app.mysql 访问

5.1.2 多数据源

如果我们的应用需要访问多个 MySQL 数据源,可以按照如下配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
复制代码exports.mysql = {
clients: {
// clientId, 获取client实例,需要通过 app.mysql.get('clientId') 获取
db1: {
// host
host: 'mysql.com',
// 端口号
port: '3306',
// 用户名
user: 'test_user',
// 密码
password: 'test_password',
// 数据库名
database: 'test',
},
db2: {
// host
host: 'mysql2.com',
// 端口号
port: '3307',
// 用户名
user: 'test_user',
// 密码
password: 'test_password',
// 数据库名
database: 'test',
},
// ...
},
// 所有数据库配置的默认值
default: {
},
// 是否加载到 app 上,默认开启
app: true,
// 是否加载到 agent 上,默认关闭
agent: false,
};

5.2 封装增删改查

5.2.1、插入,向users表内插入一条数据

1
2
3
4
5
复制代码const result = await this.app.mysql.insert('users', {
name: 'wjw',
age: 18
})
// 判断:result.affectedRows === 1

5.2.2、查询,查询users表name=Jack的数据

1
2
3
4
5
6
7
8
9
10
11
12
复制代码const result = await this.app.mysql.select('users', {
columns: ['id', 'name'], //查询字段,全部查询则不写,相当于查询*
where: {
name: 'wjw'
}, //查询条件
orders: [
['id', 'desc'] //降序desc,升序asc
],
limit: 10, //查询条数
offset: 0 //数据偏移量(分页查询使用)
})
//判断:result.length > 0

5.2.3、修改,修改users表id=1的数据age为20

1
2
3
4
5
6
7
8
复制代码const result = await this.app.mysql.update('users', {
age: 20 //需要修改的数据
}, {
where: {
id: 1
} //修改查询条件
});
//判断:result.affectedRows === 1

5.2.4、删除,删除users表name=wjw的数据

1
2
3
复制代码const result = await this.app.mysql.delete('users', {
name: 'wjw'
})

6 Cookie 的使用

6.1 Cookie 简介

  • cookie 是存储于访问者的计算机中的变量。可以让我们用同一个浏览器访问同一个域名的时候共享数据。
  • HTTP 是无状态协议。简单地说,当你浏览了一个页面,然后转到同一个网站的另一个页面,服务器无法认识到这是同一个浏览器在访问同一个网站。每一次的访问,都是没有任何关系的。

6.2 Cookie 的设置和获取

6.2.1 Cookie 设置语法

ctx.cookies.set(key, value, options)

1
复制代码this.ctx.cookies.set('name','zhangsan');

6.2.2 Cookie 获取语法

ctx.cookies.get(key, options)

1
复制代码this.ctx.cookies.get('name')

6.2.3 清除 Cookie

1
复制代码this.ctx.cookies.set('name',null);

或者设置 maxAge 过期时间为 0

6.3 Cookie 参数 options

eggjs.org/en/core/coo…

1
2
3
4
5
6
7
复制代码ctx.cookies.set(key, value, {
maxAge:24 * 3600 * 1000,
httpOnly: true, // 默认情况下是正确的
encrypt: true, // cookie在网络传输期间加密
ctx.cookies.get('frontend-cookie', {
encrypt: true
});

6.4 设置中文 Cookie

6.4.1 第一种解决方案

1
2
3
复制代码console.log(new Buffer('hello, world!').toString('base64'));
// 转换成 base64字符串:aGVsbG8sIHdvcmxkIQ==
console.log(new Buffer('aGVsbG8sIHdvcmxkIQ==', 'base64').toString()); // 还原 base64字符串:hello, world!

6.4.2 第二种解决方案

1
2
3
4
5
复制代码ctx.cookies.set(key, value, {
maxAge:24 * 3600 * 1000,
httpOnly: true, // 默认情况下是正确的
encrypt: true, // cookie在网络传输期间进行加密
});

7 Session的使用

7.1 Session 简单介绍

session 是另一种记录客户状态的机制,不同的是 Cookie 保存在客户端浏览器中,而session 保存在服务器上。

7.2 Session 的工作流程

当浏览器访问服务器并发送第一次请求时,服务器端会创建一个 session 对象,生成一个类似于 key,value 的键值对, 然后将 key(cookie)返回到浏览器(客户)端,浏览器下次再访问时,携带 key(cookie),找到对应的 session(value)。

7.3 Egg.js 中 session 的使用

egg.js 中 session 基于 egg-session 内置了对 session 的操作

7.3.1 设置

1
2
3
4
复制代码this.ctx.session.userinfo={
name:'张三',
age:'20'
}

7.3.2 获取

1
复制代码var userinfo=this.ctx.session

7.3.3 Session 的默认设置

1
2
3
4
5
复制代码exports.session = {
key: 'EGG_SESS',
maxAge: 24 * 3600 * 1000, // 1 day httpOnly: true,
encrypt: true
};

7.4 Session 在 config.default.js 中的配置

1
2
3
4
5
复制代码config.session={
key:'SESSION_ID',
maxAge:864000,
renew: true //延长会话有效期
}

7.5 cookie 和session 区别

  • cookie 数据存放在客户的浏览器上,session 数据放在服务器上。
  • cookie 相比 session 没有 session 安全,别人可以分析存放在本地的 COOKIE 并进行 COOKIE欺骗。
  • session 会在一定时间内保存在服务器上。当访问增多,会比较占用你服务器的性能考虑到减轻服务器性能方面,应当使用 COOKIE。
  • 单个 cookie 保存的数据不能超过 4K,很多浏览器都限制一个站点最多保存 20 个 cookie。

8 定时任务&定点任务

egg提供了强大的定时任务系统。通过定时任务,可以系统修改服务的缓存数据,以便处理需要定时更新的数据。

在app/schedule目录下新建一个js文件,每一个js文件就是一个定时任务

8.1 定时任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
复制代码// app/schedule
module.exports = {
schedule: {
interval: '1m', // 1 分钟间隔
type: 'all', // 指定所有的 worker 都需要执行
},
async task(ctx) {
i++
console.log(i)
},
};

/* 注释:
1ms -> 1毫秒
1s -> 1秒
1m -> 1分钟
*/

8.2 定点任务

定点任务(以每周一的5点30分0秒更新排行榜为例)

1、使用cron参数设定时间,cron参数分为6个部分,*表示所有都满足

1
2
3
4
5
6
7
8
9
复制代码*    *    *    *    *    *
┬ ┬ ┬ ┬ ┬ ┬
│ │ │ │ │ |
│ │ │ │ │ └ 星期 (0 - 7) (0或7都是星期日)
│ │ │ │ └───── 月份 (1 - 12)
│ │ │ └────────── 日期 (1 - 31)
│ │ └─────────────── 小时 (0 - 23)
│ └──────────────────── 分钟 (0 - 59)
└───────────────────────── 秒 (0 - 59, optional)
1
2
3
4
5
6
7
8
9
10
11
复制代码// app/schedule
module.exports = {
schedule: {
cron: '0 30 5 * * 1', //每周一的5点30分0秒更新
type: 'all', // 指定所有的 worker 都需要执行
},
async task(ctx) {
i++
console.log(i)
},
};

8.3 只执行一次定时任务

设置immediate参数为true时,该定时任务会在项目启动时,立即执行一次定时任务

1
2
3
4
5
6
7
8
9
10
11
复制代码module.exports = {
schedule: {
interval: '1m', // 1 分钟间隔
type: 'all', // 指定所有的 worker 都需要执行
immediate: true, //项目启动就执行一次定时任务
},
async task(ctx) {
i++
console.log(i)
},
};

8.4 关闭任务

配置disable参数为true时,该定时任务即关闭

8.5 指定定时任务执行环境env

1
复制代码env: ["dev", "debug"] //该定时任务在开发环境和debug模式下才执行

9 部署

9.1 部署服务器

首先当然是在你的服务器上部署好node服务,然后安装好。

服务器需要预装 Node.js,框架支持的 Node 版本为 >= 8.0.0。
框架内置了 egg-cluster 来启动 Master 进程,Master 有足够的稳定性,不再需要使用 pm2 等进程守护模块。
同时,框架也提供了 egg-scripts 来支持线上环境的运行和停止。

1
复制代码egg-scripts start --port=7001 --daemon --title=egg-server-showcase
  • --port=7001 端口号,默认会读取环境变量 process.env.PORT,如未传递将使用框架内置端口 7001。
  • --daemon 是否允许在后台模式,无需 nohup。若使用 Docker 建议直接前台运行。
  • --env=prod 框架运行环境,默认会读取环境变量 process.env.EGG_SERVER_ENV, 如未传递将使用框架内置环境 prod。
  • --workers=2 框架 worker 线程数,默认会创建和 CPU 核数相当的 app worker 数,可以充分的利用 CPU 资源。
  • --title=egg-server-showcase 用于方便 ps 进程时 grep 用,默认为 egg-server-${appname}。
  • --framework=yadan 如果应用使用了可以配置 package.json 的 egg.framework 或指定该参数。
  • --ignore-stderr 忽略启动期的报错。

9.1.1 启动配置项

你也可以在 config.{env}.js 中配置指定启动配置。

1
2
3
4
5
6
7
8
9
复制代码// config/config.default.js

exports.cluster = {
listen: {
port: 7001,
hostname: '127.0.0.1',
// path: '/var/run/egg.sock',
}
}

path,port,hostname 均为 server.listen 的参数,egg-scripts 和 egg.startCluster 方法传入的 port 优先级高于此配置。
s

9.1.2 停止命令

该命令将杀死 master 进程,并通知 worker 和 agent 优雅退出。
支持以下参数:

  • --title=egg-server 用于杀死指定的 egg 应用,未传递则会终止所有的 Egg 应用。
1
2
复制代码"start": "egg-scripts start --daemon --title=${进程名称}",
"stop": "egg-scripts stop --title=${进程名称}"
  • 你也可以直接通过
1
复制代码ps -eo "pid,command" | grep -- "--title=egg-server"

来找到 master 进程,并 kill 掉,无需 kill -9。

因为egg的知识点太多,故分上下两章

导读

egg-从入门到上线 (下)

egg-mongoose专题

本文转载自: 掘金

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

ThreadLocal和ThreadLocalMap源码分析

发表于 2019-03-26

背景分析

相信很多程序猿在平常实现功能的过程当中,都会遇到想要某些静态变量,不管是单线程亦或者是多线程在使用,都不会产生相互之间的影响,也就是这个静态变量在线程之间是读写隔离的。

有一个我们经常使用的工具类,它的并发问题就是用ThreadLocal来解决的,我相信大多数人都看过,那就是SimpleDateFormat日期格式化的工具类的多线程问题,大家去网上搜的话,应该会有一堆人都说使用ThreadLocal。

定义

那究竟何谓ThreadLocal呢?通过我们的Chinese English,我们也可以翻译出来,那就是线程本地的意思,而且我们是用来存放我们需要能够线程隔离的变量的,那就是线程本地变量。也就是说,当我们把变量保存在ThreadLocal当中时,就能够实现这个变量的线程隔离了。

例子

我们先来看两个例子,这里也刚好涉及到两个概念,分别是值传递和引用传递。

  • 值传递
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
复制代码public class ThreadLocalTest {

private static ThreadLocal<Integer> threadLocal = new ThreadLocal<Integer>(){
protected Integer initialValue(){
return 0;
}
};

// 值传递
@Test
public void testValue(){
for (int i = 0; i < 5; i++){
new Thread(() -> {
Integer temp = threadLocal.get();
threadLocal.set(temp + 5);
System.out.println("current thread is " + Thread.currentThread().getName() + " num is " + threadLocal.get());
}, "thread-" + i).start();
}
}
}

以上程序的输出结果是:

1
2
3
4
5
复制代码current thread is thread-1 num is 5
current thread is thread-3 num is 5
current thread is thread-0 num is 5
current thread is thread-4 num is 5
current thread is thread-2 num is 5

我们可以看到,每一个线程打印出来的都是5,哪怕我是先通过ThreadLocal.get()方法获取变量,然后再set进去,依然不会进行重复叠加。

这就是线程隔离。

但是对于引用传递来说,我们又需要多注意一下了,直接上例子看看。

  • 引用传递
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
复制代码public class ThreadLocalTest {

static NumIndex numIndex = new NumIndex();
private static ThreadLocal<NumIndex> threadLocal1 = new ThreadLocal<NumIndex>(){
protected NumIndex initialValue(){
return numIndex;
}
};

static class NumIndex{
int num = 0;
public void increment(){
num++;
}
}

// 引用传递
@Test
public void testReference(){
for (int i = 0; i < 5; i++){
new Thread(() -> {
NumIndex index = threadLocal1.get();
index.increment();
threadLocal1.set(index);
System.out.println("current thread is " + Thread.currentThread().getName() + " num is " + threadLocal1.get().num);
}, "thread-" + i).start();
}
}
}

我们看看运行的结果

1
2
3
4
5
复制代码current thread is thread-0 num is 2
current thread is thread-2 num is 3
current thread is thread-1 num is 2
current thread is thread-4 num is 5
current thread is thread-3 num is 4

我们看到值不但没有被隔离,而且还出现了线程安全的问题。

所以我们一定要注意值传递和引用传递的区别,在这里也不讲这两个概念了。

源码分析

想要更加深入地了解ThreadLocal这个东西的作用,最后还是得回到撸源码,看看==Josh Bloch and Doug Lea==这两位大神究竟是怎么实现的?整个类加起来也不过七八百行而已。

在这里,我分开两部分来说,分别是ThreadLocal和ThreadLocalMap这两个的源码分析。

ThreadLocalMap源码分析

思而再三,最后还是决定先讲ThreadLocalMap的源码解析,为什么呢?

ThreadLocalMap是ThreadLocal里面的一个静态内部类,但是确实一个很关键的东西,我们既然是在看源码并且想要弄懂这个东西,那我们就一定要有一种思维,那就是如果是我们要实现这么个功能,我们要怎么做?以及看到别人的代码,要学会思考别人为什么要这么做?

我希望通过我的文章,不求能够带给你什么牛逼的技术,但是至少能让你明白,我们需要学习的是这些大牛的严谨的思维逻辑。

言归正传,ThreadLocalMap究竟是什么?我们要这么想,既然是线程本地变量,而且我们可以通过get和set方法能够获取和赋值。

1、那我们赋值的内容,究竟保存在什么结构当中?

2、它究竟是怎么做到线程隔离的?

3、当我get和set的时候,它究竟是怎么做到线程-value的对应关系进行保存的?

通过以上三个问题,再结合ThreadLocalMap这个名字,我想大家也知道这个是什么了。

没错,它就是ThreadLocal非常核心的内容,是维护我们线程与变量之间关系的一个类,看到是Map结尾,那我们也能够知道它实际上就是一个键值对。至于KEY是什么,我们会在源码分析当中看出来。

Entry内部类

以下源码都是抽取讲解部分的内容来展示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
复制代码static class ThreadLocalMap {

/**
* 自定义一个Entry类,并继承自弱引用
* 用来保存ThreadLocal和Value之间的对应关系
*
* 之所以用弱引用,是为了解决线程与ThreadLocal之间的强绑定关系
* 会导致如果线程没有被回收,则GC便一直无法回收这部分内容
*/
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;

Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}

/**
* The initial capacity -- MUST be a power of two.
* Entry数组的初始化大小
*/
private static final int INITIAL_CAPACITY = 16;

/**
* The table, resized as necessary.
* table.length MUST always be a power of two.
* <ThreadLocal, 保存的泛型值>数组
* 长度必须是2的N次幂
* 这个可以参考为什么HashMap里维护的数组也必须是2的N次幂
* 主要是为了减少碰撞,能够让保存的元素尽量的分散
* 关键代码还是hashcode & table.length - 1
*/
private Entry[] table;

/**
* The number of entries in the table.
* table里的元素个数
*/
private int size = 0;

/**
* The next size value at which to resize.
* 扩容的阈值
*/
private int threshold; // Default to 0

/**
* Set the resize threshold to maintain at worst a 2/3 load factor.
* 根据长度计算扩容的阈值
*/
private void setThreshold(int len) {
threshold = len * 2 / 3;
}

/**
* 通过以下两个获取next和prev的代码可以看出,entry数组实际上是一个环形结构
*/
/**
* Increment i modulo len.
* 获取下一个索引,超出长度则返回0
*/
private static int nextIndex(int i, int len) {
return ((i + 1 < len) ? i + 1 : 0);
}

/**
* Decrement i modulo len.
* 返回上一个索引,如果-1为负数,返回长度-1的索引
*/
private static int prevIndex(int i, int len) {
return ((i - 1 >= 0) ? i - 1 : len - 1);
}

/**
* 构造参数创建一个ThreadLocalMap代码
* ThreadLocal为key,我们的泛型为value
*/
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
// 初始化table的大小为16
table = new Entry[INITIAL_CAPACITY];

// 通过hashcode & (长度-1)的位运算,确定键值对的位置
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);

// 创建一个新节点保存在table当中
table[i] = new Entry(firstKey, firstValue);

// 设置table内元素为1
size = 1;

// 设置扩容阈值
setThreshold(INITIAL_CAPACITY);
}

/**
* ThreadLocal本身是线程隔离的,按道理是不会出现数据共享和传递的行为的
* 这是InheritableThreadLocal提供了了一种父子间数据共享的机制
* @param parentMap the map associated with parent thread.
*/
private ThreadLocalMap(ThreadLocalMap parentMap) {
Entry[] parentTable = parentMap.table;
int len = parentTable.length;
setThreshold(len);
table = new Entry[len];

for (int j = 0; j < len; j++) {
Entry e = parentTable[j];
if (e != null) {
@SuppressWarnings("unchecked")
ThreadLocal<Object> key = (ThreadLocal<Object>) e.get();
if (key != null) {
Object value = key.childValue(e.value);
Entry c = new Entry(key, value);
int h = key.threadLocalHashCode & (len - 1);
while (table[h] != null)
h = nextIndex(h, len);
table[h] = c;
size++;
}
}
}
}
}

一些简单的东西直接看我上面的注释就可以了。

我们可以看到,在ThreadLocalMap这个内部类当中,又定义了一个Entry内部类,并且继承自弱引用,泛型是ThreadLocal,其中有一个构造方法,通过这个我们就大致可以猜出,ThreadLocalMap当中的key实际上就是当前ThreadLocal对象。

至于为什么要用弱引用呢?我想我源码上面的注释其实也写得很明白了,这ThreadLocal实际上就是个线程本地变量隔离作用的工具类而已,当线程走完了,肯定希望能回收这部分产生的资源,所以就用了弱引用。

我相信有人会有疑问,如果在我要用的时候,被回收了怎么办?下面的代码会一步步地让你明白,你考虑到的问题,这些大牛都已经想到并且解决了。接着往下学吧!

getEntry和getEntryAfterMiss方法

通过方法名我们就能看得出是从ThreadLocal对应的ThreadLocalMap当中获取Entry节点,在这我们就要思考了。

1)我们要通过什么获取对应的Entry

2)我们通过上面知道使用了弱引用,如果被GC回收了没有获取到怎么办?

3)不在通过计算得到的下标上,又要怎么办?

4)如果ThreadLocal对应的ThreadLocalMap不存在要怎么办?

以上这4个问题是我自己在看源码的时候能够想到的东西,有些问题的答案光看THreadLocalMap的源码是看不出所以然的,需要结合之后的ThreadLocal源码分析。

在这我们来看看大牛的源码是怎么解决以上问题的吧。

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
复制代码/**      
* 获取ThreadLocal的索引位置,通过下标索引获取内容
*/
private Entry getEntry(ThreadLocal<?> key) {
// 通过hashcode确定下标
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];

// 如果找到则直接返回
if (e != null && e.get() == key)
return e;
else
// 找不到的话接着从i位置开始向后遍历,基于线性探测法,是有可能在i之后的位置找到的
return getEntryAfterMiss(key, i, e);
}

private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
Entry[] tab = table;
int len = tab.length;

// 循环向后遍历
while (e != null) {

// 获取节点对应的k
ThreadLocal<?> k = e.get();

// 相等则返回
if (k == key)
return e;

// 如果为null,触发一次连续段清理
if (k == null)
expungeStaleEntry(i);

// 获取下一个下标接着进行判断
else
i = nextIndex(i, len);
e = tab[i];
}
return null;
}

一看这两个方法名,我们就知道这两个方法就是获取Entry节点的方法。

我们首先看getEntry(ThreadLocal<?> key)和getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e)这个方法就看出来了,直接根据ThreadLocal对象来获取,所以我们可以再次证明,key就是ThreadLocal对象,我们来看看它的流程

1、首先根据key的hashcode & table.length - 1来确定在table当中的下标

2、如果获取到直接返回,没获取到的话,就接着往后遍历看是否能获取到(因为用的是线性探测法,往后遍历有可能获取到结果)

3、进入了getEntryAfterMiss方法进行线性探测,如果获取到则直接返回;获取的key为null,则触发一次连续段清理(实际上在很多方法当中都会触发该方法,经常会进行连续段清理,这是ThreadLocal核心的清理方法)。

expungeStaleEntry方法

这可以说是ThreadLocal非常核心的一个清理方法,为什么会需要清理呢?或许很多人想不明白,我们用List或者是Map也好,都没有说要清理里面的内容。

但是这里是对于线程来说的隔离的本地变量,并且使用的是弱引用,那便有可能在GC的时候就被回收了。

1)如果有很多Entry节点已经被回收了,但是在table数组中还留着位置,这时候不清理就会浪费资源

2)在清理节点的同时,可以将后续非空的Entry节点重新计算下标进行排放,这样子在get的时候就能快速定位资源,加快效率。

我们来看看别人源码是怎么做的吧!

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
复制代码/**        
* 这个函数可以看做是ThreadLocal里的核心清理函数,它主要做的事情就是
* 1、从staleSlot开始,向后遍历将ThreadLocal对象被回收所在Entry节点的value和Entry节点本身设置null,方便GC,并且size自减1
* 2、并且会对非null的Entry节点进行rehash,只要不是在当前位置,就会将Entry挪到下一个为null的位置上
* 所以实际上是对从staleSlot开始做一个连续段的清理和rehash操作
*/
private int expungeStaleEntry(int staleSlot) {
// 新的引用指向table
Entry[] tab = table;

// 获取长度
int len = tab.length;

// expunge entry at staleSlot
// 先将传过来的下标置null
tab[staleSlot].value = null;
tab[staleSlot] = null;

// table的size-1
size--;

// Rehash until we encounter null
Entry e;
int i;
// 遍历删除指定节点所有后续节点当中,ThreadLocal被回收的节点
for (i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
// 获取entry当中的key
ThreadLocal<?> k = e.get();

// 如果ThreadLocal为null,则将value以及数组下标所在位置设置null,方便GC
// 并且size-1
if (k == null) {
e.value = null;
tab[i] = null;
size--;
} else { // 如果不为null
// 重新计算key的下标
int h = k.threadLocalHashCode & (len - 1);

// 如果是当前位置则遍历下一个
// 不是当前位置,则重新从i开始找到下一个为null的坐标进行赋值
if (h != i) {
tab[i] = null;

// Unlike Knuth 6.4 Algorithm R, we must scan until
// null because multiple entries could have been stale.
while (tab[h] != null)
h = nextIndex(h, len);
tab[h] = e;
}
}
}
return i;
}

上面的代码注释我相信已经是写的很清楚了,这个方法实际上就是从staleSlot开始做一个连续段的清理和rehash操作。

set方法系列

接下来我们看看set方法,自然就是要将我们的变量保存进ThreadLocal当中,实际上就是保存到ThreadLocalMap当中去,在这里我们一样要思考几个问题。

1)如果该ThreadLocal对应的ThreadLocalMap还不存在,要怎么处理?

2)如果所计算的下标,在table当中已经存在Entry节点了怎么办?

我想通过上面部分代码的讲解,对这两个问题,大家也都比较有思路了吧。

老规矩,接下来看看代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
复制代码/**        
* ThreadLocalMap的set方法,这个方法还是挺关键的
* 通过这个方法,我们可以看出该哈希表是用线性探测法来解决冲突的
*/
private void set(ThreadLocal<?> key, Object value) {

// 新开一个引用指向table
Entry[] tab = table;

// 获取table的长度
int len = tab.length;

// 获取对应ThreadLocal在table当中的下标
int i = key.threadLocalHashCode & (len-1);

/**
* 从该下标开始循环遍历
* 1、如遇相同key,则直接替换value
* 2、如果该key已经被回收失效,则替换该失效的key
*/
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();

if (k == key) {
e.value = value;
return;
}

// 如果 k 为null,则替换当前失效的k所在Entry节点
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}

// 找到空的位置,创建Entry对象并插入
tab[i] = new Entry(key, value);

// table内元素size自增
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}


private void replaceStaleEntry(ThreadLocal<?> key, Object value,
int staleSlot) {
// 新开一个引用指向table
Entry[] tab = table;

// 获取table的长度
int len = tab.length;
Entry e;

// 记录当前失效的节点下标
int slotToExpunge = staleSlot;

/**
* 通过这个for循环的prevIndex(staleSlot, len)可以看出
* 这是由staleSlot下标开始向前扫描
* 查找并记录最前位置value为null的下标
*/
for (int i = prevIndex(staleSlot, len);
(e = tab[i]) != null;
i = prevIndex(i, len))
if (e.get() == null)
slotToExpunge = i;

/**
* 通过for循环nextIndex(staleSlot, len)可以看出
* 这是由staleSlot下标开始向后扫描
*/
for (int i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {

// 获取Entry节点对应的ThreadLocal对象
ThreadLocal<?> k = e.get();

/**
* 如果与新的key对应,直接赋值value
* 则直接替换i与staleSlot两个下标
*/
if (k == key) {
e.value = value;

tab[i] = tab[staleSlot];
tab[staleSlot] = e;

// Start expunge at preceding stale entry if it exists
// 通过注释看出,i之前的节点里,没有value为null的情况
if (slotToExpunge == staleSlot)
slotToExpunge = i;

/**
* 在调用cleanSomeSlots进行启发式清理之前
* 会先调用expungeStaleEntry方法从slotToExpunge到table下标所在为null的连续段进行一次清理
* 返回值便是table[]为null的下标
* 然后以该下标--len进行一次启发式清理
* 最终里面的方法实际上还是调用了expungeStaleEntry
* 可以看出expungeStaleEntry方法是ThreadLocal核心的清理函数
*/
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
return;
}

/**
* 如果当前下标所在已经失效,并且向后扫描过程当中没有找到失效的Entry节点
* 则slotToExpunge赋值为当前位置
*/
if (k == null && slotToExpunge == staleSlot)
slotToExpunge = i;
}

// If key not found, put new entry in stale slot
// 如果并没有在table当中找到该key,则直接在当前位置new一个Entry
tab[staleSlot].value = null;
tab[staleSlot] = new Entry(key, value);

/**
* 在上面的for循环探测过程当中
* 如果发现任何无效的Entry节点,则slotToExpunge会被重新赋值
* 就会触发连续段清理和启发式清理
*/
if (slotToExpunge != staleSlot)
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}


/**
* 启发式地清理被回收的Entry
* i对应的Entry是非无效的,有可能是失效被回收了,也有可能是null
* 会有两个地方调用到这个方法
* 1、set方法,在判断是否需要resize之前,会清理并rehash一遍
* 2、替换失效的节点时候,也会进行一次清理
*/
private boolean cleanSomeSlots(int i, int n) {
boolean removed = false;
Entry[] tab = table;
int len = tab.length;
do {
i = nextIndex(i, len);
Entry e = tab[i];
// Entry对象不为空,但是ThreadLocal这个key已经为null
if (e != null && e.get() == null) {
n = len;
removed = true;

/**
* 调用该方法进行回收
* 实际上不是只回收 i 这一个节点而已
* 而是对 i 开始到table所在下标为null的范围内,对那些节点都进行一次清理和rehash
*/
i = expungeStaleEntry(i);
}
} while ( (n >>>= 1) != 0);
return removed;
}


/**
* 对table进行扩容,因为要保证table的长度是2的幂,所以扩容就扩大2倍
*/
private void resize() {

// 获取旧table的长度,并且创建一个长度为旧长度2倍的Entry数组
Entry[] oldTab = table;
int oldLen = oldTab.length;
int newLen = oldLen * 2;
Entry[] newTab = new Entry[newLen];

// 记录插入的有效Entry节点数
int count = 0;

/**
* 从下标0开始,逐个向后遍历插入到新的table当中
* 1、如遇到key已经为null,则value设置null,方便GC回收
* 2、通过hashcode & len - 1计算下标,如果该位置已经有Entry数组,则通过线性探测向后探测插入
*/
for (int j = 0; j < oldLen; ++j) {
Entry e = oldTab[j];
if (e != null) {
ThreadLocal<?> k = e.get();
if (k == null) {
e.value = null; // Help the GC
} else {
int h = k.threadLocalHashCode & (newLen - 1);
while (newTab[h] != null)
h = nextIndex(h, newLen);
newTab[h] = e;
count++;
}
}
}

// 重新设置扩容的阈值
setThreshold(newLen);

// 更新size
size = count;

// 指向新的Entry数组
table = newTab;
}

以上的代码就是调用set方法往ThreadLocalMap当中保存K-V关系的一系列代码,我就不分开再一个个讲了,这样大家看起来估计也比较方便,有连续性。

我们可以来看看一整个的set流程:

1、先通过hashcode & (len - 1)来定位该ThreadLocal在table当中的下标

2、for循环向后遍历

1)如果获取Entry节点的key与我们需要操作的ThreadLocal相等,则直接替换value

2)如果遍历的时候拿到了key为null的情况,则调用replaceStaleEntry方法进行与之替换。

3、如果上述两个情况都是,则直接在计算的出来的下标当中new一个Entry阶段插入。

4、进行一次启发式地清理并且如果插入节点后的size大于扩容的阈值,则调用resize方法进行扩容。

remove方法

既然是Map形式进行存储,我们有put方法,那肯定就会有remove的时候,任何一种数据结构,肯定都得符合增删改查的。

我们直接来看看代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
复制代码/**
* Remove the entry for key.
* 将ThreadLocal对象对应的Entry节点从table当中删除
*/
private void remove(ThreadLocal<?> key) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
if (e.get() == key) {
// 将引用设置null,方便GC
e.clear();

// 从该位置开始进行一次连续段清理
expungeStaleEntry(i);
return;
}
}
}

我们可以看到,remove节点的时候,也会使用线性探测的方式,当找到对应key的时候,就会调用clear将引用指向null,并且会触发一次连续段清理。

我相信通过以上对ThreadLocalMap的源码分析,已经让大家对其有了个基本的概念认识,相信对大家理解ThreadLocal这个概念的时候,已经不是停留在知道它就是为了实现线程本地变量而已了。

那接下来我们来看看ThreadLocal的源码分析吧。

ThreadLocal源码分析

ThreadLocal的源码相对于来说就简单很多了,因为主要都是ThreadLocalMap这个内部类在干活,在管理我们的本地变量。

get方法系列

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
复制代码/**     
* 获取当前线程本地变量的值
*/
public T get() {
// 获取当前线程
Thread t = Thread.currentThread();

// 获取当前线程对应的ThreadLocalMap
ThreadLocalMap map = getMap(t);

// 如果map不为空
if (map != null) {

// 如果当前ThreadLocal对象对应的Entry还存在
ThreadLocalMap.Entry e = map.getEntry(this);

// 并且Entry不为null,返回对应的值,否则都执行setInitialValue方法
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
// 如果该线程对应的ThreadLocalMap还不存在,则执行初始化方法
return setInitialValue();
}


ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}


private T setInitialValue() {
// 获取初始值,一般是子类重写
T value = initialValue();

// 获取当前线程
Thread t = Thread.currentThread();

// 获取当前线程对应的ThreadLocalMap
ThreadLocalMap map = getMap(t);

// 如果map不为null
if (map != null)

// 调用ThreadLocalMap的set方法进行赋值
map.set(this, value);

// 否则创建个ThreadLocalMap进行赋值
else
createMap(t, value);
return value;
}


/**
* 构造参数创建一个ThreadLocalMap代码
* ThreadLocal为key,我们的泛型为value
*/
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
// 初始化table的大小为16
table = new Entry[INITIAL_CAPACITY];

// 通过hashcode & (长度-1)的位运算,确定键值对的位置
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);

// 创建一个新节点保存在table当中
table[i] = new Entry(firstKey, firstValue);

// 设置table内元素为1
size = 1;

// 设置扩容阈值
setThreshold(INITIAL_CAPACITY);
}

ThreadLocal的get方法也不难,就几行代码,但是当它结合了ThreadLocalMap的方法后,这整个逻辑就值得我们深入研究写这个工具的人的思维了。

我们来看看它的一个流程吧。

1、获取当前线程,根据当前线程获取对应的ThreadLocalMap

2、在ThreadLocalMap当中获取该ThreadLocal对象对应的Entry节点,并且返回对应的值

3、如果获取到的ThreadLocalMap为null,则证明还没有初始化,就调用setInitialValue方法

1)在调用setInitialValue方法的时候,会双重保证,再进行获取一次ThreadLocalMap

2)如果依然为null,就最终调用ThreadLocalMap的构造方法

set方法系列

在这里我也不对ThreadLocal的set方法做太多介绍了,结合上面的ThreadLocalMap的set方法,我想就可以对上面每个方法思考出的问题有个大概的答案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码public void set(T value) {
// 获取当前线程
Thread t = Thread.currentThread();

// 获取线程所对应的ThreadLocalMap,从这可以看出每个线程都是独立的
ThreadLocalMap map = getMap(t);

// 如果map不为空,则k-v赋值,看出k是this,也就是当前ThreaLocal对象
if (map != null)
map.set(this, value);

// 如果获取的map为空,则创建一个并保存k-v关系
else
createMap(t, value);
}

其实ThreadLocal的set方法很简单的,最主要的都是调用了ThreadLocalMap的set方法,里面才是真正核心的执行流程。

不过我们照样来看看这个流程:

1、获取当前线程,根据当前线程获取对应的ThreadLocalMap

2、如果对应的ThreadLocalMap不为null,则调用其的set方法保存对应关系

3、如果map为null,就最终调用ThreadLocalMap的构造方法创建一个ThreadLocalMap并保存对应关系

执行流程总结

在这里插入图片描述

源码分析总结

上面通过对ThreadLocal和ThreadLocalMap两个类的源码进行了分析,我想对于ThreadLocal这个功能的一整个流程,大家都有了个比较清楚的了解了。我真的是很佩服==Josh Bloch and Doug Lea==这两位大神,他们在实现这个东西的时候,不是说光实现了就可以了,考虑了很多情况,例如:GC问题、如何维护好数据存储的问题以及线程与本地变量之间应该以何种方式建立对应关系。

他们写的代码逻辑非常之严谨,看到这区区几百行的代码,才真正地发现,我们其实主要不是在技术上与别人的差距,而是在功能实现的一整套思维逻辑上面就与他们有着巨大的差距,最明显的一点就是,我们单纯是为了实现而实现,基本上不会考虑其他异常情况,更加不会考虑到一些GC问题。

所以通过该篇源码的分析,让我真正地意识到,我们不能光是看源码做翻译而已,我们一定要学会他们是如何思考实现这么个功能,我们要学会他们思考每一个功能的逻辑。

本文转载自: 掘金

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

工程实践:如何规范地打印程序日志?

发表于 2019-03-22

工程实践:如何规范地打印程序日志?

很久之前,有个朋友问我,如果一个老项目让你接手去进行后续维护,你会先从哪里入手、让自己更快地上手项目?当时我没有特别正面去回答这个朋友的问题,我说:一个老项目是否容易上手,一个非常关键的地方就是这个项目的日志是否打得足够好。因为通常来说,一个老项目相对比较稳定了,后续大概率不会有比较大的变更和改动,那么对于这样的项目,核心就是“维稳”。但是任何人都无法保证项目在线上运行时不会出线上故障,在出现线上问题或者故障时,如何快速止损就是第一要义,而日志在止损过程中就扮演着非常重要的角色。日志打的足够明了清晰,可以帮助开发和运维人员快速定位问题,继而决定采取何种方案进行止损。

今天就让我们一起来聊一聊如何把项目程序日志打“好”。以下是本文大纲目录:

一.为何需要规范地打印程序日志?

二.如何规范地打印程序日志?

若有不正之处请多多谅解,并欢迎批评指正。

请尊重作者劳动成果,转载请标明原文链接:

https://www.cnblogs.com/dolphin0520/p/10396894.html

一.为何需要规范地打印程序日志?

我们平时在写程序代码过程中,一般会把主要精力集中在功能实现上,往往会忽视日志的重要性,然而日志在系统上线后是极其重要的,因为系统上线后,只有通过日志才能了解当前系统的运行状态,在出现线上故障时,日志是否足够清晰明了决定了是否能够快速找到止损方案。我们可以看一下下面这段代码:

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
复制代码public class HttpClient {
private static final Logger LOG = LoggerFactory.getLogger(HttpClient.class);

private static int CONNECT_TIMEOUT = 5000; // unit ms
private static int READ_TIMEOUT = 10000; // unit ms

public static String sendPost(String url, String param) {
OutputStream out = null;
BufferedReader in = null;
String result = "";
try {
URL realUrl = new URL(url);
URLConnection conn = realUrl.openConnection();
conn.setDoInput(true);
conn.setDoOutput(true);
conn.setConnectTimeout(CONNECT_TIMEOUT);
conn.setReadTimeout(READ_TIMEOUT);
conn.setRequestProperty("charset", "UTF-8");
out = new PrintWriter(conn.getOutputStream());
out.print(parm);
out.flush();
in = new BufferedReader(new InputStreamReader(conn.getInputStream()));
String line;
while ((line = in.readLine()) != null) {
result += line;
}
} catch (Exception ex) {
LOG.error("post request error!!!");
} finally {
try {
if (out != null) {
out.close();
}
if (in != null) {
in.close();
}
} catch (IOException ex) {
LOG.error("close stream error!!!");
}
return result;
}
}
}

某一天线上突然大量http请求失败,然后查看日志,发现了大量的“post request error!!!”错误,此时假如看到这样的日志你可能完全不知道究竟是什么原因导致的,还得继续通过一些其他的手段来定位具体原因。

假如打印的错误日志是这样的:

1
2
3
4
5
6
7
8
复制代码post request error!!!, url:[http://www.123.test.com], param:[name=jack]
java.net.ConnectException: Connection refused
at java.net.PlainSocketImpl.socketConnect(Native Method)
at java.net.AbstractPlainSocketImpl.doConnect(AbstractPlainSocketImpl.java:339)
at java.net.AbstractPlainSocketImpl.connectToAddress(AbstractPlainSocketImpl.java:200)
at java.net.AbstractPlainSocketImpl.connect(AbstractPlainSocketImpl.java:182)
at java.net.SocksSocketImpl.connect(SocksSocketImpl.java:392)
at java.net.Socket.connect(Socket.java:579)

那么便能很快地断定是下游http服务问题导致的,且下游http服务域名为www.123.test.com(Connection refused通常是由于下游服务端口未启动引起的),可以迅速找相应的人员进行止损,避免在故障定位阶段耗费大量的时间。

上面举的例子只是一个非常小的例子,实际日常开发中可能碰到的线上问题比这个更加复杂和棘手,总结来看,日志的主要作用有以下几点:

1)日志是系统运行的“照妖镜”,通过它能够实时反映系统的运行状态;

如上图所示,系统A中producer不断产生数据放入到data queue中,sender不断从data queue中取数据发送给下游系统B的receiver,那么对于系统A来说,data queue中的待发送数据量便是一个非常关键的指标,它能够从侧面真实反应当前系统的运行状况,如果data queue中的element个数超过容量的90%了,那么标志着此时系统可能运行不正常了,会有队列堵塞的风险;如果data queue中的element个数不到容量的10%,那么标志着此时系统运行比较正常,出现队列堵塞的风险较低。

如果这个指标没有输出到日志中,开发和运维人员是无法确切知道当前系统A的运行状态的(当然也有其他的方式来获取这个指标,比如通过http接口暴露出来也是一种方式之一)。

2)良好的日志便于后期运维和开发人员迅速定位线上问题,加快止损速度,减少系统故障带来的损失;

3)日志还有另外一个作用便是能够无缝与监控系统结合,通过监控系统进行日志采集,拿到系统运行的相关性能指标,有利于分析系统的性能瓶颈、提前规避风险;

举例说明:

假如有一个商城系统,在初期,数据库通过2台服务器提供服务(1台master,1台slave),此时大部分接口能在秒级内响应用户请求。随着时间的推移,商城系统的用户量逐渐增多,并发查询和写入量都出现了一定的增长,数据库中的数据量也慢慢增多,导致部分sql语句查询越来越慢,突然有一天,数据库的slave机器由于过多的慢查询导致被拖垮,彻底宕机了,导致商城服务不可用。

如果商城系统在日志中记录了每个http请求的耗时情况,通过监控系统配置日志采集,同时配置相应的报警,那么便能提前发现由于业务增长带来的系统性能瓶颈,提前进行系统优化(如机器扩容、sql语句优化、分库分表等),规避风险。

4)便于统计与业务相关的指标数据,进行相关业务分析和功能优化。

举例说明:

比如一个搜索系统,想统计过去一周不同地域(如南北地域)的搜索使用占比,如果日志中本身打印了每个搜索query请求的ip,则很容易统计,否则需要重新上线加日志才能统计。

因此,大家在日常编写代码过程中要注重日志书写的规范性,让它发挥出它应有的价值,在辅助保障我们服务稳定运行的同时,能够有效提升后期系统维护效率。

二.如何规范地打印程序日志?

接下来,我们从以下几个方面来谈谈如何规范地打印日志。

2.1 日志文件命名

通常来说日志文件的命名可包括以下几个关键信息:

1
2
3
4
复制代码类型标识(logTypeName)
日志级别(logLevel)
日志生成时间(logCreateTime)
日志备份编号(logBackupNum)

类型标识:指此日志文件的功能或者用途,比如一个web服务,记录http请求的日志通常命名为request.log或者access.log,request、access就是类型标识,而java的gc日志通常命名为gc.log,这样看一目了然;而通常用来记录服务的整体运行的日志一般用服务名称(serviceName、appKey)或者机器名(hostName)来命名,如 nginx.log;

日志级别:打印日志的时候直接通过文件来区分级别是一种比较推荐的方式,如果把所有级别的日志打到同一个日志文件中,在定位问题时,还需要去文件中进行查找操作,相对繁琐。日志级别一般包括DEBUG、INFO、WARN、ERROR、FATAL这五个级别,在实际编写代码中,可以采取严格匹配模式或者非严格匹配模式,严格匹配模式即INFO日志文件中只打印INFO日志,ERROR日志文件只打印ERROR日志;非严格匹配模式即INFO日志文件可以打印INFO日志、WARN日志、ERROR日志、FATAL日志,WARN日志文件可以打印WARN日志、ERROR日志、FATAL日志,以此类推。

日志生成时间:即在日志文件名称中附带上日志文件创建的时间,方便在查找日志文件时进行排序;

日志备份编号:当进行日志切割时,如果是以文件大小进行滚动,此时可以在日志文件名称末尾加上编号;

2.2 日志滚动

  虽然日志中能够保存系统运行时的关键信息,但是由于磁盘空间有限,所以我们不能无限制地保留日志,因此必须有日志滚动策略。日志滚动通常有以下几种模式:

  第一种:按照时间滚动

  第二种:按照单个日志文件大小滚动

  第三种:同时按照时间和单个日志文件大小滚动。

  • 按照时间滚动,即每隔一定的时间建立一个新的日志文件,通常可以按照小时级别滚动或者天级别滚动,具体采取哪种方式取决于系统日志的打印量。如果系统日志比较少,可以采取天级别滚动;而如果系统日常量比较大,则建议采取小时级别滚动。
  • 按照单个日志文件大小滚动,即每当日志文件达到一定大小则建立一个新的日志文件,通常建议单个日志文件大小不要超过500M,日志文件过大的话,对于日志监控或者问题定位排查都可能会造成一定影响。
  • 按照时间和单个日志文件大小滚动,这种模式通常适用于希望保留一定时间的日志,但是又不希望单个日志文件过大的场景。比如logback就提供了这种配置模式,可参考:logback.qos.ch/manual/appe…

  对于日志滚动策略来说,有2个比较关键的参数:最大保留日志数量和最大磁盘占用空间。这2个参数切记一定要设置,如果没有设置,则很有可能会出现把线上机器磁盘打满的情况。

2.3 日志级别

  日志的级别通常有以下几种:

  debug/trace、info、warning、error、fatal

  这几种日志级别的严重程序依次递增:

  debug/trace:debug和trace级别的日志由于打印内容较多,所以通常情况下不适用于线上生产环境使用,一般使用于前期线下环境调试。即使线上环境要使用,也需要通过开关来控制,只在定位追踪线上问题时才开启;

  info:info日志一般用来记录系统运行的关键状态、关键业务逻辑或者关键执行节点。但切记一点,info日志绝不可滥用,如果info日志滥用,则和debug/trace日志没有太大区别了。

  warning:warning日志一般用来记录系统运行时的一些非预期情况,顾名思义,是作为一种警示,提醒开发和运维人员需要关注,但是不用人为介入立刻去处理的。

  error:error日志一般用来记录系统运行时的一些普通错误,这些错误一旦出现,则表示已经影响了用户的正常访问或者使用,通常意味着需要人为介入处理。但很多时候在生产环境中,也不一定是出现error日志就需要人工立即介入处理的,通常会结合error日志的数量以及持续时间来进行综合判断。

  fatal:属于系统致命错误,一般出现意味着系统基本等于挂掉了,需要人工立即介入处理。

  下面举个简单的例子来说明,假如我们有这样一个场景,我们有一个工资计算系统,每隔月1号需要从员工考勤系统获取公司所有员工的考勤数据,然后根据考勤数据来计算上月应发工资,那么需要有一个函数从考勤系统获取员工考勤数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
复制代码public Map<Long, Double> getEmployeeWorkDaysFromAttendance(int year, int month, Set<Long> employeeList) throws BusiessException {
// 入口关键日志,需要打印关键的参数,因为employeeList可能数量较大,所以次数没有直接打印employeeList列表内容,只打印了size
logger.info("get employee work days, year:{}, month:{}, employeeList.size:{}", year, month, employeeList.size());

// 如果需要临时检验员工列表,可以把debug日志开关打开
if (debugOpen()) {
logger.debug("employ list content:{}", JSON.toJsonString(employeeList));
}

int retry = 1;
while (retry <= MAX_RETRY_TIMES) {
try {
Map<Long, Double> employeeWorkDays = employeeAttendanceRPC.getEmployeeWorkDays(year, month, employeeList);
logger.info("get employee work days success, year:{}, month:{}, employeeList.size:{}, employeeWorkDays.size:{}", year, month, employeeList.size(), employeeWorkDays.size());
return employeeWorkDays;
} catch (Exception ex) {
logger.warning("rpc invoke failed(employeeAttendanceRPC.getEmployeeWorkDays), retry times:{}, year:{}, month:{}, employeeList.size:{}", retry, year, month, employeeList.size(), ex);

// 连续重试失败之后,向上跑出异常
// 对于没有异常机制的语言,此处应该打印error日志
if (retry == MAX_RETRY_TIMES) {
throw new BusiessException(ex, "rpc invoke failed(employeeAttendanceRPC.getEmployeeWorkDays)");
}
}
retry++;
}
}

2.4 日志打印时机的选择

  由于日志是为了方便我们了解系统当前的运行状况以及定位线上问题,所以日志打印的时机非常重要,如果滥用日志,则会导致日志内容过多,影响问题定位的效率;如果日志打印过少,则容易导致缺少关键日志,导致在线上定位问题时找不到问题根音。因此把握日志打印的时机至关重要,以下是常见的适合打印日志的时机:

1)http调用或者rpc接口调用

  在程序调用其他服务或者系统的时候,需要打印接口调用参数和调用结果(成功/失败)。

2)程序异常

  在程序出现exception的时候,要么选择向上抛出异常,要么必须在catch块中打印异常堆栈信息。不过需要注意的是,最好不要重复打印异常日志,比如在catch块里既向上抛出了异常,又去打印错误日志(对外rpc接口函数入口处除外)。

3)特殊的条件分支

  程序进入到一些特殊的条件分支时,比如特殊的else或者switch分支。比如我们根据工龄计算薪资:

1
2
3
4
5
6
7
复制代码 public double calSalaryByWorkingAge(int age) {
if (age < 0) {
logger.error("wrong age value, age:{}", age);
return 0;
}
// ..
}

  理论上工龄不可能小于0,所以需要打印出这种非预期情况,当然通过抛出异常的方式也是可行的。

4)关键执行路径及中间状态

  在一些关键的执行路径以及中间状态也需要记录下关键日志信息,比如一个算法可能分为很多步骤,每隔步骤的中间输出结果是什么,需要记录下来,以方便后续定位跟踪算法执行状态。

5)请求入口和出口

  在函数或者对外接口的入口/出口处需要打印入口/出口日志,一来方便后续进行日志统计,同时也更加方便进行系统运行状态的监控。

2.5 日志内容与格式

  日志打印时机决定了能够根据日志去进行问题定位,而日志的内容决定了是否能够根据日志快速找出问题原因,因此日志内容也是至关重要的。通常来说,一行日志应该至少包括以下几个组成部分:

  logTag、param、exceptionStacktrace

  logTag为日志标识,用来标识此日志输出的场景或者原因,param为函数调用参数,exceptionStacktrace为异常堆栈。举例说明:

  • good case
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
复制代码public class HttpClient {
private static final Logger LOG = LoggerFactory.getLogger(HttpClient.class);

private static int CONNECT_TIMEOUT = 5000; // unit ms
private static int READ_TIMEOUT = 10000; // unit ms

public static String sendPost(String url, String param) {
OutputStream out = null;
BufferedReader in = null;
String result = "";
try {
URL realUrl = new URL(url);
URLConnection conn = realUrl.openConnection();
conn.setDoInput(true);
conn.setDoOutput(true);
conn.setConnectTimeout(CONNECT_TIMEOUT);
conn.setReadTimeout(READ_TIMEOUT);
conn.setRequestProperty("charset", "UTF-8");
out = new PrintWriter(conn.getOutputStream());
out.print(parm);
out.flush();
in = new BufferedReader(new InputStreamReader(conn.getInputStream()));
String line;
while ((line = in.readLine()) != null) {
result += line;
}
} catch (Exception ex) {
// 有关键logTag,有参数信息,有错误堆栈
LOG.error("post request error!!!, url:[[}], param:[{}]", url, param, ex);
} finally {
try {
if (out != null) {
out.close();
}
if (in != null) {
in.close();
}
} catch (IOException ex) {
LOG.error("close stream error!!!, url:[[}], param:[{}]", url, param, ex);
}
return result;
}
}
}
  • bad case
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
复制代码public class HttpClient {
private static final Logger LOG = LoggerFactory.getLogger(HttpClient.class);

private static int CONNECT_TIMEOUT = 5000; // unit ms
private static int READ_TIMEOUT = 10000; // unit ms

public static String sendPost(String url, String param) {
OutputStream out = null;
BufferedReader in = null;
String result = "";
try {
URL realUrl = new URL(url);
URLConnection conn = realUrl.openConnection();
conn.setDoInput(true);
conn.setDoOutput(true);
conn.setConnectTimeout(CONNECT_TIMEOUT);
conn.setReadTimeout(READ_TIMEOUT);
conn.setRequestProperty("charset", "UTF-8");
out = new PrintWriter(conn.getOutputStream());
out.print(parm);
out.flush();
in = new BufferedReader(new InputStreamReader(conn.getInputStream()));
String line;
while ((line = in.readLine()) != null) {
result += line;
}
} catch (Exception ex) {
// 没有任何错误信息
LOG.error("post request error!!!");
} finally {
try {
if (out != null) {
out.close();
}
if (in != null) {
in.close();
}
} catch (IOException ex) {
LOG.error("close stream error!!!");
}
return result;
}
}
}

  另外,对于对外http接口或者rpc接口,最好对于每个请求都有requestId,以便跟踪每个请求后续所有的执行路径。

  

参考文章:

zhuanlan.zhihu.com/p/27363484

blog.csdn.net/zollty/arti…

www.jianshu.com/p/59cd61eb9…

www.jianshu.com/p/6149463ae…

blog.jobbole.com/56574/

www.cnblogs.com/kofxxf/p/37…

gitbook.cn/books/5ae68…

www.cnblogs.com/xybaby/p/79…

blog.didispace.com/cxy-wsm-zml…

www.kancloud.cn/digest/java…

blog.csdn.net/bad_yu/arti…

本文转载自: 掘金

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

Gson源码解析和它的设计模式

发表于 2019-03-22

前言

之前一段时间,准备把糗百的项目中json解析的模块中的原生Json解析换成gson解析,工作比较繁杂,坑多,因此为了防止出错,我还对Gson做了一个源码分析。这一篇就是Gson源码分析的总结,同时对Gson内部运用的设计模式也进行了总结,相信了解了它的源码和运行机制,对于使用Gson的使用会更有帮助。

Gson简介

Gson,就是帮助我们完成序列化和反序列化的工作的一个库。

  • 日常用法
1
2
3
4
5
复制代码        
UserInfo userInfo = getUserInfo();
Gson gson = new Gson();
String jsonStr = gson.toJson(userInfo); // 序列化
UserInfo user = gson.fromJson(jsonStr,UserInfo.class); // 反序列化

实际上我们用的最多的是Gson的反序列化,主要在解析服务器返回的json串。因此,后面的文章也会以Gson中的反序列化的过程为主来分析代码。

在分析之前,我们先做个简单的猜想,要如何实现反序列化的流程的,Gson大体会做一下这三件事:

  • 反射创建该类型的对象
  • 把json中对应的值赋给对象对应的属性
  • 返回该对象。

事实上,Gson想要把json数据反序列化基本都逃不掉这三个步骤,但是这三个步骤就像小品里分三步把大象装进冰箱一样。我们知道最复杂的一步就是把大象装进去,毕竟,开冰箱门或者关冰箱门大家都会的嘛。在Gson中,复杂的就是怎样把json中对应数据放入对应的属性中。而这个问题的答案就是Gson的TypeAdapter。

Gson核心:TypeAdapter

TypeAdapter是Gson的核心,它的意思是类型适配器,而说到适配器,大家都会想到适配器模式,没错,这个TypeAdapter的设计这确实是一个适配器模式,因为Json数据接口和Type的接口两者是无法兼容,因此TypeAdapter就是来实现兼容,把json数据读到Type中,把Type中的数据写入到Json里。

1
2
3
4
5
6
7
8
9
复制代码public abstract class TypeAdapter<T> {
// JsonWriter代表Json数据,T则是对应的Type的对象
public abstract void write(JsonWriter out, T value) throws IOException;
// JsonWriter代表Json数据,T则是对应的Type的对象
public abstract T read(JsonReader in) throws IOException;
...
...
...
}

简单而言,TypeAdapter的作用就是针对Type进行适配,保证把json数据读到Type中,或者把Type中的数据写入到Json里

Type和TypeAdapter的对应关系

Gson会为每一种类型创建一个TypeAdapter,同样的,每一个Type都对应唯一一个TypeAdapter

而所有Type(类型),在Gson中又可以分为基本类型和复合类型(非基本类型)

  • 基本类型(Integer,String,Uri,Url,Calendar…):这里的基本类型不仅包括Java的基本数据类型,还有很多其他的数据类型
  • 复合类型(非基本类型):即除了基本类型之外的类型,往往是我们自定义的一些业务相关的JavaBean,比如User,Article…..等等。

这里的基本类型和复合类型(非基本类型)是笔者定义的词汇,因为这样定义对于读者理解Gson源码和运行机制更有帮助。

如上图,每一种基本类型都会创建一个TypeAdapter来适配它们,而所有的复合类型(即我们自己定义的各种JavaBean)都会由ReflectiveTypeAdapter来完成适配

TypeAdapter和Gson运行机制

既然讲到了每种Type都有对应的TypeAdapter,那么为什么说TypeAdapter是Gson的核心呢?我们可以看看Gson到底是如何实现Json解析的呢,下图是Gson完成json解析的抽象简化的流程图:

如上图,如果是基本类型,那么对应的TypeAdapter就可以直接读写Json串,如果是复合类型,ReflectiveTypeAdapter会反射创建该类型的对象,并逐个分析其内部的属性的类型,然后重复上述工作。直至所有的属性都是Gson认定的基本类型并完成读写工作。

TypeAdapter源码分析

当类型是复合类型的时候,Gson会创建ReflectiveTypeAdapter,我们可以看看这个Adapter的源码:

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
复制代码// 创建ReflectiveTypeAdapter
new Adapter<T>(constructor, getBoundFields(gson, type, raw));

...
...

/**
* ReflectiveTypeAdapter是ReflectiveTypeAdapterFactory的内部类,其实际的类名就是Adapter
* 本文只是为了区别其他的TypeAdapter而叫它 ReflectiveTypeAdapter
**/
public static final class Adapter<T> extends TypeAdapter<T> {
// 该复合类型的构造器,用于反射创建对象
private final ObjectConstructor<T> constructor;
// 该类型内部的所有的Filed属性,都通过map存储起来
private final Map<String, BoundField> boundFields;

Adapter(ObjectConstructor<T> constructor, Map<String, BoundField> boundFields) {
this.constructor = constructor;
this.boundFields = boundFields;
}

//JsonReader是Gson封装的对Json相关的操作类,可以依次读取json数据
// 类似的可以参考Android封装的对XML数据解析的操作类XmlPullParser
@Override public T read(JsonReader in) throws IOException {
if (in.peek() == JsonToken.NULL) {
in.nextNull();
return null;
}

T instance = constructor.construct();

try {
in.beginObject(); // 从“{”开始读取
while (in.hasNext()) {
String name = in.nextName(); //开始逐个读取json串中的key
BoundField field = boundFields.get(name); // 通过key寻找对应的属性
if (field == null || !field.deserialized) {
in.skipValue();
} else {
field.read(in, instance); // 将json串的读取委托给了各个属性
}
}
} catch (IllegalStateException e) {
throw new JsonSyntaxException(e);
} catch (IllegalAccessException e) {
throw new AssertionError(e);
}
in.endObject(); // 到对应的“}”结束
return instance;
}
...
...
}

Gson内部并没有ReflectiveTypeAdapter这个类,它其实际上是ReflectiveTypeAdapterFactory类一个名叫Adapter的内部类,叫它ReflectiveTypeAdapter是为了表意明确。

我们看到,ReflectiveTypeAdapter内部会首先创建该类型的对象,然后遍历该对象内部的所有属性,接着把json传的读去委托给了各个属性。

被委托的BoundField内部又是如何做的呢?BoundField这个类,是对Filed相关操作的封装,我们来看看BoundField是如何创建的,以及内部的工作原理。

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
复制代码// 创建ReflectiveTypeAdapter getBoundFields获取该类型所有的属性
new Adapter<T>(constructor, getBoundFields(gson, type, raw));

...
...


private Map<String, BoundField> getBoundFields(Gson context, TypeToken<?> type, Class<?> raw) {
// 创建一个Map结构,存放所有的BoundField
Map<String, BoundField> result = new LinkedHashMap<String, BoundField>();
if (raw.isInterface()) {
return result;
}

Type declaredType = type.getType();
while (raw != Object.class) { // 如果类型是Object则结束循环
Field[] fields = raw.getDeclaredFields(); // 获取该类型的所有的内部属性
for (Field field : fields) {
boolean serialize = excludeField(field, true);
boolean deserialize = excludeField(field, false);
if (!serialize && !deserialize) {
continue;
}
accessor.makeAccessible(field);
Type fieldType = $Gson$Types.resolve(type.getType(), raw, field.getGenericType());
List<String> fieldNames = getFieldNames(field); // 获取该Filed的名字(Gson通过注解可以给一个属性多个解析名)
BoundField previous = null;
for (int i = 0, size = fieldNames.size(); i < size; ++i) {
String name = fieldNames.get(i);
// 多个解析名,第一作为默认的序列化名称
if (i != 0) serialize = false; // only serialize the default name
// 创建BoundField
BoundField boundField = createBoundField(context, field, name,
TypeToken.get(fieldType), serialize, deserialize);
// 将BoundField放入Map中,获取被替换掉的value(如果有的话)
BoundField replaced = result.put(name, boundField);
// 做好记录
if (previous == null) previous = replaced;
}
if (previous != null) {
// 如果previous != null证明出现了两个相同的Filed name,直接抛出错误
// 注:Gson不允许定义两个相同的名称的属性(父类和子类之间可能出现)
throw new IllegalArgumentException(declaredType
+ " declares multiple JSON fields named " + previous.name);
}
}
type = TypeToken.get($Gson$Types.resolve(type.getType(), raw, raw.getGenericSuperclass()));
raw = type.getRawType(); // 获取父类类型,最终会索引到Object.因为Object是所有对象的父类
}
return result;
}

上面这段代码的主要工作就是,找到该类型内部的所有属性,并尝试逐一封装成BoundField。

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
复制代码// 根据每个Filed创建BoundField(封装Filed读写操作)
private ReflectiveTypeAdapterFactory.BoundField createBoundField(
final Gson context, final Field field, final String name,
final TypeToken<?> fieldType, boolean serialize, boolean deserialize) {
// 是否是原始数据类型 (int,boolean,float...)
final boolean isPrimitive = Primitives.isPrimitive(fieldType.getRawType());
...
...
if (mapped == null){
// Gson尝试获取该类型的TypeAdapter,这个方法我们后面也会继续提到。
mapped = context.getAdapter(fieldType);
}
// final变量,便于内部类使用
final TypeAdapter<?> typeAdapter = mapped;
return new ReflectiveTypeAdapterFactory.BoundField(name, serialize, deserialize) {
...
...
// ReflectiveTypeAdapter委托的Json读操作会调用到这里
@Override void read(JsonReader reader, Object value)
throws IOException, IllegalAccessException {
// 通过该属性的类型对应的TypeAdapter尝试读取json串
//如果是基础类型,则直接读取,
//如果是复合类型则递归之前的流程
Object fieldValue = typeAdapter.read(reader);
if (fieldValue != null || !isPrimitive) {
field.set(value, fieldValue); //更新filed值
}
}
@Override public boolean writeField(Object value) throws IOException, IllegalAccessException {
if (!serialized) return false;
Object fieldValue = field.get(value);
return fieldValue != value; // avoid recursion for example for Throwable.cause
}
};
}

假设该复合类型中所有的属性的类型是String,则属性所对应的TypeAdapter以及其读写方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
复制代码
public static final TypeAdapter<String> STRING = new TypeAdapter<String>() {
@Override
public String read(JsonReader in) throws IOException {
JsonToken peek = in.peek(); // 获取下一个jsontoken而不消耗它
if (peek == JsonToken.NULL) {
in.nextNull();
return null;
}
/* coerce booleans to strings for backwards compatibility */
if (peek == JsonToken.BOOLEAN) {
return Boolean.toString(in.nextBoolean()); // 如果时布尔值,则转化为String
}
return in.nextString(); // 从json串中获取这个String类型的value并消耗它
}
@Override
public void write(JsonWriter out, String value) throws IOException {
out.value(value); // 不做任何处理直接写入Json串
}
}

到这里,关于Gson的TypeAdapter的原理也就讲得差不多了,回顾一下,因为Type有两类,对应的TypeAdapter也有两类,一类是ReflectiveTypeAdapter,针对复合类型,它的作用是把复合类型拆解成基本类型,另一类是针对基本类型的TypeAdapter,实现对应基本类型的Json串读写工作。而Gson本质上就是按照这两类TypeAdapter来完成Json解析的。

可以说,到这里,我们现在对Gson的基本工作流程有了一个基本的认识。

再一次分析Gson的执行逻辑

事实上,文章到这里结合上面的源码剖析和简化流程图,我们已经可以比较比较真实的分析出Gson的执行逻辑了。

Gson反序列化的日常用法:

1
2
3
4
5
复制代码        
UserInfo userInfo = getUserInfo();
Gson gson = new Gson();
String jsonStr = getJsonData();
UserInfo user = gson.fromJson(jsonStr,UserInfo.class); // 反序列化

gson.fromJson(jsonStr,UserInfo.class)方法内部真实的代码执行流程大致如下:

  • 对jsonStr,UserInfo.class这两个数据进行封装
  • 通过UserInfo.class这个Type来获取它对应的TypeAdapter
  • 拿到对应的TypeAdapter(ReflectiveTypeAdapterFactor),并执行读取json的操作
  • 返回UserInfo这个类型的对象。

我们描述的这个流程和Gson代码真实的执行流程已经没太大的区别了。

TypeAdapter的创建与工厂模式

Gson中除了适配器模式之外最重要的设计模式,可能就是工厂模式吧。因为Gson中众多的TypeAdapter都是通过工厂模式统一创建的:

1
2
3
4
5
复制代码
public interface TypeAdapterFactory {
// 创建TypeAdapter的接口
<T> TypeAdapter<T> create(Gson gson, TypeToken<T> type);
}

我们可以看看ReflectiveTypeAdapterFactory的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码
// ReflectiveTypeAdapterFactory的实现
public final class ReflectiveTypeAdapterFactory implements TypeAdapterFactory {

@Override public <T> TypeAdapter<T> create(Gson gson, final TypeToken<T> type) {
Class<? super T> raw = type.getRawType();
// 只要是Object的子类,就能匹配上
if (!Object.class.isAssignableFrom(raw)) {
// it's a primitive!
return null;
}
ObjectConstructor<T> constructor = constructorConstructor.get(type);
return new Adapter<T>(constructor, getBoundFields(gson, type, raw));
}
}

Gson在其构造方法中,就提前把所有的TypeAdapterFactory放在缓存列表中。

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
复制代码
Gson(final Excluder excluder, final FieldNamingStrategy fieldNamingStrategy,
final Map<Type, InstanceCreator<?>> instanceCreators, boolean serializeNulls,
boolean complexMapKeySerialization, boolean generateNonExecutableGson, boolean htmlSafe,
boolean prettyPrinting, boolean lenient, boolean serializeSpecialFloatingPointValues,
LongSerializationPolicy longSerializationPolicy, String datePattern, int dateStyle,
int timeStyle, List<TypeAdapterFactory> builderFactories,
List<TypeAdapterFactory> builderHierarchyFactories,
List<TypeAdapterFactory> factoriesToBeAdded) {
...
...
...

List<TypeAdapterFactory> factories = new ArrayList<TypeAdapterFactory>();

// built-in type adapters that cannot be overridden
factories.add(TypeAdapters.JSON_ELEMENT_FACTORY);
factories.add(ObjectTypeAdapter.FACTORY);

// the excluder must precede all adapters that handle user-defined types
factories.add(excluder);

// users type adapters
factories.addAll(factoriesToBeAdded);

// type adapters for basic platform types
factories.add(TypeAdapters.STRING_FACTORY);
factories.add(TypeAdapters.INTEGER_FACTORY);
factories.add(TypeAdapters.BOOLEAN_FACTORY);
factories.add(TypeAdapters.BYTE_FACTORY);
factories.add(TypeAdapters.SHORT_FACTORY);
TypeAdapter<Number> longAdapter = longAdapter(longSerializationPolicy);
factories.add(TypeAdapters.newFactory(long.class, Long.class, longAdapter));
factories.add(TypeAdapters.newFactory(double.class, Double.class,
doubleAdapter(serializeSpecialFloatingPointValues)));
factories.add(TypeAdapters.newFactory(float.class, Float.class,
floatAdapter(serializeSpecialFloatingPointValues)));
factories.add(TypeAdapters.NUMBER_FACTORY);
factories.add(TypeAdapters.ATOMIC_INTEGER_FACTORY);
factories.add(TypeAdapters.ATOMIC_BOOLEAN_FACTORY);
factories.add(TypeAdapters.newFactory(AtomicLong.class, atomicLongAdapter(longAdapter)));
factories.add(TypeAdapters.newFactory(AtomicLongArray.class, atomicLongArrayAdapter(longAdapter)));
factories.add(TypeAdapters.ATOMIC_INTEGER_ARRAY_FACTORY);
factories.add(TypeAdapters.CHARACTER_FACTORY);
factories.add(TypeAdapters.STRING_BUILDER_FACTORY);
factories.add(TypeAdapters.STRING_BUFFER_FACTORY);
factories.add(TypeAdapters.newFactory(BigDecimal.class, TypeAdapters.BIG_DECIMAL));
factories.add(TypeAdapters.newFactory(BigInteger.class, TypeAdapters.BIG_INTEGER));
factories.add(TypeAdapters.URL_FACTORY);
factories.add(TypeAdapters.URI_FACTORY);
factories.add(TypeAdapters.UUID_FACTORY);
factories.add(TypeAdapters.CURRENCY_FACTORY);
factories.add(TypeAdapters.LOCALE_FACTORY);
factories.add(TypeAdapters.INET_ADDRESS_FACTORY);
factories.add(TypeAdapters.BIT_SET_FACTORY);
factories.add(DateTypeAdapter.FACTORY);
factories.add(TypeAdapters.CALENDAR_FACTORY);
factories.add(TimeTypeAdapter.FACTORY);
factories.add(SqlDateTypeAdapter.FACTORY);
factories.add(TypeAdapters.TIMESTAMP_FACTORY);
factories.add(ArrayTypeAdapter.FACTORY);
factories.add(TypeAdapters.CLASS_FACTORY);

// type adapters for composite and user-defined types
factories.add(new CollectionTypeAdapterFactory(constructorConstructor));
factories.add(new MapTypeAdapterFactory(constructorConstructor, complexMapKeySerialization));
this.jsonAdapterFactory = new JsonAdapterAnnotationTypeAdapterFactory(constructorConstructor);
factories.add(jsonAdapterFactory);
factories.add(TypeAdapters.ENUM_FACTORY);
// 注意,ReflectiveTypeAdapterFactor是要最后添加的
factories.add(new ReflectiveTypeAdapterFactory(
constructorConstructor, fieldNamingStrategy, excluder, jsonAdapterFactory));

this.factories = Collections.unmodifiableList(factories);
}

这里我们能够看到,ReflectiveTypeAdapterFactor最后被添加进去的,因为这里的添加顺序是有讲究的。我们看看getAdapter(type)方法就能知道。

getAdapter(type)这个方法就是gson通过type寻找到对应的TypeAdapter,这是Gson中非常重要的一个方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码
// 通过Type获取TypeAdapter
public <T> TypeAdapter<T> getAdapter(TypeToken<T> type) {

try {

// 遍历缓存中所有的TypeAdapterFactory,
for (TypeAdapterFactory factory : factories) {
//如果类型匹配,则create()将会返回一个TypeAdapter,否则为nulll
TypeAdapter<T> candidate = factory.create(this, type);
if (candidate != null) {
// candidate不为null,证明找到类型匹配的TypeAdapter.
return candidate;
}
}
throw new IllegalArgumentException("GSON (" + GsonBuildConfig.VERSION + ") cannot handle " + type);
}
}

ReflectiveTypeAdapterFactory之所以在缓存列表的最后一个,就是因为它能匹配几乎任何类型,因此,我们为一个类型遍历时,只能先判断它是不是基本类型,如果都不成功,最后再使用ReflectiveTypeAdapterFactor进行判断。

这就是Gson中用到的工厂模式。

关于代码的难点

我们重新回到getAdapter(type)这个方法,这个方法里面有一些比较难理解的代码

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
复制代码
// 通过Type获取TypeAdapter
public <T> TypeAdapter<T> getAdapter(TypeToken<T> type) {
// typeTokenCache是Gson的一个Map类型的缓存结构
// 0,首先尝试从缓存中获取是否有对应的TypeAdapter
TypeAdapter<?> cached = typeTokenCache.get(type == null ? NULL_KEY_SURROGATE : type);
if (cached != null) {
return (TypeAdapter<T>) cached;
}

// 1,alls 是Gson内部的ThreadLocal变量,用于保存一个Map对象
// map对象也缓存了一种FutureTypeAdapter
Map<TypeToken<?>, FutureTypeAdapter<?>> threadCalls = calls.get();
boolean requiresThreadLocalCleanup = false;
if (threadCalls == null) {
threadCalls = new HashMap<TypeToken<?>, FutureTypeAdapter<?>>();
calls.set(threadCalls);
requiresThreadLocalCleanup = true;
}

//2,如果从ThreadLocal内部的Map中找到缓存,则直接返回
// the key and value type parameters always agree
FutureTypeAdapter<T> ongoingCall = (FutureTypeAdapter<T>) threadCalls.get(type);
if (ongoingCall != null) {
return ongoingCall;
}

try {
创建一个FutureTypeAdapter
FutureTypeAdapter<T> call = new FutureTypeAdapter<T>();
// 缓存
threadCalls.put(type, call);

for (TypeAdapterFactory factory : factories) {
// 遍历所有的TypeAdapterFactory
TypeAdapter<T> candidate = factory.create(this, type);
if (candidate != null) {
// 3, 设置委托的TypeAdapter
call.setDelegate(candidate);
// 缓存到Gson内部的Map中,
typeTokenCache.put(type, candidate);
return candidate;
}
}
//如果遍历都没有找到对应的TypeAdapter,直接抛出异常
throw new IllegalArgumentException("GSON (" + GsonBuildConfig.VERSION + ") cannot handle " + type);
} finally {
// 4,移除threadCalls内部缓存的 FutureTypeAdapter
threadCalls.remove(type);

if (requiresThreadLocalCleanup) {
//ThreadLocal移除该线程环境中的Map
calls.remove();
}
}
}

上述代码比较难以理解的地方我标注了序号,用于后面解释代码

方法里出现了FutureTypeAdapter这个TypeAdapter,似乎很奇怪,因为它没有FutureTypeAdapterFactory这个工厂类,我们先来看看 FutureTypeAdapter的内部构造

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
复制代码
static class FutureTypeAdapter<T> extends TypeAdapter<T> {
private TypeAdapter<T> delegate;

public void setDelegate(TypeAdapter<T> typeAdapter) {
if (delegate != null) {
throw new AssertionError();
}
delegate = typeAdapter;
}

@Override public T read(JsonReader in) throws IOException {
if (delegate == null) {
throw new IllegalStateException();
}
return delegate.read(in);
}

@Override public void write(JsonWriter out, T value) throws IOException {
if (delegate == null) {
throw new IllegalStateException();
}
delegate.write(out, value);
}
}

这是一个明显的委派模式(也可称为代理模式)的包装类。我们都知道委托模式的功能是:隐藏代码具体实现,通过组合的方式同样的功能,避开继承带来的问题。但是在这里使用委派模式似乎并不是基于这些考虑。而是为了避免陷入无限递归导致对栈溢出的崩溃。

为什么这么说呢?我们来举个例子:

1
2
3
4
5
6
7
8
9
10
复制代码// 定义一个帖子的实体
public class Article {
// 表示帖子中链接到其他的帖子
public Article linkedArticle;
.....
.....
.....


}

Article类型中有一个linkedArticle属性,它的类型还是Article,根据我们之前总结的简化流程图:

你会发现这里有一个死循环,或者说无法终结的递归。为了避免这个问题,所以先创建一个代理类,等到递归遇到同样的类型时直接复用返回,避免无限递归。也就是注释2那段代码的用意,在注释3处,再将创建成功的TypeAdapter设置到代理类中。就基本解决这个问题了。

当然说基本解决,是因为还要考虑多线程的环境,所以就出现了ThreadLocal这个线程局部变量,这保证了它只会在单个线程中缓存,而且会在单次Json解析完成后移出缓存。见上文注释1和注释4。这是因为无限递归只会发生在单次Json解析中,而且Gson内部已经有了一个TypeAdapterde 全局缓存(typeTokenCache),见注释0.

潜在的递归循环: gson.getAdapter(type) —> (ReflectiveTypeAdapterFactory)factory.create(this, type) —> getBoundFields() —> createBoundField() —> (Gson)context.getAdapter(fieldType)

关于Gson自定义解析

上文只讲到了Gson自己内部是如何实现Json解析的,其实Gson也提供了一些自定义解析的接口。主要是两种:

  • 自己实现继承TypeAdapter
  • 实现JsonSerializer/JsonDeserializer接口

那么,两者有什么区别呢?

追求效率更高,选第一种,想要操作更简单,实现更灵活,选第二种。

为什么这么说?举个例子,假设我们需要为Article这个JavaBean自定义解析,如果我们选择继承TypeAdapter的话,需要先实现TypeAdapter,然后注册。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
复制代码    // 继承TypeAdapter,实现抽象方法
public class ArticleTypeAdapter extends TypeAdapter<Article>{

@Override
public void write(JsonWriter out, Article value) throws IOException {
// 实现把Article中的实体数据的写入到JsonWriter中,实现序列化
}

@Override
public Article read(JsonReader in) throws IOException {
// 需要创建Article对象
// 把 JsonReader中的json串读出来,并设置到Article对象中
return null;
}
}

...
...
// 注册
Gson mGson = new GsonBuilder()
.registerTypeAdapter(Article.class, new ArticleTypeAdapter<>())//实际上注册到Gson的factories列表中
.create();

这样就实现了自定义的Json解析,这种方式的读写效率很高,但是不太灵活,因为必须要同时实现序列化和反序列化的工作。

而实现JsonSerializer/JsonDeserializer接口这种方式相对更简单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码    //JsonSerializer(json序列话)/JsonDeserializer(反序列化)可按需实现
public class ArticleTypeAdapter implements JsonDeserializer<Article> {
@Override
public Article deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
// 需要创建Article对象
// 并从JsonElement中把封装好的Json数据结构读出来,并设置到Article对象中
return null;
}
}


// 注册
Gson mGson = new GsonBuilder()
.registerTypeAdapter(Article.class, new ArticleTypeAdapter<>())//实际上注册到Gson的factories列表中
.create();

我们可以看到,两者的区别,是后者更加灵活,序列化/返序列化可按需选择,而且它使用了JsonElement对Json数据进行再封装,从而使我们操作Json数据更加简单。不过正是因为使用了 JsonElement这种对Json数据再封装的类,而不是更加原始的JsonReader导致了代码执行效率的降低。

如上图所示,本质上就是多了一个中间层,导致解析效率的降低。不过话说回来,只要不是非常大批量复杂结构的连续解析,这种效率差异我们可以忽略不计,因此日常的开发,大家通过JsonSerializer/JsonDeserializer接口来实现自定义解析是一个相对更好的选择。

本文转载自: 掘金

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

一篇让你明白进程与线程之间的区别与联系

发表于 2019-03-21

前言

欢迎关注公众号:Coder编程
获取最新原创技术文章和相关免费学习资料,随时随地学习技术知识!

本章主要介绍进程与线程的区别与联系相关知识点,也是我们面试过程中,经常会问到的了一个问题。希望通过这篇文章,能让大家理解相关知识点~

涉及面试题:

  • 1.进程与线程之间有什么区别?
  • 2.进程、线程都各有什么特点?
  • 3.进程之间的是怎么进行交互的呢?
  • 4.什么是缓冲区溢出?
  • 5.进程之间如何进行交互?
  • 6.线程之间如何进行交互?

上面的面试题可以看出,其实都是一回事,只是换了一种提问方式,只要我们能掌握核心要点,随便面试官怎么提问,我们都能轻松应对!

  1. 小栗子:

1
2
3
复制代码我们生活中有许许多多关于进程与线程的小栗子,比如:1.我们使用打开一个微信软件,这个时候就开启了一个进程,
当我们在微信里面进行各种操作(查看朋友圈,扫一扫...),这么多的操作就是线程。
所以我们可以说“进程”是包含“线程”的,“线程”是“进程”的一个子集。

来源百度百科:

进程(Process) 是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。 在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。程序是指令、数据及其组织形式的描述,进程是程序的实体。

线程(thread) 是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。

我们简单总结下:

进程:指在系统中正在运行的一个应用程序;程序一旦运行就是进程;进程——资源分配的最小单位。

线程:系统分配处理器时间资源的基本单元,或者说进程之内独立执行的一个单元执行流。线程——程序执行的最小单位。

  1. 深入理解:

在这里插入图片描述

2.1 进程(线程+内存+文件/网络句柄)

我们通过上面的图片进行进一步理解:

“内存”:
我们通常所理解的内存是我们所见到的(2G/4G/8G/16G)物理内存,它为什么会在进程之中呢?
实际上,这里的内存是逻辑内存。指的是内存的寻址空间。每个进程的内存是相互独立的。
否则的话会出现一个问题:我们把指针的值改一改就指向其他进程的内存了,通过这样我们岂不是就可以看到其他进程中”微信”或者是”网上银行”的信息,
这样的话,那我们的微信聊天记录或者是银行账户的信息就都被别人找到了,这是一个很危险的信号!显然这样是不可能的。

“文件/网络句柄”:
它们是所有的进程所共有的,例如打开同一个文件,去抢同一个网络的端口这样的操作是被允许的。

“线程”:
接下来,我们就要介绍一下我们的“线程”有关知识

在这里插入图片描述

2.2 线程(栈+PC+TLS)

2.2.1 栈:

我们通常都是说调用堆栈,其实这里的堆是没有含义的,调用堆栈就是调用栈的意思。
那么我们的栈里面有什么呢?
我们从主线程的入口main函数,会不断的进行函数调用,
每次调用的时候,会把所有的参数和返回地址压入到栈中。

2.2.2 PC:

Program Counter 程序计数器,操作系统真正运行的是一个个的线程,
而我们的进程只是它的一个容器。PC就是指向当前的指令,而这个指令是放在内存中。
每个线程都有一串自己的指针,去指向自己当前所在内存的指针。
计算机绝大部分是存储程序性的,说的就是我们的数据和程序是存储在同一片内存里的
这个内存中既有我们的数据变量又有我们的程序。所以我们的PC指针就是指向我们的内存的。

2.2.2.1 缓冲区溢出

例如我们经常听到一个漏洞:缓冲区溢出
这是什么意思呢?
例如:我们有个地方要输入用户名,本来是用来存数据的地方。
然后黑客把数据输入的特别长。这个长度超出了我们给数据存储的内存区,这时候跑到了
我们给程序分配的一部分内存中。黑客就可以通过这种办法将他所要运行的代码
写入到用户名框中,来植入进来。我们的解决方法就是,用用户名的长度来限制不要超过
用户名的缓冲区的大小来解决。

2.3 TLS:

全称:thread local storage
之前我们看到每个进程都有自己独立的内存,这时候我们想,我们的线程有没有一块独立的内存呢?答案是有的,就是TLS。
可以用来存储我们线程所独有的数据。
可以看到:线程才是我们操作系统所真正去运行的,而进程呢,则是像容器一样他把需要的一些东西放在了一起,而把不需要的东西做了一层隔离,进行隔离开来。

3. 进程之间的是怎么进行交互的呢?

通过TCP/IP的端口来实现

在后续的文章中我们将一一详细介绍!

4. 线程之间又是怎样进行交互?

线程的通信就比较简单,有一大块共享的内存,只要大家的指针是同一个就可以看到各自的内存。

在后续的文章中我们将一一详细介绍!

5.小结:

1.进程要分配一大部分的内存,而线程只需要分配一部分栈就可以了.
2.一个程序至少有一个进程,一个进程至少有一个线程.
3.进程是资源分配的最小单位,线程是程序执行的最小单位。
4.一个线程可以创建和撤销另一个线程,同一个进程中的多个线程之间可以并发执行.

文末

本章节介绍了进程与线程之间的区别与联系,以及其他方面的小知识点,也是面试过程中会出现的内容点。
里面涉及到了许多的小知识点,我们并没有扩展开来讲解,会放在今后的文章中做进一步的阐述。
欢迎关注公众号:Coder编程
获取最新原创技术文章和相关免费学习资料,随时随地学习技术知识!

Github个人目录

Gitee个人目录

欢迎关注并Satr~

微信公众号

本文转载自: 掘金

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

1…876877878…956

开发者博客

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